Compare commits

...

35 Commits

Author SHA1 Message Date
Jason 21e695f68a chore(release): prepare v3.16.3
- Bump version to 3.16.3 (package.json, tauri.conf.json, Cargo.toml, Cargo.lock)
- Add CHANGELOG entry for v3.16.3 (59 commits since v3.16.2)
- Add trilingual release notes (zh / en / ja) under docs/release-notes/
2026-06-15 00:08:21 +08:00
Jason c678374c59 chore(presets): update Volcengine Ark Coding Plan invite link and promo copy
- Replace activity/agentplan links with the new codingplan invite URL
  (ac=MMAP8JTTCAQ2, rc=6J6FV5N2) across all 6 app presets
- Refresh partnerPromotion copy in all 4 locales: two-month 75% off plus
  invite code 6J6FV5N2; correct product name from Agent Plan to Coding Plan
- README: swap the CN-mainland redirect link in EN/DE, refresh the ZH promo
  copy, and add the redirect link to the JA BytePlus entry
2026-06-14 23:03:31 +08:00
Jason d307df92e9 chore(presets): drop isPartner flag from MiniMax presets across all apps
Remove the gold star badge and the partner promotion banner for MiniMax by
deleting the isPartner flag from all 12 MiniMax presets (cn + en) across
claude, claude-desktop, codex, opencode, openclaw, and hermes.

Both the preset-selector star (gated on isPartner) and the API-key promotion
banner (gated on isPartner && partnerPromotionKey) disappear as a result.
The partnerPromotionKey and the minimax_cn/minimax_en i18n copy are kept
dormant so the partnership can be re-enabled with a single line if needed.
MiniMax stays as a regular cn_official provider, keeping its icon and theme.
2026-06-14 21:47:46 +08:00
Jason 5092fe51ce fix(updater): prevent codex self-update from breaking npm platform-dispatch installs
Codex ships as an npm platform-dispatch package (JS launcher @openai/codex + per-platform binary optional deps like @openai/codex-darwin-arm64). The upgrade chain ran `<bin>/codex update || <bin>/npm i -g @openai/codex@latest`, which can leave codex throwing "Missing optional dependency @openai/codex-darwin-arm64":

- `codex update` on an npm install is a bare `npm install -g @openai/codex` that prints success and exits 0 even when the platform binary fails to land, short-circuiting the `||` npm fallback.

- The npm fallback is a no-op when version==latest and only targets the main package, so it can never re-land the missing platform binary.

Fixes: (1) remove codex from prefers_official_update (Posix+Windows) so npm-managed codex no longer runs the false-success `codex update`; (2) add a runnable=false gate in installs_anchored_command emitting an uninstall+install self-heal — the only repair that re-lands the platform binary; (3) narrow by source/real to npm-managed sources (nvm/fnm/mise/homebrew, non-brew-formula) so broken brew-formula/volta/bun installs fall back to their own anchored commands instead of being mis-repaired with npm. Reuses the existing enumerate runnable signal; no new FS probing.
2026-06-14 21:21:45 +08:00
Jason 34001aaffc perf(about): cache tool version probes across tab switches with a TTL
Settings uses Radix tabs, which unmount inactive tab content. Every time
the About tab was reopened, AboutSection remounted and its mount effect
re-ran all six tool version probes (a `--version` subprocess plus an
npm/github/pypi request each) -- wasteful, since versions rarely change
within a session and a manual Refresh already exists.

Add a module-scoped cache (lives for the app session, survives
unmount/remount) with a 10-minute TTL:

- on remount within the TTL the cached results are reused, skipping all
  probes; state is lazily initialized from the cache so the first paint
  shows the values with no loading flash
- a stale cache shows the old values immediately and revalidates per
  tool in the background (stale-while-revalidate)
- the Refresh button forces a re-probe; single-tool refreshes (shell
  change / post-update) update cached data without resetting the TTL
- cold-cache entries start at at=0 (a "not yet fully loaded" sentinel)
  so a partial cache left by a mid-probe tab switch is treated as stale
  and re-fetched rather than served as if complete; the real timestamp
  is only stamped once a full load finishes

The app's own version is cached too, purely to avoid a loading flash on
remount.
2026-06-14 21:21:45 +08:00
Jason 780acfa7de fix(about): decouple app version badge from tool version probes
getVersion() is a local, millisecond call but was awaited together with
loadAllToolVersions() in a single Promise.all, so setVersion and
setIsLoadingVersion only fired after all six tool checks finished. The
version badge under the app icon therefore waited for the whole batch.

Split the mount effect into two independent chains: loadAppVersion sets
the app version (and clears its loading flag) the moment getVersion()
resolves, while loadAllToolVersions runs its own progressive fan-out.
The two no longer block each other.
2026-06-14 21:21:45 +08:00
Jason 6bda3b0131 feat(about): render tool version checks progressively per tool
Fan out the six tool version probes concurrently instead of awaiting
one sequential batch, so each tool card updates the moment its own
check finishes rather than waiting for the slowest one.

- loadAllToolVersions now calls refreshToolVersions per tool via
  Promise.all, reusing the existing per-name merge and per-tool
  loading flags
- card loading derives from per-tool state
  (loadingTools[t] || (isLoadingTools && !resolved)) so a resolved
  card leaves loading independently while the batch is still in flight
- WSL shell/flag selects reuse the per-card loading flag, matching the
  install/update buttons' early-enable behavior

This also drops total wall time from the sum of six sequential probes
to the slowest single probe. get_single_tool_version_impl is already
isolated and read-only (shared HTTP client, spawn_blocking subprocess,
no shared state), so concurrent probes are safe.
2026-06-14 21:21:45 +08:00
Jason 89ff2d58d1 fix(usage): improve usage-query resilience and error surfacing
- useUsageQuery: retry once + keep-last-good — show the last successful
  result for up to 10min when a query fails transiently (network/timeout/
  HTTP 5xx), so a single blip no longer flips the card to red. Deterministic
  failures (auth, empty key, unknown provider, 4xx) surface immediately and
  clear the snapshot so a stale quota can't resurface after credentials change.
- bump native balance/coding-plan/subscription request timeouts 10s -> 15s
  for slow cross-border endpoints.
- coding_plan: return explicit errors ("API key is empty" / "Unknown coding
  plan provider") instead of a blank failure, mirroring balance.
- add unit tests for keep-last-good and transient/deterministic classification.
2026-06-14 21:21:45 +08:00
Jason 9a3d6a4e84 feat(about): add Fable 5 Verified banner to About section
Display the Fable 5 Verified banner to the right of the app name and version block on the settings About page, marking this as a special build. Center the version badge under the app name so the two rows share a common axis.

The app-side asset lives at src/assets/fable5-verified.png (imported via the @ alias and bundled by Vite); the original source banner is kept under assets/partners/banners/ alongside the other partner banners.
2026-06-14 21:21:45 +08:00
Jason fee354d09e fix(health-check): disable connectivity check for official providers, restore 6s degraded threshold
Official providers (Claude/Codex/Gemini/Claude Desktop) use OAuth with an intentionally empty base_url and connect via the client's default endpoint, so cc-switch has no reliable reachability target. Probing a guessed endpoint either hits the wrong target or returns a meaningless green light. Hide the connectivity button for category === 'official'; reachability stays available for copilot/codex-oauth/third-party/custom providers, which is where the old real-request probe produced false negatives. Revert the official base_url fallback added earlier — resolve_base_url is back to extract-or-error.

The 1500ms degraded threshold was too strict; normal ~1s probe latencies showed as 'slow'. Restore the original 6000ms scale (default + config panel + per-provider range). Keep the reachability-appropriate 8s timeout / 1 retry.
2026-06-14 21:21:45 +08:00
Jason a5903d8600 feat(health-check): replace real-LLM probe with HTTP reachability check
The provider panel health check sent a real streaming model request, which many third-party providers block (401/403/WAF), causing false negatives while only stable official endpoints passed. Replace it with a lightweight reachability probe: GET the provider base_url and treat any HTTP response (200/4xx/5xx) as reachable; only DNS/connect/TLS/timeout count as failure. Latency is the probe's TTFB.

Backend (services/stream_check.rs): rewrite ~2200 -> ~350 lines, dropping real-request building, format conversion, auth and API-path resolution while keeping per-app base_url extraction. Defaults: 8s timeout, 1 retry, 1500ms degraded threshold.

Failover invariant: the reachability check must never reset the circuit breaker (reachable != usable; a 403 host is reachable but broken for real traffic). Remove the resetCircuitBreaker call from useStreamCheck; failover failure detection stays driven solely by real proxy traffic (forwarder/circuit_breaker untouched). useResetCircuitBreaker is kept dormant for a future manual-recovery entry.

Open the check to all providers: drop the official/copilot/codex-oauth/third-party gating and the 'sends a real request' confirm dialog. For official providers whose base_url is intentionally empty, fall back to the endpoint the client actually uses (Claude -> api.anthropic.com, Codex -> chatgpt.com/backend-api/codex, Gemini -> generativelanguage). Non-official providers with a missing base_url still error to avoid a false green light. Claude Desktop Official is native 1P mode (talks to claude.ai, cc-switch not in the request path, no reliable endpoint) so its button stays hidden.

Slim StreamCheckConfig and per-provider testConfig to timeout/threshold/retries (drop test model + prompt); sync zh/en/ja/zh-TW. Retain the now-unused anthropic_to_openai/anthropic_to_gemini transform utilities and their test suites.
2026-06-14 21:21:45 +08:00
WangJiati b7ad1c4bf8 调整预设供应商按钮外观与搜索框位置 (#4183)
* 调整预设供应商按钮外观与搜索框位置

1. 调整预设供应商按钮外观,显示默认图标,大小统一;
2. 调整预设供应商搜索框位置。

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

* test(provider): 新增预设按钮外观与 inline 搜索的单元测试

覆盖:
1. 所有预设按钮固定 200px 宽度,视觉对齐一致
2. preset.icon 存在时按钮内渲染 ProviderIcon
3. preset 无 icon 且无 theme.icon 时渲染占位元素保持文字对齐
4. 点击放大镜 inline 切换搜索输入框可见性,ESC 收起并清空

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

* refactor(provider-preset): responsive grid layout and search polish

- Replace fixed-width preset buttons with a responsive CSS grid (auto-fill, 150px min column)
- Add a leading placeholder to the custom button so its label aligns with iconed presets
- Close the inline search box on outside click, restoring the old Popover behavior
- Span the empty-state hint across the full grid row
- Update component tests for the new layout and behaviors

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-06-14 18:18:43 +08:00
thisTom 0c46efe1be fix(macos): prevent duplicate provider terminal sessions (#4156)
* fix(macos): prevent duplicate provider terminals

* fix(macos): keep Ghostty fallback on AppleScript failure

---------

Co-authored-by: thisTom <19346741+thisTom@users.noreply.github.com>
2026-06-14 16:55:23 +08:00
Jason 11572b1337 feat(codex): restore Kimi For Coding preset with thinking on by default
The Kimi For Coding preset was removed (74104946) because the coding
endpoint (api.kimi.com/coding/v1) enforces a User-Agent whitelist that
rejects Codex's default codex-cli UA with 403. The provider-level custom
User-Agent feature now lets users override the UA (to claude-cli/*) under
proxy takeover, so the preset can be restored.

- Re-add the Codex Kimi For Coding preset (openai_chat, kimi-for-coding,
  256K context) in the same position it was removed from.
- Enable thinking mode by default via codexChatReasoning (supportsThinking
  true, thinkingParam "thinking"), mirroring the general Kimi preset since
  both target the same Moonshot model family. The proxy injects
  thinking:{type:enabled} when Codex requests reasoning.
- Restore the trilingual user-manual row to "Kimi / Kimi For Coding".

Note: this preset requires proxy takeover and a whitelisted custom
User-Agent to work; the default codex-cli UA still gets 403.
2026-06-13 23:47:27 +08:00
Jason 3bb17434fb test: align preset tests with Kimi K2.7 and Fable model tiers
Update the Codex chat preset test's Kimi expectation (kimi-k2.6 -> kimi-k2.7-code) after the Kimi K2.7 upgrade, update the Claude Desktop form test for the four-tier (Sonnet/Opus/Fable/Haiku) routes, and reformat UsageDateRangePicker imports (prettier).
2026-06-13 23:16:11 +08:00
Jason efbb52a3fc feat(presets): add GLM 5.1 context window for AtlasCloud Codex preset
Declare the 200000-token context window for zai-org/glm-5.1, matching the other GLM 5.1 preset entries.
2026-06-13 23:16:11 +08:00
Jason b37a9e8f60 chore: remove stray nested preset file accidentally committed in #667
cc-switch-main/src/config/universalProviderPresets.ts was an outdated duplicate of the real preset file, introduced by mistake in #667 and referenced by no code. Remove the orphaned nested directory.
2026-06-13 23:16:11 +08:00
Jason 1992d6be72 feat: add Kimi K2.7 Code model and upgrade official Kimi presets
Add kimi-k2.7-code pricing seed (in $0.95 / out $4.00 / cache-read $0.19
per 1M tokens, 256K context) and point all six official Moonshot Kimi
presets (Claude Code, Codex, Claude Desktop, Hermes, OpenCode, OpenClaw)
at the new model. Rename the version-tagged OpenCode/OpenClaw presets to
"Kimi K2.7 Code" and correct the OpenClaw context window to 262144.

The seed is applied via the idempotent INSERT OR IGNORE path that runs on
every startup, so existing users pick up the new pricing without a schema
migration. Kimi For Coding and Nvidia presets are intentionally untouched.
2026-06-13 22:26:36 +08:00
Jason 276b2572a3 fix(providers): scope preset search to provider names only
The preset search text also included websiteUrl and the shared category label, producing imprecise matches: a single category term matched the whole group, and URL fragments like "com"/"api" matched nearly everything. Restrict the search text to the display name and raw name; category labels are still used for rendering.
2026-06-13 22:15:32 +08:00
Jason cd8252c7d9 fix(ui): raise popover/tooltip z-index above fullscreen panels
PopoverContent and TooltipContent used z-50, below FullScreenPanel's z-[60] opaque overlay. Popovers such as the provider preset search therefore rendered behind the panel and looked unresponsive on click. Bump both to z-[100], matching the select dropdown: above fullscreen panels, below modal dialogs (z-[110]).
2026-06-13 22:15:31 +08:00
Jason 526bb60f5c fix(ui): make Claude Desktop model-mapping placeholders role-consistent
The menu display name and request model columns used mismatched example
brands (DeepSeek vs Kimi), implying a display name maps to an unrelated
request model. Derive both placeholders from the row role so each row
stays brand-consistent, and route the lightweight Haiku tier to a flash
example (deepseek-v4-flash) while other tiers use deepseek-v4-pro.
2026-06-13 20:16:09 +08:00
Jason 4f8a79c273 feat: add Claude Fable 5 model mapping across Claude Code and Desktop
- Wire claude-fable-5 as a fourth tier on both proxy paths, with a
  fable -> opus -> default fallback mirroring the official downgrade.
- Whitelist the fable- prefix for the Desktop 1.12603.1+ validator.
- Clarify fallbackModelHint (zh/en/ja/zh-TW): a blank tier on
  third-party endpoints forwards the literal model name and 404s.

Refs #3980, #4026, #4049.
2026-06-13 19:12:34 +08:00
Jason 6e519a7496 fix(usage): compact toolbar controls and unify visual style
- Reduce all four filter controls to w-[100px] h-9
- Add ChevronDown icon to date picker trigger for consistency
- Suppress focus border highlight on select triggers after close
2026-06-12 23:48:41 +08:00
Jason a95b22dd79 fix(ui): keep ToggleRow icon from shrinking next to long descriptions 2026-06-12 23:35:07 +08:00
Jason eab6bfd20c feat(codex): add opt-in migration and ledger-based restore for unified session history
- Enable dialog gains a checkbox (default off) to migrate existing
  official sessions from the built-in "openai" bucket into the shared
  "custom" bucket, with per-generation backups; failed migrations retry
  at startup
- Disable dialog offers a precise restore driven by the backup ledger:
  only sessions recorded as "openai" in backups are flipped back, and
  sessions created while the toggle was on are never touched
- Completion marker and backup generations are bound to the canonical
  Codex config dir; migrate/restore serialize on an op lock and the
  marker is written conditionally inside the settings write lock
- save_settings rolls back the toggle and fails the save when the live
  rewrite fails; migration additionally requires the live config to
  actually route to the shared bucket (skips with live_not_unified so
  refused injection or proxy takeover can't split history)
- Restore refuses to run while the toggle is (re-)enabled and reports
  nothing_to_restore instead of a zero-count success; local migration
  markers are now backend-owned in merge_settings_for_save so stale
  frontend payloads can't resurrect them
- Settings autosave reverts optimistic form state on failure so a
  failed toggle change can't be replayed by an unrelated save
- ConfirmDialog supports an optional checkbox; all four locales updated
2026-06-12 23:35:01 +08:00
Jason 948d762792 feat(codex): add unified session history toggle for official providers
Codex buckets resume history by the model_provider id recorded in each
session: official runs (no key, built-in "openai") and cc-switch
third-party runs (shared "custom") are mutually invisible in the resume
picker. Add an opt-in setting that runs official providers under the
shared "custom" id so future official sessions land in the same history
bucket as third-party ones. Forward-only by design: existing sessions
are not migrated.

When enabled, official live config.toml gets model_provider = "custom"
plus a [model_providers.custom] entry that mirrors the built-in openai
provider (requires_openai_auth routes auth to the ChatGPT login in
auth.json, name "OpenAI" keeps is_openai() feature gates, explicit
supports_websockets/wire_api restore built-in defaults). auth.json is
untouched.

Key invariants:
- Injection lives only in the live config: switch-away backfill strips
  the exact injected shape, so stored provider configs stay clean and
  turning the toggle off fully reverts on the next write.
- Toggle changes apply immediately via a takeover-aware reapply: when
  the proxy owns the live config (backup/placeholder present), only the
  live backup is updated, mirroring the provider-switch path.
- The takeover backup path runs the same injection so a takeover
  release restores a config that still carries the unified routing.
- Injection refuses to activate a foreign [model_providers.custom]
  table (e.g. stale entry with a third-party base_url) to avoid routing
  ChatGPT OAuth traffic to an unknown backend.

The toggle lives under Settings → Codex App Enhancements; the
description warns that resuming old sessions across providers may fail
because encrypted_content reasoning only decrypts on the backend that
created it (upstream treats cross-provider resume as unsupported).
2026-06-12 10:40:51 +08:00
Jason 4f355970e1 chore(presets): remove LemonData provider and demote SudoCode to regular provider
- LemonData: delete the provider preset from all apps (claude, claude-desktop,
  codex, gemini, hermes, opencode, openclaw), remove partner promotion copy
  (zh/en/ja/zh-TW), icon index/metadata entries, sponsor ads in all READMEs,
  and the logo/icon PNG assets.
- SudoCode: drop the isPartner flag and partnerPromotionKey across all app
  presets and remove the now-orphaned partner promotion copy; it stays as a
  regular third_party provider, keeping its icon.
2026-06-11 23:15:11 +08:00
Jason 22ecd2d611 feat(usage): turn refresh interval into a select and align control widths
Replace the click-to-cycle refresh button with a Select matching the
source/model filters. The "off" option now shows a localized label
(zh/en/ja/zh-TW) instead of the cryptic "--", and changing the interval
still invalidates all usage queries for an immediate refresh.

Align the top-bar controls into two width groups: source and model
selects at 120px, refresh select and date range trigger at 150px. The
date range button moves from auto width to fixed, so its long custom
range label gets truncate + hover title, and the calendar icon is
shrink-proofed.
2026-06-11 22:42:26 +08:00
Jason a75f479576 feat(usage): lift provider/model filters to dashboard-wide scope
The provider/model filters only lived inside the request-log table, so
there was no way to see "how much did app X spend on source Y" across
the whole dashboard. Promote them to the top bar next to the app
filter, applying globally to the hero summary, trend chart, request
logs, and both stats tabs.

Backend: the five stats queries (summary, summary-by-app, trends,
provider stats, model stats) accept optional provider_name/model
filters, applied to both the detail and daily-rollup branches (the
rollup PK already carries provider_id/model/pricing_model). Sources
match by exact display name via provider_name_coalesce, so session
placeholder rows like "Claude (Session)" are selectable; models match
by effective pricing model (pricing_model falling back to model), the
same grouping key the model-stats tab uses. Request-log filtering
switches from LIKE to these exact semantics.

Frontend: two truncating dropdowns list only sources/models that have
data in the current range, with the model list cascading from the
selected source. Dynamic option values are prefix-encoded so a source
literally named "all" cannot collide with the sentinel, query keys
fall back to null instead of "all", and the option queries follow the
dashboard refresh interval (otherwise their default 30s polling drags
same-key stats queries along even with refresh disabled). The
request-log filter bar keeps only the log-specific status-code select.
Labels read "sources" rather than "providers" because direct-connect
session buckets sit alongside real providers. i18n updated across
zh/en/ja/zh-TW.
2026-06-11 22:42:26 +08:00
Jason c701068f0c feat(usage): replace app filter text labels with app icons
The text tabs (All / Claude Code / Codex / Gemini / OpenCode) wrapped
awkwardly in narrow windows. Render app icons via ProviderIcon instead,
mirroring AppSwitcher's icon mapping (codex reuses the openai icon),
with a LayoutGrid icon for "all". Localized labels are kept as
title/aria-label on each button.
2026-06-11 22:42:26 +08:00
Jason c7efa77ad9 refactor(usage): fold claude-desktop into claude in the dashboard
The Desktop gateway's proxy traffic is still recorded under its own
app_type for route-takeover billing audit (the request detail panel
shows the real value), but the dashboard now folds it into `claude`
for display. A standalone "Claude Desktop" bucket only ever showed a
partial number: Desktop chat usage never passes through the proxy and
has no scannable local source, while its Code-tab sessions are just the
embedded Claude Code runtime writing into the shared ~/.claude/projects
tree — so a separate bucket misled users into reading it as Desktop's
full usage.

Backend: new `folded_app_type_sql` helper wraps the app_type column in
every dashboard read path (10 filter sites + the by-app projection) so
`= 'claude'` also matches claude-desktop and GROUP BY merges the two,
without changing bound-param counts. Dedup matching and provider-limit
checks keep exact comparison; get_request_logs folds only the WHERE
filter and keeps the raw app_type in its row projection.

Frontend: drop claude-desktop from the dashboard AppType/KNOWN_APP_TYPES
filter list, the UsageHero theme, and the now-dead appFilter locale key
in all four languages (the managed-app apps.claude-desktop label stays).

Adds test_claude_desktop_folds_into_claude_for_display.
2026-06-11 22:42:26 +08:00
codeasier 2d64d8c619 fix(proxy): preserve Codex OAuth auth token on takeover (#3789)
* fix(proxy): preserve Codex OAuth auth token on takeover

* style(proxy): format Codex OAuth takeover fix

* fix(proxy): unconditionally inject AUTH_TOKEN placeholder for codex takeover

The preserve-if-exists condition left #3784 unfixed on three paths:
hot-switch passes the provider's settings (presets carry no
ANTHROPIC_AUTH_TOKEN key), fresh installs never had the key, and live
configs already stripped by older releases stay stripped.

- Fold the bool parameter into the policy enum as
  ManagedAccount { keep_auth_token } so every construction site
  declares intent
- Decide via !is_github_copilot() within the managed branch so
  URL-only codex providers (no provider_type meta) are covered,
  matching the predicate family used for policy selection
- Inject the placeholder unconditionally instead of only when the
  key pre-exists; Copilot behavior is unchanged (API_KEY only)
- Pin the previously uncovered cases with tests: codex without a
  pre-existing key, URL-only codex, and Copilot removing a stale
  AUTH_TOKEN

---------

Co-authored-by: codeasier <liuyekang@huawei.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-06-11 22:21:42 +08:00
osscv d70e3828fe Add claude-mythos-5 model to schema (#4077)
Insert a new 'claude-mythos-5' model tuple into src-tauri/src/database/schema.rs. The tuple ("claude-mythos-5", "Claude Mythos 5", "10", "50", "1.00", "12.50") is added to the models list (placed before the Claude 4.8 series) to register the Mythos 5 model with the Database schema.
2026-06-11 21:53:11 +08:00
Jason Young 7bb59fa5a6 fix(proxy): harden takeover-residue recovery across config-dir switches (#4076)
Changing app_config_dir relocates the SQLite database, so a restart
triggered while proxy takeover is active used to strand the live
configs: the new instance reads a fresh DB with no live backups, the
first-run import then persisted the PROXY_MANAGED placeholder as the
`default` provider, and the no-backup recovery path wrote that
placeholder right back to the live files — leaving Claude/Codex/Gemini
pointed at a dead local proxy with no automatic way out.

Three orthogonal fixes, defense in depth:

- restart_app now awaits cleanup_before_exit() before app.restart().
  Since #4069 the ExitRequested handler intentionally defers restart
  requests to Tauri's default re-exec without custom cleanup, which is
  correct for same-DB restarts but not for this command's dir-change
  use case: only the old instance holds the backups needed to restore
  the taken-over live files, so it must restore them while its event
  loop is still alive.
- import_default_config refuses to import a live config that is under
  proxy takeover (placeholder detected), instead of persisting it as
  the current provider.
- restore_live_from_ssot_for_app validates that the current provider's
  settings_config does not itself contain takeover placeholders before
  writing it back; polluted SSOT now falls through to the placeholder
  cleanup fallback.

Regression tests cover the import guard and the no-backup recovery
path (the latter fails before this change by writing PROXY_MANAGED
back to live).
2026-06-11 21:32:24 +08:00
Jason Young 4f3e85fcd1 fix(updater): drive download/install/restart from backend to avoid hang (#4074)
* fix(updater): drive download/install/restart from backend to avoid hang

The 3.16/3.16.1 update flow was frontend-driven: downloadAndInstall()
then relaunchApp(). relaunch() routed through AppHandle::restart(), and
the old WebView had to keep running JS after the .app bundle was already
swapped — an unstable window that could hang the update or leave the old
version running until a manual restart.

Move the whole download -> install -> cleanup -> restart chain into a new
backend command install_update_and_restart, so it no longer depends on the
old WebView running JS after the bundle is swapped.

Platform-aware install ordering (install() behaves differently per OS):
- Windows: install() launches the external installer and exits the process
  internally, so cleanup + single-instance destroy must run before install.
  Surface a recovery hint on failure since the proxy may already be stopped.
- macOS/Linux: install() returns, so install first then cleanup — an install
  failure no longer wrongly stops the proxy / reverts takeover.

Eliminate the restart vs single-instance race: restart_process() destroys
the single-instance lock (remove socket on macOS, ReleaseMutex on Windows)
before tauri::process::restart(), so the freshly spawned process can't
connect to the old listener and exit itself.

Also remove the now-dead frontend update plumbing (relaunchApp,
UpdateHandle, mapUpdateHandle) and surface backend errors in the toast.

Adapted from the original af4271f4 while rebasing onto #4069: the
ExitRequested handler changes were dropped entirely — the classifier from
#4069 already routes RESTART_EXIT_CODE to Tauri's default restart flow,
and the original should_restart branch (prevent_exit + async cleanup)
would have reintroduced the window-state deadlock that #4069 fixed.
install_update_and_restart bypasses ExitRequested entirely, so the two
fixes compose cleanly.

* fix(updater): clear tray icon on the direct-restart update path

restart_process re-execs via tauri::process::restart (spawn + exit(0)),
which skips Tauri's internal cleanup_before_exit and all RunEvent::Exit
plugin hooks. Window state, proxy/live restore and the single-instance
lock were already compensated explicitly; the tray icon was not.

On macOS/Linux the OS drops the status item when the process dies, so
the gap there was cosmetic at most. The real residue risk is the
Windows branch, which never reaches restart_process at all:
update.install() exits the process inside the updater plugin
(std::process::exit(0)), bypassing TrayIcon::drop — no NIM_DELETE is
sent and a stale icon lingers in the shell until hovered, the same
failure remove_tray_icon_before_exit was originally added for on the
quit path.

Call remove_tray_icon_before_exit (set_visible(false), proxied to the
main thread via run_item_main_thread) in restart_process and before
the Windows install. Deliberately not AppHandle::cleanup_before_exit():
it drops tray icons on the calling thread, which is not safe off the
main thread on macOS (NSStatusItem).
2026-06-11 21:30:02 +08:00
100 changed files with 6334 additions and 3695 deletions
+57
View File
@@ -5,6 +5,63 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.16.3] - 2026-06-14
Development since v3.16.2 focuses on getting usage accounting right end-to-end — billing route-takeover and format-conversion traffic by the real upstream model and pricing basis (schema v11), counting Claude Code Workflow sub-agent sessions, folding Claude Desktop into the Claude view, refreshing the model pricing seed, and reworking the usage dashboard with global provider/model filters, brand-icon toolbars, and far more resilient quota queries — while hardening the proxy (mislabeled SSE bodies, Codex image rectification, OAuth token and takeover-residue recovery, Hermes duplicate YAML keys), reworking provider configuration (a custom User-Agent override, a unified Codex advanced section, searchable preset selection, a Fable 5 tier, and refreshed Kimi/Unity2/Volcengine/MiniMax presets), and smoothing the update, About-panel, and provider-health experiences.
**Stats**: 59 commits | 130 files changed | +10,223 insertions | -4,232 deletions
### Added
- **Custom User-Agent Override**: Provider configs can now set a custom User-Agent that the proxy applies consistently across request forwarding, stream check, and model listing (`GET /v1/models`), so coding-plan upstreams that gate on UA no longer fail detection or return 403 while the proxy itself works. The Claude and Codex forms expose it in advanced settings with a curated presets dropdown (Claude Code / Kilo Code families that pass UA whitelists) and live non-blocking validation; stale custom UAs are dropped when switching to an official preset to avoid silently altering headers (#3671).
- **Unified Codex Session History**: Official Codex sessions can now share a single resume-history bucket with cc-switch third-party sessions via an opt-in toggle under Settings → Codex App Enhancements, so the resume picker no longer hides them from each other. When enabled, the live `config.toml` routes official runs through a shared `custom` model_provider that mirrors the built-in OpenAI provider (`auth.json` is untouched); the toggle is forward-only by default but the enable dialog offers a checkbox to migrate existing official sessions (with per-generation backups), and the disable dialog offers a precise ledger-based restore that only reverts sessions originally recorded as `openai` while leaving sessions created during the toggle untouched.
- **Dashboard-Wide Provider/Model Filters**: The provider and model filters move from inside the request-log table up to the top bar, applying globally to the hero summary, trend chart, request logs, and both stats tabs so you can scope the whole dashboard to a given source and model. Sources match by exact display name (so session placeholder rows like "Claude (Session)" are selectable) and models match by effective pricing model, with the model dropdown cascading from the selected source and both lists showing only options that have data in the current range.
- **Refreshed Model Pricing Seed**: Added pricing for 9 models including Claude Fable 5, Grok 4.3, Mistral Medium 3.5 / Small 4, and Qwen 3.7 Max/Plus, and corrected 28 existing prices against current official vendor list pricing (GLM, Grok, MiMo, Doubao, Kimi, MiniMax, Mistral, Qwen) so usage cost estimates are accurate. Each change updates the seed for fresh installs and adds a guarded repair for existing databases without clobbering user-edited rows.
- **Claude Fable 5 Model Tier**: Provider forms now expose `claude-fable-5` as a fourth model-mapping tier on both the Claude Code and Claude Desktop proxy paths, with a fable → opus → default fallback mirroring the official downgrade and the `fable-` prefix whitelisted for the Desktop 1.12603.1+ validator. A clarified four-language fallback hint warns that leaving a tier blank on third-party endpoints forwards the literal model name and 404s (#3980, #4026, #4049).
- **Unity2.ai Partner Provider**: Added Unity2.ai, an AI API relay partner, as a preset across all seven managed apps (Claude Code, Codex, Gemini, OpenCode, OpenClaw, Claude Desktop, Hermes), each carrying the referral signup link and partner promotion copy in all four locales. Codex uses the bare base URL (the gateway exposes `/responses` at root) while OpenCode / OpenClaw / Hermes use the `/v1` chat-completions endpoint with `gpt-5.5`.
- **Kimi K2.7 Code Model**: Added the `kimi-k2.7-code` model (in $0.95 / out $4.00 / cache-read $0.19 per 1M tokens, 256K context) and pointed all six official Moonshot Kimi presets (Claude Code, Codex, Claude Desktop, Hermes, OpenCode, OpenClaw) at it, renaming the OpenCode / OpenClaw presets to "Kimi K2.7 Code". The pricing seed applies on startup via the idempotent insert path, so existing users pick up the new pricing without a migration.
- **Codex "Kimi For Coding" Preset Restored**: Re-added the Codex "Kimi For Coding" preset (`openai_chat`, `kimi-for-coding`, 256K context) with thinking mode enabled by default; it was previously removed because the coding endpoint rejects Codex's default `codex-cli` User-Agent with 403. It now works via proxy takeover combined with the custom User-Agent override (set to a whitelisted UA such as `claude-cli/*`).
- **Pricing-Model Audit in Request Detail**: The request detail panel now shows the requested model and the pricing model when they differ from the response model, making route-takeover bills auditable directly from the usage UI.
- **Preset Provider Search & Sorting**: The provider preset selector gains a searchable, sorted list with an inline search box (toggled via a magnifier icon, dismissed on ESC or outside click). Buttons use a responsive grid with consistent sizing and default icons, and search matches only provider display/raw names so URL fragments and shared category labels no longer produce noisy matches (#3975, #4183).
- **Claude Mythos 5 Pricing**: Registered the `claude-mythos-5` model in the bundled model/pricing table (in $10 / out $50 per 1M tokens, cache read $1.00, cache write $12.50), so usage metering prices and displays it correctly (#4077).
- **Fable 5 Verified Banner**: The Settings About page now displays a Fable 5 Verified banner beside the app name and version, marking this as a special build, with the version badge centered under the app name.
### Changed
- **Claude Desktop Usage Folded Into Claude**: The dashboard no longer shows a standalone "Claude Desktop" bucket, which only ever displayed a partial number (Desktop chat usage never passes through the proxy and its Code-tab sessions write into the shared `~/.claude/projects` tree). Desktop proxy traffic is now folded into the `claude` view for display while still recorded under its own `app_type` for route-takeover billing audit, with the real value visible in the request detail panel.
- **Lightweight Provider Health Check**: The provider health check no longer sends a real streaming model request (which many third-party providers blocked with 401/403/WAF, causing false negatives); it now performs a lightweight HTTP reachability probe of the provider `base_url`, treating any HTTP response as reachable and counting only DNS/connect/TLS/timeout as failure. The connectivity button is hidden for official providers (which use OAuth with an empty base URL and no reliable reachability target), the real-request confirmation dialog and test model/prompt fields are removed, and the degraded-latency threshold is set to 6s with an 8s timeout. The reachability check never resets the circuit breaker, so failover detection stays driven solely by real proxy traffic.
- **Codex Advanced Options Section**: The Codex provider form now folds local routing, model mapping, reasoning overrides, and custom User-Agent into a single collapsible advanced section mirroring the Claude form (auto-expanding when a UA is set or local routing is on). Custom User-Agent is now also configurable for native Responses providers, where it was previously reachable only with `openai_chat` routing enabled.
- **Usage Toolbar Refresh and Layout**: The app filter now renders brand icons (via ProviderIcon, with a grid icon for "All") instead of text tabs that wrapped awkwardly in narrow windows, and the usage hero shows the selected app's brand icon with Codex recolored to a neutral gray matching OpenAI's monochrome branding. The click-to-cycle refresh button becomes a Select with a localized "off" label, and the top-bar controls are compacted and aligned into consistent width groups with truncated long date-range labels.
- **Faster About Panel Loading**: The Settings About panel now loads progressively: the app version badge appears the instant it resolves instead of waiting for tool probes, each tool card updates the moment its own version check finishes (probes run concurrently rather than sequentially), and results are cached for the app session with a 10-minute TTL so reopening the About tab reuses cached values and revalidates stale ones in the background instead of re-probing all six tools every time.
- **Volcengine Ark Coding Plan Promo**: Updated the Volcengine Ark preset across all six apps with the new Coding Plan invite link (replacing the old Agent Plan / activity links) and refreshed the partner promotion copy in all four locales (two-month 75% off plus invite code 6J6FV5N2), correcting the product name from Agent Plan to Coding Plan.
- **MiniMax Demoted to Regular Provider**: Removed the gold partner star badge and the API-key promotion banner for MiniMax by dropping the `isPartner` flag from all its presets; it stays as a regular `cn_official` provider keeping its icon and theme. The promotion copy is kept dormant so the partnership can be re-enabled with a single line.
- **LemonData Removed, SudoCode Demoted**: Removed the LemonData provider preset entirely from all apps along with its promotion copy, icons, and sponsor listings, and demoted SudoCode from a partner to a regular `third_party` provider by dropping its `isPartner` flag and promotion copy (it keeps its icon).
- **AtlasCloud Codex GLM 5.1 Context Window**: Declared the 200,000-token context window for the `zai-org/glm-5.1` model in the AtlasCloud Codex preset, matching the other GLM 5.1 preset entries.
### Fixed
- **Route-Takeover Traffic Billed by the Real Upstream Model**: When a request was routed to a different upstream (env model mapping, Claude Desktop routes, Copilot normalization, Codex chat override), the proxy used to attribute and price usage by whatever model the upstream echoed back, recording kimi/glm tokens as `claude-*` and overstating cost roughly 525×. The forwarder now captures the real outbound model, attributes usage by upstream-echo then outbound then client alias, persists the actual pricing basis on every row (schema v11), and keeps that basis through cost backfill and 30-day rollup pruning; Claude Desktop traffic is now logged under its own `app_type` so its pricing overrides apply.
- **Usage Metering on Format-Conversion Proxy Paths**: Audited and fixed token/cache accounting across the proxy's format-conversion paths (Chat, Responses, and Gemini converted to Anthropic). The proxy now records the actually returned model, injects `stream_options.include_usage` so OpenAI-compatible upstreams emit usage in streaming, excludes `cache_read` and `cache_creation` from input on Claude←OpenAI paths to stop double-billing cache tokens, subtracts cached Gemini prompt tokens, still records fully-cached requests, and skips synthetic all-zero usage that previously inflated request counts (#2774).
- **In-App Update No Longer Hangs on Restart**: Installing an update from within the app no longer freezes on the "restarting" screen, leaving the new version installed but requiring a manual force-quit. The download-install-restart chain now runs entirely in the backend (a new `install_update_and_restart` command) with platform-aware install ordering and single-instance-lock teardown before re-exec, instead of depending on the old WebView to keep running JS after the app bundle was already swapped; exit requests are also classified so restart requests fall through to Tauri's default flow rather than deadlocking on the window-state plugin mutex (#4069, #4074).
- **Codex Upgrade No Longer Breaks the Install**: Upgrading Codex from the Settings "About" tab no longer leaves it throwing "Missing optional dependency @openai/codex-…" errors. The upgrade chain previously ran `codex update` first, which on an npm install is a bare reinstall that reports success even when the per-platform binary fails to land; Codex is now removed from the self-update-first path and a runnable check triggers an uninstall+reinstall self-heal (scoped to npm-managed installs) that actually re-lands the missing platform binary.
- **Codex OAuth Auth Token Preserved on Proxy Takeover**: Enabling proxy takeover for a Codex provider no longer strips the `ANTHROPIC_AUTH_TOKEN` placeholder, which previously broke Claude Code's login on hot-switches, fresh installs, and configs already stripped by older releases. The placeholder is now injected unconditionally for managed (non-Copilot) Codex providers, including URL-only ones; GitHub Copilot behavior (API_KEY only) is unchanged (#3789, #3784).
- **Takeover-Residue Recovery Across Config-Dir Switches**: Restarting the app after changing the config directory while proxy takeover is active no longer leaves Claude/Codex/Gemini pointed at a dead local proxy. The old instance now restores the taken-over live files before restarting, the first-run import refuses to persist a takeover placeholder as a provider, and SSOT restore validates that the current provider's config is free of placeholders before writing it back (#4076).
- **Mislabeled SSE Bodies in Format-Transform Fallback**: Requests routed through Claude/Codex format conversion no longer fail with an opaque 422 "Failed to parse upstream response" when a MaaS gateway force-streams a `stream:false` request and returns an SSE body under a non-SSE Content-Type. The proxy now sniffs for SSE on parse failure, aggregates the chunks into a single JSON, and runs the existing converter so clients still get a valid non-stream response; remaining parse failures are enriched with content-type, encoding, and body-snippet diagnostics, and deflate decoding now tries zlib before raw (#2234).
- **Duplicate YAML Keys in Hermes Config**: Hermes config writes no longer accumulate duplicate top-level keys (e.g. `mcp_servers`) that caused "Failed to parse Hermes config as YAML: duplicate entry with key" errors. Section replacement now strips all stale occurrences from the remainder instead of degrading into appends, the dedup safety net handles both LF and CRLF line endings, and healing keeps the last (newest) occurrence to match Hermes's own last-wins PyYAML semantics (#3267, #3633, #2973, #2529, #3310, #3762).
- **Usage Query Resilience and Error Clarity**: Usage cards no longer flip to red on a single transient blip: queries now retry once and keep showing the last successful result for up to 10 minutes on network/timeout/5xx failures, while deterministic failures (auth, empty key, unknown provider, 4xx) surface immediately and clear the snapshot so a stale quota can't resurface after credentials change. Native balance/coding-plan/subscription timeouts were raised from 10s to 15s for slow cross-border endpoints, and coding-plan now returns explicit "API key is empty" / "Unknown coding plan provider" errors instead of a blank failure.
- **Usage Script Provider Credential Resolution**: Custom JS-script usage queries resolved `{{apiKey}}` / `{{baseUrl}}` by guessing env fields only, so providers that store credentials elsewhere (e.g. Codex's `auth.OPENAI_API_KEY` plus `config.toml` base_url) always got empty values and failed despite being fully configured. Script queries and the test/preview now reuse the same per-app credential resolver as the native balance path, with explicit non-empty script values still taking precedence (#1479).
- **Claude Code Workflow Sub-Agent Usage Counted**: Local (no-proxy) session-log usage accounting missed Claude Code Workflow sub-agent traffic, under-counting overall usage by roughly 4.1% (concentrated in workflow/subagent transcripts). The scanner now descends into the deeper `subagents/workflows/wf_*/` transcript directories, and the parser no longer drops billable assistant messages that lack a `stop_reason` but already incurred input/cache token cost; dedup is unchanged so no usage is double-counted.
- **Codex Image Rectifier for /responses Text-Only Upstreams**: Codex `/responses` requests carrying images and routed to text-only OpenAI-chat models (e.g. DeepSeek `deepseek-v4-flash`) no longer fail with HTTP 400 "unknown variant `image_url`". The media rectifier now also covers the Codex adapter, scanning the responses `input` for `input_image` blocks so it can proactively strip images for known text-only models and reactively retry with images replaced on upstream image-unsupported errors.
- **Zhipu Coding-Plan Quota Window Mislabeling**: The Zhipu coding-plan view no longer swaps the 5-hour and weekly quota buckets in the final hours of each weekly cycle. The two windows are now classified by the explicit `unit` field (3 = 5-hour, 6 = weekly) instead of by sorting reset-time ascending, which mislabeled them exactly when users check their weekly quota most; the old reset-time heuristic remains as a fallback (#3036).
- **Duplicate Provider Terminal Sessions on macOS**: Launching a provider terminal on macOS no longer opens an extra empty window alongside the command session; Terminal.app uses `launch` (not `activate`) on cold start and Ghostty uses an initial-command so a single session opens, with a fallback retained if the AppleScript path fails (#4156).
- **Claude Desktop Model-Mapping Placeholders**: The Claude Desktop model-mapping form previously showed mismatched example brands across the menu display name and request model columns (DeepSeek vs Kimi), implying a display name maps to an unrelated model. Both placeholders are now derived from each row's role so they stay brand-consistent, with the lightweight Haiku tier using a flash example.
- **Popovers Behind Fullscreen Panels**: Popovers and tooltips such as the provider preset search no longer render behind fullscreen panels and appear unresponsive on click; their z-index is raised above the fullscreen overlay while staying below modal dialogs.
- **ToggleRow Icon Shrinking**: Toggle row icons no longer shrink or distort when paired with long descriptions, keeping the icon at a fixed size next to multi-line text.
### Docs
- **Release Notes Contributor Mentions**: Restored contributor mentions in the v3.16.1 and v3.16.2 release notes across all three locales.
## [3.16.2] - 2026-06-07
Development since v3.16.1 focuses on broadening data portability and usage observability — S3-compatible cloud sync, OpenCode session usage import, and an opt-in official-subscription quota template — while hardening Codex Chat Completions routing (stream truncation, `tool_choice` / custom-tool / reasoning-token edge cases, file and audio attachments, and a Codex CLI models endpoint), strengthening proxy robustness (ephemeral ports, takeover/placeholder restore, system-message normalization, clearer upstream errors, and a text-only image fallback), fixing coding-plan quota lookups (Zhipu, MiniMax) and several Windows/macOS issues, adding the CherryIN and ZenMux providers, and refreshing the user manual.
+1 -6
View File
@@ -63,7 +63,7 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
<tr>
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a fullmodal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, longtask execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, endtoend complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">this link</a> to get 500,000 tokens of free inference quota per model.<a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a fullmodal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, longtask execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, endtoend complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">this link</a> to get 500,000 tokens of free inference quota per model.<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
</tr>
<tr>
@@ -106,11 +106,6 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
<td>Thanks to Micu API for sponsoring this project! Micu API is a global LLM relay service provider dedicated to delivering the best cost-performance ratio with high stability. Backed by a registered enterprise for core assurance, eliminating any risk of service discontinuation, with fast official invoicing support! We champion "zero cost to try": top up from as low as ¥1 with no minimum, and get fee-free refunds anytime! Micu API offers an exclusive deal for CC Switch users: register via <a href="https://www.micuapi.ai/register?aff=aOYQ">this link</a> and enter promo code "ccswitch" when topping up to enjoy a <strong>10% discount</strong>!</td>
</tr>
<tr>
<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 3070% 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>
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
<td>Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click <a href="https://ctok.ai">here</a> to register!</td>
+1 -6
View File
@@ -63,7 +63,7 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
<tr>
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
<td>Danke an Dola seed für die Unterstützung dieses Projekts! Dola Seed 2.0 ist ein voll-modales Allzweck-Großmodell, das von ByteDance eigenständig für den globalen Markt entwickelt wurde. Aufbauend auf einer einheitlichen multimodalen Architektur unterstützt es das gemeinsame Verstehen und Generieren von Text, Bildern, Audio und Video. Es ermöglicht von Haus aus die Zusammenarbeit von Agenten und verfügt über starke Fähigkeiten in den Bereichen Schlussfolgern, Ausführung langer Aufgaben, Werkzeugintegration und Programmierung. Es ist breit einsetzbar — etwa für intelligente Cockpits, persönliche Assistenten, Bildung, Kundensupport, Marketing, Einzelhandel und weitere Szenarien. Es überzeugt bei multimodaler Wahrnehmung, der Ende-zu-Ende-Bewältigung komplexer Aufgaben, stabiler Interaktion und Datensicherheit und ist über die ModelArk-Plattform einfach zugänglich und bereitstellbar. Registrieren Sie sich über <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">diesen Link</a> und erhalten Sie pro Modell ein kostenloses Inferenzkontingent von 500.000 Token.<a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
<td>Danke an Dola seed für die Unterstützung dieses Projekts! Dola Seed 2.0 ist ein voll-modales Allzweck-Großmodell, das von ByteDance eigenständig für den globalen Markt entwickelt wurde. Aufbauend auf einer einheitlichen multimodalen Architektur unterstützt es das gemeinsame Verstehen und Generieren von Text, Bildern, Audio und Video. Es ermöglicht von Haus aus die Zusammenarbeit von Agenten und verfügt über starke Fähigkeiten in den Bereichen Schlussfolgern, Ausführung langer Aufgaben, Werkzeugintegration und Programmierung. Es ist breit einsetzbar — etwa für intelligente Cockpits, persönliche Assistenten, Bildung, Kundensupport, Marketing, Einzelhandel und weitere Szenarien. Es überzeugt bei multimodaler Wahrnehmung, der Ende-zu-Ende-Bewältigung komplexer Aufgaben, stabiler Interaktion und Datensicherheit und ist über die ModelArk-Plattform einfach zugänglich und bereitstellbar. Registrieren Sie sich über <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">diesen Link</a> und erhalten Sie pro Modell ein kostenloses Inferenzkontingent von 500.000 Token.<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
</tr>
<tr>
@@ -106,11 +106,6 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
<td>Danke an Micu API für die Unterstützung dieses Projekts! Micu API ist ein globaler LLM-Relay-Anbieter, der sich der Bereitstellung des besten Preis-Leistungs-Verhältnisses bei hoher Stabilität widmet. Gestützt auf ein eingetragenes Unternehmen als Kernabsicherung wird jedes Risiko einer Diensteinstellung ausgeschlossen, mit schneller offizieller Rechnungsstellung! Wir stehen für „kostenloses Ausprobieren": Aufladungen sind schon ab ¥1 ohne Mindestbetrag möglich, und gebührenfreie Rückerstattungen sind jederzeit möglich! Micu API bietet ein exklusives Angebot für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://www.micuapi.ai/register?aff=aOYQ">diesen Link</a> und geben Sie beim Aufladen den Gutscheincode „ccswitch" ein, um <strong>10 % Rabatt</strong> zu erhalten!</td>
</tr>
<tr>
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
<td>Danke an LemonData für die Unterstützung dieses Projekts! LemonData ist eine leistungsstarke KI-API-Aggregationsplattform — ein API-Schlüssel für mehr als 300 Modelle, darunter GPT, Claude, Gemini, DeepSeek und weitere. Alle Modelle zu Preisen 3070 % unter den offiziellen Tarifen, mit automatischem Failover, intelligentem Routing und unbegrenzter Nebenläufigkeit. Neukunden erhalten bei der Registrierung sofort 1 $ Gratisguthaben — registrieren Sie sich über <a href="https://lemondata.cc/r/FFX1ZDUP">diesen Link</a>, um Ihren Bonus einzulösen und sofort mit dem Entwickeln zu beginnen</strong>!</td>
</tr>
<tr>
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
<td>Danke an CTok.ai für die Unterstützung dieses Projekts! CTok.ai widmet sich dem Aufbau einer Komplettlösung für KI-Programmierwerkzeuge. Wir bieten professionelle Claude-Code-Pakete und Dienste einer technischen Community, mit Unterstützung für Google Gemini und OpenAI Codex. Durch sorgfältig gestaltete Pläne und eine professionelle Tech-Community geben wir Entwicklern verlässliche Servicegarantien und kontinuierlichen technischen Support an die Hand und machen KI-gestützte Programmierung zu einem echten Produktivitätswerkzeug. Klicken Sie <a href="https://ctok.ai">hier</a>, um sich zu registrieren!</td>
+1 -5
View File
@@ -63,7 +63,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
<tr>
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">このリンク</a>からご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。</td>
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">このリンク</a>からご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
</tr>
<tr>
@@ -105,10 +105,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
<td width="180"><a href="https://www.micuapi.ai/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.micuapi.ai/register?aff=aOYQ">こちらのリンク</a>から登録し、チャージ時にプロモコード「ccswitch」を入力すると <strong>10% 割引</strong> が適用されます!</td>
</tr>
<tr>
<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>
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
+2 -6
View File
@@ -62,8 +62,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
</tr>
<tr>
<td width="180"><a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
<td>感谢火山方舟Agent Plan 模型赞助了本项目!方舟Agent Plan 模型订阅套餐集成了包含Doubao-Seed、Doubao-Seedance、Doubao-Seedream等在内的字节跳动自研SOTA级模型,覆盖文本、代码、图像、视频等多模态任务。同时支持一站式接入DeepSeek V4、GLM 5.1等主流大模型。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。方舟 Agent Plan 为 CC Switch 的用户提供了专属福利:通过<a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">此链接</a>订阅方舟AgentPlan,新客户首月40元起<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">>>For developers outside Mainland China, please click here</a></td>
<td width="180"><a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
<td>感谢火山方舟 Agent Plan 模型赞助了本项目!方舟 Agent Plan 模型订阅套餐集成了包含 Doubao-Seed、Doubao-Seedance、Doubao-Seedream 等在内的字节跳动自研 SOTA 级模型,覆盖文本、代码、图像、视频等多模态任务。最新支持 MiniMax-M3、DeepSeek-V4 系列、GLM-5.1、Doubao-Seed-2.0 系列、Kimi-K2.6 等模型,工具不限。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。一次订阅,可以为不同任务切换合适的 AI 引擎。方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">此链接</a>订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">>>For developers outside Mainland China, please click here</a></td>
</tr>
<tr>
@@ -106,10 +106,6 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
<td width="180"><a href="https://www.micuapi.ai/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.micuapi.ai/register?aff=aOYQ">此链接</a>注册并在充值时填写"ccswitch"优惠码可享九折优惠!</td>
</tr>
<tr>
<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>
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

@@ -1,162 +0,0 @@
/**
* 统一供应商(Universal Provider)预设配置
*
* 统一供应商是跨应用共享的配置,修改后会自动同步到 Claude、Codex、Gemini 三个应用。
* 适用于 NewAPI 等支持多种协议的 API 网关。
*/
import type {
UniversalProvider,
UniversalProviderApps,
UniversalProviderModels,
} from "@/types";
/**
* 统一供应商预设接口
*/
export interface UniversalProviderPreset {
/** 预设名称 */
name: string;
/** 供应商类型标识 */
providerType: string;
/** 默认启用的应用 */
defaultApps: UniversalProviderApps;
/** 默认模型配置 */
defaultModels: UniversalProviderModels;
/** 网站链接 */
websiteUrl?: string;
/** 图标名称 */
icon?: string;
/** 图标颜色 */
iconColor?: string;
/** 描述 */
description?: string;
/** 是否为自定义模板(允许用户完全自定义) */
isCustomTemplate?: boolean;
}
/**
* NewAPI 默认模型配置
*/
const NEWAPI_DEFAULT_MODELS: UniversalProviderModels = {
claude: {
model: "claude-sonnet-4-20250514",
haikuModel: "claude-haiku-4-20250514",
sonnetModel: "claude-sonnet-4-20250514",
opusModel: "claude-sonnet-4-20250514",
},
codex: {
model: "gpt-4o",
reasoningEffort: "high",
},
gemini: {
model: "gemini-2.5-pro",
},
};
const N1N_DEFAULT_MODELS: UniversalProviderModels = {
claude: {
model: "claude-3-5-sonnet-20240620",
haikuModel: "claude-3-haiku-20240307",
sonnetModel: "claude-3-5-sonnet-20240620",
opusModel: "claude-3-opus-20240229",
},
codex: {
model: "gpt-4o",
reasoningEffort: "high",
},
gemini: {
model: "gemini-1.5-pro-latest",
},
};
/**
* 统一供应商预设列表
*/
export const universalProviderPresets: UniversalProviderPreset[] = [
{
name: "n1n.ai",
providerType: "n1n",
defaultApps: {
claude: true,
codex: true,
gemini: true,
},
defaultModels: N1N_DEFAULT_MODELS,
websiteUrl: "https://n1n.ai",
icon: "openai",
iconColor: "#000000",
description:
"n1n.ai - 聚合 OpenAI, Anthropic, Google 等主流大模型的一站式 AI 服务平台",
},
{
name: "NewAPI",
providerType: "newapi",
defaultApps: {
claude: true,
codex: true,
gemini: true,
},
defaultModels: NEWAPI_DEFAULT_MODELS,
websiteUrl: "https://www.newapi.pro",
icon: "newapi",
iconColor: "#00A67E",
description:
"NewAPI 是一个可自部署的 API 网关,支持 Anthropic、OpenAI、Gemini 等多种协议",
},
{
name: "自定义网关",
providerType: "custom_gateway",
defaultApps: {
claude: true,
codex: true,
gemini: true,
},
defaultModels: NEWAPI_DEFAULT_MODELS,
icon: "openai",
iconColor: "#6366F1",
description: "自定义配置的 API 网关",
isCustomTemplate: true,
},
];
/**
* 根据预设创建统一供应商
*/
export function createUniversalProviderFromPreset(
preset: UniversalProviderPreset,
id: string,
baseUrl: string,
apiKey: string,
customName?: string,
): UniversalProvider {
return {
id,
name: customName || preset.name,
providerType: preset.providerType,
apps: { ...preset.defaultApps },
baseUrl,
apiKey,
models: JSON.parse(JSON.stringify(preset.defaultModels)), // Deep copy
websiteUrl: preset.websiteUrl,
icon: preset.icon,
iconColor: preset.iconColor,
createdAt: Date.now(),
};
}
/**
* 获取预设的显示名称(用于 UI)
*/
export function getPresetDisplayName(preset: UniversalProviderPreset): string {
return preset.name;
}
/**
* 根据类型查找预设
*/
export function findPresetByType(
providerType: string,
): UniversalProviderPreset | undefined {
return universalProviderPresets.find((p) => p.providerType === providerType);
}
+339
View File
@@ -0,0 +1,339 @@
# CC Switch v3.16.3
> 🎉 **CC Switch has passed 100,000 Stars!**
> Thank you to every user, contributor, and Star — you are the reason it has come this far. 🙏
> 💎 **This release was developed with help from the Claude Fable 5 model** — it helped untangle several critical, error-prone pieces of logic: the attribution chain that bills route-takeover traffic by the real upstream model, the metering and de-duplication of cache tokens on format-conversion paths, the in-app update restart deadlock, and the migration / restore invariants of Codex unified session history. This is also why this release adds a **Fable 5 Verified** badge to the About page.
> After v3.16.2 broadened data portability and usage observability, this release puts the focus on "making usage billing truly accurate" — billing by the real upstream model, fixing cache double-counting on format-conversion paths, counting Claude Code Workflow sub-agent usage (schema v11), and a round of redesign for the usage dashboard (dashboard-wide provider / model filters, a brand-icon toolbar, and more resilient quota queries) — while also hardening a batch of local proxy and platform issues, adding a custom User-Agent override, a Codex unified session history toggle, and a Claude Fable 5 tier.
**[中文版 →](v3.16.3-zh.md) | [日本語版 →](v3.16.3-ja.md)**
---
## Usage Guides
This release changes how usage is counted and reworks the dashboard quite a bit, so it is worth starting here:
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: understand the Usage Dashboard's data sources (proxy logs, session sync) and how the statistics are counted. This release adds dashboard-wide provider / model filters and surfaces the real pricing model for route-takeover traffic.
- **[Settings](../user-manual/en/1-getting-started/1.5-settings.md)**: the custom User-Agent override, the Codex unified session history toggle, and other switches live in the provider form's advanced options and on the settings page.
---
> [!WARNING]
>
> ## Only Official Channels (Please Read)
>
> CC Switch is a **fully free and open-source** desktop app, and we **do not charge users any fees**. Please only obtain the software through the official channels listed below:
>
> | Channel | Only Official |
> | ------------------ | ------------------------------------------------------------------------------ |
> | Website | **[ccswitch.io](https://ccswitch.io)** |
> | Source | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
> | Downloads | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
> | Author | **[@farion1231](https://github.com/farion1231)** |
> | Report an Imposter | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
>
> **Any "CC Switch" website or client that asks you for payment, top-ups, or login credentials is fake.** If you have been tricked into paying, stop the transaction immediately and file a report through GitHub Issues.
---
## Overview
CC Switch v3.16.3 is a maintenance update following v3.16.2. After the previous release concentrated on broadening data portability and usage observability, this release puts the focus on "making usage billing truly accurate" — billing by the real upstream model rather than whatever the upstream echoes back, fixing the cache-token double-counting on format-conversion paths (Chat / Responses / Gemini converted to Anthropic), folding Claude Code Workflow sub-agent usage into the local statistics, and persisting the actual pricing basis used by each record as schema v11. The usage dashboard was reworked along with it, adding dashboard-wide provider / model filters, a brand-icon toolbar, and more resilient quota queries (retry on failure plus keeping the last successful result).
In addition, this release hardens a batch of local proxy robustness issues (aggregating SSE responses returned under a mislabeled Content-Type, Codex `/responses` image rectification for text-only models, recovery of Codex OAuth credentials and takeover residue, duplicate YAML keys in Hermes config), reworks the provider configuration experience (a custom User-Agent override, a unified Codex advanced section, searchable and sortable presets, a Claude Fable 5 tier), adds a Codex unified session history toggle, and fixes the in-app update hang, the Codex upgrade that broke the install, duplicate macOS terminal windows, and more.
**Release date**: 2026-06-14
**Stats**: 59 commits | 130 files changed | +10,223 / -4,232 lines
---
## Highlights
- **More accurate usage billing**: route-takeover traffic is now billed by the real upstream model (not the alias the upstream echoes back), format-conversion paths no longer count cache tokens into input twice, and Claude Code Workflow sub-agent usage is now counted — with the pricing basis persisted as schema v11.
- **Usage dashboard redesign**: provider / model filters are promoted from inside the request-log table up to dashboard-wide filters, the app filter switches to brand icons, and quota queries gain retry-on-failure plus "keep the last successful result" so a single network blip no longer turns cards red.
- **Custom User-Agent override**: providers can set a custom UA that applies consistently across forwarding, connectivity detection, and model listing, getting past coding-plan upstreams that gate on a UA whitelist (which is how the Codex "Kimi For Coding" preset was restored).
- **Codex unified session history**: a new opt-in toggle lets official Codex sessions share a single resume-history bucket with third-party sessions, with optional migration of existing sessions and precise ledger-based restore.
- **Proxy and platform hardening**: aggregating mislabeled SSE responses, Codex image rectification, takeover-residue recovery, Hermes YAML de-duplication; in-app updates no longer hang on "restarting", and Codex upgrades no longer break the install.
---
## Added
### Custom User-Agent Override
Provider configs can now set a custom User-Agent that the proxy applies consistently across request forwarding, stream check, and model listing (`GET /v1/models`), so coding-plan upstreams that gate on UA no longer fail detection or return 403 while the proxy itself works. The Claude and Codex forms expose it in advanced settings with a curated presets dropdown (Claude Code / Kilo Code families that pass UA whitelists) and live non-blocking validation; stale custom UAs are dropped when switching to an official preset to avoid silently altering headers (#3671).
### Unified Codex Session History
Official Codex sessions can now share a single resume-history bucket with cc-switch third-party sessions via an opt-in toggle under Settings → Codex App Enhancements, so the resume picker no longer hides them from each other. When enabled, the live `config.toml` routes official runs through a shared `custom` model_provider that mirrors the built-in OpenAI provider (`auth.json` is untouched); the toggle is forward-only by default but the enable dialog offers a checkbox to migrate existing official sessions (with per-generation backups), and the disable dialog offers a precise ledger-based restore that only reverts sessions originally recorded as `openai` while leaving sessions created during the toggle untouched.
### Dashboard-Wide Provider / Model Filters
The provider and model filters move from inside the request-log table up to the top bar, applying globally to the hero summary, trend chart, request logs, and both stats tabs so you can scope the whole dashboard to a given source and model. Sources match by exact display name (so session placeholder rows like "Claude (Session)" are selectable) and models match by effective pricing model, with the model dropdown cascading from the selected source and both lists showing only options that have data in the current range.
### Refreshed Model Pricing Seed
Added pricing for 9 models including Claude Fable 5, Grok 4.3, Mistral Medium 3.5 / Small 4, and Qwen 3.7 Max/Plus, and corrected 28 existing prices against current official vendor list pricing (GLM, Grok, MiMo, Doubao, Kimi, MiniMax, Mistral, Qwen) so usage cost estimates are accurate. Each change updates the seed for fresh installs and adds a guarded repair for existing databases without clobbering user-edited rows.
### Claude Fable 5 Model Tier
Provider forms now expose `claude-fable-5` as a fourth model-mapping tier on both the Claude Code and Claude Desktop proxy paths, with a fable → opus → default fallback mirroring the official downgrade and the `fable-` prefix whitelisted for the Desktop 1.12603.1+ validator. A clarified four-language fallback hint warns that leaving a tier blank on third-party endpoints forwards the literal model name and 404s (#3980, #4026, #4049).
### Unity2.ai Partner Provider
Added Unity2.ai, an AI API relay partner, as a preset across all seven managed apps (Claude Code, Codex, Gemini, OpenCode, OpenClaw, Claude Desktop, Hermes), each carrying the referral signup link and partner promotion copy in all four locales. Codex uses the bare base URL (the gateway exposes `/responses` at root) while OpenCode / OpenClaw / Hermes use the `/v1` chat-completions endpoint with `gpt-5.5`.
### Kimi K2.7 Code Model
Added the `kimi-k2.7-code` model (in $0.95 / out $4.00 / cache-read $0.19 per 1M tokens, 256K context) and pointed all six official Moonshot Kimi presets (Claude Code, Codex, Claude Desktop, Hermes, OpenCode, OpenClaw) at it, renaming the OpenCode / OpenClaw presets to "Kimi K2.7 Code". The pricing seed applies on startup via the idempotent insert path, so existing users pick up the new pricing without a migration.
### Codex "Kimi For Coding" Preset Restored
Re-added the Codex "Kimi For Coding" preset (`openai_chat`, `kimi-for-coding`, 256K context) with thinking mode enabled by default; it was previously removed because the coding endpoint rejects Codex's default `codex-cli` User-Agent with 403. It now works via proxy takeover combined with the custom User-Agent override (set to a whitelisted UA such as `claude-cli/*`).
### Pricing-Model Audit in Request Detail
The request detail panel now shows the requested model and the pricing model when they differ from the response model, making route-takeover bills auditable directly from the usage UI.
### Preset Provider Search & Sorting
The provider preset selector gains a searchable, sorted list with an inline search box (toggled via a magnifier icon, dismissed on ESC or outside click). Buttons use a responsive grid with consistent sizing and default icons, and search matches only provider display/raw names so URL fragments and shared category labels no longer produce noisy matches (#3975, #4183).
### Claude Mythos 5 Pricing
Registered the `claude-mythos-5` model in the bundled model/pricing table (in $10 / out $50 per 1M tokens, cache read $1.00, cache write $12.50), so usage metering prices and displays it correctly (#4077).
### Fable 5 Verified Banner
The Settings About page now displays a Fable 5 Verified banner beside the app name and version, marking this as a special build, with the version badge centered under the app name.
---
## Changed
### Claude Desktop Usage Folded Into Claude
The dashboard no longer shows a standalone "Claude Desktop" bucket, which only ever displayed a partial number (Desktop chat usage never passes through the proxy and its Code-tab sessions write into the shared `~/.claude/projects` tree). Desktop proxy traffic is now folded into the `claude` view for display while still recorded under its own `app_type` for route-takeover billing audit, with the real value visible in the request detail panel.
### Lightweight Provider Health Check
The provider health check no longer sends a real streaming model request (which many third-party providers blocked with 401/403/WAF, causing false negatives); it now performs a lightweight HTTP reachability probe of the provider `base_url`, treating any HTTP response as reachable and counting only DNS/connect/TLS/timeout as failure. The connectivity button is hidden for official providers (which use OAuth with an empty base URL and no reliable reachability target), the real-request confirmation dialog and test model/prompt fields are removed, and the degraded-latency threshold is set to 6s with an 8s timeout. The reachability check never resets the circuit breaker, so failover detection stays driven solely by real proxy traffic.
### Codex Advanced Options Section
The Codex provider form now folds local routing, model mapping, reasoning overrides, and custom User-Agent into a single collapsible advanced section mirroring the Claude form (auto-expanding when a UA is set or local routing is on). Custom User-Agent is now also configurable for native Responses providers, where it was previously reachable only with `openai_chat` routing enabled.
### Usage Toolbar Refresh and Layout
The app filter now renders brand icons (via ProviderIcon, with a grid icon for "All") instead of text tabs that wrapped awkwardly in narrow windows, and the usage hero shows the selected app's brand icon with Codex recolored to a neutral gray matching OpenAI's monochrome branding. The click-to-cycle refresh button becomes a Select with a localized "off" label, and the top-bar controls are compacted and aligned into consistent width groups with truncated long date-range labels.
### Faster About Panel Loading
The Settings About panel now loads progressively: the app version badge appears the instant it resolves instead of waiting for tool probes, each tool card updates the moment its own version check finishes (probes run concurrently rather than sequentially), and results are cached for the app session with a 10-minute TTL so reopening the About tab reuses cached values and revalidates stale ones in the background instead of re-probing all six tools every time.
### Volcengine Ark Coding Plan Promo
Updated the Volcengine Ark preset across all six apps with the new Coding Plan invite link (replacing the old Agent Plan / activity links) and refreshed the partner promotion copy in all four locales (two-month 75% off plus invite code 6J6FV5N2), correcting the product name from Agent Plan to Coding Plan.
### MiniMax Demoted to Regular Provider
Removed the gold partner star badge and the API-key promotion banner for MiniMax by dropping the `isPartner` flag from all its presets; it stays as a regular `cn_official` provider keeping its icon and theme. The promotion copy is kept dormant so the partnership can be re-enabled with a single line.
### LemonData Removed, SudoCode Demoted
Removed the LemonData provider preset entirely from all apps along with its promotion copy, icons, and sponsor listings, and demoted SudoCode from a partner to a regular `third_party` provider by dropping its `isPartner` flag and promotion copy (it keeps its icon).
### AtlasCloud Codex GLM 5.1 Context Window
Declared the 200,000-token context window for the `zai-org/glm-5.1` model in the AtlasCloud Codex preset, matching the other GLM 5.1 preset entries.
---
## Fixed
### Route-Takeover Traffic Billed by the Real Upstream Model
When a request was routed to a different upstream (env model mapping, Claude Desktop routes, Copilot normalization, Codex chat override), the proxy used to attribute and price usage by whatever model the upstream echoed back, recording kimi/glm tokens as `claude-*` and overstating cost roughly 525×. The forwarder now captures the real outbound model, attributes usage by upstream-echo then outbound then client alias, persists the actual pricing basis on every row (schema v11), and keeps that basis through cost backfill and 30-day rollup pruning; Claude Desktop traffic is now logged under its own `app_type` so its pricing overrides apply.
### Usage Metering on Format-Conversion Proxy Paths
Audited and fixed token/cache accounting across the proxy's format-conversion paths (Chat, Responses, and Gemini converted to Anthropic). The proxy now records the actually returned model, injects `stream_options.include_usage` so OpenAI-compatible upstreams emit usage in streaming, excludes `cache_read` and `cache_creation` from input on Claude←OpenAI paths to stop double-billing cache tokens, subtracts cached Gemini prompt tokens, still records fully-cached requests, and skips synthetic all-zero usage that previously inflated request counts (#2774).
### In-App Update No Longer Hangs on Restart
Installing an update from within the app no longer freezes on the "restarting" screen, leaving the new version installed but requiring a manual force-quit. The download-install-restart chain now runs entirely in the backend (a new `install_update_and_restart` command) with platform-aware install ordering and single-instance-lock teardown before re-exec, instead of depending on the old WebView to keep running JS after the app bundle was already swapped; exit requests are also classified so restart requests fall through to Tauri's default flow rather than deadlocking on the window-state plugin mutex (#4069, #4074).
### Codex Upgrade No Longer Breaks the Install
Upgrading Codex from the Settings "About" tab no longer leaves it throwing "Missing optional dependency @openai/codex-…" errors. The upgrade chain previously ran `codex update` first, which on an npm install is a bare reinstall that reports success even when the per-platform binary fails to land; Codex is now removed from the self-update-first path and a runnable check triggers an uninstall+reinstall self-heal (scoped to npm-managed installs) that actually re-lands the missing platform binary.
### Codex OAuth Auth Token Preserved on Proxy Takeover
Enabling proxy takeover for a Codex provider no longer strips the `ANTHROPIC_AUTH_TOKEN` placeholder, which previously broke Claude Code's login on hot-switches, fresh installs, and configs already stripped by older releases. The placeholder is now injected unconditionally for managed (non-Copilot) Codex providers, including URL-only ones; GitHub Copilot behavior (API_KEY only) is unchanged (#3789, #3784).
### Takeover-Residue Recovery Across Config-Dir Switches
Restarting the app after changing the config directory while proxy takeover is active no longer leaves Claude/Codex/Gemini pointed at a dead local proxy. The old instance now restores the taken-over live files before restarting, the first-run import refuses to persist a takeover placeholder as a provider, and SSOT restore validates that the current provider's config is free of placeholders before writing it back (#4076).
### Mislabeled SSE Bodies in Format-Transform Fallback
Requests routed through Claude/Codex format conversion no longer fail with an opaque 422 "Failed to parse upstream response" when a MaaS gateway force-streams a `stream:false` request and returns an SSE body under a non-SSE Content-Type. The proxy now sniffs for SSE on parse failure, aggregates the chunks into a single JSON, and runs the existing converter so clients still get a valid non-stream response; remaining parse failures are enriched with content-type, encoding, and body-snippet diagnostics, and deflate decoding now tries zlib before raw (#2234).
### Duplicate YAML Keys in Hermes Config
Hermes config writes no longer accumulate duplicate top-level keys (e.g. `mcp_servers`) that caused "Failed to parse Hermes config as YAML: duplicate entry with key" errors. Section replacement now strips all stale occurrences from the remainder instead of degrading into appends, the dedup safety net handles both LF and CRLF line endings, and healing keeps the last (newest) occurrence to match Hermes's own last-wins PyYAML semantics (#3267, #3633, #2973, #2529, #3310, #3762).
### Usage Query Resilience and Error Clarity
Usage cards no longer flip to red on a single transient blip: queries now retry once and keep showing the last successful result for up to 10 minutes on network/timeout/5xx failures, while deterministic failures (auth, empty key, unknown provider, 4xx) surface immediately and clear the snapshot so a stale quota can't resurface after credentials change. Native balance/coding-plan/subscription timeouts were raised from 10s to 15s for slow cross-border endpoints, and coding-plan now returns explicit "API key is empty" / "Unknown coding plan provider" errors instead of a blank failure.
### Usage Script Provider Credential Resolution
Custom JS-script usage queries resolved `{{apiKey}}` / `{{baseUrl}}` by guessing env fields only, so providers that store credentials elsewhere (e.g. Codex's `auth.OPENAI_API_KEY` plus `config.toml` base_url) always got empty values and failed despite being fully configured. Script queries and the test/preview now reuse the same per-app credential resolver as the native balance path, with explicit non-empty script values still taking precedence (#1479).
### Claude Code Workflow Sub-Agent Usage Counted
Local (no-proxy) session-log usage accounting missed Claude Code Workflow sub-agent traffic, under-counting overall usage by roughly 4.1% (concentrated in workflow/subagent transcripts). The scanner now descends into the deeper `subagents/workflows/wf_*/` transcript directories, and the parser no longer drops billable assistant messages that lack a `stop_reason` but already incurred input/cache token cost; dedup is unchanged so no usage is double-counted.
### Codex Image Rectifier for /responses Text-Only Upstreams
Codex `/responses` requests carrying images and routed to text-only OpenAI-chat models (e.g. DeepSeek `deepseek-v4-flash`) no longer fail with HTTP 400 "unknown variant `image_url`". The media rectifier now also covers the Codex adapter, scanning the responses `input` for `input_image` blocks so it can proactively strip images for known text-only models and reactively retry with images replaced on upstream image-unsupported errors.
### Zhipu Coding-Plan Quota Window Mislabeling
The Zhipu coding-plan view no longer swaps the 5-hour and weekly quota buckets in the final hours of each weekly cycle. The two windows are now classified by the explicit `unit` field (3 = 5-hour, 6 = weekly) instead of by sorting reset-time ascending, which mislabeled them exactly when users check their weekly quota most; the old reset-time heuristic remains as a fallback (#3036).
### Duplicate Provider Terminal Sessions on macOS
Launching a provider terminal on macOS no longer opens an extra empty window alongside the command session; Terminal.app uses `launch` (not `activate`) on cold start and Ghostty uses an initial-command so a single session opens, with a fallback retained if the AppleScript path fails (#4156).
### Claude Desktop Model-Mapping Placeholders
The Claude Desktop model-mapping form previously showed mismatched example brands across the menu display name and request model columns (DeepSeek vs Kimi), implying a display name maps to an unrelated model. Both placeholders are now derived from each row's role so they stay brand-consistent, with the lightweight Haiku tier using a flash example.
### Popovers Behind Fullscreen Panels
Popovers and tooltips such as the provider preset search no longer render behind fullscreen panels and appear unresponsive on click; their z-index is raised above the fullscreen overlay while staying below modal dialogs.
### ToggleRow Icon Shrinking
Toggle row icons no longer shrink or distort when paired with long descriptions, keeping the icon at a fixed size next to multi-line text.
---
## Documentation
### Release Notes Contributor Mentions
Restored contributor mentions in the v3.16.1 and v3.16.2 release notes across all three locales.
---
## Upgrade Notes
### Pricing Database schema v11 Auto-Migration
This release adds a `pricing_model` column to `proxy_request_logs` and rebuilds the rollup by `request_model` + `pricing_model`, migrating automatically on startup with no manual action required. Historical rows have their cost frozen at write time and are not recalculated (rows with `app_type="claude"` mix native and converted sources); only real but previously un-priced takeover rows stay at zero cost until pricing is supplied and then backfilled.
### Model Mapping Adds a Fourth Tier (Fable 5)
The Claude Code and Claude Desktop model mappings now have four tiers (Sonnet / Opus / Fable / Haiku). Older three-tier providers pick up the `claude-fable-5` tier after being reopened and saved; leaving that tier blank means it inherits Sonnet. Note: leaving any tier blank on third-party endpoints forwards the literal model name of that tier and may 404, so fill it in as needed.
### The "Kimi For Coding" Preset Needs Proxy Takeover + a Whitelisted UA
The restored Codex "Kimi For Coding" preset is still rejected with 403 if used with the default `codex-cli` User-Agent. To use it, enable proxy takeover and set the custom User-Agent in the provider's advanced options to a whitelisted UA (such as `claude-cli/*`).
### Provider Health Check Semantics Changed
The health check changed from "send a real model request" to "HTTP reachability probe". Note that reachable ≠ usable: a host that returns 403 is reachable but may be broken for real traffic. Failover decisions remain driven solely by real proxy traffic and are unaffected by the health check.
---
## Risk Notice
This release continues the risk notices from previous versions for reverse-proxy-style features.
**Codex OAuth reverse proxy**: using a ChatGPT subscription's Codex OAuth through a reverse proxy may violate OpenAI's terms of service. See the [v3.13.0 release notes](v3.13.0-en.md#-risk-notice) for details.
**Codex third-party provider Chat routing**: when CC Switch local proxy converts and forwards Codex requests to third-party providers, each provider may have different requirements for billing, compliance, and data retention. Read the target provider's terms before use.
**Claude Desktop third-party provider proxy switching**: when CC Switch's built-in proxy gateway forwards Claude Desktop requests to third-party providers, you must also follow the target provider's billing, compliance, and data-retention terms.
By enabling these features, users accept the related risks. CC Switch is not responsible for account restrictions, warnings, or service suspensions caused by using these features.
---
## Thanks
Thanks to the following contributors for the features and fixes in v3.16.3:
- [#3789](https://github.com/farion1231/cc-switch/pull/3789): preserve Codex OAuth auth token on takeover, thanks @codeasier.
- [#2774](https://github.com/farion1231/cc-switch/pull/2774): fix model / input-token recording on Completions→Anthropic, thanks @LaoYueHanNi.
- [#4069](https://github.com/farion1231/cc-switch/pull/4069): fix the deadlock on relaunch after an in-app update, thanks @thisTom.
- [#4156](https://github.com/farion1231/cc-switch/pull/4156): fix duplicate provider terminal sessions on macOS, thanks @thisTom.
- [#3267](https://github.com/farion1231/cc-switch/pull/3267): fix duplicate YAML keys in the Hermes config, thanks @que3sui.
- [#1479](https://github.com/farion1231/cc-switch/pull/1479): fix usage script provider credential resolution, thanks @pa001024.
- [#3975](https://github.com/farion1231/cc-switch/pull/3975): add preset search and sorting, thanks @Nastem.
- [#4183](https://github.com/farion1231/cc-switch/pull/4183): adjust the preset-provider button appearance and search-box position, thanks @WangJiati.
- [#4077](https://github.com/farion1231/cc-switch/pull/4077): add claude-mythos-5 model pricing, thanks @osscv.
Thanks also to everyone who reported usage billing, local proxy robustness, Codex upgrade, and platform compatibility issues after v3.16.2. Many of these fixes came directly from real-world reproduction details.
---
## Download & Install
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) and download the build for your system.
### System Requirements
| System | Minimum Version | Architecture |
| ------- | ------------------------ | ----------------------------------- |
| Windows | Windows 10 and later | x64 |
| macOS | macOS 12 (Monterey)+ | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 / ARM64 |
### Windows
| File | Description |
| ---------------------------------------- | ------------------------------------------------ |
| `CC-Switch-v3.16.3-Windows.msi` | **Recommended** - MSI installer with auto-update |
| `CC-Switch-v3.16.3-Windows-Portable.zip` | Portable build, unzip and run |
### macOS
| File | Description |
| -------------------------------- | ----------------------------------------------------- |
| `CC-Switch-v3.16.3-macOS.dmg` | **Recommended** - DMG installer, drag to Applications |
| `CC-Switch-v3.16.3-macOS.zip` | Unzip and drag to Applications, Universal Binary |
| `CC-Switch-v3.16.3-macOS.tar.gz` | For Homebrew install and auto-update |
Homebrew install:
```bash
brew install --cask cc-switch
```
Upgrade:
```bash
brew upgrade --cask cc-switch
```
### Linux
Linux assets are available for both **x86_64** and **ARM64** (`aarch64`). Choose the file whose architecture tag matches your machine's `uname -m` output:
- `CC-Switch-v3.16.3-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- `CC-Switch-v3.16.3-Linux-arm64.AppImage` / `.deb` / `.rpm`
| Distribution | Recommended Format | Install Command |
| --------------------------------------- | ------------------ | --------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
| Other distributions / unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+339
View File
@@ -0,0 +1,339 @@
# CC Switch v3.16.3
> 🎉 **CC Switch が 100,000 Star を突破しました!**
> すべてのユーザー・コントリビューター・Star をくださった方々に感謝します —— 皆さんのおかげでここまで来ました。🙏
> 💎 **本リリースは Claude Fable 5 モデルの協力のもとで開発されました**——重要かつ間違えやすいロジックの整理を手伝ってくれました: ルーティングテイクオーバー時に本物の上流モデルで課金する帰属チェーン、形式変換経路でのキャッシュ token の計上と重複排除、アプリ内更新の再起動デッドロック、そして Codex 統一セッション履歴の移行 / 復元の不変条件です。本リリースの「バージョン情報」ページに **Fable 5 Verified** バッジを新設したのもこのためです。
> v3.16.2 でデータの可搬性と使用量の可観測性を広げたのに続き、本リリースは「使用量の課金を本当に正確にする」ことに重きを置いています——本物の上流モデルで課金し、形式変換経路でのキャッシュの二重計上を修正し、Claude Code Workflow のサブ agent の使用量を統計に取り込み(schema v11)、使用量ダッシュボードを一通り刷新しました(全体に効くプロバイダー / モデルフィルタ、ブランドアイコンのツールバー、より安定した残量照会)。あわせて一連のローカルプロキシとプラットフォームの問題を補強し、カスタム User-Agent オーバーライド、Codex 統一セッション履歴のトグル、Claude Fable 5 階層を新設しました。
**[English →](v3.16.3-en.md) | [中文版 →](v3.16.3-zh.md)**
---
## 利用ガイド
本リリースでは使用量統計の数え方とダッシュボードに多くの調整を加えたため、まず以下をご覧ください:
- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**: 使用量ダッシュボードのデータソース(プロキシログ、セッション同期)と集計の仕組みを確認できます。本リリースで全体に効くプロバイダー / モデルフィルタを追加し、ルーティングテイクオーバー時の本物の課金モデルを表示するようにしました。
- **[設定](../user-manual/ja/1-getting-started/1.5-settings.md)**: カスタム User-Agent オーバーライド、Codex 統一セッション履歴などのトグルは、プロバイダーフォームの高度なオプションと設定ページにあります。
---
> [!WARNING]
>
> ## 唯一の公式チャネル(必ずお読みください)
>
> CC Switch は**完全に無料・オープンソース**のデスクトップアプリで、**ユーザーから料金を徴収することはありません**。本ソフトウェアは下記の公式チャネルからのみ入手してください:
>
> | チャネル | 唯一の公式 |
> | ------------ | ------------------------------------------------------------------------------ |
> | 公式サイト | **[ccswitch.io](https://ccswitch.io)** |
> | ソースコード | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
> | ダウンロード | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
> | 偽サイト通報 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
>
> **料金請求・チャージ・認証情報の提供を求める「CC Switch」サイトやクライアントはすべて偽物です。** 支払いを誘導された場合は直ちに操作を中止し、GitHub Issues からご報告ください。
---
## 概要
CC Switch v3.16.3 は v3.16.2 に続くメンテナンスアップデートです。前リリースではデータの可搬性と使用量の可観測性の拡張に集中しましたが、本リリースは「使用量の課金を本当に正確にする」ことに重きを置いています——上流が返すエイリアスではなく本物の上流モデルで課金し、形式変換(Chat / Responses / Gemini を Anthropic へ)経路でのキャッシュ token の二重計上を修正し、Claude Code Workflow のサブ agent の使用量をローカル統計に取り込み、schema v11 で各レコードが実際に使用した課金根拠を永続化しました。使用量ダッシュボードもこれにあわせて一通り刷新し、全体に効くプロバイダー / モデルフィルタ、ブランドアイコンのツールバー、より安定した残量照会(失敗時の再試行 + 前回成功した結果の保持)を追加しました。
さらに本リリースでは、一連のローカルプロキシの堅牢性に関する問題(Content-Type が誤ってラベル付けされた SSE レスポンスの集約、Codex `/responses` のテキスト専用モデル向け画像整流、Codex OAuth 認証情報とテイクオーバー残留の復元、Hermes 設定の重複 YAML キー)を補強し、プロバイダー設定まわりを作り直し(カスタム User-Agent オーバーライド、Codex フォームの高度なオプションへの統合、プリセット検索とソート、Claude Fable 5 階層)、Codex 統一セッション履歴のトグルを新設し、アプリ内更新のハング、Codex のアップグレードによるインストール破損、macOS の重複ターミナルウィンドウなどの問題を修正しました。
**リリース日**: 2026-06-14
**Stats**: 59 commits | 130 files changed | +10,223 / -4,232 lines
---
## ハイライト
- **使用量の課金がより正確に**: ルーティングテイクオーバーのトラフィックを本物の上流モデルで課金するようになり(上流が返すエイリアスではなく)、形式変換経路でキャッシュ token を input に二重計上しなくなり、Claude Code Workflow のサブ agent の使用量も統計に取り込みました——schema v11 で課金根拠を永続化します。
- **使用量ダッシュボードの刷新**: プロバイダー / モデルフィルタをリクエストログテーブルから全体フィルタへ引き上げ、アプリフィルタをブランドアイコンに変更し、残量照会に失敗時の再試行と「前回成功した結果の保持」を追加して、一度のネットワークのゆらぎでカードが赤くならないようにしました。
- **カスタム User-Agent オーバーライド**: プロバイダーにカスタム UA を設定でき、転送・接続性チェック・モデル一覧の 3 か所で一貫して有効になり、UA ホワイトリストで制限する Coding Plan 上流を通過できます(これにより Codex「Kimi For Coding」プリセットを復活させました)。
- **Codex 統一セッション履歴**: 公式 Codex セッションとサードパーティセッションが同じ resume 履歴バケットを共有できる任意のトグルを新設し、既存セッションの任意移行と台帳に基づく精密な復元を備えます。
- **プロキシとプラットフォームの補強**: 誤ラベルの SSE レスポンスの集約、Codex 画像整流、テイクオーバー残留の復元、Hermes YAML 重複排除。アプリ内更新が「再起動中」でハングしなくなり、Codex のアップグレードでインストールを壊さなくなりました。
---
## 追加機能
### カスタム User-Agent オーバーライド
プロバイダー設定でカスタム User-Agent を設定できるようになり、プロキシがリクエスト転送、接続性チェック、モデル一覧(`GET /v1/models`)の 3 つの経路で一貫して適用します。これにより、UA ホワイトリストで制限する Coding Plan 上流で「検出は失敗 / モデル一覧は 403 なのにプロキシ本体は正常に動く」という不整合が起きなくなります。Claude と Codex のフォームはいずれも高度なオプションでこのフィールドを公開し、厳選した UA プリセットのドロップダウン(Claude Code / Kilo Code など UA ホワイトリストを通過できるファミリー)とリアルタイムで非ブロッキングな形式検証を備えます。公式プリセットへ切り替えると残っていたカスタム UA は破棄され、リクエストヘッダーを密かに変更しないようにします([#3671](https://github.com/farion1231/cc-switch/pull/3671))。
### Codex 統一セッション履歴
任意のトグル(設定 → Codex アプリ拡張)を新設し、公式 Codex セッションと CC Switch のサードパーティセッションが同じ resume 履歴バケットを共有できるようにしました。resume セレクタが両者を互いに隠さなくなります。有効化すると、live の `config.toml` は公式の実行を、内蔵 OpenAI プロバイダーをミラーした共有 `custom` model_provider へルーティングします(`auth.json` は変更しません)。デフォルトでは今後のセッションにのみ有効です。有効化ダイアログには既存の公式セッションを共有バケットへ移行できるチェックボックスがあり(世代ごとのバックアップ付き)、無効化ダイアログにはバックアップ台帳に基づく精密な復元が用意されています——バックアップ内で `openai` として記録されたセッションのみを巻き戻し、有効化中に新規作成されたセッションは決して変更しません。
### 使用量ダッシュボードの全体プロバイダー / モデルフィルタ
プロバイダーフィルタとモデルフィルタを、リクエストログテーブルの内部からトップバーへ引き上げ、Hero サマリー、トレンドグラフ、リクエストログ、2 つの統計タブ全体に効くようにしました。ダッシュボード全体を特定のソースとモデルで絞り込めます。ソースは表示名で厳密に一致するため「Claude (Session)」のようなセッションのプレースホルダー行も選択でき、モデルは有効な課金モデルで一致し、モデルのドロップダウンは選択したソースに応じてカスケードし、どちらの一覧も現在の期間にデータがある選択肢のみを表示します。
### モデル価格シードの刷新
`seed_model_pricing` の全件価格点検を実施しました: 9 個のモデルの価格を新規追加し(Claude Fable 5、Grok 4.3、Mistral Medium 3.5 / Small 4、Qwen 3.7 Max/Plus などを含む)、各ベンダー公式の定価に合わせて既存価格 28 か所を訂正し(GLM、Grok、MiMo、Doubao、Kimi、MiniMax、Mistral、Qwen)、使用量コストの見積りをより正確にしました。各変更はシード(新規インストールに影響)を更新すると同時に、`repair_current_model_pricing` に旧→新のガードを 1 件追加します(既存データベースを修復し、ユーザーが手動で編集した行は上書きしません)。
### Claude Fable 5 モデル階層
プロバイダーフォームは、Claude Code と Claude Desktop の両プロキシ経路で `claude-fable-5` を 4 つ目のモデルマッピング階層として公開するようになりました。フォールバックチェーンは fable → opus → default で、公式の降格と一致し、Claude Desktop 1.12603.1+ の検証器で `fable-` プレフィックスを許可しました。4 言語のフォールバックヒントも明確化しました: サードパーティの endpoint である階層を空のままにすると、その階層のモデル名がそのまま透過されて 404 になります([#3980](https://github.com/farion1231/cc-switch/issues/3980)、[#4026](https://github.com/farion1231/cc-switch/issues/4026)、[#4049](https://github.com/farion1231/cc-switch/issues/4049))。
### Unity2.ai パートナープロバイダー
Unity2.aiAI API 中継のパートナー)をプリセットとして追加し、管理対象の 7 アプリすべて(Claude Code、Codex、Gemini、OpenCode、OpenClaw、Claude Desktop、Hermes)をカバーしました。各プリセットには紹介登録リンクを付け、4 言語でパートナー宣伝文を補いました。Codex は素の base URL を使用し(このゲートウェイはルートパスに `/responses` を公開)、OpenCode / OpenClaw / Hermes は `/v1` chat-completions の endpoint を使用し、`gpt-5.5` を既定モデルとします。
### Kimi K2.7 Code モデル
`kimi-k2.7-code` モデル(入力 $0.95 / 出力 $4.00 / キャッシュ読み取り $0.19、100 万 token あたり、256K コンテキスト)を新規追加し、6 つの公式 Moonshot Kimi プリセットすべて(Claude Code、Codex、Claude Desktop、Hermes、OpenCode、OpenClaw)をこれに向けました。OpenCode / OpenClaw のプリセットは「Kimi K2.7 Code」へ改名しました。価格シードは起動時の冪等な挿入経路で有効になるため、既存ユーザーは移行なしで新価格を取得できます。
### Codex「Kimi For Coding」プリセットの復活
Codex「Kimi For Coding」プリセット(`openai_chat``kimi-for-coding`、256K コンテキスト)を再追加し、思考モードをデフォルトで有効にしました。以前これを削除したのは、このコーディング endpoint が Codex 既定の `codex-cli` User-Agent を 403 で拒否するためでしたが、現在はプロキシテイクオーバー + カスタム User-Agent オーバーライド(ホワイトリストの UA、例 `claude-cli/*` に設定)を使えば正常に使えます。
### リクエスト詳細での課金モデル監査
リクエスト詳細パネルは、「リクエストされたモデル」「課金モデル」がレスポンスのモデルと一致しないときにそれらをすべて表示するようになり、ルーティングテイクオーバーが生む請求を使用量画面から直接照合できます。
### プリセットプロバイダーの検索とソート
プリセットプロバイダーのセレクタが、検索・ソート可能な一覧になり、インライン検索ボックスを備えました(虫眼鏡アイコンで切り替え、ESC または外側クリックで畳む)。ボタンはレスポンシブグリッドに変わってサイズが統一され、既定アイコンを表示します。検索はプロバイダーの表示名 / 生の名前のみに一致するため、URL の断片や共有のカテゴリラベルがノイズ一致を生まなくなります([#3975](https://github.com/farion1231/cc-switch/pull/3975)、[#4183](https://github.com/farion1231/cc-switch/pull/4183))。
### Claude Mythos 5 の価格
内蔵のモデル / 価格表に `claude-mythos-5` モデル(入力 $10 / 出力 $50、100 万 token あたり;キャッシュ読み取り $1.00、キャッシュ書き込み $12.50)を登録し、使用量統計が正しく課金・表示できるようにしました([#4077](https://github.com/farion1231/cc-switch/pull/4077))。
### Fable 5 Verified バッジ
設定の「バージョン情報」ページが、アプリ名とバージョンの隣に Fable 5 Verified バッジを表示し、これが特別ビルドであることを示すようになりました。バージョンバッジもアプリ名の下に中央揃えしました。
---
## 変更
### Claude Desktop の使用量を Claude に折りたたみ
ダッシュボードは独立した「Claude Desktop」バケットを表示しなくなりました——これは常に不完全な数字しか表示できませんでした(Desktop のチャット使用量はそもそもプロキシを経由せず、その Code タブのセッションは内蔵の Claude Code ランタイムが共有の `~/.claude/projects` ディレクトリへ書き込んでいるだけです)。Desktop のプロキシトラフィックは表示上 `claude` に折りたたまれますが、記帳層はルーティングテイクオーバー課金の監査のために引き続き自身の `app_type` で記録し、本物の値はリクエスト詳細パネルで確認できます。
### 軽量化したプロバイダーヘルスチェック
プロバイダーヘルスチェックは、本物のストリーミングモデルリクエストを送らなくなりました(多くのサードパーティプロバイダーが 401/403/WAF でブロックし、利用不可の誤検知を生むため)。代わりにプロバイダーの `base_url` へ軽量な HTTP 到達性プローブを 1 回行います: あらゆる HTTP レスポンスを到達可能とみなし、DNS / 接続 / TLS / タイムアウトのみを失敗とします。公式プロバイダー(OAuth を使用し、base_url が意図的に空で、信頼できる到達性ターゲットがない)は接続性チェックのボタンを隠します。従来の「本物のリクエストを送る」確認ダイアログ、テストモデル / プロンプトのフィールドは削除し、劣化レイテンシのしきい値を 6s、タイムアウトを 8s としました。この到達性チェックは決してサーキットブレーカーをリセットしません——到達可能 ≠ 利用可能(403 を返す host は到達可能でも、本物のトラフィックには壊れています)。フェイルオーバーの判定は引き続き本物のプロキシトラフィックのみで駆動されます。
### Codex の高度なオプション領域の統合
Codex プロバイダーフォームは、ローカルルーティング、モデルマッピング、推論オーバーライド、カスタム User-Agent を展開可能な高度なオプション領域に折りたたみ、Claude フォームと揃えました(UA が設定されているか、ローカルルーティングが有効なときは自動展開)。カスタム User-Agent はネイティブ Responses プロバイダーでも設定できるようになりました。以前は `openai_chat` ルーティングを有効にしたときにしか触れられませんでした。
### 使用量ツールバーとレイアウトの刷新
アプリフィルタはブランドアイコン(ProviderIcon 経由、「すべて」はグリッドアイコン)で描画するように変更し、狭いウィンドウで折り返すと見栄えの悪かったテキストタブを置き換えました。使用量 Hero も選択したアプリのブランドアイコンを表示し、Codex のテーマ色をエメラルドからニュートラルグレーへ変更して、OpenAI のモノクロブランドに合わせました。クリックで循環切り替えしていた更新ボタンは、ローカライズした「オフ」ラベルを持つドロップダウン選択に変更し、トップバーのコントロールも圧縮して幅のグループを揃え、長すぎる日付範囲のラベルは省略表示するようにしました。
### バージョン情報パネルの読み込みを高速化
設定の「バージョン情報」パネルが段階的に読み込むようになりました: アプリのバージョンバッジは解決した瞬間に表示され、ツールのプローブを待たなくなります。各ツールカードは自身のバージョン検出が完了した時点で即座に更新されます(プローブは直列ではなく並行に実行)。プローブ結果はアプリのセッション中、10 分の TTL 付きでキャッシュされるため、「バージョン情報」タブを再度開くとキャッシュ値を再利用し、期限切れの項目だけをバックグラウンドで再検証します。毎回 6 つのツールすべてを再プローブすることはなくなりました。
### 火山方舟 Coding Plan の宣伝更新
火山方舟(Volcengine Ark)プリセットを 6 アプリすべてで新しい Coding Plan 招待リンクへ更新し(旧 Agent Plan / キャンペーンリンクを置き換え)、4 言語でパートナー宣伝文を刷新しました(2 か月 75% 割引 + 招待コード 6J6FV5N2)。製品名も Agent Plan から Coding Plan へ訂正しました。
### MiniMax を通常プロバイダーへ降格
MiniMax の金色のパートナースター印と API key 宣伝バナーを削除し(全プリセットから `isPartner` フラグを除去)、引き続き通常の `cn_official` プロバイダーとしてアイコンとテーマを保持します。宣伝文は休眠状態のまま残し、必要であれば 1 行で提携関係を再有効化できます。
### LemonData を削除、SudoCode を降格
LemonData プロバイダープリセットを完全に削除し(宣伝文、アイコン、スポンサー項目もあわせて)、SudoCode をパートナーから通常の `third_party` プロバイダーへ降格しました(`isPartner` フラグと宣伝文を外し、アイコンは保持)。
### AtlasCloud Codex GLM 5.1 のコンテキストウィンドウ
AtlasCloud Codex プリセットの `zai-org/glm-5.1` モデルに 200,000 token のコンテキストウィンドウを宣言し、ほかの GLM 5.1 プリセット項目に揃えました。
---
## 修正
### ルーティングテイクオーバーのトラフィックを本物の上流モデルで課金
リクエストが別の上流へルーティングされた場合(env モデルマッピング、Claude Desktop ルーティング、Copilot 正規化、Codex chat オーバーライド)、プロキシは以前、上流が返したモデルで帰属・課金していたため、kimi / glm の token を `claude-*` として記録・課金し、コストが約 5〜25 倍に過大評価されていました。現在は転送器が本物のアウトバウンドモデルを捕捉し、「上流が返した値 → アウトバウンドモデル → クライアントのエイリアス」の順で帰属し、各行に実際に使用した課金根拠を永続化します(schema v11)。この根拠はコストのバックフィルと 30 日 rollup のプルーニングまで一貫して使われます。Claude Desktop のトラフィックも自身の `app_type` で記録されるようになり、その価格オーバーライドが正しく効くようになりました。
### 形式変換経路での使用量計上
プロキシの各形式変換経路(Chat、Responses、Gemini を Anthropic へ)での token / キャッシュの計上を監査・修正しました。プロキシは実際に返ったモデルを記録し、`stream_options.include_usage` を注入して OpenAI 互換の上流がストリーミング時に usage を吐くようにし、Claude←OpenAI 経路では `cache_read``cache_creation` を input から除外してキャッシュ token の二重計上を防ぎ、Gemini のキャッシュ済みプロンプト token を差し引き、完全にキャッシュヒットしたリクエストも引き続き記録し、過去にリクエスト数を水増ししていた合成の全ゼロ usage をスキップするようになりました([#2774](https://github.com/farion1231/cc-switch/pull/2774))。
### アプリ内更新がハングしなくなった
アプリ内から更新をインストールするとき、「再起動中」の画面でハングしなくなりました——以前は新版がインストール済みなのに、手動で強制終了せざるを得ない状況が起きていました。ダウンロード—インストール—再起動の一連の流れは、完全にバックエンドで実行するようになり(`install_update_and_restart` コマンドを新設)、プラットフォームごとにインストール順序を決め、再実行の前にまず単一インスタンスロックを破棄します。アプリパッケージがすでに置き換えられた後に古い WebView が JS を走らせ続けることに依存しなくなりました。終了リクエストも分類して、再起動リクエストが Tauri の既定フローに落ちるようにし、ウィンドウ状態プラグインのミューテックスでデッドロックしないようにしました([#4069](https://github.com/farion1231/cc-switch/pull/4069)、[#4074](https://github.com/farion1231/cc-switch/pull/4074))。
### Codex のアップグレードがインストールを壊さなくなった
設定の「バージョン情報」ページから Codex をアップグレードしても、「Missing optional dependency @openai/codex-…」エラーを投げなくなりました。アップグレードのチェーンは以前まず `codex update` を実行しますが、これは npm インストール下では実質的に素の再インストールであり、対応するプラットフォームのバイナリがインストールされていなくても成功を報告していました。現在は Codex を「self-update 優先」経路から除外し、runnable 検出が「アンインストール + 再インストール」の自己修復を駆動するようにしました(npm 管理のインストールに限定)。これが、欠けたプラットフォームバイナリを本当に補える唯一の修正です。
### テイクオーバー時に Codex OAuth 認証情報を保持
Codex プロバイダーでプロキシテイクオーバーを有効にするとき、`ANTHROPIC_AUTH_TOKEN` プレースホルダーを剥がさなくなりました——以前これはホットスイッチ、新規インストール、そして旧バージョンがすでに剥がしてしまった live 設定で、Claude Code のログインを壊していました。現在は管理対象(非 Copilot)の Codex プロバイダーに対して、URL のみのプロバイダーを含め無条件にこのプレースホルダーを注入します。GitHub Copilot の挙動(API_KEY のみ)は変わりません([#3789](https://github.com/farion1231/cc-switch/pull/3789)、[#3784](https://github.com/farion1231/cc-switch/issues/3784))。
### 設定ディレクトリをまたぐ切り替えでのテイクオーバー残留の復元
プロキシテイクオーバーが有効なときに設定ディレクトリを変更してアプリを再起動しても、Claude / Codex / Gemini を失効したローカルプロキシに向けたままにしなくなりました。現在は旧インスタンスが再起動の前にテイクオーバーされた live ファイルを先に復元し、初回実行のインポートはテイクオーバープレースホルダーをプロバイダーとして永続化することを拒否し、SSOT の復元も書き戻す前に、現在のプロバイダーの設定にプレースホルダーが含まれないことを検証します([#4076](https://github.com/farion1231/cc-switch/pull/4076))。
### 形式変換フォールバックでの誤ラベル SSE レスポンスの集約
Claude / Codex の形式変換を経たリクエストで、MaaS ゲートウェイが `stream:false` のリクエストを強制的にストリーミングし、非 SSE の Content-Type で SSE レスポンスボディを返したとき、難解な 422「Failed to parse upstream response」で失敗しなくなりました。プロキシは解析失敗時に SSE かどうかを検出し、チャンクを単一の JSON に集約してから既存の変換器を走らせ、クライアントが引き続き有効な非ストリーミングレスポンスを受け取れるようにします。残った解析失敗には content-type、エンコーディング、レスポンスボディの抜粋などの診断情報を付け、deflate のデコードも先に zlib を、次に素のストリームを試すように変更しました([#2234](https://github.com/farion1231/cc-switch/pull/2234))。
### Hermes 設定の重複 YAML キー
Hermes 設定の書き込みは、重複したトップレベルキー(`mcp_servers` など)を累積しなくなりました。これは「Failed to parse Hermes config as YAML: duplicate entry with key」エラーを引き起こしていました。セクションの置換は、追加へ退化する代わりに、残りのテキストから古いコピーをすべて取り除くようになりました。重複排除のセーフティネットは LF と CRLF の行末を両方処理します。修復時には最後(最新)のコピーを保持し、Hermes 自身の PyYAML ベースの「後勝ち」セマンティクスに揃えます([#3267](https://github.com/farion1231/cc-switch/pull/3267)、[#3633](https://github.com/farion1231/cc-switch/issues/3633)、[#2973](https://github.com/farion1231/cc-switch/issues/2973)、[#2529](https://github.com/farion1231/cc-switch/issues/2529)、[#3310](https://github.com/farion1231/cc-switch/issues/3310)、[#3762](https://github.com/farion1231/cc-switch/issues/3762))。
### 使用量照会の堅牢性とエラーの明確さ
使用量カードは、一度の瞬間的なゆらぎだけで赤くならなくなりました: 照会は一度再試行し、ネットワーク / タイムアウト / 5xx のような一時的な失敗下では前回成功した結果を最大 10 分間表示し続けます。一方、確定的な失敗(認証、空の key、未知のプロバイダー、4xx)は即座に表面化してスナップショットをクリアし、認証情報の変更後に古い残量が再び現れるのを防ぎます。ネイティブ残量 / Coding Plan / サブスクリプション照会のタイムアウトは、応答の遅い国際 endpoint に合わせて 10s から 15s へ引き上げました。Coding Plan も空白の失敗ではなく、明確な「API key is empty」/「Unknown coding plan provider」エラーを返すようになりました。
### 使用量スクリプトのプロバイダー認証情報の解決
カスタム JS スクリプトの使用量照会は、以前 env フィールドを推測して `{{apiKey}}` / `{{baseUrl}}` を解決していたため、認証情報を別の場所に置くアプリ(Codex の `auth.OPENAI_API_KEY` に加え `config.toml` の base_url など)は、プロバイダーが完全に設定されていても常に空の値となり失敗していました。スクリプト照会とそのテスト / プレビューは、ネイティブ残量経路と同じアプリごとの認証情報リゾルバを再利用するようになり、スクリプト内で明示的に記入した非空の値は引き続き優先されます([#1479](https://github.com/farion1231/cc-switch/pull/1479))。
### Claude Code Workflow サブ agent の使用量集計
ローカル(プロキシなし)のセッションログ使用量集計は、以前 Claude Code Workflow のサブ agent のトラフィックを取りこぼしており、全体の使用量が約 4.1% 過小評価されていました(workflow / subagent のセッションレコードに集中)。スキャナーはさらに一段深い `subagents/workflows/wf_*/` のレコードディレクトリまで掘り下げるようになり、パーサーも `stop_reason` を欠いているがすでに input / キャッシュ token のコストを生んだ assistant メッセージを捨てなくなりました。重複排除のロジックは変わらないため、重複計上はしません。
### Codex `/responses` のテキスト専用モデル向け画像整流
画像を伴い、テキストのみ対応の OpenAI-chat モデル(DeepSeek `deepseek-v4-flash` など)へルーティングされた Codex `/responses` リクエストが、HTTP 400「unknown variant `image_url`」で失敗しなくなりました。メディア整流器が Codex アダプターもカバーするようになり、responses の `input` 内の `input_image` ブロックをスキャンします。これにより、既知のテキスト専用モデルに対しては画像をプロアクティブに剥がし、上流が「画像非対応」を報告したときにも画像を置き換えて再試行できます。
### 智譜 Coding Plan のクォータウィンドウの誤ラベル
智譜 Coding Plan ビューは、各週周期の最後の数時間で 5 時間ウィンドウと週ウィンドウを取り違えてラベル付けしなくなりました。両ウィンドウは今や明示的な `unit` フィールドで分類され(3 = 5 時間、6 = 週)、リセット時刻の昇順ソートに頼らなくなりました——後者はユーザーが週クォータを最も照会するタイミングで、ちょうど両者を取り違えてラベル付けしていました。フィールドが欠けているときは引き続き従来のリセット時刻ヒューリスティックへフォールバックします([#3036](https://github.com/farion1231/cc-switch/pull/3036))。
### macOS の重複プロバイダーターミナルウィンドウ
macOS でプロバイダーターミナルを起動するとき、コマンドセッションの隣に空のウィンドウをもう 1 つ開かなくなりました。Terminal.app はコールドスタート時に `activate` ではなく `launch` を使い、Ghostty は初期コマンドを使うことで、単一のセッションだけを開きます。AppleScript 経路が失敗したときのフォールバックも残してあります([#4156](https://github.com/farion1231/cc-switch/pull/4156))。
### Claude Desktop モデルマッピングのプレースホルダー
Claude Desktop モデルマッピングフォームは、以前「メニュー表示名」と「リクエストモデル」の 2 列で一貫しないブランド例(DeepSeek vs Kimi)を使い、ある表示名が無関係なモデルへマッピングされるかのように見せていました。現在は両方のプレースホルダーが各行のロールから導かれ、ブランドの一貫性を保ち、軽量な Haiku 階層は flash の例を使います。
### ポップオーバーが全画面パネルに隠れる
プロバイダープリセット検索のようなポップオーバーやツールチップが、全画面パネルの後ろに描画されてクリックが効かないように見える問題がなくなりました。これらの z-index を全画面オーバーレイの上に引き上げつつ、モーダルダイアログよりは低く保ちました。
### ToggleRow アイコンの縮み
トグル行のアイコンは、長い説明と並んでも縮んだり歪んだりしなくなり、複数行のテキストの隣でアイコンが固定サイズを保ちます。
---
## ドキュメント
### Release Notes の貢献者謝辞の復元
v3.16.1 と v3.16.2 の release notes の貢献者謝辞を 3 言語で復元しました。
---
## アップグレード時の注意
### 価格ライブラリ schema v11 の自動移行
本リリースは `proxy_request_logs``pricing_model` 列を新設し、`request_model` + `pricing_model` で rollup を再構築しました。起動時に自動移行し、手動操作は不要です。過去の行のコストは書き込み時に確定済みで再計算されません(`app_type="claude"` の行はネイティブと変換の 2 種類のソースが混在しています)。本物だが当時課金されなかったテイクオーバー行のみがゼロコストのまま残り、価格が補われた後にバックフィルされます。
### モデルマッピングに 4 つ目の階層(Fable 5)を追加
Claude Code と Claude Desktop のモデルマッピングは 4 階層(Sonnet / Opus / Fable / Haiku)になりました。従来の 3 階層プロバイダーは、再度開いて保存すると `claude-fable-5` 階層が補われます。この階層を空のままにすると Sonnet を継承します。注意: サードパーティの endpoint でいずれかの階層を空のままにすると、その階層のモデル名がそのまま透過されて 404 になる可能性があるため、必要に応じて記入してください。
### 「Kimi For Coding」プリセットはプロキシテイクオーバー + ホワイトリスト UA が必要
復活した Codex「Kimi For Coding」プリセットは、既定の `codex-cli` User-Agent をそのまま使うと依然 403 になります。利用するには、プロキシテイクオーバーを有効にし、プロバイダーの高度なオプションでカスタム User-Agent をホワイトリストの UA(`claude-cli/*` など)に設定してください。
### プロバイダーヘルスチェックのセマンティクス変更
ヘルスチェックは「本物のモデルリクエストを送る」から「HTTP 到達性プローブ」へ変わりました。到達可能 ≠ 利用可能であることにご注意ください: 403 を返す host は到達可能でも、本物のトラフィックには壊れている可能性があります。フェイルオーバーの判定は引き続き本物のプロキシトラフィックのみで駆動され、ヘルスチェックの影響を受けません。
---
## リスク通知
本リリースは、リバースプロキシ系機能に関する以前のリスク通知を引き続き適用します。
**Codex OAuth リバースプロキシ**: ChatGPT サブスクリプションの Codex OAuth をリバースプロキシ経由で使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 release notes](v3.13.0-ja.md#-リスクに関する注意事項) を参照してください。
**Codex サードパーティプロバイダー Chat ルーティング**: CC Switch ローカルプロキシで Codex リクエストを変換し、サードパーティプロバイダーへ転送する場合、課金・コンプライアンス・データ保持に関する制約はプロバイダーごとに異なります。利用前に対象プロバイダーの利用規約を確認してください。
**Claude Desktop サードパーティプロバイダープロキシ切り替え**: CC Switch 内蔵のプロキシゲートウェイで Claude Desktop のリクエストをサードパーティプロバイダーへ転送する場合も、対象プロバイダーの課金・コンプライアンス・データ保持に関する規約に従う必要があります。
上記機能を有効化したユーザーは、関連するリスクを自ら負うものとします。CC Switch は、これらの機能の利用によって発生したアカウント制限、警告、サービス停止について責任を負いません。
---
## 謝辞
v3.16.3 で機能と修正を届けてくださった以下のコントリビューターに感謝します:
- [#3789](https://github.com/farion1231/cc-switch/pull/3789): テイクオーバー時に Codex OAuth 認証情報を保持、@codeasier に感謝。
- [#2774](https://github.com/farion1231/cc-switch/pull/2774): Completions を Anthropic へ変換する際に実際に返ったモデルを記録せず input token の計算が誤っていた問題を修正、@LaoYueHanNi に感謝。
- [#4069](https://github.com/farion1231/cc-switch/pull/4069): アプリ内更新後の再起動デッドロックを修正、@thisTom に感謝。
- [#4156](https://github.com/farion1231/cc-switch/pull/4156): macOS の重複プロバイダーターミナルウィンドウを修正、@thisTom に感謝。
- [#3267](https://github.com/farion1231/cc-switch/pull/3267): Hermes 設定の重複 YAML キーを修正、@que3sui に感謝。
- [#1479](https://github.com/farion1231/cc-switch/pull/1479): 使用量スクリプトのプロバイダー認証情報の解決を修正、@pa001024 に感謝。
- [#3975](https://github.com/farion1231/cc-switch/pull/3975): プリセットプロバイダーの検索とソートを追加、@Nastem に感謝。
- [#4183](https://github.com/farion1231/cc-switch/pull/4183): プリセットプロバイダーボタンの外観と検索ボックスの位置を調整、@WangJiati に感謝。
- [#4077](https://github.com/farion1231/cc-switch/pull/4077): claude-mythos-5 モデルの価格を追加、@osscv に感謝。
v3.16.2 リリース後に使用量の課金、ローカルプロキシの堅牢性、Codex のアップグレード、プラットフォーム互換性の問題を報告してくださったすべてのユーザーにも感謝します。今回の多くの修正は、実際の利用シーンから得られた再現情報に基づいています。
---
## ダウンロードとインストール
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から、お使いのシステムに対応するビルドをダウンロードしてください。
### システム要件
| システム | 最低バージョン | アーキテクチャ |
| -------- | ------------------------ | ----------------------------------- |
| Windows | Windows 10 以降 | x64 |
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 下表を参照 | x64 / ARM64 |
### Windows
| ファイル | 説明 |
| ---------------------------------------- | -------------------------------------------- |
| `CC-Switch-v3.16.3-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー |
| `CC-Switch-v3.16.3-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます |
### macOS
| ファイル | 説明 |
| -------------------------------- | ------------------------------------------------------ |
| `CC-Switch-v3.16.3-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ |
| `CC-Switch-v3.16.3-macOS.zip` | 展開して Applications へドラッグ、Universal Binary |
| `CC-Switch-v3.16.3-macOS.tar.gz` | Homebrew インストールと自動更新用 |
Homebrew インストール:
```bash
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
Linux アセットは **x86_64****ARM64**`aarch64`)の両方を提供します。ファイル名にアーキテクチャ識別子が含まれているため、マシンの `uname -m` 出力に合わせて選択してください:
- `CC-Switch-v3.16.3-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- `CC-Switch-v3.16.3-Linux-arm64.AppImage` / `.deb` / `.rpm`
| ディストリビューション | 推奨形式 | インストール方法 |
| --------------------------------------- | ----------- | ------------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接起動、または AUR を使用 |
| その他 / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+339
View File
@@ -0,0 +1,339 @@
# CC Switch v3.16.3
> 🎉 **CC Switch 突破 100,000 Star**
> 感谢每一位用户、贡献者与 Star —— 是你们让它走到这里。🙏
> 💎 **本版由 Claude Fable 5 模型协助开发**——它帮忙梳理清楚了多处关键且容易出错的逻辑:路由接管时按真实上游模型计费的归因链、格式转换路径上缓存 token 的计量与去重、应用内更新的重启死锁,以及 Codex 统一会话历史的迁移 / 还原不变量。这也是本版在「关于」页新增 **Fable 5 Verified** 标识的由来。
> 在 v3.16.2 拓宽数据可携带性与用量观测之后,这一版把重心放在「让用量计费真正准确」——按真实上游模型计费、修正格式转换路径上的缓存双算、把 Claude Code Workflow 子 agent 的用量纳入统计(schema v11),并对用量看板做了一轮改版(全局供应商 / 模型筛选、品牌图标工具栏、更稳的额度查询);同时加固了一批本地代理与平台问题,新增自定义 User-Agent 覆盖、Codex 统一会话历史开关与 Claude Fable 5 档位。
**[English →](v3.16.3-en.md) | [日本語版 →](v3.16.3-ja.md)**
---
## 使用攻略
这一版用量统计的口径和看板做了较多调整,建议先看:
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:了解用量看板的数据来源(代理日志、会话同步)与统计口径,本版新增了全局的供应商 / 模型筛选,并把路由接管的真实计价模型展示了出来。
- **[设置](../user-manual/zh/1-getting-started/1.5-settings.md)**:自定义 User-Agent 覆盖、Codex 统一会话历史等开关都在供应商表单的高级选项与设置页里。
---
> [!WARNING]
>
> ## 唯一官方渠道声明(请务必阅读)
>
> CC Switch 是**完全免费、开源**的桌面应用,**不会向用户收取任何费用**。请仅通过下列官方渠道获取本软件:
>
> | 类别 | 唯一官方 |
> | -------- | ------------------------------------------------------------------------------ |
> | 官网 | **[ccswitch.io](https://ccswitch.io)** |
> | 源码 | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
> | 下载 | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
> | 举报山寨 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
>
> **任何向你收费、要求充值、或索取登录凭据的"CC Switch"网站或客户端均为假冒**。如果你被诱导支付了费用,请立即停止操作并通过 GitHub Issues 反馈。
---
## 概览
CC Switch v3.16.3 是 v3.16.2 之后的一版维护更新。在上一版集中拓宽数据可携带性与用量观测之后,这一版把重心放在「让用量计费真正准确」这件事上——按真实上游模型计费而非上游回显、修正格式转换(Chat / Responses / Gemini 转 Anthropic)路径上的缓存 token 双算、把 Claude Code Workflow 子 agent 的用量纳入本地统计,并以 schema v11 持久化每条记录实际使用的定价依据;用量看板也随之做了一轮改版,新增全局的供应商 / 模型筛选、品牌图标工具栏,以及更稳的额度查询(失败重试 + 保留上次成功结果)。
此外,本版还加固了一批本地代理的稳健性问题(错标 Content-Type 的 SSE 响应聚合、Codex `/responses` 文本模型图像整流、Codex OAuth 凭据与接管残留的恢复、Hermes 配置重复 YAML 键),重做了供应商配置体验(自定义 User-Agent 覆盖、Codex 表单统一进高级选项、预设搜索与排序、Claude Fable 5 档位),新增 Codex 统一会话历史开关,并修复了应用内更新卡死、Codex 升级损坏安装、macOS 重复终端窗口等问题。
**发布日期**2026-06-14
**更新规模**59 commits | 130 files changed | +10,223 / -4,232 lines
---
## 重点内容
- **用量计费更准**:路由接管的流量现在按真实上游模型计费(而非上游回显的别名),格式转换路径不再把缓存 token 重复计入 inputClaude Code Workflow 子 agent 的用量也纳入了统计——以 schema v11 持久化定价依据。
- **用量看板改版**:供应商 / 模型筛选从请求日志表提升为全局筛选,应用筛选改用品牌图标,额度查询加入失败重试与「保留上次成功结果」,单次网络抖动不再让卡片变红。
- **自定义 User-Agent 覆盖**:供应商可设置自定义 UA,并在转发、连通性检测、模型列表三处一致生效,绕过按 UA 白名单放行的 Coding Plan 上游(借此恢复了 Codex「Kimi For Coding」预设)。
- **Codex 统一会话历史**:新增可选开关,让官方 Codex 会话与第三方会话共享同一份 resume 历史桶,附带可选的存量迁移与按账本精确还原。
- **代理与平台加固**:错标 SSE 响应聚合、Codex 图像整流、接管残留恢复、Hermes YAML 去重;应用内更新不再卡在「重启中」,Codex 升级不再把安装弄坏。
---
## 新功能
### 自定义 User-Agent 覆盖
供应商配置现在可以设置自定义 User-Agent,并由代理在请求转发、连通性检测和模型列表(`GET /v1/models`)三条路径上一致应用,因此按 UA 白名单放行的 Coding Plan 上游不会再出现「检测失败 / 模型列表 403、但代理本身却能正常工作」的不一致。Claude 和 Codex 表单都在高级选项里暴露该字段,配有精选的 UA 预设下拉(Claude Code / Kilo Code 等能通过 UA 白名单的家族)和实时、非阻塞的格式校验;切换到官方预设时会丢弃残留的自定义 UA,避免悄悄改动请求头([#3671](https://github.com/farion1231/cc-switch/pull/3671))。
### Codex 统一会话历史
新增一个可选开关(设置 → Codex 应用增强),让官方 Codex 会话与 CC Switch 的第三方会话共享同一份 resume 历史桶,resume 选择器不再把两者互相隐藏。开启后,live 的 `config.toml` 会把官方运行路由到一个镜像内建 OpenAI 供应商的共享 `custom` model_provider`auth.json` 不动)。默认只对未来会话生效;开启弹窗提供一个勾选项,可把已有官方会话迁入共享桶(含逐代备份),关闭弹窗则提供按备份账本精确还原——只回退备份中记录为 `openai` 的会话,开启期间新建的会话永不被改动。
### 用量看板全局供应商 / 模型筛选
供应商和模型筛选从请求日志表内部提升到了顶栏,对 Hero 汇总、趋势图、请求日志和两个统计页签全局生效,可以把整个看板按某个来源和模型缩小范围。来源按展示名精确匹配(因此像「Claude (Session)」这样的会话占位行也可选),模型按有效计价模型匹配,模型下拉会随所选来源级联,且两个列表只列出当前时间范围内有数据的选项。
### 模型定价种子刷新
`seed_model_pricing` 做了一次全量核价:新增 9 个模型的定价(含 Claude Fable 5、Grok 4.3、Mistral Medium 3.5 / Small 4、Qwen 3.7 Max/Plus 等),并按各厂商官方 list 价订正了 28 处既有价格(GLM、Grok、MiMo、Doubao、Kimi、MiniMax、Mistral、Qwen),让用量成本估算更准确。每处改动都同时更新种子(影响全新安装)并向 `repair_current_model_pricing` 加一条旧→新守卫(修复存量数据库,且不覆盖用户手改过的行)。
### Claude Fable 5 模型档位
供应商表单现在在 Claude Code 和 Claude Desktop 两条代理路径上都暴露 `claude-fable-5` 作为第四个模型映射档位,回落链为 fable → opus → default,与官方降级一致,并为 Claude Desktop 1.12603.1+ 的校验器放行了 `fable-` 前缀。四语回落提示也做了澄清:在第三方端点上把某一档留空,会原样透传该档的字面模型名并 404([#3980](https://github.com/farion1231/cc-switch/issues/3980)、[#4026](https://github.com/farion1231/cc-switch/issues/4026)、[#4049](https://github.com/farion1231/cc-switch/issues/4049))。
### Unity2.ai 合作伙伴供应商
新增 Unity2.ai(一个 AI API 中转合作伙伴)作为预设,覆盖全部 7 个受管应用(Claude Code、Codex、Gemini、OpenCode、OpenClaw、Claude Desktop、Hermes),每个预设都带上推广注册链接,并在四种语言里补充了合作伙伴推广文案。Codex 使用裸 base URL(该网关在根路径暴露 `/responses`),OpenCode / OpenClaw / Hermes 使用 `/v1` chat-completions 端点并以 `gpt-5.5` 为预设模型。
### Kimi K2.7 Code 模型
新增 `kimi-k2.7-code` 模型(输入 $0.95 / 输出 $4.00 / 缓存读取 $0.19,每百万 token,256K 上下文),并把全部 6 个官方 Moonshot Kimi 预设(Claude Code、Codex、Claude Desktop、Hermes、OpenCode、OpenClaw)指向它,OpenCode / OpenClaw 预设更名为「Kimi K2.7 Code」。定价种子通过启动时的幂等插入路径生效,存量用户无需迁移即可获得新价。
### 恢复 Codex「Kimi For Coding」预设
重新加入 Codex「Kimi For Coding」预设(`openai_chat``kimi-for-coding`、256K 上下文),默认开启思考模式。此前它被移除是因为该编程端点会以 403 拒绝 Codex 默认的 `codex-cli` User-Agent;现在借助代理接管 + 自定义 User-Agent 覆盖(设为 `claude-cli/*` 等白名单 UA)即可正常使用。
### 请求详情的计价模型审计
请求详情面板现在会在「请求的模型」「计价模型」与响应模型不一致时把它们都显示出来,让路由接管产生的账单可以直接在用量界面里核对。
### 预设供应商搜索与排序
预设供应商选择器现在是一个可搜索、可排序的列表,配有内联搜索框(点放大镜图标切换,按 ESC 或点击外部收起)。按钮改为响应式网格、尺寸统一并显示默认图标,搜索只匹配供应商的展示名 / 原始名,因此 URL 片段和共享的分类标签不会再产生噪声匹配([#3975](https://github.com/farion1231/cc-switch/pull/3975)、[#4183](https://github.com/farion1231/cc-switch/pull/4183))。
### Claude Mythos 5 定价
在内置模型 / 定价表里登记 `claude-mythos-5` 模型(输入 $10 / 输出 $50,每百万 token;缓存读取 $1.00、缓存写入 $12.50),让用量统计能正确计价并展示([#4077](https://github.com/farion1231/cc-switch/pull/4077))。
### Fable 5 Verified 标识
设置「关于」页现在会在应用名与版本旁展示 Fable 5 Verified 标识,标明这是一个特别构建,版本徽标也居中到了应用名下方。
---
## 变更
### Claude Desktop 用量折叠进 Claude
看板不再展示独立的「Claude Desktop」分桶——它一直只能显示一个不完整的数字(Desktop 聊天用量根本不经过代理,而其 Code 页签的会话只是内嵌的 Claude Code 运行时写进共享的 `~/.claude/projects` 目录)。Desktop 的代理流量现在在展示上折叠进 `claude`,但记账层仍按它自己的 `app_type` 记录以便路由接管计费审计,真实值可在请求详情面板看到。
### 轻量化供应商健康检查
供应商健康检查不再发送真实的流式模型请求(很多第三方供应商会以 401/403/WAF 拦截,造成误报不可用),改为对供应商 `base_url` 做一次轻量的 HTTP 可达性探测:任何 HTTP 响应都视为可达,只有 DNS / 连接 / TLS / 超时才算失败。官方供应商(使用 OAuth、base_url 故意为空、没有可靠的可达性目标)会隐藏连通性按钮,原先「发送真实请求」的确认弹窗以及测试模型 / 提示词字段都被移除,降级延迟阈值设为 6s、超时 8s。该可达性检查永不重置熔断器——可达不等于可用(403 的 host 可达,但对真实流量是坏的),失败转移仍只由真实代理流量驱动。
### Codex 高级选项区整合
Codex 供应商表单现在把本地路由、模型映射、推理覆盖和自定义 User-Agent 折叠进一个可展开的高级选项区,与 Claude 表单一致(设置了 UA 或开启本地路由时自动展开)。自定义 User-Agent 现在对原生 Responses 供应商也可配置,此前它只有在开启 `openai_chat` 路由时才能触及。
### 用量工具栏与布局刷新
应用筛选改用品牌图标(经 ProviderIcon,「全部」用网格图标)渲染,取代在窄窗口下换行难看的文字页签;用量 Hero 也会显示所选应用的品牌图标,并把 Codex 的主题色从翠绿改为中性灰,贴合 OpenAI 的单色品牌。点击循环切换的刷新按钮改成了带本地化「关闭」标签的下拉选择,顶栏控件也压缩并对齐成统一的宽度分组,过长的日期范围标签做了截断处理。
### 关于面板加载更快
设置「关于」面板现在渐进式加载:应用版本徽标在解析完成的瞬间就显示,不再等待工具探测;每张工具卡片在自己的版本检测完成时立即更新(探测并发执行而非串行);探测结果在应用会话期内缓存并带 10 分钟 TTL,因此再次打开「关于」页签会复用缓存值、并在后台对过期项重新校验,而不是每次都把 6 个工具全部重探一遍。
### 火山方舟 Coding Plan 推广更新
把火山方舟(Volcengine Ark)预设在全部 6 个应用里更新到新的 Coding Plan 邀请链接(替换旧的 Agent Plan / 活动链接),并在四种语言里刷新了合作伙伴推广文案(两个月 75% 折扣 + 邀请码 6J6FV5N2),把产品名从 Agent Plan 订正为 Coding Plan。
### MiniMax 降为普通供应商
移除 MiniMax 的金色合作伙伴星标和 API key 推广横幅(从所有预设里删掉 `isPartner` 标志),它继续作为常规 `cn_official` 供应商保留图标与主题。推广文案保持休眠状态,必要时一行即可重新启用合作关系。
### 移除 LemonData、SudoCode 降级
彻底移除 LemonData 供应商预设(连同其推广文案、图标和赞助商条目),并把 SudoCode 从合作伙伴降为常规 `third_party` 供应商(去掉 `isPartner` 标志和推广文案,保留图标)。
### AtlasCloud Codex GLM 5.1 上下文窗口
为 AtlasCloud Codex 预设里的 `zai-org/glm-5.1` 模型声明 200,000 token 的上下文窗口,与其他 GLM 5.1 预设条目对齐。
---
## 修复
### 路由接管流量按真实上游模型计费
当请求被路由到了不同的上游(env 模型映射、Claude Desktop 路由、Copilot 归一化、Codex chat 覆盖)时,代理过去会按上游回显的模型来归因和计价,把 kimi / glm 的 token 记成、并按 `claude-*` 计价,成本被高估约 5–25 倍。现在转发器会捕获真实的出站模型,按「上游回显 → 出站模型 → 客户端别名」的顺序归因,并在每行持久化实际使用的定价依据(schema v11),该依据会贯穿成本回填和 30 天 rollup 裁剪;Claude Desktop 流量现在也记在它自己的 `app_type` 下,使其定价覆盖能正确生效。
### 格式转换路径的用量计量
审计并修复了代理各条格式转换路径(Chat、Responses、Gemini 转 Anthropic)上的 token / 缓存计量。代理现在会记录实际返回的模型,注入 `stream_options.include_usage` 让 OpenAI 兼容上游在流式时吐出 usage,在 Claude←OpenAI 路径上把 `cache_read``cache_creation` 从 input 中排除以阻止缓存 token 双计费,扣减 Gemini 的缓存提示 token,仍记录完全命中缓存的请求,并跳过过去会虚增请求数的合成全零 usage([#2774](https://github.com/farion1231/cc-switch/pull/2774))。
### 应用内更新不再卡死
从应用内安装更新时不再卡在「重启中」界面——过去会出现新版已装好、却必须手动强制退出的情况。下载—安装—重启整条链路现在完全在后端执行(新增 `install_update_and_restart` 命令),按平台决定安装顺序,并在重新执行前先销毁单实例锁,而不再依赖旧 WebView 在应用包已被替换之后继续跑 JS;退出请求也做了分类,让重启请求落到 Tauri 默认流程,而不是在窗口状态插件的互斥锁上死锁([#4069](https://github.com/farion1231/cc-switch/pull/4069)、[#4074](https://github.com/farion1231/cc-switch/pull/4074))。
### Codex 升级不再损坏安装
从设置「关于」页升级 Codex 不再让它抛出「Missing optional dependency @openai/codex-…」错误。升级链此前会先跑 `codex update`,而它在 npm 安装下其实是一次裸的重装、即便对应平台的二进制没装上也会报告成功;现在 Codex 已从「优先 self-update」路径里移除,并由一个 runnable 检测触发「卸载 + 重装」自愈(仅限 npm 管理的安装),这是唯一能真正补回缺失平台二进制的修复。
### 接管时保留 Codex OAuth 凭据
为 Codex 供应商开启代理接管时不再剥掉 `ANTHROPIC_AUTH_TOKEN` 占位符——此前这会在热切换、全新安装、以及被旧版本已剥过的 live 配置上破坏 Claude Code 的登录。现在对受管(非 Copilot)的 Codex 供应商无条件注入该占位符,包括只有 URL 的供应商;GitHub Copilot 的行为(仅 API_KEY)不变([#3789](https://github.com/farion1231/cc-switch/pull/3789)、[#3784](https://github.com/farion1231/cc-switch/issues/3784))。
### 跨配置目录切换的接管残留恢复
在代理接管激活时更改配置目录后重启应用,不再把 Claude / Codex / Gemini 留在指向已失效的本地代理上。现在旧实例会在重启前先还原被接管的 live 文件,首次运行的导入会拒绝把接管占位符当作供应商持久化,SSOT 还原也会在写回前校验当前供应商的配置里不含占位符([#4076](https://github.com/farion1231/cc-switch/pull/4076))。
### 格式转换兜底里错标的 SSE 响应聚合
经 Claude / Codex 格式转换的请求,当 MaaS 网关把一个 `stream:false` 的请求强制流式、并以非 SSE 的 Content-Type 返回 SSE 响应体时,不再以一句晦涩的 422「Failed to parse upstream response」失败。代理现在会在解析失败时嗅探 SSE、把分片聚合成单个 JSON 再跑既有转换器,让客户端仍能拿到有效的非流式响应;剩余的解析失败会附带 content-type、编码和响应体片段等诊断信息,deflate 解码也改为先尝试 zlib 再尝试裸流([#2234](https://github.com/farion1231/cc-switch/pull/2234))。
### Hermes 配置重复 YAML 键
Hermes 配置写入不再累积重复的顶层键(如 `mcp_servers`),那会导致「Failed to parse Hermes config as YAML: duplicate entry with key」错误。区段替换现在会从剩余文本里清除所有过期副本,而不是退化成追加;去重保护层同时处理 LF 和 CRLF 行尾;修复时保留最后(最新)的那份副本,与 Hermes 自身基于 PyYAML 的「后者胜」语义一致([#3267](https://github.com/farion1231/cc-switch/pull/3267)、[#3633](https://github.com/farion1231/cc-switch/issues/3633)、[#2973](https://github.com/farion1231/cc-switch/issues/2973)、[#2529](https://github.com/farion1231/cc-switch/issues/2529)、[#3310](https://github.com/farion1231/cc-switch/issues/3310)、[#3762](https://github.com/farion1231/cc-switch/issues/3762))。
### 用量查询韧性与错误清晰度
用量卡片不再因为单次瞬时抖动就变红:查询现在会重试一次,并在网络 / 超时 / 5xx 这类瞬时失败下继续展示上次成功的结果最多 10 分钟;而确定性失败(鉴权、空 key、未知供应商、4xx)会立即暴露并清空快照,避免凭据变更后陈旧额度又冒出来。原生余额 / Coding Plan / 订阅查询的超时从 10s 提高到 15s 以适配跨境慢端点,Coding Plan 也会返回明确的「API key is empty」/「Unknown coding plan provider」错误,而不是一句空白的失败。
### 用量脚本供应商凭据解析
自定义 JS 脚本的用量查询此前只靠猜测 env 字段来解析 `{{apiKey}}` / `{{baseUrl}}`,因此凭据存放在别处的应用(如 Codex 的 `auth.OPENAI_API_KEY``config.toml` 里的 base_url)总是拿到空值、即便供应商已完整配置也会失败。脚本查询及其测试 / 预览现在复用与原生余额路径相同的按应用凭据解析器,脚本里显式填写的非空值仍然优先([#1479](https://github.com/farion1231/cc-switch/pull/1479))。
### Claude Code Workflow 子 agent 用量统计
本地(无代理)的会话日志用量统计此前漏掉了 Claude Code Workflow 子 agent 的流量,整体用量被低估约 4.1%(集中在 workflow / subagent 的会话记录里)。扫描器现在会深入更深一层的 `subagents/workflows/wf_*/` 记录目录,解析器也不再丢弃那些缺少 `stop_reason`、但已经产生 input / 缓存 token 成本的 assistant 消息;去重逻辑不变,因此不会重复计数。
### Codex `/responses` 文本模型图像整流
携带图片、且被路由到只支持文本的 OpenAI-chat 模型(如 DeepSeek `deepseek-v4-flash`)的 Codex `/responses` 请求,不再以 HTTP 400「unknown variant `image_url`」失败。媒体整流器现在也覆盖 Codex 适配器,会扫描 responses 的 `input` 里的 `input_image` 块,从而既能为已知的纯文本模型主动剥掉图片,也能在上游报「不支持图片」时把图片替换后重试。
### 智谱 Coding Plan 配额窗口误标
智谱 Coding Plan 视图不再在每个周周期的最后几个小时把 5 小时窗口和周窗口标反。两个窗口现在按显式的 `unit` 字段分类(3 = 5 小时、6 = 周),而不再靠按重置时间升序排序——后者恰好在用户最常查周额度的时候把两者标反;当字段缺失时仍回退到旧的重置时间启发式([#3036](https://github.com/farion1231/cc-switch/pull/3036))。
### macOS 重复供应商终端窗口
在 macOS 上启动供应商终端时不再在命令会话旁多开一个空窗口;Terminal.app 在冷启动时改用 `launch`(而非 `activate`),Ghostty 使用初始命令,从而只打开单个会话,并在 AppleScript 路径失败时保留回退方案([#4156](https://github.com/farion1231/cc-switch/pull/4156))。
### Claude Desktop 模型映射占位符
Claude Desktop 模型映射表单此前在「菜单展示名」和「请求模型」两列用了不一致的示例品牌(DeepSeek vs Kimi),暗示一个展示名会映射到不相关的模型。现在两个占位符都由每行的角色派生,从而保持品牌一致,轻量的 Haiku 档使用 flash 示例。
### 弹层被全屏面板遮挡
像供应商预设搜索这样的弹层和提示气泡不再渲染到全屏面板后面、看起来点了没反应;它们的 z-index 被提到全屏遮罩之上,同时仍低于模态对话框。
### ToggleRow 图标被挤压
开关行的图标在配上长描述时不再被压缩或变形,让图标在多行文字旁保持固定大小。
---
## 文档
### Release Notes 贡献者致谢恢复
恢复了 v3.16.1 与 v3.16.2 release notes 在三种语言里的贡献者致谢。
---
## 升级提醒
### 定价库 schema v11 自动迁移
本版给 `proxy_request_logs` 新增了 `pricing_model` 列、并按 `request_model` + `pricing_model` 重建了 rollup,启动时自动迁移、无需手动操作。历史行的成本在写入时已冻结、不会重算(`app_type="claude"` 的行混合了原生与转换两类来源);只有真实但当时未计价的接管行会保持零成本、待定价补齐后再回填。
### 模型映射新增第四档(Fable 5)
Claude Code 与 Claude Desktop 的模型映射现在是四档(Sonnet / Opus / Fable / Haiku)。老的三档供应商在重新打开并保存后会补上 `claude-fable-5` 档;该档留空表示继承 Sonnet。注意:在第三方端点上把任意一档留空,会原样透传该档的字面模型名并可能 404,请按需填写。
### 「Kimi For Coding」预设需要代理接管 + 白名单 UA
恢复的 Codex「Kimi For Coding」预设直接用默认的 `codex-cli` User-Agent 仍会被 403。要使用它,请开启代理接管,并在供应商高级选项里把自定义 User-Agent 设为白名单 UA(如 `claude-cli/*`)。
### 供应商健康检查语义变化
健康检查从「发送真实模型请求」改为「HTTP 可达性探测」。请注意可达 ≠ 可用:一个返回 403 的 host 是可达的,但对真实流量可能是坏的。失败转移的判定仍只由真实代理流量驱动,不受健康检查影响。
---
## 风险提示
本版本继续沿用此前版本对反向代理类功能的风险提示。
**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#-风险提示)。
**Codex 第三方供应商 Chat 路由**:通过 CC Switch 本地代理把 Codex 请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。
**Claude Desktop 第三方供应商代理切换**:通过 CC Switch 内置代理网关把 Claude Desktop 的请求转到第三方供应商时,同样需要遵守目标供应商的计费、合规与数据留存约束。
用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
---
## 致谢
感谢以下贡献者在 v3.16.3 中提交的功能与修复:
- [#3789](https://github.com/farion1231/cc-switch/pull/3789):接管时保留 Codex OAuth 凭据,感谢 @codeasier
- [#2774](https://github.com/farion1231/cc-switch/pull/2774):修复 Completions 转 Anthropic 时不记录实际返回模型、input token 计算错误,感谢 @LaoYueHanNi
- [#4069](https://github.com/farion1231/cc-switch/pull/4069):修复应用内更新后重启死锁,感谢 @thisTom
- [#4156](https://github.com/farion1231/cc-switch/pull/4156):修复 macOS 重复供应商终端窗口,感谢 @thisTom
- [#3267](https://github.com/farion1231/cc-switch/pull/3267):修复 Hermes 配置重复 YAML 键,感谢 @que3sui
- [#1479](https://github.com/farion1231/cc-switch/pull/1479):修复用量脚本供应商凭据解析,感谢 @pa001024
- [#3975](https://github.com/farion1231/cc-switch/pull/3975):新增预设供应商搜索与排序,感谢 @Nastem
- [#4183](https://github.com/farion1231/cc-switch/pull/4183):调整预设供应商按钮外观与搜索框位置,感谢 @WangJiati
- [#4077](https://github.com/farion1231/cc-switch/pull/4077):新增 claude-mythos-5 模型定价,感谢 @osscv
也感谢所有在 v3.16.2 发布后反馈用量计费、本地代理稳健性、Codex 升级与平台兼容性问题的用户,很多补丁都来自这些真实使用场景里的复现线索。
---
## 下载与安装
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | -------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 |
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 / ARM64 |
### Windows
| 文件 | 说明 |
| ---------------------------------------- | ----------------------------------- |
| `CC-Switch-v3.16.3-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.16.3-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
### macOS
| 文件 | 说明 |
| -------------------------------- | --------------------------------------------- |
| `CC-Switch-v3.16.3-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
| `CC-Switch-v3.16.3-macOS.zip` | 解压后拖入 ApplicationsUniversal Binary |
| `CC-Switch-v3.16.3-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
Homebrew 安装:
```bash
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
Linux 资产同时提供 **x86_64****ARM64**`aarch64`)两种架构。资产文件名中包含架构标识,请按你机器的 `uname -m` 输出选择对应版本:
- `CC-Switch-v3.16.3-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- `CC-Switch-v3.16.3-Linux-arm64.AppImage` / `.deb` / `.rpm`
| 发行版 | 推荐格式 | 安装方式 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb``sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm``sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+1 -1
View File
@@ -88,7 +88,7 @@ Codex presets fall into two groups by upstream protocol.
|-------------|-------------|
| DeepSeek | DeepSeek models |
| Zhipu GLM / GLM en | Zhipu AI GLM models |
| Kimi | Moonshot Kimi models |
| Kimi / Kimi For Coding | Moonshot Kimi models |
| MiniMax / MiniMax en | MiniMax models |
| StepFun / StepFun en | StepFun Step models |
| Baidu Qianfan Coding Plan | Baidu Qianfan coding plan |
+1 -1
View File
@@ -88,7 +88,7 @@ Codex プリセットは上流プロトコルにより 2 種類に分かれま
|----------|------|
| DeepSeek | DeepSeek モデル |
| Zhipu GLM / GLM en | Zhipu AI の GLM モデル |
| Kimi | Moonshot Kimi モデル |
| Kimi / Kimi For Coding | Moonshot Kimi モデル |
| MiniMax / MiniMax en | MiniMax モデル |
| StepFun / StepFun en | StepFun Step モデル |
| Baidu Qianfan Coding Plan | 百度千帆コーディングプラン |
+1 -1
View File
@@ -88,7 +88,7 @@ Codex 预设按上游协议分两类。
|----------|------|
| DeepSeek | DeepSeek 模型 |
| 智谱 GLM / GLM en | 智谱 AI 的 GLM 模型 |
| Kimi | Moonshot Kimi 模型 |
| Kimi / Kimi For Coding | Moonshot Kimi 模型 |
| MiniMax / MiniMax en | MiniMax 模型 |
| StepFun / StepFun en | 阶跃星辰 Step 模型 |
| 百度千帆 Coding Plan | 百度千帆编程套餐 |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.16.2",
"version": "3.16.3",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
+1 -1
View File
@@ -735,7 +735,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.16.2"
version = "3.16.3"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.16.2"
version = "3.16.3"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
+159 -9
View File
@@ -60,6 +60,16 @@ pub const DEFAULT_PROXY_ROUTES: &[ClaudeDesktopDefaultRoute] = &[
env_key: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
supports_1m: true,
},
// fable 置于末尾:next_catalog_safe_route_id 给非安全品牌 route 借用合法
// 角色名时仍按 sonnet→opus→haiku 顺序分配(向后兼容既有 catalog),不会把
// 无关品牌模型借用成 fable 顶配档名。UI 行序由前端 ROLE_ORDER 独立控制为
// Sonnet/Opus/Fable/Haiku(所有 proxy 路径都经 normalizeProxyRows 重排),
// 与此处物理顺序无关。
ClaudeDesktopDefaultRoute {
route_id: "claude-fable-5",
env_key: "ANTHROPIC_DEFAULT_FABLE_MODEL",
supports_1m: true,
},
];
#[derive(Debug, Clone)]
@@ -238,11 +248,16 @@ pub fn is_claude_safe_model_id(model: &str) -> bool {
// 角色前缀后必须还有实际模型标识,拒绝 claude-sonnet- 这类退化值
// (否则会写入 profile 并触发 Claude Desktop fail-all 拒收整组)。
["sonnet-", "opus-", "haiku-"].iter().any(|prefix| {
route_tail
.strip_prefix(prefix)
.is_some_and(|rest| !rest.is_empty())
})
// Claude Desktop 1.12603.1+ 的 fail-all validator 角色白名单已纳入 fable
// app.asar 内 ["sonnet","opus","haiku","fable","mythos"]),故 claude-fable-*
// 可安全写入 profile。mythos 官方未公开发布,暂不暴露给用户。
["sonnet-", "opus-", "haiku-", "fable-"]
.iter()
.any(|prefix| {
route_tail
.strip_prefix(prefix)
.is_some_and(|rest| !rest.is_empty())
})
}
fn inference_model_json(spec: &InferenceModelSpec) -> Value {
@@ -693,8 +708,8 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
.or_else(|| {
// 角色关键词回落:Claude Desktop 的部分调用(如子 agent)会请求带发布
// 日期后缀的完整官方名(claude-haiku-4-5-20251001),与 manifest 暴露的
// 简短 route_id(claude-haiku-4-5)不精确相等。按 opus/haiku/sonnet 归类
// 到同档已配置路由,对齐 Claude Code model_mapper 的宽松匹配。
// 简短 route_id(claude-haiku-4-5)不精确相等。按 opus/haiku/fable/sonnet
// 归类到同档已配置路由,对齐 Claude Code model_mapper 的宽松匹配。
// 匹配前已剥离本地 [1m] 标记;这里仍只对 Claude Desktop 认可的
// 安全模型名回落,避免非 Claude route 被误映射。
if !is_claude_safe_model_id(requested) {
@@ -704,6 +719,18 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
routes
.iter()
.find(|route| claude_role_keyword(&route.route_id) == Some(role))
// 老用户只配了 Sonnet/Opus/Haiku 三档时,fable 请求降级到 opus 档,
// 与官方安全分类器的降级方向一致,避免 route_unknown 硬错误。
// 用户一旦显式配置 fable 档,上面的精确角色匹配会优先命中。
.or_else(|| {
(role == "fable")
.then(|| {
routes
.iter()
.find(|route| claude_role_keyword(&route.route_id) == Some("opus"))
})
.flatten()
})
.map(|route| route.upstream_model.clone())
})
.ok_or_else(|| {
@@ -754,15 +781,17 @@ fn is_compatible_opus_route_alias(route_id: &str, requested: &str) -> bool {
)
}
/// 按角色关键词(opus / haiku / sonnet)归类一个 Claude 模型名/route_id。
/// 按角色关键词(opus / haiku / fable / sonnet)归类一个 Claude 模型名/route_id。
/// 仅在命中明确角色词时返回 Some,未知模型返回 None(不回落,保持精确报错语义)。
/// 与前端 `routeRoleFromId` 同序(opus → haiku → sonnet)。
/// 与前端 `routeRoleFromId` 同序(opus → haiku → fable → sonnet)。
fn claude_role_keyword(model: &str) -> Option<&'static str> {
let normalized = model.to_ascii_lowercase();
if normalized.contains("opus") {
Some("opus")
} else if normalized.contains("haiku") {
Some("haiku")
} else if normalized.contains("fable") {
Some("fable")
} else if normalized.contains("sonnet") {
Some("sonnet")
} else {
@@ -1650,6 +1679,127 @@ mod tests {
assert!(err.to_string().contains("gpt-5"));
}
#[test]
fn claude_desktop_proxy_maps_fable_to_opus_tier() {
// issue #4026/#4049:老用户只配 Sonnet/Opus/Haiku 三档、未显式配置
// fable 档时,fable 请求按官方分类器降级方向回落到 opus 档兜底。
let mut provider = proxy_provider("proxy");
provider
.meta
.as_mut()
.expect("meta")
.claude_desktop_model_routes = std::collections::HashMap::from([
(
"claude-opus-4-8".to_string(),
ClaudeDesktopModelRoute {
model: "upstream-opus".to_string(),
label_override: None,
supports_1m: Some(true),
},
),
(
"claude-sonnet-4-6".to_string(),
ClaudeDesktopModelRoute {
model: "upstream-sonnet".to_string(),
label_override: None,
supports_1m: Some(true),
},
),
]);
let mapped = map_proxy_request_model(
json!({"model": "claude-fable-5", "messages": []}),
&provider,
)
.expect("fable should fall back to the opus tier");
assert_eq!(mapped["model"], json!("upstream-opus"));
// 带 [1m] 标记与日期后缀的形态也应命中同一回落。
let mapped_one_m = map_proxy_request_model(
json!({"model": "claude-fable-5[1m]", "messages": []}),
&provider,
)
.expect("fable with [1m] marker should fall back to the opus tier");
assert_eq!(mapped_one_m["model"], json!("upstream-opus"));
let mapped_dated = map_proxy_request_model(
json!({"model": "claude-fable-5-20260609", "messages": []}),
&provider,
)
.expect("dated fable alias should fall back to the opus tier");
assert_eq!(mapped_dated["model"], json!("upstream-opus"));
}
#[test]
fn claude_desktop_proxy_fable_without_opus_route_still_errors() {
// 没有 opus 档可回落时保持精确报错语义,不静默落到其他档。
let mut provider = proxy_provider("proxy");
provider
.meta
.as_mut()
.expect("meta")
.claude_desktop_model_routes = std::collections::HashMap::from([(
"claude-sonnet-4-6".to_string(),
ClaudeDesktopModelRoute {
model: "upstream-sonnet".to_string(),
label_override: None,
supports_1m: Some(true),
},
)]);
let err = map_proxy_request_model(
json!({"model": "claude-fable-5", "messages": []}),
&provider,
)
.expect_err("fable without an opus route should fail");
assert!(err.to_string().contains("claude-fable-5"));
}
#[test]
fn claude_desktop_proxy_maps_fable_to_dedicated_route() {
// Desktop 1.12603.1+ fail-all 校验已放行 claude-fable-5,用户可显式配置
// 独立 fable 档;此时 fable 请求精确命中 fable 档,不再降级到 opus。
let mut provider = proxy_provider("proxy");
provider
.meta
.as_mut()
.expect("meta")
.claude_desktop_model_routes = std::collections::HashMap::from([
(
"claude-opus-4-8".to_string(),
ClaudeDesktopModelRoute {
model: "upstream-opus".to_string(),
label_override: None,
supports_1m: Some(true),
},
),
(
"claude-fable-5".to_string(),
ClaudeDesktopModelRoute {
model: "upstream-fable".to_string(),
label_override: None,
supports_1m: Some(true),
},
),
]);
// 精确匹配优先命中 fable 档
let mapped = map_proxy_request_model(
json!({"model": "claude-fable-5", "messages": []}),
&provider,
)
.expect("explicit fable route should match");
assert_eq!(mapped["model"], json!("upstream-fable"));
// 带日期后缀经角色关键词回落仍归 fable 档,而非降级 opus
let mapped_dated = map_proxy_request_model(
json!({"model": "claude-fable-5-20260609", "messages": []}),
&provider,
)
.expect("dated fable alias should map via fable role keyword");
assert_eq!(mapped_dated["model"], json!("upstream-fable"));
}
#[test]
fn claude_desktop_proxy_accepts_opus_4_7_4_8_alias_during_rollout() {
let mut provider = proxy_provider("proxy");
+284
View File
@@ -1043,16 +1043,197 @@ pub fn read_codex_live_settings() -> Result<Value, AppError> {
Ok(json!({ "auth": auth, "config": cfg_text }))
}
/// `[model_providers.custom]` entry that makes an official (ChatGPT OAuth)
/// provider behave like Codex's built-in `openai` entry while running under
/// the shared custom id: `requires_openai_auth` routes auth to the ChatGPT
/// login in `auth.json` (base_url then defaults to the official Codex
/// backend), `name = "OpenAI"` keeps Codex's `is_openai()` feature gates
/// (web search, remote compaction), and `supports_websockets` restores the
/// built-in default that custom entries otherwise lose.
fn codex_unified_official_provider_table() -> toml_edit::Table {
let mut table = toml_edit::Table::new();
table["name"] = toml_edit::value("OpenAI");
table["requires_openai_auth"] = toml_edit::value(true);
table["supports_websockets"] = toml_edit::value(true);
table["wire_api"] = toml_edit::value("responses");
table
}
fn table_matches_codex_unified_official_provider(table: &toml_edit::Table) -> bool {
table.len() == 4
&& table.get("name").and_then(|item| item.as_str()) == Some("OpenAI")
&& table
.get("requires_openai_auth")
.and_then(|item| item.as_bool())
== Some(true)
&& table
.get("supports_websockets")
.and_then(|item| item.as_bool())
== Some(true)
&& table.get("wire_api").and_then(|item| item.as_str()) == Some("responses")
}
/// 统一 Codex 会话历史:把官方供应商的 live 配置改写为以共享的
/// `custom` model_provider 标识运行(认证仍走 `auth.json` 的 ChatGPT 登录),
/// 使开关开启后创建的官方会话与第三方会话共用同一个 resume 历史桶。
///
/// 两种情况拒绝注入、原样返回:
/// - 配置已有显式 `model_provider`:用户手工指定的路由不被覆盖;
/// - 配置已有形态不同的 `[model_providers.custom]` 表:设置 `model_provider`
/// 会激活这张我们不认识的表(可能带第三方 base_url/token,会把 ChatGPT
/// OAuth 流量路由到错误后端),宁可让开关对该配置不生效。
pub fn inject_codex_unified_session_bucket(config_text: &str) -> Result<String, AppError> {
let mut doc = config_text
.parse::<DocumentMut>()
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
if doc.get("model_provider").is_some() {
return Ok(config_text.to_string());
}
let existing_custom_conflicts = doc
.get("model_providers")
.and_then(|item| item.as_table())
.and_then(|providers| providers.get(CC_SWITCH_CODEX_MODEL_PROVIDER_ID))
.and_then(|item| item.as_table())
.is_some_and(|table| !table_matches_codex_unified_official_provider(table));
if existing_custom_conflicts {
log::warn!(
"官方 Codex 配置已存在自定义 [model_providers.custom],跳过统一会话路由注入以避免激活未知路由"
);
return Ok(config_text.to_string());
}
doc["model_provider"] = toml_edit::value(CC_SWITCH_CODEX_MODEL_PROVIDER_ID);
if doc.get("model_providers").is_none() {
let mut parent = toml_edit::Table::new();
parent.set_implicit(true);
doc["model_providers"] = toml_edit::Item::Table(parent);
}
if let Some(providers) = doc["model_providers"].as_table_mut() {
if !providers.contains_key(CC_SWITCH_CODEX_MODEL_PROVIDER_ID) {
providers.insert(
CC_SWITCH_CODEX_MODEL_PROVIDER_ID,
toml_edit::Item::Table(codex_unified_official_provider_table()),
);
}
}
Ok(doc.to_string())
}
/// `inject_codex_unified_session_bucket` 的反向操作:从配置文本里剥掉注入的
/// 统一会话路由,保证切换回填不会把它带进数据库的存储配置(关闭开关后
/// 切换即可完全还原)。仅当形态与注入产物完全一致时才剥离;第三方模板和
/// 用户自定义的 `custom` 条目(带 base_url 等差异字段)原样保留。
pub fn strip_codex_unified_session_bucket(config_text: &str) -> Result<String, AppError> {
if !config_text.contains("model_provider") {
return Ok(config_text.to_string());
}
let mut doc = config_text
.parse::<DocumentMut>()
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
if doc.get("model_provider").and_then(|item| item.as_str())
!= Some(CC_SWITCH_CODEX_MODEL_PROVIDER_ID)
{
return Ok(config_text.to_string());
}
let matches_injected = doc
.get("model_providers")
.and_then(|item| item.as_table())
.and_then(|providers| providers.get(CC_SWITCH_CODEX_MODEL_PROVIDER_ID))
.and_then(|item| item.as_table())
.is_some_and(table_matches_codex_unified_official_provider);
if !matches_injected {
return Ok(config_text.to_string());
}
doc.as_table_mut().remove("model_provider");
let providers_empty = doc["model_providers"]
.as_table_mut()
.map(|providers| {
providers.remove(CC_SWITCH_CODEX_MODEL_PROVIDER_ID);
providers.is_empty()
})
.unwrap_or(false);
if providers_empty {
doc.as_table_mut().remove("model_providers");
}
Ok(doc.to_string())
}
/// 统一会话开关开启时,把官方供应商 `{ auth, config }` 设置对象中的
/// config 文本注入共享 custom 路由;开关关闭或非官方供应商时不做改动。
///
/// 普通 live 写入(`write_codex_live_for_provider`)与代理接管备份
/// `update_live_backup_from_provider`)两条落盘路径共用:接管期间
/// live 归代理所有,注入必须进备份,接管释放恢复的 live 才带统一路由。
pub fn apply_codex_unified_session_bucket_to_settings(
category: Option<&str>,
settings: &mut Value,
) -> Result<(), AppError> {
if category != Some("official") || !crate::settings::unify_codex_session_history() {
return Ok(());
}
let config_text = settings
.get("config")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string();
let injected = inject_codex_unified_session_bucket(&config_text)?;
if injected != config_text {
if let Some(obj) = settings.as_object_mut() {
obj.insert("config".to_string(), Value::String(injected));
}
}
Ok(())
}
/// Backfill helper: strip the unified-session injection from a live
/// `{ auth, config }` settings object before it is stored back to the DB.
pub fn strip_codex_unified_session_bucket_from_settings(
settings: &mut Value,
) -> Result<(), AppError> {
let Some(config_text) = settings
.get("config")
.and_then(|value| value.as_str())
.map(str::to_string)
else {
return Ok(());
};
let stripped = strip_codex_unified_session_bucket(&config_text)?;
if stripped != config_text {
if let Some(obj) = settings.as_object_mut() {
obj.insert("config".to_string(), Value::String(stripped));
}
}
Ok(())
}
/// Route a Codex live write between full auth+config or config-only.
///
/// Official providers with usable login material own `auth.json`. Third-party
/// providers only touch `config.toml` when the compatibility setting is enabled
/// so the user's ChatGPT login cache survives provider switches.
///
/// 统一会话开关开启时,官方配置在落盘前注入共享的 `custom` 路由
/// (见 `inject_codex_unified_session_bucket`)。
pub fn write_codex_live_for_provider(
category: Option<&str>,
auth: &Value,
config_text: Option<&str>,
) -> Result<(), AppError> {
let unified_official_config =
if category == Some("official") && crate::settings::unify_codex_session_history() {
Some(inject_codex_unified_session_bucket(
config_text.unwrap_or(""),
)?)
} else {
None
};
let config_text = unified_official_config.as_deref().or(config_text);
let should_write_auth = (category == Some("official") && codex_auth_has_login_material(auth))
|| (category != Some("official")
&& !crate::settings::preserve_codex_official_auth_on_switch());
@@ -1257,6 +1438,109 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn unified_session_bucket_injects_for_empty_official_config() {
let injected = inject_codex_unified_session_bucket("").expect("inject");
let doc: toml::Table = toml::from_str(&injected).expect("parse injected config");
assert_eq!(
doc.get("model_provider").and_then(|v| v.as_str()),
Some(CC_SWITCH_CODEX_MODEL_PROVIDER_ID)
);
let custom = doc["model_providers"][CC_SWITCH_CODEX_MODEL_PROVIDER_ID]
.as_table()
.expect("custom provider table");
assert_eq!(custom.get("name").and_then(|v| v.as_str()), Some("OpenAI"));
assert_eq!(
custom.get("requires_openai_auth").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
custom.get("supports_websockets").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
custom.get("wire_api").and_then(|v| v.as_str()),
Some("responses")
);
}
#[test]
fn unified_session_bucket_preserves_other_keys_and_explicit_routing() {
let with_catalog = "model_catalog_json = \"cc-switch-model-catalog.json\"\n";
let injected = inject_codex_unified_session_bucket(with_catalog).expect("inject");
assert!(injected.contains("model_catalog_json"));
assert!(injected.contains("model_provider = \"custom\""));
// 用户显式指定过 model_provider 的官方配置不被覆盖
let explicit = "model_provider = \"openai_https\"\n";
let unchanged = inject_codex_unified_session_bucket(explicit).expect("inject");
assert_eq!(unchanged, explicit);
}
#[test]
fn unified_session_bucket_skips_conflicting_custom_table() {
// 残留的非注入形态 custom 表:设置 model_provider 会把官方流量
// 路由到表里的第三方端点,必须整体拒绝注入。
let stale = r#"[model_providers.custom]
name = "Relay"
base_url = "https://relay.example/v1"
"#;
let unchanged = inject_codex_unified_session_bucket(stale).expect("inject");
assert_eq!(unchanged, stale);
// 已是注入形态的 custom 表(如重复注入)则照常补上 model_provider
let injected_once = inject_codex_unified_session_bucket("").expect("inject");
let reinjected = inject_codex_unified_session_bucket(&injected_once).expect("re-inject");
assert_eq!(reinjected, injected_once);
}
#[test]
fn unified_session_bucket_strip_round_trips_injection() {
let injected = inject_codex_unified_session_bucket("").expect("inject");
let stripped = strip_codex_unified_session_bucket(&injected).expect("strip");
assert_eq!(stripped.trim(), "");
let with_catalog = "model_catalog_json = \"cc-switch-model-catalog.json\"\n";
let injected = inject_codex_unified_session_bucket(with_catalog).expect("inject");
let stripped = strip_codex_unified_session_bucket(&injected).expect("strip");
assert_eq!(stripped, with_catalog);
}
#[test]
fn unified_session_bucket_strip_keeps_third_party_custom_entry() {
// 第三方模板同样用 custom 路由,但条目带 base_url 等差异字段,
// 形态不等于注入产物,必须原样保留。
let third_party = r#"model_provider = "custom"
[model_providers.custom]
name = "Relay"
base_url = "https://relay.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
let untouched = strip_codex_unified_session_bucket(third_party).expect("strip");
assert_eq!(untouched, third_party);
}
#[test]
fn unified_session_bucket_strip_from_settings_only_touches_config() {
let injected = inject_codex_unified_session_bucket("").expect("inject");
let mut settings = json!({
"auth": { "tokens": { "access_token": "secret" } },
"config": injected,
});
strip_codex_unified_session_bucket_from_settings(&mut settings).expect("strip settings");
assert_eq!(
settings
.get("config")
.and_then(|v| v.as_str())
.map(str::trim),
Some("")
);
assert!(settings.pointer("/auth/tokens/access_token").is_some());
}
#[test]
fn extract_base_url_prefers_active_provider_section() {
let input = r#"model_provider = "azure"
+865 -6
View File
@@ -10,7 +10,8 @@ use crate::config::{atomic_write, copy_file, get_app_config_dir};
use crate::database::{is_official_seed_id, Database};
use crate::error::AppError;
use crate::settings::{
CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration,
CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration,
CodexThirdPartyHistoryProviderBucketMigration,
};
use chrono::{Local, Utc};
use rusqlite::{backup::Backup, params_from_iter, Connection};
@@ -24,7 +25,25 @@ use std::time::{Duration, SystemTime};
use toml_edit::DocumentMut;
const MIGRATION_NAME: &str = "codex-history-provider-migration-v1";
const OFFICIAL_UNIFY_MIGRATION_NAME: &str = "codex-official-history-unify-v1";
/// 还原操作自身的备份目录(与迁移备份分开,保持迁移账本目录纯净)。
const OFFICIAL_UNIFY_RESTORE_BACKUP_NAME: &str = "codex-official-history-unify-restore-v1";
const CODEX_STATE_DB_FILENAME: &str = "state_5.sqlite";
/// SQLite 变量上限保守值,IN 列表按此分块。
const STATE_DB_ID_CHUNK: usize = 500;
/// 串行化官方历史的迁移与还原:开启迁移(启动重试 + 设置保存后台任务)和
/// 关闭还原可能在毫秒级先后被触发,对同一批 jsonl / state DB 双向改写。
static CODEX_OFFICIAL_HISTORY_OP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn lock_codex_official_history_op() -> std::sync::MutexGuard<'static, ()> {
CODEX_OFFICIAL_HISTORY_OP_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Codex 内建默认 provider idconfig.toml 没有 `model_provider` 键时会话归入此桶。
/// 官方订阅(ChatGPT OAuth / OpenAI API key)的历史会话都记录这个 id。
const OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID: &str = "openai";
const LEGACY_CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
// If a Codex preset ever used a temporary routing key, keep that old key here
// so local history can be bucketed under the current custom provider id.
@@ -120,7 +139,7 @@ pub fn maybe_migrate_codex_third_party_history_provider_bucket(
});
}
let backup_root = migration_backup_root();
let backup_root = migration_backup_root(MIGRATION_NAME);
let codex_dir = get_codex_config_dir();
let migrated_jsonl_files =
migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)?;
@@ -157,7 +176,7 @@ pub fn maybe_migrate_codex_provider_template_bucket(
});
}
let backup_root = migration_backup_root();
let backup_root = migration_backup_root(MIGRATION_NAME);
let outcome = migrate_codex_provider_templates_to_custom(db, &backup_root)?;
crate::settings::mark_codex_provider_template_migrated(CodexProviderTemplateMigration {
completed_at: Utc::now().to_rfc3339(),
@@ -167,6 +186,475 @@ pub fn maybe_migrate_codex_provider_template_bucket(
Ok(outcome)
}
/// 统一会话开关的存量迁移:把官方会话(内建 "openai" 桶)迁入共享 "custom" 桶。
///
/// 仅当用户在开启弹窗里勾选了"迁入既有官方会话"`unify_codex_migrate_existing`
/// 且本轮未完成时执行;开关关闭时标记与勾选意愿都会被清除(见 `save_settings`),
/// 重新开启并再次勾选即可补迁关闭期间产生的官方会话。
/// custom 桶里官方与第三方会话无法区分,自动逻辑绝不反向搬回;
/// 用户可在关闭开关时选择按备份账本精确还原(见 `restore_codex_official_history_from_backups`)。
/// 迁移前 jsonl / state DB 均备份到 `~/.cc-switch/backups/codex-official-history-unify-v1/`。
pub fn maybe_migrate_codex_official_history_to_unified_bucket(
) -> Result<CodexHistoryProviderBucketMigrationOutcome, AppError> {
if !crate::settings::unify_codex_session_history() {
return Ok(CodexHistoryProviderBucketMigrationOutcome {
skipped_reason: Some("unify_toggle_off".to_string()),
..Default::default()
});
}
if !crate::settings::unify_codex_migrate_existing_requested() {
return Ok(CodexHistoryProviderBucketMigrationOutcome {
skipped_reason: Some("stock_migration_not_requested".to_string()),
..Default::default()
});
}
let _op_guard = lock_codex_official_history_op();
let codex_dir = get_codex_config_dir();
// marker 绑定迁移时的 Codex 目录:切换 codex_config_dir 后旧 marker 不再
// 挡住新目录的迁移(迁移幂等,重跑无害)。
let codex_dir_key = canonical_dir_string(&codex_dir);
if crate::settings::is_codex_official_history_unify_migrated_for_dir(&codex_dir_key) {
return Ok(CodexHistoryProviderBucketMigrationOutcome {
skipped_reason: Some("already_migrated".to_string()),
..Default::default()
});
}
// live 必须已实际路由到共享 custom 桶才允许迁移:官方配置的注入可能被拒
// (已有显式 model_provider / 形态冲突的 custom 表,见
// `inject_codex_unified_session_bucket`),代理接管期间的 live 也不带统一
// 路由(注入只进备份)。这些状态下新会话仍落 "openai" 桶,迁移只会把
// 历史搬进当前 live 看不见的桶里。开关与迁移意愿保持不动,待 live 真正
// 统一后(下次切换 / 接管释放后的启动重试)再迁。
if !codex_config_text_routes_custom(&read_codex_config_text().unwrap_or_default()) {
return Ok(CodexHistoryProviderBucketMigrationOutcome {
skipped_reason: Some("live_not_unified".to_string()),
..Default::default()
});
}
let source_provider_ids: BTreeSet<String> =
std::iter::once(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID.to_string()).collect();
let backup_root = migration_backup_root(OFFICIAL_UNIFY_MIGRATION_NAME);
let migrated_jsonl_files =
migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)?;
let migrated_state_rows =
migrate_codex_state_dbs(&codex_dir, &source_provider_ids, &backup_root)?;
// 备份代际记录来源目录,restore 据此只取当前目录的账本。
write_backup_generation_meta(&backup_root, &codex_dir_key)?;
let outcome = CodexHistoryProviderBucketMigrationOutcome {
source_provider_ids: source_provider_ids.into_iter().collect(),
migrated_jsonl_files,
migrated_state_rows,
skipped_reason: None,
};
// 条件写入在 settings 写锁内原子完成:"迁移期间开关被关掉"时不写完成标记,
// 避免下一次开启被标记挡住而漏迁"关闭期间"新产生的 openai 桶会话。
// 与关闭路径(update_settings + 清标记)共用同一把锁,无检查-写入窗口。
let marker_written = crate::settings::mark_codex_official_history_unify_migrated_if_enabled(
CodexOfficialHistoryUnifyMigration {
completed_at: Utc::now().to_rfc3339(),
target_provider_id: CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string(),
migrated_jsonl_files,
migrated_state_rows,
codex_config_dir: Some(codex_dir_key),
},
)?;
if !marker_written {
return Ok(CodexHistoryProviderBucketMigrationOutcome {
skipped_reason: Some("toggle_disabled_during_migration".to_string()),
..outcome
});
}
Ok(outcome)
}
/// live config.toml 是否路由到共享 custom 桶(会话分桶只看这个实态:
/// base_url / 接管与否都不影响 session_meta 记录的 model_provider)。
fn codex_config_text_routes_custom(config_text: &str) -> bool {
config_text
.parse::<DocumentMut>()
.ok()
.and_then(|doc| {
doc.get("model_provider")
.and_then(|item| item.as_str())
.map(|id| id.trim() == CC_SWITCH_CODEX_MODEL_PROVIDER_ID)
})
.unwrap_or(false)
}
/// 目录的规范化字符串形式,用作 marker / 备份代际的目录身份。
/// canonicalize 失败(目录尚不存在等)时退回原始路径字符串。
fn canonical_dir_string(dir: &Path) -> String {
fs::canonicalize(dir)
.unwrap_or_else(|_| dir.to_path_buf())
.to_string_lossy()
.to_string()
}
/// 在备份代际根目录写入 meta.json,记录这批备份来自哪个 Codex 目录。
/// 代际目录不存在(本轮没有任何文件被迁移)时跳过。
fn write_backup_generation_meta(backup_root: &Path, codex_dir_key: &str) -> Result<(), AppError> {
if !backup_root.exists() {
return Ok(());
}
let payload = serde_json::json!({ "codexConfigDir": codex_dir_key });
let bytes =
serde_json::to_vec_pretty(&payload).map_err(|e| AppError::JsonSerialize { source: e })?;
atomic_write(&backup_root.join("meta.json"), &bytes)
}
#[derive(Debug, Clone, Default)]
pub struct CodexOfficialHistoryRestoreOutcome {
pub restored_jsonl_files: usize,
pub restored_state_rows: usize,
pub skipped_reason: Option<String>,
}
/// 统一会话开关迁移备份的父目录(其下每次迁移一个时间戳代际目录)。
fn official_history_unify_backup_parent() -> PathBuf {
get_app_config_dir()
.join("backups")
.join(OFFICIAL_UNIFY_MIGRATION_NAME)
}
/// 是否存在可用于还原的迁移备份(给前端决定要不要显示"恢复备份"勾选)。
/// 与 restore 的账本收集共用同一目录匹配口径:只认属于当前 Codex 目录的
/// 代际,避免切换 codex_config_dir 后弹出注定空跑的勾选。
/// 精确账本内容仍在真正还原时才解析。
pub fn has_codex_official_history_unify_backup() -> bool {
has_official_history_unify_backup_for_dir(
&official_history_unify_backup_parent(),
&canonical_dir_string(&get_codex_config_dir()),
)
}
fn has_official_history_unify_backup_for_dir(ledger_parent: &Path, codex_dir_key: &str) -> bool {
let Ok(entries) = fs::read_dir(ledger_parent) else {
return false;
};
entries.flatten().any(|entry| {
let generation = entry.path();
generation.is_dir() && backup_generation_matches_dir(&generation, codex_dir_key)
})
}
/// 关闭统一会话开关时的可选还原:按迁移备份账本,把当时迁入共享 custom 桶的
/// 官方会话精确翻回 "openai" 桶。
///
/// 备份是唯一可信的归属证据:备份里 model_provider=="openai" 的会话必定源自
/// 官方桶。开启期间新产生的会话不在任何备份里,**永不触碰**——它们可能来自
/// 第三方,方向无法判定(产品决策:宁可留在第三方历史)。
/// 扫描全部备份代际取并集,多次开关循环后仍能还原早期迁入的会话;
/// 还原前改动目标先备份到独立的 restore 目录(保持迁移账本目录纯净),
/// 且只改写当前仍为 custom 的目标,重复执行无害。
pub fn restore_codex_official_history_from_backups(
) -> Result<CodexOfficialHistoryRestoreOutcome, AppError> {
let _op_guard = lock_codex_official_history_op();
// 开关已(重新)开启时拒绝还原:live 正路由 custom,把账本会话翻回
// openai 桶等于亲手制造分裂。覆盖"关闭保存成功后用户立刻重新开启,
// 还原排在重开迁移之后才拿到 op lock"的时序。
if crate::settings::unify_codex_session_history() {
return Ok(CodexOfficialHistoryRestoreOutcome {
skipped_reason: Some("unify_toggle_on".to_string()),
..Default::default()
});
}
let config_text = read_codex_config_text().unwrap_or_default();
restore_codex_official_history_inner(
&get_codex_config_dir(),
&official_history_unify_backup_parent(),
&migration_backup_root(OFFICIAL_UNIFY_RESTORE_BACKUP_NAME),
&config_text,
)
}
fn restore_codex_official_history_inner(
codex_dir: &Path,
ledger_parent: &Path,
restore_backup_root: &Path,
config_text: &str,
) -> Result<CodexOfficialHistoryRestoreOutcome, AppError> {
let codex_dir_key = canonical_dir_string(codex_dir);
let (official_session_ids, official_thread_ids) =
collect_official_ledger(ledger_parent, &codex_dir_key)?;
if official_session_ids.is_empty() && official_thread_ids.is_empty() {
return Ok(CodexOfficialHistoryRestoreOutcome {
skipped_reason: Some("no_backup_ledger".to_string()),
..Default::default()
});
}
let mut files = Vec::new();
collect_jsonl_files(&codex_dir.join("sessions"), &mut files, 0, 8);
collect_jsonl_files(&codex_dir.join("archived_sessions"), &mut files, 0, 4);
let mut restored_jsonl_files = 0;
for file_path in files {
if rewrite_codex_session_file_lines(&file_path, codex_dir, restore_backup_root, |line| {
rewrite_codex_session_meta_line_for_restore(line, &official_session_ids)
})? {
restored_jsonl_files += 1;
}
}
let mut restored_state_rows = 0;
for db_path in codex_state_db_paths(codex_dir, config_text) {
restored_state_rows += restore_codex_state_db_official_threads(
&db_path,
codex_dir,
&official_thread_ids,
restore_backup_root,
)?;
}
if restored_jsonl_files == 0 && restored_state_rows == 0 {
// 账本非空但没有任何"当前仍为 custom"的目标(如重复还原):
// 以 reason 告知前端,避免误报"已还原 0 项"为成功。
return Ok(CodexOfficialHistoryRestoreOutcome {
skipped_reason: Some("nothing_to_restore".to_string()),
..Default::default()
});
}
Ok(CodexOfficialHistoryRestoreOutcome {
restored_jsonl_files,
restored_state_rows,
skipped_reason: None,
})
}
/// 从备份代际收集官方会话账本:jsonl 备份里 session_meta 为 "openai" 的
/// 会话 id + state DB 备份里 model_provider 为 "openai" 的 thread id。
/// 只采纳 meta.json 目录与当前 Codex 目录一致的代际,避免切换
/// codex_config_dir 后拿旧目录的账本作用到新目录。
/// 还原操作自身的备份(restore 目录)天然不会混入:那些副本里的 id 都是
/// custom,解析后贡献为空。
fn collect_official_ledger(
ledger_parent: &Path,
codex_dir_key: &str,
) -> Result<(HashSet<String>, BTreeSet<String>), AppError> {
let mut session_ids = HashSet::new();
let mut thread_ids = BTreeSet::new();
let entries = match fs::read_dir(ledger_parent) {
Ok(entries) => entries,
Err(_) => return Ok((session_ids, thread_ids)),
};
for entry in entries.flatten() {
let generation = entry.path();
if !generation.is_dir() {
continue;
}
if !backup_generation_matches_dir(&generation, codex_dir_key) {
continue;
}
let mut backup_files = Vec::new();
collect_jsonl_files(&generation.join("jsonl"), &mut backup_files, 0, 10);
for backup_file in backup_files {
collect_official_session_ids_from_backup(&backup_file, &mut session_ids);
}
let mut backup_dbs = Vec::new();
collect_files_with_extension(&generation.join("state"), "sqlite", &mut backup_dbs, 0, 4);
for backup_db in backup_dbs {
collect_official_thread_ids_from_backup(&backup_db, &mut thread_ids);
}
}
Ok((session_ids, thread_ids))
}
/// 备份代际是否属于指定 Codex 目录。无 meta.json 或解析失败时宽容接受:
/// 早期版本的备份没有 meta,而那个时期不存在切目录场景;误纳的代价也被
/// "按会话 id 精确匹配 + 仅改写 custom"双重条件兜底。
fn backup_generation_matches_dir(generation: &Path, codex_dir_key: &str) -> bool {
let Ok(text) = fs::read_to_string(generation.join("meta.json")) else {
return true;
};
serde_json::from_str::<Value>(&text)
.ok()
.and_then(|value| {
value
.get("codexConfigDir")
.and_then(Value::as_str)
.map(|dir| dir == codex_dir_key)
})
.unwrap_or(true)
}
fn collect_official_session_ids_from_backup(path: &Path, session_ids: &mut HashSet<String>) {
let Ok(content) = fs::read_to_string(path) else {
log::debug!("Failed to read unify backup file {}", path.display());
return;
};
for line in content.lines() {
if !line.contains("\"session_meta\"") || !line.contains("\"model_provider\"") {
continue;
}
let Ok(value) = serde_json::from_str::<Value>(line) else {
continue;
};
if value.get("type").and_then(Value::as_str) != Some("session_meta") {
continue;
}
let Some(payload) = value.get("payload") else {
continue;
};
if payload.get("model_provider").and_then(Value::as_str)
!= Some(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID)
{
continue;
}
if let Some(session_id) = payload.get("id").and_then(Value::as_str) {
session_ids.insert(session_id.to_string());
}
}
}
fn collect_official_thread_ids_from_backup(db_path: &Path, thread_ids: &mut BTreeSet<String>) {
let conn =
match Connection::open_with_flags(db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) {
Ok(conn) => conn,
Err(err) => {
log::debug!(
"Failed to open unify backup state DB {}: {err}",
db_path.display()
);
return;
}
};
let has_threads = Database::table_exists(&conn, "threads").unwrap_or(false)
&& Database::has_column(&conn, "threads", "model_provider").unwrap_or(false);
if !has_threads {
return;
}
let Ok(mut stmt) = conn.prepare("SELECT id FROM threads WHERE model_provider = ?1") else {
return;
};
let Ok(rows) = stmt.query_map([OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID], |row| {
row.get::<_, String>(0)
}) else {
return;
};
for thread_id in rows.flatten() {
thread_ids.insert(thread_id);
}
}
fn collect_files_with_extension(
dir: &Path,
extension: &str,
files: &mut Vec<PathBuf>,
depth: u8,
max_depth: u8,
) {
if depth > max_depth || !dir.is_dir() {
return;
}
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_files_with_extension(&path, extension, files, depth + 1, max_depth);
} else if path.extension().and_then(|ext| ext.to_str()) == Some(extension) {
files.push(path);
}
}
}
fn rewrite_codex_session_meta_line_for_restore(
line: &str,
official_session_ids: &HashSet<String>,
) -> Option<String> {
if !line.contains("\"session_meta\"") || !line.contains("\"model_provider\"") {
return None;
}
let mut value: Value = serde_json::from_str(line).ok()?;
if value.get("type").and_then(Value::as_str) != Some("session_meta") {
return None;
}
let payload = value.get_mut("payload")?.as_object_mut()?;
if payload.get("model_provider")?.as_str()? != CC_SWITCH_CODEX_MODEL_PROVIDER_ID {
return None;
}
let session_id = payload.get("id")?.as_str()?;
if !official_session_ids.contains(session_id) {
return None;
}
payload.insert(
"model_provider".to_string(),
Value::String(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID.to_string()),
);
serde_json::to_string(&value).ok()
}
fn restore_codex_state_db_official_threads(
db_path: &Path,
codex_dir: &Path,
official_thread_ids: &BTreeSet<String>,
backup_root: &Path,
) -> Result<usize, AppError> {
if !db_path.exists() || official_thread_ids.is_empty() {
return Ok(0);
}
let mut conn = Connection::open(db_path)
.map_err(|e| AppError::Database(format!("打开 Codex state DB 失败: {e}")))?;
conn.busy_timeout(Duration::from_secs(5))
.map_err(|e| AppError::Database(format!("设置 Codex state DB busy_timeout 失败: {e}")))?;
if !Database::table_exists(&conn, "threads")?
|| !Database::has_column(&conn, "threads", "model_provider")?
{
return Ok(0);
}
let ids: Vec<&String> = official_thread_ids.iter().collect();
let mut matching_rows: i64 = 0;
for chunk in ids.chunks(STATE_DB_ID_CHUNK) {
let placeholders = placeholders(chunk.len());
let count_sql = format!(
"SELECT COUNT(*) FROM threads WHERE model_provider = ? AND id IN ({placeholders})"
);
let mut values = Vec::with_capacity(chunk.len() + 1);
values.push(CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
values.extend(chunk.iter().map(|id| (*id).clone()));
let count: i64 = conn
.query_row(&count_sql, params_from_iter(values.iter()), |row| {
row.get(0)
})
.map_err(|e| AppError::Database(format!("统计 Codex state DB 待还原行失败: {e}")))?;
matching_rows += count;
}
if matching_rows == 0 {
return Ok(0);
}
backup_codex_state_db(db_path, codex_dir, backup_root, &conn)?;
let tx = conn
.transaction()
.map_err(|e| AppError::Database(format!("开启 Codex state DB 还原事务失败: {e}")))?;
let mut changed = 0;
for chunk in ids.chunks(STATE_DB_ID_CHUNK) {
let placeholders = placeholders(chunk.len());
let update_sql = format!(
"UPDATE threads SET model_provider = ? WHERE model_provider = ? AND id IN ({placeholders})"
);
let mut values = Vec::with_capacity(chunk.len() + 2);
values.push(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID.to_string());
values.push(CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
values.extend(chunk.iter().map(|id| (*id).clone()));
changed += tx
.execute(&update_sql, params_from_iter(values.iter()))
.map_err(|e| AppError::Database(format!("还原 Codex state DB provider 失败: {e}")))?;
}
tx.commit()
.map_err(|e| AppError::Database(format!("提交 Codex state DB 还原事务失败: {e}")))?;
Ok(changed)
}
fn migrate_codex_provider_templates_to_custom(
db: &Database,
backup_root: &Path,
@@ -257,10 +745,10 @@ fn insert_known_cc_switch_legacy_source_id(ids: &mut BTreeSet<String>, provider_
}
}
fn migration_backup_root() -> PathBuf {
fn migration_backup_root(migration_name: &str) -> PathBuf {
get_app_config_dir()
.join("backups")
.join(MIGRATION_NAME)
.join(migration_name)
.join(Local::now().format("%Y%m%d_%H%M%S").to_string())
}
@@ -524,6 +1012,17 @@ fn rewrite_codex_session_file_for_provider_bucket(
codex_dir: &Path,
source_provider_ids: &HashSet<String>,
backup_root: &Path,
) -> Result<bool, AppError> {
rewrite_codex_session_file_lines(path, codex_dir, backup_root, |line| {
rewrite_codex_session_meta_line(line, source_provider_ids)
})
}
fn rewrite_codex_session_file_lines(
path: &Path,
codex_dir: &Path,
backup_root: &Path,
rewrite_line: impl Fn(&str) -> Option<String>,
) -> Result<bool, AppError> {
let metadata_before = fs::metadata(path).map_err(|e| AppError::io(path, e))?;
let modified_before = metadata_before.modified().ok();
@@ -537,7 +1036,7 @@ fn rewrite_codex_session_file_for_provider_bucket(
.strip_suffix('\n')
.map(|line| (line, "\n"))
.unwrap_or((segment, ""));
if let Some(next_line) = rewrite_codex_session_meta_line(line, source_provider_ids) {
if let Some(next_line) = rewrite_line(line) {
rewritten.push_str(&next_line);
changed = true;
} else {
@@ -820,6 +1319,39 @@ mod tests {
values.iter().map(|value| value.to_string()).collect()
}
#[test]
fn detects_custom_routed_codex_config_for_unify_gate() {
// 注入产物(官方 + 统一开关)
assert!(codex_config_text_routes_custom(
r#"model_provider = "custom"
[model_providers.custom]
name = "OpenAI"
requires_openai_auth = true
supports_websockets = true
wire_api = "responses"
"#
));
// 第三方供应商的常规 custom 路由(带 base_url)同样算已统一
assert!(codex_config_text_routes_custom(
r#"model_provider = "custom"
[model_providers.custom]
name = "AIHubMix"
base_url = "https://aihubmix.example/v1"
"#
));
// 注入被拒的形态:显式 openai 路由 / 无 model_provider(接管期间、空配置)
assert!(!codex_config_text_routes_custom(
"model_provider = \"openai\"\n"
));
assert!(!codex_config_text_routes_custom(
"base_url = \"http://127.0.0.1:15721/codex\"\n"
));
assert!(!codex_config_text_routes_custom(""));
assert!(!codex_config_text_routes_custom("not toml ["));
}
fn migrate_provider_templates_for_test(
db: &Database,
) -> (
@@ -1092,6 +1624,333 @@ base_url = "https://proxy.example/v1"
);
}
#[test]
fn simulates_official_history_unify_migration_end_to_end() {
let dir = tempdir().expect("tempdir");
let codex_dir = dir.path().join(".codex");
let backup_root = dir.path().join("backup");
fs::create_dir_all(&codex_dir).expect("create codex dir");
let source_provider_ids = source_ids(&[OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID]);
let session_dir = codex_dir.join("sessions/2026/06/12");
fs::create_dir_all(&session_dir).expect("create session dir");
let session_path = session_dir.join("official-sim.jsonl");
fs::write(
&session_path,
concat!(
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"openai\"}}\n",
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s2\",\"model_provider\":\"custom\"}}\n",
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s3\",\"model_provider\":\"my-private-relay\"}}\n",
"{\"type\":\"response_item\",\"payload\":{\"text\":\"openai\"}}\n",
),
)
.expect("write session");
let migrated_jsonl =
migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)
.expect("migrate jsonl");
assert_eq!(migrated_jsonl, 1);
let session_text = fs::read_to_string(&session_path).expect("read session");
assert_eq!(
session_text
.matches("\"model_provider\":\"custom\"")
.count(),
2
);
assert!(!session_text.contains("\"model_provider\":\"openai\""));
assert!(session_text.contains("\"model_provider\":\"my-private-relay\""));
assert!(
session_text.contains("{\"type\":\"response_item\",\"payload\":{\"text\":\"openai\"}}")
);
assert!(backup_root
.join("jsonl/sessions/2026/06/12/official-sim.jsonl")
.exists());
// 第二次执行应当无事可做(幂等)
let rerun = migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)
.expect("rerun migrate jsonl");
assert_eq!(rerun, 0);
let state_db_path = codex_dir.join(CODEX_STATE_DB_FILENAME);
let conn = Connection::open(&state_db_path).expect("open state db");
conn.execute_batch(
"CREATE TABLE threads (
id TEXT PRIMARY KEY,
model_provider TEXT NOT NULL
);
INSERT INTO threads (id, model_provider) VALUES
('openai-thread', 'openai'),
('custom-thread', 'custom'),
('manual-thread', 'my-private-relay');",
)
.expect("seed state db");
drop(conn);
let migrated_state_rows = migrate_codex_state_db_provider_bucket(
&state_db_path,
&codex_dir,
&source_provider_ids,
&backup_root,
)
.expect("migrate state db");
assert_eq!(migrated_state_rows, 1);
let conn = Connection::open(&state_db_path).expect("reopen state db");
let count_provider = |provider_id: &str| -> i64 {
conn.query_row(
"SELECT COUNT(*) FROM threads WHERE model_provider = ?1",
[provider_id],
|row| row.get(0),
)
.expect("count provider")
};
assert_eq!(count_provider("custom"), 2);
assert_eq!(count_provider("openai"), 0);
assert_eq!(count_provider("my-private-relay"), 1);
}
#[test]
fn restores_only_ledgered_official_sessions_from_backups() {
let dir = tempdir().expect("tempdir");
let codex_dir = dir.path().join(".codex");
let ledger_parent = dir.path().join("ledger");
let restore_backup_root = dir.path().join("restore-backup");
// 备份账本:一个代际,jsonl 备份里 s1 是 openaistate 备份里 t1 是 openai
let generation = ledger_parent.join("20260612_010101");
let backup_session_dir = generation.join("jsonl/sessions/2026/06/01");
fs::create_dir_all(&backup_session_dir).expect("create backup session dir");
fs::write(
backup_session_dir.join("official.jsonl"),
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"openai\"}}\n",
)
.expect("write backup session");
let backup_state_dir = generation.join("state");
fs::create_dir_all(&backup_state_dir).expect("create backup state dir");
let backup_db = Connection::open(backup_state_dir.join(CODEX_STATE_DB_FILENAME))
.expect("open backup db");
backup_db
.execute_batch(
"CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT NOT NULL);
INSERT INTO threads (id, model_provider) VALUES ('t1', 'openai');",
)
.expect("seed backup db");
drop(backup_db);
// 当前数据:s1(账本内,custom)应还原;s2(开启期间新会话,不在账本)
// 与 s3(手工 relay)必须原样保留
let session_dir = codex_dir.join("sessions/2026/06/01");
fs::create_dir_all(&session_dir).expect("create session dir");
let official_path = session_dir.join("official.jsonl");
fs::write(
&official_path,
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"custom\"}}\n",
)
.expect("write official session");
let on_period_dir = codex_dir.join("sessions/2026/06/12");
fs::create_dir_all(&on_period_dir).expect("create on-period dir");
let on_period_path = on_period_dir.join("on-period.jsonl");
fs::write(
&on_period_path,
concat!(
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s2\",\"model_provider\":\"custom\"}}\n",
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s3\",\"model_provider\":\"my-private-relay\"}}\n",
),
)
.expect("write on-period session");
let state_db_path = codex_dir.join(CODEX_STATE_DB_FILENAME);
let conn = Connection::open(&state_db_path).expect("open state db");
conn.execute_batch(
"CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT NOT NULL);
INSERT INTO threads (id, model_provider) VALUES
('t1', 'custom'),
('t2', 'custom'),
('t3', 'openai');",
)
.expect("seed state db");
drop(conn);
// 代际 meta 指向当前 Codex 目录:精确匹配分支生效(而非无 meta 的宽容分支)
fs::write(
generation.join("meta.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"codexConfigDir": canonical_dir_string(&codex_dir)
}))
.expect("serialize meta"),
)
.expect("write meta");
let outcome = restore_codex_official_history_inner(
&codex_dir,
&ledger_parent,
&restore_backup_root,
"",
)
.expect("restore");
assert_eq!(outcome.restored_jsonl_files, 1);
assert_eq!(outcome.restored_state_rows, 1);
assert!(outcome.skipped_reason.is_none());
let official_text = fs::read_to_string(&official_path).expect("read official");
assert!(official_text.contains("\"model_provider\":\"openai\""));
let on_period_text = fs::read_to_string(&on_period_path).expect("read on-period");
assert!(on_period_text.contains("\"id\":\"s2\",\"model_provider\":\"custom\""));
assert!(on_period_text.contains("\"model_provider\":\"my-private-relay\""));
let conn = Connection::open(&state_db_path).expect("reopen state db");
let provider_of = |thread_id: &str| -> String {
conn.query_row(
"SELECT model_provider FROM threads WHERE id = ?1",
[thread_id],
|row| row.get(0),
)
.expect("thread provider")
};
assert_eq!(provider_of("t1"), "openai");
assert_eq!(provider_of("t2"), "custom");
assert_eq!(provider_of("t3"), "openai");
drop(conn);
// 还原前的现场已备份到独立目录
assert!(restore_backup_root
.join("jsonl/sessions/2026/06/01/official.jsonl")
.exists());
assert!(restore_backup_root
.join("state")
.join(CODEX_STATE_DB_FILENAME)
.exists());
// 幂等:第二次还原无事可做
let rerun = restore_codex_official_history_inner(
&codex_dir,
&ledger_parent,
&dir.path().join("restore-backup-2"),
"",
)
.expect("rerun restore");
assert_eq!(rerun.restored_jsonl_files, 0);
assert_eq!(rerun.restored_state_rows, 0);
assert_eq!(rerun.skipped_reason.as_deref(), Some("nothing_to_restore"));
}
#[test]
fn restore_ignores_backup_generations_from_other_codex_dirs() {
let dir = tempdir().expect("tempdir");
let codex_dir = dir.path().join(".codex");
let ledger_parent = dir.path().join("ledger");
// 账本代际属于另一个 Codex 目录
let generation = ledger_parent.join("20260612_010101");
let backup_session_dir = generation.join("jsonl/sessions/2026/06/01");
fs::create_dir_all(&backup_session_dir).expect("create backup session dir");
fs::write(
backup_session_dir.join("official.jsonl"),
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"openai\"}}\n",
)
.expect("write backup session");
fs::write(
generation.join("meta.json"),
"{\n \"codexConfigDir\": \"/some/other/codex-dir\"\n}",
)
.expect("write meta");
let session_dir = codex_dir.join("sessions/2026/06/01");
fs::create_dir_all(&session_dir).expect("create session dir");
let session_path = session_dir.join("official.jsonl");
fs::write(
&session_path,
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"custom\"}}\n",
)
.expect("write session");
let outcome = restore_codex_official_history_inner(
&codex_dir,
&ledger_parent,
&dir.path().join("restore-backup"),
"",
)
.expect("restore");
assert_eq!(outcome.skipped_reason.as_deref(), Some("no_backup_ledger"));
let text = fs::read_to_string(&session_path).expect("read session");
assert!(text.contains("\"model_provider\":\"custom\""));
}
#[test]
fn backup_probe_only_counts_generations_for_current_dir() {
let dir = tempdir().expect("tempdir");
let ledger_parent = dir.path().join("ledger");
let codex_dir_key = "/current/codex-dir";
// 空父目录 / 父目录不存在:无备份
assert!(!has_official_history_unify_backup_for_dir(
&ledger_parent,
codex_dir_key
));
// 只有其他目录的代际:不算有备份
let other = ledger_parent.join("20260612_010101");
fs::create_dir_all(&other).expect("create generation");
fs::write(
other.join("meta.json"),
"{\n \"codexConfigDir\": \"/some/other/codex-dir\"\n}",
)
.expect("write meta");
assert!(!has_official_history_unify_backup_for_dir(
&ledger_parent,
codex_dir_key
));
// 无 meta 的早期代际:宽容接受(与 restore 的账本口径一致)
fs::create_dir_all(ledger_parent.join("20260612_020202")).expect("create legacy gen");
assert!(has_official_history_unify_backup_for_dir(
&ledger_parent,
codex_dir_key
));
// 精确匹配当前目录的代际
fs::remove_dir_all(ledger_parent.join("20260612_020202")).expect("remove legacy gen");
let matched = ledger_parent.join("20260612_030303");
fs::create_dir_all(&matched).expect("create matched gen");
fs::write(
matched.join("meta.json"),
format!("{{\n \"codexConfigDir\": \"{codex_dir_key}\"\n}}"),
)
.expect("write matched meta");
assert!(has_official_history_unify_backup_for_dir(
&ledger_parent,
codex_dir_key
));
}
#[test]
fn restore_skips_when_no_backup_ledger_exists() {
let dir = tempdir().expect("tempdir");
let codex_dir = dir.path().join(".codex");
let session_dir = codex_dir.join("sessions/2026/06/01");
fs::create_dir_all(&session_dir).expect("create session dir");
fs::write(
session_dir.join("session.jsonl"),
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"custom\"}}\n",
)
.expect("write session");
let outcome = restore_codex_official_history_inner(
&codex_dir,
&dir.path().join("missing-ledger"),
&dir.path().join("restore-backup"),
"",
)
.expect("restore");
assert_eq!(outcome.skipped_reason.as_deref(), Some("no_backup_ledger"));
assert_eq!(outcome.restored_jsonl_files, 0);
assert_eq!(outcome.restored_state_rows, 0);
let text = fs::read_to_string(session_dir.join("session.jsonl")).expect("read session");
assert!(text.contains("\"model_provider\":\"custom\""));
}
#[test]
fn rewrites_only_codex_session_meta_provider_ids() {
let dir = tempdir().expect("tempdir");
+389 -70
View File
@@ -1985,10 +1985,19 @@ fn anchored_official_update_command(tool: &str, bin_path: &str) -> Option<String
official_update_args(tool).map(|args| format!("{} {args}", win_quote_path_for_batch(bin_path)))
}
/// 哪些工具的"官方 self-update"优先于包管理器升级(生成 `<tool> update || <pkg-mgr>`)。
///
/// **codex 刻意不在此列**`codex update` 在 npm 安装上只是裸 `npm install -g
/// @openai/codex`(无 `@latest` / `--include=optional` / 不先卸载),却只检查 exit code、
/// 无条件打印 “Update ran successfully”。当 npm 把平台二进制 optional 依赖
/// `@openai/codex-<triple>` 漏装时它仍 **exit 0 假成功**,使外层 `||` 兜底被短路、损坏被
/// 成功 toast 掩盖(用户报告的 “Missing optional dependency” 即源于此)。因此 codex 一律走
/// npm 锚定升级;真正损坏(`runnable=false`)时由 `installs_anchored_command` 的门控改用
/// `codex_repair_command` 的 uninstall+install 自愈,而非交给 codex 自身的 self-update。
fn prefers_official_update(tool: &str, shell: LifecycleCommandShell) -> bool {
match shell {
LifecycleCommandShell::Posix => {
matches!(tool, "claude" | "codex" | "opencode" | "openclaw")
matches!(tool, "claude" | "opencode" | "openclaw")
}
LifecycleCommandShell::WindowsBatch => {
matches!(
@@ -1997,12 +2006,66 @@ fn prefers_official_update(tool: &str, shell: LifecycleCommandShell) -> bool {
// 安装方式探测失败弹交互 promptspawn npm.cmd 没传 shell:true);静默
// lifecycle 没有 stdin 会挂死,Windows 先锚到包管理器路径,等上游修了
// 再把 opencode 加回这里。
"claude" | "codex" | "openclaw"
"claude" | "openclaw"
)
}
}
}
/// Codex 平台分发包损坏的自愈命令。Codex 的 npm 包是「主包 `@openai/codex`(纯 JS
/// launcher+ 平台二进制 optional 依赖 `@openai/codex-<triple>`」的分发模式(同 esbuild/swc)。
/// 当平台二进制缺失时 codex 跑不起来——`enumerate_tool_installations` 跑 `--version` 会拿到
/// “Missing optional dependency” 的非 0 退出,标记 `runnable=false`。此状态下普通
/// `npm i -g @pkg@latest` 是 **no-op**npm 视 optional 依赖缺失为非致命,reify 又认为主包已是
/// 最新(外加半损坏留下的空 nested `node_modules` 残骸强化「tree 已满足」判断),不会补回平台
/// 二进制。唯一实测可靠的修复是先 `uninstall` 清掉残骸、再 `install` 装回完整的主包 + 平台二进制
/// (实测输出 `added 2 packages`)。
///
/// 锚定到与 codex 入口同目录的 npm(与升级路径一致,不依赖 GUI 非登录进程的 PATH)。`|| true`
/// 让 uninstall 失败(如 nvm 上对半损坏包静默返回非 0)不触发外层 `set -e` 中止,但随后的
/// install 若失败仍会被 `set -e` 捕获并上报给前端 toast。
///
/// **仅对会锚定到 sibling npm 的 node 管理器来源(nvm/fnm/mise/homebrew npm)生效**
/// `runnable=false` 是宽信号(权限 / node 版本 / 任意 `--version` 失败皆可触发),非 npm
/// 全局安装各有自己的二进制分发与修复方式,无脑套 npm uninstall+install 会出错——Homebrew
/// formulareal 在 `Cellar/`)本应 `brew upgrade codex`npm 够不到它反而旁路装第二份 npm
/// 全局 codexVolta/Bun 本应 `volta install`/`bun add`,且 `~/.bun/bin` 下没有 npm、
/// `sibling_bin` 会拼出不存在的路径;system/未知来源无可靠 sibling npm。这些来源一律返回
/// None,让上游继续走 source-specific 的 `anchored_command_from_paths`。白名单与
/// `package_manager_anchored_command_from_paths` 的 sibling-npm 分支对齐。
/// 刻意**不**额外用 `inst.error` 文本确认「确系缺二进制」:enumerate 只保留 stderr 末尾 4 行,
/// 而 codex.js 抛错的 "Missing optional dependency" 行会被尾部 node stack `at ...` 行挤出窗口
/// (实测用户原始错误即如此),强加该条件反而漏修真实缺包;对 npm 全局安装,uninstall+install
/// 对各类损坏都是合理且不会更糟的修复。
#[cfg(not(target_os = "windows"))]
fn codex_repair_command(bin_path: &str, real: &str) -> Option<String> {
// brew formulareal 在 Cellar)→ 不归 npm 管,交回 anchored 走 brew upgrade。
if brew_formula_from_path(real).is_some() {
return None;
}
// 只认会落到 sibling npm 的 node 管理器来源;volta/bun/system/未知交回 anchored。
if !matches!(
infer_install_source(Path::new(bin_path)),
"nvm" | "fnm" | "mise" | "homebrew"
) {
return None;
}
let npm = sibling_bin(bin_path, "npm")?;
let npm = quote_path_if_spaced(&npm);
let pkg = "@openai/codex";
Some(format!(
"{npm} uninstall -g {pkg} || true; {npm} i -g {pkg}@latest"
))
}
/// Windows 暂不做平台分发自愈:Windows 上 codex 的破坏模式不同(EPERM 文件锁 / 版本 bump
/// 残留,见 openai/codex#21872、#19824),且 `.bat` 链的错误处理与 POSIX `set -e` 语义不同,
/// 需要单独设计;先在本问题实际发生的 POSIX 平台落地。返回 None → 上游走正常锚定命令。
#[cfg(target_os = "windows")]
fn codex_repair_command(_bin_path: &str, _real: &str) -> Option<String> {
None
}
#[cfg(not(target_os = "windows"))]
fn package_manager_anchored_command_from_paths(
tool: &str,
@@ -2190,6 +2253,17 @@ fn default_install(installs: &[ToolInstallation]) -> Option<&ToolInstallation> {
fn installs_anchored_command(tool: &str, installs: &[ToolInstallation]) -> Option<String> {
let inst = default_install(installs)?;
let real = inst.real.to_string_lossy();
// Codex 平台分发包损坏自愈:主包在但平台二进制缺失时 codex 跑不起来
// runnable=false),此时正常锚定的 `npm i -g @latest` 是 no-op 修不好——改用
// uninstall+install 重装补回平台二进制。**但仅限会锚定到 sibling npm 的 node 管理器
// 来源**codex_repair_command 内按 source/real 收窄,brew/volta/bun/system 交回下方
// source-specific 锚定,避免误用 npm 重装)。runnable=true 的正常升级也走下方普通锚定
// 路径(且因 codex 不在 prefers_official_update,不会再跑会假成功掩盖损坏的 `codex update`)。
if tool == "codex" && !inst.runnable {
if let Some(cmd) = codex_repair_command(&inst.path, &real) {
return Some(cmd);
}
}
anchored_command_from_paths(tool, &inst.path, &real)
}
@@ -2620,29 +2694,58 @@ exec bash --norc --noprofile
result
}
/// macOS: Terminal.app
/// Escape a value as an AppleScript string literal.
#[cfg(target_os = "macos")]
fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
fn applescript_string_literal(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
let applescript = format!(
r#"tell application "Terminal"
activate
do script "bash '{}'"
/// Build the launcher command literal used by AppleScript.
#[cfg(target_os = "macos")]
fn applescript_launcher_command(script_file: &std::path::Path) -> String {
applescript_string_literal(&format!(
"bash {}",
shell_single_quote(&script_file.to_string_lossy())
))
}
/// macOS: Terminal.app AppleScript.
/// A cold `activate` creates a default empty window before `do script` opens the command session.
/// Use `launch` for cold starts so `do script` can create the only new session without reusing restored windows.
#[cfg(target_os = "macos")]
fn build_macos_terminal_applescript(script_file: &std::path::Path) -> String {
format!(
r#"set launcher_script to {launcher}
set was_running to application "Terminal" is running
tell application "Terminal"
if was_running then
activate
do script launcher_script
else
launch
do script launcher_script
activate
end if
end tell"#,
script_file.display()
);
launcher = applescript_launcher_command(script_file)
)
}
/// Run AppleScript through `osascript -e` with shared error handling.
#[cfg(target_os = "macos")]
fn run_terminal_osascript(applescript: &str, terminal_label: &str) -> Result<(), String> {
use std::process::Command;
let output = Command::new("osascript")
.arg("-e")
.arg(&applescript)
.arg(applescript)
.output()
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() {
let stderr = decode_command_output(&output.stderr);
return Err(format!(
"Terminal.app 执行失败 (exit code: {:?}): {}",
"{terminal_label} 执行失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
@@ -2651,11 +2754,20 @@ end tell"#,
Ok(())
}
/// macOS: Terminal.app
#[cfg(target_os = "macos")]
fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> {
run_terminal_osascript(
&build_macos_terminal_applescript(script_file),
"Terminal.app",
)
}
/// macOS: iTerm2
#[cfg(target_os = "macos")]
fn build_macos_iterm2_applescript(script_file: &std::path::Path) -> String {
format!(
r#"set launcher_script to "bash '{}'"
r#"set launcher_script to {launcher}
set was_running to application "iTerm" is running
tell application "iTerm"
if was_running then
@@ -2683,63 +2795,59 @@ tell application "iTerm"
write text launcher_script
end tell
end tell"#,
script_file.display()
launcher = applescript_launcher_command(script_file)
)
}
/// macOS: iTerm2
#[cfg(target_os = "macos")]
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
let applescript = build_macos_iterm2_applescript(script_file);
let output = Command::new("osascript")
.arg("-e")
.arg(&applescript)
.output()
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() {
let stderr = decode_command_output(&output.stderr);
return Err(format!(
"iTerm2 执行失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
}
Ok(())
run_terminal_osascript(&build_macos_iterm2_applescript(script_file), "iTerm2")
}
/// macOS: Ghostty — use --quit-after-last-window-closed to avoid cloning existing tabs
/// Keep the launcher path inside a `bash -c` string.
/// A bare `.sh` passed through `open --args` may also be opened as a document.
#[cfg(target_os = "macos")]
fn build_macos_dash_c_command(script_file: &std::path::Path) -> String {
format!(
"exec bash {}",
shell_single_quote(&script_file.to_string_lossy())
)
}
/// macOS: Ghostty.
/// Warm starts use AppleScript to create one command window.
/// Cold starts use `initial-command` so the first default surface runs the launcher.
/// Do not use `initial-window=false` plus `new window`: cold launch can still create the default window first.
#[cfg(target_os = "macos")]
fn build_macos_ghostty_applescript(script_file: &std::path::Path) -> String {
format!(
r#"set launcher_command to {launcher}
set was_running to application "Ghostty" is running
if was_running then
tell application "Ghostty"
new window with configuration {{command:launcher_command}}
end tell
else
do shell script "open -na Ghostty --args --quit-after-last-window-closed=true " & quoted form of ("--initial-command=" & launcher_command)
end if
"#,
launcher = applescript_launcher_command(script_file)
)
}
/// macOS: Ghostty
#[cfg(target_os = "macos")]
fn launch_macos_ghostty(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
let output = Command::new("open")
.args([
"-na",
"Ghostty",
"--args",
"--quit-after-last-window-closed=true",
"-e",
"bash",
])
.arg(script_file)
.output()
.map_err(|e| format!("启动 Ghostty 失败: {e}"))?;
if !output.status.success() {
let stderr = decode_command_output(&output.stderr);
return Err(format!(
"Ghostty 启动失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
match run_terminal_osascript(&build_macos_ghostty_applescript(script_file), "Ghostty") {
Ok(()) => Ok(()),
Err(applescript_error) => {
log::warn!(
"Ghostty AppleScript launch failed, falling back to open -na: {applescript_error}"
);
launch_macos_open_app("Ghostty", script_file, true)
}
}
Ok(())
}
/// macOS: 使用 open -na 启动支持 --args 参数的终端(Alacritty/Kitty/WezTerm/Kaku
@@ -2757,7 +2865,10 @@ fn launch_macos_open_app(
if use_e_flag {
cmd.arg("-e");
}
cmd.arg("bash").arg(script_file);
// Keep the script path inside `bash -c`; a trailing bare `.sh` can be opened as a document.
cmd.arg("bash")
.arg("-c")
.arg(build_macos_dash_c_command(script_file));
let output = cmd
.output()
@@ -4009,8 +4120,9 @@ mod tests {
#[test]
fn codex_nvm_anchors_to_that_npm() {
// Codex 官方 self-update 只在支持的 release 上生效;失败时仍写回同一个
// node 的 npm,而非 PATH 第一个 npm。
// Codex 不走 self-update`codex update` 在 npm 安装上只是裸 `npm install -g`
// 却会假成功掩盖平台二进制漏装)——直接锚定到同一个 node 的 npm,而非 PATH
// 第一个 npm。损坏时的 uninstall+install 自愈见 codex_missing_platform_binary_*。
let cmd = anchored_command_from_paths(
"codex",
"/Users/me/.nvm/versions/node/v22.14.0/bin/codex",
@@ -4018,7 +4130,7 @@ mod tests {
);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/codex update || /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
);
}
@@ -4038,9 +4150,25 @@ mod tests {
}
#[test]
fn volta_uses_volta_install() {
fn volta_self_update_chain_anchors_to_volta() {
// `~/.volta/bin` 通常不在 GUI 非登录 `bash -c` 的 PATH 里,且用户可能
// PATH 上还有另一份 volta → 必须绝对路径锚定到命令行命中的这一份。
// 用 openclaw(仍在 prefers_official_update)覆盖 volta 分支的 self-update 链;
// codex 已改为不 self-update(见 codex_volta_anchors_to_volta_install)。
let cmd = anchored_command_from_paths(
"openclaw",
"/Users/me/.volta/bin/openclaw",
"/Users/me/.volta/tools/image/packages/openclaw/lib/node_modules/openclaw",
);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.volta/bin/openclaw update --yes || /Users/me/.volta/bin/volta install openclaw")
);
}
#[test]
fn codex_volta_anchors_to_volta_install() {
// codex 锚定到命令行命中的那份 volta,但不 self-update:纯 `volta install`。
let cmd = anchored_command_from_paths(
"codex",
"/Users/me/.volta/bin/codex",
@@ -4048,7 +4176,7 @@ mod tests {
);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.volta/bin/codex update || /Users/me/.volta/bin/volta install @openai/codex")
Some("/Users/me/.volta/bin/volta install @openai/codex")
);
}
@@ -4076,7 +4204,7 @@ mod tests {
);
assert_eq!(
cmd.as_deref(),
Some("'/Users/my name/.volta/bin/codex' update || '/Users/my name/.volta/bin/volta' install @openai/codex")
Some("'/Users/my name/.volta/bin/volta' install @openai/codex")
);
}
@@ -4144,7 +4272,7 @@ mod tests {
assert_eq!(
cmd.as_deref(),
Some(
"/Users/me/.local/share/fnm_multishells/12345_abc/bin/codex update || /Users/me/.local/share/fnm_multishells/12345_abc/bin/npm i -g @openai/codex@latest"
"/Users/me/.local/share/fnm_multishells/12345_abc/bin/npm i -g @openai/codex@latest"
)
);
}
@@ -4158,7 +4286,7 @@ mod tests {
);
assert_eq!(
cmd.as_deref(),
Some("'/Users/my name/.nvm/versions/node/v22/bin/codex' update || '/Users/my name/.nvm/versions/node/v22/bin/npm' i -g @openai/codex@latest")
Some("'/Users/my name/.nvm/versions/node/v22/bin/npm' i -g @openai/codex@latest")
);
}
@@ -4255,6 +4383,78 @@ mod tests {
assert!(default_install(&installs).is_none());
}
#[test]
fn codex_missing_platform_binary_self_heals_via_uninstall_install() {
// 平台二进制缺失 → `codex --version` 报 "Missing optional dependency" 退出非 0
// → enumerate 标记 runnable=false。此状态下普通 `npm i -g @latest` 是 no-op 修不好,
// 升级路径改用 uninstall+install 重装补回平台二进制(`|| true` 让 uninstall 在
// set -e 下对半损坏包返回非 0 时仍继续 install)。
let mut broken = inst("/Users/me/.nvm/versions/node/v22.14.0/bin/codex", true);
broken.runnable = false;
assert_eq!(
installs_anchored_command("codex", &[broken]).as_deref(),
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm uninstall -g @openai/codex || true; /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
);
}
#[test]
fn codex_runnable_uses_plain_npm_not_self_heal() {
// 正常(runnable=true)的 codex 升级:锚定 npm,既不重装、也不跑会假成功
// 掩盖损坏的 `codex update`。
let healthy = inst("/Users/me/.nvm/versions/node/v22.14.0/bin/codex", true);
let cmd = installs_anchored_command("codex", &[healthy]);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
);
assert!(!cmd.unwrap().contains("uninstall"));
}
#[test]
fn codex_broken_homebrew_formula_uses_brew_not_npm_repair() {
// brew formula 装的坏 codexreal 在 Cellar):自愈门控必须收窄放行,回落到
// `brew upgrade codex`——若误走 npm 重装,npm 够不到 Cellar 那份、反而旁路
// 装第二份 npm 全局 codex 制造双安装。
let broken = ToolInstallation {
path: "/opt/homebrew/bin/codex".to_string(),
version: None,
runnable: false,
error: None,
source: "homebrew".to_string(),
is_path_default: true,
real: std::path::PathBuf::from("/opt/homebrew/Cellar/codex/1.2.3/bin/codex"),
};
assert_eq!(
installs_anchored_command("codex", &[broken]).as_deref(),
Some("/opt/homebrew/bin/brew upgrade codex")
);
}
#[test]
fn codex_broken_volta_uses_volta_install_not_npm_repair() {
// volta 装的坏 codex:回落到 `volta install`,不走 npm 重装。
let mut broken = inst("/Users/me/.volta/bin/codex", true);
broken.runnable = false;
assert_eq!(
installs_anchored_command("codex", &[broken]).as_deref(),
Some("/Users/me/.volta/bin/volta install @openai/codex")
);
}
#[test]
fn codex_broken_bun_uses_bun_add_not_phantom_npm() {
// bun 装的坏 codex:回落到 `bun add`,且**绝不**拼出 `~/.bun/bin/npm`
// (bun 目录下没有 npm,那条路径不存在、执行会直接失败)。
let mut broken = inst("/Users/me/.bun/bin/codex", true);
broken.runnable = false;
let cmd = installs_anchored_command("codex", &[broken]);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.bun/bin/bun add -g @openai/codex@latest")
);
assert!(!cmd.unwrap().contains("npm"));
}
#[test]
fn first_abs_path_line_skips_shell_noise() {
// 交互式 .zshrc 先打印欢迎语(如 powerlevel10k / 自定义提示),
@@ -4382,8 +4582,9 @@ mod tests {
);
assert_eq!(
static_fallback_command("codex"),
"codex update || npm i -g @openai/codex@latest"
"npm i -g @openai/codex@latest"
);
assert!(!static_fallback_command("codex").contains("codex update"));
assert_eq!(
static_fallback_command("gemini"),
"npm i -g @google/gemini-cli@latest"
@@ -4693,6 +4894,124 @@ mod tests {
assert!(running_branch.contains("create tab with default profile"));
}
/// Terminal `activate` creates a default empty window on cold start; `launch` does not.
#[cfg(target_os = "macos")]
#[test]
fn terminal_applescript_cold_start_uses_launch_before_do_script() {
let script = build_macos_terminal_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
assert!(
script.contains(r#"set was_running to application "Terminal" is running"#),
"missing was_running detection:\n{script}"
);
// Cold launches avoid `activate` until after `do script`, so no default empty window is created first.
assert!(
script.contains(
"else\n launch\n do script launcher_script\n activate"
),
"cold start should launch before activating:\n{script}"
);
// Already-running launches should create a fresh session.
assert!(
script.contains(
"if was_running then\n activate\n do script launcher_script\n"
),
"already-running branch should use bare do script:\n{script}"
);
}
/// Restored windows should not receive the launcher command.
#[cfg(target_os = "macos")]
#[test]
fn terminal_applescript_does_not_hijack_restored_windows() {
let script = build_macos_terminal_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
assert!(
!script.contains(" in window 1"),
"should not inject into an existing/restored Terminal window:\n{script}"
);
assert!(
!script.contains("count of windows"),
"should not infer restored-window safety from window count:\n{script}"
);
}
/// Ghostty cold starts use `initial-command`; warm starts use the scripting dictionary.
#[cfg(target_os = "macos")]
#[test]
fn ghostty_applescript_cold_start_uses_initial_command() {
let script = build_macos_ghostty_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
// Warm launches execute through the AppleScript command property, not `open -na ... -e`.
assert!(
script.contains(r#"set launcher_command to "bash '/tmp/cc_switch_launcher.sh'""#),
"missing launcher_command:\n{script}"
);
assert!(script.contains("if was_running then"));
assert!(script.contains("new window with configuration {command:launcher_command}"));
assert!(
!script.contains(" --args -e"),
"should not execute through open -na -e:\n{script}"
);
// Cold launches make Ghostty's first default surface execute the launcher.
assert!(script.contains(r#"set was_running to application "Ghostty" is running"#));
assert!(
script.contains(
r#"do shell script "open -na Ghostty --args --quit-after-last-window-closed=true " & quoted form of ("--initial-command=" & launcher_command)"#
),
"cold start should use initial-command:\n{script}"
);
assert!(
!script.contains("--initial-window=false"),
"should not rely on initial-window=false:\n{script}"
);
assert!(
!script.contains("delay 0.5"),
"should not rely on a fixed delay:\n{script}"
);
assert!(
!script.contains("old_ids"),
"should not track default windows for closing:\n{script}"
);
assert!(
!script.contains("close window"),
"should not close a default window:\n{script}"
);
}
#[cfg(target_os = "macos")]
#[test]
fn dash_c_command_wraps_script_path_inside_quoted_arg() {
// The script path must stay inside the `-c` string, not as a bare argv.
let s = build_macos_dash_c_command(Path::new("/tmp/cc_switch_launcher_1.sh"));
assert_eq!(s, "exec bash '/tmp/cc_switch_launcher_1.sh'");
// Spaces and single quotes must stay shell-safe too.
let s2 = build_macos_dash_c_command(Path::new("/Users/me/it's dir/x.sh"));
assert_eq!(s2, r#"exec bash '/Users/me/it'"'"'s dir/x.sh'"#);
}
/// AppleScript launchers need both shell-path quoting and AppleScript string quoting.
#[cfg(target_os = "macos")]
#[test]
fn applescript_builders_safely_quote_special_paths() {
// First shell-quote the path, then wrap the whole command as an AppleScript string.
let expected = r#""bash '/Users/me/it'\"'\"'s dir/x.sh'""#;
let p = Path::new("/Users/me/it's dir/x.sh");
assert_eq!(applescript_launcher_command(p), expected);
assert!(
build_macos_terminal_applescript(p).contains(expected),
"Terminal did not quote safely"
);
assert!(
build_macos_iterm2_applescript(p).contains(expected),
"iTerm2 did not quote safely"
);
assert!(
build_macos_ghostty_applescript(p).contains(expected),
"Ghostty did not quote safely"
);
}
#[test]
fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
let command = build_windows_cwd_command_str(r"C:\work\repo");
+254 -21
View File
@@ -1,6 +1,7 @@
#![allow(non_snake_case)]
use tauri::AppHandle;
use tauri_plugin_updater::UpdaterExt;
fn merge_settings_for_save(
mut incoming: crate::settings::AppSettings,
@@ -35,24 +36,11 @@ fn merge_settings_for_save(
}
_ => {}
}
if incoming.local_migrations.is_none() {
incoming.local_migrations = existing.local_migrations.clone();
} else if let (Some(incoming_migrations), Some(existing_migrations)) =
(&mut incoming.local_migrations, &existing.local_migrations)
{
if incoming_migrations
.codex_third_party_history_provider_bucket_v1
.is_none()
{
incoming_migrations.codex_third_party_history_provider_bucket_v1 = existing_migrations
.codex_third_party_history_provider_bucket_v1
.clone();
}
if incoming_migrations.codex_provider_template_v1.is_none() {
incoming_migrations.codex_provider_template_v1 =
existing_migrations.codex_provider_template_v1.clone();
}
}
// local_migrations 是纯后端状态(迁移完成标记),前端没有合法的修改场景,
// 无条件取现有值。若按 incoming 透传:后端清掉 marker(如关闭统一会话
// 开关)后、前端 query 缓存刷新前的一次全量保存会把旧 marker 重放回来,
// 重新开启时被"复活"的标记挡住而漏迁。
incoming.local_migrations = existing.local_migrations.clone();
incoming
}
@@ -64,13 +52,117 @@ pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
/// 保存设置
#[tauri::command]
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
pub async fn save_settings(
state: tauri::State<'_, crate::store::AppState>,
settings: crate::settings::AppSettings,
) -> Result<bool, String> {
let existing = crate::settings::get_settings();
let merged = merge_settings_for_save(settings, &existing);
let unify_codex_changed =
merged.unify_codex_session_history != existing.unify_codex_session_history;
let unify_codex_enabled = merged.unify_codex_session_history;
crate::settings::update_settings(merged).map_err(|e| e.to_string())?;
// 统一会话开关变更时立即重写当前官方 Codex 供应商的 live 配置,
// 不必等下一次切换才生效。
if unify_codex_changed {
// live 重写失败时回滚设置并把保存整体报失败:若设置保持已切换状态,
// live 仍跑旧桶,后续的历史迁移/还原会让会话再次分裂(开启=历史
// 迁走而新会话仍写 openai 桶;关闭=会话还原而 live 仍写 custom)。
// 报错让前端 saved=false 短路还原;回滚是整次保存的事务语义
// (本开关的保存只携带开关相关字段)。
if let Err(err) =
crate::services::provider::reapply_current_codex_official_live(state.inner())
{
log::warn!("统一 Codex 会话历史开关变更后重写 live 配置失败,回滚设置: {err}");
if let Err(rollback_err) = crate::settings::update_settings(existing) {
log::error!("回滚统一会话开关设置失败: {rollback_err}");
}
return Err(format!(
"统一 Codex 会话历史开关未生效(live 配置重写失败): {err}"
));
}
if unify_codex_enabled {
// 后台执行存量迁移(openai 桶 → custom 桶;仅当用户勾选了迁入既有
// 会话,函数内部自门控)。大会话目录可能要读数秒,不能阻塞设置保存;
// 失败时不写完成标记,下次启动自动重试。
tauri::async_runtime::spawn_blocking(|| {
match crate::codex_history_migration::maybe_migrate_codex_official_history_to_unified_bucket() {
Ok(outcome) => {
if let Some(reason) = outcome.skipped_reason {
log::debug!("○ Codex official history unify migration skipped: {reason}");
} else {
log::info!(
"✓ Codex official history unify migration completed: jsonl_files={}, state_rows={}",
outcome.migrated_jsonl_files,
outcome.migrated_state_rows
);
}
}
Err(e) => {
log::warn!("✗ Codex official history unify migration failed: {e}");
}
}
});
} else {
// 清除标记与迁移意愿,让重新开启并再次勾选时能补迁
// 关闭期间落入 openai 桶的官方会话。
if let Err(err) = crate::settings::clear_codex_official_history_unify_migration() {
log::warn!("清除统一会话迁移标记失败: {err}");
}
if let Err(err) = crate::settings::clear_codex_unify_migrate_existing() {
log::warn!("清除统一会话迁移意愿失败: {err}");
}
}
}
Ok(true)
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexUnifyHistoryRestoreResult {
pub restored_jsonl_files: usize,
pub restored_state_rows: usize,
/// 还原被跳过的原因(如当前目录没有账本),前端据此提示而非报"成功 0 项"。
#[serde(skip_serializing_if = "Option::is_none")]
pub skipped_reason: Option<String>,
}
/// 是否存在统一会话开关的迁移备份(决定关闭弹窗里是否显示"恢复备份"勾选)。
#[tauri::command]
pub async fn has_codex_unify_history_backup() -> Result<bool, String> {
Ok(crate::codex_history_migration::has_codex_official_history_unify_backup())
}
/// 按迁移备份账本把当时迁入共享桶的官方会话还原回 "openai" 桶。
/// 由关闭统一会话开关的确认弹窗触发;幂等,可安全重试。
#[tauri::command]
pub async fn restore_codex_unified_history() -> Result<CodexUnifyHistoryRestoreResult, String> {
let outcome = tauri::async_runtime::spawn_blocking(|| {
crate::codex_history_migration::restore_codex_official_history_from_backups()
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
if let Some(reason) = &outcome.skipped_reason {
log::debug!("○ Codex official history restore skipped: {reason}");
} else {
log::info!(
"✓ Codex official history restored from backups: jsonl_files={}, state_rows={}",
outcome.restored_jsonl_files,
outcome.restored_state_rows
);
}
Ok(CodexUnifyHistoryRestoreResult {
restored_jsonl_files: outcome.restored_jsonl_files,
restored_state_rows: outcome.restored_state_rows,
skipped_reason: outcome.skipped_reason,
})
}
/// 重启应用程序(当 app_config_dir 变更后使用)
#[tauri::command]
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
@@ -79,11 +171,80 @@ pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
// 在后台延迟重启,让函数有时间返回响应
tauri::async_runtime::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// app.restart() 走 RESTART_EXIT_CODE 路径,ExitRequested 处理器会直接
// 放行给 Tauri 默认 re-exec,不执行代理/Live 清理。但本命令用于
// app_config_dir 变更后的重启:新实例会切到新数据库,拿不到旧库里的
// Live 备份,无法恢复被接管的 Live 配置。因此必须趁旧实例的事件循环
// 仍存活,在这里同步完成恢复(保留代理状态,新实例启动时自动重新接管)。
crate::cleanup_before_exit(&app).await;
app.restart();
});
Ok(true)
}
/// 下载并安装应用更新,然后由后端直接重启应用。
///
/// macOS 更新会原地替换 `.app` bundle。如果先返回前端、再让旧 WebView 调
/// `process.relaunch()`,旧进程可能已经处在 bundle 被替换后的不稳定窗口期。
/// 这里把退出清理、安装和重启串在同一个后端流程中,避免依赖旧前端继续执行。
#[tauri::command]
pub async fn install_update_and_restart(app: AppHandle) -> Result<bool, String> {
let updater = app
.updater_builder()
.build()
.map_err(|e| format!("初始化更新器失败: {e}"))?;
let Some(update) = updater
.check()
.await
.map_err(|e| format!("检查更新失败: {e}"))?
else {
return Ok(false);
};
log::info!("开始下载应用更新: {}", update.version);
let bytes = update
.download(|_, _| {}, || {})
.await
.map_err(|e| format!("下载更新失败: {e}"))?;
log::info!("开始安装应用更新: {}", update.version);
#[cfg(target_os = "windows")]
{
// Windows updater 会在 install() 内启动安装器并直接退出当前进程
// (插件内部 std::process::exit(0),绕过 TrayIcon::drop、不发
// NIM_DELETE,会残留死图标——与托盘"退出"路径相同的问题)。
// 因此清理只能放在 install 前执行,且必须显式移除托盘图标。
crate::save_window_state_before_exit(&app);
crate::cleanup_before_exit(&app).await;
crate::remove_tray_icon_before_exit(&app);
crate::destroy_single_instance_lock(&app);
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
update.install(bytes).map_err(|e| {
format!(
"Windows 更新安装失败: {e}。已执行退出前清理,代理或 Live 接管可能已暂停;请重启应用或重新开启代理后再试。"
)
})?;
return Ok(true);
}
#[cfg(not(target_os = "windows"))]
{
// macOS/Linux install() 会返回;先安装,避免安装失败时误停代理/撤回接管。
update
.install(bytes)
.map_err(|e| format!("安装更新失败: {e}"))?;
crate::save_window_state_before_exit(&app);
crate::cleanup_before_exit(&app).await;
log::info!("应用更新安装完成,正在重启应用");
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
crate::restart_process(&app);
}
}
/// 获取 app_config_dir 覆盖配置 (从 Store)
#[tauri::command]
pub async fn get_app_config_dir_override(app: AppHandle) -> Result<Option<String>, String> {
@@ -116,8 +277,9 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
mod tests {
use super::merge_settings_for_save;
use crate::settings::{
AppSettings, CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration,
LocalMigrations, S3SyncSettings, WebDavSyncSettings,
AppSettings, CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration,
CodexThirdPartyHistoryProviderBucketMigration, LocalMigrations, S3SyncSettings,
WebDavSyncSettings,
};
#[test]
@@ -316,6 +478,13 @@ mod tests {
completed_at: "2026-05-20T00:01:00Z".to_string(),
migrated_provider_ids: vec!["legacy".to_string()],
}),
codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration {
completed_at: "2026-06-12T00:00:00Z".to_string(),
target_provider_id: "custom".to_string(),
migrated_jsonl_files: 5,
migrated_state_rows: 7,
codex_config_dir: None,
}),
}),
..AppSettings::default()
};
@@ -345,6 +514,70 @@ mod tests {
template_migration.migrated_provider_ids,
vec!["legacy".to_string()]
);
let unify_migration = merged
.local_migrations
.as_ref()
.and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref())
.expect("official unify migration marker should be preserved");
assert_eq!(unify_migration.migrated_jsonl_files, 5);
assert_eq!(unify_migration.migrated_state_rows, 7);
}
/// incoming 带有 local_migrations(哪怕是空的)也不能覆盖后端维护的标记。
#[test]
fn save_settings_should_keep_backend_migration_markers_over_incoming() {
let existing = AppSettings {
local_migrations: Some(LocalMigrations {
codex_third_party_history_provider_bucket_v1: None,
codex_provider_template_v1: None,
codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration {
completed_at: "2026-06-12T00:00:00Z".to_string(),
target_provider_id: "custom".to_string(),
migrated_jsonl_files: 1,
migrated_state_rows: 2,
codex_config_dir: None,
}),
}),
..AppSettings::default()
};
let incoming = AppSettings {
local_migrations: Some(LocalMigrations::default()),
..AppSettings::default()
};
let merged = merge_settings_for_save(incoming, &existing);
assert!(merged
.local_migrations
.as_ref()
.and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref())
.is_some());
}
/// 后端清掉 marker 后(如关闭统一会话开关)、前端缓存刷新前的全量保存
/// 会携带旧 markermerge 必须忽略它,否则被"复活"的标记会让重新开启
/// 时误判已迁移而漏迁。
#[test]
fn save_settings_should_ignore_stale_incoming_migration_markers() {
let existing = AppSettings::default();
let incoming = AppSettings {
local_migrations: Some(LocalMigrations {
codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration {
completed_at: "2026-06-12T00:00:00Z".to_string(),
target_provider_id: "custom".to_string(),
migrated_jsonl_files: 1,
migrated_state_rows: 2,
codex_config_dir: None,
}),
..LocalMigrations::default()
}),
..AppSettings::default()
};
let merged = merge_settings_for_save(incoming, &existing);
assert!(merged.local_migrations.is_none());
}
}
+30 -151
View File
@@ -1,4 +1,7 @@
//! 流式健康检查命令
//! 供应商连通性检查命令
//!
//! 注意:本检查只探测 base_url 是否可达,不发真实大模型请求,也不触碰故障转移
//! 熔断器(熔断器由真实转发流量驱动)。详见 `services::stream_check`。
use crate::app_config::AppType;
use crate::commands::copilot::CopilotAuthState;
@@ -10,7 +13,7 @@ use crate::store::AppState;
use std::collections::HashSet;
use tauri::State;
/// 流式健康检查(单个供应商)
/// 连通性检查(单个供应商)
#[tauri::command]
pub async fn stream_check_provider(
state: State<'_, AppState>,
@@ -25,25 +28,12 @@ pub async fn stream_check_provider(
.get(&provider_id)
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
// Copilot 端点是动态的(随 OAuth token 解析),需预先取出 host 再探测;
// 其余供应商传 None,由服务层从 settings_config 提取 base_url。无需鉴权。
let base_url_override = resolve_copilot_base_url_override(provider, &copilot_state).await?;
let claude_api_format_override = resolve_claude_api_format_override(
&app_type,
provider,
&config,
&copilot_state,
auth_override.as_ref(),
)
.await?;
let result = StreamCheckService::check_with_retry(
&app_type,
provider,
&config,
auth_override,
base_url_override,
claude_api_format_override,
)
.await?;
let result =
StreamCheckService::check_with_retry(&app_type, provider, &config, base_url_override)
.await?;
// 记录日志
let _ =
@@ -54,7 +44,7 @@ pub async fn stream_check_provider(
Ok(result)
}
/// 批量流式健康检查
/// 批量连通性检查
#[tauri::command]
pub async fn stream_check_all_providers(
state: State<'_, AppState>,
@@ -65,7 +55,6 @@ pub async fn stream_check_all_providers(
let config = state.db.get_stream_check_config()?;
let providers = state.db.get_all_providers(app_type.as_str())?;
let mut results = Vec::new();
let allowed_ids: Option<HashSet<String>> = if proxy_targets_only {
let mut ids = HashSet::new();
if let Ok(Some(current_id)) = state.db.get_current_provider(app_type.as_str()) {
@@ -81,6 +70,7 @@ pub async fn stream_check_all_providers(
None
};
let mut results = Vec::new();
for (id, provider) in providers {
if let Some(ids) = &allowed_ids {
if !ids.contains(&id) {
@@ -88,54 +78,22 @@ pub async fn stream_check_all_providers(
}
}
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
let base_url_override =
resolve_copilot_base_url_override(&provider, &copilot_state).await?;
let claude_api_format_override = resolve_claude_api_format_override(
&app_type,
&provider,
&config,
&copilot_state,
auth_override.as_ref(),
)
.await
.unwrap_or_else(|e| {
log::warn!(
"[StreamCheck] Failed to resolve Claude API format override for {}: {}",
provider.id,
e
);
None
});
let result = StreamCheckService::check_with_retry(
&app_type,
&provider,
&config,
auth_override,
base_url_override,
claude_api_format_override,
)
.await
.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 result =
StreamCheckService::check_with_retry(&app_type, &provider, &config, base_url_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,
error_category: None,
});
let _ = state
.db
@@ -147,13 +105,13 @@ pub async fn stream_check_all_providers(
Ok(results)
}
/// 获取流式检查配置
/// 获取连通性检查配置
#[tauri::command]
pub fn get_stream_check_config(state: State<'_, AppState>) -> Result<StreamCheckConfig, AppError> {
state.db.get_stream_check_config()
}
/// 保存流式检查配置
/// 保存连通性检查配置
#[tauri::command]
pub fn save_stream_check_config(
state: State<'_, AppState>,
@@ -162,39 +120,8 @@ pub fn save_stream_check_config(
state.db.save_stream_check_config(&config)
}
async fn resolve_copilot_auth_override(
provider: &crate::provider::Provider,
copilot_state: &State<'_, CopilotAuthState>,
) -> Result<Option<crate::proxy::providers::AuthInfo>, AppError> {
let is_copilot = is_copilot_provider(provider);
if !is_copilot {
return Ok(None);
}
let auth_manager = copilot_state.0.read().await;
let account_id = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
let token = match account_id.as_deref() {
Some(id) => auth_manager
.get_valid_token_for_account(id)
.await
.map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?,
None => auth_manager
.get_valid_token()
.await
.map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?,
};
Ok(Some(crate::proxy::providers::AuthInfo::new(
token,
crate::proxy::providers::AuthStrategy::GitHubCopilot,
)))
}
/// Copilot 供应商的 base_url 需要从 OAuth 管理器动态解析(按账号或默认端点)。
/// `is_full_url` 的供应商已是完整地址,无需解析。
async fn resolve_copilot_base_url_override(
provider: &crate::provider::Provider,
copilot_state: &State<'_, CopilotAuthState>,
@@ -238,54 +165,6 @@ fn is_copilot_provider(provider: &crate::provider::Provider) -> bool {
.unwrap_or(false)
}
async fn resolve_claude_api_format_override(
app_type: &AppType,
provider: &crate::provider::Provider,
config: &StreamCheckConfig,
copilot_state: &State<'_, CopilotAuthState>,
auth_override: Option<&crate::proxy::providers::AuthInfo>,
) -> Result<Option<String>, AppError> {
if *app_type != AppType::Claude {
return Ok(None);
}
let is_copilot = auth_override
.map(|auth| auth.strategy == crate::proxy::providers::AuthStrategy::GitHubCopilot)
.unwrap_or(false);
if !is_copilot {
return Ok(None);
}
let model_id = StreamCheckService::resolve_effective_test_model(app_type, provider, config);
let auth_manager = copilot_state.0.read().await;
let account_id = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
let vendor_result = match account_id.as_deref() {
Some(id) => {
auth_manager
.get_model_vendor_for_account(id, &model_id)
.await
}
None => auth_manager.get_model_vendor(&model_id).await,
};
let api_format = match vendor_result {
Ok(Some(vendor)) if vendor.eq_ignore_ascii_case("openai") => "openai_responses",
Ok(Some(_)) | Ok(None) => "openai_chat",
Err(err) => {
log::warn!(
"[StreamCheck] Failed to resolve Copilot model vendor for {model_id}: {err}. Falling back to chat/completions"
);
"openai_chat"
}
};
Ok(Some(api_format.to_string()))
}
#[cfg(test)]
mod tests {
use super::is_copilot_provider;
+44 -13
View File
@@ -14,10 +14,16 @@ pub fn get_usage_summary(
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>,
provider_name: Option<String>,
model: Option<String>,
) -> Result<UsageSummary, AppError> {
state
.db
.get_usage_summary(start_date, end_date, app_type.as_deref())
state.db.get_usage_summary(
start_date,
end_date,
app_type.as_deref(),
provider_name.as_deref(),
model.as_deref(),
)
}
/// 获取按 app_type 拆分的使用量汇总
@@ -26,8 +32,15 @@ pub fn get_usage_summary_by_app(
state: State<'_, AppState>,
start_date: Option<i64>,
end_date: Option<i64>,
provider_name: Option<String>,
model: Option<String>,
) -> Result<Vec<UsageSummaryByApp>, AppError> {
state.db.get_usage_summary_by_app(start_date, end_date)
state.db.get_usage_summary_by_app(
start_date,
end_date,
provider_name.as_deref(),
model.as_deref(),
)
}
/// 获取每日趋势
@@ -37,10 +50,16 @@ pub fn get_usage_trends(
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>,
provider_name: Option<String>,
model: Option<String>,
) -> Result<Vec<DailyStats>, AppError> {
state
.db
.get_daily_trends(start_date, end_date, app_type.as_deref())
state.db.get_daily_trends(
start_date,
end_date,
app_type.as_deref(),
provider_name.as_deref(),
model.as_deref(),
)
}
/// 获取 Provider 统计
@@ -50,10 +69,16 @@ pub fn get_provider_stats(
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>,
provider_name: Option<String>,
model: Option<String>,
) -> Result<Vec<ProviderStats>, AppError> {
state
.db
.get_provider_stats(start_date, end_date, app_type.as_deref())
state.db.get_provider_stats(
start_date,
end_date,
app_type.as_deref(),
provider_name.as_deref(),
model.as_deref(),
)
}
/// 获取模型统计
@@ -63,10 +88,16 @@ pub fn get_model_stats(
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>,
provider_name: Option<String>,
model: Option<String>,
) -> Result<Vec<ModelStats>, AppError> {
state
.db
.get_model_stats(start_date, end_date, app_type.as_deref())
state.db.get_model_stats(
start_date,
end_date,
app_type.as_deref(),
provider_name.as_deref(),
model.as_deref(),
)
}
/// 获取请求日志列表
+16
View File
@@ -1284,6 +1284,14 @@ impl Database {
"1.00",
"12.50",
),
(
"claude-mythos-5",
"Claude Mythos 5",
"10",
"50",
"1.00",
"12.50",
),
// Claude 4.8 系列
(
"claude-opus-4-8",
@@ -1795,6 +1803,14 @@ impl Database {
),
("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"),
("kimi-k2.6", "Kimi K2.6", "0.95", "4.00", "0.16", "0"),
(
"kimi-k2.7-code",
"Kimi K2.7 Code",
"0.95",
"4.00",
"0.19",
"0",
),
// MiniMax 系列
("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
(
+49 -1
View File
@@ -600,6 +600,25 @@ pub fn run() {
log::warn!("✗ Codex provider template bucket migration failed: {e}");
}
}
// 统一会话开关的官方历史迁移:开关开启但上次未完成(如文件被占用
// 中途失败)时在启动期重试;函数内部自门控,开关关闭时直接跳过。
match crate::codex_history_migration::maybe_migrate_codex_official_history_to_unified_bucket() {
Ok(outcome) => {
if let Some(reason) = outcome.skipped_reason {
log::debug!("○ Codex official history unify migration skipped: {reason}");
} else {
log::info!(
"✓ Codex official history unify migration completed: jsonl_files={}, state_rows={}",
outcome.migrated_jsonl_files,
outcome.migrated_state_rows
);
}
}
Err(e) => {
log::warn!("✗ Codex official history unify migration failed: {e}");
}
}
});
}
@@ -1156,6 +1175,8 @@ pub fn run() {
commands::read_live_provider_settings,
commands::get_settings,
commands::save_settings,
commands::has_codex_unify_history_backup,
commands::restore_codex_unified_history,
commands::get_rectifier_config,
commands::set_rectifier_config,
commands::get_optimizer_config,
@@ -1165,6 +1186,7 @@ pub fn run() {
commands::get_log_config,
commands::set_log_config,
commands::restart_app,
commands::install_update_and_restart,
commands::check_for_updates,
commands::is_portable_mode,
commands::copy_text_to_clipboard,
@@ -1645,7 +1667,7 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
/// 触发 tray-icon 内部的 `remove_tray_icon` → `Shell_NotifyIconW(NIM_DELETE)`
/// 在进程结束前干净地把图标摘掉。其它平台 `set_visible(false)` 也是
/// 正常的隐藏/移除语义,作为跨平台兜底也安全。
fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
if let Some(tray) = app_handle.tray_by_id(tray::TRAY_ID) {
if let Err(e) = tray.set_visible(false) {
log::warn!("退出时移除托盘图标失败: {e}");
@@ -1962,6 +1984,32 @@ pub fn save_window_state_before_exit(app_handle: &tauri::AppHandle) {
}
}
/// 主动释放 single-instance 锁。
///
/// macOS single-instance 使用 `/tmp/{identifier}.sock`。我们有若干路径会直接
/// `std::process::exit(0)`,不会触发插件挂在 `RunEvent::Exit` 上的清理钩子。
/// 重启前主动 destroy 可以避免新进程误连旧 listener 后自行退出。
pub fn destroy_single_instance_lock(app_handle: &tauri::AppHandle) {
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
tauri_plugin_single_instance::destroy(app_handle);
}
/// 清理托盘图标、释放 single-instance 锁后重启当前应用。
///
/// 直接走 `tauri::process::restart`spawn 新进程 + `exit(0)`),不经过事件
/// 循环退出,因此 Tauri 内部的 `cleanup_before_exit` 和各插件的
/// `RunEvent::Exit` 钩子都不会执行。需要的清理由调用方与本函数显式补偿:
/// 窗口状态、代理/Live 恢复(调用方);托盘图标、single-instance 锁(本函数)。
///
/// 有意不调 `AppHandle::cleanup_before_exit()`:它会在调用线程上 Drop 托盘
/// 图标,而 macOS 的 NSStatusItem 操作要求主线程;`set_visible(false)` 走
/// `run_item_main_thread` 代理,跨线程安全(见 `remove_tray_icon_before_exit`)。
pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
remove_tray_icon_before_exit(app_handle);
destroy_single_instance_lock(app_handle);
tauri::process::restart(&app_handle.env());
}
#[cfg(test)]
mod tests {
use super::{classify_exit_request, ExitRequestAction};
+1 -7
View File
@@ -287,21 +287,15 @@ pub struct UsageResult {
pub error: Option<String>,
}
/// 供应商单独的模型测试配置
/// 供应商单独的连通检测配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderTestConfig {
/// 是否启用单独配置(false 时使用全局配置)
#[serde(default)]
pub enabled: bool,
/// 测试用的模型名称(覆盖全局配置)
#[serde(rename = "testModel", skip_serializing_if = "Option::is_none")]
pub test_model: Option<String>,
/// 超时时间(秒)
#[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
/// 测试提示词
#[serde(rename = "testPrompt", skip_serializing_if = "Option::is_none")]
pub test_prompt: Option<String>,
/// 降级阈值(毫秒)
#[serde(
rename = "degradedThresholdMs",
+67 -1
View File
@@ -11,6 +11,7 @@ pub struct ModelMapping {
pub haiku_model: Option<String>,
pub sonnet_model: Option<String>,
pub opus_model: Option<String>,
pub fable_model: Option<String>,
pub default_model: Option<String>,
}
@@ -35,6 +36,11 @@ impl ModelMapping {
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
fable_model: env
.and_then(|e| e.get("ANTHROPIC_DEFAULT_FABLE_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
default_model: env
.and_then(|e| e.get("ANTHROPIC_MODEL"))
.and_then(|v| v.as_str())
@@ -48,6 +54,7 @@ impl ModelMapping {
self.haiku_model.is_some()
|| self.sonnet_model.is_some()
|| self.opus_model.is_some()
|| self.fable_model.is_some()
|| self.default_model.is_some()
}
@@ -56,6 +63,16 @@ impl ModelMapping {
let model_lower = original_model.to_lowercase();
// 1. 按模型类型匹配
if model_lower.contains("fable") {
if let Some(ref m) = self.fable_model {
return m.clone();
}
// 未单独配置 fable 档时归入 opus 档,与 Claude Code 官方
// 分类器降级方向一致(fable→opus),避免落到 default 失去层级。
if let Some(ref m) = self.opus_model {
return m.clone();
}
}
if model_lower.contains("haiku") {
if let Some(ref m) = self.haiku_model {
return m.clone();
@@ -154,7 +171,8 @@ 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_DEFAULT_OPUS_MODEL": "opus-mapped",
"ANTHROPIC_DEFAULT_FABLE_MODEL": "fable-mapped"
}
}),
website_url: None,
@@ -214,6 +232,54 @@ mod tests {
assert_eq!(mapped, Some("opus-mapped".to_string()));
}
#[test]
fn test_fable_mapping() {
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-fable-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "fable-mapped");
assert_eq!(mapped, Some("fable-mapped".to_string()));
}
#[test]
fn test_fable_with_one_m_suffix_mapping() {
// Claude Code 实际会发 claude-fable-5[1m] 形态(issue #3980
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-fable-5[1m]"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "fable-mapped");
assert_eq!(mapped, Some("fable-mapped".to_string()));
}
#[test]
fn test_fable_falls_back_to_opus_when_unset() {
let mut provider = create_provider_with_mapping();
provider.settings_config = json!({
"env": {
"ANTHROPIC_MODEL": "default-model",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped"
}
});
let body = json!({"model": "claude-fable-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "opus-mapped");
assert_eq!(mapped, Some("opus-mapped".to_string()));
}
#[test]
fn test_fable_falls_back_to_default_without_opus() {
let mut provider = create_provider_with_mapping();
provider.settings_config = json!({
"env": {
"ANTHROPIC_MODEL": "default-model"
}
});
let body = json!({"model": "claude-fable-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "default-model");
assert_eq!(mapped, Some("default-model".to_string()));
}
#[test]
fn test_thinking_does_not_affect_model_mapping() {
// Issue #2081: thinking 参数不应影响模型映射
+1 -2
View File
@@ -48,8 +48,7 @@ pub use claude::{
pub use codex::CodexAdapter;
pub use codex::{
apply_codex_chat_upstream_model, codex_provider_upstream_model,
codex_provider_uses_chat_completions, is_origin_only_url, resolve_codex_chat_reasoning_config,
should_convert_codex_responses_to_chat,
resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_chat,
};
pub use gemini::GeminiAdapter;
@@ -112,6 +112,10 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
}
/// Anthropic 请求 → OpenAI Chat Completions 请求
///
/// 转换工具库 API:当前无生产调用方(连通性检查不再发真实请求,曾是其唯一 crate 内
/// 消费者),但保留其转换逻辑与下方测试套件,供代理转换路径复用 / 未来接线。
#[allow(dead_code)]
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
anthropic_to_openai_with_reasoning_content(body, false)
}
@@ -39,6 +39,11 @@ pub(crate) fn is_synthesized_tool_call_id(id: &str) -> bool {
id.starts_with(SYNTHESIZED_ID_PREFIX)
}
/// Anthropic 请求 → Gemini 原生请求。
///
/// 转换工具库 API:当前无生产调用方(连通性检查不再发真实请求,曾是其唯一 crate 内
/// 消费者),但保留其转换逻辑与下方测试套件,供代理转换路径复用 / 未来接线。
#[allow(dead_code)]
pub fn anthropic_to_gemini(body: Value) -> Result<Value, ProxyError> {
anthropic_to_gemini_with_shadow(body, None, None, None)
}
+5 -5
View File
@@ -72,7 +72,7 @@ async fn query_deepseek(api_key: &str) -> UsageResult {
.get("https://api.deepseek.com/user/balance")
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(15))
.send()
.await;
@@ -144,7 +144,7 @@ async fn query_stepfun(api_key: &str) -> UsageResult {
.get("https://api.stepfun.com/v1/accounts")
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(15))
.send()
.await;
@@ -203,7 +203,7 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult {
.get(&url)
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(15))
.send()
.await;
@@ -267,7 +267,7 @@ async fn query_openrouter(api_key: &str) -> UsageResult {
.get("https://openrouter.ai/api/v1/credits")
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(15))
.send()
.await;
@@ -327,7 +327,7 @@ async fn query_novita(api_key: &str) -> UsageResult {
.get("https://api.novita.ai/v3/user/balance")
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(15))
.send()
.await;
+9 -7
View File
@@ -95,7 +95,7 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
.get("https://api.kimi.com/coding/v1/usages")
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
@@ -308,7 +308,7 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
.header("Authorization", api_key) // 注意:智谱不加 Bearer 前缀
.header("Content-Type", "application/json")
.header("Accept-Language", "en-US,en")
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
@@ -391,7 +391,7 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
.get(&url)
.header("Authorization", format!("Bearer {api_key}"))
.header("Content-Type", "application/json")
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
@@ -463,7 +463,7 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
.get(base_url)
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
@@ -666,7 +666,8 @@ pub async fn get_coding_plan_quota(
success: false,
tiers: vec![],
extra_usage: None,
error: None,
// 与 balance::get_balance 一致:给出明确错误,避免 footer 显示无信息的失败
error: Some("API key is empty".to_string()),
queried_at: None,
});
}
@@ -681,9 +682,10 @@ pub async fn get_coding_plan_quota(
success: false,
tiers: vec![],
extra_usage: None,
error: None,
// 域名未命中已知套餐供应商(如第三方中转站):给出明确错误而非静默失败
error: Some("Unknown coding plan provider".to_string()),
queried_at: None,
})
});
}
};
+29
View File
@@ -596,6 +596,19 @@ fn restore_live_settings_for_provider_backfill(
);
}
// 统一会话开关注入的共享 `custom` 路由只属于 live 配置;切换回填时
// 必须剥掉,否则官方供应商的存储配置被污染,关闭开关后无法还原。
if provider.category.as_deref() == Some("official") {
if let Err(err) =
crate::codex_config::strip_codex_unified_session_bucket_from_settings(&mut settings)
{
log::warn!(
"Failed to strip unified session bucket while backfilling '{}': {err}",
provider.id
);
}
}
// `modelCatalog` is a cc-switchprivate field whose SSOT is the DB. Live's
// `config.toml` only carries a lossy projection (`model_catalog_json` →
// generated catalog file) that proxy takeover/restore cycles and Codex.app
@@ -1133,6 +1146,22 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
return Ok(false);
}
// 拒绝把"被代理接管的 Live"导入为供应商:接管期间 Live 里只有
// PROXY_MANAGED 占位符和本地代理地址,不是用户的真实配置。一旦导入,
// 它会成为 current providerSSOT),后续"无备份恢复"路径会把占位符
// 当真实配置写回 Live,永久卡在已失效的本地代理上。
// 典型触发场景:代理接管开启时切换 app_config_dir 并重启,新数据库首启导入。
if state
.proxy_service
.detect_takeover_in_live_config_for_app(&app_type)
{
return Err(AppError::localized(
"provider.import.live_taken_over",
"Live 配置当前处于代理接管状态(包含占位符),不能导入为供应商。请先关闭代理接管或恢复 Live 配置后重试。",
"The live config is currently taken over by the proxy (contains placeholders) and cannot be imported as a provider. Disable proxy takeover or restore the live config first.",
));
}
let settings_config = match app_type {
AppType::Codex => crate::codex_config::read_codex_live_settings()?,
AppType::Claude => {
+42
View File
@@ -42,6 +42,48 @@ use live::{
};
use usage::validate_usage_script;
/// 统一会话开关变更后,立即按新开关状态重写当前官方 Codex 供应商的
/// live 配置,使开关即时生效(无需等下一次切换)。
/// 当前供应商非官方(或不存在)时为 no-op:注入只作用于官方配置,
/// 第三方 live 配置不受开关影响。
pub fn reapply_current_codex_official_live(state: &AppState) -> Result<bool, AppError> {
let current_id = ProviderService::current(state, AppType::Codex)?;
if current_id.is_empty() {
return Ok(false);
}
let providers = state.db.get_all_providers(AppType::Codex.as_str())?;
let Some(provider) = providers.get(&current_id) else {
return Ok(false);
};
if provider.category.as_deref() != Some("official") {
return Ok(false);
}
// 代理接管期间 live 归代理所有(开启代理时官方供应商只警告不拦截,
// 二者可以共存)。与切换/保存路径一致:以 backup/占位符为所有权信号,
// 只更新备份,注入后的配置由接管释放时的恢复路径落盘。
let has_live_backup =
futures::executor::block_on(state.db.get_live_backup(AppType::Codex.as_str()))
.ok()
.flatten()
.is_some();
let live_taken_over = state
.proxy_service
.detect_takeover_in_live_config_for_app(&AppType::Codex);
if has_live_backup || live_taken_over {
futures::executor::block_on(
state
.proxy_service
.update_live_backup_from_provider(AppType::Codex.as_str(), provider),
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
return Ok(true);
}
live::write_live_with_common_config(&state.db, &AppType::Codex, provider)?;
Ok(true)
}
/// Provider business logic service
pub struct ProviderService;
+137 -4
View File
@@ -48,7 +48,7 @@ const CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = "[1M]";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ClaudeTakeoverAuthPolicy {
PreserveExistingOrAuthToken,
ManagedAccount,
ManagedAccount { keep_auth_token: bool },
}
#[derive(Clone)]
@@ -90,7 +90,12 @@ impl ProxyService {
provider: &Provider,
) {
let auth_policy = if provider.uses_managed_account_auth() {
ClaudeTakeoverAuthPolicy::ManagedAccount
// Codex 系(含仅凭 base_url 识别、无 provider_type meta 的)必须保留
// ANTHROPIC_AUTH_TOKEN 占位符:Claude Code 缺该键会弹登录提示(#3784)。
// Copilot 维持仅 API_KEY 占位,避免与 /login 管理的 key 冲突(#1049)。
ClaudeTakeoverAuthPolicy::ManagedAccount {
keep_auth_token: !provider.is_github_copilot(),
}
} else {
ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken
};
@@ -180,7 +185,7 @@ impl ProxyService {
);
}
}
ClaudeTakeoverAuthPolicy::ManagedAccount => {
ClaudeTakeoverAuthPolicy::ManagedAccount { keep_auth_token } => {
for key in token_keys {
env.remove(key);
}
@@ -188,6 +193,14 @@ impl ProxyService {
"ANTHROPIC_API_KEY".to_string(),
json!(PROXY_TOKEN_PLACEHOLDER),
);
if keep_auth_token {
// 无条件注入而非"已存在才保留":热切换路径传入的是 provider
// settings(预设不含该键),且旧版接管已把存量用户 live 中的键删光。
env.insert(
"ANTHROPIC_AUTH_TOKEN".to_string(),
json!(PROXY_TOKEN_PLACEHOLDER),
);
}
}
}
}
@@ -1632,7 +1645,7 @@ impl ProxyService {
///
/// 返回值:
/// - Ok(true):已成功写回
/// - Ok(false):缺少当前供应商/供应商不存在,无法写回
/// - Ok(false):缺少当前供应商/供应商不存在/供应商本身含占位符,无法写回
fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {
let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)
.map_err(|e| format!("获取 {app_type:?} 当前供应商失败: {e}"))?;
@@ -1650,6 +1663,16 @@ impl ProxyService {
return Ok(false);
};
// 供应商配置本身含接管占位符时不可写回(历史异常:接管期间 Live 被
// 误导入成了供应商)。写回只会把占位符固化进 Live;返回 Ok(false)
// 让调用方落到"清理占位符"兜底。
if Self::live_has_proxy_placeholder_for_app(app_type, &provider.settings_config) {
log::warn!(
"{app_type:?} 当前供应商配置含代理接管占位符(疑似接管期间被导入的残留),跳过 SSOT 写回,改走占位符清理"
);
return Ok(false);
}
write_live_with_common_config(self.db.as_ref(), app_type, provider)
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
@@ -2010,6 +2033,14 @@ impl ProxyService {
)?;
Self::preserve_codex_oauth_auth_in_backup(&mut effective_settings, existing_value)?;
}
// 统一会话开关:备份是接管释放时恢复 live 的来源,官方配置的
// 共享 custom 路由注入必须落在备份里,否则恢复后开关失效。
crate::codex_config::apply_codex_unified_session_bucket_to_settings(
provider.category.as_deref(),
&mut effective_settings,
)
.map_err(|e| format!("注入统一会话路由失败: {e}"))?;
}
let backup_json = match app_type_enum {
@@ -2935,6 +2966,108 @@ mod tests {
assert_env_str(env, "ANTHROPIC_DEFAULT_OPUS_MODEL", Some("claude-opus-4-8"));
assert_env_str(env, "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", Some("gpt-5.4"));
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER));
}
#[test]
fn managed_account_claude_takeover_codex_injects_auth_token_without_preexisting_key() {
let mut provider = Provider::with_id(
"codex".to_string(),
"Codex".to_string(),
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
}
}),
None,
);
provider.meta = Some(ProviderMeta {
provider_type: Some("codex_oauth".to_string()),
..Default::default()
});
// 全新安装/热切换形态:传入的 env 没有任何 token 键。
let mut live_config = provider.settings_config.clone();
ProxyService::apply_claude_takeover_fields_for_provider(
&mut live_config,
"http://127.0.0.1:15721",
&provider,
);
let env = live_config
.get("env")
.and_then(|value| value.as_object())
.expect("env should exist");
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER));
}
#[test]
fn managed_account_claude_takeover_codex_by_base_url_keeps_auth_token() {
// 无 provider_type meta、仅凭 base_url 识别为受管 codex 的供应商,
// 也必须保留 AUTH_TOKEN 占位符(与策略选择共用同一判定族)。
let provider = Provider::with_id(
"codex-url-only".to_string(),
"Codex (URL only)".to_string(),
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
}
}),
None,
);
assert!(provider.uses_managed_account_auth());
assert!(!provider.is_codex_oauth());
let mut live_config = provider.settings_config.clone();
ProxyService::apply_claude_takeover_fields_for_provider(
&mut live_config,
"http://127.0.0.1:15721",
&provider,
);
let env = live_config
.get("env")
.and_then(|value| value.as_object())
.expect("env should exist");
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER));
}
#[test]
fn managed_account_claude_takeover_copilot_removes_stale_auth_token() {
let mut provider = Provider::with_id(
"copilot".to_string(),
"GitHub Copilot".to_string(),
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
}
}),
None,
);
provider.meta = Some(ProviderMeta {
provider_type: Some("github_copilot".to_string()),
..Default::default()
});
let mut live_config = json!({
"env": {
"ANTHROPIC_BASE_URL": "https://stale.example.com",
"ANTHROPIC_AUTH_TOKEN": "stale-token"
}
});
ProxyService::apply_claude_takeover_fields_for_provider(
&mut live_config,
"http://127.0.0.1:15721",
&provider,
);
let env = live_config
.get("env")
.and_then(|value| value.as_object())
.expect("env should exist");
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None);
}
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -328,7 +328,7 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota {
.header("Authorization", format!("Bearer {access_token}"))
.header("anthropic-beta", "oauth-2025-04-20")
.header("Accept", "application/json")
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
@@ -668,7 +668,7 @@ pub(crate) async fn query_codex_quota(
req = req.header("ChatGPT-Account-Id", id);
}
let resp = match req.timeout(std::time::Duration::from_secs(10)).send().await {
let resp = match req.timeout(std::time::Duration::from_secs(15)).send().await {
Ok(r) => r,
Err(e) => {
return SubscriptionQuota::error(
@@ -964,7 +964,7 @@ async fn refresh_gemini_token(refresh_token: &str) -> Option<String> {
("refresh_token", refresh_token),
("grant_type", "refresh_token"),
])
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.ok()?;
@@ -1048,7 +1048,7 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota {
"pluginType": "GEMINI"
}
}))
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
@@ -1109,7 +1109,7 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota {
.header("Authorization", format!("Bearer {access_token}"))
.header("Content-Type", "application/json")
.json(&quota_body)
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
File diff suppressed because it is too large Load Diff
+91
View File
@@ -287,6 +287,10 @@ pub struct LocalMigrations {
Option<CodexThirdPartyHistoryProviderBucketMigration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_provider_template_v1: Option<CodexProviderTemplateMigration>,
/// 统一会话开关的官方历史迁移标记。开关关闭时会被清除,
/// 这样重新开启能把"关闭期间"落入 openai 桶的官方会话补迁进来。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_official_history_unify_v1: Option<CodexOfficialHistoryUnifyMigration>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -312,6 +316,21 @@ pub struct CodexProviderTemplateMigration {
pub migrated_provider_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexOfficialHistoryUnifyMigration {
pub completed_at: String,
pub target_provider_id: String,
#[serde(default)]
pub migrated_jsonl_files: usize,
#[serde(default)]
pub migrated_state_rows: usize,
/// 迁移时的规范化 Codex 目录。标记只对同一目录生效:
/// 切换 codex_config_dir 后旧标记不会挡住新目录的迁移。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_config_dir: Option<String>,
}
/// 应用设置结构
///
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
@@ -357,6 +376,16 @@ pub struct AppSettings {
/// Opt-in: defaults to false so third-party switches cleanly overwrite auth.json.
#[serde(default)]
pub preserve_codex_official_auth_on_switch: bool,
/// Run official Codex providers under the shared "custom" model_provider id
/// so official sessions share one resume-history bucket with third-party
/// providers. Opt-in: defaults to false.
#[serde(default)]
pub unify_codex_session_history: bool,
/// User opted in (via the enable dialog checkbox) to migrate existing
/// official sessions ("openai" bucket) into the shared bucket. Persisted so
/// a failed migration retries at startup; cleared when the toggle turns off.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unify_codex_migrate_existing: Option<bool>,
/// User has confirmed the failover toggle first-run notice
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failover_confirmed: Option<bool>,
@@ -475,6 +504,8 @@ impl Default for AppSettings {
stream_check_confirmed: None,
enable_failover_toggle: false,
preserve_codex_official_auth_on_switch: false,
unify_codex_session_history: false,
unify_codex_migrate_existing: None,
failover_confirmed: None,
first_run_notice_confirmed: None,
common_config_confirmed: None,
@@ -759,6 +790,56 @@ pub fn mark_codex_provider_template_migrated(
})
}
/// 统一会话迁移标记是否覆盖指定目录。标记里没记目录(不应出现的旧格式)
/// 视为不匹配——重跑迁移是幂等的,宁可重迁也不漏迁。
pub fn is_codex_official_history_unify_migrated_for_dir(codex_dir: &str) -> bool {
get_settings()
.local_migrations
.as_ref()
.and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref())
.is_some_and(|migration| migration.codex_config_dir.as_deref() == Some(codex_dir))
}
/// 条件写入迁移完成标记:仅当此刻开关仍开启且迁移意愿仍在时才写。
/// 检查与写入在 settings 写锁内原子完成,与关闭开关路径
/// `update_settings` / 清标记)串行,消除"迁移线程复查开关后、写标记前
/// 用户恰好关闭开关"的竞态窗口。返回是否实际写入。
pub fn mark_codex_official_history_unify_migrated_if_enabled(
migration: CodexOfficialHistoryUnifyMigration,
) -> Result<bool, AppError> {
let mut written = false;
mutate_settings(|settings| {
if settings.unify_codex_session_history
&& settings.unify_codex_migrate_existing.unwrap_or(false)
{
settings
.local_migrations
.get_or_insert_with(Default::default)
.codex_official_history_unify_v1 = Some(migration);
written = true;
}
})?;
Ok(written)
}
pub fn clear_codex_official_history_unify_migration() -> Result<(), AppError> {
mutate_settings(|settings| {
if let Some(migrations) = settings.local_migrations.as_mut() {
migrations.codex_official_history_unify_v1 = None;
}
})
}
pub fn unify_codex_migrate_existing_requested() -> bool {
get_settings().unify_codex_migrate_existing.unwrap_or(false)
}
pub fn clear_codex_unify_migrate_existing() -> Result<(), AppError> {
mutate_settings(|settings| {
settings.unify_codex_migrate_existing = None;
})
}
/// 从文件重新加载设置到内存缓存
/// 用于导入配置等场景,确保内存缓存与文件同步
pub fn reload_settings() -> Result<(), AppError> {
@@ -829,6 +910,16 @@ pub fn preserve_codex_official_auth_on_switch() -> bool {
.preserve_codex_official_auth_on_switch
}
pub fn unify_codex_session_history() -> bool {
settings_store()
.read()
.unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
})
.unify_codex_session_history
}
// ===== 当前供应商管理函数 =====
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.16.2",
"version": "3.16.3",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
+27
View File
@@ -588,3 +588,30 @@ fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
"current provider should remain empty or be the attempted id on failure, got: {current_id:?}"
);
}
#[test]
fn import_refuses_live_config_under_proxy_takeover() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
ensure_test_home();
// 接管态 Codex Liveauth 是 PROXY_MANAGED 占位符,不是用户真实配置
let auth = json!({"OPENAI_API_KEY": "PROXY_MANAGED"});
let config = r#"model = "gpt-5"
"#;
write_codex_live_atomic(&auth, Some(config)).expect("seed taken-over codex live");
let state = create_test_state().expect("create test state");
import_default_config_test_hook(&state, AppType::Codex)
.expect_err("importing a taken-over live config must fail");
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get codex providers");
assert!(
providers.is_empty(),
"taken-over live import must not create providers"
);
}
+59
View File
@@ -1911,3 +1911,62 @@ fn provider_service_delete_current_provider_returns_error() {
other => panic!("expected Config/Message error, got {other:?}"),
}
}
#[test]
fn recover_from_crash_without_backup_cleans_placeholder_instead_of_writing_it_back() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
// 接管态 Claude Live,且 DB 中无备份(模拟切换 app_config_dir 后新库首启的场景)
let taken_over_live = json!({
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721",
"ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED"
}
});
let settings_path = get_claude_settings_path();
std::fs::create_dir_all(settings_path.parent().expect("settings dir")).expect("create dir");
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&taken_over_live).expect("serialize taken over live"),
)
.expect("write taken over live");
let state = create_test_state().expect("create test state");
// 模拟历史异常:接管态 Live 已被导入成 current providerSSOT 被污染)
let provider = Provider::with_id(
"default".to_string(),
"default".to_string(),
taken_over_live.clone(),
None,
);
state
.db
.save_provider(AppType::Claude.as_str(), &provider)
.expect("save placeholder provider");
state
.db
.set_current_provider(AppType::Claude.as_str(), "default")
.expect("set current provider");
futures::executor::block_on(state.proxy_service.recover_from_crash())
.expect("recover from crash");
let live_after: serde_json::Value =
read_json_file(&settings_path).expect("read live settings after recovery");
let env = live_after.get("env").cloned().unwrap_or_else(|| json!({}));
assert_ne!(
env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()),
Some("PROXY_MANAGED"),
"recovery must not write the placeholder back to live"
);
assert!(
env.get("ANTHROPIC_BASE_URL")
.and_then(|v| v.as_str())
.map(|url| !url.starts_with("http://127.0.0.1"))
.unwrap_or(true),
"recovery must drop the local proxy base URL"
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+31 -2
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
@@ -7,6 +8,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { AlertTriangle, Info } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -18,7 +20,10 @@ interface ConfirmDialogProps {
cancelText?: string;
variant?: "destructive" | "info";
zIndex?: "base" | "nested" | "alert" | "top";
onConfirm: () => void;
/** 可选勾选项:提供 label 即显示,勾选状态经 onConfirm 参数回传 */
checkboxLabel?: string;
checkboxDefaultChecked?: boolean;
onConfirm: (checkboxChecked: boolean) => void;
onCancel: () => void;
}
@@ -30,10 +35,21 @@ export function ConfirmDialog({
cancelText,
variant = "destructive",
zIndex = "alert",
checkboxLabel,
checkboxDefaultChecked = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const { t } = useTranslation();
const [checkboxChecked, setCheckboxChecked] = useState(
checkboxDefaultChecked,
);
useEffect(() => {
if (isOpen) {
setCheckboxChecked(checkboxDefaultChecked);
}
}, [isOpen, checkboxDefaultChecked]);
const IconComponent = variant === "info" ? Info : AlertTriangle;
const iconClass =
@@ -58,13 +74,26 @@ export function ConfirmDialog({
{message}
</DialogDescription>
</DialogHeader>
{checkboxLabel ? (
<label className="flex cursor-pointer select-none items-start gap-2 px-6 pt-3">
<Checkbox
checked={checkboxChecked}
onCheckedChange={(value) => setCheckboxChecked(value === true)}
className="mt-0.5"
/>
<span className="text-sm leading-relaxed">{checkboxLabel}</span>
</label>
) : null}
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<Button variant="outline" onClick={onCancel}>
{cancelText || t("common.cancel")}
</Button>
<Button
variant={variant === "info" ? "default" : "destructive"}
onClick={onConfirm}
onClick={() =>
// 未渲染勾选框时不得回传 defaultChecked 残留值
onConfirm(checkboxLabel ? checkboxChecked : false)
}
>
{confirmText || t("common.confirm")}
</Button>
+3 -3
View File
@@ -1,4 +1,5 @@
import {
Activity,
BarChart3,
Check,
Copy,
@@ -8,7 +9,6 @@ import {
Play,
Plus,
Terminal,
TestTube2,
Trash2,
Zap,
} from "lucide-react";
@@ -310,7 +310,7 @@ export function ProviderActions({
variant="ghost"
onClick={onTest || undefined}
disabled={isTesting}
title={t("modelTest.testProvider", "测试模型")}
title={t("provider.connectivityCheck", "检测连通")}
className={cn(
iconButtonClass,
!onTest && "opacity-40 cursor-not-allowed text-muted-foreground",
@@ -319,7 +319,7 @@ export function ProviderActions({
{isTesting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube2 className="h-4 w-4" />
<Activity className="h-4 w-4" />
)}
</Button>
+6 -8
View File
@@ -232,9 +232,6 @@ export function ProviderCard({
provider.meta?.apiFormat,
(provider.settingsConfig as Record<string, any>)?.config,
]);
const isClaudeThirdParty =
appId === "claude" && provider.category === "third_party";
// 获取用量数据以判断是否有多套餐
// 累加模式应用(OpenCode/OpenClaw/Hermes):使用 isInConfig 代替 isCurrent
const shouldAutoQuery =
@@ -551,11 +548,12 @@ export function ProviderCard({
onEdit={() => onEdit(provider)}
onDuplicate={() => onDuplicate(provider)}
onTest={
onTest &&
!isOfficial &&
!isCopilot &&
!isCodexOauth &&
!isClaudeThirdParty
// 连通检测对第三方/自定义/Copilot/Codex-OAuth 供应商开放(这些正是旧的
// 真实请求探测会误报、而可达性探测能正确处理的对象)。官方供应商
// (category === "official") 一律隐藏:它们 base_url 故意留空、走客户端
// 默认/OAuth 端点,cc-switch 没有可靠的探测目标(尤其 Claude Desktop
// 官方是原生 1P 模式,根本不在请求路径上)。
onTest && provider.category !== "official"
? () => onTest(provider)
: undefined
}
+3 -48
View File
@@ -45,8 +45,6 @@ import {
import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { settingsApi } from "@/lib/api/settings";
interface ProviderListProps {
providers: Record<string, Provider>;
@@ -192,9 +190,6 @@ export function ProviderList({
const [searchTerm, setSearchTerm] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const [showStreamCheckConfirm, setShowStreamCheckConfirm] = useState(false);
const [pendingTestProvider, setPendingTestProvider] =
useState<Provider | null>(null);
const { data: claudeDesktopStatus } = useQuery({
queryKey: ["claudeDesktopStatus"],
queryFn: () => providersApi.getClaudeDesktopStatus(),
@@ -202,41 +197,14 @@ export function ProviderList({
refetchInterval: appId === "claude-desktop" ? 5000 : false,
});
// Query settings for streamCheckConfirmed flag
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsApi.get(),
});
// 连通性检查不发真实请求、无封号/计费风险,直接执行(无需确认弹窗)。
const handleTest = useCallback(
(provider: Provider) => {
if (!settings?.streamCheckConfirmed) {
setPendingTestProvider(provider);
setShowStreamCheckConfirm(true);
} else {
checkProvider(provider.id, provider.name);
}
checkProvider(provider.id, provider.name);
},
[checkProvider, settings?.streamCheckConfirmed],
[checkProvider],
);
const handleStreamCheckConfirm = async () => {
setShowStreamCheckConfirm(false);
try {
if (settings) {
const { webdavSync: _, ...rest } = settings;
await settingsApi.save({ ...rest, streamCheckConfirmed: true });
await queryClient.invalidateQueries({ queryKey: ["settings"] });
}
} catch (error) {
console.error("Failed to save stream check confirmed:", error);
}
if (pendingTestProvider) {
checkProvider(pendingTestProvider.id, pendingTestProvider.name);
setPendingTestProvider(null);
}
};
// Import current live config as default provider
const queryClient = useQueryClient();
const importMutation = useMutation({
@@ -558,19 +526,6 @@ export function ProviderList({
) : (
renderProviderList()
)}
<ConfirmDialog
isOpen={showStreamCheckConfirm}
variant="info"
title={t("confirm.streamCheck.title")}
message={t("confirm.streamCheck.message")}
confirmText={t("confirm.streamCheck.confirm")}
onConfirm={() => void handleStreamCheckConfirm()}
onCancel={() => {
setShowStreamCheckConfirm(false);
setPendingTestProvider(null);
}}
/>
</div>
);
}
@@ -117,7 +117,7 @@ const CLAUDE_ROUTE_PREFIX = "claude-";
const ANTHROPIC_CLAUDE_ROUTE_PREFIX = "anthropic/claude-";
const LEGACY_ONE_M_MARKER = "[1m]";
const ROLE_ROUTE_IDS = CLAUDE_DESKTOP_ROLE_ROUTE_IDS;
const ROLE_ORDER: RouteRole[] = ["sonnet", "opus", "haiku"];
const ROLE_ORDER: RouteRole[] = ["sonnet", "opus", "fable", "haiku"];
function envString(
settingsConfig: Record<string, unknown> | undefined,
@@ -138,8 +138,10 @@ function clonePlainRecord(value: unknown): Record<string, unknown> {
function routeRoleFromId(route: string): RouteRole {
const normalized = route.trim().toLowerCase();
// 与后端 claude_role_keyword 同序(opus → haiku → fable → sonnet)。
if (normalized.includes("opus")) return "opus";
if (normalized.includes("haiku")) return "haiku";
if (normalized.includes("fable")) return "fable";
return "sonnet";
}
@@ -193,9 +195,10 @@ function initialRouteRows(
});
}
// Proxy 模式对齐 Claude Code:固定 Sonnet / Opus / Haiku 档。
// 把任意来源的 route 行按角色归类到固定槽(缺档留空),保证 UI 永远行、
// 用户不会漏配 Haiku 导致子 agent 找不到模型。
// Proxy 模式对齐 Claude Code:固定 Sonnet / Opus / Fable / Haiku 档。
// 把任意来源的 route 行按角色归类到固定槽(缺档留空),保证 UI 永远行、
// 用户不会漏配某档导致子 agent 找不到模型。
// fable 自 Desktop 1.12603.1+ 起被 fail-all 校验放行,可作为独立档位。)
function normalizeProxyRows(rows: RouteRow[]): RouteRow[] {
return ROLE_ORDER.map((role) => {
const match = rows.find(
@@ -221,7 +224,8 @@ function isClaudeSafeRoute(route: string) {
// 角色前缀后必须还有实际模型标识,拒绝 claude-sonnet- 这类退化值
// (否则会写入 profile 并触发 Claude Desktop fail-all 拒收整组)。
return ["sonnet-", "opus-", "haiku-"].some(
// 与后端 is_claude_safe_model_id 镜像;fable 自 Desktop 1.12603.1+ 起被校验放行。
return ["sonnet-", "opus-", "haiku-", "fable-"].some(
(prefix) =>
routeTail.startsWith(prefix) && routeTail.length > prefix.length,
);
@@ -574,9 +578,9 @@ export function ClaudeDesktopProviderForm({
.filter((route) => route.route || route.model);
if (mode === "proxy") {
// 固定档(Sonnet / Opus / Haiku),route_id 由 UI 生成、恒合法,
// 固定档(Sonnet / Opus / Fable / Haiku),route_id 由 UI 生成、恒合法,
// 因此只要求至少填一个实际请求模型;留空档继承第一个已填档(Sonnet 优先),
// 对齐 Claude Code 的兜底,保证落库档齐全、子 agent 不会找不到 Haiku
// 对齐 Claude Code 的兜底,保证落库档齐全、子 agent 不会找不到模型
const primary = routeEntries.find((route) => route.model);
if (!primary) {
toast.error(
@@ -931,9 +935,22 @@ export function ClaudeDesktopProviderForm({
? t("claudeDesktop.routeRoleHaiku", {
defaultValue: "Haiku",
})
: t("claudeDesktop.routeRoleSonnet", {
defaultValue: "Sonnet",
});
: role === "fable"
? t("claudeDesktop.routeRoleFable", {
defaultValue: "Fable",
})
: t("claudeDesktop.routeRoleSonnet", {
defaultValue: "Sonnet",
});
// Haiku 档示范映射到轻量模型(flash),其余档映射到 pro;
// 两列占位联动,保持每行「菜单显示名 ↔ 实际请求模型」品牌一致。
const isHaikuRole = role === "haiku";
const labelPlaceholder = isHaikuRole
? "DeepSeek V4 Flash"
: "DeepSeek V4 Pro";
const modelPlaceholder = isHaikuRole
? "deepseek-v4-flash"
: "deepseek-v4-pro";
return (
<div
key={route.rowId}
@@ -949,7 +966,7 @@ export function ClaudeDesktopProviderForm({
labelOverride: event.target.value,
})
}
placeholder="DeepSeek V4 Pro"
placeholder={labelPlaceholder}
/>
<div className="flex gap-1">
<Input
@@ -957,7 +974,7 @@ export function ClaudeDesktopProviderForm({
onChange={(event) =>
updateRoute(index, { model: event.target.value })
}
placeholder="kimi-k2 / deepseek-chat"
placeholder={modelPlaceholder}
className="flex-1"
/>
{fetchedModels.length > 0 && (
@@ -123,6 +123,8 @@ interface ClaudeFormFieldsProps {
defaultSonnetModelName: string;
defaultOpusModel: string;
defaultOpusModelName: string;
defaultFableModel: string;
defaultFableModelName: string;
onModelChange: (field: ClaudeModelEnvField, value: string) => void;
// Speed Test Endpoints
@@ -187,6 +189,8 @@ export function ClaudeFormFields({
defaultSonnetModelName,
defaultOpusModel,
defaultOpusModelName,
defaultFableModel,
defaultFableModelName,
onModelChange,
speedTestEndpoints,
apiFormat,
@@ -204,6 +208,7 @@ export function ClaudeFormFields({
defaultHaikuModel ||
defaultSonnetModel ||
defaultOpusModel ||
defaultFableModel ||
apiFormat !== "anthropic" ||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN" ||
customUserAgent
@@ -495,7 +500,7 @@ export function ClaudeFormFields({
};
type ModelRoleRow = {
role: "sonnet" | "opus" | "haiku";
role: "sonnet" | "opus" | "fable" | "haiku";
label: string;
model: string;
displayName: string;
@@ -526,6 +531,16 @@ export function ClaudeFormFields({
inputId: "claudeDefaultOpusModel",
supportsOneM: true,
},
{
role: "fable",
label: t("providerForm.modelRoleFable", { defaultValue: "Fable" }),
model: defaultFableModel,
displayName: defaultFableModelName,
modelField: "ANTHROPIC_DEFAULT_FABLE_MODEL",
displayNameField: "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME",
inputId: "claudeDefaultFableModel",
supportsOneM: true,
},
{
role: "haiku",
label: t("providerForm.modelRoleHaiku", { defaultValue: "Haiku" }),
@@ -786,6 +801,7 @@ export function ClaudeFormFields({
claudeModel ||
defaultSonnetModel ||
defaultOpusModel ||
defaultFableModel ||
defaultHaikuModel;
if (value) {
for (const row of modelRoleRows) {
@@ -809,7 +825,8 @@ export function ClaudeFormFields({
!claudeModel &&
!defaultHaikuModel &&
!defaultSonnetModel &&
!defaultOpusModel
!defaultOpusModel &&
!defaultFableModel
}
className="h-7 gap-1"
>
@@ -936,7 +953,7 @@ export function ClaudeFormFields({
<p className="text-xs text-muted-foreground">
{t("providerForm.fallbackModelHint", {
defaultValue:
"仅在 Claude Code 请求没有明确落到 Sonnet、OpusHaiku 角色时使用;通常可以留空。",
"用于未明确落到 Sonnet、Opus、Fable、Haiku 角色的请求。使用第三方/中转端点时建议填写:否则这些请求(含 Haiku 后台子任务)会以原始 Claude 模型名透传给上游,可能因上游无此模型而报错。官方端点可留空。",
})}
</p>
</div>
@@ -61,7 +61,7 @@ export function ProviderAdvancedConfig({
<FlaskConical className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{t("providerAdvanced.testConfig", {
defaultValue: "模型测试配置",
defaultValue: "连通检测配置",
})}
</span>
</div>
@@ -106,31 +106,10 @@ export function ProviderAdvancedConfig({
<p className="text-sm text-muted-foreground">
{t("providerAdvanced.testConfigDesc", {
defaultValue:
"为此供应商配置单独的模型测试参数,不启用时使用全局配置。",
"为此供应商配置单独的连通检测参数(超时/阈值/重试),不启用时使用全局配置。",
})}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="test-model">
{t("providerAdvanced.testModel", {
defaultValue: "测试模型",
})}
</Label>
<Input
id="test-model"
value={testConfig.testModel || ""}
onChange={(e) =>
onTestConfigChange({
...testConfig,
testModel: e.target.value || undefined,
})
}
placeholder={t("providerAdvanced.testModelPlaceholder", {
defaultValue: "留空使用全局配置",
})}
disabled={!testConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="test-timeout">
{t("providerAdvanced.timeoutSecs", {
@@ -141,7 +120,7 @@ export function ProviderAdvancedConfig({
id="test-timeout"
type="number"
min={1}
max={300}
max={60}
value={testConfig.timeoutSecs || ""}
onChange={(e) =>
onTestConfigChange({
@@ -151,26 +130,7 @@ export function ProviderAdvancedConfig({
: undefined,
})
}
placeholder="45"
disabled={!testConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="test-prompt">
{t("providerAdvanced.testPrompt", {
defaultValue: "测试提示词",
})}
</Label>
<Input
id="test-prompt"
value={testConfig.testPrompt || ""}
onChange={(e) =>
onTestConfigChange({
...testConfig,
testPrompt: e.target.value || undefined,
})
}
placeholder="Who are you?"
placeholder="8"
disabled={!testConfig.enabled}
/>
</div>
@@ -208,7 +168,7 @@ export function ProviderAdvancedConfig({
id="max-retries"
type="number"
min={0}
max={10}
max={5}
value={testConfig.maxRetries ?? ""}
onChange={(e) =>
onTestConfigChange({
@@ -218,7 +178,7 @@ export function ProviderAdvancedConfig({
: undefined,
})
}
placeholder="2"
placeholder="1"
disabled={!testConfig.enabled}
/>
</div>
@@ -449,6 +449,8 @@ function ProviderFormFull({
defaultSonnetModelName,
defaultOpusModel,
defaultOpusModelName,
defaultFableModel,
defaultFableModelName,
handleModelChange,
} = useModelState({
settingsConfig: form.getValues("settingsConfig"),
@@ -2009,6 +2011,8 @@ function ProviderFormFull({
defaultSonnetModelName={defaultSonnetModelName}
defaultOpusModel={defaultOpusModel}
defaultOpusModelName={defaultOpusModelName}
defaultFableModel={defaultFableModel}
defaultFableModelName={defaultFableModelName}
onModelChange={handleModelChange}
speedTestEndpoints={speedTestEndpoints}
apiFormat={localApiFormat}
@@ -1,19 +1,8 @@
import { useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
import { ArrowUpAZ, Search, Zap, Star, Layers, Settings2 } from "lucide-react";
import type { ProviderPreset } from "@/config/claudeProviderPresets";
@@ -63,19 +52,9 @@ export function getPresetDisplayName(
export function getPresetSearchText(
entry: PresetEntry,
presetCategoryLabels: Record<string, string>,
t: PresetTranslator,
): string {
const presetCategory = entry.preset.category ?? "others";
const categoryLabel =
presetCategoryLabels[presetCategory] ?? String(t("providerPreset.other"));
return [
getPresetDisplayName(entry.preset, t),
entry.preset.name,
entry.preset.websiteUrl,
categoryLabel,
]
return [getPresetDisplayName(entry.preset, t), entry.preset.name]
.join(" ")
.toLowerCase();
}
@@ -83,7 +62,6 @@ export function getPresetSearchText(
export function filterPresetEntries(
entries: PresetEntry[],
query: string,
presetCategoryLabels: Record<string, string>,
t: PresetTranslator,
): PresetEntry[] {
const normalizedQuery = query.trim().toLowerCase();
@@ -92,9 +70,7 @@ export function filterPresetEntries(
}
return entries.filter((entry) =>
getPresetSearchText(entry, presetCategoryLabels, t).includes(
normalizedQuery,
),
getPresetSearchText(entry, t).includes(normalizedQuery),
);
}
@@ -117,7 +93,6 @@ export function sortPresetEntries(
export interface PresetVisibilityOptions {
query: string;
sortMode: PresetSortMode;
presetCategoryLabels: Record<string, string>;
t: PresetTranslator;
}
@@ -125,13 +100,9 @@ export function getVisiblePresetEntries(
entries: PresetEntry[],
options: PresetVisibilityOptions,
): PresetEntry[] {
const { query, sortMode, presetCategoryLabels, t } = options;
const { query, sortMode, t } = options;
return sortPresetEntries(
filterPresetEntries(entries, query, presetCategoryLabels, t),
sortMode,
t,
);
return sortPresetEntries(filterPresetEntries(entries, query, t), sortMode, t);
}
interface ProviderPresetSelectorProps {
@@ -159,16 +130,34 @@ export function ProviderPresetSelector({
const [sortMode, setSortMode] = useState<PresetSortMode>(
PresetSortMode.Original,
);
const searchContainerRef = useRef<HTMLDivElement>(null);
// 点击搜索区域外时收起并清空,对齐旧 Popover 的「点击外部关闭」行为
useEffect(() => {
if (!searchOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (
searchContainerRef.current &&
!searchContainerRef.current.contains(event.target as Node)
) {
setSearchOpen(false);
setSearchQuery("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [searchOpen]);
const visiblePresetEntries = useMemo(
() =>
getVisiblePresetEntries(presetEntries, {
query: searchQuery,
sortMode,
presetCategoryLabels,
t,
}),
[presetEntries, presetCategoryLabels, searchQuery, sortMode, t],
[presetEntries, searchQuery, sortMode, t],
);
const getCategoryHint = (): ReactNode => {
@@ -214,26 +203,38 @@ export function ProviderPresetSelector({
};
const renderPresetIcon = (preset: AnyPreset) => {
const iconType = preset.theme?.icon;
if (!iconType) return null;
switch (iconType) {
case "claude":
return <ClaudeIcon size={14} />;
case "codex":
return <CodexIcon size={14} />;
case "gemini":
return <GeminiIcon size={14} />;
case "generic":
return <Zap size={14} />;
default:
return null;
if (preset.icon) {
return (
<ProviderIcon
icon={preset.icon}
name={preset.name}
color={preset.iconColor}
size={16}
className="flex-shrink-0"
/>
);
}
const iconType = preset.theme?.icon;
if (iconType) {
switch (iconType) {
case "claude":
return <ClaudeIcon size={14} />;
case "codex":
return <CodexIcon size={14} />;
case "gemini":
return <GeminiIcon size={14} />;
case "generic":
return <Zap size={14} />;
}
}
return <span className="inline-block w-4 h-4 flex-shrink-0" aria-hidden />;
};
const getPresetButtonClass = (isSelected: boolean, preset: AnyPreset) => {
const baseClass =
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
"inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors w-full";
if (isSelected) {
if (preset.theme?.backgroundColor) {
@@ -260,101 +261,95 @@ export function ProviderPresetSelector({
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<FormLabel>{t("providerPreset.label")}</FormLabel>
<TooltipProvider delayDuration={300}>
<div className="flex items-center gap-1">
<Popover open={searchOpen} onOpenChange={setSearchOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("providerPreset.searchAriaLabel", {
defaultValue: "Search provider presets",
})}
className={
searchQuery.trim()
? "size-8 bg-accent text-foreground"
: "size-8"
}
>
<Search className="size-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
{t("providerPreset.searchTooltip", {
defaultValue: "Search presets",
})}
</TooltipContent>
</Tooltip>
<PopoverContent
align="end"
className="w-72 p-2 border-border-default"
>
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t("providerPreset.searchPlaceholder", {
defaultValue: "Search presets...",
})}
aria-label={t("providerPreset.searchAriaLabel", {
defaultValue: "Search provider presets",
})}
autoFocus
/>
</PopoverContent>
</Popover>
<div ref={searchContainerRef} className="flex items-center gap-2">
{searchOpen && (
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
setSearchQuery("");
setSearchOpen(false);
}
}}
placeholder={t("providerPreset.searchPlaceholder", {
defaultValue: "Search presets...",
})}
aria-label={t("providerPreset.searchAriaLabel", {
defaultValue: "Search provider presets",
})}
className="w-48 h-8"
autoFocus
/>
)}
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("providerPreset.searchAriaLabel", {
defaultValue: "Search provider presets",
})}
aria-pressed={searchOpen}
onClick={() => {
setSearchOpen((v) => !v);
if (searchOpen) setSearchQuery("");
}}
title={t("providerPreset.searchTooltip", {
defaultValue: "Search presets",
})}
className={
searchOpen || searchQuery.trim()
? "size-8 bg-accent text-foreground"
: "size-8"
}
>
<Search className="size-4" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("providerPreset.sortAriaLabel", {
defaultValue: "Toggle preset sorting",
})}
aria-pressed={sortMode === PresetSortMode.NameAsc}
onClick={toggleSortMode}
className={
sortMode === PresetSortMode.NameAsc
? "size-8 bg-accent text-foreground"
: "size-8"
}
>
<ArrowUpAZ className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{sortMode === PresetSortMode.NameAsc
? t("providerPreset.sortOriginalTooltip", {
defaultValue: "Restore original order",
})
: t("providerPreset.sortNameAscTooltip", {
defaultValue: "Sort A-Z",
})}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("providerPreset.sortAriaLabel", {
defaultValue: "Toggle preset sorting",
})}
aria-pressed={sortMode === PresetSortMode.NameAsc}
onClick={toggleSortMode}
title={
sortMode === PresetSortMode.NameAsc
? t("providerPreset.sortOriginalTooltip", {
defaultValue: "Restore original order",
})
: t("providerPreset.sortNameAscTooltip", {
defaultValue: "Sort A-Z",
})
}
className={
sortMode === PresetSortMode.NameAsc
? "size-8 bg-accent text-foreground"
: "size-8"
}
>
<ArrowUpAZ className="size-4" />
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2">
<button
type="button"
onClick={() => onPresetChange("custom")}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
className={`inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors w-full ${
selectedPresetId === "custom"
? "bg-blue-500 text-white dark:bg-blue-600"
: "bg-accent text-muted-foreground hover:bg-accent/80"
}`}
>
{t("providerPreset.custom")}
<span className="inline-block w-4 h-4 flex-shrink-0" aria-hidden />
<span className="truncate">{t("providerPreset.custom")}</span>
</button>
{visiblePresetEntries.length === 0 && (
<div className="w-full rounded-md border border-dashed border-border-default px-3 py-2 text-xs text-muted-foreground">
<div className="col-span-full rounded-md border border-dashed border-border-default px-3 py-2 text-xs text-muted-foreground">
{t("providerPreset.noSearchResults", {
defaultValue: "No matching presets.",
})}
@@ -378,7 +373,9 @@ export function ProviderPresetSelector({
}
>
{renderPresetIcon(entry.preset)}
{getPresetDisplayName(entry.preset, t)}
<span className="truncate">
{getPresetDisplayName(entry.preset, t)}
</span>
{isPartner && (
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-amber-500 to-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
<Star className="h-2.5 w-2.5 fill-current" />
@@ -391,20 +388,25 @@ export function ProviderPresetSelector({
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
<>
<div className="flex flex-wrap items-center gap-2">
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2">
{universalProviderPresets.map((preset) => (
<button
key={`universal-${preset.providerType}`}
type="button"
onClick={() => onUniversalPresetSelect(preset)}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative"
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative w-full"
title={t("universalProvider.hint", {
defaultValue:
"跨应用统一配置,自动同步到 Claude/Codex/Gemini",
})}
>
<ProviderIcon icon={preset.icon} name={preset.name} size={14} />
{preset.name}
<ProviderIcon
icon={preset.icon}
name={preset.name}
size={14}
className="flex-shrink-0"
/>
<span className="truncate">{preset.name}</span>
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
<Layers className="h-2.5 w-2.5" />
</span>
@@ -414,15 +416,17 @@ export function ProviderPresetSelector({
<button
type="button"
onClick={onManageUniversalProviders}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80"
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 w-full"
title={t("universalProvider.manage", {
defaultValue: "管理统一供应商",
})}
>
<Settings2 className="h-4 w-4" />
{t("universalProvider.manage", {
defaultValue: "管理",
})}
<Settings2 className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{t("universalProvider.manage", {
defaultValue: "管理",
})}
</span>
</button>
)}
</div>
@@ -12,7 +12,9 @@ export type ClaudeModelEnvField =
| "ANTHROPIC_DEFAULT_SONNET_MODEL"
| "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME"
| "ANTHROPIC_DEFAULT_OPUS_MODEL"
| "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME";
| "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME"
| "ANTHROPIC_DEFAULT_FABLE_MODEL"
| "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME";
export const CLAUDE_ONE_M_MARKER = "[1M]";
@@ -69,8 +71,28 @@ function parseModelsFromConfig(settingsConfig: string) {
typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME === "string"
? env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME
: stripClaudeOneMMarker(opus);
// 回填链镜像运行时映射链(fable → opus → default),保证 UI 展示
// 与代理实际转发的模型一致。
const fable =
typeof env.ANTHROPIC_DEFAULT_FABLE_MODEL === "string"
? env.ANTHROPIC_DEFAULT_FABLE_MODEL
: opus;
const fableName =
typeof env.ANTHROPIC_DEFAULT_FABLE_MODEL_NAME === "string"
? env.ANTHROPIC_DEFAULT_FABLE_MODEL_NAME
: stripClaudeOneMMarker(fable);
return { model, haiku, haikuName, sonnet, sonnetName, opus, opusName };
return {
model,
haiku,
haikuName,
sonnet,
sonnetName,
opus,
opusName,
fable,
fableName,
};
} catch {
return {
model: "",
@@ -80,6 +102,8 @@ function parseModelsFromConfig(settingsConfig: string) {
sonnetName: "",
opus: "",
opusName: "",
fable: "",
fableName: "",
};
}
}
@@ -106,6 +130,10 @@ export function useModelState({
const [defaultOpusModelName, setDefaultOpusModelName] = useState(
initial.opusName,
);
const [defaultFableModel, setDefaultFableModel] = useState(initial.fable);
const [defaultFableModelName, setDefaultFableModelName] = useState(
initial.fableName,
);
const isUserEditingRef = useRef(false);
const lastConfigRef = useRef(settingsConfig);
@@ -134,6 +162,8 @@ export function useModelState({
setDefaultSonnetModelName(parsed.sonnetName);
setDefaultOpusModel(parsed.opus);
setDefaultOpusModelName(parsed.opusName);
setDefaultFableModel(parsed.fable);
setDefaultFableModelName(parsed.fableName);
}, [settingsConfig]);
const handleModelChange = useCallback(
@@ -152,6 +182,10 @@ export function useModelState({
if (field === "ANTHROPIC_DEFAULT_OPUS_MODEL") setDefaultOpusModel(value);
if (field === "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME")
setDefaultOpusModelName(value);
if (field === "ANTHROPIC_DEFAULT_FABLE_MODEL")
setDefaultFableModel(value);
if (field === "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME")
setDefaultFableModelName(value);
try {
const currentConfig = latestConfigRef.current
@@ -195,6 +229,10 @@ export function useModelState({
setDefaultOpusModel,
defaultOpusModelName,
setDefaultOpusModelName,
defaultFableModel,
setDefaultFableModel,
defaultFableModelName,
setDefaultFableModelName,
handleModelChange,
};
}
+151 -82
View File
@@ -32,10 +32,10 @@ import type {
ToolInstallationReport,
} from "@/lib/api/settings";
import { useUpdate } from "@/contexts/UpdateContext";
import { relaunchApp } from "@/lib/updater";
import { Badge } from "@/components/ui/badge";
import { motion } from "framer-motion";
import appIcon from "@/assets/icons/app-icon.png";
import fable5VerifiedBanner from "@/assets/fable5-verified.png";
import { APP_ICON_MAP } from "@/config/appConfig";
import type { AppId } from "@/lib/api/types";
import { extractErrorMessage } from "@/utils/errorUtils";
@@ -179,14 +179,51 @@ const TOOL_APP_IDS: Record<ToolName, AppId> = {
hermes: "hermes",
};
// 工具版本探测代价高:每个工具一次 `--version` 子进程 + 一次 npm/github/pypi 网络请求。
// 设置页用 Radix Tabs,非激活 Tab 会被卸载——每次切回「关于」都重挂 AboutSection,若都
// 全量重查纯属浪费。用「模块级」缓存(生命周期 = JS 模块 = 应用会话,不随组件卸载销毁)
// 跨重挂存活:重挂时若缓存仍新鲜(距上次全量加载 < TTL)直接复用、跳过探测;超期或用户
// 手动「刷新」才强制重查。at = 最近一次「全量加载」完成时刻;单工具刷新(切 shell / 升级
// 后)只更新数据、不重置 at,避免一次局部刷新把整体 TTL 续命。
const TOOL_VERSIONS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 分钟
let toolVersionsCache: { data: ToolVersion[]; at: number } | null = null;
// 应用自身版本(getVersion,本地毫秒级、无网络)也缓存一份,纯为重挂时免去 loading 闪烁。
let appVersionCache: string | null = null;
// 把探测结果按 name 合并进已有列表:替换同名项、追加新项;空列表时直接采用新结果。
// 组件 state 与模块缓存共用同一套合并语义(单工具与全量探测都经此函数)。
function mergeToolVersions(
prev: ToolVersion[],
updated: ToolVersion[],
): ToolVersion[] {
if (prev.length === 0) return updated;
const byName = new Map(updated.map((t) => [t.name, t]));
const merged = prev.map((t) => byName.get(t.name) ?? t);
const existing = new Set(prev.map((t) => t.name));
for (const u of updated) {
if (!existing.has(u.name)) merged.push(u);
}
return merged;
}
export function AboutSection({ isPortable }: AboutSectionProps) {
// ... (use hooks as before) ...
const { t } = useTranslation();
const [version, setVersion] = useState<string | null>(null);
const [isLoadingVersion, setIsLoadingVersion] = useState(true);
// 惰性初始化自模块缓存:重挂时首帧即渲染上次的值,避免 loading 闪烁;首次挂载缓存
// 为空则回退到原始初值(null / loading)。
const [version, setVersion] = useState<string | null>(() => appVersionCache);
const [isLoadingVersion, setIsLoadingVersion] = useState(
() => appVersionCache === null,
);
const [isDownloading, setIsDownloading] = useState(false);
const [toolVersions, setToolVersions] = useState<ToolVersion[]>([]);
const [isLoadingTools, setIsLoadingTools] = useState(true);
const [toolVersions, setToolVersions] = useState<ToolVersion[]>(
() => toolVersionsCache?.data ?? [],
);
// 有缓存(哪怕已超期)就先展示旧值、初始不 loading;超期时由挂载副作用触发后台
// 重查(stale-while-revalidate)。无缓存(首次)才从 loading 起步。
const [isLoadingTools, setIsLoadingTools] = useState(
() => toolVersionsCache === null,
);
const [toolActions, setToolActions] = useState<
Partial<Record<ToolName, ToolLifecycleAction>>
>({});
@@ -195,14 +232,8 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
);
const [showInstallCommands, setShowInstallCommands] = useState(false);
const {
hasUpdate,
updateInfo,
updateHandle,
checkUpdate,
resetDismiss,
isChecking,
} = useUpdate();
const { hasUpdate, updateInfo, checkUpdate, resetDismiss, isChecking } =
useUpdate();
const [wslShellByTool, setWslShellByTool] = useState<
Record<string, WslShellPreference>
@@ -264,16 +295,15 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
wslOverrides,
);
setToolVersions((prev) => {
if (prev.length === 0) return updated;
const byName = new Map(updated.map((t) => [t.name, t]));
const merged = prev.map((t) => byName.get(t.name) ?? t);
const existing = new Set(prev.map((t) => t.name));
for (const u of updated) {
if (!existing.has(u.name)) merged.push(u);
}
return merged;
});
setToolVersions((prev) => mergeToolVersions(prev, updated));
// 同步进模块缓存,供切 Tab 重挂时复用。时间戳沿用上次「全量加载」的(单工具
// 刷新不算全量、不重置 TTL);缓存为空时以 at=0 起步——0 是「尚未完成全量加载」
// 的过期哨兵,确保探测中途切走/切回时,残缺缓存被判过期而触发重查,而非把半套
// 数据当成完整结果复用。真实时间戳只由 loadAllToolVersions 的 finally 盖上。
toolVersionsCache = {
data: mergeToolVersions(toolVersionsCache?.data ?? [], updated),
at: toolVersionsCache?.at ?? 0,
};
// 返回刷新结果,调用方可据此判断版本是否真的探到(避免读 state 撞 stale closure)。
return updated;
@@ -291,21 +321,42 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
[],
);
const loadAllToolVersions = useCallback(async () => {
setIsLoadingTools(true);
try {
// Respect current UI overrides (shell / flag) when doing a full refresh.
const versions = await settingsApi.getToolVersions(
[...TOOL_NAMES],
wslShellByTool,
);
setToolVersions(versions);
} catch (error) {
console.error("[AboutSection] Failed to load tool versions", error);
} finally {
setIsLoadingTools(false);
}
}, [wslShellByTool]);
const loadAllToolVersions = useCallback(
async (options?: { force?: boolean }) => {
const force = options?.force ?? false;
// 命中新鲜缓存:切回「关于」Tab 触发的重挂直接复用上次结果,跳过 6 个 `--version`
// 子进程 + 6 个 latest 版本网络请求。手动「刷新」传 force 绕过缓存强制重查。
if (
!force &&
toolVersionsCache &&
Date.now() - toolVersionsCache.at < TOOL_VERSIONS_CACHE_TTL_MS
) {
setToolVersions(toolVersionsCache.data);
setIsLoadingTools(false);
return;
}
setIsLoadingTools(true);
try {
// 逐工具并发探测:每个工具一完成就合并进 toolVersions(并写模块缓存)、清掉自己
// 的 loadingTools 标志,对应卡片随即独立刷新——而非等全部探测完才一次性显示(后端
// 原本对 6 个工具串行 await,总耗时累加;并发后压成「最慢的那一个」)。refreshTool-
// Versions 已内建按 name 合并 + per-tool loading + try/catch 兜底(单工具失败返回 []
// 不拖累其余),故 Promise.all 永不 reject。Respect current shell/flag overrides.
await Promise.all(
TOOL_NAMES.map((toolName) =>
refreshToolVersions([toolName], wslShellByTool),
),
);
} finally {
// 全量探测结束:把缓存时间戳刷新为现在,标记「刚完成一次全量加载」、重置 TTL。
if (toolVersionsCache) {
toolVersionsCache = { ...toolVersionsCache, at: Date.now() };
}
setIsLoadingTools(false);
}
},
[wslShellByTool, refreshToolVersions],
);
const handleToolShellChange = async (toolName: ToolName, value: string) => {
const wslShell = value === "auto" ? null : value;
@@ -332,18 +383,20 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
useEffect(() => {
let active = true;
const load = async () => {
try {
const [appVersion] = await Promise.all([
getVersion(),
loadAllToolVersions(),
]);
// 本软件自身版本走本地调用(getVersion,无网络,毫秒级),与工具版本探测彼此独立。
// 之前两者被塞进同一个 Promise.all,导致 setVersion / setIsLoadingVersion 被压在
// 「全部工具检查完成」之后——图标下方的版本徽标因此要干等 6 个工具全检完才显示。
// 拆成两条独立链路:应用版本一拿到就立刻显示,工具探测各自渐进刷新,互不阻塞。
const loadAppVersion = async () => {
try {
const appVersion = await getVersion();
appVersionCache = appVersion;
if (active) {
setVersion(appVersion);
}
} catch (error) {
console.error("[AboutSection] Failed to load info", error);
console.error("[AboutSection] Failed to load app version", error);
if (active) {
setVersion(null);
}
@@ -354,7 +407,8 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
}
};
void load();
void loadAppVersion();
void loadAllToolVersions();
return () => {
active = false;
};
@@ -392,7 +446,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
}, [t, updateInfo?.availableVersion, version]);
const handleCheckUpdate = useCallback(async () => {
if (hasUpdate && updateHandle) {
if (hasUpdate) {
if (isPortable) {
try {
await settingsApi.checkUpdates();
@@ -405,11 +459,16 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
setIsDownloading(true);
try {
resetDismiss();
await updateHandle.downloadAndInstall();
await relaunchApp();
const installed = await settingsApi.installUpdateAndRestart();
if (!installed) {
toast.success(t("settings.upToDate"), { closeButton: true });
}
} catch (error) {
console.error("[AboutSection] Update failed", error);
toast.error(t("settings.updateFailed"));
toast.error(t("settings.updateFailed"), {
description: extractErrorMessage(error) || undefined,
closeButton: true,
});
try {
await settingsApi.checkUpdates();
} catch (fallbackError) {
@@ -433,7 +492,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
console.error("[AboutSection] Check update failed", error);
toast.error(t("settings.checkUpdateFailed"));
}
}, [checkUpdate, hasUpdate, isPortable, resetDismiss, t, updateHandle]);
}, [checkUpdate, hasUpdate, isPortable, resetDismiss, t]);
const handleCopyInstallCommands = useCallback(async () => {
try {
@@ -768,31 +827,39 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-6 space-y-5 shadow-sm"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<img src={appIcon} alt="CC Switch" className="h-5 w-5" />
<h4 className="text-lg font-semibold text-foreground">
CC Switch
</h4>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1.5 bg-background/80">
<span className="text-muted-foreground">
{t("common.version")}
</span>
{isLoadingVersion ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<span className="font-medium">{`v${displayVersion}`}</span>
)}
</Badge>
{isPortable && (
<Badge variant="secondary" className="gap-1.5">
<Info className="h-3 w-3" />
{t("settings.portableMode")}
<div className="flex items-center gap-8">
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
<img src={appIcon} alt="CC Switch" className="h-5 w-5" />
<h4 className="text-lg font-semibold text-foreground">
CC Switch
</h4>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1.5 bg-background/80">
<span className="text-muted-foreground">
{t("common.version")}
</span>
{isLoadingVersion ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<span className="font-medium">{`v${displayVersion}`}</span>
)}
</Badge>
)}
{isPortable && (
<Badge variant="secondary" className="gap-1.5">
<Info className="h-3 w-3" />
{t("settings.portableMode")}
</Badge>
)}
</div>
</div>
<img
src={fable5VerifiedBanner}
alt="Fable 5 Verified"
className="h-16 w-auto shrink-0 select-none"
draggable={false}
/>
</div>
<div className="flex items-center gap-2">
@@ -908,7 +975,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
size="sm"
variant="outline"
className="h-7 gap-1.5 text-xs"
onClick={() => loadAllToolVersions()}
onClick={() => loadAllToolVersions({ force: true })}
disabled={isLoadingTools || isAnyBusy}
>
<RefreshCw
@@ -943,8 +1010,14 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
const tool = toolVersionByName.get(toolName);
const appConfig = APP_ICON_MAP[TOOL_APP_IDS[toolName]];
const displayName = TOOL_DISPLAY_NAMES[toolName];
// 单卡片 loading 用「结果是否已到」而非「整批是否结束」驱动,实现渐进式刷新:
// - loadingTools[t]:本工具探测在途(首次加载或单工具刷新);
// - isLoadingTools && !has(t):整批进行中且该工具尚未返回——覆盖首帧/刷新时
// 未完成卡片的 loading 外观。某工具结果一落进 toolVersionshas(t) 即为 true
// 该卡片立刻脱离 loading(哪怕全局 isLoadingTools 还为 true),其它卡片不受影响。
const isToolVersionLoading =
isLoadingTools || Boolean(loadingTools[toolName]);
Boolean(loadingTools[toolName]) ||
(isLoadingTools && !toolVersionByName.has(toolName));
const isOutdated = isUpdateAvailable(
tool?.version,
tool?.latest_version,
@@ -1047,9 +1120,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
<Select
value={wslShellByTool[toolName]?.wslShell || "auto"}
onValueChange={(v) => handleToolShellChange(toolName, v)}
disabled={
isLoadingTools || loadingTools[toolName] || isAnyBusy
}
disabled={isToolVersionLoading || isAnyBusy}
>
<SelectTrigger className="h-7 w-[82px] text-xs">
<SelectValue />
@@ -1068,9 +1139,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
onValueChange={(v) =>
handleToolShellFlagChange(toolName, v)
}
disabled={
isLoadingTools || loadingTools[toolName] || isAnyBusy
}
disabled={isToolVersionLoading || isAnyBusy}
>
<SelectTrigger className="h-7 w-[82px] text-xs">
<SelectValue />
+109 -2
View File
@@ -1,11 +1,18 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { KeyRound } from "lucide-react";
import { History, KeyRound } from "lucide-react";
import { toast } from "sonner";
import type { SettingsFormState } from "@/hooks/useSettings";
import { ToggleRow } from "@/components/ui/toggle-row";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { settingsApi } from "@/lib/api";
interface CodexAuthSettingsProps {
settings: SettingsFormState;
onChange: (updates: Partial<SettingsFormState>) => void;
/** 返回 false(或 resolve 为 false)表示保存失败;其余返回值视为成功 */
onChange: (
updates: Partial<SettingsFormState>,
) => void | boolean | Promise<void | boolean>;
}
export function CodexAuthSettings({
@@ -13,6 +20,73 @@ export function CodexAuthSettings({
onChange,
}: CodexAuthSettingsProps) {
const { t } = useTranslation();
const [showEnableConfirm, setShowEnableConfirm] = useState(false);
const [showDisableConfirm, setShowDisableConfirm] = useState(false);
const [hasUnifyBackup, setHasUnifyBackup] = useState(false);
const handleUnifyHistoryChange = (checked: boolean) => {
if (checked) {
setShowEnableConfirm(true);
return;
}
// 先探测有无迁移备份,决定关闭弹窗是否提供"恢复备份"勾选
void settingsApi
.hasCodexUnifyHistoryBackup()
.catch(() => false)
.then((hasBackup) => {
setHasUnifyBackup(hasBackup);
setShowDisableConfirm(true);
});
};
const handleEnableConfirm = (migrateExisting: boolean) => {
setShowEnableConfirm(false);
void onChange({
unifyCodexSessionHistory: true,
unifyCodexMigrateExisting: migrateExisting,
});
};
// 备份探测可能落后于正在后台进行的迁移(刚勾选迁入就立刻关闭时,
// 备份尚未产出)。只要本轮勾选过"迁入既有会话",就必须提供恢复入口;
// 真正有没有账本交给后端 restore 的 skippedReason 判定。
const showRestoreOption =
hasUnifyBackup || (settings.unifyCodexMigrateExisting ?? false);
const handleDisableConfirm = async (restoreBackup: boolean) => {
setShowDisableConfirm(false);
const saved = await onChange({
unifyCodexSessionHistory: false,
unifyCodexMigrateExisting: false,
});
// 关闭保存失败时绝不还原:否则开关仍开着(live 仍统一路由),
// 已迁移会话却被翻回 openai 桶,历史被拆成两半。
if (saved === false) return;
// 不再以探测结果短路:还原命令会在迁移锁上排队,等到迁移落盘后
// 拿到完整账本;确实无账本时由 skippedReason 提示。
if (!restoreBackup) return;
try {
const result = await settingsApi.restoreCodexUnifiedHistory();
if (result.skippedReason) {
// unify_toggle_on:还原排队期间开关被重新开启,后端拒绝还原
toast.info(
result.skippedReason === "unify_toggle_on"
? t("settings.unifyCodexHistoryRestoreSkippedToggleOn")
: t("settings.unifyCodexHistoryRestoreNothing"),
);
return;
}
toast.success(
t("settings.unifyCodexHistoryRestoreCompleted", {
files: result.restoredJsonlFiles,
rows: result.restoredStateRows,
}),
);
} catch (error) {
console.error("Failed to restore codex unified history:", error);
toast.error(t("settings.unifyCodexHistoryRestoreFailed"));
}
};
return (
<section className="space-y-4">
@@ -30,6 +104,39 @@ export function CodexAuthSettings({
onChange({ preserveCodexOfficialAuthOnSwitch: value })
}
/>
<ToggleRow
icon={<History className="h-4 w-4 text-sky-500" />}
title={t("settings.unifyCodexSessionHistory")}
description={t("settings.unifyCodexSessionHistoryDescription")}
checked={settings.unifyCodexSessionHistory ?? false}
onCheckedChange={handleUnifyHistoryChange}
/>
<ConfirmDialog
isOpen={showEnableConfirm}
title={t("confirm.unifyCodexHistory.title")}
message={t("confirm.unifyCodexHistory.message")}
checkboxLabel={t("confirm.unifyCodexHistory.migrateExisting")}
confirmText={t("confirm.unifyCodexHistory.confirm")}
onConfirm={handleEnableConfirm}
onCancel={() => setShowEnableConfirm(false)}
/>
<ConfirmDialog
isOpen={showDisableConfirm}
title={t("confirm.unifyCodexHistoryOff.title")}
message={t("confirm.unifyCodexHistoryOff.message")}
checkboxLabel={
showRestoreOption
? t("confirm.unifyCodexHistoryOff.restoreBackup")
: undefined
}
checkboxDefaultChecked
confirmText={t("confirm.unifyCodexHistoryOff.confirm")}
onConfirm={(restoreBackup) => void handleDisableConfirm(restoreBackup)}
onCancel={() => setShowDisableConfirm(false)}
/>
</section>
);
}
+1 -1
View File
@@ -22,7 +22,7 @@ import type { SettingsFormState } from "@/hooks/useSettings";
interface ProxyTabContentProps {
settings: SettingsFormState;
onAutoSave: (updates: Partial<SettingsFormState>) => Promise<void>;
onAutoSave: (updates: Partial<SettingsFormState>) => Promise<boolean | void>;
}
export function ProxyTabContent({
+17 -2
View File
@@ -163,19 +163,34 @@ export function SettingsPage({
// 通用设置即时保存(无需手动点击)
// 使用 autoSaveSettings 避免误触发系统 API(开机自启、Claude 插件等)
// 返回保存是否成功:需要在保存成功后追加动作的调用方(如统一会话历史
// 关闭后的备份还原)据此短路,其余调用方可忽略返回值。
const handleAutoSave = useCallback(
async (updates: Partial<SettingsFormState>) => {
if (!settings) return;
async (updates: Partial<SettingsFormState>): Promise<boolean> => {
if (!settings) return false;
// 乐观更新前捕获旧值:autoSaveSettings 发送的是全量表单状态,后端按
// diff 触发副作用(如统一会话开关的 live 重写与历史迁移)。保存失败
// 不回滚的话,失败的变更会滞留在表单里,被之后任意一次无关保存原样
// 重放,绕过确认弹窗。
const previousValues = Object.fromEntries(
Object.keys(updates).map((key) => [
key,
settings[key as keyof SettingsFormState],
]),
) as Partial<SettingsFormState>;
updateSettings(updates);
try {
await autoSaveSettings(updates);
return true;
} catch (error) {
console.error("[SettingsPage] Failed to autosave settings", error);
updateSettings(previousValues);
toast.error(
t("settings.saveFailedGeneric", {
defaultValue: "保存失败,请重试",
}),
);
return false;
}
},
[autoSaveSettings, settings, t, updateSettings],
+1 -1
View File
@@ -16,7 +16,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-[100] rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
+1 -1
View File
@@ -20,7 +20,7 @@ export function ToggleRow({
return (
<div className="flex items-center justify-between gap-4 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-background ring-1 ring-border">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-background ring-1 ring-border">
{icon}
</div>
<div className="space-y-1">
+1 -1
View File
@@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-[100] overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
+11 -3
View File
@@ -14,18 +14,26 @@ import type { UsageRangeSelection } from "@/types/usage";
interface ModelStatsTableProps {
range: UsageRangeSelection;
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
}
export function ModelStatsTable({
range,
appType,
providerName,
model,
refreshIntervalMs,
}: ModelStatsTableProps) {
const { t } = useTranslation();
const { data: stats, isLoading } = useModelStats(range, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
const { data: stats, isLoading } = useModelStats(
range,
{ appType, providerName, model },
{
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
},
);
if (isLoading) {
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
+17 -78
View File
@@ -2,10 +2,9 @@ import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Save, Loader2 } from "lucide-react";
import { Save, Loader2, Info } from "lucide-react";
import { toast } from "sonner";
import {
getStreamCheckConfig,
@@ -20,13 +19,9 @@ export function ModelTestConfigPanel() {
const [error, setError] = useState<string | null>(null);
// 使用字符串状态以支持完全清空数字输入框
const [config, setConfig] = useState({
timeoutSecs: "45",
maxRetries: "2",
timeoutSecs: "8",
maxRetries: "1",
degradedThresholdMs: "6000",
claudeModel: "claude-haiku-4-5-20251001",
codexModel: "gpt-5.5@low",
geminiModel: "gemini-3.5-flash",
testPrompt: "Who are you?",
});
useEffect(() => {
@@ -42,10 +37,6 @@ export function ModelTestConfigPanel() {
timeoutSecs: String(data.timeoutSecs),
maxRetries: String(data.maxRetries),
degradedThresholdMs: String(data.degradedThresholdMs),
claudeModel: data.claudeModel,
codexModel: data.codexModel,
geminiModel: data.geminiModel,
testPrompt: data.testPrompt || "Who are you?",
});
} catch (e) {
setError(String(e));
@@ -63,13 +54,9 @@ export function ModelTestConfigPanel() {
try {
setIsSaving(true);
const parsed: StreamCheckConfig = {
timeoutSecs: parseNum(config.timeoutSecs, 45),
maxRetries: parseNum(config.maxRetries, 2),
timeoutSecs: parseNum(config.timeoutSecs, 8),
maxRetries: parseNum(config.maxRetries, 1),
degradedThresholdMs: parseNum(config.degradedThresholdMs, 6000),
claudeModel: config.claudeModel,
codexModel: config.codexModel,
geminiModel: config.geminiModel,
testPrompt: config.testPrompt || "Who are you?",
};
await saveStreamCheckConfig(parsed);
toast.success(t("streamCheck.configSaved"), {
@@ -98,49 +85,16 @@ export function ModelTestConfigPanel() {
</Alert>
)}
{/* 测试模型配置 */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-muted-foreground">
{t("streamCheck.testModels")}
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="claudeModel">{t("streamCheck.claudeModel")}</Label>
<Input
id="claudeModel"
value={config.claudeModel}
onChange={(e) =>
setConfig({ ...config, claudeModel: e.target.value })
}
placeholder="claude-3-5-haiku-latest"
/>
</div>
<div className="space-y-2">
<Label htmlFor="codexModel">{t("streamCheck.codexModel")}</Label>
<Input
id="codexModel"
value={config.codexModel}
onChange={(e) =>
setConfig({ ...config, codexModel: e.target.value })
}
placeholder="gpt-4o-mini"
/>
</div>
<div className="space-y-2">
<Label htmlFor="geminiModel">{t("streamCheck.geminiModel")}</Label>
<Input
id="geminiModel"
value={config.geminiModel}
onChange={(e) =>
setConfig({ ...config, geminiModel: e.target.value })
}
placeholder="gemini-1.5-flash"
/>
</div>
</div>
</div>
{/* 连通检测语义说明:可达 ≠ 配置正确 */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
{t("streamCheck.connectivityNote", {
defaultValue:
"连通检测仅探测供应商地址是否可达,不发送真实模型请求。收到任意响应即视为“可达”——这不代表鉴权或模型配置一定正确。",
})}
</AlertDescription>
</Alert>
{/* 检查参数配置 */}
<div className="space-y-4">
@@ -153,8 +107,8 @@ export function ModelTestConfigPanel() {
<Input
id="timeoutSecs"
type="number"
min={10}
max={120}
min={2}
max={60}
value={config.timeoutSecs}
onChange={(e) =>
setConfig({ ...config, timeoutSecs: e.target.value })
@@ -193,21 +147,6 @@ export function ModelTestConfigPanel() {
/>
</div>
</div>
{/* 检查提示词配置 */}
<div className="space-y-2">
<Label htmlFor="testPrompt">{t("streamCheck.testPrompt")}</Label>
<Textarea
id="testPrompt"
value={config.testPrompt}
onChange={(e) =>
setConfig({ ...config, testPrompt: e.target.value })
}
placeholder="Who are you?"
rows={2}
className="min-h-[60px]"
/>
</div>
</div>
<div className="flex justify-end">
+11 -3
View File
@@ -14,18 +14,26 @@ import type { UsageRangeSelection } from "@/types/usage";
interface ProviderStatsTableProps {
range: UsageRangeSelection;
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
}
export function ProviderStatsTable({
range,
appType,
providerName,
model,
refreshIntervalMs,
}: ProviderStatsTableProps) {
const { t } = useTranslation();
const { data: stats, isLoading } = useProviderStats(range, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
const { data: stats, isLoading } = useProviderStats(
range,
{ appType, providerName, model },
{
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
},
);
if (isLoading) {
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
+27 -127
View File
@@ -19,13 +19,12 @@ import {
} from "@/components/ui/select";
import { useRequestLogs } from "@/lib/query/usage";
import {
KNOWN_APP_TYPES,
getFreshInputTokens,
isUnpricedUsage,
type LogFilters,
type UsageRangeSelection,
} from "@/types/usage";
import { ChevronLeft, ChevronRight, Search, X } from "lucide-react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { UsageDateRangePicker } from "./UsageDateRangePicker";
import {
fmtInt,
@@ -38,6 +37,8 @@ interface RequestLogTableProps {
range: UsageRangeSelection;
rangeLabel: string;
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
onRangeChange?: (range: UsageRangeSelection) => void;
}
@@ -46,21 +47,29 @@ export function RequestLogTable({
range,
rangeLabel,
appType: dashboardAppType,
providerName,
model,
refreshIntervalMs,
onRangeChange,
}: RequestLogTableProps) {
const { t, i18n } = useTranslation();
const [appliedFilters, setAppliedFilters] = useState<LogFilters>({});
const [draftFilters, setDraftFilters] = useState<LogFilters>({});
// 应用/Provider/模型筛选已上移到 Dashboard 顶栏(全局生效);
// 这里只保留日志特有的状态码筛选。
const [statusCode, setStatusCode] = useState<number | undefined>(undefined);
const [page, setPage] = useState(0);
const [pageInput, setPageInput] = useState("");
const pageSize = 20;
const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all";
const effectiveFilters: LogFilters = dashboardAppTypeActive
? { ...appliedFilters, appType: dashboardAppType }
: appliedFilters;
const effectiveFilters: LogFilters = {
appType:
dashboardAppType && dashboardAppType !== "all"
? dashboardAppType
: undefined,
providerName,
model,
statusCode,
};
const { data: result, isLoading } = useRequestLogs({
filters: effectiveFilters,
@@ -80,37 +89,13 @@ export function RequestLogTable({
setPage(0);
}, [
dashboardAppType,
providerName,
model,
range.customEndDate,
range.customStartDate,
range.preset,
]);
const handleSearch = () => {
setAppliedFilters(draftFilters);
setPage(0);
};
const handleReset = () => {
setDraftFilters({});
setAppliedFilters({});
setPage(0);
};
const applySelectFilter = <K extends keyof LogFilters>(
key: K,
value: LogFilters[K],
) => {
setDraftFilters((prev) => ({
...prev,
[key]: value,
}));
setAppliedFilters((prev) => ({
...prev,
[key]: value,
}));
setPage(0);
};
const handleGoToPage = () => {
const trimmed = pageInput.trim();
if (!/^\d+$/.test(trimmed)) return;
@@ -127,44 +112,16 @@ export function RequestLogTable({
<div className="space-y-4">
<div className="rounded-lg border bg-card/50 p-2 backdrop-blur-sm">
<div className="flex flex-wrap items-center gap-1.5">
{/* App type */}
<Select
value={
dashboardAppTypeActive
? dashboardAppType
: draftFilters.appType || "all"
}
onValueChange={(v) =>
applySelectFilter("appType", v === "all" ? undefined : v)
}
disabled={!!dashboardAppTypeActive}
>
<SelectTrigger className="h-8 w-[110px] bg-background text-xs">
<SelectValue placeholder={t("usage.appType")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("usage.allApps")}</SelectItem>
{KNOWN_APP_TYPES.map((at) => (
<SelectItem key={at} value={at}>
{t(`usage.appFilter.${at}`, { defaultValue: at })}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Status code */}
<Select
value={draftFilters.statusCode?.toString() || "all"}
onValueChange={(v) =>
applySelectFilter(
"statusCode",
v === "all"
? undefined
: Number.isFinite(Number.parseInt(v, 10))
? Number.parseInt(v, 10)
: undefined,
)
}
value={statusCode?.toString() || "all"}
onValueChange={(v) => {
const parsed = Number.parseInt(v, 10);
setStatusCode(
v === "all" || !Number.isFinite(parsed) ? undefined : parsed,
);
setPage(0);
}}
>
<SelectTrigger className="h-8 w-[100px] bg-background text-xs">
<SelectValue placeholder={t("usage.statusCode")} />
@@ -179,43 +136,6 @@ export function RequestLogTable({
</SelectContent>
</Select>
{/* Provider search */}
<div className="relative min-w-[140px] flex-1">
<Search className="absolute left-2 top-2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("usage.searchProviderPlaceholder")}
className="h-8 bg-background pl-7 text-xs"
value={draftFilters.providerName || ""}
onChange={(e) =>
setDraftFilters({
...draftFilters,
providerName: e.target.value || undefined,
})
}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
/>
</div>
{/* Model search */}
<div className="relative min-w-[120px] flex-1">
<Input
placeholder={t("usage.searchModelPlaceholder")}
className="h-8 bg-background text-xs"
value={draftFilters.model || ""}
onChange={(e) =>
setDraftFilters({
...draftFilters,
model: e.target.value || undefined,
})
}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
/>
</div>
{onRangeChange && (
<UsageDateRangePicker
selection={range}
@@ -223,26 +143,6 @@ export function RequestLogTable({
onApply={onRangeChange}
/>
)}
{/* Search & Reset (icon-only) */}
<Button
size="icon"
variant="default"
onClick={handleSearch}
className="h-8 w-8"
title={t("common.search")}
>
<Search className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="outline"
onClick={handleReset}
className="h-8 w-8"
title={t("common.reset")}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
+201 -36
View File
@@ -7,6 +7,7 @@ import { ProviderStatsTable } from "./ProviderStatsTable";
import { ModelStatsTable } from "./ModelStatsTable";
import {
KNOWN_APP_TYPES,
type AppType,
type AppTypeFilter,
type UsageRangeSelection,
} from "@/types/usage";
@@ -17,10 +18,18 @@ import {
Activity,
RefreshCw,
Coins,
LayoutGrid,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ProviderIcon } from "@/components/ProviderIcon";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useQueryClient } from "@tanstack/react-query";
import { usageKeys } from "@/lib/query/usage";
import { usageKeys, useModelStats, useProviderStats } from "@/lib/query/usage";
import { useUsageEventBridge } from "@/hooks/useUsageEventBridge";
import {
Accordion,
@@ -37,25 +46,56 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const APP_FILTER_OPTIONS: AppTypeFilter[] = ["all", ...KNOWN_APP_TYPES];
// 0 表示关闭自动刷新(refetchInterval=false
const REFRESH_INTERVAL_OPTIONS_MS = [0, 5000, 10000, 30000, 60000] as const;
// 与 AppSwitcher 的 appIconName 保持一致(codex 复用 openai 图标)
const APP_FILTER_ICON: Record<AppType, string> = {
claude: "claude",
codex: "openai",
gemini: "gemini",
opencode: "opencode",
};
// Select 的 "all" 哨兵和用户自定义名称同处一个值域——真有来源/模型叫 "all"
// 就会撞名(重复 value、选中即清空筛选)。动态选项统一加前缀编码隔离值域。
const DYNAMIC_OPTION_PREFIX = "v:";
const encodeOptionValue = (name: string) => `${DYNAMIC_OPTION_PREFIX}${name}`;
const decodeOptionValue = (value: string) =>
value === "all" ? undefined : value.slice(DYNAMIC_OPTION_PREFIX.length);
export function UsageDashboard() {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const [range, setRange] = useState<UsageRangeSelection>({ preset: "today" });
const [appType, setAppType] = useState<AppTypeFilter>("all");
const [providerName, setProviderName] = useState<string | undefined>(
undefined,
);
const [model, setModel] = useState<string | undefined>(undefined);
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
// 切应用时清掉下游筛选,避免留下一个在新范围内查无数据的"幽灵"组合;
// 切 Provider 同理清掉模型(模型选项随 Provider 级联)。
const changeAppType = (next: AppTypeFilter) => {
setAppType(next);
if (next !== appType) {
setProviderName(undefined);
setModel(undefined);
}
};
const changeProviderName = (next: string | undefined) => {
setProviderName(next);
if (next !== providerName) {
setModel(undefined);
}
};
// 后端写入新日志时 emit `usage-log-recorded`,本 hook 立刻 invalidate 所有
// usage 查询,实现实时刷新(仅在 Dashboard 挂载时生效,离开页面自动取消监听)
useUsageEventBridge();
const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const;
const changeRefreshInterval = () => {
const currentIndex = refreshIntervalOptionsMs.indexOf(
refreshIntervalMs as (typeof refreshIntervalOptionsMs)[number],
);
const safeIndex = currentIndex >= 0 ? currentIndex : 3;
const nextIndex = (safeIndex + 1) % refreshIntervalOptionsMs.length;
const next = refreshIntervalOptionsMs[nextIndex];
const changeRefreshInterval = (next: number) => {
setRefreshIntervalMs(next);
queryClient.invalidateQueries({ queryKey: usageKeys.all });
};
@@ -73,6 +113,45 @@ export function UsageDashboard() {
).toLocaleString(locale)}`;
}, [locale, range, resolvedRange.endDate, resolvedRange.startDate, t]);
// 顶栏下拉的选项池:Provider 列表只跟应用/时间范围走(不受自身选中值影响),
// 模型列表随所选 Provider 级联。两者都只列当前范围内真实有数据的条目。
// refetchInterval 必须跟随面板的刷新设置——未筛选时这两个查询与统计表共享
// query key,落下的话会以默认 30s 拖着同 key 查询一起轮询,"--" 形同虚设。
const optionsRefetch = {
refetchInterval:
refreshIntervalMs > 0 ? refreshIntervalMs : (false as const),
};
const { data: providerOptionsData } = useProviderStats(
range,
{ appType },
optionsRefetch,
);
const { data: modelOptionsData } = useModelStats(
range,
{ appType, providerName },
optionsRefetch,
);
const providerOptions = useMemo(() => {
const names = new Set<string>();
for (const stat of providerOptionsData ?? []) {
names.add(stat.providerName);
}
// 数据刷新后选中项可能掉出列表(如改了时间范围);补回去保证 Select
// 仍能渲染选中文案,用户看得见才能主动清除。
if (providerName) names.add(providerName);
return Array.from(names);
}, [providerOptionsData, providerName]);
const modelOptions = useMemo(() => {
const names = new Set<string>();
for (const stat of modelOptionsData ?? []) {
names.add(stat.model);
}
if (model) names.add(model);
return Array.from(names);
}, [modelOptionsData, model]);
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
@@ -90,35 +169,111 @@ export function UsageDashboard() {
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center p-1 bg-muted/30 rounded-lg border border-border/50">
{APP_FILTER_OPTIONS.map((type) => (
<button
key={type}
type="button"
onClick={() => setAppType(type)}
className={cn(
"px-3 py-1.5 rounded-md text-sm font-medium transition-all",
appType === type
? "bg-background text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
>
{t(`usage.appFilter.${type}`)}
</button>
))}
{APP_FILTER_OPTIONS.map((type) => {
const label = t(`usage.appFilter.${type}`);
return (
<button
key={type}
type="button"
onClick={() => changeAppType(type)}
title={label}
aria-label={label}
className={cn(
"flex h-8 items-center justify-center px-2.5 rounded-md transition-all",
appType === type
? "bg-background text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
>
{type === "all" ? (
<LayoutGrid className="h-4 w-4" />
) : (
<ProviderIcon
icon={APP_FILTER_ICON[type]}
name={label}
size={16}
/>
)}
</button>
);
})}
</div>
<div className="flex items-center gap-2 ml-auto lg:ml-0">
<Button
type="button"
variant="outline"
size="sm"
className="h-9 px-3 text-xs"
title={t("common.refresh", "刷新")}
onClick={changeRefreshInterval}
<Select
value={
providerName != null ? encodeOptionValue(providerName) : "all"
}
onValueChange={(v) => changeProviderName(decodeOptionValue(v))}
>
<SelectTrigger
className="h-9 w-[100px] bg-background text-xs focus:border-border-default [&>span]:min-w-0 [&>span]:truncate"
title={providerName ?? t("usage.filterBySource")}
>
<RefreshCw className="mr-2 h-3.5 w-3.5" />
{refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : "--"}
</Button>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-w-[280px]">
<SelectItem value="all">{t("usage.allSources")}</SelectItem>
{providerOptions.map((name) => (
<SelectItem
key={name}
value={encodeOptionValue(name)}
title={name}
className="[&>span]:min-w-0 [&>span]:truncate"
>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={model != null ? encodeOptionValue(model) : "all"}
onValueChange={(v) => setModel(decodeOptionValue(v))}
>
<SelectTrigger
className="h-9 w-[100px] bg-background text-xs focus:border-border-default [&>span]:min-w-0 [&>span]:truncate"
title={model ?? t("usage.filterByModel")}
>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-w-[280px]">
<SelectItem value="all">{t("usage.allModels")}</SelectItem>
{modelOptions.map((name) => (
<SelectItem
key={name}
value={encodeOptionValue(name)}
title={name}
className="[&>span]:min-w-0 [&>span]:truncate"
>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2 ml-auto lg:ml-0">
<Select
value={String(refreshIntervalMs)}
onValueChange={(v) => changeRefreshInterval(Number(v))}
>
<SelectTrigger
className="h-9 w-[100px] bg-background text-xs focus:border-border-default"
title={t("usage.refreshInterval")}
aria-label={t("usage.refreshInterval")}
>
<span className="flex items-center gap-2">
<RefreshCw className="h-3.5 w-3.5 shrink-0" />
<SelectValue />
</span>
</SelectTrigger>
<SelectContent>
{REFRESH_INTERVAL_OPTIONS_MS.map((ms) => (
<SelectItem key={ms} value={String(ms)}>
{ms > 0 ? `${ms / 1000}s` : t("usage.refreshOff")}
</SelectItem>
))}
</SelectContent>
</Select>
<UsageDateRangePicker
selection={range}
@@ -132,6 +287,8 @@ export function UsageDashboard() {
<UsageHero
range={range}
appType={appType === "all" ? undefined : appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
@@ -139,6 +296,8 @@ export function UsageDashboard() {
range={range}
rangeLabel={rangeLabel}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
@@ -171,6 +330,8 @@ export function UsageDashboard() {
range={range}
rangeLabel={rangeLabel}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
onRangeChange={setRange}
/>
@@ -180,6 +341,8 @@ export function UsageDashboard() {
<ProviderStatsTable
range={range}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent>
@@ -188,6 +351,8 @@ export function UsageDashboard() {
<ModelStatsTable
range={range}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
</TabsContent>
+11 -4
View File
@@ -1,6 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
import {
CalendarDays,
ChevronDown,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -273,10 +278,12 @@ export function UsageDateRangePicker({
<Button
type="button"
variant={selection.preset === "custom" ? "default" : "outline"}
className="justify-start gap-2"
className="h-9 w-[100px] justify-start gap-1.5 text-xs"
title={triggerLabel}
>
<CalendarDays className="h-4 w-4" />
<span className="truncate">{triggerLabel}</span>
<CalendarDays className="h-4 w-4 shrink-0" />
<span className="truncate flex-1">{triggerLabel}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
+11 -8
View File
@@ -33,6 +33,8 @@ import {
interface UsageHeroProps {
range: UsageRangeSelection;
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
}
@@ -49,11 +51,6 @@ const TITLE_THEMES: Record<AppType | "all", TitleTheme> = {
accent: "text-amber-600 dark:text-amber-400",
iconBg: "bg-amber-500/10",
},
"claude-desktop": {
// 与 Claude Code 同属 Anthropic 品牌,用更深的 orange 区分
accent: "text-orange-600 dark:text-orange-400",
iconBg: "bg-orange-500/10",
},
codex: {
// OpenAI/Codex 走黑白单色调;中性灰在深浅模式都能透出方块底色,
// 不像纯黑 bg-black/10 在深色背景下会糊掉。
@@ -164,14 +161,20 @@ function AppGlyph({
export function UsageHero({
range,
appType,
providerName,
model,
refreshIntervalMs,
}: UsageHeroProps) {
const { t, i18n } = useTranslation();
const lang = getResolvedLang(i18n);
const { data, isLoading } = useUsageSummaryByApp(range, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
const { data, isLoading } = useUsageSummaryByApp(
range,
{ providerName, model },
{
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
},
);
// No client-side filtering: Hero's totals must match the Trend/Logs/Stats
// below, which all go through the backend's full set of app_types. The
+11 -3
View File
@@ -24,6 +24,8 @@ interface UsageTrendChartProps {
range: UsageRangeSelection;
rangeLabel: string;
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
}
@@ -31,13 +33,19 @@ export function UsageTrendChart({
range,
rangeLabel,
appType,
providerName,
model,
refreshIntervalMs,
}: UsageTrendChartProps) {
const { t, i18n } = useTranslation();
const { startDate, endDate } = resolveUsageRange(range);
const { data: trends, isLoading } = useUsageTrends(range, appType, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
const { data: trends, isLoading } = useUsageTrends(
range,
{ appType, providerName, model },
{
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
},
);
if (isLoading) {
return (
+13 -24
View File
@@ -25,13 +25,16 @@ export interface ClaudeDesktopRoutePreset {
}
/**
* Claude Desktop 3P fail-all `claude-(sonnet|opus|haiku)-*`
* routeId1.6259.1+ 2026-05-13
* `next_catalog_safe_route_id` routeId
* Claude Desktop 3P fail-all Desktop 1.12603.1+
* fableapp.asar ["sonnet","opus","haiku","fable","mythos"]
* 2026-06-13 1.6259.1 sonnet/opus/haikumythos
*
* `next_catalog_safe_route_id` routeId
*/
export const CLAUDE_DESKTOP_ROLE_ROUTE_IDS = {
sonnet: "claude-sonnet-4-6",
opus: "claude-opus-4-8",
fable: "claude-fable-5",
haiku: "claude-haiku-4-5",
} as const;
@@ -183,9 +186,9 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
{
name: "火山Agentplan",
websiteUrl:
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
apiKeyUrl:
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
category: "cn_official",
baseUrl: "https://ark.cn-beijing.volces.com/api/coding",
mode: "proxy",
@@ -416,7 +419,11 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
baseUrl: "https://api.moonshot.cn/anthropic",
mode: "proxy",
apiFormat: "anthropic",
modelRoutes: brandedRoutes("kimi-k2.6", "kimi-k2.6", "kimi-k2.6"),
modelRoutes: brandedRoutes(
"kimi-k2.7-code",
"kimi-k2.7-code",
"kimi-k2.7-code",
),
icon: "kimi",
iconColor: "#6366F1",
},
@@ -505,7 +512,6 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
mode: "proxy",
apiFormat: "anthropic",
modelRoutes: brandedRoutes("MiniMax-M2.7", "MiniMax-M2.7", "MiniMax-M2.7"),
isPartner: true,
partnerPromotionKey: "minimax_cn",
theme: {
backgroundColor: "#f64551",
@@ -523,7 +529,6 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
mode: "proxy",
apiFormat: "anthropic",
modelRoutes: brandedRoutes("MiniMax-M2.7", "MiniMax-M2.7", "MiniMax-M2.7"),
isPartner: true,
partnerPromotionKey: "minimax_en",
theme: {
backgroundColor: "#f64551",
@@ -689,8 +694,6 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
apiFormat: "anthropic",
modelRoutes: passthroughRoutes(),
endpointCandidates: ["https://sudocode.us", "https://sudocode.run"],
isPartner: true,
partnerPromotionKey: "sudocode",
icon: "sudocode",
},
{
@@ -970,20 +973,6 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
icon: "novita",
iconColor: "#000000",
},
{
name: "LemonData",
websiteUrl: "https://lemondata.cc",
apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP",
category: "third_party",
baseUrl: "https://api.lemondata.cc",
apiKeyField: "ANTHROPIC_API_KEY",
mode: "direct",
apiFormat: "anthropic",
modelRoutes: passthroughRoutes(),
isPartner: true,
partnerPromotionKey: "lemondata",
icon: "lemondata",
},
{
name: "Nvidia",
websiteUrl: "https://build.nvidia.com",
+6 -26
View File
@@ -128,9 +128,9 @@ export const providerPresets: ProviderPreset[] = [
{
name: "火山Agentplan",
websiteUrl:
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
apiKeyUrl:
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://ark.cn-beijing.volces.com/api/coding",
@@ -372,10 +372,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.moonshot.cn/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "kimi-k2.6",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "kimi-k2.6",
ANTHROPIC_DEFAULT_SONNET_MODEL: "kimi-k2.6",
ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2.6",
ANTHROPIC_MODEL: "kimi-k2.7-code",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "kimi-k2.7-code",
ANTHROPIC_DEFAULT_SONNET_MODEL: "kimi-k2.7-code",
ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2.7-code",
},
},
category: "cn_official",
@@ -513,7 +513,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax_cn",
theme: {
backgroundColor: "#f64551",
@@ -539,7 +538,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax_en",
theme: {
backgroundColor: "#f64551",
@@ -743,8 +741,6 @@ export const providerPresets: ProviderPreset[] = [
},
endpointCandidates: ["https://sudocode.us", "https://sudocode.run"],
category: "third_party",
isPartner: true,
partnerPromotionKey: "sudocode",
icon: "sudocode",
},
{
@@ -1107,22 +1103,6 @@ export const providerPresets: ProviderPreset[] = [
icon: "openai",
iconColor: "#000000",
},
{
name: "LemonData",
websiteUrl: "https://lemondata.cc",
apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP",
apiKeyField: "ANTHROPIC_API_KEY",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.lemondata.cc",
ANTHROPIC_API_KEY: "",
},
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "lemondata",
icon: "lemondata",
},
{
name: "Nvidia",
websiteUrl: "https://build.nvidia.com",
+43 -25
View File
@@ -135,9 +135,9 @@ export const codexProviderPresets: CodexProviderPreset[] = [
{
name: "火山Agentplan",
websiteUrl:
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
apiKeyUrl:
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"ark_agentplan",
@@ -426,12 +426,46 @@ requires_openai_auth = true`,
config: generateThirdPartyConfig(
"kimi",
"https://api.moonshot.cn/v1",
"kimi-k2.6",
"kimi-k2.7-code",
),
endpointCandidates: ["https://api.moonshot.cn/v1"],
apiFormat: "openai_chat",
modelCatalog: modelCatalog([
{ model: "kimi-k2.6", displayName: "Kimi K2.6", contextWindow: 262144 },
{
model: "kimi-k2.7-code",
displayName: "Kimi K2.7 Code",
contextWindow: 262144,
},
]),
codexChatReasoning: {
supportsThinking: true,
supportsEffort: false,
thinkingParam: "thinking",
effortParam: "none",
outputFormat: "reasoning_content",
},
category: "cn_official",
icon: "kimi",
iconColor: "#6366F1",
},
{
name: "Kimi For Coding",
websiteUrl: "https://www.kimi.com/code/docs/",
apiKeyUrl: "https://www.kimi.com/code/",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"kimi_coding",
"https://api.kimi.com/coding/v1",
"kimi-for-coding",
),
endpointCandidates: ["https://api.kimi.com/coding/v1"],
apiFormat: "openai_chat",
modelCatalog: modelCatalog([
{
model: "kimi-for-coding",
displayName: "Kimi For Coding",
contextWindow: 262144,
},
]),
codexChatReasoning: {
supportsThinking: true,
@@ -580,7 +614,6 @@ requires_openai_auth = true`,
outputFormat: "reasoning_details",
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax_cn",
theme: {
backgroundColor: "#f64551",
@@ -616,7 +649,6 @@ requires_openai_auth = true`,
outputFormat: "reasoning_details",
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax_en",
theme: {
backgroundColor: "#f64551",
@@ -945,7 +977,11 @@ requires_openai_auth = true`,
endpointCandidates: ["https://api.atlascloud.ai/v1"],
apiFormat: "openai_chat",
modelCatalog: modelCatalog([
{ model: "zai-org/glm-5.1", displayName: "GLM 5.1" },
{
model: "zai-org/glm-5.1",
displayName: "GLM 5.1",
contextWindow: 200000,
},
]),
isPartner: true,
partnerPromotionKey: "atlascloud",
@@ -971,8 +1007,6 @@ wire_api = "responses"
requires_openai_auth = true`,
endpointCandidates: ["https://sudocode.us/v1", "https://sudocode.run/v1"],
apiFormat: "openai_responses",
isPartner: true,
partnerPromotionKey: "sudocode",
icon: "sudocode",
},
{
@@ -1226,22 +1260,6 @@ model_auto_compact_token_limit = 9000000`,
icon: "eflowcode",
iconColor: "#000000",
},
{
name: "LemonData",
websiteUrl: "https://lemondata.cc",
apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP",
category: "third_party",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"lemondata",
"https://api.lemondata.cc/v1",
"gpt-5.5",
),
endpointCandidates: ["https://api.lemondata.cc/v1"],
isPartner: true,
partnerPromotionKey: "lemondata",
icon: "lemondata",
},
{
name: "PIPELLM",
websiteUrl: "https://code.pipellm.ai",
-21
View File
@@ -164,8 +164,6 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
model: "gemini-3.1-flash-lite",
description: "SudoCode",
category: "third_party",
isPartner: true,
partnerPromotionKey: "sudocode",
endpointCandidates: ["https://sudocode.us", "https://sudocode.run"],
icon: "sudocode",
},
@@ -336,25 +334,6 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
icon: "eflowcode",
iconColor: "#000000",
},
{
name: "LemonData",
websiteUrl: "https://lemondata.cc",
apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP",
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://api.lemondata.cc",
GEMINI_MODEL: "gemini-3.5-flash",
},
},
baseURL: "https://api.lemondata.cc",
model: "gemini-3.5-flash",
description: "LemonData",
category: "third_party",
isPartner: true,
partnerPromotionKey: "lemondata",
endpointCandidates: ["https://api.lemondata.cc"],
icon: "lemondata",
},
{
name: "CherryIN",
websiteUrl: "https://open.cherryin.ai",
+4 -27
View File
@@ -151,9 +151,9 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
{
name: "火山Agentplan",
websiteUrl:
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
apiKeyUrl:
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
settingsConfig: {
name: "ark_agentplan",
base_url: "https://ark.cn-beijing.volces.com/api/coding",
@@ -521,13 +521,13 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
base_url: "https://api.moonshot.cn/v1",
api_key: "",
api_mode: "chat_completions",
models: [{ id: "kimi-k2.6", name: "Kimi K2.6" }],
models: [{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }],
},
category: "cn_official",
icon: "kimi",
iconColor: "#6366F1",
suggestedDefaults: {
model: { default: "kimi-k2.6", provider: "kimi" },
model: { default: "kimi-k2.7-code", provider: "kimi" },
},
},
{
@@ -641,7 +641,6 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7" }],
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax_cn",
theme: { backgroundColor: "#f64551", textColor: "#FFFFFF" },
icon: "minimax",
@@ -662,7 +661,6 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7" }],
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax_en",
theme: { backgroundColor: "#f64551", textColor: "#FFFFFF" },
icon: "minimax",
@@ -916,8 +914,6 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
],
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "sudocode",
icon: "sudocode",
suggestedDefaults: {
model: { default: "gpt-5.5", provider: "sudocode" },
@@ -1241,25 +1237,6 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
model: { default: "claude-opus-4-8", provider: "eflowcode" },
},
},
{
name: "LemonData",
websiteUrl: "https://lemondata.cc",
apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP",
settingsConfig: {
name: "lemondata",
base_url: "https://api.lemondata.cc/v1",
api_key: "",
api_mode: "chat_completions",
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "lemondata",
icon: "lemondata",
suggestedDefaults: {
model: { default: "gpt-5.5", provider: "lemondata" },
},
},
{
name: "TheRouter",
websiteUrl: "https://therouter.ai",
+8 -48
View File
@@ -147,9 +147,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
name: "火山Agentplan",
websiteUrl:
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
apiKeyUrl:
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
settingsConfig: {
baseUrl: "https://ark.cn-beijing.volces.com/api/coding/v3",
apiKey: "",
@@ -490,7 +490,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
{
name: "Kimi k2.6",
name: "Kimi K2.7 Code",
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
settingsConfig: {
@@ -499,9 +499,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "kimi-k2.6",
name: "Kimi K2.6",
contextWindow: 131072,
id: "kimi-k2.7-code",
name: "Kimi K2.7 Code",
contextWindow: 262144,
cost: { input: 0.002, output: 0.006 },
},
],
@@ -523,8 +523,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "kimi/kimi-k2.6" },
modelCatalog: { "kimi/kimi-k2.6": { alias: "Kimi" } },
model: { primary: "kimi/kimi-k2.7-code" },
modelCatalog: { "kimi/kimi-k2.7-code": { alias: "Kimi" } },
},
},
{
@@ -673,7 +673,6 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
],
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax_cn",
theme: {
backgroundColor: "#f64551",
@@ -711,7 +710,6 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
],
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax_en",
theme: {
backgroundColor: "#f64551",
@@ -1667,8 +1665,6 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
],
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "sudocode",
icon: "sudocode",
templateValues: {
apiKey: {
@@ -2177,42 +2173,6 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
},
{
name: "LemonData",
websiteUrl: "https://lemondata.cc",
apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP",
settingsConfig: {
baseUrl: "https://api.lemondata.cc/v1",
apiKey: "",
api: "openai-completions",
models: [
{
id: "gpt-5.5",
name: "GPT-5.5",
contextWindow: 400000,
},
],
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "lemondata",
icon: "lemondata",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
suggestedDefaults: {
model: {
primary: "lemondata/gpt-5.5",
},
modelCatalog: {
"lemondata/gpt-5.5": { alias: "GPT-5.5" },
},
},
},
// ========== Cloud Providers ==========
{
name: "AWS Bedrock",
+5 -37
View File
@@ -311,9 +311,9 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
{
name: "火山Agentplan",
websiteUrl:
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
apiKeyUrl:
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "火山Agentplan",
@@ -588,19 +588,19 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
{
name: "Kimi k2.6",
name: "Kimi K2.7 Code",
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "Kimi k2.6",
name: "Kimi K2.7 Code",
options: {
baseURL: "https://api.moonshot.cn/v1",
apiKey: "",
setCacheKey: true,
},
models: {
"kimi-k2.6": { name: "Kimi K2.6" },
"kimi-k2.7-code": { name: "Kimi K2.7 Code" },
},
},
category: "cn_official",
@@ -871,7 +871,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax_cn",
theme: {
backgroundColor: "#f64551",
@@ -904,7 +903,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax_en",
theme: {
backgroundColor: "#f64551",
@@ -1374,8 +1372,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "sudocode",
icon: "sudocode",
templateValues: {
apiKey: {
@@ -1714,34 +1710,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
},
{
name: "LemonData",
websiteUrl: "https://lemondata.cc",
apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "LemonData",
options: {
baseURL: "https://api.lemondata.cc/v1",
apiKey: "",
setCacheKey: true,
},
models: {
"gpt-5.5": { name: "GPT-5.5" },
},
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "lemondata",
icon: "lemondata",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "AWS Bedrock",
websiteUrl: "https://aws.amazon.com/bedrock/",
+1 -6
View File
@@ -6,14 +6,13 @@ import React, {
useCallback,
useRef,
} from "react";
import type { UpdateInfo, UpdateHandle } from "../lib/updater";
import type { UpdateInfo } from "../lib/updater";
import { checkForUpdate } from "../lib/updater";
interface UpdateContextValue {
// 更新状态
hasUpdate: boolean;
updateInfo: UpdateInfo | null;
updateHandle: UpdateHandle | null;
isChecking: boolean;
error: string | null;
@@ -34,7 +33,6 @@ export function UpdateProvider({ children }: { children: React.ReactNode }) {
const [hasUpdate, setHasUpdate] = useState(false);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [updateHandle, setUpdateHandle] = useState<UpdateHandle | null>(null);
const [isChecking, setIsChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isDismissed, setIsDismissed] = useState(false);
@@ -72,7 +70,6 @@ export function UpdateProvider({ children }: { children: React.ReactNode }) {
if (result.status === "available") {
setHasUpdate(true);
setUpdateInfo(result.info);
setUpdateHandle(result.update);
// 检查是否已经关闭过这个版本的提醒
let dismissedVersion = localStorage.getItem(DISMISSED_VERSION_KEY);
@@ -89,7 +86,6 @@ export function UpdateProvider({ children }: { children: React.ReactNode }) {
} else {
setHasUpdate(false);
setUpdateInfo(null);
setUpdateHandle(null);
setIsDismissed(false);
return false; // 已是最新
}
@@ -132,7 +128,6 @@ export function UpdateProvider({ children }: { children: React.ReactNode }) {
const value: UpdateContextValue = {
hasUpdate,
updateInfo,
updateHandle,
isChecking,
error,
isDismissed,
+3
View File
@@ -118,6 +118,7 @@ export function useSettingsForm(): UseSettingsFormResult {
skipClaudeOnboarding: data.skipClaudeOnboarding ?? false,
preserveCodexOfficialAuthOnSwitch:
data.preserveCodexOfficialAuthOnSwitch ?? false,
unifyCodexSessionHistory: data.unifyCodexSessionHistory ?? false,
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
codexConfigDir: sanitizeDir(data.codexConfigDir),
geminiConfigDir: sanitizeDir(data.geminiConfigDir),
@@ -143,6 +144,7 @@ export function useSettingsForm(): UseSettingsFormResult {
enableClaudePluginIntegration: false,
skipClaudeOnboarding: false,
preserveCodexOfficialAuthOnSwitch: false,
unifyCodexSessionHistory: false,
language: readPersistedLanguage(),
} as SettingsFormState);
@@ -182,6 +184,7 @@ export function useSettingsForm(): UseSettingsFormResult {
skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? false,
preserveCodexOfficialAuthOnSwitch:
serverData.preserveCodexOfficialAuthOnSwitch ?? false,
unifyCodexSessionHistory: serverData.unifyCodexSessionHistory ?? false,
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
geminiConfigDir: sanitizeDir(serverData.geminiConfigDir),
+27 -73
View File
@@ -6,12 +6,17 @@ import {
type StreamCheckResult,
} from "@/lib/api/model-test";
import type { AppId } from "@/lib/api";
import { useResetCircuitBreaker } from "@/lib/query/failover";
/**
*
*
* base_url HTTP
* ****
* "健康"线 proxy/forwarder.rs
*/
export function useStreamCheck(appId: AppId) {
const { t } = useTranslation();
const [checkingIds, setCheckingIds] = useState<Set<string>>(new Set());
const resetCircuitBreaker = useResetCircuitBreaker();
const checkProvider = useCallback(
async (
@@ -25,89 +30,38 @@ export function useStreamCheck(appId: AppId) {
if (result.status === "operational") {
toast.success(
t("streamCheck.operational", {
t("streamCheck.reachable", {
providerName: providerName,
responseTimeMs: result.responseTimeMs,
defaultValue: `${providerName} 运行正常 (${result.responseTimeMs}ms)`,
defaultValue: `${providerName} 连通正常 (${result.responseTimeMs}ms)`,
}),
{ closeButton: true },
);
// 测试通过后重置熔断器状态
resetCircuitBreaker.mutate({ providerId, appType: appId });
} else if (result.status === "degraded") {
toast.warning(
t("streamCheck.degraded", {
t("streamCheck.reachableSlow", {
providerName: providerName,
responseTimeMs: result.responseTimeMs,
defaultValue: `${providerName} 响应较慢 (${result.responseTimeMs}ms)`,
defaultValue: `${providerName} 连通但较慢 (${result.responseTimeMs}ms)`,
}),
);
// 降级状态也重置熔断器,因为至少能通信
resetCircuitBreaker.mutate({ providerId, appType: appId });
} else if (result.errorCategory === "modelNotFound") {
// 专门处理"模型不存在/已下架":指向配置入口,比通用 404 文案更有指导性
toast.error(
t("streamCheck.modelNotFound", {
providerName: providerName,
model: result.modelUsed,
defaultValue: `${providerName} 测试模型 ${result.modelUsed} 不存在或已下架`,
}),
{
description: t("streamCheck.modelNotFoundHint", {
defaultValue: "",
}),
duration: 10000,
closeButton: true,
},
);
} else if (result.errorCategory === "quotaExceeded") {
toast.warning(
t("streamCheck.quotaExceeded", {
providerName: providerName,
defaultValue: `${providerName} Coding Plan quota has been exceeded`,
}),
{
description: t("streamCheck.quotaExceededHint", {
defaultValue: "",
}),
duration: 10000,
closeButton: true,
},
);
} else {
const httpStatus = result.httpStatus;
const hintKey = httpStatus
? `streamCheck.httpHint.${httpStatus >= 500 ? "5xx" : httpStatus}`
: null;
const description =
(hintKey ? t(hintKey, { defaultValue: "" }) : "") || undefined;
// 401/403/400 = 检查被拒(供应商可能正常);429/5xx = 临时问题
const isProbeRejection =
httpStatus != null &&
([401, 403, 400, 429].includes(httpStatus) || httpStatus >= 500);
if (isProbeRejection) {
toast.warning(
t("streamCheck.rejected", {
providerName: providerName,
message: result.message,
defaultValue: `${providerName} 检查被拒: ${result.message}`,
// 仅当无法建立连接(DNS / 连接被拒 / TLS / 超时)才会到这里
toast.error(
t("streamCheck.unreachable", {
providerName: providerName,
message: result.message,
defaultValue: `${providerName} 无法连通: ${result.message}`,
}),
{
description: t("streamCheck.unreachableHint", {
defaultValue:
"无法建立连接(DNS / 连接 / TLS / 超时)。请检查 base_url 与网络。",
}),
{ description, duration: 8000, closeButton: true },
);
} else {
toast.error(
t("streamCheck.failed", {
providerName: providerName,
message: result.message,
defaultValue: `${providerName} 检查失败: ${result.message}`,
}),
{ description, duration: 8000, closeButton: true },
);
}
duration: 8000,
closeButton: true,
},
);
}
return result;
@@ -128,7 +82,7 @@ export function useStreamCheck(appId: AppId) {
});
}
},
[appId, t, resetCircuitBreaker],
[appId, t],
);
const isChecking = useCallback(
+34 -8
View File
@@ -98,6 +98,7 @@
"windowClose": "Close window"
},
"provider": {
"connectivityCheck": "Connectivity check",
"tabProvider": "Provider",
"tabUniversal": "Universal",
"noProviders": "No providers added yet",
@@ -194,6 +195,7 @@
"routeModelLabel": "Model role",
"routeRoleSonnet": "Sonnet",
"routeRoleOpus": "Opus",
"routeRoleFable": "Fable",
"routeRoleHaiku": "Haiku",
"labelOverrideLabel": "Menu display name",
"upstreamModelLabel": "Requested model",
@@ -279,6 +281,18 @@
"message": "Failover is an advanced feature. Please make sure you understand how it works before enabling.\n\nWe recommend configuring provider priorities in the failover queue first.",
"confirm": "I understand, enable"
},
"unifyCodexHistory": {
"title": "Unified Codex session history",
"message": "When enabled, the official subscription and third-party providers share one session history list. Note: resuming an old session across providers may fail because its encrypted_content reasoning cannot be decrypted by another backend.\n\nYou can also migrate your existing official session history into the shared list (originals are backed up to ~/.cc-switch/backups first and can be restored when you turn this off).",
"migrateExisting": "Also migrate existing official session history",
"confirm": "I understand, enable"
},
"unifyCodexHistoryOff": {
"title": "Turn off unified session history",
"message": "After turning this off, the official subscription and third-party providers return to separate history lists. Sessions created while it was on cannot be attributed to a provider, so they stay in the third-party history and the official subscription will not see them.",
"restoreBackup": "Restore the official sessions migrated at enable time back to the official history (exact restore from backup)",
"confirm": "Turn off"
},
"usage": {
"title": "Configure Usage Query",
"message": "Usage query requires a custom script or API parameters. Please make sure you have obtained the necessary information from your provider.\n\nIf unsure how to configure, please consult your provider's documentation first.",
@@ -670,6 +684,12 @@
"codexAuth": "Codex App Enhancements",
"preserveCodexOfficialAuthOnSwitch": "Keep official login when switching third-party providers",
"preserveCodexOfficialAuthOnSwitchDescription": "When enabled, you can use the Codex app's official plugins, mobile remote control, and other features while using third-party APIs.",
"unifyCodexSessionHistory": "Unified Codex session history",
"unifyCodexSessionHistoryDescription": "When enabled, the official subscription runs under the shared \"custom\" provider id so official and third-party sessions appear in one history list, optionally migrating existing official sessions in (backed up first). When turning it off, the migrated sessions can be restored from backup. Note: resuming an old session across providers may fail because its encrypted_content reasoning can only be decrypted by the backend that created it.",
"unifyCodexHistoryRestoreCompleted": "Official session history restored from backup ({{files}} session files, {{rows}} index rows)",
"unifyCodexHistoryRestoreFailed": "Failed to restore official session history, please try again",
"unifyCodexHistoryRestoreNothing": "No restorable migration backup for the current Codex directory",
"unifyCodexHistoryRestoreSkippedToggleOn": "Unified session history was re-enabled; restore skipped",
"appVisibility": {
"title": "Homepage Display",
"description": "Choose which apps to show on the homepage",
@@ -979,7 +999,6 @@
"apikeyfun": "APIKEY.FUN offers a special deal for CC Switch users. Register through the exclusive link to enjoy up to permanent 5% off top-ups.",
"apinebula": "APINEBULA offers CC Switch users a special discount: register using the link and enter the \"ccswitch\" promo code during your first top-up to get 10% off.",
"atlascloud": "Atlas Cloud is a full-modal AI inference platform that gives developers one API for video generation, image generation, and LLM access. Check out its new Coding Plan promotion for more budget-friendly API access.",
"sudocode": "SudoCode is a global AI model aggregation service. One API key works across multiple models, with presets for Claude Code, Codex, Gemini CLI, OpenClaw, and more.",
"patewayai": "PatewayAI offers special benefits for CC Switch users. Register via this link to receive $3 credit.",
"claudeapi": "ClaudeAPI offers special benefits for CC Switch users. Register via this link to claim test credits.",
"claudecn": "ClaudeCN is an enterprise-grade AI gateway operated by a registered company, supporting enterprise procurement processes with corporate payments, contracts, and compliance guarantees.",
@@ -998,9 +1017,8 @@
"micu": "Micu is an official partner of CC Switch",
"ctok": "Join the CTok community on the official website and subscribe to a plan.",
"shengsuanyun": "Shengsuanyun offers exclusive benefits for CC Switch users. New users who register via this link get ¥10 free credits and 10% bonus on first top-up!",
"lemondata": "Lemon Code offers a special promotion for CC Switch users. Sign up via this link to receive $1 in free credits.",
"doubaoseed": "DouBao offers exclusive benefits for CC Switch users: register via this link to get 500K tokens of inference credits for all DouBao text models and 5M tokens of free Seedance 2.0 credits.",
"volcengine_agentplan": "Volcengine Ark Agent Plan offers exclusive benefits for CC Switch users: subscribe via this link, starting from ¥40/month for new customers.",
"volcengine_agentplan": "Volcengine Ark Coding Plan offers exclusive benefits for CC Switch users: subscribe via this link and new customers get 75% off for the first two months, plus an extra 5% off with the exclusive invite code 6J6FV5N2 — as low as ¥9.4/month!",
"byteplus": "Register via this link to get 500,000 tokens of free inference quota per model.",
"ccsub": "CCSub is a stable, affordable AI API relay — a drop-in replacement for Claude.ai & OpenAI subscriptions, with one key for all models at ~30% of direct API cost.",
"unity2": "Unity2.ai offers exclusive benefits for CC Switch users: register via this link to get $2 in credits, plus another $10 for joining the official group — up to $12 in free credits!"
@@ -1077,6 +1095,7 @@
"modelRoleLabel": "Model role",
"modelRoleSonnet": "Sonnet",
"modelRoleOpus": "Opus",
"modelRoleFable": "Fable",
"modelRoleHaiku": "Haiku",
"modelDisplayNameLabel": "Display name",
"modelDisplayNamePlaceholder": "e.g. DeepSeek V4 Pro",
@@ -1084,7 +1103,7 @@
"modelOneMHeader": "Declare 1M",
"modelOneMLabel": "1M",
"fallbackModelLabel": "Default fallback model",
"fallbackModelHint": "Used only when a Claude Code request does not clearly map to Sonnet, Opus, or Haiku. Usually safe to leave blank.",
"fallbackModelHint": "A fallback for requests that don't clearly map to Sonnet, Opus, Fable, or Haiku. Recommended for third-party/relay endpoints—otherwise such requests (including Haiku background subtasks) are forwarded under their original Claude model name and may fail if the upstream doesn't host it. Safe to leave blank for official endpoints.",
"quickSetModels": "Quick Set",
"quickSetSuccess": "Model name applied to all roles",
"advancedOptionsToggle": "Advanced Options",
@@ -1386,7 +1405,6 @@
"appFilter": {
"all": "All",
"claude": "Claude Code",
"claude-desktop": "Claude Desktop",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode"
@@ -1442,10 +1460,13 @@
"modelIdPlaceholder": "e.g., claude-3-5-sonnet-20241022",
"displayNamePlaceholder": "e.g., Claude 3.5 Sonnet",
"appType": "App Type",
"allApps": "All Apps",
"statusCode": "Status Code",
"searchProviderPlaceholder": "Search provider...",
"searchModelPlaceholder": "Search model...",
"allSources": "All Sources",
"allModels": "All Models",
"filterBySource": "Filter by source",
"filterByModel": "Filter by model",
"refreshInterval": "Auto-refresh interval",
"refreshOff": "Off",
"timeRange": "Time Range",
"customRange": "Calendar Filter",
"customRangeHint": "Supports both date and time",
@@ -2495,6 +2516,11 @@
"stopWithRestoreFailed": "Stop failed: {{detail}}"
},
"streamCheck": {
"reachable": "{{providerName}} is reachable ({{responseTimeMs}}ms)",
"reachableSlow": "{{providerName}} reachable but slow ({{responseTimeMs}}ms)",
"unreachable": "{{providerName}} unreachable: {{message}}",
"unreachableHint": "Could not establish a connection (DNS / connect / TLS / timeout). Check the base_url and your network.",
"connectivityNote": "Connectivity check only probes whether the provider address is reachable; it does not send a real model request. Any response counts as \"reachable\" — which does not guarantee that auth or model settings are correct.",
"configSaved": "Health check config saved",
"configSaveFailed": "Save failed",
"testModels": "Test Models",
+34 -8
View File
@@ -98,6 +98,7 @@
"windowClose": "ウィンドウを閉じる"
},
"provider": {
"connectivityCheck": "接続チェック",
"tabProvider": "プロバイダー",
"tabUniversal": "統一プロバイダー",
"noProviders": "まだプロバイダーがありません",
@@ -194,6 +195,7 @@
"routeModelLabel": "モデル役割",
"routeRoleSonnet": "Sonnet",
"routeRoleOpus": "Opus",
"routeRoleFable": "Fable",
"routeRoleHaiku": "Haiku",
"labelOverrideLabel": "メニュー表示名",
"upstreamModelLabel": "リクエストモデル",
@@ -279,6 +281,18 @@
"message": "フェイルオーバーは上級機能です。有効にする前に、その仕組みを理解していることをご確認ください。\n\nフェイルオーバーキューでプロバイダーの優先順位を先に設定することをお勧めします。",
"confirm": "理解しました、有効にする"
},
"unifyCodexHistory": {
"title": "Codex セッション履歴を統一",
"message": "オンにすると、公式サブスクリプションとサードパーティが同じセッション履歴リストを共有します。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content を相手のバックエンドが復号できず失敗する場合があります。\n\n既存の公式セッション履歴を共有リストへ移行することもできます(移行前に ~/.cc-switch/backups へ自動バックアップされ、オフにする際に復元を選択できます)。",
"migrateExisting": "既存の公式セッション履歴も移行する",
"confirm": "理解しました、オンにする"
},
"unifyCodexHistoryOff": {
"title": "セッション履歴の統一をオフにする",
"message": "オフにすると、公式サブスクリプションとサードパーティはそれぞれ独立した履歴リストに戻ります。オン期間中に作成されたセッションは提供元を判別できないため、サードパーティの履歴に残り、公式サブスクリプションからは見えなくなります。",
"restoreBackup": "オンにした際に移行した公式セッションを公式履歴へ復元する(バックアップから正確に復元)",
"confirm": "オフにする"
},
"usage": {
"title": "使用量クエリの設定",
"message": "使用量クエリにはカスタムスクリプトまたは API パラメータが必要です。プロバイダーから必要な情報を取得していることをご確認ください。\n\n設定方法が不明な場合は、プロバイダーのドキュメントを先にご確認ください。",
@@ -670,6 +684,12 @@
"codexAuth": "Codex アプリ拡張",
"preserveCodexOfficialAuthOnSwitch": "サードパーティ切替時に公式ログインを保持",
"preserveCodexOfficialAuthOnSwitchDescription": "オンにすると、サードパーティ API を使用する際に Codex アプリの公式プラグインやスマホからのリモート操作などの機能を利用できます。",
"unifyCodexSessionHistory": "Codex セッション履歴を統一",
"unifyCodexSessionHistoryDescription": "オンにすると、公式サブスクリプションも共有の custom プロバイダー ID で動作し、公式とサードパーティのセッションが同じ履歴リストに表示されます。既存の公式セッションの移行も選択できます(移行前に自動バックアップ)。オフにする際はバックアップから復元できます。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content の推論内容を相手のバックエンドが復号できず、再開に失敗する場合があります。",
"unifyCodexHistoryRestoreCompleted": "バックアップから公式セッション履歴を復元しました(セッションファイル {{files}} 件、インデックス {{rows}} 行)",
"unifyCodexHistoryRestoreFailed": "公式セッション履歴の復元に失敗しました。もう一度お試しください",
"unifyCodexHistoryRestoreNothing": "現在の Codex ディレクトリに復元可能な移行バックアップはありません",
"unifyCodexHistoryRestoreSkippedToggleOn": "統一セッション履歴が再度有効化されたため、復元をスキップしました",
"appVisibility": {
"title": "ホームページ表示",
"description": "ホームページに表示するアプリを選択",
@@ -979,7 +999,6 @@
"apikeyfun": "APIKEY.FUN は CC Switch ユーザー向けに特別優待を提供しています。専用リンクから登録すると、最大でチャージ永久 5% オフを受けられます。",
"apinebula": "APINEBULA は CC Switch ユーザー向けに特別割引を提供しています。専用リンクから登録し、チャージ時にプロモコード「ccswitch」を入力すると、さらに 10% OFF の割引が適用されます。",
"atlascloud": "Atlas Cloud は、1 つの API で動画・画像生成や LLM を利用できる全モーダル対応の AI 推論プラットフォームです。より低コストで API を利用できる新しい「コーディングプラン」プロモーションをご確認ください。",
"sudocode": "SudoCode は、世界水準の AI モデル集約サービスです。1 つの API Key で複数モデルを利用でき、Claude Code、Codex、Gemini CLI、OpenClaw などのツールに対応しています。",
"patewayai": "PatewayAI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録すると $3 のクレジットがもらえます。",
"claudeapi": "ClaudeAPI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録するとテストクレジットを受け取ることができます。",
"claudecn": "ClaudeCN は登録企業が運営するエンタープライズグレードの AI ゲートウェイプラットフォームで、企業調達プロセスをサポートし、法人支払い、契約、コンプライアンス保証を提供します。",
@@ -998,9 +1017,8 @@
"micu": "Micu は CC Switch の公式パートナーです",
"ctok": "公式サイトで CTok コミュニティに参加し、プランを購読してください。",
"shengsuanyun": "胜算云はCC Switchユーザーに特別特典を提供しています。このリンクから登録すると、10元分の無料クレジットと初回チャージ10%ボーナスがもらえます!",
"lemondata": "Lemon Code は CC Switch ユーザー向けの特別オファーを提供しています。このリンクから登録すると 1 ドル分の無料クレジットがもらえます。",
"doubaoseed": "豆包大モデルは CC Switch ユーザーに専属特典を提供しています:このリンクから登録すると、豆包全テキストモデル50万トークンの推論クレジットおよびSeedance2.0の500万トークン無料クレジットがもらえます。",
"volcengine_agentplan": "火山方舟 Agent Plan は CC Switch ユーザーに専属特典を提供しています:このリンクから方舟AgentPlanを購読すると、新規のお客様は初月40元から利用できます。",
"volcengine_agentplan": "火山方舟 Coding Plan は CC Switch ユーザーに専属特典を提供しています:このリンクから方舟 Coding Plan を購読すると、新規のお客様は最初の 2 か月が 75% オフ、さらに専用招待コード 6J6FV5N2 で 5% オフが加算され、月額 9.4 元から!",
"byteplus": "このリンクから登録すると、各モデルごとに50万トークンの無料推論クォータがもらえます。",
"ccsub": "CCSub は安定・低価格の AI API リレーサービスです。Claude Code の公式サブスクリプションの代替として、1つのキーで全モデルを公式比約1/3のコストで利用できます。",
"unity2": "Unity2.ai は CC Switch ユーザーに専属特典を提供しています:このリンクから登録すると $2 分のクレジット、公式グループ参加でさらに $10、最大 $12 の無料クレジットがもらえます!"
@@ -1077,6 +1095,7 @@
"modelRoleLabel": "モデル役割",
"modelRoleSonnet": "Sonnet",
"modelRoleOpus": "Opus",
"modelRoleFable": "Fable",
"modelRoleHaiku": "Haiku",
"modelDisplayNameLabel": "表示名",
"modelDisplayNamePlaceholder": "例: DeepSeek V4 Pro",
@@ -1084,7 +1103,7 @@
"modelOneMHeader": "1M 対応を宣言",
"modelOneMLabel": "1M",
"fallbackModelLabel": "既定フォールバックモデル",
"fallbackModelHint": "Claude Code のリクエストが SonnetOpusHaiku のいずれにも明確に対応しない場合のみ使われます。通常は空欄で構いません。",
"fallbackModelHint": "SonnetOpus・Fable・Haiku のいずれにも明確に対応しないリクエストのフォールバックです。サードパーティ/中継エンドポイントでは設定を推奨します。設定しないと、これらのリクエスト(Haiku のバックグラウンドサブタスクを含む)が元の Claude モデル名のまま上流へ転送され、上流に該当モデルが無い場合は失敗することがあります。公式エンドポイントでは空欄で構いません。",
"quickSetModels": "一括設定",
"quickSetSuccess": "モデル名をすべての役割に適用しました",
"advancedOptionsToggle": "高級オプション",
@@ -1386,7 +1405,6 @@
"appFilter": {
"all": "すべて",
"claude": "Claude Code",
"claude-desktop": "Claude Desktop",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode"
@@ -1442,10 +1460,13 @@
"modelIdPlaceholder": "例: claude-3-5-sonnet-20241022",
"displayNamePlaceholder": "例: Claude 3.5 Sonnet",
"appType": "アプリ種別",
"allApps": "すべてのアプリ",
"statusCode": "ステータスコード",
"searchProviderPlaceholder": "プロバイダーを検索...",
"searchModelPlaceholder": "モデルを検索...",
"allSources": "すべてのソース",
"allModels": "すべてのモデル",
"filterBySource": "ソースで絞り込む",
"filterByModel": "モデルで絞り込む",
"refreshInterval": "自動更新間隔",
"refreshOff": "オフ",
"timeRange": "期間",
"customRange": "カレンダーフィルター",
"customRangeHint": "日付と時刻の両方に対応",
@@ -2495,6 +2516,11 @@
"stopWithRestoreFailed": "停止に失敗しました: {{detail}}"
},
"streamCheck": {
"reachable": "{{providerName}} は接続可能 ({{responseTimeMs}}ms)",
"reachableSlow": "{{providerName}} は接続可能だが低速 ({{responseTimeMs}}ms)",
"unreachable": "{{providerName}} に接続できません: {{message}}",
"unreachableHint": "接続を確立できませんでした(DNS / 接続 / TLS / タイムアウト)。base_url とネットワークを確認してください。",
"connectivityNote": "接続チェックはプロバイダーのアドレスに到達できるかを確認するだけで、実際のモデルリクエストは送信しません。何らかの応答があれば「到達可能」とみなされますが、認証やモデル設定が正しいことを保証するものではありません。",
"configSaved": "ヘルスチェック設定を保存しました",
"configSaveFailed": "保存に失敗しました",
"testModels": "テストモデル",
+34 -8
View File
@@ -98,6 +98,7 @@
"windowClose": "關閉視窗"
},
"provider": {
"connectivityCheck": "檢測連通",
"tabProvider": "供應商",
"tabUniversal": "通用供應商",
"noProviders": "尚未新增任何供應商",
@@ -194,6 +195,7 @@
"routeModelLabel": "模型角色",
"routeRoleSonnet": "Sonnet",
"routeRoleOpus": "Opus",
"routeRoleFable": "Fable",
"routeRoleHaiku": "Haiku",
"labelOverrideLabel": "選單顯示名稱",
"upstreamModelLabel": "實際請求模型",
@@ -279,6 +281,18 @@
"message": "故障轉移是一項進階功能,啟用前請確保您已了解其運作原理。\n\n建議先在故障轉移佇列中設定好供應商優先順序。",
"confirm": "我已了解,繼續啟用"
},
"unifyCodexHistory": {
"title": "統一 Codex 會話歷史",
"message": "開啟後,官方訂閱與第三方將共用同一個會話歷史清單。注意:跨供應商繼續舊會話時,可能因對方後端無法解密 encrypted_content 推理內容而失敗。\n\n可選擇同時把現有官方會話歷史遷入共享清單(遷移前自動備份到 ~/.cc-switch/backups,關閉開關時可選擇恢復)。",
"migrateExisting": "同時遷入現有官方會話歷史",
"confirm": "我已了解,繼續開啟"
},
"unifyCodexHistoryOff": {
"title": "關閉統一會話歷史",
"message": "關閉後,官方訂閱與第三方將恢復各自獨立的會話歷史清單。開啟期間產生的會話因無法區分來源,將留在第三方歷史中,官方訂閱將看不到它們。",
"restoreBackup": "把開啟時遷入的官方會話還原回官方歷史(按備份精確還原)",
"confirm": "關閉"
},
"usage": {
"title": "設定用量查詢",
"message": "用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\n\n如不確定如何設定,請先查閱供應商文件。",
@@ -670,6 +684,12 @@
"codexAuth": "Codex 應用增強",
"preserveCodexOfficialAuthOnSwitch": "切換第三方時保留官方登入",
"preserveCodexOfficialAuthOnSwitchDescription": "開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能",
"unifyCodexSessionHistory": "統一 Codex 會話歷史",
"unifyCodexSessionHistoryDescription": "開啟後,官方訂閱將以共享的 custom 供應商標識執行,官方與第三方會話出現在同一歷史清單中,並可選擇把現有官方會話一併遷入(遷移前自動備份)。關閉開關時可按備份恢復遷入的會話。注意:跨供應商繼續舊會話時,對方後端可能無法解密會話中的 encrypted_content 推理內容,導致繼續失敗",
"unifyCodexHistoryRestoreCompleted": "已按備份還原官方會話歷史({{files}} 個會話檔案、{{rows}} 條索引記錄)",
"unifyCodexHistoryRestoreFailed": "還原官方會話歷史失敗,請重試",
"unifyCodexHistoryRestoreNothing": "目前 Codex 目錄沒有可恢復的遷移備份",
"unifyCodexHistoryRestoreSkippedToggleOn": "統一會話歷史開關已重新開啟,已跳過還原",
"appVisibility": {
"title": "主頁面顯示",
"description": "選擇在主頁面顯示的應用程式",
@@ -950,7 +970,6 @@
"apikeyfun": "APIKEY.FUN 為 CC Switch 的使用者提供了特別優惠,透過專屬連結註冊,可享受最高儲值永久 95 折優惠。",
"apinebula": "APINEBULA 為 CC Switch 使用者提供特別優惠:使用專屬連結註冊並在儲值時填寫「ccswitch」優惠碼,可享 9 折優惠。",
"atlascloud": "Atlas Cloud 是一個全模態 AI 推理平台,透過單一 API 為開發者提供影片生成、圖像生成及 LLM 接入。立即查看全新「編程計畫」優惠,取得更具性價比的 API 接入。",
"sudocode": "SudoCode 是全球一流 AI 模型聚合服務,一個 API Key 可通用多模型,並支援 Claude Code、Codex、Gemini CLI、OpenClaw 等工具接入。",
"patewayai": "PatewayAI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊可以獲得 3 美元額度。",
"claudeapi": "ClaudeAPI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊可以領取測試額度。",
"claudecn": "ClaudeCN 是一家實體企業營運的企業級 AI 中繼平台,支援企業採購流程,可對公打款、簽約,服務合規有保障。",
@@ -970,9 +989,8 @@
"micu": "Micu 是 CC Switch 的官方合作夥伴",
"ctok": "官網加入 CTok 社群,訂閱方案。",
"shengsuanyun": "勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!",
"lemondata": "Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。",
"doubaoseed": "豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度",
"volcengine_agentplan": "火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。",
"volcengine_agentplan": "火山方舟 Coding Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 Coding Plan,新客戶首兩個月享 2.5 折優惠,再用專屬邀請碼 6J6FV5N2 領取獎勵疊加 9.5 折,低至 9.4 元/月!",
"byteplus": "透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。",
"ccsub": "CCSub 是穩定、實惠的 AI API 中轉平台,Claude Code 官方訂閱的超強平替,一個 Key 覆蓋全部模型,價格約為官方 1/3。",
"unity2": "Unity2.ai 為 CC Switch 使用者提供專屬福利:透過此連結註冊可領取 $2 餘額,加入官方群再送 $10,最高可領 $12 免費額度!"
@@ -1049,6 +1067,7 @@
"modelRoleLabel": "模型角色",
"modelRoleSonnet": "Sonnet",
"modelRoleOpus": "Opus",
"modelRoleFable": "Fable",
"modelRoleHaiku": "Haiku",
"modelDisplayNameLabel": "顯示名稱",
"modelDisplayNamePlaceholder": "例如 DeepSeek V4 Pro",
@@ -1056,7 +1075,7 @@
"modelOneMHeader": "聲明支援 1M",
"modelOneMLabel": "1M",
"fallbackModelLabel": "備用模型",
"fallbackModelHint": "僅在 Claude Code 請求沒有明確落到 Sonnet、OpusHaiku 角色時使用;通常可以留空。",
"fallbackModelHint": "用於未明確落到 Sonnet、Opus、Fable、Haiku 角色的請求。使用第三方/中轉端點時建議填寫:否則這些請求(含 Haiku 背景子任務)會以原始 Claude 模型名透傳給上游,可能因上游無此模型而報錯。官方端點可留空。",
"quickSetModels": "一鍵設定",
"quickSetSuccess": "已將模型名稱套用至所有角色",
"advancedOptionsToggle": "進階選項",
@@ -1358,7 +1377,6 @@
"appFilter": {
"all": "全部",
"claude": "Claude Code",
"claude-desktop": "Claude Desktop",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode"
@@ -1414,10 +1432,13 @@
"modelIdPlaceholder": "例如: claude-3-5-sonnet-20241022",
"displayNamePlaceholder": "例如: Claude 3.5 Sonnet",
"appType": "應用程式類型",
"allApps": "全部應用程式",
"statusCode": "狀態碼",
"searchProviderPlaceholder": "搜尋供應商...",
"searchModelPlaceholder": "搜尋模型...",
"allSources": "全部來源",
"allModels": "全部模型",
"filterBySource": "依來源篩選",
"filterByModel": "依模型篩選",
"refreshInterval": "自動重新整理間隔",
"refreshOff": "關閉",
"timeRange": "時間範圍",
"customRange": "日曆篩選",
"customRangeHint": "支援日期與時間",
@@ -2467,6 +2488,11 @@
"stopWithRestoreFailed": "停止失敗:{{detail}}"
},
"streamCheck": {
"reachable": "{{providerName}} 連通正常 ({{responseTimeMs}}ms)",
"reachableSlow": "{{providerName}} 連通但較慢 ({{responseTimeMs}}ms)",
"unreachable": "{{providerName}} 無法連通: {{message}}",
"unreachableHint": "無法建立連線(DNS / 連線 / TLS / 逾時)。請檢查 base_url 與網路。",
"connectivityNote": "連通檢測僅探測供應商位址是否可達,不發送真實模型請求。收到任意回應即視為「可達」——這不代表驗證或模型設定一定正確。",
"configSaved": "健康檢測設定已儲存",
"configSaveFailed": "儲存失敗",
"testModels": "測試模型",
+34 -8
View File
@@ -98,6 +98,7 @@
"windowClose": "关闭窗口"
},
"provider": {
"connectivityCheck": "检测连通",
"tabProvider": "供应商",
"tabUniversal": "统一供应商",
"noProviders": "还没有添加任何供应商",
@@ -194,6 +195,7 @@
"routeModelLabel": "模型角色",
"routeRoleSonnet": "Sonnet",
"routeRoleOpus": "Opus",
"routeRoleFable": "Fable",
"routeRoleHaiku": "Haiku",
"labelOverrideLabel": "菜单显示名",
"upstreamModelLabel": "实际请求模型",
@@ -279,6 +281,18 @@
"message": "故障转移是一项高级功能,启用前请确保您已了解其工作原理。\n\n建议先在故障转移队列中配置好供应商优先级。",
"confirm": "我已了解,继续启用"
},
"unifyCodexHistory": {
"title": "统一 Codex 会话历史",
"message": "开启后,官方订阅与第三方将共用同一个会话历史列表。注意:跨供应商继续旧会话时,可能因对方后端无法解密 encrypted_content 推理内容而失败。\n\n可选择同时把现有官方会话历史迁入共享列表(迁移前自动备份到 ~/.cc-switch/backups,关闭开关时可选择恢复)。",
"migrateExisting": "同时迁入现有官方会话历史",
"confirm": "我已了解,继续开启"
},
"unifyCodexHistoryOff": {
"title": "关闭统一会话历史",
"message": "关闭后,官方订阅与第三方将恢复各自独立的会话历史列表。开启期间产生的会话因无法区分来源,将留在第三方历史中,官方订阅将看不到它们。",
"restoreBackup": "把开启时迁入的官方会话还原回官方历史(按备份精确还原)",
"confirm": "关闭"
},
"usage": {
"title": "配置用量查询",
"message": "用量查询需要配置专用的查询脚本或 API 参数,请确保您已从供应商处获取相关信息。\n\n如不确定如何配置,请先查阅供应商文档。",
@@ -670,6 +684,12 @@
"codexAuth": "Codex 应用增强",
"preserveCodexOfficialAuthOnSwitch": "切换第三方时保留官方登录",
"preserveCodexOfficialAuthOnSwitchDescription": "开启后可以在使用第三方 API 的时候使用 Codex 应用的官方插件、手机远程操作等功能",
"unifyCodexSessionHistory": "统一 Codex 会话历史",
"unifyCodexSessionHistoryDescription": "开启后,官方订阅将以共享的 custom 供应商标识运行,官方与第三方会话出现在同一历史列表中,并可选择把现有官方会话一并迁入(迁移前自动备份)。关闭开关时可按备份恢复迁入的会话。注意:跨供应商继续旧会话时,对方后端可能无法解密会话中的 encrypted_content 推理内容,导致继续失败",
"unifyCodexHistoryRestoreCompleted": "已按备份还原官方会话历史({{files}} 个会话文件、{{rows}} 条索引记录)",
"unifyCodexHistoryRestoreFailed": "还原官方会话历史失败,请重试",
"unifyCodexHistoryRestoreNothing": "当前 Codex 目录没有可恢复的迁移备份",
"unifyCodexHistoryRestoreSkippedToggleOn": "统一会话历史开关已重新开启,已跳过还原",
"appVisibility": {
"title": "主页面显示",
"description": "选择在主页面显示的应用",
@@ -979,7 +999,6 @@
"apikeyfun": "APIKEY.FUN 为 CC Switch 的用户提供了特别优惠,通过专属链接注册,可享受最高充值永久 95 折优惠。",
"apinebula": "APINEBULA 为 CC Switch 用户提供特别优惠:使用专属链接注册并在充值时填写 \"ccswitch\" 优惠码,可享九折优惠。",
"atlascloud": "Atlas Cloud 是一个全模态 AI 推理平台,通过单一 API 为开发者提供视频生成、图像生成及 LLM 接入。立即查看全新“编程计划”优惠,获取更具性价比的 API 接入。",
"sudocode": "SudoCode 是全球一流 AI 模型聚合服务,一个 API Key 可通用多模型,并支持 Claude Code、Codex、Gemini CLI、OpenClaw 等工具接入。",
"patewayai": "PatewayAI 为 CC Switch 的用户提供了特别福利,通过此链接注册可以获得3美元额度。",
"claudeapi": "ClaudeAPI 为 CC Switch 的用户提供了特别福利,通过此链接注册可以领取测试额度。",
"claudecn": "ClaudeCN 是一家实体企业运营的企业级AI中转平台,支持企业采购流程,可对公打款、签约,服务合规有保障。",
@@ -998,9 +1017,8 @@
"micu": "Micu 是 CC Switch 的官方合作伙伴",
"ctok": "官网加入CTok社群,订阅套餐。",
"shengsuanyun": "胜算云为 CC Switch 的用户提供了特别福利,使用此链接注册的新用户可获 10 元模力及首充 10% 赠送!",
"lemondata": "Lemon Code 为 CC Switch 的用户提供了特别优惠。使用此链接注册可以获得1美元免费额度。",
"doubaoseed": "豆包大模型为 CC Switch 的用户提供了专属福利:通过此链接注册即可获取豆包全系列文本模型 50万tokens推理额度以及Seedance2.0的500万tokens免费额度",
"volcengine_agentplan": "火山方舟 Agent Plan 为 CC Switch 的用户提供了专属福利:通过此链接订阅方舟AgentPlan,新客户首月40元起。",
"volcengine_agentplan": "火山方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过此链接订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!",
"byteplus": "通过此链接注册即可获取每个模型50万tokens的免费推理额度。",
"ccsub": "CCSub 是稳定、实惠的 AI API 中转平台,Claude Code 官方订阅的超强平替,一个 Key 覆盖全部模型,价格约为官方 1/3。",
"unity2": "Unity2.ai 为 CC Switch 用户提供专属福利:通过此链接注册可领取 $2 余额,加入官方群再送 $10,最高可领 $12 免费额度!"
@@ -1077,6 +1095,7 @@
"modelRoleLabel": "模型角色",
"modelRoleSonnet": "Sonnet",
"modelRoleOpus": "Opus",
"modelRoleFable": "Fable",
"modelRoleHaiku": "Haiku",
"modelDisplayNameLabel": "显示名称",
"modelDisplayNamePlaceholder": "例如 DeepSeek V4 Pro",
@@ -1084,7 +1103,7 @@
"modelOneMHeader": "声明支持 1M",
"modelOneMLabel": "1M",
"fallbackModelLabel": "默认兜底模型",
"fallbackModelHint": "仅在 Claude Code 请求没有明确落到 Sonnet、OpusHaiku 角色时使用;通常可以留空。",
"fallbackModelHint": "用于未明确落到 Sonnet、Opus、Fable、Haiku 角色的请求。使用第三方/中转端点时建议填写:否则这些请求(含 Haiku 后台子任务)会以原始 Claude 模型名透传给上游,可能因上游无此模型而报错。官方端点可留空。",
"quickSetModels": "一键设置",
"quickSetSuccess": "已将模型名称应用到所有角色",
"advancedOptionsToggle": "高级选项",
@@ -1386,7 +1405,6 @@
"appFilter": {
"all": "全部",
"claude": "Claude Code",
"claude-desktop": "Claude Desktop",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode"
@@ -1442,10 +1460,13 @@
"modelIdPlaceholder": "例如: claude-3-5-sonnet-20241022",
"displayNamePlaceholder": "例如: Claude 3.5 Sonnet",
"appType": "应用类型",
"allApps": "全部应用",
"statusCode": "状态码",
"searchProviderPlaceholder": "搜索供应商...",
"searchModelPlaceholder": "搜索模型...",
"allSources": "全部来源",
"allModels": "全部模型",
"filterBySource": "按来源筛选",
"filterByModel": "按模型筛选",
"refreshInterval": "自动刷新间隔",
"refreshOff": "关闭",
"timeRange": "时间范围",
"customRange": "日历筛选",
"customRangeHint": "支持日期与时间",
@@ -2495,6 +2516,11 @@
"stopWithRestoreFailed": "停止失败: {{detail}}"
},
"streamCheck": {
"reachable": "{{providerName}} 连通正常 ({{responseTimeMs}}ms)",
"reachableSlow": "{{providerName}} 连通但较慢 ({{responseTimeMs}}ms)",
"unreachable": "{{providerName}} 无法连通: {{message}}",
"unreachableHint": "无法建立连接(DNS / 连接 / TLS / 超时)。请检查 base_url 与网络。",
"connectivityNote": "连通检测仅探测供应商地址是否可达,不发送真实模型请求。收到任意响应即视为“可达”——这不代表鉴权或模型配置一定正确。",
"configSaved": "健康检查配置已保存",
"configSaveFailed": "保存失败",
"testModels": "测试模型",
-2
View File
@@ -12,7 +12,6 @@ import _cherryin from "./cherryin.png";
import _eflowcode from "./eflowcode.png";
import _hermes from "./hermes.png";
import _huoshan from "./huoshan.png";
import _lemondata from "./lemondata.png";
import _pateway from "./pateway.jpg";
import _pipellm from "./pipellm.png";
import _relaxcode from "./relaxcode.png";
@@ -106,7 +105,6 @@ export const iconUrls: Record<string, string> = {
eflowcode: _eflowcode,
hermes: _hermes,
huoshan: _huoshan,
lemondata: _lemondata,
pateway: _pateway,
pipellm: _pipellm,
relaxcode: _relaxcode,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

-7
View File
@@ -384,13 +384,6 @@ export const iconMetadata: Record<string, IconMetadata> = {
keywords: ["hermes", "agent", "nous", "nousresearch"],
defaultColor: "#000000",
},
lemondata: {
name: "lemondata",
displayName: "LemonData",
category: "ai-provider",
keywords: ["lemondata", "lemon", "lemoncode"],
defaultColor: "#F5C518",
},
packycode: {
name: "packycode",
displayName: "PackyCode",
+7 -10
View File
@@ -1,18 +1,18 @@
import { invoke } from "@tauri-apps/api/core";
import type { AppId } from "./types";
// ===== 流式健康检查类型 =====
// ===== 连通性检查类型 =====
// 注意:本检查只探测 base_url 是否可达,不发真实大模型请求,也不触碰故障转移熔断器。
export type HealthStatus = "operational" | "degraded" | "failed";
export interface StreamCheckConfig {
/** 单次探测超时(秒) */
timeoutSecs: number;
/** 超时类失败的最大重试次数 */
maxRetries: number;
/** 降级阈值(毫秒):可达但 TTFB 超过该值判定为"较慢" */
degradedThresholdMs: number;
claudeModel: string;
codexModel: string;
geminiModel: string;
testPrompt: string;
}
export interface StreamCheckResult {
@@ -21,17 +21,14 @@ export interface StreamCheckResult {
message: string;
responseTimeMs?: number;
httpStatus?: number;
modelUsed: string;
testedAt: number;
retryCount: number;
/** 细粒度错误分类,如 "modelNotFound" */
errorCategory?: string;
}
// ===== 流式健康检查 API =====
// ===== 连通性检查 API =====
/**
*
*
*/
export async function streamCheckProvider(
appType: AppId,
+21
View File
@@ -19,6 +19,13 @@ export interface WebDavTestResult {
message?: string;
}
export interface CodexUnifyHistoryRestoreResult {
restoredJsonlFiles: number;
restoredStateRows: number;
/** 还原被跳过的原因(如当前目录没有账本);存在时不应报成功 */
skippedReason?: string;
}
export interface WebDavSyncResult {
status: string;
}
@@ -32,10 +39,24 @@ export const settingsApi = {
return await invoke("save_settings", { settings });
},
/** 是否存在统一 Codex 会话历史的迁移备份(关闭弹窗据此显示"恢复备份"勾选) */
async hasCodexUnifyHistoryBackup(): Promise<boolean> {
return await invoke("has_codex_unify_history_backup");
},
/** 按迁移备份账本把当时迁入共享桶的官方会话还原回 openai 桶(幂等) */
async restoreCodexUnifiedHistory(): Promise<CodexUnifyHistoryRestoreResult> {
return await invoke("restore_codex_unified_history");
},
async restart(): Promise<boolean> {
return await invoke("restart_app");
},
async installUpdateAndRestart(): Promise<boolean> {
return await invoke("install_update_and_restart");
},
async checkUpdates(): Promise<void> {
await invoke("check_for_updates");
},
+44 -5
View File
@@ -52,39 +52,78 @@ export const usageApi = {
startDate?: number,
endDate?: number,
appType?: string,
providerName?: string,
model?: string,
): Promise<UsageSummary> => {
return invoke("get_usage_summary", { startDate, endDate, appType });
return invoke("get_usage_summary", {
startDate,
endDate,
appType,
providerName,
model,
});
},
getUsageSummaryByApp: async (
startDate?: number,
endDate?: number,
providerName?: string,
model?: string,
): Promise<UsageSummaryByApp[]> => {
return invoke("get_usage_summary_by_app", { startDate, endDate });
return invoke("get_usage_summary_by_app", {
startDate,
endDate,
providerName,
model,
});
},
getUsageTrends: async (
startDate?: number,
endDate?: number,
appType?: string,
providerName?: string,
model?: string,
): Promise<DailyStats[]> => {
return invoke("get_usage_trends", { startDate, endDate, appType });
return invoke("get_usage_trends", {
startDate,
endDate,
appType,
providerName,
model,
});
},
getProviderStats: async (
startDate?: number,
endDate?: number,
appType?: string,
providerName?: string,
model?: string,
): Promise<ProviderStats[]> => {
return invoke("get_provider_stats", { startDate, endDate, appType });
return invoke("get_provider_stats", {
startDate,
endDate,
appType,
providerName,
model,
});
},
getModelStats: async (
startDate?: number,
endDate?: number,
appType?: string,
providerName?: string,
model?: string,
): Promise<ModelStats[]> => {
return invoke("get_model_stats", { startDate, endDate, appType });
return invoke("get_model_stats", {
startDate,
endDate,
appType,
providerName,
model,
});
},
getRequestLogs: async (
+126 -2
View File
@@ -1,3 +1,4 @@
import { useRef } from "react";
import {
useQuery,
type UseQueryResult,
@@ -99,6 +100,112 @@ export interface UseUsageQueryOptions {
autoQueryInterval?: number; // 自动查询间隔(分钟),0 表示禁用
}
/** 最近一次成功的用量结果快照(keep-last-good 用)。 */
export interface LastGoodUsage {
data: UsageResult;
at: number; // 该成功结果的获取时刻(ms
}
/** 在最近一次成功后多久内,失败仍继续展示该成功值。 */
export const KEEP_LAST_GOOD_MS = 10 * 60 * 1000; // 10 分钟
/**
* "瞬时/网络类" keep-last-good
*
* **** API Key
* 4xx//
*
*
* **** + HTTP 5xx
* "非瞬时"
* - balance/coding_plan/subscriptionreqwest send / `"Network error: …"`
* 2xx `"API error (HTTP <code>…)"`
* - JS usage_script`request_failed` "请求失败/Request failed"
* `read_response_failed` "读取响应失败/Failed to read response" zh/en
* 2xx `"HTTP <code> …"`
*
* HTTP **5xx** transient**4xx**/
* 401/403/404/429
*/
export function isTransientUsageError(result: UsageResult): boolean {
if (result.success) return false;
const e = result.error?.toLowerCase() ?? "";
if (!e) return false;
// 网络类(send 失败/超时/读取响应失败)
if (
e.includes("network error") || // 原生路径
e.includes("request failed") || // JS 脚本 (en)
e.includes("请求失败") || // JS 脚本 (zh)
e.includes("failed to read response") || // JS 脚本 (en)
e.includes("读取响应失败") // JS 脚本 (zh)
) {
return true;
}
// HTTP 状态码:5xx 视为瞬时,4xx 视为确定性。错误文案里第一处 "HTTP <code>" 即为
// 上游状态码(原生 "API error (HTTP 500…)"、JS 脚本 "HTTP 500 …")。
const httpMatch = e.match(/http\s+(\d{3})/);
if (httpMatch) {
const status = Number(httpMatch[1]);
return status >= 500 && status <= 599;
}
return false;
}
/**
* Keep-last-good ref`now` 便
*
* / `Ok(success:false)` "新数据"
* 使"一会成功一会失败"
* **/** [`isTransientUsageError`]
* `keepMs` `lastQueriedAt`
* "10 分钟前"
*
* ****/ key//4xx ** `lastGood`**
* "配置/鉴权已失效"
*
* reject Copilot/DBreact-query `data`
* `Ok(success:false)`
*/
export function resolveDisplayUsage(
raw: UsageResult | undefined,
dataUpdatedAt: number,
prevLastGood: LastGoodUsage | null,
now: number,
keepMs: number = KEEP_LAST_GOOD_MS,
): {
data: UsageResult | undefined;
lastQueriedAt: number | null;
lastGood: LastGoodUsage | null;
} {
let lastGood = prevLastGood;
if (raw?.success) {
// 成功:刷新快照
lastGood = { data: raw, at: dataUpdatedAt || now };
} else if (raw && !isTransientUsageError(raw)) {
// 确定性失败(鉴权/空 key/未知供应商/4xx 等):旧成功快照已不可信,丢弃它,
// 避免后续一次网络抖动把"配置/鉴权已失效"的旧额度重新复活。
lastGood = null;
}
let data = raw;
let lastQueriedAt = dataUpdatedAt || null;
if (
raw &&
!raw.success &&
isTransientUsageError(raw) && // 仅瞬时/网络类失败才掩盖;确定性失败立即透出
lastGood &&
now - lastGood.at < keepMs
) {
data = lastGood.data;
lastQueriedAt = lastGood.at;
}
return { data, lastQueriedAt, lastGood };
}
export const useUsageQuery = (
providerId: string,
appId: AppId,
@@ -123,14 +230,31 @@ export const useUsageQuery = (
: false,
refetchIntervalInBackground: true, // 后台也继续定时查询
refetchOnWindowFocus: false,
retry: false,
// 用量查询面向跨境/第三方端点,单次网络抖动或瞬时 5xx 不应直接判失败。
// 重试一次以吸收瞬时故障(与 useSubscriptionQuota 的 retry:1 保持一致)。
// 注意:原生 balance/coding_plan 路径把网络错误折叠成 Ok(success:false)
// 这类不会触发 react-query 重试;本项主要覆盖会 reject 的传输层失败(Copilot/DB 等)。
retry: 1,
retryDelay: 1500,
staleTime, // 使用动态计算的缓存时间
gcTime: 10 * 60 * 1000, // 缓存保留 10 分钟(组件卸载后)
});
// Keep-last-good:失败时在 10 分钟窗口内继续展示上一次成功值(见 resolveDisplayUsage)。
// 每个 hook 实例各持一份 ref(按卡片维度);ref 写入是幂等的(同份成功重复写无副作用)。
const lastGoodRef = useRef<LastGoodUsage | null>(null);
const { data, lastQueriedAt, lastGood } = resolveDisplayUsage(
query.data,
query.dataUpdatedAt,
lastGoodRef.current,
Date.now(),
);
lastGoodRef.current = lastGood;
return {
...query,
lastQueriedAt: query.dataUpdatedAt || null,
data,
lastQueriedAt,
};
};
+81 -26
View File
@@ -1,7 +1,11 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { usageApi } from "@/lib/api/usage";
import { resolveUsageRange } from "@/lib/usageRange";
import type { LogFilters, UsageRangeSelection } from "@/types/usage";
import type {
LogFilters,
UsageRangeSelection,
UsageScopeFilters,
} from "@/types/usage";
const DEFAULT_REFETCH_INTERVAL_MS = 30000;
@@ -35,7 +39,7 @@ export const usageKeys = {
preset: UsageRangeSelection["preset"],
customStartDate: number | undefined,
customEndDate: number | undefined,
appType?: string,
filters?: UsageScopeFilters,
) =>
[
...usageKeys.all,
@@ -43,12 +47,15 @@ export const usageKeys = {
preset,
customStartDate ?? 0,
customEndDate ?? 0,
appType ?? "all",
filters?.appType ?? null,
filters?.providerName ?? null,
filters?.model ?? null,
] as const,
summaryByApp: (
preset: UsageRangeSelection["preset"],
customStartDate: number | undefined,
customEndDate: number | undefined,
filters?: UsageScopeFilters,
) =>
[
...usageKeys.all,
@@ -56,12 +63,14 @@ export const usageKeys = {
preset,
customStartDate ?? 0,
customEndDate ?? 0,
filters?.providerName ?? null,
filters?.model ?? null,
] as const,
trends: (
preset: UsageRangeSelection["preset"],
customStartDate: number | undefined,
customEndDate: number | undefined,
appType?: string,
filters?: UsageScopeFilters,
) =>
[
...usageKeys.all,
@@ -69,13 +78,15 @@ export const usageKeys = {
preset,
customStartDate ?? 0,
customEndDate ?? 0,
appType ?? "all",
filters?.appType ?? null,
filters?.providerName ?? null,
filters?.model ?? null,
] as const,
providerStats: (
preset: UsageRangeSelection["preset"],
customStartDate: number | undefined,
customEndDate: number | undefined,
appType?: string,
filters?: UsageScopeFilters,
) =>
[
...usageKeys.all,
@@ -83,13 +94,15 @@ export const usageKeys = {
preset,
customStartDate ?? 0,
customEndDate ?? 0,
appType ?? "all",
filters?.appType ?? null,
filters?.providerName ?? null,
filters?.model ?? null,
] as const,
modelStats: (
preset: UsageRangeSelection["preset"],
customStartDate: number | undefined,
customEndDate: number | undefined,
appType?: string,
filters?: UsageScopeFilters,
) =>
[
...usageKeys.all,
@@ -97,7 +110,9 @@ export const usageKeys = {
preset,
customStartDate ?? 0,
customEndDate ?? 0,
appType ?? "all",
filters?.appType ?? null,
filters?.providerName ?? null,
filters?.model ?? null,
] as const,
logs: (key: RequestLogsKey, page: number, pageSize: number) =>
[
@@ -122,23 +137,38 @@ export const usageKeys = {
[...usageKeys.all, providerId, appType] as const,
};
/** 把 UI 侧的 "all" 哨兵归一成 undefined(后端语义:不过滤)。 */
function normalizeScopeFilters(filters?: UsageScopeFilters): UsageScopeFilters {
return {
appType: filters?.appType === "all" ? undefined : filters?.appType,
providerName: filters?.providerName,
model: filters?.model,
};
}
// Hooks
export function useUsageSummary(
range: UsageRangeSelection,
appType?: string,
filters?: UsageScopeFilters,
options?: UsageQueryOptions,
) {
const effectiveAppType = appType === "all" ? undefined : appType;
const effective = normalizeScopeFilters(filters);
return useQuery({
queryKey: usageKeys.summary(
range.preset,
range.customStartDate,
range.customEndDate,
appType,
effective,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
return usageApi.getUsageSummary(startDate, endDate, effectiveAppType);
return usageApi.getUsageSummary(
startDate,
endDate,
effective.appType,
effective.providerName,
effective.model,
);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
@@ -147,6 +177,7 @@ export function useUsageSummary(
export function useUsageSummaryByApp(
range: UsageRangeSelection,
filters?: Pick<UsageScopeFilters, "providerName" | "model">,
options?: UsageQueryOptions,
) {
return useQuery({
@@ -154,10 +185,16 @@ export function useUsageSummaryByApp(
range.preset,
range.customStartDate,
range.customEndDate,
filters,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
return usageApi.getUsageSummaryByApp(startDate, endDate);
return usageApi.getUsageSummaryByApp(
startDate,
endDate,
filters?.providerName,
filters?.model,
);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
@@ -166,20 +203,26 @@ export function useUsageSummaryByApp(
export function useUsageTrends(
range: UsageRangeSelection,
appType?: string,
filters?: UsageScopeFilters,
options?: UsageQueryOptions,
) {
const effectiveAppType = appType === "all" ? undefined : appType;
const effective = normalizeScopeFilters(filters);
return useQuery({
queryKey: usageKeys.trends(
range.preset,
range.customStartDate,
range.customEndDate,
appType,
effective,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
return usageApi.getUsageTrends(startDate, endDate, effectiveAppType);
return usageApi.getUsageTrends(
startDate,
endDate,
effective.appType,
effective.providerName,
effective.model,
);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
@@ -188,20 +231,26 @@ export function useUsageTrends(
export function useProviderStats(
range: UsageRangeSelection,
appType?: string,
filters?: UsageScopeFilters,
options?: UsageQueryOptions,
) {
const effectiveAppType = appType === "all" ? undefined : appType;
const effective = normalizeScopeFilters(filters);
return useQuery({
queryKey: usageKeys.providerStats(
range.preset,
range.customStartDate,
range.customEndDate,
appType,
effective,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
return usageApi.getProviderStats(startDate, endDate, effectiveAppType);
return usageApi.getProviderStats(
startDate,
endDate,
effective.appType,
effective.providerName,
effective.model,
);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
@@ -210,20 +259,26 @@ export function useProviderStats(
export function useModelStats(
range: UsageRangeSelection,
appType?: string,
filters?: UsageScopeFilters,
options?: UsageQueryOptions,
) {
const effectiveAppType = appType === "all" ? undefined : appType;
const effective = normalizeScopeFilters(filters);
return useQuery({
queryKey: usageKeys.modelStats(
range.preset,
range.customStartDate,
range.customEndDate,
appType,
effective,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
return usageApi.getModelStats(startDate, endDate, effectiveAppType);
return usageApi.getModelStats(
startDate,
endDate,
effective.appType,
effective.providerName,
effective.model,
);
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
+2 -1
View File
@@ -16,6 +16,7 @@ export const settingsSchema = z.object({
launchOnStartup: z.boolean().optional(),
enableLocalProxy: z.boolean().optional(),
preserveCodexOfficialAuthOnSwitch: z.boolean().optional(),
unifyCodexSessionHistory: z.boolean().optional(),
language: z.enum(["en", "zh", "zh-TW", "ja"]).optional(),
// 设备级目录覆盖
@@ -58,7 +59,7 @@ export const settingsSchema = z.object({
})
.optional(),
// 本机自动迁移状态(后端维护,前端保存设置时应透传
// 本机自动迁移状态(后端维护且保存时后端忽略前端值,仅供读取展示
localMigrations: z
.object({
codexThirdPartyHistoryProviderBucketV1: z
+5 -83
View File
@@ -1,22 +1,7 @@
import { getVersion } from "@tauri-apps/api/app";
// 可选导入:在未注册插件或非 Tauri 环境下,调用时会抛错,外层需做兜底
// 我们按需加载并在运行时捕获错误,避免构建期类型问题
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { Update } from "@tauri-apps/plugin-updater";
export type UpdateChannel = "stable" | "beta";
export type UpdaterPhase =
| "idle"
| "checking"
| "available"
| "downloading"
| "installing"
| "restarting"
| "upToDate"
| "error";
export interface UpdateInfo {
currentVersion: string;
availableVersion: string;
@@ -24,64 +9,11 @@ export interface UpdateInfo {
pubDate?: string;
}
export interface UpdateProgressEvent {
event: "Started" | "Progress" | "Finished";
total?: number;
downloaded?: number;
}
export interface UpdateHandle {
version: string;
notes?: string;
date?: string;
downloadAndInstall: (
onProgress?: (e: UpdateProgressEvent) => void,
) => Promise<void>;
download?: () => Promise<void>;
install?: () => Promise<void>;
}
export interface CheckOptions {
timeout?: number;
channel?: UpdateChannel;
}
function mapUpdateHandle(raw: Update): UpdateHandle {
return {
version: (raw as any).version ?? "",
notes: (raw as any).notes,
date: (raw as any).date,
async downloadAndInstall(onProgress?: (e: UpdateProgressEvent) => void) {
await (raw as any).downloadAndInstall((evt: any) => {
if (!onProgress) return;
const mapped: UpdateProgressEvent = {
event: evt?.event,
};
if (evt?.event === "Started") {
mapped.total = evt?.data?.contentLength ?? 0;
mapped.downloaded = 0;
} else if (evt?.event === "Progress") {
mapped.downloaded = evt?.data?.chunkLength ?? 0; // 累积由调用方完成
}
onProgress(mapped);
});
},
// 透传可选 API(若插件版本支持)
download: (raw as any).download
? async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await (raw as any).download();
}
: undefined,
install: (raw as any).install
? async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await (raw as any).install();
}
: undefined,
};
}
export async function getCurrentVersion(): Promise<string> {
try {
return await getVersion();
@@ -93,8 +25,7 @@ export async function getCurrentVersion(): Promise<string> {
export async function checkForUpdate(
opts: CheckOptions = {},
): Promise<
| { status: "up-to-date" }
| { status: "available"; info: UpdateInfo; update: UpdateHandle }
{ status: "up-to-date" } | { status: "available"; info: UpdateInfo }
> {
// 动态引入,避免在未安装插件时导致打包期问题
const { check } = await import("@tauri-apps/plugin-updater");
@@ -106,21 +37,12 @@ export async function checkForUpdate(
return { status: "up-to-date" };
}
const mapped = mapUpdateHandle(update);
const info: UpdateInfo = {
currentVersion,
availableVersion: mapped.version,
notes: mapped.notes,
pubDate: mapped.date,
availableVersion: (update as any).version ?? "",
notes: (update as any).notes,
pubDate: (update as any).date,
};
return { status: "available", info, update: mapped };
return { status: "available", info };
}
export async function relaunchApp(): Promise<void> {
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
}
// 旧的聚合更新流程已由调用方直接使用 updateHandle 取代
// 如需单函数封装,可在需要时基于 checkForUpdate + updateHandle 复合调用
+6 -5
View File
@@ -107,16 +107,12 @@ export interface UsageResult {
error?: string;
}
// 供应商单独的模型测试配置
// 供应商单独的连通检测配置(覆盖全局配置)
export interface ProviderTestConfig {
// 是否启用单独配置(false 时使用全局配置)
enabled: boolean;
// 测试用的模型名称(覆盖全局配置)
testModel?: string;
// 超时时间(秒)
timeoutSecs?: number;
// 测试提示词
testPrompt?: string;
// 降级阈值(毫秒)
degradedThresholdMs?: number;
// 最大重试次数
@@ -351,6 +347,11 @@ export interface Settings {
enableFailoverToggle?: boolean;
// Preserve Codex ChatGPT login in auth.json when switching third-party providers
preserveCodexOfficialAuthOnSwitch?: boolean;
// Run official Codex under the shared "custom" provider id so future
// sessions share one resume-history bucket with third-party providers
unifyCodexSessionHistory?: boolean;
// User opted in (enable dialog checkbox) to migrate existing official sessions
unifyCodexMigrateExisting?: boolean;
// User has confirmed the failover toggle first-run notice
failoverConfirmed?: boolean;
// User has confirmed the first-run welcome notice
+25 -13
View File
@@ -122,6 +122,20 @@ export interface LogFilters {
endDate?: number;
}
/**
* Dashboard Hero / / Tab
*
* - `providerName` Provider
* "Claude (Session)"
* - `model` pricing_model model
*
*/
export interface UsageScopeFilters {
appType?: string;
providerName?: string;
model?: string;
}
export interface ProviderLimitStatus {
providerId: string;
dailyUsage: string;
@@ -141,28 +155,26 @@ export interface UsageRangeSelection {
}
/**
* App types whose token usage is reliably collected by the proxy.
* App types surfaced as dashboard filter buttons.
*
* `claude-desktop` was previously hidden because its rows looked like pure
* failure noise that was an accounting bug: streaming/transform usage of
* the Desktop gateway was logged under app_type "claude", leaving only
* edge-case rows under "claude-desktop". The backend now attributes all
* Desktop traffic to "claude-desktop", so it is a first-class filter option.
* `claude-desktop` is intentionally NOT listed: the Desktop gateway's proxy
* traffic is still recorded under its own `app_type` (preserving route-takeover
* billing audit the request detail panel shows the real value), but the
* dashboard folds it into `claude` for display. It is the embedded Claude Code
* runtime running inside the Desktop shell, and Desktop *chat* usage never
* passes through this app at all, so a separate "Claude Desktop" bucket would
* only ever show a partial number and mislead users into reading it as the
* Desktop's full usage. The backend collapses `claude-desktop → claude` in
* every dashboard query (see `folded_app_type_sql`).
* `opencode` / `openclaw` / `hermes` have no proxy handler at all they
* appear only as managed apps elsewhere.
*/
export type AppType =
| "claude"
| "claude-desktop"
| "codex"
| "gemini"
| "opencode";
export type AppType = "claude" | "codex" | "gemini" | "opencode";
export type AppTypeFilter = "all" | AppType;
export const KNOWN_APP_TYPES: ReadonlyArray<AppType> = [
"claude",
"claude-desktop",
"codex",
"gemini",
"opencode",
@@ -49,7 +49,7 @@ describe("ClaudeDesktopProviderForm", () => {
},
});
// 固定档(Sonnet / Opus / Haiku)下有个菜单显示名输入,取 Sonnet(首个)。
// 固定档(Sonnet / Opus / Fable / Haiku)下有个菜单显示名输入,取 Sonnet(首个)。
const input = screen.getAllByPlaceholderText(
"DeepSeek V4 Pro",
)[0] as HTMLInputElement;
@@ -97,7 +97,7 @@ describe("ClaudeDesktopProviderForm", () => {
expect(document.activeElement).toBe(currentInput);
});
it("代理模式始终渲染 Sonnet / Opus / Haiku 档(即使只配了一档)", () => {
it("代理模式始终渲染 Sonnet / Opus / Fable / Haiku 档(即使只配了一档)", () => {
renderForm({
name: "Proxy Provider",
settingsConfig: {
@@ -114,13 +114,17 @@ describe("ClaudeDesktopProviderForm", () => {
},
});
// 固定档:每档各一个「菜单显示名」输入框,无论初始只配了几档。
expect(screen.getAllByPlaceholderText("DeepSeek V4 Pro")).toHaveLength(3);
// 固定档:每档各一个「菜单显示名」输入框,无论初始只配了几档。
// Haiku 档的占位示例是 "DeepSeek V4 Flash"、其余三档是 "DeepSeek V4 Pro"
// (见组件的 role-consistent 占位逻辑),故用正则同时匹配两种占位、数满四档。
expect(
screen.getAllByPlaceholderText(/DeepSeek V4 (Pro|Flash)/),
).toHaveLength(4);
});
it("代理模式初始无路由且默认路由未就绪时不渲染空档", () => {
it("代理模式初始无路由且默认路由未就绪时不渲染空档", () => {
// mock 的 getClaudeDesktopDefaultRoutes 返回 [],模拟默认路由尚未就绪。
// 修复前:normalizeProxyRows([]) 会渲染 3 条空行并把 routes.length 撑到 3
// 修复前:normalizeProxyRows([]) 会渲染空行并把 routes.length 撑起来
// 永久挡住 seed effect 的默认路由回填。修复后应保持空、等待 seed。
renderForm({
name: "Proxy Provider",
@@ -139,7 +143,7 @@ describe("ClaudeDesktopProviderForm", () => {
expect(screen.queryAllByPlaceholderText("DeepSeek V4 Pro")).toHaveLength(0);
});
it("保存模型映射时补齐固定档并把留空档回填为 Sonnet 模型", async () => {
it("保存模型映射时补齐固定档并把留空档回填为 Sonnet 模型", async () => {
const onSubmit = vi.fn();
renderForm(
{
@@ -166,19 +170,25 @@ describe("ClaudeDesktopProviderForm", () => {
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
const submitted = onSubmit.mock.calls[0][0];
// claude-old 迁移到 Sonnet;留空的 Opus / Haiku 回填为 Sonnet 的上游模型,
// 保证落库档齐全,子 agent 调用的 Haiku 始终可解析。
// claude-old 迁移到 Sonnet;留空的 Opus / Fable / Haiku 回填为 Sonnet 的
// 上游模型,保证落库档齐全,子 agent 调用的各档始终可解析。
expect(submitted.meta.claudeDesktopModelRoutes).toMatchObject({
"claude-sonnet-4-6": {
model: "upstream-old",
labelOverride: "upstream-old",
},
"claude-opus-4-8": { model: "upstream-old" },
"claude-fable-5": { model: "upstream-old" },
"claude-haiku-4-5": { model: "upstream-old" },
});
expect(
Object.keys(submitted.meta.claudeDesktopModelRoutes).sort(),
).toEqual(["claude-haiku-4-5", "claude-opus-4-8", "claude-sonnet-4-6"]);
expect(Object.keys(submitted.meta.claudeDesktopModelRoutes).sort()).toEqual(
[
"claude-fable-5",
"claude-haiku-4-5",
"claude-opus-4-8",
"claude-sonnet-4-6",
],
);
});
it("回填空档时继承 Sonnet 的 1M 声明", async () => {
@@ -83,6 +83,8 @@ const renderCopilotForm = (overrides: Partial<ClaudeFormFieldsProps> = {}) => {
defaultSonnetModelName: "Claude Sonnet",
defaultOpusModel: "",
defaultOpusModelName: "",
defaultFableModel: "",
defaultFableModelName: "",
onModelChange: vi.fn(),
speedTestEndpoints: [],
apiFormat: "anthropic",
+159 -35
View File
@@ -15,6 +15,29 @@ import {
type PresetSortMode,
} from "@/components/providers/forms/ProviderPresetSelector";
// Mock ProviderIcon 以避免依赖图标库的实际内容
vi.mock("@/components/ProviderIcon", () => ({
ProviderIcon: ({
icon,
name,
color,
size,
}: {
icon?: string;
name: string;
color?: string;
size?: number;
}) => (
<span
data-testid="provider-icon"
data-icon={icon}
data-name={name}
data-color={color}
data-size={size}
/>
),
}));
const presetCategoryLabels = {
official: "官方",
cn_official: "国产官方",
@@ -153,52 +176,28 @@ describe("ProviderPresetSelector pure helpers", () => {
);
});
it("拼接显示名原始名称、URL、分类 label,并统一 lower-case", () => {
const searchText = getPresetSearchText(
presetEntries[1],
presetCategoryLabels,
t,
);
it("拼接显示名原始名称、统一 lower-case,不含 URL 或分类 label", () => {
const searchText = getPresetSearchText(presetEntries[1], t);
expect(searchText).toContain("alpha 本地名");
expect(searchText).toContain("alpha raw");
expect(searchText).toContain("https://alpha.example.com/v1");
expect(searchText).toContain("官方");
expect(searchText).not.toContain("example.com");
expect(searchText).not.toContain("官方");
expect(searchText).toBe(searchText.toLowerCase());
});
it("空 query 返回原数组,非空 query 大小写不敏感匹配", () => {
expect(filterPresetEntries(presetEntries, " ", t)).toBe(presetEntries);
expect(
filterPresetEntries(presetEntries, " ", presetCategoryLabels, t),
).toBe(presetEntries);
expect(
getIds(
filterPresetEntries(
presetEntries,
"ALPHA 本地名",
presetCategoryLabels,
t,
),
),
getIds(filterPresetEntries(presetEntries, "ALPHA 本地名", t)),
).toEqual(["alpha"]);
});
it("支持通过 URL 分类 label 搜索", () => {
it("不再通过 URL 分类 label 搜索(仅匹配名称)", () => {
expect(
getIds(
filterPresetEntries(
presetEntries,
"cn-gateway.example.com",
presetCategoryLabels,
t,
),
),
).toEqual(["beta"]);
expect(
getIds(
filterPresetEntries(presetEntries, "聚合", presetCategoryLabels, t),
),
).toEqual(["gamma"]);
getIds(filterPresetEntries(presetEntries, "cn-gateway.example.com", t)),
).toEqual([]);
expect(getIds(filterPresetEntries(presetEntries, "聚合", t))).toEqual([]);
});
it("支持 A-Z 排序、original 副本恢复原顺序,并且 getVisible 先 filter 再 sort", () => {
@@ -222,7 +221,6 @@ describe("ProviderPresetSelector pure helpers", () => {
getVisiblePresetEntries(presetEntries, {
query: "a",
sortMode: nameAscMode,
presetCategoryLabels,
t,
}),
),
@@ -320,4 +318,130 @@ describe("ProviderPresetSelector", () => {
),
).toBeInTheDocument();
});
it("所有预设按钮填满网格列宽(w-full)实现等宽对齐", () => {
renderSelector();
const presetButtons = screen.getAllByRole("button");
const fullWidthButtons = presetButtons.filter((btn) =>
btn.className.includes("w-full"),
);
// 至少包含 custom + 4 个预设 = 5 个等宽按钮(搜索/排序按钮为 size-8 不计入)
expect(fullWidthButtons.length).toBeGreaterThanOrEqual(5);
});
it("preset.icon 存在时按钮内渲染图标元素(img/svg)", () => {
const entriesWithIcon = [
{
id: "with-icon",
preset: {
name: "With Icon",
websiteUrl: "https://icon.example.com",
settingsConfig: {},
category: "official" as ProviderCategory,
icon: "claude-api",
iconColor: "#D4915D",
},
},
];
renderSelector({ entries: entriesWithIcon });
const button = screen.getByRole("button", { name: /with icon/i });
const icon = button.querySelector('[data-testid="provider-icon"]');
expect(icon).not.toBeNull();
expect(icon?.getAttribute("data-icon")).toBe("claude-api");
expect(icon?.getAttribute("data-color")).toBe("#D4915D");
});
it("preset 无 icon 且无 theme.icon 时,按钮内仍渲染占位元素保持文字对齐", () => {
const entriesWithoutIcon = [
{
id: "no-icon",
preset: {
name: "No Icon",
websiteUrl: "https://noicon.example.com",
settingsConfig: {},
category: "official" as ProviderCategory,
},
},
];
renderSelector({ entries: entriesWithoutIcon });
const button = screen.getByRole("button", { name: /no icon/i });
// 占位 span(16x16)应该存在,保证文字位置与有图标的按钮对齐
const placeholder = button.querySelector("span[aria-hidden]");
expect(placeholder).not.toBeNull();
});
it("custom 按钮同样渲染占位元素,文字与带图标的预设按钮对齐", () => {
renderSelector();
const customButton = screen.getByRole("button", {
name: "providerPreset.custom",
});
const placeholder = customButton.querySelector("span[aria-hidden]");
expect(placeholder).not.toBeNull();
});
it("点击放大镜 inline 切换搜索输入框可见性,ESC 收起并清空", async () => {
const user = userEvent.setup();
renderSelector();
// 初始没有搜索输入框
expect(
screen.queryByRole("textbox", {
name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i,
}),
).not.toBeInTheDocument();
// 点击放大镜展开输入框
await user.click(getSearchButton());
const input = getSearchInput();
expect(input).toBeInTheDocument();
// 输入关键字过滤
await user.type(input, "gateway");
expect(
screen.getByRole("button", { name: "Beta Gateway" }),
).toBeInTheDocument();
// ESC 收起输入框并清空
await user.keyboard("{Escape}");
expect(
screen.queryByRole("textbox", {
name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i,
}),
).not.toBeInTheDocument();
// 收起后所有预设恢复显示
expect(
screen.getByRole("button", { name: "preset.gamma" }),
).toBeInTheDocument();
});
it("点击搜索区域外自动收起并清空", async () => {
const user = userEvent.setup();
renderSelector();
await user.click(getSearchButton());
await user.type(getSearchInput(), "gateway");
expect(getSearchInput()).toBeInTheDocument();
// 点击搜索区域外的元素(custom 按钮)应收起搜索框
await user.click(
screen.getByRole("button", { name: "providerPreset.custom" }),
);
expect(
screen.queryByRole("textbox", {
name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i,
}),
).not.toBeInTheDocument();
// 收起后清空 query,所有预设恢复显示
expect(
screen.getByRole("button", { name: "preset.gamma" }),
).toBeInTheDocument();
});
});
@@ -76,7 +76,7 @@ const expectedChatPresets = new Map<
"Kimi",
{
baseUrl: "https://api.moonshot.cn/v1",
contextWindows: { "kimi-k2.6": 262144 },
contextWindows: { "kimi-k2.7-code": 262144 },
},
],
[
+176
View File
@@ -0,0 +1,176 @@
import { describe, it, expect } from "vitest";
import {
resolveDisplayUsage,
isTransientUsageError,
KEEP_LAST_GOOD_MS,
type LastGoodUsage,
} from "@/lib/query/queries";
import type { UsageResult } from "@/types";
// keep-last-good 的纯决策逻辑:仅"瞬时/网络类"失败才在 KEEP_LAST_GOOD_MS 窗口内继续
// 展示上一次成功;确定性失败(鉴权/空 key/未知供应商等)必须立即透出。
const ok = (remaining: number): UsageResult => ({
success: true,
data: [{ remaining, unit: "USD" }],
});
// 默认用网络类错误(瞬时),需要确定性失败时显式传入。
const fail = (error = "Network error: connection reset"): UsageResult => ({
success: false,
error,
});
const T0 = 1_000_000_000_000; // 任意基准时刻(ms
describe("isTransientUsageError", () => {
it("网络类失败 → 瞬时(true", () => {
expect(isTransientUsageError(fail("Network error: timed out"))).toBe(true);
expect(isTransientUsageError(fail("Request failed: timed out"))).toBe(true);
expect(isTransientUsageError(fail("请求失败: 连接超时"))).toBe(true);
expect(isTransientUsageError(fail("Failed to read response: eof"))).toBe(
true,
);
expect(isTransientUsageError(fail("读取响应失败: eof"))).toBe(true);
});
it("确定性失败 → 非瞬时(false),必须立即透出", () => {
expect(
isTransientUsageError(fail("Authentication failed (HTTP 401)")),
).toBe(false);
expect(isTransientUsageError(fail("API key is empty"))).toBe(false);
expect(isTransientUsageError(fail("Unknown balance provider"))).toBe(false);
expect(isTransientUsageError(fail("Unknown coding plan provider"))).toBe(
false,
);
expect(isTransientUsageError(fail("API error (HTTP 400): bad"))).toBe(
false,
);
expect(isTransientUsageError(fail("Failed to parse response: x"))).toBe(
false,
);
});
it("HTTP 5xx → 瞬时(true);4xx → 非瞬时(false", () => {
expect(isTransientUsageError(fail("API error (HTTP 500): oops"))).toBe(
true,
);
expect(
isTransientUsageError(fail("HTTP 503 Service Unavailable : x")),
).toBe(true);
expect(
isTransientUsageError(fail("API error (HTTP 502): bad gateway")),
).toBe(true);
expect(
isTransientUsageError(fail("API error (HTTP 429): rate limited")),
).toBe(false);
expect(
isTransientUsageError(fail("Authentication failed (HTTP 403)")),
).toBe(false);
});
it("成功 / 无错误信息 → false", () => {
expect(isTransientUsageError(ok(1))).toBe(false);
expect(isTransientUsageError({ success: false })).toBe(false);
});
});
describe("resolveDisplayUsage (keep-last-good)", () => {
it("成功结果:原样展示并记录为 lastGoodlastQueriedAt=获取时刻", () => {
const success = ok(42);
const r = resolveDisplayUsage(success, T0, null, T0);
expect(r.data).toBe(success);
expect(r.lastQueriedAt).toBe(T0);
expect(r.lastGood).toEqual({ data: success, at: T0 });
});
it("瞬时失败 + 窗口内有上次成功:继续展示成功值,lastQueriedAt 指向成功时刻", () => {
const prev: LastGoodUsage = { data: ok(42), at: T0 };
const now = T0 + KEEP_LAST_GOOD_MS - 1; // 刚好仍在窗口内
const r = resolveDisplayUsage(fail(), now, prev, now);
expect(r.data).toBe(prev.data); // 展示的是上次成功
expect(r.lastQueriedAt).toBe(T0); // 时间戳反映成功的年龄
expect(r.lastGood).toBe(prev); // 失败不更新 lastGood
});
it("瞬时失败 + 上次成功已过期(>= 窗口):展示失败本身", () => {
const prev: LastGoodUsage = { data: ok(42), at: T0 };
const now = T0 + KEEP_LAST_GOOD_MS; // 边界:恰好到 10 分钟即过期
const failure = fail();
const r = resolveDisplayUsage(failure, now, prev, now);
expect(r.data).toBe(failure);
expect(r.lastQueriedAt).toBe(now);
expect(r.lastGood).toBe(prev);
});
it("确定性失败(鉴权/空 key/未知供应商):即使窗口内有上次成功也立即透出,并清空 lastGood", () => {
const prev: LastGoodUsage = { data: ok(42), at: T0 };
const now = T0 + 1000; // 远在窗口内
for (const failure of [
fail("Authentication failed (HTTP 401)"),
fail("API key is empty"),
fail("Unknown coding plan provider"),
]) {
const r = resolveDisplayUsage(failure, now, prev, now);
expect(r.data).toBe(failure); // 不掩盖 → 透出确定性失败
expect(r.lastQueriedAt).toBe(now);
expect(r.lastGood).toBeNull(); // 旧快照已不可信 → 清空,防止后续被复活
}
});
it("确定性失败清空 lastGood:随后的网络抖动不会复活旧成功", () => {
// 成功 → 记录 lastGood
const afterSuccess = resolveDisplayUsage(ok(42), T0, null, T0);
expect(afterSuccess.lastGood).not.toBeNull();
// 401 鉴权失败 → 透出失败并清空 lastGood
const afterAuthFail = resolveDisplayUsage(
fail("Authentication failed (HTTP 401)"),
T0 + 1000,
afterSuccess.lastGood,
T0 + 1000,
);
expect(afterAuthFail.lastGood).toBeNull();
// 随后一次网络抖动(瞬时)→ lastGood 已空 → 不复活旧成功,照常透出失败
const netFail = fail();
const afterBlip = resolveDisplayUsage(
netFail,
T0 + 2000,
afterAuthFail.lastGood,
T0 + 2000,
);
expect(afterBlip.data).toBe(netFail);
expect(afterBlip.lastGood).toBeNull();
});
it("瞬时失败 + 从无成功记录:展示失败本身", () => {
const failure = fail();
const now = T0 + 5000;
const r = resolveDisplayUsage(failure, now, null, now);
expect(r.data).toBe(failure);
expect(r.lastQueriedAt).toBe(now);
expect(r.lastGood).toBeNull();
});
it("新的成功覆盖旧的 lastGood", () => {
const prev: LastGoodUsage = { data: ok(42), at: T0 };
const fresh = ok(7);
const now = T0 + 60_000;
const r = resolveDisplayUsage(fresh, now, prev, now);
expect(r.data).toBe(fresh);
expect(r.lastGood).toEqual({ data: fresh, at: now });
});
it("加载中(raw=undefined):data 为 undefinedlastGood 不变", () => {
const prev: LastGoodUsage = { data: ok(42), at: T0 };
const r = resolveDisplayUsage(undefined, 0, prev, T0 + 1000);
expect(r.data).toBeUndefined();
expect(r.lastQueriedAt).toBeNull();
expect(r.lastGood).toBe(prev);
});
it("dataUpdatedAt 为 0 的成功:用注入的 now 作为获取时刻", () => {
const success = ok(1);
const now = T0 + 123;
const r = resolveDisplayUsage(success, 0, null, now);
expect(r.lastGood).toEqual({ data: success, at: now });
});
});