mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-01 23:12:42 +08:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28529620f4 | |||
| b3a20e58b0 | |||
| 58dd376dc4 | |||
| 56a66eea36 | |||
| 8ae1ce8558 | |||
| f42534ed26 | |||
| e3f80a98f3 | |||
| f07edc7680 | |||
| a354f08a3d | |||
| 4bfb3fc30d | |||
| c49cf96a16 | |||
| b884595a23 | |||
| 4317bd9981 | |||
| 3c1154bed9 | |||
| c0ff89b9b2 | |||
| 3b9d059343 | |||
| 6b13d01825 | |||
| f5f4281d06 | |||
| 56fb46c093 | |||
| 30409878bd | |||
| bfb767ae17 | |||
| dbb265956e | |||
| 87b0e3fb85 | |||
| b33d300d0b | |||
| 245d180c25 | |||
| cfa90f396a | |||
| 19bf236e58 | |||
| a443eae95a | |||
| 6dbb944b54 | |||
| cd17912f04 | |||
| 134bdc0e65 | |||
| 35486afdda | |||
| c98913df41 | |||
| 993077c60c | |||
| 12b972a66e | |||
| ff3bc242cc | |||
| ccda04bfa6 | |||
| 2b2f2cfad9 | |||
| 708b38791c | |||
| 934a2d0348 | |||
| bc7c82228b | |||
| b972f0a3bd | |||
| b0482320a7 | |||
| 9cf4ae41e6 | |||
| 876e9f898d | |||
| 414b71500c | |||
| 878c26f31e | |||
| 6c9d444c8a | |||
| 34cbb375f0 | |||
| cd161f4401 | |||
| 3cf84ca362 | |||
| 15d5dbe065 | |||
| a377d79303 | |||
| 846fbdd1c0 | |||
| 3a9fb13a0b |
@@ -0,0 +1,222 @@
|
||||
# Mirror release assets to Cloudflare R2 so ccswitch.io/download can serve
|
||||
# them directly (dl.ccswitch.io), and mirror the Tauri updater manifest and
|
||||
# artifacts so in-app updates avoid GitHub too (the updater verifies minisign
|
||||
# signatures client-side, so the mirror is untrusted).
|
||||
#
|
||||
# Triggered on release promotion (`released` fires when a prerelease is
|
||||
# flipped to a full release, or a release is published as stable directly) —
|
||||
# NOT on tag push. This keeps the R2 root manifests behind the same gate as
|
||||
# GitHub's /releases/latest/: while a release sits as prerelease, neither the
|
||||
# download page nor the updater sees it. Additionally, a run only touches the
|
||||
# root manifests (and prunes old versions) when its tag IS releases/latest —
|
||||
# manual backfills of older tags restore versioned files only.
|
||||
#
|
||||
# Caveat (by design of tauri-plugin-updater): the updater stops at the first
|
||||
# endpoint whose response parses, so a stale-but-valid R2 manifest would mask
|
||||
# newer GitHub releases. That is why this sync hard-fails on the official
|
||||
# repo when the R2 secrets are missing, instead of silently skipping.
|
||||
#
|
||||
# Required secrets: R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY
|
||||
name: Sync release to R2
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
# Manual recovery/backfill: re-sync any existing release tag.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to sync (e.g. v3.18.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# latest.json / manifest.json are last-writer-wins — serialize runs and keep
|
||||
# every trigger queued instead of the default replace-the-pending behavior.
|
||||
concurrency:
|
||||
group: sync-r2
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
sync-to-r2:
|
||||
name: Sync release to R2
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
R2_BUCKET: cc-switch-releases
|
||||
R2_PUBLIC_BASE_URL: https://dl.ccswitch.io
|
||||
KEEP_VERSIONS: "5"
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
# aws-cli >= 2.23 defaults to CRC checksums that R2 rejects.
|
||||
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
|
||||
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
|
||||
R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
|
||||
TAG: ${{ github.event.release.tag_name || inputs.tag }}
|
||||
steps:
|
||||
- name: Check R2 secrets
|
||||
id: r2
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
run: |
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "::error::No release tag to sync."
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$R2_ACCOUNT_ID" ] && [ -n "$AWS_ACCESS_KEY_ID" ] && [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
|
||||
echo "configured=true" >> "$GITHUB_OUTPUT"
|
||||
elif [ "$GITHUB_REPOSITORY" = "farion1231/cc-switch" ]; then
|
||||
# A silently stale mirror strands updater users on old versions
|
||||
# (the R2 endpoint wins as long as its manifest parses), so the
|
||||
# official repo must never skip this sync.
|
||||
echo "::error::R2 secrets (R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY) are missing on the official repo; refusing to skip the mirror sync."
|
||||
exit 1
|
||||
else
|
||||
echo "configured=false" >> "$GITHUB_OUTPUT"
|
||||
echo "R2 secrets not configured; skipping download mirror sync."
|
||||
fi
|
||||
|
||||
# Only the tag that IS GitHub's releases/latest may touch the bucket-root
|
||||
# manifests or prune old versions. This keeps manual backfills of old
|
||||
# tags harmless (they only restore versioned files — otherwise the root
|
||||
# manifests would point at a version the prune step deletes right after)
|
||||
# and makes out-of-order runs self-correcting: whichever run executes,
|
||||
# only the true latest publishes the manifests.
|
||||
- name: Check whether tag is the latest stable release
|
||||
id: latest
|
||||
if: steps.r2.outputs.configured == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# An API failure must fail the run — mapping it to "not latest"
|
||||
# would skip the root manifests on a green run, silently leaving
|
||||
# them stale (the exact failure mode this workflow exists to make
|
||||
# loud). Retry transient errors, then give up loudly.
|
||||
latest=""
|
||||
for attempt in 1 2 3 4; do
|
||||
latest=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name || true)
|
||||
if [ -n "$latest" ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -lt 4 ]; then
|
||||
sleep $((attempt * 10))
|
||||
fi
|
||||
done
|
||||
if [ -z "$latest" ]; then
|
||||
echo "::error::Could not resolve the latest stable release from GitHub after 4 attempts; failing instead of guessing."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$TAG" = "$latest" ]; then
|
||||
echo "is_latest=true" >> "$GITHUB_OUTPUT"
|
||||
echo "$TAG is the latest stable release; full sync."
|
||||
else
|
||||
echo "is_latest=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::$TAG is not the latest stable release ($latest); syncing versioned files only — root manifests and pruning are skipped."
|
||||
fi
|
||||
|
||||
- name: Checkout scripts
|
||||
if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true'
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
|
||||
- name: Download release assets
|
||||
if: steps.r2.outputs.configured == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p r2-assets
|
||||
gh release download "$TAG" --dir r2-assets --repo "$GITHUB_REPOSITORY"
|
||||
ls -la r2-assets
|
||||
|
||||
- name: Generate download manifest
|
||||
if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pub_date=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json publishedAt --jq .publishedAt)
|
||||
node scripts/generate-download-manifest.mjs r2-assets "$TAG" "$R2_PUBLIC_BASE_URL" manifest.json "$pub_date"
|
||||
|
||||
- name: Rewrite updater manifest for R2
|
||||
if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/rewrite-updater-manifest.mjs r2-assets/latest.json "$TAG" "$R2_PUBLIC_BASE_URL" latest-r2.json
|
||||
|
||||
- name: Upload versioned assets to R2
|
||||
if: steps.r2.outputs.configured == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Versioned asset paths never change — cache hard at the edge.
|
||||
# .tar.gz is the macOS updater payload; .sig files are not needed
|
||||
# (signatures are embedded in latest.json) and latest.json goes to
|
||||
# the bucket root with a short TTL instead.
|
||||
aws s3 cp r2-assets "s3://$R2_BUCKET/$TAG/" \
|
||||
--recursive \
|
||||
--exclude "*.sig" --exclude "latest.json" \
|
||||
--cache-control "public, max-age=31536000, immutable" \
|
||||
--endpoint-url "$R2_ENDPOINT"
|
||||
|
||||
- name: Publish root manifests to R2
|
||||
if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Re-verify right before touching the root manifests: a newer
|
||||
# release may have been promoted while assets were downloading.
|
||||
# Skipping then is safe — the newer release's own queued run
|
||||
# publishes the newer manifests. An unresolvable API is still a
|
||||
# hard failure, never a guess.
|
||||
latest=""
|
||||
for attempt in 1 2 3 4; do
|
||||
latest=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name || true)
|
||||
if [ -n "$latest" ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -lt 4 ]; then
|
||||
sleep $((attempt * 10))
|
||||
fi
|
||||
done
|
||||
if [ -z "$latest" ]; then
|
||||
echo "::error::Could not re-verify the latest stable release before publishing root manifests; failing instead of guessing."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$TAG" != "$latest" ]; then
|
||||
echo "::warning::$TAG stopped being the latest stable release mid-run ($latest is); leaving root manifests untouched."
|
||||
exit 0
|
||||
fi
|
||||
# Both manifests are replaced on every release — keep edge cache
|
||||
# short. Uploaded after the assets they reference so an aborted run
|
||||
# never leaves them pointing at missing files.
|
||||
aws s3 cp manifest.json "s3://$R2_BUCKET/manifest.json" \
|
||||
--content-type "application/json" \
|
||||
--cache-control "public, max-age=300" \
|
||||
--endpoint-url "$R2_ENDPOINT"
|
||||
aws s3 cp latest-r2.json "s3://$R2_BUCKET/latest.json" \
|
||||
--content-type "application/json" \
|
||||
--cache-control "public, max-age=300" \
|
||||
--endpoint-url "$R2_ENDPOINT"
|
||||
|
||||
- name: Prune old versions
|
||||
if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
versions=$(aws s3 ls "s3://$R2_BUCKET/" --endpoint-url "$R2_ENDPOINT" \
|
||||
| awk '$1 == "PRE" {print $2}' | tr -d '/' | grep -E '^v[0-9]' | sort -V || true)
|
||||
count=$(printf '%s\n' "$versions" | grep -c . || true)
|
||||
if [ "$count" -le "$KEEP_VERSIONS" ]; then
|
||||
echo "Nothing to prune ($count versions, keeping up to $KEEP_VERSIONS)."
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$versions" | head -n "-$KEEP_VERSIONS" | while read -r old; do
|
||||
[ -n "$old" ] || continue
|
||||
echo "Pruning s3://$R2_BUCKET/$old/"
|
||||
aws s3 rm "s3://$R2_BUCKET/$old/" --recursive --endpoint-url "$R2_ENDPOINT"
|
||||
done
|
||||
+118
@@ -5,6 +5,124 @@ 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.1] - 2026-07-31
|
||||
|
||||
Development since v3.19.0 is a maintenance pass rather than a feature wave — and the first release in this project's history that deletes more than it adds. Three Chinese Codex gateways move onto native Responses and no longer need local routing takeover: DeepSeek connects straight to `api.deepseek.com` and brings with it a general mechanism for mirroring a vendor's own model catalog verbatim, so the GPT-5 harness and the freeform `apply_patch` registration stay self-consistent instead of collapsing into the neutral template; Volcengine's Ark Coding Plan follows now that the official documentation confirms `/api/coding/v3` serves Responses; and Tencent Hunyuan's TokenHub joins as a new preset. The correctness sweep covers four failures visible in the field: Claude Desktop usage has been counted twice since v3.18.0, once as a proxy row and once as an imported session row (#5938); switching back to the built-in official Codex provider left a third-party key in `auth.json`, so Codex authenticated to the official endpoint with the wrong credential and returned 401 without ever falling through to its login screen; `grok update` failed from Settings with a bare `os error 2` because a GUI-launched app cannot see node; and Grok Build's proxy takeover returned 404 on any non-Responses backend while every request minted a fresh session id (#5677). Rounding it out, nine interface strings that rendered as Simplified Chinese in every language are localized and the Traditional Chinese About page gains the thirty tool-manager strings it was missing, eight models that silently billed at zero gain built-in prices, deep-link import confirmations mask more and truncate less, and 3,166 lines of superseded code plus four npm dependencies are removed.
|
||||
|
||||
**Stats**: 12 commits | 71 files changed | +2,324 insertions | -3,680 deletions
|
||||
|
||||
### Added
|
||||
|
||||
- **Official Vendor Model Catalogs Mirrored for Native Responses Providers**: Codex reads model capabilities from a catalog file, and CC Switch generated every provider's catalog from a neutral template — which is right for an aggregator, but strips capabilities a vendor's own integration depends on. A vendor whose published catalog is bundled with the app is now mirrored verbatim instead. DeepSeek is the first: `src-tauri/src/resources/codex_deepseek_catalog_template.json` carries its `deepseek-v4-flash` and `deepseek-v4-pro` entries with `apply_patch_tool_type: "freeform"`, `web_search_tool_type: "text"`, `supports_search_tool: true`, the low/high/max reasoning levels, and the 17,644-character GPT-5 harness in `base_instructions` and `model_messages` — which has to travel together with the freeform tool registration, because the harness instructs the model to use `apply_patch`. The gate is deliberately narrow: the provider must resolve to the native-Responses profile **and** its `base_url` must be on `deepseek.com`. Matching is by host only and never by model brand, because these entries grant capabilities that an aggregator reselling the same model may not implement. Explicit per-model overrides in the provider's own catalog still win, and an unrecognised model id clones the flagship entry while keeping its own slug. Every other provider profile emits exactly the catalog it did before.
|
||||
- **Tencent Hunyuan (TokenHub) Codex Preset**: A "Tencent Hunyuan" entry joins the Codex preset picker under the Opensource Official category, between Bailian and StepFun. It writes `https://tokenhub.tencentmaas.com/v1` with `wire_api = "responses"` and the `disable_response_storage = true` that TokenHub requires, declares `hy3` and `hy3-preview` at a 256K context window (rather than accepting Codex's 128K default), and marks both text-only so `view_image` payloads are never sent to a model that cannot read them. Because it is a native Responses provider, Codex talks to the gateway directly and no local routing is needed; the generated catalog uses the neutral native template, which pins `shell_type = "shell_command"` and drops the freeform `apply_patch` registration that native gateways reject. The address manager and latency test see two candidates from the start — the primary `.com` host and the official `.cn` backup. The region-scoped international site is deliberately excluded, since API keys do not carry across sites. Note that the API key must be a TokenHub key created with the Hy3 scope; Coding Plan and Token Plan subscription keys do not work against this endpoint.
|
||||
- **Built-In Pricing for Eight Models That Billed at Zero**: `gpt-5.3-codex-spark`, `gemini-3.5-flash-lite`, `kimi-k2.7-code-highspeed` (2x its `kimi-k2.7-code` base, matching Kimi's Turbo pattern), `glm-5-turbo`, `glm-5v-turbo` and `qwen3.6-flash` had no row in the built-in pricing table and could not be reached by the prefix fallback, so every request against them was recorded at zero cost. Two more rows, bare `claude-opus-4-6` and `claude-sonnet-4-6`, close a subtler gap: model-id resolution strips a date suffix but never adds one, so a log row carrying the undated id matched nothing at all. All eight are seeded insert-if-absent, so a price you already customized is untouched.
|
||||
- **Grok Build Joins the Failover Tabs and the Environment-Conflict Check**: The failover settings gain a fourth tab for Grok Build alongside Claude Code, Codex and Gemini, and the startup environment-conflict banner now detects a shell-exported `XAI_API_KEY` or `GROK_DEFAULT_MODEL` — variables that silently override whatever provider is selected. Detection distinguishes exact names from prefixes, so CC Switch's own `GROK_BIN_DIR` and `GROK_HOME` are not reported.
|
||||
|
||||
### Changed
|
||||
|
||||
- **DeepSeek and Volcengine Ark Coding Plan Connect to Codex Directly, Without Local Routing**: Both presets were declared OpenAI Chat, which made them takeover-required: the provider card carried the "needs routing" badge, switching with the proxy off raised the routing prompt, and every request travelled Codex → local proxy → Responses-to-Chat conversion → upstream. Both vendors now publish official Codex integrations confirming their endpoints serve the Responses API — DeepSeek's `api.deepseek.com` and Volcengine's `/api/coding/v3` — so both presets declare native Responses, the badge and the prompt disappear, and Codex connects straight to the gateway. The generated `config.toml` is unchanged in both cases, since it already emitted `wire_api = "responses"`; what changes is the catalog profile and, for DeepSeek, the context window, which moves from 1,000,000 to the vendor's own 1,048,576. BytePlus deliberately stays on Chat routing until the international site's documentation is verified separately. The Volcengine preset also gained a comment recording a billing rule worth knowing: the pay-as-you-go `/api/v3` endpoint must never be added to this preset's backup addresses, because it bills separately instead of drawing down plan quota.
|
||||
- **Catalog `displayName` and `contextWindow` Are Now Explicit-Only**: Both fields previously carried local defaults — the model id and a 128,000-token window — applied before a vendor value could be consulted, so a mirrored catalog would have had its 1M window overwritten with 128K. They are now optional, with the fallbacks applied one level down at entry construction, which makes "left blank" mean "keep whatever the vendor declared". Providers that set the fields explicitly, and every non-mirrored profile, emit identical catalogs to before.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Claude Desktop Usage Was Counted Twice**: Claude Desktop traffic through the local gateway landed in the usage dashboard once as a proxy row and once more as an imported transcript row, so its tokens, cost and request counts read roughly double. The regression shipped in v3.18.0: proxy dedup ids were scoped as `session:{app_type}:{provider_id}:{message_id}` for every app except `claude`, which put `claude-desktop` in its own namespace while the transcript importer kept writing the same Claude message under the bare `session:{message_id}` shape with `app_type = 'claude'`. All three dedup defenses failed at once — the primary-key convergence that lets a proxy row absorb an existing session row, the write-side fingerprint probe, and the read-side filter, the latter two both comparing `app_type` with strict equality. The two apps now share the bare namespace again, and the two comparisons are widened by a one-way rule where a `claude` session row can be absorbed by a `claude-desktop` proxy row but never the reverse. Because the read-side filter is also what daily rollups aggregate through, already-stored duplicates stop being counted without any row being rewritten or deleted — see Upgrade notes for the retention limit on that. For Codex, Gemini and OpenCode the widened comparison collapses to the previous exact match, and quota checks keep strict app matching. (#5938, #5951)
|
||||
- **Switching Back to the Official Codex Provider Stranded You on a 401 With No Login Screen**: With Codex API-key preservation off (the default), switching to a third-party provider writes that vendor's key into `~/.codex/auth.json`. Switching afterwards to a built-in official provider — whose stored credentials are empty — took the config-only write path, so `config.toml` was replaced while the third-party `OPENAI_API_KEY` stayed on disk. Codex then authenticated to the official endpoint with a foreign key and got 401, and because `auth.json` existed it never fell through to its own login screen, leaving no way out from inside the app. After a successful switch to an official Codex provider, CC Switch now deletes an `auth.json` that contains only an `OPENAI_API_KEY` with no first-class credential beside it — OAuth tokens, a personal access token, an agent identity or a Bedrock key all mark the file as real and leave it untouched, while metadata such as `auth_mode`, `last_refresh` or an account id can no longer shield a stale key. Deleting rather than writing `{}` is deliberate: an empty object resolves to ChatGPT mode without tokens and errors at bootstrap, whereas a missing file yields the login screen. The cleanup runs only after the outgoing provider has been backfilled into the database, so the removed key is preserved and comes back when that provider is selected again. Live-config reads were relaxed in the same change so the post-cleanup state — no `auth.json`, an existing `config.toml` — is no longer reported as "Codex is not installed".
|
||||
- **Grok Build Upgrades From Settings Failed With a Bare `os error 2`**: Upgrading Grok Build from Settings → About failed with `Error: No such file or directory (os error 2)` and nothing else. The cause is an asymmetry between how CC Switch probes tools and how it runs them: probing goes through a login shell, which sources the user's rc files and therefore sees nvm, Homebrew and Volta, while lifecycle scripts ran under a non-login shell inheriting the narrow PATH a GUI app is launched with. That normally does not matter, because anchored commands invoke binaries by absolute path — but grok 0.2.112 moved self-update onto npm distribution, so `grok update` now spawns `npm view` and `npm i -g` internally, and npm resolves node through its own shebang. The inner spawn returned ENOENT, which grok surfaced as the bare error. Lifecycle commands on macOS and Linux now run with the login shell's real PATH merged ahead of the inherited one, read through `/usr/bin/env` rather than by echoing the variable, because fish stores PATH as a list and would emit space-separated segments. Native Grok's update additionally chains the official xAI installer as a fallback — deliberately not `npm i -g`, which shares both of the primary's failure modes; the installer is the only node-free path, lands in the same location, and rewrites the CLI's own `installer` setting back to `internal`, healing users whom an earlier npm fallback had moved onto npm distribution.
|
||||
- **Grok Build Proxy Takeover Returned 404, and Every Request Looked Like a New Session**: Enabling takeover on a Grok Build provider whose API format was OpenAI Chat or Anthropic produced an immediate 404 with no failover and no usage record — takeover rewrote the base URL and key but left the backend field alone, so the CLI posted to a route the proxy does not register. Takeover now also pins the backend to Responses; the per-provider downgrade to Chat Completions still happens inside the forwarder, and the forced value reverts with the whole live config when the proxy stops. Separately, proxy session extraction recognised only Codex and OpenAI clients, so every Grok Build turn minted a fresh session id marked as not client-provided, which suppressed prompt-cache key injection and per-session grouping in the usage dashboard. Grok's own headers are now read — the conversation id first, then the session id, ignoring the per-request id — under a distinct `grokbuild_` prefix so its rows cannot collide with Codex's. (#5677)
|
||||
- **Nine Interface Strings Rendered as Simplified Chinese in Every Language**: Nine strings appeared in Simplified Chinese regardless of the selected language, English and Japanese included. Each call site used the inline-default form with a Chinese literal, but the key existed in none of the four locale files — and i18next resolves a key through the language chain before it considers an inline default, so the English fallback never engaged and the Chinese literal won everywhere. The affected strings cover the Grok Build provider form's validation toast, the failover tooltip shown when an app is not yet taken over, the warning raised when stopping Claude Desktop routing while another app holds takeover and the reason line that explains why routing must start, the duplicate-provider id read failure, the empty Codex common-config error, the routing service's stop and stop-failed toasts, and the "unpriced" cost label the usage tables show for a request that carries tokens but computes to zero. All nine now exist in Chinese, English, Japanese and Traditional Chinese. (#5960)
|
||||
- **The Traditional Chinese About Page Fell Back to English in the Tool Manager**: With the interface set to Traditional Chinese, the tool management section of the About page rendered in English — version rows, install and update buttons, result toasts, the install-conflict diagnosis and the entire upgrade-confirmation dialog. The panel was built out across three earlier changes that added labels to Chinese, English and Japanese only, and because i18next falls back to English rather than failing, thirty missing keys were invisible in testing. This gap shipped in every release from v3.16.0 through v3.19.0. All thirty strings are now translated, the install hint was brought in line with the other locales, and a new locale test asserts that every tool-management label exists in all four languages with matching interpolation variables, so this class of drift fails the suite instead of shipping. (#5943)
|
||||
- **Seeded Model Prices Disagreed With Vendor List Prices**: Costs are frozen at log time from the built-in pricing table, so a stale seed quietly mis-bills every subsequent request. Four rows were corrected: `deepseek-chat` and `deepseek-reasoner` are now legacy aliases of V4 Flash at $0.14/$0.28 per million input/output with $0.0028 cache read (from $0.27/$1.10 and $0.55/$2.19); `minimax-m3` halves to $0.30/$1.20, the official standard tier; and `gpt-5.6-luna` drops 80% to $0.20/$1.20 while `gpt-5.6-terra` drops 20% to $2/$12, following OpenAI's 2026-07-30 price cut, with `gpt-5.6-sol` deliberately unchanged and the family's cache-write ratio preserved. The repairs rewrite a row only when all four of its cost columns still hold the exact previous built-in values, so a price you edited yourself — or one set by models.dev sync — is never touched.
|
||||
|
||||
### Security
|
||||
|
||||
- **Deep-Link Import Confirmations Mask More and Truncate Less**: A continuation of the `ccswitch://` confirmation hardening in v3.19.0. Config previews are now built by one shared module that masks secrets recursively through nested TOML tables and JSON objects, which fixes two opposite defects: a Grok Build import rendered no configuration preview at all, while a Codex import printed embedded `api_key` values in the clear. The 300-character truncation on the configuration preview is gone — the full document now renders inside a scrollable box, closing the last place the confirmation could hide part of what it was about to write. Masking itself is stricter everywhere it is used, including the MCP import confirmation: the sensitive-name matcher gained `AUTHORIZATION`, `COOKIE` and `CREDENTIAL` alongside exact matches for `AUTH` and `BEARER`, masked values reveal four leading characters instead of eight, and a value of eight characters or fewer is now replaced entirely rather than shown. Finally, the frontend Base64 decoder no longer trims surrounding whitespace, which could discard a `+` that URL decoding had turned into a space — the same class of frontend/backend decoder divergence fixed in v3.19.0, where the dialog shows one thing and the importer writes another.
|
||||
|
||||
### Internal
|
||||
|
||||
- **Superseded Code Removed: 3,166 Lines, Fourteen Modules and Four Dependencies**: A deletion pass over code that had no caller. On the Rust side it drops the provider icon inference table, a placeholder health checker, a second never-wired SSE implementation with its own stream and non-stream handlers, two unused proxy session types, and four unreferenced usage parsers along with a dead cost-calculation entry point — the live billing path, its auto-detecting parsers and session id extraction are untouched. Twenty-two `#[allow(dead_code)]` suppressions, which are why the compiler never flagged any of it, go with them. On the front end, fourteen modules with no importer are deleted, including a prompt form modal and a repository manager both superseded by panel rewrites, a duplicate proxy config hook, a circuit-breaker panel that never had an importer in the project's history, and three schema files; their strings are removed identically from all four locales. The Tauri commands behind them are deliberately kept. Four unused npm dependencies are dropped. Two icon-extraction scripts that regenerated the hand-curated icon index are removed, and the index header now states that automatic regeneration is intentionally unsupported. A companion change consolidates proxy status and takeover state onto a single React Query layer — a second, parallel set of hooks over the same commands had zero call sites, so its query keys never had an observer and the invalidations aimed at them were no-ops. Query key strings are byte-identical and the surviving hook keeps its existing poll behavior. (#5916, #5928)
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- No database schema migration in this release — `SCHEMA_VERSION` stays at 16, so no pre-migration backup is triggered.
|
||||
- Claude Desktop's double-counted usage corrects itself retroactively, but only within the detail-row retention window. The fix suppresses duplicate rows at query time rather than rewriting or deleting anything, so every affected day whose detail rows are still present recovers its correct total on next launch. Detail rows older than 30 days are aggregated into daily rollups and pruned, and a day already rolled up by a build without this fix keeps its inflated figure permanently. The regression entered with v3.18.0 (2026-07-21).
|
||||
- The eight newly priced models are re-costed retroactively where possible: startup backfills requests that were logged with zero cost, so dashboard totals for those models will rise. Rows already rolled up and pruned stay at zero. The four repriced models work the other way — backfill only touches zero-cost rows, so requests already logged keep the old rate and only new requests bill at the new one; historical and current spend for the same model will differ. Manual price edits, models.dev sync values and deletion tombstones are re-applied after seeding and always win.
|
||||
- Preset changes apply only to newly created providers. A DeepSeek or Volcengine Ark Coding Plan provider already saved keeps its stored API format, keeps requiring local routing, and keeps its old catalog; re-create it from the preset, or switch the API format in the provider form, to get the direct connection. A provider that is already on native Responses with a `deepseek.com` base URL picks up the mirrored official catalog on its next switch without any re-save, because the gate reads the live configuration.
|
||||
- `deepseek-v4-pro` cannot be used over the direct connection yet. It stays listed in the preset, and in the mirrored vendor catalog, but DeepSeek has not opened its Codex integration for that model — early August 2026 by their own estimate — so selecting it while the provider is on native Responses fails upstream. Use `deepseek-v4-flash`, which is the preset default and unaffected. To use pro today, switch that provider's API format back to OpenAI Chat and enable local routing takeover: that is the path DeepSeek took before this release, and the proxy's Responses-to-Chat conversion serves pro exactly as it did.
|
||||
- The mirrored DeepSeek catalog declares a minimum Codex client version of 0.144.0, which CC Switch does not verify — the freeform `apply_patch` registration it carries needs that release or newer. The generated catalog file also grows to roughly 75 KB for the two mirrored models, since each carries the full harness text.
|
||||
- Because DeepSeek, Volcengine Ark Coding Plan and Tencent Hunyuan no longer require takeover, their traffic can bypass the local proxy entirely, and per-request proxy usage accounting does not see it. The usage itself is neither lost nor indistinguishable — Codex session-log import still records it — but that path carries no provider identity: all Codex usage that did not go through the local proxy is grouped under a single `Codex (Session)` entry, official-subscription consumption included. So a provider moving from routed to direct also moves its usage out from under its own name. Model attribution is unaffected: every row keeps its model id, and the usage panel's per-model table still separates `deepseek-v4-flash`, `hy3`, `ark-code-latest` and the official GPT models into their own rows with their own token and cost figures. Keep local routing takeover enabled only if you need the provider dimension specifically — for instance to compare the same model across several aggregators.
|
||||
- The stale Codex `auth.json` cleanup is gated on the incoming provider carrying the explicit official category and on the outgoing provider being backfilled successfully. An official entry created by hand without that category, or a switch whose backfill failed, still leaves the residue behind.
|
||||
- Enabling proxy takeover on a Grok Build provider now rewrites its backend to Responses in the live configuration. The stored provider row is untouched and the live file is restored from its backup when the proxy stops.
|
||||
- On macOS and Linux, every tool install and update triggered from Settings → About now runs with the login shell's PATH merged ahead of the inherited one, so a binary a lifecycle script resolves by name may resolve differently than before; each action also spawns one extra shell to read that PATH, which executes the user's interactive startup files. Windows is unchanged. Users on grok 0.2.112 or later may see two enumerated installations — the native one plus a global npm package that `grok update` created itself; they are kept in sync upstream and report the same version.
|
||||
- Environment-conflict detection for Claude Code, Codex and Gemini moved from substring to prefix matching, so variables that merely contain an app's name — `MY_ANTHROPIC_API_KEY`, `OLD_GEMINI_API_KEY` — are no longer reported as conflicts.
|
||||
- Deep-link import confirmations now reveal less of each secret than before: four leading characters instead of eight, and values of eight characters or fewer are masked entirely.
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -35,8 +35,8 @@ Doing mostly coding work? Try the **[Kimi Code subscription](https://www.kimi.co
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during first recharge to get 10% off.</td>
|
||||
<td width="180"><a href="https://www.packyapi.ai/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.ai/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during first recharge to get 10% off.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -45,14 +45,14 @@ Doing mostly coding work? Try the **[Kimi Code subscription](https://www.kimi.co
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apinebula.com/VjM74M"><img src="assets/partners/logos/apinebula_banner.png" alt="APINebula" width="150"></a></td>
|
||||
<td>Thanks to APINEBULA for sponsoring this project! APINEBULA, an enterprise-grade AI aggregation platform under Galaxy Video Bureau, leverages extensive platform resources to provide developers, teams, and enterprises with stable, cost-effective access to large language model APIs. The platform integrates leading, full-powered models like Claude, GPT, and Gemini, allowing you to connect to the world's top AI models through a single API, with prices starting as low as 10% of the original cost. Designed for AI programming, Agent development, and business system integration, APINEBULA supports enterprise-grade high concurrency, formal contracts, corporate bank transfers, and invoicing services. APINEBULA provides special discounts for our software users: register using <a href="https://apinebula.com/VjM74M">this link</a> and enter the <strong>"ccswitch"</strong> promo code during your first recharge to get <strong>10% off</strong>.</td>
|
||||
<td width="180"><a href="https://apinebula.ai/VjM74M"><img src="assets/partners/logos/apinebula_banner.png" alt="APINebula" width="150"></a></td>
|
||||
<td>Thanks to APINEBULA for sponsoring this project! APINEBULA, an enterprise-grade AI aggregation platform under Galaxy Video Bureau, leverages extensive platform resources to provide developers, teams, and enterprises with stable, cost-effective access to large language model APIs. The platform integrates leading, full-powered models like Claude, GPT, and Gemini, allowing you to connect to the world's top AI models through a single API, with prices starting as low as 10% of the original cost. Designed for AI programming, Agent development, and business system integration, APINEBULA supports enterprise-grade high concurrency, formal contracts, corporate bank transfers, and invoicing services. APINEBULA provides special discounts for our software users: register using <a href="https://apinebula.ai/VjM74M">this link</a> and enter the <strong>"ccswitch"</strong> promo code during your first recharge to get <strong>10% off</strong>.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support.
|
||||
Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CC Switch users: register via <a href="https://www.aicodemirror.com/register?invitecode=9915W3">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
|
||||
Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CC Switch users: register via <a href="https://www.aicodemirror.ai/register?invitecode=9915W3">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -83,8 +83,13 @@ 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>
|
||||
<td width="180"><a href="https://aicoding.inc/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>Thanks to AICoding for sponsoring this project! AICoding — Global AI Model API Relay Service at Unbeatable Prices! Claude Code at 19% of original price, GPT at just 1%! Trusted by hundreds of enterprises for cost-effective AI services. Supports Claude Code, GPT, Gemini and major domestic models, with enterprise-grade high concurrency, fast invoicing, and 24/7 dedicated technical support. CC Switch users who register via <a href="https://aicoding.inc/i/CCSWITCH">this link</a> get 10% off their first top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -98,8 +103,8 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/register?aff=pCLD"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
|
||||
<td>This project is sponsored by <a href="https://console.claudeapi.com/register?aff=pCLD">Claude API</a>. Direct Claude API access — connect Claude Code and Agent apps in 3 minutes. New users can claim a free trial credit.Powered by official Anthropic API keys + AWS Bedrock official channels. No reverse engineering, no model degradation. Full support for Opus / Sonnet / Haiku model lineup, with official capabilities preserved including Tool Use, 1M context window, and more. Built for Claude Code power users, Agent engineers, and enterprise engineering teams. Invoicing and dedicated team support available. Click <a href="https://console.claudeapi.com/register?aff=pCLD">here</a> to register!</td>
|
||||
<td width="180"><a href="https://console.apito.ai/agent/register/pQBql2buaqiX3dDS"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
|
||||
<td>This project is sponsored by <a href="https://console.apito.ai/agent/register/pQBql2buaqiX3dDS">Claude API</a>. Direct Claude API access — connect Claude Code and Agent apps in 3 minutes. New users can claim a free trial credit.Powered by official Anthropic API keys + AWS Bedrock official channels. No reverse engineering, no model degradation. Full support for Opus / Sonnet / Haiku model lineup, with official capabilities preserved including Tool Use, 1M context window, and more. Built for Claude Code power users, Agent engineers, and enterprise engineering teams. Invoicing and dedicated team support available. Click <a href="https://console.apito.ai/agent/register/pQBql2buaqiX3dDS">here</a> to register!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -139,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>
|
||||
@@ -165,8 +175,8 @@ TeamoRouter also offers enterprise features including centralized billing, team
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via <a href="https://www.right.codes/register?aff=CCSWITCH">this link</a>, and with every top-up you will receive pay-as-you-go credit equivalent to 5% of the amount paid.</td>
|
||||
<td width="180"><a href="https://www.rightapi.ai/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via <a href="https://www.rightapi.ai/register?aff=CCSWITCH">this link</a>, and with every top-up you will receive pay-as-you-go credit equivalent to 5% of the amount paid.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
+22
-12
@@ -35,8 +35,8 @@ Hauptsächlich mit Programmierung beschäftigt? Probieren Sie das **[Kimi-Code-A
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>Danke an PackyCode für die Unterstützung dieses Projekts! PackyCode ist ein zuverlässiger und effizienter API-Relay-Anbieter, der Relay-Dienste für Claude Code, Codex, Gemini und mehr bereitstellt. PackyCode bietet Sonderrabatte für Nutzer unserer Software: Registrieren Sie sich über <a href="https://www.packyapi.com/register?aff=cc-switch">diesen Link</a> und geben Sie beim ersten Aufladen den Gutscheincode „cc-switch" ein, um 10 % Rabatt zu erhalten.</td>
|
||||
<td width="180"><a href="https://www.packyapi.ai/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>Danke an PackyCode für die Unterstützung dieses Projekts! PackyCode ist ein zuverlässiger und effizienter API-Relay-Anbieter, der Relay-Dienste für Claude Code, Codex, Gemini und mehr bereitstellt. PackyCode bietet Sonderrabatte für Nutzer unserer Software: Registrieren Sie sich über <a href="https://www.packyapi.ai/register?aff=cc-switch">diesen Link</a> und geben Sie beim ersten Aufladen den Gutscheincode „cc-switch" ein, um 10 % Rabatt zu erhalten.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -45,14 +45,14 @@ Hauptsächlich mit Programmierung beschäftigt? Probieren Sie das **[Kimi-Code-A
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apinebula.com/VjM74M"><img src="assets/partners/logos/apinebula_banner.png" alt="APINebula" width="150"></a></td>
|
||||
<td>Danke an APINEBULA für die Unterstützung dieses Projekts! APINEBULA ist eine Enterprise-KI-Aggregationsplattform unter Galaxy Video Bureau und nutzt umfangreiche Plattformressourcen, um Entwicklern, Teams und Unternehmen stabilen und kosteneffizienten Zugang zu APIs großer Sprachmodelle zu bieten. Die Plattform bündelt führende vollwertige Modelle wie Claude, GPT und Gemini. Über eine einzige API erhalten Sie Zugriff auf weltweit führende KI-Modelle, mit Preisen ab 10 % des Originalpreises. APINEBULA ist für KI-Programmierung, Agent-Entwicklung und Integration in Geschäftssysteme ausgelegt und unterstützt enterprise-grade hohe Parallelität, formale Verträge, Firmenüberweisungen und Rechnungsstellung. APINEBULA bietet Nutzern dieser Software besondere Rabatte: Registrieren Sie sich über <a href="https://apinebula.com/VjM74M">diesen Link</a> und geben Sie beim ersten Aufladen den Aktionscode <strong>"ccswitch"</strong> ein, um <strong>10 % Rabatt</strong> zu erhalten.</td>
|
||||
<td width="180"><a href="https://apinebula.ai/VjM74M"><img src="assets/partners/logos/apinebula_banner.png" alt="APINebula" width="150"></a></td>
|
||||
<td>Danke an APINEBULA für die Unterstützung dieses Projekts! APINEBULA ist eine Enterprise-KI-Aggregationsplattform unter Galaxy Video Bureau und nutzt umfangreiche Plattformressourcen, um Entwicklern, Teams und Unternehmen stabilen und kosteneffizienten Zugang zu APIs großer Sprachmodelle zu bieten. Die Plattform bündelt führende vollwertige Modelle wie Claude, GPT und Gemini. Über eine einzige API erhalten Sie Zugriff auf weltweit führende KI-Modelle, mit Preisen ab 10 % des Originalpreises. APINEBULA ist für KI-Programmierung, Agent-Entwicklung und Integration in Geschäftssysteme ausgelegt und unterstützt enterprise-grade hohe Parallelität, formale Verträge, Firmenüberweisungen und Rechnungsstellung. APINEBULA bietet Nutzern dieser Software besondere Rabatte: Registrieren Sie sich über <a href="https://apinebula.ai/VjM74M">diesen Link</a> und geben Sie beim ersten Aufladen den Aktionscode <strong>"ccswitch"</strong> ein, um <strong>10 % Rabatt</strong> zu erhalten.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>Danke an AICodeMirror für die Unterstützung dieses Projekts! AICodeMirror stellt offizielle, hochstabile Relay-Dienste für Claude Code / Codex / Gemini CLI bereit, mit unternehmensgerechter Nebenläufigkeit, schneller Rechnungsstellung und rund um die Uhr verfügbarem dediziertem technischem Support.
|
||||
Offizielle Kanäle von Claude Code / Codex / Gemini zu 38 % / 2 % / 9 % des Originalpreises, mit zusätzlichen Rabatten beim Aufladen! AICodeMirror bietet besondere Vorteile für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://www.aicodemirror.com/register?invitecode=9915W3">diesen Link</a> und erhalten Sie 20 % Rabatt auf Ihre erste Aufladung; Unternehmenskunden erhalten bis zu 25 % Rabatt!</td>
|
||||
Offizielle Kanäle von Claude Code / Codex / Gemini zu 38 % / 2 % / 9 % des Originalpreises, mit zusätzlichen Rabatten beim Aufladen! AICodeMirror bietet besondere Vorteile für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://www.aicodemirror.ai/register?invitecode=9915W3">diesen Link</a> und erhalten Sie 20 % Rabatt auf Ihre erste Aufladung; Unternehmenskunden erhalten bis zu 25 % Rabatt!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -83,8 +83,13 @@ 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>
|
||||
<td width="180"><a href="https://aicoding.inc/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>Danke an AICoding für die Unterstützung dieses Projekts! AICoding — globaler AI-Modell-API-Relay-Dienst zu unschlagbaren Preisen! Claude Code für 19 % des Originalpreises, GPT für nur 1 %! Hunderte Unternehmen vertrauen auf diese kostengünstigen KI-Dienste. Unterstützt Claude Code, GPT, Gemini und führende chinesische Modelle — mit hoher Parallelität auf Unternehmensniveau, schneller Rechnungsstellung und persönlichem technischem Support rund um die Uhr. CC-Switch-Nutzer, die sich über <a href="https://aicoding.inc/i/CCSWITCH">diesen Link</a> registrieren, erhalten 10 % Rabatt auf ihre erste Aufladung!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -98,8 +103,8 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/register?aff=pCLD"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
|
||||
<td>Dieses Projekt wird von <a href="https://console.claudeapi.com/register?aff=pCLD">Claude API</a> gesponsert. Direkter Claude-API-Zugriff — verbinden Sie Claude Code und Agent-Apps in 3 Minuten. Neukunden können ein kostenloses Testguthaben einlösen. Betrieben mit offiziellen Anthropic-API-Schlüsseln + offiziellen AWS-Bedrock-Kanälen. Kein Reverse Engineering, keine Modellverschlechterung. Volle Unterstützung der Modellreihe Opus / Sonnet / Haiku, mit erhaltenen offiziellen Fähigkeiten einschließlich Tool Use, 1M-Kontextfenster und mehr. Entwickelt für Claude-Code-Power-User, Agent-Ingenieure und technische Unternehmensteams. Rechnungsstellung und dedizierter Team-Support verfügbar. Klicken Sie <a href="https://console.claudeapi.com/register?aff=pCLD">hier</a>, um sich zu registrieren!</td>
|
||||
<td width="180"><a href="https://console.apito.ai/agent/register/pQBql2buaqiX3dDS"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
|
||||
<td>Dieses Projekt wird von <a href="https://console.apito.ai/agent/register/pQBql2buaqiX3dDS">Claude API</a> gesponsert. Direkter Claude-API-Zugriff — verbinden Sie Claude Code und Agent-Apps in 3 Minuten. Neukunden können ein kostenloses Testguthaben einlösen. Betrieben mit offiziellen Anthropic-API-Schlüsseln + offiziellen AWS-Bedrock-Kanälen. Kein Reverse Engineering, keine Modellverschlechterung. Volle Unterstützung der Modellreihe Opus / Sonnet / Haiku, mit erhaltenen offiziellen Fähigkeiten einschließlich Tool Use, 1M-Kontextfenster und mehr. Entwickelt für Claude-Code-Power-User, Agent-Ingenieure und technische Unternehmensteams. Rechnungsstellung und dedizierter Team-Support verfügbar. Klicken Sie <a href="https://console.apito.ai/agent/register/pQBql2buaqiX3dDS">hier</a>, um sich zu registrieren!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -139,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>
|
||||
@@ -165,8 +175,8 @@ TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>Danke an Right Code für die Unterstützung dieses Projekts! Right Code stellt zuverlässig Routing-Dienste für Modelle wie Claude Code, Codex und Gemini bereit, wahlweise mit nutzungsbasierter Abrechnung oder monatlichem Abonnement. Rechnungen sind beim Aufladen verfügbar, und Unternehmens- sowie Teamkunden erhalten dedizierten Einzelsupport. Right Code bietet außerdem einen exklusiven Rabatt für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://www.right.codes/register?aff=CCSWITCH">diesen Link</a> und erhalten Sie bei jeder Aufladung nutzungsbasiertes Guthaben in Höhe von 5 % des gezahlten Betrags.</td>
|
||||
<td width="180"><a href="https://www.rightapi.ai/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>Danke an Right Code für die Unterstützung dieses Projekts! Right Code stellt zuverlässig Routing-Dienste für Modelle wie Claude Code, Codex und Gemini bereit, wahlweise mit nutzungsbasierter Abrechnung oder monatlichem Abonnement. Rechnungen sind beim Aufladen verfügbar, und Unternehmens- sowie Teamkunden erhalten dedizierten Einzelsupport. Right Code bietet außerdem einen exklusiven Rabatt für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://www.rightapi.ai/register?aff=CCSWITCH">diesen Link</a> und erhalten Sie bei jeder Aufladung nutzungsbasiertes Guthaben in Höhe von 5 % des gezahlten Betrags.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
+22
-12
@@ -35,8 +35,8 @@ Kimi K3 は Moonshot AI がこれまでに開発した中で最も高性能な
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>
|
||||
<td width="180"><a href="https://www.packyapi.ai/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.ai/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -45,14 +45,14 @@ Kimi K3 は Moonshot AI がこれまでに開発した中で最も高性能な
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apinebula.com/VjM74M"><img src="assets/partners/logos/apinebula_banner.png" alt="APINebula" width="150"></a></td>
|
||||
<td>本プロジェクトは「APINEBULA」のスポンサーシップにより運営されています!APINEBULA は、「銀河録像局」傘下のエンタープライズ向け AI 統合プラットフォームです。大手の豊富なリソースを背景に、開発者、チーム、そして企業ユーザーの皆様へ、安定性とコストパフォーマンスに優れた大規模言語モデル(LLM)の API 連携サービスを提供しています。Claude、GPT、Gemini をはじめとする世界中の主要なフルスペック(満血)モデルを 1 つの API に集約。世界トップクラスの AI モデルを、<strong>最大 90% OFF(元の価格の 1 割〜)</strong>という圧倒的な低価格でご利用いただけます。また、企業向けの高度な並行処理(高コンカレンシー)、正式な契約締結、法人口座振り込み、請求書・領収書発行など、ビジネス利用に必要なサポートも万全です。AI プログラミング、AI エージェント開発、業務システムへの統合など、様々なシーンに最適です。<a href="https://apinebula.com/VjM74M">こちらのリンク</a>から登録し、チャージ時にプロモコード <strong>「ccswitch」を入力すると、さらに 10% OFF</strong> の割引特典が適用されます!</td>
|
||||
<td width="180"><a href="https://apinebula.ai/VjM74M"><img src="assets/partners/logos/apinebula_banner.png" alt="APINebula" width="150"></a></td>
|
||||
<td>本プロジェクトは「APINEBULA」のスポンサーシップにより運営されています!APINEBULA は、「銀河録像局」傘下のエンタープライズ向け AI 統合プラットフォームです。大手の豊富なリソースを背景に、開発者、チーム、そして企業ユーザーの皆様へ、安定性とコストパフォーマンスに優れた大規模言語モデル(LLM)の API 連携サービスを提供しています。Claude、GPT、Gemini をはじめとする世界中の主要なフルスペック(満血)モデルを 1 つの API に集約。世界トップクラスの AI モデルを、<strong>最大 90% OFF(元の価格の 1 割〜)</strong>という圧倒的な低価格でご利用いただけます。また、企業向けの高度な並行処理(高コンカレンシー)、正式な契約締結、法人口座振り込み、請求書・領収書発行など、ビジネス利用に必要なサポートも万全です。AI プログラミング、AI エージェント開発、業務システムへの統合など、様々なシーンに最適です。<a href="https://apinebula.ai/VjM74M">こちらのリンク</a>から登録し、チャージ時にプロモコード <strong>「ccswitch」を入力すると、さらに 10% OFF</strong> の割引特典が適用されます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定リレーサービスを提供しており、エンタープライズ級の同時接続、迅速な請求書発行、24時間年中無休の専用テクニカルサポートを備えています。
|
||||
Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / 2% / 9%、チャージ時にはさらに割引!AICodeMirror は CC Switch ユーザー向けに特別特典を用意:<a href="https://www.aicodemirror.com/register?invitecode=9915W3">このリンク</a>から登録すると初回チャージ 20% オフ、法人のお客様は最大 25% オフ!</td>
|
||||
Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / 2% / 9%、チャージ時にはさらに割引!AICodeMirror は CC Switch ユーザー向けに特別特典を用意:<a href="https://www.aicodemirror.ai/register?invitecode=9915W3">このリンク</a>から登録すると初回チャージ 20% オフ、法人のお客様は最大 25% オフ!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -83,8 +83,13 @@ 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>
|
||||
<td width="180"><a href="https://aicoding.inc/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>AICoding のご支援に感謝します!AICoding —— グローバル AI モデル API 超お得な中継サービス!Claude Code 81% オフ、GPT 99% オフ!数百社の企業に高コストパフォーマンスの AI サービスを提供。Claude Code、GPT、Gemini および国内主要モデルに対応、エンタープライズ級の高同時接続、迅速な請求書発行、24 時間年中無休の専属テクニカルサポート。<a href="https://aicoding.inc/i/CCSWITCH">こちらのリンク</a>から登録した CC Switch ユーザーは、初回チャージ 10% オフ!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -98,8 +103,8 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/register?aff=pCLD"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
|
||||
<td>本プロジェクトは <a href="https://console.claudeapi.com/register?aff=pCLD">Claude API</a> がスポンサーです。Claude API 直結 — わずか 3 分で Claude Code や Agent アプリに接続可能。新規ユーザーにはテストクレジットを提供しています。Anthropic 公式キーおよび AWS Bedrock 公式チャネルに基づいており、リバースエンジニアリングや性能劣化はありません。Opus / Sonnet / Haiku の全モデルラインナップをサポートし、Tool Use や 1M コンテキストなどの公式機能をすべて保持しています。Claude Code ヘビーユーザー、Agent エンジニア、企業技術チームに最適です。請求書発行およびチーム対応が可能です。<a href="https://console.claudeapi.com/register?aff=pCLD">こちら</a>から登録してください!</td>
|
||||
<td width="180"><a href="https://console.apito.ai/agent/register/pQBql2buaqiX3dDS"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
|
||||
<td>本プロジェクトは <a href="https://console.apito.ai/agent/register/pQBql2buaqiX3dDS">Claude API</a> がスポンサーです。Claude API 直結 — わずか 3 分で Claude Code や Agent アプリに接続可能。新規ユーザーにはテストクレジットを提供しています。Anthropic 公式キーおよび AWS Bedrock 公式チャネルに基づいており、リバースエンジニアリングや性能劣化はありません。Opus / Sonnet / Haiku の全モデルラインナップをサポートし、Tool Use や 1M コンテキストなどの公式機能をすべて保持しています。Claude Code ヘビーユーザー、Agent エンジニア、企業技術チームに最適です。請求書発行およびチーム対応が可能です。<a href="https://console.apito.ai/agent/register/pQBql2buaqiX3dDS">こちら</a>から登録してください!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -139,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>
|
||||
@@ -165,8 +175,8 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデル向け中継サービスを安定して提供しており、従量課金と月額プランの 2 つの料金体系から選択できます。チャージ後に請求書の発行が可能で、法人・チームのお客様には専任担当による個別対応も行っています。さらに、CC Switch ユーザー向けの特別優待として、<a href="https://www.right.codes/register?aff=CCSWITCH">こちらのリンク</a>から登録すると、チャージのたびに実際の支払額の 5% 相当の従量課金クレジットが付与されます。</td>
|
||||
<td width="180"><a href="https://www.rightapi.ai/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデル向け中継サービスを安定して提供しており、従量課金と月額プランの 2 つの料金体系から選択できます。チャージ後に請求書の発行が可能で、法人・チームのお客様には専任担当による個別対応も行っています。さらに、CC Switch ユーザー向けの特別優待として、<a href="https://www.rightapi.ai/register?aff=CCSWITCH">こちらのリンク</a>から登録すると、チャージのたびに実際の支払額の 5% 相当の従量課金クレジットが付与されます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
+22
-12
@@ -35,8 +35,8 @@ Kimi K3 是 Moonshot AI 迄今能力最强的模型,也是全球首个开源 3
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,首次充值可以享受9折优惠!</td>
|
||||
<td width="180"><a href="https://www.packyapi.ai/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.ai/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,首次充值可以享受9折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -45,14 +45,14 @@ Kimi K3 是 Moonshot AI 迄今能力最强的模型,也是全球首个开源 3
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apinebula.com/VjM74M"><img src="assets/partners/logos/apinebula_banner.png" alt="APINebula" width="150"></a></td>
|
||||
<td>感谢 APINEBULA 赞助本项目!APINEBULA 是银河录像局旗下的企业级 AI 聚合平台,背靠大平台资源,面向开发者、团队与企业用户提供稳定、高性价比的大模型 API 接入服务。平台聚合 Claude、GPT、Gemini 等主流满血模型,一个接口,接入全球顶尖 AI 大模型,各大模型价格低至 1 折起,支持企业级高并发、正式合同、对公打款与开票服务,适合 AI 编程、Agent 开发、业务系统集成等多种场景!使用<a href="https://apinebula.com/VjM74M">此链接</a>注册并在充值时填写 <strong>"ccswitch"</strong> 优惠码可享<strong>九折优惠</strong>!</td>
|
||||
<td width="180"><a href="https://apinebula.ai/VjM74M"><img src="assets/partners/logos/apinebula_banner.png" alt="APINebula" width="150"></a></td>
|
||||
<td>感谢 APINEBULA 赞助本项目!APINEBULA 是银河录像局旗下的企业级 AI 聚合平台,背靠大平台资源,面向开发者、团队与企业用户提供稳定、高性价比的大模型 API 接入服务。平台聚合 Claude、GPT、Gemini 等主流满血模型,一个接口,接入全球顶尖 AI 大模型,各大模型价格低至 1 折起,支持企业级高并发、正式合同、对公打款与开票服务,适合 AI 编程、Agent 开发、业务系统集成等多种场景!使用<a href="https://apinebula.ai/VjM74M">此链接</a>注册并在充值时填写 <strong>"ccswitch"</strong> 优惠码可享<strong>九折优惠</strong>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。
|
||||
Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CCSwitch 的用户提供了特别福利,通过<a href="https://www.aicodemirror.com/register?invitecode=9915W3">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
|
||||
Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CCSwitch 的用户提供了特别福利,通过<a href="https://www.aicodemirror.ai/register?invitecode=9915W3">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -83,8 +83,13 @@ 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>
|
||||
<td width="180"><a href="https://aicoding.inc/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>感谢 AICoding 赞助了本项目!AICoding —— 全球大模型 API 超值中转服务!Claude Code 1.9 折,GPT 0.1 折,已为数百家企业提供高性价比 AI 服务。支持 Claude Code、GPT、Gemini 及国内主流模型,企业级高并发、极速开票、7×24 专属技术支持,通过<a href="https://aicoding.inc/i/CCSWITCH">此链接</a> 注册的 CC Switch 用户,首充可享受九折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -98,8 +103,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/register?aff=pCLD"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
|
||||
<td>本项目由 <a href="https://console.claudeapi.com/register?aff=pCLD">Claude API</a> 赞助。Claude API 直连,三分钟接入 Claude Code 与 Agent 应用 新用户可领取测试额度。基于 Anthropic 官方 Key + AWS Bedrock 官方渠道,非逆向、非降智,支持 Opus / Sonnet / Haiku 全系列模型,保留 Tool Use、1M 上下文等官方能力。适合 Claude Code 深度用户、Agent 工程师与企业技术团队,支持开票和团队对接。点击<a href="https://console.claudeapi.com/register?aff=pCLD">这里</a>注册!</td>
|
||||
<td width="180"><a href="https://console.apito.ai/agent/register/pQBql2buaqiX3dDS"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
|
||||
<td>本项目由 <a href="https://console.apito.ai/agent/register/pQBql2buaqiX3dDS">Claude API</a> 赞助。Claude API 直连,三分钟接入 Claude Code 与 Agent 应用 新用户可领取测试额度。基于 Anthropic 官方 Key + AWS Bedrock 官方渠道,非逆向、非降智,支持 Opus / Sonnet / Haiku 全系列模型,保留 Tool Use、1M 上下文等官方能力。适合 Claude Code 深度用户、Agent 工程师与企业技术团队,支持开票和团队对接。点击<a href="https://console.apito.ai/agent/register/pQBql2buaqiX3dDS">这里</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -139,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>
|
||||
@@ -165,8 +175,8 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过<a href="https://www.right.codes/register?aff=CCSWITCH">此链接</a>注册,每次充值均可获得实付金额5%的按量额度!</td>
|
||||
<td width="180"><a href="https://www.rightapi.ai/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过<a href="https://www.rightapi.ai/register?aff=CCSWITCH">此链接</a>注册,每次充值均可获得实付金额5%的按量额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
+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,141 @@
|
||||
# Using GPT Models in Claude Code with CC Switch
|
||||
|
||||
> Applies to CC Switch 3.17.0 and later. (Both integration methods in this guide existed in earlier versions, but the gpt-5.6 preset and the client-identity fix landed in 3.17.0; on older versions, requesting new models like `gpt-5.6-luna` falsely returns 404.) This guide is compiled from the repository's documentation and code, and all sample data has been de-identified.
|
||||
|
||||
## Why local routing is needed
|
||||
|
||||
Claude Code targets the Anthropic Messages protocol — that is, `/v1/messages` — whereas the upstreams for Codex-family models, whether the OpenAI Responses API exposed by a third-party gateway or the Codex service behind a ChatGPT subscription, all speak the Responses protocol. The two protocols use completely different request bodies, streaming events, and response structures, so putting such an endpoint directly into Claude Code's config leaves the upstream receiving a `/v1/messages` request it doesn't recognize — which can only fail.
|
||||
|
||||
CC Switch's approach is to keep Claude Code always connected to the local route and still sending requests as Anthropic Messages; once the route detects that the active provider is Responses-format, it converts the request into Responses for the upstream, then converts the response back into the Messages shape it returns to Claude Code — tool calls, images, PDFs, and thinking configuration are all within the conversion scope.
|
||||
|
||||
This guide covers both integration methods:
|
||||
|
||||
- **Method 1 (API Key)**: you have a gateway endpoint and key compatible with the OpenAI Responses API, and you want to run the GPT-family models behind it inside Claude Code.
|
||||
- **Method 2 (ChatGPT subscription)**: you have a ChatGPT Plus/Pro subscription and use its quota directly by signing in through Codex OAuth — no API key needed at any point.
|
||||
|
||||
The chain has four main steps:
|
||||
|
||||
1. When Claude Code is taken over, `ANTHROPIC_BASE_URL` in `~/.claude/settings.json` is written as the local route address (default `http://127.0.0.1:15721`), the auth entry keeps only a placeholder, and real credentials never enter the live config.
|
||||
2. The provider's `API Format` is set to OpenAI Responses, telling the route that the real upstream speaks the Responses protocol.
|
||||
3. The route converts the `/v1/messages` request into a Responses request body for the upstream; Method 2 additionally carries the OAuth token and the official client identity to reach ChatGPT's Codex service.
|
||||
4. After the upstream responds, the route converts the Responses JSON/SSE back into the Messages shape Claude Code understands.
|
||||
|
||||

|
||||
|
||||
## Prerequisites
|
||||
|
||||
- CC Switch installed and able to start (3.17.0 or later; see the version note at the top for why).
|
||||
- Claude Code installed and run at least once.
|
||||
- For Method 1: a service endpoint compatible with the OpenAI Responses API and its API Key; follow the gateway's documentation for the endpoint and model names. Note it's the **Responses API**, not Chat Completions; a gateway that only offers the Chat format still works — see the `API Format` note in Step 1.
|
||||
- For Method 2: a ChatGPT Plus/Pro subscription account.
|
||||
|
||||
## Step 1: Add a provider
|
||||
|
||||
### Method 1: Third-party Responses gateway (API Key)
|
||||
|
||||
Open CC Switch, switch to the top-level `Claude Code` tab, click the plus button in the upper-right corner to add a provider, keep the default `Custom Configuration`, then fill in:
|
||||
|
||||
- **Provider Name**: anything you like, e.g. `GPT Gateway`.
|
||||
- **API Key**: your gateway key. The real key is stored only in CC Switch and injected by the local route when forwarding.
|
||||
- **API Endpoint**: just the gateway's service root, e.g. `https://gpt-gateway.example.com`, without a trailing slash — the route sends requests to the gateway's Responses endpoint (`/v1/responses`) automatically. When the gateway path is unusual, turn on the `Full URL` toggle next to it and paste the complete endpoint verbatim.
|
||||
|
||||
Then expand `Advanced Options`:
|
||||
|
||||
- **API Format**: change from the default `Anthropic Messages (Native)` to **`OpenAI Responses API (Requires routing)`**. If the gateway only offers the Chat Completions protocol, choose `OpenAI Chat Completions (Requires routing)` here instead; every other step is identical.
|
||||
- **Auth Field**: keep the default `ANTHROPIC_AUTH_TOKEN (Default)`; the route sends `Authorization: Bearer <key>` to the upstream — exactly the auth header an OpenAI-compatible gateway expects. Unless the gateway's documentation explicitly requires `x-api-key`, don't switch to `ANTHROPIC_API_KEY`; the wrong choice typically shows up as 401/403.
|
||||
- **Model Mapping**: map Claude Code's model roles to the real models the gateway recognizes. **At minimum, fill in the `Default fallback model`** (e.g. `gpt-5.6`, per the gateway's documentation) — if left empty, unmatched requests pass through to the upstream under the original Claude model name and error out, while roles you haven't configured individually fall back to it. For finer control, specify per row: put your main model in `Sonnet`/`Opus`, and a cheap, fast model in `Haiku` (Claude Code's background sub-tasks use this tier). The `Display name` only affects what shows in the `/model` menu; leave it empty to show the real model name directly.
|
||||
- **Declare 1M**: the `1M` checkbox on each model-mapping row declares to Claude Code that the tier supports a 1M context. Check it only when the gateway truly serves that model with a window of one million tokens or more (e.g. a gateway offering gpt-5.6 at its API spec); otherwise long conversations will error out at the upstream's real ceiling.
|
||||
|
||||

|
||||
|
||||
After saving, a `Needs Routing` marker appears on the card — providers like this only work while local routing is running.
|
||||
|
||||
### Method 2: ChatGPT subscription (Codex OAuth)
|
||||
|
||||
Again on the `Claude Code` tab, click the plus button and pick the **`Codex`** preset with the OpenAI icon from the preset list — it appearing under the Claude Code tab is not a mistake; this preset is built precisely for "using a ChatGPT subscription inside Claude Code":
|
||||
|
||||
- **No API Key and no address needed** — requests always go to ChatGPT's Codex service, so the address field in the form needs no changes.
|
||||
- Click **`Sign in with ChatGPT`**. This is a device-code flow: CC Switch opens the browser automatically and copies the verification code to the clipboard; paste the code on the browser page to complete authorization, while the app shows `Waiting for authorization...`.
|
||||
- After a successful sign-in, `Auth status` shows the signed-in account (email). Multiple accounts are supported: you can `Add another account`, `Set as default`, or pin a specific account to this provider; day-to-day management can also go through `Settings` → `OAuth Authentication Center`.
|
||||
- **FAST mode**: an optional toggle; when on, requests carry `service_tier="priority"` for lower latency but consume ChatGPT quota at a higher rate. Keep it off by default.
|
||||
- The model tiers are pre-filled: `Sonnet`/`Opus` map to `gpt-5.6`, and `Haiku` maps to `gpt-5.6-luna` (used for background sub-tasks — faster and lighter on quota).
|
||||
|
||||

|
||||
|
||||
The login credentials are stored in `~/.cc-switch/codex_oauth_auth.json` (not `~/.codex/`), independent of the Codex CLI's own login; the token refreshes automatically before it expires.
|
||||
|
||||
## Step 2: Enable local routing and take over Claude Code
|
||||
|
||||
Go to the `Routing` page in Settings, expand `Local Routing`, and complete two toggles:
|
||||
|
||||
1. Turn on the `Routing Master Switch` to start the local service (the first time you enable it, an explanatory confirmation dialog appears). The default address is `127.0.0.1:15721`.
|
||||
2. Turn on `Claude Code` under `Routing Enabled`. If you only want Claude Code to use routing, leave the other apps off.
|
||||
|
||||
After takeover, CC Switch points Claude Code's live config at the local route, with only a placeholder in the auth entry; both Method 1's gateway key and Method 2's OAuth token are injected by the local route on forward.
|
||||
|
||||
> **Note**: the live config is read when the Claude Code process starts. After you first enable takeover (or disable it to restore a direct connection), if Claude Code is already running, open a new terminal session. Afterward, switching providers in routing mode is a hot switch and needs no further restart.
|
||||
|
||||
## Step 3: Switch providers and verify
|
||||
|
||||
Return to the Claude Code provider list and click `Enable` on the target provider. If routing isn't running, CC Switch shows "This provider uses OpenAI Responses API format, requires the routing service to work properly. Start routing first." — this notice doesn't block the switch, but with routing off the request is bound to fail, so go back to Step 2 and turn it on.
|
||||
|
||||
Inside Claude Code you can verify step by step:
|
||||
|
||||
- Open a new session and use `/model` to view the model menu: each tier shows the display name from the model mapping (by default, Method 2 shows `gpt-5.6` and `gpt-5.6-luna`). A few spots in the UI may still show Claude-family model names — those are the internal role aliases the routing takeover uses; this is normal, and the `/model` menu and usage dashboard are authoritative.
|
||||
- Send a small question and watch the `Current Provider` on the Settings → Routing page change to your provider and `Total Requests` start to climb.
|
||||
- In the usage dashboard, these requests show under the upstream's real models: a tier mapped to `gpt-5.6` resolves to the Sol tier and displays as `GPT-5.6 Sol`, while `gpt-5.6-luna` displays as `GPT-5.6 Luna`; you can filter by provider to reconcile token usage.
|
||||
- The Method 2 provider card also shows subscription quota: utilization and reset countdowns for the 5-hour and 7-day windows, drawn from the ChatGPT account itself and shared with the official Codex client.
|
||||
|
||||
## Capabilities and known limitations
|
||||
|
||||
- **Prompt caching is automatic**: the route injects a stable `prompt_cache_key` per session, and together with OpenAI's automatic prefix caching, long conversations don't resend everything at full price each turn — no configuration needed.
|
||||
- **Thinking is mapped to reasoning effort**: Claude Code's thinking toggle and thinking level are mapped to GPT's `reasoning.effort` (low/medium/high); GPT's reasoning content round-trips across turns intact in encrypted form, so multi-turn reasoning coherence is unaffected by the conversion. Method 2 also accesses in stateless mode (`store:false`), leaving no conversation stored on OpenAI's servers.
|
||||
- **Tools and multimodal are fully converted**: multi-turn tool calls, image inputs, and PDF inputs are all fully converted.
|
||||
- **Context is managed against a 200K window**: Claude Code auto-compacts routed providers against a default 200K window. When the upstream's real window is larger (e.g. gpt-5.6 on ChatGPT's Codex service is 372K), anything beyond 200K currently goes unused — compaction triggers early, which is conservative but safe. The only switch to push past 200K today is the `1M` checkbox in the model mapping (a strict 1M declaration), for use only with Method 1 and only when the upstream truly serves the model at 1M or more; Method 2's upstream ceiling is 372K, short of 1M, so checking it would instead make long conversations error out at the upstream's real ceiling — keep it at the default.
|
||||
- **Output ceiling**: for Method 2, the output ceiling is controlled by the ChatGPT server (the `max_tokens` in Claude Code's request is not sent downstream); for Method 1, Claude Code's `max_tokens` is passed through as-is — no configuration needed.
|
||||
- **Web search is unavailable**: Claude Code's WebSearch relies on Anthropic's servers to run, which the GPT upstream can't take on, so for tasks involving web search, switch back to a Claude-family provider. Locally executed WebFetch is unaffected.
|
||||
- **Dashboard dollar amounts are for reference**: token counts are accurate, but the dollar figures are estimates converted at public API prices — Method 2's subscription traffic is estimated at GPT-5.6's public price, and Method 1's third-party gateways bill at their own rates, so both may differ from what you're actually charged and serve only for comparison. For Method 2, treat the window utilization on the provider card as authoritative for quota consumption.
|
||||
|
||||
## FAQ
|
||||
|
||||
**The upstream returns 401 or 403 (Method 1)**
|
||||
|
||||
First confirm the `Auth Field` in Advanced Options is the default `ANTHROPIC_AUTH_TOKEN (Default)` — switching to `ANTHROPIC_API_KEY` sends `x-api-key`, which the vast majority of OpenAI-compatible gateways don't accept. Then confirm the key itself is valid and has balance.
|
||||
|
||||
**Requesting new models like `gpt-5.6-luna` returns 404 Model not found (Method 2)**
|
||||
|
||||
Upgrade to CC Switch 3.17.0 or later. On older versions the client identity wasn't aligned with the official Codex client, so the ChatGPT server resolves new models to a nonexistent engine.
|
||||
|
||||
**The switch didn't take effect, or the `/model` menu still shows old names**
|
||||
|
||||
Both the model menu and the route address are read when Claude Code starts: after first enabling takeover you must open a new terminal session; switching between providers is a hot switch, but the display names in the menu only refresh in a new session.
|
||||
|
||||
**Claude Code reports "Codex OAuth authentication failed", or the card shows "Session expired" (Method 2)**
|
||||
|
||||
The login credentials have expired. Go back to the provider form or `Settings` → `OAuth Authentication Center` and run through `Sign in with ChatGPT` again; no command-line steps are needed.
|
||||
|
||||
**The conversation auto-compacts partway through**
|
||||
|
||||
See "Capabilities and known limitations": routed providers are managed against a 200K window, so the compaction threshold comes early — this is expected behavior.
|
||||
|
||||
**Restoring the official Claude setup**
|
||||
|
||||
Switch back to an official provider, or turn off the `Claude Code` routing toggle on the Routing page — CC Switch restores the pre-takeover live config, and the official login credentials are unaffected throughout. After restoring, you'll again need to open a new terminal session.
|
||||
|
||||
**Should you enable FAST mode? (Method 2)**
|
||||
|
||||
Leaving it off is fine. Turn it on only if you're especially latency-sensitive and willing to accept faster quota consumption; if the ChatGPT server rejects the parameter, turning the toggle off restores things.
|
||||
|
||||
## Compliance note
|
||||
|
||||
Method 2 uses ChatGPT subscription quota outside the official Codex client, and this isn't a gray-area hack: Thibault Sottiaux (@thsottiaux), the OpenAI Codex lead, has publicly demonstrated and encouraged pointing Claude Code (the "orange crab", as he jokingly calls it) at GPT-5.6 Sol — using exactly a "local proxy + model alias" approach, the same category as Method 2 here. As the lead of the Codex product line, his active encouragement to use their own model inside a competitor's client shows that running GPT-family models in Claude Code on a subscription is a use the vendor welcomes and encourages people to try.
|
||||
|
||||
Two practical reminders are still worth noting: first, this traffic is counted against the same subscription quota as the official Codex client, so heavy use hits the cap sooner; second, CC Switch's authentication center keeps a compliance notice out of caution ("Use your other subscriptions in Claude Code — please be mindful of compliance risks."), and whether this fits the terms that apply to your account is for you to check. When using a third-party gateway in Method 1, separately read the target gateway's terms on billing, compliance, and data retention.
|
||||
|
||||
## References
|
||||
|
||||
- [CC Switch User Manual: Add a Provider (incl. Codex OAuth reverse proxy and API formats)](../user-manual/en/2-providers/2.1-add.md)
|
||||
- [CC Switch User Manual: Proxy Service](../user-manual/en/4-proxy/4.1-service.md)
|
||||
- [CC Switch User Manual: App Routing](../user-manual/en/4-proxy/4.2-routing.md)
|
||||
- [CC Switch v3.17.0 Release Notes](../release-notes/v3.17.0-en.md)
|
||||
- Reverse guide: [Using Claude Models in Codex](./codex-claude-routing-guide-en.md)
|
||||
@@ -0,0 +1,141 @@
|
||||
# CC Switch を使って Claude Code で GPT モデルを利用する
|
||||
|
||||
> 対象バージョン: CC Switch 3.17.0 以降(本記事の 2 つの接続方法自体はより古いバージョンにもありますが、gpt-5.6 プリセットとクライアントアイデンティティの修正は 3.17.0 から導入されたため、古いバージョンで `gpt-5.6-luna` のような新しいモデルをリクエストすると 404 が誤って返ることがあります)。本記事はリポジトリ内のドキュメントとコードをもとに整理し、サンプルデータはすべて匿名化しています。
|
||||
|
||||
## ローカルルーティングが必要な理由
|
||||
|
||||
Claude Code が前提としているのは Anthropic Messages プロトコル、つまり `/v1/messages` です。一方で Codex 系モデルの上流——サードパーティゲートウェイが公開している OpenAI Responses API であれ、ChatGPT サブスクリプションの背後にある Codex サービスであれ——が話すのはすべて Responses プロトコルです。この 2 つのプロトコルは、リクエストボディ、ストリーミングイベント、レスポンス構造がまったく異なります。この種のアドレスをそのまま Claude Code の設定に入れても、上流が受け取るのは自分の知らない `/v1/messages` リクエストであり、結果は失敗にしかなりません。
|
||||
|
||||
CC Switch では、Claude Code が常にローカルルートへ接続し、Anthropic Messages のままリクエストを送るようにします。ルートは現在のプロバイダーが Responses 形式だと判定すると、リクエストを Responses に変換して上流へ送り、最後にレスポンスを Messages 形式へ戻して Claude Code に返します——ツール呼び出し、画像、PDF、思考設定もすべて変換の対象です。
|
||||
|
||||
対応する 2 つの接続方法を、本記事はどちらもカバーします:
|
||||
|
||||
- **方式 1(API Key)**: OpenAI Responses API 互換のゲートウェイエンドポイントと Key を持っていて、その背後の GPT 系モデルを Claude Code で動かしたい場合。
|
||||
- **方式 2(ChatGPT サブスクリプション)**: ChatGPT Plus/Pro のサブスクリプションを持っていて、Codex OAuth ログインでサブスクリプション枠を直接使いたい場合。全工程で API Key は不要です。
|
||||
|
||||
この経路は主に 4 つのステップに分かれます:
|
||||
|
||||
1. Claude Code を引き継ぐと、`~/.claude/settings.json` の `ANTHROPIC_BASE_URL` がローカルルートのアドレス(デフォルトは `http://127.0.0.1:15721`)に書き換えられ、認証項目にはプレースホルダーだけが残り、実際の認証情報は live 設定には入りません。
|
||||
2. プロバイダーの `API フォーマット` を OpenAI Responses に設定し、実際の上流は Responses プロトコルだとルートに伝えます。
|
||||
3. ルートは `/v1/messages` リクエストを Responses のリクエストボディへ変換して上流へ送ります。方式 2 ではさらに OAuth token と公式クライアントのアイデンティティを付けて ChatGPT の Codex サービスにアクセスします。
|
||||
4. 上流から返ってきた後、ルートは Responses の JSON/SSE を Claude Code が理解できる Messages 形式へ変換して返します。
|
||||
|
||||

|
||||
|
||||
## 事前準備
|
||||
|
||||
- インストール済みで起動できる CC Switch(3.17.0 以降、理由は冒頭のバージョン説明を参照)。
|
||||
- インストール済みで、少なくとも 1 回は実行したことのある Claude Code。
|
||||
- 方式 1 に必要なもの: OpenAI Responses API 互換のサービスエンドポイントと対応する API Key。エンドポイントアドレスとモデル名はゲートウェイのドキュメントに従ってください。注意すべきは **Responses API** であって Chat Completions ではないという点です。ゲートウェイが Chat 形式しか提供していない場合でも動作します。Step 1 の「API フォーマット」の説明を参照してください。
|
||||
- 方式 2 に必要なもの: ChatGPT Plus/Pro のサブスクリプションアカウント。
|
||||
|
||||
## Step 1: プロバイダーを追加する
|
||||
|
||||
### 方式 1: サードパーティ Responses ゲートウェイ(API Key)
|
||||
|
||||
CC Switch を開き、上部の `Claude Code` タブへ切り替え、右上のプラスボタンからプロバイダーを追加します。デフォルトの `カスタム設定` のまま、次の項目を入力します:
|
||||
|
||||
- **プロバイダー名**: 任意です。たとえば `GPT Gateway`。
|
||||
- **API Key**: あなたのゲートウェイの Key。実際の Key は CC Switch 内にのみ保存され、ローカルルートが転送時に注入します。
|
||||
- **API エンドポイント**: ゲートウェイのサービスルートアドレスを入力すれば十分です。たとえば `https://gpt-gateway.example.com`。末尾にスラッシュを付けないでください。ルートが自動的にそのゲートウェイの Responses エンドポイント(`/v1/responses`)へリクエストを送ります。ゲートウェイのパスが特殊な場合は、隣の `フル URL` スイッチをオンにして、完全なエンドポイントをそのまま貼り付けてください。
|
||||
|
||||
続いて `高級オプション` を展開します:
|
||||
|
||||
- **API フォーマット**: デフォルトの `Anthropic Messages(ネイティブ)` から **`OpenAI Responses API(ルーティングが必要)`** に変更します。ゲートウェイが Chat Completions プロトコルしか提供していない場合は、ここで `OpenAI Chat Completions(ルーティングが必要)` を選びます。その他のステップはまったく同じです。
|
||||
- **認証フィールド**: デフォルトの `ANTHROPIC_AUTH_TOKEN(デフォルト)` のままにします。ルートは `Authorization: Bearer <key>` を上流へ送ります——これはまさに OpenAI 互換ゲートウェイが期待する認証ヘッダーです。ゲートウェイのドキュメントが明確に `x-api-key` を要求している場合を除き、`ANTHROPIC_API_KEY` に変更しないでください。誤って変更した場合の典型的な症状は 401/403 です。
|
||||
- **モデルマッピング**: Claude Code のモデル役割を、ゲートウェイが認識する実際のモデルへマッピングします。**少なくとも `既定フォールバックモデル` は入力してください**(たとえば `gpt-5.6`。ゲートウェイのドキュメントに従ってください)——空欄のままだと、一致しなかったリクエストは元の Claude モデル名のまま上流へ転送されてエラーになります。個別に設定していない役割はこのフォールバックへ回帰します。より細かく設定するには、行ごとに指定します:`Sonnet`/`Opus` に主力モデル、`Haiku` に安価で高速なモデル(Claude Code のバックグラウンド小タスクがこの区分を使います)。`表示名` は `/model` メニューでの表示にのみ影響し、空欄なら実際のモデル名がそのまま表示されます。
|
||||
- **1M 対応を宣言**: モデルマッピングの各行にある `1M` チェックボックスは、その区分が 1M コンテキストに対応していることを Claude Code に宣言します。ゲートウェイが実際に 100 万トークン以上のウィンドウでそのモデルを提供している場合(たとえば API 仕様どおりに gpt-5.6 を提供するゲートウェイ)にのみチェックしてください。そうでないと、長い対話が上流の実際の上限で直接エラーになります。
|
||||
|
||||

|
||||
|
||||
保存すると、カードに `ルーティングが必要` のマークが表示されます——この種のプロバイダーは、ローカルルーティングが実行中でなければ正しく動作しません。
|
||||
|
||||
### 方式 2: ChatGPT サブスクリプション(Codex OAuth)
|
||||
|
||||
同じく `Claude Code` タブでプラスボタンを押し、プリセット一覧で OpenAI アイコンの付いた **`Codex`** プリセットを選びます——これが Claude Code タブの下に出ていても選択ミスではありません。このプリセットはまさに「Claude Code で ChatGPT サブスクリプションを使う」ために用意されたものです:
|
||||
|
||||
- **API Key もアドレスの入力も不要です**——リクエストは常に ChatGPT の Codex サービスへ送られるため、フォームのアドレス項目は変更する必要がありません。
|
||||
- **`ChatGPT でログイン`** をクリックします。これはデバイスコードフローです:CC Switch が自動的にブラウザを開き、認証コードをクリップボードにコピーします。ブラウザのページで認証コードを貼り付けて認可を完了させてください。その間、アプリ内には `認証を待機中...` と表示されます。
|
||||
- ログインに成功すると、`認証状態` にログイン済みアカウント(メールアドレス)が表示されます。複数アカウントに対応しています:`別のアカウントを追加`、`デフォルトに設定`、またはこのプロバイダーで特定のアカウントを指定できます。日常的な管理は `設定` → `OAuth 認証センター` からも行えます。
|
||||
- **FAST モード**: 任意のスイッチです。オンにするとリクエストが `service_tier="priority"` を伴い、より低い遅延と引き換えに、より高いレートで ChatGPT のクォータを消費します。デフォルトはオフのままにしてください。
|
||||
- モデル区分はあらかじめ入力済みです:`Sonnet`/`Opus` が `gpt-5.6` に、`Haiku` が `gpt-5.6-luna` に対応します(バックグラウンドの小タスクがこれを使い、より速く、より枠を節約します)。
|
||||
|
||||

|
||||
|
||||
ログイン認証情報は `~/.cc-switch/codex_oauth_auth.json`(`~/.codex/` ではありません)に保存され、Codex CLI 自身のログインとは互いに影響しません。token は有効期限前に自動的に更新されます。
|
||||
|
||||
## Step 2: ローカルルーティングを有効にして Claude Code をルーティングする
|
||||
|
||||
設定の `ルーティング` ページに入り、`ローカルルーティング` を展開して、2 つのスイッチを設定します:
|
||||
|
||||
1. `ルーティング総スイッチ` をオンにしてローカルサービスを起動します(初回起動時は説明の確認ダイアログが表示されます)。デフォルトアドレスは `127.0.0.1:15721` です。
|
||||
2. `ルーティング有効` で `Claude Code` をオンにします。Claude Code だけをルーティングしたい場合は、その他のアプリはオフのままで構いません。
|
||||
|
||||
引き継ぎ後、CC Switch は Claude Code の live 設定をローカルルートへ向け、認証項目にはプレースホルダーだけが入ります。方式 1 のゲートウェイ Key も方式 2 の OAuth token も、いずれもローカルルートが転送時に注入します。
|
||||
|
||||
> **注意**: live 設定は Claude Code プロセスの起動時に読み込まれます。初回の引き継ぎを有効にした後(または引き継ぎをオフにして直結に戻した後)、Claude Code が実行中の場合は、ターミナルセッションを開き直してください。その後、ルーティングモードでのプロバイダー切り替えはホットスワップになり、再起動は不要です。
|
||||
|
||||
## Step 3: プロバイダーを切り替えて確認する
|
||||
|
||||
Claude Code のプロバイダー一覧に戻り、目的のプロバイダーの `有効化` をクリックします。ルーティングが実行されていない場合、CC Switch は「このプロバイダーは OpenAI Responses API フォーマットを使用しており、ルーティングサービスが必要です。先にルーティングを起動してください」と表示します——この表示は切り替えを妨げませんが、ルーティングが未起動のままではリクエストは必ず失敗します。Step 2 に戻ってオンにすれば解決します。
|
||||
|
||||
Claude Code に入ったら、段階的に確認できます:
|
||||
|
||||
- 新しいセッションを開き、`/model` でモデルメニューを確認します:各区分にはモデルマッピングの表示名が表示されます(方式 2 のデフォルトは `gpt-5.6`、`gpt-5.6-luna`)。画面の一部の箇所には Claude 系のモデル名が現れることがあります——それはルーティング引き継ぎが使う内部の役割エイリアスであり、正常な現象です。`/model` メニューと使用量ダッシュボードを基準にしてください。
|
||||
- 小さな質問を 1 つ送り、設定 → ルーティングページの「現在のプロバイダー」があなたのプロバイダーに変わり、「総リクエスト数」が増え始めるのを確認します。
|
||||
- 使用量ダッシュボードでは、これらのリクエストが上流の実際のモデルで表示されます:`gpt-5.6` にマッピングされた区分は Sol 区分として解析され `GPT-5.6 Sol` と表示され、`gpt-5.6-luna` は `GPT-5.6 Luna` と表示されます。プロバイダーで絞り込んで token 使用量を照合できます。
|
||||
- 方式 2 のプロバイダーカードには、さらにサブスクリプション枠が表示されます:5 時間ウィンドウと 7 日ウィンドウの利用率とリセットまでのカウントダウンです。データは ChatGPT アカウント自体から取得され、公式 Codex クライアントと同じ枠を共有します。
|
||||
|
||||
## 機能の範囲と既知の制限
|
||||
|
||||
- **プロンプトキャッシュが自動で有効**: ルートは各セッションに安定した `prompt_cache_key` を注入し、OpenAI 側の自動プレフィックスキャッシュと組み合わせます。長い対話でも毎回全額で再送されることはなく、設定は不要です。
|
||||
- **思考を reasoning effort に換算**: Claude Code の思考スイッチと思考レベルは GPT の `reasoning.effort`(low/medium/high)へ換算されます。GPT の推論内容は暗号化された形態でターンをまたいで完全に再生され、複数ターンの推論の一貫性は変換の影響を受けません。方式 2 は同時にステルスモード(`store:false`)でアクセスし、OpenAI サーバー側にはセッションを残しません。
|
||||
- **ツールとマルチモーダルを完全変換**: 複数ターンのツール呼び出し、画像、PDF 入力はすべて完全に変換されます。
|
||||
- **コンテキストは 200K ウィンドウで管理**: Claude Code はルーティングプロバイダーに対して、デフォルトの 200K ウィンドウで自動圧縮を行います。上流の実際のウィンドウがより大きい場合(たとえば ChatGPT Codex サービスの gpt-5.6 は 372K)でも、200K を超える部分は現状では使われません——圧縮が早めにトリガーされ、保守的ですが安全です。200K を突破したい場合、現在唯一のスイッチはモデルマッピングの `1M` チェックボックス(厳密に 1M を宣言)で、方式 1 に限り、かつ上流が実際に 1M 以上でそのモデルを提供している場合にのみ使用します。方式 2 の上流上限は 372K で 1M には届かないため、チェックするとかえって長い対話が上流の実際の上限でエラーになります。デフォルトのままにしてください。
|
||||
- **出力上限**: 方式 2 の出力上限は ChatGPT サーバー側が制御します(Claude Code のリクエスト内の `max_tokens` は下流に送られません)。方式 1 は Claude Code の `max_tokens` をそのまま透過するため、設定は不要です。
|
||||
- **Web 検索は利用不可**: Claude Code の WebSearch は Anthropic サーバー側での実行に依存しており、GPT 上流では引き受けられません。Web 検索を伴うタスクは Claude 系プロバイダーへ切り替えることをおすすめします。ローカルで実行される WebFetch は影響を受けません。
|
||||
- **使用量ダッシュボードの金額は参考値**: token カウントは正確ですが、ドル金額は公開 API 価格に基づく概算です——方式 2 のサブスクリプショントラフィックは GPT-5.6 の公開価格で換算され、方式 1 のサードパーティゲートウェイはそれぞれのレートで課金されます。どちらも実際の請求額と一致しない可能性があり、比較用途に留めてください。方式 2 の枠の消費は、プロバイダーカード上のウィンドウ利用率を基準にしてください。
|
||||
|
||||
## よくある質問
|
||||
|
||||
**上流が 401 または 403 を返す(方式 1)**
|
||||
|
||||
まず高級オプションの `認証フィールド` がデフォルトの `ANTHROPIC_AUTH_TOKEN(デフォルト)` になっているか確認してください——`ANTHROPIC_API_KEY` に変更すると `x-api-key` で送信され、大多数の OpenAI 互換ゲートウェイはこれを受け付けません。あわせて、Key 自体が有効で残高があることも確認してください。
|
||||
|
||||
**`gpt-5.6-luna` などの新しいモデルをリクエストすると 404 Model not found が出る(方式 2)**
|
||||
|
||||
CC Switch 3.17.0 以降にアップグレードしてください。古いバージョンではクライアントアイデンティティが公式 Codex クライアントに揃っておらず、ChatGPT サーバー側が新しいモデルを存在しないエンジンへ解決してしまいます。
|
||||
|
||||
**切り替えても反映されない、または `/model` メニューが古い名前のまま**
|
||||
|
||||
モデルメニューもルーティングアドレスも、Claude Code の起動時に読み込まれます:初回の引き継ぎを有効にした後は必ずターミナルセッションを開き直してください。プロバイダー間の切り替えはホットスワップですが、メニューの表示名は新しいセッションでなければ更新されません。
|
||||
|
||||
**Claude Code に「Codex OAuth 認証に失敗しました」と出る、またはカードに「セッションが期限切れです」と表示される(方式 2)**
|
||||
|
||||
ログイン認証情報が失効しています。プロバイダーフォーム、または `設定` → `OAuth 認証センター` に戻り、`ChatGPT でログイン` をもう一度実行してください。コマンドライン操作は一切不要です。
|
||||
|
||||
**対話の途中で自動的に圧縮されてしまう**
|
||||
|
||||
「機能の範囲」を参照してください:ルーティングプロバイダーは 200K ウィンドウで管理され、圧縮のしきい値もそれに合わせて早まります。想定どおりの動作です。
|
||||
|
||||
**公式の Claude の使い方に戻したい**
|
||||
|
||||
公式プロバイダーに切り替えるか、ルーティングページで `Claude Code` のルーティングスイッチをオフにしてください——CC Switch は引き継ぎ前の live 設定に戻し、公式ログインの認証情報は全工程を通じて影響を受けません。戻した後も同様にターミナルセッションを開き直す必要があります。
|
||||
|
||||
**FAST モードをオンにすべきか(方式 2)**
|
||||
|
||||
デフォルトのオフのままで構いません。遅延に特に敏感で、かつクォータのより速い消費を受け入れられる場合にのみオンにしてください。ChatGPT サーバー側がこのパラメータを拒否する場合は、スイッチをオフにすれば復旧します。
|
||||
|
||||
## コンプライアンスに関する注意
|
||||
|
||||
方式 2 は ChatGPT サブスクリプション枠を公式 Codex クライアント以外で使うものですが、これはグレーな手法ではありません:OpenAI Codex の責任者である Thibault Sottiaux(@thsottiaux)自身が、Claude Code(彼が「orange crab」と呼ぶもの)を GPT-5.6 Sol に向けて使うことを公開でデモし、推奨しています——用いているのはまさに「ローカルプロキシ + モデルエイリアス」であり、本記事の方式 2 と同じ類の手法です。Codex という製品ラインの責任者が、競合クライアントで自社モデルを使うことを自ら奨励しているのですから、サブスクリプションで Claude Code に GPT 系モデルを動かすことは、公式が歓迎し、試すことを推奨している使い方だと分かります。
|
||||
|
||||
とはいえ、実務上の 2 点は依然として留意する価値があります:1 つは、この部分のトラフィックが公式 Codex クライアントと同じサブスクリプション枠に合算されるため、ヘビーユースではより速く上限に達すること。もう 1 つは、CC Switch の認証センターが念のためコンプライアンス上の注意(「Claude Code で他のサブスクリプションをご利用いただけます。コンプライアンスリスクにご注意ください。」)を残していることです。あなたのアカウントに適用される規約に沿うかどうかは、各自でご確認ください。方式 1 でサードパーティゲートウェイを使う場合は、対象ゲートウェイの課金・コンプライアンス・データ保持に関する規約を別途お読みください。
|
||||
|
||||
## 参考リンク
|
||||
|
||||
- [CC Switch ユーザーマニュアル: プロバイダーを追加(Codex OAuth リバースプロキシと API フォーマットを含む)](../user-manual/ja/2-providers/2.1-add.md)
|
||||
- [CC Switch ユーザーマニュアル: プロキシサービス](../user-manual/ja/4-proxy/4.1-service.md)
|
||||
- [CC Switch ユーザーマニュアル: アプリケーションルーティング](../user-manual/ja/4-proxy/4.2-routing.md)
|
||||
- [CC Switch v3.17.0 リリースノート](../release-notes/v3.17.0-ja.md)
|
||||
- 逆方向のガイド: [Codex で Claude モデルを使う](./codex-claude-routing-guide-ja.md)
|
||||
@@ -1,4 +1,4 @@
|
||||
# 在 Claude Code 中用 Codex:CC Switch 本地路由攻略
|
||||
# 通过 CC Switch 在 Claude Code 中使用 GPT 模型
|
||||
|
||||
> 适用版本:CC Switch 3.17.0 及以上(更早版本已具备本文两种接入方式,但 gpt-5.6 预设与客户端身份修复自 3.17.0 落地,低版本请求 `gpt-5.6-luna` 这类新模型会误报 404)。本文根据仓库内文档与代码整理,示例数据均已去敏。
|
||||
|
||||
@@ -75,8 +75,6 @@ CC Switch 的做法是让 Claude Code 始终连本机路由,仍以 Anthropic M
|
||||
|
||||
> **注意**:live 配置是 Claude Code 进程启动时读取的。首次开启接管(或关闭接管恢复直连)后,如果 Claude Code 正在运行,请重开一个终端会话。之后在路由模式下切换供应商就是热切换,无需再重启。
|
||||
|
||||
<!-- TODO 截图 04:本地路由页面中启用 Claude Code 接管(docs/images/claude-codex-routing/04-local-route-claude-takeover.png)。此图需在「Claude Code 路由已开启」状态下截取,而开启接管会把本机 settings.json 的 base_url 改写为 127.0.0.1,需在不依赖该配置的环境中补拍。 -->
|
||||
|
||||
## 第三步:切换供应商并验证
|
||||
|
||||
回到 Claude Code 供应商列表,点击目标供应商的 `启用`。如果路由没有在运行,CC Switch 会提示「此供应商使用 OpenAI Responses 接口格式,需要路由服务才能正常使用,请先启动路由」——这个提示不会拦截切换,但路由未开时请求必然失败,回到第二步打开即可。
|
||||
@@ -140,4 +138,4 @@ CC Switch 的做法是让 Claude Code 始终连本机路由,仍以 Anthropic M
|
||||
- [CC Switch 用户手册:代理服务](../user-manual/zh/4-proxy/4.1-service.md)
|
||||
- [CC Switch 用户手册:应用路由](../user-manual/zh/4-proxy/4.2-routing.md)
|
||||
- [CC Switch v3.17.0 发布说明](../release-notes/v3.17.0-zh.md)
|
||||
- 反方向攻略:[在 Codex 中用 Claude](./codex-claude-routing-guide-zh.md)
|
||||
- 反方向攻略:[在 Codex 中使用 Claude 模型](./codex-claude-routing-guide-zh.md)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Using Claude in Codex: CC Switch Local Routing Guide
|
||||
# Using Claude Models in Codex with CC Switch
|
||||
|
||||
> Applies to CC Switch 3.17.0 and later (the Anthropic Messages upstream was introduced in 3.17.0). This guide is based on the repository documentation and code, and uses a Claude-family relay gateway as the example. Screenshots are generated from the current frontend UI with de-identified sample data to avoid exposing a real API key.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Codex で Claude を使う: CC Switch ローカルルーティングガイド
|
||||
# CC Switch を使って Codex で Claude モデルを利用する
|
||||
|
||||
> 対象バージョン: CC Switch 3.17.0 以降(「Anthropic Messages 上流」は 3.17.0 から導入)。本記事はリポジトリ内のドキュメントとコードをもとに整理し、Claude 系中継ゲートウェイを例に説明します。スクリーンショットは現在のフロントエンド UI から、実際の API Key が漏れないよう匿名化したサンプルデータで生成しています。
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 在 Codex 中用 Claude:CC Switch 本地路由攻略
|
||||
# 通过 CC Switch 在 Codex 中使用 Claude 模型
|
||||
|
||||
> 适用版本:CC Switch 3.17.0 及以上(「Anthropic Messages 上游」自 3.17.0 引入)。本文根据仓库内文档与代码整理,以 Claude 系中转网关为例演示。截图来自当前前端界面,使用去敏示例数据生成,避免泄露真实 API Key。
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
The new capabilities in this release land mainly in the provider presets, Settings → OAuth Auth Center, and the usage dashboard. The following docs are worth reading alongside it:
|
||||
|
||||
- **[xAI Grok Account Sign-In (Settings → OAuth Auth Center)](../user-manual/en/1-getting-started/1.5-settings.md)**: the device-code login flow, multi-account management, and the integration's boundaries; please read the client-identity disclosure under "Risk Notice" below before use.
|
||||
- **[Using Codex-Style Providers in Claude Code (local routing guide)](../guides/claude-codex-routing-guide-zh.md)** (currently Chinese only): a new step-by-step guide added in this release. Claude Code always speaks Anthropic Messages to the local `/v1/messages` route, and the local proxy converts each request to the upstream's Responses protocol — a gateway API key, a native Responses endpoint like xAI, or a ChatGPT subscription's Codex service all fit.
|
||||
- **[Using Claude in Codex (local routing guide)](../guides/codex-claude-routing-guide-en.md)**: a new step-by-step guide in three languages, pairing with v3.17.0's native Anthropic Messages upstream to connect Codex to any Claude-family gateway that only offers `/v1/messages`.
|
||||
- **[Using GPT Models in Claude Code (local routing guide)](../guides/claude-codex-routing-guide-en.md)** (now in Chinese / English / Japanese): a new step-by-step guide added in this release. Claude Code always speaks Anthropic Messages to the local `/v1/messages` route, and the local proxy converts each request to the upstream's Responses protocol — a gateway API key, a native Responses endpoint like xAI, or a ChatGPT subscription's Codex service all fit.
|
||||
- **[Using Claude Models in Codex (local routing guide)](../guides/codex-claude-routing-guide-en.md)**: a new step-by-step guide in three languages, pairing with v3.17.0's native Anthropic Messages upstream to connect Codex to any Claude-family gateway that only offers `/v1/messages`.
|
||||
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: understand the Usage Dashboard's data sources and how the statistics are counted. This release fixes the usage double count and adds the "Rebuild Codex Usage" maintenance action.
|
||||
|
||||
---
|
||||
@@ -82,7 +82,7 @@ Settings → OAuth Auth Center gains an xAI section: device-code login (user cod
|
||||
|
||||
The integration's boundaries are pinned shut: no matter what the endpoint / format fields in the form say, the upstream is always `https://api.x.ai/v1/responses` (Responses format); OAuth endpoints are resolved via OIDC discovery but strictly validated to `auth.x.ai` over https; refresh tokens live in `~/.cc-switch/xai_oauth_auth.json` (`0600` on Unix; access tokens are memory-only); and OAuth error bodies never enter error messages or logs. Pricing for `grok-4.5` ($2 input / $6 output / $0.50 cache read per million tokens) is seeded in sync so its usage no longer records $0, with existing databases picking the row up automatically on next launch. Four-locale copy included. Please read the client-identity disclosure under "Risk Notice" before use.
|
||||
|
||||
Not using OAuth and only have a pay-as-you-go xAI API key? You can still connect it to Claude Code: xAI's API endpoint is standard Responses protocol, so add it as an ordinary Responses provider — a custom provider with `https://api.x.ai/v1` and your API key, upstream format set to Responses, converted between Anthropic Messages ↔ Responses by local routing; it's the same recipe as the [Using Codex-Style Providers in Claude Code](../guides/claude-codex-routing-guide-zh.md) guide. On the Codex side there's a ready-made API-key preset — see the next section.
|
||||
Not using OAuth and only have a pay-as-you-go xAI API key? You can still connect it to Claude Code: xAI's API endpoint is standard Responses protocol, so add it as an ordinary Responses provider — a custom provider with `https://api.x.ai/v1` and your API key, upstream format set to Responses, converted between Anthropic Messages ↔ Responses by local routing; it's the same recipe as the [Using GPT Models in Claude Code](../guides/claude-codex-routing-guide-en.md) guide. On the Codex side there's a ready-made API-key preset — see the next section.
|
||||
|
||||
### Codex Straight to xAI: Managed OAuth and Native API-Key Presets
|
||||
|
||||
@@ -204,8 +204,8 @@ Fifteen OpenClaw preset entries carried cost values in the wrong unit or unconve
|
||||
|
||||
Two new guides complete the pair — "Claude models inside the Codex client" and "Responses providers inside the Claude Code client":
|
||||
|
||||
- **[Using Claude in Codex](../guides/codex-claude-routing-guide-en.md)** (Chinese / English / Japanese, with screenshots): pairs with v3.17.0's native Anthropic Messages upstream to connect Codex to a Claude-family `/v1/messages` gateway; the v3.17.0 release notes now link to it.
|
||||
- **[Using Codex-Style Providers in Claude Code](../guides/claude-codex-routing-guide-zh.md)** (Chinese, with screenshots): drive Claude Code with Responses-speaking providers (a gateway API key, or a ChatGPT subscription's Codex service) — Claude Code always speaks Anthropic Messages to the local `/v1/messages` route, and the proxy converts each request to the upstream's Responses protocol.
|
||||
- **[Using Claude Models in Codex](../guides/codex-claude-routing-guide-en.md)** (Chinese / English / Japanese, with screenshots): pairs with v3.17.0's native Anthropic Messages upstream to connect Codex to a Claude-family `/v1/messages` gateway; the v3.17.0 release notes now link to it.
|
||||
- **[Using GPT Models in Claude Code](../guides/claude-codex-routing-guide-en.md)** (Chinese / English / Japanese, with screenshots): drive Claude Code with Responses-speaking providers (a gateway API key, or a ChatGPT subscription's Codex service) — Claude Code always speaks Anthropic Messages to the local `/v1/messages` route, and the proxy converts each request to the upstream's Responses protocol.
|
||||
|
||||
### README Sponsor Updates
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
本リリースの新機能は、主にプロバイダープリセット、「設定 → OAuth 認証センター」、使用量ダッシュボードにあります。以下のドキュメントもあわせてご覧ください:
|
||||
|
||||
- **[xAI Grok アカウントサインイン(設定 → OAuth 認証センター)](../user-manual/ja/1-getting-started/1.5-settings.md)**:デバイスコードログインの流れ、複数アカウント管理、連携の境界について説明します。利用前に下記「リスク通知」のクライアント識別情報の開示を必ずお読みください。
|
||||
- **[Claude Code で Codex 系プロバイダーを使う(ローカルルーティングガイド)](../guides/claude-codex-routing-guide-zh.md)**(現時点では中国語のみ):本リリース追加のステップバイステップガイドです。Claude Code は常にローカルの `/v1/messages` ルートに対して Anthropic Messages を話し、ローカルプロキシが各リクエストを上流の Responses プロトコルへ変換します——ゲートウェイの API key、xAI のようなネイティブ Responses endpoint、ChatGPT サブスクリプションの Codex サービスのいずれにも使えます。
|
||||
- **[Codex で Claude を使う(ローカルルーティングガイド)](../guides/codex-claude-routing-guide-ja.md)**:本リリース追加の 3 言語ガイド。v3.17.0 の「ネイティブ Anthropic Messages 上流」と組み合わせ、`/v1/messages` のみを提供する Claude 系ゲートウェイへ Codex を接続します。
|
||||
- **[Claude Code で GPT モデルを使う(ローカルルーティングガイド)](../guides/claude-codex-routing-guide-ja.md)**(中国語 / 英語 / 日本語):本リリース追加のステップバイステップガイドです。Claude Code は常にローカルの `/v1/messages` ルートに対して Anthropic Messages を話し、ローカルプロキシが各リクエストを上流の Responses プロトコルへ変換します——ゲートウェイの API key、xAI のようなネイティブ Responses endpoint、ChatGPT サブスクリプションの Codex サービスのいずれにも使えます。
|
||||
- **[Codex で Claude モデルを使う(ローカルルーティングガイド)](../guides/codex-claude-routing-guide-ja.md)**:本リリース追加の 3 言語ガイド。v3.17.0 の「ネイティブ Anthropic Messages 上流」と組み合わせ、`/v1/messages` のみを提供する Claude 系ゲートウェイへ Codex を接続します。
|
||||
- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**:使用量ダッシュボードのデータソースと集計方法を確認できます。本リリースでは二重計上を修正し、「Codex 使用量を再構築」メンテナンス操作を追加しました。
|
||||
|
||||
---
|
||||
@@ -82,7 +82,7 @@ Claude Code と Claude Desktop に「xAI (Grok)」プリセットが加わり、
|
||||
|
||||
連携の境界は固定されています。フォームの endpoint / 形式フィールドをどう変えても上流は常に `https://api.x.ai/v1/responses`(Responses 形式)。OAuth endpoint は OIDC discovery で解決しますが https の `auth.x.ai` のみに厳格検証。refresh token は `~/.cc-switch/xai_oauth_auth.json`(Unix ではパーミッション `0600`、access token はメモリのみ)。OAuth のエラーボディはエラーメッセージにもログにも決して入りません。`grok-4.5` の価格(100 万 token あたり入力 $2 / 出力 $6 / キャッシュ読み取り $0.50)も登録され、使用量が $0 と記録されなくなります。既存データベースは次回起動時に自動で行を取り込みます。4 言語対応。利用前に「リスク通知」のクライアント識別情報の開示をお読みください。
|
||||
|
||||
OAuth を使わず、従量課金の xAI API key しかない場合でも、Claude Code へ接続できます。xAI の API endpoint は標準の Responses プロトコルなので、通常の Responses プロバイダーとして追加してください——カスタムプロバイダーに `https://api.x.ai/v1` と API key を設定し、上流形式に Responses を選ぶと、ローカルルーティングが Anthropic Messages ↔ Responses を変換します。[Claude Code で Codex 系プロバイダーを使う](../guides/claude-codex-routing-guide-zh.md)ガイドと同じ手順です。Codex 側には既製の API key プリセットがあります(次節参照)。
|
||||
OAuth を使わず、従量課金の xAI API key しかない場合でも、Claude Code へ接続できます。xAI の API endpoint は標準の Responses プロトコルなので、通常の Responses プロバイダーとして追加してください——カスタムプロバイダーに `https://api.x.ai/v1` と API key を設定し、上流形式に Responses を選ぶと、ローカルルーティングが Anthropic Messages ↔ Responses を変換します。[Claude Code で GPT モデルを使う](../guides/claude-codex-routing-guide-ja.md)ガイドと同じ手順です。Codex 側には既製の API key プリセットがあります(次節参照)。
|
||||
|
||||
### Codex から xAI へ直結:管理下 OAuth と API Key ネイティブの 2 プリセット
|
||||
|
||||
@@ -204,8 +204,8 @@ OpenClaw プリセットの 15 エントリが、誤った単位や未換算の
|
||||
|
||||
「Codex クライアントで Claude モデルを使う」「Claude Code クライアントで Responses プロバイダーを使う」の両方向が、2 つの新ガイドで揃いました:
|
||||
|
||||
- **[Codex で Claude を使う](../guides/codex-claude-routing-guide-ja.md)**(中 / 英 / 日 3 言語、スクリーンショット付き):v3.17.0 のネイティブ Anthropic Messages 上流と組み合わせ、Claude 系の `/v1/messages` ゲートウェイへ Codex を接続します。v3.17.0 のリリースノートからもリンクされています。
|
||||
- **[Claude Code で Codex 系プロバイダーを使う](../guides/claude-codex-routing-guide-zh.md)**(中国語、スクリーンショット付き):Responses を話すプロバイダー(ゲートウェイの API key、または ChatGPT サブスクリプションの Codex サービス)で Claude Code を動かします——Claude Code は常にローカルの `/v1/messages` ルートへ Anthropic Messages を話し、プロキシが各リクエストを上流の Responses プロトコルへ変換します。
|
||||
- **[Codex で Claude モデルを使う](../guides/codex-claude-routing-guide-ja.md)**(中 / 英 / 日 3 言語、スクリーンショット付き):v3.17.0 のネイティブ Anthropic Messages 上流と組み合わせ、Claude 系の `/v1/messages` ゲートウェイへ Codex を接続します。v3.17.0 のリリースノートからもリンクされています。
|
||||
- **[Claude Code で GPT モデルを使う](../guides/claude-codex-routing-guide-ja.md)**(中 / 英 / 日 3 言語、スクリーンショット付き):Responses を話すプロバイダー(ゲートウェイの API key、または ChatGPT サブスクリプションの Codex サービス)で Claude Code を動かします——Claude Code は常にローカルの `/v1/messages` ルートへ Anthropic Messages を話し、プロキシが各リクエストを上流の Responses プロトコルへ変換します。
|
||||
|
||||
### README のスポンサー欄を更新
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
本版新能力主要落在供应商预设、「设置 → OAuth 授权中心」与用量看板里,建议结合以下文档了解:
|
||||
|
||||
- **[xAI Grok 账号登录(设置 → OAuth 授权中心)](../user-manual/zh/1-getting-started/1.5-settings.md)**:设备码登录流程、多账号管理与集成边界说明;使用前请先阅读下方「风险提示」中的客户端身份披露。
|
||||
- **[在 Claude Code 中使用 Codex 类供应商(本地路由攻略)](../guides/claude-codex-routing-guide-zh.md)**:本版新增的中文分步攻略。Claude Code 始终对本地 `/v1/messages` 路由说 Anthropic Messages 协议,由本地代理把每个请求转换成上游的 Responses 协议——网关 API Key、xAI 这类原生 Responses 端点,或 ChatGPT 订阅的 Codex 服务都适用。
|
||||
- **[在 Codex 中用 Claude(本地路由攻略)](../guides/codex-claude-routing-guide-zh.md)**:本版新增的三语分步攻略,配合 v3.17.0 的「原生 Anthropic Messages 上游」功能,把 Codex 接到任何只提供 `/v1/messages` 的 Claude 系网关。
|
||||
- **[在 Claude Code 中使用 GPT 模型(本地路由攻略)](../guides/claude-codex-routing-guide-zh.md)**:本版新增的分步攻略,现已中 / 英 / 日三语齐全。Claude Code 始终对本地 `/v1/messages` 路由说 Anthropic Messages 协议,由本地代理把每个请求转换成上游的 Responses 协议——网关 API Key、xAI 这类原生 Responses 端点,或 ChatGPT 订阅的 Codex 服务都适用。
|
||||
- **[在 Codex 中使用 Claude 模型(本地路由攻略)](../guides/codex-claude-routing-guide-zh.md)**:本版新增的三语分步攻略,配合 v3.17.0 的「原生 Anthropic Messages 上游」功能,把 Codex 接到任何只提供 `/v1/messages` 的 Claude 系网关。
|
||||
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:了解用量看板的数据来源与统计口径。本版修复用量双计并新增「重建 Codex 用量」维护操作。
|
||||
|
||||
---
|
||||
@@ -82,7 +82,7 @@ Claude Code 与 Claude Desktop 新增「xAI (Grok)」预设,用 OAuth 设备
|
||||
|
||||
集成边界是钉死的:无论表单里的端点 / 格式字段怎么改,上游始终是 `https://api.x.ai/v1/responses`(Responses 格式);OAuth 端点经 OIDC 发现解析,但强制校验为 https 的 `auth.x.ai`;刷新令牌存于 `~/.cc-switch/xai_oauth_auth.json`(Unix 上 `0600`;访问令牌只存内存);OAuth 错误响应体绝不进入错误信息或日志。`grok-4.5` 定价($2 输入 / $6 输出 / $0.50 缓存读,每百万 token)同步入库,用量不再记 $0,存量数据库下次启动自动补行。四语文案同步。使用前请阅读「风险提示」中的客户端身份披露。
|
||||
|
||||
不用 OAuth、只有按量付费的 xAI API Key?同样能接进 Claude Code:xAI 的 API 端点就是标准 Responses 协议,把它当作一个普通的 Responses 供应商添加——自定义供应商填 `https://api.x.ai/v1` 与 API Key、上游格式选 Responses,经本地路由完成 Anthropic Messages ↔ Responses 转换,与〈[在 Claude Code 中使用 Codex 类供应商](../guides/claude-codex-routing-guide-zh.md)〉攻略是同一套玩法。Codex 侧则有现成的 API Key 预设,见下一节。
|
||||
不用 OAuth、只有按量付费的 xAI API Key?同样能接进 Claude Code:xAI 的 API 端点就是标准 Responses 协议,把它当作一个普通的 Responses 供应商添加——自定义供应商填 `https://api.x.ai/v1` 与 API Key、上游格式选 Responses,经本地路由完成 Anthropic Messages ↔ Responses 转换,与〈[在 Claude Code 中使用 GPT 模型](../guides/claude-codex-routing-guide-zh.md)〉攻略是同一套玩法。Codex 侧则有现成的 API Key 预设,见下一节。
|
||||
|
||||
### Codex 直连 xAI:OAuth 受管与 API Key 原生双预设
|
||||
|
||||
@@ -204,8 +204,8 @@ Chat→Responses 流式桥的两个 bug 会弄坏「身份分散在多个 chunk
|
||||
|
||||
两篇新攻略把「Codex 客户端用 Claude 模型」「Claude Code 客户端用 Responses 供应商」补成了双向:
|
||||
|
||||
- **[在 Codex 中用 Claude](../guides/codex-claude-routing-guide-zh.md)**(中 / 英 / 日三语,含截图):配合 v3.17.0 的原生 Anthropic Messages 上游,把 Codex 接到 Claude 系 `/v1/messages` 网关;v3.17.0 的 release notes 已回链本攻略。
|
||||
- **[在 Claude Code 中使用 Codex 类供应商](../guides/claude-codex-routing-guide-zh.md)**(中文,含截图):用 Responses 协议的供应商(网关 API Key,或 ChatGPT 订阅的 Codex 服务)驱动 Claude Code——Claude Code 始终对本地 `/v1/messages` 路由说 Anthropic Messages,由代理把每个请求转换成上游的 Responses 协议。
|
||||
- **[在 Codex 中使用 Claude 模型](../guides/codex-claude-routing-guide-zh.md)**(中 / 英 / 日三语,含截图):配合 v3.17.0 的原生 Anthropic Messages 上游,把 Codex 接到 Claude 系 `/v1/messages` 网关;v3.17.0 的 release notes 已回链本攻略。
|
||||
- **[在 Claude Code 中使用 GPT 模型](../guides/claude-codex-routing-guide-zh.md)**(中 / 英 / 日三语,含截图):用 Responses 协议的供应商(网关 API Key,或 ChatGPT 订阅的 Codex 服务)驱动 Claude Code——Claude Code 始终对本地 `/v1/messages` 路由说 Anthropic Messages,由代理把每个请求转换成上游的 Responses 协议。
|
||||
|
||||
### README 赞助商更新
|
||||
|
||||
|
||||
@@ -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` |
|
||||
@@ -0,0 +1,369 @@
|
||||
# CC Switch v3.19.1
|
||||
|
||||
> The through-line of this release is **tying off the loose ends of the last one**: three Chinese Codex gateways have been confirmed to support the Responses API natively, so **local routing takeover is no longer required** — the DeepSeek and Volcengine Ark Coding Plan presets move from routed to direct, and the newly added Tencent Hunyuan TokenHub is direct from the start. Four failures you could hit in daily use are fixed: **Claude Desktop usage has been counted twice since v3.18.0** (the historical numbers correct themselves after upgrading, but there is a 30-day window — see "Upgrade Notes"), switching back to the official Codex provider left you stuck on a 401 with no login screen, upgrading Grok Build from Settings failed with nothing but `os error 2`, and enabling takeover on Grok Build returned a straight 404. On top of that, eight models that had been billed at $0 gain built-in pricing, and 39 interface strings have their language problem fixed. This release has **no database migration**, and it is the first release in this project's history that deletes more than it adds.
|
||||
|
||||
**[中文版 →](v3.19.1-zh.md) | [日本語版 →](v3.19.1-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Highlights: What You Can Do Now
|
||||
|
||||
- **Connect DeepSeek, Volcengine Ark Coding Plan, and Tencent Hunyuan directly inside Codex**: all three vendors' official Codex documentation now confirms their endpoints serve the Responses API natively. The existing DeepSeek and Volcengine Ark Coding Plan presets move from Chat format to native format, so the "needs routing" badge on the provider card and the prompt on switching both disappear and requests no longer pass through the local proxy's protocol conversion; Tencent Hunyuan TokenHub is new in this release and is native from the start. **Note that DeepSeek V4 Pro cannot use the direct connection yet** — the vendor has not opened its Codex integration for that model. Use V4 Flash for direct connections (it is the preset default); see [Upgrade Notes](#deepseek-v4-pro-cannot-use-the-direct-connection-yet).
|
||||
- **Let DeepSeek use the model catalog DeepSeek itself publishes**: the new "official vendor catalog mirroring" mechanism serves a vendor's published `models.json` verbatim to that vendor's own endpoint, so the freeform `apply_patch` registration and its companion GPT-5 harness stay together instead of being flattened into the neutral template. The match is on host only, never on model name — the same model resold by an aggregator may not implement the same capabilities.
|
||||
- **Get correct Claude Desktop usage numbers**: since v3.18.0, Claude Desktop traffic through the local gateway has been recorded twice in the dashboard — once from the proxy and once from session-log import — roughly doubling its tokens, cost, and request counts. After this fix, every day whose detail rows are still present returns to the correct number automatically, **with no rebuild required**.
|
||||
- **Sign in normally after switching back to the official Codex provider**: previously, switching from a third-party provider back to the built-in official Codex entry left the third-party key in `~/.codex/auth.json`. Codex would use it against the official endpoint and get a reliable 401 — and because the file existed, it never fell back to its own login screen, leaving no way out from inside the app.
|
||||
- **Upgrade Grok Build from the Settings page**: since 0.2.112, `grok update` performs its distribution by calling npm internally, but an app launched from the GUI cannot see node, so the upgrade only ever reported `Error: No such file or directory (os error 2)`.
|
||||
- **Enable takeover on Grok Build without hitting a 404**: a Grok Build provider whose API format had been changed by hand to OpenAI Chat or Anthropic would, once takeover was enabled, send requests to a route the proxy does not register — a straight 404, with no failover and no usage record. On top of that, every Grok Build request used to be treated as a new session, which disabled both cache-key injection and per-session grouping.
|
||||
- **See the real cost of eight models that had been billed at $0**: `gpt-5.3-codex-spark`, `gemini-3.5-flash-lite`, `kimi-k2.7-code-highspeed`, `glm-5-turbo`, `glm-5v-turbo`, `qwen3.6-flash`, plus the undated `claude-opus-4-6` / `claude-sonnet-4-6`.
|
||||
- **Read the About page's tool manager in Traditional Chinese**: 30 strings had only been added for Simplified Chinese, English, and Japanese and were missing for Traditional Chinese; because i18next silently falls back to English, that panel had been half-English since v3.16.0. A further 9 strings showed Simplified Chinese **in every language**.
|
||||
- **Move back and forth between the official subscription and DeepSeek instead of choosing one**: `auth.json` and `config.toml` are single-slot files — Codex itself cannot hold a second set of credentials. A vendor's one-click script rewrites that configuration to be its own, whereas CC Switch snapshots and restores it per provider — which is the most practical difference between the two approaches; see [the comparison below](#importing-through-cc-switch-vs-running-the-official-script).
|
||||
|
||||
---
|
||||
|
||||
## Usage Guides
|
||||
|
||||
The changes in this release center on how Codex connects and on how usage is counted. The following docs are worth reading alongside it:
|
||||
|
||||
- **[Local Routing](../user-manual/en/4-proxy/4.2-routing.md)**: which providers need takeover enabled, and what takeover does. After this release, DeepSeek, Volcengine Ark Coding Plan, and Tencent Hunyuan no longer need it.
|
||||
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: the usage dashboard's data sources and how the statistics are counted — useful for understanding how the Claude Desktop double count happened and why some historical days cannot be corrected.
|
||||
- **[Using Chat-Format APIs Like DeepSeek in Codex](../guides/codex-deepseek-routing-guide-en.md)**: this guide explains how local routing converts Responses into Chat Completions. The mechanism still applies to Kimi, MiniMax, SiliconFlow, and other providers that remain Chat-shaped, but **the parts that use DeepSeek as the example no longer apply to this release** — DeepSeek now connects directly and needs no routing.
|
||||
|
||||
---
|
||||
|
||||
> [!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.1 is a maintenance release along three lines. The first is Chinese Codex gateways moving to native Responses as a group: DeepSeek connects directly to `api.deepseek.com` and brings a reusable mechanism with it — mirroring the model catalog a vendor publishes itself, so the freeform `apply_patch` registration and its companion GPT-5 harness stay self-consistent rather than being folded into the neutral template; Volcengine's Ark Coding Plan endpoint `/api/coding/v3` follows now that the official documentation confirms it; and Tencent Hunyuan's TokenHub joins as a new preset. None of the three needs local routing takeover any more.
|
||||
|
||||
The second is four failures visible in the field: Claude Desktop usage has been recorded twice since v3.18.0 ([#5938](https://github.com/farion1231/cc-switch/issues/5938)); switching back to the built-in official Codex provider left a third-party `auth.json` behind, producing a 401 with no login screen; `grok update` reported nothing but `os error 2` when run from the GUI; and Grok Build's proxy takeover returned 404 on non-Responses backends while every request was treated as a new session ([#5677](https://github.com/farion1231/cc-switch/pull/5677)). The third is weight loss: 3,166 lines of code with no remaining caller and 4 unused npm dependencies are deleted — this is the first release in the project's history that deletes more than it adds. Beyond that, deep-link import confirmations mask more and truncate less, eight models that had been billed at $0 gain pricing, and four built-in prices are realigned with vendor list prices. This release has **no database schema migration** (the version stays at v16), so upgrading is light.
|
||||
|
||||
**Release date**: 2026-07-31
|
||||
|
||||
**Change size**: 12 commits | 71 files changed | +2,324 / -3,680 lines
|
||||
|
||||
---
|
||||
|
||||
## Added
|
||||
|
||||
### Official Vendor Model Catalog Mirroring (DeepSeek First)
|
||||
|
||||
Codex reads model capabilities from a catalog file, and CC Switch previously generated that catalog from a neutral template for every provider — which is right for an aggregator, but strips the capabilities a vendor's own integration depends on. Now, for any vendor whose official catalog is bundled with the app, that vendor's own file is mirrored directly.
|
||||
|
||||
DeepSeek is the first: the bundled file carries the `deepseek-v4-flash` and `deepseek-v4-pro` entries, preserving `apply_patch_tool_type: "freeform"`, `web_search_tool_type: "text"`, `supports_search_tool: true`, the low / high / max reasoning tiers, and the 17,644-character GPT-5 harness in `base_instructions` and `model_messages` — **that harness has to travel together with the freeform tool registration**, because the harness itself instructs the model to use `apply_patch`; splitting either half leaves the pair inconsistent.
|
||||
|
||||
The gate is deliberately narrow: the provider must resolve to the native Responses profile **and** its `base_url` must be on `deepseek.com`. **Matching is by host, not by model brand** — the same model resold by an aggregator may not implement the same capabilities, and granting by brand would hand capabilities to a service that never implemented them. Entries a provider pins in its own catalog still win; an unrecognized model ID clones the flagship entry but keeps its own name. Catalogs generated for every other profile are byte-identical to before.
|
||||
|
||||
### Tencent Hunyuan (TokenHub) Codex Preset
|
||||
|
||||
The Codex preset picker gains "Tencent Hunyuan" under the Opensource Official category, between Bailian and StepFun. Selecting it writes `https://tokenhub.tencentmaas.com/v1`, `wire_api = "responses"`, and the `disable_response_storage = true` that TokenHub requires; it declares the two models `hy3` and `hy3-preview` with a **256K** context window (rather than accepting Codex's 128K default), and marks them text-only — Codex will no longer send `view_image` payloads to a model that cannot read them.
|
||||
|
||||
Because it is a native Responses provider, Codex connects to the gateway directly with no local routing; the generated catalog uses the neutral native template, which pins `shell_type = "shell_command"` and drops the freeform `apply_patch` registration that native gateways reject. The address manager and latency test have two candidates from the start: the primary domain and the official `.cn` backup. The regionally separate international site is deliberately excluded, because API keys do not carry across sites.
|
||||
|
||||
Note that the **API key must be a TokenHub key with Hy3 access enabled**; Coding Plan and Token Plan subscription keys do not work against this endpoint.
|
||||
|
||||
### Built-In Pricing for Eight Models That Had Been Billed at $0
|
||||
|
||||
`gpt-5.3-codex-spark`, `gemini-3.5-flash-lite`, `kimi-k2.7-code-highspeed` (2x the `kimi-k2.7-code` baseline, following Kimi's Turbo convention), `glm-5-turbo`, `glm-5v-turbo`, and `qwen3.6-flash` had no row at all in the built-in pricing table, and the prefix fallback could not reach them either, so every request against them was recorded at zero cost.
|
||||
|
||||
Two more rows — the undated `claude-opus-4-6` and `claude-sonnet-4-6` — close a subtler gap: model ID resolution only **strips** a date suffix, it never **adds** one, so a log carrying an undated ID matched nothing. All eight rows are seeded insert-if-absent, so any price you edited is untouched.
|
||||
|
||||
### Grok Build Joins the Failover Tabs and Environment-Conflict Detection
|
||||
|
||||
The failover section in Settings gains a fourth Grok Build tab alongside Claude Code, Codex, and Gemini. The environment-conflict banner shown at startup also begins detecting `XAI_API_KEY` and `GROK_DEFAULT_MODEL` — two variables that silently override whichever provider you selected in the app. Detection distinguishes exact names from prefixes, so CC Switch's own `GROK_BIN_DIR` and `GROK_HOME` are not falsely reported.
|
||||
|
||||
---
|
||||
|
||||
## Changed
|
||||
|
||||
### DeepSeek and Volcengine Ark Coding Plan Connect to Codex Directly, With No Local Routing
|
||||
|
||||
Both presets were previously marked as OpenAI Chat format, which made both of them "needs takeover": the provider card carried the "needs routing" badge, switching without the proxy running raised a prompt, and every request travelled Codex → local proxy → Responses-to-Chat → upstream.
|
||||
|
||||
Both vendors' official Codex integration docs now confirm their endpoints serve the Responses API — DeepSeek's `api.deepseek.com` and Volcengine's `/api/coding/v3` — so both presets are declared native Responses, the badge and the prompt disappear, and Codex connects to the gateway directly. Neither vendor's generated `config.toml` changes (it was already `wire_api = "responses"`); what changes is the catalog generation profile, and for DeepSeek the context window, which is realigned from 1,000,000 to the vendor's own 1,048,576.
|
||||
|
||||
BytePlus's international site deliberately stays on Chat routing until its documentation is verified separately. The Volcengine preset also carries a billing note worth knowing: the pay-as-you-go `/api/v3` endpoint **must never** be added to this preset's backup addresses — it bills separately and does not draw down plan quota.
|
||||
|
||||
### Catalog Display Name and Context Window Are Now Explicit-Only
|
||||
|
||||
Both fields previously carried local defaults — the model ID and a 128,000-token window — applied before the vendor's value had a chance to participate, so a mirrored catalog's 1M window would have been overwritten with 128K. They are now optional, with the fallbacks moved down to entry construction, so "left blank" genuinely means "keep the value the vendor declared". Providers that set the two fields explicitly, and every non-mirrored profile, generate exactly the catalog they did before.
|
||||
|
||||
---
|
||||
|
||||
## Importing Through CC Switch vs. Running the Official Script
|
||||
|
||||
DeepSeek publishes a one-click Codex setup script. It works, it takes backups, and it comes with a restore menu. **If this machine is only ever going to use DeepSeek, running the official script is perfectly fine.** What CC Switch addresses is a different situation: you want to move back and forth between several providers.
|
||||
|
||||
### Switching Providers Swaps Login State and Configuration as a Set, With No Manual Backup
|
||||
|
||||
`~/.codex/auth.json` and `~/.codex/config.toml` are both **single-slot files** — Codex itself has no multi-credential storage, and one configuration can only describe one provider. When you switch away from a provider, CC Switch snapshots the contents of both files into that provider's record; when you switch back, it writes them back whole. So "ChatGPT subscription → DeepSeek → back to the subscription" normally does not require another `codex login`, and moving between third-party providers requires no manual step at all. Doing the same thing by hand means copying both files before and after every switch; miss it once, and the overwritten OAuth credentials can only be recovered by signing in again.
|
||||
|
||||
The official script makes a different trade-off: it rewrites `config.toml` into a DeepSeek-specific configuration — pinning `preferred_auth_method = "apikey"` and `forced_login_method = "api"` at the top level to fix authentication to API key, and **deleting any existing `[profiles.*]` from `config.toml`** (Codex's own built-in mechanism for switching between providers). Your ChatGPT credentials themselves are not deleted — `auth.json` is untouched — but they cannot be used under that configuration; returning to the subscription means running the script's restore menu for a whole-file rollback, and that rollback also discards any hand edits you made to `config.toml` after installation. The script itself can only switch between flash and pro; there is no "move to a third provider" option.
|
||||
|
||||
### After Switching Providers, Your Old Sessions Are Still in `codex resume`
|
||||
|
||||
Codex sorts its resume list into drawers by the `model_provider` recorded in each session. Every third-party Codex provider CC Switch creates — DeepSeek, Kimi, an aggregator, it makes no difference — writes the same identifier `custom`, so however you switch among them, `codex resume` keeps showing the full history. On its first launch, CC Switch also performs a one-time migration that folds known per-vendor buckets (including the `deepseek` one the official script writes) into this shared bucket, backing the original files up to `~/.cc-switch/backups/` first.
|
||||
|
||||
**There is a clear boundary here**: that migration runs once, on CC Switch's first launch. **If you install CC Switch first and only later run the official script**, those `deepseek`-tagged sessions will not be folded in — they stay in their own drawer. Separately, a provider identifier you wrote by hand that is not on the known list is deliberately left alone.
|
||||
|
||||
### Official-Subscription Sessions Are Already Interleaved With Third-Party Ones in CC Switch
|
||||
|
||||
CC Switch's **Sessions panel scans the session directory directly and does not read `model_provider`**, so Codex sessions produced during official-subscription use have always been in the same list as third-party ones — searchable, resumable, deletable — **with no toggle required**.
|
||||
|
||||
If you additionally want **Codex's own `codex resume` list** to merge official and third-party sessions, that is a separate matter: Settings → General → Codex App Enhancements → **"Unified Codex session history"**, off by default. Enabling it affects new sessions only; moving existing official sessions across as well requires ticking "Also migrate existing official session history" in the enable dialog (also unchecked by default). Both are pre-existing features, not new in this release; for the edge cases see the [Unified Codex Session History guide](../guides/codex-unified-session-history-guide-en.md).
|
||||
|
||||
> **Two shared prerequisites, stated up front so they do not confuse you later:**
|
||||
>
|
||||
> **First, everything above applies to the Codex directory CC Switch points at.** That is `~/.codex` by default and can be changed in Settings. **CC Switch does not read the `CODEX_HOME` environment variable** — if you use that variable to point Codex somewhere else, CC Switch cannot see those sessions, and provider switches will be written into a directory the CLI is not using. To change the directory, use CC Switch's own config-directory setting.
|
||||
>
|
||||
> **Second, appearing in the same list does not mean a session can be resumed.** Codex's reasoning content (`encrypted_content`) can only be decrypted by the backend that produced it, so continuing an old session on a different provider may fail — that is upstream's design, not something CC Switch can work around.
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### Claude Desktop Usage Was Counted Twice
|
||||
|
||||
Claude Desktop traffic through the local gateway landed twice in the usage dashboard — once as a proxy row and once as a session-import row — so its tokens, cost, and request counts were roughly doubled.
|
||||
|
||||
This is a regression introduced in v3.18.0: the proxy-side dedup ID carried a scope prefix for every app except `claude`, written as `session:{app}:{provider}:{message_id}`, which put `claude-desktop` in its own namespace; meanwhile the session importer kept writing the same Claude message in the bare `session:{message_id}` form with `app_type = 'claude'`. Three dedup defenses failed at once: the primary-key convergence that lets a proxy row absorb an existing session row, the write-side fingerprint probe, and the read-side filter — the latter two both comparing app type with strict equality.
|
||||
|
||||
The two apps now share the bare namespace again, and the two comparisons are widened by a one-way rule: a `claude` session row can be absorbed by a `claude-desktop` proxy row, **but not the reverse**. Because the read-side filter is exactly the one daily rollups aggregate through, duplicate rows already in the database stop being counted, **with no row rewritten or deleted** — this self-healing has a retention limit, see "Upgrade Notes". For Codex, Gemini, and OpenCode the widened comparison degenerates to the previous exact match, and quota checks still use strict matching. ([#5938](https://github.com/farion1231/cc-switch/issues/5938), [#5951](https://github.com/farion1231/cc-switch/pull/5951))
|
||||
|
||||
### Switching Back to the Official Codex Provider Left You Stuck on a 401 With No Login Screen
|
||||
|
||||
With the Codex API-key preservation toggle off (the default), switching to a third-party provider writes that vendor's key into `~/.codex/auth.json`. Switching afterwards to the built-in official provider — whose stored credentials are empty — took the config-only branch, so `config.toml` was replaced while the third-party `OPENAI_API_KEY` stayed on disk as it was. Codex then used that foreign key against the official endpoint and got a reliable 401; and because `auth.json` existed, it never fell back to its own login screen, leaving no way out from inside the app.
|
||||
|
||||
Now, after a successful switch to an official Codex provider, if `auth.json` contains only an `OPENAI_API_KEY` with no first-class credential beside it, the file is deleted — an OAuth token, a personal access token, an agent identity, or a Bedrock key all mark the file as genuine and leave it fully intact, while metadata such as `auth_mode`, `last_refresh`, or an account ID can no longer "shield" a stale key.
|
||||
|
||||
**Deleting the file rather than writing `{}`** is deliberate: an empty object is read by Codex as ChatGPT mode without tokens and errors at startup, whereas a missing file is equivalent to being signed out and goes straight to the login flow. The cleanup runs only after the previous provider has been successfully backfilled into the database, so the deleted key is not lost — it is stored in that provider's record and comes back when you select it again. The same change also relaxed live-config reads: the post-cleanup state (no `auth.json`, a `config.toml` present) is no longer reported as "Codex is not installed".
|
||||
|
||||
### Upgrading Grok Build From the Settings Page Reported Nothing but `os error 2`
|
||||
|
||||
Upgrading Grok Build under Settings → About failed with `Error: No such file or directory (os error 2)` and no further information.
|
||||
|
||||
The root cause is an asymmetry between the probe path and the execution path: probing goes through a login shell, which reads the user's rc files and therefore sees nvm, Homebrew, and Volta, while lifecycle scripts ran under a non-login shell inheriting the very narrow PATH a GUI-launched app starts with. That would normally not matter, because anchored commands invoke their target by absolute path — but grok 0.2.112 moved self-update onto npm distribution, so `grok update` internally invokes `npm view` and `npm i -g`, and npm in turn resolves node through its shebang. The inner call returned ENOENT, and grok surfaced it verbatim as that `os error 2`.
|
||||
|
||||
Lifecycle commands on macOS and Linux now merge the login shell's real PATH ahead of the inherited one, read by executing `/usr/bin/env` rather than echoing the variable — because fish stores PATH as a list, and echoing would yield space-separated segments. For a natively installed Grok, the upgrade chain additionally falls back to the official xAI installer, **deliberately not `npm i -g`**: npm shares both of the primary path's failure modes (no node, a mirror missing the package) and would fail alongside it; the official installer is the only route that does not depend on node, lands in the same place, and rewrites the CLI's own `installer` setting back to `internal`, incidentally healing users an earlier npm fallback had moved onto npm distribution.
|
||||
|
||||
### Grok Build Returned 404 With Takeover Enabled, and Every Request Looked Like a New Session
|
||||
|
||||
Enabling takeover on a Grok Build provider whose API format had been changed to OpenAI Chat or Anthropic produced an immediate 404, with no failover and no usage record — takeover rewrote the address and the key but left the backend field alone, so the CLI sent requests to a route the proxy does not register. Takeover now pins the backend to Responses as well; the per-provider downgrade to Chat Completions still happens in the forwarding layer, and the forced value is restored along with the whole live-config backup when the proxy stops.
|
||||
|
||||
The other problem was that the proxy's session detection recognized only Codex and OpenAI clients, so every Grok Build turn generated a new session ID marked "not client-provided", which suppressed both cache-key injection and per-session grouping in the dashboard. Grok's own headers are now read — the conversation ID first, then the session ID, ignoring the one that changes per request — under a separate prefix, so the records cannot collide with Codex's. ([#5677](https://github.com/farion1231/cc-switch/pull/5677))
|
||||
|
||||
### Nine Interface Strings Showed Simplified Chinese in Every Language
|
||||
|
||||
Nine strings displayed Simplified Chinese regardless of the interface language, in English and Japanese interfaces alike. Every call site used the "inline default value" form with a Chinese literal as the default, but the corresponding key was in **none** of the four locale files — and i18next walks the whole language chain before it considers an inline default, so the English fallback never got a chance and the Chinese literal won in every language.
|
||||
|
||||
The affected strings cover the Grok Build provider form's required-field validation, the failover hover tooltip shown when an app is not yet under takeover, the warning raised when stopping Claude Desktop routing while another app holds takeover along with its reason line, the provider-identifier read failure, the empty Codex common-config error, the routing service's stopped and stop-failed toasts, and the "unpriced" label the usage tables put on requests that carry tokens but compute to zero cost. All nine keys now exist in Simplified Chinese, English, Japanese, and Traditional Chinese. ([#5960](https://github.com/farion1231/cc-switch/pull/5960))
|
||||
|
||||
### The Traditional Chinese About Page's Tool Manager Fell Back to English
|
||||
|
||||
With the interface language set to Traditional Chinese, the tool management section of the About page displayed English — the version rows, the install and update buttons, the result toasts, the install-conflict diagnosis, and the entire upgrade confirmation dialog. That panel was built out across three separate changes, each adding strings for Simplified Chinese, English, and Japanese only; and because i18next's strategy is to fall back to English rather than error, the 30 missing keys were completely invisible in testing. **This gap had shipped in every release from v3.16.0 through v3.19.0.**
|
||||
|
||||
All 30 strings are now translated and the install hint is aligned with the other languages; a new locale test requires every tool-management string to exist in all four languages with matching interpolation variables, so this kind of drift will fail the test suite instead of shipping. ([#5943](https://github.com/farion1231/cc-switch/pull/5943))
|
||||
|
||||
### Built-In Pricing Had Drifted From Vendor List Prices
|
||||
|
||||
Cost is frozen against the built-in pricing table at the moment a request is logged, so a stale seeded price silently miscounts every subsequent request. Four rows are corrected in this release: `deepseek-chat` and `deepseek-reasoner` are now legacy aliases of V4 Flash at $0.14 input / $0.28 output per million tokens with $0.0028 cache read (previously $0.27/$1.10 and $0.55/$2.19); `minimax-m3` is halved to $0.30/$1.20 per the official standard tier; `gpt-5.6-luna` drops 80% to $0.20/$1.20 and `gpt-5.6-terra` drops 20% to $2/$12 following OpenAI's 2026-07-30 price cut, with `gpt-5.6-sol` deliberately unchanged and the family's cache-write ratio preserved.
|
||||
|
||||
The repair only rewrites a row when all four of its price columns **still equal the previous built-in values exactly**, so a price you edited yourself — or one written by models.dev sync — is never touched.
|
||||
|
||||
---
|
||||
|
||||
## Security Hardening
|
||||
|
||||
### Deep-Link Import Confirmations: Stricter Masking, Less Truncation
|
||||
|
||||
This continues the `ccswitch://` confirmation hardening from v3.19.0. Config previews are now built by a single shared module that recursively masks secrets inside nested TOML tables and JSON objects, fixing two defects that pointed in opposite directions at once: a Grok Build import **rendered no config preview at all**, while a Codex import **printed embedded `api_key` values in the clear**.
|
||||
|
||||
The 300-character truncation on the config preview is gone; the full content now renders in a scrollable box — closing the last place the confirmation could hide part of what it was about to write. The masking itself is stricter everywhere it is used, **including the MCP import confirmation**: sensitive-name matching adds `AUTHORIZATION`, `COOKIE`, and `CREDENTIAL`, plus exact matches for `AUTH` and `BEARER`; the plaintext prefix shown after masking shrinks from 8 characters to 4; and values of 8 characters or fewer are now replaced entirely rather than displayed as they are.
|
||||
|
||||
Finally, the frontend's Base64 decoder no longer trims leading and trailing whitespace — which may well be a `+` that URL decoding turned into a space. This is the same class of frontend/backend decoder divergence fixed in v3.19.0: what the confirmation displays is one thing, what the importer writes is another.
|
||||
|
||||
---
|
||||
|
||||
## Internal
|
||||
|
||||
### 3,166 Lines With No Caller, 14 Modules, and 4 Dependencies Removed
|
||||
|
||||
A pass over code with no remaining caller. On the backend it deletes the provider icon inference table, a placeholder health checker, a never-wired SSE implementation (with its own streaming and non-streaming handlers), two unused proxy session types, four unreferenced usage parsers, and a dead cost-calculation entry point — **the live billing path, its auto-detecting parsers, and session ID extraction are all untouched**. The 22 `#[allow(dead_code)]` suppressions that had kept the compiler quiet about all of it go too.
|
||||
|
||||
On the frontend, 14 modules with no importer are deleted, including a prompt form modal and a repository manager both superseded by panel rewrites, a duplicate proxy config hook, a circuit-breaker panel that never had an importer in the project's history, and three schema files; their strings are removed from all four languages in step. The Tauri commands behind these modules are deliberately kept. Four unused npm dependencies are also dropped. Two scripts that regenerated the hand-curated icon index are removed, and the index file's header now states that automatic regeneration is intentionally unsupported.
|
||||
|
||||
A companion change consolidates proxy state and takeover state onto a single query layer — there had been a second parallel set of hooks over the same commands with zero callers, whose query keys never had an observer, so every invalidation aimed at them was a no-op. The query key strings are unchanged character for character, and the surviving hook keeps its existing polling behavior. ([#5916](https://github.com/farion1231/cc-switch/pull/5916), [#5928](https://github.com/farion1231/cc-switch/pull/5928))
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
### No Database Migration in This Release
|
||||
|
||||
v3.19.1 contains no schema migration (the version stays at v16), so no pre-upgrade backup is triggered and the upgrade is ready to use immediately.
|
||||
|
||||
### The Claude Desktop Double-Count Self-Heal Has a 30-Day Window (Please Read)
|
||||
|
||||
The fix suppresses duplicate rows at query time rather than rewriting or deleting data, so **every day whose detail rows are still present returns to the correct total on the next launch**, with no rebuild needed.
|
||||
|
||||
But detail rows older than 30 days are aggregated into daily rollups and pruned, and a rollup is computed once, under whatever rules were in effect at aggregation time. **Any day already aggregated by a build without this fix keeps its inflated number permanently.** The regression entered with v3.18.0 (2026-07-21), so the sooner you upgrade, the more of the historical range is recovered.
|
||||
|
||||
### The New Pricing Affects Historical Data in Two Different Ways
|
||||
|
||||
The eight newly priced models are **recalculated retroactively**: at startup, requests recorded with zero cost have their cost filled in, so the dashboard numbers for those models will **go up**. Detail rows that were already aggregated and pruned cannot be recalculated and stay at zero.
|
||||
|
||||
The four repriced models work in the opposite direction: the backfill only touches zero-cost rows, so requests already recorded keep the old price and only new requests bill at the new one — historical and new spend for the same model will not match. Both paths protect your own pricing: the repair only changes rows still at the original built-in value, while manual edits, models.dev sync values, and deletion tombstones in `~/.cc-switch/model-pricing.json` are replayed after seeding and repair and always win.
|
||||
|
||||
### Preset Changes Only Affect Newly Created Providers
|
||||
|
||||
An already saved DeepSeek or Volcengine Ark Coding Plan provider keeps the API format it stored, still needs local routing, and still uses the old catalog. To use the direct connection, re-create the provider from the preset, or change the API format to native Responses in the provider form's advanced section.
|
||||
|
||||
That said, **a provider already on native Responses whose address is on `deepseek.com` picks up the mirrored official catalog on its next switch**, with no re-save required — because the check reads the live configuration.
|
||||
|
||||
### DeepSeek V4 Pro Cannot Use the Direct Connection Yet
|
||||
|
||||
The preset still lists `deepseek-v4-pro`, and the vendor's own published catalog carries it too, but **DeepSeek has not opened its Codex integration for pro**; their stated timing is early August 2026. Until then, selecting pro in direct-connection mode fails upstream — use `deepseek-v4-flash`, which is also the preset's default model.
|
||||
|
||||
If you need pro right now, change that provider's API format back to "OpenAI Chat" and enable local routing takeover. This is exactly the path DeepSeek used before v3.19.1: the local proxy converts the Responses requests Codex sends into Chat Completions, and pro is unaffected on that route.
|
||||
|
||||
### Two Prerequisites for the Official DeepSeek Catalog
|
||||
|
||||
The mirrored catalog declares a minimum Codex client version of 0.144.0, and **CC Switch does not verify this itself** — the freeform `apply_patch` registration it carries requires that version or newer. Separately, the generated catalog file grows to roughly 75 KB (for the two mirrored models), because each entry carries the full harness text.
|
||||
|
||||
### After Going Direct, Usage Attribution Moves From the Provider Name to `Codex (Session)`
|
||||
|
||||
DeepSeek, Volcengine Ark Coding Plan, and Tencent Hunyuan no longer need takeover, so their traffic can bypass the local proxy entirely and the proxy's per-request records no longer see them.
|
||||
|
||||
**The usage itself is neither lost nor indistinguishable** — Codex's session-log import records it as before, only that path does not carry provider identity: all Codex usage that did not go through the local proxy is grouped under an entry named `Codex (Session)`, official-subscription consumption included. In other words, once DeepSeek moves from routed to direct, its usage moves out from under the name "DeepSeek" and into `Codex (Session)`.
|
||||
|
||||
**To tell them apart, look at the model**: every usage record carries its own model ID, and the usage panel's per-model statistics list them row by row — `deepseek-v4-flash`, `hy3`, `ark-code-latest`, and the official subscription's GPT models each get their own row, with separate cost and token figures. Only when the dimension you actually need is **per provider** (comparing the same model across several aggregators, for instance) do you need to keep using local routing takeover — that route records the real provider name.
|
||||
|
||||
### Two Prerequisites for the Stale Codex Credential Cleanup
|
||||
|
||||
The cleanup runs only when the incoming provider carries the explicit official category **and** the outgoing provider was backfilled successfully. An entry created by hand without the official category, or a switch whose backfill failed, still leaves the residue on disk.
|
||||
|
||||
### Enabling Takeover on Grok Build Rewrites the Backend Field
|
||||
|
||||
Enabling takeover on a Grok Build provider now rewrites the backend field in the live configuration to Responses. The provider record stored in the database is unaffected, and the live file is restored in full from its backup when the proxy stops.
|
||||
|
||||
### PATH Changes for Tool Installs and Upgrades (macOS / Linux Only)
|
||||
|
||||
Every tool install and upgrade triggered from Settings → About now merges the login shell's PATH ahead of the inherited one, so a program a lifecycle script resolves by name may resolve differently than before; each action also starts one extra shell to read that PATH, which executes your interactive startup files. **Windows is unaffected.**
|
||||
|
||||
Users on grok 0.2.112 or later may see two installation records — the native one, plus the global npm package `grok update` created itself; upstream keeps them in sync and they report the same version.
|
||||
|
||||
### Environment-Conflict Detection Now Matches Differently
|
||||
|
||||
Detection for Claude Code, Codex, and Gemini has been tightened from "contains" to "prefix", so variables that merely contain an app name — `MY_ANTHROPIC_API_KEY`, `OLD_GEMINI_API_KEY` — are **no longer reported as conflicts**. Detection for Grok Build was added at the same time.
|
||||
|
||||
### Deep-Link Import Confirmations Show Less of Each Secret
|
||||
|
||||
The plaintext prefix shown after masking shrinks from 8 characters to 4, and values of 8 characters or fewer are masked entirely. This affects the MCP import confirmation as well.
|
||||
|
||||
---
|
||||
|
||||
## Risk Notice
|
||||
|
||||
### 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.
|
||||
|
||||
**SuperGrok quota queries**: the quota display on provider cards depends on a non-public billing endpoint at grok.com and may stop working once xAI changes the interface — see the [v3.19.0 release notes](v3.19.0-en.md#risk-notice) for details.
|
||||
|
||||
**Third-party provider routing**: when the CC Switch local proxy converts and forwards Codex, Claude Desktop, or Grok Build requests to a third-party provider, each provider has different constraints on billing, compliance, and data retention. Please read the target provider's terms of service before use.
|
||||
|
||||
By enabling these features, users accept the associated risks. CC Switch is not responsible for any account restriction, warning, or service suspension resulting from their use.
|
||||
|
||||
---
|
||||
|
||||
## Thanks
|
||||
|
||||
Most of the fixes in this release came from outside contributors — five of the six PRs are not mine.
|
||||
|
||||
### Code Contributions
|
||||
|
||||
- [#5677](https://github.com/farion1231/cc-switch/pull/5677): finishing off Grok Build's proxy takeover and deep-link integration — the backend field, session identity, the failover tab, and environment-variable detection, plus a fix for the credential leak in config previews along the way. Thanks to @YUZHEthefool. This is the broadest single piece of work in the release.
|
||||
- [#5951](https://github.com/farion1231/cc-switch/pull/5951): the Claude Desktop double-count fix. Thanks to @Komikawayi. Pinpointing which change in v3.18.0 made all three dedup defenses fail at once was the most patient piece of investigation in this release.
|
||||
- [#5916](https://github.com/farion1231/cc-switch/pull/5916), [#5928](https://github.com/farion1231/cc-switch/pull/5928): removing 3,166 lines of code with no callers and the duplicate proxy query layer. Thanks to @SaladDay.
|
||||
- [#5943](https://github.com/farion1231/cc-switch/pull/5943): completing the Traditional Chinese tool-management strings and adding a test that prevents locale drift. Thanks to @yovinchen.
|
||||
- [#5960](https://github.com/farion1231/cc-switch/pull/5960): completing the 9 strings that showed Simplified Chinese in every language. Thanks to @mhy1227.
|
||||
|
||||
### Issue Reports
|
||||
|
||||
Thanks to @Alaric-L for reporting in [#5938](https://github.com/farion1231/cc-switch/issues/5938) that every Claude Desktop request produced an extra log row sourced from `session_log`, causing tokens to be counted twice — that report pinpointed the data source, and the most important usage fix in this release was located directly from it.
|
||||
|
||||
---
|
||||
|
||||
## 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) (downloads are distributed through Cloudflare edge nodes and do not 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.1-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||
| `CC-Switch-v3.19.1-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.1-macOS.dmg` | **Recommended** - DMG installer, drag to Applications |
|
||||
| `CC-Switch-v3.19.1-macOS.zip` | Unzip and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.19.1-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.1-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.19.1-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,369 @@
|
||||
# CC Switch v3.19.1
|
||||
|
||||
> 本リリースの主軸は**前バージョンの積み残しを片付けること**です。中国系 Codex ゲートウェイ 3 社が Responses API のネイティブ対応を確認できたため、**ローカルルーティングの引き継ぎが不要**になりました——DeepSeek と火山方舟 Coding Plan のプリセットはローカルルーティング経由から直接接続へ変更、新規追加の Tencent Hunyuan TokenHub は最初から直接接続です。日常的に遭遇しうる 4 つの不具合も修正しました——**Claude Desktop の使用量が v3.18.0 以降 2 重に計上されていた問題**(アップグレード後に過去の数値も自動で正常化しますが 30 日の期限があります。「アップグレード時の注意」を参照)、公式 Codex プロバイダに戻すと 401 のままログイン画面も表示されなくなる問題、設定ページからの Grok Build アップグレードが `os error 2` の一言で失敗する問題、Grok Build で引き継ぎを有効にすると 404 になる問題です。さらに、これまで $0 で計上されていた 8 モデルに組み込み価格を追加し、UI 文言 39 件の言語の問題を修正しました。本リリースに**データベースマイグレーションはなく**、また本プロジェクト史上初めて削除行数が追加行数を上回ったリリースでもあります。
|
||||
|
||||
**[English →](v3.19.1-en.md) | [中文版 →](v3.19.1-zh.md)**
|
||||
|
||||
---
|
||||
|
||||
## ハイライト:本リリースでできること
|
||||
|
||||
- **DeepSeek・火山方舟 Coding Plan・Tencent Hunyuan を Codex から直接接続する**:3 社の公式 Codex ドキュメントで、エンドポイントが Responses API をネイティブ提供していることが確認できました。DeepSeek と火山方舟 Coding Plan の既存プリセットは Chat 形式からネイティブ形式に変わり、プロバイダカードの「ルーティングが必要」バッジと切り替え時の確認メッセージがなくなり、リクエストはローカルプロキシのプロトコル変換を通らなくなります。Tencent Hunyuan TokenHub は本リリースで追加されたプリセットで、最初からネイティブ形式です。**DeepSeek V4 Pro はまだ直接接続で使えない点にご注意ください**——ベンダー側で Codex 連携がまだ開放されていません。直接接続では V4 Flash(プリセットの既定モデル)をご利用ください。詳細は[アップグレード時の注意](#deepseek-v4-pro-はまだ直接接続で使えません)を参照してください。
|
||||
- **DeepSeek に DeepSeek 自身が公開したモデルカタログを使わせる**:新しい「公式ベンダーカタログのミラーリング」機構は、ベンダーが公開している `models.json` をそのまま当該ベンダーのエンドポイント向けに配信します。これにより freeform な `apply_patch` の登録と、対になる GPT-5 ハーネスがセットのまま保持され、中立テンプレートに押し込められることがなくなります。判定はホスト名のみで行い、モデル名では行いません——同じモデルでも、再販するアグリゲータが同じ機能を実装しているとは限らないためです。
|
||||
- **Claude Desktop の正しい使用量を得る**:v3.18.0 以降、ローカルゲートウェイ経由の Claude Desktop のトラフィックはダッシュボードに 2 回記録されていました——1 回はプロキシから、もう 1 回はセッションログの取り込みから——token・費用・リクエスト数がおよそ 2 倍になっていました。本リリースの修正後、明細行が残っている日は自動的に正しい数値に戻ります。**再構築の操作は不要です。**
|
||||
- **公式 Codex プロバイダに戻した後に正常にログインする**:これまでサードパーティのプロバイダから組み込みの公式 Codex エントリに戻すと、サードパーティのキーが `~/.codex/auth.json` に残っていました。Codex はそのキーで公式エンドポイントにリクエストし、確実に 401 になります——しかもファイルが存在するため Codex 自身のログイン画面にも戻らず、アプリ内から抜け出す手段がありませんでした。
|
||||
- **設定ページから Grok Build をアップグレードする**:`grok update` は 0.2.112 以降、内部で npm を呼び出して配布を行うようになりましたが、GUI から起動したアプリからは node が見えないため、アップグレードは `Error: No such file or directory (os error 2)` の一言で失敗していました。
|
||||
- **Grok Build で 404 にならずに引き継ぎを有効にする**:API 形式を手動で OpenAI Chat または Anthropic に変更した Grok Build プロバイダで引き継ぎを有効にすると、プロキシが登録していないルートにリクエストが送られ、そのまま 404 になっていました。フェイルオーバーも使用量の記録もありません。さらに Grok Build のリクエストはこれまで毎回新しいセッションとして扱われ、キャッシュキーの注入とセッション単位の集計がどちらも効かなくなっていました。
|
||||
- **これまで $0 で計上されていた 8 モデルの実際のコストを見る**:`gpt-5.3-codex-spark`、`gemini-3.5-flash-lite`、`kimi-k2.7-code-highspeed`、`glm-5-turbo`、`glm-5v-turbo`、`qwen3.6-flash`、および日付サフィックスのない `claude-opus-4-6` / `claude-sonnet-4-6` です。
|
||||
- **繁体字中国語の UI で「バージョン情報」ページのツール管理を読む**:30 件の文言が簡体字中国語・英語・日本語にしか追加されておらず、繁体字中国語が漏れていました。i18next は黙って英語にフォールバックするため、このパネルは v3.16.0 以降ずっと半分英語のままでした。さらに 9 件の文言は**すべての言語で**簡体字中国語が表示されていました。
|
||||
- **公式サブスクリプションと DeepSeek を二者択一ではなく行き来する**:`auth.json` と `config.toml` はどちらもスロットが 1 つしかないファイルで、Codex 自身は 2 組目の認証情報を保持できません。ベンダーのワンクリックスクリプトはこの設定を自分専用に書き換えますが、CC Switch はプロバイダごとにまとめてスナップショットして復元します——これが公式スクリプトとの最も実際的な違いです。詳細は[後述の比較](#cc-switch-経由の導入と公式スクリプトの違い)を参照してください。
|
||||
|
||||
---
|
||||
|
||||
## 利用ガイド
|
||||
|
||||
本リリースの変更は Codex の接続方式と使用量の集計基準に集中しています。以下のドキュメントとあわせてお読みください:
|
||||
|
||||
- **[ローカルルーティング](../user-manual/ja/4-proxy/4.2-routing.md)**:どのプロバイダで引き継ぎが必要か、引き継ぎが何をするか。本リリース以降、DeepSeek・火山方舟 Coding Plan・Tencent Hunyuan には不要になります。
|
||||
- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**:使用量ダッシュボードのデータソースと集計基準。Claude Desktop の 2 重計上がどう起きたか、修正後もなぜ一部の過去日を戻せないかを理解する助けになります。
|
||||
- **[Codex で DeepSeek のような Chat 形式 API を使う](../guides/codex-deepseek-routing-guide-ja.md)**:このガイドはローカルルーティングが Responses を Chat Completions に変換する仕組みを説明したものです。仕組みの部分は Kimi・MiniMax・SiliconFlow など Chat 形式のままのプロバイダには引き続き当てはまりますが、**DeepSeek を例として扱っている部分は本リリースには当てはまりません**——DeepSeek は直接接続になり、ルーティングは不要です。
|
||||
|
||||
---
|
||||
|
||||
> [!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.1 はメンテナンスリリースで、軸は 3 つあります。1 つ目は中国系 Codex ゲートウェイがまとめてネイティブ Responses に移行したことです。DeepSeek は `api.deepseek.com` に直接接続し、あわせて再利用可能な機構をもたらしました——ベンダー自身が公開するモデルカタログをそのままミラーリングして配信し、freeform な `apply_patch` の登録と対になる GPT-5 ハーネスが中立テンプレートに畳み込まれず、整合したまま保たれます。火山方舟の Coding Plan エンドポイント `/api/coding/v3` も公式ドキュメントでの確認を経て追随し、Tencent Hunyuan の TokenHub が新しいプリセットとして加わりました。3 つとも、もうローカルルーティングの引き継ぎを有効にする必要はありません。
|
||||
|
||||
2 つ目は実際に遭遇しうる 4 つの不具合の修正です。Claude Desktop の使用量が v3.18.0 以降 2 回記録されていた問題([#5938](https://github.com/farion1231/cc-switch/issues/5938))、組み込みの公式 Codex プロバイダに戻すとサードパーティの `auth.json` が残り、401 になったうえログイン画面も出なくなる問題、`grok update` が GUI 環境では `os error 2` としか報告しない問題、そして Grok Build のプロキシ引き継ぎが Responses 以外のバックエンドで 404 になり、かつ毎回のリクエストが新規セッション扱いになっていた問題([#5677](https://github.com/farion1231/cc-switch/pull/5677))です。3 つ目は減量です。呼び出し元が 1 つもないコード 3,166 行と、未使用の npm 依存 4 件を削除しました——本プロジェクト史上初めて、削除行数が追加行数を上回ったリリースです。このほか、ディープリンクのインポート確認ダイアログはマスクをより厳しく、切り詰めをより少なくし、$0 で計上されていた 8 モデルに価格を追加し、組み込み価格 4 件をベンダーの定価に合わせ直しました。本リリースに**データベーススキーマのマイグレーションはなく**(バージョンは v16 のまま)、アップグレードは軽量です。
|
||||
|
||||
**リリース日**:2026-07-31
|
||||
|
||||
**変更規模**:12 commits | 71 files changed | +2,324 / -3,680 lines
|
||||
|
||||
---
|
||||
|
||||
## 追加機能
|
||||
|
||||
### 公式ベンダーモデルカタログのミラーリング(DeepSeek が第 1 号)
|
||||
|
||||
Codex はカタログファイルからモデルの機能を読み取りますが、CC Switch はこれまでどのプロバイダに対しても中立テンプレートからカタログを生成していました。アグリゲータにはこれが正しいものの、ベンダー自身の連携が前提としている機能が落ちてしまいます。本リリースからは、公式カタログをアプリに同梱しているベンダーについては、そのベンダー自身のファイルをそのまま配信します。
|
||||
|
||||
DeepSeek が第 1 号です。同梱ファイルは `deepseek-v4-flash` と `deepseek-v4-pro` の 2 エントリを持ち、`apply_patch_tool_type: "freeform"`、`web_search_tool_type: "text"`、`supports_search_tool: true`、low / high / max の 3 段階の推論レベル、そして `base_instructions` と `model_messages` に入っている 17,644 文字の GPT-5 ハーネスを保持します——**このハーネスは freeform なツール登録とセットで扱う必要があります**。ハーネス自体がモデルに `apply_patch` の使用を指示しているため、どちらか一方だけを取り出すと整合しなくなるからです。
|
||||
|
||||
判定条件は意図的に狭くしてあります。プロバイダがネイティブ Responses の区分に該当し、**かつ** `base_url` が `deepseek.com` 上にあることが条件です。**判定はホスト名で行い、モデルのブランド名では行いません**——同じモデルを再販するアグリゲータが同じ機能を実装しているとは限らず、ブランド名で付与することは、実装していないサービスに機能を与えることになるからです。プロバイダが自身のカタログで明示した項目は引き続き優先されます。未知のモデル ID の場合はフラッグシップのエントリを複製したうえで、そのモデル自身の名称を保ちます。他のすべての区分で生成されるカタログは、変更前とバイト単位で同一です。
|
||||
|
||||
### Tencent Hunyuan(TokenHub)Codex プリセット
|
||||
|
||||
Codex のプリセット選択に「Tencent Hunyuan」が追加され、「オープンソース公式」カテゴリの Bailian と StepFun の間に入ります。選択すると `https://tokenhub.tencentmaas.com/v1`、`wire_api = "responses"`、および TokenHub が必須としている `disable_response_storage = true` が書き込まれます。モデルは `hy3` と `hy3-preview` の 2 つを宣言し、コンテキストウィンドウは **256K**(Codex の既定である 128K を受け入れるのではなく)、さらにテキスト専用として印を付けます——Codex が `view_image` の画像ペイロードを、画像を読めないモデルに送ることはなくなります。
|
||||
|
||||
ネイティブ Responses のプロバイダであるため、Codex はローカルルーティングなしでゲートウェイに直接接続します。生成されるカタログは中立のネイティブテンプレートを使い、`shell_type = "shell_command"` を固定し、ネイティブゲートウェイが受け付けない freeform な `apply_patch` の登録を外します。アドレス管理と速度計測には最初から 2 つの候補があります——メインドメインと、公式のバックアップである `.cn` ドメインです。地域が独立している国際サイトは、API キーがサイト間で共通ではないため意図的に除外しています。
|
||||
|
||||
**API キーは Hy3 の権限が有効な TokenHub のキーである必要がある**点にご注意ください。Coding Plan や Token Plan のサブスクリプションキーは、このエンドポイントでは利用できません。
|
||||
|
||||
### $0 で計上されていた 8 モデルに組み込み価格を追加
|
||||
|
||||
`gpt-5.3-codex-spark`、`gemini-3.5-flash-lite`、`kimi-k2.7-code-highspeed`(Kimi の Turbo の慣例に従い `kimi-k2.7-code` の基準価格の 2 倍)、`glm-5-turbo`、`glm-5v-turbo`、`qwen3.6-flash` は組み込み価格テーブルに行そのものが存在せず、プレフィックスによるフォールバックでも届かなかったため、これらへのリクエストはすべてコスト 0 として記録されていました。
|
||||
|
||||
さらに 2 行——日付サフィックスのない `claude-opus-4-6` と `claude-sonnet-4-6`——は、より分かりにくい隙間を塞ぐものです。モデル ID の解決は日付サフィックスを**取り除く**ことしかせず、**付け足す**ことはないため、日付なしの ID を持つログはどれにも一致しませんでした。8 行はいずれも「存在しない場合のみ挿入」で投入されるため、変更済みの価格は影響を受けません。
|
||||
|
||||
### Grok Build がフェイルオーバータブと環境変数の競合検出に加わりました
|
||||
|
||||
設定ページのフェイルオーバーに、Claude Code・Codex・Gemini に続く 4 つ目の Grok Build タブが追加されました。起動時の環境変数競合バナーも `XAI_API_KEY` と `GROK_DEFAULT_MODEL` の検出を開始します——この 2 つはアプリで選んだプロバイダを黙って上書きしてしまう変数です。検出は完全一致とプレフィックスを区別するため、CC Switch 自身が使う `GROK_BIN_DIR` や `GROK_HOME` が誤検出されることはありません。
|
||||
|
||||
---
|
||||
|
||||
## 変更
|
||||
|
||||
### DeepSeek と火山方舟 Coding Plan が Codex に直接接続、ローカルルーティング不要に
|
||||
|
||||
両プリセットはこれまで OpenAI Chat 形式として扱われていたため、どちらも「引き継ぎが必要」でした。プロバイダカードには「ルーティングが必要」バッジが付き、プロキシを起動せずに切り替えると確認が出て、リクエストは毎回 Codex → ローカルプロキシ → Responses から Chat への変換 → 上流、という経路をたどっていました。
|
||||
|
||||
両社の公式 Codex 連携ドキュメントで、エンドポイントが Responses API を提供していることが確認できました——DeepSeek の `api.deepseek.com` と火山方舟の `/api/coding/v3` です。これに伴い両プリセットはネイティブ Responses として宣言され、バッジと確認メッセージはなくなり、Codex はゲートウェイに直接接続します。両社とも生成される `config.toml` に変化はありません(もともと `wire_api = "responses"` でした)。変わるのはカタログの生成区分と、DeepSeek についてはコンテキストウィンドウで、1,000,000 からベンダー自身の 1,048,576 に合わせ直しました。
|
||||
|
||||
BytePlus の国際サイトは、ドキュメントを個別に確認するまで意図的に Chat ルーティングのままとします。火山方舟のプリセットには、知っておく価値のある課金上の注記も残しました。従量課金の `/api/v3` エンドポイントは、このプリセットのバックアップアドレスに**絶対に追加してはいけません**——別勘定で課金され、プランの残量を消費しないためです。
|
||||
|
||||
### カタログの表示名とコンテキストウィンドウが「明示した場合のみ有効」に
|
||||
|
||||
この 2 つのフィールドには、これまでローカルの既定値——モデル ID と 128,000 のウィンドウ——が付いており、しかもベンダーの値が関与する前に適用されていたため、ミラーリングしたカタログの 1M のウィンドウが 128K に上書きされてしまう状態でした。現在この 2 つは省略可能になり、フォールバックはエントリ構築の層まで下ろされたため、「空のままにする」ことが本当に「ベンダーが宣言した値を使う」を意味するようになりました。この 2 つを明示したプロバイダ、およびミラーリング以外のすべての区分では、生成されるカタログは以前とまったく同じです。
|
||||
|
||||
---
|
||||
|
||||
## CC Switch 経由の導入と公式スクリプトの違い
|
||||
|
||||
DeepSeek は Codex のワンクリック導入スクリプトを公開しています。これはきちんと動き、バックアップも取り、復元メニューも備えています。**このマシンで DeepSeek だけを使うつもりなら、公式スクリプトを実行して何の問題もありません。** CC Switch が扱うのは別の場面です——複数のプロバイダを行き来したい場合です。
|
||||
|
||||
### プロバイダを切り替えるとき、ログイン状態と設定がまとめて入れ替わる(自分でバックアップする必要はありません)
|
||||
|
||||
`~/.codex/auth.json` と `~/.codex/config.toml` はどちらも**スロットが 1 つしかないファイル**です——Codex 自身に複数の認証情報を保持する仕組みはなく、1 つの設定は 1 つのプロバイダにしか対応できません。CC Switch はあるプロバイダから切り替えるとき、この 2 ファイルの内容をまとめてそのプロバイダのレコードにスナップショットし、戻すときにまとめて書き戻します。そのため「ChatGPT サブスクリプション → DeepSeek → サブスクリプションに戻る」で通常 `codex login` をやり直す必要はなく、サードパーティ間の行き来には手作業がまったく不要です。同じことを手作業で行う場合は、切り替えのたびに前後でこの 2 ファイルをコピーする必要があり、一度でも漏らせば、上書きされた OAuth の認証情報はログインし直す以外に取り戻せません。
|
||||
|
||||
公式スクリプトのトレードオフは異なります。`config.toml` を DeepSeek 専用の設定に作り替え——トップレベルに `preferred_auth_method = "apikey"` と `forced_login_method = "api"` を固定して認証方式を API キーに固定し、さらに **`config.toml` にすでにある `[profiles.*]` を削除します**(Codex 自身が持つ、複数プロバイダを切り替える仕組みです)。ChatGPT のログイン認証情報そのものは削除されず、`auth.json` はそのまま残りますが、その設定のもとでは使えません。サブスクリプションに戻すにはスクリプトの復元メニューで全体をロールバックする必要があり、そのロールバックではインストール後に `config.toml` へ加えた手動の変更もあわせて失われます。スクリプト自体も flash と pro の切り替えしかできず、「3 つ目のプロバイダに移る」という選択肢はありません。
|
||||
|
||||
### プロバイダを切り替えた後も、`codex resume` に以前のセッションが残る
|
||||
|
||||
Codex は各セッションに記録された `model_provider` によって、再開一覧を引き出しのように分けています。CC Switch が作成するサードパーティの Codex プロバイダは——DeepSeek でも Kimi でもアグリゲータでも——すべて同じ識別子 `custom` を書き込むため、それらの間をどう切り替えても `codex resume` からは常に全履歴が見えます。CC Switch は初回起動時に一度だけ移行処理も行い、ベンダーごとに分かれていた既知の古いセッション(公式スクリプトが書き込む `deepseek` もこれに含まれます)をこの共有の引き出しへまとめます。その際、元のファイルは先に `~/.cc-switch/backups/` へバックアップされます。
|
||||
|
||||
**ここには明確な境界があります**。この移行は CC Switch の初回起動時に一度だけ実行されます。**先に CC Switch をインストールし、その後で公式スクリプトを実行した場合**、`deepseek` の識別子を持つそれらのセッションはもうまとめられず、自分の引き出しに残ったままになります。また、手書きで指定した、既知の一覧にないプロバイダ識別子は、CC Switch が意図的に変更しません。
|
||||
|
||||
### 公式サブスクリプションのセッションは、CC Switch ではもともとサードパーティと同じ一覧に並んでいます
|
||||
|
||||
CC Switch の**セッション管理パネルはセッションディレクトリを直接スキャンし、`model_provider` を読みません**。そのため公式サブスクリプションの利用中に生まれた Codex セッションは、以前からサードパーティのセッションと同じ一覧にあり、検索・再開・削除ができます——**どのスイッチも有効にする必要はありません**。
|
||||
|
||||
さらに **Codex 自身の `codex resume` の一覧**でも公式とサードパーティをまとめたい場合は、それは別の話になります。設定 → 一般 → Codex アプリ拡張 → **「Codex セッション履歴を統一」**で、既定はオフです。有効にすると新しいセッションのみが対象になります。既存の公式セッションもあわせて移すには、有効化の確認ダイアログで「既存の公式セッション履歴もあわせて移行する」にチェックを入れる必要があります(こちらも既定ではチェックなしです)。この 2 つはどちらも既存機能で、本リリースでの新規追加ではありません。境界となるケースについては[「Codex セッション履歴の統一」ガイド](../guides/codex-unified-session-history-guide-ja.md)を参照してください。
|
||||
|
||||
> **共通の前提が 2 つあります。後で戸惑わないよう先に明記します:**
|
||||
>
|
||||
> **1. すべては CC Switch が参照している Codex ディレクトリが基準です。** 既定は `~/.codex` で、設定から変更できます。**CC Switch は `CODEX_HOME` 環境変数を読みません**——この変数で Codex を別の場所に向けている場合、そちらのセッションは CC Switch からは見えず、プロバイダの切り替えも CLI が使っていないディレクトリに書き込まれます。ディレクトリを変更するときは、CC Switch 自身の「設定ファイルのディレクトリ」設定をお使いください。
|
||||
>
|
||||
> **2. 同じ一覧に並ぶことは、必ず再開できることを意味しません。** Codex の推論内容(`encrypted_content`)は、それを生成したバックエンドでしか復号できないため、別のプロバイダで以前のセッションを続けようとすると失敗することがあります——これは上流の設計であり、CC Switch が回避できるものではありません。
|
||||
|
||||
---
|
||||
|
||||
## 修正
|
||||
|
||||
### Claude Desktop の使用量が 2 回計上されていた問題
|
||||
|
||||
ローカルゲートウェイ経由の Claude Desktop のトラフィックは、使用量ダッシュボードに 2 回入っていました——1 回はプロキシの行、もう 1 回はセッション記録の取り込みの行です——その結果、token・費用・リクエスト数がおよそ 2 倍になっていました。
|
||||
|
||||
これは v3.18.0 で入った回帰です。プロキシ側の重複排除 ID は `claude` 以外のすべてのアプリにスコープのプレフィックスを付け、`session:{アプリ}:{プロバイダ}:{メッセージID}` の形にしていました。これにより `claude-desktop` が独立した名前空間に入る一方、セッションの取り込み側は同じ Claude のメッセージを、素の `session:{メッセージID}` の形かつ `app_type = 'claude'` で書き続けていました。その結果、3 つの重複排除の防御が同時に破られました——プロキシの行が既存のセッション行を吸収するための主キーの収束、書き込み側のフィンガープリント照合、読み取り側のフィルタです。後の 2 つはどちらもアプリ種別を厳密な等価比較で判定していました。
|
||||
|
||||
現在は 2 つのアプリが再び素の名前空間を共有し、2 か所の比較は一方向の規則で緩和されています。`claude` のセッション行は `claude-desktop` のプロキシ行に吸収されうる一方、**その逆は成立しません**。読み取り側のフィルタは日次ロールアップの集計が通るものと同一であるため、すでに保存済みの重複行も計上されなくなります。**行の書き換えも削除も行いません**——この自動修復には保持期間の制限があります。「アップグレード時の注意」を参照してください。Codex・Gemini・OpenCode では緩和後の比較はもとの厳密一致に退化し、上限チェックは引き続き厳密一致を使います。([#5938](https://github.com/farion1231/cc-switch/issues/5938)、[#5951](https://github.com/farion1231/cc-switch/pull/5951))
|
||||
|
||||
### 公式 Codex プロバイダに戻すと 401 のまま、ログイン画面も出ない問題
|
||||
|
||||
Codex の API キー保持スイッチがオフのとき(既定はオフです)、サードパーティのプロバイダに切り替えると相手のキーが `~/.codex/auth.json` に書き込まれます。その後、組み込みの公式プロバイダ——保存されている認証情報は空です——に切り替えると「設定だけを書く」分岐を通るため、`config.toml` は置き換えられる一方、サードパーティの `OPENAI_API_KEY` はそのままディスクに残りました。Codex はその外部のキーで公式エンドポイントにリクエストして確実に 401 になり、さらに `auth.json` が存在するため自身のログイン画面にも戻らず、アプリ内には出口がありませんでした。
|
||||
|
||||
現在は、公式 Codex プロバイダへの切り替えが成功した後、`auth.json` の中身が `OPENAI_API_KEY` のみで、その横に第一級の認証情報が何もない場合、このファイルを削除します——OAuth トークン、パーソナルアクセストークン、agent の識別情報、Bedrock のキーのいずれかがあれば本物の認証情報であることを示すため、完全に保持されます。一方、`auth_mode`・`last_refresh`・アカウント ID のような単なるメタデータは、古くなったキーを「かばう」ことがもうできません。
|
||||
|
||||
**`{}` を書き込むのではなくファイルを削除する**のは意図的な選択です。空のオブジェクトは Codex にトークンのない ChatGPT モードと判定され、起動時にエラーになります。ファイルがない状態こそが未ログインと等価で、そのままログインの流れに入ります。このクリーンアップは、以前のプロバイダがデータベースへ正常に書き戻された後にのみ実行されるため、削除されたキーは失われていません——そのプロバイダのレコードに保存されており、再び選択すれば戻ってきます。同じ変更でライブ設定の読み取りも緩和され、クリーンアップ後の状態(`auth.json` がなく `config.toml` がある)が「Codex がインストールされていません」と報告されることはなくなりました。
|
||||
|
||||
### 設定ページからの Grok Build アップグレードが `os error 2` の一言で失敗する問題
|
||||
|
||||
設定 → バージョン情報から Grok Build をアップグレードすると、`Error: No such file or directory (os error 2)` で失敗し、それ以外の情報は何も出ませんでした。
|
||||
|
||||
根本原因は、検出の経路と実行の経路の非対称性です。検出はログインシェルを通るためユーザーの rc ファイルを読み、nvm・Homebrew・Volta が見えます。一方、ライフサイクルのスクリプトは非ログインシェルで実行され、GUI アプリの起動時に受け継がれる非常に狭い PATH を引き継ぎます。本来これは問題になりません。アンカーされたコマンドは対象のプログラムを絶対パスで呼び出すからです——しかし grok 0.2.112 は自己更新を npm 配布に移し、`grok update` は内部で `npm view` と `npm i -g` を呼び出すようになりました。そして npm 自身は shebang を通じて node を解決します。内側の呼び出しが ENOENT を返し、grok はそれをそのまま `os error 2` として投げていました。
|
||||
|
||||
現在、macOS と Linux のライフサイクルコマンドは、ログインシェルの実際の PATH を、引き継いだ PATH の前に結合します。読み取りは変数をエコーするのではなく `/usr/bin/env` を実行して行います——fish は PATH をリストとして保持しており、エコーするとスペース区切りの断片になってしまうためです。ネイティブインストールの Grok については、アップグレードの連鎖に公式 xAI インストーラーをフォールバックとして追加しました。**あえて `npm i -g` は使いません**:npm は主経路と同じ 2 つの失敗モード(node がない、ミラーにパッケージがない)を共有しており、一緒に失敗するからです。公式インストーラーは node に依存しない唯一の経路で、配置先も同じであり、さらに CLI 自身の `installer` 設定を `internal` に書き戻すため、初期の npm フォールバックによって npm 配布に切り替わってしまったユーザーも、ついでに元に戻ります。
|
||||
|
||||
### Grok Build で引き継ぎを有効にすると 404 になり、毎回のリクエストが新規セッションのように扱われる問題
|
||||
|
||||
API 形式を OpenAI Chat または Anthropic に変更した Grok Build プロバイダで引き継ぎを有効にすると、ただちに 404 になり、フェイルオーバーも使用量の記録もありませんでした——引き継ぎはアドレスとキーを書き換える一方、バックエンドのフィールドには手を付けなかったため、CLI がプロキシの登録していないルートにリクエストを送っていたのです。現在は引き継ぎがバックエンドも Responses に固定します。個別のプロバイダに応じて Chat Completions へ落とす処理は引き続き転送層で行われ、この強制された値はプロキシ停止時にライブ設定のバックアップとともに元へ戻ります。
|
||||
|
||||
もう 1 つの問題は、プロキシのセッション判定が Codex と OpenAI のクライアントしか認識していなかったことです。そのため Grok Build のリクエストは毎回新しいセッション ID を生成して「クライアント提供ではない」と印を付けられ、キャッシュキーの注入とダッシュボードのセッション単位の集計がどちらも効かなくなっていました。現在は Grok 自身のヘッダを読みます——まずセッションが属する会話の ID、次にセッション ID を見て、リクエストごとに変わる ID は無視します——さらに専用のプレフィックスを使い、Codex の記録と衝突しないようにしています。([#5677](https://github.com/farion1231/cc-switch/pull/5677))
|
||||
|
||||
### 9 件の UI 文言がすべての言語で簡体字中国語を表示していた問題
|
||||
|
||||
9 件の文字列が、UI の言語にかかわらず簡体字中国語で表示されていました。英語や日本語の UI でも同様です。各呼び出し箇所は「インラインの既定値」の書き方を使い、既定値には中国語のリテラルが入っていましたが、対応するキーは 4 つの言語ファイルの**どれにも存在しませんでした**——そして i18next は言語チェーンをすべてたどってからインラインの既定値を検討するため、英語へのフォールバックが働く機会がそもそもなく、中国語のリテラルがすべての言語で勝っていました。
|
||||
|
||||
対象の文言は、Grok Build プロバイダフォームの必須項目チェック、アプリがまだ引き継がれていないときのフェイルオーバーのホバー説明、別のアプリが引き継ぎを保持している状態で Claude Desktop のルーティングを停止しようとしたときの警告とその理由の説明、プロバイダ識別子の読み取り失敗、Codex 共通設定が空のときのエラー、ルーティングサービスの停止と停止失敗の 2 つの通知、そして使用量テーブルで「token はあるがコストが 0 と算出された」リクエストに付く「未計算」のラベルにわたります。9 件のキーは現在、簡体字中国語・英語・日本語・繁体字中国語のすべてに存在します。([#5960](https://github.com/farion1231/cc-switch/pull/5960))
|
||||
|
||||
### 繁体字中国語の「バージョン情報」ページでツール管理が英語にフォールバックしていた問題
|
||||
|
||||
UI の言語を繁体字中国語に設定すると、「バージョン情報」ページのツール管理の領域が英語で表示されていました——バージョンの行、インストールと更新のボタン、結果の通知、インストール競合の診断、そして更新確認ダイアログ全体です。このパネルは 3 回の変更を経て段階的に作られましたが、そのたびに簡体字中国語・英語・日本語だけが追加されていました。i18next の方針はエラーにせず英語へフォールバックすることなので、欠けていた 30 件のキーはテストからまったく見えませんでした。**この欠落は v3.16.0 から v3.19.0 までずっと出荷され続けていました。**
|
||||
|
||||
30 件の文言はすべて翻訳され、インストールの説明も他の言語と揃えました。新しく追加した言語テストは、ツール管理のすべての文言が 4 言語に存在し、かつ補間変数が一致することを要求するため、この種のずれは今後リリースされる前にテストで失敗します。([#5943](https://github.com/farion1231/cc-switch/pull/5943))
|
||||
|
||||
### 組み込み価格がベンダーの定価とずれていた問題
|
||||
|
||||
コストはログに書き込む時点で組み込み価格テーブルに従って確定するため、古い初期値は以後のすべてのリクエストを黙って誤って計算します。本リリースでは 4 行を修正しました。`deepseek-chat` と `deepseek-reasoner` は V4 Flash の旧称エイリアスとなり、100 万 token あたり入力 $0.14 / 出力 $0.28、キャッシュ読み取り $0.0028 になりました(従来は $0.27/$1.10 と $0.55/$2.19)。`minimax-m3` は公式の標準区分に合わせて半額の $0.30/$1.20 に。`gpt-5.6-luna` は OpenAI の 2026-07-30 の値下げに合わせて 80% 引き下げて $0.20/$1.20、`gpt-5.6-terra` は 20% 引き下げて $2/$12 とし、`gpt-5.6-sol` は意図的に据え置き、このシリーズのキャッシュ書き込みの比率も維持しています。
|
||||
|
||||
この修正は、行の 4 つの価格列が**すべて従来の組み込み値と等しいままである場合にのみ**書き換えます。そのため、自分で変更した価格——あるいは models.dev の同期によって書き込まれた価格——が触られることはありません。
|
||||
|
||||
---
|
||||
|
||||
## セキュリティ強化
|
||||
|
||||
### ディープリンクのインポート確認:マスクをより厳しく、切り詰めをより少なく
|
||||
|
||||
これは v3.19.0 の `ccswitch://` 確認ダイアログ強化の続きです。設定のプレビューは共有モジュールで一元的に構築するようになり、入れ子になった TOML テーブルや JSON オブジェクトの中の秘匿値も再帰的にマスクします。これにより、方向の異なる 2 つの欠陥が同時に解消されました。Grok Build のインポートはこれまで設定のプレビューが**まったく描画されず**、Codex のインポートは埋め込まれた `api_key` を**平文で表示**していました。
|
||||
|
||||
設定プレビューにあった 300 文字の切り詰めは撤廃され、全文がスクロール可能な枠内に描画されるようになりました——確認ダイアログが「これから何を書き込むか」を隠しうる最後の箇所が塞がれます。マスク自体も、使われているすべての箇所で厳しくなりました。**MCP のインポート確認ダイアログも含みます**:秘匿キー名の照合に `AUTHORIZATION`・`COOKIE`・`CREDENTIAL` と、完全一致の `AUTH`・`BEARER` を追加し、マスク後に表示される平文のプレフィックスは 8 文字から 4 文字に縮小、長さが 8 文字以下の値は、そのまま表示するのではなく全体を置き換えるようになりました。
|
||||
|
||||
最後に、フロントエンドの Base64 デコーダーは前後の空白を切り落とさなくなりました——それは URL デコードによって `+` が空白になったものである可能性があるためです。これは v3.19.0 で修正したのと同じ種類の、フロントエンドとバックエンドのデコード基準のずれです。確認ダイアログが表示するものと、インポーターが書き込むものが食い違ってしまいます。
|
||||
|
||||
---
|
||||
|
||||
## 内部変更
|
||||
|
||||
### 呼び出し元のないコード 3,166 行、14 モジュール、依存 4 件を削除
|
||||
|
||||
「呼び出し元が 1 つもない」コードを対象にした整理です。バックエンドではプロバイダのアイコン推定テーブル、プレースホルダーのヘルスチェッカー、一度も接続されていない SSE 実装(ストリーミングと非ストリーミングのハンドラを含みます)、未使用のプロキシセッション型 2 つ、参照のない使用量パーサー 4 つ、そして使われていないコスト計算の入口を削除しました——**本番の課金経路、その自動判別パーサー、セッション ID の抽出はすべて手つかずです**。コンパイラが今まで警告しなかった原因である 22 か所の `#[allow(dead_code)]` も、あわせて削除しました。
|
||||
|
||||
フロントエンドではインポート元のない 14 モジュールを削除しました。パネルへの作り直しで置き換えられたプロンプトフォームのモーダルとリポジトリマネージャ、重複したプロキシ設定の hook、プロジェクトの歴史上一度もインポート元を持たなかったサーキットブレーカーのパネル、そして 3 つの schema ファイルが含まれます。これらの文言も 4 言語から同時に削除しました。これらのモジュールの背後にある Tauri コマンドは意図的に残しています。未使用の npm 依存 4 件も削除しました。手動管理のアイコンインデックスを再生成する 2 つのスクリプトを削除し、インデックスファイルのヘッダには「自動再生成は意図的にサポートしない」と明記しました。
|
||||
|
||||
あわせて、プロキシの状態と引き継ぎの状態を単一のクエリ層に統合しました——これまで同じコマンドを対象とする 2 つ目の hook 群が並存していましたが、呼び出し元はゼロで、そのクエリキーには観測者が一度も付かず、それらを対象とした無効化はすべて空振りでした。クエリキーの文字列は一字も変わっておらず、残した hook は従来のポーリング動作を維持しています。([#5916](https://github.com/farion1231/cc-switch/pull/5916)、[#5928](https://github.com/farion1231/cc-switch/pull/5928))
|
||||
|
||||
---
|
||||
|
||||
## アップグレード時の注意
|
||||
|
||||
### 本リリースにデータベースマイグレーションはありません
|
||||
|
||||
v3.19.1 にスキーマのマイグレーションは含まれず(バージョンは v16 のまま)、アップグレード前のバックアップも発生しないため、そのまますぐに利用できます。
|
||||
|
||||
### Claude Desktop の 2 重計上の自動修復には 30 日の期限があります(要確認)
|
||||
|
||||
この修正はデータを書き換えたり削除したりするのではなく、クエリ時に重複行を抑制します。そのため**明細行が残っている日は、次回起動時に正しい合計へ戻ります**。再構築の操作は必要ありません。
|
||||
|
||||
ただし明細行は 30 日を超えると日次ロールアップに集計され、削除されます。そしてロールアップは集計時点で有効だった基準で一度計算され、そのまま固定されます。**この修正を含まないバージョンですでに集計されてしまった日は、水増しされた数値が永久に残ります。** この回帰は v3.18.0(2026-07-21)で入ったため、アップグレードが早いほど、取り戻せる過去の範囲は広くなります。
|
||||
|
||||
### 新しい価格が過去のデータに与える 2 通りの影響
|
||||
|
||||
新たに価格を追加した 8 モデルは**遡って再計算されます**。起動時にコストが 0 と記録されているリクエストへコストが補われるため、これらのモデルのダッシュボードの数値は**上がります**。すでに集計・削除された明細行は再計算できず、0 のままです。
|
||||
|
||||
価格を変更した 4 モデルは逆方向です。補正は 0 コストの行しか扱わないため、すでに記録済みのリクエストは旧価格のままで、新しいリクエストのみが新価格で計上されます——同じモデルでも、過去の費用と今後の費用は一致しません。どちらの経路でもご自身の価格は保護されます。修正は元の組み込み値のままの行しか変更せず、`~/.cc-switch/model-pricing.json` にある手動での価格変更・models.dev の同期値・削除の記録は、投入と修正の後に再適用され、常に優先されます。
|
||||
|
||||
### プリセットの変更は新規作成のプロバイダにのみ影響します
|
||||
|
||||
すでに保存済みの DeepSeek や火山方舟 Coding Plan のプロバイダは、保存されている API 形式を保ち、引き続きローカルルーティングを必要とし、従来のカタログを使います。直接接続にするには、プリセットからプロバイダを作り直すか、プロバイダフォームの詳細設定で API 形式をネイティブ Responses に変更してください。
|
||||
|
||||
ただし、**すでにネイティブ Responses で、アドレスが `deepseek.com` 上にあるプロバイダは、次回の切り替え時に自動でミラーリングされた公式カタログを使うようになります**。保存し直す必要はありません——判定はライブ設定を読むためです。
|
||||
|
||||
### DeepSeek V4 Pro はまだ直接接続で使えません
|
||||
|
||||
プリセットには引き続き `deepseek-v4-pro` が並んでおり、ベンダー自身が公開しているカタログにも含まれていますが、**DeepSeek 側で pro 向けの Codex 連携がまだ開放されていません**。公式に示されている時期は 2026 年 8 月上旬です。それまでは直接接続モードで pro を選ぶと上流でエラーになります——プリセットの既定モデルでもある `deepseek-v4-flash` をご利用ください。
|
||||
|
||||
いま pro をどうしても使いたい場合は、そのプロバイダの API 形式を「OpenAI Chat」に戻し、ローカルルーティングの引き継ぎを有効にしてください。これは v3.19.1 より前に DeepSeek がずっと通っていた経路そのものです。ローカルプロキシが Codex の送る Responses リクエストを Chat Completions に変換するため、pro はこの経路では影響を受けません。
|
||||
|
||||
### DeepSeek 公式カタログの 2 つの前提
|
||||
|
||||
ミラーリングされたカタログは Codex クライアントの最低バージョンとして 0.144.0 を宣言していますが、**CC Switch 自身は検証を行いません**——カタログが持つ freeform な `apply_patch` の登録には、このバージョン以降が必要です。また、生成されるカタログファイルは(ミラーリングされた 2 モデルで)約 75 KB まで大きくなります。各エントリがハーネスの全文を含むためです。
|
||||
|
||||
### 直接接続の後、使用量の帰属はプロバイダ名から `Codex (Session)` に変わります
|
||||
|
||||
DeepSeek・火山方舟 Coding Plan・Tencent Hunyuan は引き継ぎを必要としなくなったため、そのトラフィックはローカルプロキシを完全に迂回でき、プロキシ側のリクエスト単位の記録からは見えなくなります。
|
||||
|
||||
**使用量そのものは失われませんし、区別もできます**——Codex のセッションログの取り込みが従来どおり記録します。ただしこの経路はプロバイダの識別情報を持ちません。ローカルプロキシを通らなかった Codex の使用量はすべて `Codex (Session)` という名前の項目にまとめられ、公式サブスクリプションの消費もこの行に入ります。つまり DeepSeek がルーティング経由から直接接続に変わると、その使用量は「DeepSeek」という名前の下から `Codex (Session)` に移ります。
|
||||
|
||||
**区別するにはモデルを見てください**:使用量の各レコードはそれぞれのモデル ID を保持しており、使用量パネルの「モデル統計」がモデルごとに 1 行ずつ表示します——`deepseek-v4-flash`・`hy3`・`ark-code-latest` と公式サブスクリプションの GPT 系は、それぞれ別の行になり、費用も token も分かれています。**プロバイダ単位**という軸そのものが必要な場合(たとえば同じモデルを複数のアグリゲータ間で比較したい場合)にのみ、引き続きローカルルーティングの引き継ぎを使う必要があります——この経路は実際のプロバイダ名を記録します。
|
||||
|
||||
### Codex の古い認証情報のクリーンアップには 2 つの前提があります
|
||||
|
||||
クリーンアップは「切り替え先のプロバイダに明示的な公式カテゴリが付いている」**かつ**「切り替え元のプロバイダが正常に書き戻された」場合にのみ実行されます。手動で作成し公式カテゴリを付けていないエントリや、書き戻しに失敗した切り替えでは、古い認証情報はディスクに残ったままです。
|
||||
|
||||
### Grok Build で引き継ぎを有効にするとバックエンドのフィールドが書き換わります
|
||||
|
||||
Grok Build のプロバイダで引き継ぎを有効にすると、ライブ設定のバックエンドのフィールドが Responses に書き換えられるようになりました。データベースに保存されているプロバイダのレコードは影響を受けず、プロキシ停止時にライブファイルはバックアップから丸ごと復元されます。
|
||||
|
||||
### ツールのインストールと更新における PATH の変化(macOS / Linux のみ)
|
||||
|
||||
設定 → バージョン情報から実行するツールのインストールと更新はすべて、ログインシェルの PATH を、引き継いだ PATH の前に結合するようになりました。そのためライフサイクルのスクリプトが名前で解決するプログラムが、以前と異なるものになる可能性があります。また各操作でこの PATH を読むためのシェルが 1 つ余分に起動され、対話用の起動ファイルが実行されます。**Windows は影響を受けません。**
|
||||
|
||||
grok 0.2.112 以降を使っているユーザーは、インストールの記録が 2 つ見えることがあります——ネイティブのものと、`grok update` 自身が作成したグローバルの npm パッケージです。これらは上流によって同期が保たれ、同じバージョンを報告します。
|
||||
|
||||
### 環境変数の競合検出の照合基準が変わりました
|
||||
|
||||
Claude Code・Codex・Gemini の検出は「部分一致」から「プレフィックス」に引き締められました。そのため、単に名前にアプリ名を含むだけの変数——`MY_ANTHROPIC_API_KEY`、`OLD_GEMINI_API_KEY`——は**競合として報告されなくなります**。あわせて Grok Build の検出を追加しました。
|
||||
|
||||
### ディープリンクのインポート確認で表示される秘匿値が減ります
|
||||
|
||||
マスク後に表示される平文のプレフィックスは 8 文字から 4 文字に縮小され、長さが 8 文字以下の値は全体がマスクされます。これは MCP のインポート確認ダイアログにも影響します。
|
||||
|
||||
---
|
||||
|
||||
## リスク通知
|
||||
|
||||
### 継続してお伝えしている注意事項
|
||||
|
||||
**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#️-リスクに関する注意事項) を参照してください。
|
||||
|
||||
**SuperGrok の残量照会**:プロバイダカードの残量表示は grok.com の非公開の課金エンドポイントに依存しており、xAI がインターフェースを変更すると機能しなくなる可能性があります——詳細は [v3.19.0 release notes](v3.19.0-ja.md#リスク通知) を参照してください。
|
||||
|
||||
**サードパーティプロバイダへのルーティング**:CC Switch のローカルプロキシで Codex・Claude Desktop・Grok Build のリクエストを変換してサードパーティのプロバイダへ転送する場合、課金・コンプライアンス・データ保持に関する制約はプロバイダごとに異なります。利用前に対象プロバイダの利用規約をお読みください。
|
||||
|
||||
上記の機能を有効にした時点で、ユーザーは関連するリスクを自ら引き受けることになります。CC Switch は、これらの機能の利用に起因するアカウントの制限・警告・サービス停止について、一切の責任を負いません。
|
||||
|
||||
---
|
||||
|
||||
## 謝辞
|
||||
|
||||
本リリースの修正は、その大半が外部のコントリビューターによるものです——6 つの PR のうち 5 つは私が書いたものではありません。
|
||||
|
||||
### コード貢献
|
||||
|
||||
- [#5677](https://github.com/farion1231/cc-switch/pull/5677):Grok Build のプロキシ引き継ぎとディープリンク連携の仕上げ——バックエンドのフィールド、セッションの識別、フェイルオーバータブ、環境変数の検出、さらに設定プレビューでの秘匿値の漏れの修正まで。@YUZHEthefool さんに感謝します。本リリースで最も広い範囲をカバーした仕事です。
|
||||
- [#5951](https://github.com/farion1231/cc-switch/pull/5951):Claude Desktop の使用量 2 重計上の修正。@Komikawayi さんに感謝します。v3.18.0 のどの変更が 3 つの重複排除の防御を同時に破ったのかを突き止めたのは、本リリースで最も根気を要した調査でした。
|
||||
- [#5916](https://github.com/farion1231/cc-switch/pull/5916)、[#5928](https://github.com/farion1231/cc-switch/pull/5928):呼び出し元のないコード 3,166 行と、重複したプロキシのクエリ層の削除。@SaladDay さんに感謝します。
|
||||
- [#5943](https://github.com/farion1231/cc-switch/pull/5943):繁体字中国語のツール管理の文言を補い、言語のずれを防ぐテストを追加。@yovinchen さんに感謝します。
|
||||
- [#5960](https://github.com/farion1231/cc-switch/pull/5960):すべての言語で簡体字中国語が表示されていた 9 件の文言の補完。@mhy1227 さんに感謝します。
|
||||
|
||||
### 問題報告
|
||||
|
||||
[#5938](https://github.com/farion1231/cc-switch/issues/5938) で、Claude Desktop のリクエストごとに `session_log` を出所とするログが 1 行余分に生まれ、token が 2 回集計されていることを報告してくださった @Alaric-L さんに感謝します——データソースまで特定された報告で、本リリースで最も重要な使用量の修正はこの報告から直接たどり着いたものです。
|
||||
|
||||
---
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[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.1-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー |
|
||||
| `CC-Switch-v3.19.1-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます |
|
||||
|
||||
Windows ARM64 デバイスでは、ファイル名に `arm64` が含まれる対応する成果物を選択してください。
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| -------------------------------- | ------------------------------------------------------ |
|
||||
| `CC-Switch-v3.19.1-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ |
|
||||
| `CC-Switch-v3.19.1-macOS.zip` | 展開して Applications へドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.19.1-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.1-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.19.1-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,369 @@
|
||||
# CC Switch v3.19.1
|
||||
|
||||
> 这一版的主线是**把上一版的尾巴收干净**:三家国产 Codex 网关经确认原生支持 Responses API,**不用再开本地路由接管**——DeepSeek 与火山方舟 Coding Plan 的预设从走本地路由改为直连,新加入的腾讯混元 TokenHub 一上来就是直连;四个能在日常里撞上的故障被修掉——**Claude Desktop 用量自 v3.18.0 起被算了两遍**(升级后历史数字会自动回正,但有 30 天窗口,见「升级提醒」)、切回官方 Codex 会卡在 401 且看不到登录界面、从设置页升级 Grok Build 只报一句 `os error 2`、Grok Build 开启接管后直接 404。另有 8 个此前一直按 $0 记账的模型补上内置定价,39 个界面文案的语言问题被修正。本版**没有数据库迁移**,并且是本项目第一个删除量超过新增量的版本。
|
||||
|
||||
**[English →](v3.19.1-en.md) | [日本語版 →](v3.19.1-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 重点内容:你现在可以
|
||||
|
||||
- **让 DeepSeek、火山方舟 Coding Plan、腾讯混元在 Codex 里直连**:三家的官方 Codex 文档都已确认端点原生提供 Responses API。DeepSeek 与火山方舟 Coding Plan 的既有预设从 Chat 格式改为原生格式,供应商卡片上的「需要路由」标记与切换时的提示随之消失,请求不再经过本地代理的协议转换;腾讯混元 TokenHub 是本版新增的预设,从一开始就是原生格式。**注意 DeepSeek V4 Pro 暂时还不能直连**——厂商侧尚未开通它的 Codex 集成,直连请用 V4 Flash(预设默认),详见[升级提醒](#deepseek-v4-pro-暂时还不能直连)。
|
||||
- **让 DeepSeek 用上 DeepSeek 自己发布的模型目录**:新的「官方厂商目录镜像」机制把厂商公布的 `models.json` 原样下发给该厂商自己的端点,freeform `apply_patch` 与配套的 GPT-5 提示词框架成套保留,不再被压成中性模板。判定只认域名、不认模型名——同一个模型在聚合站上未必实现同样的能力。
|
||||
- **拿到正确的 Claude Desktop 用量数字**:自 v3.18.0 起,经本地网关的 Claude Desktop 流量在看板里被记了两遍——一遍来自代理、一遍来自会话日志导入,token、费用与请求数约翻倍。本版修好后,明细行还在的日子会自动回到正确数字,**不需要重建**。
|
||||
- **切回官方 Codex 之后能正常登录**:此前从第三方供应商切回内置的官方 Codex 条目时,第三方的 key 会留在 `~/.codex/auth.json` 里,Codex 拿着它去请求官方端点,稳定 401——又因为文件存在,它不会退回自己的登录界面,在应用里没有出路。
|
||||
- **从设置页把 Grok Build 升上去**:`grok update` 自 0.2.112 起改为内部调用 npm 完成分发,而图形界面启动的应用看不到 node,升级只会报一句 `Error: No such file or directory (os error 2)`。
|
||||
- **给 Grok Build 开启接管而不是撞上 404**:API 格式被手动改成 OpenAI Chat 或 Anthropic 的 Grok Build 供应商,开启接管后请求会打到一个代理没有注册的路由上,直接 404,且没有故障转移、没有用量记录。同时,Grok Build 的每次请求此前都被当成新会话,缓存键注入与按会话聚合都失效了。
|
||||
- **看到 8 个此前一直按 $0 记账的模型的真实成本**:`gpt-5.3-codex-spark`、`gemini-3.5-flash-lite`、`kimi-k2.7-code-highspeed`、`glm-5-turbo`、`glm-5v-turbo`、`qwen3.6-flash`,以及不带日期后缀的 `claude-opus-4-6` / `claude-sonnet-4-6`。
|
||||
- **在繁体中文界面里看懂「关于」页的工具管理**:30 个只补了简中 / 英文 / 日文的文案漏了繁体中文,因为 i18next 会静默回落英文,这块面板自 v3.16.0 起一直是半英文的。另有 9 个文案在**所有语言下**都显示简体中文。
|
||||
- **在官方订阅与 DeepSeek 之间来回切,而不是二选一**:`auth.json` 与 `config.toml` 都是单槽文件,Codex 自己存不下第二份凭据。厂商的一键脚本会把这份配置改造成自己专用的,而 CC Switch 是按供应商整段快照与还原——这也是它和官方脚本最实际的区别,详见[下文对照](#用-cc-switch-接入和直接跑官方脚本有什么不同)。
|
||||
|
||||
---
|
||||
|
||||
## 使用攻略
|
||||
|
||||
本版的改动集中在 Codex 的连接方式与用量统计口径上,建议结合以下文档了解:
|
||||
|
||||
- **[本地路由](../user-manual/zh/4-proxy/4.2-routing.md)**:哪些供应商需要开启接管、接管做了什么。本版之后 DeepSeek、火山方舟 Coding Plan 与腾讯混元都不再需要它。
|
||||
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:用量看板的数据来源与统计口径,理解 Claude Desktop 双算是怎么发生的、修复后为什么部分历史日期无法回正。
|
||||
- **[在 Codex 中用 DeepSeek 这类 Chat 格式 API](../guides/codex-deepseek-routing-guide-zh.md)**:这篇攻略讲的是本地路由如何把 Responses 转换成 Chat Completions,机制部分对 Kimi、MiniMax、SiliconFlow 等仍是 Chat 形态的供应商依旧适用;但**其中以 DeepSeek 作为示例的部分已不适用于本版**——DeepSeek 现在走直连,不需要路由。
|
||||
|
||||
---
|
||||
|
||||
> [!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.1 是一次维护性发布,主线有三条。第一条是国产 Codex 网关集体转向原生 Responses:DeepSeek 直连 `api.deepseek.com`,并带来一个可复用的机制——把厂商自己发布的模型目录原样镜像下发,让 freeform `apply_patch` 与配套的 GPT-5 提示词框架保持自洽,而不是被折叠成中性模板;火山方舟的 Coding Plan 端点 `/api/coding/v3` 在官方文档确认后跟进;腾讯混元的 TokenHub 作为新预设加入。三者都不再需要开启本地路由接管。
|
||||
|
||||
第二条是四个现场可见的故障修复:Claude Desktop 的用量自 v3.18.0 起被记两遍([#5938](https://github.com/farion1231/cc-switch/issues/5938));切回内置官方 Codex 供应商会留下第三方的 `auth.json`,导致 401 且看不到登录界面;`grok update` 在图形界面下只报 `os error 2`;Grok Build 的代理接管在非 Responses 后端上 404,且每次请求都被当作新会话([#5677](https://github.com/farion1231/cc-switch/pull/5677))。第三条是减重:3,166 行已无任何调用方的代码与 4 个未使用的 npm 依赖被删除——本版是本项目第一个删除量超过新增量的版本。此外,深链导入确认框的脱敏更严、截断更少,8 个此前按 $0 记账的模型补上定价,4 个内置定价与厂商牌价重新对齐。本版**没有数据库 schema 迁移**(版本号保持 v16),升级轻量。
|
||||
|
||||
**发布日期**:2026-07-31
|
||||
|
||||
**更新规模**:12 commits | 71 files changed | +2,324 / -3,680 lines
|
||||
|
||||
---
|
||||
|
||||
## 新功能
|
||||
|
||||
### 官方厂商模型目录镜像(DeepSeek 首发)
|
||||
|
||||
Codex 从一个目录文件读取模型能力,而 CC Switch 此前对所有供应商都用中性模板生成这个目录——对聚合站这是对的,但会剥掉厂商自家集成所依赖的能力。现在,凡是随应用内置了官方目录的厂商,直接镜像下发它自己的那一份。
|
||||
|
||||
DeepSeek 是第一家:内置文件带着 `deepseek-v4-flash` 与 `deepseek-v4-pro` 两个条目,保留 `apply_patch_tool_type: "freeform"`、`web_search_tool_type: "text"`、`supports_search_tool: true`、low / high / max 三档思考强度,以及 `base_instructions` 与 `model_messages` 里那份 17,644 字符的 GPT-5 提示词框架——**这份框架必须与 freeform 工具注册一起走**,因为框架本身就在指导模型使用 `apply_patch`,拆开任何一半都会不自洽。
|
||||
|
||||
判定条件刻意收得很窄:供应商必须落在原生 Responses 档**并且** `base_url` 在 `deepseek.com` 上。**只认域名、不认模型品牌**——同一个模型在转售它的聚合站上未必实现同样的能力,按品牌授予等于把能力凭空发给了没有实现它的服务。供应商自己在目录里写死的条目仍然优先;遇到不认识的模型 ID 会克隆旗舰条目,但保留它自己的名称。其它所有档位生成的目录与改动前逐字节一致。
|
||||
|
||||
### 腾讯混元(TokenHub)Codex 预设
|
||||
|
||||
Codex 的预设选择器里新增「Tencent Hunyuan」,归入「开源官方」分类,位于百炼与阶跃之间。选中即写好 `https://tokenhub.tencentmaas.com/v1`、`wire_api = "responses"` 与 TokenHub 强制要求的 `disable_response_storage = true`;声明 `hy3` 与 `hy3-preview` 两个模型,上下文窗口 **256K**(而不是接受 Codex 的 128K 默认值),并标记为纯文本——Codex 不会再把 `view_image` 的图片载荷发给读不了图的模型。
|
||||
|
||||
因为是原生 Responses 供应商,Codex 直连网关、无需本地路由;生成的目录走中性原生模板,会固定 `shell_type = "shell_command"` 并去掉原生网关拒收的 freeform `apply_patch` 注册。地址管理器与测速里从一开始就有两个候选:主域名与官方备用的 `.cn` 域名;区域独立的国际站刻意排除在外,因为 API Key 不跨站通用。
|
||||
|
||||
注意 **API Key 需要是开通了 Hy3 权限的 TokenHub key**,Coding Plan 与 Token Plan 的订阅 key 在这个端点上用不了。
|
||||
|
||||
### 8 个此前按 $0 记账的模型补上内置定价
|
||||
|
||||
`gpt-5.3-codex-spark`、`gemini-3.5-flash-lite`、`kimi-k2.7-code-highspeed`(按 Kimi 的 Turbo 惯例,取 `kimi-k2.7-code` 基准价的 2 倍)、`glm-5-turbo`、`glm-5v-turbo`、`qwen3.6-flash` 在内置定价表里根本没有行,前缀回退也够不着,因此每一次请求都被记成零成本。
|
||||
|
||||
另外两行 —— 不带日期后缀的 `claude-opus-4-6` 与 `claude-sonnet-4-6` —— 补的是一个更隐蔽的缺口:模型 ID 解析只会**剥掉**日期后缀、从不**补上**,所以一条带着无日期 ID 的日志谁也匹配不到。八行全部按「不存在才插入」播种,你改过的价格不受影响。
|
||||
|
||||
### Grok Build 加入故障转移页签与环境变量冲突检测
|
||||
|
||||
设置页的故障转移在 Claude Code、Codex、Gemini 之外新增第四个 Grok Build 页签。启动时的环境变量冲突横幅也开始检测 `XAI_API_KEY` 与 `GROK_DEFAULT_MODEL`——这两个变量会静默盖掉你在应用里选的供应商。检测区分了精确名与前缀,所以 CC Switch 自己用的 `GROK_BIN_DIR`、`GROK_HOME` 不会被误报。
|
||||
|
||||
---
|
||||
|
||||
## 变更
|
||||
|
||||
### DeepSeek 与火山方舟 Coding Plan 改为直连 Codex,不再需要本地路由
|
||||
|
||||
两家的预设此前都标记为 OpenAI Chat 格式,因此都是「需要接管」的:供应商卡片带着「需要路由」标记,未开代理就切换会弹提示,每个请求都要走 Codex → 本地代理 → Responses 转 Chat → 上游这条链路。
|
||||
|
||||
现在两家的官方 Codex 集成文档都已确认端点提供 Responses API——DeepSeek 的 `api.deepseek.com` 与火山方舟的 `/api/coding/v3`——两个预设随之声明为原生 Responses,标记与提示消失,Codex 直连网关。两家写出的 `config.toml` 都没有变化(本来就是 `wire_api = "responses"`),变的是目录生成档位;DeepSeek 另外把上下文窗口从 1,000,000 对齐到厂商自己的 1,048,576。
|
||||
|
||||
BytePlus 国际站刻意保持 Chat 路由不变,等国际站文档单独核实后再说。火山预设里还留了一条值得知道的计费注记:按量计费的 `/api/v3` 端点**绝不能**加进这个预设的备用地址——它单独计费,不走套餐额度。
|
||||
|
||||
### 目录的显示名与上下文窗口改为「显式才生效」
|
||||
|
||||
这两个字段此前带着本地默认值——模型 ID 与 128,000 的窗口——并且在厂商值有机会参与之前就应用了,镜像目录里 1M 的窗口会被 128K 覆盖掉。现在它们是可选的,回退挪到条目构造那一层,于是「留空」才真正等于「沿用厂商声明的值」。显式写了这两个字段的供应商,以及所有非镜像档位,生成的目录与之前完全一致。
|
||||
|
||||
---
|
||||
|
||||
## 用 CC Switch 接入,和直接跑官方脚本有什么不同
|
||||
|
||||
DeepSeek 官方提供了一条 Codex 一键接入脚本,它能用、会备份、也带恢复菜单。**如果你这台机器就打算专心用 DeepSeek,跑官方脚本没有任何问题。** CC Switch 解决的是另一个场景:你要在多个供应商之间来回切。
|
||||
|
||||
### 换供应商时,登录态与配置整套换,不用自己备份
|
||||
|
||||
`~/.codex/auth.json` 与 `~/.codex/config.toml` 都是**单槽文件**——Codex 本身没有多凭据存储,一份配置只能对应一个供应商。CC Switch 在你切走某个供应商时,把这一对文件的内容整段快照进那个供应商的记录里;切回来时再整段写回。所以「ChatGPT 订阅 → DeepSeek → 切回订阅」通常不需要重新 `codex login`,第三方之间来回切则完全无需手工动作。手工做同一件事,你得在每次切换前后各拷贝一次这两个文件,漏一次,被覆盖的 OAuth 凭据就只能重新登录找回。
|
||||
|
||||
官方脚本的取舍不同:它把 `config.toml` 改造成 DeepSeek 专用配置——顶层写死 `preferred_auth_method = "apikey"` 与 `forced_login_method = "api"`,把认证方式固定为 API Key,并且**删除 `config.toml` 里已有的 `[profiles.*]`**(Codex 自带的多供应商切换机制)。你的 ChatGPT 登录凭据本身没有被删,`auth.json` 原封不动;但在这份配置下用不上,想回订阅需要跑脚本的恢复菜单整体回滚——回滚会连带丢掉安装之后你对 `config.toml` 的任何手改。脚本本身也只能在 flash 与 pro 之间切换,没有「换到第三个供应商」这一档。
|
||||
|
||||
### 换供应商之后,`codex resume` 里的旧会话还在
|
||||
|
||||
Codex 的续聊列表按会话里记录的 `model_provider` 分抽屉。CC Switch 创建的所有第三方 Codex 供应商——不管是 DeepSeek、Kimi 还是聚合站——都写同一个标识 `custom`,所以在它们之间怎么换,`codex resume` 一直能看到全部历史。CC Switch 首次启动时还会做一次性迁移,把已知的按厂商分桶的旧会话(官方脚本写入的 `deepseek` 也在其中)折进这个共享桶,原文件先备份到 `~/.cc-switch/backups/`。
|
||||
|
||||
**这里有一条明确边界**:这个迁移只在 CC Switch 首次启动时跑一次。**如果你先装了 CC Switch、之后才去跑官方脚本**,那批带 `deepseek` 标识的会话不会再被折进来,它们会留在自己的抽屉里。另外,你手写的、不在已知名单里的供应商标识,CC Switch 刻意不去改动它。
|
||||
|
||||
### 官方订阅的会话,在 CC Switch 里本来就和第三方混排
|
||||
|
||||
CC Switch 的**会话管理面板直接扫描会话目录、不读 `model_provider`**,所以官方订阅期间产生的 Codex 会话一直和第三方会话在同一个列表里,可搜索、可续聊、可删除——**不需要开任何开关**。
|
||||
|
||||
如果你还希望 **Codex 自己的 `codex resume` 列表**也把官方与第三方合并,那是另一件事:设置 → 通用 → Codex 应用增强 → **「统一 Codex 会话历史」**,默认关闭。开启后只影响新会话;已有的官方会话要一并迁入,需要在开启确认框里再勾选「同时迁入现有官方会话历史」(同样默认不勾)。这两项都是既有功能、不是本版新增,边界场景见[《统一 Codex 会话历史》攻略](../guides/codex-unified-session-history-guide-zh.md)。
|
||||
|
||||
> **两个共同前提,先说清楚免得你事后困惑:**
|
||||
>
|
||||
> **一、以 CC Switch 指向的 Codex 目录为准。** 默认是 `~/.codex`,可在设置里改。**CC Switch 不读 `CODEX_HOME` 环境变量**——如果你用这个变量把 Codex 指到别处,那边的会话它看不见,供应商切换也会写进 CLI 没在用的目录里。要换目录请用 CC Switch 自己的「配置文件目录」设置。
|
||||
>
|
||||
> **二、出现在同一个列表里,不等于一定能续聊。** Codex 的推理内容(`encrypted_content`)只有产生它的后端能解密,跨供应商继续一段旧会话可能失败——这是上游的设计,不是 CC Switch 能绕过的。
|
||||
|
||||
---
|
||||
|
||||
## 修复
|
||||
|
||||
### Claude Desktop 的用量被算了两遍
|
||||
|
||||
经本地网关的 Claude Desktop 流量在用量看板里落两次——一次是代理行,一次是会话记录导入行——于是它的 token、费用与请求数大约翻倍。
|
||||
|
||||
这是 v3.18.0 引入的回归:代理侧的去重 ID 对除 `claude` 之外的所有应用都带上作用域前缀,写成 `session:{应用}:{供应商}:{消息ID}`,这就把 `claude-desktop` 放进了独立命名空间;而会话导入器仍然以裸的 `session:{消息ID}` 形态、`app_type = 'claude'` 写同一条 Claude 消息。三道去重防线因此同时失守:让代理行吸收已有会话行的主键收敛、写入侧的指纹探测、读取侧的过滤器——后两者都在用严格相等比较应用类型。
|
||||
|
||||
现在两个应用重新共用裸命名空间,两处比较则按单向规则放宽:`claude` 的会话行可以被 `claude-desktop` 的代理行吸收,**反过来不成立**。由于读取侧的过滤器也正是日报聚合所使用的那一个,已经入库的重复行会停止被计入,**不改写、不删除任何一行**——这条自愈有保留期限制,见「升级提醒」。对 Codex、Gemini、OpenCode 而言放宽后的比较退化为原来的精确匹配,额度检查仍使用严格匹配。([#5938](https://github.com/farion1231/cc-switch/issues/5938)、[#5951](https://github.com/farion1231/cc-switch/pull/5951))
|
||||
|
||||
### 切回官方 Codex 供应商会卡在 401、看不到登录界面
|
||||
|
||||
在 Codex API Key 保留开关关闭时(默认如此),切换到第三方供应商会把对方的 key 写进 `~/.codex/auth.json`。之后再切到内置的官方供应商——它存的凭据是空的——会走「只写配置」这条分支,于是 `config.toml` 被替换,而第三方的 `OPENAI_API_KEY` 原样留在盘上。Codex 随后拿着这把外来的 key 去请求官方端点,稳定 401;又因为 `auth.json` 存在,它不会退回自己的登录界面,在应用里找不到出路。
|
||||
|
||||
现在,成功切到官方 Codex 供应商之后,如果 `auth.json` 里只有一个 `OPENAI_API_KEY`、旁边没有任何一等凭据,这个文件会被删除——OAuth 令牌、个人访问令牌、agent 身份、Bedrock key 中的任何一个都标志着这是一份真实凭据,会被完整保留;而 `auth_mode`、`last_refresh`、账号 ID 这类纯元数据不再能「挡住」一把过期的 key。
|
||||
|
||||
**选择删除文件而不是写入 `{}`**:空对象会被 Codex 判定为没有令牌的 ChatGPT 模式并在启动时报错,而文件缺失才等价于未登录、直接进登录流程。清理只在旧供应商已成功回填进数据库之后执行,所以被删掉的 key 并没有丢——它存进了那个供应商的记录里,再次选中它就会回来。同一处改动还放宽了 live 配置读取:清理之后的状态(没有 `auth.json`、有 `config.toml`)不再被报成「Codex 未安装」。
|
||||
|
||||
### 从设置页升级 Grok Build 只报一句 `os error 2`
|
||||
|
||||
在设置 → 关于里升级 Grok Build 会失败于 `Error: No such file or directory (os error 2)`,没有任何其它信息。
|
||||
|
||||
根因是探测与执行两条路径的不对称:探测走登录 shell,会读取用户的 rc 文件,因此看得见 nvm、Homebrew、Volta;而生命周期脚本跑在非登录 shell 下,继承的是图形界面应用启动时那份很窄的 PATH。这本来无所谓,因为锚定命令都用绝对路径调用目标程序——但 grok 0.2.112 把自更新改到了 npm 分发上,`grok update` 内部会调起 `npm view` 与 `npm i -g`,而 npm 自己又要通过 shebang 解析 node。内层调用返回 ENOENT,grok 就把它原样抛成了那句 `os error 2`。
|
||||
|
||||
现在 macOS 与 Linux 上的生命周期命令会把登录 shell 的真实 PATH 并到继承的那份前面,读取方式是执行 `/usr/bin/env` 而不是回显变量——因为 fish 把 PATH 存成列表,回显会得到空格分隔的片段。原生安装的 Grok 还给升级链追加了官方 xAI 安装脚本作为兜底,**刻意不用 `npm i -g`**:npm 与主路径共享同样两种失败模式(没有 node、镜像源缺包),会一起失败;官方安装脚本是唯一不依赖 node 的路径,落点相同,并且会把 CLI 自己的 `installer` 设置改回 `internal`,顺带治好被早期 npm 兜底切到 npm 分发上的用户。
|
||||
|
||||
### Grok Build 开启接管后 404,且每次请求都像新会话
|
||||
|
||||
在 API 格式被改成 OpenAI Chat 或 Anthropic 的 Grok Build 供应商上开启接管,会立刻得到 404,没有故障转移也没有用量记录——接管改写了地址与 key,却没有动后端字段,于是 CLI 把请求发到了代理没有注册的路由上。现在接管会同时把后端固定为 Responses;针对具体供应商降级到 Chat Completions 的动作仍然发生在转发层,而这个被强制的值会随整份 live 配置的备份在代理停止时还原。
|
||||
|
||||
另一个问题是代理的会话识别此前只认 Codex 与 OpenAI 客户端,因此 Grok Build 的每一轮都会生成一个新的会话 ID 并标记为「非客户端提供」,这同时压掉了缓存键注入与看板里的按会话聚合。现在会读取 Grok 自己的头——先会话所属的对话 ID,再会话 ID,忽略每请求变化的那个——并使用独立前缀,避免与 Codex 的记录撞车。([#5677](https://github.com/farion1231/cc-switch/pull/5677))
|
||||
|
||||
### 9 个界面文案在所有语言下都显示简体中文
|
||||
|
||||
有 9 个字符串无论界面语言是什么都显示简体中文,英文与日文界面同样如此。每个调用点都用了「内联默认值」的写法、默认值是中文字面量,但对应的键在四个语言文件里**一个都没有**——而 i18next 会先走完语言链才考虑内联默认值,于是英文回退根本没有机会生效,中文字面量在所有语言下都赢了。
|
||||
|
||||
受影响的文案覆盖 Grok Build 供应商表单的必填校验提示、应用尚未接管时的故障转移悬停提示、另一个应用持有接管时停止 Claude Desktop 路由的警告及其原因说明、供应商标识读取失败提示、Codex 通用配置为空的错误、路由服务的停止与停止失败两个提示,以及用量表格里给「有 token 但算出来是零成本」的请求打的「未定价」标签。9 个键现在在简中、英文、日文、繁中里都有了。([#5960](https://github.com/farion1231/cc-switch/pull/5960))
|
||||
|
||||
### 繁体中文的「关于」页工具管理回落成英文
|
||||
|
||||
界面语言设为繁体中文时,「关于」页的工具管理区块显示英文——版本行、安装与更新按钮、结果提示、安装冲突诊断,以及整个升级确认弹窗。这块面板由三次改动逐步建成,每次都只补了简中、英文、日文;而 i18next 的策略是回落英文而不是报错,于是 30 个缺失的键在测试里完全不可见。**这个缺口自 v3.16.0 起一直带到 v3.19.0。**
|
||||
|
||||
30 个文案现在全部译好,安装提示也与其它语言对齐;新增的语言测试要求每一个工具管理文案在四种语言下都存在、且插值变量一致,这类漂移以后会在测试里失败而不是发出去。([#5943](https://github.com/farion1231/cc-switch/pull/5943))
|
||||
|
||||
### 内置定价与厂商牌价脱节
|
||||
|
||||
成本在写入日志时就按内置定价表冻结,所以一个过期的播种价会静默地把之后每一次请求都算错。本版修正四行:`deepseek-chat` 与 `deepseek-reasoner` 现在是 V4 Flash 的旧称别名,每百万 token $0.14 输入 / $0.28 输出、缓存读 $0.0028(原为 $0.27/$1.10 与 $0.55/$2.19);`minimax-m3` 按官方标准档减半到 $0.30/$1.20;`gpt-5.6-luna` 按 OpenAI 2026-07-30 的降价下调 80% 到 $0.20/$1.20,`gpt-5.6-terra` 下调 20% 到 $2/$12,`gpt-5.6-sol` 刻意不动,该系列的缓存写入比例保持不变。
|
||||
|
||||
修复只在一行的四个价格列**仍然全等于此前的内置值**时才改写它,所以你自己改过的价格——或者由 models.dev 同步写入的价格——绝不会被动到。
|
||||
|
||||
---
|
||||
|
||||
## 安全加固
|
||||
|
||||
### 深链导入确认框:脱敏更严,截断更少
|
||||
|
||||
这是 v3.19.0 那轮 `ccswitch://` 确认框加固的延续。配置预览改由一个共享模块统一构建,会递归地对嵌套 TOML 表与 JSON 对象里的密钥脱敏,一次修好两个方向相反的缺陷:Grok Build 的导入此前**完全不渲染**配置预览,而 Codex 的导入会把内嵌的 `api_key` **明文打印**出来。
|
||||
|
||||
配置预览上 300 字符的截断被移除,完整内容现在渲染在可滚动的框里——补上了确认框最后一处可能隐藏「即将写入什么」的地方。脱敏本身在所有使用它的位置都更严了,**包括 MCP 导入确认框**:敏感键名匹配新增 `AUTHORIZATION`、`COOKIE`、`CREDENTIAL`,以及精确匹配的 `AUTH` 与 `BEARER`;脱敏后显示的明文前缀从 8 个字符缩到 4 个;长度不超过 8 个字符的值现在整体替换,而不是原样显示。
|
||||
|
||||
最后,前端的 Base64 解码器不再裁掉首尾空白——那有可能是 URL 解码把 `+` 变成的空格。这与 v3.19.0 修过的是同一类前后端解码口径分歧:确认框显示的是一回事,导入器写进去的是另一回事。
|
||||
|
||||
---
|
||||
|
||||
## 内部
|
||||
|
||||
### 删掉 3,166 行已无调用方的代码、14 个模块与 4 个依赖
|
||||
|
||||
一轮针对「没有任何调用方」的清理。后端删除了供应商图标推断表、一个占位的健康检查器、一套从未接线的 SSE 实现(含它自己的流式与非流式处理器)、两个未使用的代理会话类型,以及四个无引用的用量解析器与一个死的成本计算入口——**线上计费路径、它的自动识别解析器与会话 ID 提取全部原封不动**。22 处 `#[allow(dead_code)]` 抑制(正是它们让编译器一直没报警)随之删除。
|
||||
|
||||
前端删除 14 个无导入方的模块,包括已被面板改版取代的提示词表单弹窗与仓库管理器、一个重复的代理配置 hook、一个在项目历史上从未有过导入方的熔断器面板,以及三个 schema 文件;它们的文案在四种语言里同步删除。这些模块背后的 Tauri 命令刻意保留。另外删除 4 个未使用的 npm 依赖。两个会重新生成手工维护的图标索引的脚本被移除,索引文件头改为写明「刻意不支持自动重生成」。
|
||||
|
||||
配套的一处改动把代理状态与接管状态合并到单一的查询层——此前有第二套并行的 hook 覆盖同样的命令,但零调用方,它的查询键从来没有观察者,针对它们的失效调用全是空转。查询键字符串逐字未变,保留下来的 hook 维持原有的轮询行为。([#5916](https://github.com/farion1231/cc-switch/pull/5916)、[#5928](https://github.com/farion1231/cc-switch/pull/5928))
|
||||
|
||||
---
|
||||
|
||||
## 升级提醒
|
||||
|
||||
### 本版没有数据库迁移
|
||||
|
||||
v3.19.1 不含 schema 迁移(版本号保持 v16),不会触发升级前备份,升级即用。
|
||||
|
||||
### Claude Desktop 双算的自愈有 30 天窗口(请读)
|
||||
|
||||
修复是在查询时抑制重复行,而不是改写或删除数据,所以**明细行还在的每一天都会在下次启动后恢复正确总数**,不需要任何重建操作。
|
||||
|
||||
但明细行超过 30 天会被聚合进日报并清理,而日报是按聚合当时生效的口径算一次就固定下来的。**已经被没有此修复的版本聚合掉的日期,会永久保留虚高的数字。** 这个回归自 v3.18.0(2026-07-21)进入,所以越早升级、能救回的历史区间越完整。
|
||||
|
||||
### 新定价对历史数据的两种不同影响
|
||||
|
||||
八个新补定价的模型会被**回溯补算**:启动时会给成本记为零的请求补上成本,因此这些模型的看板数字会**上升**。已经聚合并清理掉的明细行无法补算,保持为零。
|
||||
|
||||
四个改价的模型方向相反:补算只处理零成本行,所以已经记录的请求保持旧价,只有新请求按新价计费——同一个模型的历史花费与新增花费会不一致。两条路径都保护你自己的定价:修复只改仍是原内置值的行,而 `~/.cc-switch/model-pricing.json` 里的手工改价、models.dev 同步值与删除墓碑会在播种与修复之后重放,始终优先。
|
||||
|
||||
### 预设变更只影响新建供应商
|
||||
|
||||
已经保存的 DeepSeek 或火山方舟 Coding Plan 供应商保持它存的 API 格式,仍然需要本地路由,也仍用旧目录。想用直连,请从预设重新创建供应商,或在供应商表单的高级区把 API 格式改为原生 Responses。
|
||||
|
||||
不过,**已经是原生 Responses、且地址在 `deepseek.com` 上的供应商,下次切换时就会自动用上镜像的官方目录**,不需要重新保存——因为判定读的是 live 配置。
|
||||
|
||||
### DeepSeek V4 Pro 暂时还不能直连
|
||||
|
||||
预设里仍然列着 `deepseek-v4-pro`,厂商自己发布的目录也带着它,但 **DeepSeek 侧针对 pro 的 Codex 集成尚未开通**,官方给出的时间是 2026 年 8 月初。在那之前,直连模式下选 pro 会在上游报错——请用 `deepseek-v4-flash`,它也是预设的默认模型。
|
||||
|
||||
如果你现在就要用 pro,把这个供应商的 API 格式改回「OpenAI Chat」并开启本地路由接管即可。这正是 v3.19.1 之前 DeepSeek 一直走的那条路:本地代理会把 Codex 发出的 Responses 请求转换成 Chat Completions,pro 在这条路上不受影响。
|
||||
|
||||
### DeepSeek 官方目录的两个前提
|
||||
|
||||
镜像的目录声明了 Codex 客户端最低版本 0.144.0,**CC Switch 自己不做校验**——它携带的 freeform `apply_patch` 注册需要这个版本或更新。另外,生成的目录文件会涨到约 75 KB(两个镜像模型),因为每个条目都带着完整的提示词框架文本。
|
||||
|
||||
### 直连之后,用量的归属会从供应商名变成 `Codex (Session)`
|
||||
|
||||
DeepSeek、火山方舟 Coding Plan 与腾讯混元不再需要接管,它们的流量可以完全绕过本地代理,代理侧的逐请求记录因此看不到它们。
|
||||
|
||||
**用量本身不会丢,也仍然分得清**——Codex 的会话日志导入照常记录,只是这条路径不携带供应商身份:所有没走本地代理的 Codex 用量会一起归入名为 `Codex (Session)` 的条目,官方订阅的消耗也在这一行里。也就是说,DeepSeek 从走路由改为直连之后,它的用量会从「DeepSeek」这个名字下移到 `Codex (Session)`。
|
||||
|
||||
**要区分它们,看模型**:每条用量记录都带着自己的模型 ID,用量面板的「模型统计」按模型逐行列出——`deepseek-v4-flash`、`hy3`、`ark-code-latest` 与官方订阅的 GPT 系列各归各行,费用与 token 都是分开的。只有当你需要的正是**按供应商**这个维度(比如同一个模型在多家聚合站之间比价),才需要继续用本地路由接管——这条路会记录真实的供应商名。
|
||||
|
||||
### Codex 残留凭据清理的两个前提
|
||||
|
||||
清理只在「切入的供应商带有显式的官方分类」**且**「切出的供应商已成功回填」时执行。手工创建、没有标记官方分类的条目,或者回填失败的那次切换,残留仍会留在盘上。
|
||||
|
||||
### Grok Build 开启接管会改写后端字段
|
||||
|
||||
在 Grok Build 供应商上开启接管,现在会把 live 配置里的后端字段改写为 Responses。数据库里存的供应商记录不受影响,代理停止时 live 文件会从备份整体还原。
|
||||
|
||||
### 工具安装与升级的 PATH 变化(仅 macOS / Linux)
|
||||
|
||||
设置 → 关于里触发的每一次工具安装与升级,现在都会把登录 shell 的 PATH 并到继承的那份前面,因此生命周期脚本按名称解析到的程序有可能与之前不同;每次操作还会多启动一个 shell 来读取这份 PATH,这会执行你的交互式启动文件。**Windows 不受影响。**
|
||||
|
||||
使用 grok 0.2.112 及以上版本的用户可能会看到两份安装记录——原生的那份,加上 `grok update` 自己创建的全局 npm 包;它们由上游保持同步,版本号一致。
|
||||
|
||||
### 环境变量冲突检测的匹配口径变了
|
||||
|
||||
Claude Code、Codex、Gemini 的检测从「包含」收紧为「前缀」,因此仅仅名字里含有应用名的变量——`MY_ANTHROPIC_API_KEY`、`OLD_GEMINI_API_KEY`——**不再被报为冲突**。同时新增了 Grok Build 的检测。
|
||||
|
||||
### 深链导入确认框显示的密钥更少
|
||||
|
||||
脱敏后显示的明文前缀从 8 个字符缩到 4 个,长度不超过 8 个字符的值整体脱敏。这也影响 MCP 导入确认框。
|
||||
|
||||
---
|
||||
|
||||
## 风险提示
|
||||
|
||||
### 沿用的提示
|
||||
|
||||
**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#️-风险提示)。
|
||||
|
||||
**SuperGrok 配额查询**:供应商卡片的配额展示依赖 grok.com 的非公开计费端点,xAI 调整接口后可能失效——详见 [v3.19.0 release notes](v3.19.0-zh.md#风险提示)。
|
||||
|
||||
**第三方供应商路由**:通过 CC Switch 本地代理把 Codex、Claude Desktop 或 Grok Build 的请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。
|
||||
|
||||
用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
这一版的修复大半来自外部贡献者——六个 PR 里有五个不是我写的。
|
||||
|
||||
### 代码贡献
|
||||
|
||||
- [#5677](https://github.com/farion1231/cc-switch/pull/5677):Grok Build 的代理接管与深链集成收尾——补齐后端字段、会话身份识别、故障转移页签与环境变量检测,并顺带修好了配置预览里的密钥泄漏,感谢 @YUZHEthefool。这是本版覆盖面最广的一份工作。
|
||||
- [#5951](https://github.com/farion1231/cc-switch/pull/5951):Claude Desktop 用量双算修复,感谢 @Komikawayi。定位到 v3.18.0 的哪一处改动让三道去重防线同时失守,是本版最需要耐心的一次排查。
|
||||
- [#5916](https://github.com/farion1231/cc-switch/pull/5916)、[#5928](https://github.com/farion1231/cc-switch/pull/5928):删除 3,166 行无调用方代码与重复的代理查询层,感谢 @SaladDay。
|
||||
- [#5943](https://github.com/farion1231/cc-switch/pull/5943):补齐繁体中文的工具管理文案,并新增防止语言漂移的测试,感谢 @yovinchen。
|
||||
- [#5960](https://github.com/farion1231/cc-switch/pull/5960):补齐 9 个在所有语言下都显示简体中文的文案,感谢 @mhy1227。
|
||||
|
||||
### 问题反馈
|
||||
|
||||
感谢 @Alaric-L 在 [#5938](https://github.com/farion1231/cc-switch/issues/5938) 中报告 Claude Desktop 的每次请求都多出一条 `session_log` 来源的日志、导致 token 被统计两遍——这条反馈精确到了数据来源,本版最重要的用量修复直接由它定位。
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [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.1-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.19.1-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
Windows ARM64 设备请选择文件名中带 `arm64` 标识的对应制品。
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------- |
|
||||
| `CC-Switch-v3.19.1-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||
| `CC-Switch-v3.19.1-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||
| `CC-Switch-v3.19.1-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.1-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.19.1-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.1",
|
||||
"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');
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates the website download manifest (manifest.json) from a directory of
|
||||
// downloaded release assets. Consumed by ccswitch.io/download. The manifest
|
||||
// schema is mirrored in cc-switch-website/src/lib/downloads.ts — keep both in
|
||||
// sync when changing fields or classification rules.
|
||||
//
|
||||
// Usage: node scripts/generate-download-manifest.mjs <assets-dir> <tag> <base-url> [output] [pub-date]
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const [assetsDir, tag, baseUrl, output = 'manifest.json', pubDateArg] = process.argv.slice(2);
|
||||
|
||||
if (!assetsDir || !tag || !baseUrl) {
|
||||
console.error('Usage: node scripts/generate-download-manifest.mjs <assets-dir> <tag> <base-url> [output] [pub-date]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Prefer the release's real publishedAt (passed by CI) over generation time.
|
||||
const pubDate = pubDateArg ? new Date(pubDateArg) : new Date();
|
||||
if (Number.isNaN(pubDate.getTime())) {
|
||||
console.error(`Invalid pub-date: ${pubDateArg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Longer suffixes must come before their shorter counterparts
|
||||
// (e.g. -Windows-arm64.msi before -Windows.msi).
|
||||
const RULES = [
|
||||
{ suffix: '-macOS.dmg', platform: 'macos', kind: 'dmg', arch: 'universal' },
|
||||
{ suffix: '-macOS.zip', platform: 'macos', kind: 'zip', arch: 'universal' },
|
||||
{ suffix: '-Windows-arm64-Portable.zip', platform: 'windows', kind: 'portable', arch: 'arm64' },
|
||||
{ suffix: '-Windows-Portable.zip', platform: 'windows', kind: 'portable', arch: 'x64' },
|
||||
{ suffix: '-Windows-arm64.msi', platform: 'windows', kind: 'msi', arch: 'arm64' },
|
||||
{ suffix: '-Windows.msi', platform: 'windows', kind: 'msi', arch: 'x64' },
|
||||
{ suffix: '-Linux-arm64.AppImage', platform: 'linux', kind: 'appimage', arch: 'arm64' },
|
||||
{ suffix: '-Linux-x86_64.AppImage', platform: 'linux', kind: 'appimage', arch: 'x64' },
|
||||
{ suffix: '-Linux-arm64.deb', platform: 'linux', kind: 'deb', arch: 'arm64' },
|
||||
{ suffix: '-Linux-x86_64.deb', platform: 'linux', kind: 'deb', arch: 'x64' },
|
||||
{ suffix: '-Linux-arm64.rpm', platform: 'linux', kind: 'rpm', arch: 'arm64' },
|
||||
{ suffix: '-Linux-x86_64.rpm', platform: 'linux', kind: 'rpm', arch: 'x64' },
|
||||
];
|
||||
|
||||
const normalizedBase = baseUrl.replace(/\/+$/, '');
|
||||
const files = [];
|
||||
|
||||
for (const name of readdirSync(assetsDir).sort()) {
|
||||
// Unmatched files (.sig, .tar.gz updater artifacts, latest.json) are
|
||||
// deliberately skipped — they are not user-facing downloads.
|
||||
const rule = RULES.find((entry) => name.endsWith(entry.suffix));
|
||||
if (!rule) continue;
|
||||
const path = join(assetsDir, name);
|
||||
files.push({
|
||||
platform: rule.platform,
|
||||
kind: rule.kind,
|
||||
arch: rule.arch,
|
||||
name,
|
||||
size: statSync(path).size,
|
||||
sha256: createHash('sha256').update(readFileSync(path)).digest('hex'),
|
||||
url: `${normalizedBase}/${tag}/${encodeURIComponent(name)}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error(`No release assets matched in ${assetsDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: tag.replace(/^v/, ''),
|
||||
tag,
|
||||
pubDate: pubDate.toISOString(),
|
||||
files,
|
||||
};
|
||||
|
||||
writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(`Wrote ${output} with ${files.length} files for ${tag}`);
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
// Rewrites the Tauri updater manifest (latest.json) so its download URLs point
|
||||
// at the R2 mirror instead of GitHub Releases. Minisign signatures cover file
|
||||
// contents, not URLs, so they stay valid unchanged — the updater still verifies
|
||||
// every downloaded artifact against the pubkey baked into the app.
|
||||
//
|
||||
// Usage: node scripts/rewrite-updater-manifest.mjs <latest-json> <tag> <base-url> [output]
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const [input, tag, baseUrl, output = 'latest-r2.json'] = process.argv.slice(2);
|
||||
|
||||
if (!input || !tag || !baseUrl) {
|
||||
console.error('Usage: node scripts/rewrite-updater-manifest.mjs <latest-json> <tag> <base-url> [output]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(readFileSync(input, 'utf8'));
|
||||
const platforms = Object.entries(manifest.platforms ?? {});
|
||||
if (platforms.length === 0) {
|
||||
console.error(`No platforms found in ${input}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const normalizedBase = baseUrl.replace(/\/+$/, '');
|
||||
// Mirrored artifacts live at <base>/<tag>/<file>; on GitHub they live at
|
||||
// .../releases/download/<tag>/<file>. Rebuild from the filename rather than
|
||||
// prefix-replacing so a layout change on either side fails loudly here.
|
||||
const githubPrefix = `https://github.com/`;
|
||||
|
||||
for (const [key, entry] of platforms) {
|
||||
if (typeof entry?.url !== 'string' || typeof entry?.signature !== 'string') {
|
||||
console.error(`Platform ${key} is missing url or signature`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!entry.url.startsWith(githubPrefix) || !entry.url.includes(`/releases/download/${tag}/`)) {
|
||||
console.error(`Platform ${key} has unexpected url for ${tag}: ${entry.url}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const name = entry.url.split('/').pop();
|
||||
entry.url = `${normalizedBase}/${tag}/${name}`;
|
||||
}
|
||||
|
||||
writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(`Wrote ${output} with ${platforms.length} platforms pointing at ${normalizedBase}/${tag}/`);
|
||||
Generated
+1
-1
@@ -758,7 +758,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.18.0"
|
||||
version = "3.19.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.18.0"
|
||||
version = "3.19.1"
|
||||
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"] }
|
||||
|
||||
@@ -32,8 +32,8 @@ pub const ANTHROPIC_CLAUDE_ROUTE_PREFIX: &str = "anthropic/claude-";
|
||||
/// Claude Desktop schema 不接受此后缀,import 边界翻译为 `supports1m` 字段。
|
||||
pub const ONE_M_CONTEXT_MARKER: &str = "[1m]";
|
||||
|
||||
const CURRENT_OPUS_ROUTE_ID: &str = "claude-opus-4-8";
|
||||
const LEGACY_OPUS_ROUTE_ID: &str = "claude-opus-4-7";
|
||||
const CURRENT_OPUS_ROUTE_ID: &str = "claude-opus-5";
|
||||
const LEGACY_OPUS_ROUTE_ID: &str = "claude-opus-4-8";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -1989,7 +1989,7 @@ mod tests {
|
||||
.iter()
|
||||
.find(|route| route.upstream_model == "deepseek-v4-pro")
|
||||
.expect("repaired route");
|
||||
assert_eq!(repaired.route_id, "claude-opus-4-8");
|
||||
assert_eq!(repaired.route_id, "claude-opus-5");
|
||||
assert_eq!(repaired.label_override.as_deref(), Some("deepseek-v4-pro"));
|
||||
assert!(repaired.supports_1m);
|
||||
let repaired_old = routes
|
||||
|
||||
+599
-43
@@ -417,6 +417,93 @@ pub fn codex_auth_has_oauth_login_material(auth: &Value) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// True only when the auth carries material Codex itself authenticates with
|
||||
/// ahead of the API-key fallback: OAuth tokens or another first-class login
|
||||
/// carrier. Unlike `codex_auth_has_oauth_login_material`, pure metadata such
|
||||
/// as `last_refresh` or `tokens.account_id` does NOT count — metadata must not
|
||||
/// shield a stale third-party `OPENAI_API_KEY` from post-switch cleanup.
|
||||
pub fn codex_auth_has_credential_login_material(auth: &Value) -> bool {
|
||||
let Some(obj) = auth.as_object() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let value_present = |value: &Value| match value {
|
||||
Value::Null => false,
|
||||
Value::String(text) => !text.trim().is_empty(),
|
||||
Value::Array(items) => !items.is_empty(),
|
||||
Value::Object(map) => !map.is_empty(),
|
||||
_ => true,
|
||||
};
|
||||
|
||||
if ["personal_access_token", "agent_identity", "bedrock_api_key"]
|
||||
.iter()
|
||||
.any(|key| obj.get(*key).is_some_and(value_present))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
obj.get("tokens")
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|tokens| {
|
||||
["id_token", "access_token", "refresh_token"]
|
||||
.iter()
|
||||
.any(|key| tokens.get(*key).is_some_and(value_present))
|
||||
})
|
||||
}
|
||||
|
||||
/// True when live `auth.json` is the shape a preserve-off third-party switch
|
||||
/// leaves behind: an `OPENAI_API_KEY` (possibly alongside metadata like
|
||||
/// `auth_mode` / `last_refresh`) with no real login credential next to it.
|
||||
pub fn codex_live_auth_is_stale_third_party_residue(live_auth: &Value) -> bool {
|
||||
if codex_auth_has_credential_login_material(live_auth) {
|
||||
return false;
|
||||
}
|
||||
live_auth
|
||||
.get("OPENAI_API_KEY")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.is_some_and(|key| !key.is_empty())
|
||||
}
|
||||
|
||||
/// After a normal switch to an official provider that carries no login
|
||||
/// material of its own, delete a live `auth.json` that only holds a stale
|
||||
/// third-party API key, so Codex shows its login screen instead of sending
|
||||
/// the wrong key to the official endpoint (401 with no way to re-login).
|
||||
///
|
||||
/// Deleting the file — not writing `{}` — is deliberate: Codex resolves an
|
||||
/// empty object to ChatGPT mode without tokens and errors at bootstrap,
|
||||
/// while a missing file yields NotAuthenticated and the login screen,
|
||||
/// matching Codex's own logout.
|
||||
///
|
||||
/// Callers must only invoke this after the outgoing provider was
|
||||
/// successfully backfilled into the DB — that backfill holds the only other
|
||||
/// copy of the third-party key. The switch backfill intentionally lacks the
|
||||
/// proxy-side "no credentials in the builtin official row" guard
|
||||
/// (`services/proxy.rs` `sync_live_config_to_provider`): that asymmetry is
|
||||
/// what heals official API-key logins into the DB row, and this cleanup's
|
||||
/// safety depends on it — do not align the two guards.
|
||||
///
|
||||
/// Returns Ok(true) when the file was deleted.
|
||||
pub fn clear_stale_codex_live_auth_after_official_switch(
|
||||
db_auth: &Value,
|
||||
) -> Result<bool, AppError> {
|
||||
if codex_auth_has_login_material(db_auth) {
|
||||
// A material-carrying official provider gets a full auth write;
|
||||
// nothing stale can remain.
|
||||
return Ok(false);
|
||||
}
|
||||
let auth_path = get_codex_auth_path();
|
||||
if !auth_path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
let live_auth: Value = read_json_file(&auth_path)?;
|
||||
if !codex_live_auth_is_stale_third_party_residue(&live_auth) {
|
||||
return Ok(false);
|
||||
}
|
||||
delete_file(&auth_path)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn should_restore_codex_provider_token_for_backfill(
|
||||
category: Option<&str>,
|
||||
template_settings: &Value,
|
||||
@@ -466,17 +553,20 @@ fn codex_catalog_model_entry(
|
||||
spec: &CodexCatalogModelSpec,
|
||||
priority: usize,
|
||||
profile: CodexCatalogToolProfile,
|
||||
default_context_window: u64,
|
||||
) -> Value {
|
||||
let mut entry = template.clone();
|
||||
let Some(entry_obj) = entry.as_object_mut() else {
|
||||
return json!({});
|
||||
};
|
||||
|
||||
let display_name = spec.display_name.as_deref().unwrap_or(&spec.model);
|
||||
let context_window = spec.context_window.unwrap_or(default_context_window);
|
||||
entry_obj.insert("slug".to_string(), json!(spec.model));
|
||||
entry_obj.insert("display_name".to_string(), json!(spec.display_name));
|
||||
entry_obj.insert("description".to_string(), json!(spec.display_name));
|
||||
entry_obj.insert("context_window".to_string(), json!(spec.context_window));
|
||||
entry_obj.insert("max_context_window".to_string(), json!(spec.context_window));
|
||||
entry_obj.insert("display_name".to_string(), json!(display_name));
|
||||
entry_obj.insert("description".to_string(), json!(display_name));
|
||||
entry_obj.insert("context_window".to_string(), json!(context_window));
|
||||
entry_obj.insert("max_context_window".to_string(), json!(context_window));
|
||||
entry_obj.insert("priority".to_string(), json!(1000 + priority));
|
||||
entry_obj.insert("additional_speed_tiers".to_string(), json!([]));
|
||||
entry_obj.insert("service_tiers".to_string(), json!([]));
|
||||
@@ -535,8 +625,13 @@ fn codex_catalog_model_entry(
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct CodexCatalogModelSpec {
|
||||
model: String,
|
||||
display_name: String,
|
||||
context_window: u64,
|
||||
/// Explicit user value only. Entries fall back to the model id — except
|
||||
/// official vendor catalog entries, which keep the vendor's display name.
|
||||
display_name: Option<String>,
|
||||
/// Explicit user value only. Entries fall back to the config's
|
||||
/// `model_context_window` (or 128k) — except official vendor catalog
|
||||
/// entries, which keep the vendor's declared window.
|
||||
context_window: Option<u64>,
|
||||
/// Per-row override for the native template's `supports_parallel_tool_calls`
|
||||
/// (e.g. MiniMax=true, MiMo=false). Only consulted for `NativeResponses`.
|
||||
supports_parallel_tool_calls: Option<bool>,
|
||||
@@ -552,7 +647,7 @@ struct CodexCatalogModelSpec {
|
||||
base_instructions: Option<String>,
|
||||
}
|
||||
|
||||
fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCatalogModelSpec> {
|
||||
fn codex_catalog_model_specs(settings: &Value) -> Vec<CodexCatalogModelSpec> {
|
||||
let Some(models) = settings
|
||||
.get("modelCatalog")
|
||||
.and_then(|catalog| catalog.get("models"))
|
||||
@@ -561,8 +656,6 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCa
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let default_context_window =
|
||||
extract_codex_top_level_u64(config_text, "model_context_window").unwrap_or(128_000);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut specs = Vec::new();
|
||||
|
||||
@@ -586,13 +679,12 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCa
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or(model);
|
||||
.map(str::to_string);
|
||||
let context_window = parse_codex_positive_u64(
|
||||
model_config
|
||||
.get("contextWindow")
|
||||
.or_else(|| model_config.get("context_window")),
|
||||
)
|
||||
.unwrap_or(default_context_window);
|
||||
);
|
||||
|
||||
let supports_parallel_tool_calls = model_config
|
||||
.get("supportsParallelToolCalls")
|
||||
@@ -621,7 +713,7 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCa
|
||||
|
||||
specs.push(CodexCatalogModelSpec {
|
||||
model: model.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
display_name,
|
||||
context_window,
|
||||
supports_parallel_tool_calls,
|
||||
input_modalities,
|
||||
@@ -891,6 +983,122 @@ fn load_codex_native_responses_template() -> Value {
|
||||
serde_json::from_str(text).expect("bundled codex native responses template must be valid JSON")
|
||||
}
|
||||
|
||||
/// Hosts whose native `/responses` gateway publishes an OFFICIAL Codex model
|
||||
/// catalog (models.json) that cc-switch mirrors verbatim. Matched against
|
||||
/// `base_url` ONLY — deliberately NOT by model brand, unlike
|
||||
/// `CODEX_WEB_SEARCH_REJECT_MODEL_PREFIXES`: the official entries GRANT
|
||||
/// capabilities (freeform `apply_patch`, vendor harness), and an aggregator
|
||||
/// merely hosting the same model may not honor them. The safe failure
|
||||
/// direction for aggregators is the neutral template (degraded but working);
|
||||
/// wrongly granting freeform apply_patch would reintroduce the custom-tool
|
||||
/// rejection bug.
|
||||
const CODEX_DEEPSEEK_OFFICIAL_CATALOG_HOSTS: &[&str] = &["deepseek.com"];
|
||||
|
||||
/// Bundled copy of DeepSeek's official Codex models.json — the exact file
|
||||
/// their one-click integration script writes (api-docs.deepseek.com →
|
||||
/// quick_start/agent_integrations/codex): freeform apply_patch, GPT-5 harness
|
||||
/// base_instructions, low/high/max reasoning levels, web_search supported,
|
||||
/// 1m context. Declares `minimal_client_version` 0.144.0.
|
||||
fn load_codex_deepseek_official_catalog_models() -> Vec<Value> {
|
||||
let text = include_str!("resources/codex_deepseek_catalog_template.json");
|
||||
let catalog: Value =
|
||||
serde_json::from_str(text).expect("bundled DeepSeek official catalog must be valid JSON");
|
||||
catalog
|
||||
.get("models")
|
||||
.and_then(|models| models.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Official vendor catalog entries for the provider in `config_text`, if its
|
||||
/// gateway ships one. Only the `NativeResponses` profile qualifies: ProxyChat
|
||||
/// runs through cc-switch's converter (gpt-5.5 template contract) and the
|
||||
/// Anthropic transform drops custom tools, so both must keep their existing
|
||||
/// templates. Host-driven like the web_search blacklist, so existing providers
|
||||
/// pick it up on their next switch without a re-save.
|
||||
fn codex_official_vendor_catalog_models(
|
||||
config_text: &str,
|
||||
profile: CodexCatalogToolProfile,
|
||||
) -> Option<Vec<Value>> {
|
||||
if profile != CodexCatalogToolProfile::NativeResponses {
|
||||
return None;
|
||||
}
|
||||
let base_url = extract_codex_base_url(config_text)?.to_ascii_lowercase();
|
||||
if CODEX_DEEPSEEK_OFFICIAL_CATALOG_HOSTS
|
||||
.iter()
|
||||
.any(|host| base_url.contains(host))
|
||||
{
|
||||
let models = load_codex_deepseek_official_catalog_models();
|
||||
if !models.is_empty() {
|
||||
return Some(models);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build one catalog entry from an official vendor catalog: match the user's
|
||||
/// model id against the vendor entries by slug; an unknown id clones the
|
||||
/// vendor's first (flagship) entry so it keeps the gateway's capability
|
||||
/// profile without impersonating the flagship. The official entry is
|
||||
/// authoritative — no tool-profile stripping — but explicit per-row user
|
||||
/// overrides still win.
|
||||
fn codex_vendor_catalog_model_entry(
|
||||
vendor_models: &[Value],
|
||||
spec: &CodexCatalogModelSpec,
|
||||
priority: usize,
|
||||
) -> Value {
|
||||
let matched = vendor_models.iter().find(|entry| {
|
||||
entry
|
||||
.get("slug")
|
||||
.and_then(|slug| slug.as_str())
|
||||
.is_some_and(|slug| slug.eq_ignore_ascii_case(&spec.model))
|
||||
});
|
||||
let mut entry = match matched {
|
||||
Some(found) => found.clone(),
|
||||
None => vendor_models.first().cloned().unwrap_or_else(|| json!({})),
|
||||
};
|
||||
let Some(entry_obj) = entry.as_object_mut() else {
|
||||
return json!({});
|
||||
};
|
||||
|
||||
if matched.is_none() {
|
||||
let display_name = spec.display_name.as_deref().unwrap_or(&spec.model);
|
||||
entry_obj.insert("slug".to_string(), json!(spec.model));
|
||||
entry_obj.insert("display_name".to_string(), json!(display_name));
|
||||
entry_obj.insert("description".to_string(), json!(display_name));
|
||||
entry_obj.insert("priority".to_string(), json!(1000 + priority));
|
||||
}
|
||||
|
||||
// Explicit user overrides win over the official entry; absent values keep
|
||||
// the vendor's declarations (context window, modalities, harness, ...).
|
||||
if let Some(display_name) = spec.display_name.as_deref() {
|
||||
entry_obj.insert("display_name".to_string(), json!(display_name));
|
||||
}
|
||||
if let Some(context_window) = spec.context_window {
|
||||
entry_obj.insert("context_window".to_string(), json!(context_window));
|
||||
entry_obj.insert("max_context_window".to_string(), json!(context_window));
|
||||
}
|
||||
if let Some(parallel) = spec.supports_parallel_tool_calls {
|
||||
entry_obj.insert("supports_parallel_tool_calls".to_string(), json!(parallel));
|
||||
}
|
||||
if let Some(modalities) = spec.input_modalities.as_deref() {
|
||||
entry_obj.insert("input_modalities".to_string(), json!(modalities));
|
||||
}
|
||||
if let Some(base_instructions) = spec
|
||||
.base_instructions
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
{
|
||||
entry_obj.insert("base_instructions".to_string(), json!(base_instructions));
|
||||
}
|
||||
|
||||
// Defensive: if a future codex parser requires a field the vendor file
|
||||
// predates, backfill only whitelisted parser-required keys.
|
||||
fill_template_fields_from_static(&mut entry);
|
||||
entry
|
||||
}
|
||||
|
||||
/// Fields Codex's external-catalog parser REQUIRES (no serde default): when
|
||||
/// one is missing Codex rejects the whole catalog file at startup ("missing
|
||||
/// field ..."). `base_instructions` is the other known required field; the
|
||||
@@ -973,11 +1181,14 @@ fn codex_model_catalog_from_specs(
|
||||
specs: &[CodexCatalogModelSpec],
|
||||
template: &Value,
|
||||
profile: CodexCatalogToolProfile,
|
||||
default_context_window: u64,
|
||||
) -> Value {
|
||||
let entries: Vec<Value> = specs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, spec)| codex_catalog_model_entry(template, spec, index, profile))
|
||||
.map(|(index, spec)| {
|
||||
codex_catalog_model_entry(template, spec, index, profile, default_context_window)
|
||||
})
|
||||
.collect();
|
||||
|
||||
json!({ "models": entries })
|
||||
@@ -988,11 +1199,28 @@ fn codex_model_catalog_from_settings(
|
||||
config_text: &str,
|
||||
profile: CodexCatalogToolProfile,
|
||||
) -> Result<Option<Value>, AppError> {
|
||||
let specs = codex_catalog_model_specs(settings, config_text);
|
||||
let specs = codex_catalog_model_specs(settings);
|
||||
if specs.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Vendors that publish an OFFICIAL Codex models.json for their native
|
||||
// `/responses` gateway get it mirrored verbatim instead of the neutral
|
||||
// template: its freeform apply_patch, vendor harness base_instructions and
|
||||
// reasoning levels are load-bearing (the harness tells the model to use
|
||||
// apply_patch, so catalog and harness must stay consistent).
|
||||
if let Some(vendor_models) = codex_official_vendor_catalog_models(config_text, profile) {
|
||||
let entries: Vec<Value> = specs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, spec)| codex_vendor_catalog_model_entry(&vendor_models, spec, index))
|
||||
.collect();
|
||||
return Ok(Some(json!({ "models": entries })));
|
||||
}
|
||||
|
||||
let default_context_window =
|
||||
extract_codex_top_level_u64(config_text, "model_context_window").unwrap_or(128_000);
|
||||
|
||||
// Native providers use the bundled clean template (no freeform apply_patch,
|
||||
// no cache dependency); proxy-chat providers keep cloning Codex's gpt-5.5
|
||||
// entry so the proxy can rewrite custom<->function tools as before.
|
||||
@@ -1003,7 +1231,10 @@ fn codex_model_catalog_from_settings(
|
||||
CodexCatalogToolProfile::ProxyChat => load_codex_model_catalog_template()?,
|
||||
};
|
||||
Ok(Some(codex_model_catalog_from_specs(
|
||||
&specs, &template, profile,
|
||||
&specs,
|
||||
&template,
|
||||
profile,
|
||||
default_context_window,
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -1424,7 +1655,9 @@ fn remove_codex_experimental_bearer_token(config_text: &str) -> Result<String, A
|
||||
/// Read the current Codex live settings as a `{ auth, config }` object.
|
||||
///
|
||||
/// Missing `auth.json` collapses to `{}` so a config-only third-party install
|
||||
/// is still importable; both files empty is treated as "no live install".
|
||||
/// is still importable; both files missing is treated as "no live install".
|
||||
/// A `config.toml` that exists but is empty is a valid state — e.g. the
|
||||
/// official seed after stale-auth cleanup — and must stay readable.
|
||||
pub fn read_codex_live_settings() -> Result<Value, AppError> {
|
||||
let auth_path = get_codex_auth_path();
|
||||
let auth_present = auth_path.exists();
|
||||
@@ -1434,7 +1667,7 @@ pub fn read_codex_live_settings() -> Result<Value, AppError> {
|
||||
json!({})
|
||||
};
|
||||
let cfg_text = read_and_validate_codex_config_text()?;
|
||||
if !auth_present && cfg_text.trim().is_empty() {
|
||||
if !auth_present && !get_codex_config_path().exists() {
|
||||
return Err(AppError::localized(
|
||||
"codex.live.missing",
|
||||
"Codex 配置文件不存在",
|
||||
@@ -1908,25 +2141,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
|
||||
@@ -2375,6 +2636,65 @@ experimental_bearer_token = "stale-table-key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_login_material_only_counts_real_credentials() {
|
||||
assert!(codex_auth_has_credential_login_material(&json!({
|
||||
"tokens": { "access_token": "t" }
|
||||
})));
|
||||
assert!(codex_auth_has_credential_login_material(&json!({
|
||||
"tokens": { "refresh_token": "r" }
|
||||
})));
|
||||
assert!(codex_auth_has_credential_login_material(&json!({
|
||||
"personal_access_token": "pat"
|
||||
})));
|
||||
|
||||
// API key and pure metadata are not credentials in this predicate's
|
||||
// sense — they must not shield a stale key from cleanup.
|
||||
assert!(!codex_auth_has_credential_login_material(&json!({
|
||||
"OPENAI_API_KEY": "sk-x"
|
||||
})));
|
||||
assert!(!codex_auth_has_credential_login_material(&json!({
|
||||
"OPENAI_API_KEY": "sk-x",
|
||||
"last_refresh": "2026-01-01T00:00:00Z",
|
||||
"tokens": { "account_id": "acct-meta-only" }
|
||||
})));
|
||||
assert!(!codex_auth_has_credential_login_material(&json!({})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_third_party_residue_detection() {
|
||||
// Shapes a preserve-off third-party switch leaves behind: cleared.
|
||||
assert!(codex_live_auth_is_stale_third_party_residue(&json!({
|
||||
"OPENAI_API_KEY": "sk-third-party"
|
||||
})));
|
||||
assert!(codex_live_auth_is_stale_third_party_residue(&json!({
|
||||
"auth_mode": "apikey",
|
||||
"OPENAI_API_KEY": "sk-third-party"
|
||||
})));
|
||||
assert!(codex_live_auth_is_stale_third_party_residue(&json!({
|
||||
"OPENAI_API_KEY": "sk-third-party",
|
||||
"last_refresh": "2026-01-01T00:00:00Z",
|
||||
"tokens": { "account_id": "acct-meta-only" }
|
||||
})));
|
||||
|
||||
// Anything carrying a real credential must survive untouched.
|
||||
assert!(!codex_live_auth_is_stale_third_party_residue(&json!({
|
||||
"OPENAI_API_KEY": "sk-x",
|
||||
"tokens": { "access_token": "t" }
|
||||
})));
|
||||
assert!(!codex_live_auth_is_stale_third_party_residue(&json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": { "access_token": "official-oauth-token" }
|
||||
})));
|
||||
|
||||
// Nothing to clear.
|
||||
assert!(!codex_live_auth_is_stale_third_party_residue(&json!({})));
|
||||
assert!(!codex_live_auth_is_stale_third_party_residue(&json!({
|
||||
"OPENAI_API_KEY": ""
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_provider_live_config_does_not_create_incomplete_provider_table() {
|
||||
let input = r#"model_provider = "vendor_x"
|
||||
@@ -2585,6 +2905,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"
|
||||
@@ -2779,14 +3127,18 @@ base_url = "https://production.api/v1"
|
||||
fill_template_fields_from_static(&mut template);
|
||||
let specs = vec![CodexCatalogModelSpec {
|
||||
model: "k3".to_string(),
|
||||
display_name: "Kimi K3".to_string(),
|
||||
context_window: 262_144,
|
||||
display_name: Some("Kimi K3".to_string()),
|
||||
context_window: Some(262_144),
|
||||
supports_parallel_tool_calls: None,
|
||||
input_modalities: None,
|
||||
base_instructions: None,
|
||||
}];
|
||||
let catalog =
|
||||
codex_model_catalog_from_specs(&specs, &template, CodexCatalogToolProfile::ProxyChat);
|
||||
let catalog = codex_model_catalog_from_specs(
|
||||
&specs,
|
||||
&template,
|
||||
CodexCatalogToolProfile::ProxyChat,
|
||||
128_000,
|
||||
);
|
||||
assert_eq!(
|
||||
catalog["models"][0]
|
||||
.get("supports_reasoning_summaries")
|
||||
@@ -2842,9 +3194,13 @@ base_url = "https://production.api/v1"
|
||||
]
|
||||
}
|
||||
});
|
||||
let specs = codex_catalog_model_specs(&settings, r#"model_context_window = 128000"#);
|
||||
let catalog =
|
||||
codex_model_catalog_from_specs(&specs, &template, CodexCatalogToolProfile::ProxyChat);
|
||||
let specs = codex_catalog_model_specs(&settings);
|
||||
let catalog = codex_model_catalog_from_specs(
|
||||
&specs,
|
||||
&template,
|
||||
CodexCatalogToolProfile::ProxyChat,
|
||||
128_000,
|
||||
);
|
||||
let models = catalog
|
||||
.get("models")
|
||||
.and_then(|value| value.as_array())
|
||||
@@ -2977,40 +3333,40 @@ base_url = "https://production.api/v1"
|
||||
let specs = vec![
|
||||
CodexCatalogModelSpec {
|
||||
model: "gpt-5.4".to_string(),
|
||||
display_name: "GPT 5.4".to_string(),
|
||||
context_window: 128_000,
|
||||
display_name: Some("GPT 5.4".to_string()),
|
||||
context_window: Some(128_000),
|
||||
supports_parallel_tool_calls: None,
|
||||
input_modalities: None,
|
||||
base_instructions: None,
|
||||
},
|
||||
CodexCatalogModelSpec {
|
||||
model: "deepseek/deepseek-v4-pro".to_string(),
|
||||
display_name: "DeepSeek V4 Pro".to_string(),
|
||||
context_window: 128_000,
|
||||
display_name: Some("DeepSeek V4 Pro".to_string()),
|
||||
context_window: Some(128_000),
|
||||
supports_parallel_tool_calls: None,
|
||||
input_modalities: None,
|
||||
base_instructions: None,
|
||||
},
|
||||
CodexCatalogModelSpec {
|
||||
model: "glm-5.2v".to_string(),
|
||||
display_name: "GLM 5.2V".to_string(),
|
||||
context_window: 128_000,
|
||||
display_name: Some("GLM 5.2V".to_string()),
|
||||
context_window: Some(128_000),
|
||||
supports_parallel_tool_calls: None,
|
||||
input_modalities: None,
|
||||
base_instructions: None,
|
||||
},
|
||||
CodexCatalogModelSpec {
|
||||
model: "deepseek-v4-flash".to_string(),
|
||||
display_name: "Explicit Visual Override".to_string(),
|
||||
context_window: 128_000,
|
||||
display_name: Some("Explicit Visual Override".to_string()),
|
||||
context_window: Some(128_000),
|
||||
supports_parallel_tool_calls: None,
|
||||
input_modalities: Some(vec!["text".to_string(), "image".to_string()]),
|
||||
base_instructions: None,
|
||||
},
|
||||
CodexCatalogModelSpec {
|
||||
model: "custom-text-alias".to_string(),
|
||||
display_name: "Explicit Text Override".to_string(),
|
||||
context_window: 128_000,
|
||||
display_name: Some("Explicit Text Override".to_string()),
|
||||
context_window: Some(128_000),
|
||||
supports_parallel_tool_calls: None,
|
||||
input_modalities: Some(vec!["text".to_string()]),
|
||||
base_instructions: None,
|
||||
@@ -3022,7 +3378,7 @@ base_url = "https://production.api/v1"
|
||||
CodexCatalogToolProfile::NativeResponses,
|
||||
CodexCatalogToolProfile::Anthropic,
|
||||
] {
|
||||
let catalog = codex_model_catalog_from_specs(&specs, &template, profile);
|
||||
let catalog = codex_model_catalog_from_specs(&specs, &template, profile, 128_000);
|
||||
let models = catalog["models"].as_array().expect("models array");
|
||||
let modalities = |slug: &str| {
|
||||
models
|
||||
@@ -3073,6 +3429,205 @@ base_url = "https://production.api/v1"
|
||||
);
|
||||
}
|
||||
|
||||
const DEEPSEEK_NATIVE_CONFIG: &str = r#"model = "deepseek-v4-flash"
|
||||
model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "deepseek"
|
||||
base_url = "https://api.deepseek.com"
|
||||
wire_api = "responses"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn deepseek_host_native_catalog_mirrors_official_entries() {
|
||||
// DeepSeek publishes an official Codex models.json (freeform
|
||||
// apply_patch + GPT-5 harness + low/high/max reasoning levels). For a
|
||||
// deepseek.com native provider the generated catalog must mirror it
|
||||
// verbatim instead of the stripped neutral template — the harness
|
||||
// tells the model to use apply_patch, so stripping the tool while
|
||||
// keeping the harness would be self-inconsistent.
|
||||
let settings = json!({
|
||||
"modelCatalog": {
|
||||
"models": [
|
||||
{ "model": "deepseek-v4-flash", "displayName": "DeepSeek V4 Flash" },
|
||||
{ "model": "deepseek-v4-pro", "contextWindow": 500_000 }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
let catalog = codex_model_catalog_from_settings(
|
||||
&settings,
|
||||
DEEPSEEK_NATIVE_CONFIG,
|
||||
CodexCatalogToolProfile::NativeResponses,
|
||||
)
|
||||
.expect("vendor catalog generation should not error")
|
||||
.expect("non-empty modelCatalog must yield a catalog");
|
||||
|
||||
let flash = &catalog["models"][0];
|
||||
assert_eq!(
|
||||
flash.get("slug").and_then(|v| v.as_str()),
|
||||
Some("deepseek-v4-flash")
|
||||
);
|
||||
assert_eq!(
|
||||
flash.get("apply_patch_tool_type").and_then(|v| v.as_str()),
|
||||
Some("freeform"),
|
||||
"official DeepSeek entries keep the freeform apply_patch grant"
|
||||
);
|
||||
assert!(
|
||||
flash
|
||||
.get("base_instructions")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|s| s.starts_with("You are Codex, an agent based on GPT-5")),
|
||||
"official GPT-5 harness must survive verbatim"
|
||||
);
|
||||
let efforts: Vec<&str> = flash["supported_reasoning_levels"]
|
||||
.as_array()
|
||||
.expect("official reasoning levels array")
|
||||
.iter()
|
||||
.filter_map(|level| level.get("effort").and_then(|v| v.as_str()))
|
||||
.collect();
|
||||
assert_eq!(efforts, vec!["low", "high", "max"]);
|
||||
assert_eq!(flash.get("supports_search_tool"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
flash.get("web_search_tool_type").and_then(|v| v.as_str()),
|
||||
Some("text")
|
||||
);
|
||||
assert_eq!(
|
||||
flash.get("supports_reasoning_summaries"),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(flash.get("input_modalities"), Some(&json!(["text"])));
|
||||
assert!(
|
||||
flash.get("model_messages").is_some(),
|
||||
"official entries are mirrored verbatim, incl. model_messages"
|
||||
);
|
||||
// No explicit contextWindow on the row: the official 1m window must
|
||||
// survive instead of being clobbered by the 128k default.
|
||||
assert_eq!(
|
||||
flash.get("context_window").and_then(|v| v.as_u64()),
|
||||
Some(1_048_576)
|
||||
);
|
||||
// Explicit user display name still wins over the official one.
|
||||
assert_eq!(
|
||||
flash.get("display_name").and_then(|v| v.as_str()),
|
||||
Some("DeepSeek V4 Flash")
|
||||
);
|
||||
|
||||
let pro = &catalog["models"][1];
|
||||
assert_eq!(
|
||||
pro.get("slug").and_then(|v| v.as_str()),
|
||||
Some("deepseek-v4-pro")
|
||||
);
|
||||
// Explicit user context window override wins…
|
||||
assert_eq!(
|
||||
pro.get("context_window").and_then(|v| v.as_u64()),
|
||||
Some(500_000)
|
||||
);
|
||||
assert_eq!(
|
||||
pro.get("max_context_window").and_then(|v| v.as_u64()),
|
||||
Some(500_000)
|
||||
);
|
||||
// …while the untouched official display name is kept.
|
||||
assert_eq!(
|
||||
pro.get("display_name").and_then(|v| v.as_str()),
|
||||
Some("DeepSeek-V4-Pro")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_official_catalog_unknown_model_clones_flagship() {
|
||||
// A user-added model id the official file doesn't know keeps the
|
||||
// gateway's capability profile (clone of the flagship entry) without
|
||||
// impersonating it: own slug/name, demoted priority, and the official
|
||||
// context window rather than the 128k synthetic default.
|
||||
let settings = json!({
|
||||
"modelCatalog": { "models": [{ "model": "deepseek-v4-lite" }] }
|
||||
});
|
||||
|
||||
let catalog = codex_model_catalog_from_settings(
|
||||
&settings,
|
||||
DEEPSEEK_NATIVE_CONFIG,
|
||||
CodexCatalogToolProfile::NativeResponses,
|
||||
)
|
||||
.expect("vendor catalog generation should not error")
|
||||
.expect("non-empty modelCatalog must yield a catalog");
|
||||
|
||||
let entry = &catalog["models"][0];
|
||||
assert_eq!(
|
||||
entry.get("slug").and_then(|v| v.as_str()),
|
||||
Some("deepseek-v4-lite")
|
||||
);
|
||||
assert_eq!(
|
||||
entry.get("display_name").and_then(|v| v.as_str()),
|
||||
Some("deepseek-v4-lite")
|
||||
);
|
||||
assert!(
|
||||
entry
|
||||
.get("priority")
|
||||
.and_then(|v| v.as_u64())
|
||||
.is_some_and(|p| p >= 1000),
|
||||
"clones must sort after official entries"
|
||||
);
|
||||
assert_eq!(
|
||||
entry.get("apply_patch_tool_type").and_then(|v| v.as_str()),
|
||||
Some("freeform")
|
||||
);
|
||||
assert_eq!(
|
||||
entry.get("context_window").and_then(|v| v.as_u64()),
|
||||
Some(1_048_576),
|
||||
"absent contextWindow keeps the flagship's official window"
|
||||
);
|
||||
assert!(entry
|
||||
.get("base_instructions")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|s| !s.trim().is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_vendor_catalog_gated_by_native_profile_and_host() {
|
||||
// The official mirror is a capability GRANT, so the gate must be
|
||||
// narrow: native `/responses` profile AND the vendor's own host. Chat
|
||||
// runs through the proxy converter (gpt-5.5 contract), the Anthropic
|
||||
// transform drops custom tools, and aggregators hosting the same
|
||||
// model may reject freeform tools — all of them keep their templates.
|
||||
assert!(codex_official_vendor_catalog_models(
|
||||
DEEPSEEK_NATIVE_CONFIG,
|
||||
CodexCatalogToolProfile::NativeResponses
|
||||
)
|
||||
.is_some_and(|models| !models.is_empty()));
|
||||
|
||||
for profile in [
|
||||
CodexCatalogToolProfile::ProxyChat,
|
||||
CodexCatalogToolProfile::Anthropic,
|
||||
] {
|
||||
assert!(
|
||||
codex_official_vendor_catalog_models(DEEPSEEK_NATIVE_CONFIG, profile).is_none(),
|
||||
"only the NativeResponses profile may mirror the official catalog"
|
||||
);
|
||||
}
|
||||
|
||||
let minimax_config = r#"model = "MiniMax-M3"
|
||||
model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "minimax"
|
||||
base_url = "https://api.minimaxi.com/v1"
|
||||
wire_api = "responses"
|
||||
"#;
|
||||
assert!(
|
||||
codex_official_vendor_catalog_models(
|
||||
minimax_config,
|
||||
CodexCatalogToolProfile::NativeResponses
|
||||
)
|
||||
.is_none(),
|
||||
"non-DeepSeek native hosts keep the neutral template"
|
||||
);
|
||||
assert!(
|
||||
codex_official_vendor_catalog_models("", CodexCatalogToolProfile::NativeResponses)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_chat_profile_still_keeps_apply_patch() {
|
||||
// Regression guard for Mode A: the proxy-chat profile must keep the
|
||||
@@ -3080,8 +3635,8 @@ base_url = "https://production.api/v1"
|
||||
let template = load_codex_native_responses_template();
|
||||
let specs = vec![CodexCatalogModelSpec {
|
||||
model: "x".to_string(),
|
||||
display_name: "x".to_string(),
|
||||
context_window: 128_000,
|
||||
display_name: Some("x".to_string()),
|
||||
context_window: Some(128_000),
|
||||
supports_parallel_tool_calls: None,
|
||||
input_modalities: None,
|
||||
base_instructions: None,
|
||||
@@ -3095,6 +3650,7 @@ base_url = "https://production.api/v1"
|
||||
&specs,
|
||||
&proxy_template,
|
||||
CodexCatalogToolProfile::ProxyChat,
|
||||
128_000,
|
||||
);
|
||||
assert_eq!(
|
||||
catalog["models"][0]
|
||||
|
||||
+216
-14
@@ -214,11 +214,16 @@ fn run_tool_lifecycle_silently(command_line: &str, _label: &str) -> Result<(), S
|
||||
use std::process::Command;
|
||||
// command_line 是 bash 风格脚本(含 `set -e` 与多行命令);强制用 bash 执行,
|
||||
// 避免用户默认 shell 为 fish/zsh 时 `set -e` 等语义不一致。
|
||||
let output = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(command_line)
|
||||
.output()
|
||||
.map_err(|e| format!("启动安装进程失败: {e}"))?;
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg("-c").arg(command_line);
|
||||
// GUI App 继承的是 launchd 的窄 PATH,而锚定探测用的是登录 shell —— 见
|
||||
// `login_shell_path` doc。命令自身用绝对路径不受影响,但工具**内部** spawn 的
|
||||
// npm/node 等子进程、以及 install 链里裸的 npm fallback 都需要真实 PATH。
|
||||
if let Some(login_path) = login_shell_path() {
|
||||
let inherited = std::env::var("PATH").unwrap_or_default();
|
||||
cmd.env("PATH", merge_path_segments(&login_path, &inherited));
|
||||
}
|
||||
let output = cmd.output().map_err(|e| format!("启动安装进程失败: {e}"))?;
|
||||
finish_lifecycle_output(&output)
|
||||
}
|
||||
|
||||
@@ -1855,6 +1860,73 @@ fn first_abs_path_line(raw: &str) -> Option<&str> {
|
||||
raw.lines().map(str::trim).find(|l| l.starts_with('/'))
|
||||
}
|
||||
|
||||
/// 从 `env` 输出里取 `PATH=` 那行的值。要求值以 `/` 开头——PATH 首段必是绝对路径,
|
||||
/// 这条约束顺带跳过"某个多行值的环境变量恰好有一行以 `PATH=` 开头"的污染,
|
||||
/// 与 `first_abs_path_line` 对交互式 shell 噪音的容错同理。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn path_line_from_env_output(raw: &str) -> Option<&str> {
|
||||
raw.lines()
|
||||
.filter_map(|line| line.strip_prefix("PATH="))
|
||||
.find(|value| value.starts_with('/'))
|
||||
}
|
||||
|
||||
/// 合并两段 PATH:`primary` 全部保留在前,`extra` 中未出现过的段按序追加。
|
||||
/// 空段直接丢弃(`a::b` 里的空段在 POSIX 下语义是"当前目录",注入时不该带上)。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn merge_path_segments(primary: &str, extra: &str) -> String {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut merged: Vec<&str> = Vec::new();
|
||||
for segment in primary.split(':').chain(extra.split(':')) {
|
||||
if segment.is_empty() || !seen.insert(segment) {
|
||||
continue;
|
||||
}
|
||||
merged.push(segment);
|
||||
}
|
||||
merged.join(":")
|
||||
}
|
||||
|
||||
/// 用与 `resolve_path_default` 相同的登录 shell 解析用户的真实 PATH,
|
||||
/// 供 `run_tool_lifecycle_silently` 注入给安装/升级脚本。
|
||||
///
|
||||
/// **要解决的不对称**:探测阶段(`try_get_version` / `resolve_path_default`)跑的是
|
||||
/// `$SHELL -lic`,会读 `.zshrc`/`.zprofile`,看得到 nvm / homebrew / volta;而执行阶段
|
||||
/// 是非登录 `bash -c`,继承的是 launchd 给 GUI App 的 PATH,通常只有
|
||||
/// `/usr/bin:/bin:/usr/sbin:/sbin`。锚定命令自己用绝对路径调用执行体,本不受这条影响
|
||||
/// ——但有两类漏网:
|
||||
/// 1. **执行体在内部再 spawn 第三方 CLI**:`grok update` 靠 `npm view` 查最新版本
|
||||
/// (见 `grok_native_update_command`),npm 又是 `#!/usr/bin/env node` 脚本;
|
||||
/// 未来任何 self-update 内部调 node/git/python 同理。
|
||||
/// 2. **install 分支的 `<官方 installer> || npm i -g <pkg>@latest`**:`||` 右侧是裸命令,
|
||||
/// 窄 PATH 下必然 exit 127,等于没有兜底。
|
||||
///
|
||||
/// 把执行阶段的 PATH 拉平到探测阶段的水平,一次消除这两类。
|
||||
///
|
||||
/// **用 `/usr/bin/env` 而不是 `echo $PATH`**:`$PATH` 在 fish 里是 list 类型,
|
||||
/// `"$PATH"` 展开成空格分隔而非冒号分隔;`env` 打印的则是子进程的真实环境,
|
||||
/// 任何 shell 下格式都正确。写绝对路径又绕过了 alias / function / PATH 三重不确定性
|
||||
/// (交互式 shell 会加载用户 alias)。
|
||||
///
|
||||
/// 解析不到时返回 `None`,调用方保持原有行为(不注入),不引入新的失败模式。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn login_shell_path() -> Option<String> {
|
||||
use std::process::Command;
|
||||
let shell = std::env::var("SHELL")
|
||||
.ok()
|
||||
.filter(|s| is_valid_shell(s))
|
||||
.unwrap_or_else(|| "sh".to_string());
|
||||
let flag = default_flag_for_shell(&shell);
|
||||
let out = Command::new(shell)
|
||||
.arg(flag)
|
||||
.arg("/usr/bin/env")
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let raw = decode_command_output(&out.stdout);
|
||||
Some(path_line_from_env_output(&raw)?.to_string())
|
||||
}
|
||||
|
||||
/// 用与 `try_get_version` 相同的登录 shell 解析 PATH 默认命中的可执行文件路径,
|
||||
/// canonicalize 后作为"命令行默认 / 升级目标"的锚点(与升级会作用的那处对齐)。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
@@ -2205,6 +2277,64 @@ fn anchored_official_update_command(tool: &str, bin_path: &str) -> Option<String
|
||||
official_update_args(tool).map(|args| format!("{} {args}", win_quote_path_for_batch(bin_path)))
|
||||
}
|
||||
|
||||
/// Grok Build 原生安装的升级命令 = `<bin 绝对> update || <官方 installer>`。
|
||||
///
|
||||
/// **为什么唯独 native Grok 的 self-update 需要 fallback**(claude native / hermes 都没有):
|
||||
/// `grok update` 虽然是自包含 Rust 二进制的子命令,**却把 npm 当成自己的分发管道**——
|
||||
/// 先 spawn `npm view @xai-official/grok version --json` 查最新版,再 spawn
|
||||
/// `npm i -g @xai-official/grok@<version>` 安装,由该包的 `postinstall.js` 从平台 optional
|
||||
/// 依赖里解出二进制、安置成 `~/.grok/bin/grok-<version>` 并 relink `grok`。而 `npm` 自身是
|
||||
/// `#!/usr/bin/env node` 脚本 → **native 安装也隐式硬依赖 PATH 里同时有 `npm` + `node`**。
|
||||
/// GUI 进程 PATH 由 launchd 给、`run_tool_lifecycle_silently` 又是非登录 `bash -c`,
|
||||
/// nvm/homebrew 下的 node+npm 均不可见 → grok 内部 spawn 得到 ENOENT,只向用户抛出
|
||||
/// 费解的 `Error: No such file or directory (os error 2)`(实测复现)。
|
||||
///
|
||||
/// **这是上游换了机制**:0.2.111 及更早版本自更新是直接下载二进制到 `~/.grok/downloads/`
|
||||
/// (彼时 `is_grok_native_install` 假设的 "native = 不碰 npm" 成立),0.2.112 起改走上述 npm
|
||||
/// 管道(落点随之从 `downloads/` 变为 `bin/`)。**副作用**:npm 全局包那一处安装是
|
||||
/// `grok update` 自己装出来的,非用户手动所为,故 native 用户也会被 enumerate 到两处;
|
||||
/// 两处由同一次 postinstall 同步,版本恒等。
|
||||
///
|
||||
/// 这正是 `anchored_command_from_paths` 那条"绝对路径 + 必要时把解释器目录放 PATH 首位"
|
||||
/// 不变量没覆盖的第三类:**执行体自身既不需要解释器、也已用绝对路径,却在内部再 spawn
|
||||
/// 第三方 CLI**。`login_shell_path` 的 PATH 注入已让绝大多数机器上的 primary 直接成功;
|
||||
/// 这条 fallback 覆盖的是"这台机器根本没装 node"——用官方 installer 装的用户完全可能如此。
|
||||
///
|
||||
/// **fallback 必须是官方 installer,不能是 `npm i -g`**:后者与 primary **同源**——primary
|
||||
/// 失败的两种现实原因(本机无 node;npm registry 指向未同步该 tarball 的镜像,见
|
||||
/// npmmirror dist-tag 事故)都会让 npm fallback 一并失败,`||` 形同虚设。官方 installer
|
||||
/// 是唯一 node-free 的独立路径(只需 `curl`,在 `/usr/bin`,窄 PATH 下可用),且落点同为
|
||||
/// `~/.grok/bin`,锚定语义分毫不动 —— 真正的降级冗余要求 fallback 与 primary **失败模式不相关**。
|
||||
///
|
||||
/// **这条 fallback 还兼具第三重作用:修复上游锚点(勿在重构时丢掉)**。
|
||||
/// grok 的更新路径由 `~/.grok/config.toml` 的 `[cli] installer` 决定(`npm` / `internal` /
|
||||
/// `gh-release`),而官方 install.sh 会**无条件把该字段覆写为 `internal`**(其 awk 段落先插入
|
||||
/// `installer = "internal"`、再跳过 `[cli]` 段里已有的 `installer`/`channel` 行)。于是:
|
||||
/// 用户一旦因 install 端的 npm fallback 被切进 npm 模式(postinstall.js 会写 `installer = "npm"`
|
||||
/// 并在每次 npm 更新时重写,自我巩固),只要 npm 路径出任何问题,这里的 `||` 就会把他拉回
|
||||
/// node-free 的 internal 模式,并顺带修好 npm 模式漏更新的 `~/.grok/bin/agent` launcher。
|
||||
/// **实测验证**(2026-07-30,窄 PATH + `installer = "npm"`):primary 抛 `os error 2` → fallback
|
||||
/// 接管 → 装上最新版 → config 写回 `internal` → agent 对齐 → `.zshrc` 幂等更新不重复。
|
||||
/// ⇒ install 端保留 npm fallback 是安全的(它是 x.ai 不可达时的唯一退路,防火墙场景需要),
|
||||
/// 其副作用由本链自愈;**把这里换成 npm fallback 会同时废掉降级冗余和这条自愈路径**。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn grok_native_update_command(update: String) -> String {
|
||||
chain_update_commands(
|
||||
update,
|
||||
GROK_INSTALL_UNIX.to_string(),
|
||||
LifecycleCommandShell::Posix,
|
||||
)
|
||||
}
|
||||
|
||||
/// Windows 版同上,fallback 换成官方 PowerShell installer。
|
||||
/// **不走 `chain_update_commands`**:它会给 `||` 右侧加 `call`,而这里的 fallback 是
|
||||
/// `powershell.exe`(不是 `.cmd`/`.bat`),不需要 `call`——与 `hermes_update_windows_command`
|
||||
/// 同一理由。
|
||||
#[cfg(target_os = "windows")]
|
||||
fn grok_native_update_command(update: String) -> String {
|
||||
format!("{update} || {}", grok_install_windows_command())
|
||||
}
|
||||
|
||||
/// 哪些工具的"官方 self-update"优先于包管理器升级(生成 `<tool> update || <pkg-mgr>`)。
|
||||
///
|
||||
/// **codex 刻意不在此列**:`codex update` 在 npm 安装上只是裸 `npm install -g
|
||||
@@ -2359,7 +2489,9 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
}
|
||||
if tool == "grok" && is_grok_native_install(bin_path, real_target) {
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
return Some(grok_native_update_command(
|
||||
anchored_official_update_command(tool, bin_path)?,
|
||||
));
|
||||
}
|
||||
let package_command = package_manager_anchored_command_from_paths(tool, bin_path, real_target);
|
||||
if brew_formula_from_path(real_target).is_some() {
|
||||
@@ -2438,7 +2570,9 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
}
|
||||
if tool == "grok" && is_grok_native_install(bin_path, real_target) {
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
return Some(grok_native_update_command(
|
||||
anchored_official_update_command(tool, bin_path)?,
|
||||
));
|
||||
}
|
||||
let package_command = package_manager_anchored_command_from_paths(tool, bin_path);
|
||||
if prefers_official_update(tool, LifecycleCommandShell::WindowsBatch) {
|
||||
@@ -2535,6 +2669,11 @@ fn installer_with_npm_fallback(installer: &str, tool: &str) -> String {
|
||||
fn posix_install_command_for(tool: &str) -> String {
|
||||
match tool {
|
||||
"claude" => installer_with_npm_fallback(CLAUDE_INSTALL_UNIX, tool),
|
||||
// Grok 的 npm fallback **会切换用户的分发模式**(该包 postinstall 把
|
||||
// `~/.grok/config.toml` 的 `[cli] installer` 写成 `npm`,此后 `grok update` 一律走
|
||||
// npm、隐式依赖 node)。仍然保留它:官方 installer 不可达(防火墙 / x.ai 被拦)时
|
||||
// 这是唯一退路,而副作用可自愈——`grok_native_update_command` 的 `||` 官方 installer
|
||||
// 会在 npm 路径出问题时把 `installer` 覆写回 `internal`(见该函数 doc 的实测记录)。
|
||||
"grok" => installer_with_npm_fallback(GROK_INSTALL_UNIX, tool),
|
||||
"opencode" => installer_with_npm_fallback(OPENCODE_INSTALL_UNIX, tool),
|
||||
"hermes" => HERMES_INSTALL_UNIX.to_string(),
|
||||
@@ -4043,11 +4182,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_native_windows_uses_self_update() {
|
||||
fn grok_native_windows_uses_self_update_with_installer_fallback() {
|
||||
// sibling 有 npm.cmd 也**不能**拿它当 fallback:`grok update` 本身就是靠 npm
|
||||
// 分发的(见 grok_native_update_command doc),npm fallback 与 primary 同源、
|
||||
// 会一起失败。fallback 必须是官方 PowerShell installer —— 唯一不经 npm 的路径。
|
||||
let (_dir, _sub, bin_path) = setup_sibling(".grok/bin", "grok.exe", &["npm.cmd"]);
|
||||
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path);
|
||||
let expected = format!("{} update", expect_quoted_path(&bin_path));
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path).unwrap();
|
||||
let expected = format!(
|
||||
"{} update || {}",
|
||||
expect_quoted_path(&bin_path),
|
||||
grok_install_windows_command()
|
||||
);
|
||||
assert_eq!(cmd, expected);
|
||||
// fallback 是 powershell.exe 不是 .cmd/.bat —— `||` 右侧不该有 `call`。
|
||||
assert!(
|
||||
!cmd.contains("|| call"),
|
||||
"powershell needs no `call`: {cmd}"
|
||||
);
|
||||
assert!(!cmd.contains("npm"), "npm must not be the fallback: {cmd}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4517,7 +4669,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_native_installer_uses_self_update() {
|
||||
fn grok_native_installer_uses_self_update_with_installer_fallback() {
|
||||
// ~/.grok/bin/grok is a launcher symlink into ~/.grok/downloads.
|
||||
// Updating it through npm would create or mutate a different install.
|
||||
let cmd = anchored_command_from_paths(
|
||||
@@ -4525,7 +4677,25 @@ mod tests {
|
||||
"/Users/me/.grok/bin/grok",
|
||||
"/Users/me/.grok/downloads/grok-macos-aarch64",
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some("/Users/me/.grok/bin/grok update"));
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some(format!("/Users/me/.grok/bin/grok update || {GROK_INSTALL_UNIX}").as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_native_update_falls_back_to_installer_not_npm() {
|
||||
// 反向锁定:`grok update` 内部靠 `npm view` + `npm i -g` 完成升级,GUI 的窄
|
||||
// PATH 下会 ENOENT。fallback 必须是官方 installer —— 换成 `npm i -g` 就与
|
||||
// primary 同源(无 node / 镜像缺 tarball 时一起失败),`||` 形同虚设。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"grok",
|
||||
"/Users/me/.grok/bin/grok",
|
||||
"/Users/me/.grok/downloads/grok-macos-aarch64",
|
||||
)
|
||||
.expect("native grok should anchor");
|
||||
assert!(cmd.contains("x.ai/cli/install.sh"), "{cmd}");
|
||||
assert!(!cmd.contains("npm"), "npm must not be the fallback: {cmd}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4535,7 +4705,10 @@ mod tests {
|
||||
"/Users/me/bin/grok",
|
||||
"/Users/me/.grok/downloads/grok-macos-aarch64",
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some("/Users/me/bin/grok update"));
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some(format!("/Users/me/bin/grok update || {GROK_INSTALL_UNIX}").as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5006,6 +5179,35 @@ mod tests {
|
||||
assert_eq!(first_abs_path_line("welcome\nbye\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_line_from_env_output_survives_shell_noise() {
|
||||
// `$SHELL -lic /usr/bin/env` 的 stdout 前面可能有交互式 rc 的欢迎语。
|
||||
let raw = "🚀 Welcome back, Jason!\nSHELL=/bin/zsh\nPATH=/opt/homebrew/bin:/usr/bin\nHOME=/Users/me\n";
|
||||
assert_eq!(
|
||||
path_line_from_env_output(raw),
|
||||
Some("/opt/homebrew/bin:/usr/bin")
|
||||
);
|
||||
// 多行值的环境变量里恰好有一行以 `PATH=` 开头时,「值须以 / 开头」把它筛掉。
|
||||
let poisoned = "SOME_SCRIPT=line1\nPATH=not-a-path\nPATH=/usr/bin:/bin\n";
|
||||
assert_eq!(path_line_from_env_output(poisoned), Some("/usr/bin:/bin"));
|
||||
// 完全没有 PATH 行 → None,调用方保持不注入。
|
||||
assert_eq!(path_line_from_env_output("HOME=/Users/me\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_path_segments_dedupes_preserving_login_order() {
|
||||
// 登录 shell 的段全部在前且保序;继承 PATH 里的新段追加在后。
|
||||
assert_eq!(
|
||||
merge_path_segments(
|
||||
"/Users/me/.nvm/versions/node/v22/bin:/usr/bin:/bin",
|
||||
"/usr/bin:/bin:/usr/sbin"
|
||||
),
|
||||
"/Users/me/.nvm/versions/node/v22/bin:/usr/bin:/bin:/usr/sbin"
|
||||
);
|
||||
// 空段(`a::b` 在 POSIX 下意为当前目录)不该被注入。
|
||||
assert_eq!(merge_path_segments("/usr/bin::/bin", ""), "/usr/bin:/bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_conflicting_thresholds() {
|
||||
let make = |version: Option<&str>, runnable: bool| ToolInstallation {
|
||||
|
||||
@@ -3,6 +3,7 @@ use tauri::{Emitter, Manager, State};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::commands::copilot::CopilotAuthState;
|
||||
use crate::commands::xai_oauth::XaiOAuthState;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{ClaudeDesktopMode, Provider};
|
||||
use crate::services::{
|
||||
@@ -442,6 +443,7 @@ pub async fn queryProviderUsage(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
xai_state: State<'_, XaiOAuthState>,
|
||||
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
|
||||
app: String,
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
@@ -454,8 +456,14 @@ pub async fn queryProviderUsage(
|
||||
// 不写失败快照、不 emit:保留上一份托盘快照,与前端 react-query reject
|
||||
// 保留上次 data 的语义一致;否则失败快照会经 useUsageCacheBridge 盲写
|
||||
// 回 query 缓存,抹掉 reject 本该保留的旧值。
|
||||
let inner =
|
||||
query_provider_usage_inner(&state, &copilot_state, app_type.clone(), &providerId).await;
|
||||
let inner = query_provider_usage_inner(
|
||||
&state,
|
||||
&copilot_state,
|
||||
&xai_state,
|
||||
app_type.clone(),
|
||||
&providerId,
|
||||
)
|
||||
.await;
|
||||
if let Ok(snapshot) = &inner {
|
||||
let payload = serde_json::json!({
|
||||
"kind": "script",
|
||||
@@ -521,6 +529,7 @@ fn resolve_coding_plan_credentials(
|
||||
async fn query_provider_usage_inner(
|
||||
state: &AppState,
|
||||
copilot_state: &CopilotAuthState,
|
||||
xai_state: &XaiOAuthState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
@@ -689,9 +698,19 @@ async fn query_provider_usage_inner(
|
||||
});
|
||||
}
|
||||
|
||||
let quota = crate::services::subscription::get_subscription_quota(app_type.as_str())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query subscription quota: {e}"))?;
|
||||
// xAI OAuth 托管供应商的额度属绑定的 SuperGrok 账号,而非所在 app 的
|
||||
// CLI 凭据(对 codex/claude 而言 CLI 凭据是 ChatGPT/Claude 订阅,跨了
|
||||
// 订阅体系,查出来的数字张冠李戴)。
|
||||
let quota = if provider.map(Provider::is_xai_oauth).unwrap_or(false) {
|
||||
let account_id = provider
|
||||
.and_then(|p| p.meta.as_ref())
|
||||
.and_then(|m| m.managed_account_id_for("xai_oauth"));
|
||||
crate::commands::xai_oauth::query_xai_oauth_quota_for(xai_state, account_id).await?
|
||||
} else {
|
||||
crate::services::subscription::get_subscription_quota(app_type.as_str())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query subscription quota: {e}"))?
|
||||
};
|
||||
|
||||
if !quota.success {
|
||||
return Ok(crate::provider::UsageResult {
|
||||
@@ -1079,7 +1098,7 @@ mod import_claude_desktop_tests {
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
assert_eq!(routes.len(), 3);
|
||||
assert_eq!(routes.get("claude-sonnet-5").unwrap().model, "GLM-4.6");
|
||||
assert_eq!(routes.get("claude-opus-4-8").unwrap().model, "GLM-4-Air");
|
||||
assert_eq!(routes.get("claude-opus-5").unwrap().model, "GLM-4-Air");
|
||||
assert_eq!(routes.get("claude-haiku-4-5").unwrap().model, "GLM-4-Flash");
|
||||
assert_eq!(
|
||||
routes
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use crate::proxy::providers::xai_oauth_auth::XaiOAuthManager;
|
||||
use crate::proxy::providers::XAI_API_BASE_URL;
|
||||
use crate::services::model_fetch::FetchedModel;
|
||||
use crate::services::subscription::{CredentialStatus, SubscriptionQuota};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -11,6 +12,68 @@ use tokio::sync::RwLock;
|
||||
|
||||
pub struct XaiOAuthState(pub Arc<RwLock<XaiOAuthManager>>);
|
||||
|
||||
/// 查询 xAI OAuth (SuperGrok 反代) 订阅额度的共享核心
|
||||
///
|
||||
/// 与 `get_codex_oauth_quota` 平行:数据走 cc-switch 自管的 xAI OAuth token,
|
||||
/// 而非 Grok CLI 的 ~/.grok/auth.json。两者是同一个 OAuth client
|
||||
/// (client_id 与 Grok CLI 一致),token 对 grok.com 账单端点等效,因此
|
||||
/// 复用 `subscription_grok::query_grok_quota`,协议与 Grok CLI 路径完全一致。
|
||||
///
|
||||
/// 供两处调用:`get_xai_oauth_quota` 命令(前端 footer)与
|
||||
/// `commands::provider` 的 official_subscription 分支(用量脚本/托盘路径,
|
||||
/// xai_oauth 供应商的额度属绑定的 SuperGrok 账号而非所在 app 的 CLI 凭据)。
|
||||
///
|
||||
/// - `account_id` 未指定时回退到 `XaiOAuthManager` 的默认账号
|
||||
/// - 没有任何账号时返回 `not_found`,前端 `SubscriptionQuotaView` 会静默不渲染
|
||||
/// - 瞬时传输失败以 `Err` 传播(前端 reject → retry + 保留上次成功值)
|
||||
pub(crate) async fn query_xai_oauth_quota_for(
|
||||
state: &XaiOAuthState,
|
||||
account_id: Option<String>,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
let manager = state.0.read().await;
|
||||
|
||||
// 解析最终使用的账号 ID:显式 > 默认账号 > 无账号 (not_found)
|
||||
let resolved = match account_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
{
|
||||
Some(id) => Some(id.to_string()),
|
||||
None => manager.default_account_id().await,
|
||||
};
|
||||
let Some(id) = resolved else {
|
||||
return Ok(SubscriptionQuota::not_found("xai_oauth"));
|
||||
};
|
||||
|
||||
// 获取(必要时自动刷新)access_token
|
||||
let token = match manager.get_valid_token_for_account(&id).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
return Ok(SubscriptionQuota::error(
|
||||
"xai_oauth",
|
||||
CredentialStatus::Expired,
|
||||
format!("xAI OAuth token unavailable: {e}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
crate::services::subscription_grok::query_grok_quota(
|
||||
&token,
|
||||
"xai_oauth",
|
||||
"Please re-login via cc-switch.",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 查询 xAI OAuth (SuperGrok 反代) 订阅额度
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn get_xai_oauth_quota(
|
||||
account_id: Option<String>,
|
||||
state: State<'_, XaiOAuthState>,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
query_xai_oauth_quota_for(&state, account_id).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelsResponse {
|
||||
#[serde(default)]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1545,6 +1545,8 @@ impl Database {
|
||||
"1.00",
|
||||
"12.50",
|
||||
),
|
||||
// Claude Opus 5(与 Opus 4.8 同价位;fast mode $10/$50 不入表)
|
||||
("claude-opus-5", "Claude Opus 5", "5", "25", "0.50", "6.25"),
|
||||
// Claude 4.8 系列
|
||||
(
|
||||
"claude-opus-4-8",
|
||||
@@ -1572,7 +1574,23 @@ impl Database {
|
||||
"0.50",
|
||||
"6.25",
|
||||
),
|
||||
// Claude 4.6 系列
|
||||
// Claude 4.6 系列(裸 id 行覆盖无日期后缀的日志变体,与 dated 行同价)
|
||||
(
|
||||
"claude-opus-4-6",
|
||||
"Claude Opus 4.6",
|
||||
"5",
|
||||
"25",
|
||||
"0.50",
|
||||
"6.25",
|
||||
),
|
||||
(
|
||||
"claude-sonnet-4-6",
|
||||
"Claude Sonnet 4.6",
|
||||
"3",
|
||||
"15",
|
||||
"0.30",
|
||||
"3.75",
|
||||
),
|
||||
(
|
||||
"claude-opus-4-6-20260206",
|
||||
"Claude Opus 4.6",
|
||||
@@ -1659,15 +1677,16 @@ impl Database {
|
||||
// GPT-5.6 系列(Sol / Terra / Luna,2026-06 发布)
|
||||
// 5.6 家族起 cache write 收 1.25× 输入价(此前 GPT 模型写缓存免费,勿回填旧系列)
|
||||
("gpt-5.6-sol", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"),
|
||||
// 2026-07-30 OpenAI 降价:luna -80%、terra -20%,sol 不变(Fast mode 2× 价不入表)
|
||||
("gpt-5.6-terra", "GPT-5.6 Terra", "2", "12", "0.20", "2.50"),
|
||||
(
|
||||
"gpt-5.6-terra",
|
||||
"GPT-5.6 Terra",
|
||||
"2.50",
|
||||
"15",
|
||||
"gpt-5.6-luna",
|
||||
"GPT-5.6 Luna",
|
||||
"0.20",
|
||||
"1.20",
|
||||
"0.02",
|
||||
"0.25",
|
||||
"3.125",
|
||||
),
|
||||
("gpt-5.6-luna", "GPT-5.6 Luna", "1", "6", "0.10", "1.25"),
|
||||
// 裸名 gpt-5.6 是 sol 的官方别名;effort 后缀对齐 gpt-5.5 系列的记账形态
|
||||
("gpt-5.6", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"),
|
||||
("gpt-5.6-low", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"),
|
||||
@@ -1727,6 +1746,14 @@ impl Database {
|
||||
),
|
||||
// GPT-5.3 Codex 系列
|
||||
("gpt-5.3-codex", "GPT-5.3 Codex", "1.75", "14", "0.175", "0"),
|
||||
(
|
||||
"gpt-5.3-codex-spark",
|
||||
"GPT-5.3 Codex Spark",
|
||||
"1.75",
|
||||
"14",
|
||||
"0.175",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5.3-codex-low",
|
||||
"GPT-5.3 Codex",
|
||||
@@ -1853,6 +1880,15 @@ impl Database {
|
||||
("gpt-4.1", "GPT-4.1", "2", "8", "0.50", "0"),
|
||||
("gpt-4.1-mini", "GPT-4.1 Mini", "0.40", "1.60", "0.10", "0"),
|
||||
("gpt-4.1-nano", "GPT-4.1 Nano", "0.10", "0.40", "0.025", "0"),
|
||||
// Gemini 3.6 系列
|
||||
(
|
||||
"gemini-3.6-flash",
|
||||
"Gemini 3.6 Flash",
|
||||
"1.50",
|
||||
"7.50",
|
||||
"0.15",
|
||||
"0",
|
||||
),
|
||||
// Gemini 3.5 系列
|
||||
(
|
||||
"gemini-3.5-flash",
|
||||
@@ -1862,6 +1898,14 @@ impl Database {
|
||||
"0.15",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gemini-3.5-flash-lite",
|
||||
"Gemini 3.5 Flash Lite",
|
||||
"0.30",
|
||||
"2.50",
|
||||
"0.03",
|
||||
"0",
|
||||
),
|
||||
// Gemini 3.1 系列
|
||||
(
|
||||
"gemini-3.1-pro-preview",
|
||||
@@ -2051,20 +2095,21 @@ impl Database {
|
||||
"0",
|
||||
),
|
||||
("deepseek-v3", "DeepSeek V3", "0.28", "1.11", "0.028", "0"),
|
||||
// deepseek-chat / deepseek-reasoner 自 2026-07 起为 V4 Flash 的 legacy 别名(同价)
|
||||
(
|
||||
"deepseek-chat",
|
||||
"DeepSeek Chat",
|
||||
"0.27",
|
||||
"1.10",
|
||||
"0.07",
|
||||
"0.14",
|
||||
"0.28",
|
||||
"0.0028",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"deepseek-reasoner",
|
||||
"DeepSeek Reasoner",
|
||||
"0.55",
|
||||
"2.19",
|
||||
"0.14",
|
||||
"0.28",
|
||||
"0.0028",
|
||||
"0",
|
||||
),
|
||||
// DeepSeek V4 系列(官方 CNY 按 1 USD ≈ 7.14 折算)
|
||||
@@ -2112,6 +2157,15 @@ impl Database {
|
||||
"0.19",
|
||||
"0",
|
||||
),
|
||||
// HighSpeed 加速档=本体 2 倍价(Kimi 官方一贯模式,同 K2 Turbo)
|
||||
(
|
||||
"kimi-k2.7-code-highspeed",
|
||||
"Kimi K2.7 Code HighSpeed",
|
||||
"1.90",
|
||||
"8.00",
|
||||
"0.38",
|
||||
"0",
|
||||
),
|
||||
("kimi-k3", "Kimi K3", "3.00", "15.00", "0.30", "0"),
|
||||
// Kimi For Coding 套餐里 K3 的裸名(无 kimi- 前缀),同标准 list 价
|
||||
("k3", "Kimi K3", "3.00", "15.00", "0.30", "0"),
|
||||
@@ -2154,13 +2208,15 @@ impl Database {
|
||||
"0.06",
|
||||
"0.375",
|
||||
),
|
||||
("minimax-m3", "MiniMax M3", "0.60", "2.40", "0.12", "0"),
|
||||
("minimax-m3", "MiniMax M3", "0.30", "1.20", "0.06", "0"),
|
||||
// GLM (智谱)
|
||||
("glm-4.7", "GLM-4.7", "0.6", "2.2", "0.11", "0"),
|
||||
("glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0"),
|
||||
("glm-5", "GLM-5", "1", "3.2", "0.2", "0"),
|
||||
("glm-5.1", "GLM-5.1", "1.4", "4.4", "0.26", "0"),
|
||||
("glm-5.2", "GLM-5.2", "1.4", "4.4", "0.26", "0"),
|
||||
("glm-5-turbo", "GLM-5-Turbo", "1.2", "4", "0.24", "0"),
|
||||
("glm-5v-turbo", "GLM-5V-Turbo", "1.2", "4", "0.24", "0"),
|
||||
// MiMo (小米)
|
||||
(
|
||||
"mimo-v2-flash",
|
||||
@@ -2191,6 +2247,14 @@ impl Database {
|
||||
"0.065",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3.6-flash",
|
||||
"Qwen3.6 Flash",
|
||||
"0.1875",
|
||||
"1.125",
|
||||
"0.0375",
|
||||
"0",
|
||||
),
|
||||
("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0.052", "0"),
|
||||
("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"),
|
||||
(
|
||||
@@ -2246,6 +2310,10 @@ impl Database {
|
||||
("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
|
||||
// Grok 系列 (xAI)
|
||||
("grok-4.5", "Grok 4.5", "2", "6", "0.50", "0"),
|
||||
// Grok CLI 官方 OAuth 态 modelUsage 上报的内部别名。定价由
|
||||
// costUsdTicks(1 tick = 1e-10 USD)双轮实测反推:input/output 与
|
||||
// grok-4.5 同为 2/6,cache read 实际按 0.30 计(非 API 挂牌的 0.50)
|
||||
("grok-4.5-build", "Grok 4.5 Build", "2", "6", "0.30", "0"),
|
||||
("grok-4.3", "Grok 4.3", "1.25", "2.50", "0.20", "0"),
|
||||
(
|
||||
"grok-4.20-0309-reasoning",
|
||||
@@ -2414,6 +2482,95 @@ impl Database {
|
||||
|
||||
fn repair_current_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||||
let pricing_fixes = [
|
||||
// 2026-07-30 OpenAI GPT-5.6 降价:luna -80%、terra -20%(sol 不变)。
|
||||
// 每档两条守卫:主守卫匹配 ≥v3.19(已跑过 07-12 cache_write 修正),
|
||||
// 0 态守卫匹配 <v3.19 直升用户(cache_write 仍为旧 seed 的 0)
|
||||
(
|
||||
"gpt-5.6-luna",
|
||||
"GPT-5.6 Luna",
|
||||
"0.20",
|
||||
"1.20",
|
||||
"0.02",
|
||||
"0.25",
|
||||
"1",
|
||||
"6",
|
||||
"0.10",
|
||||
"1.25",
|
||||
),
|
||||
(
|
||||
"gpt-5.6-luna",
|
||||
"GPT-5.6 Luna",
|
||||
"0.20",
|
||||
"1.20",
|
||||
"0.02",
|
||||
"0.25",
|
||||
"1",
|
||||
"6",
|
||||
"0.10",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5.6-terra",
|
||||
"GPT-5.6 Terra",
|
||||
"2",
|
||||
"12",
|
||||
"0.20",
|
||||
"2.50",
|
||||
"2.50",
|
||||
"15",
|
||||
"0.25",
|
||||
"3.125",
|
||||
),
|
||||
(
|
||||
"gpt-5.6-terra",
|
||||
"GPT-5.6 Terra",
|
||||
"2",
|
||||
"12",
|
||||
"0.20",
|
||||
"2.50",
|
||||
"2.50",
|
||||
"15",
|
||||
"0.25",
|
||||
"0",
|
||||
),
|
||||
// 2026-07-31 models.dev 审计核价:DeepSeek V4 发布后 chat/reasoner 降为 V4 Flash
|
||||
// 别名价;MiniMax M3 官方 standard 档 0.3/1.2(旧值疑似录了加速档)
|
||||
(
|
||||
"deepseek-chat",
|
||||
"DeepSeek Chat",
|
||||
"0.14",
|
||||
"0.28",
|
||||
"0.0028",
|
||||
"0",
|
||||
"0.27",
|
||||
"1.10",
|
||||
"0.07",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"deepseek-reasoner",
|
||||
"DeepSeek Reasoner",
|
||||
"0.14",
|
||||
"0.28",
|
||||
"0.0028",
|
||||
"0",
|
||||
"0.55",
|
||||
"2.19",
|
||||
"0.14",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"minimax-m3",
|
||||
"MiniMax M3",
|
||||
"0.30",
|
||||
"1.20",
|
||||
"0.06",
|
||||
"0",
|
||||
"0.60",
|
||||
"2.40",
|
||||
"0.12",
|
||||
"0",
|
||||
),
|
||||
// 2026-07-12 GPT-5.6 家族 cache write=1.25× 输入价(OpenAI 5.6 起的新规),
|
||||
// 修正早期 seed 的 0 值;只匹配未被用户改过的行
|
||||
(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -286,7 +287,8 @@ pub fn apply_proxy_takeover(
|
||||
token_placeholder: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let updated = update_selected_model_string(config_toml, "base_url", proxy_base_url)?;
|
||||
update_selected_model_string(&updated, "api_key", token_placeholder)
|
||||
let updated = update_selected_model_string(&updated, "api_key", token_placeholder)?;
|
||||
update_selected_model_string(&updated, "api_backend", DEFAULT_API_BACKEND)
|
||||
}
|
||||
|
||||
pub fn update_api_key(config_toml: &str, api_key: &str) -> Result<String, AppError> {
|
||||
@@ -511,8 +513,12 @@ context_window = 500000
|
||||
|
||||
#[test]
|
||||
fn takeover_preserves_env_key_profile_and_injects_inline_placeholder() {
|
||||
let direct_config = valid_env_key_config().replace(
|
||||
"api_backend = \"responses\"",
|
||||
"api_backend = \"chat_completions\"",
|
||||
);
|
||||
let updated = apply_proxy_takeover(
|
||||
valid_env_key_config(),
|
||||
&direct_config,
|
||||
"http://127.0.0.1:15721/grokbuild/v1",
|
||||
"PROXY_MANAGED",
|
||||
)
|
||||
@@ -522,6 +528,7 @@ context_window = 500000
|
||||
assert_eq!(selected.profile, "grok-env");
|
||||
assert_eq!(selected.env_key.as_deref(), Some("GROK_TEST_API_KEY"));
|
||||
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
|
||||
assert_eq!(selected.api_backend, DEFAULT_API_BACKEND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -540,6 +547,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!({
|
||||
|
||||
+19
-2
@@ -28,7 +28,6 @@ mod panic_hook;
|
||||
mod prompt;
|
||||
mod prompt_files;
|
||||
mod provider;
|
||||
mod provider_defaults;
|
||||
mod proxy;
|
||||
mod services;
|
||||
mod session_manager;
|
||||
@@ -40,7 +39,9 @@ mod usage_events;
|
||||
mod usage_script;
|
||||
|
||||
pub use app_config::{AppType, InstalledSkill, McpApps, McpServer, MultiAppConfig, SkillApps};
|
||||
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
||||
pub use codex_config::{
|
||||
get_codex_auth_path, get_codex_config_path, read_codex_live_settings, write_codex_live_atomic,
|
||||
};
|
||||
pub use commands::open_provider_terminal;
|
||||
pub use commands::*;
|
||||
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||||
@@ -1184,6 +1185,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 表中的代理状态,自动恢复代理服务
|
||||
@@ -1375,6 +1387,7 @@ pub fn run() {
|
||||
commands::get_codex_oauth_quota,
|
||||
commands::get_codex_oauth_models,
|
||||
commands::get_xai_oauth_models,
|
||||
commands::get_xai_oauth_quota,
|
||||
commands::get_coding_plan_quota,
|
||||
commands::get_balance,
|
||||
// New MCP via config.json (SSOT)
|
||||
@@ -1520,7 +1533,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());
|
||||
}
|
||||
}
|
||||
@@ -4755,6 +4755,60 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn body_with_codex_tool_output_image(stringified: bool) -> Value {
|
||||
let output = json!({
|
||||
"content": [{
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,TOOL_OUTPUT_SENTINEL"
|
||||
}]
|
||||
});
|
||||
json!({
|
||||
"model": "any-model",
|
||||
"input": [{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": if stringified {
|
||||
Value::String(output.to_string())
|
||||
} else {
|
||||
output
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn body_with_stringified_chat_tool_image() -> Value {
|
||||
let content = json!({
|
||||
"content": [{
|
||||
"type": "image",
|
||||
"mimeType": "image/png",
|
||||
"data": "CHAT_TOOL_SENTINEL"
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
json!({
|
||||
"model": "any-model",
|
||||
"messages": [{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": content
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn body_with_gemini_image() -> Value {
|
||||
json!({
|
||||
"contents": [{
|
||||
"role": "user",
|
||||
"parts": [{
|
||||
"inlineData": {
|
||||
"mimeType": "image/png",
|
||||
"data": "GEMINI_SENTINEL"
|
||||
}
|
||||
}]
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn image_unsupported_error() -> ProxyError {
|
||||
ProxyError::UpstreamError {
|
||||
status: 400,
|
||||
@@ -4857,6 +4911,49 @@ mod tests {
|
||||
assert!(fwd.media_retry_should_trigger("Codex", false, &body, &error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactive_triggers_for_structured_and_stringified_codex_tool_images() {
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig::default());
|
||||
|
||||
for stringified in [false, true] {
|
||||
let body = body_with_codex_tool_output_image(stringified);
|
||||
assert!(
|
||||
fwd.media_retry_should_trigger("Codex", false, &body, &image_unsupported_error()),
|
||||
"tool-output image should trigger retry (stringified={stringified})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactive_triggers_for_chat_tool_and_gemini_images() {
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig::default());
|
||||
|
||||
assert!(fwd.media_retry_should_trigger(
|
||||
"Claude",
|
||||
false,
|
||||
&body_with_stringified_chat_tool_image(),
|
||||
&image_unsupported_error()
|
||||
));
|
||||
assert!(fwd.media_retry_should_trigger(
|
||||
"Claude",
|
||||
false,
|
||||
&body_with_gemini_image(),
|
||||
&image_unsupported_error()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactive_does_not_treat_context_limit_as_image_rejection() {
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig::default());
|
||||
let body = body_with_codex_tool_output_image(false);
|
||||
let context_error = ProxyError::UpstreamError {
|
||||
status: 400,
|
||||
body: Some(r#"{"error":{"message":"maximum context length exceeded"}}"#.to_string()),
|
||||
};
|
||||
|
||||
assert!(!fwd.media_retry_should_trigger("Codex", false, &body, &context_error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactive_skipped_when_media_fallback_off() {
|
||||
// 关闭 request_media_fallback:上游报图片错误也不触发兜底重试。
|
||||
|
||||
@@ -2630,7 +2630,7 @@ async fn log_usage(
|
||||
model
|
||||
};
|
||||
|
||||
let dedup_scope = (app_type != "claude").then_some((app_type, provider_id));
|
||||
let dedup_scope = super::usage::parser::dedup_scope_for_app(app_type, provider_id);
|
||||
let request_id = usage.dedup_request_id(dedup_scope);
|
||||
|
||||
if let Err(e) = logger.log_with_calculation(
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
//! 健康检查器
|
||||
//!
|
||||
//! 负责定期检查Provider健康状态(占位实现)
|
||||
|
||||
// 占位实现,稍后添加完整逻辑
|
||||
#[allow(dead_code)]
|
||||
pub struct HealthChecker;
|
||||
@@ -3,6 +3,9 @@ use crate::model_capabilities::is_confirmed_text_only_model as confirmed_text_on
|
||||
use crate::model_capabilities::{image_input_capability_from_settings, ImageInputCapability};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use crate::proxy::tool_media::{
|
||||
strip_media_from_tool_value, tool_output_contains_media, ToolMediaScope,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub const UNSUPPORTED_IMAGE_MARKER: &str = "[Unsupported Image]";
|
||||
@@ -40,7 +43,9 @@ pub fn replace_images_for_text_only_model(
|
||||
}
|
||||
|
||||
pub fn contains_image_blocks(body: &Value) -> bool {
|
||||
messages_have_image_blocks(body) || responses_input_has_image_blocks(body.get("input"))
|
||||
messages_have_image_blocks(body)
|
||||
|| responses_input_has_image_blocks(body.get("input"))
|
||||
|| gemini_contents_have_image_blocks(body)
|
||||
}
|
||||
|
||||
pub fn replace_image_blocks_with_marker(body: &mut Value) -> usize {
|
||||
@@ -119,7 +124,11 @@ fn content_has_image_blocks(content: &Value) -> bool {
|
||||
|
||||
blocks.iter().any(|block| {
|
||||
is_image_block_type(block.get("type").and_then(Value::as_str))
|
||||
|| block.get("content").is_some_and(content_has_image_blocks)
|
||||
|| block.get("content").is_some_and(|nested| {
|
||||
content_has_image_blocks(nested)
|
||||
|| (block.get("type").and_then(Value::as_str) == Some("tool_result")
|
||||
&& tool_output_contains_media(nested, ToolMediaScope::ImagesOnly))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -127,13 +136,7 @@ fn replace_images_in_body(body: &mut Value) -> usize {
|
||||
let message_replacements = body
|
||||
.get_mut("messages")
|
||||
.and_then(Value::as_array_mut)
|
||||
.map(|messages| {
|
||||
messages
|
||||
.iter_mut()
|
||||
.filter_map(|message| message.get_mut("content"))
|
||||
.map(replace_images_in_content)
|
||||
.sum()
|
||||
})
|
||||
.map(|messages| messages.iter_mut().map(replace_images_in_message).sum())
|
||||
.unwrap_or(0);
|
||||
|
||||
message_replacements
|
||||
@@ -141,6 +144,37 @@ fn replace_images_in_body(body: &mut Value) -> usize {
|
||||
.get_mut("input")
|
||||
.map(replace_images_in_responses_input)
|
||||
.unwrap_or(0)
|
||||
+ replace_images_in_gemini_contents(body)
|
||||
}
|
||||
|
||||
fn replace_images_in_message(message: &mut Value) -> usize {
|
||||
let is_tool_message = message.get("role").and_then(Value::as_str) == Some("tool");
|
||||
let Some(content) = message.get_mut("content") else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
if is_tool_message {
|
||||
// Preserve the legacy typed-image replacement semantics first,
|
||||
// including Anthropic cache_control on the replacement text block.
|
||||
// The shared traversal then handles JSON strings, MCP wrappers, and
|
||||
// loose data-URL shapes that the legacy recursion does not recognize.
|
||||
let mut replaced = replace_images_in_content(content);
|
||||
let replacement_block = json!({
|
||||
"type":"text",
|
||||
"text":UNSUPPORTED_IMAGE_MARKER
|
||||
});
|
||||
let mut discarded_media = Vec::new();
|
||||
replaced += strip_media_from_tool_value(
|
||||
content,
|
||||
&mut discarded_media,
|
||||
ToolMediaScope::ImagesOnly,
|
||||
&replacement_block,
|
||||
UNSUPPORTED_IMAGE_MARKER,
|
||||
);
|
||||
replaced
|
||||
} else {
|
||||
replace_images_in_content(content)
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_images_in_content(content: &mut Value) -> usize {
|
||||
@@ -154,14 +188,36 @@ fn replace_images_in_content_with_text_type(content: &mut Value, text_type: &str
|
||||
|
||||
let mut replaced = 0usize;
|
||||
for block in blocks {
|
||||
if is_image_block_type(block.get("type").and_then(Value::as_str)) {
|
||||
let block_type = block.get("type").and_then(Value::as_str);
|
||||
if is_image_block_type(block_type) {
|
||||
replace_image_block_with_text_marker(block, text_type);
|
||||
replaced += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_tool_result = block_type == Some("tool_result");
|
||||
if let Some(nested_content) = block.get_mut("content") {
|
||||
replaced += replace_images_in_content_with_text_type(nested_content, text_type);
|
||||
if is_tool_result {
|
||||
// Run the legacy typed-block replacement before the shared
|
||||
// payload-aware traversal. This makes replacement a superset
|
||||
// of detection and preserves cache_control on Anthropic image
|
||||
// blocks, while the second pass covers alternate tool shapes.
|
||||
replaced += replace_images_in_content_with_text_type(nested_content, text_type);
|
||||
let replacement_block = json!({
|
||||
"type":text_type,
|
||||
"text":UNSUPPORTED_IMAGE_MARKER
|
||||
});
|
||||
let mut discarded_media = Vec::new();
|
||||
replaced += strip_media_from_tool_value(
|
||||
nested_content,
|
||||
&mut discarded_media,
|
||||
ToolMediaScope::ImagesOnly,
|
||||
&replacement_block,
|
||||
UNSUPPORTED_IMAGE_MARKER,
|
||||
);
|
||||
} else {
|
||||
replaced += replace_images_in_content_with_text_type(nested_content, text_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,13 +228,110 @@ fn messages_have_image_blocks(body: &Value) -> bool {
|
||||
body.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|messages| {
|
||||
messages
|
||||
.iter()
|
||||
.filter_map(|message| message.get("content"))
|
||||
.any(content_has_image_blocks)
|
||||
messages.iter().any(|message| {
|
||||
let Some(content) = message.get("content") else {
|
||||
return false;
|
||||
};
|
||||
content_has_image_blocks(content)
|
||||
|| (message.get("role").and_then(Value::as_str) == Some("tool")
|
||||
&& tool_output_contains_media(content, ToolMediaScope::ImagesOnly))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn gemini_contents_have_image_blocks(body: &Value) -> bool {
|
||||
body.get("contents")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|contents| {
|
||||
contents.iter().any(|content| {
|
||||
content
|
||||
.get("parts")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|parts| parts.iter().any(gemini_part_has_image))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn gemini_part_has_image(part: &Value) -> bool {
|
||||
gemini_media_payload_is_image(part.get("inlineData").or_else(|| part.get("inline_data")))
|
||||
|| gemini_media_payload_is_image(part.get("fileData").or_else(|| part.get("file_data")))
|
||||
|| part
|
||||
.get("functionResponse")
|
||||
.or_else(|| part.get("function_response"))
|
||||
.and_then(|response| response.get("parts"))
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|parts| parts.iter().any(gemini_part_has_image))
|
||||
}
|
||||
|
||||
fn gemini_media_payload_is_image(payload: Option<&Value>) -> bool {
|
||||
payload
|
||||
.and_then(|payload| payload.get("mimeType").or_else(|| payload.get("mime_type")))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|mime_type| {
|
||||
mime_type
|
||||
.get(..6)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("image/"))
|
||||
})
|
||||
}
|
||||
|
||||
fn replace_images_in_gemini_contents(body: &mut Value) -> usize {
|
||||
body.get_mut("contents")
|
||||
.and_then(Value::as_array_mut)
|
||||
.map(|contents| {
|
||||
contents
|
||||
.iter_mut()
|
||||
.filter_map(|content| content.get_mut("parts").and_then(Value::as_array_mut))
|
||||
.map(|parts| {
|
||||
parts
|
||||
.iter_mut()
|
||||
.map(replace_images_in_gemini_part)
|
||||
.sum::<usize>()
|
||||
})
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn replace_images_in_gemini_part(part: &mut Value) -> usize {
|
||||
if gemini_media_payload_is_image(part.get("inlineData").or_else(|| part.get("inline_data")))
|
||||
|| gemini_media_payload_is_image(part.get("fileData").or_else(|| part.get("file_data")))
|
||||
{
|
||||
*part = json!({"text":UNSUPPORTED_IMAGE_MARKER});
|
||||
return 1;
|
||||
}
|
||||
|
||||
let response_key = if part.get("functionResponse").is_some() {
|
||||
"functionResponse"
|
||||
} else {
|
||||
"function_response"
|
||||
};
|
||||
let Some(function_response) = part.get_mut(response_key) else {
|
||||
return 0;
|
||||
};
|
||||
let Some(media_parts) = function_response
|
||||
.get_mut("parts")
|
||||
.and_then(Value::as_array_mut)
|
||||
else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let before = media_parts.len();
|
||||
media_parts.retain(|media_part| !gemini_part_has_image(media_part));
|
||||
let replaced = before.saturating_sub(media_parts.len());
|
||||
if replaced > 0 {
|
||||
if let Some(response) = function_response
|
||||
.get_mut("response")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
response.insert(
|
||||
"cc_switch_media".to_string(),
|
||||
Value::String(UNSUPPORTED_IMAGE_MARKER.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
replaced
|
||||
}
|
||||
|
||||
fn responses_input_has_image_blocks(input: Option<&Value>) -> bool {
|
||||
match input {
|
||||
Some(Value::Array(items)) => items.iter().any(responses_input_item_has_image_blocks),
|
||||
@@ -193,6 +346,9 @@ fn responses_input_item_has_image_blocks(item: &Value) -> bool {
|
||||
}
|
||||
|
||||
item.get("content").is_some_and(content_has_image_blocks)
|
||||
|| item
|
||||
.get("output")
|
||||
.is_some_and(|output| tool_output_contains_media(output, ToolMediaScope::ImagesOnly))
|
||||
}
|
||||
|
||||
fn replace_images_in_responses_input(input: &mut Value) -> usize {
|
||||
@@ -218,6 +374,23 @@ fn replace_images_in_responses_input_item(item: &mut Value) -> usize {
|
||||
replaced += replace_images_in_content_with_text_type(content, "input_text");
|
||||
}
|
||||
|
||||
if let Some(output) = item.get_mut("output") {
|
||||
// The image-capability fallback deliberately strips images only.
|
||||
// Tool-output files/audio remain a known unsupported-modality gap.
|
||||
let replacement_block = json!({
|
||||
"type": "input_text",
|
||||
"text": UNSUPPORTED_IMAGE_MARKER
|
||||
});
|
||||
let mut discarded_media = Vec::new();
|
||||
replaced += strip_media_from_tool_value(
|
||||
output,
|
||||
&mut discarded_media,
|
||||
ToolMediaScope::ImagesOnly,
|
||||
&replacement_block,
|
||||
UNSUPPORTED_IMAGE_MARKER,
|
||||
);
|
||||
}
|
||||
|
||||
replaced
|
||||
}
|
||||
|
||||
@@ -283,6 +456,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn large_tool_data_url() -> String {
|
||||
format!(
|
||||
"data:image/png;base64,{}",
|
||||
"SANITIZER_TOOL_MEDIA_SENTINEL".repeat(400)
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_images_when_model_capability_is_unknown() {
|
||||
let provider = provider(json!({}));
|
||||
@@ -603,6 +783,379 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_file_backed_tool_result_image_and_preserves_cache_control() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_file",
|
||||
"content": [{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "file",
|
||||
"file_id": "file_123"
|
||||
},
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
assert!(contains_image_blocks(&body));
|
||||
let count = replace_image_blocks_with_marker(&mut body);
|
||||
let replacement = &body["messages"][0]["content"][0]["content"][0];
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(replacement["type"], "text");
|
||||
assert_eq!(replacement["text"], UNSUPPORTED_IMAGE_MARKER);
|
||||
assert_eq!(replacement["cache_control"]["type"], "ephemeral");
|
||||
assert!(!body.to_string().contains("file_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_stringified_anthropic_tool_result_image_blocks() {
|
||||
let content = json!({
|
||||
"content": [{
|
||||
"type": "image",
|
||||
"mimeType": "image/png",
|
||||
"data": "ANTHROPIC_STRING_TOOL_SENTINEL"
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_1",
|
||||
"content": content
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
assert!(contains_image_blocks(&body));
|
||||
let count = replace_image_blocks_with_marker(&mut body);
|
||||
let rewritten = body["messages"][0]["content"][0]["content"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert!(rewritten.contains(UNSUPPORTED_IMAGE_MARKER));
|
||||
assert!(!rewritten.contains("ANTHROPIC_STRING_TOOL_SENTINEL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_and_replaces_responses_function_output_images() {
|
||||
let data_url = large_tool_data_url();
|
||||
let mut body = json!({
|
||||
"model": "text-only",
|
||||
"input": [{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": {
|
||||
"content": [
|
||||
{"type": "input_text", "text": "caption"},
|
||||
{"type": "input_image", "image_url": data_url.clone()},
|
||||
{"type": "image", "mimeType": "image/webp", "data": "MCP_SENTINEL"}
|
||||
]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
assert!(contains_image_blocks(&body));
|
||||
let replaced = replace_image_blocks_with_marker(&mut body);
|
||||
|
||||
assert_eq!(replaced, 2);
|
||||
assert_eq!(
|
||||
body["input"][0]["output"]["content"][1],
|
||||
json!({"type": "input_text", "text": UNSUPPORTED_IMAGE_MARKER})
|
||||
);
|
||||
assert_eq!(
|
||||
body["input"][0]["output"]["content"][2],
|
||||
json!({"type": "input_text", "text": UNSUPPORTED_IMAGE_MARKER})
|
||||
);
|
||||
assert!(!body.to_string().contains(&data_url));
|
||||
assert!(!body.to_string().contains("MCP_SENTINEL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proactive_text_only_sanitizer_covers_responses_tool_outputs() {
|
||||
let provider = provider(json!({
|
||||
"models": [{"id": "text-model", "input": ["text"]}]
|
||||
}));
|
||||
let mut body = json!({
|
||||
"model": "text-model",
|
||||
"input": [{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": [{
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,PROACTIVE_SENTINEL"
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
let replaced = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(replaced, 1);
|
||||
assert_eq!(body["input"][0]["output"][0]["type"], "input_text");
|
||||
assert!(!body.to_string().contains("PROACTIVE_SENTINEL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_and_replaces_json_string_tool_output_symmetrically() {
|
||||
let data_url = large_tool_data_url();
|
||||
let output = json!({
|
||||
"content": [{
|
||||
"type": "input_image",
|
||||
"image_url": data_url.clone()
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
let mut body = json!({
|
||||
"input": [{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_string",
|
||||
"output": output
|
||||
}]
|
||||
});
|
||||
|
||||
assert!(contains_image_blocks(&body));
|
||||
let replaced = replace_image_blocks_with_marker(&mut body);
|
||||
|
||||
assert_eq!(replaced, 1);
|
||||
let rewritten = body["input"][0]["output"].as_str().unwrap();
|
||||
assert!(rewritten.contains(UNSUPPORTED_IMAGE_MARKER));
|
||||
assert!(!rewritten.contains(&data_url));
|
||||
let parsed: Value = serde_json::from_str(rewritten).unwrap();
|
||||
assert_eq!(parsed["content"][0]["type"], "input_text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_and_replaces_whole_string_tool_image_data_url() {
|
||||
let data_url = large_tool_data_url();
|
||||
let mut body = json!({
|
||||
"input": [{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_raw",
|
||||
"output": data_url.clone()
|
||||
}]
|
||||
});
|
||||
|
||||
assert!(contains_image_blocks(&body));
|
||||
let replaced = replace_image_blocks_with_marker(&mut body);
|
||||
|
||||
assert_eq!(replaced, 1);
|
||||
assert_eq!(
|
||||
body["input"][0]["output"],
|
||||
Value::String(UNSUPPORTED_IMAGE_MARKER.to_string())
|
||||
);
|
||||
assert!(!body.to_string().contains(&data_url));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_and_replaces_custom_tool_output_images() {
|
||||
let mut body = json!({
|
||||
"input": [{
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call_custom",
|
||||
"status": "completed",
|
||||
"output": [{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://example.com/render.png"}
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
assert!(contains_image_blocks(&body));
|
||||
let replaced = replace_image_blocks_with_marker(&mut body);
|
||||
|
||||
assert_eq!(replaced, 1);
|
||||
assert_eq!(body["input"][0]["status"], "completed");
|
||||
assert_eq!(body["input"][0]["output"][0]["type"], "input_text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_no_media_and_untyped_remote_tool_outputs() {
|
||||
let mut body = json!({
|
||||
"input": [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_text",
|
||||
"output": {"content": [{"type": "text", "text": "ordinary result"}]}
|
||||
},
|
||||
{
|
||||
"type": "tool_search_output",
|
||||
"call_id": "call_search",
|
||||
"output": {
|
||||
"image_url": {"url": "https://example.com/search-thumbnail.png"}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
let original = body.clone();
|
||||
|
||||
assert!(!contains_image_blocks(&body));
|
||||
assert_eq!(replace_image_blocks_with_marker(&mut body), 0);
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_retry_scope_intentionally_ignores_tool_files_and_audio() {
|
||||
let mut body = json!({
|
||||
"input": [{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_modalities",
|
||||
"output": {
|
||||
"content": [
|
||||
{"type": "input_file", "file_id": "file_1"},
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {"data": "AUDIO", "format": "wav"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let original = body.clone();
|
||||
|
||||
assert!(!contains_image_blocks(&body));
|
||||
assert_eq!(replace_image_blocks_with_marker(&mut body), 0);
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_synthetic_user_and_tool_role_chat_image_parts() {
|
||||
let mut body = json!({
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "tool media"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,USER_SENTINEL"}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": [{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://example.com/tool.png"}
|
||||
}]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert!(contains_image_blocks(&body));
|
||||
let replaced = replace_image_blocks_with_marker(&mut body);
|
||||
|
||||
assert_eq!(replaced, 2);
|
||||
assert_eq!(body["messages"][0]["content"][1]["type"], "text");
|
||||
assert_eq!(body["messages"][1]["content"][0]["type"], "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_and_replaces_stringified_chat_tool_image() {
|
||||
let content = json!({
|
||||
"content": [{
|
||||
"type": "image",
|
||||
"mimeType": "image/png",
|
||||
"data": "STRINGIFIED_CHAT_TOOL_SENTINEL"
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
let mut body = json!({
|
||||
"messages": [{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": content
|
||||
}]
|
||||
});
|
||||
|
||||
assert!(contains_image_blocks(&body));
|
||||
let replaced = replace_image_blocks_with_marker(&mut body);
|
||||
let rewritten = body["messages"][0]["content"].as_str().unwrap();
|
||||
|
||||
assert_eq!(replaced, 1);
|
||||
assert!(rewritten.contains(UNSUPPORTED_IMAGE_MARKER));
|
||||
assert!(!rewritten.contains("STRINGIFIED_CHAT_TOOL_SENTINEL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_and_replaces_gemini_native_image_parts() {
|
||||
let mut body = json!({
|
||||
"contents": [{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"functionResponse": {
|
||||
"name": "inspect",
|
||||
"response": {"content": "done"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"inlineData": {
|
||||
"mimeType": "image/png",
|
||||
"data": "GEMINI_INLINE_SENTINEL"
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
assert!(contains_image_blocks(&body));
|
||||
let replaced = replace_image_blocks_with_marker(&mut body);
|
||||
|
||||
assert_eq!(replaced, 1);
|
||||
assert_eq!(
|
||||
body["contents"][0]["parts"][1]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
assert!(!body.to_string().contains("GEMINI_INLINE_SENTINEL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_and_removes_nested_gemini_function_response_media() {
|
||||
let mut body = json!({
|
||||
"contents": [{
|
||||
"role": "user",
|
||||
"parts": [{
|
||||
"functionResponse": {
|
||||
"name": "inspect",
|
||||
"response": {"content": "done"},
|
||||
"parts": [{
|
||||
"inlineData": {
|
||||
"mimeType": "image/webp",
|
||||
"data": "GEMINI_FUNCTION_SENTINEL"
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
assert!(contains_image_blocks(&body));
|
||||
let replaced = replace_image_blocks_with_marker(&mut body);
|
||||
|
||||
assert_eq!(replaced, 1);
|
||||
assert!(body["contents"][0]["parts"][0]["functionResponse"]["parts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
body["contents"][0]["parts"][0]["functionResponse"]["response"]["cc_switch_media"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
assert!(!body.to_string().contains("GEMINI_FUNCTION_SENTINEL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_unsupported_image_errors() {
|
||||
let error = ProxyError::UpstreamError {
|
||||
|
||||
@@ -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;
|
||||
@@ -33,6 +31,7 @@ pub(crate) mod switch_lock;
|
||||
pub mod thinking_budget_rectifier;
|
||||
pub mod thinking_optimizer;
|
||||
pub mod thinking_rectifier;
|
||||
pub(crate) mod tool_media;
|
||||
pub(crate) mod types;
|
||||
pub mod usage;
|
||||
|
||||
@@ -46,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!({
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
|
||||
//! 参考: anthropic-proxy-rs
|
||||
|
||||
use crate::proxy::{error::ProxyError, json_canonical::canonical_json_string};
|
||||
use crate::proxy::{
|
||||
error::ProxyError,
|
||||
json_canonical::canonical_json_string,
|
||||
tool_media::{
|
||||
chat_media_part_from_tool_part, flush_pending_chat_tool_media, plan_chat_tool_output_media,
|
||||
queue_chat_tool_output_media, ToolMediaScope,
|
||||
},
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const ANTHROPIC_BILLING_HEADER_PREFIX: &str = "x-anthropic-billing-header:";
|
||||
@@ -375,6 +382,7 @@ fn convert_message_to_openai(
|
||||
if let Some(blocks) = content.as_array() {
|
||||
let mut content_parts = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
let mut pending_tool_media = Vec::new();
|
||||
// reasoning_parts: 仅在兼容 Moonshot/Kimi/DeepSeek thinking tool-call 路径时
|
||||
// 生成 reasoning_content,通用 OpenAI-compatible 路径不发送该非标准字段。
|
||||
let mut reasoning_parts = Vec::new();
|
||||
@@ -389,16 +397,10 @@ fn convert_message_to_openai(
|
||||
}
|
||||
}
|
||||
"image" => {
|
||||
if let Some(source) = block.get("source") {
|
||||
let media_type = source
|
||||
.get("media_type")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("image/png");
|
||||
let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
|
||||
content_parts.push(json!({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": format!("data:{};base64,{}", media_type, data)}
|
||||
}));
|
||||
if let Some(image) =
|
||||
chat_media_part_from_tool_part(block, ToolMediaScope::ImagesOnly)
|
||||
{
|
||||
content_parts.push(image);
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
@@ -421,10 +423,22 @@ fn convert_message_to_openai(
|
||||
.and_then(|i| i.as_str())
|
||||
.unwrap_or("");
|
||||
let content_val = block.get("content");
|
||||
let content_str = match content_val {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(v) => canonical_json_string(v),
|
||||
None => String::new(),
|
||||
let media_plan = content_val.cloned().and_then(plan_chat_tool_output_media);
|
||||
let content_str = if let Some(media_plan) = media_plan {
|
||||
queue_chat_tool_output_media(
|
||||
&mut pending_tool_media,
|
||||
tool_use_id,
|
||||
media_plan.media_parts,
|
||||
);
|
||||
media_plan.tool_content
|
||||
} else {
|
||||
// Keep the no-media representation exactly equal to
|
||||
// the legacy converter for prompt-cache stability.
|
||||
match content_val {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(v) => canonical_json_string(v),
|
||||
None => String::new(),
|
||||
}
|
||||
};
|
||||
result.push(json!({
|
||||
"role": "tool",
|
||||
@@ -452,6 +466,11 @@ fn convert_message_to_openai(
|
||||
}
|
||||
}
|
||||
|
||||
// Chat tool messages cannot carry image parts. Keep parallel tool
|
||||
// results adjacent, then present all extracted media in one user turn
|
||||
// before any ordinary message content from the same Anthropic turn.
|
||||
flush_pending_chat_tool_media(&mut result, &mut pending_tool_media);
|
||||
|
||||
// 添加带内容和/或工具调用的消息
|
||||
if !content_parts.is_empty() || !tool_calls.is_empty() {
|
||||
let mut msg = json!({"role": role});
|
||||
@@ -1133,6 +1152,164 @@ mod tests {
|
||||
assert_eq!(msg["content"], "Sunny, 25°C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_no_media_tool_results_keep_legacy_representation() {
|
||||
let raw_json_string = "{ \"status\": \"ok\", \"count\": 2 }";
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_string",
|
||||
"content": raw_json_string
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_array",
|
||||
"content": [{"type": "text", "text": "plain"}]
|
||||
}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let messages = result["messages"].as_array().unwrap();
|
||||
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[0]["content"], raw_json_string);
|
||||
assert_eq!(
|
||||
messages[1]["content"],
|
||||
canonical_json_string(&json!([{"type": "text", "text": "plain"}]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_moves_tool_result_image_to_user_message() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_image",
|
||||
"content": [
|
||||
{"type": "text", "text": "caption"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "CLAUDE_CHAT_IMAGE_SENTINEL"
|
||||
},
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
"prompt_cache_breakpoint": true
|
||||
}
|
||||
]
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let messages = result["messages"].as_array().unwrap();
|
||||
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[0]["role"], "tool");
|
||||
assert_eq!(messages[0]["tool_call_id"], "call_image");
|
||||
assert!(messages[0]["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("tool result media moved"));
|
||||
assert!(!messages[0]["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("CLAUDE_CHAT_IMAGE_SENTINEL"));
|
||||
assert_eq!(messages[1]["role"], "user");
|
||||
assert_eq!(
|
||||
messages[1]["content"][0]["text"],
|
||||
"[cc-switch: media output of tool call call_image]"
|
||||
);
|
||||
assert_eq!(messages[1]["content"][1]["type"], "image_url");
|
||||
assert!(messages[1]["content"][1].get("cache_control").is_none());
|
||||
assert!(messages[1]["content"][1]
|
||||
.get("prompt_cache_breakpoint")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
messages[1]["content"][1]["image_url"]["url"],
|
||||
"data:image/png;base64,CLAUDE_CHAT_IMAGE_SENTINEL"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_batches_parallel_tool_result_media() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_1",
|
||||
"content": [{
|
||||
"type": "image",
|
||||
"source": {"type": "base64", "media_type": "image/png", "data": "ONE"}
|
||||
}]
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_2",
|
||||
"content": [{
|
||||
"type": "image",
|
||||
"source": {"type": "base64", "media_type": "image/jpeg", "data": "TWO"}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let messages = result["messages"].as_array().unwrap();
|
||||
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert_eq!(messages[0]["role"], "tool");
|
||||
assert_eq!(messages[1]["role"], "tool");
|
||||
assert_eq!(messages[2]["role"], "user");
|
||||
assert_eq!(messages[2]["content"].as_array().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_maps_remote_image_source() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "url",
|
||||
"url": "https://example.com/image.png"
|
||||
},
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
"prompt_cache_breakpoint": true
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"][0]["image_url"]["url"],
|
||||
"https://example.com/image.png"
|
||||
);
|
||||
assert!(result["messages"][0]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_none());
|
||||
assert!(result["messages"][0]["content"][0]
|
||||
.get("prompt_cache_breakpoint")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_simple() {
|
||||
let input = json!({
|
||||
|
||||
@@ -18,6 +18,9 @@ use super::transform_responses::{sanitize_anthropic_tool_use_input, TOOL_RESULT_
|
||||
use crate::proxy::error::ProxyError;
|
||||
use crate::proxy::json_canonical::canonical_json_string;
|
||||
use crate::proxy::sse::{strip_sse_field, take_sse_block};
|
||||
use crate::proxy::tool_media::{
|
||||
strip_and_clamp_media_from_tool_value, ToolMediaScope, TOOL_RESULT_MEDIA_ATTACHED_MARKER,
|
||||
};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
@@ -723,10 +726,12 @@ struct ToolResultContent {
|
||||
|
||||
fn tool_result_content_from_responses_item(item: &Value) -> ToolResultContent {
|
||||
match item.get("output") {
|
||||
Some(Value::String(text)) => ToolResultContent {
|
||||
content: json!(text),
|
||||
is_error: false,
|
||||
},
|
||||
Some(text @ Value::String(_)) => {
|
||||
alternate_image_tool_result_content(text).unwrap_or_else(|| ToolResultContent {
|
||||
content: text.clone(),
|
||||
is_error: false,
|
||||
})
|
||||
}
|
||||
Some(Value::Array(parts)) => {
|
||||
let mut content = Vec::new();
|
||||
let mut is_error = false;
|
||||
@@ -761,10 +766,26 @@ fn tool_result_content_from_responses_item(item: &Value) -> ToolResultContent {
|
||||
}));
|
||||
}
|
||||
}
|
||||
_ => content.push(json!({
|
||||
"type":"text",
|
||||
"text":canonical_json_string(part)
|
||||
})),
|
||||
_ => {
|
||||
if let Some(alternate) = alternate_image_tool_result_content(part) {
|
||||
is_error |= alternate.is_error;
|
||||
match alternate.content {
|
||||
Value::Array(mut blocks) => content.append(&mut blocks),
|
||||
Value::String(text) => {
|
||||
content.push(json!({"type":"text","text":text}))
|
||||
}
|
||||
other => content.push(json!({
|
||||
"type":"text",
|
||||
"text":canonical_json_string(&other)
|
||||
})),
|
||||
}
|
||||
} else {
|
||||
content.push(json!({
|
||||
"type":"text",
|
||||
"text":canonical_json_string(part)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolResultContent {
|
||||
@@ -772,10 +793,12 @@ fn tool_result_content_from_responses_item(item: &Value) -> ToolResultContent {
|
||||
is_error,
|
||||
}
|
||||
}
|
||||
Some(value) => ToolResultContent {
|
||||
content: json!(canonical_json_string(value)),
|
||||
is_error: false,
|
||||
},
|
||||
Some(value) => {
|
||||
alternate_image_tool_result_content(value).unwrap_or_else(|| ToolResultContent {
|
||||
content: json!(canonical_json_string(value)),
|
||||
is_error: false,
|
||||
})
|
||||
}
|
||||
None => ToolResultContent {
|
||||
content: json!(canonical_json_string(item)),
|
||||
is_error: false,
|
||||
@@ -783,6 +806,96 @@ fn tool_result_content_from_responses_item(item: &Value) -> ToolResultContent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert image-bearing tool-output variants that are not native Responses
|
||||
/// content blocks. The shared traversal recognizes JSON strings, MCP image
|
||||
/// blocks, Anthropic image blocks, Chat image_url blocks, nested `content`
|
||||
/// wrappers, and whole image data URLs.
|
||||
fn alternate_image_tool_result_content(value: &Value) -> Option<ToolResultContent> {
|
||||
let mut cleaned = value.clone();
|
||||
let replacement_block = json!({
|
||||
"type":"input_text",
|
||||
"text":TOOL_RESULT_MEDIA_ATTACHED_MARKER
|
||||
});
|
||||
let mut chat_media_parts = Vec::new();
|
||||
let replaced = strip_and_clamp_media_from_tool_value(
|
||||
&mut cleaned,
|
||||
&mut chat_media_parts,
|
||||
ToolMediaScope::ImagesOnly,
|
||||
&replacement_block,
|
||||
TOOL_RESULT_MEDIA_ATTACHED_MARKER,
|
||||
);
|
||||
if replaced == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut content = Vec::new();
|
||||
let mut is_error = false;
|
||||
append_sanitized_tool_result_value(&cleaned, &mut content, &mut is_error);
|
||||
content.extend(
|
||||
chat_media_parts
|
||||
.iter()
|
||||
.filter_map(image_block_from_input_image),
|
||||
);
|
||||
|
||||
Some(ToolResultContent {
|
||||
content: Value::Array(content),
|
||||
is_error,
|
||||
})
|
||||
}
|
||||
|
||||
fn append_sanitized_tool_result_value(
|
||||
value: &Value,
|
||||
content: &mut Vec<Value>,
|
||||
is_error: &mut bool,
|
||||
) {
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
if text == TOOL_RESULT_ERROR_MARKER {
|
||||
*is_error = true;
|
||||
} else if !text.is_empty() {
|
||||
content.push(json!({"type":"text","text":text}));
|
||||
}
|
||||
}
|
||||
Value::Array(parts) => {
|
||||
for part in parts {
|
||||
match part.get("type").and_then(Value::as_str) {
|
||||
Some("input_text" | "output_text" | "text") => {
|
||||
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
||||
if text == TOOL_RESULT_ERROR_MARKER {
|
||||
*is_error = true;
|
||||
} else {
|
||||
content.push(json!({"type":"text","text":text}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => content.push(json!({
|
||||
"type":"text",
|
||||
"text":canonical_json_string(part)
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(object)
|
||||
if matches!(
|
||||
object.get("type").and_then(Value::as_str),
|
||||
Some("input_text" | "output_text" | "text")
|
||||
) =>
|
||||
{
|
||||
if let Some(text) = object.get("text").and_then(Value::as_str) {
|
||||
if text == TOOL_RESULT_ERROR_MARKER {
|
||||
*is_error = true;
|
||||
} else {
|
||||
content.push(json!({"type":"text","text":text}));
|
||||
}
|
||||
}
|
||||
}
|
||||
other => content.push(json!({
|
||||
"type":"text",
|
||||
"text":canonical_json_string(other)
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures the first message is a user: compacted/resumed sessions may start with
|
||||
/// assistant or function_call, but Anthropic requires the first to be user, else 400.
|
||||
/// An empty array is not handled (the caller decides whether to error).
|
||||
@@ -1071,8 +1184,12 @@ fn image_block_from_input_image(part: &Value) -> Option<Value> {
|
||||
.or_else(|| v.get("url").and_then(|u| u.as_str()).map(str::to_string))
|
||||
})?;
|
||||
|
||||
if let Some(rest) = url.strip_prefix("data:") {
|
||||
if url
|
||||
.get(..5)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
|
||||
{
|
||||
// data:<media_type>;base64,<data>
|
||||
let rest = &url[5..];
|
||||
let (meta, data) = rest.split_once(',')?;
|
||||
let media_type = meta.split(';').next().unwrap_or("image/png");
|
||||
Some(json!({
|
||||
@@ -1083,7 +1200,13 @@ fn image_block_from_input_image(part: &Value) -> Option<Value> {
|
||||
"data": data
|
||||
}
|
||||
}))
|
||||
} else if url.starts_with("http://") || url.starts_with("https://") {
|
||||
} else if url
|
||||
.get(..7)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://"))
|
||||
|| url
|
||||
.get(..8)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
|
||||
{
|
||||
Some(json!({
|
||||
"type": "image",
|
||||
"source": { "type": "url", "url": url }
|
||||
@@ -1295,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();
|
||||
}
|
||||
@@ -2555,6 +2704,80 @@ mod tests {
|
||||
assert_eq!(content[1]["type"], "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alternate_mcp_tool_image_is_not_stringified_for_anthropic() {
|
||||
let response = responses_request_to_anthropic(
|
||||
json!({
|
||||
"model": "c",
|
||||
"input": [
|
||||
{"type": "function_call", "call_id": "c1", "name": "inspect", "arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "c1", "output": [{
|
||||
"type": "image",
|
||||
"mimeType": "image/webp",
|
||||
"data": "MCP_ANTHROPIC_IMAGE_SENTINEL"
|
||||
}]}
|
||||
]
|
||||
}),
|
||||
4096,
|
||||
)
|
||||
.unwrap();
|
||||
let content = &response["messages"][2]["content"][0]["content"];
|
||||
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
assert!(!content[0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("MCP_ANTHROPIC_IMAGE_SENTINEL"));
|
||||
assert_eq!(content[1]["type"], "image");
|
||||
assert_eq!(content[1]["source"]["media_type"], "image/webp");
|
||||
assert_eq!(content[1]["source"]["data"], "MCP_ANTHROPIC_IMAGE_SENTINEL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_string_nested_tool_image_is_not_text_for_anthropic() {
|
||||
let residual_base64 = "A".repeat(20_000);
|
||||
let encoded_output = json!({
|
||||
"content": [
|
||||
{"type": "input_text", "text": "caption"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "data:image/png;base64,STRING_IMAGE_SENTINEL"
|
||||
}
|
||||
},
|
||||
{"type": "video", "data": residual_base64}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let response = responses_request_to_anthropic(
|
||||
json!({
|
||||
"model": "c",
|
||||
"input": [
|
||||
{"type": "function_call", "call_id": "c1", "name": "inspect", "arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "c1", "output": encoded_output}
|
||||
]
|
||||
}),
|
||||
4096,
|
||||
)
|
||||
.unwrap();
|
||||
let content = response["messages"][2]["content"][0]["content"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
let image = content
|
||||
.iter()
|
||||
.find(|block| block["type"] == "image")
|
||||
.expect("stringified tool image should become an Anthropic image block");
|
||||
|
||||
assert_eq!(image["source"]["data"], "STRING_IMAGE_SENTINEL");
|
||||
assert!(content
|
||||
.iter()
|
||||
.filter_map(|block| block.get("text").and_then(Value::as_str))
|
||||
.all(|text| !text.contains("STRING_IMAGE_SENTINEL")));
|
||||
let serialized = response.to_string();
|
||||
assert!(serialized.contains("[cc-switch: omitted 20000 bytes]"));
|
||||
assert!(!serialized.contains(&"A".repeat(64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_structured_tool_output_restores_error_file_and_unknown_parts() {
|
||||
let response = responses_request_to_anthropic(
|
||||
@@ -2756,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ use crate::proxy::{
|
||||
canonical_json_string, canonicalize_json_string_if_parseable, canonicalize_tool_arguments,
|
||||
short_sha256_hex,
|
||||
},
|
||||
tool_media::{
|
||||
chat_file_from_input_file, flush_pending_chat_tool_media, plan_chat_tool_output_media,
|
||||
queue_chat_tool_output_media, strip_and_clamp_media_from_tool_value, ToolMediaScope,
|
||||
TOOL_RESULT_MEDIA_MOVED_MARKER,
|
||||
},
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -42,7 +47,6 @@ const CUSTOM_TOOL_INPUT_FIELD: &str = "input";
|
||||
const CHAT_TOOL_NAME_MAX_LEN: usize = 64;
|
||||
const CUSTOM_TOOL_INPUT_DESCRIPTION: &str = "Raw string input for the original custom tool. Preserve formatting exactly and follow the original tool definition embedded in the description.";
|
||||
const CUSTOM_TOOL_PRESERVED_METADATA_HEADING: &str = "Original tool definition:";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum CodexToolKind {
|
||||
Function,
|
||||
@@ -545,6 +549,7 @@ fn append_responses_input_as_chat_messages(
|
||||
tool_context: &CodexToolContext,
|
||||
) -> Result<(), ProxyError> {
|
||||
let mut pending_tool_calls = Vec::new();
|
||||
let mut pending_media = Vec::new();
|
||||
let mut pending_reasoning: Option<String> = None;
|
||||
let mut last_assistant_index: Option<usize> = None;
|
||||
|
||||
@@ -561,6 +566,7 @@ fn append_responses_input_as_chat_messages(
|
||||
item,
|
||||
messages,
|
||||
&mut pending_tool_calls,
|
||||
&mut pending_media,
|
||||
&mut pending_reasoning,
|
||||
&mut last_assistant_index,
|
||||
tool_context,
|
||||
@@ -572,6 +578,7 @@ fn append_responses_input_as_chat_messages(
|
||||
input,
|
||||
messages,
|
||||
&mut pending_tool_calls,
|
||||
&mut pending_media,
|
||||
&mut pending_reasoning,
|
||||
&mut last_assistant_index,
|
||||
tool_context,
|
||||
@@ -580,9 +587,14 @@ fn append_responses_input_as_chat_messages(
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// If a later assistant tool-call batch was accumulated after an earlier
|
||||
// media-bearing result, the synthetic user media belongs before that next
|
||||
// assistant turn.
|
||||
flush_pending_chat_tool_media(messages, &mut pending_media);
|
||||
flush_pending_tool_calls(
|
||||
messages,
|
||||
&mut pending_tool_calls,
|
||||
&mut pending_media,
|
||||
&mut pending_reasoning,
|
||||
&mut last_assistant_index,
|
||||
);
|
||||
@@ -603,6 +615,7 @@ fn append_responses_item_as_chat_message(
|
||||
item: &Value,
|
||||
messages: &mut Vec<Value>,
|
||||
pending_tool_calls: &mut Vec<Value>,
|
||||
pending_media: &mut Vec<Value>,
|
||||
pending_reasoning: &mut Option<String>,
|
||||
last_assistant_index: &mut Option<usize>,
|
||||
tool_context: &CodexToolContext,
|
||||
@@ -628,14 +641,26 @@ fn append_responses_item_as_chat_message(
|
||||
flush_pending_tool_calls(
|
||||
messages,
|
||||
pending_tool_calls,
|
||||
pending_media,
|
||||
pending_reasoning,
|
||||
last_assistant_index,
|
||||
);
|
||||
let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let output = match item.get("output") {
|
||||
Some(Value::String(s)) => canonicalize_json_string_if_parseable(s),
|
||||
Some(v) => canonical_json_string(v),
|
||||
None => String::new(),
|
||||
let media_plan = item
|
||||
.get("output")
|
||||
.cloned()
|
||||
.and_then(plan_chat_tool_output_media);
|
||||
let output = if let Some(media_plan) = media_plan {
|
||||
queue_chat_tool_output_media(pending_media, call_id, media_plan.media_parts);
|
||||
media_plan.tool_content
|
||||
} else {
|
||||
// Cache-sensitive no-media fallback: keep these expressions
|
||||
// byte-for-byte equivalent to the pre-fix conversion.
|
||||
match item.get("output") {
|
||||
Some(Value::String(s)) => canonicalize_json_string_if_parseable(s),
|
||||
Some(v) => canonical_json_string(v),
|
||||
None => String::new(),
|
||||
}
|
||||
};
|
||||
messages.push(json!({
|
||||
"role": "tool",
|
||||
@@ -647,11 +672,36 @@ fn append_responses_item_as_chat_message(
|
||||
flush_pending_tool_calls(
|
||||
messages,
|
||||
pending_tool_calls,
|
||||
pending_media,
|
||||
pending_reasoning,
|
||||
last_assistant_index,
|
||||
);
|
||||
let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let output = canonical_json_string(item);
|
||||
let mut transformed_item = item.clone();
|
||||
let replacement_block = json!({
|
||||
"type": "text",
|
||||
"text": TOOL_RESULT_MEDIA_MOVED_MARKER
|
||||
});
|
||||
let mut media_parts = Vec::new();
|
||||
let replaced = transformed_item
|
||||
.get_mut("output")
|
||||
.map(|output| {
|
||||
strip_and_clamp_media_from_tool_value(
|
||||
output,
|
||||
&mut media_parts,
|
||||
ToolMediaScope::AllSupported,
|
||||
&replacement_block,
|
||||
TOOL_RESULT_MEDIA_MOVED_MARKER,
|
||||
)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let output = if replaced > 0 {
|
||||
queue_chat_tool_output_media(pending_media, call_id, media_parts);
|
||||
canonical_json_string(&transformed_item)
|
||||
} else {
|
||||
// Preserve the legacy whole-item representation exactly.
|
||||
canonical_json_string(item)
|
||||
};
|
||||
messages.push(json!({
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
@@ -672,9 +722,14 @@ fn append_responses_item_as_chat_message(
|
||||
flush_pending_tool_calls(
|
||||
messages,
|
||||
pending_tool_calls,
|
||||
pending_media,
|
||||
pending_reasoning,
|
||||
last_assistant_index,
|
||||
);
|
||||
// `flush_pending_tool_calls` intentionally returns early when
|
||||
// there is no new assistant batch. A previous tool result may
|
||||
// still have media waiting, so flush it before this new message.
|
||||
flush_pending_chat_tool_media(messages, pending_media);
|
||||
let role = item
|
||||
.get("role")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -705,13 +760,15 @@ fn append_responses_item_as_chat_message(
|
||||
messages.push(message);
|
||||
}
|
||||
Some("message") | None => {
|
||||
flush_pending_tool_calls(
|
||||
messages,
|
||||
pending_tool_calls,
|
||||
pending_reasoning,
|
||||
last_assistant_index,
|
||||
);
|
||||
if item.get("role").is_some() || item.get("content").is_some() {
|
||||
flush_pending_tool_calls(
|
||||
messages,
|
||||
pending_tool_calls,
|
||||
pending_media,
|
||||
pending_reasoning,
|
||||
last_assistant_index,
|
||||
);
|
||||
flush_pending_chat_tool_media(messages, pending_media);
|
||||
let message = responses_message_item_to_chat_message(
|
||||
item,
|
||||
pending_reasoning,
|
||||
@@ -720,16 +777,28 @@ fn append_responses_item_as_chat_message(
|
||||
);
|
||||
update_last_assistant_index(messages, &message, last_assistant_index);
|
||||
messages.push(message);
|
||||
} else if pending_media.is_empty() {
|
||||
// Preserve legacy no-media ordering: inert message-like items
|
||||
// used to close a pending tool-call batch.
|
||||
flush_pending_tool_calls(
|
||||
messages,
|
||||
pending_tool_calls,
|
||||
pending_media,
|
||||
pending_reasoning,
|
||||
last_assistant_index,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
flush_pending_tool_calls(
|
||||
messages,
|
||||
pending_tool_calls,
|
||||
pending_reasoning,
|
||||
last_assistant_index,
|
||||
);
|
||||
if item.get("role").is_some() || item.get("content").is_some() {
|
||||
flush_pending_tool_calls(
|
||||
messages,
|
||||
pending_tool_calls,
|
||||
pending_media,
|
||||
pending_reasoning,
|
||||
last_assistant_index,
|
||||
);
|
||||
flush_pending_chat_tool_media(messages, pending_media);
|
||||
let message = responses_message_item_to_chat_message(
|
||||
item,
|
||||
pending_reasoning,
|
||||
@@ -738,6 +807,16 @@ fn append_responses_item_as_chat_message(
|
||||
);
|
||||
update_last_assistant_index(messages, &message, last_assistant_index);
|
||||
messages.push(message);
|
||||
} else if pending_media.is_empty() {
|
||||
// Preserve legacy no-media ordering without letting an inert
|
||||
// unknown item flush a media-bearing result batch.
|
||||
flush_pending_tool_calls(
|
||||
messages,
|
||||
pending_tool_calls,
|
||||
pending_media,
|
||||
pending_reasoning,
|
||||
last_assistant_index,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -748,6 +827,7 @@ fn append_responses_item_as_chat_message(
|
||||
fn flush_pending_tool_calls(
|
||||
messages: &mut Vec<Value>,
|
||||
pending_tool_calls: &mut Vec<Value>,
|
||||
pending_media: &mut Vec<Value>,
|
||||
pending_reasoning: &mut Option<String>,
|
||||
last_assistant_index: &mut Option<usize>,
|
||||
) {
|
||||
@@ -755,6 +835,10 @@ fn flush_pending_tool_calls(
|
||||
return;
|
||||
}
|
||||
|
||||
// Media from the preceding tool-result batch must be presented before a
|
||||
// new assistant tool-call turn. Consecutive outputs do not enter here
|
||||
// because `pending_tool_calls` is empty after the first output.
|
||||
flush_pending_chat_tool_media(messages, pending_media);
|
||||
let mut message = json!({
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
@@ -1057,18 +1141,7 @@ fn responses_content_to_chat_content(_role: &str, content: &Value) -> Value {
|
||||
}
|
||||
|
||||
fn responses_input_file_to_chat_file(part: &Value) -> Option<Value> {
|
||||
let mut file = serde_json::Map::new();
|
||||
let has_supported_file_ref = part.get("file_id").is_some() || part.get("file_data").is_some();
|
||||
if !has_supported_file_ref {
|
||||
return None;
|
||||
}
|
||||
|
||||
for key in ["file_id", "file_data", "filename"] {
|
||||
if let Some(value) = part.get(key) {
|
||||
file.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
Some(Value::Object(file))
|
||||
chat_file_from_input_file(part)
|
||||
}
|
||||
|
||||
fn collect_tool_search_output_tools(value: &Value, context: &mut CodexToolContext) {
|
||||
@@ -1834,6 +1907,50 @@ pub fn chat_error_to_response_error(body: Option<&Value>) -> Value {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
|
||||
fn large_test_image_data_url() -> String {
|
||||
let bytes = b"CC_SWITCH_TOOL_MEDIA_SENTINEL".repeat(400);
|
||||
format!("data:image/png;base64,{}", STANDARD.encode(bytes))
|
||||
}
|
||||
|
||||
fn message_roles(result: &Value) -> Vec<&str> {
|
||||
result["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|message| message.get("role").and_then(Value::as_str))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn test_function_call(call_id: &str) -> Value {
|
||||
json!({
|
||||
"type": "function_call",
|
||||
"call_id": call_id,
|
||||
"name": "view_image",
|
||||
"arguments": "{}"
|
||||
})
|
||||
}
|
||||
|
||||
fn test_function_output(call_id: &str, output: Value) -> Value {
|
||||
json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
"output": output
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_test_input(items: Vec<Value>) -> Value {
|
||||
responses_to_chat_completions(json!({
|
||||
"model": "kimi-k3",
|
||||
"input": items
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn result_messages(result: &Value) -> &[Value] {
|
||||
result["messages"].as_array().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_with_stream_injects_include_usage() {
|
||||
@@ -3061,6 +3178,577 @@ mod tests {
|
||||
assert_eq!(messages[1]["content"], "plain text result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_moves_tool_image_to_synthetic_user_message() {
|
||||
let data_url = large_test_image_data_url();
|
||||
let result = convert_test_input(vec![
|
||||
test_function_call("call_image"),
|
||||
test_function_output(
|
||||
"call_image",
|
||||
json!([
|
||||
{"type": "input_text", "text": "screenshot follows"},
|
||||
{"type": "input_image", "image_url": data_url.clone()}
|
||||
]),
|
||||
),
|
||||
]);
|
||||
let messages = result_messages(&result);
|
||||
|
||||
assert_eq!(message_roles(&result), vec!["assistant", "tool", "user"]);
|
||||
assert!(messages[1]["content"].is_string());
|
||||
let tool_content: Value =
|
||||
serde_json::from_str(messages[1]["content"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(tool_content[0]["text"], "screenshot follows");
|
||||
assert_eq!(tool_content[1]["type"], "text");
|
||||
assert_eq!(tool_content[1]["text"], TOOL_RESULT_MEDIA_MOVED_MARKER);
|
||||
assert!(!messages[1]["content"].as_str().unwrap().contains(&data_url));
|
||||
|
||||
assert_eq!(
|
||||
messages[2]["content"][0]["text"],
|
||||
"[cc-switch: media output of tool call call_image]"
|
||||
);
|
||||
assert_eq!(messages[2]["content"][1]["type"], "image_url");
|
||||
assert_eq!(messages[2]["content"][1]["image_url"]["url"], data_url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_groups_parallel_media_after_all_tool_outputs() {
|
||||
let first_url = large_test_image_data_url();
|
||||
let second_payload = "MCP_TOOL_MEDIA_SENTINEL";
|
||||
let result = convert_test_input(vec![
|
||||
test_function_call("call_1"),
|
||||
test_function_call("call_2"),
|
||||
test_function_output(
|
||||
"call_1",
|
||||
json!({"type": "input_image", "image_url": first_url.clone()}),
|
||||
),
|
||||
json!({
|
||||
"type": "reasoning",
|
||||
"summary": [{"type": "summary_text", "text": "keep outputs adjacent"}]
|
||||
}),
|
||||
test_function_output(
|
||||
"call_2",
|
||||
json!({
|
||||
"type": "image",
|
||||
"mimeType": "image/webp",
|
||||
"data": second_payload
|
||||
}),
|
||||
),
|
||||
]);
|
||||
let messages = result_messages(&result);
|
||||
|
||||
assert_eq!(
|
||||
message_roles(&result),
|
||||
vec!["assistant", "tool", "tool", "user"]
|
||||
);
|
||||
assert_eq!(messages[0]["reasoning_content"], "keep outputs adjacent");
|
||||
assert_ne!(messages[0]["reasoning_content"], "tool call");
|
||||
assert_eq!(messages[1]["tool_call_id"], "call_1");
|
||||
assert_eq!(messages[2]["tool_call_id"], "call_2");
|
||||
assert!(messages[1]["content"].is_string());
|
||||
assert!(messages[2]["content"].is_string());
|
||||
assert!(!messages[1]["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(&first_url));
|
||||
assert!(!messages[2]["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(second_payload));
|
||||
assert_eq!(messages[3]["content"].as_array().unwrap().len(), 4);
|
||||
assert_eq!(
|
||||
messages[3]["content"][3]["image_url"]["url"],
|
||||
format!("data:image/webp;base64,{second_payload}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_flushes_media_before_next_tool_call_batch() {
|
||||
let result = convert_test_input(vec![
|
||||
test_function_call("call_1"),
|
||||
test_function_output(
|
||||
"call_1",
|
||||
json!({
|
||||
"type": "input_image",
|
||||
"image_url": large_test_image_data_url()
|
||||
}),
|
||||
),
|
||||
test_function_call("call_2"),
|
||||
test_function_output("call_2", json!("second result")),
|
||||
]);
|
||||
let messages = result_messages(&result);
|
||||
|
||||
assert_eq!(
|
||||
message_roles(&result),
|
||||
vec!["assistant", "tool", "user", "assistant", "tool"]
|
||||
);
|
||||
assert_eq!(messages[0]["tool_calls"][0]["id"], "call_1");
|
||||
assert_eq!(messages[3]["tool_calls"][0]["id"], "call_2");
|
||||
assert_eq!(messages[4]["tool_call_id"], "call_2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_flushes_media_before_real_user_messages() {
|
||||
let boundaries = [
|
||||
json!({"type": "input_text", "text": "continue"}),
|
||||
json!({"type": "future_message", "role": "user", "content": "continue"}),
|
||||
];
|
||||
|
||||
for boundary in boundaries {
|
||||
let result = convert_test_input(vec![
|
||||
test_function_call("call_1"),
|
||||
test_function_output(
|
||||
"call_1",
|
||||
json!({
|
||||
"type": "input_image",
|
||||
"image_url": large_test_image_data_url()
|
||||
}),
|
||||
),
|
||||
boundary,
|
||||
]);
|
||||
let messages = result_messages(&result);
|
||||
|
||||
assert_eq!(
|
||||
message_roles(&result),
|
||||
vec!["assistant", "tool", "user", "user"]
|
||||
);
|
||||
assert!(messages[2]["content"].is_array());
|
||||
assert_eq!(messages[3]["role"], "user");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_handles_raw_data_url_thresholds() {
|
||||
let large = large_test_image_data_url();
|
||||
let large_result = convert_test_input(vec![
|
||||
test_function_call("call_large"),
|
||||
test_function_output("call_large", Value::String(large.clone())),
|
||||
]);
|
||||
let large_messages = result_messages(&large_result);
|
||||
|
||||
assert_eq!(
|
||||
message_roles(&large_result),
|
||||
vec!["assistant", "tool", "user"]
|
||||
);
|
||||
assert_eq!(large_messages[1]["content"], TOOL_RESULT_MEDIA_MOVED_MARKER);
|
||||
assert_eq!(large_messages[2]["content"][1]["image_url"]["url"], large);
|
||||
|
||||
let small = "data:image/png;base64,YWJj";
|
||||
let small_result = convert_test_input(vec![
|
||||
test_function_call("call_small"),
|
||||
test_function_output("call_small", json!(small)),
|
||||
]);
|
||||
assert_eq!(message_roles(&small_result), vec!["assistant", "tool"]);
|
||||
assert_eq!(small_result["messages"][1]["content"], small);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_maps_supported_structured_image_shapes() {
|
||||
let cases = vec![
|
||||
(
|
||||
json!({
|
||||
"type": "input_image",
|
||||
"image_url": {"url": "https://example.com/input.png"},
|
||||
"detail": "high"
|
||||
}),
|
||||
"https://example.com/input.png",
|
||||
Some("high"),
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"type": "image_url",
|
||||
"image_url": "https://example.com/chat-string.png"
|
||||
}),
|
||||
"https://example.com/chat-string.png",
|
||||
None,
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "https://example.com/chat-object.png",
|
||||
"detail": "low"
|
||||
}
|
||||
}),
|
||||
"https://example.com/chat-object.png",
|
||||
Some("low"),
|
||||
),
|
||||
(
|
||||
json!({"image_url": "data:image/gif;base64,LOOSE_SENTINEL"}),
|
||||
"data:image/gif;base64,LOOSE_SENTINEL",
|
||||
None,
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"media_type": "image/jpeg",
|
||||
"data": "ANTHROPIC_SENTINEL"
|
||||
}
|
||||
}),
|
||||
"data:image/jpeg;base64,ANTHROPIC_SENTINEL",
|
||||
None,
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"type": "image",
|
||||
"mimeType": "image/webp",
|
||||
"data": "MCP_SENTINEL"
|
||||
}),
|
||||
"data:image/webp;base64,MCP_SENTINEL",
|
||||
None,
|
||||
),
|
||||
];
|
||||
|
||||
for (index, (output, expected_url, expected_detail)) in cases.into_iter().enumerate() {
|
||||
let call_id = format!("call_shape_{index}");
|
||||
let result = convert_test_input(vec![
|
||||
test_function_call(&call_id),
|
||||
test_function_output(&call_id, output),
|
||||
]);
|
||||
let image = &result["messages"][2]["content"][1];
|
||||
|
||||
assert_eq!(message_roles(&result), vec!["assistant", "tool", "user"]);
|
||||
assert_eq!(image["type"], "image_url");
|
||||
assert_eq!(image["image_url"]["url"], expected_url);
|
||||
match expected_detail {
|
||||
Some(detail) => assert_eq!(image["image_url"]["detail"], detail),
|
||||
None => assert!(image["image_url"].get("detail").is_none()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_extracts_media_from_json_string_and_content_wrapper() {
|
||||
let output = json!({
|
||||
"content": [
|
||||
{"type": "input_text", "text": "MCP response"},
|
||||
{
|
||||
"type": "image",
|
||||
"mimeType": "image/png",
|
||||
"data": "STRING_MCP_SENTINEL"
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let result = convert_test_input(vec![
|
||||
test_function_call("call_string"),
|
||||
test_function_output("call_string", Value::String(output)),
|
||||
]);
|
||||
let messages = result_messages(&result);
|
||||
|
||||
assert_eq!(message_roles(&result), vec!["assistant", "tool", "user"]);
|
||||
let tool_content: Value =
|
||||
serde_json::from_str(messages[1]["content"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(tool_content["content"][0]["text"], "MCP response");
|
||||
assert_eq!(
|
||||
tool_content["content"][1]["text"],
|
||||
TOOL_RESULT_MEDIA_MOVED_MARKER
|
||||
);
|
||||
assert!(!messages[1]["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("STRING_MCP_SENTINEL"));
|
||||
assert_eq!(
|
||||
messages[2]["content"][1]["image_url"]["url"],
|
||||
"data:image/png;base64,STRING_MCP_SENTINEL"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_extracts_tool_files_and_audio() {
|
||||
let result = convert_test_input(vec![
|
||||
test_function_call("call_media"),
|
||||
test_function_output(
|
||||
"call_media",
|
||||
json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "input_file",
|
||||
"file_id": "file_123",
|
||||
"filename": "report.pdf"
|
||||
},
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {"data": "AUDIO_SENTINEL", "format": "wav"}
|
||||
}
|
||||
]
|
||||
}),
|
||||
),
|
||||
]);
|
||||
let messages = result_messages(&result);
|
||||
|
||||
assert_eq!(message_roles(&result), vec!["assistant", "tool", "user"]);
|
||||
assert_eq!(messages[2]["content"][1]["type"], "file");
|
||||
assert_eq!(messages[2]["content"][1]["file"]["file_id"], "file_123");
|
||||
assert_eq!(messages[2]["content"][2]["type"], "input_audio");
|
||||
assert_eq!(
|
||||
messages[2]["content"][2]["input_audio"]["data"],
|
||||
"AUDIO_SENTINEL"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_extracts_custom_and_tool_search_output_media() {
|
||||
let cases = [
|
||||
(
|
||||
json!({
|
||||
"type": "custom_tool_call",
|
||||
"call_id": "call_custom",
|
||||
"name": "render",
|
||||
"input": "draw"
|
||||
}),
|
||||
json!({
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call_custom",
|
||||
"status": "completed",
|
||||
"output": {
|
||||
"content": [{
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,CUSTOM_SENTINEL"
|
||||
}]
|
||||
}
|
||||
}),
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"type": "tool_search_call",
|
||||
"call_id": "call_search",
|
||||
"arguments": {"query": "image tool"}
|
||||
}),
|
||||
json!({
|
||||
"type": "tool_search_output",
|
||||
"call_id": "call_search",
|
||||
"status": "completed",
|
||||
"output": {
|
||||
"content": [{
|
||||
"type": "image",
|
||||
"mimeType": "image/png",
|
||||
"data": "SEARCH_SENTINEL"
|
||||
}]
|
||||
}
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
for (call, output) in cases {
|
||||
let expected_type = output["type"].as_str().unwrap().to_string();
|
||||
let result = convert_test_input(vec![call, output]);
|
||||
let messages = result_messages(&result);
|
||||
let tool_content: Value =
|
||||
serde_json::from_str(messages[1]["content"].as_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(message_roles(&result), vec!["assistant", "tool", "user"]);
|
||||
assert_eq!(tool_content["type"], expected_type);
|
||||
assert_eq!(tool_content["status"], "completed");
|
||||
assert_eq!(
|
||||
tool_content["output"]["content"][0]["text"],
|
||||
TOOL_RESULT_MEDIA_MOVED_MARKER
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_clamps_stringified_custom_output_residual_base64() {
|
||||
let encoded_output = json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,CUSTOM_STRING_IMAGE_SENTINEL"
|
||||
},
|
||||
{
|
||||
"type": "video",
|
||||
"data": "A".repeat(20_000)
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let result = convert_test_input(vec![
|
||||
json!({
|
||||
"type": "custom_tool_call",
|
||||
"call_id": "call_custom_string",
|
||||
"name": "render",
|
||||
"input": "draw"
|
||||
}),
|
||||
json!({
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call_custom_string",
|
||||
"status": "completed",
|
||||
"output": encoded_output
|
||||
}),
|
||||
]);
|
||||
let messages = result_messages(&result);
|
||||
let tool_item: Value =
|
||||
serde_json::from_str(messages[1]["content"].as_str().unwrap()).unwrap();
|
||||
let rewritten = tool_item["output"].as_str().unwrap();
|
||||
|
||||
assert!(rewritten.contains("[cc-switch: omitted 20000 bytes]"));
|
||||
assert!(!rewritten.contains(&"A".repeat(64)));
|
||||
assert!(!rewritten.contains("CUSTOM_STRING_IMAGE_SENTINEL"));
|
||||
assert_eq!(messages[2]["content"][1]["type"], "image_url");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_rejects_false_positive_media_shapes() {
|
||||
let outputs = [
|
||||
json!({"type": "image", "name": "business metadata"}),
|
||||
json!({
|
||||
"type": "image",
|
||||
"mimeType": "text/plain",
|
||||
"data": "NOT_AN_IMAGE"
|
||||
}),
|
||||
json!({
|
||||
"image_url": {
|
||||
"url": "https://example.com/search-thumbnail.png"
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
for (index, output) in outputs.into_iter().enumerate() {
|
||||
let call_id = format!("call_false_positive_{index}");
|
||||
let expected = canonical_json_string(&output);
|
||||
let result = convert_test_input(vec![
|
||||
test_function_call(&call_id),
|
||||
test_function_output(&call_id, output),
|
||||
]);
|
||||
|
||||
assert_eq!(message_roles(&result), vec!["assistant", "tool"]);
|
||||
assert_eq!(result["messages"][1]["content"], expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_keeps_no_media_tool_output_bytes_stable() {
|
||||
let cases = [
|
||||
(Some(json!("plain text")), "plain text".to_string()),
|
||||
(
|
||||
Some(json!("{ \"z\": true, \"a\": [2, 1] }")),
|
||||
r#"{"a":[2,1],"z":true}"#.to_string(),
|
||||
),
|
||||
(
|
||||
Some(json!(["main.rs", "lib.rs"])),
|
||||
r#"["main.rs","lib.rs"]"#.to_string(),
|
||||
),
|
||||
(
|
||||
Some(json!({"z": true, "a": [2, 1]})),
|
||||
r#"{"a":[2,1],"z":true}"#.to_string(),
|
||||
),
|
||||
(Some(json!([])), "[]".to_string()),
|
||||
(None, String::new()),
|
||||
];
|
||||
|
||||
for (index, (output, expected)) in cases.into_iter().enumerate() {
|
||||
let call_id = format!("call_stable_{index}");
|
||||
let mut item = json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id
|
||||
});
|
||||
if let Some(output) = output {
|
||||
item["output"] = output;
|
||||
}
|
||||
let result = convert_test_input(vec![test_function_call(&call_id), item]);
|
||||
|
||||
assert_eq!(message_roles(&result), vec!["assistant", "tool"]);
|
||||
assert_eq!(result["messages"][1]["content"], expected);
|
||||
}
|
||||
|
||||
for item in [
|
||||
json!({
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call_custom",
|
||||
"status": "completed",
|
||||
"output": {"text": "unchanged"}
|
||||
}),
|
||||
json!({
|
||||
"type": "tool_search_output",
|
||||
"call_id": "call_search",
|
||||
"status": "completed",
|
||||
"output": []
|
||||
}),
|
||||
] {
|
||||
let expected = canonical_json_string(&item);
|
||||
let result = convert_test_input(vec![item]);
|
||||
assert_eq!(result["messages"][0]["content"], expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_preserves_legacy_unknown_item_batch_boundary_without_media() {
|
||||
let result = convert_test_input(vec![
|
||||
test_function_call("call_1"),
|
||||
json!({"type": "future_metadata", "value": 1}),
|
||||
json!({
|
||||
"type": "reasoning",
|
||||
"summary": [{"type": "summary_text", "text": "second batch reasoning"}]
|
||||
}),
|
||||
test_function_call("call_2"),
|
||||
test_function_output("call_1", json!("first result")),
|
||||
test_function_output("call_2", json!("second result")),
|
||||
]);
|
||||
let messages = result_messages(&result);
|
||||
|
||||
assert_eq!(
|
||||
message_roles(&result),
|
||||
vec!["assistant", "assistant", "tool", "tool"]
|
||||
);
|
||||
assert_eq!(messages[0]["tool_calls"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(messages[0]["tool_calls"][0]["id"], "call_1");
|
||||
assert_eq!(messages[0]["reasoning_content"], "tool call");
|
||||
assert_eq!(messages[1]["tool_calls"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(messages[1]["tool_calls"][0]["id"], "call_2");
|
||||
assert_eq!(messages[1]["reasoning_content"], "second batch reasoning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_clamps_only_residual_base64ish_strings() {
|
||||
let data_url = large_test_image_data_url();
|
||||
let long_text = format!("{}end", "ordinary OCR text with spaces. ".repeat(3_500));
|
||||
let residual_base64 = "A".repeat(20_000);
|
||||
let encoded_output = json!([
|
||||
{"type": "input_image", "image_url": data_url.clone()},
|
||||
{"type": "text", "text": long_text.clone()},
|
||||
{"type": "video", "data": residual_base64}
|
||||
])
|
||||
.to_string();
|
||||
let result = convert_test_input(vec![
|
||||
test_function_call("call_clamp"),
|
||||
test_function_output("call_clamp", json!(encoded_output)),
|
||||
]);
|
||||
let tool_content_text = result["messages"][1]["content"].as_str().unwrap();
|
||||
let tool_content: Value = serde_json::from_str(tool_content_text).unwrap();
|
||||
|
||||
assert_eq!(tool_content[1]["text"], long_text);
|
||||
assert!(tool_content[2]["data"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("[cc-switch: omitted 20000 bytes]"));
|
||||
assert!(!tool_content_text.contains(&data_url));
|
||||
assert!(!tool_content_text.contains("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_media_conversion_is_deterministic() {
|
||||
let input = json!({
|
||||
"model": "kimi-k3",
|
||||
"input": [
|
||||
test_function_call("call_repeat"),
|
||||
test_function_output(
|
||||
"call_repeat",
|
||||
json!({
|
||||
"content": [{
|
||||
"type": "input_image",
|
||||
"image_url": large_test_image_data_url()
|
||||
}]
|
||||
})
|
||||
)
|
||||
]
|
||||
});
|
||||
|
||||
let first = responses_to_chat_completions(input.clone()).unwrap();
|
||||
let second = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert_eq!(first, second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_response_to_responses_maps_text_tool_calls_and_usage() {
|
||||
let input = json!({
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
use super::gemini_schema::build_gemini_function_declaration;
|
||||
use super::gemini_shadow::{GeminiAssistantTurn, GeminiShadowStore, GeminiToolCallMeta};
|
||||
use crate::proxy::error::ProxyError;
|
||||
use crate::proxy::tool_media::{
|
||||
strip_and_clamp_media_from_tool_value, ToolMediaScope, TOOL_RESULT_MEDIA_ATTACHED_MARKER,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
@@ -61,6 +64,10 @@ pub fn anthropic_to_gemini_with_shadow(
|
||||
.and_then(|((store, provider_id), session_id)| store.get_session(provider_id, session_id))
|
||||
.map(|snapshot| snapshot.turns)
|
||||
.unwrap_or_default();
|
||||
let supports_multimodal_function_response = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(is_gemini_3_series);
|
||||
|
||||
let messages = body.get("messages").and_then(|value| value.as_array());
|
||||
|
||||
@@ -73,7 +80,11 @@ pub fn anthropic_to_gemini_with_shadow(
|
||||
}
|
||||
|
||||
if let Some(messages) = messages {
|
||||
result["contents"] = json!(convert_messages_to_contents(messages, &shadow_turns)?);
|
||||
result["contents"] = json!(convert_messages_to_contents(
|
||||
messages,
|
||||
&shadow_turns,
|
||||
supports_multimodal_function_response,
|
||||
)?);
|
||||
}
|
||||
|
||||
if let Some(generation_config) = build_generation_config(&body) {
|
||||
@@ -361,6 +372,7 @@ fn build_generation_config(body: &Value) -> Option<Value> {
|
||||
fn convert_messages_to_contents(
|
||||
messages: &[Value],
|
||||
shadow_turns: &[GeminiAssistantTurn],
|
||||
supports_multimodal_function_response: bool,
|
||||
) -> Result<Vec<Value>, ProxyError> {
|
||||
let mut contents = Vec::new();
|
||||
let mut used_shadow_indices = HashSet::new();
|
||||
@@ -446,6 +458,7 @@ fn convert_messages_to_contents(
|
||||
role,
|
||||
&mut tool_name_by_id,
|
||||
&thought_signature_by_id,
|
||||
supports_multimodal_function_response,
|
||||
)?
|
||||
}
|
||||
} else {
|
||||
@@ -454,6 +467,7 @@ fn convert_messages_to_contents(
|
||||
role,
|
||||
&mut tool_name_by_id,
|
||||
&thought_signature_by_id,
|
||||
supports_multimodal_function_response,
|
||||
)?
|
||||
}
|
||||
} else {
|
||||
@@ -462,6 +476,7 @@ fn convert_messages_to_contents(
|
||||
role,
|
||||
&mut tool_name_by_id,
|
||||
&thought_signature_by_id,
|
||||
supports_multimodal_function_response,
|
||||
)?
|
||||
};
|
||||
|
||||
@@ -560,6 +575,7 @@ fn convert_message_content_to_parts(
|
||||
role: &str,
|
||||
tool_name_by_id: &mut std::collections::HashMap<String, String>,
|
||||
thought_signature_by_id: &std::collections::HashMap<String, String>,
|
||||
supports_multimodal_function_response: bool,
|
||||
) -> Result<Vec<Value>, ProxyError> {
|
||||
let Some(content) = content else {
|
||||
return Ok(Vec::new());
|
||||
@@ -705,16 +721,31 @@ fn convert_message_content_to_parts(
|
||||
))
|
||||
})?;
|
||||
|
||||
let (response, media_parts) = plan_gemini_tool_result(block.get("content"));
|
||||
|
||||
// See `tool_use` above: synthesized ids must not leak upstream.
|
||||
let mut function_response = json!({
|
||||
"name": name,
|
||||
"response": normalize_tool_result_response(block.get("content"))
|
||||
"response": response
|
||||
});
|
||||
if !tool_use_id.is_empty() && !is_synthesized_tool_call_id(tool_use_id) {
|
||||
function_response["id"] = json!(tool_use_id);
|
||||
}
|
||||
|
||||
parts.push(json!({ "functionResponse": function_response }));
|
||||
if supports_multimodal_function_response && !media_parts.is_empty() {
|
||||
function_response["parts"] = Value::Array(media_parts);
|
||||
parts.push(json!({ "functionResponse": function_response }));
|
||||
} else {
|
||||
parts.push(json!({ "functionResponse": function_response }));
|
||||
if !media_parts.is_empty() {
|
||||
parts.push(json!({
|
||||
"text": format!(
|
||||
"[cc-switch: media output of tool call {tool_use_id}]"
|
||||
)
|
||||
}));
|
||||
parts.extend(media_parts);
|
||||
}
|
||||
}
|
||||
}
|
||||
"thinking" | "redacted_thinking" => {}
|
||||
_ => {}
|
||||
@@ -745,6 +776,74 @@ fn normalize_tool_result_response(content: Option<&Value>) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_gemini_tool_result(content: Option<&Value>) -> (Value, Vec<Value>) {
|
||||
let Some(content) = content else {
|
||||
return (normalize_tool_result_response(None), Vec::new());
|
||||
};
|
||||
|
||||
let mut cleaned = content.clone();
|
||||
let replacement_block = json!({
|
||||
"type":"text",
|
||||
"text":TOOL_RESULT_MEDIA_ATTACHED_MARKER
|
||||
});
|
||||
let mut chat_media_parts = Vec::new();
|
||||
let replaced = strip_and_clamp_media_from_tool_value(
|
||||
&mut cleaned,
|
||||
&mut chat_media_parts,
|
||||
ToolMediaScope::InlineImagesOnly,
|
||||
&replacement_block,
|
||||
TOOL_RESULT_MEDIA_ATTACHED_MARKER,
|
||||
);
|
||||
if replaced == 0 {
|
||||
return (normalize_tool_result_response(Some(content)), Vec::new());
|
||||
}
|
||||
|
||||
let mut gemini_parts = Vec::new();
|
||||
gemini_parts.extend(
|
||||
chat_media_parts
|
||||
.iter()
|
||||
.filter_map(gemini_part_from_chat_image),
|
||||
);
|
||||
|
||||
(normalize_tool_result_response(Some(&cleaned)), gemini_parts)
|
||||
}
|
||||
|
||||
fn is_gemini_3_series(model: &str) -> bool {
|
||||
let normalized = model.trim().to_ascii_lowercase();
|
||||
normalized.starts_with("gemini-3")
|
||||
|| normalized
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.is_some_and(|tail| tail.starts_with("gemini-3"))
|
||||
}
|
||||
|
||||
fn gemini_part_from_chat_image(part: &Value) -> Option<Value> {
|
||||
let image_url = part
|
||||
.pointer("/image_url/url")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|url| !url.trim().is_empty())?;
|
||||
|
||||
if image_url
|
||||
.get(..5)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
|
||||
{
|
||||
let rest = &image_url[5..];
|
||||
let (meta, data) = rest.split_once(',')?;
|
||||
if data.is_empty() || !meta.to_ascii_lowercase().contains(";base64") {
|
||||
return None;
|
||||
}
|
||||
let mime_type = meta.split(';').next().unwrap_or("image/png");
|
||||
return Some(json!({
|
||||
"inlineData": {
|
||||
"mimeType": mime_type,
|
||||
"data": data
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn shadow_parts(content: &Value) -> Option<Vec<Value>> {
|
||||
let mut parts = content
|
||||
.get("parts")
|
||||
@@ -1266,6 +1365,290 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_to_gemini_moves_mixed_tool_result_image_to_native_part() {
|
||||
let input = json!({
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "tool_use",
|
||||
"id": "call_image",
|
||||
"name": "inspect",
|
||||
"input": {}
|
||||
}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_image",
|
||||
"content": [
|
||||
{"type": "text", "text": "caption"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "GEMINI_TOOL_IMAGE_SENTINEL"
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let result = anthropic_to_gemini(input).unwrap();
|
||||
let parts = result["contents"][1]["parts"].as_array().unwrap();
|
||||
let response = &parts[0]["functionResponse"]["response"]["content"];
|
||||
|
||||
assert!(response.as_str().unwrap().contains("caption"));
|
||||
assert!(response
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("tool result media attached"));
|
||||
assert!(!response
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("GEMINI_TOOL_IMAGE_SENTINEL"));
|
||||
assert_eq!(
|
||||
parts[1]["text"],
|
||||
"[cc-switch: media output of tool call call_image]"
|
||||
);
|
||||
assert_eq!(parts[2]["inlineData"]["mimeType"], "image/png");
|
||||
assert_eq!(parts[2]["inlineData"]["data"], "GEMINI_TOOL_IMAGE_SENTINEL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_to_gemini_clamps_json_string_residual_base64_before_serializing() {
|
||||
let residual_base64 = "A".repeat(20_000);
|
||||
let encoded = json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "data:image/png;base64,GEMINI_STRING_IMAGE_SENTINEL"
|
||||
}
|
||||
},
|
||||
{"type": "video", "data": residual_base64}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let input = json!({
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "tool_use",
|
||||
"id": "call_string",
|
||||
"name": "inspect",
|
||||
"input": {}
|
||||
}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_string",
|
||||
"content": encoded
|
||||
}]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let result = anthropic_to_gemini(input).unwrap();
|
||||
let serialized = result.to_string();
|
||||
|
||||
assert!(serialized.contains("[cc-switch: omitted 20000 bytes]"));
|
||||
assert!(!serialized.contains(&"A".repeat(64)));
|
||||
assert_eq!(
|
||||
result["contents"][1]["parts"][2]["inlineData"]["data"],
|
||||
"GEMINI_STRING_IMAGE_SENTINEL"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_to_gemini_keeps_remote_tool_image_in_legacy_response() {
|
||||
let remote_image = json!({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "url",
|
||||
"url": "https://example.com/tool-image.png"
|
||||
}
|
||||
});
|
||||
let input = json!({
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "tool_use",
|
||||
"id": "call_remote",
|
||||
"name": "inspect",
|
||||
"input": {}
|
||||
}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_remote",
|
||||
"content": [remote_image.clone()]
|
||||
}]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let result = anthropic_to_gemini(input).unwrap();
|
||||
let parts = result["contents"][1]["parts"].as_array().unwrap();
|
||||
|
||||
assert_eq!(parts.len(), 1);
|
||||
assert_eq!(
|
||||
parts[0]["functionResponse"]["response"]["content"][0],
|
||||
remote_image
|
||||
);
|
||||
assert!(!result.to_string().contains("fileData"));
|
||||
assert!(!result
|
||||
.to_string()
|
||||
.contains(TOOL_RESULT_MEDIA_ATTACHED_MARKER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_to_gemini_does_not_strip_unconvertible_tool_data_url() {
|
||||
let malformed_image = json!({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "data:image/png,NOT_BASE64"
|
||||
}
|
||||
});
|
||||
let input = json!({
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "tool_use",
|
||||
"id": "call_malformed",
|
||||
"name": "inspect",
|
||||
"input": {}
|
||||
}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_malformed",
|
||||
"content": [malformed_image.clone()]
|
||||
}]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let result = anthropic_to_gemini(input).unwrap();
|
||||
let parts = result["contents"][1]["parts"].as_array().unwrap();
|
||||
|
||||
assert_eq!(parts.len(), 1);
|
||||
assert_eq!(
|
||||
parts[0]["functionResponse"]["response"]["content"][0],
|
||||
malformed_image
|
||||
);
|
||||
assert!(!result.to_string().contains("inlineData"));
|
||||
assert!(!result
|
||||
.to_string()
|
||||
.contains(TOOL_RESULT_MEDIA_ATTACHED_MARKER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_to_gemini_does_not_embed_image_only_tool_result_as_json() {
|
||||
let input = json!({
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "tool_use",
|
||||
"id": "call_image",
|
||||
"name": "inspect",
|
||||
"input": {}
|
||||
}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_image",
|
||||
"content": [{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/webp",
|
||||
"data": "IMAGE_ONLY_SENTINEL"
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let result = anthropic_to_gemini(input).unwrap();
|
||||
let parts = result["contents"][1]["parts"].as_array().unwrap();
|
||||
let response = &parts[0]["functionResponse"]["response"]["content"];
|
||||
|
||||
assert!(response.is_string());
|
||||
assert!(!response.as_str().unwrap().contains("IMAGE_ONLY_SENTINEL"));
|
||||
assert_eq!(parts[2]["inlineData"]["mimeType"], "image/webp");
|
||||
assert_eq!(parts[2]["inlineData"]["data"], "IMAGE_ONLY_SENTINEL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_to_gemini_3_uses_multimodal_function_response_parts() {
|
||||
let input = json!({
|
||||
"model": "gemini-3-pro-preview",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "tool_use",
|
||||
"id": "call_image",
|
||||
"name": "inspect",
|
||||
"input": {}
|
||||
}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_image",
|
||||
"content": [{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": "GEMINI_3_IMAGE_SENTINEL"
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let result = anthropic_to_gemini(input).unwrap();
|
||||
let parts = result["contents"][1]["parts"].as_array().unwrap();
|
||||
let function_response = &parts[0]["functionResponse"];
|
||||
|
||||
assert_eq!(parts.len(), 1);
|
||||
assert_eq!(
|
||||
function_response["parts"][0]["inlineData"]["mimeType"],
|
||||
"image/jpeg"
|
||||
);
|
||||
assert_eq!(
|
||||
function_response["parts"][0]["inlineData"]["data"],
|
||||
"GEMINI_3_IMAGE_SENTINEL"
|
||||
);
|
||||
assert!(!function_response["response"]["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("GEMINI_3_IMAGE_SENTINEL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_to_gemini_resolves_tool_result_name_from_shadow_content() {
|
||||
let store = GeminiShadowStore::with_limits(8, 4);
|
||||
|
||||
@@ -8,7 +8,13 @@
|
||||
//! - system prompt 使用 `instructions` 字段而非 system role message
|
||||
//! - usage 字段命名与 Anthropic 一致 (input_tokens/output_tokens)
|
||||
|
||||
use crate::proxy::{error::ProxyError, json_canonical::canonical_json_string};
|
||||
use crate::proxy::{
|
||||
error::ProxyError,
|
||||
json_canonical::canonical_json_string,
|
||||
tool_media::{
|
||||
strip_and_clamp_media_from_tool_value, ToolMediaScope, TOOL_RESULT_MEDIA_ATTACHED_MARKER,
|
||||
},
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::reasoning_bridge::{
|
||||
@@ -80,8 +86,11 @@ fn anthropic_tool_result_to_responses_output(block: &Value) -> Value {
|
||||
let content = block.get("content");
|
||||
|
||||
if !is_error {
|
||||
if let Some(Value::String(text)) = content {
|
||||
return json!(text);
|
||||
if let Some(text @ Value::String(_)) = content {
|
||||
if let Some(output) = alternate_image_tool_result_to_responses(text) {
|
||||
return Value::Array(output);
|
||||
}
|
||||
return text.clone();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +101,13 @@ fn anthropic_tool_result_to_responses_output(block: &Value) -> Value {
|
||||
|
||||
match content {
|
||||
Some(Value::String(text)) => {
|
||||
output.push(json!({"type":"input_text","text":text}));
|
||||
if let Some(mut alternate) =
|
||||
alternate_image_tool_result_to_responses(&Value::String(text.clone()))
|
||||
{
|
||||
output.append(&mut alternate);
|
||||
} else {
|
||||
output.push(json!({"type":"input_text","text":text}));
|
||||
}
|
||||
}
|
||||
Some(Value::Array(blocks)) => {
|
||||
for part in blocks {
|
||||
@@ -105,6 +120,10 @@ fn anthropic_tool_result_to_responses_output(block: &Value) -> Value {
|
||||
Some("image") => {
|
||||
if let Some(image) = anthropic_image_to_responses_part(part) {
|
||||
output.push(image);
|
||||
} else if let Some(mut alternate) =
|
||||
alternate_image_tool_result_to_responses(part)
|
||||
{
|
||||
output.append(&mut alternate);
|
||||
} else {
|
||||
output.push(json!({
|
||||
"type":"input_text",
|
||||
@@ -122,6 +141,77 @@ fn anthropic_tool_result_to_responses_output(block: &Value) -> Value {
|
||||
}));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(mut alternate) = alternate_image_tool_result_to_responses(part)
|
||||
{
|
||||
output.append(&mut alternate);
|
||||
} else {
|
||||
output.push(json!({
|
||||
"type":"input_text",
|
||||
"text":canonical_json_string(part)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(value) => {
|
||||
if let Some(mut alternate) = alternate_image_tool_result_to_responses(value) {
|
||||
output.append(&mut alternate);
|
||||
} else {
|
||||
output.push(json!({
|
||||
"type":"input_text",
|
||||
"text":canonical_json_string(value)
|
||||
}));
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
Value::Array(output)
|
||||
}
|
||||
|
||||
fn alternate_image_tool_result_to_responses(value: &Value) -> Option<Vec<Value>> {
|
||||
let mut cleaned = value.clone();
|
||||
let replacement_block = json!({
|
||||
"type":"input_text",
|
||||
"text":TOOL_RESULT_MEDIA_ATTACHED_MARKER
|
||||
});
|
||||
let mut chat_media_parts = Vec::new();
|
||||
let replaced = strip_and_clamp_media_from_tool_value(
|
||||
&mut cleaned,
|
||||
&mut chat_media_parts,
|
||||
ToolMediaScope::ImagesOnly,
|
||||
&replacement_block,
|
||||
TOOL_RESULT_MEDIA_ATTACHED_MARKER,
|
||||
);
|
||||
if replaced == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut output = Vec::new();
|
||||
append_sanitized_responses_tool_value(&cleaned, &mut output);
|
||||
output.extend(
|
||||
chat_media_parts
|
||||
.iter()
|
||||
.filter_map(responses_image_from_chat_media),
|
||||
);
|
||||
Some(output)
|
||||
}
|
||||
|
||||
fn append_sanitized_responses_tool_value(value: &Value, output: &mut Vec<Value>) {
|
||||
match value {
|
||||
Value::String(text) if !text.is_empty() => {
|
||||
output.push(json!({"type":"input_text","text":text}));
|
||||
}
|
||||
Value::Array(parts) => {
|
||||
for part in parts {
|
||||
match part.get("type").and_then(Value::as_str) {
|
||||
Some("input_text" | "output_text" | "text") => {
|
||||
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
||||
output.push(json!({"type":"input_text","text":text}));
|
||||
}
|
||||
}
|
||||
_ => output.push(json!({
|
||||
"type":"input_text",
|
||||
"text":canonical_json_string(part)
|
||||
@@ -129,14 +219,37 @@ fn anthropic_tool_result_to_responses_output(block: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(value) => output.push(json!({
|
||||
Value::Object(object)
|
||||
if matches!(
|
||||
object.get("type").and_then(Value::as_str),
|
||||
Some("input_text" | "output_text" | "text")
|
||||
) =>
|
||||
{
|
||||
if let Some(text) = object.get("text").and_then(Value::as_str) {
|
||||
output.push(json!({"type":"input_text","text":text}));
|
||||
}
|
||||
}
|
||||
Value::Null | Value::String(_) => {}
|
||||
other => output.push(json!({
|
||||
"type":"input_text",
|
||||
"text":canonical_json_string(value)
|
||||
"text":canonical_json_string(other)
|
||||
})),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
Value::Array(output)
|
||||
fn responses_image_from_chat_media(part: &Value) -> Option<Value> {
|
||||
let image_url = part
|
||||
.pointer("/image_url/url")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|url| !url.trim().is_empty())?;
|
||||
let mut image = json!({
|
||||
"type":"input_image",
|
||||
"image_url":image_url
|
||||
});
|
||||
if let Some(detail) = part.pointer("/image_url/detail") {
|
||||
image["detail"] = detail.clone();
|
||||
}
|
||||
Some(image)
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_anthropic_tool_use_input(name: &str, input: Value) -> Value {
|
||||
@@ -1157,6 +1270,78 @@ mod tests {
|
||||
assert_eq!(output[3]["filename"], "trace.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_converts_mcp_tool_image() {
|
||||
let input = json!({
|
||||
"model":"gpt-5",
|
||||
"messages":[{"role":"user","content":[{
|
||||
"type":"tool_result",
|
||||
"tool_use_id":"call_1",
|
||||
"content":[{
|
||||
"type":"image",
|
||||
"mimeType":"image/webp",
|
||||
"data":"MCP_RESPONSES_IMAGE_SENTINEL"
|
||||
}]
|
||||
}]}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
let output = result["input"][0]["output"].as_array().unwrap();
|
||||
|
||||
assert_eq!(output[0]["type"], "input_text");
|
||||
assert!(!output[0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("MCP_RESPONSES_IMAGE_SENTINEL"));
|
||||
assert_eq!(output[1]["type"], "input_image");
|
||||
assert_eq!(
|
||||
output[1]["image_url"],
|
||||
"data:image/webp;base64,MCP_RESPONSES_IMAGE_SENTINEL"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_converts_json_string_tool_image() {
|
||||
let residual_base64 = "A".repeat(20_000);
|
||||
let encoded = json!({
|
||||
"content":[
|
||||
{
|
||||
"type":"image_url",
|
||||
"image_url":{"url":"data:image/png;base64,STRING_RESPONSES_SENTINEL"}
|
||||
},
|
||||
{"type":"video","data":residual_base64}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let input = json!({
|
||||
"model":"gpt-5",
|
||||
"messages":[{"role":"user","content":[{
|
||||
"type":"tool_result",
|
||||
"tool_use_id":"call_1",
|
||||
"content":encoded
|
||||
}]}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
let output = result["input"][0]["output"].as_array().unwrap();
|
||||
let image = output
|
||||
.iter()
|
||||
.find(|part| part["type"] == "input_image")
|
||||
.expect("stringified image must stay a Responses image");
|
||||
|
||||
assert_eq!(
|
||||
image["image_url"],
|
||||
"data:image/png;base64,STRING_RESPONSES_SENTINEL"
|
||||
);
|
||||
assert!(output
|
||||
.iter()
|
||||
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
||||
.all(|text| !text.contains("STRING_RESPONSES_SENTINEL")));
|
||||
let serialized = result.to_string();
|
||||
assert!(serialized.contains("[cc-switch: omitted 20000 bytes]"));
|
||||
assert!(!serialized.contains(&"A".repeat(64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_thinking_discarded() {
|
||||
let input = json!({
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -642,7 +642,7 @@ async fn log_usage_internal(
|
||||
model
|
||||
};
|
||||
|
||||
let dedup_scope = (app_type != "claude").then_some((app_type, provider_id));
|
||||
let dedup_scope = super::usage::parser::dedup_scope_for_app(app_type, provider_id);
|
||||
let request_id = usage.dedup_request_id(dedup_scope);
|
||||
|
||||
log::debug!(
|
||||
|
||||
+130
-297
@@ -7,183 +7,12 @@
|
||||
//! 支持从客户端请求中提取 Session ID,用于关联同一对话的多个请求:
|
||||
//! - Claude: 从 `metadata.user_id` (格式: `user_xxx_session_yyy`) 或 `metadata.session_id` 提取
|
||||
//! - Codex: 从 headers 中的 `session_id` / `x-session-id` 或 `metadata.session_id` 提取
|
||||
//! - Grok Build: 从 headers 中的 `x-grok-conv-id` / `x-grok-session-id` 提取
|
||||
//! - 其他: 生成新的 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 提取器
|
||||
// ============================================================================
|
||||
@@ -195,7 +24,7 @@ pub enum SessionIdSource {
|
||||
MetadataUserId,
|
||||
/// 从 metadata.session_id 提取
|
||||
MetadataSessionId,
|
||||
/// 从 headers 提取 (Codex)
|
||||
/// 从 headers 提取
|
||||
Header,
|
||||
/// 新生成
|
||||
Generated,
|
||||
@@ -228,6 +57,11 @@ pub struct SessionIdResult {
|
||||
/// 2. `metadata.session_id`
|
||||
/// 3. 生成新 UUID
|
||||
///
|
||||
/// ### Grok Build 请求
|
||||
/// 1. Headers: `x-grok-conv-id` 或 `x-grok-session-id`
|
||||
/// 2. `metadata.session_id`
|
||||
/// 3. 生成新 UUID
|
||||
///
|
||||
/// ## 示例
|
||||
///
|
||||
/// ```ignore
|
||||
@@ -245,9 +79,15 @@ pub fn extract_session_id(
|
||||
}
|
||||
}
|
||||
|
||||
// Codex 请求特殊处理
|
||||
if client_format == "codex" || client_format == "openai" {
|
||||
if let Some(result) = extract_codex_session(headers, body) {
|
||||
// Responses 请求特殊处理。Grok Build 使用与 Codex 相同的客户端协议,
|
||||
// 但保留独立前缀,避免统计和缓存键跨应用碰撞。
|
||||
if matches!(client_format, "codex" | "openai" | "grokbuild") {
|
||||
let prefix = if client_format == "grokbuild" {
|
||||
"grokbuild"
|
||||
} else {
|
||||
"codex"
|
||||
};
|
||||
if let Some(result) = extract_responses_session(headers, body, prefix) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -283,16 +123,28 @@ fn extract_claude_session(
|
||||
extract_from_metadata(body)
|
||||
}
|
||||
|
||||
/// 提取 Codex Session ID
|
||||
fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option<SessionIdResult> {
|
||||
/// 提取 Responses 客户端的 Session ID
|
||||
fn extract_responses_session(
|
||||
headers: &HeaderMap,
|
||||
body: &serde_json::Value,
|
||||
prefix: &str,
|
||||
) -> Option<SessionIdResult> {
|
||||
// 1. 从 headers 提取
|
||||
for header_name in &["session_id", "x-session-id"] {
|
||||
let header_names: &[&str] = if prefix == "grokbuild" {
|
||||
// Conversation ID 跨多轮请求保持稳定;session ID 作为客户端缺少
|
||||
// conversation ID 时的回退。x-grok-req-id 是逐请求 ID,不能用于聚合。
|
||||
&["x-grok-conv-id", "x-grok-session-id"]
|
||||
} else {
|
||||
&["session_id", "x-session-id"]
|
||||
};
|
||||
for header_name in header_names {
|
||||
if let Some(value) = headers.get(*header_name) {
|
||||
if let Ok(session_id) = value.to_str() {
|
||||
// Codex Session ID 通常较长(UUID 格式)
|
||||
let session_id = session_id.trim();
|
||||
// Responses 客户端的 Session ID 通常较长(UUID 格式)
|
||||
if session_id.len() > 20 {
|
||||
return Some(SessionIdResult {
|
||||
session_id: format!("codex_{session_id}"),
|
||||
session_id: format!("{prefix}_{session_id}"),
|
||||
source: SessionIdSource::Header,
|
||||
client_provided: true,
|
||||
});
|
||||
@@ -307,9 +159,10 @@ fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Optio
|
||||
.and_then(|m| m.get("session_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let session_id = session_id.trim();
|
||||
if session_id.len() > 10 {
|
||||
return Some(SessionIdResult {
|
||||
session_id: format!("codex_{session_id}"),
|
||||
session_id: format!("{prefix}_{session_id}"),
|
||||
source: SessionIdSource::MetadataSessionId,
|
||||
client_provided: true,
|
||||
});
|
||||
@@ -380,121 +233,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]
|
||||
@@ -589,6 +327,101 @@ mod tests {
|
||||
assert!(!result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_keeps_existing_response_session_headers() {
|
||||
let body = json!({ "input": "Write a function" });
|
||||
|
||||
for header_name in ["session_id", "x-session-id"] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header_name,
|
||||
"d937243f-2702-4f20-97b6-c9682235ab81".parse().unwrap(),
|
||||
);
|
||||
|
||||
let result = extract_session_id(&headers, &body, "codex");
|
||||
|
||||
assert_eq!(
|
||||
result.session_id,
|
||||
"codex_d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
);
|
||||
assert_eq!(result.source, SessionIdSource::Header);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grokbuild_prefers_conversation_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-grok-conv-id",
|
||||
"conv-724f4275-584e-43af-ad46-b5e7509a3ca2".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"x-grok-session-id",
|
||||
"session-d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
let body = json!({ "input": "Write a function" });
|
||||
|
||||
let result = extract_session_id(&headers, &body, "grokbuild");
|
||||
|
||||
assert_eq!(
|
||||
result.session_id,
|
||||
"grokbuild_conv-724f4275-584e-43af-ad46-b5e7509a3ca2"
|
||||
);
|
||||
assert_eq!(result.source, SessionIdSource::Header);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grokbuild_falls_back_to_session_header() {
|
||||
let body = json!({ "input": "Write a function" });
|
||||
|
||||
for conversation_id in ["", " "] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-grok-conv-id", conversation_id.parse().unwrap());
|
||||
headers.insert(
|
||||
"x-grok-session-id",
|
||||
"session-d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let result = extract_session_id(&headers, &body, "grokbuild");
|
||||
|
||||
assert_eq!(
|
||||
result.session_id,
|
||||
"grokbuild_session-d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
);
|
||||
assert_eq!(result.source, SessionIdSource::Header);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grokbuild_ignores_request_and_codex_session_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-grok-req-id",
|
||||
"request-724f4275-584e-43af-ad46-b5e7509a3ca2"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"x-session-id",
|
||||
"codex-d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
let body = json!({ "input": "Write a function" });
|
||||
|
||||
let result = extract_session_id(&headers, &body, "grokbuild");
|
||||
|
||||
assert_eq!(result.source, SessionIdSource::Generated);
|
||||
assert!(!result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_generates_new_when_not_found() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
@@ -0,0 +1,991 @@
|
||||
//! Shared media handling for tool outputs.
|
||||
//!
|
||||
//! Responses and Anthropic tool outputs may carry structured media blocks.
|
||||
//! Chat Completions tool messages are text-only, so protocol bridges extract
|
||||
//! those blocks and re-emit them in a synthetic user message. The media
|
||||
//! sanitizer reuses the same recognition and traversal rules when it needs to
|
||||
//! remove images for a text-only upstream.
|
||||
|
||||
use crate::proxy::json_canonical::canonical_json_string;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
pub(crate) const WHOLE_DATA_URL_MIN_BYTES: usize = 8 * 1024;
|
||||
pub(crate) const TOOL_RESULT_MEDIA_MOVED_MARKER: &str =
|
||||
"[cc-switch: tool result media moved to the following user message]";
|
||||
pub(crate) const TOOL_RESULT_MEDIA_ATTACHED_MARKER: &str =
|
||||
"[cc-switch: tool result media attached as native media]";
|
||||
const BASE64ISH_MIN_BYTES: usize = 16 * 1024;
|
||||
const MAX_MEDIA_TRAVERSAL_DEPTH: usize = 32;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ToolMediaScope {
|
||||
/// Used by the existing image-capability sanitizer and its retry path.
|
||||
ImagesOnly,
|
||||
/// Used by Gemini Native `generateContent`, whose existing bridge only
|
||||
/// promises inline base64 image input. Remote URLs and malformed data URLs
|
||||
/// must stay in the legacy tool-result representation.
|
||||
InlineImagesOnly,
|
||||
/// Used by Chat conversion bridges, where user messages can carry all
|
||||
/// currently mapped Chat input modalities.
|
||||
AllSupported,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ToolMediaKind {
|
||||
Image,
|
||||
File,
|
||||
Audio,
|
||||
}
|
||||
|
||||
pub(crate) struct ChatToolOutputMediaPlan {
|
||||
pub(crate) tool_content: String,
|
||||
pub(crate) media_parts: Vec<Value>,
|
||||
}
|
||||
|
||||
impl ToolMediaScope {
|
||||
fn allows(self, kind: ToolMediaKind) -> bool {
|
||||
matches!(kind, ToolMediaKind::Image) || matches!(self, Self::AllSupported)
|
||||
}
|
||||
|
||||
fn accepts_chat_part(self, part: &Value) -> bool {
|
||||
!matches!(self, Self::InlineImagesOnly) || chat_image_part_has_inline_data(part)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Chat-compatible tool-output plan without changing no-media output.
|
||||
///
|
||||
/// Scalar strings remain scalar strings after replacement. This matters for a
|
||||
/// raw image data URL and for JSON encoded inside a tool-output string: adding
|
||||
/// another layer of JSON string quotes would change what the model sees.
|
||||
pub(crate) fn plan_chat_tool_output_media(mut output: Value) -> Option<ChatToolOutputMediaPlan> {
|
||||
let output_was_string = output.is_string();
|
||||
let replacement_block = json!({
|
||||
"type": "text",
|
||||
"text": TOOL_RESULT_MEDIA_MOVED_MARKER
|
||||
});
|
||||
let mut media_parts = Vec::new();
|
||||
let replaced = strip_and_clamp_media_from_tool_value(
|
||||
&mut output,
|
||||
&mut media_parts,
|
||||
ToolMediaScope::AllSupported,
|
||||
&replacement_block,
|
||||
TOOL_RESULT_MEDIA_MOVED_MARKER,
|
||||
);
|
||||
if replaced == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let tool_content = if output_was_string {
|
||||
output.as_str().unwrap_or_default().to_string()
|
||||
} else {
|
||||
canonical_json_string(&output)
|
||||
};
|
||||
|
||||
Some(ChatToolOutputMediaPlan {
|
||||
tool_content,
|
||||
media_parts,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn queue_chat_tool_output_media(
|
||||
pending_media: &mut Vec<Value>,
|
||||
call_id: &str,
|
||||
media_parts: Vec<Value>,
|
||||
) {
|
||||
if media_parts.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
pending_media.push(json!({
|
||||
"type": "text",
|
||||
"text": format!("[cc-switch: media output of tool call {call_id}]")
|
||||
}));
|
||||
pending_media.extend(media_parts);
|
||||
}
|
||||
|
||||
pub(crate) fn flush_pending_chat_tool_media(
|
||||
messages: &mut Vec<Value>,
|
||||
pending_media: &mut Vec<Value>,
|
||||
) {
|
||||
if pending_media.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
messages.push(json!({
|
||||
"role": "user",
|
||||
"content": std::mem::take(pending_media)
|
||||
}));
|
||||
}
|
||||
|
||||
/// Convert one recognized tool media block to a Chat user content part. This
|
||||
/// is the single shape-recognition entry point used by extraction.
|
||||
pub(crate) fn chat_media_part_from_tool_part(part: &Value, scope: ToolMediaScope) -> Option<Value> {
|
||||
let kind = tool_media_kind(part)?;
|
||||
if !scope.allows(kind) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let chat_part = match kind {
|
||||
ToolMediaKind::Image => chat_image_part(part),
|
||||
ToolMediaKind::File => chat_file_from_input_file(part).map(|file| {
|
||||
json!({
|
||||
"type": "file",
|
||||
"file": file
|
||||
})
|
||||
}),
|
||||
ToolMediaKind::Audio => part.get("input_audio").map(|input_audio| {
|
||||
json!({
|
||||
"type": "input_audio",
|
||||
"input_audio": input_audio.clone()
|
||||
})
|
||||
}),
|
||||
}?;
|
||||
|
||||
scope.accepts_chat_part(&chat_part).then_some(chat_part)
|
||||
}
|
||||
|
||||
/// Map a Responses `input_file` block to the Chat file payload. Kept here so
|
||||
/// top-level content and tool-output extraction share the exact same rules.
|
||||
pub(crate) fn chat_file_from_input_file(part: &Value) -> Option<Value> {
|
||||
let mut file = Map::new();
|
||||
let has_supported_file_ref = part.get("file_id").is_some() || part.get("file_data").is_some();
|
||||
if !has_supported_file_ref {
|
||||
return None;
|
||||
}
|
||||
|
||||
for key in ["file_id", "file_data", "filename"] {
|
||||
if let Some(value) = part.get(key) {
|
||||
file.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
Some(Value::Object(file))
|
||||
}
|
||||
|
||||
/// Recognize a complete image data URL stored as a scalar string.
|
||||
///
|
||||
/// Only whole-string matches are accepted. Embedded data URLs in HTML/CSS/SVG
|
||||
/// source are deliberately left alone. Small values remain text as well, which
|
||||
/// preserves workflows that intentionally inspect tiny inline icons.
|
||||
pub(crate) fn whole_string_image_data_url(value: &str) -> Option<Value> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.len() < WHOLE_DATA_URL_MIN_BYTES || !is_image_base64_data_url(trimmed) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": trimmed
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Read-only media detection using the same shape classifier and recursive
|
||||
/// boundaries as [`strip_media_from_tool_value`].
|
||||
pub(crate) fn tool_output_contains_media(value: &Value, scope: ToolMediaScope) -> bool {
|
||||
tool_output_contains_media_at_depth(value, scope, 0)
|
||||
}
|
||||
|
||||
/// Extract recognized media blocks and replace them in-place.
|
||||
///
|
||||
/// `replacement_block` is used for structured array/object parts. A scalar
|
||||
/// string that is itself a complete image data URL uses `replacement_text`, so
|
||||
/// an originally plain string stays a plain string. Parseable JSON strings are
|
||||
/// recursively transformed and canonicalized back into a string only when a
|
||||
/// replacement actually occurred.
|
||||
pub(crate) fn strip_media_from_tool_value(
|
||||
value: &mut Value,
|
||||
media_parts: &mut Vec<Value>,
|
||||
scope: ToolMediaScope,
|
||||
replacement_block: &Value,
|
||||
replacement_text: &str,
|
||||
) -> usize {
|
||||
strip_media_from_tool_value_at_depth(
|
||||
value,
|
||||
media_parts,
|
||||
scope,
|
||||
replacement_block,
|
||||
replacement_text,
|
||||
false,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract media and clamp residual large data/base64 scalars on media-bearing
|
||||
/// outputs. Parseable JSON strings are clamped while still represented as a
|
||||
/// JSON tree, before they are canonicalized back into their original string
|
||||
/// container.
|
||||
pub(crate) fn strip_and_clamp_media_from_tool_value(
|
||||
value: &mut Value,
|
||||
media_parts: &mut Vec<Value>,
|
||||
scope: ToolMediaScope,
|
||||
replacement_block: &Value,
|
||||
replacement_text: &str,
|
||||
) -> usize {
|
||||
let replaced = strip_media_from_tool_value_at_depth(
|
||||
value,
|
||||
media_parts,
|
||||
scope,
|
||||
replacement_block,
|
||||
replacement_text,
|
||||
true,
|
||||
0,
|
||||
);
|
||||
if replaced > 0 {
|
||||
clamp_base64ish_strings(value);
|
||||
}
|
||||
replaced
|
||||
}
|
||||
|
||||
/// Remove residual data/base64 payloads only after a tool output has already
|
||||
/// been positively identified as media-bearing. Ordinary long text is kept.
|
||||
pub(crate) fn clamp_base64ish_strings(value: &mut Value) {
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
let trimmed = text.trim();
|
||||
let should_omit = (trimmed.len() >= WHOLE_DATA_URL_MIN_BYTES
|
||||
&& trimmed
|
||||
.get(..5)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:")))
|
||||
|| looks_like_base64_payload(trimmed);
|
||||
if should_omit {
|
||||
let byte_len = text.len();
|
||||
*text = format!("[cc-switch: omitted {byte_len} bytes]");
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
clamp_base64ish_strings(item);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for nested in object.values_mut() {
|
||||
clamp_base64ish_strings(nested);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_output_contains_media_at_depth(value: &Value, scope: ToolMediaScope, depth: usize) -> bool {
|
||||
if depth > MAX_MEDIA_TRAVERSAL_DEPTH {
|
||||
return false;
|
||||
}
|
||||
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
if scope.allows(ToolMediaKind::Image) && whole_string_image_data_url(text).is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
serde_json::from_str::<Value>(trimmed)
|
||||
.ok()
|
||||
.is_some_and(|parsed| {
|
||||
tool_output_contains_media_at_depth(&parsed, scope, depth + 1)
|
||||
})
|
||||
}
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.any(|item| tool_output_contains_media_at_depth(item, scope, depth + 1)),
|
||||
Value::Object(object) => {
|
||||
if chat_media_part_from_tool_part(value, scope).is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
object.get("content").is_some_and(|content| {
|
||||
tool_output_contains_media_at_depth(content, scope, depth + 1)
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_media_from_tool_value_at_depth(
|
||||
value: &mut Value,
|
||||
media_parts: &mut Vec<Value>,
|
||||
scope: ToolMediaScope,
|
||||
replacement_block: &Value,
|
||||
replacement_text: &str,
|
||||
clamp_parsed_strings: bool,
|
||||
depth: usize,
|
||||
) -> usize {
|
||||
if depth > MAX_MEDIA_TRAVERSAL_DEPTH {
|
||||
return 0;
|
||||
}
|
||||
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
if scope.allows(ToolMediaKind::Image) {
|
||||
if let Some(media_part) = whole_string_image_data_url(text) {
|
||||
media_parts.push(media_part);
|
||||
*text = replacement_text.to_string();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let Ok(mut parsed) = serde_json::from_str::<Value>(trimmed) else {
|
||||
return 0;
|
||||
};
|
||||
let replaced = strip_media_from_tool_value_at_depth(
|
||||
&mut parsed,
|
||||
media_parts,
|
||||
scope,
|
||||
replacement_block,
|
||||
replacement_text,
|
||||
clamp_parsed_strings,
|
||||
depth + 1,
|
||||
);
|
||||
if replaced > 0 {
|
||||
if clamp_parsed_strings {
|
||||
clamp_base64ish_strings(&mut parsed);
|
||||
}
|
||||
*text = canonical_json_string(&parsed);
|
||||
}
|
||||
replaced
|
||||
}
|
||||
Value::Array(items) => items
|
||||
.iter_mut()
|
||||
.map(|item| {
|
||||
strip_media_from_tool_value_at_depth(
|
||||
item,
|
||||
media_parts,
|
||||
scope,
|
||||
replacement_block,
|
||||
replacement_text,
|
||||
clamp_parsed_strings,
|
||||
depth + 1,
|
||||
)
|
||||
})
|
||||
.sum(),
|
||||
Value::Object(_) => {
|
||||
if let Some(media_part) = chat_media_part_from_tool_part(value, scope) {
|
||||
media_parts.push(media_part);
|
||||
*value = replacement_block.clone();
|
||||
return 1;
|
||||
}
|
||||
|
||||
value
|
||||
.as_object_mut()
|
||||
.expect("object match arm must remain an object")
|
||||
.get_mut("content")
|
||||
.map(|content| {
|
||||
strip_media_from_tool_value_at_depth(
|
||||
content,
|
||||
media_parts,
|
||||
scope,
|
||||
replacement_block,
|
||||
replacement_text,
|
||||
clamp_parsed_strings,
|
||||
depth + 1,
|
||||
)
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_media_kind(part: &Value) -> Option<ToolMediaKind> {
|
||||
let object = part.as_object()?;
|
||||
let part_type = object.get("type").and_then(Value::as_str);
|
||||
|
||||
match part_type {
|
||||
Some("input_image" | "image_url") if normalized_image_url(part).is_some() => {
|
||||
Some(ToolMediaKind::Image)
|
||||
}
|
||||
Some("input_file") if part.get("file_id").is_some() || part.get("file_data").is_some() => {
|
||||
Some(ToolMediaKind::File)
|
||||
}
|
||||
Some("input_audio") if part.get("input_audio").is_some_and(Value::is_object) => {
|
||||
Some(ToolMediaKind::Audio)
|
||||
}
|
||||
Some("image") if typed_image_has_payload(part) => Some(ToolMediaKind::Image),
|
||||
None if loose_data_image_url(part).is_some() => Some(ToolMediaKind::Image),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_image_part(part: &Value) -> Option<Value> {
|
||||
match part.get("type").and_then(Value::as_str) {
|
||||
Some("input_image" | "image_url") => normalized_image_url(part).map(image_url_content_part),
|
||||
Some("image") => typed_image_url(part).map(image_url_content_part),
|
||||
None => loose_data_image_url(part).map(image_url_content_part),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_image_url(part: &Value) -> Option<Value> {
|
||||
let image_url = part.get("image_url")?;
|
||||
let mut object = match image_url {
|
||||
Value::String(url) if !url.trim().is_empty() => {
|
||||
let mut object = Map::new();
|
||||
object.insert("url".to_string(), Value::String(url.clone()));
|
||||
object
|
||||
}
|
||||
Value::Object(object)
|
||||
if object
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|url| !url.trim().is_empty()) =>
|
||||
{
|
||||
object.clone()
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
merge_top_level_detail(part, &mut object);
|
||||
Some(Value::Object(object))
|
||||
}
|
||||
|
||||
fn loose_data_image_url(part: &Value) -> Option<Value> {
|
||||
if part.get("type").is_some() {
|
||||
return None;
|
||||
}
|
||||
let normalized = normalized_image_url(part)?;
|
||||
let url = normalized.get("url").and_then(Value::as_str)?;
|
||||
if !url
|
||||
.get(..5)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(normalized)
|
||||
}
|
||||
|
||||
fn typed_image_has_payload(part: &Value) -> bool {
|
||||
let Some(object) = part.as_object() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Some(source) = object.get("source").and_then(Value::as_object) {
|
||||
if source_media_type_is_image(source) {
|
||||
let has_url = source
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|url| !url.trim().is_empty());
|
||||
let has_data = source
|
||||
.get("data")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|data| !data.is_empty());
|
||||
if has_url || has_data {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object
|
||||
.get("data")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|data| !data.is_empty())
|
||||
&& object
|
||||
.get("mimeType")
|
||||
.or_else(|| object.get("mime_type"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(is_image_mime_type)
|
||||
}
|
||||
|
||||
fn typed_image_url(part: &Value) -> Option<Value> {
|
||||
let object = part.as_object()?;
|
||||
|
||||
if let Some(source) = object.get("source").and_then(Value::as_object) {
|
||||
if !source_media_type_is_image(source) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(url) = source
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|url| !url.trim().is_empty())
|
||||
{
|
||||
let mut image_url = Map::new();
|
||||
image_url.insert("url".to_string(), Value::String(url.to_string()));
|
||||
merge_top_level_detail(part, &mut image_url);
|
||||
return Some(Value::Object(image_url));
|
||||
}
|
||||
|
||||
if let Some(data) = source
|
||||
.get("data")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|data| !data.is_empty())
|
||||
{
|
||||
let media_type = source
|
||||
.get("media_type")
|
||||
.or_else(|| source.get("mime_type"))
|
||||
.or_else(|| source.get("mimeType"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("image/png");
|
||||
let url = if data
|
||||
.get(..11)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:image/"))
|
||||
{
|
||||
data.to_string()
|
||||
} else {
|
||||
format!("data:{media_type};base64,{data}")
|
||||
};
|
||||
let mut image_url = Map::new();
|
||||
image_url.insert("url".to_string(), Value::String(url));
|
||||
merge_top_level_detail(part, &mut image_url);
|
||||
return Some(Value::Object(image_url));
|
||||
}
|
||||
}
|
||||
|
||||
let data = object
|
||||
.get("data")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|data| !data.is_empty())?;
|
||||
let media_type = object
|
||||
.get("mimeType")
|
||||
.or_else(|| object.get("mime_type"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|media_type| is_image_mime_type(media_type))?;
|
||||
let mut image_url = Map::new();
|
||||
image_url.insert(
|
||||
"url".to_string(),
|
||||
Value::String(format!("data:{media_type};base64,{data}")),
|
||||
);
|
||||
merge_top_level_detail(part, &mut image_url);
|
||||
Some(Value::Object(image_url))
|
||||
}
|
||||
|
||||
fn image_url_content_part(image_url: Value) -> Value {
|
||||
let mut content_part = Map::new();
|
||||
content_part.insert("type".to_string(), Value::String("image_url".to_string()));
|
||||
content_part.insert("image_url".to_string(), image_url);
|
||||
Value::Object(content_part)
|
||||
}
|
||||
|
||||
fn chat_image_part_has_inline_data(part: &Value) -> bool {
|
||||
part.pointer("/image_url/url")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|url| {
|
||||
let trimmed = url.trim();
|
||||
let Some(comma_index) = trimmed.find(',') else {
|
||||
return false;
|
||||
};
|
||||
comma_index + 1 < trimmed.len() && is_image_base64_data_url(trimmed)
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_top_level_detail(part: &Value, image_url: &mut Map<String, Value>) {
|
||||
if image_url.get("detail").is_none() {
|
||||
if let Some(detail) = part.get("detail") {
|
||||
image_url.insert("detail".to_string(), detail.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn source_media_type_is_image(source: &Map<String, Value>) -> bool {
|
||||
source
|
||||
.get("media_type")
|
||||
.or_else(|| source.get("mime_type"))
|
||||
.or_else(|| source.get("mimeType"))
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(is_image_mime_type)
|
||||
}
|
||||
|
||||
fn is_image_mime_type(value: &str) -> bool {
|
||||
value
|
||||
.get(..6)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("image/"))
|
||||
}
|
||||
|
||||
fn is_image_base64_data_url(value: &str) -> bool {
|
||||
let Some(comma_index) = value.find(',') else {
|
||||
return false;
|
||||
};
|
||||
let header = &value[..comma_index];
|
||||
let header = header.to_ascii_lowercase();
|
||||
header.starts_with("data:image/") && header.ends_with(";base64")
|
||||
}
|
||||
|
||||
fn looks_like_base64_payload(value: &str) -> bool {
|
||||
if value.len() < BASE64ISH_MIN_BYTES {
|
||||
return false;
|
||||
}
|
||||
|
||||
value
|
||||
.bytes()
|
||||
.all(|byte| matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'+' | b'/' | b'='))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
|
||||
fn large_image_data_url() -> String {
|
||||
format!(
|
||||
"data:image/png;base64,{}",
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAE".repeat(400)
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_input_image_and_merges_top_level_detail() {
|
||||
let part = json!({
|
||||
"type": "input_image",
|
||||
"image_url": "https://example.com/image.png",
|
||||
"detail": "high"
|
||||
});
|
||||
|
||||
let mapped = chat_media_part_from_tool_part(&part, ToolMediaScope::AllSupported).unwrap();
|
||||
|
||||
assert_eq!(mapped["type"], "image_url");
|
||||
assert_eq!(mapped["image_url"]["url"], "https://example.com/image.png");
|
||||
assert_eq!(mapped["image_url"]["detail"], "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_already_chat_shaped_image_url() {
|
||||
let part = json!({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "https://example.com/image.png",
|
||||
"detail": "low"
|
||||
},
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
"prompt_cache_breakpoint": true
|
||||
});
|
||||
|
||||
let mapped = chat_media_part_from_tool_part(&part, ToolMediaScope::AllSupported).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
mapped,
|
||||
json!({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "https://example.com/image.png",
|
||||
"detail": "low"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_anthropic_and_mcp_image_shapes() {
|
||||
let anthropic = json!({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": "YWJj"
|
||||
}
|
||||
});
|
||||
let mcp = json!({
|
||||
"type": "image",
|
||||
"mimeType": "image/webp",
|
||||
"data": "ZGVm"
|
||||
});
|
||||
let anthropic_url = json!({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"url": "https://example.com/anthropic.png"
|
||||
}
|
||||
});
|
||||
|
||||
let anthropic =
|
||||
chat_media_part_from_tool_part(&anthropic, ToolMediaScope::AllSupported).unwrap();
|
||||
let mcp = chat_media_part_from_tool_part(&mcp, ToolMediaScope::AllSupported).unwrap();
|
||||
let anthropic_url =
|
||||
chat_media_part_from_tool_part(&anthropic_url, ToolMediaScope::AllSupported).unwrap();
|
||||
|
||||
assert_eq!(anthropic["image_url"]["url"], "data:image/jpeg;base64,YWJj");
|
||||
assert_eq!(mcp["image_url"]["url"], "data:image/webp;base64,ZGVm");
|
||||
assert_eq!(
|
||||
anthropic_url["image_url"]["url"],
|
||||
"https://example.com/anthropic.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_anthropic_source_data_when_optional_url_is_empty() {
|
||||
let part = json!({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"url": "",
|
||||
"media_type": "image/png",
|
||||
"data": "YWJj"
|
||||
}
|
||||
});
|
||||
|
||||
let mapped = chat_media_part_from_tool_part(&part, ToolMediaScope::AllSupported).unwrap();
|
||||
|
||||
assert_eq!(mapped["image_url"]["url"], "data:image/png;base64,YWJj");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_image_metadata_and_non_image_mcp_payloads() {
|
||||
let metadata = json!({"type": "image", "name": "cover"});
|
||||
let non_image = json!({
|
||||
"type": "image",
|
||||
"mimeType": "text/plain",
|
||||
"data": "aGVsbG8="
|
||||
});
|
||||
|
||||
assert!(chat_media_part_from_tool_part(&metadata, ToolMediaScope::AllSupported).is_none());
|
||||
assert!(chat_media_part_from_tool_part(&non_image, ToolMediaScope::AllSupported).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_data_image_url_is_media_but_loose_remote_url_is_not() {
|
||||
let data = json!({
|
||||
"image_url": {
|
||||
"url": "data:application/octet-stream;base64,YWJj"
|
||||
}
|
||||
});
|
||||
let remote = json!({
|
||||
"image_url": {
|
||||
"url": "https://example.com/search-thumbnail.png"
|
||||
}
|
||||
});
|
||||
|
||||
assert!(tool_output_contains_media(
|
||||
&data,
|
||||
ToolMediaScope::ImagesOnly
|
||||
));
|
||||
assert!(!tool_output_contains_media(
|
||||
&remote,
|
||||
ToolMediaScope::ImagesOnly
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_image_scope_rejects_remote_and_malformed_data_urls() {
|
||||
let inline = json!({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,YWJj"}
|
||||
});
|
||||
let remote = json!({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://example.com/image.png"}
|
||||
});
|
||||
let missing_base64 = json!({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png,YWJj"}
|
||||
});
|
||||
let empty_data = json!({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,"}
|
||||
});
|
||||
|
||||
assert!(tool_output_contains_media(
|
||||
&inline,
|
||||
ToolMediaScope::InlineImagesOnly
|
||||
));
|
||||
assert!(!tool_output_contains_media(
|
||||
&remote,
|
||||
ToolMediaScope::InlineImagesOnly
|
||||
));
|
||||
assert!(!tool_output_contains_media(
|
||||
&missing_base64,
|
||||
ToolMediaScope::InlineImagesOnly
|
||||
));
|
||||
assert!(!tool_output_contains_media(
|
||||
&empty_data,
|
||||
ToolMediaScope::InlineImagesOnly
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_scan_embedded_data_urls_inside_plain_text() {
|
||||
let data_url = large_image_data_url();
|
||||
let mut value = json!(format!("<html><img src=\"{data_url}\"></html>"));
|
||||
let original = value.clone();
|
||||
let replacement = json!({"type": "text", "text": "moved"});
|
||||
let mut media = Vec::new();
|
||||
|
||||
assert!(!tool_output_contains_media(
|
||||
&value,
|
||||
ToolMediaScope::AllSupported
|
||||
));
|
||||
assert_eq!(
|
||||
strip_media_from_tool_value(
|
||||
&mut value,
|
||||
&mut media,
|
||||
ToolMediaScope::AllSupported,
|
||||
&replacement,
|
||||
"moved",
|
||||
),
|
||||
0
|
||||
);
|
||||
assert!(media.is_empty());
|
||||
assert_eq!(value, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_string_data_url_respects_threshold() {
|
||||
let large = large_image_data_url();
|
||||
let small = "data:image/png;base64,YWJj";
|
||||
|
||||
assert!(whole_string_image_data_url(&large).is_some());
|
||||
assert!(whole_string_image_data_url(small).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_media_from_json_string_and_nested_content() {
|
||||
let data_url = large_image_data_url();
|
||||
let mut value = Value::String(
|
||||
json!({
|
||||
"content": [
|
||||
{"type": "input_text", "text": "caption"},
|
||||
{"type": "input_image", "image_url": data_url}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
let replacement = json!({
|
||||
"type": "text",
|
||||
"text": "moved"
|
||||
});
|
||||
let mut media = Vec::new();
|
||||
|
||||
let replaced = strip_media_from_tool_value(
|
||||
&mut value,
|
||||
&mut media,
|
||||
ToolMediaScope::AllSupported,
|
||||
&replacement,
|
||||
"moved",
|
||||
);
|
||||
|
||||
assert_eq!(replaced, 1);
|
||||
assert_eq!(media.len(), 1);
|
||||
let serialized = value.as_str().unwrap();
|
||||
assert!(serialized.contains("\"text\":\"moved\""));
|
||||
assert!(!serialized.contains("iVBORw0KGgo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_plan_keeps_scalar_tool_strings_unquoted() {
|
||||
let raw_data_url = large_image_data_url();
|
||||
let raw_plan = plan_chat_tool_output_media(Value::String(raw_data_url.clone())).unwrap();
|
||||
assert_eq!(raw_plan.tool_content, TOOL_RESULT_MEDIA_MOVED_MARKER);
|
||||
assert_eq!(raw_plan.media_parts[0]["image_url"]["url"], raw_data_url);
|
||||
|
||||
let encoded = json!({
|
||||
"content": [{
|
||||
"type": "input_image",
|
||||
"image_url": raw_data_url
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
let encoded_plan = plan_chat_tool_output_media(Value::String(encoded)).unwrap();
|
||||
assert!(encoded_plan.tool_content.starts_with('{'));
|
||||
assert!(encoded_plan
|
||||
.tool_content
|
||||
.contains(TOOL_RESULT_MEDIA_MOVED_MARKER));
|
||||
assert!(!encoded_plan.tool_content.starts_with('"'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_plan_clamps_residual_base64_inside_json_string_before_serializing() {
|
||||
let residual_base64 = "A".repeat(20_000);
|
||||
let encoded = json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,IMAGE_SENTINEL"
|
||||
},
|
||||
{
|
||||
"type": "video",
|
||||
"data": residual_base64
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let plan = plan_chat_tool_output_media(Value::String(encoded)).unwrap();
|
||||
|
||||
assert!(plan
|
||||
.tool_content
|
||||
.contains("[cc-switch: omitted 20000 bytes]"));
|
||||
assert!(!plan.tool_content.contains(&"A".repeat(64)));
|
||||
assert!(!plan.tool_content.contains("IMAGE_SENTINEL"));
|
||||
assert_eq!(plan.media_parts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_only_scope_ignores_file_and_audio() {
|
||||
let file = json!({"type": "input_file", "file_id": "file_1"});
|
||||
let audio = json!({
|
||||
"type": "input_audio",
|
||||
"input_audio": {"data": "YWJj", "format": "wav"}
|
||||
});
|
||||
|
||||
assert!(!tool_output_contains_media(
|
||||
&file,
|
||||
ToolMediaScope::ImagesOnly
|
||||
));
|
||||
assert!(!tool_output_contains_media(
|
||||
&audio,
|
||||
ToolMediaScope::ImagesOnly
|
||||
));
|
||||
assert!(tool_output_contains_media(
|
||||
&file,
|
||||
ToolMediaScope::AllSupported
|
||||
));
|
||||
assert!(tool_output_contains_media(
|
||||
&audio,
|
||||
ToolMediaScope::AllSupported
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_preserves_long_text_but_removes_data_and_base64_payloads() {
|
||||
let long_text = format!(
|
||||
"{} with spaces and punctuation!",
|
||||
"ordinary text ".repeat(9000)
|
||||
);
|
||||
let data_url = large_image_data_url();
|
||||
let bytes = (0_u8..=255).cycle().take(18_000).collect::<Vec<_>>();
|
||||
let base64 = STANDARD.encode(bytes);
|
||||
let mut value = json!({
|
||||
"text": long_text,
|
||||
"data_url": data_url,
|
||||
"raw": base64
|
||||
});
|
||||
|
||||
clamp_base64ish_strings(&mut value);
|
||||
|
||||
assert_eq!(value["text"], long_text);
|
||||
assert!(value["data_url"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("[cc-switch: omitted "));
|
||||
assert!(value["raw"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("[cc-switch: omitted "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_media_strip_is_byte_stable() {
|
||||
let mut value = json!({
|
||||
"content": [
|
||||
{"type": "text", "text": "hello"},
|
||||
{"type": "image", "name": "business metadata"}
|
||||
]
|
||||
});
|
||||
let before = canonical_json_string(&value);
|
||||
let replacement = json!({"type": "text", "text": "moved"});
|
||||
let mut media = Vec::new();
|
||||
|
||||
let replaced = strip_media_from_tool_value(
|
||||
&mut value,
|
||||
&mut media,
|
||||
ToolMediaScope::AllSupported,
|
||||
&replacement,
|
||||
"moved",
|
||||
);
|
||||
|
||||
assert_eq!(replaced, 0);
|
||||
assert!(media.is_empty());
|
||||
assert_eq!(canonical_json_string(&value), before);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -59,7 +59,8 @@ impl CostCalculator {
|
||||
pricing: &ModelPricing,
|
||||
cost_multiplier: Decimal,
|
||||
) -> CostBreakdown {
|
||||
let input_includes_cache_read = matches!(app_type, "codex" | "gemini" | "grokbuild");
|
||||
let input_includes_cache_read =
|
||||
crate::services::sql_helpers::is_cache_inclusive_app(app_type);
|
||||
Self::calculate_with_cache_semantics(
|
||||
usage,
|
||||
pricing,
|
||||
@@ -111,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,
|
||||
@@ -252,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 {
|
||||
|
||||
@@ -122,7 +122,7 @@ impl<'a> UsageLogger<'a> {
|
||||
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
let input_token_semantics =
|
||||
if matches!(log.app_type.as_str(), "codex" | "gemini" | "grokbuild") {
|
||||
if crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str()) {
|
||||
INPUT_TOKEN_SEMANTICS_TOTAL
|
||||
} else {
|
||||
INPUT_TOKEN_SEMANTICS_FRESH
|
||||
@@ -687,6 +687,57 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_proxy_replaces_matching_session_log_row() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, input_tokens,
|
||||
output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
latency_ms, status_code, created_at, data_source
|
||||
) VALUES ('session:msg_desktop', '_session', 'claude',
|
||||
'claude-sonnet-4-5', 10, 5, 2, 1, 0, 200, 1, 'session_log')",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cache_read_tokens: 2,
|
||||
cache_creation_tokens: 1,
|
||||
model: Some("claude-sonnet-4-5".to_string()),
|
||||
message_id: Some("msg_desktop".to_string()),
|
||||
};
|
||||
let request_id = usage.dedup_request_id(crate::proxy::usage::parser::dedup_scope_for_app(
|
||||
"claude-desktop",
|
||||
"desktop-provider",
|
||||
));
|
||||
let mut proxy_log = request_log(&request_id, 10);
|
||||
proxy_log.provider_id = "desktop-provider".to_string();
|
||||
proxy_log.app_type = "claude-desktop".to_string();
|
||||
proxy_log.model = "claude-sonnet-4-5".to_string();
|
||||
proxy_log.request_model = "claude-sonnet-4-5".to_string();
|
||||
proxy_log.pricing_model = "claude-sonnet-4-5".to_string();
|
||||
proxy_log.usage = usage;
|
||||
|
||||
UsageLogger::new(&db).log_request(&proxy_log)?;
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let (count, source, app_type): (i64, String, String) = conn.query_row(
|
||||
"SELECT COUNT(*), data_source, app_type FROM proxy_request_logs
|
||||
WHERE request_id = 'session:msg_desktop'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)?;
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(source, "proxy");
|
||||
assert_eq!(app_type, "claude-desktop");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_error() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,6 +30,16 @@ fn openai_cache_write_tokens(usage: &Value) -> u32 {
|
||||
/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致
|
||||
pub const SESSION_REQUEST_ID_PREFIX: &str = "session:";
|
||||
|
||||
/// Claude Code and Claude Desktop share Claude message ids with the session
|
||||
/// importer, so both use the bare `session:{message_id}` namespace. Other
|
||||
/// apps retain app/provider scoping to avoid collisions between upstreams.
|
||||
pub fn dedup_scope_for_app<'a>(
|
||||
app_type: &'a str,
|
||||
provider_id: &'a str,
|
||||
) -> Option<(&'a str, &'a str)> {
|
||||
(!matches!(app_type, "claude" | "claude-desktop")).then_some((app_type, provider_id))
|
||||
}
|
||||
|
||||
fn response_id(body: &Value, field: &str) -> Option<String> {
|
||||
body.get(field)
|
||||
.and_then(Value::as_str)
|
||||
@@ -82,16 +92,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 +238,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 +277,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 格式:
|
||||
@@ -557,6 +489,25 @@ mod tests {
|
||||
.starts_with("session:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_apps_share_the_session_request_id_namespace() {
|
||||
let usage = TokenUsage {
|
||||
message_id: Some("msg_123".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for app_type in ["claude", "claude-desktop"] {
|
||||
assert_eq!(
|
||||
usage.dedup_request_id(dedup_scope_for_app(app_type, "provider-a")),
|
||||
"session:msg_123"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
usage.dedup_request_id(dedup_scope_for_app("codex", "provider-a")),
|
||||
"session:codex:provider-a:msg_123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_parsers_recover_ids_from_envelope_chunks() {
|
||||
let openai = vec![
|
||||
@@ -750,22 +701,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 +818,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]
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -31,25 +31,46 @@ pub fn check_env_conflicts(app: &str) -> Result<Vec<EnvConflict>, String> {
|
||||
Ok(conflicts)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EnvKeyword {
|
||||
Exact(&'static str),
|
||||
Prefix(&'static str),
|
||||
}
|
||||
|
||||
/// Get relevant keywords for each app
|
||||
fn get_keywords_for_app(app: &str) -> Vec<&str> {
|
||||
fn get_keywords_for_app(app: &str) -> Vec<EnvKeyword> {
|
||||
match app.to_lowercase().as_str() {
|
||||
"claude" => vec!["ANTHROPIC"],
|
||||
"codex" => vec!["OPENAI"],
|
||||
"gemini" => vec!["GEMINI", "GOOGLE_GEMINI"],
|
||||
"claude" => vec![EnvKeyword::Prefix("ANTHROPIC")],
|
||||
"codex" => vec![EnvKeyword::Prefix("OPENAI")],
|
||||
"gemini" => vec![
|
||||
EnvKeyword::Prefix("GEMINI"),
|
||||
EnvKeyword::Prefix("GOOGLE_GEMINI"),
|
||||
],
|
||||
"grokbuild" | "grok" => vec![
|
||||
EnvKeyword::Exact("XAI_API_KEY"),
|
||||
EnvKeyword::Exact("GROK_DEFAULT_MODEL"),
|
||||
],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_env_keyword(name: &str, keywords: &[EnvKeyword]) -> bool {
|
||||
let upper_name = name.to_uppercase();
|
||||
keywords.iter().any(|keyword| match keyword {
|
||||
EnvKeyword::Exact(name) => upper_name == *name,
|
||||
EnvKeyword::Prefix(prefix) => upper_name.starts_with(prefix),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check system environment variables (Windows Registry or Unix env)
|
||||
#[cfg(target_os = "windows")]
|
||||
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
// Check HKEY_CURRENT_USER\Environment
|
||||
if let Ok(hkcu) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment") {
|
||||
for (name, value) in hkcu.enum_values().filter_map(Result::ok) {
|
||||
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
|
||||
if matches_env_keyword(&name, keywords) {
|
||||
conflicts.push(EnvConflict {
|
||||
var_name: name.clone(),
|
||||
var_value: value.to_string(),
|
||||
@@ -65,7 +86,7 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
.open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment")
|
||||
{
|
||||
for (name, value) in hklm.enum_values().filter_map(Result::ok) {
|
||||
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
|
||||
if matches_env_keyword(&name, keywords) {
|
||||
conflicts.push(EnvConflict {
|
||||
var_name: name.clone(),
|
||||
var_value: value.to_string(),
|
||||
@@ -80,12 +101,12 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
// Check current process environment
|
||||
for (key, value) in std::env::vars() {
|
||||
if keywords.iter().any(|k| key.to_uppercase().contains(k)) {
|
||||
if matches_env_keyword(&key, keywords) {
|
||||
conflicts.push(EnvConflict {
|
||||
var_name: key,
|
||||
var_value: value,
|
||||
@@ -100,7 +121,7 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
|
||||
/// Check shell configuration files for environment variable exports (Unix only)
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
fn check_shell_configs(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
@@ -131,7 +152,7 @@ fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
let var_value = export_line[eq_pos + 1..].trim();
|
||||
|
||||
// Check if variable name contains any keyword
|
||||
if keywords.iter().any(|k| var_name.to_uppercase().contains(k)) {
|
||||
if matches_env_keyword(var_name, keywords) {
|
||||
conflicts.push(EnvConflict {
|
||||
var_name: var_name.to_string(),
|
||||
var_value: var_value
|
||||
@@ -157,12 +178,58 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_keywords() {
|
||||
assert_eq!(get_keywords_for_app("claude"), vec!["ANTHROPIC"]);
|
||||
assert_eq!(get_keywords_for_app("codex"), vec!["OPENAI"]);
|
||||
assert_eq!(
|
||||
get_keywords_for_app("claude"),
|
||||
vec![EnvKeyword::Prefix("ANTHROPIC")]
|
||||
);
|
||||
assert_eq!(
|
||||
get_keywords_for_app("codex"),
|
||||
vec![EnvKeyword::Prefix("OPENAI")]
|
||||
);
|
||||
assert_eq!(
|
||||
get_keywords_for_app("gemini"),
|
||||
vec!["GEMINI", "GOOGLE_GEMINI"]
|
||||
vec![
|
||||
EnvKeyword::Prefix("GEMINI"),
|
||||
EnvKeyword::Prefix("GOOGLE_GEMINI")
|
||||
]
|
||||
);
|
||||
assert_eq!(get_keywords_for_app("unknown"), Vec::<&str>::new());
|
||||
assert_eq!(
|
||||
get_keywords_for_app("grokbuild"),
|
||||
vec![
|
||||
EnvKeyword::Exact("XAI_API_KEY"),
|
||||
EnvKeyword::Exact("GROK_DEFAULT_MODEL")
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
get_keywords_for_app("grok"),
|
||||
get_keywords_for_app("grokbuild")
|
||||
);
|
||||
assert_eq!(get_keywords_for_app("unknown"), Vec::<EnvKeyword>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_keywords_only_match_credentials() {
|
||||
let keywords = get_keywords_for_app("grokbuild");
|
||||
|
||||
assert!(matches_env_keyword("XAI_API_KEY", &keywords));
|
||||
assert!(matches_env_keyword("xai_api_key", &keywords));
|
||||
assert!(matches_env_keyword("GROK_DEFAULT_MODEL", &keywords));
|
||||
assert!(matches_env_keyword("grok_default_model", &keywords));
|
||||
assert!(!matches_env_keyword("MY_XAI_API_KEY", &keywords));
|
||||
assert!(!matches_env_keyword("XAI_API_KEY_BACKUP", &keywords));
|
||||
assert!(!matches_env_keyword("MY_GROK_DEFAULT_MODEL", &keywords));
|
||||
assert!(!matches_env_keyword("GROK_DEFAULT_MODEL_BACKUP", &keywords));
|
||||
assert!(!matches_env_keyword("GROK_BIN_DIR", &keywords));
|
||||
assert!(!matches_env_keyword("GROK_HOME", &keywords));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broad_app_keywords_match_only_at_the_start() {
|
||||
let keywords = get_keywords_for_app("claude");
|
||||
|
||||
assert!(matches_env_keyword("ANTHROPIC_API_KEY", &keywords));
|
||||
assert!(matches_env_keyword("anthropic_base_url", &keywords));
|
||||
assert!(!matches_env_keyword("MY_ANTHROPIC_API_KEY", &keywords));
|
||||
assert!(!matches_env_keyword("NOT_ANTHROPIC", &keywords));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -17,12 +18,14 @@ pub mod s3_sync;
|
||||
pub mod session_usage;
|
||||
pub mod session_usage_codex;
|
||||
pub mod session_usage_gemini;
|
||||
pub mod session_usage_grokbuild;
|
||||
pub mod session_usage_opencode;
|
||||
pub mod skill;
|
||||
pub mod speedtest;
|
||||
pub mod sql_helpers;
|
||||
pub mod stream_check;
|
||||
pub mod subscription;
|
||||
pub mod subscription_grok;
|
||||
pub mod sync_protocol;
|
||||
pub mod usage_cache;
|
||||
pub mod usage_stats;
|
||||
|
||||
@@ -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)
|
||||
@@ -2627,6 +3090,7 @@ impl ProviderService {
|
||||
// Use effective current provider (validated existence) to ensure backfill targets valid provider
|
||||
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||
|
||||
let mut backfill_completed = false;
|
||||
if let Some(current_id) = current_id {
|
||||
if current_id != id {
|
||||
// Additive mode apps - all providers coexist in the same file,
|
||||
@@ -2660,6 +3124,8 @@ impl ProviderService {
|
||||
result
|
||||
.warnings
|
||||
.push(format!("backfill_failed:{current_id}"));
|
||||
} else {
|
||||
backfill_completed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2679,6 +3145,30 @@ impl ProviderService {
|
||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
|
||||
|
||||
// A material-less official Codex provider gets a config-only live
|
||||
// write, which can leave the previous third-party key in
|
||||
// ~/.codex/auth.json and strand the user on a 401 with no login
|
||||
// screen. Only clean up after a successful backfill — the DB copy
|
||||
// made above is what keeps that key recoverable. Failures degrade to
|
||||
// a log entry: config.toml and is_current are already committed, so
|
||||
// failing the switch here would report a switch that in fact happened.
|
||||
if matches!(app_type, AppType::Codex)
|
||||
&& backfill_completed
|
||||
&& provider.category.as_deref() == Some("official")
|
||||
{
|
||||
let db_auth = provider.settings_config.get("auth");
|
||||
match crate::codex_config::clear_stale_codex_live_auth_after_official_switch(
|
||||
db_auth.unwrap_or(&serde_json::Value::Null),
|
||||
) {
|
||||
Ok(true) => log::info!(
|
||||
"Removed stale third-party auth.json after switching to official Codex provider '{}'",
|
||||
provider.id
|
||||
),
|
||||
Ok(false) => {}
|
||||
Err(e) => log::warn!("Failed to clean stale Codex auth.json: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// Hermes is additive, so "switching" doesn't overwrite a live config file
|
||||
// — we instead update the top-level `model:` section to point at this
|
||||
// provider's first declared model. Without this, clicking "switch" would
|
||||
@@ -3029,20 +3519,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 +3751,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 +3778,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
|
||||
|
||||
@@ -1505,8 +1505,11 @@ impl ProxyService {
|
||||
/// Grok Build live 是否具备可接管的自定义模型表。
|
||||
///
|
||||
/// 官方态 live(Grok CLI 自带 OAuth 登录、无 `[model.*]` 表)没有注入
|
||||
/// 占位符的落点,且官方供应商本就禁止经代理接管(封号风险),调用方
|
||||
/// 应跳过接管或直接报错。
|
||||
/// 占位符的落点:Grok CLI 以「config 是否为空」区分官方 OAuth / 自定义
|
||||
/// 供应商两种模式,表达不出「官方 OAuth + 自定义 base_url」。官方供应商
|
||||
/// 的接管能力门见 `official_provider_supports_proxy_takeover`(按应用逐个
|
||||
/// 开,目前仅 Codex),调用方应跳过接管或直接报错。官方态的用量统计由
|
||||
/// `session_usage_grokbuild` 从会话日志导入,不依赖代理。
|
||||
fn grok_live_config_supports_takeover(config: &Value) -> bool {
|
||||
config
|
||||
.get("config")
|
||||
|
||||
@@ -86,6 +86,11 @@ pub fn sync_all_unlocked(db: &Database) -> SessionSyncResult {
|
||||
"OpenCode",
|
||||
crate::services::session_usage_opencode::sync_opencode_usage(db),
|
||||
);
|
||||
merge_sync_step(
|
||||
&mut result,
|
||||
"Grok Build",
|
||||
crate::services::session_usage_grokbuild::sync_grokbuild_usage(db),
|
||||
);
|
||||
notify_sync_result(&result);
|
||||
result
|
||||
}
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1445
-140
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,16 @@
|
||||
/// style provider not added here) shows up loudly as a too-low cache hit
|
||||
/// rate, which is easier to catch than the silent over-deduction that
|
||||
/// would happen with the opposite default.
|
||||
const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini", "grokbuild"];
|
||||
/// 单一语义集(SSOT):写入侧(proxy logger/calculator)、回填侧
|
||||
/// (usage_stats 成本重算)与展示侧(本文件的 SQL 归一)都必须引用这里,
|
||||
/// 防止同一语义散落多处后新增 app 时漏改(grokbuild 曾在回填侧漏掉)。
|
||||
/// 前端 `src/types/usage.ts` 的同名常量是跨语言的对应物,改动须同步。
|
||||
pub(crate) const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini", "grokbuild"];
|
||||
|
||||
/// `app_type` 的存储 `input_tokens` 是否已包含 cache read/write。
|
||||
pub(crate) fn is_cache_inclusive_app(app_type: &str) -> bool {
|
||||
CACHE_INCLUSIVE_APP_TYPES.contains(&app_type)
|
||||
}
|
||||
|
||||
pub(crate) const INPUT_TOKEN_SEMANTICS_LEGACY: i64 = 0;
|
||||
pub(crate) const INPUT_TOKEN_SEMANTICS_TOTAL: i64 = 1;
|
||||
|
||||
@@ -318,6 +318,12 @@ pub const TIER_MONTHLY: &str = "monthly";
|
||||
/// 三处共用同一标识。见 #3651。
|
||||
pub const TIER_THIRTY_DAY: &str = "30_day";
|
||||
|
||||
/// Grok credit 额度窗口的兜底 tier 名。Grok 账单接口只返回一个 credit 用量
|
||||
/// 窗口,`subscription_grok::tier_name_for_reset` 按重置距离优先映射到
|
||||
/// `weekly_limit` / `monthly`,两者都不匹配时用此标识;前端 `TIER_I18N_KEYS`
|
||||
/// 映射到 `subscription.credits`,tray 归入 "c" 分组。
|
||||
pub const TIER_CREDITS: &str = "credits";
|
||||
|
||||
/// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。
|
||||
pub const TIER_GEMINI_PRO: &str = "gemini_pro";
|
||||
pub const TIER_GEMINI_FLASH: &str = "gemini_flash";
|
||||
@@ -1340,6 +1346,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
|
||||
}
|
||||
}
|
||||
}
|
||||
"grokbuild" => crate::services::subscription_grok::get_grok_subscription_quota().await,
|
||||
_ => Ok(SubscriptionQuota::not_found(tool)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
//! Grok (xAI) 官方订阅额度查询
|
||||
//!
|
||||
//! 读取 Grok CLI 的 OAuth 凭据(~/.grok/auth.json),调用 grok.com 的
|
||||
//! gRPC-web billing 端点查询 SuperGrok 订阅的 credit 用量。
|
||||
//!
|
||||
//! 实现移植自 CodexBar(steipete/CodexBar)的 Grok provider:
|
||||
//! - 凭据:`GrokAuth.swift` —— auth.json 是以 OIDC scope URL 为 key 的 map,
|
||||
//! 优先 SuperGrok 的 `https://auth.x.ai::<client-id>` 条目,回退 legacy
|
||||
//! session 条目;`key` 字段即 Bearer token。
|
||||
//! - 查询:`GrokWebBillingFetcher.swift` —— POST 空 gRPC-web 帧到
|
||||
//! `GetGrokCreditsConfig`,响应无公开 .proto,用通用 protobuf 扫描按
|
||||
//! 字段路径启发式提取已用百分比与重置时间。
|
||||
//! - token 刷新由 Grok CLI 自己负责(约 7 天过期),本模块只读不刷新,
|
||||
//! 过期时引导用户重新 `grok login`。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::services::subscription::{
|
||||
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_CREDITS, TIER_MONTHLY, TIER_WEEKLY_LIMIT,
|
||||
};
|
||||
|
||||
const GROK_BILLING_ENDPOINT: &str =
|
||||
"https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig";
|
||||
|
||||
/// SuperGrok(OIDC)条目的 scope 前缀
|
||||
const OIDC_SCOPE_PREFIX: &str = "https://auth.x.ai::";
|
||||
/// 旧版 `grok login` 的 session scope
|
||||
const LEGACY_SESSION_SCOPE: &str = "https://accounts.x.ai/sign-in";
|
||||
|
||||
const RELOGIN_HINT: &str = "Please re-login with `grok login`.";
|
||||
|
||||
// ── 凭据读取 ──────────────────────────────────────────────
|
||||
|
||||
/// (access_token, status, message)
|
||||
type GrokCredentials = (Option<String>, CredentialStatus, Option<String>);
|
||||
|
||||
/// 读取 Grok CLI 的 OAuth 凭据(~/.grok/auth.json,目录可被设置覆盖)
|
||||
fn read_grok_credentials() -> GrokCredentials {
|
||||
let auth_path = crate::grok_config::get_grok_config_dir().join("auth.json");
|
||||
|
||||
if !auth_path.exists() {
|
||||
return (None, CredentialStatus::NotFound, None);
|
||||
}
|
||||
|
||||
let content = match std::fs::read_to_string(&auth_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return (
|
||||
None,
|
||||
CredentialStatus::ParseError,
|
||||
Some(format!("Failed to read Grok auth file: {e}")),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
parse_grok_auth_json(&content)
|
||||
}
|
||||
|
||||
/// 解析 auth.json:顶层是 scope → 条目的 map,选出首选条目并检查过期
|
||||
fn parse_grok_auth_json(content: &str) -> GrokCredentials {
|
||||
let parsed: serde_json::Value = match serde_json::from_str(content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return (
|
||||
None,
|
||||
CredentialStatus::ParseError,
|
||||
Some(format!("Failed to parse Grok auth JSON: {e}")),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let root = match parsed.as_object() {
|
||||
Some(o) => o,
|
||||
None => {
|
||||
return (
|
||||
None,
|
||||
CredentialStatus::ParseError,
|
||||
Some("Grok auth.json root is not an object".to_string()),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let entry = match select_preferred_entry(root) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
return (
|
||||
None,
|
||||
CredentialStatus::ParseError,
|
||||
Some("Grok auth.json contains no usable access token".to_string()),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// select_preferred_entry 已保证 key 非空
|
||||
let access_token = entry
|
||||
.get("key")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
if let Some(expires_at) = entry.get("expires_at").and_then(|v| v.as_str()) {
|
||||
if is_iso_expired(expires_at) {
|
||||
return (
|
||||
Some(access_token),
|
||||
CredentialStatus::Expired,
|
||||
Some("Grok OAuth token has expired".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
(Some(access_token), CredentialStatus::Valid, None)
|
||||
}
|
||||
|
||||
/// 选择首选凭据条目:OIDC(SuperGrok)优先,legacy session 兜底。
|
||||
///
|
||||
/// 只接受 `key` 非空的条目——残缺的 OIDC 记录不能遮蔽健康的 legacy 条目
|
||||
/// (与 CodexBar `selectPreferredEntry` 一致)。
|
||||
fn select_preferred_entry(
|
||||
root: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
let mut oidc_candidate = None;
|
||||
let mut legacy_candidate = None;
|
||||
|
||||
for (scope, value) in root {
|
||||
let entry = match value.as_object() {
|
||||
Some(e) => e,
|
||||
None => continue,
|
||||
};
|
||||
let has_key = entry
|
||||
.get("key")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|k| !k.is_empty());
|
||||
if !has_key {
|
||||
continue;
|
||||
}
|
||||
if scope.starts_with(OIDC_SCOPE_PREFIX) {
|
||||
oidc_candidate = Some(entry);
|
||||
} else if scope == LEGACY_SESSION_SCOPE || scope.contains("/sign-in") {
|
||||
legacy_candidate = Some(entry);
|
||||
}
|
||||
}
|
||||
|
||||
oidc_candidate.or(legacy_candidate)
|
||||
}
|
||||
|
||||
/// 判断 ISO 8601 时间串是否已过期;无法解析时不视为过期
|
||||
fn is_iso_expired(iso: &str) -> bool {
|
||||
let now_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(iso) {
|
||||
dt.timestamp() < now_secs
|
||||
} else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%S%.f") {
|
||||
dt.and_utc().timestamp() < now_secs
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ── gRPC-web 帧与 protobuf 解析 ──────────────────────────
|
||||
|
||||
/// protobuf 扫描收集到的字段(路径 = 从根到该字段的 field number 链)
|
||||
#[derive(Default)]
|
||||
struct ProtobufScan {
|
||||
/// (path, float 值, 出现顺序)
|
||||
fixed32_fields: Vec<(Vec<u64>, f32, usize)>,
|
||||
/// (path, varint 值)
|
||||
varint_fields: Vec<(Vec<u64>, u64)>,
|
||||
}
|
||||
|
||||
fn read_varint(bytes: &[u8], index: &mut usize) -> Option<u64> {
|
||||
let mut value: u64 = 0;
|
||||
let mut shift: u32 = 0;
|
||||
while *index < bytes.len() && shift < 64 {
|
||||
let byte = bytes[*index];
|
||||
*index += 1;
|
||||
value |= u64::from(byte & 0x7F) << shift;
|
||||
if byte & 0x80 == 0 {
|
||||
return Some(value);
|
||||
}
|
||||
shift += 7;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 递归扫描 protobuf 消息,收集 varint 与 fixed32 字段。
|
||||
///
|
||||
/// 无 .proto 定义,length-delimited 字段一律当嵌套消息试扫(深度 ≤4);
|
||||
/// 无法解析的字节从字段起点 +1 重新同步。返回下一个 fixed32 序号。
|
||||
fn scan_protobuf(
|
||||
bytes: &[u8],
|
||||
depth: usize,
|
||||
path: &[u64],
|
||||
order: usize,
|
||||
scan: &mut ProtobufScan,
|
||||
) -> usize {
|
||||
let mut index = 0;
|
||||
let mut next_order = order;
|
||||
|
||||
while index < bytes.len() {
|
||||
let field_start = index;
|
||||
let key = match read_varint(bytes, &mut index) {
|
||||
Some(k) if k != 0 => k,
|
||||
_ => {
|
||||
index = field_start + 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let field_number = key >> 3;
|
||||
let wire_type = key & 0x07;
|
||||
let mut field_path = path.to_vec();
|
||||
field_path.push(field_number);
|
||||
|
||||
match wire_type {
|
||||
0 => match read_varint(bytes, &mut index) {
|
||||
Some(value) => scan.varint_fields.push((field_path, value)),
|
||||
None => index = field_start + 1,
|
||||
},
|
||||
1 => {
|
||||
if index + 8 > bytes.len() {
|
||||
return next_order;
|
||||
}
|
||||
index += 8;
|
||||
}
|
||||
2 => {
|
||||
let length = match read_varint(bytes, &mut index) {
|
||||
Some(l) if l <= (bytes.len() - index) as u64 => l as usize,
|
||||
_ => {
|
||||
index = field_start + 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let end = index + length;
|
||||
if depth < 4 {
|
||||
next_order =
|
||||
scan_protobuf(&bytes[index..end], depth + 1, &field_path, next_order, scan);
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
5 => {
|
||||
if index + 4 > bytes.len() {
|
||||
return next_order;
|
||||
}
|
||||
let bits = u32::from_le_bytes([
|
||||
bytes[index],
|
||||
bytes[index + 1],
|
||||
bytes[index + 2],
|
||||
bytes[index + 3],
|
||||
]);
|
||||
scan.fixed32_fields
|
||||
.push((field_path, f32::from_bits(bits), next_order));
|
||||
next_order += 1;
|
||||
index += 4;
|
||||
}
|
||||
_ => index = field_start + 1,
|
||||
}
|
||||
}
|
||||
|
||||
next_order
|
||||
}
|
||||
|
||||
/// 拆出 gRPC-web data 帧(flags 高位 0x80 的 trailer 帧跳过)。
|
||||
/// 任一帧长度非法时返回空——调用方再按裸 protobuf 兜底。
|
||||
fn grpc_web_data_frames(data: &[u8]) -> Vec<&[u8]> {
|
||||
let mut frames = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < data.len() {
|
||||
if index + 5 > data.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
let flags = data[index];
|
||||
let length = u32::from_be_bytes([
|
||||
data[index + 1],
|
||||
data[index + 2],
|
||||
data[index + 3],
|
||||
data[index + 4],
|
||||
]) as usize;
|
||||
let start = index + 5;
|
||||
let end = start + length;
|
||||
if end > data.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
if flags & 0x80 == 0 {
|
||||
frames.push(&data[start..end]);
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
frames
|
||||
}
|
||||
|
||||
/// 响应体没有帧头时,看首字节是否像合法 protobuf tag(某些成功请求直接返回裸 protobuf)
|
||||
fn looks_like_protobuf_payload(data: &[u8]) -> bool {
|
||||
match data.first() {
|
||||
Some(&first) => {
|
||||
let field_number = first >> 3;
|
||||
let wire_type = first & 0x07;
|
||||
field_number > 0 && matches!(wire_type, 0 | 1 | 2 | 5)
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 trailer 帧(flags & 0x80)解析 `grpc-status` / `grpc-message` 等字段
|
||||
fn grpc_web_trailer_fields(data: &[u8]) -> HashMap<String, String> {
|
||||
let mut fields = HashMap::new();
|
||||
let mut index = 0;
|
||||
while index + 5 <= data.len() {
|
||||
let flags = data[index];
|
||||
let length = u32::from_be_bytes([
|
||||
data[index + 1],
|
||||
data[index + 2],
|
||||
data[index + 3],
|
||||
data[index + 4],
|
||||
]) as usize;
|
||||
let start = index + 5;
|
||||
let end = start + length;
|
||||
if end > data.len() {
|
||||
break;
|
||||
}
|
||||
if flags & 0x80 != 0 {
|
||||
if let Ok(text) = std::str::from_utf8(&data[start..end]) {
|
||||
for line in text.lines().filter(|l| !l.is_empty()) {
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
fields.insert(key.trim().to_lowercase(), percent_decode(value.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
/// gRPC message 使用 percent-encoding;解码失败的序列原样保留
|
||||
fn percent_decode(input: &str) -> String {
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
// 只切字节切片再校验 UTF-8:对 &str 按字节切片会在多字节字符
|
||||
// 边界内 panic(trailer 内容由服务端控制,可含任意 UTF-8)
|
||||
if let Ok(hex) = std::str::from_utf8(&bytes[i + 1..i + 3]) {
|
||||
if let Ok(b) = u8::from_str_radix(hex, 16) {
|
||||
out.push(b);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// 解析出的账单快照
|
||||
struct GrokBillingSnapshot {
|
||||
used_percent: f64,
|
||||
/// Unix 秒
|
||||
resets_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// 从响应体提取已用百分比与重置时间(CodexBar `parseGRPCWebResponse` 的移植)。
|
||||
///
|
||||
/// 启发式:
|
||||
/// - 百分比:wire-type 5 (float) 中路径末段为 1、值域 [0,100] 的字段,
|
||||
/// 取路径最浅、出现最早的那个;
|
||||
/// - 重置时间:varint 中值落在合理 Unix 秒区间且晚于当前时刻的字段,
|
||||
/// 优先精确路径 [1,5,1],否则取最近的未来时间;
|
||||
/// - 零用量特判:proto3 会省略值为 0 的 percent 字段,此时若存在重置时间
|
||||
/// 和用量周期标记(路径 [1,6,*] 或 [1,8,1]=1/2),按 0% 处理。
|
||||
fn parse_billing_payload(data: &[u8], now_secs: i64) -> Result<GrokBillingSnapshot, String> {
|
||||
let mut payloads = grpc_web_data_frames(data);
|
||||
if payloads.is_empty() && looks_like_protobuf_payload(data) {
|
||||
payloads = vec![data];
|
||||
}
|
||||
if payloads.is_empty() {
|
||||
return Err("Grok billing response contained no protobuf payload".to_string());
|
||||
}
|
||||
|
||||
let mut scan = ProtobufScan::default();
|
||||
for payload in payloads {
|
||||
// 与 CodexBar 一致:fixed32 序号在每个顶层 data 帧内独立从 0 计数
|
||||
scan_protobuf(payload, 0, &[], 0, &mut scan);
|
||||
}
|
||||
|
||||
let parsed_percent = scan
|
||||
.fixed32_fields
|
||||
.iter()
|
||||
.filter(|(path, value, _)| {
|
||||
path.last() == Some(&1) && value.is_finite() && *value >= 0.0 && *value <= 100.0
|
||||
})
|
||||
.min_by_key(|(path, _, order)| (path.len(), *order))
|
||||
.map(|(_, value, _)| f64::from(*value));
|
||||
|
||||
let reset_candidates: Vec<(&[u64], i64)> = scan
|
||||
.varint_fields
|
||||
.iter()
|
||||
.filter(|(_, value)| (1_700_000_000..=2_100_000_000).contains(value))
|
||||
.map(|(path, value)| (path.as_slice(), *value as i64))
|
||||
.filter(|(_, ts)| *ts > now_secs)
|
||||
.collect();
|
||||
let reset = reset_candidates
|
||||
.iter()
|
||||
.filter(|(path, _)| *path == [1, 5, 1])
|
||||
.map(|(_, ts)| *ts)
|
||||
.min()
|
||||
.or_else(|| reset_candidates.iter().map(|(_, ts)| *ts).min());
|
||||
|
||||
let has_usage_period = scan.varint_fields.iter().any(|(path, value)| {
|
||||
path.starts_with(&[1, 6]) || (path.as_slice() == [1, 8, 1] && (*value == 1 || *value == 2))
|
||||
});
|
||||
let no_usage_yet = parsed_percent.is_none()
|
||||
&& scan.fixed32_fields.is_empty()
|
||||
&& reset.is_some()
|
||||
&& has_usage_period;
|
||||
|
||||
let used_percent = match parsed_percent.or(if no_usage_yet { Some(0.0) } else { None }) {
|
||||
Some(p) => p,
|
||||
None => return Err("Could not locate usage percent in Grok billing response".to_string()),
|
||||
};
|
||||
|
||||
Ok(GrokBillingSnapshot {
|
||||
used_percent,
|
||||
resets_at: reset,
|
||||
})
|
||||
}
|
||||
|
||||
// ── API 查询 ──────────────────────────────────────────────
|
||||
|
||||
/// 认证类失败(token 无效/过期)的 gRPC 状态判定,
|
||||
/// 移植自 CodexBar `GrokWebBillingError.isAuthenticationFailure`
|
||||
fn is_grpc_auth_failure(status: i64, message: &str) -> bool {
|
||||
if status == 16 {
|
||||
return true;
|
||||
}
|
||||
if status != 7 {
|
||||
return false;
|
||||
}
|
||||
let lower = message.to_lowercase();
|
||||
lower.contains("bad-credentials")
|
||||
|| lower.contains("unauthenticated")
|
||||
|| (lower.contains("oauth2") && lower.contains("could not be validated"))
|
||||
|| (lower.contains("access token")
|
||||
&& (lower.contains("invalid")
|
||||
|| lower.contains("expired")
|
||||
|| lower.contains("could not be validated")))
|
||||
}
|
||||
|
||||
/// xAI 尚未提供团队主体的用量接口,识别其专属失败以给出可读提示
|
||||
fn is_team_billing_unavailable(status: i64, message: &str) -> bool {
|
||||
status == 9
|
||||
&& matches!(
|
||||
message.trim().to_lowercase().as_str(),
|
||||
"no personal team" | "no personal team."
|
||||
)
|
||||
}
|
||||
|
||||
/// 瞬时性 gRPC 状态:DEADLINE_EXCEEDED(4) / UNAVAILABLE(14),以及带超时
|
||||
/// 文案的 CANCELLED(1)。语义上等价 HTTP 504/503,对齐 CodexBar `shouldRetry`
|
||||
/// 的 rpcFailed 分支
|
||||
fn is_transient_grpc_status(status: i64, message: &str) -> bool {
|
||||
match status {
|
||||
4 | 14 => true,
|
||||
1 => {
|
||||
let lower = message.to_lowercase();
|
||||
lower.contains("timeout") || lower.contains("deadline") || lower.contains("expired")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 将非 0 的 gRPC 状态映射为失败。
|
||||
///
|
||||
/// 瞬时状态(超时/不可用)→ `Err`:前端 react-query retry + keep-last-good
|
||||
/// 保留上次成功值,托盘保留旧快照;折叠成 `Ok(success:false)` 会因错误文案
|
||||
/// 匹配不到前端 `isTransientUsageError` 的任何瞬时模式而被当确定性失败,
|
||||
/// 一次服务端抖动就清掉展示值与 lastGood 快照。其余状态 → 确定性失败快照。
|
||||
/// header(trailers-only 响应)与 body trailer 两条路径都必须走这里。
|
||||
fn grpc_status_failure(
|
||||
status: i64,
|
||||
message: &str,
|
||||
tool_label: &str,
|
||||
relogin_hint: &str,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
if is_transient_grpc_status(status, message) {
|
||||
return Err(format!(
|
||||
"Transient gRPC failure (grpc-status {status}): {message}"
|
||||
));
|
||||
}
|
||||
Ok(grpc_status_error(status, message, tool_label, relogin_hint))
|
||||
}
|
||||
|
||||
/// 将非 0 的 gRPC 状态映射为确定性失败快照
|
||||
fn grpc_status_error(
|
||||
status: i64,
|
||||
message: &str,
|
||||
tool_label: &str,
|
||||
relogin_hint: &str,
|
||||
) -> SubscriptionQuota {
|
||||
if is_grpc_auth_failure(status, message) {
|
||||
return SubscriptionQuota::error(
|
||||
tool_label,
|
||||
CredentialStatus::Expired,
|
||||
format!("Grok credentials were rejected (grpc-status {status}). {relogin_hint}"),
|
||||
);
|
||||
}
|
||||
if is_team_billing_unavailable(status, message) {
|
||||
return SubscriptionQuota::error(
|
||||
tool_label,
|
||||
CredentialStatus::Valid,
|
||||
"Grok team usage is not available from the billing API yet".to_string(),
|
||||
);
|
||||
}
|
||||
SubscriptionQuota::error(
|
||||
tool_label,
|
||||
CredentialStatus::Valid,
|
||||
format!("Grok billing RPC failed (grpc-status {status}): {message}"),
|
||||
)
|
||||
}
|
||||
|
||||
/// 按重置时间距今的天数推断窗口 tier 名(CodexBar `primaryLabel` 的阈值):
|
||||
/// 4–12 天 → 周窗口,20–45 天 → 月窗口,其余 → 通用 credit 额度
|
||||
fn tier_name_for_reset(resets_at: Option<i64>, now_secs: i64) -> &'static str {
|
||||
if let Some(ts) = resets_at {
|
||||
let days = ((ts - now_secs) as f64 / 86400.0).round() as i64;
|
||||
if (4..=12).contains(&days) {
|
||||
return TIER_WEEKLY_LIMIT;
|
||||
}
|
||||
if (20..=45).contains(&days) {
|
||||
return TIER_MONTHLY;
|
||||
}
|
||||
}
|
||||
TIER_CREDITS
|
||||
}
|
||||
|
||||
/// 查询 Grok 官方订阅额度
|
||||
///
|
||||
/// 与 claude/codex/gemini 同一约定:瞬时传输失败返回 `Err`(前端 retry +
|
||||
/// 保留上次成功值),确定性失败返回 `Ok(success:false)`。
|
||||
///
|
||||
/// 参数化 `tool_label` / `relogin_hint` 让该函数可被两个调用点共用(与
|
||||
/// `query_codex_quota` 的双调用点设计一致):
|
||||
/// - `"grokbuild"` + "grok login"(Grok CLI 凭据路径)
|
||||
/// - `"xai_oauth"` + "re-login via cc-switch"(cc-switch 自管 xAI OAuth 路径,
|
||||
/// 见 `commands::xai_oauth::get_xai_oauth_quota`;两者是同一个 OAuth client,
|
||||
/// token 对 grok.com 账单端点等效)
|
||||
pub(crate) async fn query_grok_quota(
|
||||
access_token: &str,
|
||||
tool_label: &str,
|
||||
relogin_hint: &str,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
// 空 gRPC-web 帧:1 字节 flags + 4 字节大端长度 0
|
||||
let resp = client
|
||||
.post(GROK_BILLING_ENDPOINT)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.header("Origin", "https://grok.com")
|
||||
.header("Referer", "https://grok.com/?_s=usage")
|
||||
.header("Accept", "*/*")
|
||||
.header("Content-Type", "application/grpc-web+proto")
|
||||
.header("x-grpc-web", "1")
|
||||
.header("x-user-agent", "connect-es/2.1.1")
|
||||
.header("User-Agent", "cc-switch")
|
||||
.body(vec![0u8; 5])
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Err(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return Ok(SubscriptionQuota::error(
|
||||
tool_label,
|
||||
CredentialStatus::Expired,
|
||||
format!("Authentication failed (HTTP {status}). {relogin_hint}"),
|
||||
));
|
||||
}
|
||||
|
||||
// HTTP 408 与 grpc-status 4 同为服务端超时,以 Err 传播(前端 retry +
|
||||
// keep-last-good);折叠进下方通用分支会因前端 isTransientUsageError 只认
|
||||
// 5xx/429 为瞬时而清掉 lastGood。CodexBar 的 shouldRetry 同样重试 408,
|
||||
// 其余的 502/503/504 前端已按 5xx 识别为瞬时,维持 Ok(success:false)。
|
||||
if status == reqwest::StatusCode::REQUEST_TIMEOUT {
|
||||
return Err(format!("Transient HTTP failure (HTTP {status})"));
|
||||
}
|
||||
|
||||
// gRPC 错误可能在 HTTP 头里携带(trailers-only 响应),先于响应体检查
|
||||
let header_status = resp
|
||||
.headers()
|
||||
.get("grpc-status")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<i64>().ok());
|
||||
let header_message = resp
|
||||
.headers()
|
||||
.get("grpc-message")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(percent_decode)
|
||||
.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let body: String = body.chars().take(400).collect();
|
||||
return Ok(SubscriptionQuota::error(
|
||||
tool_label,
|
||||
CredentialStatus::Valid,
|
||||
format!("API error (HTTP {status}): {body}"),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(code) = header_status {
|
||||
if code != 0 {
|
||||
return grpc_status_failure(code, &header_message, tool_label, relogin_hint);
|
||||
}
|
||||
}
|
||||
|
||||
let raw = match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Err(format!("Failed to read API response: {e}")),
|
||||
};
|
||||
|
||||
let trailers = grpc_web_trailer_fields(&raw);
|
||||
if let Some(code) = trailers
|
||||
.get("grpc-status")
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
{
|
||||
if code != 0 {
|
||||
let message = trailers
|
||||
.get("grpc-message")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("");
|
||||
return grpc_status_failure(code, message, tool_label, relogin_hint);
|
||||
}
|
||||
}
|
||||
|
||||
let now_secs = now_secs();
|
||||
let snapshot = match parse_billing_payload(&raw, now_secs) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return Ok(SubscriptionQuota::error(
|
||||
tool_label,
|
||||
CredentialStatus::Valid,
|
||||
format!("Failed to parse API response: {e}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let tier = QuotaTier {
|
||||
name: tier_name_for_reset(snapshot.resets_at, now_secs).to_string(),
|
||||
utilization: snapshot.used_percent.clamp(0.0, 100.0),
|
||||
resets_at: snapshot
|
||||
.resets_at
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
|
||||
.map(|dt| dt.to_rfc3339()),
|
||||
used_value_usd: None,
|
||||
max_value_usd: None,
|
||||
};
|
||||
|
||||
Ok(SubscriptionQuota {
|
||||
tool: tool_label.to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: None,
|
||||
success: true,
|
||||
tiers: vec![tier],
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
})
|
||||
}
|
||||
|
||||
/// grokbuild 的订阅额度入口(由 `subscription::get_subscription_quota` 分发)
|
||||
pub(crate) async fn get_grok_subscription_quota() -> Result<SubscriptionQuota, String> {
|
||||
let (token, status, message) = read_grok_credentials();
|
||||
|
||||
match status {
|
||||
CredentialStatus::NotFound => Ok(SubscriptionQuota::not_found("grokbuild")),
|
||||
CredentialStatus::ParseError => Ok(SubscriptionQuota::error(
|
||||
"grokbuild",
|
||||
CredentialStatus::ParseError,
|
||||
message.unwrap_or_else(|| "Failed to parse Grok credentials".to_string()),
|
||||
)),
|
||||
CredentialStatus::Expired => {
|
||||
// 即使过期也尝试调用 API(时钟偏差时 token 可能仍有效)
|
||||
if let Some(ref token) = token {
|
||||
let result = query_grok_quota(token, "grokbuild", RELOGIN_HINT).await?;
|
||||
if result.success {
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
Ok(SubscriptionQuota::error(
|
||||
"grokbuild",
|
||||
CredentialStatus::Expired,
|
||||
format!(
|
||||
"{} {RELOGIN_HINT}",
|
||||
message.unwrap_or_else(|| "Grok OAuth token has expired.".to_string())
|
||||
),
|
||||
))
|
||||
}
|
||||
CredentialStatus::Valid => {
|
||||
let token = token.expect("token must be Some when status is Valid");
|
||||
query_grok_quota(&token, "grokbuild", RELOGIN_HINT).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 辅助函数 ──────────────────────────────────────────────
|
||||
|
||||
fn now_secs() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
fn now_millis() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── protobuf 构造辅助 ──
|
||||
|
||||
fn varint(mut value: u64) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let byte = (value & 0x7F) as u8;
|
||||
value >>= 7;
|
||||
if value == 0 {
|
||||
out.push(byte);
|
||||
break;
|
||||
}
|
||||
out.push(byte | 0x80);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn field_varint(number: u64, value: u64) -> Vec<u8> {
|
||||
let mut out = varint(number << 3);
|
||||
out.extend(varint(value));
|
||||
out
|
||||
}
|
||||
|
||||
fn field_float(number: u64, value: f32) -> Vec<u8> {
|
||||
let mut out = varint((number << 3) | 5);
|
||||
out.extend(value.to_bits().to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn field_message(number: u64, payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = varint((number << 3) | 2);
|
||||
out.extend(varint(payload.len() as u64));
|
||||
out.extend(payload);
|
||||
out
|
||||
}
|
||||
|
||||
fn grpc_web_frame(flags: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![flags];
|
||||
out.extend((payload.len() as u32).to_be_bytes());
|
||||
out.extend(payload);
|
||||
out
|
||||
}
|
||||
|
||||
const NOW: i64 = 1_750_000_000;
|
||||
|
||||
#[test]
|
||||
fn parses_percent_and_reset_from_framed_payload() {
|
||||
// message { 1: { 1: 37.5f, 5: { 1: reset_ts } } }
|
||||
let reset_ts = (NOW + 30 * 86400) as u64;
|
||||
let inner = [
|
||||
field_float(1, 37.5),
|
||||
field_message(5, &field_varint(1, reset_ts)),
|
||||
]
|
||||
.concat();
|
||||
let payload = field_message(1, &inner);
|
||||
let data = grpc_web_frame(0, &payload);
|
||||
|
||||
let snapshot = parse_billing_payload(&data, NOW).expect("parse ok");
|
||||
assert_eq!(snapshot.used_percent, 37.5);
|
||||
assert_eq!(snapshot.resets_at, Some(reset_ts as i64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bare_protobuf_without_frame_header() {
|
||||
let payload = field_message(1, &field_float(1, 12.0));
|
||||
let snapshot = parse_billing_payload(&payload, NOW).expect("parse ok");
|
||||
assert_eq!(snapshot.used_percent, 12.0);
|
||||
assert_eq!(snapshot.resets_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_shallowest_percent_candidate() {
|
||||
// 深层 [1,2,1]=99.0 不应盖过浅层 [1,1]=25.0
|
||||
let inner = [
|
||||
field_message(2, &field_float(1, 99.0)),
|
||||
field_float(1, 25.0),
|
||||
]
|
||||
.concat();
|
||||
let payload = field_message(1, &inner);
|
||||
let data = grpc_web_frame(0, &payload);
|
||||
|
||||
let snapshot = parse_billing_payload(&data, NOW).expect("parse ok");
|
||||
assert_eq!(snapshot.used_percent, 25.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_usage_period_without_percent_field_reads_as_zero() {
|
||||
// proto3 省略 0 值 percent:仅有 [1,5,1] 重置时间 + [1,6,1] 周期标记
|
||||
let reset_ts = (NOW + 7 * 86400) as u64;
|
||||
let inner = [
|
||||
field_message(5, &field_varint(1, reset_ts)),
|
||||
field_message(6, &field_varint(1, 3)),
|
||||
]
|
||||
.concat();
|
||||
let payload = field_message(1, &inner);
|
||||
let data = grpc_web_frame(0, &payload);
|
||||
|
||||
let snapshot = parse_billing_payload(&data, NOW).expect("parse ok");
|
||||
assert_eq!(snapshot.used_percent, 0.0);
|
||||
assert_eq!(snapshot.resets_at, Some(reset_ts as i64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_percent_without_period_marker_is_parse_error() {
|
||||
let payload = field_message(1, &field_varint(7, 42));
|
||||
let data = grpc_web_frame(0, &payload);
|
||||
assert!(parse_billing_payload(&data, NOW).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailer_frames_are_excluded_from_payload_and_expose_status() {
|
||||
let payload = field_message(1, &field_float(1, 50.0));
|
||||
let mut data = grpc_web_frame(0, &payload);
|
||||
data.extend(grpc_web_frame(0x80, b"grpc-status: 0\r\ngrpc-message: ok"));
|
||||
|
||||
let snapshot = parse_billing_payload(&data, NOW).expect("parse ok");
|
||||
assert_eq!(snapshot.used_percent, 50.0);
|
||||
|
||||
let trailers = grpc_web_trailer_fields(&data);
|
||||
assert_eq!(trailers.get("grpc-status").map(String::as_str), Some("0"));
|
||||
assert_eq!(trailers.get("grpc-message").map(String::as_str), Some("ok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_decode_unescapes_grpc_message() {
|
||||
assert_eq!(percent_decode("no%20personal%20team"), "no personal team");
|
||||
assert_eq!(percent_decode("plain"), "plain");
|
||||
// 非法序列原样保留
|
||||
assert_eq!(percent_decode("50%ZZ"), "50%ZZ");
|
||||
// '%' + ASCII + 多字节字符:不得在字符边界内切片 panic
|
||||
assert_eq!(percent_decode("bad%1é"), "bad%1é");
|
||||
assert_eq!(percent_decode("%1é"), "%1é");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_json_prefers_oidc_entry_over_legacy() {
|
||||
let content = r#"{
|
||||
"https://accounts.x.ai/sign-in": {"key": "legacy-token"},
|
||||
"https://auth.x.ai::client-id": {"key": "oidc-token"}
|
||||
}"#;
|
||||
let (token, status, _) = parse_grok_auth_json(content);
|
||||
assert_eq!(token.as_deref(), Some("oidc-token"));
|
||||
assert!(matches!(status, CredentialStatus::Valid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_json_empty_oidc_key_falls_back_to_legacy() {
|
||||
// 残缺 OIDC 记录不遮蔽健康的 legacy 条目
|
||||
let content = r#"{
|
||||
"https://auth.x.ai::client-id": {"key": ""},
|
||||
"https://accounts.x.ai/sign-in": {"key": "legacy-token"}
|
||||
}"#;
|
||||
let (token, status, _) = parse_grok_auth_json(content);
|
||||
assert_eq!(token.as_deref(), Some("legacy-token"));
|
||||
assert!(matches!(status, CredentialStatus::Valid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_json_expired_entry_reports_expired() {
|
||||
let content = r#"{
|
||||
"https://auth.x.ai::client-id": {
|
||||
"key": "token",
|
||||
"expires_at": "2020-01-01T00:00:00.000Z"
|
||||
}
|
||||
}"#;
|
||||
let (token, status, message) = parse_grok_auth_json(content);
|
||||
assert_eq!(token.as_deref(), Some("token"));
|
||||
assert!(matches!(status, CredentialStatus::Expired));
|
||||
assert!(message.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_json_without_usable_entry_is_parse_error() {
|
||||
let (token, status, _) = parse_grok_auth_json(r#"{"other-scope": {"key": "x"}}"#);
|
||||
assert!(token.is_none());
|
||||
assert!(matches!(status, CredentialStatus::ParseError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_name_follows_reset_distance() {
|
||||
assert_eq!(
|
||||
tier_name_for_reset(Some(NOW + 7 * 86400), NOW),
|
||||
TIER_WEEKLY_LIMIT
|
||||
);
|
||||
assert_eq!(
|
||||
tier_name_for_reset(Some(NOW + 30 * 86400), NOW),
|
||||
TIER_MONTHLY
|
||||
);
|
||||
assert_eq!(tier_name_for_reset(Some(NOW + 86400), NOW), TIER_CREDITS);
|
||||
assert_eq!(tier_name_for_reset(None, NOW), TIER_CREDITS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_auth_and_team_failures_classify_correctly() {
|
||||
assert!(is_grpc_auth_failure(16, ""));
|
||||
assert!(is_grpc_auth_failure(7, "Bad-Credentials: token rejected"));
|
||||
assert!(!is_grpc_auth_failure(7, "quota exceeded"));
|
||||
assert!(is_team_billing_unavailable(9, " No Personal Team "));
|
||||
assert!(!is_team_billing_unavailable(9, "other precondition"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_grpc_statuses_propagate_as_err() {
|
||||
// DEADLINE_EXCEEDED / UNAVAILABLE 无条件瞬时
|
||||
assert!(is_transient_grpc_status(4, ""));
|
||||
assert!(is_transient_grpc_status(14, ""));
|
||||
// CANCELLED 仅在带超时文案时瞬时
|
||||
assert!(is_transient_grpc_status(1, "context deadline exceeded"));
|
||||
assert!(!is_transient_grpc_status(1, "cancelled by user"));
|
||||
// 鉴权/团队/其他状态不属瞬时
|
||||
assert!(!is_transient_grpc_status(16, ""));
|
||||
assert!(!is_transient_grpc_status(9, "no personal team"));
|
||||
assert!(!is_transient_grpc_status(13, "internal"));
|
||||
|
||||
// 瞬时 → Err(前端 retry + keep-last-good),确定性 → Ok(success:false)
|
||||
assert!(grpc_status_failure(4, "deadline exceeded", "grokbuild", RELOGIN_HINT).is_err());
|
||||
assert!(grpc_status_failure(14, "unavailable", "grokbuild", RELOGIN_HINT).is_err());
|
||||
let determinate = grpc_status_failure(13, "internal", "grokbuild", RELOGIN_HINT)
|
||||
.expect("determinate is Ok");
|
||||
assert!(!determinate.success);
|
||||
// tool_label 参数化:两条链路(CLI / cc-switch 自管 OAuth)标签正确落到快照
|
||||
let auth =
|
||||
grpc_status_failure(16, "", "xai_oauth", "re-login").expect("auth failure is Ok");
|
||||
assert!(matches!(auth.credential_status, CredentialStatus::Expired));
|
||||
assert_eq!(auth.tool, "xai_oauth");
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,7 @@ fn provider_name_coalesce(log_alias: &str, provider_alias: &str) -> String {
|
||||
WHEN '_codex_session' THEN 'Codex (Session)' \
|
||||
WHEN '_gemini_session' THEN 'Gemini (Session)' \
|
||||
WHEN '_opencode_session' THEN 'OpenCode (Session)' \
|
||||
WHEN '_grok_session' THEN 'Grok Build (Session)' \
|
||||
ELSE {log_alias}.provider_id END)"
|
||||
)
|
||||
}
|
||||
@@ -229,6 +230,12 @@ fn data_source_expr(log_alias: &str) -> String {
|
||||
format!("COALESCE({log_alias}.data_source, 'proxy')")
|
||||
}
|
||||
|
||||
fn dedup_app_type_match_sql(left: &str, right: &str) -> String {
|
||||
format!(
|
||||
"{left} IN ({right}, CASE WHEN {right} = 'claude' THEN 'claude-desktop' ELSE {right} END)"
|
||||
)
|
||||
}
|
||||
|
||||
/// SQL 标量表达式:把 Claude Desktop 网关的 `claude-desktop` app_type 在“展示口径”
|
||||
/// 上折叠进 `claude`,其余 app_type 原样返回。
|
||||
///
|
||||
@@ -242,8 +249,8 @@ fn data_source_expr(log_alias: &str) -> String {
|
||||
/// 而不改动任何已存储的行(详情面板仍读原始 `app_type`)。
|
||||
///
|
||||
/// 注意:包裹后该列上的索引在此比较中失效,但这些都是已带时间过滤的聚合扫描,
|
||||
/// app_type 本就不是主访问路径,可接受。仅用于读侧;去重匹配(`has_matching_
|
||||
/// proxy_usage_log`)与额度检查(`check_provider_limits`)必须保留原始精确比较。
|
||||
/// app_type 本就不是主访问路径,可接受。仅用于读侧;跨源去重使用更窄的
|
||||
/// [`dedup_app_type_match_sql`],额度检查(`check_provider_limits`)仍保留原始精确比较。
|
||||
fn folded_app_type_sql(column: &str) -> String {
|
||||
format!("CASE WHEN {column} = 'claude-desktop' THEN 'claude' ELSE {column} END")
|
||||
}
|
||||
@@ -297,6 +304,8 @@ fn push_provider_model_filters(
|
||||
pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String {
|
||||
let data_source = data_source_expr(log_alias);
|
||||
let proxy_data_source = data_source_expr("proxy_dedup");
|
||||
let app_type_match =
|
||||
dedup_app_type_match_sql("proxy_dedup.app_type", &format!("{log_alias}.app_type"));
|
||||
format!(
|
||||
"NOT (
|
||||
{data_source} IN ('session_log', 'codex_session', 'gemini_session', 'opencode_session')
|
||||
@@ -304,7 +313,7 @@ pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String {
|
||||
SELECT 1
|
||||
FROM proxy_request_logs proxy_dedup
|
||||
WHERE {proxy_data_source} = 'proxy'
|
||||
AND proxy_dedup.app_type = {log_alias}.app_type
|
||||
AND {app_type_match}
|
||||
AND proxy_dedup.status_code >= 200
|
||||
AND proxy_dedup.status_code < 300
|
||||
AND proxy_dedup.input_tokens = {log_alias}.input_tokens
|
||||
@@ -377,12 +386,13 @@ pub(crate) fn has_matching_proxy_usage_log(
|
||||
matches!(key.app_type, "codex" | "gemini" | "opencode") && key.cache_creation_tokens == 0;
|
||||
|
||||
let l_data_source = data_source_expr("l");
|
||||
let app_type_match = dedup_app_type_match_sql("l.app_type", "?1");
|
||||
let sql = format!(
|
||||
"SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM proxy_request_logs l
|
||||
WHERE {l_data_source} = 'proxy'
|
||||
AND l.app_type = ?1
|
||||
AND {app_type_match}
|
||||
AND l.status_code >= 200
|
||||
AND l.status_code < 300
|
||||
AND l.input_tokens = ?3
|
||||
@@ -416,6 +426,40 @@ pub(crate) fn has_matching_proxy_usage_log(
|
||||
.map_err(|e| AppError::Database(format!("查询重复代理用量日志失败: {e}")))
|
||||
}
|
||||
|
||||
/// grokbuild 会话导入的接管活动守卫:给定时刻 ±窗口内存在任何 grokbuild
|
||||
/// 代理直录行,即认为当时处于代理接管态,会话事件应整体跳过——同一请求
|
||||
/// 已由代理逐请求记账,会话侧再入账必双算。
|
||||
///
|
||||
/// 不复用 [`has_matching_proxy_usage_log`] 的指纹匹配:Grok 会话事件是
|
||||
/// 逐轮聚合值,与代理逐请求行的 token 值结构性不相等,指纹永不命中。
|
||||
/// 这里按"接管态检测"而非"行匹配"设计,故不过滤 status_code——失败的
|
||||
/// 代理请求同样证明流量正走代理。
|
||||
///
|
||||
/// 已知局限(有意取舍,方向保守只漏不双):窗口不含 session 维度,任一
|
||||
/// grokbuild 代理行会给 ±窗口内的全部会话事件投下阴影——接管/官方两态在
|
||||
/// 十分钟内交替或并行使用时,官方侧轮次会被跳过(漏记而非双算)。
|
||||
pub(crate) fn has_recent_grokbuild_proxy_activity(
|
||||
conn: &Connection,
|
||||
created_at: i64,
|
||||
) -> Result<bool, AppError> {
|
||||
let l_data_source = data_source_expr("l");
|
||||
let sql = format!(
|
||||
"SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM proxy_request_logs l
|
||||
WHERE {l_data_source} = 'proxy'
|
||||
AND l.app_type = 'grokbuild'
|
||||
AND l.created_at BETWEEN ?1 - ?2 AND ?1 + ?2
|
||||
)"
|
||||
);
|
||||
conn.query_row(
|
||||
&sql,
|
||||
params![created_at, SESSION_PROXY_DEDUP_WINDOW_SECONDS],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("查询 Grok 接管活动失败: {e}")))
|
||||
}
|
||||
|
||||
pub(crate) fn has_suspected_codex_session_duplicate(
|
||||
conn: &Connection,
|
||||
request_id: &str,
|
||||
@@ -1850,10 +1894,11 @@ impl Database {
|
||||
let million = rust_decimal::Decimal::from(1_000_000u64);
|
||||
|
||||
// 与 CostCalculator::calculate_for_app 保持一致的计算逻辑:
|
||||
// 1. 历史 Codex/Gemini 行只包含 cache read;新 total 行还包含 cache write。
|
||||
// 1. 历史 cache-inclusive 行只包含 cache read;新 total 行还包含 cache write。
|
||||
// 2. Claude/Anthropic 的 input_tokens 已经是 fresh input,不能再次扣减
|
||||
// 3. 各项成本是基础成本(不含倍率),倍率只作用于最终总价
|
||||
let cache_inclusive_app = matches!(log.app_type.as_str(), "codex" | "gemini");
|
||||
let cache_inclusive_app =
|
||||
crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str());
|
||||
let billable_input_tokens =
|
||||
if !cache_inclusive_app || log.input_token_semantics == INPUT_TOKEN_SEMANTICS_FRESH {
|
||||
log.input_tokens as u64
|
||||
@@ -2427,6 +2472,75 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matching_proxy_log_matches_claude_desktop_for_claude_session() -> Result<(), AppError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
create_legacy_nullable_logs_table(&conn)?;
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, app_type, model, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_creation_tokens, status_code, created_at, data_source
|
||||
) VALUES ('desktop-proxy', 'claude-desktop', 'claude-sonnet-4-5', 100, 20, 10, 5, 200, 1000, 'proxy')",
|
||||
[],
|
||||
)?;
|
||||
|
||||
let key = DedupKey {
|
||||
app_type: "claude",
|
||||
model: "claude-sonnet-4-5",
|
||||
input_tokens: 100,
|
||||
output_tokens: 20,
|
||||
cache_read_tokens: 10,
|
||||
cache_creation_tokens: 5,
|
||||
created_at: 1060,
|
||||
};
|
||||
assert!(has_matching_proxy_usage_log(&conn, &key)?);
|
||||
|
||||
let mut outside_window = key;
|
||||
outside_window.created_at = 1_601;
|
||||
assert!(!has_matching_proxy_usage_log(&conn, &outside_window)?);
|
||||
|
||||
let mut different_model = key;
|
||||
different_model.model = "claude-opus-4-5";
|
||||
assert!(!has_matching_proxy_usage_log(&conn, &different_model)?);
|
||||
|
||||
let mut different_input = key;
|
||||
different_input.input_tokens += 1;
|
||||
assert!(!has_matching_proxy_usage_log(&conn, &different_input)?);
|
||||
|
||||
let mut different_cache_creation = key;
|
||||
different_cache_creation.cache_creation_tokens += 1;
|
||||
assert!(!has_matching_proxy_usage_log(
|
||||
&conn,
|
||||
&different_cache_creation
|
||||
)?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_filter_dedups_claude_session_against_desktop_proxy() -> Result<(), AppError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
create_legacy_nullable_logs_table(&conn)?;
|
||||
conn.execute_batch(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, app_type, model, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_creation_tokens, status_code, created_at, data_source
|
||||
) VALUES
|
||||
('desktop-proxy', 'claude-desktop', 'claude-sonnet-4-5', 100, 20, 10, 5, 200, 1000, 'proxy'),
|
||||
('claude-session', 'claude', 'claude-sonnet-4-5', 100, 20, 10, 5, 200, 1060, 'session_log');",
|
||||
)?;
|
||||
|
||||
let filter = effective_usage_log_filter("l");
|
||||
let sql = format!("SELECT request_id FROM proxy_request_logs l WHERE {filter}");
|
||||
let request_ids = conn
|
||||
.prepare(&sql)?
|
||||
.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
assert_eq!(request_ids, vec!["desktop-proxy"]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_desktop_folds_into_claude_for_display() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
@@ -2613,6 +2727,53 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backfill_deducts_cache_read_for_grokbuild_total_rows() -> Result<(), AppError> {
|
||||
// 回归:回填侧的 cache-inclusive 判定曾硬编码 codex|gemini 漏掉
|
||||
// grokbuild,导致 TOTAL 行按全量 input 计价、cache_read 双算。
|
||||
// 判定收敛到 sql_helpers::is_cache_inclusive_app 后按 450 fresh 计价。
|
||||
let db = Database::memory()?;
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
insert_usage_log(
|
||||
&conn,
|
||||
"grokbuild-total-backfill",
|
||||
"grokbuild",
|
||||
"_grok_session",
|
||||
"grok-4.5",
|
||||
"grok_session",
|
||||
1000,
|
||||
700,
|
||||
100,
|
||||
250,
|
||||
0,
|
||||
200,
|
||||
"0",
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE proxy_request_logs
|
||||
SET input_token_semantics = ?1
|
||||
WHERE request_id = 'grokbuild-total-backfill'",
|
||||
[INPUT_TOKEN_SEMANTICS_TOTAL],
|
||||
)?;
|
||||
}
|
||||
|
||||
assert_eq!(db.backfill_missing_usage_costs()?, 1);
|
||||
|
||||
let conn = lock_conn!(db.conn);
|
||||
let (input_cost, cache_read_cost, total_cost): (String, String, String) = conn.query_row(
|
||||
"SELECT input_cost_usd, cache_read_cost_usd, total_cost_usd
|
||||
FROM proxy_request_logs WHERE request_id = 'grokbuild-total-backfill'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)?;
|
||||
// grok-4.5 定价 2/6/0.50:input = (700-250)×2/1M,cache_read = 250×0.5/1M
|
||||
assert_eq!(input_cost, "0.000900");
|
||||
assert_eq!(cache_read_cost, "0.000125");
|
||||
assert_eq!(total_cost, "0.001625");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backfill_missing_usage_costs_uses_stored_multiplier() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ const M_TIER_NAMES: &[&str] = &[
|
||||
crate::services::subscription::TIER_MONTHLY,
|
||||
crate::services::subscription::TIER_THIRTY_DAY,
|
||||
];
|
||||
// Grok credit 额度的兜底窗口(重置距离能识别为周/月时归入 w/m 组)
|
||||
const CREDITS_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_CREDITS];
|
||||
const GEMINI_PRO_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_PRO];
|
||||
const GEMINI_FLASH_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_FLASH];
|
||||
const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] =
|
||||
@@ -34,6 +36,7 @@ const TIER_LABEL_GROUPS: &[(&str, &[&str])] = &[
|
||||
("h", H_TIER_NAMES),
|
||||
("w", W_TIER_NAMES),
|
||||
("m", M_TIER_NAMES),
|
||||
("c", CREDITS_TIER_NAMES),
|
||||
("p", GEMINI_PRO_TIER_NAMES),
|
||||
("f", GEMINI_FLASH_TIER_NAMES),
|
||||
("l", GEMINI_FLASH_LITE_TIER_NAMES),
|
||||
@@ -1097,6 +1100,7 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
|
||||
let app_clone = app.clone();
|
||||
let state = app.state::<AppState>();
|
||||
let copilot_state = app.state::<CopilotAuthState>();
|
||||
let xai_state = app.state::<crate::commands::XaiOAuthState>();
|
||||
let provider_id = current_id.clone();
|
||||
let app_str = app_type_str.to_string();
|
||||
script_futures.push(async move {
|
||||
@@ -1104,6 +1108,7 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
|
||||
app_clone,
|
||||
state,
|
||||
copilot_state,
|
||||
xai_state,
|
||||
provider_id.clone(),
|
||||
app_str,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.18.0",
|
||||
"version": "3.19.1",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -62,6 +62,7 @@
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
|
||||
"endpoints": [
|
||||
"https://dl.ccswitch.io/latest.json",
|
||||
"https://github.com/farion1231/cc-switch/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -882,6 +882,168 @@ requires_openai_auth = true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_service_switch_codex_official_clears_stale_third_party_auth() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
// preservation stays OFF (default): switching to the third-party provider
|
||||
// wrote its key into live auth.json, and that residue is what this test
|
||||
// expects the official switch to clean up.
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let third_party_config = r#"model_provider = "aihubmix"
|
||||
model = "gpt-5.4"
|
||||
|
||||
[model_providers.aihubmix]
|
||||
name = "AiHubMix"
|
||||
base_url = "https://aihubmix.example/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true
|
||||
"#;
|
||||
// Live key intentionally differs from the DB row so the assertion below
|
||||
// proves the backfill preserved the live copy before it was deleted.
|
||||
let live_auth = json!({ "OPENAI_API_KEY": "stale-live-key" });
|
||||
write_codex_live_atomic(&live_auth, Some(third_party_config))
|
||||
.expect("seed third-party live config");
|
||||
|
||||
let mut initial_config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = initial_config
|
||||
.get_manager_mut(&AppType::Codex)
|
||||
.expect("codex manager");
|
||||
manager.current = "third-party".to_string();
|
||||
manager.providers.insert(
|
||||
"third-party".to_string(),
|
||||
Provider::with_id(
|
||||
"third-party".to_string(),
|
||||
"AiHubMix".to_string(),
|
||||
json!({
|
||||
"auth": {"OPENAI_API_KEY": "old-db-key"},
|
||||
"config": third_party_config
|
||||
}),
|
||||
None,
|
||||
),
|
||||
);
|
||||
let mut official_provider = Provider::with_id(
|
||||
"official-provider".to_string(),
|
||||
"OpenAI Official".to_string(),
|
||||
json!({
|
||||
"auth": {},
|
||||
"config": ""
|
||||
}),
|
||||
None,
|
||||
);
|
||||
official_provider.category = Some("official".to_string());
|
||||
manager
|
||||
.providers
|
||||
.insert("official-provider".to_string(), official_provider);
|
||||
}
|
||||
|
||||
let state = create_test_state_with_config(&initial_config).expect("create test state");
|
||||
|
||||
ProviderService::switch(&state, AppType::Codex, "official-provider")
|
||||
.expect("switch to official provider should succeed");
|
||||
|
||||
assert!(
|
||||
!cc_switch_lib::get_codex_auth_path().exists(),
|
||||
"switching to a material-less official provider must delete the stale \
|
||||
third-party auth.json so Codex shows its login screen"
|
||||
);
|
||||
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(AppType::Codex.as_str())
|
||||
.expect("read providers after switch");
|
||||
assert_eq!(
|
||||
providers
|
||||
.get("third-party")
|
||||
.expect("third-party provider exists")
|
||||
.settings_config
|
||||
.pointer("/auth/OPENAI_API_KEY")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("stale-live-key"),
|
||||
"the live key must be backfilled into the outgoing provider before deletion"
|
||||
);
|
||||
|
||||
let live_config =
|
||||
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
||||
assert!(
|
||||
!live_config.contains("experimental_bearer_token"),
|
||||
"official provider has no API key to inject"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_service_reswitch_current_official_keeps_live_auth() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
// Re-selecting the already-current provider performs no backfill, so the
|
||||
// cleanup must not run either: without a fresh DB copy of whatever sits
|
||||
// in live auth.json, deleting it would destroy the only copy.
|
||||
let live_auth = json!({ "OPENAI_API_KEY": "residue-key" });
|
||||
write_codex_live_atomic(&live_auth, Some("")).expect("seed live config");
|
||||
|
||||
let mut initial_config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = initial_config
|
||||
.get_manager_mut(&AppType::Codex)
|
||||
.expect("codex manager");
|
||||
manager.current = "official-provider".to_string();
|
||||
let mut official_provider = Provider::with_id(
|
||||
"official-provider".to_string(),
|
||||
"OpenAI Official".to_string(),
|
||||
json!({
|
||||
"auth": {},
|
||||
"config": ""
|
||||
}),
|
||||
None,
|
||||
);
|
||||
official_provider.category = Some("official".to_string());
|
||||
manager
|
||||
.providers
|
||||
.insert("official-provider".to_string(), official_provider);
|
||||
}
|
||||
|
||||
let state = create_test_state_with_config(&initial_config).expect("create test state");
|
||||
|
||||
ProviderService::switch(&state, AppType::Codex, "official-provider")
|
||||
.expect("re-switch to current official provider should succeed");
|
||||
|
||||
let auth_value: serde_json::Value =
|
||||
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("auth.json must survive");
|
||||
assert_eq!(
|
||||
auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()),
|
||||
Some("residue-key"),
|
||||
"no backfill happened, so live auth.json must be left untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_codex_live_settings_tolerates_missing_auth_when_config_file_exists() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
assert!(
|
||||
cc_switch_lib::read_codex_live_settings().is_err(),
|
||||
"both files missing is still 'no live install'"
|
||||
);
|
||||
|
||||
// auth.json deleted + empty config.toml is the exact state the official
|
||||
// switch cleanup leaves behind; it must stay readable or the next
|
||||
// backfill / hot switch would treat Codex as uninstalled.
|
||||
let config_path = cc_switch_lib::get_codex_config_path();
|
||||
std::fs::create_dir_all(config_path.parent().expect("codex dir")).expect("create codex dir");
|
||||
std::fs::write(&config_path, "").expect("write empty config.toml");
|
||||
|
||||
let live = cc_switch_lib::read_codex_live_settings()
|
||||
.expect("config file present but empty must be readable");
|
||||
assert_eq!(live.get("auth"), Some(&json!({})));
|
||||
assert_eq!(live.get("config").and_then(|v| v.as_str()), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reapply_codex_official_live_resyncs_mcp_servers() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
|
||||
+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>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink";
|
||||
import { parseDeepLinkConfigPreview } from "@/utils/deepLinkConfigPreview";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,6 +18,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;
|
||||
@@ -220,73 +229,33 @@ export function DeepLinkImportDialog() {
|
||||
? "url"
|
||||
: null;
|
||||
|
||||
// Parse config file content for display
|
||||
interface ParsedConfig {
|
||||
type: "claude" | "codex" | "gemini";
|
||||
env?: Record<string, string>;
|
||||
auth?: Record<string, string>;
|
||||
tomlConfig?: string;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
const parsedConfig = useMemo(
|
||||
() => (request ? parseDeepLinkConfigPreview(request) : null),
|
||||
[request],
|
||||
);
|
||||
|
||||
// Helper to decode base64 with UTF-8 support
|
||||
const b64ToUtf8 = (str: string): string => {
|
||||
try {
|
||||
const binString = atob(str);
|
||||
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0) || 0);
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch (e) {
|
||||
console.error("Failed to decode base64:", e);
|
||||
return atob(str);
|
||||
}
|
||||
};
|
||||
|
||||
const parsedConfig = useMemo((): ParsedConfig | null => {
|
||||
if (!request?.config) return null;
|
||||
try {
|
||||
const decoded = b64ToUtf8(request.config);
|
||||
const parsed = JSON.parse(decoded) as Record<string, unknown>;
|
||||
|
||||
if (request.app === "claude") {
|
||||
// Claude 格式: { env: { ANTHROPIC_AUTH_TOKEN: ..., ... } }
|
||||
return {
|
||||
type: "claude",
|
||||
env: (parsed.env as Record<string, string>) || {},
|
||||
raw: parsed,
|
||||
};
|
||||
} else if (request.app === "codex") {
|
||||
// Codex 格式: { auth: { OPENAI_API_KEY: ... }, config: "TOML string" }
|
||||
return {
|
||||
type: "codex",
|
||||
auth: (parsed.auth as Record<string, string>) || {},
|
||||
tomlConfig: (parsed.config as string) || "",
|
||||
raw: parsed,
|
||||
};
|
||||
} else if (request.app === "gemini") {
|
||||
// Gemini 格式: 扁平结构 { GEMINI_API_KEY: ..., GEMINI_BASE_URL: ... }
|
||||
return {
|
||||
type: "gemini",
|
||||
env: parsed as Record<string, string>,
|
||||
raw: parsed,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse config:", e);
|
||||
return null;
|
||||
}
|
||||
}, [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 +360,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,46 +511,38 @@ 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>
|
||||
)}
|
||||
|
||||
{/* Codex config */}
|
||||
{parsedConfig.type === "codex" && (
|
||||
{(parsedConfig.type === "codex" ||
|
||||
parsedConfig.type === "grokbuild") && (
|
||||
<div className="space-y-2">
|
||||
{parsedConfig.auth &&
|
||||
{parsedConfig.type === "codex" &&
|
||||
parsedConfig.auth &&
|
||||
Object.keys(parsedConfig.auth).length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<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 && (
|
||||
@@ -574,10 +550,8 @@ export function DeepLinkImportDialog() {
|
||||
<div className="text-xs text-muted-foreground">
|
||||
TOML Config:
|
||||
</div>
|
||||
<pre className="text-xs font-mono bg-background p-2 rounded overflow-x-auto max-h-24 whitespace-pre-wrap">
|
||||
{parsedConfig.tomlConfig.substring(0, 300)}
|
||||
{parsedConfig.tomlConfig.length > 300 &&
|
||||
"..."}
|
||||
<pre className="text-xs font-mono bg-background p-2 rounded overflow-auto max-h-24 whitespace-pre-wrap break-all">
|
||||
{parsedConfig.tomlConfig}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -590,17 +564,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 +600,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 +623,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 && (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user