Compare commits

..

59 Commits

Author SHA1 Message Date
YoVinchen 8ffaa2ce01 Let Kaku users launch sessions from their chosen terminal (#1954)
Kaku is a WezTerm-derived macOS terminal, so reusing the existing WezTerm-compatible launch path keeps the change small while making it selectable in settings and session resume flows.

Constraint: Kaku support should stay macOS-only and avoid introducing a separate launcher model
Rejected: Treat Kaku as a silent WezTerm fallback | users could not explicitly choose it in settings
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep Kaku on the shared WezTerm-compatible launch path unless upstream drops the start-compatible CLI
Tested: pnpm typecheck; pnpm format:check; cargo check --manifest-path src-tauri/Cargo.toml; cargo fmt --manifest-path src-tauri/Cargo.toml --check; cargo test --manifest-path src-tauri/Cargo.toml --lib session_manager::terminal::tests
Not-tested: End-to-end launch against a locally installed Kaku.app
Related: #1954
2026-04-10 11:31:43 +08:00
Jason 489c7c75ea style: apply cargo fmt to schema migration code 2026-04-10 11:23:08 +08:00
Jason b85e449949 fix: guard migrations against missing tables and fix highlighted text assertion
- Make migrate_v6_to_v7 check skills table existence before ALTER
- Make migrate_v7_to_v8 check model_pricing table existence before UPDATE
- Fix SessionManagerPage test: use getByRole heading instead of getAllByText
  which breaks when highlightText splits text across <mark> elements
2026-04-10 11:20:47 +08:00
Jason c4458cf280 fix: update tests for InstalledSkill new fields and missing hook mocks
- Add content_hash and updated_at fields to 4 InstalledSkill literals in skill_sync.rs
- Add useCheckSkillUpdates and useUpdateSkill to UnifiedSkillsPanel test mock
- Suppress unused import warning in auto_launch.rs test module
2026-04-10 11:08:09 +08:00
Jason cc1530f997 docs: add working directory feature implementation plan
Design document for per-directory state switching (providers, MCP,
skills, prompts). Not yet implemented - saved for future reference.
2026-04-10 08:33:13 +08:00
Jason f9fb9085ac feat(session-manager): highlight search keywords in session titles and messages 2026-04-10 08:30:37 +08:00
Jason 308314baac fix(session-manager): improve session search accuracy and Chinese support
- Pre-filter sessions by provider before indexing to prevent result
  truncation when FlexSearch limit cuts across providers
- Switch tokenizer from "forward" to "full" for Chinese substring matching
- Preserve FlexSearch relevance ranking when search query is present
2026-04-10 00:14:50 +08:00
Jason 5ac6fb5315 feat(session-manager): extract meaningful titles for Claude sessions
Instead of showing directory basenames for all Claude sessions, extract
titles from JSONL content with a priority chain:
1. custom-title metadata (set via /rename in Claude Code)
2. First real user message (skipping /clear, /compact caveats)
3. Directory basename (fallback)
2026-04-09 23:18:06 +08:00
Jason a5a5207f74 style: fix prettier formatting in ProviderCard and ProviderForm 2026-04-09 21:13:32 +08:00
Jason accdacf94d docs: add DDS as sponsor in all README languages 2026-04-09 17:40:46 +08:00
Jason 97152c0f21 docs: add LionCC as sponsor in all README languages 2026-04-09 17:25:15 +08:00
Jason 129b1b45b0 docs: add Shengsuanyun as sponsor in all README languages 2026-04-09 17:13:19 +08:00
Jason fa4297f4d3 feat(common-config): show first-run notice dialog when editing providers
Display a one-time informational dialog explaining the Common Config
Snippet feature when users first open the add/edit provider form.
Uses a derived isOpen state from settings to avoid race conditions.
Adds commonConfigConfirmed flag to both TS and Rust settings types.
2026-04-09 16:49:14 +08:00
Jason ecb1fed5db feat(common-config): add guide info and empty state to common config editor
Add an informational alert block at the top of the common config snippet
editor modal (Claude/Codex/Gemini) explaining what the feature is, why
it exists, and how to use it. Also add an empty state prompt when no
snippet has been extracted yet, guiding users to click "Extract from
Editor". Includes i18n support for zh/en/ja.
2026-04-09 16:49:14 +08:00
Jason 521571c4ca fix(usage): only show CLI subscription quota for active provider
CLI-credential-based subscriptions (Claude/Codex/Gemini) read from a
single global credential file, so the quota always reflects the last
CLI login rather than a specific provider. Showing it on non-current
cards is misleading when multiple official subscriptions exist.

Apply the same isCurrent + autoQuery pattern already used by Copilot
and Codex OAuth: only query and render the quota footer when the
provider is the currently active one.
2026-04-09 16:49:14 +08:00
Jason bafe9e820d fix(notifications): remove duplicate toast when switching to proxy providers
When switching to Copilot/ChatGPT/OpenAI-format providers with the proxy
not running, two toasts appeared: a "proxy required" warning followed by
a "switch success" toast. Unify the post-switch toast logic so that all
provider types show a single success toast, and skip it entirely when
a proxy-required warning was already shown.
2026-04-09 16:49:14 +08:00
Jason 8669879ad0 feat(welcome): show first-run welcome dialog on fresh install
Introduce a one-time welcome dialog that explains CC Switch's workflow
to new users: how their existing config is preserved as a "default"
provider and how the bundled "Official" preset enables one-click revert.
Upgrade users are excluded by checking is_providers_empty() at startup
and never see the dialog.

Persistence follows the existing *_confirmed convention in AppSettings
(proxy/usage/stream_check/failover), stored in settings.json. The field
is only written when the user explicitly clicks the confirm button,
keeping its semantics strictly about user acknowledgement.

Also adds two reusable DAO helpers:
- Database::is_providers_empty for fresh-install detection, using
  EXISTS(SELECT 1) for a short-circuit query.
- Database::get_bool_flag accepting "true" | "1", with
  init_default_official_providers migrated to use it.

Dialog copy in zh/en/ja uses conditional phrasing so it stays
accurate whether or not existing live config was found.
2026-04-09 16:49:14 +08:00
Jason a058ebeafc docs(user-manual): update to v3.13.0 across en/zh/ja
Refresh the user manual to cover the v3.13.0 feature set so users can
discover and correctly use new functionality without cross-referencing
the release notes. All three language versions are updated
line-by-line symmetric.

Highlights:

- Lightweight Mode: tray-only running state added in 1.5-settings,
  with a comparison table against "Minimize to tray" and a new OAuth
  Auth Center (Beta) section
- Quota & Balance display restructured in 2.5-usage-query: split into
  auto-query (Claude/Codex/Gemini official, Copilot, Codex OAuth) vs
  manual-enable (Token Plan, third-party balances). Explains why
  manual enabling is required: the same API URL may expose both
  plan-quota and balance query modes
- Codex OAuth reverse proxy: full usage guide in 2.1-add with two
  entry points (Add Provider panel / OAuth Auth Center), Device Code
  login flow, token auto-refresh, multi-account management, quota
  display, common failures, and risk notice
- Full URL Endpoint Mode: new advanced option in 2.1-add
- Per-app tray submenus: 2.2-switch refactored to reflect the 5-app
  submenu structure and cross-link to Lightweight Mode
- Skills workflow: remove obsolete "automatic update not supported"
  section in 3.3-skills, add SHA-256 update detection, single/batch
  update, storage location switch, and skills.sh registry search
- Directory picker for Claude terminal resume in 3.4-sessions
- Usage stats in 4.4-usage: document the new CLI session log source
  (no proxy required) and per-app filtering for Claude/Codex/Gemini;
  note CNY->USD pricing corrections and MiniMax quota fixes
- Stream Check coverage extended to OpenCode/OpenClaw in 4.5-model-test
- New FAQs in 5.2-questions: quota visibility (auto vs manual),
  Codex OAuth risks and login flow, deep link wake in Lightweight Mode
- v3.13.0 highlights navigation block added to top-level README and
  each per-language README; version bumped to v3.13.0 / 2026-04-08
2026-04-09 16:49:14 +08:00
Jason 24bf3e810b feat(providers): auto-import OpenCode/OpenClaw live providers on startup
Drops the friction of clicking the manual "Import current config" button
for OpenCode and OpenClaw — they now match the auto-import behavior the
previous commit added for Claude/Codex/Gemini.

- New "1.6." startup block in lib.rs runs both
  import_opencode_providers_from_live and import_openclaw_providers_from_live
  on every launch. The functions are id-keyed and idempotent, so re-running
  just picks up new providers added externally to the live JSON files.
- Both functions now use a new Database::get_provider_ids() helper
  (HashSet<String> from a single SELECT id-only query) instead of
  get_all_providers(), avoiding the N+1 endpoint sub-queries that would
  otherwise hit the startup hot path on every launch.
2026-04-09 16:49:14 +08:00
Jason 5dbea70eb1 feat(providers): seed an official preset on startup for Claude/Codex/Gemini
New and existing users now see a built-in "Claude Official" / "OpenAI
Official" / "Google Official" entry in their provider list, so switching
back to the official endpoint is one click away instead of buried in the
README.

- New providers_seed.rs holds the three seeds (id, name, settings_config,
  icon) keyed by AppType, with a single is_official_seed_id() helper that
  scans OFFICIAL_SEEDS so the id list has one source of truth.
- Database::init_default_official_providers() runs once per database
  (gated by an official_providers_seeded setting flag), appends each seed
  to the end of the sort order, and never touches is_current.
- Startup also auto-imports the live config (settings.json / auth.json /
  .env) as a "default" provider before seeding, so users with an existing
  manual config don't lose it when they click the official preset.
- Database::has_non_official_seed_provider() replaces the get_all_providers
  call in import_default_config's gating check with an id-only scan,
  dropping the N+1 endpoint sub-queries from every startup.
2026-04-09 16:49:14 +08:00
Jason df5cb6d771 fix(usage): only auto-poll Copilot/ChatGPT quota for current provider
CopilotQuotaFooter and CodexOauthQuotaFooter called their hooks with a
hardcoded `enabled: true` plus an unconditional 5-minute refetch and
refetchOnWindowFocus, so non-current reverse-proxy cards kept polling
in violation of the project's "only the active provider auto-queries
on cooldown" rule. With multiple Copilot or ChatGPT accounts bound to
different cards, every card kept hitting its own usage endpoint.

Adopt the same pattern as useUsageQuery: keep `enabled` independent of
isCurrent so first-fetch and manual refresh still work, but gate
refetchInterval / refetchIntervalInBackground / refetchOnWindowFocus on
a new `autoQuery` option, and thread `isCurrent` from ProviderCard
through the footers into the hooks.
2026-04-09 16:49:14 +08:00
Jason 62fc1d9151 docs(release-notes): add v3.13.0 release notes (en/zh/ja)
Draft trilingual release notes for the upcoming v3.13.0 feature release
covering lightweight mode, quota and balance visibility, provider model
auto-fetch, Codex OAuth reverse proxy, tray per-app submenus, the
Hyper-based proxy forwarding stack, Skills discovery and batch updates,
session workflow upgrades, OpenCode/OpenClaw Stream Check coverage, the
full URL endpoint mode, and the Copilot interaction optimizer, plus the
accompanying Copilot auth, UTF-8 streaming boundary, system prompt
normalization, WebDAV password, and Linux startup fixes. Contributor
attribution follows industry convention using ASCII punctuation inside
PR references across all three locales, and the Codex OAuth reverse
proxy carries its own risk notice alongside a backward link to the
v3.12.3 Copilot risk notice.
2026-04-09 16:49:14 +08:00
Jason 87b1792eba docs(changelog): cover UTF-8 boundary and system prompt fixes
Document two additional Fixed entries for the Unreleased section after
rebasing onto the latest origin/main: the SSE streaming UTF-8 chunk
boundary fix that prevents U+FFFD replacement characters in multi-byte
output via the Copilot reverse proxy, and the OpenAI-compatible chat
transform fix that normalizes fragmented Claude system prompts into a
single leading system message for strict backends like Nvidia and
Qwen-style providers.
2026-04-09 16:49:14 +08:00
Jason 16ed738048 docs(changelog): add Unreleased section for upcoming release
Catalog all user-visible changes since v3.12.3: lightweight mode,
provider model auto-fetch, quota and balance visibility, skills
discovery and batch updates, session workflow upgrades, Codex OAuth
reverse proxy, OpenCode/OpenClaw stream check coverage, tray submenus,
Hyper-based proxy stack, provider key lifecycle, OAuth Auth Center UI
polish, and the accompanying Copilot auth, WebDAV, usage pricing,
Linux UI, and Skills i18n fixes.
2026-04-09 16:49:14 +08:00
Jason 50cbb3be12 refactor(stream-check): rename helper and drop phase markers
Post-merge cleanup from a simplify review pass on the phase 1-4
OpenCode/OpenClaw changes.

- Rename check_once_opencode_like → check_once_without_adapter. The
  new name directly expresses the intent (bypass get_adapter) instead
  of suggesting the function is somehow "like" OpenCode.
- Drop two "Phase 4 会美化错误消息" phase-history markers from
  docstrings; git history is the right place for them.
- Document in resolve_opencode_base_url why its default endpoints
  cannot be merged with ProviderType::default_endpoint(): the former
  encode AI SDK package defaults (e.g. @ai-sdk/openai ships with the
  /v1 suffix) while the latter encode proxy upstream hosts. They
  happen to overlap but are two independent truth sources.
2026-04-09 16:49:14 +08:00
Jason 5a61f01cfc feat(stream-check): handle edge cases for OpenCode/OpenClaw
Phase 4: polish the four remaining edge cases uncovered by Phase 1-3.

Custom headers passthrough
- check_claude_stream and check_gemini_stream now accept an optional
  extra_headers map which is appended after all built-in headers so it
  can override defaults (e.g. a custom User-Agent).
- OpenClaw reads from settings_config.headers.
- OpenCode reads from settings_config.options.headers.
- All pre-existing Claude/Codex/Gemini call sites pass None.

OpenClaw custom auth header (Longcat-style)
- When settings_config.authHeader is true, the provider expects a
  custom auth header whose name is only known to the OpenClaw gateway
  itself. Return a dedicated openclaw_auth_header_not_supported error
  so the user sees a meaningful explanation instead of a 401.

Bedrock error polish
- The bedrock-converse-stream (OpenClaw) and @ai-sdk/amazon-bedrock
  (OpenCode) branches now explain why (SigV4 signing) and point to
  the official consoles as an alternative test path.

OpenCode baseURL fallback
- resolve_opencode_base_url: when options.baseURL is empty and the
  npm package has a canonical default endpoint (@ai-sdk/openai,
  @ai-sdk/anthropic, @ai-sdk/google), fall back to that endpoint.
  @ai-sdk/openai-compatible still requires an explicit baseURL
  because its whole purpose is to point at a custom OpenAI clone.

Tests
- 8 new unit tests covering authHeader detection, baseURL resolution
  (explicit / fallback / error), and header map extraction on both
  apps. Total stream_check tests: 18 → 26.
2026-04-09 16:49:14 +08:00
Jason c02b8c58cb feat(stream-check): support OpenCode via npm package mapping
Phase 3: implement stream check for OpenCode providers by mapping the
`settings_config.npm` (AI SDK package name) to the corresponding API
protocol and delegating to the existing stream checkers.

Package mapping:
- @ai-sdk/openai-compatible → openai_chat
- @ai-sdk/openai            → openai_responses
- @ai-sdk/anthropic         → anthropic (ClaudeAuth strategy)
- @ai-sdk/google            → gemini (Google strategy)
- @ai-sdk/amazon-bedrock    → not supported (phase 4 message polish)

Note: OpenCode nests baseURL/apiKey under `settings_config.options`
(different from OpenClaw's root-level fields) and uses `baseURL` with
a capital L. Three new extractors (base_url / api_key / npm) encode
these shape differences so check_opencode_stream stays symmetric with
check_openclaw_stream.

Frontend: drop the remaining `appId !== "opencode"` filter in
ProviderList.tsx — both apps can now test providers.
2026-04-09 16:49:14 +08:00
Jason 7b3cfc683a feat(stream-check): support remaining 3 OpenClaw protocols
Phase 2: extend check_openclaw_stream to cover the full non-Bedrock
protocol set declared by openclawApiProtocols.

- openai-responses   → check_claude_stream(api_format="openai_responses")
- anthropic-messages → check_claude_stream(api_format="anthropic"),
  using AuthStrategy::ClaudeAuth (Bearer-only) so Claude relay
  services that reject a simultaneous x-api-key still work. Official
  Anthropic also accepts pure Bearer on /v1/messages.
- google-generative-ai → check_gemini_stream with AuthStrategy::Google.

bedrock-converse-stream still errors out but with a dedicated
openclaw_bedrock_not_supported key; its user-facing message will be
polished in phase 4.

Each protocol now builds its own AuthInfo inside the match arm because
the auth strategy is protocol-specific.
2026-04-09 16:49:13 +08:00
Jason 516fcdf6bf feat(stream-check): support OpenClaw openai-completions protocol
Phase 1 of extending stream health check to OpenCode/OpenClaw apps.

- Add early-dispatch path for OpenCode/OpenClaw in check_once so they
  bypass the adapter layer (which only knows Claude/Codex/Gemini
  settings_config shapes).
- Introduce check_openclaw_stream dispatcher that reads the `api` field
  from settings_config and routes to the existing check_claude_stream
  with api_format="openai_chat" for "openai-completions". Other
  protocols return localized errors to be lit up in phases 2 and 4.
- Extract build_stream_check_result helper to avoid duplicating the
  StreamCheckResult construction logic between the two code paths.
- Unblock the test button for OpenClaw providers in ProviderList.tsx.

OpenCode still returns the "not yet supported" error; it will be
enabled in phase 3.
2026-04-09 16:49:13 +08:00
Jason 34c6696baa fix(providers): disable test/usage buttons for Copilot and Codex OAuth cards
These OAuth providers ship with non-empty ANTHROPIC_BASE_URL, so the
isOfficialProvider() heuristic (which checks for a missing base URL)
returned false and left the health-check and usage-config buttons
enabled — inconsistent with other official OAuth cards. Extend the
button disabling logic at the call site with isCopilot / isCodexOauth,
matching the pattern already used for the quota footer branch above.
2026-04-09 16:49:13 +08:00
Jason 641c2fc2c7 chore(presets): bump Codex OAuth preset to GPT-5.4 family
Update the "Codex (ChatGPT Plus/Pro)" entry in Claude Code presets to
the new GPT-5.4 naming, which drops the legacy `-codex` suffix. Map the
Haiku tier to `gpt-5.4-mini` for lower-cost lightweight calls while
keeping Sonnet/Opus on the standard `gpt-5.4`.
2026-04-09 16:49:13 +08:00
Jason 64c068415e fix(linux): repair unresponsive UI on startup and full-screen panels
Linux users reported the window UI (including native title bar buttons)
couldn't receive clicks until manually maximizing and restoring the
window. Root causes: (1) Tauri webview did not acquire focus on startup
so first clicks were consumed by X11/Wayland click-to-activate
(Tauri #10746, wry #637); (2) GTK surface input region failed to
renegotiate on the visible:false + show() path under some
WebKitGTK/compositor combinations.

- Add linux_fix::nudge_main_window helper that performs set_focus plus
  a ±1px no-op resize after window show, with a 500ms reconciliation
  readback to compensate for dropped resize requests on slow
  compositors.
- Wire the helper into every window re-show path: normal startup,
  deeplink, single_instance, tray show_main, and lightweight exit.
- Set WEBKIT_DISABLE_COMPOSITING_MODE=1 at startup to avoid resize
  crashes and Wayland surface negotiation issues.
- Remove data-tauri-drag-region on Linux from App.tsx header and the
  shared FullScreenPanel (used by all provider/MCP/workspace forms)
  to avoid Tauri #13440 in Wayland sessions. Extract drag-region
  constants to src/lib/platform.ts for reuse.

All Rust changes are gated by #[cfg(target_os = "linux")]; frontend
changes preserve macOS/Windows behavior via runtime isLinux() checks.
Known limitation: tiling Wayland compositors ignore set_size, so
GDK_BACKEND=x11 remains the user-side workaround.
2026-04-09 16:49:13 +08:00
Jason 3a16462261 i18n(zh): unify Skills terminology in settings labels
Use "Skills" consistently in skillStorage title/description and
skillSync title to match the upstream Agent Skills wording and the
existing English label style used elsewhere on the settings page.
2026-04-09 16:49:13 +08:00
Jason 5288238694 refactor: tighten OAuth Auth Center copy, layout, and icon
- Trim Auth Center section descriptions to focus on user intent
- Remove duplicate outer heading on the auth settings tab
- Swap Sparkles glyph for CodexIcon on the ChatGPT card
- Generalize codexOauth.authStatus to a neutral "Auth status"
- Register settings.authCenter.* keys across zh/en/ja locales
2026-04-09 16:49:13 +08:00
Jason d164191bd1 feat: display subscription quota for Codex OAuth provider cards
Codex OAuth (ChatGPT Plus/Pro) providers previously fell through to the
default UsageFooter branch and showed no quota at all, while Copilot and
official Codex providers already had a wham/usage-backed quota footer.

This wires up the same five-hour / seven-day tier badges for codex_oauth
provider cards by reusing the existing query_codex_quota function and
SubscriptionQuotaFooter rendering, parameterized to keep both the CLI
credential path ("codex") and the cc-switch managed OAuth path
("codex_oauth") working from a single source of truth.

- Parameterize services::subscription::query_codex_quota with tool_label
  and expired_message; promote SubscriptionQuota constructors to
  pub(crate). The CLI path keeps its existing "codex" label and the
  "re-login with Codex CLI" message; the new path passes "codex_oauth"
  and a cc-switch-specific re-login hint.
- Add a new get_codex_oauth_quota Tauri command in commands/codex_oauth.rs
  that resolves the ChatGPT account (explicit binding > default account
  > not_found), pulls a valid access_token from CodexOAuthManager
  (auto-refresh handled), and delegates to query_codex_quota.
- Extract SubscriptionQuotaFooter's render body into a pure
  SubscriptionQuotaView component (props: quota / loading / refetch /
  appIdForExpiredHint / inline). The existing SubscriptionQuotaFooter
  becomes a thin wrapper with identical props and behavior, so
  CopilotQuotaFooter and the official Claude/Codex/Gemini paths are
  untouched. This avoids duplicating ~280 lines of five-state rendering.
- Add CodexOauthQuotaFooter, a 38-line wrapper that calls the new
  useCodexOauthQuota hook and forwards to SubscriptionQuotaView.
- ProviderCard inserts an isCodexOauth branch between isCopilot and
  isOfficial, keyed off PROVIDER_TYPES.CODEX_OAUTH (newly added to
  config/constants.ts to centralize the previously scattered string).
- Frontend hook caches per (codex_oauth, accountId) so multiple cards
  bound to the same ChatGPT account share one fetch via react-query
  dedup; cards bound to different accounts get independent fetches.
- No new i18n keys: existing subscription.fiveHour / sevenDay / expired /
  refresh / queryFailed / expiredHint are reused.
2026-04-09 16:49:13 +08:00
Jason 6a34253934 feat: add Codex OAuth (ChatGPT Plus/Pro) reverse proxy support
Adds a new managed OAuth provider that lets Claude Code route requests
through a user's ChatGPT Plus/Pro subscription via the chatgpt.com
backend-api/codex endpoint.

- CodexOAuthManager: OpenAI Device Code flow with multi-account support,
  JWT-based account identification, and automatic access_token refresh.
- Reuses the generic managed-auth command surface (auth_start_login,
  auth_poll_for_account, etc.) via provider dispatch in commands/auth.rs.
- ClaudeAdapter detects codex_oauth providers, forces the base URL to
  the ChatGPT backend, pins api_format to openai_responses, and emits
  Authorization + originator headers; the forwarder injects the dynamic
  access_token and ChatGPT-Account-Id per request.
- transform_responses gains an is_codex_oauth path that aligns the body
  with OpenAI's codex-rs ResponsesApiRequest contract: sets store:false,
  appends reasoning.encrypted_content to include, strips max_output_tokens
  / temperature / top_p, injects default instructions/tools/parallel_tool_calls,
  and forces stream:true. Covered by 9 new unit tests plus regression
  guards for the non-Codex path.
- Stream check reuses the same transform flag so detection matches the
  production request shape.
- Frontend adds CodexOAuthSection + useCodexOauth hook, integrates it
  into ClaudeFormFields / ProviderForm / AuthCenterPanel, ships a new
  "Codex (ChatGPT Plus/Pro)" preset, and adds zh/en/ja i18n strings.
2026-04-09 16:49:13 +08:00
Jason 697d0dd6e1 fix: resolve rustfmt formatting and clippy warnings
- Apply cargo fmt across schema.rs, session_usage*.rs, skill.rs, usage_stats.rs
- Fix clippy::for_kv_map: use messages.values() instead of (_, msg) pattern
- Suppress clippy::only_used_in_recursion for intentional recursive base path
- Fix prettier formatting in UsageScriptModal.tsx
2026-04-09 16:49:13 +08:00
Jason d33d63e4df feat: display Copilot premium interactions quota on provider card
Copilot usage query API was implemented but never surfaced on the main
provider list. Add CopilotQuotaFooter component that auto-detects
github_copilot providers and displays premium interaction utilization
inline, reusing the existing TierBadge UI from SubscriptionQuotaFooter.
2026-04-09 16:49:13 +08:00
Jason eb41e1052c fix: resolve session-based usage showing as unknown provider
Session logs use placeholder provider_ids (_session, _codex_session,
_gemini_session) that don't exist in the providers table, causing LEFT
JOIN to return NULL and display "Unknown". Add COALESCE fallback in all
4 usage queries to show meaningful names like "Claude (Session)".
2026-04-09 16:49:13 +08:00
Jason 687ffc237d feat: add per-app usage filtering (Claude/Codex/Gemini)
Add dashboard-level app type filter to usage statistics, replacing the
DataSourceBar with a more useful segmented control. All components
(summary cards, trend chart, provider stats, model stats, request logs)
now respond to the selected app filter.

Backend: add optional app_type parameter to get_usage_summary,
get_daily_trends, get_provider_stats, and get_model_stats queries.
Frontend: new AppTypeFilter type, updated query keys with appType
dimension for proper cache separation, and RequestLogTable local
filter auto-locks when dashboard filter is active.
2026-04-09 16:49:13 +08:00
Jason c0bcd19d44 fix: correct Gemini session sync accuracy issues
- Use UPSERT with WHERE guard instead of INSERT OR IGNORE, so updated
  token values on existing messages are properly synced without
  unnecessary rewrites of unchanged rows
- Include cached tokens in the skip-zero filter to stop silently
  discarding pure cache-hit records
- Restrict file collection to session-*.json to match documented scope
  and prevent ingesting non-session JSON files
2026-04-09 16:49:13 +08:00
Jason f5d7064d57 feat: add Gemini CLI session log usage tracking
Parse ~/.gemini/tmp/*/chats/session-*.json for precise per-message
token data (input/output/cached/thoughts). Integrates with existing
background sync and manual sync button alongside Claude and Codex.
2026-04-09 16:49:13 +08:00
Jason 8ad1bb7924 feat: add Codex model name normalization for consistent pricing lookup
Normalize model names from JSONL session logs before storage and pricing
lookup: lowercase, strip provider prefix (openai/), strip date suffixes
(-YYYY-MM-DD, -YYYYMMDD). Also clamp cached tokens to not exceed input.
2026-04-09 16:49:13 +08:00
Jason 2ea6a307fc feat: replace Codex estimated usage with precise JSONL session log parsing
Replace the 70/30 input/output token estimation from state_5.sqlite
with precise parsing of Codex CLI JSONL session logs (~/.codex/sessions/).

- Parse event_msg (token_count), turn_context, and session_meta events
- Compute exact input/output/cached token deltas from cumulative totals
- Reuse session_log_sync table for incremental file scanning
- Pre-filter lines with string contains() before JSON deserialization
- Add codex_session data source to DataSourceBar with i18n (zh/en/ja)
2026-04-09 16:49:13 +08:00
Jason 46051317da fix: correct model pricing CNY→USD and add missing models
- Fix 13 Chinese model prices that were stored as CNY values in USD
  fields (DeepSeek, Kimi, MiniMax, GLM, Doubao, Mimo)
- Add 12 new models: GPT-5.4/mini/nano, o3, o4-mini, GPT-4.1/mini/nano,
  Gemini 3.1 Pro/Flash Lite, Gemini 2.5 Flash Lite, Gemini 2.0 Flash,
  DeepSeek Chat/Reasoner, Kimi K2.5
- Merge pricing migration into existing v7→v8 to avoid extra version bump
2026-04-09 16:49:13 +08:00
Jason 154342ca00 feat: add session log usage tracking without proxy
Parse Claude Code JSONL session files (~/.claude/projects/) and Codex
SQLite database (~/.codex/state_5.sqlite) to track API usage without
requiring proxy interception. This enables usage statistics for users
who don't use the proxy feature.

Key changes:
- Add session_usage.rs: incremental JSONL parser with message.id dedup
- Add session_usage_codex.rs: import thread-level token data from Codex
- Add data_source column to proxy_request_logs (proxy/session_log/codex_db)
- Add session_log_sync table for tracking parse offsets
- Background sync every 60s + manual sync via DataSourceBar UI
- Schema migration v7→v8
- i18n support for zh/en/ja
2026-04-09 16:49:13 +08:00
Jason 2d581bce91 fix: hide empty description and fix broken skill link for skills.sh results
- Hide "暂无描述" text when skill has no description (skills.sh API
  doesn't return descriptions), show empty spacer instead
- Change skills.sh result link from guessed subdirectory path to repo
  root URL, since skillId doesn't reflect the actual nested path
2026-04-09 16:49:13 +08:00
Jason d51e774b20 feat: integrate skills.sh search for discovering skills from public registry
Add skills.sh API integration allowing users to search and install from
a catalog of 91K+ agent skills directly within CC Switch. The search
results are converted to DiscoverableSkill objects and reuse the existing
install pipeline. Includes fallback directory search for repos where
skills are nested in subdirectories, and filters out non-GitHub sources.
2026-04-09 16:49:13 +08:00
Jason 33f5d56afd fix: move skill settings above window settings in general tab 2026-04-09 16:49:13 +08:00
Jason 8cfce8abfc feat: add skill storage location toggle between CC Switch and ~/.agents/skills
Allow users to choose between storing skills in CC Switch's managed
directory (~/.cc-switch/skills/) or the Agent Skills open standard
directory (~/.agents/skills/). Includes migration logic that safely
moves files before updating settings, with confirmation dialog for
non-empty installations.
2026-04-09 16:49:13 +08:00
Jason 6d220b2528 fix: animate "Update All" button sliding in from the left of "Check Updates"
Use max-width + opacity CSS transition so the button smoothly expands
into view instead of popping in abruptly.
2026-04-09 16:49:13 +08:00
Jason b7e99014c9 feat: add "Update All" button for batch skill updates
Show an "Update All (N)" button next to "Check Updates" when updates
are available. Sequentially updates each skill and reports results.
2026-04-09 16:49:13 +08:00
Jason e3179ad9e4 feat: add skill update detection via SHA-256 content hashing
- Add content_hash and updated_at fields to skills table (DB migration v6→v7)
- Compute directory content hash on install/import/restore for version tracking
- Add check_updates command: downloads repos, compares hashes, returns update list
- Add update_skill command: backs up old files, re-downloads and replaces SSOT
- Backfill content_hash for existing skills on first update check
- Add "Check Updates" button and per-skill update badge/button in UnifiedSkillsPanel
- Add i18n keys for zh/en/ja
2026-04-09 16:49:13 +08:00
Jason 46488ecd93 fix: set default auto-query interval to 5min and fix number input clearing
- Change default autoQueryInterval from 0 (disabled) to 5 minutes for
  new usage scripts (Token Plan, Balance, and general templates)
- Fix controlled number inputs (timeout & interval) that couldn't be
  cleared: defer validation to onBlur so users can delete and retype
2026-04-09 16:49:13 +08:00
Jason 9192b6f988 fix: align usage display across provider cards by always rendering action buttons
Test and ConfigureUsage buttons are now always rendered instead of
conditionally, with a disabled style for providers that don't support
them (e.g. official subscriptions). This ensures consistent button
container width so the usage display aligns uniformly across all cards.
2026-04-09 16:49:13 +08:00
Jason f1fb3351c1 feat: add official balance query for DeepSeek, StepFun, SiliconFlow, OpenRouter, Novita AI
Add a new "Official" (官方) template type in the usage query panel that
queries account balance via each provider's native API endpoint.
Follows the same zero-script pattern as Token Plan — Rust handles the
HTTP call, frontend auto-detects the provider from base URL.

Supported providers and endpoints:
- DeepSeek: GET /user/balance
- StepFun: GET /v1/accounts
- SiliconFlow: GET /v1/user/info (cn + com)
- OpenRouter: GET /api/v1/credits
- Novita AI: GET /v3/user/balance
2026-04-09 16:49:13 +08:00
Satoru 24555275bb fix: add padding to toolbar container to prevent add button shadow clipping (#1951)
The orange add button's circular shadow appeared square because the
parent overflow-x-hidden container was clipping it at the edges.
2026-04-08 21:17:19 +08:00
Cod1ng dc4524e960 fix: handle UTF-8 multi-byte characters split across stream chunk boundaries (#1923)
* fix: handle UTF-8 multi-byte characters split across stream chunk boundaries

Replace String::from_utf8_lossy with append_utf8_safe in all four SSE
streaming paths. When a multi-byte UTF-8 character (e.g. Chinese, emoji)
is split across TCP chunk boundaries, from_utf8_lossy silently replaces
the incomplete halves with U+FFFD (�). This caused intermittent garbled
output in Claude Code when using the Copilot reverse proxy, because the
format conversion streams reconstruct SSE events from the corrupted buffer.

The new append_utf8_safe function preserves incomplete trailing bytes in
a remainder buffer and merges them with the next chunk before decoding,
ensuring characters are never split during UTF-8 conversion.

Fixes: intermittent U+FFFD replacement characters in Claude Code output
via Copilot proxy (not reproducible with direct Copilot connections like
opencode because they pass through raw bytes without format conversion).

* style: fix cargo fmt formatting in UTF-8 boundary tests

---------

Co-authored-by: Cod1ng <codingts@gmail.com>
Co-authored-by: encodets <encodets@gmail.com>
2026-04-08 10:02:35 +08:00
Dex Miller 34f16886a2 Normalize fragmented system prompts for strict chat backends (#1942)
Some OpenAI-compatible chat providers reject requests when Claude-side\nsystem fragments arrive as multiple system messages. Normalize the\nconverted OpenAI chat payload so system content becomes a single\nleading system message while leaving the rest of the message stream\nunchanged.\n\nConstraint: Nvidia/Qwen-style chat completions require a single leading system prompt\nRejected: Reorder system messages only | still leaves fragmented system prompts for strict backends\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep OpenAI chat system prompts normalized unless a provider explicitly requires fragmented system messages\nTested: cargo test proxy::providers::transform --manifest-path src-tauri/Cargo.toml\nNot-tested: Full end-to-end proxy capture against Nvidia upstream in this session\nRelated: #1881
2026-04-08 09:41:20 +08:00
149 changed files with 13883 additions and 1288 deletions
+47
View File
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
Development since v3.12.3 focuses on quota visibility, provider workflow upgrades, stronger proxy compatibility, and lower-overhead tray / session workflows.
### Added
- **Lightweight Mode**: Added a tray-only mode that destroys the main window and keeps CC Switch running from the system tray, with the window recreated when users reopen it.
- **Provider Model Auto-Fetch**: Added OpenAI-compatible `/v1/models` discovery for Claude, Codex, Gemini, OpenCode, and OpenClaw provider forms, including grouped dropdown selection and failure-specific error messages.
- **Quota & Balance Visibility**: Added inline quota or balance display for official Claude / Codex / Gemini providers, GitHub Copilot premium interactions, Codex OAuth providers, Token Plan providers (Kimi / Zhipu GLM / MiniMax), and official balance queries for DeepSeek, StepFun, SiliconFlow, OpenRouter, and Novita AI.
- **Skills Discovery & Batch Updates**: Added SHA-256 based skill update detection, per-skill and batch update actions, a storage-location toggle between CC Switch and `~/.agents/skills`, and public `skills.sh` search integration.
- **Session Workflow Upgrades**: Added batch delete in Session Manager, a directory picker before launching Claude terminal restore commands, usage import from Claude / Codex / Gemini session logs without requiring proxy interception, and per-app usage filtering for Claude / Codex / Gemini dashboards.
- **Codex OAuth Reverse Proxy**: Added ChatGPT Plus / Pro based Codex OAuth reverse proxy support for Claude provider cards, including managed OAuth login and inline subscription quota display.
- **OpenCode / OpenClaw Stream Check Coverage**: Added OpenCode npm package mapping plus support for OpenClaw `openai-completions` and the remaining OpenClaw protocol variants in Stream Check.
- **Full URL Endpoint Mode**: Added a provider option that treats `base_url` as a complete upstream endpoint so proxy forwarding and stream checks can work with vendors that require nonstandard URL layouts.
- **OpenCode StepFun Step Plan Preset**: Added a StepFun Step Plan provider preset for OpenCode.
- **Copilot Interaction Optimizer**: Added request classification and routing logic to reduce unnecessary GitHub Copilot premium interaction consumption.
### Changed
- **Tray Menu Organization**: Reworked the tray menu into per-app submenus to prevent overflow and make background provider switching scale better with larger provider lists.
- **Proxy Forwarding Stack**: Refactored proxy forwarding onto a Hyper-based client with transparent header forwarding, improved endpoint rewriting, and better support for dynamic upstream endpoints.
- **OAuth Auth Center UI Polish**: Tightened the Auth Center copy, layout, and icon presentation so the Codex OAuth login flow feels cleaner and less cluttered.
- **Provider Key Lifecycle & Live Sync**: Reworked additive provider create / rename / duplicate flows so live config writes, cleanup, and rollback stay consistent across OpenCode / OpenClaw and takeover scenarios.
- **Codex OAuth Defaults**: Updated the Codex OAuth preset to the GPT-5.4 model family.
### Fixed
- **Copilot Authentication & Proxy Compatibility**: Fixed GitHub Copilot authentication regressions, corrected enterprise / dynamic endpoint handling, repaired clipboard verification-code copying on macOS and Linux, and fixed Responses routing when Copilot-backed Claude providers target OpenAI models.
- **Streaming Parser Compatibility**: Fixed SSE parsing to accept fields with optional spaces, improving compatibility with non-strict streaming implementations.
- **UTF-8 Stream Chunk Boundaries**: Fixed intermittent garbled output (U+FFFD replacement characters) in Claude Code when multi-byte UTF-8 sequences such as Chinese characters or emoji were split across TCP stream chunks via the Copilot reverse proxy, by preserving incomplete trailing bytes across chunks in all four SSE streaming paths instead of lossy decoding.
- **Fragmented System Prompt Normalization**: Fixed strict OpenAI-compatible chat backends (Nvidia, Qwen-style) rejecting requests when converted Claude payloads contained multiple system messages, by merging system content into a single leading system message during the Anthropic → OpenAI chat transformation.
- **Provider Switch State Corruption**: Serialized per-app provider switches to prevent concurrent failover or hot-switch operations from leaving `is_current`, settings state, and live backup state out of sync.
- **Claude Takeover Live Config Drift**: Fixed provider edits while Claude takeover is active so live settings remain aligned with the latest provider state without breaking takeover restore behavior.
- **WebDAV Password Retention & Validation**: Fixed the WebDAV password field so saved credentials remain visible after refresh and treated `MKCOL 405` responses correctly during connection validation.
- **Provider Card Action States**: Fixed additive-mode highlight behavior, aligned usage display layout across provider cards, replaced hard proxy-switch blocking with a warning path, and disabled unsupported test / usage actions for Copilot and Codex OAuth cards.
- **Usage Accuracy & Pricing**: Fixed MiniMax quota math and 0%→100% progression, corrected CNY→USD pricing plus missing model definitions, improved Gemini session-log syncing, and resolved session-based usage entries being shown as unknown providers.
- **Usage Editor & Skills UI Regressions**: Fixed usage query fields being reset while editing extractor code, corrected broken `skills.sh` links and empty descriptions, and fixed auto-query defaults plus number-input clearing in usage configuration.
- **Chinese Skills Terminology**: Unified Skills-related labels across settings panels in the `zh` locale so storage and sync options use consistent wording.
- **Environment & Preset Compatibility**: Added Bun global bin detection in CLI scan, adapted to the oh-my-openagent rename with backward compatibility, corrected the OpenCode `kimi-for-coding` preset, gated Gemini keychain parsing to macOS, and fixed an OpenClaw serializer panic on empty collections.
- **Linux UI Unresponsive on Startup**: Fixed a bug where the window UI (including native title bar buttons) couldn't receive clicks on Linux until the user manually maximized and restored the window. Root causes: (1) Tauri webview did not acquire keyboard focus after `show()` on Linux, so the first click was consumed by X11/Wayland click-to-activate (Tauri #10746, wry #637); (2) GTK surface's input region failed to renegotiate on the `visible:false → show()` path under some WebKitGTK/compositor combinations, leaving the entire window unresponsive. Mitigations: set `WEBKIT_DISABLE_COMPOSITING_MODE=1` at startup, and added a new `linux_fix::nudge_main_window` helper that performs `set_focus` + a ±1px no-op resize ~200ms after show, equivalent to a visually invisible "maximize-and-restore". Wired into all window-re-show paths (normal startup, deeplink, single_instance, tray `show_main`, lightweight exit).
- **Linux Drag Region on Header**: Removed `data-tauri-drag-region` from the top header bar on Linux to avoid triggering `gtk_window_begin_move_drag` paths affected by Tauri #13440 under Wayland. macOS drag behavior is preserved.
- **OpenCode / OpenClaw Stream Check Edge Cases**: Fixed custom-header passthrough, OpenClaw custom auth-header detection, Bedrock error messaging, and OpenCode default `baseURL` fallback handling in Stream Check.
### Docs
- **User Manual Refresh**: Updated the EN / ZH / JA manuals for tray submenus, lightweight mode, provider model fetching, session management, workspace files, WebDAV v2 behavior, OpenCode / OpenClaw activation, and other provider workflow improvements.
- **Community & Contribution Docs**: Added `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, bilingual issue / PR templates, Dependabot config, and CI quality checks.
- **Release Notes Risk Notice**: Added a Copilot reverse proxy risk notice and anchored highlight links in the v3.12.3 release notes across all three languages.
---
## [3.12.3] - 2026-03-24
+16
View File
@@ -41,6 +41,11 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e
<td>Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via <a href="https://cloud.siliconflow.cn/i/drGuwc9k">this link</a> and complete real-name verification to receive ¥20 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free.</td>
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.svg" alt="Shengsuanyun" width="150"></a></td>
<td>Thanks to Shengsuanyun for sponsoring this project! Shengsuanyun is a super factory serving AI Native Teams — an industrial-grade AI task parallel execution platform. Its model marketplace aggregates Claude, ChatGPT, Gemini, and other domestic and international LLM and multimedia model capabilities with direct supply. Absolutely no reverse engineering or dilution — platform-wide model SLA availability reaches 99.7%, with <a href="https://watch.shengsuanyun.com/status/shengsuanyun">monitoring dashboards</a> showing green across the board. It also offers enterprise-grade custom gateways for fine-grained team cost and permission management, smart routing, security protection, and BYOK (Bring Your Own Key) hosting. The platform charges on a pay-per-use and tokens plan (coming soon) basis, with invoicing available. Register via <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">this link</a> as a new user to receive ¥10 in credits plus a 10% bonus on your first top-up.</td>
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
@@ -107,6 +112,17 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
<td>Thanks to ChefShop AI for sponsoring this project! ChefShop AI is a premium account service provider tailored for heavy AI subscription users. The platform offers official top-up and stable account services for mainstream large models including ChatGPT Plus/Pro, Claude Max, Grok Super/Heavy, and Gemini. Click <a href="https://chefshop.ai">here</a> to purchase!</td>
</tr>
<tr>
<td width="180"><a href="https://vibecodingapi.ai"><img src="assets/partners/logos/lioncc.png" alt="LionCC" width="150"></a></td>
<td>Thanks to LionCC for sponsoring this project! LionCC is built for Vibe Coders who pursue the ultimate development experience. We provide stable, low-latency, and competitively priced computing services for Claude Code, Codex, and OpenClaw, saving up to 50% in costs. After registering, add customer service on WeChat (HSQBJ088888888) with the code "cc-switch" to receive $10 in free credits (10 million tokens). For other collaborations, follow the blog @LionCC.ai. Click <a href="https://vibecodingapi.ai">here</a> to register!</td>
</tr>
<tr>
<td width="180"><a href="https://ddshub.short.gy/ccswitch"><img src="assets/partners/logos/dds.png" alt="DDS" width="150"></a></td>
<td>Thanks to DDS for sponsoring this project! DDS Hub is a reliable and high-performance Claude API proxy service. We provides cost-effective domestic Claude direct acceleration services for both individual and enterprise users. We offer stable and low-latency Claude Max number pools, with full support for Claude Haiku, Opus, Sonnet and other flagship models. Invoices are available for recharges of 1000 RMB or more. Enterprise customers can also enjoy customized grouping and dedicated technical support services.
Exclusive benefit for CC Switch users: Register via <a href="https://ddshub.short.gy/ccswitch">the link </a>below and enjoy an extra 10% credit on your first recharge (please contact the group admin to claim after recharging)!</td>
</tr>
</table>
</details>
+17
View File
@@ -41,6 +41,11 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設
<td>SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。<a href="https://cloud.siliconflow.cn/i/drGuwc9k">このリンク</a>から登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥20 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。</td>
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.svg" alt="Shengsuanyun" width="150"></a></td>
<td>胜算雲(Shengsuanyun)のご支援に感謝します!胜算雲は AI ネイティブチーム向けのスーパーファクトリーであり、産業グレードの AI タスク並列実行プラットフォームです。モデルマーケットプレイスでは Claude、ChatGPT、Gemini をはじめとする国内外の LLM およびマルチメディアモデルの計算リソースを集約・直接提供。リバースエンジニアリングや品質低下は一切なく、プラットフォーム全体のモデル SLA 可用性は 99.7% に達し、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">監視ダッシュボード</a>は常時グリーン表示です。さらにエンタープライズ向けカスタムゲートウェイを提供し、チームのきめ細かなコスト・権限管理、スマートルーティング、セキュリティ保護、BYOK(自社キー持ち込み)ホスティングを実現します。従量課金およびトークンプラン(近日公開)対応で、請求書発行にも対応。<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">このリンク</a>から新規登録すると 10 元分のクレジットと初回チャージ 10% ボーナスが付与されます。</td>
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
@@ -107,6 +112,18 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
<td>ChefShop AI のご支援に感謝します!ChefShop AI は、AI ヘビーユーザー向けにカスタマイズされたプレミアムアカウントサービスプロバイダーです。ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy、Gemini など主流の大規模モデルの公式チャージと安定したアカウントサービスを提供しています。<a href="https://chefshop.ai">こちら</a>から購入してください!</td>
</tr>
<tr>
<td width="180"><a href="https://vibecodingapi.ai"><img src="assets/partners/logos/lioncc.png" alt="LionCC" width="150"></a></td>
<td>LionCC のご支援に感謝します!LionCC は究極の開発体験を追求する「Vibe Coders」のために生まれました。Claude Code、Codex、OpenClaw 向けに安定・低遅延・お得な価格の計算リソースサービスを提供し、最大 50% のコスト削減を実現します。登録後、カスタマーサービスの WeChatHSQBJ088888888)を追加し、合言葉「cc-switch」を送信すると、10 ドル分のクレジット(1,000 万トークン)がもらえます。その他のコラボレーションについてはブログ @LionCC.ai をフォローしてください。<a href="https://vibecodingapi.ai">こちら</a>から登録してください!</td>
</tr>
<tr>
<td width="180"><a href="https://ddshub.short.gy/ccswitch"><img src="assets/partners/logos/dds.png" alt="DDS" width="150"></a></td>
<td>本プロジェクトのスポンサーである DDS に感謝いたします! DDS(呆呆獣 / DDS Hub)は、Claude に特化した信頼性とパフォーマンスの高い API プロキシサービスです。個人および企業ユーザーの皆様に、圧倒的なコストパフォーマンスを誇る Claude 直結アクセラレーションサービスを提供しています。Claude Haiku / Opus / Sonnet などのフルスペックモデルを完全サポートし、安定した低遅延のアクセスを実現します。
1,000人民元以上のチャージで領収書(発票)の発行が可能です。さらに、企業のお客様にはカスタマイズされたグループ管理や専用テクニカルサポートをご提供しています。
CC Switch ユーザー限定特典: 専用リンクからご<a href="https://ddshub.short.gy/ccswitch">登録</a>いただくと、初回チャージ時に 10% の追加ボーナスクレジット をプレゼントいたします!(※チャージ完了後、グループ管理人へご連絡の上お受け取りください。)</td>
</tr>
</table>
</details>
+15
View File
@@ -41,6 +41,11 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构
<td>感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过<a href="https://cloud.siliconflow.cn/i/drGuwc9k">此链接</a>注册并完成实名认证,即可获得 ¥20 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。</td>
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.svg" alt="Shengsuanyun" width="150"></a></td>
<td>感谢胜算云赞助了本项目!胜算云是专为AI Native Teams服务的超级工厂,工业级AI任务并行执行平台,模型商城集采直供聚合接入了Claude、Chatgpt、Gemini等海内外LLM及图片视频多媒体模型算力,绝无逆向掺水、全站模型SLA可用性高达99.7%、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">监测接口</a>日常全绿。更有企业级专属定制网关,实现团队精细化成本与权限管控,智能路由+安全防护+BYOK企业自带密钥托管。平台按量及tokens plan(即将上线)计费,可开票,使用<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">此链接</a>注册新用户可获10元模力及首充10%赠送。</td>
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
@@ -108,6 +113,16 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
<td>感谢 厨师长AI小铺 赞助了本项目!厨师长AI小铺 是一家专为 AI 重度订阅用户量身定制的优质账号服务商。平台提供涵盖 ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy 以及 Gemini 等主流大模型的官方代充与稳定成品账号服务。点击<a href="https://chefshop.ai">这里</a>购买!</td>
</tr>
<tr>
<td width="180"><a href="https://vibecodingapi.ai"><img src="assets/partners/logos/lioncc.png" alt="LionCC" width="150"></a></td>
<td>感谢 LionCC 狮子API 赞助了本项目!LionCC 专为追求极致开发体验的”Vibe Coders”而生。我们提供稳定、低延迟、优惠价格的 Claude Code、Codex 及 OpenClaw 算力服务,可节约 50% 成本。注册后添加客服微信 HSQBJ088888888,发暗号 cc-switch 备注即可送 10 美金额度(1000 万 token 算力)。其他项目合作关注博客 @LionCC.ai,点击<a href=”https://vibecodingapi.ai”>这里</a>注册!</td>
</tr>
<tr>
<td width="180"><a href="https://ddshub.short.gy/ccswitch"><img src="assets/partners/logos/dds.png" alt="DDS" width="150"></a></td>
<td>感谢 DDS 赞助本项目!呆呆兽是一家专注 Claude 的可靠高效 API 中转站,为个人和企业用户提供极具性价比的国内 Claude 直连加速服务。支持 Claude Haiku / Opus / Sonnet 等满血模型。充值满 1000 元即可开具发票,企业客户更可享受定制化分组和技术支持服务。CC Switch 用户专属福利:通过<a href="https://ddshub.short.gy/ccswitch">此链接</a>注册后,首单充值可额外赠送 10% 额度(充值后请联系群主领取)!</td>
</tr>
</table>
</details>
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 116 KiB

+353
View File
@@ -0,0 +1,353 @@
# CC Switch v3.13.0
> Lightweight Mode, Quota & Balance Visibility, Provider Model Auto-Fetch, Codex OAuth Reverse Proxy, and Tray Per-App Submenus
**[中文版 →](v3.13.0-zh.md) | [日本語版 →](v3.13.0-ja.md)**
---
## Overview
CC Switch v3.13.0 is a major feature release centered on observability, provider workflow ergonomics, and proxy compatibility. It adds inline **quota and balance displays** across official Claude / Codex / Gemini providers plus Token Plan, Copilot, and third-party balance APIs; introduces a **Lightweight Mode** that keeps CC Switch running from the system tray without a main window; delivers **automatic model discovery** via OpenAI-compatible `/v1/models` across all five supported applications; ships a **Codex OAuth reverse proxy** for ChatGPT Plus / Pro subscribers; reorganizes the tray menu into **per-app submenus**; rebuilds the proxy forwarding stack on a **Hyper-based client**; and overhauls the **Skills workflow** with discovery, batch updates, and storage-location toggling. Additional improvements include full URL endpoint mode, session-log usage tracking without proxy interception, the Copilot interaction optimizer, a UTF-8 streaming chunk boundary fix for multi-byte output, and a Linux startup UI responsiveness fix.
**Release Date**: 2026-04-08
**Update Scale**: 98 commits | 229 files changed | +23,891 / -2,305 lines
---
## Highlights
- **Lightweight Mode**: Tray-only operating mode that destroys the main window on exit to tray and recreates it on demand, reducing CC Switch's desktop footprint to near zero when idle
- **Quota & Balance Visibility**: Inline quota or balance readout across provider cards — official Claude / Codex / Gemini subscriptions, GitHub Copilot premium interactions, Codex OAuth, Token Plan providers (Kimi / Zhipu GLM / MiniMax), plus official balance queries for DeepSeek, StepFun, SiliconFlow, OpenRouter, and Novita AI
- **Provider Model Auto-Fetch**: OpenAI-compatible `/v1/models` discovery across Claude, Codex, Gemini, OpenCode, and OpenClaw provider forms, with grouped dropdown selection and failure-specific error messages
- **Codex OAuth Reverse Proxy**: ChatGPT Plus / Pro Codex reverse proxy exposed as a new Claude provider card with managed OAuth login and inline subscription quota display ([⚠️ Risk Notice](#-risk-notice))
- **Tray Per-App Submenus**: Reworked the tray menu into per-application submenus so it never overflows the screen and background provider switching scales to dozens of providers per app
- **Skills Discovery & Batch Updates**: SHA-256-based skill update detection, per-skill and "Update All" batch actions, public `skills.sh` search integration, and a storage-location toggle between CC Switch storage and `~/.agents/skills`
- **Session Workflow Upgrades**: Batch session deletion, a directory picker before launching Claude terminal restore, usage import from Claude / Codex / Gemini session logs without proxy interception, precise Codex JSONL parsing, and per-app usage filtering
- **OpenCode / OpenClaw Stream Check Coverage**: OpenCode detection via npm package mapping, OpenClaw `openai-completions` support, and the remaining OpenClaw protocol variants — with custom-header passthrough and auth-header detection fixes
- **Full URL Endpoint Mode**: Provider option that treats `base_url` as a complete upstream endpoint, unblocking vendors that require nonstandard URL layouts
- **Hyper-based Proxy Forwarding Stack**: Refactored proxy forwarding onto a Hyper-based client with transparent header forwarding, improved endpoint rewriting, and better support for dynamic upstream endpoints
- **Copilot Interaction Optimizer**: Request classification and routing logic that reduces unnecessary GitHub Copilot premium interaction consumption
- **UTF-8 Stream Chunk Boundary Fix**: All four SSE streaming paths now preserve incomplete multi-byte UTF-8 sequences across TCP chunks, eliminating intermittent U+FFFD garbled output via the Copilot reverse proxy
- **Linux Startup UI Fix**: Fixed the long-standing issue where the window UI couldn't receive clicks on Linux until the user manually maximized and restored the window
---
## New Features
### Lightweight Mode
A tray-only operating mode that dramatically reduces CC Switch's desktop footprint when idle.
- Destroys the main window on exit-to-tray instead of hiding it, freeing UI resources and memory
- Recreates the window on demand when the user reopens CC Switch from the tray, a deeplink, or single-instance activation
- Integrated into every window-re-show path: normal startup, deeplink, single_instance, tray `show_main`, and the lightweight-exit round-trip
### Quota & Balance Visibility
Added inline quota and balance readouts to provider cards so users can see remaining capacity without leaving the card.
- **Official subscriptions**: Inline quota display for Claude, Codex, and Gemini official providers
- **GitHub Copilot**: Premium interactions quota display on the Copilot provider card
- **Codex OAuth**: ChatGPT Plus / Pro subscription quota inline with the Codex OAuth provider card
- **Token Plan providers**: Kimi, Zhipu GLM, and MiniMax with corrected quota math and consistent 0% → 100% usage progression
- **Third-party balances**: Official balance queries for DeepSeek, StepFun, SiliconFlow, OpenRouter, and Novita AI
- Health-check and usage-config buttons are hidden for official providers to keep the card clean
### Provider Model Auto-Fetch
Added OpenAI-compatible model discovery to every provider form, removing the manual copy-paste loop for model IDs.
- Queries the configured provider endpoint's `/v1/models`
- Groups models in the dropdown by category for easier selection
- Failure-specific error messages distinguish network / authentication / endpoint issues
- Supported across all five applications: Claude, Codex, Gemini, OpenCode, and OpenClaw
### Codex OAuth Reverse Proxy
Added a reverse proxy path for ChatGPT Plus / Pro subscribers who want to route their Codex OAuth session through CC Switch.
- Managed OAuth login flow with ChatGPT Plus / Pro authentication
- Surfaces as a new Claude provider card type alongside API-key providers
- Inline subscription quota display
- Integrated into the Auth Center with tightened copy, layout, and icon presentation
- Bumped the Codex OAuth preset to the GPT-5.4 model family
- See the [⚠️ Risk Notice](#-risk-notice) below before enabling
### Tray Per-App Submenus
Reorganized the tray menu so providers are grouped under each application instead of living in a flat list.
- Per-application submenus for Claude, Codex, Gemini, OpenCode, and OpenClaw
- Prevents the tray menu from overflowing the screen when users have many providers
- Background provider switching scales cleanly to long provider lists
### Skills Discovery & Batch Updates
Upgraded the Skills management panel into a complete discovery plus maintenance workflow.
- **SHA-256 update detection**: Skills are content-hashed so the UI knows exactly which ones have upstream changes
- **Per-skill and batch updates**: Individual "Update" buttons plus an animated "Update All" batch action
- **Storage-location toggle**: Switch between CC Switch storage and `~/.agents/skills` without losing skill state
- **Public registry search**: `skills.sh` search integrated directly into the dialog for discovering community skills
### Session Workflow Upgrades
Multiple session management improvements that reduce friction when working with Claude / Codex / Gemini sessions.
- **Batch session deletion**: Select and delete multiple sessions at once from Session Manager (#1693, thanks @Alexlangl)
- **Directory picker before restore**: Claude terminal restore now prompts for the working directory up front (#1752, thanks @yovinchen)
- **Usage from session logs without proxy**: Usage data imported directly from Claude / Codex / Gemini session logs — no proxy interception required
- **Precise Codex JSONL parsing**: Replaced estimated Codex usage with precise JSONL session-log parsing plus Codex model name normalization for consistent pricing lookup
- **Gemini CLI session log integration**: Gemini usage now syncs accurately from Gemini CLI session logs
- **Per-app usage filtering**: Filter the usage dashboard by Claude, Codex, or Gemini independently
### OpenCode / OpenClaw Stream Check Coverage
Extended the Stream Check panel to cover the full OpenCode and OpenClaw surface area.
- OpenCode detection via npm package mapping
- Support for the OpenClaw `openai-completions` protocol
- Support for the remaining three OpenClaw protocol variants
- Edge-case handling for custom-header passthrough, OpenClaw custom auth-header detection, Bedrock error messaging, and OpenCode default `baseURL` fallback
### Full URL Endpoint Mode
Added a provider option that treats `base_url` as a complete upstream endpoint instead of a base URL with path appending (#1561, thanks @yovinchen).
- Proxy forwarding and Stream Check both honor the full-URL mode
- Unblocks vendors that require nonstandard URL layouts
- Configurable per-provider on the provider form
### OpenCode StepFun Step Plan Preset
- Added a StepFun Step Plan provider preset for OpenCode with sensible defaults (#1668, thanks @sky-wang-salvation)
### Copilot Interaction Optimizer
Added request classification and routing logic that reduces unnecessary GitHub Copilot premium interaction consumption.
- Classifies incoming requests by intent and weight
- Routes low-value requests away from premium interaction consumption paths
- Designed to extend the usable lifetime of a Copilot subscription
---
## Changes
### Tray Menu Organization
- Reworked the tray menu into per-application submenus (Claude / Codex / Gemini / OpenCode / OpenClaw)
- Prevents overflow and scales to long provider lists
### Proxy Forwarding Stack
Rebuilt the proxy forwarding layer on a Hyper-based HTTP client (#1714, thanks @yovinchen).
- Transparent header forwarding: headers are forwarded without aggressive filtering
- Improved endpoint rewriting logic
- Better support for dynamic upstream endpoints
- Paired with the new Full URL Endpoint Mode to unblock vendors with nonstandard URL layouts
### OAuth Auth Center UI Polish
- Tightened the Auth Center copy, layout, and icon presentation so the Codex OAuth login flow feels cleaner and less cluttered
### Provider Key Lifecycle & Live Sync
Reworked the additive provider create / rename / duplicate flows so live config writes, cleanup, and rollback stay consistent across OpenCode / OpenClaw and takeover scenarios (#1724, thanks @yovinchen).
- Additive-mode highlight behavior made persistent across refreshes (#1747, thanks @yovinchen)
- Consistent live config writes across OpenCode / OpenClaw
- Rollback behavior preserved when operations fail
### Codex OAuth Defaults
- Updated the Codex OAuth preset to the GPT-5.4 model family
---
## Bug Fixes
### Copilot Authentication & Proxy Compatibility
- Fixed GitHub Copilot authentication regressions (#1854, thanks @Mason-mengze)
- Corrected enterprise and dynamic endpoint handling
- Repaired clipboard verification-code copying on macOS and Linux
- Fixed Responses routing when Copilot-backed Claude providers target OpenAI models (#1735, thanks @Mason-mengze)
### UTF-8 Stream Chunk Boundaries
Fixed intermittent garbled output (U+FFFD replacement characters) in Claude Code when multi-byte UTF-8 sequences such as Chinese characters and emoji were split across TCP stream chunks via the Copilot reverse proxy (#1923, thanks @Cod1ng).
- Replaced `String::from_utf8_lossy` with a new `append_utf8_safe` helper across all four SSE streaming paths
- Preserves incomplete trailing bytes in a remainder buffer and merges them with the next chunk before decoding
- Not reproducible with direct Copilot connections that pass through raw bytes without format conversion
### Fragmented System Prompt Normalization
Fixed strict OpenAI-compatible chat backends (Nvidia, Qwen-style) rejecting requests when converted Claude payloads contained multiple system messages (#1942, thanks @yovinchen).
- Normalized system content into a single leading system message during the Anthropic → OpenAI chat transformation
- Leaves the rest of the message stream unchanged
### Streaming Parser Compatibility
- Fixed SSE parsing to accept fields with optional spaces, improving compatibility with non-strict streaming implementations (#1664, thanks @Alexlangl)
### Provider Switch State Corruption
- Serialized per-app provider switches to prevent concurrent failover or hot-switch operations from leaving `is_current`, settings state, and live backup state out of sync
### Claude Takeover Live Config Drift
- Fixed provider edits while Claude takeover is active so live settings remain aligned with the latest provider state without breaking takeover restore behavior (#1828, thanks @geekdada)
### WebDAV Password Retention & Validation
- Fixed the WebDAV password field so saved credentials remain visible after refresh
- Treated `MKCOL 405` responses correctly during connection validation (#1685, thanks @Alexlangl)
### Provider Card Action States
- Fixed additive-mode highlight behavior (#1747, thanks @yovinchen)
- Aligned usage display layout across provider cards by always rendering action buttons
- Replaced hard proxy-switch blocking with a warning path
- Disabled unsupported test and usage actions for Copilot and Codex OAuth cards
- Hid usage-config and health-check buttons for official providers
- Removed the hover-push animation from provider cards
### Usage Accuracy & Pricing
- Fixed MiniMax quota math and 0% → 100% progression
- Corrected CNY → USD pricing plus missing model definitions
- Improved Gemini session-log syncing accuracy
- Resolved session-based usage entries being shown as unknown providers
### Usage Editor & Skills UI Regressions
- Fixed usage query fields being reset while editing extractor code (#1771, thanks @if-nil)
- Corrected broken `skills.sh` links and empty descriptions
- Fixed auto-query default interval (5 min) and number-input clearing in usage configuration
### Chinese Skills Terminology
- Unified Skills-related labels across settings panels in the `zh` locale so storage and sync options use consistent wording
### Environment & Preset Compatibility
- Added Bun global bin detection in CLI scan (#1742, thanks @makoMakoGo)
- Adapted to the oh-my-openagent rename with backward compatibility (#1746, thanks @yovinchen)
- Corrected the OpenCode `kimi-for-coding` preset (#1738, thanks @makoMakoGo)
- Gated Gemini keychain parsing to macOS only
- Fixed an OpenClaw serializer panic on empty collections (#1724, thanks @yovinchen)
### Linux UI Unresponsive on Startup
Fixed a long-standing Linux bug where the window UI (including native title bar buttons) couldn't receive clicks until the user manually maximized and restored the window.
- **Root causes**: (1) Tauri webview did not acquire keyboard focus after `show()` on Linux, so the first click was consumed by X11/Wayland click-to-activate (Tauri #10746, wry #637); (2) GTK surface's input region failed to renegotiate on the `visible:false → show()` path under some WebKitGTK/compositor combinations, leaving the entire window unresponsive
- **Mitigations**: Set `WEBKIT_DISABLE_COMPOSITING_MODE=1` at startup, and added a new `linux_fix::nudge_main_window` helper that performs `set_focus` + a ±1px no-op resize ~200ms after show, equivalent to a visually invisible "maximize-and-restore"
- **Coverage**: Wired into all window-re-show paths — normal startup, deeplink, single_instance, tray `show_main`, and lightweight-mode exit
### Linux Drag Region on Header
- Removed `data-tauri-drag-region` from the top header bar on Linux to avoid triggering `gtk_window_begin_move_drag` paths affected by Tauri #13440 under Wayland
- macOS drag behavior is preserved
### OpenCode / OpenClaw Stream Check Edge Cases
- Fixed custom-header passthrough
- OpenClaw custom auth-header detection
- Bedrock error messaging
- OpenCode default `baseURL` fallback handling
---
## Documentation
### User Manual Refresh
- Updated the EN / ZH / JA user manuals to cover tray submenus, lightweight mode, provider model fetching, session management, workspace files, WebDAV v2 behavior, OpenCode / OpenClaw activation, and other provider workflow improvements
### Community & Contribution Docs
- Added `CONTRIBUTING.md`, `SECURITY.md`, and `CODE_OF_CONDUCT.md`
- Added bilingual GitHub issue and PR templates
- Added Dependabot configuration (#1829, thanks @bengbengbalabalabeng) and a stale-bot workflow for inactive issues
- Added a PR / push quality-checks CI workflow
### Release Notes Risk Notice Backport
- Added a Copilot reverse proxy risk notice and anchored highlight links in the v3.12.3 release notes across all three languages
---
## ⚠️ Risk Notice
**Codex OAuth Reverse Proxy Disclaimer**
The Codex OAuth reverse proxy introduced in this release accesses ChatGPT Plus / Pro Codex services through reverse-engineered OAuth flows. Please be aware of the following risks before enabling this feature:
1. **Terms of Service**: Using reverse-engineered OAuth flows to access OpenAI services may violate OpenAI's terms of service, which prohibit unauthorized automated access, service reproduction, and circumventing intended access paths.
2. **Account Risk**: OpenAI may flag unusual usage patterns as suspicious automated activity, potentially resulting in temporary or permanent restrictions on ChatGPT Plus / Pro access.
3. **No Guarantee**: OpenAI may update its authentication and detection mechanisms at any time, and usage patterns that work today may be flagged in the future.
The **GitHub Copilot reverse proxy** introduced in v3.12.3 also remains subject to its existing risk notice — see the [v3.12.3 release notes](v3.12.3-en.md#-risk-notice) for the full disclosure.
Users enable these features **at their own risk**. CC Switch is not responsible for any account restrictions, warnings, or service suspensions resulting from the use of these features.
---
## Download & Installation
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
### System Requirements
| System | Minimum Version | Architecture |
| ------- | ------------------------------- | ----------------------------------- |
| Windows | Windows 10 or later | x64 |
| macOS | macOS 12 (Monterey) or later | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 |
### Windows
| File | Description |
| ------------------------------------------ | ---------------------------------------------------- |
| `CC-Switch-v3.13.0-Windows.msi` | **Recommended** - MSI installer with auto-update |
| `CC-Switch-v3.13.0-Windows-Portable.zip` | Portable version, extract and run, no registry write |
### macOS
| File | Description |
| ---------------------------------- | -------------------------------------------------------------------- |
| `CC-Switch-v3.13.0-macOS.dmg` | **Recommended** - DMG installer, drag to Applications, Universal Binary |
| `CC-Switch-v3.13.0-macOS.zip` | ZIP archive, extract and drag to Applications, Universal Binary |
| `CC-Switch-v3.13.0-macOS.tar.gz` | For Homebrew installation and auto-update |
> macOS builds are code-signed and notarized by Apple for a seamless install experience.
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
### Linux
| Distribution | Recommended Format | Installation Method |
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | Add execute permission and run directly, or use AUR |
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+353
View File
@@ -0,0 +1,353 @@
# CC Switch v3.13.0
> 軽量モード、クォータ・残高の可視化、プロバイダーモデル自動取得、Codex OAuth リバースプロキシ、トレイのアプリ別サブメニュー
**[中文版 →](v3.13.0-zh.md) | [English →](v3.13.0-en.md)**
---
## 概要
CC Switch v3.13.0 は、可観測性、プロバイダーワークフローの使いやすさ、プロキシ互換性を中心とした大型機能リリースです。Claude / Codex / Gemini の公式プロバイダー、Token Plan、Copilot、サードパーティ残高 API にわたる**クォータと残高のインライン表示**を追加し、メインウィンドウなしでシステムトレイから CC Switch を動作させる**軽量モード**を導入しました。OpenAI 互換の `/v1/models` による**自動モデル発見**を 5 つのサポート対象アプリケーションすべてに提供し、ChatGPT Plus / Pro サブスクライバー向けの **Codex OAuth リバースプロキシ**を同梱しています。トレイメニューを**アプリ別サブメニュー**に再編成し、プロキシ転送スタックを **Hyper ベースのクライアント**に再構築し、**Skills ワークフロー**を発見、バッチ更新、ストレージ位置切り替えで刷新しました。さらに、フル URL エンドポイントモード、プロキシ傍受なしのセッションログ用量追跡、Copilot インタラクション最適化、マルチバイト UTF-8 ストリームチャンク境界修正、Linux 起動時の UI 応答性修正なども含まれます。
**リリース日**: 2026-04-08
**更新規模**: 98 commits | 229 files changed | +23,891 / -2,305 lines
---
## ハイライト
- **軽量モード**: トレイ専用の動作モード。トレイへの終了時にメインウィンドウを破棄し、必要時に再作成することで、アイドル時の CC Switch のデスクトップフットプリントを最小化
- **クォータと残高の可視化**: プロバイダーカードでのインラインクォータ/残高表示 — Claude / Codex / Gemini 公式サブスクリプション、GitHub Copilot premium interactions、Codex OAuth、Token Plan プロバイダー(Kimi / Zhipu GLM / MiniMax)、および DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI の公式残高クエリをカバー
- **プロバイダーモデル自動取得**: Claude / Codex / Gemini / OpenCode / OpenClaw のプロバイダーフォームに OpenAI 互換の `/v1/models` 発見機能を追加。グループ化ドロップダウンと失敗時の具体的なエラーメッセージ付き
- **Codex OAuth リバースプロキシ**: ChatGPT Plus / Pro の Codex リバースプロキシを新しい Claude プロバイダーカードタイプとして追加。マネージド OAuth ログインとサブスクリプションクォータのインライン表示を提供([⚠️ リスクに関する注意事項](#-リスクに関する注意事項)
- **トレイのアプリ別サブメニュー**: トレイメニューをアプリ別サブメニューに再編成し、プロバイダー数が多くてもメニューがオーバーフローせず、バックグラウンドのプロバイダー切り替えが長いリストでもスケール
- **Skills 発見とバッチ更新**: SHA-256 ベースの skill 更新検出、各 skill および「すべて更新」のバッチ更新、公開 `skills.sh` 検索統合、CC Switch ストレージと `~/.agents/skills` の間のストレージ位置切り替え
- **セッションワークフローの改善**: Session Manager でのバッチ削除、Claude ターミナル復元前のディレクトリピッカー、プロキシ傍受なしでの Claude / Codex / Gemini セッションログからの用量インポート、正確な Codex JSONL 解析、アプリ別の用量フィルタリング
- **OpenCode / OpenClaw Stream Check カバレッジ**: OpenCode の npm パッケージマッピング検出、OpenClaw `openai-completions` サポート、および残りの OpenClaw プロトコルバリアント
- **フル URL エンドポイントモード**: `base_url` を完全な上流エンドポイントとして扱うプロバイダーオプションを追加し、非標準 URL レイアウトを要求するベンダーに対応
- **Hyper ベースのプロキシ転送スタック**: プロキシ転送層を Hyper ベースのクライアントに再構築し、透過的なヘッダー転送、改善されたエンドポイントリライト、および動的上流エンドポイントのサポートを強化
- **Copilot インタラクション最適化**: GitHub Copilot premium interaction の不要な消費を削減するリクエスト分類とルーティングロジックを追加
- **UTF-8 ストリームチャンク境界修正**: マルチバイト UTF-8 シーケンスが TCP チャンクを跨いで分割された際の Copilot リバースプロキシ経由での文字化け(U+FFFD 置換文字)を解消するため、すべての 4 つの SSE ストリーミングパスを修正
- **Linux 起動時 UI 修正**: ユーザーが手動でウィンドウを最大化・復元するまでウィンドウ UI がクリックを受け付けない長年の問題を修正
---
## 新機能
### 軽量モード
CC Switch のアイドル時のデスクトップフットプリントを大幅に削減するトレイ専用動作モード。
- トレイへの終了時にメインウィンドウを隠すのではなく破棄し、UI リソースとメモリを解放
- トレイ、ディープリンク、またはシングルインスタンスアクティベーションからユーザーが CC Switch を再オープンしたときにウィンドウを再作成
- 通常起動、ディープリンク、シングルインスタンス、トレイ `show_main`、軽量モード終了など、すべてのウィンドウ再表示パスに統合
### クォータと残高の可視化
プロバイダーカードにクォータと残高の表示を追加し、カードから離れずに残容量を確認できるようにしました。
- **公式サブスクリプション**: Claude / Codex / Gemini 公式プロバイダーのサブスクリプションクォータ表示
- **GitHub Copilot**: Copilot プロバイダーカードに premium interactions 残量を表示
- **Codex OAuth**: Codex OAuth カードに ChatGPT Plus / Pro サブスクリプションクォータをインライン表示
- **Token Plan プロバイダー**: Kimi、Zhipu GLM、MiniMax。クォータ計算と 0% → 100% 使用量進行を修正
- **サードパーティ残高**: DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI に公式残高クエリを追加
- 公式プロバイダーではヘルスチェックと用量設定ボタンを非表示にし、カードをクリーンに保つ
### プロバイダーモデル自動取得
すべてのプロバイダーフォームに OpenAI 互換のモデル発見機能を追加し、モデル ID の手動コピー&ペーストを不要に。
- 設定された API キーを使ってプロバイダーの `/v1/models` エンドポイントをクエリ
- ドロップダウンでモデルをカテゴリ別にグループ化
- ネットワーク / 認証 / エンドポイント未検出 / パース失敗を区別する具体的なエラーメッセージを提供
- 5 つのアプリケーション(Claude / Codex / Gemini / OpenCode / OpenClaw)すべてをサポート
### Codex OAuth リバースプロキシ
ChatGPT Plus / Pro のサブスクライバーが Codex OAuth セッションを CC Switch 経由で利用できるリバースプロキシパスを追加。
- ChatGPT Plus / Pro 認証を使ったマネージド OAuth ログインフロー
- API キー型プロバイダーと並ぶ新しい Claude プロバイダーカードタイプとして表示
- サブスクリプションクォータのインライン表示
- Auth Center との緊密な統合と、コピー・レイアウト・アイコンの調整
- Codex OAuth プリセットを GPT-5.4 モデルファミリーに更新
- 有効化前に下記の [⚠️ リスクに関する注意事項](#-リスクに関する注意事項) をご確認ください
### トレイのアプリ別サブメニュー
トレイメニューを、フラットリストの代わりにアプリケーション別にプロバイダーをグループ化する構造に再編成しました。
- Claude / Codex / Gemini / OpenCode / OpenClaw のアプリ別サブメニュー
- プロバイダーが多い場合にトレイメニューが画面からはみ出すことを防止
- バックグラウンドのプロバイダー切り替えが長いリストでもクリーンにスケール
### Skills 発見とバッチ更新
Skills 管理パネルを、発見と保守を備えた完全なワークフローにアップグレード。
- **SHA-256 更新検出**: Skill をコンテンツハッシュ化することで、どれが上流で変更されたかを UI が正確に把握
- **各 skill およびバッチ更新**: 個別の「更新」ボタンと、スライドインアニメーション付きの「すべて更新」バッチアクション
- **ストレージ位置切り替え**: CC Switch ストレージと `~/.agents/skills` の間を skill 状態を失わずに切り替え
- **公開レジストリ検索**: `skills.sh` 検索をダイアログに直接統合し、コミュニティ skill を発見しやすく
### セッションワークフローの改善
Claude / Codex / Gemini セッションでの作業を効率化する複数のセッション管理改善。
- **セッションのバッチ削除**: Session Manager で複数のセッションを選択し、1 つのアクションで削除 (#1693, @Alexlangl に感謝)
- **復元前のディレクトリピッカー**: Claude ターミナルの復元時、事前に作業ディレクトリを選択 (#1752, @yovinchen に感謝)
- **プロキシなしのセッションログ用量**: Claude / Codex / Gemini セッションログから直接用量データをインポート — プロキシ傍受は不要
- **正確な Codex JSONL 解析**: Codex の推定用量を、JSONL セッションログの正確な解析に置き換え。Codex モデル名の正規化により料金ルックアップが一貫
- **Gemini CLI セッションログ統合**: Gemini 用量が Gemini CLI セッションログから正確に同期
- **アプリ別の用量フィルタリング**: 用量ダッシュボードを Claude / Codex / Gemini ごとに独立してフィルタリング可能
### OpenCode / OpenClaw Stream Check カバレッジ
Stream Check パネルのカバレッジを OpenCode と OpenClaw のサーフェス全体に拡張。
- npm パッケージマッピングによる OpenCode 検出
- OpenClaw `openai-completions` プロトコルのサポート
- 残りの 3 つの OpenClaw プロトコルバリアントのサポート
- カスタムヘッダー透過、OpenClaw カスタム auth-header 検出、Bedrock エラーメッセージ、OpenCode デフォルト `baseURL` フォールバックのエッジケース処理
### フル URL エンドポイントモード
`base_url` をパス付加を伴わない完全な上流エンドポイントとして扱うプロバイダーオプションを追加 (#1561, @yovinchen に感謝)。
- プロキシ転送と Stream Check の両方がフル URL モードに対応
- 非標準 URL レイアウトを要求するベンダーをアンブロック
- プロバイダーフォームでプロバイダー単位で設定可能
### OpenCode StepFun Step Plan プリセット
- OpenCode 向けに StepFun Step Plan プロバイダープリセットと適切なデフォルト値を追加 (#1668, @sky-wang-salvation に感謝)
### Copilot インタラクション最適化
GitHub Copilot premium interaction の不要な消費を削減するリクエスト分類とルーティングロジックを追加。
- 受信リクエストを意図と重要度で分類
- 価値の低いリクエストを premium interaction 消費パスから迂回
- Copilot サブスクリプションの使用可能期間を延長することを目的
---
## 変更
### トレイメニュー構成
- トレイメニューをアプリ別サブメニュー(Claude / Codex / Gemini / OpenCode / OpenClaw)に再編成
- オーバーフローを防ぎ、長いプロバイダーリストでもスケール
### プロキシ転送スタック
プロキシ転送層を Hyper ベースの HTTP クライアント上に再構築 (#1714, @yovinchen に感謝)。
- 透過的なヘッダー転送: ヘッダーをアグレッシブにフィルタせずに転送
- 改善されたエンドポイントリライトロジック
- 動的上流エンドポイントへのより良いサポート
- 新しいフル URL エンドポイントモードと組み合わせ、非標準 URL レイアウトのベンダーをアンブロック
### OAuth Auth Center UI 調整
- Auth Center のコピー、レイアウト、アイコンの表現を調整し、Codex OAuth ログインフローをよりクリーンに
### プロバイダーキーライフサイクルと Live 同期
アディティブプロバイダーの作成/名前変更/複製フローを再構築し、OpenCode / OpenClaw およびテイクオーバーシナリオで Live 設定の書き込み、クリーンアップ、ロールバックが一貫するように (#1724, @yovinchen に感謝)。
- アディティブモードのハイライト動作がリフレッシュ後も保持 (#1747, @yovinchen に感謝)
- OpenCode / OpenClaw 全体で Live 設定の書き込みが一貫
- 操作失敗時のロールバック動作を保持
### Codex OAuth デフォルト
- Codex OAuth プリセットを GPT-5.4 モデルファミリーに更新
---
## バグ修正
### Copilot 認証とプロキシ互換性
- GitHub Copilot 認証の回帰を修正 (#1854, @Mason-mengze に感謝)
- エンタープライズおよび動的エンドポイントの処理を修正
- macOS と Linux でのクリップボード検証コードコピーを修復
- Copilot バックの Claude プロバイダーが OpenAI モデルをターゲットとする場合の Responses ルーティングを修正 (#1735, @Mason-mengze に感謝)
### UTF-8 ストリームチャンク境界
Claude Code で Copilot リバースプロキシ経由時、中国語文字や絵文字などのマルチバイト UTF-8 シーケンスが TCP ストリームチャンクを跨いで分割される際の文字化け(U+FFFD 置換文字)を修正 (#1923, @Cod1ng に感謝)。
- すべての 4 つの SSE ストリーミングパスで `String::from_utf8_lossy` を新しい `append_utf8_safe` ヘルパーに置き換え
- 不完全な末尾バイトを残余バッファで保持し、次のチャンクとマージしてからデコード
- 直接の Copilot 接続では再現しない(フォーマット変換なしで生バイトを通すため)
### フラグメント System Prompt の正規化
厳格な OpenAI 互換 chat バックエンド(Nvidia、Qwen 系)が変換後の Claude ペイロードに複数の system メッセージを含む場合にリクエストを拒否する問題を修正 (#1942, @yovinchen に感謝)。
- Anthropic → OpenAI chat 変換時に、system コンテンツを単一の先頭 system メッセージに正規化
- メッセージストリームの残りは変更なし
### ストリーミングパーサー互換性
- オプションのスペースを含むフィールドを受け入れるよう SSE パースを修正し、非厳格なストリーミング実装との互換性を向上 (#1664, @Alexlangl に感謝)
### プロバイダー切り替え状態の破損
- アプリごとのプロバイダー切り替えを直列化し、並行フェイルオーバーやホットスイッチ操作が `is_current`、設定状態、Live バックアップ状態を不整合状態のままにすることを防止
### Claude テイクオーバー Live 設定のドリフト
- Claude テイクオーバーが有効な間のプロバイダー編集で、Live 設定が最新のプロバイダー状態と整合を保つようにし、テイクオーバー復元動作を壊さない (#1828, @geekdada に感謝)
### WebDAV パスワード保持と検証
- 保存済みの WebDAV パスワードがリフレッシュ後も表示されるように修正
- 接続検証時に `MKCOL 405` レスポンスを正しく処理 (#1685, @Alexlangl に感謝)
### プロバイダーカードのアクション状態
- アディティブモードのハイライト動作を修正 (#1747, @yovinchen に感謝)
- アクションボタンを常にレンダリングすることでプロバイダーカード全体の用量表示レイアウトを整列
- ハードなプロキシ切り替えブロッキングを警告パスに置き換え
- Copilot および Codex OAuth カードでサポートされていないテスト/用量アクションを無効化
- 公式プロバイダーでは用量設定とヘルスチェックのボタンを非表示
- プロバイダーカードのホバープッシュアニメーションを削除
### 用量精度と料金
- MiniMax クォータの計算と 0% → 100% 進行を修正
- CNY → USD の料金を修正し、不足モデルを追加
- Gemini セッションログ同期の精度を改善
- セッションベースの用量エントリが「不明なプロバイダー」として表示される問題を解決
### 用量エディタと Skills UI の回帰
- エクストラクタコード編集時に用量クエリフィールドがリセットされる問題を修正 (#1771, @if-nil に感謝)
- 壊れた `skills.sh` リンクと空の説明を修正
- 用量設定の auto-query デフォルト間隔(5 分)と数値入力のクリア問題を修正
### 中国語 Skills 用語
- zh ロケールの設定パネルで Skills 関連ラベルを統一し、ストレージと同期オプションで一貫した表現を使用
### 環境とプリセット互換性
- CLI スキャンで Bun グローバル bin 検出を追加 (#1742, @makoMakoGo に感謝)
- oh-my-openagent のリネームに後方互換性を持って対応 (#1746, @yovinchen に感謝)
- OpenCode `kimi-for-coding` プリセットを修正 (#1738, @makoMakoGo に感謝)
- Gemini キーチェーン解析を macOS のみに制限
- 空コレクションで発生する OpenClaw シリアライザのパニックを修正 (#1724, @yovinchen に感謝)
### Linux 起動時の UI 応答性
ユーザーが手動でウィンドウを最大化・復元するまで、ウィンドウ UI(ネイティブタイトルバーボタンを含む)がクリックを受け付けないという長年の Linux 固有のバグを修正。
- **根本原因**: (1) Tauri webview が Linux の `show()` 後にキーボードフォーカスを取得せず、最初のクリックが X11/Wayland の click-to-activate によって消費される(Tauri #10746、wry #637; (2) GTK surface の入力領域が、一部の WebKitGTK/コンポジター組み合わせで `visible:false → show()` パスの再交渉に失敗し、ウィンドウ全体が応答しなくなる
- **緩和策**: 起動時に `WEBKIT_DISABLE_COMPOSITING_MODE=1` を設定し、新しい `linux_fix::nudge_main_window` ヘルパーを追加。show から ~200ms 後に `set_focus` + ±1px のノーオペレーションリサイズを実行し、視覚的に見えない「最大化と復元」と同等の動作を実現
- **カバレッジ**: すべてのウィンドウ再表示パス(通常起動、ディープリンク、シングルインスタンス、トレイ `show_main`、軽量モード終了)に統合
### Linux ヘッダーのドラッグ領域
- Wayland 下で Tauri #13440 の影響を受ける `gtk_window_begin_move_drag` パスのトリガーを回避するため、Linux ではトップヘッダーバーから `data-tauri-drag-region` を削除
- macOS のドラッグ動作は保持
### OpenCode / OpenClaw Stream Check のエッジケース
- カスタムヘッダー透過を修正
- OpenClaw カスタム auth-header 検出を修正
- Bedrock エラーメッセージを修正
- OpenCode デフォルト `baseURL` のフォールバック処理を修正
---
## ドキュメント
### ユーザーマニュアルの刷新
- EN / ZH / JA ユーザーマニュアルで、トレイサブメニュー、軽量モード、プロバイダーモデル取得、セッション管理、ワークスペースファイル、WebDAV v2 の動作、OpenCode / OpenClaw の有効化、その他のプロバイダーワークフロー改善を更新
### コミュニティと貢献ドキュメント
- `CONTRIBUTING.md``SECURITY.md``CODE_OF_CONDUCT.md` を追加
- バイリンガル GitHub issue および PR テンプレートを追加
- Dependabot 設定 (#1829, @bengbengbalabalabeng に感謝) と、非アクティブな issue を自動クローズする stale-bot ワークフローを追加
- PR / push 品質チェック CI ワークフローを追加
### Release Notes のリスク通知バックポート
- v3.12.3 の release notes に Copilot リバースプロキシのリスク通知とハイライトリンクのアンカーを 3 言語すべてに追加
---
## ⚠️ リスクに関する注意事項
**Codex OAuth リバースプロキシに関する免責事項**
本リリースで追加された Codex OAuth リバースプロキシ機能は、リバースエンジニアリングによる OAuth フローを通じて ChatGPT Plus / Pro の Codex サービスにアクセスします。この機能を有効にする前に、以下のリスクをご確認ください:
1. **利用規約違反の可能性**: リバースエンジニアリングされた OAuth フローを使用して OpenAI サービスにアクセスすることは、OpenAI の利用規約に違反する可能性があります。これらの規約では、未承認の自動アクセス、サービス複製、および意図されたアクセスパスの回避が禁止されています。
2. **アカウントリスク**: OpenAI は異常な使用パターンを疑わしい自動化活動としてフラグ付けし、ChatGPT Plus / Pro へのアクセスに一時的または永久的な制限を科す可能性があります。
3. **将来の利用保証なし**: OpenAI は認証および検出メカニズムをいつでも更新する可能性があり、現在動作する使用パターンが将来的にフラグ付けされる可能性があります。
v3.12.3 で導入された **GitHub Copilot リバースプロキシ**も、既存のリスク通知の対象となります — 詳細は [v3.12.3 リリースノート](v3.12.3-ja.md#-リスクに関する注意事項) を参照してください。
これらの機能を有効にすることで、ユーザーは**すべてのリスクを自己責任で負う**ものとします。CC Switch は、これらの機能の使用に起因するアカウント制限、警告、またはサービス停止について一切の責任を負いません。
---
## ダウンロードとインストール
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
### システム要件
| システム | 最小バージョン | アーキテクチャ |
| -------- | -------------------------------- | ----------------------------------- |
| Windows | Windows 10 以降 | x64 |
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 下表参照 | x64 |
### Windows
| ファイル | 説明 |
| ------------------------------------------ | ---------------------------------------------------- |
| `CC-Switch-v3.13.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
| `CC-Switch-v3.13.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
### macOS
| ファイル | 説明 |
| ---------------------------------- | ----------------------------------------------------------------- |
| `CC-Switch-v3.13.0-macOS.dmg` | **推奨** - DMG インストーラー、ドラッグ&ドロップでインストール |
| `CC-Switch-v3.13.0-macOS.zip` | 解凍して Applications にドラッグ、Universal Binary |
| `CC-Switch-v3.13.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
> macOS 版は Apple のコード署名と公証済みで、そのままインストールしてご利用いただけます。
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| ディストリビューション | 推奨形式 | インストール方法 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用 |
| その他のディストリビューション / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+354
View File
@@ -0,0 +1,354 @@
# CC Switch v3.13.0
> 轻量模式、配额与余额展示、供应商模型自动获取、Codex OAuth 反向代理、托盘按应用分级菜单
**[English →](v3.13.0-en.md) | [日本語版 →](v3.13.0-ja.md)**
---
## 概览
CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供应商工作流与代理兼容性。本版本在各主要供应商卡片上新增了**配额与余额展示**,覆盖 Claude / Codex / Gemini 官方订阅、Token PlanKimi / Zhipu GLM / MiniMax)、Copilot premium interactions 以及 DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI 等第三方余额查询;引入了**轻量模式**,让 CC Switch 可以仅驻留在系统托盘中运行;通过 OpenAI 兼容的 `/v1/models` 端点在 Claude / Codex / Gemini / OpenCode / OpenClaw 五个应用的供应商表单中实现了**模型自动发现**;为 ChatGPT Plus / Pro 订阅者提供了 **Codex OAuth 反向代理**;将托盘菜单重构为**按应用分级的子菜单**;将代理转发层重建在 **Hyper 客户端**之上;并完成了 **Skills 工作流**的发现、批量更新和存储位置切换改造。其他改进还包括完整 URL 端点模式、无需代理拦截的会话日志用量追踪、Copilot 调用优化器、多字节 UTF-8 流式分片边界修复以及 Linux 启动时 UI 无响应修复等。
**发布日期**2026-04-08
**更新规模**98 commits | 229 files changed | +23,891 / -2,305 lines
---
## 重点内容
- **轻量模式**:新增仅托盘运行模式,退出到托盘时销毁主窗口、按需重建,空闲时资源占用接近零
- **配额与余额展示**:供应商卡片上直接展示配额或余额 —— 覆盖 Claude / Codex / Gemini 官方订阅、GitHub Copilot premium interactions、Codex OAuth、Token PlanKimi / Zhipu GLM / MiniMax),以及 DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI 的官方余额查询
- **供应商模型自动获取**:为 Claude / Codex / Gemini / OpenCode / OpenClaw 的供应商表单新增 OpenAI 兼容的 `/v1/models` 发现能力,按分组下拉展示并提供针对性错误提示
- **Codex OAuth 反向代理**:新增 ChatGPT Plus / Pro 的 Codex 反向代理,作为新的 Claude 供应商卡片类型,包含受管 OAuth 登录流程和订阅配额展示([⚠️ 风险提示](#-风险提示)
- **托盘按应用分级菜单**:将托盘菜单重构为按应用分组的子菜单,防止供应商多时菜单溢出,让后台切换供应商在大量供应商场景下仍可用
- **Skills 发现与批量更新**:基于 SHA-256 内容哈希的更新检测、单项和"全部更新"批量操作、`skills.sh` 公共注册表搜索集成,以及 CC Switch 与 `~/.agents/skills` 的存储位置切换
- **会话工作流升级**:会话管理器批量删除、Claude 终端恢复前的目录选择器、无需代理拦截即可导入 Claude / Codex / Gemini 会话日志用量、精确的 Codex JSONL 解析、按应用筛选用量面板
- **OpenCode / OpenClaw 流式检测覆盖**:新增 OpenCode 的 npm 包映射检测、OpenClaw `openai-completions` 支持,以及其余所有 OpenClaw 协议变体
- **完整 URL 端点模式**:新增将 `base_url` 视作完整上游端点的供应商选项,支持非标准 URL 布局的厂商
- **Hyper 代理转发栈**:将代理转发层重构到 Hyper 客户端之上,实现透明头部转发、改进的端点重写以及对动态上游端点的更好支持
- **Copilot 调用优化器**:新增请求分类和路由逻辑,降低 GitHub Copilot premium interaction 的不必要消耗
- **UTF-8 流式分片边界修复**:所有 4 条 SSE 流式路径改为跨分片保留残留多字节序列,消除 Copilot 反代下中文/emoji 乱码
- **Linux 启动 UI 修复**:修复长期存在的 Linux 窗口初次无法响应点击、需用户手动最大化再还原才能操作的问题
---
## 新功能
### 轻量模式
新增仅托盘运行模式,显著降低 CC Switch 空闲时的桌面占用。
- 退出到托盘时销毁主窗口而非隐藏,释放 UI 资源和内存
- 用户从托盘、深链接或单例激活时按需重建窗口
- 覆盖所有窗口重新显示路径:正常启动、深链接、单例、托盘 `show_main` 以及轻量模式退出返程
### 配额与余额展示
在供应商卡片上新增配额和余额读数,用户无需离开卡片即可查看剩余容量。
- **官方订阅**Claude / Codex / Gemini 官方供应商的订阅配额展示
- **GitHub Copilot**:在 Copilot 供应商卡片上显示 premium interactions 剩余量
- **Codex OAuth**:在 Codex OAuth 卡片上内联展示 ChatGPT Plus / Pro 订阅配额
- **Token Plan 供应商**Kimi、Zhipu GLM、MiniMax,修正配额数学与 0% → 100% 用量进度
- **第三方余额**:为 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 提供官方余额查询
- 官方供应商的健康检查和用量配置按钮自动隐藏,保持卡片简洁
### 供应商模型自动获取
为所有供应商表单新增 OpenAI 兼容的模型发现能力,消除手动复制粘贴模型 ID 的繁琐流程。
- 使用配置的 API key 向供应商的 `/v1/models` 端点发起请求
- 在下拉菜单中按类别分组展示模型
- 对网络 / 认证 / 端点不存在 / 解析失败等场景提供具体错误消息
- 支持全部五个应用(Claude / Codex / Gemini / OpenCode / OpenClaw
### Codex OAuth 反向代理
新增 ChatGPT Plus / Pro 订阅者的 Codex OAuth 反向代理路径,让 ChatGPT 订阅者可以在 Claude Code 中使用自己的订阅。
- 受管 OAuth 登录流程,通过 ChatGPT Plus / Pro 认证
- 作为新的 Claude 供应商卡片类型出现在列表中,与 API-key 型供应商并列
- 订阅配额内联展示
- 与 Auth Center UI 紧密集成,统一管理 Token
- Codex OAuth 预设升级到 GPT-5.4 系列
- 启用前请参见下文的 [⚠️ 风险提示](#-风险提示)
### 托盘按应用分级菜单
将托盘菜单重构为按应用分组的子菜单,取代原来的扁平列表。
- 为 Claude / Codex / Gemini / OpenCode / OpenClaw 分别建立独立的子菜单
- 防止用户有大量供应商时托盘菜单溢出屏幕
- 后台切换供应商的可扩展性更好
### Skills 发现与批量更新
将 Skills 管理面板升级为完整的发现 + 维护工作流。
- **SHA-256 更新检测**:通过内容哈希判断哪些 skill 在远端有更新
- **单项与批量更新**:单项"更新"按钮 + 带滑入动画的"全部更新"批量操作
- **存储位置切换**:在 CC Switch 存储和 `~/.agents/skills` 之间切换而不丢失 skill 状态
- **公共注册表搜索**:将 `skills.sh` 搜索直接集成到对话框中,方便发现社区 skill
### 会话工作流升级
多项会话管理改进,降低使用 Claude / Codex / Gemini 会话时的摩擦。
- **批量删除会话**:在会话管理器中选择并一次删除多个会话 (#1693, 感谢 @Alexlangl)
- **恢复前目录选择器**:Claude 终端恢复前先选择工作目录 (#1752, 感谢 @yovinchen)
- **无需代理的会话日志用量**:直接从 Claude / Codex / Gemini 会话日志导入用量数据,无需代理拦截
- **精确的 Codex JSONL 解析**:替换 Codex 的估算用量为基于 JSONL 会话日志的精确解析,同时对模型名称做归一化以保证定价查询一致性
- **Gemini CLI 会话日志集成**Gemini 用量现在从 Gemini CLI 会话日志精确同步
- **按应用筛选用量**:用量面板可按 Claude / Codex / Gemini 独立筛选
### OpenCode / OpenClaw 流式检测覆盖
将 Stream Check 面板的覆盖范围扩展到 OpenCode 和所有 OpenClaw 协议变体。
- 通过 npm 包映射检测 OpenCode 供应商
- 支持 OpenClaw `openai-completions` 协议
- 支持剩余的三个 OpenClaw 协议变体
- 针对自定义头透传、OpenClaw 自定义 auth-header 检测、Bedrock 错误消息、OpenCode 默认 `baseURL` 回退等边界情况进行了处理
### 完整 URL 端点模式
新增将 `base_url` 视作完整上游端点的供应商选项,取代原有的 base-URL 加路径拼接模式 (#1561, 感谢 @yovinchen)。
- 代理转发和 Stream Check 都会遵循完整 URL 模式
- 解锁需要非标准 URL 布局的厂商
- 可在供应商表单中按供应商配置
### OpenCode StepFun Step Plan 预设
- 为 OpenCode 新增 StepFun Step Plan 供应商预设及合理默认值 (#1668, 感谢 @sky-wang-salvation)
### Copilot 调用优化器
新增请求分类和路由逻辑,降低 GitHub Copilot premium interaction 的不必要消耗。
- 根据请求意图和权重进行分类
- 将低价值请求路由到非 premium 通道
- 旨在延长 Copilot 订阅的可用时长
---
## 变更
### 托盘菜单组织
- 将托盘菜单重构为按应用分级的子菜单(Claude / Codex / Gemini / OpenCode / OpenClaw
- 防止菜单溢出,支持大量供应商的场景
### 代理转发栈
将代理转发层重建在 Hyper HTTP 客户端之上 (#1714, 感谢 @yovinchen)。
- 透明头部转发:头部透传,不做激进过滤
- 改进的端点重写逻辑
- 更好地支持动态上游端点
- 与新的"完整 URL 端点模式"配合,解锁非标准 URL 布局的厂商
### OAuth Auth Center UI 精修
- 精修 Auth Center 的文案、布局和图标呈现,让 Codex OAuth 登录流程更清爽
### 供应商键生命周期与 Live 同步
重做了新增模式供应商的创建/重命名/复制流程,让 Live 配置写入、清理和回滚在 OpenCode / OpenClaw 与接管场景下保持一致 (#1724, 感谢 @yovinchen)。
- 新增模式高亮行为在刷新后依旧保持 (#1747, 感谢 @yovinchen)
- OpenCode / OpenClaw 的 Live 配置写入保持一致
- 失败时正确回滚,避免半提交状态
### Codex OAuth 默认值
- Codex OAuth 预设升级到 GPT-5.4 系列
---
## Bug 修复
### Copilot 认证与代理兼容性
- 修复 GitHub Copilot 认证回归问题 (#1854, 感谢 @Mason-mengze)
- 修正企业版和动态端点处理
- 修复 macOS 和 Linux 上的剪贴板验证码复制问题
- 修复 Copilot 作为 Claude 供应商时 OpenAI 模型的 Responses 分流 (#1735, 感谢 @Mason-mengze)
### UTF-8 流式分片边界
修复 Claude Code 在 Copilot 反代下,当中文字符或 emoji 等多字节 UTF-8 序列跨 TCP 分片传输时出现的间歇性乱码(U+FFFD 替换字符)问题 (#1923, 感谢 @Cod1ng)。
- 将所有 4 条 SSE 流式路径中的 `String::from_utf8_lossy` 替换为新的 `append_utf8_safe` 辅助函数
- 通过残留缓冲区保留不完整的尾部字节,并在下一个分片合并后再解码
- 直连 Copilot 的场景不可复现,因为直连模式透传原始字节而不做格式转换
### 碎片 System Prompt 规范化
修复严格的 OpenAI 兼容 chat 后端(Nvidia、Qwen 风格)在转换后 Claude 负载包含多条 system 消息时拒绝请求的问题 (#1942, 感谢 @yovinchen)。
- 在 Anthropic → OpenAI chat 转换时将 system 内容合并为单条前置 system 消息
- 其余消息流保持不变
### 流式解析兼容性
- 修复 SSE 解析以接受包含可选空格的字段,提升对非严格流式实现的兼容性 (#1664, 感谢 @Alexlangl)
### 供应商切换状态损坏
- 将按应用的供应商切换串行化,防止并发故障转移或热切换操作导致 `is_current`、设置状态和 Live 备份状态不一致
### Claude 接管 Live 配置漂移
- 修复 Claude 接管启用时供应商编辑导致 Live 设置与供应商状态失步,同时保持接管恢复行为不被破坏 (#1828, 感谢 @geekdada)
### WebDAV 密码保留与校验
- 修复 WebDAV 密码字段在刷新后不可见的问题
- 连接校验时正确处理 `MKCOL 405` 响应 (#1685, 感谢 @Alexlangl)
### 供应商卡片动作状态
- 修复新增模式高亮行为 (#1747, 感谢 @yovinchen)
- 始终渲染动作按钮,对齐各卡片的用量显示布局
- 用警告路径替换硬阻塞的代理切换
- 禁用 Copilot 和 Codex OAuth 卡片上不受支持的测试/用量动作
- 隐藏官方供应商的用量配置和健康检查按钮
- 移除供应商卡片上的 hover 推送动画
### 用量精确性与定价
- 修复 MiniMax 配额数学和 0% → 100% 进度
- 修正 CNY → USD 定价并补齐缺失模型
- 改进 Gemini 会话日志同步的精度
- 修复基于会话的用量条目显示为"未知供应商"的问题
### 用量编辑器与 Skills UI 回归
- 修复编辑提取器代码时用量查询字段被重置的问题 (#1771, 感谢 @if-nil)
- 修正 `skills.sh` 链接失效和空描述问题
- 修复用量配置中的 auto-query 默认间隔(5 分钟)和 number-input 清空问题
### 中文 Skills 术语
- 统一 zh locale 下设置面板中的 Skills 相关标签,保持存储与同步选项用词一致
### 环境与预设兼容性
- 在 CLI 扫描中新增 Bun 全局 bin 检测 (#1742, 感谢 @makoMakoGo)
- 适配 oh-my-openagent 重命名并保持向后兼容 (#1746, 感谢 @yovinchen)
- 修正 OpenCode `kimi-for-coding` 预设 (#1738, 感谢 @makoMakoGo)
- 将 Gemini keychain 解析限制为仅 macOS
- 修复空集合时 OpenClaw 序列化器 panic (#1724, 感谢 @yovinchen)
### Linux 启动时 UI 无响应
修复长期存在的 Linux 专属 bug:窗口 UI(包括原生标题栏按钮)在用户手动最大化再还原之前无法接收点击。
- **根因 1**Tauri webview 在 Linux 上 `show()` 之后未获得键盘焦点,首次点击被 X11 / Wayland 的 click-to-activate 消费掉(Tauri #10746、wry #637
- **根因 2**:在某些 WebKitGTK / 合成器组合下,GTK surface 的输入区域在 `visible:false → show()` 路径上未能重协商,导致整个窗口无响应
- **缓解措施**:启动时设置 `WEBKIT_DISABLE_COMPOSITING_MODE=1`,并新增 `linux_fix::nudge_main_window` 辅助函数,在 show 之后 ~200ms 执行 `set_focus` + ±1px 无操作尺寸调整,等效于一次视觉上不可见的"最大化再还原"
- **覆盖范围**:接入所有窗口重新显示路径 —— 正常启动、深链接、单例、托盘 `show_main` 以及轻量模式退出返程
### Linux 标题栏拖动区域
- 在 Linux 上从顶部标题栏移除 `data-tauri-drag-region`,避免触发 Wayland 下受 Tauri #13440 影响的 `gtk_window_begin_move_drag` 路径
- macOS 拖动行为保持不变
### OpenCode / OpenClaw 流式检测边界情况
- 修复自定义头透传
- 修复 OpenClaw 自定义 auth-header 检测
- 修复 Bedrock 错误消息
- 修复 OpenCode 默认 `baseURL` 回退处理
---
## 文档
### 用户手册刷新
- 在 EN / ZH / JA 用户手册中覆盖托盘子菜单、轻量模式、供应商模型获取、会话管理、工作区文件、WebDAV v2 行为、OpenCode / OpenClaw 启用等供应商工作流改进
### 社区与贡献文档
- 新增 `CONTRIBUTING.md``SECURITY.md``CODE_OF_CONDUCT.md`
- 新增双语 GitHub issue 和 PR 模板
- 新增 Dependabot 配置 (#1829, 感谢 @bengbengbalabalabeng) 和 stale-bot 工作流以自动关闭不活跃的 issue
- 新增 PR / push 质量检查 CI 工作流
### Release Notes 风险提示回填
- 在三语 v3.12.3 release notes 中新增 Copilot 反代风险提示,并为重点内容添加锚点链接
---
## ⚠️ 风险提示
**Codex OAuth 反向代理免责声明**
本版本新增的 Codex OAuth 反向代理功能通过逆向工程的 OAuth 流程访问 ChatGPT Plus / Pro 的 Codex 服务。启用此功能前,请注意以下风险:
1. **违反服务条款**:使用逆向 OAuth 流程访问 OpenAI 服务可能违反 OpenAI 的服务条款,其中禁止未经授权的自动化访问、服务复制以及绕过既定的访问路径。
2. **账号风险**:OpenAI 可能将异常使用模式标记为可疑的自动化行为,从而对 ChatGPT Plus / Pro 访问施加临时或永久限制。
3. **无法保证长期可用**:OpenAI 可能随时更新其认证和检测机制,当前可用的使用方式未来可能被标记。
v3.12.3 引入的 **GitHub Copilot 反向代理**同样适用原有风险提示 —— 详见 [v3.12.3 release notes](v3.12.3-zh.md#-风险提示)。
用户启用上述功能即表示**自行承担所有风险**。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
---
## 下载与安装
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | ----------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 |
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 |
### Windows
| 文件 | 说明 |
| ------------------------------------------ | ----------------------------------- |
| `CC-Switch-v3.13.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.13.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
### macOS
| 文件 | 说明 |
| ---------------------------------- | --------------------------------------------------------- |
| `CC-Switch-v3.13.0-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
| `CC-Switch-v3.13.0-macOS.zip` | 解压后拖入 ApplicationsUniversal Binary |
| `CC-Switch-v3.13.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
> macOS 版本已通过 Apple 代码签名和公证,可直接安装使用。
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| 发行版 | 推荐格式 | 安装方式 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb``sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm``sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+3 -3
View File
@@ -12,9 +12,9 @@
## Version / 版本 / バージョン
- Documentation version: v3.12.3
- Last updated: 2026-04-02
- Compatible with CC Switch v3.12.0+
- Documentation version: v3.13.0
- Last updated: 2026-04-08
- Compatible with CC Switch v3.13.0+
## Links
@@ -46,6 +46,26 @@ When enabled, CC Switch automatically runs when the system starts.
"Minimize to tray" is recommended for convenient provider switching via the tray.
### Lightweight Mode
Starting from v3.13.0, CC Switch adds **Lightweight Mode** — a **tray-only** running state that minimizes desktop footprint when idle.
**How to enter**: Right-click the tray icon → click **Lightweight Mode**. The main window is **destroyed** (not just hidden), freeing UI resources and memory.
**How to exit**: Click **Open Main Window** from the tray menu, or trigger CC Switch via deep link / relaunch. The window is **rebuilt on demand**, with state preserved.
| Aspect | Minimize to Tray | Lightweight Mode |
|--------|------------------|------------------|
| UI process | Kept in memory | Fully destroyed |
| Idle resource footprint | Same as normal run | Near zero |
| Reopen speed | Instant (direct show) | Slightly slower (window rebuild) |
| Tray switching | Available | Available |
| Deep link wake | Available | Available (on-demand rebuild) |
> **Use case**: If CC Switch runs in the background for long periods and you mainly switch providers via the tray menu, enabling Lightweight Mode significantly reduces memory usage.
> **Note**: Lightweight Mode state is not persistent — the next normal launch returns to normal mode. Combine with Launch on Startup for long-term use.
### Claude Plugin Integration
When enabled, CC Switch automatically syncs the configuration to the VS Code Claude Code extension (writes `primaryApiKey` to `~/.claude/config.json`) when switching providers.
@@ -251,6 +271,26 @@ Log level descriptions:
- **debug** - Detailed debugging information
- **trace** - All verbose information
## OAuth Auth Center (Beta)
Settings > **OAuth Auth Center** Tab
Added in v3.13.0, the **OAuth Auth Center** (Beta) provides unified management for third-party OAuth credentials. It currently supports two account types:
| Account Type | Purpose |
| ------------------------- | ---------------------------------------------------------- |
| **GitHub Copilot** | Used with the Copilot reverse proxy |
| **ChatGPT (Codex OAuth)** | Used with the Codex OAuth reverse proxy; manage ChatGPT accounts |
**What you can do here**:
- Log in to ChatGPT / GitHub accounts via the Device Code flow
- View the list of logged-in accounts and authentication status
- Set a default account when managing multiple accounts
- Remove individual accounts or log out all accounts at once
> **Note**: Both features use reverse-engineered OAuth flows and carry account risk and Terms of Service risk. Before using, please read the full risk notice in [2.1 Add Provider → Codex OAuth Reverse Proxy](../2-providers/2.1-add.md#codex-oauth-reverse-proxy-claude-provider).
## About Page
Settings > About Tab
+160 -6
View File
@@ -154,19 +154,20 @@ Presets are pre-configured provider templates that only require an API Key to us
## Auto-Fetch Models
When adding or editing a provider, you can auto-fetch available models from the provider's endpoint:
When adding or editing a provider, you can automatically discover available models from the provider's endpoint — eliminating the tedious copy-and-paste of model IDs.
1. Ensure the **API Key** and **Endpoint URL** are filled in
2. Click the **Fetch Models** button (download icon) next to the model input field
3. CC Switch calls the provider's `/v1/models` endpoint to retrieve the model list
4. Select a model from the dropdown, grouped by vendor
3. CC Switch uses the configured API Key to call the OpenAI-compatible `/v1/models` endpoint
4. Select a model from the dropdown, grouped by category
This feature works for any provider that supports the OpenAI-compatible `/v1/models` API. It is available for Claude, Codex, Gemini, OpenCode, and OpenClaw providers.
This feature covers **all five apps****Claude / Codex / Gemini / OpenCode / OpenClaw** — and works for any provider that supports the `/v1/models` endpoint.
**Common errors:**
- **Authentication failed (401/403)**: Check your API Key
- **Endpoint not supported (404/405)**: The provider does not expose a `/v1/models` endpoint
- **Timeout**: The endpoint is slow to respond; try again later
- **Endpoint not supported (404/405)**: The provider does not expose a `/v1/models` endpoint; fall back to manual model ID entry
- **Parse failure**: The response does not match the OpenAI-compatible format
- **Timeout**: The endpoint is slow to respond; try again later or check your network
## Custom Configuration
@@ -330,6 +331,135 @@ Batch import from SQL backup files:
> **Note**: Importing will overwrite the existing database. It is recommended to export your current configuration as a backup first. The exported file name format is `cc-switch-export-{timestamp}.sql`.
## Codex OAuth Reverse Proxy (Claude Provider)
Starting from v3.13.0, CC Switch adds a **Codex OAuth reverse proxy** path that lets you reuse your ChatGPT account's Codex service inside Claude Code.
> **Location hint**: This feature appears as a **new Claude provider card type**, not as a Codex-side preset. Once added, it sits alongside regular API-Key providers in the Claude provider list.
### Prerequisites
- A **ChatGPT account** you can log in to
- Network access to `auth.openai.com` and `chatgpt.com`
- **Before using, please read the [⚠️ Risk Notice](#-risk-notice-important) at the end of this section**
### Two Entry Points
You can start from either entry point:
#### Entry A: From the Add Provider panel (recommended for new users)
1. Switch to the **Claude** app
2. Click the **+** button in the top-right to open the Add Provider panel
3. Under the third-party category, select the **Codex (ChatGPT Plus/Pro)** preset (use the name as shown in the UI)
4. If no ChatGPT account is logged in yet, the panel **automatically guides** you into the login flow (see "Login Flow" below)
5. After login succeeds, the provider form shows the logged-in account — click **Save** to finish
#### Entry B: From the OAuth Auth Center (better for multi-account management)
1. Open **Settings → OAuth Auth Center** (tab marked with a **Beta** label)
2. In the **ChatGPT (Codex OAuth)** section, click **Log in with ChatGPT**
3. Complete the login flow (see below)
4. Once logged in, return to the **Claude** app → **Add Provider** → select the same Codex (ChatGPT Plus/Pro) preset
5. In the form's **Select Account** dropdown, choose the account you just logged in and save
### Login Flow (Device Code)
No matter which entry point you use, the login flow is the same:
1. **Get the verification code**: CC Switch invokes OpenAI's Device Code flow and displays:
- An **8-character verification code** (e.g., `ABCD-1234`)
- A **Copy** button next to the code
- The authorization URL `https://auth.openai.com/codex/device`
- An "Waiting for authorization..." animation
2. **Browser authorization**: Click the link (or manually visit the URL) and in the browser:
- Log in to your ChatGPT account
- Enter the verification code you copied
- Confirm authorization
3. **Automatic polling**: CC Switch keeps polling the OpenAI server in the background and closes the waiting UI once authorization succeeds
4. **Account appears in the list**: The logged-in ChatGPT account (login email) shows up in **OAuth Auth Center → Logged-in Accounts**
> ⏱️ **Verification codes are valid for about 15 minutes**. If it expires, the UI shows "Device Code has expired" — click **Retry** to get a new one.
### Enable and Use
After adding and saving a Codex OAuth provider:
1. Find it in the Claude provider list
2. Click the **Enable** button on the card — same as any regular provider
3. Claude Code CLI then uses the reverse proxy to access the Codex service
4. The provider also appears in the tray menu's **Claude** submenu for quick switching
> **Under the hood**: CC Switch routes requests to `https://chatgpt.com/backend-api/codex`, with the base URL forcibly rewritten — you **do not** need to manually fill in the endpoint. The API format is fixed to `openai_responses`.
### Default Models
The Codex OAuth preset's default model mapping:
| Role | Default Model |
| -------------- | ------------- |
| Main model | `gpt-5.4` |
| Sonnet role | `gpt-5.4` |
| Opus role | `gpt-5.4` |
| Haiku role | `gpt-5.4-mini` |
You can override the `ANTHROPIC_MODEL` and related environment variables in the provider's JSON editor to customize.
### Multi-Account Management (OAuth Auth Center)
The **OAuth Auth Center** supports managing multiple ChatGPT accounts at the same time:
| Action | Description |
| ---------------------- | ----------------------------------------------------------------- |
| Add another account | Click **Add Another Account** to repeat the login flow |
| Set as default | Click **Set as Default** on an account row — new providers use it |
| Choose for a provider | In the provider form, use the **Select Account** dropdown |
| Remove account | Click the red × next to an account (the token is cleared) |
| Log out all accounts | The **Log Out All Accounts** button at the bottom clears all |
> **Use case**: If you share a dev machine with teammates, create one provider per member's ChatGPT account and switch between them via the tray menu.
### Token Auto-Refresh
- Tokens are **automatically refreshed 60 seconds before expiry**, fully in the background — no manual action required
- Refresh tokens are stored in the local data directory and are never uploaded anywhere
- **Token export is not supported** (to prevent leaks)
### Quota Display
After login and enabling the provider, the **bottom of the provider card** automatically shows the account quota:
| Display Element | Example | Color Rules |
| ------------------- | ---------------- | -------------------------------------------- |
| Usage percentage | `45%` | < 70% green, 7089% orange, ≥ 90% red |
| Reset countdown | `7d12h until reset` | ChatGPT account's sliding window or daily limit |
| Refresh button | Circular arrow | Manually re-query quota |
> ⚠️ **Session Expired**: If the token fails to refresh, the card displays a yellow "Session Expired" warning. Go to the **OAuth Auth Center**, remove the account, and log in again.
### Common Failures
| Scenario | Symptom | Resolution |
| --------------------------- | -------------------------------- | ------------------------------------------- |
| Verification code timeout | "Device Code has expired" shown | Click **Retry** to get a new code |
| Authorization denied | "User denied authorization" | Retry and click "Authorize" in the browser |
| Network error | Specific error details shown | Check network, confirm access to OpenAI domains |
| Not logged in before adding | "Please log in to ChatGPT first" | Complete login in OAuth Auth Center first |
| Token refresh failed | "Session Expired" in quota box | Remove the account and log in again |
| Quota query failed | "Query failed" in quota box | Click the **Refresh** button to retry |
### ⚠️ Risk Notice (Important)
The Codex OAuth reverse proxy accesses your ChatGPT account's Codex service through a **reverse-engineered OAuth flow**. Before enabling, please make sure you understand the following risks:
1. **Terms of Service violations**: May violate OpenAI's Terms of Service, which prohibit unauthorized automated access, service replication, and bypassing established access paths
2. **Account risk**: OpenAI may flag unusual usage patterns as suspicious automation and impose temporary or permanent restrictions on your ChatGPT account
3. **No guarantee of long-term availability**: OpenAI may update its authentication and detection mechanisms at any time, and currently available methods may be blocked in the future
**By enabling this feature, you assume all risks**. CC Switch is not responsible for any account restrictions, warnings, or service suspensions resulting from its use.
> 📖 See the full disclaimer and background in the [v3.13.0 Release Notes](../../../release-notes/v3.13.0-en.md#-risk-notice).
## Advanced Options
### API Format (Claude Only)
@@ -346,6 +476,30 @@ When adding a Claude provider that uses a third-party API, you may need to selec
The Advanced Options section auto-expands when a non-default API format is configured.
### Full URL Endpoint Mode
Added in v3.13.0. By default, CC Switch treats the configured `base_url` as a **prefix** and appends fixed paths like `/v1/chat/completions`. For some vendors (such as third-party services with non-standard URL layouts), this path concatenation causes requests to fail.
**How to enable**:
1. Edit the provider and expand **Advanced Options**
2. Check the **Full URL Mode** checkbox
3. Fill in the **complete upstream endpoint** (not a prefix) as `base_url`
**Example comparison**:
| Mode | `base_url` value | Actual request target |
| ------------------------- | ------------------------------------------------ | ------------------------------------------------ |
| Default (prefix concat) | `https://api.example.com` | `https://api.example.com/v1/chat/completions` |
| **Full URL Mode** | `https://api.example.com/custom/path/messages` | `https://api.example.com/custom/path/messages` |
**When to use**:
- The vendor requires a non-standard path (not `/v1/chat/completions`)
- The vendor has a multi-level path structure
- Vendor-specific API gateway paths
> **Note**: Both proxy forwarding and Stream Check respect the Full URL Mode setting, so no extra adjustments are needed after enabling. Disabling this option restores default path concatenation.
### Claude Common Config Toggles
When editing Claude providers, a set of **quick toggles** is available above the JSON editor:
+18 -2
View File
@@ -30,10 +30,26 @@ Quickly switch providers via the system tray without opening the main interface.
3. Click the provider name you want to switch to
4. Switching completes with a brief tray notification
> Providers are organized into collapsible submenus by app type (Claude/Codex/Gemini). The submenu title shows the currently active provider name.
### Tray Menu Structure
Starting from v3.13.0, the tray menu is refactored from a flat list into **per-app submenus**, with a dedicated submenu for each app:
| Submenu | Description |
| ---------- | -------------------------------------------------------------- |
| Claude | All Claude providers (including Codex OAuth reverse proxy) |
| Codex | All Codex providers |
| Gemini | All Gemini providers |
| OpenCode | All OpenCode providers |
| OpenClaw | All OpenClaw providers |
**Benefits of the refactor**:
- **Prevents menu overflow**: With many providers, a flat list would exceed screen height; per-app submenus scale naturally
- **Submenu title shows the currently active provider**: You know at a glance which provider each app is using, without opening the submenu
- **Per-app isolation**: Switching Claude's provider doesn't disturb the Codex view
> **Tip**: The combination of background residency + Lightweight Mode + per-app submenus is especially suited for heavy users who frequently switch among multiple apps. See [1.5 Personalization → Lightweight Mode](../1-getting-started/1.5-settings.md).
![image-20260108004348993](../../assets/image-20260108004348993.png)
## Activation Methods
@@ -1,8 +1,72 @@
# 2.5 Usage Query
## Overview
CC Switch's quota / balance display is split into two categories: **Auto Query** (official subscription types, works out of the box) and **Manual Enable** (built-in templates + custom scripts, requires user configuration before showing).
The usage query feature allows you to configure custom scripts to query a provider's remaining balance, used amount, and other information in real time.
| Category | Scope | User Enable Required |
| ------------------------------ | -------------------------------------------------------------------------- | -------------------- |
| **Auto Query** | Claude / Codex / Gemini official subscriptions, GitHub Copilot, Codex OAuth reverse proxy | No (enabled by default) |
| **Manual Enable (built-in templates)** | Token Plan, third-party balance query | Yes (see below) |
| **Manual Enable (custom script)** | Proxies, private deployments, special APIs not covered by built-in templates | Yes (see below) |
## Auto Query (Official Subscription Types)
Starting from v3.13.0, the following three categories automatically display the quota at the bottom of the provider card after the provider is enabled — no additional configuration required:
| Category | Covered Providers | Displayed Content |
| ---------------- | ----------------------------------------------------- | ----------------------------------------- |
| Official subscriptions | Claude / Codex / Gemini official login | Official subscription quota |
| GitHub Copilot | Copilot provider card | Premium interactions remaining |
| Codex OAuth | Codex OAuth reverse proxy card (Claude provider) | ChatGPT account Codex quota |
These three share the common trait that **their data source is unique and semantically unambiguous** (the usage rate of an official subscription), so CC Switch directly calls the corresponding official or OAuth query endpoint.
### Auto Query Interactions
- **Card footer display**: Usage percentage + reset countdown, colored by usage (< 70% green / 7089% orange / ≥ 90% red)
- **Manual refresh**: Click the refresh icon on the card to re-query
- **Simplified card**: For these three types, the **Health Check** and **Usage Query Config** buttons are hidden to avoid interfering with the built-in display
- **Session expired notice**: If a token fails to refresh, the card shows a yellow "Session Expired" warning (Copilot / Codex OAuth)
---
## Manual Enable (Built-in Templates + Custom Scripts)
Besides the three auto-query types above, **all other providers** (including Token Plan, third-party balance queries, and various proxy services) need to have the **Usage Query** switch manually turned on in the provider card before any quota is displayed.
### Why do these need manual enabling?
One important reason: **the same request URL (same vendor) may expose multiple query modes** — for example, both plan-based quota queries and account-level balance queries. CC Switch cannot automatically infer which one you want, so the built-in query for such providers is **disabled by default**, leaving you to pick the right template.
### Built-in Template Coverage
v3.13.0 provides **ready-to-use built-in templates** for the following categories — no script writing required:
| Category | Covered Providers | Template Type |
| ------------------ | --------------------------------------------------------- | ------------------------------- |
| Token Plan | Kimi / Zhipu GLM / MiniMax | Plan quota (with usage progress) |
| Third-party balance| DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | Official balance query |
> **Tip**: Beyond these built-in templates, for uncovered providers you can use the **custom script** approach (see below) to write your own query logic.
### Enable Steps
1. Hover over the provider card to reveal action buttons
2. Click the **Usage Query** button (chart icon)
3. At the top of the configuration panel, toggle on **Enable Usage Query**
4. Select the right built-in template (e.g., Token Plan, third-party balance) or choose "Custom"
5. Fill in API Key / Base URL / Access Token as needed (most cases can be left blank, reusing the provider's own credentials)
6. Click **Test Script** to verify the query returns successfully
7. Save — next time the provider is activated, the quota will show up at the bottom of the card
> ⚠️ **Note**: The auto-refresh interval after enabling is controlled by the "Auto Query Interval" field (set to `0` to disable auto-refresh). Background queries only trigger when the provider is in "Currently Active" state.
---
## Custom Script Query (Advanced)
### Overview
When a provider **is not covered by the built-in templates**, you can write a custom JavaScript query script. Suitable for proxy services, private deployments, special API formats, etc.
**Use cases**:
- Check API account remaining balance
@@ -155,6 +219,24 @@ The following placeholders can be used in scripts and are automatically replaced
### Troubleshooting
### Auto Query Not Displayed (Official Subscription Types)
**Check**:
1. Confirm the provider is an official subscription type — Claude / Codex / Gemini official login, GitHub Copilot, or Codex OAuth reverse proxy
2. The provider is in "Currently Active" state (inactive providers do not trigger queries)
3. For OAuth types (Copilot / Codex OAuth), check whether the token is still valid; if the card shows "Session Expired", log in again in the **OAuth Auth Center**
4. Network access to the official quota endpoint
### Manual Enable Still Not Showing Quota
**Check**:
1. Whether the **Enable Usage Query** toggle at the top of the "Usage Query" panel is on
2. Whether a suitable built-in template (Token Plan / third-party balance / custom) is selected
3. Click **Test Script** to see the specific error
4. Required fields such as API Key / Base URL are filled correctly
5. Network access to the provider's quota endpoint
6. Background auto-refresh only triggers when the provider is in "Currently Active" state
### Query Failed
**Check**:
+64 -4
View File
@@ -192,11 +192,59 @@ Each skill card displays:
## Skill Updates
Automatic updates are not currently supported. To update a skill:
Starting from v3.13.0, Skills support **automatic update detection** and **batch updates** — no more uninstall-and-reinstall.
1. Uninstall the existing skill
2. Refresh the list
3. Reinstall
### Update Detection Mechanism
CC Switch compares installed skills with the remote repository version using **SHA-256 content hashes**. Whenever the remote has any content changes, the corresponding local skill card automatically shows an "Update available" indicator.
### Single Update
For a skill with an available update:
1. Find the skill card with the update indicator in the Skills panel
2. Click the **Update** button on the card
3. Wait for the download to finish — status refreshes automatically
### Update All
When multiple skills need updating:
1. Click the **Update All** button at the top of the Skills panel (appears with a slide-in animation)
2. CC Switch batch-downloads all skills with pending updates
3. The panel refreshes automatically when done, and the update indicators disappear
> **Tip**: Regularly click the **Refresh** button to trigger a remote scan so update detection stays current.
## Storage Location Switch
Starting from v3.13.0, the **source storage location** for skills can be switched between two locations:
| Location | Description |
| ------------------------ | --------------------------------------------------------------------- |
| **CC Switch built-in** | Default location `~/.cc-switch/skills/`, managed by CC Switch |
| **`~/.agents/skills`** | A shared directory conforming to community agent tool conventions, better for cross-tool collaboration |
### How to Switch
Select the target storage location from the settings or management menu in the Skills panel. The switch **does not lose skill state** — CC Switch smoothly migrates existing skills to the new location.
> ⚠️ **Distinction**: The "Storage Location Switch" here manages the **source storage** of skills. In contrast, [1.5 Personalization → Skill Sync Method](../1-getting-started/1.5-settings.md) controls how skills are **distributed to each app's directory** (symlink vs. copy). The two settings work together.
## Public Registry Search (skills.sh)
v3.13.0 integrates **skills.sh** public registry search so you can discover community skills directly inside CC Switch.
### How to Use
1. Click the **Repository Management** button to open the dialog
2. Use the **skills.sh Search** input inside the dialog
3. Type keywords to filter results in real time
4. Click a target skill to quickly add it to your repository list
v3.13.0 also fixes broken link and empty description handling for skills.sh, so community skill metadata is displayed more reliably.
## Troubleshooting
### Empty Skill List
@@ -224,3 +272,15 @@ Solutions:
- Check network connection
- Check disk space
- Check directory permissions
### Update Button Not Showing
Possible causes:
- The remote repository has no new content
- CC Switch has not finished the latest scan
Solutions:
- Click **Refresh** to rescan
- Confirm the repository configuration points to the right branch and path
@@ -89,6 +89,22 @@ Click the **Resume** button (play icon) on a selected session to continue the co
> The Resume button is disabled if the session has no resume command available.
#### Directory Picker (Claude Terminal Resume)
Starting from v3.13.0, **Claude sessions** show a **directory picker** before resume, allowing you to override the default project directory. Useful when:
- **Project was moved**: The original project directory was moved or renamed
- **Broken symlink**: The original path is no longer accessible
- **Temporary directory change**: You want to continue the conversation in a different working directory
**How to use**:
1. Click the **Resume** button on a Claude session
2. In the popup directory picker, confirm the default directory or choose a new one
3. CC Switch launches the Claude terminal session in the selected directory
> **Note**: Codex / Gemini / OpenCode / OpenClaw session resume flows do not yet include the directory picker and still use the session's original project directory.
### Delete Session
Click the **Delete** button (trash icon) to permanently remove a session file. A confirmation dialog is shown before deletion.
+22 -2
View File
@@ -9,14 +9,34 @@ The usage statistics feature records and analyzes API request data, helping you:
- Analyze usage patterns
- Troubleshoot issues
Starting from v3.13.0, usage data comes from two sources:
| Data Source | Coverage | Proxy Interception Required? |
| ------------------------------- | ----------------------------------------- | ---------------------------- |
| **Proxy request log** | All requests forwarded through the proxy | Yes |
| **CLI session log** (new in v3.13) | Claude / Codex / Gemini session history | No |
- **Codex sessions**: Switched to **precise parsing** based on JSONL session logs, replacing the previous estimation; model names are normalized for consistent pricing lookup
- **Gemini sessions**: Synced precisely from Gemini CLI session logs
- **Claude sessions**: Also supports direct usage import from session logs
- The usage panel supports **per-app filtering** (Claude / Codex / Gemini) so data from different apps does not mix
## Prerequisites
Using the usage statistics feature requires:
Depending on which data source you use, the prerequisites differ:
**Proxy request log** (covers all apps and all proxy requests):
1. Proxy service started
2. App takeover enabled
3. Log recording enabled
**CLI session log** (new in v3.13, no proxy required):
1. The corresponding app (Claude / Codex / Gemini) is enabled in CC Switch
2. The corresponding CLI has session history files
3. CC Switch periodically scans session directories and imports usage data
## Open Usage Statistics
Settings > Usage Tab
@@ -212,7 +232,7 @@ When adding pricing entries, enter the normalized Model ID rather than the full
### Preset Prices
CC Switch includes preset official prices for common models (per million tokens):
CC Switch includes preset official prices for common models (per million tokens). v3.13.0 corrects **CNY → USD pricing** for several models and adds previously missing model definitions; it also fixes **MiniMax plan quota math** and the **0% → 100% usage progress** display, making cost estimates and plan progress more accurate.
**Claude Series (USD)**:
+11 -6
View File
@@ -2,12 +2,15 @@
## Overview
The model test feature verifies whether a provider's configured model is available by sending actual API requests to test:
The model test feature (also known as **Stream Check**) verifies whether a provider's configured model is available by sending actual API requests to test:
- Whether the model exists
- Whether the API Key is valid
- Whether the endpoint responds normally
- Whether the response latency is acceptable
- Time to first token (TTFB) for streaming responses
Starting from v3.13.0, Stream Check coverage is extended to **all five apps** (Claude / Codex / Gemini / OpenCode / OpenClaw), including all OpenClaw protocol variants (such as `openai-completions`). OpenCode is auto-detected via npm package mapping; OpenClaw supports custom `auth-header` detection and handles edge cases like Bedrock error messages and `baseURL` fallback.
## Open Configuration
@@ -17,11 +20,13 @@ Settings > Advanced > Model Test Config
Configure the model used for testing per application:
| Application | Setting | Default | Notes |
|-------------|---------|---------|-------|
| Claude | Claude Model | System default | Recommend using Haiku series (low cost, fast) |
| Codex | Codex Model | System default | Recommend using mini series |
| Gemini | Gemini Model | System default | Recommend using Flash series |
| Application | Setting | Default | Notes |
| ----------- | -------------- | -------------- | ---------------------------------------------------- |
| Claude | Claude Model | System default | Recommend using Haiku series (low cost, fast) |
| Codex | Codex Model | System default | Recommend using mini series |
| Gemini | Gemini Model | System default | Recommend using Flash series |
| OpenCode | OpenCode Model | System default | Added in v3.13.0, auto-detected via npm package mapping |
| OpenClaw | OpenClaw Model | System default | Added in v3.13.0, covers all protocol variants and custom auth-header |
### Model Selection Tips
@@ -156,6 +156,51 @@ chmod +x CC-Switch-*.AppImage
- [ ] Is log recording enabled
- [ ] Have requests been going through the proxy
## Quota & Balance
### Why do some providers show quota automatically while others need manual enabling?
Only **official subscription types** (Claude / Codex / Gemini official login, GitHub Copilot, Codex OAuth reverse proxy) automatically display the quota after enabling the provider. **All other providers** (including Token Plan and third-party balance queries) need the **Usage Query** switch to be manually turned on and a built-in template selected in the provider card — because the same request URL may expose both "plan" and "balance" query modes, requiring you to pick the right one. See [2.5 Usage Query → Manual Enable](../2-providers/2.5-usage-query.md#manual-enable-built-in-templates--custom-scripts).
### Official subscription provider shows no quota
**Check**:
1. Confirm the provider is in "Currently Active" state (inactive providers do not trigger queries)
2. For Copilot / Codex OAuth, check whether the OAuth token is still valid; if the card shows "Session Expired", log in again in the **OAuth Auth Center**
3. Check network connectivity
4. Click the refresh icon on the card to manually re-query
### Token Plan or third-party balance still not shown after enabling
**Check**:
1. Confirm the **Enable Usage Query** toggle is on in the "Usage Query" panel
2. A suitable built-in template is selected and saved
3. Click **Test Script** to see the specific error
4. The provider must be in "Currently Active" state for background auto-refresh
### Codex usage does not match the direct-connection numbers
v3.13.0 switched Codex usage from estimation to **precise parsing based on JSONL session logs**, with normalized model names for consistent pricing lookup. New data aligns with official bills. If you still see old estimated data, delete the historical entries or wait for new session data to overwrite them.
## Codex OAuth Reverse Proxy
### How do I log in to Codex OAuth?
See the complete Device Code login flow (verification code + browser authorization), both entry points (Add Provider panel / OAuth Auth Center), multi-account management, and common failure scenarios in [2.1 Add Provider → Codex OAuth Reverse Proxy (Claude Provider)](../2-providers/2.1-add.md#codex-oauth-reverse-proxy-claude-provider).
### What are the risks of enabling the Codex OAuth reverse proxy?
The Codex OAuth reverse proxy accesses your ChatGPT account's Codex service through a **reverse-engineered OAuth flow**. This may violate OpenAI's Terms of Service, carries the risk of account restrictions or suspensions, and provides no guarantee of long-term availability. **By enabling, you assume all risks**.
See the full disclaimer in the [v3.13.0 Release Notes → Risk Notice](../../../release-notes/v3.13.0-en.md#-risk-notice) and in [2.1 Add Provider → Codex OAuth Reverse Proxy](../2-providers/2.1-add.md#codex-oauth-reverse-proxy-claude-provider).
### Codex OAuth logged in but no quota shown
**Solutions**:
1. Confirm the OAuth login flow is completed in **OAuth Auth Center** (Settings → OAuth Auth Center, with the Beta label)
2. Check whether the token is still valid — if the card shows "Session Expired", the token cannot be refreshed
3. If expired, remove the account in the OAuth Auth Center and log in again
## Other Issues
### Tray Icon Not Showing
@@ -193,6 +238,10 @@ Toggle "Lightweight Mode" from the system tray menu. The main window closes, and
Yes. Lightweight Mode destroys the main window and its web view, reducing memory usage significantly while keeping tray menu functionality available.
### Can deep links still wake the main window in Lightweight Mode?
Yes. Starting from v3.13.0, CC Switch covers all window re-show paths (normal launch, deep links, singleton activation, tray `show_main`, and Lightweight Mode return). Clicking a `ccswitch://` link **rebuilds the main window on demand** and displays the import confirmation dialog. The first open is slightly slower than normal state (window rebuild required), but subsequent switches return to normal speed.
## Getting Help
### Submit an Issue
+13 -3
View File
@@ -103,9 +103,19 @@ CC Switch User Manual
## Version Information
- Documentation version: v3.12.3
- Last updated: 2026-04-04
- Applicable to CC Switch v3.12.3+
- Documentation version: v3.13.0
- Last updated: 2026-04-08
- Applicable to CC Switch v3.13.0+
### v3.13.0 Highlights
- **Lightweight Mode**: Destroys the main window when minimizing to tray — near-zero idle footprint. See [1.5 Personalization](./1-getting-started/1.5-settings.md)
- **Quota & Balance Display**: Official subscriptions (Claude/Codex/Gemini/Copilot/Codex OAuth) auto-display quotas; Token Plan and third-party balances use built-in templates with one-click enable — see [2.5 Usage Query](./2-providers/2.5-usage-query.md)
- **Codex OAuth Reverse Proxy**: Reuse your ChatGPT account's Codex service inside Claude Code — see [2.1 Add Provider](./2-providers/2.1-add.md)
- **Per-App Tray Submenus**: Five independent app submenus to prevent tray overflow — see [2.2 Switch Provider](./2-providers/2.2-switch.md)
- **Skills Discovery & Batch Updates**: SHA-256 update detection, batch updates, skills.sh public registry search — see [3.3 Skills Management](./3-extensions/3.3-skills.md)
- **Full URL Endpoint Mode**: Advanced option to treat `base_url` as the full upstream endpoint — see [2.1 Add Provider](./2-providers/2.1-add.md)
- **OpenCode / OpenClaw Stream Check Coverage**: Stream Check panel extended to all five apps — see [4.5 Model Test](./4-proxy/4.5-model-test.md)
## Contributing
@@ -46,6 +46,26 @@ CC Switch は 3 つの言語に対応しています:
トレイからプロバイダーを素早く切り替えられるため、「トレイへ最小化」の使用を推奨します。
### 軽量モード
v3.13.0 より、CC Switch に **軽量モード** が追加されました — アイドル時のデスクトップ占有を最小限に抑える **トレイのみ実行状態** です。
**開始方法**:トレイアイコンを右クリック → **軽量モード** をクリック。メインウィンドウは **破棄**(単に非表示ではなく)され、UI リソースとメモリが解放されます。
**終了方法**:トレイメニューから **メインウィンドウを開く** をクリック、またはディープリンク / 再起動で CC Switch を呼び出します。ウィンドウは **必要に応じて再構築** され、状態は保持されます。
| 項目 | トレイへ最小化 | 軽量モード |
| -------------------- | ---------------- | ---------------------- |
| UI プロセス | メモリに保持 | 完全に破棄 |
| アイドル時リソース | 通常実行と同じ | ほぼゼロ |
| 再表示速度 | 瞬時(直接表示) | やや遅い(ウィンドウ再構築) |
| トレイ切り替え | 利用可能 | 利用可能 |
| ディープリンク起動 | 利用可能 | 利用可能(必要時再構築)|
> **使用シーン**:CC Switch を長時間バックグラウンドに常駐させ、主にトレイメニューからプロバイダーを切り替える場合、軽量モードを有効にするとメモリ使用量を大幅に削減できます。
> **注意**:軽量モードの状態は永続化されません — 次回の通常起動では通常モードに戻ります。長期的に使用する場合は「起動時に自動実行」と組み合わせてください。
### Claude プラグイン連携
有効にすると、CC Switch はプロバイダー切り替え時に VS Code の Claude Code 拡張に設定を自動同期します(`~/.claude/config.json``primaryApiKey` に書き込み)。
@@ -251,6 +271,26 @@ WebDAV プロトコルを使用して複数のデバイス間で設定を同期
- **debug** - デバッグ情報を記録
- **trace** - すべての詳細情報を記録
## OAuth 認証センター(Beta
設定 → **OAuth 認証センター** タブ
v3.13.0 で追加された **OAuth 認証センター**(Beta)は、サードパーティの OAuth 認証情報を一元管理します。現在、以下の 2 種類のアカウントタイプをサポートしています:
| アカウントタイプ | 用途 |
| ----------------------------- | --------------------------------------------------------------- |
| **GitHub Copilot** | Copilot リバースプロキシと組み合わせて使用 |
| **ChatGPT (Codex OAuth)** | Codex OAuth リバースプロキシと組み合わせて使用、ChatGPT アカウントを管理 |
**ここでできること**
- Device Code フローで ChatGPT / GitHub アカウントにログイン
- ログイン済みアカウント一覧と認証状態の確認
- マルチアカウント時のデフォルトアカウント設定
- 個別アカウントの削除や全アカウントの一括ログアウト
> **注意**:これら 2 つの機能はリバースエンジニアリングされた OAuth フローを使用するため、アカウントリスクおよび利用規約リスクが存在します。ご利用前に [2.1 プロバイダーの追加 → Codex OAuth リバースプロキシ](../2-providers/2.1-add.md#codex-oauth-リバースプロキシclaude-プロバイダー) の完全なリスク通知をお読みください。
## バージョン情報ページ
設定 → バージョン情報 タブ
+161 -7
View File
@@ -154,19 +154,20 @@
## モデル自動取得
プロバイダーの追加や編集時に、プロバイダーのエンドポイントから利用可能なモデルを自動取得できます
プロバイダーの追加や編集時に、プロバイダーのエンドポイントから利用可能なモデルを自動検出でき、モデル ID の手動コピー&ペーストの手間を省けます
1. **API Key****エンドポイントアドレス** が入力されていることを確認
2. モデル入力フィールドの横にある **モデル取得** ボタン(ダウンロードアイコン)をクリック
3. CC Switch がプロバイダー`/v1/models` エンドポイントを呼び出してモデルリストを取得
4. ベンダー別にグループ化されたドロップダウンからモデルを選択
3. CC Switch が設定された API Key で OpenAI 互換`/v1/models` エンドポイントを呼び出し
4. カテゴリ別にグループ化されたドロップダウンからモデルを選択
この機能は、OpenAI 互換の `/v1/models` API をサポートするすべてのプロバイダーで動作します。ClaudeCodexGeminiOpenCodeOpenClaw のプロバイダーで利用可能です。
この機能は **5 つのアプリ全対応** —— **Claude / Codex / Gemini / OpenCode / OpenClaw** のプロバイダーで利用可能で`/v1/models` エンドポイントをサポートするすべてのプロバイダーに対応します。
**よくあるエラー:**
- **認証失敗(401/403**API Key 確認してください
- **エンドポイント未対応(404/405)**:プロバイダーが `/v1/models` エンドポイントを公開していません
- **タイムアウト**:エンドポイントの応答が遅いです。後ほど再試行してください
- **認証失敗(401/403**API Key が正しいか確認してください
- **エンドポイント未対応(404/405)**:プロバイダーが `/v1/models` エンドポイントを公開していません。手動でモデル ID を入力してください
- **解析失敗**:レスポンスが OpenAI 互換フォーマットに準拠していません
- **タイムアウト**:エンドポイントの応答が遅いです。後ほど再試行するかネットワークを確認してください
## カスタム設定
@@ -330,6 +331,135 @@ SQL バックアップファイルから一括インポート:
> **注意**:インポートは既存のデータベースを上書きするため、事前に現在の設定をエクスポートしてバックアップすることをお勧めします。エクスポートファイル名の形式は `cc-switch-export-{タイムスタンプ}.sql` です。
## Codex OAuth リバースプロキシ(Claude プロバイダー)
v3.13.0 より、CC Switch は **Codex OAuth リバースプロキシ** 経路を追加しました。**ChatGPT アカウント** を使って Claude Code 内から Codex サービスを再利用できます。
> **位置ヒント**:この機能は **新しい Claude プロバイダーカードタイプ** として表示され、Codex 側のプリセットではありません。追加後は通常の API Key プロバイダーと並んで Claude のプロバイダーリストに表示されます。
### 前提条件
- ログイン可能な **ChatGPT アカウント**
- `auth.openai.com` および `chatgpt.com` にアクセスできる
- **利用前に必ず本節末尾の [⚠️ リスク通知](#-リスク通知重要) をお読みください**
### 2 つの入口
以下のどちらの入口からでも開始できます:
#### 入口 A:プロバイダー追加パネルから(新規ユーザー推奨)
1. **Claude** アプリに切り替える
2. 右上の **+** ボタンをクリックしてプロバイダー追加パネルを開く
3. プリセットリストの第三者カテゴリから **Codex (ChatGPT Plus/Pro)** プリセットを選択(UI に表示される名称を優先)
4. まだ ChatGPT アカウントにログインしていない場合、パネルが **自動的に** ログインフローへ誘導します(下記「ログインフロー」を参照)
5. ログイン成功後、プロバイダーフォームにログイン済みアカウントが表示されるので「保存」をクリックして完了
#### 入口 B:OAuth 認証センターから(マルチアカウント管理に適する)
1. **設定 → OAuth 認証センター** を開く(タブに **Beta** マーク)
2. **ChatGPT (Codex OAuth)** セクションで **ChatGPT でログイン** ボタンをクリック
3. ログインフローを完了(下記参照)
4. ログイン完了後、**Claude** アプリに戻る → **プロバイダーの追加** → 同じ Codex (ChatGPT Plus/Pro) プリセットを選択
5. フォーム内の「アカウント選択」ドロップダウンから、先ほどログインしたアカウントを選択して保存
### ログインフロー(Device Code
どちらの入口から入っても、ログインフローは同一です:
1. **認証コードを取得**CC Switch が OpenAI Device Code フローを呼び出し、以下を表示:
- **認証コード**(約 8 文字、例:`ABCD-1234`
- 認証コード右側の **コピー** ボタン
- その下の認証 URL `https://auth.openai.com/codex/device`
- 「認証を待っています...」のアニメーション表示
2. **ブラウザ認証**:リンクをクリック(または URL を手動で訪問)し、ブラウザで:
- ChatGPT アカウントにログイン
- 先ほどコピーした認証コードを入力
- 認証を確認
3. **自動ポーリング完了**:CC Switch はバックグラウンドで OpenAI サーバーをポーリングし、認証成功を検知すると待機画面を自動的に閉じます
4. **ログイン済みアカウントを表示**:ログインした ChatGPT アカウント(ログインメール)が **OAuth 認証センター → ログイン済みアカウント** リストに表示されます
> ⏱️ **認証コードの有効期限は約 15 分** です。タイムアウトすると「Device Code の有効期限切れ」が表示されるので、**再試行** をクリックして新しい認証コードを取得してください。
### 有効化と使用
Codex OAuth プロバイダーを追加・保存した後:
1. Claude のプロバイダーリストから探す
2. カードの **有効化** ボタンをクリック —— 通常のプロバイダーと同じ
3. Claude Code CLI がリバースプロキシ経由で Codex サービスを使用します
4. トレイメニューの **Claude** サブメニューにもこのプロバイダーが表示され、素早く切り替え可能
> **内部動作**CC Switch はリクエストを `https://chatgpt.com/backend-api/codex` にルーティングし、base URL が強制的に書き換えられます —— フォームにエンドポイントを手動入力する **必要はありません**。API フォーマットは `openai_responses` に固定されます。
### デフォルトモデル
Codex OAuth プリセットのデフォルトモデルマッピング:
| 役割 | デフォルトモデル |
| ------------- | ---------------- |
| メインモデル | `gpt-5.4` |
| Sonnet 役割 | `gpt-5.4` |
| Opus 役割 | `gpt-5.4` |
| Haiku 役割 | `gpt-5.4-mini` |
プロバイダーの JSON エディタで `ANTHROPIC_MODEL` などの環境変数を上書きしてカスタマイズできます。
### マルチアカウント管理(OAuth 認証センター)
**OAuth 認証センター** は複数の ChatGPT アカウントの同時管理をサポートします:
| 操作 | 説明 |
| -------------------------- | ---------------------------------------------------------------------- |
| 別のアカウントを追加 | 「別のアカウントを追加」をクリックしてログインフローを繰り返す |
| デフォルトに設定 | アカウント行の「デフォルトに設定」をクリック —— 新規プロバイダーに適用 |
| プロバイダー用に選択 | プロバイダーフォームの「アカウント選択」ドロップダウンで特定アカウントを指定 |
| アカウントを削除 | アカウント右側の赤い × をクリックして削除(Token がクリアされる) |
| すべてのアカウントをログアウト | 下部の「すべてのアカウントをログアウト」ボタンで一括クリア |
> **使用シーン**:チームで開発マシンを共有する場合、各メンバーの ChatGPT アカウントごとに 1 つのプロバイダーを作成し、トレイメニューから素早く切り替えできます。
### Token 自動更新
- Token は **有効期限の 60 秒前** に自動更新され、すべてバックグラウンドで処理されるため手動介入は不要です
- Refresh Token はローカルデータディレクトリに保存され、どこにもアップロードされません
- Token のエクスポートは **サポートされていません**(漏洩防止)
### クォータ表示
ログインしてプロバイダーを有効化すると、**プロバイダーカード下部** に自動的にアカウントクォータが表示されます:
| 表示要素 | 例 | カラールール |
| ---------------- | ------------------- | --------------------------------------------- |
| 使用率 | `45%` | < 70% 緑、7089% オレンジ、≥ 90% 赤 |
| リセットまでの時間 | `7d12h 後にリセット` | ChatGPT アカウントのスライディングウィンドウまたは日次制限 |
| 更新ボタン | 円形の矢印 | 手動でクォータを再取得 |
> ⚠️ **セッション期限切れ**:Token が完全に無効になった(自動更新できない)場合、カード下部に黄色い警告枠「セッション期限切れ」が表示されます。**OAuth 認証センター** からこのアカウントを削除し、再ログインしてください。
### よくある失敗
| シナリオ | 表示 | 解決方法 |
| --------------------------- | --------------------------------- | --------------------------------------------- |
| 認証コードタイムアウト | 「Device Code の有効期限切れ」 | 「再試行」をクリックして新しい認証コードを取得 |
| ブラウザで認証拒否 | 「ユーザーが認証を拒否」 | 再ログインしブラウザで「認証」をクリック |
| ネットワークエラー | 具体的なエラー情報を表示 | ネットワーク接続を確認、OpenAI ドメインへのアクセス可否を確認 |
| ログイン前にプロバイダー作成 | 「ChatGPT アカウントにログインしてください」 | 先に OAuth 認証センターでログインを完了 |
| Token 更新失敗 | クォータ欄に「セッション期限切れ」 | アカウントを削除して再ログイン |
| クォータ取得失敗 | クォータ欄に「取得失敗」 | 「更新」ボタンをクリックして再試行 |
### ⚠️ リスク通知(重要)
Codex OAuth リバースプロキシは **リバースエンジニアリングされた OAuth フロー** で ChatGPT アカウントの Codex サービスにアクセスします。有効化前に必ず以下のリスクをご理解ください:
1. **利用規約違反**:OpenAI の利用規約に違反する可能性があります。同規約は未承認の自動化アクセス、サービスの複製、および既定のアクセス経路の迂回を禁止しています
2. **アカウントリスク**:OpenAI は異常な使用パターンを疑わしい自動化として検知し、ChatGPT アカウントに一時的または永続的な制限を課す可能性があります
3. **長期的な可用性は保証されません**:OpenAI は認証および検出メカニズムをいつでも更新する可能性があり、現在利用可能な方法が将来ブロックされる可能性があります
**この機能を有効化することは、すべてのリスクを自己責任で負うことを意味します**。CC Switch は本機能の使用による一切のアカウント制限、警告、サービス停止について責任を負いません。
> 📖 完全な免責事項と背景は [v3.13.0 Release Notes](../../../release-notes/v3.13.0-ja.md#-リスク通知) をご覧ください。
## 高度なオプション
### API フォーマット(Claude のみ)
@@ -346,6 +476,30 @@ SQL バックアップファイルから一括インポート:
デフォルト以外の API フォーマットが設定されている場合、高度なオプションセクションが自動展開されます。
### 完全URLエンドポイントモード
v3.13.0 で追加された高度なオプション。デフォルトでは、CC Switch は設定された `base_url`**プレフィックス** として扱い、`/v1/chat/completions` などの固定パスを後ろに連結します。一部のベンダー(非標準の URL レイアウトを必要とする第三者サービスなど)では、この連結方式ではリクエストが失敗します。
**有効化方法**
1. プロバイダーを編集し、「高度なオプション」を展開
2. **完全 URL モード** チェックボックスにチェックを入れる
3. **完全なアップストリームエンドポイント**(プレフィックスではなく)を `base_url` に入力
**例の比較**
| モード | `base_url` の記入例 | 実際のリクエスト先 |
| ----------------------- | ------------------------------------------------ | ------------------------------------------------ |
| デフォルト(プレフィックス連結) | `https://api.example.com` | `https://api.example.com/v1/chat/completions` |
| **完全 URL モード** | `https://api.example.com/custom/path/messages` | `https://api.example.com/custom/path/messages` |
**使用シーン**
- ベンダーが非標準パスを要求する場合(`/v1/chat/completions` 以外)
- ベンダーに多階層のパス構造がある場合
- ベンダー専用の API ゲートウェイパス
> **ヒント**:プロキシ転送および Stream Check のいずれも「完全 URL モード」の設定に従うため、有効化後に追加調整は不要です。このオプションを無効化すると、パス連結はデフォルトの動作に戻ります。
### Claude 共通設定クイックトグル
Claude プロバイダーの編集時、JSON エディタの上部に **クイックトグル** が利用できます:
+18 -2
View File
@@ -30,10 +30,26 @@
3. 切り替えたいプロバイダー名をクリック
4. 切り替え完了、トレイに短い通知が表示
> プロバイダーはアプリタイプ(Claude/Codex/Gemini)ごとに折りたたみサブメニューに整理されています。サブメニューのタイトルには現在有効なプロバイダー名が表示されます。
### トレイメニュー構造
v3.13.0 より、トレイメニューがフラットなリストから **アプリ別サブメニュー** にリファクタリングされ、各アプリに独立したサブメニューが用意されました:
| サブメニュー | 説明 |
| ------------ | -------------------------------------------------------------------- |
| Claude | Claude のすべてのプロバイダー(Codex OAuth リバースプロキシを含む) |
| Codex | Codex のすべてのプロバイダー |
| Gemini | Gemini のすべてのプロバイダー |
| OpenCode | OpenCode のすべてのプロバイダー |
| OpenClaw | OpenClaw のすべてのプロバイダー |
**リファクタリングの利点**
- **メニューのオーバーフロー防止**:プロバイダーが多数ある場合、フラットなリストでは画面の高さを超えますが、アプリ別サブメニューは自然にスケールします
- **サブメニューのタイトルに現在有効なプロバイダーを表示**:サブメニューを開かなくても、各アプリがどのプロバイダーを使用中か一目でわかります
- **アプリ別の分離**:Claude のプロバイダーを切り替えても Codex のビューには影響しません
> **ヒント**:バックグラウンド常駐 + 軽量モード + アプリ別サブメニューの組み合わせは、複数のアプリを頻繁に切り替えるヘビーユーザーに特に適しています。[1.5 個人設定 → 軽量モード](../1-getting-started/1.5-settings.md) を参照してください。
![image-20260108004348993](../../assets/image-20260108004348993.png)
## 反映方法
@@ -1,8 +1,72 @@
# 2.5 使用量クエリ
## 機能説明
CC Switch のクォータ・残高表示は 2 つのカテゴリに分かれます:**自動クエリ**(公式サブスクリプション系、すぐに使える)と **手動有効化**(内蔵テンプレート + カスタムスクリプト、ユーザー設定後に表示)。
使用量クエリ機能により、カスタムスクリプトを設定して、プロバイダーの残額や使用量などの情報をリアルタイムでクエリできます。
| カテゴリ | 範囲 | ユーザー操作必要 |
| ---------------------------------- | --------------------------------------------------------------------------------- | ---------------- |
| **自動クエリ** | Claude / Codex / Gemini 公式サブスクリプション、GitHub Copilot、Codex OAuth リバースプロキシ | 不要(デフォルト有効) |
| **手動有効化(内蔵テンプレート)** | Token Plan、第三者残高クエリ | 必要(下記参照) |
| **手動有効化(カスタムスクリプト)** | 内蔵テンプレート未対応の中継サービス、プライベートデプロイ、特殊 API | 必要(下記参照) |
## 自動クエリ(公式サブスクリプション系)
v3.13.0 より、以下の 3 カテゴリはプロバイダー有効化後に **自動的** にカード下部にクォータが表示され、追加設定は不要です:
| カテゴリ | 対象プロバイダー | 表示内容 |
| ---------------------- | --------------------------------------------- | ------------------------------------ |
| 公式サブスクリプション | Claude / Codex / Gemini 公式ログイン | 公式サブスクリプションクォータ |
| GitHub Copilot | Copilot プロバイダーカード | Premium interactions 残量 |
| Codex OAuth | Codex OAuth リバースプロキシカード(Claude プロバイダー) | ChatGPT アカウント Codex クォータ |
これら 3 カテゴリの共通点は、**データソースが唯一かつ意味が明確** であることです(公式サブスクリプションの使用率)。そのため CC Switch は対応する公式または OAuth クエリエンドポイントを直接呼び出します。
### 自動クエリの操作
- **カード下部表示**:使用率 + リセットまでのカウントダウン、使用率に応じて色が変化(< 70% 緑 / 7089% オレンジ / ≥ 90% 赤)
- **手動更新**:カード上の更新アイコンをクリックして再取得
- **カードの簡略化**:これら 3 カテゴリでは、**ヘルスチェック** と **使用量クエリ設定** ボタンが自動的に非表示となり、内蔵表示への干渉を防ぎます
- **セッション期限切れ通知**:Token の更新に失敗した場合、カードに黄色の「セッション期限切れ」警告が表示されます(Copilot / Codex OAuth
---
## 手動有効化(内蔵テンプレート + カスタムスクリプト)
上記 3 カテゴリの自動クエリ対応プロバイダー以外、**その他すべてのプロバイダー**(Token Plan、第三者残高クエリ、各種中継サービスを含む)では、プロバイダーカード上で **手動で「使用量クエリ」スイッチをオン** にして初めてクォータが表示されます。
### なぜ手動有効化が必要なのか?
重要な理由の一つは:**同じリクエスト URL(同じベンダー)が複数のクエリモードを提供している場合がある** ことです —— プランごとのクォータクエリと、アカウント残高クエリの両方が存在する可能性があります。CC Switch はどちらをクエリすべきか自動判定できないため、このようなプロバイダーの内蔵クエリは **デフォルトで無効** になっており、適切なテンプレートを選択してから有効化する必要があります。
### 内蔵テンプレートの対象範囲
v3.13.0 では以下のカテゴリに **すぐに使える内蔵テンプレート** を提供しており、有効化後にスクリプトを書く必要はありません:
| カテゴリ | 対象プロバイダー | テンプレートタイプ |
| --------------- | --------------------------------------------------------- | ------------------------- |
| Token Plan | Kimi / Zhipu GLM / MiniMax | プランクォータ(使用進捗付き) |
| 第三者残高 | DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | 公式残高クエリ |
> **ヒント**:上記の内蔵テンプレート以外で対象外のプロバイダーには、**カスタムスクリプト** 方式(下記参照)で独自のクエリロジックを記述できます。
### 有効化手順
1. プロバイダーカードにマウスをホバーして操作ボタンを表示
2. **使用量クエリ** ボタン(📊 アイコン)をクリック
3. 設定パネル上部の **使用量クエリを有効にする** スイッチをオンにする
4. 適切な内蔵テンプレート(Token Plan、第三者残高など)または「カスタム」を選択
5. 必要に応じて API Key / Base URL / Access Token などのパラメータを入力(多くの場合は空欄のままプロバイダー自身の認証情報を使用可能)
6. 「スクリプトをテスト」をクリックして正常に応答するか確認
7. 設定を保存 —— 次回プロバイダーを有効化すると、カード下部にクォータが表示されます
> ⚠️ **注意**:有効化後の自動更新間隔は「自動クエリ間隔」フィールドで制御します(`0` に設定すると自動更新を無効化)。プロバイダーが「現在有効」状態のときのみバックグラウンドクエリがトリガーされます。
---
## カスタムスクリプトクエリ(高度)
### 機能説明
プロバイダーが **内蔵テンプレートの対象範囲外** の場合、JavaScript でカスタムクエリスクリプトを記述できます。中継サービス、プライベートデプロイ、特殊な API 形式などに適しています。
**使用シーン**
- API アカウントの残額確認
@@ -155,6 +219,24 @@ New API タイプの中継サービス専用に設計されています:
### トラブルシューティング
### 自動クエリにクォータが表示されない(公式サブスクリプション系)
**確認事項**
1. プロバイダーが公式サブスクリプション系であることを確認 —— Claude / Codex / Gemini 公式ログイン、GitHub Copilot、Codex OAuth リバースプロキシ
2. プロバイダーが「現在有効」状態か(非アクティブ時はクエリがトリガーされません)
3. OAuth タイプ(Copilot / Codex OAuth)の場合、Token がまだ有効期限内か確認。カードに「セッション期限切れ」と表示される場合は **OAuth 認証センター** で再ログインしてください
4. 公式クォータエンドポイントへのネットワークアクセス可否
### 手動有効化後もクォータが表示されない
**確認事項**
1. プロバイダーカードの「使用量クエリ」パネル上部にある **使用量クエリを有効にする** スイッチがオンか
2. 適切な内蔵テンプレート(Token Plan / 第三者残高 / カスタム)が選択されているか
3. 「スクリプトをテスト」をクリックして具体的なエラー情報を確認
4. API Key / Base URL などの必須フィールドが正しく入力されているか
5. プロバイダーのクォータエンドポイントへのネットワークアクセス可否
6. プロバイダーが「現在有効」状態のときのみ、バックグラウンドの自動更新がトリガーされます
### クエリ失敗
**確認事項**
+64 -4
View File
@@ -192,11 +192,59 @@ Subdirectory: skills
## スキルの更新
現在、自動更新には対応していません。スキルを更新するには:
v3.13.0 より、Skills は **自動更新検出****一括更新** に対応しました —— アンインストール&再インストールの必要はありません。
1. 既存のスキルをアンインストール
2. リストを更新
3. 再度インストール
### 更新検出の仕組み
CC Switch は **SHA-256 コンテンツハッシュ** によってローカルにインストールされた skill とリモートリポジトリのバージョンを比較します。リモートに何らかのファイル変更があれば、対応するローカル skill カードに「新しいバージョンあり」のインジケーターが自動的に表示されます。
### 単体更新
更新が必要な skill について:
1. Skills パネルで更新インジケーター付きの skill カードを見つける
2. カード上の **更新** ボタンをクリック
3. ダウンロード完了を待つ —— ステータスは自動的に更新されます
### 一括更新
複数の skill に更新が必要な場合:
1. Skills パネル上部の **すべて更新** ボタンをクリック(スライドインアニメーション付きで表示)
2. CC Switch が更新が必要なすべての skill を一括ダウンロード
3. 完了後パネルが自動的に更新され、更新インジケーターは消えます
> **ヒント**:定期的に「更新」ボタンをクリックしてリモートスキャンをトリガーし、更新検出の結果を最新に保ってください。
## 保存場所の切り替え
v3.13.0 より、Skills の **ソース保存場所** は 2 つの場所から切り替え可能になりました:
| 場所 | 説明 |
| ------------------------ | -------------------------------------------------------------------- |
| **CC Switch 内蔵保存** | デフォルト位置 `~/.cc-switch/skills/`、CC Switch が一元管理 |
| **`~/.agents/skills`** | コミュニティの agent ツール規約に準拠した共有ディレクトリ、他ツールとの連携に適する |
### 切り替え方法
Skills パネルの設定または管理メニューから対象の保存場所を選択します。切り替えの際 **skill の状態は失われません** —— CC Switch が既存の skill を新しい場所へスムーズに移行します。
> ⚠️ **区別**:本節の「保存場所の切り替え」は skill の **ソース保存** を管理します。一方、[1.5 個人設定 → Skills 同期方式](../1-getting-started/1.5-settings.md) は skill を **各アプリディレクトリへどう配布するか**(シンボリックリンク vs コピー)を管理します。両者は併用します。
## 公式レジストリ検索(skills.sh)
v3.13.0 では **skills.sh** 公式レジストリ検索を統合し、CC Switch 内から直接コミュニティ skill を発見できます。
### 使用手順
1. 「リポジトリ管理」ボタンをクリックしてダイアログを開く
2. ダイアログ内の **skills.sh 検索** 入力欄を使用
3. キーワードを入力してリアルタイムで結果をフィルタリング
4. 対象の skill をクリックして自分のリポジトリリストに素早く追加
v3.13.0 では skills.sh のリンク切れと空の説明への対応も修正され、コミュニティ skill のメタデータ表示がより安定しました。
## トラブルシューティング
### スキルリストが空の場合
@@ -224,3 +272,15 @@ Subdirectory: skills
- ネットワーク接続を確認
- ディスク容量を確認
- ディレクトリの権限を確認
### 更新ボタンが表示されない場合
考えられる原因:
- リモートリポジトリに新しいコンテンツがない
- CC Switch が最新のスキャンを完了していない
解決方法:
- 「更新」をクリックして再スキャン
- リポジトリ設定が正しいブランチとパスを指していることを確認
@@ -89,6 +89,22 @@
> 再開コマンドが利用できないセッションでは、再開ボタンは無効になります。
#### ディレクトリピッカー(Claude ターミナル再開)
v3.13.0 より、**Claude セッション** の再開前に **ディレクトリピッカー** が表示され、デフォルトのプロジェクトディレクトリを上書きできます。以下のシナリオに対応します:
- **プロジェクトが移動された**:元のプロジェクトディレクトリが移動・リネームされた
- **シンボリックリンク切れ**:元のパスにアクセスできない
- **一時的なディレクトリ変更**:異なる作業ディレクトリで会話を続けたい
**使用方法**
1. Claude セッションの **再開** ボタンをクリック
2. 表示されるディレクトリピッカーで、デフォルトのディレクトリを確認するか、新しいディレクトリを選択
3. CC Switch が選択したディレクトリで Claude ターミナルセッションを起動します
> **ヒント**Codex / Gemini / OpenCode / OpenClaw のセッション再開フローには現在ディレクトリピッカーは含まれず、セッション元のプロジェクトディレクトリを使用します。
### セッションの削除
**削除** ボタン(ゴミ箱アイコン)をクリックすると、セッションファイルが完全に削除されます。削除前に確認ダイアログが表示されます。
+22 -2
View File
@@ -9,14 +9,34 @@
- 使用パターンの分析
- 問題のトラブルシューティング
v3.13.0 より、使用量データの取得元は 2 つあります:
| データ取得元 | 対象範囲 | プロキシ経由が必要? |
| ---------------------------------- | --------------------------------------- | -------------------- |
| **プロキシリクエストログ** | プロキシを経由したすべてのリクエスト | 必要 |
| **CLI セッションログ**v3.13 新規)| Claude / Codex / Gemini のセッション履歴 | 不要 |
- **Codex セッション**JSONL セッションログに基づく **精密な解析** に切り替え、従来の推定値を置き換え。モデル名を正規化することで料金検索の整合性を保証
- **Gemini セッション**:Gemini CLI のセッションログから精密に同期
- **Claude セッション**:セッションログから直接使用量をインポート可能
- 使用量パネルは **アプリ別フィルタリング**Claude / Codex / Gemini)に対応し、データが混在しません
## 前提条件
使用量統計機能を使用するには
使用するデータ取得元によって前提条件が異なります
**プロキシリクエストログ**(すべてのアプリとプロキシリクエストを対象):
1. プロキシサービスを起動
2. アプリケーション接管を有効化
3. ログ記録を有効化
**CLI セッションログ**(v3.13 新規、プロキシ不要):
1. CC Switch で対応するアプリ(Claude / Codex / Gemini)を有効化
2. 対応する CLI にセッション履歴ファイルがあること
3. CC Switch が定期的にセッションディレクトリをスキャンして使用量をインポートします
## 使用量統計を開く
設定 → 使用量 タブ
@@ -212,7 +232,7 @@ Token 使用量の変化を表示:
### プリセット価格
CC Switch は一般的なモデルの公式価格(100 万 Token あたり)をプリセットしています
CC Switch は一般的なモデルの公式価格(100 万 Token あたり)をプリセットしています。v3.13.0 では一部モデルの **CNY → USD 価格を修正** し、これまで欠けていたモデル定義を補完したほか、**MiniMax のプランクォータ計算** と **0% → 100% の使用進捗** 表示を修正し、費用見積もりとプラン進捗の表示がより正確になりました。
**Claude シリーズ(ドル)**
+11 -6
View File
@@ -2,12 +2,15 @@
## 機能説明
モデルテスト機能は、プロバイダーに設定されたモデルが使用可能かどうかを確認するために、実際の API リクエストを送信してテストします:
モデルテスト機能**Stream Check** とも呼ばれる)は、プロバイダーに設定されたモデルが使用可能かどうかを確認するために、実際の API リクエストを送信してテストします:
- モデルが存在するか
- API Key が有効か
- エンドポイントが正常に応答するか
- 応答レイテンシが正常か
- ストリーミングレスポンスの初回トークン時間(TTFB)
v3.13.0 より、Stream Check の対応範囲が **5 つのアプリ全対応**Claude / Codex / Gemini / OpenCode / OpenClaw)に拡張され、OpenClaw の全プロトコルバリアント(`openai-completions` など)も含まれます。OpenCode は npm パッケージマッピングで自動識別、OpenClaw はカスタム `auth-header` 検出、Bedrock エラーメッセージ、`baseURL` フォールバックなどのエッジケースにも対応しています。
## 設定を開く
@@ -17,11 +20,13 @@
各アプリのテスト用モデルを設定します:
| アプリ | 設定項目 | デフォルト値 | 説明 |
|------|--------|--------|------|
| Claude | Claude モデル | システムデフォルト | Haiku シリーズの使用を推奨(低コスト・高速) |
| Codex | Codex モデル | システムデフォルト | mini シリーズの使用を推奨 |
| Gemini | Gemini モデル | システムデフォルト | Flash シリーズの使用を推奨 |
| アプリ | 設定項目 | デフォルト値 | 説明 |
| ---------- | ---------------- | ------------------ | ------------------------------------------------------- |
| Claude | Claude モデル | システムデフォルト | Haiku シリーズの使用を推奨(低コスト・高速) |
| Codex | Codex モデル | システムデフォルト | mini シリーズの使用を推奨 |
| Gemini | Gemini モデル | システムデフォルト | Flash シリーズの使用を推奨 |
| OpenCode | OpenCode モデル | システムデフォルト | v3.13.0 で追加、npm パッケージマッピングで自動検出 |
| OpenClaw | OpenClaw モデル | システムデフォルト | v3.13.0 で追加、全プロトコルバリアントとカスタム auth-header に対応 |
### モデル選択のアドバイス
+54 -5
View File
@@ -156,6 +156,51 @@ chmod +x CC-Switch-*.AppImage
- [ ] ログ記録が有効か
- [ ] プロキシ経由でリクエストがあったか
## クォータ・残高
### なぜ一部のプロバイダーは自動的にクォータが表示され、他は手動で有効化する必要があるのですか?
**公式サブスクリプション系**Claude / Codex / Gemini 公式ログイン、GitHub Copilot、Codex OAuth リバースプロキシ)のみ、プロバイダーを有効化すると自動的にクォータが表示されます。**その他すべてのプロバイダー**(Token Plan および第三者残高クエリを含む)は、プロバイダーカードの「使用量クエリ」パネルで手動でスイッチをオンにし、内蔵テンプレートを選択する必要があります。同じリクエスト URL が「プラン」と「残高」の両方のクエリモードを持つ可能性があるため、ユーザー自身が選択する必要があるからです。詳細は [2.5 使用量クエリ → 手動有効化](../2-providers/2.5-usage-query.md#手動有効化内蔵テンプレート--カスタムスクリプト) を参照してください。
### 公式サブスクリプションのプロバイダーにクォータが表示されない
**確認事項**
1. プロバイダーが「現在有効」状態であることを確認(非アクティブ時はクエリがトリガーされません)
2. Copilot / Codex OAuth の場合、OAuth Token がまだ有効期限内か確認。カードに「セッション期限切れ」と表示されたら **OAuth 認証センター** で再ログインしてください
3. ネットワーク接続を確認
4. カード上の更新アイコンをクリックして手動で再取得
### Token Plan や第三者残高を有効化しても表示されない
**確認事項**
1. 「使用量クエリ」パネルで「使用量クエリを有効にする」スイッチがオンになっているか
2. 適切な内蔵テンプレートが選択されて保存されているか
3. 「スクリプトをテスト」をクリックして具体的なエラーを確認
4. プロバイダーが「現在有効」状態のときのみバックグラウンド自動更新が動作します
### Codex の使用量が直接接続時と合わない
v3.13.0 で Codex の使用量が推定値から **JSONL セッションログに基づく精密解析** に切り替わり、モデル名が正規化されて料金検索の整合性が保たれます。新しいデータは公式の請求と一致します。古い推定データが残っている場合は、履歴エントリを削除するか、新しいセッションデータによる上書きを待ってください。
## Codex OAuth リバースプロキシ
### Codex OAuth のログイン方法は?
完全な Device Code ログインフロー(認証コード + ブラウザ認証)、2 つの入口(プロバイダー追加パネル / OAuth 認証センター)、マルチアカウント管理、よくある失敗シナリオは [2.1 プロバイダーの追加 → Codex OAuth リバースプロキシ(Claude プロバイダー)](../2-providers/2.1-add.md#codex-oauth-リバースプロキシclaude-プロバイダー) を参照してください。
### Codex OAuth リバースプロキシを有効化するリスクは?
Codex OAuth リバースプロキシは **リバースエンジニアリングされた OAuth フロー** で ChatGPT アカウントの Codex サービスにアクセスします。OpenAI の利用規約に違反する可能性があり、アカウント制限や停止のリスクがあり、長期的な可用性も保証されません。**有効化すると自己責任となります**。
完全な免責事項は [v3.13.0 Release Notes → リスク通知](../../../release-notes/v3.13.0-ja.md#-リスク通知) と [2.1 プロバイダーの追加 → Codex OAuth リバースプロキシ](../2-providers/2.1-add.md#codex-oauth-リバースプロキシclaude-プロバイダー) を参照してください。
### Codex OAuth にログインしたがクォータが表示されない
**解決方法**
1. **OAuth 認証センター**(設定 → OAuth 認証センター、Beta ラベル付き)で OAuth ログインフローが完了していることを確認
2. Token がまだ有効期限内か確認。カードに「セッション期限切れ」と表示される場合は Token が更新できない状態
3. 期限切れの場合は、OAuth 認証センターでアカウントを削除して再ログインしてください
## その他の問題
### トレイアイコンが表示されない
@@ -183,15 +228,19 @@ chmod +x CC-Switch-*.AppImage
2. 最新版を手動でダウンロードしてインストール
3. Homebrew を使用する場合:`brew upgrade --cask cc-switch`
## ライトウェイトモード
## 軽量モード
### ライトウェイトモードに入るには?
### 軽量モードに入るには?
システムトレイメニューから「ライトウェイトモード」をトグルします。メインウィンドウが閉じ、CC Switch はトレイ専用アプリとして動作します。再度トグルするか「メインウィンドウを開く」をクリックすると終了します。
システムトレイメニューから「軽量モード」をトグルします。メインウィンドウが閉じ、CC Switch はトレイ専用アプリとして動作します。再度トグルするか「メインウィンドウを開く」をクリックすると終了します。
### ライトウェイトモードではメモリ使用量が少なくなる?
### 軽量モードではメモリ使用量が少なくなる?
はい。ライトウェイトモードではメインウィンドウとその Web ビューを破棄するため、トレイメニュー機能を維持しながらメモリ使用量を大幅に削減します。
はい。軽量モードではメインウィンドウとその Web ビューを破棄するため、トレイメニュー機能を維持しながらメモリ使用量を大幅に削減します。
### 軽量モードでもディープリンクでメインウィンドウを呼び出せる?
はい。CC Switch v3.13.0 より、すべてのウィンドウ再表示パス(通常起動、ディープリンク、シングルトン起動、トレイ `show_main`、軽量モードからの復帰)をカバーしています。`ccswitch://` リンクをクリックするとメインウィンドウが **必要に応じて再構築** され、インポート確認ダイアログが表示されます。初回起動は通常状態より若干遅くなります(ウィンドウの再構築が必要なため)が、以降の切り替えは通常速度に戻ります。
## ヘルプの入手
+13 -3
View File
@@ -103,9 +103,19 @@ CC Switch ユーザーマニュアル
## バージョン情報
- ドキュメントバージョン:v3.12.3
- 最終更新:2026-04-04
- CC Switch v3.12.3+ 対応
- ドキュメントバージョン:v3.13.0
- 最終更新:2026-04-08
- CC Switch v3.13.0+ 対応
### v3.13.0 の注目機能
- **軽量モード**:トレイへ最小化時にメインウィンドウを破棄、アイドル時のリソース使用量をほぼゼロに — 詳細は [1.5 個人設定](./1-getting-started/1.5-settings.md)
- **クォータ・残高表示**:公式サブスクリプション系(Claude/Codex/Gemini/Copilot/Codex OAuth)はカードに自動表示、Token Plan および第三者残高は内蔵テンプレートでワンクリック有効化 — 詳細は [2.5 使用量クエリ](./2-providers/2.5-usage-query.md)
- **Codex OAuth リバースプロキシ**ChatGPT アカウントで Claude Code 内から Codex サービスを再利用 — 詳細は [2.1 プロバイダーの追加](./2-providers/2.1-add.md)
- **アプリ別トレイサブメニュー**:5 アプリ独立サブメニュー、メニューのオーバーフローを防止 — 詳細は [2.2 プロバイダーの切り替え](./2-providers/2.2-switch.md)
- **Skills の発見と一括更新**:SHA-256 ハッシュによる更新検出、一括更新、skills.sh 公式レジストリ検索 — 詳細は [3.3 Skills スキル管理](./3-extensions/3.3-skills.md)
- **完全URLエンドポイントモード**:高度なオプションで `base_url` を完全なアップストリームエンドポイントとして扱う — 詳細は [2.1 プロバイダーの追加](./2-providers/2.1-add.md)
- **OpenCode / OpenClaw ストリームチェック完全対応**Stream Check パネルを 5 アプリ全対応に拡張 — 詳細は [4.5 モデルテスト](./4-proxy/4.5-model-test.md)
## コントリビュート
@@ -46,6 +46,26 @@ CC Switch 支持三种语言:
推荐使用「最小化到托盘」,方便通过托盘快速切换供应商。
### 轻量模式
v3.13.0 起新增「轻量模式」——一种**仅托盘运行**的状态,用于把空闲时的桌面占用降到最低。
**触发方式**:右键系统托盘图标 → 点击「轻量模式」。主窗口会被**销毁**(而不是隐藏),UI 资源和内存随之释放。
**退出方式**:从托盘菜单点击「打开主界面」,或通过深链接 / 再次启动 CC Switch。窗口会按需**重建**,状态保持一致。
| 特性 | 最小化到托盘 | 轻量模式 |
| ------------ | -------------------- | ------------------------ |
| UI 进程 | 保留在内存中 | 完全销毁 |
| 空闲资源占用 | 与正常运行相当 | 接近零 |
| 再次打开速度 | 瞬时(直接显示) | 略慢(需要重建窗口) |
| 托盘切换功能 | 可用 | 可用 |
| 深链接唤起 | 可用 | 可用(按需重建) |
> 💡 **使用场景**:如果你 CC Switch 长时间常驻后台,主要通过托盘菜单切换供应商,开启轻量模式能显著降低内存占用。
> ⚠️ **注意**:轻量模式状态不持久 — 下次正常启动时会回到普通模式。需要长期使用可搭配开机自启。
### Claude 插件集成
开启后,CC Switch 在切换供应商时会自动同步配置到 VS Code 中的 Claude Code 插件(写入 `~/.claude/config.json``primaryApiKey`)。
@@ -251,6 +271,26 @@ CC Switch 自身数据的存储位置,默认为 `~/.cc-switch/`。
- **debug** - 记录调试信息
- **trace** - 记录所有详细信息
## OAuth 认证中心(Beta
设置 → **OAuth 认证中心** Tab
v3.13.0 新增的 **OAuth 认证中心**(Beta)统一管理第三方 OAuth 凭据,目前支持两类账号:
| 账号类型 | 用途 |
| ---------------------------- | ----------------------------------------------- |
| **GitHub Copilot** | 配合 Copilot 反向代理使用 |
| **ChatGPT (Codex OAuth)** | 配合 Codex OAuth 反向代理使用,管理 ChatGPT 账号 |
**你可以在这里**
- 通过 Device Code 流程登录 ChatGPT / GitHub 账号
- 查看已登录账号列表和认证状态
- 为多账号设置默认账号
- 移除单个账号或一键注销所有账号
> ⚠️ **注意**:这两项功能使用逆向 OAuth 流程,存在账号风险和服务条款风险。使用前请阅读 [2.1 添加供应商 → Codex OAuth 反向代理](../2-providers/2.1-add.md#codex-oauth-反向代理claude-供应商) 的完整风险提示。
## 关于页面
设置 → 关于 Tab
+161 -7
View File
@@ -154,19 +154,20 @@
## 自动获取模型
添加或编辑供应商时,可以自动从供应商端点获取可用模型列表
添加或编辑供应商时,可以自动从供应商端点发现可用模型列表,免去手动复制粘贴模型 ID 的繁琐流程。
1. 确保已填写 **API Key** 和 **端点地址**
2. 点击模型输入框旁的 **获取模型** 按钮(下载图标)
3. CC Switch 调用供应商`/v1/models` 端点获取模型列表
4. 从按供应商分组的下拉菜单中选择模型
3. CC Switch 使用配置的 API Key 调用 OpenAI 兼容`/v1/models` 端点
4. 从按类别分组的下拉菜单中选择模型
此功能适用于所有支持 OpenAI 兼容 `/v1/models` API 的供应商。ClaudeCodexGeminiOpenCode OpenClaw 供应商均可使用
此功能覆盖全部五个应用 —— **Claude / Codex / Gemini / OpenCode / OpenClaw**,适用于所有支持 `/v1/models` 端点的供应商
**常见错误**
- **认证失败(401/403**:检查你的 API Key
- **端点不支持(404/405**:该供应商未提供 `/v1/models` 端点
- **超时**端点响应缓慢,请稍后重试
- **认证失败(401/403**:检查你的 API Key 是否正确
- **端点不支持(404/405**:该供应商未提供 `/v1/models` 端点,需手动填写模型 ID
- **解析失败**返回内容不符合 OpenAI 兼容格式
- **超时**:端点响应缓慢,请稍后重试或检查网络
## 自定义配置
@@ -330,6 +331,135 @@ CC Switch 支持两种方式导入供应商配置:
> ⚠️ **注意**:导入会覆盖现有数据库,建议先导出当前配置作为备份。导出的文件名格式为 `cc-switch-export-{时间戳}.sql`
## Codex OAuth 反向代理(Claude 供应商)
v3.13.0 起,CC Switch 新增了 **Codex OAuth 反向代理**路径,让你可以**用 ChatGPT 账号**在 Claude Code 中复用 Codex 服务。
> 💡 **位置提示**:这项功能作为一个**新的 Claude 供应商卡片类型**出现,而不是 Codex 侧的预设。添加后会和普通 API-Key 型供应商并列在 Claude 的供应商列表中。
### 前提条件
- 拥有可登录的 **ChatGPT 账号**
- 能够访问 `auth.openai.com``chatgpt.com`
- **在使用前请先阅读本节末尾的 [⚠️ 风险提示](#-风险提示重要)**
### 两个入口
你可以从下面任意一个入口开始:
#### 入口 A:从添加供应商面板开始(推荐新用户)
1. 切换到 **Claude** 应用
2. 点击右上角的 **+** 按钮打开添加供应商面板
3. 在预设列表的第三方分类下选择 **Codex (ChatGPT Plus/Pro)** 预设(以 UI 中显示的名称为准)
4. 如果尚未登录 ChatGPT 账号,面板会**自动引导**你进入登录流程(见下文"登录流程")
5. 登录成功后,供应商表单会显示已登录的账号,点击「保存」完成添加
#### 入口 B:从 OAuth 认证中心开始(适合多账号管理)
1. 打开 **设置 → OAuth 认证中心**(标签页顶部带 **Beta** 标记)
2. 在 **ChatGPT (Codex OAuth)** 区块点击 **使用 ChatGPT 登录** 按钮
3. 完成登录流程(见下文)
4. 登录完成后,回到 **Claude** 应用 → **添加供应商** → 选择同一个 Codex (ChatGPT Plus/Pro) 预设
5. 在表单中的「选择账号」下拉框选择刚登录的账号,保存即可
### 登录流程(Device Code
不管从哪个入口进入,登录流程都一致:
1. **获取验证码**CC Switch 调用 OpenAI Device Code 流程,并在界面上显示:
- 一个 **验证码**(约 8 位字符,例如 `ABCD-1234`
- 验证码右侧的 **复制** 按钮
- 下方的授权链接 `https://auth.openai.com/codex/device`
- "等待授权中..." 的动画提示
2. **浏览器授权**:点击链接(或手动访问该 URL),在浏览器中:
- 登录你的 ChatGPT 账号
- 输入上一步复制的验证码
- 确认授权
3. **自动轮询完成**:CC Switch 会在后台持续轮询 OpenAI 服务器,检测到授权成功后自动关闭等待界面
4. **显示已登录账号**:登录的 ChatGPT 账号会出现在 **OAuth 认证中心 → 已登录账号**列表中,显示登录邮箱
> ⏱️ **验证码有效期约 15 分钟**。如果超时,界面会显示"Device Code 已过期",点击「重试」即可重新获取验证码。
### 启用与使用
添加并保存 Codex OAuth 供应商后:
1. 在 Claude 供应商列表中找到它
2. 点击卡片的 **启用** 按钮 — 和普通供应商完全一致
3. Claude Code CLI 即可通过反向代理使用 ChatGPT 订阅
4. 托盘菜单的 **Claude** 子菜单中也会出现这个供应商,支持快速切换
> 💡 **底层细节**CC Switch 会将请求路由到 `https://chatgpt.com/backend-api/codex`,Base URL 被强制重写 — 你**无需**在表单中手动填写端点地址。API 格式固定为 `openai_responses`
### 默认模型
Codex OAuth 预设的默认模型映射:
| 角色 | 默认模型 |
| ------------- | -------------- |
| 主模型 | `gpt-5.4` |
| Sonnet 角色 | `gpt-5.4` |
| Opus 角色 | `gpt-5.4` |
| Haiku 角色 | `gpt-5.4-mini` |
你可以在供应商的 JSON 编辑器中覆盖 `ANTHROPIC_MODEL` 等环境变量来自定义。
### 多账号管理(OAuth 认证中心)
**OAuth 认证中心**支持同时管理多个 ChatGPT 账号:
| 操作 | 说明 |
| ---------------- | ---------------------------------------------------- |
| 添加其他账号 | 点击「添加其他账号」重复登录流程 |
| 设为默认 | 在账号行点击「设为默认」—— 新建供应商默认使用该账号 |
| 为供应商选账号 | 供应商表单中通过「选择账号」下拉框指定特定账号 |
| 移除账号 | 点击账号右侧的红色 × 移除(Token 被清除) |
| 注销所有账号 | 底部「注销所有账号」按钮一键清除 |
> 💡 **使用场景**:如果你和团队共享一台开发机,可以为每个成员的 ChatGPT 账号各建一个供应商,通过托盘菜单快速切换。
### Token 自动刷新
- Token 会在**过期前 60 秒**自动刷新,全程后台进行,无需手动干预
- Refresh Token 存储在本地数据目录,不会上传到任何地方
- **不支持**导出 Token(防止泄露)
### 配额展示
登录并启用供应商后,**供应商卡片底部**会自动显示账号配额:
| 显示元素 | 示例 | 颜色规则 |
| ------------ | ------------------ | ------------------------------------------- |
| 使用百分比 | `45%` | < 70% 绿色,7089% 橙色,≥ 90% 红色 |
| 重置倒计时 | `7d12h 后重置` | ChatGPT 账号的滑动窗口或每日限额 |
| 刷新按钮 | 圆形箭头 | 手动重新查询配额 |
> ⚠️ **会话已过期**:如果 Token 完全失效(无法自动刷新),卡片底部会显示黄色警告框「会话已过期」。此时请到 **OAuth 认证中心**移除该账号并重新登录。
### 常见失败
| 场景 | 表现 | 解决方法 |
| -------------------- | ------------------------------- | --------------------------------------- |
| 验证码超时 | 显示"Device Code 已过期" | 点击「重试」重新获取验证码 |
| 浏览器拒绝授权 | 显示"用户拒绝授权" | 重新登录,在浏览器中点击"授权" |
| 网络错误 | 显示具体错误信息 | 检查网络连接,确认能访问 OpenAI 域名 |
| 创建供应商前未登录 | "请先登录 ChatGPT 账号"提示 | 先到 OAuth 认证中心完成登录 |
| Token 失效无法刷新 | 配额框显示"会话已过期" | 移除账号后重新登录 |
| 配额查询失败 | 配额框显示"查询失败" | 点击「刷新」按钮重试 |
### ⚠️ 风险提示(重要)
Codex OAuth 反向代理通过**逆向工程的 OAuth 流程**访问 ChatGPT 账号的 Codex 服务。启用前请务必理解以下风险:
1. **违反服务条款**:可能违反 OpenAI 的服务条款,该条款禁止未经授权的自动化访问、服务复制和绕过既定访问路径
2. **账号风险**:OpenAI 可能将异常使用模式标记为可疑自动化,对 ChatGPT 账号施加临时或永久限制
3. **无法保证长期可用**:OpenAI 随时可能更新其认证和检测机制,当前可用的方式未来可能被封堵
**启用此功能即表示你自行承担所有风险**。CC Switch 不对因使用本功能产生的账号限制、警告或服务暂停承担责任。
> 📖 完整免责声明及更多背景参见 [v3.13.0 Release Notes](../../../release-notes/v3.13.0-zh.md#-风险提示)。
## 高级选项
### API 格式(仅 Claude
@@ -346,6 +476,30 @@ CC Switch 支持两种方式导入供应商配置:
当配置了非默认 API 格式时,高级选项区域会自动展开。
### 完整 URL 端点模式
v3.13.0 起新增的高级选项。默认情况下,CC Switch 会把配置的 `base_url` 视作**前缀**,再在其后拼接 `/v1/chat/completions` 等固定路径。对于部分厂商(如需要非标准 URL 布局的第三方服务),这种拼接方式会导致请求失败。
**启用方式**
1. 编辑供应商,展开「高级选项」
2. 勾选 **完整 URL 模式** 复选框
3. 将**完整的上游端点**(而非前缀)填入 `base_url`
**示例对比**
| 模式 | `base_url` 填写 | 实际请求目标 |
| ----------------------- | ------------------------------------------------ | ------------------------------------------------ |
| 默认(前缀拼接) | `https://api.example.com` | `https://api.example.com/v1/chat/completions` |
| **完整 URL 模式** | `https://api.example.com/custom/path/messages` | `https://api.example.com/custom/path/messages` |
**适用场景**
- 供应商要求使用非标准路径(不是 `/v1/chat/completions`
- 供应商有多层级路径结构
- 厂商专属的 API 网关路径
> 💡 **提示**:代理转发和 Stream Check 都会遵循「完整 URL 模式」配置,因此启用后无需额外调整。如果关闭此选项,路径拼接恢复为默认行为。
### Claude 通用配置快捷开关
编辑 Claude 供应商时,JSON 编辑器上方提供一组 **快捷开关**
+18 -2
View File
@@ -30,10 +30,26 @@
3. 点击要切换到的供应商名称
4. 切换完成,托盘会短暂提示
> 供应商按应用类型(Claude/Codex/Gemini)组织到折叠子菜单中。子菜单标题显示当前激活的供应商名称。
### 托盘菜单结构
v3.13.0 起,托盘菜单从原来的扁平列表重构为**按应用分组的分级子菜单**,为每个应用独立建立子菜单:
| 子菜单 | 说明 |
| ----------- | -------------------------------------------- |
| Claude | Claude 所有供应商(含 Codex OAuth 反向代理) |
| Codex | Codex 所有供应商 |
| Gemini | Gemini 所有供应商 |
| OpenCode | OpenCode 所有供应商 |
| OpenClaw | OpenClaw 所有供应商 |
**重构带来的好处**
- **防止菜单溢出**:有大量供应商时,扁平列表会超出屏幕高度;分级子菜单天然支持无限扩展
- **子菜单标题显示当前激活供应商**:无需打开子菜单即可知道每个应用当前用的是哪个供应商
- **按应用隔离操作**:切换 Claude 的供应商不会干扰到 Codex 的视图
> 💡 **提示**:后台常驻 + 轻量模式 + 分级子菜单的组合特别适合频繁切换多个应用的重度用户。参考 [1.5 个性化配置 → 轻量模式](../1-getting-started/1.5-settings.md)。
![image-20260108004348993](../../assets/image-20260108004348993.png)
## 生效方式
@@ -1,8 +1,72 @@
# 2.5 用量查询
## 功能说明
CC Switch 的配额/余额展示分为两大类:**自动查询**(官方订阅类,开箱即用)和**手动启用**(内置模板 + 自定义脚本,需要用户配置后再显示)。
用量查询功能允许你配置自定义脚本,实时查询供应商的剩余额度、已用量等信息。
| 类别 | 范围 | 是否需要用户启用 |
| -------------------------- | --------------------------------------------------------------------- | ---------------- |
| **自动查询** | Claude / Codex / Gemini 官方订阅、GitHub Copilot、Codex OAuth 反向代理 | 否(默认启用) |
| **手动启用(内置模板)** | Token Plan、第三方余额查询 | 是(见下文) |
| **手动启用(自定义脚本)** | 未被内置模板覆盖的中转服务、私有部署、特殊 API | 是(见下文) |
## 自动查询(官方订阅类)
v3.13.0 起,以下三类供应商在启用后会**自动**在卡片底部显示配额,用户无需任何额外配置:
| 类别 | 覆盖供应商 | 显示内容 |
| --------------- | ----------------------------------------- | --------------------------- |
| 官方订阅 | Claude / Codex / Gemini 官方登录 | 官方订阅配额 |
| GitHub Copilot | Copilot 供应商卡片 | Premium interactions 剩余量 |
| Codex OAuth | Codex OAuth 反向代理卡片(Claude 供应商) | ChatGPT 账号 Codex 配额 |
这三类的共同特点是**数据来源唯一且语义明确**(官方订阅的使用率),不存在歧义,因此 CC Switch 直接调用对应的官方或 OAuth 查询接口。
### 自动查询的交互
- **卡片底部显示**:使用百分比 + 重置倒计时,颜色随使用率变化(< 70% 绿 / 7089% 橙 / ≥ 90% 红)
- **手动刷新**:点击卡片上的刷新图标按钮重新查询
- **卡片简化**:对这三类供应商,**健康检查**和**用量查询配置**按钮会被自动隐藏,避免干扰内置展示
- **会话过期提示**:如果 Token 无法刷新,卡片会显示「会话已过期」警告(Copilot / Codex OAuth
---
## 手动启用(内置模板 + 自定义脚本)
除了上述三类自动查询的供应商,**所有其他供应商**(包括 Token Plan、第三方余额查询、以及各类中转服务)都需要在供应商卡片上**手动打开「用量查询」开关**后才会显示配额。
### 为什么需要手动启用?
一个重要原因是:**同一个请求地址(同一家供应商)可能同时提供多种查询模式** —— 既可能有按套餐的配额查询,也可能有按账户余额的查询。CC Switch 无法自动推断你想查哪一种,所以这类供应商的内置查询**默认关闭**,由你选择合适的模板后启用。
### 覆盖的内置模板
v3.13.0 为以下类别提供了**开箱即用的内置模板**,启用后无需手写脚本:
| 类别 | 覆盖供应商 | 模板类型 |
| ---------- | --------------------------------------------------------- | ----------------------- |
| Token Plan | Kimi / Zhipu GLM / MiniMax | 套餐配额(带使用进度) |
| 第三方余额 | DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | 官方余额查询 |
> 💡 除了以上内置模板外,对未被覆盖的供应商,你可以使用**自定义脚本**方式(见下文)编写自己的查询逻辑。
### 启用步骤
1. 鼠标悬停在供应商卡片上,显示操作按钮
2. 点击 **用量查询** 按钮(📊 图标)
3. 在配置面板顶部打开 **启用用量查询** 开关
4. 选择合适的内置模板(例如 Token Plan、第三方余额)或选择「自定义」
5. 按需填入 API Key / Base URL / Access Token 等参数(大多数情况可留空,使用供应商本身的凭据)
6. 点击「测试脚本」确认能正常返回
7. 保存配置 —— 下次激活该供应商时,配额将显示在卡片底部
> ⚠️ **注意**:启用后的自动刷新间隔通过「自动查询间隔」字段控制(设为 `0` 禁用自动刷新),仅当供应商处于「当前启用」状态时才会触发后台查询。
---
## 自定义脚本查询(高级)
### 功能说明
当供应商**不在内置模板覆盖范围**内时,你可以用 JavaScript 编写自定义查询脚本。适用于中转服务、私有部署、特殊格式 API 等。
**使用场景**
- 查看 API 账户剩余余额
@@ -155,6 +219,24 @@ CC Switch 提供三种预设模板:
### 故障排除
### 自动查询未显示配额(官方订阅类)
**检查**
1. 确认供应商是官方订阅类 —— Claude / Codex / Gemini 官方登录、GitHub Copilot、Codex OAuth 反向代理
2. 供应商是否处于「当前启用」状态(非激活时不会触发查询)
3. 对于 OAuth 类型(Copilot / Codex OAuth),检查 Token 是否仍在有效期内;如果卡片显示「会话已过期」,请到 **OAuth 认证中心**重新登录
4. 网络是否可访问官方配额接口
### 手动启用后仍未显示配额
**检查**
1. 供应商卡片的「用量查询」面板顶部**启用用量查询**开关是否已打开
2. 是否选择了合适的内置模板(Token Plan / 第三方余额 / 自定义)
3. 点击「测试脚本」查看返回的具体错误信息
4. API Key / Base URL 等必要字段是否填写正确
5. 网络是否可访问供应商的配额端点
6. 仅当供应商处于「当前启用」状态时,后台自动查询才会生效
### 查询失败
**检查**
+64 -4
View File
@@ -192,11 +192,59 @@ Subdirectory: skills
## 技能更新
目前不支持自动更新。如需更新技能:
v3.13.0 起,Skills 支持**自动更新检测**和**批量更新**,不再需要卸载后重新安装。
1. 卸载现有技能
2. 刷新列表
3. 重新安装
### 更新检测原理
CC Switch 基于 **SHA-256 内容哈希**比较本地已安装的 skill 与远端仓库版本。只要远端有任何文件内容变化,本地对应的 skill 卡片会自动显示「有新版本」标识。
### 单项更新
对于有新版本的 skill
1. 在 Skills 面板找到带更新标识的 skill 卡片
2. 点击卡片上的 **更新** 按钮
3. 等待下载完成,状态自动刷新
### 全部更新
当有多个 skill 需要更新时:
1. 点击 Skills 面板顶部的 **全部更新** 按钮(出现时带滑入动画)
2. CC Switch 会批量下载所有需要更新的 skill
3. 完成后面板自动刷新,更新标识消失
> 💡 **建议**:定期点击「刷新」按钮触发一次远端扫描,确保更新检测结果最新。
## 存储位置切换
v3.13.0 起,Skills 的**源存储位置**可以在两个位置之间切换:
| 位置 | 说明 |
| ------------------------ | -------------------------------------------------------- |
| **CC Switch 内置存储** | 默认位置 `~/.cc-switch/skills/`,由 CC Switch 统一管理 |
| **`~/.agents/skills`** | 符合社区 agent 工具约定的共享目录,便于与其他工具协同 |
### 切换方式
在 Skills 面板的设置或管理菜单中选择目标存储位置。切换过程**不会丢失 skill 状态** —— CC Switch 会平滑迁移现有 skill 到新位置。
> ⚠️ **区别提示**:本节的「存储位置切换」管理的是 skill 的**源存储**。而 [1.5 个性化配置 → Skills 同步方式](../1-getting-started/1.5-settings.md) 管理的是 skill 如何**分发到各应用目录**(软链接 vs 复制),两者配合使用。
## 公共注册表搜索(skills.sh
v3.13.0 集成了 **skills.sh** 公共注册表搜索,让你直接在 CC Switch 内发现社区 skill。
### 使用步骤
1. 点击「仓库管理」按钮打开对话框
2. 在对话框内使用 **skills.sh 搜索** 输入框
3. 输入关键词实时筛选结果
4. 点击目标 skill 即可快速添加到你的仓库列表
v3.13.0 还修复了 skills.sh 链接失效和空描述的兼容处理,社区 skill 的元数据显示更稳定。
## 常见问题
### 技能列表为空
@@ -224,3 +272,15 @@ Subdirectory: skills
- 检查网络连接
- 检查磁盘空间
- 检查目录权限
### 更新按钮不出现
可能原因:
- 远端仓库没有新内容
- CC Switch 尚未完成最新扫描
解决方法:
- 点击「刷新」重新扫描
- 确认仓库配置指向正确的分支和路径
@@ -89,6 +89,22 @@
> 如果会话没有可用的恢复命令,恢复按钮将被禁用。
#### 目录选择器(Claude 终端恢复)
v3.13.0 起,**Claude 会话**恢复前会弹出**目录选择器**,让你可以覆盖默认的项目目录。适用于下列场景:
- **项目已迁移**:原项目目录已被移动或重命名
- **软链接断裂**:原始路径无法访问
- **临时换目录**:想在不同的工作目录中继续对话
**使用方法**
1. 点击 Claude 会话的 **恢复** 按钮
2. 在弹出的目录选择器中,确认默认目录或选择新目录
3. CC Switch 会在所选目录下启动 Claude 终端会话
> 💡 **提示**Codex / Gemini / OpenCode / OpenClaw 会话的恢复流程暂不包含目录选择器,仍使用会话原始项目目录。
### 删除会话
点击 **删除** 按钮(垃圾桶图标)永久删除会话文件。删除前会显示确认对话框。
+22 -2
View File
@@ -9,14 +9,34 @@
- 分析使用模式
- 排查问题
v3.13.0 起,用量数据有两个来源:
| 数据来源 | 覆盖范围 | 是否需要代理拦截 |
| -------------------------- | -------------------------------- | ---------------- |
| **代理请求日志** | 通过代理转发的所有请求 | 需要 |
| **CLI 会话日志**v3.13 新增) | Claude / Codex / Gemini 会话历史 | 不需要 |
- **Codex 会话**:改用 JSONL 会话日志**精确解析**,替代原先的估算,并对模型名称做归一化保证定价查询一致
- **Gemini 会话**:通过 Gemini CLI 会话日志精确同步
- **Claude 会话**:同样支持从会话日志直接导入用量
- 用量面板支持**按应用筛选**Claude / Codex / Gemini),数据互不干扰
## 前提条件
使用用量统计功能需要
根据你使用的数据来源,前提条件不同
**代理请求日志**(覆盖全部应用和所有代理请求):
1. ✅ 启动代理服务
2. ✅ 开启应用接管
3. ✅ 开启日志记录
**CLI 会话日志**(v3.13 新增,无需代理):
1. ✅ 在 CC Switch 中启用对应应用(Claude / Codex / Gemini
2. ✅ 确保对应 CLI 有会话历史文件
3. ✅ CC Switch 会定期扫描会话目录并导入用量
## 打开用量统计
设置 → 用量 Tab
@@ -212,7 +232,7 @@
### 预设价格
CC Switch 预设了常用模型的官方价格(每百万 Token)
CC Switch 预设了常用模型的官方价格(每百万 Token)。v3.13.0 修正了部分模型的 **CNY → USD 定价**并补齐了此前缺失的模型定义,同时修复了 **MiniMax 套餐配额数学**与 **0% → 100% 用量进度**,使费用估算和套餐进度展示更准确。
**Claude 系列(美元)**
+11 -6
View File
@@ -2,12 +2,15 @@
## 功能说明
模型检查功能用于验证供应商配置的模型是否可用,通过发送实际的 API 请求来测试:
模型检查功能(也称为 **Stream Check**用于验证供应商配置的模型是否可用,通过发送实际的 API 请求来测试:
- 模型是否存在
- API Key 是否有效
- 端点是否正常响应
- 响应延迟是否正常
- 流式响应首字节时间(TTFB
v3.13.0 起,Stream Check 覆盖范围扩展到**全部五个应用**Claude / Codex / Gemini / OpenCode / OpenClaw),包括 OpenClaw 的全部协议变体(`openai-completions` 等)。OpenCode 通过 npm 包映射自动识别;OpenClaw 支持自定义 `auth-header` 检测,并处理了 Bedrock 错误消息、`baseURL` 回退等边界情况。
## 打开配置
@@ -17,11 +20,13 @@
为每个应用配置用于测试的模型:
| 应用 | 配置项 | 默认值 | 说明 |
|------|--------|--------|------|
| Claude | Claude 模型 | 系统默认 | 建议使用 Haiku 系列(成本低、速度快) |
| Codex | Codex 模型 | 系统默认 | 建议使用 mini 系列 |
| Gemini | Gemini 模型 | 系统默认 | 建议使用 Flash 系列 |
| 应用 | 配置项 | 默认值 | 说明 |
| -------- | ------------- | -------- | -------------------------------------------- |
| Claude | Claude 模型 | 系统默认 | 建议使用 Haiku 系列(成本低、速度快) |
| Codex | Codex 模型 | 系统默认 | 建议使用 mini 系列 |
| Gemini | Gemini 模型 | 系统默认 | 建议使用 Flash 系列 |
| OpenCode | OpenCode 模型 | 系统默认 | v3.13.0 新增,通过 npm 包映射自动检测 |
| OpenClaw | OpenClaw 模型 | 系统默认 | v3.13.0 新增,覆盖全部协议变体及自定义 auth-header |
### 模型选择建议
@@ -156,6 +156,51 @@ chmod +x CC-Switch-*.AppImage
- [ ] 日志记录是否开启
- [ ] 是否有请求通过代理
## 配额与余额
### 为什么有的供应商自动显示配额,有的需要手动启用?
只有**官方订阅类**Claude / Codex / Gemini 官方登录、GitHub Copilot、Codex OAuth 反向代理)会在启用供应商后自动显示配额。**其他所有供应商**(包括 Token Plan 和第三方余额查询)都需要手动到供应商卡片的「用量查询」面板中打开开关并选择内置模板,因为同一个请求地址可能同时有"套餐"和"余额"两种查询模式,需要你自行选择。详见 [2.5 用量查询 → 手动启用](../2-providers/2.5-usage-query.md#手动启用内置模板--自定义脚本)。
### 官方订阅供应商没有显示配额
**检查**
1. 确认供应商处于「当前启用」状态(非激活时不触发查询)
2. 对于 Copilot / Codex OAuth,检查 OAuth Token 是否仍在有效期内;如果卡片显示「会话已过期」,请到 **OAuth 认证中心**重新登录
3. 检查网络连通性
4. 点击卡片上的刷新图标手动重新查询
### Token Plan 或第三方余额启用后仍不显示
**检查**
1. 确认在「用量查询」面板中已打开「启用用量查询」开关
2. 已经选择了合适的内置模板并保存
3. 点击「测试脚本」查看具体错误信息
4. 供应商需要处于「当前启用」状态后台才会自动刷新
### Codex 用量和直连时对不上
v3.13.0 将 Codex 用量从估算切换为**基于 JSONL 会话日志的精确解析**,同时对模型名称做归一化以保证定价查询一致。新数据会与官方账单对齐;若仍看到旧的估算数据,可以删除历史条目或等待新会话数据覆盖。
## Codex OAuth 反向代理
### 启用 Codex OAuth 反向代理有什么风险?
Codex OAuth 反向代理通过**逆向工程的 OAuth 流程**访问 ChatGPT 账号的 Codex 服务,可能违反 OpenAI 的服务条款,存在账号被限制或暂停的风险,且长期可用性无法保证。**启用即表示自行承担所有风险**。
完整免责声明参见 [v3.13.0 Release Notes → 风险提示](../../../release-notes/v3.13.0-zh.md#-风险提示) 和 [2.1 添加供应商 → Codex OAuth 反向代理](../2-providers/2.1-add.md)。
### 如何登录 Codex OAuth
完整的 Device Code 登录流程(验证码 + 浏览器授权)、两个入口(添加供应商面板 / OAuth 认证中心)、多账号管理和常见失败场景,参见 [2.1 添加供应商 → Codex OAuth 反向代理(Claude 供应商)](../2-providers/2.1-add.md#codex-oauth-反向代理claude-供应商)。
### Codex OAuth 登录后配额没显示
**解决方法**
1. 确认在 **OAuth 认证中心**(设置 → OAuth 认证中心,带 Beta 标记)中已完成 OAuth 登录流程
2. 检查 Token 是否仍在有效期内 — 卡片上如果显示"会话已过期"表示 Token 无法刷新
3. 如果过期,在 OAuth 认证中心移除该账号后重新登录
## 其他问题
### 托盘图标不显示
@@ -193,6 +238,10 @@ chmod +x CC-Switch-*.AppImage
是的。轻量模式会销毁主窗口及其 Web 视图,显著减少内存占用,同时保留托盘菜单功能。
### 轻量模式下深链接还能唤起主界面吗?
可以。CC Switch v3.13.0 起会覆盖所有窗口重新显示路径(正常启动、深链接、单例激活、托盘 `show_main` 以及轻量模式返程),点击 `ccswitch://` 链接会**按需重建**主窗口并显示导入确认对话框。第一次打开会比普通状态略慢(需要重建窗口),但后续切换恢复正常速度。
## 获取帮助
### 提交 Issue
+13 -3
View File
@@ -103,9 +103,19 @@
## 版本信息
- 文档版本:v3.12.3
- 最后更新:2026-04-04
- 适用于 CC Switch v3.12.3+
- 文档版本:v3.13.0
- 最后更新:2026-04-08
- 适用于 CC Switch v3.13.0+
### v3.13.0 亮点
- **轻量模式**:退出到托盘时销毁主窗口,空闲占用接近零 — 详见 [1.5 个性化配置](./1-getting-started/1.5-settings.md)
- **配额与余额展示**:官方订阅类(Claude/Codex/Gemini/Copilot/Codex OAuth)自动展示剩余额度;Token Plan 和第三方余额通过内置模板一键启用 — 详见 [2.5 用量查询](./2-providers/2.5-usage-query.md)
- **Codex OAuth 反向代理**:用 ChatGPT 账号在 Claude Code 中复用 Codex 服务 — 详见 [2.1 添加供应商](./2-providers/2.1-add.md)
- **托盘按应用分级菜单**:五应用独立子菜单,防止菜单溢出 — 详见 [2.2 切换供应商](./2-providers/2.2-switch.md)
- **Skills 发现与批量更新**:SHA-256 更新检测、批量更新、skills.sh 公共注册表搜索 — 详见 [3.3 Skills 技能管理](./3-extensions/3.3-skills.md)
- **完整 URL 端点模式**:高级选项支持将 base_url 视作完整上游端点 — 详见 [2.1 添加供应商](./2-providers/2.1-add.md)
- **OpenCode / OpenClaw 流式检测全覆盖**Stream Check 面板扩展到全部五个应用 — 详见 [4.5 模型检查](./4-proxy/4.5-model-test.md)
## 贡献
+597
View File
@@ -0,0 +1,597 @@
# CC-Switch "工作目录" 功能 — 实施方案
## Context
CC-Switch 管理 5 个 CLI 工具(Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw)的供应商、MCP 服务器、Skills、提示词配置。当前所有启用状态是全局的——用户在不同项目间切换时需要手动 toggle。
本功能允许用户注册多个工作目录(项目文件夹),切换目录时自动保存/恢复各实体的启用状态。**不做数据隔离**——所有实体共享全局池,仅 "谁是激活的" 按目录区分。
---
## 一、需要按目录区分的实体(完整清单)
| 实体 | 当前状态字段 | 存储方式 | 需要区分? | 理由 |
|------|-------------|---------|-----------|------|
| **Provider** | `is_current` | per `(id, app_type)` | **YES** | 不同项目用不同供应商 |
| **Provider (Failover)** | `in_failover_queue` | per `(id, app_type)` | **YES** | 备用供应商队列跟随主供应商配置 |
| **MCP Server** | `enabled_claude/codex/gemini/opencode` | per `id`, 4列 | **YES** | 不同项目需要不同 MCP 工具 |
| **Skill** | `enabled_claude/codex/gemini/opencode` | per `id`, 4列 | **YES** | 不同项目需要不同 Skills |
| **Prompt** | `enabled` | per `(id, app_type)`, 单选 | **YES** | 不同项目用不同系统提示词 |
| Proxy Config | `enabled`, thresholds | per `app_type` | NO | 基础设施级别,非项目相关 |
| Settings | key-value | flat table | NO | 全局用户偏好 |
| Provider Health | failures, errors | runtime | **CLEAR** | 切换时清除,重新计算 |
| Common Config | `common_config_{app}` | settings table | NO | 全局模板,非项目相关 |
| Usage/Logs | historical | various tables | NO | 历史数据,不应分区 |
> 原计划遗漏了 **Failover Queue****Provider Health 清除**
---
## 二、数据库变更(Schema v8 → v9
### 新增 5 张表
```sql
-- 1. 工作目录注册表
CREATE TABLE IF NOT EXISTS working_directories (
id TEXT PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
name TEXT,
is_current BOOLEAN NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT 0
);
-- 2. Provider 状态快照 (is_current + in_failover_queue)
-- 每个目录保存所有 provider 的两个状态标志
CREATE TABLE IF NOT EXISTS dir_provider_state (
dir_id TEXT NOT NULL,
app_type TEXT NOT NULL,
provider_id TEXT NOT NULL,
is_current BOOLEAN NOT NULL DEFAULT 0,
in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (dir_id, app_type, provider_id)
);
-- 3. MCP 启用状态快照 (直接镜像 4 列,不做行展开)
CREATE TABLE IF NOT EXISTS dir_mcp_state (
dir_id TEXT NOT NULL,
mcp_id TEXT NOT NULL,
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (dir_id, mcp_id)
);
-- 4. Skill 启用状态快照 (直接镜像 4 列)
CREATE TABLE IF NOT EXISTS dir_skill_state (
dir_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (dir_id, skill_id)
);
-- 5. Prompt 启用状态快照 (每个 app_type 只存激活的 prompt_id)
CREATE TABLE IF NOT EXISTS dir_prompt_state (
dir_id TEXT NOT NULL,
app_type TEXT NOT NULL,
prompt_id TEXT NOT NULL,
PRIMARY KEY (dir_id, app_type)
);
```
### 设计决策说明
**MCP/Skill 用 4 列镜像而非 `(entity_id, app_type, enabled)` 行展开**
- 与主表 `mcp_servers` / `skills` 结构一致,snapshot/apply 代码直接 copy 4 列
- 避免 4 倍行膨胀(每个 MCP 服务器 1 行 vs 4 行)
- 未来增加新 app 时,两边同步加列即可
**Prompt 只存 `(dir_id, app_type, prompt_id)`**
- 每个 app_type 最多一个 enabled prompt,不需要存 boolean
- 无记录 = 该 app 无激活 prompt
**Provider 合并 `is_current` + `in_failover_queue`**
- 两个标志都是 per `(app_type, provider_id)` 的状态
- 存在同一表中避免多表 JOIN
### 迁移脚本
`schema.rs` 中:
- `create_tables_on_conn()` 添加 5 个 CREATE TABLE
- 新增 `migrate_v8_to_v9(conn)`: 创建 5 张表 + 插入 `__default__`
- `SCHEMA_VERSION` 升至 9
- 迁移循环添加 `7 => ...` 后加 `8 => { Self::migrate_v8_to_v9(conn)?; Self::set_user_version(conn, 9)?; }`
```rust
fn migrate_v8_to_v9(conn: &Connection) -> Result<(), AppError> {
// 创建 5 张表(使用 IF NOT EXISTS,幂等)
// ...
// 插入 __default__ 虚拟目录,代表"全局默认"状态
conn.execute(
"INSERT OR IGNORE INTO working_directories (id, path, name, is_current, created_at) \
VALUES ('__default__', '__default__', NULL, 0, ?1)",
[crate::database::get_unix_timestamp()?],
)?;
Ok(())
}
```
---
## 三、后端实现
### 3.1 DAO 层 — `src-tauri/src/database/dao/working_dir.rs`
所有方法都是 `impl Database` 块,遵循现有 DAO 模式。
**关键方法签名**(需要 `_on_conn` 变体支持事务):
```rust
// ═══ 工作目录 CRUD ═══
pub fn list_working_directories(&self) -> Result<Vec<WorkingDirectory>, AppError>
pub fn add_working_directory(&self, id: &str, path: &str, name: Option<&str>) -> Result<(), AppError>
pub fn delete_working_directory(&self, id: &str) -> Result<(), AppError>
pub fn rename_working_directory(&self, id: &str, name: &str) -> Result<(), AppError>
pub fn get_current_working_directory(&self) -> Result<Option<WorkingDirectory>, AppError>
// 使用 _on_conn 变体,在 Service 层的事务中调用
fn set_current_working_directory_on_conn(conn: &Connection, id: &str) -> Result<(), AppError>
// ═══ 快照写入 ═══ (都有 _on_conn 变体)
fn snapshot_providers_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError>
fn snapshot_mcp_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError>
fn snapshot_skills_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError>
fn snapshot_prompts_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError>
// ═══ 快照恢复 ═══ (都有 _on_conn 变体, 返回 bool = 是否有快照)
fn apply_provider_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result<bool, AppError>
fn apply_mcp_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result<bool, AppError>
fn apply_skill_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result<bool, AppError>
fn apply_prompt_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result<bool, AppError>
```
**snapshot_providers 实现思路**
```sql
-- 先清除旧快照
DELETE FROM dir_provider_state WHERE dir_id = ?1;
-- 从主表复制当前状态
INSERT INTO dir_provider_state (dir_id, app_type, provider_id, is_current, in_failover_queue)
SELECT ?1, app_type, id, is_current, in_failover_queue
FROM providers
WHERE is_current = 1 OR in_failover_queue = 1;
```
**apply_provider_snapshot 实现思路**
```sql
-- 检查是否有快照
SELECT COUNT(*) FROM dir_provider_state WHERE dir_id = ?1; -- 如果 0,返回 false
-- 在事务中:先清除主表所有 is_current 和 in_failover_queue
UPDATE providers SET is_current = 0;
UPDATE providers SET in_failover_queue = 0;
-- 从快照恢复
UPDATE providers SET is_current = 1
WHERE (id, app_type) IN (SELECT provider_id, app_type FROM dir_provider_state WHERE dir_id = ?1 AND is_current = 1);
UPDATE providers SET in_failover_queue = 1
WHERE (id, app_type) IN (SELECT provider_id, app_type FROM dir_provider_state WHERE dir_id = ?1 AND in_failover_queue = 1);
```
**snapshot_mcp / snapshot_skills 实现思路**(直接镜像 4 列):
```sql
DELETE FROM dir_mcp_state WHERE dir_id = ?1;
INSERT INTO dir_mcp_state (dir_id, mcp_id, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode)
SELECT ?1, id, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
FROM mcp_servers;
```
**apply_mcp_snapshot 实现思路**
```sql
-- 先全部禁用
UPDATE mcp_servers SET enabled_claude = 0, enabled_codex = 0, enabled_gemini = 0, enabled_opencode = 0;
-- 从快照恢复
UPDATE mcp_servers SET
enabled_claude = (SELECT enabled_claude FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id),
enabled_codex = (SELECT enabled_codex FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id),
enabled_gemini = (SELECT enabled_gemini FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id),
enabled_opencode = (SELECT enabled_opencode FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id)
WHERE id IN (SELECT mcp_id FROM dir_mcp_state WHERE dir_id = ?1);
```
### 3.2 Service 层 — `src-tauri/src/services/working_dir.rs`
```rust
use crate::store::AppState;
use crate::error::AppError;
use crate::database::lock_conn;
use crate::app_config::AppType;
use crate::services::{McpService, ProviderService, SkillService};
use crate::config::write_text_file;
use crate::prompt_files::prompt_file_path;
pub struct WorkingDirService;
impl WorkingDirService {
/// 核心切换逻辑
pub fn switch(state: &AppState, target_dir_id: &str) -> Result<(), AppError> {
// ═══ 前置检查 ═══
// 1. 检查代理接管状态,若活跃则拒绝切换
// 使用 db.is_live_takeover_active() 或同步检查 proxy_config.live_takeover_active
// (因为 ProxyService::is_running() 是 async,而此函数是 sync
Self::check_proxy_not_active(state)?;
// ═══ Phase 1: 回填 Prompt ═══
// 在 snapshot 之前,将 live 文件内容回填到当前 enabled prompt
// 这样即使用户手动编辑了 live 文件,内容也不会丢失
Self::backfill_prompt_content(state)?;
// ═══ Phase 2: 数据库操作(事务) ═══
{
let conn = lock_conn!(state.db.conn);
conn.execute("BEGIN IMMEDIATE", [])?;
let result = (|| -> Result<(), AppError> {
// 获取当前工作目录
let current = Self::get_current_dir_id_on_conn(&conn)?;
// 保存当前状态到旧目录
if let Some(old_id) = &current {
Database::snapshot_providers_on_conn(&conn, old_id)?;
Database::snapshot_mcp_on_conn(&conn, old_id)?;
Database::snapshot_skills_on_conn(&conn, old_id)?;
Database::snapshot_prompts_on_conn(&conn, old_id)?;
} else {
// 无当前目录 = 全局模式,保存到 __default__
Database::snapshot_providers_on_conn(&conn, "__default__")?;
Database::snapshot_mcp_on_conn(&conn, "__default__")?;
Database::snapshot_skills_on_conn(&conn, "__default__")?;
Database::snapshot_prompts_on_conn(&conn, "__default__")?;
}
// 加载目标目录快照(如果有的话)
// 如果无快照(首次进入),保持主表不变
Database::apply_provider_snapshot_on_conn(&conn, target_dir_id)?;
Database::apply_mcp_snapshot_on_conn(&conn, target_dir_id)?;
Database::apply_skill_snapshot_on_conn(&conn, target_dir_id)?;
Database::apply_prompt_snapshot_on_conn(&conn, target_dir_id)?;
// 更新 is_current 标记
Database::set_current_working_directory_on_conn(&conn, target_dir_id)?;
Ok(())
})();
match result {
Ok(()) => conn.execute("COMMIT", [])?,
Err(e) => {
let _ = conn.execute("ROLLBACK", []);
return Err(e);
}
};
}
// conn 锁在此处释放
// ═══ Phase 3: 同步 live 配置文件 ═══
Self::sync_all_live(state)?;
// ═══ Phase 4: 清除 Provider Health ═══
state.db.clear_all_provider_health()?;
Ok(())
}
/// 回填 live prompt 文件内容到 DB(切换前调用)
fn backfill_prompt_content(state: &AppState) -> Result<(), AppError> {
for app in AppType::all() {
let path = prompt_file_path(&app)?;
if !path.exists() { continue; }
let live_content = std::fs::read_to_string(&path).unwrap_or_default();
if live_content.trim().is_empty() { continue; }
let mut prompts = state.db.get_prompts(app.as_str())?;
if let Some((_, prompt)) = prompts.iter_mut().find(|(_, p)| p.enabled) {
prompt.content = live_content;
prompt.updated_at = Some(get_unix_timestamp()?);
state.db.save_prompt(app.as_str(), prompt)?;
}
}
Ok(())
}
/// 将 DB 中的 enabled prompt 内容写入 live 文件(切换后调用)
/// 注意:不做回填!只写入。区别于 PromptService::enable_prompt()
fn write_prompts_to_live(state: &AppState) -> Result<(), AppError> {
for app in AppType::all() {
let path = prompt_file_path(&app)?;
let prompts = state.db.get_prompts(app.as_str())?;
if let Some(prompt) = prompts.values().find(|p| p.enabled) {
write_text_file(&path, &prompt.content)?;
}
// 无 enabled prompt 时不清空文件(保留现状)
}
Ok(())
}
/// 同步所有 live 配置(Provider + MCP + Skill + Prompt
fn sync_all_live(state: &AppState) -> Result<(), AppError> {
// 1. Provider → live files
ProviderService::sync_current_to_live(state)?;
// sync_current_to_live 内部已调用 McpService::sync_all_enabled()
// 2. Skills → app dirs (循环每个 app)
for app in AppType::all() {
let _ = SkillService::sync_to_app(&state.db, &app);
}
// 3. Prompts → live files
Self::write_prompts_to_live(state)?;
Ok(())
}
/// 检查代理是否活跃(同步检查数据库标志)
fn check_proxy_not_active(state: &AppState) -> Result<(), AppError> {
// 检查 proxy_config 表中 live_takeover_active 列
// 如果有任何 app 的 live_takeover_active = 1,拒绝切换
let conn = lock_conn!(state.db.conn);
let active: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM proxy_config WHERE live_takeover_active = 1)",
[], |r| r.get(0)
).unwrap_or(false);
if active {
return Err(AppError::Message(
"代理接管模式运行中,请先停止代理再切换工作目录".into()
));
}
Ok(())
}
}
```
### 3.3 Command 层 — `src-tauri/src/commands/working_dir.rs`
遵循现有模式:`State<'_, AppState>` + `Result<T, String>` + `.map_err(|e| e.to_string())`
```rust
#[tauri::command]
pub fn list_working_directories(state: State<'_, AppState>) -> Result<Vec<WorkingDirectory>, String>
#[tauri::command]
pub fn add_working_directory(state: State<'_, AppState>, path: String, name: Option<String>) -> Result<WorkingDirectory, String>
#[tauri::command]
pub fn delete_working_directory(state: State<'_, AppState>, id: String) -> Result<(), String>
#[tauri::command]
pub fn rename_working_directory(state: State<'_, AppState>, id: String, name: String) -> Result<(), String>
#[tauri::command]
pub fn switch_working_directory(state: State<'_, AppState>, id: String) -> Result<(), String>
// 调用 WorkingDirService::switch()
#[tauri::command]
pub fn get_current_working_directory(state: State<'_, AppState>) -> Result<Option<WorkingDirectory>, String>
```
### 3.4 需修改的现有文件
| 文件 | 修改内容 |
|------|---------|
| `src-tauri/src/database/schema.rs` | 添加 5 个 CREATE TABLE + `migrate_v8_to_v9()` |
| `src-tauri/src/database/mod.rs` | `SCHEMA_VERSION = 9` + 迁移循环加 `8 => ...` + `pub mod working_dir` in dao |
| `src-tauri/src/database/dao/mod.rs` | 添加 `pub mod working_dir;` |
| `src-tauri/src/services/mod.rs` | 添加 `pub mod working_dir;` + `pub use working_dir::WorkingDirService;` |
| `src-tauri/src/commands/mod.rs` | 添加 `mod working_dir;` + `pub use working_dir::*;` |
| `src-tauri/src/lib.rs` | invoke_handler 注册 6 个新命令 |
### 3.5 可能需要新增的 DAO 辅助方法
`src-tauri/src/database/dao/failover.rs`
```rust
/// 清除所有 provider_health 记录(切换目录时调用)
pub fn clear_all_provider_health(&self) -> Result<(), AppError>
```
---
## 四、前端实现
### 4.1 API — `src/lib/api/workingDir.ts`
```typescript
import { invoke } from "@tauri-apps/api/core";
export interface WorkingDirectory {
id: string;
path: string;
name?: string;
isCurrent: boolean;
createdAt: number;
}
export const workingDirApi = {
list: () => invoke<WorkingDirectory[]>("list_working_directories"),
add: (path: string, name?: string) =>
invoke<WorkingDirectory>("add_working_directory", { path, name }),
delete: (id: string) => invoke<void>("delete_working_directory", { id }),
rename: (id: string, name: string) =>
invoke<void>("rename_working_directory", { id, name }),
switch: (id: string) => invoke<void>("switch_working_directory", { id }),
getCurrent: () =>
invoke<WorkingDirectory | null>("get_current_working_directory"),
};
```
### 4.2 组件 — `src/components/WorkingDirSwitcher.tsx`
**位置**Header toolbar,靠近 AppSwitcher。
**功能**
- 下拉菜单显示已注册目录列表
- 当前目录高亮
- "浏览…" 按钮调用 Tauri 文件夹选择对话框
- 右键菜单:重命名、删除
- "__default__(全局)" 选项恢复到全局状态
- 切换后 invalidate 所有相关 React Query
**切换后的 Query Invalidation**
```typescript
// 需要验证实际的 queryKey 名称
queryClient.invalidateQueries({ queryKey: ["providers"] });
queryClient.invalidateQueries({ queryKey: ["mcp-servers"] });
queryClient.invalidateQueries({ queryKey: ["installed-skills"] });
queryClient.invalidateQueries({ queryKey: ["prompts"] });
queryClient.invalidateQueries({ queryKey: ["workingDirectories"] });
```
### 4.3 i18n
三个文件都需更新:
- `src/i18n/locales/zh.json`
- `src/i18n/locales/en.json`
- `src/i18n/locales/ja.json`
---
## 五、切换流程时序
```
用户选择目录 B
├── 1. check_proxy_not_active()
│ → 如果代理接管中,返回错误,终止
├── 2. backfill_prompt_content()
│ → 读 live prompt 文件 → 更新 DB 中已启用 prompt 的 content
│ → 保护用户手动编辑的 prompt 不丢失
├── 3. BEGIN TRANSACTION
│ ├── snapshot(old_dir / __default__)
│ │ ├── providers → dir_provider_state (is_current + in_failover_queue)
│ │ ├── mcp_servers → dir_mcp_state (4 列直接复制)
│ │ ├── skills → dir_skill_state (4 列直接复制)
│ │ └── prompts → dir_prompt_state (enabled prompt_id)
│ │
│ ├── apply(target_dir)
│ │ ├── dir_provider_state → providers
│ │ ├── dir_mcp_state → mcp_servers
│ │ ├── dir_skill_state → skills
│ │ └── dir_prompt_state → prompts
│ │
│ └── set_current_working_directory(target_dir)
├── COMMIT
├── 4. sync_all_live()
│ ├── ProviderService::sync_current_to_live(state)
│ │ └── 内部已调用 McpService::sync_all_enabled()
│ ├── for app in AppType::all() { SkillService::sync_to_app(&db, &app) }
│ └── write_prompts_to_live() ← 无回填,直接写
└── 5. clear_all_provider_health()
→ 清除运行时熔断器状态
```
---
## 六、边界情况处理
| 场景 | 处理方式 |
|------|---------|
| **首次进入目录(无快照)** | `apply_*_snapshot()` 返回 false,主表保持不变。用户调整后,下次切走时自动保存。 |
| **全局模式 → 目录** | 自动将当前状态 snapshot 到 `__default__` 虚拟目录。`__default__` 在 v9 迁移中预创建。 |
| **目录 → 全局模式** | 用户选择 `__default__`,恢复全局状态。 |
| **新增 MCP/Skill/Provider** | 新实体在 dir_*_state 中无记录。apply 时只更新有记录的实体,新增的保持 DB 默认值。 |
| **删除 MCP/Skill/Provider** | dir_*_state 中对应记录在 apply 时找不到主表行,UPDATE 影响 0 行,静默跳过。 |
| **删除工作目录** | 级联删除 dir_*_state 中所有 `dir_id` 匹配的行。若为当前目录,回退到 `__default__`。 |
| **代理接管中切换** | `check_proxy_not_active()` 检测到 `live_takeover_active = 1`,拒绝切换并提示用户先停止代理。 |
| **切换中途崩溃** | 事务保护 DB 操作的原子性。最坏情况:DB 已更新但 live 文件未同步。下次启动可添加恢复检查(Phase 2 优化)。 |
| **用户手动编辑了 prompt 文件** | `backfill_prompt_content()` 在切换前读取 live 文件回填到 DB,保护手动修改。 |
---
## 七、实施顺序
### Phase 1: 数据库
1. `database/schema.rs` — 5 个 CREATE TABLE + `migrate_v8_to_v9()`
2. `database/mod.rs``SCHEMA_VERSION = 9` + 迁移分支
3. `database/dao/working_dir.rs` — 全部 DAO 方法(`_on_conn` 变体)
4. `database/dao/failover.rs` — 新增 `clear_all_provider_health()`
5. `database/dao/mod.rs` — 注册模块
### Phase 2: 服务 + 命令
6. `services/working_dir.rs``WorkingDirService::switch()`
7. `commands/working_dir.rs` — 6 个 Tauri 命令
8. `services/mod.rs` — 注册模块
9. `commands/mod.rs` — 注册模块
10. `lib.rs` — invoke_handler 注册
### Phase 3: 前端
11. `src/lib/api/workingDir.ts` — API 封装
12. `src/types.ts` — WorkingDirectory 类型
13. `src/components/WorkingDirSwitcher.tsx` — UI 组件
14. `src/App.tsx` — 集成到 header toolbar
15. `src/i18n/locales/{zh,en,ja}.json` — 国际化
### Phase 4: 优化(可选)
16. 启动恢复检查(DB 状态 vs live 文件一致性)
17. 托盘菜单显示当前工作目录
---
## 八、关键文件索引
### 新增文件(5 个)
- `src-tauri/src/database/dao/working_dir.rs`
- `src-tauri/src/services/working_dir.rs`
- `src-tauri/src/commands/working_dir.rs`
- `src/lib/api/workingDir.ts`
- `src/components/WorkingDirSwitcher.tsx`
### 必须修改的文件(7 个)
- `src-tauri/src/database/schema.rs` — CREATE TABLE + 迁移
- `src-tauri/src/database/mod.rs` — 版本号 + 迁移循环
- `src-tauri/src/database/dao/mod.rs` — 模块注册
- `src-tauri/src/database/dao/failover.rs` — clear_all_provider_health
- `src-tauri/src/services/mod.rs` — 模块注册
- `src-tauri/src/commands/mod.rs` — 模块注册
- `src-tauri/src/lib.rs` — invoke_handler
### 必须修改的前端文件(4 个)
- `src/App.tsx` — 集成 WorkingDirSwitcher
- `src/types.ts` — WorkingDirectory 接口
- `src/i18n/locales/zh.json` — 中文
- `src/i18n/locales/en.json` — 英文
- `src/i18n/locales/ja.json` — 日文
### 参考文件(理解现有模式)
- `src-tauri/src/services/mcp.rs``sync_all_enabled()` (line 165)
- `src-tauri/src/services/skill.rs``sync_to_app()` (line 1707)
- `src-tauri/src/services/provider/mod.rs``sync_current_to_live()` (line 1552)
- `src-tauri/src/services/prompt.rs``enable_prompt()` (line 73) — 理解回填逻辑
- `src-tauri/src/prompt_files.rs` — prompt 文件路径
- `src-tauri/src/config.rs``write_text_file()` (line 176)
---
## 九、验证计划
### 后端验证
1. `cargo test` — DAO 层单元测试(使用 `Database::memory()`
- 快照/恢复往返一致性
- 新增/删除实体后的 apply 行为
- `__default__` 全局状态保护
- 事务回滚测试
2. 手动测试 — 启动应用,创建两个目录,切换并验证 live 文件变化
### 前端验证
1. `pnpm typecheck` — TypeScript 类型检查
2. `pnpm lint` — ESLint 检查
3. 手动 UI 测试 — 工作目录切换器交互、query invalidation 后数据刷新
+6
View File
@@ -174,6 +174,12 @@ pub struct InstalledSkill {
pub apps: SkillApps,
/// 安装时间(Unix 时间戳)
pub installed_at: i64,
/// 内容哈希(SHA-256,用于更新检测)
#[serde(skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
/// 最近更新时间(Unix 时间戳,0 = 从未更新)
#[serde(default)]
pub updated_at: i64,
}
/// 未管理的 Skill(在应用目录中发现但未被 CC Switch 管理)
+1
View File
@@ -70,6 +70,7 @@ pub fn is_auto_launch_enabled() -> Result<bool, AppError> {
#[cfg(test)]
mod tests {
#[allow(unused_imports)]
use super::*;
#[cfg(target_os = "macos")]
+177 -61
View File
@@ -1,9 +1,14 @@
use tauri::State;
use crate::commands::codex_oauth::CodexOAuthState;
use crate::commands::copilot::CopilotAuthState;
use crate::proxy::providers::copilot_auth::{GitHubAccount, GitHubDeviceCodeResponse};
use crate::proxy::providers::codex_oauth_auth::CodexOAuthError;
use crate::proxy::providers::copilot_auth::{
CopilotAuthError, GitHubAccount, GitHubDeviceCodeResponse,
};
const AUTH_PROVIDER_GITHUB_COPILOT: &str = "github_copilot";
const AUTH_PROVIDER_CODEX_OAUTH: &str = "codex_oauth";
#[derive(Debug, Clone, serde::Serialize)]
pub struct ManagedAuthAccount {
@@ -34,9 +39,10 @@ pub struct ManagedAuthDeviceCodeResponse {
pub interval: u64,
}
fn ensure_auth_provider(auth_provider: &str) -> Result<&str, String> {
fn ensure_auth_provider(auth_provider: &str) -> Result<&'static str, String> {
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => Ok(AUTH_PROVIDER_GITHUB_COPILOT),
AUTH_PROVIDER_CODEX_OAUTH => Ok(AUTH_PROVIDER_CODEX_OAUTH),
_ => Err(format!("Unsupported auth provider: {auth_provider}")),
}
}
@@ -73,110 +79,220 @@ fn map_device_code_response(
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_start_login(
auth_provider: String,
state: State<'_, CopilotAuthState>,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<ManagedAuthDeviceCodeResponse, String> {
let auth_provider = ensure_auth_provider(&auth_provider)?;
let auth_manager = state.0.read().await;
let response = auth_manager
.start_device_flow()
.await
.map_err(|e| e.to_string())?;
Ok(map_device_code_response(auth_provider, response))
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => {
let auth_manager = copilot_state.0.read().await;
let response = auth_manager
.start_device_flow()
.await
.map_err(|e| e.to_string())?;
Ok(map_device_code_response(auth_provider, response))
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.read().await;
let response = auth_manager
.start_device_flow()
.await
.map_err(|e| e.to_string())?;
Ok(map_device_code_response(auth_provider, response))
}
_ => unreachable!(),
}
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_poll_for_account(
auth_provider: String,
device_code: String,
state: State<'_, CopilotAuthState>,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<Option<ManagedAuthAccount>, String> {
let auth_provider = ensure_auth_provider(&auth_provider)?;
let auth_manager = state.0.write().await;
match auth_manager.poll_for_token(&device_code).await {
Ok(account) => {
let default_account_id = auth_manager.get_status().await.default_account_id;
Ok(account
.map(|account| map_account(auth_provider, account, default_account_id.as_deref())))
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => {
let auth_manager = copilot_state.0.write().await;
match auth_manager.poll_for_token(&device_code).await {
Ok(account) => {
let default_account_id = auth_manager.get_status().await.default_account_id;
Ok(account.map(|account| {
map_account(auth_provider, account, default_account_id.as_deref())
}))
}
Err(CopilotAuthError::AuthorizationPending) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {
Ok(None)
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
match auth_manager.poll_for_token(&device_code).await {
Ok(account) => {
let default_account_id = auth_manager.get_status().await.default_account_id;
Ok(account.map(|account| {
map_account(auth_provider, account, default_account_id.as_deref())
}))
}
Err(CodexOAuthError::AuthorizationPending) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
Err(e) => Err(e.to_string()),
_ => unreachable!(),
}
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_list_accounts(
auth_provider: String,
state: State<'_, CopilotAuthState>,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<Vec<ManagedAuthAccount>, String> {
let auth_provider = ensure_auth_provider(&auth_provider)?;
let auth_manager = state.0.read().await;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(status
.accounts
.into_iter()
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
.collect())
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => {
let auth_manager = copilot_state.0.read().await;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(status
.accounts
.into_iter()
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
.collect())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.read().await;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(status
.accounts
.into_iter()
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
.collect())
}
_ => unreachable!(),
}
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_get_status(
auth_provider: String,
state: State<'_, CopilotAuthState>,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<ManagedAuthStatus, String> {
let auth_provider = ensure_auth_provider(&auth_provider)?;
let auth_manager = state.0.read().await;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(ManagedAuthStatus {
provider: auth_provider.to_string(),
authenticated: status.authenticated,
default_account_id: default_account_id.clone(),
migration_error: status.migration_error,
accounts: status
.accounts
.into_iter()
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
.collect(),
})
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => {
let auth_manager = copilot_state.0.read().await;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(ManagedAuthStatus {
provider: auth_provider.to_string(),
authenticated: status.authenticated,
default_account_id: default_account_id.clone(),
migration_error: status.migration_error,
accounts: status
.accounts
.into_iter()
.map(|account| {
map_account(auth_provider, account, default_account_id.as_deref())
})
.collect(),
})
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.read().await;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(ManagedAuthStatus {
provider: auth_provider.to_string(),
authenticated: status.authenticated,
default_account_id: default_account_id.clone(),
migration_error: None,
accounts: status
.accounts
.into_iter()
.map(|account| {
map_account(auth_provider, account, default_account_id.as_deref())
})
.collect(),
})
}
_ => unreachable!(),
}
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_remove_account(
auth_provider: String,
account_id: String,
state: State<'_, CopilotAuthState>,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<(), String> {
ensure_auth_provider(&auth_provider)?;
let auth_manager = state.0.write().await;
auth_manager
.remove_account(&account_id)
.await
.map_err(|e| e.to_string())
let auth_provider = ensure_auth_provider(&auth_provider)?;
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => {
let auth_manager = copilot_state.0.write().await;
auth_manager
.remove_account(&account_id)
.await
.map_err(|e| e.to_string())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
auth_manager
.remove_account(&account_id)
.await
.map_err(|e| e.to_string())
}
_ => unreachable!(),
}
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_set_default_account(
auth_provider: String,
account_id: String,
state: State<'_, CopilotAuthState>,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<(), String> {
ensure_auth_provider(&auth_provider)?;
let auth_manager = state.0.write().await;
auth_manager
.set_default_account(&account_id)
.await
.map_err(|e| e.to_string())
let auth_provider = ensure_auth_provider(&auth_provider)?;
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => {
let auth_manager = copilot_state.0.write().await;
auth_manager
.set_default_account(&account_id)
.await
.map_err(|e| e.to_string())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
auth_manager
.set_default_account(&account_id)
.await
.map_err(|e| e.to_string())
}
_ => unreachable!(),
}
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_logout(
auth_provider: String,
state: State<'_, CopilotAuthState>,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<(), String> {
ensure_auth_provider(&auth_provider)?;
let auth_manager = state.0.write().await;
auth_manager.clear_auth().await.map_err(|e| e.to_string())
let auth_provider = ensure_auth_provider(&auth_provider)?;
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => {
let auth_manager = copilot_state.0.write().await;
auth_manager.clear_auth().await.map_err(|e| e.to_string())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
auth_manager.clear_auth().await.map_err(|e| e.to_string())
}
_ => unreachable!(),
}
}
+6
View File
@@ -0,0 +1,6 @@
use crate::provider::UsageResult;
#[tauri::command]
pub async fn get_balance(base_url: String, api_key: String) -> Result<UsageResult, String> {
crate::services::balance::get_balance(&base_url, &api_key).await
}
+58
View File
@@ -0,0 +1,58 @@
//! Codex OAuth Tauri Commands
//!
//! 提供 OpenAI ChatGPT Plus/Pro OAuth 认证相关的 Tauri 命令。
//!
//! 大部分认证命令通过通用 `auth_*` 命令(参见 `commands::auth`)暴露给前端,
//! 此处定义 State wrapper 以及 Codex OAuth 专属的订阅额度查询命令。
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::services::subscription::{query_codex_quota, CredentialStatus, SubscriptionQuota};
use std::sync::Arc;
use tauri::State;
use tokio::sync::RwLock;
/// Codex OAuth 认证状态
pub struct CodexOAuthState(pub Arc<RwLock<CodexOAuthManager>>);
/// 查询 Codex OAuth (ChatGPT Plus/Pro) 订阅额度
///
/// - `account_id` 未指定时回退到 `CodexOAuthManager` 的默认账号
/// - 没有任何账号时返回 `not_found`,前端 `SubscriptionQuotaView` 会静默不渲染
/// - 复用 `services::subscription::query_codex_quota`,因此 wham/usage 端点协议
/// 与 Codex CLI 路径完全一致
#[tauri::command(rename_all = "camelCase")]
pub async fn get_codex_oauth_quota(
account_id: Option<String>,
state: State<'_, CodexOAuthState>,
) -> Result<SubscriptionQuota, String> {
let manager = state.0.read().await;
// 解析最终使用的账号 ID:显式 > 默认账号 > 无账号 (not_found)
let resolved = match account_id {
Some(id) => Some(id),
None => manager.default_account_id().await,
};
let Some(id) = resolved else {
return Ok(SubscriptionQuota::not_found("codex_oauth"));
};
// 获取(必要时自动刷新)access_token
let token = match manager.get_valid_token_for_account(&id).await {
Ok(t) => t,
Err(e) => {
return Ok(SubscriptionQuota::error(
"codex_oauth",
CredentialStatus::Expired,
format!("Codex OAuth token unavailable: {e}"),
));
}
};
Ok(query_codex_quota(
&token,
Some(&id),
"codex_oauth",
"Codex OAuth access token expired or rejected. Please re-login via cc-switch.",
)
.await)
}
+1
View File
@@ -948,6 +948,7 @@ exec bash --norc --noprofile
"kitty" => launch_macos_open_app("kitty", &script_file, false),
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
"kaku" => launch_macos_open_app("Kaku", &script_file, true),
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
};
+4
View File
@@ -1,6 +1,8 @@
#![allow(non_snake_case)]
mod auth;
mod balance;
mod codex_oauth;
mod coding_plan;
mod config;
mod copilot;
@@ -31,6 +33,8 @@ mod webdav_sync;
mod workspace;
pub use auth::*;
pub use balance::*;
pub use codex_oauth::*;
pub use coding_plan::*;
pub use config::*;
pub use copilot::*;
+25
View File
@@ -14,6 +14,7 @@ use std::str::FromStr;
// 常量定义
const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot";
const TEMPLATE_TYPE_TOKEN_PLAN: &str = "token_plan";
const TEMPLATE_TYPE_BALANCE: &str = "balance";
const COPILOT_UNIT_PREMIUM: &str = "requests";
/// 获取所有供应商
@@ -268,6 +269,30 @@ pub async fn queryProviderUsage(
});
}
// ── 官方余额查询路径 ──
if template_type == TEMPLATE_TYPE_BALANCE {
let settings_config = provider
.map(|p| &p.settings_config)
.cloned()
.unwrap_or_default();
let env = settings_config.get("env");
let base_url = env
.and_then(|e| e.get("ANTHROPIC_BASE_URL"))
.and_then(|v| v.as_str())
.unwrap_or("");
let api_key = env
.and_then(|e| {
e.get("ANTHROPIC_AUTH_TOKEN")
.or_else(|| e.get("ANTHROPIC_API_KEY"))
})
.and_then(|v| v.as_str())
.unwrap_or("");
return crate::services::balance::get_balance(base_url, api_key)
.await
.map_err(|e| format!("Failed to query balance: {e}"));
}
// ── 通用 JS 脚本路径 ──
ProviderService::query_usage(state.inner(), app_type, &providerId)
.await
+51 -2
View File
@@ -7,8 +7,9 @@
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
use crate::error::format_skill_error;
use crate::services::skill::{
DiscoverableSkill, ImportSkillSelection, Skill, SkillBackupEntry, SkillRepo, SkillService,
SkillUninstallResult,
DiscoverableSkill, ImportSkillSelection, MigrationResult, Skill, SkillBackupEntry, SkillRepo,
SkillService, SkillStorageLocation, SkillUninstallResult, SkillUpdateInfo,
SkillsShSearchResult,
};
use crate::store::AppState;
use std::sync::Arc;
@@ -134,6 +135,54 @@ pub async fn discover_available_skills(
.map_err(|e| e.to_string())
}
/// 检查 Skills 更新
#[tauri::command]
pub async fn check_skill_updates(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<SkillUpdateInfo>, String> {
service
.0
.check_updates(&app_state.db)
.await
.map_err(|e| e.to_string())
}
/// 更新单个 Skill
#[tauri::command]
pub async fn update_skill(
id: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<InstalledSkill, String> {
service
.0
.update_skill(&app_state.db, &id)
.await
.map_err(|e| e.to_string())
}
/// 迁移 Skill 存储位置
#[tauri::command]
pub async fn migrate_skill_storage(
target: SkillStorageLocation,
app_state: State<'_, AppState>,
) -> Result<MigrationResult, String> {
SkillService::migrate_storage(&app_state.db, target).map_err(|e| e.to_string())
}
/// 搜索 skills.sh 公共目录
#[tauri::command]
pub async fn search_skills_sh(
query: String,
limit: usize,
offset: usize,
) -> Result<SkillsShSearchResult, String> {
SkillService::search_skills_sh(&query, limit, offset)
.await
.map_err(|e| e.to_string())
}
// ========== 兼容旧 API 的命令 ==========
/// 获取技能列表(兼容旧 API)
+63 -6
View File
@@ -11,8 +11,11 @@ pub fn get_usage_summary(
state: State<'_, AppState>,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>,
) -> Result<UsageSummary, AppError> {
state.db.get_usage_summary(start_date, end_date)
state
.db
.get_usage_summary(start_date, end_date, app_type.as_deref())
}
/// 获取每日趋势
@@ -21,20 +24,29 @@ pub fn get_usage_trends(
state: State<'_, AppState>,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>,
) -> Result<Vec<DailyStats>, AppError> {
state.db.get_daily_trends(start_date, end_date)
state
.db
.get_daily_trends(start_date, end_date, app_type.as_deref())
}
/// 获取 Provider 统计
#[tauri::command]
pub fn get_provider_stats(state: State<'_, AppState>) -> Result<Vec<ProviderStats>, AppError> {
state.db.get_provider_stats()
pub fn get_provider_stats(
state: State<'_, AppState>,
app_type: Option<String>,
) -> Result<Vec<ProviderStats>, AppError> {
state.db.get_provider_stats(app_type.as_deref())
}
/// 获取模型统计
#[tauri::command]
pub fn get_model_stats(state: State<'_, AppState>) -> Result<Vec<ModelStats>, AppError> {
state.db.get_model_stats()
pub fn get_model_stats(
state: State<'_, AppState>,
app_type: Option<String>,
) -> Result<Vec<ModelStats>, AppError> {
state.db.get_model_stats(app_type.as_deref())
}
/// 获取请求日志列表
@@ -166,6 +178,51 @@ pub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Res
Ok(())
}
/// 手动触发会话日志同步
#[tauri::command]
pub fn sync_session_usage(
state: State<'_, AppState>,
) -> Result<crate::services::session_usage::SessionSyncResult, AppError> {
// 同步 Claude 会话日志
let mut result = crate::services::session_usage::sync_claude_session_logs(&state.db)?;
// 同步 Codex 使用数据
match crate::services::session_usage_codex::sync_codex_usage(&state.db) {
Ok(codex_result) => {
result.imported += codex_result.imported;
result.skipped += codex_result.skipped;
result.files_scanned += codex_result.files_scanned;
result.errors.extend(codex_result.errors);
}
Err(e) => {
result.errors.push(format!("Codex 同步失败: {e}"));
}
}
// 同步 Gemini 使用数据
match crate::services::session_usage_gemini::sync_gemini_usage(&state.db) {
Ok(gemini_result) => {
result.imported += gemini_result.imported;
result.skipped += gemini_result.skipped;
result.files_scanned += gemini_result.files_scanned;
result.errors.extend(gemini_result.errors);
}
Err(e) => {
result.errors.push(format!("Gemini 同步失败: {e}"));
}
}
Ok(result)
}
/// 获取数据来源分布
#[tauri::command]
pub fn get_usage_data_sources(
state: State<'_, AppState>,
) -> Result<Vec<crate::services::session_usage::DataSourceSummary>, AppError> {
crate::services::session_usage::get_data_source_breakdown(&state.db)
}
/// 模型定价信息
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
+1
View File
@@ -6,6 +6,7 @@ pub mod failover;
pub mod mcp;
pub mod prompts;
pub mod providers;
pub mod providers_seed;
pub mod proxy;
pub mod settings;
pub mod skills;
+133 -1
View File
@@ -3,7 +3,7 @@ use crate::error::AppError;
use crate::provider::{Provider, ProviderMeta};
use indexmap::IndexMap;
use rusqlite::params;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
type OmoProviderRow = (
String,
@@ -501,4 +501,136 @@ impl Database {
in_failover_queue: false,
}))
}
/// 判断 providers 表是否为空(全 app_type 一起算)。
///
/// 用于区分"全新安装"和"升级用户":在启动流程 import/seed 之前调用。
/// 使用 `EXISTS` 短路查询,比 `COUNT(*)` 在将来表变大时更高效。
pub fn is_providers_empty(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let exists: bool = conn
.query_row("SELECT EXISTS(SELECT 1 FROM providers)", [], |row| {
row.get(0)
})
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(!exists)
}
/// 仅获取指定 app 下所有 provider 的 id 集合。
///
/// 比 `get_all_providers` 轻量得多:只读 id 列、无 endpoint 子查询。
/// 用于只需要做存在性检查的场景(如 additive 模式的 live 同步去重)。
pub fn get_provider_ids(&self, app_type: &str) -> Result<HashSet<String>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT id FROM providers WHERE app_type = ?1")
.map_err(|e| AppError::Database(e.to_string()))?;
let rows = stmt
.query_map(params![app_type], |row| row.get::<_, String>(0))
.map_err(|e| AppError::Database(e.to_string()))?;
let mut ids = HashSet::new();
for row in rows {
ids.insert(row.map_err(|e| AppError::Database(e.to_string()))?);
}
Ok(ids)
}
/// 判断指定 app 下是否存在非官方种子的供应商。
///
/// 比 `get_all_providers` 轻量得多:只读 id 列、无 endpoint 子查询、首条命中即返回。
/// 用于 `import_default_config` 决定是否跳过 live 导入。
pub fn has_non_official_seed_provider(&self, app_type: &str) -> Result<bool, AppError> {
use crate::database::dao::providers_seed::is_official_seed_id;
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT id FROM providers WHERE app_type = ?1")
.map_err(|e| AppError::Database(e.to_string()))?;
let mut rows = stmt
.query(params![app_type])
.map_err(|e| AppError::Database(e.to_string()))?;
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
let id: String = row.get(0).map_err(|e| AppError::Database(e.to_string()))?;
if !is_official_seed_id(&id) {
return Ok(true);
}
}
Ok(false)
}
/// 计算指定 app 下一个可用的 sort_index(追加到末尾)。
fn next_sort_index_for_app(&self, app_type: &str) -> Result<usize, AppError> {
let conn = lock_conn!(self.conn);
let max: Option<i64> = conn
.query_row(
"SELECT MAX(sort_index) FROM providers WHERE app_type = ?1",
params![app_type],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(max.map(|v| (v + 1) as usize).unwrap_or(0))
}
/// 启动时调用:补齐缺失的官方预设供应商(Claude / Codex / Gemini)。
///
/// 使用 settings flag `official_providers_seeded` 保证每个数据库只执行一次:
/// - 全新用户:seed 三条官方预设
/// - 老用户升级:同样会触发一次(flag 不存在),追加到末尾,不影响已有排序
/// - 用户删除 seed 后:不再重建(flag 已为 true),尊重用户意图
///
/// 与 `Database::save_provider` 的 UPSERT 语义配合,即使被意外重复调用
/// 也不会覆盖用户当前激活的供应商(is_current 字段会被保留)。
pub fn init_default_official_providers(&self) -> Result<usize, AppError> {
use crate::database::dao::providers_seed::OFFICIAL_SEEDS;
if self
.get_bool_flag("official_providers_seeded")
.unwrap_or(false)
{
return Ok(0);
}
let mut inserted = 0_usize;
let now_ms = chrono::Utc::now().timestamp_millis();
for seed in OFFICIAL_SEEDS {
let app_type_str = seed.app_type.as_str();
// 若该 id 已存在(极端情况:用户曾手动用过同 id),跳过
if self.get_provider_by_id(seed.id, app_type_str)?.is_some() {
continue;
}
let next_sort_index = self.next_sort_index_for_app(app_type_str)?;
let settings_config: serde_json::Value =
serde_json::from_str(seed.settings_config_json).map_err(|e| {
AppError::Database(format!("Seed JSON parse failed for {}: {e}", seed.id))
})?;
let mut provider = Provider::with_id(
seed.id.to_string(),
seed.name.to_string(),
settings_config,
Some(seed.website_url.to_string()),
);
provider.category = Some("official".to_string());
provider.icon = Some(seed.icon.to_string());
provider.icon_color = Some(seed.icon_color.to_string());
provider.sort_index = Some(next_sort_index);
provider.created_at = Some(now_ms);
self.save_provider(app_type_str, &provider)?;
inserted += 1;
log::info!(
"✓ Seeded official provider: {} ({})",
seed.name,
app_type_str
);
}
// 即使 inserted=0(例如用户手动创建过同 id)也设置 flag 防止反复检查
self.set_setting("official_providers_seeded", "true")?;
Ok(inserted)
}
}
@@ -0,0 +1,66 @@
//! 官方供应商种子数据
//!
//! 启动时调用 `Database::init_default_official_providers` 把这些条目
//! 写入 `providers` 表,让所有用户都能看到一个"一键切回官方"的入口。
//!
//! 字段与前端预设保持一致,参见:
//! - `src/config/claudeProviderPresets.ts`"Claude Official"
//! - `src/config/codexProviderPresets.ts`"OpenAI Official"
//! - `src/config/geminiProviderPresets.ts`"Google Official"
use crate::app_config::AppType;
/// 单条官方供应商种子定义。
pub(crate) struct OfficialProviderSeed {
pub id: &'static str,
pub app_type: AppType,
pub name: &'static str,
pub website_url: &'static str,
pub icon: &'static str,
pub icon_color: &'static str,
/// settings_config 的 JSON 字符串,每个 app 结构不同。
pub settings_config_json: &'static str,
}
/// Claude / Codex / Gemini 三个应用的官方预设。
///
/// id 固定,便于幂等检查;name 直接用英文原名(与前端预设一致),不做 i18n。
pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
OfficialProviderSeed {
id: "claude-official",
app_type: AppType::Claude,
name: "Claude Official",
website_url: "https://www.anthropic.com/claude-code",
icon: "anthropic",
icon_color: "#D4915D",
// 空 env 让用户走 Claude CLI 默认认证流程
settings_config_json: r#"{"env":{}}"#,
},
OfficialProviderSeed {
id: "codex-official",
app_type: AppType::Codex,
name: "OpenAI Official",
website_url: "https://chatgpt.com/codex",
icon: "openai",
icon_color: "#00A67E",
// 空 auth + 空 config 让用户走 ChatGPT Plus/Pro OAuth
settings_config_json: r#"{"auth":{},"config":""}"#,
},
OfficialProviderSeed {
id: "gemini-official",
app_type: AppType::Gemini,
name: "Google Official",
website_url: "https://ai.google.dev/",
icon: "gemini",
icon_color: "#4285F4",
// 空 env + 空 config 让用户走 Google OAuth
settings_config_json: r#"{"env":{},"config":{}}"#,
},
];
/// 判断给定的 provider id 是否属于内置官方种子。
///
/// 单一事实源:直接扫描 `OFFICIAL_SEEDS`,避免在多处重复维护 id 列表。
pub(crate) fn is_official_seed_id(id: &str) -> bool {
OFFICIAL_SEEDS.iter().any(|seed| seed.id == id)
}
+12
View File
@@ -33,6 +33,18 @@ impl Database {
}
}
/// 以布尔语义读取 flag`"true"` 或 `"1"` → true,其它全部 false。
///
/// 用于一次性启动 flag`official_providers_seeded` / `first_run_notice_shown` 等)。
/// 与 `is_legacy_common_config_migrated` 等只认 `"true"` 的历史辅助函数**不同**——
/// 这里同时接受 `"1"` 是为了兼容 `init_default_official_providers` 既有写法。
pub fn get_bool_flag(&self, key: &str) -> Result<bool, AppError> {
Ok(matches!(
self.get_setting(key)?.as_deref(),
Some("true") | Some("1")
))
}
/// 设置值
pub fn set_setting(&self, key: &str, value: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
+30 -4
View File
@@ -22,7 +22,8 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
installed_at, content_hash, updated_at
FROM skills ORDER BY name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -45,6 +46,8 @@ impl Database {
opencode: row.get(11)?,
},
installed_at: row.get(12)?,
content_hash: row.get(13)?,
updated_at: row.get::<_, i64>(14).unwrap_or(0),
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -63,7 +66,8 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
installed_at, content_hash, updated_at
FROM skills WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -85,6 +89,8 @@ impl Database {
opencode: row.get(11)?,
},
installed_at: row.get(12)?,
content_hash: row.get(13)?,
updated_at: row.get::<_, i64>(14).unwrap_or(0),
})
});
@@ -101,8 +107,9 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO skills
(id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
installed_at, content_hash, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
params![
skill.id,
skill.name,
@@ -117,6 +124,8 @@ impl Database {
skill.apps.gemini,
skill.apps.opencode,
skill.installed_at,
skill.content_hash,
skill.updated_at,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -152,6 +161,23 @@ impl Database {
Ok(affected > 0)
}
/// 更新 Skill 的内容哈希和更新时间
pub fn update_skill_hash(
&self,
id: &str,
content_hash: &str,
updated_at: i64,
) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let affected = conn
.execute(
"UPDATE skills SET content_hash = ?1, updated_at = ?2 WHERE id = ?3",
params![content_hash, updated_at, id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
}
// ========== SkillRepo CRUD(保持原有) ==========
/// 获取所有 Skill 仓库
+1 -1
View File
@@ -44,7 +44,7 @@ use std::sync::Mutex;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 6;
pub(crate) const SCHEMA_VERSION: i32 = 8;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+196 -28
View File
@@ -93,7 +93,9 @@ impl Database {
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0
installed_at INTEGER NOT NULL DEFAULT 0,
content_hash TEXT,
updated_at INTEGER NOT NULL DEFAULT 0
)",
[],
)
@@ -187,7 +189,8 @@ impl Database {
total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL
cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL,
data_source TEXT NOT NULL DEFAULT 'proxy'
)", []).map_err(|e| AppError::Database(e.to_string()))?;
conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_provider ON proxy_request_logs(provider_id, app_type)", [])
@@ -269,6 +272,18 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 18. Session Log Sync 表 (会话日志同步状态)
conn.execute(
"CREATE TABLE IF NOT EXISTS session_log_sync (
file_path TEXT PRIMARY KEY,
last_modified INTEGER NOT NULL,
last_line_offset INTEGER NOT NULL DEFAULT 0,
last_synced_at INTEGER NOT NULL
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 尝试添加 live_takeover_active 列到 proxy_config 表
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
@@ -393,6 +408,16 @@ impl Database {
Self::migrate_v5_to_v6(conn)?;
Self::set_user_version(conn, 6)?;
}
6 => {
log::info!("迁移数据库从 v6 到 v7(Skills 更新检测支持)");
Self::migrate_v6_to_v7(conn)?;
Self::set_user_version(conn, 7)?;
}
7 => {
log::info!("迁移数据库从 v7 到 v8(会话日志使用追踪 + 修正模型定价)");
Self::migrate_v7_to_v8(conn)?;
Self::set_user_version(conn, 8)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -1045,6 +1070,80 @@ impl Database {
Ok(())
}
/// v6 -> v7: Skills 更新检测支持(content_hash + updated_at
fn migrate_v6_to_v7(conn: &Connection) -> Result<(), AppError> {
if Self::table_exists(conn, "skills")? {
Self::add_column_if_missing(conn, "skills", "content_hash", "TEXT")?;
Self::add_column_if_missing(
conn,
"skills",
"updated_at",
"INTEGER NOT NULL DEFAULT 0",
)?;
}
log::info!("v6 -> v7 迁移完成:已添加 content_hash 和 updated_at 列");
Ok(())
}
/// v7 -> v8: 会话日志使用追踪(无代理模式统计支持)
fn migrate_v7_to_v8(conn: &Connection) -> Result<(), AppError> {
// 1. 为 proxy_request_logs 添加 data_source 列,区分数据来源
if Self::table_exists(conn, "proxy_request_logs")? {
Self::add_column_if_missing(
conn,
"proxy_request_logs",
"data_source",
"TEXT NOT NULL DEFAULT 'proxy'",
)?;
}
// 2. 创建会话日志同步状态表
conn.execute(
"CREATE TABLE IF NOT EXISTS session_log_sync (
file_path TEXT PRIMARY KEY,
last_modified INTEGER NOT NULL,
last_line_offset INTEGER NOT NULL DEFAULT 0,
last_synced_at INTEGER NOT NULL
)",
[],
)
.map_err(|e| AppError::Database(format!("创建 session_log_sync 表失败: {e}")))?;
// 3. 修正国产模型定价:之前误将 CNY 值存为 USD 字段,统一转换为 USD
if Self::table_exists(conn, "model_pricing")? {
let pricing_fixes: &[(&str, &str, &str, &str, &str)] = &[
("deepseek-v3.2", "0.28", "0.42", "0.028", "0"),
("deepseek-v3.1", "0.55", "1.67", "0.055", "0"),
("deepseek-v3", "0.28", "1.11", "0.028", "0"),
("doubao-seed-code", "0.17", "1.11", "0.02", "0"),
("kimi-k2-thinking", "0.55", "2.20", "0.10", "0"),
("kimi-k2-0905", "0.55", "2.20", "0.10", "0"),
("kimi-k2-turbo", "1.11", "8.06", "0.14", "0"),
("minimax-m2.1", "0.27", "0.95", "0.03", "0"),
("minimax-m2.1-lightning", "0.27", "2.33", "0.03", "0"),
("minimax-m2", "0.27", "0.95", "0.03", "0"),
("glm-4.7", "0.39", "1.75", "0.04", "0"),
("glm-4.6", "0.28", "1.11", "0.03", "0"),
("mimo-v2-flash", "0.09", "0.29", "0.009", "0"),
];
for (model_id, input, output, cache_read, cache_creation) in pricing_fixes {
conn.execute(
"UPDATE model_pricing SET
input_cost_per_million = ?2,
output_cost_per_million = ?3,
cache_read_cost_per_million = ?4,
cache_creation_cost_per_million = ?5
WHERE model_id = ?1",
rusqlite::params![model_id, input, output, cache_read, cache_creation],
)
.map_err(|e| AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")))?;
}
}
log::info!("v7 -> v8 迁移完成:data_source 列、session_log_sync 表、修正 13 个模型定价");
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
@@ -1134,6 +1233,10 @@ impl Database {
"0.30",
"3.75",
),
// GPT-5.4 系列
("gpt-5.4", "GPT-5.4", "2.50", "15", "0.25", "0"),
("gpt-5.4-mini", "GPT-5.4 Mini", "0.75", "4.50", "0.075", "0"),
("gpt-5.4-nano", "GPT-5.4 Nano", "0.20", "1.25", "0.02", "0"),
// GPT-5.2 系列
("gpt-5.2", "GPT-5.2", "1.75", "14", "0.175", "0"),
("gpt-5.2-low", "GPT-5.2", "1.75", "14", "0.175", "0"),
@@ -1294,6 +1397,30 @@ impl Database {
"0.125",
"0",
),
// OpenAI Reasoning 系列
("o3", "OpenAI o3", "2", "8", "0.50", "0"),
("o4-mini", "OpenAI o4-mini", "1.10", "4.40", "0.275", "0"),
// GPT-4.1 系列
("gpt-4.1", "GPT-4.1", "2", "8", "0.50", "0"),
("gpt-4.1-mini", "GPT-4.1 Mini", "0.40", "1.60", "0.10", "0"),
("gpt-4.1-nano", "GPT-4.1 Nano", "0.10", "0.40", "0.025", "0"),
// Gemini 3.1 系列
(
"gemini-3.1-pro-preview",
"Gemini 3.1 Pro Preview",
"2",
"12",
"0.20",
"0",
),
(
"gemini-3.1-flash-lite-preview",
"Gemini 3.1 Flash Lite Preview",
"0.25",
"1.50",
"0.025",
"0",
),
// Gemini 3 系列
(
"gemini-3-pro-preview",
@@ -1328,6 +1455,23 @@ impl Database {
"0.03",
"0",
),
(
"gemini-2.5-flash-lite",
"Gemini 2.5 Flash Lite",
"0.10",
"0.40",
"0.01",
"0",
),
// Gemini 2.0 系列
(
"gemini-2.0-flash",
"Gemini 2.0 Flash",
"0.10",
"0.40",
"0.025",
"0",
),
// StepFun 系列
(
"step-3.5-flash",
@@ -1337,68 +1481,92 @@ impl Database {
"0.02",
"0",
),
// ====== 国产模型 (CNY/1M tokens) ======
// ====== 国产模型 (USD/1M tokens) ======
// Doubao (字节跳动)
(
"doubao-seed-code",
"Doubao Seed Code",
"1.20",
"8.00",
"0.24",
"0.17",
"1.11",
"0.02",
"0",
),
// DeepSeek 系列
(
"deepseek-v3.2",
"DeepSeek V3.2",
"2.00",
"3.00",
"0.40",
"0.28",
"0.42",
"0.028",
"0",
),
(
"deepseek-v3.1",
"DeepSeek V3.1",
"4.00",
"12.00",
"0.80",
"0.55",
"1.67",
"0.055",
"0",
),
("deepseek-v3", "DeepSeek V3", "0.28", "1.11", "0.028", "0"),
(
"deepseek-chat",
"DeepSeek Chat",
"0.28",
"0.42",
"0.028",
"0",
),
(
"deepseek-reasoner",
"DeepSeek Reasoner",
"0.28",
"0.42",
"0.028",
"0",
),
("deepseek-v3", "DeepSeek V3", "2.00", "8.00", "0.40", "0"),
// Kimi (月之暗面)
(
"kimi-k2-thinking",
"Kimi K2 Thinking",
"4.00",
"16.00",
"1.00",
"0.55",
"2.20",
"0.10",
"0",
),
("kimi-k2-0905", "Kimi K2", "4.00", "16.00", "1.00", "0"),
("kimi-k2-0905", "Kimi K2", "0.55", "2.20", "0.10", "0"),
(
"kimi-k2-turbo",
"Kimi K2 Turbo",
"8.00",
"58.00",
"1.00",
"1.11",
"8.06",
"0.14",
"0",
),
("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"),
// MiniMax 系列
("minimax-m2.1", "MiniMax M2.1", "2.10", "8.40", "0.21", "0"),
("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
(
"minimax-m2.1-lightning",
"MiniMax M2.1 Lightning",
"2.10",
"16.80",
"0.21",
"0.27",
"2.33",
"0.03",
"0",
),
("minimax-m2", "MiniMax M2", "2.10", "8.40", "0.21", "0"),
("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"),
// GLM (智谱)
("glm-4.7", "GLM-4.7", "2.00", "8.00", "0.40", "0"),
("glm-4.6", "GLM-4.6", "2.00", "8.00", "0.40", "0"),
("glm-4.7", "GLM-4.7", "0.39", "1.75", "0.04", "0"),
("glm-4.6", "GLM-4.6", "0.28", "1.11", "0.03", "0"),
// Mimo (小米)
("mimo-v2-flash", "Mimo V2 Flash", "0", "0", "0", "0"),
(
"mimo-v2-flash",
"Mimo V2 Flash",
"0.09",
"0.29",
"0.009",
"0",
),
];
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
+172
View File
@@ -13,6 +13,8 @@ mod gemini_config;
mod gemini_mcp;
mod init_status;
mod lightweight;
#[cfg(target_os = "linux")]
mod linux_fix;
mod mcp;
mod openclaw_config;
mod opencode_config;
@@ -132,6 +134,10 @@ fn handle_deeplink_url(
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "linux")]
{
linux_fix::nudge_main_window(window.clone());
}
log::info!("✓ Window shown and focused");
}
}
@@ -229,6 +235,10 @@ pub fn run() {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "linux")]
{
linux_fix::nudge_main_window(window.clone());
}
}
}));
}
@@ -457,6 +467,80 @@ pub fn run() {
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
}
// 1.5. 自动导入 live 配置 + seed 官方预设供应商(Claude / Codex / Gemini
//
// 先 import 后 seed 是有意为之:先把用户手动配置的 settings.json / auth.json / .env
// 落成 "default" provider 设为 current,再追加官方预设(is_current=false)。
// 这样用户切到官方预设时,回填机制会保护原 live 配置不丢失。
//
// 捕获首次运行快照:所有全新装用户都会看到欢迎弹窗介绍 CC Switch 的工作方式。
// 读失败时默认不弹,宁可漏弹也不要因为故障打扰用户。
let first_run_already_confirmed = crate::settings::get_settings()
.first_run_notice_confirmed
.unwrap_or(false);
let fresh_install_at_startup =
app_state.db.is_providers_empty().unwrap_or(false);
for app_type in
crate::app_config::AppType::all().filter(|t| !t.is_additive_mode())
{
match crate::services::provider::import_default_config(
&app_state,
app_type.clone(),
) {
Ok(true) => log::info!(
"✓ Imported live config for {} as default provider",
app_type.as_str()
),
Ok(false) => log::debug!(
"○ {} already has providers; live import skipped",
app_type.as_str()
),
Err(e) => log::debug!(
"○ No live config to import for {}: {e}",
app_type.as_str()
),
}
}
match app_state.db.init_default_official_providers() {
Ok(count) if count > 0 => {
log::info!("✓ Seeded {count} official provider(s)");
}
Ok(_) => {}
Err(e) => log::warn!("✗ Failed to seed official providers: {e}"),
}
// 老用户 / 已确认的路径由 `fresh_install_at_startup` 自行拦截,这里不做写入。
// 字段只由前端在用户点击"我知道了"时 save_settings 回写,语义是"用户显式确认过"。
if !first_run_already_confirmed && fresh_install_at_startup {
log::info!("✓ First-run welcome notice pending");
}
// 1.6. 自动同步 OpenCode / OpenClaw 的 live providers 到数据库
//
// additive 模式(OpenCode / OpenClaw)的 import 函数本身按 id 幂等,
// 已有的 provider 会被跳过,所以每次启动都跑是安全的——既保证新装
// 用户开箱可见 live 中的供应商,也让外部修改的 live 文件能在重启
// 后同步到数据库(与之前依赖前端"导入当前配置"按钮手动触发不同)。
//
// 底层 read_*_config 在文件不存在时返回默认空配置,因此新装且无
// live 文件的用户走 Ok(0) 路径,不会产生错误日志噪音。
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} OpenCode provider(s) from live config");
}
Ok(_) => log::debug!("○ No new OpenCode providers to import"),
Err(e) => log::warn!("✗ Failed to import OpenCode providers: {e}"),
}
match crate::services::provider::import_openclaw_providers_from_live(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} OpenClaw provider(s) from live config");
}
Ok(_) => log::debug!("○ No new OpenClaw providers to import"),
Err(e) => log::warn!("✗ Failed to import OpenClaw providers: {e}"),
}
// 2. OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
{
let has_omo = app_state
@@ -715,6 +799,18 @@ pub fn run() {
log::info!("✓ CopilotAuthManager initialized");
}
// 初始化 CodexOAuthManager (ChatGPT Plus/Pro 反代)
{
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use commands::CodexOAuthState;
use tokio::sync::RwLock;
let app_config_dir = crate::config::get_app_config_dir();
let codex_oauth_manager = CodexOAuthManager::new(app_config_dir);
app.manage(CodexOAuthState(Arc::new(RwLock::new(codex_oauth_manager))));
log::info!("✓ CodexOAuthManager initialized");
}
// 初始化全局出站代理 HTTP 客户端
{
let db = &app.state::<AppState>().db;
@@ -796,6 +892,65 @@ pub fn run() {
}
}
});
// Session log usage sync: 启动时同步一次,之后每 60 秒检查
let db_for_session_sync = state.db.clone();
tauri::async_runtime::spawn(async move {
const SESSION_SYNC_INTERVAL_SECS: u64 = 60;
// 首次同步
if let Err(e) =
crate::services::session_usage::sync_claude_session_logs(
&db_for_session_sync,
)
{
log::warn!("Session usage initial sync failed: {e}");
}
if let Err(e) =
crate::services::session_usage_codex::sync_codex_usage(
&db_for_session_sync,
)
{
log::warn!("Codex usage initial sync failed: {e}");
}
if let Err(e) =
crate::services::session_usage_gemini::sync_gemini_usage(
&db_for_session_sync,
)
{
log::warn!("Gemini usage initial sync failed: {e}");
}
// 定期同步
let mut interval = tokio::time::interval(std::time::Duration::from_secs(
SESSION_SYNC_INTERVAL_SECS,
));
interval.tick().await; // skip immediate first tick
loop {
interval.tick().await;
if let Err(e) =
crate::services::session_usage::sync_claude_session_logs(
&db_for_session_sync,
)
{
log::warn!("Session usage periodic sync failed: {e}");
}
if let Err(e) =
crate::services::session_usage_codex::sync_codex_usage(
&db_for_session_sync,
)
{
log::warn!("Codex usage periodic sync failed: {e}");
}
if let Err(e) =
crate::services::session_usage_gemini::sync_gemini_usage(
&db_for_session_sync,
)
{
log::warn!("Gemini usage periodic sync failed: {e}");
}
}
});
});
// Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏
@@ -828,6 +983,14 @@ pub fn run() {
// 正常启动模式:显示窗口
let _ = window.show();
log::info!("正常启动模式:主窗口已显示");
// Linux: 解决首次启动 UI 无响应问题(Tauri #10746 + wry #637)。
// 启动时 webview 未获取焦点 + surface 尺寸协商失败,导致点击无效。
// 这里做 set_focus + 伪 resize,等价于无视觉版本的"最大化-还原"。
#[cfg(target_os = "linux")]
{
linux_fix::nudge_main_window(window.clone());
}
}
}
@@ -892,7 +1055,9 @@ pub fn run() {
commands::testUsageScript,
// subscription quota
commands::get_subscription_quota,
commands::get_codex_oauth_quota,
commands::get_coding_plan_quota,
commands::get_balance,
// New MCP via config.json (SSOT)
commands::get_mcp_config,
commands::upsert_mcp_server_in_config,
@@ -962,6 +1127,10 @@ pub fn run() {
commands::scan_unmanaged_skills,
commands::import_skills_from_apps,
commands::discover_available_skills,
commands::check_skill_updates,
commands::update_skill,
commands::migrate_skill_storage,
commands::search_skills_sh,
// Skill management (legacy API compatibility)
commands::get_skills,
commands::get_skills_for_app,
@@ -1020,6 +1189,9 @@ pub fn run() {
commands::update_model_pricing,
commands::delete_model_pricing,
commands::check_provider_limits,
// Session usage sync
commands::sync_session_usage,
commands::get_usage_data_sources,
// Stream health check
commands::stream_check_provider,
commands::stream_check_all_providers,
+8
View File
@@ -36,6 +36,10 @@ pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "linux")]
{
crate::linux_fix::nudge_main_window(window.clone());
}
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
@@ -66,6 +70,10 @@ pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
#[cfg(target_os = "linux")]
{
crate::linux_fix::nudge_main_window(window.clone());
}
}
#[cfg(target_os = "windows")]
+121
View File
@@ -0,0 +1,121 @@
//! Linux 专用的主窗口恢复补丁。
//!
//! 解决 Tauri 2.x 在部分 Linux 发行版(尤其是 Wayland / 某些 WebKitGTK
//! 版本)上启动后 UI 无法响应点击的问题:
//!
//! - **失效模式 A**Tauri #10746 / wry #637):webview 在 `show()` 后
//! 没有获得 keyboard focus,导致首次点击被 X11/Wayland 用作
//! click-to-activate 而非传给 webview。
//! - **失效模式 B**GTK surface 与 WebKitWebView 的 input region 尺寸
//! 协商在 `visible:false` → `show()` 的路径上失败,整窗永远不响应
//! 点击,只有重新 `size_allocate`(例如最大化-还原)才能恢复。
//!
//! 本模块导出 [`nudge_main_window`],它通过「显式 set_focus + 无视觉
//! 版本的 ±1px 伪 resize」精确模拟用户手动最大化再还原的 workaround
//! 但肉眼无法察觉。所有"让主窗口出现在用户面前"的路径(正常启动、
//! deeplink 唤起、single_instance 回调、托盘 show_main、lightweight
//! 退出)都应在现有 `set_focus()` 之后追加一次调用。
use std::time::Duration;
use tauri::{PhysicalSize, WebviewWindow};
/// 在 webview realize 之后的延迟,等 GTK 主循环把 realize 事件处理完。
/// 200ms 是社区经验值;太短 set_focus 仍会无效,太长会让首屏可交互
/// 时间被肉眼感知到。
const REALIZE_WAIT: Duration = Duration::from_millis(200);
/// ±1px 伪 resize 两步之间的间隔,确保 GTK 先处理了第一次
/// `size_allocate` 再收到第二次 resize。放宽到 100ms 是因为 Tao 在 Linux
/// 上的尺寸 API 是异步的(底层走 `gtk_window_resize` → 合成器 configure),
/// 太短会让合成器把两次连续 resize coalesce 成一次。
const RESIZE_GAP: Duration = Duration::from_millis(100);
/// 尺寸对账回读前的额外等待。200ms + 100ms + 500ms = 总共 ~800ms 后
/// 校验窗口尺寸是否回到 original。这个时间足够所有合成器处理完
/// resize 消息队列。
const RECONCILE_WAIT: Duration = Duration::from_millis(500);
/// 对主窗口执行 Linux 专用的「focus + surface 重激活」序列。
///
/// 调用是 fire-and-forget:内部 spawn 一个异步任务在 ~250ms 后完成。
/// 调用线程立即返回,不阻塞 UI。
pub(crate) fn nudge_main_window(window: WebviewWindow) {
// 第一次 set_focuswebview 可能还没 realize,这一次通常是无效的,
// 但成本极低(线程安全,内部 run_on_main_thread),顺手做掉。
let _ = window.set_focus();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(REALIZE_WAIT).await;
// 第二次 set_focus:此时 webview realize 已完成,在绝大多数
// 发行版上这一次会真的生效,消除失效模式 A。
let _ = window.set_focus();
// 伪 resize:读取当前 inner_size,先加 1px 再还原。这会触发
// GTK 的 size-allocate → WebKitWebViewBase::size_allocate →
// 重新 attach input surface,消除失效模式 B。
//
// 使用 PhysicalSize 避免跨 DPI 的逻辑坐标漂移;saturating_add
// 防止极端尺寸溢出。
match window.inner_size() {
Ok(original) => {
let bumped = PhysicalSize::new(original.width.saturating_add(1), original.height);
let _ = window.set_size(bumped);
tokio::time::sleep(RESIZE_GAP).await;
let _ = window.set_size(original);
log::info!("Linux: 已对主窗口执行 focus + surface 重激活");
// 尺寸对账回读:Tao Linux 的尺寸 API 是异步的,`set_size` 只是把
// resize 请求送进 GTK 主循环队列,合成器可能会 coalesce 两次连续
// 请求(尤其是第二次 `set_size(original)`),导致窗口永久停留在
// width+1。这里等合成器处理完队列后读一次实际尺寸,发现 drift 就
// 再补一次 `set_size(original)` 兜底。
//
// 已知限制:tiling Wayland 合成器(sway/river/hyprland)会完全忽略
// `set_size`,此时对账永远 drift=0(因为两次 set_size 都是 no-op),
// 看起来"没问题"但失效模式 B 其实没被修复;这是已知限制,需要用户
// 侧用 GDK_BACKEND=x11 绕过,README 应该有说明。
tokio::time::sleep(RECONCILE_WAIT).await;
match window.inner_size() {
Ok(after) => {
if after.width != original.width || after.height != original.height {
log::info!(
"Linux nudge 尺寸 drift: expected={}x{}, got={}x{},已补偿",
original.width,
original.height,
after.width,
after.height
);
let _ = window.set_size(original);
// 最终校验:如果补偿后仍然不一致,记 warn 让用户/开发者
// 知道对账失败。这时窗口会停在非预期尺寸(通常是 +1px),
// 属于极端兜底场景。
if let Ok(final_size) = window.inner_size() {
if final_size.width != original.width
|| final_size.height != original.height
{
log::warn!(
"Linux nudge 尺寸 drift 补偿后仍不一致: expected={}x{}, got={}x{}",
original.width,
original.height,
final_size.width,
final_size.height
);
}
}
}
}
Err(e) => {
log::warn!("Linux nudge: 对账回读 inner_size 失败: {e}");
}
}
}
Err(e) => {
// 极罕见的失败路径;只做了 set_focus 也比什么都不做强,
// 不要让 resize 失败把整个补丁吞掉。
log::warn!("Linux nudge: 读取 inner_size 失败,跳过伪 resize: {e}");
}
}
});
}
+6
View File
@@ -10,6 +10,12 @@ fn main() {
if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
// 禁用 WebKitGTK 合成模式,规避 resize 时 webview 崩溃以及部分 Wayland
// 合成器下的 surface 协商问题(整窗 UI 点击无响应、必须最大化-还原才能恢复)。
// 参考: https://github.com/tauri-apps/tauri/issues/9394
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
}
}
cc_switch_lib::run();
+2 -3
View File
@@ -268,7 +268,6 @@ pub struct ProviderMeta {
/// Claude API 格式(仅 Claude 供应商使用)
/// - "anthropic": 原生 Anthropic Messages API,直接透传
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
/// - "gemini_chat": Gemini Chat 兼容格式,需要转换,但不注入 prompt_cache_key
/// - "openai_responses": OpenAI Responses API 格式,需要转换
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
pub api_format: Option<String>,
@@ -283,9 +282,9 @@ pub struct ProviderMeta {
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
pub is_full_url: Option<bool>,
/// Prompt cache key for OpenAI-compatible endpoints that accept it.
/// Prompt cache key for OpenAI-compatible endpoints.
/// When set, injected into converted requests to improve cache hit rate.
/// If not set, provider ID is used automatically during openai_chat/openai_responses conversion.
/// If not set, provider ID is used automatically during format conversion.
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
/// 累加模式应用中,该 provider 是否已写入 live config。
+66 -14
View File
@@ -17,7 +17,8 @@ use super::{
types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig},
ProxyError,
};
use crate::commands::CopilotAuthState;
use crate::commands::{CodexOAuthState, CopilotAuthState};
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
use crate::{app_config::AppType, provider::Provider};
use http::Extensions;
@@ -917,6 +918,9 @@ impl RequestForwarder {
let force_identity_encoding = needs_transform
|| should_force_identity_encoding(&effective_endpoint, &filtered_body, headers);
// Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充)
let mut codex_oauth_account_id: Option<String> = None;
// 获取认证头(提前准备,用于内联替换)
let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
// GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token
@@ -969,11 +973,71 @@ impl RequestForwarder {
));
}
}
// Codex OAuth 特殊处理:从 CodexOAuthManager 获取真实 access_token
if auth.strategy == AuthStrategy::CodexOAuth {
if let Some(app_handle) = &self.app_handle {
let codex_state = app_handle.state::<CodexOAuthState>();
let codex_auth: tokio::sync::RwLockReadGuard<'_, CodexOAuthManager> =
codex_state.0.read().await;
// 从 provider.meta 获取关联的 ChatGPT 账号 ID
let account_id = provider
.meta
.as_ref()
.and_then(|m| m.managed_account_id_for("codex_oauth"));
let token_result = match &account_id {
Some(id) => {
log::debug!("[CodexOAuth] 使用指定账号 {id} 获取 token");
codex_auth.get_valid_token_for_account(id).await
}
None => {
log::debug!("[CodexOAuth] 使用默认账号获取 token");
codex_auth.get_valid_token().await
}
};
match token_result {
Ok(token) => {
auth = AuthInfo::new(token, AuthStrategy::CodexOAuth);
// 解析使用的 account_id(用于注入 ChatGPT-Account-Id header
codex_oauth_account_id = match account_id {
Some(id) => Some(id),
None => codex_auth.default_account_id().await,
};
log::debug!(
"[CodexOAuth] 成功获取 access_token (account={})",
codex_oauth_account_id.as_deref().unwrap_or("default")
);
}
Err(e) => {
log::error!("[CodexOAuth] 获取 access_token 失败: {e}");
return Err(ProxyError::AuthError(format!(
"Codex OAuth 认证失败: {e}"
)));
}
}
} else {
log::error!("[CodexOAuth] AppHandle 不可用");
return Err(ProxyError::AuthError(
"Codex OAuth 认证不可用(无 AppHandle".to_string(),
));
}
}
adapter.get_auth_headers(&auth)
} else {
Vec::new()
};
// 注入 Codex OAuth 的 ChatGPT-Account-Id header(如果有 account_id
if let Some(ref account_id) = codex_oauth_account_id {
if let Ok(hv) = http::HeaderValue::from_str(account_id) {
auth_headers.push((http::HeaderName::from_static("chatgpt-account-id"), hv));
}
}
// --- Copilot 优化器:动态 header 注入 ---
if let Some((ref classification, ref det_request_id)) = copilot_optimization {
for (name, value) in auth_headers.iter_mut() {
@@ -1544,7 +1608,7 @@ fn rewrite_claude_transform_endpoint(
let target_path = if is_copilot && api_format == "openai_responses" {
"/v1/responses"
} else if is_copilot || api_format == "gemini_chat" {
} else if is_copilot {
"/chat/completions"
} else if api_format == "openai_responses" {
"/v1/responses"
@@ -1694,18 +1758,6 @@ mod tests {
assert_eq!(passthrough_query.as_deref(), Some("foo=bar"));
}
#[test]
fn rewrite_claude_transform_endpoint_uses_gemini_chat_path() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
"/v1/messages?beta=true&foo=bar",
"gemini_chat",
false,
);
assert_eq!(endpoint, "/chat/completions?foo=bar");
assert_eq!(passthrough_query.as_deref(), Some("foo=bar"));
}
#[test]
fn rewrite_claude_transform_endpoint_strips_beta_for_responses() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
+10
View File
@@ -119,6 +119,15 @@ pub enum AuthStrategy {
///
/// 使用动态获取的 Copilot Token(通过 GitHub OAuth 设备码流程获取)
GitHubCopilot,
/// Codex OAuth 认证方式(ChatGPT Plus/Pro
///
/// - Header: `Authorization: Bearer <access_token>`
/// - Header: `ChatGPT-Account-Id: <account_id>` (来自 forwarder 注入)
/// - Header: `originator: cc-switch`
///
/// 使用动态获取的 OpenAI access_token(通过 Device Code 流程获取)
CodexOAuth,
}
#[cfg(test)]
@@ -234,6 +243,7 @@ mod tests {
AuthStrategy::Google,
AuthStrategy::GoogleOAuth,
AuthStrategy::GitHubCopilot,
AuthStrategy::CodexOAuth,
];
for (i, s1) in strategies.iter().enumerate() {
+76 -53
View File
@@ -5,7 +5,6 @@
//! ## API 格式
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
//! - **gemini_chat**: Gemini Chat 兼容格式,走 `/chat/completions`,且不注入 `prompt_cache_key`
//! - **openai_responses**: OpenAI Responses API 格式,需要 Anthropic ↔ Responses 转换
//!
//! ## 认证模式
@@ -23,12 +22,18 @@ use crate::proxy::error::ProxyError;
/// 供 handler/forwarder 外部使用的公开函数。
/// 优先级:meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic"
pub fn get_claude_api_format(provider: &Provider) -> &'static str {
// 0) Codex OAuth 强制使用 openai_responses(不可被覆盖)
if let Some(meta) = provider.meta.as_ref() {
if meta.provider_type.as_deref() == Some("codex_oauth") {
return "openai_responses";
}
}
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
if let Some(meta) = provider.meta.as_ref() {
if let Some(api_format) = meta.api_format.as_deref() {
return match api_format {
"openai_chat" => "openai_chat",
"gemini_chat" => "gemini_chat",
"openai_responses" => "openai_responses",
_ => "anthropic",
};
@@ -43,7 +48,6 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
{
return match api_format {
"openai_chat" => "openai_chat",
"gemini_chat" => "gemini_chat",
"openai_responses" => "openai_responses",
_ => "anthropic",
};
@@ -69,10 +73,7 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
}
pub fn claude_api_format_needs_transform(api_format: &str) -> bool {
matches!(
api_format,
"openai_chat" | "gemini_chat" | "openai_responses"
)
matches!(api_format, "openai_chat" | "openai_responses")
}
pub fn transform_claude_request_for_api_format(
@@ -88,10 +89,20 @@ pub fn transform_claude_request_for_api_format(
match api_format {
"openai_responses" => {
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false
// + include: ["reasoning.encrypted_content"],由 transform 层统一处理。
let is_codex_oauth = provider
.meta
.as_ref()
.and_then(|m| m.provider_type.as_deref())
== Some("codex_oauth");
super::transform_responses::anthropic_to_responses(
body,
Some(cache_key),
is_codex_oauth,
)
}
"openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)),
"gemini_chat" => super::transform::anthropic_to_openai(body, None),
_ => Ok(body),
}
}
@@ -108,10 +119,16 @@ impl ClaudeAdapter {
///
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
/// - GitHubCopilot: meta.provider_type 为 github_copilot 或 base_url 包含 githubcopilot.com
/// - CodexOAuth: meta.provider_type 为 codex_oauth
/// - OpenRouter: base_url 包含 openrouter.ai
/// - ClaudeAuth: auth_mode 为 bearer_only
/// - Claude: 默认 Anthropic 官方
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
// 检测 Codex OAuth (ChatGPT Plus/Pro)
if self.is_codex_oauth(provider) {
return ProviderType::CodexOAuth;
}
// 检测 GitHub Copilot
if self.is_github_copilot(provider) {
return ProviderType::GitHubCopilot;
@@ -130,6 +147,16 @@ impl ClaudeAdapter {
ProviderType::Claude
}
/// 检测是否为 Codex OAuth 供应商(ChatGPT Plus/Pro 反代)
fn is_codex_oauth(&self, provider: &Provider) -> bool {
if let Some(meta) = provider.meta.as_ref() {
if meta.provider_type.as_deref() == Some("codex_oauth") {
return true;
}
}
false
}
/// 检测是否为 GitHub Copilot 供应商
fn is_github_copilot(&self, provider: &Provider) -> bool {
// 方式1: 检查 meta.provider_type
@@ -162,7 +189,6 @@ impl ClaudeAdapter {
/// 从 provider.meta.api_format 读取格式设置:
/// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
/// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
/// - "gemini_chat": Gemini Chat 兼容格式,需要格式转换,但不注入 prompt_cache_key
/// - "openai_responses": OpenAI Responses API 格式,需要格式转换
fn get_api_format(&self, provider: &Provider) -> &'static str {
get_claude_api_format(provider)
@@ -262,6 +288,11 @@ impl ProviderAdapter for ClaudeAdapter {
}
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
// Codex OAuth: 强制使用 ChatGPT 后端 API 端点(忽略用户配置的 base_url)
if self.is_codex_oauth(provider) {
return Ok("https://chatgpt.com/backend-api/codex".to_string());
}
// 1. 从 env 中获取
if let Some(env) = provider.settings_config.get("env") {
if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
@@ -312,6 +343,15 @@ impl ProviderAdapter for ClaudeAdapter {
));
}
// Codex OAuth (ChatGPT Plus/Pro) 同样使用占位符
// 实际的 access_token 由 CodexOAuthManager 动态提供
if provider_type == ProviderType::CodexOAuth {
return Some(AuthInfo::new(
"codex_oauth_placeholder".to_string(),
AuthStrategy::CodexOAuth,
));
}
let strategy = match provider_type {
ProviderType::OpenRouter => AuthStrategy::Bearer,
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
@@ -323,6 +363,12 @@ impl ProviderAdapter for ClaudeAdapter {
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
// Codex OAuth: 所有请求统一走 /responses 端点
if base_url == "https://chatgpt.com/backend-api/codex" {
let _ = endpoint; // 忽略原始 endpoint
return "https://chatgpt.com/backend-api/codex/responses".to_string();
}
// NOTE:
// 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages`
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。
@@ -355,6 +401,20 @@ impl ProviderAdapter for ClaudeAdapter {
HeaderValue::from_str(&bearer).unwrap(),
)]
}
AuthStrategy::CodexOAuth => {
// 注意:bearer token 由 forwarder 动态注入到 auth.api_key
// ChatGPT-Account-Id 由 forwarder 注入额外 header
vec![
(
HeaderName::from_static("authorization"),
HeaderValue::from_str(&bearer).unwrap(),
),
(
HeaderName::from_static("originator"),
HeaderValue::from_static("cc-switch"),
),
]
}
AuthStrategy::GitHubCopilot => {
// 生成请求追踪 ID
let request_id = uuid::Uuid::new_v4().to_string();
@@ -420,14 +480,18 @@ impl ProviderAdapter for ClaudeAdapter {
return true;
}
// Codex OAuth 总是需要格式转换 (Anthropic → OpenAI Responses API)
if self.is_codex_oauth(provider) {
return true;
}
// 根据 api_format 配置决定是否需要格式转换
// - "anthropic" (默认): 直接透传,无需转换
// - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换
// - "gemini_chat": 需要 Anthropic ↔ Gemini Chat 兼容格式转换(不注入 prompt_cache_key
// - "openai_responses": 需要 Anthropic ↔ OpenAI Responses API 格式转换
matches!(
self.get_api_format(provider),
"openai_chat" | "gemini_chat" | "openai_responses"
"openai_chat" | "openai_responses"
)
}
@@ -735,20 +799,6 @@ mod tests {
);
assert!(adapter.needs_transform(&openai_chat_provider));
// Gemini Chat format in meta: needs transform
let gemini_chat_provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com/v1beta/openai"
}
}),
ProviderMeta {
api_format: Some("gemini_chat".to_string()),
..Default::default()
},
);
assert!(adapter.needs_transform(&gemini_chat_provider));
// OpenAI Responses format in meta: needs transform
let openai_responses_provider = create_provider_with_meta(
json!({
@@ -877,31 +927,4 @@ mod tests {
assert!(transformed.get("input").is_some());
assert!(transformed.get("max_output_tokens").is_some());
}
#[test]
fn test_transform_claude_request_for_api_format_gemini_chat_omits_prompt_cache_key() {
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com/v1beta/openai"
}
}),
ProviderMeta {
prompt_cache_key: Some("custom-cache-key".to_string()),
..Default::default()
},
);
let body = json!({
"model": "gemini-2.5-flash",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128
});
let transformed =
transform_claude_request_for_api_format(body, &provider, "gemini_chat").unwrap();
assert_eq!(transformed["model"], "gemini-2.5-flash");
assert!(transformed.get("messages").is_some());
assert!(transformed.get("prompt_cache_key").is_none());
}
}
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -15,6 +15,7 @@ mod adapter;
mod auth;
mod claude;
mod codex;
pub mod codex_oauth_auth;
pub mod copilot_auth;
mod gemini;
pub mod models;
@@ -58,6 +59,8 @@ pub enum ProviderType {
OpenRouter,
/// GitHub Copilot (OAuth + Copilot Token,需要 Anthropic ↔ OpenAI 转换)
GitHubCopilot,
/// OpenAI Codex (ChatGPT Plus/Pro OAuth,需要 Anthropic ↔ Responses API 转换)
CodexOAuth,
}
impl ProviderType {
@@ -70,6 +73,7 @@ impl ProviderType {
pub fn needs_transform(&self) -> bool {
match self {
ProviderType::GitHubCopilot => true,
ProviderType::CodexOAuth => true,
ProviderType::OpenRouter => false,
_ => false,
}
@@ -86,6 +90,7 @@ impl ProviderType {
}
ProviderType::OpenRouter => "https://openrouter.ai/api",
ProviderType::GitHubCopilot => "https://api.githubcopilot.com",
ProviderType::CodexOAuth => "https://chatgpt.com/backend-api/codex",
}
}
@@ -101,6 +106,9 @@ impl ProviderType {
if meta.provider_type.as_deref() == Some("github_copilot") {
return ProviderType::GitHubCopilot;
}
if meta.provider_type.as_deref() == Some("codex_oauth") {
return ProviderType::CodexOAuth;
}
}
// 检测 base_url 是否为 GitHub Copilot
@@ -175,6 +183,7 @@ impl ProviderType {
ProviderType::GeminiCli => "gemini_cli",
ProviderType::OpenRouter => "openrouter",
ProviderType::GitHubCopilot => "github_copilot",
ProviderType::CodexOAuth => "codex_oauth",
}
}
}
@@ -199,6 +208,7 @@ impl std::str::FromStr for ProviderType {
"github_copilot" | "github-copilot" | "githubcopilot" => {
Ok(ProviderType::GitHubCopilot)
}
"codex_oauth" | "codex-oauth" | "codexoauth" => Ok(ProviderType::CodexOAuth),
_ => Err(format!("Invalid provider type: {s}")),
}
}
@@ -228,7 +238,8 @@ pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn Pr
ProviderType::Claude
| ProviderType::ClaudeAuth
| ProviderType::OpenRouter
| ProviderType::GitHubCopilot => Box::new(ClaudeAdapter::new()),
| ProviderType::GitHubCopilot
| ProviderType::CodexOAuth => Box::new(ClaudeAdapter::new()),
ProviderType::Codex => Box::new(CodexAdapter::new()),
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
}
+43 -2
View File
@@ -93,6 +93,7 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! {
let mut buffer = String::new();
let mut utf8_remainder: Vec<u8> = Vec::new();
let mut message_id = None;
let mut current_model = None;
let mut next_content_index: u32 = 0;
@@ -107,8 +108,7 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
while let Some(chunk) = stream.next().await {
match chunk {
Ok(bytes) => {
let text = String::from_utf8_lossy(&bytes);
buffer.push_str(&text);
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
while let Some(pos) = buffer.find("\n\n") {
let line = buffer[..pos].to_string();
@@ -750,4 +750,45 @@ mod tests {
assert!(deltas.contains(&"{\"a\":"));
assert!(deltas.contains(&"1}"));
}
#[tokio::test]
async fn test_streaming_chinese_split_across_chunks_no_replacement_chars() {
// "你好" split across two TCP chunks inside a streaming text delta.
// Before the fix, from_utf8_lossy would produce U+FFFD for each half.
let full = concat!(
"data: {\"id\":\"chatcmpl_3\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_3\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2}}\n\n",
"data: [DONE]\n\n"
);
let bytes = full.as_bytes();
// Find "你" in the byte stream and split inside it
let ni_start = bytes.windows(3).position(|w| w == "".as_bytes()).unwrap();
let split_point = ni_start + 1; // split after first byte of "你"
let chunk1 = Bytes::from(bytes[..split_point].to_vec());
let chunk2 = Bytes::from(bytes[split_point..].to_vec());
let upstream = stream::iter(vec![
Ok::<_, std::io::Error>(chunk1),
Ok::<_, std::io::Error>(chunk2),
]);
let converted = create_anthropic_sse_stream(upstream);
let chunks: Vec<_> = converted.collect().await;
let merged = chunks
.into_iter()
.map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())
.collect::<String>();
// Must contain the original Chinese characters, not replacement chars
assert!(
merged.contains("你好"),
"expected '你好' in output, got replacement chars (U+FFFD)"
);
assert!(
!merged.contains('\u{FFFD}'),
"output must not contain U+FFFD replacement characters"
);
}
}
@@ -101,6 +101,7 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! {
let mut buffer = String::new();
let mut utf8_remainder: Vec<u8> = Vec::new();
let mut message_id: Option<String> = None;
let mut current_model: Option<String> = None;
let mut has_sent_message_start = false;
@@ -118,8 +119,7 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
while let Some(chunk) = stream.next().await {
match chunk {
Ok(bytes) => {
let text = String::from_utf8_lossy(&bytes);
buffer.push_str(&text);
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
// SSE 事件由 \n\n 分隔
while let Some(pos) = buffer.find("\n\n") {
@@ -1029,4 +1029,45 @@ mod tests {
assert_eq!(text_stops, 1);
assert_eq!(text_deltas, vec!["".to_string(), "".to_string()]);
}
#[tokio::test]
async fn test_streaming_responses_chinese_split_across_chunks_no_replacement_chars() {
// Chinese text delta split across two TCP chunks.
let full = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_cn\",\"model\":\"gpt-4o\",\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}\n\n",
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"你好世界\"}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":4}}}\n\n"
);
let bytes = full.as_bytes();
// Find "你" and split inside it
let ni_start = bytes.windows(3).position(|w| w == "".as_bytes()).unwrap();
let split_point = ni_start + 2; // split after second byte of "你"
let chunk1 = Bytes::from(bytes[..split_point].to_vec());
let chunk2 = Bytes::from(bytes[split_point..].to_vec());
let upstream = stream::iter(vec![
Ok::<_, std::io::Error>(chunk1),
Ok::<_, std::io::Error>(chunk2),
]);
let converted = create_anthropic_sse_stream_from_responses(upstream);
let chunks: Vec<_> = converted.collect().await;
let merged = chunks
.into_iter()
.map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())
.collect::<String>();
assert!(
merged.contains("你好世界"),
"expected '你好世界' in output, got replacement chars (U+FFFD)"
);
assert!(
!merged.contains('\u{FFFD}'),
"output must not contain U+FFFD replacement characters"
);
}
}
@@ -113,6 +113,7 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
}
}
normalize_openai_system_messages(&mut messages);
result["messages"] = json!(messages);
// 转换参数 — o-series 模型需要 max_completion_tokens
@@ -182,6 +183,57 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
Ok(result)
}
fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
let system_count = messages
.iter()
.filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("system"))
.count();
if system_count == 0 {
return;
}
if system_count == 1 {
if let Some(index) = messages.iter().position(|message| {
message.get("role").and_then(|value| value.as_str()) == Some("system")
}) {
if index > 0 {
let message = messages.remove(index);
messages.insert(0, message);
}
}
return;
}
let mut parts = Vec::new();
messages.retain(|message| {
if message.get("role").and_then(|value| value.as_str()) != Some("system") {
return true;
}
match message.get("content") {
Some(Value::String(text)) if !text.is_empty() => parts.push(text.clone()),
Some(Value::Array(content_parts)) => {
let text = content_parts
.iter()
.filter_map(|part| part.get("text").and_then(|value| value.as_str()))
.collect::<Vec<_>>()
.join("\n");
if !text.is_empty() {
parts.push(text);
}
}
_ => {}
}
false
});
if !parts.is_empty() {
messages.insert(0, json!({"role": "system", "content": parts.join("\n")}));
}
}
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
fn convert_message_to_openai(
role: &str,
@@ -560,6 +612,31 @@ mod tests {
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
}
#[test]
fn test_anthropic_to_openai_normalizes_fragmented_system_messages() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": [
{"type": "text", "text": "You are Claude Code."},
{"type": "text", "text": "Be concise."}
],
"messages": [
{"role": "system", "content": "Follow repo conventions."},
{"role": "user", "content": "Hello"}
]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["messages"].as_array().unwrap().len(), 2);
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
"You are Claude Code.\nBe concise.\nFollow repo conventions."
);
assert_eq!(result["messages"][1]["role"], "user");
}
#[test]
fn test_anthropic_to_openai_tool_use() {
let input = json!({
@@ -14,7 +14,14 @@ use serde_json::{json, Value};
/// Anthropic 请求 → OpenAI Responses 请求
///
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {
/// `is_codex_oauth`: 当目标后端是 ChatGPT Plus/Pro 反代 (`chatgpt.com/backend-api/codex`) 时为 true。
/// 该后端强制要求 `store: false`,并要求 `include` 包含 `reasoning.encrypted_content`
/// 以便在无服务端状态下保持多轮 reasoning 上下文。
pub fn anthropic_to_responses(
body: Value,
cache_key: Option<&str>,
is_codex_oauth: bool,
) -> Result<Value, ProxyError> {
let mut result = json!({});
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
@@ -103,6 +110,59 @@ pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Va
result["prompt_cache_key"] = json!(key);
}
// Codex OAuth (ChatGPT Plus/Pro 反代) 特殊协议约束:
// 整体依据:OpenAI 官方 codex-rs 的 `ResponsesApiRequest` 结构体
// (codex-rs/codex-api/src/common.rs) 是 ChatGPT 反代后端的协议契约。
// 任何不在该结构体里的字段都可能被 ChatGPT 后端以
// "Unsupported parameter: ..." 400 拒绝;任何在结构体里的必填字段
// 都需要在请求体里出现。
//
// 字段处理:
// - store: 必须显式为 falseChatGPT 消费级后端不允许服务端持久化)
// - include: 必须包含 "reasoning.encrypted_content"
// 否则多轮 reasoning 中间态会丢失(无服务端状态 + 无加密回传 = 上下文断链)
// - max_output_tokens / temperature / top_p: 必须删除
// (codex-rs 结构体根本没有这三个字段,OpenAI 自己的客户端不发它们)
// - instructions / tools / parallel_tool_calls: 必填字段,缺则兜底默认值
// cc-switch 的 transform 当前是"条件写入",可能产生缺失)
// - stream: 必须永远 truecodex-rs 硬编码 true,且 cc-switch 的
// SSE 解析层只处理流式响应,强制覆盖避免客户端误传 false)
if is_codex_oauth {
result["store"] = json!(false);
const REASONING_MARKER: &str = "reasoning.encrypted_content";
let mut includes: Vec<Value> = body
.get("include")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
if !includes
.iter()
.any(|v| v.as_str() == Some(REASONING_MARKER))
{
includes.push(json!(REASONING_MARKER));
}
result["include"] = json!(includes);
if let Some(obj) = result.as_object_mut() {
// —— 删除 ChatGPT 反代不接受的字段 ——
obj.remove("max_output_tokens");
obj.remove("temperature");
obj.remove("top_p");
// —— 兜底必填字段(or_insert:客户端送了什么就保留,否则注入默认值)——
obj.entry("instructions".to_string()).or_insert(json!(""));
obj.entry("tools".to_string()).or_insert(json!([]));
obj.entry("parallel_tool_calls".to_string())
.or_insert(json!(false));
// —— 强制覆盖 stream = true ——
// 即便客户端误传 stream:false 也要覆盖,因为 codex-rs 永远 true
// 且 cc-switch SSE 解析层只支持流式响应。
obj.insert("stream".to_string(), json!(true));
}
}
Ok(result)
}
@@ -468,7 +528,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["model"], "gpt-4o");
assert_eq!(result["max_output_tokens"], 1024);
assert_eq!(result["input"][0]["role"], "user");
@@ -487,7 +547,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["instructions"], "You are a helpful assistant.");
// system should not appear in input
assert_eq!(result["input"].as_array().unwrap().len(), 1);
@@ -505,7 +565,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["instructions"], "Part 1\n\nPart 2");
}
@@ -522,7 +582,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["tools"][0]["type"], "function");
assert_eq!(result["tools"][0]["name"], "get_weather");
assert!(result["tools"][0].get("parameters").is_some());
@@ -539,7 +599,7 @@ mod tests {
"tool_choice": {"type": "any"}
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["tool_choice"], "required");
}
@@ -552,7 +612,7 @@ mod tests {
"tool_choice": {"type": "tool", "name": "get_weather"}
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["tool_choice"]["type"], "function");
assert_eq!(result["tool_choice"]["name"], "get_weather");
}
@@ -571,7 +631,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
let input_arr = result["input"].as_array().unwrap();
// Should produce: assistant message (text) + function_call item
@@ -601,7 +661,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
let input_arr = result["input"].as_array().unwrap();
// Should produce: function_call_output item (lifted)
@@ -625,7 +685,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
let input_arr = result["input"].as_array().unwrap();
// thinking should be discarded, only text remains
@@ -648,7 +708,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
let content = result["input"][0]["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "input_text");
@@ -806,7 +866,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["model"], "o3-mini");
}
@@ -818,7 +878,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, Some("my-provider-id")).unwrap();
let result = anthropic_to_responses(input, Some("my-provider-id"), false).unwrap();
assert_eq!(result["prompt_cache_key"], "my-provider-id");
}
@@ -836,7 +896,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert!(result["tools"][0].get("cache_control").is_none());
}
@@ -853,7 +913,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert!(result["input"][0]["content"][0]
.get("cache_control")
.is_none());
@@ -915,7 +975,7 @@ mod tests {
"max_tokens": 4096,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["max_output_tokens"], 4096);
assert!(result.get("max_completion_tokens").is_none());
}
@@ -929,7 +989,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "xhigh");
}
@@ -943,7 +1003,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "low");
}
@@ -956,7 +1016,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "low");
}
@@ -969,7 +1029,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "medium");
}
@@ -982,7 +1042,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "high");
}
@@ -995,7 +1055,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "high");
}
@@ -1008,7 +1068,271 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
let result = anthropic_to_responses(input, None, false).unwrap();
assert!(result.get("reasoning").is_none());
}
// ==================== Codex OAuth (ChatGPT 反代) 协议约束 ====================
#[test]
fn test_anthropic_to_responses_codex_oauth_sets_store_and_include() {
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
// store 必须显式为 falseChatGPT 后端拒绝 true
assert_eq!(result["store"], json!(false));
// include 必须包含 reasoning.encrypted_content(无服务端状态下保持多轮 reasoning)
assert_eq!(result["include"], json!(["reasoning.encrypted_content"]));
}
#[test]
fn test_anthropic_to_responses_non_codex_omits_store_and_include() {
// 回归护栏:is_codex_oauth=false 时,行为必须与今日字节级一致
// —— 不写 store、不写 includeOpenRouter / Azure / OpenAI 付费 API 路径不受影响
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert!(result.get("store").is_none());
assert!(result.get("include").is_none());
}
#[test]
fn test_anthropic_to_responses_codex_oauth_preserves_existing_include() {
// 客户端预置了 includeunion 保留原有项 + 添加 marker,不重复
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}],
"include": ["something.else", "reasoning.encrypted_content"]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let includes = result["include"]
.as_array()
.expect("include should be array");
// 原有项必须保留
assert!(includes
.iter()
.any(|v| v.as_str() == Some("something.else")));
// marker 必须存在
assert!(includes
.iter()
.any(|v| v.as_str() == Some("reasoning.encrypted_content")));
// 不重复:marker 只出现一次
let marker_count = includes
.iter()
.filter(|v| v.as_str() == Some("reasoning.encrypted_content"))
.count();
assert_eq!(marker_count, 1, "marker 不应被重复添加(idempotent 失败)");
}
#[test]
fn test_anthropic_to_responses_codex_oauth_strips_max_output_tokens() {
// ChatGPT Plus/Pro 反代不接受 max_output_tokensOpenAI 官方 codex-rs 的
// ResponsesApiRequest 结构体里也没有这个字段),必须删除,否则服务端 400:
// "Unsupported parameter: max_output_tokens"
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
assert!(
result.get("max_output_tokens").is_none(),
"Codex OAuth 路径必须删除 max_output_tokens"
);
}
#[test]
fn test_anthropic_to_responses_non_codex_keeps_max_output_tokens() {
// 回归护栏:非 Codex OAuth 路径必须保留 max_output_tokens
// —— OpenAI 付费 Responses API / Azure 等仍然依赖这个字段
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["max_output_tokens"], json!(1024));
}
// ==================== 第二轮:P0 + P1 字段对齐 ====================
#[test]
fn test_codex_oauth_strips_temperature() {
// P0: ChatGPT 反代不接受 temperature
// 依据:OpenAI 官方 codex-rs 的 ResponsesApiRequest 结构体根本没有这个字段
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"temperature": 0.7,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
assert!(
result.get("temperature").is_none(),
"Codex OAuth 路径必须删除 temperature"
);
}
#[test]
fn test_codex_oauth_strips_top_p() {
// P0: ChatGPT 反代不接受 top_p
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"top_p": 0.9,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
assert!(
result.get("top_p").is_none(),
"Codex OAuth 路径必须删除 top_p"
);
}
#[test]
fn test_codex_oauth_defaults_required_fields_when_absent() {
// P1: 极简输入(无 system / 无 tools / 无 stream),断言四个必填字段都被注入默认值
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
assert_eq!(
result["instructions"],
json!(""),
"instructions 缺失时应兜底为空字符串"
);
assert_eq!(result["tools"], json!([]), "tools 缺失时应兜底为空数组");
assert_eq!(
result["parallel_tool_calls"],
json!(false),
"parallel_tool_calls 应兜底为 false"
);
assert_eq!(result["stream"], json!(true), "stream 应被强制设为 true");
}
#[test]
fn test_codex_oauth_preserves_existing_instructions_and_tools() {
// P1: 客户端送了 system 和 tools,应保留原值,不被默认值覆盖
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"system": "You are a helpful assistant",
"tools": [{
"name": "get_weather",
"description": "Get weather",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string"}}
}
}],
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
assert_eq!(
result["instructions"],
json!("You are a helpful assistant"),
"client 已送的 instructions 必须保留"
);
let tools = result["tools"].as_array().expect("tools 应为数组");
assert_eq!(tools.len(), 1, "client 已送的 tools 必须保留");
assert_eq!(tools[0]["name"], json!("get_weather"));
}
#[test]
fn test_codex_oauth_forces_stream_true_even_when_client_sends_false() {
// 即使客户端误传 stream:false,也要强制覆盖为 true
// 依据:cc-switch SSE 解析层只支持流式响应
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"stream": false,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
assert_eq!(
result["stream"],
json!(true),
"Codex OAuth 路径下 stream 必须强制为 true"
);
}
#[test]
fn test_non_codex_keeps_temperature_and_top_p() {
// 回归护栏:非 Codex OAuth 路径必须保留 temperature/top_p
// —— 防止 P0 删除逻辑误扩散到 OpenRouter / Azure / 付费 OpenAI 路径
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"temperature": 0.7,
"top_p": 0.9,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["temperature"], json!(0.7));
assert_eq!(result["top_p"], json!(0.9));
}
#[test]
fn test_non_codex_does_not_inject_default_required_fields() {
// 回归护栏:非 Codex OAuth 路径不应被 P1 默认值污染
// —— OpenRouter / Azure / 付费 OpenAI 等保持原有"条件写入"语义
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert!(
result.get("parallel_tool_calls").is_none(),
"非 Codex OAuth 路径不应注入 parallel_tool_calls"
);
assert!(
result.get("stream").is_none(),
"非 Codex OAuth 路径不应注入 stream"
);
// instructions 和 tools 因为客户端没送,所以不应出现
assert!(
result.get("instructions").is_none(),
"非 Codex OAuth 路径下 instructions 在客户端未送时不应被注入"
);
assert!(
result.get("tools").is_none(),
"非 Codex OAuth 路径下 tools 在客户端未送时不应被注入"
);
}
}
+2 -2
View File
@@ -71,6 +71,7 @@ impl StreamHandler {
async_stream::stream! {
let mut _last_activity = Instant::now();
let mut buffer = String::new();
let mut utf8_remainder: Vec<u8> = Vec::new();
tokio::pin!(stream);
@@ -82,8 +83,7 @@ impl StreamHandler {
_last_activity = Instant::now();
// 解析 SSE 事件
let text = String::from_utf8_lossy(&bytes);
buffer.push_str(&text);
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
// 提取完整事件
while let Some(pos) = buffer.find("\n\n") {
+2 -2
View File
@@ -568,6 +568,7 @@ pub fn create_logged_passthrough_stream(
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! {
let mut buffer = String::new();
let mut utf8_remainder: Vec<u8> = Vec::new();
let mut collector = usage_collector;
let mut is_first_chunk = true;
@@ -619,8 +620,7 @@ pub fn create_logged_passthrough_stream(
);
}
is_first_chunk = false;
let text = String::from_utf8_lossy(&bytes);
buffer.push_str(&text);
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
// 尝试解析并记录完整的 SSE 事件
while let Some(pos) = buffer.find("\n\n") {
+274 -1
View File
@@ -4,9 +4,71 @@ pub(crate) fn strip_sse_field<'a>(line: &'a str, field: &str) -> Option<&'a str>
.or_else(|| line.strip_prefix(&format!("{field}:")))
}
/// Append raw bytes to a UTF-8 `String` buffer, correctly handling multi-byte
/// characters that are split across chunk boundaries.
///
/// `remainder` accumulates trailing bytes from the previous chunk that form an
/// incomplete UTF-8 sequence (at most 3 bytes under normal operation). On each
/// call the remainder is prepended to `new_bytes`, the longest valid UTF-8
/// prefix is appended to `buffer`, and any trailing incomplete bytes are saved
/// back into `remainder` for the next call.
///
/// A defensive guard discards `remainder` via lossy conversion if it ever
/// exceeds 3 bytes, which cannot happen with well-formed UTF-8 streams.
pub(crate) fn append_utf8_safe(buffer: &mut String, remainder: &mut Vec<u8>, new_bytes: &[u8]) {
// Build the byte slice to decode: prepend any leftover bytes from previous chunk.
let (owned, bytes): (Option<Vec<u8>>, &[u8]) = if remainder.is_empty() {
(None, new_bytes)
} else {
// Defensive guard: remainder should never exceed 3 bytes (max incomplete
// UTF-8 sequence is 3 bytes: a 4-byte char missing its last byte). If it
// does, the stream is producing genuinely invalid bytes; flush them lossy
// and start fresh.
if remainder.len() > 3 {
buffer.push_str(&String::from_utf8_lossy(remainder));
remainder.clear();
(None, new_bytes)
} else {
let mut combined = std::mem::take(remainder);
combined.extend_from_slice(new_bytes);
(Some(combined), &[])
}
};
let input = owned.as_deref().unwrap_or(bytes);
// Decode loop: consume all valid UTF-8 and any genuinely invalid bytes,
// only leaving a trailing incomplete sequence in remainder.
let mut pos = 0;
loop {
match std::str::from_utf8(&input[pos..]) {
Ok(s) => {
buffer.push_str(s);
// Everything consumed remainder stays empty.
return;
}
Err(e) => {
let valid_up_to = pos + e.valid_up_to();
buffer.push_str(
// Safety: from_utf8 guarantees [pos..valid_up_to] is valid UTF-8.
std::str::from_utf8(&input[pos..valid_up_to]).unwrap(),
);
if let Some(invalid_len) = e.error_len() {
// Genuinely invalid byte(s) emit U+FFFD and continue.
buffer.push('\u{FFFD}');
pos = valid_up_to + invalid_len;
} else {
// Incomplete trailing sequence stash for next chunk.
*remainder = input[valid_up_to..].to_vec();
return;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::strip_sse_field;
use super::{append_utf8_safe, strip_sse_field};
#[test]
fn strip_sse_field_accepts_optional_space() {
@@ -28,4 +90,215 @@ mod tests {
);
assert_eq!(strip_sse_field("id:1", "data"), None);
}
// ------------------------------------------------------------------
// append_utf8_safe tests
// ------------------------------------------------------------------
#[test]
fn ascii_passthrough() {
let mut buf = String::new();
let mut rem = Vec::new();
append_utf8_safe(&mut buf, &mut rem, b"hello world");
assert_eq!(buf, "hello world");
assert!(rem.is_empty());
}
#[test]
fn complete_multibyte_in_single_chunk() {
let mut buf = String::new();
let mut rem = Vec::new();
append_utf8_safe(&mut buf, &mut rem, "你好世界".as_bytes());
assert_eq!(buf, "你好世界");
assert!(rem.is_empty());
}
#[test]
fn split_multibyte_across_two_chunks() {
// "你" = E4 BD A0 (3 bytes)
let bytes = "".as_bytes();
assert_eq!(bytes.len(), 3);
let mut buf = String::new();
let mut rem = Vec::new();
// Chunk 1: first 2 bytes (incomplete)
append_utf8_safe(&mut buf, &mut rem, &bytes[..2]);
assert_eq!(buf, "");
assert_eq!(rem.len(), 2);
// Chunk 2: last byte completes the character
append_utf8_safe(&mut buf, &mut rem, &bytes[2..]);
assert_eq!(buf, "");
assert!(rem.is_empty());
}
#[test]
fn split_four_byte_char_across_chunks() {
// 😀 = F0 9F 98 80 (4 bytes)
let bytes = "😀".as_bytes();
assert_eq!(bytes.len(), 4);
let mut buf = String::new();
let mut rem = Vec::new();
// Send 1 byte at a time
append_utf8_safe(&mut buf, &mut rem, &bytes[..1]);
assert_eq!(buf, "");
assert_eq!(rem.len(), 1);
append_utf8_safe(&mut buf, &mut rem, &bytes[1..2]);
assert_eq!(buf, "");
assert_eq!(rem.len(), 2);
append_utf8_safe(&mut buf, &mut rem, &bytes[2..3]);
assert_eq!(buf, "");
assert_eq!(rem.len(), 3);
append_utf8_safe(&mut buf, &mut rem, &bytes[3..]);
assert_eq!(buf, "😀");
assert!(rem.is_empty());
}
#[test]
fn mixed_ascii_and_split_multibyte() {
// "hi你" = 68 69 E4 BD A0
let all = "hi你".as_bytes();
assert_eq!(all.len(), 5);
let mut buf = String::new();
let mut rem = Vec::new();
// Chunk 1: "hi" + first byte of "你"
append_utf8_safe(&mut buf, &mut rem, &all[..3]);
assert_eq!(buf, "hi");
assert_eq!(rem.len(), 1);
// Chunk 2: remaining 2 bytes of "你"
append_utf8_safe(&mut buf, &mut rem, &all[3..]);
assert_eq!(buf, "hi你");
assert!(rem.is_empty());
}
#[test]
fn multiple_split_characters_in_sequence() {
let text = "你好";
let bytes = text.as_bytes(); // E4 BD A0 E5 A5 BD
let mut buf = String::new();
let mut rem = Vec::new();
// Split in the middle: first char complete + 1 byte of second
append_utf8_safe(&mut buf, &mut rem, &bytes[..4]);
assert_eq!(buf, "");
assert_eq!(rem.len(), 1);
// Remaining 2 bytes complete second char
append_utf8_safe(&mut buf, &mut rem, &bytes[4..]);
assert_eq!(buf, "你好");
assert!(rem.is_empty());
}
#[test]
fn empty_chunks_are_harmless() {
let mut buf = String::new();
let mut rem = Vec::new();
append_utf8_safe(&mut buf, &mut rem, b"");
assert_eq!(buf, "");
assert!(rem.is_empty());
append_utf8_safe(&mut buf, &mut rem, b"ok");
assert_eq!(buf, "ok");
append_utf8_safe(&mut buf, &mut rem, b"");
assert_eq!(buf, "ok");
}
#[test]
fn sse_json_with_chinese_split_at_boundary() {
// Simulates an SSE data line with Chinese content split across chunks
let json_line = "data: {\"text\":\"你好\"}\n\n";
let bytes = json_line.as_bytes();
// Find where "你" starts in the byte stream and split there
let ni_start = bytes.windows(3).position(|w| w == "".as_bytes()).unwrap();
let split_point = ni_start + 1; // split inside "你"
let mut buf = String::new();
let mut rem = Vec::new();
append_utf8_safe(&mut buf, &mut rem, &bytes[..split_point]);
append_utf8_safe(&mut buf, &mut rem, &bytes[split_point..]);
assert_eq!(buf, json_line);
assert!(rem.is_empty());
// Verify the buffer can be parsed as SSE with valid JSON
let data = strip_sse_field(buf.lines().next().unwrap(), "data").unwrap();
let parsed: serde_json::Value = serde_json::from_str(data).unwrap();
assert_eq!(parsed["text"], "你好");
}
#[test]
fn invalid_bytes_flushed_immediately_not_accumulated() {
// 0xFF is never valid in UTF-8 it should be replaced immediately,
// not stashed in remainder.
let mut buf = String::new();
let mut rem = Vec::new();
// "hi" + invalid byte + "ok"
append_utf8_safe(&mut buf, &mut rem, b"hi\xFFok");
assert!(
rem.is_empty(),
"remainder should be empty after invalid byte"
);
assert!(buf.contains("hi"), "valid prefix must be present");
assert!(buf.contains("ok"), "valid suffix must be present");
assert!(buf.contains('\u{FFFD}'), "invalid byte must produce U+FFFD");
}
#[test]
fn invalid_byte_in_slow_path_flushed_immediately() {
let mut buf = String::new();
let mut rem = Vec::new();
// Prime remainder with an incomplete sequence (first byte of "你")
append_utf8_safe(&mut buf, &mut rem, &"".as_bytes()[..1]);
assert_eq!(rem.len(), 1);
// Next chunk starts with an invalid byte the stale remainder and the
// invalid byte should both be flushed, not accumulated.
append_utf8_safe(&mut buf, &mut rem, b"\xFFworld");
assert!(rem.is_empty(), "remainder should be empty");
assert!(
buf.contains("world"),
"valid data after invalid byte must appear"
);
}
#[test]
fn defensive_guard_flushes_oversized_remainder() {
let mut buf = String::new();
let mut rem = Vec::new();
// Manually inject 4 invalid bytes into remainder to trigger the >3 guard.
// This can't happen with well-formed UTF-8, but tests the safety net.
rem.extend_from_slice(b"\x80\x80\x80\x80");
assert_eq!(rem.len(), 4);
append_utf8_safe(&mut buf, &mut rem, b"hello");
// The 4 invalid bytes should have been flushed lossy, then "hello" decoded.
assert!(rem.is_empty(), "remainder must be empty after guard flush");
assert!(
buf.contains("hello"),
"valid data after guard flush must appear"
);
// The 4 invalid bytes each produce a U+FFFD
let replacement_count = buf.chars().filter(|&c| c == '\u{FFFD}').count();
assert_eq!(
replacement_count, 4,
"each invalid byte should produce one U+FFFD"
);
}
}
+411
View File
@@ -0,0 +1,411 @@
//! 供应商余额查询服务
//!
//! 支持 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 的账户余额查询。
//! 返回 UsageResult 格式,与现有用量系统无缝对接。
use crate::provider::{UsageData, UsageResult};
use std::time::Duration;
// ── 供应商检测 ──────────────────────────────────────────────
enum BalanceProvider {
DeepSeek,
StepFun,
SiliconFlow,
SiliconFlowEn,
OpenRouter,
NovitaAI,
}
fn detect_provider(base_url: &str) -> Option<BalanceProvider> {
let url = base_url.to_lowercase();
if url.contains("api.deepseek.com") {
Some(BalanceProvider::DeepSeek)
} else if url.contains("api.stepfun.ai") || url.contains("api.stepfun.com") {
Some(BalanceProvider::StepFun)
} else if url.contains("api.siliconflow.cn") {
Some(BalanceProvider::SiliconFlow)
} else if url.contains("api.siliconflow.com") {
Some(BalanceProvider::SiliconFlowEn)
} else if url.contains("openrouter.ai") {
Some(BalanceProvider::OpenRouter)
} else if url.contains("api.novita.ai") {
Some(BalanceProvider::NovitaAI)
} else {
None
}
}
fn make_error(msg: String) -> UsageResult {
UsageResult {
success: false,
data: None,
error: Some(msg),
}
}
fn make_auth_error(status: reqwest::StatusCode) -> UsageResult {
UsageResult {
success: false,
data: Some(vec![UsageData {
plan_name: None,
remaining: None,
total: None,
used: None,
unit: None,
is_valid: Some(false),
invalid_message: Some(format!("Authentication failed (HTTP {status})")),
extra: None,
}]),
error: Some(format!("Authentication failed (HTTP {status})")),
}
}
// ── DeepSeek ────────────────────────────────────────────────
// GET https://api.deepseek.com/user/balance
// Response: { balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }], is_available }
async fn query_deepseek(api_key: &str) -> UsageResult {
let client = crate::proxy::http_client::get();
let resp = client
.get("https://api.deepseek.com/user/balance")
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(10))
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => return make_error(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return make_auth_error(status);
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return make_error(format!("API error (HTTP {status}): {body}"));
}
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(e) => return make_error(format!("Failed to parse response: {e}")),
};
let is_available = body
.get("is_available")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let mut data = Vec::new();
if let Some(infos) = body.get("balance_infos").and_then(|v| v.as_array()) {
for info in infos {
let currency = info
.get("currency")
.and_then(|v| v.as_str())
.unwrap_or("CNY");
let total = parse_f64_field(info, "total_balance");
data.push(UsageData {
plan_name: Some(currency.to_string()),
remaining: total,
total: None,
used: None,
unit: Some(currency.to_string()),
is_valid: Some(is_available),
invalid_message: if !is_available {
Some("Insufficient balance".to_string())
} else {
None
},
extra: None,
});
}
}
UsageResult {
success: true,
data: if data.is_empty() { None } else { Some(data) },
error: None,
}
}
// ── StepFun ─────────────────────────────────────────────────
// GET https://api.stepfun.com/v1/accounts
// Response: { object, type, balance, total_cash_balance, total_voucher_balance }
async fn query_stepfun(api_key: &str) -> UsageResult {
let client = crate::proxy::http_client::get();
let resp = client
.get("https://api.stepfun.com/v1/accounts")
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(10))
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => return make_error(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return make_auth_error(status);
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return make_error(format!("API error (HTTP {status}): {body}"));
}
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(e) => return make_error(format!("Failed to parse response: {e}")),
};
let balance = parse_f64_field(&body, "balance").unwrap_or(0.0);
UsageResult {
success: true,
data: Some(vec![UsageData {
plan_name: Some("StepFun".to_string()),
remaining: Some(balance),
total: None,
used: None,
unit: Some("CNY".to_string()),
is_valid: Some(true),
invalid_message: None,
extra: None,
}]),
error: None,
}
}
// ── SiliconFlow ─────────────────────────────────────────────
// GET https://api.siliconflow.cn/v1/user/info (or .com for EN)
// Response: { code, data: { balance, chargeBalance, totalBalance, status } }
async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult {
let client = crate::proxy::http_client::get();
let domain = if is_cn {
"api.siliconflow.cn"
} else {
"api.siliconflow.com"
};
let url = format!("https://{domain}/v1/user/info");
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(10))
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => return make_error(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return make_auth_error(status);
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return make_error(format!("API error (HTTP {status}): {body}"));
}
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(e) => return make_error(format!("Failed to parse response: {e}")),
};
let data = match body.get("data") {
Some(d) => d,
None => return make_error("Missing 'data' field in response".to_string()),
};
let total_balance = parse_f64_field(data, "totalBalance").unwrap_or(0.0);
UsageResult {
success: true,
data: Some(vec![UsageData {
plan_name: Some("SiliconFlow".to_string()),
remaining: Some(total_balance),
total: None,
used: None,
unit: Some("CNY".to_string()),
is_valid: Some(true),
invalid_message: None,
extra: None,
}]),
error: None,
}
}
// ── OpenRouter ──────────────────────────────────────────────
// GET https://openrouter.ai/api/v1/credits
// Response: { data: { total_credits, total_usage } }
async fn query_openrouter(api_key: &str) -> UsageResult {
let client = crate::proxy::http_client::get();
let resp = client
.get("https://openrouter.ai/api/v1/credits")
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(10))
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => return make_error(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return make_auth_error(status);
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return make_error(format!("API error (HTTP {status}): {body}"));
}
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(e) => return make_error(format!("Failed to parse response: {e}")),
};
let data = body.get("data").unwrap_or(&body);
let total_credits = parse_f64_field(data, "total_credits").unwrap_or(0.0);
let total_usage = parse_f64_field(data, "total_usage").unwrap_or(0.0);
let remaining = total_credits - total_usage;
UsageResult {
success: true,
data: Some(vec![UsageData {
plan_name: Some("OpenRouter".to_string()),
remaining: Some(remaining),
total: Some(total_credits),
used: Some(total_usage),
unit: Some("USD".to_string()),
is_valid: Some(remaining > 0.0),
invalid_message: if remaining <= 0.0 {
Some("No credits remaining".to_string())
} else {
None
},
extra: None,
}]),
error: None,
}
}
// ── Novita AI ───────────────────────────────────────────────
// GET https://api.novita.ai/v3/user/balance
// Response: { availableBalance, cashBalance, creditLimit, outstandingInvoices }
// 金额单位:0.0001 USD
async fn query_novita(api_key: &str) -> UsageResult {
let client = crate::proxy::http_client::get();
let resp = client
.get("https://api.novita.ai/v3/user/balance")
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(10))
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => return make_error(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return make_auth_error(status);
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return make_error(format!("API error (HTTP {status}): {body}"));
}
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(e) => return make_error(format!("Failed to parse response: {e}")),
};
// Novita 金额单位为 0.0001 USD,需除以 10000 转为 USD
let available = parse_f64_field(&body, "availableBalance").unwrap_or(0.0) / 10000.0;
UsageResult {
success: true,
data: Some(vec![UsageData {
plan_name: Some("Novita AI".to_string()),
remaining: Some(available),
total: None,
used: None,
unit: Some("USD".to_string()),
is_valid: Some(available > 0.0),
invalid_message: if available <= 0.0 {
Some("No balance remaining".to_string())
} else {
None
},
extra: None,
}]),
error: None,
}
}
// ── 工具函数 ────────────────────────────────────────────────
/// 解析 JSON 字段为 f64,兼容数字和字符串格式
fn parse_f64_field(obj: &serde_json::Value, field: &str) -> Option<f64> {
obj.get(field).and_then(|v| {
v.as_f64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
}
// ── 公开入口 ────────────────────────────────────────────────
pub async fn get_balance(base_url: &str, api_key: &str) -> Result<UsageResult, String> {
if api_key.trim().is_empty() {
return Ok(UsageResult {
success: false,
data: None,
error: Some("API key is empty".to_string()),
});
}
let provider = match detect_provider(base_url) {
Some(p) => p,
None => {
return Ok(UsageResult {
success: false,
data: None,
error: Some("Unknown balance provider".to_string()),
})
}
};
let result = match provider {
BalanceProvider::DeepSeek => query_deepseek(api_key).await,
BalanceProvider::StepFun => query_stepfun(api_key).await,
BalanceProvider::SiliconFlow => query_siliconflow(api_key, true).await,
BalanceProvider::SiliconFlowEn => query_siliconflow(api_key, false).await,
BalanceProvider::OpenRouter => query_openrouter(api_key).await,
BalanceProvider::NovitaAI => query_novita(api_key).await,
};
Ok(result)
}
+4
View File
@@ -1,3 +1,4 @@
pub mod balance;
pub mod coding_plan;
pub mod config;
pub mod env_checker;
@@ -8,6 +9,9 @@ pub mod omo;
pub mod prompt;
pub mod provider;
pub mod proxy;
pub mod session_usage;
pub mod session_usage_codex;
pub mod session_usage_gemini;
pub mod skill;
pub mod speedtest;
pub mod stream_check;
+10 -9
View File
@@ -999,11 +999,12 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
return Ok(false);
}
{
let providers = state.db.get_all_providers(app_type.as_str())?;
if !providers.is_empty() {
return Ok(false); // 已有供应商,跳过
}
// 允许 "只有官方 seed 预设" 的情况下继续导入 live:
// - 启动编排顺序是先 import 后 seed,新用户启动时 providers 为空,导入照常
// - 老用户已有非 seed provider,跳过导入(正确)
// - 用户手动点 ProviderEmptyState 的导入按钮时,与官方 seed 共存而不被阻塞
if state.db.has_non_official_seed_provider(app_type.as_str())? {
return Ok(false);
}
let settings_config = match app_type {
@@ -1208,11 +1209,11 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
}
let mut imported = 0;
let existing = state.db.get_all_providers("opencode")?;
let existing_ids = state.db.get_provider_ids("opencode")?;
for (id, config) in providers {
// Skip if already exists in database
if existing.contains_key(&id) {
if existing_ids.contains(&id) {
log::debug!("OpenCode provider '{id}' already exists in database, skipping");
continue;
}
@@ -1265,7 +1266,7 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
}
let mut imported = 0;
let existing = state.db.get_all_providers("openclaw")?;
let existing_ids = state.db.get_provider_ids("openclaw")?;
for (id, config) in providers {
// Validate: skip entries with empty id or no models
@@ -1279,7 +1280,7 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
}
// Skip if already exists in database
if existing.contains_key(&id) {
if existing_ids.contains(&id) {
log::debug!("OpenClaw provider '{id}' already exists in database, skipping");
continue;
}
+634
View File
@@ -0,0 +1,634 @@
//! Claude Code 会话日志使用追踪
//!
//! 从 ~/.claude/projects/ 下的 JSONL 会话文件中提取 token 使用数据,
//! 实现无代理模式下的使用统计。
//!
//! ## 数据流
//! ```text
//! ~/.claude/projects/*/*.jsonl → 增量解析 → 去重 → 费用计算 → proxy_request_logs 表
//! ```
use crate::config::get_claude_config_dir;
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::proxy::usage::calculator::{CostCalculator, ModelPricing};
use crate::proxy::usage::parser::TokenUsage;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
/// 同步结果
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSyncResult {
pub imported: u32,
pub skipped: u32,
pub files_scanned: u32,
pub errors: Vec<String>,
}
/// 数据来源分布
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DataSourceSummary {
pub data_source: String,
pub request_count: u32,
pub total_cost_usd: String,
}
/// 从 JSONL 中解析出的 assistant 消息使用数据
#[derive(Debug)]
struct ParsedAssistantUsage {
message_id: String,
model: String,
input_tokens: u32,
output_tokens: u32,
cache_read_tokens: u32,
cache_creation_tokens: u32,
stop_reason: Option<String>,
timestamp: Option<String>,
session_id: Option<String>,
}
/// 同步 Claude Code 会话日志到使用统计数据库
pub fn sync_claude_session_logs(db: &Database) -> Result<SessionSyncResult, AppError> {
let projects_dir = get_claude_config_dir().join("projects");
if !projects_dir.exists() {
return Ok(SessionSyncResult {
imported: 0,
skipped: 0,
files_scanned: 0,
errors: vec![],
});
}
let mut result = SessionSyncResult {
imported: 0,
skipped: 0,
files_scanned: 0,
errors: vec![],
};
// 收集所有 .jsonl 文件
let jsonl_files = collect_jsonl_files(&projects_dir);
for file_path in &jsonl_files {
result.files_scanned += 1;
match sync_single_file(db, file_path) {
Ok((imported, skipped)) => {
result.imported += imported;
result.skipped += skipped;
}
Err(e) => {
let msg = format!("{}: {e}", file_path.display());
log::warn!("[SESSION-SYNC] 文件解析失败: {msg}");
result.errors.push(msg);
}
}
}
if result.imported > 0 {
log::info!(
"[SESSION-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, 扫描 {} 个文件",
result.imported,
result.skipped,
result.files_scanned
);
}
Ok(result)
}
/// 收集目录下所有 .jsonl 文件
fn collect_jsonl_files(projects_dir: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let entries = match fs::read_dir(projects_dir) {
Ok(e) => e,
Err(_) => return files,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
// 每个项目目录下的 .jsonl 文件
if let Ok(sub_entries) = fs::read_dir(&path) {
for sub_entry in sub_entries.flatten() {
let sub_path = sub_entry.path();
if sub_path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
files.push(sub_path);
}
}
}
}
files
}
/// 同步单个 JSONL 文件,返回 (imported, skipped)
fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppError> {
let file_path_str = file_path.to_string_lossy().to_string();
// 获取文件元数据
let metadata = fs::metadata(file_path)
.map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?;
let file_modified = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
// 检查同步状态
let (last_modified, last_offset) = get_sync_state(db, &file_path_str)?;
// 文件未变化则跳过
if file_modified <= last_modified {
return Ok((0, 0));
}
// 从上次偏移位置开始增量解析
let file =
fs::File::open(file_path).map_err(|e| AppError::Config(format!("无法打开文件: {e}")))?;
let reader = BufReader::new(file);
let mut line_offset: i64 = 0;
let mut messages: HashMap<String, ParsedAssistantUsage> = HashMap::new();
let mut current_session_id: Option<String> = None;
for line_result in reader.lines() {
line_offset += 1;
// 跳过已处理的行
if line_offset <= last_offset {
continue;
}
let line = match line_result {
Ok(l) => l,
Err(_) => continue, // 容忍不完整的最后一行
};
if line.trim().is_empty() {
continue;
}
let value: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
// 提取 session ID (从 system 或首条消息)
if current_session_id.is_none() {
if let Some(sid) = value.get("sessionId").and_then(|v| v.as_str()) {
current_session_id = Some(sid.to_string());
}
}
// 只处理 assistant 类型的消息
if value.get("type").and_then(|t| t.as_str()) != Some("assistant") {
continue;
}
let message = match value.get("message") {
Some(m) => m,
None => continue,
};
let msg_id = match message.get("id").and_then(|v| v.as_str()) {
Some(id) => id.to_string(),
None => continue,
};
let usage = match message.get("usage") {
Some(u) => u,
None => continue,
};
let parsed = ParsedAssistantUsage {
message_id: msg_id.clone(),
model: message
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
input_tokens: usage
.get("input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
output_tokens: usage
.get("output_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_read_tokens: usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_tokens: usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
stop_reason: message
.get("stop_reason")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
timestamp: value
.get("timestamp")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
session_id: current_session_id.clone(),
};
// 按 message.id 去重:优先保留有 stop_reason 的条目,否则保留最新的
let should_replace = match messages.get(&msg_id) {
None => true,
Some(existing) => {
// 新条目有 stop_reason 而旧条目没有 → 替换
if parsed.stop_reason.is_some() && existing.stop_reason.is_none() {
true
}
// 两个都有或都没有 stop_reason → 取 output_tokens 更大的
else if parsed.stop_reason.is_some() == existing.stop_reason.is_some() {
parsed.output_tokens > existing.output_tokens
} else {
false
}
}
};
if should_replace {
messages.insert(msg_id, parsed);
}
}
// 写入数据库
let mut imported: u32 = 0;
let mut skipped: u32 = 0;
for msg in messages.values() {
// 只导入有 stop_reason 的最终条目(完整的 API 调用)
if msg.stop_reason.is_none() {
continue;
}
let request_id = format!("session:{}", msg.message_id);
// 跳过 output_tokens 为 0 的无意义条目
if msg.output_tokens == 0 {
continue;
}
match insert_session_log_entry(db, &request_id, msg) {
Ok(true) => imported += 1,
Ok(false) => skipped += 1,
Err(e) => {
log::warn!("[SESSION-SYNC] 插入失败 ({}): {e}", msg.message_id);
skipped += 1;
}
}
}
// 更新同步状态
update_sync_state(db, &file_path_str, file_modified, line_offset)?;
Ok((imported, skipped))
}
/// 获取文件的同步状态
fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> {
let conn = lock_conn!(db.conn);
let result = conn.query_row(
"SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1",
rusqlite::params![file_path],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
);
Ok(result.unwrap_or((0, 0)))
}
/// 更新文件的同步状态
fn update_sync_state(
db: &Database,
file_path: &str,
last_modified: i64,
last_offset: i64,
) -> Result<(), AppError> {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT OR REPLACE INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![file_path, last_modified, last_offset, now],
)
.map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?;
Ok(())
}
/// 插入单条会话日志到 proxy_request_logs,返回是否成功插入 (true=新插入, false=已存在)
fn insert_session_log_entry(
db: &Database,
request_id: &str,
msg: &ParsedAssistantUsage,
) -> Result<bool, AppError> {
let conn = lock_conn!(db.conn);
// 检查是否已存在
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1",
rusqlite::params![request_id],
|row| row.get::<_, i64>(0).map(|c| c > 0),
)
.unwrap_or(false);
if exists {
return Ok(false);
}
// 解析时间戳
let created_at = msg
.timestamp
.as_ref()
.and_then(|ts| {
// 尝试解析 ISO 8601 时间戳
chrono::DateTime::parse_from_rfc3339(ts)
.ok()
.map(|dt| dt.timestamp())
})
.unwrap_or_else(|| {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
});
// 计算费用
let usage = TokenUsage {
input_tokens: msg.input_tokens,
output_tokens: msg.output_tokens,
cache_read_tokens: msg.cache_read_tokens,
cache_creation_tokens: msg.cache_creation_tokens,
model: Some(msg.model.clone()),
};
let pricing = find_model_pricing_for_session(&conn, &msg.model);
let multiplier = Decimal::from(1);
let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = match pricing
{
Some(p) => {
let cost = CostCalculator::calculate(&usage, &p, multiplier);
(
cost.input_cost.to_string(),
cost.output_cost.to_string(),
cost.cache_read_cost.to_string(),
cost.cache_creation_cost.to_string(),
cost.total_cost.to_string(),
)
}
None => (
"0".to_string(),
"0".to_string(),
"0".to_string(),
"0".to_string(),
"0".to_string(),
),
};
conn.execute(
"INSERT OR IGNORE INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
latency_ms, first_token_ms, status_code, error_message, session_id,
provider_type, is_streaming, cost_multiplier, created_at, data_source
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)",
rusqlite::params![
request_id,
"_session", // provider_id: 标记为会话来源
"claude", // app_type
msg.model,
msg.model, // request_model = model
msg.input_tokens,
msg.output_tokens,
msg.cache_read_tokens,
msg.cache_creation_tokens,
input_cost,
output_cost,
cache_read_cost,
cache_creation_cost,
total_cost,
0i64, // latency_ms: 会话日志无此数据
Option::<i64>::None, // first_token_ms
200i64, // status_code: 有 stop_reason 说明请求成功
Option::<String>::None, // error_message
msg.session_id,
Some("session_log"), // provider_type
1i64, // is_streaming: Claude Code 通常使用流式
"1.0", // cost_multiplier
created_at,
"session_log", // data_source
],
)
.map_err(|e| AppError::Database(format!("插入会话日志失败: {e}")))?;
Ok(true)
}
/// 从 model_pricing 表查找模型定价(支持模糊匹配)
fn find_model_pricing_for_session(
conn: &rusqlite::Connection,
model_id: &str,
) -> Option<ModelPricing> {
// 精确匹配
if let Ok(Some(pricing)) = try_find_pricing(conn, model_id) {
return Some(pricing);
}
// 模糊匹配:去掉日期后缀
// 例如 "claude-opus-4-6-20260206" -> "claude-opus-4-6"
let parts: Vec<&str> = model_id.rsplitn(2, '-').collect();
if parts.len() == 2 {
if let Some(suffix) = parts.first() {
if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) {
if let Ok(Some(pricing)) = try_find_pricing(conn, parts[1]) {
return Some(pricing);
}
}
}
}
// 尝试 LIKE 匹配
let pattern = format!("{model_id}%");
let result = conn.query_row(
"SELECT input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing WHERE model_id LIKE ?1 LIMIT 1",
rusqlite::params![pattern],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
);
match result {
Ok((input, output, cache_read, cache_creation)) => {
ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation).ok()
}
Err(_) => None,
}
}
fn try_find_pricing(
conn: &rusqlite::Connection,
model_id: &str,
) -> Result<Option<ModelPricing>, AppError> {
let result = conn.query_row(
"SELECT input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing WHERE model_id = ?1",
rusqlite::params![model_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
);
match result {
Ok((input, output, cache_read, cache_creation)) => {
ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation)
.map(Some)
.map_err(|e| AppError::Database(format!("解析定价失败: {e}")))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(format!("查询定价失败: {e}"))),
}
}
/// 查询数据来源分布统计
pub fn get_data_source_breakdown(db: &Database) -> Result<Vec<DataSourceSummary>, AppError> {
let conn = lock_conn!(db.conn);
let mut stmt = conn.prepare(
"SELECT COALESCE(data_source, 'proxy') as ds, COUNT(*) as cnt,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as cost
FROM proxy_request_logs
GROUP BY ds
ORDER BY cnt DESC",
)?;
let rows = stmt.query_map([], |row| {
Ok(DataSourceSummary {
data_source: row.get(0)?,
request_count: row.get::<_, i64>(1)? as u32,
total_cost_usd: format!("{:.6}", row.get::<_, f64>(2)?),
})
})?;
let mut summaries = Vec::new();
for row in rows {
summaries.push(row.map_err(|e| AppError::Database(e.to_string()))?);
}
Ok(summaries)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_usage_from_jsonl_line() {
let line = r#"{"type":"assistant","message":{"id":"msg_test123","model":"claude-opus-4-6","usage":{"input_tokens":3,"output_tokens":150,"cache_read_input_tokens":5000,"cache_creation_input_tokens":10000},"stop_reason":"end_turn"},"timestamp":"2026-04-05T12:00:00Z","sessionId":"session-abc"}"#;
let value: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(
value.get("type").and_then(|t| t.as_str()),
Some("assistant")
);
let message = value.get("message").unwrap();
let usage = message.get("usage").unwrap();
assert_eq!(usage.get("input_tokens").unwrap().as_u64().unwrap(), 3);
assert_eq!(usage.get("output_tokens").unwrap().as_u64().unwrap(), 150);
assert_eq!(
usage
.get("cache_read_input_tokens")
.unwrap()
.as_u64()
.unwrap(),
5000
);
assert_eq!(
usage
.get("cache_creation_input_tokens")
.unwrap()
.as_u64()
.unwrap(),
10000
);
assert_eq!(
message.get("stop_reason").unwrap().as_str().unwrap(),
"end_turn"
);
}
#[test]
fn test_dedup_by_message_id() {
// 同一个 message.id 有多条,应该取 stop_reason 有值的那条
let mut messages: HashMap<String, ParsedAssistantUsage> = HashMap::new();
// 中间条目(无 stop_reason
let intermediate = ParsedAssistantUsage {
message_id: "msg_1".to_string(),
model: "claude-opus-4-6".to_string(),
input_tokens: 3,
output_tokens: 26,
cache_read_tokens: 5000,
cache_creation_tokens: 10000,
stop_reason: None,
timestamp: Some("2026-04-05T12:00:00Z".to_string()),
session_id: None,
};
messages.insert("msg_1".to_string(), intermediate);
// 最终条目(有 stop_reason
let final_entry = ParsedAssistantUsage {
message_id: "msg_1".to_string(),
model: "claude-opus-4-6".to_string(),
input_tokens: 3,
output_tokens: 1349,
cache_read_tokens: 5000,
cache_creation_tokens: 10000,
stop_reason: Some("end_turn".to_string()),
timestamp: Some("2026-04-05T12:00:00Z".to_string()),
session_id: None,
};
// 应该替换
let should_replace = final_entry.stop_reason.is_some()
&& messages.get("msg_1").unwrap().stop_reason.is_none();
assert!(should_replace);
messages.insert("msg_1".to_string(), final_entry);
assert_eq!(messages.get("msg_1").unwrap().output_tokens, 1349);
}
}
@@ -0,0 +1,814 @@
//! Codex 会话日志使用追踪
//!
//! 从 ~/.codex/sessions/ 下的 JSONL 会话文件中提取精确 token 使用数据,
//! 替代原有的 state_5.sqlite 估算方案。
//!
//! ## 数据流
//! ```text
//! ~/.codex/sessions/YYYY/MM/DD/*.jsonl → 增量解析 → delta 计算 → 费用计算 → proxy_request_logs 表
//! ```
//!
//! ## 解析的事件类型
//! - `session_meta` → 提取 session_id
//! - `turn_context` → 提取当前 model
//! - `event_msg` (type=token_count) → 提取累计 token 用量,计算 delta
use crate::codex_config::get_codex_config_dir;
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::proxy::usage::calculator::{CostCalculator, ModelPricing};
use crate::proxy::usage::parser::TokenUsage;
use crate::services::session_usage::SessionSyncResult;
use rust_decimal::Decimal;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
/// 累计 token 用量(跟踪 total_token_usage 字段)
#[derive(Debug, Clone, Default)]
struct CumulativeTokens {
input: u64,
cached_input: u64,
output: u64,
}
/// 单次 API 调用的 token 增量
#[derive(Debug)]
struct DeltaTokens {
input: u32,
cached_input: u32,
output: u32,
}
impl DeltaTokens {
fn is_zero(&self) -> bool {
self.input == 0 && self.cached_input == 0 && self.output == 0
}
}
/// 单文件解析时的运行状态
struct FileParseState {
session_id: Option<String>,
current_model: String,
prev_total: Option<CumulativeTokens>,
event_index: u32,
}
/// 归一化 Codex 模型名
///
/// 处理规则(按顺序):
/// 1. 转小写:`GLM-4.6` → `glm-4.6`
/// 2. 剥离 provider 前缀:`openai/gpt-5.4` → `gpt-5.4`
/// 3. 剥离 ISO 日期后缀:`gpt-5.4-2026-03-05` → `gpt-5.4`
/// 4. 剥离紧凑日期后缀:`gpt-5.4-20260305` → `gpt-5.4`
fn normalize_codex_model(raw: &str) -> String {
// Step 1: 小写
let mut name = raw.to_lowercase();
// Step 2: 剥离 "provider/" 前缀(如 openai/, azure/
if let Some(pos) = name.rfind('/') {
name = name[pos + 1..].to_string();
}
// Step 3: 剥离 ISO 日期后缀 -YYYY-MM-DD(正好 11 字符)
if name.len() > 11 {
let suffix = &name[name.len() - 11..];
if suffix.as_bytes()[0] == b'-'
&& suffix[1..5].chars().all(|c| c.is_ascii_digit())
&& suffix.as_bytes()[5] == b'-'
&& suffix[6..8].chars().all(|c| c.is_ascii_digit())
&& suffix.as_bytes()[8] == b'-'
&& suffix[9..11].chars().all(|c| c.is_ascii_digit())
{
name.truncate(name.len() - 11);
}
}
// Step 4: 剥离紧凑日期后缀 -YYYYMMDD(正好 9 字符)
if name.len() > 9 {
let parts: Vec<&str> = name.rsplitn(2, '-').collect();
if parts.len() == 2 {
if let Some(suffix) = parts.first() {
if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) {
name = parts[1].to_string();
}
}
}
}
name
}
/// 计算两次累计值之间的 delta
fn compute_delta(prev: &Option<CumulativeTokens>, current: &CumulativeTokens) -> DeltaTokens {
match prev {
None => DeltaTokens {
input: current.input as u32,
cached_input: current.cached_input as u32,
output: current.output as u32,
},
Some(p) => DeltaTokens {
input: current.input.saturating_sub(p.input) as u32,
cached_input: current.cached_input.saturating_sub(p.cached_input) as u32,
output: current.output.saturating_sub(p.output) as u32,
},
}
}
/// 从 JSON Value 中提取累计 token 用量
fn parse_cumulative_tokens(total_usage: &serde_json::Value) -> Option<CumulativeTokens> {
if total_usage.is_null() || !total_usage.is_object() {
return None;
}
Some(CumulativeTokens {
input: total_usage
.get("input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0),
cached_input: total_usage
.get("cached_input_tokens")
.or_else(|| total_usage.get("cache_read_input_tokens"))
.and_then(|v| v.as_u64())
.unwrap_or(0),
output: total_usage
.get("output_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0),
})
}
/// 同步 Codex 使用数据(从 JSONL 会话日志)
pub fn sync_codex_usage(db: &Database) -> Result<SessionSyncResult, AppError> {
let codex_dir = get_codex_config_dir();
let files = collect_codex_session_files(&codex_dir);
let mut result = SessionSyncResult {
imported: 0,
skipped: 0,
files_scanned: files.len() as u32,
errors: vec![],
};
if files.is_empty() {
return Ok(result);
}
for file_path in &files {
match sync_single_codex_file(db, file_path) {
Ok((imported, skipped)) => {
result.imported += imported;
result.skipped += skipped;
}
Err(e) => {
let msg = format!("Codex 会话文件解析失败 {}: {e}", file_path.display());
log::warn!("[CODEX-SYNC] {msg}");
result.errors.push(msg);
}
}
}
if result.imported > 0 {
log::info!(
"[CODEX-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, 描 {} 个文件",
result.imported,
result.skipped,
result.files_scanned
);
}
Ok(result)
}
/// 收集所有 Codex 会话 JSONL 文件
fn collect_codex_session_files(codex_dir: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
// 1. 扫描 sessions/YYYY/MM/DD/*.jsonl(日期分区目录
let sessions_dir = codex_dir.join("sessions");
if sessions_dir.is_dir() {
collect_jsonl_recursive(&sessions_dir, &mut files, 0, 3);
}
// 2. 扫描 archived_sessions/*.jsonl(扁平归档目录)
let archived_dir = codex_dir.join("archived_sessions");
if archived_dir.is_dir() {
if let Ok(entries) = fs::read_dir(&archived_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
files.push(path);
}
}
}
}
files
}
/// 递归扫描目录下的 .jsonl 文件(限制最大深度)
fn collect_jsonl_recursive(dir: &Path, files: &mut Vec<PathBuf>, depth: u32, max_depth: u32) {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() && depth < max_depth {
collect_jsonl_recursive(&path, files, depth + 1, max_depth);
} else if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
files.push(path);
}
}
}
/// 同步单 Codex JSONL 文件,返回 (imported, skipped)
fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppError> {
let file_path_str = file_path.to_string_lossy().to_string();
// 获取文件元数据
let metadata = fs::metadata(file_path)
.map_err(|e| AppError::Config(format!("无法读取文元数据: {e}")))?;
let file_modified = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
// 检查同步状态
let (last_modified, last_offset) = get_sync_state(db, &file_path_str)?;
// 文件未变化则跳过
if file_modified <= last_modified {
return Ok((0, 0));
}
// 打开文件逐行解析
let file =
fs::File::open(file_path).map_err(|e| AppError::Config(format!("无法打开文件: {e}")))?;
let reader = BufReader::new(file);
let mut state = FileParseState {
session_id: None,
current_model: "unknown".to_string(),
prev_total: None,
event_index: 0,
};
let mut line_offset: i64 = 0;
let mut imported: u32 = 0;
let mut skipped: u32 = 0;
for line_result in reader.lines() {
line_offset += 1;
let line = match line_result {
Ok(l) => l,
Err(_) => continue, // 容忍不完整的最后一行
};
if line.trim().is_empty() {
continue;
}
// 快速过滤:在 JSON 反序列化前跳过无关行
let is_event_msg = line.contains("\"event_msg\"");
let is_turn_context = line.contains("\"turn_context\"");
let is_session_meta = line.contains("\"session_meta\"");
if !is_event_msg && !is_turn_context && !is_session_meta {
continue;
}
if is_event_msg && !line.contains("\"token_count\"") {
continue;
}
let value: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
let event_type = match value.get("type").and_then(|t| t.as_str()) {
Some(t) => t,
None => continue,
};
match event_type {
"session_meta" => {
if state.session_id.is_none() {
let payload = value.get("payload");
state.session_id = payload
.and_then(|p| {
p.get("session_id")
.or_else(|| p.get("sessionId"))
.or_else(|| p.get("id"))
})
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
}
"turn_context" => {
if let Some(payload) = value.get("payload") {
// model 可能在 payload.model 或 payload.info.model
if let Some(model) = payload
.get("model")
.or_else(|| payload.get("info").and_then(|info| info.get("model")))
.and_then(|v| v.as_str())
{
state.current_model = normalize_codex_model(model);
}
}
}
"event_msg" => {
let payload = match value.get("payload") {
Some(p) => p,
None => continue,
};
// 只处理 token_count 类型
if payload.get("type").and_then(|t| t.as_str()) != Some("token_count") {
continue;
}
let info = match payload.get("info") {
Some(i) if !i.is_null() => i,
_ => continue, // info 为 null 的首个事件跳
};
// 提取模型(token_count 事件也可能携带 model
if let Some(model) = info
.get("model")
.or_else(|| info.get("model_name"))
.or_else(|| payload.get("model"))
.and_then(|v| v.as_str())
{
state.current_model = normalize_codex_model(model);
}
// 优先用 total_token_usage(累计值),fallback 到 last_token_usage(增量值)
let (cumulative, is_total) = if let Some(total) = info.get("total_token_usage") {
(parse_cumulative_tokens(total), true)
} else if let Some(last) = info.get("last_token_usage") {
(parse_cumulative_tokens(last), false)
} else {
continue;
};
let cumulative = match cumulative {
Some(c) => c,
None => continue,
};
let delta = if is_total {
// 累计值模式:计算与上次的 delta
let d = compute_delta(&state.prev_total, &cumulative);
state.prev_total = Some(cumulative);
d
} else {
// 增量值模式:直接使用 last_token_usage 的值
DeltaTokens {
input: cumulative.input as u32,
cached_input: cumulative.cached_input as u32,
output: cumulative.output as u32,
}
};
// 钳制:cached 不应超过 input(防护异常数据)
let delta = DeltaTokens {
cached_input: delta.cached_input.min(delta.input),
..delta
};
if delta.is_zero() {
continue; // 跳过 task 边界的零 delta 事件
}
state.event_index += 1;
// 跳过已处理的行(但仍需解析以恢复状态)
if line_offset <= last_offset {
continue;
}
// 生成唯一 request_id
let session_id_str = state.session_id.as_deref().unwrap_or("unknown");
let request_id = format!("codex_session:{}:{}", session_id_str, state.event_index);
// 提取时间戳
let timestamp = value
.get("timestamp")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
match insert_codex_session_entry(
db,
&request_id,
&delta,
&state.current_model,
state.session_id.as_deref(),
timestamp.as_deref(),
) {
Ok(true) => imported += 1,
Ok(false) => skipped += 1,
Err(e) => {
log::warn!("[CODEX-SYNC] 插入失败 ({}): {e}", request_id);
skipped += 1;
}
}
}
_ => {}
}
}
// 更新同步状态
update_sync_state(db, &file_path_str, file_modified, line_offset)?;
Ok((imported, skipped))
}
/// 插入单条 Codex 会话记录到 proxy_request_logs
fn insert_codex_session_entry(
db: &Database,
request_id: &str,
delta: &DeltaTokens,
model: &str,
session_id: Option<&str>,
timestamp: Option<&str>,
) -> Result<bool, AppError> {
let conn = lock_conn!(db.conn);
// 检查是否已存在
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1",
rusqlite::params![request_id],
|row| row.get::<_, i64>(0).map(|c| c > 0),
)
.unwrap_or(false);
if exists {
return Ok(false);
}
// 解析时间戳
let created_at = timestamp
.and_then(|ts| {
chrono::DateTime::parse_from_rfc3339(ts)
.ok()
.map(|dt| dt.timestamp())
})
.unwrap_or_else(|| {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
});
// 计算费用
let usage = TokenUsage {
input_tokens: delta.input,
output_tokens: delta.output,
cache_read_tokens: delta.cached_input,
cache_creation_tokens: 0,
model: Some(model.to_string()),
};
let pricing = find_codex_pricing(&conn, model);
let multiplier = Decimal::from(1);
let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = match pricing
{
Some(p) => {
let cost = CostCalculator::calculate(&usage, &p, multiplier);
(
cost.input_cost.to_string(),
cost.output_cost.to_string(),
cost.cache_read_cost.to_string(),
cost.cache_creation_cost.to_string(),
cost.total_cost.to_string(),
)
}
None => (
"0".to_string(),
"0".to_string(),
"0".to_string(),
"0".to_string(),
"0".to_string(),
),
};
conn.execute(
"INSERT OR IGNORE INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
latency_ms, first_token_ms, status_code, error_message, session_id,
provider_type, is_streaming, cost_multiplier, created_at, data_source
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)",
rusqlite::params![
request_id,
"_codex_session", // provider_id
"codex", // app_type
model,
model, // request_model = model
delta.input,
delta.output,
delta.cached_input,
0i64, // cache_creation_tokens: Codex 日志无此数据
input_cost,
output_cost,
cache_read_cost,
cache_creation_cost,
total_cost,
0i64, // latency_ms
Option::<i64>::None, // first_token_ms
200i64, // status_code
Option::<String>::None, // error_message
session_id.map(|s| s.to_string()),
Some("codex_session"), // provider_type
1i64, // is_streaming
"1.0", // cost_multiplier
created_at,
"codex_session", // data_source
],
)
.map_err(|e| AppError::Database(format!("插入 Codex 会话日志失败: {e}")))?;
Ok(true)
}
/// 获取文件的同步状态
fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> {
let conn = lock_conn!(db.conn);
let result = conn.query_row(
"SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1",
rusqlite::params![file_path],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
);
Ok(result.unwrap_or((0, 0)))
}
/// 更新文件的同步状态
fn update_sync_state(
db: &Database,
file_path: &str,
last_modified: i64,
last_offset: i64,
) -> Result<(), AppError> {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT OR REPLACE INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![file_path, last_modified, last_offset, now],
)
.map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?;
Ok(())
}
/// 找 Codex 模型定价(带归一化)
fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
let normalized = normalize_codex_model(model_id);
// 1. 精确匹配(归一化后的名称)
if let Some(pricing) = try_find_pricing(conn, &normalized) {
return Some(pricing);
}
// 2. LIKE 模糊匹配(兜底)
let pattern = format!("{normalized}%");
conn.query_row(
"SELECT input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing WHERE model_id LIKE ?1 LIMIT 1",
rusqlite::params![pattern],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
)
.ok()
.and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok())
}
/// 精确匹配定价查询
fn try_find_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
conn.query_row(
"SELECT input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing WHERE model_id = ?1",
rusqlite::params![model_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
)
.ok()
.and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_delta_first_event() {
let prev = None;
let current = CumulativeTokens {
input: 17934,
cached_input: 9600,
output: 454,
};
let delta = compute_delta(&prev, &current);
assert_eq!(delta.input, 17934);
assert_eq!(delta.cached_input, 9600);
assert_eq!(delta.output, 454);
assert!(!delta.is_zero());
}
#[test]
fn test_delta_subsequent_event() {
let prev = Some(CumulativeTokens {
input: 17934,
cached_input: 9600,
output: 454,
});
let current = CumulativeTokens {
input: 36722,
cached_input: 27904,
output: 804,
};
let delta = compute_delta(&prev, &current);
assert_eq!(delta.input, 36722 - 17934);
assert_eq!(delta.cached_input, 27904 - 9600);
assert_eq!(delta.output, 804 - 454);
}
#[test]
fn test_delta_zero_at_task_boundary() {
let prev = Some(CumulativeTokens {
input: 58346,
cached_input: 46976,
output: 1045,
});
// task 边界:相同的累计值
let current = CumulativeTokens {
input: 58346,
cached_input: 46976,
output: 1045,
};
let delta = compute_delta(&prev, &current);
assert!(delta.is_zero());
}
#[test]
fn test_delta_saturating_sub() {
// 异常情况:当前值小于前值(不应发生,但需防护)
let prev = Some(CumulativeTokens {
input: 100,
cached_input: 50,
output: 30,
});
let current = CumulativeTokens {
input: 80,
cached_input: 40,
output: 20,
};
let delta = compute_delta(&prev, &current);
assert_eq!(delta.input, 0);
assert_eq!(delta.cached_input, 0);
assert_eq!(delta.output, 0);
assert!(delta.is_zero());
}
#[test]
fn test_parse_cumulative_tokens_valid() {
let json: serde_json::Value = serde_json::json!({
"input_tokens": 17934,
"cached_input_tokens": 9600,
"output_tokens": 454,
"reasoning_output_tokens": 233,
"total_tokens": 18388
});
let tokens = parse_cumulative_tokens(&json).unwrap();
assert_eq!(tokens.input, 17934);
assert_eq!(tokens.cached_input, 9600);
assert_eq!(tokens.output, 454);
}
#[test]
fn test_parse_cumulative_tokens_null() {
let json = serde_json::Value::Null;
assert!(parse_cumulative_tokens(&json).is_none());
}
#[test]
fn test_parse_cumulative_tokens_alt_field_names() {
// 某些版本可能使用 cache_read_input_tokens 而非 cached_input_tokens
let json: serde_json::Value = serde_json::json!({
"input_tokens": 1000,
"cache_read_input_tokens": 500,
"output_tokens": 200
});
let tokens = parse_cumulative_tokens(&json).unwrap();
assert_eq!(tokens.cached_input, 500);
}
#[test]
fn test_collect_codex_session_files_nonexistent() {
let files = collect_codex_session_files(Path::new("/nonexistent/path"));
assert!(files.is_empty());
}
// ── 模型名归一化测试 ──
#[test]
fn test_normalize_codex_model_lowercase() {
assert_eq!(normalize_codex_model("GLM-4.6"), "glm-4.6");
assert_eq!(normalize_codex_model("DeepSeek-Chat"), "deepseek-chat");
assert_eq!(normalize_codex_model("GPT-5.4"), "gpt-5.4");
}
#[test]
fn test_normalize_codex_model_strip_prefix() {
assert_eq!(normalize_codex_model("openai/gpt-5.4"), "gpt-5.4");
assert_eq!(
normalize_codex_model("azure/gpt-5.2-codex"),
"gpt-5.2-codex"
);
assert_eq!(normalize_codex_model("OPENAI/GPT-5.4"), "gpt-5.4");
}
#[test]
fn test_normalize_codex_model_strip_iso_date() {
assert_eq!(normalize_codex_model("gpt-5.4-2026-03-05"), "gpt-5.4");
assert_eq!(
normalize_codex_model("gpt-5.4-pro-2026-03-05"),
"gpt-5.4-pro"
);
}
#[test]
fn test_normalize_codex_model_strip_compact_date() {
assert_eq!(normalize_codex_model("gpt-5.4-20260305"), "gpt-5.4");
assert_eq!(
normalize_codex_model("claude-opus-4-6-20260206"),
"claude-opus-4-6"
);
}
#[test]
fn test_normalize_codex_model_no_change() {
assert_eq!(normalize_codex_model("gpt-5.4"), "gpt-5.4");
assert_eq!(normalize_codex_model("gpt-5.2-codex"), "gpt-5.2-codex");
assert_eq!(normalize_codex_model("o3"), "o3");
assert_eq!(normalize_codex_model("deepseek-chat"), "deepseek-chat");
}
#[test]
fn test_normalize_codex_model_combined() {
// prefix + uppercase + ISO date
assert_eq!(
normalize_codex_model("openai/GPT-5.4-2026-03-05"),
"gpt-5.4"
);
// prefix + compact date
assert_eq!(normalize_codex_model("openai/gpt-5.4-20260305"), "gpt-5.4");
}
#[test]
fn test_cached_clamped_to_input() {
// cached > input 的异常场景应被 min() 钳制
let prev = Some(CumulativeTokens {
input: 100,
cached_input: 0,
output: 50,
});
let current = CumulativeTokens {
input: 110, // delta = 10
cached_input: 80, // delta = 80(异常:大于 input delta
output: 60,
};
let delta = compute_delta(&prev, &current);
// 钳制前:cached_input = 80, input = 10
assert_eq!(delta.cached_input, 80);
assert_eq!(delta.input, 10);
// 实际钳制在调用侧:delta.cached_input.min(delta.input)
let clamped = delta.cached_input.min(delta.input);
assert_eq!(clamped, 10);
}
}
@@ -0,0 +1,503 @@
//! Gemini CLI 会话日志使用追踪
//!
//! 从 ~/.gemini/tmp/<project_hash>/chats/session-*.json 中提取精确 token 使用数据。
//!
//! ## 数据流
//! ```text
//! ~/.gemini/tmp/*/chats/session-*.json → 全量解析 → 费用计算 → proxy_request_logs 表
//! ```
//!
//! ## 与 Claude/Codex 解析器的差异
//! - JSON 格式(非 JSONL):每个文件是单个 JSON 对象,包含 messages 数组
//! - 无需 delta 计算:tokens 字段是 per-message 独立值
//! - 无需状态恢复:不依赖前一条消息的累计值
//! - 天然去重:每条消息有唯一 id 字段
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::gemini_config::get_gemini_dir;
use crate::proxy::usage::calculator::{CostCalculator, ModelPricing};
use crate::proxy::usage::parser::TokenUsage;
use crate::services::session_usage::SessionSyncResult;
use rust_decimal::Decimal;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
/// 从 Gemini message 中提取的 token 数据
#[derive(Debug)]
struct GeminiTokens {
input: u32,
output: u32,
cached: u32,
thoughts: u32,
}
/// 同步 Gemini 使用数据(从 JSON 会话日志)
pub fn sync_gemini_usage(db: &Database) -> Result<SessionSyncResult, AppError> {
let gemini_dir = get_gemini_dir();
let files = collect_gemini_session_files(&gemini_dir);
let mut result = SessionSyncResult {
imported: 0,
skipped: 0,
files_scanned: files.len() as u32,
errors: vec![],
};
if files.is_empty() {
return Ok(result);
}
for file_path in &files {
match sync_single_gemini_file(db, file_path) {
Ok((imported, skipped)) => {
result.imported += imported;
result.skipped += skipped;
}
Err(e) => {
let msg = format!("Gemini 会话文件解析失败 {}: {e}", file_path.display());
log::warn!("[GEMINI-SYNC] {msg}");
result.errors.push(msg);
}
}
}
if result.imported > 0 {
log::info!(
"[GEMINI-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, 扫描 {} 个文件",
result.imported,
result.skipped,
result.files_scanned
);
}
Ok(result)
}
/// 收集所有 Gemini 会话 JSON 文件
fn collect_gemini_session_files(gemini_dir: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let tmp_dir = gemini_dir.join("tmp");
if !tmp_dir.is_dir() {
return files;
}
// 遍历 tmp/<project_hash>/chats/session-*.json
let project_dirs = match fs::read_dir(&tmp_dir) {
Ok(entries) => entries,
Err(_) => return files,
};
for entry in project_dirs.flatten() {
let chats_dir = entry.path().join("chats");
if !chats_dir.is_dir() {
continue;
}
let chat_files = match fs::read_dir(&chats_dir) {
Ok(entries) => entries,
Err(_) => continue,
};
for file_entry in chat_files.flatten() {
let path = file_entry.path();
let is_session = path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("session-") && n.ends_with(".json"))
.unwrap_or(false);
if is_session {
files.push(path);
}
}
}
files
}
/// 同步单个 Gemini 会话 JSON 文件,返回 (imported, skipped)
fn sync_single_gemini_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppError> {
let file_path_str = file_path.to_string_lossy().to_string();
// 获取文件元数据
let metadata = fs::metadata(file_path)
.map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?;
let file_modified = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
// 检查同步状态
let (last_modified, _last_offset) = get_sync_state(db, &file_path_str)?;
// 文件未变化则跳过
if file_modified <= last_modified {
return Ok((0, 0));
}
// 读取并解析整个 JSON 文件
let content = fs::read_to_string(file_path)
.map_err(|e| AppError::Config(format!("无法读取文件: {e}")))?;
let value: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| AppError::Config(format!("JSON 解析失败: {e}")))?;
// 提取顶层 sessionId
let session_id = value
.get("sessionId")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// 遍历 messages 数组
let messages = match value.get("messages").and_then(|v| v.as_array()) {
Some(msgs) => msgs,
None => return Ok((0, 0)),
};
let mut imported: u32 = 0;
let mut skipped: u32 = 0;
let mut gemini_msg_count: i64 = 0;
for msg in messages {
// 只处理 type == "gemini" 的消息
if msg.get("type").and_then(|t| t.as_str()) != Some("gemini") {
continue;
}
// 提取 tokens 对象
let tokens_obj = match msg.get("tokens") {
Some(t) if t.is_object() => t,
_ => continue,
};
let tokens = parse_gemini_tokens(tokens_obj);
if tokens.input == 0 && tokens.output == 0 && tokens.thoughts == 0 && tokens.cached == 0 {
continue; // 跳过全零的空 token 消息
}
gemini_msg_count += 1;
// 提取消息 ID 和模型
let message_id = msg.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
let model = msg
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let timestamp = msg.get("timestamp").and_then(|v| v.as_str());
// 生成唯一 request_id
let session_id_str = session_id.as_deref().unwrap_or("unknown");
let request_id = format!("gemini_session:{session_id_str}:{message_id}");
match insert_gemini_session_entry(
db,
&request_id,
&tokens,
model,
session_id.as_deref(),
timestamp,
) {
Ok(true) => imported += 1,
Ok(false) => skipped += 1,
Err(e) => {
log::warn!("[GEMINI-SYNC] 插入失败 ({}): {e}", request_id);
skipped += 1;
}
}
}
// 更新同步状态
update_sync_state(db, &file_path_str, file_modified, gemini_msg_count)?;
Ok((imported, skipped))
}
/// 从 tokens JSON 对象中提取 token 数据
fn parse_gemini_tokens(tokens: &serde_json::Value) -> GeminiTokens {
GeminiTokens {
input: tokens.get("input").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
output: tokens.get("output").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
cached: tokens.get("cached").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
thoughts: tokens.get("thoughts").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
}
}
/// 插入单条 Gemini 会话记录到 proxy_request_logs
fn insert_gemini_session_entry(
db: &Database,
request_id: &str,
tokens: &GeminiTokens,
model: &str,
session_id: Option<&str>,
timestamp: Option<&str>,
) -> Result<bool, AppError> {
let conn = lock_conn!(db.conn);
// 解析时间戳
let created_at = timestamp
.and_then(|ts| {
chrono::DateTime::parse_from_rfc3339(ts)
.ok()
.map(|dt| dt.timestamp())
})
.unwrap_or_else(|| {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
});
// 合并 thoughts 到 output(思考 token 按输出计费)
let output_tokens = tokens.output + tokens.thoughts;
// 计算费用
let usage = TokenUsage {
input_tokens: tokens.input,
output_tokens,
cache_read_tokens: tokens.cached,
cache_creation_tokens: 0,
model: Some(model.to_string()),
};
let pricing = find_gemini_pricing(&conn, model);
let multiplier = Decimal::from(1);
let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = match pricing
{
Some(p) => {
let cost = CostCalculator::calculate(&usage, &p, multiplier);
(
cost.input_cost.to_string(),
cost.output_cost.to_string(),
cost.cache_read_cost.to_string(),
cost.cache_creation_cost.to_string(),
cost.total_cost.to_string(),
)
}
None => (
"0".to_string(),
"0".to_string(),
"0".to_string(),
"0".to_string(),
"0".to_string(),
),
};
// 使用 UPSERT:新记录插入,已存在记录更新 token 和费用(Gemini 全量重读可能携带更新值)
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
latency_ms, first_token_ms, status_code, error_message, session_id,
provider_type, is_streaming, cost_multiplier, created_at, data_source
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)
ON CONFLICT(request_id) DO UPDATE SET
model = excluded.model,
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
cache_read_tokens = excluded.cache_read_tokens,
input_cost_usd = excluded.input_cost_usd,
output_cost_usd = excluded.output_cost_usd,
cache_read_cost_usd = excluded.cache_read_cost_usd,
cache_creation_cost_usd = excluded.cache_creation_cost_usd,
total_cost_usd = excluded.total_cost_usd
WHERE input_tokens != excluded.input_tokens
OR output_tokens != excluded.output_tokens
OR cache_read_tokens != excluded.cache_read_tokens
OR model != excluded.model",
rusqlite::params![
request_id,
"_gemini_session", // provider_id
"gemini", // app_type
model,
model, // request_model = model
tokens.input,
output_tokens,
tokens.cached,
0i64, // cache_creation_tokens
input_cost,
output_cost,
cache_read_cost,
cache_creation_cost,
total_cost,
0i64, // latency_ms
Option::<i64>::None, // first_token_ms
200i64, // status_code
Option::<String>::None, // error_message
session_id.map(|s| s.to_string()),
Some("gemini_session"), // provider_type
1i64, // is_streaming
"1.0", // cost_multiplier
created_at,
"gemini_session", // data_source
],
)
.map_err(|e| AppError::Database(format!("插入 Gemini 会话日志失败: {e}")))?;
// changes() > 0 表示新插入或已更新,== 0 表示值完全相同(无实际变更)
Ok(conn.changes() > 0)
}
/// 获取文件的同步状态
fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> {
let conn = lock_conn!(db.conn);
let result = conn.query_row(
"SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1",
rusqlite::params![file_path],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
);
Ok(result.unwrap_or((0, 0)))
}
/// 更新文件的同步状态
fn update_sync_state(
db: &Database,
file_path: &str,
last_modified: i64,
last_offset: i64,
) -> Result<(), AppError> {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT OR REPLACE INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![file_path, last_modified, last_offset, now],
)
.map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?;
Ok(())
}
/// 查找 Gemini 模型定价
fn find_gemini_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
// 精确匹配
if let Some(pricing) = try_find_pricing(conn, model_id) {
return Some(pricing);
}
// LIKE 模糊匹配(兜底)
let pattern = format!("{model_id}%");
conn.query_row(
"SELECT input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing WHERE model_id LIKE ?1 LIMIT 1",
rusqlite::params![pattern],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
)
.ok()
.and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok())
}
/// 精确匹配定价查询
fn try_find_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
conn.query_row(
"SELECT input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing WHERE model_id = ?1",
rusqlite::params![model_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
)
.ok()
.and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_collect_gemini_session_files_nonexistent() {
let files = collect_gemini_session_files(Path::new("/nonexistent/path"));
assert!(files.is_empty());
}
#[test]
fn test_parse_gemini_tokens() {
let json: serde_json::Value = serde_json::json!({
"input": 8522,
"output": 29,
"cached": 3138,
"thoughts": 405,
"tool": 0,
"total": 8956
});
let tokens = parse_gemini_tokens(&json);
assert_eq!(tokens.input, 8522);
assert_eq!(tokens.output, 29);
assert_eq!(tokens.cached, 3138);
assert_eq!(tokens.thoughts, 405);
// output + thoughts = 29 + 405 = 434(用于计费)
assert_eq!(tokens.output + tokens.thoughts, 434);
}
#[test]
fn test_parse_gemini_tokens_missing_fields() {
// 缺少某些字段时应返回 0
let json: serde_json::Value = serde_json::json!({
"input": 100,
"output": 50
});
let tokens = parse_gemini_tokens(&json);
assert_eq!(tokens.input, 100);
assert_eq!(tokens.output, 50);
assert_eq!(tokens.cached, 0);
assert_eq!(tokens.thoughts, 0);
}
#[test]
fn test_parse_gemini_tokens_all_zero() {
let json: serde_json::Value = serde_json::json!({
"input": 0,
"output": 0,
"cached": 0,
"thoughts": 0,
"tool": 0,
"total": 0
});
let tokens = parse_gemini_tokens(&json);
assert_eq!(tokens.input, 0);
assert_eq!(tokens.output, 0);
// 全零(包括 cached=0)会被 sync 逻辑跳过
assert!(
tokens.input == 0 && tokens.output == 0 && tokens.thoughts == 0 && tokens.cached == 0
);
}
#[test]
fn test_parse_gemini_tokens_cache_only_not_skipped() {
// 纯缓存命中消息(input/output/thoughts=0 但 cached>0)不应被跳过
let json: serde_json::Value = serde_json::json!({
"input": 0,
"output": 0,
"cached": 5000,
"thoughts": 0
});
let tokens = parse_gemini_tokens(&json);
assert_eq!(tokens.cached, 5000);
// 跳过条件:所有四个字段都为 0 才跳过
let should_skip =
tokens.input == 0 && tokens.output == 0 && tokens.thoughts == 0 && tokens.cached == 0;
assert!(!should_skip, "纯缓存命中记录不应被跳过");
}
}
+646 -9
View File
@@ -34,6 +34,17 @@ pub enum SyncMethod {
Copy,
}
/// Skill 存储位置(SSOT 目录选择)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SkillStorageLocation {
/// CC Switch 管理目录 (~/.cc-switch/skills/)
#[default]
CcSwitch,
/// Agent Skills 统一标准目录 (~/.agents/skills/)
Unified,
}
/// 可发现的技能(来自仓库)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoverableSkill {
@@ -160,6 +171,81 @@ pub struct SkillUninstallResult {
pub backup_path: Option<String>,
}
/// Skill 更新检测结果
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillUpdateInfo {
/// Skill ID
pub id: String,
/// Skill 名称
pub name: String,
/// 当前本地哈希
pub current_hash: Option<String>,
/// 远程最新哈希
pub remote_hash: String,
}
/// Skill 存储位置迁移结果
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MigrationResult {
pub migrated_count: usize,
pub skipped_count: usize,
pub errors: Vec<String>,
}
// ========== skills.sh API 类型 ==========
/// skills.sh API 原始响应
///
/// 注意:API 命名不一致(searchType 是 camelCaseduration_ms 是 snake_case),
/// 因此不能用 rename_all,需要逐字段指定。
#[derive(Debug, Clone, Deserialize)]
struct SkillsShApiResponse {
pub query: String,
#[serde(rename = "searchType")]
#[allow(dead_code)]
pub search_type: String,
pub skills: Vec<SkillsShApiSkill>,
pub count: usize,
#[allow(dead_code)]
pub duration_ms: u64,
}
/// skills.sh API 原始技能条目
#[derive(Debug, Clone, Deserialize)]
struct SkillsShApiSkill {
pub id: String,
#[serde(rename = "skillId")]
pub skill_id: String,
pub name: String,
pub installs: u64,
pub source: String,
}
/// skills.sh 搜索结果(返回给前端)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillsShSearchResult {
pub skills: Vec<SkillsShDiscoverableSkill>,
pub total_count: usize,
pub query: String,
}
/// skills.sh 可安装技能(返回给前端)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillsShDiscoverableSkill {
pub key: String,
pub name: String,
pub directory: String,
pub repo_owner: String,
pub repo_name: String,
pub repo_branch: String,
pub installs: u64,
pub readme_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillBackupEntry {
@@ -389,9 +475,20 @@ impl SkillService {
// ========== 路径管理 ==========
/// 获取 SSOT 目录(~/.cc-switch/skills/
/// 获取 SSOT 目录(根据设置返回 ~/.cc-switch/skills/ 或 ~/.agents/skills/
pub fn get_ssot_dir() -> Result<PathBuf> {
let dir = get_app_config_dir().join("skills");
let location = crate::settings::get_skill_storage_location();
let dir = match location {
SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),
SkillStorageLocation::Unified => {
let home = dirs::home_dir().context(format_skill_error(
"GET_HOME_DIR_FAILED",
&[],
Some("checkPermission"),
))?;
home.join(".agents").join("skills")
}
};
fs::create_dir_all(&dir)?;
Ok(dir)
}
@@ -569,14 +666,28 @@ impl SkillService {
repo_branch = used_branch;
// 复制到 SSOT
let source = temp_dir.join(&source_rel);
let mut source = temp_dir.join(&source_rel);
if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &source.display().to_string())],
Some("checkRepoUrl"),
)));
// 回退:在 temp_dir 中递归查找名称匹配的目录(含 SKILL.md)
let target_name = source_rel
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if let Some(found) = Self::find_skill_dir_by_name(&temp_dir, &target_name) {
log::info!(
"Skill directory '{}' not found at direct path, using fallback: {}",
target_name,
found.display()
);
source = found;
} else {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &source.display().to_string())],
Some("checkRepoUrl"),
)));
}
}
let canonical_temp = temp_dir.canonicalize().unwrap_or_else(|_| temp_dir.clone());
@@ -632,6 +743,12 @@ impl SkillService {
));
// 创建 InstalledSkill 记录
// 计算内容哈希
let content_hash = Self::compute_dir_hash(&dest).map(Some).unwrap_or_else(|e| {
log::warn!("Failed to compute content hash for {}: {e}", install_name);
None
});
let installed_skill = InstalledSkill {
id: skill.key.clone(),
name: skill.name.clone(),
@@ -647,6 +764,8 @@ impl SkillService {
readme_url,
apps: SkillApps::only(current_app),
installed_at: chrono::Utc::now().timestamp(),
content_hash,
updated_at: 0,
};
// 保存到数据库
@@ -706,6 +825,412 @@ impl SkillService {
Ok(SkillUninstallResult { backup_path })
}
// ========== 更新检测 ==========
/// 计算目录内容的 SHA-256 哈希
///
/// 递归遍历目录下所有非隐藏文件,按相对路径字典序排列,
/// 将 "相对路径\0内容\0" 逐文件 feed 给同一个 hasher。
pub fn compute_dir_hash(dir: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
let mut files: Vec<PathBuf> = Vec::new();
Self::collect_files_for_hash(dir, dir, &mut files)?;
files.sort();
let mut hasher = Sha256::new();
for file_path in &files {
let relative = file_path.strip_prefix(dir).unwrap_or(file_path);
let rel_str = relative.to_string_lossy().replace('\\', "/");
hasher.update(rel_str.as_bytes());
hasher.update(b"\0");
let content = fs::read(file_path)
.with_context(|| format!("读取文件失败: {}", file_path.display()))?;
hasher.update(&content);
hasher.update(b"\0");
}
Ok(format!("{:x}", hasher.finalize()))
}
/// 递归收集目录下所有非隐藏文件
#[allow(clippy::only_used_in_recursion)]
fn collect_files_for_hash(base: &Path, current: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
let entries = fs::read_dir(current)
.with_context(|| format!("读取目录失败: {}", current.display()))?;
for entry in entries {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let path = entry.path();
if path.is_dir() {
Self::collect_files_for_hash(base, &path, files)?;
} else {
files.push(path);
}
}
Ok(())
}
/// 检查所有已安装 Skill 的更新
///
/// 仅检查有 repo_owner 的 Skill(本地 Skill 跳过),
/// 按仓库分组下载,避免重复下载同一仓库。
pub async fn check_updates(&self, db: &Arc<Database>) -> Result<Vec<SkillUpdateInfo>> {
let skills = db.get_all_installed_skills()?;
let mut updates = Vec::new();
// 按 (owner, name, branch) 分组
let mut repo_groups: HashMap<(String, String, String), Vec<InstalledSkill>> =
HashMap::new();
for skill in skills.into_values() {
let (owner, name, branch) =
match (&skill.repo_owner, &skill.repo_name, &skill.repo_branch) {
(Some(o), Some(n), Some(b)) => (o.clone(), n.clone(), b.clone()),
(Some(o), Some(n), None) => (o.clone(), n.clone(), "main".to_string()),
_ => continue,
};
repo_groups
.entry((owner, name, branch))
.or_default()
.push(skill);
}
let ssot_dir = Self::get_ssot_dir()?;
for ((owner, name, branch), group_skills) in &repo_groups {
let repo = SkillRepo {
owner: owner.clone(),
name: name.clone(),
branch: branch.clone(),
enabled: true,
};
// 下载仓库 ZIP
let (temp_dir, _used_branch) = match timeout(
std::time::Duration::from_secs(60),
self.download_repo(&repo),
)
.await
{
Ok(Ok(result)) => result,
Ok(Err(e)) => {
log::warn!("检查更新时下载 {}/{} 失败: {e}", owner, name);
continue;
}
Err(_) => {
log::warn!("检查更新时下载 {}/{} 超时", owner, name);
continue;
}
};
// 扫描仓库中的所有 Skill 目录
let mut remote_skills: Vec<DiscoverableSkill> = Vec::new();
let _ = self.scan_dir_recursive(&temp_dir, &temp_dir, &repo, &mut remote_skills);
for skill in group_skills {
// 在远程仓库中找到匹配的 Skill 目录
let remote_match = remote_skills.iter().find(|rs| {
// 匹配方式:安装名称的最后一段
let remote_install_name =
rs.directory.rsplit('/').next().unwrap_or(&rs.directory);
remote_install_name.eq_ignore_ascii_case(&skill.directory)
});
let remote_skill_dir = match remote_match {
Some(rs) => temp_dir.join(&rs.directory),
None => continue,
};
if !remote_skill_dir.exists() {
continue;
}
let remote_hash = match Self::compute_dir_hash(&remote_skill_dir) {
Ok(h) => h,
Err(e) => {
log::warn!("计算远程哈希失败 {}: {e}", skill.id);
continue;
}
};
// 本地哈希:优先数据库,否则实时计算
let local_hash = match &skill.content_hash {
Some(h) => Some(h.clone()),
None => {
let local_dir = ssot_dir.join(&skill.directory);
if local_dir.exists() {
match Self::compute_dir_hash(&local_dir) {
Ok(h) => {
let _ = db.update_skill_hash(&skill.id, &h, 0);
Some(h)
}
Err(_) => None,
}
} else {
None
}
}
};
if local_hash.as_deref() != Some(&remote_hash) {
updates.push(SkillUpdateInfo {
id: skill.id.clone(),
name: skill.name.clone(),
current_hash: local_hash,
remote_hash,
});
}
}
let _ = fs::remove_dir_all(&temp_dir);
}
Ok(updates)
}
/// 更新单个 Skill(重新下载并替换本地文件)
pub async fn update_skill(&self, db: &Arc<Database>, skill_id: &str) -> Result<InstalledSkill> {
let skill = db
.get_installed_skill(skill_id)?
.ok_or_else(|| anyhow!("Skill not found: {skill_id}"))?;
let (owner, name, branch) = match (&skill.repo_owner, &skill.repo_name) {
(Some(o), Some(n)) => (
o.clone(),
n.clone(),
skill
.repo_branch
.clone()
.unwrap_or_else(|| "main".to_string()),
),
_ => return Err(anyhow!("Cannot update local skill: {skill_id}")),
};
let repo = SkillRepo {
owner: owner.clone(),
name: name.clone(),
branch: branch.clone(),
enabled: true,
};
let ssot_dir = Self::get_ssot_dir()?;
// 下载仓库
let (temp_dir, used_branch) = timeout(
std::time::Duration::from_secs(60),
self.download_repo(&repo),
)
.await
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[("owner", &owner), ("name", &name), ("timeout", "60")],
Some("checkNetwork"),
))
})??;
// 在解压的仓库中查找 Skill 源目录
let mut remote_skills: Vec<DiscoverableSkill> = Vec::new();
let _ = self.scan_dir_recursive(&temp_dir, &temp_dir, &repo, &mut remote_skills);
let remote_match = remote_skills
.iter()
.find(|rs| {
let remote_install_name = rs.directory.rsplit('/').next().unwrap_or(&rs.directory);
remote_install_name.eq_ignore_ascii_case(&skill.directory)
})
.ok_or_else(|| {
let _ = fs::remove_dir_all(&temp_dir);
anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &skill.directory)],
Some("checkRepoUrl"),
))
})?;
let source = temp_dir.join(&remote_match.directory);
if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &source.display().to_string())],
Some("checkRepoUrl"),
)));
}
// 备份旧文件
let _ = Self::create_uninstall_backup(&skill);
// 删除旧 SSOT 目录并复制新文件
let dest = ssot_dir.join(&skill.directory);
if dest.exists() {
fs::remove_dir_all(&dest)?;
}
Self::copy_dir_recursive(&source, &dest)?;
let _ = fs::remove_dir_all(&temp_dir);
// 计算新哈希 + 解析新元数据
let new_hash = Self::compute_dir_hash(&dest).ok();
let skill_md = dest.join("SKILL.md");
let (new_name, new_description) = Self::read_skill_name_desc(&skill_md, &skill.directory);
// 更新 readme_url
let doc_path = skill
.readme_url
.as_deref()
.and_then(Self::extract_doc_path_from_url)
.unwrap_or_else(|| format!("{}/SKILL.md", skill.directory.trim_end_matches('/')));
let readme_url = Some(Self::build_skill_doc_url(
&owner,
&name,
&used_branch,
&doc_path,
));
let updated_skill = InstalledSkill {
id: skill.id.clone(),
name: new_name,
description: new_description,
directory: skill.directory.clone(),
repo_owner: skill.repo_owner.clone(),
repo_name: skill.repo_name.clone(),
repo_branch: Some(used_branch),
readme_url,
apps: skill.apps.clone(),
installed_at: skill.installed_at,
content_hash: new_hash,
updated_at: chrono::Utc::now().timestamp(),
};
db.save_skill(&updated_skill)?;
// 同步到所有已启用的应用目录
for app in updated_skill.apps.enabled_apps() {
if let Err(e) = Self::sync_to_app_dir(&updated_skill.directory, &app) {
log::warn!("同步更新后的 skill 到 {:?} 失败: {e}", app);
}
}
log::info!("Skill {} 更新成功", updated_skill.name);
Ok(updated_skill)
}
/// 为缺少 content_hash 的已安装 Skill 补算哈希
pub fn backfill_content_hashes(db: &Arc<Database>) -> Result<usize> {
let skills = db.get_all_installed_skills()?;
let ssot_dir = Self::get_ssot_dir()?;
let mut count = 0;
for skill in skills.values() {
if skill.content_hash.is_some() {
continue;
}
let skill_dir = ssot_dir.join(&skill.directory);
if !skill_dir.exists() {
continue;
}
match Self::compute_dir_hash(&skill_dir) {
Ok(hash) => {
let _ = db.update_skill_hash(&skill.id, &hash, 0);
count += 1;
}
Err(e) => {
log::warn!("补算哈希失败 {}: {e}", skill.id);
}
}
}
if count > 0 {
log::info!("已为 {count} 个 Skill 补算内容哈希");
}
Ok(count)
}
/// 迁移 Skill 存储位置(在两个 SSOT 目录间移动文件)
///
/// 安全策略:先移文件,后改设置。中途崩溃时设置仍指向旧目录。
pub fn migrate_storage(
db: &Arc<Database>,
target: SkillStorageLocation,
) -> Result<MigrationResult> {
let current = crate::settings::get_skill_storage_location();
if current == target {
return Ok(MigrationResult {
migrated_count: 0,
skipped_count: 0,
errors: vec![],
});
}
// 1. 解析旧目录和新目录(不改设置)
let old_dir = Self::get_ssot_dir()?;
let new_dir = match target {
SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),
SkillStorageLocation::Unified => {
let home = dirs::home_dir().context("Cannot determine home directory")?;
home.join(".agents").join("skills")
}
};
fs::create_dir_all(&new_dir)?;
// 2. 逐个移动 skill 目录
let skills = db.get_all_installed_skills()?;
let mut result = MigrationResult {
migrated_count: 0,
skipped_count: 0,
errors: vec![],
};
for skill in skills.values() {
let src = old_dir.join(&skill.directory);
let dst = new_dir.join(&skill.directory);
if !src.exists() {
result.skipped_count += 1;
continue;
}
if dst.exists() {
result.skipped_count += 1;
continue;
}
// 优先 rename(同文件系统原子操作),失败则 copy+delete
match fs::rename(&src, &dst) {
Ok(()) => result.migrated_count += 1,
Err(_) => match Self::copy_dir_recursive(&src, &dst) {
Ok(()) => {
let _ = fs::remove_dir_all(&src);
result.migrated_count += 1;
}
Err(e) => {
result.errors.push(format!("{}: {e}", skill.directory));
}
},
}
}
// 3. 文件移动完成后才持久化设置
crate::settings::set_skill_storage_location(target)?;
// 4. 刷新所有应用目录的 symlink(指向新 SSOT
for app in AppType::all() {
let _ = Self::sync_to_app(db, &app);
}
log::info!(
"Skill 存储迁移完成: {} 迁移, {} 跳过, {} 错误",
result.migrated_count,
result.skipped_count,
result.errors.len()
);
Ok(result)
}
pub fn list_backups() -> Result<Vec<SkillBackupEntry>> {
let backup_dir = Self::get_backup_dir()?;
let mut entries = Vec::new();
@@ -800,9 +1325,13 @@ impl SkillService {
let mut restored_skill = metadata.skill;
restored_skill.installed_at = Utc::now().timestamp();
restored_skill.apps = SkillApps::only(current_app);
restored_skill.updated_at = 0;
Self::copy_dir_recursive(&backup_skill_dir, &restore_path)?;
// 重新计算内容哈希
restored_skill.content_hash = Self::compute_dir_hash(&restore_path).ok();
if let Err(err) = db.save_skill(&restored_skill) {
let _ = fs::remove_dir_all(&restore_path);
return Err(err.into());
@@ -991,6 +1520,10 @@ impl SkillService {
let (id, repo_owner, repo_name, repo_branch, readme_url) =
build_repo_info_from_lock(&agents_lock, &dir_name);
// 计算内容哈希
let ssot_skill_dir = ssot_dir.join(&dir_name);
let content_hash = Self::compute_dir_hash(&ssot_skill_dir).ok();
// 创建记录
let skill = InstalledSkill {
id,
@@ -1003,6 +1536,8 @@ impl SkillService {
readme_url,
apps,
installed_at: chrono::Utc::now().timestamp(),
content_hash,
updated_at: 0,
};
// 保存到数据库
@@ -1514,6 +2049,38 @@ impl SkillService {
}
}
/// 在目录树中查找名称匹配且包含 SKILL.md 的子目录
///
/// 用于 skills.sh 安装回退:API 只返回 skillId(如 "find-skills"),
/// 但实际文件可能在仓库子目录中(如 "skills/find-skills")。
fn find_skill_dir_by_name(root: &Path, target_name: &str) -> Option<PathBuf> {
fn walk(dir: &Path, target: &str, depth: usize) -> Option<PathBuf> {
if depth > 3 {
return None;
}
let entries = fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') {
continue;
}
if name_str.eq_ignore_ascii_case(target) && path.join("SKILL.md").exists() {
return Some(path);
}
if let Some(found) = walk(&path, target, depth + 1) {
return Some(found);
}
}
None
}
walk(root, target_name, 0)
}
/// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示)
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
let mut seen = HashMap::new();
@@ -1965,6 +2532,9 @@ impl SkillService {
}
Self::copy_dir_recursive(&skill_dir, &dest)?;
// 计算内容哈希
let content_hash = Self::compute_dir_hash(&dest).ok();
// 创建 InstalledSkill 记录
let skill = InstalledSkill {
id: format!("local:{install_name}"),
@@ -1977,6 +2547,8 @@ impl SkillService {
readme_url: None,
apps: SkillApps::only(current_app),
installed_at: chrono::Utc::now().timestamp(),
content_hash,
updated_at: 0,
};
// 保存到数据库
@@ -2116,6 +2688,67 @@ impl SkillService {
Ok(())
}
// ========== skills.sh 搜索 ==========
/// 搜索 skills.sh 公共目录
pub async fn search_skills_sh(
query: &str,
limit: usize,
offset: usize,
) -> Result<SkillsShSearchResult> {
let client = crate::proxy::http_client::get();
let url = url::Url::parse_with_params(
"https://skills.sh/api/search",
&[
("q", query),
("limit", &limit.to_string()),
("offset", &offset.to_string()),
],
)?;
let resp = client
.get(url)
.timeout(std::time::Duration::from_secs(10))
.send()
.await?
.error_for_status()?
.json::<SkillsShApiResponse>()
.await?;
let skills = resp
.skills
.into_iter()
.filter_map(|s| {
let parts: Vec<&str> = s.source.splitn(2, '/').collect();
if parts.len() != 2 {
return None;
}
let (owner, repo) = (parts[0].to_string(), parts[1].to_string());
// 过滤非 GitHub 来源(如 "skills.volces.com"、"mcp-hub.momenta.works"
if owner.contains('.') || repo.contains('.') {
return None;
}
Some(SkillsShDiscoverableSkill {
key: s.id,
name: s.name,
directory: s.skill_id.clone(),
repo_owner: owner.clone(),
repo_name: repo.clone(),
repo_branch: "main".to_string(),
installs: s.installs,
readme_url: Some(format!("https://github.com/{}/{}", owner, repo)),
})
})
.collect();
Ok(SkillsShSearchResult {
skills,
total_count: resp.count,
query: resp.query,
})
}
}
// ========== 迁移支持 ==========
@@ -2288,6 +2921,8 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
let (id, repo_owner, repo_name, repo_branch, readme_url) =
build_repo_info_from_lock(&agents_lock, &directory);
let content_hash = SkillService::compute_dir_hash(&ssot_path).ok();
let skill = InstalledSkill {
id,
name,
@@ -2299,6 +2934,8 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
readme_url,
apps,
installed_at: chrono::Utc::now().timestamp(),
content_hash,
updated_at: 0,
};
db.save_skill(&skill)?;
+618 -40
View File
@@ -197,6 +197,14 @@ impl StreamCheckService {
claude_api_format_override: Option<String>,
) -> Result<StreamCheckResult, AppError> {
let start = Instant::now();
// OpenCode / OpenClaw 的 settings_config 结构与 Claude/Codex/Gemini 不同
// baseUrl / apiKey 直接作为根字段而非嵌套在 env),并且协议由 `api`
// 或 `npm` 字段显式指定。它们不走 get_adapter 路径,而是直接分发。
if matches!(app_type, AppType::OpenCode | AppType::OpenClaw) {
return Self::check_once_without_adapter(app_type, provider, config, start).await;
}
let adapter = get_adapter(app_type);
let base_url = match base_url_override {
@@ -229,6 +237,7 @@ impl StreamCheckService {
request_timeout,
provider,
claude_api_format_override.as_deref(),
None,
)
.await
}
@@ -252,24 +261,13 @@ impl StreamCheckService {
&model_to_test,
test_prompt,
request_timeout,
None,
)
.await
}
AppType::OpenCode => {
// OpenCode doesn't support stream check yet
return Err(AppError::localized(
"opencode_no_stream_check",
"OpenCode 暂不支持健康检查",
"OpenCode does not support health check yet",
));
}
AppType::OpenClaw => {
// OpenClaw doesn't support stream check yet
return Err(AppError::localized(
"openclaw_no_stream_check",
"OpenClaw 暂不支持健康检查",
"OpenClaw does not support health check yet",
));
AppType::OpenCode | AppType::OpenClaw => {
// Already handled via early dispatch above
unreachable!("OpenCode/OpenClaw 已通过 check_once_without_adapter 处理")
}
};
@@ -309,7 +307,10 @@ impl StreamCheckService {
/// 根据供应商的 api_format 选择请求格式:
/// - "anthropic" (默认): Anthropic Messages API (/v1/messages)
/// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions)
/// - "gemini_chat": Gemini Chat 兼容 API (/chat/completions, 不注入 prompt_cache_key)
///
/// `extra_headers` 是一个可选的供应商级自定义 header 集合(从 OpenClaw
/// 的 `settings_config.headers` 或 OpenCode 的 `settings_config.options.headers`
/// 读取),在所有内置 header 之后追加,用于覆盖或补充(例如自定义 User-Agent)。
#[allow(clippy::too_many_arguments)]
async fn check_claude_stream(
client: &Client,
@@ -320,6 +321,7 @@ impl StreamCheckService {
timeout: std::time::Duration,
provider: &Provider,
claude_api_format_override: Option<&str>,
extra_headers: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
let is_github_copilot = auth.strategy == AuthStrategy::GitHubCopilot;
@@ -345,7 +347,6 @@ impl StreamCheckService {
.and_then(|meta| meta.is_full_url)
.unwrap_or(false);
let is_openai_chat = effective_api_format == "openai_chat";
let is_gemini_chat = effective_api_format == "gemini_chat";
let is_openai_responses = effective_api_format == "openai_responses";
let url =
Self::resolve_claude_stream_url(base, auth.strategy, effective_api_format, is_full_url);
@@ -359,11 +360,16 @@ impl StreamCheckService {
"messages": [{ "role": "user", "content": test_prompt }],
"stream": true
});
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要 store:false + include 标记,
// 否则 Stream Check 会和生产路径一样被服务端 400 拒绝。
let is_codex_oauth = provider
.meta
.as_ref()
.and_then(|m| m.provider_type.as_deref())
== Some("codex_oauth");
let body = if is_openai_responses {
anthropic_to_responses(anthropic_body, Some(&provider.id))
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
} else if is_gemini_chat {
anthropic_to_openai(anthropic_body, None)
anthropic_to_responses(anthropic_body, Some(&provider.id), is_codex_oauth)
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
} else if is_openai_chat {
anthropic_to_openai(anthropic_body, Some(&provider.id))
@@ -400,7 +406,7 @@ impl StreamCheckService {
.header("x-vscode-user-agent-library-version", "electron-fetch")
.header("x-request-id", &request_id)
.header("x-agent-task-id", &request_id);
} else if is_openai_chat || is_gemini_chat || is_openai_responses {
} else if is_openai_chat || is_openai_responses {
// OpenAI-compatible targets: Bearer auth + SSE headers only
request_builder = request_builder
.header("authorization", format!("Bearer {}", auth.api_key))
@@ -450,6 +456,15 @@ impl StreamCheckService {
.header("connection", "keep-alive");
}
// 供应商自定义 headers 最后追加,允许覆盖内置默认值(例如 user-agent
if let Some(headers) = extra_headers {
for (key, value) in headers {
if let Some(v) = value.as_str() {
request_builder = request_builder.header(key.as_str(), v);
}
}
}
let response = request_builder
.timeout(timeout)
.json(&body)
@@ -570,6 +585,7 @@ impl StreamCheckService {
model: &str,
test_prompt: &str,
timeout: std::time::Duration,
extra_headers: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
// Gemini 原生 API: /v1beta/models/{model}:streamGenerateContent?alt=sse
@@ -589,11 +605,22 @@ impl StreamCheckService {
}]
});
let response = client
let mut request_builder = client
.post(&url)
.header("x-goog-api-key", &auth.api_key)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.header("Accept", "text/event-stream");
// 供应商自定义 headers 最后追加
if let Some(headers) = extra_headers {
for (key, value) in headers {
if let Some(v) = value.as_str() {
request_builder = request_builder.header(key.as_str(), v);
}
}
}
let response = request_builder
.timeout(timeout)
.json(&body)
.send()
@@ -618,6 +645,451 @@ impl StreamCheckService {
}
}
/// OpenCode / OpenClaw 的独立分发入口(绕过 `get_adapter`
///
/// 这两个应用的 `settings_config` 与 Claude/Codex/Gemini 完全不同:
/// - OpenClaw: `{ baseUrl, apiKey, api, models: [...] }``api` 字段标识协议
/// - OpenCode: `{ npm, options: { baseURL, apiKey }, models: {...} }``npm` 字段标识协议
///
/// 因此不能复用 `get_adapter`(会 fallback 到 CodexAdapter 而提取失败),
/// 改为独立解析 base_url/api_key/协议,再分发到现有的 check_*_stream 函数。
async fn check_once_without_adapter(
app_type: &AppType,
provider: &Provider,
config: &StreamCheckConfig,
start: Instant,
) -> Result<StreamCheckResult, AppError> {
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
let client = crate::proxy::http_client::get_for_provider(proxy_config);
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
let model_to_test = Self::resolve_test_model(app_type, provider, config);
let test_prompt = &config.test_prompt;
let result = match app_type {
AppType::OpenClaw => {
Self::check_openclaw_stream(
&client,
provider,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
AppType::OpenCode => {
Self::check_opencode_stream(
&client,
provider,
&model_to_test,
test_prompt,
request_timeout,
)
.await
}
_ => unreachable!("check_once_without_adapter 只处理 OpenCode/OpenClaw"),
};
let response_time = start.elapsed().as_millis() as u64;
Ok(Self::build_stream_check_result(
result,
response_time,
config.degraded_threshold_ms,
))
}
/// 将 check_*_stream 的原始结果包装成 StreamCheckResult
///
/// 抽取自 check_once 的末尾逻辑,以便 OpenCode/OpenClaw 的独立分支复用。
fn build_stream_check_result(
result: Result<(u16, String), AppError>,
response_time: u64,
degraded_threshold_ms: u64,
) -> StreamCheckResult {
let tested_at = chrono::Utc::now().timestamp();
match result {
Ok((status_code, model)) => StreamCheckResult {
status: Self::determine_status(response_time, degraded_threshold_ms),
success: true,
message: "Check succeeded".to_string(),
response_time_ms: Some(response_time),
http_status: Some(status_code),
model_used: model,
tested_at,
retry_count: 0,
},
Err(e) => StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: e.to_string(),
response_time_ms: Some(response_time),
http_status: None,
model_used: String::new(),
tested_at,
retry_count: 0,
},
}
}
/// OpenClaw 流式检查分发器
///
/// 根据 `settings_config.api` 字段分发到对应协议的检查器。
/// 取值参见 `openclawApiProtocols` (前端 openclawProviderPresets.ts):
/// - `openai-completions` → check_claude_stream + api_format="openai_chat"
/// - `openai-responses` → check_claude_stream + api_format="openai_responses"
/// - `anthropic-messages` → check_claude_stream + api_format="anthropic" (ClaudeAuth 策略)
/// - `google-generative-ai` → check_gemini_stream (Google API Key 策略)
/// - `bedrock-converse-stream` → 不支持(需要 AWS SigV4 签名)
async fn check_openclaw_stream(
client: &Client,
provider: &Provider,
model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
// 自定义认证头(如 Longcat 的 `apikey` 头)不走标准 Bearer
// 具体头名由 OpenClaw 网关内部决定,cc-switch 无法准确构造,
// 因此直接返回友好错误而不是让用户看到一个误导性的 401。
if Self::openclaw_uses_auth_header(provider) {
return Err(AppError::localized(
"openclaw_auth_header_not_supported",
"该供应商使用自定义认证头,暂不支持流式健康检查。建议直接通过 OpenClaw 测试。",
"This provider uses a custom auth header; stream health check is not supported. Please test it directly via OpenClaw.",
));
}
let base_url = Self::extract_openclaw_base_url(provider)?;
let api_key = Self::extract_openclaw_api_key(provider)?;
let api = Self::extract_openclaw_protocol(provider);
let extra_headers = Self::extract_openclaw_headers(provider);
match api.as_deref() {
Some("openai-completions") => {
let auth = AuthInfo::new(api_key, AuthStrategy::Bearer);
Self::check_claude_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
provider,
Some("openai_chat"),
extra_headers,
)
.await
}
Some("openai-responses") => {
let auth = AuthInfo::new(api_key, AuthStrategy::Bearer);
Self::check_claude_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
provider,
Some("openai_responses"),
extra_headers,
)
.await
}
Some("anthropic-messages") => {
// 使用 ClaudeAuthBearer-only)以兼容 Claude 中转服务。
// 某些中转同时收到 Authorization 和 x-api-key 会报错,ClaudeAuth
// 策略保证只下发 Bearer。官方 Anthropic 也接受纯 Bearer。
let auth = AuthInfo::new(api_key, AuthStrategy::ClaudeAuth);
Self::check_claude_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
provider,
Some("anthropic"),
extra_headers,
)
.await
}
Some("google-generative-ai") => {
let auth = AuthInfo::new(api_key, AuthStrategy::Google);
Self::check_gemini_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
extra_headers,
)
.await
}
Some("bedrock-converse-stream") => Err(AppError::localized(
"openclaw_bedrock_not_supported",
"AWS Bedrock 需要 SigV4 签名,当前不支持健康检查。请通过 AWS 控制台或 OpenClaw 验证连通性。",
"AWS Bedrock requires SigV4 signing and is not supported by stream health check. Please verify connectivity via AWS console or OpenClaw.",
)),
Some(other) => Err(AppError::localized(
"openclaw_protocol_not_yet_supported",
format!("OpenClaw 暂不支持协议: {other}"),
format!("OpenClaw protocol not yet supported: {other}"),
)),
None => Err(AppError::localized(
"openclaw_protocol_missing",
"OpenClaw 供应商缺少 api 字段",
"OpenClaw provider is missing the `api` field",
)),
}
}
/// 判断 OpenClaw 供应商是否使用自定义认证头(`authHeader: true`
fn openclaw_uses_auth_header(provider: &Provider) -> bool {
provider
.settings_config
.get("authHeader")
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
/// 提取 OpenClaw 供应商的自定义 headers(来自 `settings_config.headers`
fn extract_openclaw_headers(
provider: &Provider,
) -> Option<&serde_json::Map<String, serde_json::Value>> {
provider
.settings_config
.get("headers")
.and_then(|v| v.as_object())
.filter(|m| !m.is_empty())
}
fn extract_openclaw_base_url(provider: &Provider) -> Result<String, AppError> {
provider
.settings_config
.get("baseUrl")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
AppError::localized(
"openclaw_base_url_missing",
"OpenClaw 供应商缺少 baseUrl",
"OpenClaw provider is missing `baseUrl`",
)
})
}
fn extract_openclaw_api_key(provider: &Provider) -> Result<String, AppError> {
provider
.settings_config
.get("apiKey")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
AppError::localized(
"openclaw_api_key_missing",
"OpenClaw 供应商缺少 apiKey",
"OpenClaw provider is missing `apiKey`",
)
})
}
fn extract_openclaw_protocol(provider: &Provider) -> Option<String> {
provider
.settings_config
.get("api")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// OpenCode 流式检查分发器
///
/// OpenCode 用 `npm` 字段(AI SDK 包名)隐式指定协议。映射关系参见
/// `opencodeNpmPackages` (前端 opencodeProviderPresets.ts):
/// - `@ai-sdk/openai-compatible` → check_claude_stream + api_format="openai_chat"
/// - `@ai-sdk/openai` → check_claude_stream + api_format="openai_responses"
/// - `@ai-sdk/anthropic` → check_claude_stream + api_format="anthropic"
/// - `@ai-sdk/google` → check_gemini_stream (Google API Key 策略)
/// - `@ai-sdk/amazon-bedrock` → 不支持(需要 AWS SigV4 签名)
///
/// URL/API Key 存放在 `settings_config.options.{baseURL,apiKey}`,注意
/// `baseURL` 大写 L(与 OpenClaw 的 `baseUrl` 首字母小写 u 不同)。
async fn check_opencode_stream(
client: &Client,
provider: &Provider,
model: &str,
test_prompt: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let npm = Self::extract_opencode_npm(provider);
// 若用户未显式填 baseURL,则根据 npm 回退到 AI SDK 包自带的默认端点
let base_url = Self::resolve_opencode_base_url(provider, npm.as_deref())?;
let api_key = Self::extract_opencode_api_key(provider)?;
let extra_headers = Self::extract_opencode_headers(provider);
match npm.as_deref() {
Some("@ai-sdk/openai-compatible") => {
let auth = AuthInfo::new(api_key, AuthStrategy::Bearer);
Self::check_claude_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
provider,
Some("openai_chat"),
extra_headers,
)
.await
}
Some("@ai-sdk/openai") => {
let auth = AuthInfo::new(api_key, AuthStrategy::Bearer);
Self::check_claude_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
provider,
Some("openai_responses"),
extra_headers,
)
.await
}
Some("@ai-sdk/anthropic") => {
// 见 check_openclaw_stream 对 anthropic-messages 的注释:
// 用 ClaudeAuthBearer-only)兼容中转服务。
let auth = AuthInfo::new(api_key, AuthStrategy::ClaudeAuth);
Self::check_claude_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
provider,
Some("anthropic"),
extra_headers,
)
.await
}
Some("@ai-sdk/google") => {
let auth = AuthInfo::new(api_key, AuthStrategy::Google);
Self::check_gemini_stream(
client,
&base_url,
&auth,
model,
test_prompt,
timeout,
extra_headers,
)
.await
}
Some("@ai-sdk/amazon-bedrock") => Err(AppError::localized(
"opencode_bedrock_not_supported",
"AWS Bedrock 需要 SigV4 签名,当前不支持健康检查。请通过 AWS 控制台或 OpenCode 验证连通性。",
"AWS Bedrock requires SigV4 signing and is not supported by stream health check. Please verify connectivity via AWS console or OpenCode.",
)),
Some(other) => Err(AppError::localized(
"opencode_npm_not_yet_supported",
format!("OpenCode 暂不支持 SDK 包: {other}"),
format!("OpenCode SDK package not yet supported: {other}"),
)),
None => Err(AppError::localized(
"opencode_npm_missing",
"OpenCode 供应商缺少 npm 字段",
"OpenCode provider is missing the `npm` field",
)),
}
}
/// 按 OpenCode 的实际 SDK 包特性确定 baseURL
/// - 用户显式填写的 `options.baseURL` 总是优先
/// - 否则根据 `npm` 返回 AI SDK 包自带的默认端点
/// - `@ai-sdk/openai-compatible` 没有默认端点,必须显式填
///
/// 注意:这里的默认端点对应 AI SDK 包的行为(例如 `@ai-sdk/openai`
/// 自带 `/v1` 路径后缀),与 `proxy/providers/mod.rs` 里的
/// `ProviderType::default_endpoint()` 语义不同——后者是代理层的上游
/// 默认值,不带 `/v1`。两者维护的是不同系统的默认值,不能简单共享。
fn resolve_opencode_base_url(
provider: &Provider,
npm: Option<&str>,
) -> Result<String, AppError> {
if let Some(explicit) = Self::extract_opencode_base_url(provider) {
return Ok(explicit);
}
let fallback = match npm {
Some("@ai-sdk/openai") => Some("https://api.openai.com/v1"),
Some("@ai-sdk/anthropic") => Some("https://api.anthropic.com"),
Some("@ai-sdk/google") => Some("https://generativelanguage.googleapis.com"),
_ => None,
};
fallback.map(|s| s.to_string()).ok_or_else(|| {
AppError::localized(
"opencode_base_url_missing",
"OpenCode 供应商缺少 options.baseURL,且当前 SDK 包没有默认端点",
"OpenCode provider is missing `options.baseURL` and the SDK package has no default endpoint",
)
})
}
fn extract_opencode_base_url(provider: &Provider) -> Option<String> {
provider
.settings_config
.get("options")
.and_then(|v| v.get("baseURL"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// 提取 OpenCode 供应商的自定义 headers(来自 `settings_config.options.headers`
fn extract_opencode_headers(
provider: &Provider,
) -> Option<&serde_json::Map<String, serde_json::Value>> {
provider
.settings_config
.get("options")
.and_then(|v| v.get("headers"))
.and_then(|v| v.as_object())
.filter(|m| !m.is_empty())
}
fn extract_opencode_api_key(provider: &Provider) -> Result<String, AppError> {
provider
.settings_config
.get("options")
.and_then(|v| v.get("apiKey"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
AppError::localized(
"opencode_api_key_missing",
"OpenCode 供应商缺少 options.apiKey",
"OpenCode provider is missing `options.apiKey`",
)
})
}
fn extract_opencode_npm(provider: &Provider) -> Option<String> {
provider
.settings_config
.get("npm")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn determine_status(latency_ms: u64, threshold: u64) -> HealthStatus {
if latency_ms <= threshold {
HealthStatus::Operational
@@ -766,7 +1238,7 @@ impl StreamCheckService {
if is_github_copilot && api_format == "openai_responses" {
format!("{base}/v1/responses")
} else if is_github_copilot || api_format == "gemini_chat" {
} else if is_github_copilot {
format!("{base}/chat/completions")
} else if api_format == "openai_responses" {
if base.ends_with("/v1") {
@@ -815,6 +1287,127 @@ impl StreamCheckService {
mod tests {
use super::*;
fn make_provider(settings_config: serde_json::Value) -> Provider {
Provider::with_id(
"test".to_string(),
"Test".to_string(),
settings_config,
None,
)
}
#[test]
fn test_openclaw_uses_auth_header_true() {
let p = make_provider(serde_json::json!({
"baseUrl": "https://api.longcat.chat/v1",
"apiKey": "k",
"api": "openai-completions",
"authHeader": true,
}));
assert!(StreamCheckService::openclaw_uses_auth_header(&p));
}
#[test]
fn test_openclaw_uses_auth_header_default_false() {
let p = make_provider(serde_json::json!({
"baseUrl": "https://api.deepseek.com/v1",
"apiKey": "k",
"api": "openai-completions",
}));
assert!(!StreamCheckService::openclaw_uses_auth_header(&p));
}
#[test]
fn test_resolve_opencode_base_url_explicit_wins() {
let p = make_provider(serde_json::json!({
"npm": "@ai-sdk/openai",
"options": { "baseURL": "https://proxy.local/v1", "apiKey": "k" },
"models": {},
}));
let resolved =
StreamCheckService::resolve_opencode_base_url(&p, Some("@ai-sdk/openai")).unwrap();
assert_eq!(resolved, "https://proxy.local/v1");
}
#[test]
fn test_resolve_opencode_base_url_falls_back_for_known_npm() {
let p = make_provider(serde_json::json!({
"npm": "@ai-sdk/openai",
"options": { "apiKey": "k" },
"models": {},
}));
let resolved =
StreamCheckService::resolve_opencode_base_url(&p, Some("@ai-sdk/openai")).unwrap();
assert_eq!(resolved, "https://api.openai.com/v1");
let p2 = make_provider(serde_json::json!({
"npm": "@ai-sdk/anthropic",
"options": { "apiKey": "k" },
"models": {},
}));
let resolved2 =
StreamCheckService::resolve_opencode_base_url(&p2, Some("@ai-sdk/anthropic")).unwrap();
assert_eq!(resolved2, "https://api.anthropic.com");
}
#[test]
fn test_resolve_opencode_base_url_errors_for_openai_compatible_without_url() {
// @ai-sdk/openai-compatible 没有默认端点,必须显式填
let p = make_provider(serde_json::json!({
"npm": "@ai-sdk/openai-compatible",
"options": { "apiKey": "k" },
"models": {},
}));
let result =
StreamCheckService::resolve_opencode_base_url(&p, Some("@ai-sdk/openai-compatible"));
assert!(result.is_err());
}
#[test]
fn test_extract_openclaw_headers_preserves_map() {
let p = make_provider(serde_json::json!({
"baseUrl": "https://example.com/v1",
"apiKey": "k",
"api": "openai-completions",
"headers": { "User-Agent": "MyBot/1.0", "X-Trace": "abc" },
}));
let headers = StreamCheckService::extract_openclaw_headers(&p).unwrap();
assert_eq!(
headers.get("User-Agent").and_then(|v| v.as_str()),
Some("MyBot/1.0")
);
assert_eq!(headers.get("X-Trace").and_then(|v| v.as_str()), Some("abc"));
}
#[test]
fn test_extract_openclaw_headers_ignores_empty_map() {
let p = make_provider(serde_json::json!({
"baseUrl": "https://example.com/v1",
"apiKey": "k",
"api": "openai-completions",
"headers": {},
}));
assert!(StreamCheckService::extract_openclaw_headers(&p).is_none());
}
#[test]
fn test_extract_opencode_headers_from_options() {
let p = make_provider(serde_json::json!({
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://example.com/v1",
"apiKey": "k",
"headers": { "X-Custom": "yes" },
},
"models": {},
}));
let headers = StreamCheckService::extract_opencode_headers(&p).unwrap();
assert_eq!(
headers.get("X-Custom").and_then(|v| v.as_str()),
Some("yes")
);
}
#[test]
fn test_determine_status() {
assert_eq!(
@@ -960,21 +1553,6 @@ mod tests {
assert_eq!(url, "https://example.com/v1/chat/completions");
}
#[test]
fn test_resolve_claude_stream_url_for_gemini_chat() {
let url = StreamCheckService::resolve_claude_stream_url(
"https://generativelanguage.googleapis.com/v1beta/openai",
AuthStrategy::Bearer,
"gemini_chat",
false,
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
);
}
#[test]
fn test_resolve_claude_stream_url_for_openai_responses() {
let url = StreamCheckService::resolve_claude_stream_url(
+33 -12
View File
@@ -60,7 +60,7 @@ pub struct SubscriptionQuota {
}
impl SubscriptionQuota {
fn not_found(tool: &str) -> Self {
pub(crate) fn not_found(tool: &str) -> Self {
Self {
tool: tool.to_string(),
credential_status: CredentialStatus::NotFound,
@@ -73,7 +73,7 @@ impl SubscriptionQuota {
}
}
fn error(tool: &str, status: CredentialStatus, message: String) -> Self {
pub(crate) fn error(tool: &str, status: CredentialStatus, message: String) -> Self {
Self {
tool: tool.to_string(),
credential_status: status,
@@ -621,8 +621,17 @@ fn unix_ts_to_iso(ts: i64) -> Option<String> {
chrono::DateTime::from_timestamp(ts, 0).map(|dt| dt.to_rfc3339())
}
/// 查询 Codex 官方订阅额度
async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> SubscriptionQuota {
/// 查询 Codex / ChatGPT 反代订阅额度
///
/// 参数化 `tool_label` 和 `expired_message` 让该函数可被两个调用点共用:
/// - `"codex"` + "Please re-login with Codex CLI."CLI 凭据路径)
/// - `"codex_oauth"` + "Please re-login via cc-switch."cc-switch 自管 OAuth 路径)
pub(crate) async fn query_codex_quota(
access_token: &str,
account_id: Option<&str>,
tool_label: &str,
expired_message: &str,
) -> SubscriptionQuota {
let client = crate::proxy::http_client::get();
let mut req = client
@@ -639,7 +648,7 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs
Ok(r) => r,
Err(e) => {
return SubscriptionQuota::error(
"codex",
tool_label,
CredentialStatus::Valid,
format!("Network error: {e}"),
);
@@ -650,16 +659,16 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return SubscriptionQuota::error(
"codex",
tool_label,
CredentialStatus::Expired,
format!("Authentication failed (HTTP {status}). Please re-login with Codex CLI."),
format!("{expired_message} (HTTP {status})"),
);
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return SubscriptionQuota::error(
"codex",
tool_label,
CredentialStatus::Valid,
format!("API error (HTTP {status}): {body}"),
);
@@ -669,7 +678,7 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs
Ok(v) => v,
Err(e) => {
return SubscriptionQuota::error(
"codex",
tool_label,
CredentialStatus::Valid,
format!("Failed to parse API response: {e}"),
);
@@ -697,7 +706,7 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs
}
SubscriptionQuota {
tool: "codex".to_string(),
tool: tool_label.to_string(),
credential_status: CredentialStatus::Valid,
credential_message: None,
success: true,
@@ -1221,7 +1230,13 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
CredentialStatus::Expired => {
// 即使可能过期也尝试调用 API
if let Some(token) = token {
let result = query_codex_quota(&token, account_id.as_deref()).await;
let result = query_codex_quota(
&token,
account_id.as_deref(),
"codex",
"Authentication failed. Please re-login with Codex CLI.",
)
.await;
if result.success {
return Ok(result);
}
@@ -1234,7 +1249,13 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
}
CredentialStatus::Valid => {
let token = token.expect("token must be Some when status is Valid");
Ok(query_codex_quota(&token, account_id.as_deref()).await)
Ok(query_codex_quota(
&token,
account_id.as_deref(),
"codex",
"Authentication failed. Please re-login with Codex CLI.",
)
.await)
}
}
}
+197 -104
View File
@@ -113,6 +113,21 @@ pub struct RequestLogDetail {
pub status_code: u16,
pub error_message: Option<String>,
pub created_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_source: Option<String>,
}
/// SQL fragment: resolve provider_name with fallback for session-based entries.
/// Session logs use placeholder provider_ids (_session, _codex_session, _gemini_session)
/// that don't exist in the providers table — this COALESCE gives them readable names.
fn provider_name_coalesce(log_alias: &str, provider_alias: &str) -> String {
format!(
"COALESCE({provider_alias}.name, CASE {log_alias}.provider_id \
WHEN '_session' THEN 'Claude (Session)' \
WHEN '_codex_session' THEN 'Codex (Session)' \
WHEN '_gemini_session' THEN 'Gemini (Session)' \
ELSE {log_alias}.provider_id END)"
)
}
impl Database {
@@ -121,44 +136,54 @@ impl Database {
&self,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<&str>,
) -> Result<UsageSummary, AppError> {
let conn = lock_conn!(self.conn);
let (where_clause, params_vec) = if start_date.is_some() || end_date.is_some() {
let mut conditions = Vec::new();
let mut params = Vec::new();
// Build detail WHERE clause
let mut conditions = Vec::new();
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(start) = start_date {
conditions.push("created_at >= ?");
params.push(start);
}
if let Some(end) = end_date {
conditions.push("created_at <= ?");
params.push(end);
}
if let Some(start) = start_date {
conditions.push("created_at >= ?");
params_vec.push(Box::new(start));
}
if let Some(end) = end_date {
conditions.push("created_at <= ?");
params_vec.push(Box::new(end));
}
if let Some(at) = app_type {
conditions.push("app_type = ?");
params_vec.push(Box::new(at.to_string()));
}
(format!("WHERE {}", conditions.join(" AND ")), params)
let where_clause = if conditions.is_empty() {
String::new()
} else {
(String::new(), Vec::new())
format!("WHERE {}", conditions.join(" AND "))
};
// Build rollup WHERE clause using date strings (use ? for sequential binding)
let (rollup_where, rollup_params) = if start_date.is_some() || end_date.is_some() {
let mut conditions: Vec<String> = Vec::new();
let mut params = Vec::new();
// Build rollup WHERE clause using date strings
let mut rollup_conditions: Vec<String> = Vec::new();
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(start) = start_date {
conditions.push("date >= date(?, 'unixepoch', 'localtime')".to_string());
params.push(start);
}
if let Some(end) = end_date {
conditions.push("date <= date(?, 'unixepoch', 'localtime')".to_string());
params.push(end);
}
if let Some(start) = start_date {
rollup_conditions.push("date >= date(?, 'unixepoch', 'localtime')".to_string());
rollup_params.push(Box::new(start));
}
if let Some(end) = end_date {
rollup_conditions.push("date <= date(?, 'unixepoch', 'localtime')".to_string());
rollup_params.push(Box::new(end));
}
if let Some(at) = app_type {
rollup_conditions.push("app_type = ?".to_string());
rollup_params.push(Box::new(at.to_string()));
}
(format!("WHERE {}", conditions.join(" AND ")), params)
let rollup_where = if rollup_conditions.is_empty() {
String::new()
} else {
(String::new(), Vec::new())
format!("WHERE {}", rollup_conditions.join(" AND "))
};
let sql = format!(
@@ -192,10 +217,11 @@ impl Database {
);
// Combine params: detail params first, then rollup params
let mut all_params: Vec<i64> = params_vec;
let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = params_vec;
all_params.extend(rollup_params);
let param_refs: Vec<&dyn rusqlite::ToSql> = all_params.iter().map(|p| p.as_ref()).collect();
let result = conn.query_row(&sql, rusqlite::params_from_iter(all_params), |row| {
let result = conn.query_row(&sql, param_refs.as_slice(), |row| {
let total_requests: i64 = row.get(0)?;
let total_cost: f64 = row.get(1)?;
let total_input_tokens: i64 = row.get(2)?;
@@ -229,6 +255,7 @@ impl Database {
&self,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<&str>,
) -> Result<Vec<DailyStats>, AppError> {
let conn = lock_conn!(self.conn);
@@ -260,9 +287,15 @@ impl Database {
bucket_count = 1;
}
let app_type_filter = if app_type.is_some() {
"AND app_type = ?4"
} else {
""
};
// Query detail logs
let sql = "
SELECT
let sql = format!(
"SELECT
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
COUNT(*) as request_count,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
@@ -272,12 +305,13 @@ impl Database {
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
FROM proxy_request_logs
WHERE created_at >= ?1 AND created_at <= ?2
WHERE created_at >= ?1 AND created_at <= ?2 {app_type_filter}
GROUP BY bucket_idx
ORDER BY bucket_idx ASC";
ORDER BY bucket_idx ASC"
);
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| {
let mut stmt = conn.prepare(&sql)?;
let row_mapper = |row: &rusqlite::Row| {
Ok((
row.get::<_, i64>(0)?,
DailyStats {
@@ -291,24 +325,33 @@ impl Database {
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
},
))
})?;
};
let mut map: HashMap<i64, DailyStats> = HashMap::new();
for row in rows {
let (mut bucket_idx, stat) = row?;
if bucket_idx < 0 {
continue;
// Collect rows into map (need to handle both param variants)
{
let rows = if let Some(at) = app_type {
stmt.query_map(params![start_ts, end_ts, bucket_seconds, at], row_mapper)?
} else {
stmt.query_map(params![start_ts, end_ts, bucket_seconds], row_mapper)?
};
for row in rows {
let (mut bucket_idx, stat) = row?;
if bucket_idx < 0 {
continue;
}
if bucket_idx >= bucket_count {
bucket_idx = bucket_count - 1;
}
map.insert(bucket_idx, stat);
}
if bucket_idx >= bucket_count {
bucket_idx = bucket_count - 1;
}
map.insert(bucket_idx, stat);
}
// Also query rollup data (daily granularity, only useful for daily buckets)
if bucket_seconds >= 86400 {
let rollup_sql = "
SELECT
let rollup_sql = format!(
"SELECT
CAST((CAST(strftime('%s', date) AS INTEGER) - ?1) / ?3 AS INTEGER) as bucket_idx,
COALESCE(SUM(request_count), 0),
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0),
@@ -318,12 +361,12 @@ impl Database {
COALESCE(SUM(cache_creation_tokens), 0),
COALESCE(SUM(cache_read_tokens), 0)
FROM usage_daily_rollups
WHERE date >= date(?1, 'unixepoch', 'localtime') AND date <= date(?2, 'unixepoch', 'localtime')
WHERE date >= date(?1, 'unixepoch', 'localtime') AND date <= date(?2, 'unixepoch', 'localtime') {app_type_filter}
GROUP BY bucket_idx
ORDER BY bucket_idx ASC";
ORDER BY bucket_idx ASC"
);
let mut rstmt = conn.prepare(rollup_sql)?;
let rrows = rstmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| {
let rollup_row_mapper = |row: &rusqlite::Row| {
Ok((
row.get::<_, i64>(0)?,
(
@@ -336,7 +379,17 @@ impl Database {
row.get::<_, i64>(7)? as u64,
),
))
})?;
};
let mut rstmt = conn.prepare(&rollup_sql)?;
let rrows = if let Some(at) = app_type {
rstmt.query_map(
params![start_ts, end_ts, bucket_seconds, at],
rollup_row_mapper,
)?
} else {
rstmt.query_map(params![start_ts, end_ts, bucket_seconds], rollup_row_mapper)?
};
for row in rrows {
let (mut bucket_idx, (req, cost, tok, inp, out, cc, cr)) = row?;
@@ -398,11 +451,23 @@ impl Database {
}
/// 获取 Provider 统计
pub fn get_provider_stats(&self) -> Result<Vec<ProviderStats>, AppError> {
pub fn get_provider_stats(
&self,
app_type: Option<&str>,
) -> Result<Vec<ProviderStats>, AppError> {
let conn = lock_conn!(self.conn);
let (detail_where, rollup_where) = if app_type.is_some() {
("WHERE l.app_type = ?1", "WHERE r.app_type = ?2")
} else {
("", "")
};
// UNION detail logs + rollup data, then aggregate
let sql = "SELECT
let detail_pname = provider_name_coalesce("l", "p");
let rollup_pname = provider_name_coalesce("r", "p2");
let sql = format!(
"SELECT
provider_id, app_type, provider_name,
SUM(request_count) as request_count,
SUM(total_tokens) as total_tokens,
@@ -413,7 +478,7 @@ impl Database {
ELSE 0 END as avg_latency
FROM (
SELECT l.provider_id, l.app_type,
p.name as provider_name,
{detail_pname} as provider_name,
COUNT(*) as request_count,
COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens,
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost,
@@ -421,10 +486,11 @@ impl Database {
COALESCE(SUM(l.latency_ms), 0) as latency_sum
FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
{detail_where}
GROUP BY l.provider_id, l.app_type
UNION ALL
SELECT r.provider_id, r.app_type,
p2.name as provider_name,
{rollup_pname} as provider_name,
COALESCE(SUM(r.request_count), 0),
COALESCE(SUM(r.input_tokens + r.output_tokens), 0),
COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0),
@@ -432,13 +498,15 @@ impl Database {
COALESCE(SUM(r.avg_latency_ms * r.request_count), 0)
FROM usage_daily_rollups r
LEFT JOIN providers p2 ON r.provider_id = p2.id AND r.app_type = p2.app_type
{rollup_where}
GROUP BY r.provider_id, r.app_type
)
GROUP BY provider_id, app_type
ORDER BY total_cost DESC";
ORDER BY total_cost DESC"
);
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([], |row| {
let mut stmt = conn.prepare(&sql)?;
let row_mapper = |row: &rusqlite::Row| {
let request_count: i64 = row.get(3)?;
let success_count: i64 = row.get(6)?;
let success_rate = if request_count > 0 {
@@ -449,16 +517,20 @@ impl Database {
Ok(ProviderStats {
provider_id: row.get(0)?,
provider_name: row
.get::<_, Option<String>>(2)?
.unwrap_or_else(|| "Unknown".to_string()),
provider_name: row.get(2)?,
request_count: request_count as u64,
total_tokens: row.get::<_, i64>(4)? as u64,
total_cost: format!("{:.6}", row.get::<_, f64>(5)?),
success_rate,
avg_latency_ms: row.get::<_, f64>(7)? as u64,
})
})?;
};
let rows = if let Some(at) = app_type {
stmt.query_map(params![at, at], row_mapper)?
} else {
stmt.query_map([], row_mapper)?
};
let mut stats = Vec::new();
for row in rows {
@@ -469,11 +541,18 @@ impl Database {
}
/// 获取模型统计
pub fn get_model_stats(&self) -> Result<Vec<ModelStats>, AppError> {
pub fn get_model_stats(&self, app_type: Option<&str>) -> Result<Vec<ModelStats>, AppError> {
let conn = lock_conn!(self.conn);
let (detail_where, rollup_where) = if app_type.is_some() {
("WHERE app_type = ?1", "WHERE app_type = ?2")
} else {
("", "")
};
// UNION detail logs + rollup data
let sql = "SELECT
let sql = format!(
"SELECT
model,
SUM(request_count) as request_count,
SUM(total_tokens) as total_tokens,
@@ -484,6 +563,7 @@ impl Database {
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost
FROM proxy_request_logs
{detail_where}
GROUP BY model
UNION ALL
SELECT model,
@@ -491,13 +571,15 @@ impl Database {
COALESCE(SUM(input_tokens + output_tokens), 0),
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
FROM usage_daily_rollups
{rollup_where}
GROUP BY model
)
GROUP BY model
ORDER BY total_cost DESC";
ORDER BY total_cost DESC"
);
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([], |row| {
let mut stmt = conn.prepare(&sql)?;
let row_mapper = |row: &rusqlite::Row| {
let request_count: i64 = row.get(1)?;
let total_cost: f64 = row.get(3)?;
let avg_cost = if request_count > 0 {
@@ -513,7 +595,13 @@ impl Database {
total_cost: format!("{total_cost:.6}"),
avg_cost_per_request: format!("{avg_cost:.6}"),
})
})?;
};
let rows = if let Some(at) = app_type {
stmt.query_map(params![at, at], row_mapper)?
} else {
stmt.query_map([], row_mapper)?
};
let mut stats = Vec::new();
for row in rows {
@@ -582,13 +670,14 @@ impl Database {
params.push(Box::new(page_size as i64));
params.push(Box::new(offset as i64));
let logs_pname = provider_name_coalesce("l", "p");
let sql = format!(
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,
"SELECT l.request_id, l.provider_id, {logs_pname} as provider_name, l.app_type, l.model,
l.request_model, l.cost_multiplier,
l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens,
l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd,
l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,
l.status_code, l.error_message, l.created_at
l.status_code, l.error_message, l.created_at, l.data_source
FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
{where_clause}
@@ -625,6 +714,7 @@ impl Database {
status_code: row.get::<_, i64>(20)? as u16,
error_message: row.get(21)?,
created_at: row.get(22)?,
data_source: row.get(23)?,
})
})?;
@@ -658,45 +748,48 @@ impl Database {
) -> Result<Option<RequestLogDetail>, AppError> {
let conn = lock_conn!(self.conn);
let result = conn.query_row(
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,
let detail_pname = provider_name_coalesce("l", "p");
let detail_sql = format!(
"SELECT l.request_id, l.provider_id, {detail_pname} as provider_name, l.app_type, l.model,
l.request_model, l.cost_multiplier,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
is_streaming, latency_ms, first_token_ms, duration_ms,
status_code, error_message, created_at
status_code, error_message, created_at, l.data_source
FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
WHERE l.request_id = ?",
[request_id],
|row| {
Ok(RequestLogDetail {
request_id: row.get(0)?,
provider_id: row.get(1)?,
provider_name: row.get(2)?,
app_type: row.get(3)?,
model: row.get(4)?,
request_model: row.get(5)?,
cost_multiplier: row.get::<_, Option<String>>(6)?.unwrap_or_else(|| "1".to_string()),
input_tokens: row.get::<_, i64>(7)? as u32,
output_tokens: row.get::<_, i64>(8)? as u32,
cache_read_tokens: row.get::<_, i64>(9)? as u32,
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
input_cost_usd: row.get(11)?,
output_cost_usd: row.get(12)?,
cache_read_cost_usd: row.get(13)?,
cache_creation_cost_usd: row.get(14)?,
total_cost_usd: row.get(15)?,
is_streaming: row.get::<_, i64>(16)? != 0,
latency_ms: row.get::<_, i64>(17)? as u64,
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
status_code: row.get::<_, i64>(20)? as u16,
error_message: row.get(21)?,
created_at: row.get(22)?,
})
},
WHERE l.request_id = ?"
);
let result = conn.query_row(&detail_sql, [request_id], |row| {
Ok(RequestLogDetail {
request_id: row.get(0)?,
provider_id: row.get(1)?,
provider_name: row.get(2)?,
app_type: row.get(3)?,
model: row.get(4)?,
request_model: row.get(5)?,
cost_multiplier: row
.get::<_, Option<String>>(6)?
.unwrap_or_else(|| "1".to_string()),
input_tokens: row.get::<_, i64>(7)? as u32,
output_tokens: row.get::<_, i64>(8)? as u32,
cache_read_tokens: row.get::<_, i64>(9)? as u32,
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
input_cost_usd: row.get(11)?,
output_cost_usd: row.get(12)?,
cache_read_cost_usd: row.get(13)?,
cache_creation_cost_usd: row.get(14)?,
total_cost_usd: row.get(15)?,
is_streaming: row.get::<_, i64>(16)? != 0,
latency_ms: row.get::<_, i64>(17)? as u64,
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
status_code: row.get::<_, i64>(20)? as u16,
error_message: row.get(21)?,
created_at: row.get(22)?,
data_source: row.get(23)?,
})
});
match result {
Ok(mut detail) => {
@@ -1040,7 +1133,7 @@ mod tests {
)?;
}
let summary = db.get_usage_summary(None, None)?;
let summary = db.get_usage_summary(None, None, None)?;
assert_eq!(summary.total_requests, 2);
assert_eq!(summary.success_rate, 100.0);
@@ -1075,7 +1168,7 @@ mod tests {
)?;
}
let stats = db.get_model_stats()?;
let stats = db.get_model_stats(None)?;
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].model, "claude-3-sonnet");
assert_eq!(stats[0].request_count, 1);
@@ -12,6 +12,7 @@ use super::utils::{
};
const PROVIDER_ID: &str = "claude";
const TITLE_MAX_CHARS: usize = 80;
pub fn scan_sessions() -> Vec<SessionMeta> {
let root = get_claude_config_dir().join("projects");
@@ -129,8 +130,9 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
let mut session_id: Option<String> = None;
let mut project_dir: Option<String> = None;
let mut created_at: Option<i64> = None;
let mut first_user_message: Option<String> = None;
// Extract metadata from head lines
// Extract metadata and first user message from head lines
for line in &head {
let value: Value = match serde_json::from_str(line) {
Ok(parsed) => parsed,
@@ -151,11 +153,41 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
if created_at.is_none() {
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
}
// Extract first real user message as title candidate
// Skip system-injected caveats and slash commands (e.g. /clear, /compact)
if first_user_message.is_none() {
let is_user = value.get("type").and_then(Value::as_str) == Some("user")
|| value
.get("message")
.and_then(|m| m.get("role"))
.and_then(Value::as_str)
== Some("user");
if is_user {
if let Some(message) = value.get("message") {
let text = message.get("content").map(extract_text).unwrap_or_default();
let trimmed = text.trim();
if !trimmed.is_empty()
&& !trimmed.contains("<local-command-caveat>")
&& !trimmed.starts_with("<command-name>")
{
first_user_message = Some(trimmed.to_string());
}
}
}
}
if session_id.is_some()
&& project_dir.is_some()
&& created_at.is_some()
&& first_user_message.is_some()
{
break;
}
}
// Extract last_active_at and summary from tail lines (reverse order)
// Extract last_active_at, summary, and custom-title from tail lines (reverse order)
let mut last_active_at: Option<i64> = None;
let mut summary: Option<String> = None;
let mut custom_title: Option<String> = None;
for line in tail.iter().rev() {
let value: Value = match serde_json::from_str(line) {
@@ -165,6 +197,16 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
if last_active_at.is_none() {
last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
}
// Look for custom-title entry (take the last one, i.e. first in reverse)
if custom_title.is_none()
&& value.get("type").and_then(Value::as_str) == Some("custom-title")
{
custom_title = value
.get("customTitle")
.and_then(Value::as_str)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
}
if summary.is_none() {
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
continue;
@@ -176,7 +218,7 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
}
}
}
if last_active_at.is_some() && summary.is_some() {
if last_active_at.is_some() && summary.is_some() && custom_title.is_some() {
break;
}
}
@@ -184,10 +226,16 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
let session_id = session_id?;
let title = project_dir
.as_deref()
.and_then(path_basename)
.map(|value| value.to_string());
// Title priority: custom-title > first user message > directory basename
let title = custom_title
.map(|t| truncate_summary(&t, TITLE_MAX_CHARS))
.or_else(|| first_user_message.map(|t| truncate_summary(&t, TITLE_MAX_CHARS)))
.or_else(|| {
project_dir
.as_deref()
.and_then(path_basename)
.map(|v| v.to_string())
});
let summary = summary.map(|text| truncate_summary(&text, 160));
@@ -336,4 +384,117 @@ mod tests {
assert_eq!(msgs[0].role, "user");
assert!(msgs[0].content.contains("Please continue"));
}
#[test]
fn parse_session_uses_first_user_message_as_title() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session-abc.jsonl");
std::fs::write(
&path,
concat!(
"{\"sessionId\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"How do I deploy?\"},\"sessionId\":\"session-abc\",\"timestamp\":\"2026-03-06T10:01:00Z\"}\n",
"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Here is how...\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n",
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
assert_eq!(meta.title.as_deref(), Some("How do I deploy?"));
}
#[test]
fn parse_session_custom_title_overrides_first_message() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session-def.jsonl");
std::fs::write(
&path,
concat!(
"{\"sessionId\":\"session-def\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"fix something\"},\"sessionId\":\"session-def\",\"timestamp\":\"2026-03-06T10:01:00Z\"}\n",
"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Done.\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n",
"{\"type\":\"custom-title\",\"customTitle\":\"fix-login-bug\",\"sessionId\":\"session-def\"}\n",
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
assert_eq!(meta.title.as_deref(), Some("fix-login-bug"));
}
#[test]
fn parse_session_falls_back_to_dir_basename() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session-ghi.jsonl");
std::fs::write(
&path,
concat!(
"{\"sessionId\":\"session-ghi\",\"cwd\":\"/tmp/my-project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n",
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
// No user message and no custom-title → falls back to dir basename
assert_eq!(meta.title.as_deref(), Some("my-project"));
}
#[test]
fn parse_session_truncates_long_title() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session-trunc.jsonl");
let long_msg = "a".repeat(200);
std::fs::write(
&path,
format!(
"{{\"sessionId\":\"session-trunc\",\"cwd\":\"/tmp/p\",\"timestamp\":\"2026-03-06T10:00:00Z\"}}\n\
{{\"type\":\"user\",\"message\":{{\"role\":\"user\",\"content\":\"{long_msg}\"}},\"sessionId\":\"session-trunc\",\"timestamp\":\"2026-03-06T10:01:00Z\"}}\n",
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
let title = meta.title.unwrap();
assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..."
assert!(title.ends_with("..."));
}
#[test]
fn parse_session_new_format_with_snapshot() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session-new.jsonl");
std::fs::write(
&path,
concat!(
"{\"type\":\"file-history-snapshot\",\"messageId\":\"msg-1\",\"snapshot\":{},\"isSnapshotUpdate\":false}\n",
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"请帮我重构这个函数\"},\"sessionId\":\"session-new\",\"timestamp\":\"2026-03-06T10:00:00Z\",\"cwd\":\"/tmp/project\"}\n",
"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"OK\"},\"timestamp\":\"2026-03-06T10:01:00Z\",\"cwd\":\"/tmp/project\"}\n",
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
assert_eq!(meta.title.as_deref(), Some("请帮我重构这个函数"));
}
#[test]
fn parse_session_skips_command_caveat_and_slash_commands() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session-clear.jsonl");
std::fs::write(
&path,
concat!(
"{\"type\":\"file-history-snapshot\",\"messageId\":\"msg-1\",\"snapshot\":{},\"isSnapshotUpdate\":false}\n",
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"<local-command-caveat>Caveat: The messages below were generated by the user while running local commands.</local-command-caveat>\"},\"sessionId\":\"session-clear\",\"timestamp\":\"2026-03-06T10:00:00Z\",\"cwd\":\"/tmp/project\"}\n",
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"<command-name>/clear</command-name>\\n<command-message>clear</command-message>\"},\"sessionId\":\"session-clear\",\"timestamp\":\"2026-03-06T10:00:01Z\",\"cwd\":\"/tmp/project\"}\n",
"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Done.\"},\"timestamp\":\"2026-03-06T10:00:02Z\",\"cwd\":\"/tmp/project\"}\n",
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"帮我看看工作区的改动\"},\"sessionId\":\"session-clear\",\"timestamp\":\"2026-03-06T10:01:00Z\",\"cwd\":\"/tmp/project\"}\n",
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
assert_eq!(meta.title.as_deref(), Some("帮我看看工作区的改动"));
}
}
+77 -17
View File
@@ -20,6 +20,7 @@ pub fn launch_terminal(
"ghostty" => launch_ghostty(command, cwd),
"kitty" => launch_kitty(command, cwd),
"wezterm" => launch_wezterm(command, cwd),
"kaku" => launch_kaku(command, cwd),
"alacritty" => launch_alacritty(command, cwd),
"custom" => launch_custom(command, cwd, custom_config),
_ => Err(format!("Unsupported terminal target: {target}")),
@@ -153,25 +154,10 @@ fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> {
fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
// wezterm start --cwd ... -- command
// To invoke via `open`, we use `open -na "WezTerm" --args start ...`
let full_command = build_shell_command(command, None);
let mut args = vec!["-na", "WezTerm", "--args", "start"];
if let Some(dir) = cwd {
args.push("--cwd");
args.push(dir);
}
// Invoke shell to run the command string (to handle pipes, etc)
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
args.push("--");
args.push(&shell);
args.push("-c");
args.push(&full_command);
let args = build_wezterm_compatible_args("WezTerm", command, cwd);
let status = Command::new("open")
.args(&args)
.args(args.iter().map(String::as_str))
.status()
.map_err(|e| format!("Failed to launch WezTerm: {e}"))?;
@@ -182,6 +168,54 @@ fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
}
}
fn launch_kaku(command: &str, cwd: Option<&str>) -> Result<(), String> {
// Kaku is a WezTerm-derived terminal and keeps a compatible `start` entrypoint.
let args = build_wezterm_compatible_args("Kaku", command, cwd);
let status = Command::new("open")
.args(args.iter().map(String::as_str))
.status()
.map_err(|e| format!("Failed to launch Kaku: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Kaku.".to_string())
}
}
fn build_wezterm_compatible_args(app_name: &str, command: &str, cwd: Option<&str>) -> Vec<String> {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
build_wezterm_compatible_args_with_shell(app_name, command, cwd, &shell)
}
fn build_wezterm_compatible_args_with_shell(
app_name: &str,
command: &str,
cwd: Option<&str>,
shell: &str,
) -> Vec<String> {
let full_command = build_shell_command(command, None);
let mut args = vec![
"-na".to_string(),
app_name.to_string(),
"--args".to_string(),
"start".to_string(),
];
if let Some(dir) = cwd {
args.push("--cwd".to_string());
args.push(dir.to_string());
}
// Invoke shell to run the command string (to handle pipes, etc)
args.push("--".to_string());
args.push(shell.to_string());
args.push("-c".to_string());
args.push(full_command);
args
}
fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> {
// Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command
let full_command = build_shell_command(command, None);
@@ -305,4 +339,30 @@ mod tests {
"raw:echo foo\\\\\\\\bar\\npwd\\n"
);
}
#[test]
fn wezterm_compatible_terminals_use_start_and_cwd_arguments() {
let args = build_wezterm_compatible_args_with_shell(
"Kaku",
"claude --resume abc-123",
Some("/tmp/project dir"),
"/bin/zsh",
);
assert_eq!(
args,
vec![
"-na".to_string(),
"Kaku".to_string(),
"--args".to_string(),
"start".to_string(),
"--cwd".to_string(),
"/tmp/project dir".to_string(),
"--".to_string(),
"/bin/zsh".to_string(),
"-c".to_string(),
"claude --resume abc-123".to_string(),
]
);
}
}
+34 -2
View File
@@ -6,7 +6,7 @@ use std::sync::{OnceLock, RwLock};
use crate::app_config::AppType;
use crate::error::AppError;
use crate::services::skill::SyncMethod;
use crate::services::skill::{SkillStorageLocation, SyncMethod};
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -205,6 +205,12 @@ pub struct AppSettings {
/// User has confirmed the failover toggle first-run notice
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failover_confirmed: Option<bool>,
/// User has confirmed the first-run welcome notice
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_run_notice_confirmed: Option<bool>,
/// User has confirmed the common config first-run notice
#[serde(default, skip_serializing_if = "Option::is_none")]
pub common_config_confirmed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
@@ -245,6 +251,9 @@ pub struct AppSettings {
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
#[serde(default)]
pub skill_sync_method: SyncMethod,
/// Skill 存储位置:cc_switch(默认)或 unified~/.agents/skills/
#[serde(default)]
pub skill_storage_location: SkillStorageLocation,
// ===== WebDAV 同步设置 =====
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -264,7 +273,7 @@ pub struct AppSettings {
// ===== 终端设置 =====
/// 首选终端应用(可选,默认使用系统默认终端)
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" | "wezterm" | "kaku"
/// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal)
/// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -294,6 +303,8 @@ impl Default for AppSettings {
stream_check_confirmed: None,
enable_failover_toggle: false,
failover_confirmed: None,
first_run_notice_confirmed: None,
common_config_confirmed: None,
language: None,
visible_apps: None,
claude_config_dir: None,
@@ -307,6 +318,7 @@ impl Default for AppSettings {
current_provider_opencode: None,
current_provider_openclaw: None,
skill_sync_method: SyncMethod::default(),
skill_storage_location: SkillStorageLocation::default(),
webdav_sync: None,
webdav_backup: None,
backup_interval_hours: None,
@@ -642,6 +654,26 @@ pub fn get_skill_sync_method() -> SyncMethod {
.skill_sync_method
}
// ===== Skill 存储位置管理函数 =====
/// 获取 Skill 存储位置配置
pub fn get_skill_storage_location() -> SkillStorageLocation {
settings_store()
.read()
.unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
})
.skill_storage_location
}
/// 设置 Skill 存储位置
pub fn set_skill_storage_location(location: SkillStorageLocation) -> Result<(), AppError> {
mutate_settings(|s| {
s.skill_storage_location = location;
})
}
// ===== 备份策略管理函数 =====
/// Get the effective auto-backup interval in hours (default 24)
+4
View File
@@ -424,6 +424,10 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "linux")]
{
crate::linux_fix::nudge_main_window(window.clone());
}
#[cfg(target_os = "macos")]
{
apply_tray_policy(app, true);
+8
View File
@@ -110,6 +110,8 @@ fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() {
opencode: false,
},
installed_at: 0,
content_hash: None,
updated_at: 0,
})
.expect("save disabled skill");
@@ -154,6 +156,8 @@ fn uninstall_skill_creates_backup_before_removing_ssot() {
opencode: false,
},
installed_at: 123,
content_hash: None,
updated_at: 0,
})
.expect("save skill");
@@ -222,6 +226,8 @@ fn restore_skill_backup_restores_files_to_ssot_and_current_app() {
opencode: false,
},
installed_at: 456,
content_hash: None,
updated_at: 0,
})
.expect("save skill");
@@ -303,6 +309,8 @@ fn delete_skill_backup_removes_backup_directory() {
opencode: false,
},
installed_at: 789,
content_hash: None,
updated_at: 0,
})
.expect("save skill");
+20 -11
View File
@@ -40,7 +40,12 @@ import { useLastValidValue } from "@/hooks/useLastValidValue";
import { extractErrorMessage } from "@/utils/errorUtils";
import { isTextEditableTarget } from "@/utils/domUtils";
import { cn } from "@/lib/utils";
import { isWindows, isLinux } from "@/lib/platform";
import {
isWindows,
isLinux,
DRAG_REGION_ATTR,
DRAG_REGION_STYLE,
} from "@/lib/platform";
import { AppSwitcher } from "@/components/AppSwitcher";
import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
@@ -57,6 +62,7 @@ import PromptPanel from "@/components/prompts/PromptPanel";
import { SkillsPage } from "@/components/skills/SkillsPage";
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { FirstRunNoticeDialog } from "@/components/FirstRunNoticeDialog";
import { AgentsPanel } from "@/components/agents/AgentsPanel";
import { UniversalProviderPanel } from "@/components/universal";
import { McpIcon } from "@/components/BrandIcons";
@@ -876,11 +882,13 @@ function App() {
className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30"
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
>
<div
className="fixed top-0 left-0 right-0 z-[60]"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
/>
{DRAG_BAR_HEIGHT > 0 && (
<div
className="fixed top-0 left-0 right-0 z-[60]"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
/>
)}
{showEnvBanner && envConflicts.length > 0 && (
<EnvWarningBanner
conflicts={envConflicts}
@@ -908,10 +916,10 @@ function App() {
<header
className="fixed z-50 w-full transition-all duration-300 bg-background/80 backdrop-blur-md"
data-tauri-drag-region
{...DRAG_REGION_ATTR}
style={
{
WebkitAppRegion: "drag",
...DRAG_REGION_STYLE,
top: DRAG_BAR_HEIGHT,
height: HEADER_HEIGHT,
} as any
@@ -919,8 +927,8 @@ function App() {
>
<div
className="flex h-full items-center justify-between gap-2 px-6"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any}
{...DRAG_REGION_ATTR}
style={{ ...DRAG_REGION_STYLE } as any}
>
<div
className="flex items-center gap-1"
@@ -1035,7 +1043,7 @@ function App() {
)}
<div
ref={toolbarRef}
className="flex flex-1 min-w-0 overflow-x-hidden items-center"
className="flex flex-1 min-w-0 overflow-x-hidden items-center py-4 pr-2"
>
<div
className="flex shrink-0 items-center gap-1.5 ml-auto"
@@ -1347,6 +1355,7 @@ function App() {
/>
<DeepLinkImportDialog />
<FirstRunNoticeDialog />
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import React from "react";
import type { ProviderMeta } from "@/types";
import { useCodexOauthQuota } from "@/lib/query/subscription";
import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter";
interface CodexOauthQuotaFooterProps {
meta?: ProviderMeta;
inline?: boolean;
/** 是否为当前激活的供应商 */
isCurrent?: boolean;
}
/**
* Codex OAuth (ChatGPT Plus/Pro ) footer
*
* SubscriptionQuotaView 5 × inline/expanded
* cc-switch OAuth token Codex CLI
*/
const CodexOauthQuotaFooter: React.FC<CodexOauthQuotaFooterProps> = ({
meta,
inline = false,
isCurrent = false,
}) => {
const {
data: quota,
isFetching: loading,
refetch,
} = useCodexOauthQuota(meta, { enabled: true, autoQuery: isCurrent });
return (
<SubscriptionQuotaView
quota={quota}
loading={loading}
refetch={refetch}
appIdForExpiredHint="codex_oauth"
inline={inline}
/>
);
};
export default CodexOauthQuotaFooter;
+189
View File
@@ -0,0 +1,189 @@
import React from "react";
import { RefreshCw, AlertCircle, Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ProviderMeta } from "@/types";
import { useCopilotQuota } from "@/lib/query/copilot";
import { resolveManagedAccountId } from "@/lib/authBinding";
import { PROVIDER_TYPES } from "@/config/constants";
import {
TierBadge,
utilizationColor,
} from "@/components/SubscriptionQuotaFooter";
interface CopilotQuotaFooterProps {
meta?: ProviderMeta;
inline?: boolean;
/** 是否为当前激活的供应商 */
isCurrent?: boolean;
}
/** 格式化相对时间 */
function formatRelativeTime(
timestamp: number,
now: number,
t: (key: string, options?: { count?: number }) => string,
): string {
const diff = Math.floor((now - timestamp) / 1000);
if (diff < 60) return t("usage.justNow");
if (diff < 3600)
return t("usage.minutesAgo", { count: Math.floor(diff / 60) });
if (diff < 86400)
return t("usage.hoursAgo", { count: Math.floor(diff / 3600) });
return t("usage.daysAgo", { count: Math.floor(diff / 86400) });
}
const CopilotQuotaFooter: React.FC<CopilotQuotaFooterProps> = ({
meta,
inline = false,
isCurrent = false,
}) => {
const { t } = useTranslation();
const accountId = resolveManagedAccountId(
meta,
PROVIDER_TYPES.GITHUB_COPILOT,
);
const {
data: quota,
isFetching: loading,
refetch,
} = useCopilotQuota(accountId, { enabled: true, autoQuery: isCurrent });
const [now, setNow] = React.useState(Date.now());
React.useEffect(() => {
if (!quota?.queriedAt) return;
const interval = setInterval(() => setNow(Date.now()), 30000);
return () => clearInterval(interval);
}, [quota?.queriedAt]);
if (!quota) return null;
// API 调用失败
if (!quota.success) {
if (inline) {
return (
<div className="inline-flex items-center gap-2 text-xs rounded-lg border border-border-default bg-card px-3 py-2 shadow-sm">
<div className="flex items-center gap-1.5 text-red-500 dark:text-red-400">
<AlertCircle size={12} />
<span>{quota.error || t("subscription.queryFailed")}</span>
</div>
<button
onClick={() => refetch()}
disabled={loading}
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0"
title={t("subscription.refresh")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
);
}
return null;
}
const tiers = quota.tiers;
if (tiers.length === 0) return null;
if (inline) {
return (
<div className="flex flex-col items-end gap-1 text-xs whitespace-nowrap flex-shrink-0">
<div className="flex items-center gap-2 justify-end">
{quota.plan && (
<span className="text-[10px] text-muted-foreground/70">
{quota.plan}
</span>
)}
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
<Clock size={10} />
{quota.queriedAt
? formatRelativeTime(quota.queriedAt, now, t)
: t("usage.never", { defaultValue: "Never" })}
</span>
<button
onClick={(e) => {
e.stopPropagation();
refetch();
}}
disabled={loading}
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0 text-muted-foreground"
title={t("subscription.refresh")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
<div className="flex items-center gap-2">
{tiers.map((tier) => (
<TierBadge key={tier.name} tier={tier} t={t} />
))}
</div>
</div>
);
}
// 展开模式
return (
<div className="mt-3 rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
{quota.plan || t("subscription.title")}
</span>
<div className="flex items-center gap-2">
{quota.queriedAt && (
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
<Clock size={10} />
{formatRelativeTime(quota.queriedAt, now, t)}
</span>
)}
<button
onClick={() => refetch()}
disabled={loading}
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50"
title={t("subscription.refresh")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
</div>
<div className="flex flex-col gap-2">
{tiers.map((tier) => {
const label = t("subscription.copilotPremium", {
defaultValue: "Premium",
});
return (
<div key={tier.name} className="flex items-center gap-3 text-xs">
<span
className="text-gray-500 dark:text-gray-400 min-w-0 font-medium"
style={{ width: "25%" }}
>
{label}
</span>
<div className="flex-1 h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
tier.utilization >= 90
? "bg-red-500"
: tier.utilization >= 70
? "bg-orange-500"
: "bg-green-500"
}`}
style={{
width: `${Math.min(tier.utilization, 100)}%`,
}}
/>
</div>
<span
className={`font-semibold tabular-nums ${utilizationColor(tier.utilization)}`}
>
{Math.round(tier.utilization)}%
</span>
</div>
);
})}
</div>
</div>
);
};
export default CopilotQuotaFooter;
+67
View File
@@ -0,0 +1,67 @@
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useSettingsQuery } from "@/lib/query";
import { settingsApi } from "@/lib/api";
/** 首次运行欢迎提示:仅当后端启动阶段保留 firstRunNoticeConfirmed 为空时弹出。 */
export function FirstRunNoticeDialog() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: settings } = useSettingsQuery();
// 后端启动时已经决定好要不要弹:条件不满足的话字段会立即被写成 true,
// 所以前端这里只需要判空即可——完全对齐 streamCheckConfirmed 等既有 flag 的模式。
const isOpen = settings != null && settings.firstRunNoticeConfirmed !== true;
const handleAcknowledge = async () => {
if (!settings) return;
try {
const { webdavSync: _, ...rest } = settings;
await settingsApi.save({ ...rest, firstRunNoticeConfirmed: true });
await queryClient.invalidateQueries({ queryKey: ["settings"] });
} catch (error) {
console.error("Failed to save firstRunNoticeConfirmed:", error);
}
};
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) void handleAcknowledge();
}}
>
<DialogContent className="max-w-md" zIndex="top">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-blue-500" />
{t("firstRunNotice.title")}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 px-6 py-5">
<DialogDescription className="whitespace-pre-line leading-relaxed">
{t("firstRunNotice.bodyDefault")}
</DialogDescription>
<DialogDescription className="whitespace-pre-line leading-relaxed">
{t("firstRunNotice.bodyOfficial")}
</DialogDescription>
</div>
<DialogFooter>
<Button onClick={handleAcknowledge}>
{t("firstRunNotice.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+55 -9
View File
@@ -3,11 +3,21 @@ import { RefreshCw, AlertCircle, Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { AppId } from "@/lib/api";
import { useSubscriptionQuota } from "@/lib/query/subscription";
import type { QuotaTier } from "@/types/subscription";
import type { QuotaTier, SubscriptionQuota } from "@/types/subscription";
interface SubscriptionQuotaFooterProps {
appId: AppId;
inline?: boolean;
isCurrent?: boolean;
}
interface SubscriptionQuotaViewProps {
quota: SubscriptionQuota | undefined;
loading: boolean;
refetch: () => void;
/** 用于 `subscription.expiredHint` 的 {tool} 插值;解耦了 hook 的 appId */
appIdForExpiredHint: string;
inline?: boolean;
}
/** 已知 tier 名称的显示映射(官方订阅 + Token Plan 共用) */
@@ -22,6 +32,8 @@ export const TIER_I18N_KEYS: Record<string, string> = {
gemini_flash_lite: "subscription.geminiFlashLite",
// Token Planfive_hour 已在上方官方映射中)
weekly_limit: "subscription.weeklyLimit",
// GitHub Copilot
premium: "subscription.copilotPremium",
};
/** 根据使用百分比返回颜色 class */
@@ -76,16 +88,22 @@ function formatRelativeTime(
return t("usage.daysAgo", { count: Math.floor(diff / 86400) });
}
const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
appId,
/**
* SubscriptionQuota 5 not_found / parse_error /
* expired / API / inline / expanded
*
* hook 便
* - `SubscriptionQuotaFooter`CLI by appId
* - `CodexOauthQuotaFooter`cc-switch OAuth by ChatGPT account
*/
export const SubscriptionQuotaView: React.FC<SubscriptionQuotaViewProps> = ({
quota,
loading,
refetch,
appIdForExpiredHint,
inline = false,
}) => {
const { t } = useTranslation();
const {
data: quota,
isFetching: loading,
refetch,
} = useSubscriptionQuota(appId, true);
// 定期更新相对时间显示
const [now, setNow] = React.useState(Date.now());
@@ -129,7 +147,7 @@ const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
<div>
<span className="font-medium">{t("subscription.expired")}</span>
<span className="ml-2 text-amber-500/70 dark:text-amber-400/70">
{t("subscription.expiredHint", { tool: appId })}
{t("subscription.expiredHint", { tool: appIdForExpiredHint })}
</span>
</div>
</div>
@@ -362,4 +380,32 @@ const TierBar: React.FC<{
);
};
/**
* CLI wrapper useSubscriptionQuota(appId)
* SubscriptionQuotaView props/
*/
const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
appId,
inline = false,
isCurrent = false,
}) => {
const {
data: quota,
isFetching: loading,
refetch,
} = useSubscriptionQuota(appId, isCurrent, isCurrent);
if (!isCurrent) return null;
return (
<SubscriptionQuotaView
quota={quota}
loading={loading}
refetch={refetch}
appIdForExpiredHint={appId}
inline={inline}
/>
);
};
export default SubscriptionQuotaFooter;
+115 -7
View File
@@ -98,6 +98,9 @@ const generatePresetTemplates = (
// Coding Plan 模板不需要脚本,使用专用 Rust 查询
[TEMPLATE_TYPES.TOKEN_PLAN]: "",
// 官方余额查询模板不需要脚本,使用专用 Rust 查询
[TEMPLATE_TYPES.BALANCE]: "",
});
// 模板名称国际化键映射
@@ -107,6 +110,7 @@ const TEMPLATE_NAME_KEYS: Record<string, string> = {
[TEMPLATE_TYPES.NEW_API]: "usageScript.templateNewAPI",
[TEMPLATE_TYPES.GITHUB_COPILOT]: "usageScript.templateCopilot",
[TEMPLATE_TYPES.TOKEN_PLAN]: "usageScript.templateTokenPlan",
[TEMPLATE_TYPES.BALANCE]: "usageScript.templateBalance",
};
/** Coding Plan 供应商选项 */
@@ -124,6 +128,25 @@ const TOKEN_PLAN_PROVIDERS = [
},
] as const;
/** 官方余额查询供应商检测 */
const BALANCE_PROVIDERS = [
{ id: "deepseek", label: "DeepSeek", pattern: /api\.deepseek\.com/i },
{ id: "stepfun", label: "StepFun", pattern: /api\.stepfun\.(ai|com)/i },
{
id: "siliconflow",
label: "SiliconFlow",
pattern: /api\.siliconflow\.(cn|com)/i,
},
{ id: "openrouter", label: "OpenRouter", pattern: /openrouter\.ai/i },
{ id: "novita", label: "Novita AI", pattern: /api\.novita\.ai/i },
] as const;
/** 根据 Base URL 自动检测余额查询供应商 */
function detectBalanceProvider(baseUrl: string | undefined): boolean {
if (!baseUrl) return false;
return BALANCE_PROVIDERS.some((bp) => bp.pattern.test(baseUrl));
}
/** 根据 Base URL 自动检测 Coding Plan 供应商 */
function detectTokenPlanProvider(baseUrl: string | undefined): string | null {
if (!baseUrl) return null;
@@ -215,15 +238,28 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
language: "javascript" as const,
code: "",
timeout: 10,
autoQueryInterval: 5,
codingPlanProvider: autoDetected,
};
}
// 新配置:如果 URL 匹配官方余额查询供应商,自动初始化
if (detectBalanceProvider(providerCredentials.baseUrl)) {
return {
enabled: false,
language: "javascript" as const,
code: "",
timeout: 10,
autoQueryInterval: 5,
};
}
return {
enabled: false,
language: "javascript" as const,
code: PRESET_TEMPLATES[TEMPLATE_TYPES.GENERAL],
timeout: 10,
autoQueryInterval: 5,
};
});
@@ -300,6 +336,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
if (detectTokenPlanProvider(providerCredentials.baseUrl)) {
return TEMPLATE_TYPES.TOKEN_PLAN;
}
// 新配置:如果 URL 匹配官方余额查询供应商,自动选择 Balance 模板
if (detectBalanceProvider(providerCredentials.baseUrl)) {
return TEMPLATE_TYPES.BALANCE;
}
// 默认使用 GENERAL(与默认代码模板一致)
return TEMPLATE_TYPES.GENERAL;
},
@@ -331,10 +371,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
};
const handleSave = () => {
// CopilotCoding Plan 模板不需要脚本验证
// CopilotCoding Plan、Balance 模板不需要脚本验证
if (
selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT &&
selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN
selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN &&
selectedTemplate !== TEMPLATE_TYPES.BALANCE
) {
if (script.enabled && !script.code.trim()) {
toast.error(t("usageScript.scriptEmpty"));
@@ -354,6 +395,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
| "newapi"
| "github_copilot"
| "token_plan"
| "balance"
| undefined,
};
onSave(scriptWithTemplate);
@@ -363,6 +405,37 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const handleTest = async () => {
setTesting(true);
try {
// 官方余额查询模板使用专用 API
if (selectedTemplate === TEMPLATE_TYPES.BALANCE) {
const config = provider.settingsConfig as Record<string, any>;
const baseUrl: string = config?.env?.ANTHROPIC_BASE_URL ?? "";
const apiKey: string =
config?.env?.ANTHROPIC_AUTH_TOKEN ??
config?.env?.ANTHROPIC_API_KEY ??
"";
const { subscriptionApi } = await import("@/lib/api/subscription");
const result = await subscriptionApi.getBalance(baseUrl, apiKey);
if (result.success && result.data && result.data.length > 0) {
const summary = result.data
.map((d) => {
const name = d.planName ? `[${d.planName}] ` : "";
return `${name}${t("usage.remaining")} ${d.remaining?.toFixed(2)} ${d.unit || ""}`;
})
.join(", ");
toast.success(`${t("usageScript.testSuccess")}${summary}`, {
duration: 3000,
closeButton: true,
});
queryClient.setQueryData(["usage", provider.id, appId], result);
} else {
toast.error(
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
{ duration: 5000 },
);
}
return;
}
// Coding Plan 模板使用专用 API
if (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN) {
const config = provider.settingsConfig as Record<string, any>;
@@ -558,6 +631,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
codingPlanProvider:
script.codingPlanProvider || autoDetected || "kimi",
});
} else if (presetName === TEMPLATE_TYPES.BALANCE) {
// 官方余额查询模板不需要脚本,使用 Rust 原生查询
setScript({
...script,
code: "",
apiKey: undefined,
baseUrl: undefined,
accessToken: undefined,
userId: undefined,
});
}
setSelectedTemplate(presetName);
}
@@ -746,6 +829,27 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
)}
{/* 官方余额查询模式:自动提示 */}
{selectedTemplate === TEMPLATE_TYPES.BALANCE && (
<div className="space-y-3 border-t border-white/10 pt-3">
<p className="text-sm text-muted-foreground">
{t("usageScript.balanceHint")}
</p>
<div className="flex gap-2 flex-wrap">
{BALANCE_PROVIDERS.filter((bp) =>
bp.pattern.test(providerCredentials.baseUrl || ""),
).map((bp) => (
<span
key={bp.id}
className="inline-flex items-center px-2.5 py-1 rounded-md bg-primary/10 text-primary text-xs font-medium"
>
{bp.label}
</span>
))}
</div>
</div>
)}
{/* Coding Plan 模式:供应商选择 */}
{selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && (
<div className="space-y-3 border-t border-white/10 pt-3">
@@ -960,7 +1064,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
onChange={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
timeout:
e.target.value === ""
? ("" as unknown as number)
: Number(e.target.value),
})
}
onBlur={(e) =>
@@ -984,14 +1091,15 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
min={0}
max={1440}
value={
script.autoQueryInterval ?? script.autoIntervalMinutes ?? 0
script.autoQueryInterval ?? script.autoIntervalMinutes ?? 5
}
onChange={(e) =>
setScript({
...script,
autoQueryInterval: validateAndClampInterval(
e.target.value,
),
autoQueryInterval:
e.target.value === ""
? ("" as unknown as number)
: Number(e.target.value),
})
}
onBlur={(e) =>
+23 -15
View File
@@ -3,7 +3,12 @@ import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { isWindows, isLinux } from "@/lib/platform";
import {
isWindows,
isLinux,
DRAG_REGION_ATTR,
DRAG_REGION_STYLE,
} from "@/lib/platform";
import { isTextEditableTarget } from "@/utils/domUtils";
interface FullScreenPanelProps {
@@ -82,24 +87,27 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
className="fixed inset-0 z-[60] flex flex-col"
style={{ backgroundColor: "hsl(var(--background))" }}
>
{/* Drag region - match App.tsx */}
<div
data-tauri-drag-region
style={
{
WebkitAppRegion: "drag",
height: DRAG_BAR_HEIGHT,
} as React.CSSProperties
}
/>
{/* Drag region - match App.tsx. Linux DRAG_BAR_HEIGHT=0
macOS 28px */}
{DRAG_BAR_HEIGHT > 0 && (
<div
data-tauri-drag-region
style={
{
WebkitAppRegion: "drag",
height: DRAG_BAR_HEIGHT,
} as React.CSSProperties
}
/>
)}
{/* Header - match App.tsx */}
<div
className="flex-shrink-0 flex items-center"
data-tauri-drag-region
{...DRAG_REGION_ATTR}
style={
{
WebkitAppRegion: "drag",
...DRAG_REGION_STYLE,
backgroundColor: "hsl(var(--background))",
height: HEADER_HEIGHT,
} as React.CSSProperties
@@ -107,8 +115,8 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
>
<div
className="px-6 w-full flex items-center gap-4"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
{...DRAG_REGION_ATTR}
style={{ ...DRAG_REGION_STYLE } as React.CSSProperties}
>
<Button
type="button"
+30 -27
View File
@@ -246,34 +246,37 @@ export function ProviderActions({
<Copy className="h-4 w-4" />
</Button>
{onTest && (
<Button
size="icon"
variant="ghost"
onClick={onTest}
disabled={isTesting}
title={t("modelTest.testProvider", "测试模型")}
className={iconButtonClass}
>
{isTesting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube2 className="h-4 w-4" />
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
onClick={onTest || undefined}
disabled={isTesting}
title={t("modelTest.testProvider", "测试模型")}
className={cn(
iconButtonClass,
!onTest && "opacity-40 cursor-not-allowed text-muted-foreground",
)}
>
{isTesting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube2 className="h-4 w-4" />
)}
</Button>
{onConfigureUsage && (
<Button
size="icon"
variant="ghost"
onClick={onConfigureUsage}
title={t("provider.configureUsage")}
className={iconButtonClass}
>
<BarChart3 className="h-4 w-4" />
</Button>
)}
<Button
size="icon"
variant="ghost"
onClick={onConfigureUsage || undefined}
title={t("provider.configureUsage")}
className={cn(
iconButtonClass,
!onConfigureUsage &&
"opacity-40 cursor-not-allowed text-muted-foreground",
)}
>
<BarChart3 className="h-4 w-4" />
</Button>
{onOpenTerminal && (
<Button
+32 -4
View File
@@ -12,6 +12,9 @@ import { ProviderActions } from "@/components/providers/ProviderActions";
import { ProviderIcon } from "@/components/ProviderIcon";
import UsageFooter from "@/components/UsageFooter";
import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter";
import CopilotQuotaFooter from "@/components/CopilotQuotaFooter";
import CodexOauthQuotaFooter from "@/components/CodexOauthQuotaFooter";
import { PROVIDER_TYPES } from "@/config/constants";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
@@ -172,6 +175,11 @@ export function ProviderCard({
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
const isOfficial = isOfficialProvider(provider, appId);
const isCopilot =
provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT ||
provider.meta?.usage_script?.templateType === "github_copilot";
const isCodexOauth =
provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH;
// 获取用量数据以判断是否有多套餐
// 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent
@@ -348,8 +356,24 @@ export function ProviderCard({
<div className="flex items-center ml-auto min-w-0 gap-3">
<div className="ml-auto">
<div className="flex items-center gap-1">
{isOfficial ? (
<SubscriptionQuotaFooter appId={appId} inline={true} />
{isCopilot ? (
<CopilotQuotaFooter
meta={provider.meta}
inline={true}
isCurrent={isCurrent}
/>
) : isCodexOauth ? (
<CodexOauthQuotaFooter
meta={provider.meta}
inline={true}
isCurrent={isCurrent}
/>
) : isOfficial ? (
<SubscriptionQuotaFooter
appId={appId}
inline={true}
isCurrent={isCurrent}
/>
) : hasMultiplePlans ? (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<span className="font-medium">
@@ -405,10 +429,14 @@ export function ProviderCard({
onEdit={() => onEdit(provider)}
onDuplicate={() => onDuplicate(provider)}
onTest={
onTest && !isOfficial ? () => onTest(provider) : undefined
onTest && !isOfficial && !isCopilot && !isCodexOauth
? () => onTest(provider)
: undefined
}
onConfigureUsage={
isOfficial ? undefined : () => onConfigureUsage(provider)
isOfficial || isCopilot || isCodexOauth
? undefined
: () => onConfigureUsage(provider)
}
onDelete={() => onDelete(provider)}
onRemoveFromConfig={

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