mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-31 19:22:15 +08:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
@@ -5,6 +5,73 @@ All notable changes to CC Switch will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.19.0] - 2026-07-30
|
||||
|
||||
Development since v3.18.0 is headlined by a security-hardening wave and a proxy correctness fix with immediate field impact. On the security side (#5811 and follow-ups): skill installs from GitHub repositories are hardened against zip-slip and repository-coordinate path traversal with hard archive limits; a Gemini common-config credential leak is closed and a one-time startup cleanup scrubs keys that already leaked into other providers' configs; imported SQL backups now run under a SQLite authorizer that denies `ATTACH` and every other statement that can reach outside the import database; common-config snippet handling no longer follows `__proto__` into the global prototype; terminal launch escapes project paths with POSIX single quotes so a directory name can no longer inject commands; and `ccswitch://` import confirmations show every argument, env var, URL, and the complete usage-script body — nothing hidden by truncation, nor by a Base64 variant the dialog could not decode, credential-shaped values masked — flag risky values, and import usage scripts disabled by default. On the proxy side, tool-result images are lifted out of stringified tool text and re-emitted as native media on every conversion bridge, ending the ~9,000× token inflation that pushed Codex sessions past their context window after a few screenshots (#4465, #5663). Usage statistics gain opt-in automatic price sync from models.dev with a persistent local override file (#5734), plus coverage of Grok CLI's official OAuth mode imported from session logs and SuperGrok subscription quota on provider cards. Rounding it out: in-app updates are served from a Cloudflare R2 mirror at `dl.ccswitch.io` with GitHub as fallback, Codex usage import reuses parsed parent rollouts across forked sessions (#5626), preset defaults advance to Claude Opus 5, GPT-5.6 Sol, and Gemini 3.6 Flash, the OpenClaw Kimi For Coding base URL is corrected, and the GPT-in-Claude-Code routing guide is now available in English and Japanese.
|
||||
|
||||
**Stats**: 38 commits | 132 files changed | +14,926 insertions | -1,415 deletions
|
||||
|
||||
### Added
|
||||
|
||||
- **Automatic models.dev Pricing Sync**: The Usage panel's pricing section gains an opt-in (off by default) "Automatic models.dev pricing sync" card: once enabled, CC Switch refreshes the selected models' prices from models.dev at launch, at most once every six hours, overwriting built-in or hand-edited prices that share the same normalized model ID — enabling shows a confirmation that spells this out. A "Choose models" dialog exposes the full models.dev catalog with search and filtering, alongside an "automatically include common models" option that tracks the most recent Claude, GPT, Gemini, Grok, DeepSeek, Qwen, MiMo, LongCat, Kimi, MiniMax, and GLM releases (up to six per family, individually excludable). The card shows the last sync time and last error, offers "Sync now", and can open or reload the local pricing file. From this version onward, manual price overrides and deletions are also recorded in a human-editable `~/.cc-switch/model-pricing.json` next to the database and re-applied at every startup, so they survive database rebuilds — and deleting a built-in price finally sticks across restarts via a tombstone instead of being re-seeded. The file starts empty and deliberately does not back-fill from the existing pricing table, so price edits made before this version still live only in the database until you re-save them. Whenever a sync actually changes a price, historical usage rows that never got a cost (zero or missing) are backfilled at the new price — rows already carrying a computed cost keep their original figures; a failed or offline fetch never blocks startup. Non-text and deprecated models (audio, image, video, embedding, moderation, realtime, TTS, transcription) are filtered out of the models.dev list, which also cleans up the existing manual price picker. (#5734)
|
||||
- **Grok Build Usage Tracking in Official OAuth Mode**: Usage from Grok CLI's official OAuth mode — which cannot be routed through the local proxy, since Grok uses an empty config as the mode switch — is now recorded in the usage dashboard. Per-turn usage is imported from the `turn_completed` events in `~/.grok/sessions` (and archived sessions) `updates.jsonl` files as part of the regular session-log sync; costs prefer the exact figure the CLI reports and fall back to local pricing, with a `grok-4.5-build` row seeded at $2/$6 per million input/output tokens and $0.30 cache read. Imported rows are keyed on the upstream per-turn ID so rewinding a session cannot double count, and a settle window plus a recent-proxy-activity check prevents the same traffic being counted twice when routing is also in use. The rows appear under the provider name "Grok Build (Session)", the app filter gains a Grok Build option, and the data-source breakdown gains a "Grok Build Session" entry with its own icon, in all four languages.
|
||||
- **SuperGrok Subscription Quota on Provider Cards**: Grok Build providers with category `official` now show SuperGrok subscription usage directly on the provider card, alongside the existing Claude Code, Codex, and Gemini official-subscription footers: CC Switch reads the Grok CLI's OAuth credentials from `~/.grok/auth.json` and queries the grok.com billing endpoint for the credit window's used percentage and reset time, labelling the window Weekly or Monthly when the reset distance identifies it, and otherwise a new "Credits" tier that appears as a `c` group in the tray usage summary. Transient network problems keep the last good reading and retry instead of blanking the footer; an expired token surfaces a hint to run `grok login` again. Managed xAI OAuth (SuperGrok) providers in the Claude Code, Claude Desktop, and Codex sections render the same quota automatically from the account bound to the provider and hide their usage-script entry since the quota is built in. Note that Grok Build "officialness" is now decided solely by the provider's `category` field rather than by inspecting config contents.
|
||||
- **Built-in Pricing for Claude Opus 5**: `claude-opus-5` joins the built-in pricing table at $5 / $25 per million input/output tokens, with $0.50 cache read and $6.25 cache write, so its usage no longer reports zero cost without a manual entry. Seeded insert-if-absent, so any price you already customized is untouched. (Opus 5 fast mode is billed through a separate path and deliberately not seeded.)
|
||||
- **Preset Catalog Updates**: A6API — a token aggregator that lists several upstreams for the same model and routes to the cheaper, more stable one — joins the sponsor presets across all eight apps; the PackyCode preset gains three backup endpoints selectable in the address manager and latency test (on the five preset families that support endpoint candidates); the AICoding partner preset returns across seven app types; and sponsor ordering was re-synced so the in-app list matches the READMEs.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Default Preset Models Upgraded to Claude Opus 5, GPT-5.6 Sol, and Gemini 3.6 Flash**: Built-in presets now default to the current model generation: `claude-opus-5` replaces `claude-opus-4-8` in all three naming forms, `gpt-5.6-sol` replaces `gpt-5.5` and bare `gpt-5.6`, and `gemini-3.6-flash` replaces `gemini-3.5-flash`. The same IDs were carried into everything that mirrors the presets — universal/NewAPI defaults, the Codex custom `config.toml` template, agent and category recommendations, form placeholders, and all four locales — and `gemini-3.6-flash` was added to the built-in pricing table ($1.50 / $7.50 per million input/output, $0.15 cache read) so it costs correctly out of the box. The Code0 and Qiniu Gemini presets, still pinned to `gemini-3.1-pro-preview`, were brought onto the same 3.6 Flash baseline — a deliberate tier change, since there is no 3.6 Pro release and 3.5 Pro remains limited to partner testing.
|
||||
- **In-App Updates Are Served From the ccswitch.io Mirror**: The updater now queries `https://dl.ccswitch.io/latest.json` — a Cloudflare R2 mirror of the release manifest — before falling back to GitHub Releases, so checking for and downloading updates no longer depends on GitHub being reachable, which is slow or blocked for many users. The mirrored manifest points every platform's download at the same bucket while minisign signatures are left untouched: they cover file contents rather than URLs, so each downloaded artifact is still verified against the public key baked into the app and the mirror itself remains untrusted. Publishing is handled by a release-gated sync workflow that only rewrites the root manifest when its tag really is GitHub's `releases/latest`, so the mirror can never push users backwards to an older version.
|
||||
- **Faster Codex Usage Import for Forked Sessions**: Importing and rebuilding Codex usage statistics no longer re-reads the same parent rollout file once per fork point: each parent `~/.codex/sessions/*.jsonl` rollout is parsed once into an in-memory token timeline shared by every child session forked from it, with each child's cutoff resolved by an in-memory filter instead of another full-file scan. The cached timeline is validated against a file identity stamp (mtime, size, plus device/inode or Windows file ID), so an appended, rotated, or replaced parent file is re-read rather than served stale. The gain scales with fork density: heavily forked histories see a large reduction in redundant parsing, while a history with few forks is essentially unchanged — import results are byte-identical either way. (#5626)
|
||||
- **Toolbar App Switcher Is Now Icon-Only**: The app switcher buttons no longer render text labels next to the icons — with eight managed apps the labels were collapsed by the overflow detection most of the time anyway, so the ResizeObserver-based auto-compact mechanism was removed and the switcher always shows icons. App names remain discoverable by hovering (tooltip) and stay accessible to screen readers via `aria-label`.
|
||||
- **Sponsor Domains and Referral Links Refreshed**: Several sponsor providers moved to new domains, and their preset addresses, endpoint candidates, referral links, and README rows were updated accordingly (PackyCode → `www.packyapi.ai`, RightCode → `www.rightapi.ai`, ClaudeAPI → `www.apito.ai`, APINebula → `apinebula.ai`, AICodeMirror → `.ai`, AICoding → `.inc`, AIGoCode → `.app`), with two dead backup endpoints dropped along the way. Existing providers keep the base URL stored in the database — see Upgrade notes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Tool-Result Images No Longer Blow Up Context Through the Proxy**: When a client read an image through a tool call — Codex's `view_image`, or any MCP tool that returns a picture — the proxy's protocol conversion serialized the entire image block into the text of the tool message, so the upstream tokenized the base64 payload as prose: roughly 9,000× inflation, where a single 113 KB PNG cost over 100,000 prompt tokens, and because Codex replays the whole history every turn, two or three screenshots were enough to push a session past the context limit and wedge it behind repeated 400s (#4465, #5663). The proxy now lifts media payloads out of tool results and re-emits them as native content on every conversion bridge — images everywhere, files and audio where the target protocol supports them: the two Chat bridges (Claude→Chat and Codex Responses→Chat) carry image, file, and audio parts, leaving a short marker in the tool message and attaching the media as a synthetic user message right after the tool batch, while Claude→Responses restores native `input_image` parts, Codex/GrokBuild→Anthropic rebuilds proper Anthropic image blocks, and Claude→Gemini uses multimodal `functionResponse.parts` on Gemini 3 (with `inlineData` parts for older models), accepting only inline base64 images. Detection covers typed Responses blocks, Anthropic `source` blocks, MCP `data`+`mimeType` results, and whole-string image data URLs, reached through arrays and nested `content` wrappers including inside JSON-encoded tool outputs; once an output is identified as media-bearing, residual data URLs and raw base64 blobs anywhere inside it are collapsed to a placeholder — bare base64 on its own is never treated as media. Tool results with no media keep their exact previous byte-for-byte representation on all bridges, so prompt-cache prefixes are unaffected, and the emitted media parts carry no `cache_control` markers that strict upstreams such as GLM and Qwen reject. An end-to-end run against Kimi K3 held a replayed turn at roughly 12k input tokens with a 99% cache hit, versus more than 85k of base64 text per replay before.
|
||||
- **Unsupported-Image Fallback Now Reaches Images Buried in Tool Results**: The Unsupported Image Fallback setting replaces image blocks with a marker when a provider is text-only or the upstream rejects images, but it could only see images that were still structured blocks — tool-result images already flattened into base64 text were invisible to it, so a text-only upstream failed outright with no way to recover. The media sanitizer now detects and strips media inside tool outputs symmetrically on every path, so both the pre-send strip and the retry-after-rejection path can heal these turns; and because that check now looks inside tool results, a regression test pins that the reactive retry still fires only on genuine modality rejections — a context-length 400 is left alone rather than retried.
|
||||
- **Grok Build Costs Overstated by the Cost Backfill**: The routine that recomputes missing costs treated only Codex and Gemini as providers whose reported input token counts already include cache reads. Grok Build uses the same convention, so any Grok Build row repaired by the backfill was priced on the full input count with cache reads billed a second time. The cache-inclusive provider set is now defined in one place and shared by the routing logger, the cost calculator, and the backfill, so the three can no longer disagree.
|
||||
- **Hand-Edited Config Files No Longer Crash the App or Silently Swallow Edits**: A `~/.codex/config.toml` in which `mcp_servers` exists but is not a table made MCP sync panic mid-switch, after the database and live-config writes had already committed — leaving a half-applied switch; non-table values are now normalised to an empty table with a logged warning, in both the Codex and GrokBuild writers. Inline-table forms (valid TOML) had the mirrored problem: MCP removal silently did nothing while the UI reported success, and a `base_url` edit was written at a level Codex never reads — both now handled. An `opencode.json` whose root, `provider`, or `mcp` section is an array or scalar no longer panics; such files are rejected with an error rather than rebuilt, so the user's own settings are not destroyed. (#5811)
|
||||
- **Proxy Transforms Survive Malformed Upstream Responses**: A non-object `message` or `content_block` in an Anthropic SSE stream, or a buffered body that is a top-level JSON array or scalar (what a gateway that ignores `stream: true` returns), could abort the local proxy instead of producing an error; the stream now bails out with a proper failed event. A malformed `content_block` header is additionally recovered as a text block — sanitising it to an empty object stopped the panic but silently dropped everything that followed — so the deltas after a garbled header come through, with a warning logged. (#5811)
|
||||
- **OpenClaw Kimi For Coding Base URL Corrected**: The OpenClaw preset pointed at `https://api.kimi.com/v1`, the general platform endpoint, rather than the endpoint the Kimi For Coding subscription is served from, so the preset did not work with a coding-plan key. The base URL is now `https://api.kimi.com/coding/v1`, with the form placeholder and default updated to match.
|
||||
|
||||
### Security
|
||||
|
||||
- **Gemini Common Config No Longer Leaks Credentials, With a One-Time Cleanup**: The Gemini common-config extractor stripped only `GEMINI_API_KEY` and `GOOGLE_GEMINI_BASE_URL` from the shared snippet and copied every other `env` entry verbatim — but `GOOGLE_API_KEY` is a first-class Gemini credential, so one account's key (along with anything else credential-shaped) was deep-merged into every other Gemini provider using the common config and sent to that provider's base URL, which may be a third-party relay. The extractor now skips any key matching the same credential-pattern matcher the Claude extractor already uses, and the frontend snippet validator was aligned so a hand-edited snippet cannot put a key straight back. Because a Gemini snippet is never re-extracted once it exists, a one-time startup cleanup also scrubs credentials that already leaked — from the snippet, from every provider they were merged into, and from `~/.gemini/.env`, matching by key *and* value so a provider's own same-named key with a different value survives. (#5811)
|
||||
- **Skill Repository Install Hardening**: Installing or browsing skills from a GitHub repository could write files outside the intended directory: archive entries were joined onto the destination path without normalisation (zip-slip), and repository coordinates were never validated, so a branch like `../../../releases/download/v1/evil` retargeted the download at an arbitrary release asset — and since skill repositories can arrive via an untrusted `ccswitch://` deeplink and are enabled by default, merely opening the Skills panel could trigger the download. Skill `directory` values from restored backups, sync snapshots, and import-from-apps were likewise joined raw, letting uninstall `remove_dir_all` outside the managed skills directory. Every sink now validates the directory name, repository owner/name/branch are whitelisted at the single download convergence point, and extraction is capped (10,000 entries, 512 MB written, 128 MB download, 4 KB symlink targets, self-referential symlinks rejected), with the new errors localized in all four languages. (#5811)
|
||||
- **Deep-Link Import Confirmations Show the Whole Payload (Credentials Masked) and Flag Risky Values**: The `ccswitch://` MCP import confirmation previously rendered a single truncated `Command:` line and showed neither `args`, `url`, nor `env` — so a link carrying `command: "sh"` with `args: ["-c", "curl …|sh"]` and an `LD_PRELOAD` variable displayed as a harmless `sh`, yet was written to the live MCP files on confirm. The dialog now renders command, each argument, URL, and env vars on separate lines that wrap instead of truncating, so nothing is clipped out of view (env values whose name contains TOKEN, KEY, SECRET, or PASSWORD are shown masked, as a prefix plus asterisks), and highlights values that warrant a second look: shell interpreters invoked with inline-command flags, env vars that change how a process loads code (`LD_*`, `DYLD_*`, `NODE_OPTIONS`, `PYTHONPATH`, `PATH`, proxy vars), and endpoints pointing at loopback, private-range, or cloud-metadata addresses. The markers are advisory and never block an import — a local Ollama endpoint is ordinary usage. The provider confirmation gains the same treatment, and the "will be written immediately" warning is now shown unconditionally instead of being gated on a link-controlled field.
|
||||
- **Usage Scripts From Deep Links Arrive Disabled and Their Code Is Shown**: A usage-query script imported through a deep link is JavaScript that runs on every usage query, and it was previously possible to acquire one without ever seeing it: the backend treated the mere presence of code as a decision to enable it, and the dialog showed only an Enabled/Disabled badge. Scripts now default to disabled — a link must explicitly carry `usageEnabled=true` — and the confirmation displays the complete decoded script in a scrollable code block with a warning that it will execute once enabled. Decoding failures fall back to displaying the raw payload, so a malformed script can never read as "there is no script".
|
||||
- **URL-Safe Base64 Payloads Could Blank the Confirmation Dialog Entirely**: The two entries above make the confirmation show more; this one is about the confirmation showing *nothing*. The backend accepts four Base64 variants including the URL-safe alphabet (RFC 4648 §5), while the frontend's `atob` accepts only the standard one and, on failure, returned its input unchanged instead of signalling an error — so for the same payload the backend decoded and imported successfully while the frontend held an undecodable string. Usage scripts and system prompts rendered as opaque Base64, and MCP configs were worse: `JSON.parse` threw, the component swallowed it, and the dialog rendered **"0 servers" with an empty list while the backend wrote the real entry into the live MCP files**. Substituting a single `/` for `_` in the payload was enough — the dialog went blank, the import stayed fully functional, and the complete-payload display added above was defeated along with it. The frontend decoder now normalises the URL-safe alphabet before decoding, so the confirmation always matches what will be imported, and the shared decoder gained its first unit tests — with in-test preconditions asserting each sample really exercises the URL-safe path rather than happening to encode identically under both alphabets. Affected every release from v3.8.0 onward; see Upgrade notes for what to check.
|
||||
- **SQL Import Rejects Statements That Reach Outside the Import Database**: Importing a database backup validated only the file's header comment and then handed the entire text to `execute_batch`, so a crafted backup could `ATTACH DATABASE` and create a SQLite file anywhere the user can write — a side effect that landed even when the import as a whole failed, and one reachable through WebDAV/S3 sync snapshots too. A SQLite authorizer is now installed for the duration of the external batch only, denying `ATTACH`/`DETACH`, `VACUUM`, virtual-table creation, and any action SQLite reports as unknown (fail-closed), with PRAGMAs limited to the two the exporter actually emits.
|
||||
- **Prototype Pollution in Common-Config Snippet Handling**: The three walkers that apply, remove, and compare common-config snippets all followed `__proto__` into the global `Object.prototype`, so a malicious snippet — reachable via WebDAV/S3 sync, since the `settings` table is overwritten by whatever the remote sends — could write attacker-chosen values onto the global prototype the moment a provider form was opened. All three walkers now skip `__proto__`, `constructor`, and `prototype`; the "common config applied" comparison also now requires own properties, fixing a visible quirk where `{"__proto__":{}}` counted as a subset of every config.
|
||||
- **Command Substitution via Project Directory Names on Terminal Launch**: Resuming a session in an external terminal built the `cd` line with double-quote escaping, which stops spaces but not `$(…)`, backticks, or `$VAR` — and the value is the real project path recorded in the CLI's session history, which may legitimately contain those characters. Any project folder named that way executed the embedded command in the user's terminal on Resume. The three launchers that build a shell line — Terminal.app, iTerm, and kitty — now use POSIX single-quote escaping, where nothing expands, including through the AppleScript quoting layer; Ghostty, WezTerm/Kaku, and Alacritty were already safe because they pass the directory as its own argument.
|
||||
- **GrokBuild Credential Resolution No Longer Substitutes Environment Secrets**: GrokBuild credential extraction fell back to the process-wide `XAI_API_KEY` whenever the configured `env_key` variable was unset, silently sending a different account's key to whatever base URL the config pointed at; credentials now come only from an explicit inline `api_key` or the exact variable named by `env_key`. Deep-link import no longer resolves environment variables into a plaintext `api_key`, and a link whose only credential is an `env_key` name is rejected with a message to add the provider manually. As a side effect, base-URL resolution was decoupled from credential resolution, fixing a macOS quirk where a missing credential also blanked the base URL shown in the UI and used by usage scripts. (#5811)
|
||||
|
||||
### Docs
|
||||
|
||||
- **Claude Code GPT Routing Guide Now in English and Japanese**: The local-routing guide for running GPT-family models inside Claude Code, previously Chinese-only, is now fully ported to English and Japanese, covering both integration paths end to end (a third-party OpenAI Responses gateway with an API key, and a ChatGPT Plus/Pro subscription via the Codex device-code OAuth flow). Both routing guides were retitled to name the models they are about — "Using GPT Models in Claude Code with CC Switch" and "Using Claude Models in Codex with CC Switch" — and every cross-link, including the v3.18.0 release notes in all three languages, now points readers at their own-language copy.
|
||||
- **Corrected the Deep-Link `usageEnabled` Default in the User Manual**: The deep-link reference in all three user manuals claimed `usageEnabled` defaults to `true`; it actually defaults to `false`, matching the importer. The manuals now state the correct default and document that the script body is shown in full before import and stays disabled until explicitly enabled.
|
||||
- **Security Policy Documents the Threat Model and Reporting Scope**: `SECURITY.md` gained a bilingual threat model with explicit in-scope and out-of-scope lists, so reports are triaged by who controls the input rather than which API a value eventually reaches: the bundled WebView renderer is declared a trusted component (with the four independently checkable facts backing that and the triggers that would void it), while deep-link payloads, WebDAV/S3 restore data, imported files, upstream API responses, and inbound requests to the local proxy are all named as untrusted and in scope. The same reasoning is recorded as a doc comment on the terminal-launch command.
|
||||
|
||||
### Internal
|
||||
|
||||
- **Release Assets Mirrored to Cloudflare R2 for ccswitch.io Downloads**: Release automation mirrors every installer to an R2 bucket behind `dl.ccswitch.io` and generates the manifest the ccswitch.io download page consumes — per-file SHA-256 checksums, sizes, and the release's real publish date — with immutable caching for versioned assets, a five-minute TTL on the root manifest, and pruning beyond the five most recent versions. Forks without the R2 credentials skip the sync entirely.
|
||||
- **Skill ZIP Extraction Tests No Longer Hijack TMPDIR**: Two cleanup-guard tests pointed the process-global `TMPDIR` at a scratch directory and asserted it ended up empty, so any concurrently running test that created a temp directory randomly failed the assertion on Ubuntu and macOS CI. The extraction now takes its temp base explicitly through a test seam and the tests use a private directory; the shipped code path is unchanged.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- No database schema migration in this release — `SCHEMA_VERSION` stays at 16.
|
||||
- On first launch after upgrading, a one-time Gemini common-config cleanup runs before the usual config extraction. Some Gemini providers may afterwards report a missing API key: entries are removed by credential-style key name plus exact value match, so this is usually another provider's credential that leaked through the shared snippet (the provider's own original value was overwritten when the leak occurred and cannot be recovered) — but a key you intentionally reused across several Gemini providers is removed from all of them as well. Either way, rotate and re-enter the key: treat anything that sat in the shared Gemini snippet as exposed. An audit record listing the removed key names and affected provider ids — never the values — is stored in the `settings` table under `gemini_common_config_scrub_audit_v1`.
|
||||
- If you have ever imported an MCP server from a `ccswitch://` link, it is worth auditing once. Before this release the MCP import confirmation could fail to show what it was about to write: arguments and env vars were not rendered at all (`command: "sh"` with `args: ["-c", …]` displayed as a harmless `sh`), and a URL-safe Base64 payload made the whole list render as "0 servers" — while the backend wrote the real entry to the live MCP files in both cases. Both defects affected **every release from v3.8.0 onward** and are fixed here. Exploitation required opening an attacker-supplied link and confirming the import, so most users are unaffected; if you did open an MCP import link from a source you do not fully trust, review the entries in the MCP panel, or directly in `~/.claude.json` under `mcpServers` (Codex: `mcp_servers` in `~/.codex/config.toml`), and confirm you recognise each one — MCP servers are executed as subprocesses the next time the CLI starts.
|
||||
- Deep links carrying a usage-query script now import it disabled unless the link explicitly sets `usageEnabled=true`. Links that relied on auto-enablement (for example some partner one-click setup links) will import the script but leave usage querying off — enable it in the provider editor after reviewing the code.
|
||||
- The new default models apply only to newly added providers — providers already saved keep the model IDs they were created with. For Claude Desktop, the current opus route ID advances to `claude-opus-5` with `claude-opus-4-8` moving into the legacy slot, so opus route IDs stored in existing configs still resolve through the compatibility alias.
|
||||
- New built-in pricing rows (`claude-opus-5`, `gemini-3.6-flash`, `grok-4.5-build`) are appended insert-if-absent on next launch — this seeding never overwrites a price you edited. A `~/.cc-switch/model-pricing.json` file is created (empty) to hold manual overrides and deletions made from this version onward — earlier price edits are not migrated into it, so re-save any you want protected against a database rebuild. Automatic models.dev sync stays off until you enable it; once enabled, it is the one path that does overwrite matching prices, built-in and hand-edited alike.
|
||||
- GrokBuild providers that relied on the implicit `XAI_API_KEY` environment fallback now need an explicit `api_key` or a correctly named `env_key`.
|
||||
- Grok Build official-mode usage appears with a deliberate delay of roughly ten minutes plus one sync cycle, so events can be checked against proxy-recorded rows; if routed and official usage alternate within that window, some official-mode turns are skipped rather than risk double counting. Grok Build rows already over-priced by the old cost backfill keep their inflated value — the backfill never revises an existing positive cost.
|
||||
- The updater endpoint list ships inside the app binary, so existing installations keep checking GitHub until they have updated once to a build containing this change; from then on the `dl.ccswitch.io` mirror is preferred with GitHub as fallback.
|
||||
- Sponsor providers you already created keep the base URL stored in the database, so they still target the old domains. To move onto a migrated domain, edit the provider's address by hand or re-create it from the refreshed preset.
|
||||
|
||||
## [3.18.0] - 2026-07-21
|
||||
|
||||
Development since v3.17.0 is headlined by Grok Build joining as the eighth managed app — full provider switching, proxy takeover on its own route namespace, MCP/Skills/prompts sync, a curated preset list, and a Grok Official entry with official-login import (schema v14/v15) — and by xAI Grok account sign-in over an OAuth device flow for Claude Code, Claude Desktop, and Codex, including a strict-gateway compatibility layer that lets codex 0.142+ drive a Grok subscription over native Responses. A usage-accounting repair wave fixes the v3.17.0 fork/sub-agent double count with a one-time automatic rebuild (schema v16) plus a manual rebuild action, makes proxy usage logging idempotent, and stops the usage page freezing during large imports. Diagnostics mature: logs persist across restarts under size rotation, every log egress redacts secrets, and renderer crashes are captured to disk behind an error boundary with a reload screen. The Codex conversion layer gets four correctness fixes — tool schemas normalized to object type, reasoning attached forward across turns, streamed tool-call identity and order preserved, and parser-required catalog fields backfilled so codex 0.144.5+ starts — while managed-OAuth providers are now reliably flagged as routing-required and Windows provider switches no longer flash a console window or freeze the UI. Rounded out by Kimi K3 presets and pricing, corrected OpenClaw preset costs, SudoCode.us restored beside SudoCode.chat, sponsor-grouped preset ordering, first-run tray language detection, and permanently deletable default Skill repositories.
|
||||
|
||||
@@ -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` |
|
||||
@@ -60,7 +60,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
|
||||
| `configUrl` | No | Remote configuration URL |
|
||||
| `enabled` | No | Whether to enable (boolean) |
|
||||
| `usageScript` | No | Usage query script |
|
||||
| `usageEnabled` | No | Whether to enable usage query (default true) |
|
||||
| `usageEnabled` | No | Whether to enable usage query (**default false**). The script body is shown in full in the import confirmation dialog; without an explicit `true` the script is imported but left disabled, and can be enabled in the app |
|
||||
| `usageApiKey` | No | Usage query API Key |
|
||||
| `usageBaseUrl` | No | Usage query base URL |
|
||||
| `usageAccessToken` | No | Usage query access token |
|
||||
|
||||
@@ -60,7 +60,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
|
||||
| `configUrl` | いいえ | リモート設定 URL |
|
||||
| `enabled` | いいえ | 有効にするかどうか(ブール値) |
|
||||
| `usageScript` | いいえ | 使用量クエリスクリプト |
|
||||
| `usageEnabled` | いいえ | 使用量クエリを有効にするか(デフォルト true) |
|
||||
| `usageEnabled` | いいえ | 使用量クエリを有効にするか(**デフォルト false**)。スクリプト本文はインポート確認ダイアログに全文表示されます。明示的に `true` を指定しない場合はインポートされるだけで有効化されず、アプリ内で手動で有効にできます |
|
||||
| `usageApiKey` | いいえ | 使用量クエリ専用 API Key |
|
||||
| `usageBaseUrl` | いいえ | 使用量クエリ専用アドレス |
|
||||
| `usageAccessToken` | いいえ | 使用量クエリアクセストークン |
|
||||
|
||||
@@ -60,7 +60,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
|
||||
| `configUrl` | 否 | 远程配置 URL |
|
||||
| `enabled` | 否 | 是否启用(布尔值) |
|
||||
| `usageScript` | 否 | 用量查询脚本 |
|
||||
| `usageEnabled` | 否 | 是否启用用量查询(默认 true) |
|
||||
| `usageEnabled` | 否 | 是否启用用量查询(**默认 false**)。脚本正文会完整展示在导入确认框中;未显式传 `true` 时仅导入不启用,可在应用内手动开启 |
|
||||
| `usageApiKey` | 否 | 用量查询专用 API Key |
|
||||
| `usageBaseUrl` | 否 | 用量查询专用地址 |
|
||||
| `usageAccessToken` | 否 | 用量查询访问令牌 |
|
||||
|
||||
+1
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.18.0",
|
||||
"version": "3.19.0",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -51,7 +51,6 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lobehub/icons-static-svg": "^1.73.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -65,14 +64,12 @@
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.90.3",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-log": "2.8.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-store": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -81,7 +78,6 @@
|
||||
"flexsearch": "^0.8.212",
|
||||
"framer-motion": "^12.23.25",
|
||||
"i18next": "^25.5.2",
|
||||
"jsonc-parser": "^3.2.1",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
Generated
-51
@@ -41,9 +41,6 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.65.0(react@18.3.1))
|
||||
'@lobehub/icons-static-svg':
|
||||
specifier: ^1.73.0
|
||||
version: 1.73.0
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.12
|
||||
version: 1.2.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -83,9 +80,6 @@ importers:
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.8
|
||||
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-visually-hidden':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.90.3
|
||||
version: 5.90.3(react@18.3.1)
|
||||
@@ -104,9 +98,6 @@ importers:
|
||||
'@tauri-apps/plugin-process':
|
||||
specifier: ^2.0.0
|
||||
version: 2.3.0
|
||||
'@tauri-apps/plugin-store':
|
||||
specifier: ^2.0.0
|
||||
version: 2.4.0
|
||||
'@tauri-apps/plugin-updater':
|
||||
specifier: ^2.0.0
|
||||
version: 2.9.0
|
||||
@@ -131,9 +122,6 @@ importers:
|
||||
i18next:
|
||||
specifier: ^25.5.2
|
||||
version: 25.5.2(typescript@5.9.2)
|
||||
jsonc-parser:
|
||||
specifier: ^3.2.1
|
||||
version: 3.3.1
|
||||
lucide-react:
|
||||
specifier: ^0.542.0
|
||||
version: 0.542.0(react@18.3.1)
|
||||
@@ -835,9 +823,6 @@ packages:
|
||||
'@lezer/markdown@1.6.0':
|
||||
resolution: {integrity: sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==}
|
||||
|
||||
'@lobehub/icons-static-svg@1.73.0':
|
||||
resolution: {integrity: sha512-ydKUCDoopdmulbjDZo/gppaODd5Ju5nPneVcN9A5dAz9IJZUMkLms8bqostMLrqcdMQ8resKjLuV9RhJaWhaag==}
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2':
|
||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||
|
||||
@@ -1319,19 +1304,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.4':
|
||||
resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
@@ -1571,9 +1543,6 @@ packages:
|
||||
'@tauri-apps/plugin-process@2.3.0':
|
||||
resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==}
|
||||
|
||||
'@tauri-apps/plugin-store@2.4.0':
|
||||
resolution: {integrity: sha512-PjBnlnH6jyI71MGhrPaxUUCsOzc7WO1mbc4gRhME0m2oxLgCqbksw6JyeKQimuzv4ysdpNO3YbmaY2haf82a3A==}
|
||||
|
||||
'@tauri-apps/plugin-updater@2.9.0':
|
||||
resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==}
|
||||
|
||||
@@ -2267,9 +2236,6 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jsonc-parser@3.3.1:
|
||||
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||
|
||||
launch-ide@1.3.0:
|
||||
resolution: {integrity: sha512-pxiF+HVNMV0dDc6Z0q89RDmzMF9XmSGaOn4ueTegjMy3cUkezc3zrki5PCiz68zZIqAuhW7iwoWX7JO4Kn6B0A==}
|
||||
|
||||
@@ -3692,8 +3658,6 @@ snapshots:
|
||||
'@lezer/common': 1.2.3
|
||||
'@lezer/highlight': 1.2.1
|
||||
|
||||
'@lobehub/icons-static-svg@1.73.0': {}
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2': {}
|
||||
|
||||
'@mswjs/interceptors@0.40.0':
|
||||
@@ -4197,15 +4161,6 @@ snapshots:
|
||||
'@types/react': 18.3.23
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.23
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@reduxjs/toolkit@2.11.0(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1)':
|
||||
@@ -4362,10 +4317,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.8.0
|
||||
|
||||
'@tauri-apps/plugin-store@2.4.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.8.0
|
||||
|
||||
'@tauri-apps/plugin-updater@2.9.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.8.0
|
||||
@@ -5099,8 +5050,6 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonc-parser@3.3.1: {}
|
||||
|
||||
launch-ide@1.3.0:
|
||||
dependencies:
|
||||
chalk: 4.1.1
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 要提取的图标列表(按分类组织)
|
||||
const ICONS_TO_EXTRACT = {
|
||||
// AI 服务商(必需)
|
||||
aiProviders: [
|
||||
'openai', 'anthropic', 'claude', 'google', 'gemini',
|
||||
'deepseek', 'kimi', 'moonshot', 'stepfun', 'zhipu', 'minimax',
|
||||
'baidu', 'alibaba', 'tencent', 'meta', 'microsoft',
|
||||
'cohere', 'perplexity', 'mistral', 'huggingface'
|
||||
],
|
||||
|
||||
// 云平台
|
||||
cloudPlatforms: [
|
||||
'aws', 'azure', 'huawei', 'cloudflare'
|
||||
],
|
||||
|
||||
// 开发工具
|
||||
devTools: [
|
||||
'github', 'gitlab', 'docker', 'kubernetes', 'vscode'
|
||||
],
|
||||
|
||||
// 其他
|
||||
others: [
|
||||
'settings', 'folder', 'file', 'link'
|
||||
]
|
||||
};
|
||||
|
||||
// 合并所有图标
|
||||
const ALL_ICONS = [
|
||||
...ICONS_TO_EXTRACT.aiProviders,
|
||||
...ICONS_TO_EXTRACT.cloudPlatforms,
|
||||
...ICONS_TO_EXTRACT.devTools,
|
||||
...ICONS_TO_EXTRACT.others
|
||||
];
|
||||
|
||||
// 提取逻辑
|
||||
const OUTPUT_DIR = path.join(__dirname, '../src/icons/extracted');
|
||||
const SOURCE_DIR = path.join(__dirname, '../node_modules/@lobehub/icons-static-svg/icons');
|
||||
|
||||
// 确保输出目录存在
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
console.log('🎨 CC-Switch Icon Extractor\n');
|
||||
console.log('========================================');
|
||||
console.log('📦 Extracting icons...\n');
|
||||
|
||||
let extracted = 0;
|
||||
let notFound = [];
|
||||
|
||||
// 提取图标
|
||||
ALL_ICONS.forEach(iconName => {
|
||||
const sourceFile = path.join(SOURCE_DIR, `${iconName}.svg`);
|
||||
const targetFile = path.join(OUTPUT_DIR, `${iconName}.svg`);
|
||||
|
||||
if (fs.existsSync(sourceFile)) {
|
||||
fs.copyFileSync(sourceFile, targetFile);
|
||||
console.log(` ✓ ${iconName}.svg`);
|
||||
extracted++;
|
||||
} else if (fs.existsSync(targetFile)) {
|
||||
console.log(` ✓ ${iconName}.svg (kept local custom icon)`);
|
||||
extracted++;
|
||||
} else {
|
||||
console.log(` ✗ ${iconName}.svg (not found)`);
|
||||
notFound.push(iconName);
|
||||
}
|
||||
});
|
||||
|
||||
// 生成索引文件
|
||||
console.log('\n📝 Generating index file...\n');
|
||||
|
||||
const indexContent = `// Auto-generated icon index
|
||||
// Do not edit manually
|
||||
|
||||
export const icons: Record<string, string> = {
|
||||
${ALL_ICONS.filter(name => !notFound.includes(name))
|
||||
.map(name => {
|
||||
const svg = fs.readFileSync(path.join(OUTPUT_DIR, `${name}.svg`), 'utf-8');
|
||||
const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
||||
return ` '${name}': \`${escaped}\`,`;
|
||||
})
|
||||
.join('\n')}
|
||||
};
|
||||
|
||||
export const iconList = Object.keys(icons);
|
||||
|
||||
export function getIcon(name: string): string {
|
||||
return icons[name.toLowerCase()] || '';
|
||||
}
|
||||
|
||||
export function hasIcon(name: string): boolean {
|
||||
return name.toLowerCase() in icons;
|
||||
}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'index.ts'), indexContent);
|
||||
console.log('✓ Generated: src/icons/extracted/index.ts');
|
||||
|
||||
// 生成图标元数据
|
||||
const metadataContent = `// Icon metadata for search and categorization
|
||||
import { IconMetadata } from '@/types/icon';
|
||||
|
||||
export const iconMetadata: Record<string, IconMetadata> = {
|
||||
// AI Providers
|
||||
openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },
|
||||
anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },
|
||||
claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },
|
||||
google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },
|
||||
gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },
|
||||
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
|
||||
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
|
||||
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
|
||||
stepfun: { name: 'stepfun', displayName: 'StepFun', category: 'ai-provider', keywords: ['stepfun', 'step', 'jieyue', '阶跃星辰'], defaultColor: '#005AFF' },
|
||||
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
|
||||
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
|
||||
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
|
||||
alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },
|
||||
tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },
|
||||
meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },
|
||||
microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },
|
||||
cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },
|
||||
perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },
|
||||
mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },
|
||||
huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },
|
||||
|
||||
// Cloud Platforms
|
||||
aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },
|
||||
azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },
|
||||
huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },
|
||||
cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },
|
||||
|
||||
// Dev Tools
|
||||
github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },
|
||||
gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },
|
||||
docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },
|
||||
kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },
|
||||
vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },
|
||||
|
||||
// Others
|
||||
settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },
|
||||
folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },
|
||||
file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },
|
||||
link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },
|
||||
};
|
||||
|
||||
export function getIconMetadata(name: string): IconMetadata | undefined {
|
||||
return iconMetadata[name.toLowerCase()];
|
||||
}
|
||||
|
||||
export function searchIcons(query: string): string[] {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return Object.values(iconMetadata)
|
||||
.filter(meta =>
|
||||
meta.name.includes(lowerQuery) ||
|
||||
meta.displayName.toLowerCase().includes(lowerQuery) ||
|
||||
meta.keywords.some(k => k.includes(lowerQuery))
|
||||
)
|
||||
.map(meta => meta.name);
|
||||
}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'metadata.ts'), metadataContent);
|
||||
console.log('✓ Generated: src/icons/extracted/metadata.ts');
|
||||
|
||||
// 生成 README
|
||||
const readmeContent = `# Extracted Icons
|
||||
|
||||
This directory contains extracted icons from @lobehub/icons-static-svg.
|
||||
|
||||
## Statistics
|
||||
- Total extracted: ${extracted} icons
|
||||
- Not found: ${notFound.length} icons
|
||||
|
||||
## Extracted Icons
|
||||
${ALL_ICONS.filter(name => !notFound.includes(name)).map(name => `- ${name}`).join('\n')}
|
||||
|
||||
${notFound.length > 0 ? `\n## Not Found\n${notFound.map(name => `- ${name}`).join('\n')}` : ''}
|
||||
|
||||
## Usage
|
||||
|
||||
\`\`\`typescript
|
||||
import { getIcon, hasIcon, iconList } from './extracted';
|
||||
|
||||
// Get icon SVG
|
||||
const svg = getIcon('openai');
|
||||
|
||||
// Check if icon exists
|
||||
if (hasIcon('openai')) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Get all available icons
|
||||
console.log(iconList);
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
Last updated: ${new Date().toISOString()}
|
||||
Generated by: scripts/extract-icons.js
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'README.md'), readmeContent);
|
||||
console.log('✓ Generated: src/icons/extracted/README.md');
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('✅ Extraction complete!\n');
|
||||
console.log(` ✓ Extracted: ${extracted} icons`);
|
||||
console.log(` ✗ Not found: ${notFound.length} icons`);
|
||||
console.log(` 📉 Bundle size reduction: ~${Math.round((1 - extracted / 723) * 100)}%`);
|
||||
console.log('========================================\n');
|
||||
@@ -1,96 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ICONS_DIR = path.join(__dirname, '../src/icons/extracted');
|
||||
|
||||
// List of "Famous" icons to keep
|
||||
// Based on common AI providers and tools
|
||||
const KEEP_LIST = [
|
||||
// AI Providers
|
||||
'openai', 'anthropic', 'claude', 'google', 'gemini', 'gemma', 'palm',
|
||||
'microsoft', 'azure', 'copilot', 'meta', 'llama',
|
||||
'alibaba', 'qwen', 'tencent', 'hunyuan', 'baidu', 'wenxin',
|
||||
'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi', 'stepfun',
|
||||
'zhipu', 'chatglm', 'glm', 'minimax', 'mistral', 'cohere',
|
||||
'perplexity', 'huggingface', 'midjourney', 'stability',
|
||||
'xai', 'grok', 'yi', 'zeroone', 'ollama',
|
||||
'packycode',
|
||||
|
||||
// Cloud/Tools
|
||||
'aws', 'googlecloud', 'huawei', 'cloudflare',
|
||||
'github', 'githubcopilot', 'vercel', 'notion', 'discord',
|
||||
'gitlab', 'docker', 'kubernetes', 'vscode', 'settings', 'folder', 'file', 'link'
|
||||
];
|
||||
|
||||
// Get all SVG files
|
||||
const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));
|
||||
|
||||
console.log(`Scanning ${files.length} files...`);
|
||||
|
||||
let keptCount = 0;
|
||||
let deletedCount = 0;
|
||||
let renamedCount = 0;
|
||||
|
||||
// First pass: Identify files to keep and prefer color versions
|
||||
const fileMap = {}; // name -> { hasColor: bool, hasMono: bool }
|
||||
|
||||
files.forEach(file => {
|
||||
const isColor = file.endsWith('-color.svg');
|
||||
const baseName = isColor ? file.replace('-color.svg', '') : file.replace('.svg', '');
|
||||
|
||||
if (!fileMap[baseName]) {
|
||||
fileMap[baseName] = { hasColor: false, hasMono: false };
|
||||
}
|
||||
|
||||
if (isColor) {
|
||||
fileMap[baseName].hasColor = true;
|
||||
} else {
|
||||
fileMap[baseName].hasMono = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Second pass: Process files
|
||||
Object.keys(fileMap).forEach(baseName => {
|
||||
const info = fileMap[baseName];
|
||||
const shouldKeep = KEEP_LIST.includes(baseName);
|
||||
|
||||
if (!shouldKeep) {
|
||||
// Delete both versions if not in keep list
|
||||
if (info.hasColor) {
|
||||
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}-color.svg`));
|
||||
deletedCount++;
|
||||
}
|
||||
if (info.hasMono) {
|
||||
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}.svg`));
|
||||
deletedCount++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If keeping, prefer color
|
||||
if (info.hasColor) {
|
||||
// Rename color version to base version (overwrite mono if exists)
|
||||
const colorPath = path.join(ICONS_DIR, `${baseName}-color.svg`);
|
||||
const targetPath = path.join(ICONS_DIR, `${baseName}.svg`);
|
||||
|
||||
try {
|
||||
// If mono exists, it will be overwritten/replaced
|
||||
fs.renameSync(colorPath, targetPath);
|
||||
renamedCount++;
|
||||
keptCount++;
|
||||
} catch (e) {
|
||||
console.error(`Error renaming ${baseName}:`, e);
|
||||
}
|
||||
} else if (info.hasMono) {
|
||||
// Keep mono if no color version
|
||||
keptCount++;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\nCleanup complete:`);
|
||||
console.log(`- Kept: ${keptCount}`);
|
||||
console.log(`- Deleted: ${deletedCount}`);
|
||||
console.log(`- Renamed (Color -> Standard): ${renamedCount}`);
|
||||
|
||||
// Regenerate index and metadata
|
||||
require('./generate-icon-index.js');
|
||||
@@ -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.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.18.0"
|
||||
version = "3.19.0"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
@@ -91,7 +91,11 @@ webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.52"
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_UI_Shell"] }
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Globalization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
|
||||
[target.'cfg(all(target_os = "windows", target_arch = "aarch64"))'.dependencies]
|
||||
rquickjs = { version = "0.8", features = ["bindgen"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1908,25 +1908,53 @@ pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Resu
|
||||
|
||||
if let Some(provider_key) = model_provider {
|
||||
// Ensure [model_providers] table exists
|
||||
if doc.get("model_providers").is_none() {
|
||||
//
|
||||
// 用 as_table_like_mut 而非 as_table_mut:用户把配置写成 inline table
|
||||
// (`model_providers = { foo = {...} }`,TOML 合法)时 as_table_mut
|
||||
// 返回 None,会一路掉进下面的顶层 fallback——用户改的 base_url 被写到
|
||||
// 了错误层级且毫无提示。
|
||||
if doc
|
||||
.get("model_providers")
|
||||
.is_none_or(|item| item.as_table_like().is_none())
|
||||
{
|
||||
// 键存在但不是表(`model_providers = 42`)时,下面这行会把用户
|
||||
// 手写的值替换掉。旧代码在这种形状下会掉进顶层 fallback 而不动
|
||||
// 它,所以归一化必须留痕——与 mcp/codex.rs、mcp/grokbuild.rs、
|
||||
// opencode_config.rs 的同款处理保持一致。
|
||||
if doc
|
||||
.get("model_providers")
|
||||
.is_some_and(|item| !item.is_none())
|
||||
{
|
||||
log::warn!("config.toml 的 model_providers 不是表,已重置为空表");
|
||||
}
|
||||
doc["model_providers"] = toml_edit::table();
|
||||
}
|
||||
|
||||
if let Some(model_providers) = doc["model_providers"].as_table_mut() {
|
||||
if let Some(model_providers) = doc
|
||||
.get_mut("model_providers")
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
{
|
||||
// Ensure [model_providers.<provider_key>] table exists
|
||||
if !model_providers.contains_key(&provider_key) {
|
||||
model_providers[&provider_key] = toml_edit::table();
|
||||
model_providers.insert(&provider_key, toml_edit::table());
|
||||
}
|
||||
|
||||
if let Some(provider_table) = model_providers[&provider_key].as_table_mut() {
|
||||
if let Some(provider_table) = model_providers
|
||||
.get_mut(&provider_key)
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
{
|
||||
if trimmed.is_empty() {
|
||||
provider_table.remove(field);
|
||||
} else {
|
||||
provider_table[field] = toml_edit::value(trimmed);
|
||||
provider_table.insert(field, toml_edit::value(trimmed));
|
||||
}
|
||||
return Ok(doc.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"config.toml 的 [model_providers.{provider_key}] 结构异常,{field} 改写为顶层字段"
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: no model_provider or structure mismatch → top-level field
|
||||
@@ -2585,6 +2613,34 @@ model = "gpt-4"
|
||||
assert_eq!(base_url, "https://fallback.api/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_url_writes_into_inline_table_provider_section() {
|
||||
// inline table 是合法 TOML,但 as_table_mut() 对它返回 None。旧代码会因此
|
||||
// 掉进「写顶层字段」的 fallback:用户改的 base_url 落在错误层级,
|
||||
// Codex 读不到,且界面毫无提示。
|
||||
let input = r#"model_provider = "any"
|
||||
model_providers = { any = { name = "any", base_url = "https://old.api/v1", wire_api = "responses" } }
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed["model_providers"]["any"]["base_url"].as_str(),
|
||||
Some("https://new.api/v1"),
|
||||
"must update the provider section, not a top-level field"
|
||||
);
|
||||
assert!(
|
||||
parsed.get("base_url").is_none(),
|
||||
"must not leak a top-level base_url fallback"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["model_providers"]["any"]["wire_api"].as_str(),
|
||||
Some("responses"),
|
||||
"sibling fields must survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_base_url_removes_only_from_correct_section() {
|
||||
let input = r#"model_provider = "any"
|
||||
|
||||
@@ -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",
|
||||
@@ -1853,6 +1855,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",
|
||||
@@ -2246,6 +2257,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",
|
||||
|
||||
@@ -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!({
|
||||
|
||||
+16
-1
@@ -28,7 +28,6 @@ mod panic_hook;
|
||||
mod prompt;
|
||||
mod prompt_files;
|
||||
mod provider;
|
||||
mod provider_defaults;
|
||||
mod proxy;
|
||||
mod services;
|
||||
mod session_manager;
|
||||
@@ -1184,6 +1183,17 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 必须排在 auto-extract 之前:先把历史泄漏进 Gemini 共享片段的凭据
|
||||
// 清干净,否则紧接着的提取会基于被污染的 live 再写一遍。
|
||||
if let Err(e) =
|
||||
crate::services::provider::ProviderService::scrub_leaked_gemini_common_config(
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("清理 Gemini 通用配置泄漏凭据失败: {e}");
|
||||
}
|
||||
|
||||
initialize_common_config_snippets(&state);
|
||||
|
||||
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||||
@@ -1375,6 +1385,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 +1531,11 @@ pub fn run() {
|
||||
commands::get_request_detail,
|
||||
commands::get_model_pricing,
|
||||
commands::update_model_pricing,
|
||||
commands::update_model_pricing_batch,
|
||||
commands::delete_model_pricing,
|
||||
commands::get_models_dev_sync_config,
|
||||
commands::save_models_dev_sync_config,
|
||||
commands::record_models_dev_sync_result,
|
||||
commands::check_provider_limits,
|
||||
// Session usage sync
|
||||
commands::sync_session_usage,
|
||||
|
||||
+153
-22
@@ -348,6 +348,74 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||
|
||||
/// 将单个 MCP 服务器同步到 Codex live 配置
|
||||
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
|
||||
/// 把单个 MCP server 表写入 `[mcp_servers]`,并保证该键是「表」。
|
||||
///
|
||||
/// `~/.codex/config.toml` 是用户可手改的:若 `mcp_servers` 存在但不是表
|
||||
/// (如 `mcp_servers = "x"` / `[]`),仅判 `contains_key` 会跳过重建,随后的
|
||||
/// `doc["mcp_servers"][id] = …` 会触发 toml_edit 的 `IndexMut` panic
|
||||
/// (panic 发生在 Tauri command 内、跨 FFI 展开)。这里统一归一化后再插入。
|
||||
fn upsert_mcp_server_table(
|
||||
doc: &mut toml_edit::DocumentMut,
|
||||
id: &str,
|
||||
table: toml_edit::Table,
|
||||
) -> Result<(), AppError> {
|
||||
if doc
|
||||
.get_mut("mcp_servers")
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
.is_none()
|
||||
{
|
||||
// 键存在但不是表时,归一化会丢掉用户手写的那个值——必须留痕,
|
||||
// 否则用户只会看到自己的改动凭空消失。
|
||||
if doc.get("mcp_servers").is_some_and(|item| !item.is_none()) {
|
||||
log::warn!("config.toml 的 mcp_servers 不是表,已重置为空表");
|
||||
}
|
||||
doc["mcp_servers"] = toml_edit::table();
|
||||
}
|
||||
let servers = doc
|
||||
.get_mut("mcp_servers")
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
.ok_or_else(|| AppError::McpValidation("config.toml 的 mcp_servers 不是表".to_string()))?;
|
||||
servers.insert(id, toml_edit::Item::Table(table));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 `[mcp_servers]`(以及历史错误格式 `[mcp.servers]`)中删除单个 MCP server。
|
||||
///
|
||||
/// 与 `upsert_mcp_server_table` 对称地使用 `as_table_like_mut`:用户若把配置写成
|
||||
/// inline table(`mcp_servers = { foo = {...} }`,TOML 合法),`as_table_mut` 会返回
|
||||
/// None 导致删除**静默失效**——界面提示已移除,条目却还在文件里,Codex 下次启动照样
|
||||
/// 加载。这比 panic 更隐蔽,因为用户往往正是发现某个 MCP 有问题才来关它的。
|
||||
///
|
||||
/// 与写入分离成纯 doc 级函数,使守卫可脱离真实 `~/.codex/config.toml` 单测。
|
||||
fn remove_mcp_server_from_doc(doc: &mut toml_edit::DocumentMut, id: &str) {
|
||||
if let Some(item) = doc.get_mut("mcp_servers") {
|
||||
// `Item::None` 是 toml_edit 的占位形态,不是用户写下的值——对它告警是噪音。
|
||||
// 必须在取可变借用之前算出来。
|
||||
let user_authored = !item.is_none();
|
||||
match item.as_table_like_mut() {
|
||||
Some(mcp_servers) => {
|
||||
mcp_servers.remove(id);
|
||||
}
|
||||
None if user_authored => {
|
||||
log::warn!("config.toml 的 mcp_servers 不是表,无法删除服务器 '{id}'");
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
|
||||
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_like_mut()) {
|
||||
if let Some(servers) = mcp_table
|
||||
.get_mut("servers")
|
||||
.and_then(|s| s.as_table_like_mut())
|
||||
{
|
||||
if servers.remove(id).is_some() {
|
||||
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync_single_server_to_codex(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
@@ -356,7 +424,6 @@ pub fn sync_single_server_to_codex(
|
||||
if !should_sync_codex_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
use toml_edit::Item;
|
||||
|
||||
// 读取现有的 config.toml
|
||||
let config_path = crate::codex_config::get_codex_config_path();
|
||||
@@ -383,16 +450,9 @@ pub fn sync_single_server_to_codex(
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 [mcp_servers] 表存在
|
||||
if !doc.contains_key("mcp_servers") {
|
||||
doc["mcp_servers"] = toml_edit::table();
|
||||
}
|
||||
|
||||
// 将 JSON 服务器规范转换为 TOML 表
|
||||
let toml_table = json_server_to_toml_table(server_spec)?;
|
||||
|
||||
// 使用唯一正确的格式:[mcp_servers]
|
||||
doc["mcp_servers"][id] = Item::Table(toml_table);
|
||||
upsert_mcp_server_table(&mut doc, id, toml_table)?;
|
||||
|
||||
// 写回文件
|
||||
let new_text = doc.to_string();
|
||||
@@ -425,19 +485,7 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
|
||||
}
|
||||
};
|
||||
|
||||
// 从正确的位置删除:[mcp_servers]
|
||||
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
|
||||
mcp_servers.remove(id);
|
||||
}
|
||||
|
||||
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
|
||||
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
|
||||
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
|
||||
if servers.remove(id).is_some() {
|
||||
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
remove_mcp_server_from_doc(&mut doc, id);
|
||||
|
||||
// 写回文件
|
||||
let new_text = doc.to_string();
|
||||
@@ -683,6 +731,89 @@ pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn upsert_normalizes_non_table_mcp_servers_without_panicking() {
|
||||
// 用户手改过的 config.toml:mcp_servers 是字符串而不是表。
|
||||
// 修复前 `doc["mcp_servers"][id] = …` 会 panic。
|
||||
for malformed in [
|
||||
"mcp_servers = \"x\"\n",
|
||||
"mcp_servers = []\n",
|
||||
"mcp_servers = 42\n",
|
||||
] {
|
||||
let mut doc = malformed
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.expect("fixture parses");
|
||||
let table = json_server_to_toml_table(&json!({
|
||||
"type": "stdio",
|
||||
"command": "npx"
|
||||
}))
|
||||
.expect("server table");
|
||||
|
||||
upsert_mcp_server_table(&mut doc, "echo", table)
|
||||
.unwrap_or_else(|e| panic!("upsert must not fail for {malformed:?}: {e}"));
|
||||
|
||||
let servers = doc
|
||||
.get("mcp_servers")
|
||||
.and_then(|item| item.as_table_like())
|
||||
.unwrap_or_else(|| panic!("mcp_servers must be normalized to a table"));
|
||||
assert!(servers.contains_key("echo"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_existing_servers_in_a_valid_table() {
|
||||
let mut doc = "[mcp_servers.keep]\ncommand = \"keep\"\n"
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.expect("fixture parses");
|
||||
let table = json_server_to_toml_table(&json!({
|
||||
"type": "stdio",
|
||||
"command": "npx"
|
||||
}))
|
||||
.expect("server table");
|
||||
|
||||
upsert_mcp_server_table(&mut doc, "added", table).expect("upsert");
|
||||
|
||||
let servers = doc
|
||||
.get("mcp_servers")
|
||||
.and_then(|item| item.as_table_like())
|
||||
.expect("table");
|
||||
assert!(servers.contains_key("keep"), "existing server must survive");
|
||||
assert!(servers.contains_key("added"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_deletes_from_inline_table_form_too() {
|
||||
// inline table 是合法 TOML,但 as_table_mut() 对它返回 None——用它做守卫
|
||||
// 会让删除静默失效:界面说移除成功,条目却还在,Codex 下次启动照样加载。
|
||||
let mut doc = "mcp_servers = { drop = { command = \"x\" }, keep = { command = \"y\" } }\n"
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.expect("fixture parses");
|
||||
|
||||
remove_mcp_server_from_doc(&mut doc, "drop");
|
||||
|
||||
let servers = doc
|
||||
.get("mcp_servers")
|
||||
.and_then(|item| item.as_table_like())
|
||||
.expect("mcp_servers must still be table-like");
|
||||
assert!(
|
||||
!servers.contains_key("drop"),
|
||||
"removal must work on the inline-table form"
|
||||
);
|
||||
assert!(servers.contains_key("keep"), "siblings must survive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_is_a_noop_on_non_table_mcp_servers() {
|
||||
// 既不能 panic,也不能把用户手写的值悄悄抹掉
|
||||
let mut doc = "mcp_servers = 42\n"
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.expect("fixture parses");
|
||||
|
||||
remove_mcp_server_from_doc(&mut doc, "whatever");
|
||||
|
||||
assert_eq!(doc.to_string(), "mcp_servers = 42\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_headers_are_only_written_to_codex_http_headers() {
|
||||
let table = json_server_to_toml_table(&json!({
|
||||
|
||||
@@ -148,10 +148,30 @@ pub fn sync_single_server_to_grokbuild(
|
||||
AppError::McpValidation(format!("解析 Grok Build config.toml 失败: {e}"))
|
||||
})?
|
||||
};
|
||||
if !doc.contains_key("mcp_servers") {
|
||||
// 若 mcp_servers 缺失或存在但不是 table(如 `mcp_servers = "x"` / `[]`),
|
||||
// 用空 table 归一化,避免后续 `doc["mcp_servers"][id] = …` 对非 table 索引
|
||||
// 触发 toml_edit 的 IndexMut panic。用户手写的 config.toml 不可信。
|
||||
// 判定走不可变的 `as_table_like`:借可变引用只为判空,会逼着下面再 get_mut 一次。
|
||||
if doc
|
||||
.get("mcp_servers")
|
||||
.is_none_or(|item| item.as_table_like().is_none())
|
||||
{
|
||||
// 归一化会丢掉用户手写的那个非表值,必须留痕。
|
||||
if doc.get("mcp_servers").is_some_and(|item| !item.is_none()) {
|
||||
log::warn!("Grok Build config.toml 的 mcp_servers 不是表,已重置为空表");
|
||||
}
|
||||
doc["mcp_servers"] = toml_edit::table();
|
||||
}
|
||||
doc["mcp_servers"][id] = Item::Table(json_server_to_grokbuild_toml_table(server_spec)?);
|
||||
let servers = doc
|
||||
.get_mut("mcp_servers")
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
.ok_or_else(|| {
|
||||
AppError::McpValidation("Grok Build config.toml 的 mcp_servers 不是表".to_string())
|
||||
})?;
|
||||
servers.insert(
|
||||
id,
|
||||
Item::Table(json_server_to_grokbuild_toml_table(server_spec)?),
|
||||
);
|
||||
crate::config::write_text_file(&path, &doc.to_string())
|
||||
}
|
||||
|
||||
@@ -171,11 +191,21 @@ pub fn remove_server_from_grokbuild(id: &str) -> Result<(), AppError> {
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if let Some(servers) = doc
|
||||
.get_mut("mcp_servers")
|
||||
.and_then(toml_edit::Item::as_table_mut)
|
||||
{
|
||||
servers.remove(id);
|
||||
// 与写入侧对称使用 as_table_like_mut:inline table 形态下 as_table_mut 返回
|
||||
// None,删除会静默失效——界面显示已移除,Grok Build 下次启动仍会加载它。
|
||||
if let Some(item) = doc.get_mut("mcp_servers") {
|
||||
// `Item::None` 是 toml_edit 的占位形态,不是用户写下的值——对它告警是噪音。
|
||||
// 必须在取可变借用之前算出来。
|
||||
let user_authored = !item.is_none();
|
||||
match item.as_table_like_mut() {
|
||||
Some(servers) => {
|
||||
servers.remove(id);
|
||||
}
|
||||
None if user_authored => {
|
||||
log::warn!("Grok Build config.toml 的 mcp_servers 不是表,无法删除服务器 '{id}'");
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
crate::config::write_text_file(&path, &doc.to_string())
|
||||
}
|
||||
|
||||
@@ -95,12 +95,27 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
json5::from_str(&content).map_err(|e| {
|
||||
let value: Value = json5::from_str(&content).map_err(|e| {
|
||||
AppError::Config(format!(
|
||||
"Failed to parse OpenCode config: {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
})?;
|
||||
|
||||
// 根节点必须是对象:下游 set_provider / set_mcp_server / add_plugin 都对它做
|
||||
// `config["key"] = …` 索引赋值,而 serde_json 只把 Null 自动升级成对象,
|
||||
// 数组或标量会直接 panic(panic 发生在 Tauri command 内、跨 FFI 展开)。
|
||||
//
|
||||
// 这里选择报错而不是重建根节点:opencode.json 里还有 model / theme 等用户自有
|
||||
// 配置,静默重建等于删掉它们。让用户自己修文件,与 read_claude_live 的做法一致。
|
||||
if !value.is_object() {
|
||||
return Err(AppError::Config(format!(
|
||||
"OpenCode 配置文件根节点必须是 JSON 对象: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
|
||||
@@ -123,7 +138,13 @@ pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
||||
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
||||
let mut full_config = read_opencode_config()?;
|
||||
|
||||
if full_config.get("provider").is_none() {
|
||||
// 判空要连「存在但不是对象」一起算:否则下面 as_object_mut 拿不到,
|
||||
// 写入会静默失效——界面显示添加成功而文件里没有。provider 段是 cc-switch
|
||||
// 的投影区,归一化不会碰用户自有的 model / theme 等顶层配置。
|
||||
if !full_config.get("provider").is_some_and(Value::is_object) {
|
||||
if full_config.get("provider").is_some() {
|
||||
log::warn!("opencode.json 的 provider 不是对象,已重置为空对象");
|
||||
}
|
||||
full_config["provider"] = json!({});
|
||||
}
|
||||
|
||||
@@ -142,6 +163,8 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
||||
|
||||
if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
|
||||
providers.remove(id);
|
||||
} else if config.get("provider").is_some() {
|
||||
log::warn!("opencode.json 的 provider 不是对象,无法删除供应商 '{id}'");
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
@@ -182,7 +205,10 @@ pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
|
||||
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
||||
let mut full_config = read_opencode_config()?;
|
||||
|
||||
if full_config.get("mcp").is_none() {
|
||||
if !full_config.get("mcp").is_some_and(Value::is_object) {
|
||||
if full_config.get("mcp").is_some() {
|
||||
log::warn!("opencode.json 的 mcp 不是对象,已重置为空对象");
|
||||
}
|
||||
full_config["mcp"] = json!({});
|
||||
}
|
||||
|
||||
@@ -198,6 +224,8 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
||||
|
||||
if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
||||
mcp.remove(id);
|
||||
} else if config.get("mcp").is_some() {
|
||||
log::warn!("opencode.json 的 mcp 不是对象,无法删除服务器 '{id}'");
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
@@ -265,3 +293,77 @@ pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
|
||||
|
||||
write_opencode_config(&config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct TestHomeGuard(Option<std::ffi::OsString>);
|
||||
impl TestHomeGuard {
|
||||
fn set(home: &std::path::Path) -> Self {
|
||||
let guard = Self(std::env::var_os("CC_SWITCH_TEST_HOME"));
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", home);
|
||||
guard
|
||||
}
|
||||
}
|
||||
impl Drop for TestHomeGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_config(home: &std::path::Path, content: &str) {
|
||||
let dir = home.join(".config").join("opencode");
|
||||
std::fs::create_dir_all(&dir).expect("create config dir");
|
||||
std::fs::write(dir.join("opencode.json"), content).expect("write config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn read_rejects_non_object_root_instead_of_panicking_downstream() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let _guard = TestHomeGuard::set(temp.path());
|
||||
|
||||
// 顶层数组/标量会让下游 `config["provider"] = …` 触发 serde_json panic。
|
||||
// 顶层 null 例外——serde_json 会把它自动升级成对象,本来就不炸。
|
||||
for malformed in ["[]", "[{\"a\":1}]", "42", "\"oops\""] {
|
||||
write_config(temp.path(), malformed);
|
||||
let result = read_opencode_config();
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"non-object root must be rejected: {malformed}"
|
||||
);
|
||||
}
|
||||
|
||||
write_config(temp.path(), "{\"model\": \"x\"}");
|
||||
assert!(
|
||||
read_opencode_config().is_ok(),
|
||||
"a normal object config must still load"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn set_mcp_server_normalizes_non_object_section() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let _guard = TestHomeGuard::set(temp.path());
|
||||
|
||||
// `"mcp": []` 时旧代码的 as_object_mut 返回 None → 写入静默失效
|
||||
write_config(temp.path(), "{\"model\": \"keep-me\", \"mcp\": []}");
|
||||
|
||||
set_mcp_server("echo", json!({"command": "npx"})).expect("set must succeed");
|
||||
|
||||
let config = read_opencode_config().expect("reload");
|
||||
assert_eq!(
|
||||
config["mcp"]["echo"]["command"], "npx",
|
||||
"server must actually be written"
|
||||
);
|
||||
assert_eq!(
|
||||
config["model"], "keep-me",
|
||||
"unrelated user config must be preserved"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,11 +169,23 @@ impl Provider {
|
||||
let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
|
||||
(base_url, api_key)
|
||||
}
|
||||
AppType::GrokBuild => settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(crate::grok_config::extract_credentials)
|
||||
.unwrap_or_default(),
|
||||
// GrokBuild 的 base_url 与 api_key 必须各自解析:extract_credentials 在
|
||||
// 凭据缺失时整个 Option 变 None,一并 unwrap_or_default 会把明明写在
|
||||
// 配置里的 base_url 也清成空串。凭据缺失是常态(env_key 指向的变量在
|
||||
// GUI 进程里读不到),端点不该被连坐——否则用量脚本的 {{baseUrl}} 变成
|
||||
// 相对路径、余额查询只报「API key is empty」掩盖真因。
|
||||
// 与上面 Codex 分支的写法保持一致。
|
||||
AppType::GrokBuild => {
|
||||
let config_text = settings.get("config").and_then(Value::as_str);
|
||||
let base_url = config_text
|
||||
.and_then(crate::grok_config::extract_base_url)
|
||||
.unwrap_or_default();
|
||||
let api_key = config_text
|
||||
.and_then(crate::grok_config::extract_credentials)
|
||||
.map(|(_, api_key)| api_key)
|
||||
.unwrap_or_default();
|
||||
(base_url, api_key)
|
||||
}
|
||||
// Hermes (config.yaml) flattens credentials at the top level, snake_case.
|
||||
AppType::Hermes => (
|
||||
str_at(settings.get("base_url")),
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 供应商图标信息
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ProviderIcon {
|
||||
pub name: &'static str,
|
||||
pub color: &'static str,
|
||||
}
|
||||
|
||||
/// 供应商名称到图标的默认映射
|
||||
#[allow(dead_code)]
|
||||
pub static DEFAULT_PROVIDER_ICONS: Lazy<HashMap<&'static str, ProviderIcon>> = Lazy::new(|| {
|
||||
let mut m = HashMap::new();
|
||||
|
||||
// AI 服务商
|
||||
m.insert(
|
||||
"openai",
|
||||
ProviderIcon {
|
||||
name: "openai",
|
||||
color: "#00A67E",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"anthropic",
|
||||
ProviderIcon {
|
||||
name: "anthropic",
|
||||
color: "#D4915D",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"claude",
|
||||
ProviderIcon {
|
||||
name: "claude",
|
||||
color: "#D4915D",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"google",
|
||||
ProviderIcon {
|
||||
name: "google",
|
||||
color: "#4285F4",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"gemini",
|
||||
ProviderIcon {
|
||||
name: "gemini",
|
||||
color: "#4285F4",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"deepseek",
|
||||
ProviderIcon {
|
||||
name: "deepseek",
|
||||
color: "#1E88E5",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"kimi",
|
||||
ProviderIcon {
|
||||
name: "kimi",
|
||||
color: "#6366F1",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"moonshot",
|
||||
ProviderIcon {
|
||||
name: "moonshot",
|
||||
color: "#6366F1",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"zhipu",
|
||||
ProviderIcon {
|
||||
name: "zhipu",
|
||||
color: "#0F62FE",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"minimax",
|
||||
ProviderIcon {
|
||||
name: "minimax",
|
||||
color: "#FF6B6B",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"baidu",
|
||||
ProviderIcon {
|
||||
name: "baidu",
|
||||
color: "#2932E1",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"alibaba",
|
||||
ProviderIcon {
|
||||
name: "alibaba",
|
||||
color: "#FF6A00",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"tencent",
|
||||
ProviderIcon {
|
||||
name: "tencent",
|
||||
color: "#00A4FF",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"meta",
|
||||
ProviderIcon {
|
||||
name: "meta",
|
||||
color: "#0081FB",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"microsoft",
|
||||
ProviderIcon {
|
||||
name: "microsoft",
|
||||
color: "#00A4EF",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"cohere",
|
||||
ProviderIcon {
|
||||
name: "cohere",
|
||||
color: "#39594D",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"perplexity",
|
||||
ProviderIcon {
|
||||
name: "perplexity",
|
||||
color: "#20808D",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"mistral",
|
||||
ProviderIcon {
|
||||
name: "mistral",
|
||||
color: "#FF7000",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"huggingface",
|
||||
ProviderIcon {
|
||||
name: "huggingface",
|
||||
color: "#FFD21E",
|
||||
},
|
||||
);
|
||||
|
||||
// 云平台
|
||||
m.insert(
|
||||
"aws",
|
||||
ProviderIcon {
|
||||
name: "aws",
|
||||
color: "#FF9900",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"azure",
|
||||
ProviderIcon {
|
||||
name: "azure",
|
||||
color: "#0078D4",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"huawei",
|
||||
ProviderIcon {
|
||||
name: "huawei",
|
||||
color: "#FF0000",
|
||||
},
|
||||
);
|
||||
m.insert(
|
||||
"cloudflare",
|
||||
ProviderIcon {
|
||||
name: "cloudflare",
|
||||
color: "#F38020",
|
||||
},
|
||||
);
|
||||
|
||||
m
|
||||
});
|
||||
|
||||
/// 根据供应商名称智能推断图标
|
||||
#[allow(dead_code)]
|
||||
pub fn infer_provider_icon(provider_name: &str) -> Option<ProviderIcon> {
|
||||
let name_lower = provider_name.to_lowercase();
|
||||
|
||||
// 精确匹配
|
||||
if let Some(icon) = DEFAULT_PROVIDER_ICONS.get(name_lower.as_str()) {
|
||||
return Some(icon.clone());
|
||||
}
|
||||
|
||||
// 模糊匹配(包含关键词)
|
||||
for (key, icon) in DEFAULT_PROVIDER_ICONS.iter() {
|
||||
if name_lower.contains(key) {
|
||||
return Some(icon.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_exact_match() {
|
||||
let icon = infer_provider_icon("openai");
|
||||
assert!(icon.is_some());
|
||||
let icon = icon.unwrap();
|
||||
assert_eq!(icon.name, "openai");
|
||||
assert_eq!(icon.color, "#00A67E");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match() {
|
||||
let icon = infer_provider_icon("OpenAI Official");
|
||||
assert!(icon.is_some());
|
||||
let icon = icon.unwrap();
|
||||
assert_eq!(icon.name, "openai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive() {
|
||||
let icon = infer_provider_icon("ANTHROPIC");
|
||||
assert!(icon.is_some());
|
||||
assert_eq!(icon.unwrap().name, "anthropic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_match() {
|
||||
let icon = infer_provider_icon("unknown provider");
|
||||
assert!(icon.is_none());
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
@@ -3029,20 +3492,39 @@ impl ProviderService {
|
||||
/// `OPENROUTER_API_KEY` / `GOOGLE_API_KEY` 等回退)、各类 `*_AUTH_TOKEN` /
|
||||
/// 单数 `*_TOKEN`、AWS Bedrock / Vertex 凭据、以及通用 secret / password /
|
||||
/// 私钥命名。
|
||||
fn is_sensitive_config_key(name: &str) -> bool {
|
||||
pub(crate) fn is_sensitive_config_key(name: &str) -> bool {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
|
||||
// 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但**不**误伤复数 `_TOKENS`
|
||||
// (CLAUDE_CODE_MAX_OUTPUT_TOKENS / MAX_THINKING_TOKENS 是正常可共享配置)。
|
||||
const SENSITIVE_SUFFIXES: &[&str] = &[
|
||||
// 裸 `_KEY` 是最常见的凭据写法(OPENAI_KEY / GROQ_KEY / XAI_KEY…),
|
||||
// 必须单列:只枚举 `_API_KEY` / `_ACCESS_KEY` 这些子类,等于把最普通
|
||||
// 的那一种漏在外面。下面几条 `_*_KEY` 被它蕴含,保留是为了说明覆盖面。
|
||||
"_KEY",
|
||||
"_API_KEY",
|
||||
"_APIKEY",
|
||||
"_AUTH_TOKEN",
|
||||
"_TOKEN",
|
||||
"_ACCESS_KEY",
|
||||
"_ACCESS_KEY_ID",
|
||||
"_KEY_ID",
|
||||
"_PRIVATE_KEY",
|
||||
// 不带分隔符的复合写法各走各的后缀:`_KEY` 够不着 `..._APIKEY`
|
||||
// (倒数第四个字符是 I 不是下划线)。VOLC_ACCESSKEY 是火山引擎文档
|
||||
// 里的正式变量名,本仓库就实现了火山 AK/SK 用量查询。
|
||||
"_APIKEY",
|
||||
"_ACCESSKEY",
|
||||
"_SECRETKEY",
|
||||
"_APITOKEN",
|
||||
"_AUTH_TOKEN",
|
||||
"_TOKEN",
|
||||
// GITHUB_PAT / GITLAB_PAT 等 personal access token 的惯用写法,
|
||||
// 既不含 TOKEN 也不含 KEY,前面每一条规则都够不着。
|
||||
"_PAT",
|
||||
// 口令类的常见缩写。`_PASS` 不会误伤 `*_BYPASS`(那个以 `_BYPASS`
|
||||
// 结尾),`_PWD` 也不会误伤 shell 的 PWD / OLDPWD。
|
||||
"_PWD",
|
||||
"_PASS",
|
||||
"_PASSPHRASE",
|
||||
"_CREDS",
|
||||
];
|
||||
const SENSITIVE_EXACT: &[&str] = &[
|
||||
"APIKEY",
|
||||
@@ -3242,7 +3724,13 @@ impl ProviderService {
|
||||
let mut snippet = serde_json::Map::new();
|
||||
if let Some(env) = env {
|
||||
for (key, value) in env {
|
||||
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
|
||||
// 端点按名剥离(它不是凭据,模式匹配够不着);凭据全部交给
|
||||
// `is_sensitive_config_key` 统一模式匹配(与 Claude 提取器一致)。
|
||||
// 只列固定名单会漏掉下一个 `*_API_KEY` —— 例如 `GOOGLE_API_KEY`
|
||||
// (provider.rs 认可的一等 Gemini 凭据),而共享片段会被 deep-merge
|
||||
// 回其它 Gemini 供应商,漏剥即等于把 A 账号的密钥写进 B 供应商并
|
||||
// 发往 B 的 base_url。`GEMINI_API_KEY` 不必单列:`_KEY` 后缀已覆盖。
|
||||
if key == "GOOGLE_GEMINI_BASE_URL" || Self::is_sensitive_config_key(key) {
|
||||
continue;
|
||||
}
|
||||
let Value::String(v) = value else {
|
||||
@@ -3263,6 +3751,221 @@ impl ProviderService {
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// 一次性清理:把历史泄漏进 Gemini 共享片段的凭据从所有存储位置抹掉。
|
||||
///
|
||||
/// 背景:`extract_gemini_common_config` 曾只剥离两个固定键名,`GOOGLE_API_KEY`
|
||||
/// 等一等凭据会进入共享片段,再被 `apply_common_config_to_settings` 深合并进
|
||||
/// **其它** Gemini 供应商的 env,随请求发往对方的 base_url。
|
||||
///
|
||||
/// 光修提取器不够:Gemini 的片段一旦生成就**永不自动重提取**(启动期
|
||||
/// auto-extract 与导入后补提取都要求 `snippet.is_none()`,切换时的回写又只对
|
||||
/// Claude / Codex 生效),所以存量片段会一直带着密钥继续注入。
|
||||
///
|
||||
/// 两个关键约束:
|
||||
///
|
||||
/// 1. **不能只清片段**。合并与剥离是一对靠「值相等」严格抵消的操作:切走供应商时
|
||||
/// `remove_common_config_from_settings` 依据片段内容把注入的键删掉。片段里一旦
|
||||
/// 没了这个键,backfill 就会把 live 中残留的密钥原样写进受害供应商的
|
||||
/// `settings_config`——泄漏从瞬时污染变成永久污染。所以片段、各供应商配置、
|
||||
/// live 文件必须一起清。
|
||||
/// 2. **按值相等定向删除,不按键名一刀切**。复用 `remove_common_config_from_settings`
|
||||
/// 可以只清掉扩散出去的那一份,保留某个供应商自己写的、值不同的同名键。
|
||||
///
|
||||
/// 步骤顺序本身是安全属性的一部分:**清片段必须排在最后**。片段是
|
||||
/// `remove_common_config_from_settings` 唯一的"该剥哪些键"来源,一旦清空,任何
|
||||
/// 残留(live 文件里的、下一轮重试要处理的)都再也无法被识别和剥离。所以所有
|
||||
/// 可能失败的步骤都排在它前面,失败即带错返回,让下次启动能原样重来。
|
||||
///
|
||||
/// 清理后部分供应商会显示缺少 API Key,需用户重填——这是正确行为:那把密钥本就
|
||||
/// 不属于它们。(受害者原有的同名键在合并时已被覆盖,无法恢复。)动手前会往
|
||||
/// settings 的 `gemini_common_config_scrub_audit_v1` 写一条审计记录,内容是
|
||||
/// **键名与受影响的供应商 id,不含值**:`settings` 会随 WebDAV/S3 同步上传,
|
||||
/// 而这里处理的正是必须销毁的凭据,留值等于把一次清除换成一份跨设备扩散、
|
||||
/// 没有界面入口、永不过期的明文副本。
|
||||
pub async fn scrub_leaked_gemini_common_config(state: &AppState) -> Result<(), AppError> {
|
||||
const FLAG: &str = "gemini_common_config_credentials_scrubbed_v1";
|
||||
const AUDIT_KEY: &str = "gemini_common_config_scrub_audit_v1";
|
||||
let app = AppType::Gemini;
|
||||
|
||||
if state.db.get_bool_flag(FLAG).unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(snippet_text) = state.db.get_config_snippet(app.as_str())? else {
|
||||
state.db.set_setting(FLAG, "true")?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// 片段解析不了就不动它,只标记完成——乱改用户数据比留着更糟
|
||||
let Ok(Value::Object(entries)) = serde_json::from_str::<Value>(&snippet_text) else {
|
||||
state.db.set_setting(FLAG, "true")?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut poison = serde_json::Map::new();
|
||||
let mut clean = serde_json::Map::new();
|
||||
for (key, value) in entries {
|
||||
if Self::is_sensitive_config_key(&key) {
|
||||
poison.insert(key, value);
|
||||
} else {
|
||||
clean.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if poison.is_empty() {
|
||||
state.db.set_setting(FLAG, "true")?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"检测到 {} 个凭据键残留在 Gemini 通用配置片段中,开始一次性清理",
|
||||
poison.len()
|
||||
);
|
||||
|
||||
let poison_keys: Vec<String> = poison.keys().cloned().collect();
|
||||
let poison_value = Value::Object(poison);
|
||||
let poison_text = serde_json::to_string(&poison_value)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
|
||||
|
||||
// 1) 先算出各供应商清理后的配置,但**先不落库**
|
||||
let providers = state.db.get_all_providers(app.as_str())?;
|
||||
let mut pending: Vec<(String, Provider, Value)> = Vec::new();
|
||||
for (id, provider) in providers {
|
||||
let cleaned = match live::remove_common_config_from_settings(
|
||||
&app,
|
||||
&provider.settings_config,
|
||||
&poison_text,
|
||||
) {
|
||||
Ok(cleaned) => cleaned,
|
||||
Err(err) => {
|
||||
log::warn!("清理供应商 '{id}' 的泄漏凭据失败: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if cleaned != provider.settings_config {
|
||||
pending.push((id, provider, cleaned));
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 落库前留一份审计记录:**只记键名与受影响的供应商,不记值**。
|
||||
//
|
||||
// 「按值相等定向删除」在一种合法场景下也会命中:用户有意在多个供应商里
|
||||
// 复用同一把 key。所以必须留下"删了什么、从哪删的",否则用户只能靠翻
|
||||
// 日志。但不能留值——`settings` 表不在 `SYNC_SKIP_TABLES` 里,会随
|
||||
// WebDAV/S3 同步上传,而这里处理的恰恰是必须销毁的泄漏凭据:留值等于
|
||||
// 把一次清除换成一份没有界面入口、永不过期、还会跨设备扩散的明文副本。
|
||||
// 密钥本来就该轮换,可恢复性不值这个代价。
|
||||
let removed_env_keys = |before: &Value, after: &Value| -> Vec<String> {
|
||||
let before_env = before.get("env").and_then(Value::as_object);
|
||||
let after_env = after.get("env").and_then(Value::as_object);
|
||||
match (before_env, after_env) {
|
||||
(Some(before_env), Some(after_env)) => before_env
|
||||
.keys()
|
||||
.filter(|key| !after_env.contains_key(*key))
|
||||
.cloned()
|
||||
.collect(),
|
||||
(Some(before_env), None) => before_env.keys().cloned().collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
let audit = serde_json::json!({
|
||||
"removedFromSnippet": poison_keys,
|
||||
"providers": pending
|
||||
.iter()
|
||||
.map(|(id, provider, cleaned)| serde_json::json!({
|
||||
"id": id,
|
||||
"removedKeys": removed_env_keys(&provider.settings_config, cleaned),
|
||||
}))
|
||||
.collect::<Vec<_>>(),
|
||||
});
|
||||
let audit_text = serde_json::to_string(&audit)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
|
||||
// 只在没有记录时写。provider 的写入不是一个事务(每次 save_provider 各自
|
||||
// 提交),上一轮可能改到一半就中止;此时完成标记没置位,下次启动会重跑,
|
||||
// 而重跑看到的"原始状态"已经残缺。无条件 INSERT OR REPLACE 会拿这份残缺
|
||||
// 记录盖掉第一轮那份完整的。
|
||||
if state.db.get_setting(AUDIT_KEY)?.is_none() {
|
||||
state.db.set_setting(AUDIT_KEY, &audit_text)?;
|
||||
}
|
||||
|
||||
// 3) 各供应商 settings_config:按值相等定向删除扩散出去的副本
|
||||
for (id, provider, cleaned) in pending {
|
||||
let mut updated = provider;
|
||||
updated.settings_config = cleaned;
|
||||
state.db.save_provider(app.as_str(), &updated)?;
|
||||
log::info!("已从 Gemini 供应商 '{id}' 中清除泄漏的共享凭据");
|
||||
}
|
||||
|
||||
// 4) 代理接管中的 live 快照里也可能有一份副本。这一步的失败**必须传播**:
|
||||
//
|
||||
// 关代理时 `restore_live_config_for_app_with_fallback_inner`(proxy.rs:869)
|
||||
// 会把这份快照原样写回 `~/.gemini/.env`。若它仍带毒而我们照样清了片段、置了
|
||||
// 完成标记,那么代理一停凭据就当场复活,而一次性标记又保证不会再清第二次;
|
||||
// 此后片段里已没有这个键,下一次切换的 backfill 就把它永久写进受害供应商的
|
||||
// 配置——还是本函数开头那个顺序陷阱,只是换了扇门进来。
|
||||
//
|
||||
// 带错返回是安全的失败方式:调用方(lib.rs:1189)只记 warn 不中断启动,
|
||||
// 片段和标记都原样留着,下次启动照原样重来。
|
||||
if let Some(backup) = state.db.get_live_backup(app.as_str()).await? {
|
||||
let original: Value = serde_json::from_str(&backup.original_config)
|
||||
.map_err(|e| AppError::Message(format!("解析 Gemini 代理接管备份失败: {e}")))?;
|
||||
let cleaned = live::remove_common_config_from_settings(&app, &original, &poison_text)?;
|
||||
if cleaned != original {
|
||||
let text = serde_json::to_string(&cleaned)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
|
||||
state.db.save_live_backup(app.as_str(), &text).await?;
|
||||
log::info!("已从 Gemini 代理接管备份中清除泄漏的共享凭据");
|
||||
}
|
||||
}
|
||||
|
||||
// 5) `~/.gemini/.env`:**定向**删除,且必须在清片段之前做,失败即中止。
|
||||
//
|
||||
// 为什么不用 `sync_current_provider_for_app` 重投影:它在没有当前供应商
|
||||
// 时直接返回 Ok 而根本不写文件,泄漏值会原样留在 live 里;等片段被清空
|
||||
// 之后,下次切换时 `remove_common_config_from_settings` 再也认不出这个
|
||||
// 键,backfill 就把它永久写进受害供应商的配置——正是本函数开头说的那个
|
||||
// 顺序陷阱,只是由"没修"变成"修了一半更糟"。定向删除还顺带保住了只存在
|
||||
// 于 live、与供应商无关的手工 env(重投影会把它们抹掉)。
|
||||
//
|
||||
// 删除走 `remove_gemini_env_entries` 的**保序**实现而不是 read→HashMap→
|
||||
// write 往返:后者会顺手抹掉注释、空行和无法识别的行,并按键名重排整个
|
||||
// 文件。全量投影时那无所谓,但这里是一次用户没主动触发的启动期清理,不该
|
||||
// 连带改写与泄漏无关的内容。
|
||||
//
|
||||
// 失败就带着错误返回:片段此刻还留着毒键,完成标记也没置位,下次启动能
|
||||
// 照原样重来。清片段是不可逆的一步,必须排在所有会失败的步骤之后。
|
||||
let poison_env: HashMap<String, String> = poison_value
|
||||
.as_object()
|
||||
.map(|map| {
|
||||
map.iter()
|
||||
.filter_map(|(key, value)| {
|
||||
value.as_str().map(|text| (key.clone(), text.to_string()))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if crate::gemini_config::remove_gemini_env_entries(&poison_env)? {
|
||||
log::info!("已从 ~/.gemini/.env 中清除泄漏的共享凭据");
|
||||
}
|
||||
|
||||
// 6) 片段本身:保留可共享的部分。全部清空时删行而不是写 "{}"——留着空行会让
|
||||
// should_auto_extract_config_snippet 永远为 false,用户的合法共享配置再也
|
||||
// 重建不回来。同理绝不置 cleared 标记。
|
||||
if clean.is_empty() {
|
||||
state.db.set_config_snippet(app.as_str(), None)?;
|
||||
} else {
|
||||
let cleaned_snippet = serde_json::to_string_pretty(&Value::Object(clean))
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(app.as_str(), Some(cleaned_snippet))?;
|
||||
}
|
||||
|
||||
state.db.set_setting(FLAG, "true")?;
|
||||
log::info!("Gemini 通用配置凭据清理完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract common config for OpenCode (JSON format)
|
||||
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
// OpenCode uses a different config structure with npm, options, models
|
||||
|
||||
@@ -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.0",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
+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 && (
|
||||
|
||||
@@ -37,6 +37,8 @@ export const TIER_I18N_KEYS: Record<string, string> = {
|
||||
weekly_limit: "subscription.sevenDay",
|
||||
// 火山方舟 Agent Plan / Coding Plan 的月窗口
|
||||
monthly: "subscription.monthly",
|
||||
// Grok credit 额度的兜底窗口(重置距离可识别时归入 weekly_limit/monthly)
|
||||
credits: "subscription.credits",
|
||||
// GitHub Copilot
|
||||
premium: "subscription.copilotPremium",
|
||||
};
|
||||
@@ -422,7 +424,8 @@ const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
|
||||
quota={quota}
|
||||
loading={loading}
|
||||
refetch={refetch}
|
||||
appIdForExpiredHint={appId}
|
||||
// expiredHint 里的 {tool} 是 CLI 命令名:Grok 的命令是 `grok` 而非 appId
|
||||
appIdForExpiredHint={appId === "grokbuild" ? "grok" : appId}
|
||||
inline={inline}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -160,7 +160,7 @@ function detectBalanceProvider(baseUrl: string | undefined): boolean {
|
||||
}
|
||||
|
||||
function isOfficialSubscriptionProvider(provider: Provider, appId: AppId) {
|
||||
if (!["claude", "codex", "gemini"].includes(appId)) return false;
|
||||
if (!["claude", "codex", "gemini", "grokbuild"].includes(appId)) return false;
|
||||
if (provider.category === "official") return true;
|
||||
|
||||
const config = provider.settingsConfig as Record<string, any>;
|
||||
@@ -188,6 +188,12 @@ function isOfficialSubscriptionProvider(provider: Provider, appId: AppId) {
|
||||
(!baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""))
|
||||
);
|
||||
}
|
||||
// grokbuild 不做配置启发式,只认上方的 category === "official":官方态判定
|
||||
// 在后端是 TOML 解析(grok_config::is_official_live_config),正则无法忠实
|
||||
// 镜像(引号键/inline table/非法 TOML 均会误判为官方),误判会让本组件的
|
||||
// state 初始化丢弃已保存的非官方脚本。claude/codex/gemini 的启发式建立在
|
||||
// 已解析的 JSON 字段上是精确的,不受此限。官方判定以 category 为 SSOT 的
|
||||
// 理由见 ProviderCard 中的注释。
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import type { ProviderMeta } from "@/types";
|
||||
import { useXaiOauthQuota } from "@/lib/query/subscription";
|
||||
import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter";
|
||||
|
||||
interface XaiOauthQuotaFooterProps {
|
||||
meta?: ProviderMeta;
|
||||
inline?: boolean;
|
||||
/** 是否为当前激活的供应商 */
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* xAI OAuth (SuperGrok 反代) 订阅额度 footer
|
||||
*
|
||||
* 复用 SubscriptionQuotaView 的全部渲染逻辑(5 状态 × inline/expanded)。
|
||||
* 数据源为 cc-switch 自管的 xAI OAuth token,与 Grok Build 分区读 Grok CLI
|
||||
* 凭据的路径查同一个 grok.com 账单端点,展示同一份订阅额度。
|
||||
*/
|
||||
const XaiOauthQuotaFooter: React.FC<XaiOauthQuotaFooterProps> = ({
|
||||
meta,
|
||||
inline = false,
|
||||
isCurrent = false,
|
||||
}) => {
|
||||
const {
|
||||
data: quota,
|
||||
isFetching: loading,
|
||||
refetch,
|
||||
} = useXaiOauthQuota(meta, { enabled: true, autoQuery: isCurrent });
|
||||
|
||||
return (
|
||||
<SubscriptionQuotaView
|
||||
quota={quota}
|
||||
loading={loading}
|
||||
refetch={refetch}
|
||||
appIdForExpiredHint="xai_oauth"
|
||||
inline={inline}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default XaiOauthQuotaFooter;
|
||||
@@ -2,6 +2,14 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeepLinkImportRequest } from "../../lib/api/deeplink";
|
||||
import { decodeBase64Utf8 } from "../../lib/utils/base64";
|
||||
import {
|
||||
classifyCommand,
|
||||
classifyEndpoint,
|
||||
classifyEnvKey,
|
||||
maskValue,
|
||||
riskI18nKey,
|
||||
type RiskKind,
|
||||
} from "@/utils/deeplinkRisk";
|
||||
|
||||
export function McpConfirmation({
|
||||
request,
|
||||
@@ -25,6 +33,47 @@ export function McpConfirmation({
|
||||
const targetApps = request.apps?.split(",") || [];
|
||||
const serverCount = Object.keys(mcpServers || {}).length;
|
||||
|
||||
// 汇总所有条目命中的风险,在底部给一句总的提示——逐行的 ⚠ 容易被划过去。
|
||||
const risks = useMemo(() => {
|
||||
const found = new Set<RiskKind>();
|
||||
for (const spec of Object.values(mcpServers || {}) as any[]) {
|
||||
const commandRisk = classifyCommand(spec?.command, spec?.args);
|
||||
if (commandRisk) found.add(commandRisk);
|
||||
if (typeof spec?.url === "string") {
|
||||
const urlRisk = classifyEndpoint(spec.url);
|
||||
if (urlRisk) found.add(urlRisk);
|
||||
}
|
||||
for (const key of Object.keys(spec?.env || {})) {
|
||||
const envRisk = classifyEnvKey(key);
|
||||
if (envRisk) found.add(envRisk);
|
||||
}
|
||||
}
|
||||
return [...found];
|
||||
}, [mcpServers]);
|
||||
|
||||
/** 一行 key/value。`break-all` 而非 `truncate`:payload 不得被 CSS 藏起来。 */
|
||||
const Row = ({
|
||||
label,
|
||||
value,
|
||||
risk,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
risk?: RiskKind | null;
|
||||
}) => (
|
||||
<div className="grid grid-cols-[4rem_1fr] gap-2 text-xs">
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span
|
||||
className={`font-mono break-all ${
|
||||
risk ? "text-yellow-700 dark:text-yellow-500 font-semibold" : ""
|
||||
}`}
|
||||
>
|
||||
{risk && <span aria-hidden="true">⚠ </span>}
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("deeplink.mcp.title")}</h3>
|
||||
@@ -51,25 +100,83 @@ export function McpConfirmation({
|
||||
</label>
|
||||
<div className="mt-1 space-y-2 max-h-64 overflow-auto border rounded p-2 bg-muted/30">
|
||||
{mcpServers &&
|
||||
Object.entries(mcpServers).map(([id, spec]: [string, any]) => (
|
||||
<div key={id} className="p-2 bg-background rounded border">
|
||||
<div className="font-semibold text-sm">{id}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 font-mono truncate">
|
||||
{spec.command
|
||||
? `Command: ${spec.command} `
|
||||
: `URL: ${spec.url} `}
|
||||
Object.entries(mcpServers).map(([id, spec]: [string, any]) => {
|
||||
const commandRisk = classifyCommand(spec?.command, spec?.args);
|
||||
const argv: string[] = Array.isArray(spec?.args)
|
||||
? spec.args.map(String)
|
||||
: [];
|
||||
const env: Record<string, unknown> = spec?.env || {};
|
||||
|
||||
return (
|
||||
<div key={id} className="p-2 bg-background rounded border">
|
||||
<div className="font-semibold text-sm mb-1">{id}</div>
|
||||
<div className="space-y-1">
|
||||
{spec?.command && (
|
||||
<Row
|
||||
label={t("deeplink.mcp.command")}
|
||||
value={String(spec.command)}
|
||||
risk={commandRisk}
|
||||
/>
|
||||
)}
|
||||
{/* 逐项展开而不是 join(" "):payload 常常整条藏在某一个 arg 里,
|
||||
拼成一行再 truncate 正是它此前得以隐身的原因。 */}
|
||||
{argv.map((arg, index) => (
|
||||
<Row
|
||||
key={index}
|
||||
label={index === 0 ? t("deeplink.mcp.args") : ""}
|
||||
value={arg}
|
||||
risk={commandRisk}
|
||||
/>
|
||||
))}
|
||||
{spec?.url && (
|
||||
<Row
|
||||
label={t("deeplink.mcp.url")}
|
||||
value={String(spec.url)}
|
||||
risk={classifyEndpoint(String(spec.url))}
|
||||
/>
|
||||
)}
|
||||
{Object.entries(env).map(([key, value], index) => (
|
||||
<Row
|
||||
key={key}
|
||||
label={index === 0 ? t("deeplink.mcp.env") : ""}
|
||||
value={`${key}=${maskValue(key, String(value))}`}
|
||||
risk={classifyEnvKey(key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{request.enabled && (
|
||||
<div className="text-yellow-600 dark:text-yellow-500 text-sm flex items-center gap-2">
|
||||
<span>⚠️</span>
|
||||
<span>{t("deeplink.mcp.enabledWarning")}</span>
|
||||
{risks.length > 0 && (
|
||||
<div className="rounded border border-yellow-500/40 bg-yellow-500/10 p-2 space-y-1">
|
||||
{risks.map((kind) => (
|
||||
<div
|
||||
key={kind}
|
||||
className="text-yellow-700 dark:text-yellow-500 text-sm flex items-start gap-2"
|
||||
>
|
||||
<span aria-hidden="true">⚠️</span>
|
||||
<span>{t(riskI18nKey(kind))}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
无条件显示,不看 `request.enabled`。
|
||||
MCP 导入路径**根本不读这个字段**——`deeplink/mcp.rs` 里全文没有它,
|
||||
而 `:196` 是无条件的 `merged.set_enabled_for(&app, true)`。
|
||||
(prompt.rs / skill.rs / provider.rs 各自读了 `request.enabled`,唯独 MCP 没有,
|
||||
所以很容易误以为这里也生效。)
|
||||
挂条件的后果是:恶意链接省略 `enabled` 就能让这条警告消失,而写入行为
|
||||
一模一样——把提示变成了可被攻击者关掉的开关。
|
||||
*/}
|
||||
<div className="text-yellow-600 dark:text-yellow-500 text-sm flex items-center gap-2">
|
||||
<span>⚠️</span>
|
||||
<span>{t("deeplink.mcp.enabledWarning")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { SVGProps } from "react";
|
||||
|
||||
export function ITermIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>iTerm2</title>
|
||||
<path d="M24 5.359v13.282A5.36 5.36 0 0 1 18.641 24H5.359A5.36 5.36 0 0 1 0 18.641V5.359A5.36 5.36 0 0 1 5.359 0h13.282A5.36 5.36 0 0 1 24 5.359m-.932-.233A4.196 4.196 0 0 0 18.874.932H5.126A4.196 4.196 0 0 0 .932 5.126v13.748a4.196 4.196 0 0 0 4.194 4.194h13.748a4.196 4.196 0 0 0 4.194-4.194zm-.816.233v13.282a3.613 3.613 0 0 1-3.611 3.611H5.359a3.613 3.613 0 0 1-3.611-3.611V5.359a3.613 3.613 0 0 1 3.611-3.611h13.282a3.613 3.613 0 0 1 3.611 3.611M8.854 4.194v6.495h.962V4.194zM5.483 9.493v1.085h.597V9.48q.283-.037.508-.133.373-.165.575-.448.208-.284.208-.649a.9.9 0 0 0-.171-.568 1.4 1.4 0 0 0-.426-.388 3 3 0 0 0-.544-.261 32 32 0 0 0-.545-.209 1.8 1.8 0 0 1-.426-.216q-.164-.12-.164-.284 0-.223.179-.351.18-.126.485-.127.344 0 .575.105.239.105.5.298l.433-.5a2.3 2.3 0 0 0-.605-.433 1.6 1.6 0 0 0-.582-.159v-.968h-.597v.978a2 2 0 0 0-.477.127 1.2 1.2 0 0 0-.545.411q-.194.268-.194.634 0 .335.164.56.164.224.418.38a4 4 0 0 0 .552.262q.291.104.545.209.261.104.425.238a.39.39 0 0 1 .165.321q0 .225-.187.359-.18.134-.537.134-.381 0-.717-.134a4.4 4.4 0 0 1-.649-.351l-.388.589q.209.173.477.306.276.135.575.217.191.046.373.064" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlacrittyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Alacritty</title>
|
||||
<path d="m10.065 0-8.57 21.269h3.595l6.91-16.244 6.91 16.244h3.594l-8.57-21.269zm1.935 9.935c-0.76666 1.8547-1.5334 3.7094-2.298 5.565 1.475 4.54 1.475 4.54 2.298 8.5 0.823-3.96 0.823-3.96 2.297-8.5-0.76637-1.8547-1.5315-3.7099-2.297-5.565z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WezTermIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>WezTerm</title>
|
||||
<path d="M3.27 8.524c0-.623.62-1.007 2.123-1.007l-.5 2.757c-.931-.623-1.624-1.199-1.624-1.75zm4.008 6.807c0 .647-.644 1.079-2.123 1.15l.524-2.924c.931.624 1.6 1.175 1.6 1.774zm-2.625 5.992.454-2.708c3.603-.336 5.01-1.798 5.01-3.404 0-1.653-2.004-2.948-3.841-4.074l.668-3.548c.764.072 1.67.216 2.744.432l.31-2.469c-.81-.12-1.575-.168-2.29-.216L8.257 2.7l-2.363-.024-.453 2.684C1.838 5.648.43 7.158.43 8.764c0 1.63 2.004 2.876 3.841 3.954l-.668 3.716c-.859-.048-1.908-.192-3.125-.408L0 18.495c1.026.12 1.98.192 2.84.216l-.525 2.588zm15.553-1.894h2.673c.334-2.804.81-8.46 1.121-14.86h-2.553c-.071 1.51-.334 10.498-.43 11.241h-.071c-.644-2.42-1.169-4.386-1.813-6.782h-1.456c-.62 2.396-1.05 4.194-1.694 6.782h-.096c-.071-.743-.477-9.73-.525-11.24h-2.648c.31 6.399.763 12.055 1.097 14.86h2.625l1.838-7.12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GhosttyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Ghostty</title>
|
||||
<path d="M12 0C6.7 0 2.4 4.3 2.4 9.6v11.146c0 1.772 1.45 3.267 3.222 3.254a3.18 3.18 0 0 0 1.955-.686 1.96 1.96 0 0 1 2.444 0 3.18 3.18 0 0 0 1.976.686c.75 0 1.436-.257 1.98-.686.715-.563 1.71-.587 2.419-.018.59.476 1.355.743 2.182.699 1.705-.094 3.022-1.537 3.022-3.244V9.601C21.6 4.3 17.302 0 12 0M6.069 6.562a1 1 0 0 1 .46.131l3.578 2.065v.002a.974.974 0 0 1 0 1.687L6.53 12.512a.975.975 0 0 1-.976-1.687L7.67 9.602 5.553 8.38a.975.975 0 0 1 .515-1.818m7.438 2.063h4.7a.975.975 0 1 1 0 1.95h-4.7a.975.975 0 0 1 0-1.95" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KittyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
// Official icon is complex and has fixed width/height/viewBox in original.
|
||||
// Simplifying viewBox to 0 0 256 256 effectively as original was 240x240 but translated.
|
||||
// Original viewBox="0 0 240 240" with g transform="translate(0 -812.362)" and elements around y=850.
|
||||
// 850 - 812 = 38. So it's confusing.
|
||||
// Let's copy the raw SVG content but adapt it to be a component.
|
||||
// To make it behave like an icon, we should probably set viewBox="0 0 240 240" and keep the transform.
|
||||
// It relies on fill colors. If we want it to be monochrome (currentColor), we should remove fills or set them to currentColor.
|
||||
// However, official icons often have brand colors. The user said "official icon", which implies color.
|
||||
// But usually in a dropdown we might want monochrome or original color.
|
||||
// simple-icons are usually monochrome.
|
||||
// Let's keep Kitty as original color since it's complex, OR mono if it works?
|
||||
// The kitty icon has multiple paths with different colors. I'll preserve them for now as it's "official".
|
||||
// If it looks weird in dark mode/light mode, we might need to adjust.
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 240 240"
|
||||
{...props} // Allow overriding width/height
|
||||
>
|
||||
<g transform="translate(0 -812.362)">
|
||||
<rect
|
||||
width="100.446"
|
||||
height="161.551"
|
||||
x="72.824"
|
||||
y="850.13"
|
||||
ry="0"
|
||||
style={{
|
||||
fill: "#ddd",
|
||||
fillOpacity: 1,
|
||||
fillRule: "evenodd",
|
||||
stroke: "none",
|
||||
strokeWidth: 5.86876726,
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
strokeMiterlimit: 4,
|
||||
strokeDasharray: "none",
|
||||
strokeOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M67.896 1029.71h104.208a7.065 7.065 0 0 0 7.065-7.066V918.436a7.065 7.065 0 0 0-7.065-7.065H67.896a7.065 7.065 0 0 0-7.065 7.065v104.208a7.065 7.065 0 0 0 7.065 7.065m55.813-38.35h37.444a4.239 4.239 0 0 1 0 8.479H123.71a4.239 4.239 0 0 1 0-8.478m-45.032-45.71a4.239 4.239 0 0 1 5.991-5.99l26.48 26.464a4.24 4.24 0 0 1 0 5.992l-26.48 26.48a4.239 4.239 0 0 1-5.991-5.992l23.484-23.484z"
|
||||
style={{ strokeWidth: 1.41299629 }}
|
||||
/>
|
||||
<path
|
||||
d="M96.085 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.385 3.574-3.385 8.17 0 4.595 1.504 8.17 3.385 8.17"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fill: "#c0c81f",
|
||||
fillOpacity: 1,
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 3.09913683,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M193.128 836.886c-4.596-4.85-25.53 1.022-38.295 8.936-9.957-5.106-21.956-8.17-34.721-8.17-13.02 0-25.02 3.064-34.977 8.17-12.765-7.914-33.955-14.042-38.295-8.936-4.595 5.106 3.32 26.296 12.765 38.04-.766 3.064-1.276 6.128-1.276 9.446 0 10.212 4.34 19.659 11.744 27.318h42.124c-1.276-2.553.511-4.085 8.17-4.085 7.659.255 9.19 1.532 8.17 4.085h42.124c7.404-7.66 11.744-17.36 11.744-27.318 0-3.318-.51-6.382-1.276-9.446 8.935-11.744 16.594-33.189 11.999-38.04m-97.015 67.4c-8.935 0-16.339-7.404-16.339-16.34s7.404-16.339 16.34-16.339 16.339 7.404 16.339 16.34-7.404 16.339-16.34 16.339m47.997 0c-8.936 0-16.34-7.404-16.34-16.34s7.404-16.339 16.34-16.339 16.34 7.404 16.34 16.34-7.15 16.339-16.34 16.339"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fill: "#784421",
|
||||
fillOpacity: 1,
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
}}
|
||||
/>
|
||||
<g style={{ fill: "#2b1100", fillOpacity: 1 }}>
|
||||
<path
|
||||
d="M168.507 903.265c15.318-19.148 46.72-28.339 67.655-15.063-24.509-3.83-46.72 2.553-67.655 15.063"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
fill: "#2b1100",
|
||||
fillOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M167.486 898.67c8.68-20.425 34.466-33.7 55.145-26.552-21.7 2.808-39.316 11.233-55.145 26.551m-.256 9.957c15.83-15.063 50.806-20.169 61.528-4.34-21.7-6.893-40.593-3.83-61.527 4.34"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
fill: "#2b1100",
|
||||
fillOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
<g style={{ fill: "#2b1100", fillOpacity: 1 }}>
|
||||
<path
|
||||
d="M71.493 903.265c-15.318-19.148-46.72-28.339-67.655-15.063 24.509-3.83 46.72 2.553 67.655 15.063"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
fill: "#2b1100",
|
||||
fillOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M72.514 898.67c-8.68-20.425-34.466-33.7-55.145-26.552 21.7 2.808 39.316 11.233 55.145 26.551m.256 9.957c-15.83-15.063-50.806-20.169-61.528-4.34 21.7-6.893 40.593-3.83 61.527 4.34"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
fill: "#2b1100",
|
||||
fillOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M52.6 893.563c-6.382 0-11.743 3.32-14.296 8.425h-.766c-6.893 0-12.765 5.106-12.765 11.489 0 8.935 9.19 13.786 17.615 10.722 5.106 7.404 16.084 7.915 20.17 0 6.126-.255 16.083-1.276 17.615-10.722 1.021-6.383-5.617-11.489-12.765-11.489h-.766c-2.042-5.106-7.659-8.425-14.041-8.425m134.8 0c6.382 0 11.743 3.32 14.296 8.425h.766c3.574 0 12.765 5.106 12.765 11.489 0 8.935-9.19 13.786-17.615 10.722-5.107 7.404-16.084 7.915-20.17 0-6.126-.255-16.083-1.276-17.615-10.722-1.021-6.383 9.19-11.489 12.765-11.489h.766c2.042-5.106 7.659-8.425 14.041-8.425"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fill: "#483737",
|
||||
fillOpacity: 1,
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 2.55301046,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M143.542 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.386 3.574-3.386 8.17 0 4.595 1.505 8.17 3.386 8.17"
|
||||
style={{
|
||||
clipRule: "evenodd",
|
||||
fill: "#c0c81f",
|
||||
fillOpacity: 1,
|
||||
fillRule: "evenodd",
|
||||
strokeWidth: 3.09913683,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
|
||||
export function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (theme === "dark") {
|
||||
setTheme("light");
|
||||
} else {
|
||||
setTheme("dark");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="icon" onClick={toggleTheme}>
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">{t("common.toggleTheme")}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user