mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-31 19:22:15 +08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 483256ad92 | |||
| 3c1154bed9 | |||
| c0ff89b9b2 | |||
| 3b9d059343 | |||
| 6b13d01825 | |||
| f5f4281d06 | |||
| 56fb46c093 | |||
| 30409878bd | |||
| bfb767ae17 | |||
| dbb265956e | |||
| 87b0e3fb85 | |||
| b33d300d0b | |||
| 245d180c25 | |||
| cfa90f396a | |||
| 19bf236e58 | |||
| a443eae95a | |||
| 6dbb944b54 | |||
| cd17912f04 | |||
| 134bdc0e65 | |||
| 35486afdda | |||
| c98913df41 | |||
| 993077c60c | |||
| 12b972a66e | |||
| ff3bc242cc |
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
os: [ubuntu-22.04, windows-latest, macos-latest]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
|
||||
- name: Checkout scripts
|
||||
if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true'
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
|
||||
|
||||
@@ -5,6 +5,73 @@ All notable changes to CC Switch will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.19.0] - 2026-07-30
|
||||
|
||||
Development since v3.18.0 is headlined by a security-hardening wave and a proxy correctness fix with immediate field impact. On the security side (#5811 and follow-ups): skill installs from GitHub repositories are hardened against zip-slip and repository-coordinate path traversal with hard archive limits; a Gemini common-config credential leak is closed and a one-time startup cleanup scrubs keys that already leaked into other providers' configs; imported SQL backups now run under a SQLite authorizer that denies `ATTACH` and every other statement that can reach outside the import database; common-config snippet handling no longer follows `__proto__` into the global prototype; terminal launch escapes project paths with POSIX single quotes so a directory name can no longer inject commands; and `ccswitch://` import confirmations show every argument, env var, URL, and the complete usage-script body — nothing hidden by truncation, nor by a Base64 variant the dialog could not decode, credential-shaped values masked — flag risky values, and import usage scripts disabled by default. On the proxy side, tool-result images are lifted out of stringified tool text and re-emitted as native media on every conversion bridge, ending the ~9,000× token inflation that pushed Codex sessions past their context window after a few screenshots (#4465, #5663). Usage statistics gain opt-in automatic price sync from models.dev with a persistent local override file (#5734), plus coverage of Grok CLI's official OAuth mode imported from session logs and SuperGrok subscription quota on provider cards. Rounding it out: in-app updates are served from a Cloudflare R2 mirror at `dl.ccswitch.io` with GitHub as fallback, Codex usage import reuses parsed parent rollouts across forked sessions (#5626), preset defaults advance to Claude Opus 5, GPT-5.6 Sol, and Gemini 3.6 Flash, the OpenClaw Kimi For Coding base URL is corrected, and the GPT-in-Claude-Code routing guide is now available in English and Japanese.
|
||||
|
||||
**Stats**: 38 commits | 132 files changed | +14,926 insertions | -1,415 deletions
|
||||
|
||||
### Added
|
||||
|
||||
- **Automatic models.dev Pricing Sync**: The Usage panel's pricing section gains an opt-in (off by default) "Automatic models.dev pricing sync" card: once enabled, CC Switch refreshes the selected models' prices from models.dev at launch, at most once every six hours, overwriting built-in or hand-edited prices that share the same normalized model ID — enabling shows a confirmation that spells this out. A "Choose models" dialog exposes the full models.dev catalog with search and filtering, alongside an "automatically include common models" option that tracks the most recent Claude, GPT, Gemini, Grok, DeepSeek, Qwen, MiMo, LongCat, Kimi, MiniMax, and GLM releases (up to six per family, individually excludable). The card shows the last sync time and last error, offers "Sync now", and can open or reload the local pricing file. From this version onward, manual price overrides and deletions are also recorded in a human-editable `~/.cc-switch/model-pricing.json` next to the database and re-applied at every startup, so they survive database rebuilds — and deleting a built-in price finally sticks across restarts via a tombstone instead of being re-seeded. The file starts empty and deliberately does not back-fill from the existing pricing table, so price edits made before this version still live only in the database until you re-save them. Whenever a sync actually changes a price, historical usage rows that never got a cost (zero or missing) are backfilled at the new price — rows already carrying a computed cost keep their original figures; a failed or offline fetch never blocks startup. Non-text and deprecated models (audio, image, video, embedding, moderation, realtime, TTS, transcription) are filtered out of the models.dev list, which also cleans up the existing manual price picker. (#5734)
|
||||
- **Grok Build Usage Tracking in Official OAuth Mode**: Usage from Grok CLI's official OAuth mode — which cannot be routed through the local proxy, since Grok uses an empty config as the mode switch — is now recorded in the usage dashboard. Per-turn usage is imported from the `turn_completed` events in `~/.grok/sessions` (and archived sessions) `updates.jsonl` files as part of the regular session-log sync; costs prefer the exact figure the CLI reports and fall back to local pricing, with a `grok-4.5-build` row seeded at $2/$6 per million input/output tokens and $0.30 cache read. Imported rows are keyed on the upstream per-turn ID so rewinding a session cannot double count, and a settle window plus a recent-proxy-activity check prevents the same traffic being counted twice when routing is also in use. The rows appear under the provider name "Grok Build (Session)", the app filter gains a Grok Build option, and the data-source breakdown gains a "Grok Build Session" entry with its own icon, in all four languages.
|
||||
- **SuperGrok Subscription Quota on Provider Cards**: Grok Build providers with category `official` now show SuperGrok subscription usage directly on the provider card, alongside the existing Claude Code, Codex, and Gemini official-subscription footers: CC Switch reads the Grok CLI's OAuth credentials from `~/.grok/auth.json` and queries the grok.com billing endpoint for the credit window's used percentage and reset time, labelling the window Weekly or Monthly when the reset distance identifies it, and otherwise a new "Credits" tier that appears as a `c` group in the tray usage summary. Transient network problems keep the last good reading and retry instead of blanking the footer; an expired token surfaces a hint to run `grok login` again. Managed xAI OAuth (SuperGrok) providers in the Claude Code, Claude Desktop, and Codex sections render the same quota automatically from the account bound to the provider and hide their usage-script entry since the quota is built in. Note that Grok Build "officialness" is now decided solely by the provider's `category` field rather than by inspecting config contents.
|
||||
- **Built-in Pricing for Claude Opus 5**: `claude-opus-5` joins the built-in pricing table at $5 / $25 per million input/output tokens, with $0.50 cache read and $6.25 cache write, so its usage no longer reports zero cost without a manual entry. Seeded insert-if-absent, so any price you already customized is untouched. (Opus 5 fast mode is billed through a separate path and deliberately not seeded.)
|
||||
- **Preset Catalog Updates**: A6API — a token aggregator that lists several upstreams for the same model and routes to the cheaper, more stable one — joins the sponsor presets across all eight apps; the PackyCode preset gains three backup endpoints selectable in the address manager and latency test (on the five preset families that support endpoint candidates); the AICoding partner preset returns across seven app types; and sponsor ordering was re-synced so the in-app list matches the READMEs.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Default Preset Models Upgraded to Claude Opus 5, GPT-5.6 Sol, and Gemini 3.6 Flash**: Built-in presets now default to the current model generation: `claude-opus-5` replaces `claude-opus-4-8` in all three naming forms, `gpt-5.6-sol` replaces `gpt-5.5` and bare `gpt-5.6`, and `gemini-3.6-flash` replaces `gemini-3.5-flash`. The same IDs were carried into everything that mirrors the presets — universal/NewAPI defaults, the Codex custom `config.toml` template, agent and category recommendations, form placeholders, and all four locales — and `gemini-3.6-flash` was added to the built-in pricing table ($1.50 / $7.50 per million input/output, $0.15 cache read) so it costs correctly out of the box. The Code0 and Qiniu Gemini presets, still pinned to `gemini-3.1-pro-preview`, were brought onto the same 3.6 Flash baseline — a deliberate tier change, since there is no 3.6 Pro release and 3.5 Pro remains limited to partner testing.
|
||||
- **In-App Updates Are Served From the ccswitch.io Mirror**: The updater now queries `https://dl.ccswitch.io/latest.json` — a Cloudflare R2 mirror of the release manifest — before falling back to GitHub Releases, so checking for and downloading updates no longer depends on GitHub being reachable, which is slow or blocked for many users. The mirrored manifest points every platform's download at the same bucket while minisign signatures are left untouched: they cover file contents rather than URLs, so each downloaded artifact is still verified against the public key baked into the app and the mirror itself remains untrusted. Publishing is handled by a release-gated sync workflow that only rewrites the root manifest when its tag really is GitHub's `releases/latest`, so the mirror can never push users backwards to an older version.
|
||||
- **Faster Codex Usage Import for Forked Sessions**: Importing and rebuilding Codex usage statistics no longer re-reads the same parent rollout file once per fork point: each parent `~/.codex/sessions/*.jsonl` rollout is parsed once into an in-memory token timeline shared by every child session forked from it, with each child's cutoff resolved by an in-memory filter instead of another full-file scan. The cached timeline is validated against a file identity stamp (mtime, size, plus device/inode or Windows file ID), so an appended, rotated, or replaced parent file is re-read rather than served stale. The gain scales with fork density: heavily forked histories see a large reduction in redundant parsing, while a history with few forks is essentially unchanged — import results are byte-identical either way. (#5626)
|
||||
- **Toolbar App Switcher Is Now Icon-Only**: The app switcher buttons no longer render text labels next to the icons — with eight managed apps the labels were collapsed by the overflow detection most of the time anyway, so the ResizeObserver-based auto-compact mechanism was removed and the switcher always shows icons. App names remain discoverable by hovering (tooltip) and stay accessible to screen readers via `aria-label`.
|
||||
- **Sponsor Domains and Referral Links Refreshed**: Several sponsor providers moved to new domains, and their preset addresses, endpoint candidates, referral links, and README rows were updated accordingly (PackyCode → `www.packyapi.ai`, RightCode → `www.rightapi.ai`, ClaudeAPI → `www.apito.ai`, APINebula → `apinebula.ai`, AICodeMirror → `.ai`, AICoding → `.inc`, AIGoCode → `.app`), with two dead backup endpoints dropped along the way. Existing providers keep the base URL stored in the database — see Upgrade notes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Tool-Result Images No Longer Blow Up Context Through the Proxy**: When a client read an image through a tool call — Codex's `view_image`, or any MCP tool that returns a picture — the proxy's protocol conversion serialized the entire image block into the text of the tool message, so the upstream tokenized the base64 payload as prose: roughly 9,000× inflation, where a single 113 KB PNG cost over 100,000 prompt tokens, and because Codex replays the whole history every turn, two or three screenshots were enough to push a session past the context limit and wedge it behind repeated 400s (#4465, #5663). The proxy now lifts media payloads out of tool results and re-emits them as native content on every conversion bridge — images everywhere, files and audio where the target protocol supports them: the two Chat bridges (Claude→Chat and Codex Responses→Chat) carry image, file, and audio parts, leaving a short marker in the tool message and attaching the media as a synthetic user message right after the tool batch, while Claude→Responses restores native `input_image` parts, Codex/GrokBuild→Anthropic rebuilds proper Anthropic image blocks, and Claude→Gemini uses multimodal `functionResponse.parts` on Gemini 3 (with `inlineData` parts for older models), accepting only inline base64 images. Detection covers typed Responses blocks, Anthropic `source` blocks, MCP `data`+`mimeType` results, and whole-string image data URLs, reached through arrays and nested `content` wrappers including inside JSON-encoded tool outputs; once an output is identified as media-bearing, residual data URLs and raw base64 blobs anywhere inside it are collapsed to a placeholder — bare base64 on its own is never treated as media. Tool results with no media keep their exact previous byte-for-byte representation on all bridges, so prompt-cache prefixes are unaffected, and the emitted media parts carry no `cache_control` markers that strict upstreams such as GLM and Qwen reject. An end-to-end run against Kimi K3 held a replayed turn at roughly 12k input tokens with a 99% cache hit, versus more than 85k of base64 text per replay before.
|
||||
- **Unsupported-Image Fallback Now Reaches Images Buried in Tool Results**: The Unsupported Image Fallback setting replaces image blocks with a marker when a provider is text-only or the upstream rejects images, but it could only see images that were still structured blocks — tool-result images already flattened into base64 text were invisible to it, so a text-only upstream failed outright with no way to recover. The media sanitizer now detects and strips media inside tool outputs symmetrically on every path, so both the pre-send strip and the retry-after-rejection path can heal these turns; and because that check now looks inside tool results, a regression test pins that the reactive retry still fires only on genuine modality rejections — a context-length 400 is left alone rather than retried.
|
||||
- **Grok Build Costs Overstated by the Cost Backfill**: The routine that recomputes missing costs treated only Codex and Gemini as providers whose reported input token counts already include cache reads. Grok Build uses the same convention, so any Grok Build row repaired by the backfill was priced on the full input count with cache reads billed a second time. The cache-inclusive provider set is now defined in one place and shared by the routing logger, the cost calculator, and the backfill, so the three can no longer disagree.
|
||||
- **Hand-Edited Config Files No Longer Crash the App or Silently Swallow Edits**: A `~/.codex/config.toml` in which `mcp_servers` exists but is not a table made MCP sync panic mid-switch, after the database and live-config writes had already committed — leaving a half-applied switch; non-table values are now normalised to an empty table with a logged warning, in both the Codex and GrokBuild writers. Inline-table forms (valid TOML) had the mirrored problem: MCP removal silently did nothing while the UI reported success, and a `base_url` edit was written at a level Codex never reads — both now handled. An `opencode.json` whose root, `provider`, or `mcp` section is an array or scalar no longer panics; such files are rejected with an error rather than rebuilt, so the user's own settings are not destroyed. (#5811)
|
||||
- **Proxy Transforms Survive Malformed Upstream Responses**: A non-object `message` or `content_block` in an Anthropic SSE stream, or a buffered body that is a top-level JSON array or scalar (what a gateway that ignores `stream: true` returns), could abort the local proxy instead of producing an error; the stream now bails out with a proper failed event. A malformed `content_block` header is additionally recovered as a text block — sanitising it to an empty object stopped the panic but silently dropped everything that followed — so the deltas after a garbled header come through, with a warning logged. (#5811)
|
||||
- **OpenClaw Kimi For Coding Base URL Corrected**: The OpenClaw preset pointed at `https://api.kimi.com/v1`, the general platform endpoint, rather than the endpoint the Kimi For Coding subscription is served from, so the preset did not work with a coding-plan key. The base URL is now `https://api.kimi.com/coding/v1`, with the form placeholder and default updated to match.
|
||||
|
||||
### Security
|
||||
|
||||
- **Gemini Common Config No Longer Leaks Credentials, With a One-Time Cleanup**: The Gemini common-config extractor stripped only `GEMINI_API_KEY` and `GOOGLE_GEMINI_BASE_URL` from the shared snippet and copied every other `env` entry verbatim — but `GOOGLE_API_KEY` is a first-class Gemini credential, so one account's key (along with anything else credential-shaped) was deep-merged into every other Gemini provider using the common config and sent to that provider's base URL, which may be a third-party relay. The extractor now skips any key matching the same credential-pattern matcher the Claude extractor already uses, and the frontend snippet validator was aligned so a hand-edited snippet cannot put a key straight back. Because a Gemini snippet is never re-extracted once it exists, a one-time startup cleanup also scrubs credentials that already leaked — from the snippet, from every provider they were merged into, and from `~/.gemini/.env`, matching by key *and* value so a provider's own same-named key with a different value survives. (#5811)
|
||||
- **Skill Repository Install Hardening**: Installing or browsing skills from a GitHub repository could write files outside the intended directory: archive entries were joined onto the destination path without normalisation (zip-slip), and repository coordinates were never validated, so a branch like `../../../releases/download/v1/evil` retargeted the download at an arbitrary release asset — and since skill repositories can arrive via an untrusted `ccswitch://` deeplink and are enabled by default, merely opening the Skills panel could trigger the download. Skill `directory` values from restored backups, sync snapshots, and import-from-apps were likewise joined raw, letting uninstall `remove_dir_all` outside the managed skills directory. Every sink now validates the directory name, repository owner/name/branch are whitelisted at the single download convergence point, and extraction is capped (10,000 entries, 512 MB written, 128 MB download, 4 KB symlink targets, self-referential symlinks rejected), with the new errors localized in all four languages. (#5811)
|
||||
- **Deep-Link Import Confirmations Show the Whole Payload (Credentials Masked) and Flag Risky Values**: The `ccswitch://` MCP import confirmation previously rendered a single truncated `Command:` line and showed neither `args`, `url`, nor `env` — so a link carrying `command: "sh"` with `args: ["-c", "curl …|sh"]` and an `LD_PRELOAD` variable displayed as a harmless `sh`, yet was written to the live MCP files on confirm. The dialog now renders command, each argument, URL, and env vars on separate lines that wrap instead of truncating, so nothing is clipped out of view (env values whose name contains TOKEN, KEY, SECRET, or PASSWORD are shown masked, as a prefix plus asterisks), and highlights values that warrant a second look: shell interpreters invoked with inline-command flags, env vars that change how a process loads code (`LD_*`, `DYLD_*`, `NODE_OPTIONS`, `PYTHONPATH`, `PATH`, proxy vars), and endpoints pointing at loopback, private-range, or cloud-metadata addresses. The markers are advisory and never block an import — a local Ollama endpoint is ordinary usage. The provider confirmation gains the same treatment, and the "will be written immediately" warning is now shown unconditionally instead of being gated on a link-controlled field.
|
||||
- **Usage Scripts From Deep Links Arrive Disabled and Their Code Is Shown**: A usage-query script imported through a deep link is JavaScript that runs on every usage query, and it was previously possible to acquire one without ever seeing it: the backend treated the mere presence of code as a decision to enable it, and the dialog showed only an Enabled/Disabled badge. Scripts now default to disabled — a link must explicitly carry `usageEnabled=true` — and the confirmation displays the complete decoded script in a scrollable code block with a warning that it will execute once enabled. Decoding failures fall back to displaying the raw payload, so a malformed script can never read as "there is no script".
|
||||
- **URL-Safe Base64 Payloads Could Blank the Confirmation Dialog Entirely**: The two entries above make the confirmation show more; this one is about the confirmation showing *nothing*. The backend accepts four Base64 variants including the URL-safe alphabet (RFC 4648 §5), while the frontend's `atob` accepts only the standard one and, on failure, returned its input unchanged instead of signalling an error — so for the same payload the backend decoded and imported successfully while the frontend held an undecodable string. Usage scripts and system prompts rendered as opaque Base64, and MCP configs were worse: `JSON.parse` threw, the component swallowed it, and the dialog rendered **"0 servers" with an empty list while the backend wrote the real entry into the live MCP files**. Substituting a single `/` for `_` in the payload was enough — the dialog went blank, the import stayed fully functional, and the complete-payload display added above was defeated along with it. The frontend decoder now normalises the URL-safe alphabet before decoding, so the confirmation always matches what will be imported, and the shared decoder gained its first unit tests — with in-test preconditions asserting each sample really exercises the URL-safe path rather than happening to encode identically under both alphabets. Affected every release from v3.8.0 onward; see Upgrade notes for what to check.
|
||||
- **SQL Import Rejects Statements That Reach Outside the Import Database**: Importing a database backup validated only the file's header comment and then handed the entire text to `execute_batch`, so a crafted backup could `ATTACH DATABASE` and create a SQLite file anywhere the user can write — a side effect that landed even when the import as a whole failed, and one reachable through WebDAV/S3 sync snapshots too. A SQLite authorizer is now installed for the duration of the external batch only, denying `ATTACH`/`DETACH`, `VACUUM`, virtual-table creation, and any action SQLite reports as unknown (fail-closed), with PRAGMAs limited to the two the exporter actually emits.
|
||||
- **Prototype Pollution in Common-Config Snippet Handling**: The three walkers that apply, remove, and compare common-config snippets all followed `__proto__` into the global `Object.prototype`, so a malicious snippet — reachable via WebDAV/S3 sync, since the `settings` table is overwritten by whatever the remote sends — could write attacker-chosen values onto the global prototype the moment a provider form was opened. All three walkers now skip `__proto__`, `constructor`, and `prototype`; the "common config applied" comparison also now requires own properties, fixing a visible quirk where `{"__proto__":{}}` counted as a subset of every config.
|
||||
- **Command Substitution via Project Directory Names on Terminal Launch**: Resuming a session in an external terminal built the `cd` line with double-quote escaping, which stops spaces but not `$(…)`, backticks, or `$VAR` — and the value is the real project path recorded in the CLI's session history, which may legitimately contain those characters. Any project folder named that way executed the embedded command in the user's terminal on Resume. The three launchers that build a shell line — Terminal.app, iTerm, and kitty — now use POSIX single-quote escaping, where nothing expands, including through the AppleScript quoting layer; Ghostty, WezTerm/Kaku, and Alacritty were already safe because they pass the directory as its own argument.
|
||||
- **GrokBuild Credential Resolution No Longer Substitutes Environment Secrets**: GrokBuild credential extraction fell back to the process-wide `XAI_API_KEY` whenever the configured `env_key` variable was unset, silently sending a different account's key to whatever base URL the config pointed at; credentials now come only from an explicit inline `api_key` or the exact variable named by `env_key`. Deep-link import no longer resolves environment variables into a plaintext `api_key`, and a link whose only credential is an `env_key` name is rejected with a message to add the provider manually. As a side effect, base-URL resolution was decoupled from credential resolution, fixing a macOS quirk where a missing credential also blanked the base URL shown in the UI and used by usage scripts. (#5811)
|
||||
|
||||
### Docs
|
||||
|
||||
- **Claude Code GPT Routing Guide Now in English and Japanese**: The local-routing guide for running GPT-family models inside Claude Code, previously Chinese-only, is now fully ported to English and Japanese, covering both integration paths end to end (a third-party OpenAI Responses gateway with an API key, and a ChatGPT Plus/Pro subscription via the Codex device-code OAuth flow). Both routing guides were retitled to name the models they are about — "Using GPT Models in Claude Code with CC Switch" and "Using Claude Models in Codex with CC Switch" — and every cross-link, including the v3.18.0 release notes in all three languages, now points readers at their own-language copy.
|
||||
- **Corrected the Deep-Link `usageEnabled` Default in the User Manual**: The deep-link reference in all three user manuals claimed `usageEnabled` defaults to `true`; it actually defaults to `false`, matching the importer. The manuals now state the correct default and document that the script body is shown in full before import and stays disabled until explicitly enabled.
|
||||
- **Security Policy Documents the Threat Model and Reporting Scope**: `SECURITY.md` gained a bilingual threat model with explicit in-scope and out-of-scope lists, so reports are triaged by who controls the input rather than which API a value eventually reaches: the bundled WebView renderer is declared a trusted component (with the four independently checkable facts backing that and the triggers that would void it), while deep-link payloads, WebDAV/S3 restore data, imported files, upstream API responses, and inbound requests to the local proxy are all named as untrusted and in scope. The same reasoning is recorded as a doc comment on the terminal-launch command.
|
||||
|
||||
### Internal
|
||||
|
||||
- **Release Assets Mirrored to Cloudflare R2 for ccswitch.io Downloads**: Release automation mirrors every installer to an R2 bucket behind `dl.ccswitch.io` and generates the manifest the ccswitch.io download page consumes — per-file SHA-256 checksums, sizes, and the release's real publish date — with immutable caching for versioned assets, a five-minute TTL on the root manifest, and pruning beyond the five most recent versions. Forks without the R2 credentials skip the sync entirely.
|
||||
- **Skill ZIP Extraction Tests No Longer Hijack TMPDIR**: Two cleanup-guard tests pointed the process-global `TMPDIR` at a scratch directory and asserted it ended up empty, so any concurrently running test that created a temp directory randomly failed the assertion on Ubuntu and macOS CI. The extraction now takes its temp base explicitly through a test seam and the tests use a private directory; the shipped code path is unchanged.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- No database schema migration in this release — `SCHEMA_VERSION` stays at 16.
|
||||
- On first launch after upgrading, a one-time Gemini common-config cleanup runs before the usual config extraction. Some Gemini providers may afterwards report a missing API key: entries are removed by credential-style key name plus exact value match, so this is usually another provider's credential that leaked through the shared snippet (the provider's own original value was overwritten when the leak occurred and cannot be recovered) — but a key you intentionally reused across several Gemini providers is removed from all of them as well. Either way, rotate and re-enter the key: treat anything that sat in the shared Gemini snippet as exposed. An audit record listing the removed key names and affected provider ids — never the values — is stored in the `settings` table under `gemini_common_config_scrub_audit_v1`.
|
||||
- If you have ever imported an MCP server from a `ccswitch://` link, it is worth auditing once. Before this release the MCP import confirmation could fail to show what it was about to write: arguments and env vars were not rendered at all (`command: "sh"` with `args: ["-c", …]` displayed as a harmless `sh`), and a URL-safe Base64 payload made the whole list render as "0 servers" — while the backend wrote the real entry to the live MCP files in both cases. Both defects affected **every release from v3.8.0 onward** and are fixed here. Exploitation required opening an attacker-supplied link and confirming the import, so most users are unaffected; if you did open an MCP import link from a source you do not fully trust, review the entries in the MCP panel, or directly in `~/.claude.json` under `mcpServers` (Codex: `mcp_servers` in `~/.codex/config.toml`), and confirm you recognise each one — MCP servers are executed as subprocesses the next time the CLI starts.
|
||||
- Deep links carrying a usage-query script now import it disabled unless the link explicitly sets `usageEnabled=true`. Links that relied on auto-enablement (for example some partner one-click setup links) will import the script but leave usage querying off — enable it in the provider editor after reviewing the code.
|
||||
- The new default models apply only to newly added providers — providers already saved keep the model IDs they were created with. For Claude Desktop, the current opus route ID advances to `claude-opus-5` with `claude-opus-4-8` moving into the legacy slot, so opus route IDs stored in existing configs still resolve through the compatibility alias.
|
||||
- New built-in pricing rows (`claude-opus-5`, `gemini-3.6-flash`, `grok-4.5-build`) are appended insert-if-absent on next launch — this seeding never overwrites a price you edited. A `~/.cc-switch/model-pricing.json` file is created (empty) to hold manual overrides and deletions made from this version onward — earlier price edits are not migrated into it, so re-save any you want protected against a database rebuild. Automatic models.dev sync stays off until you enable it; once enabled, it is the one path that does overwrite matching prices, built-in and hand-edited alike.
|
||||
- GrokBuild providers that relied on the implicit `XAI_API_KEY` environment fallback now need an explicit `api_key` or a correctly named `env_key`.
|
||||
- Grok Build official-mode usage appears with a deliberate delay of roughly ten minutes plus one sync cycle, so events can be checked against proxy-recorded rows; if routed and official usage alternate within that window, some official-mode turns are skipped rather than risk double counting. Grok Build rows already over-priced by the old cost backfill keep their inflated value — the backfill never revises an existing positive cost.
|
||||
- The updater endpoint list ships inside the app binary, so existing installations keep checking GitHub until they have updated once to a build containing this change; from then on the `dl.ccswitch.io` mirror is preferred with GitHub as fallback.
|
||||
- Sponsor providers you already created keep the base URL stored in the database, so they still target the old domains. To move onto a migrated domain, edit the provider's address by hand or re-create it from the refreshed preset.
|
||||
|
||||
## [3.18.0] - 2026-07-21
|
||||
|
||||
Development since v3.17.0 is headlined by Grok Build joining as the eighth managed app — full provider switching, proxy takeover on its own route namespace, MCP/Skills/prompts sync, a curated preset list, and a Grok Official entry with official-login import (schema v14/v15) — and by xAI Grok account sign-in over an OAuth device flow for Claude Code, Claude Desktop, and Codex, including a strict-gateway compatibility layer that lets codex 0.142+ drive a Grok subscription over native Responses. A usage-accounting repair wave fixes the v3.17.0 fork/sub-agent double count with a one-time automatic rebuild (schema v16) plus a manual rebuild action, makes proxy usage logging idempotent, and stops the usage page freezing during large imports. Diagnostics mature: logs persist across restarts under size rotation, every log egress redacts secrets, and renderer crashes are captured to disk behind an error boundary with a reload screen. The Codex conversion layer gets four correctness fixes — tool schemas normalized to object type, reasoning attached forward across turns, streamed tool-call identity and order preserved, and parser-required catalog fields backfilled so codex 0.144.5+ starts — while managed-OAuth providers are now reliably flagged as routing-required and Windows provider switches no longer flash a console window or freeze the UI. Rounded out by Kimi K3 presets and pricing, corrected OpenClaw preset costs, SudoCode.us restored beside SudoCode.chat, sponsor-grouped preset ordering, first-run tray language detection, and permanently deletable default Skill repositories.
|
||||
|
||||
@@ -83,8 +83,8 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
|
||||
<td width="180"><a href="https://aigocode.app/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.app/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -144,6 +144,11 @@ TeamoRouter also offers enterprise features including centralized billing, team
|
||||
<td>Thanks to <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> for sponsoring this project! NekoCode provides developers with a stable, efficient, and reliable API relay service for Claude, Codex, and other AI models. With transparent pricing and flexible pay-as-you-go billing, it offers a simple and cost-effective way to access AI models. CC Switch users can enjoy an exclusive 10% discount: register via <a href="https://nekocode.ai?aff=CCSWITCH">this link</a> and enter promo code <code>cc-switch</code> during recharge to receive 10% off your top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://a6api.com/register?aff=AqNr"><img src="assets/partners/logos/a6-banner-en.jpg" alt="A6API" width="150"></a></td>
|
||||
<td>Thanks to <a href="https://a6api.com/register?aff=AqNr">A6API</a> for sponsoring this project! A6API is a one-stop AI model API aggregation platform covering Claude, GPT, Gemini, Codex, and other mainstream models. Multiple vendors can list their supply on the platform, so the same model can be quoted competitively by several upstream providers. Smart routing automatically picks the more stable, lower-priced route available and fails over automatically, helping you reduce failed requests, cut costs, and improve stability. Whether you are an individual developer, an AI product team, or a studio, you can integrate quickly through a unified interface — compatible with all formats, with low migration cost. New users who register via <a href="https://a6api.com/register?aff=AqNr">this link</a> receive free trial credits: try it first, then use it at a low price.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch"><img src="assets/partners/logos/atlascloud_banner.png" alt="Atlas Cloud" width="150"></a></td>
|
||||
<td>Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out Atlas Cloud's new <a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">coding plan</a> promotion for more budget-friendly API access!</td>
|
||||
|
||||
+7
-2
@@ -83,8 +83,8 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
||||
</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>Danke an AIGoCode für die Unterstützung dieses Projekts! AIGoCode ist eine All-in-One-Plattform, die Claude Code, Codex und die neuesten Gemini-Modelle integriert und Ihnen stabile, effiziente und äußerst kostengünstige KI-Coding-Dienste bietet. Die Plattform stellt flexible Abonnementpläne bereit, birgt kein Risiko einer Kontosperrung, ermöglicht Direktzugriff ohne VPN und reagiert blitzschnell. AIGoCode hat ein besonderes Angebot für CC-Switch-Nutzer vorbereitet: Wenn Sie sich über <a href="https://aigocode.com/invite/CC-SWITCH">diesen Link</a> registrieren, erhalten Sie bei Ihrer ersten Aufladung zusätzliche 10 % Bonusguthaben!</td>
|
||||
<td width="180"><a href="https://aigocode.app/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>Danke an AIGoCode für die Unterstützung dieses Projekts! AIGoCode ist eine All-in-One-Plattform, die Claude Code, Codex und die neuesten Gemini-Modelle integriert und Ihnen stabile, effiziente und äußerst kostengünstige KI-Coding-Dienste bietet. Die Plattform stellt flexible Abonnementpläne bereit, birgt kein Risiko einer Kontosperrung, ermöglicht Direktzugriff ohne VPN und reagiert blitzschnell. AIGoCode hat ein besonderes Angebot für CC-Switch-Nutzer vorbereitet: Wenn Sie sich über <a href="https://aigocode.app/invite/CC-SWITCH">diesen Link</a> registrieren, erhalten Sie bei Ihrer ersten Aufladung zusätzliche 10 % Bonusguthaben!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -144,6 +144,11 @@ TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team
|
||||
<td>Vielen Dank an <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> für die Unterstützung dieses Projekts! NekoCode bietet Entwicklern einen stabilen, effizienten und zuverlässigen API-Relay-Dienst für Claude, Codex und weitere KI-Modelle. Mit transparenter Preisgestaltung und flexibler nutzungsbasierter Abrechnung bietet es einen einfachen und kostengünstigen Zugang zu KI-Modellen. CC-Switch-Nutzer erhalten einen exklusiven Rabatt von 10 %: Registrieren Sie sich über <a href="https://nekocode.ai?aff=CCSWITCH">diesen Link</a> und geben Sie beim Aufladen den Gutscheincode <code>cc-switch</code> ein, um 10 % Rabatt auf Ihre Aufladung zu erhalten!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://a6api.com/register?aff=AqNr"><img src="assets/partners/logos/a6-banner-en.jpg" alt="A6API" width="150"></a></td>
|
||||
<td>Vielen Dank an <a href="https://a6api.com/register?aff=AqNr">A6API</a> für die Unterstützung dieses Projekts! A6API ist eine All-in-one-Aggregationsplattform für KI-Modell-APIs und deckt Claude, GPT, Gemini, Codex und weitere gängige Modelle ab. Mehrere Anbieter können ihr Angebot einstellen, sodass dasselbe Modell von verschiedenen Upstream-Anbietern im Preiswettbewerb bereitgestellt wird. Intelligentes Routing wählt automatisch die stabilere und günstigere verfügbare Route und schaltet bei Fehlern automatisch um – das reduziert fehlgeschlagene Anfragen, senkt die Kosten und erhöht die Stabilität. Ob einzelne Entwickler, KI-Produktteams oder Studios: Die Anbindung erfolgt schnell über eine einheitliche Schnittstelle, kompatibel mit allen Formaten und mit geringem Migrationsaufwand. Neue Nutzer erhalten bei der Registrierung über <a href="https://a6api.com/register?aff=AqNr">diesen Link</a> kostenloses Testguthaben – erst testen, dann günstig loslegen.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch"><img src="assets/partners/logos/atlascloud_banner.png" alt="Atlas Cloud" width="150"></a></td>
|
||||
<td>Atlas Cloud ist eine vollmodale KI-Inferenzplattform, die Entwicklern über eine einzige KI-API Zugriff auf Videogenerierung, Bildgenerierung und LLM-APIs bietet. Statt mehrere Anbieterintegrationen zu verwalten, verbinden Sie sich einmal und erhalten einheitlichen Zugriff auf mehr als 300 kuratierte Modelle über alle Modalitäten hinweg. Sehen Sie sich die neue <a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">Coding-Plan</a>-Aktion von Atlas Cloud für kostengünstigeren API-Zugang an!</td>
|
||||
|
||||
+7
-2
@@ -83,8 +83,8 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
|
||||
<td width="180"><a href="https://aigocode.app/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.app/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -144,6 +144,11 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
|
||||
<td>本プロジェクトをご支援いただいている <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> に感謝します!NekoCode は、Claude や Codex などの AI モデルに対応した、安定性・効率性・信頼性に優れた API 中継サービスを提供しています。料金体系は明瞭で、柔軟な従量課金にも対応しています。CC Switch ユーザー限定の 10%オフ特典:<a href="https://nekocode.ai?aff=CCSWITCH">こちらのリンク</a> から登録し、チャージ時にクーポンコード <code>cc-switch</code> を入力すると、チャージが 10%オフになります!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://a6api.com/register?aff=AqNr"><img src="assets/partners/logos/a6-banner-en.jpg" alt="A6API" width="150"></a></td>
|
||||
<td>本プロジェクトをご支援いただいている <a href="https://a6api.com/register?aff=AqNr">A6API</a> に感謝します!A6API は、Claude、GPT、Gemini、Codex などの主要モデルを網羅するワンストップの AI モデル API アグリゲーションプラットフォームです。複数のベンダーが出品でき、同じモデルを複数の上流プロバイダーが競争価格で提供します。スマートルーティングにより、より安定して安価な利用可能ルートを自動で選択し、失敗時には自動で切り替えるため、リクエストの失敗を減らし、コストを抑え、安定性を高められます。個人開発者でも、AI プロダクトチームでも、スタジオでも、統一されたインターフェースからすぐに接続でき、あらゆるフォーマットに対応、移行コストも低く抑えられます。<a href="https://a6api.com/register?aff=AqNr">こちらのリンク</a> から新規登録すると無料の体験クレジットがもらえます。まず試してから、低価格で使い始められます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch"><img src="assets/partners/logos/atlascloud_banner.png" alt="Atlas Cloud" width="150"></a></td>
|
||||
<td>Atlas Cloud は、1 つの API で動画・画像生成や LLM(大規模言語モデル)を利用できる全モーダル対応の AI 推論プラットフォームです。複数のベンダーを個別に管理する手間を省き、一度の接続で 300 以上の厳選されたマルチモーダルモデルにアクセスできます。より低コストで API を利用できる、開発者向けの新しい<a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">「コーディングプラン」</a>プロモーションをぜひチェックしてください!</td>
|
||||
|
||||
+7
-2
@@ -83,8 +83,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
|
||||
<td width="180"><a href="https://aigocode.app/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.app/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -144,6 +144,11 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
|
||||
<td>感谢 <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> 赞助本项目!NekoCode 为开发者提供稳定、高效、可靠的 Claude、Codex 等 AI 模型 API 中转服务,价格透明,接入便捷,支持灵活的按量计费。CC Switch 用户专享 9 折福利:通过 <a href="https://nekocode.ai?aff=CCSWITCH">此链接</a> 注册,并在充值时输入优惠码 <code>cc-switch</code>,即可享受充值 9 折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://a6api.com/register?aff=AqNr"><img src="assets/partners/logos/a6-banner-zh.jpg" alt="A6API" width="150"></a></td>
|
||||
<td>感谢 <a href="https://a6api.com/register?aff=AqNr">A6API</a> 赞助本项目!A6API 是一站式 AI 模型 API 聚合平台,覆盖 Claude、GPT、Gemini、Codex 等主流模型,支持多商家入驻供货,同一个模型可由多个上游商家竞争报价。平台通过智能路由自动优选更稳定、更低价的可用线路,并支持失败自动切换,帮助用户减少请求失败、降低调用成本、提升使用稳定性。无论你是开发者、AI 产品团队还是工作室,都可以通过统一接口快速接入,兼容所有格式,迁移成本低,使用更省心。新用户通过 <a href="https://a6api.com/register?aff=AqNr">此链接</a> 注册即可获得免费体验额度,先试再用,低价开用。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch"><img src="assets/partners/logos/atlascloud_banner.png" alt="Atlas Cloud" width="150"></a></td>
|
||||
<td>Atlas Cloud 是一个全模态 AI 推理平台,通过单一 API 为开发者提供视频生成、图像生成及 LLM 接入。免去繁琐的多供应商对接,一次连接即可调用 300+ 款全模态精选模型。立即查看 Atlas Cloud 全新<a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">“编程计划”</a>优惠,获取更具性价比的 API 接入!</td>
|
||||
|
||||
+103
-1
@@ -11,6 +11,93 @@ Only the latest release of CC Switch receives security updates.
|
||||
| Latest 3.x | ✅ Yes / 是 |
|
||||
| < 3.0 | ❌ No / 否 |
|
||||
|
||||
## Threat Model / 威胁模型
|
||||
|
||||
CC Switch is a local desktop application. It manages configuration files for AI coding CLIs on the user's own machine. There is no project-operated cloud backend, no multi-user model, and no privilege separation from the user who runs it.
|
||||
|
||||
CC Switch 是一个本地桌面应用,用于管理本机上各 AI 编程 CLI 的配置文件。本项目不运营任何云端后端,没有多用户模型,也不与运行它的用户之间存在权限隔离。
|
||||
|
||||
It does, however, run a **local HTTP proxy** whose listen address and port are user-configurable and **may be bound to a non-loopback interface**. Requests arriving at that listener are untrusted input and are in scope — see Scope below.
|
||||
|
||||
但它会启动一个**本地 HTTP 代理**,其监听地址与端口可由用户配置,**可能绑定到非 loopback 接口**。抵达该监听端口的请求属于不可信输入,在范围内——见下方「范围」。
|
||||
|
||||
### The bundled renderer is inside the trust boundary / 打包的渲染进程属于信任边界之内
|
||||
|
||||
The bundled WebView renderer is treated as a trusted component. This is a **scoping decision, supported by the facts below rather than derived from them** — the facts are what make the decision checkable, and if any ceases to hold the decision must be revisited. Verified against v3.18.0:
|
||||
|
||||
打包的 WebView 渲染进程被视为可信组件。这是一项**范围划定决策,由下列事实支撑,而非从中必然推出**——这些事实的作用是让该决策可被核验;一旦任一条不再成立,该决策必须重新评估。已针对 v3.18.0 核实:
|
||||
|
||||
1. **No remote executable content is loaded.** `frontendDist` is bundled at build time (`src-tauri/tauri.conf.json`); the codebase contains no `<iframe>`, no `<webview>`, and no remote script or stylesheet URL. The application *does* retrieve remote **data** — model pricing JSON and provider avatars — which CSP permits via `connect-src`/`img-src`; such data is treated as untrusted input, not as content.
|
||||
前端资源在构建期打包,代码库中不存在 `<iframe>`、`<webview>` 或远程脚本/样式地址。应用**确实**会获取远程**数据**(模型定价 JSON、供应商头像),CSP 经 `connect-src`/`img-src` 允许之;此类数据按不可信输入对待,不作为内容。
|
||||
2. **CSP restricts script execution to bundled assets** — `script-src 'self'` (`src-tauri/tauri.conf.json`).
|
||||
CSP 将脚本执行限制在打包资源内。
|
||||
3. **No dynamic code evaluation.** There is no `eval()` or `new Function()` anywhere under `src/`.
|
||||
`src/` 下不存在 `eval()` 或 `new Function()`。
|
||||
4. **The single HTML sink never receives user-controlled content.** `src/components/ProviderIcon.tsx` uses `dangerouslySetInnerHTML` exactly once; the user-supplied `icon` field is used **solely as a lookup key** into a build-time icon table, never as content.
|
||||
全库唯一的 `dangerouslySetInnerHTML` 位于 `src/components/ProviderIcon.tsx`;用户提供的 `icon` 字段**仅作为查表的键**用于构建期图标表,永不作为内容。
|
||||
|
||||
**What this excludes — and what it does not.** Out of scope: reports whose only path to the IPC surface is **direct invocation from DevTools or from a locally modified frontend**. Someone in that position controls the machine already.
|
||||
|
||||
**它排除什么、不排除什么。** 不在范围内的是:抵达 IPC 接口的**唯一途径**为**从 DevTools 或本地改造过的前端直接调用**的报告。处于该位置的人已经控制了这台机器。
|
||||
|
||||
**Still in scope:** any complete, demonstrable chain in which an *untrusted* source — a `ccswitch://` deep link, a remote sync payload, remote data, an inbound proxy request, or an XSS — reaches a high-privilege IPC command. The trust placed in the renderer covers the code we ship, not arbitrary values that flow through it.
|
||||
|
||||
**仍在范围内**:任何完整、可演示的利用链,其中**不可信来源**——`ccswitch://` deeplink、远程同步载荷、远程数据、代理入站请求或 XSS——抵达高权限 IPC 命令。对渲染进程的信任覆盖的是我们发布的代码,而非流经其中的任意值。
|
||||
|
||||
### Invalidation triggers / 声明失效条件
|
||||
|
||||
The scoping decision above is void the moment any of the following becomes true. Reports demonstrating any of these are **always in scope**, and we ask to be told about them:
|
||||
|
||||
一旦下列任一条件成立,上述范围划定立即作废。证明下列任一情形的报告**始终在范围内**,也欢迎报告:
|
||||
|
||||
- Remote **executable or navigable** content is loaded into the WebView — iframe, webview, remote script, remote stylesheet, or navigation to a remote origin
|
||||
远程**可执行或可导航**内容被载入 WebView——iframe、webview、远程脚本、远程样式表,或导航至远程源
|
||||
- `script-src` is relaxed beyond `'self'`
|
||||
`script-src` 被放宽到 `'self'` 之外
|
||||
- `eval()` or `new Function()` is introduced
|
||||
引入 `eval()` 或 `new Function()`
|
||||
- Any user-controlled string reaches an HTML sink as content
|
||||
任何用户可控字符串作为内容进入 HTML sink
|
||||
- The IPC surface is exposed to a non-bundled origin
|
||||
IPC 接口暴露给非打包来源
|
||||
|
||||
## Scope / 范围
|
||||
|
||||
### In scope / 在范围内
|
||||
|
||||
Inputs that genuinely cross a trust boundary:
|
||||
|
||||
真正跨越信任边界的输入:
|
||||
|
||||
- `ccswitch://` deep link payloads / deeplink 载荷(由第三方构造,经浏览器抵达)
|
||||
- **Inbound requests to the local HTTP proxy**, including from other hosts when it is configured to bind a non-loopback address / **抵达本地 HTTP 代理的入站请求**,包括配置为绑定非 loopback 地址时来自其他主机的请求
|
||||
- Remote sync payloads restored from WebDAV / S3 / 从 WebDAV、S3 还原的同步数据
|
||||
- Imported files: SQL import/export, provider and MCP config import / 导入文件:SQL 导入导出、供应商与 MCP 配置导入
|
||||
- Upstream API responses processed by the local proxy (`src-tauri/src/proxy/`) / 本地代理处理的上游 API 响应
|
||||
- Remote data rendered or acted upon by the renderer (model pricing, avatars) / 渲染进程展示或据以行动的远程数据(模型定价、头像)
|
||||
- Live config files on disk that a third party can write / 磁盘上可被第三方写入的 live 配置文件
|
||||
- Any path by which credentials (API keys, tokens) reach logs, telemetry, or shared config snippets / 凭据(API Key、令牌)进入日志、遥测或共享配置片段的任何路径
|
||||
- The build, release, signing and updater pipeline / 构建、发布、签名与更新链路
|
||||
|
||||
### Out of scope / 不在范围内
|
||||
|
||||
- Findings whose only path to the IPC surface is direct invocation from DevTools or a locally modified frontend — see Threat Model
|
||||
抵达 IPC 接口的唯一途径为从 DevTools 或本地改造过的前端直接调用的问题——见威胁模型
|
||||
- **Ordinary file operations the user directs.** Reading or writing a file whose path *and* content the user chose through the local UI, with no untrusted input participating.
|
||||
**用户主动指示的常规文件操作。** 读写路径**与**内容均由用户经本地界面选定、且无不可信输入参与的文件。
|
||||
→ Not excluded: cases where a deep link, sync payload, proxy request or other untrusted source controls the path or the content. Having the same filesystem permissions as the user does not make it the user's decision — that is a confused-deputy attack and is **in scope**.
|
||||
→ 不属豁免:路径或内容由 deeplink、同步载荷、代理请求等不可信来源控制的情形。攻击者与用户拥有相同的文件系统权限,并不等于该操作出自用户的决定——那是 confused deputy 攻击,**在范围内**。
|
||||
- **User-authored integrations executing by design.** MCP servers, terminal launch and usage scripts run commands because that is their purpose. Where the user typed the command themselves and enabled it themselves, execution is the feature, not the bug.
|
||||
**用户亲手编写的集成按设计执行命令。** MCP server、终端启动、用量脚本执行命令是其本职。命令由用户自己输入、并由用户自己启用时,执行本身是功能而非缺陷。
|
||||
→ Not excluded: the same integrations when they **arrive through import or a deep link**. There the required security property is *informed consent*, and the following are **in scope**: the command, arguments, environment or script body being hidden, truncated or misrepresented in the confirmation UI; and any integration carrying executable content being enabled without an explicit user decision.
|
||||
→ 不属豁免:同样的集成**经导入或 deeplink 抵达**时。此时所要求的安全属性是**知情同意**,下列情形**在范围内**:确认界面隐藏、截断或错误展示命令、参数、环境变量或脚本正文;以及任何携带可执行内容的集成在缺少用户明确决定的情况下被启用。
|
||||
- Denial of service against the user's own local instance
|
||||
针对用户自己本地实例的拒绝服务
|
||||
- Automated scanner output without a demonstrated exploitation path on a currently supported release
|
||||
未在受支持版本上给出可行利用路径的自动化扫描结果
|
||||
- Findings against unsupported versions — see Supported Versions
|
||||
针对不受支持版本的问题——见支持的版本
|
||||
|
||||
## Reporting a Vulnerability / 报告漏洞
|
||||
|
||||
**Please do NOT report security vulnerabilities through public GitHub issues.**
|
||||
@@ -26,10 +113,15 @@ When reporting, please include:
|
||||
报告时请包含以下信息:
|
||||
|
||||
- A description of the vulnerability / 漏洞描述
|
||||
- Steps to reproduce / 复现步骤
|
||||
- **The untrusted source of the input, and the full data path from that source to the affected code** / **输入的不可信来源,以及从该来源到相关代码的完整数据路径**
|
||||
- Steps to reproduce against a currently supported release / 在受支持版本上的复现步骤
|
||||
- Potential impact / 潜在影响
|
||||
- Affected versions / 受影响版本
|
||||
|
||||
The data path matters more than the sink. We assess severity by **who controls the input**, not by which API the value eventually reaches — the same function call can be critical or harmless depending entirely on where its argument came from.
|
||||
|
||||
数据路径比 sink 更重要。我们按**谁能控制输入**来评估严重度,而非按该值最终抵达哪个 API——同一处调用是严重还是无害,完全取决于其参数的来源。
|
||||
|
||||
## Response Timeline / 响应时间
|
||||
|
||||
- **Acknowledgment / 确认**: within 48 hours / 48 小时内
|
||||
@@ -51,6 +143,16 @@ Reporters will be credited in the release notes unless they prefer to remain ano
|
||||
|
||||
除非报告者希望匿名,否则将在发布说明中致谢。
|
||||
|
||||
### CVE identifiers / CVE 编号
|
||||
|
||||
For eligible vulnerabilities, the maintainer may request a CVE ID through a GitHub Security Advisory once a fix is available. Being in scope for this policy and meeting GitHub's CVE eligibility criteria are separate questions, and the second is decided by GitHub as CNA, not by this project.
|
||||
|
||||
对于符合条件的漏洞,维护者可在修复就绪后通过 GitHub 安全公告申请 CVE 编号。「属于本策略范围」与「符合 GitHub 的 CVE 分配条件」是两个不同的问题,后者由作为 CNA 的 GitHub 判定,而非本项目。
|
||||
|
||||
Severity is scored with CVSS (v3.1 or v4.0), and the vector will reflect any required user interaction or prior local access.
|
||||
|
||||
严重度采用 CVSS(v3.1 或 v4.0)评分,向量将如实反映所需的用户交互或前置本地访问条件。
|
||||
|
||||
## Security Updates / 安全更新
|
||||
|
||||
Security fixes are released as patch versions and announced via [GitHub Releases](https://github.com/farion1231/cc-switch/releases). We recommend always updating to the latest version.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
@@ -0,0 +1,353 @@
|
||||
# CC Switch v3.19.0
|
||||
|
||||
> The through-line of this release is **peace of mind**: a concentrated wave of security hardening — Skill installation, `ccswitch://` import confirmations, SQL backup imports, common-config merging, and terminal launching are all tightened, and two of those items are worth a minute of your attention — the Gemini common-config credential leak is fixed and scrubbed automatically after upgrading (**you need to rotate your keys**), and the `ccswitch://` MCP import confirmation could previously fail to show the command it was about to write (**if you have ever opened an import link of unknown origin, it is worth a check**); both are covered under "Upgrade Notes". Alongside them is one major proxy correctness fix — **reading images through the proxy no longer blows up your context** (a single screenshot used to eat 100,000+ tokens, and two or three were enough to jam a Codex session on 400s). The convenience side is just as substantial: **model pricing can be handed over to models.dev to maintain automatically**, usage from Grok CLI's official login mode and the SuperGrok subscription balance finally reach the dashboard, and **in-app updates now go through the `dl.ccswitch.io` mirror** — so upgrading works even when GitHub is hard to reach.
|
||||
|
||||
**[中文版 →](v3.19.0-zh.md) | [日本語版 →](v3.19.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Highlights: What You Can Do Now
|
||||
|
||||
- **Read images through the proxy without blowing up your context**: with Codex's `view_image` or any MCP tool that returns images, the image used to be serialized into tool text and token-counted as plain text (roughly 9,000× inflation); now every conversion bridge restores images to their native format before sending them upstream (files and audio are supported on the two Chat bridges as well). In a real test the same replayed turn dropped from 85k+ tokens to about 12k, with a 99% cache hit rate.
|
||||
- **Hand model pricing over to models.dev**: the usage panel gains "models.dev Automatic Pricing Sync" (off by default). Once enabled, the prices of the models you selected refresh automatically at startup (at most once every 6 hours); you can pick which models to track from the full catalog, or let it automatically include each vendor's latest commonly used models. From this release on, manual price edits and deletions are recorded in `~/.cc-switch/model-pricing.json`, so they survive a database rebuild.
|
||||
- **See usage and subscription balance for Grok's official mode**: when Grok CLI signs in with official OAuth it cannot go through the local proxy, so that consumption was previously entirely invisible; now per-turn usage is imported from the session logs and presented in the dashboard as "Grok Build (Session)". Grok Build provider cards in the official category also show the SuperGrok subscription's quota usage and reset time directly.
|
||||
- **Open `ccswitch://` import links with more confidence**: the confirmation now shows the command, every argument, the URL, and the environment variables in full (credential-shaped values are shown masked), and highlights the values worth a second look — inline shell execution, environment variables that change loading behavior, intranet / metadata addresses; usage-query scripts show their complete code and are **imported disabled by default**.
|
||||
- **Confirm that your Gemini providers no longer carry somebody else's key**: the common-config shared snippet used to copy credentials such as `GOOGLE_API_KEY` into every Gemini provider that used it; this release closes that path, and the first launch after upgrading performs a one-time scrub automatically. **Any key that ever entered the shared Gemini snippet should be treated as exposed — rotate it before re-entering it** (see "Upgrade Notes").
|
||||
- **Keep updating the app when GitHub is hard to reach**: the in-app updater queries `https://dl.ccswitch.io/latest.json` (a Cloudflare R2 mirror) first, with GitHub as the fallback; minisign signature verification is unchanged, and the mirror itself is never trusted.
|
||||
- **Use the latest models straight away when creating a provider**: preset default models are upgraded to Claude Opus 5, GPT-5.6 Sol, and Gemini 3.6 Flash, with matching pricing seeded into the database; already-created providers are left as they are.
|
||||
- **Import fork-dense Codex usage history faster**: each parent rollout file is parsed only once and shared across all of its fork points, so rebuilding fork-dense history speeds up noticeably, and the import results are byte-identical.
|
||||
|
||||
---
|
||||
|
||||
## Usage Guides
|
||||
|
||||
The new capabilities in this release land mainly in the usage panel and `ccswitch://` deep-link imports. The following docs are worth reading alongside it:
|
||||
|
||||
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: the usage dashboard's data sources and how the statistics are counted. This release adds models.dev automatic pricing sync and usage import for Grok's official mode.
|
||||
- **[Deep-Link Import (ccswitch://)](../user-manual/en/5-faq/5.3-deeplink.md)**: what each field in the import confirmation means, and the default values of parameters such as `usageEnabled` (from this release on, usage scripts are imported disabled by default, and the docs have been corrected to match).
|
||||
- **[Security Policy (SECURITY.md)](../../SECURITY.md)**: this release fills in the threat model and reporting scope — which inputs are treated as untrusted and which issues are welcome, at a glance.
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## Only Official Channels (Please Read)
|
||||
>
|
||||
> CC Switch is a **fully free and open-source** desktop app, and we **do not charge users any fees**. Please only obtain the software through the official channels listed below:
|
||||
>
|
||||
> | Channel | Only Official |
|
||||
> | ------------------ | ------------------------------------------------------------------------------ |
|
||||
> | Website | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | Source | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | Downloads | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | Author | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | Report an Imposter | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **Any "CC Switch" website or client that asks you for payment, top-ups, or login credentials is fake.** If you have been tricked into paying, stop the transaction immediately and file a report through GitHub Issues.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.19.0 is led by a wave of security hardening and one major proxy correctness fix. On the security side ([#5811](https://github.com/farion1231/cc-switch/pull/5811) plus follow-up standalone fixes): installing Skills from a GitHub repository is hardened against zip-slip and path traversal and given archive limits; the Gemini common-config credential leak is closed, and the first launch after upgrading performs a one-time scrub that cleans out keys already leaked into other providers' configs; importing a SQL backup now executes under a SQLite authorizer, and statements that can reach outside the importing database — `ATTACH` among them — are rejected outright; common-config snippet merging no longer follows `__proto__` into the global prototype; external terminal launching switches to POSIX single-quote escaping, so a directory name can never inject a command again; and the `ccswitch://` import confirmation displays the payload in full (credential-shaped values masked) and flags risky values, with usage scripts imported disabled by default. On the proxy side, images in tool results are no longer serialized into tool text but restored as native media on each conversion bridge (files and audio are supported on the two Chat bridges as well) — ending the "one 113 KB screenshot eats 100,000+ tokens, two or three images jam the Codex session on 400s" problem ([#4465](https://github.com/farion1231/cc-switch/issues/4465), [#5663](https://github.com/farion1231/cc-switch/issues/5663)).
|
||||
|
||||
Usage statistics gain two new capabilities: **models.dev automatic pricing sync** (opt-in, [#5734](https://github.com/farion1231/cc-switch/pull/5734)), together with persisting manual price edits / deletions to the human-editable `~/.cc-switch/model-pricing.json`; and **usage import for Grok CLI's official OAuth mode** — traffic that cannot go through the local proxy and was previously entirely invisible — plus a SuperGrok subscription quota display on the provider card. Around distribution and experience: in-app updates prefer the Cloudflare R2 mirror at `dl.ccswitch.io` (GitHub as fallback, signature verification unchanged); Codex usage import reuses an already-parsed parent rollout timeline for forked sessions ([#5626](https://github.com/farion1231/cc-switch/pull/5626)); preset default models move up to Claude Opus 5, GPT-5.6 Sol, and Gemini 3.6 Flash; the OpenClaw Kimi For Coding preset's base URL is corrected; and the toolbar app switcher becomes icon-only. This release has **no database schema migration**, so upgrading is lightweight.
|
||||
|
||||
**Release date**: 2026-07-30
|
||||
|
||||
**Stats**: 38 commits | 132 files changed | +14,926 / -1,415 lines
|
||||
|
||||
---
|
||||
|
||||
## Added
|
||||
|
||||
### models.dev Automatic Pricing Sync
|
||||
|
||||
The pricing area of the usage panel gains a "models.dev Automatic Pricing Sync" card, **off by default and enabled manually**: turning it on shows a confirmation explaining that CC Switch will refresh the prices of the selected models from models.dev at startup (at most once every 6 hours), and that **both the built-in price and a hand-set price for a matching model will be overwritten**. The "Select Models" dialog offers the full models.dev catalog (searchable and filterable), plus an "automatically include common models" option covering the recently released models from Claude, GPT, Gemini, Grok, DeepSeek, Qwen, MiMo, LongCat, Kimi, MiniMax, and GLM (at most 6 per family, each individually excludable). The card shows the last sync time and any errors, offers "Sync Now", and can open or reload the local pricing file.
|
||||
|
||||
From this release on, manual price edits and deletions are also recorded in `~/.cc-switch/model-pricing.json`, a human-editable file sitting next to the database, and replayed on every startup — hand-set pricing no longer disappears after a database rebuild, and a deleted built-in price finally stays deleted (recorded as a tombstone rather than being re-seeded). Note that the file **starts empty and is deliberately not back-filled from the existing pricing table** (otherwise built-in prices would all be written in as overrides, blocking future built-in price corrections), so edits made before the upgrade still live only in the database — re-save one and it enters the file. When a sync genuinely changes a price, historical usage rows whose cost was **never computed** (zero or missing) are recalculated at the new price — rows that already have a cost keep their original values; a failed fetch or being offline never blocks startup. The models.dev list also filters out non-text and deprecated models (audio / image / video / embedding, and so on), which tidies up the manual price-selection dialog along the way. ([#5734](https://github.com/farion1231/cc-switch/pull/5734))
|
||||
|
||||
### Grok Official Mode Usage Finally Reaches the Dashboard
|
||||
|
||||
When Grok CLI signs in with official OAuth it cannot be routed through the local proxy — Grok uses an empty config as the mode switch, so there is nowhere to point it at CC Switch — and that consumption was previously entirely invisible in the usage dashboard. CC Switch now imports per-turn usage alongside the regular session-log sync, reading `turn_completed` events from `updates.jsonl` under `~/.grok/sessions` (archived sessions included): cost prefers the exact figure the CLI reports itself, falling back to local pricing when that is missing (the built-in pricing table gains `grok-4.5-build` at $2 input / $6 output / $0.30 cache read, per million tokens). Imported rows are keyed on the upstream per-turn ID, so a rolled-back session cannot cause double counting; a settling window plus a recent-proxy-activity check ensures the same traffic is not counted twice when routed and official modes are mixed. New rows appear in the dashboard under the provider name "Grok Build (Session)", the app filter gains a Grok Build option, and the data-source breakdown gains a "Grok Build Session" entry with its own icon — all four locales included.
|
||||
|
||||
### SuperGrok Subscription Quota on Provider Cards
|
||||
|
||||
Grok Build providers in the "official" category now show SuperGrok subscription usage directly on the card, alongside the official-subscription footers for Claude Code / Codex / Gemini: CC Switch reads Grok CLI's own OAuth credentials (`~/.grok/auth.json`) and queries grok.com's billing endpoint for the quota window's used percentage and reset time; when the reset interval is recognizable it is labeled "weekly" or "monthly", otherwise it falls into a new "Credits" tier (rendered as the `c` group in the tray usage summary). A transient network failure keeps the previous reading and retries rather than clearing the footer; an expired token prompts you to run `grok login` again. Managed xAI OAuth (SuperGrok) providers in Claude Code, Claude Desktop, and Codex automatically get the same quota display — the data comes from the account bound to that provider, and the usage-script entry is hidden accordingly. Note that whether a Grok Build provider counts as "official" is now decided purely by the `category` field, with no probing of config contents.
|
||||
|
||||
### Claude Opus 5 Built-In Pricing
|
||||
|
||||
`claude-opus-5` joins the built-in pricing table at $5 input / $25 output, $0.50 cache read / $6.25 cache write (per million tokens), so its usage no longer shows $0. It is seeded insert-if-absent, so edited prices are unaffected (Opus 5 fast mode bills separately and is deliberately left out of the table).
|
||||
|
||||
### Preset Catalog Updates
|
||||
|
||||
A6API (an aggregation platform that automatically picks the best of several upstreams for the same model) joins the sponsor presets across eight apps; the PackyCode preset gains three backup addresses on the five preset types that support backup endpoints (Claude Code / Claude Desktop / Codex / Gemini CLI / Grok Build), selectable in the endpoint manager and the speed test; the AICoding partner preset returns across seven apps; and sponsor ordering is realigned with the README.
|
||||
|
||||
---
|
||||
|
||||
## Changed
|
||||
|
||||
### Preset Default Models Upgraded: Claude Opus 5, GPT-5.6 Sol, Gemini 3.6 Flash
|
||||
|
||||
The built-in presets' default models are all on the current generation now: `claude-opus-5` replaces `claude-opus-4-8` (all three naming forms covered), `gpt-5.6-sol` replaces `gpt-5.5` and bare `gpt-5.6`, and `gemini-3.6-flash` replaces `gemini-3.5-flash`. Every mirrored location was updated in sync — universal / NewAPI defaults, the Codex custom `config.toml` template, recommendation lists, form placeholders, and copy in all four locales; `gemini-3.6-flash` pricing is seeded into the database at the same time ($1.50 / $7.50, $0.15 cache read, per million tokens). The Code0 and Qiniu Gemini presets, still pinned to `gemini-3.1-pro-preview`, are aligned to 3.6 Flash as well — this is a **deliberate tier change**: 3.6 has no Pro version, and 3.5 Pro is still restricted to partner testing. **Defaults only affect newly created providers**; saved providers keep the model they were created with. Claude Desktop's opus route advances to `claude-opus-5`, with `claude-opus-4-8` moving into the compatibility-alias slot, so existing configs still resolve as before.
|
||||
|
||||
### In-App Updates Now Go Through the ccswitch.io Mirror
|
||||
|
||||
The updater now queries `https://dl.ccswitch.io/latest.json` first — a Cloudflare R2 mirror of the release manifest — with GitHub Releases as the fallback, so checking for and downloading updates no longer depends on GitHub being reachable. The mirror manifest points every platform's download at the same bucket, while the minisign signatures stay exactly as they were: a signature covers file content, not the URL, and every downloaded artifact is still verified against the built-in public key, so **the mirror itself is never trusted**. Publishing is handled by a release-gated sync workflow that only rewrites the root manifest when the tag really is GitHub's `releases/latest`, so the mirror can never push users back to an older version.
|
||||
|
||||
### Codex Usage Import: Faster on Forked Sessions
|
||||
|
||||
Importing and rebuilding Codex usage statistics no longer re-reads the same parent rollout file once per fork point: each parent `~/.codex/sessions/*.jsonl` is parsed only once into an in-memory token timeline shared by every child session forked from it, and each child's cutoff becomes an in-memory filter instead. The cache is validated by a file identity stamp (modification time, size, plus device/inode on Unix or volume serial number + file ID on Windows), so a parent file that was appended to, rotated, or replaced is re-read rather than served stale. How much faster it gets depends on fork density: fork-dense history sheds a great deal of redundant parsing, while fork-sparse history is essentially unchanged — and in both cases the import results are byte-identical. ([#5626](https://github.com/farion1231/cc-switch/pull/5626))
|
||||
|
||||
### Toolbar App Switcher Becomes Icon-Only
|
||||
|
||||
The switcher buttons no longer render a text label next to the icon — with the managed apps grown to eight, the labels were already being collapsed by overflow detection almost all the time, so the ResizeObserver-based auto-compaction was removed and only the icons are shown. App names remain in the hover tooltip, and screen readers still reach them through `aria-label`.
|
||||
|
||||
### Sponsor Domains and Referral Links Refreshed
|
||||
|
||||
Several sponsors migrated domains, and preset addresses, backup endpoints, referral links, and README rows have been updated to match (PackyCode → `www.packyapi.ai`, RightCode → `www.rightapi.ai`, ClaudeAPI → `www.apito.ai`, APINebula → `apinebula.ai`, AICodeMirror → `.ai`, AICoding → `.inc`, AIGoCode → `.app`), along with the removal of two backup endpoints that had gone dead. **Already-created providers keep the old address stored in the database** — to move to the new domain, edit the address by hand or recreate the provider from the refreshed preset.
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### Reading Images Through the Proxy No Longer Blows Up the Context
|
||||
|
||||
When a client read an image through a tool call — Codex's `view_image`, or any MCP tool that returns images — the proxy's protocol conversion serialized the entire image block into the tool message's text, and the upstream token-counted the base64 as plain text: roughly 9,000× inflation, with a 113 KB PNG working out to 100,000+ prompt tokens; Codex replays the whole history every turn, so two or three screenshots were enough to push the session out of the context window and jam it on repeated 400s ([#4465](https://github.com/farion1231/cc-switch/issues/4465), [#5663](https://github.com/farion1231/cc-switch/issues/5663)).
|
||||
|
||||
The proxy now lifts media payloads out of tool results and re-sends them in each bridge's native format — **images are covered on every bridge; files and audio apply wherever the target protocol supports them**: the two Chat bridges (Claude→Chat, Codex Responses→Chat) carry images / files / audio, leaving a short marker in the tool message with the media following the tool batch as a synthetic user message; Claude→Responses restores native `input_image`; Codex / GrokBuild→Anthropic rebuilds standard Anthropic image blocks; and Claude→Gemini uses multimodal `functionResponse.parts` on Gemini 3 (`inlineData` on older models), accepting inline base64 images only. Detection covers typed Responses blocks, Anthropic `source` blocks, MCP `data`+`mimeType` results, and whole image data URLs, and sees through arrays and nested `content` wrappers (including JSON-encoded tool output); once an output is judged to contain media, any data URLs and bare base64 left in it are collapsed into placeholders — **bare base64 on its own never triggers media detection**, and ordinary tool output is left exactly as it was. Tool results without media stay byte-identical to before on every bridge, so prompt-cache prefixes are unaffected; the media blocks sent upstream deliberately carry no `cache_control` marker, so strict upstreams such as GLM and Qwen do not reject them. Measured end to end against Kimi K3: the same replayed turn settles at about 12k input tokens with a 99% cache hit rate, where each replay previously had to carry 85k+ of base64 text.
|
||||
|
||||
### "Unsupported Image Fallback" Now Sees Images Inside Tool Results
|
||||
|
||||
The "unsupported image fallback" setting replaces image blocks with a placeholder marker when a provider is text-only or the upstream rejects images, but it could previously only see images that were still structured blocks — tool-result images already flattened into base64 text were invisible to it, so a text-only upstream simply failed with no way to recover. The media scrubber now symmetrically detects and strips media inside tool output on every path, so both the strip-before-sending route and the retry-after-rejection route can rescue such turns. Because that detection now also reaches into tool results, a regression test pins down that the reactive retry still fires only on genuine modality rejections — a context-length 400 is not mistaken for an image rejection and retried.
|
||||
|
||||
### Grok Build Cost Backfill No Longer Overestimates
|
||||
|
||||
The routine that backfills missing costs previously treated only Codex and Gemini as providers whose reported input tokens already include cache reads, while Grok Build follows the same convention — so backfilled Grok Build rows were priced on the full input and then charged for the cache read a second time, inflating the cost. The set of cache-inclusive providers is now defined in exactly one place, shared by the route logger, the cost calculator, and the backfill routine, so the three can no longer disagree. Note that rows already touched by the old backfill keep their values — the backfill only handles zero-cost rows and never rewrites an existing positive cost.
|
||||
|
||||
### Hand-Edited Config Files No Longer Crash the App or Swallow Your Edits
|
||||
|
||||
When `mcp_servers` exists in `~/.codex/config.toml` but is not a table (for example `mcp_servers = "x"`), MCP sync panicked mid-switch — and it happened after both the database and the live config had already been written, leaving a half-applied switch; a non-table value is now warned about and normalized to an empty table, with the same fix in the Codex and GrokBuild writers. The inline-table form (valid TOML) had mirror-image problems: MCP deletion silently did nothing while the UI reported success, and `base_url` edits were written to a level Codex never reads — both handled. An `opencode.json` whose root node or `provider` / `mcp` section is an array or scalar no longer panics; such files are rejected with an error instead of being rebuilt, so your own `model` and `theme` settings are not wiped out. ([#5811](https://github.com/farion1231/cc-switch/pull/5811))
|
||||
|
||||
### Proxy Conversion Survives Malformed Upstream Responses
|
||||
|
||||
Malformed data from an upstream gateway could previously take down the local proxy outright instead of producing an error: a non-object `message` or `content_block` in an Anthropic SSE stream, and a buffered response body that is a top-level JSON array or scalar (what a gateway that ignores `stream: true` returns), both hit a panicking indexed assignment; the stream now ends with a normal failure event. A malformed `content_block` header is additionally recovered as a text block — merely sanitizing it into an empty object stops the panic but leaves all the following content silently discarded, making the model look like it said nothing — and since the deltas after a bad header are usually intact, the common case now passes through, with a warning logged whenever the substitution happens. ([#5811](https://github.com/farion1231/cc-switch/pull/5811))
|
||||
|
||||
### OpenClaw's Kimi For Coding Address Corrected
|
||||
|
||||
The OpenClaw preset previously pointed at the general platform endpoint `https://api.kimi.com/v1`, which is not what the Kimi For Coding subscription uses, so coding-plan keys did not work. The address is corrected to `https://api.kimi.com/coding/v1`, with the form placeholder and default value updated to match. **Providers created from the old preset need to be pointed at the new address manually.**
|
||||
|
||||
---
|
||||
|
||||
## Security Hardening
|
||||
|
||||
Of the nine items in this section, **two need action from you**: rotating any key that entered the Gemini common config, and checking the MCP entries you once imported through `ccswitch://` — "Upgrade Notes" spells out how. The rest take effect on upgrade with nothing for you to do.
|
||||
|
||||
If you never open `ccswitch://` links sent to you by other people and have never used a shared Gemini common config, these nine items mostly mean "less can go wrong from here on"; if either of those two applies to you, **this release is worth upgrading to first**.
|
||||
|
||||
### The Gemini Common Config No Longer Leaks Keys, and Scrubs Automatically After Upgrading
|
||||
|
||||
The Gemini common-config extractor previously stripped only `GEMINI_API_KEY` and `GOOGLE_GEMINI_BASE_URL` from the shared snippet and copied every other `env` entry verbatim — but `GOOGLE_API_KEY` is a first-class Gemini credential, so one account's key (along with any other credential-looking entries) was deep-merged into every Gemini provider that used the common config and sent to that provider's base URL, which may well be a third-party relay. The extractor now skips every key that matches a credential pattern (the same matcher set as the Claude extractor), the frontend snippet validator is aligned with it, and a hand edit cannot push one back in either. Because a Gemini snippet is never re-extracted once it exists, the first launch after upgrading also performs a **one-time scrub**: leaked credentials are cleared from the snippet, from every provider they were merged into, and from `~/.gemini/.env` — matched on the key name **plus** an exact value match, so a provider's own same-named key with a different value is not caught up in it — while the env file's formatting and comments are preserved. See "Upgrade Notes" for the scrub's details and caveats. ([#5811](https://github.com/farion1231/cc-switch/pull/5811))
|
||||
|
||||
### Skill Repository Installation Hardened: Path Traversal and Archive Limits
|
||||
|
||||
Installing or browsing Skills from a GitHub repository could previously write outside the target directory: archive entries were joined onto the destination path without normalization, so a ZIP containing `..` could escape the extraction directory (zip-slip); and repository coordinates were never validated, so a branch name like `../../../releases/download/v1/evil` could redirect the download to an arbitrary release asset — while Skill repositories can be added through an untrusted `ccswitch://` deep link and are enabled by default, so merely opening the Skills panel is enough to trigger a download. Skill `directory` values coming from backup restores, sync snapshots, and "import from app" were likewise joined into paths without validation, so an uninstall could `remove_dir_all` outside the managed directory. Every landing point now validates the directory name, repository owner / name / branch are allowlisted at the single download choke point, extraction has hard limits (10,000 entries, 512 MB written, 128 MB downloaded, 4 KB symlink targets, with self-referential links rejected), and the new error messages ship in all four locales. ([#5811](https://github.com/farion1231/cc-switch/pull/5811))
|
||||
|
||||
### Deep-Link Import Confirmation: See Everything, Flag the Risks
|
||||
|
||||
The `ccswitch://` MCP import confirmation previously rendered only a single truncated `Command:` line and showed nothing of `args`, `url`, or `env` — a link carrying `command: "sh"` plus `args: ["-c", "curl …|sh"]` and an `LD_PRELOAD` environment variable displayed as nothing more than an innocuous `sh`, yet on confirmation it was written into each app's live MCP file. The confirmation now renders the command, every argument, the URL, and the environment variables line by line, wrapping instead of truncating, so nothing gets clipped out of sight (env values whose key name contains TOKEN / KEY / SECRET / PASSWORD are shown masked, as a prefix plus asterisks). Values worth a second look are highlighted and collected into a warning block: shell interpreters carrying an inline-execution flag (including combined forms such as `bash -lc`, `cmd /C`, and PowerShell's `-Command` abbreviations), environment variables that change process loading behavior (`LD_*`, `DYLD_*`, `NODE_OPTIONS`, `PYTHONPATH`, `PATH`, proxy variables, and so on), and endpoints pointing at loopback / intranet / cloud metadata addresses. Flagging is purely advisory and never blocks an import — a local Ollama endpoint is a perfectly ordinary thing to have. The provider confirmation gets the same treatment, and the "will be written to all specified apps immediately" warning is now shown unconditionally instead of being gated on link-controlled fields.
|
||||
|
||||
### Deep-Link Usage Scripts: Disabled by Default, Code Shown Before Use
|
||||
|
||||
A usage-query script imported through a deep link is JavaScript that runs every time usage is queried, and it could previously be enabled without you ever having seen the code: the backend treated "code was supplied" as "consent to execute", and the confirmation showed only an enabled / disabled badge, never the script body. Scripts are now **disabled by default** — a link must explicitly carry `usageEnabled=true` to request enabling — and the confirmation shows the entire decoded script in a scrollable, fully wrapped code block, warning that it will be executed once enabled. If decoding fails it falls back to showing the raw payload, so a malformed script cannot masquerade as "no script". The script code is still stored on the provider, and you can enable it manually inside the app after reviewing it.
|
||||
|
||||
### URL-Safe Base64 Could Blank the Whole Confirmation Dialog
|
||||
|
||||
The two items above fix "the confirmation doesn't show enough"; this one fixes "the confirmation can show nothing at all". The backend accepts four Base64 variants (including RFC 4648 §5's URL-safe alphabet), while the frontend's `atob` only knows the standard alphabet and, when it cannot decode, **returns the input unchanged instead of raising an error** — so for one and the same payload, the backend decodes successfully and imports, while the frontend is left holding a blob of undecodable characters. Usage scripts and system prompts therefore displayed as opaque Base64; **MCP configs were the worst: the `JSON.parse` failure was swallowed by the component, the confirmation rendered "0 servers" with an empty list, and the backend went right on writing the real entries into the live MCP file**. Changing a single `/` to `_` in the payload was enough — the confirmation goes blank, the import still works, and the full display the two items above had just added is defeated along with it.
|
||||
|
||||
The frontend decoder now normalizes the URL-safe alphabet before decoding, so what the confirmation shows is always what will be imported; the shared decoder gains unit tests for the first time, containing a precondition self-assertion that the sample really does land on the URL-safe branch rather than happening to be identical in both encodings.
|
||||
|
||||
> This defect affects every version since v3.8.0. If you have ever imported MCP servers through a `ccswitch://` link, it is worth a check — see "Upgrade Notes".
|
||||
|
||||
### SQL Imports Reject Statements That Reach Outside the Importing Database
|
||||
|
||||
Importing a database backup previously validated only the file's header comment and then handed the whole text straight to `execute_batch` — a carefully crafted backup could `ATTACH DATABASE` to create a SQLite file anywhere the user can write, and that side effect happened before the import's own state validation, so the file landed even when the import failed as a whole; WebDAV / S3 sync snapshots go through the same code path. A SQLite authorizer is now installed for the duration of an external batch (and removed the moment it ends, so the app's own schema maintenance is unaffected): `ATTACH` / `DETACH`, `VACUUM`, virtual-table creation (file-backed modules such as csvfile can read and write arbitrary paths), and every action SQLite reports as unknown are all rejected — future new statements fail by default; of the PRAGMAs, only the two the exporter actually writes, `foreign_keys` and `user_version`, are allowed through.
|
||||
|
||||
### Prototype Pollution in Common-Config Snippets
|
||||
|
||||
The three walkers that apply, remove, and compare common-config snippets all used to follow `__proto__` into the global `Object.prototype`: `JSON.parse('{"__proto__":{…}}')` yields an own enumerable property, so merging wrote attacker-chosen values onto the global prototype — and the `settings` table is overwritten wholesale from the remote during sync, so once a malicious WebDAV / S3 snapshot has landed, opening a provider form once is enough to trigger the merge. All three walkers now skip `__proto__`, `constructor`, and `prototype`; the "common config applied" comparison additionally requires own properties, which incidentally fixes a visible oddity — `{"__proto__":{}}` used to be judged a subset of any config.
|
||||
|
||||
### Command Injection Through Directory Names When Launching a Terminal
|
||||
|
||||
When resuming a session in an external terminal, the `cd` line previously wrapped the working directory in double quotes and escaped only backslashes and double quotes — but inside double quotes a shell still expands `$(…)`, backticks, and `$VAR`, and this value is the real project path recorded in the CLI's session history, where on macOS a directory name may legitimately contain those characters. Name a folder that way, and clicking "Resume" executes the embedded command in your terminal, with no compromised component involved anywhere. The three launchers that assemble a shell line — Terminal.app, iTerm, and kitty — switch to POSIX single-quote escaping, under which nothing expands at all (the AppleScript quoting layer needed to get through Terminal / iTerm is handled safely too); Ghostty, WezTerm / Kaku, and Alacritty already passed the directory as a separate argument and were safe to begin with.
|
||||
|
||||
### GrokBuild Credential Resolution No Longer Substitutes or Inlines Environment Keys
|
||||
|
||||
GrokBuild credential extraction previously fell back to the process-level `XAI_API_KEY` when the `env_key` variable named in the config was unset — silently substituting another account's key and sending it to whatever base URL the config pointed at; credentials now come only from an explicit inline `api_key` or from the environment variable that `env_key` names exactly. Deep-link imports no longer resolve environment variables into a plaintext `api_key`; a link carrying only an `env_key` name is rejected with a prompt to add it manually — accepting it as-is would mean the victim's environment key still gets resolved at request time and sent to the address the link declares. Fixed along the way: base URL resolution is decoupled from credential resolution, where previously a missing credential cleared the base URL along with it, so on macOS (where GUI processes do not inherit the shell environment) the address shown in the UI differed from the one actually used, and the usage script's `{{baseUrl}}` expanded to empty. ([#5811](https://github.com/farion1231/cc-switch/pull/5811))
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
### "Using GPT Models in Claude Code" Guide Completed in English and Japanese
|
||||
|
||||
The local routing guide that previously existed only in Chinese has now been fully ported to English and Japanese, covering both connection paths end to end: a third-party OpenAI Responses gateway (API key), and a ChatGPT Plus/Pro subscription through Codex device-code OAuth sign-in. Both routing guides were also retitled around "which model" rather than "which client pairs with which" — [Using GPT Models in Claude Code](../guides/claude-codex-routing-guide-en.md) and [Using Claude Models in Codex](../guides/codex-claude-routing-guide-en.md) — and every cross-link (including the v3.18.0 release notes in all three languages) now points at the version in the reader's own language.
|
||||
|
||||
### User Manual: Deep-Link `usageEnabled` Default Corrected
|
||||
|
||||
The deep-link reference in the three-language user manual previously claimed `usageEnabled` defaults to `true`, when the actual default is `false`, matching the importer. The manual now states the correct default and adds two consequences: the confirmation shows the script's full code before import, and without an explicit `usageEnabled=true` the script is imported disabled and can be enabled inside the app later.
|
||||
|
||||
### SECURITY.md: Threat Model and Reporting Scope
|
||||
|
||||
`SECURITY.md` gains a bilingual threat model and an explicit in-scope / out-of-scope list, triaging reports by "who controls this input" rather than "which API the value eventually reached": the built-in WebView renderer is declared a trusted component (with four independently verifiable facts and the conditions that would invalidate them); deep-link payloads, WebDAV / S3 restore data, imported files, upstream API responses, and inbound requests to the local proxy are all listed as untrusted inputs, and reports about them are welcome.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
### No Database Migration in This Release
|
||||
|
||||
v3.19.0 contains no schema migration (the version stays at v16), so it is ready to use straight after upgrading, with no wait for a data rebuild.
|
||||
|
||||
### One-Time Gemini Key Scrub (Please Read)
|
||||
|
||||
The first launch after upgrading performs a one-time Gemini common-config scrub before the regular config extraction. **Some Gemini providers may afterwards report a missing API key**: entries are deleted by matching a credential-style key name plus an exact value match, and what usually gets removed is another provider's credential that leaked in through the shared snippet (that provider's own original value was already overwritten when the leak happened and cannot be recovered) — but **a same-value key you intentionally reused across several Gemini providers is removed too**. Either way, rotate before re-entering: **any key that ever entered the shared Gemini snippet should be treated as exposed**. The deleted key names and the affected provider ids (never the values) are recorded in the `settings` table under `gemini_common_config_scrub_audit_v1`, which you can use to locate each provider whose key needs filling in again.
|
||||
|
||||
### Ever Imported MCP Through a Deep Link? Worth a Check (Please Read)
|
||||
|
||||
Before this release, the `ccswitch://` MCP import confirmation could **fail to show what was about to be written**: arguments and environment variables were not rendered at all (`command: "sh"` plus `args: ["-c", …]` displayed as a harmless `sh`), and if the payload was URL-safe Base64 encoded the whole list displayed as "0 servers" — while in both cases the backend went right on writing the entries into each app's live MCP file. Both defects affect **every version since v3.8.0** and are fixed together in this release.
|
||||
|
||||
Exploiting this requires you to personally open an attacker-supplied link and click "Import", so the vast majority of users are unaffected. **If you have in fact opened a `ccswitch://` MCP import link from a source you did not fully trust**, it is worth going through the MCP panel entry by entry, or checking `mcpServers` in `~/.claude.json` directly (for Codex, `mcp_servers` in `~/.codex/config.toml`), to confirm there is nothing there you do not recognize — MCP servers are executed as child processes the next time the CLI starts.
|
||||
|
||||
### Deep-Link Usage Scripts Are Disabled by Default
|
||||
|
||||
A deep link carrying a usage-query script is now imported with the script disabled, unless the link explicitly carries `usageEnabled=true`. Links that relied on automatic enabling (some partners' one-click setup links, for example) will import the script without turning usage queries on — review the code and enable it manually in the provider editor. Usage scripts configured by hand inside the app are unaffected.
|
||||
|
||||
### New Default Models Only Affect Newly Created Providers
|
||||
|
||||
Saved providers keep the model ID they were created with; using the new models requires editing them by hand. Claude Desktop's opus route advances to `claude-opus-5` and `claude-opus-4-8` moves into the compatibility-alias slot, so existing configs still resolve as before.
|
||||
|
||||
### Pricing Seeding and the Local Pricing File
|
||||
|
||||
The new pricing rows (`claude-opus-5`, `gemini-3.6-flash`, `grok-4.5-build`) are appended on next launch via insert-if-absent — **seeding never overwrites a price you have edited**. `~/.cc-switch/model-pricing.json` starts empty and records only the manual price edits and deletions made from this release on — earlier edits are not migrated into it; to make them survive a database rebuild, just re-save them once. The models.dev automatic sync stays off until you turn it on; once it is on, it is the one path that overwrites matching prices, built-in and hand-edited alike.
|
||||
|
||||
### GrokBuild Implicit Environment-Variable Fallback Removed
|
||||
|
||||
GrokBuild providers that relied on the implicit `XAI_API_KEY` environment fallback now need an explicit `api_key` or a correctly named `env_key`.
|
||||
|
||||
### Grok Official Mode Usage Is Intentionally Delayed
|
||||
|
||||
Grok usage from official mode appears after a delay of roughly ten minutes plus one sync cycle — events settle first and are then checked against proxy-recorded rows to prevent double counting; if routed traffic and official traffic alternate within the window, some official turns are skipped rather than risking a duplicate count. Grok Build rows that the old cost backfill overestimated keep their values — the backfill only handles zero-cost rows and never revises an existing positive cost.
|
||||
|
||||
### The Update Mirror Takes Effect from the Next Release
|
||||
|
||||
The updater's endpoint list is baked into the app binary, so existing installations still query only GitHub until they upgrade to a version containing this change; from then on the `dl.ccswitch.io` mirror comes first, with GitHub as fallback.
|
||||
|
||||
### Sponsor Domain Migrations Do Not Change Existing Providers
|
||||
|
||||
Already-created providers keep the old address stored in the database and still point at the old domain. To move to the new domain, edit the provider's address by hand, or recreate it from the refreshed preset.
|
||||
|
||||
---
|
||||
|
||||
## Risk Notice
|
||||
|
||||
### SuperGrok Quota Queries (New in This Release)
|
||||
|
||||
The SuperGrok quota display on provider cards reads Grok CLI's own OAuth credentials (`~/.grok/auth.json`) and queries grok.com's billing endpoint — an endpoint that is not publicly documented, and whose response parsing is based on observation of the current format, so this feature may stop working once xAI changes the interface (at which point the card degrades to showing no quota, with nothing else affected). CC Switch neither stores nor modifies these credentials.
|
||||
|
||||
### Carried-Over Notices
|
||||
|
||||
**xAI Grok OAuth sign-in**: reuses the public OAuth client identity of the official Grok CLI; using it could lead to account restriction or suspension — see the [v3.18.0 release notes](v3.18.0-en.md#risk-notice) for details.
|
||||
|
||||
**Codex OAuth reverse proxy**: using a ChatGPT subscription's Codex OAuth through a reverse proxy may violate OpenAI's terms of service. See the [v3.13.0 release notes](v3.13.0-en.md#️-risk-notice) for details.
|
||||
|
||||
**Third-party provider routing**: when CC Switch's local proxy converts and forwards Codex, Claude Desktop, or Grok Build requests to third-party providers, each provider may have different requirements for billing, compliance, and data retention. Read the target provider's terms before use.
|
||||
|
||||
By enabling these features, users accept the related risks. CC Switch is not responsible for account restrictions, warnings, or service suspensions caused by using these features.
|
||||
|
||||
---
|
||||
|
||||
## Thanks
|
||||
|
||||
This release's security hardening came almost entirely from outside — one PR, plus the security reports we received.
|
||||
|
||||
### Code Contributions
|
||||
|
||||
- [#5811](https://github.com/farion1231/cc-switch/pull/5811): zip-slip and repository-coordinate traversal in Skill installation, the Gemini common-config credential leak and its one-time scrub, GrokBuild credential resolution, and fixes across several panic paths, thanks @zayokami. This is the broadest single body of work in the release.
|
||||
- [#5734](https://github.com/farion1231/cc-switch/pull/5734): models.dev automatic pricing sync, thanks @YUZHEthefool.
|
||||
- [#5626](https://github.com/farion1231/cc-switch/pull/5626): faster Codex usage import for forked sessions, thanks @ayanamislover (co-credited with @SaladDay).
|
||||
|
||||
### Security Reports
|
||||
|
||||
Four of the fixes in this release's "Security Hardening" section came from security reports sent privately. Thanks to **23pds** (SlowMist) and **zues devil** — attributed item by item below:
|
||||
|
||||
- **The deep-link import confirmation showed only a single truncated `Command:` line** — `args`, `url`, and `env` were not rendered at all, so an `sh -c` payload with `LD_PRELOAD` looked like nothing but an `sh` in the UI. This is the highest-impact item in the release. (23pds, SlowMist)
|
||||
- **SQL backup imports were unconstrained** — `ATTACH DATABASE` could create a file anywhere the user can write, and the side effect happened before the import's own validation. (zues devil)
|
||||
- **Command injection through directory names when launching an external terminal** — the `cd` line was double-quoted, so `$(…)` still expanded, and this value is the real project path recorded in the session history. (Reported independently by zues devil and 23pds, against the built-in launchers and the custom template paths respectively.)
|
||||
- **Prototype pollution in common-config snippet merging** — all three walkers followed `__proto__` into the global `Object.prototype`. (23pds, SlowMist)
|
||||
|
||||
These reports also prompted us to fill in the threat model and reporting scope in [SECURITY.md](../../SECURITY.md) — until then, this project documented only how to report, never what counts as a vulnerability.
|
||||
|
||||
The other two deep-link fixes (usage scripts disabled by default, and the URL-safe Base64 bypass) were found while reviewing those fixes themselves, and were not part of the original reports.
|
||||
|
||||
### Issue Reports
|
||||
|
||||
Thanks to the users who reported proxy image reads blowing up the context in [#4465](https://github.com/farion1231/cc-switch/issues/4465) and [#5663](https://github.com/farion1231/cc-switch/issues/5663) — this release's most important proxy fix came from the reproduction clues in those real-world scenarios.
|
||||
|
||||
---
|
||||
|
||||
## Download & Install
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) and download the build for your system, or get it from the official site [ccswitch.io](https://ccswitch.io) (from this release on, downloads are distributed through Cloudflare edge nodes and no longer depend on GitHub being reachable).
|
||||
|
||||
### System Requirements
|
||||
|
||||
| System | Minimum Version | Architecture |
|
||||
| ------- | -------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 and later | x64 / ARM64 |
|
||||
| macOS | macOS 12 (Monterey)+ | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------------------- | ------------------------------------------------ |
|
||||
| `CC-Switch-v3.19.0-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||
| `CC-Switch-v3.19.0-Windows-Portable.zip` | Portable build, unzip and run |
|
||||
|
||||
Windows ARM64 devices should pick the artifact whose file name carries the `arm64` tag.
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------- | ----------------------------------------------------- |
|
||||
| `CC-Switch-v3.19.0-macOS.dmg` | **Recommended** - DMG installer, drag to Applications |
|
||||
| `CC-Switch-v3.19.0-macOS.zip` | Unzip and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.19.0-macOS.tar.gz` | For Homebrew install and auto-update |
|
||||
|
||||
Homebrew install:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Upgrade:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux assets are available for both **x86_64** and **ARM64** (`aarch64`). Choose the file whose architecture tag matches your machine's `uname -m` output:
|
||||
|
||||
- `CC-Switch-v3.19.0-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.19.0-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| Distribution | Recommended Format | Install Command |
|
||||
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
|
||||
| Other distributions / unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,353 @@
|
||||
# CC Switch v3.19.0
|
||||
|
||||
> 本リリースの主軸は**より安心して使えること**です。集中的なセキュリティ強化——Skill のインストール、`ccswitch://` インポート確認、SQL バックアップのインポート、共通設定のマージ、ターミナル起動をすべて引き締めました。うち 2 点は 1 分ほど確認をお願いします——Gemini 共通設定の API キー漏えいを修正し、アップグレード後に自動でクリーンアップします(**キーのローテーションが必要です**)。そして `ccswitch://` の MCP インポート確認ダイアログは、これまで書き込まれるコマンドを表示できないことがありました(**出所の不明なインポートリンクを開いたことがある場合は、一度確認することをおすすめします**)。どちらも「アップグレード時の注意」を参照してください。プロキシの正確性についても大きな修正があります——**プロキシ経由の画像読み取りでコンテキストが溢れなくなりました**(スクリーンショット 1 枚が 10 万 token 超を消費し、2〜3 枚で Codex のセッションが 400 で固まっていました)。手間を減らす部分も実質的です——**モデル価格を models.dev に自動メンテナンスさせられる**ようになり、Grok CLI 公式ログインモードの使用量と SuperGrok サブスクリプション残量がついにダッシュボードに入り、**アプリ内更新は `dl.ccswitch.io` ミラー経由**になりました——GitHub につながりにくい環境でも問題なくアップグレードできます。
|
||||
|
||||
**[English →](v3.19.0-en.md) | [中文版 →](v3.19.0-zh.md)**
|
||||
|
||||
---
|
||||
|
||||
## ハイライト:本リリースでできること
|
||||
|
||||
- **プロキシ下でも画像を普通に読める、コンテキストは溢れない**:Codex の `view_image` や画像を返す MCP ツールでは、これまで画像がツールテキストへシリアライズされ、プレーンテキストとして token 計算されていました(約 9,000 倍の膨張)。現在はすべての変換ブリッジが画像をネイティブ形式へ復元してから送信します(ファイルと音声は 2 本の Chat ブリッジで併せて対応)。実測では同一のリプレイターンが 85k+ token から約 12k へ下がり、キャッシュヒット率は 99% でした。
|
||||
- **モデル価格を models.dev に自動メンテナンスさせる**:使用量パネルに「models.dev 自動価格同期」を追加しました(既定は無効)。有効にすると、起動時に選択したモデルの価格を自動更新します(6 時間に最大 1 回)。完全なカタログから追跡するモデルを選ぶことも、各社の最新のよく使うモデルを自動的に含めることもできます。手動での価格変更と削除は、本リリースから `~/.cc-switch/model-pricing.json` に記録され、データベースを再構築しても失われません。
|
||||
- **Grok 公式モードの使用量とサブスクリプション残量が見える**:Grok CLI が公式 OAuth でログインしている場合はローカルプロキシを経由できないため、この分の消費はこれまでまったく見えませんでした。現在はセッションログからターンごとの使用量をインポートし、ダッシュボードに「Grok Build (Session)」として表示します。公式カテゴリの Grok Build プロバイダーのカードには、SuperGrok サブスクリプションのクォータ使用量とリセット時刻も直接表示されます。
|
||||
- **`ccswitch://` インポートリンクをより安心して開ける**:確認ダイアログはコマンド、各引数、URL、環境変数を余さず表示し(認証情報らしき値はマスク表示)、よく見るべき値をハイライトします——シェルのインライン実行、ロード挙動を変える環境変数、内部ネットワーク / メタデータのアドレスです。使用量照会スクリプトはコード全文を表示し、**既定では無効の状態でインポート**されます。
|
||||
- **Gemini プロバイダーに他人のキーが混入していないことを確認**:共通設定の共有スニペットは、これまで `GOOGLE_API_KEY` などの認証情報を、それを使うすべての Gemini プロバイダーにコピーしていました。本リリースでこの経路を閉じ、アップグレード後の初回起動で一回限りのクリーンアップを自動実行します。**共有 Gemini スニペットを通過したキーはすべて漏えい済みとみなし、ローテーションしてから入力し直してください**(「アップグレード時の注意」参照)。
|
||||
- **GitHub につながりにくくてもアプリを更新できる**:アプリ内アップデーターは `https://dl.ccswitch.io/latest.json`(Cloudflare R2 ミラー)を優先して照会し、GitHub をフォールバックにします。minisign の署名検証は変わらず、ミラー自体は信頼されません。
|
||||
- **新規プロバイダーで最新モデルをすぐ使える**:プリセットの既定モデルを Claude Opus 5、GPT-5.6 Sol、Gemini 3.6 Flash にアップグレードし、対応する価格も登録しました。作成済みのプロバイダーはそのままです。
|
||||
- **fork の多い Codex 使用量履歴をより速くインポート**:親 rollout ファイルは一度だけ解析してすべての fork 地点で共有するようになり、fork の多い履歴の再構築が明らかに高速化します。インポート結果はバイト単位で変わりません。
|
||||
|
||||
---
|
||||
|
||||
## 利用ガイド
|
||||
|
||||
本リリースの新機能は主に使用量パネルと `ccswitch://` ディープリンクインポートにあります。以下のドキュメントもあわせてご覧ください:
|
||||
|
||||
- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**:使用量ダッシュボードのデータソースと集計方法を確認できます。本リリースでは models.dev 自動価格同期と Grok 公式モードの使用量インポートを追加しました。
|
||||
- **[ディープリンクインポート(ccswitch://)](../user-manual/ja/5-faq/5.3-deeplink.md)**:インポート確認ダイアログの各フィールドと、`usageEnabled` などのパラメーターの既定値を説明します(本リリースから使用量スクリプトは既定で無効の状態でインポートされます。ドキュメントも修正済みです)。
|
||||
- **[セキュリティポリシー(SECURITY.md)](../../SECURITY.md)**:本リリースで脅威モデルと報告範囲を整備しました——どの入力を信頼しないか、どのような問題の報告を歓迎するかが一目で分かります。
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## 唯一の公式チャネル(必ずお読みください)
|
||||
>
|
||||
> CC Switch は**完全に無料・オープンソース**のデスクトップアプリで、**ユーザーから料金を徴収することはありません**。本ソフトウェアは下記の公式チャネルからのみ入手してください:
|
||||
>
|
||||
> | チャネル | 唯一の公式 |
|
||||
> | ------------ | ------------------------------------------------------------------------------ |
|
||||
> | 公式サイト | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | ソースコード | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | ダウンロード | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | 偽サイト通報 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **料金請求・チャージ・認証情報の提供を求める「CC Switch」サイトやクライアントはすべて偽物です。** 支払いを誘導された場合は直ちに操作を中止し、GitHub Issues からご報告ください。
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.19.0 は、集中的なセキュリティ強化と、プロキシの正確性に関する大きな修正が主役です。セキュリティ側([#5811](https://github.com/farion1231/cc-switch/pull/5811) および後続の個別修正):GitHub リポジトリからの Skill インストールは zip-slip とパストラバーサルを塞ぎ、アーカイブに上限を設けました。Gemini 共通設定のキー漏えい経路を閉じ、アップグレード後の初回起動で一回限りのクリーンアップを自動実行して、既に他プロバイダーの設定へ漏れ出したキーを取り除きます。SQL バックアップのインポートは SQLite authorizer の下で実行するようになり、`ATTACH` などインポート対象データベースの外に触れられる文はすべて拒否します。共通設定スニペットのマージは `__proto__` をたどってグローバルなプロトタイプを汚染しなくなりました。外部ターミナルの起動は POSIX のシングルクォートエスケープに変え、ディレクトリ名からコマンドを注入することはもうできません。`ccswitch://` のインポート確認ダイアログはペイロードを余さず表示し(認証情報らしき値はマスク表示)、リスクのある値をマークします。使用量スクリプトは既定で無効の状態でインポートされます。プロキシ側では、ツール結果に含まれる画像がツールテキストへシリアライズされなくなり、各変換ブリッジでネイティブなメディアとして復元して送信されるようになりました(ファイルと音声は 2 本の Chat ブリッジで併せて対応)——「113 KB のスクリーンショット 1 枚が 10 万 token 超を食い、2〜3 枚で Codex のセッションが 400 で固まる」問題に終止符を打っています([#4465](https://github.com/farion1231/cc-switch/issues/4465)、[#5663](https://github.com/farion1231/cc-switch/issues/5663))。
|
||||
|
||||
使用量統計には 2 つの新機能が入りました。**models.dev 自動価格同期**(オプトイン、[#5734](https://github.com/farion1231/cc-switch/pull/5734))と、それに伴う手動の価格変更 / 削除の、人間が編集可能な `~/.cc-switch/model-pricing.json` への永続化。そして **Grok CLI 公式 OAuth モードの使用量インポート**——このトラフィックはローカルプロキシを通せず、これまでまったく見えませんでした——加えてプロバイダーカードでの SuperGrok サブスクリプションクォータの表示です。配信と使い勝手まわりでは、アプリ内更新が `dl.ccswitch.io` の Cloudflare R2 ミラーを優先するようになり(GitHub にフォールバック、署名検証は不変)、Codex 使用量インポートは fork セッションで解析済みの親 rollout タイムラインを再利用し([#5626](https://github.com/farion1231/cc-switch/pull/5626))、プリセットの既定モデルは Claude Opus 5、GPT-5.6 Sol、Gemini 3.6 Flash にアップグレード、OpenClaw の Kimi For Coding プリセットは base URL を修正、ツールバーのアプリ切り替えはアイコンのみになりました。本リリースに**データベースの schema 移行はありません**。アップグレードは軽量です。
|
||||
|
||||
**リリース日**: 2026-07-30
|
||||
|
||||
**Stats**: 38 commits | 132 files changed | +14,926 / -1,415 lines
|
||||
|
||||
---
|
||||
|
||||
## 追加機能
|
||||
|
||||
### models.dev 自動価格同期
|
||||
|
||||
使用量パネルの価格セクションに「models.dev 自動価格同期」カードが加わりました。**既定は無効で、手動での有効化が必要です**。有効にする際には確認の説明が表示されます——CC Switch は起動時に(6 時間に最大 1 回)models.dev から選択したモデルの価格を更新し、**同名モデルの内蔵価格も手動で設定した価格も上書きされます**。「モデルを選択」ダイアログでは models.dev の完全なカタログを検索・絞り込みでき、さらに「よく使うモデルを自動的に含める」オプションもあります。これは Claude、GPT、Gemini、Grok、DeepSeek、Qwen、MiMo、LongCat、Kimi、MiniMax、GLM 各社の最近リリースされたモデル(ファミリーごとに最大 6 個、個別に除外可能)をカバーします。カードには前回の同期時刻とエラーが表示され、「今すぐ同期」を実行でき、ローカルの価格ファイルを開いたり再読み込みしたりもできます。
|
||||
|
||||
本リリースから、手動での価格変更と価格削除は、データベースの隣に置かれる人間が編集可能なファイル `~/.cc-switch/model-pricing.json` にも記録され、起動のたびに再適用されます——データベースを再構築しても手動価格が失われなくなり、削除した内蔵価格もようやく削除したままにできます(tombstone として記録され、再シードされません)。なおこのファイルは作成時点では空で、**既存の価格テーブルから遡って書き出すことは意図的にしていません**(そうすると内蔵価格までオーバーライド項目として書き込まれ、将来の内蔵価格の修正を塞いでしまうためです)。アップグレード前に行った価格変更は引き続きデータベースにのみ存在するので、一度保存し直せばファイルに入ります。同期によって実際に価格が変わった場合は、**これまでコストが算出されたことのない**(ゼロまたは欠落の)過去の使用量行を新しい価格で計算し直します——既にコストがある行は元の値のままです。取得に失敗した場合やオフラインの場合でも、起動をブロックすることは決してありません。models.dev のリストからはテキスト以外のモデルと非推奨のモデル(音声 / 画像 / 動画 / embedding など)も除外し、手動で価格を選ぶダイアログもすっきりしました。([#5734](https://github.com/farion1231/cc-switch/pull/5734))
|
||||
|
||||
### Grok 公式モードの使用量が、ついにダッシュボードへ
|
||||
|
||||
Grok CLI が公式 OAuth でログインしているときはローカルプロキシへルーティングできません——Grok は空の設定をモードスイッチとして使うため、CC Switch を指し示す場所がないのです——そのためこの分の消費は、これまで使用量ダッシュボードでまったく見えませんでした。現在は通常のセッションログ同期に合わせて、`~/.grok/sessions`(アーカイブ済みセッションを含む)の `updates.jsonl` から `turn_completed` イベント単位でターンごとの使用量をインポートします。コストは CLI 自身が報告する正確な数値を優先し、欠落時はローカル価格にフォールバックします(内蔵価格テーブルに `grok-4.5-build` を追加。100 万 token あたり入力 $2 / 出力 $6 / キャッシュ読み取り $0.30)。インポート行は上流のターンごとの ID をキーにするため、巻き戻したセッションで重複計上されることはありません。確定待ちウィンドウには直近のプロキシ活動チェックを加え、「ルーティング + 公式」を併用しても同じトラフィックが二重に計上されないようにしています。ダッシュボードでは新しい行がプロバイダー名「Grok Build (Session)」として表示され、アプリフィルターに Grok Build の選択肢が加わり、データソース内訳に専用アイコン付きの「Grok Build Session」項目が追加されました。4 言語対応です。
|
||||
|
||||
### プロバイダーカード上の SuperGrok サブスクリプションクォータ
|
||||
|
||||
カテゴリが「公式」の Grok Build プロバイダーでは、カード上に SuperGrok サブスクリプションの使用量が直接表示されるようになりました——Claude Code / Codex / Gemini の公式サブスクリプションのフッターと並ぶ形です。CC Switch は Grok CLI 自身の OAuth 認証情報(`~/.grok/auth.json`)を読み取り、grok.com の課金 endpoint に問い合わせて、クォータウィンドウの使用率とリセット時刻を取得します。リセット間隔が判別できる場合は「週」または「月」と表示し、そうでない場合は新設の「Credits」区分に入ります(トレイの使用量サマリーでは `c` グループとして表示)。ネットワークの一時的な切断時は前回の値を保持して再試行し、フッターを空にしません。トークンが期限切れの場合は `grok login` のやり直しを促します。Claude Code、Claude Desktop、Codex の管理下 xAI OAuth(SuperGrok)プロバイダーにも同じクォータ表示が自動的に付きます——データはそのプロバイダーに紐づくアカウントから取得され、使用量スクリプトの入口はそれに伴って非表示になります。なお Grok Build プロバイダーの「公式」判定は、現在は `category` フィールドのみで行い、設定内容の推定は行いません。
|
||||
|
||||
### Claude Opus 5 の内蔵価格
|
||||
|
||||
`claude-opus-5` を内蔵価格テーブルに追加しました。100 万 token あたり入力 $5 / 出力 $25、キャッシュ読み取り $0.50 / キャッシュ書き込み $6.25 で、使用量が $0 と表示されなくなります。「存在しなければ挿入」でシードするため、編集済みの価格は影響を受けません(Opus 5 の fast モードは別建ての課金のため、意図的にテーブルへ入れていません)。
|
||||
|
||||
### プリセットカタログの更新
|
||||
|
||||
A6API(同一モデルに複数の上流を持ち自動で最適な経路を選ぶアグリゲーションプラットフォーム)を 8 つのアプリのスポンサープリセットに追加しました。PackyCode のプリセットは、バックアップ endpoint に対応する 5 種類のプリセット(Claude Code / Claude Desktop / Codex / Gemini CLI / Grok Build)で 3 つのバックアップアドレスを追加し、アドレスマネージャーと速度計測から選択できます。AICoding パートナープリセットが 7 アプリで復帰し、スポンサーの並び順を README と再度揃えました。
|
||||
|
||||
---
|
||||
|
||||
## 変更
|
||||
|
||||
### プリセット既定モデルのアップグレード:Claude Opus 5、GPT-5.6 Sol、Gemini 3.6 Flash
|
||||
|
||||
内蔵プリセットの既定モデルが全面的に現行世代になりました。`claude-opus-5` が `claude-opus-4-8` を置き換え(3 種類の命名形態すべてをカバー)、`gpt-5.6-sol` が `gpt-5.5` と裸の `gpt-5.6` を置き換え、`gemini-3.6-flash` が `gemini-3.5-flash` を置き換えます。ミラーされているすべての箇所も同期更新しました——汎用 / NewAPI の既定値、Codex カスタム `config.toml` テンプレート、推奨リスト、フォームのプレースホルダー、4 言語の文言です。`gemini-3.6-flash` の価格も登録しました(100 万 token あたり $1.50 / $7.50、キャッシュ読み取り $0.15)。`gemini-3.1-pro-preview` に固定されたままだった Code0 と Qiniu の Gemini プリセットも 3.6 Flash に揃えています——これは**意図的な tier の調整**です。3.6 に Pro 版はなく、3.5 Pro は引き続きパートナーテストに限定されています。**既定値は新規作成するプロバイダーにのみ影響します**。保存済みのプロバイダーは作成時のモデルを維持します。Claude Desktop の opus ルートの現在値は `claude-opus-5` に前進し、`claude-opus-4-8` は互換エイリアスのスロットへ移りました。既存の設定は従来どおり解決されます。
|
||||
|
||||
### アプリ内更新は ccswitch.io ミラー経由へ
|
||||
|
||||
アップデーターは `https://dl.ccswitch.io/latest.json`——リリースマニフェストの Cloudflare R2 ミラー——を優先して照会し、GitHub Releases をフォールバックにするようになりました。更新の確認とダウンロードが GitHub への到達性に依存しなくなります。ミラーのマニフェストは各プラットフォームのダウンロードを同一のバケットへ向けますが、minisign の署名はそのままです。署名が保証するのは URL ではなくファイルの内容であり、ダウンロードした成果物はいずれも内蔵の公開鍵に対して検証されます——**ミラー自体は常に信頼されません**。配信は release でゲートされた同期ワークフローが担当し、tag が確かに GitHub の `releases/latest` である場合にのみルートのマニフェストを書き換えるため、ミラーがユーザーを古いバージョンへ引き戻すことはありません。
|
||||
|
||||
### Codex 使用量インポート:fork セッションの高速化
|
||||
|
||||
Codex 使用量統計のインポートと再構築で、同じ親 rollout ファイルを fork 地点ごとに読み直すことはなくなりました。親の `~/.codex/sessions/*.jsonl` は 1 回だけ解析してメモリ上の token タイムラインを生成し、そこから fork したすべての子セッションで共有します。各子セッションの切り詰め位置はメモリ上のフィルタリングで処理されます。キャッシュはファイル同一性のスタンプ(更新時刻、サイズ、加えて Unix では device/inode、Windows ではボリュームシリアル番号 + ファイル ID)で検証されるため、追記・ローテーション・置換された親ファイルは古いデータを返さず読み直されます。高速化の度合いは fork の密度に応じて変わります——fork の多い履歴では冗長な解析が大幅に減り、fork の少ない履歴はほぼ変わりません。いずれの場合もインポート結果はバイト単位で同一です。([#5626](https://github.com/farion1231/cc-switch/pull/5626))
|
||||
|
||||
### ツールバーのアプリ切り替えをアイコンのみに
|
||||
|
||||
切り替えボタンはアイコンの横にテキストラベルを描画しなくなりました——管理対象アプリが 8 つに増えてからは、ラベルはほぼ常にオーバーフロー検出で畳まれていたためです。そこで ResizeObserver ベースの自動コンパクト機構を取り除き、常にアイコンのみを表示します。アプリ名はホバー時のツールチップに残り、スクリーンリーダーからは `aria-label` 経由で従来どおりアクセスできます。
|
||||
|
||||
### スポンサーのドメインと紹介リンクの更新
|
||||
|
||||
複数のスポンサーがドメインを移転したため、プリセットのアドレス、バックアップ endpoint、紹介リンク、README の行を同期しました(PackyCode → `www.packyapi.ai`、RightCode → `www.rightapi.ai`、ClaudeAPI → `www.apito.ai`、APINebula → `apinebula.ai`、AICodeMirror → `.ai`、AICoding → `.inc`、AIGoCode → `.app`)。あわせて、既に無効になっていたバックアップ endpoint を 2 つ削除しています。**作成済みのプロバイダーはデータベースに保存された旧アドレスを保持します**——新しいドメインへ移したい場合は、アドレスを手動で変更するか、更新後のプリセットから作り直してください。
|
||||
|
||||
---
|
||||
|
||||
## 修正
|
||||
|
||||
### プロキシ経由の画像読み取りでコンテキストが溢れない
|
||||
|
||||
クライアントがツール呼び出しで画像を読み取るとき——Codex の `view_image` や、画像を返す任意の MCP ツール——プロキシのプロトコル変換は画像ブロック全体をツールメッセージのテキストへシリアライズしており、上流は base64 をプレーンテキストとして token 計算していました。約 9,000 倍の膨張で、113 KB の PNG 1 枚が 10 万 prompt token 超に換算されていました。Codex は毎ターン履歴全体をリプレイするため、スクリーンショットが 2〜3 枚あればセッションをコンテキストウィンドウの外へ押し出し、400 の繰り返しで固まらせるのに十分でした([#4465](https://github.com/farion1231/cc-switch/issues/4465)、[#5663](https://github.com/farion1231/cc-switch/issues/5663))。
|
||||
|
||||
プロキシはメディアのペイロードをツール結果から取り出し、各ブリッジのネイティブ形式で送り直すようになりました——**画像は全ブリッジでカバーされ、ファイルと音声は対象プロトコルが対応している箇所で有効になります**。2 本の Chat ブリッジ(Claude→Chat、Codex Responses→Chat)は画像 / ファイル / 音声を運び、ツールメッセージには短いマーカーを残して、メディアは合成したユーザーメッセージとしてツールバッチの直後に続きます。Claude→Responses はネイティブな `input_image` に復元し、Codex / GrokBuild→Anthropic は標準の Anthropic 画像ブロックを再構築し、Claude→Gemini は Gemini 3 ではマルチモーダルな `functionResponse.parts` を使い(旧モデルでは `inlineData`)、インライン base64 画像のみを受け付けます。検出は型付きの Responses ブロック、Anthropic の `source` ブロック、MCP の `data`+`mimeType` 結果、画像 data URL 全体をカバーし、配列やネストされた `content` ラッパー(JSON エンコードされたツール出力を含む)も貫通します。出力がメディアを含むと判定された場合に限り、そこに残る data URL と裸の base64 はプレースホルダーへ畳まれます——**裸の base64 それ自体がメディア判定を引き起こすことは決してありません**。通常のツール出力はそのままです。メディアを含まないツール結果はすべてのブリッジで従来どおりバイト単位に同一で、prompt キャッシュのプレフィックスには影響しません。送信するメディアブロックには意図的に `cache_control` マーカーを付けないため、GLM や Qwen のような厳格な上流に拒否されることもありません。Kimi K3 でのエンドツーエンド実測では、同一のリプレイターンが安定して入力約 12k token・キャッシュヒット率 99% となりました。以前は毎回のリプレイで 85k+ の base64 テキストを背負っていました。
|
||||
|
||||
### 「非対応画像フォールバック」がツール結果の中の画像も見えるように
|
||||
|
||||
「非対応画像フォールバック」設定は、プロバイダーがテキストのみの場合や上流が画像を拒否した場合に、画像ブロックをプレースホルダーマーカーへ置き換えます。しかしこれまでは、構造化ブロックのままの画像しか見えていませんでした——base64 テキストへ平坦化されたツール結果の画像は見えず、テキストのみの上流ではそのまま失敗して復旧しようがありませんでした。メディアクリーナーは、各経路で対称的にツール出力内のメディアを検出して剥がすようになり、送信前の剥がしと拒否後の再試行のどちらの経路でもこの種のターンを救えます。この検出がツール結果の内部まで及ぶようになったため、リアクティブな再試行が引き続き本物のモダリティ拒否に対してのみ発火することを回帰テストで固定しました——コンテキスト長超過の 400 が画像拒否と誤認されて再試行されることはありません。
|
||||
|
||||
### Grok Build のコスト補填が過大にならない
|
||||
|
||||
欠落したコストを埋めるルーチンは、これまで Codex と Gemini だけを「報告される入力 token にキャッシュ読み取りが含まれる」プロバイダーとして扱っていましたが、Grok Build も同じ扱いに属します——補填された Grok Build の行は入力を全量として計算し、キャッシュ読み取りをもう一度計上していたため、コストが過大でした。キャッシュ込みのプロバイダー集合は現在 1 箇所でのみ定義され、ルーティングのレコーダー、コスト計算機、補填ルーチンが共有します。三者の言うことが食い違う可能性はなくなりました。なお旧来の補填で既に修正済みの行は元の値のままです——補填はコストがゼロの行のみを処理し、既にある正のコストを書き換えることは決してありません。
|
||||
|
||||
### 手動編集した設定ファイルでアプリがクラッシュしたり編集が飲み込まれたりしない
|
||||
|
||||
`~/.codex/config.toml` の `mcp_servers` が存在するもののテーブルではない場合(たとえば `mcp_servers = "x"`)、MCP 同期が切り替えの途中で panic していました——しかもデータベースと live 設定の両方が書き込まれた後に起きるため、中途半端に適用された切り替えが残ります。テーブル以外の値は警告を出したうえで空のテーブルへ正規化するようになり、Codex と GrokBuild の書き込み側も同様に修正しました。インラインテーブル形式(合法な TOML)には、これと鏡写しの問題がありました——MCP の削除が黙って無効なのに UI は成功と表示する、`base_url` の編集が Codex がまったく読まない階層へ書き込まれる——いずれも対処済みです。ルートノード、`provider` セクション、`mcp` セクションが配列 / スカラーになっている `opencode.json` でも panic しなくなり、この種のファイルは再構築ではなくエラーとして拒否されるため、ユーザーの `model` や `theme` の設定が消されることはありません。([#5811](https://github.com/farion1231/cc-switch/pull/5811))
|
||||
|
||||
### プロキシ変換が不正な上流レスポンスに耐える
|
||||
|
||||
上流ゲートウェイからの不正なデータが、これまではエラーを生む代わりにローカルプロキシを落としかねませんでした。Anthropic SSE ストリーム内のオブジェクトでない `message` や `content_block`、バッファされたレスポンスボディがトップレベルの JSON 配列やスカラーである場合(`stream: true` を無視するゲートウェイはこれを返します)は、panic するインデックス代入に当たっていました。現在はストリームが正常な失敗イベントで終わります。不正な `content_block` ヘッダーはテキストブロックとして復元されるようにもなりました——単に空のオブジェクトへ浄化するだけでは panic は止まっても、後続の内容がすべて黙って捨てられ、モデルが何も言っていないように見えてしまいます——壊れたヘッダーの後に来る差分は通常は無傷なので、よくあるケースでは中身が通るようになり、置き換えが起きた際には警告を 1 件記録します。([#5811](https://github.com/farion1231/cc-switch/pull/5811))
|
||||
|
||||
### OpenClaw の Kimi For Coding アドレス修正
|
||||
|
||||
OpenClaw のプリセットは、これまで汎用プラットフォームの endpoint `https://api.kimi.com/v1` を指していましたが、Kimi For Coding サブスクリプションが使うのはそこではないため、coding プランの key が使えませんでした。アドレスを `https://api.kimi.com/coding/v1` に修正し、フォームのプレースホルダーと既定値も同期更新しました。**旧プリセットから作成したプロバイダーは、手動で新しいアドレスに変更する必要があります。**
|
||||
|
||||
---
|
||||
|
||||
## セキュリティ強化
|
||||
|
||||
本節の 9 項目のうち、**2 つはあなたの操作が必要です**。Gemini 共通設定を通過したキーのローテーションと、過去に `ccswitch://` でインポートした MCP 項目の確認です——やり方は「アップグレード時の注意」に書いてあります。残りはアップグレードするだけで有効になり、操作は不要です。
|
||||
|
||||
他人から送られてきた `ccswitch://` リンクを開いたことがなく、共有された Gemini 共通設定も使っていないなら、この 9 項目の意味は主に「今後トラブルが起きにくくなる」ことです。2 つのうちどちらかに心当たりがあるなら、**このバージョンは優先してアップグレードする価値があります**。
|
||||
|
||||
### Gemini 共通設定がキーを漏らさなくなり、アップグレード後に自動クリーンアップ
|
||||
|
||||
Gemini の共通設定エクストラクターは、これまで共有スニペットから `GEMINI_API_KEY` と `GOOGLE_GEMINI_BASE_URL` だけを剥がし、他の `env` 項目はそのままコピーしていました——しかし `GOOGLE_API_KEY` はまさに Gemini の一級の認証情報であり、あるアカウントの key が(他の認証情報らしき項目とともに)共通設定を使うすべての Gemini プロバイダーへディープマージされ、相手の base URL——それはサードパーティの中継かもしれません——へ送られていました。エクストラクターは、認証情報パターンに合致するキーをすべてスキップするようになり(Claude のエクストラクターと同じマッチャー群を使用)、フロントエンドのスニペット検証も同様に揃えたため、手動編集で押し戻すこともできません。Gemini のスニペットは一度存在すると再抽出されないため、アップグレード後の初回起動で**一回限りのクリーンアップ**も実行します。既に漏れた認証情報を、スニペットから、マージされた各プロバイダーから、そして `~/.gemini/.env` から取り除きます——キー名**と値**の完全一致で照合するため、プロバイダー自身の同名で値の異なる key は巻き込まれません——env ファイルの書式とコメントは保持されます。クリーンアップの詳細と注意点は「アップグレード時の注意」を参照してください。([#5811](https://github.com/farion1231/cc-switch/pull/5811))
|
||||
|
||||
### Skill リポジトリインストールの強化:パストラバーサルとアーカイブ上限
|
||||
|
||||
GitHub リポジトリから Skill をインストール / 閲覧すると、これまで対象ディレクトリの外へ書き込まれる可能性がありました。アーカイブのエントリを正規化せずに対象パスへ連結していたため、`..` を含む ZIP は展開ディレクトリから脱出できました(zip-slip)。リポジトリ座標は一度も検証されておらず、`../../../releases/download/v1/evil` のようなブランチ名でダウンロードを任意の release アセットへリダイレクトできました——しかも Skill リポジトリは信頼できない `ccswitch://` ディープリンクから追加でき既定で有効なため、Skills パネルを開くだけでダウンロードが走ります。バックアップからの復元、同期スナップショット、「アプリからインポート」由来の Skill `directory` 値も検証されずにパスへ連結されており、アンインストールが管理対象ディレクトリの外へ `remove_dir_all` を行う可能性がありました。現在はすべての書き込み先でディレクトリ名を検証し、リポジトリの owner / 名前 / ブランチは唯一のダウンロード集約点でホワイトリスト化し、展開にはハード上限を設けました(10,000 エントリ、書き込み 512 MB、ダウンロード 128 MB、シンボリックリンクのターゲット 4 KB、自己参照リンクは拒否)。新しいエラーメッセージは 4 言語対応です。([#5811](https://github.com/farion1231/cc-switch/pull/5811))
|
||||
|
||||
### ディープリンクインポート確認ダイアログ:内容を余さず表示し、リスクをマーク
|
||||
|
||||
`ccswitch://` の MCP インポート確認ダイアログは、これまで切り詰められる 1 行の `Command:` しか描画せず、`args`、`url`、`env` は一切表示していませんでした——リンクに `command: "sh"` と `args: ["-c", "curl …|sh"]`、そして `LD_PRELOAD` 環境変数を付けても、表示上は無害な `sh` に見えるだけで、確認すると各アプリの live MCP ファイルへ書き込まれます。確認ダイアログは現在、コマンド、各引数、URL、環境変数を 1 行ずつ描画し、切り詰めではなく折り返して表示するため、見えないまま切り落とされる内容はありません(キー名に TOKEN / KEY / SECRET / PASSWORD を含む env の値は、接頭辞 + アスタリスクでマスク表示されます)。よく見るべき値はハイライトされ、警告ブロックにまとめられます——インライン実行フラグ付きのシェルインタープリター(`bash -lc`、`cmd /C`、PowerShell の `-Command` の短縮形などの組み合わせを含む)、プロセスのロード挙動を変える環境変数(`LD_*`、`DYLD_*`、`NODE_OPTIONS`、`PYTHONPATH`、`PATH`、プロキシ変数など)、ループバック / 内部ネットワーク / クラウドメタデータのアドレスを指す endpoint です。マークはあくまで注意喚起であり、インポートを遮断することはありません——ローカルの Ollama endpoint はごく普通の使い方です。プロバイダーの確認ダイアログにも同じ処理を入れました。「指定されたすべてのアプリへ直ちに書き込まれます」の警告は無条件表示に変更し、リンク側で制御できるフィールドによるゲートをやめました。
|
||||
|
||||
### ディープリンクの使用量スクリプト:既定で無効、コードを見てから使う
|
||||
|
||||
ディープリンク経由でインポートされる使用量照会スクリプトは、使用量を照会するたびに実行される JavaScript ですが、これまでコードを一度も見ないまま有効化される可能性がありました。バックエンドが「コードが付いている」ことを「実行に同意した」とみなし、確認ダイアログは有効 / 無効のバッジしか表示せず、スクリプト本体は決して表示しなかったためです。スクリプトは**既定で無効**になりました——リンクが明示的に `usageEnabled=true` を持つ場合にのみ有効化を要求します——確認ダイアログはデコード済みのスクリプト全文を、スクロール可能で完全に折り返されたコードブロックとして表示し、有効化すると実行される旨を警告します。デコードに失敗した場合は元のペイロードを表示するフォールバックとなるため、不正なスクリプトが「スクリプトなし」を装うことはできません。スクリプトコードは従来どおりプロバイダーに保存され、確認したうえでアプリ内から手動で有効化できます。
|
||||
|
||||
### URL-safe Base64 で確認ダイアログが丸ごと空になっていた
|
||||
|
||||
上の 2 項目が直すのは「確認ダイアログの表示が足りない」ことですが、この項目が直すのは「確認ダイアログが何も表示しないことがある」ことです。バックエンドは 4 種類の Base64 バリアント(RFC 4648 §5 の URL-safe アルファベットを含む)を受け付けますが、フロントエンドの `atob` は標準アルファベットしか認識せず、解けないときは**エラーを出さずに入力をそのまま返して**いました——その結果、同じペイロードでもバックエンドはデコードに成功してインポートし、フロントエンドが手にするのは解けない文字の塊になります。使用量スクリプトとシステムプロンプトは不透明な Base64 として表示されました。**最悪なのは MCP 設定です。`JSON.parse` の失敗がコンポーネントに飲み込まれ、確認ダイアログは「0 個のサーバー」と空のリストを描画する一方、バックエンドは従来どおり実際の項目を live MCP ファイルへ書き込みます**。ペイロードの `/` を 1 つ `_` に替えるだけで十分でした——確認ダイアログは空になり、インポート機能は完全に動き、上の 2 項目で補ったばかりの完全表示も同時に効かなくなります。
|
||||
|
||||
フロントエンドのデコーダーは、URL-safe アルファベットを正規化してからデコードするようになり、確認ダイアログの表示は常にインポートされる内容と一致します。共有デコーダーには初めてユニットテストが付き、テストケースには前提条件の自己検証が含まれていて、サンプルが確かに URL-safe の分岐に落ちること(たまたま両方のエンコードが同一なのではないこと)を保証します。
|
||||
|
||||
> この不具合は v3.8.0 以降のすべてのバージョンに影響します。`ccswitch://` リンクで MCP サーバーをインポートしたことがある場合は、一度確認することをおすすめします——「アップグレード時の注意」を参照してください。
|
||||
|
||||
### SQL インポートはインポート対象データベースの外に触れる文を拒否
|
||||
|
||||
データベースバックアップのインポートは、これまでファイル先頭のコメントを検証するだけで、その後はテキスト全体をそのまま `execute_batch` に渡していました——巧妙に作られたバックアップは `ATTACH DATABASE` でユーザーが書き込める任意の場所に SQLite ファイルを作成でき、しかもその副作用はインポート自身の状態検証より前に発生するため、インポート全体が失敗してもファイルは既に作られています。WebDAV / S3 の同期スナップショットも同じコードパスを通ります。現在は外部バッチの実行中に SQLite authorizer をインストールします(終了時に直ちに取り外すため、アプリ自身の schema メンテナンスは影響を受けません)。`ATTACH` / `DETACH`、`VACUUM`、仮想テーブルの作成(csvfile のようなファイルバックエンドのモジュールは任意のパスを読み書きできます)、および SQLite が未知と報告するすべてのアクションは一律に拒否されます——将来の新しい文も既定で失敗します。PRAGMA はエクスポーターが実際に書き込む `foreign_keys` と `user_version` の 2 つだけを許可します。
|
||||
|
||||
### 共通設定スニペットのプロトタイプ汚染
|
||||
|
||||
共通設定スニペットの適用・削除・比較を行う 3 つのトラバーサルは、これまでいずれも `__proto__` をたどってグローバルな `Object.prototype` に入り込んでいました。`JSON.parse('{"__proto__":{…}}')` が生成するのは自身の列挙可能プロパティであり、マージすると攻撃者が指定した値がグローバルなプロトタイプに書き込まれます——そして `settings` テーブルは同期時にリモート側でテーブルごと上書きされるため、悪意ある WebDAV / S3 のスナップショットが着地した後にプロバイダーフォームを一度開くだけでマージが発火します。3 つのトラバーサルは `__proto__`、`constructor`、`prototype` を一律にスキップするようになりました。「共通設定が適用済みか」の比較では自身のプロパティであることも同時に要求するようにし、ついでに目に見える奇妙な挙動——`{"__proto__":{}}` がこれまで任意の設定の部分集合と判定されていたこと——も修正しました。
|
||||
|
||||
### ターミナル起動におけるディレクトリ名のコマンドインジェクション
|
||||
|
||||
外部ターミナルでセッションを復元する際、`cd` 行はこれまで作業ディレクトリをダブルクォートで囲み、バックスラッシュとダブルクォートだけをエスケープしていました——ダブルクォートの中でもシェルは `$(…)`、バッククォート、`$VAR` を展開しますし、この値は CLI のセッション履歴に記録された実際のプロジェクトパスで、macOS ではディレクトリ名にこれらの文字を合法的に含められます。フォルダーにそのような名前が付いていれば、「復元」をクリックしただけであなたのターミナルで埋め込まれたコマンドが実行されます。侵害されたコンポーネントは一切必要ありません。シェル行を組み立てる 3 つのランチャー——Terminal.app、iTerm、kitty——は POSIX のシングルクォートエスケープに変更し、いかなる内容も展開されなくなりました(Terminal / iTerm を通すために必要な AppleScript の引用層も同様に安全です)。Ghostty、WezTerm / Kaku、Alacritty はもともとディレクトリを独立した引数として渡しており、初めから安全でした。
|
||||
|
||||
### GrokBuild の認証情報解決が環境のキーを差し替えたりインライン化したりしない
|
||||
|
||||
GrokBuild の認証情報の取り出しは、これまで設定が指定する `env_key` 変数が未設定の場合にプロセスレベルの `XAI_API_KEY` へフォールバックしていました——別アカウントの key に黙って差し替えられ、設定が指す任意の base URL へ送られます。認証情報は現在、明示的なインラインの `api_key` か、`env_key` が正確に名指しする環境変数からのみ取得されます。ディープリンクインポートは環境変数を平文の `api_key` に解決しなくなりました。`env_key` の名前だけを持つリンクは拒否され、手動での追加を促します——そのまま受け入れると、リクエスト時に被害者の環境のキーが解決され、リンクが宣言したアドレスへ送られてしまうためです。あわせて修正:base URL の解決を認証情報の解決から切り離しました。これまでは認証情報が欠けると base URL まで空になり、macOS(GUI プロセスはシェル環境を継承しません)で UI に表示されるアドレスと実際に使われるアドレスが食い違い、使用量スクリプトの `{{baseUrl}}` が空に展開されていました。([#5811](https://github.com/farion1231/cc-switch/pull/5811))
|
||||
|
||||
---
|
||||
|
||||
## ドキュメント
|
||||
|
||||
### 「Claude Code で GPT モデルを使う」ガイドに英語版・日本語版を追加
|
||||
|
||||
これまで中国語版しかなかったローカルルーティングガイドを、英語と日本語へ完全に移植しました。2 つの接続経路をエンドツーエンドでカバーします——サードパーティの OpenAI Responses ゲートウェイ(API Key)と、ChatGPT Plus/Pro サブスクリプションを Codex のデバイスコード OAuth ログインで使う経路です。2 つのルーティングガイドは同時に、「どのクライアントが正しいか」ではなく「どのモデルを使うか」を示すタイトルへ変更しました——《[Claude Code で GPT モデルを使う](../guides/claude-codex-routing-guide-ja.md)》《[Codex で Claude モデルを使う](../guides/codex-claude-routing-guide-ja.md)》——そしてすべての相互リンク(3 言語の v3.18.0 リリースノートを含む)を読者の言語のバージョンへ向けました。
|
||||
|
||||
### ユーザーマニュアル:ディープリンク `usageEnabled` の既定値を修正
|
||||
|
||||
3 言語のユーザーマニュアルのディープリンクリファレンスは、これまで `usageEnabled` の既定値が `true` だと記載していましたが、実際の既定値は `false` で、インポーターと一致します。マニュアルには正しい既定値を明記し、2 つの帰結も補いました。インポート前の確認ダイアログはスクリプトのコードを完全に表示すること、そして明示的な `usageEnabled=true` がない場合はスクリプトが無効の状態でインポートされ、後からアプリ内で有効化できることです。
|
||||
|
||||
### SECURITY.md:脅威モデルと報告範囲
|
||||
|
||||
`SECURITY.md` に 2 言語の脅威モデルと、明確な範囲内 / 範囲外のリストを整備しました。報告は「値が最終的にどの API へ届いたか」ではなく「この入力を誰が制御しているか」で分類します。内蔵の WebView レンダラーは信頼されたコンポーネントとして宣言し(独立に検証できる 4 つの事実と、その前提が崩れる条件を添えています)、ディープリンクのペイロード、WebDAV / S3 の復元データ、インポートファイル、上流 API のレスポンス、ローカルプロキシへの受信リクエストはすべて信頼できない入力として列挙し、報告を歓迎します。
|
||||
|
||||
---
|
||||
|
||||
## アップグレード時の注意
|
||||
|
||||
### 本リリースにデータベース移行はありません
|
||||
|
||||
v3.19.0 に schema 移行は含まれません(バージョンは v16 のまま)。アップグレードすればすぐ使え、データの再構築を待つ必要はありません。
|
||||
|
||||
### Gemini キーの一回限りのクリーンアップ(必ずお読みください)
|
||||
|
||||
アップグレード後の初回起動時、通常の設定抽出より前に、一回限りの Gemini 共通設定クリーンアップが実行されます。**その後、一部の Gemini プロバイダーで API Key が不足していると表示される場合があります**。項目は認証情報型のキー名と値の完全一致で削除され、通常削除されるのは共有スニペット経由で漏れ込んだ他プロバイダーの認証情報です(そのプロバイダー自身の元の値は、漏えいが発生した時点で上書きされており復元できません)——ただし、**複数の Gemini プロバイダー間で意図的に使い回していた同じ値の key も一緒に削除されます**。いずれの場合も、ローテーションしてから入力し直してください。**共有 Gemini スニペットを通過したキーはすべて漏えい済みとみなすべきです**。削除されたキー名と影響を受けたプロバイダー id(値は決して含みません)は `settings` テーブルの `gemini_common_config_scrub_audit_v1` に記録されており、これをもとにキーの再入力が必要なプロバイダーを 1 つずつ特定できます。
|
||||
|
||||
### ディープリンクで MCP をインポートしたことがありますか?一度確認をおすすめします(必ずお読みください)
|
||||
|
||||
本リリース以前、`ccswitch://` の MCP インポート確認ダイアログは**書き込まれる内容を表示できないことがありました**。引数と環境変数は一切描画されず(`command: "sh"` に `args: ["-c", …]` を付けても無害な `sh` としか表示されません)、ペイロードが URL-safe Base64 でエンコードされている場合はリスト全体が「0 個のサーバー」と表示されました——それでいてバックエンドはどちらの場合も従来どおり項目を各アプリの live MCP ファイルへ書き込みます。この 2 つの不具合は **v3.8.0 以降のすべてのバージョン**に影響し、本リリースでまとめて修正されました。
|
||||
|
||||
悪用には、攻撃者が提供したリンクをあなた自身が開いて「インポート」をクリックする必要があるため、大多数のユーザーは影響を受けません。**完全には信頼できない出所から `ccswitch://` の MCP インポートリンクを開いたことが実際にある場合**は、MCP パネルで 1 件ずつ確認するか、`~/.claude.json` の `mcpServers`(Codex は `~/.codex/config.toml` の `mcp_servers`)を直接確認し、見覚えのない項目がないことを確かめてください——MCP サーバーは CLI の次回起動時に子プロセスとして実行されます。
|
||||
|
||||
### ディープリンクの使用量スクリプトは既定で無効
|
||||
|
||||
使用量照会スクリプトを含むディープリンクは、リンクが明示的に `usageEnabled=true` を持たない限り、既定で無効の状態でインポートされるようになりました。自動有効化に依存していたリンク(一部パートナーのワンクリック設定リンクなど)はスクリプトをインポートしますが、使用量照会は有効になりません——コードを確認したうえで、プロバイダーエディターから手動で有効化してください。アプリ内で手動設定した使用量スクリプトは影響を受けません。
|
||||
|
||||
### 新しい既定モデルは新規プロバイダーにのみ影響します
|
||||
|
||||
保存済みのプロバイダーは作成時のモデル ID を維持するため、新しいモデルを使うには手動での編集が必要です。Claude Desktop の opus ルートの現在値は `claude-opus-5` に前進し、`claude-opus-4-8` は互換エイリアスのスロットへ移りました。既存の設定は従来どおり解決されます。
|
||||
|
||||
### 価格のシードとローカル価格ファイル
|
||||
|
||||
新しい価格行(`claude-opus-5`、`gemini-3.6-flash`、`grok-4.5-build`)は次回起動時に「存在しなければ挿入」で追加されます——**シードがあなたの編集した価格を上書きすることは決してありません**。`~/.cc-switch/model-pricing.json` は作成時点では空で、本リリース以降の手動の価格変更と削除のみを記録します——それより前の価格変更は移行されないので、データベースの再構築に耐えさせたい場合は一度保存し直してください。models.dev の自動同期は、あなたが手動で有効にするまで無効のままです。いったん有効にすると、これは同名の価格を上書きする(内蔵・手動を問わず)唯一の経路になります。
|
||||
|
||||
### GrokBuild の暗黙的な環境変数フォールバックを削除
|
||||
|
||||
暗黙的な `XAI_API_KEY` 環境変数フォールバックに依存していた GrokBuild プロバイダーは、明示的な `api_key` か、正しく名前を指定した `env_key` が必要になりました。
|
||||
|
||||
### Grok 公式モードの使用量は意図的に遅れます
|
||||
|
||||
公式モードの Grok 使用量は、約 10 分 + 同期周期 1 回分だけ遅れて現れます——イベントをまず寝かせて確定を待ち、その後プロキシが記録した行と照合して二重計上を防ぐためです。ルーティング経由のトラフィックと公式のトラフィックがウィンドウ内で交互に発生する場合、一部の公式ターンは重複計上の危険を冒す代わりにスキップされます。旧来のコスト補填で過大評価されていた Grok Build の行は元の値のままです——補填はコストがゼロの行のみを処理し、既にある正のコストを修正することは決してありません。
|
||||
|
||||
### 更新ミラーは次のバージョンから有効になります
|
||||
|
||||
アップデーターの endpoint リストはアプリのバイナリに内蔵されているため、既存のインストールは本変更を含むバージョンへアップグレードするまで GitHub のみを照会し続けます。それ以降は `dl.ccswitch.io` ミラーを優先し、GitHub にフォールバックします。
|
||||
|
||||
### スポンサーのドメイン移転は既存プロバイダーを変更しません
|
||||
|
||||
作成済みのプロバイダーはデータベースに保存された旧アドレスを保持し、引き続き旧ドメインを指します。新しいドメインへ移したい場合は、プロバイダーのアドレスを手動で変更するか、更新後のプリセットから作り直してください。
|
||||
|
||||
---
|
||||
|
||||
## リスク通知
|
||||
|
||||
### SuperGrok クォータ照会(本リリース新規)
|
||||
|
||||
プロバイダーカードの SuperGrok クォータ表示は、Grok CLI 自身の OAuth 認証情報(`~/.grok/auth.json`)を読み取り、grok.com の課金 endpoint に問い合わせます——この endpoint は公開ドキュメント化されたインターフェースではなく、レスポンスの解析は現行フォーマットの観察に基づいています。xAI がインターフェースを変更するとこの機能は動かなくなる可能性があります(その場合カードはクォータを表示しない状態に縮退し、他の機能は影響を受けません)。CC Switch がこれらの認証情報を保存したり変更したりすることはありません。
|
||||
|
||||
### 引き続き適用される注意
|
||||
|
||||
**xAI Grok OAuth サインイン**:公式 Grok CLI の公開 OAuth クライアント識別情報を再利用しており、利用によってアカウントの制限や停止につながる恐れがあります——詳細は [v3.18.0 release notes](v3.18.0-ja.md#リスク通知) を参照してください。
|
||||
|
||||
**Codex OAuth リバースプロキシ**:ChatGPT サブスクリプションの Codex OAuth をリバースプロキシ経由で使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 release notes](v3.13.0-ja.md#️-リスクに関する注意事項) を参照してください。
|
||||
|
||||
**サードパーティプロバイダーのルーティング**:CC Switch ローカルプロキシで Codex、Claude Desktop、Grok Build のリクエストを変換してサードパーティプロバイダーへ転送する場合、課金・コンプライアンス・データ保持に関する制約はプロバイダーごとに異なります。利用前に対象プロバイダーの利用規約を確認してください。
|
||||
|
||||
上記機能を有効化したユーザーは、関連するリスクを自ら負うものとします。CC Switch は、これらの機能の利用によって発生したアカウント制限、警告、サービス停止について責任を負いません。
|
||||
|
||||
---
|
||||
|
||||
## 謝辞
|
||||
|
||||
本リリースのセキュリティ強化は、そのほとんどが外部からもたらされたものです——1 本の PR と、寄せられたセキュリティ報告です。
|
||||
|
||||
### コード貢献
|
||||
|
||||
- [#5811](https://github.com/farion1231/cc-switch/pull/5811):Skill インストールの zip-slip とリポジトリ座標のトラバーサル、Gemini 共通設定のキー漏えいと一回限りのクリーンアップ、GrokBuild の認証情報解決、複数の panic 経路の修正、@zayokami に感謝。本リリースにおいて単一の出所として最も広い範囲をカバーした仕事です。
|
||||
- [#5734](https://github.com/farion1231/cc-switch/pull/5734):models.dev 自動価格同期、@YUZHEthefool に感謝。
|
||||
- [#5626](https://github.com/farion1231/cc-switch/pull/5626):Codex fork セッションの使用量インポート高速化、@ayanamislover に感謝(@SaladDay との共同署名)。
|
||||
|
||||
### セキュリティ報告
|
||||
|
||||
本リリースの「セキュリティ強化」のうち 4 項目の修正は、非公開で寄せられたセキュリティ報告に基づくものです。**23pds**(SlowMist 慢雾)と **zues devil** に感謝します——項目ごとの帰属は以下のとおりです:
|
||||
|
||||
- **ディープリンクインポート確認ダイアログが切り詰められた 1 行の `Command:` しか表示しない**——`args`、`url`、`env` は一切描画されず、`sh -c` に `LD_PRELOAD` を組み合わせたペイロードが画面上ではただの `sh` に見えていました。本リリースで最も影響範囲の大きい 1 件です。(23pds、SlowMist)
|
||||
- **SQL バックアップのインポートが制約されていない**——`ATTACH DATABASE` でユーザーが書き込める任意の場所にファイルを作成でき、しかもその副作用はインポート自身の検証より前に発生します。(zues devil)
|
||||
- **外部ターミナル起動におけるディレクトリ名のコマンドインジェクション**——`cd` 行がダブルクォートで囲まれており `$(…)` が通常どおり展開され、しかもこの値はセッション履歴に記録された実際のプロジェクトパスです。(zues devil と 23pds がそれぞれ独立に報告。内蔵ランチャーとカスタムテンプレートの 2 つの経路をそれぞれ指摘)
|
||||
- **共通設定スニペットのマージにおけるプロトタイプ汚染**——3 つのトラバーサルがいずれも `__proto__` をたどってグローバルな `Object.prototype` に入り込んでいました。(23pds、SlowMist)
|
||||
|
||||
これらの報告はまた、[SECURITY.md](../../SECURITY.md) の脅威モデルと報告範囲を整備するきっかけにもなりました——それ以前、このプロジェクトには報告の方法しか書かれておらず、何が脆弱性にあたるかは書かれていませんでした。
|
||||
|
||||
残る 2 つのディープリンク修正(使用量スクリプトの既定無効化、URL-safe Base64 のバイパス)は、上記の修正そのものをレビューする過程で発見されたもので、元の報告には含まれていません。
|
||||
|
||||
### 問題報告
|
||||
|
||||
[#4465](https://github.com/farion1231/cc-switch/issues/4465) と [#5663](https://github.com/farion1231/cc-switch/issues/5663) で、プロキシ経由の画像読み取りによるコンテキスト溢れを報告してくださったユーザーに感謝します——本リリースで最も重要なプロキシの修正は、こうした実際の場面からの再現情報をもとにしています。
|
||||
|
||||
---
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から、お使いのシステムに対応するビルドをダウンロードするか、公式サイト [ccswitch.io](https://ccswitch.io) から入手してください(本リリースからダウンロードは Cloudflare のエッジノード経由で配信され、GitHub への到達性に依存しなくなりました)。
|
||||
|
||||
### システム要件
|
||||
|
||||
| システム | 最低バージョン | アーキテクチャ |
|
||||
| -------- | ------------------------ | ----------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 / ARM64 |
|
||||
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表を参照 | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ---------------------------------------- | -------------------------------------------- |
|
||||
| `CC-Switch-v3.19.0-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー |
|
||||
| `CC-Switch-v3.19.0-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます |
|
||||
|
||||
Windows ARM64 デバイスでは、ファイル名に `arm64` が含まれる対応する成果物を選択してください。
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| -------------------------------- | ------------------------------------------------------ |
|
||||
| `CC-Switch-v3.19.0-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ |
|
||||
| `CC-Switch-v3.19.0-macOS.zip` | 展開して Applications へドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.19.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
Homebrew インストール:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux アセットは **x86_64** と **ARM64**(`aarch64`)の両方を提供します。ファイル名のアーキテクチャ識別子を、マシンの `uname -m` 出力に合わせて選択してください:
|
||||
|
||||
- `CC-Switch-v3.19.0-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.19.0-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | -------------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接実行、または AUR を利用 |
|
||||
| その他 / 不明な場合 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,353 @@
|
||||
# CC Switch v3.19.0
|
||||
|
||||
> 这一版的主线是**让你更放心**:一波集中式安全加固——Skill 安装、`ccswitch://` 导入确认、SQL 备份导入、通用配置合并、终端启动全部收紧,其中两条需要你花一分钟确认——Gemini 通用配置的密钥泄漏已修复并在升级后自动清洗(**需要你轮换密钥**),`ccswitch://` 的 MCP 导入确认框此前可能显示不出即将写入的命令(**若你曾打开过来源不明的导入链接,建议核对一次**),两条都见「升级提醒」;一个代理正确性大修——**通过代理读图不再撑爆上下文**(一张截图曾经吃掉 10 万+ token,两三张就能把 Codex 会话卡死在 400 上)。省心的部分同样实在:**模型定价可以交给 models.dev 自动维护**、Grok CLI 官方登录模式的用量与 SuperGrok 订阅余量终于进看板、**应用内更新改走 `dl.ccswitch.io` 镜像**——GitHub 访问不畅也能顺利升级。
|
||||
|
||||
**[English →](v3.19.0-en.md) | [日本語版 →](v3.19.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 重点内容:你现在可以
|
||||
|
||||
- **在代理下正常读图,不再撑爆上下文**:Codex 的 `view_image`、返回图片的 MCP 工具,图片此前被序列化成工具文本、按纯文本计 token(约 9,000 倍膨胀);现在所有转换桥都把图片还原为原生格式再上送(文件与音频在两条 Chat 桥上一并支持)。真实测试里同一回放轮从 85k+ token 降到约 12k、缓存命中 99%。
|
||||
- **把模型定价交给 models.dev 自动维护**:用量面板新增「models.dev 自动定价同步」(默认关闭)。开启后启动时自动刷新所选模型的价格(每 6 小时至多一次),可在完整目录里挑选要跟踪的模型,或让它自动包含各家最新的常用模型。手工改价与删价从本版起会记入 `~/.cc-switch/model-pricing.json`,数据库重建也不丢。
|
||||
- **看到 Grok 官方模式的用量与订阅余量**:Grok CLI 用官方 OAuth 登录时无法走本地代理,此前这部分消耗完全不可见;现在会从会话日志导入逐轮用量,看板里以「Grok Build (Session)」呈现。官方类 Grok Build 供应商卡片还会直接显示 SuperGrok 订阅的额度用量与重置时间。
|
||||
- **更放心地点开 `ccswitch://` 导入链接**:确认框现在完整展示命令、每个参数、URL 与环境变量(凭据类值脱敏显示),高亮标记值得多看一眼的值——shell 内联执行、改变加载行为的环境变量、内网 / 元数据地址;用量查询脚本会显示完整代码,且**默认以禁用状态导入**。
|
||||
- **确认 Gemini 供应商里不再夹带别人的密钥**:通用配置共享片段此前会把 `GOOGLE_API_KEY` 等凭据复制进每个使用它的 Gemini 供应商;本版关闭该路径,升级后首次启动自动执行一次性清洗。**凡是进过共享 Gemini 片段的密钥都应视为已暴露,请先轮换再重填**(见「升级提醒」)。
|
||||
- **在 GitHub 访问不畅时照常更新应用**:应用内更新器优先查询 `https://dl.ccswitch.io/latest.json`(Cloudflare R2 镜像),GitHub 作为回落;minisign 签名校验不变,镜像本身不被信任。
|
||||
- **新建供应商时直接用上最新模型**:预设默认模型升级为 Claude Opus 5、GPT-5.6 Sol 与 Gemini 3.6 Flash,配套定价同步入库;已创建的供应商保持原样。
|
||||
- **更快导入 fork 密集的 Codex 用量历史**:父 rollout 文件只解析一次、跨全部 fork 点共享,fork 密集的历史重建明显提速,导入结果逐字节不变。
|
||||
|
||||
---
|
||||
|
||||
## 使用攻略
|
||||
|
||||
本版新能力主要落在用量面板与 `ccswitch://` 深链导入上,建议结合以下文档了解:
|
||||
|
||||
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:用量看板的数据来源与统计口径。本版新增 models.dev 自动定价同步与 Grok 官方模式用量导入。
|
||||
- **[深链导入(ccswitch://)](../user-manual/zh/5-faq/5.3-deeplink.md)**:导入确认框的字段说明与 `usageEnabled` 等参数的默认值(本版起用量脚本默认禁用导入,文档已同步修正)。
|
||||
- **[安全策略(SECURITY.md)](../../SECURITY.md)**:本版补齐了威胁模型与报告范围——哪些输入被视为不可信、哪些问题欢迎报告,一目了然。
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## 唯一官方渠道声明(请务必阅读)
|
||||
>
|
||||
> CC Switch 是**完全免费、开源**的桌面应用,**不会向用户收取任何费用**。请仅通过下列官方渠道获取本软件:
|
||||
>
|
||||
> | 类别 | 唯一官方 |
|
||||
> | -------- | ------------------------------------------------------------------------------ |
|
||||
> | 官网 | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | 源码 | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | 下载 | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | 举报山寨 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **任何向你收费、要求充值、或索取登录凭据的"CC Switch"网站或客户端均为假冒**。如果你被诱导支付了费用,请立即停止操作并通过 GitHub Issues 反馈。
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.19.0 由一波安全加固与一个代理正确性大修领衔。安全侧([#5811](https://github.com/farion1231/cc-switch/pull/5811) 及后续独立修复):从 GitHub 仓库安装 Skill 加固了 zip-slip 与路径穿越并设归档上限;Gemini 通用配置的密钥泄漏被关闭,升级后首次启动自动执行一次性清洗,把已经泄漏进其它供应商配置的密钥清理干净;导入 SQL 备份改在 SQLite authorizer 下执行,`ATTACH` 等能触及导入库之外的语句一律拒绝;通用配置片段合并不再跟随 `__proto__` 污染全局原型;外部终端启动改用 POSIX 单引号转义,目录名再也注入不了命令;`ccswitch://` 导入确认框完整展示载荷(凭据类值脱敏显示)并标记风险值,用量脚本默认禁用导入。代理侧,工具结果里的图片不再被序列化成工具文本,而是在各转换桥还原为原生媒体上送(文件与音频在两条 Chat 桥上一并支持)——终结了「一张 113 KB 截图吃掉 10 万+ token、两三张图把 Codex 会话卡死在 400 上」的问题([#4465](https://github.com/farion1231/cc-switch/issues/4465)、[#5663](https://github.com/farion1231/cc-switch/issues/5663))。
|
||||
|
||||
用量统计获得两块新能力:**models.dev 自动定价同步**(可选开启,[#5734](https://github.com/farion1231/cc-switch/pull/5734)),配套把手工改价 / 删价持久化到人类可编辑的 `~/.cc-switch/model-pricing.json`;以及 **Grok CLI 官方 OAuth 模式的用量导入**——这条流量无法走本地代理,此前完全不可见——外加供应商卡片上的 SuperGrok 订阅配额展示。围绕分发与体验:应用内更新优先走 `dl.ccswitch.io` 的 Cloudflare R2 镜像(GitHub 回落,签名校验不变);Codex 用量导入对 fork 会话重用已解析的父 rollout 时间线([#5626](https://github.com/farion1231/cc-switch/pull/5626));预设默认模型升级为 Claude Opus 5、GPT-5.6 Sol 与 Gemini 3.6 Flash;OpenClaw 的 Kimi For Coding 预设修正了 base URL;工具栏应用切换器改为纯图标。本版**没有数据库 schema 迁移**,升级轻量。
|
||||
|
||||
**发布日期**:2026-07-30
|
||||
|
||||
**更新规模**:38 commits | 132 files changed | +14,926 / -1,415 lines
|
||||
|
||||
---
|
||||
|
||||
## 新功能
|
||||
|
||||
### models.dev 自动定价同步
|
||||
|
||||
用量面板的定价区新增「models.dev 自动定价同步」卡片,**默认关闭、需手动开启**:开启时会有确认说明——CC Switch 将在启动时(每 6 小时至多一次)从 models.dev 刷新所选模型的价格,**同名模型的内置价与手工价都会被覆盖**。「选择模型」对话框提供完整的 models.dev 目录(可搜索筛选),另有「自动包含常用模型」选项,覆盖 Claude、GPT、Gemini、Grok、DeepSeek、Qwen、MiMo、LongCat、Kimi、MiniMax、GLM 各家最近发布的模型(每族至多 6 个,可单独排除)。卡片显示上次同步时间与错误,提供「立即同步」,还能打开或重载本地定价文件。
|
||||
|
||||
从本版起,手工改价与删价会同时记入数据库旁边的人类可编辑文件 `~/.cc-switch/model-pricing.json`,每次启动重放——数据库重建后手工定价不再丢失,删掉的内置价也终于能删得掉(以墓碑记录,不再被重新播种)。注意该文件创建时为空、**刻意不从既有定价表回填**(否则内置价会被一并写成覆盖项、挡掉将来的内置价修正),升级前的改价仍只存在数据库里,重存一次即可入文件。同步真的改了价格时,会把**从未算出成本**(零或缺失)的历史用量行按新价补算——已有成本的行保持原值;拉取失败或离线绝不阻塞启动。models.dev 列表还过滤掉了非文本与已弃用的模型(音频 / 图像 / 视频 / embedding 等),手动选价对话框一并清爽了。([#5734](https://github.com/farion1231/cc-switch/pull/5734))
|
||||
|
||||
### Grok 官方模式的用量,终于进看板
|
||||
|
||||
Grok CLI 用官方 OAuth 登录时无法经本地代理路由——Grok 以空配置作为模式开关,没有地方能把它指向 CC Switch——这部分消耗此前在用量看板里完全不可见。现在 CC Switch 会随常规会话日志同步,从 `~/.grok/sessions`(含归档会话)的 `updates.jsonl` 里按 `turn_completed` 事件导入逐轮用量:成本优先采用 CLI 自己上报的精确数字,缺失时回落本地定价(内置定价表新增 `grok-4.5-build`,$2 输入 / $6 输出 / $0.30 缓存读,每百万 token)。导入行以上游逐轮 ID 为键,回卷会话不会造成重复计数;沉淀窗口加近期代理活动检查,确保同一流量在「路由 + 官方」混用时也不会算两次。看板里新行以「Grok Build (Session)」供应商名呈现,应用筛选器新增 Grok Build 选项,数据来源分栏新增「Grok Build Session」条目与专属图标,四语齐全。
|
||||
|
||||
### 供应商卡片上的 SuperGrok 订阅配额
|
||||
|
||||
类别为「官方」的 Grok Build 供应商,卡片上现在直接显示 SuperGrok 订阅用量——与 Claude Code / Codex / Gemini 的官方订阅页脚并列:CC Switch 读取 Grok CLI 自己的 OAuth 凭据(`~/.grok/auth.json`),查询 grok.com 计费端点获取额度窗口的已用百分比与重置时间;重置间隔可识别时标注为「周」或「月」,否则归入新的「Credits」档(托盘用量摘要中以 `c` 组呈现)。网络瞬断时保留上一次读数并重试,不清空页脚;令牌过期会提示重新 `grok login`。Claude Code、Claude Desktop 与 Codex 里的受管 xAI OAuth(SuperGrok)供应商也自动获得同款配额展示——数据来自绑定到该供应商的账号,用量脚本入口随之隐藏。注意 Grok Build 供应商的「官方」判定现在只看 `category` 字段,不再探测配置内容。
|
||||
|
||||
### Claude Opus 5 内置定价
|
||||
|
||||
`claude-opus-5` 加入内置定价表:$5 输入 / $25 输出、$0.50 缓存读 / $6.25 缓存写(每百万 token),用量不再显示 $0。按「不存在才插入」播种,改过的价格不受影响(Opus 5 fast 模式走独立计费,刻意未入表)。
|
||||
|
||||
### 预设目录更新
|
||||
|
||||
A6API(同模型多上游自动择优的聚合平台)加入八个应用的赞助商预设;PackyCode 预设在支持备用端点的五类预设(Claude Code / Claude Desktop / Codex / Gemini CLI / Grok Build)上新增三个备用地址,可在地址管理器与测速里选择;AICoding 合作伙伴预设回归七个应用;赞助商排序与 README 重新对齐。
|
||||
|
||||
---
|
||||
|
||||
## 变更
|
||||
|
||||
### 预设默认模型升级:Claude Opus 5、GPT-5.6 Sol、Gemini 3.6 Flash
|
||||
|
||||
内置预设的默认模型全面来到当前一代:`claude-opus-5` 替换 `claude-opus-4-8`(三种命名形态全覆盖),`gpt-5.6-sol` 替换 `gpt-5.5` 与裸 `gpt-5.6`,`gemini-3.6-flash` 替换 `gemini-3.5-flash`。同步更新了所有镜像位置——通用 / NewAPI 默认值、Codex 自定义 `config.toml` 模板、推荐列表、表单占位符与四语文案;`gemini-3.6-flash` 定价同步入库($1.50 / $7.50、缓存读 $0.15,每百万 token)。仍钉在 `gemini-3.1-pro-preview` 的 Code0 与七牛 Gemini 预设一并对齐到 3.6 Flash——这是**有意的档位调整**:3.6 没有 Pro 版,3.5 Pro 仍限合作测试。**默认值只影响新建供应商**,已保存的供应商维持创建时的模型;Claude Desktop 的 opus 路由现值前进到 `claude-opus-5`,`claude-opus-4-8` 转入兼容别名槽,存量配置照常解析。
|
||||
|
||||
### 应用内更新改走 ccswitch.io 镜像
|
||||
|
||||
更新器现在优先查询 `https://dl.ccswitch.io/latest.json`——发布清单的 Cloudflare R2 镜像——GitHub Releases 作为回落,检查与下载更新不再依赖 GitHub 可达。镜像清单把各平台下载指向同一存储桶,而 minisign 签名保持不动:签名覆盖的是文件内容而非 URL,每个下载产物仍会对着内置公钥校验,**镜像本身始终不被信任**。发布由 release 门控的同步工作流负责,只有当 tag 确为 GitHub 的 `releases/latest` 时才改写根清单,镜像永远不会把用户往回推到旧版本。
|
||||
|
||||
### Codex 用量导入:fork 会话提速
|
||||
|
||||
导入与重建 Codex 用量统计不再对同一个父 rollout 文件按 fork 点逐次重读:每个父 `~/.codex/sessions/*.jsonl` 只解析一次,生成内存中的 token 时间线,由所有从它 fork 出的子会话共享,各子会话的截断点改为内存过滤。缓存以文件身份戳校验(修改时间、大小,加 Unix 的 device/inode 或 Windows 的卷序列号 + 文件 ID),被追加、轮转或替换的父文件会重读而不是拿到陈旧数据。提速幅度取决于 fork 密度:fork 密集的历史冗余解析大幅减少,fork 稀少的历史基本不变——两种情况下导入结果都逐字节一致。([#5626](https://github.com/farion1231/cc-switch/pull/5626))
|
||||
|
||||
### 工具栏应用切换器改为纯图标
|
||||
|
||||
切换器按钮不再在图标旁渲染文字标签——受管应用增至八个后,标签本来就几乎总是被溢出检测收起,于是移除了基于 ResizeObserver 的自动紧凑机制,始终只显示图标。应用名保留在悬停提示里,读屏器经 `aria-label` 照常可及。
|
||||
|
||||
### 赞助商域名与推荐链接刷新
|
||||
|
||||
多家赞助商迁移了域名,预设地址、备用端点、推荐链接与 README 行已同步(PackyCode → `www.packyapi.ai`、RightCode → `www.rightapi.ai`、ClaudeAPI → `www.apito.ai`、APINebula → `apinebula.ai`、AICodeMirror → `.ai`、AICoding → `.inc`、AIGoCode → `.app`),顺带移除了两个已失效的备用端点。**已创建的供应商保留数据库里存的旧地址**——想迁到新域名,手动改地址或从刷新后的预设重建即可。
|
||||
|
||||
---
|
||||
|
||||
## 修复
|
||||
|
||||
### 通过代理读图不再撑爆上下文
|
||||
|
||||
客户端经工具调用读取图片时——Codex 的 `view_image`,或任何返回图片的 MCP 工具——代理的协议转换会把整个图片块序列化进工具消息的文本里,上游按纯文本给 base64 计 token:约 9,000 倍的膨胀,一张 113 KB 的 PNG 折算 10 万+ prompt token;Codex 每轮重放全部历史,两三张截图就足以把会话顶出上下文窗口、卡死在反复的 400 上([#4465](https://github.com/farion1231/cc-switch/issues/4465)、[#5663](https://github.com/farion1231/cc-switch/issues/5663))。
|
||||
|
||||
代理现在把媒体载荷从工具结果里提出来、按各桥的原生格式重新上送——**图片全桥覆盖,文件与音频在目标协议支持处生效**:两条 Chat 桥(Claude→Chat、Codex Responses→Chat)承载图片 / 文件 / 音频,工具消息里留下简短标记、媒体作为合成用户消息紧随工具批次之后;Claude→Responses 还原原生 `input_image`,Codex / GrokBuild→Anthropic 重建标准 Anthropic 图片块,Claude→Gemini 在 Gemini 3 上用多模态 `functionResponse.parts`(旧型号用 `inlineData`),只接受内联 base64 图片。检测覆盖有类型的 Responses 块、Anthropic `source` 块、MCP `data`+`mimeType` 结果与整串图片 data URL,可穿透数组与嵌套 `content` 包装(含 JSON 编码的工具输出);一旦判定输出含媒体,其中残留的 data URL 与裸 base64 会被折叠成占位——**裸 base64 本身从不触发媒体判定**,普通工具输出原样不动。不含媒体的工具结果在所有桥上保持与之前逐字节一致,prompt 缓存前缀不受影响;上送的媒体块刻意不带 `cache_control` 标记,GLM、Qwen 这类严格上游不会拒收。对 Kimi K3 的端到端实测:同一回放轮稳定在约 12k 输入 token、缓存命中 99%,此前每次重放要背 85k+ 的 base64 文本。
|
||||
|
||||
### 「不支持图片回退」现在能看到工具结果里的图
|
||||
|
||||
「不支持图片回退」设置会在供应商仅文本或上游拒图时用占位标记替换图片块,但它此前只能看到仍是结构化块的图片——已被打平成 base64 文本的工具结果图片对它不可见,仅文本上游直接失败、无从恢复。媒体清洗器现在在每条路径上对称地检测并剥离工具输出内的媒体,发送前剥离与被拒后重试两条路都能救回这类轮次;由于该检测现在也深入工具结果,一条回归测试钉住了反应式重试仍只对真正的模态拒绝触发——上下文超限的 400 不会被误当拒图去重试。
|
||||
|
||||
### Grok Build 成本回填不再高估
|
||||
|
||||
补算缺失成本的例程此前只把 Codex 与 Gemini 视为「上报输入 token 已含缓存读」的供应商,而 Grok Build 同属该口径——被回填的 Grok Build 行按全量输入计价、缓存读又计一次,成本虚高。缓存含入式供应商集合现在只定义一处,由路由记录器、成本计算器与回填例程共享,三者不再可能各说各话。注意此前已被旧回填修过的行保持原值——回填只处理零成本行,从不改写已有正成本。
|
||||
|
||||
### 手工编辑的配置文件不再让应用崩溃或吞掉编辑
|
||||
|
||||
`~/.codex/config.toml` 里 `mcp_servers` 存在但不是表(比如 `mcp_servers = "x"`)时,MCP 同步会在切换中途 panic——且发生在数据库与 live 配置都已写入之后,留下半套用的切换;非表值现在先告警再归一为空表,Codex 与 GrokBuild 写入器同步修复。内联表形态(合法 TOML)有镜像问题:MCP 删除静默无效而界面报成功、`base_url` 编辑写到 Codex 根本不读的层级——均已处理。根节点、`provider` 或 `mcp` 段是数组 / 标量的 `opencode.json` 不再 panic,这类文件会被报错拒绝而不是重建,你自己的 `model`、`theme` 设置不会被抹掉。([#5811](https://github.com/farion1231/cc-switch/pull/5811))
|
||||
|
||||
### 代理转换扛得住畸形上游响应
|
||||
|
||||
上游网关的畸形数据此前可能直接干掉本地代理而不是产生错误:Anthropic SSE 流里非对象的 `message` 或 `content_block`、缓冲响应体是顶层 JSON 数组或标量(无视 `stream: true` 的网关就返回这种)都会命中 panic 的索引赋值;流现在以正常的失败事件收尾。畸形的 `content_block` 头还会被恢复为文本块——只把它净化成空对象虽止住 panic,却让后续内容全部被静默丢弃、模型看起来什么都没说——由于坏头之后的增量通常是完好的,常见情况现在能通传,替换发生时记一条警告。([#5811](https://github.com/farion1231/cc-switch/pull/5811))
|
||||
|
||||
### OpenClaw 的 Kimi For Coding 地址修正
|
||||
|
||||
OpenClaw 预设此前指向通用平台端点 `https://api.kimi.com/v1`,而 Kimi For Coding 订阅走的不是它,coding 套餐的 key 用不了。地址修正为 `https://api.kimi.com/coding/v1`,表单占位符与默认值同步更新。**从旧预设创建的供应商需手动改到新地址。**
|
||||
|
||||
---
|
||||
|
||||
## 安全加固
|
||||
|
||||
本节九条里,**有两件事需要你动手**:轮换进过 Gemini 通用配置的密钥,以及核对曾经通过 `ccswitch://` 导入的 MCP 条目——「升级提醒」里写明了怎么做。其余的升级即生效,不需要你操作。
|
||||
|
||||
如果你从不点开别人发来的 `ccswitch://` 链接,也没用过共享的 Gemini 通用配置,那这九条对你的意义主要是「以后更不容易出事」;如果两条里有一条对得上,**这一版值得优先升级**。
|
||||
|
||||
### Gemini 通用配置不再泄漏密钥,升级后自动清洗
|
||||
|
||||
Gemini 通用配置提取器此前只从共享片段里剥掉 `GEMINI_API_KEY` 与 `GOOGLE_GEMINI_BASE_URL`,其余 `env` 条目原样复制——而 `GOOGLE_API_KEY` 正是 Gemini 的一等凭据,某个账号的 key(连同其它长得像凭据的条目)会被深合并进每一个使用通用配置的 Gemini 供应商,并发往对方的 base URL——那可能是第三方中转。提取器现在跳过一切命中凭据模式的键(与 Claude 提取器同一套匹配器),前端片段校验器同步对齐,手工编辑也塞不回去。由于 Gemini 片段一旦存在就不再重提取,升级后首次启动还会执行**一次性清洗**:把已经泄漏的凭据从片段、从每个被合并到的供应商、从 `~/.gemini/.env` 里清掉——按键名**加值**全等匹配,供应商自己的同名不同值 key 不受牵连——并保留 env 文件的排版与注释。清洗细节与注意事项见「升级提醒」。([#5811](https://github.com/farion1231/cc-switch/pull/5811))
|
||||
|
||||
### Skill 仓库安装加固:路径穿越与归档上限
|
||||
|
||||
从 GitHub 仓库安装或浏览 Skill 此前可能写到目标目录之外:归档条目未经归一就拼上目标路径,带 `..` 的 ZIP 能逃出解压目录(zip-slip);仓库坐标从未校验,`../../../releases/download/v1/evil` 这样的分支名能把下载重定向到任意 release 资产——而 Skill 仓库可经不可信的 `ccswitch://` 深链添加且默认启用,打开 Skills 面板就足以触发下载。来自备份恢复、同步快照与「从应用导入」的 Skill `directory` 值同样未经校验就拼路径,卸载可能 `remove_dir_all` 到受管目录之外。所有落点现在都校验目录名,仓库 owner / 名称 / 分支在唯一下载汇聚点白名单化,解压设硬上限(10,000 条目、写入 512 MB、下载 128 MB、符号链接目标 4 KB,自指链接拒绝),新错误信息四语齐全。([#5811](https://github.com/farion1231/cc-switch/pull/5811))
|
||||
|
||||
### 深链导入确认框:看全内容,标记风险
|
||||
|
||||
`ccswitch://` 的 MCP 导入确认框此前只渲染一行会被截断的 `Command:`,`args`、`url`、`env` 一概不显示——链接带上 `command: "sh"` 加 `args: ["-c", "curl …|sh"]` 和一个 `LD_PRELOAD` 环境变量,显示出来只是一个人畜无害的 `sh`,确认后却被写进各应用的 live MCP 文件。确认框现在把命令、每个参数、URL 与环境变量逐行渲染,换行而非截断,不会有内容被裁掉看不见(键名含 TOKEN / KEY / SECRET / PASSWORD 的 env 值以前缀加星号脱敏显示);值得多看一眼的值会被高亮并汇总进警告块:带内联执行标志的 shell 解释器(含 `bash -lc`、`cmd /C`、PowerShell `-Command` 缩写等组合形态)、改变进程加载行为的环境变量(`LD_*`、`DYLD_*`、`NODE_OPTIONS`、`PYTHONPATH`、`PATH`、代理变量等)、指向回环 / 内网 / 云元数据地址的端点。标记纯属提示、从不拦截导入——本地 Ollama 端点是再正常不过的用法。供应商确认框获得同款处理;「将立即写入所有指定应用」的警告改为无条件显示,不再受链接可控字段的门控。
|
||||
|
||||
### 深链用量脚本:默认禁用导入,代码先看后用
|
||||
|
||||
经深链导入的用量查询脚本是每次查用量都会执行的 JavaScript,此前可能全程没见过代码就被启用:后端把「带了代码」当作「同意执行」,确认框只显示启用 / 禁用徽标、从不显示脚本体。脚本现在**默认禁用**——链接必须显式携带 `usageEnabled=true` 才请求启用——确认框以可滚动、完整换行的代码块显示解码后的全部脚本,并警告启用后将会执行。解码失败时回落显示原始载荷,畸形脚本不可能伪装成「没有脚本」。脚本代码照常存到供应商上,审阅后可在应用内手动开启。
|
||||
|
||||
### URL-safe Base64 曾让确认框整块变空
|
||||
|
||||
上面两条修的是「确认框显示得不够」,这一条修的是「确认框可以什么都不显示」。后端接受四种 Base64 变体(含 RFC 4648 §5 的 URL-safe 字母表),而前端的 `atob` 只认标准字母表、解不开时**原样返回输入而不报错**——于是同一段载荷,后端解码成功并导入,前端拿到的是一坨解不开的字符。用量脚本与系统提示词因此显示成不透明的 Base64;**MCP 配置最糟:`JSON.parse` 失败被组件吞掉,确认框渲染成「0 个服务器」加一张空列表,而后端照常把真实条目写进 live MCP 文件**。把载荷里一个 `/` 换成 `_` 就够了——确认框变空,导入功能完好,上面两条刚补上的完整展示随之一并失效。
|
||||
|
||||
前端解码器现在先归一 URL-safe 字母表再解码,确认框显示的永远与将要导入的一致;共享解码器首次有了单元测试,用例内含前置自检,确保样本真的落在 URL-safe 分支上而不是碰巧两种编码相同。
|
||||
|
||||
> 这条缺陷影响 v3.8.0 起的所有版本。若你曾通过 `ccswitch://` 链接导入过 MCP 服务器,建议检查一次——见「升级提醒」。
|
||||
|
||||
### SQL 导入拒绝触及导入库之外的语句
|
||||
|
||||
导入数据库备份此前只校验文件头注释,之后整段文本直接交给 `execute_batch`——精心构造的备份可以 `ATTACH DATABASE` 在用户可写的任意位置创建 SQLite 文件,且该副作用发生在导入自身的状态校验之前,导入整体失败文件也已落地;WebDAV / S3 同步快照走的是同一条代码路径。现在外部批次执行期间安装 SQLite authorizer(结束立即卸下,应用自身的 schema 维护不受影响):`ATTACH` / `DETACH`、`VACUUM`、虚表创建(csvfile 这类文件后端模块能读写任意路径)以及一切 SQLite 报告为未知的动作一律拒绝——未来的新语句默认失败;PRAGMA 只放行导出器实际会写的 `foreign_keys` 与 `user_version` 两个。
|
||||
|
||||
### 通用配置片段的原型污染
|
||||
|
||||
应用、移除、比对通用配置片段的三个遍历器此前都会跟着 `__proto__` 走进全局 `Object.prototype`:`JSON.parse('{"__proto__":{…}}')` 产出的是自有可枚举属性,合并会把攻击者指定的值写上全局原型——而 `settings` 表在同步时会被远端整表覆盖,恶意 WebDAV / S3 快照落地后,打开一次供应商表单就触发合并。三个遍历器现在一律跳过 `__proto__`、`constructor`、`prototype`;「已应用通用配置」的比对同时要求自有属性,顺带修掉一个可见怪象——`{"__proto__":{}}` 此前被判定为任何配置的子集。
|
||||
|
||||
### 终端启动的目录名命令注入
|
||||
|
||||
在外部终端恢复会话时,`cd` 行此前用双引号包裹工作目录、只转义反斜杠和双引号——双引号里 shell 照样展开 `$(…)`、反引号与 `$VAR`,而这个值是 CLI 会话历史里记录的真实项目路径,macOS 上目录名合法地可以包含这些字符。文件夹起了那样的名字,点「恢复」就会在你的终端里执行内嵌命令,全程无需任何被攻破的组件。三个拼 shell 行的启动器——Terminal.app、iTerm、kitty——改用 POSIX 单引号转义,任何内容都不展开(穿过 Terminal / iTerm 所需的 AppleScript 引号层同样安全);Ghostty、WezTerm / Kaku、Alacritty 本就把目录作为独立参数传递,原本安全。
|
||||
|
||||
### GrokBuild 凭据解析不再替换或内联环境密钥
|
||||
|
||||
GrokBuild 凭据提取此前在配置指定的 `env_key` 变量未设置时回落到进程级 `XAI_API_KEY`——静默替换成另一个账号的 key、发往配置指向的任意 base URL;凭据现在只来自显式的内联 `api_key` 或 `env_key` 精确命名的环境变量。深链导入不再把环境变量解析成明文 `api_key`;只带 `env_key` 名字的链接会被拒绝并提示手动添加——照单全收意味着请求时仍会解析受害者的环境密钥、送往链接声明的地址。顺带修复:base URL 解析与凭据解析解耦,此前凭据缺失连 base URL 一起清空,macOS(GUI 进程不继承 shell 环境)上界面显示的地址与实际使用的不一致、用量脚本的 `{{baseUrl}}` 展开为空。([#5811](https://github.com/farion1231/cc-switch/pull/5811))
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
### 「在 Claude Code 中使用 GPT 模型」攻略补齐英日双语
|
||||
|
||||
此前仅有中文的本地路由攻略现已完整移植为英文与日文,端到端覆盖两条接入路径:第三方 OpenAI Responses 网关(API Key),以及 ChatGPT Plus/Pro 订阅经 Codex 设备码 OAuth 登录。两篇路由攻略同时改题为「用什么模型」而非「什么客户端对」——《[在 Claude Code 中使用 GPT 模型](../guides/claude-codex-routing-guide-zh.md)》《[在 Codex 中使用 Claude 模型](../guides/codex-claude-routing-guide-zh.md)》——所有交叉链接(含三语 v3.18.0 release notes)改为指向读者语言的版本。
|
||||
|
||||
### 用户手册:深链 `usageEnabled` 默认值修正
|
||||
|
||||
三语用户手册的深链参考此前声称 `usageEnabled` 默认为 `true`,实际默认 `false`、与导入器一致。手册现在写明正确默认值,并补充两个推论:导入前确认框会完整显示脚本代码;未显式 `usageEnabled=true` 时脚本以禁用状态导入,可稍后在应用内开启。
|
||||
|
||||
### SECURITY.md:威胁模型与报告范围
|
||||
|
||||
`SECURITY.md` 补齐双语威胁模型与明确的范围内 / 范围外清单,报告按「谁控制这个输入」而非「值最终到了哪个 API」分诊:内置 WebView 渲染器声明为受信组件(附四条可独立验证的事实与失效触发条件);深链载荷、WebDAV / S3 恢复数据、导入文件、上游 API 响应、本地代理的入站请求全部列为不可信输入、欢迎报告。
|
||||
|
||||
---
|
||||
|
||||
## 升级提醒
|
||||
|
||||
### 本版没有数据库迁移
|
||||
|
||||
v3.19.0 不含 schema 迁移(版本号保持 v16),升级即用,无需等待数据重建。
|
||||
|
||||
### Gemini 密钥一次性清洗(请读)
|
||||
|
||||
升级后首次启动会在常规配置提取前执行一次性的 Gemini 通用配置清洗。**部分 Gemini 供应商随后可能提示缺少 API Key**:条目按凭据型键名加值全等匹配删除,通常删掉的是经共享片段泄漏进来的其它供应商凭据(该供应商自己的原值在泄漏发生时已被覆盖、无法找回)——但**你有意在多个 Gemini 供应商间复用的同值 key 也会被一并移除**。无论哪种情况,请先轮换再重填:**凡是进过共享 Gemini 片段的密钥都应视为已暴露**。删除的键名与受影响的供应商 id(绝不含值)记录在 `settings` 表的 `gemini_common_config_scrub_audit_v1` 下,可据此逐一定位需要重新填写密钥的供应商。
|
||||
|
||||
### 曾用深链导入过 MCP?建议检查一次(请读)
|
||||
|
||||
本版之前,`ccswitch://` 的 MCP 导入确认框可能**显示不出即将写入的内容**:参数与环境变量一概不渲染(`command: "sh"` 加 `args: ["-c", …]` 显示成一个无害的 `sh`),若载荷用 URL-safe Base64 编码,则整个列表显示成「0 个服务器」——而后端两种情况都照常把条目写进各应用的 live MCP 文件。这两条缺陷影响 **v3.8.0 起的所有版本**,本版一并修复。
|
||||
|
||||
利用需要你亲自打开攻击者提供的链接并点「导入」,因此绝大多数用户不受影响。**如果你确实从不完全信任的来源打开过 `ccswitch://` MCP 导入链接**,建议在 MCP 面板逐条核对,或直接检查 `~/.claude.json` 的 `mcpServers`(Codex 见 `~/.codex/config.toml` 的 `mcp_servers`),确认没有你不认识的条目——MCP 服务器会在 CLI 下次启动时作为子进程执行。
|
||||
|
||||
### 深链用量脚本默认禁用
|
||||
|
||||
携带用量查询脚本的深链现在默认以禁用状态导入,除非链接显式携带 `usageEnabled=true`。依赖自动启用的链接(例如部分合作伙伴的一键配置链接)会导入脚本但不开启用量查询——审阅代码后在供应商编辑器里手动开启即可。应用内手工配置的用量脚本不受影响。
|
||||
|
||||
### 新默认模型只影响新建供应商
|
||||
|
||||
已保存的供应商维持创建时的模型 ID,想用新模型需手动编辑。Claude Desktop 的 opus 路由现值前进到 `claude-opus-5`、`claude-opus-4-8` 移入兼容别名槽,存量配置照常解析。
|
||||
|
||||
### 定价播种与本地定价文件
|
||||
|
||||
新定价行(`claude-opus-5`、`gemini-3.6-flash`、`grok-4.5-build`)在下次启动按「不存在才插入」追加——**播种绝不覆盖你改过的价格**。`~/.cc-switch/model-pricing.json` 创建时为空,只记录本版之后的手工改价与删价——更早的改价不会迁入,想让它们扛住数据库重建,重存一次即可。models.dev 自动同步保持关闭直到你手动开启;一旦开启,它是唯一会覆盖同名价格(内置与手工皆然)的路径。
|
||||
|
||||
### GrokBuild 隐式环境变量回落已移除
|
||||
|
||||
依赖隐式 `XAI_API_KEY` 环境回落的 GrokBuild 供应商,现在需要显式的 `api_key` 或正确命名的 `env_key`。
|
||||
|
||||
### Grok 官方模式用量有意延迟
|
||||
|
||||
官方模式的 Grok 用量会延迟约十分钟加一个同步周期出现——事件先沉淀、再与代理记录的行核对防止双计;若路由流量与官方流量在窗口内交替,部分官方轮会被跳过而不是冒险重复计数。被旧成本回填高估过的 Grok Build 行保持原值——回填只处理零成本行,从不修订已有正成本。
|
||||
|
||||
### 更新镜像自下个版本起生效
|
||||
|
||||
更新器端点列表内置在应用二进制里,现有安装在升级到含本改动的版本之前仍然只查 GitHub;此后优先 `dl.ccswitch.io` 镜像、GitHub 回落。
|
||||
|
||||
### 赞助商域名迁移不改存量供应商
|
||||
|
||||
已创建的供应商保留数据库中存储的旧地址,仍指向旧域名。想迁到新域名,手动修改供应商地址,或从刷新后的预设重新创建。
|
||||
|
||||
---
|
||||
|
||||
## 风险提示
|
||||
|
||||
### SuperGrok 配额查询(本版新增)
|
||||
|
||||
供应商卡片的 SuperGrok 配额展示会读取 Grok CLI 自己的 OAuth 凭据(`~/.grok/auth.json`)并查询 grok.com 的计费端点——该端点并非公开文档化接口,其响应解析基于对现有格式的观察,xAI 调整接口后此功能可能失效(届时卡片降级为不显示配额,其余功能不受影响)。CC Switch 不会存储或修改这些凭据。
|
||||
|
||||
### 沿用的提示
|
||||
|
||||
**xAI Grok OAuth 登录**:复用官方 Grok CLI 的公开 OAuth 客户端身份,使用可能导致账号被限制或封禁——详见 [v3.18.0 release notes](v3.18.0-zh.md#风险提示)。
|
||||
|
||||
**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#️-风险提示)。
|
||||
|
||||
**第三方供应商路由**:通过 CC Switch 本地代理把 Codex、Claude Desktop 或 Grok Build 的请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。
|
||||
|
||||
用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
这一版的安全加固几乎全部来自外部——一个 PR,加上收到的安全报告。
|
||||
|
||||
### 代码贡献
|
||||
|
||||
- [#5811](https://github.com/farion1231/cc-switch/pull/5811):Skill 安装的 zip-slip 与仓库坐标穿越、Gemini 通用配置密钥泄漏与一次性清洗、GrokBuild 凭据解析、多处 panic 路径修复,感谢 @zayokami。这是本版单个来源里覆盖面最广的一份工作。
|
||||
- [#5734](https://github.com/farion1231/cc-switch/pull/5734):models.dev 自动定价同步,感谢 @YUZHEthefool。
|
||||
- [#5626](https://github.com/farion1231/cc-switch/pull/5626):Codex fork 会话用量导入提速,感谢 @ayanamislover(与 @SaladDay 共同署名)。
|
||||
|
||||
### 安全报告
|
||||
|
||||
本版「安全加固」里的四条修复来自私下发来的安全报告。感谢 **23pds**(SlowMist 慢雾)与 **zues devil**——逐条归属如下:
|
||||
|
||||
- **深链导入确认框只显示一行会被截断的 `Command:`**——`args`、`url`、`env` 一概不渲染,`sh -c` 加 `LD_PRELOAD` 的载荷在界面上看起来只是一个 `sh`。这是本版影响面最大的一条。(23pds,SlowMist)
|
||||
- **导入 SQL 备份未受约束**——`ATTACH DATABASE` 能在用户可写的任意位置创建文件,且副作用发生在导入自身的校验之前。(zues devil)
|
||||
- **外部终端启动的目录名命令注入**——`cd` 行用双引号包裹,`$(…)` 照常展开,而这个值是会话历史里记录的真实项目路径。(zues devil 与 23pds 各自独立报告,分别指向内置启动器与自定义模板两条路径)
|
||||
- **通用配置片段合并的原型污染**——三个遍历器都会跟着 `__proto__` 走进全局 `Object.prototype`。(23pds,SlowMist)
|
||||
|
||||
报告同时促使我们补齐了 [SECURITY.md](../../SECURITY.md) 的威胁模型与报告范围——在此之前,这个项目只写了怎么报告,没写什么算漏洞。
|
||||
|
||||
其余两条深链修复(用量脚本默认禁用、URL-safe Base64 绕过)是在审查上述修复本身时发现的,不在原始报告内。
|
||||
|
||||
### 问题反馈
|
||||
|
||||
感谢在 [#4465](https://github.com/farion1231/cc-switch/issues/4465) 与 [#5663](https://github.com/farion1231/cc-switch/issues/5663) 中反馈代理读图撑爆上下文的用户——本版最重要的代理修复来自这些真实场景的复现线索。
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本,或从官网 [ccswitch.io](https://ccswitch.io) 获取(本版起下载经 Cloudflare 边缘节点分发,不再依赖 GitHub 可达)。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | -------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 / ARM64 |
|
||||
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------------- | ----------------------------------- |
|
||||
| `CC-Switch-v3.19.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.19.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
Windows ARM64 设备请选择文件名中带 `arm64` 标识的对应制品。
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------- |
|
||||
| `CC-Switch-v3.19.0-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||
| `CC-Switch-v3.19.0-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||
| `CC-Switch-v3.19.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
Homebrew 安装:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux 资产同时提供 **x86_64** 和 **ARM64**(`aarch64`)两种架构。资产文件名中包含架构标识,请按你机器的 `uname -m` 输出选择对应版本:
|
||||
|
||||
- `CC-Switch-v3.19.0-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.19.0-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
|
||||
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -60,7 +60,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
|
||||
| `configUrl` | No | Remote configuration URL |
|
||||
| `enabled` | No | Whether to enable (boolean) |
|
||||
| `usageScript` | No | Usage query script |
|
||||
| `usageEnabled` | No | Whether to enable usage query (default true) |
|
||||
| `usageEnabled` | No | Whether to enable usage query (**default false**). The script body is shown in full in the import confirmation dialog; without an explicit `true` the script is imported but left disabled, and can be enabled in the app |
|
||||
| `usageApiKey` | No | Usage query API Key |
|
||||
| `usageBaseUrl` | No | Usage query base URL |
|
||||
| `usageAccessToken` | No | Usage query access token |
|
||||
|
||||
@@ -60,7 +60,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
|
||||
| `configUrl` | いいえ | リモート設定 URL |
|
||||
| `enabled` | いいえ | 有効にするかどうか(ブール値) |
|
||||
| `usageScript` | いいえ | 使用量クエリスクリプト |
|
||||
| `usageEnabled` | いいえ | 使用量クエリを有効にするか(デフォルト true) |
|
||||
| `usageEnabled` | いいえ | 使用量クエリを有効にするか(**デフォルト false**)。スクリプト本文はインポート確認ダイアログに全文表示されます。明示的に `true` を指定しない場合はインポートされるだけで有効化されず、アプリ内で手動で有効にできます |
|
||||
| `usageApiKey` | いいえ | 使用量クエリ専用 API Key |
|
||||
| `usageBaseUrl` | いいえ | 使用量クエリ専用アドレス |
|
||||
| `usageAccessToken` | いいえ | 使用量クエリアクセストークン |
|
||||
|
||||
@@ -60,7 +60,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
|
||||
| `configUrl` | 否 | 远程配置 URL |
|
||||
| `enabled` | 否 | 是否启用(布尔值) |
|
||||
| `usageScript` | 否 | 用量查询脚本 |
|
||||
| `usageEnabled` | 否 | 是否启用用量查询(默认 true) |
|
||||
| `usageEnabled` | 否 | 是否启用用量查询(**默认 false**)。脚本正文会完整展示在导入确认框中;未显式传 `true` 时仅导入不启用,可在应用内手动开启 |
|
||||
| `usageApiKey` | 否 | 用量查询专用 API Key |
|
||||
| `usageBaseUrl` | 否 | 用量查询专用地址 |
|
||||
| `usageAccessToken` | 否 | 用量查询访问令牌 |
|
||||
|
||||
+1
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.18.0",
|
||||
"version": "3.19.0",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -51,7 +51,6 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lobehub/icons-static-svg": "^1.73.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -65,14 +64,12 @@
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.90.3",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-log": "2.8.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-store": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -81,7 +78,6 @@
|
||||
"flexsearch": "^0.8.212",
|
||||
"framer-motion": "^12.23.25",
|
||||
"i18next": "^25.5.2",
|
||||
"jsonc-parser": "^3.2.1",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
Generated
-51
@@ -41,9 +41,6 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.65.0(react@18.3.1))
|
||||
'@lobehub/icons-static-svg':
|
||||
specifier: ^1.73.0
|
||||
version: 1.73.0
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.12
|
||||
version: 1.2.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -83,9 +80,6 @@ importers:
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.8
|
||||
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-visually-hidden':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.90.3
|
||||
version: 5.90.3(react@18.3.1)
|
||||
@@ -104,9 +98,6 @@ importers:
|
||||
'@tauri-apps/plugin-process':
|
||||
specifier: ^2.0.0
|
||||
version: 2.3.0
|
||||
'@tauri-apps/plugin-store':
|
||||
specifier: ^2.0.0
|
||||
version: 2.4.0
|
||||
'@tauri-apps/plugin-updater':
|
||||
specifier: ^2.0.0
|
||||
version: 2.9.0
|
||||
@@ -131,9 +122,6 @@ importers:
|
||||
i18next:
|
||||
specifier: ^25.5.2
|
||||
version: 25.5.2(typescript@5.9.2)
|
||||
jsonc-parser:
|
||||
specifier: ^3.2.1
|
||||
version: 3.3.1
|
||||
lucide-react:
|
||||
specifier: ^0.542.0
|
||||
version: 0.542.0(react@18.3.1)
|
||||
@@ -835,9 +823,6 @@ packages:
|
||||
'@lezer/markdown@1.6.0':
|
||||
resolution: {integrity: sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==}
|
||||
|
||||
'@lobehub/icons-static-svg@1.73.0':
|
||||
resolution: {integrity: sha512-ydKUCDoopdmulbjDZo/gppaODd5Ju5nPneVcN9A5dAz9IJZUMkLms8bqostMLrqcdMQ8resKjLuV9RhJaWhaag==}
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2':
|
||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||
|
||||
@@ -1319,19 +1304,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.4':
|
||||
resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
@@ -1571,9 +1543,6 @@ packages:
|
||||
'@tauri-apps/plugin-process@2.3.0':
|
||||
resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==}
|
||||
|
||||
'@tauri-apps/plugin-store@2.4.0':
|
||||
resolution: {integrity: sha512-PjBnlnH6jyI71MGhrPaxUUCsOzc7WO1mbc4gRhME0m2oxLgCqbksw6JyeKQimuzv4ysdpNO3YbmaY2haf82a3A==}
|
||||
|
||||
'@tauri-apps/plugin-updater@2.9.0':
|
||||
resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==}
|
||||
|
||||
@@ -2267,9 +2236,6 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jsonc-parser@3.3.1:
|
||||
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||
|
||||
launch-ide@1.3.0:
|
||||
resolution: {integrity: sha512-pxiF+HVNMV0dDc6Z0q89RDmzMF9XmSGaOn4ueTegjMy3cUkezc3zrki5PCiz68zZIqAuhW7iwoWX7JO4Kn6B0A==}
|
||||
|
||||
@@ -3692,8 +3658,6 @@ snapshots:
|
||||
'@lezer/common': 1.2.3
|
||||
'@lezer/highlight': 1.2.1
|
||||
|
||||
'@lobehub/icons-static-svg@1.73.0': {}
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2': {}
|
||||
|
||||
'@mswjs/interceptors@0.40.0':
|
||||
@@ -4197,15 +4161,6 @@ snapshots:
|
||||
'@types/react': 18.3.23
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.23
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@reduxjs/toolkit@2.11.0(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1)':
|
||||
@@ -4362,10 +4317,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.8.0
|
||||
|
||||
'@tauri-apps/plugin-store@2.4.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.8.0
|
||||
|
||||
'@tauri-apps/plugin-updater@2.9.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.8.0
|
||||
@@ -5099,8 +5050,6 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonc-parser@3.3.1: {}
|
||||
|
||||
launch-ide@1.3.0:
|
||||
dependencies:
|
||||
chalk: 4.1.1
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 要提取的图标列表(按分类组织)
|
||||
const ICONS_TO_EXTRACT = {
|
||||
// AI 服务商(必需)
|
||||
aiProviders: [
|
||||
'openai', 'anthropic', 'claude', 'google', 'gemini',
|
||||
'deepseek', 'kimi', 'moonshot', 'stepfun', 'zhipu', 'minimax',
|
||||
'baidu', 'alibaba', 'tencent', 'meta', 'microsoft',
|
||||
'cohere', 'perplexity', 'mistral', 'huggingface'
|
||||
],
|
||||
|
||||
// 云平台
|
||||
cloudPlatforms: [
|
||||
'aws', 'azure', 'huawei', 'cloudflare'
|
||||
],
|
||||
|
||||
// 开发工具
|
||||
devTools: [
|
||||
'github', 'gitlab', 'docker', 'kubernetes', 'vscode'
|
||||
],
|
||||
|
||||
// 其他
|
||||
others: [
|
||||
'settings', 'folder', 'file', 'link'
|
||||
]
|
||||
};
|
||||
|
||||
// 合并所有图标
|
||||
const ALL_ICONS = [
|
||||
...ICONS_TO_EXTRACT.aiProviders,
|
||||
...ICONS_TO_EXTRACT.cloudPlatforms,
|
||||
...ICONS_TO_EXTRACT.devTools,
|
||||
...ICONS_TO_EXTRACT.others
|
||||
];
|
||||
|
||||
// 提取逻辑
|
||||
const OUTPUT_DIR = path.join(__dirname, '../src/icons/extracted');
|
||||
const SOURCE_DIR = path.join(__dirname, '../node_modules/@lobehub/icons-static-svg/icons');
|
||||
|
||||
// 确保输出目录存在
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
console.log('🎨 CC-Switch Icon Extractor\n');
|
||||
console.log('========================================');
|
||||
console.log('📦 Extracting icons...\n');
|
||||
|
||||
let extracted = 0;
|
||||
let notFound = [];
|
||||
|
||||
// 提取图标
|
||||
ALL_ICONS.forEach(iconName => {
|
||||
const sourceFile = path.join(SOURCE_DIR, `${iconName}.svg`);
|
||||
const targetFile = path.join(OUTPUT_DIR, `${iconName}.svg`);
|
||||
|
||||
if (fs.existsSync(sourceFile)) {
|
||||
fs.copyFileSync(sourceFile, targetFile);
|
||||
console.log(` ✓ ${iconName}.svg`);
|
||||
extracted++;
|
||||
} else if (fs.existsSync(targetFile)) {
|
||||
console.log(` ✓ ${iconName}.svg (kept local custom icon)`);
|
||||
extracted++;
|
||||
} else {
|
||||
console.log(` ✗ ${iconName}.svg (not found)`);
|
||||
notFound.push(iconName);
|
||||
}
|
||||
});
|
||||
|
||||
// 生成索引文件
|
||||
console.log('\n📝 Generating index file...\n');
|
||||
|
||||
const indexContent = `// Auto-generated icon index
|
||||
// Do not edit manually
|
||||
|
||||
export const icons: Record<string, string> = {
|
||||
${ALL_ICONS.filter(name => !notFound.includes(name))
|
||||
.map(name => {
|
||||
const svg = fs.readFileSync(path.join(OUTPUT_DIR, `${name}.svg`), 'utf-8');
|
||||
const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
||||
return ` '${name}': \`${escaped}\`,`;
|
||||
})
|
||||
.join('\n')}
|
||||
};
|
||||
|
||||
export const iconList = Object.keys(icons);
|
||||
|
||||
export function getIcon(name: string): string {
|
||||
return icons[name.toLowerCase()] || '';
|
||||
}
|
||||
|
||||
export function hasIcon(name: string): boolean {
|
||||
return name.toLowerCase() in icons;
|
||||
}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'index.ts'), indexContent);
|
||||
console.log('✓ Generated: src/icons/extracted/index.ts');
|
||||
|
||||
// 生成图标元数据
|
||||
const metadataContent = `// Icon metadata for search and categorization
|
||||
import { IconMetadata } from '@/types/icon';
|
||||
|
||||
export const iconMetadata: Record<string, IconMetadata> = {
|
||||
// AI Providers
|
||||
openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },
|
||||
anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },
|
||||
claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },
|
||||
google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },
|
||||
gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },
|
||||
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
|
||||
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
|
||||
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
|
||||
stepfun: { name: 'stepfun', displayName: 'StepFun', category: 'ai-provider', keywords: ['stepfun', 'step', 'jieyue', '阶跃星辰'], defaultColor: '#005AFF' },
|
||||
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
|
||||
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
|
||||
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
|
||||
alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },
|
||||
tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },
|
||||
meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },
|
||||
microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },
|
||||
cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },
|
||||
perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },
|
||||
mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },
|
||||
huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },
|
||||
|
||||
// Cloud Platforms
|
||||
aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },
|
||||
azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },
|
||||
huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },
|
||||
cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },
|
||||
|
||||
// Dev Tools
|
||||
github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },
|
||||
gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },
|
||||
docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },
|
||||
kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },
|
||||
vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },
|
||||
|
||||
// Others
|
||||
settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },
|
||||
folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },
|
||||
file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },
|
||||
link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },
|
||||
};
|
||||
|
||||
export function getIconMetadata(name: string): IconMetadata | undefined {
|
||||
return iconMetadata[name.toLowerCase()];
|
||||
}
|
||||
|
||||
export function searchIcons(query: string): string[] {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return Object.values(iconMetadata)
|
||||
.filter(meta =>
|
||||
meta.name.includes(lowerQuery) ||
|
||||
meta.displayName.toLowerCase().includes(lowerQuery) ||
|
||||
meta.keywords.some(k => k.includes(lowerQuery))
|
||||
)
|
||||
.map(meta => meta.name);
|
||||
}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'metadata.ts'), metadataContent);
|
||||
console.log('✓ Generated: src/icons/extracted/metadata.ts');
|
||||
|
||||
// 生成 README
|
||||
const readmeContent = `# Extracted Icons
|
||||
|
||||
This directory contains extracted icons from @lobehub/icons-static-svg.
|
||||
|
||||
## Statistics
|
||||
- Total extracted: ${extracted} icons
|
||||
- Not found: ${notFound.length} icons
|
||||
|
||||
## Extracted Icons
|
||||
${ALL_ICONS.filter(name => !notFound.includes(name)).map(name => `- ${name}`).join('\n')}
|
||||
|
||||
${notFound.length > 0 ? `\n## Not Found\n${notFound.map(name => `- ${name}`).join('\n')}` : ''}
|
||||
|
||||
## Usage
|
||||
|
||||
\`\`\`typescript
|
||||
import { getIcon, hasIcon, iconList } from './extracted';
|
||||
|
||||
// Get icon SVG
|
||||
const svg = getIcon('openai');
|
||||
|
||||
// Check if icon exists
|
||||
if (hasIcon('openai')) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Get all available icons
|
||||
console.log(iconList);
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
Last updated: ${new Date().toISOString()}
|
||||
Generated by: scripts/extract-icons.js
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'README.md'), readmeContent);
|
||||
console.log('✓ Generated: src/icons/extracted/README.md');
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('✅ Extraction complete!\n');
|
||||
console.log(` ✓ Extracted: ${extracted} icons`);
|
||||
console.log(` ✗ Not found: ${notFound.length} icons`);
|
||||
console.log(` 📉 Bundle size reduction: ~${Math.round((1 - extracted / 723) * 100)}%`);
|
||||
console.log('========================================\n');
|
||||
@@ -1,96 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ICONS_DIR = path.join(__dirname, '../src/icons/extracted');
|
||||
|
||||
// List of "Famous" icons to keep
|
||||
// Based on common AI providers and tools
|
||||
const KEEP_LIST = [
|
||||
// AI Providers
|
||||
'openai', 'anthropic', 'claude', 'google', 'gemini', 'gemma', 'palm',
|
||||
'microsoft', 'azure', 'copilot', 'meta', 'llama',
|
||||
'alibaba', 'qwen', 'tencent', 'hunyuan', 'baidu', 'wenxin',
|
||||
'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi', 'stepfun',
|
||||
'zhipu', 'chatglm', 'glm', 'minimax', 'mistral', 'cohere',
|
||||
'perplexity', 'huggingface', 'midjourney', 'stability',
|
||||
'xai', 'grok', 'yi', 'zeroone', 'ollama',
|
||||
'packycode',
|
||||
|
||||
// Cloud/Tools
|
||||
'aws', 'googlecloud', 'huawei', 'cloudflare',
|
||||
'github', 'githubcopilot', 'vercel', 'notion', 'discord',
|
||||
'gitlab', 'docker', 'kubernetes', 'vscode', 'settings', 'folder', 'file', 'link'
|
||||
];
|
||||
|
||||
// Get all SVG files
|
||||
const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));
|
||||
|
||||
console.log(`Scanning ${files.length} files...`);
|
||||
|
||||
let keptCount = 0;
|
||||
let deletedCount = 0;
|
||||
let renamedCount = 0;
|
||||
|
||||
// First pass: Identify files to keep and prefer color versions
|
||||
const fileMap = {}; // name -> { hasColor: bool, hasMono: bool }
|
||||
|
||||
files.forEach(file => {
|
||||
const isColor = file.endsWith('-color.svg');
|
||||
const baseName = isColor ? file.replace('-color.svg', '') : file.replace('.svg', '');
|
||||
|
||||
if (!fileMap[baseName]) {
|
||||
fileMap[baseName] = { hasColor: false, hasMono: false };
|
||||
}
|
||||
|
||||
if (isColor) {
|
||||
fileMap[baseName].hasColor = true;
|
||||
} else {
|
||||
fileMap[baseName].hasMono = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Second pass: Process files
|
||||
Object.keys(fileMap).forEach(baseName => {
|
||||
const info = fileMap[baseName];
|
||||
const shouldKeep = KEEP_LIST.includes(baseName);
|
||||
|
||||
if (!shouldKeep) {
|
||||
// Delete both versions if not in keep list
|
||||
if (info.hasColor) {
|
||||
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}-color.svg`));
|
||||
deletedCount++;
|
||||
}
|
||||
if (info.hasMono) {
|
||||
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}.svg`));
|
||||
deletedCount++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If keeping, prefer color
|
||||
if (info.hasColor) {
|
||||
// Rename color version to base version (overwrite mono if exists)
|
||||
const colorPath = path.join(ICONS_DIR, `${baseName}-color.svg`);
|
||||
const targetPath = path.join(ICONS_DIR, `${baseName}.svg`);
|
||||
|
||||
try {
|
||||
// If mono exists, it will be overwritten/replaced
|
||||
fs.renameSync(colorPath, targetPath);
|
||||
renamedCount++;
|
||||
keptCount++;
|
||||
} catch (e) {
|
||||
console.error(`Error renaming ${baseName}:`, e);
|
||||
}
|
||||
} else if (info.hasMono) {
|
||||
// Keep mono if no color version
|
||||
keptCount++;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\nCleanup complete:`);
|
||||
console.log(`- Kept: ${keptCount}`);
|
||||
console.log(`- Deleted: ${deletedCount}`);
|
||||
console.log(`- Renamed (Color -> Standard): ${renamedCount}`);
|
||||
|
||||
// Regenerate index and metadata
|
||||
require('./generate-icon-index.js');
|
||||
Generated
+1
-1
@@ -758,7 +758,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.18.0"
|
||||
version = "3.19.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.18.0"
|
||||
version = "3.19.0"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
@@ -91,7 +91,11 @@ webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.52"
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_UI_Shell"] }
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Globalization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
|
||||
[target.'cfg(all(target_os = "windows", target_arch = "aarch64"))'.dependencies]
|
||||
rquickjs = { version = "0.8", features = ["bindgen"] }
|
||||
|
||||
@@ -1908,25 +1908,53 @@ pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Resu
|
||||
|
||||
if let Some(provider_key) = model_provider {
|
||||
// Ensure [model_providers] table exists
|
||||
if doc.get("model_providers").is_none() {
|
||||
//
|
||||
// 用 as_table_like_mut 而非 as_table_mut:用户把配置写成 inline table
|
||||
// (`model_providers = { foo = {...} }`,TOML 合法)时 as_table_mut
|
||||
// 返回 None,会一路掉进下面的顶层 fallback——用户改的 base_url 被写到
|
||||
// 了错误层级且毫无提示。
|
||||
if doc
|
||||
.get("model_providers")
|
||||
.is_none_or(|item| item.as_table_like().is_none())
|
||||
{
|
||||
// 键存在但不是表(`model_providers = 42`)时,下面这行会把用户
|
||||
// 手写的值替换掉。旧代码在这种形状下会掉进顶层 fallback 而不动
|
||||
// 它,所以归一化必须留痕——与 mcp/codex.rs、mcp/grokbuild.rs、
|
||||
// opencode_config.rs 的同款处理保持一致。
|
||||
if doc
|
||||
.get("model_providers")
|
||||
.is_some_and(|item| !item.is_none())
|
||||
{
|
||||
log::warn!("config.toml 的 model_providers 不是表,已重置为空表");
|
||||
}
|
||||
doc["model_providers"] = toml_edit::table();
|
||||
}
|
||||
|
||||
if let Some(model_providers) = doc["model_providers"].as_table_mut() {
|
||||
if let Some(model_providers) = doc
|
||||
.get_mut("model_providers")
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
{
|
||||
// Ensure [model_providers.<provider_key>] table exists
|
||||
if !model_providers.contains_key(&provider_key) {
|
||||
model_providers[&provider_key] = toml_edit::table();
|
||||
model_providers.insert(&provider_key, toml_edit::table());
|
||||
}
|
||||
|
||||
if let Some(provider_table) = model_providers[&provider_key].as_table_mut() {
|
||||
if let Some(provider_table) = model_providers
|
||||
.get_mut(&provider_key)
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
{
|
||||
if trimmed.is_empty() {
|
||||
provider_table.remove(field);
|
||||
} else {
|
||||
provider_table[field] = toml_edit::value(trimmed);
|
||||
provider_table.insert(field, toml_edit::value(trimmed));
|
||||
}
|
||||
return Ok(doc.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"config.toml 的 [model_providers.{provider_key}] 结构异常,{field} 改写为顶层字段"
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: no model_provider or structure mismatch → top-level field
|
||||
@@ -2585,6 +2613,34 @@ model = "gpt-4"
|
||||
assert_eq!(base_url, "https://fallback.api/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_url_writes_into_inline_table_provider_section() {
|
||||
// inline table 是合法 TOML,但 as_table_mut() 对它返回 None。旧代码会因此
|
||||
// 掉进「写顶层字段」的 fallback:用户改的 base_url 落在错误层级,
|
||||
// Codex 读不到,且界面毫无提示。
|
||||
let input = r#"model_provider = "any"
|
||||
model_providers = { any = { name = "any", base_url = "https://old.api/v1", wire_api = "responses" } }
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed["model_providers"]["any"]["base_url"].as_str(),
|
||||
Some("https://new.api/v1"),
|
||||
"must update the provider section, not a top-level field"
|
||||
);
|
||||
assert!(
|
||||
parsed.get("base_url").is_none(),
|
||||
"must not leak a top-level base_url fallback"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["model_providers"]["any"]["wire_api"].as_str(),
|
||||
Some("responses"),
|
||||
"sibling fields must survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_base_url_removes_only_from_correct_section() {
|
||||
let input = r#"model_provider = "any"
|
||||
|
||||
@@ -24,6 +24,40 @@ pub async fn get_session_messages(
|
||||
.map_err(|e| format!("Failed to load session messages: {e}"))?
|
||||
}
|
||||
|
||||
/// 在用户选定的终端里恢复一个会话。
|
||||
///
|
||||
/// # 安全边界:`command` 是刻意不加校验的
|
||||
///
|
||||
/// 本命令接受 renderer 传来的任意字符串并最终交给 shell。多份外部审计把这一点
|
||||
/// 报成"IPC 任意命令执行",这里明确记录为**已知并接受的风险**,而不是待修缺陷。
|
||||
///
|
||||
/// 依据是本应用把 renderer 当作可信边界。支撑这一判断的是以下事实,全部逐条
|
||||
/// 核实过(2026-07):
|
||||
///
|
||||
/// 1. 全库仅一处 `dangerouslySetInnerHTML`(`ProviderIcon.tsx`),其入参是图标
|
||||
/// **名字**,经 `hasIcon()` 把关后从手工维护的构建期注册表取 SVG——用户与
|
||||
/// 深链接都只能给名字,给不了标记内容
|
||||
/// 2. 前端无 `eval` / `new Function`
|
||||
/// 3. `tauri.conf.json` 的 `frontendDist` 指向打包产物,webview 不加载任何远程
|
||||
/// 源;界面里也没有 `<iframe>` / `<webview>`
|
||||
/// 4. CSP 为 `script-src 'self'`——既不允许内联脚本,也不允许外部脚本
|
||||
///
|
||||
/// 因此"攻击者能调用本 IPC"这一前提,成立时已意味着他能以当前用户身份执行代码;
|
||||
/// 那种情况下绕道本命令并不会让他多拿到任何东西。
|
||||
///
|
||||
/// # 什么会推翻这个结论
|
||||
///
|
||||
/// 上面四条任意一条不再成立,本命令就必须改成**只接收 session / provider 标识、
|
||||
/// 由后端从会话记录重建命令**。具体触发条件:
|
||||
///
|
||||
/// - 渲染任何来自网络或配置文件的富文本 / HTML / SVG 内容
|
||||
/// - 引入 `<iframe>`、`<webview>`,或让 webview 导航到远程 origin
|
||||
/// - 放宽 CSP 的 `script-src`(例如为了加载第三方脚本或统计 SDK)
|
||||
/// - 引入任何在 renderer 内执行外部代码的机制
|
||||
///
|
||||
/// 相比之下 `cwd` 的处理**不属于**这条豁免:它是磁盘上扫来的项目路径,正常使用
|
||||
/// 就可能含 `$(...)`,与 renderer 是否可信无关,因此在
|
||||
/// `session_manager::terminal::shell_escape` 里做了完整的单引号转义。
|
||||
#[tauri::command]
|
||||
pub async fn launch_session_terminal(
|
||||
command: String,
|
||||
|
||||
@@ -301,6 +301,10 @@ pub fn get_skill_repos(app_state: State<'_, AppState>) -> Result<Vec<SkillRepo>,
|
||||
/// 添加技能仓库
|
||||
#[tauri::command]
|
||||
pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||
// 整个结构体由前端反序列化而来,owner/name/branch 会被拼进归档下载 URL。
|
||||
// 主防线在 download_repo,这里让非法值当场报错而不是沉淀进表。
|
||||
SkillService::validate_repo_ref(&repo.owner, &repo.name, &repo.branch)
|
||||
.map_err(|e| e.to_string())?;
|
||||
app_state
|
||||
.db
|
||||
.save_skill_repo(&repo)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
//! 使用统计相关命令
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::services::model_pricing::{ModelPricingInfo, ModelsDevSyncConfig, ModelsDevSyncState};
|
||||
use crate::services::usage_stats::*;
|
||||
use crate::store::AppState;
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
use tauri::State;
|
||||
|
||||
/// 获取使用量汇总
|
||||
@@ -125,6 +124,7 @@ pub fn get_request_detail(
|
||||
pub fn get_model_pricing(state: State<'_, AppState>) -> Result<Vec<ModelPricingInfo>, AppError> {
|
||||
log::info!("获取模型定价列表");
|
||||
state.db.ensure_model_pricing_seeded()?;
|
||||
crate::services::model_pricing::sync_local_model_pricing(&state.db)?;
|
||||
|
||||
let db = state.db.clone();
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
@@ -181,72 +181,53 @@ pub fn update_model_pricing(
|
||||
cache_read_cost: String,
|
||||
cache_creation_cost: String,
|
||||
) -> Result<(), AppError> {
|
||||
let db = state.db.clone();
|
||||
let model_id = model_id.trim().to_string();
|
||||
let display_name = display_name.trim().to_string();
|
||||
if model_id.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"usage.modelIdRequired",
|
||||
"模型 ID 不能为空",
|
||||
"Model ID is required",
|
||||
));
|
||||
}
|
||||
if display_name.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"usage.displayNameRequired",
|
||||
"显示名称不能为空",
|
||||
"Display name is required",
|
||||
));
|
||||
}
|
||||
|
||||
for (label, value) in [
|
||||
("input_cost", &input_cost),
|
||||
("output_cost", &output_cost),
|
||||
("cache_read_cost", &cache_read_cost),
|
||||
("cache_creation_cost", &cache_creation_cost),
|
||||
] {
|
||||
let parsed = Decimal::from_str(value.trim()).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage.invalidPrice",
|
||||
format!("{label} 价格无效: {value} - {e}"),
|
||||
format!("{label} price is invalid: {value} - {e}"),
|
||||
)
|
||||
})?;
|
||||
if parsed < Decimal::ZERO {
|
||||
return Err(AppError::localized(
|
||||
"usage.invalidPrice",
|
||||
format!("{label} 价格必须为非负数: {value}"),
|
||||
format!("{label} price must be non-negative: {value}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO model_pricing (
|
||||
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
model_id,
|
||||
display_name,
|
||||
input_cost.trim(),
|
||||
output_cost.trim(),
|
||||
cache_read_cost.trim(),
|
||||
cache_creation_cost.trim()
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?;
|
||||
}
|
||||
|
||||
if let Err(e) = db.backfill_missing_usage_costs_for_model(&model_id) {
|
||||
log::warn!("模型定价更新后回填历史用量成本失败 (model_id={model_id}): {e}");
|
||||
}
|
||||
|
||||
crate::services::model_pricing::update_model_pricing(
|
||||
&state.db,
|
||||
ModelPricingInfo {
|
||||
model_id,
|
||||
display_name,
|
||||
input_cost_per_million: input_cost,
|
||||
output_cost_per_million: output_cost,
|
||||
cache_read_cost_per_million: cache_read_cost,
|
||||
cache_creation_cost_per_million: cache_creation_cost,
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 批量更新模型定价(models.dev 自动同步仅触发一次历史成本回填)
|
||||
#[tauri::command]
|
||||
pub fn update_model_pricing_batch(
|
||||
state: State<'_, AppState>,
|
||||
entries: Vec<ModelPricingInfo>,
|
||||
) -> Result<usize, AppError> {
|
||||
crate::services::model_pricing::update_model_pricing_batch(&state.db, entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_models_dev_sync_config(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ModelsDevSyncState, AppError> {
|
||||
crate::services::model_pricing::get_models_dev_sync_state(&state.db)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_models_dev_sync_config(
|
||||
state: State<'_, AppState>,
|
||||
config: ModelsDevSyncConfig,
|
||||
) -> Result<(), AppError> {
|
||||
crate::services::model_pricing::save_models_dev_sync_config(&state.db, config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn record_models_dev_sync_result(
|
||||
state: State<'_, AppState>,
|
||||
synced_at: Option<i64>,
|
||||
error: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
crate::services::model_pricing::record_models_dev_sync_result(&state.db, synced_at, error)
|
||||
}
|
||||
|
||||
/// 检查 Provider 使用限额
|
||||
#[tauri::command]
|
||||
pub fn check_provider_limits(
|
||||
@@ -260,15 +241,7 @@ pub fn check_provider_limits(
|
||||
/// 删除模型定价
|
||||
#[tauri::command]
|
||||
pub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Result<(), AppError> {
|
||||
let db = state.db.clone();
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
|
||||
conn.execute(
|
||||
"DELETE FROM model_pricing WHERE model_id = ?1",
|
||||
rusqlite::params![model_id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("删除模型定价失败: {e}")))?;
|
||||
|
||||
crate::services::model_pricing::delete_model_pricing(&state.db, &model_id)?;
|
||||
log::info!("已删除模型定价: {model_id}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -326,18 +299,6 @@ pub fn get_usage_data_sources(
|
||||
crate::services::session_usage::get_data_source_breakdown(&state.db)
|
||||
}
|
||||
|
||||
/// 模型定价信息
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelPricingInfo {
|
||||
pub model_id: String,
|
||||
pub display_name: String,
|
||||
pub input_cost_per_million: String,
|
||||
pub output_cost_per_million: String,
|
||||
pub cache_read_cost_per_million: String,
|
||||
pub cache_creation_cost_per_million: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -15,6 +15,56 @@ use tempfile::NamedTempFile;
|
||||
|
||||
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
||||
|
||||
/// `dump_sql` 会写出的 PRAGMA。其余 PRAGMA 一律拒绝——`temp_store_directory`
|
||||
/// 能把临时文件重定向到任意目录,`writable_schema` 能绕过 schema 完整性检查。
|
||||
const IMPORT_ALLOWED_PRAGMAS: &[&str] = &["foreign_keys", "user_version"];
|
||||
|
||||
/// 执行外部 SQL 期间的 authorizer:拒绝一切能**离开临时数据库文件**的动作。
|
||||
///
|
||||
/// 头部校验(`validate_cc_switch_sql_export`)只比较一个注释前缀,任何人都能在
|
||||
/// 合法前缀后面接着写别的语句。`ATTACH DATABASE '/path/x.db'` 的副作用发生在
|
||||
/// `validate_basic_state` 之前,导入即使最终失败,文件也已经被创建;而 `settings`
|
||||
/// 表不在 `SYNC_SKIP_TABLES` / `SYNC_PRESERVE_TABLES` 之列,WebDAV/S3 同步会走
|
||||
/// 同一条 `import_sql_string_inner`,所以这条路径的输入不可信。
|
||||
///
|
||||
/// 为什么是 authorizer 而不是「扫描 ATTACH 关键字」:字符串扫描会被 `/*x*/ATTACH`、
|
||||
/// 大小写、换行绕过,还漏掉 `VACUUM INTO`。authorizer 在 prepare 阶段按**解析结果**
|
||||
/// 回调,绕不过语法层。
|
||||
///
|
||||
/// 为什么是「拒绝越界动作」而不是「只放行 dump_sql 的语句」:这段 SQL 跑在
|
||||
/// `NamedTempFile` 建的一次性库上,而那个库的全部内容本来就由这份 SQL 决定。
|
||||
/// 因此 `DELETE` / `DROP` / `UPDATE` 给不了攻击者任何新东西——**唯一有意义的边界
|
||||
/// 是那个临时文件本身**。按 dump_sql 的产物做严格白名单只会带来误伤风险(用户
|
||||
/// 库里出现一种没预料到的对象就恢复不了备份),却不多挡任何攻击。
|
||||
///
|
||||
/// 越界动作是实测出来的,不是推断的:
|
||||
/// - `ATTACH DATABASE 'x'`、`VACUUM INTO 'x'`、裸 `VACUUM` **三者都**报
|
||||
/// `AuthAction::Attach`,所以拒 `Attach` 一条即可覆盖
|
||||
/// - 文件后端的虚拟表模块(`csvfile`、`zipfile` 等)能读写任意路径 → 拒 vtable
|
||||
/// - `Unknown` 是 rusqlite 对未识别动作码的兜底 → 未知即拒,将来 SQLite 新增的
|
||||
/// 跨文件语句会默认落进这里,不依赖有人记得回来补名单
|
||||
fn import_authorizer(context: rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization {
|
||||
use rusqlite::hooks::{AuthAction, Authorization};
|
||||
|
||||
let escapes_temp_db = match context.action {
|
||||
AuthAction::Attach { .. } | AuthAction::Detach { .. } => true,
|
||||
AuthAction::CreateVtable { .. } | AuthAction::DropVtable { .. } => true,
|
||||
AuthAction::Unknown { .. } => true,
|
||||
AuthAction::Pragma { pragma_name, .. } => !IMPORT_ALLOWED_PRAGMAS
|
||||
.iter()
|
||||
.any(|allowed| pragma_name.eq_ignore_ascii_case(allowed)),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if escapes_temp_db {
|
||||
// SQLite 只会回一句 "not authorized",不记日志就无从知道是哪条语句被拦。
|
||||
log::warn!("SQL 导入拒绝了越界语句: {:?}", context.action);
|
||||
Authorization::Deny
|
||||
} else {
|
||||
Authorization::Allow
|
||||
}
|
||||
}
|
||||
|
||||
/// Tables whose data rows are skipped when exporting for WebDAV sync.
|
||||
const SYNC_SKIP_TABLES: &[&str] = &[
|
||||
"proxy_request_logs",
|
||||
@@ -117,9 +167,15 @@ impl Database {
|
||||
let temp_conn =
|
||||
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
temp_conn
|
||||
.execute_batch(sql_content)
|
||||
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
|
||||
// authorizer 只覆盖外部 SQL,执行完立刻摘掉:紧随其后的
|
||||
// `create_tables_on_conn` / `apply_schema_migrations_on_conn` 是本程序自己的
|
||||
// schema 维护语句,不属于需要设防的输入,没必要让它们也过一遍守卫。
|
||||
temp_conn.authorizer(Some(import_authorizer));
|
||||
let batch_result = temp_conn.execute_batch(sql_content);
|
||||
temp_conn.authorizer(
|
||||
None::<fn(rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization>,
|
||||
);
|
||||
batch_result.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
|
||||
|
||||
// 补齐缺失表/索引并进行基础校验
|
||||
Self::create_tables_on_conn(&temp_conn)?;
|
||||
@@ -694,6 +750,72 @@ mod tests {
|
||||
use crate::settings::{update_settings, AppSettings};
|
||||
use serial_test::serial;
|
||||
|
||||
#[test]
|
||||
fn import_rejects_cross_file_statements_and_leaves_no_file_behind() -> Result<(), AppError> {
|
||||
// `VACUUM INTO` 是关键字扫描方案最容易漏的一条:它不含 "ATTACH" 字样,
|
||||
// 却和 ATTACH 一样落到 `AuthAction::Attach`(实测),因此同一条规则挡住两者。
|
||||
let cases: [(&str, &str); 2] = [
|
||||
("attach", "ATTACH DATABASE '{path}' AS evil;"),
|
||||
("vacuum-into", "VACUUM INTO '{path}';"),
|
||||
];
|
||||
|
||||
for (label, template) in cases {
|
||||
let target = std::env::temp_dir().join(format!("cc-switch-authorizer-{label}.sqlite"));
|
||||
let _ = std::fs::remove_file(&target);
|
||||
|
||||
// 合法的导出头 + 越界语句。头部校验只比前缀,这份输入过得了它,
|
||||
// 真正拦下来的必须是 authorizer。
|
||||
let malicious = format!(
|
||||
"{}\n{}\n",
|
||||
super::CC_SWITCH_SQL_EXPORT_HEADER,
|
||||
template.replace("{path}", &target.display().to_string())
|
||||
);
|
||||
|
||||
let db = Database::memory()?;
|
||||
let result = db.import_sql_string(&malicious);
|
||||
|
||||
assert!(result.is_err(), "{label} 必须被拒绝");
|
||||
// 光报错不够:文件创建发生在 prepare 之后、`validate_basic_state` 之前,
|
||||
// 守卫若失效,即便导入整体失败,文件也已经躺在磁盘上了。
|
||||
assert!(
|
||||
!target.exists(),
|
||||
"被拒绝的 {label} 不得在磁盘上留下文件: {}",
|
||||
target.display()
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&target);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_still_accepts_a_genuine_export() -> Result<(), AppError> {
|
||||
// 白名单收得紧,必须有一条回归防线证明它没误伤自家导出格式——
|
||||
// 这条测试红了就说明 dump_sql 写出了白名单没覆盖的语句。
|
||||
let source = Database::memory()?;
|
||||
{
|
||||
let conn = crate::database::lock_conn!(source.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
||||
VALUES ('p1', 'claude', 'Provider One', '{}', '{}')",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
let exported = source.export_sql_string()?;
|
||||
|
||||
let target = Database::memory()?;
|
||||
target.import_sql_string(&exported)?;
|
||||
|
||||
let conn = crate::database::lock_conn!(target.conn);
|
||||
let name: String = conn.query_row(
|
||||
"SELECT name FROM providers WHERE id = 'p1' AND app_type = 'claude'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(name, "Provider One");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_import_preserves_local_only_tables() -> Result<(), AppError> {
|
||||
let remote_db = Database::memory()?;
|
||||
|
||||
@@ -144,6 +144,9 @@ impl Database {
|
||||
log::warn!("Failed to ensure incremental auto-vacuum: {e}");
|
||||
}
|
||||
db.ensure_model_pricing_seeded()?;
|
||||
if let Err(e) = crate::services::model_pricing::sync_local_model_pricing(&db) {
|
||||
log::warn!("Failed to sync local model pricing file: {e}");
|
||||
}
|
||||
|
||||
// Startup cleanup: prune old logs and reclaim space
|
||||
if let Err(e) = db.cleanup_old_stream_check_logs(7) {
|
||||
|
||||
@@ -114,7 +114,8 @@ pub struct DeepLinkImportRequest {
|
||||
pub config_url: Option<String>,
|
||||
|
||||
// ============ Usage script fields (v3.9+) ============
|
||||
/// Whether to enable usage query (default: true if usage_script is provided)
|
||||
/// Whether to enable usage query. Defaults to **disabled** — carrying a script
|
||||
/// is not itself a decision to run it; the link must say `usageEnabled=true`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_enabled: Option<bool>,
|
||||
/// Base64 encoded usage query script code
|
||||
|
||||
@@ -254,8 +254,18 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Determine enabled state: explicit param > has code > false
|
||||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
||||
// Determine enabled state: explicit param only, defaulting to disabled.
|
||||
//
|
||||
// 「携带了代码」不构成用户的启用决定。此处的输入来自 deeplink——即第三方
|
||||
// 构造、经浏览器抵达的不可信载荷——而 `code` 是一段会在查询用量时执行的
|
||||
// JavaScript。若以 `!code.is_empty()` 作默认,一条链接就能让脚本在用户
|
||||
// 从未勾选过的情况下进入启用态。
|
||||
//
|
||||
// 要启用,链接必须显式携带 `usageEnabled=true`。注意该参数是**链接作者**的
|
||||
// 请求,不构成用户的同意;用户的同意体现在确认框展示了完整脚本正文与启用
|
||||
// 徽章之后仍点了导入——所以那两处展示是本设计的承重部分,不可省略。
|
||||
// 用户在应用内手动配置的脚本不走这条路径。
|
||||
let enabled = request.usage_enabled.unwrap_or(false);
|
||||
|
||||
let usage_script = UsageScript {
|
||||
enabled,
|
||||
@@ -832,9 +842,34 @@ fn merge_grokbuild_config(
|
||||
.as_ref()
|
||||
.is_none_or(|value| value.is_empty())
|
||||
{
|
||||
request.api_key = model.api_key.or_else(|| {
|
||||
crate::grok_config::extract_credentials(&config_toml).map(|(_, api_key)| api_key)
|
||||
});
|
||||
// Only inline an explicitly-declared `api_key`. Do NOT resolve `env_key`
|
||||
// (or any process env var) into a plaintext value here: a deeplink is
|
||||
// untrusted input, and resolving+inlining would silently persist the
|
||||
// victim's environment secret into the imported provider's config.toml
|
||||
// and ship it to whatever `base_url` the link declares. `env_key` is an
|
||||
// indirection that must stay a name, not a resolved secret, on import.
|
||||
request.api_key = model.api_key;
|
||||
|
||||
// An `env_key`-only link is not importable at all, and saying so beats
|
||||
// falling through to the generic "API key is required" (which reads like
|
||||
// a malformed link and invites a "just carry the name over" fix).
|
||||
// Carrying the name over is exactly what must not happen: the forwarder
|
||||
// and the usage query both resolve `env_key` at request time, so the
|
||||
// victim's environment secret would still reach the link's `base_url`
|
||||
// — the same leak, merely deferred.
|
||||
if request
|
||||
.api_key
|
||||
.as_ref()
|
||||
.is_none_or(|value| value.is_empty())
|
||||
&& model.env_key.is_some()
|
||||
{
|
||||
return Err(AppError::InvalidInput(
|
||||
"This link supplies its API key indirectly through `env_key`, which cannot be \
|
||||
imported from an untrusted link. Add the provider manually and enter the key \
|
||||
yourself."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if request
|
||||
.endpoint
|
||||
@@ -929,6 +964,112 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// deeplink 同时声明 `api_key` 与 `env_key` 时,导入结果只保留用户可见的
|
||||
/// `api_key`:既不能带上解析后的明文环境变量,也不能把 `env_key` 这个间接
|
||||
/// 引用本身写进供应商配置。
|
||||
///
|
||||
/// 后者容易被误当成「应该补上的功能」,但恰恰不能补:deeplink 是不可信输入,
|
||||
/// 攻击者可以让 `env_key` 指向 `XAI_API_KEY`、`base_url` 指向自己的服务器。
|
||||
/// 密钥虽然导入时没落盘,却会在转发/用量查询调用 `extract_credentials` 时
|
||||
/// 被现场解析并发给攻击者——等于把已修掉的泄露换成延迟触发的版本。
|
||||
/// 手工建供应商的表单路径不受影响,那里的 env_key 是用户自己输入的。
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn grokbuild_deeplink_never_carries_env_key_or_resolved_secret() {
|
||||
let original = std::env::var_os("CC_SWITCH_DEEPLINK_ENV_PROBE");
|
||||
std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", "secret-must-not-leak");
|
||||
|
||||
let mut request = DeepLinkImportRequest {
|
||||
resource: "provider".to_string(),
|
||||
app: Some("grokbuild".to_string()),
|
||||
name: Some("Attacker".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let config = serde_json::json!({
|
||||
"config": concat!(
|
||||
"[models]\ndefault = \"grok-env\"\n\n",
|
||||
"[model.\"grok-env\"]\nmodel = \"grok-4.5\"\n",
|
||||
"base_url = \"https://attacker.example/v1\"\nname = \"Attacker\"\n",
|
||||
"api_key = \"sk-declared-by-link\"\n",
|
||||
"env_key = \"CC_SWITCH_DEEPLINK_ENV_PROBE\"\n",
|
||||
"api_backend = \"responses\"\ncontext_window = 500000\n",
|
||||
)
|
||||
});
|
||||
|
||||
merge_grokbuild_config(&mut request, &config).expect("merge should succeed");
|
||||
let settings = build_grokbuild_settings(&request);
|
||||
let rendered = settings["config"].as_str().expect("config string");
|
||||
|
||||
assert!(
|
||||
!rendered.contains("secret-must-not-leak"),
|
||||
"the environment secret must never be inlined: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("env_key"),
|
||||
"the env_key indirection must not be carried over from an untrusted link: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("api_key = \"sk-declared-by-link\""),
|
||||
"only the explicitly declared api_key should survive: {rendered}"
|
||||
);
|
||||
|
||||
match original {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", value),
|
||||
None => std::env::remove_var("CC_SWITCH_DEEPLINK_ENV_PROBE"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `env_key` 独苗的链接必须在**公开入口**上被明确拒绝。
|
||||
///
|
||||
/// 这条走 `parse_and_merge_config` 而不是内部 helper:真实失败路径在这里,
|
||||
/// 而且报错必须指名 `env_key`——否则用户只看到泛化的 "API key is required",
|
||||
/// 读起来像链接坏了,下一步就会有人「顺手把 env_key 透传过去」,把泄露改成
|
||||
/// 延迟触发的版本。
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn grokbuild_env_key_only_link_is_rejected_at_the_public_entry() {
|
||||
use base64::prelude::*;
|
||||
|
||||
// 探针变量必须**真的设上**。若它在环境里不存在,旧代码的
|
||||
// `extract_credentials` 回退也解析不出东西,api_key 同样留空、同样触发
|
||||
// 拒绝——测试就退化成只覆盖"没有 key 时报错",对"不得解析环境变量"这条
|
||||
// 真正的安全属性零覆盖。设上之后,一旦有人恢复回退解析,api_key 会变成
|
||||
// 非空、拒绝不再触发,这条断言立刻变红。
|
||||
let original = std::env::var_os("CC_SWITCH_DEEPLINK_ENV_PROBE");
|
||||
std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", "secret-must-not-leak");
|
||||
|
||||
let config_toml = concat!(
|
||||
"[models]\ndefault = \"grok-env\"\n\n",
|
||||
"[model.\"grok-env\"]\nmodel = \"grok-4.5\"\n",
|
||||
"base_url = \"https://attacker.example/v1\"\nname = \"Attacker\"\n",
|
||||
"env_key = \"CC_SWITCH_DEEPLINK_ENV_PROBE\"\n",
|
||||
"api_backend = \"responses\"\ncontext_window = 500000\n",
|
||||
);
|
||||
let request = DeepLinkImportRequest {
|
||||
resource: "provider".to_string(),
|
||||
app: Some("grokbuild".to_string()),
|
||||
name: Some("Attacker".to_string()),
|
||||
config: Some(BASE64_STANDARD.encode(config_toml)),
|
||||
config_format: Some("toml".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = parse_and_merge_config(&request);
|
||||
|
||||
match original {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", value),
|
||||
None => std::env::remove_var("CC_SWITCH_DEEPLINK_ENV_PROBE"),
|
||||
}
|
||||
|
||||
let err = result.expect_err(
|
||||
"an env_key-only link must not be importable, and its env var must never be resolved",
|
||||
);
|
||||
assert!(
|
||||
err.to_string().contains("env_key"),
|
||||
"the rejection must name env_key so it is not mistaken for a malformed link: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hermes_settings_emits_snake_case() {
|
||||
let settings = build_hermes_settings(&hermes_request());
|
||||
|
||||
@@ -33,12 +33,25 @@ pub fn import_skill_from_deeplink(
|
||||
}
|
||||
let owner = parts[0].to_string();
|
||||
let name = parts[1].to_string();
|
||||
let branch = request.branch.unwrap_or_else(|| "main".to_string());
|
||||
|
||||
// deeplink 是不可信输入,且 branch 会被拼进归档下载 URL:URL 解析会消解点段,
|
||||
// `../../../releases/download/v1/evil` 之类会把落点改写成攻击者可上传的
|
||||
// release asset。主防线在 SkillService::download_repo,这里是入库前的纵深拦截,
|
||||
// 让用户当场看到错误而不是让脏数据沉淀进 skill_repos 表。
|
||||
crate::services::skill::SkillService::validate_repo_ref(&owner, &name, &branch).map_err(
|
||||
|_| {
|
||||
AppError::InvalidInput(format!(
|
||||
"Invalid skill repository reference: '{owner}/{name}' branch '{branch}'"
|
||||
))
|
||||
},
|
||||
)?;
|
||||
|
||||
// Create SkillRepo
|
||||
let repo = SkillRepo {
|
||||
owner: owner.clone(),
|
||||
name: name.clone(),
|
||||
branch: request.branch.unwrap_or_else(|| "main".to_string()),
|
||||
branch,
|
||||
enabled: request.enabled.unwrap_or(true),
|
||||
};
|
||||
|
||||
|
||||
@@ -342,6 +342,87 @@ fn test_deeplink_usage_script_does_not_copy_provider_credentials() {
|
||||
assert_eq!(script.base_url, None);
|
||||
}
|
||||
|
||||
/// 构造一个只带用量脚本字段的 provider 请求,其余保持最小。
|
||||
fn usage_script_request(code: &str, usage_enabled: Option<bool>) -> DeepLinkImportRequest {
|
||||
DeepLinkImportRequest {
|
||||
version: "v1".to_string(),
|
||||
resource: "provider".to_string(),
|
||||
app: Some("claude".to_string()),
|
||||
name: Some("Test Claude".to_string()),
|
||||
homepage: Some("https://example.com".to_string()),
|
||||
endpoint: Some("https://api.example.com/v1/".to_string()),
|
||||
api_key: Some("sk-main".to_string()),
|
||||
icon: None,
|
||||
model: None,
|
||||
notes: None,
|
||||
haiku_model: None,
|
||||
sonnet_model: None,
|
||||
opus_model: None,
|
||||
config: None,
|
||||
config_format: None,
|
||||
config_url: None,
|
||||
apps: None,
|
||||
repo: None,
|
||||
directory: None,
|
||||
branch: None,
|
||||
content: None,
|
||||
description: None,
|
||||
enabled: None,
|
||||
usage_enabled,
|
||||
usage_script: Some(BASE64_STANDARD.encode(code)),
|
||||
usage_api_key: None,
|
||||
usage_base_url: None,
|
||||
usage_access_token: None,
|
||||
usage_user_id: None,
|
||||
usage_auto_interval: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deeplink_usage_script_is_not_enabled_merely_by_carrying_code() {
|
||||
use super::provider::build_provider_from_request;
|
||||
|
||||
// deeplink 是第三方构造、经浏览器抵达的不可信载荷。「带了代码」不是用户的
|
||||
// 启用决定——否则一条链接就能让这段 JS 在用户从未勾选的情况下进入启用态。
|
||||
let code = "export async function query() { return { cost: 0 }; }";
|
||||
let request = usage_script_request(code, None);
|
||||
|
||||
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||||
let script = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.usage_script.as_ref())
|
||||
.expect("usage script should still be created");
|
||||
|
||||
assert!(
|
||||
!script.enabled,
|
||||
"缺省必须是未启用;`带了代码`不构成用户的启用决定"
|
||||
);
|
||||
// 代码本身仍要保留:确认框要展示它,用户之后也可在应用内手动开启。
|
||||
assert_eq!(script.code, code);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deeplink_usage_script_honors_an_explicit_enable_request_from_the_link() {
|
||||
use super::provider::build_provider_from_request;
|
||||
|
||||
// `usageEnabled=true` 是**链接作者**的请求,不是用户的选择——用户的同意体现在
|
||||
// 看过确认框里完整的脚本正文与启用状态之后点了导入。收紧默认值不能顺手把这条
|
||||
// 正常通路改坏:合作伙伴的预设链接靠它一次性配好用量查询。
|
||||
let code = "export async function query() { return { cost: 0 }; }";
|
||||
let request = usage_script_request(code, Some(true));
|
||||
|
||||
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||||
let script = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.usage_script.as_ref())
|
||||
.expect("usage script should be created");
|
||||
|
||||
assert!(script.enabled);
|
||||
assert_eq!(script.code, code);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deeplink_usage_script_omits_explicit_credentials_that_match_provider() {
|
||||
use super::provider::build_provider_from_request;
|
||||
|
||||
@@ -152,8 +152,71 @@ pub fn read_gemini_env() -> Result<HashMap<String, String>, AppError> {
|
||||
Ok(parse_env_file(&content))
|
||||
}
|
||||
|
||||
/// 从 .env 原文中按「键名 + 值」双匹配删除若干行,其余内容逐字保留
|
||||
///
|
||||
/// 不走 `parse_env_file` → `serialize_env_file` 的往返:那对函数会丢掉注释、空行、
|
||||
/// 无法识别的行和重复定义,并按键名重排整个文件。全量投影时这无所谓(本来就要重写
|
||||
/// 整份),但用来做**定向**清理就等于顺手把用户手写的东西一起删了。
|
||||
///
|
||||
/// 按值匹配而非按键名:只清掉扩散出去的那一份,用户自己写的同名不同值的行保留。
|
||||
/// 同一个键有重复定义时也只删命中的那条,被它遮住的上一条会重新生效——这正是想要的
|
||||
/// 结果,因为遮住它的恰恰是泄漏值。
|
||||
///
|
||||
/// 返回 `None` 表示没有任何一行命中,调用方据此跳过写盘。
|
||||
pub fn remove_env_entries_preserving_layout(
|
||||
content: &str,
|
||||
doomed: &HashMap<String, String>,
|
||||
) -> Option<String> {
|
||||
let mut removed = false;
|
||||
let mut kept: Vec<&str> = Vec::new();
|
||||
|
||||
for line in content.split('\n') {
|
||||
let trimmed = line.trim();
|
||||
let hit = !trimmed.is_empty()
|
||||
&& !trimmed.starts_with('#')
|
||||
&& trimmed.split_once('=').is_some_and(|(key, value)| {
|
||||
doomed
|
||||
.get(key.trim())
|
||||
.is_some_and(|doomed_value| doomed_value == value.trim())
|
||||
});
|
||||
|
||||
if hit {
|
||||
removed = true;
|
||||
} else {
|
||||
kept.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
removed.then(|| kept.join("\n"))
|
||||
}
|
||||
|
||||
/// 从 `~/.gemini/.env` 中定向删除「键=值」完全匹配的行,返回是否真的改了文件
|
||||
pub fn remove_gemini_env_entries(doomed: &HashMap<String, String>) -> Result<bool, AppError> {
|
||||
let path = get_gemini_env_path();
|
||||
if !path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
match remove_env_entries_preserving_layout(&content, doomed) {
|
||||
Some(cleaned) => {
|
||||
write_gemini_env_text_atomic(&cleaned)?;
|
||||
Ok(true)
|
||||
}
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// 写入 Gemini .env 文件(原子操作)
|
||||
pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppError> {
|
||||
write_gemini_env_text_atomic(&serialize_env_file(map))
|
||||
}
|
||||
|
||||
/// 写入 Gemini .env 文件(原子操作,内容逐字落盘)
|
||||
///
|
||||
/// 与 `write_gemini_env_atomic` 共用目录/文件权限处理,区别只在于内容不经
|
||||
/// `serialize_env_file` 归一化——供保序的定向删除使用。
|
||||
pub fn write_gemini_env_text_atomic(content: &str) -> Result<(), AppError> {
|
||||
let path = get_gemini_env_path();
|
||||
|
||||
// 确保目录存在
|
||||
@@ -172,8 +235,7 @@ pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppE
|
||||
}
|
||||
}
|
||||
|
||||
let content = serialize_env_file(map);
|
||||
write_text_file(&path, &content)?;
|
||||
write_text_file(&path, content)?;
|
||||
|
||||
// 设置文件权限为 600(仅所有者可读写)
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -209,22 +209,23 @@ pub fn extract_model_config(config_toml: &str) -> Option<GrokModelConfig> {
|
||||
|
||||
pub fn extract_credentials(config_toml: &str) -> Option<(String, String)> {
|
||||
let config = extract_model_config(config_toml)?;
|
||||
let api_key = config
|
||||
.api_key
|
||||
.or_else(|| {
|
||||
config
|
||||
.env_key
|
||||
.as_deref()
|
||||
.and_then(|key| std::env::var(key).ok())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.or_else(|| {
|
||||
std::env::var("XAI_API_KEY")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
})?;
|
||||
// Credentials only come from two explicit, config-declared sources:
|
||||
// 1. an inline `api_key`, or
|
||||
// 2. the process env var named by `env_key`.
|
||||
//
|
||||
// Deliberately NO unconditional fallback to `XAI_API_KEY`: silently
|
||||
// substituting a different account's key (when the declared `env_key` var is
|
||||
// unset) would leak that key to whatever `base_url` this config points at.
|
||||
// An unset/missing declared credential must surface as "no credential"
|
||||
// (None) so callers can fail loudly rather than transmit the wrong secret.
|
||||
let api_key = config.api_key.or_else(|| {
|
||||
config
|
||||
.env_key
|
||||
.as_deref()
|
||||
.and_then(|key| std::env::var(key).ok())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
})?;
|
||||
Some((config.base_url, api_key))
|
||||
}
|
||||
|
||||
@@ -540,6 +541,48 @@ context_window = 500000
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造一个 `env_key` 指向未设置环境变量的 config——这是"声明了间接引用但
|
||||
/// 该变量不存在"的场景,修复前会静默兜底到 `XAI_API_KEY`。
|
||||
fn env_key_unset_config() -> &'static str {
|
||||
r#"[models]
|
||||
default = "grok-env"
|
||||
|
||||
[model."grok-env"]
|
||||
model = "grok-4.5"
|
||||
base_url = "https://attacker.example/v1"
|
||||
name = "Attacker Env"
|
||||
env_key = "GROK_TEST_DEFINITELY_UNSET_VAR"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
"#
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn does_not_fall_back_to_xai_api_key_when_declared_env_key_is_unset() {
|
||||
// 即使进程里恰好设了 XAI_API_KEY,也不能被静默借用到别的 base_url 上。
|
||||
let original_xai = std::env::var_os("XAI_API_KEY");
|
||||
let original_unset = std::env::var_os("GROK_TEST_DEFINITELY_UNSET_VAR");
|
||||
std::env::set_var("XAI_API_KEY", "xai-secret-should-not-leak");
|
||||
std::env::remove_var("GROK_TEST_DEFINITELY_UNSET_VAR");
|
||||
|
||||
let credentials = extract_credentials(env_key_unset_config());
|
||||
|
||||
assert!(
|
||||
credentials.is_none(),
|
||||
"declared env_key unset must yield None, never a borrowed XAI_API_KEY; got {credentials:?}"
|
||||
);
|
||||
|
||||
match original_xai {
|
||||
Some(value) => std::env::set_var("XAI_API_KEY", value),
|
||||
None => std::env::remove_var("XAI_API_KEY"),
|
||||
}
|
||||
match original_unset {
|
||||
Some(value) => std::env::set_var("GROK_TEST_DEFINITELY_UNSET_VAR", value),
|
||||
None => std::env::remove_var("GROK_TEST_DEFINITELY_UNSET_VAR"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_projected_mcp_servers_without_touching_model_config() {
|
||||
let mut settings = json!({
|
||||
|
||||
+15
-1
@@ -28,7 +28,6 @@ mod panic_hook;
|
||||
mod prompt;
|
||||
mod prompt_files;
|
||||
mod provider;
|
||||
mod provider_defaults;
|
||||
mod proxy;
|
||||
mod services;
|
||||
mod session_manager;
|
||||
@@ -1184,6 +1183,17 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 必须排在 auto-extract 之前:先把历史泄漏进 Gemini 共享片段的凭据
|
||||
// 清干净,否则紧接着的提取会基于被污染的 live 再写一遍。
|
||||
if let Err(e) =
|
||||
crate::services::provider::ProviderService::scrub_leaked_gemini_common_config(
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("清理 Gemini 通用配置泄漏凭据失败: {e}");
|
||||
}
|
||||
|
||||
initialize_common_config_snippets(&state);
|
||||
|
||||
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||||
@@ -1521,7 +1531,11 @@ pub fn run() {
|
||||
commands::get_request_detail,
|
||||
commands::get_model_pricing,
|
||||
commands::update_model_pricing,
|
||||
commands::update_model_pricing_batch,
|
||||
commands::delete_model_pricing,
|
||||
commands::get_models_dev_sync_config,
|
||||
commands::save_models_dev_sync_config,
|
||||
commands::record_models_dev_sync_result,
|
||||
commands::check_provider_limits,
|
||||
// Session usage sync
|
||||
commands::sync_session_usage,
|
||||
|
||||
+153
-22
@@ -348,6 +348,74 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||
|
||||
/// 将单个 MCP 服务器同步到 Codex live 配置
|
||||
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
|
||||
/// 把单个 MCP server 表写入 `[mcp_servers]`,并保证该键是「表」。
|
||||
///
|
||||
/// `~/.codex/config.toml` 是用户可手改的:若 `mcp_servers` 存在但不是表
|
||||
/// (如 `mcp_servers = "x"` / `[]`),仅判 `contains_key` 会跳过重建,随后的
|
||||
/// `doc["mcp_servers"][id] = …` 会触发 toml_edit 的 `IndexMut` panic
|
||||
/// (panic 发生在 Tauri command 内、跨 FFI 展开)。这里统一归一化后再插入。
|
||||
fn upsert_mcp_server_table(
|
||||
doc: &mut toml_edit::DocumentMut,
|
||||
id: &str,
|
||||
table: toml_edit::Table,
|
||||
) -> Result<(), AppError> {
|
||||
if doc
|
||||
.get_mut("mcp_servers")
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
.is_none()
|
||||
{
|
||||
// 键存在但不是表时,归一化会丢掉用户手写的那个值——必须留痕,
|
||||
// 否则用户只会看到自己的改动凭空消失。
|
||||
if doc.get("mcp_servers").is_some_and(|item| !item.is_none()) {
|
||||
log::warn!("config.toml 的 mcp_servers 不是表,已重置为空表");
|
||||
}
|
||||
doc["mcp_servers"] = toml_edit::table();
|
||||
}
|
||||
let servers = doc
|
||||
.get_mut("mcp_servers")
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
.ok_or_else(|| AppError::McpValidation("config.toml 的 mcp_servers 不是表".to_string()))?;
|
||||
servers.insert(id, toml_edit::Item::Table(table));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 `[mcp_servers]`(以及历史错误格式 `[mcp.servers]`)中删除单个 MCP server。
|
||||
///
|
||||
/// 与 `upsert_mcp_server_table` 对称地使用 `as_table_like_mut`:用户若把配置写成
|
||||
/// inline table(`mcp_servers = { foo = {...} }`,TOML 合法),`as_table_mut` 会返回
|
||||
/// None 导致删除**静默失效**——界面提示已移除,条目却还在文件里,Codex 下次启动照样
|
||||
/// 加载。这比 panic 更隐蔽,因为用户往往正是发现某个 MCP 有问题才来关它的。
|
||||
///
|
||||
/// 与写入分离成纯 doc 级函数,使守卫可脱离真实 `~/.codex/config.toml` 单测。
|
||||
fn remove_mcp_server_from_doc(doc: &mut toml_edit::DocumentMut, id: &str) {
|
||||
if let Some(item) = doc.get_mut("mcp_servers") {
|
||||
// `Item::None` 是 toml_edit 的占位形态,不是用户写下的值——对它告警是噪音。
|
||||
// 必须在取可变借用之前算出来。
|
||||
let user_authored = !item.is_none();
|
||||
match item.as_table_like_mut() {
|
||||
Some(mcp_servers) => {
|
||||
mcp_servers.remove(id);
|
||||
}
|
||||
None if user_authored => {
|
||||
log::warn!("config.toml 的 mcp_servers 不是表,无法删除服务器 '{id}'");
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
|
||||
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_like_mut()) {
|
||||
if let Some(servers) = mcp_table
|
||||
.get_mut("servers")
|
||||
.and_then(|s| s.as_table_like_mut())
|
||||
{
|
||||
if servers.remove(id).is_some() {
|
||||
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync_single_server_to_codex(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
@@ -356,7 +424,6 @@ pub fn sync_single_server_to_codex(
|
||||
if !should_sync_codex_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
use toml_edit::Item;
|
||||
|
||||
// 读取现有的 config.toml
|
||||
let config_path = crate::codex_config::get_codex_config_path();
|
||||
@@ -383,16 +450,9 @@ pub fn sync_single_server_to_codex(
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 [mcp_servers] 表存在
|
||||
if !doc.contains_key("mcp_servers") {
|
||||
doc["mcp_servers"] = toml_edit::table();
|
||||
}
|
||||
|
||||
// 将 JSON 服务器规范转换为 TOML 表
|
||||
let toml_table = json_server_to_toml_table(server_spec)?;
|
||||
|
||||
// 使用唯一正确的格式:[mcp_servers]
|
||||
doc["mcp_servers"][id] = Item::Table(toml_table);
|
||||
upsert_mcp_server_table(&mut doc, id, toml_table)?;
|
||||
|
||||
// 写回文件
|
||||
let new_text = doc.to_string();
|
||||
@@ -425,19 +485,7 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
|
||||
}
|
||||
};
|
||||
|
||||
// 从正确的位置删除:[mcp_servers]
|
||||
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
|
||||
mcp_servers.remove(id);
|
||||
}
|
||||
|
||||
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
|
||||
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
|
||||
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
|
||||
if servers.remove(id).is_some() {
|
||||
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
remove_mcp_server_from_doc(&mut doc, id);
|
||||
|
||||
// 写回文件
|
||||
let new_text = doc.to_string();
|
||||
@@ -683,6 +731,89 @@ pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn upsert_normalizes_non_table_mcp_servers_without_panicking() {
|
||||
// 用户手改过的 config.toml:mcp_servers 是字符串而不是表。
|
||||
// 修复前 `doc["mcp_servers"][id] = …` 会 panic。
|
||||
for malformed in [
|
||||
"mcp_servers = \"x\"\n",
|
||||
"mcp_servers = []\n",
|
||||
"mcp_servers = 42\n",
|
||||
] {
|
||||
let mut doc = malformed
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.expect("fixture parses");
|
||||
let table = json_server_to_toml_table(&json!({
|
||||
"type": "stdio",
|
||||
"command": "npx"
|
||||
}))
|
||||
.expect("server table");
|
||||
|
||||
upsert_mcp_server_table(&mut doc, "echo", table)
|
||||
.unwrap_or_else(|e| panic!("upsert must not fail for {malformed:?}: {e}"));
|
||||
|
||||
let servers = doc
|
||||
.get("mcp_servers")
|
||||
.and_then(|item| item.as_table_like())
|
||||
.unwrap_or_else(|| panic!("mcp_servers must be normalized to a table"));
|
||||
assert!(servers.contains_key("echo"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_existing_servers_in_a_valid_table() {
|
||||
let mut doc = "[mcp_servers.keep]\ncommand = \"keep\"\n"
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.expect("fixture parses");
|
||||
let table = json_server_to_toml_table(&json!({
|
||||
"type": "stdio",
|
||||
"command": "npx"
|
||||
}))
|
||||
.expect("server table");
|
||||
|
||||
upsert_mcp_server_table(&mut doc, "added", table).expect("upsert");
|
||||
|
||||
let servers = doc
|
||||
.get("mcp_servers")
|
||||
.and_then(|item| item.as_table_like())
|
||||
.expect("table");
|
||||
assert!(servers.contains_key("keep"), "existing server must survive");
|
||||
assert!(servers.contains_key("added"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_deletes_from_inline_table_form_too() {
|
||||
// inline table 是合法 TOML,但 as_table_mut() 对它返回 None——用它做守卫
|
||||
// 会让删除静默失效:界面说移除成功,条目却还在,Codex 下次启动照样加载。
|
||||
let mut doc = "mcp_servers = { drop = { command = \"x\" }, keep = { command = \"y\" } }\n"
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.expect("fixture parses");
|
||||
|
||||
remove_mcp_server_from_doc(&mut doc, "drop");
|
||||
|
||||
let servers = doc
|
||||
.get("mcp_servers")
|
||||
.and_then(|item| item.as_table_like())
|
||||
.expect("mcp_servers must still be table-like");
|
||||
assert!(
|
||||
!servers.contains_key("drop"),
|
||||
"removal must work on the inline-table form"
|
||||
);
|
||||
assert!(servers.contains_key("keep"), "siblings must survive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_is_a_noop_on_non_table_mcp_servers() {
|
||||
// 既不能 panic,也不能把用户手写的值悄悄抹掉
|
||||
let mut doc = "mcp_servers = 42\n"
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.expect("fixture parses");
|
||||
|
||||
remove_mcp_server_from_doc(&mut doc, "whatever");
|
||||
|
||||
assert_eq!(doc.to_string(), "mcp_servers = 42\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_headers_are_only_written_to_codex_http_headers() {
|
||||
let table = json_server_to_toml_table(&json!({
|
||||
|
||||
@@ -148,10 +148,30 @@ pub fn sync_single_server_to_grokbuild(
|
||||
AppError::McpValidation(format!("解析 Grok Build config.toml 失败: {e}"))
|
||||
})?
|
||||
};
|
||||
if !doc.contains_key("mcp_servers") {
|
||||
// 若 mcp_servers 缺失或存在但不是 table(如 `mcp_servers = "x"` / `[]`),
|
||||
// 用空 table 归一化,避免后续 `doc["mcp_servers"][id] = …` 对非 table 索引
|
||||
// 触发 toml_edit 的 IndexMut panic。用户手写的 config.toml 不可信。
|
||||
// 判定走不可变的 `as_table_like`:借可变引用只为判空,会逼着下面再 get_mut 一次。
|
||||
if doc
|
||||
.get("mcp_servers")
|
||||
.is_none_or(|item| item.as_table_like().is_none())
|
||||
{
|
||||
// 归一化会丢掉用户手写的那个非表值,必须留痕。
|
||||
if doc.get("mcp_servers").is_some_and(|item| !item.is_none()) {
|
||||
log::warn!("Grok Build config.toml 的 mcp_servers 不是表,已重置为空表");
|
||||
}
|
||||
doc["mcp_servers"] = toml_edit::table();
|
||||
}
|
||||
doc["mcp_servers"][id] = Item::Table(json_server_to_grokbuild_toml_table(server_spec)?);
|
||||
let servers = doc
|
||||
.get_mut("mcp_servers")
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
.ok_or_else(|| {
|
||||
AppError::McpValidation("Grok Build config.toml 的 mcp_servers 不是表".to_string())
|
||||
})?;
|
||||
servers.insert(
|
||||
id,
|
||||
Item::Table(json_server_to_grokbuild_toml_table(server_spec)?),
|
||||
);
|
||||
crate::config::write_text_file(&path, &doc.to_string())
|
||||
}
|
||||
|
||||
@@ -171,11 +191,21 @@ pub fn remove_server_from_grokbuild(id: &str) -> Result<(), AppError> {
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if let Some(servers) = doc
|
||||
.get_mut("mcp_servers")
|
||||
.and_then(toml_edit::Item::as_table_mut)
|
||||
{
|
||||
servers.remove(id);
|
||||
// 与写入侧对称使用 as_table_like_mut:inline table 形态下 as_table_mut 返回
|
||||
// None,删除会静默失效——界面显示已移除,Grok Build 下次启动仍会加载它。
|
||||
if let Some(item) = doc.get_mut("mcp_servers") {
|
||||
// `Item::None` 是 toml_edit 的占位形态,不是用户写下的值——对它告警是噪音。
|
||||
// 必须在取可变借用之前算出来。
|
||||
let user_authored = !item.is_none();
|
||||
match item.as_table_like_mut() {
|
||||
Some(servers) => {
|
||||
servers.remove(id);
|
||||
}
|
||||
None if user_authored => {
|
||||
log::warn!("Grok Build config.toml 的 mcp_servers 不是表,无法删除服务器 '{id}'");
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
crate::config::write_text_file(&path, &doc.to_string())
|
||||
}
|
||||
|
||||
@@ -95,12 +95,27 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
json5::from_str(&content).map_err(|e| {
|
||||
let value: Value = json5::from_str(&content).map_err(|e| {
|
||||
AppError::Config(format!(
|
||||
"Failed to parse OpenCode config: {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
})?;
|
||||
|
||||
// 根节点必须是对象:下游 set_provider / set_mcp_server / add_plugin 都对它做
|
||||
// `config["key"] = …` 索引赋值,而 serde_json 只把 Null 自动升级成对象,
|
||||
// 数组或标量会直接 panic(panic 发生在 Tauri command 内、跨 FFI 展开)。
|
||||
//
|
||||
// 这里选择报错而不是重建根节点:opencode.json 里还有 model / theme 等用户自有
|
||||
// 配置,静默重建等于删掉它们。让用户自己修文件,与 read_claude_live 的做法一致。
|
||||
if !value.is_object() {
|
||||
return Err(AppError::Config(format!(
|
||||
"OpenCode 配置文件根节点必须是 JSON 对象: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
||||
@@ -123,7 +138,13 @@ pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
||||
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
||||
let mut full_config = read_opencode_config()?;
|
||||
|
||||
if full_config.get("provider").is_none() {
|
||||
// 判空要连「存在但不是对象」一起算:否则下面 as_object_mut 拿不到,
|
||||
// 写入会静默失效——界面显示添加成功而文件里没有。provider 段是 cc-switch
|
||||
// 的投影区,归一化不会碰用户自有的 model / theme 等顶层配置。
|
||||
if !full_config.get("provider").is_some_and(Value::is_object) {
|
||||
if full_config.get("provider").is_some() {
|
||||
log::warn!("opencode.json 的 provider 不是对象,已重置为空对象");
|
||||
}
|
||||
full_config["provider"] = json!({});
|
||||
}
|
||||
|
||||
@@ -142,6 +163,8 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
||||
|
||||
if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
|
||||
providers.remove(id);
|
||||
} else if config.get("provider").is_some() {
|
||||
log::warn!("opencode.json 的 provider 不是对象,无法删除供应商 '{id}'");
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
@@ -182,7 +205,10 @@ pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
|
||||
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
||||
let mut full_config = read_opencode_config()?;
|
||||
|
||||
if full_config.get("mcp").is_none() {
|
||||
if !full_config.get("mcp").is_some_and(Value::is_object) {
|
||||
if full_config.get("mcp").is_some() {
|
||||
log::warn!("opencode.json 的 mcp 不是对象,已重置为空对象");
|
||||
}
|
||||
full_config["mcp"] = json!({});
|
||||
}
|
||||
|
||||
@@ -198,6 +224,8 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
||||
|
||||
if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
||||
mcp.remove(id);
|
||||
} else if config.get("mcp").is_some() {
|
||||
log::warn!("opencode.json 的 mcp 不是对象,无法删除服务器 '{id}'");
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
@@ -265,3 +293,77 @@ pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
|
||||
|
||||
write_opencode_config(&config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct TestHomeGuard(Option<std::ffi::OsString>);
|
||||
impl TestHomeGuard {
|
||||
fn set(home: &std::path::Path) -> Self {
|
||||
let guard = Self(std::env::var_os("CC_SWITCH_TEST_HOME"));
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", home);
|
||||
guard
|
||||
}
|
||||
}
|
||||
impl Drop for TestHomeGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_config(home: &std::path::Path, content: &str) {
|
||||
let dir = home.join(".config").join("opencode");
|
||||
std::fs::create_dir_all(&dir).expect("create config dir");
|
||||
std::fs::write(dir.join("opencode.json"), content).expect("write config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn read_rejects_non_object_root_instead_of_panicking_downstream() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let _guard = TestHomeGuard::set(temp.path());
|
||||
|
||||
// 顶层数组/标量会让下游 `config["provider"] = …` 触发 serde_json panic。
|
||||
// 顶层 null 例外——serde_json 会把它自动升级成对象,本来就不炸。
|
||||
for malformed in ["[]", "[{\"a\":1}]", "42", "\"oops\""] {
|
||||
write_config(temp.path(), malformed);
|
||||
let result = read_opencode_config();
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"non-object root must be rejected: {malformed}"
|
||||
);
|
||||
}
|
||||
|
||||
write_config(temp.path(), "{\"model\": \"x\"}");
|
||||
assert!(
|
||||
read_opencode_config().is_ok(),
|
||||
"a normal object config must still load"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn set_mcp_server_normalizes_non_object_section() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let _guard = TestHomeGuard::set(temp.path());
|
||||
|
||||
// `"mcp": []` 时旧代码的 as_object_mut 返回 None → 写入静默失效
|
||||
write_config(temp.path(), "{\"model\": \"keep-me\", \"mcp\": []}");
|
||||
|
||||
set_mcp_server("echo", json!({"command": "npx"})).expect("set must succeed");
|
||||
|
||||
let config = read_opencode_config().expect("reload");
|
||||
assert_eq!(
|
||||
config["mcp"]["echo"]["command"], "npx",
|
||||
"server must actually be written"
|
||||
);
|
||||
assert_eq!(
|
||||
config["model"], "keep-me",
|
||||
"unrelated user config must be preserved"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,11 +169,23 @@ impl Provider {
|
||||
let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
|
||||
(base_url, api_key)
|
||||
}
|
||||
AppType::GrokBuild => settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(crate::grok_config::extract_credentials)
|
||||
.unwrap_or_default(),
|
||||
// GrokBuild 的 base_url 与 api_key 必须各自解析:extract_credentials 在
|
||||
// 凭据缺失时整个 Option 变 None,一并 unwrap_or_default 会把明明写在
|
||||
// 配置里的 base_url 也清成空串。凭据缺失是常态(env_key 指向的变量在
|
||||
// GUI 进程里读不到),端点不该被连坐——否则用量脚本的 {{baseUrl}} 变成
|
||||
// 相对路径、余额查询只报「API key is empty」掩盖真因。
|
||||
// 与上面 Codex 分支的写法保持一致。
|
||||
AppType::GrokBuild => {
|
||||
let config_text = settings.get("config").and_then(Value::as_str);
|
||||
let base_url = config_text
|
||||
.and_then(crate::grok_config::extract_base_url)
|
||||
.unwrap_or_default();
|
||||
let api_key = config_text
|
||||
.and_then(crate::grok_config::extract_credentials)
|
||||
.map(|(_, api_key)| api_key)
|
||||
.unwrap_or_default();
|
||||
(base_url, api_key)
|
||||
}
|
||||
// Hermes (config.yaml) flattens credentials at the top level, snake_case.
|
||||
AppType::Hermes => (
|
||||
str_at(settings.get("base_url")),
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 供应商图标信息
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ProviderIcon {
|
||||
pub name: &'static str,
|
||||
pub color: &'static str,
|
||||
}
|
||||
|
||||
/// 供应商名称到图标的默认映射
|
||||
#[allow(dead_code)]
|
||||
pub static DEFAULT_PROVIDER_ICONS: Lazy<HashMap<&'static str, ProviderIcon>> = Lazy::new(|| {
|
||||
let mut m = HashMap::new();
|
||||
|
||||
// AI 服务商
|
||||
m.insert(
|
||||
"openai",
|
||||
ProviderIcon {
|
||||
name: "openai",
|
||||
color: "#00A67E",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"anthropic",
|
||||
ProviderIcon {
|
||||
name: "anthropic",
|
||||
color: "#D4915D",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"claude",
|
||||
ProviderIcon {
|
||||
name: "claude",
|
||||
color: "#D4915D",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"google",
|
||||
ProviderIcon {
|
||||
name: "google",
|
||||
color: "#4285F4",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"gemini",
|
||||
ProviderIcon {
|
||||
name: "gemini",
|
||||
color: "#4285F4",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"deepseek",
|
||||
ProviderIcon {
|
||||
name: "deepseek",
|
||||
color: "#1E88E5",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"kimi",
|
||||
ProviderIcon {
|
||||
name: "kimi",
|
||||
color: "#6366F1",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"moonshot",
|
||||
ProviderIcon {
|
||||
name: "moonshot",
|
||||
color: "#6366F1",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"zhipu",
|
||||
ProviderIcon {
|
||||
name: "zhipu",
|
||||
color: "#0F62FE",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"minimax",
|
||||
ProviderIcon {
|
||||
name: "minimax",
|
||||
color: "#FF6B6B",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"baidu",
|
||||
ProviderIcon {
|
||||
name: "baidu",
|
||||
color: "#2932E1",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"alibaba",
|
||||
ProviderIcon {
|
||||
name: "alibaba",
|
||||
color: "#FF6A00",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"tencent",
|
||||
ProviderIcon {
|
||||
name: "tencent",
|
||||
color: "#00A4FF",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"meta",
|
||||
ProviderIcon {
|
||||
name: "meta",
|
||||
color: "#0081FB",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"microsoft",
|
||||
ProviderIcon {
|
||||
name: "microsoft",
|
||||
color: "#00A4EF",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"cohere",
|
||||
ProviderIcon {
|
||||
name: "cohere",
|
||||
color: "#39594D",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"perplexity",
|
||||
ProviderIcon {
|
||||
name: "perplexity",
|
||||
color: "#20808D",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"mistral",
|
||||
ProviderIcon {
|
||||
name: "mistral",
|
||||
color: "#FF7000",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"huggingface",
|
||||
ProviderIcon {
|
||||
name: "huggingface",
|
||||
color: "#FFD21E",
|
||||
},
|
||||
);
|
||||
|
||||
// 云平台
|
||||
m.insert(
|
||||
"aws",
|
||||
ProviderIcon {
|
||||
name: "aws",
|
||||
color: "#FF9900",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"azure",
|
||||
ProviderIcon {
|
||||
name: "azure",
|
||||
color: "#0078D4",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"huawei",
|
||||
ProviderIcon {
|
||||
name: "huawei",
|
||||
color: "#FF0000",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"cloudflare",
|
||||
ProviderIcon {
|
||||
name: "cloudflare",
|
||||
color: "#F38020",
|
||||
},
|
||||
);
|
||||
|
||||
m
|
||||
});
|
||||
|
||||
/// 根据供应商名称智能推断图标
|
||||
#[allow(dead_code)]
|
||||
pub fn infer_provider_icon(provider_name: &str) -> Option<ProviderIcon> {
|
||||
let name_lower = provider_name.to_lowercase();
|
||||
|
||||
// 精确匹配
|
||||
if let Some(icon) = DEFAULT_PROVIDER_ICONS.get(name_lower.as_str()) {
|
||||
return Some(icon.clone());
|
||||
}
|
||||
|
||||
// 模糊匹配(包含关键词)
|
||||
for (key, icon) in DEFAULT_PROVIDER_ICONS.iter() {
|
||||
if name_lower.contains(key) {
|
||||
return Some(icon.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_exact_match() {
|
||||
let icon = infer_provider_icon("openai");
|
||||
assert!(icon.is_some());
|
||||
let icon = icon.unwrap();
|
||||
assert_eq!(icon.name, "openai");
|
||||
assert_eq!(icon.color, "#00A67E");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match() {
|
||||
let icon = infer_provider_icon("OpenAI Official");
|
||||
assert!(icon.is_some());
|
||||
let icon = icon.unwrap();
|
||||
assert_eq!(icon.name, "openai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive() {
|
||||
let icon = infer_provider_icon("ANTHROPIC");
|
||||
assert!(icon.is_some());
|
||||
assert_eq!(icon.unwrap().name, "anthropic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_match() {
|
||||
let icon = infer_provider_icon("unknown provider");
|
||||
assert!(icon.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//! 健康检查器
|
||||
//!
|
||||
//! 负责定期检查Provider健康状态(占位实现)
|
||||
|
||||
// 占位实现,稍后添加完整逻辑
|
||||
#[allow(dead_code)]
|
||||
pub struct HealthChecker;
|
||||
@@ -15,7 +15,6 @@ pub mod gemini_url;
|
||||
pub mod handler_config;
|
||||
pub mod handler_context;
|
||||
mod handlers;
|
||||
mod health;
|
||||
pub mod http_client;
|
||||
pub mod hyper_client;
|
||||
pub(crate) mod json_canonical;
|
||||
@@ -24,7 +23,6 @@ pub mod media_sanitizer;
|
||||
pub mod model_mapper;
|
||||
pub mod provider_router;
|
||||
pub mod providers;
|
||||
pub mod response_handler;
|
||||
pub mod response_processor;
|
||||
pub(crate) mod server;
|
||||
pub mod session;
|
||||
@@ -47,11 +45,7 @@ pub use error::ProxyError;
|
||||
#[allow(unused_imports)]
|
||||
pub use provider_router::ProviderRouter;
|
||||
#[allow(unused_imports)]
|
||||
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
|
||||
#[allow(unused_imports)]
|
||||
pub use session::{
|
||||
extract_session_id, ClientFormat, ProxySession, SessionIdResult, SessionIdSource,
|
||||
};
|
||||
pub use session::{extract_session_id, SessionIdResult, SessionIdSource};
|
||||
#[allow(unused_imports)]
|
||||
pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus};
|
||||
|
||||
|
||||
@@ -589,6 +589,21 @@ pub(crate) fn responses_sse_events_from_anthropic_message(
|
||||
tool_context: CodexToolContext,
|
||||
) -> Vec<Bytes> {
|
||||
let mut state = AnthropicToResponsesState::with_tool_context(tool_context);
|
||||
|
||||
// The whole conversion below assumes `body` is an Anthropic message object.
|
||||
// A misbehaving gateway can return a top-level JSON array (or scalar) with
|
||||
// HTTP 200 despite `stream:true`; index-assigning `message_start["content"]`
|
||||
// on such a non-object would panic. Bail out gracefully instead.
|
||||
if !body.is_object() {
|
||||
return state
|
||||
.failed_event(
|
||||
"upstream returned a non-object Anthropic message body".to_string(),
|
||||
Some("invalid_response".to_string()),
|
||||
)
|
||||
.into_iter()
|
||||
.collect();
|
||||
}
|
||||
|
||||
if body.get("type").and_then(Value::as_str) == Some("error") || body.get("error").is_some() {
|
||||
let (message, error_type) = extract_anthropic_sse_error(body);
|
||||
return state
|
||||
@@ -819,6 +834,15 @@ mod tests {
|
||||
assert!(!merged.contains("stream_truncated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_non_object_body_returns_failed_event_not_panic() {
|
||||
// A gateway that ignores `stream:true` and returns a top-level JSON array
|
||||
// would have panicked on `message_start["content"] = …` before the guard.
|
||||
let merged = render_message_events(&json!([1, 2, 3]));
|
||||
assert!(merged.contains("event: response.failed"));
|
||||
assert!(merged.contains("invalid_response"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_message_becomes_complete_responses_stream() {
|
||||
let merged = render_message_events(&json!({
|
||||
|
||||
@@ -1418,13 +1418,39 @@ pub fn anthropic_sse_to_message_value(body: &str) -> Result<Value, ProxyError> {
|
||||
};
|
||||
match value.get("type").and_then(|t| t.as_str()).unwrap_or("") {
|
||||
"message_start" => {
|
||||
if let Some(msg) = value.get("message") {
|
||||
// Only accept an object message; a malformed upstream could send a
|
||||
// scalar/array here, and the later `message["content"] = …` index
|
||||
// assignment would panic on a non-object Value.
|
||||
if let Some(msg) = value.get("message").filter(|m| m.is_object()) {
|
||||
*message = Some(msg.clone());
|
||||
}
|
||||
}
|
||||
"content_block_start" => {
|
||||
if let Some(index) = value.get("index").and_then(|v| v.as_u64()) {
|
||||
let block = value.get("content_block").cloned().unwrap_or(json!({}));
|
||||
// Sanitize to an object: any later index-assignment (`["text"]`,
|
||||
// `["signature"]`, `["input"]`) requires a JSON object, so a
|
||||
// malformed non-object block from the upstream cannot be stored
|
||||
// verbatim (it would panic on the next delta).
|
||||
//
|
||||
// The replacement carries `type: "text"` rather than being empty:
|
||||
// the deltas that follow are usually well-formed, and a block with
|
||||
// no `type` is silently dropped by the final Responses conversion,
|
||||
// which turns a garbled block header into a `completed` response
|
||||
// with empty output — the client sees the model saying nothing and
|
||||
// has no way to tell that data was discarded. A text block recovers
|
||||
// the common case; a tool-use block still yields nothing, exactly as
|
||||
// it did before.
|
||||
let block = match value.get("content_block") {
|
||||
Some(block) if block.is_object() => block.clone(),
|
||||
malformed => {
|
||||
if malformed.is_some() {
|
||||
log::warn!(
|
||||
"Anthropic upstream sent a non-object content_block at index {index}; recovering it as a text block"
|
||||
);
|
||||
}
|
||||
json!({ "type": "text" })
|
||||
}
|
||||
};
|
||||
blocks.insert(index, block);
|
||||
json_accum.entry(index).or_default();
|
||||
}
|
||||
@@ -2953,4 +2979,42 @@ data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":
|
||||
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n";
|
||||
assert!(anthropic_sse_to_message_value(sse).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_sse_aggregation_non_object_content_block_does_not_panic() {
|
||||
// A malformed upstream can send a non-object `content_block`; the index
|
||||
// assignment on the next delta would have panicked before the shape guard.
|
||||
let sse = concat!(
|
||||
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n",
|
||||
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":[1]}\n\n",
|
||||
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"x\"}}\n\n",
|
||||
"data: {\"type\":\"message_stop\"}\n\n",
|
||||
);
|
||||
let msg = anthropic_sse_to_message_value(sse)
|
||||
.expect("aggregation must not panic on a non-object content_block");
|
||||
assert_eq!(msg["content"][0]["text"], json!("x"));
|
||||
|
||||
// Not panicking is only half of it: the sanitized block must still carry a
|
||||
// `type`, because the final conversion matches on it and silently drops
|
||||
// anything it does not recognise. Asserting only on the intermediate value
|
||||
// would pass while the client receives a `completed` response with empty
|
||||
// output and no indication that the text was thrown away.
|
||||
let response = anthropic_response_to_responses(msg).expect("final conversion must succeed");
|
||||
assert_eq!(
|
||||
response["output"][0]["content"][0]["text"],
|
||||
json!("x"),
|
||||
"text recovered from a malformed block must survive to the Responses output: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_sse_aggregation_non_object_message_errors_not_panic() {
|
||||
// A malformed upstream can send a scalar `message`; the later
|
||||
// `message["content"] = …` would have panicked before the shape guard.
|
||||
let sse = concat!(
|
||||
"data: {\"type\":\"message_start\",\"message\":\"oops\"}\n\n",
|
||||
"data: {\"type\":\"message_stop\"}\n\n",
|
||||
);
|
||||
assert!(anthropic_sse_to_message_value(sse).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
//! Response Handler - 统一响应处理
|
||||
//!
|
||||
//! 提供流式和非流式响应的统一处理接口
|
||||
|
||||
use super::session::ProxySession;
|
||||
use super::usage::parser::TokenUsage;
|
||||
use super::ProxyError;
|
||||
use crate::proxy::sse::{strip_sse_field, take_sse_block};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// 响应类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ResponseType {
|
||||
/// 流式响应 (SSE)
|
||||
Stream,
|
||||
/// 非流式响应
|
||||
NonStream,
|
||||
}
|
||||
|
||||
impl ResponseType {
|
||||
/// 从 Content-Type 检测响应类型
|
||||
#[allow(dead_code)]
|
||||
pub fn from_content_type(content_type: &str) -> Self {
|
||||
if content_type.contains("text/event-stream") {
|
||||
ResponseType::Stream
|
||||
} else {
|
||||
ResponseType::NonStream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式响应处理器
|
||||
#[allow(dead_code)]
|
||||
pub struct StreamHandler {
|
||||
/// 空闲超时时间
|
||||
idle_timeout: Duration,
|
||||
/// 收集的事件
|
||||
events: Arc<Mutex<Vec<Value>>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl StreamHandler {
|
||||
/// 创建新的流式处理器
|
||||
pub fn new(idle_timeout_secs: u64) -> Self {
|
||||
Self {
|
||||
idle_timeout: Duration::from_secs(idle_timeout_secs),
|
||||
events: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理流式响应,返回分流后的客户端流
|
||||
///
|
||||
/// 客户端流立即返回,内部流在后台收集事件
|
||||
pub fn handle_stream<S>(
|
||||
&self,
|
||||
stream: S,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send
|
||||
where
|
||||
S: Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
|
||||
{
|
||||
let events = self.events.clone();
|
||||
let idle_timeout = self.idle_timeout;
|
||||
|
||||
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);
|
||||
|
||||
loop {
|
||||
let chunk_result = timeout(idle_timeout, stream.next()).await;
|
||||
|
||||
match chunk_result {
|
||||
Ok(Some(Ok(bytes))) => {
|
||||
_last_activity = Instant::now();
|
||||
|
||||
// 解析 SSE 事件
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// 提取完整事件
|
||||
while let Some(event_text) = take_sse_block(&mut buffer) {
|
||||
for line in event_text.lines() {
|
||||
if let Some(data) = strip_sse_field(line, "data") {
|
||||
if data.trim() != "[DONE]" {
|
||||
if let Ok(json) = serde_json::from_str::<Value>(data) {
|
||||
let mut guard = events.lock().await;
|
||||
guard.push(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield Ok(bytes);
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
log::error!("流错误: {e}");
|
||||
yield Err(std::io::Error::other(e.to_string()));
|
||||
break;
|
||||
}
|
||||
Ok(None) => {
|
||||
// 流结束
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
// 空闲超时
|
||||
log::warn!("流式响应空闲超时: {idle_timeout:?} 无数据");
|
||||
yield Err(std::io::Error::other("Stream idle timeout"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取收集的事件
|
||||
pub async fn get_events(&self) -> Vec<Value> {
|
||||
let guard = self.events.lock().await;
|
||||
guard.clone()
|
||||
}
|
||||
|
||||
/// 从收集的事件中提取 Token 使用量
|
||||
pub async fn extract_usage(&self, session: &ProxySession) -> Option<TokenUsage> {
|
||||
let events = self.get_events().await;
|
||||
|
||||
match session.client_format {
|
||||
super::session::ClientFormat::Claude => TokenUsage::from_claude_stream_events(&events),
|
||||
super::session::ClientFormat::Codex => TokenUsage::from_codex_stream_events(&events),
|
||||
super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {
|
||||
TokenUsage::from_gemini_stream_chunks(&events)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 非流式响应处理器
|
||||
#[allow(dead_code)]
|
||||
pub struct NonStreamHandler;
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl NonStreamHandler {
|
||||
/// 处理非流式响应
|
||||
///
|
||||
/// 克隆响应体用于后台解析,原始响应立即返回
|
||||
pub async fn handle_response(
|
||||
body: &[u8],
|
||||
session: &ProxySession,
|
||||
) -> Result<Option<TokenUsage>, ProxyError> {
|
||||
let json: Value = serde_json::from_slice(body)
|
||||
.map_err(|e| ProxyError::TransformError(format!("Failed to parse response: {e}")))?;
|
||||
|
||||
let usage = match session.client_format {
|
||||
super::session::ClientFormat::Claude => TokenUsage::from_claude_response(&json),
|
||||
super::session::ClientFormat::Codex => TokenUsage::from_codex_response_adjusted(&json),
|
||||
super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {
|
||||
TokenUsage::from_gemini_response(&json)
|
||||
}
|
||||
super::session::ClientFormat::OpenAI => TokenUsage::from_openrouter_response(&json),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(usage)
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一响应分发器
|
||||
#[allow(dead_code)]
|
||||
pub struct ResponseDispatcher;
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ResponseDispatcher {
|
||||
/// 判断响应类型
|
||||
pub fn detect_type(content_type: &str) -> ResponseType {
|
||||
ResponseType::from_content_type(content_type)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_response_type_detection() {
|
||||
assert_eq!(
|
||||
ResponseType::from_content_type("text/event-stream"),
|
||||
ResponseType::Stream
|
||||
);
|
||||
assert_eq!(
|
||||
ResponseType::from_content_type("text/event-stream; charset=utf-8"),
|
||||
ResponseType::Stream
|
||||
);
|
||||
assert_eq!(
|
||||
ResponseType::from_content_type("application/json"),
|
||||
ResponseType::NonStream
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stream_handler_creation() {
|
||||
let handler = StreamHandler::new(30);
|
||||
assert_eq!(handler.idle_timeout, Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_sse_field_accepts_optional_space() {
|
||||
assert_eq!(
|
||||
super::strip_sse_field("data: {\"ok\":true}", "data"),
|
||||
Some("{\"ok\":true}")
|
||||
);
|
||||
assert_eq!(
|
||||
super::strip_sse_field("data:{\"ok\":true}", "data"),
|
||||
Some("{\"ok\":true}")
|
||||
);
|
||||
assert_eq!(
|
||||
super::strip_sse_field("event: message_start", "event"),
|
||||
Some("message_start")
|
||||
);
|
||||
assert_eq!(
|
||||
super::strip_sse_field("event:message_start", "event"),
|
||||
Some("message_start")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,180 +10,8 @@
|
||||
//! - 其他: 生成新的 UUID
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use std::time::Instant;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 客户端请求格式
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ClientFormat {
|
||||
/// Claude Messages API (/v1/messages)
|
||||
Claude,
|
||||
/// Codex Response API (/v1/responses)
|
||||
Codex,
|
||||
/// OpenAI Chat Completions API (/v1/chat/completions)
|
||||
OpenAI,
|
||||
/// Gemini API (/v1beta/models/*/generateContent)
|
||||
Gemini,
|
||||
/// Gemini CLI API (/v1internal/models/*/generateContent)
|
||||
GeminiCli,
|
||||
/// 未知格式
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ClientFormat {
|
||||
/// 从请求路径检测格式
|
||||
pub fn from_path(path: &str) -> Self {
|
||||
if path.contains("/v1/messages") {
|
||||
ClientFormat::Claude
|
||||
} else if path.contains("/v1/responses") {
|
||||
ClientFormat::Codex
|
||||
} else if path.contains("/v1/chat/completions") {
|
||||
ClientFormat::OpenAI
|
||||
} else if path.contains("/v1internal/") && path.contains("generateContent") {
|
||||
// Gemini CLI 使用 /v1internal/ 路径
|
||||
ClientFormat::GeminiCli
|
||||
} else if (path.contains("/v1beta/") || path.contains("/v1/"))
|
||||
&& path.contains("generateContent")
|
||||
{
|
||||
// Gemini API 使用 /v1beta/ 或 /v1/ 路径
|
||||
ClientFormat::Gemini
|
||||
} else if path.contains("generateContent") {
|
||||
// 通用 Gemini 端点
|
||||
ClientFormat::Gemini
|
||||
} else {
|
||||
ClientFormat::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// 从请求体内容检测格式(回退方案)
|
||||
pub fn from_body(body: &serde_json::Value) -> Self {
|
||||
// Claude 格式特征: messages 数组 + model 字段 + 无 response_format
|
||||
if body.get("messages").is_some()
|
||||
&& body.get("model").is_some()
|
||||
&& body.get("response_format").is_none()
|
||||
&& body.get("contents").is_none()
|
||||
{
|
||||
// 区分 Claude 和 OpenAI
|
||||
if body.get("max_tokens").is_some() {
|
||||
return ClientFormat::Claude;
|
||||
}
|
||||
return ClientFormat::OpenAI;
|
||||
}
|
||||
|
||||
// Codex 格式特征: input 字段
|
||||
if body.get("input").is_some() {
|
||||
return ClientFormat::Codex;
|
||||
}
|
||||
|
||||
// Gemini 格式特征: contents 数组
|
||||
if body.get("contents").is_some() {
|
||||
return ClientFormat::Gemini;
|
||||
}
|
||||
|
||||
ClientFormat::Unknown
|
||||
}
|
||||
|
||||
/// 转换为字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ClientFormat::Claude => "claude",
|
||||
ClientFormat::Codex => "codex",
|
||||
ClientFormat::OpenAI => "openai",
|
||||
ClientFormat::Gemini => "gemini",
|
||||
ClientFormat::GeminiCli => "gemini_cli",
|
||||
ClientFormat::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ClientFormat {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// 代理会话
|
||||
///
|
||||
/// 包含请求全生命周期的上下文数据
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ProxySession {
|
||||
/// 唯一会话 ID
|
||||
pub session_id: String,
|
||||
/// 请求开始时间
|
||||
pub start_time: Instant,
|
||||
/// HTTP 方法
|
||||
pub method: String,
|
||||
/// 请求 URL
|
||||
pub request_url: String,
|
||||
/// User-Agent
|
||||
pub user_agent: Option<String>,
|
||||
/// 客户端请求格式
|
||||
pub client_format: ClientFormat,
|
||||
/// 选定的供应商 ID
|
||||
pub provider_id: Option<String>,
|
||||
/// 模型名称
|
||||
pub model: Option<String>,
|
||||
/// 是否为流式请求
|
||||
pub is_streaming: bool,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ProxySession {
|
||||
/// 从请求创建会话
|
||||
pub fn from_request(
|
||||
method: &str,
|
||||
request_url: &str,
|
||||
user_agent: Option<&str>,
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> Self {
|
||||
// 检测客户端格式
|
||||
let mut client_format = ClientFormat::from_path(request_url);
|
||||
if client_format == ClientFormat::Unknown {
|
||||
if let Some(body) = body {
|
||||
client_format = ClientFormat::from_body(body);
|
||||
}
|
||||
}
|
||||
|
||||
// 检测是否为流式请求
|
||||
let is_streaming = body
|
||||
.and_then(|b| b.get("stream"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// 提取模型名称
|
||||
let model = body
|
||||
.and_then(|b| b.get("model"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Self {
|
||||
session_id: Uuid::new_v4().to_string(),
|
||||
start_time: Instant::now(),
|
||||
method: method.to_string(),
|
||||
request_url: request_url.to_string(),
|
||||
user_agent: user_agent.map(|s| s.to_string()),
|
||||
client_format,
|
||||
provider_id: None,
|
||||
model,
|
||||
is_streaming,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置供应商 ID
|
||||
pub fn with_provider(mut self, provider_id: &str) -> Self {
|
||||
self.provider_id = Some(provider_id.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// 获取请求延迟(毫秒)
|
||||
pub fn latency_ms(&self) -> u64 {
|
||||
self.start_time.elapsed().as_millis() as u64
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session ID 提取器
|
||||
// ============================================================================
|
||||
@@ -380,121 +208,6 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_path_claude() {
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/v1/messages"),
|
||||
ClientFormat::Claude
|
||||
);
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/api/v1/messages"),
|
||||
ClientFormat::Claude
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_path_codex() {
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/v1/responses"),
|
||||
ClientFormat::Codex
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_path_openai() {
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/v1/chat/completions"),
|
||||
ClientFormat::OpenAI
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_path_gemini() {
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/v1beta/models/gemini-pro:generateContent"),
|
||||
ClientFormat::Gemini
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_path_gemini_cli() {
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/v1internal/models/gemini-pro:generateContent"),
|
||||
ClientFormat::GeminiCli
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_body_claude() {
|
||||
let body = json!({
|
||||
"model": "claude-3-5-sonnet",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"max_tokens": 1024
|
||||
});
|
||||
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Claude);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_body_codex() {
|
||||
let body = json!({
|
||||
"input": "Write a function"
|
||||
});
|
||||
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Codex);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_body_gemini() {
|
||||
let body = json!({
|
||||
"contents": [{"parts": [{"text": "Hello"}]}]
|
||||
});
|
||||
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Gemini);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_id_uniqueness() {
|
||||
let session1 = ProxySession::from_request("POST", "/v1/messages", None, None);
|
||||
let session2 = ProxySession::from_request("POST", "/v1/messages", None, None);
|
||||
assert_ne!(session1.session_id, session2.session_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_from_request() {
|
||||
let body = json!({
|
||||
"model": "claude-3-5-sonnet",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"max_tokens": 1024,
|
||||
"stream": true
|
||||
});
|
||||
|
||||
let session =
|
||||
ProxySession::from_request("POST", "/v1/messages", Some("Mozilla/5.0"), Some(&body));
|
||||
|
||||
assert_eq!(session.method, "POST");
|
||||
assert_eq!(session.request_url, "/v1/messages");
|
||||
assert_eq!(session.user_agent, Some("Mozilla/5.0".to_string()));
|
||||
assert_eq!(session.client_format, ClientFormat::Claude);
|
||||
assert_eq!(session.model, Some("claude-3-5-sonnet".to_string()));
|
||||
assert!(session.is_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_with_provider() {
|
||||
let session = ProxySession::from_request("POST", "/v1/messages", None, None)
|
||||
.with_provider("provider-123");
|
||||
|
||||
assert_eq!(session.provider_id, Some("provider-123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_as_str() {
|
||||
assert_eq!(ClientFormat::Claude.as_str(), "claude");
|
||||
assert_eq!(ClientFormat::Codex.as_str(), "codex");
|
||||
assert_eq!(ClientFormat::OpenAI.as_str(), "openai");
|
||||
assert_eq!(ClientFormat::Gemini.as_str(), "gemini");
|
||||
assert_eq!(ClientFormat::GeminiCli.as_str(), "gemini_cli");
|
||||
assert_eq!(ClientFormat::Unknown.as_str(), "unknown");
|
||||
}
|
||||
|
||||
// ========== Session ID 提取测试 ==========
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -118,15 +118,6 @@ pub struct ProxyTakeoverStatus {
|
||||
pub openclaw: bool,
|
||||
}
|
||||
|
||||
/// API 格式类型(预留,当前不需要格式转换)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ApiFormat {
|
||||
Claude,
|
||||
OpenAI,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
/// Provider健康状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderHealth {
|
||||
|
||||
@@ -112,16 +112,6 @@ impl CostCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 尝试计算成本,如果模型未知则返回 None
|
||||
#[allow(dead_code)]
|
||||
pub fn try_calculate(
|
||||
usage: &TokenUsage,
|
||||
pricing: Option<&ModelPricing>,
|
||||
cost_multiplier: Decimal,
|
||||
) -> Option<CostBreakdown> {
|
||||
pricing.map(|p| Self::calculate(usage, p, cost_multiplier))
|
||||
}
|
||||
|
||||
pub fn try_calculate_for_app(
|
||||
app_type: &str,
|
||||
usage: &TokenUsage,
|
||||
@@ -253,23 +243,6 @@ mod tests {
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.0045").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_model_handling() {
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let multiplier = Decimal::from_str("1.0").unwrap();
|
||||
let cost = CostCalculator::try_calculate(&usage, None, multiplier);
|
||||
|
||||
assert!(cost.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_precision() {
|
||||
let usage = TokenUsage {
|
||||
|
||||
@@ -12,4 +12,4 @@ pub use calculator::{CostBreakdown, CostCalculator, ModelPricing};
|
||||
#[allow(unused_imports)]
|
||||
pub use logger::{RequestLog, UsageLogger};
|
||||
#[allow(unused_imports)]
|
||||
pub use parser::{ApiType, TokenUsage};
|
||||
pub use parser::TokenUsage;
|
||||
|
||||
@@ -82,16 +82,6 @@ impl TokenUsage {
|
||||
}
|
||||
}
|
||||
|
||||
/// API 类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ApiType {
|
||||
Claude,
|
||||
OpenRouter,
|
||||
Codex,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
impl TokenUsage {
|
||||
/// 从 Claude API 非流式响应解析
|
||||
pub fn from_claude_response(body: &Value) -> Option<Self> {
|
||||
@@ -238,20 +228,6 @@ impl TokenUsage {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 OpenRouter 响应解析 (OpenAI 格式)
|
||||
#[allow(dead_code)]
|
||||
pub fn from_openrouter_response(body: &Value) -> Option<Self> {
|
||||
let usage = body.get("usage")?;
|
||||
Some(Self {
|
||||
input_tokens: usage.get("prompt_tokens")?.as_u64()? as u32,
|
||||
output_tokens: usage.get("completion_tokens")?.as_u64()? as u32,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: response_id(body, "id"),
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 Codex API 非流式响应解析
|
||||
pub fn from_codex_response(body: &Value) -> Option<Self> {
|
||||
let usage = body.get("usage");
|
||||
@@ -291,60 +267,6 @@ impl TokenUsage {
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 Codex API 响应解析并调整 input_tokens
|
||||
///
|
||||
/// Codex 的 input_tokens 需要减去 cached_tokens 以获得实际计费的 token 数
|
||||
/// 公式: adjusted_input = max(input_tokens - cached_tokens, 0)
|
||||
#[allow(dead_code)]
|
||||
pub fn from_codex_response_adjusted(body: &Value) -> Option<Self> {
|
||||
let usage = body.get("usage")?;
|
||||
let input_tokens = usage.get("input_tokens")?.as_u64()? as u32;
|
||||
let output_tokens = usage.get("output_tokens")?.as_u64()? as u32;
|
||||
|
||||
// 获取 cached_tokens (可能在 cache_read_input_tokens 或 input_tokens_details 中)
|
||||
let cached_tokens = openai_cache_read_tokens(usage);
|
||||
let cache_write_tokens = openai_cache_write_tokens(usage);
|
||||
|
||||
// 调整 input_tokens: OpenAI total input 同时包含 cache read/write 两桶。
|
||||
let adjusted_input = input_tokens
|
||||
.saturating_sub(cached_tokens)
|
||||
.saturating_sub(cache_write_tokens);
|
||||
|
||||
// 提取响应中的模型名称
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Some(Self {
|
||||
input_tokens: adjusted_input,
|
||||
output_tokens,
|
||||
cache_read_tokens: cached_tokens,
|
||||
cache_creation_tokens: cache_write_tokens,
|
||||
model,
|
||||
message_id: response_id(body, "id"),
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 Codex API 流式响应解析
|
||||
#[allow(dead_code)]
|
||||
pub fn from_codex_stream_events(events: &[Value]) -> Option<Self> {
|
||||
log::debug!("[Codex] 解析流式事件,共 {} 个事件", events.len());
|
||||
for event in events {
|
||||
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
|
||||
log::debug!("[Codex] 事件类型: {event_type}");
|
||||
if event_type == "response.completed" {
|
||||
if let Some(response) = event.get("response") {
|
||||
log::debug!("[Codex] 找到 response.completed 事件,解析 usage");
|
||||
return Self::from_codex_response_adjusted(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log::debug!("[Codex] 未找到 response.completed 事件");
|
||||
None
|
||||
}
|
||||
|
||||
/// 智能 Codex 响应解析 - 自动检测 OpenAI 或 Codex 格式
|
||||
///
|
||||
/// Codex 支持两种 API 格式:
|
||||
@@ -750,22 +672,6 @@ mod tests {
|
||||
assert_eq!(usage.model, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openrouter_response_parsing() {
|
||||
let response = json!({
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_openrouter_response(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.cache_read_tokens, 0);
|
||||
assert_eq!(usage.cache_creation_tokens, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gemini_response_parsing() {
|
||||
let response = json!({
|
||||
@@ -883,81 +789,6 @@ mod tests {
|
||||
assert_eq!(usage.input_tokens, 1000);
|
||||
assert_eq!(usage.cache_read_tokens, 300);
|
||||
assert_eq!(usage.cache_creation_tokens, 200);
|
||||
|
||||
let adjusted = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
||||
assert_eq!(adjusted.input_tokens, 500);
|
||||
assert_eq!(adjusted.cache_read_tokens, 300);
|
||||
assert_eq!(adjusted.cache_creation_tokens, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_adjusted() {
|
||||
let response = json!({
|
||||
"usage": {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 500,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 300
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
||||
// input_tokens 应该被调整: 1000 - 300 = 700
|
||||
assert_eq!(usage.input_tokens, 700);
|
||||
assert_eq!(usage.output_tokens, 500);
|
||||
assert_eq!(usage.cache_read_tokens, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_adjusted_no_cache() {
|
||||
let response = json!({
|
||||
"usage": {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 500
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
||||
// 没有 cached_tokens,input_tokens 保持不变
|
||||
assert_eq!(usage.input_tokens, 1000);
|
||||
assert_eq!(usage.output_tokens, 500);
|
||||
assert_eq!(usage.cache_read_tokens, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_adjusted_cache_read_input_tokens() {
|
||||
let response = json!({
|
||||
"usage": {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 500,
|
||||
"cache_read_input_tokens": 200
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 800);
|
||||
assert_eq!(usage.output_tokens, 500);
|
||||
assert_eq!(usage.cache_read_tokens, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_adjusted_saturating_sub() {
|
||||
// 测试 cached_tokens > input_tokens 的边界情况
|
||||
let response = json!({
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 200
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
||||
// saturating_sub 确保不会下溢
|
||||
assert_eq!(usage.input_tokens, 0);
|
||||
assert_eq!(usage.cache_read_tokens, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod env_checker;
|
||||
pub mod env_manager;
|
||||
pub mod mcp;
|
||||
pub mod model_fetch;
|
||||
pub mod model_pricing;
|
||||
pub mod omo;
|
||||
pub mod profile;
|
||||
pub mod prompt;
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
use crate::config::{atomic_write, get_app_config_dir};
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use rusqlite::{params, Transaction};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
const MODEL_PRICING_FILE_NAME: &str = "model-pricing.json";
|
||||
const MODEL_PRICING_FILE_VERSION: u32 = 1;
|
||||
|
||||
static MODEL_PRICING_FILE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
fn file_lock() -> &'static Mutex<()> {
|
||||
MODEL_PRICING_FILE_LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_file_version() -> u32 {
|
||||
MODEL_PRICING_FILE_VERSION
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelPricingInfo {
|
||||
pub model_id: String,
|
||||
pub display_name: String,
|
||||
pub input_cost_per_million: String,
|
||||
pub output_cost_per_million: String,
|
||||
pub cache_read_cost_per_million: String,
|
||||
pub cache_creation_cost_per_million: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelsDevSyncConfig {
|
||||
#[serde(default)]
|
||||
pub auto_sync_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub include_common_models: bool,
|
||||
#[serde(default)]
|
||||
pub selected_model_keys: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub excluded_common_model_keys: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub last_sync_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub last_sync_error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ModelsDevSyncConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_sync_enabled: false,
|
||||
include_common_models: true,
|
||||
selected_model_keys: Vec::new(),
|
||||
excluded_common_model_keys: Vec::new(),
|
||||
last_sync_at: None,
|
||||
last_sync_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ModelPricingFile {
|
||||
#[serde(default = "default_file_version")]
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
models_dev_sync: ModelsDevSyncConfig,
|
||||
#[serde(default)]
|
||||
models: Vec<ModelPricingInfo>,
|
||||
#[serde(default)]
|
||||
deleted_model_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for ModelPricingFile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: MODEL_PRICING_FILE_VERSION,
|
||||
models_dev_sync: ModelsDevSyncConfig::default(),
|
||||
models: Vec::new(),
|
||||
deleted_model_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelsDevSyncState {
|
||||
pub config: ModelsDevSyncConfig,
|
||||
pub config_path: String,
|
||||
}
|
||||
|
||||
pub fn model_pricing_file_path() -> PathBuf {
|
||||
get_app_config_dir().join(MODEL_PRICING_FILE_NAME)
|
||||
}
|
||||
|
||||
fn normalize_decimal(label: &str, value: &str) -> Result<String, AppError> {
|
||||
let value = value.trim();
|
||||
let parsed = Decimal::from_str(value).map_err(|error| {
|
||||
AppError::localized(
|
||||
"usage.invalidPrice",
|
||||
format!("{label} 价格无效: {value} - {error}"),
|
||||
format!("{label} price is invalid: {value} - {error}"),
|
||||
)
|
||||
})?;
|
||||
if parsed < Decimal::ZERO {
|
||||
return Err(AppError::localized(
|
||||
"usage.invalidPrice",
|
||||
format!("{label} 价格必须为非负数: {value}"),
|
||||
format!("{label} price must be non-negative: {value}"),
|
||||
));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn normalize_pricing(entry: ModelPricingInfo) -> Result<ModelPricingInfo, AppError> {
|
||||
let model_id = entry.model_id.trim().to_string();
|
||||
let display_name = entry.display_name.trim().to_string();
|
||||
if model_id.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"usage.modelIdRequired",
|
||||
"模型 ID 不能为空",
|
||||
"Model ID is required",
|
||||
));
|
||||
}
|
||||
if display_name.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"usage.displayNameRequired",
|
||||
"显示名称不能为空",
|
||||
"Display name is required",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ModelPricingInfo {
|
||||
model_id,
|
||||
display_name,
|
||||
input_cost_per_million: normalize_decimal("input_cost", &entry.input_cost_per_million)?,
|
||||
output_cost_per_million: normalize_decimal("output_cost", &entry.output_cost_per_million)?,
|
||||
cache_read_cost_per_million: normalize_decimal(
|
||||
"cache_read_cost",
|
||||
&entry.cache_read_cost_per_million,
|
||||
)?,
|
||||
cache_creation_cost_per_million: normalize_decimal(
|
||||
"cache_creation_cost",
|
||||
&entry.cache_creation_cost_per_million,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_key_list(values: Vec<String>) -> Vec<String> {
|
||||
values
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_sync_config(mut config: ModelsDevSyncConfig) -> ModelsDevSyncConfig {
|
||||
config.selected_model_keys = normalize_key_list(config.selected_model_keys);
|
||||
config.excluded_common_model_keys = normalize_key_list(config.excluded_common_model_keys);
|
||||
config.last_sync_error = config.last_sync_error.and_then(|error| {
|
||||
let trimmed = error.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.chars().take(1000).collect())
|
||||
}
|
||||
});
|
||||
config
|
||||
}
|
||||
|
||||
fn normalize_file(mut file: ModelPricingFile) -> Result<ModelPricingFile, AppError> {
|
||||
if file.version > MODEL_PRICING_FILE_VERSION {
|
||||
return Err(AppError::Config(format!(
|
||||
"model-pricing.json version {} is newer than supported version {}",
|
||||
file.version, MODEL_PRICING_FILE_VERSION
|
||||
)));
|
||||
}
|
||||
|
||||
let deleted = normalize_key_list(file.deleted_model_ids)
|
||||
.into_iter()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut models = BTreeMap::new();
|
||||
for entry in file.models {
|
||||
let entry = normalize_pricing(entry)?;
|
||||
if !deleted.contains(&entry.model_id) {
|
||||
models.insert(entry.model_id.clone(), entry);
|
||||
}
|
||||
}
|
||||
|
||||
file.version = MODEL_PRICING_FILE_VERSION;
|
||||
file.models_dev_sync = normalize_sync_config(file.models_dev_sync);
|
||||
file.models = models.into_values().collect();
|
||||
file.deleted_model_ids = deleted.into_iter().collect();
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn read_file_unlocked() -> Result<Option<ModelPricingFile>, AppError> {
|
||||
let path = model_pricing_file_path();
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = fs::read_to_string(&path).map_err(|error| AppError::io(&path, error))?;
|
||||
let file = serde_json::from_str(&content).map_err(|error| AppError::json(&path, error))?;
|
||||
normalize_file(file).map(Some)
|
||||
}
|
||||
|
||||
fn write_file_unlocked(file: &ModelPricingFile) -> Result<(), AppError> {
|
||||
let path = model_pricing_file_path();
|
||||
let mut data = serde_json::to_vec_pretty(file)
|
||||
.map_err(|error| AppError::Config(format!("序列化模型定价配置失败: {error}")))?;
|
||||
data.push(b'\n');
|
||||
atomic_write(&path, &data)
|
||||
}
|
||||
|
||||
fn load_or_create_file_unlocked() -> Result<ModelPricingFile, AppError> {
|
||||
if let Some(file) = read_file_unlocked()? {
|
||||
return Ok(file);
|
||||
}
|
||||
|
||||
// The local file stores user/models.dev overrides only. Exporting the
|
||||
// complete seeded table here would turn built-in prices into overrides and
|
||||
// roll back future repair_current_model_pricing corrections on startup.
|
||||
let file = ModelPricingFile::default();
|
||||
write_file_unlocked(&file)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn upsert_pricing(
|
||||
transaction: &Transaction<'_>,
|
||||
entry: &ModelPricingInfo,
|
||||
) -> Result<usize, AppError> {
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO model_pricing (
|
||||
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(model_id) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
input_cost_per_million = excluded.input_cost_per_million,
|
||||
output_cost_per_million = excluded.output_cost_per_million,
|
||||
cache_read_cost_per_million = excluded.cache_read_cost_per_million,
|
||||
cache_creation_cost_per_million = excluded.cache_creation_cost_per_million
|
||||
WHERE display_name <> excluded.display_name
|
||||
OR input_cost_per_million <> excluded.input_cost_per_million
|
||||
OR output_cost_per_million <> excluded.output_cost_per_million
|
||||
OR cache_read_cost_per_million <> excluded.cache_read_cost_per_million
|
||||
OR cache_creation_cost_per_million <> excluded.cache_creation_cost_per_million",
|
||||
params![
|
||||
entry.model_id,
|
||||
entry.display_name,
|
||||
entry.input_cost_per_million,
|
||||
entry.output_cost_per_million,
|
||||
entry.cache_read_cost_per_million,
|
||||
entry.cache_creation_cost_per_million
|
||||
],
|
||||
)
|
||||
.map_err(|error| AppError::Database(format!("更新模型定价失败: {error}")))
|
||||
}
|
||||
|
||||
fn apply_file_to_database(
|
||||
db: &Database,
|
||||
file: &ModelPricingFile,
|
||||
) -> Result<(usize, usize), AppError> {
|
||||
let mut conn = lock_conn!(db.conn);
|
||||
let transaction = conn.transaction()?;
|
||||
let mut upserted = 0;
|
||||
for entry in &file.models {
|
||||
upserted += upsert_pricing(&transaction, entry)?;
|
||||
}
|
||||
let mut deleted = 0;
|
||||
for model_id in &file.deleted_model_ids {
|
||||
deleted += transaction.execute(
|
||||
"DELETE FROM model_pricing WHERE model_id = ?1",
|
||||
params![model_id],
|
||||
)?;
|
||||
}
|
||||
transaction.commit()?;
|
||||
Ok((upserted, deleted))
|
||||
}
|
||||
|
||||
/// Load user-maintained overrides from `~/.cc-switch/model-pricing.json`.
|
||||
/// Built-in rows remain database-owned so application updates can repair them;
|
||||
/// the file contains only explicit overrides and deletion tombstones.
|
||||
pub fn sync_local_model_pricing(db: &Database) -> Result<usize, AppError> {
|
||||
let (upserted, deleted) = {
|
||||
let _file_guard = file_lock()
|
||||
.lock()
|
||||
.map_err(|error| AppError::Config(format!("模型定价文件锁失败: {error}")))?;
|
||||
let file = load_or_create_file_unlocked()?;
|
||||
apply_file_to_database(db, &file)?
|
||||
};
|
||||
|
||||
// Deleting pricing cannot make a zero-cost usage row calculable. In
|
||||
// particular, seeded rows covered by tombstones may be reinserted and
|
||||
// deleted on every startup; they must not trigger a full-table backfill.
|
||||
if upserted > 0 {
|
||||
if let Err(error) = db.backfill_missing_usage_costs() {
|
||||
log::warn!("本地模型定价同步后回填历史用量成本失败: {error}");
|
||||
}
|
||||
}
|
||||
Ok(upserted + deleted)
|
||||
}
|
||||
|
||||
pub fn get_models_dev_sync_state(db: &Database) -> Result<ModelsDevSyncState, AppError> {
|
||||
sync_local_model_pricing(db)?;
|
||||
let _file_guard = file_lock()
|
||||
.lock()
|
||||
.map_err(|error| AppError::Config(format!("模型定价文件锁失败: {error}")))?;
|
||||
let file = load_or_create_file_unlocked()?;
|
||||
Ok(ModelsDevSyncState {
|
||||
config: file.models_dev_sync,
|
||||
config_path: model_pricing_file_path().display().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_models_dev_sync_config(
|
||||
db: &Database,
|
||||
config: ModelsDevSyncConfig,
|
||||
) -> Result<(), AppError> {
|
||||
sync_local_model_pricing(db)?;
|
||||
let _file_guard = file_lock()
|
||||
.lock()
|
||||
.map_err(|error| AppError::Config(format!("模型定价文件锁失败: {error}")))?;
|
||||
let mut file = load_or_create_file_unlocked()?;
|
||||
file.models_dev_sync = normalize_sync_config(config);
|
||||
write_file_unlocked(&file)
|
||||
}
|
||||
|
||||
/// Persist only the outcome of a models.dev sync. Keeping this separate from
|
||||
/// `save_models_dev_sync_config` prevents a slow startup fetch from restoring
|
||||
/// stale switches or model selections that the user changed in the meantime.
|
||||
pub fn record_models_dev_sync_result(
|
||||
db: &Database,
|
||||
synced_at: Option<i64>,
|
||||
error: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
sync_local_model_pricing(db)?;
|
||||
let _file_guard = file_lock()
|
||||
.lock()
|
||||
.map_err(|lock_error| AppError::Config(format!("模型定价文件锁失败: {lock_error}")))?;
|
||||
let mut file = load_or_create_file_unlocked()?;
|
||||
if let Some(synced_at) = synced_at {
|
||||
file.models_dev_sync.last_sync_at = Some(synced_at);
|
||||
}
|
||||
file.models_dev_sync.last_sync_error = error;
|
||||
file.models_dev_sync = normalize_sync_config(file.models_dev_sync);
|
||||
write_file_unlocked(&file)
|
||||
}
|
||||
|
||||
fn update_model_pricing_batch_inner(
|
||||
db: &Database,
|
||||
entries: Vec<ModelPricingInfo>,
|
||||
backfill_all: bool,
|
||||
) -> Result<usize, AppError> {
|
||||
if entries.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut normalized = BTreeMap::new();
|
||||
for entry in entries {
|
||||
let entry = normalize_pricing(entry)?;
|
||||
normalized.insert(entry.model_id.clone(), entry);
|
||||
}
|
||||
let entries = normalized.into_values().collect::<Vec<_>>();
|
||||
let model_ids = entries
|
||||
.iter()
|
||||
.map(|entry| entry.model_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
sync_local_model_pricing(db)?;
|
||||
let changed = {
|
||||
let _file_guard = file_lock()
|
||||
.lock()
|
||||
.map_err(|error| AppError::Config(format!("模型定价文件锁失败: {error}")))?;
|
||||
let mut file = load_or_create_file_unlocked()?;
|
||||
let mut file_models = file
|
||||
.models
|
||||
.into_iter()
|
||||
.map(|entry| (entry.model_id.clone(), entry))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let updated_ids = entries
|
||||
.iter()
|
||||
.map(|entry| entry.model_id.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
for entry in &entries {
|
||||
file_models.insert(entry.model_id.clone(), entry.clone());
|
||||
}
|
||||
file.models = file_models.into_values().collect();
|
||||
file.deleted_model_ids
|
||||
.retain(|model_id| !updated_ids.contains(model_id));
|
||||
|
||||
let mut conn = lock_conn!(db.conn);
|
||||
let transaction = conn.transaction()?;
|
||||
let mut changed = 0;
|
||||
for entry in &entries {
|
||||
changed += upsert_pricing(&transaction, entry)?;
|
||||
}
|
||||
write_file_unlocked(&file)?;
|
||||
transaction.commit()?;
|
||||
changed
|
||||
};
|
||||
|
||||
if changed > 0 {
|
||||
if backfill_all {
|
||||
if let Err(error) = db.backfill_missing_usage_costs() {
|
||||
log::warn!("批量更新模型定价后回填历史用量成本失败: {error}");
|
||||
}
|
||||
} else {
|
||||
for model_id in model_ids {
|
||||
if let Err(error) = db.backfill_missing_usage_costs_for_model(&model_id) {
|
||||
log::warn!("模型定价更新后回填历史用量成本失败 (model_id={model_id}): {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn update_model_pricing(db: &Database, entry: ModelPricingInfo) -> Result<usize, AppError> {
|
||||
update_model_pricing_batch_inner(db, vec![entry], false)
|
||||
}
|
||||
|
||||
pub fn update_model_pricing_batch(
|
||||
db: &Database,
|
||||
entries: Vec<ModelPricingInfo>,
|
||||
) -> Result<usize, AppError> {
|
||||
update_model_pricing_batch_inner(db, entries, true)
|
||||
}
|
||||
|
||||
pub fn delete_model_pricing(db: &Database, model_id: &str) -> Result<(), AppError> {
|
||||
let model_id = model_id.trim();
|
||||
if model_id.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"usage.modelIdRequired",
|
||||
"模型 ID 不能为空",
|
||||
"Model ID is required",
|
||||
));
|
||||
}
|
||||
|
||||
sync_local_model_pricing(db)?;
|
||||
let _file_guard = file_lock()
|
||||
.lock()
|
||||
.map_err(|error| AppError::Config(format!("模型定价文件锁失败: {error}")))?;
|
||||
let mut file = load_or_create_file_unlocked()?;
|
||||
file.models.retain(|entry| entry.model_id != model_id);
|
||||
if !file.deleted_model_ids.iter().any(|entry| entry == model_id) {
|
||||
file.deleted_model_ids.push(model_id.to_string());
|
||||
file.deleted_model_ids.sort();
|
||||
}
|
||||
|
||||
let mut conn = lock_conn!(db.conn);
|
||||
let transaction = conn.transaction()?;
|
||||
transaction.execute(
|
||||
"DELETE FROM model_pricing WHERE model_id = ?1",
|
||||
params![model_id],
|
||||
)?;
|
||||
write_file_unlocked(&file)?;
|
||||
transaction.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
|
||||
fn with_test_home(test: impl FnOnce(&Database, &PathBuf)) {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let previous = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
|
||||
|
||||
let db = Database::memory().expect("memory database");
|
||||
let path = model_pricing_file_path();
|
||||
test(&db, &path);
|
||||
|
||||
match previous {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_pricing() -> ModelPricingInfo {
|
||||
ModelPricingInfo {
|
||||
model_id: "custom-model".to_string(),
|
||||
display_name: "Custom Model".to_string(),
|
||||
input_cost_per_million: "1.25".to_string(),
|
||||
output_cost_per_million: "5".to_string(),
|
||||
cache_read_cost_per_million: "0.1".to_string(),
|
||||
cache_creation_cost_per_million: "1.5".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn creates_local_file_with_auto_sync_disabled_by_default() {
|
||||
with_test_home(|db, path| {
|
||||
let state = get_models_dev_sync_state(db).expect("sync state");
|
||||
assert!(path.exists());
|
||||
assert!(!state.config.auto_sync_enabled);
|
||||
assert!(state.config.include_common_models);
|
||||
assert_eq!(state.config_path, path.display().to_string());
|
||||
|
||||
let content = fs::read_to_string(path).expect("read pricing file");
|
||||
let file: ModelPricingFile = serde_json::from_str(&content).expect("parse file");
|
||||
assert!(file.models.is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn empty_override_file_does_not_roll_back_builtin_pricing_repairs() {
|
||||
with_test_home(|db, path| {
|
||||
get_models_dev_sync_state(db).expect("create override file");
|
||||
{
|
||||
let conn = db.conn.lock().expect("lock test database");
|
||||
assert_eq!(
|
||||
conn.execute(
|
||||
"UPDATE model_pricing
|
||||
SET input_cost_per_million = '99'
|
||||
WHERE model_id = 'claude-sonnet-5'",
|
||||
[],
|
||||
)
|
||||
.expect("simulate built-in pricing repair"),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(sync_local_model_pricing(db).expect("reload overrides"), 0);
|
||||
|
||||
let conn = db.conn.lock().expect("lock test database");
|
||||
let input: String = conn
|
||||
.query_row(
|
||||
"SELECT input_cost_per_million
|
||||
FROM model_pricing WHERE model_id = 'claude-sonnet-5'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("query repaired pricing");
|
||||
drop(conn);
|
||||
assert_eq!(input, "99");
|
||||
|
||||
let content = fs::read_to_string(path).expect("read override file");
|
||||
let file: ModelPricingFile = serde_json::from_str(&content).expect("parse file");
|
||||
assert!(file.models.is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn models_dev_batch_sync_overwrites_existing_manual_pricing() {
|
||||
with_test_home(|db, path| {
|
||||
let mut manual = sample_pricing();
|
||||
manual.input_cost_per_million = "9".to_string();
|
||||
manual.output_cost_per_million = "18".to_string();
|
||||
update_model_pricing(db, manual).expect("save manual pricing");
|
||||
|
||||
let synced = sample_pricing();
|
||||
update_model_pricing_batch(db, vec![synced.clone()]).expect("sync models.dev pricing");
|
||||
|
||||
let conn = db.conn.lock().expect("lock test database");
|
||||
let input: String = conn
|
||||
.query_row(
|
||||
"SELECT input_cost_per_million FROM model_pricing WHERE model_id = ?1",
|
||||
params!["custom-model"],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("query synced pricing");
|
||||
drop(conn);
|
||||
assert_eq!(input, synced.input_cost_per_million);
|
||||
|
||||
let content = fs::read_to_string(path).expect("read pricing file");
|
||||
let file: ModelPricingFile = serde_json::from_str(&content).expect("parse file");
|
||||
let saved = file
|
||||
.models
|
||||
.iter()
|
||||
.find(|entry| entry.model_id == "custom-model")
|
||||
.expect("saved synced pricing");
|
||||
assert_eq!(saved, &synced);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn batch_update_and_delete_are_persisted_to_local_file() {
|
||||
with_test_home(|db, path| {
|
||||
assert_eq!(
|
||||
update_model_pricing_batch(db, vec![sample_pricing()]).expect("batch update"),
|
||||
1
|
||||
);
|
||||
let content = fs::read_to_string(path).expect("read pricing file");
|
||||
let file: ModelPricingFile = serde_json::from_str(&content).expect("parse file");
|
||||
assert!(file
|
||||
.models
|
||||
.iter()
|
||||
.any(|entry| entry.model_id == "custom-model"));
|
||||
|
||||
delete_model_pricing(db, "custom-model").expect("delete pricing");
|
||||
let content = fs::read_to_string(path).expect("read updated file");
|
||||
let file: ModelPricingFile =
|
||||
serde_json::from_str(&content).expect("parse updated file");
|
||||
assert!(!file
|
||||
.models
|
||||
.iter()
|
||||
.any(|entry| entry.model_id == "custom-model"));
|
||||
assert!(file
|
||||
.deleted_model_ids
|
||||
.iter()
|
||||
.any(|entry| entry == "custom-model"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn reloads_manual_file_edits_and_deletion_tombstones() {
|
||||
with_test_home(|db, path| {
|
||||
get_models_dev_sync_state(db).expect("create pricing file");
|
||||
let content = fs::read_to_string(path).expect("read pricing file");
|
||||
let mut file: ModelPricingFile =
|
||||
serde_json::from_str(&content).expect("parse pricing file");
|
||||
file.models.push(sample_pricing());
|
||||
fs::write(
|
||||
path,
|
||||
serde_json::to_vec_pretty(&file).expect("serialize file"),
|
||||
)
|
||||
.expect("write manual edit");
|
||||
|
||||
assert_eq!(sync_local_model_pricing(db).expect("reload file"), 1);
|
||||
{
|
||||
let conn = db.conn.lock().expect("lock test database");
|
||||
let input: String = conn
|
||||
.query_row(
|
||||
"SELECT input_cost_per_million FROM model_pricing WHERE model_id = ?1",
|
||||
params!["custom-model"],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("query manually added pricing");
|
||||
assert_eq!(input, "1.25");
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(path).expect("read updated pricing file");
|
||||
let mut file: ModelPricingFile =
|
||||
serde_json::from_str(&content).expect("parse updated pricing file");
|
||||
file.deleted_model_ids.push("custom-model".to_string());
|
||||
fs::write(
|
||||
path,
|
||||
serde_json::to_vec_pretty(&file).expect("serialize tombstone"),
|
||||
)
|
||||
.expect("write tombstone");
|
||||
|
||||
assert_eq!(sync_local_model_pricing(db).expect("apply tombstone"), 1);
|
||||
let conn = db.conn.lock().expect("lock test database");
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM model_pricing WHERE model_id = ?1",
|
||||
params!["custom-model"],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("query deleted pricing");
|
||||
assert_eq!(count, 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn repeated_seeded_tombstone_deletion_does_not_backfill_unrelated_usage() {
|
||||
with_test_home(|db, _path| {
|
||||
get_models_dev_sync_state(db).expect("create override file");
|
||||
{
|
||||
let conn = db.conn.lock().expect("lock test database");
|
||||
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,
|
||||
status_code, created_at, data_source
|
||||
) VALUES (
|
||||
'pending-cost', 'test-provider', 'codex', 'gpt-5', 'gpt-5',
|
||||
1000000, 0, 0, 0, '0', '0', '0', '0', '0', 100, 200, 1, 'proxy'
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.expect("insert zero-cost usage");
|
||||
}
|
||||
|
||||
delete_model_pricing(db, "claude-sonnet-5").expect("create tombstone");
|
||||
db.ensure_model_pricing_seeded()
|
||||
.expect("reseed built-in pricing");
|
||||
assert_eq!(sync_local_model_pricing(db).expect("apply tombstone"), 1);
|
||||
|
||||
let conn = db.conn.lock().expect("lock test database");
|
||||
let deleted_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM model_pricing
|
||||
WHERE model_id = 'claude-sonnet-5'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("query tombstoned pricing");
|
||||
let total_cost: f64 = conn
|
||||
.query_row(
|
||||
"SELECT CAST(total_cost_usd AS REAL)
|
||||
FROM proxy_request_logs WHERE request_id = 'pending-cost'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("query pending usage cost");
|
||||
assert_eq!(deleted_count, 0);
|
||||
assert_eq!(total_cost, 0.0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn recording_sync_result_preserves_user_selection_and_switches() {
|
||||
with_test_home(|db, _path| {
|
||||
let config = ModelsDevSyncConfig {
|
||||
auto_sync_enabled: false,
|
||||
include_common_models: false,
|
||||
selected_model_keys: vec!["relay/custom-model".to_string()],
|
||||
excluded_common_model_keys: vec!["openai/gpt-5".to_string()],
|
||||
last_sync_at: Some(123),
|
||||
last_sync_error: Some("old error".to_string()),
|
||||
};
|
||||
save_models_dev_sync_config(db, config.clone()).expect("save sync config");
|
||||
|
||||
record_models_dev_sync_result(db, Some(456), None).expect("record success");
|
||||
let state = get_models_dev_sync_state(db).expect("read sync state");
|
||||
assert_eq!(state.config.auto_sync_enabled, config.auto_sync_enabled);
|
||||
assert_eq!(
|
||||
state.config.include_common_models,
|
||||
config.include_common_models
|
||||
);
|
||||
assert_eq!(state.config.selected_model_keys, config.selected_model_keys);
|
||||
assert_eq!(
|
||||
state.config.excluded_common_model_keys,
|
||||
config.excluded_common_model_keys
|
||||
);
|
||||
assert_eq!(state.config.last_sync_at, Some(456));
|
||||
assert_eq!(state.config.last_sync_error, None);
|
||||
|
||||
record_models_dev_sync_result(db, None, Some("offline".to_string()))
|
||||
.expect("record failure");
|
||||
let state = get_models_dev_sync_state(db).expect("read failure state");
|
||||
assert_eq!(state.config.last_sync_at, Some(456));
|
||||
assert_eq!(state.config.last_sync_error.as_deref(), Some("offline"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -702,6 +702,469 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_gemini_common_config_strips_credentials_keeps_shareable() {
|
||||
// Gemini 的共享片段会被 deep-merge 回**其它** Gemini 供应商的 env
|
||||
// (live.rs::apply_common_config_to_settings),因此任何凭据都不得进入片段。
|
||||
// 之前这里只硬编码跳过 GEMINI_API_KEY/GOOGLE_GEMINI_BASE_URL,而
|
||||
// GOOGLE_API_KEY 是 provider.rs 认可的一等 Gemini 凭据 → 会泄露到别的供应商。
|
||||
let settings = json!({
|
||||
"env": {
|
||||
"GEMINI_API_KEY": "g-gem",
|
||||
"GOOGLE_API_KEY": "g-legacy-real-key",
|
||||
"GOOGLE_GEMINI_BASE_URL": "https://gemini.example",
|
||||
"GOOGLE_APPLICATION_CREDENTIALS": "/path/creds.json",
|
||||
"SOME_PROXY_AUTH_TOKEN": "tok-proxy",
|
||||
// 可共享的非机密配置必须保留
|
||||
"GEMINI_TIMEOUT_MS": "30000"
|
||||
}
|
||||
});
|
||||
|
||||
let snippet =
|
||||
ProviderService::extract_gemini_common_config(&settings).expect("extract should work");
|
||||
let value: Value = serde_json::from_str(&snippet).expect("snippet is valid JSON");
|
||||
|
||||
for leaked in [
|
||||
"GEMINI_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||
"SOME_PROXY_AUTH_TOKEN",
|
||||
] {
|
||||
assert!(
|
||||
value.get(leaked).is_none(),
|
||||
"credential {leaked} must not leak into the shared Gemini snippet"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
value.get("GEMINI_TIMEOUT_MS").and_then(|v| v.as_str()),
|
||||
Some("30000"),
|
||||
"shareable non-secret config must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
/// 造一个「已被污染」的现场:片段里带 A 账号的凭据 + 一个合法可共享键。
|
||||
#[test]
|
||||
fn sensitive_key_matcher_covers_common_credential_namings() {
|
||||
for key in [
|
||||
// 裸 `_KEY`:最常见的写法,却曾被"只枚举 `_API_KEY` 这些子类"漏在外面
|
||||
"OPENAI_KEY",
|
||||
"GROQ_KEY",
|
||||
"XAI_KEY",
|
||||
// 不带分隔符的复合写法
|
||||
"VOLC_ACCESSKEY",
|
||||
"ALIYUN_SECRETKEY",
|
||||
"SOME_APITOKEN",
|
||||
// personal access token:既不含 TOKEN 也不含 KEY
|
||||
"GITHUB_PAT",
|
||||
"gitlab_pat",
|
||||
// 口令类缩写
|
||||
"MYSQL_PWD",
|
||||
"DB_PASS",
|
||||
"GPG_PASSPHRASE",
|
||||
"AWS_CREDS",
|
||||
] {
|
||||
assert!(
|
||||
ProviderService::is_sensitive_config_key(key),
|
||||
"{key} must be treated as a credential"
|
||||
);
|
||||
}
|
||||
|
||||
// 后缀必须带下划线,不能把正常配置一起卷进来
|
||||
for key in [
|
||||
"PATH",
|
||||
"OLDPWD",
|
||||
"GEMINI_COMPAT",
|
||||
"SSL_BYPASS",
|
||||
"GEMINI_TIMEOUT_MS",
|
||||
"CLAUDE_CODE_MAX_OUTPUT_TOKENS",
|
||||
] {
|
||||
assert!(
|
||||
!ProviderService::is_sensitive_config_key(key),
|
||||
"{key} is ordinary shareable config and must not be stripped"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_leaked_gemini_state(db: &Arc<Database>) {
|
||||
db.set_config_snippet(
|
||||
"gemini",
|
||||
Some(
|
||||
json!({
|
||||
"GOOGLE_API_KEY": "key-A-leaked",
|
||||
"SOME_PROXY_AUTH_TOKEN": "tok-A-leaked",
|
||||
"GEMINI_TIMEOUT_MS": "30000"
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.expect("seed snippet");
|
||||
|
||||
// 受害者 B:泄漏的密钥已经被合并进它的 env
|
||||
let victim = Provider::with_id(
|
||||
"b".into(),
|
||||
"Relay B".into(),
|
||||
json!({ "env": {
|
||||
"GOOGLE_GEMINI_BASE_URL": "https://relay-b.example",
|
||||
"GOOGLE_API_KEY": "key-A-leaked",
|
||||
"GEMINI_TIMEOUT_MS": "30000"
|
||||
}}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("gemini", &victim).expect("save victim");
|
||||
|
||||
// 供应商 C:自己写了同名键但值不同,不能被误删
|
||||
let unrelated = Provider::with_id(
|
||||
"c".into(),
|
||||
"Own Key C".into(),
|
||||
json!({ "env": {
|
||||
"GOOGLE_GEMINI_BASE_URL": "https://c.example",
|
||||
"GOOGLE_API_KEY": "key-C-owned"
|
||||
}}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("gemini", &unrelated).expect("save c");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scrub_gemini_removes_leaked_credentials_from_snippet_and_providers() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let state = AppState::new(db.clone());
|
||||
seed_leaked_gemini_state(&db);
|
||||
|
||||
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||
.await
|
||||
.expect("scrub must succeed");
|
||||
|
||||
// 片段:凭据清掉,可共享配置保留
|
||||
let snippet = db
|
||||
.get_config_snippet("gemini")
|
||||
.expect("read snippet")
|
||||
.expect("snippet must still exist");
|
||||
let snippet: Value = serde_json::from_str(&snippet).expect("valid json");
|
||||
assert!(snippet.get("GOOGLE_API_KEY").is_none());
|
||||
assert!(snippet.get("SOME_PROXY_AUTH_TOKEN").is_none());
|
||||
assert_eq!(
|
||||
snippet.get("GEMINI_TIMEOUT_MS").and_then(Value::as_str),
|
||||
Some("30000"),
|
||||
"shareable config must survive the scrub"
|
||||
);
|
||||
|
||||
// 受害者 B:扩散过去的那一份被清掉
|
||||
let providers = db.get_all_providers("gemini").expect("providers");
|
||||
let victim_env = &providers["b"].settings_config["env"];
|
||||
assert!(
|
||||
victim_env.get("GOOGLE_API_KEY").is_none(),
|
||||
"leaked key must be removed from the victim provider"
|
||||
);
|
||||
assert_eq!(
|
||||
victim_env.get("GEMINI_TIMEOUT_MS").and_then(Value::as_str),
|
||||
Some("30000"),
|
||||
"non-credential config must not be touched"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scrub_gemini_keeps_a_providers_own_differently_valued_key() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let state = AppState::new(db.clone());
|
||||
seed_leaked_gemini_state(&db);
|
||||
|
||||
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||
.await
|
||||
.expect("scrub must succeed");
|
||||
|
||||
// 这条最容易写错成「按键名一刀切」:C 自己的密钥值与片段不同,是它自己的凭据
|
||||
let providers = db.get_all_providers("gemini").expect("providers");
|
||||
assert_eq!(
|
||||
providers["c"].settings_config["env"]
|
||||
.get("GOOGLE_API_KEY")
|
||||
.and_then(Value::as_str),
|
||||
Some("key-C-owned"),
|
||||
"a provider's own key must not be deleted by name matching"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scrub_gemini_audit_records_key_names_but_never_values() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let state = AppState::new(db.clone());
|
||||
seed_leaked_gemini_state(&db);
|
||||
|
||||
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||
.await
|
||||
.expect("scrub must succeed");
|
||||
|
||||
let audit_text = db
|
||||
.get_setting("gemini_common_config_scrub_audit_v1")
|
||||
.expect("read audit")
|
||||
.expect("an audit record must exist so the deletion is not silent");
|
||||
|
||||
// 值绝不能进这条记录:`settings` 会随 WebDAV/S3 同步上传,留值等于把一次
|
||||
// 清除换成一份跨设备扩散、没有界面入口、永不过期的明文副本。
|
||||
assert!(
|
||||
!audit_text.contains("key-A-leaked") && !audit_text.contains("tok-A-leaked"),
|
||||
"the audit record must never carry credential values: {audit_text}"
|
||||
);
|
||||
|
||||
// 但必须说清楚删了什么、从哪删的,否则用户只能靠翻日志
|
||||
let audit: Value = serde_json::from_str(&audit_text).expect("audit is JSON");
|
||||
let removed: Vec<&str> = audit["removedFromSnippet"]
|
||||
.as_array()
|
||||
.expect("removedFromSnippet array")
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
assert!(
|
||||
removed.contains(&"GOOGLE_API_KEY") && removed.contains(&"SOME_PROXY_AUTH_TOKEN"),
|
||||
"every key removed from the snippet must be named: {audit}"
|
||||
);
|
||||
let victim = audit["providers"]
|
||||
.as_array()
|
||||
.expect("providers array")
|
||||
.iter()
|
||||
.find(|entry| entry["id"] == json!("b"))
|
||||
.expect("every provider whose config gets rewritten must be recorded");
|
||||
assert_eq!(
|
||||
victim["removedKeys"],
|
||||
json!(["GOOGLE_API_KEY"]),
|
||||
"the record must name what was taken from each provider: {audit}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scrub_gemini_never_overwrites_an_existing_audit_record() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let state = AppState::new(db.clone());
|
||||
seed_leaked_gemini_state(&db);
|
||||
|
||||
// 上一轮改到一半就中止的情形:完成标记没置位,下次启动会重跑,但那时
|
||||
// 读到的"原始状态"已经残缺。无条件覆盖会拿残缺记录盖掉第一轮那份完整的。
|
||||
db.set_setting(
|
||||
"gemini_common_config_scrub_audit_v1",
|
||||
"{\"from\":\"an earlier, complete run\"}",
|
||||
)
|
||||
.expect("seed an existing audit record");
|
||||
|
||||
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||
.await
|
||||
.expect("scrub must succeed");
|
||||
|
||||
assert_eq!(
|
||||
db.get_setting("gemini_common_config_scrub_audit_v1")
|
||||
.expect("read audit")
|
||||
.as_deref(),
|
||||
Some("{\"from\":\"an earlier, complete run\"}"),
|
||||
"an audit record from an earlier run must survive a retry"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scrub_gemini_cleans_the_live_env_without_a_current_provider() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let state = AppState::new(db.clone());
|
||||
seed_leaked_gemini_state(&db);
|
||||
|
||||
// 没有当前供应商——这正是 sync_current_provider_for_app 直接返回 Ok 而
|
||||
// 根本不写文件的分支。此时 live 若清不掉,片段又已被清空,下次切换的
|
||||
// backfill 就会把残留永久写进受害供应商的配置。
|
||||
crate::gemini_config::write_gemini_env_atomic(&HashMap::from([
|
||||
("GOOGLE_API_KEY".to_string(), "key-A-leaked".to_string()),
|
||||
("GEMINI_TIMEOUT_MS".to_string(), "30000".to_string()),
|
||||
// 只存在于 live 的手工修改:定向删除必须保住它,全量重投影会抹掉
|
||||
(
|
||||
"HTTPS_PROXY".to_string(),
|
||||
"http://127.0.0.1:7890".to_string(),
|
||||
),
|
||||
]))
|
||||
.expect("seed live env");
|
||||
|
||||
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||
.await
|
||||
.expect("scrub must succeed");
|
||||
|
||||
let live = crate::gemini_config::read_gemini_env().expect("read live env");
|
||||
assert!(
|
||||
!live.contains_key("GOOGLE_API_KEY"),
|
||||
"the leaked credential must be gone from ~/.gemini/.env: {live:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
live.get("HTTPS_PROXY").map(String::as_str),
|
||||
Some("http://127.0.0.1:7890"),
|
||||
"a hand-added live-only var must survive targeted removal: {live:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scrub_gemini_live_cleanup_preserves_the_rest_of_the_env_file() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let state = AppState::new(db.clone());
|
||||
seed_leaked_gemini_state(&db);
|
||||
|
||||
// 这是一次用户没主动触发的启动期清理,不该顺手重写与泄漏无关的内容。
|
||||
// read→HashMap→write 的往返会把注释、空行、无法识别的行全丢掉并按键名重排。
|
||||
let original = "\
|
||||
# my own notes
|
||||
GOOGLE_API_KEY=key-C-owned
|
||||
|
||||
GOOGLE_API_KEY=key-A-leaked
|
||||
this line is not KEY=VALUE at all
|
||||
GEMINI_TIMEOUT_MS=30000
|
||||
";
|
||||
crate::gemini_config::write_gemini_env_text_atomic(original).expect("seed live env");
|
||||
|
||||
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||
.await
|
||||
.expect("scrub must succeed");
|
||||
|
||||
let raw = std::fs::read_to_string(crate::gemini_config::get_gemini_env_path())
|
||||
.expect("read live env");
|
||||
assert!(
|
||||
!raw.contains("key-A-leaked"),
|
||||
"the leaked line must be gone: {raw:?}"
|
||||
);
|
||||
assert!(
|
||||
raw.contains("# my own notes"),
|
||||
"comments must survive a targeted removal: {raw:?}"
|
||||
);
|
||||
assert!(
|
||||
raw.contains("this line is not KEY=VALUE at all"),
|
||||
"unparseable lines must survive a targeted removal: {raw:?}"
|
||||
);
|
||||
// 被泄漏值遮住的那条重新生效——正是想要的结果,遮住它的恰恰是泄漏值
|
||||
assert_eq!(
|
||||
crate::gemini_config::read_gemini_env()
|
||||
.expect("read live env")
|
||||
.get("GOOGLE_API_KEY")
|
||||
.map(String::as_str),
|
||||
Some("key-C-owned"),
|
||||
"only the matching line may be dropped: {raw:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scrub_gemini_aborts_before_clearing_the_snippet_when_the_live_backup_fails() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let state = AppState::new(db.clone());
|
||||
seed_leaked_gemini_state(&db);
|
||||
|
||||
// 关代理时这份快照会被原样写回 live。若清不动它却照样清了片段、置了完成标记,
|
||||
// 代理一停凭据就复活,而一次性标记保证不会再清第二次。
|
||||
db.save_live_backup("gemini", "}not json{")
|
||||
.await
|
||||
.expect("seed backup");
|
||||
|
||||
let result = ProviderService::scrub_leaked_gemini_common_config(&state).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a backup that cannot be cleaned must abort the scrub"
|
||||
);
|
||||
|
||||
// 片段是「该剥哪些键」的唯一知识来源,中止后必须原样留着,否则下次重试
|
||||
// 会因为 poison 为空而直接短路,反倒把标记置上
|
||||
let snippet = db
|
||||
.get_config_snippet("gemini")
|
||||
.expect("read snippet")
|
||||
.expect("snippet must still exist");
|
||||
assert!(
|
||||
snippet.contains("key-A-leaked"),
|
||||
"the snippet must be left intact so the next boot can retry: {snippet}"
|
||||
);
|
||||
assert!(
|
||||
db.get_setting("gemini_common_config_credentials_scrubbed_v1")
|
||||
.expect("read flag")
|
||||
.is_none(),
|
||||
"the one-shot flag must not be set when the scrub aborted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scrub_gemini_leaves_no_residue_for_backfill_to_persist() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let state = AppState::new(db.clone());
|
||||
seed_leaked_gemini_state(&db);
|
||||
|
||||
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||
.await
|
||||
.expect("scrub must succeed");
|
||||
|
||||
// 顺序陷阱回归:如果只清了片段,切走供应商时 remove_common_config_from_settings
|
||||
// 就不再认识这个键,live 里的残留会被 backfill 永久写进供应商配置。
|
||||
// 清理必须是原子的——清完之后,任何地方都不该再有那个值。
|
||||
let snippet = db
|
||||
.get_config_snippet("gemini")
|
||||
.expect("read snippet")
|
||||
.unwrap_or_default();
|
||||
assert!(!snippet.contains("key-A-leaked"));
|
||||
|
||||
for (id, provider) in db.get_all_providers("gemini").expect("providers") {
|
||||
assert!(
|
||||
!provider
|
||||
.settings_config
|
||||
.to_string()
|
||||
.contains("key-A-leaked"),
|
||||
"provider '{id}' still carries the leaked value"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scrub_gemini_is_idempotent_and_skips_on_second_run() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let state = AppState::new(db.clone());
|
||||
seed_leaked_gemini_state(&db);
|
||||
|
||||
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||
.await
|
||||
.expect("first run");
|
||||
|
||||
// 第二次必须是 no-op:用户清理后重新填的凭据不能被再抹一遍
|
||||
db.set_config_snippet(
|
||||
"gemini",
|
||||
Some(json!({"GOOGLE_API_KEY": "restored"}).to_string()),
|
||||
)
|
||||
.expect("user re-adds a value");
|
||||
|
||||
ProviderService::scrub_leaked_gemini_common_config(&state)
|
||||
.await
|
||||
.expect("second run");
|
||||
|
||||
let snippet = db
|
||||
.get_config_snippet("gemini")
|
||||
.expect("read snippet")
|
||||
.expect("snippet exists");
|
||||
assert!(
|
||||
snippet.contains("restored"),
|
||||
"the one-shot flag must prevent a second scrub: {snippet}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_claude_common_config_strips_all_credentials_keeps_shareable() {
|
||||
// env 混入多种凭据(Anthropic/OpenRouter/Google/OpenAI/Gemini + AWS/Vertex)
|
||||
@@ -3029,20 +3492,39 @@ impl ProviderService {
|
||||
/// `OPENROUTER_API_KEY` / `GOOGLE_API_KEY` 等回退)、各类 `*_AUTH_TOKEN` /
|
||||
/// 单数 `*_TOKEN`、AWS Bedrock / Vertex 凭据、以及通用 secret / password /
|
||||
/// 私钥命名。
|
||||
fn is_sensitive_config_key(name: &str) -> bool {
|
||||
pub(crate) fn is_sensitive_config_key(name: &str) -> bool {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
|
||||
// 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但**不**误伤复数 `_TOKENS`
|
||||
// (CLAUDE_CODE_MAX_OUTPUT_TOKENS / MAX_THINKING_TOKENS 是正常可共享配置)。
|
||||
const SENSITIVE_SUFFIXES: &[&str] = &[
|
||||
// 裸 `_KEY` 是最常见的凭据写法(OPENAI_KEY / GROQ_KEY / XAI_KEY…),
|
||||
// 必须单列:只枚举 `_API_KEY` / `_ACCESS_KEY` 这些子类,等于把最普通
|
||||
// 的那一种漏在外面。下面几条 `_*_KEY` 被它蕴含,保留是为了说明覆盖面。
|
||||
"_KEY",
|
||||
"_API_KEY",
|
||||
"_APIKEY",
|
||||
"_AUTH_TOKEN",
|
||||
"_TOKEN",
|
||||
"_ACCESS_KEY",
|
||||
"_ACCESS_KEY_ID",
|
||||
"_KEY_ID",
|
||||
"_PRIVATE_KEY",
|
||||
// 不带分隔符的复合写法各走各的后缀:`_KEY` 够不着 `..._APIKEY`
|
||||
// (倒数第四个字符是 I 不是下划线)。VOLC_ACCESSKEY 是火山引擎文档
|
||||
// 里的正式变量名,本仓库就实现了火山 AK/SK 用量查询。
|
||||
"_APIKEY",
|
||||
"_ACCESSKEY",
|
||||
"_SECRETKEY",
|
||||
"_APITOKEN",
|
||||
"_AUTH_TOKEN",
|
||||
"_TOKEN",
|
||||
// GITHUB_PAT / GITLAB_PAT 等 personal access token 的惯用写法,
|
||||
// 既不含 TOKEN 也不含 KEY,前面每一条规则都够不着。
|
||||
"_PAT",
|
||||
// 口令类的常见缩写。`_PASS` 不会误伤 `*_BYPASS`(那个以 `_BYPASS`
|
||||
// 结尾),`_PWD` 也不会误伤 shell 的 PWD / OLDPWD。
|
||||
"_PWD",
|
||||
"_PASS",
|
||||
"_PASSPHRASE",
|
||||
"_CREDS",
|
||||
];
|
||||
const SENSITIVE_EXACT: &[&str] = &[
|
||||
"APIKEY",
|
||||
@@ -3242,7 +3724,13 @@ impl ProviderService {
|
||||
let mut snippet = serde_json::Map::new();
|
||||
if let Some(env) = env {
|
||||
for (key, value) in env {
|
||||
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
|
||||
// 端点按名剥离(它不是凭据,模式匹配够不着);凭据全部交给
|
||||
// `is_sensitive_config_key` 统一模式匹配(与 Claude 提取器一致)。
|
||||
// 只列固定名单会漏掉下一个 `*_API_KEY` —— 例如 `GOOGLE_API_KEY`
|
||||
// (provider.rs 认可的一等 Gemini 凭据),而共享片段会被 deep-merge
|
||||
// 回其它 Gemini 供应商,漏剥即等于把 A 账号的密钥写进 B 供应商并
|
||||
// 发往 B 的 base_url。`GEMINI_API_KEY` 不必单列:`_KEY` 后缀已覆盖。
|
||||
if key == "GOOGLE_GEMINI_BASE_URL" || Self::is_sensitive_config_key(key) {
|
||||
continue;
|
||||
}
|
||||
let Value::String(v) = value else {
|
||||
@@ -3263,6 +3751,221 @@ impl ProviderService {
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// 一次性清理:把历史泄漏进 Gemini 共享片段的凭据从所有存储位置抹掉。
|
||||
///
|
||||
/// 背景:`extract_gemini_common_config` 曾只剥离两个固定键名,`GOOGLE_API_KEY`
|
||||
/// 等一等凭据会进入共享片段,再被 `apply_common_config_to_settings` 深合并进
|
||||
/// **其它** Gemini 供应商的 env,随请求发往对方的 base_url。
|
||||
///
|
||||
/// 光修提取器不够:Gemini 的片段一旦生成就**永不自动重提取**(启动期
|
||||
/// auto-extract 与导入后补提取都要求 `snippet.is_none()`,切换时的回写又只对
|
||||
/// Claude / Codex 生效),所以存量片段会一直带着密钥继续注入。
|
||||
///
|
||||
/// 两个关键约束:
|
||||
///
|
||||
/// 1. **不能只清片段**。合并与剥离是一对靠「值相等」严格抵消的操作:切走供应商时
|
||||
/// `remove_common_config_from_settings` 依据片段内容把注入的键删掉。片段里一旦
|
||||
/// 没了这个键,backfill 就会把 live 中残留的密钥原样写进受害供应商的
|
||||
/// `settings_config`——泄漏从瞬时污染变成永久污染。所以片段、各供应商配置、
|
||||
/// live 文件必须一起清。
|
||||
/// 2. **按值相等定向删除,不按键名一刀切**。复用 `remove_common_config_from_settings`
|
||||
/// 可以只清掉扩散出去的那一份,保留某个供应商自己写的、值不同的同名键。
|
||||
///
|
||||
/// 步骤顺序本身是安全属性的一部分:**清片段必须排在最后**。片段是
|
||||
/// `remove_common_config_from_settings` 唯一的"该剥哪些键"来源,一旦清空,任何
|
||||
/// 残留(live 文件里的、下一轮重试要处理的)都再也无法被识别和剥离。所以所有
|
||||
/// 可能失败的步骤都排在它前面,失败即带错返回,让下次启动能原样重来。
|
||||
///
|
||||
/// 清理后部分供应商会显示缺少 API Key,需用户重填——这是正确行为:那把密钥本就
|
||||
/// 不属于它们。(受害者原有的同名键在合并时已被覆盖,无法恢复。)动手前会往
|
||||
/// settings 的 `gemini_common_config_scrub_audit_v1` 写一条审计记录,内容是
|
||||
/// **键名与受影响的供应商 id,不含值**:`settings` 会随 WebDAV/S3 同步上传,
|
||||
/// 而这里处理的正是必须销毁的凭据,留值等于把一次清除换成一份跨设备扩散、
|
||||
/// 没有界面入口、永不过期的明文副本。
|
||||
pub async fn scrub_leaked_gemini_common_config(state: &AppState) -> Result<(), AppError> {
|
||||
const FLAG: &str = "gemini_common_config_credentials_scrubbed_v1";
|
||||
const AUDIT_KEY: &str = "gemini_common_config_scrub_audit_v1";
|
||||
let app = AppType::Gemini;
|
||||
|
||||
if state.db.get_bool_flag(FLAG).unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(snippet_text) = state.db.get_config_snippet(app.as_str())? else {
|
||||
state.db.set_setting(FLAG, "true")?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// 片段解析不了就不动它,只标记完成——乱改用户数据比留着更糟
|
||||
let Ok(Value::Object(entries)) = serde_json::from_str::<Value>(&snippet_text) else {
|
||||
state.db.set_setting(FLAG, "true")?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut poison = serde_json::Map::new();
|
||||
let mut clean = serde_json::Map::new();
|
||||
for (key, value) in entries {
|
||||
if Self::is_sensitive_config_key(&key) {
|
||||
poison.insert(key, value);
|
||||
} else {
|
||||
clean.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if poison.is_empty() {
|
||||
state.db.set_setting(FLAG, "true")?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"检测到 {} 个凭据键残留在 Gemini 通用配置片段中,开始一次性清理",
|
||||
poison.len()
|
||||
);
|
||||
|
||||
let poison_keys: Vec<String> = poison.keys().cloned().collect();
|
||||
let poison_value = Value::Object(poison);
|
||||
let poison_text = serde_json::to_string(&poison_value)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
|
||||
|
||||
// 1) 先算出各供应商清理后的配置,但**先不落库**
|
||||
let providers = state.db.get_all_providers(app.as_str())?;
|
||||
let mut pending: Vec<(String, Provider, Value)> = Vec::new();
|
||||
for (id, provider) in providers {
|
||||
let cleaned = match live::remove_common_config_from_settings(
|
||||
&app,
|
||||
&provider.settings_config,
|
||||
&poison_text,
|
||||
) {
|
||||
Ok(cleaned) => cleaned,
|
||||
Err(err) => {
|
||||
log::warn!("清理供应商 '{id}' 的泄漏凭据失败: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if cleaned != provider.settings_config {
|
||||
pending.push((id, provider, cleaned));
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 落库前留一份审计记录:**只记键名与受影响的供应商,不记值**。
|
||||
//
|
||||
// 「按值相等定向删除」在一种合法场景下也会命中:用户有意在多个供应商里
|
||||
// 复用同一把 key。所以必须留下"删了什么、从哪删的",否则用户只能靠翻
|
||||
// 日志。但不能留值——`settings` 表不在 `SYNC_SKIP_TABLES` 里,会随
|
||||
// WebDAV/S3 同步上传,而这里处理的恰恰是必须销毁的泄漏凭据:留值等于
|
||||
// 把一次清除换成一份没有界面入口、永不过期、还会跨设备扩散的明文副本。
|
||||
// 密钥本来就该轮换,可恢复性不值这个代价。
|
||||
let removed_env_keys = |before: &Value, after: &Value| -> Vec<String> {
|
||||
let before_env = before.get("env").and_then(Value::as_object);
|
||||
let after_env = after.get("env").and_then(Value::as_object);
|
||||
match (before_env, after_env) {
|
||||
(Some(before_env), Some(after_env)) => before_env
|
||||
.keys()
|
||||
.filter(|key| !after_env.contains_key(*key))
|
||||
.cloned()
|
||||
.collect(),
|
||||
(Some(before_env), None) => before_env.keys().cloned().collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
let audit = serde_json::json!({
|
||||
"removedFromSnippet": poison_keys,
|
||||
"providers": pending
|
||||
.iter()
|
||||
.map(|(id, provider, cleaned)| serde_json::json!({
|
||||
"id": id,
|
||||
"removedKeys": removed_env_keys(&provider.settings_config, cleaned),
|
||||
}))
|
||||
.collect::<Vec<_>>(),
|
||||
});
|
||||
let audit_text = serde_json::to_string(&audit)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
|
||||
// 只在没有记录时写。provider 的写入不是一个事务(每次 save_provider 各自
|
||||
// 提交),上一轮可能改到一半就中止;此时完成标记没置位,下次启动会重跑,
|
||||
// 而重跑看到的"原始状态"已经残缺。无条件 INSERT OR REPLACE 会拿这份残缺
|
||||
// 记录盖掉第一轮那份完整的。
|
||||
if state.db.get_setting(AUDIT_KEY)?.is_none() {
|
||||
state.db.set_setting(AUDIT_KEY, &audit_text)?;
|
||||
}
|
||||
|
||||
// 3) 各供应商 settings_config:按值相等定向删除扩散出去的副本
|
||||
for (id, provider, cleaned) in pending {
|
||||
let mut updated = provider;
|
||||
updated.settings_config = cleaned;
|
||||
state.db.save_provider(app.as_str(), &updated)?;
|
||||
log::info!("已从 Gemini 供应商 '{id}' 中清除泄漏的共享凭据");
|
||||
}
|
||||
|
||||
// 4) 代理接管中的 live 快照里也可能有一份副本。这一步的失败**必须传播**:
|
||||
//
|
||||
// 关代理时 `restore_live_config_for_app_with_fallback_inner`(proxy.rs:869)
|
||||
// 会把这份快照原样写回 `~/.gemini/.env`。若它仍带毒而我们照样清了片段、置了
|
||||
// 完成标记,那么代理一停凭据就当场复活,而一次性标记又保证不会再清第二次;
|
||||
// 此后片段里已没有这个键,下一次切换的 backfill 就把它永久写进受害供应商的
|
||||
// 配置——还是本函数开头那个顺序陷阱,只是换了扇门进来。
|
||||
//
|
||||
// 带错返回是安全的失败方式:调用方(lib.rs:1189)只记 warn 不中断启动,
|
||||
// 片段和标记都原样留着,下次启动照原样重来。
|
||||
if let Some(backup) = state.db.get_live_backup(app.as_str()).await? {
|
||||
let original: Value = serde_json::from_str(&backup.original_config)
|
||||
.map_err(|e| AppError::Message(format!("解析 Gemini 代理接管备份失败: {e}")))?;
|
||||
let cleaned = live::remove_common_config_from_settings(&app, &original, &poison_text)?;
|
||||
if cleaned != original {
|
||||
let text = serde_json::to_string(&cleaned)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
|
||||
state.db.save_live_backup(app.as_str(), &text).await?;
|
||||
log::info!("已从 Gemini 代理接管备份中清除泄漏的共享凭据");
|
||||
}
|
||||
}
|
||||
|
||||
// 5) `~/.gemini/.env`:**定向**删除,且必须在清片段之前做,失败即中止。
|
||||
//
|
||||
// 为什么不用 `sync_current_provider_for_app` 重投影:它在没有当前供应商
|
||||
// 时直接返回 Ok 而根本不写文件,泄漏值会原样留在 live 里;等片段被清空
|
||||
// 之后,下次切换时 `remove_common_config_from_settings` 再也认不出这个
|
||||
// 键,backfill 就把它永久写进受害供应商的配置——正是本函数开头说的那个
|
||||
// 顺序陷阱,只是由"没修"变成"修了一半更糟"。定向删除还顺带保住了只存在
|
||||
// 于 live、与供应商无关的手工 env(重投影会把它们抹掉)。
|
||||
//
|
||||
// 删除走 `remove_gemini_env_entries` 的**保序**实现而不是 read→HashMap→
|
||||
// write 往返:后者会顺手抹掉注释、空行和无法识别的行,并按键名重排整个
|
||||
// 文件。全量投影时那无所谓,但这里是一次用户没主动触发的启动期清理,不该
|
||||
// 连带改写与泄漏无关的内容。
|
||||
//
|
||||
// 失败就带着错误返回:片段此刻还留着毒键,完成标记也没置位,下次启动能
|
||||
// 照原样重来。清片段是不可逆的一步,必须排在所有会失败的步骤之后。
|
||||
let poison_env: HashMap<String, String> = poison_value
|
||||
.as_object()
|
||||
.map(|map| {
|
||||
map.iter()
|
||||
.filter_map(|(key, value)| {
|
||||
value.as_str().map(|text| (key.clone(), text.to_string()))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if crate::gemini_config::remove_gemini_env_entries(&poison_env)? {
|
||||
log::info!("已从 ~/.gemini/.env 中清除泄漏的共享凭据");
|
||||
}
|
||||
|
||||
// 6) 片段本身:保留可共享的部分。全部清空时删行而不是写 "{}"——留着空行会让
|
||||
// should_auto_extract_config_snippet 永远为 false,用户的合法共享配置再也
|
||||
// 重建不回来。同理绝不置 cleared 标记。
|
||||
if clean.is_empty() {
|
||||
state.db.set_config_snippet(app.as_str(), None)?;
|
||||
} else {
|
||||
let cleaned_snippet = serde_json::to_string_pretty(&Value::Object(clean))
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(app.as_str(), Some(cleaned_snippet))?;
|
||||
}
|
||||
|
||||
state.db.set_setting(FLAG, "true")?;
|
||||
log::info!("Gemini 通用配置凭据清理完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract common config for OpenCode (JSON format)
|
||||
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
// OpenCode uses a different config structure with npm, options, models
|
||||
|
||||
@@ -29,9 +29,17 @@ use rust_decimal::Decimal;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::SystemTime;
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
FileIdInfo, GetFileInformationByHandleEx, FILE_ID_INFO,
|
||||
};
|
||||
|
||||
const CODEX_THREAD_REQUEST_ID_PREFIX: &str = "codex_session:thread-v1";
|
||||
|
||||
@@ -72,6 +80,115 @@ struct TokenUsageSignature {
|
||||
last: Option<TokenCountersSignature>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TimestampedTokenSignature {
|
||||
timestamp: DateTime<Utc>,
|
||||
signature: TokenUsageSignature,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct ParentFileStamp {
|
||||
modified_nanos: i64,
|
||||
size: u64,
|
||||
#[cfg(unix)]
|
||||
device: u64,
|
||||
#[cfg(unix)]
|
||||
inode: u64,
|
||||
#[cfg(windows)]
|
||||
volume_serial: u64,
|
||||
#[cfg(windows)]
|
||||
file_id: [u8; 16],
|
||||
}
|
||||
|
||||
impl ParentFileStamp {
|
||||
fn from_file(file: &fs::File) -> Option<Self> {
|
||||
let metadata = file.metadata().ok()?;
|
||||
#[cfg(windows)]
|
||||
let (volume_serial, file_id) = windows_file_identity(file)?;
|
||||
Some(Self {
|
||||
modified_nanos: metadata_modified_nanos(&metadata),
|
||||
size: metadata.len(),
|
||||
#[cfg(unix)]
|
||||
device: metadata.dev(),
|
||||
#[cfg(unix)]
|
||||
inode: metadata.ino(),
|
||||
#[cfg(windows)]
|
||||
volume_serial,
|
||||
#[cfg(windows)]
|
||||
file_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_file_identity(file: &fs::File) -> Option<(u64, [u8; 16])> {
|
||||
let mut information = FILE_ID_INFO::default();
|
||||
// SAFETY: `file` owns a live handle for this call, and `information` is a
|
||||
// valid writable FILE_ID_INFO buffer of the size passed to Windows.
|
||||
let succeeded = unsafe {
|
||||
GetFileInformationByHandleEx(
|
||||
file.as_raw_handle(),
|
||||
FileIdInfo,
|
||||
std::ptr::addr_of_mut!(information).cast(),
|
||||
std::mem::size_of::<FILE_ID_INFO>() as u32,
|
||||
)
|
||||
} != 0;
|
||||
succeeded.then_some((
|
||||
information.VolumeSerialNumber,
|
||||
information.FileId.Identifier,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ParentTokenTimeline {
|
||||
events: Vec<TimestampedTokenSignature>,
|
||||
max_timestamp: Option<DateTime<Utc>>,
|
||||
has_token_without_timestamp: bool,
|
||||
}
|
||||
|
||||
impl ParentTokenTimeline {
|
||||
fn signatures_before(
|
||||
&self,
|
||||
parent_path: &Path,
|
||||
cutoff: DateTime<Utc>,
|
||||
) -> Result<Vec<TokenUsageSignature>, String> {
|
||||
if self.has_token_without_timestamp {
|
||||
return Err(format!(
|
||||
"父 rollout {} 的 token_count 缺少有效 timestamp",
|
||||
parent_path.display()
|
||||
));
|
||||
}
|
||||
if self
|
||||
.max_timestamp
|
||||
.is_none_or(|timestamp| timestamp < cutoff)
|
||||
{
|
||||
return Err(format!(
|
||||
"父 rollout {} 尚未写到 child fork 时刻",
|
||||
parent_path.display()
|
||||
));
|
||||
}
|
||||
Ok(self
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.timestamp <= cutoff)
|
||||
.map(|event| event.signature.clone())
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CachedParentTimeline {
|
||||
stamp: ParentFileStamp,
|
||||
timeline: Arc<ParentTokenTimeline>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CachedReplayPrefix {
|
||||
modified: i64,
|
||||
size: u64,
|
||||
prefix: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ParsedTokenEvent {
|
||||
line_offset: i64,
|
||||
@@ -116,8 +233,8 @@ struct PendingEntry {
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct CodexReplayCaches {
|
||||
parent_signatures: HashMap<(PathBuf, i64), Vec<TokenUsageSignature>>,
|
||||
replay_prefixes: HashMap<(PathBuf, i64, u64), usize>,
|
||||
parent_timelines: HashMap<PathBuf, CachedParentTimeline>,
|
||||
replay_prefixes: HashMap<PathBuf, CachedReplayPrefix>,
|
||||
pending: HashMap<PathBuf, PendingEntry>,
|
||||
}
|
||||
|
||||
@@ -757,20 +874,28 @@ fn parent_signatures_before(
|
||||
parent_path: &Path,
|
||||
cutoff: DateTime<Utc>,
|
||||
) -> Result<Vec<TokenUsageSignature>, String> {
|
||||
let cache_key = (parent_path.to_path_buf(), cutoff.timestamp_micros());
|
||||
if let Ok(caches) = replay_caches().lock() {
|
||||
if let Some(signatures) = caches.parent_signatures.get(&cache_key) {
|
||||
return Ok(signatures.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let file = fs::File::open(parent_path)
|
||||
.map_err(|error| format!("无法打开父 rollout {}: {error}", parent_path.display()))?;
|
||||
let mut signatures = Vec::new();
|
||||
let mut max_timestamp: Option<DateTime<Utc>> = None;
|
||||
let stamp = ParentFileStamp::from_file(&file);
|
||||
let cached_timeline = stamp.and_then(|stamp| {
|
||||
replay_caches().lock().ok().and_then(|caches| {
|
||||
caches
|
||||
.parent_timelines
|
||||
.get(parent_path)
|
||||
.filter(|entry| entry.stamp == stamp)
|
||||
.map(|entry| Arc::clone(&entry.timeline))
|
||||
})
|
||||
});
|
||||
if let Some(timeline) = cached_timeline {
|
||||
return timeline.signatures_before(parent_path, cutoff);
|
||||
}
|
||||
|
||||
// 必须扫描完整父文件并逐行应用 cutoff,不能在首个未来时间戳处 break:
|
||||
// rollout 写入顺序不承诺时间戳严格单调。
|
||||
let mut events = Vec::new();
|
||||
let mut max_timestamp: Option<DateTime<Utc>> = None;
|
||||
let mut has_token_without_timestamp = false;
|
||||
|
||||
// 必须扫描完整父文件,不能在首个未来时间戳处 break:rollout 写入顺序
|
||||
// 不承诺时间戳严格单调。缓存完整时间线后,不同 child cutoff 只需内存过滤。
|
||||
for line in BufReader::new(file).lines() {
|
||||
let Ok(line) = line else {
|
||||
continue;
|
||||
@@ -802,29 +927,31 @@ fn parent_signatures_before(
|
||||
continue;
|
||||
};
|
||||
let Some(timestamp) = timestamp else {
|
||||
return Err(format!(
|
||||
"父 rollout {} 的 token_count 缺少有效 timestamp",
|
||||
parent_path.display()
|
||||
));
|
||||
has_token_without_timestamp = true;
|
||||
continue;
|
||||
};
|
||||
if timestamp <= cutoff {
|
||||
signatures.push(signature);
|
||||
}
|
||||
events.push(TimestampedTokenSignature {
|
||||
timestamp,
|
||||
signature,
|
||||
});
|
||||
}
|
||||
|
||||
if max_timestamp.is_none_or(|timestamp| timestamp < cutoff) {
|
||||
return Err(format!(
|
||||
"父 rollout {} 尚未写到 child fork 时刻",
|
||||
parent_path.display()
|
||||
));
|
||||
let timeline = Arc::new(ParentTokenTimeline {
|
||||
events,
|
||||
max_timestamp,
|
||||
has_token_without_timestamp,
|
||||
});
|
||||
let result = timeline.signatures_before(parent_path, cutoff);
|
||||
if let (Some(stamp), Ok(mut caches)) = (stamp, replay_caches().lock()) {
|
||||
caches.parent_timelines.insert(
|
||||
parent_path.to_path_buf(),
|
||||
CachedParentTimeline {
|
||||
stamp,
|
||||
timeline: Arc::clone(&timeline),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(mut caches) = replay_caches().lock() {
|
||||
caches
|
||||
.parent_signatures
|
||||
.insert(cache_key, signatures.clone());
|
||||
}
|
||||
Ok(signatures)
|
||||
result
|
||||
}
|
||||
|
||||
fn resolve_parent_signatures(
|
||||
@@ -993,10 +1120,14 @@ fn sync_single_codex_file(
|
||||
),
|
||||
));
|
||||
};
|
||||
let cache_key = (file_path.to_path_buf(), file_modified, file_size);
|
||||
if let Ok(caches) = replay_caches().lock() {
|
||||
if let Some(prefix) = caches.replay_prefixes.get(&cache_key) {
|
||||
*prefix
|
||||
if let Some(prefix) = caches
|
||||
.replay_prefixes
|
||||
.get(file_path)
|
||||
.filter(|cached| cached.modified == file_modified && cached.size == file_size)
|
||||
.map(|cached| cached.prefix)
|
||||
{
|
||||
prefix
|
||||
} else {
|
||||
drop(caches);
|
||||
let parent_signatures =
|
||||
@@ -1018,7 +1149,14 @@ fn sync_single_codex_file(
|
||||
};
|
||||
let prefix = matching_replay_prefix(&parsed.token_events, &parent_signatures);
|
||||
if let Ok(mut caches) = replay_caches().lock() {
|
||||
caches.replay_prefixes.insert(cache_key, prefix);
|
||||
caches.replay_prefixes.insert(
|
||||
file_path.to_path_buf(),
|
||||
CachedReplayPrefix {
|
||||
modified: file_modified,
|
||||
size: file_size,
|
||||
prefix,
|
||||
},
|
||||
);
|
||||
}
|
||||
prefix
|
||||
}
|
||||
@@ -1285,6 +1423,15 @@ mod tests {
|
||||
token_count_at(input, cached, output, "2026-07-10T03:00:02Z")
|
||||
}
|
||||
|
||||
fn token_count_without_timestamp(input: u64, cached: u64, output: u64) -> serde_json::Value {
|
||||
let mut value = token_count(input, cached, output);
|
||||
value
|
||||
.as_object_mut()
|
||||
.expect("token_count must be an object")
|
||||
.remove("timestamp");
|
||||
value
|
||||
}
|
||||
|
||||
fn sync_test_file(
|
||||
db: &Database,
|
||||
file: &Path,
|
||||
@@ -1407,6 +1554,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_thread_spawn_parent_strips_replay_and_keeps_live_usage() -> Result<(), AppError> {
|
||||
clear_codex_replay_caches();
|
||||
let db = Database::memory()?;
|
||||
@@ -1449,6 +1597,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_filtered_parent_events_use_subsequence_prefix_alignment() -> Result<(), AppError> {
|
||||
clear_codex_replay_caches();
|
||||
let db = Database::memory()?;
|
||||
@@ -1481,6 +1630,158 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_parent_rollout_is_cached_once_across_fork_cutoffs() -> Result<(), AppError> {
|
||||
clear_codex_replay_caches();
|
||||
let temp = tempdir().unwrap();
|
||||
let parent = rollout_path(temp.path(), PARENT_ID);
|
||||
write_jsonl(
|
||||
&parent,
|
||||
&[
|
||||
session_meta(PARENT_ID),
|
||||
token_count_at(100, 50, 10, "2026-07-10T03:00:01Z"),
|
||||
token_count_at(200, 100, 20, "2026-07-10T03:00:10Z"),
|
||||
turn_context_at("2026-07-10T03:00:20Z"),
|
||||
],
|
||||
);
|
||||
|
||||
let early = "2026-07-10T03:00:05Z".parse::<DateTime<Utc>>().unwrap();
|
||||
let late = "2026-07-10T03:00:15Z".parse::<DateTime<Utc>>().unwrap();
|
||||
assert_eq!(parent_signatures_before(&parent, early).unwrap().len(), 1);
|
||||
let first_timeline =
|
||||
Arc::clone(&replay_caches().lock().unwrap().parent_timelines[&parent].timeline);
|
||||
assert_eq!(parent_signatures_before(&parent, late).unwrap().len(), 2);
|
||||
|
||||
let caches = replay_caches().lock().unwrap();
|
||||
assert_eq!(caches.parent_timelines.len(), 1);
|
||||
assert!(Arc::ptr_eq(
|
||||
&first_timeline,
|
||||
&caches.parent_timelines[&parent].timeline
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_parent_rollout_cache_invalidates_after_append() -> Result<(), AppError> {
|
||||
clear_codex_replay_caches();
|
||||
let temp = tempdir().unwrap();
|
||||
let parent = rollout_path(temp.path(), PARENT_ID);
|
||||
let cutoff = "2026-07-10T03:00:15Z".parse::<DateTime<Utc>>().unwrap();
|
||||
write_jsonl(
|
||||
&parent,
|
||||
&[
|
||||
session_meta(PARENT_ID),
|
||||
token_count_at(100, 50, 10, "2026-07-10T03:00:01Z"),
|
||||
turn_context_at("2026-07-10T03:00:20Z"),
|
||||
],
|
||||
);
|
||||
assert_eq!(parent_signatures_before(&parent, cutoff).unwrap().len(), 1);
|
||||
|
||||
write_jsonl(
|
||||
&parent,
|
||||
&[
|
||||
session_meta(PARENT_ID),
|
||||
token_count_at(100, 50, 10, "2026-07-10T03:00:01Z"),
|
||||
token_count_at(200, 100, 20, "2026-07-10T03:00:10Z"),
|
||||
turn_context_at("2026-07-10T03:00:20Z"),
|
||||
],
|
||||
);
|
||||
assert_eq!(parent_signatures_before(&parent, cutoff).unwrap().len(), 2);
|
||||
|
||||
let caches = replay_caches().lock().unwrap();
|
||||
assert_eq!(caches.parent_timelines.len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_parent_rollout_content_error_cache_preserves_open_errors() {
|
||||
clear_codex_replay_caches();
|
||||
let temp = tempdir().unwrap();
|
||||
let parent = rollout_path(temp.path(), PARENT_ID);
|
||||
let cutoff = "2026-07-10T03:00:05Z".parse::<DateTime<Utc>>().unwrap();
|
||||
write_jsonl(
|
||||
&parent,
|
||||
&[
|
||||
session_meta(PARENT_ID),
|
||||
token_count_without_timestamp(100, 50, 10),
|
||||
turn_context_at("2026-07-10T03:00:20Z"),
|
||||
],
|
||||
);
|
||||
|
||||
let first_error = parent_signatures_before(&parent, cutoff).unwrap_err();
|
||||
assert!(first_error.contains("token_count 缺少有效 timestamp"));
|
||||
let cached_timeline =
|
||||
|| Arc::clone(&replay_caches().lock().unwrap().parent_timelines[&parent].timeline);
|
||||
let first_timeline = cached_timeline();
|
||||
|
||||
let second_error = parent_signatures_before(&parent, cutoff).unwrap_err();
|
||||
assert_eq!(second_error, first_error);
|
||||
assert!(Arc::ptr_eq(&first_timeline, &cached_timeline()));
|
||||
|
||||
fs::remove_file(&parent).unwrap();
|
||||
let open_error = parent_signatures_before(&parent, cutoff).unwrap_err();
|
||||
assert!(open_error.contains("无法打开父 rollout"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_parent_rollout_nanosecond_cutoffs_are_exact() {
|
||||
clear_codex_replay_caches();
|
||||
let temp = tempdir().unwrap();
|
||||
let parent = rollout_path(temp.path(), PARENT_ID);
|
||||
write_jsonl(
|
||||
&parent,
|
||||
&[
|
||||
session_meta(PARENT_ID),
|
||||
token_count_at(100, 50, 10, "2026-07-10T03:00:00.000000500Z"),
|
||||
turn_context_at("2026-07-10T03:00:00.000000900Z"),
|
||||
],
|
||||
);
|
||||
|
||||
let before = "2026-07-10T03:00:00.000000300Z"
|
||||
.parse::<DateTime<Utc>>()
|
||||
.unwrap();
|
||||
let after = "2026-07-10T03:00:00.000000700Z"
|
||||
.parse::<DateTime<Utc>>()
|
||||
.unwrap();
|
||||
assert!(parent_signatures_before(&parent, before)
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
assert_eq!(parent_signatures_before(&parent, after).unwrap().len(), 1);
|
||||
assert_eq!(replay_caches().lock().unwrap().parent_timelines.len(), 1);
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn test_parent_file_stamp_distinguishes_same_size_same_mtime_files() {
|
||||
let temp = tempdir().unwrap();
|
||||
let parent = rollout_path(temp.path(), PARENT_ID);
|
||||
let replacement = temp.path().join("replacement.jsonl");
|
||||
let values = [session_meta(PARENT_ID), token_count(100, 50, 10)];
|
||||
write_jsonl(&parent, &values);
|
||||
write_jsonl(&replacement, &values);
|
||||
let original_file = fs::File::open(&parent).unwrap();
|
||||
let original_metadata = original_file.metadata().unwrap();
|
||||
let replacement_file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&replacement)
|
||||
.unwrap();
|
||||
replacement_file
|
||||
.set_times(fs::FileTimes::new().set_modified(original_metadata.modified().unwrap()))
|
||||
.unwrap();
|
||||
let original_stamp = ParentFileStamp::from_file(&original_file).unwrap();
|
||||
let replacement_stamp = ParentFileStamp::from_file(&replacement_file).unwrap();
|
||||
assert_eq!(
|
||||
(original_stamp.size, original_stamp.modified_nanos),
|
||||
(replacement_stamp.size, replacement_stamp.modified_nanos)
|
||||
);
|
||||
assert_ne!(original_stamp, replacement_stamp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_empty_fork_imports_no_parent_usage() -> Result<(), AppError> {
|
||||
clear_codex_replay_caches();
|
||||
let db = Database::memory()?;
|
||||
@@ -1526,6 +1827,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_conflicting_explicit_parents_are_deferred() -> Result<(), AppError> {
|
||||
clear_codex_replay_caches();
|
||||
let db = Database::memory()?;
|
||||
@@ -1551,6 +1853,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_parent_future_signature_cannot_extend_replay_prefix() -> Result<(), AppError> {
|
||||
clear_codex_replay_caches();
|
||||
let db = Database::memory()?;
|
||||
@@ -1582,6 +1885,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_missing_parent_is_deferred_and_recovered_without_child_change() -> Result<(), AppError>
|
||||
{
|
||||
clear_codex_replay_caches();
|
||||
@@ -1615,6 +1919,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_billable_file_without_meta_is_deferred_without_cursor() -> Result<(), AppError> {
|
||||
clear_codex_replay_caches();
|
||||
let db = Database::memory()?;
|
||||
@@ -1641,6 +1946,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_non_billable_file_without_meta_advances_cursor() -> Result<(), AppError> {
|
||||
clear_codex_replay_caches();
|
||||
let db = Database::memory()?;
|
||||
@@ -1661,6 +1967,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_subagents_use_filename_thread_ids() -> Result<(), AppError> {
|
||||
clear_codex_replay_caches();
|
||||
let db = Database::memory()?;
|
||||
|
||||
+1445
-140
File diff suppressed because it is too large
Load Diff
@@ -286,11 +286,21 @@ fn launch_custom(
|
||||
}
|
||||
|
||||
let cmd_str = command;
|
||||
let dir_str = cwd.unwrap_or(".");
|
||||
// `{cwd}` 是磁盘上扫来的路径,先做转义;`{command}` 保持原样——模板作者写下
|
||||
// 这个占位符的本意就是让它当命令展开。
|
||||
//
|
||||
// ⚠️ 这里的转义**不是完备防护**,只在占位符处于未加引号的 shell 词位置时成立。
|
||||
// 模板若写成 `echo "{cwd}"`,插入的单引号会落进双引号里变成普通字符,`cwd`
|
||||
// 里的 `$(...)` 照样求值。安全性取决于模板怎么写,而模板不由这里控制。
|
||||
//
|
||||
// 目前本分支无 UI 入口(终端选项列表没有 `custom`,前端也从不传
|
||||
// `customConfig`),所以不可达。**接线前必须换掉这个方案**——正确做法是让
|
||||
// 模板声明参数位、由此处按 argv 传递,而不是让用户拼 shell 字符串。
|
||||
let dir_str = shell_escape(cwd.unwrap_or("."));
|
||||
|
||||
let final_cmd_line = template
|
||||
.replace("{command}", cmd_str)
|
||||
.replace("{cwd}", dir_str);
|
||||
.replace("{cwd}", &dir_str);
|
||||
|
||||
// Execute via sh -c
|
||||
let status = Command::new("sh")
|
||||
@@ -315,9 +325,16 @@ fn build_shell_command(command: &str, cwd: Option<&str>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// POSIX 单引号转义。
|
||||
///
|
||||
/// **必须是单引号**:双引号内 `$(...)`、反引号、`$VAR` 照常展开,而这里包的是
|
||||
/// `projectDir`——会话历史里记录的真实项目路径,macOS 允许目录名含 `$` `(` `)`,
|
||||
/// 所以一个名为 `$(...)` 的目录就足以让命令替换在用户终端里执行。
|
||||
///
|
||||
/// 单引号内不做任何展开,唯一的特例是 `'` 自身无法被表示:用「闭合-转义-重开」
|
||||
/// 的 `'\''` 序列绕过。
|
||||
fn shell_escape(value: &str) -> String {
|
||||
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
format!("\"{escaped}\"")
|
||||
format!("'{}'", value.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
fn escape_osascript(value: &str) -> String {
|
||||
@@ -377,6 +394,47 @@ mod tests {
|
||||
);
|
||||
|
||||
// Verify shell_escape works correctly for paths with spaces
|
||||
assert_eq!(shell_escape(cwd), "\"/tmp/project dir\"");
|
||||
assert_eq!(shell_escape(cwd), "'/tmp/project dir'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_escape_neutralizes_command_substitution_in_directory_names() {
|
||||
// 这些字符在 macOS 目录名里全部合法,而 `cwd` 就是会话历史里的
|
||||
// `projectDir`——一个名为 `$(...)` 的目录必须原样落到 `cd` 后面,
|
||||
// 不能被 shell 求值。旧的双引号实现对这三种全部失守。
|
||||
assert_eq!(shell_escape("/tmp/$(id -un)"), "'/tmp/$(id -un)'");
|
||||
assert_eq!(shell_escape("/tmp/`id -un`"), "'/tmp/`id -un`'");
|
||||
assert_eq!(shell_escape("/tmp/$HOME"), "'/tmp/$HOME'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_escape_handles_embedded_single_quote() {
|
||||
// 单引号是单引号包裹法唯一表示不了的字符,靠「闭合-转义-重开」绕过。
|
||||
assert_eq!(shell_escape("/tmp/it's"), r"'/tmp/it'\''s'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_escape_survives_the_osascript_layer() {
|
||||
// Terminal / iTerm 的链路是两层:shell_escape 的结果先被塞进 AppleScript
|
||||
// 字符串字面量,由 escape_osascript 再转义一次,AppleScript 求值后才交给
|
||||
// shell。反斜杠会在中间那层被加倍,必须确认最终落到 shell 的字节没变形。
|
||||
let escaped = shell_escape("/tmp/it's");
|
||||
assert_eq!(escaped, r"'/tmp/it'\''s'");
|
||||
|
||||
let for_applescript = escape_osascript(&escaped);
|
||||
assert_eq!(for_applescript, r"'/tmp/it'\\''s'");
|
||||
|
||||
// AppleScript 把 `\\` 求值回单个 `\`,于是 shell 拿到的正是 escaped 本身。
|
||||
assert_eq!(for_applescript.replace(r"\\", r"\"), escaped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_shell_command_quotes_the_cwd_it_prefixes() {
|
||||
// Terminal / iTerm / kitty 三条路径都经这里;ghostty / wezterm / alacritty
|
||||
// 走 `cwd = None` 并把目录当独立 argv 传,不受影响。
|
||||
assert_eq!(
|
||||
build_shell_command("claude --resume x", Some("/tmp/$(id -un)")),
|
||||
"cd '/tmp/$(id -un)' && claude --resume x"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.18.0",
|
||||
"version": "3.19.0",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+6
-12
@@ -29,7 +29,7 @@ import {
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import type { Provider, VisibleApps } from "@/types";
|
||||
import type { EnvConflict } from "@/types/env";
|
||||
import { useProvidersQuery, useSettingsQuery } from "@/lib/query";
|
||||
import { proxyKeys, useProvidersQuery, useSettingsQuery } from "@/lib/query";
|
||||
import {
|
||||
providersApi,
|
||||
settingsApi,
|
||||
@@ -42,7 +42,6 @@ import { openclawKeys, useOpenClawHealth } from "@/hooks/useOpenClaw";
|
||||
import { hermesKeys, useOpenHermesWebUI } from "@/hooks/useHermes";
|
||||
import { hermesApi } from "@/lib/api/hermes";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { useAutoCompact } from "@/hooks/useAutoCompact";
|
||||
import { useUsageCacheBridge } from "@/hooks/useUsageCacheBridge";
|
||||
import { useTauriEvent } from "@/hooks/useTauriEvent";
|
||||
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||
@@ -247,9 +246,6 @@ function App() {
|
||||
const effectiveEditingProvider = useLastValidValue(editingProvider);
|
||||
const effectiveUsageProvider = useLastValidValue(usageProvider);
|
||||
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const isToolbarCompact = useAutoCompact(toolbarRef);
|
||||
|
||||
useUsageCacheBridge();
|
||||
|
||||
const promptPanelRef = useRef<any>(null);
|
||||
@@ -393,8 +389,10 @@ function App() {
|
||||
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: proxyKeys.takeoverStatus,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: proxyKeys.status });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["providers", "claude-desktop"],
|
||||
});
|
||||
@@ -1277,10 +1275,7 @@ function App() {
|
||||
<ProfileSwitcher activeApp={activeApp} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className="flex flex-1 min-w-0 overflow-x-hidden items-center py-4 pr-2"
|
||||
>
|
||||
<div 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"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
@@ -1399,7 +1394,6 @@ function App() {
|
||||
activeApp={activeApp}
|
||||
onSwitch={setActiveApp}
|
||||
visibleApps={visibleApps}
|
||||
compact={isToolbarCompact}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||
|
||||
@@ -15,7 +15,6 @@ interface AppSwitcherProps {
|
||||
activeApp: AppId;
|
||||
onSwitch: (app: AppId) => void;
|
||||
visibleApps?: VisibleApps;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const ALL_APPS: AppId[] = [
|
||||
@@ -34,7 +33,6 @@ export function AppSwitcher({
|
||||
activeApp,
|
||||
onSwitch,
|
||||
visibleApps,
|
||||
compact,
|
||||
}: AppSwitcherProps) {
|
||||
const handleSwitch = (app: AppId) => {
|
||||
if (app === activeApp) return;
|
||||
@@ -80,6 +78,8 @@ export function AppSwitcher({
|
||||
key={app}
|
||||
type="button"
|
||||
onClick={() => handleSwitch(app)}
|
||||
title={appDisplayName[app]}
|
||||
aria-label={appDisplayName[app]}
|
||||
className={cn(
|
||||
"group inline-flex items-center px-3 h-8 rounded-md text-sm font-medium transition-all duration-200",
|
||||
isActive
|
||||
@@ -115,16 +115,6 @@ export function AppSwitcher({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"transition-all duration-200 whitespace-nowrap overflow-hidden",
|
||||
compact
|
||||
? "max-w-0 opacity-0 ml-0"
|
||||
: "max-w-[120px] opacity-100 ml-2",
|
||||
)}
|
||||
>
|
||||
{appDisplayName[app]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ColorPickerProps {
|
||||
value?: string;
|
||||
onValueChange: (color: string) => void;
|
||||
label?: string;
|
||||
presets?: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_PRESETS = [
|
||||
"#00A67E",
|
||||
"#D4915D",
|
||||
"#4285F4",
|
||||
"#FF6A00",
|
||||
"#00A4FF",
|
||||
"#FF9900",
|
||||
"#0078D4",
|
||||
"#FF0000",
|
||||
"#1E88E5",
|
||||
"#6366F1",
|
||||
"#0F62FE",
|
||||
"#2932E1",
|
||||
];
|
||||
|
||||
export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
value = "#4285F4",
|
||||
onValueChange,
|
||||
label,
|
||||
presets = DEFAULT_PRESETS,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const displayLabel = label ?? t("providerIcon.color", "图标颜色");
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label>{displayLabel}</Label>
|
||||
|
||||
{/* 颜色预设 */}
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{presets.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => onValueChange(color)}
|
||||
className={cn(
|
||||
"w-full aspect-square rounded-lg border-2 transition-all",
|
||||
"hover:scale-110 hover:shadow-lg",
|
||||
value === color
|
||||
? "border-primary ring-2 ring-primary/20"
|
||||
: "border-border",
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 自定义颜色输入 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
className="w-16 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
placeholder="#4285F4"
|
||||
className="flex-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -17,6 +17,14 @@ import { PromptConfirmation } from "./deeplink/PromptConfirmation";
|
||||
import { McpConfirmation } from "./deeplink/McpConfirmation";
|
||||
import { SkillConfirmation } from "./deeplink/SkillConfirmation";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import {
|
||||
classifyEndpoint,
|
||||
classifyEnvKey,
|
||||
decodeDeeplinkPayload,
|
||||
maskValue,
|
||||
riskI18nKey,
|
||||
} from "@/utils/deeplinkRisk";
|
||||
import { decodeBase64Utf8 } from "@/lib/utils/base64";
|
||||
|
||||
interface DeeplinkError {
|
||||
url: string;
|
||||
@@ -277,16 +285,28 @@ export function DeepLinkImportDialog() {
|
||||
}
|
||||
}, [request?.config, request?.app]);
|
||||
|
||||
// Helper to mask sensitive values
|
||||
const maskValue = (key: string, value: string): string => {
|
||||
const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"];
|
||||
const isSensitive = sensitiveKeys.some((k) =>
|
||||
key.toUpperCase().includes(k),
|
||||
/**
|
||||
* env 行:值经 `maskValue` 脱敏,键命中加载器控制变量时标记。
|
||||
*
|
||||
* `break-all` 而非 `truncate`——被截断的值等于没展示。
|
||||
*/
|
||||
const EnvRow = ({ envKey, value }: { envKey: string; value: string }) => {
|
||||
const risk = classifyEnvKey(envKey);
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<span
|
||||
className={`font-mono break-all ${
|
||||
risk
|
||||
? "text-yellow-700 dark:text-yellow-500 font-semibold"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{risk && <span aria-hidden="true">⚠ </span>}
|
||||
{envKey}
|
||||
</span>
|
||||
<span className="font-mono break-all">{maskValue(envKey, value)}</span>
|
||||
</div>
|
||||
);
|
||||
if (isSensitive && value.length > 8) {
|
||||
return `${value.substring(0, 8)}${"*".repeat(12)}`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
@@ -391,22 +411,37 @@ export function DeepLinkImportDialog() {
|
||||
{t("deeplink.endpoint")}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm break-all space-y-1">
|
||||
{request.endpoint?.split(",").map((ep, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={
|
||||
idx === 0 ? "font-medium" : "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{idx === 0 ? "🔹 " : "└ "}
|
||||
{ep.trim()}
|
||||
{idx === 0 && request.endpoint?.includes(",") && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({t("deeplink.primaryEndpoint")})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{request.endpoint?.split(",").map((ep, idx) => {
|
||||
const endpointRisk = classifyEndpoint(ep.trim());
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={
|
||||
endpointRisk
|
||||
? "text-yellow-700 dark:text-yellow-500 font-semibold"
|
||||
: idx === 0
|
||||
? "font-medium"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{idx === 0 ? "🔹 " : "└ "}
|
||||
{endpointRisk && (
|
||||
<span aria-hidden="true">⚠ </span>
|
||||
)}
|
||||
{ep.trim()}
|
||||
{idx === 0 && request.endpoint?.includes(",") && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({t("deeplink.primaryEndpoint")})
|
||||
</span>
|
||||
)}
|
||||
{endpointRisk && (
|
||||
<div className="text-xs font-normal mt-0.5">
|
||||
{t(riskI18nKey(endpointRisk))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -527,17 +562,11 @@ export function DeepLinkImportDialog() {
|
||||
<div className="space-y-1.5">
|
||||
{Object.entries(parsedConfig.env).map(
|
||||
([key, value]) => (
|
||||
<div
|
||||
<EnvRow
|
||||
key={key}
|
||||
className="grid grid-cols-2 gap-2 text-xs"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground truncate">
|
||||
{key}
|
||||
</span>
|
||||
<span className="font-mono truncate">
|
||||
{maskValue(key, String(value))}
|
||||
</span>
|
||||
</div>
|
||||
envKey={key}
|
||||
value={String(value)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
@@ -552,21 +581,17 @@ export function DeepLinkImportDialog() {
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Auth:
|
||||
</div>
|
||||
{Object.entries(parsedConfig.auth).map(
|
||||
([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="grid grid-cols-2 gap-2 text-xs pl-2"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground truncate">
|
||||
{key}
|
||||
</span>
|
||||
<span className="font-mono truncate">
|
||||
{maskValue(key, String(value))}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<div className="pl-2 space-y-1.5">
|
||||
{Object.entries(parsedConfig.auth).map(
|
||||
([key, value]) => (
|
||||
<EnvRow
|
||||
key={key}
|
||||
envKey={key}
|
||||
value={String(value)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{parsedConfig.tomlConfig && (
|
||||
@@ -590,17 +615,11 @@ export function DeepLinkImportDialog() {
|
||||
<div className="space-y-1.5">
|
||||
{Object.entries(parsedConfig.env).map(
|
||||
([key, value]) => (
|
||||
<div
|
||||
<EnvRow
|
||||
key={key}
|
||||
className="grid grid-cols-2 gap-2 text-xs"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground truncate">
|
||||
{key}
|
||||
</span>
|
||||
<span className="font-mono truncate">
|
||||
{maskValue(key, String(value))}
|
||||
</span>
|
||||
</div>
|
||||
envKey={key}
|
||||
value={String(value)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
@@ -632,14 +651,19 @@ export function DeepLinkImportDialog() {
|
||||
})}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm">
|
||||
{/*
|
||||
判据是 `=== true`,与后端 `usage_enabled.unwrap_or(false)`
|
||||
严格对齐。此前用的 `!== false` 会把"链接没说"渲染成绿色的
|
||||
「已启用」——徽章必须显示实际会发生的事,不能比后端更乐观。
|
||||
*/}
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium ${
|
||||
request.usageEnabled !== false
|
||||
request.usageEnabled === true
|
||||
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{request.usageEnabled !== false
|
||||
{request.usageEnabled === true
|
||||
? t("deeplink.usageScriptEnabled", {
|
||||
defaultValue: "已启用",
|
||||
})
|
||||
@@ -650,6 +674,33 @@ export function DeepLinkImportDialog() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
脚本正文必须完整展示。这段是会执行的 JavaScript,而 payload
|
||||
常常整条藏在中间——`whitespace-pre-wrap break-all` + 可滚动容器,
|
||||
不用 truncate,任何字符都不得被 CSS 藏起来。
|
||||
*/}
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{t("deeplink.usageScriptCode")}
|
||||
</div>
|
||||
<pre className="max-h-48 overflow-auto rounded border border-border-default bg-muted/40 p-2 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{decodeDeeplinkPayload(
|
||||
request.usageScript,
|
||||
decodeBase64Utf8,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
无条件显示,不看 `usageEnabled`:代码无论启用与否都会被写入供应商
|
||||
配置,用户之后在应用内一键即可开启。挂条件等于让攻击者省略参数就能
|
||||
关掉这条警告。
|
||||
*/}
|
||||
<div className="text-yellow-600 dark:text-yellow-500 text-sm flex items-start gap-2">
|
||||
<span aria-hidden="true">⚠️</span>
|
||||
<span>{t("deeplink.usageScriptWarning")}</span>
|
||||
</div>
|
||||
|
||||
{/* Usage API Key (if different from provider) */}
|
||||
{request.usageApiKey &&
|
||||
request.usageApiKey !== request.apiKey && (
|
||||
|
||||
@@ -2,6 +2,14 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeepLinkImportRequest } from "../../lib/api/deeplink";
|
||||
import { decodeBase64Utf8 } from "../../lib/utils/base64";
|
||||
import {
|
||||
classifyCommand,
|
||||
classifyEndpoint,
|
||||
classifyEnvKey,
|
||||
maskValue,
|
||||
riskI18nKey,
|
||||
type RiskKind,
|
||||
} from "@/utils/deeplinkRisk";
|
||||
|
||||
export function McpConfirmation({
|
||||
request,
|
||||
@@ -25,6 +33,47 @@ export function McpConfirmation({
|
||||
const targetApps = request.apps?.split(",") || [];
|
||||
const serverCount = Object.keys(mcpServers || {}).length;
|
||||
|
||||
// 汇总所有条目命中的风险,在底部给一句总的提示——逐行的 ⚠ 容易被划过去。
|
||||
const risks = useMemo(() => {
|
||||
const found = new Set<RiskKind>();
|
||||
for (const spec of Object.values(mcpServers || {}) as any[]) {
|
||||
const commandRisk = classifyCommand(spec?.command, spec?.args);
|
||||
if (commandRisk) found.add(commandRisk);
|
||||
if (typeof spec?.url === "string") {
|
||||
const urlRisk = classifyEndpoint(spec.url);
|
||||
if (urlRisk) found.add(urlRisk);
|
||||
}
|
||||
for (const key of Object.keys(spec?.env || {})) {
|
||||
const envRisk = classifyEnvKey(key);
|
||||
if (envRisk) found.add(envRisk);
|
||||
}
|
||||
}
|
||||
return [...found];
|
||||
}, [mcpServers]);
|
||||
|
||||
/** 一行 key/value。`break-all` 而非 `truncate`:payload 不得被 CSS 藏起来。 */
|
||||
const Row = ({
|
||||
label,
|
||||
value,
|
||||
risk,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
risk?: RiskKind | null;
|
||||
}) => (
|
||||
<div className="grid grid-cols-[4rem_1fr] gap-2 text-xs">
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span
|
||||
className={`font-mono break-all ${
|
||||
risk ? "text-yellow-700 dark:text-yellow-500 font-semibold" : ""
|
||||
}`}
|
||||
>
|
||||
{risk && <span aria-hidden="true">⚠ </span>}
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("deeplink.mcp.title")}</h3>
|
||||
@@ -51,25 +100,83 @@ export function McpConfirmation({
|
||||
</label>
|
||||
<div className="mt-1 space-y-2 max-h-64 overflow-auto border rounded p-2 bg-muted/30">
|
||||
{mcpServers &&
|
||||
Object.entries(mcpServers).map(([id, spec]: [string, any]) => (
|
||||
<div key={id} className="p-2 bg-background rounded border">
|
||||
<div className="font-semibold text-sm">{id}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 font-mono truncate">
|
||||
{spec.command
|
||||
? `Command: ${spec.command} `
|
||||
: `URL: ${spec.url} `}
|
||||
Object.entries(mcpServers).map(([id, spec]: [string, any]) => {
|
||||
const commandRisk = classifyCommand(spec?.command, spec?.args);
|
||||
const argv: string[] = Array.isArray(spec?.args)
|
||||
? spec.args.map(String)
|
||||
: [];
|
||||
const env: Record<string, unknown> = spec?.env || {};
|
||||
|
||||
return (
|
||||
<div key={id} className="p-2 bg-background rounded border">
|
||||
<div className="font-semibold text-sm mb-1">{id}</div>
|
||||
<div className="space-y-1">
|
||||
{spec?.command && (
|
||||
<Row
|
||||
label={t("deeplink.mcp.command")}
|
||||
value={String(spec.command)}
|
||||
risk={commandRisk}
|
||||
/>
|
||||
)}
|
||||
{/* 逐项展开而不是 join(" "):payload 常常整条藏在某一个 arg 里,
|
||||
拼成一行再 truncate 正是它此前得以隐身的原因。 */}
|
||||
{argv.map((arg, index) => (
|
||||
<Row
|
||||
key={index}
|
||||
label={index === 0 ? t("deeplink.mcp.args") : ""}
|
||||
value={arg}
|
||||
risk={commandRisk}
|
||||
/>
|
||||
))}
|
||||
{spec?.url && (
|
||||
<Row
|
||||
label={t("deeplink.mcp.url")}
|
||||
value={String(spec.url)}
|
||||
risk={classifyEndpoint(String(spec.url))}
|
||||
/>
|
||||
)}
|
||||
{Object.entries(env).map(([key, value], index) => (
|
||||
<Row
|
||||
key={key}
|
||||
label={index === 0 ? t("deeplink.mcp.env") : ""}
|
||||
value={`${key}=${maskValue(key, String(value))}`}
|
||||
risk={classifyEnvKey(key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{request.enabled && (
|
||||
<div className="text-yellow-600 dark:text-yellow-500 text-sm flex items-center gap-2">
|
||||
<span>⚠️</span>
|
||||
<span>{t("deeplink.mcp.enabledWarning")}</span>
|
||||
{risks.length > 0 && (
|
||||
<div className="rounded border border-yellow-500/40 bg-yellow-500/10 p-2 space-y-1">
|
||||
{risks.map((kind) => (
|
||||
<div
|
||||
key={kind}
|
||||
className="text-yellow-700 dark:text-yellow-500 text-sm flex items-start gap-2"
|
||||
>
|
||||
<span aria-hidden="true">⚠️</span>
|
||||
<span>{t(riskI18nKey(kind))}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
无条件显示,不看 `request.enabled`。
|
||||
MCP 导入路径**根本不读这个字段**——`deeplink/mcp.rs` 里全文没有它,
|
||||
而 `:196` 是无条件的 `merged.set_enabled_for(&app, true)`。
|
||||
(prompt.rs / skill.rs / provider.rs 各自读了 `request.enabled`,唯独 MCP 没有,
|
||||
所以很容易误以为这里也生效。)
|
||||
挂条件的后果是:恶意链接省略 `enabled` 就能让这条警告消失,而写入行为
|
||||
一模一样——把提示变成了可被攻击者关掉的开关。
|
||||
*/}
|
||||
<div className="text-yellow-600 dark:text-yellow-500 text-sm flex items-center gap-2">
|
||||
<span>⚠️</span>
|
||||
<span>{t("deeplink.mcp.enabledWarning")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { SVGProps } from "react";
|
||||
|
||||
export function ITermIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>iTerm2</title>
|
||||
<path d="M24 5.359v13.282A5.36 5.36 0 0 1 18.641 24H5.359A5.36 5.36 0 0 1 0 18.641V5.359A5.36 5.36 0 0 1 5.359 0h13.282A5.36 5.36 0 0 1 24 5.359m-.932-.233A4.196 4.196 0 0 0 18.874.932H5.126A4.196 4.196 0 0 0 .932 5.126v13.748a4.196 4.196 0 0 0 4.194 4.194h13.748a4.196 4.196 0 0 0 4.194-4.194zm-.816.233v13.282a3.613 3.613 0 0 1-3.611 3.611H5.359a3.613 3.613 0 0 1-3.611-3.611V5.359a3.613 3.613 0 0 1 3.611-3.611h13.282a3.613 3.613 0 0 1 3.611 3.611M8.854 4.194v6.495h.962V4.194zM5.483 9.493v1.085h.597V9.48q.283-.037.508-.133.373-.165.575-.448.208-.284.208-.649a.9.9 0 0 0-.171-.568 1.4 1.4 0 0 0-.426-.388 3 3 0 0 0-.544-.261 32 32 0 0 0-.545-.209 1.8 1.8 0 0 1-.426-.216q-.164-.12-.164-.284 0-.223.179-.351.18-.126.485-.127.344 0 .575.105.239.105.5.298l.433-.5a2.3 2.3 0 0 0-.605-.433 1.6 1.6 0 0 0-.582-.159v-.968h-.597v.978a2 2 0 0 0-.477.127 1.2 1.2 0 0 0-.545.411q-.194.268-.194.634 0 .335.164.56.164.224.418.38a4 4 0 0 0 .552.262q.291.104.545.209.261.104.425.238a.39.39 0 0 1 .165.321q0 .225-.187.359-.18.134-.537.134-.381 0-.717-.134a4.4 4.4 0 0 1-.649-.351l-.388.589q.209.173.477.306.276.135.575.217.191.046.373.064" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlacrittyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Alacritty</title>
|
||||
<path d="m10.065 0-8.57 21.269h3.595l6.91-16.244 6.91 16.244h3.594l-8.57-21.269zm1.935 9.935c-0.76666 1.8547-1.5334 3.7094-2.298 5.565 1.475 4.54 1.475 4.54 2.298 8.5 0.823-3.96 0.823-3.96 2.297-8.5-0.76637-1.8547-1.5315-3.7099-2.297-5.565z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WezTermIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>WezTerm</title>
|
||||
<path d="M3.27 8.524c0-.623.62-1.007 2.123-1.007l-.5 2.757c-.931-.623-1.624-1.199-1.624-1.75zm4.008 6.807c0 .647-.644 1.079-2.123 1.15l.524-2.924c.931.624 1.6 1.175 1.6 1.774zm-2.625 5.992.454-2.708c3.603-.336 5.01-1.798 5.01-3.404 0-1.653-2.004-2.948-3.841-4.074l.668-3.548c.764.072 1.67.216 2.744.432l.31-2.469c-.81-.12-1.575-.168-2.29-.216L8.257 2.7l-2.363-.024-.453 2.684C1.838 5.648.43 7.158.43 8.764c0 1.63 2.004 2.876 3.841 3.954l-.668 3.716c-.859-.048-1.908-.192-3.125-.408L0 18.495c1.026.12 1.98.192 2.84.216l-.525 2.588zm15.553-1.894h2.673c.334-2.804.81-8.46 1.121-14.86h-2.553c-.071 1.51-.334 10.498-.43 11.241h-.071c-.644-2.42-1.169-4.386-1.813-6.782h-1.456c-.62 2.396-1.05 4.194-1.694 6.782h-.096c-.071-.743-.477-9.73-.525-11.24h-2.648c.31 6.399.763 12.055 1.097 14.86h2.625l1.838-7.12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GhosttyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Ghostty</title>
|
||||
<path d="M12 0C6.7 0 2.4 4.3 2.4 9.6v11.146c0 1.772 1.45 3.267 3.222 3.254a3.18 3.18 0 0 0 1.955-.686 1.96 1.96 0 0 1 2.444 0 3.18 3.18 0 0 0 1.976.686c.75 0 1.436-.257 1.98-.686.715-.563 1.71-.587 2.419-.018.59.476 1.355.743 2.182.699 1.705-.094 3.022-1.537 3.022-3.244V9.601C21.6 4.3 17.302 0 12 0M6.069 6.562a1 1 0 0 1 .46.131l3.578 2.065v.002a.974.974 0 0 1 0 1.687L6.53 12.512a.975.975 0 0 1-.976-1.687L7.67 9.602 5.553 8.38a.975.975 0 0 1 .515-1.818m7.438 2.063h4.7a.975.975 0 1 1 0 1.95h-4.7a.975.975 0 0 1 0-1.95" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KittyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
// Official icon is complex and has fixed width/height/viewBox in original.
|
||||
// Simplifying viewBox to 0 0 256 256 effectively as original was 240x240 but translated.
|
||||
// Original viewBox="0 0 240 240" with g transform="translate(0 -812.362)" and elements around y=850.
|
||||
// 850 - 812 = 38. So it's confusing.
|
||||
// Let's copy the raw SVG content but adapt it to be a component.
|
||||
// To make it behave like an icon, we should probably set viewBox="0 0 240 240" and keep the transform.
|
||||
// It relies on fill colors. If we want it to be monochrome (currentColor), we should remove fills or set them to currentColor.
|
||||
// However, official icons often have brand colors. The user said "official icon", which implies color.
|
||||
// But usually in a dropdown we might want monochrome or original color.
|
||||
// simple-icons are usually monochrome.
|
||||
// Let's keep Kitty as original color since it's complex, OR mono if it works?
|
||||
// The kitty icon has multiple paths with different colors. I'll preserve them for now as it's "official".
|
||||
// If it looks weird in dark mode/light mode, we might need to adjust.
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 240 240"
|
||||
{...props} // Allow overriding width/height
|
||||
>
|
||||
<g transform="translate(0 -812.362)">
|
||||
<rect
|
||||
width="100.446"
|
||||
height="161.551"
|
||||
x="72.824"
|
||||
y="850.13"
|
||||
ry="0"
|
||||
style={{
|
||||
fill: "#ddd",
|
||||
fillOpacity: 1,
|
||||
fillRule: "evenodd",
|
||||
stroke: "none",
|
||||
strokeWidth: 5.86876726,
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
strokeMiterlimit: 4,
|
||||
strokeDasharray: "none",
|
||||
strokeOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M67.896 1029.71h104.208a7.065 7.065 0 0 0 7.065-7.066V918.436a7.065 7.065 0 0 0-7.065-7.065H67.896a7.065 7.065 0 0 0-7.065 7.065v104.208a7.065 7.065 0 0 0 7.065 7.065m55.813-38.35h37.444a4.239 4.239 0 0 1 0 8.479H123.71a4.239 4.239 0 0 1 0-8.478m-45.032-45.71a4.239 4.239 0 0 1 5.991-5.99l26.48 26.464a4.24 4.24 0 0 1 0 5.992l-26.48 26.48a4.239 4.239 0 0 1-5.991-5.992l23.484-23.484z"
|
||||
style={{ strokeWidth: 1.41299629 }}
|
||||
/>
|
||||
<path
|
||||
d="M96.085 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.385 3.574-3.385 8.17 0 4.595 1.504 8.17 3.385 8.17"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fill: "#c0c81f",
|
||||
fillOpacity: 1,
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 3.09913683,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M193.128 836.886c-4.596-4.85-25.53 1.022-38.295 8.936-9.957-5.106-21.956-8.17-34.721-8.17-13.02 0-25.02 3.064-34.977 8.17-12.765-7.914-33.955-14.042-38.295-8.936-4.595 5.106 3.32 26.296 12.765 38.04-.766 3.064-1.276 6.128-1.276 9.446 0 10.212 4.34 19.659 11.744 27.318h42.124c-1.276-2.553.511-4.085 8.17-4.085 7.659.255 9.19 1.532 8.17 4.085h42.124c7.404-7.66 11.744-17.36 11.744-27.318 0-3.318-.51-6.382-1.276-9.446 8.935-11.744 16.594-33.189 11.999-38.04m-97.015 67.4c-8.935 0-16.339-7.404-16.339-16.34s7.404-16.339 16.34-16.339 16.339 7.404 16.339 16.34-7.404 16.339-16.34 16.339m47.997 0c-8.936 0-16.34-7.404-16.34-16.34s7.404-16.339 16.34-16.339 16.34 7.404 16.34 16.34-7.15 16.339-16.34 16.339"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fill: "#784421",
|
||||
fillOpacity: 1,
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
}}
|
||||
/>
|
||||
<g style={{ fill: "#2b1100", fillOpacity: 1 }}>
|
||||
<path
|
||||
d="M168.507 903.265c15.318-19.148 46.72-28.339 67.655-15.063-24.509-3.83-46.72 2.553-67.655 15.063"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
fill: "#2b1100",
|
||||
fillOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M167.486 898.67c8.68-20.425 34.466-33.7 55.145-26.552-21.7 2.808-39.316 11.233-55.145 26.551m-.256 9.957c15.83-15.063 50.806-20.169 61.528-4.34-21.7-6.893-40.593-3.83-61.527 4.34"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
fill: "#2b1100",
|
||||
fillOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
<g style={{ fill: "#2b1100", fillOpacity: 1 }}>
|
||||
<path
|
||||
d="M71.493 903.265c-15.318-19.148-46.72-28.339-67.655-15.063 24.509-3.83 46.72 2.553 67.655 15.063"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
fill: "#2b1100",
|
||||
fillOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M72.514 898.67c-8.68-20.425-34.466-33.7-55.145-26.552 21.7 2.808 39.316 11.233 55.145 26.551m.256 9.957c-15.83-15.063-50.806-20.169-61.528-4.34 21.7-6.893 40.593-3.83 61.527 4.34"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
fill: "#2b1100",
|
||||
fillOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M52.6 893.563c-6.382 0-11.743 3.32-14.296 8.425h-.766c-6.893 0-12.765 5.106-12.765 11.489 0 8.935 9.19 13.786 17.615 10.722 5.106 7.404 16.084 7.915 20.17 0 6.126-.255 16.083-1.276 17.615-10.722 1.021-6.383-5.617-11.489-12.765-11.489h-.766c-2.042-5.106-7.659-8.425-14.041-8.425m134.8 0c6.382 0 11.743 3.32 14.296 8.425h.766c3.574 0 12.765 5.106 12.765 11.489 0 8.935-9.19 13.786-17.615 10.722-5.107 7.404-16.084 7.915-20.17 0-6.126-.255-16.083-1.276-17.615-10.722-1.021-6.383 9.19-11.489 12.765-11.489h.766c2.042-5.106 7.659-8.425 14.041-8.425"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fill: "#483737",
|
||||
fillOpacity: 1,
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M143.542 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.386 3.574-3.386 8.17 0 4.595 1.505 8.17 3.386 8.17"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fill: "#c0c81f",
|
||||
fillOpacity: 1,
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 3.09913683,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
|
||||
export function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (theme === "dark") {
|
||||
setTheme("light");
|
||||
} else {
|
||||
setTheme("dark");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="icon" onClick={toggleTheme}>
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">{t("common.toggleTheme")}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||
import type { Prompt, AppId } from "@/lib/api";
|
||||
|
||||
interface PromptFormModalProps {
|
||||
appId: AppId;
|
||||
editingId?: string;
|
||||
initialData?: Prompt;
|
||||
onSave: (id: string, prompt: Prompt) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PromptFormModal: React.FC<PromptFormModalProps> = ({
|
||||
appId,
|
||||
editingId,
|
||||
initialData,
|
||||
onSave,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const appName = t(`apps.${appId}`);
|
||||
const filenameMap: Record<Exclude<AppId, "openclaw">, string> = {
|
||||
claude: "CLAUDE.md",
|
||||
"claude-desktop": "CLAUDE.md",
|
||||
codex: "AGENTS.md",
|
||||
gemini: "GEMINI.md",
|
||||
grokbuild: "AGENTS.md",
|
||||
opencode: "AGENTS.md",
|
||||
hermes: "AGENTS.md",
|
||||
};
|
||||
const filename = filenameMap[appId as Exclude<AppId, "openclaw">];
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 检测初始暗色模式状态
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
// 监听 html 元素的 class 变化以实时响应主题切换
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setName(initialData.name);
|
||||
setDescription(initialData.description || "");
|
||||
setContent(initialData.content);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const id = editingId || `prompt-${Date.now()}`;
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const prompt: Prompt = {
|
||||
id,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
content: content.trim(),
|
||||
enabled: initialData?.enabled || false,
|
||||
createdAt: initialData?.createdAt || timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
await onSave(id, prompt);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
// Error handled by hook
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingId
|
||||
? t("prompts.editTitle", { appName })
|
||||
: t("prompts.addTitle", { appName })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4 px-6 py-4">
|
||||
<div>
|
||||
<Label htmlFor="name">{t("prompts.name")}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("prompts.namePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">{t("prompts.description")}</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("prompts.descriptionPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="content" className="mb-2 block">
|
||||
{t("prompts.content")}
|
||||
</Label>
|
||||
<MarkdownEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
placeholder={t("prompts.contentPlaceholder", { filename })}
|
||||
darkMode={isDarkMode}
|
||||
minHeight="300px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim() || saving}
|
||||
>
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptFormModal;
|
||||
@@ -1,51 +0,0 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { HealthStatus } from "@/lib/api/connectivity-check";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface HealthStatusIndicatorProps {
|
||||
status: HealthStatus;
|
||||
responseTimeMs?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
operational: {
|
||||
color: "bg-emerald-500",
|
||||
labelKey: "health.operational",
|
||||
labelFallback: "正常",
|
||||
textColor: "text-emerald-600 dark:text-emerald-400",
|
||||
},
|
||||
degraded: {
|
||||
color: "bg-yellow-500",
|
||||
labelKey: "health.degraded",
|
||||
labelFallback: "降级",
|
||||
textColor: "text-yellow-600 dark:text-yellow-400",
|
||||
},
|
||||
failed: {
|
||||
color: "bg-red-500",
|
||||
labelKey: "health.failed",
|
||||
labelFallback: "失败",
|
||||
textColor: "text-red-600 dark:text-red-400",
|
||||
},
|
||||
};
|
||||
|
||||
export const HealthStatusIndicator: React.FC<HealthStatusIndicatorProps> = ({
|
||||
status,
|
||||
responseTimeMs,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const config = statusConfig[status];
|
||||
const label = t(config.labelKey, { defaultValue: config.labelFallback });
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className={cn("w-2 h-2 rounded-full", config.color)} />
|
||||
<span className={cn("text-xs font-medium", config.textColor)}>
|
||||
{label}
|
||||
{responseTimeMs !== undefined && ` (${responseTimeMs}ms)`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,6 @@ export { useBaseUrlState } from "./useBaseUrlState";
|
||||
export { useModelState } from "./useModelState";
|
||||
export { useCodexConfigState } from "./useCodexConfigState";
|
||||
export { useApiKeyLink } from "./useApiKeyLink";
|
||||
export { useCustomEndpoints } from "./useCustomEndpoints";
|
||||
export { useTemplateValues } from "./useTemplateValues";
|
||||
export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
|
||||
export { useCodexCommonConfig } from "./useCodexCommonConfig";
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import type { CustomEndpoint } from "@/types";
|
||||
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||
|
||||
type PresetEntry = {
|
||||
id: string;
|
||||
preset: ProviderPreset | CodexProviderPreset;
|
||||
};
|
||||
|
||||
interface UseCustomEndpointsProps {
|
||||
appId: AppId;
|
||||
selectedPresetId: string | null;
|
||||
presetEntries: PresetEntry[];
|
||||
draftCustomEndpoints: string[];
|
||||
baseUrl: string;
|
||||
codexBaseUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集和管理自定义端点
|
||||
*
|
||||
* 收集来源:
|
||||
* 1. 用户在测速弹窗中新增的自定义端点
|
||||
* 2. 预设中的 endpointCandidates
|
||||
* 3. 当前选中的 Base URL
|
||||
*/
|
||||
export function useCustomEndpoints({
|
||||
appId,
|
||||
selectedPresetId,
|
||||
presetEntries,
|
||||
draftCustomEndpoints,
|
||||
baseUrl,
|
||||
codexBaseUrl,
|
||||
}: UseCustomEndpointsProps) {
|
||||
const customEndpointsMap = useMemo(() => {
|
||||
const urlSet = new Set<string>();
|
||||
|
||||
// 辅助函数:标准化并添加 URL
|
||||
const push = (raw?: string) => {
|
||||
const url = (raw || "").trim().replace(/\/+$/, "");
|
||||
if (url) urlSet.add(url);
|
||||
};
|
||||
|
||||
// 1. 自定义端点(来自用户新增)
|
||||
for (const u of draftCustomEndpoints) push(u);
|
||||
|
||||
// 2. 预设端点候选
|
||||
if (selectedPresetId && selectedPresetId !== "custom") {
|
||||
const entry = presetEntries.find((item) => item.id === selectedPresetId);
|
||||
if (entry) {
|
||||
const preset = entry.preset as any;
|
||||
if (Array.isArray(preset?.endpointCandidates)) {
|
||||
for (const u of preset.endpointCandidates as string[]) push(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 当前 Base URL
|
||||
if (appId === "codex") {
|
||||
push(codexBaseUrl);
|
||||
} else {
|
||||
push(baseUrl);
|
||||
}
|
||||
|
||||
// 构建 CustomEndpoint map
|
||||
const urls = Array.from(urlSet.values());
|
||||
if (urls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const customMap: Record<string, CustomEndpoint> = {};
|
||||
for (const url of urls) {
|
||||
if (!customMap[url]) {
|
||||
customMap[url] = { url, addedAt: now, lastUsed: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
return customMap;
|
||||
}, [
|
||||
appId,
|
||||
selectedPresetId,
|
||||
presetEntries,
|
||||
draftCustomEndpoints,
|
||||
baseUrl,
|
||||
codexBaseUrl,
|
||||
]);
|
||||
|
||||
return customEndpointsMap;
|
||||
}
|
||||
@@ -5,11 +5,77 @@ import { configApi } from "@/lib/api";
|
||||
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
|
||||
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}";
|
||||
|
||||
/** 供应商专属、不可共享的键(端点与主凭据),按名单剥离。 */
|
||||
const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
|
||||
"GOOGLE_GEMINI_BASE_URL",
|
||||
"GEMINI_API_KEY",
|
||||
] as const;
|
||||
type GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
|
||||
|
||||
/**
|
||||
* 凭据键的模式匹配,对应后端 `ProviderService::is_sensitive_config_key`。
|
||||
*
|
||||
* 共享片段会被深合并进**其它** Gemini 供应商的 env,所以任何凭据都不能进来。
|
||||
* 固定名单挡不住下一个 `*_API_KEY`(`GOOGLE_API_KEY` 就是这么漏过去的),
|
||||
* 必须用模式覆盖整类。两端判定要一致,否则后端清理完还能从这里手工写回去。
|
||||
*/
|
||||
const SENSITIVE_EXACT = [
|
||||
"APIKEY",
|
||||
"API_KEY",
|
||||
"TOKEN",
|
||||
"SECRET",
|
||||
"PASSWORD",
|
||||
"CREDENTIALS",
|
||||
];
|
||||
// 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但不误伤复数 `_TOKENS`(那是正常配置)
|
||||
const SENSITIVE_SUFFIXES = [
|
||||
// 裸 `_KEY` 是最常见的凭据写法(OPENAI_KEY / GROQ_KEY / XAI_KEY…),必须单列:
|
||||
// 只枚举 `_API_KEY` / `_ACCESS_KEY` 这些子类,等于把最普通的那一种漏在外面。
|
||||
"_KEY",
|
||||
"_API_KEY",
|
||||
"_ACCESS_KEY",
|
||||
"_ACCESS_KEY_ID",
|
||||
"_KEY_ID",
|
||||
"_PRIVATE_KEY",
|
||||
// 不带分隔符的复合写法各走各的后缀:`_KEY` 够不着 `..._APIKEY`。
|
||||
"_APIKEY",
|
||||
"_ACCESSKEY",
|
||||
"_SECRETKEY",
|
||||
"_APITOKEN",
|
||||
"_AUTH_TOKEN",
|
||||
"_TOKEN",
|
||||
// GITHUB_PAT / GITLAB_PAT 等 personal access token 的惯用写法,
|
||||
// 既不含 TOKEN 也不含 KEY,前面每一条规则都够不着。
|
||||
"_PAT",
|
||||
// 口令类的常见缩写。`_PASS` 不会误伤 `*_BYPASS`,`_PWD` 不会误伤 PWD / OLDPWD。
|
||||
"_PWD",
|
||||
"_PASS",
|
||||
"_PASSPHRASE",
|
||||
"_CREDS",
|
||||
];
|
||||
const SENSITIVE_CONTAINS = [
|
||||
"SECRET",
|
||||
"PASSWORD",
|
||||
"PASSWD",
|
||||
"CREDENTIAL",
|
||||
"PRIVATE_KEY",
|
||||
"BEARER_TOKEN",
|
||||
];
|
||||
|
||||
function isForbiddenCommonEnvKey(name: string): boolean {
|
||||
if (
|
||||
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(
|
||||
name as (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number],
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const upper = name.toUpperCase();
|
||||
return (
|
||||
SENSITIVE_EXACT.includes(upper) ||
|
||||
SENSITIVE_SUFFIXES.some((suffix) => upper.endsWith(suffix)) ||
|
||||
SENSITIVE_CONTAINS.some((part) => upper.includes(part))
|
||||
);
|
||||
}
|
||||
|
||||
interface UseGeminiCommonConfigProps {
|
||||
envValue: string;
|
||||
@@ -90,9 +156,7 @@ export function useGeminiCommonConfig({
|
||||
}
|
||||
|
||||
const keys = Object.keys(parsed);
|
||||
const forbiddenKeys = keys.filter((key) =>
|
||||
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey),
|
||||
);
|
||||
const forbiddenKeys = keys.filter(isForbiddenCommonEnvKey);
|
||||
if (forbiddenKeys.length > 0) {
|
||||
return {
|
||||
env: {},
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
import {
|
||||
useCircuitBreakerConfig,
|
||||
useUpdateCircuitBreakerConfig,
|
||||
} from "@/lib/query/failover";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* 熔断器配置面板
|
||||
* 允许用户调整熔断器参数
|
||||
*/
|
||||
export function CircuitBreakerConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { data: config, isLoading } = useCircuitBreakerConfig();
|
||||
const updateConfig = useUpdateCircuitBreakerConfig();
|
||||
|
||||
// 使用字符串状态以支持完全清空输入框
|
||||
const [formData, setFormData] = useState({
|
||||
failureThreshold: "5",
|
||||
successThreshold: "2",
|
||||
timeoutSeconds: "60",
|
||||
errorRateThreshold: "50", // 存储百分比值
|
||||
minRequests: "10",
|
||||
});
|
||||
|
||||
// 当配置加载完成时更新表单数据
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setFormData({
|
||||
failureThreshold: String(config.failureThreshold),
|
||||
successThreshold: String(config.successThreshold),
|
||||
timeoutSeconds: String(config.timeoutSeconds),
|
||||
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
|
||||
minRequests: String(config.minRequests),
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// 解析数字,返回 NaN 表示无效输入
|
||||
const parseNum = (val: string) => {
|
||||
const trimmed = val.trim();
|
||||
// 必须是纯数字
|
||||
if (!/^-?\d+$/.test(trimmed)) return NaN;
|
||||
return parseInt(trimmed);
|
||||
};
|
||||
|
||||
// 定义各字段的有效范围
|
||||
const ranges = {
|
||||
failureThreshold: { min: 1, max: 20 },
|
||||
successThreshold: { min: 1, max: 10 },
|
||||
timeoutSeconds: { min: 0, max: 300 },
|
||||
errorRateThreshold: { min: 0, max: 100 },
|
||||
minRequests: { min: 5, max: 100 },
|
||||
};
|
||||
|
||||
// 解析原始值
|
||||
const raw = {
|
||||
failureThreshold: parseNum(formData.failureThreshold),
|
||||
successThreshold: parseNum(formData.successThreshold),
|
||||
timeoutSeconds: parseNum(formData.timeoutSeconds),
|
||||
errorRateThreshold: parseNum(formData.errorRateThreshold),
|
||||
minRequests: parseNum(formData.minRequests),
|
||||
};
|
||||
|
||||
// 校验是否超出范围(NaN 也视为无效)
|
||||
const errors: string[] = [];
|
||||
const checkRange = (
|
||||
value: number,
|
||||
range: { min: number; max: number },
|
||||
label: string,
|
||||
) => {
|
||||
if (isNaN(value) || value < range.min || value > range.max) {
|
||||
errors.push(`${label}: ${range.min}-${range.max}`);
|
||||
}
|
||||
};
|
||||
|
||||
checkRange(
|
||||
raw.failureThreshold,
|
||||
ranges.failureThreshold,
|
||||
t("circuitBreaker.failureThreshold", "失败阈值"),
|
||||
);
|
||||
checkRange(
|
||||
raw.successThreshold,
|
||||
ranges.successThreshold,
|
||||
t("circuitBreaker.successThreshold", "成功阈值"),
|
||||
);
|
||||
checkRange(
|
||||
raw.timeoutSeconds,
|
||||
ranges.timeoutSeconds,
|
||||
t("circuitBreaker.timeoutSeconds", "超时时间"),
|
||||
);
|
||||
checkRange(
|
||||
raw.errorRateThreshold,
|
||||
ranges.errorRateThreshold,
|
||||
t("circuitBreaker.errorRateThreshold", "错误率阈值"),
|
||||
);
|
||||
checkRange(
|
||||
raw.minRequests,
|
||||
ranges.minRequests,
|
||||
t("circuitBreaker.minRequests", "最小请求数"),
|
||||
);
|
||||
|
||||
if (errors.length > 0) {
|
||||
toast.error(
|
||||
t("circuitBreaker.validationFailed", {
|
||||
fields: errors.join("; "),
|
||||
defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateConfig.mutateAsync({
|
||||
failureThreshold: raw.failureThreshold,
|
||||
successThreshold: raw.successThreshold,
|
||||
timeoutSeconds: raw.timeoutSeconds,
|
||||
errorRateThreshold: raw.errorRateThreshold / 100,
|
||||
minRequests: raw.minRequests,
|
||||
});
|
||||
toast.success(t("circuitBreaker.configSaved", "熔断器配置已保存"), {
|
||||
closeButton: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("circuitBreaker.saveFailed", "保存失败") + ": " + String(error),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (config) {
|
||||
setFormData({
|
||||
failureThreshold: String(config.failureThreshold),
|
||||
successThreshold: String(config.successThreshold),
|
||||
timeoutSeconds: String(config.timeoutSeconds),
|
||||
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
|
||||
minRequests: String(config.minRequests),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("circuitBreaker.loading", "加载中...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("circuitBreaker.title", "熔断器配置")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t(
|
||||
"circuitBreaker.description",
|
||||
"调整熔断器参数以控制故障检测和恢复行为",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border my-4" />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 失败阈值 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="failureThreshold">
|
||||
{t("circuitBreaker.failureThreshold", "失败阈值")}
|
||||
</Label>
|
||||
<Input
|
||||
id="failureThreshold"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={formData.failureThreshold}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, failureThreshold: e.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"circuitBreaker.failureThresholdHint",
|
||||
"连续失败多少次后打开熔断器",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 超时时间 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timeoutSeconds">
|
||||
{t("circuitBreaker.timeoutSeconds", "超时时间(秒)")}
|
||||
</Label>
|
||||
<Input
|
||||
id="timeoutSeconds"
|
||||
type="number"
|
||||
min="0"
|
||||
max="300"
|
||||
value={formData.timeoutSeconds}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, timeoutSeconds: e.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"circuitBreaker.timeoutSecondsHint",
|
||||
"熔断器打开后多久尝试恢复(半开状态)",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 成功阈值 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="successThreshold">
|
||||
{t("circuitBreaker.successThreshold", "成功阈值")}
|
||||
</Label>
|
||||
<Input
|
||||
id="successThreshold"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={formData.successThreshold}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, successThreshold: e.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"circuitBreaker.successThresholdHint",
|
||||
"半开状态下成功多少次后关闭熔断器",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 错误率阈值 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="errorRateThreshold">
|
||||
{t("circuitBreaker.errorRateThreshold", "错误率阈值 (%)")}
|
||||
</Label>
|
||||
<Input
|
||||
id="errorRateThreshold"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={formData.errorRateThreshold}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, errorRateThreshold: e.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"circuitBreaker.errorRateThresholdHint",
|
||||
"错误率超过此值时打开熔断器",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 最小请求数 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minRequests">
|
||||
{t("circuitBreaker.minRequests", "最小请求数")}
|
||||
</Label>
|
||||
<Input
|
||||
id="minRequests"
|
||||
type="number"
|
||||
min="5"
|
||||
max="100"
|
||||
value={formData.minRequests}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, minRequests: e.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("circuitBreaker.minRequestsHint", "计算错误率前的最小请求数")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleSave} disabled={updateConfig.isPending}>
|
||||
{updateConfig.isPending
|
||||
? t("common.saving", "保存中...")
|
||||
: t("circuitBreaker.saveConfig", "保存配置")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={updateConfig.isPending}
|
||||
>
|
||||
{t("common.reset", "重置")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 说明信息 */}
|
||||
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
|
||||
<h4 className="font-medium">
|
||||
{t("circuitBreaker.instructionsTitle", "配置说明")}
|
||||
</h4>
|
||||
<ul className="space-y-1 text-muted-foreground">
|
||||
<li>
|
||||
•{" "}
|
||||
<strong>{t("circuitBreaker.failureThreshold", "失败阈值")}</strong>
|
||||
:
|
||||
{t(
|
||||
"circuitBreaker.instructions.failureThreshold",
|
||||
"连续失败达到此次数时,熔断器打开",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
• <strong>{t("circuitBreaker.timeoutSeconds", "超时时间")}</strong>
|
||||
:
|
||||
{t(
|
||||
"circuitBreaker.instructions.timeout",
|
||||
"熔断器打开后,等待此时间后尝试半开",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
•{" "}
|
||||
<strong>{t("circuitBreaker.successThreshold", "成功阈值")}</strong>
|
||||
:
|
||||
{t(
|
||||
"circuitBreaker.instructions.successThreshold",
|
||||
"半开状态下,成功达到此次数时关闭熔断器",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
•{" "}
|
||||
<strong>
|
||||
{t("circuitBreaker.errorRateThreshold", "错误率阈值")}
|
||||
</strong>
|
||||
:
|
||||
{t(
|
||||
"circuitBreaker.instructions.errorRate",
|
||||
"错误率超过此值时,熔断器打开",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
• <strong>{t("circuitBreaker.minRequests", "最小请求数")}</strong>:
|
||||
{t(
|
||||
"circuitBreaker.instructions.minRequests",
|
||||
"只有请求数达到此值后才计算错误率",
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,12 +15,12 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ToggleRow } from "@/components/ui/toggle-row";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { toast } from "sonner";
|
||||
import { useFailoverQueue } from "@/lib/query/failover";
|
||||
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
||||
import { useProviderHealth } from "@/lib/query/failover";
|
||||
import {
|
||||
useProxyStatusQuery,
|
||||
useProxyTakeoverStatus,
|
||||
useSetProxyTakeoverForApp,
|
||||
useGlobalProxyConfig,
|
||||
@@ -45,7 +45,8 @@ export function ProxyPanel({
|
||||
isProxyPending,
|
||||
}: ProxyPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { status, isRunning } = useProxyStatus();
|
||||
const { data: status } = useProxyStatusQuery();
|
||||
const isRunning = status?.running ?? false;
|
||||
|
||||
// 获取应用接管状态
|
||||
const { data: takeoverStatus } = useProxyTakeoverStatus();
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Trash2, ExternalLink, Plus } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
|
||||
|
||||
interface RepoManagerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
repos: SkillRepo[];
|
||||
skills: DiscoverableSkill[];
|
||||
onAdd: (repo: SkillRepo) => Promise<void>;
|
||||
onRemove: (owner: string, name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function RepoManager({
|
||||
open: isOpen,
|
||||
onOpenChange,
|
||||
repos,
|
||||
skills,
|
||||
onAdd,
|
||||
onRemove,
|
||||
}: RepoManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [repoUrl, setRepoUrl] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const getSkillCount = (repo: SkillRepo) =>
|
||||
skills.filter(
|
||||
(skill) =>
|
||||
skill.repoOwner === repo.owner &&
|
||||
skill.repoName === repo.name &&
|
||||
(skill.repoBranch || "main") === (repo.branch || "main"),
|
||||
).length;
|
||||
|
||||
const parseRepoUrl = (
|
||||
url: string,
|
||||
): { owner: string; name: string } | null => {
|
||||
// 支持格式:
|
||||
// - https://github.com/owner/name
|
||||
// - owner/name
|
||||
// - https://github.com/owner/name.git
|
||||
|
||||
let cleaned = url.trim();
|
||||
cleaned = cleaned.replace(/^https?:\/\/github\.com\//, "");
|
||||
cleaned = cleaned.replace(/\.git$/, "");
|
||||
|
||||
const parts = cleaned.split("/");
|
||||
if (parts.length === 2 && parts[0] && parts[1]) {
|
||||
return { owner: parts[0], name: parts[1] };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
setError("");
|
||||
|
||||
const parsed = parseRepoUrl(repoUrl);
|
||||
if (!parsed) {
|
||||
setError(t("skills.repo.invalidUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onAdd({
|
||||
owner: parsed.owner,
|
||||
name: parsed.name,
|
||||
branch: branch || "main",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
setRepoUrl("");
|
||||
setBranch("");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenRepo = async (owner: string, name: string) => {
|
||||
try {
|
||||
await settingsApi.openExternal(`https://github.com/${owner}/${name}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to open URL:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col p-0">
|
||||
{/* 固定头部 */}
|
||||
<DialogHeader className="flex-shrink-0 border-b border-border-default px-6 py-4">
|
||||
<DialogTitle>{t("skills.repo.title")}</DialogTitle>
|
||||
<DialogDescription>{t("skills.repo.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 可滚动内容区域 */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4">
|
||||
{/* 添加仓库表单 */}
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repo-url">{t("skills.repo.url")}</Label>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
id="repo-url"
|
||||
placeholder={t("skills.repo.urlPlaceholder")}
|
||||
value={repoUrl}
|
||||
onChange={(e) => setRepoUrl(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
id="branch"
|
||||
placeholder={t("skills.repo.branchPlaceholder")}
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
className="w-full sm:w-auto sm:px-4"
|
||||
variant="mcp"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("skills.repo.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* 仓库列表 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">{t("skills.repo.list")}</h4>
|
||||
{repos.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("skills.repo.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{repos.map((repo) => (
|
||||
<div
|
||||
key={`${repo.owner}/${repo.name}`}
|
||||
className="flex items-center justify-between rounded-xl border border-border-default bg-card px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{repo.owner}/{repo.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{t("skills.repo.branch")}: {repo.branch || "main"}
|
||||
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
|
||||
{t("skills.repo.skillCount", {
|
||||
count: getSkillCount(repo),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => handleOpenRepo(repo.owner, repo.name)}
|
||||
title={t("common.view", { defaultValue: "查看" })}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => onRemove(repo.owner, repo.name)}
|
||||
title={t("common.delete")}
|
||||
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { usageApi } from "@/lib/api/usage";
|
||||
import { usageKeys } from "@/lib/query/usage";
|
||||
import { Database, FileText, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface DataSourceBarProps {
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
const DATA_SOURCE_ICONS: Record<string, React.ReactNode> = {
|
||||
proxy: <Database className="h-3.5 w-3.5" />,
|
||||
session_log: <FileText className="h-3.5 w-3.5" />,
|
||||
codex_db: <Database className="h-3.5 w-3.5" />,
|
||||
codex_session: <FileText className="h-3.5 w-3.5" />,
|
||||
gemini_session: <FileText className="h-3.5 w-3.5" />,
|
||||
opencode_session: <FileText className="h-3.5 w-3.5" />,
|
||||
grok_session: <FileText className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
export function DataSourceBar({ refreshIntervalMs }: DataSourceBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
const { data: sources } = useQuery({
|
||||
queryKey: [...usageKeys.all, "data-sources"],
|
||||
queryFn: usageApi.getDataSourceBreakdown,
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const result = await usageApi.syncSessionUsage();
|
||||
if (result.imported > 0) {
|
||||
toast.success(
|
||||
t("usage.sessionSync.imported", {
|
||||
count: result.imported,
|
||||
defaultValue: "Imported {{count}} records from session logs",
|
||||
}),
|
||||
);
|
||||
// Refresh all usage data
|
||||
queryClient.invalidateQueries({ queryKey: usageKeys.all });
|
||||
} else {
|
||||
toast.info(
|
||||
t("usage.sessionSync.upToDate", {
|
||||
defaultValue: "Session logs are up to date",
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
t("usage.sessionSync.failed", {
|
||||
defaultValue: "Session sync failed",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!sources || sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasNonProxy = sources.some((s) => s.dataSource !== "proxy");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground bg-muted/30 rounded-lg px-4 py-2">
|
||||
<span className="font-medium text-foreground/70">
|
||||
{t("usage.dataSources", { defaultValue: "Data Sources" })}:
|
||||
</span>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{sources.map((source) => (
|
||||
<div
|
||||
key={source.dataSource}
|
||||
className="flex items-center gap-1.5 bg-background/50 rounded-md px-2 py-1"
|
||||
>
|
||||
{DATA_SOURCE_ICONS[source.dataSource] ?? (
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>
|
||||
{t(`usage.dataSource.${source.dataSource}`, {
|
||||
defaultValue: source.dataSource,
|
||||
})}
|
||||
</span>
|
||||
<span className="font-mono font-medium text-foreground/80">
|
||||
{source.requestCount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
title={t("usage.sessionSync.trigger", {
|
||||
defaultValue: "Sync session logs",
|
||||
})}
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="ml-1">
|
||||
{hasNonProxy
|
||||
? t("usage.sessionSync.resync", { defaultValue: "Sync" })
|
||||
: t("usage.sessionSync.import", {
|
||||
defaultValue: "Import Sessions",
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Check,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { settingsApi } from "@/lib/api/settings";
|
||||
import { usageApi } from "@/lib/api/usage";
|
||||
import {
|
||||
MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
|
||||
syncModelsDevPricing,
|
||||
} from "@/lib/modelsDevAutoSync";
|
||||
import {
|
||||
fetchModelsDevPricing,
|
||||
flattenModels,
|
||||
formatPrice,
|
||||
getCommonModelKeys,
|
||||
type ModelsDevEntry,
|
||||
} from "@/lib/modelsDevPricing";
|
||||
import { usageKeys } from "@/lib/query/usage";
|
||||
import type { ModelsDevSyncConfig, ModelsDevSyncState } from "@/types/usage";
|
||||
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||
|
||||
const MODELS_DEV_QUERY_KEY = ["models-dev-pricing"] as const;
|
||||
const DEFAULT_VISIBLE_ROWS = 80;
|
||||
const MAX_VISIBLE_ROWS = 300;
|
||||
|
||||
interface AutoSyncDialogProps {
|
||||
state: ModelsDevSyncState;
|
||||
onClose: () => void;
|
||||
onSaved: (state: ModelsDevSyncState) => void;
|
||||
}
|
||||
|
||||
function AutoSyncDialog({ state, onClose, onSaved }: AutoSyncDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const [providerFilter, setProviderFilter] = useState("all");
|
||||
const [includeCommonModels, setIncludeCommonModels] = useState(
|
||||
state.config.includeCommonModels,
|
||||
);
|
||||
const [selectedModelKeys, setSelectedModelKeys] = useState(
|
||||
() => new Set(state.config.selectedModelKeys),
|
||||
);
|
||||
const [excludedCommonModelKeys, setExcludedCommonModelKeys] = useState(
|
||||
() => new Set(state.config.excludedCommonModelKeys),
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: MODELS_DEV_QUERY_KEY,
|
||||
queryFn: fetchModelsDevPricing,
|
||||
staleTime: 60 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
const entries = useMemo(() => (data ? flattenModels(data) : []), [data]);
|
||||
const commonModelKeys = useMemo(() => getCommonModelKeys(entries), [entries]);
|
||||
|
||||
const effectiveSelectedKeys = useMemo(() => {
|
||||
const selected = new Set(selectedModelKeys);
|
||||
if (includeCommonModels) {
|
||||
for (const key of commonModelKeys) {
|
||||
if (!excludedCommonModelKeys.has(key)) selected.add(key);
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}, [
|
||||
commonModelKeys,
|
||||
excludedCommonModelKeys,
|
||||
includeCommonModels,
|
||||
selectedModelKeys,
|
||||
]);
|
||||
|
||||
const providers = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
if (!map.has(entry.providerId)) {
|
||||
map.set(entry.providerId, entry.providerName);
|
||||
}
|
||||
}
|
||||
return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
}, [entries]);
|
||||
|
||||
const isFiltering = search.trim() !== "" || providerFilter !== "all";
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return entries.filter(
|
||||
(entry) =>
|
||||
(providerFilter === "all" || entry.providerId === providerFilter) &&
|
||||
(!query ||
|
||||
entry.modelId.toLowerCase().includes(query) ||
|
||||
entry.normalizedId.includes(query) ||
|
||||
entry.modelName.toLowerCase().includes(query) ||
|
||||
entry.providerName.toLowerCase().includes(query)),
|
||||
);
|
||||
}, [entries, providerFilter, search]);
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
filtered.slice(0, isFiltering ? MAX_VISIBLE_ROWS : DEFAULT_VISIBLE_ROWS),
|
||||
[filtered, isFiltering],
|
||||
);
|
||||
|
||||
const toggleEntry = (entry: ModelsDevEntry) => {
|
||||
const isSelected = effectiveSelectedKeys.has(entry.key);
|
||||
setSelectedModelKeys((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (isSelected) next.delete(entry.key);
|
||||
else next.add(entry.key);
|
||||
return next;
|
||||
});
|
||||
setExcludedCommonModelKeys((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (isSelected && includeCommonModels && commonModelKeys.has(entry.key)) {
|
||||
next.add(entry.key);
|
||||
} else {
|
||||
next.delete(entry.key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectFiltered = () => {
|
||||
setSelectedModelKeys((previous) => {
|
||||
const next = new Set(previous);
|
||||
for (const entry of filtered) next.add(entry.key);
|
||||
return next;
|
||||
});
|
||||
setExcludedCommonModelKeys((previous) => {
|
||||
const next = new Set(previous);
|
||||
for (const entry of filtered) next.delete(entry.key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearFiltered = () => {
|
||||
setSelectedModelKeys((previous) => {
|
||||
const next = new Set(previous);
|
||||
for (const entry of filtered) next.delete(entry.key);
|
||||
return next;
|
||||
});
|
||||
if (includeCommonModels) {
|
||||
setExcludedCommonModelKeys((previous) => {
|
||||
const next = new Set(previous);
|
||||
for (const entry of filtered) {
|
||||
if (commonModelKeys.has(entry.key)) next.add(entry.key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const config: ModelsDevSyncConfig = {
|
||||
...state.config,
|
||||
includeCommonModels,
|
||||
selectedModelKeys: Array.from(selectedModelKeys).sort(),
|
||||
excludedCommonModelKeys: Array.from(excludedCommonModelKeys).sort(),
|
||||
};
|
||||
await usageApi.saveModelsDevSyncConfig(config);
|
||||
onSaved({ ...state, config });
|
||||
toast.success(t("usage.modelsDevAutoSync.selectionSaved"));
|
||||
onClose();
|
||||
} catch (saveError) {
|
||||
toast.error(String(saveError));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const priceColumns = (entry: ModelsDevEntry) =>
|
||||
[
|
||||
{ label: t("usage.inputCost"), value: entry.input },
|
||||
{ label: t("usage.outputCost"), value: entry.output },
|
||||
{ label: t("usage.cacheReadCost"), value: entry.cacheRead },
|
||||
{ label: t("usage.cacheWriteCost"), value: entry.cacheWrite },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && !isSaving && onClose()}>
|
||||
<DialogContent
|
||||
zIndex="top"
|
||||
className="max-w-4xl h-[84vh]"
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (isTextEditableTarget(event.target)) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("usage.modelsDevAutoSync.configureTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("usage.modelsDevAutoSync.configureDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-1 min-h-0 flex-col gap-3 px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/50 bg-muted/20 px-3 py-2.5">
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{t("usage.modelsDevAutoSync.commonModels")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("usage.modelsDevAutoSync.commonModelsDescription", {
|
||||
count: commonModelKeys.size,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={includeCommonModels}
|
||||
onCheckedChange={setIncludeCommonModels}
|
||||
aria-label={t("usage.modelsDevAutoSync.commonModels")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="flex items-center justify-between gap-3">
|
||||
<span>
|
||||
{t("usage.modelsDevLoadError")}: {String(error)}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
{t("usage.modelsDevRetry")}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={providerFilter}
|
||||
onValueChange={setProviderFilter}
|
||||
>
|
||||
<SelectTrigger className="w-48 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[120] max-h-[min(24rem,var(--radix-select-content-available-height))]">
|
||||
<SelectItem value="all">
|
||||
{t("usage.modelsDevAllProviders")}
|
||||
</SelectItem>
|
||||
{providers.map((provider) => (
|
||||
<SelectItem key={provider.id} value={provider.id}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("usage.modelsDevSearchPlaceholder")}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={selectFiltered}
|
||||
disabled={filtered.length === 0}
|
||||
>
|
||||
{t("usage.modelsDevAutoSync.selectFiltered", {
|
||||
count: filtered.length,
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearFiltered}
|
||||
disabled={filtered.length === 0}
|
||||
>
|
||||
{t("usage.modelsDevAutoSync.clearFiltered")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("usage.modelsDevAutoSync.selectedCount", {
|
||||
count: effectiveSelectedKeys.size,
|
||||
})}
|
||||
</span>
|
||||
<span>{t("usage.modelsDevAutoSync.selectionHint")}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto rounded-md border border-border/50">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
{t("usage.modelsDevNoResults")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/30">
|
||||
{visible.map((entry) => {
|
||||
const selected = effectiveSelectedKeys.has(entry.key);
|
||||
const common = commonModelKeys.has(entry.key);
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => toggleEntry(entry)}
|
||||
className={`flex w-full items-center gap-3 px-3 py-2 text-left ${
|
||||
selected ? "bg-accent/50" : "hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-muted-foreground/50"
|
||||
}`}
|
||||
>
|
||||
{selected && <Check className="h-3 w-3" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{entry.modelName}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{entry.providerName}
|
||||
</span>
|
||||
{common && (
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">
|
||||
{t("usage.modelsDevAutoSync.commonBadge")}
|
||||
</span>
|
||||
)}
|
||||
{entry.releaseDate && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground/70">
|
||||
{entry.releaseDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="truncate font-mono text-xs text-muted-foreground"
|
||||
title={entry.modelId}
|
||||
>
|
||||
{entry.normalizedId}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-3 text-right">
|
||||
{priceColumns(entry).map((column) => (
|
||||
<div key={column.label} className="w-16">
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{column.label}
|
||||
</div>
|
||||
<div className="font-mono text-xs">
|
||||
${formatPrice(column.value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filtered.length > visible.length && (
|
||||
<div className="px-3 py-2 text-center text-xs text-muted-foreground">
|
||||
{isFiltering
|
||||
? t("usage.modelsDevTruncated", {
|
||||
shown: visible.length,
|
||||
total: filtered.length,
|
||||
})
|
||||
: t("usage.modelsDevDefaultHint", {
|
||||
shown: visible.length,
|
||||
total: filtered.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isSaving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={save} disabled={isSaving || isLoading || !!error}>
|
||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelsDevAutoSyncPanel() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [isReloading, setIsReloading] = useState(false);
|
||||
const [showEnableConfirm, setShowEnableConfirm] = useState(false);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
|
||||
queryFn: usageApi.getModelsDevSyncConfig,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
const updateCachedState = (state: ModelsDevSyncState) => {
|
||||
queryClient.setQueryData(MODELS_DEV_SYNC_CONFIG_QUERY_KEY, state);
|
||||
};
|
||||
|
||||
const saveConfig = async (config: ModelsDevSyncConfig) => {
|
||||
if (!data) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await usageApi.saveModelsDevSyncConfig(config);
|
||||
updateCachedState({ ...data, config });
|
||||
} catch (saveError) {
|
||||
toast.error(String(saveError));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncNow = async () => {
|
||||
if (!data) return;
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
const result = await syncModelsDevPricing(data, true);
|
||||
await Promise.all([
|
||||
refetch(),
|
||||
queryClient.invalidateQueries({ queryKey: usageKeys.all }),
|
||||
]);
|
||||
toast.success(
|
||||
t("usage.modelsDevAutoSync.syncSuccess", {
|
||||
imported: result.imported,
|
||||
changed: result.changed,
|
||||
}),
|
||||
);
|
||||
} catch (syncError) {
|
||||
await refetch();
|
||||
toast.error(
|
||||
t("usage.modelsDevAutoSync.syncFailed", { error: String(syncError) }),
|
||||
);
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reloadLocalFile = async () => {
|
||||
setIsReloading(true);
|
||||
try {
|
||||
await usageApi.getModelPricing();
|
||||
await Promise.all([
|
||||
refetch(),
|
||||
queryClient.invalidateQueries({ queryKey: usageKeys.all }),
|
||||
]);
|
||||
toast.success(t("usage.modelsDevAutoSync.localFileReloaded"));
|
||||
} catch (reloadError) {
|
||||
toast.error(
|
||||
t("usage.modelsDevAutoSync.localFileReloadFailed", {
|
||||
error: String(reloadError),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsReloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openLocalFileFolder = async () => {
|
||||
try {
|
||||
await settingsApi.openAppConfigFolder();
|
||||
} catch (openError) {
|
||||
toast.error(
|
||||
t("usage.modelsDevAutoSync.openFolderFailed", {
|
||||
error: String(openError),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg border border-border/50 py-6">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="flex items-center justify-between gap-3">
|
||||
<span>
|
||||
{t("usage.modelsDevAutoSync.configLoadFailed", {
|
||||
error: String(error),
|
||||
})}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
{t("usage.modelsDevRetry")}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const lastSync = data.config.lastSyncAt
|
||||
? new Date(data.config.lastSyncAt).toLocaleString(i18n.resolvedLanguage)
|
||||
: t("usage.modelsDevAutoSync.neverSynced");
|
||||
|
||||
const handleAutoSyncChange = (autoSyncEnabled: boolean) => {
|
||||
if (autoSyncEnabled) {
|
||||
setShowEnableConfirm(true);
|
||||
return;
|
||||
}
|
||||
void saveConfig({ ...data.config, autoSyncEnabled: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/15 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium">
|
||||
{t("usage.modelsDevAutoSync.title")}
|
||||
</h5>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("usage.modelsDevAutoSync.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{data.config.autoSyncEnabled
|
||||
? t("usage.modelsDevAutoSync.enabled")
|
||||
: t("usage.modelsDevAutoSync.disabled")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={data.config.autoSyncEnabled}
|
||||
disabled={isSaving}
|
||||
onCheckedChange={handleAutoSyncChange}
|
||||
aria-label={t("usage.modelsDevAutoSync.title")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 text-xs text-muted-foreground md:grid-cols-2">
|
||||
<div>
|
||||
{t("usage.modelsDevAutoSync.lastSync")}: {lastSync}
|
||||
</div>
|
||||
<div>
|
||||
{t("usage.modelsDevAutoSync.commonStatus")}:{" "}
|
||||
{data.config.includeCommonModels
|
||||
? t("usage.modelsDevAutoSync.enabled")
|
||||
: t("usage.modelsDevAutoSync.disabled")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.config.lastSyncError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("usage.modelsDevAutoSync.lastError", {
|
||||
error: data.config.lastSyncError,
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="rounded-md bg-background/60 px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("usage.modelsDevAutoSync.localFile")}
|
||||
</div>
|
||||
<div className="truncate font-mono text-xs" title={data.configPath}>
|
||||
{data.configPath}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void openLocalFileFolder()}
|
||||
>
|
||||
<FolderOpen className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("usage.modelsDevAutoSync.openFolder")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void reloadLocalFile()}
|
||||
disabled={isReloading}
|
||||
>
|
||||
{isReloading ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("usage.modelsDevAutoSync.reloadLocalFile")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
>
|
||||
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("usage.modelsDevAutoSync.configure")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void syncNow()} disabled={isSyncing}>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("usage.modelsDevAutoSync.syncNow")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDialogOpen && (
|
||||
<AutoSyncDialog
|
||||
state={data}
|
||||
onClose={() => setIsDialogOpen(false)}
|
||||
onSaved={updateCachedState}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
isOpen={showEnableConfirm}
|
||||
title={t("usage.modelsDevAutoSync.enableConfirmTitle")}
|
||||
message={t("usage.modelsDevAutoSync.enableConfirmMessage")}
|
||||
confirmText={t("usage.modelsDevAutoSync.enableConfirmAction")}
|
||||
variant="destructive"
|
||||
onConfirm={() => {
|
||||
setShowEnableConfirm(false);
|
||||
void saveConfig({ ...data.config, autoSyncEnabled: true });
|
||||
}}
|
||||
onCancel={() => setShowEnableConfirm(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,114 +22,24 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useUpdateModelPricing } from "@/lib/query/usage";
|
||||
import {
|
||||
fetchModelsDevPricing,
|
||||
flattenModels,
|
||||
formatPrice,
|
||||
type ModelsDevEntry,
|
||||
} from "@/lib/modelsDevPricing";
|
||||
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||
|
||||
const MODELS_DEV_API_URL = "https://models.dev/api.json";
|
||||
export {
|
||||
flattenModels,
|
||||
formatPrice,
|
||||
normalizeModelIdForPricing,
|
||||
} from "@/lib/modelsDevPricing";
|
||||
|
||||
// 全量约 5000 条:默认只展示最新发布的一批,搜索时才做全量匹配
|
||||
const DEFAULT_VISIBLE_ROWS = 50;
|
||||
const MAX_VISIBLE_ROWS = 200;
|
||||
|
||||
interface ModelsDevCost {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
}
|
||||
|
||||
interface ModelsDevModel {
|
||||
id?: string;
|
||||
name?: string;
|
||||
release_date?: string;
|
||||
cost?: ModelsDevCost;
|
||||
}
|
||||
|
||||
interface ModelsDevProvider {
|
||||
id?: string;
|
||||
name?: string;
|
||||
models?: Record<string, ModelsDevModel>;
|
||||
}
|
||||
|
||||
type ModelsDevResponse = Record<string, ModelsDevProvider>;
|
||||
|
||||
interface ModelsDevEntry {
|
||||
/** providerId/modelId,同一模型可能出现在多个供应商下 */
|
||||
key: string;
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
modelId: string;
|
||||
/** 实际入库的 ID,与后端 clean_model_id_for_pricing 的归一化规则一致 */
|
||||
normalizedId: string;
|
||||
modelName: string;
|
||||
/** YYYY-MM-DD 或 YYYY-MM,缺失时为空串 */
|
||||
releaseDate: string;
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 与后端 clean_model_id_for_pricing(usage_stats.rs)保持一致:
|
||||
* 取最后一个 '/' 之后的段、去掉 ':' 后缀、'@' 换成 '-'、转小写、去掉 [1m] 标记。
|
||||
* 成本归因查询用的就是这种归一化形式,原样入库的 ID 永远匹配不上。
|
||||
*/
|
||||
export function normalizeModelIdForPricing(modelId: string): string {
|
||||
const afterSlash = modelId.slice(modelId.lastIndexOf("/") + 1);
|
||||
const beforeColon = afterSlash.split(":")[0] ?? "";
|
||||
let normalized = beforeColon.trim().replace(/@/g, "-").toLowerCase();
|
||||
if (normalized.endsWith("[1m]")) {
|
||||
normalized = normalized.slice(0, -"[1m]".length).trim();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** 转成后端可解析的非负十进制字符串(不能用 String(),小数可能变成科学计数法) */
|
||||
export function formatPrice(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0";
|
||||
// toFixed 对 >=1e21 会退化成科学计数法;这种量级的"价格"只可能是脏数据,按 0 处理
|
||||
if (value >= 1e12) return "0";
|
||||
const trimmed = value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
|
||||
return trimmed || "0";
|
||||
}
|
||||
|
||||
export function flattenModels(data: ModelsDevResponse): ModelsDevEntry[] {
|
||||
const entries: ModelsDevEntry[] = [];
|
||||
for (const [providerId, provider] of Object.entries(data)) {
|
||||
if (!provider || typeof provider !== "object") continue;
|
||||
const providerName = provider.name || providerId;
|
||||
for (const [modelId, model] of Object.entries(provider.models ?? {})) {
|
||||
const cost = model?.cost;
|
||||
const input = typeof cost?.input === "number" ? cost.input : null;
|
||||
const output = typeof cost?.output === "number" ? cost.output : null;
|
||||
if (input === null && output === null) continue;
|
||||
const normalizedId = normalizeModelIdForPricing(modelId);
|
||||
if (!normalizedId) continue;
|
||||
entries.push({
|
||||
key: `${providerId}/${modelId}`,
|
||||
providerId,
|
||||
providerName,
|
||||
modelId,
|
||||
normalizedId,
|
||||
modelName: model?.name || modelId,
|
||||
releaseDate:
|
||||
typeof model?.release_date === "string" ? model.release_date : "",
|
||||
input: input ?? 0,
|
||||
output: output ?? 0,
|
||||
cacheRead: typeof cost?.cache_read === "number" ? cost.cache_read : 0,
|
||||
cacheWrite:
|
||||
typeof cost?.cache_write === "number" ? cost.cache_write : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 最新发布的排在前面
|
||||
entries.sort(
|
||||
(a, b) =>
|
||||
b.releaseDate.localeCompare(a.releaseDate) ||
|
||||
a.modelName.localeCompare(b.modelName),
|
||||
);
|
||||
return entries;
|
||||
}
|
||||
|
||||
interface ModelsDevPickerDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -160,13 +70,7 @@ export function ModelsDevPickerDialog({
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["models-dev-pricing"],
|
||||
queryFn: async (): Promise<ModelsDevResponse> => {
|
||||
const res = await fetch(MODELS_DEV_API_URL);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
queryFn: fetchModelsDevPricing,
|
||||
enabled: open,
|
||||
staleTime: 60 * 60 * 1000,
|
||||
retry: 1,
|
||||
|
||||
@@ -32,6 +32,7 @@ import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage";
|
||||
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { proxyApi } from "@/lib/api/proxy";
|
||||
import { ModelsDevAutoSyncPanel } from "./ModelsDevAutoSyncPanel";
|
||||
|
||||
const PRICING_APPS = ["claude", "codex", "gemini", "grokbuild"] as const;
|
||||
type PricingApp = (typeof PRICING_APPS)[number];
|
||||
@@ -341,6 +342,8 @@ export function PricingConfigPanel() {
|
||||
|
||||
{/* 模型定价配置 */}
|
||||
<div className="space-y-4">
|
||||
<ModelsDevAutoSyncPanel />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
|
||||
|
||||
@@ -191,7 +191,12 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: ["https://www.packyapi.ai"],
|
||||
endpointCandidates: [
|
||||
"https://www.packyapi.ai",
|
||||
"https://cf.api.fan",
|
||||
"https://slb-v1.api.fan",
|
||||
"https://www.packyapi.com",
|
||||
],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "packycode",
|
||||
icon: "packycode",
|
||||
@@ -311,14 +316,14 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "AIGoCode",
|
||||
websiteUrl: "https://aigocode.com",
|
||||
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
||||
websiteUrl: "https://aigocode.app",
|
||||
apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH",
|
||||
category: "third_party",
|
||||
baseUrl: "https://api.aigocode.com",
|
||||
baseUrl: "https://api.aigocode.app",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: ["https://api.aigocode.com"],
|
||||
endpointCandidates: ["https://api.aigocode.app"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aigocode",
|
||||
icon: "aigocode",
|
||||
@@ -528,6 +533,19 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
partnerPromotionKey: "nekocode",
|
||||
icon: "nekocode",
|
||||
},
|
||||
{
|
||||
name: "A6API",
|
||||
websiteUrl: "https://www.a6api.com",
|
||||
apiKeyUrl: "https://a6api.com/register?aff=AqNr",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://api.a6api.com",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "a6api",
|
||||
icon: "a6api",
|
||||
},
|
||||
{
|
||||
name: "AtlasCloud",
|
||||
websiteUrl: "https://www.atlascloud.ai/console/coding-plan",
|
||||
|
||||
@@ -144,7 +144,12 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
// 请求地址候选(用于地址管理/测速)
|
||||
endpointCandidates: ["https://www.packyapi.ai"],
|
||||
endpointCandidates: [
|
||||
"https://www.packyapi.ai",
|
||||
"https://cf.api.fan",
|
||||
"https://slb-v1.api.fan",
|
||||
"https://www.packyapi.com",
|
||||
],
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "packycode", // 促销信息 i18n key
|
||||
@@ -283,16 +288,16 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "AIGoCode",
|
||||
websiteUrl: "https://aigocode.com",
|
||||
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
||||
websiteUrl: "https://aigocode.app",
|
||||
apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.aigocode.com",
|
||||
ANTHROPIC_BASE_URL: "https://api.aigocode.app",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
// 请求地址候选(用于地址管理/测速)
|
||||
endpointCandidates: ["https://api.aigocode.com"],
|
||||
endpointCandidates: ["https://api.aigocode.app"],
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "aigocode", // 促销信息 i18n key
|
||||
@@ -532,6 +537,21 @@ export const providerPresets: ProviderPreset[] = [
|
||||
partnerPromotionKey: "nekocode",
|
||||
icon: "nekocode",
|
||||
},
|
||||
{
|
||||
name: "A6API",
|
||||
websiteUrl: "https://www.a6api.com",
|
||||
apiKeyUrl: "https://a6api.com/register?aff=AqNr",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.a6api.com",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "a6api",
|
||||
icon: "a6api",
|
||||
},
|
||||
{
|
||||
name: "AtlasCloud",
|
||||
websiteUrl: "https://www.atlascloud.ai/console/coding-plan",
|
||||
|
||||
@@ -205,7 +205,12 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
"https://www.packyapi.ai/v1",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://www.packyapi.ai/v1"],
|
||||
endpointCandidates: [
|
||||
"https://www.packyapi.ai/v1",
|
||||
"https://cf.api.fan/v1",
|
||||
"https://slb-v1.api.fan/v1",
|
||||
"https://www.packyapi.com/v1",
|
||||
],
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "packycode", // 促销信息 i18n key
|
||||
icon: "packycode",
|
||||
@@ -349,16 +354,16 @@ requires_openai_auth = true`,
|
||||
},
|
||||
{
|
||||
name: "AIGoCode",
|
||||
websiteUrl: "https://aigocode.com",
|
||||
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
||||
websiteUrl: "https://aigocode.app",
|
||||
apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH",
|
||||
category: "third_party",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"aigocode",
|
||||
"https://api.aigocode.com",
|
||||
"https://api.aigocode.app",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://api.aigocode.com"],
|
||||
endpointCandidates: ["https://api.aigocode.app"],
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "aigocode", // 促销信息 i18n key
|
||||
icon: "aigocode",
|
||||
@@ -622,6 +627,22 @@ requires_openai_auth = true`,
|
||||
partnerPromotionKey: "nekocode",
|
||||
icon: "nekocode",
|
||||
},
|
||||
{
|
||||
name: "A6API",
|
||||
websiteUrl: "https://www.a6api.com",
|
||||
apiKeyUrl: "https://a6api.com/register?aff=AqNr",
|
||||
category: "aggregator",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"a6api",
|
||||
"https://api.a6api.com/v1",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://api.a6api.com/v1"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "a6api",
|
||||
icon: "a6api",
|
||||
},
|
||||
{
|
||||
name: "AtlasCloud",
|
||||
websiteUrl: "https://www.atlascloud.ai/console/coding-plan",
|
||||
|
||||
@@ -68,7 +68,12 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "packycode",
|
||||
endpointCandidates: ["https://www.packyapi.ai"],
|
||||
endpointCandidates: [
|
||||
"https://www.packyapi.ai",
|
||||
"https://cf.api.fan",
|
||||
"https://slb-v1.api.fan",
|
||||
"https://www.packyapi.com",
|
||||
],
|
||||
icon: "packycode",
|
||||
},
|
||||
{
|
||||
@@ -150,21 +155,21 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "AIGoCode",
|
||||
websiteUrl: "https://aigocode.com",
|
||||
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
||||
websiteUrl: "https://aigocode.app",
|
||||
apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.aigocode.com",
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.aigocode.app",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.aigocode.com",
|
||||
baseURL: "https://api.aigocode.app",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "AIGoCode",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aigocode",
|
||||
endpointCandidates: ["https://api.aigocode.com"],
|
||||
endpointCandidates: ["https://api.aigocode.app"],
|
||||
icon: "aigocode",
|
||||
iconColor: "#5B7FFF",
|
||||
},
|
||||
@@ -234,17 +239,35 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://code0.ai",
|
||||
GEMINI_MODEL: "gemini-3.1-pro-preview",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://code0.ai",
|
||||
model: "gemini-3.1-pro-preview",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "Code0",
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "code0",
|
||||
icon: "code0",
|
||||
},
|
||||
{
|
||||
name: "A6API",
|
||||
websiteUrl: "https://www.a6api.com",
|
||||
apiKeyUrl: "https://a6api.com/register?aff=AqNr",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.a6api.com",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.a6api.com",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "A6API",
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "a6api",
|
||||
icon: "a6api",
|
||||
},
|
||||
{
|
||||
name: "SSSAiCode",
|
||||
websiteUrl: "https://sssaicodeapi.com",
|
||||
@@ -342,11 +365,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.qnaigc.com/bypass/vertex",
|
||||
GEMINI_MODEL: "gemini-3.1-pro-preview",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.qnaigc.com/bypass/vertex",
|
||||
model: "gemini-3.1-pro-preview",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "Qiniu",
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
|
||||
@@ -84,7 +84,12 @@ export const grokBuildProviderPresets: GrokBuildProviderPreset[] = [
|
||||
apiKeyUrl: "https://www.packyapi.ai/register?aff=cc-switch",
|
||||
auth: grokAuth(),
|
||||
config: grokPresetConfig("PackyCode", "https://www.packyapi.ai/v1"),
|
||||
endpointCandidates: ["https://www.packyapi.ai/v1"],
|
||||
endpointCandidates: [
|
||||
"https://www.packyapi.ai/v1",
|
||||
"https://cf.api.fan/v1",
|
||||
"https://slb-v1.api.fan/v1",
|
||||
"https://www.packyapi.com/v1",
|
||||
],
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "packycode",
|
||||
@@ -198,11 +203,11 @@ export const grokBuildProviderPresets: GrokBuildProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "AIGoCode",
|
||||
websiteUrl: "https://aigocode.com",
|
||||
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
||||
websiteUrl: "https://aigocode.app",
|
||||
apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH",
|
||||
auth: grokAuth(),
|
||||
config: grokPresetConfig("AIGoCode", "https://api.aigocode.com"),
|
||||
endpointCandidates: ["https://api.aigocode.com"],
|
||||
config: grokPresetConfig("AIGoCode", "https://api.aigocode.app"),
|
||||
endpointCandidates: ["https://api.aigocode.app"],
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aigocode",
|
||||
@@ -285,6 +290,18 @@ export const grokBuildProviderPresets: GrokBuildProviderPreset[] = [
|
||||
partnerPromotionKey: "nekocode",
|
||||
icon: "nekocode",
|
||||
},
|
||||
{
|
||||
name: "A6API",
|
||||
websiteUrl: "https://www.a6api.com",
|
||||
apiKeyUrl: "https://a6api.com/register?aff=AqNr",
|
||||
auth: grokAuth(),
|
||||
config: grokPresetConfig("A6API", "https://api.a6api.com/v1"),
|
||||
endpointCandidates: ["https://api.a6api.com/v1"],
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "a6api",
|
||||
icon: "a6api",
|
||||
},
|
||||
{
|
||||
name: "Compshare",
|
||||
nameKey: "providerForm.presets.ucloud",
|
||||
|
||||
@@ -356,11 +356,11 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "AIGoCode",
|
||||
websiteUrl: "https://aigocode.com",
|
||||
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
||||
websiteUrl: "https://aigocode.app",
|
||||
apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH",
|
||||
settingsConfig: {
|
||||
name: "aigocode",
|
||||
base_url: "https://api.aigocode.com",
|
||||
base_url: "https://api.aigocode.app",
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
@@ -691,6 +691,25 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
model: { default: "gpt-5.6-sol", provider: "nekocode" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "A6API",
|
||||
websiteUrl: "https://www.a6api.com",
|
||||
apiKeyUrl: "https://a6api.com/register?aff=AqNr",
|
||||
settingsConfig: {
|
||||
name: "a6api",
|
||||
base_url: "https://api.a6api.com/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "a6api",
|
||||
icon: "a6api",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.6-sol", provider: "a6api" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AtlasCloud",
|
||||
websiteUrl: "https://www.atlascloud.ai/console/coding-plan",
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* 根据供应商名称智能推断图标配置
|
||||
*/
|
||||
|
||||
const iconMappings = {
|
||||
// AI 服务商
|
||||
claude: { icon: "claude", iconColor: "#D4915D" },
|
||||
anthropic: { icon: "anthropic", iconColor: "#D4915D" },
|
||||
deepseek: { icon: "deepseek", iconColor: "#1E88E5" },
|
||||
zhipu: { icon: "zhipu", iconColor: "#0F62FE" },
|
||||
glm: { icon: "zhipu", iconColor: "#0F62FE" },
|
||||
qwen: { icon: "qwen", iconColor: "#FF6A00" },
|
||||
bailian: { icon: "bailian", iconColor: "#624AFF" },
|
||||
alibaba: { icon: "alibaba", iconColor: "#FF6A00" },
|
||||
aliyun: { icon: "alibaba", iconColor: "#FF6A00" },
|
||||
kimi: { icon: "kimi", iconColor: "#6366F1" },
|
||||
moonshot: { icon: "moonshot", iconColor: "#6366F1" },
|
||||
stepfun: { icon: "stepfun", iconColor: "#005AFF" },
|
||||
step: { icon: "stepfun", iconColor: "#005AFF" },
|
||||
baidu: { icon: "baidu", iconColor: "#2932E1" },
|
||||
tencent: { icon: "tencent", iconColor: "#00A4FF" },
|
||||
hunyuan: { icon: "hunyuan", iconColor: "#00A4FF" },
|
||||
minimax: { icon: "minimax", iconColor: "#FF6B6B" },
|
||||
google: { icon: "google", iconColor: "#4285F4" },
|
||||
meta: { icon: "meta", iconColor: "#0081FB" },
|
||||
mistral: { icon: "mistral", iconColor: "#FF7000" },
|
||||
cohere: { icon: "cohere", iconColor: "#39594D" },
|
||||
perplexity: { icon: "perplexity", iconColor: "#20808D" },
|
||||
huggingface: { icon: "huggingface", iconColor: "#FFD21E" },
|
||||
novita: { icon: "novita", iconColor: "#000000" },
|
||||
|
||||
// 云平台
|
||||
aws: { icon: "aws", iconColor: "#FF9900" },
|
||||
azure: { icon: "azure", iconColor: "#0078D4" },
|
||||
huawei: { icon: "huawei", iconColor: "#FF0000" },
|
||||
cloudflare: { icon: "cloudflare", iconColor: "#F38020" },
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据预设名称推断图标
|
||||
*/
|
||||
export function inferIconForPreset(presetName: string): {
|
||||
icon?: string;
|
||||
iconColor?: string;
|
||||
} {
|
||||
const nameLower = presetName.toLowerCase();
|
||||
|
||||
// 精确匹配或模糊匹配
|
||||
for (const [key, config] of Object.entries(iconMappings)) {
|
||||
if (nameLower.includes(key)) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量为预设添加图标配置
|
||||
*/
|
||||
export function addIconsToPresets<
|
||||
T extends { name: string; icon?: string; iconColor?: string },
|
||||
>(presets: T[]): T[] {
|
||||
return presets.map((preset) => {
|
||||
// 如果已经配置了图标,则保留原配置
|
||||
if (preset.icon) {
|
||||
return preset;
|
||||
}
|
||||
|
||||
// 否则根据名称推断
|
||||
const inferred = inferIconForPreset(preset.name);
|
||||
return {
|
||||
...preset,
|
||||
...inferred,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -514,10 +514,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "AIGoCode",
|
||||
websiteUrl: "https://aigocode.com",
|
||||
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
||||
websiteUrl: "https://aigocode.app",
|
||||
apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.aigocode.com",
|
||||
baseUrl: "https://api.aigocode.app",
|
||||
apiKey: "",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
@@ -1030,6 +1030,42 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "A6API",
|
||||
websiteUrl: "https://www.a6api.com",
|
||||
apiKeyUrl: "https://a6api.com/register?aff=AqNr",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.a6api.com/v1",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
contextWindow: 400000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "a6api",
|
||||
icon: "a6api",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "a6api/gpt-5.6-sol",
|
||||
},
|
||||
modelCatalog: {
|
||||
"a6api/gpt-5.6-sol": { alias: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AtlasCloud",
|
||||
websiteUrl: "https://www.atlascloud.ai/console/coding-plan",
|
||||
|
||||
@@ -591,13 +591,13 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "AIGoCode",
|
||||
websiteUrl: "https://aigocode.com",
|
||||
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
||||
websiteUrl: "https://aigocode.app",
|
||||
apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "AIGoCode",
|
||||
options: {
|
||||
baseURL: "https://api.aigocode.com",
|
||||
baseURL: "https://api.aigocode.app",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
@@ -921,6 +921,34 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "A6API",
|
||||
websiteUrl: "https://www.a6api.com",
|
||||
apiKeyUrl: "https://a6api.com/register?aff=AqNr",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "A6API",
|
||||
options: {
|
||||
baseURL: "https://api.a6api.com/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "a6api",
|
||||
icon: "a6api",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AtlasCloud",
|
||||
websiteUrl: "https://www.atlascloud.ai/console/coding-plan",
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
/**
|
||||
* Detects whether the container's children overflow the available width
|
||||
* and returns a `compact` flag for the AppSwitcher.
|
||||
*
|
||||
* Uses ResizeObserver on a flex-constrained container. The container
|
||||
* must have `flex-1 min-w-0 overflow-hidden` so its width is determined
|
||||
* by the parent layout, not its own content — avoiding the oscillation
|
||||
* problem when toggling compact mode.
|
||||
*/
|
||||
export function useAutoCompact(
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
): boolean {
|
||||
const [compact, setCompact] = useState(false);
|
||||
const normalWidthRef = useRef(0);
|
||||
const lockUntilRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
// During expand animation, ignore resize events to prevent flicker
|
||||
if (Date.now() < lockUntilRef.current) return;
|
||||
|
||||
if (!compact) {
|
||||
// Overflow detected → switch to compact
|
||||
if (el.scrollWidth > el.clientWidth + 1) {
|
||||
// Cache only at the overflow edge: when content fits,
|
||||
// scrollWidth === clientWidth (DOM spec), so caching unconditionally
|
||||
// would pollute normalWidthRef with the container width (e.g. after
|
||||
// maximizing), making the expand threshold unreachable.
|
||||
normalWidthRef.current = el.scrollWidth;
|
||||
setCompact(true);
|
||||
}
|
||||
} else if (normalWidthRef.current > 0) {
|
||||
// In compact mode: only recover to normal if
|
||||
// available space >= what normal mode needed
|
||||
if (el.clientWidth >= normalWidthRef.current) {
|
||||
// Lock out resize events during the expand animation (200ms + 50ms margin)
|
||||
lockUntilRef.current = Date.now() + 250;
|
||||
setCompact(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [compact]);
|
||||
|
||||
return compact;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 代理配置管理 Hook
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ProxyConfig } from "@/types/proxy";
|
||||
|
||||
/**
|
||||
* 代理配置管理
|
||||
*/
|
||||
export function useProxyConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 查询配置
|
||||
const { data: config, isLoading } = useQuery({
|
||||
queryKey: ["proxyConfig"],
|
||||
queryFn: () => invoke<ProxyConfig>("get_proxy_config"),
|
||||
});
|
||||
|
||||
// 更新配置
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (newConfig: ProxyConfig) =>
|
||||
invoke("update_proxy_config", { config: newConfig }),
|
||||
onSuccess: () => {
|
||||
toast.success(t("proxy.settings.toast.saved"), { closeButton: true });
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(
|
||||
t("proxy.settings.toast.saveFailed", {
|
||||
error: error.message,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
config,
|
||||
isLoading,
|
||||
updateConfig: updateMutation.mutateAsync,
|
||||
isUpdating: updateMutation.isPending,
|
||||
};
|
||||
}
|
||||
+19
-88
@@ -2,15 +2,15 @@
|
||||
* 代理服务状态管理 Hook
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
ProxyStatus,
|
||||
ProxyServerInfo,
|
||||
ProxyTakeoverStatus,
|
||||
} from "@/types/proxy";
|
||||
import { proxyApi } from "@/lib/api/proxy";
|
||||
import {
|
||||
proxyKeys,
|
||||
useProxyStatusQuery,
|
||||
useProxyTakeoverStatus,
|
||||
} from "@/lib/query/proxy";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
|
||||
/**
|
||||
@@ -21,25 +21,14 @@ export function useProxyStatus() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 查询状态(自动轮询)
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ["proxyStatus"],
|
||||
queryFn: () => invoke<ProxyStatus>("get_proxy_status"),
|
||||
// 仅在服务运行时轮询
|
||||
refetchInterval: (query) => (query.state.data?.running ? 2000 : false),
|
||||
// 保持之前的数据,避免闪烁
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
const { data: status } = useProxyStatusQuery();
|
||||
|
||||
// 查询各应用接管状态
|
||||
const { data: takeoverStatus } = useQuery({
|
||||
queryKey: ["proxyTakeoverStatus"],
|
||||
queryFn: () => invoke<ProxyTakeoverStatus>("get_proxy_takeover_status"),
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
const { data: takeoverStatus } = useProxyTakeoverStatus(false);
|
||||
|
||||
// 启动服务器(总开关:仅启动服务,不接管)
|
||||
const startProxyServerMutation = useMutation({
|
||||
mutationFn: () => invoke<ProxyServerInfo>("start_proxy_server"),
|
||||
mutationFn: () => proxyApi.startProxyServer(),
|
||||
onSuccess: (info) => {
|
||||
toast.success(
|
||||
t("proxy.server.started", {
|
||||
@@ -49,7 +38,7 @@ export function useProxyStatus() {
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||
queryClient.invalidateQueries({ queryKey: proxyKeys.status });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const detail =
|
||||
@@ -66,7 +55,7 @@ export function useProxyStatus() {
|
||||
|
||||
// 停止服务器(仅停止服务,不改写/恢复其它应用接管状态)
|
||||
const stopProxyServerMutation = useMutation({
|
||||
mutationFn: () => invoke("stop_proxy_server"),
|
||||
mutationFn: () => proxyApi.stopProxyServer(),
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t("proxy.server.stopped", {
|
||||
@@ -74,7 +63,7 @@ export function useProxyStatus() {
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||
queryClient.invalidateQueries({ queryKey: proxyKeys.status });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const detail =
|
||||
@@ -91,7 +80,7 @@ export function useProxyStatus() {
|
||||
|
||||
// 停止服务器(总开关关闭:强制恢复所有已接管的 Live 配置)
|
||||
const stopWithRestoreMutation = useMutation({
|
||||
mutationFn: () => invoke("stop_proxy_with_restore"),
|
||||
mutationFn: () => proxyApi.stopProxyWithRestore(),
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t("proxy.stoppedWithRestore", {
|
||||
@@ -99,8 +88,8 @@ export function useProxyStatus() {
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
|
||||
queryClient.invalidateQueries({ queryKey: proxyKeys.status });
|
||||
queryClient.invalidateQueries({ queryKey: proxyKeys.takeoverStatus });
|
||||
// 彻底删除所有供应商健康状态缓存(后端已清空数据库记录)
|
||||
queryClient.removeQueries({ queryKey: ["providerHealth"] });
|
||||
// 彻底删除所有熔断器统计缓存(代理停止后熔断器状态已重置)
|
||||
@@ -123,7 +112,7 @@ export function useProxyStatus() {
|
||||
// 按应用开启/关闭接管
|
||||
const setTakeoverForAppMutation = useMutation({
|
||||
mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) =>
|
||||
invoke("set_proxy_takeover_for_app", { appType, enabled }),
|
||||
proxyApi.setProxyTakeoverForApp(appType, enabled),
|
||||
onSuccess: (_data, variables) => {
|
||||
const appLabel =
|
||||
variables.appType === "claude"
|
||||
@@ -149,8 +138,8 @@ export function useProxyStatus() {
|
||||
{ closeButton: true },
|
||||
);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
|
||||
queryClient.invalidateQueries({ queryKey: proxyKeys.status });
|
||||
queryClient.invalidateQueries({ queryKey: proxyKeys.takeoverStatus });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const detail =
|
||||
@@ -165,60 +154,10 @@ export function useProxyStatus() {
|
||||
},
|
||||
});
|
||||
|
||||
// 代理模式切换供应商(热切换)
|
||||
const switchProxyProviderMutation = useMutation({
|
||||
mutationFn: ({
|
||||
appType,
|
||||
providerId,
|
||||
}: {
|
||||
appType: string;
|
||||
providerId: string;
|
||||
}) => invoke("switch_proxy_provider", { appType, providerId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const detail =
|
||||
extractErrorMessage(error) ||
|
||||
t("common.unknown", { defaultValue: "未知错误" });
|
||||
toast.error(
|
||||
t("proxy.switchFailed", {
|
||||
error: detail,
|
||||
defaultValue: `切换失败: ${detail}`,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// 检查是否运行中
|
||||
const checkRunning = async () => {
|
||||
try {
|
||||
return await invoke<boolean>("is_proxy_running");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查接管状态
|
||||
const checkTakeoverActive = async () => {
|
||||
try {
|
||||
return await invoke<boolean>("is_live_takeover_active");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
status,
|
||||
isLoading,
|
||||
isRunning: status?.running || false,
|
||||
takeoverStatus,
|
||||
isTakeoverActive:
|
||||
takeoverStatus?.claude ||
|
||||
takeoverStatus?.codex ||
|
||||
takeoverStatus?.gemini ||
|
||||
takeoverStatus?.grokbuild ||
|
||||
false,
|
||||
|
||||
// 启动/停止(总开关)
|
||||
startProxyServer: startProxyServerMutation.mutateAsync,
|
||||
@@ -228,17 +167,9 @@ export function useProxyStatus() {
|
||||
// 按应用接管开关
|
||||
setTakeoverForApp: setTakeoverForAppMutation.mutateAsync,
|
||||
|
||||
// 代理模式下切换供应商
|
||||
switchProxyProvider: switchProxyProviderMutation.mutateAsync,
|
||||
|
||||
// 状态检查
|
||||
checkRunning,
|
||||
checkTakeoverActive,
|
||||
|
||||
// 加载状态
|
||||
isStarting: startProxyServerMutation.isPending,
|
||||
isStoppingServer: stopProxyServerMutation.isPending,
|
||||
isStopping: stopWithRestoreMutation.isPending,
|
||||
isPending:
|
||||
startProxyServerMutation.isPending ||
|
||||
stopProxyServerMutation.isPending ||
|
||||
|
||||
+52
-50
@@ -23,7 +23,6 @@
|
||||
"unknown": "Unknown",
|
||||
"enterValidValue": "Please enter a valid value",
|
||||
"clear": "Clear",
|
||||
"toggleTheme": "Toggle theme",
|
||||
"format": "Format",
|
||||
"formatSuccess": "Formatted successfully",
|
||||
"formatError": "Format failed: {{error}}",
|
||||
@@ -1020,6 +1019,7 @@
|
||||
"providerKeyStatusLoading": "Provider identifier status is still loading. Please try again shortly.",
|
||||
"getApiKey": "Get API Key",
|
||||
"partnerPromotion": {
|
||||
"a6api": "A6API is a token aggregation platform with a built-in real-time price leaderboard that automatically picks the lowest price on the market. Smooth and stable, simple to operate, and no more tedious price comparisons. Register now to claim free trial credits!",
|
||||
"sudocode": "With one SudoCode key, Claude Code and Claude Desktop use Claude Opus 5, while Codex uses GPT-5.6. Sign up, join QQ group 726213516, and contact the group owner to claim CNY ¥10 in trial credit.",
|
||||
"code0": "code0.ai is an AI coding service platform for developers, supporting Claude Code, Codex, and Gemini. Exclusive for CC Switch users: contact support via the official website to claim free trial credits!",
|
||||
"nekocode": "NekoCode gives developers a stable, efficient, and reliable API relay for Claude, Codex, and other AI models, with transparent pay-as-you-go pricing. Exclusive 10% off for CC Switch users: register via the link above and enter promo code cc-switch at recharge to save 10%!",
|
||||
@@ -1506,24 +1506,6 @@
|
||||
"grokbuild": "Grok Build"
|
||||
},
|
||||
"rawInputLabel": "Raw",
|
||||
"dataSources": "Data Sources",
|
||||
"dataSource": {
|
||||
"proxy": "Routing",
|
||||
"session_log": "Session Log",
|
||||
"codex_db": "Codex DB",
|
||||
"codex_session": "Codex Session",
|
||||
"gemini_session": "Gemini Session",
|
||||
"opencode_session": "OpenCode Session",
|
||||
"grok_session": "Grok Build Session"
|
||||
},
|
||||
"sessionSync": {
|
||||
"trigger": "Sync session logs",
|
||||
"import": "Import Sessions",
|
||||
"resync": "Sync",
|
||||
"imported": "Imported {{count}} records from session logs",
|
||||
"upToDate": "Session logs are up to date",
|
||||
"failed": "Session sync failed"
|
||||
},
|
||||
"rebuildCodex": {
|
||||
"title": "Codex Usage Maintenance",
|
||||
"description": "Rebuild Codex session usage from local rollout logs",
|
||||
@@ -1633,6 +1615,40 @@
|
||||
"modelsDevNoResults": "No matching models",
|
||||
"modelsDevTruncated": "Showing first {{shown}} of {{total}} results — refine your search",
|
||||
"modelsDevDefaultHint": "Showing the {{shown}} most recently released models (of {{total}}) — type to search all",
|
||||
"modelsDevAutoSync": {
|
||||
"title": "Automatic models.dev pricing sync",
|
||||
"description": "When enabled, periodically fetch selected prices when CC Switch launches and keep them in your local pricing file.",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"enableConfirmTitle": "Enable automatic pricing sync?",
|
||||
"enableConfirmMessage": "After enabling, CC Switch will periodically update prices for the selected models from models.dev when it launches (at most once every 6 hours). Built-in and manually configured prices with the same model ID will be overwritten.",
|
||||
"enableConfirmAction": "Enable automatic sync",
|
||||
"configure": "Choose models",
|
||||
"configureTitle": "Choose models for automatic pricing sync",
|
||||
"configureDescription": "Search and filter models to update automatically. Your choices are stored locally.",
|
||||
"commonModels": "Automatically include common models",
|
||||
"commonModelsDescription": "Selects {{count}} recent Claude, GPT, Gemini, Grok, DeepSeek, Qwen, MiMo, LongCat, Kimi, MiniMax, and GLM models.",
|
||||
"selectFiltered": "Select filtered ({{count}})",
|
||||
"clearFiltered": "Clear filtered",
|
||||
"selectedCount": "{{count}} models selected",
|
||||
"selectionHint": "Selections with the same normalized model ID are imported once.",
|
||||
"commonBadge": "Common",
|
||||
"selectionSaved": "Automatic pricing selection saved",
|
||||
"syncSuccess": "Pricing sync complete: {{imported}} models checked, {{changed}} updated",
|
||||
"syncFailed": "Pricing sync failed: {{error}}",
|
||||
"configLoadFailed": "Failed to load local pricing configuration: {{error}}",
|
||||
"neverSynced": "Never",
|
||||
"lastSync": "Last sync",
|
||||
"commonStatus": "Common models",
|
||||
"lastError": "Last automatic sync failed: {{error}}",
|
||||
"localFile": "Local pricing file",
|
||||
"openFolder": "Open folder",
|
||||
"openFolderFailed": "Failed to open the local pricing folder: {{error}}",
|
||||
"reloadLocalFile": "Reload file",
|
||||
"localFileReloaded": "Local pricing file reloaded",
|
||||
"localFileReloadFailed": "Failed to reload local pricing file: {{error}}",
|
||||
"syncNow": "Sync now"
|
||||
},
|
||||
"cacheReadCostPerMillion": "Cache Read Cost (per million tokens, USD)",
|
||||
"cacheCreationCostPerMillion": "Cache Write Cost (per million tokens, USD)"
|
||||
},
|
||||
@@ -2283,6 +2299,9 @@
|
||||
"skillDirNotFound": "Skill directory not found: {{path}}",
|
||||
"directoryConflict": "Skill directory '{{directory}}' is already occupied by {{existing_repo}}, cannot install from {{new_repo}}",
|
||||
"emptyArchive": "Downloaded archive is empty",
|
||||
"invalidRepoRef": "Invalid repository reference: {{owner}}/{{name}}",
|
||||
"archiveTooLarge": "Archive exceeds the {{limit_mb}} MB extraction limit; aborted",
|
||||
"archiveTooManyEntries": "Archive has too many entries ({{count}}); the limit is {{limit}}",
|
||||
"downloadFailed": "Download failed: HTTP {{status}}",
|
||||
"allBranchesFailed": "All branches failed, tried: {{branches}}",
|
||||
"httpError": "HTTP error {{status}}",
|
||||
@@ -2446,7 +2465,11 @@
|
||||
"title": "Batch Import MCP Servers",
|
||||
"targetApps": "Target Apps",
|
||||
"serverCount": "MCP Servers ({{count}})",
|
||||
"enabledWarning": "After import, configurations will be written to all specified apps immediately"
|
||||
"enabledWarning": "After import, configurations will be written to all specified apps immediately",
|
||||
"command": "Command",
|
||||
"args": "Args",
|
||||
"env": "Env",
|
||||
"url": "URL"
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Import System Prompt",
|
||||
@@ -2468,10 +2491,17 @@
|
||||
"usageScript": "Usage Query",
|
||||
"usageScriptEnabled": "Enabled",
|
||||
"usageScriptDisabled": "Disabled",
|
||||
"usageScriptCode": "Script code",
|
||||
"usageScriptWarning": "This is JavaScript that runs when usage is queried, once enabled. Import it only if you trust the source.",
|
||||
"usageApiKey": "Usage API Key",
|
||||
"usageBaseUrl": "Usage Query URL",
|
||||
"usageAutoInterval": "Auto Query",
|
||||
"usageAutoIntervalValue": "Every {{minutes}} minutes"
|
||||
"usageAutoIntervalValue": "Every {{minutes}} minutes",
|
||||
"risk": {
|
||||
"envHijack": "This config sets environment variables that change how processes load code (e.g. injecting libraries or replacing CA certificates). Import only from a source you trust.",
|
||||
"privateEndpoint": "This address points at localhost or a private network. Do not import unless it is your own local service.",
|
||||
"shellCommand": "This config runs a full command line through a shell — what actually executes is in the arguments. Review each line."
|
||||
}
|
||||
},
|
||||
"iconPicker": {
|
||||
"search": "Search Icons",
|
||||
@@ -2490,8 +2520,7 @@
|
||||
"selectIcon": "Select Icon",
|
||||
"preview": "Preview",
|
||||
"clickToChange": "Click to change icon",
|
||||
"clickToSelect": "Click to select icon",
|
||||
"color": "Icon Color"
|
||||
"clickToSelect": "Click to select icon"
|
||||
},
|
||||
"migration": {
|
||||
"success": "Configuration migrated successfully",
|
||||
@@ -2733,33 +2762,6 @@
|
||||
"streamingIdle": "Streaming Idle Timeout",
|
||||
"nonStreaming": "Non-Streaming Timeout"
|
||||
},
|
||||
"circuitBreaker": {
|
||||
"failureThreshold": "Failure Threshold",
|
||||
"successThreshold": "Success Threshold",
|
||||
"timeoutSeconds": "Timeout (seconds)",
|
||||
"errorRateThreshold": "Error Rate Threshold (%)",
|
||||
"minRequests": "Minimum Requests",
|
||||
"validationFailed": "The following fields are out of valid range: {{fields}}",
|
||||
"configSaved": "Circuit breaker configuration saved",
|
||||
"saveFailed": "Save failed",
|
||||
"loading": "Loading...",
|
||||
"title": "Circuit Breaker Configuration",
|
||||
"description": "Adjust circuit breaker parameters to control fault detection and recovery behavior",
|
||||
"failureThresholdHint": "How many consecutive failures trigger the circuit breaker",
|
||||
"timeoutSecondsHint": "How long to wait before attempting recovery (half-open state)",
|
||||
"successThresholdHint": "How many successes in half-open state to close the circuit breaker",
|
||||
"errorRateThresholdHint": "Open circuit breaker when error rate exceeds this value",
|
||||
"minRequestsHint": "Minimum requests before calculating error rate",
|
||||
"saveConfig": "Save Configuration",
|
||||
"instructionsTitle": "Configuration Instructions",
|
||||
"instructions": {
|
||||
"failureThreshold": "Circuit breaker opens when consecutive failures reach this count",
|
||||
"timeout": "After circuit breaker opens, wait this time before attempting half-open",
|
||||
"successThreshold": "In half-open state, close circuit breaker when successes reach this count",
|
||||
"errorRate": "Circuit breaker opens when error rate exceeds this value",
|
||||
"minRequests": "Error rate is only calculated after request count reaches this value"
|
||||
}
|
||||
},
|
||||
"universalProvider": {
|
||||
"duplicate": "Duplicate",
|
||||
"duplicatedAndSynced": "Universal provider duplicated and synced",
|
||||
|
||||
+52
-50
@@ -23,7 +23,6 @@
|
||||
"unknown": "不明",
|
||||
"enterValidValue": "有効な値を入力してください",
|
||||
"clear": "クリア",
|
||||
"toggleTheme": "テーマを切り替え",
|
||||
"format": "フォーマット",
|
||||
"formatSuccess": "整形しました",
|
||||
"formatError": "整形に失敗しました: {{error}}",
|
||||
@@ -1020,6 +1019,7 @@
|
||||
"providerKeyStatusLoading": "プロバイダー識別子の状態を読み込んでいます。しばらくしてからもう一度お試しください",
|
||||
"getApiKey": "API Key を取得",
|
||||
"partnerPromotion": {
|
||||
"a6api": "A6API はトークンアグリゲーションサイトです。リアルタイム価格ランキングを内蔵し、全ネット最安値を自動で選択。動作は滑らかで安定し、面倒な価格比較も不要。登録するだけで体験クレジットがもらえます!",
|
||||
"sudocode": "SudoCode の1つのキーで、Claude Code と Claude Desktop では Claude Opus 5、Codex では GPT-5.6 を利用できます。登録後 QQ グループ 726213516 に参加し、管理者へ連絡すると CNY ¥10 のトライアルクレジットを受け取れます。",
|
||||
"code0": "code0.ai は開発者向けの AI コーディングサービスプラットフォームで、Claude Code、Codex、Gemini に対応。CC Switch ユーザー限定特典:公式サイトからサポートに連絡してテストクレジットを受け取れます!",
|
||||
"nekocode": "NekoCode は Claude や Codex などの AI モデルに対応した、安定・高効率で信頼性の高い API 中継サービスを提供します。明瞭な従量課金制。CC Switch ユーザー限定 10%オフ:上のリンクから登録し、チャージ時にクーポンコード cc-switch を入力すると 10%オフ!",
|
||||
@@ -1506,24 +1506,6 @@
|
||||
"grokbuild": "Grok Build"
|
||||
},
|
||||
"rawInputLabel": "原始",
|
||||
"dataSources": "データソース",
|
||||
"dataSource": {
|
||||
"proxy": "ルーティング",
|
||||
"session_log": "セッションログ",
|
||||
"codex_db": "Codex DB",
|
||||
"codex_session": "Codex セッション",
|
||||
"gemini_session": "Gemini セッション",
|
||||
"opencode_session": "OpenCode セッション",
|
||||
"grok_session": "Grok Build セッション"
|
||||
},
|
||||
"sessionSync": {
|
||||
"trigger": "セッションログを同期",
|
||||
"import": "セッションをインポート",
|
||||
"resync": "同期",
|
||||
"imported": "セッションログから {{count}} 件のレコードをインポートしました",
|
||||
"upToDate": "セッションログは最新です",
|
||||
"failed": "セッション同期に失敗しました"
|
||||
},
|
||||
"rebuildCodex": {
|
||||
"title": "Codex 使用量メンテナンス",
|
||||
"description": "ローカルの rollout ログから Codex セッション使用量を再構築します",
|
||||
@@ -1633,6 +1615,40 @@
|
||||
"modelsDevNoResults": "一致するモデルがありません",
|
||||
"modelsDevTruncated": "{{total}} 件中、先頭 {{shown}} 件のみ表示しています。検索条件を絞り込んでください",
|
||||
"modelsDevDefaultHint": "最新リリース順に {{shown}} 件を表示しています(全 {{total}} 件)。キーワード入力で全件検索できます",
|
||||
"modelsDevAutoSync": {
|
||||
"title": "models.dev 料金の自動同期",
|
||||
"description": "有効にすると、CC Switch の起動時に選択したモデルの最新料金を定期的に取得し、ローカル料金ファイルに保存します。",
|
||||
"enabled": "有効",
|
||||
"disabled": "無効",
|
||||
"enableConfirmTitle": "料金の自動同期を有効にしますか?",
|
||||
"enableConfirmMessage": "有効にすると、CC Switch の起動時に models.dev から選択したモデルの料金を定期的に更新します(最大 6 時間に 1 回)。同じモデル ID の内蔵料金と手動設定した料金は上書きされます。",
|
||||
"enableConfirmAction": "自動同期を有効にする",
|
||||
"configure": "モデルを選択",
|
||||
"configureTitle": "料金を自動同期するモデルを選択",
|
||||
"configureDescription": "検索とプロバイダーの絞り込みで自動更新するモデルを選択します。選択内容はローカルに保存されます。",
|
||||
"commonModels": "よく使うモデルを自動的に含める",
|
||||
"commonModelsDescription": "最近の Claude、GPT、Gemini、Grok、DeepSeek、Qwen、MiMo、LongCat、Kimi、MiniMax、GLM から {{count}} モデルを選択します。",
|
||||
"selectFiltered": "絞り込み結果を選択({{count}})",
|
||||
"clearFiltered": "絞り込み結果を解除",
|
||||
"selectedCount": "{{count}} モデルを選択中",
|
||||
"selectionHint": "正規化後のモデル ID が同じ項目は一度だけインポートされます。",
|
||||
"commonBadge": "よく使う",
|
||||
"selectionSaved": "自動料金同期の選択を保存しました",
|
||||
"syncSuccess": "料金同期が完了しました:{{imported}} モデルを確認、{{changed}} モデルを更新",
|
||||
"syncFailed": "料金同期に失敗しました:{{error}}",
|
||||
"configLoadFailed": "ローカル料金設定の読み込みに失敗しました:{{error}}",
|
||||
"neverSynced": "未同期",
|
||||
"lastSync": "最終同期",
|
||||
"commonStatus": "よく使うモデル",
|
||||
"lastError": "前回の自動同期に失敗しました:{{error}}",
|
||||
"localFile": "ローカル料金ファイル",
|
||||
"openFolder": "フォルダーを開く",
|
||||
"openFolderFailed": "ローカル料金フォルダーを開けませんでした: {{error}}",
|
||||
"reloadLocalFile": "ファイルを再読み込み",
|
||||
"localFileReloaded": "ローカル料金ファイルを再読み込みしました",
|
||||
"localFileReloadFailed": "ローカル料金ファイルの再読み込みに失敗しました:{{error}}",
|
||||
"syncNow": "今すぐ同期"
|
||||
},
|
||||
"cacheReadCostPerMillion": "キャッシュ読み取りコスト(100万トークンあたり、USD)",
|
||||
"cacheCreationCostPerMillion": "キャッシュ書き込みコスト(100万トークンあたり、USD)"
|
||||
},
|
||||
@@ -2283,6 +2299,9 @@
|
||||
"skillDirNotFound": "スキルディレクトリが見つかりません: {{path}}",
|
||||
"directoryConflict": "スキルディレクトリ '{{directory}}' は既に {{existing_repo}} で使用されています。{{new_repo}} からインストールできません",
|
||||
"emptyArchive": "ダウンロードしたアーカイブが空です",
|
||||
"invalidRepoRef": "リポジトリの指定が不正です:{{owner}}/{{name}}",
|
||||
"archiveTooLarge": "アーカイブが展開上限 {{limit_mb}} MB を超えたため中止しました",
|
||||
"archiveTooManyEntries": "アーカイブのエントリ数が多すぎます({{count}})。上限は {{limit}} です",
|
||||
"downloadFailed": "ダウンロードに失敗しました: HTTP {{status}}",
|
||||
"allBranchesFailed": "すべてのブランチで失敗しました。試行: {{branches}}",
|
||||
"httpError": "HTTP エラー {{status}}",
|
||||
@@ -2446,7 +2465,11 @@
|
||||
"title": "MCP サーバーを一括インポート",
|
||||
"targetApps": "ターゲットアプリ",
|
||||
"serverCount": "MCP サーバー({{count}} 件)",
|
||||
"enabledWarning": "インポート後、指定したすべてのアプリに即座に書き込まれます"
|
||||
"enabledWarning": "インポート後、指定したすべてのアプリに即座に書き込まれます",
|
||||
"command": "コマンド",
|
||||
"args": "引数",
|
||||
"env": "環境変数",
|
||||
"url": "URL"
|
||||
},
|
||||
"prompt": {
|
||||
"title": "システムプロンプトをインポート",
|
||||
@@ -2468,10 +2491,17 @@
|
||||
"usageScript": "使用量クエリ",
|
||||
"usageScriptEnabled": "有効",
|
||||
"usageScriptDisabled": "無効",
|
||||
"usageScriptCode": "スクリプトコード",
|
||||
"usageScriptWarning": "これは有効化すると使用量クエリ時に実行される JavaScript です。提供元が信頼できる場合のみインポートしてください。",
|
||||
"usageApiKey": "使用量 API キー",
|
||||
"usageBaseUrl": "使用量クエリ URL",
|
||||
"usageAutoInterval": "自動クエリ",
|
||||
"usageAutoIntervalValue": "{{minutes}} 分ごと"
|
||||
"usageAutoIntervalValue": "{{minutes}} 分ごと",
|
||||
"risk": {
|
||||
"envHijack": "この設定には、プロセスの読み込み動作を変える環境変数(ライブラリの注入、CA 証明書の差し替えなど)が含まれています。信頼できる提供元か確認してください。",
|
||||
"privateEndpoint": "このアドレスはローカルホストまたは内部ネットワークを指しています。自分で立てたローカルサービス以外はインポートしないでください。",
|
||||
"shellCommand": "この設定はシェル経由でコマンド全体を実行します。実際に動作する内容は引数にあります。1 行ずつ確認してください。"
|
||||
}
|
||||
},
|
||||
"iconPicker": {
|
||||
"search": "アイコンを検索",
|
||||
@@ -2490,8 +2520,7 @@
|
||||
"selectIcon": "アイコンを選択",
|
||||
"preview": "プレビュー",
|
||||
"clickToChange": "クリックでアイコンを変更",
|
||||
"clickToSelect": "クリックでアイコンを選択",
|
||||
"color": "アイコンカラー"
|
||||
"clickToSelect": "クリックでアイコンを選択"
|
||||
},
|
||||
"migration": {
|
||||
"success": "設定の移行が完了しました",
|
||||
@@ -2733,33 +2762,6 @@
|
||||
"streamingIdle": "ストリーミングアイドルタイムアウト",
|
||||
"nonStreaming": "非ストリーミングタイムアウト"
|
||||
},
|
||||
"circuitBreaker": {
|
||||
"failureThreshold": "失敗閾値",
|
||||
"successThreshold": "成功閾値",
|
||||
"timeoutSeconds": "タイムアウト(秒)",
|
||||
"errorRateThreshold": "エラー率閾値 (%)",
|
||||
"minRequests": "最小リクエスト数",
|
||||
"validationFailed": "以下のフィールドが有効範囲外です: {{fields}}",
|
||||
"configSaved": "サーキットブレーカー設定が保存されました",
|
||||
"saveFailed": "保存に失敗しました",
|
||||
"loading": "読み込み中...",
|
||||
"title": "サーキットブレーカー設定",
|
||||
"description": "サーキットブレーカーパラメータを調整して、障害検出と復旧動作を制御します",
|
||||
"failureThresholdHint": "連続失敗後にサーキットブレーカーを開く回数",
|
||||
"timeoutSecondsHint": "サーキットブレーカーを開いた後、復旧を試みるまでの時間(半開状態)",
|
||||
"successThresholdHint": "半開状態で成功してサーキットブレーカーを閉じる回数",
|
||||
"errorRateThresholdHint": "エラー率がこの値を超えるとサーキットブレーカーを開く",
|
||||
"minRequestsHint": "エラー率を計算する前の最小リクエスト数",
|
||||
"saveConfig": "設定を保存",
|
||||
"instructionsTitle": "設定説明",
|
||||
"instructions": {
|
||||
"failureThreshold": "連続失敗がこの回数に達するとサーキットブレーカーが開く",
|
||||
"timeout": "サーキットブレーカーを開いた後、この時間待機してから半開を試みる",
|
||||
"successThreshold": "半開状態で成功がこの回数に達するとサーキットブレーカーを閉じる",
|
||||
"errorRate": "エラー率がこの値を超えるとサーキットブレーカーが開く",
|
||||
"minRequests": "リクエスト数がこの値に達した後にのみエラー率が計算される"
|
||||
}
|
||||
},
|
||||
"universalProvider": {
|
||||
"duplicate": "複製",
|
||||
"duplicatedAndSynced": "統合プロバイダーを複製して同期しました",
|
||||
|
||||
+52
-50
@@ -23,7 +23,6 @@
|
||||
"unknown": "未知",
|
||||
"enterValidValue": "請輸入有效的內容",
|
||||
"clear": "清除",
|
||||
"toggleTheme": "切換主題",
|
||||
"format": "格式化",
|
||||
"formatSuccess": "格式化成功",
|
||||
"formatError": "格式化失敗:{{error}}",
|
||||
@@ -991,6 +990,7 @@
|
||||
"providerKeyStatusLoading": "正在載入供應商識別碼狀態,請稍後再試",
|
||||
"getApiKey": "取得 API Key",
|
||||
"partnerPromotion": {
|
||||
"a6api": "A6API 是一家 Token 聚合站,內建即時價格排行自動篩選全網最低價,滑順穩定不卡頓,簡易操作省去比價繁瑣,註冊獲得體驗金!",
|
||||
"sudocode": "SudoCode 讓 Claude Code 與 Claude Desktop 使用 Claude Opus 5,Codex 使用 GPT-5.6,一個 Key 統一管理。CC Switch 使用者註冊並加入 QQ 群 726213516,聯絡群主領取人民幣 ¥10 試用額度。",
|
||||
"code0": "code0.ai 是面向開發者的 AI 程式設計服務平台,支援 Claude Code、Codex、Gemini。CC Switch 使用者專屬福利:透過官網聯繫客服即可領取測試額度!",
|
||||
"nekocode": "NekoCode 為開發者提供穩定、高效、可靠的 Claude、Codex 等 AI 模型 API 中轉服務,價格透明、按量計費。CC Switch 使用者專享 9 折:透過上方連結註冊,儲值時輸入優惠碼 cc-switch 即享 9 折優惠!",
|
||||
@@ -1477,24 +1477,6 @@
|
||||
"grokbuild": "Grok Build"
|
||||
},
|
||||
"rawInputLabel": "原始",
|
||||
"dataSources": "資料來源",
|
||||
"dataSource": {
|
||||
"proxy": "路由",
|
||||
"session_log": "工作階段日誌",
|
||||
"codex_db": "Codex 資料庫",
|
||||
"codex_session": "Codex 工作階段日誌",
|
||||
"gemini_session": "Gemini 工作階段日誌",
|
||||
"opencode_session": "OpenCode 工作階段日誌",
|
||||
"grok_session": "Grok Build 工作階段日誌"
|
||||
},
|
||||
"sessionSync": {
|
||||
"trigger": "同步工作階段日誌",
|
||||
"import": "匯入工作階段",
|
||||
"resync": "同步",
|
||||
"imported": "從工作階段日誌匯入了 {{count}} 筆紀錄",
|
||||
"upToDate": "工作階段日誌已是最新",
|
||||
"failed": "工作階段同步失敗"
|
||||
},
|
||||
"rebuildCodex": {
|
||||
"title": "Codex 用量維護",
|
||||
"description": "從本機 rollout 日誌重新建構 Codex 工作階段用量",
|
||||
@@ -1604,6 +1586,40 @@
|
||||
"modelsDevNoResults": "沒有符合的模型",
|
||||
"modelsDevTruncated": "僅顯示前 {{shown}} 條,共 {{total}} 條結果,請縮小搜尋範圍",
|
||||
"modelsDevDefaultHint": "預設展示最新發布的 {{shown}} 個模型(共 {{total}} 個),輸入關鍵字可全量搜尋",
|
||||
"modelsDevAutoSync": {
|
||||
"title": "自動同步 models.dev 定價",
|
||||
"description": "開啟後,CC Switch 會在啟動時定期擷取所選模型的最新價格,並儲存到本機定價檔案。",
|
||||
"enabled": "已開啟",
|
||||
"disabled": "已關閉",
|
||||
"enableConfirmTitle": "開啟自動同步定價?",
|
||||
"enableConfirmMessage": "開啟後,CC Switch 會在啟動時定期從 models.dev 更新所選模型的價格(最多每 6 小時一次)。相同模型 ID 的軟體內建價格和手動設定價格都會被覆寫。",
|
||||
"enableConfirmAction": "開啟自動同步",
|
||||
"configure": "選擇模型",
|
||||
"configureTitle": "選擇自動同步定價的模型",
|
||||
"configureDescription": "透過搜尋和供應商篩選選擇需要自動更新的模型,選擇結果儲存在本機。",
|
||||
"commonModels": "自動包含常用模型",
|
||||
"commonModelsDescription": "自動選擇 {{count}} 個近期 Claude、GPT、Gemini、Grok、DeepSeek、Qwen、MiMo、LongCat、Kimi、MiniMax 和 GLM 模型。",
|
||||
"selectFiltered": "全選篩選結果({{count}})",
|
||||
"clearFiltered": "清除篩選結果",
|
||||
"selectedCount": "已選擇 {{count}} 個模型",
|
||||
"selectionHint": "標準化模型 ID 相同的選項只會匯入一次。",
|
||||
"commonBadge": "常用",
|
||||
"selectionSaved": "自動定價同步選擇已儲存",
|
||||
"syncSuccess": "定價同步完成:檢查 {{imported}} 個模型,更新 {{changed}} 個",
|
||||
"syncFailed": "定價同步失敗:{{error}}",
|
||||
"configLoadFailed": "載入本機定價設定失敗:{{error}}",
|
||||
"neverSynced": "從未同步",
|
||||
"lastSync": "上次同步",
|
||||
"commonStatus": "常用模型",
|
||||
"lastError": "上次自動同步失敗:{{error}}",
|
||||
"localFile": "本機定價檔案",
|
||||
"openFolder": "開啟資料夾",
|
||||
"openFolderFailed": "開啟本機定價檔案資料夾失敗:{{error}}",
|
||||
"reloadLocalFile": "重新載入檔案",
|
||||
"localFileReloaded": "本機定價檔案已重新載入",
|
||||
"localFileReloadFailed": "重新載入本機定價檔案失敗:{{error}}",
|
||||
"syncNow": "立即同步"
|
||||
},
|
||||
"cacheReadCostPerMillion": "快取讀取成本 (每百萬 tokens, USD)",
|
||||
"cacheCreationCostPerMillion": "快取寫入成本 (每百萬 tokens, USD)"
|
||||
},
|
||||
@@ -2254,6 +2270,9 @@
|
||||
"skillDirNotFound": "技能目錄不存在:{{path}}",
|
||||
"directoryConflict": "技能目錄 '{{directory}}' 已被 {{existing_repo}} 佔用,無法從 {{new_repo}} 安裝",
|
||||
"emptyArchive": "下載的壓縮檔為空",
|
||||
"invalidRepoRef": "倉庫位址不合法:{{owner}}/{{name}}",
|
||||
"archiveTooLarge": "壓縮檔解壓後超過 {{limit_mb}} MB 上限,已中止",
|
||||
"archiveTooManyEntries": "壓縮檔項目過多({{count}}),上限 {{limit}}",
|
||||
"downloadFailed": "下載失敗:HTTP {{status}}",
|
||||
"allBranchesFailed": "所有分支下載失敗,嘗試了:{{branches}}",
|
||||
"httpError": "HTTP 錯誤 {{status}}",
|
||||
@@ -2417,7 +2436,11 @@
|
||||
"title": "批次匯入 MCP Servers",
|
||||
"targetApps": "目標應用程式",
|
||||
"serverCount": "MCP Servers ({{count}} 個)",
|
||||
"enabledWarning": "匯入後將立即寫入所有指定應用程式的設定檔"
|
||||
"enabledWarning": "匯入後將立即寫入所有指定應用程式的設定檔",
|
||||
"command": "命令",
|
||||
"args": "參數",
|
||||
"env": "環境",
|
||||
"url": "位址"
|
||||
},
|
||||
"prompt": {
|
||||
"title": "匯入系統提示詞",
|
||||
@@ -2439,10 +2462,17 @@
|
||||
"usageScript": "用量查詢",
|
||||
"usageScriptEnabled": "已啟用",
|
||||
"usageScriptDisabled": "未啟用",
|
||||
"usageScriptCode": "指令碼程式碼",
|
||||
"usageScriptWarning": "這是一段 JavaScript 程式碼,啟用後會在查詢用量時執行。請確認來源可信後再匯入。",
|
||||
"usageApiKey": "用量 API Key",
|
||||
"usageBaseUrl": "用量查詢位址",
|
||||
"usageAutoInterval": "自動查詢",
|
||||
"usageAutoIntervalValue": "每 {{minutes}} 分鐘"
|
||||
"usageAutoIntervalValue": "每 {{minutes}} 分鐘",
|
||||
"risk": {
|
||||
"envHijack": "該設定包含可改變處理程序載入行為的環境變數(例如注入動態程式庫、替換 CA 憑證),請確認來源可信。",
|
||||
"privateEndpoint": "該位址指向本機或內部網路。若非你自建的本機服務,請勿匯入。",
|
||||
"shellCommand": "該設定透過 shell 執行整段命令,實際執行的內容在參數裡,請逐行核對。"
|
||||
}
|
||||
},
|
||||
"iconPicker": {
|
||||
"search": "搜尋圖示",
|
||||
@@ -2461,8 +2491,7 @@
|
||||
"selectIcon": "選擇圖示",
|
||||
"preview": "預覽",
|
||||
"clickToChange": "點擊更換圖示",
|
||||
"clickToSelect": "點擊選擇圖示",
|
||||
"color": "圖示顏色"
|
||||
"clickToSelect": "點擊選擇圖示"
|
||||
},
|
||||
"migration": {
|
||||
"success": "設定遷移成功",
|
||||
@@ -2704,33 +2733,6 @@
|
||||
"streamingIdle": "串流閒置逾時",
|
||||
"nonStreaming": "非串流逾時"
|
||||
},
|
||||
"circuitBreaker": {
|
||||
"failureThreshold": "失敗閾值",
|
||||
"successThreshold": "成功閾值",
|
||||
"timeoutSeconds": "逾時時間(秒)",
|
||||
"errorRateThreshold": "錯誤率閾值 (%)",
|
||||
"minRequests": "最小請求數",
|
||||
"validationFailed": "以下欄位超出有效範圍:{{fields}}",
|
||||
"configSaved": "斷路器設定已儲存",
|
||||
"saveFailed": "儲存失敗",
|
||||
"loading": "載入中...",
|
||||
"title": "斷路器設定",
|
||||
"description": "調整斷路器參數以控制故障檢測和恢復行為",
|
||||
"failureThresholdHint": "連續失敗多少次後打開斷路器",
|
||||
"timeoutSecondsHint": "斷路器打開後多久嘗試恢復(半開狀態)",
|
||||
"successThresholdHint": "半開狀態下成功多少次後關閉斷路器",
|
||||
"errorRateThresholdHint": "錯誤率超過此值時打開斷路器",
|
||||
"minRequestsHint": "計算錯誤率前的最小請求數",
|
||||
"saveConfig": "儲存設定",
|
||||
"instructionsTitle": "設定說明",
|
||||
"instructions": {
|
||||
"failureThreshold": "連續失敗達到此次數時,斷路器打開",
|
||||
"timeout": "斷路器打開後,等待此時間後嘗試半開",
|
||||
"successThreshold": "半開狀態下,成功達到此次數時關閉斷路器",
|
||||
"errorRate": "錯誤率超過此值時,斷路器打開",
|
||||
"minRequests": "只有請求數達到此值後才計算錯誤率"
|
||||
}
|
||||
},
|
||||
"universalProvider": {
|
||||
"duplicate": "複製",
|
||||
"duplicatedAndSynced": "通用供應商已複製並同步",
|
||||
|
||||
+52
-50
@@ -23,7 +23,6 @@
|
||||
"unknown": "未知",
|
||||
"enterValidValue": "请输入有效的内容",
|
||||
"clear": "清除",
|
||||
"toggleTheme": "切换主题",
|
||||
"format": "格式化",
|
||||
"formatSuccess": "格式化成功",
|
||||
"formatError": "格式化失败:{{error}}",
|
||||
@@ -1020,6 +1019,7 @@
|
||||
"providerKeyStatusLoading": "正在加载供应商标识状态,请稍后再试",
|
||||
"getApiKey": "获取 API Key",
|
||||
"partnerPromotion": {
|
||||
"a6api": "A6API 是一家 Token 聚合站,内置实时价格排行自动筛选全网最低价,丝滑稳定不卡顿,简易操作省去比价繁琐,注册获得体验金!",
|
||||
"sudocode": "SudoCode 让 Claude Code 与 Claude Desktop 接入 Claude Opus 5,Codex 接入 GPT-5.6,一个 Key 统一使用。CC Switch 用户注册并加入 QQ 群 726213516,联系群主领取 ¥10 试用额度。",
|
||||
"code0": "code0.ai 是面向开发者的 AI 编程服务平台,支持 Claude Code、Codex、Gemini。CC Switch 用户专属福利:通过官网联系客服即可领取测试额度!",
|
||||
"nekocode": "NekoCode 为开发者提供稳定、高效、可靠的 Claude、Codex 等 AI 模型 API 中转服务,价格透明、按量计费。CC Switch 用户专享 9 折:通过上方链接注册,充值时输入优惠码 cc-switch 即享 9 折优惠!",
|
||||
@@ -1506,24 +1506,6 @@
|
||||
"grokbuild": "Grok Build"
|
||||
},
|
||||
"rawInputLabel": "原始",
|
||||
"dataSources": "数据来源",
|
||||
"dataSource": {
|
||||
"proxy": "路由",
|
||||
"session_log": "会话日志",
|
||||
"codex_db": "Codex 数据库",
|
||||
"codex_session": "Codex 会话日志",
|
||||
"gemini_session": "Gemini 会话日志",
|
||||
"opencode_session": "OpenCode 会话日志",
|
||||
"grok_session": "Grok Build 会话日志"
|
||||
},
|
||||
"sessionSync": {
|
||||
"trigger": "同步会话日志",
|
||||
"import": "导入会话",
|
||||
"resync": "同步",
|
||||
"imported": "从会话日志导入了 {{count}} 条记录",
|
||||
"upToDate": "会话日志已是最新",
|
||||
"failed": "会话同步失败"
|
||||
},
|
||||
"rebuildCodex": {
|
||||
"title": "Codex 用量维护",
|
||||
"description": "从本地 rollout 日志重新构建 Codex 会话用量",
|
||||
@@ -1633,6 +1615,40 @@
|
||||
"modelsDevNoResults": "没有匹配的模型",
|
||||
"modelsDevTruncated": "仅显示前 {{shown}} 条,共 {{total}} 条结果,请缩小搜索范围",
|
||||
"modelsDevDefaultHint": "默认展示最新发布的 {{shown}} 个模型(共 {{total}} 个),输入关键字可全量搜索",
|
||||
"modelsDevAutoSync": {
|
||||
"title": "自动同步 models.dev 定价",
|
||||
"description": "开启后,CC Switch 会在启动时定期拉取所选模型的最新价格,并保存到本地定价文件。",
|
||||
"enabled": "已开启",
|
||||
"disabled": "已关闭",
|
||||
"enableConfirmTitle": "开启自动同步定价?",
|
||||
"enableConfirmMessage": "开启后,CC Switch 会在启动时定期从 models.dev 更新所选模型的价格(最多每 6 小时一次)。同一模型 ID 的软件内置价格和手动设置价格都会被覆盖。",
|
||||
"enableConfirmAction": "开启自动同步",
|
||||
"configure": "选择模型",
|
||||
"configureTitle": "选择自动同步定价的模型",
|
||||
"configureDescription": "通过搜索和供应商筛选选择需要自动更新的模型,选择结果保存在本地。",
|
||||
"commonModels": "自动包含常用模型",
|
||||
"commonModelsDescription": "自动选择 {{count}} 个近期 Claude、GPT、Gemini、Grok、DeepSeek、Qwen、MiMo、LongCat、Kimi、MiniMax 和 GLM 模型。",
|
||||
"selectFiltered": "全选筛选结果({{count}})",
|
||||
"clearFiltered": "清空筛选结果",
|
||||
"selectedCount": "已选择 {{count}} 个模型",
|
||||
"selectionHint": "归一化模型 ID 相同的选项只会导入一次。",
|
||||
"commonBadge": "常用",
|
||||
"selectionSaved": "自动定价同步选择已保存",
|
||||
"syncSuccess": "定价同步完成:检查 {{imported}} 个模型,更新 {{changed}} 个",
|
||||
"syncFailed": "定价同步失败:{{error}}",
|
||||
"configLoadFailed": "加载本地定价配置失败:{{error}}",
|
||||
"neverSynced": "从未同步",
|
||||
"lastSync": "上次同步",
|
||||
"commonStatus": "常用模型",
|
||||
"lastError": "上次自动同步失败:{{error}}",
|
||||
"localFile": "本地定价文件",
|
||||
"openFolder": "打开目录",
|
||||
"openFolderFailed": "打开本地定价文件目录失败:{{error}}",
|
||||
"reloadLocalFile": "重新加载文件",
|
||||
"localFileReloaded": "本地定价文件已重新加载",
|
||||
"localFileReloadFailed": "重新加载本地定价文件失败:{{error}}",
|
||||
"syncNow": "立即同步"
|
||||
},
|
||||
"cacheReadCostPerMillion": "缓存读取成本 (每百万 tokens, USD)",
|
||||
"cacheCreationCostPerMillion": "缓存写入成本 (每百万 tokens, USD)"
|
||||
},
|
||||
@@ -2283,6 +2299,9 @@
|
||||
"skillDirNotFound": "技能目录不存在:{{path}}",
|
||||
"directoryConflict": "技能目录 '{{directory}}' 已被 {{existing_repo}} 占用,无法从 {{new_repo}} 安装",
|
||||
"emptyArchive": "下载的压缩包为空",
|
||||
"invalidRepoRef": "仓库地址不合法:{{owner}}/{{name}}",
|
||||
"archiveTooLarge": "压缩包解压后超过 {{limit_mb}} MB 上限,已中止",
|
||||
"archiveTooManyEntries": "压缩包条目过多({{count}}),上限 {{limit}}",
|
||||
"downloadFailed": "下载失败:HTTP {{status}}",
|
||||
"allBranchesFailed": "所有分支下载失败,尝试了:{{branches}}",
|
||||
"httpError": "HTTP 错误 {{status}}",
|
||||
@@ -2446,7 +2465,11 @@
|
||||
"title": "批量导入 MCP Servers",
|
||||
"targetApps": "目标应用",
|
||||
"serverCount": "MCP Servers ({{count}} 个)",
|
||||
"enabledWarning": "导入后将立即写入所有指定应用的配置文件"
|
||||
"enabledWarning": "导入后将立即写入所有指定应用的配置文件",
|
||||
"command": "命令",
|
||||
"args": "参数",
|
||||
"env": "环境",
|
||||
"url": "地址"
|
||||
},
|
||||
"prompt": {
|
||||
"title": "导入系统提示词",
|
||||
@@ -2468,10 +2491,17 @@
|
||||
"usageScript": "用量查询",
|
||||
"usageScriptEnabled": "已启用",
|
||||
"usageScriptDisabled": "未启用",
|
||||
"usageScriptCode": "脚本代码",
|
||||
"usageScriptWarning": "这是一段 JavaScript 代码,启用后会在查询用量时执行。请确认来源可信后再导入。",
|
||||
"usageApiKey": "用量 API Key",
|
||||
"usageBaseUrl": "用量查询地址",
|
||||
"usageAutoInterval": "自动查询",
|
||||
"usageAutoIntervalValue": "每 {{minutes}} 分钟"
|
||||
"usageAutoIntervalValue": "每 {{minutes}} 分钟",
|
||||
"risk": {
|
||||
"envHijack": "该配置包含可改变进程加载行为的环境变量(如注入动态库、替换 CA 证书),请确认来源可信。",
|
||||
"privateEndpoint": "该地址指向本机或内网。若非你自建的本地服务,请勿导入。",
|
||||
"shellCommand": "该配置通过 shell 执行一整段命令,实际运行的内容在参数里,请逐行核对。"
|
||||
}
|
||||
},
|
||||
"iconPicker": {
|
||||
"search": "搜索图标",
|
||||
@@ -2490,8 +2520,7 @@
|
||||
"selectIcon": "选择图标",
|
||||
"preview": "预览",
|
||||
"clickToChange": "点击更换图标",
|
||||
"clickToSelect": "点击选择图标",
|
||||
"color": "图标颜色"
|
||||
"clickToSelect": "点击选择图标"
|
||||
},
|
||||
"migration": {
|
||||
"success": "配置迁移成功",
|
||||
@@ -2733,33 +2762,6 @@
|
||||
"streamingIdle": "流式静默超时",
|
||||
"nonStreaming": "非流式超时"
|
||||
},
|
||||
"circuitBreaker": {
|
||||
"failureThreshold": "失败阈值",
|
||||
"successThreshold": "成功阈值",
|
||||
"timeoutSeconds": "超时时间(秒)",
|
||||
"errorRateThreshold": "错误率阈值 (%)",
|
||||
"minRequests": "最小请求数",
|
||||
"validationFailed": "以下字段超出有效范围: {{fields}}",
|
||||
"configSaved": "熔断器配置已保存",
|
||||
"saveFailed": "保存失败",
|
||||
"loading": "加载中...",
|
||||
"title": "熔断器配置",
|
||||
"description": "调整熔断器参数以控制故障检测和恢复行为",
|
||||
"failureThresholdHint": "连续失败多少次后打开熔断器",
|
||||
"timeoutSecondsHint": "熔断器打开后多久尝试恢复(半开状态)",
|
||||
"successThresholdHint": "半开状态下成功多少次后关闭熔断器",
|
||||
"errorRateThresholdHint": "错误率超过此值时打开熔断器",
|
||||
"minRequestsHint": "计算错误率前的最小请求数",
|
||||
"saveConfig": "保存配置",
|
||||
"instructionsTitle": "配置说明",
|
||||
"instructions": {
|
||||
"failureThreshold": "连续失败达到此次数时,熔断器打开",
|
||||
"timeout": "熔断器打开后,等待此时间后尝试半开",
|
||||
"successThreshold": "半开状态下,成功达到此次数时关闭熔断器",
|
||||
"errorRate": "错误率超过此值时,熔断器打开",
|
||||
"minRequests": "只有请求数达到此值后才计算错误率"
|
||||
}
|
||||
},
|
||||
"universalProvider": {
|
||||
"duplicate": "复制",
|
||||
"duplicatedAndSynced": "统一供应商已复制并同步",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -1,6 +1,7 @@
|
||||
// Auto-generated icon index
|
||||
// Do not edit manually
|
||||
// Hand-curated icon index with optimized SVG content and custom name mappings.
|
||||
// Update entries deliberately; automatic regeneration is intentionally unsupported.
|
||||
|
||||
import _a6api from "./a6-icon.png";
|
||||
import _apikeyfun from "./apikeyfun.png";
|
||||
import _apinebula from "./apinebula_icon.png";
|
||||
import _atlascloud from "./atlascloud_icon.png";
|
||||
@@ -103,6 +104,7 @@ export const icons: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const iconUrls: Record<string, string> = {
|
||||
a6api: _a6api,
|
||||
apikeyfun: _apikeyfun,
|
||||
apinebula: _apinebula,
|
||||
atlascloud: _atlascloud,
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
import { IconMetadata } from "@/types/icon";
|
||||
|
||||
export const iconMetadata: Record<string, IconMetadata> = {
|
||||
a6api: {
|
||||
name: "a6api",
|
||||
displayName: "A6API",
|
||||
category: "ai-provider",
|
||||
keywords: ["a6api", "a6", "aggregator", "relay", "gateway", "claude"],
|
||||
defaultColor: "#3B82F6",
|
||||
},
|
||||
aigocode: {
|
||||
name: "aigocode",
|
||||
displayName: "AIGoCode",
|
||||
|
||||
+5
-31
@@ -1,6 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
ProxyConfig,
|
||||
ProxyStatus,
|
||||
ProxyServerInfo,
|
||||
ProxyTakeoverStatus,
|
||||
@@ -16,6 +15,11 @@ export const proxyApi = {
|
||||
return invoke("start_proxy_server");
|
||||
},
|
||||
|
||||
// 停止代理服务器(不恢复已接管配置)
|
||||
async stopProxyServer(): Promise<void> {
|
||||
return invoke("stop_proxy_server");
|
||||
},
|
||||
|
||||
// 停止代理服务器并恢复配置
|
||||
async stopProxyWithRestore(): Promise<void> {
|
||||
return invoke("stop_proxy_with_restore");
|
||||
@@ -26,24 +30,6 @@ export const proxyApi = {
|
||||
return invoke("get_proxy_status");
|
||||
},
|
||||
|
||||
// 检查代理服务器是否正在运行
|
||||
async isProxyRunning(): Promise<boolean> {
|
||||
return invoke("is_proxy_running");
|
||||
},
|
||||
|
||||
// 检查是否处于接管模式
|
||||
async isLiveTakeoverActive(): Promise<boolean> {
|
||||
return invoke("is_live_takeover_active");
|
||||
},
|
||||
|
||||
// 代理模式下切换供应商
|
||||
async switchProxyProvider(
|
||||
appType: string,
|
||||
providerId: string,
|
||||
): Promise<void> {
|
||||
return invoke("switch_proxy_provider", { appType, providerId });
|
||||
},
|
||||
|
||||
// ========== 接管状态 API ==========
|
||||
|
||||
// 获取各应用接管状态
|
||||
@@ -59,18 +45,6 @@ export const proxyApi = {
|
||||
return invoke("set_proxy_takeover_for_app", { appType, enabled });
|
||||
},
|
||||
|
||||
// ========== Legacy 代理配置 API (兼容) ==========
|
||||
|
||||
// 获取代理配置(旧版 v2 兼容接口)
|
||||
async getProxyConfig(): Promise<ProxyConfig> {
|
||||
return invoke("get_proxy_config");
|
||||
},
|
||||
|
||||
// 更新代理配置(旧版 v2 兼容接口)
|
||||
async updateProxyConfig(config: ProxyConfig): Promise<void> {
|
||||
return invoke("update_proxy_config", { config });
|
||||
},
|
||||
|
||||
// ========== v3+ 全局/应用级配置 API ==========
|
||||
|
||||
// 获取全局代理配置
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
RequestLog,
|
||||
LogFilters,
|
||||
ModelPricing,
|
||||
ModelsDevSyncConfig,
|
||||
ModelsDevSyncState,
|
||||
ProviderLimitStatus,
|
||||
PaginatedLogs,
|
||||
SessionSyncResult,
|
||||
@@ -164,6 +166,27 @@ export const usageApi = {
|
||||
});
|
||||
},
|
||||
|
||||
updateModelPricingBatch: async (entries: ModelPricing[]): Promise<number> => {
|
||||
return invoke("update_model_pricing_batch", { entries });
|
||||
},
|
||||
|
||||
getModelsDevSyncConfig: async (): Promise<ModelsDevSyncState> => {
|
||||
return invoke("get_models_dev_sync_config");
|
||||
},
|
||||
|
||||
saveModelsDevSyncConfig: async (
|
||||
config: ModelsDevSyncConfig,
|
||||
): Promise<void> => {
|
||||
return invoke("save_models_dev_sync_config", { config });
|
||||
},
|
||||
|
||||
recordModelsDevSyncResult: async (
|
||||
syncedAt: number | null,
|
||||
error: string | null,
|
||||
): Promise<void> => {
|
||||
return invoke("record_models_dev_sync_result", { syncedAt, error });
|
||||
},
|
||||
|
||||
deleteModelPricing: async (modelId: string): Promise<void> => {
|
||||
return invoke("delete_model_pricing", { modelId });
|
||||
},
|
||||
|
||||
@@ -37,6 +37,9 @@ function getErrorI18nKey(code: string): string {
|
||||
SKILL_DIR_NOT_FOUND: "skills.error.skillDirNotFound",
|
||||
SKILL_DIRECTORY_CONFLICT: "skills.error.directoryConflict",
|
||||
EMPTY_ARCHIVE: "skills.error.emptyArchive",
|
||||
INVALID_REPO_REF: "skills.error.invalidRepoRef",
|
||||
ARCHIVE_TOO_LARGE: "skills.error.archiveTooLarge",
|
||||
ARCHIVE_TOO_MANY_ENTRIES: "skills.error.archiveTooManyEntries",
|
||||
GET_HOME_DIR_FAILED: "skills.error.getHomeDirFailed",
|
||||
NO_SKILLS_IN_ZIP: "skills.error.noSkillsInZip",
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user