mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 05:38:38 +08:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 878c26f31e | |||
| 6c9d444c8a | |||
| 34cbb375f0 | |||
| cd161f4401 | |||
| 3cf84ca362 | |||
| 15d5dbe065 | |||
| a377d79303 | |||
| 846fbdd1c0 | |||
| 3a9fb13a0b | |||
| 606e7bbe75 | |||
| df1751a893 | |||
| f3108bf754 | |||
| b3f3aee3ee | |||
| 2bfca54801 | |||
| 107eade30e | |||
| 3537076af2 | |||
| 72efd64a3d | |||
| f0b7b81c11 | |||
| b660061b8a | |||
| 200aa14295 | |||
| bc8001238a | |||
| 325ba48486 | |||
| a8daf7daad | |||
| f733def452 | |||
| a5aa1fd82b | |||
| 6428e993a3 | |||
| dbb5bd1537 | |||
| 8dcedbc062 | |||
| eccb296afe | |||
| eff1e0ccfc | |||
| c9ac6efd69 | |||
| df3e07edbb | |||
| a10b569a53 | |||
| eb105eae76 | |||
| 01fca69641 | |||
| db44484726 | |||
| cdf0ee3491 | |||
| 09101e2a2d | |||
| e9317f476e | |||
| 615c99c62b | |||
| a35209a6e7 | |||
| c4795e98ff | |||
| 6fddcaa97c | |||
| aa0e441d07 | |||
| 17b053ed94 | |||
| e356fc6efc | |||
| f20458229d | |||
| 2bfecead4f | |||
| 6274705848 | |||
| 22d2872c33 | |||
| 7f028632b5 | |||
| 3bc828aecc | |||
| 08710d51fc | |||
| 613fef70bc | |||
| 997be22bfa | |||
| edea624a27 | |||
| 1c0ee0c58a |
@@ -5,6 +5,71 @@ All notable changes to CC Switch will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.18.0] - 2026-07-21
|
||||
|
||||
Development since v3.17.0 is headlined by Grok Build joining as the eighth managed app — full provider switching, proxy takeover on its own route namespace, MCP/Skills/prompts sync, a curated preset list, and a Grok Official entry with official-login import (schema v14/v15) — and by xAI Grok account sign-in over an OAuth device flow for Claude Code, Claude Desktop, and Codex, including a strict-gateway compatibility layer that lets codex 0.142+ drive a Grok subscription over native Responses. A usage-accounting repair wave fixes the v3.17.0 fork/sub-agent double count with a one-time automatic rebuild (schema v16) plus a manual rebuild action, makes proxy usage logging idempotent, and stops the usage page freezing during large imports. Diagnostics mature: logs persist across restarts under size rotation, every log egress redacts secrets, and renderer crashes are captured to disk behind an error boundary with a reload screen. The Codex conversion layer gets four correctness fixes — tool schemas normalized to object type, reasoning attached forward across turns, streamed tool-call identity and order preserved, and parser-required catalog fields backfilled so codex 0.144.5+ starts — while managed-OAuth providers are now reliably flagged as routing-required and Windows provider switches no longer flash a console window or freeze the UI. Rounded out by Kimi K3 presets and pricing, corrected OpenClaw preset costs, SudoCode.us restored beside SudoCode.chat, sponsor-grouped preset ordering, first-run tray language detection, and permanently deletable default Skill repositories.
|
||||
|
||||
**Stats**: 52 commits | 217 files changed | +21,452 insertions | -6,285 deletions
|
||||
|
||||
### Added
|
||||
|
||||
- **Grok Build Becomes the Eighth Managed App**: xAI's Grok CLI ("Grok Build", live config `~/.grok/config.toml`) is now a first-class managed app alongside Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, and Hermes: full provider add/import/switch (with a "restart Grok Build to apply" toast), app visibility and config-directory override settings, session manager and usage dashboard coverage, prompts with first-launch auto-import, deep-link (`ccswitch://`) provider import, and proxy takeover with its own `/grokbuild/v1/responses` route namespace, failover queue, and per-app proxy settings row — it reuses the Codex Responses forwarding path but never shares Codex's provider namespace or circuit-breaker state. MCP servers sync bidirectionally into Grok's `[mcp_servers]` table with the dialect differences handled (Grok infers transport from `command`/`url` and uses `headers`, so exports strip the explicit `type` and rename `http_headers` to `headers`, while imports infer the transport back), and Skills gain a Grok Build enable flag. Instead of borrowing the Codex preset list (an early cut leaked China-official direct providers and Codex default models into the Grok form), Grok Build ships its own curated preset module: aggregators and relays that actually carry Grok models, default model normalized to `grok-4.5` (`x-ai/grok-4.5` on namespaced routers), with no `cn_official` category. The tools panel installs Grok via the official xAI installer (`x.ai/cli/install.sh` / `install.ps1`) with npm `@xai-official/grok` as fallback; installs detected as native (launcher in `~/.grok/bin` or `$GROK_BIN_DIR`, real target under `~/.grok/downloads/grok-*`) self-update via `grok update`, while npm installs keep npm-anchored updates — the self-update is gated on positive native detection so it can never mutate a different install. Localized in all four locales (zh/en/ja/zh-TW). (#5453)
|
||||
- **Grok Official Provider With Official-Login Import**: A "Grok Official" entry lets Grok Build fall back to the CLI's own built-in xAI OAuth login: selecting it hides the connection fields and writes an empty `~/.grok/config.toml`, and CC Switch never stores or touches those credentials. Live-config reads, backups, and official-state writes now use syntax-only TOML validation, so an official-login (empty) config round-trips instead of failing; importing the live config while Grok is in official login state yields "Grok Official set as current" instead of an error, matching Codex behavior. Official-state detection is deliberately wired only into the manual import command — the startup auto-importer still rejects official-state configs, so a deleted Grok Official entry is never resurrected on launch. Proxy takeover of an official-login Grok config is skipped automatically and rejected with a clear message on the manual path, consistent with the existing ban on proxying official providers.
|
||||
- **Sign In With Your xAI Grok Account (OAuth) in Claude Code and Claude Desktop**: New "xAI (Grok)" presets for Claude Code and Claude Desktop authenticate with an OAuth device-code login instead of an API key, routing requests through the local proxy which transforms Anthropic Messages traffic to the xAI Responses API and injects the access token per request (default model `grok-4.5` on every role; the Claude Desktop preset maps the branded `claude-*` role IDs to `grok-4.5` upstream so it passes Desktop's third-party model validation). A new xAI section in Settings → OAuth Auth Center handles device-code login (user code with copy button, verification link, waiting/cancel/retry), multiple accounts with a default-account picker, per-account removal, and re-auth badges — accounts whose refresh token was revoked stay visible as "expired" instead of disappearing, and auth status refetches every 15 s so server-side revocation surfaces on its own. The integration is pinned closed: upstream is always `https://api.x.ai/v1/responses` in Responses format regardless of what the editable endpoint/format fields say, OAuth endpoints are resolved via OIDC discovery but validated to `auth.x.ai` over https only, refresh tokens live in `~/.cc-switch/xai_oauth_auth.json` (`0600` on Unix; access tokens are memory-only), and OAuth error bodies are never embedded in error messages or logs. `grok-4.5` is also seeded into the built-in pricing table ($2 input / $6 output / $0.50 cache-read per 1M tokens), fixing the $0 cost the dashboard would otherwise record; existing databases pick the row up automatically on next launch. Localized in all four locales (zh/en/ja/zh-TW). See the upgrade notes for the client-identity disclosure.
|
||||
- **xAI (Grok) Codex Presets: OAuth Managed and Native-Responses API Key**: Codex gains two ways to talk to xAI directly. The "xAI (Grok) OAuth" managed preset drives Codex against a Grok subscription through the local proxy — the form hides the key/endpoint/format fields and shows the account picker, "fetch models" uses the signed-in account, and the provider is pinned to native Responses with the base URL and per-request token enforced by the proxy (edited base URLs or formats are ignored, so the managed route cannot be redirected). Because codex 0.142+ emits ChatGPT-backend-private shapes that xAI's strict parser rejects (`type:"namespace"` tool declarations → 422, plus private fields like `prompt_cache_retention`, `safety_identifier`, `external_web_access`, the `additional_tools` carrier, and grok-4.5-unsupported sampling knobs), the OAuth route gets a compatibility layer on the native passthrough: namespace tools are flattened to top-level function tools (using the same sha256-truncated naming as the Chat path) and restored to namespaced form in both streaming and non-streaming responses, and unsupported fields are stripped — all deterministic field-removal/structural lifts, never semantic rewrites, so prompt-cache prefixes stay stable. The layer is gated exclusively on the xAI OAuth provider type: no other provider's traffic is touched, including the second new preset, a plain "xAI (Grok)" API-key preset (native Responses against `api.x.ai/v1` with a 500K-context `grok-4.5` catalog entry) that does not receive these xAI-specific compatibility transforms — API-key users on codex 0.142+ can still hit xAI's strict parser, and the OAuth preset is the fully-compatible path. xAI OAuth token failures are classified non-retryable so failover never silently moves a conversation onto a different Grok account.
|
||||
- **Renderer Crash Capture With On-Disk Reports and a Reload Screen**: A React error boundary now wraps the UI (including the database-recovery screen), so a renderer crash shows a "Something went wrong in the interface" card with a Reload button instead of a blank white window, and global `error`/`unhandledrejection` handlers persist renderer errors to disk via the new `log:default` capability — previously a JS crash left zero on-disk evidence. Everything logged from the frontend passes two redaction layers: a structured serializer that redacts by sensitive property name (normalized so `tokens`/`apiKeys`/`credentials` variants all match, hiding the entire value including nested objects) and by value shape (token prefixes, PEM headers, high-entropy opaque strings), then a single text egress (`redactFrontendLogText`) whose ordered regex chain covers URL query values and credentials, auth headers and schemes, and named secret containers even in double-encoded JSON. JSON that arrives as a string is re-parsed and redacted structurally, and oversized structured input is dropped entirely rather than truncated — a truncated JSON string would fall back to the weaker text regexes and could leak. The settings toggles were also relabeled to say what they actually control: "Application Diagnostic Logs" (cc-switch.log) versus the proxy's "Record Request Usage" (the stats database, which never was a text log). Localized in all four locales (zh/en/ja/zh-TW).
|
||||
- **"Rebuild Codex Usage" Maintenance Action**: The usage dashboard's maintenance section gains a "Rebuild Codex Usage" button that backs up the database, wipes only `codex_session`-sourced detail rows, their `_codex_session` daily rollups, and the Codex sync cursors, then re-imports every rollout file from scratch with the corrected parser — the recovery path for databases already inflated by the fork double-count bug below, and the retry path for deferred fork files whose parent log has since been restored. The manual rebuild fails hard when the pre-rebuild backup cannot be written (unlike the automatic migration variant, which only warns, since blocking startup on an unwritable backup directory would leave the app unable to launch after upgrade), holds the session-sync lock across the whole backup → reset → reimport sequence so the 60-second background sync cannot interleave with the wipe, and always sends exactly one frontend refresh notification on completion — including when the reimport returns zero rows or fails after the reset — so the dashboard never keeps showing pre-reset numbers (`finish_codex_rebuild` in `commands/usage.rs`, pinned by two regression tests). Cursor deletion matches rollout paths purely by shape (`rollout-{uuid}` filename under a `sessions`/`archived_sessions` segment), so cursors recorded under an old `CODEX_HOME` are still cleaned. Localized in all four locales (zh/en/ja/zh-TW).
|
||||
- **Session Import Observability: Deferred Files and Suspected Duplicates**: Session sync results now report `filesScanned`, `deferredFiles` — fork rollouts whose parent log is missing or whose parent markers conflict, held back without writing a cursor so a later sync or manual rebuild retries them, instead of being imported on a guess — and `suspectedDuplicates`, backed by a post-insert probe that checks each imported row for a pre-existing same-fingerprint row (via the `idx_request_logs_dedup_lookup_expr` expression index) and logs a warning per hit, so any future regression of the double-count bug announces itself in the logs instead of silently inflating totals.
|
||||
- **Kimi K3 in Presets and Built-In Pricing**: The Kimi open-platform presets for Codex, Hermes, OpenClaw, and OpenCode gain the Kimi K3 model (1M context window), appended after K2.7 Code so existing default-model behavior is unchanged, and the built-in pricing table gains `kimi-k3` at the official list price ($3 input / $15 output / $0.30 cache-read per 1M tokens) plus a bare `k3` alias — the Kimi For Coding subscription reports its model under the short id `k3`, which would otherwise match no pricing row (same precedent as the existing `hunyuan-hy3`/`hy3` pair). Existing databases pick both rows up automatically on next launch without touching user-edited pricing.
|
||||
- **SudoCode.us Restored Alongside SudoCode.chat**: The two unrelated companies sharing the "SudoCode" name are now separate presets: the sponsor is renamed "SudoCode.chat", and the legacy "SudoCode.us" (which an earlier release had replaced in place) returns with its original endpoints and models, its own icon, and a distinct Hermes slug so both can coexist in the additive `~/.hermes/config.yaml`. Counting the new Grok Build preset list, SudoCode.chat now ships in seven apps and SudoCode.us in all eight.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Diagnostic Logs Persist Across Restarts, Are Size-Rotated, and Never Record Secrets**: `cc-switch.log` is no longer wiped on every startup — the log that explained a crash used to be gone by the time the app reopened — and instead rotates at 20 MB with 4 archives kept (~100 MB cap, versus a single 1 GB file before); `crash.log`, previously unbounded, rotates at 5 MB with 2 archives, with the size-check/rotate/append sequence under one lock so concurrent panics cannot lose an archive. Because persistent logs make verbatim secrets a real exposure (users attach these files to public issues), every backend log egress was scrubbed in the same change: upstream URLs are logged with userinfo, query, and fragment stripped (origin-only when no known secret is available to scrub, since a credential could be embedded in the path), request and response bodies are never logged — replaced by byte counts, a short hash, or a safe classification (`sse`/`html`/`json-like`/`binary-or-encoded`/…) that preserves the transform-debugging signal without the content — response headers go through an allowlist (unlisted headers log name-only), the exact secret values in use (API key, access token) are scrubbed out of any logged URL that carries them, and MCP custom-field values are omitted. The log plugin also registers earlier (so updater/startup failures are diagnosable), the persisted log level applies as soon as the database opens and fails closed to Info, and the "Enable Diagnostic Logs" switch now gates frontend-originated log writes too. Pre-upgrade log files are not retroactively scrubbed — see upgrade notes.
|
||||
- **Preset Picker Groups Sponsors and Alphabetizes the Rest**: The picker's default ordering now has four tiers — official first, then the prime partner, then sponsor presets (in the same order as the README sponsor table, which the preset files were physically reordered to match), then all remaining presets sorted alphabetically by display name instead of file order. An entry matching multiple tiers lands only in the earliest, so nothing is listed twice.
|
||||
- **Preset "Get API Key" Links Updated to Current Referral URLs**: The key-application links on the RunAPI, ClaudeCN, ZetaAPI, and APINebula presets now open each provider's current registration/referral page (ClaudeCN also moved domains, claudecn.top → claudecn.ai). Referral tags remain confined to these links and the README — website links and API endpoints stay untouched.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Codex Fork/Sub-Agent Rollouts No Longer Import Replayed Parent History**: Fixes the v3.17.0 usage inflation where forking a Codex task or spawning a sub-agent in copy mode re-counted the parent conversation's token history as new usage — reports showed single days jumping by billions of tokens, duplicated parent/child rows that were byte-identical, and empty forks carrying usage they never consumed. Fork and sub-agent rollout files begin by replaying the parent thread's history, and the previous parser located the takeover boundary heuristically (first `thread_settings_applied` event, object-shaped `subagent` source markers): the boundary landed too early when the parent's own settings changes appeared in the replay, and was missed entirely for the current string-shaped source format, importing the whole replayed parent history. The parser now identifies a fork only by explicit parent identity — `forked_from_id` on the child's `session_meta` or `source.subagent.thread_spawn.parent_thread_id`, with a conflict between the two deferring the file — anchors each thread's identity to the rollout filename UUID, loads the parent rollout's own pre-fork token-count sequence, and strips the child's replayed prefix by aligning token signatures against it, so replayed events only restore the cumulative baseline and are never inserted as rows. Sub-agent logs that carry no replayed history are now counted as live usage, fixing the opposite-direction undercount where real sub-agent consumption was skipped as suspected replay. (#5335, #5433, #5381)
|
||||
- **Proxy Usage Logging Is Now Idempotent With Stable Response-Scoped Keys**: When a terminal usage event carried no message id — the norm for Codex `/responses` traffic through the local proxy — the dedup key fell back to a random UUID, so every retry or replay of the same upstream response minted a fresh key and `INSERT OR REPLACE` stacked a new row each time; one reporter's database held the same usage combination 2,078 times. The parser now derives the key from the response envelope itself — the Codex `response.completed` event's `response.id` (`response.created` ids are discarded), Chat Completions `chatcmpl` ids, and the Gemini `responseId` — scoped as `session:{app_type}:{provider_id}:{id}` so the same response replayed against a different provider during failover still bills once per provider without cross-provider collisions (Claude keeps the bare `session:{id}` shape so proxy rows continue to converge with session-log imports). When no envelope id exists at all, the fallback is a deterministic SHA-256 over the response's usage semantics rather than a random UUID — an identical replay must collide into the same key for dedup to work — and the final database write is a guarded insert-if-absent within the dedup window instead of an unconditional REPLACE. (#5496)
|
||||
- **Usage Page No Longer Freezes During Large Session Imports**: Opening the usage page while a big import ran could lock up the UI: every inserted row fired a refresh notification, each notification made the frontend re-run all ~10 usage queries, and those queries contended with the importer for the single database connection while it parsed rollout files tens of megabytes large line by line — on duplicate-inflated databases the row storm compounded all three. Session sync now notifies the frontend once per completed sync pass instead of once per row, all session importers are serialized behind a single-flight lock (a manual "sync now" queues behind the running pass instead of racing it), the blocking parse work runs on a dedicated blocking thread so it no longer starves the async runtime driving the UI's commands, and the 60-second background tick skips missed runs instead of bursting to catch up.
|
||||
- **Codex 0.144.5+ No Longer Fails to Start on CC Switch-Generated Model Catalogs**: codex ≥ 0.144.5 parses external model catalogs strictly and rejects the whole file when an entry is missing `supports_reasoning_summaries` — so both the Codex CLI and desktop app failed to launch, and deleting the generated catalog didn't help because any provider save regenerated it the same way. The root cause is that CC Switch clones its catalog template from the machine-shared `models_cache.json`, whose field set is whatever the last-writing codex process produced — a coexisting older codex build kept rewriting the cache without the field the newer parser requires. Generated catalogs now backfill parser-required fields from the bundled static template, only when absent (dynamic values always win), and deliberately do not backfill optional capability fields whose "missing = parser default" semantics must survive.
|
||||
- **Windows: No More Console Flash or UI Freeze When Switching Providers**: Switching providers or toggling takeover on Windows flashed a transient console window and froze the UI for up to ~2 seconds. Three causes, three fixes: the `codex debug models --bundled` probe launches `codex.cmd` through `cmd.exe`, which in a GUI-subsystem app spawns its own console — the child process is now created with `CREATE_NO_WINDOW`; the model-catalog template was regenerated on every switch — it is now cached process-wide after the first successful load (failures stay retryable so a bad first probe cannot poison the cache), so the Codex CLI starts at most once per app run; and `switch_provider` was a synchronous command running on the main thread — it is now async with the real work on a blocking thread, still serialized by the per-app switch lock. The freeze fix benefits all platforms; the console-flash fix is Windows-specific.
|
||||
- **Codex Tool Schemas With Null, Missing, or Union Parameter Types Are Accepted by Strict Upstreams**: Built-in Codex tools such as `codex_app__automation_update` declare `parameters: null` (or `type: null`), which strict OpenAI-compatible upstreams like DeepSeek reject with a 400 for the entire request, killing tool-using sessions routed through the proxy. The Responses→Chat bridge now normalizes every tool's parameters to a `type:"object"` schema — null or missing parameters (including the nested-form missing case) become `{"type":"object","properties":{}}`, a non-object `type` (including `type: null`) is coerced to `"object"` in place, and top-level `oneOf` union schemas get a root `type:"object"` added while their branches are preserved untouched. The same object-type guarantee was extended to the Codex→Anthropic tool path's `input_schema`. Existing `properties`/`required` are never dropped. (#4706, #5315; issues #4705, #4783)
|
||||
- **Reasoning Models Keep Their Thinking Across Multi-Turn Codex Chat Conversations**: With a reasoning model (e.g. kimi-k2-thinking) behind the proxy's Responses→Chat bridge, multi-turn history mangled the thinking: each turn's `reasoning` item was glued onto the tail of the _previous_ assistant message, leaving the following assistant turn with no `reasoning_content` — models would visibly break off mid-conversation. Responses semantics place reasoning _before_ the message it belongs to, so the bridge now attaches reasoning forward to the assistant message or tool call that follows it; genuine trailing reasoning back-attaches only at the true tail (end of input, or a turn boundary such as an incoming user message — where it was previously silently discarded), appending to any embedded reasoning already present, and pending reasoning is always consumed at boundaries so it can never leak across a user turn into a later assistant message. (#5508)
|
||||
- **Streamed Parallel Tool Calls Keep Their IDs and Their Order**: Two bugs in the Chat→Responses streaming bridge corrupted parallel tool calls from upstreams that split identity across chunks: a continuation delta carrying an empty `id` overwrote the real `call_id` (the Codex client then saw `call_id:""` and couldn't match tool results to calls), and tool calls were emitted the moment they individually became ready, so a later index whose name arrived early could jump ahead of an earlier one — reordering parallel calls. Empty ids are now ignored, and emission goes through a consecutive-index gate that releases tool calls strictly in Chat `index` order, waiting on any not-yet-identified earlier index; no fake call id is ever synthesized mid-stream (only as a last resort at stream finalization, which also skips nameless calls defensively and still emits sparse indexes). (#5310)
|
||||
- **Managed-OAuth Providers Are Reliably Flagged as Needing Local Routing**: The "needs routing" badge and switch-time warning were derived from the provider's API format, which is the wrong signal for managed-OAuth providers (Copilot, Codex OAuth, xAI) whose credential is injected by the proxy regardless of upstream format — a managed provider on a native format got no warning and failed silently without takeover. Routing need is now decided by a single shared predicate: official providers never need routing, managed-OAuth providers always do, and format-based rules apply only to the remaining cases per app. The switch-time gate also checks the right readiness signal per app: per-app takeover for most apps (the old gate looked only at a global proxy-running flag, missing the case where the proxy runs but the current app is not taken over), while Claude Desktop keeps watching the proxy process itself — the backend's takeover status has no Claude Desktop field, so a uniform per-app gate would have left Desktop warning forever. Claude Desktop provider forms now force proxy mode and lock the model-mapping toggle for every managed-OAuth type, not just xAI. Localized in all four locales (zh/en/ja/zh-TW).
|
||||
- **Tool Updates Work When Node Lives in nvm/fnm/mise Under a GUI Launch**: Anchored npm update and repair commands invoked npm by absolute path, but npm's launcher resolves `node` via its `#!/usr/bin/env node` shebang against PATH — and a GUI-launched app inherits only the system PATH, not the user's version-manager directories, so updates silently failed for tools installed via nvm/fnm/mise. Every anchored npm invocation now prefixes PATH with npm's own sibling `bin` directory, so both npm and its shebang resolve to the same Node install; the Codex self-repair (uninstall + reinstall) path is covered too.
|
||||
- **Deleted Default Skill Repositories Stay Deleted**: Default Skill repositories were re-seeded on every startup by a "supplement missing defaults" pass, so a default repo the user deleted silently returned on the next launch. Seeding is now one-time per database, tracked by a settings flag; databases that already contain repositories at upgrade time get the flag set without any re-seeding, so existing selections are untouched. (#5356)
|
||||
- **First-Run Tray Language Follows the System Locale**: Before any language was chosen in settings, the tray menu was hardcoded to Simplified Chinese even on English/Japanese/Traditional-Chinese systems where the main UI correctly followed the OS locale — tray and UI disagreed until the user switched language once. The tray now derives its first-run language from the OS locale with the same precedence rules as the frontend (including `zh-TW`/`zh-HK`/`zh-Hant` → Traditional Chinese); an explicitly chosen language always wins, and unreadable locales fall back to Chinese as before. (#4355)
|
||||
- **Failed Live-Config Imports Show the Real Error and Refresh the List**: Every failed "import from live config" produced an empty error toast, because Tauri's `invoke` rejects with the backend's error _string_ while the handler read `.message` off it; the actual backend message is now shown (with a localized generic fallback), and the provider list is refreshed even on failure so side effects committed before the error are visible immediately.
|
||||
- **OpenClaw Preset Model Costs Corrected to Official List Prices**: Fifteen OpenClaw preset entries carried cost values in the wrong unit or unconverted currency — the `cost` field is USD per million tokens, but e.g. `glm-5.1` was listed at `0.001/0.001` (≈1000× undervalued, so its usage showed near-zero cost) while `deepseek-v4-pro` carried unconverted CNY values (overvalued). All entries now carry official list prices in $/M; subscription-plan and free-tier endpoints deliberately show list prices too, so plan users see the standard value of their usage. Providers created from these presets going forward get the corrected values; previously added providers keep the config they were created with.
|
||||
- **AiHubMix Icon on the Codex Preset**: The Codex app's AiHubMix preset was the only one missing its brand icon fields and rendered a generic icon; it now matches the other apps.
|
||||
- **Two Missing Locale Keys Backfilled in All Four Locales**: The Codex "needs routing because it uses Anthropic Messages format" toast rendered its reason fragment in Chinese inside an otherwise-localized sentence because `proxyReasonAnthropicMessages` existed in no locale file, and the provider-form key-status loading label had shipped only as a hardcoded default since April; both now exist in zh/en/ja/zh-TW.
|
||||
|
||||
### Docs
|
||||
|
||||
- **Codex + Claude Local Routing Guide (Three Languages)**: New guide in Chinese, English, and Japanese on pointing Codex at a Claude-family `/v1/messages` gateway via the native Anthropic Messages upstream format introduced in v3.17.0, with screenshots; the v3.17.0 release notes now link to it.
|
||||
- **Claude Code via Codex Providers Guide (Chinese)**: New Chinese guide on using Responses-speaking providers (a gateway API key, or a ChatGPT subscription's Codex service) from Claude Code: Claude Code keeps talking Anthropic Messages to the local `/v1/messages` route, and the proxy converts each request to the upstream's Responses protocol. With screenshots.
|
||||
- **README Sponsor Updates**: SubRouter added as a sponsor across the four README languages; the pinned Kimi sponsor copy refreshed to K3 with banners served from the Moonshot CDN; RunAPI benefit copy refreshed and sponsor rows reordered to match the in-app preset order.
|
||||
|
||||
### Internal
|
||||
|
||||
- **Backend CI Now Covers Linux, Windows, and macOS**: Backend checks previously ran only on Ubuntu, so Windows/macOS-gated code paths were never compiled or tested in CI — the matrix now spans all three platforms, with the platform-gated tests repaired to pass everywhere (TOML literal-string path escapes, stale anchored-update expectations, and skill tests honoring the test home-directory override). No shipped behavior changes. (#5138)
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- Upgrading from v3.17.0 runs three schema migrations (v13 → v16): v14 rebuilds the `proxy_config` table to admit Grok Build (existing per-app proxy settings are carried over and a `grokbuild` row is added), v15 adds Grok Build enablement columns to the MCP-server and Skills tables, and v16 triggers the Codex usage rebuild described next.
|
||||
- Schema v16 performs a one-time automatic rebuild of Codex session usage on first launch after upgrading: the database is backed up under `backups/`, `codex_session` data and cursors are reset, and the normal startup sync re-imports everything with the corrected parser. Expect seconds for typical data; the heaviest dataset measured (1,801 rollout files / 1.5 GB) took about 65 seconds. Later launches are incremental as before.
|
||||
- The rebuild recomputes usage from the rollout JSONL files, so history whose source log was already deleted cannot be reconstructed.
|
||||
- Fork files whose parent rollout is missing are deferred and reported instead of guessed; restoring the parent log and running “Rebuild Codex Usage” imports them later.
|
||||
- Historical proxy-source duplicate rows are permanently retained — the migration rebuilds only session-sourced data, and no cleanup pass for past proxy inflation exists; the idempotent logger prevents new duplicates from this point on.
|
||||
- The xAI Grok OAuth integration reuses the official Grok CLI's public OAuth client identity and scopes (`client_id b1a00492-073a-47ea-816f-4c329264a828`, scope including `grok-cli:access`) rather than a CC Switch-registered application. xAI may not support this use, and it could lead to account restriction or suspension — use at your own risk. The feature is entirely opt-in; nothing changes unless you add an xAI provider. On first login it creates `~/.cc-switch/xai_oauth_auth.json` (refresh tokens only, `0600` on Unix; access tokens are held in memory) and contacts `auth.x.ai` and `api.x.ai` through the configured outbound proxy, with no local callback port.
|
||||
- Diagnostic logs are no longer cleared at startup and now persist across restarts (up to ~100 MB of rotated runtime logs plus ~15 MB of crash logs). Log files written by earlier versions are not retroactively scrubbed and may contain API keys, tokens, or URLs with credentials — review pre-upgrade logs before sharing them publicly.
|
||||
- Installing or reinstalling Grok Build now prefers the official xAI installer, fetching `x.ai/cli/install.sh` (or `install.ps1` on Windows) at install time, with npm as fallback; existing npm installs keep updating via npm.
|
||||
- New built-in pricing rows (`grok-4.5`, `kimi-k3`, `k3`) are appended automatically on next launch via insert-if-absent; user-edited pricing rows are never overwritten.
|
||||
|
||||
## [3.17.0] - 2026-07-13
|
||||
|
||||
Development since v3.16.5 is headlined by project profiles — named snapshots of provider/MCP/Skills/prompt state, switchable per scope from a new header switcher or the tray (schema v12) — and a deep Codex push: official ChatGPT-subscription sessions can now route through the local proxy takeover with a corrected client identity, gpt-5.6 lands across context-window injection and Sol/Terra/Luna pricing with 1.25× cache-write rates, and a native Anthropic Messages upstream joins the Codex format options. A proxy-correctness wave makes the Responses↔Anthropic bridges fail closed and round-trip reasoning/tool results losslessly, strengthens prompt-cache breakpoint injection, and fixes cache-write accounting across historical token semantics (schema v13); a config.toml hardening batch stops deleted MCP servers from resurrecting, fails MCP sync closed on unparseable files, extends switch-time common-config autosync to Codex, and moves the common-config merge to backend toml_edit. Usage tooling gains Zhipu team-plan quota queries, Codex sub-agent and free-plan accounting, and transient-failure retry, while Kimi For Coding's 256K window finally takes effect — rounded out by a Codex default-model form field, renamed-session titles, OpenCode form and live-sync fixes, and preset updates (SudoCode sponsorship, LongCat-2.0, GPT-5.6 defaults, Hunyuan Hy3 pricing).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### The All-in-One Manager for Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw & Hermes Agent
|
||||
### The All-in-One Manager for Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw & Hermes Agent
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -25,11 +25,11 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Deutsch](README_
|
||||
<details open>
|
||||
<summary>Click to collapse</summary>
|
||||
|
||||
[](https://platform.kimi.ai?aff=cc-switch)
|
||||
[](https://platform.kimi.ai?aff=cc-switch)
|
||||
|
||||
Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI. It delivers stronger coding and agent performance, with substantial improvements in real-world long-horizon coding tasks. These gains translate into higher end-to-end task success rates across complex software engineering workflows. K2.7 Code also improves reasoning efficiency, reducing thinking-token usage by approximately 30% compared with K2.6. **[Click here to start using Kimi](https://platform.kimi.ai?aff=cc-switch)**
|
||||
Kimi K3 is Moonshot AI's most capable model and the world's first open 3T-class model. With 2.8 trillion parameters, native vision, and a 1-million-token context window, K3 delivers frontier performance across long-horizon coding, knowledge work, and reasoning. CC Switch makes it easy to configure and switch to Kimi across agentic tools. **[Click here to start using Kimi](https://platform.kimi.ai?aff=cc-switch)**
|
||||
|
||||
Doing mostly coding work? Try the **[Kimi For Coding plan](https://www.kimi.com/code/?aff=cc-switch)** — a subscription service built for coding!
|
||||
Doing mostly coding work? Try the **[Kimi Code subscription](https://www.kimi.com/code/?aff=cc-switch)**.
|
||||
|
||||
---
|
||||
|
||||
@@ -40,8 +40,13 @@ Doing mostly coding work? Try the **[Kimi For Coding plan](https://www.kimi.com/
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
|
||||
<td width="180"><a href="https://zetaapi.ai/go/u117"><img src="assets/partners/logos/zetaapi-banner.png" alt="ZetaAPI" width="150"></a></td>
|
||||
<td>Thanks to ZetaAPI for sponsoring this project! ZetaAPI focuses on real model fidelity, no watered-down responses, no quality degradation, and pricing as low as 35% of official rates. The platform does not mix traffic, secretly replace models with lower-quality alternatives, or use fake model routing. It supports Claude Code, Codex, Gemini, ChatGPT, and other mainstream AI models, helping users significantly reduce API costs while maintaining reliable model quality. At the same time, ZetaAPI provides enterprise-grade SLA-backed stability, standard API compatibility, one API key for multiple models, fast integration, and pay-as-you-go billing, making it suitable for AI products, coding agents, internal business tools, customer service systems, content generation, and automation workflows. If any model is verified to be inconsistent with its stated quality, ZetaAPI backs it with a 10x compensation guarantee, giving users a more stable, transparent, and trustworthy experience. Register via <a href="https://zetaapi.ai/go/u117">this link</a> and use the promo code CC-SWITCH during your first recharge to enjoy an exclusive 10% discount on your first top-up, just for CC Switch users!</td>
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -50,11 +55,6 @@ Doing mostly coding work? Try the **[Kimi For Coding plan](https://www.kimi.com/
|
||||
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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>Thanks to Shengsuanyun for sponsoring this project! Shengsuanyun is a super factory serving AI Native Teams — an industrial-grade AI task parallel execution platform. Its model marketplace aggregates Claude, ChatGPT, Gemini, and other domestic and international LLM and multimedia model capabilities with direct supply. Absolutely no reverse engineering or dilution — platform-wide model SLA availability reaches 99.7%, with <a href="https://watch.shengsuanyun.com/status/shengsuanyun">monitoring dashboards</a> showing green across the board. It also offers enterprise-grade custom gateways for fine-grained team cost and permission management, smart routing, security protection, and BYOK (Bring Your Own Key) hosting. The platform charges on a pay-per-use and tokens plan (coming soon) basis, with invoicing available. Register via <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">this link</a> as a new user to receive ¥10 in credits plus a 10% bonus on your first top-up.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
|
||||
<td>Thanks to PatewayAI for sponsoring this project! PatewayAI is an API relay service provider built for heavy AI developers, focused on directly relaying official high-quality model APIs. It offers the full Claude lineup and the Codex series, 100% sourced from official channels — no dilution, no fakes, verification welcome. Billing is transparent and every token-level invoice can be audited line by line.
|
||||
@@ -63,65 +63,38 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory"><img src="assets/partners/logos/TeamoRouter-banner.png" alt="TeamoRouter" width="150"></a></td>
|
||||
<td>Thanks to TeamoRouter for sponsoring this project! TeamoRouter is an enterprise-grade Agentic LLM gateway built for developers, AI teams, and businesses. Without requiring any subscriptions, it lets you access Claude Code, Codex, Gemini CLI, OpenAI Codex, and other popular AI agents through a single unified API, while offering API pricing at discounts of up to 90%.
|
||||
Unlike typical API relay services, TeamoRouter aggregates hundreds of official model providers and trusted infrastructure partners, including OpenAI, Anthropic, Vertex, Azure, and AWS bedrock. Every provider is verified for 100% Agent protocol compatibility, cache performance, and request traceability, ensuring stable quality instead of reverse-engineered or diluted endpoints. The platform delivers near-official TTFT, 99.6% SLA, enterprise-scale throughput up to 5,000 QPM, and industry-leading cache hit rates that dramatically reduce token costs for long-running agent workflows.
|
||||
TeamoRouter also offers enterprise features including centralized billing, team management, BYOK, smart routing, usage analytics, dynamic provider optimization, and dedicated support. For an even simpler experience, Teamo Desktop lets you use Claude Code, Codex, Gemini CLI, and other popular AI agents with one-click setup—no API key management or manual gateway configuration required. Register via <a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory">this link</a> as a new user to receive 10% off your first top-up.</td>
|
||||
<td width="180"><a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL"><img src="assets/partners/logos/fenno-banner.png" alt="Fenno.ai" width="150"></a></td>
|
||||
<td>Thanks to Fenno.ai for sponsoring this project! Fenno.ai is a stable and efficient API relay service provider, currently focused on Codex relay. It is compatible with the OpenAI and Anthropic protocols and can be flexibly used from Codex, Claude Code, OpenCode, and other mainstream coding tools. It reliably supports enterprise-grade workloads of hundreds of billions of tokens per day, with corporate (B2B) settlement and invoicing for both domestic and overseas entities. Fenno.ai offers an exclusive benefit for CC Switch users: subscribe via <a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL">this link</a> to the incredible ¥9.9 Coding Plan worth $150 in credits, and earn up to 20% in referral rewards — invite more, earn more!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://zetaapi.ai/go/ccs"><img src="assets/partners/logos/zetaapi-banner.png" alt="ZetaAPI" width="150"></a></td>
|
||||
<td>Thanks to ZetaAPI for sponsoring this project! ZetaAPI focuses on real model fidelity, no watered-down responses, no quality degradation, and pricing as low as 35% of official rates. The platform does not mix traffic, secretly replace models with lower-quality alternatives, or use fake model routing. It supports Claude Code, Codex, Gemini, ChatGPT, and other mainstream AI models, helping users significantly reduce API costs while maintaining reliable model quality. At the same time, ZetaAPI provides enterprise-grade SLA-backed stability, standard API compatibility, one API key for multiple models, fast integration, and pay-as-you-go billing, making it suitable for AI products, coding agents, internal business tools, customer service systems, content generation, and automation workflows. If any model is verified to be inconsistent with its stated quality, ZetaAPI backs it with a 10x compensation guarantee, giving users a more stable, transparent, and trustworthy experience. Register via <a href="https://zetaapi.ai/go/ccs">this link</a> and use the promo code CC-SWITCH during your first recharge to enjoy an exclusive 10% discount on your first top-up, just for CC Switch users!</td>
|
||||
<td width="180"><a href="https://runapi.co/register?aff=iOKB"><img src="assets/partners/logos/runapi.jpg" alt="RunAPI" width="150"></a></td>
|
||||
<td>Thanks to RunAPI for sponsoring this project! RunAPI is a high-performance and reliable AI model API gateway — one API key gives you access to 150+ mainstream models including OpenAI, Claude, Gemini, DeepSeek, and Grok, with prices as low as 10% of the official rate and excellent stability. It works seamlessly with Claude Code, OpenClaw, and other tools. Exclusive benefit for CC Switch users: register via <a href="https://runapi.co/register?aff=iOKB">this link</a> and enjoy a 10% discount on your first top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a full‑modal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, long‑task execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, end‑to‑end complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">this link</a> to get 500,000 tokens of free inference quota per model.<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>Thanks to Unity2.ai for sponsoring this project! Unity2.ai is a high-performance AI model API relay platform for individual developers, teams, and enterprises. Long trusted by leading companies in China, it serves over 30 billion tokens per day and supports high concurrency at the 5,000 RPM level. It offers balance-based billing, first top-up bonuses, bundle subscriptions, corporate invoicing, and dedicated support. Register via <a href="https://unity2.ai/register?source=ccs">this link</a> to get $2 in credits, plus another $10 for joining the official group — up to $12 in free credits!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/YflgU2Ve"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via <a href="https://cloud.siliconflow.cn/i/YflgU2Ve">this link</a> and complete real-name verification to receive ¥16 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free.</td>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>Thanks to Shengsuanyun for sponsoring this project! Shengsuanyun is a super factory serving AI Native Teams — an industrial-grade AI task parallel execution platform. Its model marketplace aggregates Claude, ChatGPT, Gemini, and other domestic and international LLM and multimedia model capabilities with direct supply. Absolutely no reverse engineering or dilution — platform-wide model SLA availability reaches 99.7%, with <a href="https://watch.shengsuanyun.com/status/shengsuanyun">monitoring dashboards</a> showing green across the board. It also offers enterprise-grade custom gateways for fine-grained team cost and permission management, smart routing, security protection, and BYOK (Bring Your Own Key) hosting. The platform charges on a pay-per-use and tokens plan (coming soon) basis, with invoicing available. Register via <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">this link</a> as a new user to receive ¥10 in credits plus a 10% bonus on your first top-up.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more with flexible billing options including pay-as-you-go and monthly plans. Cubence provides special discounts for CC Switch users: register using <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">this link</a> and enter the "CCSWITCH" promo code during recharge to get 10% off every top-up!</td>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
|
||||
<td>Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! <a href="https://www.dmxapi.cn/register?aff=bUHu">Register here</a></td>
|
||||
<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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Compshare" width="150"></a></td>
|
||||
<td>Thanks to Compshare for sponsoring this project! Compshare is UCloud's AI cloud platform, providing stable and comprehensive domestic and international model APIs with just one key. Featuring cost-effective monthly and per-use domestic-model Coding Plan packages, alongside stable officially-relayed overseas models. Supports Claude Code, Codex, and API access. Enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing. Users who register via <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">this link</a> will receive a free 5 CNY platform trial credit!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, and more. All models at 55% of official pricing with auto-failover, smart routing, and unlimited concurrency. Crazyrouter offers an exclusive deal for CC Switch users: register via <a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">this link</a> and contact customer support to claim <strong>$2 free credit</strong>, plus enter promo code `CCSWITCH` on your first top-up for an extra <strong>30% bonus credit</strong>! </td>
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||
<td>Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via <a href="https://www.sssaicode.com/register?ref=DCP0SM">this link</a> to enjoy $10 extra credit on every top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>Thanks to Micu API for sponsoring this project! Micu API is a global LLM relay service provider dedicated to delivering the best cost-performance ratio with high stability. Backed by a registered enterprise for core assurance, eliminating any risk of service discontinuation, with fast official invoicing support! We champion "zero cost to try": top up from as low as ¥1 with no minimum, and get fee-free refunds anytime! Micu API offers an exclusive deal for CC Switch users: register via <a href="https://www.micuapi.ai/register?aff=aOYQ">this link</a> and enter promo code "ccswitch" when topping up to enjoy a <strong>10% discount</strong>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||
<td>Thanks to ETok.ai for sponsoring this project! ETok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click <a href="https://etok.ai">here</a> to register!</td>
|
||||
<td width="180"><a href="https://apikey.fun/register?aff=CCSwitch"><img src="assets/partners/logos/apikey_banner.png" alt="APIKEY.FUN" width="150"></a></td>
|
||||
<td>Thanks to APIKEY.FUN for sponsoring this project! APIKEY.FUN is a professional enterprise-grade AI relay platform dedicated to providing stable, efficient, and low-cost AI model API access for enterprises and individual developers. The platform supports popular mainstream models such as Claude, OpenAI, and Gemini, with prices as low as 7% of official rates. Register through this project's <a href="https://apikey.fun/register?aff=CCSwitch">exclusive link</a> to enjoy an exclusive offer of up to <strong>permanent 5% off top-ups</strong>.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -135,48 +108,10 @@ TeamoRouter also offers enterprise features including centralized billing, team
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://claudecn.top"><img src="assets/partners/logos/claudecn.jpg" alt="ClaudeCN" width="150"></a></td>
|
||||
<td>Thanks to ClaudeCN for sponsoring this project! ClaudeCN is an enterprise-grade AI gateway platform operated by a registered company. It delivers high-availability commercial API access to popular models including Claude, GPT, and DeepSeek, and is built around formal enterprise procurement workflows — corporate bank transfers, signed contracts, and full compliance. Register via <a href="https://claudecn.top">this link</a>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://runapi.co"><img src="assets/partners/logos/runapi.jpg" alt="RunAPI" width="150"></a></td>
|
||||
<td>Thanks to RunAPI for sponsoring this project! RunAPI is a high-performance and reliable AI model API gateway — one API key gives you access to 150+ mainstream models including OpenAI, Claude, Gemini, DeepSeek, and Grok, with prices as low as 10% of the official rate and excellent stability. It works seamlessly with Claude Code, OpenClaw, and other tools. Exclusive benefit for CC Switch users: register and contact customer support to claim a free ¥14 credit. Register via <a href="https://runapi.co">this link</a>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikey.fun/register?aff=CCSwitch"><img src="assets/partners/logos/apikey_banner.png" alt="APIKEY.FUN" width="150"></a></td>
|
||||
<td>Thanks to APIKEY.FUN for sponsoring this project! APIKEY.FUN is a professional enterprise-grade AI relay platform dedicated to providing stable, efficient, and low-cost AI model API access for enterprises and individual developers. The platform supports popular mainstream models such as Claude, OpenAI, and Gemini, with prices as low as 7% of official rates. Register through this project's <a href="https://apikey.fun/register?aff=CCSwitch">exclusive link</a> to enjoy an exclusive offer of up to <strong>permanent 5% off top-ups</strong>.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apinebula.com/02rw5X"><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/02rw5X">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.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch"><img src="assets/partners/logos/atlascloud_banner.png" alt="Atlas Cloud" width="150"></a></td>
|
||||
<td>Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out Atlas Cloud's new <a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">coding plan</a> promotion for more budget-friendly API access!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.svg" alt="CCSub" width="150"></a></td>
|
||||
<td>Thanks to CCSub for sponsoring this project! CCSub is a stable, affordable AI API relay platform — your drop-in replacement for a Claude.ai subscription. One API key gives you access to Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini, and DeepSeek at roughly 30% of direct API cost, with no VPN required from anywhere in the world. Compatible with Claude Code, Codex, Cursor, Cline, Continue, Windsurf, and all major AI coding tools. Register via <a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">this link</a> and get $5 free credit on sign-up.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>Thanks to Unity2.ai for sponsoring this project! Unity2.ai is a high-performance AI model API relay platform for individual developers, teams, and enterprises. Long trusted by leading companies in China, it serves over 30 billion tokens per day and supports high concurrency at the 5,000 RPM level. It offers balance-based billing, first top-up bonuses, bundle subscriptions, corporate invoicing, and dedicated support. Register via <a href="https://unity2.ai/register?source=ccs">this link</a> to get $2 in credits, plus another $10 for joining the official group — up to $12 in free credits!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL"><img src="assets/partners/logos/fenno-banner.png" alt="Fenno.ai" width="150"></a></td>
|
||||
<td>Thanks to Fenno.ai for sponsoring this project! Fenno.ai is a stable and efficient API relay service provider, currently focused on Codex relay. It is compatible with the OpenAI and Anthropic protocols and can be flexibly used from Codex, Claude Code, OpenCode, and other mainstream coding tools. It reliably supports enterprise-grade workloads of hundreds of billions of tokens per day, with corporate (B2B) settlement and invoicing for both domestic and overseas entities. Fenno.ai offers an exclusive benefit for CC Switch users: subscribe via <a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL">this link</a> to the incredible ¥9.9 Coding Plan worth $150 in credits, and earn up to 20% in referral rewards — invite more, earn more!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
|
||||
<td>Thanks to <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> for sponsoring this project! NekoCode provides developers with a stable, efficient, and reliable API relay service for Claude, Codex, and other AI models. With transparent pricing and flexible pay-as-you-go billing, it offers a simple and cost-effective way to access AI models. CC Switch users can enjoy an exclusive 10% discount: register via <a href="https://nekocode.ai?aff=CCSWITCH">this link</a> and enter promo code <code>cc-switch</code> during recharge to receive 10% off your top-up!</td>
|
||||
<td width="180"><a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory"><img src="assets/partners/logos/TeamoRouter-banner.png" alt="TeamoRouter" width="150"></a></td>
|
||||
<td>Thanks to TeamoRouter for sponsoring this project! TeamoRouter is an enterprise-grade Agentic LLM gateway built for developers, AI teams, and businesses. Without requiring any subscriptions, it lets you access Claude Code, Codex, Gemini CLI, OpenAI Codex, and other popular AI agents through a single unified API, while offering API pricing at discounts of up to 90%.
|
||||
Unlike typical API relay services, TeamoRouter aggregates hundreds of official model providers and trusted infrastructure partners, including OpenAI, Anthropic, Vertex, Azure, and AWS bedrock. Every provider is verified for 100% Agent protocol compatibility, cache performance, and request traceability, ensuring stable quality instead of reverse-engineered or diluted endpoints. The platform delivers near-official TTFT, 99.6% SLA, enterprise-scale throughput up to 5,000 QPM, and industry-leading cache hit rates that dramatically reduce token costs for long-running agent workflows.
|
||||
TeamoRouter also offers enterprise features including centralized billing, team management, BYOK, smart routing, usage analytics, dynamic provider optimization, and dedicated support. For an even simpler experience, Teamo Desktop lets you use Claude Code, Codex, Gemini CLI, and other popular AI agents with one-click setup—no API key management or manual gateway configuration required. Register via <a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory">this link</a> as a new user to receive 10% off your first top-up.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -185,8 +120,73 @@ TeamoRouter also offers enterprise features including centralized billing, team
|
||||
</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>
|
||||
<td width="180"><a href="https://claudecn.ai/register?aff=HEL9"><img src="assets/partners/logos/claudecn.jpg" alt="ClaudeCN" width="150"></a></td>
|
||||
<td>Thanks to ClaudeCN for sponsoring this project! ClaudeCN is an enterprise-grade AI gateway platform operated by a registered company. It delivers high-availability commercial API access to popular models including Claude, GPT, and DeepSeek, and is built around formal enterprise procurement workflows — corporate bank transfers, signed contracts, and full compliance. Register via <a href="https://claudecn.ai/register?aff=HEL9">this link</a>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a full‑modal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, long‑task execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, end‑to‑end complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">this link</a> to get 500,000 tokens of free inference quota per model.<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/YflgU2Ve"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via <a href="https://cloud.siliconflow.cn/i/YflgU2Ve">this link</a> and complete real-name verification to receive ¥16 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
|
||||
<td>Thanks to <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> for sponsoring this project! NekoCode provides developers with a stable, efficient, and reliable API relay service for Claude, Codex, and other AI models. With transparent pricing and flexible pay-as-you-go billing, it offers a simple and cost-effective way to access AI models. CC Switch users can enjoy an exclusive 10% discount: register via <a href="https://nekocode.ai?aff=CCSWITCH">this link</a> and enter promo code <code>cc-switch</code> during recharge to receive 10% off your top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch"><img src="assets/partners/logos/atlascloud_banner.png" alt="Atlas Cloud" width="150"></a></td>
|
||||
<td>Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out Atlas Cloud's new <a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">coding plan</a> promotion for more budget-friendly API access!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Compshare" width="150"></a></td>
|
||||
<td>Thanks to Compshare for sponsoring this project! Compshare is UCloud's AI cloud platform, providing stable and comprehensive domestic and international model APIs with just one key. Featuring cost-effective monthly and per-use domestic-model Coding Plan packages, alongside stable officially-relayed overseas models. Supports Claude Code, Codex, and API access. Enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing. Users who register via <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">this link</a> will receive a free 5 CNY platform trial credit!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.svg" alt="CCSub" width="150"></a></td>
|
||||
<td>Thanks to CCSub for sponsoring this project! CCSub is a stable, affordable AI API relay platform — your drop-in replacement for a Claude.ai subscription. One API key gives you access to Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini, and DeepSeek at roughly 30% of direct API cost, with no VPN required from anywhere in the world. Compatible with Claude Code, Codex, Cursor, Cline, Continue, Windsurf, and all major AI coding tools. Register via <a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">this link</a> and get $5 free credit on sign-up.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||
<td>Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via <a href="https://www.sssaicode.com/register?ref=DCP0SM">this link</a> to enjoy $10 extra credit on every top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>Thanks to Micu API for sponsoring this project! Micu API is a global LLM relay service provider dedicated to delivering the best cost-performance ratio with high stability. Backed by a registered enterprise for core assurance, eliminating any risk of service discontinuation, with fast official invoicing support! We champion "zero cost to try": top up from as low as ¥1 with no minimum, and get fee-free refunds anytime! Micu API offers an exclusive deal for CC Switch users: register via <a href="https://www.micuapi.ai/register?aff=aOYQ">this link</a> and enter promo code "ccswitch" when topping up to enjoy a <strong>10% discount</strong>!</td>
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||
<td>Thanks to ETok.ai for sponsoring this project! ETok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click <a href="https://etok.ai">here</a> to register!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more with flexible billing options including pay-as-you-go and monthly plans. Cubence provides special discounts for CC Switch users: register using <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">this link</a> and enter the "CCSWITCH" promo code during recharge to get 10% off every top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, and more. All models at 55% of official pricing with auto-failover, smart routing, and unlimited concurrency. Crazyrouter offers an exclusive deal for CC Switch users: register via <a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">this link</a> and contact customer support to claim <strong>$2 free credit</strong>, plus enter promo code `CCSWITCH` on your first top-up for an extra <strong>30% bonus credit</strong>! </td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
|
||||
<td>Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! <a href="https://www.dmxapi.cn/register?aff=bUHu">Register here</a></td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
@@ -195,13 +195,13 @@ TeamoRouter also offers enterprise features including centralized billing, team
|
||||
|
||||
## Why CC Switch?
|
||||
|
||||
Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, and Hermes — but each has its own configuration format. Switching API providers means manually editing JSON, TOML, or `.env` files, and there is no unified way to manage MCP and Skills across multiple tools.
|
||||
Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, and Hermes — but each has its own configuration format. Switching API providers means manually editing JSON, TOML, or `.env` files, and there is no unified way to manage MCP and Skills across multiple tools.
|
||||
|
||||
**CC Switch** gives you a single desktop app to manage all supported AI tools. Instead of editing config files by hand, you get a visual interface to import providers with one click, switch between them instantly, with 50+ built-in provider presets, unified MCP and Skills management, and system tray quick switching — all backed by a reliable SQLite database with atomic writes that protect your configs from corruption.
|
||||
|
||||
- **One App, Seven Tools** — Manage Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, and Hermes from a single interface
|
||||
- **One App, Eight Tools** — Manage Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, and Hermes from a single interface
|
||||
- **No More Manual Editing** — 50+ provider presets including AWS Bedrock, NVIDIA NIM, and community relays; just pick and switch
|
||||
- **Unified MCP & Skills Management** — One panel to manage MCP servers and Skills across Claude, Codex, Gemini, OpenCode, and Hermes with bidirectional sync
|
||||
- **Unified MCP & Skills Management** — One panel to manage MCP servers and Skills across Claude, Codex, Gemini, Grok Build, OpenCode, and Hermes with bidirectional sync
|
||||
- **System Tray Quick Switch** — Switch providers instantly from the tray menu, no need to open the full app
|
||||
- **Cloud Sync** — Sync provider data across devices via Dropbox, OneDrive, iCloud, or WebDAV servers
|
||||
- **Cross-Platform** — Native desktop app for Windows, macOS, and Linux, built with Tauri 2
|
||||
@@ -219,18 +219,18 @@ Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex
|
||||
|
||||
### Provider Management
|
||||
|
||||
- **7 supported tools, 50+ presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, Hermes; copy your key and import with one click
|
||||
- **8 supported tools, 50+ presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, Hermes; copy your key and import with one click
|
||||
- **Universal providers** — One config syncs to Claude Code, Codex, and Gemini CLI
|
||||
- One-click switching, system tray quick access, drag-and-drop sorting, import/export
|
||||
|
||||
### Proxy & Failover
|
||||
|
||||
- **Local proxy with hot-switching** — Format conversion, auto-failover, circuit breaker, provider health monitoring, and request rectifier
|
||||
- **App-level takeover** — Independently proxy Claude, Codex, or Gemini, down to individual providers
|
||||
- **App-level takeover** — Independently proxy Claude, Codex, Gemini, or Grok Build, down to individual providers
|
||||
|
||||
### MCP, Prompts & Skills
|
||||
|
||||
- **Unified MCP panel** — Manage MCP servers across Claude, Codex, Gemini, OpenCode, and Hermes with bidirectional sync and Deep Link import
|
||||
- **Unified MCP panel** — Manage MCP servers across Claude, Codex, Gemini, Grok Build, OpenCode, and Hermes with bidirectional sync and Deep Link import
|
||||
- **Prompts** — Markdown editor with cross-app sync (CLAUDE.md / AGENTS.md / GEMINI.md) and backfill protection
|
||||
- **Skills** — One-click install from GitHub repos or ZIP files, custom repository management, with symlink and file copy support
|
||||
|
||||
@@ -254,7 +254,7 @@ Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex
|
||||
<details>
|
||||
<summary><strong>Which AI tools does CC Switch support?</strong></summary>
|
||||
|
||||
CC Switch supports seven tools: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **OpenCode**, **OpenClaw**, and **Hermes**. Each tool has dedicated provider presets and configuration management.
|
||||
CC Switch supports eight tools: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **Grok Build**, **OpenCode**, **OpenClaw**, and **Hermes**. Each tool has dedicated provider presets and configuration management.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+103
-103
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### Der All-in-One-Manager für Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw & Hermes Agent
|
||||
### Der All-in-One-Manager für Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw & Hermes Agent
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -25,11 +25,11 @@
|
||||
<details open>
|
||||
<summary>Zum Einklappen klicken</summary>
|
||||
|
||||
[](https://platform.kimi.ai?aff=cc-switch)
|
||||
[](https://platform.kimi.ai?aff=cc-switch)
|
||||
|
||||
Kimi K2.7 Code ist ein quelloffenes, auf Programmierung spezialisiertes Agenten-Modell von Moonshot AI. Es bietet eine stärkere Programmier- und Agentenleistung mit erheblichen Verbesserungen bei realen, langfristigen Programmieraufgaben. Diese Fortschritte führen zu höheren End-to-End-Erfolgsraten in komplexen Software-Engineering-Workflows. Zudem verbessert K2.7 Code die Reasoning-Effizienz und reduziert den Verbrauch an Thinking-Tokens um rund 30 % gegenüber K2.6. **[Hier klicken, um Kimi auszuprobieren](https://platform.kimi.ai?aff=cc-switch)**
|
||||
Kimi K3 ist das bislang leistungsstärkste Modell von Moonshot AI und das weltweit erste offene Modell der 3T-Klasse. Mit 2,8 Billionen Parametern, nativen visuellen Fähigkeiten und einem Kontextfenster von 1 Million Token liefert K3 Spitzenleistung bei langfristigen Programmieraufgaben, Wissensarbeit und Reasoning. Mit CC Switch lässt sich Kimi in den verschiedensten Agenten-Tools bequem konfigurieren und umschalten. **[Hier klicken, um Kimi zu nutzen](https://platform.kimi.ai?aff=cc-switch)**
|
||||
|
||||
Hauptsächlich mit Programmierung beschäftigt? Probieren Sie den **[Kimi-For-Coding-Plan](https://www.kimi.com/code/?aff=cc-switch)** – ein Abo, das speziell fürs Programmieren entwickelt wurde!
|
||||
Hauptsächlich mit Programmierung beschäftigt? Probieren Sie das **[Kimi-Code-Abo](https://www.kimi.com/code/?aff=cc-switch)** aus!
|
||||
|
||||
---
|
||||
|
||||
@@ -40,8 +40,13 @@ Hauptsächlich mit Programmierung beschäftigt? Probieren Sie den **[Kimi-For-Co
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>Danke an AIGoCode für die Unterstützung dieses Projekts! AIGoCode ist eine All-in-One-Plattform, die Claude Code, Codex und die neuesten Gemini-Modelle integriert und Ihnen stabile, effiziente und äußerst kostengünstige KI-Coding-Dienste bietet. Die Plattform stellt flexible Abonnementpläne bereit, birgt kein Risiko einer Kontosperrung, ermöglicht Direktzugriff ohne VPN und reagiert blitzschnell. AIGoCode hat ein besonderes Angebot für CC-Switch-Nutzer vorbereitet: Wenn Sie sich über <a href="https://aigocode.com/invite/CC-SWITCH">diesen Link</a> registrieren, erhalten Sie bei Ihrer ersten Aufladung zusätzliche 10 % Bonusguthaben!</td>
|
||||
<td width="180"><a href="https://zetaapi.ai/go/u117"><img src="assets/partners/logos/zetaapi-banner.png" alt="ZetaAPI" width="150"></a></td>
|
||||
<td>Danke an ZetaAPI für die Unterstützung dieses Projekts! ZetaAPI legt den Fokus auf echte Modelltreue — keine verwässerten Antworten, keine Qualitätsminderung — und Preise von nur 35 % der offiziellen Tarife. Die Plattform mischt keinen Traffic, ersetzt Modelle nicht heimlich durch minderwertige Alternativen und nutzt kein gefälschtes Modell-Routing. Sie unterstützt Claude Code, Codex, Gemini, ChatGPT und weitere gängige KI-Modelle und hilft Nutzern, die API-Kosten deutlich zu senken und gleichzeitig eine zuverlässige Modellqualität zu gewährleisten. Gleichzeitig bietet ZetaAPI eine SLA-gestützte Stabilität auf Unternehmensniveau, Standard-API-Kompatibilität, einen API-Key für mehrere Modelle, schnelle Integration und nutzungsbasierte Abrechnung — geeignet für KI-Produkte, Coding-Agents, interne Unternehmenstools, Kundenservice-Systeme, Content-Erstellung und Automatisierungs-Workflows. Falls bei einem Modell nachgewiesen wird, dass es nicht der angegebenen Qualität entspricht, sichert ZetaAPI dies mit einer 10-fachen Entschädigungsgarantie ab und bietet Nutzern ein stabileres, transparenteres und vertrauenswürdigeres Erlebnis. Registrieren Sie sich über <a href="https://zetaapi.ai/go/u117">diesen Link</a> und verwenden Sie bei Ihrer ersten Aufladung den Promo-Code CC-SWITCH, um als CC-Switch-Nutzer einen exklusiven Rabatt von 10 % auf Ihre erste Aufladung zu erhalten!</td>
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -50,11 +55,6 @@ Hauptsächlich mit Programmierung beschäftigt? Probieren Sie den **[Kimi-For-Co
|
||||
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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>Danke an Shengsuanyun für die Unterstützung dieses Projekts! Shengsuanyun ist eine Superfabrik für KI-native Teams — eine Plattform zur parallelen Ausführung von KI-Aufgaben in industrieller Qualität. Ihr Modellmarktplatz bündelt die Fähigkeiten von Claude, ChatGPT, Gemini und weiteren in- und ausländischen LLM- und Multimedia-Modellen mit Direktbezug. Absolut kein Reverse Engineering und keine Verwässerung — die plattformweite Modell-SLA-Verfügbarkeit erreicht 99,7 %, und die <a href="https://watch.shengsuanyun.com/status/shengsuanyun">Monitoring-Dashboards</a> zeigen durchgehend grün an. Es bietet außerdem unternehmensgerechte, anpassbare Gateways für fein abgestufte Kosten- und Berechtigungsverwaltung im Team, intelligentes Routing, Sicherheitsschutz und BYOK-Hosting (Bring Your Own Key). Die Plattform rechnet nach Nutzung sowie über einen Token-Plan (in Kürze verfügbar) ab, und Rechnungsstellung ist möglich. Registrieren Sie sich über <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">diesen Link</a> als Neukunde und erhalten Sie ein Guthaben von ¥10 sowie 10 % Bonus auf Ihre erste Aufladung.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
|
||||
<td>Danke an PatewayAI für die Unterstützung dieses Projekts! PatewayAI ist ein API-Relay-Anbieter für anspruchsvolle KI-Entwickler, der sich auf das direkte Relayen offizieller hochwertiger Modell-APIs konzentriert. Er bietet die komplette Claude-Reihe und die Codex-Serie, zu 100 % aus offiziellen Kanälen bezogen — keine Verwässerung, keine Fälschungen, Überprüfung ausdrücklich erwünscht. Die Abrechnung ist transparent, und jede Rechnung auf Token-Ebene lässt sich Zeile für Zeile prüfen.
|
||||
@@ -63,65 +63,38 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory"><img src="assets/partners/logos/TeamoRouter-banner.png" alt="TeamoRouter" width="150"></a></td>
|
||||
<td>Danke an TeamoRouter für die Unterstützung dieses Projekts! TeamoRouter ist ein Agentic-LLM-Gateway in Enterprise-Qualität, das für Entwickler, KI-Teams und Unternehmen entwickelt wurde. Ganz ohne Abonnement können Sie über eine einzige einheitliche API auf Claude Code, Codex, Gemini CLI, OpenAI Codex und weitere beliebte KI-Agenten zugreifen — bei API-Preisen mit Rabatten von bis zu 90 %.
|
||||
Anders als typische API-Relay-Dienste bündelt TeamoRouter Hunderte offizieller Modellanbieter und vertrauenswürdiger Infrastrukturpartner, darunter OpenAI, Anthropic, Vertex, Azure und AWS Bedrock. Jeder Anbieter wird auf 100%ige Kompatibilität mit dem Agent-Protokoll, Cache-Performance und Nachverfolgbarkeit von Anfragen geprüft und liefert so stabile Qualität statt reverse-engineerter oder verwässerter Endpunkte. Die Plattform bietet nahezu offizielle TTFT, 99,6 % SLA, Durchsatz in Unternehmensgröße von bis zu 5.000 QPM und branchenführende Cache-Trefferquoten, die die Token-Kosten für langlaufende Agent-Workflows drastisch senken.
|
||||
TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team-Verwaltung, BYOK, intelligentes Routing, Nutzungsanalysen, dynamische Anbieter-Optimierung und dedizierten Support. Für ein noch einfacheres Erlebnis können Sie mit Teamo Desktop Claude Code, Codex, Gemini CLI und weitere beliebte KI-Agenten per Ein-Klick-Einrichtung nutzen — ohne Verwaltung von API-Schlüsseln oder manuelle Gateway-Konfiguration. Registrieren Sie sich als neuer Nutzer über <a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory">diesen Link</a> und erhalten Sie 10 % Rabatt auf Ihre erste Aufladung.</td>
|
||||
<td width="180"><a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL"><img src="assets/partners/logos/fenno-banner.png" alt="Fenno.ai" width="150"></a></td>
|
||||
<td>Danke an Fenno.ai für die Unterstützung dieses Projekts! Fenno.ai ist ein stabiler und effizienter API-Relay-Dienstleister, der sich derzeit hauptsächlich auf Codex-Relay konzentriert. Er ist mit den OpenAI- und Anthropic-Protokollen kompatibel und lässt sich flexibel mit Codex, Claude Code, OpenCode und anderen gängigen Coding-Tools nutzen. Er unterstützt zuverlässig Workloads auf Unternehmensniveau von Hunderten Milliarden Tokens pro Tag und bietet B2B-Abrechnung sowie Rechnungsstellung für Unternehmen im In- und Ausland. Fenno.ai bietet einen exklusiven Vorteil für CC-Switch-Nutzer: Abonnieren Sie über <a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL">diesen Link</a> den unschlagbaren ¥9,9-Coding-Plan im Wert von $150 Guthaben und erhalten Sie bis zu 20% Empfehlungsprämien — je mehr Einladungen, desto mehr Belohnung!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://zetaapi.ai/go/ccs"><img src="assets/partners/logos/zetaapi-banner.png" alt="ZetaAPI" width="150"></a></td>
|
||||
<td>Danke an ZetaAPI für die Unterstützung dieses Projekts! ZetaAPI legt den Fokus auf echte Modelltreue — keine verwässerten Antworten, keine Qualitätsminderung — und Preise von nur 35 % der offiziellen Tarife. Die Plattform mischt keinen Traffic, ersetzt Modelle nicht heimlich durch minderwertige Alternativen und nutzt kein gefälschtes Modell-Routing. Sie unterstützt Claude Code, Codex, Gemini, ChatGPT und weitere gängige KI-Modelle und hilft Nutzern, die API-Kosten deutlich zu senken und gleichzeitig eine zuverlässige Modellqualität zu gewährleisten. Gleichzeitig bietet ZetaAPI eine SLA-gestützte Stabilität auf Unternehmensniveau, Standard-API-Kompatibilität, einen API-Key für mehrere Modelle, schnelle Integration und nutzungsbasierte Abrechnung — geeignet für KI-Produkte, Coding-Agents, interne Unternehmenstools, Kundenservice-Systeme, Content-Erstellung und Automatisierungs-Workflows. Falls bei einem Modell nachgewiesen wird, dass es nicht der angegebenen Qualität entspricht, sichert ZetaAPI dies mit einer 10-fachen Entschädigungsgarantie ab und bietet Nutzern ein stabileres, transparenteres und vertrauenswürdigeres Erlebnis. Registrieren Sie sich über <a href="https://zetaapi.ai/go/ccs">diesen Link</a> und verwenden Sie bei Ihrer ersten Aufladung den Promo-Code CC-SWITCH, um als CC-Switch-Nutzer einen exklusiven Rabatt von 10 % auf Ihre erste Aufladung zu erhalten!</td>
|
||||
<td width="180"><a href="https://runapi.co/register?aff=iOKB"><img src="assets/partners/logos/runapi.jpg" alt="RunAPI" width="150"></a></td>
|
||||
<td>Danke an RunAPI für die Unterstützung dieses Projekts! RunAPI ist ein leistungsstarkes und zuverlässiges KI-Modell-API-Gateway — ein API-Schlüssel gibt Ihnen Zugriff auf mehr als 150 gängige Modelle, darunter OpenAI, Claude, Gemini, DeepSeek und Grok, zu Preisen ab 10 % des offiziellen Tarifs und mit ausgezeichneter Stabilität. Es arbeitet nahtlos mit Claude Code, OpenClaw und weiteren Werkzeugen zusammen. Exklusiver Vorteil für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://runapi.co/register?aff=iOKB">diesen Link</a> und erhalten Sie 10 % Rabatt auf Ihre erste Aufladung!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Danke an Dola seed für die Unterstützung dieses Projekts! Dola Seed 2.0 ist ein voll-modales Allzweck-Großmodell, das von ByteDance eigenständig für den globalen Markt entwickelt wurde. Aufbauend auf einer einheitlichen multimodalen Architektur unterstützt es das gemeinsame Verstehen und Generieren von Text, Bildern, Audio und Video. Es ermöglicht von Haus aus die Zusammenarbeit von Agenten und verfügt über starke Fähigkeiten in den Bereichen Schlussfolgern, Ausführung langer Aufgaben, Werkzeugintegration und Programmierung. Es ist breit einsetzbar — etwa für intelligente Cockpits, persönliche Assistenten, Bildung, Kundensupport, Marketing, Einzelhandel und weitere Szenarien. Es überzeugt bei multimodaler Wahrnehmung, der Ende-zu-Ende-Bewältigung komplexer Aufgaben, stabiler Interaktion und Datensicherheit und ist über die ModelArk-Plattform einfach zugänglich und bereitstellbar. Registrieren Sie sich über <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">diesen Link</a> und erhalten Sie pro Modell ein kostenloses Inferenzkontingent von 500.000 Token.<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>Danke an Unity2.ai für die Unterstützung dieses Projekts! Unity2.ai ist eine leistungsstarke AI-Modell-API-Relay-Plattform für Einzelentwickler, Teams und Unternehmen. Sie wird seit Langem von führenden Unternehmen in China genutzt, verarbeitet täglich über 30 Milliarden Tokens und unterstützt hohe Parallelität auf 5.000-RPM-Niveau. Geboten werden Guthaben-Abrechnung, Ersteinzahlungsbonus, Kombi-Abonnements, Firmenrechnungen und persönliche Betreuung. Registrieren Sie sich über <a href="https://unity2.ai/register?source=ccs">diesen Link</a> und erhalten Sie $2 Guthaben, plus weitere $10 für den Beitritt zur offiziellen Gruppe — bis zu $12 Gratis-Guthaben!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/YflgU2Ve"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>Danke an SiliconFlow für die Unterstützung dieses Projekts! SiliconFlow ist eine leistungsstarke KI-Infrastruktur- und Modell-API-Plattform, die schnellen und zuverlässigen Zugriff auf Sprach-, Audio-, Bild- und Videomodelle an einem Ort bietet. Mit nutzungsbasierter Abrechnung, breiter Unterstützung multimodaler Modelle, Hochgeschwindigkeitsinferenz und unternehmensgerechter Stabilität hilft SiliconFlow Entwicklern und Teams, KI-Anwendungen effizienter zu erstellen und zu skalieren. Registrieren Sie sich über <a href="https://cloud.siliconflow.cn/i/YflgU2Ve">diesen Link</a> und schließen Sie die Identitätsverifizierung ab, um ein Bonusguthaben von ¥16 zu erhalten, das für alle Modelle der Plattform nutzbar ist. SiliconFlow ist zudem nun mit OpenClaw kompatibel, sodass Nutzer einen SiliconFlow-API-Schlüssel verbinden und große KI-Modelle kostenlos aufrufen können.</td>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>Danke an Shengsuanyun für die Unterstützung dieses Projekts! Shengsuanyun ist eine Superfabrik für KI-native Teams — eine Plattform zur parallelen Ausführung von KI-Aufgaben in industrieller Qualität. Ihr Modellmarktplatz bündelt die Fähigkeiten von Claude, ChatGPT, Gemini und weiteren in- und ausländischen LLM- und Multimedia-Modellen mit Direktbezug. Absolut kein Reverse Engineering und keine Verwässerung — die plattformweite Modell-SLA-Verfügbarkeit erreicht 99,7 %, und die <a href="https://watch.shengsuanyun.com/status/shengsuanyun">Monitoring-Dashboards</a> zeigen durchgehend grün an. Es bietet außerdem unternehmensgerechte, anpassbare Gateways für fein abgestufte Kosten- und Berechtigungsverwaltung im Team, intelligentes Routing, Sicherheitsschutz und BYOK-Hosting (Bring Your Own Key). Die Plattform rechnet nach Nutzung sowie über einen Token-Plan (in Kürze verfügbar) ab, und Rechnungsstellung ist möglich. Registrieren Sie sich über <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">diesen Link</a> als Neukunde und erhalten Sie ein Guthaben von ¥10 sowie 10 % Bonus auf Ihre erste Aufladung.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>Danke an Cubence für die Unterstützung dieses Projekts! Cubence ist ein zuverlässiger und effizienter API-Relay-Anbieter, der Relay-Dienste für Claude Code, Codex, Gemini und mehr mit flexiblen Abrechnungsoptionen einschließlich nutzungsbasierter und monatlicher Pläne bereitstellt. Cubence bietet Sonderrabatte für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">diesen Link</a> und geben Sie beim Aufladen den Gutscheincode „CCSWITCH" ein, um bei jeder Aufladung 10 % Rabatt zu erhalten!</td>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>Danke an AIGoCode für die Unterstützung dieses Projekts! AIGoCode ist eine All-in-One-Plattform, die Claude Code, Codex und die neuesten Gemini-Modelle integriert und Ihnen stabile, effiziente und äußerst kostengünstige KI-Coding-Dienste bietet. Die Plattform stellt flexible Abonnementpläne bereit, birgt kein Risiko einer Kontosperrung, ermöglicht Direktzugriff ohne VPN und reagiert blitzschnell. AIGoCode hat ein besonderes Angebot für CC-Switch-Nutzer vorbereitet: Wenn Sie sich über <a href="https://aigocode.com/invite/CC-SWITCH">diesen Link</a> registrieren, erhalten Sie bei Ihrer ersten Aufladung zusätzliche 10 % Bonusguthaben!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
|
||||
<td>Danke an DMXAPI für die Unterstützung dieses Projekts! DMXAPI stellt mehr als 200 Unternehmenskunden globale Großmodell-API-Dienste bereit. Ein API-Schlüssel für alle Modelle weltweit. Zu den Funktionen gehören: sofortige Rechnungsstellung, unbegrenzte Nebenläufigkeit, ab 0,15 $, technischer Support rund um die Uhr. GPT/Claude/Gemini durchgehend zu 32 % Rabatt, inländische Modelle 20–50 % Rabatt, exklusive Claude-Code-Modelle zu 66 % Rabatt! <a href="https://www.dmxapi.cn/register?aff=bUHu">Hier registrieren</a></td>
|
||||
<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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Compshare" width="150"></a></td>
|
||||
<td>Danke an Compshare für die Unterstützung dieses Projekts! Compshare ist die KI-Cloud-Plattform von UCloud, die mit nur einem Schlüssel stabile und umfassende in- und ausländische Modell-APIs bereitstellt. Sie bietet kostengünstige Coding-Plan-Pakete für inländische Modelle mit monatlicher und nutzungsbasierter Abrechnung sowie stabile, offiziell gerelayte ausländische Modelle. Unterstützt Claude Code, Codex und API-Zugriff. Unternehmensgerechte hohe Nebenläufigkeit, technischer Support rund um die Uhr und Self-Service-Rechnungsstellung. Wer sich über <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">diesen Link</a> registriert, erhält ein kostenloses Plattform-Testguthaben von 5 CNY!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>Danke an Crazyrouter für die Unterstützung dieses Projekts! Crazyrouter ist eine leistungsstarke KI-API-Aggregationsplattform — ein API-Schlüssel für mehr als 300 Modelle, darunter Claude Code, Codex, Gemini CLI und weitere. Alle Modelle zu 55 % des offiziellen Preises, mit automatischem Failover, intelligentem Routing und unbegrenzter Nebenläufigkeit. Crazyrouter bietet ein exklusives Angebot für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">diesen Link</a> und kontaktieren Sie den Kundensupport, um <strong>2 $ Gratisguthaben</strong> zu erhalten; geben Sie zusätzlich bei Ihrer ersten Aufladung den Gutscheincode `CCSWITCH` ein, um <strong>30 % Bonusguthaben</strong> zu bekommen! </td>
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||
<td>Danke an SSSAiCode für die Unterstützung dieses Projekts! SSSAiCode ist ein stabiler und zuverlässiger API-Relay-Dienst, der sich der Bereitstellung stabiler, zuverlässiger und erschwinglicher Claude- und Codex-Modelldienste widmet, mit schneller Rechnungsstellung am selben Tag. SSSAiCode bietet ein besonderes Angebot für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://www.sssaicode.com/register?ref=DCP0SM">diesen Link</a> und erhalten Sie bei jeder Aufladung 10 $ zusätzliches Guthaben!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>Danke an Micu API für die Unterstützung dieses Projekts! Micu API ist ein globaler LLM-Relay-Anbieter, der sich der Bereitstellung des besten Preis-Leistungs-Verhältnisses bei hoher Stabilität widmet. Gestützt auf ein eingetragenes Unternehmen als Kernabsicherung wird jedes Risiko einer Diensteinstellung ausgeschlossen, mit schneller offizieller Rechnungsstellung! Wir stehen für „kostenloses Ausprobieren": Aufladungen sind schon ab ¥1 ohne Mindestbetrag möglich, und gebührenfreie Rückerstattungen sind jederzeit möglich! Micu API bietet ein exklusives Angebot für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://www.micuapi.ai/register?aff=aOYQ">diesen Link</a> und geben Sie beim Aufladen den Gutscheincode „ccswitch" ein, um <strong>10 % Rabatt</strong> zu erhalten!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||
<td>Danke an ETok.ai für die Unterstützung dieses Projekts! ETok.ai widmet sich dem Aufbau einer Komplettlösung für KI-Programmierwerkzeuge. Wir bieten professionelle Claude-Code-Pakete und Dienste einer technischen Community, mit Unterstützung für Google Gemini und OpenAI Codex. Durch sorgfältig gestaltete Pläne und eine professionelle Tech-Community geben wir Entwicklern verlässliche Servicegarantien und kontinuierlichen technischen Support an die Hand und machen KI-gestützte Programmierung zu einem echten Produktivitätswerkzeug. Klicken Sie <a href="https://etok.ai">hier</a>, um sich zu registrieren!</td>
|
||||
<td width="180"><a href="https://apikey.fun/register?aff=CCSwitch"><img src="assets/partners/logos/apikey_banner.png" alt="APIKEY.FUN" width="150"></a></td>
|
||||
<td>Danke an APIKEY.FUN für die Unterstützung dieses Projekts! APIKEY.FUN ist eine professionelle KI-Relay-Plattform auf Enterprise-Niveau, die Unternehmen und einzelnen Entwicklern stabilen, effizienten und kostengünstigen Zugriff auf KI-Modell-APIs bietet. Die Plattform unterstützt beliebte Mainstream-Modelle wie Claude, OpenAI und Gemini, mit Preisen ab 7 % der offiziellen Tarife. Wer sich über den <a href="https://apikey.fun/register?aff=CCSwitch">exklusiven Link</a> dieses Projekts registriert, kann ein exklusives Angebot von bis zu <strong>dauerhaft 5 % Rabatt auf Aufladungen</strong> erhalten.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -135,48 +108,10 @@ TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://claudecn.top"><img src="assets/partners/logos/claudecn.jpg" alt="ClaudeCN" width="150"></a></td>
|
||||
<td>Danke an ClaudeCN für die Unterstützung dieses Projekts! ClaudeCN ist eine unternehmensgerechte KI-Gateway-Plattform, die von einem eingetragenen Unternehmen betrieben wird. Sie bietet hochverfügbaren kommerziellen API-Zugriff auf beliebte Modelle wie Claude, GPT und DeepSeek und ist auf formelle Unternehmensbeschaffungsprozesse ausgerichtet — Banküberweisungen von Firmen, unterzeichnete Verträge und volle Compliance. Registrieren Sie sich über <a href="https://claudecn.top">diesen Link</a>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://runapi.co"><img src="assets/partners/logos/runapi.jpg" alt="RunAPI" width="150"></a></td>
|
||||
<td>Danke an RunAPI für die Unterstützung dieses Projekts! RunAPI ist ein leistungsstarkes und zuverlässiges KI-Modell-API-Gateway — ein API-Schlüssel gibt Ihnen Zugriff auf mehr als 150 gängige Modelle, darunter OpenAI, Claude, Gemini, DeepSeek und Grok, zu Preisen ab 10 % des offiziellen Tarifs und mit ausgezeichneter Stabilität. Es arbeitet nahtlos mit Claude Code, OpenClaw und weiteren Werkzeugen zusammen. Exklusiver Vorteil für CC-Switch-Nutzer: Registrieren Sie sich und kontaktieren Sie den Kundensupport, um ein kostenloses Guthaben von ¥14 einzulösen. Registrieren Sie sich über <a href="https://runapi.co">diesen Link</a>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikey.fun/register?aff=CCSwitch"><img src="assets/partners/logos/apikey_banner.png" alt="APIKEY.FUN" width="150"></a></td>
|
||||
<td>Danke an APIKEY.FUN für die Unterstützung dieses Projekts! APIKEY.FUN ist eine professionelle KI-Relay-Plattform auf Enterprise-Niveau, die Unternehmen und einzelnen Entwicklern stabilen, effizienten und kostengünstigen Zugriff auf KI-Modell-APIs bietet. Die Plattform unterstützt beliebte Mainstream-Modelle wie Claude, OpenAI und Gemini, mit Preisen ab 7 % der offiziellen Tarife. Wer sich über den <a href="https://apikey.fun/register?aff=CCSwitch">exklusiven Link</a> dieses Projekts registriert, kann ein exklusives Angebot von bis zu <strong>dauerhaft 5 % Rabatt auf Aufladungen</strong> erhalten.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apinebula.com/02rw5X"><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/02rw5X">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.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch"><img src="assets/partners/logos/atlascloud_banner.png" alt="Atlas Cloud" width="150"></a></td>
|
||||
<td>Atlas Cloud ist eine vollmodale KI-Inferenzplattform, die Entwicklern über eine einzige KI-API Zugriff auf Videogenerierung, Bildgenerierung und LLM-APIs bietet. Statt mehrere Anbieterintegrationen zu verwalten, verbinden Sie sich einmal und erhalten einheitlichen Zugriff auf mehr als 300 kuratierte Modelle über alle Modalitäten hinweg. Sehen Sie sich die neue <a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">Coding-Plan</a>-Aktion von Atlas Cloud für kostengünstigeren API-Zugang an!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.svg" alt="CCSub" width="150"></a></td>
|
||||
<td>Danke an CCSub für die Unterstützung dieses Projekts! CCSub ist eine zuverlässige und kostengünstige AI-API-Relay-Plattform — Ihr direkter Ersatz für ein Claude.ai-Abonnement. Mit einem einzigen API-Schlüssel erhalten Sie Zugriff auf Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini und DeepSeek zu etwa 30 % der Kosten der direkten API-Nutzung — ohne VPN, weltweit nutzbar. Kompatibel mit Claude Code, Codex, Cursor, Cline, Continue, Windsurf und allen gängigen AI-Coding-Tools. Registrieren Sie sich über <a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">diesen Link</a> und erhalten Sie $5 Startguthaben bei der Anmeldung.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>Danke an Unity2.ai für die Unterstützung dieses Projekts! Unity2.ai ist eine leistungsstarke AI-Modell-API-Relay-Plattform für Einzelentwickler, Teams und Unternehmen. Sie wird seit Langem von führenden Unternehmen in China genutzt, verarbeitet täglich über 30 Milliarden Tokens und unterstützt hohe Parallelität auf 5.000-RPM-Niveau. Geboten werden Guthaben-Abrechnung, Ersteinzahlungsbonus, Kombi-Abonnements, Firmenrechnungen und persönliche Betreuung. Registrieren Sie sich über <a href="https://unity2.ai/register?source=ccs">diesen Link</a> und erhalten Sie $2 Guthaben, plus weitere $10 für den Beitritt zur offiziellen Gruppe — bis zu $12 Gratis-Guthaben!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL"><img src="assets/partners/logos/fenno-banner.png" alt="Fenno.ai" width="150"></a></td>
|
||||
<td>Danke an Fenno.ai für die Unterstützung dieses Projekts! Fenno.ai ist ein stabiler und effizienter API-Relay-Dienstleister, der sich derzeit hauptsächlich auf Codex-Relay konzentriert. Er ist mit den OpenAI- und Anthropic-Protokollen kompatibel und lässt sich flexibel mit Codex, Claude Code, OpenCode und anderen gängigen Coding-Tools nutzen. Er unterstützt zuverlässig Workloads auf Unternehmensniveau von Hunderten Milliarden Tokens pro Tag und bietet B2B-Abrechnung sowie Rechnungsstellung für Unternehmen im In- und Ausland. Fenno.ai bietet einen exklusiven Vorteil für CC-Switch-Nutzer: Abonnieren Sie über <a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL">diesen Link</a> den unschlagbaren ¥9,9-Coding-Plan im Wert von $150 Guthaben und erhalten Sie bis zu 20% Empfehlungsprämien — je mehr Einladungen, desto mehr Belohnung!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
|
||||
<td>Vielen Dank an <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> für die Unterstützung dieses Projekts! NekoCode bietet Entwicklern einen stabilen, effizienten und zuverlässigen API-Relay-Dienst für Claude, Codex und weitere KI-Modelle. Mit transparenter Preisgestaltung und flexibler nutzungsbasierter Abrechnung bietet es einen einfachen und kostengünstigen Zugang zu KI-Modellen. CC-Switch-Nutzer erhalten einen exklusiven Rabatt von 10 %: Registrieren Sie sich über <a href="https://nekocode.ai?aff=CCSWITCH">diesen Link</a> und geben Sie beim Aufladen den Gutscheincode <code>cc-switch</code> ein, um 10 % Rabatt auf Ihre Aufladung zu erhalten!</td>
|
||||
<td width="180"><a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory"><img src="assets/partners/logos/TeamoRouter-banner.png" alt="TeamoRouter" width="150"></a></td>
|
||||
<td>Danke an TeamoRouter für die Unterstützung dieses Projekts! TeamoRouter ist ein Agentic-LLM-Gateway in Enterprise-Qualität, das für Entwickler, KI-Teams und Unternehmen entwickelt wurde. Ganz ohne Abonnement können Sie über eine einzige einheitliche API auf Claude Code, Codex, Gemini CLI, OpenAI Codex und weitere beliebte KI-Agenten zugreifen — bei API-Preisen mit Rabatten von bis zu 90 %.
|
||||
Anders als typische API-Relay-Dienste bündelt TeamoRouter Hunderte offizieller Modellanbieter und vertrauenswürdiger Infrastrukturpartner, darunter OpenAI, Anthropic, Vertex, Azure und AWS Bedrock. Jeder Anbieter wird auf 100%ige Kompatibilität mit dem Agent-Protokoll, Cache-Performance und Nachverfolgbarkeit von Anfragen geprüft und liefert so stabile Qualität statt reverse-engineerter oder verwässerter Endpunkte. Die Plattform bietet nahezu offizielle TTFT, 99,6 % SLA, Durchsatz in Unternehmensgröße von bis zu 5.000 QPM und branchenführende Cache-Trefferquoten, die die Token-Kosten für langlaufende Agent-Workflows drastisch senken.
|
||||
TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team-Verwaltung, BYOK, intelligentes Routing, Nutzungsanalysen, dynamische Anbieter-Optimierung und dedizierten Support. Für ein noch einfacheres Erlebnis können Sie mit Teamo Desktop Claude Code, Codex, Gemini CLI und weitere beliebte KI-Agenten per Ein-Klick-Einrichtung nutzen — ohne Verwaltung von API-Schlüsseln oder manuelle Gateway-Konfiguration. Registrieren Sie sich als neuer Nutzer über <a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory">diesen Link</a> und erhalten Sie 10 % Rabatt auf Ihre erste Aufladung.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -185,8 +120,73 @@ TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team
|
||||
</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>
|
||||
<td width="180"><a href="https://claudecn.ai/register?aff=HEL9"><img src="assets/partners/logos/claudecn.jpg" alt="ClaudeCN" width="150"></a></td>
|
||||
<td>Danke an ClaudeCN für die Unterstützung dieses Projekts! ClaudeCN ist eine unternehmensgerechte KI-Gateway-Plattform, die von einem eingetragenen Unternehmen betrieben wird. Sie bietet hochverfügbaren kommerziellen API-Zugriff auf beliebte Modelle wie Claude, GPT und DeepSeek und ist auf formelle Unternehmensbeschaffungsprozesse ausgerichtet — Banküberweisungen von Firmen, unterzeichnete Verträge und volle Compliance. Registrieren Sie sich über <a href="https://claudecn.ai/register?aff=HEL9">diesen Link</a>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Danke an Dola seed für die Unterstützung dieses Projekts! Dola Seed 2.0 ist ein voll-modales Allzweck-Großmodell, das von ByteDance eigenständig für den globalen Markt entwickelt wurde. Aufbauend auf einer einheitlichen multimodalen Architektur unterstützt es das gemeinsame Verstehen und Generieren von Text, Bildern, Audio und Video. Es ermöglicht von Haus aus die Zusammenarbeit von Agenten und verfügt über starke Fähigkeiten in den Bereichen Schlussfolgern, Ausführung langer Aufgaben, Werkzeugintegration und Programmierung. Es ist breit einsetzbar — etwa für intelligente Cockpits, persönliche Assistenten, Bildung, Kundensupport, Marketing, Einzelhandel und weitere Szenarien. Es überzeugt bei multimodaler Wahrnehmung, der Ende-zu-Ende-Bewältigung komplexer Aufgaben, stabiler Interaktion und Datensicherheit und ist über die ModelArk-Plattform einfach zugänglich und bereitstellbar. Registrieren Sie sich über <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">diesen Link</a> und erhalten Sie pro Modell ein kostenloses Inferenzkontingent von 500.000 Token.<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/YflgU2Ve"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>Danke an SiliconFlow für die Unterstützung dieses Projekts! SiliconFlow ist eine leistungsstarke KI-Infrastruktur- und Modell-API-Plattform, die schnellen und zuverlässigen Zugriff auf Sprach-, Audio-, Bild- und Videomodelle an einem Ort bietet. Mit nutzungsbasierter Abrechnung, breiter Unterstützung multimodaler Modelle, Hochgeschwindigkeitsinferenz und unternehmensgerechter Stabilität hilft SiliconFlow Entwicklern und Teams, KI-Anwendungen effizienter zu erstellen und zu skalieren. Registrieren Sie sich über <a href="https://cloud.siliconflow.cn/i/YflgU2Ve">diesen Link</a> und schließen Sie die Identitätsverifizierung ab, um ein Bonusguthaben von ¥16 zu erhalten, das für alle Modelle der Plattform nutzbar ist. SiliconFlow ist zudem nun mit OpenClaw kompatibel, sodass Nutzer einen SiliconFlow-API-Schlüssel verbinden und große KI-Modelle kostenlos aufrufen können.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
|
||||
<td>Vielen Dank an <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> für die Unterstützung dieses Projekts! NekoCode bietet Entwicklern einen stabilen, effizienten und zuverlässigen API-Relay-Dienst für Claude, Codex und weitere KI-Modelle. Mit transparenter Preisgestaltung und flexibler nutzungsbasierter Abrechnung bietet es einen einfachen und kostengünstigen Zugang zu KI-Modellen. CC-Switch-Nutzer erhalten einen exklusiven Rabatt von 10 %: Registrieren Sie sich über <a href="https://nekocode.ai?aff=CCSWITCH">diesen Link</a> und geben Sie beim Aufladen den Gutscheincode <code>cc-switch</code> ein, um 10 % Rabatt auf Ihre Aufladung zu erhalten!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch"><img src="assets/partners/logos/atlascloud_banner.png" alt="Atlas Cloud" width="150"></a></td>
|
||||
<td>Atlas Cloud ist eine vollmodale KI-Inferenzplattform, die Entwicklern über eine einzige KI-API Zugriff auf Videogenerierung, Bildgenerierung und LLM-APIs bietet. Statt mehrere Anbieterintegrationen zu verwalten, verbinden Sie sich einmal und erhalten einheitlichen Zugriff auf mehr als 300 kuratierte Modelle über alle Modalitäten hinweg. Sehen Sie sich die neue <a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">Coding-Plan</a>-Aktion von Atlas Cloud für kostengünstigeren API-Zugang an!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Compshare" width="150"></a></td>
|
||||
<td>Danke an Compshare für die Unterstützung dieses Projekts! Compshare ist die KI-Cloud-Plattform von UCloud, die mit nur einem Schlüssel stabile und umfassende in- und ausländische Modell-APIs bereitstellt. Sie bietet kostengünstige Coding-Plan-Pakete für inländische Modelle mit monatlicher und nutzungsbasierter Abrechnung sowie stabile, offiziell gerelayte ausländische Modelle. Unterstützt Claude Code, Codex und API-Zugriff. Unternehmensgerechte hohe Nebenläufigkeit, technischer Support rund um die Uhr und Self-Service-Rechnungsstellung. Wer sich über <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">diesen Link</a> registriert, erhält ein kostenloses Plattform-Testguthaben von 5 CNY!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.svg" alt="CCSub" width="150"></a></td>
|
||||
<td>Danke an CCSub für die Unterstützung dieses Projekts! CCSub ist eine zuverlässige und kostengünstige AI-API-Relay-Plattform — Ihr direkter Ersatz für ein Claude.ai-Abonnement. Mit einem einzigen API-Schlüssel erhalten Sie Zugriff auf Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini und DeepSeek zu etwa 30 % der Kosten der direkten API-Nutzung — ohne VPN, weltweit nutzbar. Kompatibel mit Claude Code, Codex, Cursor, Cline, Continue, Windsurf und allen gängigen AI-Coding-Tools. Registrieren Sie sich über <a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">diesen Link</a> und erhalten Sie $5 Startguthaben bei der Anmeldung.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||
<td>Danke an SSSAiCode für die Unterstützung dieses Projekts! SSSAiCode ist ein stabiler und zuverlässiger API-Relay-Dienst, der sich der Bereitstellung stabiler, zuverlässiger und erschwinglicher Claude- und Codex-Modelldienste widmet, mit schneller Rechnungsstellung am selben Tag. SSSAiCode bietet ein besonderes Angebot für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://www.sssaicode.com/register?ref=DCP0SM">diesen Link</a> und erhalten Sie bei jeder Aufladung 10 $ zusätzliches Guthaben!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>Danke an Micu API für die Unterstützung dieses Projekts! Micu API ist ein globaler LLM-Relay-Anbieter, der sich der Bereitstellung des besten Preis-Leistungs-Verhältnisses bei hoher Stabilität widmet. Gestützt auf ein eingetragenes Unternehmen als Kernabsicherung wird jedes Risiko einer Diensteinstellung ausgeschlossen, mit schneller offizieller Rechnungsstellung! Wir stehen für „kostenloses Ausprobieren": Aufladungen sind schon ab ¥1 ohne Mindestbetrag möglich, und gebührenfreie Rückerstattungen sind jederzeit möglich! Micu API bietet ein exklusives Angebot für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://www.micuapi.ai/register?aff=aOYQ">diesen Link</a> und geben Sie beim Aufladen den Gutscheincode „ccswitch" ein, um <strong>10 % Rabatt</strong> zu erhalten!</td>
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||
<td>Danke an ETok.ai für die Unterstützung dieses Projekts! ETok.ai widmet sich dem Aufbau einer Komplettlösung für KI-Programmierwerkzeuge. Wir bieten professionelle Claude-Code-Pakete und Dienste einer technischen Community, mit Unterstützung für Google Gemini und OpenAI Codex. Durch sorgfältig gestaltete Pläne und eine professionelle Tech-Community geben wir Entwicklern verlässliche Servicegarantien und kontinuierlichen technischen Support an die Hand und machen KI-gestützte Programmierung zu einem echten Produktivitätswerkzeug. Klicken Sie <a href="https://etok.ai">hier</a>, um sich zu registrieren!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>Danke an Cubence für die Unterstützung dieses Projekts! Cubence ist ein zuverlässiger und effizienter API-Relay-Anbieter, der Relay-Dienste für Claude Code, Codex, Gemini und mehr mit flexiblen Abrechnungsoptionen einschließlich nutzungsbasierter und monatlicher Pläne bereitstellt. Cubence bietet Sonderrabatte für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">diesen Link</a> und geben Sie beim Aufladen den Gutscheincode „CCSWITCH" ein, um bei jeder Aufladung 10 % Rabatt zu erhalten!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>Danke an Crazyrouter für die Unterstützung dieses Projekts! Crazyrouter ist eine leistungsstarke KI-API-Aggregationsplattform — ein API-Schlüssel für mehr als 300 Modelle, darunter Claude Code, Codex, Gemini CLI und weitere. Alle Modelle zu 55 % des offiziellen Preises, mit automatischem Failover, intelligentem Routing und unbegrenzter Nebenläufigkeit. Crazyrouter bietet ein exklusives Angebot für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">diesen Link</a> und kontaktieren Sie den Kundensupport, um <strong>2 $ Gratisguthaben</strong> zu erhalten; geben Sie zusätzlich bei Ihrer ersten Aufladung den Gutscheincode `CCSWITCH` ein, um <strong>30 % Bonusguthaben</strong> zu bekommen! </td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
|
||||
<td>Danke an DMXAPI für die Unterstützung dieses Projekts! DMXAPI stellt mehr als 200 Unternehmenskunden globale Großmodell-API-Dienste bereit. Ein API-Schlüssel für alle Modelle weltweit. Zu den Funktionen gehören: sofortige Rechnungsstellung, unbegrenzte Nebenläufigkeit, ab 0,15 $, technischer Support rund um die Uhr. GPT/Claude/Gemini durchgehend zu 32 % Rabatt, inländische Modelle 20–50 % Rabatt, exklusive Claude-Code-Modelle zu 66 % Rabatt! <a href="https://www.dmxapi.cn/register?aff=bUHu">Hier registrieren</a></td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
@@ -195,13 +195,13 @@ TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team
|
||||
|
||||
## Warum CC Switch?
|
||||
|
||||
Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw und Hermes — doch jedes hat sein eigenes Konfigurationsformat. Der Wechsel des API-Anbieters bedeutet, JSON-, TOML- oder `.env`-Dateien von Hand zu bearbeiten, und es gibt keine einheitliche Möglichkeit, MCP und Skills über mehrere Werkzeuge hinweg zu verwalten.
|
||||
Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw und Hermes — doch jedes hat sein eigenes Konfigurationsformat. Der Wechsel des API-Anbieters bedeutet, JSON-, TOML- oder `.env`-Dateien von Hand zu bearbeiten, und es gibt keine einheitliche Möglichkeit, MCP und Skills über mehrere Werkzeuge hinweg zu verwalten.
|
||||
|
||||
**CC Switch** gibt Ihnen eine einzige Desktop-App, um alle unterstützten KI-Werkzeuge zu verwalten. Statt Konfigurationsdateien von Hand zu bearbeiten, erhalten Sie eine visuelle Oberfläche, um Anbieter mit einem Klick zu importieren und sofort zwischen ihnen zu wechseln — mit 50+ integrierten Anbieter-Presets, einheitlicher MCP- und Skills-Verwaltung und schnellem Umschalten über das System-Tray. Das Ganze gestützt auf eine zuverlässige SQLite-Datenbank mit atomaren Schreibvorgängen, die Ihre Konfigurationen vor Beschädigung schützen.
|
||||
|
||||
- **Eine App, sieben Werkzeuge** — Verwalten Sie Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw und Hermes über eine einzige Oberfläche
|
||||
- **Eine App, acht Werkzeuge** — Verwalten Sie Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw und Hermes über eine einzige Oberfläche
|
||||
- **Kein manuelles Bearbeiten mehr** — 50+ Anbieter-Presets einschließlich AWS Bedrock, NVIDIA NIM und Community-Relays; einfach auswählen und umschalten
|
||||
- **Einheitliche MCP- & Skills-Verwaltung** — Ein Panel zur Verwaltung von MCP-Servern und Skills für Claude, Codex, Gemini, OpenCode und Hermes mit bidirektionaler Synchronisierung
|
||||
- **Einheitliche MCP- & Skills-Verwaltung** — Ein Panel zur Verwaltung von MCP-Servern und Skills für Claude, Codex, Gemini, Grok Build, OpenCode und Hermes mit bidirektionaler Synchronisierung
|
||||
- **Schnellumschaltung über System-Tray** — Wechseln Sie Anbieter sofort über das Tray-Menü, ohne die vollständige App öffnen zu müssen
|
||||
- **Cloud-Synchronisierung** — Synchronisieren Sie Anbieterdaten geräteübergreifend über Dropbox, OneDrive, iCloud oder WebDAV-Server
|
||||
- **Plattformübergreifend** — Native Desktop-App für Windows, macOS und Linux, gebaut mit Tauri 2
|
||||
@@ -219,18 +219,18 @@ Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code
|
||||
|
||||
### Anbieterverwaltung
|
||||
|
||||
- **7 unterstützte Werkzeuge, 50+ Presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, Hermes; Schlüssel kopieren und mit einem Klick importieren
|
||||
- **8 unterstützte Werkzeuge, 50+ Presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, Hermes; Schlüssel kopieren und mit einem Klick importieren
|
||||
- **Universelle Anbieter** — Eine Konfiguration synchronisiert sich mit Claude Code, Codex und Gemini CLI
|
||||
- Umschaltung mit einem Klick, Schnellzugriff über System-Tray, Sortierung per Drag-and-drop, Import/Export
|
||||
|
||||
### Proxy & Failover
|
||||
|
||||
- **Lokaler Proxy mit Hot-Switching** — Formatkonvertierung, automatisches Failover, Circuit Breaker, Anbieter-Health-Monitoring und Request-Rectifier
|
||||
- **Übernahme auf App-Ebene** — Claude, Codex oder Gemini unabhängig über den Proxy leiten, bis hinunter auf einzelne Anbieter
|
||||
- **Übernahme auf App-Ebene** — Claude, Codex, Gemini oder Grok Build unabhängig über den Proxy leiten, bis hinunter auf einzelne Anbieter
|
||||
|
||||
### MCP, Prompts & Skills
|
||||
|
||||
- **Einheitliches MCP-Panel** — Verwalten Sie MCP-Server für Claude, Codex, Gemini, OpenCode und Hermes mit bidirektionaler Synchronisierung und Deep-Link-Import
|
||||
- **Einheitliches MCP-Panel** — Verwalten Sie MCP-Server für Claude, Codex, Gemini, Grok Build, OpenCode und Hermes mit bidirektionaler Synchronisierung und Deep-Link-Import
|
||||
- **Prompts** — Markdown-Editor mit App-übergreifender Synchronisierung (CLAUDE.md / AGENTS.md / GEMINI.md) und Backfill-Schutz
|
||||
- **Skills** — Installation mit einem Klick aus GitHub-Repositorys oder ZIP-Dateien, Verwaltung eigener Repositorys, mit Unterstützung für Symlinks und Dateikopien
|
||||
|
||||
@@ -254,7 +254,7 @@ Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code
|
||||
<details>
|
||||
<summary><strong>Welche KI-Werkzeuge unterstützt CC Switch?</strong></summary>
|
||||
|
||||
CC Switch unterstützt sieben Werkzeuge: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **OpenCode**, **OpenClaw** und **Hermes**. Jedes Werkzeug verfügt über dedizierte Anbieter-Presets und Konfigurationsverwaltung.
|
||||
CC Switch unterstützt acht Werkzeuge: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **Grok Build**, **OpenCode**, **OpenClaw** und **Hermes**. Jedes Werkzeug verfügt über dedizierte Anbieter-Presets und Konfigurationsverwaltung.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+103
-103
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes Agent のオールインワン管理ツール
|
||||
### Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes Agent のオールインワン管理ツール
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -25,11 +25,11 @@
|
||||
<details open>
|
||||
<summary>クリックで折りたたむ</summary>
|
||||
|
||||
[](https://platform.kimi.ai?aff=cc-switch)
|
||||
[](https://platform.kimi.ai?aff=cc-switch)
|
||||
|
||||
Kimi K2.7 Code は Moonshot AI が開発した、コーディングに特化したオープンソースのエージェントモデルです。コーディング能力とエージェント性能が全面的に強化され、実世界の長程コーディングタスクで大幅な向上を実現し、複雑なソフトウェアエンジニアリングのワークフロー全体でエンドツーエンドのタスク成功率を高めます。さらに K2.7 Code は推論効率を改善し、K2.6 と比べて推論トークンの消費を約 30% 削減します。**[ここをクリックして Kimi を体験する](https://platform.kimi.ai?aff=cc-switch)**
|
||||
Kimi K3 は Moonshot AI がこれまでに開発した中で最も高性能なモデルであり、世界初のオープンソース 3T クラスモデルです。2.8 兆パラメータ、ネイティブな視覚能力、100 万トークンのコンテキストウィンドウを備え、長期にわたるコーディング、ナレッジワーク、推論タスクにおいてフロンティア級の性能を発揮します。CC Switch を使えば、さまざまなエージェントツールで Kimi を手軽に設定・切り替えできます。**[ここをクリックして Kimi を使い始める](https://platform.kimi.ai?aff=cc-switch)**
|
||||
|
||||
コーディング作業がメインですか?コーディングのために作られたサブスクリプション、**[Kimi For Coding プラン](https://www.kimi.com/code/?aff=cc-switch)** をぜひお試しください!
|
||||
コーディング作業がメインですか?**[Kimi Code サブスクリプション](https://www.kimi.com/code/?aff=cc-switch)** をぜひお試しください!
|
||||
|
||||
---
|
||||
|
||||
@@ -40,8 +40,13 @@ Kimi K2.7 Code は Moonshot AI が開発した、コーディングに特化し
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
|
||||
<td width="180"><a href="https://zetaapi.ai/go/u117"><img src="assets/partners/logos/zetaapi-banner.png" alt="ZetaAPI" width="150"></a></td>
|
||||
<td>本プロジェクトをご支援いただいている ZetaAPI に感謝します!ZetaAPI は、モデル品質の忠実性、水増しなし、性能劣化なし、公式価格の 35% から利用できる低価格を主な特徴としています。プラットフォームはトラフィックの混在、低品質モデルへの密かな切り替え、虚偽のモデルルーティングを行わず、Claude Code、Codex、Gemini、ChatGPT などの主要 AI モデルに対応しており、モデル品質を維持しながら API 利用コストを大幅に削減できます。同時に、ZetaAPI はエンタープライズ級の SLA 安定性保証、標準 API 互換、1つの Key による複数モデル接続、迅速な導入、従量課金などの機能を提供し、AI プロダクト、コード生成、企業内ツール、カスタマーサポート、コンテンツ生成、自動化ワークフローなどの用途に適しています。万が一、モデル品質が表記内容と一致しないことが確認された場合、ZetaAPI は 10 倍補償保証を提供し、ユーザーがより安定して、透明性高く、安心して利用できる環境を実現します。<a href="https://zetaapi.ai/go/u117">こちらのリンク</a>から登録し、初回チャージ時にプロモコード CC-SWITCH を使用すると、CC Switch ユーザー限定の初回チャージ 10% オフ特典をご利用いただけます!</td>
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -50,11 +55,6 @@ Kimi K2.7 Code は Moonshot AI が開発した、コーディングに特化し
|
||||
Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / 2% / 9%、チャージ時にはさらに割引!AICodeMirror は CC Switch ユーザー向けに特別特典を用意:<a href="https://www.aicodemirror.com/register?invitecode=9915W3">このリンク</a>から登録すると初回チャージ 20% オフ、法人のお客様は最大 25% オフ!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>胜算雲(Shengsuanyun)のご支援に感謝します!胜算雲は AI ネイティブチーム向けのスーパーファクトリーであり、産業グレードの AI タスク並列実行プラットフォームです。モデルマーケットプレイスでは Claude、ChatGPT、Gemini をはじめとする国内外の LLM およびマルチメディアモデルの計算リソースを集約・直接提供。リバースエンジニアリングや品質低下は一切なく、プラットフォーム全体のモデル SLA 可用性は 99.7% に達し、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">監視ダッシュボード</a>は常時グリーン表示です。さらにエンタープライズ向けカスタムゲートウェイを提供し、チームのきめ細かなコスト・権限管理、スマートルーティング、セキュリティ保護、BYOK(自社キー持ち込み)ホスティングを実現します。従量課金およびトークンプラン(近日公開)対応で、請求書発行にも対応。<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">このリンク</a>から新規登録すると 10 元分のクレジットと初回チャージ 10% ボーナスが付与されます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
|
||||
<td>PatewayAI のご支援に感謝します!PatewayAI はヘビーな AI 開発者向けに、公式直結の高品質モデル API 中継サービスを専門に提供するプロバイダーです。Claude シリーズ全モデルおよび Codex シリーズに対応し、100% 公式ソースから直接提供。混ぜ物・水増しは一切なく、検証も歓迎します。課金は透明で、トークン単位の請求書を 1 件ずつ照合可能です。
|
||||
@@ -63,65 +63,38 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory"><img src="assets/partners/logos/TeamoRouter-banner.png" alt="TeamoRouter" width="150"></a></td>
|
||||
<td>このプロジェクトをご支援いただいている TeamoRouter に感謝します!TeamoRouter は、開発者、AI チーム、企業向けに構築されたエンタープライズグレードの Agentic LLM ゲートウェイです。サブスクリプション不要で、単一の統合 API を通じて Claude Code、Codex、Gemini CLI、OpenAI Codex、その他の人気 AI エージェントにアクセスでき、API 料金は最大 90% オフで利用できます。
|
||||
一般的な API リレーサービスとは異なり、TeamoRouter は OpenAI、Anthropic、Vertex、Azure、AWS Bedrock など、数百の公式モデルプロバイダーと信頼できるインフラパートナーを集約しています。各プロバイダーは、Agent プロトコルとの 100% 互換性、キャッシュ性能、リクエストの追跡可能性について検証されており、リバースエンジニアリングされたものや品質が薄められたエンドポイントではなく、安定した品質を提供します。プラットフォームは公式に近い TTFT、99.6% の SLA、最大 5,000 QPM のエンタープライズ規模のスループット、そして業界トップクラスのキャッシュヒット率を提供し、長時間稼働するエージェントワークフローのトークンコストを大幅に削減します。
|
||||
TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーティング、利用状況分析、動的なプロバイダー最適化、専任サポートなどのエンタープライズ機能も提供しています。さらにシンプルな体験を求める場合は、Teamo Desktop を使うことで、Claude Code、Codex、Gemini CLI、その他の人気 AI エージェントをワンクリックで利用できます。API キー管理や手動のゲートウェイ設定は不要です。新規ユーザーとして<a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory">こちらのリンク</a>から登録すると、初回チャージが 10% オフになります。</td>
|
||||
<td width="180"><a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL"><img src="assets/partners/logos/fenno-banner.png" alt="Fenno.ai" width="150"></a></td>
|
||||
<td>Fenno.ai のご支援に感謝します!Fenno.ai は安定かつ高効率な API 中継サービスプロバイダーで、現在は主に Codex の中継を提供しています。OpenAI および Anthropic プロトコルに対応し、Codex・Claude Code・OpenCode などの主要なコーディングツールから柔軟に利用できます。1 日あたり数千億トークンというエンタープライズ級の呼び出し需要を安定して支え、国内外の法人による B2B 決済・請求書発行に対応しています。Fenno.ai は CC Switch 利用者限定の特典を用意しています:<a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL">こちらのリンク</a>から 9.9 元(150 ドル相当)のお得な Coding Plan を購入でき、友達紹介で最大 20% の特典がもらえます。紹介すればするほどお得です!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://zetaapi.ai/go/ccs"><img src="assets/partners/logos/zetaapi-banner.png" alt="ZetaAPI" width="150"></a></td>
|
||||
<td>本プロジェクトをご支援いただいている ZetaAPI に感謝します!ZetaAPI は、モデル品質の忠実性、水増しなし、性能劣化なし、公式価格の 35% から利用できる低価格を主な特徴としています。プラットフォームはトラフィックの混在、低品質モデルへの密かな切り替え、虚偽のモデルルーティングを行わず、Claude Code、Codex、Gemini、ChatGPT などの主要 AI モデルに対応しており、モデル品質を維持しながら API 利用コストを大幅に削減できます。同時に、ZetaAPI はエンタープライズ級の SLA 安定性保証、標準 API 互換、1つの Key による複数モデル接続、迅速な導入、従量課金などの機能を提供し、AI プロダクト、コード生成、企業内ツール、カスタマーサポート、コンテンツ生成、自動化ワークフローなどの用途に適しています。万が一、モデル品質が表記内容と一致しないことが確認された場合、ZetaAPI は 10 倍補償保証を提供し、ユーザーがより安定して、透明性高く、安心して利用できる環境を実現します。<a href="https://zetaapi.ai/go/ccs">こちらのリンク</a>から登録し、初回チャージ時にプロモコード CC-SWITCH を使用すると、CC Switch ユーザー限定の初回チャージ 10% オフ特典をご利用いただけます!</td>
|
||||
<td width="180"><a href="https://runapi.co/register?aff=iOKB"><img src="assets/partners/logos/runapi.jpg" alt="RunAPI" width="150"></a></td>
|
||||
<td>本プロジェクトのスポンサーである RunAPI に感謝いたします!RunAPI は高効率で安定した AI モデル API ゲートウェイです。一つの API Key で、OpenAI、Claude、Gemini、DeepSeek、Grok など 150 種類以上の主要モデルにアクセス可能。料金は公式価格の最大 10%、安定性にも優れ、Claude Code や OpenClaw などのツールとシームレスに連携できます。CC Switch ユーザー限定特典:<a href="https://runapi.co/register?aff=iOKB">こちらのリンク</a>から登録し、初回チャージで 10% オフの割引をお楽しみいただけます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">このリンク</a>からご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>Unity2.ai のご支援に感謝します!Unity2.ai は個人開発者・チーム・企業向けの高性能 AI モデル API リレープラットフォームです。中国の大手企業に長年利用されており、1 日 300 億トークン以上を処理し、5000 RPM クラスの高並列に対応しています。残高課金、初回チャージボーナス、組み合わせサブスクリプション、企業向け請求書発行、専任サポートを提供。<a href="https://unity2.ai/register?source=ccs">こちらのリンク</a>から登録すると $2 のクレジット、公式グループへの参加でさらに $10、最大 $12 の無料クレジットがもらえます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/YflgU2Ve"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。<a href="https://cloud.siliconflow.cn/i/YflgU2Ve">このリンク</a>から登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥16 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。</td>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>胜算雲(Shengsuanyun)のご支援に感謝します!胜算雲は AI ネイティブチーム向けのスーパーファクトリーであり、産業グレードの AI タスク並列実行プラットフォームです。モデルマーケットプレイスでは Claude、ChatGPT、Gemini をはじめとする国内外の LLM およびマルチメディアモデルの計算リソースを集約・直接提供。リバースエンジニアリングや品質低下は一切なく、プラットフォーム全体のモデル SLA 可用性は 99.7% に達し、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">監視ダッシュボード</a>は常時グリーン表示です。さらにエンタープライズ向けカスタムゲートウェイを提供し、チームのきめ細かなコスト・権限管理、スマートルーティング、セキュリティ保護、BYOK(自社キー持ち込み)ホスティングを実現します。従量課金およびトークンプラン(近日公開)対応で、請求書発行にも対応。<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">このリンク</a>から新規登録すると 10 元分のクレジットと初回チャージ 10% ボーナスが付与されます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>Cubence のご支援に感謝します!Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引:<a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">このリンク</a>で登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります!</td>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
|
||||
<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href="https://www.dmxapi.cn/register?aff=bUHu">登録はこちら</a></td>
|
||||
<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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Compshare" width="150"></a></td>
|
||||
<td>Compshare のご支援に感謝します!Compshare は UCloud 傘下の AI クラウドプラットフォームで、国内外の安定した包括的なモデル API を 1 つのキーだけで利用可能。月額・都度課金のコストパフォーマンスに優れた国内モデル Coding Plan パッケージを提供し、公式リレーによる安定した海外モデルも利用できます。Claude Code、Codex および API アクセスに対応。エンタープライズ級の高同時接続、24 時間年中無休のテクニカルサポート、セルフサービス請求書発行に対応。<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">こちらのリンク</a>から登録すると、無料で 5 元分のプラットフォーム体験クレジットがもらえます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI など 300 以上のモデルにアクセス可能。全モデルが公式価格の 55% で利用でき、自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。CC Switch ユーザー向けの限定特典:<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">こちらのリンク</a>から登録後、カスタマーサポートまでご連絡いただくと <strong>$2 の無料クレジット</strong> を受け取れます。さらに初回チャージ時にプロモコード `CCSWITCH` を入力すると <strong>30% のボーナスクレジット</strong> が追加されます!</td>
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||
<td>SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:<a href="https://www.sssaicode.com/register?ref=DCP0SM">こちらのリンク</a>から登録すると、毎回のチャージで $10 の追加ボーナスを受けられます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>Micu API のご支援に感謝します!Micu API は、最高のコストパフォーマンスと高い安定性を追求するグローバル大規模言語モデル中継サービスプロバイダーです。法人企業がバックアップしており、サービス停止のリスクを排除、迅速な正規請求書発行に対応!「試行コストゼロ」をモットーに、最低 1 元からチャージ可能で手数料無料、いつでも返金可能!CC Switch ユーザー向けの限定特典:<a href="https://www.micuapi.ai/register?aff=aOYQ">こちらのリンク</a>から登録し、チャージ時にプロモコード「ccswitch」を入力すると <strong>10% 割引</strong> が適用されます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||
<td>ETok.ai のご支援に感謝します!ETok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://etok.ai">こちら</a>から登録してください!</td>
|
||||
<td width="180"><a href="https://apikey.fun/register?aff=CCSwitch"><img src="assets/partners/logos/apikey_banner.png" alt="APIKEY.FUN" width="150"></a></td>
|
||||
<td>APIKEY.FUN のご支援に感謝します!APIKEY.FUN は、企業および個人開発者向けに安定・高効率・低コストな AI モデル API 接続サービスを提供する、プロフェッショナルなエンタープライズ級 AI リレープラットフォームです。Claude、OpenAI、Gemini などの主要人気モデルに対応し、料金は公式価格の 7% から利用できます。本プロジェクトの<a href="https://apikey.fun/register?aff=CCSwitch">専用リンク</a>から登録すると、最大で<strong>チャージ永久 5% オフ</strong>の特別優待も受けられます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -135,48 +108,10 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://claudecn.top"><img src="assets/partners/logos/claudecn.jpg" alt="ClaudeCN" width="150"></a></td>
|
||||
<td>本プロジェクトのスポンサーである ClaudeCN に感謝いたします!ClaudeCN は、実体のある企業によって運営されるエンタープライズ向け AI ゲートウェイプラットフォームです。Claude、GPT、DeepSeek など主要モデルへの高可用な商用 API アクセスを提供し、企業の調達プロセスにも対応 — 法人振込や正式契約に対応し、コンプライアンス面でも安心してご利用いただけます。<a href="https://claudecn.top">こちら</a>からご登録ください!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://runapi.co"><img src="assets/partners/logos/runapi.jpg" alt="RunAPI" width="150"></a></td>
|
||||
<td>本プロジェクトのスポンサーである RunAPI に感謝いたします!RunAPI は高効率で安定した AI モデル API ゲートウェイです。一つの API Key で、OpenAI、Claude、Gemini、DeepSeek、Grok など 150 種類以上の主要モデルにアクセス可能。料金は公式価格の最大 10%、安定性にも優れ、Claude Code や OpenClaw などのツールとシームレスに連携できます。CC Switch ユーザー限定特典:ご登録後にカスタマーサポートへご連絡いただくと、14 元の無料クレジットを進呈いたします。<a href="https://runapi.co">こちら</a>からご登録ください!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikey.fun/register?aff=CCSwitch"><img src="assets/partners/logos/apikey_banner.png" alt="APIKEY.FUN" width="150"></a></td>
|
||||
<td>APIKEY.FUN のご支援に感謝します!APIKEY.FUN は、企業および個人開発者向けに安定・高効率・低コストな AI モデル API 接続サービスを提供する、プロフェッショナルなエンタープライズ級 AI リレープラットフォームです。Claude、OpenAI、Gemini などの主要人気モデルに対応し、料金は公式価格の 7% から利用できます。本プロジェクトの<a href="https://apikey.fun/register?aff=CCSwitch">専用リンク</a>から登録すると、最大で<strong>チャージ永久 5% オフ</strong>の特別優待も受けられます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apinebula.com/02rw5X"><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/02rw5X">こちらのリンク</a>から登録し、チャージ時にプロモコード <strong>「ccswitch」を入力すると、さらに 10% OFF</strong> の割引特典が適用されます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch"><img src="assets/partners/logos/atlascloud_banner.png" alt="Atlas Cloud" width="150"></a></td>
|
||||
<td>Atlas Cloud は、1 つの API で動画・画像生成や LLM(大規模言語モデル)を利用できる全モーダル対応の AI 推論プラットフォームです。複数のベンダーを個別に管理する手間を省き、一度の接続で 300 以上の厳選されたマルチモーダルモデルにアクセスできます。より低コストで API を利用できる、開発者向けの新しい<a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">「コーディングプラン」</a>プロモーションをぜひチェックしてください!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.svg" alt="CCSub" width="150"></a></td>
|
||||
<td>CCSub のご支援に感謝します!CCSub は安定した低価格の AI API リレープラットフォームで、Claude Code 公式サブスクリプションの強力な代替です。1 つの API キーで Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek の全モデルを公式直接利用の約 1/3 のコストでご利用いただけます。VPN 不要で世界中から直接接続可能。Claude Code、Codex、Cursor、Cline、Continue、Windsurf など主要な AI コーディングツールすべてに対応しています。<a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">こちらのリンク</a>から登録すると $5 の無料クレジットがもらえます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>Unity2.ai のご支援に感謝します!Unity2.ai は個人開発者・チーム・企業向けの高性能 AI モデル API リレープラットフォームです。中国の大手企業に長年利用されており、1 日 300 億トークン以上を処理し、5000 RPM クラスの高並列に対応しています。残高課金、初回チャージボーナス、組み合わせサブスクリプション、企業向け請求書発行、専任サポートを提供。<a href="https://unity2.ai/register?source=ccs">こちらのリンク</a>から登録すると $2 のクレジット、公式グループへの参加でさらに $10、最大 $12 の無料クレジットがもらえます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL"><img src="assets/partners/logos/fenno-banner.png" alt="Fenno.ai" width="150"></a></td>
|
||||
<td>Fenno.ai のご支援に感謝します!Fenno.ai は安定かつ高効率な API 中継サービスプロバイダーで、現在は主に Codex の中継を提供しています。OpenAI および Anthropic プロトコルに対応し、Codex・Claude Code・OpenCode などの主要なコーディングツールから柔軟に利用できます。1 日あたり数千億トークンというエンタープライズ級の呼び出し需要を安定して支え、国内外の法人による B2B 決済・請求書発行に対応しています。Fenno.ai は CC Switch 利用者限定の特典を用意しています:<a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL">こちらのリンク</a>から 9.9 元(150 ドル相当)のお得な Coding Plan を購入でき、友達紹介で最大 20% の特典がもらえます。紹介すればするほどお得です!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
|
||||
<td>本プロジェクトをご支援いただいている <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> に感謝します!NekoCode は、Claude や Codex などの AI モデルに対応した、安定性・効率性・信頼性に優れた API 中継サービスを提供しています。料金体系は明瞭で、柔軟な従量課金にも対応しています。CC Switch ユーザー限定の 10%オフ特典:<a href="https://nekocode.ai?aff=CCSWITCH">こちらのリンク</a> から登録し、チャージ時にクーポンコード <code>cc-switch</code> を入力すると、チャージが 10%オフになります!</td>
|
||||
<td width="180"><a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory"><img src="assets/partners/logos/TeamoRouter-banner.png" alt="TeamoRouter" width="150"></a></td>
|
||||
<td>このプロジェクトをご支援いただいている TeamoRouter に感謝します!TeamoRouter は、開発者、AI チーム、企業向けに構築されたエンタープライズグレードの Agentic LLM ゲートウェイです。サブスクリプション不要で、単一の統合 API を通じて Claude Code、Codex、Gemini CLI、OpenAI Codex、その他の人気 AI エージェントにアクセスでき、API 料金は最大 90% オフで利用できます。
|
||||
一般的な API リレーサービスとは異なり、TeamoRouter は OpenAI、Anthropic、Vertex、Azure、AWS Bedrock など、数百の公式モデルプロバイダーと信頼できるインフラパートナーを集約しています。各プロバイダーは、Agent プロトコルとの 100% 互換性、キャッシュ性能、リクエストの追跡可能性について検証されており、リバースエンジニアリングされたものや品質が薄められたエンドポイントではなく、安定した品質を提供します。プラットフォームは公式に近い TTFT、99.6% の SLA、最大 5,000 QPM のエンタープライズ規模のスループット、そして業界トップクラスのキャッシュヒット率を提供し、長時間稼働するエージェントワークフローのトークンコストを大幅に削減します。
|
||||
TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーティング、利用状況分析、動的なプロバイダー最適化、専任サポートなどのエンタープライズ機能も提供しています。さらにシンプルな体験を求める場合は、Teamo Desktop を使うことで、Claude Code、Codex、Gemini CLI、その他の人気 AI エージェントをワンクリックで利用できます。API キー管理や手動のゲートウェイ設定は不要です。新規ユーザーとして<a href="https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory">こちらのリンク</a>から登録すると、初回チャージが 10% オフになります。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -185,8 +120,73 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
|
||||
</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>
|
||||
<td width="180"><a href="https://claudecn.ai/register?aff=HEL9"><img src="assets/partners/logos/claudecn.jpg" alt="ClaudeCN" width="150"></a></td>
|
||||
<td>本プロジェクトのスポンサーである ClaudeCN に感謝いたします!ClaudeCN は、実体のある企業によって運営されるエンタープライズ向け AI ゲートウェイプラットフォームです。Claude、GPT、DeepSeek など主要モデルへの高可用な商用 API アクセスを提供し、企業の調達プロセスにも対応 — 法人振込や正式契約に対応し、コンプライアンス面でも安心してご利用いただけます。<a href="https://claudecn.ai/register?aff=HEL9">こちら</a>からご登録ください!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">このリンク</a>からご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/YflgU2Ve"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。<a href="https://cloud.siliconflow.cn/i/YflgU2Ve">このリンク</a>から登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥16 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
|
||||
<td>本プロジェクトをご支援いただいている <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> に感謝します!NekoCode は、Claude や Codex などの AI モデルに対応した、安定性・効率性・信頼性に優れた API 中継サービスを提供しています。料金体系は明瞭で、柔軟な従量課金にも対応しています。CC Switch ユーザー限定の 10%オフ特典:<a href="https://nekocode.ai?aff=CCSWITCH">こちらのリンク</a> から登録し、チャージ時にクーポンコード <code>cc-switch</code> を入力すると、チャージが 10%オフになります!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch"><img src="assets/partners/logos/atlascloud_banner.png" alt="Atlas Cloud" width="150"></a></td>
|
||||
<td>Atlas Cloud は、1 つの API で動画・画像生成や LLM(大規模言語モデル)を利用できる全モーダル対応の AI 推論プラットフォームです。複数のベンダーを個別に管理する手間を省き、一度の接続で 300 以上の厳選されたマルチモーダルモデルにアクセスできます。より低コストで API を利用できる、開発者向けの新しい<a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">「コーディングプラン」</a>プロモーションをぜひチェックしてください!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Compshare" width="150"></a></td>
|
||||
<td>Compshare のご支援に感謝します!Compshare は UCloud 傘下の AI クラウドプラットフォームで、国内外の安定した包括的なモデル API を 1 つのキーだけで利用可能。月額・都度課金のコストパフォーマンスに優れた国内モデル Coding Plan パッケージを提供し、公式リレーによる安定した海外モデルも利用できます。Claude Code、Codex および API アクセスに対応。エンタープライズ級の高同時接続、24 時間年中無休のテクニカルサポート、セルフサービス請求書発行に対応。<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">こちらのリンク</a>から登録すると、無料で 5 元分のプラットフォーム体験クレジットがもらえます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.svg" alt="CCSub" width="150"></a></td>
|
||||
<td>CCSub のご支援に感謝します!CCSub は安定した低価格の AI API リレープラットフォームで、Claude Code 公式サブスクリプションの強力な代替です。1 つの API キーで Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek の全モデルを公式直接利用の約 1/3 のコストでご利用いただけます。VPN 不要で世界中から直接接続可能。Claude Code、Codex、Cursor、Cline、Continue、Windsurf など主要な AI コーディングツールすべてに対応しています。<a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">こちらのリンク</a>から登録すると $5 の無料クレジットがもらえます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||
<td>SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:<a href="https://www.sssaicode.com/register?ref=DCP0SM">こちらのリンク</a>から登録すると、毎回のチャージで $10 の追加ボーナスを受けられます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>Micu API のご支援に感謝します!Micu API は、最高のコストパフォーマンスと高い安定性を追求するグローバル大規模言語モデル中継サービスプロバイダーです。法人企業がバックアップしており、サービス停止のリスクを排除、迅速な正規請求書発行に対応!「試行コストゼロ」をモットーに、最低 1 元からチャージ可能で手数料無料、いつでも返金可能!CC Switch ユーザー向けの限定特典:<a href="https://www.micuapi.ai/register?aff=aOYQ">こちらのリンク</a>から登録し、チャージ時にプロモコード「ccswitch」を入力すると <strong>10% 割引</strong> が適用されます!</td>
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||
<td>ETok.ai のご支援に感謝します!ETok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://etok.ai">こちら</a>から登録してください!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>Cubence のご支援に感謝します!Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引:<a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">このリンク</a>で登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI など 300 以上のモデルにアクセス可能。全モデルが公式価格の 55% で利用でき、自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。CC Switch ユーザー向けの限定特典:<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">こちらのリンク</a>から登録後、カスタマーサポートまでご連絡いただくと <strong>$2 の無料クレジット</strong> を受け取れます。さらに初回チャージ時にプロモコード `CCSWITCH` を入力すると <strong>30% のボーナスクレジット</strong> が追加されます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
|
||||
<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href="https://www.dmxapi.cn/register?aff=bUHu">登録はこちら</a></td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
@@ -195,13 +195,13 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
|
||||
|
||||
## CC Switch を選ぶ理由
|
||||
|
||||
最新の AI コーディングは Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes などのツールに依存していますが、各ツールの設定形式はバラバラです。API プロバイダを切り替えるたびに JSON、TOML、`.env` ファイルを手動で編集する必要があり、複数ツール間で MCP や Skills を統一的に管理する手段もありません。
|
||||
最新の AI コーディングは Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes などのツールに依存していますが、各ツールの設定形式はバラバラです。API プロバイダを切り替えるたびに JSON、TOML、`.env` ファイルを手動で編集する必要があり、複数ツール間で MCP や Skills を統一的に管理する手段もありません。
|
||||
|
||||
**CC Switch** は、対応する AI ツールを 1 つのデスクトップアプリで一元管理できます。設定ファイルを手作業で編集する代わりに、ワンクリックでプロバイダをインポートし、瞬時に切り替えられるビジュアルインターフェースを提供します。50 以上の組み込みプリセット、統一 MCP・Skills 管理、システムトレイからの即時切り替え機能を搭載。すべてはアトミック書き込みによる信頼性の高い SQLite データベースに支えられており、設定の破損を防ぎます。
|
||||
|
||||
- **1 つのアプリで 7 つのツール** -- Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes を単一インターフェースで管理
|
||||
- **1 つのアプリで 8 つのツール** -- Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes を単一インターフェースで管理
|
||||
- **手動編集は不要** -- AWS Bedrock、NVIDIA NIM、コミュニティリレーなど 50 以上のプロバイダプリセットを内蔵。選んで切り替えるだけ
|
||||
- **統一 MCP・Skills 管理** -- 1 つのパネルで Claude、Codex、Gemini、OpenCode、Hermes の MCP サーバーと Skills を双方向同期で管理
|
||||
- **統一 MCP・Skills 管理** -- 1 つのパネルで Claude、Codex、Gemini、Grok Build、OpenCode、Hermes の MCP サーバーと Skills を双方向同期で管理
|
||||
- **システムトレイでクイック切り替え** -- トレイメニューから即座にプロバイダを切り替え。アプリを開く必要なし
|
||||
- **クラウド同期** -- Dropbox、OneDrive、iCloud、または WebDAV サーバー経由でデバイス間のプロバイダデータを同期
|
||||
- **クロスプラットフォーム** -- Tauri 2 で構築された Windows、macOS、Linux 対応のネイティブデスクトップアプリ
|
||||
@@ -219,18 +219,18 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
|
||||
|
||||
### プロバイダ管理
|
||||
|
||||
- **7 つの対応ツール、50 以上のプリセット** -- Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes。キーをコピーしてワンクリックでインポート
|
||||
- **8 つの対応ツール、50 以上のプリセット** -- Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes。キーをコピーしてワンクリックでインポート
|
||||
- **ユニバーサルプロバイダ** -- 1 つの設定を Claude Code、Codex、Gemini CLI に同期
|
||||
- ワンクリック切り替え、システムトレイクイックアクセス、ドラッグ&ドロップ並び替え、インポート/エクスポート
|
||||
|
||||
### プロキシ & フェイルオーバー
|
||||
|
||||
- **ローカルプロキシのホットスイッチ** -- フォーマット変換、自動フェイルオーバー、サーキットブレーカー、プロバイダヘルスモニタリング、リクエストレクティファイア
|
||||
- **アプリレベルのテイクオーバー** -- Claude、Codex、Gemini を個別にプロキシ経由でルーティング、プロバイダ単位で設定可能
|
||||
- **アプリレベルのテイクオーバー** -- Claude、Codex、Gemini、Grok Build を個別にプロキシ経由でルーティング、プロバイダ単位で設定可能
|
||||
|
||||
### MCP、Prompts & Skills
|
||||
|
||||
- **統一 MCP パネル** -- Claude、Codex、Gemini、OpenCode、Hermes の MCP サーバーを管理、双方向同期、Deep Link インポート対応
|
||||
- **統一 MCP パネル** -- Claude、Codex、Gemini、Grok Build、OpenCode、Hermes の MCP サーバーを管理、双方向同期、Deep Link インポート対応
|
||||
- **Prompts** -- Markdown エディタ、クロスアプリ同期(CLAUDE.md / AGENTS.md / GEMINI.md)、バックフィル保護
|
||||
- **Skills** -- GitHub リポジトリまたは ZIP ファイルからワンクリックインストール、カスタムリポジトリ管理、シンボリックリンクとファイルコピーに対応
|
||||
|
||||
@@ -254,7 +254,7 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
|
||||
<details>
|
||||
<summary><strong>CC Switch はどの AI ツールに対応していますか?</strong></summary>
|
||||
|
||||
CC Switch は **Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw**、**Hermes** の 7 つのツールに対応しています。各ツールに専用のプロバイダプリセットと設定管理が用意されています。
|
||||
CC Switch は **Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**Grok Build**、**OpenCode**、**OpenClaw**、**Hermes** の 8 つのツールに対応しています。各ツールに専用のプロバイダプリセットと設定管理が用意されています。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+104
-104
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes Agent 的全方位管理工具
|
||||
### Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 和 Hermes Agent 的全方位管理工具
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -25,11 +25,11 @@
|
||||
<details open>
|
||||
<summary>点击折叠</summary>
|
||||
|
||||
[](https://platform.kimi.com?aff=cc-switch)
|
||||
[](https://platform.kimi.com?aff=cc-switch)
|
||||
|
||||
Kimi K2.7 Code 是 Moonshot AI 开发的编程专用开源智能体模型。它在编程与智能体执行能力上全面增强,在真实长程编程任务中实现显著提升,带来复杂软件工程工作流中更高的端到端任务成功率。同时,K2.7 Code 优化了推理效率,相较 K2.6 平均减少约 30% 的推理 token 消耗。**[点击此处开启 Kimi 使用体验](https://platform.kimi.com?aff=cc-switch)**
|
||||
Kimi K3 是 Moonshot AI 迄今能力最强的模型,也是全球首个开源 3T 级模型。K3 拥有 2.8T 参数、原生视觉能力与 100 万 Token 上下文,在长程编码、知识工作和推理任务中展现前沿性能。使用 CC Switch,可以在各类 Agent 工具中便捷配置和切换 Kimi。**[点击此处开始使用 Kimi](https://platform.kimi.com?aff=cc-switch)**
|
||||
|
||||
主要做编程工作?可以试试 **[Kimi For Coding 编程套餐](https://www.kimi.com/code/?aff=cc-switch)**,专为编程打造的订阅服务!
|
||||
主要进行编程工作?可以试试 **[Kimi Code 订阅](https://www.kimi.com/code/?aff=cc-switch)**。
|
||||
|
||||
---
|
||||
|
||||
@@ -40,8 +40,13 @@ Kimi K2.7 Code 是 Moonshot AI 开发的编程专用开源智能体模型。它
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
|
||||
<td width="180"><a href="https://zetaapi.ai/go/u117"><img src="assets/partners/logos/zetaapi-banner.png" alt="ZetaAPI" width="150"></a></td>
|
||||
<td>感谢 ZetaAPI 赞助本项目!ZetaAPI 主打模型不掺水、保真不降智、价格低至官方价 35 折,平台不混量、不暗中替换低质量模型、不做虚假路由,支持 Claude Code、Codex、Gemini、ChatGPT 等主流模型接入,帮助用户在保证模型质量的同时大幅降低 API 使用成本。同时,ZetaAPI 提供企业级 SLA 稳定性保障、标准接口兼容、一个 Key 接入多模型、快速集成、按量计费等能力,适用于 AI 产品、代码生成、企业内部工具、客服系统、内容生产和自动化流程等场景。若经验证发现模型质量与标称不符,ZetaAPI 承诺假一赔十,让用户用得更稳定、更透明、更放心。通过<a href="https://zetaapi.ai/go/u117">此链接</a>注册,并在首次充值时使用优惠码 CC-SWITCH,即可享受 CC Switch 用户专属的首次充值九折优惠!</td>
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -50,11 +55,6 @@ Kimi K2.7 Code 是 Moonshot AI 开发的编程专用开源智能体模型。它
|
||||
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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>感谢胜算云赞助了本项目!胜算云是专为AI Native Teams服务的超级工厂,工业级AI任务并行执行平台,模型商城集采直供聚合接入了Claude、Chatgpt、Gemini等海内外LLM及图片视频多媒体模型算力,绝无逆向掺水、全站模型SLA可用性高达99.7%、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">监测接口</a>日常全绿。更有企业级专属定制网关,实现团队精细化成本与权限管控,智能路由+安全防护+BYOK企业自带密钥托管。平台按量及tokens plan(即将上线)计费,可开票,使用<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">此链接</a>注册新用户可获10元模力及首充10%赠送。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
|
||||
<td>感谢 PatewayAI 赞助了本项目!PatewayAI 是一家面向重度 AI 开发者、专注官方直连高品质模型 API 中转服务商。提供 Claude 全系列与 Codex 系列模型,100% 官方源直供,不掺假不注水,欢迎检验。计费透明,Token 级账单可逐笔核验。
|
||||
@@ -63,66 +63,38 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://teamorouter.com/zh?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory"><img src="assets/partners/logos/TeamoRouter-banner.png" alt="TeamoRouter" width="150"></a></td>
|
||||
<td>感谢 TeamoRouter 赞助本项目!TeamoRouter 是一款面向开发者、AI 团队和企业的企业级 Agentic LLM 网关。无需任何订阅,你就可以通过一个统一 API 访问 Claude Code、Codex、Gemini CLI、OpenAI Codex 以及其他热门 AI Agent,同时享受最高可达 90% 折扣的 API 价格。
|
||||
不同于常见的 API 中转服务,TeamoRouter 聚合了数百家官方模型提供商和可信基础设施合作伙伴,包括 OpenAI、Anthropic、Vertex、Azure 和 AWS Bedrock。每个提供商都经过验证,确保 100% 兼容 Agent 协议,并具备可靠的缓存性能和请求可追踪性,从而提供稳定质量,而不是反向工程或缩水后的接口。平台提供接近官方水平的 TTFT、99.6% SLA、最高 5,000 QPM 的企业级吞吐量,以及行业领先的缓存命中率,可大幅降低长时间运行的 Agent 工作流中的 token 成本。
|
||||
TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK、智能路由、用量分析、动态提供商优化和专属支持。为了获得更简单的使用体验,Teamo Desktop 支持你一键使用 Claude Code、Codex、Gemini CLI 和其他热门 AI Agent,无需管理 API Key,也无需手动配置网关。新用户通过<a href="https://teamorouter.com/zh?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory">此链接</a>注册,首次充值可享受 10% 折扣。</td>
|
||||
<td width="180"><a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL"><img src="assets/partners/logos/fenno-banner.png" alt="Fenno.ai" width="150"></a></td>
|
||||
<td>感谢 Fenno.ai 赞助了本项目!Fenno.ai 是一家稳定、高效的 API 中转服务商,目前主要提供 Codex 中转服务,兼容 OpenAI 及 Anthropic 协议,可灵活接入 Codex、Claude Code、OpenCode 等主流编程工具,可稳定支撑千亿 Token/日的企业级调用需求,支持国内及海外主体公对公结算、开票。Fenno.ai 为 CC Switch 的用户提供了专属福利:通过<a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL">此链接</a>即可订阅 9.9 元/150 刀额度的超值 Coding Plan,邀请好友最高可享 20% 奖励,多邀多得!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://zetaapi.ai/go/ccs"><img src="assets/partners/logos/zetaapi-banner.png" alt="ZetaAPI" width="150"></a></td>
|
||||
<td>感谢 ZetaAPI 赞助本项目!ZetaAPI 主打模型不掺水、保真不降智、价格低至官方价 35 折,平台不混量、不暗中替换低质量模型、不做虚假路由,支持 Claude Code、Codex、Gemini、ChatGPT 等主流模型接入,帮助用户在保证模型质量的同时大幅降低 API 使用成本。同时,ZetaAPI 提供企业级 SLA 稳定性保障、标准接口兼容、一个 Key 接入多模型、快速集成、按量计费等能力,适用于 AI 产品、代码生成、企业内部工具、客服系统、内容生产和自动化流程等场景。若经验证发现模型质量与标称不符,ZetaAPI 承诺假一赔十,让用户用得更稳定、更透明、更放心。通过<a href="https://zetaapi.ai/go/ccs">此链接</a>注册,并在首次充值时使用优惠码 CC-SWITCH,即可享受 CC Switch 用户专属的首次充值九折优惠!</td>
|
||||
<td width="180"><a href="https://runapi.co/register?aff=iOKB"><img src="assets/partners/logos/runapi.jpg" alt="RunAPI" width="150"></a></td>
|
||||
<td>感谢 RunAPI 赞助本项目!RunAPI 是高效稳定的 AI 模型 API 中转平台,一个 API Key 即可访问 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型,低至 1 折,极其稳定,可以无缝兼容 Claude Code、OpenClaw 等工具。RunAPI为CC switch的用户提供了特别福利,使用<a href="https://runapi.co/register?aff=iOKB">此链接</a>注册并首次充值即可享受10%的优惠折扣!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
|
||||
<td>感谢火山方舟 Agent Plan 模型赞助了本项目!方舟 Agent Plan 模型订阅套餐集成了包含 Doubao-Seed、Doubao-Seedance、Doubao-Seedream 等在内的字节跳动自研 SOTA 级模型,覆盖文本、代码、图像、视频等多模态任务。最新支持 MiniMax-M3、DeepSeek-V4 系列、GLM-5.1、Doubao-Seed-2.0 系列、Kimi-K2.6 等模型,工具不限。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。一次订阅,可以为不同任务切换合适的 AI 引擎。方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">此链接</a>订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">>>For developers outside Mainland China, please click here</a></td>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>感谢 Unity2.ai 赞助了本项目!Unity2.ai 是面向个人开发者、团队和企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。支持余额计费、首充赠额、组合订阅、企业开票和专属对接。通过<a href="https://unity2.ai/register?source=ccs">此链接</a>注册可领取 $2 余额,加入官方群再送 $10 余额,最高可领 $12 免费额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/YflgU2Ve"><img src="assets/partners/logos/silicon_zh.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过<a href="https://cloud.siliconflow.cn/i/YflgU2Ve">此链接</a>注册并完成实名认证,即可获得 ¥16 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。</td>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>感谢胜算云赞助了本项目!胜算云是专为AI Native Teams服务的超级工厂,工业级AI任务并行执行平台,模型商城集采直供聚合接入了Claude、Chatgpt、Gemini等海内外LLM及图片视频多媒体模型算力,绝无逆向掺水、全站模型SLA可用性高达99.7%、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">监测接口</a>日常全绿。更有企业级专属定制网关,实现团队精细化成本与权限管控,智能路由+安全防护+BYOK企业自带密钥托管。平台按量及tokens plan(即将上线)计费,可开票,使用<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">此链接</a>注册新用户可获10元模力及首充10%赠送。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>感谢 Cubence 赞助本项目!Cubence 是一家可靠高效的 API 中继服务提供商,提供对 Claude Code、Codex、Gemini 等模型的中继服务,并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠:使用 <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">此链接</a> 注册,并在充值时输入 "CCSWITCH" 优惠码,每次充值均可享受九折优惠!</td>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-zh.jpeg" alt="DMXAPI" width="150"></a></td>
|
||||
<td>感谢 DMXAPI(大模型API)赞助了本项目! DMXAPI,一个Key用全球大模型。
|
||||
为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制 ·1元起充 · 7x24 在线技术辅导,GPT/Claude/Gemini全部6.8折,国内模型5~8折,Claude Code 专属模型3.4折进行中!<a href="https://www.dmxapi.cn/register?aff=bUHu">点击这里注册</a></td>
|
||||
<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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="优云智算" width="150"></a></td>
|
||||
<td>感谢优云智算赞助了本项目!优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按次的高性价比 国模Coding Plan套餐,同时提供官转稳定海外模型。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">此链接</a>注册的用户,可得免费5元平台体验金!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI 等。全部模型低至官方定价的 55%,支持自动故障转移、智能路由和无限并发。Crazyrouter 为 CC Switch 用户提供了专属优惠:通过<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">此链接</a>注册后联系客服即可领取 <strong>$2 免费额度</strong>,首次充值时输入优惠码 `CCSWITCH` 还可获得额外 <strong>30% 奖励额度</strong>!</td>
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||
<td>感谢 SSSAiCode 赞助了本项目!SSSAiCode 是一家稳定可靠的API中转站,致力于提供稳定、可靠、平价的Claude、CodeX模型服务,支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用<a href="https://www.sssaicode.com/register?ref=DCP0SM">此链接</a>注册每次充值均可享受10$的额外奖励!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>感谢 米醋API 赞助了本项目!米醋API 是一家致力于提供极致性价比与高稳定性的全球大模型中转服务商。米醋API 背后有实体企业做核心保障,杜绝跑路风险,支持极速正规开票!我们主打“试错零成本”:1 元起充低门槛,0 手续费随时退款!米醋API 为本软件的用户提供了特别优惠,使用<a href="https://www.micuapi.ai/register?aff=aOYQ">此链接</a>注册并在充值时填写"ccswitch"优惠码可享九折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||
<td>感谢 ETok.ai 赞助了本项目!ETok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://etok.ai">这里</a>注册!</td>
|
||||
<td width="180"><a href="https://apikey.fun/register?aff=CCSwitch"><img src="assets/partners/logos/apikey_banner.png" alt="APIKEY.FUN" width="150"></a></td>
|
||||
<td>感谢 APIKEY.FUN 赞助本项目!APIKEY.FUN 是一家专业的企业级 AI 中转站,致力于为企业和个人开发者提供稳定、高效、低成本的 AI 模型 API 接入服务。平台支持 Claude、OpenAI、Gemini 等主流热门模型,价格低至官方原价的 7%。通过本项目<a href="https://apikey.fun/register?aff=CCSwitch">专属链接</a>注册,还可享受最高 <strong>充值永久 95 折</strong> 专属优惠。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -136,48 +108,10 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://claudecn.top"><img src="assets/partners/logos/claudecn.jpg" alt="ClaudeCN" width="150"></a></td>
|
||||
<td>感谢 ClaudeCN 赞助本项目!ClaudeCN 由是一家实体企业运营的企业级AI中转平台。平台可提供高可用性的商用API服务,提供Claude、GPT、Deepseek等热门模型,支持企业采购流程,可对公打款、签约,服务合规有保障。点击<a href="https://claudecn.top">此链接</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://runapi.co"><img src="assets/partners/logos/runapi.jpg" alt="RunAPI" width="150"></a></td>
|
||||
<td>感谢 RunAPI 赞助本项目!RunAPI 是高效稳定的 AI 模型 API 中转平台,一个 API Key 即可访问 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型,低至 1 折,极其稳定,可以无缝兼容 Claude Code、OpenClaw 等工具。RunAPI为CC switch的用户提供了特别福利,注册后联系客服可以领取14元额度,点击<a href="https://runapi.co">此链接</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikey.fun/register?aff=CCSwitch"><img src="assets/partners/logos/apikey_banner.png" alt="APIKEY.FUN" width="150"></a></td>
|
||||
<td>感谢 APIKEY.FUN 赞助本项目!APIKEY.FUN 是一家专业的企业级 AI 中转站,致力于为企业和个人开发者提供稳定、高效、低成本的 AI 模型 API 接入服务。平台支持 Claude、OpenAI、Gemini 等主流热门模型,价格低至官方原价的 7%。通过本项目<a href="https://apikey.fun/register?aff=CCSwitch">专属链接</a>注册,还可享受最高 <strong>充值永久 95 折</strong> 专属优惠。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apinebula.com/02rw5X"><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/02rw5X">此链接</a>注册并在充值时填写 <strong>"ccswitch"</strong> 优惠码可享<strong>九折优惠</strong>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch"><img src="assets/partners/logos/atlascloud_banner.png" alt="Atlas Cloud" width="150"></a></td>
|
||||
<td>Atlas Cloud 是一个全模态 AI 推理平台,通过单一 API 为开发者提供视频生成、图像生成及 LLM 接入。免去繁琐的多供应商对接,一次连接即可调用 300+ 款全模态精选模型。立即查看 Atlas Cloud 全新<a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">“编程计划”</a>优惠,获取更具性价比的 API 接入!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.svg" alt="CCSub" width="150"></a></td>
|
||||
<td>感谢 CCSub 赞助本项目!CCSub 是稳定、实惠的 AI API 中转平台,是 Claude Code 官方订阅的超强平替。一个 API Key 即可调用 Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek 全系列模型,价格约为官方直连的 1/3,全球直连无需梯子。兼容 Claude Code、Codex、Cursor、Cline、Continue、Windsurf 等所有主流 AI 编程工具。通过<a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">此链接</a>注册即送 $5 体验额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>感谢 Unity2.ai 赞助了本项目!Unity2.ai 是面向个人开发者、团队和企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。支持余额计费、首充赠额、组合订阅、企业开票和专属对接。通过<a href="https://unity2.ai/register?source=ccs">此链接</a>注册可领取 $2 余额,加入官方群再送 $10 余额,最高可领 $12 免费额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL"><img src="assets/partners/logos/fenno-banner.png" alt="Fenno.ai" width="150"></a></td>
|
||||
<td>感谢 Fenno.ai 赞助了本项目!Fenno.ai 是一家稳定、高效的 API 中转服务商,目前主要提供 Codex 中转服务,兼容 OpenAI 及 Anthropic 协议,可灵活接入 Codex、Claude Code、OpenCode 等主流编程工具,可稳定支撑千亿 Token/日的企业级调用需求,支持国内及海外主体公对公结算、开票。Fenno.ai 为 CC Switch 的用户提供了专属福利:通过<a href="https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL">此链接</a>即可订阅 9.9 元/150 刀额度的超值 Coding Plan,邀请好友最高可享 20% 奖励,多邀多得!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
|
||||
<td>感谢 <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> 赞助本项目!NekoCode 为开发者提供稳定、高效、可靠的 Claude、Codex 等 AI 模型 API 中转服务,价格透明,接入便捷,支持灵活的按量计费。CC Switch 用户专享 9 折福利:通过 <a href="https://nekocode.ai?aff=CCSWITCH">此链接</a> 注册,并在充值时输入优惠码 <code>cc-switch</code>,即可享受充值 9 折优惠!</td>
|
||||
<td width="180"><a href="https://teamorouter.com/zh?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory"><img src="assets/partners/logos/TeamoRouter-banner.png" alt="TeamoRouter" width="150"></a></td>
|
||||
<td>感谢 TeamoRouter 赞助本项目!TeamoRouter 是一款面向开发者、AI 团队和企业的企业级 Agentic LLM 网关。无需任何订阅,你就可以通过一个统一 API 访问 Claude Code、Codex、Gemini CLI、OpenAI Codex 以及其他热门 AI Agent,同时享受最高可达 90% 折扣的 API 价格。
|
||||
不同于常见的 API 中转服务,TeamoRouter 聚合了数百家官方模型提供商和可信基础设施合作伙伴,包括 OpenAI、Anthropic、Vertex、Azure 和 AWS Bedrock。每个提供商都经过验证,确保 100% 兼容 Agent 协议,并具备可靠的缓存性能和请求可追踪性,从而提供稳定质量,而不是反向工程或缩水后的接口。平台提供接近官方水平的 TTFT、99.6% SLA、最高 5,000 QPM 的企业级吞吐量,以及行业领先的缓存命中率,可大幅降低长时间运行的 Agent 工作流中的 token 成本。
|
||||
TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK、智能路由、用量分析、动态提供商优化和专属支持。为了获得更简单的使用体验,Teamo Desktop 支持你一键使用 Claude Code、Codex、Gemini CLI 和其他热门 AI Agent,无需管理 API Key,也无需手动配置网关。新用户通过<a href="https://teamorouter.com/zh?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory">此链接</a>注册,首次充值可享受 10% 折扣。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -186,8 +120,74 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
|
||||
</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>
|
||||
<td width="180"><a href="https://claudecn.ai/register?aff=HEL9"><img src="assets/partners/logos/claudecn.jpg" alt="ClaudeCN" width="150"></a></td>
|
||||
<td>感谢 ClaudeCN 赞助本项目!ClaudeCN 由是一家实体企业运营的企业级AI中转平台。平台可提供高可用性的商用API服务,提供Claude、GPT、Deepseek等热门模型,支持企业采购流程,可对公打款、签约,服务合规有保障。点击<a href="https://claudecn.ai/register?aff=HEL9">此链接</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
|
||||
<td>感谢火山方舟 Agent Plan 模型赞助了本项目!方舟 Agent Plan 模型订阅套餐集成了包含 Doubao-Seed、Doubao-Seedance、Doubao-Seedream 等在内的字节跳动自研 SOTA 级模型,覆盖文本、代码、图像、视频等多模态任务。最新支持 MiniMax-M3、DeepSeek-V4 系列、GLM-5.1、Doubao-Seed-2.0 系列、Kimi-K2.6 等模型,工具不限。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。一次订阅,可以为不同任务切换合适的 AI 引擎。方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">此链接</a>订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">>>For developers outside Mainland China, please click here</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/YflgU2Ve"><img src="assets/partners/logos/silicon_zh.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过<a href="https://cloud.siliconflow.cn/i/YflgU2Ve">此链接</a>注册并完成实名认证,即可获得 ¥16 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
|
||||
<td>感谢 <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> 赞助本项目!NekoCode 为开发者提供稳定、高效、可靠的 Claude、Codex 等 AI 模型 API 中转服务,价格透明,接入便捷,支持灵活的按量计费。CC Switch 用户专享 9 折福利:通过 <a href="https://nekocode.ai?aff=CCSWITCH">此链接</a> 注册,并在充值时输入优惠码 <code>cc-switch</code>,即可享受充值 9 折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch"><img src="assets/partners/logos/atlascloud_banner.png" alt="Atlas Cloud" width="150"></a></td>
|
||||
<td>Atlas Cloud 是一个全模态 AI 推理平台,通过单一 API 为开发者提供视频生成、图像生成及 LLM 接入。免去繁琐的多供应商对接,一次连接即可调用 300+ 款全模态精选模型。立即查看 Atlas Cloud 全新<a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">“编程计划”</a>优惠,获取更具性价比的 API 接入!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="优云智算" width="150"></a></td>
|
||||
<td>感谢优云智算赞助了本项目!优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按次的高性价比 国模Coding Plan套餐,同时提供官转稳定海外模型。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">此链接</a>注册的用户,可得免费5元平台体验金!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.svg" alt="CCSub" width="150"></a></td>
|
||||
<td>感谢 CCSub 赞助本项目!CCSub 是稳定、实惠的 AI API 中转平台,是 Claude Code 官方订阅的超强平替。一个 API Key 即可调用 Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek 全系列模型,价格约为官方直连的 1/3,全球直连无需梯子。兼容 Claude Code、Codex、Cursor、Cline、Continue、Windsurf 等所有主流 AI 编程工具。通过<a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">此链接</a>注册即送 $5 体验额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||
<td>感谢 SSSAiCode 赞助了本项目!SSSAiCode 是一家稳定可靠的API中转站,致力于提供稳定、可靠、平价的Claude、CodeX模型服务,支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用<a href="https://www.sssaicode.com/register?ref=DCP0SM">此链接</a>注册每次充值均可享受10$的额外奖励!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>感谢 米醋API 赞助了本项目!米醋API 是一家致力于提供极致性价比与高稳定性的全球大模型中转服务商。米醋API 背后有实体企业做核心保障,杜绝跑路风险,支持极速正规开票!我们主打“试错零成本”:1 元起充低门槛,0 手续费随时退款!米醋API 为本软件的用户提供了特别优惠,使用<a href="https://www.micuapi.ai/register?aff=aOYQ">此链接</a>注册并在充值时填写"ccswitch"优惠码可享九折优惠!</td>
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||
<td>感谢 ETok.ai 赞助了本项目!ETok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://etok.ai">这里</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>感谢 Cubence 赞助本项目!Cubence 是一家可靠高效的 API 中继服务提供商,提供对 Claude Code、Codex、Gemini 等模型的中继服务,并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠:使用 <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">此链接</a> 注册,并在充值时输入 "CCSWITCH" 优惠码,每次充值均可享受九折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI 等。全部模型低至官方定价的 55%,支持自动故障转移、智能路由和无限并发。Crazyrouter 为 CC Switch 用户提供了专属优惠:通过<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">此链接</a>注册后联系客服即可领取 <strong>$2 免费额度</strong>,首次充值时输入优惠码 `CCSWITCH` 还可获得额外 <strong>30% 奖励额度</strong>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-zh.jpeg" alt="DMXAPI" width="150"></a></td>
|
||||
<td>感谢 DMXAPI(大模型API)赞助了本项目! DMXAPI,一个Key用全球大模型。
|
||||
为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制 ·1元起充 · 7x24 在线技术辅导,GPT/Claude/Gemini全部6.8折,国内模型5~8折,Claude Code 专属模型3.4折进行中!<a href="https://www.dmxapi.cn/register?aff=bUHu">点击这里注册</a></td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
@@ -196,13 +196,13 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
|
||||
|
||||
## 为什么选择 CC Switch?
|
||||
|
||||
现代 AI 编程依赖于 Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes 等工具——但每个工具都有自己的配置格式。切换 API 供应商意味着手动编辑 JSON、TOML 或 `.env` 文件,而在多个工具之间缺乏一个统一管理 MCP, SKILLS 的方式。
|
||||
现代 AI 编程依赖于 Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 和 Hermes 等工具——但每个工具都有自己的配置格式。切换 API 供应商意味着手动编辑 JSON、TOML 或 `.env` 文件,而在多个工具之间缺乏一个统一管理 MCP, SKILLS 的方式。
|
||||
|
||||
**CC Switch** 为你提供一个桌面应用来管理所有支持的 AI 工具。无需手动编辑配置文件,你将获得一个可视化界面,一键将供应商导入应用,一键在不同的供应商之间进行切换,内置 50+ 供应商预设、统一的 MCP, SKILLS 管理以及系统托盘即时切换功能——所有操作都基于可靠的 SQLite 数据库和原子写入机制,保护你的配置不被损坏。
|
||||
|
||||
- **一个应用,七个工具** — 在单一界面中管理 Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes
|
||||
- **一个应用,八个工具** — 在单一界面中管理 Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 和 Hermes
|
||||
- **告别手动编辑** — 50+ 供应商预设,包括 AWS Bedrock、NVIDIA NIM 和社区中转服务;一键即可切换
|
||||
- **统一 MCP, SKILLS 管理** — 一个面板管理 Claude、Codex、Gemini、OpenCode 和 Hermes 的 MCP, SKILLS, 支持双向同步
|
||||
- **统一 MCP, SKILLS 管理** — 一个面板管理 Claude、Codex、Gemini、Grok Build、OpenCode 和 Hermes 的 MCP, SKILLS, 支持双向同步
|
||||
- **系统托盘快速切换** — 从托盘菜单即时切换供应商,无需打开完整应用
|
||||
- **云同步** — 通过 Dropbox、OneDrive、iCloud 或 WebDAV 服务器在不同设备之间同步供应商数据
|
||||
- **跨平台** — 基于 Tauri 2 构建的原生桌面应用,支持 Windows、macOS 和 Linux
|
||||
@@ -220,18 +220,18 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
|
||||
|
||||
### 供应商管理
|
||||
|
||||
- **7 个支持工具,50+ 预设** — Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes;复制 key 即可一键导入
|
||||
- **8 个支持工具,50+ 预设** — Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes;复制 key 即可一键导入
|
||||
- **通用供应商** — 一份配置同步到 Claude Code、Codex 和 Gemini CLI
|
||||
- 一键切换、系统托盘快速访问、拖拽排序、导入导出
|
||||
|
||||
### 代理与故障转移
|
||||
|
||||
- **本地代理热切换** — 格式转换、自动故障转移、熔断器、供应商健康监控和整流器
|
||||
- **应用级代理接管** — 独立为 Claude、Codex 或 Gemini 配置代理,具体到单个供应商
|
||||
- **应用级代理接管** — 独立为 Claude、Codex、Gemini 或 Grok Build 配置代理,具体到单个供应商
|
||||
|
||||
### MCP、Prompts 与 Skills
|
||||
|
||||
- **统一 MCP 面板** — 管理 Claude、Codex、Gemini、OpenCode 和 Hermes 的 MCP 服务器,双向同步,支持 Deep Link 导入
|
||||
- **统一 MCP 面板** — 管理 Claude、Codex、Gemini、Grok Build、OpenCode 和 Hermes 的 MCP 服务器,双向同步,支持 Deep Link 导入
|
||||
- **Prompts** — Markdown 编辑器,跨应用同步(CLAUDE.md / AGENTS.md / GEMINI.md),回填保护
|
||||
- **Skills** — 从 GitHub 仓库或 ZIP 文件一键安装,自定义仓库管理,支持软连接和文件复制
|
||||
|
||||
@@ -255,7 +255,7 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
|
||||
<details>
|
||||
<summary><strong>CC Switch 支持哪些 AI 工具?</strong></summary>
|
||||
|
||||
CC Switch 支持七个工具:**Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw** 和 **Hermes**。每个工具都有专属的供应商预设和配置管理。
|
||||
CC Switch 支持八个工具:**Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**Grok Build**、**OpenCode**、**OpenClaw** 和 **Hermes**。每个工具都有专属的供应商预设和配置管理。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Using GPT Models in Claude Code with CC Switch
|
||||
|
||||
> Applies to CC Switch 3.17.0 and later. (Both integration methods in this guide existed in earlier versions, but the gpt-5.6 preset and the client-identity fix landed in 3.17.0; on older versions, requesting new models like `gpt-5.6-luna` falsely returns 404.) This guide is compiled from the repository's documentation and code, and all sample data has been de-identified.
|
||||
|
||||
## Why local routing is needed
|
||||
|
||||
Claude Code targets the Anthropic Messages protocol — that is, `/v1/messages` — whereas the upstreams for Codex-family models, whether the OpenAI Responses API exposed by a third-party gateway or the Codex service behind a ChatGPT subscription, all speak the Responses protocol. The two protocols use completely different request bodies, streaming events, and response structures, so putting such an endpoint directly into Claude Code's config leaves the upstream receiving a `/v1/messages` request it doesn't recognize — which can only fail.
|
||||
|
||||
CC Switch's approach is to keep Claude Code always connected to the local route and still sending requests as Anthropic Messages; once the route detects that the active provider is Responses-format, it converts the request into Responses for the upstream, then converts the response back into the Messages shape it returns to Claude Code — tool calls, images, PDFs, and thinking configuration are all within the conversion scope.
|
||||
|
||||
This guide covers both integration methods:
|
||||
|
||||
- **Method 1 (API Key)**: you have a gateway endpoint and key compatible with the OpenAI Responses API, and you want to run the GPT-family models behind it inside Claude Code.
|
||||
- **Method 2 (ChatGPT subscription)**: you have a ChatGPT Plus/Pro subscription and use its quota directly by signing in through Codex OAuth — no API key needed at any point.
|
||||
|
||||
The chain has four main steps:
|
||||
|
||||
1. When Claude Code is taken over, `ANTHROPIC_BASE_URL` in `~/.claude/settings.json` is written as the local route address (default `http://127.0.0.1:15721`), the auth entry keeps only a placeholder, and real credentials never enter the live config.
|
||||
2. The provider's `API Format` is set to OpenAI Responses, telling the route that the real upstream speaks the Responses protocol.
|
||||
3. The route converts the `/v1/messages` request into a Responses request body for the upstream; Method 2 additionally carries the OAuth token and the official client identity to reach ChatGPT's Codex service.
|
||||
4. After the upstream responds, the route converts the Responses JSON/SSE back into the Messages shape Claude Code understands.
|
||||
|
||||

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

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

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

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

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

|
||||
|
||||
ログイン認証情報は `~/.cc-switch/codex_oauth_auth.json`(`~/.codex/` ではありません)に保存され、Codex CLI 自身のログインとは互いに影響しません。token は有効期限前に自動的に更新されます。
|
||||
|
||||
## Step 2: ローカルルーティングを有効にして Claude Code をルーティングする
|
||||
|
||||
設定の `ルーティング` ページに入り、`ローカルルーティング` を展開して、2 つのスイッチを設定します:
|
||||
|
||||
1. `ルーティング総スイッチ` をオンにしてローカルサービスを起動します(初回起動時は説明の確認ダイアログが表示されます)。デフォルトアドレスは `127.0.0.1:15721` です。
|
||||
2. `ルーティング有効` で `Claude Code` をオンにします。Claude Code だけをルーティングしたい場合は、その他のアプリはオフのままで構いません。
|
||||
|
||||
引き継ぎ後、CC Switch は Claude Code の live 設定をローカルルートへ向け、認証項目にはプレースホルダーだけが入ります。方式 1 のゲートウェイ Key も方式 2 の OAuth token も、いずれもローカルルートが転送時に注入します。
|
||||
|
||||
> **注意**: live 設定は Claude Code プロセスの起動時に読み込まれます。初回の引き継ぎを有効にした後(または引き継ぎをオフにして直結に戻した後)、Claude Code が実行中の場合は、ターミナルセッションを開き直してください。その後、ルーティングモードでのプロバイダー切り替えはホットスワップになり、再起動は不要です。
|
||||
|
||||
## Step 3: プロバイダーを切り替えて確認する
|
||||
|
||||
Claude Code のプロバイダー一覧に戻り、目的のプロバイダーの `有効化` をクリックします。ルーティングが実行されていない場合、CC Switch は「このプロバイダーは OpenAI Responses API フォーマットを使用しており、ルーティングサービスが必要です。先にルーティングを起動してください」と表示します——この表示は切り替えを妨げませんが、ルーティングが未起動のままではリクエストは必ず失敗します。Step 2 に戻ってオンにすれば解決します。
|
||||
|
||||
Claude Code に入ったら、段階的に確認できます:
|
||||
|
||||
- 新しいセッションを開き、`/model` でモデルメニューを確認します:各区分にはモデルマッピングの表示名が表示されます(方式 2 のデフォルトは `gpt-5.6`、`gpt-5.6-luna`)。画面の一部の箇所には Claude 系のモデル名が現れることがあります——それはルーティング引き継ぎが使う内部の役割エイリアスであり、正常な現象です。`/model` メニューと使用量ダッシュボードを基準にしてください。
|
||||
- 小さな質問を 1 つ送り、設定 → ルーティングページの「現在のプロバイダー」があなたのプロバイダーに変わり、「総リクエスト数」が増え始めるのを確認します。
|
||||
- 使用量ダッシュボードでは、これらのリクエストが上流の実際のモデルで表示されます:`gpt-5.6` にマッピングされた区分は Sol 区分として解析され `GPT-5.6 Sol` と表示され、`gpt-5.6-luna` は `GPT-5.6 Luna` と表示されます。プロバイダーで絞り込んで token 使用量を照合できます。
|
||||
- 方式 2 のプロバイダーカードには、さらにサブスクリプション枠が表示されます:5 時間ウィンドウと 7 日ウィンドウの利用率とリセットまでのカウントダウンです。データは ChatGPT アカウント自体から取得され、公式 Codex クライアントと同じ枠を共有します。
|
||||
|
||||
## 機能の範囲と既知の制限
|
||||
|
||||
- **プロンプトキャッシュが自動で有効**: ルートは各セッションに安定した `prompt_cache_key` を注入し、OpenAI 側の自動プレフィックスキャッシュと組み合わせます。長い対話でも毎回全額で再送されることはなく、設定は不要です。
|
||||
- **思考を reasoning effort に換算**: Claude Code の思考スイッチと思考レベルは GPT の `reasoning.effort`(low/medium/high)へ換算されます。GPT の推論内容は暗号化された形態でターンをまたいで完全に再生され、複数ターンの推論の一貫性は変換の影響を受けません。方式 2 は同時にステルスモード(`store:false`)でアクセスし、OpenAI サーバー側にはセッションを残しません。
|
||||
- **ツールとマルチモーダルを完全変換**: 複数ターンのツール呼び出し、画像、PDF 入力はすべて完全に変換されます。
|
||||
- **コンテキストは 200K ウィンドウで管理**: Claude Code はルーティングプロバイダーに対して、デフォルトの 200K ウィンドウで自動圧縮を行います。上流の実際のウィンドウがより大きい場合(たとえば ChatGPT Codex サービスの gpt-5.6 は 372K)でも、200K を超える部分は現状では使われません——圧縮が早めにトリガーされ、保守的ですが安全です。200K を突破したい場合、現在唯一のスイッチはモデルマッピングの `1M` チェックボックス(厳密に 1M を宣言)で、方式 1 に限り、かつ上流が実際に 1M 以上でそのモデルを提供している場合にのみ使用します。方式 2 の上流上限は 372K で 1M には届かないため、チェックするとかえって長い対話が上流の実際の上限でエラーになります。デフォルトのままにしてください。
|
||||
- **出力上限**: 方式 2 の出力上限は ChatGPT サーバー側が制御します(Claude Code のリクエスト内の `max_tokens` は下流に送られません)。方式 1 は Claude Code の `max_tokens` をそのまま透過するため、設定は不要です。
|
||||
- **Web 検索は利用不可**: Claude Code の WebSearch は Anthropic サーバー側での実行に依存しており、GPT 上流では引き受けられません。Web 検索を伴うタスクは Claude 系プロバイダーへ切り替えることをおすすめします。ローカルで実行される WebFetch は影響を受けません。
|
||||
- **使用量ダッシュボードの金額は参考値**: token カウントは正確ですが、ドル金額は公開 API 価格に基づく概算です——方式 2 のサブスクリプショントラフィックは GPT-5.6 の公開価格で換算され、方式 1 のサードパーティゲートウェイはそれぞれのレートで課金されます。どちらも実際の請求額と一致しない可能性があり、比較用途に留めてください。方式 2 の枠の消費は、プロバイダーカード上のウィンドウ利用率を基準にしてください。
|
||||
|
||||
## よくある質問
|
||||
|
||||
**上流が 401 または 403 を返す(方式 1)**
|
||||
|
||||
まず高級オプションの `認証フィールド` がデフォルトの `ANTHROPIC_AUTH_TOKEN(デフォルト)` になっているか確認してください——`ANTHROPIC_API_KEY` に変更すると `x-api-key` で送信され、大多数の OpenAI 互換ゲートウェイはこれを受け付けません。あわせて、Key 自体が有効で残高があることも確認してください。
|
||||
|
||||
**`gpt-5.6-luna` などの新しいモデルをリクエストすると 404 Model not found が出る(方式 2)**
|
||||
|
||||
CC Switch 3.17.0 以降にアップグレードしてください。古いバージョンではクライアントアイデンティティが公式 Codex クライアントに揃っておらず、ChatGPT サーバー側が新しいモデルを存在しないエンジンへ解決してしまいます。
|
||||
|
||||
**切り替えても反映されない、または `/model` メニューが古い名前のまま**
|
||||
|
||||
モデルメニューもルーティングアドレスも、Claude Code の起動時に読み込まれます:初回の引き継ぎを有効にした後は必ずターミナルセッションを開き直してください。プロバイダー間の切り替えはホットスワップですが、メニューの表示名は新しいセッションでなければ更新されません。
|
||||
|
||||
**Claude Code に「Codex OAuth 認証に失敗しました」と出る、またはカードに「セッションが期限切れです」と表示される(方式 2)**
|
||||
|
||||
ログイン認証情報が失効しています。プロバイダーフォーム、または `設定` → `OAuth 認証センター` に戻り、`ChatGPT でログイン` をもう一度実行してください。コマンドライン操作は一切不要です。
|
||||
|
||||
**対話の途中で自動的に圧縮されてしまう**
|
||||
|
||||
「機能の範囲」を参照してください:ルーティングプロバイダーは 200K ウィンドウで管理され、圧縮のしきい値もそれに合わせて早まります。想定どおりの動作です。
|
||||
|
||||
**公式の Claude の使い方に戻したい**
|
||||
|
||||
公式プロバイダーに切り替えるか、ルーティングページで `Claude Code` のルーティングスイッチをオフにしてください——CC Switch は引き継ぎ前の live 設定に戻し、公式ログインの認証情報は全工程を通じて影響を受けません。戻した後も同様にターミナルセッションを開き直す必要があります。
|
||||
|
||||
**FAST モードをオンにすべきか(方式 2)**
|
||||
|
||||
デフォルトのオフのままで構いません。遅延に特に敏感で、かつクォータのより速い消費を受け入れられる場合にのみオンにしてください。ChatGPT サーバー側がこのパラメータを拒否する場合は、スイッチをオフにすれば復旧します。
|
||||
|
||||
## コンプライアンスに関する注意
|
||||
|
||||
方式 2 は ChatGPT サブスクリプション枠を公式 Codex クライアント以外で使うものですが、これはグレーな手法ではありません:OpenAI Codex の責任者である Thibault Sottiaux(@thsottiaux)自身が、Claude Code(彼が「orange crab」と呼ぶもの)を GPT-5.6 Sol に向けて使うことを公開でデモし、推奨しています——用いているのはまさに「ローカルプロキシ + モデルエイリアス」であり、本記事の方式 2 と同じ類の手法です。Codex という製品ラインの責任者が、競合クライアントで自社モデルを使うことを自ら奨励しているのですから、サブスクリプションで Claude Code に GPT 系モデルを動かすことは、公式が歓迎し、試すことを推奨している使い方だと分かります。
|
||||
|
||||
とはいえ、実務上の 2 点は依然として留意する価値があります:1 つは、この部分のトラフィックが公式 Codex クライアントと同じサブスクリプション枠に合算されるため、ヘビーユースではより速く上限に達すること。もう 1 つは、CC Switch の認証センターが念のためコンプライアンス上の注意(「Claude Code で他のサブスクリプションをご利用いただけます。コンプライアンスリスクにご注意ください。」)を残していることです。あなたのアカウントに適用される規約に沿うかどうかは、各自でご確認ください。方式 1 でサードパーティゲートウェイを使う場合は、対象ゲートウェイの課金・コンプライアンス・データ保持に関する規約を別途お読みください。
|
||||
|
||||
## 参考リンク
|
||||
|
||||
- [CC Switch ユーザーマニュアル: プロバイダーを追加(Codex OAuth リバースプロキシと API フォーマットを含む)](../user-manual/ja/2-providers/2.1-add.md)
|
||||
- [CC Switch ユーザーマニュアル: プロキシサービス](../user-manual/ja/4-proxy/4.1-service.md)
|
||||
- [CC Switch ユーザーマニュアル: アプリケーションルーティング](../user-manual/ja/4-proxy/4.2-routing.md)
|
||||
- [CC Switch v3.17.0 リリースノート](../release-notes/v3.17.0-ja.md)
|
||||
- 逆方向のガイド: [Codex で Claude モデルを使う](./codex-claude-routing-guide-ja.md)
|
||||
@@ -0,0 +1,141 @@
|
||||
# 通过 CC Switch 在 Claude Code 中使用 GPT 模型
|
||||
|
||||
> 适用版本:CC Switch 3.17.0 及以上(更早版本已具备本文两种接入方式,但 gpt-5.6 预设与客户端身份修复自 3.17.0 落地,低版本请求 `gpt-5.6-luna` 这类新模型会误报 404)。本文根据仓库内文档与代码整理,示例数据均已去敏。
|
||||
|
||||
## 为什么需要本地路由
|
||||
|
||||
Claude Code 面向的是 Anthropic Messages 协议,也就是 `/v1/messages`;而 Codex 系模型的上游——无论是第三方网关暴露的 OpenAI Responses API,还是 ChatGPT 订阅背后的 Codex 服务——说的都是 Responses 协议。两种协议的请求体、流式事件和返回结构完全不同,把这类地址直接填进 Claude Code 的配置里,上游收到的是它不认识的 `/v1/messages` 请求,结果只能是失败。
|
||||
|
||||
CC Switch 的做法是让 Claude Code 始终连本机路由,仍以 Anthropic Messages 发送请求;路由识别当前供应商是 Responses 格式后,把请求转换成 Responses 发给上游,再把响应转换回 Messages 形态返回给 Claude Code——工具调用、图片、PDF、思考配置都在转换范围内。
|
||||
|
||||
对应两种接入方式,本文都会覆盖:
|
||||
|
||||
- **方式一(API Key)**:你手里有某个兼容 OpenAI Responses API 的网关端点和 Key,想在 Claude Code 里跑它背后的 GPT 系模型。
|
||||
- **方式二(ChatGPT 订阅)**:你有 ChatGPT Plus/Pro 订阅,通过 Codex OAuth 登录直接使用订阅额度,全程不需要 API Key。
|
||||
|
||||
这条链路主要分成四步:
|
||||
|
||||
1. 接管 Claude Code 后,`~/.claude/settings.json` 里的 `ANTHROPIC_BASE_URL` 会被写成本机路由地址(默认 `http://127.0.0.1:15721`),认证项只留占位符,真实凭据不进 live 配置。
|
||||
2. 供应商的 `API 格式` 设为 OpenAI Responses,告诉路由:真实上游说的是 Responses 协议。
|
||||
3. 路由把 `/v1/messages` 请求转换成 Responses 请求体发给上游;方式二还会带上 OAuth token 与官方客户端身份访问 ChatGPT 的 Codex 服务。
|
||||
4. 上游返回后,路由再把 Responses 的 JSON/SSE 转回 Claude Code 能理解的 Messages 形态。
|
||||
|
||||

|
||||
|
||||
## 准备工作
|
||||
|
||||
- 已安装并能启动的 CC Switch(3.17.0 及以上,原因见开头的版本说明)。
|
||||
- 已安装 Claude Code,并至少运行过一次。
|
||||
- 方式一需要:一个兼容 OpenAI Responses API 的服务端点和对应的 API Key,端点地址与模型名以网关文档为准。注意是 **Responses API**,不是 Chat Completions;网关只提供 Chat 格式时也能走通,见第一步「API 格式」处的说明。
|
||||
- 方式二需要:一个 ChatGPT Plus/Pro 订阅账号。
|
||||
|
||||
## 第一步:添加供应商
|
||||
|
||||
### 方式一:第三方 Responses 网关(API Key)
|
||||
|
||||
打开 CC Switch,切到顶部的 `Claude Code` 标签,点击右上角加号添加供应商,保持默认的 `自定义配置`,然后填写:
|
||||
|
||||
- **供应商名称**:随意,例如 `GPT Gateway`。
|
||||
- **API Key**:你的网关 Key。真实 Key 只保存在 CC Switch 里,由本地路由转发时注入。
|
||||
- **请求地址**:填网关服务根地址即可,例如 `https://gpt-gateway.example.com`,不要以斜杠结尾,路由会自动把请求打到该网关的 Responses 端点(`/v1/responses`)。网关路径特殊时,打开旁边的 `完整 URL` 开关原样粘贴完整端点。
|
||||
|
||||
然后展开 `高级选项`:
|
||||
|
||||
- **API 格式**:从默认的 `Anthropic Messages (原生)` 改成 **`OpenAI Responses API (需开启路由)`**。如果网关只提供 Chat Completions 协议,这里改选 `OpenAI Chat Completions (需开启路由)`,其余步骤完全相同。
|
||||
- **认证字段**:保持默认的 `ANTHROPIC_AUTH_TOKEN(默认)`,路由会以 `Authorization: Bearer <key>` 发给上游——这正是 OpenAI 兼容网关期望的认证头。除非网关文档明确要求 `x-api-key`,否则不要改成 `ANTHROPIC_API_KEY`,改错的典型表现是 401/403。
|
||||
- **模型映射**:把 Claude Code 的模型角色映射到网关认识的真实模型。**至少要填 `默认兜底模型`**(例如 `gpt-5.6`,以网关文档为准)——留空时未匹配的请求会以原始 Claude 模型名透传给上游而报错,未单独配置的角色则会回落到它。更精细的做法是按行指定:`Sonnet`/`Opus` 填主力模型,`Haiku` 填一个便宜快速的模型(Claude Code 的后台小任务走这一档)。`显示名称` 只影响 `/model` 菜单里的展示,留空则直接显示真实模型名。
|
||||
- **声明支持 1M**:模型映射每行的 `1M` 复选框会向 Claude Code 声明该档支持 1M 上下文。仅当网关确实以 100 万 token 及以上的窗口服务该模型时才勾选(例如按 API 规格提供 gpt-5.6 的网关),否则长对话会在上游真实上限处直接报错。
|
||||
|
||||

|
||||
|
||||
保存后,卡片上会出现 `需要路由` 标记——这类供应商必须在本地路由运行时才能正常工作。
|
||||
|
||||
### 方式二:ChatGPT 订阅(Codex OAuth)
|
||||
|
||||
同样在 `Claude Code` 标签点加号,在预设列表里选择带 OpenAI 图标的 **`Codex`** 预设——它出现在 Claude Code 标签下没有选错,这个预设就是为「在 Claude Code 里用 ChatGPT 订阅」准备的:
|
||||
|
||||
- **不需要 API Key,也不需要填地址**——请求固定发往 ChatGPT 的 Codex 服务,表单里的地址项无需改动。
|
||||
- 点击 **`使用 ChatGPT 登录`**。这是设备码流程:CC Switch 会自动打开浏览器并把验证码复制到剪贴板,在浏览器页面粘贴验证码完成授权即可,期间应用内显示 `等待授权中...`。
|
||||
- 登录成功后,`认证状态` 显示已登录账号(邮箱)。支持多账号:可 `添加其他账号`、`设为默认`,或在这个供应商上指定使用某个账号;日常管理也可以走 `设置` → `OAuth 认证中心`。
|
||||
- **FAST 模式**:可选开关,开启后请求携带 `service_tier="priority"` 换取更低延迟,但会按更高速率消耗 ChatGPT 配额,默认保持关闭。
|
||||
- 模型档位已预填好:`Sonnet`/`Opus` 对应 `gpt-5.6`,`Haiku` 对应 `gpt-5.6-luna`(后台小任务走它,更快更省额度)。
|
||||
|
||||

|
||||
|
||||
登录凭据保存在 `~/.cc-switch/codex_oauth_auth.json`(不是 `~/.codex/`),与 Codex CLI 自己的登录互不影响;token 会在到期前自动刷新。
|
||||
|
||||
## 第二步:开启本地路由并接管 Claude Code
|
||||
|
||||
进入设置里的 `路由` 页面,展开 `本地路由`,完成两个开关:
|
||||
|
||||
1. 打开 `路由总开关`,启动本地服务(首次开启会弹出说明确认框)。默认地址是 `127.0.0.1:15721`。
|
||||
2. 在 `路由启用` 中打开 `Claude Code`。只想让 Claude Code 走路由的话,其余应用保持关闭。
|
||||
|
||||
接管后,CC Switch 会把 Claude Code 的 live 配置指向本机路由,认证项只有占位符;方式一的网关 Key 和方式二的 OAuth token 都由本地路由在转发时注入。
|
||||
|
||||
> **注意**:live 配置是 Claude Code 进程启动时读取的。首次开启接管(或关闭接管恢复直连)后,如果 Claude Code 正在运行,请重开一个终端会话。之后在路由模式下切换供应商就是热切换,无需再重启。
|
||||
|
||||
## 第三步:切换供应商并验证
|
||||
|
||||
回到 Claude Code 供应商列表,点击目标供应商的 `启用`。如果路由没有在运行,CC Switch 会提示「此供应商使用 OpenAI Responses 接口格式,需要路由服务才能正常使用,请先启动路由」——这个提示不会拦截切换,但路由未开时请求必然失败,回到第二步打开即可。
|
||||
|
||||
进入 Claude Code 后可以逐级验证:
|
||||
|
||||
- 新开会话,用 `/model` 查看模型菜单:各档显示的是模型映射里的显示名称(方式二默认即 `gpt-5.6`、`gpt-5.6-luna`)。界面个别位置仍可能出现 Claude 系的模型字样——那是路由接管使用的内部角色别名,属正常现象,以 `/model` 菜单和用量看板为准。
|
||||
- 发一个小问题,观察设置 → 路由页面的「当前 Provider」变成你的供应商、「总请求数」开始增长。
|
||||
- 用量看板里,这些请求会按上游真实模型显示:映射到 `gpt-5.6` 的档位解析为 Sol 档、显示为 `GPT-5.6 Sol`,`gpt-5.6-luna` 显示为 `GPT-5.6 Luna`,可按供应商筛选核对 token 用量。
|
||||
- 方式二的供应商卡片还会显示订阅额度:5 小时与 7 天窗口的利用率和重置倒计时,数据来自 ChatGPT 账号本身,与官方 Codex 客户端共用同一份额度。
|
||||
|
||||
## 能力边界与已知限制
|
||||
|
||||
- **提示缓存自动生效**:路由会为每个会话注入稳定的 `prompt_cache_key`,配合 OpenAI 侧的自动前缀缓存,长对话不会每轮全价重发,无需任何配置。
|
||||
- **思考折算为 reasoning effort**:Claude Code 的思考开关与思考等级会被折算成 GPT 的 `reasoning.effort`(low/medium/high);GPT 的推理内容以加密形态跨轮完整回放,多轮推理连贯性不受转换影响。方式二同时以无痕模式(`store:false`)访问,不在 OpenAI 服务端留存会话。
|
||||
- **工具与多模态完整转换**:多轮工具调用、图片与 PDF 输入都被完整转换。
|
||||
- **上下文按 200K 窗口管理**:Claude Code 对路由供应商按默认 200K 窗口做自动压缩。上游实际窗口更大时(如 ChatGPT Codex 服务的 gpt-5.6 为 372K),超出 200K 的部分当前不会被用到——压缩会提前触发,保守但安全。想突破 200K,目前唯一的开关是模型映射里的 `1M` 复选框(严格声明 1M),仅限方式一且上游真按 1M 及以上服务该模型时使用;方式二的上游上限是 372K、够不到 1M,勾选反而会让长对话在上游真实上限处报错,请维持默认。
|
||||
- **输出上限**:方式二的输出上限由 ChatGPT 服务端控制(Claude Code 请求里的 `max_tokens` 不会下发);方式一则原样透传 Claude Code 的 `max_tokens`,无需配置。
|
||||
- **联网搜索不可用**:Claude Code 的 WebSearch 依赖 Anthropic 服务端执行,GPT 上游无法承接,涉及联网搜索的任务建议切回 Claude 系供应商。本地执行的 WebFetch 不受影响。
|
||||
- **用量看板金额是参考值**:token 计数准确,但美元金额是按公开 API 价折算的估算——方式二订阅流量按 GPT-5.6 公开价折算,方式一的第三方网关按各自费率计费,两者都可能与真实扣费不符,仅供对比。方式二的额度消耗以供应商卡片上的窗口利用率为准。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**上游返回 401 或 403(方式一)**
|
||||
|
||||
先确认高级选项里的 `认证字段` 是默认的 `ANTHROPIC_AUTH_TOKEN(默认)`——改成 `ANTHROPIC_API_KEY` 会以 `x-api-key` 发送,绝大多数 OpenAI 兼容网关不接受。再确认 Key 本身有效、有余额。
|
||||
|
||||
**请求 `gpt-5.6-luna` 等新模型报 404 Model not found(方式二)**
|
||||
|
||||
升级到 CC Switch 3.17.0 及以上。旧版本的客户端身份未对齐官方 Codex 客户端,ChatGPT 服务端会把新模型解析到不存在的引擎。
|
||||
|
||||
**切换后没生效,或 `/model` 菜单还是旧名字**
|
||||
|
||||
模型菜单和路由地址都是 Claude Code 启动时读取的:首次开启接管后必须重开终端会话;供应商之间切换虽然是热切换,但菜单里的显示名称要新会话才刷新。
|
||||
|
||||
**Claude Code 报「Codex OAuth 认证失败」或卡片显示「会话已过期」(方式二)**
|
||||
|
||||
登录凭据已失效。回到供应商表单或 `设置` → `OAuth 认证中心`,重新走一遍 `使用 ChatGPT 登录` 即可;无需运行任何命令行操作。
|
||||
|
||||
**对话进行到一半就自动压缩了**
|
||||
|
||||
见「能力边界」:路由供应商按 200K 窗口管理,压缩阈值随之提前,属预期行为。
|
||||
|
||||
**想恢复官方 Claude 用法**
|
||||
|
||||
切回官方供应商,或在路由页面关闭 `Claude Code` 的路由开关——CC Switch 会恢复接管前的 live 配置,官方登录凭据全程不受影响。恢复后同样需要重开终端会话。
|
||||
|
||||
**FAST 模式要不要开(方式二)**
|
||||
|
||||
默认关闭即可。只有当你对延迟特别敏感、且愿意接受配额更快消耗时再打开;如果 ChatGPT 服务端拒绝该参数,关掉开关即可恢复。
|
||||
|
||||
## 合规提示
|
||||
|
||||
方式二把 ChatGPT 订阅额度用在官方 Codex 客户端之外,这并不是灰色玩法:OpenAI Codex 负责人 Thibault Sottiaux(@thsottiaux)就公开演示并鼓励过把 Claude Code(他戏称的「orange crab」)指向 GPT-5.6 Sol 使用——用的正是「本地代理 + 模型别名」,和本文方式二属于同一类做法。作为 Codex 这条产品线的负责人,他主动鼓励大家在竞品客户端里用自家模型,可见用订阅在 Claude Code 里跑 GPT 系模型,是官方乐见并鼓励尝试的用法。
|
||||
|
||||
两点实务提醒仍值得留意:一是这部分流量与官方 Codex 客户端合并计入同一份订阅额度,重度使用会更快触顶;二是 CC Switch 认证中心出于稳妥保留了合规提示(「在 Claude Code 中使用您的其他订阅,请注意合规风险。」),是否符合你账号所适用的条款可自行留意。方式一使用第三方网关时,请另行阅读目标网关关于计费、合规与数据留存的条款。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [CC Switch 用户手册:添加供应商(含 Codex OAuth 反向代理与 API 格式)](../user-manual/zh/2-providers/2.1-add.md)
|
||||
- [CC Switch 用户手册:代理服务](../user-manual/zh/4-proxy/4.1-service.md)
|
||||
- [CC Switch 用户手册:应用路由](../user-manual/zh/4-proxy/4.2-routing.md)
|
||||
- [CC Switch v3.17.0 发布说明](../release-notes/v3.17.0-zh.md)
|
||||
- 反方向攻略:[在 Codex 中使用 Claude 模型](./codex-claude-routing-guide-zh.md)
|
||||
@@ -1,4 +1,4 @@
|
||||
# Using Claude in Codex: CC Switch Local Routing Guide
|
||||
# Using Claude Models in Codex with CC Switch
|
||||
|
||||
> Applies to CC Switch 3.17.0 and later (the Anthropic Messages upstream was introduced in 3.17.0). This guide is based on the repository documentation and code, and uses a Claude-family relay gateway as the example. Screenshots are generated from the current frontend UI with de-identified sample data to avoid exposing a real API key.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Codex で Claude を使う: CC Switch ローカルルーティングガイド
|
||||
# CC Switch を使って Codex で Claude モデルを利用する
|
||||
|
||||
> 対象バージョン: CC Switch 3.17.0 以降(「Anthropic Messages 上流」は 3.17.0 から導入)。本記事はリポジトリ内のドキュメントとコードをもとに整理し、Claude 系中継ゲートウェイを例に説明します。スクリーンショットは現在のフロントエンド UI から、実際の API Key が漏れないよう匿名化したサンプルデータで生成しています。
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 在 Codex 中用 Claude:CC Switch 本地路由攻略
|
||||
# 通过 CC Switch 在 Codex 中使用 Claude 模型
|
||||
|
||||
> 适用版本:CC Switch 3.17.0 及以上(「Anthropic Messages 上游」自 3.17.0 引入)。本文根据仓库内文档与代码整理,以 Claude 系中转网关为例演示。截图来自当前前端界面,使用去敏示例数据生成,避免泄露真实 API Key。
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 293 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 324 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
@@ -0,0 +1,331 @@
|
||||
# CC Switch v3.18.0
|
||||
|
||||
> This release lets you do two brand-new things: **hand xAI's Grok CLI (Grok Build) over to CC Switch** — it becomes the eighth managed app, with one-click provider switching, MCP / Skills sync, proxy takeover, and usage statistics all included; and **connect Grok to Claude Code, Claude Desktop, and Codex** — either by signing in with your xAI Grok account directly (device-code authorization, no API key, running on your Grok subscription, with a strict-gateway compatibility layer on the Codex side so codex 0.142+ works too), or with an xAI API key (Codex gets a native Responses direct-connection preset; Claude Code can go through local routing). Just as important is a wave of fixes: the **Codex usage double count introduced in v3.17.0 is fixed**, with an automatic data rebuild after upgrading so the dashboard numbers become real again; **codex 0.144.5+ failing to start because of the model catalog is fixed**; and switching providers on Windows no longer flashes a console window or freezes the UI. Diagnostic logs also move from "wiped on every startup" to persistent across restarts, size-rotated, and fully redacted — and a UI crash now leaves an on-disk report instead of just a blank white window.
|
||||
|
||||
**[中文版 →](v3.18.0-zh.md) | [日本語版 →](v3.18.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Highlights: What You Can Do Now
|
||||
|
||||
- **Manage Grok Build (xAI's Grok CLI)**: add, import, and one-click switch Grok Build providers just like Claude Code / Codex; MCP servers and Skills sync bidirectionally, prompts auto-import on first launch, and session management plus the usage dashboard cover it fully; you can also route it through the local proxy for independent routing, failover, and billing.
|
||||
- **Connect Grok with your account or an API key — both paths work**: subscription users complete an xAI device-code sign-in under Settings → OAuth Auth Center (multiple accounts supported), and all three clients — Claude Code, Claude Desktop, Codex — run straight on your Grok subscription with no API key anywhere; pay-as-you-go users connect with an xAI API key instead — Codex has a ready-made "xAI (Grok)" preset connecting natively to `api.x.ai`, and Claude Code can connect through local routing following this release's new guide. The default model is `grok-4.5` throughout.
|
||||
- **Get your Codex usage numbers back to reality**: the v3.17.0 fork / sub-agent double count is rooted out at the parser level; the first launch after upgrading automatically backs up the database and rebuilds Codex usage, and the usage page gains a manual "Rebuild Codex Usage" button. Note that on first launch the history is repaired **progressively** — the dashboard numbers first shrink, then fill back in as the background re-import proceeds; this is expected (see "Upgrade Notes").
|
||||
- **Upgrade the codex CLI without fear**: the "fails to start" problem caused by codex 0.144.5's strict model-catalog parsing is fixed — generated catalogs now backfill the parser-required fields automatically.
|
||||
- **Switch smoothly on Windows**: switching providers or toggling takeover no longer flashes a black console window, and no longer freezes the UI for ~2 seconds (the freeze fix benefits all platforms).
|
||||
- **Troubleshoot and share logs with more confidence**: diagnostic logs persist across restarts (20 MB × 4 rotation) and every egress is uniformly redacted — URL credentials, request/response bodies, and sensitive headers never reach disk anymore; a UI crash shows an error card with a Reload button, with the details written to disk.
|
||||
- **Multi-turn reasoning and parallel tool calls no longer fall over**: the Responses↔Chat bridge fixes three classes of problems — reasoning content attached to the wrong message, parallel tool calls losing their IDs or their order, and tools with a null schema getting the whole request rejected by strict upstreams.
|
||||
- **Use Kimi K3**: the Kimi open-platform presets for Codex / Hermes / OpenClaw / OpenCode gain K3 (1M context), with built-in pricing seeded so its usage no longer shows $0.
|
||||
|
||||
---
|
||||
|
||||
## Usage Guides
|
||||
|
||||
The new capabilities in this release land mainly in the provider presets, Settings → OAuth Auth Center, and the usage dashboard. The following docs are worth reading alongside it:
|
||||
|
||||
- **[xAI Grok Account Sign-In (Settings → OAuth Auth Center)](../user-manual/en/1-getting-started/1.5-settings.md)**: the device-code login flow, multi-account management, and the integration's boundaries; please read the client-identity disclosure under "Risk Notice" below before use.
|
||||
- **[Using GPT Models in Claude Code (local routing guide)](../guides/claude-codex-routing-guide-en.md)** (now in Chinese / English / Japanese): a new step-by-step guide added in this release. Claude Code always speaks Anthropic Messages to the local `/v1/messages` route, and the local proxy converts each request to the upstream's Responses protocol — a gateway API key, a native Responses endpoint like xAI, or a ChatGPT subscription's Codex service all fit.
|
||||
- **[Using Claude Models in Codex (local routing guide)](../guides/codex-claude-routing-guide-en.md)**: a new step-by-step guide in three languages, pairing with v3.17.0's native Anthropic Messages upstream to connect Codex to any Claude-family gateway that only offers `/v1/messages`.
|
||||
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: understand the Usage Dashboard's data sources and how the statistics are counted. This release fixes the usage double count and adds the "Rebuild Codex Usage" maintenance action.
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## Only Official Channels (Please Read)
|
||||
>
|
||||
> CC Switch is a **fully free and open-source** desktop app, and we **do not charge users any fees**. Please only obtain the software through the official channels listed below:
|
||||
>
|
||||
> | Channel | Only Official |
|
||||
> | ------------------ | ------------------------------------------------------------------------------ |
|
||||
> | Website | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | Source | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | Downloads | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | Author | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | Report an Imposter | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **Any "CC Switch" website or client that asks you for payment, top-ups, or login credentials is fake.** If you have been tricked into paying, stop the transaction immediately and file a report through GitHub Issues.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Both main threads of CC Switch v3.18.0 revolve around xAI Grok. The first is **Grok Build joining the managed apps**: xAI's Grok CLI (live config `~/.grok/config.toml`) becomes the eighth managed app alongside Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, and Hermes — provider add / import / one-click switch, bidirectional MCP and Skills sync, deep-link import, a standalone preset list, and proxy takeover with its own route namespace; the companion "Grok Official" entry supports official-login-state detection and import, and CC Switch never touches the official credentials. The second is **xAI Grok account OAuth sign-in**: device-code authorization replaces the API key, the local proxy injects the access token per request, and on the Claude Code / Claude Desktop side it performs the Anthropic Messages → xAI Responses conversion; the Codex side gets a managed OAuth preset with its own compatibility layer — the ChatGPT-backend-private shapes emitted by codex 0.142+ (namespace tool declarations, private fields) are deterministically flattened and stripped, so the strictly parsing xAI gateway no longer returns 422; API-key users get a separate "xAI (Grok)" native Responses direct-connection preset that goes through no conversion at all.
|
||||
|
||||
On correctness, this release concentrates on **the Codex usage double count from v3.17.0**: the replay of the parent thread's history at the start of fork / sub-agent logs is no longer imported as new usage (the parser now only trusts explicit parent identity plus token-signature alignment), a one-time automatic usage rebuild runs after upgrading (schema v16), and the usage page gains a manual rebuild button; proxy-side usage logging becomes idempotent (replays of the same response no longer stack duplicate rows), and the usage page no longer freezes during large session imports. The **Codex conversion layer** gets four more fixes: tool schemas normalized to object type, reasoning attached forward across turns, streamed parallel tool calls keeping their IDs and order, and generated model catalogs backfilling the fields codex 0.144.5+ requires. The diagnostics story matures too: logs persist across restarts, rotate by size, and redact at every egress, and UI crashes are captured by an error boundary and written to disk. Rounding things out: Kimi K3 presets and pricing, corrected OpenClaw preset costs, SudoCode.us restored, and the first-run tray language following the system locale.
|
||||
|
||||
**Release date**: 2026-07-21
|
||||
|
||||
**Stats**: 52 commits | 217 files changed | +21,452 / -6,285 lines
|
||||
|
||||
---
|
||||
|
||||
## Added
|
||||
|
||||
### Grok Build: The Eighth Managed App
|
||||
|
||||
xAI's Grok CLI ("Grok Build", live config `~/.grok/config.toml`) is now a first-class citizen in CC Switch: provider add / import / one-click switch (with a "restart Grok Build to apply" toast), app visibility and config-directory override settings, session manager and usage dashboard coverage, prompts with first-launch auto-import, `ccswitch://` deep-link provider import, and local proxy takeover — with its own `/grokbuild/v1/responses` route namespace, an independent failover queue, and per-app proxy settings; forwarding reuses Codex's Responses path but never shares Codex's provider namespace or circuit-breaker state.
|
||||
|
||||
MCP servers sync bidirectionally with Grok's `[mcp_servers]` table, with the dialect differences smoothed over: Grok infers the transport from `command` / `url` and uses a `headers` field, so exports strip the explicit `type` and rename `http_headers` to `headers`, while imports infer the transport back. Skills gain a Grok Build enable flag too.
|
||||
|
||||
On presets, the Codex list was deliberately **not** borrowed (an early cut leaked China-direct providers and Codex default models into the Grok form); instead Grok Build ships its own curated list: only aggregators and relays that actually carry Grok models, with the default model normalized to `grok-4.5` (`x-ai/grok-4.5` on namespaced routers). The tools panel installs Grok via the official xAI installer first (`x.ai/cli/install.sh` / `install.ps1`), with the npm package `@xai-official/grok` as fallback; installs confirmed as native self-update via `grok update`, while npm installs keep npm-anchored updates — the self-update is gated on positive native detection, so it can never touch a different kind of install. UI copy is in sync across all four locales. ([#5453](https://github.com/farion1231/cc-switch/pull/5453))
|
||||
|
||||
### Grok Official Login: Detection, Import, and Protection
|
||||
|
||||
A new "Grok Official" provider entry maps to the Grok CLI's own built-in xAI OAuth login: selecting it hides the connection fields and writes an empty `~/.grok/config.toml`, and CC Switch never stores or touches the official credentials. Live-config reads, backups, and official-state writes now use syntax-only TOML validation, so an official-login (empty) config round-trips normally; importing the live config while Grok is in official login state yields "Grok Official set as current" instead of an error, matching Codex behavior. Official-state detection is deliberately wired only into the manual import command — the startup auto-importer still rejects official-state configs, so a "Grok Official" entry you deleted never comes back on the next launch. Proxy takeover of an official-login Grok config is skipped automatically, with a clear rejection on the manual path, consistent with the existing "never proxy official providers" policy.
|
||||
|
||||
### Sign In with Your xAI Grok Account: Claude Code and Claude Desktop
|
||||
|
||||
Claude Code and Claude Desktop gain an "xAI (Grok)" preset that replaces the API key with an OAuth device-code login: requests go through the local proxy, which performs the Anthropic Messages → xAI Responses API conversion and injects the access token per request, with `grok-4.5` as the default model on every tier (the Claude Desktop preset maps the `claude-*`-style role IDs to `grok-4.5` upstream so it passes Desktop's third-party model validation).
|
||||
|
||||
Settings → OAuth Auth Center gains an xAI section: device-code login (user code with a copy button, verification link, waiting / cancel / retry), multiple accounts with a default-account picker, per-account removal, and re-auth badges — an account whose refresh token was revoked stays visible as "expired" instead of disappearing, and auth status refetches every 15 seconds so a server-side revocation surfaces on its own.
|
||||
|
||||
The integration's boundaries are pinned shut: no matter what the endpoint / format fields in the form say, the upstream is always `https://api.x.ai/v1/responses` (Responses format); OAuth endpoints are resolved via OIDC discovery but strictly validated to `auth.x.ai` over https; refresh tokens live in `~/.cc-switch/xai_oauth_auth.json` (`0600` on Unix; access tokens are memory-only); and OAuth error bodies never enter error messages or logs. Pricing for `grok-4.5` ($2 input / $6 output / $0.50 cache read per million tokens) is seeded in sync so its usage no longer records $0, with existing databases picking the row up automatically on next launch. Four-locale copy included. Please read the client-identity disclosure under "Risk Notice" before use.
|
||||
|
||||
Not using OAuth and only have a pay-as-you-go xAI API key? You can still connect it to Claude Code: xAI's API endpoint is standard Responses protocol, so add it as an ordinary Responses provider — a custom provider with `https://api.x.ai/v1` and your API key, upstream format set to Responses, converted between Anthropic Messages ↔ Responses by local routing; it's the same recipe as the [Using GPT Models in Claude Code](../guides/claude-codex-routing-guide-en.md) guide. On the Codex side there's a ready-made API-key preset — see the next section.
|
||||
|
||||
### Codex Straight to xAI: Managed OAuth and Native API-Key Presets
|
||||
|
||||
Codex gets two ways to talk to xAI directly — OAuth managed if you have a Grok subscription, native direct connection if you have an API key:
|
||||
|
||||
- **The "xAI (Grok) OAuth" managed preset** runs Codex on a Grok subscription. The form hides the key / endpoint / format fields and shows the account picker, "fetch models" uses the signed-in account, and the provider is pinned to native Responses with the base URL and per-request token enforced by the proxy — edits are ignored, so the managed route cannot be redirected. Because codex 0.142+ emits ChatGPT-backend-private request shapes (`type:"namespace"` tool declarations make xAI's strict parser return a straight 422, plus private fields like `prompt_cache_retention`, `safety_identifier`, `external_web_access`, the `additional_tools` carrier, and sampling knobs grok-4.5 doesn't support), the OAuth route adds a compatibility layer on top of the native passthrough: namespace tools are flattened to top-level function tools (with the same sha256-truncated naming as the Chat path) and restored to namespaced form on the response side for both streaming and non-streaming, and unsupported fields are stripped — all deterministic field removals / structural lifts, never semantic rewrites, so prompt-cache prefixes stay stable. The layer is gated exclusively on the xAI OAuth provider type; no other provider's traffic is touched.
|
||||
- **The "xAI (Grok)" API-key preset** connects natively to Responses at `api.x.ai/v1`, with a 500K-context `grok-4.5` catalog entry built in. This preset does **not** receive the xAI-specific compatibility transforms above — API-key users on codex 0.142+ can still hit xAI's strict parser, and the OAuth preset is the fully compatible path.
|
||||
|
||||
xAI OAuth token failures are classified as non-retryable, so failover never quietly moves your conversation onto a different Grok account.
|
||||
|
||||
### UI Crash Capture: On-Disk Reports and a Reload Screen
|
||||
|
||||
A React error boundary now wraps the entire UI (including the database-recovery screen): a renderer crash shows a "Something went wrong in the interface" card with a Reload button instead of a blank white window, and global `error` / `unhandledrejection` handlers persist renderer errors to disk — previously a JS crash left zero on-disk evidence. Everything the frontend logs passes two redaction layers: a structured serializer that redacts by sensitive property name (`tokens` / `apiKeys` / `credentials` variants all normalized to match, hiding the entire value including nested objects) and by value shape (token prefixes, PEM headers, high-entropy opaque strings), then a single text egress whose ordered regex chain covers URL query values and credentials, auth headers and schemes, and named secret containers — even in double-encoded JSON. JSON arriving as a string is re-parsed and redacted structurally, and oversized structured input is dropped entirely rather than truncated — a truncated JSON string would fall back to the weaker text regexes and could leak. The settings toggles were also relabeled to say what they actually control: "Application Diagnostic Logs" (cc-switch.log) versus the proxy's "Record Request Usage" (the stats database, which never was a text log). Four locales in sync.
|
||||
|
||||
### "Rebuild Codex Usage" Maintenance Button
|
||||
|
||||
The usage dashboard's maintenance section gains "Rebuild Codex Usage": after backing up the database, it wipes only `codex_session`-sourced detail rows, their `_codex_session` daily rollups, and the Codex sync cursors, then re-imports every rollout file from scratch with the corrected parser — the recovery path for databases already inflated by the double-count bug below, and the retry path for deferred fork files whose parent log has since been restored. The manual rebuild fails hard when the pre-rebuild backup cannot be written (the automatic migration variant only warns, since blocking startup on an unwritable backup directory would be the worse outcome after an upgrade); the whole backup → reset → re-import sequence holds the session-sync lock, so the 60-second background sync cannot interleave with the wipe; and completion always sends exactly one frontend refresh notification — including when the re-import returns zero rows or fails — so the dashboard never keeps showing pre-reset numbers. Cursor cleanup matches rollout paths purely by shape (a `rollout-{uuid}` filename under a `sessions` / `archived_sessions` segment), so cursors recorded under an old `CODEX_HOME` are cleaned too. Four locales in sync.
|
||||
|
||||
### Session Import Observability: Deferred Files and Suspected Duplicates
|
||||
|
||||
Session sync results now report `filesScanned`, `deferredFiles` — fork rollouts whose parent log is missing or whose parent markers conflict are held back without writing a cursor, so a later sync or a manual rebuild retries them instead of importing on a guess — and `suspectedDuplicates`: a post-insert probe checks each imported row for a pre-existing same-fingerprint row (via the `idx_request_logs_dedup_lookup_expr` expression index) and logs a warning per hit. If the double-count bug ever regresses, it will announce itself in the logs instead of silently inflating the totals.
|
||||
|
||||
### Kimi K3 in Presets and Built-In Pricing
|
||||
|
||||
The Kimi open-platform presets for Codex / Hermes / OpenClaw / OpenCode gain Kimi K3 (1M context window), appended after K2.7 Code so existing default-model behavior is unchanged. The built-in pricing table gains `kimi-k3` (official list price: $3 input / $15 output / $0.30 cache read per million tokens) plus a bare `k3` alias — the Kimi For Coding subscription reports its model under the short id `k3`, which would otherwise match no pricing row (same precedent as the existing `hunyuan-hy3` / `hy3` pair). Existing databases pick both rows up automatically on next launch, without touching user-edited pricing.
|
||||
|
||||
### SudoCode.us Returns, Coexisting with SudoCode.chat
|
||||
|
||||
The two unrelated companies that happen to share the "SudoCode" name are now two separate presets: the sponsor is renamed "SudoCode.chat", and the previously replaced-in-place "SudoCode.us" returns with its original endpoints, models, and icon, plus a distinct Hermes slug so both can coexist in the additive `~/.hermes/config.yaml`. Counting the new Grok Build preset list, SudoCode.chat ships in seven apps and SudoCode.us in all eight.
|
||||
|
||||
---
|
||||
|
||||
## Changed
|
||||
|
||||
### Diagnostic Logs: Persistent Across Restarts, Size-Rotated, and Never Recording Secrets
|
||||
|
||||
`cc-switch.log` is no longer wiped on every startup — the log that could have explained a crash used to be gone by the time the app reopened — and instead rotates at 20 MB with 4 archives kept (~100 MB cap, versus a single file that could balloon to 1 GB before); the previously unbounded `crash.log` rotates at 5 MB with 2 archives, with the check / rotate / append sequence under one lock so concurrent panics cannot lose an archive.
|
||||
|
||||
Persistent logs make verbatim secrets a real exposure (users attach these files to public issues), so the same change scrubs every backend log egress: upstream URLs are logged with userinfo / query / fragment stripped (origin-only when no known secret is available to substitute, since a credential could be embedded in the path); request and response bodies are never logged — replaced by byte counts, a short hash, or a safe classification (`sse` / `html` / `json-like` / `binary-or-encoded`, …) that keeps the transform-debugging signal without the content; response headers go through an allowlist (unlisted headers log name-only); the secret values currently in use (API key, access token) are substituted out of any logged URL that carries them; and MCP custom-field values are omitted. The log plugin also registers earlier (updater / startup failures become diagnosable), the persisted log level applies as soon as the database opens and fails closed to Info, and the "Enable Diagnostic Logs" switch now gates frontend-originated log writes too. **Log files written by earlier versions are not retroactively scrubbed** — see "Upgrade Notes".
|
||||
|
||||
### Preset Picker: Sponsors Grouped, Everything Else Alphabetized
|
||||
|
||||
The preset picker's default order becomes four tiers: official first, then the prime partner, then sponsor presets (in the same order as the README sponsor table, which the preset files were physically reordered to match), then all remaining presets sorted alphabetically by display name instead of file order. An entry matching multiple tiers lands only in the earliest one, so nothing shows up twice.
|
||||
|
||||
### Preset "Get API Key" Links Updated
|
||||
|
||||
The key-application links on the RunAPI, ClaudeCN, ZetaAPI, and APINebula presets now open each provider's current registration / referral page (ClaudeCN also moved domains: claudecn.top → claudecn.ai). Referral tags remain confined to these links and the README — website links and API endpoints stay untouched.
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### Codex Fork / Sub-Agent Replayed Parent History No Longer Counted as New Usage (the v3.17.0 Double Count)
|
||||
|
||||
Fixes the v3.17.0 usage inflation: forking a Codex task or spawning a sub-agent in copy mode re-counted the parent conversation's token history as new usage — users reported single days jumping by billions of tokens, byte-identical parent/child rows, and empty forks carrying usage they never consumed. Fork / sub-agent rollout files begin by replaying the parent thread's history, and the old parser located the takeover boundary heuristically (the first `thread_settings_applied` event, object-shaped `subagent` source markers): the boundary landed too early when the parent's own settings changes appeared in the replay, and the current string-shaped source markers weren't recognized at all, importing the entire replayed parent history verbatim. The new parser trusts only explicit parent identity — `forked_from_id` on the child's `session_meta`, or `source.subagent.thread_spawn.parent_thread_id`, with a conflict between the two deferring the file — anchors each thread's identity to the rollout filename UUID, loads the parent rollout's own pre-fork token-count sequence, and strips the child's replayed prefix by aligning token signatures against it: replayed events only restore the cumulative baseline and are never inserted as rows. Sub-agent logs that carry no replayed history are now counted as live usage, also fixing the opposite-direction undercount where real sub-agent consumption was skipped as suspected replay. ([#5335](https://github.com/farion1231/cc-switch/issues/5335), [#5433](https://github.com/farion1231/cc-switch/issues/5433), [#5381](https://github.com/farion1231/cc-switch/issues/5381))
|
||||
|
||||
### Proxy Usage Logging Is Now Idempotent: Response-Scoped Stable Keys
|
||||
|
||||
When a terminal usage event carried no message id — the norm for Codex `/responses` traffic through the local proxy — the dedup key fell back to a random UUID, so every retry / replay of the same upstream response minted a fresh key and `INSERT OR REPLACE` stacked a new row each time; one reporter's database held the same usage combination 2,078 times. The parser now takes the key from the response envelope itself — the Codex `response.completed` event's `response.id` (ids from `response.created` are discarded), Chat Completions `chatcmpl` ids, and the Gemini `responseId` — scoped as `session:{app_type}:{provider_id}:{id}`: the same response replayed against a different provider during failover still bills once per provider without cross-provider collisions (Claude keeps the bare `session:{id}` shape so proxy rows continue to converge with session-log imports). When no envelope id exists at all, the fallback is a deterministic SHA-256 over the response's usage semantics rather than a random UUID — an identical replay must collide into the same key for dedup to work — and the final database write becomes a guarded insert-if-absent within the dedup window instead of an unconditional REPLACE. ([#5496](https://github.com/farion1231/cc-switch/issues/5496))
|
||||
|
||||
### Usage Page No Longer Freezes During Large Session Imports
|
||||
|
||||
Opening the usage page while a big import ran could lock the whole UI up: every inserted row fired a refresh notification, each notification made the frontend re-run all ~10 usage queries, and those queries contended with the importer for the single database connection while it parsed rollout files tens of megabytes large, line by line — on duplicate-inflated databases the three compounded each other. Session sync now notifies the frontend once per completed pass; all session importers are serialized behind a single-flight lock (a manual "sync now" queues behind the running pass instead of racing it); the blocking parse work runs on a dedicated blocking thread so it no longer starves the async runtime driving the UI's commands; and the 60-second background tick skips missed runs instead of bursting to catch up.
|
||||
|
||||
### codex 0.144.5+ No Longer Fails to Start on CC Switch-Generated Model Catalogs
|
||||
|
||||
codex ≥ 0.144.5 parses external model catalogs strictly and rejects the whole file when an entry is missing `supports_reasoning_summaries` — both the Codex CLI and the desktop app failed to launch, and deleting the generated catalog didn't help because any provider save regenerated it the same way. The root cause: CC Switch clones its catalog template from the machine-shared `models_cache.json`, whose field set is whatever the last-writing codex process produced — a coexisting older codex build kept rewriting the cache without the field the newer parser requires. Generated catalogs now backfill parser-required fields from the bundled static template, and only when absent (dynamic values always win); optional capability fields whose "missing = parser default" semantics must survive are deliberately not backfilled.
|
||||
|
||||
### Windows: No More Console Flash or UI Freeze When Switching Providers
|
||||
|
||||
Switching providers or toggling takeover on Windows flashed a transient console window and froze the UI for up to ~2 seconds. Three causes, three fixes: the `codex debug models --bundled` probe launches `codex.cmd` through `cmd.exe`, which in a GUI-subsystem app spawns its own console — the child process is now created with `CREATE_NO_WINDOW`; the model-catalog template was regenerated on every switch — it's now cached process-wide after the first successful load (failures stay retryable, so a bad first probe cannot poison the cache), and the Codex CLI starts at most once per app run; and `switch_provider` was a synchronous command on the main thread — it's now async with the real work on a blocking thread, still serialized by the per-app switch lock. The freeze fix benefits all platforms; the console-flash fix is Windows-specific.
|
||||
|
||||
### Tool Schemas with Null, Missing, or Union Parameter Types Accepted by Strict Upstreams
|
||||
|
||||
Built-in Codex tools such as `codex_app__automation_update` declare `parameters: null` (or `type: null`), which strict OpenAI-compatible upstreams like DeepSeek reject with a 400 for the entire request — killing tool-using sessions routed through the proxy. The Responses→Chat bridge now normalizes every tool's parameters to a `type:"object"` schema: null or missing parameters (including the nested-form missing case) become `{"type":"object","properties":{}}`, a non-object `type` (including `type: null`) is corrected to `"object"` in place, and top-level `oneOf` union schemas get a root `type:"object"` added with their branches preserved untouched. The same object-type guarantee was extended to the Codex→Anthropic tool path's `input_schema`. Existing `properties` / `required` are never dropped. ([#4706](https://github.com/farion1231/cc-switch/pull/4706), [#5315](https://github.com/farion1231/cc-switch/pull/5315), fixes #4705, #4783)
|
||||
|
||||
### Reasoning Models Keep Their Thinking Across Multi-Turn Codex Chat Conversations
|
||||
|
||||
With a reasoning model (e.g. kimi-k2-thinking) behind the proxy's Responses→Chat bridge, multi-turn history mangled the thinking: each turn's `reasoning` item got glued onto the tail of the **previous** assistant message, leaving the following assistant turn with no `reasoning_content` — models would visibly break off mid-conversation. Responses semantics place reasoning **before** the message it belongs to, so the bridge now attaches reasoning forward to the assistant message or tool call that follows it; genuine trailing reasoning back-attaches only at a confirmed tail (end of input, or a turn boundary such as an incoming user message — where it was previously silently discarded), appending to any embedded reasoning already present; and pending reasoning is always consumed at boundaries, so it can never leak across a user turn into a later assistant message. ([#5508](https://github.com/farion1231/cc-switch/pull/5508))
|
||||
|
||||
### Streamed Parallel Tool Calls Keep Their IDs and Their Order
|
||||
|
||||
Two bugs in the Chat→Responses streaming bridge corrupted parallel tool calls from upstreams that split identity across chunks: a continuation delta carrying an empty `id` overwrote the real `call_id` (the Codex client then saw `call_id:""` and couldn't match tool results to calls), and tool calls were emitted the moment they individually became ready, so a later index whose name arrived early could jump ahead of an earlier one — reordering parallel calls. Empty ids are now ignored, and emission goes through a consecutive-index gate that releases tool calls strictly in Chat `index` order, waiting on any not-yet-identified earlier index; no fake call id is ever synthesized mid-stream (only as a last resort at stream finalization, which also skips nameless calls defensively and still emits sparse indexes). ([#5310](https://github.com/farion1231/cc-switch/pull/5310))
|
||||
|
||||
### Managed-OAuth Providers Reliably Flagged as "Needs Local Routing"
|
||||
|
||||
The "needs routing" badge and switch-time warning were derived from the provider's API format — the wrong signal for managed-OAuth providers (Copilot, Codex OAuth, xAI), whose credential is injected by the proxy regardless of upstream format: a managed provider on a native format got no warning and failed silently without takeover. Routing need is now decided by a single shared predicate: official providers never need routing, managed-OAuth providers always do, and format-based rules apply only to the remaining cases. The switch-time gate also checks the right readiness signal per app: per-app takeover status for most apps (the old gate looked only at a global proxy-running flag, missing "the proxy runs but this app isn't taken over"), while Claude Desktop keeps watching the proxy process itself — the backend's takeover status has no Claude Desktop field, so a uniform per-app gate would have left Desktop warning forever. Claude Desktop provider forms now force proxy mode and lock the model-mapping toggle for every managed-OAuth type, not just xAI. Four locales in sync.
|
||||
|
||||
### Tool Updates Work When Node Lives in nvm / fnm / mise
|
||||
|
||||
Anchored npm update and repair commands invoked npm by absolute path, but npm's launcher resolves `node` via its `#!/usr/bin/env node` shebang against PATH — and a GUI-launched app inherits only the system PATH, without the version-manager directories, so updates for tools installed via nvm / fnm / mise silently failed. Every anchored npm invocation now prefixes PATH with npm's own sibling `bin` directory, so npm and its shebang resolve to the same Node install; the Codex self-repair (uninstall + reinstall) path is covered too.
|
||||
|
||||
### Deleted Default Skill Repositories Stay Deleted
|
||||
|
||||
Default Skill repositories were re-seeded on every startup by a "supplement missing defaults" pass, so a default repo you deleted silently returned on the next launch. Seeding is now one-time per database, tracked by a settings flag; databases that already contain repositories at upgrade time get the flag set without any re-seeding, so existing selections are untouched. ([#5356](https://github.com/farion1231/cc-switch/pull/5356))
|
||||
|
||||
### First-Run Tray Language Follows the System Locale
|
||||
|
||||
Before any language was chosen in settings, the tray menu was hardcoded to Simplified Chinese — on English / Japanese / Traditional-Chinese systems the main UI correctly followed the OS locale while the tray disagreed, until the user switched language once. The tray now derives its first-run language from the OS locale with the same precedence rules as the frontend (including `zh-TW` / `zh-HK` / `zh-Hant` → Traditional Chinese); an explicitly chosen language always wins, and unreadable locales fall back to Chinese as before. ([#4355](https://github.com/farion1231/cc-switch/pull/4355))
|
||||
|
||||
### Failed Imports Show the Real Error and Refresh the List
|
||||
|
||||
Every failed "import from live config" produced an empty error toast, because Tauri's `invoke` rejects with the backend's error **string** while the handler read `.message` off it. The backend's actual message is now shown (with a localized generic fallback), and the provider list refreshes even on failure, so side effects committed before the error are visible immediately.
|
||||
|
||||
### OpenClaw Preset Model Costs Corrected to Official List Prices
|
||||
|
||||
Fifteen OpenClaw preset entries carried cost values in the wrong unit or unconverted currency — the `cost` field is USD per million tokens, but e.g. `glm-5.1` was listed at `0.001/0.001` (≈1000× undervalued, so its usage showed near-zero cost) while `deepseek-v4-pro` carried unconverted CNY values (overvalued). All entries now carry official list prices in $/M; subscription-plan and free-tier endpoints deliberately show list prices too, so plan users see the standard value of their usage. Providers created from these presets going forward get the corrected values; previously added providers keep the config they were created with.
|
||||
|
||||
### A Batch of Small UI Fixes
|
||||
|
||||
- **AiHubMix icon**: the Codex app's AiHubMix preset was the only one missing its brand-icon fields and rendered a generic icon; it now matches the other apps.
|
||||
- **Two missing locale keys backfilled**: the reason fragment in Codex's "needs routing because it uses Anthropic Messages format" toast rendered in Chinese inside an otherwise-localized sentence (`proxyReasonAnthropicMessages` existed in no locale file), and the provider form's key-status loading label had shipped only as a hardcoded default since April; both now exist in zh / en / ja / zh-TW.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
### Codex ↔ Claude Routing Guides, Both Directions
|
||||
|
||||
Two new guides complete the pair — "Claude models inside the Codex client" and "Responses providers inside the Claude Code client":
|
||||
|
||||
- **[Using Claude Models in Codex](../guides/codex-claude-routing-guide-en.md)** (Chinese / English / Japanese, with screenshots): pairs with v3.17.0's native Anthropic Messages upstream to connect Codex to a Claude-family `/v1/messages` gateway; the v3.17.0 release notes now link to it.
|
||||
- **[Using GPT Models in Claude Code](../guides/claude-codex-routing-guide-en.md)** (Chinese / English / Japanese, with screenshots): drive Claude Code with Responses-speaking providers (a gateway API key, or a ChatGPT subscription's Codex service) — Claude Code always speaks Anthropic Messages to the local `/v1/messages` route, and the proxy converts each request to the upstream's Responses protocol.
|
||||
|
||||
### README Sponsor Updates
|
||||
|
||||
SubRouter joins the sponsor table across the four README languages; the pinned Kimi sponsor copy is refreshed to K3 with banners served from the Moonshot CDN; RunAPI benefit copy is refreshed, and sponsor rows are reordered to match the in-app preset order.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
### Automatic Database Migration and the One-Time Codex Usage Rebuild
|
||||
|
||||
Upgrading from v3.17.0 runs three schema migrations in sequence (v13 → v16): v14 rebuilds the `proxy_config` table to admit Grok Build (existing per-app proxy settings are all carried over, and a `grokbuild` row is added); v15 adds Grok Build enablement columns to the MCP-server and Skills tables; and v16 triggers the one-time automatic Codex usage rebuild — the database is first backed up under `backups/`, `codex_session` data and cursors are reset, and the normal startup sync re-imports everything with the corrected parser. Typical datasets take seconds; the heaviest dataset measured (1,801 rollout files / 1.5 GB) took about 65 seconds. Later launches are incremental as before. If you're in the habit of rolling back to older versions, back up `~/.cc-switch/cc-switch.db` yourself first.
|
||||
|
||||
**Please note on first launch**: the history repair completes **progressively** — the rebuild proceeds in the background with the startup sync, and during that window the Codex history numbers on the usage dashboard first drop to zero and then fill back in step by step. This is expected behavior, not data loss. The post-rebuild total will usually be **smaller** than before the upgrade: the part inflated by double counting is squeezed out, and what remains is your real usage.
|
||||
|
||||
### Boundaries of the Rebuild
|
||||
|
||||
- The rebuild recomputes usage from the rollout JSONL files, so **history whose source log was already deleted cannot be reconstructed**.
|
||||
- Fork files whose parent rollout is missing are deferred and reported instead of imported on a guess; restore the parent log and run "Rebuild Codex Usage" to import them later.
|
||||
- Historical proxy-source duplicate rows are permanently retained — the migration rebuilds only session-sourced data, and no cleanup pass for past proxy inflation exists; the idempotent logger only guarantees no new duplicates from this point on.
|
||||
|
||||
### Old Log Files Are Not Retroactively Scrubbed
|
||||
|
||||
From this release on, diagnostic logs are no longer cleared at startup and persist across restarts (up to ~100 MB of rotated runtime logs plus ~15 MB of crash logs). **Log files written by earlier versions are not retroactively scrubbed** and may contain API keys, tokens, or URLs with credentials — review pre-upgrade logs before sharing them publicly.
|
||||
|
||||
### Grok Build Installs via the Official Installer Script
|
||||
|
||||
Installing or reinstalling Grok Build now prefers the official xAI installer, fetching `x.ai/cli/install.sh` (or `install.ps1` on Windows) at install time, with npm as fallback; existing npm installs keep updating via npm.
|
||||
|
||||
### Built-In Pricing Rows Auto-Appended
|
||||
|
||||
New pricing rows (`grok-4.5`, `kimi-k3`, `k3`) are appended automatically on next launch via insert-if-absent; user-edited pricing rows are never overwritten.
|
||||
|
||||
---
|
||||
|
||||
## Risk Notice
|
||||
|
||||
### xAI Grok OAuth Sign-In (New in This Release — Please Read)
|
||||
|
||||
This release's xAI Grok OAuth integration **reuses the public OAuth client identity and scopes registered for the official Grok CLI** (`client_id b1a00492-073a-47ea-816f-4c329264a828`, scope including `grok-cli:access`), rather than an application identity registered by CC Switch. xAI may not support this use, and **it could lead to account restriction or suspension — use at your own risk**. The feature is entirely opt-in: if you don't add an xAI provider, nothing changes. On first login it creates `~/.cc-switch/xai_oauth_auth.json` (refresh tokens only, `0600` on Unix; access tokens are held in memory) and contacts `auth.x.ai` and `api.x.ai` through your configured outbound proxy, with no local callback port.
|
||||
|
||||
### Carried-Over Reverse-Proxy Notices
|
||||
|
||||
**Codex OAuth reverse proxy**: using a ChatGPT subscription's Codex OAuth through a reverse proxy may violate OpenAI's terms of service. See the [v3.13.0 release notes](v3.13.0-en.md#️-risk-notice) for details.
|
||||
|
||||
**Third-party provider routing**: when CC Switch's local proxy converts and forwards Codex, Claude Desktop, or Grok Build requests to third-party providers, each provider may have different requirements for billing, compliance, and data retention. Read the target provider's terms before use.
|
||||
|
||||
By enabling these features, users accept the related risks. CC Switch is not responsible for account restrictions, warnings, or service suspensions caused by using these features.
|
||||
|
||||
---
|
||||
|
||||
## Thanks
|
||||
|
||||
Thanks to the following contributors for the features and fixes in v3.18.0:
|
||||
|
||||
- [#5453](https://github.com/farion1231/cc-switch/pull/5453): first-class Grok Build support (the core of the eighth managed app), thanks @YUZHEthefool.
|
||||
- [#5508](https://github.com/farion1231/cc-switch/pull/5508): attach reasoning forward across the Responses→Chat bridge, thanks @ka79376046.
|
||||
- [#5310](https://github.com/farion1231/cc-switch/pull/5310): preserve streamed parallel tool-call identity and order, thanks @SaladDay.
|
||||
- [#5315](https://github.com/farion1231/cc-switch/pull/5315): default Codex tool parameters to an object schema, thanks @Komikawayi.
|
||||
- [#4706](https://github.com/farion1231/cc-switch/pull/4706): normalize tool parameter types for strict OpenAI-compatible providers, thanks @Ryan2128.
|
||||
- [#5356](https://github.com/farion1231/cc-switch/pull/5356): keep deleted default Skill repositories deleted, thanks @allenxu09.
|
||||
- [#4355](https://github.com/farion1231/cc-switch/pull/4355): first-run tray language follows the system locale, thanks @LaiYueTing.
|
||||
- [#5138](https://github.com/farion1231/cc-switch/pull/5138): extend backend CI to Linux / Windows / macOS, thanks @zayokami.
|
||||
|
||||
Thanks also to everyone who reported Codex usage anomalies, new-codex startup failures, and tool-call problems — this release's most important fixes came directly from reproduction clues in those real-world reports.
|
||||
|
||||
---
|
||||
|
||||
## Download & Install
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) and download the build for your system.
|
||||
|
||||
### System Requirements
|
||||
|
||||
| System | Minimum Version | Architecture |
|
||||
| ------- | -------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 and later | x64 / ARM64 |
|
||||
| macOS | macOS 12 (Monterey)+ | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------------------- | ------------------------------------------------ |
|
||||
| `CC-Switch-v3.18.0-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||
| `CC-Switch-v3.18.0-Windows-Portable.zip` | Portable build, unzip and run |
|
||||
|
||||
Windows ARM64 devices should pick the artifact whose file name carries the `arm64` tag.
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------- | ----------------------------------------------------- |
|
||||
| `CC-Switch-v3.18.0-macOS.dmg` | **Recommended** - DMG installer, drag to Applications |
|
||||
| `CC-Switch-v3.18.0-macOS.zip` | Unzip and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.18.0-macOS.tar.gz` | For Homebrew install and auto-update |
|
||||
|
||||
Homebrew install:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Upgrade:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux assets are available for both **x86_64** and **ARM64** (`aarch64`). Choose the file whose architecture tag matches your machine's `uname -m` output:
|
||||
|
||||
- `CC-Switch-v3.18.0-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.18.0-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| Distribution | Recommended Format | Install Command |
|
||||
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
|
||||
| Other distributions / unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,331 @@
|
||||
# CC Switch v3.18.0
|
||||
|
||||
> 本リリースでは、まったく新しい 2 つのことができるようになります。**xAI の Grok CLI(Grok Build)を CC Switch で管理する**——8 番目の管理対象アプリとして、プロバイダーのワンクリック切り替え、MCP / Skills 同期、プロキシテイクオーバー、使用量統計をすべて利用できます。そして **Grok を Claude Code、Claude Desktop、Codex へ接続する**——xAI Grok アカウントで直接サインインする方法(デバイスコード認証、API key 不要、Grok サブスクリプションで動作。Codex 側には厳格ゲートウェイ互換レイヤーがあり codex 0.142+ でも動きます)と、xAI API key で接続する方法(Codex にはネイティブ Responses 直結プリセット、Claude Code はローカルルーティング経由)の両方に対応します。修正も重要です。**v3.17.0 で混入した Codex 使用量の二重計上を修正**し、アップグレード後にデータを自動再構築してダッシュボードの数字を実態に戻します。**codex 0.144.5+ がモデルカタログ起因で起動できない問題も修正**。Windows でのプロバイダー切り替え時にコンソールウィンドウが一瞬表示されたり UI が固まったりする問題も解消しました。診断ログは「起動のたびに消去」から、再起動をまたいで保持・サイズローテーション・全面的な秘密情報除去へ移行し、UI クラッシュも真っ白な画面ではなくディスクに記録が残ります。
|
||||
|
||||
**[English →](v3.18.0-en.md) | [中文版 →](v3.18.0-zh.md)**
|
||||
|
||||
---
|
||||
|
||||
## ハイライト:本リリースでできること
|
||||
|
||||
- **Grok Build(xAI の Grok CLI)を管理**:Claude Code / Codex と同じように、Grok Build のプロバイダーを追加・インポート・ワンクリック切り替えできます。MCP サーバーと Skills は双方向同期、プロンプトは初回起動時に自動インポート、セッション管理と使用量ダッシュボードも完全対応。ローカルプロキシによるテイクオーバーで、独立したルーティング・failover・課金も利用できます。
|
||||
- **Grok の接続はアカウントでも API Key でも**:サブスクリプションユーザーは「設定 → OAuth 認証センター」でデバイスコードによる xAI サインインを行い(複数アカウント対応)、Claude Code・Claude Desktop・Codex の 3 クライアントが API key なしで Grok サブスクリプションで動きます。従量課金ユーザーは xAI API key で接続——Codex には `api.x.ai` へネイティブ直結する「xAI (Grok)」プリセットがあり、Claude Code は本リリースの新ガイドに沿ってローカルルーティングで接続できます。既定モデルはいずれも `grok-4.5` です。
|
||||
- **Codex の使用量を実態に戻す**:v3.17.0 の fork / サブエージェント二重計上をパーサーレベルで根治しました。アップグレード後の初回起動でデータベースをバックアップして Codex 使用量を自動再構築し、使用量ページには手動の「Codex 使用量を再構築」ボタンも追加。なお初回起動時の履歴修復は**段階的に**進みます——数字がいったん減り、バックグラウンドの再インポートに伴って徐々に埋め戻されるのは想定どおりの動作です(「アップグレード時の注意」参照)。
|
||||
- **codex CLI を安心してアップグレード**:codex 0.144.5 の厳格なモデルカタログ解析による「起動できない」問題を修正。生成カタログはパーサー必須フィールドを自動補完します。
|
||||
- **Windows でスムーズに切り替え**:プロバイダー切り替えやテイクオーバーの切り替えで黒いコンソールウィンドウが一瞬表示されることがなくなり、UI が約 2 秒固まる問題も解消しました(フリーズ修正は全プラットフォームに効きます)。
|
||||
- **ログの調査と共有をより安心に**:診断ログは再起動をまたいで保持(20 MB × 4 ローテーション)され、すべての出口で一律に秘密情報を除去——URL 内の認証情報、リクエスト / レスポンスボディ、機微なヘッダーはディスクに残りません。UI クラッシュはエラーカードと再読み込みボタンで受け止め、詳細をディスクへ記録します。
|
||||
- **マルチターンの reasoning と並列ツール呼び出しが壊れない**:Responses↔Chat ブリッジで、reasoning の誤った付け先、並列ツール呼び出しの ID 消失・順序入れ替わり、tool schema が null の場合にリクエスト全体が拒否される問題の 3 種類を修正しました。
|
||||
- **Kimi K3 を使う**:Codex / Hermes / OpenClaw / OpenCode の Kimi オープンプラットフォームプリセットに K3(1M コンテキスト)を追加し、内蔵価格も登録。使用量が $0 と表示されなくなります。
|
||||
|
||||
---
|
||||
|
||||
## 利用ガイド
|
||||
|
||||
本リリースの新機能は、主にプロバイダープリセット、「設定 → OAuth 認証センター」、使用量ダッシュボードにあります。以下のドキュメントもあわせてご覧ください:
|
||||
|
||||
- **[xAI Grok アカウントサインイン(設定 → OAuth 認証センター)](../user-manual/ja/1-getting-started/1.5-settings.md)**:デバイスコードログインの流れ、複数アカウント管理、連携の境界について説明します。利用前に下記「リスク通知」のクライアント識別情報の開示を必ずお読みください。
|
||||
- **[Claude Code で GPT モデルを使う(ローカルルーティングガイド)](../guides/claude-codex-routing-guide-ja.md)**(中国語 / 英語 / 日本語):本リリース追加のステップバイステップガイドです。Claude Code は常にローカルの `/v1/messages` ルートに対して Anthropic Messages を話し、ローカルプロキシが各リクエストを上流の Responses プロトコルへ変換します——ゲートウェイの API key、xAI のようなネイティブ Responses endpoint、ChatGPT サブスクリプションの Codex サービスのいずれにも使えます。
|
||||
- **[Codex で Claude モデルを使う(ローカルルーティングガイド)](../guides/codex-claude-routing-guide-ja.md)**:本リリース追加の 3 言語ガイド。v3.17.0 の「ネイティブ Anthropic Messages 上流」と組み合わせ、`/v1/messages` のみを提供する Claude 系ゲートウェイへ Codex を接続します。
|
||||
- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**:使用量ダッシュボードのデータソースと集計方法を確認できます。本リリースでは二重計上を修正し、「Codex 使用量を再構築」メンテナンス操作を追加しました。
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## 唯一の公式チャネル(必ずお読みください)
|
||||
>
|
||||
> CC Switch は**完全に無料・オープンソース**のデスクトップアプリで、**ユーザーから料金を徴収することはありません**。本ソフトウェアは下記の公式チャネルからのみ入手してください:
|
||||
>
|
||||
> | チャネル | 唯一の公式 |
|
||||
> | ------------ | ------------------------------------------------------------------------------ |
|
||||
> | 公式サイト | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | ソースコード | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | ダウンロード | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | 偽サイト通報 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **料金請求・チャージ・認証情報の提供を求める「CC Switch」サイトやクライアントはすべて偽物です。** 支払いを誘導された場合は直ちに操作を中止し、GitHub Issues からご報告ください。
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.18.0 の 2 本柱はどちらも xAI Grok を中心に据えています。1 つ目は **Grok Build の管理対象アプリ入り**です。xAI の Grok CLI(live 設定 `~/.grok/config.toml`)が、Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes に並ぶ 8 番目の管理対象アプリになりました——プロバイダーの追加 / インポート / ワンクリック切り替え、MCP・Skills の双方向同期、ディープリンクインポート、独立したプリセットリスト、専用ルート名前空間つきのプロキシテイクオーバー。併設の「Grok 公式」エントリは公式ログイン状態の検出とインポートに対応し、CC Switch が公式認証情報に触れることはありません。2 つ目は **xAI Grok アカウントの OAuth サインイン**です。デバイスコード認証が API key を置き換え、ローカルプロキシがリクエストごとにアクセストークンを注入します。Claude Code / Claude Desktop 側では Anthropic Messages → xAI Responses の変換を行い、Codex 側は管理下 OAuth プリセットと専用互換レイヤーを備えます——codex 0.142+ が発する ChatGPT バックエンド固有の形(namespace ツール宣言や非公開フィールド)を決定的に平坦化・除去し、厳格に解析する xAI ゲートウェイが 422 を返さなくなります。API key ユーザーには、変換を一切通さない「xAI (Grok)」ネイティブ Responses 直結プリセットも用意しました。
|
||||
|
||||
正確性の面では、**v3.17.0 の Codex 使用量二重計上**を集中的に修正しました。fork / サブエージェントログ冒頭にある親スレッド履歴のリプレイを新規使用量として取り込まなくなり(パーサーは明示的な親識別子とトークン署名の照合のみを信頼)、アップグレード後に一度だけ使用量を自動再構築(schema v16)、使用量ページには手動再構築ボタンを追加。プロキシ側の使用量記録は冪等になり(同じレスポンスのリプレイが重複行を積み上げない)、大量セッションのインポート中に使用量ページが固まる問題も解消しました。**Codex 変換レイヤー**にはさらに 4 つの修正——tool schema の object 型への正規化、reasoning のターンをまたぐ前方アタッチ、ストリーミング並列ツール呼び出しの ID と順序の保持、codex 0.144.5+ が要求するカタログフィールドの補完。診断まわりも成熟し、ログは再起動をまたいで保持・サイズローテーション・全出口で秘密情報除去、UI クラッシュはエラーバウンダリで捕捉してディスクへ記録します。ほかにも Kimi K3 のプリセットと価格、OpenClaw プリセット価格の修正、SudoCode.us の復活、トレイの初回言語がシステムロケールに従う改善などを含みます。
|
||||
|
||||
**リリース日**: 2026-07-21
|
||||
|
||||
**Stats**: 52 commits | 217 files changed | +21,452 / -6,285 lines
|
||||
|
||||
---
|
||||
|
||||
## 追加機能
|
||||
|
||||
### Grok Build:8 番目の管理対象アプリ
|
||||
|
||||
xAI の Grok CLI(Grok Build、live 設定 `~/.grok/config.toml`)が CC Switch の第一級市民になりました。プロバイダーの追加 / インポート / ワンクリック切り替え(切り替え後は「Grok Build を再起動して適用」のトースト表示)、アプリ表示と設定ディレクトリ上書きの設定、セッション管理と使用量ダッシュボード対応、初回起動時のプロンプト自動インポート、`ccswitch://` ディープリンクによるプロバイダーインポート、そしてローカルプロキシテイクオーバー——専用の `/grokbuild/v1/responses` ルート名前空間、独立した failover キュー、アプリ別プロキシ設定を持ちます。転送は Codex の Responses 経路を再利用しますが、Codex のプロバイダー名前空間や circuit breaker の状態を共有することはありません。
|
||||
|
||||
MCP サーバーは Grok の `[mcp_servers]` テーブルと双方向同期し、方言の差異も吸収します。Grok は `command` / `url` からトランスポートを推定し `headers` フィールドを使うため、エクスポート時は明示的な `type` を取り除き `http_headers` を `headers` にリネームし、インポート時は逆方向に推定して戻します。Skills にも Grok Build の有効化フラグが付きました。
|
||||
|
||||
プリセットはあえて Codex のリストを流用**していません**(初期の実装では中国向け直結プロバイダーや Codex の既定モデルが Grok のフォームに混入していました)。Grok Build 専用に整理したリストとして、実際に Grok モデルを扱うアグリゲーターと中継サービスのみを収録し、既定モデルは `grok-4.5`(名前空間型ルーターでは `x-ai/grok-4.5`)に正規化しています。ツールパネルでの Grok インストールは xAI 公式インストーラー(`x.ai/cli/install.sh` / `install.ps1`)を優先し、npm パッケージ `@xai-official/grok` をフォールバックにします。ネイティブと確認されたインストールは `grok update` で自己更新し、npm インストールは npm 経由の更新を維持——自己更新はネイティブ検出が確定した場合にのみ動くため、別種のインストールを壊すことはありません。4 言語の UI 文言も同期済みです。([#5453](https://github.com/farion1231/cc-switch/pull/5453))
|
||||
|
||||
### Grok 公式ログイン:検出・インポート・保護
|
||||
|
||||
新しい「Grok 公式」プロバイダーエントリは、Grok CLI 内蔵の xAI OAuth ログインに対応します。選択すると接続フィールドが隠れ、空の `~/.grok/config.toml` を書き込みます。CC Switch が公式認証情報を保存したり触れたりすることはありません。live 設定の読み取り・バックアップ・公式状態の書き込みは構文レベルのみの TOML 検証に変更され、公式ログイン状態(空の設定)が正常に往復します。Grok が公式ログイン状態のときに「live 設定からインポート」すると、エラーではなく「Grok 公式を現在のプロバイダーに設定しました」となり、Codex と同じ挙動になります。公式状態の検出は意図的に手動インポートコマンドだけに配線されています——起動時の自動インポートは公式状態の設定を引き続き拒否するため、削除した「Grok 公式」エントリが次回起動時に復活することはありません。公式ログイン設定へのプロキシテイクオーバーは自動的にスキップされ、手動操作でも明確に拒否されます。既存の「公式プロバイダーはプロキシしない」方針と一貫しています。
|
||||
|
||||
### xAI Grok アカウントでサインイン:Claude Code と Claude Desktop
|
||||
|
||||
Claude Code と Claude Desktop に「xAI (Grok)」プリセットが加わり、API key の代わりに OAuth デバイスコードログインを使います。リクエストはローカルプロキシを通り、Anthropic Messages → xAI Responses API の変換とリクエストごとのアクセストークン注入が行われます。既定モデルは全 tier で `grok-4.5` です(Claude Desktop プリセットは `claude-*` 形式のロール ID を上流の `grok-4.5` にマッピングし、Desktop のサードパーティモデル検証を通過させます)。
|
||||
|
||||
「設定 → OAuth 認証センター」に xAI セクションが追加されました。デバイスコードログイン(コピー可能なユーザーコード、確認リンク、待機 / キャンセル / 再試行)、複数アカウントと既定アカウントの選択、アカウント別の削除、再認証バッジ——refresh token が失効したアカウントは消えるのではなく「期限切れ」として表示され続け、認証状態は 15 秒ごとに再取得されるため、サーバー側の失効も自然に表面化します。
|
||||
|
||||
連携の境界は固定されています。フォームの endpoint / 形式フィールドをどう変えても上流は常に `https://api.x.ai/v1/responses`(Responses 形式)。OAuth endpoint は OIDC discovery で解決しますが https の `auth.x.ai` のみに厳格検証。refresh token は `~/.cc-switch/xai_oauth_auth.json`(Unix ではパーミッション `0600`、access token はメモリのみ)。OAuth のエラーボディはエラーメッセージにもログにも決して入りません。`grok-4.5` の価格(100 万 token あたり入力 $2 / 出力 $6 / キャッシュ読み取り $0.50)も登録され、使用量が $0 と記録されなくなります。既存データベースは次回起動時に自動で行を取り込みます。4 言語対応。利用前に「リスク通知」のクライアント識別情報の開示をお読みください。
|
||||
|
||||
OAuth を使わず、従量課金の xAI API key しかない場合でも、Claude Code へ接続できます。xAI の API endpoint は標準の Responses プロトコルなので、通常の Responses プロバイダーとして追加してください——カスタムプロバイダーに `https://api.x.ai/v1` と API key を設定し、上流形式に Responses を選ぶと、ローカルルーティングが Anthropic Messages ↔ Responses を変換します。[Claude Code で GPT モデルを使う](../guides/claude-codex-routing-guide-ja.md)ガイドと同じ手順です。Codex 側には既製の API key プリセットがあります(次節参照)。
|
||||
|
||||
### Codex から xAI へ直結:管理下 OAuth と API Key ネイティブの 2 プリセット
|
||||
|
||||
Codex には xAI へ直結する 2 つの道があります——Grok サブスクリプションなら管理下 OAuth、API key ならネイティブ直結です:
|
||||
|
||||
- **「xAI (Grok) OAuth」管理下プリセット**:Codex を Grok サブスクリプションで動かします。フォームは key / endpoint / 形式フィールドを隠してアカウント選択を表示し、「モデルを取得」はサインイン済みアカウントで実行。プロバイダーはネイティブ Responses に固定され、base URL とリクエストごとのトークンはプロキシが強制します——編集しても無視されるため、管理下ルートを別の宛先へ向けることはできません。codex 0.142+ は ChatGPT バックエンド固有のリクエスト形を発します(`type:"namespace"` ツール宣言は xAI の厳格パーサーで即 422。ほかに `prompt_cache_retention`、`safety_identifier`、`external_web_access`、`additional_tools` キャリア、grok-4.5 が対応しないサンプリングパラメータ)。そのため OAuth ルートはネイティブパススルーの上に互換レイヤーを重ねます。namespace ツールはトップレベルの function ツールへ平坦化(Chat 経路と同じ sha256 切り詰め命名)し、レスポンス側ではストリーミング・非ストリーミングの両方で namespace 形へ復元、未対応フィールドは除去——すべて決定的なフィールド削除 / 構造変換であり、意味的な書き換えは一切ないため、プロンプトキャッシュの前方一致は安定を保ちます。このレイヤーは xAI OAuth プロバイダー型だけにゲートされ、他のプロバイダーのトラフィックには触れません。
|
||||
- **「xAI (Grok)」API Key プリセット**:`api.x.ai/v1` のネイティブ Responses に直結し、500K コンテキストの `grok-4.5` カタログエントリを内蔵します。このプリセットには上記の xAI 専用互換変換は**適用されません**——codex 0.142+ の API key ユーザーは依然 xAI の厳格パーサーに当たる可能性があり、完全互換の道は OAuth プリセットです。
|
||||
|
||||
xAI OAuth のトークンエラーは再試行不能に分類されるため、failover が会話を別の Grok アカウントへ静かに移すことはありません。
|
||||
|
||||
### UI クラッシュの捕捉:エラーのディスク保存と再読み込み画面
|
||||
|
||||
React のエラーバウンダリが UI 全体(データベース復旧画面を含む)を包むようになりました。レンダラーがクラッシュすると、真っ白なウィンドウの代わりに「インターフェースでエラーが発生しました」カードと再読み込みボタンが表示され、グローバルの `error` / `unhandledrejection` ハンドラーがレンダラーのエラーをディスクへ永続化します——以前は JS クラッシュの証拠がディスクに一切残りませんでした。フロントエンドが書き出すログはすべて二層の秘密情報除去を通ります。構造化シリアライザーが機微なプロパティ名(`tokens` / `apiKeys` / `credentials` の変種を正規化して照合し、ネストされたオブジェクトを含む値全体を隠す)と値の形(トークン接頭辞、PEM ヘッダー、高エントロピーな不透明文字列)で除去し、次に唯一のテキスト出口の順序付き正規表現チェーンが URL クエリ値と認証情報、認証ヘッダーとスキーム、名前付きシークレットコンテナ(二重エンコードされた JSON も対象)をカバーします。文字列として届いた JSON は再パースして構造的に除去し、過大な構造化入力は切り詰めではなく全体を破棄します——切り詰められた JSON 文字列は弱いテキスト正規表現にフォールバックして漏えいの恐れがあるためです。設定のトグル名も実態に合わせて変更しました。「アプリ診断ログ」(cc-switch.log)と、プロキシの「リクエスト使用量を記録」(統計データベースであり、もともとテキストログではありません)です。4 言語同期。
|
||||
|
||||
### 「Codex 使用量を再構築」メンテナンス操作
|
||||
|
||||
使用量ダッシュボードのメンテナンスに「Codex 使用量を再構築」が加わりました。データベースをバックアップした後、`codex_session` ソースの明細行、対応する `_codex_session` 日次集計、Codex 同期カーソルだけを消去し、修正済みパーサーで全 rollout ファイルを最初から再インポートします——下記の二重計上バグで膨張したデータベースの復旧経路であり、親ログが復元された保留 fork ファイルの再試行経路でもあります。手動再構築は事前バックアップを書き込めない場合にハードフェイルします(自動移行版は警告のみ。アップグレード後にバックアップディレクトリ書き込み不能で起動がブロックされる方が悪い結果だからです)。バックアップ → リセット → 再インポートの全工程はセッション同期ロックを保持し、60 秒間隔のバックグラウンド同期が消去と交錯できません。完了時にはちょうど 1 回のフロントエンド更新通知を必ず送ります——再インポートが 0 行やエラーで終わった場合も含めて——のでダッシュボードがリセット前の数字のまま止まることはありません。カーソルの掃除はパスの形(`sessions` / `archived_sessions` セグメント配下の `rollout-{uuid}` ファイル名)だけで照合するため、旧 `CODEX_HOME` 配下に記録されたカーソルも掃除できます。4 言語同期。
|
||||
|
||||
### セッションインポートの可観測性:保留ファイルと重複疑い
|
||||
|
||||
セッション同期の結果に `filesScanned`、`deferredFiles`、`suspectedDuplicates` が加わりました。`deferredFiles` は、親ログの欠落や親マーカーの矛盾がある fork rollout をカーソルを書かずに保留し、後の同期や手動再構築で再試行するものです——推測でインポートしません。`suspectedDuplicates` は、挿入後に各行を同一フィンガープリントの既存行と照合(`idx_request_logs_dedup_lookup_expr` 式インデックス経由)し、ヒットごとに警告を記録します。二重計上バグが将来再発しても、総量を静かに膨らませるのではなく、ログの中で自ら名乗り出ます。
|
||||
|
||||
### Kimi K3 のプリセットと内蔵価格
|
||||
|
||||
Codex / Hermes / OpenClaw / OpenCode の Kimi オープンプラットフォームプリセットに Kimi K3(1M コンテキストウィンドウ)を追加しました。K2.7 Code の後ろに追加したため、既存の既定モデルの挙動は変わりません。内蔵価格テーブルには `kimi-k3`(公式表示価格:100 万 token あたり入力 $3 / 出力 $15 / キャッシュ読み取り $0.30)と裸の `k3` エイリアスを追加——Kimi For Coding サブスクリプションはモデルを短縮 id `k3` で報告するため、これがないとどの価格行にも一致しません(既存の `hunyuan-hy3` / `hy3` ペアと同じ前例です)。既存データベースは次回起動時に両行を自動で取り込み、ユーザーが編集した価格には触れません。
|
||||
|
||||
### SudoCode.us が SudoCode.chat と並んで復活
|
||||
|
||||
たまたま「SudoCode」という名前を共有する無関係な 2 社が、別々のプリセットになりました。スポンサーは「SudoCode.chat」に改名し、以前その場で置き換えられていた「SudoCode.us」が元の endpoint・モデル・アイコンとともに復帰。Hermes の slug も区別され、追記型の `~/.hermes/config.yaml` の中で両者が共存できます。新しい Grok Build プリセットリストを含めると、SudoCode.chat は 7 アプリ、SudoCode.us は全 8 アプリに収録されています。
|
||||
|
||||
---
|
||||
|
||||
## 変更
|
||||
|
||||
### 診断ログ:再起動をまたいで保持、サイズローテーション、秘密情報は記録しない
|
||||
|
||||
`cc-switch.log` は起動のたびに消去されなくなりました——クラッシュを説明できたはずのログが、アプリを開き直したときには消えていたのです。代わりに 20 MB でローテーションし、アーカイブを 4 つ保持します(上限約 100 MB。以前は単一ファイルが 1 GB まで膨張し得ました)。これまで無制限だった `crash.log` は 5 MB ローテーション・アーカイブ 2 つになり、チェック / ローテーション / 追記の一連の処理を 1 つのロックで包んで、並行 panic がアーカイブを失わないようにしました。
|
||||
|
||||
ログの永続化は、秘密情報の平文がそのまま露出リスクになることを意味します(ユーザーはこれらのファイルを公開 issue に添付します)。そこで同じ変更で、バックエンドのすべてのログ出口を洗浄しました。上流 URL は userinfo / query / fragment を取り除いた形でのみ記録(置換できる既知のシークレットがない場合は origin のみ。パスに認証情報が埋まっている可能性があるため)。リクエストとレスポンスのボディは一切記録せず、バイト数、短いハッシュ、または安全な分類(`sse` / `html` / `json-like` / `binary-or-encoded` など)に置き換え——変換デバッグに必要なシグナルは残り、中身は残りません。レスポンスヘッダーは許可リスト方式(リスト外は名前のみ記録)。現在使用中のシークレット値(API key、access token)は、それを含むあらゆる記録 URL から置換されます。MCP カスタムフィールドの値は省略。ログプラグインの登録は早められ(アップデーターや起動時の障害が診断可能に)、永続化されたログレベルはデータベースが開き次第適用され、障害時は Info へ安全側に倒れます。「診断ログを有効化」スイッチはフロントエンド発のログ書き込みも制御するようになりました。**アップグレード前の古いログファイルは遡って洗浄されません**——「アップグレード時の注意」を参照してください。
|
||||
|
||||
### プリセット選択:スポンサーをグループ化し、残りは名前順
|
||||
|
||||
プリセット選択の既定順序が 4 層になりました。公式が最初、次に主要パートナー、その後にスポンサープリセット(README のスポンサー表と同じ順序。プリセットファイルも物理的に並べ替えて揃えました)、最後に残りすべてをファイル順ではなく表示名のアルファベット順で並べます。複数の層に該当するエントリは最も早い層にだけ現れ、重複表示はありません。
|
||||
|
||||
### プリセットの「API Key を取得」リンクを更新
|
||||
|
||||
RunAPI、ClaudeCN、ZetaAPI、APINebula プリセットの key 申請リンクを、各社の現在の登録 / 紹介ページに更新しました(ClaudeCN はドメインも移転:claudecn.top → claudecn.ai)。紹介タグはこれらのリンクと README に限定され、公式サイトリンクと API endpoint には手を付けていません。
|
||||
|
||||
---
|
||||
|
||||
## 修正
|
||||
|
||||
### Codex fork / サブエージェントのリプレイ済み親履歴を新規使用量として計上しない(v3.17.0 の二重計上を根治)
|
||||
|
||||
v3.17.0 の使用量膨張を修正します。Codex タスクの fork やコピーモードでのサブエージェント生成で、親会話の token 履歴が新規使用量として再計上されていました——1 日の使用量が数十億 token 跳ね上がる、親子でバイト単位に同一の行が並ぶ、空の fork が消費していない使用量を背負う、といった報告がありました。fork / サブエージェントの rollout ファイルは冒頭で親スレッドの履歴をリプレイします。旧パーサーは引き継ぎ境界をヒューリスティックに探していました(最初の `thread_settings_applied` イベント、オブジェクト形の `subagent` ソースマーカー)。親自身の設定変更がリプレイに現れると境界が早すぎる位置に落ち、現行の文字列形ソースマーカーはまったく認識されず、リプレイされた親履歴が丸ごとインポートされていました。新パーサーは明示的な親識別子だけを信頼します——子の `session_meta` の `forked_from_id` または `source.subagent.thread_spawn.parent_thread_id`。両者が矛盾する場合はファイルを保留します。スレッドの識別は rollout ファイル名の UUID に固定し、親 rollout 自身の fork 前 token カウント系列を読み込み、トークン署名の照合で子のリプレイ済みプレフィックスを取り除きます。リプレイイベントは累積ベースラインの復元にのみ使われ、行として挿入されることはありません。リプレイ履歴を持たないサブエージェントログは実使用量として計上されるようになり、実際のサブエージェント消費がリプレイ疑いとしてスキップされる逆方向の計上漏れも同時に修正しました。([#5335](https://github.com/farion1231/cc-switch/issues/5335)、[#5433](https://github.com/farion1231/cc-switch/issues/5433)、[#5381](https://github.com/farion1231/cc-switch/issues/5381))
|
||||
|
||||
### プロキシ使用量記録を冪等に:レスポンス由来の安定キー
|
||||
|
||||
終端の使用量イベントにメッセージ id がない場合(ローカルプロキシ経由の Codex `/responses` トラフィックでは通常)、重複排除キーはランダム UUID にフォールバックしていました——同じ上流レスポンスの再試行 / リプレイのたびに新しいキーが作られ、`INSERT OR REPLACE` が毎回新しい行を積み上げます。ある報告者のデータベースには同じ使用量の組み合わせが 2,078 回入っていました。パーサーはキーをレスポンスのエンベロープ自体から取るようになりました——Codex `response.completed` イベントの `response.id`(`response.created` の id は破棄)、Chat Completions の `chatcmpl` id、Gemini の `responseId`——スコープは `session:{app_type}:{provider_id}:{id}`。failover で同じレスポンスが別プロバイダーに再生された場合もプロバイダーごとに 1 回ずつ課金され、プロバイダー間で衝突しません(Claude は裸の `session:{id}` 形を維持し、プロキシ行はセッションログのインポートと収束し続けます)。エンベロープ id がまったくない場合のフォールバックは、ランダム UUID ではなくレスポンスの使用量セマンティクスに対する決定的 SHA-256 です——同一のリプレイが同じキーに衝突しなければ重複排除は成立しません。最終的なデータベース書き込みも、無条件の REPLACE から重複排除ウィンドウ内の「存在しなければ挿入」に変わりました。([#5496](https://github.com/farion1231/cc-switch/issues/5496))
|
||||
|
||||
### 大量セッションインポート中に使用量ページが固まらない
|
||||
|
||||
大きなインポートの実行中に使用量ページを開くと、UI 全体が固まることがありました。行を挿入するたびに更新通知が飛び、通知のたびにフロントエンドが約 10 個の使用量クエリをすべて再実行し、それらのクエリが、数十 MB の rollout ファイルを 1 行ずつ解析中のインポーターと唯一のデータベース接続を奪い合う——重複で膨張したデータベースでは三者が互いに増幅し合っていました。セッション同期は 1 パス完了ごとに 1 回だけ通知するようになり、すべてのセッションインポーターはシングルフライトロックの後ろで直列化され(手動の「今すぐ同期」は実行中のパスと競争せず、後ろに並びます)、ブロッキングな解析処理は専用のブロッキングスレッドへ移されて UI コマンドを駆動する非同期ランタイムを飢えさせなくなり、60 秒間隔のバックグラウンド実行は取りこぼし分をまとめて実行せずスキップします。
|
||||
|
||||
### codex 0.144.5+ が CC Switch 生成のモデルカタログで起動不能にならない
|
||||
|
||||
codex ≥ 0.144.5 は外部モデルカタログを厳格に解析し、エントリに `supports_reasoning_summaries` がないとファイル全体を拒否します——Codex CLI もデスクトップアプリも起動できず、生成カタログを削除しても、プロバイダーを保存するたびに同じ形で再生成されるため解決しませんでした。根本原因は、CC Switch がカタログのテンプレートをマシン共有の `models_cache.json` から複製していることです。そのフィールド構成は最後に書き込んだ codex プロセス次第で、共存する旧版 codex が新パーサー必須のフィールドを持たない形でキャッシュを書き換え続けていました。生成カタログは、同梱の静的テンプレートからパーサー必須フィールドを補完するようになりました。補完は欠けている場合のみで(動的な値が常に優先)、「欠落 = パーサー既定値」のセマンティクスを保つべきオプションの能力フィールドは意図的に補完しません。
|
||||
|
||||
### Windows:プロバイダー切り替えでコンソールが一瞬表示されず、固まらない
|
||||
|
||||
Windows でプロバイダーを切り替えたりテイクオーバーを切り替えたりすると、コンソールウィンドウが一瞬表示され、UI が最大約 2 秒固まっていました。原因は 3 つ、修正も 3 つです。`codex debug models --bundled` プローブは `cmd.exe` 経由で `codex.cmd` を起動しますが、GUI サブシステムのアプリではこれが独自のコンソールを生成します——子プロセスは `CREATE_NO_WINDOW` で作成するようにしました。モデルカタログのテンプレートは切り替えのたびに再生成されていました——初回の成功ロード後にプロセス全体でキャッシュし(失敗は再試行可能のままなので、初回プローブの失敗がキャッシュを汚しません)、Codex CLI の起動はアプリ実行ごとに最大 1 回になりました。`switch_provider` はメインスレッド上の同期コマンドでした——非同期化して実処理をブロッキングスレッドに移し、アプリ別の切り替えロックによる直列化は維持しています。フリーズ修正は全プラットフォームに効き、コンソール表示の修正は Windows 固有です。
|
||||
|
||||
### tool schema が null / 欠落 / union 型でも厳格上流に拒否されない
|
||||
|
||||
Codex の組み込みツール(`codex_app__automation_update` など)は `parameters: null`(または `type: null`)を宣言しますが、DeepSeek のような厳格な OpenAI 互換上流はリクエスト全体を 400 で拒否し、プロキシ経由のツール利用セッションが死んでいました。Responses→Chat ブリッジはすべてのツールの parameters を `type:"object"` schema に正規化します。null または欠落(ネスト形の欠落を含む)は `{"type":"object","properties":{}}` に、object 以外の `type`(`type: null` を含む)はその場で `"object"` に修正、トップレベルの `oneOf` union schema にはルートの `type:"object"` を追加して分岐はそのまま保持します。同じ object 型の保証を Codex→Anthropic ツール経路の `input_schema` にも拡張しました。既存の `properties` / `required` を捨てることはありません。([#4706](https://github.com/farion1231/cc-switch/pull/4706)、[#5315](https://github.com/farion1231/cc-switch/pull/5315)、#4705・#4783 を修正)
|
||||
|
||||
### 推論モデルがマルチターンの Codex Chat 会話で思考を失わない
|
||||
|
||||
推論モデル(kimi-k2-thinking など)をプロキシの Responses→Chat ブリッジで使うと、マルチターン履歴が思考を壊していました。各ターンの `reasoning` 項目が**前の**アシスタントメッセージの末尾に貼り付けられ、直後のアシスタントターンに `reasoning_content` がなくなる——モデルが会話の途中で目に見えて壊れていました。Responses のセマンティクスでは reasoning は属するメッセージの**前**に置かれます。ブリッジは reasoning を、その後に続くアシスタントメッセージまたはツール呼び出しへ前方アタッチするようになりました。本物の末尾 reasoning だけが、確定した末尾(入力の終端、またはユーザーメッセージのようなターン境界——以前はここで静かに破棄されていました)で後方アタッチされ、既に埋め込まれた reasoning があれば追記します。保留中の reasoning は境界で必ず消費されるため、ユーザーターンを越えて後のアシスタントメッセージへ漏れることはありません。([#5508](https://github.com/farion1231/cc-switch/pull/5508))
|
||||
|
||||
### ストリーミング並列ツール呼び出しの ID と順序を保持
|
||||
|
||||
Chat→Responses ストリーミングブリッジの 2 つのバグが、チャンクをまたいで識別子が分割される上流からの並列ツール呼び出しを壊していました。空の `id` を持つ継続デルタが本物の `call_id` を上書きし(Codex クライアントは `call_id:""` を見てツール結果を呼び出しに対応付けられません)、ツール呼び出しは個別に準備でき次第送出されていたため、名前が先に届いた後方インデックスが前方インデックスを追い越し、並列呼び出しの順序が入れ替わっていました。空の id は無視されるようになり、送出は連続インデックスゲートを通って Chat の `index` 順で厳密に解放され、まだ識別されていない前方インデックスを待ちます。ストリーム途中で偽の call id を合成することはありません(ストリーム終端の最終手段としてのみ。そこでも名前のない呼び出しは防御的にスキップし、疎なインデックスはそのまま送出します)。([#5310](https://github.com/farion1231/cc-switch/pull/5310))
|
||||
|
||||
### 管理下 OAuth プロバイダーを確実に「ローカルルーティング必要」と表示
|
||||
|
||||
「ルーティング必要」バッジと切り替え時の警告は、プロバイダーの API 形式から導出されていました。管理下 OAuth プロバイダー(Copilot、Codex OAuth、xAI)にとってこれは誤ったシグナルです——認証情報は上流形式に関係なくプロキシが注入するため、ネイティブ形式の管理下プロバイダーは警告を受けず、テイクオーバーなしで静かに失敗していました。ルーティングの要否は単一の共有述語で決まるようになりました。公式プロバイダーは決してルーティングを必要とせず、管理下 OAuth プロバイダーは常に必要とし、形式ベースのルールは残りのケースにのみ適用されます。切り替え時のゲートもアプリごとに正しい準備状態を確認します。ほとんどのアプリはアプリ別テイクオーバー状態(旧ゲートはグローバルなプロキシ実行フラグだけを見ており、「プロキシは動いているが当該アプリはテイクオーバーされていない」を見逃していました)、Claude Desktop は引き続きプロキシプロセス自体を監視します——バックエンドのテイクオーバー状態に Claude Desktop のフィールドがないため、一律のアプリ別ゲートでは Desktop が永遠に警告を出し続けます。Claude Desktop のプロバイダーフォームは、xAI だけでなくすべての管理下 OAuth 型でプロキシモードを強制し、モデルマッピングトグルをロックします。4 言語同期。
|
||||
|
||||
### Node が nvm / fnm / mise にある場合でもツール更新が動く
|
||||
|
||||
アンカー付き npm の更新・修復コマンドは npm を絶対パスで呼び出しますが、npm のランチャーは `#!/usr/bin/env node` shebang で PATH から `node` を解決します——GUI 起動のアプリはシステム PATH しか継承せず、バージョンマネージャーのディレクトリを含まないため、nvm / fnm / mise でインストールしたツールの更新は静かに失敗していました。すべてのアンカー付き npm 呼び出しは、npm 自身の隣にある `bin` ディレクトリを PATH の先頭に追加するようになり、npm とその shebang が同じ Node インストールを解決します。Codex の自己修復(アンインストール + 再インストール)経路もカバーしています。
|
||||
|
||||
### 削除した既定 Skill リポジトリが復活しない
|
||||
|
||||
既定の Skill リポジトリは、起動のたびに「欠けている既定を補充」する処理で再シードされ、削除した既定リポジトリが次回起動時に静かに戻っていました。シードはデータベースごとに 1 回だけになり、設定フラグで記録されます。アップグレード時に既にリポジトリを持つデータベースは再シードなしでフラグが立つため、既存の選択には触れません。([#5356](https://github.com/farion1231/cc-switch/pull/5356))
|
||||
|
||||
### トレイの初回言語がシステムロケールに従う
|
||||
|
||||
設定でまだ言語を選んでいない場合、トレイメニューは簡体字中国語に固定されていました——英語 / 日本語 / 繁体字中国語のシステムでは、メイン UI は正しく OS ロケールに従うのにトレイだけが食い違い、ユーザーが一度言語を切り替えるまで続いていました。トレイはフロントエンドと同じ優先順位で OS ロケールから初回言語を導出するようになりました(`zh-TW` / `zh-HK` / `zh-Hant` → 繁体字中国語を含む)。明示的に選んだ言語が常に優先され、ロケールを読めない場合は従来どおり中国語へフォールバックします。([#4355](https://github.com/farion1231/cc-switch/pull/4355))
|
||||
|
||||
### インポート失敗時に実際のエラーを表示しリストを更新
|
||||
|
||||
「live 設定からインポート」が失敗するたびに空のエラートーストが出ていました。Tauri の `invoke` はバックエンドのエラー**文字列**で reject するのに、ハンドラーがその上の `.message` を読んでいたためです。バックエンドの実際のメッセージを表示するようになり(ローカライズされた汎用フォールバック付き)、失敗時にもプロバイダーリストを更新するため、エラー前にコミットされた副作用がすぐ見えます。
|
||||
|
||||
### OpenClaw プリセットのモデルコストを公式表示価格に修正
|
||||
|
||||
OpenClaw プリセットの 15 エントリが、誤った単位や未換算の通貨でコスト値を持っていました——`cost` フィールドは 100 万 token あたりの USD ですが、たとえば `glm-5.1` は `0.001/0.001`(約 1000 分の 1 に過小評価され、使用量のコストがほぼ 0 と表示)、`deepseek-v4-pro` は未換算の人民元値(過大評価)でした。全エントリを $/M の公式表示価格に修正。サブスクリプションプランや無料 tier の endpoint も意図的に表示価格を示し、プランユーザーが自分の使用量の標準価値を見られるようにしています。今後プリセットから作成するプロバイダーには修正値が入り、作成済みのプロバイダーは作成時の設定を保持します。
|
||||
|
||||
### UI の小さな修正
|
||||
|
||||
- **AiHubMix アイコン**:Codex アプリの AiHubMix プリセットだけがブランドアイコンのフィールドを欠き、汎用アイコンで表示されていました。他のアプリと揃いました。
|
||||
- **欠落していた 2 つのロケールキーを補完**:Codex の「Anthropic Messages 形式のためルーティングが必要」トーストの理由部分が、ローカライズ済みの文の中で中国語のまま表示されていました(`proxyReasonAnthropicMessages` がどの言語ファイルにも存在しなかったため)。プロバイダーフォームの key 状態読み込みラベルも 4 月からハードコードの既定値のみでした。両方とも zh / en / ja / zh-TW に揃いました。
|
||||
|
||||
---
|
||||
|
||||
## ドキュメント
|
||||
|
||||
### Codex ↔ Claude 双方向ルーティングガイド
|
||||
|
||||
「Codex クライアントで Claude モデルを使う」「Claude Code クライアントで Responses プロバイダーを使う」の両方向が、2 つの新ガイドで揃いました:
|
||||
|
||||
- **[Codex で Claude モデルを使う](../guides/codex-claude-routing-guide-ja.md)**(中 / 英 / 日 3 言語、スクリーンショット付き):v3.17.0 のネイティブ Anthropic Messages 上流と組み合わせ、Claude 系の `/v1/messages` ゲートウェイへ Codex を接続します。v3.17.0 のリリースノートからもリンクされています。
|
||||
- **[Claude Code で GPT モデルを使う](../guides/claude-codex-routing-guide-ja.md)**(中 / 英 / 日 3 言語、スクリーンショット付き):Responses を話すプロバイダー(ゲートウェイの API key、または ChatGPT サブスクリプションの Codex サービス)で Claude Code を動かします——Claude Code は常にローカルの `/v1/messages` ルートへ Anthropic Messages を話し、プロキシが各リクエストを上流の Responses プロトコルへ変換します。
|
||||
|
||||
### README のスポンサー欄を更新
|
||||
|
||||
SubRouter が 4 言語 README のスポンサー表に加わりました。ピン留めの Kimi スポンサー文言は K3 に更新され、バナーは Moonshot CDN から配信されます。RunAPI の特典文言も更新し、スポンサー行の順序をアプリ内プリセット順に揃えました。
|
||||
|
||||
---
|
||||
|
||||
## アップグレード時の注意
|
||||
|
||||
### データベース自動移行と Codex 使用量の一回限りの再構築
|
||||
|
||||
v3.17.0 からのアップグレードでは、3 つの schema 移行(v13 → v16)が連続実行されます。v14 は Grok Build を受け入れるために `proxy_config` テーブルを再構築(既存のアプリ別プロキシ設定はすべて引き継ぎ、`grokbuild` 行を追加)。v15 は MCP サーバーテーブルと Skills テーブルに Grok Build 有効化列を追加。v16 は一回限りの Codex 使用量自動再構築をトリガーします——データベースをまず `backups/` 配下へバックアップし、`codex_session` データとカーソルをリセットし、その後通常の起動時同期が修正済みパーサーですべてを再インポートします。一般的なデータ量なら数秒。実測で最も重いデータセット(rollout ファイル 1,801 個 / 1.5 GB)は約 65 秒でした。以後の起動は従来どおり増分です。旧バージョンへ戻す可能性がある場合は、先に `~/.cc-switch/cc-switch.db` を自分でバックアップしてください。
|
||||
|
||||
**初回起動時の注意**:履歴の修復は**段階的に**完了します——再構築は起動時同期と共にバックグラウンドで進み、その間、使用量ダッシュボードの Codex 履歴の数字はいったんゼロになり、その後少しずつ埋め戻されます。これは想定どおりの動作で、データ損失ではありません。再構築後の合計は通常、アップグレード前より**小さく**なります。二重計上で膨らんでいた分が絞り出され、残ったものが実際の使用量です。
|
||||
|
||||
### 再構築の境界
|
||||
|
||||
- 再構築は rollout JSONL ファイルから使用量を再計算するため、**ソースログが既に削除された履歴は復元できません**。
|
||||
- 親 rollout が欠けている fork ファイルは、推測でインポートせず保留として報告されます。親ログを復元してから「Codex 使用量を再構築」を実行すると取り込めます。
|
||||
- 過去のプロキシソースの重複行は恒久的に残ります——移行が再構築するのはセッションソースのデータだけで、過去のプロキシ膨張を掃除する処理は存在しません。冪等ロガーが保証するのは、今後新しい重複が生まれないことだけです。
|
||||
|
||||
### 古いログファイルは遡って洗浄されません
|
||||
|
||||
本リリースから診断ログは起動時に消去されず、再起動をまたいで保持されます(ローテーション済みランタイムログ最大約 100 MB、クラッシュログ約 15 MB)。**旧バージョンが書き込んだログファイルは遡って洗浄されず**、API key、トークン、認証情報付き URL を含む可能性があります——公開の場で共有する前に、アップグレード前のログを確認してください。
|
||||
|
||||
### Grok Build のインストールは公式インストーラースクリプトを使用
|
||||
|
||||
Grok Build のインストール・再インストールは xAI 公式インストーラーを優先し、インストール時に `x.ai/cli/install.sh`(Windows では `install.ps1`)を外部取得します。npm はフォールバックです。既存の npm インストールは引き続き npm 経由で更新されます。
|
||||
|
||||
### 内蔵価格の行を自動追加
|
||||
|
||||
新しい価格行(`grok-4.5`、`kimi-k3`、`k3`)は次回起動時に「存在しなければ挿入」で自動追加されます。ユーザーが編集した価格行が上書きされることはありません。
|
||||
|
||||
---
|
||||
|
||||
## リスク通知
|
||||
|
||||
### xAI Grok OAuth サインイン(本リリース新規——必ずお読みください)
|
||||
|
||||
本リリースの xAI Grok OAuth 連携は、**公式 Grok CLI 用に登録された公開 OAuth クライアント識別情報とスコープを再利用します**(`client_id b1a00492-073a-47ea-816f-4c329264a828`、scope に `grok-cli:access` を含む)。CC Switch 自身が登録したアプリケーション識別情報ではありません。xAI がこの利用方法を認めない可能性があり、**アカウントの制限や停止につながる恐れがあります——自己責任でご利用ください**。この機能は完全にオプトインです。xAI プロバイダーを追加しなければ、何も変わりません。初回ログイン時に `~/.cc-switch/xai_oauth_auth.json` を作成し(refresh token のみ保存、Unix ではパーミッション `0600`、access token はメモリのみ)、設定済みのアウトバウンドプロキシ経由で `auth.x.ai` と `api.x.ai` へ接続します。ローカルのコールバックポートはありません。
|
||||
|
||||
### 引き続き適用されるリバースプロキシ系の注意
|
||||
|
||||
**Codex OAuth リバースプロキシ**:ChatGPT サブスクリプションの Codex OAuth をリバースプロキシ経由で使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 release notes](v3.13.0-ja.md#️-リスクに関する注意事項) を参照してください。
|
||||
|
||||
**サードパーティプロバイダーのルーティング**:CC Switch ローカルプロキシで Codex、Claude Desktop、Grok Build のリクエストを変換してサードパーティプロバイダーへ転送する場合、課金・コンプライアンス・データ保持に関する制約はプロバイダーごとに異なります。利用前に対象プロバイダーの利用規約を確認してください。
|
||||
|
||||
上記機能を有効化したユーザーは、関連するリスクを自ら負うものとします。CC Switch は、これらの機能の利用によって発生したアカウント制限、警告、サービス停止について責任を負いません。
|
||||
|
||||
---
|
||||
|
||||
## 謝辞
|
||||
|
||||
v3.18.0 で機能と修正を届けてくださった以下のコントリビューターに感謝します:
|
||||
|
||||
- [#5453](https://github.com/farion1231/cc-switch/pull/5453):Grok Build の第一級サポート(8 番目の管理対象アプリの中核実装)、@YUZHEthefool に感謝。
|
||||
- [#5508](https://github.com/farion1231/cc-switch/pull/5508):Responses→Chat ブリッジの reasoning 前方アタッチ、@ka79376046 に感謝。
|
||||
- [#5310](https://github.com/farion1231/cc-switch/pull/5310):ストリーミング並列ツール呼び出しの ID と順序の保持、@SaladDay に感謝。
|
||||
- [#5315](https://github.com/farion1231/cc-switch/pull/5315):Codex ツール parameters の object schema への正規化、@Komikawayi に感謝。
|
||||
- [#4706](https://github.com/farion1231/cc-switch/pull/4706):厳格な OpenAI 互換プロバイダー向けのツール型正規化、@Ryan2128 に感謝。
|
||||
- [#5356](https://github.com/farion1231/cc-switch/pull/5356):削除した既定 Skill リポジトリの復活防止、@allenxu09 に感謝。
|
||||
- [#4355](https://github.com/farion1231/cc-switch/pull/4355):トレイ初回言語のシステムロケール追従、@LaiYueTing に感謝。
|
||||
- [#5138](https://github.com/farion1231/cc-switch/pull/5138):バックエンド CI の Linux / Windows / macOS 3 プラットフォーム展開、@zayokami に感謝。
|
||||
|
||||
Codex 使用量の異常、新しい codex の起動失敗、ツール呼び出しの問題を報告してくださったすべてのユーザーにも感謝します。本リリースの最も重要な修正は、こうした実際の利用場面からの再現情報をもとにしています。
|
||||
|
||||
---
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から、お使いのシステムに対応するビルドをダウンロードしてください。
|
||||
|
||||
### システム要件
|
||||
|
||||
| システム | 最低バージョン | アーキテクチャ |
|
||||
| -------- | ------------------------ | ----------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 / ARM64 |
|
||||
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表を参照 | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ---------------------------------------- | -------------------------------------------- |
|
||||
| `CC-Switch-v3.18.0-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー |
|
||||
| `CC-Switch-v3.18.0-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます |
|
||||
|
||||
Windows ARM64 デバイスでは、ファイル名に `arm64` が含まれる対応する成果物を選択してください。
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| -------------------------------- | ------------------------------------------------------ |
|
||||
| `CC-Switch-v3.18.0-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ |
|
||||
| `CC-Switch-v3.18.0-macOS.zip` | 展開して Applications へドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.18.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
Homebrew インストール:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux アセットは **x86_64** と **ARM64**(`aarch64`)の両方を提供します。ファイル名のアーキテクチャ識別子を、マシンの `uname -m` 出力に合わせて選択してください:
|
||||
|
||||
- `CC-Switch-v3.18.0-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.18.0-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | -------------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接実行、または AUR を利用 |
|
||||
| その他 / 不明な場合 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,331 @@
|
||||
# CC Switch v3.18.0
|
||||
|
||||
> 这一版你可以做两件全新的事:**把 xAI 的 Grok CLI(Grok Build)交给 CC Switch 管理**——它成为第八个受管应用,供应商一键切换、MCP / Skills 同步、代理接管与用量统计一应俱全;以及**把 Grok 接进 Claude Code、Claude Desktop 和 Codex**——既可以直接用 xAI Grok 账号登录(设备码授权、无需 API Key,跑你的 Grok 订阅,Codex 侧自带严格网关兼容层,codex 0.142+ 也能跑通),也可以用 xAI API Key 接入(Codex 有原生 Responses 直连预设,Claude Code 可走本地路由)。同样重要的是一波修复:v3.17.0 引入的 **Codex 用量双计已修**,升级后自动重建数据,看板数字恢复真实;**codex 0.144.5+ 因模型目录无法启动的问题已修**;Windows 上切换供应商不再闪黑窗、不再卡住界面。诊断日志也从「每次启动清空」变为跨重启持久保留、按大小轮转、全面脱敏,界面崩溃会落盘留证而不再只剩一片白屏。
|
||||
|
||||
**[English →](v3.18.0-en.md) | [日本語版 →](v3.18.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 重点内容:你现在可以
|
||||
|
||||
- **管理 Grok Build(xAI 的 Grok CLI)**:像管理 Claude Code / Codex 一样添加、导入、一键切换 Grok Build 的供应商;MCP 服务器与 Skills 双向同步、提示词首启自动导入、会话管理与用量看板全覆盖;还可以走本地代理接管,获得独立的路由、failover 与计费。
|
||||
- **把 Grok 接进 Claude Code / Claude Desktop / Codex——账号登录与 API Key 双路径**:订阅用户在「设置 → OAuth 授权中心」用设备码完成 xAI 账号登录(支持多账号),三个客户端直接跑你的 Grok 订阅、全程无需 API Key;按量付费用户则用 xAI API Key 接入——Codex 有现成的「xAI (Grok)」预设原生直连 `api.x.ai`,Claude Code 可按本版新攻略走本地路由接入。默认模型均为 `grok-4.5`。
|
||||
- **把 Codex 的用量数字修回真实值**:v3.17.0 的 fork / 子代理双计问题已在解析器层根治;升级后首次启动自动备份并重建 Codex 用量,用量页里也新增了手动「重建 Codex 用量」按钮。注意首次启动时历史记录是**逐渐**修复的——看板数字先变少、再随后台重导逐步回填,属预期行为(见「升级提醒」)。
|
||||
- **放心升级 codex CLI**:codex 0.144.5 起严格解析模型目录导致的「无法启动」已修复,生成目录会自动补齐解析器必需字段。
|
||||
- **在 Windows 上顺滑切换**:切换供应商 / 开关接管不再闪过黑色控制台窗口,也不再卡住界面约 2 秒(卡顿修复对全平台生效)。
|
||||
- **更放心地排查与分享日志**:诊断日志跨重启保留(20 MB × 4 轮转)、所有出口统一脱敏——URL 凭据、请求响应体、敏感请求头都不会再落盘;界面崩溃有错误卡片和重载按钮,错误详情写入磁盘。
|
||||
- **多轮重推理、并行工具调用不再翻车**:Responses↔Chat 桥修复了推理内容错挂、并行工具调用 ID 丢失 / 乱序、工具 schema 为 null 被严格上游整单拒绝三类问题。
|
||||
- **用上 Kimi K3**:Codex / Hermes / OpenClaw / OpenCode 的 Kimi 开放平台预设加入 K3(1M 上下文),内置定价同步入库,用量不再显示 $0。
|
||||
|
||||
---
|
||||
|
||||
## 使用攻略
|
||||
|
||||
本版新能力主要落在供应商预设、「设置 → OAuth 授权中心」与用量看板里,建议结合以下文档了解:
|
||||
|
||||
- **[xAI Grok 账号登录(设置 → OAuth 授权中心)](../user-manual/zh/1-getting-started/1.5-settings.md)**:设备码登录流程、多账号管理与集成边界说明;使用前请先阅读下方「风险提示」中的客户端身份披露。
|
||||
- **[在 Claude Code 中使用 GPT 模型(本地路由攻略)](../guides/claude-codex-routing-guide-zh.md)**:本版新增的分步攻略,现已中 / 英 / 日三语齐全。Claude Code 始终对本地 `/v1/messages` 路由说 Anthropic Messages 协议,由本地代理把每个请求转换成上游的 Responses 协议——网关 API Key、xAI 这类原生 Responses 端点,或 ChatGPT 订阅的 Codex 服务都适用。
|
||||
- **[在 Codex 中使用 Claude 模型(本地路由攻略)](../guides/codex-claude-routing-guide-zh.md)**:本版新增的三语分步攻略,配合 v3.17.0 的「原生 Anthropic Messages 上游」功能,把 Codex 接到任何只提供 `/v1/messages` 的 Claude 系网关。
|
||||
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:了解用量看板的数据来源与统计口径。本版修复用量双计并新增「重建 Codex 用量」维护操作。
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## 唯一官方渠道声明(请务必阅读)
|
||||
>
|
||||
> CC Switch 是**完全免费、开源**的桌面应用,**不会向用户收取任何费用**。请仅通过下列官方渠道获取本软件:
|
||||
>
|
||||
> | 类别 | 唯一官方 |
|
||||
> | -------- | ------------------------------------------------------------------------------ |
|
||||
> | 官网 | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | 源码 | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | 下载 | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | 举报山寨 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **任何向你收费、要求充值、或索取登录凭据的"CC Switch"网站或客户端均为假冒**。如果你被诱导支付了费用,请立即停止操作并通过 GitHub Issues 反馈。
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.18.0 的两条主线都围绕 xAI Grok。第一条是 **Grok Build 加入受管应用**:xAI 的 Grok CLI(live 配置 `~/.grok/config.toml`)成为与 Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes 并列的第八个受管应用——供应商添加 / 导入 / 一键切换、MCP 与 Skills 双向同步、深链导入、独立预设列表,以及带专属路由命名空间的代理接管;配套的「Grok 官方」条目支持官方登录态识别与导入,CC Switch 绝不触碰官方凭据。第二条是 **xAI Grok 账号 OAuth 登录**:设备码授权替代 API Key,本地代理逐请求注入访问令牌,Claude Code / Claude Desktop 侧完成 Anthropic Messages → xAI Responses 转换;Codex 侧则提供受管 OAuth 预设并自带兼容层——codex 0.142+ 发出的 ChatGPT 后端私有形态(namespace 工具声明、私有字段)会被确定性地展平与剥离,严格解析的 xAI 网关不再返回 422;API Key 用户则另有一条「xAI (Grok)」原生 Responses 直连预设,不经任何转换。
|
||||
|
||||
围绕正确性,本版集中修复了 **v3.17.0 的 Codex 用量双计**:fork / 子代理日志开头对父线程历史的重放不再被当作新用量导入(解析器改为只认显式父身份 + 令牌签名对齐),升级后自动执行一次性用量重建(schema v16),用量页新增手动重建按钮;代理侧用量记录改为幂等(同一响应重放不再堆叠重复行),大量会话导入时用量页不再卡死。**Codex 转换层**另有四处修复:工具 schema 归一为 object 类型、推理内容跨轮前向附挂、流式并行工具调用保 ID 保序、生成的模型目录补齐 codex 0.144.5+ 必需字段。诊断体系也走向成熟:日志跨重启持久、按大小轮转、所有出口脱敏,界面崩溃被错误边界捕获并落盘。此外还有 Kimi K3 预设与定价、OpenClaw 预设成本修正、SudoCode.us 回归、托盘首启语言跟随系统等一批改进。
|
||||
|
||||
**发布日期**:2026-07-21
|
||||
|
||||
**更新规模**:52 commits | 217 files changed | +21,452 / -6,285 lines
|
||||
|
||||
---
|
||||
|
||||
## 新功能
|
||||
|
||||
### Grok Build:第八个受管应用
|
||||
|
||||
xAI 的 Grok CLI(Grok Build,live 配置 `~/.grok/config.toml`)现在是 CC Switch 的一等公民:供应商添加 / 导入 / 一键切换(切换后提示重启 Grok Build 生效)、应用显隐与配置目录覆盖设置、会话管理与用量看板覆盖、提示词首启自动导入、`ccswitch://` 深链导入供应商,以及本地代理接管——拥有专属的 `/grokbuild/v1/responses` 路由命名空间、独立的 failover 队列与按应用代理设置;转发复用 Codex 的 Responses 通路,但绝不与 Codex 共享供应商命名空间或熔断状态。
|
||||
|
||||
MCP 服务器与 Grok 的 `[mcp_servers]` 表双向同步,方言差异已被抹平:Grok 靠 `command` / `url` 推断传输类型且用 `headers` 字段,导出时会剥掉显式 `type` 并把 `http_headers` 重命名为 `headers`,导入时反向推断回来。Skills 也获得 Grok Build 启用开关。
|
||||
|
||||
预设方面刻意**没有**借用 Codex 列表(早期版本曾把国产直连供应商和 Codex 默认模型漏进 Grok 表单),而是独立整理了一份:只收录真正承载 Grok 模型的聚合与中转站,默认模型归一为 `grok-4.5`(命名空间路由站为 `x-ai/grok-4.5`)。工具面板安装 Grok 优先走 xAI 官方安装器(`x.ai/cli/install.sh` / `install.ps1`),npm 包 `@xai-official/grok` 作为兜底;被确认是原生安装的走 `grok update` 自更新,npm 安装保持 npm 锚定更新——自更新门控在「确定检测为原生」上,绝不会误伤另一种安装。四语界面文案同步就位。([#5453](https://github.com/farion1231/cc-switch/pull/5453))
|
||||
|
||||
### Grok 官方登录:识别、导入与保护
|
||||
|
||||
新增「Grok 官方」供应商条目,对应 Grok CLI 自带的 xAI OAuth 登录:选中它会隐藏连接字段并写入一个空的 `~/.grok/config.toml`,CC Switch 从不存储、也从不触碰官方凭据。live 配置的读取、备份与官方态写入改用仅语法级的 TOML 校验,官方登录态(空配置)可以正常往返;Grok 处于官方登录态时「从 live 导入」会得到「已设 Grok 官方为当前」而不是报错,与 Codex 行为一致。官方态识别刻意只接线到手动导入命令——启动时的自动导入器仍会拒绝官方态配置,所以你删掉的「Grok 官方」条目绝不会在下次启动时复活。对官方登录配置的代理接管会被自动跳过,手动路径给出明确拒绝,与现有「不代理官方供应商」的策略一致。
|
||||
|
||||
### 用 xAI Grok 账号登录:Claude Code 与 Claude Desktop
|
||||
|
||||
Claude Code 与 Claude Desktop 新增「xAI (Grok)」预设,用 OAuth 设备码登录代替 API Key:请求经本地代理完成 Anthropic Messages → xAI Responses API 转换并逐请求注入访问令牌,各档默认模型都是 `grok-4.5`(Claude Desktop 预设把 `claude-*` 形式的角色 ID 映射到上游 `grok-4.5`,以通过 Desktop 的第三方模型校验)。
|
||||
|
||||
「设置 → OAuth 授权中心」新增 xAI 区块:设备码登录(用户码带复制按钮、验证链接、等待 / 取消 / 重试)、多账号与默认账号选择、按账号移除、重授权徽标——刷新令牌被吊销的账号会以「已过期」状态保留可见而不是消失,授权状态每 15 秒自动刷新,服务端吊销会自己浮现出来。
|
||||
|
||||
集成边界是钉死的:无论表单里的端点 / 格式字段怎么改,上游始终是 `https://api.x.ai/v1/responses`(Responses 格式);OAuth 端点经 OIDC 发现解析,但强制校验为 https 的 `auth.x.ai`;刷新令牌存于 `~/.cc-switch/xai_oauth_auth.json`(Unix 上 `0600`;访问令牌只存内存);OAuth 错误响应体绝不进入错误信息或日志。`grok-4.5` 定价($2 输入 / $6 输出 / $0.50 缓存读,每百万 token)同步入库,用量不再记 $0,存量数据库下次启动自动补行。四语文案同步。使用前请阅读「风险提示」中的客户端身份披露。
|
||||
|
||||
不用 OAuth、只有按量付费的 xAI API Key?同样能接进 Claude Code:xAI 的 API 端点就是标准 Responses 协议,把它当作一个普通的 Responses 供应商添加——自定义供应商填 `https://api.x.ai/v1` 与 API Key、上游格式选 Responses,经本地路由完成 Anthropic Messages ↔ Responses 转换,与〈[在 Claude Code 中使用 GPT 模型](../guides/claude-codex-routing-guide-zh.md)〉攻略是同一套玩法。Codex 侧则有现成的 API Key 预设,见下一节。
|
||||
|
||||
### Codex 直连 xAI:OAuth 受管与 API Key 原生双预设
|
||||
|
||||
Codex 获得两条直连 xAI 的路——有 Grok 订阅走 OAuth 受管,有 API Key 走原生直连:
|
||||
|
||||
- **「xAI (Grok) OAuth」受管预设**:让 Codex 跑在 Grok 订阅上。表单隐藏密钥 / 端点 / 格式字段、显示账号选择器,「获取模型」用已登录账号发起;供应商被钉死为原生 Responses,base URL 与逐请求令牌由代理强制执行——改了也会被忽略,受管路由无法被重定向。由于 codex 0.142+ 会发出 ChatGPT 后端私有的请求形态(`type:"namespace"` 工具声明会让 xAI 严格解析器直接 422,另有 `prompt_cache_retention`、`safety_identifier`、`external_web_access`、`additional_tools` 载体字段和 grok-4.5 不支持的采样参数),OAuth 路由在原生透传上加了一层兼容层:namespace 工具被展平为顶层 function 工具(与 Chat 路径同款 sha256 截断命名)、响应侧流式与非流式都还原回 namespace 形态,不支持的字段被剥除——全部是确定性的字段删除 / 结构提升,绝无语义改写,prompt 缓存前缀保持稳定。兼容层只门控在 xAI OAuth 供应商类型上,任何其它供应商的流量都不受影响。
|
||||
- **「xAI (Grok)」API Key 预设**:直连 `api.x.ai/v1` 的原生 Responses,自带 500K 上下文的 `grok-4.5` 目录条目。该预设**不会**应用上述 xAI 专属兼容转换——codex 0.142+ 的 API Key 用户仍可能撞上 xAI 的严格解析器,OAuth 预设才是完全兼容的路径。
|
||||
|
||||
xAI OAuth 的令牌失败被归为不可重试错误,failover 绝不会把你的对话悄悄挪到另一个 Grok 账号上。
|
||||
|
||||
### 界面崩溃捕获:错误落盘与重载页
|
||||
|
||||
React 错误边界现在包住整个界面(包括数据库恢复界面):渲染进程崩溃时显示「界面出错了」卡片和重载按钮,而不是一片白屏;全局 `error` / `unhandledrejection` 处理器把渲染端错误持久化到磁盘——此前一次 JS 崩溃在盘上零证据。前端写出的所有日志经过两层脱敏:结构化序列化器按敏感属性名(`tokens` / `apiKeys` / `credentials` 等变体归一匹配,整值含嵌套对象一起隐藏)与值形态(令牌前缀、PEM 头、高熵不透明串)脱敏,再经唯一文本出口的有序正则链覆盖 URL 查询值与凭据、认证头与 scheme、命名密钥容器(双重编码的 JSON 也覆盖)。字符串形态到达的 JSON 会被重新解析后做结构化脱敏;超大结构化输入整体丢弃而非截断——截断的 JSON 串会退化到较弱的文本正则,可能泄漏。设置里的开关文案也改为名副其实:「应用诊断日志」(cc-switch.log)与代理的「记录请求用量」(统计数据库,本来就不是文本日志)。四语同步。
|
||||
|
||||
### 「重建 Codex 用量」维护按钮
|
||||
|
||||
用量看板的维护区新增「重建 Codex 用量」:备份数据库后,只清除 `codex_session` 来源的明细行、对应的 `_codex_session` 日汇总与 Codex 同步游标,然后用修正后的解析器从头重导所有 rollout 文件——这是被下述双计 bug 污染的数据库的恢复路径,也是父日志恢复后延迟 fork 文件的重试路径。手动重建在备份写不出时会硬失败(自动迁移版只告警,因为在升级后因备份目录不可写而卡死启动是更糟的结局);整个「备份 → 重置 → 重导」序列持有会话同步锁,60 秒后台同步无法与清除交错;完成时保证恰好发出一次前端刷新通知——包括重导为零行或失败的路径——看板绝不会停留在重置前的数字上。游标清理按路径形态匹配(`sessions` / `archived_sessions` 段下的 `rollout-{uuid}` 文件名),旧 `CODEX_HOME` 下记录的游标也能清到。四语同步。
|
||||
|
||||
### 会话导入可观测性:延迟文件与疑似重复
|
||||
|
||||
会话同步结果现在报告 `filesScanned`、`deferredFiles`——父日志缺失或父标记冲突的 fork rollout 会被搁置且不写游标,等后续同步或手动重建重试,而不是靠猜导入——以及 `suspectedDuplicates`:插入后逐行探测是否已存在同指纹行(走 `idx_request_logs_dedup_lookup_expr` 表达式索引),每次命中记一条警告。双计 bug 未来若复发,会在日志里自己喊出来,而不是无声地吹大总数。
|
||||
|
||||
### Kimi K3 预设与定价
|
||||
|
||||
Codex / Hermes / OpenClaw / OpenCode 的 Kimi 开放平台预设加入 Kimi K3(1M 上下文窗口),追加在 K2.7 Code 之后,现有默认模型行为不变。内置定价表新增 `kimi-k3`(官方牌价 $3 输入 / $15 输出 / $0.30 缓存读,每百万 token)与裸 `k3` 别名——Kimi For Coding 订阅上报的模型短 id 是 `k3`,否则匹配不到任何定价行(与现有 `hunyuan-hy3` / `hy3` 同款先例)。存量数据库下次启动自动补齐两行,不碰用户改过的定价。
|
||||
|
||||
### SudoCode.us 回归,与 SudoCode.chat 并存
|
||||
|
||||
两家恰好同名「SudoCode」的无关公司现在是两个独立预设:赞助商更名为「SudoCode.chat」,此前被原位替换掉的「SudoCode.us」带着原有端点、模型与图标回归,Hermes slug 也做了区分,两者可在累加式的 `~/.hermes/config.yaml` 中共存。算上新的 Grok Build 预设列表,SudoCode.chat 覆盖七个应用、SudoCode.us 覆盖全部八个。
|
||||
|
||||
---
|
||||
|
||||
## 变更
|
||||
|
||||
### 诊断日志:跨重启持久、按大小轮转、绝不记录密钥
|
||||
|
||||
`cc-switch.log` 不再在每次启动时被清空——过去能解释崩溃的日志,等应用重开时已经没了——改为 20 MB 轮转、保留 4 个归档(上限约 100 MB,对比过去单文件可膨胀到 1 GB);此前无上限的 `crash.log` 改为 5 MB 轮转、保留 2 个归档,检查 / 轮转 / 追加序列在同一把锁下,并发 panic 不会丢归档。
|
||||
|
||||
日志持久化让明文密钥成为真实的暴露面(用户会把日志附到公开 issue 里),所以同一批改动里把后端所有日志出口都做了清洗:上游 URL 只记剥掉 userinfo / query / fragment 的形式(没有已知密钥可替换时只记 origin,因为凭据可能嵌在路径里);请求与响应体一律不记——换成字节数、短哈希或安全分类(`sse` / `html` / `json-like` / `binary-or-encoded` 等),排查转换问题的信号还在、内容没了;响应头走白名单(名单外只记名字);正在使用的密钥值(API Key、访问令牌)会从任何携带它的 URL 里被替换掉;MCP 自定义字段值一律省略。日志插件注册提前(更新器 / 启动期故障可诊断),持久化的日志级别在数据库打开后立即生效、故障时收敛到 Info,「启用诊断日志」开关现在也管前端发起的日志写入。**升级前的旧日志文件不会被追溯清洗**——见「升级提醒」。
|
||||
|
||||
### 预设选择器:赞助商分组,其余按名称排序
|
||||
|
||||
预设选择器的默认顺序改为四层:官方最前,其次首要合作伙伴,然后是赞助商预设(与 README 赞助商表同序,预设文件已物理重排对齐),最后所有其余预设按显示名字母序排列,不再按文件序。命中多层的条目只落在最早一层,不会重复出现。
|
||||
|
||||
### 预设「获取 API Key」链接更新
|
||||
|
||||
RunAPI、ClaudeCN、ZetaAPI、APINebula 预设的密钥申请链接更新为各家当前的注册 / 推荐页(ClaudeCN 同时迁移了域名:claudecn.top → claudecn.ai)。推荐标签仅限这些链接与 README——官网链接和 API 端点保持不动。
|
||||
|
||||
---
|
||||
|
||||
## 修复
|
||||
|
||||
### Codex fork / 子代理不再把重放的父历史当新用量(v3.17.0 双计根治)
|
||||
|
||||
修复 v3.17.0 的用量膨胀:fork 一个 Codex 任务或以复制模式派生子代理时,父对话的 token 历史被当作新用量重复计入——有用户报告单日用量跳涨数十亿 token、父子行字节级相同、空 fork 背着从未消耗过的用量。fork / 子代理的 rollout 文件开头会重放父线程历史,旧解析器靠启发式找接管边界(第一个 `thread_settings_applied` 事件、对象形态的 `subagent` 来源标记):父线程自己的设置变更出现在重放里时边界落得太早,而当前字符串形态的来源标记则完全识别不到,整段父历史被原样导入。新解析器只认显式父身份——子方 `session_meta` 上的 `forked_from_id` 或 `source.subagent.thread_spawn.parent_thread_id`,两者冲突时搁置该文件——线程身份锚定到 rollout 文件名 UUID,加载父 rollout 自己的 fork 前 token 计数序列,用令牌签名对齐剥掉子方的重放前缀:重放事件只用于恢复累计基线,绝不插行。不带重放历史的子代理日志现在按真实用量计入,反方向的漏计(真实子代理消耗被当作疑似重放跳过)同步修复。([#5335](https://github.com/farion1231/cc-switch/issues/5335)、[#5433](https://github.com/farion1231/cc-switch/issues/5433)、[#5381](https://github.com/farion1231/cc-switch/issues/5381))
|
||||
|
||||
### 代理用量记录改为幂等:响应级稳定键
|
||||
|
||||
终态用量事件不带消息 id 时(经本地代理的 Codex `/responses` 流量是常态),去重键此前回退到随机 UUID——同一上游响应的每次重试 / 重放都造一个新键,`INSERT OR REPLACE` 每次都堆一行新的;有用户的数据库里同一用量组合出现了 2,078 次。解析器现在从响应信封本身取键——Codex `response.completed` 事件的 `response.id`(丢弃 `response.created` 的 id)、Chat Completions 的 `chatcmpl` id、Gemini 的 `responseId`——并按 `session:{app_type}:{provider_id}:{id}` 作用域化:failover 时同一响应打到不同供应商仍按供应商各记一次、互不碰撞(Claude 保持裸 `session:{id}` 形态,代理行继续与会话日志导入合流)。完全没有信封 id 时,兜底从响应的用量语义做确定性 SHA-256——相同重放必须撞进同一个键,去重才成立——最终写库也从无条件 REPLACE 改为去重窗口内的「不存在才插入」。([#5496](https://github.com/farion1231/cc-switch/issues/5496))
|
||||
|
||||
### 大量会话导入时用量页不再卡死
|
||||
|
||||
导入大批会话时打开用量页可能整个卡住:每插入一行就发一次刷新通知,每次通知让前端重跑全部约 10 个用量查询,这些查询又与正在逐行解析几十 MB rollout 文件的导入器争抢唯一数据库连接——在被重复行吹大的数据库上三者互相放大。现在会话同步改为每轮完成只通知一次;所有会话导入器串行在单飞锁后(手动「立即同步」排队等待运行中的一轮,而不是与之竞争);阻塞式解析挪到专用阻塞线程,不再饿死驱动界面命令的异步运行时;60 秒后台节拍错过就跳过,不再突发补跑。
|
||||
|
||||
### codex 0.144.5+ 不再因 CC Switch 生成的模型目录无法启动
|
||||
|
||||
codex ≥ 0.144.5 严格解析外部模型目录,条目缺 `supports_reasoning_summaries` 时整个文件被拒——Codex CLI 和桌面端都起不来,删掉生成目录也没用,因为任何一次供应商保存都会按同样方式重新生成。根因是 CC Switch 从机器共享的 `models_cache.json` 克隆目录模板,而它的字段集取决于最后写它的那个 codex 进程——共存的旧版 codex 一直在用缺字段的形态重写缓存。生成目录现在会从内置静态模板回填解析器必需字段,且只在缺失时回填(动态值永远优先);「缺失即解析器默认值」的可选能力字段刻意不回填,语义必须保留。
|
||||
|
||||
### Windows:切换供应商不再闪黑窗、不再卡死
|
||||
|
||||
Windows 上切换供应商或开关接管会闪过一个控制台窗口、界面卡住约 2 秒。三个原因、三处修复:`codex debug models --bundled` 探测经 `cmd.exe` 启动 `codex.cmd`,GUI 子系统应用里这会弹出自己的控制台——子进程现在带 `CREATE_NO_WINDOW` 创建;模型目录模板此前每次切换都重新生成——现在首次成功加载后进程级缓存(失败保持可重试,坏的首次探测不会毒化缓存),Codex CLI 每次应用运行至多启动一次;`switch_provider` 此前是跑在主线程上的同步命令——现在异步化、真实工作在阻塞线程上,仍由按应用切换锁串行。卡顿修复对全平台生效,闪窗修复是 Windows 专属。
|
||||
|
||||
### 工具 schema 为 null / 缺失 / 联合类型不再被严格上游整单拒绝
|
||||
|
||||
Codex 内置工具(如 `codex_app__automation_update`)声明 `parameters: null`(或 `type: null`),DeepSeek 这类严格的 OpenAI 兼容上游会对整个请求返回 400,经代理路由的工具会话直接被杀。Responses→Chat 桥现在把每个工具的 parameters 归一为 `type:"object"` schema:null 或缺失(含嵌套形态的缺失)变为 `{"type":"object","properties":{}}`,非 object 的 `type`(含 `type: null`)原位纠正为 `"object"`,顶层 `oneOf` 联合 schema 补根 `type:"object"`、分支原样保留。同样的 object 类型保证扩展到了 Codex→Anthropic 工具路径的 `input_schema`。已有的 `properties` / `required` 绝不丢弃。([#4706](https://github.com/farion1231/cc-switch/pull/4706)、[#5315](https://github.com/farion1231/cc-switch/pull/5315),修复 #4705、#4783)
|
||||
|
||||
### 推理模型在多轮 Codex Chat 对话中保住思考
|
||||
|
||||
推理模型(如 kimi-k2-thinking)走代理的 Responses→Chat 桥时,多轮历史会弄坏思考内容:每轮的 `reasoning` 条目被粘到**上一条**助手消息的尾巴上,紧随的助手轮反而没有 `reasoning_content`——模型会肉眼可见地中途断片。Responses 语义里推理位于它所属消息**之前**,桥现在把推理前向附挂到其后的助手消息或工具调用上;真正的尾部推理只在确凿的尾部(输入结束,或用户消息这样的轮次边界——此前在这里会被静默丢弃)向后附挂,并追加到已内嵌的推理之后;悬挂中的推理在边界处必被消费,绝不会跨过用户轮泄漏进后面的助手消息。([#5508](https://github.com/farion1231/cc-switch/pull/5508))
|
||||
|
||||
### 流式并行工具调用保住 ID 与顺序
|
||||
|
||||
Chat→Responses 流式桥的两个 bug 会弄坏「身份分散在多个 chunk」的上游发来的并行工具调用:携带空 `id` 的续传增量会覆盖真实 `call_id`(Codex 客户端看到 `call_id:""`,工具结果对不上调用);工具调用各自就绪就立即发出,名字先到的靠后索引能插到靠前索引前面——并行调用被重排。现在空 id 一律忽略;发射经过连续索引闸门,严格按 Chat `index` 顺序放行,未识别的靠前索引没就绪就等待;流中途绝不合成假 call id(只在流终结时作为最后手段,且防御性跳过无名调用、稀疏索引照常发出)。([#5310](https://github.com/farion1231/cc-switch/pull/5310))
|
||||
|
||||
### 受管 OAuth 供应商可靠地标记为「需本地路由」
|
||||
|
||||
「需路由」徽标与切换时警告此前由供应商的 API 格式推导,对受管 OAuth 供应商(Copilot、Codex OAuth、xAI)这是错误信号——它们的凭据由代理注入、与上游格式无关,原生格式的受管供应商拿不到警告、不开接管就静默失败。路由需求现在由唯一共享谓词决定:官方供应商永不需要路由,受管 OAuth 供应商恒需要,格式规则只适用于其余情况。切换时的门槛也按应用查对了就绪信号:多数应用查按应用接管状态(旧门槛只看全局代理运行标志,漏掉「代理在跑但当前应用没被接管」),Claude Desktop 继续看代理进程本身——后端接管状态没有 Claude Desktop 字段,统一按应用查会让 Desktop 永远弹警告。Claude Desktop 供应商表单对所有受管 OAuth 类型强制代理模式并锁定模型映射开关,不再只对 xAI。四语同步。
|
||||
|
||||
### Node 装在 nvm / fnm / mise 里时工具更新可用
|
||||
|
||||
锚定的 npm 更新与修复命令按绝对路径调 npm,但 npm 启动器靠 `#!/usr/bin/env node` shebang 从 PATH 找 `node`——GUI 启动的应用只继承系统 PATH,不含版本管理器目录,nvm / fnm / mise 安装的工具更新静默失败。现在每个锚定 npm 调用都把 npm 自己的同级 `bin` 目录前置到 PATH,npm 与它的 shebang 解析到同一个 Node;Codex 自修复(卸载 + 重装)路径同样覆盖。
|
||||
|
||||
### 删除的默认 Skill 仓库不再复活
|
||||
|
||||
默认 Skill 仓库此前每次启动被「补齐缺失默认项」逻辑重新播种,删掉的默认仓库下次启动又静默回来。播种改为按数据库一次性,用设置标志记录;升级时已有仓库的数据库直接置标志、不再补种,现有选择不受影响。([#5356](https://github.com/farion1231/cc-switch/pull/5356))
|
||||
|
||||
### 托盘首启语言跟随系统
|
||||
|
||||
设置里还没选过语言时,托盘菜单被硬编码为简体中文——英文 / 日文 / 繁中系统上主界面正确跟随系统语言、托盘却不一致,直到用户手动切一次语言。托盘现在按与前端相同的优先级从系统 locale 推导首启语言(含 `zh-TW` / `zh-HK` / `zh-Hant` → 繁体中文);显式选择的语言永远优先,locale 读不到时照旧回落中文。([#4355](https://github.com/farion1231/cc-switch/pull/4355))
|
||||
|
||||
### 导入失败显示真实错误并刷新列表
|
||||
|
||||
每次「从 live 配置导入」失败都弹一个空错误提示,因为 Tauri 的 `invoke` 以后端错误**字符串**拒绝,而处理器从它上面读 `.message`。现在显示后端真实报错(带本地化的通用兜底),失败时也会刷新供应商列表——报错前已提交的副作用立即可见。
|
||||
|
||||
### OpenClaw 预设模型成本修正为官方牌价
|
||||
|
||||
15 个 OpenClaw 预设条目的成本值单位错误或未换汇——`cost` 字段是美元每百万 token,例如 `glm-5.1` 记成 `0.001/0.001`(低估约 1000 倍,用量成本近乎 0),`deepseek-v4-pro` 则带着未换算的人民币值(高估)。所有条目改为官方牌价 $/M;订阅套餐与免费档端点也刻意展示牌价,套餐用户能看到自己用量的标准价值。今后从预设新建的供应商拿到修正值;已创建的供应商保持创建时的配置。
|
||||
|
||||
### 界面小修一组
|
||||
|
||||
- **AiHubMix 图标**:Codex 应用的 AiHubMix 预设此前缺品牌图标字段、渲染成通用图标,现与其它应用一致。
|
||||
- **两个缺失文案键补齐**:Codex「因使用 Anthropic Messages 格式需要路由」提示里的原因片段此前在非中文界面显示中文(`proxyReasonAnthropicMessages` 不存在于任何语言文件);供应商表单的密钥状态加载标签自 4 月起只有硬编码默认值。两者已在 zh / en / ja / zh-TW 全部补齐。
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
### Codex ↔ Claude 双向路由攻略
|
||||
|
||||
两篇新攻略把「Codex 客户端用 Claude 模型」「Claude Code 客户端用 Responses 供应商」补成了双向:
|
||||
|
||||
- **[在 Codex 中使用 Claude 模型](../guides/codex-claude-routing-guide-zh.md)**(中 / 英 / 日三语,含截图):配合 v3.17.0 的原生 Anthropic Messages 上游,把 Codex 接到 Claude 系 `/v1/messages` 网关;v3.17.0 的 release notes 已回链本攻略。
|
||||
- **[在 Claude Code 中使用 GPT 模型](../guides/claude-codex-routing-guide-zh.md)**(中 / 英 / 日三语,含截图):用 Responses 协议的供应商(网关 API Key,或 ChatGPT 订阅的 Codex 服务)驱动 Claude Code——Claude Code 始终对本地 `/v1/messages` 路由说 Anthropic Messages,由代理把每个请求转换成上游的 Responses 协议。
|
||||
|
||||
### README 赞助商更新
|
||||
|
||||
SubRouter 加入四语 README 赞助商表;置顶的 Kimi 赞助文案更新到 K3、横幅改由 Moonshot CDN 提供;RunAPI 权益文案刷新,赞助商行序与应用内预设顺序对齐。
|
||||
|
||||
---
|
||||
|
||||
## 升级提醒
|
||||
|
||||
### 数据库自动迁移与 Codex 用量一次性重建
|
||||
|
||||
从 v3.17.0 升级会连续执行三次 schema 迁移(v13 → v16):v14 重建 `proxy_config` 表以纳入 Grok Build(现有按应用代理设置全部保留,并新增 `grokbuild` 行);v15 给 MCP 服务器表与 Skills 表加 Grok Build 启用列;v16 触发一次性的 Codex 用量自动重建——数据库先备份到 `backups/` 下,`codex_session` 数据与游标被重置,随后正常的启动同步用修正后的解析器重导全部数据。典型数据量只需数秒;实测最重的数据集(1,801 个 rollout 文件 / 1.5 GB)约 65 秒。之后的启动照旧增量。若有回退旧版本的习惯,建议先自行备份 `~/.cc-switch/cc-switch.db`。
|
||||
|
||||
**首次启动时请留意**:历史记录的修复是**逐渐完成**的——重建随启动同步在后台进行,这段时间里用量看板的 Codex 历史数字会先清零、再逐步回填,属预期行为,不是数据丢失。重建完成后的总数通常会比升级前**更小**:被双计吹大的那部分被挤掉了,剩下的才是真实用量。
|
||||
|
||||
### 重建的边界
|
||||
|
||||
- 重建从 rollout JSONL 文件重新计算用量,**源日志已被删除的历史无法重建**。
|
||||
- 父 rollout 缺失的 fork 文件会被搁置并报告,而不是靠猜导入;恢复父日志后运行「重建 Codex 用量」可补导。
|
||||
- 历史上代理来源的重复行会永久保留——迁移只重建会话来源的数据,不存在针对过往代理膨胀的清理逻辑;幂等记录只保证从此不再产生新重复。
|
||||
|
||||
### 旧日志文件不会被追溯脱敏
|
||||
|
||||
诊断日志从本版起不再在启动时清空、跨重启持久保留(运行日志轮转上限约 100 MB,另有约 15 MB 崩溃日志)。**早期版本写下的日志文件不会被追溯清洗**,可能含有 API Key、令牌或带凭据的 URL——公开分享前请先检查升级前的旧日志。
|
||||
|
||||
### Grok Build 安装走官方安装脚本
|
||||
|
||||
安装或重装 Grok Build 现在优先使用 xAI 官方安装器,安装时会外联获取 `x.ai/cli/install.sh`(Windows 为 `install.ps1`),npm 作为兜底;已有的 npm 安装继续经 npm 更新。
|
||||
|
||||
### 内置定价自动补行
|
||||
|
||||
新定价行(`grok-4.5`、`kimi-k3`、`k3`)在下次启动时按「不存在才插入」自动追加;用户编辑过的定价行绝不被覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 风险提示
|
||||
|
||||
### xAI Grok OAuth 登录(本版新增,请阅读)
|
||||
|
||||
本版的 xAI Grok OAuth 集成**复用官方 Grok CLI 注册的公开 OAuth 客户端身份与权限范围**(`client_id b1a00492-073a-47ea-816f-4c329264a828`,scope 含 `grok-cli:access`),而不是 CC Switch 自己注册的应用身份。xAI 可能不支持这种用法,**使用可能导致账号被限制或封禁——风险自担**。该功能完全可选:不添加 xAI 供应商,一切照旧。首次登录会创建 `~/.cc-switch/xai_oauth_auth.json`(仅存刷新令牌,Unix 上权限 `0600`;访问令牌只存内存),并经你配置的出站代理访问 `auth.x.ai` 与 `api.x.ai`,无本地回调端口。
|
||||
|
||||
### 沿用的反向代理类提示
|
||||
|
||||
**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#️-风险提示)。
|
||||
|
||||
**第三方供应商路由**:通过 CC Switch 本地代理把 Codex、Claude Desktop 或 Grok Build 的请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。
|
||||
|
||||
用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
感谢以下贡献者在 v3.18.0 中提交的功能与修复:
|
||||
|
||||
- [#5453](https://github.com/farion1231/cc-switch/pull/5453):Grok Build 一等公民支持(第八个受管应用的主体实现),感谢 @YUZHEthefool。
|
||||
- [#5508](https://github.com/farion1231/cc-switch/pull/5508):Responses→Chat 桥推理内容前向附挂,感谢 @ka79376046。
|
||||
- [#5310](https://github.com/farion1231/cc-switch/pull/5310):流式并行工具调用保 ID 保序,感谢 @SaladDay。
|
||||
- [#5315](https://github.com/farion1231/cc-switch/pull/5315):Codex 工具 parameters 归一为 object schema,感谢 @Komikawayi。
|
||||
- [#4706](https://github.com/farion1231/cc-switch/pull/4706):严格 OpenAI 兼容上游的工具类型归一,感谢 @Ryan2128。
|
||||
- [#5356](https://github.com/farion1231/cc-switch/pull/5356):删除的默认 Skill 仓库不再复活,感谢 @allenxu09。
|
||||
- [#4355](https://github.com/farion1231/cc-switch/pull/4355):托盘首启语言跟随系统 locale,感谢 @LaiYueTing。
|
||||
- [#5138](https://github.com/farion1231/cc-switch/pull/5138):后端 CI 扩展到 Linux / Windows / macOS 三平台,感谢 @zayokami。
|
||||
|
||||
也感谢所有反馈 Codex 用量异常、codex 新版启动失败与工具调用问题的用户——本版最重要的几个修复都来自这些真实场景里的复现线索。
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | -------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 / ARM64 |
|
||||
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------------- | ----------------------------------- |
|
||||
| `CC-Switch-v3.18.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.18.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
Windows ARM64 设备请选择文件名中带 `arm64` 标识的对应制品。
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------- |
|
||||
| `CC-Switch-v3.18.0-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||
| `CC-Switch-v3.18.0-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||
| `CC-Switch-v3.18.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
Homebrew 安装:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux 资产同时提供 **x86_64** 和 **ARM64**(`aarch64`)两种架构。资产文件名中包含架构标识,请按你机器的 `uname -m` 输出选择对应版本:
|
||||
|
||||
- `CC-Switch-v3.18.0-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.18.0-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
|
||||
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -278,21 +278,36 @@ Log level descriptions:
|
||||
|
||||
Settings > **OAuth Auth Center** Tab
|
||||
|
||||
Added in v3.13.0, the **OAuth Auth Center** (Beta) provides unified management for third-party OAuth credentials. It currently supports two account types:
|
||||
Added in v3.13.0, the **OAuth Auth Center** (Beta) provides unified management for third-party OAuth credentials. It currently supports three account types:
|
||||
|
||||
| Account Type | Purpose |
|
||||
| ------------------------- | ---------------------------------------------------------- |
|
||||
| **GitHub Copilot** | Used with the Copilot reverse proxy |
|
||||
| **ChatGPT (Codex OAuth)** | Used with the Codex OAuth reverse proxy; manage ChatGPT accounts |
|
||||
| **xAI (Grok OAuth)** | Used with the xAI Responses API reverse proxy |
|
||||
|
||||
**What you can do here**:
|
||||
|
||||
- Log in to ChatGPT / GitHub accounts via the Device Code flow
|
||||
- Log in to ChatGPT / GitHub / xAI accounts via the Device Code flow
|
||||
- View the list of logged-in accounts and authentication status
|
||||
- Set a default account when managing multiple accounts
|
||||
- Remove individual accounts or log out all accounts at once
|
||||
|
||||
> **Note**: Both features use reverse-engineered OAuth flows and carry account risk and Terms of Service risk. Before using, please read the full risk notice in [2.1 Add Provider → Codex OAuth Reverse Proxy](../2-providers/2.1-add.md#codex-oauth-reverse-proxy-claude-provider).
|
||||
The xAI integration uses the OAuth 2.0 Device Authorization Grant, not a loopback callback. Its network contract is explicit:
|
||||
|
||||
| Dependency | Endpoint |
|
||||
| ---------- | -------- |
|
||||
| OpenID discovery | `https://auth.x.ai/.well-known/openid-configuration` |
|
||||
| Device authorization | `device_authorization_endpoint` from discovery (currently `https://auth.x.ai/oauth2/device/code`) |
|
||||
| Token exchange / refresh | `token_endpoint` from discovery (currently `https://auth.x.ai/oauth2/token`) |
|
||||
| Inference | `https://api.x.ai/v1/responses` |
|
||||
| Model discovery | `https://api.x.ai/v1/models` |
|
||||
|
||||
There is no local callback port. Authentication and model requests use CC Switch's global outbound proxy configuration.
|
||||
|
||||
The xAI integration does **not** use an OAuth application registered to CC Switch. It reuses the public OAuth client identity and scopes registered for the official Grok CLI (`client_id: b1a00492-073a-47ea-816f-4c329264a828`, including `grok-cli:access`) to obtain account-backed API access.
|
||||
|
||||
> **Warning**: These account-backed reverse proxies use reverse-engineered OAuth integrations and carry account and Terms of Service risk. Reusing an upstream product's OAuth client may be unsupported and may cause the provider to restrict or suspend the account. Review the applicable provider terms and use these features at your own risk. For the existing Codex flow, also read [2.1 Add Provider → Codex OAuth Reverse Proxy](../2-providers/2.1-add.md#codex-oauth-reverse-proxy-claude-provider).
|
||||
|
||||
## About Page
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Once enabled, you can:
|
||||
|
||||
- Use third-party Anthropic-compatible providers in Claude Desktop
|
||||
- Configure model mapping for models outside the three role IDs: legacy Claude IDs (e.g. `claude-3-5-sonnet`) and non-Claude models like DeepSeek / Kimi / DouBao / OpenAI / Gemini all need it
|
||||
- Reuse Copilot / Codex OAuth account-based providers
|
||||
- Reuse Copilot / Codex OAuth / xAI OAuth account-based providers
|
||||
- Switch between Claude Desktop official mode and third-party providers
|
||||
|
||||
Claude Desktop and Claude Code are separate app entry points. Claude Code uses `~/.claude/settings.json`, while Claude Desktop uses its own 3P profile configuration. In CC Switch they appear as separate apps: "Claude" and "Claude Desktop"; the icon badge in the bottom-right corner helps distinguish them.
|
||||
|
||||
@@ -275,7 +275,12 @@ If none of the above solutions work:
|
||||
|
||||
### Log Files
|
||||
|
||||
Attach log files when submitting an Issue:
|
||||
CC Switch stores logs under its application configuration directory. By default this is `.cc-switch` in your home directory; if you changed the configuration directory in Advanced Settings, use that custom location instead.
|
||||
|
||||
- macOS/Linux: `~/.cc-switch/logs/`
|
||||
- Windows: `%APPDATA%\cc-switch\logs\`
|
||||
When submitting an Issue, attach the files relevant to the problem:
|
||||
|
||||
- General, network, or proxy errors: `~/.cc-switch/logs/cc-switch.log` and its rotated files
|
||||
- Application crashes: `~/.cc-switch/crash.log`, `crash.log.1`, and `crash.log.2`
|
||||
- The default Windows location is `C:\Users\<username>\.cc-switch\...`
|
||||
|
||||
The runtime log rotates at 20 MB and retains the four most recent archives. Restarting the application does not clear existing logs. Logs may contain runtime environment details, so review them before posting publicly.
|
||||
|
||||
@@ -278,21 +278,36 @@ WebDAV プロトコルを使用して複数のデバイス間で設定を同期
|
||||
|
||||
設定 → **OAuth 認証センター** タブ
|
||||
|
||||
v3.13.0 で追加された **OAuth 認証センター**(Beta)は、サードパーティの OAuth 認証情報を一元管理します。現在、以下の 2 種類のアカウントタイプをサポートしています:
|
||||
v3.13.0 で追加された **OAuth 認証センター**(Beta)は、サードパーティの OAuth 認証情報を一元管理します。現在、以下の 3 種類のアカウントタイプをサポートしています:
|
||||
|
||||
| アカウントタイプ | 用途 |
|
||||
| ----------------------------- | --------------------------------------------------------------- |
|
||||
| **GitHub Copilot** | Copilot リバースプロキシと組み合わせて使用 |
|
||||
| **ChatGPT (Codex OAuth)** | Codex OAuth リバースプロキシと組み合わせて使用、ChatGPT アカウントを管理 |
|
||||
| **xAI (Grok OAuth)** | xAI Responses API リバースプロキシと組み合わせて使用 |
|
||||
|
||||
**ここでできること**:
|
||||
|
||||
- Device Code フローで ChatGPT / GitHub アカウントにログイン
|
||||
- Device Code フローで ChatGPT / GitHub / xAI アカウントにログイン
|
||||
- ログイン済みアカウント一覧と認証状態の確認
|
||||
- マルチアカウント時のデフォルトアカウント設定
|
||||
- 個別アカウントの削除や全アカウントの一括ログアウト
|
||||
|
||||
> **注意**:これら 2 つの機能はリバースエンジニアリングされた OAuth フローを使用するため、アカウントリスクおよび利用規約リスクが存在します。ご利用前に [2.1 プロバイダーの追加 → Codex OAuth リバースプロキシ](../2-providers/2.1-add.md#codex-oauth-リバースプロキシclaude-プロバイダー) の完全なリスク通知をお読みください。
|
||||
xAI 連携はローカルのループバックコールバックではなく、OAuth 2.0 Device Authorization Grant を使用します。ネットワーク依存関係は次のとおりです:
|
||||
|
||||
| 依存関係 | エンドポイント |
|
||||
| -------- | -------------- |
|
||||
| OpenID discovery | `https://auth.x.ai/.well-known/openid-configuration` |
|
||||
| デバイス認証 | discovery の `device_authorization_endpoint`(現在は `https://auth.x.ai/oauth2/device/code`) |
|
||||
| Token 交換・更新 | discovery の `token_endpoint`(現在は `https://auth.x.ai/oauth2/token`) |
|
||||
| 推論 | `https://api.x.ai/v1/responses` |
|
||||
| モデル取得 | `https://api.x.ai/v1/models` |
|
||||
|
||||
ローカルのコールバックポートは使用しません。認証およびモデルリクエストは CC Switch のグローバル送信プロキシ設定を共有します。
|
||||
|
||||
xAI 連携は CC Switch 用に登録された OAuth アプリケーションを**使用しません**。公式 Grok CLI 用に登録された公開 OAuth クライアントの ID とスコープ(`client_id: b1a00492-073a-47ea-816f-4c329264a828`、`grok-cli:access` を含む)を再利用して、アカウントベースの API アクセスを取得します。
|
||||
|
||||
> **警告**:これらのアカウント型リバースプロキシは、リバースエンジニアリングされた OAuth 連携を使用しており、アカウントおよび利用規約上のリスクがあります。上流製品の OAuth クライアント再利用は公式にサポートされない可能性があり、サービス提供者によるアカウントの制限・停止につながるおそれがあります。各プロバイダーの規約を確認し、自己責任で使用してください。既存の Codex フローについては [2.1 プロバイダーの追加 → Codex OAuth リバースプロキシ](../2-providers/2.1-add.md#codex-oauth-リバースプロキシclaude-プロバイダー) も参照してください。
|
||||
|
||||
## バージョン情報ページ
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Claude Desktop パネルでは、CC Switch 上で Claude Desktop のプロバイ
|
||||
|
||||
- Claude Desktop でサードパーティの Anthropic 互換プロバイダーを使う
|
||||
- 三つの役割 ID 以外のモデルにマッピングを設定する:旧式 Claude ID(例:`claude-3-5-sonnet`)や DeepSeek / Kimi / DouBao / OpenAI / Gemini などの非 Claude モデルはいずれも必要
|
||||
- Copilot / Codex OAuth のアカウント型プロバイダーを再利用する
|
||||
- Copilot / Codex OAuth / xAI OAuth のアカウント型プロバイダーを再利用する
|
||||
- Claude Desktop 公式モードとサードパーティプロバイダーを切り替える
|
||||
|
||||
Claude Desktop と Claude Code は別々のアプリ入口です。Claude Code は `~/.claude/settings.json` を使い、Claude Desktop は専用の 3P profile 設定を使います。CC Switch 上でも「Claude」と「Claude Desktop」として別々に表示され、アイコン右下の小さなバッジで区別できます。
|
||||
|
||||
@@ -275,7 +275,12 @@ Codex OAuth リバースプロキシは **リバースエンジニアリング
|
||||
|
||||
### ログファイル
|
||||
|
||||
Issue を提出する際にログファイルを添付できます:
|
||||
CC Switch のログはアプリケーション設定ディレクトリに保存されます。既定ではホームディレクトリ内の `.cc-switch` です。詳細設定で設定ディレクトリを変更した場合は、その場所を使用してください。
|
||||
|
||||
- macOS/Linux:`~/.cc-switch/logs/`
|
||||
- Windows:`%APPDATA%\cc-switch\logs\`
|
||||
Issue を提出する際は、問題に応じて次のファイルを添付してください:
|
||||
|
||||
- 一般的なエラー、ネットワーク、プロキシの問題:`~/.cc-switch/logs/cc-switch.log` とローテーション済みファイル
|
||||
- アプリケーションのクラッシュ:`~/.cc-switch/crash.log`、`crash.log.1`、`crash.log.2`
|
||||
- Windows の既定パス:`C:\Users\<ユーザー名>\.cc-switch\...`
|
||||
|
||||
実行ログは 20 MB ごとにローテーションされ、直近 4 個のアーカイブを保持します。アプリを再起動しても既存ログは削除されません。公開する前に、ログの内容を確認してください。
|
||||
|
||||
@@ -278,21 +278,36 @@ CC Switch 自身数据的存储位置,默认为 `~/.cc-switch/`。
|
||||
|
||||
设置 → **OAuth 认证中心** Tab
|
||||
|
||||
v3.13.0 新增的 **OAuth 认证中心**(Beta)统一管理第三方 OAuth 凭据,目前支持两类账号:
|
||||
v3.13.0 新增的 **OAuth 认证中心**(Beta)统一管理第三方 OAuth 凭据,目前支持三类账号:
|
||||
|
||||
| 账号类型 | 用途 |
|
||||
| ---------------------------- | ----------------------------------------------- |
|
||||
| **GitHub Copilot** | 配合 Copilot 反向代理使用 |
|
||||
| **ChatGPT (Codex OAuth)** | 配合 Codex OAuth 反向代理使用,管理 ChatGPT 账号 |
|
||||
| **xAI (Grok OAuth)** | 配合 xAI Responses API 反向代理使用 |
|
||||
|
||||
**你可以在这里**:
|
||||
|
||||
- 通过 Device Code 流程登录 ChatGPT / GitHub 账号
|
||||
- 通过 Device Code 流程登录 ChatGPT / GitHub / xAI 账号
|
||||
- 查看已登录账号列表和认证状态
|
||||
- 为多账号设置默认账号
|
||||
- 移除单个账号或一键注销所有账号
|
||||
|
||||
> ⚠️ **注意**:这两项功能使用逆向 OAuth 流程,存在账号风险和服务条款风险。使用前请阅读 [2.1 添加供应商 → Codex OAuth 反向代理](../2-providers/2.1-add.md#codex-oauth-反向代理claude-供应商) 的完整风险提示。
|
||||
xAI 集成使用 OAuth 2.0 Device Authorization Grant,不使用本机回环回调。其网络依赖契约如下:
|
||||
|
||||
| 依赖 | 端点 |
|
||||
| ---- | ---- |
|
||||
| OpenID discovery | `https://auth.x.ai/.well-known/openid-configuration` |
|
||||
| 设备授权 | discovery 返回的 `device_authorization_endpoint`(当前为 `https://auth.x.ai/oauth2/device/code`) |
|
||||
| Token 交换/刷新 | discovery 返回的 `token_endpoint`(当前为 `https://auth.x.ai/oauth2/token`) |
|
||||
| 推理 | `https://api.x.ai/v1/responses` |
|
||||
| 模型发现 | `https://api.x.ai/v1/models` |
|
||||
|
||||
流程不占用任何本地回调端口;认证和模型请求均复用 CC Switch 的全局出站代理设置。
|
||||
|
||||
xAI 集成**不使用**注册给 CC Switch 的 OAuth 应用,而是复用 xAI 官方 Grok CLI 注册的公共 OAuth 客户端身份与 scope(`client_id: b1a00492-073a-47ea-816f-4c329264a828`,包含 `grok-cli:access`)获取账号型 API 访问权限。
|
||||
|
||||
> ⚠️ **警告**:这些账号型反向代理使用逆向接入的 OAuth 流程,存在账号与服务条款风险。复用上游产品的 OAuth 客户端可能不受官方支持,并可能导致服务商限制或封禁账号。请在使用前审阅对应服务商条款并自行承担风险;现有 Codex 流程另见 [2.1 添加供应商 → Codex OAuth 反向代理](../2-providers/2.1-add.md#codex-oauth-反向代理claude-供应商) 的完整风险提示。
|
||||
|
||||
## 关于页面
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Claude Desktop 面板用于在 CC Switch 中管理 Claude Desktop 的供应商
|
||||
|
||||
- 在 Claude Desktop 中使用第三方 Anthropic 兼容供应商
|
||||
- 为三档角色 ID 之外的模型配置映射:旧式 Claude ID(如 `claude-3-5-sonnet`)、以及 DeepSeek / Kimi / DouBao / OpenAI / Gemini 等非 Claude 模型都需要
|
||||
- 复用 Copilot / Codex OAuth 账号类供应商
|
||||
- 复用 Copilot / Codex OAuth / xAI OAuth 账号类供应商
|
||||
- 在 Claude Desktop 官方模式和第三方供应商之间切换
|
||||
|
||||
Claude Desktop 与 Claude Code 是两个不同的应用入口。Claude Code 使用 `~/.claude/settings.json`,Claude Desktop 使用自己的 3P profile 配置;在 CC Switch 中也分别显示为「Claude」和「Claude Desktop」,图标右下角会有一个小图标用来区分。
|
||||
|
||||
@@ -275,7 +275,12 @@ Codex OAuth 反向代理通过**逆向工程的 OAuth 流程**访问 ChatGPT 账
|
||||
|
||||
### 日志文件
|
||||
|
||||
提交 Issue 时可附上日志文件:
|
||||
CC Switch 的日志位于应用配置目录中。默认配置目录是用户主目录下的 `.cc-switch`;如果你在高级设置中修改过配置目录,请以自定义目录为准。
|
||||
|
||||
- macOS/Linux:`~/.cc-switch/logs/`
|
||||
- Windows:`%APPDATA%\cc-switch\logs\`
|
||||
提交 Issue 时,请按问题类型附上:
|
||||
|
||||
- 普通错误、网络或代理问题:`~/.cc-switch/logs/` 中的 `cc-switch.log` 及其轮转文件
|
||||
- 应用崩溃:`~/.cc-switch/crash.log` 及 `crash.log.1`、`crash.log.2`
|
||||
- Windows 默认路径为 `C:\Users\<用户名>\.cc-switch\...`
|
||||
|
||||
运行日志按 20 MB 轮转,并保留最近 4 个归档;重启应用不会再清空已有日志。日志可能包含运行环境信息,公开提交前建议先检查内容。
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.17.0",
|
||||
"version": "3.18.0",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -70,6 +70,7 @@
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-log": "2.8.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-store": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
|
||||
Generated
+10
@@ -98,6 +98,9 @@ importers:
|
||||
'@tauri-apps/plugin-dialog':
|
||||
specifier: ^2.4.0
|
||||
version: 2.4.0
|
||||
'@tauri-apps/plugin-log':
|
||||
specifier: 2.8.0
|
||||
version: 2.8.0
|
||||
'@tauri-apps/plugin-process':
|
||||
specifier: ^2.0.0
|
||||
version: 2.3.0
|
||||
@@ -1562,6 +1565,9 @@ packages:
|
||||
'@tauri-apps/plugin-dialog@2.4.0':
|
||||
resolution: {integrity: sha512-OvXkrEBfWwtd8tzVCEXIvRfNEX87qs2jv6SqmVPiHcJjBhSF/GUvjqUNIDmKByb5N8nvDqVUM7+g1sXwdC/S9w==}
|
||||
|
||||
'@tauri-apps/plugin-log@2.8.0':
|
||||
resolution: {integrity: sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==}
|
||||
|
||||
'@tauri-apps/plugin-process@2.3.0':
|
||||
resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==}
|
||||
|
||||
@@ -4348,6 +4354,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.8.0
|
||||
|
||||
'@tauri-apps/plugin-log@2.8.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.8.0
|
||||
|
||||
'@tauri-apps/plugin-process@2.3.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.8.0
|
||||
|
||||
Generated
+11
-1
@@ -758,7 +758,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.17.0"
|
||||
version = "3.18.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -799,6 +799,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha2",
|
||||
"sys-locale",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
@@ -5438,6 +5439,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.17.0"
|
||||
version = "3.18.0"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
@@ -81,6 +81,7 @@ sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
json5 = "0.4"
|
||||
json-five = "0.3.1"
|
||||
sys-locale = "0.3"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"updater:default",
|
||||
"log:default",
|
||||
"core:window:allow-set-skip-taskbar",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
@@ -19,4 +18,4 @@
|
||||
"process:allow-restart",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct McpApps {
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
#[serde(default)]
|
||||
pub grokbuild: bool,
|
||||
#[serde(default)]
|
||||
pub opencode: bool,
|
||||
#[serde(default)]
|
||||
pub hermes: bool,
|
||||
@@ -26,6 +28,7 @@ impl McpApps {
|
||||
AppType::Claude => self.claude,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::GrokBuild => self.grokbuild,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
|
||||
AppType::Hermes => self.hermes,
|
||||
@@ -39,6 +42,7 @@ impl McpApps {
|
||||
AppType::Claude => self.claude = enabled,
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::GrokBuild => self.grokbuild = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
|
||||
AppType::Hermes => self.hermes = enabled,
|
||||
@@ -58,6 +62,9 @@ impl McpApps {
|
||||
if self.gemini {
|
||||
apps.push(AppType::Gemini);
|
||||
}
|
||||
if self.grokbuild {
|
||||
apps.push(AppType::GrokBuild);
|
||||
}
|
||||
if self.opencode {
|
||||
apps.push(AppType::OpenCode);
|
||||
}
|
||||
@@ -69,7 +76,12 @@ impl McpApps {
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
|
||||
!self.claude
|
||||
&& !self.codex
|
||||
&& !self.gemini
|
||||
&& !self.grokbuild
|
||||
&& !self.opencode
|
||||
&& !self.hermes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +95,8 @@ pub struct SkillApps {
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
#[serde(default)]
|
||||
pub grokbuild: bool,
|
||||
#[serde(default)]
|
||||
pub opencode: bool,
|
||||
#[serde(default)]
|
||||
pub hermes: bool,
|
||||
@@ -95,6 +109,7 @@ impl SkillApps {
|
||||
AppType::Claude => self.claude,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::GrokBuild => self.grokbuild,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::Hermes => self.hermes,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
|
||||
@@ -108,6 +123,7 @@ impl SkillApps {
|
||||
AppType::Claude => self.claude = enabled,
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::GrokBuild => self.grokbuild = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::Hermes => self.hermes = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
|
||||
@@ -127,6 +143,9 @@ impl SkillApps {
|
||||
if self.gemini {
|
||||
apps.push(AppType::Gemini);
|
||||
}
|
||||
if self.grokbuild {
|
||||
apps.push(AppType::GrokBuild);
|
||||
}
|
||||
if self.opencode {
|
||||
apps.push(AppType::OpenCode);
|
||||
}
|
||||
@@ -138,7 +157,12 @@ impl SkillApps {
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
|
||||
!self.claude
|
||||
&& !self.codex
|
||||
&& !self.gemini
|
||||
&& !self.grokbuild
|
||||
&& !self.opencode
|
||||
&& !self.hermes
|
||||
}
|
||||
|
||||
/// 仅启用指定应用(其他应用设为禁用)
|
||||
@@ -271,6 +295,8 @@ pub struct McpRoot {
|
||||
pub codex: McpConfig,
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub gemini: McpConfig,
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub grokbuild: McpConfig,
|
||||
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json)
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub opencode: McpConfig,
|
||||
@@ -292,6 +318,7 @@ impl Default for McpRoot {
|
||||
claude_desktop: McpConfig::default(),
|
||||
codex: McpConfig::default(),
|
||||
gemini: McpConfig::default(),
|
||||
grokbuild: McpConfig::default(),
|
||||
opencode: McpConfig::default(),
|
||||
openclaw: McpConfig::default(),
|
||||
hermes: McpConfig::default(),
|
||||
@@ -323,6 +350,8 @@ pub struct PromptRoot {
|
||||
#[serde(default)]
|
||||
pub gemini: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub grokbuild: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub opencode: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub openclaw: PromptConfig,
|
||||
@@ -348,6 +377,7 @@ pub enum AppType {
|
||||
ClaudeDesktop,
|
||||
Codex,
|
||||
Gemini,
|
||||
GrokBuild,
|
||||
OpenCode,
|
||||
OpenClaw,
|
||||
Hermes,
|
||||
@@ -360,6 +390,7 @@ impl AppType {
|
||||
AppType::ClaudeDesktop => "claude-desktop",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::GrokBuild => "grokbuild",
|
||||
AppType::OpenCode => "opencode",
|
||||
AppType::OpenClaw => "openclaw",
|
||||
AppType::Hermes => "hermes",
|
||||
@@ -384,6 +415,7 @@ impl AppType {
|
||||
AppType::ClaudeDesktop,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::GrokBuild,
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
AppType::Hermes,
|
||||
@@ -402,13 +434,14 @@ impl FromStr for AppType {
|
||||
"claude-desktop" | "claude_desktop" | "claudedesktop" => Ok(AppType::ClaudeDesktop),
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"grokbuild" | "grok-build" | "grok_build" | "grok" => Ok(AppType::GrokBuild),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
"openclaw" => Ok(AppType::OpenClaw),
|
||||
"hermes" => Ok(AppType::Hermes),
|
||||
other => Err(AppError::localized(
|
||||
"unsupported_app",
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes."),
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -444,6 +477,7 @@ impl CommonConfigSnippets {
|
||||
AppType::ClaudeDesktop => None,
|
||||
AppType::Codex => self.codex.as_ref(),
|
||||
AppType::Gemini => self.gemini.as_ref(),
|
||||
AppType::GrokBuild => None,
|
||||
AppType::OpenCode => self.opencode.as_ref(),
|
||||
AppType::OpenClaw => self.openclaw.as_ref(),
|
||||
AppType::Hermes => self.hermes.as_ref(),
|
||||
@@ -457,6 +491,7 @@ impl CommonConfigSnippets {
|
||||
AppType::ClaudeDesktop => {}
|
||||
AppType::Codex => self.codex = snippet,
|
||||
AppType::Gemini => self.gemini = snippet,
|
||||
AppType::GrokBuild => {}
|
||||
AppType::OpenCode => self.opencode = snippet,
|
||||
AppType::OpenClaw => self.openclaw = snippet,
|
||||
AppType::Hermes => self.hermes = snippet,
|
||||
@@ -500,6 +535,7 @@ impl Default for MultiAppConfig {
|
||||
apps.insert("claude-desktop".to_string(), ProviderManager::default());
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
apps.insert("gemini".to_string(), ProviderManager::default());
|
||||
apps.insert("grokbuild".to_string(), ProviderManager::default());
|
||||
apps.insert("opencode".to_string(), ProviderManager::default());
|
||||
apps.insert("openclaw".to_string(), ProviderManager::default());
|
||||
apps.insert("hermes".to_string(), ProviderManager::default());
|
||||
@@ -662,6 +698,7 @@ impl MultiAppConfig {
|
||||
AppType::ClaudeDesktop => &self.mcp.claude_desktop,
|
||||
AppType::Codex => &self.mcp.codex,
|
||||
AppType::Gemini => &self.mcp.gemini,
|
||||
AppType::GrokBuild => &self.mcp.grokbuild,
|
||||
AppType::OpenCode => &self.mcp.opencode,
|
||||
AppType::OpenClaw => &self.mcp.openclaw,
|
||||
AppType::Hermes => &self.mcp.hermes,
|
||||
@@ -675,6 +712,7 @@ impl MultiAppConfig {
|
||||
AppType::ClaudeDesktop => &mut self.mcp.claude_desktop,
|
||||
AppType::Codex => &mut self.mcp.codex,
|
||||
AppType::Gemini => &mut self.mcp.gemini,
|
||||
AppType::GrokBuild => &mut self.mcp.grokbuild,
|
||||
AppType::OpenCode => &mut self.mcp.opencode,
|
||||
AppType::OpenClaw => &mut self.mcp.openclaw,
|
||||
AppType::Hermes => &mut self.mcp.hermes,
|
||||
@@ -691,6 +729,7 @@ impl MultiAppConfig {
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::GrokBuild)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?;
|
||||
@@ -714,6 +753,7 @@ impl MultiAppConfig {
|
||||
|| !self.prompts.claude_desktop.prompts.is_empty()
|
||||
|| !self.prompts.codex.prompts.is_empty()
|
||||
|| !self.prompts.gemini.prompts.is_empty()
|
||||
|| !self.prompts.grokbuild.prompts.is_empty()
|
||||
|| !self.prompts.opencode.prompts.is_empty()
|
||||
|| !self.prompts.openclaw.prompts.is_empty()
|
||||
|| !self.prompts.hermes.prompts.is_empty()
|
||||
@@ -728,6 +768,7 @@ impl MultiAppConfig {
|
||||
AppType::Claude,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::GrokBuild,
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
AppType::Hermes,
|
||||
@@ -801,6 +842,7 @@ impl MultiAppConfig {
|
||||
AppType::ClaudeDesktop => &mut config.prompts.claude_desktop.prompts,
|
||||
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||
AppType::GrokBuild => &mut config.prompts.grokbuild.prompts,
|
||||
AppType::OpenCode => &mut config.prompts.opencode.prompts,
|
||||
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
|
||||
AppType::Hermes => &mut config.prompts.hermes.prompts,
|
||||
@@ -843,6 +885,7 @@ impl MultiAppConfig {
|
||||
AppType::ClaudeDesktop => continue, // Claude Desktop 3P profiles don't use MCP here
|
||||
AppType::Codex => &self.mcp.codex.servers,
|
||||
AppType::Gemini => &self.mcp.gemini.servers,
|
||||
AppType::GrokBuild => continue,
|
||||
AppType::OpenCode => &self.mcp.opencode.servers,
|
||||
AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip
|
||||
AppType::Hermes => continue, // Hermes didn't exist in v3.6.x, skip
|
||||
@@ -1133,6 +1176,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn auto_imports_grokbuild_prompt_on_first_launch() {
|
||||
let _home = TempHome::new();
|
||||
write_prompt_file(AppType::GrokBuild, "# Grok Build Prompt\n\nTest content");
|
||||
|
||||
let config = MultiAppConfig::load().expect("load config");
|
||||
|
||||
assert_eq!(config.prompts.grokbuild.prompts.len(), 1);
|
||||
let prompt = config
|
||||
.prompts
|
||||
.grokbuild
|
||||
.prompts
|
||||
.values()
|
||||
.next()
|
||||
.expect("grokbuild prompt exists");
|
||||
assert!(prompt.enabled, "grokbuild prompt should be enabled");
|
||||
assert_eq!(prompt.content, "# Grok Build Prompt\n\nTest content");
|
||||
assert_eq!(
|
||||
prompt.description,
|
||||
Some("Automatically imported on first launch".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn auto_imports_all_three_apps_prompts() {
|
||||
|
||||
@@ -371,7 +371,7 @@ pub fn validate_direct_provider(provider: &Provider) -> Result<(), AppError> {
|
||||
|
||||
if matches!(
|
||||
meta.provider_type.as_deref(),
|
||||
Some("github_copilot") | Some("codex_oauth")
|
||||
Some("github_copilot") | Some("codex_oauth") | Some("xai_oauth")
|
||||
) {
|
||||
return Err(AppError::localized(
|
||||
"claude_desktop.provider.type_unsupported",
|
||||
@@ -476,7 +476,12 @@ fn is_managed_oauth_proxy_provider(provider: &Provider) -> bool {
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.provider_type.as_deref())
|
||||
.is_some_and(|provider_type| matches!(provider_type, "github_copilot" | "codex_oauth"))
|
||||
.is_some_and(|provider_type| {
|
||||
matches!(
|
||||
provider_type,
|
||||
"github_copilot" | "codex_oauth" | "xai_oauth"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_provider(provider: &Provider) -> Result<(), AppError> {
|
||||
@@ -1583,6 +1588,7 @@ mod tests {
|
||||
for (provider_type, api_format) in [
|
||||
("github_copilot", "openai_chat"),
|
||||
("codex_oauth", "openai_responses"),
|
||||
("xai_oauth", "openai_responses"),
|
||||
] {
|
||||
let provider = oauth_proxy_provider(provider_type, provider_type, api_format);
|
||||
validate_proxy_provider(&provider).expect("oauth proxy provider should validate");
|
||||
|
||||
@@ -7,9 +7,10 @@ use crate::config::{
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::model_capabilities::{image_input_capability_from_modalities, ImageInputCapability};
|
||||
use once_cell::sync::OnceCell;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use std::process::{Command, Stdio};
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom";
|
||||
@@ -21,6 +22,17 @@ pub const CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID: &str = "cc-switch-official
|
||||
pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json";
|
||||
const CODEX_PROXY_AUTH_PLACEHOLDER: &str = "PROXY_MANAGED";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
// Generating a ProxyChat catalog only needs one stable Codex model template per
|
||||
// process. Without this cache every provider switch/takeover can start the
|
||||
// Codex CLI again, which is especially expensive for npm-installed `codex.cmd`
|
||||
// on Windows. Tests deliberately bypass the global cache because they isolate
|
||||
// CODEX_HOME and seed different model templates.
|
||||
#[cfg(not(test))]
|
||||
static CODEX_MODEL_CATALOG_TEMPLATE_CACHE: OnceCell<Value> = OnceCell::new();
|
||||
|
||||
/// Top-level `config.toml` key that controls Codex's built-in web-search tool.
|
||||
pub(crate) const CODEX_WEB_SEARCH_FIELD: &str = "web_search";
|
||||
/// Value that disables the web-search tool. Some native `/responses` gateways
|
||||
@@ -804,13 +816,28 @@ fn codex_cli_candidates() -> Vec<PathBuf> {
|
||||
candidates
|
||||
}
|
||||
|
||||
fn codex_bundled_models_command(candidate: &Path) -> Command {
|
||||
let mut command = Command::new(candidate);
|
||||
command
|
||||
.args(["debug", "models", "--bundled"])
|
||||
.stdin(Stdio::null());
|
||||
|
||||
// A release build uses the Windows GUI subsystem, so a console child that
|
||||
// is created without this flag gets its own transient console window. npm
|
||||
// installs Codex as `codex.cmd`, which Windows launches through cmd.exe.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
command
|
||||
}
|
||||
|
||||
fn load_codex_model_template_from_bundled() -> Result<Option<Value>, AppError> {
|
||||
for candidate in codex_cli_candidates() {
|
||||
let candidate_label = candidate.to_string_lossy();
|
||||
let output = match Command::new(&candidate)
|
||||
.args(["debug", "models", "--bundled"])
|
||||
.output()
|
||||
{
|
||||
let output = match codex_bundled_models_command(&candidate).output() {
|
||||
Ok(output) => output,
|
||||
Err(err) => {
|
||||
log::debug!("failed to run `{candidate_label} debug models --bundled`: {err}");
|
||||
@@ -864,13 +891,49 @@ fn load_codex_native_responses_template() -> Value {
|
||||
serde_json::from_str(text).expect("bundled codex native responses template must be valid JSON")
|
||||
}
|
||||
|
||||
fn load_codex_model_catalog_template() -> Result<Value, AppError> {
|
||||
/// Fields Codex's external-catalog parser REQUIRES (no serde default): when
|
||||
/// one is missing Codex rejects the whole catalog file at startup ("missing
|
||||
/// field ..."). `base_instructions` is the other known required field; the
|
||||
/// templates always carry it and `codex_catalog_model_entry` handles it.
|
||||
/// When Codex requires a new field, add it here AND to the static templates.
|
||||
const CODEX_CATALOG_PARSER_REQUIRED_FIELDS: &[&str] = &["supports_reasoning_summaries"];
|
||||
|
||||
/// `models_cache.json` is shared by every Codex install on the machine (npm
|
||||
/// CLI, desktop-bundled binary, ...), and each version serializes its own
|
||||
/// `ModelInfo` shape — the cache's field set follows whichever process wrote
|
||||
/// it last, so it cannot be assumed to satisfy the current external-catalog
|
||||
/// schema (observed live: 0.144.5 requires `supports_reasoning_summaries`
|
||||
/// while a coexisting build kept rewriting the cache without it). Backfill
|
||||
/// ONLY parser-required fields from the bundled static template: optional
|
||||
/// capability fields keep their missing-means-default semantics, and existing
|
||||
/// values always win.
|
||||
fn fill_template_fields_from_static(template: &mut Value) {
|
||||
let Some(static_template) = load_codex_model_template_static() else {
|
||||
return;
|
||||
};
|
||||
let (Some(template_obj), Some(static_obj)) =
|
||||
(template.as_object_mut(), static_template.as_object())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for key in CODEX_CATALOG_PARSER_REQUIRED_FIELDS {
|
||||
if !template_obj.contains_key(*key) {
|
||||
if let Some(value) = static_obj.get(*key) {
|
||||
template_obj.insert((*key).to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_codex_model_catalog_template_uncached() -> Result<Value, AppError> {
|
||||
// ① models_cache.json (created by Codex when it connects to OpenAI)
|
||||
if let Some(template) = load_codex_model_template_from_cache()? {
|
||||
if let Some(mut template) = load_codex_model_template_from_cache()? {
|
||||
fill_template_fields_from_static(&mut template);
|
||||
return Ok(template);
|
||||
}
|
||||
// ② codex CLI (PATH + platform-specific common paths)
|
||||
if let Some(template) = load_codex_model_template_from_bundled()? {
|
||||
if let Some(mut template) = load_codex_model_template_from_bundled()? {
|
||||
fill_template_fields_from_static(&mut template);
|
||||
return Ok(template);
|
||||
}
|
||||
// ③ Static fallback bundled at compile time
|
||||
@@ -883,6 +946,29 @@ fn load_codex_model_catalog_template() -> Result<Value, AppError> {
|
||||
)))
|
||||
}
|
||||
|
||||
fn get_or_load_codex_model_catalog_template<F>(
|
||||
cache: &OnceCell<Value>,
|
||||
loader: F,
|
||||
) -> Result<Value, AppError>
|
||||
where
|
||||
F: FnOnce() -> Result<Value, AppError>,
|
||||
{
|
||||
cache.get_or_try_init(loader).cloned()
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn load_codex_model_catalog_template() -> Result<Value, AppError> {
|
||||
get_or_load_codex_model_catalog_template(
|
||||
&CODEX_MODEL_CATALOG_TEMPLATE_CACHE,
|
||||
load_codex_model_catalog_template_uncached,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn load_codex_model_catalog_template() -> Result<Value, AppError> {
|
||||
load_codex_model_catalog_template_uncached()
|
||||
}
|
||||
|
||||
fn codex_model_catalog_from_specs(
|
||||
specs: &[CodexCatalogModelSpec],
|
||||
template: &Value,
|
||||
@@ -2647,6 +2733,68 @@ base_url = "https://production.api/v1"
|
||||
assert_eq!(base_url, Some("https://production.api/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_template_backfills_parser_required_fields_from_static() {
|
||||
// Simulate a template cloned from a models_cache.json written by a
|
||||
// Codex build whose ModelInfo lacks parser-side required fields such
|
||||
// as `supports_reasoning_summaries` (codex >= 0.144.5 rejects the
|
||||
// whole catalog file without it).
|
||||
let mut template = json!({
|
||||
"slug": "gpt-5.5",
|
||||
"context_window": 272_000,
|
||||
"supports_parallel_tool_calls": false
|
||||
});
|
||||
fill_template_fields_from_static(&mut template);
|
||||
|
||||
assert_eq!(
|
||||
template
|
||||
.get("supports_reasoning_summaries")
|
||||
.and_then(Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
// Keys already present in the dynamic template are never overwritten.
|
||||
assert_eq!(
|
||||
template
|
||||
.get("supports_parallel_tool_calls")
|
||||
.and_then(Value::as_bool),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
template.get("context_window").and_then(Value::as_u64),
|
||||
Some(272_000)
|
||||
);
|
||||
// Optional capability fields must NOT be backfilled: for the catalog
|
||||
// parser "missing" means the parser default, not the static
|
||||
// template's value.
|
||||
assert!(template.get("supports_search_tool").is_none());
|
||||
assert!(template.get("supports_image_detail_original").is_none());
|
||||
assert!(template.get("web_search_tool_type").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_chat_catalog_entries_carry_reasoning_summaries_flag() {
|
||||
// End to end: a stale dynamic template, once backfilled, must yield
|
||||
// catalog entries codex 0.144.5+ can parse.
|
||||
let mut template = json!({ "slug": "gpt-5.5" });
|
||||
fill_template_fields_from_static(&mut template);
|
||||
let specs = vec![CodexCatalogModelSpec {
|
||||
model: "k3".to_string(),
|
||||
display_name: "Kimi K3".to_string(),
|
||||
context_window: 262_144,
|
||||
supports_parallel_tool_calls: None,
|
||||
input_modalities: None,
|
||||
base_instructions: None,
|
||||
}];
|
||||
let catalog =
|
||||
codex_model_catalog_from_specs(&specs, &template, CodexCatalogToolProfile::ProxyChat);
|
||||
assert_eq!(
|
||||
catalog["models"][0]
|
||||
.get("supports_reasoning_summaries")
|
||||
.and_then(Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_model_catalog_uses_provider_models_and_context() {
|
||||
let template = json!({
|
||||
@@ -3309,6 +3457,63 @@ web_search = "disabled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_bundled_models_command_uses_expected_program_and_args() {
|
||||
let command = codex_bundled_models_command(Path::new("codex"));
|
||||
assert_eq!(command.get_program(), "codex");
|
||||
assert_eq!(
|
||||
command
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
["debug", "models", "--bundled"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_model_catalog_template_load_is_cached() {
|
||||
use std::cell::Cell;
|
||||
|
||||
let cache = OnceCell::new();
|
||||
let calls = Cell::new(0);
|
||||
let first = get_or_load_codex_model_catalog_template(&cache, || {
|
||||
calls.set(calls.get() + 1);
|
||||
Ok(json!({ "slug": "first" }))
|
||||
})
|
||||
.expect("first template load");
|
||||
let second = get_or_load_codex_model_catalog_template(&cache, || {
|
||||
calls.set(calls.get() + 1);
|
||||
Ok(json!({ "slug": "second" }))
|
||||
})
|
||||
.expect("cached template load");
|
||||
|
||||
assert_eq!(first, json!({ "slug": "first" }));
|
||||
assert_eq!(second, first);
|
||||
assert_eq!(calls.get(), 1, "successful template should load only once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_model_catalog_template_load_can_retry() {
|
||||
use std::cell::Cell;
|
||||
|
||||
let cache = OnceCell::new();
|
||||
let calls = Cell::new(0);
|
||||
let first = get_or_load_codex_model_catalog_template(&cache, || {
|
||||
calls.set(calls.get() + 1);
|
||||
Err(AppError::Message("temporary failure".to_string()))
|
||||
});
|
||||
assert!(first.is_err());
|
||||
|
||||
let second = get_or_load_codex_model_catalog_template(&cache, || {
|
||||
calls.set(calls.get() + 1);
|
||||
Ok(json!({ "slug": "recovered" }))
|
||||
})
|
||||
.expect("retry template load");
|
||||
|
||||
assert_eq!(second, json!({ "slug": "recovered" }));
|
||||
assert_eq!(calls.get(), 2, "failed loads must not poison the cache");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_cli_candidates_include_user_node_manager_bins() {
|
||||
let temp_home = tempfile::tempdir().expect("create temp home");
|
||||
|
||||
@@ -2,13 +2,16 @@ use tauri::State;
|
||||
|
||||
use crate::commands::codex_oauth::CodexOAuthState;
|
||||
use crate::commands::copilot::CopilotAuthState;
|
||||
use crate::commands::xai_oauth::XaiOAuthState;
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthError;
|
||||
use crate::proxy::providers::copilot_auth::{
|
||||
CopilotAuthError, GitHubAccount, GitHubDeviceCodeResponse,
|
||||
};
|
||||
use crate::proxy::providers::xai_oauth_auth::{XaiOAuthAccount, XaiOAuthError};
|
||||
|
||||
const AUTH_PROVIDER_GITHUB_COPILOT: &str = "github_copilot";
|
||||
const AUTH_PROVIDER_CODEX_OAUTH: &str = "codex_oauth";
|
||||
const AUTH_PROVIDER_XAI_OAUTH: &str = "xai_oauth";
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ManagedAuthAccount {
|
||||
@@ -19,6 +22,7 @@ pub struct ManagedAuthAccount {
|
||||
pub authenticated_at: i64,
|
||||
pub is_default: bool,
|
||||
pub github_domain: String,
|
||||
pub requires_reauth: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
@@ -44,6 +48,7 @@ fn ensure_auth_provider(auth_provider: &str) -> Result<&'static str, String> {
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => Ok(AUTH_PROVIDER_GITHUB_COPILOT),
|
||||
AUTH_PROVIDER_CODEX_OAUTH => Ok(AUTH_PROVIDER_CODEX_OAUTH),
|
||||
AUTH_PROVIDER_XAI_OAUTH => Ok(AUTH_PROVIDER_XAI_OAUTH),
|
||||
_ => Err(format!("Unsupported auth provider: {auth_provider}")),
|
||||
}
|
||||
}
|
||||
@@ -61,6 +66,23 @@ fn map_account(
|
||||
avatar_url: account.avatar_url,
|
||||
authenticated_at: account.authenticated_at,
|
||||
github_domain: account.github_domain,
|
||||
requires_reauth: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_xai_account(
|
||||
account: XaiOAuthAccount,
|
||||
default_account_id: Option<&str>,
|
||||
) -> ManagedAuthAccount {
|
||||
ManagedAuthAccount {
|
||||
is_default: default_account_id == Some(account.id.as_str()),
|
||||
id: account.id,
|
||||
provider: AUTH_PROVIDER_XAI_OAUTH.to_string(),
|
||||
login: account.login,
|
||||
avatar_url: account.avatar_url,
|
||||
authenticated_at: account.authenticated_at,
|
||||
github_domain: account.github_domain,
|
||||
requires_reauth: account.requires_reauth,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +106,7 @@ pub async fn auth_start_login(
|
||||
github_domain: Option<String>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
xai_state: State<'_, XaiOAuthState>,
|
||||
) -> Result<ManagedAuthDeviceCodeResponse, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
match auth_provider {
|
||||
@@ -103,6 +126,14 @@ pub async fn auth_start_login(
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(map_device_code_response(auth_provider, response))
|
||||
}
|
||||
AUTH_PROVIDER_XAI_OAUTH => {
|
||||
let auth_manager = xai_state.0.read().await;
|
||||
let response = auth_manager
|
||||
.start_device_flow()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(map_device_code_response(auth_provider, response))
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -114,6 +145,7 @@ pub async fn auth_poll_for_account(
|
||||
github_domain: Option<String>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
xai_state: State<'_, XaiOAuthState>,
|
||||
) -> Result<Option<ManagedAuthAccount>, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
match auth_provider {
|
||||
@@ -146,6 +178,18 @@ pub async fn auth_poll_for_account(
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
AUTH_PROVIDER_XAI_OAUTH => {
|
||||
let auth_manager = xai_state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
Ok(account) => {
|
||||
let default_account_id = auth_manager.get_status().await.default_account_id;
|
||||
Ok(account
|
||||
.map(|account| map_xai_account(account, default_account_id.as_deref())))
|
||||
}
|
||||
Err(XaiOAuthError::AuthorizationPending) => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -155,6 +199,7 @@ pub async fn auth_list_accounts(
|
||||
auth_provider: String,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
xai_state: State<'_, XaiOAuthState>,
|
||||
) -> Result<Vec<ManagedAuthAccount>, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
match auth_provider {
|
||||
@@ -178,6 +223,16 @@ pub async fn auth_list_accounts(
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
|
||||
.collect())
|
||||
}
|
||||
AUTH_PROVIDER_XAI_OAUTH => {
|
||||
let auth_manager = xai_state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| map_xai_account(account, default_account_id.as_deref()))
|
||||
.collect())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -187,6 +242,7 @@ pub async fn auth_get_status(
|
||||
auth_provider: String,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
xai_state: State<'_, XaiOAuthState>,
|
||||
) -> Result<ManagedAuthStatus, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
match auth_provider {
|
||||
@@ -226,6 +282,22 @@ pub async fn auth_get_status(
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
AUTH_PROVIDER_XAI_OAUTH => {
|
||||
let auth_manager = xai_state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(ManagedAuthStatus {
|
||||
provider: auth_provider.to_string(),
|
||||
authenticated: status.authenticated,
|
||||
default_account_id: default_account_id.clone(),
|
||||
migration_error: None,
|
||||
accounts: status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| map_xai_account(account, default_account_id.as_deref()))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -236,6 +308,7 @@ pub async fn auth_remove_account(
|
||||
account_id: String,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
xai_state: State<'_, XaiOAuthState>,
|
||||
) -> Result<(), String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
match auth_provider {
|
||||
@@ -253,6 +326,13 @@ pub async fn auth_remove_account(
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
AUTH_PROVIDER_XAI_OAUTH => {
|
||||
let auth_manager = xai_state.0.write().await;
|
||||
auth_manager
|
||||
.remove_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -263,6 +343,7 @@ pub async fn auth_set_default_account(
|
||||
account_id: String,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
xai_state: State<'_, XaiOAuthState>,
|
||||
) -> Result<(), String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
match auth_provider {
|
||||
@@ -280,6 +361,13 @@ pub async fn auth_set_default_account(
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
AUTH_PROVIDER_XAI_OAUTH => {
|
||||
let auth_manager = xai_state.0.write().await;
|
||||
auth_manager
|
||||
.set_default_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -289,6 +377,7 @@ pub async fn auth_logout(
|
||||
auth_provider: String,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
xai_state: State<'_, XaiOAuthState>,
|
||||
) -> Result<(), String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
match auth_provider {
|
||||
@@ -300,6 +389,10 @@ pub async fn auth_logout(
|
||||
let auth_manager = codex_state.0.write().await;
|
||||
auth_manager.clear_auth().await.map_err(|e| e.to_string())
|
||||
}
|
||||
AUTH_PROVIDER_XAI_OAUTH => {
|
||||
let auth_manager = xai_state.0.write().await;
|
||||
auth_manager.clear_auth().await.map_err(|e| e.to_string())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,15 @@ pub async fn get_config_status(
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
let config_path = crate::grok_config::get_grok_config_path();
|
||||
let exists = config_path.exists();
|
||||
let path = crate::grok_config::get_grok_config_dir()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
let config_path = crate::opencode_config::get_opencode_config_path();
|
||||
let exists = config_path.exists();
|
||||
@@ -143,6 +152,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||
}
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::GrokBuild => crate::grok_config::get_grok_config_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
@@ -160,6 +170,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
||||
}
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::GrokBuild => crate::grok_config::get_grok_config_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
|
||||
@@ -8,7 +8,7 @@ use tauri::State;
|
||||
/// Parse a deep link URL and return the parsed request for frontend confirmation
|
||||
#[tauri::command]
|
||||
pub fn parse_deeplink(url: String) -> Result<DeepLinkImportRequest, String> {
|
||||
log::info!("Parsing deep link URL: {url}");
|
||||
log::info!("Parsing deep link URL: {}", crate::url_for_log(&url));
|
||||
parse_deeplink_url(&url).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
|
||||
+350
-34
@@ -111,8 +111,8 @@ pub struct ToolVersion {
|
||||
wsl_distro: Option<String>,
|
||||
}
|
||||
|
||||
const VALID_TOOLS: [&str; 6] = [
|
||||
"claude", "codex", "gemini", "opencode", "openclaw", "hermes",
|
||||
const VALID_TOOLS: [&str; 7] = [
|
||||
"claude", "codex", "gemini", "grok", "opencode", "openclaw", "hermes",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
@@ -424,6 +424,7 @@ fn tool_display_name(tool: &str) -> &'static str {
|
||||
"claude" => "Claude Code",
|
||||
"codex" => "Codex",
|
||||
"gemini" => "Gemini CLI",
|
||||
"grok" => "Grok Build",
|
||||
"opencode" => "OpenCode",
|
||||
"openclaw" => "OpenClaw",
|
||||
"hermes" => "Hermes",
|
||||
@@ -440,6 +441,8 @@ const CLAUDE_INSTALL_UNIX: &str =
|
||||
"bash -c 'tmp=$(mktemp) && curl -fsSL https://claude.ai/install.sh -o $tmp && bash $tmp; status=$?; rm -f $tmp; exit $status'";
|
||||
const OPENCODE_INSTALL_UNIX: &str =
|
||||
"bash -c 'tmp=$(mktemp) && curl -fsSL https://opencode.ai/install -o $tmp && bash $tmp; status=$?; rm -f $tmp; exit $status'";
|
||||
const GROK_INSTALL_UNIX: &str =
|
||||
"bash -c 'tmp=$(mktemp) && curl -fsSL https://x.ai/cli/install.sh -o $tmp && bash $tmp; status=$?; rm -f $tmp; exit $status'";
|
||||
|
||||
/// Hermes 官方安装器会自带/选择合适的 Python 运行时。不要再用
|
||||
/// `python3 -m pip ... || python -m pip ...`:Hermes PyPI 包要求 Python >=3.11,
|
||||
@@ -453,6 +456,8 @@ const HERMES_UPDATE_UNIX: &str =
|
||||
#[cfg(target_os = "windows")]
|
||||
const HERMES_INSTALL_WINDOWS_SCRIPT: &str =
|
||||
"irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex";
|
||||
#[cfg(target_os = "windows")]
|
||||
const GROK_INSTALL_WINDOWS_SCRIPT: &str = "irm https://x.ai/cli/install.ps1 | iex";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn powershell_encoded_command(script: &str) -> String {
|
||||
@@ -473,6 +478,14 @@ fn hermes_install_windows_command() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn grok_install_windows_command() -> String {
|
||||
format!(
|
||||
"powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand {}",
|
||||
powershell_encoded_command(GROK_INSTALL_WINDOWS_SCRIPT)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn hermes_update_windows_command() -> String {
|
||||
// fallback 是 powershell.exe,不是 .cmd/.bat;这里不需要 `call`。PowerShell 的
|
||||
@@ -492,6 +505,7 @@ fn npm_install_command_for(tool: &str) -> Option<&'static str> {
|
||||
"claude" => Some("npm i -g @anthropic-ai/claude-code@latest"),
|
||||
"codex" => Some("npm i -g @openai/codex@latest"),
|
||||
"gemini" => Some("npm i -g @google/gemini-cli@latest"),
|
||||
"grok" => Some("npm i -g @xai-official/grok@latest"),
|
||||
"opencode" => Some("npm i -g opencode-ai@latest"),
|
||||
"openclaw" => Some("npm i -g openclaw@latest"),
|
||||
_ => None,
|
||||
@@ -500,7 +514,7 @@ fn npm_install_command_for(tool: &str) -> Option<&'static str> {
|
||||
|
||||
fn official_update_args(tool: &str) -> Option<&'static str> {
|
||||
match tool {
|
||||
"claude" | "codex" | "hermes" => Some("update"),
|
||||
"claude" | "codex" | "grok" | "hermes" => Some("update"),
|
||||
"openclaw" => Some("update --yes"),
|
||||
"opencode" => Some("upgrade"),
|
||||
_ => None,
|
||||
@@ -532,6 +546,20 @@ fn tool_action_shell_command_for_shell(
|
||||
action: ToolLifecycleAction,
|
||||
shell: LifecycleCommandShell,
|
||||
) -> Option<String> {
|
||||
// xAI's primary Windows distribution is the native PowerShell installer.
|
||||
// Keep npm as the network/policy fallback, matching the POSIX installer chain.
|
||||
#[cfg(target_os = "windows")]
|
||||
if tool == "grok"
|
||||
&& matches!(action, ToolLifecycleAction::Install)
|
||||
&& matches!(shell, LifecycleCommandShell::WindowsBatch)
|
||||
{
|
||||
return Some(chain_update_commands(
|
||||
grok_install_windows_command(),
|
||||
npm_install_command_for(tool)?.to_string(),
|
||||
shell,
|
||||
));
|
||||
}
|
||||
|
||||
if tool == "hermes" {
|
||||
return Some(
|
||||
match (action, shell) {
|
||||
@@ -762,6 +790,7 @@ async fn get_single_tool_version_impl(
|
||||
}
|
||||
"codex" => fetch_npm_latest_for_tool(&client, "@openai/codex", tool, local).await,
|
||||
"gemini" => fetch_npm_latest_for_tool(&client, "@google/gemini-cli", tool, local).await,
|
||||
"grok" => fetch_npm_latest_for_tool(&client, "@xai-official/grok", tool, local).await,
|
||||
"opencode" => {
|
||||
if let Some(version) =
|
||||
fetch_npm_latest_for_tool(&client, "opencode-ai", tool, local).await
|
||||
@@ -1451,6 +1480,22 @@ fn opencode_extra_search_paths(
|
||||
paths
|
||||
}
|
||||
|
||||
/// Grok's official installer writes the launcher to `$GROK_BIN_DIR` or, by
|
||||
/// default, `~/.grok/bin`. Keep these ahead of generic npm/Node locations so
|
||||
/// version probing and anchored updates can see the native distribution even
|
||||
/// when the GUI process inherited a stale PATH.
|
||||
fn grok_extra_search_paths(
|
||||
home: &Path,
|
||||
grok_bin_dir: Option<std::ffi::OsString>,
|
||||
) -> Vec<std::path::PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
push_env_single_dir(&mut paths, grok_bin_dir);
|
||||
if !home.as_os_str().is_empty() {
|
||||
push_unique_path(&mut paths, home.join(".grok").join("bin"));
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
fn tool_executable_candidates(tool: &str, dir: &Path) -> Vec<std::path::PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -1500,6 +1545,12 @@ fn build_tool_search_paths(tool: &str) -> Vec<std::path::PathBuf> {
|
||||
|
||||
// 常见的安装路径(原生安装优先)
|
||||
let mut search_paths: Vec<std::path::PathBuf> = Vec::new();
|
||||
if tool == "grok" {
|
||||
let extra_paths = grok_extra_search_paths(&home, std::env::var_os("GROK_BIN_DIR"));
|
||||
for path in extra_paths {
|
||||
push_unique_path(&mut search_paths, path);
|
||||
}
|
||||
}
|
||||
if !home.as_os_str().is_empty() {
|
||||
push_unique_path(&mut search_paths, home.join(".local/bin"));
|
||||
push_unique_path(&mut search_paths, home.join(".npm-global/bin"));
|
||||
@@ -1945,6 +1996,7 @@ fn npm_package_for(tool: &str) -> Option<&'static str> {
|
||||
"claude" => Some("@anthropic-ai/claude-code"),
|
||||
"codex" => Some("@openai/codex"),
|
||||
"gemini" => Some("@google/gemini-cli"),
|
||||
"grok" => Some("@xai-official/grok"),
|
||||
"opencode" => Some("opencode-ai"),
|
||||
"openclaw" => Some("openclaw"),
|
||||
_ => None,
|
||||
@@ -1983,6 +2035,19 @@ fn brew_formula_from_path(real: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// xAI's native installer uses `~/.grok/bin` for its launchers and
|
||||
/// `~/.grok/downloads/grok-<platform>` for the downloaded binary. The launcher
|
||||
/// directory also covers the default Windows layout, where the executable is
|
||||
/// copied instead of symlinked. Checking the real target additionally supports
|
||||
/// a custom `$GROK_BIN_DIR` on POSIX, whose launcher still points into the
|
||||
/// standard downloads directory.
|
||||
fn is_grok_native_install(bin_path: &str, real_target: &str) -> bool {
|
||||
[bin_path, real_target].iter().any(|path| {
|
||||
let normalized = path.replace('\\', "/").to_ascii_lowercase();
|
||||
normalized.contains("/.grok/bin/") || normalized.contains("/.grok/downloads/grok-")
|
||||
})
|
||||
}
|
||||
|
||||
/// 含空格才用 POSIX 单引号包一层,否则保持裸路径——命令展示更干净。
|
||||
/// claude / brew / volta / bun / npm 五个锚定分支共用,避免"含空格"判定漂移。
|
||||
///
|
||||
@@ -2108,6 +2173,28 @@ fn sibling_bin(bin_path: &str, exe: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an npm command anchored to the same Node installation as `bin_path`.
|
||||
///
|
||||
/// npm's POSIX launcher is normally a JavaScript file with an
|
||||
/// `#!/usr/bin/env node` shebang. Calling npm by absolute path therefore does
|
||||
/// not make it independent of PATH: `env` still needs to find `node`. GUI apps
|
||||
/// commonly inherit only the system PATH, while nvm/fnm/mise keep Node in a
|
||||
/// user directory. Prefixing npm's sibling directory makes both npm and its
|
||||
/// transitive Node interpreter resolve to the installation we selected.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn anchored_npm_command(bin_path: &str, args: &str) -> Option<String> {
|
||||
let dir = parent_dir(bin_path);
|
||||
if dir.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let npm = sibling_bin(bin_path, "npm")?;
|
||||
Some(format!(
|
||||
"PATH={}:\"$PATH\" {} {args}",
|
||||
shell_single_quote(&dir),
|
||||
quote_path_if_spaced(&npm)
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn anchored_official_update_command(tool: &str, bin_path: &str) -> Option<String> {
|
||||
official_update_args(tool).map(|args| format!("{} {args}", quote_path_if_spaced(bin_path)))
|
||||
@@ -2154,9 +2241,10 @@ fn prefers_official_update(tool: &str, shell: LifecycleCommandShell) -> bool {
|
||||
/// 二进制。唯一实测可靠的修复是先 `uninstall` 清掉残骸、再 `install` 装回完整的主包 + 平台二进制
|
||||
/// (实测输出 `added 2 packages`)。
|
||||
///
|
||||
/// 锚定到与 codex 入口同目录的 npm(与升级路径一致,不依赖 GUI 非登录进程的 PATH)。`|| true`
|
||||
/// 让 uninstall 失败(如 nvm 上对半损坏包静默返回非 0)不触发外层 `set -e` 中止,但随后的
|
||||
/// install 若失败仍会被 `set -e` 捕获并上报给前端 toast。
|
||||
/// 锚定到与 codex 入口同目录的 npm,并把该目录放到每条 npm 命令的 PATH 首位,使 npm
|
||||
/// 的 `#!/usr/bin/env node` 能找到同一安装下的 node,不依赖 GUI 非登录进程的 PATH。
|
||||
/// `|| true` 让 uninstall 失败(如 nvm 上对半损坏包静默返回非 0)不触发外层 `set -e`
|
||||
/// 中止,但随后的 install 若失败仍会被 `set -e` 捕获并上报给前端 toast。
|
||||
///
|
||||
/// **仅对会锚定到 sibling npm 的 node 管理器来源(nvm/fnm/mise/homebrew npm)生效**:
|
||||
/// `runnable=false` 是宽信号(权限 / node 版本 / 任意 `--version` 失败皆可触发),非 npm
|
||||
@@ -2183,12 +2271,10 @@ fn codex_repair_command(bin_path: &str, real: &str) -> Option<String> {
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let npm = sibling_bin(bin_path, "npm")?;
|
||||
let npm = quote_path_if_spaced(&npm);
|
||||
let pkg = "@openai/codex";
|
||||
Some(format!(
|
||||
"{npm} uninstall -g {pkg} || true; {npm} i -g {pkg}@latest"
|
||||
))
|
||||
let uninstall = anchored_npm_command(bin_path, &format!("uninstall -g {pkg}"))?;
|
||||
let install = anchored_npm_command(bin_path, &format!("i -g {pkg}@latest"))?;
|
||||
Some(format!("{uninstall} || true; {install}"))
|
||||
}
|
||||
|
||||
/// Windows 暂不做平台分发自愈:Windows 上 codex 的破坏模式不同(EPERM 文件锁 / 版本 bump
|
||||
@@ -2228,8 +2314,7 @@ fn package_manager_anchored_command_from_paths(
|
||||
// self-update,上层会直接锚到 CLI 自身;否则返回 None 走静态兜底。
|
||||
_ => return None,
|
||||
}
|
||||
let npm = sibling_bin(bin_path, "npm")?;
|
||||
Some(format!("{} i -g {pkg}@latest", quote_path_if_spaced(&npm)))
|
||||
anchored_npm_command(bin_path, &format!("i -g {pkg}@latest"))
|
||||
}
|
||||
|
||||
/// 给定工具、原始 bin 路径(命令行命中的入口)、canonicalize 后的真身路径,
|
||||
@@ -2238,7 +2323,8 @@ fn package_manager_anchored_command_from_paths(
|
||||
/// 便于单测覆盖各包管理器分支。Windows 版同名函数因 sibling 扩展名歧义必须读 fs,
|
||||
/// 是刻意保留的平台差异(详见 Windows 版本 doc)。
|
||||
///
|
||||
/// **关键不变量:返回的命令必须用绝对路径调用执行体,不依赖 PATH**。
|
||||
/// **关键不变量:返回的命令必须用绝对路径调用执行体;若执行体通过
|
||||
/// `#!/usr/bin/env` 查找解释器,还必须把其同级 bin 目录显式放到 PATH 首位**。
|
||||
/// 这条命令最终在 `run_tool_lifecycle_silently` 的非登录 `bash -c` 里执行——
|
||||
/// GUI App 启动的进程 PATH 由 launchd / Windows Service / systemd 给,通常**不含**
|
||||
/// `~/.local/bin` / `/opt/homebrew/bin` / `~/.volta/bin` 等用户级 bin 目录;而探测
|
||||
@@ -2250,14 +2336,15 @@ fn package_manager_anchored_command_from_paths(
|
||||
/// 判定顺序(命中即返回):
|
||||
/// ① Hermes → `<bin_path 绝对> update`;Hermes CLI 自己知道安装环境,避免 cc-switch
|
||||
/// 猜系统 `python3`/`python` 时撞上 Python 版本或 pyenv shim 问题。
|
||||
/// ② Claude 原生安装器(`~/.local/share/claude/versions/`)→ `<bin_path 绝对> update`;
|
||||
/// ② Claude / Grok 原生安装器 → `<bin_path 绝对> update`;
|
||||
/// bin_path 指向 launcher,launcher 内部 dispatch update 子命令。它不归 npm 管,
|
||||
/// 且在 PATH 里比 nvm/homebrew 更靠前,用 npm 升级会装到别处且被原生那份遮蔽。
|
||||
/// ③ Homebrew formula(真身在 `Cellar/<formula>/`)→ `<bin_path 同目录>/brew upgrade <formula>`;
|
||||
/// formula 由 Homebrew 拥有,避免 self-update 尝试改动包管理器管理的安装。
|
||||
/// ④ 其余支持官方自升级的工具 → `<bin_path 绝对> update/upgrade || <原锚定包管理器命令>`;
|
||||
/// Codex 的 self-update 只在部分 release 可用,所以保留 npm/brew/bun/volta fallback。
|
||||
/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI) → 锚定到"那处 bin 目录的 npm"。
|
||||
/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI,以及非 native 的 Grok Build) → 锚定到
|
||||
/// "那处 bin 目录的 npm"。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> Option<String> {
|
||||
let real_lower = real_target.to_ascii_lowercase();
|
||||
@@ -2271,6 +2358,9 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
|
||||
{
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
}
|
||||
if tool == "grok" && is_grok_native_install(bin_path, real_target) {
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
}
|
||||
let package_command = package_manager_anchored_command_from_paths(tool, bin_path, real_target);
|
||||
if brew_formula_from_path(real_target).is_some() {
|
||||
return package_command;
|
||||
@@ -2318,7 +2408,7 @@ fn package_manager_anchored_command_from_paths(tool: &str, bin_path: &str) -> Op
|
||||
|
||||
/// Windows 版锚定命令生成。对平台确认可静默运行的工具优先使用官方 CLI 自升级;
|
||||
/// 对 npm/Volta/pnpm 这类可确认写回位置的安装,再接一个包管理器 fallback。不存在 brew/bun/claude-native
|
||||
/// (Windows 没 Homebrew、Bun for Windows 仍 preview、claude.ai/install.sh 是 bash 脚本)。
|
||||
/// (Windows 没 Homebrew、Bun for Windows 仍 preview;Grok native 使用 PowerShell installer)。
|
||||
/// Scoop/Chocolatey/winget/nvm-windows/MS Store node 都归 npm 类——它们都只是"如何装
|
||||
/// node"的不同入口,全局包真正的 idiom 仍是 sibling `npm.cmd`。
|
||||
///
|
||||
@@ -2327,16 +2417,15 @@ fn package_manager_anchored_command_from_paths(tool: &str, bin_path: &str) -> Op
|
||||
/// 装 `.cmd`、Volta 装 `.exe`,纯字符串拼接无法消歧。这一平台差异**被刻意保留**:
|
||||
/// 测试用 tempdir 隔离 fs,生产侧 TOCTOU 是 by design(见 `sibling_bin_with_ext` doc)。
|
||||
///
|
||||
/// `_real_target` 占位维持与 POSIX 版的签名对称——Windows 上未观测到需要真身路径
|
||||
/// 区分的等价类(无 Cellar、无 claude-native installer)。若未来加 Scoop persist 锚定
|
||||
/// (scoop 装的工具真身在 `<scoop_root>/persist/<app>/...`),从这里启用 `_real_target`。
|
||||
/// `real_target` 维持与 POSIX 版的签名对称,并辅助识别 Grok native。若未来加 Scoop
|
||||
/// persist 锚定(scoop 装的工具真身在 `<scoop_root>/persist/<app>/...`),也从这里取真身。
|
||||
///
|
||||
/// **关键不变量同 POSIX 版:返回的命令必须用绝对路径,不依赖 PATH**。Windows GUI
|
||||
/// 进程 PATH 由 Service Control Manager / explorer.exe 给,通常不含用户 `%LOCALAPPDATA%`
|
||||
/// 下的 Volta/pnpm 路径;`$SHELL -lic` 的探测时 PATH 与执行时 PATH 不对称。
|
||||
///
|
||||
/// 判定顺序(命中即返回):
|
||||
/// ① hermes → `<bin_path> update`;Hermes CLI 自己处理安装环境。
|
||||
/// ① hermes / Grok native → `<bin_path> update`;CLI 自己处理安装环境。
|
||||
/// ② 支持官方自升级且 Windows 可安全静默执行的工具 → `<bin_path> update/upgrade || call <包管理器 fallback>`。
|
||||
/// ③ 其余 npm 工具 → sibling `npm.cmd`/`.exe` i -g <pkg>@latest。
|
||||
///
|
||||
@@ -2344,10 +2433,13 @@ fn package_manager_anchored_command_from_paths(tool: &str, bin_path: &str) -> Op
|
||||
/// 扩展名存在时,支持官方自升级的工具仍返回 `<bin_path> update/upgrade`,其余工具
|
||||
/// 才返 None 让上游兜回静态命令、`anchored=false`。
|
||||
#[cfg(target_os = "windows")]
|
||||
fn anchored_command_from_paths(tool: &str, bin_path: &str, _real_target: &str) -> Option<String> {
|
||||
fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> Option<String> {
|
||||
if tool == "hermes" {
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
}
|
||||
if tool == "grok" && is_grok_native_install(bin_path, real_target) {
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
}
|
||||
let package_command = package_manager_anchored_command_from_paths(tool, bin_path);
|
||||
if prefers_official_update(tool, LifecycleCommandShell::WindowsBatch) {
|
||||
let update = anchored_official_update_command(tool, bin_path)?;
|
||||
@@ -2418,17 +2510,17 @@ fn static_fallback_command(tool: &str) -> String {
|
||||
///
|
||||
/// 设计理由:
|
||||
/// - install 没有锚点可言(从无到有),但**有"上游推荐方式"这一事实** ——
|
||||
/// Anthropic 和 SST(OpenCode)都已将自家 native installer 列为首推、把 npm 列为传统方式。
|
||||
/// Anthropic、xAI 和 SST(OpenCode)都已将自家 native installer 列为首推、把 npm 列为替代方式。
|
||||
/// 把这层认知补进来,让 install 表与 update 端的锚定决策树共用同一份"上游事实"。
|
||||
/// - Hermes 使用官方 installer,避免用系统 Python/pip 安装时踩 Python >=3.11 与 pyenv
|
||||
/// `python` shim 问题;更新路径若能锚定已安装 CLI,则走 `<hermes> update`。
|
||||
/// **Hermes 没有 npm 包,install 端不享受 `||` 降级**——上游 installer 不可达就只能等。
|
||||
/// - 对**有 npm 包**的工具(claude/opencode),短路链(POSIX `||`)保证官方脚本不可达/
|
||||
/// - 对**有 npm 包**的工具(claude/grok/opencode),短路链(POSIX `||`)保证官方脚本不可达/
|
||||
/// 防火墙拦截时仍能装上,降级到裸 `npm i -g`。官方脚本本身不用 pipe,
|
||||
/// 所以这条路径在 WSL 的 `sh -c` 子 shell 中也不依赖外层 `pipefail`。
|
||||
/// - Windows 原生不启用:claude.ai/install.sh、opencode.ai/install 都是 bash 脚本,
|
||||
/// Windows 原生继续走 `tool_action_shell_command` 的 npm/PowerShell 命令;WSL 作为
|
||||
/// Linux 环境复用这套 POSIX 安装优先级。
|
||||
/// - Windows 上 Claude/OpenCode 原生不启用(对应 installer 都是 bash 脚本);Grok
|
||||
/// 使用官方 PowerShell installer,并同样保留 npm fallback。WSL 作为 Linux 环境
|
||||
/// 复用这套 POSIX 安装优先级。
|
||||
fn installer_with_npm_fallback(installer: &str, tool: &str) -> String {
|
||||
match npm_install_command_for(tool) {
|
||||
Some(npm) => chain_update_commands(
|
||||
@@ -2443,6 +2535,7 @@ fn installer_with_npm_fallback(installer: &str, tool: &str) -> String {
|
||||
fn posix_install_command_for(tool: &str) -> String {
|
||||
match tool {
|
||||
"claude" => installer_with_npm_fallback(CLAUDE_INSTALL_UNIX, tool),
|
||||
"grok" => installer_with_npm_fallback(GROK_INSTALL_UNIX, tool),
|
||||
"opencode" => installer_with_npm_fallback(OPENCODE_INSTALL_UNIX, tool),
|
||||
"hermes" => HERMES_INSTALL_UNIX.to_string(),
|
||||
_ => static_fallback_command_for(tool, ToolLifecycleAction::Install),
|
||||
@@ -2553,6 +2646,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option<String> {
|
||||
"claude" => crate::settings::get_claude_override_dir(),
|
||||
"codex" => crate::settings::get_codex_override_dir(),
|
||||
"gemini" => crate::settings::get_gemini_override_dir(),
|
||||
"grok" => crate::settings::get_grok_override_dir(),
|
||||
"opencode" => crate::settings::get_opencode_override_dir(),
|
||||
"openclaw" => crate::settings::get_openclaw_override_dir(),
|
||||
"hermes" => crate::settings::get_hermes_override_dir(),
|
||||
@@ -3660,6 +3754,39 @@ mod tests {
|
||||
assert_eq!(extract_version("no version here"), "no version here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_lifecycle_metadata_is_consistent() {
|
||||
let requested = vec!["unsupported".to_string(), "grok".to_string()];
|
||||
assert_eq!(normalize_requested_tools(&requested), vec!["grok"]);
|
||||
assert_eq!(tool_display_name("grok"), "Grok Build");
|
||||
assert_eq!(npm_package_for("grok"), Some("@xai-official/grok"));
|
||||
assert_eq!(
|
||||
npm_install_command_for("grok"),
|
||||
Some("npm i -g @xai-official/grok@latest")
|
||||
);
|
||||
assert_eq!(official_update_args("grok"), Some("update"));
|
||||
|
||||
for action in [ToolLifecycleAction::Install, ToolLifecycleAction::Update] {
|
||||
assert_eq!(
|
||||
tool_action_shell_command_for_shell("grok", action, LifecycleCommandShell::Posix)
|
||||
.as_deref(),
|
||||
Some("npm i -g @xai-official/grok@latest")
|
||||
);
|
||||
}
|
||||
|
||||
// Static update remains package-manager based. Only a path positively
|
||||
// identified as xAI's native install may run `grok update`.
|
||||
assert_eq!(
|
||||
tool_action_shell_command_for_shell(
|
||||
"grok",
|
||||
ToolLifecycleAction::Update,
|
||||
LifecycleCommandShell::WindowsBatch,
|
||||
)
|
||||
.as_deref(),
|
||||
Some("npm i -g @xai-official/grok@latest")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compare_semver() {
|
||||
use std::cmp::Ordering;
|
||||
@@ -3903,6 +4030,47 @@ mod tests {
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_windows_anchors_to_sibling_npm() {
|
||||
let (_dir, sub, bin_path) = setup_sibling("v22.0.0", "grok.cmd", &["npm.cmd"]);
|
||||
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path);
|
||||
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
|
||||
let expected = format!(
|
||||
"{} i -g @xai-official/grok@latest",
|
||||
expect_quoted_path(&npm_full)
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_native_windows_uses_self_update() {
|
||||
let (_dir, _sub, bin_path) = setup_sibling(".grok/bin", "grok.exe", &["npm.cmd"]);
|
||||
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path);
|
||||
let expected = format!("{} update", expect_quoted_path(&bin_path));
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_windows_install_prefers_powershell_with_npm_fallback() {
|
||||
let install = static_fallback_command_for("grok", ToolLifecycleAction::Install);
|
||||
let native = grok_install_windows_command();
|
||||
assert!(
|
||||
install.starts_with(&native),
|
||||
"native installer first: {install}"
|
||||
);
|
||||
assert!(
|
||||
install.ends_with("|| call npm i -g @xai-official/grok@latest"),
|
||||
"npm fallback should remain available: {install}"
|
||||
);
|
||||
let expected_encoded = powershell_encoded_command(GROK_INSTALL_WINDOWS_SCRIPT);
|
||||
assert_eq!(
|
||||
native
|
||||
.split_once("-EncodedCommand ")
|
||||
.map(|(_, encoded)| encoded),
|
||||
Some(expected_encoded.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_no_sibling_uses_cli_update_without_package_fallback() {
|
||||
// sibling 包管理器不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。
|
||||
@@ -4223,6 +4391,15 @@ mod tests {
|
||||
let codex =
|
||||
wsl_tool_action_shell_command("codex", ToolLifecycleAction::Install).unwrap();
|
||||
assert_eq!(codex, "npm i -g @openai/codex@latest");
|
||||
|
||||
let grok = wsl_tool_action_shell_command("grok", ToolLifecycleAction::Install).unwrap();
|
||||
assert!(
|
||||
grok.starts_with(
|
||||
"bash -c 'tmp=$(mktemp) && curl -fsSL https://x.ai/cli/install.sh "
|
||||
) && grok.contains(" || npm i -g @xai-official/grok@latest"),
|
||||
"WSL grok install should prefer native POSIX installer with npm fallback: {grok}"
|
||||
);
|
||||
assert!(!grok.contains("| bash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4339,6 +4516,28 @@ mod tests {
|
||||
assert_eq!(cmd.as_deref(), Some("/Users/me/.local/bin/claude update"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_native_installer_uses_self_update() {
|
||||
// ~/.grok/bin/grok is a launcher symlink into ~/.grok/downloads.
|
||||
// Updating it through npm would create or mutate a different install.
|
||||
let cmd = anchored_command_from_paths(
|
||||
"grok",
|
||||
"/Users/me/.grok/bin/grok",
|
||||
"/Users/me/.grok/downloads/grok-macos-aarch64",
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some("/Users/me/.grok/bin/grok update"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_custom_bin_dir_is_native_when_target_is_official_download() {
|
||||
let cmd = anchored_command_from_paths(
|
||||
"grok",
|
||||
"/Users/me/bin/grok",
|
||||
"/Users/me/.grok/downloads/grok-macos-aarch64",
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some("/Users/me/bin/grok update"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_homebrew_formula_uses_brew_upgrade() {
|
||||
// /opt/homebrew/bin/gemini → Cellar/gemini-cli/...:是 brew formula 而非 npm 全局包,
|
||||
@@ -4378,7 +4577,22 @@ mod tests {
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some(
|
||||
"/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @google/gemini-cli@latest"
|
||||
"PATH='/Users/me/.nvm/versions/node/v22.14.0/bin':\"$PATH\" /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @google/gemini-cli@latest"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_nvm_anchors_to_npm_without_cli_update() {
|
||||
let cmd = anchored_command_from_paths(
|
||||
"grok",
|
||||
"/Users/me/.nvm/versions/node/v22.14.0/bin/grok",
|
||||
"/Users/me/.nvm/versions/node/v22.14.0/lib/node_modules/@xai-official/grok/bin/grok",
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some(
|
||||
"PATH='/Users/me/.nvm/versions/node/v22.14.0/bin':\"$PATH\" /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @xai-official/grok@latest"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -4395,7 +4609,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
|
||||
Some("PATH='/Users/me/.nvm/versions/node/v22.14.0/bin':\"$PATH\" /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4410,7 +4624,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/opt/homebrew/bin/openclaw update --yes || /opt/homebrew/bin/npm i -g openclaw@latest")
|
||||
Some("/opt/homebrew/bin/openclaw update --yes || PATH='/opt/homebrew/bin':\"$PATH\" /opt/homebrew/bin/npm i -g openclaw@latest")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4537,7 +4751,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some(
|
||||
"/Users/me/.local/share/fnm_multishells/12345_abc/bin/npm i -g @openai/codex@latest"
|
||||
"PATH='/Users/me/.local/share/fnm_multishells/12345_abc/bin':\"$PATH\" /Users/me/.local/share/fnm_multishells/12345_abc/bin/npm i -g @openai/codex@latest"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -4551,7 +4765,62 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("'/Users/my name/.nvm/versions/node/v22/bin/npm' i -g @openai/codex@latest")
|
||||
Some("PATH='/Users/my name/.nvm/versions/node/v22/bin':\"$PATH\" '/Users/my name/.nvm/versions/node/v22/bin/npm' i -g @openai/codex@latest")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_anchor_supplies_sibling_node_to_env_shebang() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process::Command;
|
||||
|
||||
let temp = tempfile::tempdir().expect("temp dir should be created");
|
||||
let bin = temp.path().join("home dir/.nvm/versions/node/v22.14.0/bin");
|
||||
std::fs::create_dir_all(&bin).expect("node bin should be created");
|
||||
|
||||
let marker = temp.path().join("sibling-node-used");
|
||||
let node = bin.join("node");
|
||||
let npm = bin.join("npm");
|
||||
std::fs::write(
|
||||
&node,
|
||||
format!(
|
||||
"#!/bin/sh\nprintf sibling-node > {}\n",
|
||||
shell_single_quote(&marker.to_string_lossy())
|
||||
),
|
||||
)
|
||||
.expect("fake node should be written");
|
||||
std::fs::write(&npm, "#!/usr/bin/env node\n").expect("fake npm should be written");
|
||||
for executable in [&node, &npm] {
|
||||
let mut permissions = std::fs::metadata(executable)
|
||||
.expect("fake executable metadata should exist")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(executable, permissions)
|
||||
.expect("fake executable should be executable");
|
||||
}
|
||||
|
||||
let codex = bin.join("codex").to_string_lossy().into_owned();
|
||||
let real = temp
|
||||
.path()
|
||||
.join("home dir/.nvm/versions/node/v22.14.0/lib/node_modules/@openai/codex/bin/codex.js")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let command = anchored_command_from_paths("codex", &codex, &real)
|
||||
.expect("nvm codex should produce an anchored npm command");
|
||||
let output = Command::new("/bin/bash")
|
||||
.args(["-c", &command])
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.output()
|
||||
.expect("anchored npm command should start");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"anchored npm command failed: {}",
|
||||
decode_command_output(&output.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(marker).expect("sibling node should leave a marker"),
|
||||
"sibling-node"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4658,7 +4927,7 @@ mod tests {
|
||||
broken.runnable = false;
|
||||
assert_eq!(
|
||||
installs_anchored_command("codex", &[broken]).as_deref(),
|
||||
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm uninstall -g @openai/codex || true; /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
|
||||
Some("PATH='/Users/me/.nvm/versions/node/v22.14.0/bin':\"$PATH\" /Users/me/.nvm/versions/node/v22.14.0/bin/npm uninstall -g @openai/codex || true; PATH='/Users/me/.nvm/versions/node/v22.14.0/bin':\"$PATH\" /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4670,7 +4939,7 @@ mod tests {
|
||||
let cmd = installs_anchored_command("codex", &[healthy]);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
|
||||
Some("PATH='/Users/me/.nvm/versions/node/v22.14.0/bin':\"$PATH\" /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
|
||||
);
|
||||
assert!(!cmd.unwrap().contains("uninstall"));
|
||||
}
|
||||
@@ -4833,6 +5102,27 @@ mod tests {
|
||||
assert_eq!(cmd, "npm i -g @google/gemini-cli@latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_install_prefers_native_with_npm_fallback() {
|
||||
let cmd = install_command_for("grok");
|
||||
assert!(
|
||||
cmd.contains("https://x.ai/cli/install.sh"),
|
||||
"should include official installer URL: {cmd}"
|
||||
);
|
||||
assert!(
|
||||
cmd.contains("@xai-official/grok@latest"),
|
||||
"should keep npm package as fallback: {cmd}"
|
||||
);
|
||||
let parts: Vec<&str> = cmd.split("||").collect();
|
||||
assert_eq!(parts.len(), 2, "should be a two-step short-circuit chain");
|
||||
assert!(parts[0].contains("install.sh"), "native first: {cmd}");
|
||||
assert!(
|
||||
!parts[0].contains('|'),
|
||||
"native installer should avoid pipe: {cmd}"
|
||||
);
|
||||
assert!(parts[1].contains("npm i -g"), "npm second: {cmd}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openclaw_install_keeps_static_npm() {
|
||||
let cmd = install_command_for("openclaw");
|
||||
@@ -4855,6 +5145,11 @@ mod tests {
|
||||
"npm i -g @google/gemini-cli@latest"
|
||||
);
|
||||
assert!(!static_fallback_command("gemini").contains("gemini update"));
|
||||
assert_eq!(
|
||||
static_fallback_command("grok"),
|
||||
"npm i -g @xai-official/grok@latest"
|
||||
);
|
||||
assert!(!static_fallback_command("grok").contains("grok update"));
|
||||
assert_eq!(
|
||||
static_fallback_command("opencode"),
|
||||
"opencode upgrade || npm i -g opencode-ai@latest"
|
||||
@@ -4997,6 +5292,27 @@ mod tests {
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_extra_search_paths_prefers_override_then_default_native_dir() {
|
||||
let home = PathBuf::from("/home/tester");
|
||||
let paths =
|
||||
grok_extra_search_paths(&home, Some(std::ffi::OsString::from("/custom/grok/bin")));
|
||||
|
||||
assert_eq!(paths[0], PathBuf::from("/custom/grok/bin"));
|
||||
assert_eq!(paths[1], PathBuf::from("/home/tester/.grok/bin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_extra_search_paths_deduplicates_default_override() {
|
||||
let home = PathBuf::from("/home/tester");
|
||||
let paths = grok_extra_search_paths(
|
||||
&home,
|
||||
Some(std::ffi::OsString::from("/home/tester/.grok/bin")),
|
||||
);
|
||||
|
||||
assert_eq!(paths, vec![PathBuf::from("/home/tester/.grok/bin")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_path_env_search_paths_include_path_entries_and_dedupe() {
|
||||
let temp = tempfile::tempdir().expect("temp dir should be created");
|
||||
|
||||
@@ -28,6 +28,7 @@ pub mod skill;
|
||||
mod stream_check;
|
||||
mod subscription;
|
||||
mod sync_support;
|
||||
mod xai_oauth;
|
||||
|
||||
mod lightweight;
|
||||
mod s3_sync;
|
||||
@@ -62,6 +63,7 @@ pub use settings::*;
|
||||
pub use skill::*;
|
||||
pub use stream_check::*;
|
||||
pub use subscription::*;
|
||||
pub use xai_oauth::*;
|
||||
|
||||
pub use lightweight::*;
|
||||
pub use s3_sync::*;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use indexmap::IndexMap;
|
||||
use tauri::{Emitter, State};
|
||||
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::{
|
||||
@@ -100,16 +101,63 @@ pub fn switch_provider_test_hook(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn switch_provider(
|
||||
state: State<'_, AppState>,
|
||||
pub async fn switch_provider(
|
||||
app_handle: tauri::AppHandle,
|
||||
app: String,
|
||||
id: String,
|
||||
) -> Result<SwitchResult, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
switch_provider_internal(&state, app_type, &id).map_err(|e| e.to_string())
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let state = app_handle
|
||||
.try_state::<AppState>()
|
||||
.ok_or_else(|| "应用状态不可用".to_string())?;
|
||||
switch_provider_internal(state.inner(), app_type, &id).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("供应商切换任务执行失败: {e}"))?
|
||||
}
|
||||
|
||||
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||
if matches!(app_type, AppType::GrokBuild) {
|
||||
// 官方登录态(live 语法合法且无自定义模型表)+ 用户手动导入:
|
||||
// 导入的正确结果是让 Grok Official 成为当前供应商,而非报错。
|
||||
// 只挂在命令层 = 只有手动动作可达;启动自动导入走 service 层、
|
||||
// 官方态照旧报错静默跳过,删掉的官方条目不会被重启复活
|
||||
//(全项目惯例:启动自动导入只产出 default,从不产出官方条目)。
|
||||
if let Ok(settings) = crate::grok_config::read_grok_live_settings() {
|
||||
let config = settings
|
||||
.get("config")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if crate::grok_config::is_official_live_config(config) {
|
||||
state.db.ensure_official_seed_by_id(
|
||||
crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID,
|
||||
AppType::GrokBuild,
|
||||
)?;
|
||||
state.db.set_current_provider(
|
||||
app_type.as_str(),
|
||||
crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID,
|
||||
)?;
|
||||
crate::settings::set_current_provider(
|
||||
&app_type,
|
||||
Some(crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID),
|
||||
)?;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Safety net: 与 claude-desktop 导入同语义 —— 用户主动点导入是"重新
|
||||
// 整理该表"的隐式信号,把官方入口补回来。覆盖导入必然失败的场景
|
||||
//(live 文件缺失 / TOML 语法错误 / 残缺的自定义配置),避免
|
||||
// "报错 + 空列表"死胡同。失败只 warn,不影响导入主流程。
|
||||
if let Err(e) = state.db.ensure_official_seed_by_id(
|
||||
crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID,
|
||||
AppType::GrokBuild,
|
||||
) {
|
||||
log::warn!("Failed to ensure grokbuild-official seed during import: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let imported = ProviderService::import_default_config(state, app_type.clone())?;
|
||||
|
||||
if imported {
|
||||
@@ -239,6 +287,17 @@ pub fn ensure_codex_official_provider(state: State<'_, AppState>) -> Result<bool
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ensure_grokbuild_official_provider(state: State<'_, AppState>) -> Result<bool, String> {
|
||||
state
|
||||
.db
|
||||
.ensure_official_seed_by_id(
|
||||
crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID,
|
||||
AppType::GrokBuild,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool {
|
||||
let Some(env) = provider
|
||||
.settings_config
|
||||
@@ -274,7 +333,7 @@ pub(crate) fn suggested_claude_desktop_routes(
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.provider_type.as_deref()),
|
||||
Some("github_copilot") | Some("codex_oauth")
|
||||
Some("github_copilot") | Some("codex_oauth") | Some("xai_oauth")
|
||||
);
|
||||
|
||||
fn add_route(
|
||||
@@ -384,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> {
|
||||
@@ -396,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",
|
||||
@@ -463,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> {
|
||||
@@ -631,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 {
|
||||
|
||||
@@ -23,6 +23,7 @@ pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(),
|
||||
if takeover.claude
|
||||
|| takeover.codex
|
||||
|| takeover.gemini
|
||||
|| takeover.grokbuild
|
||||
|| takeover.opencode
|
||||
|| takeover.openclaw
|
||||
{
|
||||
|
||||
@@ -275,52 +275,47 @@ pub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Res
|
||||
|
||||
/// 手动触发会话日志同步
|
||||
#[tauri::command]
|
||||
pub fn sync_session_usage(
|
||||
pub async fn sync_session_usage(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<crate::services::session_usage::SessionSyncResult, AppError> {
|
||||
// 同步 Claude 会话日志
|
||||
let mut result = crate::services::session_usage::sync_claude_session_logs(&state.db)?;
|
||||
let db = state.db.clone();
|
||||
let _guard = crate::services::session_usage::session_sync_mutex()
|
||||
.lock()
|
||||
.await;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
crate::services::session_usage::sync_all_unlocked(&db)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| AppError::Message(format!("会话用量同步任务失败: {error}")))
|
||||
}
|
||||
|
||||
// 同步 Codex 使用数据
|
||||
match crate::services::session_usage_codex::sync_codex_usage(&state.db) {
|
||||
Ok(codex_result) => {
|
||||
result.imported += codex_result.imported;
|
||||
result.skipped += codex_result.skipped;
|
||||
result.files_scanned += codex_result.files_scanned;
|
||||
result.errors.extend(codex_result.errors);
|
||||
}
|
||||
Err(e) => {
|
||||
result.errors.push(format!("Codex 同步失败: {e}"));
|
||||
}
|
||||
}
|
||||
/// Codex reset 成功后,无论重导是否导入新行或返回错误,都必须通知前端刷新。
|
||||
/// 调用方应只在 reset 成功后调用,避免把未发生的数据变更误报为重建完成。
|
||||
fn finish_codex_rebuild(
|
||||
result: Result<crate::services::session_usage::SessionSyncResult, AppError>,
|
||||
) -> Result<crate::services::session_usage::SessionSyncResult, AppError> {
|
||||
crate::usage_events::notify_log_recorded();
|
||||
result
|
||||
}
|
||||
|
||||
// 同步 Gemini 使用数据
|
||||
match crate::services::session_usage_gemini::sync_gemini_usage(&state.db) {
|
||||
Ok(gemini_result) => {
|
||||
result.imported += gemini_result.imported;
|
||||
result.skipped += gemini_result.skipped;
|
||||
result.files_scanned += gemini_result.files_scanned;
|
||||
result.errors.extend(gemini_result.errors);
|
||||
}
|
||||
Err(e) => {
|
||||
result.errors.push(format!("Gemini 同步失败: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
// 同步 OpenCode 使用数据
|
||||
match crate::services::session_usage_opencode::sync_opencode_usage(&state.db) {
|
||||
Ok(opencode_result) => {
|
||||
result.imported += opencode_result.imported;
|
||||
result.skipped += opencode_result.skipped;
|
||||
result.files_scanned += opencode_result.files_scanned;
|
||||
result.errors.extend(opencode_result.errors);
|
||||
}
|
||||
Err(e) => {
|
||||
result.errors.push(format!("OpenCode 同步失败: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
/// 备份数据库后,仅重建 Codex session 用量。锁覆盖 backup → reset → import
|
||||
/// 整个序列,避免后台同步在清理和重导之间插入数据。
|
||||
#[tauri::command]
|
||||
pub async fn rebuild_codex_usage(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<crate::services::session_usage::SessionSyncResult, AppError> {
|
||||
let db = state.db.clone();
|
||||
let _guard = crate::services::session_usage::session_sync_mutex()
|
||||
.lock()
|
||||
.await;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
db.backup_database_file()?;
|
||||
db.reset_codex_usage()?;
|
||||
let result = crate::services::session_usage_codex::sync_codex_usage(&db);
|
||||
finish_codex_rebuild(result)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| AppError::Message(format!("Codex 用量重建任务失败: {error}")))?
|
||||
}
|
||||
|
||||
/// 获取数据来源分布
|
||||
@@ -342,3 +337,33 @@ pub struct ModelPricingInfo {
|
||||
pub cache_read_cost_per_million: String,
|
||||
pub cache_creation_cost_per_million: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn codex_rebuild_notifies_when_reimport_is_empty() {
|
||||
crate::usage_events::take_test_notify_count();
|
||||
|
||||
let result = finish_codex_rebuild(Ok(
|
||||
crate::services::session_usage::SessionSyncResult::default(),
|
||||
))
|
||||
.expect("空重导应成功");
|
||||
|
||||
assert_eq!(result.imported, 0);
|
||||
assert_eq!(crate::usage_events::take_test_notify_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_rebuild_notifies_when_reimport_fails_after_reset() {
|
||||
crate::usage_events::take_test_notify_count();
|
||||
|
||||
let result = finish_codex_rebuild(Err(AppError::Message(
|
||||
"synthetic reimport failure".to_string(),
|
||||
)));
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(crate::usage_events::take_test_notify_count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! xAI OAuth state and xAI-specific commands.
|
||||
|
||||
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;
|
||||
use tauri::State;
|
||||
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)]
|
||||
data: Vec<ModelEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelEntry {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
owned_by: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn get_xai_oauth_models(
|
||||
account_id: Option<String>,
|
||||
state: State<'_, XaiOAuthState>,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
let manager = state.0.read().await;
|
||||
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 account_id = resolved.ok_or_else(|| "No usable xAI account available".to_string())?;
|
||||
let token = manager
|
||||
.get_valid_token_for_account(&account_id)
|
||||
.await
|
||||
.map_err(|error| format!("xAI OAuth token unavailable: {error}"))?;
|
||||
|
||||
let response = crate::proxy::http_client::get()
|
||||
.get(format!("{XAI_API_BASE_URL}/models"))
|
||||
.bearer_auth(token)
|
||||
.timeout(Duration::from_secs(15))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("xAI models request failed: {error}"))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(format!("xAI models request failed: HTTP {status}"));
|
||||
}
|
||||
let payload: ModelsResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|_| "xAI models response was not valid JSON".to_string())?;
|
||||
let mut models: Vec<FetchedModel> = payload
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|model| FetchedModel {
|
||||
id: model.id,
|
||||
owned_by: model.owned_by,
|
||||
})
|
||||
.collect();
|
||||
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
Ok(models)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ impl Database {
|
||||
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes
|
||||
FROM mcp_servers
|
||||
ORDER BY name ASC, id ASC"
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -30,8 +30,9 @@ impl Database {
|
||||
let enabled_claude: bool = row.get(7)?;
|
||||
let enabled_codex: bool = row.get(8)?;
|
||||
let enabled_gemini: bool = row.get(9)?;
|
||||
let enabled_opencode: bool = row.get(10)?;
|
||||
let enabled_hermes: bool = row.get(11)?;
|
||||
let enabled_grokbuild: bool = row.get(10)?;
|
||||
let enabled_opencode: bool = row.get(11)?;
|
||||
let enabled_hermes: bool = row.get(12)?;
|
||||
|
||||
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
|
||||
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
@@ -46,6 +47,7 @@ impl Database {
|
||||
claude: enabled_claude,
|
||||
codex: enabled_codex,
|
||||
gemini: enabled_gemini,
|
||||
grokbuild: enabled_grokbuild,
|
||||
opencode: enabled_opencode,
|
||||
hermes: enabled_hermes,
|
||||
},
|
||||
@@ -72,8 +74,8 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO mcp_servers (
|
||||
id, name, server_config, description, homepage, docs, tags,
|
||||
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
server.id,
|
||||
server.name,
|
||||
@@ -88,6 +90,7 @@ impl Database {
|
||||
server.apps.claude,
|
||||
server.apps.codex,
|
||||
server.apps.gemini,
|
||||
server.apps.grokbuild,
|
||||
server.apps.opencode,
|
||||
server.apps.hermes,
|
||||
],
|
||||
|
||||
@@ -712,6 +712,7 @@ mod ensure_official_seed_tests {
|
||||
use crate::app_config::AppType;
|
||||
use crate::database::{
|
||||
Database, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID,
|
||||
GROKBUILD_OFFICIAL_PROVIDER_ID,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -790,6 +791,26 @@ mod ensure_official_seed_tests {
|
||||
assert_eq!(provider.settings_config["auth"], serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_recreates_grokbuild_official_seed_after_deletion() {
|
||||
let db = Database::memory().expect("memory db");
|
||||
db.init_default_official_providers().expect("seed");
|
||||
db.delete_provider(AppType::GrokBuild.as_str(), GROKBUILD_OFFICIAL_PROVIDER_ID)
|
||||
.expect("delete Grok Build official");
|
||||
|
||||
let inserted = db
|
||||
.ensure_official_seed_by_id(GROKBUILD_OFFICIAL_PROVIDER_ID, AppType::GrokBuild)
|
||||
.expect("ensure Grok Build official");
|
||||
assert!(inserted);
|
||||
let provider = db
|
||||
.get_provider_by_id(GROKBUILD_OFFICIAL_PROVIDER_ID, AppType::GrokBuild.as_str())
|
||||
.expect("query")
|
||||
.expect("Grok Build official restored");
|
||||
assert_eq!(provider.category.as_deref(), Some("official"));
|
||||
// 空 config:切换时不注入自定义模型表,Grok CLI 回落到自带 OAuth 登录
|
||||
assert_eq!(provider.settings_config["config"], serde_json::json!(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_rejects_unknown_seed() {
|
||||
let db = Database::memory().expect("memory db");
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
//! - `src/config/claudeProviderPresets.ts`("Claude Official")
|
||||
//! - `src/config/codexProviderPresets.ts`("OpenAI Official")
|
||||
//! - `src/config/geminiProviderPresets.ts`("Google Official")
|
||||
//! - `src/components/providers/forms/GrokBuildProviderForm.tsx`("Grok Official")
|
||||
|
||||
use crate::app_config::AppType;
|
||||
|
||||
pub(crate) const CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID: &str = "claude-desktop-official";
|
||||
pub(crate) const CODEX_OFFICIAL_PROVIDER_ID: &str = "codex-official";
|
||||
pub(crate) const GROKBUILD_OFFICIAL_PROVIDER_ID: &str = "grokbuild-official";
|
||||
|
||||
/// 单条官方供应商种子定义。
|
||||
pub(crate) struct OfficialProviderSeed {
|
||||
@@ -69,6 +71,16 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
|
||||
// 空 env + 空 config 让用户走 Google OAuth
|
||||
settings_config_json: r#"{"env":{},"config":{}}"#,
|
||||
},
|
||||
OfficialProviderSeed {
|
||||
id: GROKBUILD_OFFICIAL_PROVIDER_ID,
|
||||
app_type: AppType::GrokBuild,
|
||||
name: "Grok Official",
|
||||
website_url: "https://x.ai/grok",
|
||||
icon: "grok",
|
||||
icon_color: "currentColor",
|
||||
// 空 config = 不写自定义模型表,Grok CLI 回落到自带的 xAI OAuth 登录
|
||||
settings_config_json: r#"{"config":""}"#,
|
||||
},
|
||||
];
|
||||
|
||||
/// 判断给定的 provider id 是否属于内置官方种子。
|
||||
@@ -92,4 +104,17 @@ mod tests {
|
||||
assert_eq!(seed.app_type, AppType::ClaudeDesktop);
|
||||
assert!(is_official_seed_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_seeds_include_grokbuild() {
|
||||
let seed = OFFICIAL_SEEDS
|
||||
.iter()
|
||||
.find(|seed| seed.id == GROKBUILD_OFFICIAL_PROVIDER_ID)
|
||||
.expect("grok build official seed");
|
||||
|
||||
assert_eq!(seed.app_type, AppType::GrokBuild);
|
||||
assert!(is_official_seed_id(GROKBUILD_OFFICIAL_PROVIDER_ID));
|
||||
// 空 config = 官方登录态:切换时不注入自定义模型表
|
||||
assert_eq!(seed.settings_config_json, r#"{"config":""}"#);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +328,7 @@ impl Database {
|
||||
"claude" => (6, 90, 180, 8, 3, 90, 0.7, 15),
|
||||
"codex" => (3, 60, 120, 4, 2, 60, 0.6, 10),
|
||||
"gemini" => (5, 60, 120, 4, 2, 60, 0.6, 10),
|
||||
"grokbuild" => (3, 60, 120, 4, 2, 60, 0.6, 10),
|
||||
_ => (3, 60, 120, 4, 2, 60, 0.6, 10), // 默认值
|
||||
};
|
||||
|
||||
@@ -398,6 +399,18 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// grokbuild: Responses protocol, same timeout defaults as Codex.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (
|
||||
app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests
|
||||
) VALUES ('grokbuild', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ impl Database {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
|
||||
enabled_hermes, installed_at, content_hash, updated_at
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
|
||||
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
|
||||
FROM skills ORDER BY name ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -43,12 +43,13 @@ impl Database {
|
||||
claude: row.get(8)?,
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
opencode: row.get(11)?,
|
||||
hermes: row.get(12)?,
|
||||
grokbuild: row.get(11)?,
|
||||
opencode: row.get(12)?,
|
||||
hermes: row.get(13)?,
|
||||
},
|
||||
installed_at: row.get(13)?,
|
||||
content_hash: row.get(14)?,
|
||||
updated_at: row.get::<_, i64>(15).unwrap_or(0),
|
||||
installed_at: row.get(14)?,
|
||||
content_hash: row.get(15)?,
|
||||
updated_at: row.get::<_, i64>(16).unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -67,8 +68,8 @@ impl Database {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
|
||||
enabled_hermes, installed_at, content_hash, updated_at
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
|
||||
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
|
||||
FROM skills WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -87,12 +88,13 @@ impl Database {
|
||||
claude: row.get(8)?,
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
opencode: row.get(11)?,
|
||||
hermes: row.get(12)?,
|
||||
grokbuild: row.get(11)?,
|
||||
opencode: row.get(12)?,
|
||||
hermes: row.get(13)?,
|
||||
},
|
||||
installed_at: row.get(13)?,
|
||||
content_hash: row.get(14)?,
|
||||
updated_at: row.get::<_, i64>(15).unwrap_or(0),
|
||||
installed_at: row.get(14)?,
|
||||
content_hash: row.get(15)?,
|
||||
updated_at: row.get::<_, i64>(16).unwrap_or(0),
|
||||
})
|
||||
});
|
||||
|
||||
@@ -109,9 +111,9 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO skills
|
||||
(id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes,
|
||||
installed_at, content_hash, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
|
||||
params![
|
||||
skill.id,
|
||||
skill.name,
|
||||
@@ -124,6 +126,7 @@ impl Database {
|
||||
skill.apps.claude,
|
||||
skill.apps.codex,
|
||||
skill.apps.gemini,
|
||||
skill.apps.grokbuild,
|
||||
skill.apps.opencode,
|
||||
skill.apps.hermes,
|
||||
skill.installed_at,
|
||||
@@ -157,8 +160,8 @@ impl Database {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let affected = conn
|
||||
.execute(
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4, enabled_hermes = ?5 WHERE id = ?6",
|
||||
params![apps.claude, apps.codex, apps.gemini, apps.opencode, apps.hermes, id],
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6 WHERE id = ?7",
|
||||
params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
@@ -232,32 +235,30 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化默认的 Skill 仓库(启动时调用,补充缺失的默认仓库)
|
||||
/// 初始化默认的 Skill 仓库(启动时调用,每个数据库仅执行一次)
|
||||
pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {
|
||||
// 获取已有仓库列表
|
||||
let existing = self.get_skill_repos()?;
|
||||
let existing_keys: std::collections::HashSet<(String, String)> = existing
|
||||
.iter()
|
||||
.map(|r| (r.owner.clone(), r.name.clone()))
|
||||
.collect();
|
||||
const INITIALIZED_KEY: &str = "default_skill_repos_initialized";
|
||||
|
||||
if self.get_bool_flag(INITIALIZED_KEY)? {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// 兼容升级前已经存在的用户选择,并记录初始化状态,避免以后删空后恢复默认值。
|
||||
if !self.get_skill_repos()?.is_empty() {
|
||||
self.set_setting(INITIALIZED_KEY, "true")?;
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// 获取默认仓库列表
|
||||
let default_store = crate::services::skill::SkillStore::default();
|
||||
let mut count = 0;
|
||||
|
||||
// 仅插入缺失的默认仓库
|
||||
for repo in &default_store.repos {
|
||||
let key = (repo.owner.clone(), repo.name.clone());
|
||||
if !existing_keys.contains(&key) {
|
||||
self.save_skill_repo(repo)?;
|
||||
count += 1;
|
||||
log::info!("补充默认 Skill 仓库: {}/{}", repo.owner, repo.name);
|
||||
}
|
||||
self.save_skill_repo(repo)?;
|
||||
count += 1;
|
||||
log::info!("初始化默认 Skill 仓库: {}/{}", repo.owner, repo.name);
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
log::info!("补充默认 Skill 仓库完成,新增 {count} 个");
|
||||
}
|
||||
self.set_setting(INITIALIZED_KEY, "true")?;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ mod tests;
|
||||
// DAO 类型导出供外部使用
|
||||
pub(crate) use dao::providers_seed::{
|
||||
is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID,
|
||||
GROKBUILD_OFFICIAL_PROVIDER_ID,
|
||||
};
|
||||
pub(crate) use dao::proxy::{
|
||||
validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST,
|
||||
@@ -52,7 +53,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 13;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 16;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
|
||||
@@ -65,7 +65,8 @@ impl Database {
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
|
||||
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_hermes BOOLEAN NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
@@ -93,6 +94,7 @@ impl Database {
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -122,7 +124,7 @@ impl Database {
|
||||
|
||||
// 8. Proxy Config 表(三行结构,app_type 主键)
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -169,6 +171,15 @@ impl Database {
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests)
|
||||
VALUES ('grokbuild', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
// 9. Provider Health 表
|
||||
@@ -485,6 +496,21 @@ impl Database {
|
||||
Self::migrate_v12_to_v13(conn)?;
|
||||
Self::set_user_version(conn, 13)?;
|
||||
}
|
||||
13 => {
|
||||
log::info!("迁移数据库从 v13 到 v14(添加 Grok Build 代理配置)");
|
||||
Self::migrate_v13_to_v14(conn)?;
|
||||
Self::set_user_version(conn, 14)?;
|
||||
}
|
||||
14 => {
|
||||
log::info!("迁移数据库从 v14 到 v15(Skills/MCP 添加 Grok Build 支持)");
|
||||
Self::migrate_v14_to_v15(conn)?;
|
||||
Self::set_user_version(conn, 15)?;
|
||||
}
|
||||
15 => {
|
||||
log::info!("迁移数据库从 v15 到 v16(重建 Codex 会话用量)");
|
||||
Self::migrate_v15_to_v16(conn)?;
|
||||
Self::set_user_version(conn, 16)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -798,12 +824,25 @@ impl Database {
|
||||
old_cb.3,
|
||||
old_cb.4,
|
||||
),
|
||||
(
|
||||
"grokbuild",
|
||||
false,
|
||||
false,
|
||||
3,
|
||||
old_config.4,
|
||||
old_config.5,
|
||||
old_cb.0,
|
||||
old_cb.1,
|
||||
old_cb.2,
|
||||
old_cb.3,
|
||||
old_cb.4,
|
||||
),
|
||||
];
|
||||
|
||||
// 创建新表
|
||||
conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?;
|
||||
conn.execute("CREATE TABLE proxy_config_new (
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -814,6 +853,7 @@ impl Database {
|
||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
|
||||
pricing_model_source TEXT NOT NULL DEFAULT 'response',
|
||||
live_takeover_active INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)", [])?;
|
||||
|
||||
@@ -1354,6 +1394,135 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v13 -> v14: allow Grok Build to own an independent proxy configuration row.
|
||||
fn migrate_v13_to_v14(conn: &Connection) -> Result<(), AppError> {
|
||||
if !Self::table_exists(conn, "proxy_config")? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
conn.execute("DROP TABLE IF EXISTS proxy_config_v14", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"CREATE TABLE proxy_config_v14 (
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 15721,
|
||||
enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
|
||||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120,
|
||||
non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
|
||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4,
|
||||
circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60,
|
||||
circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
|
||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
|
||||
pricing_model_source TEXT NOT NULL DEFAULT 'response',
|
||||
live_takeover_active INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let copied_columns = [
|
||||
("app_type", "'claude'"),
|
||||
("proxy_enabled", "0"),
|
||||
("listen_address", "'127.0.0.1'"),
|
||||
("listen_port", "15721"),
|
||||
("enable_logging", "1"),
|
||||
("enabled", "0"),
|
||||
("auto_failover_enabled", "0"),
|
||||
("max_retries", "3"),
|
||||
("streaming_first_byte_timeout", "60"),
|
||||
("streaming_idle_timeout", "120"),
|
||||
("non_streaming_timeout", "600"),
|
||||
("circuit_failure_threshold", "4"),
|
||||
("circuit_success_threshold", "2"),
|
||||
("circuit_timeout_seconds", "60"),
|
||||
("circuit_error_rate_threshold", "0.6"),
|
||||
("circuit_min_requests", "10"),
|
||||
("default_cost_multiplier", "'1'"),
|
||||
("pricing_model_source", "'response'"),
|
||||
("live_takeover_active", "0"),
|
||||
("created_at", "datetime('now')"),
|
||||
("updated_at", "datetime('now')"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(column, fallback)| {
|
||||
Self::has_column(conn, "proxy_config", column).map(|exists| {
|
||||
if exists {
|
||||
format!("\"{column}\"")
|
||||
} else {
|
||||
fallback.into()
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, AppError>>()?
|
||||
.join(", ");
|
||||
|
||||
let copy_sql = format!(
|
||||
"INSERT INTO proxy_config_v14 (
|
||||
app_type, proxy_enabled, listen_address, listen_port, enable_logging,
|
||||
enabled, auto_failover_enabled, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests,
|
||||
default_cost_multiplier, pricing_model_source, live_takeover_active,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT {copied_columns} FROM proxy_config"
|
||||
);
|
||||
conn.execute(©_sql, [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute("DROP TABLE proxy_config", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute("ALTER TABLE proxy_config_v14 RENAME TO proxy_config", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES ('grokbuild')",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v14 -> v15: persist Grok Build enablement for unified Skills and MCP.
|
||||
fn migrate_v14_to_v15(conn: &Connection) -> Result<(), AppError> {
|
||||
if Self::table_exists(conn, "mcp_servers")? {
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"mcp_servers",
|
||||
"enabled_grokbuild",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
}
|
||||
if Self::table_exists(conn, "skills")? {
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"skills",
|
||||
"enabled_grokbuild",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v15 -> v16: remove Codex session rows and cursors so startup sync can
|
||||
/// rebuild them with fork-history alignment. Must stay connection-level:
|
||||
/// schema migration already owns the Database connection mutex.
|
||||
fn migrate_v15_to_v16(conn: &Connection) -> Result<(), AppError> {
|
||||
let codex_dir = crate::codex_config::get_codex_config_dir();
|
||||
crate::services::session_usage_codex::reset_codex_usage_on_conn(conn, &codex_dir)
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
@@ -1943,6 +2112,9 @@ impl Database {
|
||||
"0.19",
|
||||
"0",
|
||||
),
|
||||
("kimi-k3", "Kimi K3", "3.00", "15.00", "0.30", "0"),
|
||||
// Kimi For Coding 套餐里 K3 的裸名(无 kimi- 前缀),同标准 list 价
|
||||
("k3", "Kimi K3", "3.00", "15.00", "0.30", "0"),
|
||||
// 腾讯混元 (Tencent Hunyuan)(官方 CNY 1/4/0.25 按 1 USD ≈ 7.14 折算;Hy3 阶梯计价取最低档)
|
||||
("hunyuan-hy3", "Hunyuan Hy3", "0.14", "0.56", "0.035", "0"),
|
||||
("hy3", "Hunyuan Hy3", "0.14", "0.56", "0.035", "0"),
|
||||
@@ -2073,6 +2245,11 @@ impl Database {
|
||||
("qwq-32b", "QwQ 32B", "0.20", "0.60", "0", "0"),
|
||||
("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",
|
||||
@@ -2766,7 +2943,7 @@ mod tests {
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, 13);
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
assert!(Database::has_column(
|
||||
&conn,
|
||||
"proxy_request_logs",
|
||||
@@ -2787,4 +2964,122 @@ mod tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_v13_to_v14_adds_grokbuild_proxy_row_and_preserves_values() -> Result<(), AppError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
Database::create_tables_on_conn(&conn)?;
|
||||
conn.execute("DELETE FROM proxy_config WHERE app_type = 'grokbuild'", [])?;
|
||||
conn.execute(
|
||||
"UPDATE proxy_config SET enabled = 1, max_retries = 9 WHERE app_type = 'codex'",
|
||||
[],
|
||||
)?;
|
||||
Database::set_user_version(&conn, 13)?;
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
let grok_rows: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'grokbuild'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(grok_rows, 1);
|
||||
let codex_values: (i64, i64) = conn.query_row(
|
||||
"SELECT enabled, max_retries FROM proxy_config WHERE app_type = 'codex'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
assert_eq!(codex_values, (1, 9));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_v14_to_v15_adds_grokbuild_skill_and_mcp_flags() -> Result<(), AppError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0
|
||||
);",
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO mcp_servers (id, enabled_codex) VALUES ('mcp-1', 1)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO skills (id, enabled_codex) VALUES ('skill-1', 1)",
|
||||
[],
|
||||
)?;
|
||||
Database::set_user_version(&conn, 14)?;
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
assert!(Database::has_column(
|
||||
&conn,
|
||||
"mcp_servers",
|
||||
"enabled_grokbuild"
|
||||
)?);
|
||||
assert!(Database::has_column(&conn, "skills", "enabled_grokbuild")?);
|
||||
let mcp_values: (i64, i64) = conn.query_row(
|
||||
"SELECT enabled_codex, enabled_grokbuild FROM mcp_servers WHERE id = 'mcp-1'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
let skill_values: (i64, i64) = conn.query_row(
|
||||
"SELECT enabled_codex, enabled_grokbuild FROM skills WHERE id = 'skill-1'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
assert_eq!(mcp_values, (1, 0));
|
||||
assert_eq!(skill_values, (1, 0));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_v15_to_v16_resets_only_codex_session_usage() -> Result<(), AppError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
Database::create_tables_on_conn(&conn)?;
|
||||
conn.execute_batch(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, input_tokens,
|
||||
output_tokens, cache_read_tokens, latency_ms, status_code,
|
||||
created_at, data_source
|
||||
) VALUES
|
||||
('codex-row', '_codex_session', 'codex', 'gpt', 1, 1, 0, 0, 200, 1, 'codex_session'),
|
||||
('gemini-row', '_gemini_session', 'gemini', 'gemini', 1, 1, 0, 0, 200, 1, 'gemini_session');
|
||||
INSERT INTO usage_daily_rollups (date, app_type, provider_id, model)
|
||||
VALUES
|
||||
('2026-07-10', 'codex', '_codex_session', 'gpt'),
|
||||
('2026-07-10', 'gemini', '_gemini_session', 'gemini');
|
||||
INSERT INTO session_log_sync
|
||||
(file_path, last_modified, last_line_offset, last_synced_at)
|
||||
VALUES
|
||||
('/old/sessions/rollout-old-00000000-0000-4000-8000-000000000001.jsonl', 1, 1, 1),
|
||||
('/gemini/tmp/session-123.json', 1, 1, 1);",
|
||||
)?;
|
||||
Database::set_user_version(&conn, 15)?;
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, 16);
|
||||
let counts: (i64, i64, i64, i64) = conn.query_row(
|
||||
"SELECT
|
||||
(SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'codex_session'),
|
||||
(SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'gemini_session'),
|
||||
(SELECT COUNT(*) FROM usage_daily_rollups WHERE provider_id = '_codex_session'),
|
||||
(SELECT COUNT(*) FROM session_log_sync)",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)?;
|
||||
assert_eq!(counts, (0, 1, 0, 1));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,38 @@ fn normalize_default(default: &Option<String>) -> Option<String> {
|
||||
.map(|s| s.trim_matches('\'').trim_matches('"').to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_default_skill_repo_is_not_restored() {
|
||||
let db = Database::memory().expect("create memory db");
|
||||
|
||||
assert_eq!(db.init_default_skill_repos().expect("initialize repos"), 4);
|
||||
for repo in db.get_skill_repos().expect("get initialized repos") {
|
||||
db.delete_skill_repo(&repo.owner, &repo.name)
|
||||
.expect("delete repo");
|
||||
}
|
||||
assert!(db.get_skill_repos().expect("get deleted repos").is_empty());
|
||||
|
||||
assert_eq!(
|
||||
db.init_default_skill_repos().expect("reinitialize repos"),
|
||||
0
|
||||
);
|
||||
assert!(db.get_skill_repos().expect("get repos").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_skill_repo_selection_is_not_supplemented() {
|
||||
let db = Database::memory().expect("create memory db");
|
||||
let default_store = crate::services::skill::SkillStore::default();
|
||||
db.save_skill_repo(&default_store.repos[0])
|
||||
.expect("save existing repo");
|
||||
|
||||
assert_eq!(db.init_default_skill_repos().expect("initialize repos"), 0);
|
||||
assert_eq!(db.get_skill_repos().expect("get repos").len(), 1);
|
||||
assert!(db
|
||||
.get_bool_flag("default_skill_repos_initialized")
|
||||
.expect("get initialized flag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_migration_sets_user_version_when_missing() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
@@ -517,7 +549,7 @@ fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
||||
let count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
|
||||
.expect("count rows");
|
||||
assert_eq!(count, 3, "per-app proxy_config should have 3 rows");
|
||||
assert_eq!(count, 4, "per-app proxy_config should have 4 rows");
|
||||
|
||||
// 新结构下应能按 app_type 查询
|
||||
let _: i32 = conn
|
||||
@@ -657,7 +689,7 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
|
||||
let proxy_rows: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
|
||||
.expect("count proxy_config rows");
|
||||
assert_eq!(proxy_rows, 3);
|
||||
assert_eq!(proxy_rows, 4);
|
||||
|
||||
// model_pricing 应具备默认数据(迁移时会 seed)
|
||||
let pricing_rows: i64 = conn
|
||||
|
||||
@@ -101,17 +101,7 @@ pub fn import_mcp_from_deeplink(
|
||||
// Server exists - merge apps only, keep other fields unchanged
|
||||
log::info!("MCP server '{id}' already exists, merging apps only");
|
||||
|
||||
let mut merged_apps = existing.apps.clone();
|
||||
// Merge new apps into existing apps
|
||||
if target_apps.claude {
|
||||
merged_apps.claude = true;
|
||||
}
|
||||
if target_apps.codex {
|
||||
merged_apps.codex = true;
|
||||
}
|
||||
if target_apps.gemini {
|
||||
merged_apps.gemini = true;
|
||||
}
|
||||
let merged_apps = merge_mcp_apps(&existing.apps, &target_apps);
|
||||
|
||||
McpServer {
|
||||
id: existing.id.clone(),
|
||||
@@ -166,6 +156,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
};
|
||||
@@ -175,6 +166,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
"claude" => apps.claude = true,
|
||||
"codex" => apps.codex = true,
|
||||
"gemini" => apps.gemini = true,
|
||||
"grokbuild" | "grok" => apps.grokbuild = true,
|
||||
"opencode" => apps.opencode = true,
|
||||
"openclaw" => {
|
||||
// OpenClaw doesn't support MCP, ignore silently
|
||||
@@ -197,3 +189,40 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
|
||||
Ok(apps)
|
||||
}
|
||||
|
||||
fn merge_mcp_apps(existing: &McpApps, target: &McpApps) -> McpApps {
|
||||
let mut merged = existing.clone();
|
||||
for app in target.enabled_apps() {
|
||||
merged.set_enabled_for(&app, true);
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enabled_apps_merge_covers_every_supported_mcp_client() {
|
||||
let existing = McpApps {
|
||||
claude: true,
|
||||
..McpApps::default()
|
||||
};
|
||||
let target = McpApps {
|
||||
codex: true,
|
||||
gemini: true,
|
||||
grokbuild: true,
|
||||
opencode: true,
|
||||
hermes: true,
|
||||
..McpApps::default()
|
||||
};
|
||||
let merged = merge_mcp_apps(&existing, &target);
|
||||
|
||||
assert!(merged.claude);
|
||||
assert!(merged.codex);
|
||||
assert!(merged.gemini);
|
||||
assert!(merged.grokbuild);
|
||||
assert!(merged.opencode);
|
||||
assert!(merged.hermes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +81,10 @@ fn parse_provider_deeplink(
|
||||
// Validate app type
|
||||
if !matches!(
|
||||
app.as_str(),
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
|
||||
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -190,10 +190,10 @@ fn parse_prompt_deeplink(
|
||||
// Validate app type
|
||||
if !matches!(
|
||||
app.as_str(),
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
|
||||
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -262,10 +262,17 @@ fn parse_mcp_deeplink(
|
||||
let trimmed = app.trim();
|
||||
if !matches!(
|
||||
trimmed,
|
||||
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
|
||||
"claude"
|
||||
| "codex"
|
||||
| "gemini"
|
||||
| "grokbuild"
|
||||
| "grok"
|
||||
| "opencode"
|
||||
| "openclaw"
|
||||
| "hermes"
|
||||
) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
|
||||
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,10 @@ pub fn import_provider_from_deeplink(
|
||||
&provider_id,
|
||||
normalized.clone(),
|
||||
) {
|
||||
log::warn!("Failed to add custom endpoint '{normalized}': {e}");
|
||||
log::warn!(
|
||||
"Failed to add custom endpoint '{}': {e}",
|
||||
crate::url_for_log(&normalized)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,6 +148,7 @@ pub(crate) fn build_provider_from_request(
|
||||
AppType::Claude | AppType::ClaudeDesktop => build_claude_settings(request),
|
||||
AppType::Codex => build_codex_settings(request),
|
||||
AppType::Gemini => build_gemini_settings(request),
|
||||
AppType::GrokBuild => build_grokbuild_settings(request),
|
||||
AppType::OpenCode => build_opencode_settings(request),
|
||||
AppType::OpenClaw => build_additive_app_settings(request),
|
||||
AppType::Hermes => build_hermes_settings(request),
|
||||
@@ -444,6 +448,36 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
json!({ "env": env })
|
||||
}
|
||||
|
||||
fn build_grokbuild_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let model = request
|
||||
.model
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(crate::grok_config::DEFAULT_MODEL)
|
||||
.trim();
|
||||
let name = request
|
||||
.name
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("custom")
|
||||
.trim();
|
||||
let endpoint = get_primary_endpoint(request).trim().to_string();
|
||||
let api_key = request.api_key.as_deref().unwrap_or("").trim();
|
||||
|
||||
let model_value = toml_edit::Value::from(model).to_string();
|
||||
let name_value = toml_edit::Value::from(name).to_string();
|
||||
let endpoint_value = toml_edit::Value::from(endpoint.as_str()).to_string();
|
||||
let api_key_value = toml_edit::Value::from(api_key).to_string();
|
||||
|
||||
json!({
|
||||
"config": format!(
|
||||
"[models]\ndefault = {model_value}\n\n[model.{model_value}]\nmodel = {model_value}\nbase_url = {endpoint_value}\nname = {name_value}\napi_key = {api_key_value}\napi_backend = \"{}\"\ncontext_window = {}\n",
|
||||
crate::grok_config::DEFAULT_API_BACKEND,
|
||||
crate::grok_config::DEFAULT_CONTEXT_WINDOW,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Build OpenCode settings configuration
|
||||
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let endpoint = get_primary_endpoint(request);
|
||||
@@ -600,6 +634,7 @@ pub fn parse_and_merge_config(
|
||||
"claude" => merge_claude_config(&mut merged, &config_value)?,
|
||||
"codex" => merge_codex_config(&mut merged, &config_value)?,
|
||||
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
|
||||
"grokbuild" => merge_grokbuild_config(&mut merged, &config_value)?,
|
||||
// Additive mode apps use JSON config directly; pass through as-is
|
||||
"openclaw" | "opencode" | "hermes" => {
|
||||
merge_additive_config(&mut merged, &config_value)?;
|
||||
@@ -774,6 +809,56 @@ fn merge_gemini_config(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_grokbuild_config(
|
||||
request: &mut DeepLinkImportRequest,
|
||||
config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
let config_toml = if let Some(config_toml) = config.get("config").and_then(|v| v.as_str()) {
|
||||
config_toml.to_string()
|
||||
} else {
|
||||
let toml_value: toml::Value = serde_json::from_value(config.clone()).map_err(|error| {
|
||||
AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))
|
||||
})?;
|
||||
toml::to_string(&toml_value).map_err(|error| {
|
||||
AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))
|
||||
})?
|
||||
};
|
||||
let model = crate::grok_config::extract_model_config(&config_toml).ok_or_else(|| {
|
||||
AppError::InvalidInput("Invalid Grok Build config.toml model profile".to_string())
|
||||
})?;
|
||||
|
||||
if request
|
||||
.api_key
|
||||
.as_ref()
|
||||
.is_none_or(|value| value.is_empty())
|
||||
{
|
||||
request.api_key = model.api_key.or_else(|| {
|
||||
crate::grok_config::extract_credentials(&config_toml).map(|(_, api_key)| api_key)
|
||||
});
|
||||
}
|
||||
if request
|
||||
.endpoint
|
||||
.as_ref()
|
||||
.is_none_or(|value| value.is_empty())
|
||||
{
|
||||
request.endpoint = Some(model.base_url);
|
||||
}
|
||||
if request.model.is_none() {
|
||||
request.model = Some(model.model);
|
||||
}
|
||||
if request
|
||||
.homepage
|
||||
.as_ref()
|
||||
.is_none_or(|value| value.is_empty())
|
||||
{
|
||||
if let Some(endpoint) = request.endpoint.as_deref() {
|
||||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge configuration for additive mode apps (OpenClaw, OpenCode)
|
||||
///
|
||||
/// These apps use JSON config directly, so we only extract common fields
|
||||
|
||||
@@ -87,6 +87,39 @@ fn test_parse_deeplink_with_notes() {
|
||||
assert_eq!(request.notes, Some("Test notes".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_grokbuild_provider() {
|
||||
use super::provider::build_provider_from_request;
|
||||
|
||||
let url = "ccswitch://v1/import?resource=provider&app=grokbuild&name=Grok%20Relay&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=secret&model=grok-4.5";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
assert_eq!(request.app.as_deref(), Some("grokbuild"));
|
||||
assert_eq!(request.name.as_deref(), Some("Grok Relay"));
|
||||
assert_eq!(
|
||||
request.endpoint.as_deref(),
|
||||
Some("https://api.example.com/v1")
|
||||
);
|
||||
assert_eq!(request.api_key.as_deref(), Some("secret"));
|
||||
assert_eq!(request.model.as_deref(), Some("grok-4.5"));
|
||||
|
||||
let provider = build_provider_from_request(&AppType::GrokBuild, &request).unwrap();
|
||||
let config = provider.settings_config["config"].as_str().unwrap();
|
||||
let document = config.parse::<toml::Value>().unwrap();
|
||||
let model = &document["model"]["grok-4.5"];
|
||||
|
||||
assert_eq!(document["models"]["default"].as_str(), Some("grok-4.5"));
|
||||
assert_eq!(
|
||||
model["base_url"].as_str(),
|
||||
Some("https://api.example.com/v1")
|
||||
);
|
||||
assert_eq!(model["name"].as_str(), Some("Grok Relay"));
|
||||
assert_eq!(model["api_key"].as_str(), Some("secret"));
|
||||
assert_eq!(model["api_backend"].as_str(), Some("responses"));
|
||||
assert_eq!(model["context_window"].as_integer(), Some(500_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_scheme() {
|
||||
let url = "https://v1/import?resource=provider&app=claude&name=Test";
|
||||
@@ -500,6 +533,38 @@ experimental_bearer_token = "sk-rightcode"
|
||||
assert_eq!(merged.model, Some("gpt-5-codex".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_and_merge_config_grokbuild() {
|
||||
let config_toml = r#"[models]
|
||||
default = "grok-profile"
|
||||
|
||||
[model."grok-profile"]
|
||||
model = "grok-upstream"
|
||||
base_url = "https://grok.example/v1"
|
||||
name = "Grok Relay"
|
||||
api_key = "sk-grok"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
"#;
|
||||
let config_json = serde_json::json!({ "config": config_toml }).to_string();
|
||||
let request = DeepLinkImportRequest {
|
||||
version: "v1".to_string(),
|
||||
resource: "provider".to_string(),
|
||||
app: Some("grokbuild".to_string()),
|
||||
name: Some("Grok Relay".to_string()),
|
||||
config: Some(BASE64_STANDARD.encode(config_json.as_bytes())),
|
||||
config_format: Some("json".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let merged = parse_and_merge_config(&request).expect("merge Grok Build config");
|
||||
|
||||
assert_eq!(merged.api_key.as_deref(), Some("sk-grok"));
|
||||
assert_eq!(merged.endpoint.as_deref(), Some("https://grok.example/v1"));
|
||||
assert_eq!(merged.model.as_deref(), Some("grok-upstream"));
|
||||
assert_eq!(merged.homepage.as_deref(), Some("https://grok.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_and_merge_config_url_override() {
|
||||
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#;
|
||||
@@ -709,6 +774,11 @@ fn test_parse_mcp_apps() {
|
||||
assert!(!apps.codex);
|
||||
assert!(apps.gemini);
|
||||
|
||||
let apps = parse_mcp_apps("grokbuild,opencode,hermes").unwrap();
|
||||
assert!(apps.grokbuild);
|
||||
assert!(apps.opencode);
|
||||
assert!(apps.hermes);
|
||||
|
||||
let err = parse_mcp_apps("invalid").unwrap_err();
|
||||
assert!(err.to_string().contains("Invalid app"));
|
||||
}
|
||||
@@ -731,6 +801,18 @@ fn test_parse_prompt_deeplink() {
|
||||
assert!(request.enabled.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_grokbuild_prompt_deeplink() {
|
||||
let content_b64 = BASE64_STANDARD.encode("Grok instructions");
|
||||
let url = format!(
|
||||
"ccswitch://v1/import?resource=prompt&app=grokbuild&name=test&content={content_b64}"
|
||||
);
|
||||
|
||||
let request = parse_deeplink_url(&url).expect("parse Grok Build prompt deeplink");
|
||||
|
||||
assert_eq!(request.app.as_deref(), Some("grokbuild"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_deeplink() {
|
||||
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
||||
@@ -747,6 +829,19 @@ fn test_parse_mcp_deeplink() {
|
||||
assert!(request.enabled.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_grokbuild_mcp_deeplink() {
|
||||
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
||||
let config_b64 = BASE64_STANDARD.encode(config);
|
||||
let url = format!(
|
||||
"ccswitch://v1/import?resource=mcp&apps=grokbuild&config={config_b64}&enabled=true"
|
||||
);
|
||||
|
||||
let request = parse_deeplink_url(&url).expect("parse Grok Build MCP deeplink");
|
||||
|
||||
assert_eq!(request.apps.as_deref(), Some("grokbuild"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_skill_deeplink() {
|
||||
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{get_home_dir, write_text_file};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
|
||||
pub const DEFAULT_MODEL: &str = "grok-4.5";
|
||||
pub const DEFAULT_API_BACKEND: &str = "responses";
|
||||
pub const DEFAULT_CONTEXT_WINDOW: i64 = 500_000;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GrokModelConfig {
|
||||
pub profile: String,
|
||||
pub model: String,
|
||||
pub base_url: String,
|
||||
pub name: String,
|
||||
pub api_key: Option<String>,
|
||||
pub env_key: Option<String>,
|
||||
pub api_backend: String,
|
||||
pub context_window: i64,
|
||||
}
|
||||
|
||||
/// Grok Build configuration directory (`~/.grok`).
|
||||
pub fn get_grok_config_dir() -> PathBuf {
|
||||
crate::settings::get_grok_override_dir().unwrap_or_else(|| get_home_dir().join(".grok"))
|
||||
}
|
||||
|
||||
/// Grok Build live configuration path (`~/.grok/config.toml`).
|
||||
pub fn get_grok_config_path() -> PathBuf {
|
||||
get_grok_config_dir().join("config.toml")
|
||||
}
|
||||
|
||||
fn required_non_empty_string<'a>(
|
||||
table: &'a toml::value::Table,
|
||||
key: &str,
|
||||
) -> Result<&'a str, AppError> {
|
||||
table
|
||||
.get(key)
|
||||
.and_then(toml::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.field.missing",
|
||||
format!("Grok Build 配置缺少有效的 {key} 字段"),
|
||||
format!("Grok Build configuration is missing a valid {key} field"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_non_empty_string(table: &toml::value::Table, key: &str) -> Option<String> {
|
||||
table
|
||||
.get(key)
|
||||
.and_then(toml::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
/// Syntax-only validation for a Grok Build config document (empty allowed).
|
||||
///
|
||||
/// 官方条目走 Grok CLI 自带的 xAI OAuth 登录,config.toml 不需要(通常也没有)
|
||||
/// 自定义模型表:空文档合法,非空只要求 TOML 语法合法。live 层的读写与官方
|
||||
/// 快照校验都用它;"必须有完整自定义模型表"的强校验见 `validate_config_toml`。
|
||||
pub fn validate_config_toml_syntax(config_toml: &str) -> Result<(), AppError> {
|
||||
if config_toml.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
config_toml
|
||||
.parse::<toml::Value>()
|
||||
.map(|_| ())
|
||||
.map_err(|error| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.invalid_toml",
|
||||
format!("Grok Build config.toml 格式错误: {error}"),
|
||||
format!("Invalid Grok Build config.toml: {error}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a live config document represents the official login state.
|
||||
///
|
||||
/// 官方态 = 语法合法且完全没有自定义模型痕迹(无 `[models]` 也无 `[model.*]`,
|
||||
/// 允许 `[mcp_servers]` 等其它内容)。只要出现过任一自定义键就返回 false,
|
||||
/// 让残缺的自定义配置继续走 `validate_config_toml` 报出真实错误,
|
||||
/// 而不是被误判成官方态静默吞掉。语法不合法同样返回 false。
|
||||
pub fn is_official_live_config(config_toml: &str) -> bool {
|
||||
let Ok(document) = config_toml.parse::<toml::Value>() else {
|
||||
return false;
|
||||
};
|
||||
document
|
||||
.as_table()
|
||||
.is_some_and(|root| !root.contains_key("models") && !root.contains_key("model"))
|
||||
}
|
||||
|
||||
/// Validate the provider-owned Grok Build TOML document.
|
||||
pub fn validate_config_toml(config_toml: &str) -> Result<(), AppError> {
|
||||
let document = config_toml.parse::<toml::Value>().map_err(|error| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.invalid_toml",
|
||||
format!("Grok Build config.toml 格式错误: {error}"),
|
||||
format!("Invalid Grok Build config.toml: {error}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let root = document.as_table().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.not_table",
|
||||
"Grok Build 配置必须是 TOML 表结构",
|
||||
"Grok Build configuration must be a TOML table",
|
||||
)
|
||||
})?;
|
||||
let models = root
|
||||
.get("models")
|
||||
.and_then(toml::Value::as_table)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.models.missing",
|
||||
"Grok Build 配置缺少 [models]",
|
||||
"Grok Build configuration is missing [models]",
|
||||
)
|
||||
})?;
|
||||
let default_model = required_non_empty_string(models, "default")?;
|
||||
let model_entries = root
|
||||
.get("model")
|
||||
.and_then(toml::Value::as_table)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.model.missing",
|
||||
"Grok Build 配置缺少 [model.<name>]",
|
||||
"Grok Build configuration is missing [model.<name>]",
|
||||
)
|
||||
})?;
|
||||
let selected_model = model_entries
|
||||
.get(default_model)
|
||||
.and_then(toml::Value::as_table)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.default_model.missing",
|
||||
format!("Grok Build 配置缺少 [model.\"{default_model}\"]"),
|
||||
format!("Grok Build configuration is missing [model.\"{default_model}\"]"),
|
||||
)
|
||||
})?;
|
||||
|
||||
required_non_empty_string(selected_model, "model")?;
|
||||
required_non_empty_string(selected_model, "base_url")?;
|
||||
required_non_empty_string(selected_model, "name")?;
|
||||
if optional_non_empty_string(selected_model, "api_key").is_none()
|
||||
&& optional_non_empty_string(selected_model, "env_key").is_none()
|
||||
{
|
||||
return Err(AppError::localized(
|
||||
"provider.grokbuild.credentials.missing",
|
||||
"Grok Build 配置缺少有效的 api_key 或 env_key 字段",
|
||||
"Grok Build configuration is missing a valid api_key or env_key field",
|
||||
));
|
||||
}
|
||||
required_non_empty_string(selected_model, "api_backend")?;
|
||||
|
||||
selected_model
|
||||
.get("context_window")
|
||||
.and_then(toml::Value::as_integer)
|
||||
.filter(|value| *value > 0)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.context_window.invalid",
|
||||
"Grok Build context_window 必须是正整数",
|
||||
"Grok Build context_window must be a positive integer",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn extract_model_config(config_toml: &str) -> Option<GrokModelConfig> {
|
||||
let document = config_toml.parse::<toml::Value>().ok()?;
|
||||
let root = document.as_table()?;
|
||||
let default_model = root
|
||||
.get("models")?
|
||||
.as_table()?
|
||||
.get("default")?
|
||||
.as_str()?
|
||||
.trim();
|
||||
let selected_model = root
|
||||
.get("model")?
|
||||
.as_table()?
|
||||
.get(default_model)?
|
||||
.as_table()?;
|
||||
Some(GrokModelConfig {
|
||||
profile: default_model.to_string(),
|
||||
model: selected_model.get("model")?.as_str()?.trim().to_string(),
|
||||
base_url: selected_model
|
||||
.get("base_url")?
|
||||
.as_str()?
|
||||
.trim_end_matches('/')
|
||||
.to_string(),
|
||||
name: selected_model.get("name")?.as_str()?.trim().to_string(),
|
||||
api_key: optional_non_empty_string(selected_model, "api_key"),
|
||||
env_key: optional_non_empty_string(selected_model, "env_key"),
|
||||
api_backend: selected_model
|
||||
.get("api_backend")?
|
||||
.as_str()?
|
||||
.trim()
|
||||
.to_string(),
|
||||
context_window: selected_model.get("context_window")?.as_integer()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extract_credentials(config_toml: &str) -> Option<(String, String)> {
|
||||
let config = extract_model_config(config_toml)?;
|
||||
let api_key = config
|
||||
.api_key
|
||||
.or_else(|| {
|
||||
config
|
||||
.env_key
|
||||
.as_deref()
|
||||
.and_then(|key| std::env::var(key).ok())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.or_else(|| {
|
||||
std::env::var("XAI_API_KEY")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
})?;
|
||||
Some((config.base_url, api_key))
|
||||
}
|
||||
|
||||
pub fn extract_inline_api_key(config_toml: &str) -> Option<String> {
|
||||
extract_model_config(config_toml)?.api_key
|
||||
}
|
||||
|
||||
pub fn extract_base_url(config_toml: &str) -> Option<String> {
|
||||
Some(extract_model_config(config_toml)?.base_url)
|
||||
}
|
||||
|
||||
fn update_selected_model_string(
|
||||
config_toml: &str,
|
||||
field: &str,
|
||||
value: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let mut document = config_toml
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|error| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.invalid_toml",
|
||||
format!("Grok Build config.toml 格式错误: {error}"),
|
||||
format!("Invalid Grok Build config.toml: {error}"),
|
||||
)
|
||||
})?;
|
||||
let default_model = document
|
||||
.get("models")
|
||||
.and_then(|item| item.get("default"))
|
||||
.and_then(toml_edit::Item::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|model| !model.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.default_model.missing",
|
||||
"Grok Build 配置缺少 models.default",
|
||||
"Grok Build configuration is missing models.default",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let selected_model = document
|
||||
.get_mut("model")
|
||||
.and_then(|item| item.get_mut(&default_model))
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.default_model.missing",
|
||||
format!("Grok Build 配置缺少 [model.\"{default_model}\"]"),
|
||||
format!("Grok Build configuration is missing [model.\"{default_model}\"]"),
|
||||
)
|
||||
})?;
|
||||
selected_model.insert(field, toml_edit::value(value));
|
||||
Ok(document.to_string())
|
||||
}
|
||||
|
||||
pub fn apply_proxy_takeover(
|
||||
config_toml: &str,
|
||||
proxy_base_url: &str,
|
||||
token_placeholder: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let updated = update_selected_model_string(config_toml, "base_url", proxy_base_url)?;
|
||||
update_selected_model_string(&updated, "api_key", token_placeholder)
|
||||
}
|
||||
|
||||
pub fn update_api_key(config_toml: &str, api_key: &str) -> Result<String, AppError> {
|
||||
update_selected_model_string(config_toml, "api_key", api_key)
|
||||
}
|
||||
|
||||
pub fn has_proxy_placeholder(config_toml: &str, token_placeholder: &str) -> bool {
|
||||
extract_model_config(config_toml)
|
||||
.and_then(|config| config.api_key)
|
||||
.is_some_and(|api_key| api_key == token_placeholder)
|
||||
}
|
||||
|
||||
pub fn base_url_matches(config_toml: &str, predicate: impl FnOnce(&str) -> bool) -> bool {
|
||||
extract_model_config(config_toml).is_some_and(|config| predicate(&config.base_url))
|
||||
}
|
||||
|
||||
/// Remove MCP projections from a provider-owned Grok Build settings snapshot.
|
||||
/// MCP servers are owned by the database and projected into live config.toml.
|
||||
pub fn strip_grok_mcp_servers_from_settings(settings: &mut Value) -> Result<(), AppError> {
|
||||
let Some(config_text) = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if !config_text.contains("mcp") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut document = config_text
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|error| AppError::Message(format!("Invalid Grok Build config.toml: {error}")))?;
|
||||
let mut changed = document.as_table_mut().remove("mcp_servers").is_some();
|
||||
if let Some(mcp_table) = document
|
||||
.get_mut("mcp")
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
{
|
||||
if mcp_table.remove("servers").is_some() {
|
||||
changed = true;
|
||||
}
|
||||
if mcp_table.is_empty() {
|
||||
document.as_table_mut().remove("mcp");
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
if let Some(object) = settings.as_object_mut() {
|
||||
object.insert("config".to_string(), Value::String(document.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the live `~/.grok/config.toml` as a provider settings snapshot.
|
||||
///
|
||||
/// 只做 TOML 语法校验:live 处于官方态(无自定义模型表)时同样需要能被
|
||||
/// 读取,供切换回填与界面展示使用。需要"完整自定义模型配置"的导入路径
|
||||
/// 由调用方自行叠加 `validate_config_toml`。
|
||||
pub fn read_grok_live_settings() -> Result<Value, AppError> {
|
||||
let path = get_grok_config_path();
|
||||
if !path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"grokbuild.config.missing",
|
||||
"Grok Build 配置文件不存在",
|
||||
"Grok Build configuration file not found",
|
||||
));
|
||||
}
|
||||
|
||||
let config = fs::read_to_string(&path).map_err(|error| AppError::io(&path, error))?;
|
||||
validate_config_toml_syntax(&config)?;
|
||||
Ok(json!({ "config": config }))
|
||||
}
|
||||
|
||||
pub fn write_grok_provider_live(provider: &Provider) -> Result<(), AppError> {
|
||||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.settings.not_object",
|
||||
"Grok Build 配置必须是 JSON 对象",
|
||||
"Grok Build configuration must be a JSON object",
|
||||
)
|
||||
})?;
|
||||
let config = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.missing",
|
||||
"Grok Build 配置缺少 config 字段",
|
||||
"Grok Build configuration is missing the config field",
|
||||
)
|
||||
})?;
|
||||
|
||||
// 官方条目不注入自定义模型表:按快照原样写回(首次为空文件),
|
||||
// Grok CLI 回落到官方内置模型 + 自带 OAuth 登录;MCP 投影随后由
|
||||
// 切换流程重新补写。非官方供应商必须携带完整的自定义模型配置。
|
||||
if provider.category.as_deref() != Some("official") {
|
||||
validate_config_toml(config)?;
|
||||
}
|
||||
|
||||
write_grok_live_settings(&json!({ "config": config }))
|
||||
}
|
||||
|
||||
/// Raw live-file writer, mirroring `read_grok_live_settings` (syntax-only).
|
||||
///
|
||||
/// 代理接管的备份/恢复也走这里:官方态 live(无自定义模型表)必须可以
|
||||
/// 原样写回。完整形状校验由 `write_grok_provider_live` 的非官方分支负责。
|
||||
pub fn write_grok_live_settings(settings: &Value) -> Result<(), AppError> {
|
||||
let config = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.missing",
|
||||
"Grok Build 配置缺少 config 字段",
|
||||
"Grok Build configuration is missing the config field",
|
||||
)
|
||||
})?;
|
||||
validate_config_toml_syntax(config)?;
|
||||
write_text_file(&get_grok_config_path(), config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn valid_config() -> &'static str {
|
||||
r#"[models]
|
||||
default = "grok-4.5"
|
||||
|
||||
[model."grok-4.5"]
|
||||
model = "grok-4.5"
|
||||
base_url = "https://example.com/v1"
|
||||
name = "Example"
|
||||
api_key = "secret"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
"#
|
||||
}
|
||||
|
||||
fn valid_env_key_config() -> &'static str {
|
||||
r#"[models]
|
||||
default = "grok-env"
|
||||
|
||||
[model."grok-env"]
|
||||
model = "grok-4.5"
|
||||
base_url = "https://example.com/v1"
|
||||
name = "Example Env"
|
||||
env_key = "GROK_TEST_API_KEY"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
"#
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_expected_config_shape() {
|
||||
validate_config_toml(valid_config()).expect("valid Grok Build config");
|
||||
validate_config_toml(valid_env_key_config()).expect("valid env_key configuration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_validation_accepts_official_snapshots() {
|
||||
validate_config_toml_syntax("").expect("empty official snapshot");
|
||||
validate_config_toml_syntax("[mcp_servers.echo]\ncommand = \"echo\"\n")
|
||||
.expect("official-mode config without model tables");
|
||||
assert!(validate_config_toml_syntax("not = [valid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_live_config_detection() {
|
||||
// 官方态:完全没有自定义模型痕迹
|
||||
assert!(is_official_live_config(""));
|
||||
assert!(is_official_live_config(" \n# comment only\n"));
|
||||
assert!(is_official_live_config(
|
||||
"[mcp_servers.echo]\ncommand = \"echo\"\n"
|
||||
));
|
||||
|
||||
// 出现过任一自定义键(哪怕残缺)都不是官方态,交给强校验报错
|
||||
assert!(!is_official_live_config(valid_config()));
|
||||
assert!(!is_official_live_config("[models]\ndefault = \"x\"\n"));
|
||||
assert!(!is_official_live_config("[model.x]\nmodel = \"x\"\n"));
|
||||
|
||||
// 语法不合法不是官方态
|
||||
assert!(!is_official_live_config("not = [valid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_selected_model_table() {
|
||||
let error = validate_config_toml("[models]\ndefault = \"grok-4.5\"\n")
|
||||
.expect_err("missing model table should fail");
|
||||
assert!(error.to_string().contains("model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_config_without_api_key_or_env_key() {
|
||||
let config = valid_config().replace("api_key = \"secret\"\n", "");
|
||||
let error = validate_config_toml(&config).expect_err("credentials should be required");
|
||||
assert!(error.to_string().contains("api_key"));
|
||||
assert!(error.to_string().contains("env_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_selected_model_and_updates_takeover_fields() {
|
||||
let selected = extract_model_config(valid_config()).expect("selected model");
|
||||
assert_eq!(selected.profile, "grok-4.5");
|
||||
assert_eq!(selected.model, "grok-4.5");
|
||||
assert_eq!(selected.base_url, "https://example.com/v1");
|
||||
|
||||
let updated = apply_proxy_takeover(
|
||||
valid_config(),
|
||||
"http://127.0.0.1:15721/grokbuild/v1",
|
||||
"PROXY_MANAGED",
|
||||
)
|
||||
.expect("takeover config");
|
||||
let selected = extract_model_config(&updated).expect("updated selected model");
|
||||
assert_eq!(selected.base_url, "http://127.0.0.1:15721/grokbuild/v1");
|
||||
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
|
||||
assert!(has_proxy_placeholder(&updated, "PROXY_MANAGED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takeover_preserves_env_key_profile_and_injects_inline_placeholder() {
|
||||
let updated = apply_proxy_takeover(
|
||||
valid_env_key_config(),
|
||||
"http://127.0.0.1:15721/grokbuild/v1",
|
||||
"PROXY_MANAGED",
|
||||
)
|
||||
.expect("takeover config");
|
||||
let selected = extract_model_config(&updated).expect("updated selected model");
|
||||
|
||||
assert_eq!(selected.profile, "grok-env");
|
||||
assert_eq!(selected.env_key.as_deref(), Some("GROK_TEST_API_KEY"));
|
||||
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolves_api_key_from_configured_environment_variable() {
|
||||
let original = std::env::var_os("GROK_TEST_API_KEY");
|
||||
std::env::set_var("GROK_TEST_API_KEY", "env-secret");
|
||||
|
||||
let credentials = extract_credentials(valid_env_key_config()).expect("credentials");
|
||||
|
||||
assert_eq!(credentials.0, "https://example.com/v1");
|
||||
assert_eq!(credentials.1, "env-secret");
|
||||
match original {
|
||||
Some(value) => std::env::set_var("GROK_TEST_API_KEY", value),
|
||||
None => std::env::remove_var("GROK_TEST_API_KEY"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_projected_mcp_servers_without_touching_model_config() {
|
||||
let mut settings = json!({
|
||||
"config": format!(
|
||||
"{}\n[mcp_servers.echo]\ncommand = \"echo\"\n",
|
||||
valid_config()
|
||||
)
|
||||
});
|
||||
|
||||
strip_grok_mcp_servers_from_settings(&mut settings).expect("strip MCP servers");
|
||||
|
||||
let config = settings.get("config").and_then(Value::as_str).unwrap();
|
||||
assert!(!config.contains("mcp_servers"));
|
||||
assert!(config.contains("model = \"grok-4.5\""));
|
||||
validate_config_toml(config).expect("stripped config remains valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn official_provider_roundtrips_without_custom_model_tables() {
|
||||
let temp = TempDir::new().expect("temp dir");
|
||||
let original_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
|
||||
|
||||
// 官方条目:空 config 可写(清掉自定义模型表,交还 Grok CLI 官方登录)
|
||||
let mut official = Provider::with_id(
|
||||
"grokbuild-official".to_string(),
|
||||
"Grok Official".to_string(),
|
||||
json!({ "config": "" }),
|
||||
None,
|
||||
);
|
||||
official.category = Some("official".to_string());
|
||||
write_grok_provider_live(&official).expect("official empty config is writable");
|
||||
assert_eq!(
|
||||
fs::read_to_string(get_grok_config_path()).expect("read config"),
|
||||
""
|
||||
);
|
||||
|
||||
// 官方态 live(如 MCP 投影补写后)无自定义模型表,读取与原样写回都必须可用
|
||||
let official_live = "[mcp_servers.echo]\ncommand = \"echo\"\n";
|
||||
write_grok_live_settings(&json!({ "config": official_live }))
|
||||
.expect("official-mode live is writable for backup restore");
|
||||
let settings = read_grok_live_settings().expect("official-mode live is readable");
|
||||
assert_eq!(
|
||||
settings.get("config").and_then(Value::as_str),
|
||||
Some(official_live)
|
||||
);
|
||||
|
||||
// 非官方供应商仍要求完整的自定义模型配置
|
||||
let custom = Provider::with_id(
|
||||
"custom".to_string(),
|
||||
"Custom".to_string(),
|
||||
json!({ "config": "" }),
|
||||
None,
|
||||
);
|
||||
assert!(write_grok_provider_live(&custom).is_err());
|
||||
|
||||
match original_test_home {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn writes_and_reads_live_config() {
|
||||
let temp = TempDir::new().expect("temp dir");
|
||||
let original_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
|
||||
|
||||
let provider = Provider::with_id(
|
||||
"grok".to_string(),
|
||||
"Example".to_string(),
|
||||
json!({ "config": valid_config() }),
|
||||
None,
|
||||
);
|
||||
write_grok_provider_live(&provider).expect("write live config");
|
||||
|
||||
let path = get_grok_config_path();
|
||||
assert_eq!(path, temp.path().join(".grok").join("config.toml"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(path).expect("read config"),
|
||||
valid_config()
|
||||
);
|
||||
assert_eq!(
|
||||
read_grok_live_settings()
|
||||
.expect("read live settings")
|
||||
.get("config")
|
||||
.and_then(Value::as_str),
|
||||
Some(valid_config())
|
||||
);
|
||||
|
||||
match original_test_home {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+359
-124
@@ -14,6 +14,7 @@ mod deeplink;
|
||||
mod error;
|
||||
mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod grok_config;
|
||||
pub mod hermes_config;
|
||||
mod init_status;
|
||||
mod lightweight;
|
||||
@@ -46,11 +47,13 @@ pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||||
pub use database::{Database, Profile};
|
||||
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
|
||||
pub use error::AppError;
|
||||
pub use grok_config::get_grok_config_path;
|
||||
pub use mcp::{
|
||||
import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude,
|
||||
remove_server_from_codex, remove_server_from_gemini, sync_enabled_to_claude,
|
||||
sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude,
|
||||
sync_single_server_to_codex, sync_single_server_to_gemini,
|
||||
import_from_claude, import_from_codex, import_from_gemini, import_from_grokbuild,
|
||||
remove_server_from_claude, remove_server_from_codex, remove_server_from_gemini,
|
||||
remove_server_from_grokbuild, sync_enabled_to_claude, sync_enabled_to_codex,
|
||||
sync_enabled_to_gemini, sync_single_server_to_claude, sync_single_server_to_codex,
|
||||
sync_single_server_to_gemini, sync_single_server_to_grokbuild,
|
||||
};
|
||||
pub use prompt::Prompt;
|
||||
pub use provider::{Provider, ProviderMeta};
|
||||
@@ -66,7 +69,7 @@ pub use store::AppState;
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{fmt, sync::Arc};
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::image::Image;
|
||||
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
||||
@@ -90,35 +93,129 @@ fn set_windows_app_user_model_id(app: &tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_url_for_log(url_str: &str) -> String {
|
||||
match url::Url::parse(url_str) {
|
||||
Ok(url) => {
|
||||
let mut output = format!("{}://", url.scheme());
|
||||
if let Some(host) = url.host_str() {
|
||||
output.push_str(host);
|
||||
}
|
||||
output.push_str(url.path());
|
||||
pub(crate) struct RedactedUrl<'a> {
|
||||
url: &'a str,
|
||||
known_secrets: &'a [String],
|
||||
}
|
||||
|
||||
let mut keys: Vec<String> = url.query_pairs().map(|(k, _)| k.to_string()).collect();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
impl fmt::Display for RedactedUrl<'_> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&redact_url_for_log_with_secrets(
|
||||
self.url,
|
||||
self.known_secrets,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if !keys.is_empty() {
|
||||
output.push_str("?[keys:");
|
||||
output.push_str(&keys.join(","));
|
||||
output.push(']');
|
||||
}
|
||||
/// 为日志提供惰性 URL 脱敏包装;只有日志实际输出时才解析和重建 URL。
|
||||
pub(crate) fn url_for_log(url: &str) -> RedactedUrl<'_> {
|
||||
RedactedUrl {
|
||||
url,
|
||||
known_secrets: &[],
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
Err(_) => {
|
||||
let base = url_str.split('#').next().unwrap_or(url_str);
|
||||
match base.split_once('?') {
|
||||
Some((prefix, _)) => format!("{prefix}?[redacted]"),
|
||||
None => base.to_string(),
|
||||
}
|
||||
/// 为持有确切认证材料的调用方提供优先精确匹配、再启发式兜底的 URL 脱敏。
|
||||
pub(crate) fn url_for_log_with_secrets<'a>(
|
||||
url: &'a str,
|
||||
known_secrets: &'a [String],
|
||||
) -> RedactedUrl<'a> {
|
||||
RedactedUrl { url, known_secrets }
|
||||
}
|
||||
|
||||
/// 已知密钥参与子串脱敏的最短长度:过短的值(如 "api")当作子串会误伤无关文本,
|
||||
/// 所以只对足够长、几乎不可能是普通词的值做替换。
|
||||
const MIN_KNOWN_SECRET_LEN: usize = 8;
|
||||
|
||||
/// 唯一的密钥脱敏原语:把字符串里出现的、我们确切握有的密钥值替换为 [REDACTED]。
|
||||
/// 不做任何“看起来像密钥”的形状猜测——只隐藏已知值,天然收敛、不误伤正常路径。
|
||||
fn redact_known_secrets(text: &str, known_secrets: &[String]) -> String {
|
||||
let mut output = text.to_string();
|
||||
for secret in known_secrets {
|
||||
if secret.chars().count() >= MIN_KNOWN_SECRET_LEN {
|
||||
output = output.replace(secret.as_str(), "[REDACTED]");
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
/// 无 scheme 的裸 authority 形态(如 `user:pass@host/path`)剥掉 userinfo:
|
||||
/// 仅当 `@` 出现在第一个 `/` 之前时才视为凭据。
|
||||
fn strip_bare_userinfo(input: &str) -> &str {
|
||||
let authority_end = input.find('/').unwrap_or(input.len());
|
||||
match input[..authority_end].rfind('@') {
|
||||
Some(at) => &input[at + 1..],
|
||||
None => input,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn redact_url_for_log(url_str: &str) -> String {
|
||||
redact_url_for_log_with_secrets(url_str, &[])
|
||||
}
|
||||
|
||||
/// 为日志脱敏 URL:剥掉 userinfo(user:pass@) 与整个 query/fragment,保留
|
||||
/// scheme/host/port/path 供诊断(如 base_url 配错路径导致 404),最后再抹掉已知密钥值。
|
||||
pub(crate) fn redact_url_for_log_with_secrets(url_str: &str, known_secrets: &[String]) -> String {
|
||||
let scheme_relative = url_str.starts_with("//");
|
||||
let parsed = if scheme_relative {
|
||||
url::Url::parse(&format!("https:{url_str}"))
|
||||
} else {
|
||||
url::Url::parse(url_str)
|
||||
};
|
||||
|
||||
let sanitized = match parsed {
|
||||
Ok(mut url) if url.has_host() => {
|
||||
let _ = url.set_username("");
|
||||
let _ = url.set_password(None);
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
let rendered = url.as_str();
|
||||
if scheme_relative {
|
||||
rendered
|
||||
.strip_prefix("https:")
|
||||
.unwrap_or(rendered)
|
||||
.to_string()
|
||||
} else {
|
||||
rendered.to_string()
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 解析失败(相对路径、含裸 userinfo 的非法 URL 等):丢掉 query/fragment,
|
||||
// 尽力剥掉 userinfo,其余原样保留。
|
||||
let without_tail = url_str.split(['?', '#']).next().unwrap_or(url_str);
|
||||
strip_bare_userinfo(without_tail).to_string()
|
||||
}
|
||||
};
|
||||
|
||||
redact_known_secrets(&sanitized, known_secrets)
|
||||
}
|
||||
|
||||
/// 只保留 `scheme://host:port`,丢掉 path/query/userinfo。用于我们手里没有任何
|
||||
/// 已知密钥可脱敏 path 的场景——凭据可能整个内嵌在 base_url 的 path 里,此时
|
||||
/// 记录 path 无法保证不泄漏,只能退回到 origin。
|
||||
pub(crate) fn redact_url_origin_for_log(url_str: &str) -> String {
|
||||
let scheme_relative = url_str.starts_with("//");
|
||||
let parsed = if scheme_relative {
|
||||
url::Url::parse(&format!("https:{url_str}"))
|
||||
} else {
|
||||
url::Url::parse(url_str)
|
||||
};
|
||||
|
||||
match parsed {
|
||||
Ok(url) if url.has_host() => {
|
||||
let authority = &url[url::Position::BeforeHost..url::Position::AfterPort];
|
||||
if scheme_relative {
|
||||
format!("//{authority}")
|
||||
} else {
|
||||
format!("{}://{authority}", url.scheme())
|
||||
}
|
||||
}
|
||||
_ => "[invalid target]".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_log_level_allows(level: log::Level, max_level: log::LevelFilter) -> bool {
|
||||
max_level.to_level().is_some_and(|maximum| level <= maximum)
|
||||
}
|
||||
|
||||
/// 统一处理 ccswitch:// 深链接 URL
|
||||
@@ -136,9 +233,10 @@ fn handle_deeplink_url(
|
||||
return false;
|
||||
}
|
||||
|
||||
let redacted_url = redact_url_for_log(url_str);
|
||||
log::info!("✓ Deep link URL detected from {source}: {redacted_url}");
|
||||
log::debug!("Deep link URL (raw) from {source}: {url_str}");
|
||||
log::info!(
|
||||
"✓ Deep link URL detected from {source}: {}",
|
||||
url_for_log(url_str)
|
||||
);
|
||||
|
||||
match crate::deeplink::parse_deeplink_url(url_str) {
|
||||
Ok(request) => {
|
||||
@@ -234,7 +332,7 @@ pub fn run() {
|
||||
log::info!("=== Single Instance Callback Triggered ===");
|
||||
log::debug!("Args count: {}", args.len());
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
|
||||
log::debug!(" arg[{i}]: {}", url_for_log(arg));
|
||||
}
|
||||
|
||||
if crate::lightweight::is_lightweight_mode() {
|
||||
@@ -319,21 +417,8 @@ pub fn run() {
|
||||
// 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)
|
||||
app_store::refresh_app_config_dir_override(app.handle());
|
||||
panic_hook::init_app_config_dir(crate::config::get_app_config_dir());
|
||||
#[cfg(target_os = "windows")]
|
||||
set_windows_app_user_model_id(app.handle());
|
||||
|
||||
// 注册 Updater 插件(桌面端)
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
if let Err(e) = app
|
||||
.handle()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
{
|
||||
// 若配置不完整(如缺少 pubkey),跳过 Updater 而不中断应用
|
||||
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
||||
}
|
||||
}
|
||||
// 初始化日志(单文件输出到 <app_config_dir>/logs/cc-switch.log)
|
||||
// 初始化日志(输出到 <app_config_dir>/logs/cc-switch.log)
|
||||
{
|
||||
use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};
|
||||
|
||||
@@ -344,14 +429,16 @@ pub fn run() {
|
||||
eprintln!("创建日志目录失败: {e}");
|
||||
}
|
||||
|
||||
// 启动时删除旧日志文件,实现单文件覆盖效果
|
||||
let log_file_path = log_dir.join("cc-switch.log");
|
||||
let _ = std::fs::remove_file(&log_file_path);
|
||||
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
// 初始化为 Trace,允许后续通过 log::set_max_level() 动态调整级别
|
||||
// 底层保留 Trace 能力,便于加载用户配置后动态调高级别。
|
||||
// 插件注册后会立即把全局级别收紧到 Info,避免启动阶段全量 Trace。
|
||||
.level(log::LevelFilter::Trace)
|
||||
// plugin-log 的前端 command 会直达 logger,绕过 log 宏的全局
|
||||
// max_level;在分发层补一次过滤,确保动态总开关同样约束前端日志。
|
||||
.filter(|metadata| {
|
||||
runtime_log_level_allows(metadata.level(), log::max_level())
|
||||
})
|
||||
.targets([
|
||||
Target::new(TargetKind::Stdout),
|
||||
Target::new(TargetKind::Folder {
|
||||
@@ -359,15 +446,36 @@ pub fn run() {
|
||||
file_name: Some("cc-switch".into()),
|
||||
}),
|
||||
])
|
||||
// 单文件模式:启动时删除旧文件,达到大小时轮转
|
||||
// 注意:KeepSome(n) 内部会做 n-2 运算,n=1 会导致 usize 下溢
|
||||
// KeepSome(2) 是最小安全值,表示不保留轮转文件
|
||||
.rotation_strategy(RotationStrategy::KeepSome(2))
|
||||
// 单文件大小限制 1GB
|
||||
.max_file_size(1024 * 1024 * 1024)
|
||||
// KeepSome(4) 保留 4 个轮转归档,加上当前文件最多约 100 MiB。
|
||||
// 轮转仅按大小触发;跨重启继续追加,不再丢失上一次运行的日志。
|
||||
.rotation_strategy(RotationStrategy::KeepSome(4))
|
||||
.max_file_size(20 * 1024 * 1024)
|
||||
.timezone_strategy(TimezoneStrategy::UseLocal)
|
||||
.build(),
|
||||
)?;
|
||||
|
||||
// 用户配置存在数据库中,数据库尚未打开时使用保守的 Info 级别。
|
||||
log::set_max_level(log::LevelFilter::Info);
|
||||
log::info!("=== CC Switch v{} started ===", env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
|
||||
// 首次读取覆盖路径时 logger 尚未可用;此处重放一次,
|
||||
// 让 Store 损坏或路径无效等启动警告能够真正落盘。
|
||||
let _ = app_store::refresh_app_config_dir_override(app.handle());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
set_windows_app_user_model_id(app.handle());
|
||||
|
||||
// 注册 Updater 插件(桌面端);放在 logger 之后,确保失败可诊断。
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
if let Err(e) = app
|
||||
.handle()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
{
|
||||
// 若配置不完整(如缺少 pubkey),跳过 Updater 而不中断应用
|
||||
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
||||
}
|
||||
}
|
||||
|
||||
// 注入 AppHandle 给 usage_events,让无 AppHandle 持有的写日志路径
|
||||
@@ -464,6 +572,23 @@ pub fn run() {
|
||||
}
|
||||
};
|
||||
|
||||
// 数据库可用后立即应用持久化日志级别,避免后续服务初始化
|
||||
// 继续使用启动阶段的 Info 回退。损坏配置显式 fail-closed 到 Info。
|
||||
match db.get_log_config() {
|
||||
Ok(log_config) => {
|
||||
log::set_max_level(log_config.to_level_filter());
|
||||
log::info!(
|
||||
"已加载日志配置: enabled={}, level={}",
|
||||
log_config.enabled,
|
||||
log_config.level
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::set_max_level(log::LevelFilter::Info);
|
||||
log::warn!("读取日志配置失败,已回退到 info: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有预加载的配置,执行迁移
|
||||
if let Some(config) = migration_config {
|
||||
log::info!("开始执行数据迁移...");
|
||||
@@ -783,6 +908,14 @@ pub fn run() {
|
||||
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
|
||||
}
|
||||
|
||||
match crate::services::mcp::McpService::import_from_grokbuild(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} MCP server(s) from Grok Build");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No Grok Build MCP servers found to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import Grok Build MCP: {e}"),
|
||||
}
|
||||
|
||||
match crate::services::mcp::McpService::import_from_opencode(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} MCP server(s) from OpenCode");
|
||||
@@ -808,6 +941,7 @@ pub fn run() {
|
||||
crate::app_config::AppType::Claude,
|
||||
crate::app_config::AppType::Codex,
|
||||
crate::app_config::AppType::Gemini,
|
||||
crate::app_config::AppType::GrokBuild,
|
||||
crate::app_config::AppType::OpenCode,
|
||||
crate::app_config::AppType::OpenClaw,
|
||||
crate::app_config::AppType::Hermes,
|
||||
@@ -886,7 +1020,7 @@ pub fn run() {
|
||||
|
||||
for (i, url) in urls.iter().enumerate() {
|
||||
let url_str = url.as_str();
|
||||
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
|
||||
log::debug!(" URL[{i}]: {}", url_for_log(url_str));
|
||||
|
||||
if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") {
|
||||
break; // Process only first ccswitch:// URL
|
||||
@@ -954,19 +1088,6 @@ pub fn run() {
|
||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||
app.manage(app_state);
|
||||
|
||||
// 从数据库加载日志配置并应用
|
||||
{
|
||||
let db = &app.state::<AppState>().db;
|
||||
if let Ok(log_config) = db.get_log_config() {
|
||||
log::set_max_level(log_config.to_level_filter());
|
||||
log::info!(
|
||||
"已加载日志配置: enabled={}, level={}",
|
||||
log_config.enabled,
|
||||
log_config.level
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 SkillService
|
||||
let skill_service = SkillService::new();
|
||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||
@@ -995,6 +1116,18 @@ pub fn run() {
|
||||
log::info!("✓ CodexOAuthManager initialized");
|
||||
}
|
||||
|
||||
// 初始化 xAI OAuthManager (Grok API 反代)
|
||||
{
|
||||
use crate::proxy::providers::xai_oauth_auth::XaiOAuthManager;
|
||||
use commands::XaiOAuthState;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
let app_config_dir = crate::config::get_app_config_dir();
|
||||
let xai_oauth_manager = XaiOAuthManager::new(app_config_dir);
|
||||
app.manage(XaiOAuthState(Arc::new(RwLock::new(xai_oauth_manager))));
|
||||
log::info!("✓ XaiOAuthManager initialized");
|
||||
}
|
||||
|
||||
// 初始化全局出站代理 HTTP 客户端
|
||||
{
|
||||
let db = &app.state::<AppState>().db;
|
||||
@@ -1082,59 +1215,42 @@ pub fn run() {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
const SESSION_SYNC_INTERVAL_SECS: u64 = 60;
|
||||
|
||||
fn run_step<T>(name: &str, result: Result<T, crate::error::AppError>) {
|
||||
if let Err(e) = result {
|
||||
log::warn!("{name} failed: {e}");
|
||||
async fn run_session_sync(db: std::sync::Arc<crate::database::Database>, backfill: bool) {
|
||||
let _guard = crate::services::session_usage::session_sync_mutex()
|
||||
.lock()
|
||||
.await;
|
||||
let task = tauri::async_runtime::spawn_blocking(move || {
|
||||
if backfill {
|
||||
if let Err(error) = db.backfill_missing_usage_costs() {
|
||||
log::warn!("Usage cost startup backfill failed: {error}");
|
||||
}
|
||||
}
|
||||
crate::services::session_usage::sync_all_unlocked(&db)
|
||||
});
|
||||
match task.await {
|
||||
Ok(result) if !result.errors.is_empty() => {
|
||||
log::warn!(
|
||||
"Session usage sync completed with {} error(s)",
|
||||
result.errors.len()
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => log::warn!("Session usage blocking task failed: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
let db = &db_for_session_sync;
|
||||
|
||||
// 首次同步
|
||||
run_step(
|
||||
"Usage cost startup backfill",
|
||||
db.backfill_missing_usage_costs(),
|
||||
);
|
||||
run_step(
|
||||
"Session usage initial sync",
|
||||
crate::services::session_usage::sync_claude_session_logs(db),
|
||||
);
|
||||
run_step(
|
||||
"Codex usage initial sync",
|
||||
crate::services::session_usage_codex::sync_codex_usage(db),
|
||||
);
|
||||
run_step(
|
||||
"Gemini usage initial sync",
|
||||
crate::services::session_usage_gemini::sync_gemini_usage(db),
|
||||
);
|
||||
run_step(
|
||||
"OpenCode usage initial sync",
|
||||
crate::services::session_usage_opencode::sync_opencode_usage(db),
|
||||
);
|
||||
// 首次同步(含费用回填)
|
||||
run_session_sync(db_for_session_sync.clone(), true).await;
|
||||
|
||||
// 定期同步
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(
|
||||
SESSION_SYNC_INTERVAL_SECS,
|
||||
));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
interval.tick().await; // skip immediate first tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
run_step(
|
||||
"Session usage periodic sync",
|
||||
crate::services::session_usage::sync_claude_session_logs(db),
|
||||
);
|
||||
run_step(
|
||||
"Codex usage periodic sync",
|
||||
crate::services::session_usage_codex::sync_codex_usage(db),
|
||||
);
|
||||
run_step(
|
||||
"Gemini usage periodic sync",
|
||||
crate::services::session_usage_gemini::sync_gemini_usage(db),
|
||||
);
|
||||
run_step(
|
||||
"OpenCode usage periodic sync",
|
||||
crate::services::session_usage_opencode::sync_opencode_usage(db),
|
||||
);
|
||||
run_session_sync(db_for_session_sync.clone(), false).await;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1201,6 +1317,7 @@ pub fn run() {
|
||||
commands::import_claude_desktop_providers_from_claude,
|
||||
commands::ensure_claude_desktop_official_provider,
|
||||
commands::ensure_codex_official_provider,
|
||||
commands::ensure_grokbuild_official_provider,
|
||||
commands::get_claude_config_status,
|
||||
commands::get_config_status,
|
||||
commands::get_claude_code_config_path,
|
||||
@@ -1257,6 +1374,8 @@ pub fn run() {
|
||||
commands::get_subscription_quota,
|
||||
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)
|
||||
@@ -1406,6 +1525,7 @@ pub fn run() {
|
||||
commands::check_provider_limits,
|
||||
// Session usage sync
|
||||
commands::sync_session_usage,
|
||||
commands::rebuild_codex_usage,
|
||||
commands::get_usage_data_sources,
|
||||
// Stream health check
|
||||
commands::stream_check_provider,
|
||||
@@ -1600,7 +1720,10 @@ pub fn run() {
|
||||
RunEvent::Opened { urls } => {
|
||||
if let Some(url) = urls.first() {
|
||||
let url_str = url.to_string();
|
||||
log::info!("RunEvent::Opened with URL: {url_str}");
|
||||
log::info!(
|
||||
"RunEvent::Opened with URL: {}",
|
||||
url_for_log(&url_str)
|
||||
);
|
||||
|
||||
if url_str.starts_with("ccswitch://") {
|
||||
if crate::lightweight::is_lightweight_mode() {
|
||||
@@ -1740,16 +1863,25 @@ pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
|
||||
///
|
||||
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`,
|
||||
/// 则自动启动代理服务并接管对应应用的 Live 配置。
|
||||
async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
|
||||
let mut apps_to_restore = Vec::new();
|
||||
for app_type in ["claude", "codex", "gemini"] {
|
||||
if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await {
|
||||
if config.enabled {
|
||||
apps_to_restore.push(app_type);
|
||||
}
|
||||
const PROXY_STARTUP_APP_TYPES: [&str; 4] = ["claude", "codex", "gemini", "grokbuild"];
|
||||
|
||||
async fn enabled_proxy_apps_on_startup(db: &database::Database) -> Vec<&'static str> {
|
||||
let mut apps = Vec::new();
|
||||
for app_type in PROXY_STARTUP_APP_TYPES {
|
||||
if db
|
||||
.get_proxy_config_for_app(app_type)
|
||||
.await
|
||||
.is_ok_and(|config| config.enabled)
|
||||
{
|
||||
apps.push(app_type);
|
||||
}
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
|
||||
let apps_to_restore = enabled_proxy_apps_on_startup(&state.db).await;
|
||||
|
||||
if apps_to_restore.is_empty() {
|
||||
log::debug!("启动时无需恢复代理状态");
|
||||
@@ -2067,7 +2199,93 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{classify_exit_request, ExitRequestAction};
|
||||
use super::{
|
||||
classify_exit_request, enabled_proxy_apps_on_startup, redact_url_for_log,
|
||||
redact_url_for_log_with_secrets, redact_url_origin_for_log, runtime_log_level_allows,
|
||||
ExitRequestAction,
|
||||
};
|
||||
use crate::database::Database;
|
||||
|
||||
#[test]
|
||||
fn log_url_redaction_strips_credentials_and_query_keeps_path() {
|
||||
// userinfo 与整个 query 剥离,path 保留用于诊断 base_url 配错。
|
||||
assert_eq!(
|
||||
redact_url_for_log(
|
||||
"https://user:secret@example.com:8443/v1/models?key=top-secret&alt=sse"
|
||||
),
|
||||
"https://example.com:8443/v1/models"
|
||||
);
|
||||
// scheme-relative 保持形态,userinfo 去掉。
|
||||
assert_eq!(
|
||||
redact_url_for_log("//user:sk-secret@gw.example.com/v1"),
|
||||
"//gw.example.com/v1"
|
||||
);
|
||||
// 无 scheme 的裸 userinfo。
|
||||
assert_eq!(
|
||||
redact_url_for_log("user:sk-secret@gw.example.com/v1"),
|
||||
"gw.example.com/v1"
|
||||
);
|
||||
// 无法解析为绝对 URL 时:丢 query,其余原样保留。
|
||||
assert_eq!(redact_url_for_log("not-a-url?token=secret"), "not-a-url");
|
||||
// 不再对 path 段做“看起来像密钥”的形状猜测,正常路径完整保留。
|
||||
assert_eq!(
|
||||
redact_url_for_log("https://host.example/v1/models/gemini-2.5-pro"),
|
||||
"https://host.example/v1/models/gemini-2.5-pro"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_url_redaction_replaces_known_secret_values() {
|
||||
// 精确匹配已知密钥值:无论它出现在 path 还是别处都被抹掉。
|
||||
let secrets = vec!["k-9f3a7c2b1e".to_string()];
|
||||
assert_eq!(
|
||||
redact_url_for_log_with_secrets("https://gw.example.com/k-9f3a7c2b1e/v1", &secrets),
|
||||
"https://gw.example.com/[REDACTED]/v1"
|
||||
);
|
||||
// 过短(<8)的已知值不参与子串脱敏,避免误伤 /v1/ 之类的正常路径。
|
||||
let short_secrets = vec!["api".to_string()];
|
||||
assert_eq!(
|
||||
redact_url_for_log_with_secrets("https://api.example.com/v1", &short_secrets),
|
||||
"https://api.example.com/v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_url_origin_drops_path_for_credential_in_path() {
|
||||
// 没有已知密钥可脱敏时,凭据可能整个内嵌在 path,只记 origin。
|
||||
assert_eq!(
|
||||
redact_url_origin_for_log("https://gw.example.com/k-9f3a7c2b1e/v1"),
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
redact_url_origin_for_log("https://user:pass@gw.example.com:8443/secret/v1"),
|
||||
"https://gw.example.com:8443"
|
||||
);
|
||||
assert_eq!(
|
||||
redact_url_origin_for_log("//gw.example.com/secret/v1"),
|
||||
"//gw.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_log_filter_honors_dynamic_max_level() {
|
||||
assert!(!runtime_log_level_allows(
|
||||
log::Level::Error,
|
||||
log::LevelFilter::Off
|
||||
));
|
||||
assert!(runtime_log_level_allows(
|
||||
log::Level::Error,
|
||||
log::LevelFilter::Info
|
||||
));
|
||||
assert!(runtime_log_level_allows(
|
||||
log::Level::Info,
|
||||
log::LevelFilter::Info
|
||||
));
|
||||
assert!(!runtime_log_level_allows(
|
||||
log::Level::Debug,
|
||||
log::LevelFilter::Info
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_code_keeps_app_alive_in_tray() {
|
||||
@@ -2093,4 +2311,21 @@ mod tests {
|
||||
ExitRequestAction::CleanupAndExit
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_restore_includes_enabled_grokbuild_route() {
|
||||
let db = Database::memory().expect("initialize database");
|
||||
let mut config = db
|
||||
.get_proxy_config_for_app("grokbuild")
|
||||
.await
|
||||
.expect("read Grok Build proxy config");
|
||||
config.enabled = true;
|
||||
db.update_proxy_config_for_app(config)
|
||||
.await
|
||||
.expect("enable Grok Build proxy config");
|
||||
|
||||
let apps = enabled_proxy_apps_on_startup(&db).await;
|
||||
|
||||
assert_eq!(apps, vec!["grokbuild"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -84,7 +84,9 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||
// 核心字段(需要手动处理的字段)
|
||||
let core_fields = match typ {
|
||||
"stdio" => vec!["type", "command", "args", "env", "cwd"],
|
||||
"http" | "sse" => vec!["type", "url", "http_headers"],
|
||||
// DB 中的统一规范使用 headers,Codex TOML 使用 http_headers。
|
||||
// 两者都必须视为核心字段,避免鉴权值落入通用日志路径。
|
||||
"http" | "sse" => vec!["type", "url", "headers", "http_headers"],
|
||||
_ => vec!["type"],
|
||||
};
|
||||
|
||||
@@ -204,7 +206,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||
|
||||
if let Some(val) = json_val {
|
||||
spec.insert(key.clone(), val);
|
||||
log::debug!("导入扩展字段 '{key}' = {toml_val:?}");
|
||||
log::debug!("导入扩展字段 '{key}'(值已省略)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +237,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
@@ -556,7 +559,7 @@ fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option<toml_edit:
|
||||
/// 1. 核心字段(type, command, args, url, headers, env, cwd)使用强类型处理
|
||||
/// 2. 扩展字段(timeout、retry 等)通过白名单列表自动转换
|
||||
/// 3. 其他未知字段使用通用转换器尝试转换
|
||||
fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
|
||||
pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
|
||||
use toml_edit::{Array, Item, Table};
|
||||
|
||||
let mut t = Table::new();
|
||||
@@ -566,7 +569,7 @@ fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError>
|
||||
// 定义核心字段(已在下方处理,跳过通用转换)
|
||||
let core_fields = match typ {
|
||||
"stdio" => vec!["type", "command", "args", "env", "cwd"],
|
||||
"http" | "sse" => vec!["type", "url", "http_headers"],
|
||||
"http" | "sse" => vec!["type", "url", "headers", "http_headers"],
|
||||
_ => vec!["type"],
|
||||
};
|
||||
|
||||
@@ -663,11 +666,11 @@ fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError>
|
||||
if let Some(toml_item) = json_value_to_toml_item(value, key) {
|
||||
t[&key[..]] = toml_item;
|
||||
|
||||
// 记录扩展字段的处理
|
||||
// 只记录字段名:未知字段同样可能携带 token / secret。
|
||||
if extended_fields.contains(&key.as_str()) {
|
||||
log::debug!("已转换扩展字段 '{key}' = {value:?}");
|
||||
log::debug!("已转换扩展字段 '{key}'(值已省略)");
|
||||
} else {
|
||||
log::info!("已转换自定义字段 '{key}' = {value:?}");
|
||||
log::debug!("已转换自定义字段 '{key}'(值已省略)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -675,3 +678,39 @@ fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError>
|
||||
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn http_headers_are_only_written_to_codex_http_headers() {
|
||||
let table = json_server_to_toml_table(&json!({
|
||||
"type": "http",
|
||||
"url": "https://mcp.example.com",
|
||||
"headers": {
|
||||
"Authorization": "Bearer top-secret",
|
||||
"X-Api-Key": "also-secret"
|
||||
},
|
||||
"timeout": 30
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let headers = table
|
||||
.get("http_headers")
|
||||
.and_then(|item| item.as_table())
|
||||
.expect("Codex http_headers table should be written");
|
||||
assert_eq!(
|
||||
headers.get("Authorization").and_then(|item| item.as_str()),
|
||||
Some("Bearer top-secret")
|
||||
);
|
||||
assert!(
|
||||
table.get("headers").is_none(),
|
||||
"legacy headers must not be emitted a second time"
|
||||
);
|
||||
assert_eq!(
|
||||
table.get("timeout").and_then(|item| item.as_integer()),
|
||||
Some(30)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: true,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Grok Build MCP synchronization and import.
|
||||
//!
|
||||
//! Grok Build uses the same top-level `[mcp_servers]` TOML layout as Codex,
|
||||
//! stored alongside its model configuration in `~/.grok/config.toml`.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::codex::json_server_to_toml_table;
|
||||
use super::validation::validate_server_spec;
|
||||
|
||||
fn should_sync_grokbuild_mcp() -> bool {
|
||||
crate::grok_config::get_grok_config_dir().exists()
|
||||
}
|
||||
|
||||
fn read_config_text() -> Result<String, AppError> {
|
||||
let path = crate::grok_config::get_grok_config_path();
|
||||
if !path.exists() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
|
||||
}
|
||||
|
||||
fn json_server_to_grokbuild_toml_table(server_spec: &Value) -> Result<toml_edit::Table, AppError> {
|
||||
let mut table = json_server_to_toml_table(server_spec)?;
|
||||
// Grok infers transport from `command` or `url` and uses `headers`, while
|
||||
// Codex writes an explicit `type` plus `http_headers`.
|
||||
table.remove("type");
|
||||
if let Some(headers) = table.remove("http_headers") {
|
||||
table.insert("headers", headers);
|
||||
}
|
||||
Ok(table)
|
||||
}
|
||||
|
||||
fn toml_server_to_json(entry: &toml::value::Table) -> Value {
|
||||
fn convert(value: &toml::Value) -> Option<Value> {
|
||||
match value {
|
||||
toml::Value::String(value) => Some(json!(value)),
|
||||
toml::Value::Integer(value) => Some(json!(value)),
|
||||
toml::Value::Float(value) => Some(json!(value)),
|
||||
toml::Value::Boolean(value) => Some(json!(value)),
|
||||
toml::Value::Datetime(value) => Some(json!(value.to_string())),
|
||||
toml::Value::Array(values) => Some(Value::Array(
|
||||
values.iter().filter_map(convert).collect::<Vec<_>>(),
|
||||
)),
|
||||
toml::Value::Table(values) => Some(Value::Object(
|
||||
values
|
||||
.iter()
|
||||
.filter_map(|(key, value)| convert(value).map(|value| (key.clone(), value)))
|
||||
.collect(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
let mut spec = serde_json::Map::new();
|
||||
for (key, value) in entry {
|
||||
let output_key = if key == "http_headers" {
|
||||
"headers"
|
||||
} else {
|
||||
key
|
||||
};
|
||||
if let Some(value) = convert(value) {
|
||||
spec.insert(output_key.to_string(), value);
|
||||
}
|
||||
}
|
||||
let default_type = if spec.contains_key("url") {
|
||||
"http"
|
||||
} else {
|
||||
"stdio"
|
||||
};
|
||||
spec.entry("type".to_string())
|
||||
.or_insert_with(|| json!(default_type));
|
||||
Value::Object(spec)
|
||||
}
|
||||
|
||||
pub fn import_from_grokbuild(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
let text = read_config_text()?;
|
||||
if text.trim().is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let root: toml::Table = toml::from_str(&text)
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 ~/.grok/config.toml 失败: {e}")))?;
|
||||
let Some(entries) = root.get("mcp_servers").and_then(toml::Value::as_table) else {
|
||||
return Ok(0);
|
||||
};
|
||||
|
||||
let servers = config
|
||||
.mcp
|
||||
.servers
|
||||
.get_or_insert_with(std::collections::HashMap::new);
|
||||
let mut changed = 0;
|
||||
for (id, entry) in entries {
|
||||
let Some(entry) = entry.as_table() else {
|
||||
continue;
|
||||
};
|
||||
let spec = toml_server_to_json(entry);
|
||||
if let Err(error) = validate_server_spec(&spec) {
|
||||
log::warn!("跳过无效 Grok Build MCP 项 '{id}': {error}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = servers.get_mut(id) {
|
||||
if !existing.apps.grokbuild {
|
||||
existing.apps.grokbuild = true;
|
||||
changed += 1;
|
||||
}
|
||||
} else {
|
||||
servers.insert(
|
||||
id.clone(),
|
||||
McpServer {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
server: spec,
|
||||
apps: McpApps {
|
||||
grokbuild: true,
|
||||
..Default::default()
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn sync_single_server_to_grokbuild(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
if !should_sync_grokbuild_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
use toml_edit::Item;
|
||||
|
||||
let path = crate::grok_config::get_grok_config_path();
|
||||
let text = read_config_text()?;
|
||||
let mut doc = if text.trim().is_empty() {
|
||||
toml_edit::DocumentMut::new()
|
||||
} else {
|
||||
text.parse::<toml_edit::DocumentMut>().map_err(|e| {
|
||||
AppError::McpValidation(format!("解析 Grok Build config.toml 失败: {e}"))
|
||||
})?
|
||||
};
|
||||
if !doc.contains_key("mcp_servers") {
|
||||
doc["mcp_servers"] = toml_edit::table();
|
||||
}
|
||||
doc["mcp_servers"][id] = Item::Table(json_server_to_grokbuild_toml_table(server_spec)?);
|
||||
crate::config::write_text_file(&path, &doc.to_string())
|
||||
}
|
||||
|
||||
pub fn remove_server_from_grokbuild(id: &str) -> Result<(), AppError> {
|
||||
if !should_sync_grokbuild_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
let path = crate::grok_config::get_grok_config_path();
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let text = read_config_text()?;
|
||||
let mut doc = match text.parse::<toml_edit::DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(error) => {
|
||||
log::warn!("解析 Grok Build config.toml 失败: {error},跳过删除操作");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if let Some(servers) = doc
|
||||
.get_mut("mcp_servers")
|
||||
.and_then(toml_edit::Item::as_table_mut)
|
||||
{
|
||||
servers.remove(id);
|
||||
}
|
||||
crate::config::write_text_file(&path, &doc.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn converts_http_headers_to_unified_headers() {
|
||||
let entry: toml::value::Table = toml::from_str(
|
||||
r#"type = "http"
|
||||
url = "https://example.com/mcp"
|
||||
http_headers = { Authorization = "Bearer token" }
|
||||
"#,
|
||||
)
|
||||
.expect("parse table");
|
||||
let spec = toml_server_to_json(&entry);
|
||||
assert_eq!(spec["type"], "http");
|
||||
assert_eq!(spec["headers"]["Authorization"], "Bearer token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_grokbuild_remote_server_without_codex_only_fields() {
|
||||
let table = json_server_to_grokbuild_toml_table(&json!({
|
||||
"type": "http",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer token" }
|
||||
}))
|
||||
.expect("convert server");
|
||||
|
||||
assert!(!table.contains_key("type"));
|
||||
assert!(!table.contains_key("http_headers"));
|
||||
assert_eq!(
|
||||
table
|
||||
.get("headers")
|
||||
.and_then(toml_edit::Item::as_table)
|
||||
.and_then(|headers| headers.get("Authorization"))
|
||||
.and_then(toml_edit::Item::as_str),
|
||||
Some("Bearer token")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -315,6 +315,7 @@ pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
hermes: true,
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod gemini;
|
||||
mod grokbuild;
|
||||
mod hermes;
|
||||
mod opencode;
|
||||
mod validation;
|
||||
@@ -30,6 +31,9 @@ pub use gemini::{
|
||||
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
|
||||
sync_single_server_to_gemini,
|
||||
};
|
||||
pub use grokbuild::{
|
||||
import_from_grokbuild, remove_server_from_grokbuild, sync_single_server_to_grokbuild,
|
||||
};
|
||||
pub use hermes::{import_from_hermes, remove_server_from_hermes, sync_single_server_to_hermes};
|
||||
pub use opencode::{
|
||||
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
|
||||
|
||||
@@ -258,6 +258,7 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppErr
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: true,
|
||||
hermes: false,
|
||||
},
|
||||
|
||||
+103
-8
@@ -3,16 +3,19 @@
|
||||
//! 在应用崩溃时捕获 panic 信息并记录到 `<app_config_dir>/crash.log` 文件中(默认 `~/.cc-switch/crash.log`)。
|
||||
//! 便于用户和开发者诊断闪退问题。
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::panic;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// 应用版本号(从 Cargo.toml 读取)
|
||||
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const CRASH_LOG_MAX_SIZE: u64 = 5 * 1024 * 1024;
|
||||
const CRASH_LOG_ARCHIVES_TO_KEEP: usize = 2;
|
||||
|
||||
static APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
static CRASH_LOG_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
pub fn init_app_config_dir(dir: PathBuf) {
|
||||
let _ = APP_CONFIG_DIR.set(dir);
|
||||
@@ -38,6 +41,50 @@ fn get_crash_log_path() -> PathBuf {
|
||||
get_app_config_dir().join("crash.log")
|
||||
}
|
||||
|
||||
fn rotated_crash_log_path(path: &Path, index: usize) -> PathBuf {
|
||||
let mut rotated = path.as_os_str().to_os_string();
|
||||
rotated.push(format!(".{index}"));
|
||||
PathBuf::from(rotated)
|
||||
}
|
||||
|
||||
fn rotate_crash_log_if_needed_with_limit(
|
||||
path: &Path,
|
||||
max_size: u64,
|
||||
archives_to_keep: usize,
|
||||
) -> std::io::Result<()> {
|
||||
let size = match fs::metadata(path) {
|
||||
Ok(metadata) => metadata.len(),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if size < max_size || archives_to_keep == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for index in (1..=archives_to_keep).rev() {
|
||||
let source = if index == 1 {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
rotated_crash_log_path(path, index - 1)
|
||||
};
|
||||
if !source.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let destination = rotated_crash_log_path(path, index);
|
||||
if destination.exists() {
|
||||
fs::remove_file(&destination)?;
|
||||
}
|
||||
fs::rename(source, destination)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rotate_crash_log_if_needed(path: &Path) -> std::io::Result<()> {
|
||||
rotate_crash_log_if_needed_with_limit(path, CRASH_LOG_MAX_SIZE, CRASH_LOG_ARCHIVES_TO_KEEP)
|
||||
}
|
||||
|
||||
/// 获取日志目录路径
|
||||
pub fn get_log_dir() -> PathBuf {
|
||||
get_app_config_dir().join("logs")
|
||||
@@ -167,12 +214,23 @@ Stack Trace (Backtrace)
|
||||
"#
|
||||
);
|
||||
|
||||
// 写入文件(追加模式)
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) {
|
||||
let _ = file.write_all(crash_entry.as_bytes());
|
||||
let _ = file.flush();
|
||||
// 将 size check、轮转和追加合成同一个临界区,避免多线程同时 panic
|
||||
// 时两个 hook 竞争 rename 而丢失归档。
|
||||
let crash_log_guard = CRASH_LOG_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let _ = rotate_crash_log_if_needed(&log_path);
|
||||
let saved =
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) {
|
||||
let _ = file.write_all(crash_entry.as_bytes());
|
||||
let _ = file.flush();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
drop(crash_log_guard);
|
||||
|
||||
// 记录日志文件位置到 stderr
|
||||
if saved {
|
||||
eprintln!("\n[CC-Switch] Crash log saved to: {}", log_path.display());
|
||||
}
|
||||
|
||||
@@ -202,4 +260,41 @@ mod tests {
|
||||
assert!(info.contains("Arch:"));
|
||||
assert!(info.contains("App Version:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_log_rotation_keeps_bounded_archives() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("crash.log");
|
||||
|
||||
fs::write(&path, b"first").unwrap();
|
||||
rotate_crash_log_if_needed_with_limit(&path, 4, 2).unwrap();
|
||||
assert!(!path.exists());
|
||||
assert_eq!(
|
||||
fs::read(rotated_crash_log_path(&path, 1)).unwrap(),
|
||||
b"first"
|
||||
);
|
||||
|
||||
fs::write(&path, b"second").unwrap();
|
||||
rotate_crash_log_if_needed_with_limit(&path, 4, 2).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(rotated_crash_log_path(&path, 1)).unwrap(),
|
||||
b"second"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(rotated_crash_log_path(&path, 2)).unwrap(),
|
||||
b"first"
|
||||
);
|
||||
|
||||
fs::write(&path, b"third").unwrap();
|
||||
rotate_crash_log_if_needed_with_limit(&path, 4, 2).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(rotated_crash_log_path(&path, 1)).unwrap(),
|
||||
b"third"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(rotated_crash_log_path(&path, 2)).unwrap(),
|
||||
b"second"
|
||||
);
|
||||
assert!(!rotated_crash_log_path(&path, 3).exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ use crate::opencode_config::get_opencode_dir;
|
||||
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
if matches!(app, AppType::ClaudeDesktop) {
|
||||
return Err(AppError::localized(
|
||||
"claude_desktop.prompts_unsupported",
|
||||
"Claude Desktop 暂不支持 Prompts",
|
||||
"Claude Desktop does not support Prompts",
|
||||
"app.prompts_unsupported",
|
||||
"当前应用暂不支持 Prompts",
|
||||
"This app does not support Prompts",
|
||||
));
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
|
||||
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
||||
AppType::Gemini => get_gemini_dir(),
|
||||
AppType::GrokBuild => crate::grok_config::get_grok_config_dir(),
|
||||
AppType::OpenCode => get_opencode_dir(),
|
||||
AppType::OpenClaw => get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
@@ -32,7 +33,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Claude => "CLAUDE.md",
|
||||
AppType::Codex => "AGENTS.md",
|
||||
AppType::Gemini => "GEMINI.md",
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
|
||||
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
|
||||
AppType::ClaudeDesktop => unreachable!("handled above"),
|
||||
};
|
||||
|
||||
|
||||
@@ -71,6 +71,10 @@ impl Provider {
|
||||
self.provider_type() == Some("codex_oauth")
|
||||
}
|
||||
|
||||
pub fn is_xai_oauth(&self) -> bool {
|
||||
self.provider_type() == Some("xai_oauth")
|
||||
}
|
||||
|
||||
pub fn is_github_copilot(&self) -> bool {
|
||||
self.provider_type() == Some("github_copilot")
|
||||
|| self.claude_base_url_contains("githubcopilot.com")
|
||||
@@ -79,6 +83,7 @@ impl Provider {
|
||||
pub fn uses_managed_account_auth(&self) -> bool {
|
||||
self.is_github_copilot()
|
||||
|| self.is_codex_oauth()
|
||||
|| self.is_xai_oauth()
|
||||
|| self.claude_base_url_contains("chatgpt.com/backend-api/codex")
|
||||
}
|
||||
|
||||
@@ -164,6 +169,11 @@ impl Provider {
|
||||
let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
|
||||
(base_url, api_key)
|
||||
}
|
||||
AppType::GrokBuild => settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(crate::grok_config::extract_credentials)
|
||||
.unwrap_or_default(),
|
||||
// Hermes (config.yaml) flattens credentials at the top level, snake_case.
|
||||
AppType::Hermes => (
|
||||
str_at(settings.get("base_url")),
|
||||
|
||||
@@ -22,9 +22,10 @@ use super::{
|
||||
types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use crate::commands::{CodexOAuthState, CopilotAuthState};
|
||||
use crate::commands::{CodexOAuthState, CopilotAuthState, XaiOAuthState};
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
|
||||
use crate::proxy::providers::xai_oauth_auth::XaiOAuthManager;
|
||||
use crate::{
|
||||
app_config::AppType,
|
||||
provider::{LocalProxyRequestOverrides, Provider},
|
||||
@@ -1129,7 +1130,9 @@ impl RequestForwarder {
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.is_full_url)
|
||||
.unwrap_or(false);
|
||||
.unwrap_or(false)
|
||||
&& !provider.is_codex_oauth()
|
||||
&& !provider.is_xai_oauth();
|
||||
|
||||
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
|
||||
let is_copilot = provider
|
||||
@@ -1142,9 +1145,9 @@ impl RequestForwarder {
|
||||
// Codex upstream conversion mode — computed early because the [1m]-suffix strip
|
||||
// below must be skipped on the Anthropic path (the marker has to survive to
|
||||
// catalog matching and to the transform's own strip+beta detection).
|
||||
let codex_responses_to_chat = matches!(app_type, AppType::Codex)
|
||||
let codex_responses_to_chat = matches!(app_type, AppType::Codex | AppType::GrokBuild)
|
||||
&& super::providers::should_convert_codex_responses_to_chat(provider, endpoint);
|
||||
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex)
|
||||
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex | AppType::GrokBuild)
|
||||
&& super::providers::should_convert_codex_responses_to_anthropic(provider, endpoint);
|
||||
let codex_official_auth_passthrough = matches!(app_type, AppType::Codex)
|
||||
&& super::providers::is_codex_official_provider(provider);
|
||||
@@ -1168,6 +1171,13 @@ impl RequestForwarder {
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mut mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
// Grok Build exposes a stable client-side model profile in config.toml.
|
||||
// Route requests to the provider's real upstream model before applying
|
||||
// the optional Responses -> Chat/Anthropic bridge.
|
||||
if matches!(app_type, AppType::GrokBuild) {
|
||||
super::providers::apply_codex_upstream_model(provider, &mut mapped_body);
|
||||
}
|
||||
|
||||
if is_copilot {
|
||||
mapped_body =
|
||||
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
|
||||
@@ -1511,7 +1521,49 @@ impl RequestForwarder {
|
||||
mapped_body
|
||||
};
|
||||
|
||||
if matches!(app_type, AppType::Codex) {
|
||||
// Native Responses passthrough to a strict third-party gateway (xAI):
|
||||
// flatten Codex's private `namespace`/plugin tool declarations into
|
||||
// top-level function tools so the upstream's strict serde parser does
|
||||
// not 422 on `unknown variant "namespace"`. The Chat/Anthropic paths
|
||||
// above already unwrap namespaces, so this only fires on the native
|
||||
// passthrough. The response handler restores the flat names using a map
|
||||
// re-derived from the same request tools.
|
||||
if matches!(app_type, AppType::Codex | AppType::GrokBuild)
|
||||
&& !codex_responses_to_chat
|
||||
&& !codex_responses_to_anthropic
|
||||
&& super::providers::provider_needs_responses_namespace_flatten(provider)
|
||||
&& super::providers::transform_codex_responses_namespace::flatten_request_namespaces(
|
||||
&mut request_body,
|
||||
)?
|
||||
{
|
||||
log::debug!(
|
||||
"[Codex] Flattened namespace tools for native Responses upstream (provider={})",
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
|
||||
// Same native-Responses path: scrub the OpenAI-backend-private fields
|
||||
// and tool carriers (`external_web_access`, `prompt_cache_retention`,
|
||||
// `additional_tools`, `tool_search`, …) that xAI's strict serde parser
|
||||
// rejects with 400/422. Deterministic field removals only, gated on the
|
||||
// xAI OAuth path, so the prompt-cache prefix stays stable and no other
|
||||
// provider is affected. Runs after the flatten above so lifted
|
||||
// `namespace` tools survive the tool-type whitelist.
|
||||
if matches!(app_type, AppType::Codex | AppType::GrokBuild)
|
||||
&& !codex_responses_to_chat
|
||||
&& !codex_responses_to_anthropic
|
||||
&& super::providers::provider_needs_responses_namespace_flatten(provider)
|
||||
&& super::providers::transform_codex_responses_xai_sanitize::sanitize_xai_responses_request(
|
||||
&mut request_body,
|
||||
)
|
||||
{
|
||||
log::debug!(
|
||||
"[Codex] Sanitized xAI-unsupported Responses fields (provider={})",
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
|
||||
if matches!(app_type, AppType::Codex | AppType::GrokBuild) {
|
||||
self.apply_media_prevention(&mut request_body, provider);
|
||||
}
|
||||
|
||||
@@ -1556,7 +1608,9 @@ impl RequestForwarder {
|
||||
let mut codex_oauth_account_id: Option<String> = None;
|
||||
let mut should_send_codex_oauth_session_headers = false;
|
||||
|
||||
// 获取认证头(提前准备,用于内联替换)
|
||||
// 获取认证头(提前准备,用于内联替换),同时保留仅用于日志脱敏的
|
||||
// 精确认证材料。实际日志永远不输出这些值。
|
||||
let mut log_secrets: Vec<String> = Vec::new();
|
||||
let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
|
||||
// GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token
|
||||
if auth.strategy == AuthStrategy::GitHubCopilot {
|
||||
@@ -1662,6 +1716,50 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
// xAI OAuth: resolve a managed account token immediately before
|
||||
// sending the request. Invalid refresh credentials are persisted as
|
||||
// requiring re-authentication by the manager.
|
||||
if auth.strategy == AuthStrategy::XaiOAuth {
|
||||
if let Some(app_handle) = &self.app_handle {
|
||||
let xai_state = app_handle.state::<XaiOAuthState>();
|
||||
let xai_auth: tokio::sync::RwLockReadGuard<'_, XaiOAuthManager> =
|
||||
xai_state.0.read().await;
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.managed_account_id_for("xai_oauth"));
|
||||
let token_result = match &account_id {
|
||||
Some(id) => xai_auth.get_valid_token_for_account(id).await,
|
||||
None => xai_auth.get_valid_token().await,
|
||||
};
|
||||
match token_result {
|
||||
Ok(token) => {
|
||||
auth = AuthInfo::new(token, AuthStrategy::XaiOAuth);
|
||||
log::debug!(
|
||||
"[XaiOAuth] 成功获取 access_token (account={})",
|
||||
account_id.as_deref().unwrap_or("default")
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("[XaiOAuth] 获取 access_token 失败: {error}");
|
||||
return Err(ProxyError::AuthError(format!(
|
||||
"xAI OAuth 认证失败: {error}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(ProxyError::AuthError(
|
||||
"xAI OAuth 认证不可用(无 AppHandle)".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for secret in std::iter::once(&auth.api_key).chain(auth.access_token.iter()) {
|
||||
if !secret.is_empty() && !log_secrets.contains(secret) {
|
||||
log_secrets.push(secret.clone());
|
||||
}
|
||||
}
|
||||
|
||||
adapter.get_auth_headers(&auth)?
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -2067,22 +2165,29 @@ impl RequestForwarder {
|
||||
|
||||
reject_proxy_placeholder_for_managed_account_upstream(&url, &ordered_headers)?;
|
||||
|
||||
// 日志目标 URL 的脱敏分两种情形:
|
||||
// - 有已知密钥(log_secrets 非空):记录脱敏后的完整 URL,剥 userinfo/query
|
||||
// 并抹掉已知密钥值,保留 host+path 便于诊断 base_url 配错路径导致的 404。
|
||||
// - 无已知密钥:凭据可能整个内嵌在 path 里且无从脱敏,只记 origin,
|
||||
// 避免默认 Info 级把形如 https://gw/<KEY>/v1 的 path 完整落盘。
|
||||
let target_for_log = if log_secrets.is_empty() {
|
||||
crate::redact_url_origin_for_log(&url)
|
||||
} else {
|
||||
crate::redact_url_for_log_with_secrets(&url, &log_secrets)
|
||||
};
|
||||
|
||||
// 输出请求信息日志
|
||||
let tag = adapter.name();
|
||||
let request_model = filtered_body
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("<none>");
|
||||
log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})");
|
||||
if log::log_enabled!(log::Level::Debug) {
|
||||
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
|
||||
log::debug!(
|
||||
"[{tag}] >>> 请求体内容 ({}字节): {}",
|
||||
body_str.len(),
|
||||
body_str
|
||||
);
|
||||
}
|
||||
}
|
||||
log::info!("[{tag}] >>> 请求目标: {target_for_log} (model={request_model})");
|
||||
log::debug!(
|
||||
"[{tag}] >>> 请求体已准备: bytes={}, hash={} (content omitted)",
|
||||
body_bytes.len(),
|
||||
short_value_hash(Some(&filtered_body))
|
||||
);
|
||||
|
||||
// 确定超时
|
||||
let timeout = if self.non_streaming_timeout.is_zero() {
|
||||
@@ -2149,11 +2254,12 @@ impl RequestForwarder {
|
||||
} else {
|
||||
// HTTP 代理或直连:走 hyper raw write(保持 header 大小写)
|
||||
// 如果有 HTTP 代理,hyper_client 会用 CONNECT 隧道穿过代理
|
||||
let uri: http::Uri = url
|
||||
.parse()
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?;
|
||||
let uri: http::Uri = url.parse().map_err(|e| {
|
||||
ProxyError::ForwardFailed(format!("Invalid upstream URL ({target_for_log}): {e}"))
|
||||
})?;
|
||||
super::hyper_client::send_request(
|
||||
uri,
|
||||
&target_for_log,
|
||||
method.clone(),
|
||||
ordered_headers,
|
||||
extensions.clone(),
|
||||
@@ -2544,6 +2650,14 @@ impl RequestForwarder {
|
||||
return ErrorCategory::NonRetryable;
|
||||
}
|
||||
|
||||
// xAI OAuth mirrors the same rule for token acquisition: a local
|
||||
// AuthError means the managed account needs re-login. Failing over
|
||||
// would silently move the conversation off the selected Grok account
|
||||
// and poison the provider's health state for an account-level issue.
|
||||
if provider.is_xai_oauth() && matches!(error, ProxyError::AuthError(_)) {
|
||||
return ErrorCategory::NonRetryable;
|
||||
}
|
||||
|
||||
match error {
|
||||
// 网络和上游错误:都应该尝试下一个供应商
|
||||
ProxyError::Timeout(_) => ErrorCategory::Retryable,
|
||||
@@ -3120,6 +3234,7 @@ fn is_managed_account_upstream_url(url: &str) -> bool {
|
||||
host == "githubcopilot.com"
|
||||
|| host.ends_with(".githubcopilot.com")
|
||||
|| (host == "chatgpt.com" && uri.path().starts_with("/backend-api/codex"))
|
||||
|| (host == "api.x.ai" && uri.path().starts_with("/v1/"))
|
||||
}
|
||||
|
||||
fn headers_contain_proxy_placeholder(headers: &http::HeaderMap) -> bool {
|
||||
@@ -3141,7 +3256,7 @@ fn should_preserve_exact_header_case(
|
||||
return false;
|
||||
}
|
||||
|
||||
if is_copilot || provider.is_codex_oauth() {
|
||||
if is_copilot || provider.is_codex_oauth() || provider.is_xai_oauth() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3179,11 +3294,11 @@ fn should_force_identity_encoding(
|
||||
|
||||
fn map_reqwest_send_error(error: reqwest::Error) -> ProxyError {
|
||||
if error.is_timeout() {
|
||||
ProxyError::Timeout(format!("请求超时: {error}"))
|
||||
ProxyError::Timeout(format!("上游请求超时: {}", error.without_url()))
|
||||
} else if error.is_connect() {
|
||||
ProxyError::ForwardFailed(format!("连接失败: {error}"))
|
||||
ProxyError::ForwardFailed(format!("上游连接失败: {}", error.without_url()))
|
||||
} else {
|
||||
ProxyError::ForwardFailed(error.to_string())
|
||||
ProxyError::ForwardFailed(format!("上游请求发送失败: {}", error.without_url()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3383,7 +3498,8 @@ fn log_prompt_cache_trace(
|
||||
"[CacheTrace] app={}, provider={}, endpoint={}, api_format={}, session_client_provided={}, prompt_cache_key={}, store={}, stream={}, instructions_hash={}, system_hash={}, tools_hash={}, input_hash={}, messages_hash={}, include_hash={}, cache_controls={}, body_hash={}",
|
||||
app_type.as_str(),
|
||||
provider.id,
|
||||
endpoint,
|
||||
// Gemini 的 endpoint 带 ?key=<API_KEY>;脱敏剥掉 query 再落盘。
|
||||
crate::redact_url_for_log(endpoint),
|
||||
api_format.unwrap_or("native"),
|
||||
session_client_provided,
|
||||
prompt_cache_key,
|
||||
@@ -3520,6 +3636,7 @@ mod tests {
|
||||
assert_eq!(code, log_fwd::SINGLE_PROVIDER_FAILED);
|
||||
assert!(message.contains("Provider PackyCode-response 请求失败"));
|
||||
assert!(message.contains("上游 HTTP 429"));
|
||||
// 上游错误消息保留(截断),用于诊断失败原因。
|
||||
assert!(message.contains("rate limit exceeded"));
|
||||
assert!(!message.contains("切换下一个"));
|
||||
}
|
||||
@@ -3552,20 +3669,6 @@ mod tests {
|
||||
assert!(message.contains("connection reset by peer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_upstream_body_prefers_json_message() {
|
||||
let body = json!({
|
||||
"error": {
|
||||
"message": "invalid_request_error: unsupported field"
|
||||
},
|
||||
"request_id": "req_123"
|
||||
});
|
||||
|
||||
let summary = summarize_upstream_body(&body.to_string());
|
||||
|
||||
assert_eq!(summary, "invalid_request_error: unsupported field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_text_for_log_collapses_whitespace_and_truncates() {
|
||||
let summary = summarize_text_for_log("line1\n\n line2 line3", 12);
|
||||
@@ -3893,6 +3996,16 @@ mod tests {
|
||||
err,
|
||||
ProxyError::AuthError(message) if message.contains("PROXY_MANAGED")
|
||||
));
|
||||
|
||||
let xai_err = reject_proxy_placeholder_for_managed_account_upstream(
|
||||
"https://api.x.ai/v1/responses",
|
||||
&headers,
|
||||
)
|
||||
.expect_err("xAI placeholder should be rejected before upstream");
|
||||
assert!(matches!(
|
||||
xai_err,
|
||||
ProxyError::AuthError(message) if message.contains("PROXY_MANAGED")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4280,6 +4393,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xai_oauth_token_auth_failures_are_not_retryable() {
|
||||
let forwarder = test_forwarder(Duration::ZERO, Duration::ZERO);
|
||||
let provider = test_provider_with_type(Some("xai_oauth"));
|
||||
|
||||
// 本地取 token 失败 = 账号级问题(需重新登录),failover 无济于事
|
||||
assert_eq!(
|
||||
forwarder.categorize_proxy_error(
|
||||
&ProxyError::AuthError("xAI OAuth 认证失败".to_string()),
|
||||
&provider,
|
||||
),
|
||||
ErrorCategory::NonRetryable
|
||||
);
|
||||
// 上游 401/403 保持 Retryable:换 provider 可能持有可用的 key
|
||||
assert_eq!(
|
||||
forwarder.categorize_proxy_error(
|
||||
&ProxyError::UpstreamError {
|
||||
status: 401,
|
||||
body: None,
|
||||
},
|
||||
&provider,
|
||||
),
|
||||
ErrorCategory::Retryable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_codex_rejects_stale_proxy_placeholder_with_restart_hint() {
|
||||
let mut headers = HeaderMap::new();
|
||||
@@ -4616,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,
|
||||
@@ -4718,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:上游报图片错误也不触发兜底重试。
|
||||
|
||||
+332
-61
@@ -28,13 +28,13 @@ use super::{
|
||||
streaming_codex_chat::create_responses_sse_stream_from_chat_with_context,
|
||||
streaming_gemini::create_anthropic_sse_stream_from_gemini,
|
||||
streaming_responses::create_anthropic_sse_stream_from_responses,
|
||||
transform, transform_codex_anthropic, transform_codex_chat, transform_gemini,
|
||||
transform_responses,
|
||||
transform, transform_codex_anthropic, transform_codex_chat,
|
||||
transform_codex_responses_namespace, transform_gemini, transform_responses,
|
||||
},
|
||||
response_processor::{
|
||||
create_logged_passthrough_stream, process_response, read_decoded_body,
|
||||
strip_entity_headers_for_rebuilt_body, strip_hop_by_hop_response_headers,
|
||||
usage_logging_enabled, SseUsageCollector,
|
||||
create_logged_passthrough_stream, create_usage_collector, process_response,
|
||||
read_decoded_body, strip_entity_headers_for_rebuilt_body,
|
||||
strip_hop_by_hop_response_headers, usage_logging_enabled, SseUsageCollector,
|
||||
},
|
||||
server::ProxyState,
|
||||
sse::{strip_sse_field, take_sse_block},
|
||||
@@ -538,16 +538,22 @@ async fn handle_claude_transform(
|
||||
} else {
|
||||
chat_sse_to_response_value(&body_str)
|
||||
};
|
||||
// 聚合也失败时:保留全量 body 服务端日志,并给客户端错误附带同款
|
||||
// 现场诊断(content-type/body 摘要),否则命中嗅探臂的用户只拿到
|
||||
// 聚合也失败时:服务端日志只记录长度,并给客户端错误附带同款
|
||||
// 现场诊断(content-type/body 分类),否则命中嗅探臂的用户只拿到
|
||||
// 裸聚合错误、丢失非嗅探臂已有的诊断增强(C7)
|
||||
aggregated.map_err(|e| {
|
||||
log::error!("[Claude] SSE 聚合兜底失败: {e}, body: {body_str}");
|
||||
log::error!(
|
||||
"[Claude] SSE 聚合兜底失败: {e}, body_bytes={}",
|
||||
body_bytes.len()
|
||||
);
|
||||
aggregate_fallback_error(e, &response_headers, &body_str)
|
||||
})?
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}");
|
||||
log::error!(
|
||||
"[Claude] 解析上游响应失败: {e}, body_bytes={}",
|
||||
body_bytes.len()
|
||||
);
|
||||
return Err(upstream_body_parse_error(
|
||||
"Failed to parse upstream response",
|
||||
&e,
|
||||
@@ -758,6 +764,30 @@ pub async fn handle_chat_completions(
|
||||
pub async fn handle_responses(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_responses_for_app(state, request, AppType::Codex, "Codex", "codex").await
|
||||
}
|
||||
|
||||
pub async fn handle_grokbuild_responses(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_responses_for_app(
|
||||
state,
|
||||
request,
|
||||
AppType::GrokBuild,
|
||||
"Grok Build",
|
||||
"grokbuild",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_responses_for_app(
|
||||
state: ProxyState,
|
||||
request: axum::extract::Request,
|
||||
app_type: AppType,
|
||||
tag: &'static str,
|
||||
app_type_str: &'static str,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
@@ -774,7 +804,7 @@ pub async fn handle_responses(
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses");
|
||||
|
||||
let is_stream = body
|
||||
@@ -782,11 +812,15 @@ pub async fn handle_responses(
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let codex_tool_context = transform_codex_chat::build_codex_tool_context_from_request(&body);
|
||||
// Captured before `body` is moved into the forwarder: the flat-name →
|
||||
// {namespace, name} map used to restore the native Responses upstream's
|
||||
// function-call names (see the namespace-restore dispatch below).
|
||||
let namespace_restore_map = transform_codex_responses_namespace::namespace_restore_map(&body);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let mut result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
&app_type,
|
||||
method,
|
||||
&endpoint,
|
||||
body,
|
||||
@@ -835,6 +869,24 @@ pub async fn handle_responses(
|
||||
.await;
|
||||
}
|
||||
|
||||
// Native Responses passthrough to a strict gateway (xAI): the request-side
|
||||
// flatten (in the forwarder) turned Codex `namespace` tools into flat
|
||||
// function tools, so the upstream returns flat function-call names. Restore
|
||||
// them to `{name, namespace}` so the Codex client matches them against its
|
||||
// namespaced tool registry.
|
||||
if super::providers::provider_needs_responses_namespace_flatten(&ctx.provider)
|
||||
&& !namespace_restore_map.is_empty()
|
||||
{
|
||||
return handle_codex_responses_namespace_restore(
|
||||
response,
|
||||
&ctx,
|
||||
&state,
|
||||
connection_guard,
|
||||
namespace_restore_map,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
process_response(
|
||||
response,
|
||||
&ctx,
|
||||
@@ -849,6 +901,30 @@ pub async fn handle_responses(
|
||||
pub async fn handle_responses_compact(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_responses_compact_for_app(state, request, AppType::Codex, "Codex", "codex").await
|
||||
}
|
||||
|
||||
pub async fn handle_grokbuild_responses_compact(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_responses_compact_for_app(
|
||||
state,
|
||||
request,
|
||||
AppType::GrokBuild,
|
||||
"Grok Build",
|
||||
"grokbuild",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_responses_compact_for_app(
|
||||
state: ProxyState,
|
||||
request: axum::extract::Request,
|
||||
app_type: AppType,
|
||||
tag: &'static str,
|
||||
app_type_str: &'static str,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
@@ -865,7 +941,7 @@ pub async fn handle_responses_compact(
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses/compact");
|
||||
|
||||
let is_stream = body
|
||||
@@ -873,11 +949,12 @@ pub async fn handle_responses_compact(
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let codex_tool_context = transform_codex_chat::build_codex_tool_context_from_request(&body);
|
||||
let namespace_restore_map = transform_codex_responses_namespace::namespace_restore_map(&body);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let mut result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
&app_type,
|
||||
method,
|
||||
&endpoint,
|
||||
body,
|
||||
@@ -926,6 +1003,19 @@ pub async fn handle_responses_compact(
|
||||
.await;
|
||||
}
|
||||
|
||||
if super::providers::provider_needs_responses_namespace_flatten(&ctx.provider)
|
||||
&& !namespace_restore_map.is_empty()
|
||||
{
|
||||
return handle_codex_responses_namespace_restore(
|
||||
response,
|
||||
&ctx,
|
||||
&state,
|
||||
connection_guard,
|
||||
namespace_restore_map,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
process_response(
|
||||
response,
|
||||
&ctx,
|
||||
@@ -936,6 +1026,154 @@ pub async fn handle_responses_compact(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Response handler for the native Responses passthrough to a strict gateway
|
||||
/// (xAI), restoring the flattened `function_call` names produced by the
|
||||
/// request-side namespace flatten. Success bodies only carry a light rename;
|
||||
/// error bodies and everything unrelated pass through unchanged. Usage is
|
||||
/// collected exactly as `process_response` would (same `CODEX_PARSER_CONFIG`).
|
||||
async fn handle_codex_responses_namespace_restore(
|
||||
response: super::hyper_client::ProxyResponse,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
connection_guard: Option<ActiveConnectionGuard>,
|
||||
restore_map: std::collections::HashMap<
|
||||
String,
|
||||
transform_codex_responses_namespace::NamespacedName,
|
||||
>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let status = response.status();
|
||||
|
||||
// Error bodies (and any non-SSE, non-success response) never contain
|
||||
// restorable function calls; hand them to the generic passthrough so error
|
||||
// shape and usage handling stay identical to the untransformed path.
|
||||
if !status.is_success() {
|
||||
return process_response(response, ctx, state, &CODEX_PARSER_CONFIG, connection_guard)
|
||||
.await;
|
||||
}
|
||||
|
||||
if response.is_sse() {
|
||||
let mut response_headers = response.headers().clone();
|
||||
strip_hop_by_hop_response_headers(&mut response_headers);
|
||||
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
for (key, value) in &response_headers {
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
|
||||
let restore_stream =
|
||||
transform_codex_responses_namespace::create_namespace_restore_sse_stream(
|
||||
response.bytes_stream(),
|
||||
restore_map,
|
||||
);
|
||||
let usage_collector =
|
||||
create_usage_collector(ctx, state, status.as_u16(), &CODEX_PARSER_CONFIG);
|
||||
let logged_stream = create_logged_passthrough_stream(
|
||||
restore_stream,
|
||||
ctx.tag,
|
||||
usage_collector,
|
||||
ctx.streaming_timeout_config(),
|
||||
connection_guard,
|
||||
);
|
||||
|
||||
let body = axum::body::Body::from_stream(logged_stream);
|
||||
return builder.body(body).map_err(|e| {
|
||||
log::error!("[{}] 构建 namespace 还原流式响应失败: {e}", ctx.tag);
|
||||
ProxyError::Internal(format!("Failed to build streaming response: {e}"))
|
||||
});
|
||||
}
|
||||
|
||||
// Non-streaming: restore the flattened function-call names in the full body,
|
||||
// then account usage from the (restore-neutral) Responses payload.
|
||||
let _connection_guard = connection_guard;
|
||||
let body_timeout =
|
||||
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
|
||||
std::time::Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
|
||||
} else {
|
||||
std::time::Duration::ZERO
|
||||
};
|
||||
let (mut response_headers, status, body_bytes) =
|
||||
read_decoded_body(response, ctx.tag, body_timeout).await?;
|
||||
strip_hop_by_hop_response_headers(&mut response_headers);
|
||||
|
||||
// Restore names when the body parses as JSON; otherwise pass the bytes
|
||||
// through untouched (a native Responses non-stream body is always JSON, so
|
||||
// this only guards against a malformed upstream).
|
||||
let restored_bytes = match serde_json::from_slice::<Value>(&body_bytes) {
|
||||
Ok(mut value) => {
|
||||
transform_codex_responses_namespace::restore_response_namespaces(
|
||||
&mut value,
|
||||
&restore_map,
|
||||
);
|
||||
if let Some(usage) =
|
||||
TokenUsage::from_codex_response_auto(&value).filter(TokenUsage::has_billable_tokens)
|
||||
{
|
||||
let model = value
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| ctx.outbound_model.clone())
|
||||
.unwrap_or_else(|| ctx.request_model.clone());
|
||||
let request_model = ctx.request_model.clone();
|
||||
let outbound_model = ctx
|
||||
.outbound_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| ctx.request_model.clone());
|
||||
let app_type_str = ctx.app_type_str;
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let session_id = ctx.session_id.clone();
|
||||
let latency_ms = ctx.latency_ms();
|
||||
async move {
|
||||
log_usage(
|
||||
&state,
|
||||
&provider_id,
|
||||
app_type_str,
|
||||
&model,
|
||||
&request_model,
|
||||
&outbound_model,
|
||||
usage,
|
||||
latency_ms,
|
||||
None,
|
||||
false,
|
||||
status.as_u16(),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
match serde_json::to_vec(&value) {
|
||||
Ok(bytes) => Bytes::from(bytes),
|
||||
Err(e) => {
|
||||
log::error!("[{}] 序列化 namespace 还原响应失败: {e}", ctx.tag);
|
||||
body_bytes
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => body_bytes,
|
||||
};
|
||||
|
||||
strip_entity_headers_for_rebuilt_body(&mut response_headers);
|
||||
response_headers.remove(axum::http::header::CONTENT_TYPE);
|
||||
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
for (key, value) in response_headers.iter() {
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
builder = builder.header(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
builder
|
||||
.body(axum::body::Body::from(restored_bytes))
|
||||
.map_err(|e| {
|
||||
log::error!("[{}] 构建 namespace 还原响应失败: {e}", ctx.tag);
|
||||
ProxyError::Internal(format!("Failed to build response: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_codex_chat_to_responses_transform(
|
||||
response: super::hyper_client::ProxyResponse,
|
||||
ctx: &RequestContext,
|
||||
@@ -1060,14 +1298,20 @@ async fn handle_codex_chat_to_responses_transform(
|
||||
// 上游对 stream:false 返回未标记 Content-Type 的 SSE 体时按 SSE 聚合。
|
||||
Err(_) if body_looks_like_sse(&body_str) => {
|
||||
log::warn!("[Codex] 上游对非流请求返回未标记的 SSE 体,按 Chat SSE 聚合兜底");
|
||||
// 聚合也失败时:保留全量 body 服务端日志,并给客户端错误附带现场诊断(C7)
|
||||
// 聚合也失败时:服务端日志只记录长度,并给客户端错误附带现场诊断(C7)
|
||||
chat_sse_to_response_value(&body_str).map_err(|e| {
|
||||
log::error!("[Codex] SSE 聚合兜底失败: {e}, body: {body_str}");
|
||||
log::error!(
|
||||
"[Codex] SSE 聚合兜底失败: {e}, body_bytes={}",
|
||||
body_bytes.len()
|
||||
);
|
||||
aggregate_fallback_error(e, &response_headers, &body_str)
|
||||
})?
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[Codex] 解析 Chat 上游响应失败: {e}, body: {body_str}");
|
||||
log::error!(
|
||||
"[Codex] 解析 Chat 上游响应失败: {e}, body_bytes={}",
|
||||
body_bytes.len()
|
||||
);
|
||||
return Err(upstream_body_parse_error(
|
||||
"Failed to parse upstream chat response",
|
||||
&e,
|
||||
@@ -1222,7 +1466,8 @@ async fn handle_codex_anthropic_to_responses_transform(
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[Codex] Failed to parse Anthropic upstream response: {e}, body: {body_str}"
|
||||
"[Codex] Failed to parse Anthropic upstream response: {e}, body_bytes={}",
|
||||
body_bytes.len()
|
||||
);
|
||||
return Err(upstream_body_parse_error(
|
||||
"Failed to parse upstream anthropic response",
|
||||
@@ -1446,7 +1691,10 @@ async fn handle_codex_chat_error_response(
|
||||
} else {
|
||||
lossy.into_owned()
|
||||
};
|
||||
log::warn!("[Codex] Chat 错误响应不是合法 JSON,按文本透传: {truncated}");
|
||||
log::warn!(
|
||||
"[Codex] Chat 错误响应不是合法 JSON,按文本透传: body_bytes={} (content omitted)",
|
||||
body_bytes.len()
|
||||
);
|
||||
Value::String(truncated)
|
||||
}
|
||||
};
|
||||
@@ -1866,9 +2114,8 @@ fn body_looks_like_sse(body: &str) -> bool {
|
||||
.any(|prefix| trimmed.starts_with(prefix))
|
||||
}
|
||||
|
||||
/// 构造带现场诊断的上游解析错误:附 content-type / content-encoding 与 body
|
||||
/// 前缀摘要,让客户端收到的报错自带根因判别("data:"=错标 SSE、"<"=HTML
|
||||
/// 拦截页、� 乱码=未解压二进制),不再依赖向用户索要服务端日志。
|
||||
/// 构造带现场诊断的上游解析错误:只附结构化分类与元数据,
|
||||
/// 避免响应正文经错误链间接进入持久化日志。
|
||||
fn upstream_body_parse_error(
|
||||
prefix: &str,
|
||||
err: &serde_json::Error,
|
||||
@@ -1881,8 +2128,8 @@ fn upstream_body_parse_error(
|
||||
))
|
||||
}
|
||||
|
||||
/// SSE 聚合兜底失败时,给聚合器内部错误附加同款现场诊断(content-type/
|
||||
/// content-encoding/body 摘要),使命中 #2234 嗅探臂的客户端也拿到根因线索,
|
||||
/// SSE 聚合兜底失败时,给聚合器内部错误附加同款现场诊断,
|
||||
/// 使命中 #2234 嗅探臂的客户端也拿到根因线索,
|
||||
/// 而非仅 "No chat completion choices in upstream SSE" 这类无 header/body 的裸消息。
|
||||
fn aggregate_fallback_error(
|
||||
err: ProxyError,
|
||||
@@ -1896,7 +2143,43 @@ fn aggregate_fallback_error(
|
||||
ProxyError::TransformError(format!("{base} {}", body_diagnostics_suffix(headers, body)))
|
||||
}
|
||||
|
||||
/// 现场诊断后缀:content-type、content-encoding 与 body 前 120 字符摘要。
|
||||
/// 将正文归入有限类别,保留 HTML/SSE/乱码等关键线索而不记录正文。
|
||||
fn classify_body_for_diagnostics(body: &str) -> &'static str {
|
||||
let trimmed = body.trim_start_matches('\u{feff}').trim_start();
|
||||
if trimmed.is_empty() {
|
||||
return "empty";
|
||||
}
|
||||
if body_looks_like_sse(trimmed) {
|
||||
return "sse";
|
||||
}
|
||||
|
||||
// 分类只检查前 4 KiB,避免为了诊断再次线性扫描异常返回的超大正文。
|
||||
let sample = trimmed.chars().take(4096).collect::<String>();
|
||||
let prefix = sample
|
||||
.chars()
|
||||
.take(256)
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase();
|
||||
if ["<!doctype html", "<html", "<head", "<body"]
|
||||
.iter()
|
||||
.any(|marker| prefix.starts_with(marker))
|
||||
{
|
||||
return "html";
|
||||
}
|
||||
if sample.contains('\u{fffd}')
|
||||
|| sample
|
||||
.chars()
|
||||
.any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
|
||||
{
|
||||
return "binary-or-encoded";
|
||||
}
|
||||
if prefix.starts_with('{') || prefix.starts_with('[') {
|
||||
return "json-like";
|
||||
}
|
||||
"text"
|
||||
}
|
||||
|
||||
/// 现场诊断后缀:content-type、content-encoding、body 长度与安全分类,不含正文。
|
||||
fn body_diagnostics_suffix(headers: &axum::http::HeaderMap, body: &str) -> String {
|
||||
let header_str = |name: &str| {
|
||||
headers
|
||||
@@ -1905,10 +2188,11 @@ fn body_diagnostics_suffix(headers: &axum::http::HeaderMap, body: &str) -> Strin
|
||||
.unwrap_or("<none>")
|
||||
};
|
||||
format!(
|
||||
"(content-type: {}; content-encoding: {}; body[..120]: '{}')",
|
||||
"(content-type: {}; content-encoding: {}; body-bytes: {}; body-kind: {}; content omitted)",
|
||||
header_str("content-type"),
|
||||
header_str("content-encoding"),
|
||||
body_snippet(body, 120),
|
||||
body.len(),
|
||||
classify_body_for_diagnostics(body),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1925,24 +2209,6 @@ fn error_event_message(error: &Value) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 取 body 前 `max_chars` 个字符的单行摘要:\r 丢弃、\n 折叠为字面 \n、
|
||||
/// 其余控制字符替换为 �,超长加省略号。
|
||||
fn body_snippet(body: &str, max_chars: usize) -> String {
|
||||
let mut snippet = String::new();
|
||||
for c in body.chars().take(max_chars) {
|
||||
match c {
|
||||
'\n' => snippet.push_str("\\n"),
|
||||
'\r' => {}
|
||||
c if c.is_control() => snippet.push('\u{FFFD}'),
|
||||
c => snippet.push(c),
|
||||
}
|
||||
}
|
||||
if body.chars().nth(max_chars).is_some() {
|
||||
snippet.push('…');
|
||||
}
|
||||
snippet
|
||||
}
|
||||
|
||||
/// 解析单个 SSE 块的 event 名与 data 负载(多行 data 按规范以 \n 连接)。
|
||||
/// 行首允许前导空白后再匹配字段名——与 body_looks_like_sse 的 trim 宽容度对齐,
|
||||
/// 否则缩进的 ` data:` 行被嗅探接受却在此静默丢失(C4)。返回 None 表示无 data 行。
|
||||
@@ -2364,7 +2630,8 @@ async fn log_usage(
|
||||
model
|
||||
};
|
||||
|
||||
let request_id = usage.dedup_request_id();
|
||||
let dedup_scope = (app_type != "claude").then_some((app_type, provider_id));
|
||||
let request_id = usage.dedup_request_id(dedup_scope);
|
||||
|
||||
if let Err(e) = logger.log_with_calculation(
|
||||
request_id,
|
||||
@@ -2389,9 +2656,9 @@ async fn log_usage(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
body_looks_like_sse, body_snippet, chat_sse_to_response_value, codex_proxy_error_json,
|
||||
responses_sse_to_response_value, should_use_claude_transform_streaming, transform,
|
||||
upstream_body_parse_error,
|
||||
body_looks_like_sse, chat_sse_to_response_value, classify_body_for_diagnostics,
|
||||
codex_proxy_error_json, responses_sse_to_response_value,
|
||||
should_use_claude_transform_streaming, transform, upstream_body_parse_error,
|
||||
};
|
||||
use crate::proxy::ProxyError;
|
||||
|
||||
@@ -2432,7 +2699,9 @@ mod tests {
|
||||
ProxyError::TransformError(msg) => {
|
||||
assert!(msg.contains("content-type: text/html"), "{msg}");
|
||||
assert!(msg.contains("content-encoding: gzip"), "{msg}");
|
||||
assert!(msg.contains("<html>\\nblocked</html>"), "{msg}");
|
||||
assert!(msg.contains("body-bytes: 21"), "{msg}");
|
||||
assert!(msg.contains("body-kind: html"), "{msg}");
|
||||
assert!(!msg.contains("blocked"), "{msg}");
|
||||
}
|
||||
other => panic!("expected TransformError, got {other:?}"),
|
||||
}
|
||||
@@ -2449,11 +2718,25 @@ mod tests {
|
||||
ProxyError::TransformError(msg) => {
|
||||
assert!(msg.contains("content-type: <none>"), "{msg}");
|
||||
assert!(msg.contains("content-encoding: <none>"), "{msg}");
|
||||
assert!(msg.contains("body-kind: sse"), "{msg}");
|
||||
}
|
||||
other => panic!("expected TransformError, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_diagnostics_classifies_without_exposing_content() {
|
||||
assert_eq!(classify_body_for_diagnostics(""), "empty");
|
||||
assert_eq!(classify_body_for_diagnostics(" <HTML>blocked"), "html");
|
||||
assert_eq!(classify_body_for_diagnostics("data: {}\n\n"), "sse");
|
||||
assert_eq!(classify_body_for_diagnostics("{\"ok\":true}"), "json-like");
|
||||
assert_eq!(
|
||||
classify_body_for_diagnostics("decoded\u{fffd}payload"),
|
||||
"binary-or-encoded"
|
||||
);
|
||||
assert_eq!(classify_body_for_diagnostics("Bad Gateway"), "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_sse_to_response_value_collects_reasoning_alias() {
|
||||
// OpenRouter/Kimi 用 reasoning(字符串),部分网关用对象形态
|
||||
@@ -2593,18 +2876,6 @@ data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant
|
||||
assert_eq!(response["choices"][0]["message"]["content"], "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_snippet_sanitizes_controls_and_truncates() {
|
||||
assert_eq!(
|
||||
body_snippet("<html>\r\nblocked\u{0}</html>", 120),
|
||||
"<html>\\nblocked\u{FFFD}</html>"
|
||||
);
|
||||
let long = "a".repeat(200);
|
||||
let snippet = body_snippet(&long, 120);
|
||||
assert_eq!(snippet.chars().count(), 121); // 120 个字符 + 省略号
|
||||
assert!(snippet.ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_sse_to_response_value_aggregates_text_finish_reason_and_usage() {
|
||||
let sse = "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":123,\"model\":\"gpt-5.4\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hel\"},\"finish_reason\":null}]}\n\n\
|
||||
|
||||
@@ -241,8 +241,13 @@ impl ProxyResponse {
|
||||
/// `proxy_url`: optional upstream HTTP proxy URL (e.g. `http://127.0.0.1:7890`).
|
||||
/// When set, the raw write path uses HTTP CONNECT tunneling through the proxy,
|
||||
/// so header-case preservation works even when an upstream proxy is configured.
|
||||
///
|
||||
/// `log_display` is a caller-supplied, already-sanitized string used only for
|
||||
/// logging; this layer never derives a log value from the raw `uri`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn send_request(
|
||||
uri: http::Uri,
|
||||
log_display: &str,
|
||||
method: http::Method,
|
||||
headers: http::HeaderMap,
|
||||
original_extensions: http::Extensions,
|
||||
@@ -256,13 +261,13 @@ pub async fn send_request(
|
||||
.as_ref()
|
||||
.map(|c| !c.cases.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
log::debug!(
|
||||
"[HyperClient] Sending request: uri={uri}, header_count={}, \
|
||||
"[HyperClient] Sending request: target={}, header_count={}, \
|
||||
has_host={}, has_original_cases={has_cases}, proxy={:?}",
|
||||
log_display,
|
||||
headers.len(),
|
||||
headers.contains_key(http::header::HOST),
|
||||
proxy_url,
|
||||
proxy_url.map(super::http_client::mask_url),
|
||||
);
|
||||
|
||||
if let Some(original_cases) = original_cases
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -128,6 +128,13 @@ pub enum AuthStrategy {
|
||||
///
|
||||
/// 使用动态获取的 OpenAI access_token(通过 Device Code 流程获取)
|
||||
CodexOAuth,
|
||||
|
||||
/// xAI OAuth(Grok API)
|
||||
///
|
||||
/// - Header: `Authorization: Bearer <access_token>`
|
||||
///
|
||||
/// access token 由 xAI Device Code 流程获取并由 forwarder 动态注入。
|
||||
XaiOAuth,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -170,6 +177,7 @@ mod tests {
|
||||
assert_eq!(AuthStrategy::Anthropic, AuthStrategy::Anthropic);
|
||||
assert_ne!(AuthStrategy::Anthropic, AuthStrategy::Bearer);
|
||||
assert_ne!(AuthStrategy::Bearer, AuthStrategy::Google);
|
||||
assert_ne!(AuthStrategy::CodexOAuth, AuthStrategy::XaiOAuth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -36,9 +36,14 @@ const CODEX_OAUTH_CLIENT_VERSION: &str = "0.144.1";
|
||||
/// 供 handler/forwarder 外部使用的公开函数。
|
||||
/// 优先级:meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic"
|
||||
pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
// 0) Codex OAuth 强制使用 openai_responses(不可被覆盖)
|
||||
// 0) Managed Responses OAuth providers force their wire protocol. This is
|
||||
// an invariant, not a preset default: editable metadata must not be able to
|
||||
// send an Anthropic Messages body to a Responses-only upstream.
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if meta.provider_type.as_deref() == Some("codex_oauth") {
|
||||
if matches!(
|
||||
meta.provider_type.as_deref(),
|
||||
Some("codex_oauth" | "xai_oauth")
|
||||
) {
|
||||
return "openai_responses";
|
||||
}
|
||||
}
|
||||
@@ -400,12 +405,28 @@ pub fn transform_claude_request_for_api_format(
|
||||
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false
|
||||
// + include: ["reasoning.encrypted_content"],由 transform 层统一处理。
|
||||
let codex_fast_mode = provider.codex_fast_mode_enabled();
|
||||
super::transform_responses::anthropic_to_responses(
|
||||
let mut result = super::transform_responses::anthropic_to_responses(
|
||||
body,
|
||||
cache_key,
|
||||
is_codex_oauth,
|
||||
codex_fast_mode,
|
||||
)
|
||||
)?;
|
||||
if provider.is_xai_oauth() {
|
||||
const REASONING_MARKER: &str = "reasoning.encrypted_content";
|
||||
let mut include = result
|
||||
.get("include")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if !include
|
||||
.iter()
|
||||
.any(|item| item.as_str() == Some(REASONING_MARKER))
|
||||
{
|
||||
include.push(json!(REASONING_MARKER));
|
||||
}
|
||||
result["include"] = json!(include);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
"openai_chat" => {
|
||||
let preserve_reasoning_content =
|
||||
@@ -451,6 +472,7 @@ impl ClaudeAdapter {
|
||||
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
|
||||
/// - GitHubCopilot: meta.provider_type 为 github_copilot 或 base_url 包含 githubcopilot.com
|
||||
/// - CodexOAuth: meta.provider_type 为 codex_oauth
|
||||
/// - XaiOAuth: meta.provider_type 为 xai_oauth
|
||||
/// - OpenRouter: base_url 包含 openrouter.ai
|
||||
/// - ClaudeAuth: auth_mode 为 bearer_only
|
||||
/// - Claude: 默认 Anthropic 官方
|
||||
@@ -470,6 +492,10 @@ impl ClaudeAdapter {
|
||||
return ProviderType::CodexOAuth;
|
||||
}
|
||||
|
||||
if self.is_xai_oauth(provider) {
|
||||
return ProviderType::XaiOAuth;
|
||||
}
|
||||
|
||||
// 检测 GitHub Copilot
|
||||
if self.is_github_copilot(provider) {
|
||||
return ProviderType::GitHubCopilot;
|
||||
@@ -498,6 +524,10 @@ impl ClaudeAdapter {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_xai_oauth(&self, provider: &Provider) -> bool {
|
||||
provider.is_xai_oauth()
|
||||
}
|
||||
|
||||
/// 检测是否为 GitHub Copilot 供应商
|
||||
fn is_github_copilot(&self, provider: &Provider) -> bool {
|
||||
// 方式1: 检查 meta.provider_type
|
||||
@@ -676,6 +706,12 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
return Ok(super::CHATGPT_CODEX_BASE_URL.to_string());
|
||||
}
|
||||
|
||||
// xAI OAuth: ignore editable provider base URLs and always use the xAI
|
||||
// API origin associated with the managed token.
|
||||
if self.is_xai_oauth(provider) {
|
||||
return Ok(super::XAI_API_BASE_URL.to_string());
|
||||
}
|
||||
|
||||
// 1. 从 env 中获取
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
||||
@@ -735,6 +771,13 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
));
|
||||
}
|
||||
|
||||
if provider_type == ProviderType::XaiOAuth {
|
||||
return Some(AuthInfo::new(
|
||||
"xai_oauth_placeholder".to_string(),
|
||||
AuthStrategy::XaiOAuth,
|
||||
));
|
||||
}
|
||||
|
||||
let key = self.extract_key(provider)?;
|
||||
|
||||
match provider_type {
|
||||
@@ -790,6 +833,17 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
return format!("{}/responses", super::CHATGPT_CODEX_BASE_URL);
|
||||
}
|
||||
|
||||
// Defense in depth for callers that bypass endpoint rewriting.
|
||||
if base_url == super::XAI_API_BASE_URL {
|
||||
let query = endpoint.split_once('?').map(|(_, query)| query);
|
||||
return match query {
|
||||
Some(query) if !query.is_empty() => {
|
||||
format!("{}/responses?{query}", super::XAI_API_BASE_URL)
|
||||
}
|
||||
_ => format!("{}/responses", super::XAI_API_BASE_URL),
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE:
|
||||
// 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages`
|
||||
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。
|
||||
@@ -858,6 +912,9 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
),
|
||||
]
|
||||
}
|
||||
AuthStrategy::XaiOAuth => {
|
||||
vec![(HeaderName::from_static("authorization"), hv(&bearer)?)]
|
||||
}
|
||||
AuthStrategy::GitHubCopilot => {
|
||||
// 生成请求追踪 ID
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
@@ -919,6 +976,10 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.is_xai_oauth(provider) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 根据 api_format 配置决定是否需要格式转换
|
||||
// - "anthropic" (默认): 直接透传,无需转换
|
||||
// - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换
|
||||
@@ -1376,6 +1437,64 @@ mod tests {
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xai_oauth_invariants_ignore_editable_format_and_base_url() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://attacker.example/anthropic",
|
||||
"ANTHROPIC_API_KEY": "user-edited"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
provider_type: Some("xai_oauth".to_string()),
|
||||
api_format: Some("anthropic".to_string()),
|
||||
is_full_url: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(get_claude_api_format(&provider), "openai_responses");
|
||||
assert_eq!(adapter.provider_type(&provider), ProviderType::XaiOAuth);
|
||||
assert_eq!(
|
||||
adapter.extract_base_url(&provider).unwrap(),
|
||||
super::super::XAI_API_BASE_URL
|
||||
);
|
||||
assert!(adapter.needs_transform(&provider));
|
||||
assert_eq!(
|
||||
adapter
|
||||
.extract_auth(&provider)
|
||||
.expect("managed auth placeholder")
|
||||
.strategy,
|
||||
AuthStrategy::XaiOAuth
|
||||
);
|
||||
assert_eq!(
|
||||
adapter.build_url(super::super::XAI_API_BASE_URL, "/v1/messages?beta=1"),
|
||||
"https://api.x.ai/v1/responses?beta=1"
|
||||
);
|
||||
|
||||
let transformed = transform_claude_request_for_api_format(
|
||||
json!({
|
||||
"model": "grok-4.5",
|
||||
"max_tokens": 2048,
|
||||
"thinking": { "type": "enabled", "budget_tokens": 20000 },
|
||||
"messages": [{ "role": "user", "content": "hello" }]
|
||||
}),
|
||||
&provider,
|
||||
"openai_responses",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(transformed["reasoning"]["effort"], json!("high"));
|
||||
assert_eq!(
|
||||
transformed["include"],
|
||||
json!(["reasoning.encrypted_content"])
|
||||
);
|
||||
assert!(transformed.get("store").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_openrouter() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
@@ -206,6 +206,19 @@ pub fn should_convert_codex_responses_to_anthropic(provider: &Provider, endpoint
|
||||
) && codex_provider_uses_anthropic(provider)
|
||||
}
|
||||
|
||||
/// Whether a native-Responses Codex upstream needs Codex `namespace`/plugin
|
||||
/// tool declarations flattened before forwarding.
|
||||
///
|
||||
/// Codex 0.142+ emits ChatGPT-backend-private `{"type":"namespace",…}` tool
|
||||
/// shapes that strict third-party Responses gateways reject with
|
||||
/// `422 unknown variant "namespace"`. Only providers whose upstream is such a
|
||||
/// strict native gateway need the flatten+restore pass; the Chat/Anthropic
|
||||
/// transform paths already unwrap namespaces on their own. Currently that is the
|
||||
/// managed xAI (Grok) OAuth provider — the first strict gateway cc-switch hit.
|
||||
pub fn provider_needs_responses_namespace_flatten(provider: &Provider) -> bool {
|
||||
provider.is_xai_oauth()
|
||||
}
|
||||
|
||||
/// The single built-in official Codex provider. Unlike managed Codex OAuth
|
||||
/// providers used by Claude, this route receives authentication from the
|
||||
/// calling Codex client (`requires_openai_auth = true`).
|
||||
@@ -228,6 +241,11 @@ pub fn resolve_codex_catalog_tool_profile(
|
||||
if is_codex_official_provider(provider) {
|
||||
return CodexCatalogToolProfile::NativeResponses;
|
||||
}
|
||||
// xAI OAuth pins the native Responses profile regardless of editable
|
||||
// api_format, mirroring the Claude-side managed-provider invariant.
|
||||
if provider.is_xai_oauth() {
|
||||
return CodexCatalogToolProfile::NativeResponses;
|
||||
}
|
||||
if codex_provider_uses_anthropic(provider) {
|
||||
return CodexCatalogToolProfile::Anthropic;
|
||||
}
|
||||
@@ -250,7 +268,11 @@ pub fn codex_provider_upstream_model(provider: &Provider) -> Option<String> {
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(extract_codex_model_from_toml)
|
||||
.and_then(|config| {
|
||||
crate::grok_config::extract_model_config(config)
|
||||
.map(|model| model.model)
|
||||
.or_else(|| extract_codex_model_from_toml(config))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -621,6 +643,9 @@ impl CodexAdapter {
|
||||
}
|
||||
|
||||
if let Some(config_str) = config.as_str() {
|
||||
if let Some((_, key)) = crate::grok_config::extract_credentials(config_str) {
|
||||
return Some(key);
|
||||
}
|
||||
if let Some(key) =
|
||||
crate::codex_config::extract_codex_experimental_bearer_token(config_str)
|
||||
{
|
||||
@@ -649,6 +674,12 @@ impl ProviderAdapter for CodexAdapter {
|
||||
return Ok(super::CHATGPT_CODEX_BASE_URL.to_string());
|
||||
}
|
||||
|
||||
// xAI OAuth: ignore editable provider base URLs and always use the xAI
|
||||
// API origin associated with the managed token.
|
||||
if provider.is_xai_oauth() {
|
||||
return Ok(super::XAI_API_BASE_URL.to_string());
|
||||
}
|
||||
|
||||
// 1. 尝试直接获取 base_url 字段
|
||||
if let Some(url) = provider
|
||||
.settings_config
|
||||
@@ -675,6 +706,9 @@ impl ProviderAdapter for CodexAdapter {
|
||||
|
||||
// 尝试解析 TOML 字符串格式
|
||||
if let Some(config_str) = config.as_str() {
|
||||
if let Some(url) = crate::grok_config::extract_base_url(config_str) {
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
if let Some(start) = config_str.find("base_url = \"") {
|
||||
let rest = &config_str[start + 12..];
|
||||
if let Some(end) = rest.find('"') {
|
||||
@@ -696,6 +730,15 @@ impl ProviderAdapter for CodexAdapter {
|
||||
}
|
||||
|
||||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||
// xAI OAuth (Grok subscription): placeholder credentials only; the real
|
||||
// access_token is resolved per-request by the forwarder via XaiOAuthManager.
|
||||
if provider.is_xai_oauth() {
|
||||
return Some(AuthInfo::new(
|
||||
"xai_oauth_placeholder".to_string(),
|
||||
AuthStrategy::XaiOAuth,
|
||||
));
|
||||
}
|
||||
|
||||
// Anthropic upstream: the auth field is chosen by the user in the UI (meta.apiKeyField).
|
||||
// ANTHROPIC_API_KEY → x-api-key (AuthStrategy::Anthropic)
|
||||
// ANTHROPIC_AUTH_TOKEN → Authorization: Bearer (default, AuthStrategy::Bearer)
|
||||
@@ -796,6 +839,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_build_toml_exposes_upstream_credentials_and_model() {
|
||||
let adapter = CodexAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"config": r#"
|
||||
[models]
|
||||
default = "grok-4.5"
|
||||
|
||||
[model."grok-4.5"]
|
||||
model = "upstream-grok-model"
|
||||
base_url = "https://relay.example.com/v1/"
|
||||
name = "Example Relay"
|
||||
api_key = "grok-secret"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
"#
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
adapter.extract_base_url(&provider).unwrap(),
|
||||
"https://relay.example.com/v1"
|
||||
);
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "grok-secret");
|
||||
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||||
assert_eq!(
|
||||
codex_provider_upstream_model(&provider).as_deref(),
|
||||
Some("upstream-grok-model")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_provider_uses_fixed_chatgpt_backend_without_stored_key() {
|
||||
let mut provider = create_provider(json!({ "auth": {}, "config": "" }));
|
||||
@@ -1468,4 +1542,70 @@ wire_api = "chat"
|
||||
assert_eq!(config.supports_effort, Some(false));
|
||||
assert_eq!(config.output_format.as_deref(), Some("reasoning_content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xai_oauth_invariants_ignore_editable_base_url_and_auth() {
|
||||
let adapter = CodexAdapter::new();
|
||||
let mut provider = create_provider(json!({
|
||||
"auth": { "OPENAI_API_KEY": "user-edited" },
|
||||
"config": r#"
|
||||
model = "grok-4.5"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "xai"
|
||||
base_url = "https://attacker.example/v1"
|
||||
wire_api = "responses"
|
||||
"#
|
||||
}));
|
||||
provider.meta = Some(crate::provider::ProviderMeta {
|
||||
provider_type: Some("xai_oauth".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// 可编辑字段(base_url / auth key)不得影响托管路由:
|
||||
// 端点硬定向 api.x.ai,凭据是占位符(真 token 由 forwarder 注入)。
|
||||
assert_eq!(
|
||||
adapter.extract_base_url(&provider).unwrap(),
|
||||
super::super::XAI_API_BASE_URL
|
||||
);
|
||||
let auth = adapter
|
||||
.extract_auth(&provider)
|
||||
.expect("managed auth placeholder");
|
||||
assert_eq!(auth.api_key, "xai_oauth_placeholder");
|
||||
assert_eq!(auth.strategy, AuthStrategy::XaiOAuth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xai_oauth_pins_native_responses_catalog_profile() {
|
||||
let mut provider = create_provider(json!({ "auth": {}, "config": "" }));
|
||||
provider.meta = Some(crate::provider::ProviderMeta {
|
||||
provider_type: Some("xai_oauth".to_string()),
|
||||
// 即使 api_format 被改成 anthropic,catalog 画像也必须钉死原生 Responses
|
||||
api_format: Some("anthropic".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
resolve_codex_catalog_tool_profile(&provider),
|
||||
crate::codex_config::CodexCatalogToolProfile::NativeResponses
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_flatten_gate_only_fires_for_xai_oauth() {
|
||||
// xAI OAuth: strict native gateway → needs namespace flattening.
|
||||
let mut xai = create_provider(json!({ "auth": {}, "config": "" }));
|
||||
xai.meta = Some(crate::provider::ProviderMeta {
|
||||
provider_type: Some("xai_oauth".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(provider_needs_responses_namespace_flatten(&xai));
|
||||
|
||||
// A plain third-party API-key Codex provider must not be flattened.
|
||||
let plain = create_provider(json!({
|
||||
"auth": { "OPENAI_API_KEY": "sk-x" },
|
||||
"config": "base_url = \"https://api.x.ai/v1\"\nwire_api = \"responses\""
|
||||
}));
|
||||
assert!(!provider_needs_responses_namespace_flatten(&plain));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,14 +34,18 @@ pub mod streaming_responses;
|
||||
pub mod transform;
|
||||
pub mod transform_codex_anthropic;
|
||||
pub mod transform_codex_chat;
|
||||
pub mod transform_codex_responses_namespace;
|
||||
pub mod transform_codex_responses_xai_sanitize;
|
||||
pub mod transform_gemini;
|
||||
pub mod transform_responses;
|
||||
pub mod xai_oauth_auth;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::provider::Provider;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const CHATGPT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
|
||||
pub const XAI_API_BASE_URL: &str = "https://api.x.ai/v1";
|
||||
|
||||
// 公开导出
|
||||
pub use adapter::ProviderAdapter;
|
||||
@@ -55,8 +59,9 @@ pub use codex::CodexAdapter;
|
||||
pub use codex::{
|
||||
apply_codex_chat_upstream_model, apply_codex_upstream_model, codex_provider_upstream_model,
|
||||
inject_codex_chat_prompt_cache_key, is_codex_official_provider,
|
||||
resolve_codex_catalog_tool_profile, resolve_codex_chat_reasoning_config,
|
||||
should_convert_codex_responses_to_anthropic, should_convert_codex_responses_to_chat,
|
||||
provider_needs_responses_namespace_flatten, resolve_codex_catalog_tool_profile,
|
||||
resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_anthropic,
|
||||
should_convert_codex_responses_to_chat,
|
||||
};
|
||||
pub use gemini::GeminiAdapter;
|
||||
|
||||
@@ -83,6 +88,8 @@ pub enum ProviderType {
|
||||
GitHubCopilot,
|
||||
/// OpenAI Codex (ChatGPT Plus/Pro OAuth,需要 Anthropic ↔ Responses API 转换)
|
||||
CodexOAuth,
|
||||
/// xAI Grok OAuth(需要 Anthropic ↔ Responses API 转换)
|
||||
XaiOAuth,
|
||||
}
|
||||
|
||||
impl ProviderType {
|
||||
@@ -96,6 +103,7 @@ impl ProviderType {
|
||||
match self {
|
||||
ProviderType::GitHubCopilot => true,
|
||||
ProviderType::CodexOAuth => true,
|
||||
ProviderType::XaiOAuth => true,
|
||||
ProviderType::OpenRouter => false,
|
||||
_ => false,
|
||||
}
|
||||
@@ -113,6 +121,7 @@ impl ProviderType {
|
||||
ProviderType::OpenRouter => "https://openrouter.ai/api",
|
||||
ProviderType::GitHubCopilot => "https://api.githubcopilot.com",
|
||||
ProviderType::CodexOAuth => CHATGPT_CODEX_BASE_URL,
|
||||
ProviderType::XaiOAuth => XAI_API_BASE_URL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +148,9 @@ impl ProviderType {
|
||||
if meta.provider_type.as_deref() == Some("codex_oauth") {
|
||||
return ProviderType::CodexOAuth;
|
||||
}
|
||||
if meta.provider_type.as_deref() == Some("xai_oauth") {
|
||||
return ProviderType::XaiOAuth;
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 base_url 是否为 GitHub Copilot
|
||||
@@ -192,10 +204,8 @@ impl ProviderType {
|
||||
}
|
||||
ProviderType::Gemini
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy, fallback to Codex-like type
|
||||
ProviderType::Codex
|
||||
}
|
||||
AppType::GrokBuild => ProviderType::Codex,
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => ProviderType::Codex,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +220,7 @@ impl ProviderType {
|
||||
ProviderType::OpenRouter => "openrouter",
|
||||
ProviderType::GitHubCopilot => "github_copilot",
|
||||
ProviderType::CodexOAuth => "codex_oauth",
|
||||
ProviderType::XaiOAuth => "xai_oauth",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,6 +246,7 @@ impl std::str::FromStr for ProviderType {
|
||||
Ok(ProviderType::GitHubCopilot)
|
||||
}
|
||||
"codex_oauth" | "codex-oauth" | "codexoauth" => Ok(ProviderType::CodexOAuth),
|
||||
"xai_oauth" | "xai-oauth" | "xaioauth" => Ok(ProviderType::XaiOAuth),
|
||||
_ => Err(format!("Invalid provider type: {s}")),
|
||||
}
|
||||
}
|
||||
@@ -246,10 +258,8 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
AppType::Claude | AppType::ClaudeDesktop => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy, fallback to Codex adapter
|
||||
Box::new(CodexAdapter::new())
|
||||
}
|
||||
AppType::GrokBuild => Box::new(CodexAdapter::new()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Box::new(CodexAdapter::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +271,8 @@ pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn Pr
|
||||
| ProviderType::ClaudeAuth
|
||||
| ProviderType::OpenRouter
|
||||
| ProviderType::GitHubCopilot
|
||||
| ProviderType::CodexOAuth => Box::new(ClaudeAdapter::new()),
|
||||
| ProviderType::CodexOAuth
|
||||
| ProviderType::XaiOAuth => Box::new(ClaudeAdapter::new()),
|
||||
ProviderType::Codex => Box::new(CodexAdapter::new()),
|
||||
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
|
||||
}
|
||||
@@ -378,6 +389,10 @@ mod tests {
|
||||
"githubcopilot".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::GitHubCopilot
|
||||
);
|
||||
assert_eq!(
|
||||
"xai_oauth".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::XaiOAuth
|
||||
);
|
||||
assert!("invalid".parse::<ProviderType>().is_err());
|
||||
}
|
||||
|
||||
@@ -390,6 +405,7 @@ mod tests {
|
||||
assert_eq!(ProviderType::GeminiCli.as_str(), "gemini_cli");
|
||||
assert_eq!(ProviderType::OpenRouter.as_str(), "openrouter");
|
||||
assert_eq!(ProviderType::GitHubCopilot.as_str(), "github_copilot");
|
||||
assert_eq!(ProviderType::XaiOAuth.as_str(), "xai_oauth");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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:";
|
||||
@@ -54,18 +61,23 @@ pub fn is_openai_o_series(model: &str) -> bool {
|
||||
&& model.as_bytes().get(1).is_some_and(|b| b.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Detect OpenAI models that support reasoning_effort.
|
||||
/// Detect Responses-compatible models that support reasoning effort.
|
||||
///
|
||||
/// Supported families:
|
||||
/// - o-series: o1, o3, o4-mini, etc.
|
||||
/// - GPT-5+: gpt-5, gpt-5.1, gpt-5.4, gpt-5-codex, etc.
|
||||
/// - xAI Grok Build models. `grok-4.5` is the current documented Grok Build
|
||||
/// model; retain the previous `grok-build-*` family for saved providers.
|
||||
pub fn supports_reasoning_effort(model: &str) -> bool {
|
||||
is_openai_o_series(model)
|
||||
|| model
|
||||
.to_lowercase()
|
||||
let normalized = model.to_lowercase();
|
||||
is_openai_o_series(&normalized)
|
||||
|| normalized
|
||||
.strip_prefix("gpt-")
|
||||
.and_then(|rest| rest.chars().next())
|
||||
.is_some_and(|c| c.is_ascii_digit() && c >= '5')
|
||||
|| normalized == "grok-4.5"
|
||||
|| normalized.starts_with("grok-4.5-")
|
||||
|| normalized.starts_with("grok-build-")
|
||||
}
|
||||
|
||||
/// Resolve the appropriate OpenAI `reasoning_effort` from an Anthropic request body.
|
||||
@@ -370,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();
|
||||
@@ -384,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" => {
|
||||
@@ -416,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",
|
||||
@@ -447,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});
|
||||
@@ -1128,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!({
|
||||
@@ -1176,7 +1358,7 @@ mod tests {
|
||||
Some("chatcmpl-claude-compatible")
|
||||
);
|
||||
assert_eq!(
|
||||
usage.dedup_request_id(),
|
||||
usage.dedup_request_id(None),
|
||||
"session:chatcmpl-claude-compatible"
|
||||
);
|
||||
}
|
||||
@@ -1566,6 +1748,8 @@ mod tests {
|
||||
assert!(supports_reasoning_effort("gpt-5"));
|
||||
assert!(supports_reasoning_effort("gpt-5.4"));
|
||||
assert!(supports_reasoning_effort("gpt-5-codex"));
|
||||
assert!(supports_reasoning_effort("grok-4.5"));
|
||||
assert!(supports_reasoning_effort("grok-build-0.1"));
|
||||
assert!(!supports_reasoning_effort("gpt-4o"));
|
||||
assert!(!supports_reasoning_effort("claude-sonnet-4-6"));
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
@@ -440,14 +443,17 @@ fn chat_tool_to_anthropic_tool(chat_tool: &Value) -> Option<Value> {
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let mut tool = json!({
|
||||
"name": name,
|
||||
"input_schema": function
|
||||
.get("parameters")
|
||||
.cloned()
|
||||
.filter(|value| value.as_object().is_some_and(|object| !object.is_empty()))
|
||||
.unwrap_or_else(|| json!({ "type": "object", "properties": {} }))
|
||||
});
|
||||
let mut input_schema = function
|
||||
.get("parameters")
|
||||
.cloned()
|
||||
.filter(|value| value.as_object().is_some_and(|object| !object.is_empty()))
|
||||
.unwrap_or_else(|| json!({ "type": "object", "properties": {} }));
|
||||
if let Some(schema) = input_schema.as_object_mut() {
|
||||
if schema.get("type").and_then(Value::as_str) != Some("object") {
|
||||
schema.insert("type".to_string(), json!("object"));
|
||||
}
|
||||
}
|
||||
let mut tool = json!({ "name": name, "input_schema": input_schema });
|
||||
if let Some(description) = function.get("description").and_then(|value| value.as_str()) {
|
||||
tool["description"] = json!(description);
|
||||
}
|
||||
@@ -720,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;
|
||||
@@ -758,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 {
|
||||
@@ -769,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,
|
||||
@@ -780,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).
|
||||
@@ -1068,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!({
|
||||
@@ -1080,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 }
|
||||
@@ -1545,6 +1671,44 @@ mod tests {
|
||||
assert_eq!(tools[1]["name"], "apply_patch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_tool_search_output_schema_defaults_root_type_to_object() {
|
||||
let input = json!({
|
||||
"model": "claude",
|
||||
"max_output_tokens": 100,
|
||||
"input": [
|
||||
{ "role": "user", "content": "hi" },
|
||||
{
|
||||
"type": "tool_search_output",
|
||||
"call_id": "tool_demo123",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "demo_union_tool",
|
||||
"parameters": {
|
||||
"oneOf": [
|
||||
{ "type": "object", "properties": { "mode": { "type": "string" } } },
|
||||
{ "type": "object", "properties": { "name": { "type": "string" } } }
|
||||
]
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let result = responses_request_to_anthropic(input, 4096).unwrap();
|
||||
let input_schema = &result["tools"][0]["input_schema"];
|
||||
|
||||
assert_eq!(input_schema["type"], "object");
|
||||
assert_eq!(
|
||||
input_schema["oneOf"][0]["properties"]["mode"]["type"],
|
||||
"string"
|
||||
);
|
||||
assert_eq!(
|
||||
input_schema["oneOf"][1]["properties"]["name"]["type"],
|
||||
"string"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_tool_choice_mapping() {
|
||||
// A function tool must be present, else tool_choice is (correctly) dropped.
|
||||
@@ -2514,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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,623 @@
|
||||
//! Codex `namespace` tool flattening for native Responses upstreams.
|
||||
//!
|
||||
//! Codex 0.142+ declares its plugin/MCP tools with a private Responses
|
||||
//! extension shape — `{"type":"namespace","name":"mcp__x__","tools":[…]}` plus
|
||||
//! `tool_search` — that the OpenAI ChatGPT backend understands but strict
|
||||
//! third-party gateways (e.g. xAI's `api.x.ai/v1/responses`) reject with
|
||||
//! `422 unknown variant "namespace"`. cc-switch's Chat/Anthropic transforms
|
||||
//! already unwrap these, but the *native* Responses passthrough sends them
|
||||
//! verbatim.
|
||||
//!
|
||||
//! This module implements the request-side flatten + response-side restore for
|
||||
//! that native path, mirroring the proven design of sub2api
|
||||
//! (`pkg/apicompat/responses_namespace.go`):
|
||||
//!
|
||||
//! - **Request**: lift every `namespace` child into a top-level `function` tool
|
||||
//! whose name is the deterministic flat name `<namespace>__<child>` (with the
|
||||
//! same sha256 truncation used by the Chat path, so both layers agree), then
|
||||
//! rewrite namespace-qualified `function_call` items in the replayed `input`
|
||||
//! history to the flat name and drop a `namespace`-typed `tool_choice`.
|
||||
//! - **Response**: restore the flat `function_call` names back to
|
||||
//! `{name, namespace}` so the Codex client can match the call against its own
|
||||
//! namespaced tool registry (streaming and non-streaming).
|
||||
//!
|
||||
//! Flatten and restore both derive their name map from the *same* request tools
|
||||
//! via [`flatten_namespace_tool_name`], so the forwarder (flatten) and the
|
||||
//! response handler (restore) stay consistent without threading state between
|
||||
//! them.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::transform_codex_chat::flatten_namespace_tool_name;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use crate::proxy::sse::{append_utf8_safe, strip_sse_field, take_sse_block};
|
||||
|
||||
/// Reverse map entry: a flattened tool name resolves back to its original
|
||||
/// namespace and bare child name.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct NamespacedName {
|
||||
pub namespace: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Build the flat-name → `{namespace, name}` restore map from a Codex Responses
|
||||
/// request body. Used by the response handler to invert the request-side
|
||||
/// flatten; derives names exactly as [`flatten_request_namespaces`] does.
|
||||
pub(crate) fn namespace_restore_map(request_body: &Value) -> HashMap<String, NamespacedName> {
|
||||
let mut map = HashMap::new();
|
||||
let Some(tools) = request_body.get("tools").and_then(Value::as_array) else {
|
||||
return map;
|
||||
};
|
||||
for tool in tools {
|
||||
if tool.get("type").and_then(Value::as_str) != Some("namespace") {
|
||||
continue;
|
||||
}
|
||||
let Some(namespace) = tool.get("name").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let namespace = namespace.trim();
|
||||
if namespace.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for child in namespace_children(tool) {
|
||||
if child.get("type").and_then(Value::as_str) != Some("function") {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = child.get("name").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let flat = flatten_namespace_tool_name(namespace, name);
|
||||
map.entry(flat).or_insert_with(|| NamespacedName {
|
||||
namespace: namespace.to_string(),
|
||||
name: name.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Flatten Codex `namespace` tool declarations in a native Responses request
|
||||
/// body into top-level `function` tools, rewrite namespace-qualified calls in
|
||||
/// the `input` history, and neutralize a `namespace` `tool_choice`.
|
||||
///
|
||||
/// Returns `Ok(true)` when the body was rewritten. Returns a `TransformError`
|
||||
/// when two distinct namespace children (or a child and a top-level tool)
|
||||
/// collapse to the same flat name — the upstream could not disambiguate them,
|
||||
/// so failing loudly beats silently dropping a tool (matches sub2api).
|
||||
pub(crate) fn flatten_request_namespaces(body: &mut Value) -> Result<bool, ProxyError> {
|
||||
let Some(tools) = body.get("tools").and_then(Value::as_array) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !tools
|
||||
.iter()
|
||||
.any(|tool| tool.get("type").and_then(Value::as_str) == Some("namespace"))
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Names already occupied by top-level function/custom tools; a namespace
|
||||
// child flattening onto one of these is an unrecoverable collision.
|
||||
let mut top_level = std::collections::HashSet::new();
|
||||
for tool in tools {
|
||||
let typ = tool.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
if typ == "function" || typ == "custom" {
|
||||
if let Some(name) = tool.get("name").and_then(Value::as_str) {
|
||||
let name = name.trim();
|
||||
if !name.is_empty() {
|
||||
top_level.insert(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate flat-name uniqueness before mutating anything.
|
||||
let mut owners: HashMap<String, NamespacedName> = HashMap::new();
|
||||
for tool in tools {
|
||||
if tool.get("type").and_then(Value::as_str) != Some("namespace") {
|
||||
continue;
|
||||
}
|
||||
let Some(namespace) = tool.get("name").and_then(Value::as_str).map(str::trim) else {
|
||||
continue;
|
||||
};
|
||||
if namespace.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for child in namespace_children(tool) {
|
||||
if child.get("type").and_then(Value::as_str) != Some("function") {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = child.get("name").and_then(Value::as_str).map(str::trim) else {
|
||||
continue;
|
||||
};
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let flat = flatten_namespace_tool_name(namespace, name);
|
||||
if top_level.contains(&flat) {
|
||||
return Err(ProxyError::TransformError(format!(
|
||||
"namespace tool {namespace:?}/{name:?} flattens to {flat:?} which \
|
||||
collides with a top-level tool of the same name; rename one of them"
|
||||
)));
|
||||
}
|
||||
let entry = NamespacedName {
|
||||
namespace: namespace.to_string(),
|
||||
name: name.to_string(),
|
||||
};
|
||||
if let Some(prev) = owners.get(&flat) {
|
||||
if *prev != entry {
|
||||
return Err(ProxyError::TransformError(format!(
|
||||
"namespace tools {:?}/{:?} and {namespace:?}/{name:?} both flatten to \
|
||||
{flat:?}; rename one of them",
|
||||
prev.namespace, prev.name
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
owners.insert(flat, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the tools array with namespace children lifted to top level.
|
||||
let tools = body
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut flattened: Vec<Value> = Vec::with_capacity(tools.len());
|
||||
let mut seen_flat = std::collections::HashSet::new();
|
||||
for tool in tools {
|
||||
if tool.get("type").and_then(Value::as_str) != Some("namespace") {
|
||||
flattened.push(tool);
|
||||
continue;
|
||||
}
|
||||
let Some(namespace) = tool.get("name").and_then(Value::as_str).map(str::trim) else {
|
||||
continue;
|
||||
};
|
||||
for child in namespace_children(&tool) {
|
||||
if child.get("type").and_then(Value::as_str) != Some("function") {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = child.get("name").and_then(Value::as_str).map(str::trim) else {
|
||||
continue;
|
||||
};
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let flat = flatten_namespace_tool_name(namespace, name);
|
||||
if !seen_flat.insert(flat.clone()) {
|
||||
continue;
|
||||
}
|
||||
let mut lifted = child.clone();
|
||||
if let Some(obj) = lifted.as_object_mut() {
|
||||
obj.insert("name".to_string(), json!(flat));
|
||||
}
|
||||
flattened.push(lifted);
|
||||
}
|
||||
}
|
||||
body["tools"] = json!(flattened);
|
||||
|
||||
// Rewrite namespace-qualified function_call items in the replayed history.
|
||||
if let Some(input) = body.get_mut("input") {
|
||||
rewrite_namespace_qualified_calls(input, &owners);
|
||||
}
|
||||
|
||||
// A namespace-typed tool_choice cannot survive flattening: degrade to auto.
|
||||
if let Some(choice) = body.get_mut("tool_choice") {
|
||||
if choice.get("type").and_then(Value::as_str) == Some("namespace") {
|
||||
*choice = json!("auto");
|
||||
} else {
|
||||
rewrite_namespace_qualified_call(choice, &owners);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Restore flattened `function_call` names in a full (non-streaming) Responses
|
||||
/// payload back to their `{name, namespace}` identity. Returns whether anything
|
||||
/// changed.
|
||||
pub(crate) fn restore_response_namespaces(
|
||||
value: &mut Value,
|
||||
map: &HashMap<String, NamespacedName>,
|
||||
) -> bool {
|
||||
if map.is_empty() {
|
||||
return false;
|
||||
}
|
||||
restore_value(value, map)
|
||||
}
|
||||
|
||||
/// Restore a single parsed SSE event (e.g. `response.output_item.added` /
|
||||
/// `.done` carrying a `function_call`). Returns whether anything changed.
|
||||
pub(crate) fn restore_sse_event_namespaces(
|
||||
event: &mut Value,
|
||||
map: &HashMap<String, NamespacedName>,
|
||||
) -> bool {
|
||||
if map.is_empty() {
|
||||
return false;
|
||||
}
|
||||
restore_value(event, map)
|
||||
}
|
||||
|
||||
fn namespace_children(tool: &Value) -> Vec<Value> {
|
||||
tool.get("tools")
|
||||
.or_else(|| tool.get("children"))
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn rewrite_namespace_qualified_calls(value: &mut Value, owners: &HashMap<String, NamespacedName>) {
|
||||
match value {
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
rewrite_namespace_qualified_calls(item, owners);
|
||||
}
|
||||
}
|
||||
Value::Object(obj) => {
|
||||
if obj.get("type").and_then(Value::as_str) == Some("function_call") {
|
||||
rewrite_namespace_qualified_call(value, owners);
|
||||
return;
|
||||
}
|
||||
for child in obj.values_mut() {
|
||||
rewrite_namespace_qualified_calls(child, owners);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_namespace_qualified_call(
|
||||
item: &mut Value,
|
||||
owners: &HashMap<String, NamespacedName>,
|
||||
) -> bool {
|
||||
let Some(obj) = item.as_object_mut() else {
|
||||
return false;
|
||||
};
|
||||
let namespace = obj
|
||||
.get("namespace")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let name = obj
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if namespace.is_empty() || name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let flat = flatten_namespace_tool_name(&namespace, &name);
|
||||
match owners.get(&flat) {
|
||||
Some(entry) if entry.namespace == namespace && entry.name == name => {
|
||||
obj.insert("name".to_string(), json!(flat));
|
||||
obj.remove("namespace");
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_value(value: &mut Value, map: &HashMap<String, NamespacedName>) -> bool {
|
||||
let mut changed = false;
|
||||
match value {
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
changed |= restore_value(item, map);
|
||||
}
|
||||
}
|
||||
Value::Object(obj) => {
|
||||
if obj.get("type").and_then(Value::as_str) == Some("function_call") {
|
||||
if let Some(flat) = obj.get("name").and_then(Value::as_str) {
|
||||
if let Some(entry) = map.get(flat) {
|
||||
obj.insert("name".to_string(), json!(entry.name));
|
||||
obj.insert("namespace".to_string(), json!(entry.namespace));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for child in obj.values_mut() {
|
||||
changed |= restore_value(child, map);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Wrap a native Responses SSE byte stream, restoring flattened `function_call`
|
||||
/// names in each event back to their namespace identity. Events that carry no
|
||||
/// affected function call pass through with their inner content preserved
|
||||
/// verbatim (only the block delimiter is normalized to `\n\n`).
|
||||
pub(crate) fn create_namespace_restore_sse_stream<E>(
|
||||
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
|
||||
map: HashMap<String, NamespacedName>,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send
|
||||
where
|
||||
E: std::error::Error + Send + 'static,
|
||||
{
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
while let Some(block) = take_sse_block(&mut buffer) {
|
||||
if block.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
yield Ok(restore_sse_block(&block, &map));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
yield Err(std::io::Error::other(e.to_string()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any trailing partial block (streams normally end on a delimiter,
|
||||
// but be defensive so no bytes are dropped).
|
||||
if !utf8_remainder.is_empty() {
|
||||
buffer.push_str(&String::from_utf8_lossy(&utf8_remainder));
|
||||
}
|
||||
let tail = std::mem::take(&mut buffer);
|
||||
if !tail.trim().is_empty() {
|
||||
yield Ok(restore_sse_block(&tail, &map));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore one SSE block. When the block's `data:` JSON carries an affected
|
||||
/// function call, re-serialize just that line; otherwise the original block text
|
||||
/// is preserved and only the `\n\n` delimiter re-appended.
|
||||
fn restore_sse_block(block: &str, map: &HashMap<String, NamespacedName>) -> Bytes {
|
||||
let mut event_name: Option<&str> = None;
|
||||
let mut data_parts: Vec<&str> = Vec::new();
|
||||
for line in block.lines() {
|
||||
if let Some(event) = strip_sse_field(line, "event") {
|
||||
event_name = Some(event.trim());
|
||||
}
|
||||
if let Some(data) = strip_sse_field(line, "data") {
|
||||
data_parts.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
if data_parts.is_empty() {
|
||||
return Bytes::from(format!("{block}\n\n"));
|
||||
}
|
||||
|
||||
let data = data_parts.join("\n");
|
||||
if data.trim() == "[DONE]" {
|
||||
return Bytes::from(format!("{block}\n\n"));
|
||||
}
|
||||
|
||||
let mut event: Value = match serde_json::from_str(&data) {
|
||||
Ok(value) => value,
|
||||
// Non-JSON data (shouldn't happen on the Responses wire): pass through.
|
||||
Err(_) => return Bytes::from(format!("{block}\n\n")),
|
||||
};
|
||||
|
||||
if !restore_sse_event_namespaces(&mut event, map) {
|
||||
return Bytes::from(format!("{block}\n\n"));
|
||||
}
|
||||
|
||||
let restored = serde_json::to_string(&event).unwrap_or(data);
|
||||
let mut out = String::new();
|
||||
if let Some(name) = event_name {
|
||||
out.push_str("event: ");
|
||||
out.push_str(name);
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str("data: ");
|
||||
out.push_str(&restored);
|
||||
out.push_str("\n\n");
|
||||
Bytes::from(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::stream;
|
||||
use serde_json::json;
|
||||
|
||||
fn namespace_request() -> Value {
|
||||
json!({
|
||||
"model": "grok-4.5",
|
||||
"tools": [
|
||||
{ "type": "function", "name": "plain_tool", "parameters": {} },
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "mcp__files__",
|
||||
"tools": [
|
||||
{ "type": "function", "name": "read", "description": "read a file", "parameters": {} },
|
||||
{ "type": "function", "name": "write", "parameters": {} }
|
||||
]
|
||||
}
|
||||
],
|
||||
"input": [
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": "read",
|
||||
"namespace": "mcp__files__",
|
||||
"call_id": "c1",
|
||||
"arguments": "{}"
|
||||
}
|
||||
],
|
||||
"tool_choice": { "type": "namespace", "name": "mcp__files__" }
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_lifts_namespace_children_to_top_level_functions() {
|
||||
let mut body = namespace_request();
|
||||
assert!(flatten_request_namespaces(&mut body).unwrap());
|
||||
|
||||
let tools = body["tools"].as_array().unwrap();
|
||||
// plain + read + write, all top-level function tools; no namespace left.
|
||||
assert_eq!(tools.len(), 3);
|
||||
assert!(tools.iter().all(|t| t["type"] == "function"));
|
||||
let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
|
||||
assert!(names.contains(&"plain_tool"));
|
||||
assert!(names.contains(&"mcp__files____read"));
|
||||
assert!(names.contains(&"mcp__files____write"));
|
||||
// Child metadata is preserved on the lifted tool.
|
||||
let read = tools
|
||||
.iter()
|
||||
.find(|t| t["name"] == "mcp__files____read")
|
||||
.unwrap();
|
||||
assert_eq!(read["description"], "read a file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_rewrites_input_history_calls_and_tool_choice() {
|
||||
let mut body = namespace_request();
|
||||
flatten_request_namespaces(&mut body).unwrap();
|
||||
|
||||
let call = &body["input"][0];
|
||||
assert_eq!(call["name"], "mcp__files____read");
|
||||
assert!(call.get("namespace").is_none());
|
||||
assert_eq!(call["call_id"], "c1");
|
||||
// A namespace-typed tool_choice degrades to "auto".
|
||||
assert_eq!(body["tool_choice"], json!("auto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_is_noop_without_namespace_tools() {
|
||||
let mut body = json!({
|
||||
"tools": [ { "type": "function", "name": "plain", "parameters": {} } ]
|
||||
});
|
||||
assert!(!flatten_request_namespaces(&mut body).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_errors_on_flat_name_collision_with_top_level() {
|
||||
let mut body = json!({
|
||||
"tools": [
|
||||
{ "type": "function", "name": "mcp__files____read", "parameters": {} },
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "mcp__files__",
|
||||
"tools": [ { "type": "function", "name": "read", "parameters": {} } ]
|
||||
}
|
||||
]
|
||||
});
|
||||
assert!(flatten_request_namespaces(&mut body).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_map_inverts_flatten_naming() {
|
||||
let body = namespace_request();
|
||||
let map = namespace_restore_map(&body);
|
||||
let entry = map.get("mcp__files____read").unwrap();
|
||||
assert_eq!(entry.namespace, "mcp__files__");
|
||||
assert_eq!(entry.name, "read");
|
||||
// Plain top-level tools are not in the restore map.
|
||||
assert!(!map.contains_key("plain_tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_flatten_then_restore_recovers_namespace() {
|
||||
let request = namespace_request();
|
||||
let map = namespace_restore_map(&request);
|
||||
|
||||
// Upstream returns a function_call using the flattened name.
|
||||
let mut response = json!({
|
||||
"type": "response",
|
||||
"output": [
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": "mcp__files____read",
|
||||
"call_id": "c1",
|
||||
"arguments": "{}"
|
||||
}
|
||||
]
|
||||
});
|
||||
assert!(restore_response_namespaces(&mut response, &map));
|
||||
let call = &response["output"][0];
|
||||
assert_eq!(call["name"], "read");
|
||||
assert_eq!(call["namespace"], "mcp__files__");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_leaves_unmapped_calls_untouched() {
|
||||
let map = namespace_restore_map(&namespace_request());
|
||||
let mut response = json!({
|
||||
"output": [
|
||||
{ "type": "function_call", "name": "plain_tool", "call_id": "x" }
|
||||
]
|
||||
});
|
||||
assert!(!restore_response_namespaces(&mut response, &map));
|
||||
assert_eq!(response["output"][0]["name"], "plain_tool");
|
||||
assert!(response["output"][0].get("namespace").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_flat_names_stay_consistent_between_flatten_and_restore() {
|
||||
let long_child = "a".repeat(80);
|
||||
let body = json!({
|
||||
"tools": [{
|
||||
"type": "namespace",
|
||||
"name": "mcp__srv__",
|
||||
"tools": [ { "type": "function", "name": long_child, "parameters": {} } ]
|
||||
}]
|
||||
});
|
||||
let mut flattened = body.clone();
|
||||
flatten_request_namespaces(&mut flattened).unwrap();
|
||||
let flat_name = flattened["tools"][0]["name"].as_str().unwrap().to_string();
|
||||
// Truncation kicks in past the 64-char chat tool-name limit.
|
||||
assert!(flat_name.len() <= 64);
|
||||
|
||||
let map = namespace_restore_map(&body);
|
||||
// The truncated name from flatten must be a restore-map key.
|
||||
let entry = map.get(&flat_name).unwrap();
|
||||
assert_eq!(entry.namespace, "mcp__srv__");
|
||||
assert_eq!(entry.name, long_child);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_stream_restores_function_call_events_and_passes_others_through() {
|
||||
let map = namespace_restore_map(&namespace_request());
|
||||
|
||||
let added = "event: response.output_item.added\n\
|
||||
data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"name\":\"mcp__files____read\",\"call_id\":\"c1\"}}\n\n";
|
||||
let delta = "event: response.output_text.delta\n\
|
||||
data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\n";
|
||||
let done = "data: [DONE]\n\n";
|
||||
|
||||
let chunks = vec![
|
||||
Ok::<Bytes, std::io::Error>(Bytes::from(added)),
|
||||
Ok(Bytes::from(delta)),
|
||||
Ok(Bytes::from(done)),
|
||||
];
|
||||
let input = stream::iter(chunks);
|
||||
let out = create_namespace_restore_sse_stream(input, map);
|
||||
futures::pin_mut!(out);
|
||||
|
||||
let mut collected = String::new();
|
||||
while let Some(chunk) = out.next().await {
|
||||
collected.push_str(std::str::from_utf8(&chunk.unwrap()).unwrap());
|
||||
}
|
||||
|
||||
// function_call name restored to namespace form.
|
||||
assert!(collected.contains("\"name\":\"read\""));
|
||||
assert!(collected.contains("\"namespace\":\"mcp__files__\""));
|
||||
assert!(!collected.contains("mcp__files____read"));
|
||||
// Unrelated events preserved verbatim.
|
||||
assert!(collected.contains("\"delta\":\"hi\""));
|
||||
assert!(collected.contains("[DONE]"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
//! xAI (Grok) `Responses` request field sanitization for native Responses
|
||||
//! upstreams.
|
||||
//!
|
||||
//! Codex 0.142+ sends `wire_api="responses"` requests carrying a handful of
|
||||
//! OpenAI-backend-private fields and tool carriers that xAI's strict
|
||||
//! `api.x.ai/v1/responses` serde parser rejects (HTTP 400/422). cc-switch's
|
||||
//! Chat/Anthropic transforms already drop these on the way through, but the
|
||||
//! *native* Responses passthrough forwards the body verbatim, so we scrub them
|
||||
//! here.
|
||||
//!
|
||||
//! This is a faithful port of sub2api's `patchGrokResponsesBody`
|
||||
//! (`backend/internal/service/openai_gateway_grok.go`), the production Go
|
||||
//! gateway that routes Codex → Grok subscriptions. Every transform is a
|
||||
//! deterministic field removal or structural lift — no semantic rewriting — so
|
||||
//! the same input always yields the same output and the upstream prompt-cache
|
||||
//! prefix stays stable across requests. Gated on the xAI OAuth path only (see
|
||||
//! [`super::codex::provider_needs_responses_namespace_flatten`]), so no other
|
||||
//! provider is ever touched.
|
||||
//!
|
||||
//! Run this *after* namespace flattening: by then Codex's `namespace` tools are
|
||||
//! already lifted to top-level `function` tools, so the tool-type whitelist
|
||||
//! below keeps them instead of dropping them.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Codex plugin-private fields removed recursively at any nesting depth.
|
||||
const RECURSIVE_UNSUPPORTED_FIELDS: &[&str] = &["external_web_access"];
|
||||
|
||||
/// Top-level request fields xAI rejects regardless of model.
|
||||
const TOP_LEVEL_UNSUPPORTED_FIELDS: &[&str] = &["prompt_cache_retention", "safety_identifier"];
|
||||
|
||||
/// Top-level sampling fields rejected specifically by grok-4.5.
|
||||
const GROK_45_UNSUPPORTED_FIELDS: &[&str] = &[
|
||||
"presence_penalty",
|
||||
"presencePenalty",
|
||||
"frequency_penalty",
|
||||
"frequencyPenalty",
|
||||
"stop",
|
||||
];
|
||||
|
||||
/// Tool `type` values xAI's Responses schema accepts. Sourced from xAI's own
|
||||
/// serde error enumeration (which is more complete than sub2api's hand-copied
|
||||
/// list — it includes `image_generation`). Any other `type` is a Codex/OpenAI
|
||||
/// private carrier (`tool_search`, a stray `namespace`, `custom`, …) that the
|
||||
/// strict parser would reject, so it is dropped.
|
||||
const XAI_SUPPORTED_TOOL_TYPES: &[&str] = &[
|
||||
"function",
|
||||
"web_search",
|
||||
"x_search",
|
||||
"image_generation",
|
||||
"collections_search",
|
||||
"file_search",
|
||||
"code_execution",
|
||||
"code_interpreter",
|
||||
"mcp",
|
||||
"shell",
|
||||
];
|
||||
|
||||
/// Strip xAI-unsupported fields and tools from a native Codex Responses request
|
||||
/// body in place. Returns whether anything changed. Deterministic and
|
||||
/// idempotent: running it twice on the same body changes nothing the second
|
||||
/// time.
|
||||
pub(crate) fn sanitize_xai_responses_request(body: &mut Value) -> bool {
|
||||
if !body.is_object() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
|
||||
// 1. Top-level fields xAI rejects for every model.
|
||||
for field in TOP_LEVEL_UNSUPPORTED_FIELDS {
|
||||
changed |= remove_top_level_field(body, field);
|
||||
}
|
||||
|
||||
// 2. grok-4.5 additionally rejects these sampling knobs.
|
||||
if request_targets_grok_45(body) {
|
||||
for field in GROK_45_UNSUPPORTED_FIELDS {
|
||||
changed |= remove_top_level_field(body, field);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Codex plugin-private flags buried at any depth (e.g. inside tools or
|
||||
// tool parameter schemas).
|
||||
for field in RECURSIVE_UNSUPPORTED_FIELDS {
|
||||
changed |= remove_field_recursive(body, field);
|
||||
}
|
||||
|
||||
// 4. Lift the `additional_tools` input carrier (Responses Lite private
|
||||
// shape) up to top-level `tools` so the supported ones survive.
|
||||
changed |= promote_additional_tools(body);
|
||||
|
||||
// 5. Drop `content: null` on reasoning input items — xAI's untagged enum
|
||||
// deserializer refuses a present-but-null content field.
|
||||
changed |= strip_null_reasoning_content(body);
|
||||
|
||||
// 6. Whitelist the tool types and clean a now-dangling `tool_choice`.
|
||||
changed |= filter_unsupported_tools(body);
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
/// Whether the request's (possibly provider-prefixed) model resolves to
|
||||
/// grok-4.5. Mirrors sub2api's suffix match: `foo/grok-4.5` counts.
|
||||
fn request_targets_grok_45(body: &Value) -> bool {
|
||||
let Some(model) = body.get("model").and_then(Value::as_str) else {
|
||||
return false;
|
||||
};
|
||||
let mut model = model.trim();
|
||||
if let Some(idx) = model.rfind('/') {
|
||||
model = model[idx + 1..].trim();
|
||||
}
|
||||
model.eq_ignore_ascii_case("grok-4.5")
|
||||
}
|
||||
|
||||
fn remove_top_level_field(body: &mut Value, field: &str) -> bool {
|
||||
body.as_object_mut()
|
||||
.and_then(|obj| obj.remove(field))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Delete every occurrence of `field` in the tree, at any depth.
|
||||
fn remove_field_recursive(value: &mut Value, field: &str) -> bool {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let mut changed = map.remove(field).is_some();
|
||||
for child in map.values_mut() {
|
||||
changed |= remove_field_recursive(child, field);
|
||||
}
|
||||
changed
|
||||
}
|
||||
Value::Array(items) => {
|
||||
let mut changed = false;
|
||||
for child in items.iter_mut() {
|
||||
changed |= remove_field_recursive(child, field);
|
||||
}
|
||||
changed
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_additional_tools_item(item: &Value) -> bool {
|
||||
item.get("type").and_then(Value::as_str).map(str::trim) == Some("additional_tools")
|
||||
}
|
||||
|
||||
/// Promote any `additional_tools` carrier items from `input` into top-level
|
||||
/// `tools`, preserving top-level order and appending carrier tools in order,
|
||||
/// de-duplicated. The carrier items themselves are removed from `input`.
|
||||
fn promote_additional_tools(body: &mut Value) -> bool {
|
||||
// Clone `input` up front so the later mutable write-back to `body` doesn't
|
||||
// collide with the read borrow. Only pays the clone on the rare carrier path.
|
||||
let input_items: Vec<Value> = match body.get("input").and_then(Value::as_array) {
|
||||
Some(arr) if arr.iter().any(is_additional_tools_item) => arr.clone(),
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// Seed merged tools + dedup keys from the existing top-level tools.
|
||||
let mut merged: Vec<Value> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
if let Some(tools) = body.get("tools").and_then(Value::as_array) {
|
||||
for tool in tools {
|
||||
seen.insert(tool_dedup_key(tool));
|
||||
merged.push(tool.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut filtered_input: Vec<Value> = Vec::with_capacity(input_items.len());
|
||||
let mut promoted = false;
|
||||
for item in input_items {
|
||||
if is_additional_tools_item(&item) {
|
||||
if let Some(carrier_tools) = item.get("tools").and_then(Value::as_array) {
|
||||
for tool in carrier_tools {
|
||||
if seen.insert(tool_dedup_key(tool)) {
|
||||
merged.push(tool.clone());
|
||||
promoted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue; // carrier item dropped regardless of dedup outcome
|
||||
}
|
||||
filtered_input.push(item);
|
||||
}
|
||||
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
obj.insert("input".to_string(), Value::Array(filtered_input));
|
||||
if promoted {
|
||||
obj.insert("tools".to_string(), Value::Array(merged));
|
||||
}
|
||||
}
|
||||
// We reached here only because a carrier existed, so `input` changed.
|
||||
true
|
||||
}
|
||||
|
||||
/// Stable dedup key for a tool: `(type, name)`, `(mcp, server_label)`, or the
|
||||
/// serialized tool as a last resort. Mirrors sub2api's `grokResponsesToolDedupKey`.
|
||||
fn tool_dedup_key(tool: &Value) -> String {
|
||||
let tool_type = tool
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if !tool_type.is_empty() {
|
||||
if let Some(name) = tool.get("name").and_then(Value::as_str) {
|
||||
let name = name.trim();
|
||||
if !name.is_empty() {
|
||||
return format!("type:{tool_type}\u{0}name:{name}");
|
||||
}
|
||||
}
|
||||
if tool_type == "mcp" {
|
||||
if let Some(label) = tool.get("server_label").and_then(Value::as_str) {
|
||||
let label = label.trim();
|
||||
if !label.is_empty() {
|
||||
return format!("type:mcp\u{0}server_label:{label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
format!("json:{tool}")
|
||||
}
|
||||
|
||||
fn strip_null_reasoning_content(body: &mut Value) -> bool {
|
||||
let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) else {
|
||||
return false;
|
||||
};
|
||||
let mut changed = false;
|
||||
for item in input.iter_mut() {
|
||||
if item.get("type").and_then(Value::as_str).map(str::trim) != Some("reasoning") {
|
||||
continue;
|
||||
}
|
||||
if let Some(obj) = item.as_object_mut() {
|
||||
if matches!(obj.get("content"), Some(Value::Null)) {
|
||||
obj.remove("content");
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Keep only whitelisted tool types and drop a `tool_choice` that now points at
|
||||
/// a removed or unsupported tool.
|
||||
fn filter_unsupported_tools(body: &mut Value) -> bool {
|
||||
let Some(tools) = body.get("tools").and_then(Value::as_array) else {
|
||||
return false;
|
||||
};
|
||||
let original_len = tools.len();
|
||||
let filtered: Vec<Value> = tools
|
||||
.iter()
|
||||
.filter(|tool| {
|
||||
let t = tool
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
XAI_SUPPORTED_TOOL_TYPES.contains(&t)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let mut changed = false;
|
||||
if filtered.len() != original_len {
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
if filtered.is_empty() {
|
||||
obj.remove("tools");
|
||||
} else {
|
||||
obj.insert("tools".to_string(), Value::Array(filtered.clone()));
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if body.get("tool_choice").is_some() && should_drop_tool_choice(body, &filtered) {
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
obj.remove("tool_choice");
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
/// Whether `tool_choice` should be dropped given the surviving `tools`. String
|
||||
/// choices (`"auto"`, `"none"`, `"required"`) are always kept; object choices
|
||||
/// are dropped when they reference an unsupported type or a function name that
|
||||
/// no longer exists.
|
||||
fn should_drop_tool_choice(body: &Value, tools: &[Value]) -> bool {
|
||||
let Some(tool_choice) = body.get("tool_choice") else {
|
||||
return false;
|
||||
};
|
||||
if tools.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let Some(choice) = tool_choice.as_object() else {
|
||||
return false; // "auto"/"none"/"required" string choices stay
|
||||
};
|
||||
let choice_type = choice
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if choice_type.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if !XAI_SUPPORTED_TOOL_TYPES.contains(&choice_type) {
|
||||
return true;
|
||||
}
|
||||
if choice_type == "function" {
|
||||
let choice_name = choice
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
choice
|
||||
.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if choice_name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let exists = tools.iter().any(|tool| {
|
||||
let t = tool
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let name = tool
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
tool.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
t == "function" && name == choice_name
|
||||
});
|
||||
return !exists;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn strips_external_web_access_recursively() {
|
||||
let mut body = json!({
|
||||
"model": "grok-4.5",
|
||||
"external_web_access": true,
|
||||
"tools": [
|
||||
{"type": "function", "name": "f", "external_web_access": true,
|
||||
"parameters": {"type": "object", "q": {"external_web_access": true}}}
|
||||
],
|
||||
"metadata": {"external_web_access": false}
|
||||
});
|
||||
assert!(sanitize_xai_responses_request(&mut body));
|
||||
let s = body.to_string();
|
||||
assert!(!s.contains("external_web_access"), "left over: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_top_level_unsupported_fields() {
|
||||
let mut body = json!({
|
||||
"model": "grok-4.5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"safety_identifier": "abc"
|
||||
});
|
||||
assert!(sanitize_xai_responses_request(&mut body));
|
||||
assert!(body.get("prompt_cache_retention").is_none());
|
||||
assert!(body.get("safety_identifier").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_grok_45_only_sampling_fields() {
|
||||
let mut body = json!({
|
||||
"model": "grok-4.5",
|
||||
"presence_penalty": 0.1,
|
||||
"frequency_penalty": 0.2,
|
||||
"stop": ["x"]
|
||||
});
|
||||
assert!(sanitize_xai_responses_request(&mut body));
|
||||
assert!(body.get("presence_penalty").is_none());
|
||||
assert!(body.get("frequency_penalty").is_none());
|
||||
assert!(body.get("stop").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_sampling_fields_for_non_grok_45() {
|
||||
let mut body = json!({
|
||||
"model": "grok-4-fast",
|
||||
"presence_penalty": 0.1,
|
||||
"stop": ["x"]
|
||||
});
|
||||
// No unsupported fields present, so no change and knobs preserved.
|
||||
assert!(!sanitize_xai_responses_request(&mut body));
|
||||
assert_eq!(body.get("presence_penalty"), Some(&json!(0.1)));
|
||||
assert_eq!(body.get("stop"), Some(&json!(["x"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_grok_45_with_provider_prefix() {
|
||||
let mut body = json!({"model": "xai/grok-4.5", "stop": ["x"]});
|
||||
assert!(sanitize_xai_responses_request(&mut body));
|
||||
assert!(body.get("stop").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promotes_additional_tools_dedup() {
|
||||
let mut body = json!({
|
||||
"model": "grok-4.5",
|
||||
"tools": [{"type": "function", "name": "kept"}],
|
||||
"input": [
|
||||
{"type": "message", "role": "user", "content": "hi"},
|
||||
{"type": "additional_tools", "tools": [
|
||||
{"type": "function", "name": "kept"},
|
||||
{"type": "function", "name": "extra"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
assert!(sanitize_xai_responses_request(&mut body));
|
||||
// carrier removed from input
|
||||
let input = body.get("input").unwrap().as_array().unwrap();
|
||||
assert_eq!(input.len(), 1);
|
||||
assert!(input.iter().all(|i| !is_additional_tools_item(i)));
|
||||
// extra promoted, kept not duplicated
|
||||
let tools = body.get("tools").unwrap().as_array().unwrap();
|
||||
let names: Vec<&str> = tools
|
||||
.iter()
|
||||
.map(|t| t.get("name").and_then(Value::as_str).unwrap())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["kept", "extra"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_null_reasoning_content() {
|
||||
let mut body = json!({
|
||||
"model": "grok-4.5",
|
||||
"input": [
|
||||
{"type": "reasoning", "content": null, "id": "r1"},
|
||||
{"type": "reasoning", "content": [{"text": "keep"}], "id": "r2"}
|
||||
]
|
||||
});
|
||||
assert!(sanitize_xai_responses_request(&mut body));
|
||||
let input = body.get("input").unwrap().as_array().unwrap();
|
||||
assert!(input[0].get("content").is_none());
|
||||
assert!(input[1].get("content").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_unsupported_tool_types() {
|
||||
let mut body = json!({
|
||||
"model": "grok-4.5",
|
||||
"tools": [
|
||||
{"type": "function", "name": "f"},
|
||||
{"type": "tool_search"},
|
||||
{"type": "custom", "name": "c"},
|
||||
{"type": "mcp", "server_label": "s"}
|
||||
]
|
||||
});
|
||||
assert!(sanitize_xai_responses_request(&mut body));
|
||||
let types: Vec<&str> = body
|
||||
.get("tools")
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| t.get("type").and_then(Value::as_str).unwrap())
|
||||
.collect();
|
||||
assert_eq!(types, vec!["function", "mcp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_dangling_function_tool_choice() {
|
||||
let mut body = json!({
|
||||
"model": "grok-4.5",
|
||||
"tools": [{"type": "tool_search"}],
|
||||
"tool_choice": {"type": "function", "name": "gone"}
|
||||
});
|
||||
assert!(sanitize_xai_responses_request(&mut body));
|
||||
// tool_search filtered → no tools → tool_choice dropped
|
||||
assert!(body.get("tools").is_none());
|
||||
assert!(body.get("tool_choice").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_valid_function_tool_choice() {
|
||||
let mut body = json!({
|
||||
"model": "grok-4.5",
|
||||
"tools": [{"type": "function", "name": "run"}],
|
||||
"tool_choice": {"type": "function", "name": "run"}
|
||||
});
|
||||
assert!(!sanitize_xai_responses_request(&mut body));
|
||||
assert_eq!(
|
||||
body.get("tool_choice").unwrap(),
|
||||
&json!({"type": "function", "name": "run"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_string_tool_choice() {
|
||||
let mut body = json!({
|
||||
"model": "grok-4.5",
|
||||
"tools": [{"type": "function", "name": "run"}],
|
||||
"tool_choice": "auto"
|
||||
});
|
||||
assert!(!sanitize_xai_responses_request(&mut body));
|
||||
assert_eq!(body.get("tool_choice").unwrap(), &json!("auto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_on_clean_request() {
|
||||
let mut body = json!({
|
||||
"model": "grok-4.5",
|
||||
"input": [{"type": "message", "role": "user", "content": "hi"}],
|
||||
"tools": [{"type": "function", "name": "f"}]
|
||||
});
|
||||
assert!(!sanitize_xai_responses_request(&mut body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotent_second_pass() {
|
||||
let mut body = json!({
|
||||
"model": "grok-4.5",
|
||||
"external_web_access": true,
|
||||
"prompt_cache_retention": "24h",
|
||||
"tools": [{"type": "function", "name": "f"}, {"type": "tool_search"}]
|
||||
});
|
||||
assert!(sanitize_xai_responses_request(&mut body));
|
||||
// second pass finds nothing left to change
|
||||
assert!(!sanitize_xai_responses_request(&mut body));
|
||||
}
|
||||
}
|
||||
@@ -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!({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -225,9 +225,9 @@ pub async fn handle_non_streaming(
|
||||
strip_hop_by_hop_response_headers(&mut response_headers);
|
||||
|
||||
log::debug!(
|
||||
"[{}] 上游响应体内容: {}",
|
||||
"[{}] 上游响应体已接收: bytes={} (content omitted)",
|
||||
ctx.tag,
|
||||
String::from_utf8_lossy(&body_bytes)
|
||||
body_bytes.len()
|
||||
);
|
||||
|
||||
// 解析并记录使用量。关闭 usage logging 时直接跳过,避免非流式响应整包 JSON parse。
|
||||
@@ -456,7 +456,7 @@ impl Drop for SseUsageFinishGuard {
|
||||
// ============================================================================
|
||||
|
||||
/// 创建使用量收集器
|
||||
fn create_usage_collector(
|
||||
pub(crate) fn create_usage_collector(
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
status_code: u16,
|
||||
@@ -642,7 +642,8 @@ async fn log_usage_internal(
|
||||
model
|
||||
};
|
||||
|
||||
let request_id = usage.dedup_request_id();
|
||||
let dedup_scope = (app_type != "claude").then_some((app_type, provider_id));
|
||||
let request_id = usage.dedup_request_id(dedup_scope);
|
||||
|
||||
log::debug!(
|
||||
"[{app_type}] 记录请求日志: id={request_id}, provider={provider_id}, model={model}, streaming={is_streaming}, status={status_code}, latency_ms={latency_ms}, first_token_ms={first_token_ms:?}, session={}, input={}, output={}, cache_read={}, cache_creation={}",
|
||||
@@ -761,11 +762,10 @@ pub fn create_logged_passthrough_stream(
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if collected {
|
||||
log::debug!("[{tag}] <<< SSE 事件: {data}");
|
||||
} else {
|
||||
log::debug!("[{tag}] <<< SSE 数据: {data}");
|
||||
}
|
||||
log::trace!(
|
||||
"[{tag}] <<< SSE data: bytes={}, usage_collected={collected} (content omitted)",
|
||||
data.len()
|
||||
);
|
||||
} else {
|
||||
log::debug!("[{tag}] <<< SSE: [DONE]");
|
||||
}
|
||||
@@ -798,15 +798,53 @@ pub fn create_logged_passthrough_stream(
|
||||
}
|
||||
}
|
||||
|
||||
fn is_safe_diagnostic_header(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"content-type"
|
||||
| "content-encoding"
|
||||
| "content-length"
|
||||
| "retry-after"
|
||||
| "cf-ray"
|
||||
| "x-request-id"
|
||||
| "request-id"
|
||||
| "x-correlation-id"
|
||||
) || name.starts_with("x-ratelimit-")
|
||||
|| name.starts_with("ratelimit-")
|
||||
}
|
||||
|
||||
fn bounded_header_value(value: &axum::http::HeaderValue) -> Option<String> {
|
||||
let value = value.to_str().ok()?;
|
||||
let mut bounded = value.chars().take(160).collect::<String>();
|
||||
if value.chars().count() > 160 {
|
||||
bounded.push('…');
|
||||
}
|
||||
Some(bounded)
|
||||
}
|
||||
|
||||
fn format_headers(headers: &HeaderMap) -> String {
|
||||
headers
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
let value_str = value.to_str().unwrap_or("<non-utf8>");
|
||||
format!("{key}={value_str}")
|
||||
let mut entries = headers
|
||||
.keys()
|
||||
.map(|key| {
|
||||
let name = key.as_str();
|
||||
if !is_safe_diagnostic_header(name) {
|
||||
return name.to_string();
|
||||
}
|
||||
|
||||
let values = headers
|
||||
.get_all(key)
|
||||
.iter()
|
||||
.filter_map(bounded_header_value)
|
||||
.collect::<Vec<_>>();
|
||||
if values.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{name}={}", values.join("|"))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort();
|
||||
format!("[{}]", entries.join(", "))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -827,6 +865,25 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[test]
|
||||
fn format_headers_keeps_only_allowlisted_diagnostic_values() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("authorization", "Bearer super-secret".parse().unwrap());
|
||||
headers.insert("set-cookie", "session=cookie-secret".parse().unwrap());
|
||||
headers.insert("retry-after", "30".parse().unwrap());
|
||||
headers.insert("x-ratelimit-remaining", "2".parse().unwrap());
|
||||
headers.insert("cf-ray", "abc123-SJC".parse().unwrap());
|
||||
|
||||
let formatted = format_headers(&headers);
|
||||
assert!(formatted.contains("authorization"), "{formatted}");
|
||||
assert!(formatted.contains("set-cookie"), "{formatted}");
|
||||
assert!(formatted.contains("retry-after=30"), "{formatted}");
|
||||
assert!(formatted.contains("x-ratelimit-remaining=2"), "{formatted}");
|
||||
assert!(formatted.contains("cf-ray=abc123-SJC"), "{formatted}");
|
||||
assert!(!formatted.contains("super-secret"), "{formatted}");
|
||||
assert!(!formatted.contains("cookie-secret"), "{formatted}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_sse_field_accepts_optional_space() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -327,6 +327,12 @@ impl ProxyServer {
|
||||
.route("/v1/responses", post(handlers::handle_responses))
|
||||
.route("/v1/v1/responses", post(handlers::handle_responses))
|
||||
.route("/codex/v1/responses", post(handlers::handle_responses))
|
||||
// Grok Build uses the Responses protocol but has an independent
|
||||
// provider namespace and failover queue.
|
||||
.route(
|
||||
"/grokbuild/v1/responses",
|
||||
post(handlers::handle_grokbuild_responses),
|
||||
)
|
||||
// OpenAI Responses Compact API (Codex CLI 远程压缩,透传)
|
||||
.route(
|
||||
"/responses/compact",
|
||||
@@ -344,6 +350,10 @@ impl ProxyServer {
|
||||
"/codex/v1/responses/compact",
|
||||
post(handlers::handle_responses_compact),
|
||||
)
|
||||
.route(
|
||||
"/grokbuild/v1/responses/compact",
|
||||
post(handlers::handle_grokbuild_responses_compact),
|
||||
)
|
||||
// Gemini API (支持带前缀和不带前缀)
|
||||
//
|
||||
// 用 `any(..)` 覆盖所有 HTTP 方法:除了 POST `:generateContent` /
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,7 @@ pub struct ProxyTakeoverStatus {
|
||||
pub claude: bool,
|
||||
pub codex: bool,
|
||||
pub gemini: bool,
|
||||
pub grokbuild: bool,
|
||||
pub opencode: bool,
|
||||
pub openclaw: bool,
|
||||
}
|
||||
|
||||
@@ -59,7 +59,8 @@ impl CostCalculator {
|
||||
pricing: &ModelPricing,
|
||||
cost_multiplier: Decimal,
|
||||
) -> CostBreakdown {
|
||||
let input_includes_cache_read = matches!(app_type, "codex" | "gemini");
|
||||
let input_includes_cache_read =
|
||||
crate::services::sql_helpers::is_cache_inclusive_app(app_type);
|
||||
Self::calculate_with_cache_semantics(
|
||||
usage,
|
||||
pricing,
|
||||
@@ -211,6 +212,25 @@ mod tests {
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.010035").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grokbuild_does_not_double_bill_cached_input() {
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 600,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
let pricing = ModelPricing::from_strings("10", "0", "1", "0").unwrap();
|
||||
|
||||
let cost = CostCalculator::calculate_for_app("grokbuild", &usage, &pricing, Decimal::ONE);
|
||||
|
||||
assert_eq!(cost.input_cost, Decimal::from_str("0.004").unwrap());
|
||||
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.0006").unwrap());
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.0046").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_multiplier() {
|
||||
let usage = TokenUsage {
|
||||
|
||||
@@ -6,9 +6,59 @@ use crate::database::{Database, PRICING_SOURCE_REQUEST, PRICING_SOURCE_RESPONSE}
|
||||
use crate::error::AppError;
|
||||
use crate::services::sql_helpers::{INPUT_TOKEN_SEMANTICS_FRESH, INPUT_TOKEN_SEMANTICS_TOTAL};
|
||||
use crate::services::usage_stats::{find_model_pricing_row, is_placeholder_pricing_model};
|
||||
use rusqlite::OptionalExtension;
|
||||
use rust_decimal::Decimal;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct UsageSemantic {
|
||||
app_type: String,
|
||||
provider_id: String,
|
||||
model: String,
|
||||
input_token_semantics: i64,
|
||||
input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
cache_read_tokens: u32,
|
||||
cache_creation_tokens: u32,
|
||||
status_code: u16,
|
||||
}
|
||||
|
||||
impl UsageSemantic {
|
||||
fn from_log(log: &RequestLog, input_token_semantics: i64) -> Self {
|
||||
Self {
|
||||
app_type: log.app_type.clone(),
|
||||
provider_id: log.provider_id.clone(),
|
||||
model: log.model.clone(),
|
||||
input_token_semantics,
|
||||
input_tokens: log.usage.input_tokens,
|
||||
output_tokens: log.usage.output_tokens,
|
||||
cache_read_tokens: log.usage.cache_read_tokens,
|
||||
cache_creation_tokens: log.usage.cache_creation_tokens,
|
||||
status_code: log.status_code,
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256(&self) -> String {
|
||||
let encoded = serde_json::to_vec(&(
|
||||
&self.app_type,
|
||||
&self.provider_id,
|
||||
&self.model,
|
||||
self.input_token_semantics,
|
||||
self.input_tokens,
|
||||
self.output_tokens,
|
||||
self.cache_read_tokens,
|
||||
self.cache_creation_tokens,
|
||||
self.status_code,
|
||||
))
|
||||
.expect("usage semantic tuple is serializable");
|
||||
Sha256::digest(encoded)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求日志
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RequestLog {
|
||||
@@ -71,57 +121,138 @@ impl<'a> UsageLogger<'a> {
|
||||
};
|
||||
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
let input_token_semantics = if matches!(log.app_type.as_str(), "codex" | "gemini") {
|
||||
INPUT_TOKEN_SEMANTICS_TOTAL
|
||||
} else {
|
||||
INPUT_TOKEN_SEMANTICS_FRESH
|
||||
let input_token_semantics =
|
||||
if crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str()) {
|
||||
INPUT_TOKEN_SEMANTICS_TOTAL
|
||||
} else {
|
||||
INPUT_TOKEN_SEMANTICS_FRESH
|
||||
};
|
||||
let semantic = UsageSemantic::from_log(log, input_token_semantics);
|
||||
let existing = Self::load_existing_semantic(&conn, &log.request_id)?;
|
||||
|
||||
let (request_id, replace_session_log, collision) = match existing {
|
||||
None => (log.request_id.clone(), false, false),
|
||||
Some((data_source, _existing_semantic))
|
||||
if data_source.as_deref() == Some("session_log") =>
|
||||
{
|
||||
(log.request_id.clone(), true, false)
|
||||
}
|
||||
Some((data_source, existing_semantic))
|
||||
if data_source.as_deref().unwrap_or("proxy") == "proxy"
|
||||
&& existing_semantic == semantic =>
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Some(_) => {
|
||||
let fallback = format!("{}:collision:{}", log.request_id, semantic.sha256());
|
||||
if let Some((data_source, existing_semantic)) =
|
||||
Self::load_existing_semantic(&conn, &fallback)?
|
||||
{
|
||||
if data_source.as_deref().unwrap_or("proxy") == "proxy"
|
||||
&& existing_semantic == semantic
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
return Err(AppError::Database(format!(
|
||||
"usage collision fallback 主键发生 SHA-256 冲突: {fallback}"
|
||||
)));
|
||||
}
|
||||
(fallback, false, true)
|
||||
}
|
||||
};
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO proxy_request_logs (
|
||||
let insert_verb = if replace_session_log {
|
||||
"INSERT OR REPLACE"
|
||||
} else {
|
||||
"INSERT OR IGNORE"
|
||||
};
|
||||
let sql = format!(
|
||||
"{insert_verb} INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model, pricing_model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_token_semantics,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
latency_ms, first_token_ms, status_code, error_message, session_id,
|
||||
provider_type, is_streaming, cost_multiplier, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)",
|
||||
rusqlite::params![
|
||||
log.request_id,
|
||||
log.provider_id,
|
||||
log.app_type,
|
||||
log.model,
|
||||
log.request_model,
|
||||
log.pricing_model,
|
||||
log.usage.input_tokens,
|
||||
log.usage.output_tokens,
|
||||
log.usage.cache_read_tokens,
|
||||
log.usage.cache_creation_tokens,
|
||||
input_token_semantics,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_read_cost,
|
||||
cache_creation_cost,
|
||||
total_cost,
|
||||
log.latency_ms as i64,
|
||||
log.first_token_ms.map(|v| v as i64),
|
||||
log.status_code as i64,
|
||||
log.error_message,
|
||||
log.session_id,
|
||||
log.provider_type,
|
||||
log.is_streaming as i64,
|
||||
log.cost_multiplier,
|
||||
created_at,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("记录请求日志失败: {e}")))?;
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)"
|
||||
);
|
||||
let affected_rows = conn
|
||||
.execute(
|
||||
&sql,
|
||||
rusqlite::params![
|
||||
request_id,
|
||||
log.provider_id,
|
||||
log.app_type,
|
||||
log.model,
|
||||
log.request_model,
|
||||
log.pricing_model,
|
||||
log.usage.input_tokens,
|
||||
log.usage.output_tokens,
|
||||
log.usage.cache_read_tokens,
|
||||
log.usage.cache_creation_tokens,
|
||||
input_token_semantics,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_read_cost,
|
||||
cache_creation_cost,
|
||||
total_cost,
|
||||
log.latency_ms as i64,
|
||||
log.first_token_ms.map(|v| v as i64),
|
||||
log.status_code as i64,
|
||||
log.error_message,
|
||||
log.session_id,
|
||||
log.provider_type,
|
||||
log.is_streaming as i64,
|
||||
log.cost_multiplier,
|
||||
created_at,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("记录请求日志失败: {e}")))?;
|
||||
|
||||
// 通知前端使用统计有更新(200ms 防抖合并,不阻塞写入路径)
|
||||
crate::usage_events::notify_log_recorded();
|
||||
if affected_rows > 0 {
|
||||
if collision {
|
||||
log::warn!(
|
||||
"usage request_id collision: primary={}, fallback={request_id}",
|
||||
log.request_id
|
||||
);
|
||||
}
|
||||
crate::usage_events::notify_log_recorded();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_existing_semantic(
|
||||
conn: &rusqlite::Connection,
|
||||
request_id: &str,
|
||||
) -> Result<Option<(Option<String>, UsageSemantic)>, AppError> {
|
||||
conn.query_row(
|
||||
"SELECT data_source, app_type, provider_id, model, input_token_semantics,
|
||||
input_tokens, output_tokens, cache_read_tokens,
|
||||
cache_creation_tokens, status_code
|
||||
FROM proxy_request_logs WHERE request_id = ?1",
|
||||
[request_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
UsageSemantic {
|
||||
app_type: row.get(1)?,
|
||||
provider_id: row.get(2)?,
|
||||
model: row.get(3)?,
|
||||
input_token_semantics: row.get(4)?,
|
||||
input_tokens: row.get::<_, i64>(5)? as u32,
|
||||
output_tokens: row.get::<_, i64>(6)? as u32,
|
||||
cache_read_tokens: row.get::<_, i64>(7)? as u32,
|
||||
cache_creation_tokens: row.get::<_, i64>(8)? as u32,
|
||||
status_code: row.get::<_, i64>(9)? as u16,
|
||||
},
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(format!("查询 usage request_id 失败: {error}")))
|
||||
}
|
||||
|
||||
/// 记录失败的请求
|
||||
///
|
||||
/// 用于记录无法从上游获取 usage 信息的失败请求
|
||||
@@ -374,6 +505,34 @@ impl<'a> UsageLogger<'a> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn request_log(request_id: &str, input_tokens: u32) -> RequestLog {
|
||||
RequestLog {
|
||||
request_id: request_id.to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
app_type: "codex".to_string(),
|
||||
model: "gpt-5.6".to_string(),
|
||||
request_model: "gpt-5.6".to_string(),
|
||||
pricing_model: "gpt-5.6".to_string(),
|
||||
usage: TokenUsage {
|
||||
input_tokens,
|
||||
output_tokens: 5,
|
||||
cache_read_tokens: 2,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: Some("resp-1".to_string()),
|
||||
},
|
||||
cost: None,
|
||||
latency_ms: 10,
|
||||
first_token_ms: Some(2),
|
||||
status_code: 200,
|
||||
error_message: None,
|
||||
session_id: None,
|
||||
provider_type: Some("codex".to_string()),
|
||||
is_streaming: true,
|
||||
cost_multiplier: "1".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_request() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
@@ -431,6 +590,103 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_replay_writes_and_notifies_once() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let logger = UsageLogger::new(&db);
|
||||
crate::usage_events::take_test_notify_count();
|
||||
let log = request_log("stable-id", 10);
|
||||
|
||||
logger.log_request(&log)?;
|
||||
logger.log_request(&log)?;
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = 'stable-id'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(crate::usage_events::take_test_notify_count(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_collision_uses_deterministic_idempotent_fallback() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let logger = UsageLogger::new(&db);
|
||||
crate::usage_events::take_test_notify_count();
|
||||
let first = request_log("shared-id", 10);
|
||||
let second = request_log("shared-id", 20);
|
||||
|
||||
logger.log_request(&first)?;
|
||||
logger.log_request(&second)?;
|
||||
logger.log_request(&second)?;
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let rows: Vec<(String, i64)> = conn
|
||||
.prepare(
|
||||
"SELECT request_id, input_tokens FROM proxy_request_logs ORDER BY input_tokens",
|
||||
)?
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||
.collect::<Result<_, _>>()?;
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0], ("shared-id".to_string(), 10));
|
||||
assert!(rows[1].0.starts_with("shared-id:collision:"));
|
||||
assert_eq!(rows[1].1, 20);
|
||||
assert_eq!(crate::usage_events::take_test_notify_count(), 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_session_log_primary_rows_may_be_replaced() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
for (request_id, data_source) in [
|
||||
("session-primary", "session_log"),
|
||||
("codex-primary", "codex_session"),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, input_tokens,
|
||||
output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
latency_ms, status_code, created_at, data_source
|
||||
) VALUES (?1, '_session', 'claude', 'old', 1, 1, 0, 0, 0, 200, 1, ?2)",
|
||||
rusqlite::params![request_id, data_source],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
let logger = UsageLogger::new(&db);
|
||||
|
||||
let mut session_replacement = request_log("session-primary", 10);
|
||||
session_replacement.app_type = "claude".to_string();
|
||||
logger.log_request(&session_replacement)?;
|
||||
logger.log_request(&request_log("codex-primary", 20))?;
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let session_source: String = conn.query_row(
|
||||
"SELECT data_source FROM proxy_request_logs WHERE request_id = 'session-primary'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let codex_input: i64 = conn.query_row(
|
||||
"SELECT input_tokens FROM proxy_request_logs WHERE request_id = 'codex-primary'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let fallback_count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM proxy_request_logs
|
||||
WHERE request_id LIKE 'codex-primary:collision:%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(session_source, "proxy");
|
||||
assert_eq!(codex_input, 1);
|
||||
assert_eq!(fallback_count, 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_error() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
@@ -459,4 +715,39 @@ mod tests {
|
||||
assert_eq!(error, Some("Internal Server Error".to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grokbuild_logs_total_input_token_semantics() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let logger = UsageLogger::new(&db);
|
||||
let log = RequestLog {
|
||||
request_id: "grok-semantics".to_string(),
|
||||
provider_id: "grok-provider".to_string(),
|
||||
app_type: "grokbuild".to_string(),
|
||||
model: "grok-4.5".to_string(),
|
||||
request_model: "grok-4.5".to_string(),
|
||||
pricing_model: String::new(),
|
||||
usage: TokenUsage::default(),
|
||||
cost: None,
|
||||
latency_ms: 1,
|
||||
first_token_ms: None,
|
||||
status_code: 200,
|
||||
error_message: None,
|
||||
session_id: None,
|
||||
provider_type: Some("grokbuild".to_string()),
|
||||
is_streaming: false,
|
||||
cost_multiplier: "1".to_string(),
|
||||
};
|
||||
|
||||
logger.log_request(&log)?;
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let semantics: i64 = conn.query_row(
|
||||
"SELECT input_token_semantics FROM proxy_request_logs WHERE request_id = 'grok-semantics'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(semantics, INPUT_TOKEN_SEMANTICS_TOTAL);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@ fn openai_cache_write_tokens(usage: &Value) -> u32 {
|
||||
/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致
|
||||
pub const SESSION_REQUEST_ID_PREFIX: &str = "session:";
|
||||
|
||||
fn response_id(body: &Value, field: &str) -> Option<String> {
|
||||
body.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Token 使用量统计
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
@@ -47,12 +54,18 @@ pub struct TokenUsage {
|
||||
}
|
||||
|
||||
impl TokenUsage {
|
||||
/// 生成与 session 日志共享的 request_id,用于跨源去重。
|
||||
/// 有 message_id 时返回 `session:{id}`,否则回退到随机 UUID。
|
||||
pub fn dedup_request_id(&self) -> String {
|
||||
/// 生成稳定 request_id。Claude 不加作用域,以便继续与 session JSONL 的
|
||||
/// `session:{message_id}` 主键收敛;其他协议加入 app/provider 作用域,避免
|
||||
/// 不同上游复用 envelope id 时互相覆盖。
|
||||
pub fn dedup_request_id(&self, scope: Option<(&str, &str)>) -> String {
|
||||
self.message_id
|
||||
.as_ref()
|
||||
.map(|mid| format!("{SESSION_REQUEST_ID_PREFIX}{mid}"))
|
||||
.map(|message_id| match scope {
|
||||
Some((app_type, provider_id)) => {
|
||||
format!("{SESSION_REQUEST_ID_PREFIX}{app_type}:{provider_id}:{message_id}")
|
||||
}
|
||||
None => format!("{SESSION_REQUEST_ID_PREFIX}{message_id}"),
|
||||
})
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
|
||||
}
|
||||
|
||||
@@ -88,10 +101,7 @@ impl TokenUsage {
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let message_id = body
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let message_id = response_id(body, "id");
|
||||
|
||||
Some(Self {
|
||||
input_tokens: usage.get("input_tokens")?.as_u64()? as u32,
|
||||
@@ -128,8 +138,8 @@ impl TokenUsage {
|
||||
}
|
||||
}
|
||||
if message_id.is_none() {
|
||||
if let Some(id) = message.get("id").and_then(|v| v.as_str()) {
|
||||
message_id = Some(id.to_string());
|
||||
if let Some(id) = response_id(message, "id") {
|
||||
message_id = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,7 +248,7 @@ impl TokenUsage {
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
message_id: response_id(body, "id"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -277,7 +287,7 @@ impl TokenUsage {
|
||||
cache_read_tokens: cached_tokens,
|
||||
cache_creation_tokens: cache_write_tokens,
|
||||
model,
|
||||
message_id: None,
|
||||
message_id: response_id(body, "id"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -312,7 +322,7 @@ impl TokenUsage {
|
||||
cache_read_tokens: cached_tokens,
|
||||
cache_creation_tokens: cache_write_tokens,
|
||||
model,
|
||||
message_id: None,
|
||||
message_id: response_id(body, "id"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -404,7 +414,7 @@ impl TokenUsage {
|
||||
cache_read_tokens: cached_tokens,
|
||||
cache_creation_tokens: cache_write_tokens,
|
||||
model,
|
||||
message_id: None,
|
||||
message_id: response_id(body, "id"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -416,7 +426,12 @@ impl TokenUsage {
|
||||
if let Some(usage) = event.get("usage") {
|
||||
if !usage.is_null() {
|
||||
log::debug!("[Codex] 找到 usage: {usage:?}");
|
||||
return Self::from_openai_response(event);
|
||||
let mut parsed = Self::from_openai_response(event)?;
|
||||
if parsed.message_id.is_none() {
|
||||
parsed.message_id =
|
||||
events.iter().find_map(|chunk| response_id(chunk, "id"));
|
||||
}
|
||||
return Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -449,7 +464,7 @@ impl TokenUsage {
|
||||
.unwrap_or(0) as u32,
|
||||
cache_creation_tokens: 0,
|
||||
model,
|
||||
message_id: None,
|
||||
message_id: response_id(body, "responseId"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -460,6 +475,7 @@ impl TokenUsage {
|
||||
let mut total_tokens = 0u32;
|
||||
let mut total_cache_read = 0u32;
|
||||
let mut model: Option<String> = None;
|
||||
let mut message_id: Option<String> = None;
|
||||
|
||||
for chunk in chunks {
|
||||
if let Some(usage) = chunk.get("usageMetadata") {
|
||||
@@ -488,6 +504,9 @@ impl TokenUsage {
|
||||
model = Some(model_version.to_string());
|
||||
}
|
||||
}
|
||||
if message_id.is_none() {
|
||||
message_id = response_id(chunk, "responseId");
|
||||
}
|
||||
}
|
||||
|
||||
// 输出 tokens = 总 tokens - 输入 tokens
|
||||
@@ -500,7 +519,7 @@ impl TokenUsage {
|
||||
cache_read_tokens: total_cache_read,
|
||||
cache_creation_tokens: 0,
|
||||
model,
|
||||
message_id: None,
|
||||
message_id,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -513,6 +532,61 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn response_ids_produce_scoped_dedup_keys_and_empty_ids_fall_back() {
|
||||
let response = json!({
|
||||
"id": "resp_123",
|
||||
"model": "gpt-5.6",
|
||||
"usage": { "input_tokens": 10, "output_tokens": 2 }
|
||||
});
|
||||
let usage = TokenUsage::from_codex_response(&response).unwrap();
|
||||
assert_eq!(usage.message_id.as_deref(), Some("resp_123"));
|
||||
assert_eq!(
|
||||
usage.dedup_request_id(Some(("codex", "provider-a"))),
|
||||
"session:codex:provider-a:resp_123"
|
||||
);
|
||||
|
||||
let empty = json!({
|
||||
"id": "",
|
||||
"usage": { "input_tokens": 10, "output_tokens": 2 }
|
||||
});
|
||||
let empty_usage = TokenUsage::from_codex_response(&empty).unwrap();
|
||||
assert!(empty_usage.message_id.is_none());
|
||||
assert!(!empty_usage
|
||||
.dedup_request_id(Some(("codex", "provider-a")))
|
||||
.starts_with("session:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_parsers_recover_ids_from_envelope_chunks() {
|
||||
let openai = vec![
|
||||
json!({"id": "chatcmpl_123", "choices": []}),
|
||||
json!({
|
||||
"usage": { "prompt_tokens": 10, "completion_tokens": 2 },
|
||||
"choices": []
|
||||
}),
|
||||
];
|
||||
assert_eq!(
|
||||
TokenUsage::from_openai_stream_events(&openai)
|
||||
.unwrap()
|
||||
.message_id
|
||||
.as_deref(),
|
||||
Some("chatcmpl_123")
|
||||
);
|
||||
|
||||
let gemini = vec![json!({
|
||||
"responseId": "gemini_123",
|
||||
"usageMetadata": { "promptTokenCount": 10, "totalTokenCount": 12 }
|
||||
})];
|
||||
assert_eq!(
|
||||
TokenUsage::from_gemini_stream_chunks(&gemini)
|
||||
.unwrap()
|
||||
.message_id
|
||||
.as_deref(),
|
||||
Some("gemini_123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_response_parsing() {
|
||||
let response = json!({
|
||||
|
||||
@@ -88,6 +88,7 @@ impl ConfigService {
|
||||
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
|
||||
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
|
||||
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
|
||||
Self::sync_current_provider_for_app(config, &AppType::GrokBuild)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -125,6 +126,7 @@ impl ConfigService {
|
||||
// Claude Desktop 3P profiles are managed by claude_desktop_config.
|
||||
}
|
||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
||||
AppType::GrokBuild => crate::grok_config::write_grok_provider_live(&provider)?,
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode, no live sync needed
|
||||
// OpenCode providers are managed directly in the config file
|
||||
|
||||
@@ -37,6 +37,9 @@ impl McpService {
|
||||
if prev_apps.gemini && !server.apps.gemini {
|
||||
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
|
||||
}
|
||||
if prev_apps.grokbuild && !server.apps.grokbuild {
|
||||
Self::remove_server_from_app(state, &server.id, &AppType::GrokBuild)?;
|
||||
}
|
||||
if prev_apps.opencode && !server.apps.opencode {
|
||||
Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;
|
||||
}
|
||||
@@ -122,6 +125,13 @@ impl McpService {
|
||||
AppType::Gemini => {
|
||||
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
mcp::sync_single_server_to_grokbuild(
|
||||
&Default::default(),
|
||||
&server.id,
|
||||
&server.server,
|
||||
)?;
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
mcp::sync_single_server_to_opencode(
|
||||
&Default::default(),
|
||||
@@ -162,6 +172,7 @@ impl McpService {
|
||||
}
|
||||
AppType::Codex => mcp::remove_server_from_codex(id)?,
|
||||
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
|
||||
AppType::GrokBuild => mcp::remove_server_from_grokbuild(id)?,
|
||||
AppType::OpenCode => {
|
||||
mcp::remove_server_from_opencode(id)?;
|
||||
}
|
||||
@@ -393,6 +404,32 @@ impl McpService {
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// 从 Grok Build 的 `[mcp_servers]` 导入 MCP。
|
||||
pub fn import_from_grokbuild(state: &AppState) -> Result<usize, AppError> {
|
||||
let mut temp_config = crate::app_config::MultiAppConfig::default();
|
||||
let count = crate::mcp::import_from_grokbuild(&mut temp_config)?;
|
||||
let mut new_count = 0;
|
||||
|
||||
if count > 0 {
|
||||
if let Some(servers) = &temp_config.mcp.servers {
|
||||
let mut existing = state.db.get_all_mcp_servers()?;
|
||||
for server in servers.values() {
|
||||
let to_save = if let Some(existing_server) = existing.get(&server.id) {
|
||||
let mut merged = existing_server.clone();
|
||||
merged.apps.grokbuild = true;
|
||||
merged
|
||||
} else {
|
||||
new_count += 1;
|
||||
server.clone()
|
||||
};
|
||||
state.db.save_mcp_server(&to_save)?;
|
||||
existing.insert(to_save.id.clone(), to_save);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// 从 OpenCode 导入 MCP(v3.9.2+ 新增)
|
||||
pub fn import_from_opencode(state: &AppState) -> Result<usize, AppError> {
|
||||
// 创建临时 MultiAppConfig 用于导入
|
||||
@@ -479,10 +516,11 @@ impl McpService {
|
||||
let mut total = 0;
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
let results: [(&str, Result<usize, AppError>); 5] = [
|
||||
let results: [(&str, Result<usize, AppError>); 6] = [
|
||||
("claude", Self::import_from_claude(state)),
|
||||
("codex", Self::import_from_codex(state)),
|
||||
("gemini", Self::import_from_gemini(state)),
|
||||
("grokbuild", Self::import_from_grokbuild(state)),
|
||||
("opencode", Self::import_from_opencode(state)),
|
||||
("hermes", Self::import_from_hermes(state)),
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -65,9 +65,13 @@ pub async fn fetch_models(
|
||||
let candidates = build_models_url_candidates(base_url, is_full_url, models_url_override)?;
|
||||
let client = crate::proxy::http_client::get();
|
||||
let mut last_err: Option<String> = None;
|
||||
let log_secrets = vec![api_key.to_string()];
|
||||
|
||||
for url in &candidates {
|
||||
log::debug!("[ModelFetch] Trying endpoint: {url}");
|
||||
log::debug!(
|
||||
"[ModelFetch] Trying endpoint: {}",
|
||||
crate::url_for_log_with_secrets(url, &log_secrets)
|
||||
);
|
||||
let mut request = client
|
||||
.get(url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
|
||||
@@ -523,7 +523,11 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet:
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => false,
|
||||
AppType::GrokBuild
|
||||
| AppType::OpenCode
|
||||
| AppType::OpenClaw
|
||||
| AppType::Hermes
|
||||
| AppType::ClaudeDesktop => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,9 +597,11 @@ pub(crate) fn remove_common_config_from_settings(
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
|
||||
Ok(settings.clone())
|
||||
}
|
||||
AppType::GrokBuild
|
||||
| AppType::OpenCode
|
||||
| AppType::OpenClaw
|
||||
| AppType::Hermes
|
||||
| AppType::ClaudeDesktop => Ok(settings.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -650,9 +656,11 @@ fn apply_common_config_to_settings(
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
|
||||
Ok(settings.clone())
|
||||
}
|
||||
AppType::GrokBuild
|
||||
| AppType::OpenCode
|
||||
| AppType::OpenClaw
|
||||
| AppType::Hermes
|
||||
| AppType::ClaudeDesktop => Ok(settings.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -825,6 +833,16 @@ fn restore_live_settings_for_provider_backfill(
|
||||
strip_injected_kimi_for_coding_context_defaults(&mut settings, provider);
|
||||
return settings;
|
||||
}
|
||||
if matches!(app_type, AppType::GrokBuild) {
|
||||
let mut settings = live_settings;
|
||||
if let Err(err) = crate::grok_config::strip_grok_mcp_servers_from_settings(&mut settings) {
|
||||
log::warn!(
|
||||
"Failed to strip Grok Build mcp_servers while backfilling '{}': {err}",
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
if !matches!(app_type, AppType::Codex) {
|
||||
return live_settings;
|
||||
}
|
||||
@@ -1037,6 +1055,9 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
// Delegate to write_gemini_live which handles env file writing correctly
|
||||
write_gemini_live(provider)?;
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
crate::grok_config::write_grok_provider_live(provider)?;
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode - write provider to config
|
||||
use crate::opencode_config;
|
||||
@@ -1367,6 +1388,7 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
let config = read_opencode_config()?;
|
||||
Ok(config)
|
||||
}
|
||||
AppType::GrokBuild => crate::grok_config::read_grok_live_settings(),
|
||||
AppType::OpenClaw => {
|
||||
use crate::openclaw_config::{get_openclaw_config_path, read_openclaw_config};
|
||||
|
||||
@@ -1435,6 +1457,21 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
|
||||
let settings_config = match app_type {
|
||||
AppType::Codex => crate::codex_config::read_codex_live_settings()?,
|
||||
AppType::GrokBuild => {
|
||||
let mut settings = crate::grok_config::read_grok_live_settings()?;
|
||||
let config = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
// 官方登录态(无自定义模型表)在这里必须报错:本函数也被启动
|
||||
// 自动导入调用,而全项目惯例是"启动自动导入只产出 default,
|
||||
// 从不产出官方条目"——否则删掉的官方条目每次重启都会复活。
|
||||
// 官方态的成功导入(补官方条目并激活)只挂在手动导入的命令层
|
||||
// (`import_default_config_internal`)。
|
||||
crate::grok_config::validate_config_toml(config)?;
|
||||
crate::grok_config::strip_grok_mcp_servers_from_settings(&mut settings)?;
|
||||
settings
|
||||
}
|
||||
AppType::Claude => {
|
||||
let settings_path = get_claude_settings_path();
|
||||
if !settings_path.exists() {
|
||||
@@ -1533,6 +1570,20 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||
crate::settings::set_current_provider(&app_type, Some(provider.id.as_str()))?;
|
||||
|
||||
// 初次导入已有配置时随手补出官方入口,对齐其它应用"首启动 = 导入 default
|
||||
// + 播种官方条目"的观感。grokbuild 种子晚于 `official_providers_seeded`
|
||||
// flag 引入,存量库的主播种不会再跑,只能挂在导入动作上补。
|
||||
// 只在导入成功时执行;live 完全不可导入(文件缺失/语法错误/残缺配置)
|
||||
// 不会到达这里。失败只 warn。
|
||||
if matches!(app_type, AppType::GrokBuild) {
|
||||
if let Err(e) = state.db.ensure_official_seed_by_id(
|
||||
crate::database::GROKBUILD_OFFICIAL_PROVIDER_ID,
|
||||
AppType::GrokBuild,
|
||||
) {
|
||||
log::warn!("Failed to ensure grokbuild-official seed after import: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true) // 真正导入了
|
||||
}
|
||||
|
||||
@@ -2536,4 +2587,32 @@ base_url = "https://a.example/v1"
|
||||
"non-MCP content must survive the strip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_switch_backfill_strips_synced_mcp_servers() {
|
||||
let provider = Provider::with_id(
|
||||
"grok".to_string(),
|
||||
"Grok".to_string(),
|
||||
json!({
|
||||
"config": "[models]\ndefault = \"grok-4.5\"\n\n[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nbase_url = \"https://example.com/v1\"\nname = \"Example\"\napi_key = \"secret\"\napi_backend = \"responses\"\ncontext_window = 500000\n"
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let live_settings = json!({
|
||||
"config": "[models]\ndefault = \"grok-4.5\"\n\n[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nbase_url = \"https://example.com/v1\"\nname = \"Example\"\napi_key = \"secret\"\napi_backend = \"responses\"\ncontext_window = 500000\n\n[mcp_servers.echo]\ncommand = \"echo\"\n"
|
||||
});
|
||||
|
||||
let result = restore_live_settings_for_provider_backfill(
|
||||
&AppType::GrokBuild,
|
||||
&provider,
|
||||
live_settings,
|
||||
);
|
||||
let config_text = result
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.expect("config text");
|
||||
|
||||
assert!(!config_text.contains("mcp_servers"));
|
||||
assert!(config_text.contains("model = \"grok-4.5\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2527,14 +2527,16 @@ impl ProviderService {
|
||||
// restore backup. Serialize them per app, then decide from the locked
|
||||
// current state so a just-started takeover cannot be overwritten by a
|
||||
// normal live write.
|
||||
let _switch_guard =
|
||||
if matches!(app_type, AppType::Claude | AppType::Codex | AppType::Gemini) {
|
||||
Some(futures::executor::block_on(
|
||||
state.proxy_service.lock_switch_for_app(app_type.as_str()),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _switch_guard = if matches!(
|
||||
app_type,
|
||||
AppType::Claude | AppType::Codex | AppType::Gemini | AppType::GrokBuild
|
||||
) {
|
||||
Some(futures::executor::block_on(
|
||||
state.proxy_service.lock_switch_for_app(app_type.as_str()),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Backup or live placeholders mean the live file is owned by proxy
|
||||
// takeover, even if the proxy server is temporarily stopped or is in the
|
||||
@@ -2993,6 +2995,7 @@ impl ProviderService {
|
||||
AppType::ClaudeDesktop => Ok(String::new()),
|
||||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
||||
AppType::GrokBuild => Ok(String::new()),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
||||
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
|
||||
AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets
|
||||
@@ -3009,6 +3012,7 @@ impl ProviderService {
|
||||
AppType::ClaudeDesktop => Ok(String::new()),
|
||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
||||
AppType::GrokBuild => Ok(String::new()),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
||||
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
|
||||
AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets
|
||||
@@ -3479,6 +3483,32 @@ impl ProviderService {
|
||||
use crate::gemini_config::validate_gemini_settings;
|
||||
validate_gemini_settings(&provider.settings_config)?
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.settings.not_object",
|
||||
"Grok Build 配置必须是 JSON 对象",
|
||||
"Grok Build configuration must be a JSON object",
|
||||
)
|
||||
})?;
|
||||
let config = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.missing",
|
||||
"Grok Build 配置缺少 config 字段",
|
||||
"Grok Build configuration is missing the config field",
|
||||
)
|
||||
})?;
|
||||
if provider.category.as_deref() == Some("official") {
|
||||
// 官方条目走 Grok CLI 自带 OAuth:空 config 合法,
|
||||
// 回填快照只要求 TOML 语法合法。
|
||||
crate::grok_config::validate_config_toml_syntax(config)?;
|
||||
} else {
|
||||
crate::grok_config::validate_config_toml(config)?;
|
||||
}
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses a different config structure: { npm, options, models }
|
||||
// Basic validation - must be an object
|
||||
@@ -3575,6 +3605,28 @@ impl ProviderService {
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
let config_toml = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.config.missing",
|
||||
"Grok Build 配置缺少 config 字段",
|
||||
"Grok Build configuration is missing the config field",
|
||||
)
|
||||
})?;
|
||||
let (base_url, api_key) = crate::grok_config::extract_credentials(config_toml)
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.grokbuild.credentials.missing",
|
||||
"Grok Build 配置缺少 Base URL 或 API Key",
|
||||
"Grok Build configuration is missing the base URL or API key",
|
||||
)
|
||||
})?;
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::ClaudeDesktop => {
|
||||
let credentials =
|
||||
crate::claude_desktop_config::direct_gateway_credentials(provider)?;
|
||||
|
||||
+751
-47
File diff suppressed because it is too large
Load Diff
@@ -22,18 +22,85 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// 同步结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionSyncResult {
|
||||
pub imported: u32,
|
||||
pub skipped: u32,
|
||||
pub files_scanned: u32,
|
||||
pub suspected_duplicates: u32,
|
||||
pub deferred_files: u32,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
impl SessionSyncResult {
|
||||
pub fn merge(&mut self, other: SessionSyncResult) {
|
||||
self.imported = self.imported.saturating_add(other.imported);
|
||||
self.skipped = self.skipped.saturating_add(other.skipped);
|
||||
self.files_scanned = self.files_scanned.saturating_add(other.files_scanned);
|
||||
self.suspected_duplicates = self
|
||||
.suspected_duplicates
|
||||
.saturating_add(other.suspected_duplicates);
|
||||
self.deferred_files = self.deferred_files.saturating_add(other.deferred_files);
|
||||
self.errors.extend(other.errors);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_sync_mutex() -> &'static tokio::sync::Mutex<()> {
|
||||
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
|
||||
}
|
||||
|
||||
fn merge_sync_step(
|
||||
aggregate: &mut SessionSyncResult,
|
||||
name: &str,
|
||||
step: Result<SessionSyncResult, AppError>,
|
||||
) {
|
||||
match step {
|
||||
Ok(result) => aggregate.merge(result),
|
||||
Err(error) => aggregate.errors.push(format!("{name} 同步失败: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 调用方必须持有 [`session_sync_mutex`]。此函数是同步内核,供后台任务、
|
||||
/// 手动同步和 Codex 重建共享,避免 tokio Mutex 重入。
|
||||
pub fn sync_all_unlocked(db: &Database) -> SessionSyncResult {
|
||||
let mut result = SessionSyncResult::default();
|
||||
merge_sync_step(&mut result, "Claude", sync_claude_session_logs(db));
|
||||
merge_sync_step(
|
||||
&mut result,
|
||||
"Codex",
|
||||
crate::services::session_usage_codex::sync_codex_usage(db),
|
||||
);
|
||||
merge_sync_step(
|
||||
&mut result,
|
||||
"Gemini",
|
||||
crate::services::session_usage_gemini::sync_gemini_usage(db),
|
||||
);
|
||||
merge_sync_step(
|
||||
&mut result,
|
||||
"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
|
||||
}
|
||||
|
||||
pub(crate) fn notify_sync_result(result: &SessionSyncResult) {
|
||||
if result.imported > 0 {
|
||||
crate::usage_events::notify_log_recorded();
|
||||
}
|
||||
}
|
||||
|
||||
/// 数据来源分布
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -65,6 +132,8 @@ pub fn sync_claude_session_logs(db: &Database) -> Result<SessionSyncResult, AppE
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
files_scanned: 0,
|
||||
suspected_duplicates: 0,
|
||||
deferred_files: 0,
|
||||
errors: vec![],
|
||||
});
|
||||
}
|
||||
@@ -73,6 +142,8 @@ pub fn sync_claude_session_logs(db: &Database) -> Result<SessionSyncResult, AppE
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
files_scanned: 0,
|
||||
suspected_duplicates: 0,
|
||||
deferred_files: 0,
|
||||
errors: vec![],
|
||||
};
|
||||
|
||||
@@ -513,12 +584,7 @@ fn insert_session_log_entry(
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入会话日志失败: {e}")))?;
|
||||
|
||||
// 仅在确实写入新行时通知前端,避免 INSERT OR IGNORE 跳过时产生空刷新
|
||||
if inserted_rows > 0 {
|
||||
crate::usage_events::notify_log_recorded();
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
Ok(inserted_rows > 0)
|
||||
}
|
||||
|
||||
/// 从 model_pricing 表查找模型定价(支持模糊匹配)
|
||||
@@ -565,6 +631,26 @@ pub fn get_data_source_breakdown(db: &Database) -> Result<Vec<DataSourceSummary>
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sync_result_notification_is_coalesced_to_one_call() {
|
||||
crate::usage_events::take_test_notify_count();
|
||||
notify_sync_result(&SessionSyncResult::default());
|
||||
let result = SessionSyncResult {
|
||||
imported: 25,
|
||||
..SessionSyncResult::default()
|
||||
};
|
||||
notify_sync_result(&result);
|
||||
assert_eq!(crate::usage_events::take_test_notify_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_sync_mutex_serializes_callers() {
|
||||
let first = session_sync_mutex().lock().await;
|
||||
assert!(session_sync_mutex().try_lock().is_err());
|
||||
drop(first);
|
||||
assert!(session_sync_mutex().try_lock().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_usage_from_jsonl_line() {
|
||||
let line = r#"{"type":"assistant","message":{"id":"msg_test123","model":"claude-opus-4-6","usage":{"input_tokens":3,"output_tokens":150,"cache_read_input_tokens":5000,"cache_creation_input_tokens":10000},"stop_reason":"end_turn"},"timestamp":"2026-04-05T12:00:00Z","sessionId":"session-abc"}"#;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user