mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 00:35:32 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccda04bfa6 | |||
| 2b2f2cfad9 | |||
| 708b38791c | |||
| 934a2d0348 | |||
| bc7c82228b | |||
| b972f0a3bd | |||
| b0482320a7 | |||
| 9cf4ae41e6 | |||
| 876e9f898d | |||
| 414b71500c | |||
| 878c26f31e | |||
| 6c9d444c8a | |||
| 34cbb375f0 | |||
| cd161f4401 | |||
| 3cf84ca362 | |||
| 15d5dbe065 |
@@ -0,0 +1,222 @@
|
||||
# Mirror release assets to Cloudflare R2 so ccswitch.io/download can serve
|
||||
# them directly (dl.ccswitch.io), and mirror the Tauri updater manifest and
|
||||
# artifacts so in-app updates avoid GitHub too (the updater verifies minisign
|
||||
# signatures client-side, so the mirror is untrusted).
|
||||
#
|
||||
# Triggered on release promotion (`released` fires when a prerelease is
|
||||
# flipped to a full release, or a release is published as stable directly) —
|
||||
# NOT on tag push. This keeps the R2 root manifests behind the same gate as
|
||||
# GitHub's /releases/latest/: while a release sits as prerelease, neither the
|
||||
# download page nor the updater sees it. Additionally, a run only touches the
|
||||
# root manifests (and prunes old versions) when its tag IS releases/latest —
|
||||
# manual backfills of older tags restore versioned files only.
|
||||
#
|
||||
# Caveat (by design of tauri-plugin-updater): the updater stops at the first
|
||||
# endpoint whose response parses, so a stale-but-valid R2 manifest would mask
|
||||
# newer GitHub releases. That is why this sync hard-fails on the official
|
||||
# repo when the R2 secrets are missing, instead of silently skipping.
|
||||
#
|
||||
# Required secrets: R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY
|
||||
name: Sync release to R2
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
# Manual recovery/backfill: re-sync any existing release tag.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to sync (e.g. v3.18.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# latest.json / manifest.json are last-writer-wins — serialize runs and keep
|
||||
# every trigger queued instead of the default replace-the-pending behavior.
|
||||
concurrency:
|
||||
group: sync-r2
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
sync-to-r2:
|
||||
name: Sync release to R2
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
R2_BUCKET: cc-switch-releases
|
||||
R2_PUBLIC_BASE_URL: https://dl.ccswitch.io
|
||||
KEEP_VERSIONS: "5"
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
# aws-cli >= 2.23 defaults to CRC checksums that R2 rejects.
|
||||
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
|
||||
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
|
||||
R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
|
||||
TAG: ${{ github.event.release.tag_name || inputs.tag }}
|
||||
steps:
|
||||
- name: Check R2 secrets
|
||||
id: r2
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
run: |
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "::error::No release tag to sync."
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$R2_ACCOUNT_ID" ] && [ -n "$AWS_ACCESS_KEY_ID" ] && [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
|
||||
echo "configured=true" >> "$GITHUB_OUTPUT"
|
||||
elif [ "$GITHUB_REPOSITORY" = "farion1231/cc-switch" ]; then
|
||||
# A silently stale mirror strands updater users on old versions
|
||||
# (the R2 endpoint wins as long as its manifest parses), so the
|
||||
# official repo must never skip this sync.
|
||||
echo "::error::R2 secrets (R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY) are missing on the official repo; refusing to skip the mirror sync."
|
||||
exit 1
|
||||
else
|
||||
echo "configured=false" >> "$GITHUB_OUTPUT"
|
||||
echo "R2 secrets not configured; skipping download mirror sync."
|
||||
fi
|
||||
|
||||
# Only the tag that IS GitHub's releases/latest may touch the bucket-root
|
||||
# manifests or prune old versions. This keeps manual backfills of old
|
||||
# tags harmless (they only restore versioned files — otherwise the root
|
||||
# manifests would point at a version the prune step deletes right after)
|
||||
# and makes out-of-order runs self-correcting: whichever run executes,
|
||||
# only the true latest publishes the manifests.
|
||||
- name: Check whether tag is the latest stable release
|
||||
id: latest
|
||||
if: steps.r2.outputs.configured == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# An API failure must fail the run — mapping it to "not latest"
|
||||
# would skip the root manifests on a green run, silently leaving
|
||||
# them stale (the exact failure mode this workflow exists to make
|
||||
# loud). Retry transient errors, then give up loudly.
|
||||
latest=""
|
||||
for attempt in 1 2 3 4; do
|
||||
latest=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name || true)
|
||||
if [ -n "$latest" ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -lt 4 ]; then
|
||||
sleep $((attempt * 10))
|
||||
fi
|
||||
done
|
||||
if [ -z "$latest" ]; then
|
||||
echo "::error::Could not resolve the latest stable release from GitHub after 4 attempts; failing instead of guessing."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$TAG" = "$latest" ]; then
|
||||
echo "is_latest=true" >> "$GITHUB_OUTPUT"
|
||||
echo "$TAG is the latest stable release; full sync."
|
||||
else
|
||||
echo "is_latest=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::$TAG is not the latest stable release ($latest); syncing versioned files only — root manifests and pruning are skipped."
|
||||
fi
|
||||
|
||||
- name: Checkout scripts
|
||||
if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true'
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
|
||||
- name: Download release assets
|
||||
if: steps.r2.outputs.configured == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p r2-assets
|
||||
gh release download "$TAG" --dir r2-assets --repo "$GITHUB_REPOSITORY"
|
||||
ls -la r2-assets
|
||||
|
||||
- name: Generate download manifest
|
||||
if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pub_date=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json publishedAt --jq .publishedAt)
|
||||
node scripts/generate-download-manifest.mjs r2-assets "$TAG" "$R2_PUBLIC_BASE_URL" manifest.json "$pub_date"
|
||||
|
||||
- name: Rewrite updater manifest for R2
|
||||
if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/rewrite-updater-manifest.mjs r2-assets/latest.json "$TAG" "$R2_PUBLIC_BASE_URL" latest-r2.json
|
||||
|
||||
- name: Upload versioned assets to R2
|
||||
if: steps.r2.outputs.configured == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Versioned asset paths never change — cache hard at the edge.
|
||||
# .tar.gz is the macOS updater payload; .sig files are not needed
|
||||
# (signatures are embedded in latest.json) and latest.json goes to
|
||||
# the bucket root with a short TTL instead.
|
||||
aws s3 cp r2-assets "s3://$R2_BUCKET/$TAG/" \
|
||||
--recursive \
|
||||
--exclude "*.sig" --exclude "latest.json" \
|
||||
--cache-control "public, max-age=31536000, immutable" \
|
||||
--endpoint-url "$R2_ENDPOINT"
|
||||
|
||||
- name: Publish root manifests to R2
|
||||
if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Re-verify right before touching the root manifests: a newer
|
||||
# release may have been promoted while assets were downloading.
|
||||
# Skipping then is safe — the newer release's own queued run
|
||||
# publishes the newer manifests. An unresolvable API is still a
|
||||
# hard failure, never a guess.
|
||||
latest=""
|
||||
for attempt in 1 2 3 4; do
|
||||
latest=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name || true)
|
||||
if [ -n "$latest" ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -lt 4 ]; then
|
||||
sleep $((attempt * 10))
|
||||
fi
|
||||
done
|
||||
if [ -z "$latest" ]; then
|
||||
echo "::error::Could not re-verify the latest stable release before publishing root manifests; failing instead of guessing."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$TAG" != "$latest" ]; then
|
||||
echo "::warning::$TAG stopped being the latest stable release mid-run ($latest is); leaving root manifests untouched."
|
||||
exit 0
|
||||
fi
|
||||
# Both manifests are replaced on every release — keep edge cache
|
||||
# short. Uploaded after the assets they reference so an aborted run
|
||||
# never leaves them pointing at missing files.
|
||||
aws s3 cp manifest.json "s3://$R2_BUCKET/manifest.json" \
|
||||
--content-type "application/json" \
|
||||
--cache-control "public, max-age=300" \
|
||||
--endpoint-url "$R2_ENDPOINT"
|
||||
aws s3 cp latest-r2.json "s3://$R2_BUCKET/latest.json" \
|
||||
--content-type "application/json" \
|
||||
--cache-control "public, max-age=300" \
|
||||
--endpoint-url "$R2_ENDPOINT"
|
||||
|
||||
- name: Prune old versions
|
||||
if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
versions=$(aws s3 ls "s3://$R2_BUCKET/" --endpoint-url "$R2_ENDPOINT" \
|
||||
| awk '$1 == "PRE" {print $2}' | tr -d '/' | grep -E '^v[0-9]' | sort -V || true)
|
||||
count=$(printf '%s\n' "$versions" | grep -c . || true)
|
||||
if [ "$count" -le "$KEEP_VERSIONS" ]; then
|
||||
echo "Nothing to prune ($count versions, keeping up to $KEEP_VERSIONS)."
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$versions" | head -n "-$KEEP_VERSIONS" | while read -r old; do
|
||||
[ -n "$old" ] || continue
|
||||
echo "Pruning s3://$R2_BUCKET/$old/"
|
||||
aws s3 rm "s3://$R2_BUCKET/$old/" --recursive --endpoint-url "$R2_ENDPOINT"
|
||||
done
|
||||
@@ -35,8 +35,8 @@ Doing mostly coding work? Try the **[Kimi Code subscription](https://www.kimi.co
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during first recharge to get 10% off.</td>
|
||||
<td width="180"><a href="https://www.packyapi.ai/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.ai/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during first recharge to get 10% off.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -45,14 +45,14 @@ Doing mostly coding work? Try the **[Kimi Code subscription](https://www.kimi.co
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apinebula.com/VjM74M"><img src="assets/partners/logos/apinebula_banner.png" alt="APINebula" width="150"></a></td>
|
||||
<td>Thanks to APINEBULA for sponsoring this project! APINEBULA, an enterprise-grade AI aggregation platform under Galaxy Video Bureau, leverages extensive platform resources to provide developers, teams, and enterprises with stable, cost-effective access to large language model APIs. The platform integrates leading, full-powered models like Claude, GPT, and Gemini, allowing you to connect to the world's top AI models through a single API, with prices starting as low as 10% of the original cost. Designed for AI programming, Agent development, and business system integration, APINEBULA supports enterprise-grade high concurrency, formal contracts, corporate bank transfers, and invoicing services. APINEBULA provides special discounts for our software users: register using <a href="https://apinebula.com/VjM74M">this link</a> and enter the <strong>"ccswitch"</strong> promo code during your first recharge to get <strong>10% off</strong>.</td>
|
||||
<td width="180"><a href="https://apinebula.ai/VjM74M"><img src="assets/partners/logos/apinebula_banner.png" alt="APINebula" width="150"></a></td>
|
||||
<td>Thanks to APINEBULA for sponsoring this project! APINEBULA, an enterprise-grade AI aggregation platform under Galaxy Video Bureau, leverages extensive platform resources to provide developers, teams, and enterprises with stable, cost-effective access to large language model APIs. The platform integrates leading, full-powered models like Claude, GPT, and Gemini, allowing you to connect to the world's top AI models through a single API, with prices starting as low as 10% of the original cost. Designed for AI programming, Agent development, and business system integration, APINEBULA supports enterprise-grade high concurrency, formal contracts, corporate bank transfers, and invoicing services. APINEBULA provides special discounts for our software users: register using <a href="https://apinebula.ai/VjM74M">this link</a> and enter the <strong>"ccswitch"</strong> promo code during your first recharge to get <strong>10% off</strong>.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support.
|
||||
Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CC Switch users: register via <a href="https://www.aicodemirror.com/register?invitecode=9915W3">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
|
||||
Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CC Switch users: register via <a href="https://www.aicodemirror.ai/register?invitecode=9915W3">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -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://aicoding.inc/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>Thanks to AICoding for sponsoring this project! AICoding — Global AI Model API Relay Service at Unbeatable Prices! Claude Code at 19% of original price, GPT at just 1%! Trusted by hundreds of enterprises for cost-effective AI services. Supports Claude Code, GPT, Gemini and major domestic models, with enterprise-grade high concurrency, fast invoicing, and 24/7 dedicated technical support. CC Switch users who register via <a href="https://aicoding.inc/i/CCSWITCH">this link</a> get 10% off their first top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<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>
|
||||
|
||||
+15
-10
@@ -35,8 +35,8 @@ Hauptsächlich mit Programmierung beschäftigt? Probieren Sie das **[Kimi-Code-A
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>Danke an PackyCode für die Unterstützung dieses Projekts! PackyCode ist ein zuverlässiger und effizienter API-Relay-Anbieter, der Relay-Dienste für Claude Code, Codex, Gemini und mehr bereitstellt. PackyCode bietet Sonderrabatte für Nutzer unserer Software: Registrieren Sie sich über <a href="https://www.packyapi.com/register?aff=cc-switch">diesen Link</a> und geben Sie beim ersten Aufladen den Gutscheincode „cc-switch" ein, um 10 % Rabatt zu erhalten.</td>
|
||||
<td width="180"><a href="https://www.packyapi.ai/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>Danke an PackyCode für die Unterstützung dieses Projekts! PackyCode ist ein zuverlässiger und effizienter API-Relay-Anbieter, der Relay-Dienste für Claude Code, Codex, Gemini und mehr bereitstellt. PackyCode bietet Sonderrabatte für Nutzer unserer Software: Registrieren Sie sich über <a href="https://www.packyapi.ai/register?aff=cc-switch">diesen Link</a> und geben Sie beim ersten Aufladen den Gutscheincode „cc-switch" ein, um 10 % Rabatt zu erhalten.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -45,14 +45,14 @@ Hauptsächlich mit Programmierung beschäftigt? Probieren Sie das **[Kimi-Code-A
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apinebula.com/VjM74M"><img src="assets/partners/logos/apinebula_banner.png" alt="APINebula" width="150"></a></td>
|
||||
<td>Danke an APINEBULA für die Unterstützung dieses Projekts! APINEBULA ist eine Enterprise-KI-Aggregationsplattform unter Galaxy Video Bureau und nutzt umfangreiche Plattformressourcen, um Entwicklern, Teams und Unternehmen stabilen und kosteneffizienten Zugang zu APIs großer Sprachmodelle zu bieten. Die Plattform bündelt führende vollwertige Modelle wie Claude, GPT und Gemini. Über eine einzige API erhalten Sie Zugriff auf weltweit führende KI-Modelle, mit Preisen ab 10 % des Originalpreises. APINEBULA ist für KI-Programmierung, Agent-Entwicklung und Integration in Geschäftssysteme ausgelegt und unterstützt enterprise-grade hohe Parallelität, formale Verträge, Firmenüberweisungen und Rechnungsstellung. APINEBULA bietet Nutzern dieser Software besondere Rabatte: Registrieren Sie sich über <a href="https://apinebula.com/VjM74M">diesen Link</a> und geben Sie beim ersten Aufladen den Aktionscode <strong>"ccswitch"</strong> ein, um <strong>10 % Rabatt</strong> zu erhalten.</td>
|
||||
<td width="180"><a href="https://apinebula.ai/VjM74M"><img src="assets/partners/logos/apinebula_banner.png" alt="APINebula" width="150"></a></td>
|
||||
<td>Danke an APINEBULA für die Unterstützung dieses Projekts! APINEBULA ist eine Enterprise-KI-Aggregationsplattform unter Galaxy Video Bureau und nutzt umfangreiche Plattformressourcen, um Entwicklern, Teams und Unternehmen stabilen und kosteneffizienten Zugang zu APIs großer Sprachmodelle zu bieten. Die Plattform bündelt führende vollwertige Modelle wie Claude, GPT und Gemini. Über eine einzige API erhalten Sie Zugriff auf weltweit führende KI-Modelle, mit Preisen ab 10 % des Originalpreises. APINEBULA ist für KI-Programmierung, Agent-Entwicklung und Integration in Geschäftssysteme ausgelegt und unterstützt enterprise-grade hohe Parallelität, formale Verträge, Firmenüberweisungen und Rechnungsstellung. APINEBULA bietet Nutzern dieser Software besondere Rabatte: Registrieren Sie sich über <a href="https://apinebula.ai/VjM74M">diesen Link</a> und geben Sie beim ersten Aufladen den Aktionscode <strong>"ccswitch"</strong> ein, um <strong>10 % Rabatt</strong> zu erhalten.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>Danke an AICodeMirror für die Unterstützung dieses Projekts! AICodeMirror stellt offizielle, hochstabile Relay-Dienste für Claude Code / Codex / Gemini CLI bereit, mit unternehmensgerechter Nebenläufigkeit, schneller Rechnungsstellung und rund um die Uhr verfügbarem dediziertem technischem Support.
|
||||
Offizielle Kanäle von Claude Code / Codex / Gemini zu 38 % / 2 % / 9 % des Originalpreises, mit zusätzlichen Rabatten beim Aufladen! AICodeMirror bietet besondere Vorteile für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://www.aicodemirror.com/register?invitecode=9915W3">diesen Link</a> und erhalten Sie 20 % Rabatt auf Ihre erste Aufladung; Unternehmenskunden erhalten bis zu 25 % Rabatt!</td>
|
||||
Offizielle Kanäle von Claude Code / Codex / Gemini zu 38 % / 2 % / 9 % des Originalpreises, mit zusätzlichen Rabatten beim Aufladen! AICodeMirror bietet besondere Vorteile für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://www.aicodemirror.ai/register?invitecode=9915W3">diesen Link</a> und erhalten Sie 20 % Rabatt auf Ihre erste Aufladung; Unternehmenskunden erhalten bis zu 25 % Rabatt!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -87,6 +87,11 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
||||
<td>Danke an AIGoCode für die Unterstützung dieses Projekts! AIGoCode ist eine All-in-One-Plattform, die Claude Code, Codex und die neuesten Gemini-Modelle integriert und Ihnen stabile, effiziente und äußerst kostengünstige KI-Coding-Dienste bietet. Die Plattform stellt flexible Abonnementpläne bereit, birgt kein Risiko einer Kontosperrung, ermöglicht Direktzugriff ohne VPN und reagiert blitzschnell. AIGoCode hat ein besonderes Angebot für CC-Switch-Nutzer vorbereitet: Wenn Sie sich über <a href="https://aigocode.com/invite/CC-SWITCH">diesen Link</a> registrieren, erhalten Sie bei Ihrer ersten Aufladung zusätzliche 10 % Bonusguthaben!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aicoding.inc/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>Danke an AICoding für die Unterstützung dieses Projekts! AICoding — globaler AI-Modell-API-Relay-Dienst zu unschlagbaren Preisen! Claude Code für 19 % des Originalpreises, GPT für nur 1 %! Hunderte Unternehmen vertrauen auf diese kostengünstigen KI-Dienste. Unterstützt Claude Code, GPT, Gemini und führende chinesische Modelle — mit hoher Parallelität auf Unternehmensniveau, schneller Rechnungsstellung und persönlichem technischem Support rund um die Uhr. CC-Switch-Nutzer, die sich über <a href="https://aicoding.inc/i/CCSWITCH">diesen Link</a> registrieren, erhalten 10 % Rabatt auf ihre erste Aufladung!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<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>Danke an SubRouter für die Unterstützung dieses Projekts! SubRouter ist ein Marktplatz und eine intelligente Routing-Plattform für Betreiber von KI-Diensten. Händler können eigene Betriebsseiten starten, Pakete veröffentlichen sowie Nutzer, Modelle und Preise verwalten, während Nutzer im Marktplatz Dienste entdecken und über eine einzige einheitliche API zuverlässige und effiziente Modellaufrufe nutzen. Registrieren Sie sich über <a href="https://subrouter.ai/register?aff=l3ri">diesen Link</a>!</td>
|
||||
@@ -98,8 +103,8 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/register?aff=pCLD"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
|
||||
<td>Dieses Projekt wird von <a href="https://console.claudeapi.com/register?aff=pCLD">Claude API</a> gesponsert. Direkter Claude-API-Zugriff — verbinden Sie Claude Code und Agent-Apps in 3 Minuten. Neukunden können ein kostenloses Testguthaben einlösen. Betrieben mit offiziellen Anthropic-API-Schlüsseln + offiziellen AWS-Bedrock-Kanälen. Kein Reverse Engineering, keine Modellverschlechterung. Volle Unterstützung der Modellreihe Opus / Sonnet / Haiku, mit erhaltenen offiziellen Fähigkeiten einschließlich Tool Use, 1M-Kontextfenster und mehr. Entwickelt für Claude-Code-Power-User, Agent-Ingenieure und technische Unternehmensteams. Rechnungsstellung und dedizierter Team-Support verfügbar. Klicken Sie <a href="https://console.claudeapi.com/register?aff=pCLD">hier</a>, um sich zu registrieren!</td>
|
||||
<td width="180"><a href="https://console.apito.ai/agent/register/pQBql2buaqiX3dDS"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
|
||||
<td>Dieses Projekt wird von <a href="https://console.apito.ai/agent/register/pQBql2buaqiX3dDS">Claude API</a> gesponsert. Direkter Claude-API-Zugriff — verbinden Sie Claude Code und Agent-Apps in 3 Minuten. Neukunden können ein kostenloses Testguthaben einlösen. Betrieben mit offiziellen Anthropic-API-Schlüsseln + offiziellen AWS-Bedrock-Kanälen. Kein Reverse Engineering, keine Modellverschlechterung. Volle Unterstützung der Modellreihe Opus / Sonnet / Haiku, mit erhaltenen offiziellen Fähigkeiten einschließlich Tool Use, 1M-Kontextfenster und mehr. Entwickelt für Claude-Code-Power-User, Agent-Ingenieure und technische Unternehmensteams. Rechnungsstellung und dedizierter Team-Support verfügbar. Klicken Sie <a href="https://console.apito.ai/agent/register/pQBql2buaqiX3dDS">hier</a>, um sich zu registrieren!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -165,8 +170,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
@@ -35,8 +35,8 @@ Kimi K3 は Moonshot AI がこれまでに開発した中で最も高性能な
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>
|
||||
<td width="180"><a href="https://www.packyapi.ai/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.ai/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -45,14 +45,14 @@ Kimi K3 は Moonshot AI がこれまでに開発した中で最も高性能な
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apinebula.com/VjM74M"><img src="assets/partners/logos/apinebula_banner.png" alt="APINebula" width="150"></a></td>
|
||||
<td>本プロジェクトは「APINEBULA」のスポンサーシップにより運営されています!APINEBULA は、「銀河録像局」傘下のエンタープライズ向け AI 統合プラットフォームです。大手の豊富なリソースを背景に、開発者、チーム、そして企業ユーザーの皆様へ、安定性とコストパフォーマンスに優れた大規模言語モデル(LLM)の API 連携サービスを提供しています。Claude、GPT、Gemini をはじめとする世界中の主要なフルスペック(満血)モデルを 1 つの API に集約。世界トップクラスの AI モデルを、<strong>最大 90% OFF(元の価格の 1 割〜)</strong>という圧倒的な低価格でご利用いただけます。また、企業向けの高度な並行処理(高コンカレンシー)、正式な契約締結、法人口座振り込み、請求書・領収書発行など、ビジネス利用に必要なサポートも万全です。AI プログラミング、AI エージェント開発、業務システムへの統合など、様々なシーンに最適です。<a href="https://apinebula.com/VjM74M">こちらのリンク</a>から登録し、チャージ時にプロモコード <strong>「ccswitch」を入力すると、さらに 10% OFF</strong> の割引特典が適用されます!</td>
|
||||
<td width="180"><a href="https://apinebula.ai/VjM74M"><img src="assets/partners/logos/apinebula_banner.png" alt="APINebula" width="150"></a></td>
|
||||
<td>本プロジェクトは「APINEBULA」のスポンサーシップにより運営されています!APINEBULA は、「銀河録像局」傘下のエンタープライズ向け AI 統合プラットフォームです。大手の豊富なリソースを背景に、開発者、チーム、そして企業ユーザーの皆様へ、安定性とコストパフォーマンスに優れた大規模言語モデル(LLM)の API 連携サービスを提供しています。Claude、GPT、Gemini をはじめとする世界中の主要なフルスペック(満血)モデルを 1 つの API に集約。世界トップクラスの AI モデルを、<strong>最大 90% OFF(元の価格の 1 割〜)</strong>という圧倒的な低価格でご利用いただけます。また、企業向けの高度な並行処理(高コンカレンシー)、正式な契約締結、法人口座振り込み、請求書・領収書発行など、ビジネス利用に必要なサポートも万全です。AI プログラミング、AI エージェント開発、業務システムへの統合など、様々なシーンに最適です。<a href="https://apinebula.ai/VjM74M">こちらのリンク</a>から登録し、チャージ時にプロモコード <strong>「ccswitch」を入力すると、さらに 10% OFF</strong> の割引特典が適用されます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定リレーサービスを提供しており、エンタープライズ級の同時接続、迅速な請求書発行、24時間年中無休の専用テクニカルサポートを備えています。
|
||||
Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / 2% / 9%、チャージ時にはさらに割引!AICodeMirror は CC Switch ユーザー向けに特別特典を用意:<a href="https://www.aicodemirror.com/register?invitecode=9915W3">このリンク</a>から登録すると初回チャージ 20% オフ、法人のお客様は最大 25% オフ!</td>
|
||||
Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / 2% / 9%、チャージ時にはさらに割引!AICodeMirror は CC Switch ユーザー向けに特別特典を用意:<a href="https://www.aicodemirror.ai/register?invitecode=9915W3">このリンク</a>から登録すると初回チャージ 20% オフ、法人のお客様は最大 25% オフ!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -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://aicoding.inc/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>AICoding のご支援に感謝します!AICoding —— グローバル AI モデル API 超お得な中継サービス!Claude Code 81% オフ、GPT 99% オフ!数百社の企業に高コストパフォーマンスの AI サービスを提供。Claude Code、GPT、Gemini および国内主要モデルに対応、エンタープライズ級の高同時接続、迅速な請求書発行、24 時間年中無休の専属テクニカルサポート。<a href="https://aicoding.inc/i/CCSWITCH">こちらのリンク</a>から登録した CC Switch ユーザーは、初回チャージ 10% オフ!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<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
@@ -35,8 +35,8 @@ Kimi K3 是 Moonshot AI 迄今能力最强的模型,也是全球首个开源 3
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,首次充值可以享受9折优惠!</td>
|
||||
<td width="180"><a href="https://www.packyapi.ai/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.ai/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,首次充值可以享受9折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -45,14 +45,14 @@ Kimi K3 是 Moonshot AI 迄今能力最强的模型,也是全球首个开源 3
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apinebula.com/VjM74M"><img src="assets/partners/logos/apinebula_banner.png" alt="APINebula" width="150"></a></td>
|
||||
<td>感谢 APINEBULA 赞助本项目!APINEBULA 是银河录像局旗下的企业级 AI 聚合平台,背靠大平台资源,面向开发者、团队与企业用户提供稳定、高性价比的大模型 API 接入服务。平台聚合 Claude、GPT、Gemini 等主流满血模型,一个接口,接入全球顶尖 AI 大模型,各大模型价格低至 1 折起,支持企业级高并发、正式合同、对公打款与开票服务,适合 AI 编程、Agent 开发、业务系统集成等多种场景!使用<a href="https://apinebula.com/VjM74M">此链接</a>注册并在充值时填写 <strong>"ccswitch"</strong> 优惠码可享<strong>九折优惠</strong>!</td>
|
||||
<td width="180"><a href="https://apinebula.ai/VjM74M"><img src="assets/partners/logos/apinebula_banner.png" alt="APINebula" width="150"></a></td>
|
||||
<td>感谢 APINEBULA 赞助本项目!APINEBULA 是银河录像局旗下的企业级 AI 聚合平台,背靠大平台资源,面向开发者、团队与企业用户提供稳定、高性价比的大模型 API 接入服务。平台聚合 Claude、GPT、Gemini 等主流满血模型,一个接口,接入全球顶尖 AI 大模型,各大模型价格低至 1 折起,支持企业级高并发、正式合同、对公打款与开票服务,适合 AI 编程、Agent 开发、业务系统集成等多种场景!使用<a href="https://apinebula.ai/VjM74M">此链接</a>注册并在充值时填写 <strong>"ccswitch"</strong> 优惠码可享<strong>九折优惠</strong>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。
|
||||
Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CCSwitch 的用户提供了特别福利,通过<a href="https://www.aicodemirror.com/register?invitecode=9915W3">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
|
||||
Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CCSwitch 的用户提供了特别福利,通过<a href="https://www.aicodemirror.ai/register?invitecode=9915W3">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -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://aicoding.inc/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>感谢 AICoding 赞助了本项目!AICoding —— 全球大模型 API 超值中转服务!Claude Code 1.9 折,GPT 0.1 折,已为数百家企业提供高性价比 AI 服务。支持 Claude Code、GPT、Gemini 及国内主流模型,企业级高并发、极速开票、7×24 专属技术支持,通过<a href="https://aicoding.inc/i/CCSWITCH">此链接</a> 注册的 CC Switch 用户,首充可享受九折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<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,77 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates the website download manifest (manifest.json) from a directory of
|
||||
// downloaded release assets. Consumed by ccswitch.io/download. The manifest
|
||||
// schema is mirrored in cc-switch-website/src/lib/downloads.ts — keep both in
|
||||
// sync when changing fields or classification rules.
|
||||
//
|
||||
// Usage: node scripts/generate-download-manifest.mjs <assets-dir> <tag> <base-url> [output] [pub-date]
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const [assetsDir, tag, baseUrl, output = 'manifest.json', pubDateArg] = process.argv.slice(2);
|
||||
|
||||
if (!assetsDir || !tag || !baseUrl) {
|
||||
console.error('Usage: node scripts/generate-download-manifest.mjs <assets-dir> <tag> <base-url> [output] [pub-date]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Prefer the release's real publishedAt (passed by CI) over generation time.
|
||||
const pubDate = pubDateArg ? new Date(pubDateArg) : new Date();
|
||||
if (Number.isNaN(pubDate.getTime())) {
|
||||
console.error(`Invalid pub-date: ${pubDateArg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Longer suffixes must come before their shorter counterparts
|
||||
// (e.g. -Windows-arm64.msi before -Windows.msi).
|
||||
const RULES = [
|
||||
{ suffix: '-macOS.dmg', platform: 'macos', kind: 'dmg', arch: 'universal' },
|
||||
{ suffix: '-macOS.zip', platform: 'macos', kind: 'zip', arch: 'universal' },
|
||||
{ suffix: '-Windows-arm64-Portable.zip', platform: 'windows', kind: 'portable', arch: 'arm64' },
|
||||
{ suffix: '-Windows-Portable.zip', platform: 'windows', kind: 'portable', arch: 'x64' },
|
||||
{ suffix: '-Windows-arm64.msi', platform: 'windows', kind: 'msi', arch: 'arm64' },
|
||||
{ suffix: '-Windows.msi', platform: 'windows', kind: 'msi', arch: 'x64' },
|
||||
{ suffix: '-Linux-arm64.AppImage', platform: 'linux', kind: 'appimage', arch: 'arm64' },
|
||||
{ suffix: '-Linux-x86_64.AppImage', platform: 'linux', kind: 'appimage', arch: 'x64' },
|
||||
{ suffix: '-Linux-arm64.deb', platform: 'linux', kind: 'deb', arch: 'arm64' },
|
||||
{ suffix: '-Linux-x86_64.deb', platform: 'linux', kind: 'deb', arch: 'x64' },
|
||||
{ suffix: '-Linux-arm64.rpm', platform: 'linux', kind: 'rpm', arch: 'arm64' },
|
||||
{ suffix: '-Linux-x86_64.rpm', platform: 'linux', kind: 'rpm', arch: 'x64' },
|
||||
];
|
||||
|
||||
const normalizedBase = baseUrl.replace(/\/+$/, '');
|
||||
const files = [];
|
||||
|
||||
for (const name of readdirSync(assetsDir).sort()) {
|
||||
// Unmatched files (.sig, .tar.gz updater artifacts, latest.json) are
|
||||
// deliberately skipped — they are not user-facing downloads.
|
||||
const rule = RULES.find((entry) => name.endsWith(entry.suffix));
|
||||
if (!rule) continue;
|
||||
const path = join(assetsDir, name);
|
||||
files.push({
|
||||
platform: rule.platform,
|
||||
kind: rule.kind,
|
||||
arch: rule.arch,
|
||||
name,
|
||||
size: statSync(path).size,
|
||||
sha256: createHash('sha256').update(readFileSync(path)).digest('hex'),
|
||||
url: `${normalizedBase}/${tag}/${encodeURIComponent(name)}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error(`No release assets matched in ${assetsDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: tag.replace(/^v/, ''),
|
||||
tag,
|
||||
pubDate: pubDate.toISOString(),
|
||||
files,
|
||||
};
|
||||
|
||||
writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(`Wrote ${output} with ${files.length} files for ${tag}`);
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
// Rewrites the Tauri updater manifest (latest.json) so its download URLs point
|
||||
// at the R2 mirror instead of GitHub Releases. Minisign signatures cover file
|
||||
// contents, not URLs, so they stay valid unchanged — the updater still verifies
|
||||
// every downloaded artifact against the pubkey baked into the app.
|
||||
//
|
||||
// Usage: node scripts/rewrite-updater-manifest.mjs <latest-json> <tag> <base-url> [output]
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const [input, tag, baseUrl, output = 'latest-r2.json'] = process.argv.slice(2);
|
||||
|
||||
if (!input || !tag || !baseUrl) {
|
||||
console.error('Usage: node scripts/rewrite-updater-manifest.mjs <latest-json> <tag> <base-url> [output]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(readFileSync(input, 'utf8'));
|
||||
const platforms = Object.entries(manifest.platforms ?? {});
|
||||
if (platforms.length === 0) {
|
||||
console.error(`No platforms found in ${input}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const normalizedBase = baseUrl.replace(/\/+$/, '');
|
||||
// Mirrored artifacts live at <base>/<tag>/<file>; on GitHub they live at
|
||||
// .../releases/download/<tag>/<file>. Rebuild from the filename rather than
|
||||
// prefix-replacing so a layout change on either side fails loudly here.
|
||||
const githubPrefix = `https://github.com/`;
|
||||
|
||||
for (const [key, entry] of platforms) {
|
||||
if (typeof entry?.url !== 'string' || typeof entry?.signature !== 'string') {
|
||||
console.error(`Platform ${key} is missing url or signature`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!entry.url.startsWith(githubPrefix) || !entry.url.includes(`/releases/download/${tag}/`)) {
|
||||
console.error(`Platform ${key} has unexpected url for ${tag}: ${entry.url}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const name = entry.url.split('/').pop();
|
||||
entry.url = `${normalizedBase}/${tag}/${name}`;
|
||||
}
|
||||
|
||||
writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(`Wrote ${output} with ${platforms.length} platforms pointing at ${normalizedBase}/${tag}/`);
|
||||
Generated
+775
-767
File diff suppressed because it is too large
Load Diff
+19
-19
@@ -36,21 +36,21 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
dirs = "6.0"
|
||||
toml = "1.0"
|
||||
toml_edit = "0.25"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
|
||||
arboard = "3.6"
|
||||
flate2 = "1"
|
||||
brotli = "8"
|
||||
brotli = "7"
|
||||
zstd = "0.13"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||
futures = "0.3"
|
||||
async-stream = "0.3"
|
||||
bytes = "1.5"
|
||||
axum = "0.8"
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
axum = "0.7"
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
hyper = { version = "1.0", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["tokio", "http1", "client-legacy"] }
|
||||
hyper-rustls = { version = "0.27", features = ["http1", "tls12", "ring", "webpki-tokio"] }
|
||||
@@ -60,26 +60,26 @@ http-body-util = "0.1"
|
||||
httparse = "1"
|
||||
tokio-rustls = "0.26"
|
||||
rustls = "0.23"
|
||||
webpki-roots = "1.0"
|
||||
webpki-roots = "0.26"
|
||||
rustls-native-certs = "0.8"
|
||||
regex = "1.10"
|
||||
rquickjs = { version = "0.12", features = ["array-buffer", "classes"] }
|
||||
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
||||
thiserror = "2.0"
|
||||
anyhow = "1.0"
|
||||
zip = "4.6"
|
||||
zip = "2.2"
|
||||
serde_yaml = "0.9"
|
||||
tempfile = "3"
|
||||
url = "2.5"
|
||||
auto-launch = "0.6"
|
||||
auto-launch = "0.5"
|
||||
once_cell = "1.21.3"
|
||||
base64 = "0.23"
|
||||
rusqlite = { version = "0.40", features = ["bundled", "backup", "hooks"] }
|
||||
base64 = "0.22"
|
||||
rusqlite = { version = "0.31", features = ["bundled", "backup", "hooks"] }
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
rust_decimal = "1.33"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
sha2 = "0.11"
|
||||
hmac = "0.13"
|
||||
json5 = "1.3"
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
json5 = "0.4"
|
||||
json-five = "0.3.1"
|
||||
sys-locale = "0.3"
|
||||
|
||||
@@ -90,15 +90,15 @@ tauri-plugin-single-instance = "2"
|
||||
webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.55"
|
||||
winreg = "0.52"
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_UI_Shell"] }
|
||||
|
||||
[target.'cfg(all(target_os = "windows", target_arch = "aarch64"))'.dependencies]
|
||||
rquickjs = { version = "0.8", features = ["bindgen"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", features = ["NSColor"] }
|
||||
objc2 = "0.5"
|
||||
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
||||
|
||||
# Optimize release binary size to help reduce AppImage footprint
|
||||
[profile.release]
|
||||
|
||||
@@ -32,8 +32,8 @@ pub const ANTHROPIC_CLAUDE_ROUTE_PREFIX: &str = "anthropic/claude-";
|
||||
/// Claude Desktop schema 不接受此后缀,import 边界翻译为 `supports1m` 字段。
|
||||
pub const ONE_M_CONTEXT_MARKER: &str = "[1m]";
|
||||
|
||||
const CURRENT_OPUS_ROUTE_ID: &str = "claude-opus-4-8";
|
||||
const LEGACY_OPUS_ROUTE_ID: &str = "claude-opus-4-7";
|
||||
const CURRENT_OPUS_ROUTE_ID: &str = "claude-opus-5";
|
||||
const LEGACY_OPUS_ROUTE_ID: &str = "claude-opus-4-8";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -1989,7 +1989,7 @@ mod tests {
|
||||
.iter()
|
||||
.find(|route| route.upstream_model == "deepseek-v4-pro")
|
||||
.expect("repaired route");
|
||||
assert_eq!(repaired.route_id, "claude-opus-4-8");
|
||||
assert_eq!(repaired.route_id, "claude-opus-5");
|
||||
assert_eq!(repaired.label_override.as_deref(), Some("deepseek-v4-pro"));
|
||||
assert!(repaired.supports_1m);
|
||||
let repaired_old = routes
|
||||
|
||||
@@ -3,6 +3,7 @@ use tauri::{Emitter, Manager, State};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::commands::copilot::CopilotAuthState;
|
||||
use crate::commands::xai_oauth::XaiOAuthState;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{ClaudeDesktopMode, Provider};
|
||||
use crate::services::{
|
||||
@@ -442,6 +443,7 @@ pub async fn queryProviderUsage(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
xai_state: State<'_, XaiOAuthState>,
|
||||
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
|
||||
app: String,
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
@@ -454,8 +456,14 @@ pub async fn queryProviderUsage(
|
||||
// 不写失败快照、不 emit:保留上一份托盘快照,与前端 react-query reject
|
||||
// 保留上次 data 的语义一致;否则失败快照会经 useUsageCacheBridge 盲写
|
||||
// 回 query 缓存,抹掉 reject 本该保留的旧值。
|
||||
let inner =
|
||||
query_provider_usage_inner(&state, &copilot_state, app_type.clone(), &providerId).await;
|
||||
let inner = query_provider_usage_inner(
|
||||
&state,
|
||||
&copilot_state,
|
||||
&xai_state,
|
||||
app_type.clone(),
|
||||
&providerId,
|
||||
)
|
||||
.await;
|
||||
if let Ok(snapshot) = &inner {
|
||||
let payload = serde_json::json!({
|
||||
"kind": "script",
|
||||
@@ -521,6 +529,7 @@ fn resolve_coding_plan_credentials(
|
||||
async fn query_provider_usage_inner(
|
||||
state: &AppState,
|
||||
copilot_state: &CopilotAuthState,
|
||||
xai_state: &XaiOAuthState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
@@ -689,9 +698,19 @@ async fn query_provider_usage_inner(
|
||||
});
|
||||
}
|
||||
|
||||
let quota = crate::services::subscription::get_subscription_quota(app_type.as_str())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query subscription quota: {e}"))?;
|
||||
// xAI OAuth 托管供应商的额度属绑定的 SuperGrok 账号,而非所在 app 的
|
||||
// CLI 凭据(对 codex/claude 而言 CLI 凭据是 ChatGPT/Claude 订阅,跨了
|
||||
// 订阅体系,查出来的数字张冠李戴)。
|
||||
let quota = if provider.map(Provider::is_xai_oauth).unwrap_or(false) {
|
||||
let account_id = provider
|
||||
.and_then(|p| p.meta.as_ref())
|
||||
.and_then(|m| m.managed_account_id_for("xai_oauth"));
|
||||
crate::commands::xai_oauth::query_xai_oauth_quota_for(xai_state, account_id).await?
|
||||
} else {
|
||||
crate::services::subscription::get_subscription_quota(app_type.as_str())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query subscription quota: {e}"))?
|
||||
};
|
||||
|
||||
if !quota.success {
|
||||
return Ok(crate::provider::UsageResult {
|
||||
@@ -1079,7 +1098,7 @@ mod import_claude_desktop_tests {
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
assert_eq!(routes.len(), 3);
|
||||
assert_eq!(routes.get("claude-sonnet-5").unwrap().model, "GLM-4.6");
|
||||
assert_eq!(routes.get("claude-opus-4-8").unwrap().model, "GLM-4-Air");
|
||||
assert_eq!(routes.get("claude-opus-5").unwrap().model, "GLM-4-Air");
|
||||
assert_eq!(routes.get("claude-haiku-4-5").unwrap().model, "GLM-4-Flash");
|
||||
assert_eq!(
|
||||
routes
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -1545,6 +1545,8 @@ impl Database {
|
||||
"1.00",
|
||||
"12.50",
|
||||
),
|
||||
// Claude Opus 5(与 Opus 4.8 同价位;fast mode $10/$50 不入表)
|
||||
("claude-opus-5", "Claude Opus 5", "5", "25", "0.50", "6.25"),
|
||||
// Claude 4.8 系列
|
||||
(
|
||||
"claude-opus-4-8",
|
||||
@@ -1853,6 +1855,15 @@ impl Database {
|
||||
("gpt-4.1", "GPT-4.1", "2", "8", "0.50", "0"),
|
||||
("gpt-4.1-mini", "GPT-4.1 Mini", "0.40", "1.60", "0.10", "0"),
|
||||
("gpt-4.1-nano", "GPT-4.1 Nano", "0.10", "0.40", "0.025", "0"),
|
||||
// Gemini 3.6 系列
|
||||
(
|
||||
"gemini-3.6-flash",
|
||||
"Gemini 3.6 Flash",
|
||||
"1.50",
|
||||
"7.50",
|
||||
"0.15",
|
||||
"0",
|
||||
),
|
||||
// Gemini 3.5 系列
|
||||
(
|
||||
"gemini-3.5-flash",
|
||||
@@ -2246,6 +2257,10 @@ impl Database {
|
||||
("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
|
||||
// Grok 系列 (xAI)
|
||||
("grok-4.5", "Grok 4.5", "2", "6", "0.50", "0"),
|
||||
// Grok CLI 官方 OAuth 态 modelUsage 上报的内部别名。定价由
|
||||
// costUsdTicks(1 tick = 1e-10 USD)双轮实测反推:input/output 与
|
||||
// grok-4.5 同为 2/6,cache read 实际按 0.30 计(非 API 挂牌的 0.50)
|
||||
("grok-4.5-build", "Grok 4.5 Build", "2", "6", "0.30", "0"),
|
||||
("grok-4.3", "Grok 4.3", "1.25", "2.50", "0.20", "0"),
|
||||
(
|
||||
"grok-4.20-0309-reasoning",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:上游报图片错误也不触发兜底重试。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1505,8 +1505,11 @@ impl ProxyService {
|
||||
/// Grok Build live 是否具备可接管的自定义模型表。
|
||||
///
|
||||
/// 官方态 live(Grok CLI 自带 OAuth 登录、无 `[model.*]` 表)没有注入
|
||||
/// 占位符的落点,且官方供应商本就禁止经代理接管(封号风险),调用方
|
||||
/// 应跳过接管或直接报错。
|
||||
/// 占位符的落点:Grok CLI 以「config 是否为空」区分官方 OAuth / 自定义
|
||||
/// 供应商两种模式,表达不出「官方 OAuth + 自定义 base_url」。官方供应商
|
||||
/// 的接管能力门见 `official_provider_supports_proxy_takeover`(按应用逐个
|
||||
/// 开,目前仅 Codex),调用方应跳过接管或直接报错。官方态的用量统计由
|
||||
/// `session_usage_grokbuild` 从会话日志导入,不依赖代理。
|
||||
fn grok_live_config_supports_takeover(config: &Value) -> bool {
|
||||
config
|
||||
.get("config")
|
||||
|
||||
@@ -86,6 +86,11 @@ pub fn sync_all_unlocked(db: &Database) -> SessionSyncResult {
|
||||
"OpenCode",
|
||||
crate::services::session_usage_opencode::sync_opencode_usage(db),
|
||||
);
|
||||
merge_sync_step(
|
||||
&mut result,
|
||||
"Grok Build",
|
||||
crate::services::session_usage_grokbuild::sync_grokbuild_usage(db),
|
||||
);
|
||||
notify_sync_result(&result);
|
||||
result
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,16 @@
|
||||
/// style provider not added here) shows up loudly as a too-low cache hit
|
||||
/// rate, which is easier to catch than the silent over-deduction that
|
||||
/// would happen with the opposite default.
|
||||
const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini", "grokbuild"];
|
||||
/// 单一语义集(SSOT):写入侧(proxy logger/calculator)、回填侧
|
||||
/// (usage_stats 成本重算)与展示侧(本文件的 SQL 归一)都必须引用这里,
|
||||
/// 防止同一语义散落多处后新增 app 时漏改(grokbuild 曾在回填侧漏掉)。
|
||||
/// 前端 `src/types/usage.ts` 的同名常量是跨语言的对应物,改动须同步。
|
||||
pub(crate) const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini", "grokbuild"];
|
||||
|
||||
/// `app_type` 的存储 `input_tokens` 是否已包含 cache read/write。
|
||||
pub(crate) fn is_cache_inclusive_app(app_type: &str) -> bool {
|
||||
CACHE_INCLUSIVE_APP_TYPES.contains(&app_type)
|
||||
}
|
||||
|
||||
pub(crate) const INPUT_TOKEN_SEMANTICS_LEGACY: i64 = 0;
|
||||
pub(crate) const INPUT_TOKEN_SEMANTICS_TOTAL: i64 = 1;
|
||||
|
||||
@@ -318,6 +318,12 @@ pub const TIER_MONTHLY: &str = "monthly";
|
||||
/// 三处共用同一标识。见 #3651。
|
||||
pub const TIER_THIRTY_DAY: &str = "30_day";
|
||||
|
||||
/// Grok credit 额度窗口的兜底 tier 名。Grok 账单接口只返回一个 credit 用量
|
||||
/// 窗口,`subscription_grok::tier_name_for_reset` 按重置距离优先映射到
|
||||
/// `weekly_limit` / `monthly`,两者都不匹配时用此标识;前端 `TIER_I18N_KEYS`
|
||||
/// 映射到 `subscription.credits`,tray 归入 "c" 分组。
|
||||
pub const TIER_CREDITS: &str = "credits";
|
||||
|
||||
/// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。
|
||||
pub const TIER_GEMINI_PRO: &str = "gemini_pro";
|
||||
pub const TIER_GEMINI_FLASH: &str = "gemini_flash";
|
||||
@@ -1340,6 +1346,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
|
||||
}
|
||||
}
|
||||
}
|
||||
"grokbuild" => crate::services::subscription_grok::get_grok_subscription_quota().await,
|
||||
_ => Ok(SubscriptionQuota::not_found(tool)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
//! Grok (xAI) 官方订阅额度查询
|
||||
//!
|
||||
//! 读取 Grok CLI 的 OAuth 凭据(~/.grok/auth.json),调用 grok.com 的
|
||||
//! gRPC-web billing 端点查询 SuperGrok 订阅的 credit 用量。
|
||||
//!
|
||||
//! 实现移植自 CodexBar(steipete/CodexBar)的 Grok provider:
|
||||
//! - 凭据:`GrokAuth.swift` —— auth.json 是以 OIDC scope URL 为 key 的 map,
|
||||
//! 优先 SuperGrok 的 `https://auth.x.ai::<client-id>` 条目,回退 legacy
|
||||
//! session 条目;`key` 字段即 Bearer token。
|
||||
//! - 查询:`GrokWebBillingFetcher.swift` —— POST 空 gRPC-web 帧到
|
||||
//! `GetGrokCreditsConfig`,响应无公开 .proto,用通用 protobuf 扫描按
|
||||
//! 字段路径启发式提取已用百分比与重置时间。
|
||||
//! - token 刷新由 Grok CLI 自己负责(约 7 天过期),本模块只读不刷新,
|
||||
//! 过期时引导用户重新 `grok login`。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::services::subscription::{
|
||||
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_CREDITS, TIER_MONTHLY, TIER_WEEKLY_LIMIT,
|
||||
};
|
||||
|
||||
const GROK_BILLING_ENDPOINT: &str =
|
||||
"https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig";
|
||||
|
||||
/// SuperGrok(OIDC)条目的 scope 前缀
|
||||
const OIDC_SCOPE_PREFIX: &str = "https://auth.x.ai::";
|
||||
/// 旧版 `grok login` 的 session scope
|
||||
const LEGACY_SESSION_SCOPE: &str = "https://accounts.x.ai/sign-in";
|
||||
|
||||
const RELOGIN_HINT: &str = "Please re-login with `grok login`.";
|
||||
|
||||
// ── 凭据读取 ──────────────────────────────────────────────
|
||||
|
||||
/// (access_token, status, message)
|
||||
type GrokCredentials = (Option<String>, CredentialStatus, Option<String>);
|
||||
|
||||
/// 读取 Grok CLI 的 OAuth 凭据(~/.grok/auth.json,目录可被设置覆盖)
|
||||
fn read_grok_credentials() -> GrokCredentials {
|
||||
let auth_path = crate::grok_config::get_grok_config_dir().join("auth.json");
|
||||
|
||||
if !auth_path.exists() {
|
||||
return (None, CredentialStatus::NotFound, None);
|
||||
}
|
||||
|
||||
let content = match std::fs::read_to_string(&auth_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return (
|
||||
None,
|
||||
CredentialStatus::ParseError,
|
||||
Some(format!("Failed to read Grok auth file: {e}")),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
parse_grok_auth_json(&content)
|
||||
}
|
||||
|
||||
/// 解析 auth.json:顶层是 scope → 条目的 map,选出首选条目并检查过期
|
||||
fn parse_grok_auth_json(content: &str) -> GrokCredentials {
|
||||
let parsed: serde_json::Value = match serde_json::from_str(content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return (
|
||||
None,
|
||||
CredentialStatus::ParseError,
|
||||
Some(format!("Failed to parse Grok auth JSON: {e}")),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let root = match parsed.as_object() {
|
||||
Some(o) => o,
|
||||
None => {
|
||||
return (
|
||||
None,
|
||||
CredentialStatus::ParseError,
|
||||
Some("Grok auth.json root is not an object".to_string()),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let entry = match select_preferred_entry(root) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
return (
|
||||
None,
|
||||
CredentialStatus::ParseError,
|
||||
Some("Grok auth.json contains no usable access token".to_string()),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// select_preferred_entry 已保证 key 非空
|
||||
let access_token = entry
|
||||
.get("key")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
if let Some(expires_at) = entry.get("expires_at").and_then(|v| v.as_str()) {
|
||||
if is_iso_expired(expires_at) {
|
||||
return (
|
||||
Some(access_token),
|
||||
CredentialStatus::Expired,
|
||||
Some("Grok OAuth token has expired".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
(Some(access_token), CredentialStatus::Valid, None)
|
||||
}
|
||||
|
||||
/// 选择首选凭据条目:OIDC(SuperGrok)优先,legacy session 兜底。
|
||||
///
|
||||
/// 只接受 `key` 非空的条目——残缺的 OIDC 记录不能遮蔽健康的 legacy 条目
|
||||
/// (与 CodexBar `selectPreferredEntry` 一致)。
|
||||
fn select_preferred_entry(
|
||||
root: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
let mut oidc_candidate = None;
|
||||
let mut legacy_candidate = None;
|
||||
|
||||
for (scope, value) in root {
|
||||
let entry = match value.as_object() {
|
||||
Some(e) => e,
|
||||
None => continue,
|
||||
};
|
||||
let has_key = entry
|
||||
.get("key")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|k| !k.is_empty());
|
||||
if !has_key {
|
||||
continue;
|
||||
}
|
||||
if scope.starts_with(OIDC_SCOPE_PREFIX) {
|
||||
oidc_candidate = Some(entry);
|
||||
} else if scope == LEGACY_SESSION_SCOPE || scope.contains("/sign-in") {
|
||||
legacy_candidate = Some(entry);
|
||||
}
|
||||
}
|
||||
|
||||
oidc_candidate.or(legacy_candidate)
|
||||
}
|
||||
|
||||
/// 判断 ISO 8601 时间串是否已过期;无法解析时不视为过期
|
||||
fn is_iso_expired(iso: &str) -> bool {
|
||||
let now_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(iso) {
|
||||
dt.timestamp() < now_secs
|
||||
} else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%S%.f") {
|
||||
dt.and_utc().timestamp() < now_secs
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ── gRPC-web 帧与 protobuf 解析 ──────────────────────────
|
||||
|
||||
/// protobuf 扫描收集到的字段(路径 = 从根到该字段的 field number 链)
|
||||
#[derive(Default)]
|
||||
struct ProtobufScan {
|
||||
/// (path, float 值, 出现顺序)
|
||||
fixed32_fields: Vec<(Vec<u64>, f32, usize)>,
|
||||
/// (path, varint 值)
|
||||
varint_fields: Vec<(Vec<u64>, u64)>,
|
||||
}
|
||||
|
||||
fn read_varint(bytes: &[u8], index: &mut usize) -> Option<u64> {
|
||||
let mut value: u64 = 0;
|
||||
let mut shift: u32 = 0;
|
||||
while *index < bytes.len() && shift < 64 {
|
||||
let byte = bytes[*index];
|
||||
*index += 1;
|
||||
value |= u64::from(byte & 0x7F) << shift;
|
||||
if byte & 0x80 == 0 {
|
||||
return Some(value);
|
||||
}
|
||||
shift += 7;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 递归扫描 protobuf 消息,收集 varint 与 fixed32 字段。
|
||||
///
|
||||
/// 无 .proto 定义,length-delimited 字段一律当嵌套消息试扫(深度 ≤4);
|
||||
/// 无法解析的字节从字段起点 +1 重新同步。返回下一个 fixed32 序号。
|
||||
fn scan_protobuf(
|
||||
bytes: &[u8],
|
||||
depth: usize,
|
||||
path: &[u64],
|
||||
order: usize,
|
||||
scan: &mut ProtobufScan,
|
||||
) -> usize {
|
||||
let mut index = 0;
|
||||
let mut next_order = order;
|
||||
|
||||
while index < bytes.len() {
|
||||
let field_start = index;
|
||||
let key = match read_varint(bytes, &mut index) {
|
||||
Some(k) if k != 0 => k,
|
||||
_ => {
|
||||
index = field_start + 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let field_number = key >> 3;
|
||||
let wire_type = key & 0x07;
|
||||
let mut field_path = path.to_vec();
|
||||
field_path.push(field_number);
|
||||
|
||||
match wire_type {
|
||||
0 => match read_varint(bytes, &mut index) {
|
||||
Some(value) => scan.varint_fields.push((field_path, value)),
|
||||
None => index = field_start + 1,
|
||||
},
|
||||
1 => {
|
||||
if index + 8 > bytes.len() {
|
||||
return next_order;
|
||||
}
|
||||
index += 8;
|
||||
}
|
||||
2 => {
|
||||
let length = match read_varint(bytes, &mut index) {
|
||||
Some(l) if l <= (bytes.len() - index) as u64 => l as usize,
|
||||
_ => {
|
||||
index = field_start + 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let end = index + length;
|
||||
if depth < 4 {
|
||||
next_order =
|
||||
scan_protobuf(&bytes[index..end], depth + 1, &field_path, next_order, scan);
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
5 => {
|
||||
if index + 4 > bytes.len() {
|
||||
return next_order;
|
||||
}
|
||||
let bits = u32::from_le_bytes([
|
||||
bytes[index],
|
||||
bytes[index + 1],
|
||||
bytes[index + 2],
|
||||
bytes[index + 3],
|
||||
]);
|
||||
scan.fixed32_fields
|
||||
.push((field_path, f32::from_bits(bits), next_order));
|
||||
next_order += 1;
|
||||
index += 4;
|
||||
}
|
||||
_ => index = field_start + 1,
|
||||
}
|
||||
}
|
||||
|
||||
next_order
|
||||
}
|
||||
|
||||
/// 拆出 gRPC-web data 帧(flags 高位 0x80 的 trailer 帧跳过)。
|
||||
/// 任一帧长度非法时返回空——调用方再按裸 protobuf 兜底。
|
||||
fn grpc_web_data_frames(data: &[u8]) -> Vec<&[u8]> {
|
||||
let mut frames = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < data.len() {
|
||||
if index + 5 > data.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
let flags = data[index];
|
||||
let length = u32::from_be_bytes([
|
||||
data[index + 1],
|
||||
data[index + 2],
|
||||
data[index + 3],
|
||||
data[index + 4],
|
||||
]) as usize;
|
||||
let start = index + 5;
|
||||
let end = start + length;
|
||||
if end > data.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
if flags & 0x80 == 0 {
|
||||
frames.push(&data[start..end]);
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
frames
|
||||
}
|
||||
|
||||
/// 响应体没有帧头时,看首字节是否像合法 protobuf tag(某些成功请求直接返回裸 protobuf)
|
||||
fn looks_like_protobuf_payload(data: &[u8]) -> bool {
|
||||
match data.first() {
|
||||
Some(&first) => {
|
||||
let field_number = first >> 3;
|
||||
let wire_type = first & 0x07;
|
||||
field_number > 0 && matches!(wire_type, 0 | 1 | 2 | 5)
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 trailer 帧(flags & 0x80)解析 `grpc-status` / `grpc-message` 等字段
|
||||
fn grpc_web_trailer_fields(data: &[u8]) -> HashMap<String, String> {
|
||||
let mut fields = HashMap::new();
|
||||
let mut index = 0;
|
||||
while index + 5 <= data.len() {
|
||||
let flags = data[index];
|
||||
let length = u32::from_be_bytes([
|
||||
data[index + 1],
|
||||
data[index + 2],
|
||||
data[index + 3],
|
||||
data[index + 4],
|
||||
]) as usize;
|
||||
let start = index + 5;
|
||||
let end = start + length;
|
||||
if end > data.len() {
|
||||
break;
|
||||
}
|
||||
if flags & 0x80 != 0 {
|
||||
if let Ok(text) = std::str::from_utf8(&data[start..end]) {
|
||||
for line in text.lines().filter(|l| !l.is_empty()) {
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
fields.insert(key.trim().to_lowercase(), percent_decode(value.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
/// gRPC message 使用 percent-encoding;解码失败的序列原样保留
|
||||
fn percent_decode(input: &str) -> String {
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
// 只切字节切片再校验 UTF-8:对 &str 按字节切片会在多字节字符
|
||||
// 边界内 panic(trailer 内容由服务端控制,可含任意 UTF-8)
|
||||
if let Ok(hex) = std::str::from_utf8(&bytes[i + 1..i + 3]) {
|
||||
if let Ok(b) = u8::from_str_radix(hex, 16) {
|
||||
out.push(b);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// 解析出的账单快照
|
||||
struct GrokBillingSnapshot {
|
||||
used_percent: f64,
|
||||
/// Unix 秒
|
||||
resets_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// 从响应体提取已用百分比与重置时间(CodexBar `parseGRPCWebResponse` 的移植)。
|
||||
///
|
||||
/// 启发式:
|
||||
/// - 百分比:wire-type 5 (float) 中路径末段为 1、值域 [0,100] 的字段,
|
||||
/// 取路径最浅、出现最早的那个;
|
||||
/// - 重置时间:varint 中值落在合理 Unix 秒区间且晚于当前时刻的字段,
|
||||
/// 优先精确路径 [1,5,1],否则取最近的未来时间;
|
||||
/// - 零用量特判:proto3 会省略值为 0 的 percent 字段,此时若存在重置时间
|
||||
/// 和用量周期标记(路径 [1,6,*] 或 [1,8,1]=1/2),按 0% 处理。
|
||||
fn parse_billing_payload(data: &[u8], now_secs: i64) -> Result<GrokBillingSnapshot, String> {
|
||||
let mut payloads = grpc_web_data_frames(data);
|
||||
if payloads.is_empty() && looks_like_protobuf_payload(data) {
|
||||
payloads = vec![data];
|
||||
}
|
||||
if payloads.is_empty() {
|
||||
return Err("Grok billing response contained no protobuf payload".to_string());
|
||||
}
|
||||
|
||||
let mut scan = ProtobufScan::default();
|
||||
for payload in payloads {
|
||||
// 与 CodexBar 一致:fixed32 序号在每个顶层 data 帧内独立从 0 计数
|
||||
scan_protobuf(payload, 0, &[], 0, &mut scan);
|
||||
}
|
||||
|
||||
let parsed_percent = scan
|
||||
.fixed32_fields
|
||||
.iter()
|
||||
.filter(|(path, value, _)| {
|
||||
path.last() == Some(&1) && value.is_finite() && *value >= 0.0 && *value <= 100.0
|
||||
})
|
||||
.min_by_key(|(path, _, order)| (path.len(), *order))
|
||||
.map(|(_, value, _)| f64::from(*value));
|
||||
|
||||
let reset_candidates: Vec<(&[u64], i64)> = scan
|
||||
.varint_fields
|
||||
.iter()
|
||||
.filter(|(_, value)| (1_700_000_000..=2_100_000_000).contains(value))
|
||||
.map(|(path, value)| (path.as_slice(), *value as i64))
|
||||
.filter(|(_, ts)| *ts > now_secs)
|
||||
.collect();
|
||||
let reset = reset_candidates
|
||||
.iter()
|
||||
.filter(|(path, _)| *path == [1, 5, 1])
|
||||
.map(|(_, ts)| *ts)
|
||||
.min()
|
||||
.or_else(|| reset_candidates.iter().map(|(_, ts)| *ts).min());
|
||||
|
||||
let has_usage_period = scan.varint_fields.iter().any(|(path, value)| {
|
||||
path.starts_with(&[1, 6]) || (path.as_slice() == [1, 8, 1] && (*value == 1 || *value == 2))
|
||||
});
|
||||
let no_usage_yet = parsed_percent.is_none()
|
||||
&& scan.fixed32_fields.is_empty()
|
||||
&& reset.is_some()
|
||||
&& has_usage_period;
|
||||
|
||||
let used_percent = match parsed_percent.or(if no_usage_yet { Some(0.0) } else { None }) {
|
||||
Some(p) => p,
|
||||
None => return Err("Could not locate usage percent in Grok billing response".to_string()),
|
||||
};
|
||||
|
||||
Ok(GrokBillingSnapshot {
|
||||
used_percent,
|
||||
resets_at: reset,
|
||||
})
|
||||
}
|
||||
|
||||
// ── API 查询 ──────────────────────────────────────────────
|
||||
|
||||
/// 认证类失败(token 无效/过期)的 gRPC 状态判定,
|
||||
/// 移植自 CodexBar `GrokWebBillingError.isAuthenticationFailure`
|
||||
fn is_grpc_auth_failure(status: i64, message: &str) -> bool {
|
||||
if status == 16 {
|
||||
return true;
|
||||
}
|
||||
if status != 7 {
|
||||
return false;
|
||||
}
|
||||
let lower = message.to_lowercase();
|
||||
lower.contains("bad-credentials")
|
||||
|| lower.contains("unauthenticated")
|
||||
|| (lower.contains("oauth2") && lower.contains("could not be validated"))
|
||||
|| (lower.contains("access token")
|
||||
&& (lower.contains("invalid")
|
||||
|| lower.contains("expired")
|
||||
|| lower.contains("could not be validated")))
|
||||
}
|
||||
|
||||
/// xAI 尚未提供团队主体的用量接口,识别其专属失败以给出可读提示
|
||||
fn is_team_billing_unavailable(status: i64, message: &str) -> bool {
|
||||
status == 9
|
||||
&& matches!(
|
||||
message.trim().to_lowercase().as_str(),
|
||||
"no personal team" | "no personal team."
|
||||
)
|
||||
}
|
||||
|
||||
/// 瞬时性 gRPC 状态:DEADLINE_EXCEEDED(4) / UNAVAILABLE(14),以及带超时
|
||||
/// 文案的 CANCELLED(1)。语义上等价 HTTP 504/503,对齐 CodexBar `shouldRetry`
|
||||
/// 的 rpcFailed 分支
|
||||
fn is_transient_grpc_status(status: i64, message: &str) -> bool {
|
||||
match status {
|
||||
4 | 14 => true,
|
||||
1 => {
|
||||
let lower = message.to_lowercase();
|
||||
lower.contains("timeout") || lower.contains("deadline") || lower.contains("expired")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 将非 0 的 gRPC 状态映射为失败。
|
||||
///
|
||||
/// 瞬时状态(超时/不可用)→ `Err`:前端 react-query retry + keep-last-good
|
||||
/// 保留上次成功值,托盘保留旧快照;折叠成 `Ok(success:false)` 会因错误文案
|
||||
/// 匹配不到前端 `isTransientUsageError` 的任何瞬时模式而被当确定性失败,
|
||||
/// 一次服务端抖动就清掉展示值与 lastGood 快照。其余状态 → 确定性失败快照。
|
||||
/// header(trailers-only 响应)与 body trailer 两条路径都必须走这里。
|
||||
fn grpc_status_failure(
|
||||
status: i64,
|
||||
message: &str,
|
||||
tool_label: &str,
|
||||
relogin_hint: &str,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
if is_transient_grpc_status(status, message) {
|
||||
return Err(format!(
|
||||
"Transient gRPC failure (grpc-status {status}): {message}"
|
||||
));
|
||||
}
|
||||
Ok(grpc_status_error(status, message, tool_label, relogin_hint))
|
||||
}
|
||||
|
||||
/// 将非 0 的 gRPC 状态映射为确定性失败快照
|
||||
fn grpc_status_error(
|
||||
status: i64,
|
||||
message: &str,
|
||||
tool_label: &str,
|
||||
relogin_hint: &str,
|
||||
) -> SubscriptionQuota {
|
||||
if is_grpc_auth_failure(status, message) {
|
||||
return SubscriptionQuota::error(
|
||||
tool_label,
|
||||
CredentialStatus::Expired,
|
||||
format!("Grok credentials were rejected (grpc-status {status}). {relogin_hint}"),
|
||||
);
|
||||
}
|
||||
if is_team_billing_unavailable(status, message) {
|
||||
return SubscriptionQuota::error(
|
||||
tool_label,
|
||||
CredentialStatus::Valid,
|
||||
"Grok team usage is not available from the billing API yet".to_string(),
|
||||
);
|
||||
}
|
||||
SubscriptionQuota::error(
|
||||
tool_label,
|
||||
CredentialStatus::Valid,
|
||||
format!("Grok billing RPC failed (grpc-status {status}): {message}"),
|
||||
)
|
||||
}
|
||||
|
||||
/// 按重置时间距今的天数推断窗口 tier 名(CodexBar `primaryLabel` 的阈值):
|
||||
/// 4–12 天 → 周窗口,20–45 天 → 月窗口,其余 → 通用 credit 额度
|
||||
fn tier_name_for_reset(resets_at: Option<i64>, now_secs: i64) -> &'static str {
|
||||
if let Some(ts) = resets_at {
|
||||
let days = ((ts - now_secs) as f64 / 86400.0).round() as i64;
|
||||
if (4..=12).contains(&days) {
|
||||
return TIER_WEEKLY_LIMIT;
|
||||
}
|
||||
if (20..=45).contains(&days) {
|
||||
return TIER_MONTHLY;
|
||||
}
|
||||
}
|
||||
TIER_CREDITS
|
||||
}
|
||||
|
||||
/// 查询 Grok 官方订阅额度
|
||||
///
|
||||
/// 与 claude/codex/gemini 同一约定:瞬时传输失败返回 `Err`(前端 retry +
|
||||
/// 保留上次成功值),确定性失败返回 `Ok(success:false)`。
|
||||
///
|
||||
/// 参数化 `tool_label` / `relogin_hint` 让该函数可被两个调用点共用(与
|
||||
/// `query_codex_quota` 的双调用点设计一致):
|
||||
/// - `"grokbuild"` + "grok login"(Grok CLI 凭据路径)
|
||||
/// - `"xai_oauth"` + "re-login via cc-switch"(cc-switch 自管 xAI OAuth 路径,
|
||||
/// 见 `commands::xai_oauth::get_xai_oauth_quota`;两者是同一个 OAuth client,
|
||||
/// token 对 grok.com 账单端点等效)
|
||||
pub(crate) async fn query_grok_quota(
|
||||
access_token: &str,
|
||||
tool_label: &str,
|
||||
relogin_hint: &str,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
// 空 gRPC-web 帧:1 字节 flags + 4 字节大端长度 0
|
||||
let resp = client
|
||||
.post(GROK_BILLING_ENDPOINT)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.header("Origin", "https://grok.com")
|
||||
.header("Referer", "https://grok.com/?_s=usage")
|
||||
.header("Accept", "*/*")
|
||||
.header("Content-Type", "application/grpc-web+proto")
|
||||
.header("x-grpc-web", "1")
|
||||
.header("x-user-agent", "connect-es/2.1.1")
|
||||
.header("User-Agent", "cc-switch")
|
||||
.body(vec![0u8; 5])
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Err(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return Ok(SubscriptionQuota::error(
|
||||
tool_label,
|
||||
CredentialStatus::Expired,
|
||||
format!("Authentication failed (HTTP {status}). {relogin_hint}"),
|
||||
));
|
||||
}
|
||||
|
||||
// HTTP 408 与 grpc-status 4 同为服务端超时,以 Err 传播(前端 retry +
|
||||
// keep-last-good);折叠进下方通用分支会因前端 isTransientUsageError 只认
|
||||
// 5xx/429 为瞬时而清掉 lastGood。CodexBar 的 shouldRetry 同样重试 408,
|
||||
// 其余的 502/503/504 前端已按 5xx 识别为瞬时,维持 Ok(success:false)。
|
||||
if status == reqwest::StatusCode::REQUEST_TIMEOUT {
|
||||
return Err(format!("Transient HTTP failure (HTTP {status})"));
|
||||
}
|
||||
|
||||
// gRPC 错误可能在 HTTP 头里携带(trailers-only 响应),先于响应体检查
|
||||
let header_status = resp
|
||||
.headers()
|
||||
.get("grpc-status")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<i64>().ok());
|
||||
let header_message = resp
|
||||
.headers()
|
||||
.get("grpc-message")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(percent_decode)
|
||||
.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let body: String = body.chars().take(400).collect();
|
||||
return Ok(SubscriptionQuota::error(
|
||||
tool_label,
|
||||
CredentialStatus::Valid,
|
||||
format!("API error (HTTP {status}): {body}"),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(code) = header_status {
|
||||
if code != 0 {
|
||||
return grpc_status_failure(code, &header_message, tool_label, relogin_hint);
|
||||
}
|
||||
}
|
||||
|
||||
let raw = match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Err(format!("Failed to read API response: {e}")),
|
||||
};
|
||||
|
||||
let trailers = grpc_web_trailer_fields(&raw);
|
||||
if let Some(code) = trailers
|
||||
.get("grpc-status")
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
{
|
||||
if code != 0 {
|
||||
let message = trailers
|
||||
.get("grpc-message")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("");
|
||||
return grpc_status_failure(code, message, tool_label, relogin_hint);
|
||||
}
|
||||
}
|
||||
|
||||
let now_secs = now_secs();
|
||||
let snapshot = match parse_billing_payload(&raw, now_secs) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return Ok(SubscriptionQuota::error(
|
||||
tool_label,
|
||||
CredentialStatus::Valid,
|
||||
format!("Failed to parse API response: {e}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let tier = QuotaTier {
|
||||
name: tier_name_for_reset(snapshot.resets_at, now_secs).to_string(),
|
||||
utilization: snapshot.used_percent.clamp(0.0, 100.0),
|
||||
resets_at: snapshot
|
||||
.resets_at
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
|
||||
.map(|dt| dt.to_rfc3339()),
|
||||
used_value_usd: None,
|
||||
max_value_usd: None,
|
||||
};
|
||||
|
||||
Ok(SubscriptionQuota {
|
||||
tool: tool_label.to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: None,
|
||||
success: true,
|
||||
tiers: vec![tier],
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
})
|
||||
}
|
||||
|
||||
/// grokbuild 的订阅额度入口(由 `subscription::get_subscription_quota` 分发)
|
||||
pub(crate) async fn get_grok_subscription_quota() -> Result<SubscriptionQuota, String> {
|
||||
let (token, status, message) = read_grok_credentials();
|
||||
|
||||
match status {
|
||||
CredentialStatus::NotFound => Ok(SubscriptionQuota::not_found("grokbuild")),
|
||||
CredentialStatus::ParseError => Ok(SubscriptionQuota::error(
|
||||
"grokbuild",
|
||||
CredentialStatus::ParseError,
|
||||
message.unwrap_or_else(|| "Failed to parse Grok credentials".to_string()),
|
||||
)),
|
||||
CredentialStatus::Expired => {
|
||||
// 即使过期也尝试调用 API(时钟偏差时 token 可能仍有效)
|
||||
if let Some(ref token) = token {
|
||||
let result = query_grok_quota(token, "grokbuild", RELOGIN_HINT).await?;
|
||||
if result.success {
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
Ok(SubscriptionQuota::error(
|
||||
"grokbuild",
|
||||
CredentialStatus::Expired,
|
||||
format!(
|
||||
"{} {RELOGIN_HINT}",
|
||||
message.unwrap_or_else(|| "Grok OAuth token has expired.".to_string())
|
||||
),
|
||||
))
|
||||
}
|
||||
CredentialStatus::Valid => {
|
||||
let token = token.expect("token must be Some when status is Valid");
|
||||
query_grok_quota(&token, "grokbuild", RELOGIN_HINT).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 辅助函数 ──────────────────────────────────────────────
|
||||
|
||||
fn now_secs() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
fn now_millis() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── protobuf 构造辅助 ──
|
||||
|
||||
fn varint(mut value: u64) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let byte = (value & 0x7F) as u8;
|
||||
value >>= 7;
|
||||
if value == 0 {
|
||||
out.push(byte);
|
||||
break;
|
||||
}
|
||||
out.push(byte | 0x80);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn field_varint(number: u64, value: u64) -> Vec<u8> {
|
||||
let mut out = varint(number << 3);
|
||||
out.extend(varint(value));
|
||||
out
|
||||
}
|
||||
|
||||
fn field_float(number: u64, value: f32) -> Vec<u8> {
|
||||
let mut out = varint((number << 3) | 5);
|
||||
out.extend(value.to_bits().to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn field_message(number: u64, payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = varint((number << 3) | 2);
|
||||
out.extend(varint(payload.len() as u64));
|
||||
out.extend(payload);
|
||||
out
|
||||
}
|
||||
|
||||
fn grpc_web_frame(flags: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![flags];
|
||||
out.extend((payload.len() as u32).to_be_bytes());
|
||||
out.extend(payload);
|
||||
out
|
||||
}
|
||||
|
||||
const NOW: i64 = 1_750_000_000;
|
||||
|
||||
#[test]
|
||||
fn parses_percent_and_reset_from_framed_payload() {
|
||||
// message { 1: { 1: 37.5f, 5: { 1: reset_ts } } }
|
||||
let reset_ts = (NOW + 30 * 86400) as u64;
|
||||
let inner = [
|
||||
field_float(1, 37.5),
|
||||
field_message(5, &field_varint(1, reset_ts)),
|
||||
]
|
||||
.concat();
|
||||
let payload = field_message(1, &inner);
|
||||
let data = grpc_web_frame(0, &payload);
|
||||
|
||||
let snapshot = parse_billing_payload(&data, NOW).expect("parse ok");
|
||||
assert_eq!(snapshot.used_percent, 37.5);
|
||||
assert_eq!(snapshot.resets_at, Some(reset_ts as i64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bare_protobuf_without_frame_header() {
|
||||
let payload = field_message(1, &field_float(1, 12.0));
|
||||
let snapshot = parse_billing_payload(&payload, NOW).expect("parse ok");
|
||||
assert_eq!(snapshot.used_percent, 12.0);
|
||||
assert_eq!(snapshot.resets_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_shallowest_percent_candidate() {
|
||||
// 深层 [1,2,1]=99.0 不应盖过浅层 [1,1]=25.0
|
||||
let inner = [
|
||||
field_message(2, &field_float(1, 99.0)),
|
||||
field_float(1, 25.0),
|
||||
]
|
||||
.concat();
|
||||
let payload = field_message(1, &inner);
|
||||
let data = grpc_web_frame(0, &payload);
|
||||
|
||||
let snapshot = parse_billing_payload(&data, NOW).expect("parse ok");
|
||||
assert_eq!(snapshot.used_percent, 25.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_usage_period_without_percent_field_reads_as_zero() {
|
||||
// proto3 省略 0 值 percent:仅有 [1,5,1] 重置时间 + [1,6,1] 周期标记
|
||||
let reset_ts = (NOW + 7 * 86400) as u64;
|
||||
let inner = [
|
||||
field_message(5, &field_varint(1, reset_ts)),
|
||||
field_message(6, &field_varint(1, 3)),
|
||||
]
|
||||
.concat();
|
||||
let payload = field_message(1, &inner);
|
||||
let data = grpc_web_frame(0, &payload);
|
||||
|
||||
let snapshot = parse_billing_payload(&data, NOW).expect("parse ok");
|
||||
assert_eq!(snapshot.used_percent, 0.0);
|
||||
assert_eq!(snapshot.resets_at, Some(reset_ts as i64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_percent_without_period_marker_is_parse_error() {
|
||||
let payload = field_message(1, &field_varint(7, 42));
|
||||
let data = grpc_web_frame(0, &payload);
|
||||
assert!(parse_billing_payload(&data, NOW).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailer_frames_are_excluded_from_payload_and_expose_status() {
|
||||
let payload = field_message(1, &field_float(1, 50.0));
|
||||
let mut data = grpc_web_frame(0, &payload);
|
||||
data.extend(grpc_web_frame(0x80, b"grpc-status: 0\r\ngrpc-message: ok"));
|
||||
|
||||
let snapshot = parse_billing_payload(&data, NOW).expect("parse ok");
|
||||
assert_eq!(snapshot.used_percent, 50.0);
|
||||
|
||||
let trailers = grpc_web_trailer_fields(&data);
|
||||
assert_eq!(trailers.get("grpc-status").map(String::as_str), Some("0"));
|
||||
assert_eq!(trailers.get("grpc-message").map(String::as_str), Some("ok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_decode_unescapes_grpc_message() {
|
||||
assert_eq!(percent_decode("no%20personal%20team"), "no personal team");
|
||||
assert_eq!(percent_decode("plain"), "plain");
|
||||
// 非法序列原样保留
|
||||
assert_eq!(percent_decode("50%ZZ"), "50%ZZ");
|
||||
// '%' + ASCII + 多字节字符:不得在字符边界内切片 panic
|
||||
assert_eq!(percent_decode("bad%1é"), "bad%1é");
|
||||
assert_eq!(percent_decode("%1é"), "%1é");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_json_prefers_oidc_entry_over_legacy() {
|
||||
let content = r#"{
|
||||
"https://accounts.x.ai/sign-in": {"key": "legacy-token"},
|
||||
"https://auth.x.ai::client-id": {"key": "oidc-token"}
|
||||
}"#;
|
||||
let (token, status, _) = parse_grok_auth_json(content);
|
||||
assert_eq!(token.as_deref(), Some("oidc-token"));
|
||||
assert!(matches!(status, CredentialStatus::Valid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_json_empty_oidc_key_falls_back_to_legacy() {
|
||||
// 残缺 OIDC 记录不遮蔽健康的 legacy 条目
|
||||
let content = r#"{
|
||||
"https://auth.x.ai::client-id": {"key": ""},
|
||||
"https://accounts.x.ai/sign-in": {"key": "legacy-token"}
|
||||
}"#;
|
||||
let (token, status, _) = parse_grok_auth_json(content);
|
||||
assert_eq!(token.as_deref(), Some("legacy-token"));
|
||||
assert!(matches!(status, CredentialStatus::Valid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_json_expired_entry_reports_expired() {
|
||||
let content = r#"{
|
||||
"https://auth.x.ai::client-id": {
|
||||
"key": "token",
|
||||
"expires_at": "2020-01-01T00:00:00.000Z"
|
||||
}
|
||||
}"#;
|
||||
let (token, status, message) = parse_grok_auth_json(content);
|
||||
assert_eq!(token.as_deref(), Some("token"));
|
||||
assert!(matches!(status, CredentialStatus::Expired));
|
||||
assert!(message.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_json_without_usable_entry_is_parse_error() {
|
||||
let (token, status, _) = parse_grok_auth_json(r#"{"other-scope": {"key": "x"}}"#);
|
||||
assert!(token.is_none());
|
||||
assert!(matches!(status, CredentialStatus::ParseError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_name_follows_reset_distance() {
|
||||
assert_eq!(
|
||||
tier_name_for_reset(Some(NOW + 7 * 86400), NOW),
|
||||
TIER_WEEKLY_LIMIT
|
||||
);
|
||||
assert_eq!(
|
||||
tier_name_for_reset(Some(NOW + 30 * 86400), NOW),
|
||||
TIER_MONTHLY
|
||||
);
|
||||
assert_eq!(tier_name_for_reset(Some(NOW + 86400), NOW), TIER_CREDITS);
|
||||
assert_eq!(tier_name_for_reset(None, NOW), TIER_CREDITS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_auth_and_team_failures_classify_correctly() {
|
||||
assert!(is_grpc_auth_failure(16, ""));
|
||||
assert!(is_grpc_auth_failure(7, "Bad-Credentials: token rejected"));
|
||||
assert!(!is_grpc_auth_failure(7, "quota exceeded"));
|
||||
assert!(is_team_billing_unavailable(9, " No Personal Team "));
|
||||
assert!(!is_team_billing_unavailable(9, "other precondition"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_grpc_statuses_propagate_as_err() {
|
||||
// DEADLINE_EXCEEDED / UNAVAILABLE 无条件瞬时
|
||||
assert!(is_transient_grpc_status(4, ""));
|
||||
assert!(is_transient_grpc_status(14, ""));
|
||||
// CANCELLED 仅在带超时文案时瞬时
|
||||
assert!(is_transient_grpc_status(1, "context deadline exceeded"));
|
||||
assert!(!is_transient_grpc_status(1, "cancelled by user"));
|
||||
// 鉴权/团队/其他状态不属瞬时
|
||||
assert!(!is_transient_grpc_status(16, ""));
|
||||
assert!(!is_transient_grpc_status(9, "no personal team"));
|
||||
assert!(!is_transient_grpc_status(13, "internal"));
|
||||
|
||||
// 瞬时 → Err(前端 retry + keep-last-good),确定性 → Ok(success:false)
|
||||
assert!(grpc_status_failure(4, "deadline exceeded", "grokbuild", RELOGIN_HINT).is_err());
|
||||
assert!(grpc_status_failure(14, "unavailable", "grokbuild", RELOGIN_HINT).is_err());
|
||||
let determinate = grpc_status_failure(13, "internal", "grokbuild", RELOGIN_HINT)
|
||||
.expect("determinate is Ok");
|
||||
assert!(!determinate.success);
|
||||
// tool_label 参数化:两条链路(CLI / cc-switch 自管 OAuth)标签正确落到快照
|
||||
let auth =
|
||||
grpc_status_failure(16, "", "xai_oauth", "re-login").expect("auth failure is Ok");
|
||||
assert!(matches!(auth.credential_status, CredentialStatus::Expired));
|
||||
assert_eq!(auth.tool, "xai_oauth");
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,7 @@ fn provider_name_coalesce(log_alias: &str, provider_alias: &str) -> String {
|
||||
WHEN '_codex_session' THEN 'Codex (Session)' \
|
||||
WHEN '_gemini_session' THEN 'Gemini (Session)' \
|
||||
WHEN '_opencode_session' THEN 'OpenCode (Session)' \
|
||||
WHEN '_grok_session' THEN 'Grok Build (Session)' \
|
||||
ELSE {log_alias}.provider_id END)"
|
||||
)
|
||||
}
|
||||
@@ -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.50:input = (700-250)×2/1M,cache_read = 250×0.5/1M
|
||||
assert_eq!(input_cost, "0.000900");
|
||||
assert_eq!(cache_read_cost, "0.000125");
|
||||
assert_eq!(total_cost, "0.001625");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backfill_missing_usage_costs_uses_stored_multiplier() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
|
||||
"endpoints": [
|
||||
"https://dl.ccswitch.io/latest.json",
|
||||
"https://github.com/farion1231/cc-switch/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ export const TIER_I18N_KEYS: Record<string, string> = {
|
||||
weekly_limit: "subscription.sevenDay",
|
||||
// 火山方舟 Agent Plan / Coding Plan 的月窗口
|
||||
monthly: "subscription.monthly",
|
||||
// Grok credit 额度的兜底窗口(重置距离可识别时归入 weekly_limit/monthly)
|
||||
credits: "subscription.credits",
|
||||
// GitHub Copilot
|
||||
premium: "subscription.copilotPremium",
|
||||
};
|
||||
@@ -422,7 +424,8 @@ const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
|
||||
quota={quota}
|
||||
loading={loading}
|
||||
refetch={refetch}
|
||||
appIdForExpiredHint={appId}
|
||||
// expiredHint 里的 {tool} 是 CLI 命令名:Grok 的命令是 `grok` 而非 appId
|
||||
appIdForExpiredHint={appId === "grokbuild" ? "grok" : appId}
|
||||
inline={inline}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -160,7 +160,7 @@ function detectBalanceProvider(baseUrl: string | undefined): boolean {
|
||||
}
|
||||
|
||||
function isOfficialSubscriptionProvider(provider: Provider, appId: AppId) {
|
||||
if (!["claude", "codex", "gemini"].includes(appId)) return false;
|
||||
if (!["claude", "codex", "gemini", "grokbuild"].includes(appId)) return false;
|
||||
if (provider.category === "official") return true;
|
||||
|
||||
const config = provider.settingsConfig as Record<string, any>;
|
||||
@@ -188,6 +188,12 @@ function isOfficialSubscriptionProvider(provider: Provider, appId: AppId) {
|
||||
(!baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""))
|
||||
);
|
||||
}
|
||||
// grokbuild 不做配置启发式,只认上方的 category === "official":官方态判定
|
||||
// 在后端是 TOML 解析(grok_config::is_official_live_config),正则无法忠实
|
||||
// 镜像(引号键/inline table/非法 TOML 均会误判为官方),误判会让本组件的
|
||||
// state 初始化丢弃已保存的非官方脚本。claude/codex/gemini 的启发式建立在
|
||||
// 已解析的 JSON 字段上是精确的,不受此限。官方判定以 category 为 SSOT 的
|
||||
// 理由见 ProviderCard 中的注释。
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import type { ProviderMeta } from "@/types";
|
||||
import { useXaiOauthQuota } from "@/lib/query/subscription";
|
||||
import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter";
|
||||
|
||||
interface XaiOauthQuotaFooterProps {
|
||||
meta?: ProviderMeta;
|
||||
inline?: boolean;
|
||||
/** 是否为当前激活的供应商 */
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* xAI OAuth (SuperGrok 反代) 订阅额度 footer
|
||||
*
|
||||
* 复用 SubscriptionQuotaView 的全部渲染逻辑(5 状态 × inline/expanded)。
|
||||
* 数据源为 cc-switch 自管的 xAI OAuth token,与 Grok Build 分区读 Grok CLI
|
||||
* 凭据的路径查同一个 grok.com 账单端点,展示同一份订阅额度。
|
||||
*/
|
||||
const XaiOauthQuotaFooter: React.FC<XaiOauthQuotaFooterProps> = ({
|
||||
meta,
|
||||
inline = false,
|
||||
isCurrent = false,
|
||||
}) => {
|
||||
const {
|
||||
data: quota,
|
||||
isFetching: loading,
|
||||
refetch,
|
||||
} = useXaiOauthQuota(meta, { enabled: true, autoQuery: isCurrent });
|
||||
|
||||
return (
|
||||
<SubscriptionQuotaView
|
||||
quota={quota}
|
||||
loading={loading}
|
||||
refetch={refetch}
|
||||
appIdForExpiredHint="xai_oauth"
|
||||
inline={inline}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default XaiOauthQuotaFooter;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ export const GeminiCommonConfigModal: React.FC<
|
||||
value={draftValue}
|
||||
onChange={setDraftValue}
|
||||
placeholder={`{
|
||||
"GEMINI_MODEL": "gemini-3.5-flash"
|
||||
"GEMINI_MODEL": "gemini-3.6-flash"
|
||||
}`}
|
||||
darkMode={isDarkMode}
|
||||
rows={16}
|
||||
|
||||
@@ -97,7 +97,7 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||
onChange={handleChange}
|
||||
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
|
||||
GEMINI_API_KEY=sk-your-api-key-here
|
||||
GEMINI_MODEL=gemini-3.5-flash`}
|
||||
GEMINI_MODEL=gemini-3.6-flash`}
|
||||
darkMode={isDarkMode}
|
||||
rows={6}
|
||||
showValidation={false}
|
||||
|
||||
@@ -185,7 +185,7 @@ export function GeminiFormFields({
|
||||
id="gemini-model"
|
||||
value={model}
|
||||
onChange={onModelChange}
|
||||
placeholder="gemini-3.5-flash"
|
||||
placeholder="gemini-3.6-flash"
|
||||
fetchedModels={fetchedModels}
|
||||
isLoading={isFetchingModels}
|
||||
/>
|
||||
|
||||
@@ -417,7 +417,7 @@ export function HermesFormFields({
|
||||
handleModelChange(index, "id", e.target.value)
|
||||
}
|
||||
placeholder={t("hermes.form.modelIdPlaceholder", {
|
||||
defaultValue: "anthropic/claude-opus-4-8",
|
||||
defaultValue: "anthropic/claude-opus-5",
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
@@ -473,7 +473,7 @@ export function HermesFormFields({
|
||||
handleModelChange(index, "name", e.target.value)
|
||||
}
|
||||
placeholder={t("hermes.form.modelNamePlaceholder", {
|
||||
defaultValue: "Claude Opus 4.8",
|
||||
defaultValue: "Claude Opus 5",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ export const GEMINI_DEFAULT_CONFIG = JSON.stringify(
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "",
|
||||
GEMINI_API_KEY: "",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -148,7 +148,7 @@ export function UniversalProviderFormModal({
|
||||
// 计算 Codex 配置 JSON 预览
|
||||
const codexConfigJson = useMemo(() => {
|
||||
if (!codexEnabled) return null;
|
||||
const model = models.codex?.model || "gpt-5.5";
|
||||
const model = models.codex?.model || "gpt-5.6-sol";
|
||||
const reasoningEffort = models.codex?.reasoningEffort || "high";
|
||||
// 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API)
|
||||
const codexBaseUrl = baseUrl.endsWith("/v1")
|
||||
@@ -594,7 +594,7 @@ requires_openai_auth = true`;
|
||||
onChange={(e) =>
|
||||
updateModel("codex", "model", e.target.value)
|
||||
}
|
||||
placeholder="gpt-5.5"
|
||||
placeholder="gpt-5.6-sol"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface ClaudeDesktopRoutePreset {
|
||||
*/
|
||||
export const CLAUDE_DESKTOP_ROLE_ROUTE_IDS = {
|
||||
sonnet: "claude-sonnet-5",
|
||||
opus: "claude-opus-4-8",
|
||||
opus: "claude-opus-5",
|
||||
fable: "claude-fable-5",
|
||||
haiku: "claude-haiku-4-5",
|
||||
} as const;
|
||||
@@ -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,31 +211,28 @@ 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",
|
||||
},
|
||||
{
|
||||
name: "AICodeMirror",
|
||||
websiteUrl: "https://www.aicodemirror.com",
|
||||
apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3",
|
||||
websiteUrl: "https://www.aicodemirror.ai",
|
||||
apiKeyUrl: "https://www.aicodemirror.ai/register?invitecode=9915W3",
|
||||
category: "third_party",
|
||||
baseUrl: "https://api.aicodemirror.com/api/claudecode",
|
||||
baseUrl: "https://api.aicodemirror.ai/api/claudecode",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: [
|
||||
"https://api.aicodemirror.com/api/claudecode",
|
||||
"https://api.claudecode.net.cn/api/claudecode",
|
||||
],
|
||||
endpointCandidates: ["https://api.aicodemirror.ai/api/claudecode"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicodemirror",
|
||||
icon: "aicodemirror",
|
||||
@@ -308,7 +302,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: mappedRoutes(
|
||||
"anthropic/claude-sonnet-5",
|
||||
"anthropic/claude-opus-4.8",
|
||||
"anthropic/claude-opus-5",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
),
|
||||
isPartner: true,
|
||||
@@ -330,6 +324,21 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
icon: "aigocode",
|
||||
iconColor: "#5B7FFF",
|
||||
},
|
||||
{
|
||||
name: "AICoding",
|
||||
websiteUrl: "https://aicoding.inc",
|
||||
apiKeyUrl: "https://aicoding.inc/i/CCSWITCH",
|
||||
category: "third_party",
|
||||
baseUrl: "https://api.aicoding.inc",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: ["https://api.aicoding.inc"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicoding",
|
||||
icon: "aicoding",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "SubRouter",
|
||||
websiteUrl: "https://subrouter.ai",
|
||||
@@ -359,10 +368,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 +625,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 +718,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",
|
||||
@@ -755,9 +764,9 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
mode: "proxy",
|
||||
apiFormat: "gemini_native",
|
||||
modelRoutes: brandedRoutes(
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.6-flash",
|
||||
"gemini-3.6-flash",
|
||||
"gemini-3.6-flash",
|
||||
),
|
||||
endpointCandidates: ["https://generativelanguage.googleapis.com"],
|
||||
icon: "gemini",
|
||||
@@ -789,7 +798,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
apiFormat: "openai_responses",
|
||||
providerType: "codex_oauth",
|
||||
requiresOAuth: true,
|
||||
modelRoutes: brandedRoutes("gpt-5.6", "gpt-5.6", "gpt-5.6-luna"),
|
||||
modelRoutes: brandedRoutes("gpt-5.6-sol", "gpt-5.6-sol", "gpt-5.6-luna"),
|
||||
icon: "openai",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
@@ -1031,7 +1040,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: mappedRoutes(
|
||||
"anthropic/claude-sonnet-5",
|
||||
"anthropic/claude-opus-4.8",
|
||||
"anthropic/claude-opus-5",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
),
|
||||
endpointCandidates: ["https://open.cherryin.net"],
|
||||
@@ -1071,7 +1080,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: mappedRoutes(
|
||||
"anthropic/claude-sonnet-5",
|
||||
"anthropic/claude-opus-4.8",
|
||||
"anthropic/claude-opus-5",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
true,
|
||||
),
|
||||
@@ -1088,7 +1097,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: mappedRoutes(
|
||||
"anthropic/claude-sonnet-5",
|
||||
"anthropic/claude-opus-4.8",
|
||||
"anthropic/claude-opus-5",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
true,
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
@@ -187,18 +184,15 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "AICodeMirror",
|
||||
websiteUrl: "https://www.aicodemirror.com",
|
||||
apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3",
|
||||
websiteUrl: "https://www.aicodemirror.ai",
|
||||
apiKeyUrl: "https://www.aicodemirror.ai/register?invitecode=9915W3",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.aicodemirror.com/api/claudecode",
|
||||
ANTHROPIC_BASE_URL: "https://api.aicodemirror.ai/api/claudecode",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
endpointCandidates: [
|
||||
"https://api.aicodemirror.com/api/claudecode",
|
||||
"https://api.claudecode.net.cn/api/claudecode",
|
||||
],
|
||||
endpointCandidates: ["https://api.aicodemirror.ai/api/claudecode"],
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "aicodemirror", // 促销信息 i18n key
|
||||
@@ -279,7 +273,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
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",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-5",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -305,6 +299,23 @@ export const providerPresets: ProviderPreset[] = [
|
||||
icon: "aigocode",
|
||||
iconColor: "#5B7FFF",
|
||||
},
|
||||
{
|
||||
name: "AICoding",
|
||||
websiteUrl: "https://aicoding.inc",
|
||||
apiKeyUrl: "https://aicoding.inc/i/CCSWITCH",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.aicoding.inc",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
endpointCandidates: ["https://api.aicoding.inc"],
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "aicoding", // 促销信息 i18n key
|
||||
icon: "aicoding",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "SubRouter",
|
||||
websiteUrl: "https://subrouter.ai",
|
||||
@@ -339,11 +350,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 +646,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 +752,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",
|
||||
@@ -794,10 +805,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://generativelanguage.googleapis.com",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
ANTHROPIC_MODEL: "gemini-3.5-flash",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "gemini-3.5-flash",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "gemini-3.5-flash",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "gemini-3.5-flash",
|
||||
ANTHROPIC_MODEL: "gemini-3.6-flash",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "gemini-3.6-flash",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "gemini-3.6-flash",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -1125,7 +1136,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
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",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-5",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -1178,7 +1189,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
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",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-5",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -1197,7 +1208,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
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",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-5",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -1249,12 +1260,12 @@ export const providerPresets: ProviderPreset[] = [
|
||||
// base_url 由代理后端强制重写为 chatgpt.com/backend-api/codex
|
||||
// 用户无需配置
|
||||
ANTHROPIC_BASE_URL: "https://chatgpt.com/backend-api/codex",
|
||||
ANTHROPIC_MODEL: "gpt-5.6",
|
||||
ANTHROPIC_MODEL: "gpt-5.6-sol",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "gpt-5.6-luna",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "gpt-5.6",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "gpt-5.6",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "gpt-5.6-sol",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "gpt-5.6-sol",
|
||||
// Claude Code falls back to a 200K context window for unrecognized
|
||||
// non-Claude model ids. The ChatGPT Codex backend catalogs gpt-5.6
|
||||
// non-Claude model ids. The ChatGPT Codex backend catalogs gpt-5.6-sol
|
||||
// at a 372K window with a ~353K effective budget (openai/codex#31860),
|
||||
// not the 1.05M API window. Pin both knobs: the compact window equals
|
||||
// min(model window, value), so matching the window is behavior-neutral
|
||||
@@ -1318,10 +1329,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://cc-api.pipellm.ai",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "claude-opus-4-8",
|
||||
ANTHROPIC_MODEL: "claude-opus-5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "claude-haiku-4-5-20251001",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "claude-sonnet-5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "claude-opus-4-8",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "claude-opus-5",
|
||||
},
|
||||
includeCoAuthoredBy: false,
|
||||
},
|
||||
@@ -1374,11 +1385,11 @@ export const providerPresets: ProviderPreset[] = [
|
||||
AWS_ACCESS_KEY_ID: "${AWS_ACCESS_KEY_ID}",
|
||||
AWS_SECRET_ACCESS_KEY: "${AWS_SECRET_ACCESS_KEY}",
|
||||
AWS_REGION: "${AWS_REGION}",
|
||||
ANTHROPIC_MODEL: "global.anthropic.claude-opus-4-8",
|
||||
ANTHROPIC_MODEL: "global.anthropic.claude-opus-5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL:
|
||||
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "global.anthropic.claude-sonnet-5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "global.anthropic.claude-opus-4-8",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "global.anthropic.claude-opus-5",
|
||||
CLAUDE_CODE_USE_BEDROCK: "1",
|
||||
},
|
||||
},
|
||||
@@ -1412,11 +1423,11 @@ export const providerPresets: ProviderPreset[] = [
|
||||
ANTHROPIC_BASE_URL:
|
||||
"https://bedrock-runtime.${AWS_REGION}.amazonaws.com",
|
||||
AWS_REGION: "${AWS_REGION}",
|
||||
ANTHROPIC_MODEL: "global.anthropic.claude-opus-4-8",
|
||||
ANTHROPIC_MODEL: "global.anthropic.claude-opus-5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL:
|
||||
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "global.anthropic.claude-sonnet-5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "global.anthropic.claude-opus-4-8",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "global.anthropic.claude-opus-5",
|
||||
CLAUDE_CODE_USE_BEDROCK: "1",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -60,7 +60,7 @@ export function generateThirdPartyAuth(apiKey: string): Record<string, any> {
|
||||
export function generateThirdPartyConfig(
|
||||
providerName: string,
|
||||
baseUrl: string,
|
||||
modelName = "gpt-5.5",
|
||||
modelName = "gpt-5.6-sol",
|
||||
): string {
|
||||
const tomlString = (value: string) => JSON.stringify(value);
|
||||
|
||||
@@ -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",
|
||||
"gpt-5.5",
|
||||
"https://www.packyapi.ai/v1",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
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",
|
||||
@@ -222,7 +219,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
config: generateThirdPartyConfig(
|
||||
"zetaapi",
|
||||
"https://api.zetaapi.ai/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://api.zetaapi.ai/v1"],
|
||||
isPartner: true,
|
||||
@@ -231,22 +228,22 @@ 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"
|
||||
model = "gpt-5.5"
|
||||
review_model = "gpt-5.5"
|
||||
model = "gpt-5.6-sol"
|
||||
review_model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "high"
|
||||
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",
|
||||
@@ -254,17 +251,16 @@ requires_openai_auth = true`,
|
||||
},
|
||||
{
|
||||
name: "AICodeMirror",
|
||||
websiteUrl: "https://www.aicodemirror.com",
|
||||
apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3",
|
||||
websiteUrl: "https://www.aicodemirror.ai",
|
||||
apiKeyUrl: "https://www.aicodemirror.ai/register?invitecode=9915W3",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"aicodemirror",
|
||||
"https://api.aicodemirror.com/api/codex/backend-api/codex",
|
||||
"gpt-5.5",
|
||||
"https://api.aicodemirror.ai/api/codex/backend-api/codex",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: [
|
||||
"https://api.aicodemirror.com/api/codex/backend-api/codex",
|
||||
"https://api.claudecode.net.cn/api/codex/backend-api/codex",
|
||||
"https://api.aicodemirror.ai/api/codex/backend-api/codex",
|
||||
],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicodemirror",
|
||||
@@ -280,7 +276,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"patewayai",
|
||||
"https://api.pateway.ai/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://api.pateway.ai/v1"],
|
||||
isPartner: true,
|
||||
@@ -297,7 +293,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"fenno",
|
||||
"https://api.fenno.ai",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://api.fenno.ai"],
|
||||
isPartner: true,
|
||||
@@ -313,7 +309,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"runapi",
|
||||
"https://runapi.co/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "runapi",
|
||||
@@ -328,7 +324,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"unity2",
|
||||
"https://api.unity2.ai",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://api.unity2.ai"],
|
||||
isPartner: true,
|
||||
@@ -344,7 +340,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"shengsuanyun",
|
||||
"https://router.shengsuanyun.com/api/v1",
|
||||
"openai/gpt-5.5",
|
||||
"openai/gpt-5.6-sol",
|
||||
),
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
@@ -360,7 +356,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"aigocode",
|
||||
"https://api.aigocode.com",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://api.aigocode.com"],
|
||||
isPartner: true, // 合作伙伴
|
||||
@@ -368,6 +364,22 @@ requires_openai_auth = true`,
|
||||
icon: "aigocode",
|
||||
iconColor: "#5B7FFF",
|
||||
},
|
||||
{
|
||||
name: "AICoding",
|
||||
websiteUrl: "https://aicoding.inc",
|
||||
apiKeyUrl: "https://aicoding.inc/i/CCSWITCH",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"aicoding",
|
||||
"https://api.aicoding.inc",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://api.aicoding.inc"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicoding",
|
||||
icon: "aicoding",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "SubRouter",
|
||||
websiteUrl: "https://subrouter.ai",
|
||||
@@ -377,7 +389,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"subrouter",
|
||||
"https://subrouter.ai/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://subrouter.ai/v1"],
|
||||
isPartner: true,
|
||||
@@ -391,8 +403,8 @@ requires_openai_auth = true`,
|
||||
category: "third_party",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: `model_provider = "custom"
|
||||
model = "gpt-5.5"
|
||||
review_model = "gpt-5.5"
|
||||
model = "gpt-5.6-sol"
|
||||
review_model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "high"
|
||||
disable_response_storage = true
|
||||
|
||||
@@ -416,7 +428,11 @@ requires_openai_auth = true`,
|
||||
apiKeyUrl: "https://code0.ai/agent/register/B2XHxGjGmRvqgznY",
|
||||
category: "aggregator",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig("code0", "https://code0.ai/v1", "gpt-5.5"),
|
||||
config: generateThirdPartyConfig(
|
||||
"code0",
|
||||
"https://code0.ai/v1",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://code0.ai/v1"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "code0",
|
||||
@@ -432,7 +448,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"teamorouter",
|
||||
"https://api.teamorouter.com/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://api.teamorouter.com/v1"],
|
||||
isPartner: true,
|
||||
@@ -448,7 +464,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"claudecn",
|
||||
"https://claudecn.top/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "claudecn",
|
||||
@@ -599,7 +615,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"nekocode",
|
||||
"https://nekocode.ai/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://nekocode.ai/v1"],
|
||||
isPartner: true,
|
||||
@@ -644,7 +660,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"compshare",
|
||||
"https://api.modelverse.cn/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://api.modelverse.cn/v1"],
|
||||
category: "aggregator",
|
||||
@@ -663,7 +679,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"compshare_coding",
|
||||
"https://cp.compshare.cn/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://cp.compshare.cn/v1"],
|
||||
category: "aggregator",
|
||||
@@ -681,7 +697,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"ccsub",
|
||||
"https://www.ccsub.net/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://www.ccsub.net/v1"],
|
||||
isPartner: true,
|
||||
@@ -696,7 +712,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"sssaicode",
|
||||
"https://node-hk.sssaicodeapi.com/api/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: [
|
||||
"https://node-hk.sssaicodeapi.com/api/v1",
|
||||
@@ -717,7 +733,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"micu",
|
||||
"https://www.micuapi.ai/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://www.micuapi.ai/v1"],
|
||||
category: "third_party",
|
||||
@@ -728,13 +744,13 @@ 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",
|
||||
"gpt-5.5",
|
||||
"https://www.rightapi.ai/codex/v1",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
@@ -750,7 +766,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"etok",
|
||||
"https://api.etok.ai/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://api.etok.ai/v1"],
|
||||
category: "third_party",
|
||||
@@ -767,7 +783,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"cubence",
|
||||
"https://api.cubence.com/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: [
|
||||
"https://api.cubence.com/v1",
|
||||
@@ -789,7 +805,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"crazyrouter",
|
||||
"https://cn.crazyrouter.com/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://cn.crazyrouter.com/v1"],
|
||||
isPartner: true,
|
||||
@@ -805,7 +821,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"dmxapi",
|
||||
"https://www.dmxapi.cn/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://www.dmxapi.cn/v1"],
|
||||
isPartner: true, // 合作伙伴
|
||||
@@ -821,7 +837,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"qiniu",
|
||||
"https://api.qnaigc.com/bypass/openai/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: [
|
||||
"https://api.qnaigc.com/bypass/openai/v1",
|
||||
@@ -835,7 +851,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"
|
||||
@@ -862,8 +878,8 @@ requires_openai_auth = true`,
|
||||
category: "third_party",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: `model_provider = "custom"
|
||||
model = "gpt-5.5"
|
||||
review_model = "gpt-5.5"
|
||||
model = "gpt-5.6-sol"
|
||||
review_model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "high"
|
||||
disable_response_storage = true
|
||||
model_verbosity = "high"
|
||||
@@ -888,7 +904,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"amux",
|
||||
"https://api.amux.ai/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://api.amux.ai/v1"],
|
||||
icon: "amux",
|
||||
@@ -901,7 +917,7 @@ requires_openai_auth = true`,
|
||||
isOfficial: true,
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: `model_provider = "custom"
|
||||
model = "gpt-5.5"
|
||||
model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "high"
|
||||
disable_response_storage = true
|
||||
|
||||
@@ -1493,7 +1509,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"aihubmix",
|
||||
"https://aihubmix.com/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: [
|
||||
"https://aihubmix.com/v1",
|
||||
@@ -1510,7 +1526,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"cherryin",
|
||||
"https://open.cherryin.net/v1",
|
||||
"openai/gpt-5.5",
|
||||
"openai/gpt-5.6-sol",
|
||||
),
|
||||
endpointCandidates: ["https://open.cherryin.net/v1"],
|
||||
category: "aggregator",
|
||||
@@ -1525,7 +1541,7 @@ requires_openai_auth = true`,
|
||||
config: generateThirdPartyConfig(
|
||||
"relaxycode",
|
||||
"https://www.relaxycode.com/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
icon: "relaxcode",
|
||||
},
|
||||
@@ -1537,7 +1553,7 @@ requires_openai_auth = true`,
|
||||
OPENAI_API_KEY: "",
|
||||
},
|
||||
config: `model_provider = "custom"
|
||||
model = "gpt-5.5"
|
||||
model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "high"
|
||||
disable_response_storage = true
|
||||
personality = "pragmatic"
|
||||
@@ -1562,7 +1578,7 @@ model_auto_compact_token_limit = 9000000`,
|
||||
OPENAI_API_KEY: "",
|
||||
},
|
||||
config: `model_provider = "custom"
|
||||
model = "gpt-5.5"
|
||||
model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "medium"
|
||||
disable_response_storage = true
|
||||
|
||||
@@ -1583,7 +1599,7 @@ base_url = "https://cc-api.pipellm.ai/v1"`,
|
||||
config: generateThirdPartyConfig(
|
||||
"openrouter",
|
||||
"https://openrouter.ai/api/v1",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-sol",
|
||||
),
|
||||
category: "aggregator",
|
||||
icon: "openrouter",
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface CodexTemplate {
|
||||
*/
|
||||
export function getCodexCustomTemplate(): CodexTemplate {
|
||||
const config = `model_provider = "custom"
|
||||
model = "gpt-5.6"
|
||||
model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "high"
|
||||
disable_response_storage = true
|
||||
|
||||
|
||||
@@ -54,66 +54,60 @@ 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",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
GOOGLE_GEMINI_BASE_URL: "https://www.packyapi.ai",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://www.packyapi.com",
|
||||
model: "gemini-3.5-flash",
|
||||
baseURL: "https://www.packyapi.ai",
|
||||
model: "gemini-3.6-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",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://apinebula.com",
|
||||
model: "gemini-3.5-flash",
|
||||
baseURL: "https://apinebula.ai",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "APINebula",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "apinebula",
|
||||
endpointCandidates: ["https://apinebula.com"],
|
||||
endpointCandidates: ["https://apinebula.ai"],
|
||||
icon: "apinebula",
|
||||
},
|
||||
{
|
||||
name: "AICodeMirror",
|
||||
websiteUrl: "https://www.aicodemirror.com",
|
||||
apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3",
|
||||
websiteUrl: "https://www.aicodemirror.ai",
|
||||
apiKeyUrl: "https://www.aicodemirror.ai/register?invitecode=9915W3",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.aicodemirror.com/api/gemini",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.aicodemirror.ai/api/gemini",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.aicodemirror.com/api/gemini",
|
||||
model: "gemini-3.5-flash",
|
||||
baseURL: "https://api.aicodemirror.ai/api/gemini",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "AICodeMirror",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicodemirror",
|
||||
endpointCandidates: [
|
||||
"https://api.aicodemirror.com/api/gemini",
|
||||
"https://api.claudecode.net.cn/api/gemini",
|
||||
],
|
||||
endpointCandidates: ["https://api.aicodemirror.ai/api/gemini"],
|
||||
icon: "aicodemirror",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
@@ -143,11 +137,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://router.shengsuanyun.com/api",
|
||||
GEMINI_MODEL: "google/gemini-3.5-flash",
|
||||
GEMINI_MODEL: "google/gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://router.shengsuanyun.com/api",
|
||||
model: "google/gemini-3.5-flash",
|
||||
model: "google/gemini-3.6-flash",
|
||||
description: "Shengsuanyun",
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
@@ -161,11 +155,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.aigocode.com",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.aigocode.com",
|
||||
model: "gemini-3.5-flash",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "AIGoCode",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
@@ -174,6 +168,26 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
icon: "aigocode",
|
||||
iconColor: "#5B7FFF",
|
||||
},
|
||||
{
|
||||
name: "AICoding",
|
||||
websiteUrl: "https://aicoding.inc",
|
||||
apiKeyUrl: "https://aicoding.inc/i/CCSWITCH",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.aicoding.inc",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.aicoding.inc",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "AICoding",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicoding",
|
||||
endpointCandidates: ["https://api.aicoding.inc"],
|
||||
icon: "aicoding",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "SubRouter",
|
||||
websiteUrl: "https://subrouter.ai",
|
||||
@@ -181,11 +195,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://subrouter.ai/v1beta",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://subrouter.ai/v1beta",
|
||||
model: "gemini-3.5-flash",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "SubRouter",
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
@@ -201,11 +215,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.apikey.fun",
|
||||
GEMINI_API_KEY: "",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.apikey.fun",
|
||||
model: "gemini-3.5-flash",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "APIKEY.FUN",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
@@ -238,11 +252,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://node-hk.sssaicodeapi.com/api",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://node-hk.sssaicodeapi.com/api",
|
||||
model: "gemini-3.5-flash",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "SSSAiCode",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
@@ -262,11 +276,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.etok.ai/v1beta",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.etok.ai/v1beta",
|
||||
model: "gemini-3.5-flash",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "ETok",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
@@ -282,11 +296,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.cubence.com",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.cubence.com",
|
||||
model: "gemini-3.5-flash",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "Cubence",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
@@ -307,11 +321,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://cn.crazyrouter.com",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://cn.crazyrouter.com",
|
||||
model: "gemini-3.5-flash",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "CrazyRouter",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
@@ -371,7 +385,7 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://e-flowcode.cc",
|
||||
GEMINI_API_KEY: "",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
config: {
|
||||
general: {
|
||||
@@ -391,7 +405,7 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
baseURL: "https://e-flowcode.cc",
|
||||
model: "gemini-3.5-flash",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "E-FlowCode",
|
||||
category: "third_party",
|
||||
endpointCandidates: ["https://e-flowcode.cc"],
|
||||
@@ -406,11 +420,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://open.cherryin.net",
|
||||
GEMINI_API_KEY: "",
|
||||
GEMINI_MODEL: "google/gemini-3.5-flash",
|
||||
GEMINI_MODEL: "google/gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://open.cherryin.net",
|
||||
model: "google/gemini-3.5-flash",
|
||||
model: "google/gemini-3.6-flash",
|
||||
description: "CherryIN",
|
||||
category: "aggregator",
|
||||
endpointCandidates: ["https://open.cherryin.net"],
|
||||
@@ -423,11 +437,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://openrouter.ai/api",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://openrouter.ai/api",
|
||||
model: "gemini-3.5-flash",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "OpenRouter",
|
||||
category: "aggregator",
|
||||
icon: "openrouter",
|
||||
@@ -440,11 +454,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.therouter.ai",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.therouter.ai",
|
||||
model: "gemini-3.5-flash",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "TheRouter",
|
||||
category: "aggregator",
|
||||
endpointCandidates: ["https://api.therouter.ai"],
|
||||
@@ -455,10 +469,10 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
GEMINI_MODEL: "gemini-3.6-flash",
|
||||
},
|
||||
},
|
||||
model: "gemini-3.5-flash",
|
||||
model: "gemini-3.6-flash",
|
||||
description: "自定义 Gemini API 端点",
|
||||
category: "custom",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
@@ -120,16 +117,15 @@ export const grokBuildProviderPresets: GrokBuildProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "AICodeMirror",
|
||||
websiteUrl: "https://www.aicodemirror.com",
|
||||
apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3",
|
||||
websiteUrl: "https://www.aicodemirror.ai",
|
||||
apiKeyUrl: "https://www.aicodemirror.ai/register?invitecode=9915W3",
|
||||
auth: grokAuth(),
|
||||
config: grokPresetConfig(
|
||||
"AICodeMirror",
|
||||
"https://api.aicodemirror.com/api/codex/backend-api/codex",
|
||||
"https://api.aicodemirror.ai/api/codex/backend-api/codex",
|
||||
),
|
||||
endpointCandidates: [
|
||||
"https://api.aicodemirror.com/api/codex/backend-api/codex",
|
||||
"https://api.claudecode.net.cn/api/codex/backend-api/codex",
|
||||
"https://api.aicodemirror.ai/api/codex/backend-api/codex",
|
||||
],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicodemirror",
|
||||
@@ -369,10 +365,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 +451,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"],
|
||||
|
||||
@@ -35,7 +35,7 @@ export function isHermesReadOnlyProvider(settingsConfig: unknown): boolean {
|
||||
*
|
||||
* ```yaml
|
||||
* models:
|
||||
* anthropic/claude-opus-4-8:
|
||||
* anthropic/claude-opus-5:
|
||||
* context_length: 200000
|
||||
* ```
|
||||
*
|
||||
@@ -171,15 +171,15 @@ 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: [
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
@@ -189,7 +189,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
partnerPromotionKey: "packycode",
|
||||
icon: "packycode",
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-opus-4-8", provider: "packycode" },
|
||||
model: { default: "claude-opus-5", provider: "packycode" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -201,29 +201,29 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
base_url: "https://api.zetaapi.ai/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zetaapi",
|
||||
icon: "zetaapi",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "zetaapi" },
|
||||
model: { default: "gpt-5.6-sol", provider: "zetaapi" },
|
||||
},
|
||||
},
|
||||
{
|
||||
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: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -232,20 +232,20 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
partnerPromotionKey: "apinebula",
|
||||
icon: "apinebula",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "apinebula" },
|
||||
model: { default: "gpt-5.6-sol", provider: "apinebula" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AICodeMirror",
|
||||
websiteUrl: "https://www.aicodemirror.com",
|
||||
apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3",
|
||||
websiteUrl: "https://www.aicodemirror.ai",
|
||||
apiKeyUrl: "https://www.aicodemirror.ai/register?invitecode=9915W3",
|
||||
settingsConfig: {
|
||||
name: "aicodemirror",
|
||||
base_url: "https://api.aicodemirror.com/api/claudecode",
|
||||
base_url: "https://api.aicodemirror.ai/api/claudecode",
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
@@ -256,7 +256,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
icon: "aicodemirror",
|
||||
iconColor: "#000000",
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-opus-4-8", provider: "aicodemirror" },
|
||||
model: { default: "claude-opus-5", provider: "aicodemirror" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -269,14 +269,14 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
base_url: "https://api.fenno.ai/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "fenno",
|
||||
icon: "fenno",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "fenno" },
|
||||
model: { default: "gpt-5.6-sol", provider: "fenno" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -289,7 +289,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
@@ -320,8 +320,8 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_mode: "chat_completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
context_length: 400000,
|
||||
},
|
||||
],
|
||||
@@ -331,7 +331,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "unity2" },
|
||||
model: { default: "gpt-5.6-sol", provider: "unity2" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -344,14 +344,14 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
base_url: "https://router.shengsuanyun.com/api/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "openai/gpt-5.5", name: "GPT-5.5" }],
|
||||
models: [{ id: "openai/gpt-5.6-sol", name: "GPT-5.6 Sol" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "shengsuanyun",
|
||||
icon: "shengsuanyun",
|
||||
suggestedDefaults: {
|
||||
model: { default: "openai/gpt-5.5", provider: "shengsuanyun" },
|
||||
model: { default: "openai/gpt-5.6-sol", provider: "shengsuanyun" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -364,7 +364,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
@@ -375,7 +375,31 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
icon: "aigocode",
|
||||
iconColor: "#5B7FFF",
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-opus-4-8", provider: "aigocode" },
|
||||
model: { default: "claude-opus-5", provider: "aigocode" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AICoding",
|
||||
websiteUrl: "https://aicoding.inc",
|
||||
apiKeyUrl: "https://aicoding.inc/i/CCSWITCH",
|
||||
settingsConfig: {
|
||||
name: "aicoding",
|
||||
base_url: "https://api.aicoding.inc",
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicoding",
|
||||
icon: "aicoding",
|
||||
iconColor: "#000000",
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-opus-5", provider: "aicoding" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -389,8 +413,8 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_mode: "chat_completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
context_length: 400000,
|
||||
},
|
||||
],
|
||||
@@ -400,7 +424,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
partnerPromotionKey: "subrouter",
|
||||
icon: "subrouter",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "subrouter" },
|
||||
model: { default: "gpt-5.6-sol", provider: "subrouter" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -414,8 +438,8 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
context_length: 1000000,
|
||||
},
|
||||
{
|
||||
@@ -435,7 +459,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
partnerPromotionKey: "apikeyfun",
|
||||
icon: "apikeyfun",
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-opus-4-8", provider: "apikeyfun" },
|
||||
model: { default: "claude-opus-5", provider: "apikeyfun" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -447,14 +471,14 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
base_url: "https://code0.ai/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "code0",
|
||||
icon: "code0",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "code0" },
|
||||
model: { default: "gpt-5.6-sol", provider: "code0" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -467,14 +491,14 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
base_url: "https://api.teamorouter.com/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "teamorouter",
|
||||
icon: "teamorouter",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "teamorouter" },
|
||||
model: { default: "gpt-5.6-sol", provider: "teamorouter" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -487,7 +511,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
@@ -657,14 +681,14 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
base_url: "https://nekocode.ai/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "nekocode",
|
||||
icon: "nekocode",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "nekocode" },
|
||||
model: { default: "gpt-5.6-sol", provider: "nekocode" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -702,7 +726,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
base_url: "https://api.modelverse.cn/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
@@ -710,7 +734,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
icon: "ucloud",
|
||||
iconColor: "#000000",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "compshare" },
|
||||
model: { default: "gpt-5.6-sol", provider: "compshare" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -724,7 +748,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
base_url: "https://cp.compshare.cn/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
@@ -732,7 +756,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
icon: "ucloud",
|
||||
iconColor: "#000000",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "compshare_coding" },
|
||||
model: { default: "gpt-5.6-sol", provider: "compshare_coding" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -746,8 +770,8 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_mode: "chat_completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
context_length: 400000,
|
||||
},
|
||||
],
|
||||
@@ -757,7 +781,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
partnerPromotionKey: "ccsub",
|
||||
icon: "ccsub",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "ccsub" },
|
||||
model: { default: "gpt-5.6-sol", provider: "ccsub" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -770,7 +794,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
@@ -781,7 +805,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
icon: "sssaicode",
|
||||
iconColor: "#000000",
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-opus-4-8", provider: "sssaicode" },
|
||||
model: { default: "claude-opus-5", provider: "sssaicode" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -794,7 +818,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
@@ -805,20 +829,20 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
icon: "micu",
|
||||
iconColor: "#000000",
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-opus-4-8", provider: "micu" },
|
||||
model: { default: "claude-opus-5", provider: "micu" },
|
||||
},
|
||||
},
|
||||
{
|
||||
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: [
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
@@ -829,7 +853,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
icon: "rc",
|
||||
iconColor: "#E96B2C",
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-opus-4-8", provider: "rightcode" },
|
||||
model: { default: "claude-opus-5", provider: "rightcode" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -842,7 +866,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
@@ -853,7 +877,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
icon: "etok",
|
||||
iconColor: "#000000",
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-opus-4-8", provider: "etok" },
|
||||
model: { default: "claude-opus-5", provider: "etok" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -866,7 +890,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
@@ -877,7 +901,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
icon: "cubence",
|
||||
iconColor: "#000000",
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-opus-4-8", provider: "cubence" },
|
||||
model: { default: "claude-opus-5", provider: "cubence" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -890,7 +914,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
@@ -901,7 +925,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
icon: "crazyrouter",
|
||||
iconColor: "#000000",
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-opus-4-8", provider: "crazyrouter" },
|
||||
model: { default: "claude-opus-5", provider: "crazyrouter" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -913,13 +937,13 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
base_url: "https://www.dmxapi.cn/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "dmxapi",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "dmxapi" },
|
||||
model: { default: "gpt-5.6-sol", provider: "dmxapi" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -932,21 +956,21 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
base_url: "https://api.qnaigc.com/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "qiniu",
|
||||
icon: "qiniu",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "qiniu" },
|
||||
model: { default: "gpt-5.6-sol", provider: "qiniu" },
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
@@ -978,8 +1002,8 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_mode: "codex_responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -987,7 +1011,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
isPartner: true,
|
||||
icon: "sudocode-us",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "sudocode_us" },
|
||||
model: { default: "gpt-5.6-sol", provider: "sudocode_us" },
|
||||
},
|
||||
},
|
||||
// ===== 非赞助商预设:应用内展示按显示名排序,此处文件顺序不影响展示 =====
|
||||
@@ -1000,12 +1024,12 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
base_url: "https://api.amux.ai/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
icon: "amux",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "amux" },
|
||||
model: { default: "gpt-5.6-sol", provider: "amux" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1020,8 +1044,8 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_mode: "chat_completions",
|
||||
models: [
|
||||
{
|
||||
id: "anthropic/claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "anthropic/claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
context_length: 1000000,
|
||||
},
|
||||
{
|
||||
@@ -1035,13 +1059,13 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
context_length: 200000,
|
||||
},
|
||||
{
|
||||
id: "openai/gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "openai/gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
context_length: 400000,
|
||||
},
|
||||
{
|
||||
id: "google/gemini-3.5-flash",
|
||||
name: "Gemini 3.5 Flash",
|
||||
id: "google/gemini-3.6-flash",
|
||||
name: "Gemini 3.6 Flash",
|
||||
context_length: 1000000,
|
||||
},
|
||||
],
|
||||
@@ -1050,7 +1074,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
icon: "openrouter",
|
||||
iconColor: "#6366F1",
|
||||
suggestedDefaults: {
|
||||
model: { default: "anthropic/claude-opus-4-8", provider: "openrouter" },
|
||||
model: { default: "anthropic/claude-opus-5", provider: "openrouter" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1378,13 +1402,13 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
base_url: "https://aihubmix.com/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }],
|
||||
},
|
||||
category: "aggregator",
|
||||
icon: "aihubmix",
|
||||
iconColor: "#006FFB",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "aihubmix" },
|
||||
model: { default: "gpt-5.6-sol", provider: "aihubmix" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1397,14 +1421,14 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{ id: "anthropic/claude-opus-4.8", name: "Claude Opus 4.8" },
|
||||
{ id: "anthropic/claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
icon: "cherryin",
|
||||
suggestedDefaults: {
|
||||
model: { default: "anthropic/claude-opus-4.8", provider: "cherryin" },
|
||||
model: { default: "anthropic/claude-opus-5", provider: "cherryin" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1417,7 +1441,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
@@ -1426,7 +1450,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
icon: "eflowcode",
|
||||
iconColor: "#000000",
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-opus-4-8", provider: "eflowcode" },
|
||||
model: { default: "claude-opus-5", provider: "eflowcode" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1439,7 +1463,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [
|
||||
{ id: "openai/gpt-5.5", name: "GPT-5.5" },
|
||||
{ id: "openai/gpt-5.6-sol", name: "GPT-5.6 Sol" },
|
||||
{ id: "openai/gpt-5.4-mini", name: "GPT-5.4 mini" },
|
||||
{ id: "openai/gpt-5.4-nano", name: "GPT-5.4 nano" },
|
||||
],
|
||||
@@ -1447,7 +1471,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
category: "aggregator",
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
default: "openai/gpt-5.5",
|
||||
default: "openai/gpt-5.6-sol",
|
||||
provider: "therouter",
|
||||
},
|
||||
},
|
||||
@@ -1498,7 +1522,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||
{
|
||||
id: "claude-haiku-4-5-20251001",
|
||||
@@ -1509,7 +1533,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
category: "aggregator",
|
||||
icon: "pipellm",
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-opus-4-8", provider: "pipellm" },
|
||||
model: { default: "claude-opus-5", provider: "pipellm" },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -151,7 +151,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
websiteUrl: "https://www.kimi.com/code/?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.kimi.com/console/api-keys?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.kimi.com/v1",
|
||||
baseUrl: "https://api.kimi.com/coding/v1",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
@@ -169,8 +169,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
templateValues: {
|
||||
baseUrl: {
|
||||
label: "Base URL",
|
||||
placeholder: "https://api.kimi.com/v1",
|
||||
defaultValue: "https://api.kimi.com/v1",
|
||||
placeholder: "https://api.kimi.com/coding/v1",
|
||||
defaultValue: "https://api.kimi.com/coding/v1",
|
||||
editorValue: "",
|
||||
},
|
||||
apiKey: {
|
||||
@@ -187,16 +187,16 @@ 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: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
@@ -221,11 +221,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "packycode/claude-opus-4-8",
|
||||
primary: "packycode/claude-opus-5",
|
||||
fallbacks: ["packycode/claude-sonnet-5"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"packycode/claude-opus-4-8": { alias: "Opus" },
|
||||
"packycode/claude-opus-5": { alias: "Opus" },
|
||||
"packycode/claude-sonnet-5": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
@@ -240,8 +240,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
contextWindow: 400000,
|
||||
},
|
||||
],
|
||||
@@ -259,25 +259,25 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "zetaapi/gpt-5.5",
|
||||
primary: "zetaapi/gpt-5.6-sol",
|
||||
},
|
||||
modelCatalog: {
|
||||
"zetaapi/gpt-5.5": { alias: "GPT-5.5" },
|
||||
"zetaapi/gpt-5.6-sol": { alias: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -294,22 +294,22 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "apinebula/gpt-5.5",
|
||||
primary: "apinebula/gpt-5.6-sol",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AICodeMirror",
|
||||
websiteUrl: "https://www.aicodemirror.com",
|
||||
apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3",
|
||||
websiteUrl: "https://www.aicodemirror.ai",
|
||||
apiKeyUrl: "https://www.aicodemirror.ai/register?invitecode=9915W3",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.aicodemirror.com/api/claudecode",
|
||||
baseUrl: "https://api.aicodemirror.ai/api/claudecode",
|
||||
apiKey: "",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
@@ -335,11 +335,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "aicodemirror/claude-opus-4-8",
|
||||
primary: "aicodemirror/claude-opus-5",
|
||||
fallbacks: ["aicodemirror/claude-sonnet-5"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"aicodemirror/claude-opus-4-8": { alias: "Opus" },
|
||||
"aicodemirror/claude-opus-5": { alias: "Opus" },
|
||||
"aicodemirror/claude-sonnet-5": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
@@ -355,8 +355,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
contextWindow: 400000,
|
||||
},
|
||||
],
|
||||
@@ -374,10 +374,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "fenno/gpt-5.5",
|
||||
primary: "fenno/gpt-5.6-sol",
|
||||
},
|
||||
modelCatalog: {
|
||||
"fenno/gpt-5.5": { alias: "GPT-5.5" },
|
||||
"fenno/gpt-5.6-sol": { alias: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -391,8 +391,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
},
|
||||
{
|
||||
@@ -423,7 +423,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
primary: "runapi/claude-sonnet-5",
|
||||
},
|
||||
modelCatalog: {
|
||||
"runapi/claude-opus-4-8": { alias: "Opus" },
|
||||
"runapi/claude-opus-5": { alias: "Opus" },
|
||||
"runapi/claude-sonnet-5": { alias: "Sonnet" },
|
||||
"runapi/claude-haiku-4-5": { alias: "Haiku" },
|
||||
},
|
||||
@@ -439,8 +439,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
contextWindow: 400000,
|
||||
cost: { input: 5, output: 15 },
|
||||
},
|
||||
@@ -459,10 +459,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "unity2/gpt-5.5",
|
||||
primary: "unity2/gpt-5.6-sol",
|
||||
},
|
||||
modelCatalog: {
|
||||
"unity2/gpt-5.5": { alias: "GPT-5.5" },
|
||||
"unity2/gpt-5.6-sol": { alias: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -477,8 +477,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "anthropic/claude-opus-4.8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "anthropic/claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
@@ -503,11 +503,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "shengsuanyun/anthropic/claude-opus-4.8",
|
||||
primary: "shengsuanyun/anthropic/claude-opus-5",
|
||||
fallbacks: ["shengsuanyun/anthropic/claude-sonnet-5"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"shengsuanyun/anthropic/claude-opus-4.8": { alias: "Opus" },
|
||||
"shengsuanyun/anthropic/claude-opus-5": { alias: "Opus" },
|
||||
"shengsuanyun/anthropic/claude-sonnet-5": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
@@ -522,8 +522,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
@@ -549,15 +549,61 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "aigocode/claude-opus-4-8",
|
||||
primary: "aigocode/claude-opus-5",
|
||||
fallbacks: ["aigocode/claude-sonnet-5"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"aigocode/claude-opus-4-8": { alias: "Opus" },
|
||||
"aigocode/claude-opus-5": { alias: "Opus" },
|
||||
"aigocode/claude-sonnet-5": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AICoding",
|
||||
websiteUrl: "https://aicoding.inc",
|
||||
apiKeyUrl: "https://aicoding.inc/i/CCSWITCH",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.aicoding.inc",
|
||||
apiKey: "",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-5",
|
||||
name: "Claude Sonnet 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicoding",
|
||||
icon: "aicoding",
|
||||
iconColor: "#000000",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "aicoding/claude-opus-5",
|
||||
fallbacks: ["aicoding/claude-sonnet-5"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"aicoding/claude-opus-5": { alias: "Opus" },
|
||||
"aicoding/claude-sonnet-5": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SubRouter",
|
||||
websiteUrl: "https://subrouter.ai",
|
||||
@@ -568,8 +614,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
contextWindow: 400000,
|
||||
},
|
||||
],
|
||||
@@ -587,10 +633,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "subrouter/gpt-5.5",
|
||||
primary: "subrouter/gpt-5.6-sol",
|
||||
},
|
||||
modelCatalog: {
|
||||
"subrouter/gpt-5.5": { alias: "GPT-5.5" },
|
||||
"subrouter/gpt-5.6-sol": { alias: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -604,8 +650,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
},
|
||||
{
|
||||
@@ -633,11 +679,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "apikeyfun/claude-opus-4-8",
|
||||
primary: "apikeyfun/claude-opus-5",
|
||||
fallbacks: ["apikeyfun/claude-sonnet-5"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"apikeyfun/claude-opus-4-8": { alias: "Opus" },
|
||||
"apikeyfun/claude-opus-5": { alias: "Opus" },
|
||||
"apikeyfun/claude-sonnet-5": { alias: "Sonnet" },
|
||||
"apikeyfun/claude-haiku-4-5": { alias: "Haiku" },
|
||||
},
|
||||
@@ -653,8 +699,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
contextWindow: 400000,
|
||||
},
|
||||
],
|
||||
@@ -672,10 +718,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "code0/gpt-5.5",
|
||||
primary: "code0/gpt-5.6-sol",
|
||||
},
|
||||
modelCatalog: {
|
||||
"code0/gpt-5.5": { alias: "GPT-5.5" },
|
||||
"code0/gpt-5.6-sol": { alias: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -690,8 +736,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
contextWindow: 400000,
|
||||
},
|
||||
],
|
||||
@@ -709,10 +755,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "teamorouter/gpt-5.5",
|
||||
primary: "teamorouter/gpt-5.6-sol",
|
||||
},
|
||||
modelCatalog: {
|
||||
"teamorouter/gpt-5.5": { alias: "GPT-5.5" },
|
||||
"teamorouter/gpt-5.6-sol": { alias: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -726,8 +772,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
},
|
||||
{
|
||||
@@ -758,7 +804,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
primary: "claudecn/claude-sonnet-5",
|
||||
},
|
||||
modelCatalog: {
|
||||
"claudecn/claude-opus-4-8": { alias: "Opus" },
|
||||
"claudecn/claude-opus-5": { alias: "Opus" },
|
||||
"claudecn/claude-sonnet-5": { alias: "Sonnet" },
|
||||
"claudecn/claude-haiku-4-5": { alias: "Haiku" },
|
||||
},
|
||||
@@ -958,8 +1004,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
contextWindow: 400000,
|
||||
},
|
||||
],
|
||||
@@ -977,10 +1023,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "nekocode/gpt-5.5",
|
||||
primary: "nekocode/gpt-5.6-sol",
|
||||
},
|
||||
modelCatalog: {
|
||||
"nekocode/gpt-5.5": { alias: "GPT-5.5" },
|
||||
"nekocode/gpt-5.6-sol": { alias: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1028,8 +1074,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
@@ -1049,10 +1095,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "compshare/claude-opus-4-8",
|
||||
primary: "compshare/claude-opus-5",
|
||||
},
|
||||
modelCatalog: {
|
||||
"compshare/claude-opus-4-8": { alias: "Opus" },
|
||||
"compshare/claude-opus-5": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1068,8 +1114,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
@@ -1089,10 +1135,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "compshare-coding/claude-opus-4-8",
|
||||
primary: "compshare-coding/claude-opus-5",
|
||||
},
|
||||
modelCatalog: {
|
||||
"compshare-coding/claude-opus-4-8": { alias: "Opus" },
|
||||
"compshare-coding/claude-opus-5": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1106,8 +1152,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
contextWindow: 400000,
|
||||
cost: { input: 5, output: 15 },
|
||||
},
|
||||
@@ -1126,10 +1172,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "ccsub/gpt-5.5",
|
||||
primary: "ccsub/gpt-5.6-sol",
|
||||
},
|
||||
modelCatalog: {
|
||||
"ccsub/gpt-5.5": { alias: "GPT-5.5" },
|
||||
"ccsub/gpt-5.6-sol": { alias: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1143,8 +1189,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
@@ -1170,11 +1216,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "sssaicode/claude-opus-4-8",
|
||||
primary: "sssaicode/claude-opus-5",
|
||||
fallbacks: ["sssaicode/claude-sonnet-5"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"sssaicode/claude-opus-4-8": { alias: "Opus" },
|
||||
"sssaicode/claude-opus-5": { alias: "Opus" },
|
||||
"sssaicode/claude-sonnet-5": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
@@ -1189,8 +1235,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
@@ -1210,25 +1256,25 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "micu/claude-opus-4-8",
|
||||
primary: "micu/claude-opus-5",
|
||||
},
|
||||
modelCatalog: {
|
||||
"micu/claude-opus-4-8": { alias: "Opus" },
|
||||
"micu/claude-opus-5": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
@@ -1254,11 +1300,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "rightcode/claude-opus-4-8",
|
||||
primary: "rightcode/claude-opus-5",
|
||||
fallbacks: ["rightcode/claude-sonnet-5"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"rightcode/claude-opus-4-8": { alias: "Opus" },
|
||||
"rightcode/claude-opus-5": { alias: "Opus" },
|
||||
"rightcode/claude-sonnet-5": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
@@ -1273,8 +1319,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
@@ -1294,10 +1340,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "etok/claude-opus-4-8",
|
||||
primary: "etok/claude-opus-5",
|
||||
},
|
||||
modelCatalog: {
|
||||
"etok/claude-opus-4-8": { alias: "Opus" },
|
||||
"etok/claude-opus-5": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1311,8 +1357,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
@@ -1338,11 +1384,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "cubence/claude-opus-4-8",
|
||||
primary: "cubence/claude-opus-5",
|
||||
fallbacks: ["cubence/claude-sonnet-5"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"cubence/claude-opus-4-8": { alias: "Opus" },
|
||||
"cubence/claude-opus-5": { alias: "Opus" },
|
||||
"cubence/claude-sonnet-5": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
@@ -1357,8 +1403,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
@@ -1384,11 +1430,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "crazyrouter/claude-opus-4-8",
|
||||
primary: "crazyrouter/claude-opus-5",
|
||||
fallbacks: ["crazyrouter/claude-sonnet-5"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"crazyrouter/claude-opus-4-8": { alias: "Opus" },
|
||||
"crazyrouter/claude-opus-5": { alias: "Opus" },
|
||||
"crazyrouter/claude-sonnet-5": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
@@ -1403,8 +1449,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
@@ -1428,11 +1474,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "dmxapi/claude-opus-4-8",
|
||||
primary: "dmxapi/claude-opus-5",
|
||||
fallbacks: ["dmxapi/claude-sonnet-5"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"dmxapi/claude-opus-4-8": { alias: "Opus" },
|
||||
"dmxapi/claude-opus-5": { alias: "Opus" },
|
||||
"dmxapi/claude-sonnet-5": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
@@ -1448,8 +1494,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
contextWindow: 400000,
|
||||
},
|
||||
],
|
||||
@@ -1467,10 +1513,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "qiniu/gpt-5.5",
|
||||
primary: "qiniu/gpt-5.6-sol",
|
||||
},
|
||||
modelCatalog: {
|
||||
"qiniu/gpt-5.5": { alias: "GPT-5.5" },
|
||||
"qiniu/gpt-5.6-sol": { alias: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -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: "",
|
||||
@@ -1517,8 +1563,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1534,7 +1580,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "sudocode-us/gpt-5.5",
|
||||
primary: "sudocode-us/gpt-5.6-sol",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1549,8 +1595,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
contextWindow: 400000,
|
||||
},
|
||||
],
|
||||
@@ -1566,10 +1612,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "amux/gpt-5.5",
|
||||
primary: "amux/gpt-5.6-sol",
|
||||
},
|
||||
modelCatalog: {
|
||||
"amux/gpt-5.5": { alias: "GPT-5.5" },
|
||||
"amux/gpt-5.6-sol": { alias: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2107,8 +2153,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
@@ -2132,11 +2178,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "aihubmix/claude-opus-4-8",
|
||||
primary: "aihubmix/claude-opus-5",
|
||||
fallbacks: ["aihubmix/claude-sonnet-5"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"aihubmix/claude-opus-4-8": { alias: "Opus" },
|
||||
"aihubmix/claude-opus-5": { alias: "Opus" },
|
||||
"aihubmix/claude-sonnet-5": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
@@ -2151,8 +2197,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "anthropic/claude-opus-4.8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "anthropic/claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
},
|
||||
{
|
||||
@@ -2173,11 +2219,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "cherryin/anthropic/claude-opus-4.8",
|
||||
primary: "cherryin/anthropic/claude-opus-5",
|
||||
fallbacks: ["cherryin/anthropic/claude-sonnet-5"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"cherryin/anthropic/claude-opus-4.8": { alias: "Opus" },
|
||||
"cherryin/anthropic/claude-opus-5": { alias: "Opus" },
|
||||
"cherryin/anthropic/claude-sonnet-5": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
@@ -2192,8 +2238,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "anthropic/claude-opus-4.8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "anthropic/claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
@@ -2217,11 +2263,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "openrouter/anthropic/claude-opus-4.8",
|
||||
primary: "openrouter/anthropic/claude-opus-5",
|
||||
fallbacks: ["openrouter/anthropic/claude-sonnet-5"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"openrouter/anthropic/claude-opus-4.8": { alias: "Opus" },
|
||||
"openrouter/anthropic/claude-opus-5": { alias: "Opus" },
|
||||
"openrouter/anthropic/claude-sonnet-5": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
@@ -2254,8 +2300,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
cost: { input: 1.75, output: 14, cacheRead: 0.175 },
|
||||
},
|
||||
{
|
||||
id: "google/gemini-3.5-flash",
|
||||
name: "Gemini 3.5 Flash",
|
||||
id: "google/gemini-3.6-flash",
|
||||
name: "Gemini 3.6 Flash",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 1.5, output: 9, cacheRead: 0.15 },
|
||||
},
|
||||
@@ -2280,13 +2326,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
primary: "therouter/anthropic/claude-sonnet-5",
|
||||
fallbacks: [
|
||||
"therouter/openai/gpt-5.2",
|
||||
"therouter/google/gemini-3.5-flash",
|
||||
"therouter/google/gemini-3.6-flash",
|
||||
],
|
||||
},
|
||||
modelCatalog: {
|
||||
"therouter/anthropic/claude-sonnet-5": { alias: "Sonnet" },
|
||||
"therouter/openai/gpt-5.2": { alias: "GPT-5.2" },
|
||||
"therouter/google/gemini-3.5-flash": { alias: "Gemini Flash" },
|
||||
"therouter/google/gemini-3.6-flash": { alias: "Gemini Flash" },
|
||||
"therouter/openai/gpt-5.3-codex": { alias: "Codex" },
|
||||
"therouter/qwen/qwen3-coder-480b": { alias: "Qwen Coder" },
|
||||
},
|
||||
@@ -2406,8 +2452,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "claude-opus-4-8",
|
||||
id: "claude-opus-5",
|
||||
name: "claude-opus-5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
@@ -2436,11 +2482,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "pipellm/claude-opus-4-8",
|
||||
primary: "pipellm/claude-opus-5",
|
||||
fallbacks: ["pipellm/claude-sonnet-5"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"pipellm/claude-opus-4-8": { alias: "Opus" },
|
||||
"pipellm/claude-opus-5": { alias: "Opus" },
|
||||
"pipellm/claude-sonnet-5": { alias: "Sonnet" },
|
||||
"pipellm/claude-haiku-4-5-20251001": { alias: "Haiku" },
|
||||
},
|
||||
@@ -2472,8 +2518,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
name: "gpt-5.3-codex",
|
||||
},
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "gpt-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "gpt-5.6-sol",
|
||||
},
|
||||
{
|
||||
id: "gpt-5.2-codex",
|
||||
@@ -2498,11 +2544,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "eflowcode/gpt-5.3-codex",
|
||||
fallbacks: ["eflowcode/gpt-5.5", "eflowcode/gpt-5.2-codex"],
|
||||
fallbacks: ["eflowcode/gpt-5.6-sol", "eflowcode/gpt-5.2-codex"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"eflowcode/gpt-5.3-codex": { alias: "gpt-5.3-codex" },
|
||||
"eflowcode/gpt-5.5": { alias: "gpt-5.5" },
|
||||
"eflowcode/gpt-5.6-sol": { alias: "gpt-5.6-sol" },
|
||||
"eflowcode/gpt-5.2-codex": { alias: "gpt-5.2-codex" },
|
||||
"eflowcode/gpt-5.2": { alias: "gpt-5.2" },
|
||||
},
|
||||
@@ -2518,8 +2564,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "bedrock-converse-stream",
|
||||
models: [
|
||||
{
|
||||
id: "anthropic.claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "anthropic.claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
},
|
||||
|
||||
@@ -99,8 +99,8 @@ export const OPENCODE_PRESET_MODEL_VARIANTS: Record<
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gemini-3.5-flash",
|
||||
name: "Gemini 3.5 Flash",
|
||||
id: "gemini-3.6-flash",
|
||||
name: "Gemini 3.6 Flash",
|
||||
contextLimit: 1048576,
|
||||
outputLimit: 65536,
|
||||
modalities: {
|
||||
@@ -125,8 +125,8 @@ export const OPENCODE_PRESET_MODEL_VARIANTS: Record<
|
||||
],
|
||||
"@ai-sdk/openai": [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
contextLimit: 400000,
|
||||
outputLimit: 128000,
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
@@ -156,8 +156,8 @@ export const OPENCODE_PRESET_MODEL_VARIANTS: Record<
|
||||
],
|
||||
"@ai-sdk/amazon-bedrock": [
|
||||
{
|
||||
id: "global.anthropic.claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "global.anthropic.claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextLimit: 1000000,
|
||||
outputLimit: 128000,
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
@@ -224,8 +224,8 @@ export const OPENCODE_PRESET_MODEL_VARIANTS: Record<
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
id: "claude-opus-5",
|
||||
name: "Claude Opus 5",
|
||||
contextLimit: 1000000,
|
||||
outputLimit: 128000,
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
@@ -359,19 +359,19 @@ 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,
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
"claude-opus-4-8": { name: "Claude Opus 4.8" },
|
||||
"claude-opus-5": { name: "Claude Opus 5" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -399,7 +399,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -416,18 +416,18 @@ 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,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -444,19 +444,19 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "AICodeMirror",
|
||||
websiteUrl: "https://www.aicodemirror.com",
|
||||
apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3",
|
||||
websiteUrl: "https://www.aicodemirror.ai",
|
||||
apiKeyUrl: "https://www.aicodemirror.ai/register?invitecode=9915W3",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "AICodeMirror",
|
||||
options: {
|
||||
baseURL: "https://api.aicodemirror.com/api/claudecode",
|
||||
baseURL: "https://api.aicodemirror.ai/api/claudecode",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
"claude-opus-4.8": { name: "Claude Opus 4.8" },
|
||||
"claude-opus-5": { name: "Claude Opus 5" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -486,7 +486,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -515,7 +515,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
"claude-opus-4-8": { name: "Claude Opus 4.8" },
|
||||
"claude-opus-5": { name: "Claude Opus 5" },
|
||||
"claude-haiku-4-5": { name: "Claude Haiku 4.5" },
|
||||
},
|
||||
},
|
||||
@@ -544,7 +544,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -573,7 +573,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-opus-4.8": { name: "Claude Opus 4.8" },
|
||||
"anthropic/claude-opus-5": { name: "Claude Opus 5" },
|
||||
"anthropic/claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
},
|
||||
},
|
||||
@@ -603,7 +603,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
"claude-opus-4-8": { name: "Claude Opus 4.8" },
|
||||
"claude-opus-5": { name: "Claude Opus 5" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -619,6 +619,36 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AICoding",
|
||||
websiteUrl: "https://aicoding.inc",
|
||||
apiKeyUrl: "https://aicoding.inc/i/CCSWITCH",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "AICoding",
|
||||
options: {
|
||||
baseURL: "https://api.aicoding.inc",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
"claude-opus-5": { name: "Claude Opus 5" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicoding",
|
||||
icon: "aicoding",
|
||||
iconColor: "#000000",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SubRouter",
|
||||
websiteUrl: "https://subrouter.ai",
|
||||
@@ -632,7 +662,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -660,7 +690,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"claude-opus-4-8": { name: "Claude Opus 4.8" },
|
||||
"claude-opus-5": { name: "Claude Opus 5" },
|
||||
"claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
"claude-haiku-4-5": { name: "Claude Haiku 4.5" },
|
||||
},
|
||||
@@ -690,7 +720,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -719,7 +749,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -748,7 +778,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
"claude-opus-4-8": { name: "Claude Opus 4.8" },
|
||||
"claude-opus-5": { name: "Claude Opus 5" },
|
||||
"claude-haiku-4-5": { name: "Claude Haiku 4.5" },
|
||||
},
|
||||
},
|
||||
@@ -876,7 +906,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -932,7 +962,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -961,7 +991,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
"claude-opus-4-8": { name: "Claude Opus 4.8" },
|
||||
"claude-opus-5": { name: "Claude Opus 5" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -990,7 +1020,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"claude-opus-4-8": { name: "Claude Opus 4.8" },
|
||||
"claude-opus-5": { name: "Claude Opus 5" },
|
||||
"claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
},
|
||||
},
|
||||
@@ -1009,18 +1039,18 @@ 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,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -1049,7 +1079,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"claude-opus-4-8": { name: "Claude Opus 4.8" },
|
||||
"claude-opus-5": { name: "Claude Opus 5" },
|
||||
"claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
},
|
||||
},
|
||||
@@ -1080,7 +1110,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
"claude-opus-4-8": { name: "Claude Opus 4.8" },
|
||||
"claude-opus-5": { name: "Claude Opus 5" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -1110,7 +1140,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
"claude-opus-4-8": { name: "Claude Opus 4.8" },
|
||||
"claude-opus-5": { name: "Claude Opus 5" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -1140,7 +1170,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
"claude-opus-4-8": { name: "Claude Opus 4.8" },
|
||||
"claude-opus-5": { name: "Claude Opus 5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -1168,7 +1198,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -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",
|
||||
@@ -1225,7 +1255,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -1253,7 +1283,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -1801,7 +1831,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
"claude-opus-4-8": { name: "Claude Opus 4.8" },
|
||||
"claude-opus-5": { name: "Claude Opus 5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -1829,7 +1859,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
"anthropic/claude-opus-4.8": { name: "Claude Opus 4.8" },
|
||||
"anthropic/claude-opus-5": { name: "Claude Opus 5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -1856,7 +1886,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
"anthropic/claude-opus-4.8": { name: "Claude Opus 4.8" },
|
||||
"anthropic/claude-opus-5": { name: "Claude Opus 5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -1886,8 +1916,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
"anthropic/claude-sonnet-5": { name: "Claude Sonnet 5" },
|
||||
"openai/gpt-5.3-codex": { name: "GPT-5.3 Codex" },
|
||||
"openai/gpt-5.2": { name: "GPT-5.2" },
|
||||
"google/gemini-3.5-flash": {
|
||||
name: "Gemini 3.5 Flash",
|
||||
"google/gemini-3.6-flash": {
|
||||
name: "Gemini 3.6 Flash",
|
||||
},
|
||||
"qwen/qwen3-coder-480b": { name: "Qwen3 Coder 480B" },
|
||||
},
|
||||
@@ -1968,7 +1998,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"claude-opus-4-8": { name: "claude-opus-4-8" },
|
||||
"claude-opus-5": { name: "claude-opus-5" },
|
||||
"claude-sonnet-5": { name: "claude-sonnet-5" },
|
||||
"claude-haiku-4-5-20251001": { name: "claude-haiku-4-5-20251001" },
|
||||
},
|
||||
@@ -2026,7 +2056,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"global.anthropic.claude-opus-4-8": { name: "Claude Opus 4.8" },
|
||||
"global.anthropic.claude-opus-5": { name: "Claude Opus 5" },
|
||||
"global.anthropic.claude-sonnet-5": {
|
||||
name: "Claude Sonnet 5",
|
||||
},
|
||||
|
||||
@@ -44,14 +44,14 @@ const NEWAPI_DEFAULT_MODELS: UniversalProviderModels = {
|
||||
model: "claude-sonnet-5",
|
||||
haikuModel: "claude-haiku-4-5-20251001",
|
||||
sonnetModel: "claude-sonnet-5",
|
||||
opusModel: "claude-opus-4-8",
|
||||
opusModel: "claude-opus-5",
|
||||
},
|
||||
codex: {
|
||||
model: "gpt-5.5",
|
||||
model: "gpt-5.6-sol",
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
gemini: {
|
||||
model: "gemini-3.5-flash",
|
||||
model: "gemini-3.6-flash",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1020,7 +1020,7 @@
|
||||
"providerKeyStatusLoading": "Provider identifier status is still loading. Please try again shortly.",
|
||||
"getApiKey": "Get API Key",
|
||||
"partnerPromotion": {
|
||||
"sudocode": "With one SudoCode key, Claude Code and Claude Desktop use Claude Opus 4.8, while Codex uses GPT-5.6. Sign up, join QQ group 726213516, and contact the group owner to claim CNY ¥10 in trial credit.",
|
||||
"sudocode": "With one SudoCode key, Claude Code and Claude Desktop use Claude Opus 5, while Codex uses GPT-5.6. Sign up, join QQ group 726213516, and contact the group owner to claim CNY ¥10 in trial credit.",
|
||||
"code0": "code0.ai is an AI coding service platform for developers, supporting Claude Code, Codex, and Gemini. Exclusive for CC Switch users: contact support via the official website to claim free trial credits!",
|
||||
"nekocode": "NekoCode gives developers a stable, efficient, and reliable API relay for Claude, Codex, and other AI models, with transparent pay-as-you-go pricing. Exclusive 10% off for CC Switch users: register via the link above and enter promo code cc-switch at recharge to save 10%!",
|
||||
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off",
|
||||
@@ -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",
|
||||
@@ -1344,7 +1345,7 @@
|
||||
"modelMetadataAdvanced": "Model Metadata Advanced Options",
|
||||
"modelMetadataAdvancedHint": "Overrides Codex metadata for unknown models; clearing a field removes the matching config.toml entry.",
|
||||
"defaultModelLabel": "Default Model",
|
||||
"defaultModelPlaceholder": "e.g. gpt-5.6",
|
||||
"defaultModelPlaceholder": "e.g. gpt-5.6-sol",
|
||||
"defaultModelHint": "The model Codex requests by default; change it anytime without waiting for a preset update. If left empty while model mapping is configured, the first mapping row is used.",
|
||||
"defaultModelNotInCatalog": "This model is not in the model mapping, so the Codex /model menu won't list it (direct requests still work).",
|
||||
"addToModelMapping": "Add to mapping",
|
||||
@@ -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",
|
||||
@@ -2157,9 +2160,9 @@
|
||||
"addModel": "Add model",
|
||||
"noModels": "No models configured. Switching to this provider won't change the default model.",
|
||||
"modelId": "Model ID",
|
||||
"modelIdPlaceholder": "anthropic/claude-opus-4-8",
|
||||
"modelIdPlaceholder": "anthropic/claude-opus-5",
|
||||
"modelName": "Display name",
|
||||
"modelNamePlaceholder": "Claude Opus 4.8",
|
||||
"modelNamePlaceholder": "Claude Opus 5",
|
||||
"contextLength": "Context length",
|
||||
"advancedOptions": "Advanced options",
|
||||
"modelsHint": "On switch, the first model is written to top-level model.default.",
|
||||
@@ -2937,13 +2940,13 @@
|
||||
"writing": "Writing"
|
||||
},
|
||||
"categoryTooltip": {
|
||||
"visualEngineering": "Frontend and visual engineering category for UI/UX design, styling, animation and interface implementation. Defaults to Gemini 3.5 Flash model.",
|
||||
"visualEngineering": "Frontend and visual engineering category for UI/UX design, styling, animation and interface implementation. Defaults to Gemini 3.6 Flash model.",
|
||||
"ultrabrain": "Deep logical reasoning category for complex architectural decisions requiring extensive analysis. Defaults to GPT-5.3 Codex ultra-high reasoning variant.",
|
||||
"deep": "Deep autonomous problem-solving category with goal-oriented execution. Conducts thorough research before action, suitable for difficult problems requiring deep understanding.",
|
||||
"artistry": "Highly creative and artistic task category that inspires novel ideas and creative solutions. Defaults to Gemini 3.5 Flash high variant.",
|
||||
"artistry": "Highly creative and artistic task category that inspires novel ideas and creative solutions. Defaults to Gemini 3.6 Flash high variant.",
|
||||
"quick": "Lightweight task category for single-file modifications, typo fixes, and simple adjustments. Defaults to Claude Haiku 4.5 fast model.",
|
||||
"unspecifiedLow": "Uncategorized low-effort task category for tasks that don't fit other categories with small workload. Defaults to Claude Sonnet 4.5.",
|
||||
"unspecifiedHigh": "Uncategorized high-effort task category for tasks that don't fit other categories with large workload. Defaults to Claude Opus 4.8 max variant.",
|
||||
"unspecifiedHigh": "Uncategorized high-effort task category for tasks that don't fit other categories with large workload. Defaults to Claude Opus 5 max variant.",
|
||||
"writing": "Writing category for documentation, prose and technical writing. Defaults to Kimi K2.5 model."
|
||||
},
|
||||
"slimAgentDesc": {
|
||||
@@ -3006,6 +3009,7 @@
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "Weekly",
|
||||
"monthly": "Monthly",
|
||||
"credits": "Credits",
|
||||
"copilotPremium": "Premium",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "Resets in {{time}}",
|
||||
|
||||
@@ -1020,7 +1020,7 @@
|
||||
"providerKeyStatusLoading": "プロバイダー識別子の状態を読み込んでいます。しばらくしてからもう一度お試しください",
|
||||
"getApiKey": "API Key を取得",
|
||||
"partnerPromotion": {
|
||||
"sudocode": "SudoCode の1つのキーで、Claude Code と Claude Desktop では Claude Opus 4.8、Codex では GPT-5.6 を利用できます。登録後 QQ グループ 726213516 に参加し、管理者へ連絡すると CNY ¥10 のトライアルクレジットを受け取れます。",
|
||||
"sudocode": "SudoCode の1つのキーで、Claude Code と Claude Desktop では Claude Opus 5、Codex では GPT-5.6 を利用できます。登録後 QQ グループ 726213516 に参加し、管理者へ連絡すると CNY ¥10 のトライアルクレジットを受け取れます。",
|
||||
"code0": "code0.ai は開発者向けの AI コーディングサービスプラットフォームで、Claude Code、Codex、Gemini に対応。CC Switch ユーザー限定特典:公式サイトからサポートに連絡してテストクレジットを受け取れます!",
|
||||
"nekocode": "NekoCode は Claude や Codex などの AI モデルに対応した、安定・高効率で信頼性の高い API 中継サービスを提供します。明瞭な従量課金制。CC Switch ユーザー限定 10%オフ:上のリンクから登録し、チャージ時にクーポンコード cc-switch を入力すると 10%オフ!",
|
||||
"packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ",
|
||||
@@ -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 の公式パートナーです",
|
||||
@@ -1344,7 +1345,7 @@
|
||||
"modelMetadataAdvanced": "モデルメタデータ詳細オプション",
|
||||
"modelMetadataAdvancedHint": "未知のモデル向けに Codex メタデータを上書きします。空にすると対応する config.toml 設定を削除します。",
|
||||
"defaultModelLabel": "デフォルトモデル",
|
||||
"defaultModelPlaceholder": "例: gpt-5.6",
|
||||
"defaultModelPlaceholder": "例: gpt-5.6-sol",
|
||||
"defaultModelHint": "Codex がデフォルトでリクエストするモデルです。プリセットの更新を待たずにいつでも変更できます。空欄のままモデルマッピングを設定している場合は、マッピングの先頭行が使われます。",
|
||||
"defaultModelNotInCatalog": "このモデルはモデルマッピングにないため、Codex の /model メニューには表示されません(直接リクエストは有効です)。",
|
||||
"addToModelMapping": "マッピングに追加",
|
||||
@@ -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": "セッションログを同期",
|
||||
@@ -2157,9 +2160,9 @@
|
||||
"addModel": "モデルを追加",
|
||||
"noModels": "モデル未設定。このプロバイダーに切り替えてもデフォルトモデルは更新されません。",
|
||||
"modelId": "モデル ID",
|
||||
"modelIdPlaceholder": "anthropic/claude-opus-4-8",
|
||||
"modelIdPlaceholder": "anthropic/claude-opus-5",
|
||||
"modelName": "表示名",
|
||||
"modelNamePlaceholder": "Claude Opus 4.8",
|
||||
"modelNamePlaceholder": "Claude Opus 5",
|
||||
"contextLength": "コンテキスト長",
|
||||
"advancedOptions": "詳細オプション",
|
||||
"modelsHint": "切り替え時、最初のモデルが model.default に書き込まれます。",
|
||||
@@ -2937,13 +2940,13 @@
|
||||
"writing": "ライティング"
|
||||
},
|
||||
"categoryTooltip": {
|
||||
"visualEngineering": "フロントエンドとビジュアルエンジニアリングカテゴリ。UI/UX デザイン、スタイリング、アニメーション、インターフェース実装に特化。デフォルトで Gemini 3.5 Flash モデルを使用。",
|
||||
"visualEngineering": "フロントエンドとビジュアルエンジニアリングカテゴリ。UI/UX デザイン、スタイリング、アニメーション、インターフェース実装に特化。デフォルトで Gemini 3.6 Flash モデルを使用。",
|
||||
"ultrabrain": "ディープロジック推論カテゴリ。広範な分析が必要な複雑なアーキテクチャ決定に使用。デフォルトで GPT-5.3 Codex の超高推論バリアントを使用。",
|
||||
"deep": "ディープ自律問題解決カテゴリ。目標指向の実行で、行動前に徹底的な調査を実施。深い理解が必要な難問に適しています。",
|
||||
"artistry": "高度にクリエイティブで芸術的なタスクカテゴリ。斬新なアイデアとクリエイティブなソリューションを促進。デフォルトで Gemini 3.5 Flash の高推論バリアントを使用。",
|
||||
"artistry": "高度にクリエイティブで芸術的なタスクカテゴリ。斬新なアイデアとクリエイティブなソリューションを促進。デフォルトで Gemini 3.6 Flash の高推論バリアントを使用。",
|
||||
"quick": "軽量タスクカテゴリ。単一ファイルの修正、タイポ修正、簡単な調整などの些細な作業に使用。デフォルトで Claude Haiku 4.5 高速モデルを使用。",
|
||||
"unspecifiedLow": "未分類の低作業量タスクカテゴリ。他のカテゴリに該当せず作業量が小さいタスクに適用。デフォルトで Claude Sonnet 4.5 を使用。",
|
||||
"unspecifiedHigh": "未分類の高作業量タスクカテゴリ。他のカテゴリに該当せず作業量が大きいタスクに適用。デフォルトで Claude Opus 4.8 の max バリアントを使用。",
|
||||
"unspecifiedHigh": "未分類の高作業量タスクカテゴリ。他のカテゴリに該当せず作業量が大きいタスクに適用。デフォルトで Claude Opus 5 の max バリアントを使用。",
|
||||
"writing": "ライティングカテゴリ。ドキュメント、散文、技術文書に特化。デフォルトで Kimi K2.5 モデルを使用。"
|
||||
},
|
||||
"slimAgentDesc": {
|
||||
@@ -3006,6 +3009,7 @@
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "週間",
|
||||
"monthly": "月間",
|
||||
"credits": "クレジット",
|
||||
"copilotPremium": "プレミアム",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "{{time}}後にリセット",
|
||||
|
||||
@@ -991,7 +991,7 @@
|
||||
"providerKeyStatusLoading": "正在載入供應商識別碼狀態,請稍後再試",
|
||||
"getApiKey": "取得 API Key",
|
||||
"partnerPromotion": {
|
||||
"sudocode": "SudoCode 讓 Claude Code 與 Claude Desktop 使用 Claude Opus 4.8,Codex 使用 GPT-5.6,一個 Key 統一管理。CC Switch 使用者註冊並加入 QQ 群 726213516,聯絡群主領取人民幣 ¥10 試用額度。",
|
||||
"sudocode": "SudoCode 讓 Claude Code 與 Claude Desktop 使用 Claude Opus 5,Codex 使用 GPT-5.6,一個 Key 統一管理。CC Switch 使用者註冊並加入 QQ 群 726213516,聯絡群主領取人民幣 ¥10 試用額度。",
|
||||
"code0": "code0.ai 是面向開發者的 AI 程式設計服務平台,支援 Claude Code、Codex、Gemini。CC Switch 使用者專屬福利:透過官網聯繫客服即可領取測試額度!",
|
||||
"nekocode": "NekoCode 為開發者提供穩定、高效、可靠的 Claude、Codex 等 AI 模型 API 中轉服務,價格透明、按量計費。CC Switch 使用者專享 9 折:透過上方連結註冊,儲值時輸入優惠碼 cc-switch 即享 9 折優惠!",
|
||||
"packycode": "PackyCode 是 CC Switch 的官方合作夥伴,使用此連結註冊並在儲值時填寫「cc-switch」優惠碼,可以享受 9 折優惠",
|
||||
@@ -1310,7 +1310,7 @@
|
||||
"promptCacheRoutingDisabled": "關閉",
|
||||
"promptCacheRoutingHint": "自動模式僅對已確認相容的上游傳送 prompt_cache_key;開啟可用於其他相容閘道,關閉可避免嚴格閘道因未知欄位回傳 400。只使用客戶端提供的穩定工作階段 ID。",
|
||||
"defaultModelLabel": "預設模型",
|
||||
"defaultModelPlaceholder": "例如: gpt-5.6",
|
||||
"defaultModelPlaceholder": "例如: gpt-5.6-sol",
|
||||
"defaultModelHint": "Codex 預設請求的模型,隨時可改,無需等待軟體更新供應商範本。留空且設定了模型映射時,預設使用映射第一行。",
|
||||
"defaultModelNotInCatalog": "該模型不在模型映射中,Codex 的 /model 選單不會列出它(直接請求仍然有效)。",
|
||||
"addToModelMapping": "加入映射",
|
||||
@@ -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": "同步工作階段日誌",
|
||||
@@ -2129,9 +2131,9 @@
|
||||
"addModel": "新增模型",
|
||||
"noModels": "暫無模型設定。切換至此供應商時將不會更新預設模型。",
|
||||
"modelId": "模型 ID",
|
||||
"modelIdPlaceholder": "anthropic/claude-opus-4-8",
|
||||
"modelIdPlaceholder": "anthropic/claude-opus-5",
|
||||
"modelName": "顯示名稱",
|
||||
"modelNamePlaceholder": "Claude Opus 4.8",
|
||||
"modelNamePlaceholder": "Claude Opus 5",
|
||||
"contextLength": "上下文長度",
|
||||
"advancedOptions": "進階選項",
|
||||
"modelsHint": "切換至此供應商時,第一個模型會寫入頂層 model.default。",
|
||||
@@ -2909,13 +2911,13 @@
|
||||
"writing": "寫作"
|
||||
},
|
||||
"categoryTooltip": {
|
||||
"visualEngineering": "前端與視覺工程類別,專注於 UI/UX 設計、樣式、動畫和介面實作,預設使用 Gemini 3.5 Flash 模型。",
|
||||
"visualEngineering": "前端與視覺工程類別,專注於 UI/UX 設計、樣式、動畫和介面實作,預設使用 Gemini 3.6 Flash 模型。",
|
||||
"ultrabrain": "深度邏輯推理類別,用於需要廣泛分析的複雜架構決策,預設使用 GPT-5.3 Codex 的超高推理變體。",
|
||||
"deep": "深度自主問題解決類別,目標導向執行,行動前進行徹底研究,適用於需要深度理解的棘手問題。",
|
||||
"artistry": "高度創意與藝術性任務類別,激發新穎想法和創造性解決方案,預設使用 Gemini 3.5 Flash 的高推理變體。",
|
||||
"artistry": "高度創意與藝術性任務類別,激發新穎想法和創造性解決方案,預設使用 Gemini 3.6 Flash 的高推理變體。",
|
||||
"quick": "輕量任務類別,用於單檔案修改、錯別字修復、簡單調整等瑣碎工作,預設使用 Claude Haiku 4.5 快速模型。",
|
||||
"unspecifiedLow": "未分類低工作量任務類別,適用於不適合其他類別且工作量較小的任務,預設使用 Claude Sonnet 4.5。",
|
||||
"unspecifiedHigh": "未分類高工作量任務類別,適用於不適合其他類別且工作量較大的任務,預設使用 Claude Opus 4.8 的最大變體。",
|
||||
"unspecifiedHigh": "未分類高工作量任務類別,適用於不適合其他類別且工作量較大的任務,預設使用 Claude Opus 5 的最大變體。",
|
||||
"writing": "寫作類別,專注於文件、散文和技術寫作,預設使用 Kimi K2.5 模型。"
|
||||
},
|
||||
"slimAgentDesc": {
|
||||
@@ -2978,6 +2980,7 @@
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "每週",
|
||||
"monthly": "每月",
|
||||
"credits": "額度",
|
||||
"copilotPremium": "進階請求",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "{{time}} 後重設",
|
||||
|
||||
@@ -1020,7 +1020,7 @@
|
||||
"providerKeyStatusLoading": "正在加载供应商标识状态,请稍后再试",
|
||||
"getApiKey": "获取 API Key",
|
||||
"partnerPromotion": {
|
||||
"sudocode": "SudoCode 让 Claude Code 与 Claude Desktop 接入 Claude Opus 4.8,Codex 接入 GPT-5.6,一个 Key 统一使用。CC Switch 用户注册并加入 QQ 群 726213516,联系群主领取 ¥10 试用额度。",
|
||||
"sudocode": "SudoCode 让 Claude Code 与 Claude Desktop 接入 Claude Opus 5,Codex 接入 GPT-5.6,一个 Key 统一使用。CC Switch 用户注册并加入 QQ 群 726213516,联系群主领取 ¥10 试用额度。",
|
||||
"code0": "code0.ai 是面向开发者的 AI 编程服务平台,支持 Claude Code、Codex、Gemini。CC Switch 用户专属福利:通过官网联系客服即可领取测试额度!",
|
||||
"nekocode": "NekoCode 为开发者提供稳定、高效、可靠的 Claude、Codex 等 AI 模型 API 中转服务,价格透明、按量计费。CC Switch 用户专享 9 折:通过上方链接注册,充值时输入优惠码 cc-switch 即享 9 折优惠!",
|
||||
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
|
||||
@@ -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 的官方合作伙伴",
|
||||
@@ -1344,7 +1345,7 @@
|
||||
"modelMetadataAdvanced": "模型元数据高级选项",
|
||||
"modelMetadataAdvancedHint": "用于未知模型的 Codex 元数据覆盖;清空字段会从 config.toml 删除对应配置。",
|
||||
"defaultModelLabel": "默认模型",
|
||||
"defaultModelPlaceholder": "例如: gpt-5.6",
|
||||
"defaultModelPlaceholder": "例如: gpt-5.6-sol",
|
||||
"defaultModelHint": "Codex 默认请求的模型,随时可改,无需等待预设更新。留空且配置了模型映射时,默认使用映射第一行。",
|
||||
"defaultModelNotInCatalog": "该模型不在模型映射中,Codex 的 /model 菜单不会列出它(直接请求仍然有效)。",
|
||||
"addToModelMapping": "加入映射",
|
||||
@@ -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": "同步会话日志",
|
||||
@@ -2157,9 +2160,9 @@
|
||||
"addModel": "添加模型",
|
||||
"noModels": "暂无模型配置。切换到此供应商时将不会更新默认模型。",
|
||||
"modelId": "模型 ID",
|
||||
"modelIdPlaceholder": "anthropic/claude-opus-4-8",
|
||||
"modelIdPlaceholder": "anthropic/claude-opus-5",
|
||||
"modelName": "显示名称",
|
||||
"modelNamePlaceholder": "Claude Opus 4.8",
|
||||
"modelNamePlaceholder": "Claude Opus 5",
|
||||
"contextLength": "上下文长度",
|
||||
"advancedOptions": "高级选项",
|
||||
"modelsHint": "切换到此供应商时,第一个模型会写入顶层 model.default。",
|
||||
@@ -2937,13 +2940,13 @@
|
||||
"writing": "写作"
|
||||
},
|
||||
"categoryTooltip": {
|
||||
"visualEngineering": "前端与视觉工程类别,专注于 UI/UX 设计、样式、动画和界面实现,默认使用 Gemini 3.5 Flash 模型。",
|
||||
"visualEngineering": "前端与视觉工程类别,专注于 UI/UX 设计、样式、动画和界面实现,默认使用 Gemini 3.6 Flash 模型。",
|
||||
"ultrabrain": "深度逻辑推理类别,用于需要广泛分析的复杂架构决策,默认使用 GPT-5.3 Codex 的超高推理变体。",
|
||||
"deep": "深度自主问题解决类别,目标导向执行,行动前进行彻底研究,适用于需要深度理解的棘手问题。",
|
||||
"artistry": "高度创意与艺术性任务类别,激发新颖想法和创造性解决方案,默认使用 Gemini 3.5 Flash 的高推理变体。",
|
||||
"artistry": "高度创意与艺术性任务类别,激发新颖想法和创造性解决方案,默认使用 Gemini 3.6 Flash 的高推理变体。",
|
||||
"quick": "轻量任务类别,用于单文件修改、错别字修复、简单调整等琐碎工作,默认使用 Claude Haiku 4.5 快速模型。",
|
||||
"unspecifiedLow": "未归类低工作量任务类别,适用于不适合其他类别且工作量较小的任务,默认使用 Claude Sonnet 4.5。",
|
||||
"unspecifiedHigh": "未归类高工作量任务类别,适用于不适合其他类别且工作量较大的任务,默认使用 Claude Opus 4.8 的最大变体。",
|
||||
"unspecifiedHigh": "未归类高工作量任务类别,适用于不适合其他类别且工作量较大的任务,默认使用 Claude Opus 5 的最大变体。",
|
||||
"writing": "写作类别,专注于文档、散文和技术写作,默认使用 Kimi K2.5 模型。"
|
||||
},
|
||||
"slimAgentDesc": {
|
||||
@@ -3006,6 +3009,7 @@
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "每周",
|
||||
"monthly": "每月",
|
||||
"credits": "额度",
|
||||
"copilotPremium": "高级请求",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "{{time}}后重置",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
+18
-18
@@ -29,7 +29,7 @@ export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
|
||||
display: "Sisyphus",
|
||||
descKey: "omo.agentDesc.sisyphus",
|
||||
tooltipKey: "omo.agentTooltip.sisyphus",
|
||||
recommended: "claude-opus-4-8",
|
||||
recommended: "claude-opus-5",
|
||||
group: "main",
|
||||
},
|
||||
{
|
||||
@@ -37,7 +37,7 @@ export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
|
||||
display: "Hephaestus",
|
||||
descKey: "omo.agentDesc.hephaestus",
|
||||
tooltipKey: "omo.agentTooltip.hephaestus",
|
||||
recommended: "gpt-5.5",
|
||||
recommended: "gpt-5.6-sol",
|
||||
group: "main",
|
||||
},
|
||||
{
|
||||
@@ -45,7 +45,7 @@ export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
|
||||
display: "Prometheus",
|
||||
descKey: "omo.agentDesc.prometheus",
|
||||
tooltipKey: "omo.agentTooltip.prometheus",
|
||||
recommended: "claude-opus-4-8",
|
||||
recommended: "claude-opus-5",
|
||||
group: "main",
|
||||
},
|
||||
{
|
||||
@@ -61,7 +61,7 @@ export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
|
||||
display: "Oracle",
|
||||
descKey: "omo.agentDesc.oracle",
|
||||
tooltipKey: "omo.agentTooltip.oracle",
|
||||
recommended: "gpt-5.5",
|
||||
recommended: "gpt-5.6-sol",
|
||||
group: "sub",
|
||||
},
|
||||
{
|
||||
@@ -85,7 +85,7 @@ export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
|
||||
display: "Multimodal-Looker",
|
||||
descKey: "omo.agentDesc.multimodalLooker",
|
||||
tooltipKey: "omo.agentTooltip.multimodalLooker",
|
||||
recommended: "gpt-5.5",
|
||||
recommended: "gpt-5.6-sol",
|
||||
group: "sub",
|
||||
},
|
||||
{
|
||||
@@ -101,7 +101,7 @@ export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
|
||||
display: "Momus",
|
||||
descKey: "omo.agentDesc.momus",
|
||||
tooltipKey: "omo.agentTooltip.momus",
|
||||
recommended: "gpt-5.5",
|
||||
recommended: "gpt-5.6-sol",
|
||||
group: "sub",
|
||||
},
|
||||
{
|
||||
@@ -120,28 +120,28 @@ export const OMO_BUILTIN_CATEGORIES: OmoCategoryDef[] = [
|
||||
display: "Visual Engineering",
|
||||
descKey: "omo.categoryDesc.visualEngineering",
|
||||
tooltipKey: "omo.categoryTooltip.visualEngineering",
|
||||
recommended: "gemini-3.5-flash",
|
||||
recommended: "gemini-3.6-flash",
|
||||
},
|
||||
{
|
||||
key: "ultrabrain",
|
||||
display: "Ultrabrain",
|
||||
descKey: "omo.categoryDesc.ultrabrain",
|
||||
tooltipKey: "omo.categoryTooltip.ultrabrain",
|
||||
recommended: "gpt-5.5",
|
||||
recommended: "gpt-5.6-sol",
|
||||
},
|
||||
{
|
||||
key: "deep",
|
||||
display: "Deep",
|
||||
descKey: "omo.categoryDesc.deep",
|
||||
tooltipKey: "omo.categoryTooltip.deep",
|
||||
recommended: "gpt-5.5",
|
||||
recommended: "gpt-5.6-sol",
|
||||
},
|
||||
{
|
||||
key: "artistry",
|
||||
display: "Artistry",
|
||||
descKey: "omo.categoryDesc.artistry",
|
||||
tooltipKey: "omo.categoryTooltip.artistry",
|
||||
recommended: "gemini-3.5-flash",
|
||||
recommended: "gemini-3.6-flash",
|
||||
},
|
||||
{
|
||||
key: "quick",
|
||||
@@ -162,7 +162,7 @@ export const OMO_BUILTIN_CATEGORIES: OmoCategoryDef[] = [
|
||||
display: "Unspecified High",
|
||||
descKey: "omo.categoryDesc.unspecifiedHigh",
|
||||
tooltipKey: "omo.categoryTooltip.unspecifiedHigh",
|
||||
recommended: "claude-opus-4-8",
|
||||
recommended: "claude-opus-5",
|
||||
},
|
||||
{
|
||||
key: "writing",
|
||||
@@ -281,8 +281,8 @@ export const OMO_BACKGROUND_TASK_PLACEHOLDER = `{
|
||||
"google": 10
|
||||
},
|
||||
"modelConcurrency": {
|
||||
"anthropic/claude-opus-4-8": 2,
|
||||
"google/gemini-3.5-flash": 10
|
||||
"anthropic/claude-opus-5": 2,
|
||||
"google/gemini-3.6-flash": 10
|
||||
}
|
||||
}`;
|
||||
|
||||
@@ -320,7 +320,7 @@ export const OMO_SLIM_BUILTIN_AGENTS: OmoAgentDef[] = [
|
||||
display: "Orchestrator",
|
||||
descKey: "omo.slimAgentDesc.orchestrator",
|
||||
tooltipKey: "omo.slimAgentTooltip.orchestrator",
|
||||
recommended: "claude-opus-4-8",
|
||||
recommended: "claude-opus-5",
|
||||
group: "main",
|
||||
},
|
||||
{
|
||||
@@ -328,7 +328,7 @@ export const OMO_SLIM_BUILTIN_AGENTS: OmoAgentDef[] = [
|
||||
display: "Oracle",
|
||||
descKey: "omo.slimAgentDesc.oracle",
|
||||
tooltipKey: "omo.slimAgentTooltip.oracle",
|
||||
recommended: "gpt-5.5",
|
||||
recommended: "gpt-5.6-sol",
|
||||
group: "sub",
|
||||
},
|
||||
{
|
||||
@@ -336,7 +336,7 @@ export const OMO_SLIM_BUILTIN_AGENTS: OmoAgentDef[] = [
|
||||
display: "Librarian",
|
||||
descKey: "omo.slimAgentDesc.librarian",
|
||||
tooltipKey: "omo.slimAgentTooltip.librarian",
|
||||
recommended: "gemini-3.5-flash",
|
||||
recommended: "gemini-3.6-flash",
|
||||
group: "sub",
|
||||
},
|
||||
{
|
||||
@@ -352,7 +352,7 @@ export const OMO_SLIM_BUILTIN_AGENTS: OmoAgentDef[] = [
|
||||
display: "Designer",
|
||||
descKey: "omo.slimAgentDesc.designer",
|
||||
tooltipKey: "omo.slimAgentTooltip.designer",
|
||||
recommended: "gemini-3.5-flash",
|
||||
recommended: "gemini-3.6-flash",
|
||||
group: "sub",
|
||||
},
|
||||
{
|
||||
@@ -360,7 +360,7 @@ export const OMO_SLIM_BUILTIN_AGENTS: OmoAgentDef[] = [
|
||||
display: "Fixer",
|
||||
descKey: "omo.slimAgentDesc.fixer",
|
||||
tooltipKey: "omo.slimAgentTooltip.fixer",
|
||||
recommended: "gpt-5.5",
|
||||
recommended: "gpt-5.6-sol",
|
||||
group: "sub",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -206,7 +206,7 @@ describe("ClaudeDesktopProviderForm", () => {
|
||||
model: "upstream-old",
|
||||
labelOverride: "upstream-old",
|
||||
},
|
||||
"claude-opus-4-8": { model: "upstream-old" },
|
||||
"claude-opus-5": { model: "upstream-old" },
|
||||
"claude-fable-5": { model: "upstream-old" },
|
||||
"claude-haiku-4-5": { model: "upstream-old" },
|
||||
});
|
||||
@@ -214,7 +214,7 @@ describe("ClaudeDesktopProviderForm", () => {
|
||||
[
|
||||
"claude-fable-5",
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-5",
|
||||
"claude-sonnet-5",
|
||||
],
|
||||
);
|
||||
@@ -250,7 +250,7 @@ describe("ClaudeDesktopProviderForm", () => {
|
||||
model: "deepseek-v4-pro",
|
||||
supports1m: true,
|
||||
});
|
||||
expect(routes["claude-opus-4-8"]).toMatchObject({
|
||||
expect(routes["claude-opus-5"]).toMatchObject({
|
||||
model: "deepseek-v4-pro",
|
||||
supports1m: true,
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("AWS Bedrock OpenCode Provider Presets", () => {
|
||||
expect(variants.length).toBeGreaterThan(0);
|
||||
|
||||
const opusModel = variants.find((v) =>
|
||||
v.id.includes("anthropic.claude-opus-4-8"),
|
||||
v.id.includes("anthropic.claude-opus-5"),
|
||||
);
|
||||
expect(opusModel).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("SubRouter provider presets", () => {
|
||||
expect(preset?.endpointCandidates).toEqual(["https://subrouter.ai/v1"]);
|
||||
expect(preset?.auth).toEqual({ OPENAI_API_KEY: "" });
|
||||
expect(preset?.config).toContain('name = "subrouter"');
|
||||
expect(preset?.config).toContain('model = "gpt-5.5"');
|
||||
expect(preset?.config).toContain('model = "gpt-5.6-sol"');
|
||||
expect(preset?.config).toContain('base_url = "https://subrouter.ai/v1"');
|
||||
expect(preset?.config).toContain('wire_api = "responses"');
|
||||
});
|
||||
@@ -53,11 +53,11 @@ describe("SubRouter provider presets", () => {
|
||||
expect(preset).toBeDefined();
|
||||
expect(preset?.baseURL).toBe("https://subrouter.ai/v1beta");
|
||||
expect(preset?.endpointCandidates).toEqual(["https://subrouter.ai/v1beta"]);
|
||||
expect(preset?.model).toBe("gemini-3.5-flash");
|
||||
expect(preset?.model).toBe("gemini-3.6-flash");
|
||||
|
||||
const env = (preset?.settingsConfig as { env: Record<string, string> }).env;
|
||||
expect(env.GOOGLE_GEMINI_BASE_URL).toBe("https://subrouter.ai/v1beta");
|
||||
expect(env.GEMINI_MODEL).toBe("gemini-3.5-flash");
|
||||
expect(env.GEMINI_MODEL).toBe("gemini-3.6-flash");
|
||||
});
|
||||
|
||||
it("uses OpenAI-compatible config for OpenCode", () => {
|
||||
@@ -71,7 +71,7 @@ describe("SubRouter provider presets", () => {
|
||||
"https://subrouter.ai/v1",
|
||||
);
|
||||
expect(preset?.settingsConfig.options?.apiKey).toBe("");
|
||||
expect(preset?.settingsConfig.models).toHaveProperty("gpt-5.5");
|
||||
expect(preset?.settingsConfig.models).toHaveProperty("gpt-5.6-sol");
|
||||
});
|
||||
|
||||
it("uses OpenAI completions config for OpenClaw without hardcoded pricing", () => {
|
||||
@@ -84,13 +84,13 @@ describe("SubRouter provider presets", () => {
|
||||
expect(preset?.settingsConfig.baseUrl).toBe("https://subrouter.ai/v1");
|
||||
expect(preset?.settingsConfig.api).toBe("openai-completions");
|
||||
expect(model).toMatchObject({
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
contextWindow: 400000,
|
||||
});
|
||||
expect(model).not.toHaveProperty("cost");
|
||||
expect(preset?.suggestedDefaults?.model).toEqual({
|
||||
primary: "subrouter/gpt-5.5",
|
||||
primary: "subrouter/gpt-5.6-sol",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("SubRouter provider presets", () => {
|
||||
api_mode: "chat_completions",
|
||||
});
|
||||
expect(preset?.suggestedDefaults?.model).toEqual({
|
||||
default: "gpt-5.5",
|
||||
default: "gpt-5.6-sol",
|
||||
provider: "subrouter",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,8 +23,8 @@ describe("TheRouter OpenCode and OpenClaw presets", () => {
|
||||
expect(preset?.settingsConfig.options?.setCacheKey).toBe(true);
|
||||
expect(models).toHaveProperty("openai/gpt-5.3-codex");
|
||||
expect(models).toHaveProperty("anthropic/claude-sonnet-5");
|
||||
expect(models).toHaveProperty("google/gemini-3.5-flash");
|
||||
expect(models["google/gemini-3.5-flash"]?.name).toBe("Gemini 3.5 Flash");
|
||||
expect(models).toHaveProperty("google/gemini-3.6-flash");
|
||||
expect(models["google/gemini-3.6-flash"]?.name).toBe("Gemini 3.6 Flash");
|
||||
});
|
||||
|
||||
it("uses OpenAI completions config for OpenClaw", () => {
|
||||
@@ -45,20 +45,20 @@ describe("TheRouter OpenCode and OpenClaw presets", () => {
|
||||
"anthropic/claude-sonnet-5",
|
||||
"openai/gpt-5.3-codex",
|
||||
"openai/gpt-5.2",
|
||||
"google/gemini-3.5-flash",
|
||||
"google/gemini-3.6-flash",
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
openClawModels.find((model) => model.id === "google/gemini-3.5-flash"),
|
||||
openClawModels.find((model) => model.id === "google/gemini-3.6-flash"),
|
||||
).toMatchObject({
|
||||
name: "Gemini 3.5 Flash",
|
||||
name: "Gemini 3.6 Flash",
|
||||
cost: { input: 1.5, output: 9, cacheRead: 0.15 },
|
||||
});
|
||||
expect(preset?.suggestedDefaults?.model).toEqual({
|
||||
primary: "therouter/anthropic/claude-sonnet-5",
|
||||
fallbacks: [
|
||||
"therouter/openai/gpt-5.2",
|
||||
"therouter/google/gemini-3.5-flash",
|
||||
"therouter/google/gemini-3.6-flash",
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -67,13 +67,13 @@ describe("TheRouter OpenCode and OpenClaw presets", () => {
|
||||
const googleModels = OPENCODE_PRESET_MODEL_VARIANTS["@ai-sdk/google"];
|
||||
const ids = googleModels.map((model) => model.id);
|
||||
const geminiFlashModels = googleModels.filter(
|
||||
(model) => model.id === "gemini-3.5-flash",
|
||||
(model) => model.id === "gemini-3.6-flash",
|
||||
);
|
||||
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(geminiFlashModels).toHaveLength(1);
|
||||
expect(geminiFlashModels[0]).toMatchObject({
|
||||
name: "Gemini 3.5 Flash",
|
||||
name: "Gemini 3.6 Flash",
|
||||
variants: {
|
||||
minimal: expect.any(Object),
|
||||
low: expect.any(Object),
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("TheRouter provider presets", () => {
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe(
|
||||
"anthropic/claude-sonnet-5",
|
||||
);
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("anthropic/claude-opus-4.8");
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("anthropic/claude-opus-5");
|
||||
});
|
||||
|
||||
it("uses the OpenAI-compatible v1 endpoint for Codex", () => {
|
||||
@@ -60,10 +60,10 @@ describe("TheRouter provider presets", () => {
|
||||
expect(preset?.category).toBe("aggregator");
|
||||
expect(preset?.endpointCandidates).toEqual(["https://api.therouter.ai"]);
|
||||
expect(preset?.baseURL).toBe("https://api.therouter.ai");
|
||||
expect(preset?.model).toBe("gemini-3.5-flash");
|
||||
expect(preset?.model).toBe("gemini-3.6-flash");
|
||||
|
||||
const env = (preset?.settingsConfig as { env: Record<string, string> }).env;
|
||||
expect(env.GOOGLE_GEMINI_BASE_URL).toBe("https://api.therouter.ai");
|
||||
expect(env.GEMINI_MODEL).toBe("gemini-3.5-flash");
|
||||
expect(env.GEMINI_MODEL).toBe("gemini-3.6-flash");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user