Compare commits

...

14 Commits

Author SHA1 Message Date
dependabot[bot] f8254389a0 chore(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-26 09:50:25 +00:00
Jason b0482320a7 chore(presets): refresh sponsor domains and referral links
- PackyCode: packyapi.com -> www.packyapi.ai across all 8 app presets;
  drop the api-slb.packyapi.com endpoint candidate
- RightCode: right.codes -> www.rightapi.ai across 7 app presets, keeping
  the /claude and /codex/v1 path split and adding the www prefix that the
  Codex-side endpoints were missing
- ClaudeAPI: site -> www.apito.ai, referral -> console.apito.ai/agent/...,
  endpoint -> gw.apito.ai (Claude Code and Claude Desktop only)
- APINebula: apinebula.com -> apinebula.ai across all 8 app presets,
  including the endpoint inside the Codex config.toml template
- SudoCode.chat: referral link only, /register -> /sign-up with new aff
  and utm parameters (7 app presets); SudoCode.us is a different company
  and is untouched

READMEs (en/zh/ja/de) carry the same URL updates. Brand names, icons,
i18n promo keys and README copy are deliberately unchanged.
2026-07-26 17:46:43 +08:00
Jason 9cf4ae41e6 feat(pricing): add Claude Opus 5 to built-in model pricing table
Seed claude-opus-5 at $5/$25 per MTok with $0.50 cache read and $6.25
cache creation, matching the Opus 4.8 tier. Verified against models.dev
and the official Anthropic models overview. Fast mode ($10/$50) is a
separate billing path and is deliberately left out of the table.

New model id, so seed only; no repair entry and no SCHEMA_VERSION bump.
2026-07-26 17:06:20 +08:00
Jason 876e9f898d feat(presets): restore AICoding partner across seven app types
Bring back the AICoding (aicoding.sh) provider preset that was dropped
in 910ca3b4, covering claude, claudeDesktop, codex, gemini, hermes,
opencode and openclaw, along with the partnerPromotion.aicoding string
in zh/en/ja and the sponsor entry in the three READMEs.

Model ids follow the current baseline rather than the pre-removal
values: gpt-5.5 for Codex, gemini-3.5-flash for Gemini, and
claude-opus-4-8 / claude-sonnet-5 for the Anthropic-relay presets.
OpenClaw costs use standard list pricing, consistent with its peers.

The icon, the logo asset, the zh-TW string and the legacy id in
codex_history_migration.rs were never removed and are reused as-is.
2026-07-26 10:18:57 +08:00
Jason 414b71500c ci: mirror release assets to Cloudflare R2 for ccswitch.io downloads
Add a sync-to-r2 job that downloads the published release assets,
generates the website download manifest, uploads both to the
cc-switch-releases bucket (dl.ccswitch.io), and prunes versions
beyond the latest five. The job skips itself when R2 secrets are
not configured, so forks and the current pipeline are unaffected.
2026-07-24 23:25:14 +08:00
Jason 878c26f31e feat(proxy): extend tool-result media handling to all conversion bridges
Generalize the Codex Responses-to-Chat tool media mechanism from 6c9d444c
to the remaining protocol bridges, so image-bearing tool results are never
tokenized as base64 text on any conversion path:

- Claude-to-Chat: tool_result images become native image_url parts,
  batched into one synthetic user message after the tool message batch.
- Claude-to-Responses: JSON-string, MCP, and nested variants are restored
  as native input_image parts inside function_call_output.
- Codex/GrokBuild-to-Anthropic: non-standard tool images are restored as
  Anthropic image blocks (case-insensitive data:/http prefix parsing).
- Claude-to-Gemini: Gemini 3 uses multimodal functionResponse.parts;
  older models get inlineData parts in the same user turn. The new
  InlineImagesOnly scope keeps remote URLs and malformed data URLs in the
  legacy text form instead of emitting fileData the API would reject.

Shared changes in tool_media:

- Centralize the plan/queue/flush helpers used by both Chat bridges.
- strip_and_clamp_media_from_tool_value clamps residual base64 inside
  parsed JSON strings at any nesting depth before re-serialization.
- Emitted Chat parts no longer carry cache_control or
  prompt_cache_breakpoint, preserving the strip-all-cache_control
  contract that strict upstreams (GLM/Qwen) depend on.
- Media detection now requires full convertibility, keeping detection
  and extraction symmetric by construction.

The media sanitizer detects and strips the new shapes symmetrically
(Chat string tool results, Anthropic string tool results, Gemini
inlineData/fileData and functionResponse.parts); the legacy typed-block
replacement runs first so replacement stays a superset of detection and
cache_control survives on replaced Anthropic blocks.

No-media tool outputs keep byte-identical legacy representations on all
bridges to protect prompt-cache prefixes.
2026-07-24 12:12:22 +08:00
Jason 6c9d444c8a fix(proxy): move Codex tool-result media out of stringified tool text
The Responses->Chat conversion serialized image-bearing *_output items
into role:"tool" text via canonical_json_string, so view_image results
were tokenized as base64 text (~9000x inflation). Codex replays full
history every turn, so sessions hit context-limit 400s and wedged
(#4465, #5663).

- add proxy/tool_media: shared detection/strip/clamp walker for tool
  output media (typed input_image / image_url / input_file /
  input_audio, Anthropic source and MCP data+mimeType image shapes,
  untyped data: image_url, whole-string bare data URLs)
- transform_codex_chat: replace media blocks in place with marker text
  so tool content stays a plain string, and flush the extracted media
  as one synthetic role:"user" message after each consecutive tool
  batch; media-free traffic stays byte-identical to keep prompt-cache
  prefixes stable
- media_sanitizer: detect and strip tool-output media symmetrically
  (including JSON-string outputs) so reactive image stripping can heal
  upstream modality rejections
- forwarder: regression tests pinning the reactive trigger and the
  context-limit-400 non-trigger

E2E against Kimi K3 through the proxy: the replayed turn stays ~12k
input tokens with 99% cache hit, versus ~85k+ of base64 text per
replay before.
2026-07-24 10:16:57 +08:00
Jason 34cbb375f0 feat(usage): surface Grok Build session source in usage UI
Add the grok_session icon to DataSourceBar and the Grok Build labels
for the app filter and data-source breakdown in all four locales
(zh/en/ja/zh-TW).
2026-07-23 17:00:09 +08:00
Jason cd161f4401 feat(usage): import Grok Build official-mode usage from session logs
Grok CLI's official OAuth mode cannot be routed through the local proxy
(empty config is the mode switch, so there is no injection point), which
left official-mode usage invisible. Add session_usage_grokbuild to
import usage from ~/.grok/sessions updates.jsonl:

- Only turn_completed events carry usage; each event is the independent
  per-turn total (accumulated across inference loops within one prompt),
  so events are imported at face value. Do not reintroduce differencing
  of adjacent events: counters reset every turn and differencing would
  massively under-record.
- Cost priority: reported costUsdTicks (1 tick = 1e-10 USD) wins when
  complete, because the backfill only repairs rows with total <= 0 and
  can never correct a positive mispriced value; local pricing fills the
  breakdown and raises a drift warning above max(1% of reported, 1e-6).
  costIsPartial marks the reported value a lower bound: prefer a full
  local recompute when the model is priced, else record the lower bound.
- Idempotency key grok_session:{session}:{prompt_id}:{model} anchors on
  the upstream per-turn UUID (index fallback only when prompt_id is
  empty), so rewind truncation cannot shift keys and double count;
  orphan rows from truncated turns are kept since the tokens were spent.
- Anti double-count vs proxy takeover: 10min settle window plus a
  time-window guard over recent grokbuild proxy activity; guarded skips
  never mark files as synced.
- Seed grok-4.5-build pricing 2/6/0.30, back-derived from exact
  costUsdTicks samples (cache read bills at 0.30, not the listed 0.50).
- Map _grok_session to a friendly provider display name and refresh the
  takeover-capability comment in services/proxy.rs.
2026-07-23 17:00:03 +08:00
Jason 3cf84ca362 fix(usage): centralize cache-inclusive app set and cover grokbuild in cost backfill
The cost backfill hardcoded codex|gemini as cache-inclusive apps, so
grokbuild TOTAL-semantics rows were priced on full input tokens with
cache reads double-counted. Converge the writer (proxy logger and
calculator) and the backfill recompute onto a single
sql_helpers::is_cache_inclusive_app predicate backed by the existing
CACHE_INCLUSIVE_APP_TYPES constant, and add a regression test for the
grokbuild backfill path.
2026-07-23 16:59:41 +08:00
Jason 15d5dbe065 feat: add Grok official subscription quota query
Add SuperGrok subscription usage display, following the existing
Claude Code / Codex official-subscription pattern (protocol ported
from steipete/CodexBar):

- New subscription_grok service: reads Grok CLI credentials from
  ~/.grok/auth.json, calls the grok.com GrokBuildBilling gRPC-web
  endpoint, and parses the response via heuristic protobuf scanning
  (used percent, reset time, zero-usage special case)
- Transient failures (network errors, HTTP 408, gRPC deadline/
  cancelled) propagate as Err so the frontend retries and keeps the
  last good value; auth failures map to Expired with a re-login hint
- Tier naming by reset distance: weekly limit, monthly, or a new
  "credits" tier (i18n added for zh/en/ja/zh-TW; tray shows "c")
- New get_xai_oauth_quota command: xai_oauth providers (managed
  SuperGrok OAuth accounts) query the same billing endpoint with
  their bound account token; ProviderCard auto-renders the quota
  footer for them and hides the usage-script entry, and the tray /
  usage-script path routes xai_oauth providers to the managed
  account instead of the host app's CLI credentials
- UsageScriptModal: drop the config-content heuristic for official
  detection; category === "official" is the single source of truth

Claude-Session: https://claude.ai/code/session_01LSNvhEfuoJHaQLZcYQgBU5
2026-07-23 11:13:07 +08:00
Jason a377d79303 docs: sync v3.18.0 release notes and guide cross-links to new guide titles 2026-07-22 12:58:11 +08:00
Jason 846fbdd1c0 docs(guides): retitle Codex Claude guide to match 2026-07-22 12:13:57 +08:00
Jason 3a9fb13a0b docs(guides): add en/ja translations for Claude Code GPT guide and retitle 2026-07-22 12:13:57 +08:00
60 changed files with 6678 additions and 383 deletions
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -62,7 +62,7 @@ jobs:
os: [ubuntu-22.04, windows-latest, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha || github.sha }}
+86 -1
View File
@@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -737,3 +737,88 @@ jobs:
run: |
set -euxo pipefail
gh release upload "$GITHUB_REF_NAME" latest.json --clobber --repo "$GITHUB_REPOSITORY"
# Mirror the release assets to Cloudflare R2 so ccswitch.io/download can
# serve them directly (dl.ccswitch.io). Skips itself when the R2 secrets
# are not configured (e.g. on forks). Required secrets:
# R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY
sync-to-r2:
name: Sync release to R2
runs-on: ubuntu-22.04
needs: assemble-latest-json
permissions:
contents: read
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
steps:
- name: Check R2 secrets
id: r2
run: |
if [ -n "$AWS_ACCESS_KEY_ID" ]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
else
echo "configured=false" >> "$GITHUB_OUTPUT"
echo "R2 secrets not configured; skipping download mirror sync."
fi
- name: Checkout scripts
if: steps.r2.outputs.configured == 'true'
uses: actions/checkout@v7
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 "$GITHUB_REF_NAME" --dir r2-assets --repo "$GITHUB_REPOSITORY"
ls -la r2-assets
- name: Generate download manifest
if: steps.r2.outputs.configured == 'true'
run: node scripts/generate-download-manifest.mjs r2-assets "$GITHUB_REF_NAME" "$R2_PUBLIC_BASE_URL" manifest.json
- name: Upload assets and manifest to R2
if: steps.r2.outputs.configured == 'true'
run: |
set -euo pipefail
# Versioned asset paths never change — cache hard at the edge.
aws s3 cp r2-assets "s3://$R2_BUCKET/$GITHUB_REF_NAME/" \
--recursive \
--exclude "*.sig" --exclude "*.tar.gz" --exclude "latest.json" \
--cache-control "public, max-age=31536000, immutable" \
--endpoint-url "$R2_ENDPOINT"
# The manifest is replaced on every release — keep edge cache short.
aws s3 cp manifest.json "s3://$R2_BUCKET/manifest.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'
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
+15 -10
View File
@@ -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,8 +45,8 @@ 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>
@@ -78,8 +78,8 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td>Thanks to Shengsuanyun for sponsoring this project! Shengsuanyun is a super factory serving AI Native Teams — an industrial-grade AI task parallel execution platform. Its model marketplace aggregates Claude, ChatGPT, Gemini, and other domestic and international LLM and multimedia model capabilities with direct supply. Absolutely no reverse engineering or dilution — platform-wide model SLA availability reaches 99.7%, with <a href="https://watch.shengsuanyun.com/status/shengsuanyun">monitoring dashboards</a> showing green across the board. It also offers enterprise-grade custom gateways for fine-grained team cost and permission management, smart routing, security protection, and BYOK (Bring Your Own Key) hosting. The platform charges on a pay-per-use and tokens plan (coming soon) basis, with invoicing available. Register via <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">this link</a> as a new user to receive ¥10 in credits plus a 10% bonus on your first top-up.</td>
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
<td>Thanks to AICoding.sh for sponsoring this project! AICoding.sh — 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.sh/i/CCSWITCH">this link</a> get 10% off their first top-up!</td>
</tr>
<tr>
@@ -87,6 +87,11 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
<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>
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td>Thanks to Shengsuanyun for sponsoring this project! Shengsuanyun is a super factory serving AI Native Teams — an industrial-grade AI task parallel execution platform. Its model marketplace aggregates Claude, ChatGPT, Gemini, and other domestic and international LLM and multimedia model capabilities with direct supply. Absolutely no reverse engineering or dilution — platform-wide model SLA availability reaches 99.7%, with <a href="https://watch.shengsuanyun.com/status/shengsuanyun">monitoring dashboards</a> showing green across the board. It also offers enterprise-grade custom gateways for fine-grained team cost and permission management, smart routing, security protection, and BYOK (Bring Your Own Key) hosting. The platform charges on a pay-per-use and tokens plan (coming soon) basis, with invoicing available. Register via <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">this link</a> as a new user to receive ¥10 in credits plus a 10% bonus on your first top-up.</td>
</tr>
<tr>
<td width="180"><a href="https://subrouter.ai/register?aff=l3ri"><img src="assets/partners/logos/subrouter-banner.png" alt="SubRouter" width="150"></a></td>
<td>Thanks to SubRouter for sponsoring this project! SubRouter is a marketplace and smart routing platform for AI service operators. Merchants can launch operating sites, publish packages, manage users, models, and pricing, while users discover services and access reliable AI models through one unified API. Register via <a href="https://subrouter.ai/register?aff=l3ri">this link</a>!</td>
@@ -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>
@@ -165,8 +170,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>
+8 -8
View File
@@ -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,8 +45,8 @@ 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>
@@ -98,8 +98,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>
@@ -165,8 +165,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>
+15 -10
View File
@@ -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,8 +45,8 @@ 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>
@@ -78,8 +78,8 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td>胜算雲(Shengsuanyun)のご支援に感謝します!胜算雲は AI ネイティブチーム向けのスーパーファクトリーであり、産業グレードの AI タスク並列実行プラットフォームです。モデルマーケットプレイスでは Claude、ChatGPT、Gemini をはじめとする国内外の LLM およびマルチメディアモデルの計算リソースを集約・直接提供。リバースエンジニアリングや品質低下は一切なく、プラットフォーム全体のモデル SLA 可用性は 99.7% に達し、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">監視ダッシュボード</a>は常時グリーン表示です。さらにエンタープライズ向けカスタムゲートウェイを提供し、チームのきめ細かなコスト・権限管理、スマートルーティング、セキュリティ保護、BYOK(自社キー持ち込み)ホスティングを実現します。従量課金およびトークンプラン(近日公開)対応で、請求書発行にも対応。<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">このリンク</a>から新規登録すると 10 元分のクレジットと初回チャージ 10% ボーナスが付与されます。</td>
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
<td>AICoding.sh のご支援に感謝します!AICoding.sh —— グローバル AI モデル API 超お得な中継サービス!Claude Code 81% オフ、GPT 99% オフ!数百社の企業に高コストパフォーマンスの AI サービスを提供。Claude Code、GPT、Gemini および国内主要モデルに対応、エンタープライズ級の高同時接続、迅速な請求書発行、24 時間年中無休の専属テクニカルサポート。<a href="https://aicoding.sh/i/CCSWITCH">こちらのリンク</a>から登録した CC Switch ユーザーは、初回チャージ 10% オフ!</td>
</tr>
<tr>
@@ -87,6 +87,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td>胜算雲(Shengsuanyun)のご支援に感謝します!胜算雲は AI ネイティブチーム向けのスーパーファクトリーであり、産業グレードの AI タスク並列実行プラットフォームです。モデルマーケットプレイスでは Claude、ChatGPT、Gemini をはじめとする国内外の LLM およびマルチメディアモデルの計算リソースを集約・直接提供。リバースエンジニアリングや品質低下は一切なく、プラットフォーム全体のモデル SLA 可用性は 99.7% に達し、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">監視ダッシュボード</a>は常時グリーン表示です。さらにエンタープライズ向けカスタムゲートウェイを提供し、チームのきめ細かなコスト・権限管理、スマートルーティング、セキュリティ保護、BYOK(自社キー持ち込み)ホスティングを実現します。従量課金およびトークンプラン(近日公開)対応で、請求書発行にも対応。<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">このリンク</a>から新規登録すると 10 元分のクレジットと初回チャージ 10% ボーナスが付与されます。</td>
</tr>
<tr>
<td width="180"><a href="https://subrouter.ai/register?aff=l3ri"><img src="assets/partners/logos/subrouter-banner.png" alt="SubRouter" width="150"></a></td>
<td>本プロジェクトをご支援いただいている SubRouter に感謝します!SubRouter は、AI サービス事業者向けのマーケットプレイス兼スマートルーティングプラットフォームです。事業者は独立した運営サイトを立ち上げ、プランを公開し、ユーザー・モデル・価格を管理でき、ユーザーはマーケットでサービスを見つけ、統一された API を通じて安定かつ高効率なモデル呼び出しを利用できます。<a href="https://subrouter.ai/register?aff=l3ri">こちらのリンク</a>から登録してください!</td>
@@ -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>
@@ -165,8 +170,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>
+15 -10
View File
@@ -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,8 +45,8 @@ 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>
@@ -78,8 +78,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td>感谢胜算云赞助了本项目!胜算云是专为AI Native Teams服务的超级工厂,工业级AI任务并行执行平台,模型商城集采直供聚合接入了Claude、Chatgpt、Gemini等海内外LLM及图片视频多媒体模型算力,绝无逆向掺水、全站模型SLA可用性高达99.7%、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">监测接口</a>日常全绿。更有企业级专属定制网关,实现团队精细化成本与权限管控,智能路由+安全防护+BYOK企业自带密钥托管。平台按量及tokens plan(即将上线)计费,可开票,使用<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">此链接</a>注册新用户可获10元模力及首充10%赠送。</td>
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
<td>感谢 AICoding.sh 赞助了本项目!AICoding.sh —— 全球大模型 API 超值中转服务!Claude Code 1.9 折,GPT 0.1 折,已为数百家企业提供高性价比 AI 服务。支持 Claude Code、GPT、Gemini 及国内主流模型,企业级高并发、极速开票、7×24 专属技术支持,通过<a href="https://aicoding.sh/i/CCSWITCH">此链接</a> 注册的 CC Switch 用户,首充可享受九折优惠!</td>
</tr>
<tr>
@@ -87,6 +87,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td>感谢胜算云赞助了本项目!胜算云是专为AI Native Teams服务的超级工厂,工业级AI任务并行执行平台,模型商城集采直供聚合接入了Claude、Chatgpt、Gemini等海内外LLM及图片视频多媒体模型算力,绝无逆向掺水、全站模型SLA可用性高达99.7%、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">监测接口</a>日常全绿。更有企业级专属定制网关,实现团队精细化成本与权限管控,智能路由+安全防护+BYOK企业自带密钥托管。平台按量及tokens plan(即将上线)计费,可开票,使用<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">此链接</a>注册新用户可获10元模力及首充10%赠送。</td>
</tr>
<tr>
<td width="180"><a href="https://subrouter.ai/register?aff=l3ri"><img src="assets/partners/logos/subrouter-banner.png" alt="SubRouter" width="150"></a></td>
<td>感谢 SubRouter 赞助本项目!SubRouter 是面向 AI 服务经营者的公开市场与智能路由平台。商家可快速开通独立经营站,发布套餐、管理用户与模型价格;用户可在市场发现服务,并通过统一 API 获得稳定高效的模型调用。通过<a href="https://subrouter.ai/register?aff=l3ri">此链接</a>注册!</td>
@@ -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>
@@ -165,8 +170,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>
@@ -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.
![The Needs Routing marker on a GPT provider in the Claude provider list](../images/claude-codex-routing/01-claude-providers-require-routing.png)
## 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.
![Method 1: in Advanced Options, set API Format to OpenAI Responses, keep the default auth field, and map to the upstream's real models](../images/claude-codex-routing/02-responses-provider-form.png)
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).
![Method 2: ChatGPT login status and account management in the Codex preset](../images/claude-codex-routing/03-codex-oauth-form.png)
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 つの接続方法を、本記事はどちらもカバーします:
- **方式 1API 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 形式へ変換して返します。
![Claude プロバイダー一覧の GPT プロバイダーに付く「ルーティングが必要」マーク](../images/claude-codex-routing/01-claude-providers-require-routing.png)
## 事前準備
- インストール済みで起動できる 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 を提供するゲートウェイ)にのみチェックしてください。そうでないと、長い対話が上流の実際の上限で直接エラーになります。
![方式 1: 高級オプションで API フォーマットを OpenAI Responses に設定し、認証フィールドはデフォルトのまま、上流の実際のモデルへマッピングする](../images/claude-codex-routing/02-responses-provider-form.png)
保存すると、カードに `ルーティングが必要` のマークが表示されます——この種のプロバイダーは、ローカルルーティングが実行中でなければ正しく動作しません。
### 方式 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` に対応します(バックグラウンドの小タスクがこれを使い、より速く、より枠を節約します)。
![方式 2: Codex プリセットの ChatGPT ログイン状態とアカウント管理](../images/claude-codex-routing/03-codex-oauth-form.png)
ログイン認証情報は `~/.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)
+2 -4
View File
@@ -1,4 +1,4 @@
# 在 Claude Code 中用 CodexCC 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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
# 在 Codex 中用 ClaudeCC Switch 本地路由攻略
# 通过 CC Switch 在 Codex 中使用 Claude 模型
> 适用版本:CC Switch 3.17.0 及以上(「Anthropic Messages 上游」自 3.17.0 引入)。本文根据仓库内文档与代码整理,以 Claude 系中转网关为例演示。截图来自当前前端界面,使用去敏示例数据生成,避免泄露真实 API Key。
+5 -5
View File
@@ -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
+5 -5
View File
@@ -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 のスポンサー欄を更新
+5 -5
View File
@@ -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 CodexAI 的 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 CodexAI 的 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 直连 xAIOAuth 受管与 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 赞助商更新
+67
View File
@@ -0,0 +1,67 @@
#!/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]
import { readdirSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const [assetsDir, tag, baseUrl, output = 'manifest.json'] = process.argv.slice(2);
if (!assetsDir || !tag || !baseUrl) {
console.error('Usage: node scripts/generate-download-manifest.mjs <assets-dir> <tag> <base-url> [output]');
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;
files.push({
platform: rule.platform,
kind: rule.kind,
arch: rule.arch,
name,
size: statSync(join(assetsDir, name)).size,
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: new Date().toISOString(),
files,
};
writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`);
console.log(`Wrote ${output} with ${files.length} files for ${tag}`);
+24 -5
View File
@@ -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 {
+63
View File
@@ -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)]
+6
View File
@@ -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",
@@ -2246,6 +2248,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 上报的内部别名。定价由
// costUsdTicks1 tick = 1e-10 USD)双轮实测反推:input/output 与
// grok-4.5 同为 2/6cache 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",
+1
View File
@@ -1375,6 +1375,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)
+97
View File
@@ -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:上游报图片错误也不触发兜底重试。
+568 -15
View File
@@ -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 {
+1
View File
@@ -33,6 +33,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;
+192 -15
View File
@@ -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 }
@@ -2555,6 +2678,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(
@@ -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!({
+991
View File
@@ -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);
}
}
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+2
View File
@@ -17,12 +17,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;
+5 -2
View File
@@ -1505,8 +1505,11 @@ impl ProxyService {
/// Grok Build live 是否具备可接管的自定义模型表。
///
/// 官方态 liveGrok 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")
+5
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -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;
+7
View File
@@ -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)),
}
}
+961
View File
@@ -0,0 +1,961 @@
//! Grok (xAI) 官方订阅额度查询
//!
//! 读取 Grok CLI 的 OAuth 凭据(~/.grok/auth.json),调用 grok.com 的
//! gRPC-web billing 端点查询 SuperGrok 订阅的 credit 用量。
//!
//! 实现移植自 CodexBarsteipete/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";
/// SuperGrokOIDC)条目的 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)
}
/// 选择首选凭据条目:OIDCSuperGrok)优先,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 快照。其余状态 → 确定性失败快照。
/// headertrailers-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` 的阈值):
/// 412 天 → 周窗口,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");
}
}
+85 -2
View File
@@ -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)"
)
}
@@ -416,6 +417,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 +1885,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
@@ -2613,6 +2649,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.50input = (700-250)×2/1Mcache_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()?;
+5
View File
@@ -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,
)
+4 -1
View File
@@ -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}
/>
);
+7 -1
View File
@@ -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;
}
+42
View File
@@ -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;
+12 -2
View File
@@ -14,6 +14,7 @@ import UsageFooter from "@/components/UsageFooter";
import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter";
import CopilotQuotaFooter from "@/components/CopilotQuotaFooter";
import CodexOauthQuotaFooter from "@/components/CodexOauthQuotaFooter";
import XaiOauthQuotaFooter from "@/components/XaiOauthQuotaFooter";
import { PROVIDER_TYPES, TEMPLATE_TYPES } from "@/config/constants";
import { isHermesReadOnlyProvider } from "@/config/hermesProviderPresets";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
@@ -196,7 +197,7 @@ export function ProviderCard({
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
const isOfficial = isOfficialProvider(provider, appId);
const supportsOfficialSubscription =
isOfficial && ["claude", "codex", "gemini"].includes(appId);
isOfficial && ["claude", "codex", "gemini", "grokbuild"].includes(appId);
const isOfficialSubscriptionUsage =
provider.meta?.usage_script?.templateType ===
TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION;
@@ -227,6 +228,8 @@ export function ProviderCard({
appId === "hermes" && isHermesReadOnlyProvider(provider.settingsConfig);
const isCodexOauth =
provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH;
// xAI OAuth (SuperGrok 反代):额度经自管 OAuth token 自动显示,与 codex_oauth 同构
const isXaiOauth = provider.meta?.providerType === PROVIDER_TYPES.XAI_OAUTH;
// 统一权威谓词(详见 providerNeedsRouting):以 providerType 为准,不受
// apiFormat 被改动/缺省影响。此 badge 仅在 Codex 视图渲染,故加 appId 守卫。
const codexNeedsRouting =
@@ -493,6 +496,12 @@ export function ProviderCard({
inline={true}
isCurrent={isCurrent}
/>
) : isXaiOauth ? (
<XaiOauthQuotaFooter
meta={provider.meta}
inline={true}
isCurrent={isCurrent}
/>
) : isOfficial ? (
officialSubscriptionEnabled ? (
<SubscriptionQuotaFooter
@@ -573,7 +582,8 @@ export function ProviderCard({
onConfigureUsage={
(isOfficial && !supportsOfficialSubscription) ||
isCopilot ||
isCodexOauth
isCodexOauth ||
isXaiOauth
? undefined
: () => onConfigureUsage(provider)
}
+1
View File
@@ -18,6 +18,7 @@ const DATA_SOURCE_ICONS: Record<string, React.ReactNode> = {
codex_session: <FileText className="h-3.5 w-3.5" />,
gemini_session: <FileText className="h-3.5 w-3.5" />,
opencode_session: <FileText className="h-3.5 w-3.5" />,
grok_session: <FileText className="h-3.5 w-3.5" />,
};
export function DataSourceBar({ refreshIntervalMs }: DataSourceBarProps) {
+45 -33
View File
@@ -184,17 +184,14 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
},
{
name: "PackyCode",
websiteUrl: "https://www.packyapi.com",
apiKeyUrl: "https://www.packyapi.com/register?aff=cc-switch",
websiteUrl: "https://www.packyapi.ai",
apiKeyUrl: "https://www.packyapi.ai/register?aff=cc-switch",
category: "third_party",
baseUrl: "https://www.packyapi.com",
baseUrl: "https://www.packyapi.ai",
mode: "direct",
apiFormat: "anthropic",
modelRoutes: passthroughRoutes(),
endpointCandidates: [
"https://www.packyapi.com",
"https://api-slb.packyapi.com",
],
endpointCandidates: ["https://www.packyapi.ai"],
isPartner: true,
partnerPromotionKey: "packycode",
icon: "packycode",
@@ -214,14 +211,14 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
},
{
name: "APINebula",
websiteUrl: "https://apinebula.com",
apiKeyUrl: "https://apinebula.com/VjM74M",
websiteUrl: "https://apinebula.ai",
apiKeyUrl: "https://apinebula.ai/VjM74M",
category: "third_party",
baseUrl: "https://apinebula.com",
baseUrl: "https://apinebula.ai",
mode: "direct",
apiFormat: "anthropic",
modelRoutes: passthroughRoutes(),
endpointCandidates: ["https://apinebula.com"],
endpointCandidates: ["https://apinebula.ai"],
isPartner: true,
partnerPromotionKey: "apinebula",
icon: "apinebula",
@@ -297,6 +294,36 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
partnerPromotionKey: "unity2",
icon: "unity2",
},
{
name: "AICoding",
websiteUrl: "https://aicoding.sh",
apiKeyUrl: "https://aicoding.sh/i/CCSWITCH",
category: "third_party",
baseUrl: "https://api.aicoding.sh",
mode: "direct",
apiFormat: "anthropic",
modelRoutes: passthroughRoutes(),
endpointCandidates: ["https://api.aicoding.sh"],
isPartner: true,
partnerPromotionKey: "aicoding",
icon: "aicoding",
iconColor: "#000000",
},
{
name: "AIGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
category: "third_party",
baseUrl: "https://api.aigocode.com",
mode: "direct",
apiFormat: "anthropic",
modelRoutes: passthroughRoutes(),
endpointCandidates: ["https://api.aigocode.com"],
isPartner: true,
partnerPromotionKey: "aigocode",
icon: "aigocode",
iconColor: "#5B7FFF",
},
{
name: "Shengsuanyun",
nameKey: "providerForm.presets.shengsuanyun",
@@ -315,21 +342,6 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
partnerPromotionKey: "shengsuanyun",
icon: "shengsuanyun",
},
{
name: "AIGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
category: "third_party",
baseUrl: "https://api.aigocode.com",
mode: "direct",
apiFormat: "anthropic",
modelRoutes: passthroughRoutes(),
endpointCandidates: ["https://api.aigocode.com"],
isPartner: true,
partnerPromotionKey: "aigocode",
icon: "aigocode",
iconColor: "#5B7FFF",
},
{
name: "SubRouter",
websiteUrl: "https://subrouter.ai",
@@ -359,10 +371,10 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
},
{
name: "ClaudeAPI",
websiteUrl: "https://claudeapi.com",
apiKeyUrl: "https://console.claudeapi.com/register?aff=pCLD",
websiteUrl: "https://www.apito.ai",
apiKeyUrl: "https://console.apito.ai/agent/register/pQBql2buaqiX3dDS",
category: "aggregator",
baseUrl: "https://gw.claudeapi.com",
baseUrl: "https://gw.apito.ai",
mode: "direct",
apiFormat: "anthropic",
modelRoutes: passthroughRoutes(),
@@ -616,10 +628,10 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
},
{
name: "RightCode",
websiteUrl: "https://www.right.codes",
apiKeyUrl: "https://www.right.codes/register?aff=CCSWITCH",
websiteUrl: "https://www.rightapi.ai",
apiKeyUrl: "https://www.rightapi.ai/register?aff=CCSWITCH",
category: "third_party",
baseUrl: "https://www.right.codes/claude",
baseUrl: "https://www.rightapi.ai/claude",
mode: "direct",
apiFormat: "anthropic",
modelRoutes: passthroughRoutes(),
@@ -709,7 +721,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
name: "SudoCode.chat",
websiteUrl: "https://sudocode.chat",
apiKeyUrl:
"https://sudocode.chat/register?utm_source=ccswitch&utm_medium=partner",
"https://sudocode.chat/sign-up?aff=CC-SWITCH&utm_source=cc-switch&utm_medium=sponsor&utm_campaign=ccswitch",
category: "third_party",
baseUrl: "https://api.sudocode.chat",
mode: "direct",
+45 -31
View File
@@ -135,19 +135,16 @@ export const providerPresets: ProviderPreset[] = [
},
{
name: "PackyCode",
websiteUrl: "https://www.packyapi.com",
apiKeyUrl: "https://www.packyapi.com/register?aff=cc-switch",
websiteUrl: "https://www.packyapi.ai",
apiKeyUrl: "https://www.packyapi.ai/register?aff=cc-switch",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://www.packyapi.com",
ANTHROPIC_BASE_URL: "https://www.packyapi.ai",
ANTHROPIC_AUTH_TOKEN: "",
},
},
// 请求地址候选(用于地址管理/测速)
endpointCandidates: [
"https://www.packyapi.com",
"https://api-slb.packyapi.com",
],
endpointCandidates: ["https://www.packyapi.ai"],
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "packycode", // 促销信息 i18n key
@@ -170,16 +167,16 @@ export const providerPresets: ProviderPreset[] = [
},
{
name: "APINebula",
websiteUrl: "https://apinebula.com",
apiKeyUrl: "https://apinebula.com/VjM74M",
websiteUrl: "https://apinebula.ai",
apiKeyUrl: "https://apinebula.ai/VjM74M",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://apinebula.com",
ANTHROPIC_BASE_URL: "https://apinebula.ai",
ANTHROPIC_AUTH_TOKEN: "",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
},
},
endpointCandidates: ["https://apinebula.com"],
endpointCandidates: ["https://apinebula.ai"],
category: "third_party",
isPartner: true,
partnerPromotionKey: "apinebula",
@@ -268,24 +265,21 @@ export const providerPresets: ProviderPreset[] = [
icon: "unity2",
},
{
name: "Shengsuanyun",
nameKey: "providerForm.presets.shengsuanyun",
websiteUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
apiKeyUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
name: "AICoding",
websiteUrl: "https://aicoding.sh",
apiKeyUrl: "https://aicoding.sh/i/CCSWITCH",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://router.shengsuanyun.com/api",
ANTHROPIC_BASE_URL: "https://api.aicoding.sh",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "anthropic/claude-sonnet-5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "anthropic/claude-haiku-4.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.8",
},
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "shengsuanyun",
icon: "shengsuanyun",
endpointCandidates: ["https://api.aicoding.sh"],
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "aicoding", // 促销信息 i18n key
icon: "aicoding",
iconColor: "#000000",
},
{
name: "AIGoCode",
@@ -305,6 +299,26 @@ export const providerPresets: ProviderPreset[] = [
icon: "aigocode",
iconColor: "#5B7FFF",
},
{
name: "Shengsuanyun",
nameKey: "providerForm.presets.shengsuanyun",
websiteUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
apiKeyUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://router.shengsuanyun.com/api",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "anthropic/claude-sonnet-5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "anthropic/claude-haiku-4.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.8",
},
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "shengsuanyun",
icon: "shengsuanyun",
},
{
name: "SubRouter",
websiteUrl: "https://subrouter.ai",
@@ -339,11 +353,11 @@ export const providerPresets: ProviderPreset[] = [
},
{
name: "ClaudeAPI",
websiteUrl: "https://claudeapi.com",
apiKeyUrl: "https://console.claudeapi.com/register?aff=pCLD",
websiteUrl: "https://www.apito.ai",
apiKeyUrl: "https://console.apito.ai/agent/register/pQBql2buaqiX3dDS",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://gw.claudeapi.com",
ANTHROPIC_BASE_URL: "https://gw.apito.ai",
ANTHROPIC_AUTH_TOKEN: "",
},
},
@@ -635,11 +649,11 @@ export const providerPresets: ProviderPreset[] = [
},
{
name: "RightCode",
websiteUrl: "https://www.right.codes",
apiKeyUrl: "https://www.right.codes/register?aff=CCSWITCH",
websiteUrl: "https://www.rightapi.ai",
apiKeyUrl: "https://www.rightapi.ai/register?aff=CCSWITCH",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://www.right.codes/claude",
ANTHROPIC_BASE_URL: "https://www.rightapi.ai/claude",
ANTHROPIC_AUTH_TOKEN: "",
},
},
@@ -741,7 +755,7 @@ export const providerPresets: ProviderPreset[] = [
name: "SudoCode.chat",
websiteUrl: "https://sudocode.chat",
apiKeyUrl:
"https://sudocode.chat/register?utm_source=ccswitch&utm_medium=partner",
"https://sudocode.chat/sign-up?aff=CC-SWITCH&utm_source=cc-switch&utm_medium=sponsor&utm_campaign=ccswitch",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.sudocode.chat",
+38 -25
View File
@@ -196,19 +196,16 @@ export const codexProviderPresets: CodexProviderPreset[] = [
},
{
name: "PackyCode",
websiteUrl: "https://www.packyapi.com",
apiKeyUrl: "https://www.packyapi.com/register?aff=cc-switch",
websiteUrl: "https://www.packyapi.ai",
apiKeyUrl: "https://www.packyapi.ai/register?aff=cc-switch",
category: "third_party",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"packycode",
"https://www.packyapi.com/v1",
"https://www.packyapi.ai/v1",
"gpt-5.5",
),
endpointCandidates: [
"https://www.packyapi.com/v1",
"https://api-slb.packyapi.com/v1",
],
endpointCandidates: ["https://www.packyapi.ai/v1"],
isPartner: true, // 合作伙伴
partnerPromotionKey: "packycode", // 促销信息 i18n key
icon: "packycode",
@@ -231,8 +228,8 @@ export const codexProviderPresets: CodexProviderPreset[] = [
},
{
name: "APINebula",
websiteUrl: "https://apinebula.com",
apiKeyUrl: "https://apinebula.com/VjM74M",
websiteUrl: "https://apinebula.ai",
apiKeyUrl: "https://apinebula.ai/VjM74M",
category: "third_party",
auth: generateThirdPartyAuth(""),
config: `model_provider = "custom"
@@ -243,10 +240,10 @@ disable_response_storage = true
[model_providers.custom]
name = "APINebula"
base_url = "https://apinebula.com/v1"
base_url = "https://apinebula.ai/v1"
wire_api = "responses"
requires_openai_auth = true`,
endpointCandidates: ["https://apinebula.com/v1"],
endpointCandidates: ["https://apinebula.ai/v1"],
apiFormat: "openai_responses",
isPartner: true,
partnerPromotionKey: "apinebula",
@@ -336,20 +333,20 @@ requires_openai_auth = true`,
icon: "unity2",
},
{
name: "Shengsuanyun",
nameKey: "providerForm.presets.shengsuanyun",
websiteUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
apiKeyUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
name: "AICoding",
websiteUrl: "https://aicoding.sh",
apiKeyUrl: "https://aicoding.sh/i/CCSWITCH",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"shengsuanyun",
"https://router.shengsuanyun.com/api/v1",
"openai/gpt-5.5",
"aicoding",
"https://api.aicoding.sh",
"gpt-5.5",
),
category: "aggregator",
endpointCandidates: ["https://api.aicoding.sh"],
isPartner: true,
partnerPromotionKey: "shengsuanyun",
icon: "shengsuanyun",
partnerPromotionKey: "aicoding",
icon: "aicoding",
iconColor: "#000000",
},
{
name: "AIGoCode",
@@ -368,6 +365,22 @@ requires_openai_auth = true`,
icon: "aigocode",
iconColor: "#5B7FFF",
},
{
name: "Shengsuanyun",
nameKey: "providerForm.presets.shengsuanyun",
websiteUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
apiKeyUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"shengsuanyun",
"https://router.shengsuanyun.com/api/v1",
"openai/gpt-5.5",
),
category: "aggregator",
isPartner: true,
partnerPromotionKey: "shengsuanyun",
icon: "shengsuanyun",
},
{
name: "SubRouter",
websiteUrl: "https://subrouter.ai",
@@ -728,12 +741,12 @@ requires_openai_auth = true`,
},
{
name: "RightCode",
websiteUrl: "https://www.right.codes",
apiKeyUrl: "https://www.right.codes/register?aff=CCSWITCH",
websiteUrl: "https://www.rightapi.ai",
apiKeyUrl: "https://www.rightapi.ai/register?aff=CCSWITCH",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"rightcode",
"https://right.codes/codex/v1",
"https://www.rightapi.ai/codex/v1",
"gpt-5.5",
),
category: "third_party",
@@ -835,7 +848,7 @@ requires_openai_auth = true`,
name: "SudoCode.chat",
websiteUrl: "https://sudocode.chat",
apiKeyUrl:
"https://sudocode.chat/register?utm_source=ccswitch&utm_medium=partner",
"https://sudocode.chat/sign-up?aff=CC-SWITCH&utm_source=cc-switch&utm_medium=sponsor&utm_campaign=ccswitch",
category: "third_party",
auth: generateThirdPartyAuth(""),
config: `model_provider = "custom"
+42 -25
View File
@@ -54,44 +54,41 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
// ===== 赞助商预设:文件顺序 = 应用内展示顺序,与 README 赞助商表对齐 =====
{
name: "PackyCode",
websiteUrl: "https://www.packyapi.com",
apiKeyUrl: "https://www.packyapi.com/register?aff=cc-switch",
websiteUrl: "https://www.packyapi.ai",
apiKeyUrl: "https://www.packyapi.ai/register?aff=cc-switch",
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://www.packyapi.com",
GOOGLE_GEMINI_BASE_URL: "https://www.packyapi.ai",
GEMINI_MODEL: "gemini-3.5-flash",
},
},
baseURL: "https://www.packyapi.com",
baseURL: "https://www.packyapi.ai",
model: "gemini-3.5-flash",
description: "PackyCode",
category: "third_party",
isPartner: true,
partnerPromotionKey: "packycode",
endpointCandidates: [
"https://api-slb.packyapi.com",
"https://www.packyapi.com",
],
endpointCandidates: ["https://www.packyapi.ai"],
icon: "packycode",
},
{
name: "APINebula",
websiteUrl: "https://apinebula.com",
apiKeyUrl: "https://apinebula.com/VjM74M",
websiteUrl: "https://apinebula.ai",
apiKeyUrl: "https://apinebula.ai/VjM74M",
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://apinebula.com",
GOOGLE_GEMINI_BASE_URL: "https://apinebula.ai",
GEMINI_API_KEY: "",
GEMINI_MODEL: "gemini-3.5-flash",
},
},
baseURL: "https://apinebula.com",
baseURL: "https://apinebula.ai",
model: "gemini-3.5-flash",
description: "APINebula",
category: "third_party",
isPartner: true,
partnerPromotionKey: "apinebula",
endpointCandidates: ["https://apinebula.com"],
endpointCandidates: ["https://apinebula.ai"],
icon: "apinebula",
},
{
@@ -136,23 +133,24 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
icon: "unity2",
},
{
name: "Shengsuanyun",
nameKey: "providerForm.presets.shengsuanyun",
websiteUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
apiKeyUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
name: "AICoding",
websiteUrl: "https://aicoding.sh",
apiKeyUrl: "https://aicoding.sh/i/CCSWITCH",
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://router.shengsuanyun.com/api",
GEMINI_MODEL: "google/gemini-3.5-flash",
GOOGLE_GEMINI_BASE_URL: "https://api.aicoding.sh",
GEMINI_MODEL: "gemini-3.5-flash",
},
},
baseURL: "https://router.shengsuanyun.com/api",
model: "google/gemini-3.5-flash",
description: "Shengsuanyun",
category: "aggregator",
baseURL: "https://api.aicoding.sh",
model: "gemini-3.5-flash",
description: "AICoding",
category: "third_party",
isPartner: true,
partnerPromotionKey: "shengsuanyun",
icon: "shengsuanyun",
partnerPromotionKey: "aicoding",
endpointCandidates: ["https://api.aicoding.sh"],
icon: "aicoding",
iconColor: "#000000",
},
{
name: "AIGoCode",
@@ -174,6 +172,25 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
icon: "aigocode",
iconColor: "#5B7FFF",
},
{
name: "Shengsuanyun",
nameKey: "providerForm.presets.shengsuanyun",
websiteUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
apiKeyUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://router.shengsuanyun.com/api",
GEMINI_MODEL: "google/gemini-3.5-flash",
},
},
baseURL: "https://router.shengsuanyun.com/api",
model: "google/gemini-3.5-flash",
description: "Shengsuanyun",
category: "aggregator",
isPartner: true,
partnerPromotionKey: "shengsuanyun",
icon: "shengsuanyun",
},
{
name: "SubRouter",
websiteUrl: "https://subrouter.ai",
+25 -28
View File
@@ -80,14 +80,11 @@ export const grokBuildProviderPresets: GrokBuildProviderPreset[] = [
// ===== 赞助商预设:文件顺序 = 应用内展示顺序,与 README 赞助商表对齐 =====
{
name: "PackyCode",
websiteUrl: "https://www.packyapi.com",
apiKeyUrl: "https://www.packyapi.com/register?aff=cc-switch",
websiteUrl: "https://www.packyapi.ai",
apiKeyUrl: "https://www.packyapi.ai/register?aff=cc-switch",
auth: grokAuth(),
config: grokPresetConfig("PackyCode", "https://www.packyapi.com/v1"),
endpointCandidates: [
"https://www.packyapi.com/v1",
"https://api-slb.packyapi.com/v1",
],
config: grokPresetConfig("PackyCode", "https://www.packyapi.ai/v1"),
endpointCandidates: ["https://www.packyapi.ai/v1"],
category: "third_party",
isPartner: true,
partnerPromotionKey: "packycode",
@@ -107,11 +104,11 @@ export const grokBuildProviderPresets: GrokBuildProviderPreset[] = [
},
{
name: "APINebula",
websiteUrl: "https://apinebula.com",
apiKeyUrl: "https://apinebula.com/VjM74M",
websiteUrl: "https://apinebula.ai",
apiKeyUrl: "https://apinebula.ai/VjM74M",
auth: grokAuth(),
config: grokPresetConfig("APINebula", "https://apinebula.com/v1"),
endpointCandidates: ["https://apinebula.com/v1"],
config: grokPresetConfig("APINebula", "https://apinebula.ai/v1"),
endpointCandidates: ["https://apinebula.ai/v1"],
apiFormat: "openai_responses",
category: "third_party",
isPartner: true,
@@ -184,6 +181,19 @@ export const grokBuildProviderPresets: GrokBuildProviderPreset[] = [
partnerPromotionKey: "unity2",
icon: "unity2",
},
{
name: "AIGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
auth: grokAuth(),
config: grokPresetConfig("AIGoCode", "https://api.aigocode.com"),
endpointCandidates: ["https://api.aigocode.com"],
category: "third_party",
isPartner: true,
partnerPromotionKey: "aigocode",
icon: "aigocode",
iconColor: "#5B7FFF",
},
{
name: "Shengsuanyun",
nameKey: "providerForm.presets.shengsuanyun",
@@ -200,19 +210,6 @@ export const grokBuildProviderPresets: GrokBuildProviderPreset[] = [
partnerPromotionKey: "shengsuanyun",
icon: "shengsuanyun",
},
{
name: "AIGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
auth: grokAuth(),
config: grokPresetConfig("AIGoCode", "https://api.aigocode.com"),
endpointCandidates: ["https://api.aigocode.com"],
category: "third_party",
isPartner: true,
partnerPromotionKey: "aigocode",
icon: "aigocode",
iconColor: "#5B7FFF",
},
{
name: "SubRouter",
websiteUrl: "https://subrouter.ai",
@@ -369,10 +366,10 @@ export const grokBuildProviderPresets: GrokBuildProviderPreset[] = [
},
{
name: "RightCode",
websiteUrl: "https://www.right.codes",
apiKeyUrl: "https://www.right.codes/register?aff=CCSWITCH",
websiteUrl: "https://www.rightapi.ai",
apiKeyUrl: "https://www.rightapi.ai/register?aff=CCSWITCH",
auth: grokAuth(),
config: grokPresetConfig("RightCode", "https://right.codes/codex/v1"),
config: grokPresetConfig("RightCode", "https://www.rightapi.ai/codex/v1"),
category: "third_party",
isPartner: true,
partnerPromotionKey: "rightcode",
@@ -455,7 +452,7 @@ export const grokBuildProviderPresets: GrokBuildProviderPreset[] = [
name: "SudoCode.chat",
websiteUrl: "https://sudocode.chat",
apiKeyUrl:
"https://sudocode.chat/register?utm_source=ccswitch&utm_medium=partner",
"https://sudocode.chat/sign-up?aff=CC-SWITCH&utm_source=cc-switch&utm_medium=sponsor&utm_campaign=ccswitch",
auth: grokAuth(),
config: grokPresetConfig("SudoCode.chat", "https://api.sudocode.chat/v1"),
endpointCandidates: ["https://api.sudocode.chat/v1"],
+46 -22
View File
@@ -171,11 +171,11 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
},
{
name: "PackyCode",
websiteUrl: "https://www.packyapi.com",
apiKeyUrl: "https://www.packyapi.com/register?aff=cc-switch",
websiteUrl: "https://www.packyapi.ai",
apiKeyUrl: "https://www.packyapi.ai/register?aff=cc-switch",
settingsConfig: {
name: "packycode",
base_url: "https://www.packyapi.com",
base_url: "https://www.packyapi.ai",
api_key: "",
api_mode: "anthropic_messages",
models: [
@@ -213,11 +213,11 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
},
{
name: "APINebula",
websiteUrl: "https://apinebula.com",
apiKeyUrl: "https://apinebula.com/VjM74M",
websiteUrl: "https://apinebula.ai",
apiKeyUrl: "https://apinebula.ai/VjM74M",
settingsConfig: {
name: "apinebula",
base_url: "https://apinebula.com/v1",
base_url: "https://apinebula.ai/v1",
api_key: "",
api_mode: "chat_completions",
models: [
@@ -335,23 +335,27 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
},
},
{
name: "Shengsuanyun",
nameKey: "providerForm.presets.shengsuanyun",
websiteUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
apiKeyUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
name: "AICoding",
websiteUrl: "https://aicoding.sh",
apiKeyUrl: "https://aicoding.sh/i/CCSWITCH",
settingsConfig: {
name: "shengsuanyun",
base_url: "https://router.shengsuanyun.com/api/v1",
name: "aicoding",
base_url: "https://api.aicoding.sh",
api_key: "",
api_mode: "chat_completions",
models: [{ id: "openai/gpt-5.5", name: "GPT-5.5" }],
api_mode: "anthropic_messages",
models: [
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
],
},
category: "aggregator",
category: "third_party",
isPartner: true,
partnerPromotionKey: "shengsuanyun",
icon: "shengsuanyun",
partnerPromotionKey: "aicoding",
icon: "aicoding",
iconColor: "#000000",
suggestedDefaults: {
model: { default: "openai/gpt-5.5", provider: "shengsuanyun" },
model: { default: "claude-opus-4-8", provider: "aicoding" },
},
},
{
@@ -378,6 +382,26 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
model: { default: "claude-opus-4-8", provider: "aigocode" },
},
},
{
name: "Shengsuanyun",
nameKey: "providerForm.presets.shengsuanyun",
websiteUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
apiKeyUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
settingsConfig: {
name: "shengsuanyun",
base_url: "https://router.shengsuanyun.com/api/v1",
api_key: "",
api_mode: "chat_completions",
models: [{ id: "openai/gpt-5.5", name: "GPT-5.5" }],
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "shengsuanyun",
icon: "shengsuanyun",
suggestedDefaults: {
model: { default: "openai/gpt-5.5", provider: "shengsuanyun" },
},
},
{
name: "SubRouter",
websiteUrl: "https://subrouter.ai",
@@ -810,11 +834,11 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
},
{
name: "RightCode",
websiteUrl: "https://www.right.codes",
apiKeyUrl: "https://www.right.codes/register?aff=CCSWITCH",
websiteUrl: "https://www.rightapi.ai",
apiKeyUrl: "https://www.rightapi.ai/register?aff=CCSWITCH",
settingsConfig: {
name: "rightcode",
base_url: "https://www.right.codes/claude",
base_url: "https://www.rightapi.ai/claude",
api_key: "",
api_mode: "anthropic_messages",
models: [
@@ -946,7 +970,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
name: "SudoCode.chat",
websiteUrl: "https://sudocode.chat",
apiKeyUrl:
"https://sudocode.chat/register?utm_source=ccswitch&utm_medium=partner",
"https://sudocode.chat/sign-up?aff=CC-SWITCH&utm_source=cc-switch&utm_medium=sponsor&utm_campaign=ccswitch",
settingsConfig: {
name: "sudocode",
base_url: "https://api.sudocode.chat/v1",
+70 -24
View File
@@ -187,10 +187,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
name: "PackyCode",
websiteUrl: "https://www.packyapi.com",
apiKeyUrl: "https://www.packyapi.com/register?aff=cc-switch",
websiteUrl: "https://www.packyapi.ai",
apiKeyUrl: "https://www.packyapi.ai/register?aff=cc-switch",
settingsConfig: {
baseUrl: "https://www.packyapi.com",
baseUrl: "https://www.packyapi.ai",
apiKey: "",
api: "anthropic-messages",
models: [
@@ -268,10 +268,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
{
name: "APINebula",
websiteUrl: "https://apinebula.com",
apiKeyUrl: "https://apinebula.com/VjM74M",
websiteUrl: "https://apinebula.ai",
apiKeyUrl: "https://apinebula.ai/VjM74M",
settingsConfig: {
baseUrl: "https://apinebula.com/v1",
baseUrl: "https://apinebula.ai/v1",
apiKey: "",
api: "openai-completions",
models: [
@@ -467,33 +467,33 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
{
name: "Shengsuanyun",
nameKey: "providerForm.presets.shengsuanyun",
websiteUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
apiKeyUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
name: "AICoding",
websiteUrl: "https://aicoding.sh",
apiKeyUrl: "https://aicoding.sh/i/CCSWITCH",
settingsConfig: {
baseUrl: "https://router.shengsuanyun.com/api",
baseUrl: "https://api.aicoding.sh",
apiKey: "",
api: "anthropic-messages",
models: [
{
id: "anthropic/claude-opus-4.8",
id: "claude-opus-4-8",
name: "Claude Opus 4.8",
contextWindow: 1000000,
cost: { input: 5, output: 25 },
},
{
id: "anthropic/claude-sonnet-5",
id: "claude-sonnet-5",
name: "Claude Sonnet 5",
contextWindow: 1000000,
cost: { input: 3, output: 15 },
},
],
},
category: "aggregator",
category: "third_party",
isPartner: true,
partnerPromotionKey: "shengsuanyun",
icon: "shengsuanyun",
partnerPromotionKey: "aicoding",
icon: "aicoding",
iconColor: "#000000",
templateValues: {
apiKey: {
label: "API Key",
@@ -503,12 +503,12 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
suggestedDefaults: {
model: {
primary: "shengsuanyun/anthropic/claude-opus-4.8",
fallbacks: ["shengsuanyun/anthropic/claude-sonnet-5"],
primary: "aicoding/claude-opus-4-8",
fallbacks: ["aicoding/claude-sonnet-5"],
},
modelCatalog: {
"shengsuanyun/anthropic/claude-opus-4.8": { alias: "Opus" },
"shengsuanyun/anthropic/claude-sonnet-5": { alias: "Sonnet" },
"aicoding/claude-opus-4-8": { alias: "Opus" },
"aicoding/claude-sonnet-5": { alias: "Sonnet" },
},
},
},
@@ -558,6 +558,52 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
},
{
name: "Shengsuanyun",
nameKey: "providerForm.presets.shengsuanyun",
websiteUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
apiKeyUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
settingsConfig: {
baseUrl: "https://router.shengsuanyun.com/api",
apiKey: "",
api: "anthropic-messages",
models: [
{
id: "anthropic/claude-opus-4.8",
name: "Claude Opus 4.8",
contextWindow: 1000000,
cost: { input: 5, output: 25 },
},
{
id: "anthropic/claude-sonnet-5",
name: "Claude Sonnet 5",
contextWindow: 1000000,
cost: { input: 3, output: 15 },
},
],
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "shengsuanyun",
icon: "shengsuanyun",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
suggestedDefaults: {
model: {
primary: "shengsuanyun/anthropic/claude-opus-4.8",
fallbacks: ["shengsuanyun/anthropic/claude-sonnet-5"],
},
modelCatalog: {
"shengsuanyun/anthropic/claude-opus-4.8": { alias: "Opus" },
"shengsuanyun/anthropic/claude-sonnet-5": { alias: "Sonnet" },
},
},
},
{
name: "SubRouter",
websiteUrl: "https://subrouter.ai",
@@ -1219,10 +1265,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
{
name: "RightCode",
websiteUrl: "https://www.right.codes",
apiKeyUrl: "https://www.right.codes/register?aff=CCSWITCH",
websiteUrl: "https://www.rightapi.ai",
apiKeyUrl: "https://www.rightapi.ai/register?aff=CCSWITCH",
settingsConfig: {
baseUrl: "https://www.right.codes/claude",
baseUrl: "https://www.rightapi.ai/claude",
apiKey: "",
api: "anthropic-messages",
models: [
@@ -1478,7 +1524,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
name: "SudoCode.chat",
websiteUrl: "https://sudocode.chat",
apiKeyUrl:
"https://sudocode.chat/register?utm_source=ccswitch&utm_medium=partner",
"https://sudocode.chat/sign-up?aff=CC-SWITCH&utm_source=cc-switch&utm_medium=sponsor&utm_campaign=ccswitch",
settingsConfig: {
baseUrl: "https://api.sudocode.chat/v1",
apiKey: "",
+51 -21
View File
@@ -359,13 +359,13 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
{
name: "PackyCode",
websiteUrl: "https://www.packyapi.com",
apiKeyUrl: "https://www.packyapi.com/register?aff=cc-switch",
websiteUrl: "https://www.packyapi.ai",
apiKeyUrl: "https://www.packyapi.ai/register?aff=cc-switch",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "PackyCode",
options: {
baseURL: "https://www.packyapi.com/v1",
baseURL: "https://www.packyapi.ai/v1",
apiKey: "",
setCacheKey: true,
},
@@ -416,13 +416,13 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
{
name: "APINebula",
websiteUrl: "https://apinebula.com",
apiKeyUrl: "https://apinebula.com/VjM74M",
websiteUrl: "https://apinebula.ai",
apiKeyUrl: "https://apinebula.ai/VjM74M",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "APINebula",
options: {
baseURL: "https://apinebula.com/v1",
baseURL: "https://apinebula.ai/v1",
apiKey: "",
setCacheKey: true,
},
@@ -560,27 +560,27 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
{
name: "Shengsuanyun",
nameKey: "providerForm.presets.shengsuanyun",
websiteUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
apiKeyUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
name: "AICoding",
websiteUrl: "https://aicoding.sh",
apiKeyUrl: "https://aicoding.sh/i/CCSWITCH",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "Shengsuanyun",
name: "AICoding",
options: {
baseURL: "https://router.shengsuanyun.com/api/v1",
baseURL: "https://api.aicoding.sh",
apiKey: "",
setCacheKey: true,
},
models: {
"anthropic/claude-opus-4.8": { name: "Claude Opus 4.8" },
"anthropic/claude-sonnet-5": { name: "Claude Sonnet 5" },
"claude-sonnet-5": { name: "Claude Sonnet 5" },
"claude-opus-4-8": { name: "Claude Opus 4.8" },
},
},
category: "aggregator",
category: "third_party",
isPartner: true,
partnerPromotionKey: "shengsuanyun",
icon: "shengsuanyun",
partnerPromotionKey: "aicoding",
icon: "aicoding",
iconColor: "#000000",
templateValues: {
apiKey: {
label: "API Key",
@@ -619,6 +619,36 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
},
{
name: "Shengsuanyun",
nameKey: "providerForm.presets.shengsuanyun",
websiteUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
apiKeyUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "Shengsuanyun",
options: {
baseURL: "https://router.shengsuanyun.com/api/v1",
apiKey: "",
setCacheKey: true,
},
models: {
"anthropic/claude-opus-4.8": { name: "Claude Opus 4.8" },
"anthropic/claude-sonnet-5": { name: "Claude Sonnet 5" },
},
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "shengsuanyun",
icon: "shengsuanyun",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "SubRouter",
websiteUrl: "https://subrouter.ai",
@@ -1009,13 +1039,13 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
{
name: "RightCode",
websiteUrl: "https://www.right.codes",
apiKeyUrl: "https://www.right.codes/register?aff=CCSWITCH",
websiteUrl: "https://www.rightapi.ai",
apiKeyUrl: "https://www.rightapi.ai/register?aff=CCSWITCH",
settingsConfig: {
npm: "@ai-sdk/openai",
name: "RightCode",
options: {
baseURL: "https://right.codes/codex/v1",
baseURL: "https://www.rightapi.ai/codex/v1",
apiKey: "",
setCacheKey: true,
},
@@ -1187,7 +1217,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
name: "SudoCode.chat",
websiteUrl: "https://sudocode.chat",
apiKeyUrl:
"https://sudocode.chat/register?utm_source=ccswitch&utm_medium=partner",
"https://sudocode.chat/sign-up?aff=CC-SWITCH&utm_source=cc-switch&utm_medium=sponsor&utm_campaign=ccswitch",
settingsConfig: {
npm: "@ai-sdk/openai",
name: "SudoCode.chat",
+6 -2
View File
@@ -1039,6 +1039,7 @@
"aigocode": "AIGoCode is an official partner of CC Switch. Register using this link and get 10% bonus credit on your first top-up!",
"rightcode": "RightCode is an official partner of CC Switch. Register using this link and get 5% bonus credit on every top-up!",
"aicodemirror": "AICodeMirror is an official partner of CC Switch. Register using this link to get 20% off!",
"aicoding": "AI Coding offers an exclusive discount for CC Switch users — 10% off your first top-up!",
"crazyrouter": "CrazyRouter offers an exclusive bonus for CC Switch users — 30% extra credit on your first top-up!",
"sssaicode": "SSAI Code offers an exclusive bonus for CC Switch users — $10 extra credit on every top-up!",
"siliconflow": "SiliconFlow is an official partner of CC Switch",
@@ -1501,7 +1502,8 @@
"claude": "Claude Code",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode"
"opencode": "OpenCode",
"grokbuild": "Grok Build"
},
"rawInputLabel": "Raw",
"dataSources": "Data Sources",
@@ -1511,7 +1513,8 @@
"codex_db": "Codex DB",
"codex_session": "Codex Session",
"gemini_session": "Gemini Session",
"opencode_session": "OpenCode Session"
"opencode_session": "OpenCode Session",
"grok_session": "Grok Build Session"
},
"sessionSync": {
"trigger": "Sync session logs",
@@ -3006,6 +3009,7 @@
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "Weekly",
"monthly": "Monthly",
"credits": "Credits",
"copilotPremium": "Premium",
"utilization": "{{value}}%",
"resetsIn": "Resets in {{time}}",
+6 -2
View File
@@ -1039,6 +1039,7 @@
"aigocode": "AIGoCode は CC Switch の公式パートナーです。このリンクから登録すると、初回チャージ時に 10% のボーナスクレジットがもらえます!",
"rightcode": "RightCode は CC Switch の公式パートナーです。このリンクから登録すると、毎回のチャージに 5% のボーナスクレジットがもらえます!",
"aicodemirror": "AICodeMirror は CC Switch の公式パートナーです。このリンクから登録すると20%オフ!",
"aicoding": "AI Coding は CC Switch ユーザー向けに特別割引を提供しています。初回チャージが 10% オフ!",
"crazyrouter": "CrazyRouter は CC Switch ユーザー向けに特別ボーナスを提供しています。初回チャージで 30% の追加クレジットがもらえます!",
"sssaicode": "SSAI Code は CC Switch ユーザー向けに特別ボーナスを提供しています。チャージごとに $10 の追加クレジットがもらえます!",
"siliconflow": "SiliconFlow は CC Switch の公式パートナーです",
@@ -1501,7 +1502,8 @@
"claude": "Claude Code",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode"
"opencode": "OpenCode",
"grokbuild": "Grok Build"
},
"rawInputLabel": "原始",
"dataSources": "データソース",
@@ -1511,7 +1513,8 @@
"codex_db": "Codex DB",
"codex_session": "Codex セッション",
"gemini_session": "Gemini セッション",
"opencode_session": "OpenCode セッション"
"opencode_session": "OpenCode セッション",
"grok_session": "Grok Build セッション"
},
"sessionSync": {
"trigger": "セッションログを同期",
@@ -3006,6 +3009,7 @@
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "週間",
"monthly": "月間",
"credits": "クレジット",
"copilotPremium": "プレミアム",
"utilization": "{{value}}%",
"resetsIn": "{{time}}後にリセット",
+5 -2
View File
@@ -1473,7 +1473,8 @@
"claude": "Claude Code",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode"
"opencode": "OpenCode",
"grokbuild": "Grok Build"
},
"rawInputLabel": "原始",
"dataSources": "資料來源",
@@ -1483,7 +1484,8 @@
"codex_db": "Codex 資料庫",
"codex_session": "Codex 工作階段日誌",
"gemini_session": "Gemini 工作階段日誌",
"opencode_session": "OpenCode 工作階段日誌"
"opencode_session": "OpenCode 工作階段日誌",
"grok_session": "Grok Build 工作階段日誌"
},
"sessionSync": {
"trigger": "同步工作階段日誌",
@@ -2978,6 +2980,7 @@
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "每週",
"monthly": "每月",
"credits": "額度",
"copilotPremium": "進階請求",
"utilization": "{{value}}%",
"resetsIn": "{{time}} 後重設",
+6 -2
View File
@@ -1039,6 +1039,7 @@
"aigocode": "AIGoCode 是 CC Switch 的官方合作伙伴,使用此链接注册首次充值时可以获得10%额度奖励!",
"rightcode": "RightCode 是 CC Switch 的官方合作伙伴,使用此链接注册每次充值均可赠送5%额外额度!",
"aicodemirror": "AICodeMirror 是 CC Switch 的官方合作伙伴,使用此链接注册可享受8折优惠!",
"aicoding": "AI Coding 为 CC Switch 的用户提供了特殊优惠,首次充值可以享受 9 折优惠!",
"crazyrouter": "CrazyRouter 为 CC Switch 的用户提供了特殊优惠,首次充值赠予 30% 额外额度!",
"sssaicode": "SSAI Code 为 CC Switch 的用户提供了特殊优惠,每次充值赠予额外 $10 额度!",
"siliconflow": "硅基流动是 CC Switch 的官方合作伙伴",
@@ -1501,7 +1502,8 @@
"claude": "Claude Code",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode"
"opencode": "OpenCode",
"grokbuild": "Grok Build"
},
"rawInputLabel": "原始",
"dataSources": "数据来源",
@@ -1511,7 +1513,8 @@
"codex_db": "Codex 数据库",
"codex_session": "Codex 会话日志",
"gemini_session": "Gemini 会话日志",
"opencode_session": "OpenCode 会话日志"
"opencode_session": "OpenCode 会话日志",
"grok_session": "Grok Build 会话日志"
},
"sessionSync": {
"trigger": "同步会话日志",
@@ -3006,6 +3009,7 @@
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "每周",
"monthly": "每月",
"credits": "额度",
"copilotPremium": "高级请求",
"utilization": "{{value}}%",
"resetsIn": "{{time}}后重置",
+2
View File
@@ -6,6 +6,8 @@ export const subscriptionApi = {
invoke("get_subscription_quota", { tool }),
getCodexOauthQuota: (accountId: string | null): Promise<SubscriptionQuota> =>
invoke("get_codex_oauth_quota", { accountId }),
getXaiOauthQuota: (accountId: string | null): Promise<SubscriptionQuota> =>
invoke("get_xai_oauth_quota", { accountId }),
getCodingPlanQuota: (
baseUrl: string,
apiKey: string,
+29 -1
View File
@@ -90,7 +90,8 @@ export function useSubscriptionQuota(
const query = useQuery({
queryKey: subscriptionKeys.quota(appId),
queryFn: () => subscriptionApi.getQuota(appId),
enabled: enabled && ["claude", "codex", "gemini"].includes(appId),
enabled:
enabled && ["claude", "codex", "gemini", "grokbuild"].includes(appId),
refetchInterval,
refetchIntervalInBackground: Boolean(refetchInterval),
refetchOnWindowFocus: Boolean(refetchInterval),
@@ -138,3 +139,30 @@ export function useCodexOauthQuota(
return useQuotaKeepLastGood(query, accountId ?? "default");
}
/**
* xAI OAuth (SuperGrok ) hook
*
* `useCodexOauthQuota` cc-switch xAI OAuth token
* Grok CLI ~/.grok/auth.json grok.com
* Grok Build
*/
export function useXaiOauthQuota(
meta: ProviderMeta | undefined,
options: UseCodexOauthQuotaOptions = {},
) {
const { enabled = true, autoQuery = false } = options;
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.XAI_OAUTH);
const query = useQuery({
queryKey: ["xai_oauth", "quota", accountId ?? "default"],
queryFn: () => subscriptionApi.getXaiOauthQuota(accountId),
enabled,
refetchInterval: autoQuery ? REFETCH_INTERVAL : false,
refetchIntervalInBackground: autoQuery,
refetchOnWindowFocus: autoQuery,
staleTime: REFETCH_INTERVAL,
retry: 1,
});
return useQuotaKeepLastGood(query, accountId ?? "default");
}