diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..14198182c --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,14 @@ +# Code owners for cc-switch +# +# Branch protection on `main` has "Require review from Code Owners" enabled. +# With this file present, a PR cannot merge to `main` without an approval from +# the owner below. This closes the gap where two collaborators could approve +# each other's malicious PRs (a single non-owner approval is no longer enough). + +# Global fallback: every PR needs owner approval before merging to main. +* @farion1231 + +# Most sensitive paths — CI/signing/release and the Rust backend. Listed +# explicitly so they stay locked even if the global rule above is relaxed later. +/.github/ @farion1231 +/src-tauri/ @farion1231 diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..1e19b96b3 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,74 @@ +# Configuration for actions/labeler v5 +# Automatically labels PRs based on changed file paths + +frontend: + - changed-files: + - any-glob-to-any-file: + - "src/**" + - "index.html" + - "vite.config.ts" + - "vitest.config.ts" + - "tailwind.config.cjs" + - "postcss.config.cjs" + - "tsconfig.json" + - "tsconfig.node.json" + - "components.json" + - "tests/**" + +backend: + - changed-files: + - any-glob-to-any-file: + - "src-tauri/**" + +i18n: + - changed-files: + - any-glob-to-any-file: + - "src/locales/**" + +actions: + - changed-files: + - any-glob-to-any-file: + - ".github/**" + +dependencies: + - changed-files: + - any-glob-to-any-file: + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "src-tauri/Cargo.toml" + - "src-tauri/Cargo.lock" + +documentation: + - changed-files: + - any-glob-to-any-file: + - "*.md" + - "docs/**" + - "LICENSE" + +mcp: + - changed-files: + - any-glob-to-any-file: + - "src/components/mcp/**" + - "src/lib/api/mcp.ts" + - "src-tauri/src/mcp/**" + - "src-tauri/src/claude_mcp.rs" + - "src-tauri/src/gemini_mcp.rs" + - "src-tauri/src/commands/mcp.rs" + +skills: + - changed-files: + - any-glob-to-any-file: + - "src/components/skills/**" + - "src/lib/api/skills.ts" + - "src-tauri/src/commands/skill.rs" + - "src-tauri/src/services/skill.rs" + - "src-tauri/src/database/dao/skills.rs" + +proxy: + - changed-files: + - any-glob-to-any-file: + - "src/components/proxy/**" + - "src/lib/api/proxy.ts" + - "src-tauri/src/proxy/**" + - "src-tauri/src/commands/proxy.rs" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33dab3f0b..09e180adb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,11 @@ jobs: backend: name: Backend Checks - runs-on: ubuntu-22.04 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, windows-latest, macos-latest] steps: - name: Checkout uses: actions/checkout@v6 @@ -66,6 +70,7 @@ jobs: components: rustfmt, clippy - name: Install Linux system deps + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ @@ -87,6 +92,7 @@ jobs: restore-keys: ${{ runner.os }}-cargo- - name: Create frontend dist placeholder + shell: bash run: mkdir -p dist - name: Check Rust formatting diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 000000000..b8c91803c --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,17 @@ +name: Label PRs + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v5 + with: + sync-labels: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 703fc5f34..dde229900 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,7 @@ concurrency: jobs: release: runs-on: ${{ matrix.os }} + environment: release strategy: fail-fast: false matrix: diff --git a/.gitignore b/.gitignore index 49f529856..525d4a7b4 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,4 @@ flatpak-repo/ copilot-api .history CODEBUDDY.md -.github mainWindow.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a2aa1140..292b4daa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,124 @@ All notable changes to CC Switch will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.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). + +**Stats**: 69 commits | 172 files changed | +21,067 insertions | -2,464 deletions + +### Added + +- **Project Profiles for One-Click, Snapshot-Based Config Switching**: You can now save the current provider, MCP, Skills, and prompt state as a named "project" and re-apply it in one click from a new header switcher or the tray Projects submenu, so moving between per-project setups no longer means toggling each dimension by hand; it covers Claude Code, Claude Desktop, and Codex (Claude Desktop's only cc-switch-managed dimension is its provider, so its snapshots capture just that and leave the other dimensions untouched on apply). Projects are global shared entities, but capture and switching are per scope — Claude Code, Claude Desktop, and Codex each keep their own `current_profile_id_` marker and only touch their own payload slots, so picking a project on the Codex tab never disturbs the Claude config (slots use `Option` to distinguish "never captured" from "captured empty", preventing cross-side accidental disable). Applying is best-effort: `ProfileService::apply` (in `services/profile.rs`) reuses the existing switch primitives — provider switch, then minimal MCP/Skills toggle diffs, then prompt enable — and any dangling reference or per-item failure becomes a warning instead of rolling back. Switching first autosaves the leaving project's current state for the active scope, so a project always holds the configuration you last left it in, which is why there is no manual "update snapshot from current state" button. Before applying, proxy takeover is unconditionally disabled per app in the scope, the frontend invalidates takeover status, and the proxy server is stopped when the switch leaves no takeovers active so Claude Desktop's local-route master toggle reads off. Backed by a new `profiles` table introduced by the v11→v12 schema migration, wired into `commands/profile.rs`, `database/dao/profiles.rs`, the tray, and `components/profiles/`, localized in all four locales (zh/en/ja/zh-TW), with integration tests covering roundtrip, dangling refs, bidirectional autosave, and proxy-takeover teardown. +- **Setting to Show or Hide the Project Switcher on the Main Page**: The header project switcher can now be turned off from a new "Show project switcher" toggle under the Homepage Display section of Settings, for users who don't use projects and prefer a cleaner header. It defaults to on (`show_profile_switcher` / `showProfileSwitcher`) so existing users keep the current behavior, and disabling it only hides the header control on every tab — projects and the tray Projects submenu remain fully usable. Localized in all four locales. +- **Route Codex Official ChatGPT Sessions Through Proxy Takeover**: CC Switch can now route a Codex session authenticated with a ChatGPT subscription (native OAuth or API-key login, no stored API key) through the local proxy, so official-account traffic gets the same proxy routing, format conversion, and usage accounting that third-party providers already receive. The built-in `codex-official` "OpenAI Official" seed is restored if it was deleted (idempotent `ensure_codex_official_provider` command wired into the add-provider flow) and is now selectable during takeover from the provider panel and tray, with the provider card badge reading "Official Account Routing" while routing and "Codex Sign-in" otherwise; only that fixed seed qualifies (`is_codex_official_provider` / `official_provider_supports_proxy_takeover`, mirrored in the frontend `supportsOfficialProxyTakeover`), so copied UUID-based official entries stay blocked and still show "No Routing Support". Instead of writing an `OPENAI_API_KEY = PROXY_MANAGED` placeholder into `auth.json`, the official route projects a dedicated `cc-switch-official` `model_provider` into `config.toml` with `requires_openai_auth = true`, `wire_api = "responses"`, `supports_websockets = false`, and `base_url` pointing at the local proxy (`apply_codex_official_proxy_route`), so Codex forwards its own ChatGPT authorization to the proxy's `/responses` endpoint and the `codex-official` row never stores a credential (its `auth` stays `{}`). The forwarder passes the client's `Authorization` header through unchanged to the fixed first-party endpoint (`CHATGPT_CODEX_BASE_URL`, now shared by the Codex and Claude adapters), rejects a missing header or a stale `PROXY_MANAGED` placeholder with an actionable error (finish the ChatGPT login / restart Codex or start a new session), and treats official `401`/`403` and auth errors as non-retryable so failover never silently moves the conversation to another account or pollutes the circuit breaker. The native login is never overwritten — takeover preserves OAuth or API-key material into the backup (falling back to live `auth.json` when the backup is missing), config transforms in `codex_config.rs`/`services/proxy.rs` now fail closed instead of swallowing errors, and stale managed placeholders left in other provider tables are cleaned; the `preserveCodexOfficialAuthOnSwitch` setting copy was updated to clarify that takeover routing always preserves the official login and the toggle now only governs direct (non-routing) third-party switches. +- **GPT-5.6 Context Window for Claude Code Codex Takeover**: When Claude Code is routed to a ChatGPT Codex (Codex OAuth) backend via proxy takeover, CC Switch now injects `CLAUDE_CODE_MAX_CONTEXT_TOKENS` and `CLAUDE_CODE_AUTO_COMPACT_WINDOW` set to `372000` into the effective live `settings.json`, so Claude Code stops assuming its default 200K window for the unrecognized GPT model id and auto-compacts before the upstream rejects an oversized prompt. 372000 matches the ChatGPT Codex catalog window for gpt-5.6 — the catalog lists a ~353K effective budget, not the 1.05M API spec — and Claude Code's built-in output reserve and compact buffer then keep the actual compact trigger below that budget. The defaults are gated on every configured model env key (`ANTHROPIC_MODEL`, the haiku/sonnet/opus/fable defaults, and `CLAUDE_CODE_SUBAGENT_MODEL`) starting with `gpt-5.6`: gpt-5.5's upstream catalog oscillates between 272K and 372K and must not inherit them; any non-gpt-5.6 or mixed mapping is skipped (and any value a legacy shared snippet dragged in is stripped), while an explicit user value always wins. The Codex OAuth presets for both Claude Code and Claude Desktop bump their default routes to the gpt-5.6 family (haiku → `gpt-5.6-luna`, main/sonnet/opus → `gpt-5.6`), the custom Codex `config.toml` template default model moves to gpt-5.6, and the Claude Code preset ships both context env keys pinned at 372000 (see the corresponding Changed entry on preset-pinned context windows). On switch-away backfill the injected values are stripped as the mirror-inverse of the injection conditions so program defaults never harden into per-provider explicit values, and both keys are held out of the shared Claude common-config snippet (`services/provider/mod.rs` strip list plus a guard in `live.rs`) because context limits must follow the actual upstream model; six Rust integration tests cover injection, user overrides, legacy-snippet strip, non-gpt5.6 skip, and the backfill round-trip. (openai/codex#31860) +- **GPT-5.6 Sol/Terra/Luna Pricing With 1.25x Cache-Write Rate**: The usage dashboard now costs GPT-5.6 traffic using seeded pricing for the three tiers — Sol at 5 / 30 / 0.50, Terra at 2.50 / 15 / 0.25, and Luna at 1 / 6 / 0.10 USD per million input / output / cache-read tokens — cross-checked against OpenAI's pricing page and OpenRouter. Unlike GPT-5.5 and earlier, whose cache writes are seeded at zero, the 5.6 family bills prompt-cache writes at 1.25x the uncached input rate, so cache-write is seeded at 6.25 / 3.125 / 1.25 for Sol / Terra / Luna. The seed set also adds the bare `gpt-5.6` id as the official Sol alias plus effort-suffix variants (`-low`, `-medium`, `-high`, `-xhigh`, `-minimal`), all priced at Sol rates and mirroring the gpt-5.5 accounting shape. Rows are applied through the seed+repair dual-write path in `src-tauri/src/database/schema.rs`: a `repair_current_model_pricing` branch corrects the earlier zero cache-write seeds for the three tiers in existing databases, matching only rows still holding the exact old input/output/cache-read/cache-write values so user-customized prices survive, and no `SCHEMA_VERSION` bump is required. +- **Native Anthropic Messages Protocol as Codex Upstream**: Codex providers can now target a gateway that only exposes the native Anthropic Messages protocol (`/v1/messages`) via a new `anthropic` value on the upstream-format selector in the Codex form; the local proxy performs bidirectional Responses↔Anthropic request, response, and streaming conversion (new `transform_codex_anthropic` / `streaming_codex_anthropic` modules). The form adds an auth-field selector — `ANTHROPIC_AUTH_TOKEN` sends `Authorization: Bearer` (default) and `ANTHROPIC_API_KEY` sends `x-api-key`, mutually exclusive — an optional Claude Code client-impersonation toggle (`impersonateClaudeCode`, off unless explicitly enabled, spoofing User-Agent / `anthropic-beta` / `x-app` / system-prompt first line and dropping Codex/OpenAI fingerprint headers), and a per-provider `maxOutputTokens` override (Codex omits `model_max_output_tokens`, so without it the path falls back to a conservative 8192 that can truncate long or thinking-heavy replies). The bridge injects standard 5-minute ephemeral `cache_control` so system/tools/history are cached rather than resent at full price, defers stripping the `[1m]` long-context marker until after catalog matching and re-emits the `context-1m-2025-08-07` beta header, gates extended thinking on the trailing turn only so history-resending sessions don't permanently lose it after the first tool call, disables native `web_search` for this profile, drops `tool_choice` when no tools survive filtering, treats a base URL already ending in `/v1/messages` as a full endpoint, and reports truncated streams as incomplete/failed instead of completed. (#5071) +- **Default Model Field for Codex Provider Form**: The Codex provider form now exposes the top-level `model` key of `config.toml` as an editable field, so users can point an existing provider at a newly released model (e.g. `gpt-5.6`) without waiting for a preset update — preset changes only affect newly added providers, while existing ones keep their saved TOML. The field syncs bidirectionally with the TOML editor (mirroring the base-URL pattern), suggests models from the mapping catalog unioned with the provider's `/models` endpoint, offers a one-click "add to mapping" action when the value falls outside a non-empty catalog, and is hidden for official providers. Save-time catalog sync now backfills the first mapping row into the top-level `model` only when the field was left empty, so an explicit value always wins over the implicit row-0 fallback; model names and `base_url` are written with TOML basic-string escaping and control characters are stripped from field input, since `/models` ids are remote data and unescaped interpolation could inject `config.toml` lines such as a forged `[mcp_servers.*]`. The strict model-line matcher (`src/utils/providerConfigUtils.ts`) recognizes escaped output, empty strings, and single-quoted literals to keep extract/set round-trips stable, and the fetched model list is invalidated (with an in-flight sequence guard) whenever request identity changes — base URL, full-URL toggle, API key, or custom User-Agent — so the dropdown never shows a previous provider's models. +- **Switch-Time Common-Config Autosync Now Covers Codex**: When switching away from a Codex provider with `common_config_enabled`, the service now re-extracts the shareable portion of its live `config.toml` into the stored common-config snippet — the same live-to-snippet sync that previously ran only for Claude — so preferences you tweak directly in the running Codex config propagate to every opted-in provider and a removed key isn't silently re-injected on the next switch. Codex was gated out before because its TOML extractor leaked provider-specific and injected content; `extract_codex_common_config` now strips top-level `model`, `model_provider`, `base_url`, and `wire_api`, the entire `[model_providers]` table, `mcp_servers` and the legacy `[mcp.servers]` form, the top-level `experimental_bearer_token` fallback (which would otherwise leak the API key into the shared snippet), the `model_catalog_json` catalog pointer, and the injected `web_search = "disabled"` sentinel, while keeping a user-set `web_search` value as a shareable preference. The sync in `services/provider/mod.rs` is scoped strictly to Claude and Codex (Gemini is still excluded), skipped when the snippet was explicitly cleared, and all failures are warn-only and never block the switch; the autosync-before-strip ordering also self-heals stale snippet values previously baked into provider snapshots, since the re-extracted snippet matches the live values and the value-match strip removes them on the same switch. +- **Claude Subagent Model Configuration**: Claude providers can now pin a dedicated sub-agent model through a new "Subagent" row in the provider form, writing the `CLAUDE_CODE_SUBAGENT_MODEL` env key so Claude Code's spawned sub-agents run on a chosen (typically cheaper or faster) model. The row supports the `[1M]` marker but has no display-name field — it renders a "Not shown in /model" placeholder since the sub-agent model never appears in the `/model` menu — and the "quick set" button now fills it alongside the other tiers. On the proxy takeover path (`services/proxy.rs`), `CLAUDE_CODE_SUBAGENT_MODEL` is added to `CLAUDE_MODEL_OVERRIDE_ENV_KEYS` so it is written when a provider configures it and stale values are cleared when a provider omits it, and the model mapper (`proxy/model_mapper.rs`) gains a `subagent_model` field that passes a request through unchanged when its model (comparing with the 1M suffix stripped) matches the configured sub-agent model, instead of collapsing it to the default-model fallback. The key is also excluded from the shared Claude common-config snippet in `services/provider/mod.rs` so it can't leak across providers, and `modelRoleSubagent` / `modelNoDisplayName` labels were added in all four locales (zh/en/ja/zh-TW). (#4830) +- **1M Context Checkbox on the Claude Fallback Model Field**: The Claude provider form's fallback model field (`ANTHROPIC_MODEL`, the "default/fallback model") now carries the same 1M checkbox that the role-specific Sonnet/Opus/Fable rows already had, so a fallback model backed by a 1M-token window can declare it instead of silently being treated as 200K. Checking the box appends the `[1M]` marker to the fallback model id and unchecking strips it, reusing the shared `hasClaudeOneMMarker` / `setClaudeOneMMarker` / `stripClaudeOneMMarker` helpers, and the checkbox is a no-op when the model field is empty. The marker is written in the documented uppercase form (`[1M]`), which Claude Code parses case-insensitively. This is a frontend-only change to `ClaudeFormFields.tsx`. (#5124, fixes #3679) +- **Zhipu (智谱) Team Plan Quota Query Support**: Added quota-query support for Zhipu's team plan (团队套餐), which the personal-plan query could not reach: it hits the same `open.bigmodel.cn` quota endpoint with `?type=2` and requires two extra request headers, `bigmodel-organization` and `bigmodel-project`, so `detect_provider` cannot tell it apart from the personal plan by base URL alone. The usage-script modal gains a "Zhipu GLM Team" template with organization-ID and project-ID inputs (preserved when switching templates), and the backend routes on an explicit `coding_plan_provider == "zhipu_team"` identifier threaded through the usage script, IPC command, and background query path; the new `query_zhipu_team` in `services/coding_plan.rs` pins the CN site, appends `?type=2`, and shares response parsing (`zhipu_quota_from_body` / `parse_zhipu_token_tiers`) with the personal plan. All three of API key + organization ID + project ID are required — missing any one returns a NotFound that prompts the user to complete them (identifier match is case-insensitive) — and new copy was added across all four locales (zh/en/ja/zh-TW). (#5128) +- **OpenCode Provider Form Gains Headers and Per-Model Token-Limit Editors**: The OpenCode provider form now exposes two previously unreachable config fields as structured editors: a Headers section for the provider's `options.headers` (arbitrary HTTP headers like `HTTP-Referer` or `X-Title` sent with provider requests) with add/remove rows and case-insensitive duplicate-name rejection that reverts the input on conflict, and per-model Token Limits (Context and Output number inputs writing `model.limit.context` / `model.limit.output`, where clearing the field removes it and negative or non-finite values are rejected while valid ones are truncated to integers). The Extra Options block was reworked into a collapsible section (its i18n label stays "Extra Options") that auto-expands when options already exist. Both the headers and extra-options editors switched their placeholder draft keys to colon-bearing prefixes (`draft-header:` / `draft-option:`) — a code comment notes a colon is invalid in an HTTP field name so a draft key cannot collide with a legitimate header, and the same technique now guards option keys — and those drafts are stripped on save, fixing a bug where the old filter discarded any real option key literally starting with `option-` (the former placeholder prefix). New i18n keys were added across all four locales (zh/en/ja/zh-TW), and `aria-label`s were added for the header add/remove and model-details toggle controls in `OpenCodeFormFields.tsx`; the headers state and draft-stripping logic live in `useOpencodeFormState.ts`. (#2907) +- **Tencent Hunyuan Hy3 Model Pricing**: Seeded pricing for Tencent's Hunyuan Hy3 (released 2026-07-06, 256K context) so its usage is billed instead of showing $0. The rows are added to `seed_model_pricing` in `schema.rs` under both the `hunyuan-hy3` and `hy3` ids, since the upstream billing id isn't yet confirmed and one of the two should match logged usage. Prices follow Tencent's launch-day list rate (CNY 1 / 4 / 0.25 per Mtok input / output / cache-hit), converted at 1 USD ≈ 7.14 to $0.14 / $0.56 / $0.035, with `cache_write` left at `0` because no write rate is published. Hy3 actually uses input-length tiered billing (<16K / 16-32K / ≥32K) but the single-price table holds the lowest tier, so long-context requests are under-billed until this is revisited against the official billing page. Being a brand-new model it is seed-only, applied on next app start via `ensure_model_pricing_seeded` with no `SCHEMA_VERSION` bump. + +### Changed + +- **Codex Chat Prompt-Cache Routing**: Added provider-aware `prompt_cache_key` injection on the Codex Responses→Chat Completions bridge. Kimi Coding and OpenAI official endpoints are enabled automatically, Kimi's preset opts in explicitly, and unknown OpenAI-compatible gateways remain off to avoid strict-schema 400s. The key is taken from an explicit client value or a real client-provided session ID—never a generated per-request UUID—with an advanced Auto/Enabled/Disabled override in all four UI locales. +- **Provider Connectivity Configuration Simplified**: Removed the obsolete per-provider `testConfig` override (timeout, retry count, and degraded-latency threshold) from provider forms, frontend/backend provider metadata, and reachability-check merging. The lightweight `base_url` probe now always uses the single global connectivity-check configuration, while automatic failover remains driven exclusively by its separate proxy timeout and circuit-breaker settings. Also renamed the remaining settings UI and API modules from model-test terminology to connectivity-check terminology, removed the retired first-use confirmation flag, and deleted stale model/prompt/error copy across all four locales. +- **Codex Image Capabilities Are Inferred Without a User Toggle**: Generated Codex model catalogs now advertise only models in CC Switch's confirmed, exact text-only registry as `input_modalities = ["text"]`; GPT, aliases, new suffix variants, and all unknown models fail open to `["text", "image"]`. The rectifier's “Text-Only Model Preflight” switch continues to control only proactive proxy request-body replacement and does not change Codex's catalog declaration. Live catalog reverse-import also collapses inferred modalities instead of persisting them as hidden row overrides, so a later registry correction or a model's multimodal upgrade takes effect automatically; only declarations that differ from inference survive a DB-missing/import round-trip. +- **Context Window Values Pinned in Presets Instead of Editable Form Fields**: The `Codex` (ChatGPT/GPT-5.6) and `Kimi For Coding` presets no longer surface the "Max Context Tokens" and "Auto Compact Window" inputs in the provider form; the numbers are now hardcoded directly in the preset env (`372000`/`372000` for Codex, `262144`/`262144` for Kimi For Coding) instead of being exposed as editable `templateValues`. This drops two fields most users never need to touch while keeping both env keys present on purpose: since Claude Code resolves the compact window as `min(model window, value)`, setting it equal to the declared context window is behavior-neutral today, but pinning it explicitly shields the local compaction trigger against remote-config experiments that dial it down. The rare user who wants different numbers can still edit `CLAUDE_CODE_MAX_CONTEXT_TOKENS` / `CLAUDE_CODE_AUTO_COMPACT_WINDOW` directly in the provider's JSON editor. The change is confined to `claudeProviderPresets.ts` and its tests. +- **Universal Provider Auto-Syncs to Live Configs After Being Added**: Adding a universal (multi-app) provider through the Add Provider dialog now immediately pushes it to its live target configs instead of only saving it to the database, so the cross-app config lands in Claude/Codex/Gemini without a separate manual sync step. After `universalProvidersApi.upsert` succeeds, `AddProviderDialog` calls `universalProvidersApi.sync(provider.id)` and reports the combined outcome: a success toast (`universalProvider.addedAndSynced`) when the sync lands, or a non-blocking warning toast (`universalProvider.addedButSyncFailed`) when the provider was saved but the sync failed — while a save failure still aborts early with the existing error toast (`universalProvider.addFailed`) and never attempts the sync. The now-unused `universalProvider.addSuccess` key was removed from all four locales (zh/en/ja/zh-TW) and a new `universalProvider.addedButSyncFailed` key was added alongside the pre-existing `addedAndSynced` key. (#2811) +- **SudoCode Promoted to Paid Sponsor Across Six Clients**: The existing SudoCode preset — previously a `sudocode.us` provider that collided by name with an unrelated service — is replaced in place by the new paid sponsor SudoCode on `sudocode.chat`, now marked `isPartner` (gold star) with `partnerPromotionKey: "sudocode"` and a four-locale promo blurb (zh/en/ja/zh-TW). The preset spans six clients: Claude Code, Claude Desktop, Codex, OpenCode, OpenClaw, and Hermes; the Gemini entry was removed as outside sponsor scope. All endpoints move to `api.sudocode.chat` (the presets that carry an `endpointCandidates` list — Claude Code, Claude Desktop, Codex — collapse it to that single host, dropping the old `sudocode.run` fallback), and the `apiKeyUrl` points to the attributed `sudocode.chat/register?utm_source=ccswitch&utm_medium=partner` signup while `websiteUrl` moves to `sudocode.chat`. Claude Code and Claude Desktop use direct Anthropic passthrough with no model mapping, whereas Codex/OpenCode/OpenClaw/Hermes default to `gpt-5.6-sol` (replacing the old `gpt-5.5`) — Codex/Hermes/OpenClaw over the native Responses format and OpenCode via `@ai-sdk/openai`. The provider icon is refreshed to the new brand PNG, and the promo copy surfaces that one key drives Claude Opus 4.8 on the Claude apps and GPT-5.6 on Codex plus a CNY ¥10 trial-credit offer. +- **LongCat Presets Updated to LongCat-2.0**: The LongCat presets across Claude Code, Claude Desktop, Codex, Hermes, OpenClaw, and OpenCode now default to `LongCat-2.0` in place of the retired `LongCat-Flash-Chat` / `LongCat-2.0-Preview`, advertising the model's real 1M (1048576) context window and — for the Claude Code preset — a raised `CLAUDE_CODE_MAX_OUTPUT_TOKENS` of 131072 plus an added `ANTHROPIC_SMALL_FAST_MODEL` pin. OpenClaw's and OpenCode's base URL move to the `.../openai/v1` path (Codex and Hermes already used it), and the OpenClaw model entry gains `reasoning: false`, `input: ["text"]`, `maxTokens: 131072`, a bumped `contextWindow` of 1048576, and a new `compat.maxTokensField` (`max_tokens`) hint backed by a new optional `compat` field on the `OpenClawModel` type. Because LongCat-2.0 is text-only, the proxy's media sanitizer allowlist now classifies `longcat-2.0` (case-insensitively, keeping the retired `longcat-flash-chat` name for saved configs) so images pasted into a LongCat-2.0 session are replaced with the unsupported-image marker instead of being forwarded upstream and hard-rejected; a regression test covers the classification. (#4838) +- **Volcengine/Doubao/BytePlus Website Links Restored to Invite Pages**: Reverts the v3.16.5 change (`56248087`) that had pointed the `火山Agentplan`, `DouBaoSeed`, and `BytePlus` presets' `websiteUrl` at their product homepages. Per the commit, the `websiteUrl` for these three presets is intentionally the same ccswitch-attributed campaign/invite link as `apiKeyUrl`, so it is restored across all six app preset files — `火山Agentplan` back to its `activity/codingplan` link, `BytePlus` to its `product/modelark` link, and `DouBaoSeed` to the Ark console API-key page, each carrying the `utm_*` attribution params. The `apiKeyUrl` links were already left intact by the reverted commit and are unchanged. +- **Code0.ai Invite Link Updated to Agent Register URL**: The Code0.ai sponsor's `apiKeyUrl` moves from the `?source=ccswitch` referral to the new agent-register invite link `https://code0.ai/agent/register/B2XHxGjGmRvqgznY`, updated across all seven app presets and the Code0 sponsor rows in all four READMEs (`README.md`, `README_ZH.md`, `README_JA.md`, `README_DE.md`). The bare `code0.ai` API endpoint stays untouched. +- **Dropped the Redundant OpenAI Compatible Preset**: Removed the `OpenAI Compatible` custom-template preset from the OpenCode and OpenClaw preset lists so the picker no longer shows two entries pointing at the same place. It was a duplicate entry point — the built-in `custom` provider flow already exposes an interface-format selector whose default (`@ai-sdk/openai-compatible` / `openai-completions`) seeds a byte-identical starting config. Existing providers are unaffected, since presets only seed form defaults on creation and saved providers store concrete `npm`/`baseUrl`/`apiKey`/`models` values; the `@ai-sdk/openai-compatible` dropdown label is deliberately kept as the format option users pick in the custom flow, not the deleted preset. + +### Fixed + +- **Align Codex OAuth Client Identity to Fix 404s on Newest ChatGPT Models**: The newest ChatGPT-gated subscription models (e.g. `gpt-5.6-luna`) now resolve correctly when routed through the local proxy takeover with an official Codex OAuth account, instead of returning a misleading `404 Model not found` even though the account had access. ChatGPT's Codex backend performs model cohort routing by the `originator`+`version` header pair, and cc-switch previously identified takeover requests as `originator: cc-switch` with no version, landing them in a cohort where `gpt-5.6-luna` resolved to an undeployed internal engine. The `CodexOAuth` auth strategy in `proxy/providers/claude.rs` now sends `originator: codex_cli_rs` paired with `version: 0.144.1`, matching the real Codex CLI and satisfying luna's `minimal_client_version` requirement of `0.144.0`. Both headers must be sent together (dropping either re-triggers the 404), and the `CODEX_OAUTH_CLIENT_VERSION` constant must be bumped whenever a newer target model raises the minimum. A direct HTTP A/B test against the live backend confirmed the old identity 404ed while the aligned identity completed successfully, so no WebSocket transport workaround is required. +- **Fail Closed on Responses Upstream Failures Instead of Returning Empty Replies**: When the proxy bridges an Anthropic-format client (Claude Code / Claude Desktop pointed at a Responses gateway) to an OpenAI Responses upstream, a semantic upstream failure that arrives inside an HTTP 2xx body — a `{status:"failed"|"cancelled"}` object, an `error` envelope, or a `response.failed`/`error` SSE event before any output — is now surfaced as a real error so failover can select a different provider, instead of being converted into a silent, empty `end_turn`. The forwarder validates buffered/JSON bodies (`validate_responses_success_response`) and primes streaming responses up to 256 KB (`validate_responses_stream_start`) while still inside the retry loop, and the streaming converter emits an Anthropic `error` event then ignores every event after a terminal so a late delta cannot synthesize a spurious `message_start`. Streams that end cleanly after partial text now finalize as an explicit `max_tokens`-style incomplete turn with content blocks closed in protocol order, whereas a stream cut off mid tool-call or mid-reasoning (partial JSON or a missing thinking signature) yields a `stream_truncated` error rather than a corrupt turn, and on the Codex→Anthropic direction a truncated tool call is reported `incomplete` instead of `completed`. Gateways that ignore `stream:true` and return a single whole JSON document (even without a JSON content-type) are now recognized and expanded into a full Anthropic SSE lifecycle, and malformed client-supplied history is raised as an `InvalidRequest` — a `NonRetryable` category — so it fails fast instead of retrying across every provider. Touches `forwarder.rs`, `streaming_responses.rs`, `streaming_codex_anthropic.rs`, `transform_responses.rs`, and `transform_codex_anthropic.rs`. +- **Preserve Reasoning, Tool Results, and System Roles Across the Responses/Anthropic Bridge**: Content and token accounting that crosses the Responses↔Anthropic bridge in multi-turn tool loops is now preserved instead of being dropped or corrupted. Encrypted Responses `reasoning` items round-trip losslessly by being carried inside a versioned bridge-owned Anthropic thinking `signature` (or a `redacted_thinking` `data` field, prefixed `ccswitch-openai-reasoning-v1:`) and restored on replay, orphaned reasoning-only assistant turns are discarded so they cannot brick the next request with a missing-following-item error, and the streaming converter now consumes the official `response.reasoning_summary_text.*` / `response.reasoning_text.*` event vocabulary (keeping `response.reasoning.*` as a compatibility alias), tracks concurrent items by stable id/output-index, emits the signature before closing the block, and recovers tool arguments from `*.done` / `output_item.done` events when a gateway skips deltas. Non-streaming Responses→Anthropic tool calls with empty arguments normalize to `{}`, invalid arguments in an incomplete turn degrade to `{}` with a warning, and invalid arguments on a completed response are rejected, while on the Codex→Anthropic request path malformed or non-object arguments raise a non-retryable `InvalidRequest`, incomplete historical tool calls are dropped, and only signed thinking blocks are re-encoded as encrypted reasoning. Structured tool results now keep their `is_error` flag, text, base64/URL images, and PDF/`input_file` documents in both directions instead of collapsing to a canonical JSON string, and historical `system`/`developer` messages are hoisted into Anthropic `system` rather than being silently demoted to user turns. For accounting, usage from a successful upstream request is recorded even when the subsequent conversion fails, and the Codex→Anthropic bridge keeps standard 5-minute prompt caching on by default while honoring the dedicated `cache_injection` sub-switch. Touches `reasoning_bridge.rs`, `transform_responses.rs`, `transform_codex_anthropic.rs`, `streaming_responses.rs`, `handlers.rs`, and `forwarder.rs`. +- **Account for Cache-Write Tokens in Proxy Usage and Cost**: Prompt-cache write tokens are now billed correctly on the proxy usage dashboard. The parser reads cache writes from OpenAI/Codex-style usage details (`input_tokens_details.cache_write_tokens` and `prompt_tokens_details.cache_write_tokens`) and preserves them through every response-usage conversion path — Chat→Responses, Responses→Anthropic, Anthropic→Responses, and OpenAI/Chat→Anthropic (`transform_codex_chat.rs`, `transform_responses.rs`, `transform_codex_anthropic.rs`, `transform.rs`, `streaming.rs`) — so cache creation is no longer dropped when a request crosses a format boundary. Because a Codex/Gemini provider's reported `input_tokens` is inclusive of both cache reads and cache writes, the cost calculator now subtracts both before applying the input rate; previously only reads were removed, so cache-write tokens were double-charged at both the input rate and the cache-creation rate. To keep historical rows accurate across this shift, a new `input_token_semantics` column (schema v13; `0` legacy = subtract reads only, `1` total-inclusive = subtract reads and writes, `2` fresh = no subtraction) records how each row's `input_tokens` was stored, so the cost backfill subtracts reads only for pre-existing rows and reads-plus-writes for new total-inclusive rows, while daily rollups are normalized to fresh input and stamped `2`. v12 databases gain the column on both `proxy_request_logs` and `usage_daily_rollups` via `migrate_v12_to_v13`, and Claude-style rows stay fresh input with no subtraction. +- **Stronger Prompt-Cache Breakpoint Injection on the Proxy Bridge**: On the proxy paths that inject Anthropic `cache_control` breakpoints — the `codex_responses_to_anthropic` takeover bridge and the Bedrock native optimizer — the injector now spends its four-breakpoint budget more effectively so long, tool-heavy conversations keep hitting the prompt cache instead of re-sending system, tools, and history at full price every turn. Beyond marking the tools tail, system tail, and the latest cacheable message, it now adds a second older user anchor (`msgs-prior-user`) when at least four messages exist and budget remains, keeping the stable prefix inside Anthropic's 20-block lookback from the newest breakpoint. Injection is short-circuited unless both the optimizer master switch (`enabled`) and the `cache_injection` sub-switch are on, `thinking`/`redacted_thinking` blocks are never chosen as cache targets, and injected markers use Anthropic's standard 5-minute TTL. Caller-owned breakpoints are preserved verbatim — never deleted, reordered, or rewritten — and when a caller already ships more than the supported four, the injector logs a warning, adds no automatic breakpoints, and leaves the markers in place (`cache_injector.rs`, `forwarder.rs`). +- **Kimi For Coding's 256K Context Window Now Takes Effect**: The Kimi For Coding preset's standalone `CLAUDE_CODE_AUTO_COMPACT_WINDOW=262144` (added in #4401 and shipped in 3.16.4) never actually applied, because Claude Code caps unrecognized non-Claude model ids at a 200K window and resolves the compact window as `min(model window, value)`, clamping 262144 back down to 200K. The preset now pairs it with `CLAUDE_CODE_MAX_CONTEXT_TOKENS` (both pinned to 262144) and explicitly routes the endpoint's `kimi-for-coding` alias across `ANTHROPIC_MODEL` and all three tier keys (`ANTHROPIC_DEFAULT_HAIKU/SONNET/OPUS_MODEL`), since Claude Code ignores both context envs for `claude-`-prefixed ids — the non-Claude alias is what unlocks the enlarged window. For already-saved providers, `build_effective_settings_with_common_config` in `services/provider/live.rs` injects the same two context defaults (262144) into the effective live settings at switch time for any provider whose `ANTHROPIC_BASE_URL` is the Kimi coding endpoint (`https://api.kimi.com/coding`), with a mirror-inverse strip on backfill so an injected default never hardens into stored provider config and an explicit user value always wins. Because the alias routing lives only in the preset and not in the injection, a provider saved from the old preset — which set neither the alias nor `CLAUDE_CODE_MAX_CONTEXT_TOKENS` — stays effectively at 200K until the preset is re-applied. Three tests cover backfill injection, user-override preservation, and injected-only strip. +- **Deleted Codex MCP Servers No Longer Resurrected on Provider Activation**: MCP servers are owned by the database `mcp_servers` table and the `[mcp_servers]` section in Codex's live `config.toml` is only a projection re-synced after every write, but switching away used to bake that projection into the stored provider snapshot — so a server you deleted in the app came back to life the next time you activated that provider, and per-entry reconcile (which only knows rows still in the DB) could never clean up the orphan. Switch-away backfill now strips `[mcp_servers]` (and the legacy `[mcp.servers]` form) from the stored settings via `strip_codex_mcp_servers_from_settings`, and any snapshot already polluted self-heals the next time you switch away from it. One user-visible consequence: a hand-written `[mcp_servers.*]` section in a Codex provider's config is stripped out of the stored snapshot the first time you switch away, so going forward define Codex MCP servers through the MCP manager (the DB `mcp_servers` table) rather than by hand-editing the provider config. +- **Codex MCP Sync Fails Closed on an Unparseable config.toml**: When writing a single MCP server into Codex, `sync_single_server_to_codex` silently fell back to an empty `toml_edit` document if the existing `config.toml` failed to parse and then wrote the whole file back — wiping every other section (`model`, `model_providers`, comments) and leaving only the one synced `[mcp_servers.]` entry, a destructive fallback left behind when an earlier fix (`3a548152`) hardened only the removal path. The sync path now returns an `McpValidation` error and leaves the file untouched, matching the fail-closed behavior already used on the read/validate path, so a temporarily malformed config can no longer be silently truncated to a single MCP table. +- **MCP Operations Now Report Per-App Failures Instead of Silently Blocking Others**: Importing MCP servers from apps used to swallow every importer error with `unwrap_or(0)`, so a corrupt Codex `config.toml` surfaced only as "imported 0 servers" with no hint anything went wrong; `import_from_all_apps` now imports each app (claude/codex/gemini/opencode/hermes) best-effort and, when any fail, reports an aggregated error naming the failing apps alongside the count that did import, with the frontend refreshing the server list on settle so partially-imported servers still appear. On the projection side `sync_all_enabled` iterated every app with `?`, so one app's unparseable live file (e.g. a broken `~/.claude.json` that passes the existence gate but fails to parse) blocked every app behind it in iteration order and bubbled a false "switch failed" back even though the target app's live file and the database were already updated; switch and save now re-project only the target app through the new `sync_enabled_for_app` and degrade projection failure to a warning, while config-import and cloud-restore keep the all-apps sweep but collect failures best-effort. Toggling `unify_codex_session_history`, which rewrites the current official provider's live `config.toml` in full and drops the `[mcp_servers]` projection, now re-projects Codex MCP through that same target-only path so enabled servers no longer silently vanish until the next provider switch. In every degraded case the projection self-heals on the next switch or MCP toggle. +- **Codex Common-Config Form Preserves Comments and Key Order**: Toggling "use common config" in the Codex provider form used to route the merge through the frontend `smol-toml` implementation, which re-serialized the whole document (parse → deep-merge → stringify): comments were dropped, keys were reordered, and empty parent table headers like `[model_providers]` were synthesized — the long-standing "config.toml keeps getting reordered" symptom. The merge now runs on a backend command backed by the same `merge_toml_table_like` / `remove_toml_table_like` used to write live configs, so the form preview and the live write share one merge semantic and hand-written formatting survives edit-time merges, and the frontend helper was deleted outright so the pattern can't come back. Because the operations are now async, a landing result is discarded unless it is still current: a per-hook sequence number covers rapid toggle/save races so an earlier merge resolving after a later removal can't flip the switch back (last operation wins), and a config-baseline check keeps a stale merge from clobbering a TOML the user hand-edited while the merge was in flight, with the checkbox self-healing via the existing inference effect. +- **Inject a Single Auth Placeholder on Managed Claude Takeover**: Switching the managed Claude provider from a third-party endpoint (DeepSeek/MiMo/…) to a Codex-managed provider wrote both `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` as `PROXY_MANAGED` into `~/.claude/settings.json`, so Claude Code warned "Both ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY set - auth may not work as expected" on every run — an accretion artifact of the original API-key insert (#1049, Copilot) and the later `ANTHROPIC_AUTH_TOKEN` preservation that avoided a login prompt (#3784), which added the token without removing the key. The `ManagedAccount` branch of `apply_claude_takeover_fields_for_provider` now clears all token keys and injects exactly one placeholder: `ANTHROPIC_AUTH_TOKEN` for Codex-managed providers and `ANTHROPIC_API_KEY` for Copilot; local-proxy auth is unaffected because the forwarder resolves the `PROXY_MANAGED` placeholder from either header. Because enabling takeover short-circuits (`return Ok(())`) when the live config already matches the current proxy, users upgrading with an existing double-key `settings.json` may need to toggle Claude routing off and back on once (or switch providers) to trigger the rewrite. (#4919, #5095) +- **Skip Reachability Probes for Official Providers**: Health/connectivity checks no longer derive an unauthenticated probe target from an official provider's runtime adapter defaults. Batch `stream_check_all_providers` now skips any entry whose `category` is `official`, and `StreamCheckService::resolve_base_url` returns an explicit error for official providers instead of falling back to a first-party endpoint (e.g. `https://chatgpt.com/backend-api/codex`) and probing it without credentials — a request that is meaningless and always fails. The connectivity-check button is already hidden for these providers in `ProviderCard.tsx`, so this is defense-in-depth that also matters now that the built-in Codex official provider participates in takeover; a regression test covers the Codex official provider so a future adapter change cannot silently reintroduce the probe. +- **Usage and Quota Queries Reject Transient Transport Failures So Retry and Keep-Last-Good Work**: Usage and quota queries frequently showed spurious "query failed" states that a manual refresh could not clear, because every transport-level failure — including read-timeouts mid-body — was folded into `Ok(success:false)`, so react-query's retry never fired and the failure body poisoned the cache as if it were real data. The `balance`, `coding_plan`, and `subscription` services now return `Err` for send failures and body-read failures, reading the body via `bytes()` before `serde_json::from_slice` (reqwest's `json()` wraps read errors as `Decode`, which made error-kind checks on it dead code), while auth/4xx/parse errors stay `Ok(success:false)` and surface immediately; the script path maps transient `AppError` keys (`request_failed` / `read_response_failed`) to `Err`, Volcengine gains a `Transient` call variant, HTTP 429 is now classified transient alongside 5xx, and expired-credential retries propagate the transient error instead of rewriting it as "OAuth token has expired". The command layer skips snapshot persistence, the `usage-cache-updated` emit, and tray refresh on `Err` so the cache bridge cannot overwrite retained data. On the frontend, `resolveDisplayUsage` (generalized to subscription quotas via a new options object carrying a `rejected` flag) re-anchors stale success data retained by react-query across rejections to `dataUpdatedAt` so it expires through the same 10-minute keep-last-good window instead of masking a longer outage indefinitely, `useSubscriptionQuota` / `useCodexOauthQuota` gain keep-last-good with per-scope snapshot reset, rejected queries with no displayable value synthesize a failure placeholder carrying the real error message so the footer keeps a retry entry point, and `UsageScriptModal` surfaces string rejections via `extractErrorMessage`. (#3820) +- **Codex Sub-Agent Session Usage Now Counted in Local Statistics**: Token usage from Codex sub-agent (spawned agent) sessions was missing from local usage statistics because the parser keyed each session's synthetic `request_id` on `session_meta.session_id`, but a sub-agent's log carries the parent thread's `session_id` while its own unique identity lives in the `id` / `thread_id` field — so multiple sub-agents under one parent collapsed onto colliding request IDs and were dropped as duplicates. `session_usage_codex.rs` now extracts a unique `thread_id` per file (preferring `id` / `thread_id` over `session_id`) and prefixes request IDs with `codex_session:thread-v1::`, giving each sub-agent distinct rows. Because fork/sub-agent logs first replay the parent thread's history before the takeover event, the parser detects a history-snapshot boundary (via `forked_from_id` / `subagent` source markers plus a `thread_settings_applied` or `inter_agent_communication` event) and uses the replayed `token_count` events only to restore the cumulative baseline instead of double-counting them as new usage. Archived logs under `archived_sessions/` inherit the sync cursor from their original path by filename suffix, so re-parsing an archived copy imports only appended usage rather than re-importing the whole thread; the fix ships with unit tests covering identity extraction, replay-baseline accounting, distinct request IDs, and archived-cursor inheritance. (#5187) +- **Codex Free-Plan 30-Day Quota Window Now Renders in Footer and Tray**: Codex free accounts are metered on a rolling 30-day secondary window (`limit_window_seconds = 2,592,000`) instead of the weekly window paid plans use, and although the backend already mapped this to the tier name `30_day`, neither the frontend `TIER_I18N_KEYS` whitelist nor the tray's `TIER_LABEL_GROUPS` month group recognized it — so `SubscriptionQuotaView` filtered the tier out and, since it is the only surviving tier for a free account, the quota footer rendered nothing while the tray's `format_subscription_summary` returned `None` and showed no quota at all. A single-sourced `TIER_THIRTY_DAY` ("30_day") constant now ties together the backend `window_seconds_to_tier_name` mapping (which maps `2_592_000` explicitly), the tray's month ("m") group, and the frontend whitelist (`30_day → subscription.thirtyDay`), with the `thirtyDay` label added to all four locales (zh/en/ja/zh-TW). Two regression tests lock in the window-seconds-to-tier mappings and assert that a 30-day-only Codex account still renders in the tray, guarding the invariant that any tier visible in the footer must not leave the tray blank. (#3651, #4886) +- **Usage Dashboard Refresh Interval Persists Across Restarts**: The usage dashboard's auto-refresh cadence was component-local state that reset to 30s on every restart, so a user who chose a different interval (off, 5s, 10s, or 60s) had to re-select it each session. The dashboard now reads and writes the choice through a new `usageDashboardRefreshIntervalMs` app setting: `UsageDashboard` takes `refreshIntervalMs` / `onRefreshIntervalChange` props wired from `SettingsPage` to the persisted settings, initializes from the saved value (normalized to a known option, falling back to the 30s default for unrecognized values), and applies changes optimistically — invalidating the usage queries immediately — while rolling back the selection if the save fails. The field is added to the Rust `AppSettings` struct, the TypeScript `Settings` type, and the zod settings schema, and three component tests cover mount-from-saved, persist-on-change, and rollback-on-failure. (#5057) +- **Exclude Fable Tier Model Env Keys From the Shared Claude Common Config**: Fable is Claude's fourth model-mapping tier (added in v3.16.3), but its `ANTHROPIC_DEFAULT_FABLE_MODEL` / `ANTHROPIC_DEFAULT_FABLE_MODEL_NAME` env keys were missing from the provider-specific exclusion list, so a provider's Fable model pin could leak into the shared common-config snippet and then be injected into other providers on the next switch. Both keys are now stripped in `extract_claude_common_config` alongside the haiku/sonnet/opus pins, guarded by a regression test. The same commit also completes Fable's proxy-takeover support: the two keys are added to `CLAUDE_MODEL_OVERRIDE_ENV_KEYS` (now 12 entries) and the takeover writes a stable `claude-fable-5` role alias (`CLAUDE_TAKEOVER_FABLE_MODEL`), reapplying it when the provider configures Fable and clearing stale values otherwise, mirroring the other three tiers; when Fable is unconfigured no alias is written and the mapper falls back fable→opus. (#5206, fixes #4272) +- **Default Missing Tool Schema Types and Restore the API Key Field for Uncategorized Providers**: Two provider-facing fixes. First, when a client sends a tool whose Anthropic `input_schema` omits the top-level `type` or is an empty `{}`, the proxy passed the schema through the shared `clean_schema` helper without filling in the missing type, producing an OpenAI `parameters` object that strict gateways reject. `clean_schema` (defined once in `transform.rs` and reused by the Responses converter via `super::transform::clean_schema`, so both the Anthropic→OpenAI Chat and Responses paths pick up the change) now defaults a missing root `type` to `"object"` — adding an empty `properties: {}` only when properties is also absent — and applies the defaulting only to the root schema via a new `is_root` flag threaded through a `clean_schema_inner` helper, so nested sub-schemas (an `anyOf` string/null union, an `items`-only array) are preserved unchanged rather than having a spurious `type: "object"` forced onto them. Second, editing a provider with no category (a historically imported or hand-built custom provider) hid the Claude API key input, because `useApiKeyState` only showed and auto-created the field in add mode with a defined non-official category; the gate now keys off category alone, showing and populating the field for any provider that isn't `official` or `cloud_provider` — including the undefined-category edit case — while `official` (OAuth-only, input disabled) and `cloud_provider` (which use dedicated auth fields rather than an Anthropic key) stay conservative. New unit and hook tests cover both converters and the uncategorized/official/cloud-provider key paths. (#5069) +- **Media Fallback for Volcano GLM 5.2 Text-Only Image 400s**: When the local proxy takes over a Volcano Coding Plan provider running GLM 5.2, image blocks in a request no longer produce a dead 400 — the rectifier's media fallback in `src-tauri/src/proxy/media_sanitizer.rs` now covers GLM 5.2 on both its preventive and reactive paths. Preventively, the text-only model registry gains `glm-5.2` as an exact tail match (deliberately not a prefix, so a future multimodal `glm-5.2v` variant following Zhipu's 4v/5v naming keeps its images), stripping image blocks before they reach the text-only endpoint. Reactively, because the gateway's error `Model only support text input` never mentions image/vision/media and drops the third-person `s`, a new self-evident phrase list (`only support text` / `only supports text`) now asserts a modality rejection on its own and bypasses the `mentions_image` gate that previously discarded the error before the fallback hints could run; the old `only supports text` hint is folded into that list. Regression tests exercise the verbatim #5025 error body, the `glm-5.2[1M]` mapped-model form, and a `glm-5.2v` negative assertion. (#5025) +- **Display Renamed Codex Session Titles**: Sessions renamed inside Codex now show their renamed titles in the session manager instead of silently falling back to the original first-message text. The scanner (`src-tauri/src/session_manager/providers/codex.rs`) loads thread titles from Codex's `session_index.jsonl` and `state_5.sqlite` — resolving the DB path from `sqlite_home` / `CODEX_SQLITE_HOME` through a new shared `codex_state_db` module that deduplicates path resolution previously copy-pasted with the history-migration code — and prefers a stored thread title over the first user message when building each session's display title. The read-only title query adds a `busy_timeout` so a lookup during a concurrent Codex write no longer fails immediately with `SQLITE_BUSY` and drops back to the first message, and the `title == first_user_message` check is pushed into a NULL-safe SQL `WHERE` clause (matching Codex's `distinct_thread_metadata_title` semantics) so the potentially large `first_user_message` column no longer crosses into Rust. (#4927) +- **Live Config Edits for OpenCode, OpenClaw, and Hermes Now Sync Into the Database on Startup**: The startup auto-import for the live-config-managed apps previously skipped any provider whose id already existed in the database, so edits made directly to the OpenCode, OpenClaw, or Hermes (`~/.hermes/config.yaml`) live files — a changed base URL, an added or renamed model — were never picked up after the first import and silently diverged from the app's stored copy. The `import_{opencode,openclaw,hermes}_providers_from_live` functions in `services/provider/live.rs` now look up each existing provider and, when its live `settings_config` differs, rewrite the stored provider from the live file (OpenCode additionally refreshes the display name from the live `name`, falling back to the existing name rather than the id); the returned count now covers both new imports and updates, and the startup log line changed from "Imported N" to "Synced N". The sync runs on every launch from `lib.rs::run()`, is a no-op when nothing changed, and is fully non-fatal — a provider that vanishes mid-import or a DB lookup error is warned and skipped, and users with no live file still take the quiet `Ok(0)` path. (#4712, #5098) +- **OpenCode Session Resume Command Updated to Current CLI Syntax**: The Session Manager displayed and copied `opencode session resume ` as the resume command for OpenCode sessions, which is not the OpenCode CLI's current resume syntax, so pasting the copied command would not resume the session. Both the SQLite-scanned (`scan_sessions_sqlite`) and JSON-parsed (`parse_session`) session paths in `session_manager/providers/opencode.rs` now emit `opencode -s ` to match the CLI's actual resume flag. (#2359) + +### Docs + +- **Codex + Kimi Local Routing Guides**: New step-by-step guides explain how to run Kimi inside the Codex CLI through CC Switch's local routing, motivated by a protocol mismatch: the newer Codex CLI targets the OpenAI Responses API while both Kimi Open Platform and Kimi For Coding expose the OpenAI Chat Completions shape (`/chat/completions`), so pointing a Kimi endpoint straight at Codex typically 404s on `/responses` or yields streams Codex cannot parse. The guides walk through adding a Codex provider from the built-in `Kimi` (Open Platform, pay-as-you-go, `kimi-k2.7-code`) or `Kimi For Coding` (membership, `kimi-for-coding`) preset, and lay out the four-step conversion chain: Codex is written to talk to `http://127.0.0.1:15721/v1` with `wire_api = "responses"` kept in place, the provider's `meta.apiFormat = "openai_chat"` flags the real upstream as Chat, the route rewrites `/responses` to `/chat/completions` and converts the body, then converts the upstream Chat JSON/SSE back into the Responses shape Codex expects. Added under `docs/guides/` in English, Japanese, and Chinese with accompanying UI screenshots. +- **new-api Sponsor Row in READMEs**: The open-source AI infrastructure project `new-api` is appended as the newest entry in the sponsor table across all four localized READMEs (`README.md`, `README_ZH.md`, `README_JA.md`, `README_DE.md`), along with its banner asset. + +### Internal + +- **Harden Release Supply Chain**: Added a `.github/CODEOWNERS` that, together with branch protection's Code Owners rule, requires owner review before any PR merges to `main` (with `/.github/` and `/src-tauri/` pinned explicitly alongside the global `*` fallback, all to `@farion1231`), gated the release job behind a `release` environment in `release.yml` so signing secrets unlock only after manual approval, and removed `.github` from `.gitignore` so the previously untracked `labeler.yml` and `workflows/labeler.yml` are now versioned. +- **Settle pnpm Build-Script Approvals**: Approved `esbuild` (via `onlyBuiltDependencies`) and ignored `msw` (via `ignoredBuiltDependencies`) in `pnpm-workspace.yaml` so pnpm 10.13+ stops appending `allowBuilds` placeholders to the file on every install. +- **Platform-Gate the Desktop-Scope Assertion in the Profile Roundtrip Test**: The `profile_roundtrip` integration test's Claude Desktop assertion is now `cfg`-gated to macOS/Windows to match the already cfg-gated desktop switch, so on Linux CI (where desktop live writes error) it expects the seeded `d1` instead of `d2` and no longer panics, poisons the shared test mutex, and cascades into two more failures. + +## [3.16.5] - 2026-07-01 + +Development since v3.16.4 reworks the Codex native-Responses path — restoring a generated model catalog for proxy-less direct-connect, decoupling model mapping from the local-routing toggle, and adding a host/model-prefix blacklist that disables Codex's built-in web_search on gateways that reject it — alongside a broad wave of new provider presets (Qiniu and Code0.ai across all seven apps, the FennoAI/ZetaAPI/TeamoRouter/NekoCode partners, and the non-partner Amux), Claude Sonnet 5 pricing plus a default-tier bump to it, a categorized two-level session view with group-level batch selection, live auto-sync of the shared Claude common config on switch, and a run of credential-safety, tool-detection, Doubao model-id, branding, and icon-size fixes. + +**Stats**: 36 commits | 93 files changed | +5,678 insertions | -2,804 deletions + +### Added + +- **Codex Native Responses Direct-Connect Regenerates a Model Catalog**: Reverses the v3.16.4 direction that dropped the catalog — Codex providers now run in two explicit modes, and native direct-connect once again ships a catalog file. Providers with `apiFormat: "openai_responses"` run with no proxy, and cc-switch generates `~/.codex/cc-switch-model-catalog.json` (`CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME`, provider id `custom`) so Codex Desktop actually shows the custom models and tools work without the freeform `apply_patch` (`type=custom`) tool that native gateways like MiMo reject — editing falls back to `shell_command`. Catalog generation is keyed on `apiFormat` via `CodexCatalogToolProfile` and decoupled from the route-takeover toggle, so a native provider persists a catalog without enabling local route mapping, while `openai_chat` keeps the existing Responses↔Chat proxy conversion unchanged. Codex's parser requires `base_instructions` on every entry, so the native template (`codex_native_responses_template.json`, slug `gpt-5.5`) carries a neutral default that per-vendor official text overrides (MiMo, MiniMax); synthesized catalogs for Qwen/Doubao/LongCat use the neutral default. Existing native providers must be re-saved once to regenerate a valid catalog (no DB migration). +- **Categorized Session View With Two-Level Grouping**: The Session Manager gains a grouped view mode alongside the flat list, toggled via a List/ListTree Select in the toolbar and persisted to `localStorage` under `cc-switch.sessionManager.listViewMode`. The new `groupSessionsByProviderAndDirectory` helper builds a two-level hierarchy (provider group → project-directory group): directory groups are keyed by `getSessionDirectoryGroupKey(providerId, projectDir)` and labeled with `getBaseName(projectDir)`, and sessions lacking a `projectDir` fall into an "unknown directory" bucket (`UNKNOWN_PROJECT_DIR_KEY = __unknown_project_dir__`). Both levels are Radix Collapsible sections whose expansion state is serialized to `cc-switch.sessionManager.groupExpansionState` (pruned to valid keys after load), with a "collapse all" button; in batch mode each group header gains a tri-state checkbox that selects/deselects every selectable session (those with a `sourcePath`) at once, backed by a `Minus`-glyph indeterminate state added to `ui/checkbox.tsx` and a selected/selectable count badge. New keys were added across all four locales (zh/en/ja/zh-TW). The change is entirely frontend — no Tauri commands or DAO changes. (#4776) +- **Qiniu (七牛云) Provider Preset Across All Seven Apps**: Added the Qiniu Cloud AI gateway as a partner aggregator spanning Claude Code, Claude Desktop, Codex, Gemini, OpenCode, OpenClaw, and Hermes — the widest coverage of the batch and the only one carrying a Gemini preset. Because Qiniu relays native Claude/GPT/Gemini rather than remapping to domestic models, Claude Code/Desktop use the Anthropic-compatible bare host with native passthrough (no model pinning), Codex hits native Responses at `https://api.qnaigc.com/bypass/openai/v1` defaulting to `gpt-5.5`, and Gemini uses `gemini-3.1-pro-preview` via `https://api.qnaigc.com/bypass/vertex`; OpenCode/OpenClaw/Hermes default to `gpt-5.5` over the OpenAI-compatible `/v1`. Each type carries `endpointCandidates` listing the `api.qnaigc.com` primary plus the `api.modelink.ai` overseas mirror for failover. It is marked `isPartner` with referral `https://s.qiniu.com/nMvAvy`, a localized display name via `nameKey` (`providerForm.presets.qiniu`) and a partner-promotion blurb in all four locales (zh/en/ja/zh-TW), and registers the `qiniu.png` icon through URL import plus `iconUrls` and metadata. +- **FennoAI, ZetaAPI, and TeamoRouter Partner Presets**: Added three partner aggregators — FennoAI (`api.fenno.ai`), ZetaAPI (`api.zetaapi.ai`), and TeamoRouter (`api.teamorouter.com`) — each covering Claude Code, Claude Desktop, Codex, OpenCode, OpenClaw, and Hermes with no Gemini preset, since these relay native Claude/GPT only. In every case Claude Code/Desktop use the Anthropic-compatible bare host with native passthrough, Codex uses native Responses (FennoAI at `api.fenno.ai`, ZetaAPI and TeamoRouter at their `/v1` paths) defaulting to `gpt-5.5`, and OpenCode/OpenClaw/Hermes default to `gpt-5.5` over the OpenAI-compatible `/v1`. All three are `isPartner` with referral `apiKeyUrls` (FennoAI's ¥9.9 Coding Plan link, ZetaAPI's `zetaapi.ai/go/ccs`, TeamoRouter's `teamorouter.com` with `utm_source=cc_switch` — its in-app link stays English while README links are per-language), each ships a localized promo blurb in zh/en/ja/zh-TW plus a sponsor row in all four READMEs, and each registers its own icon (`fenno-icon.webp`, `zetaapi-icon.png`, `teamorouter`). Two carry surfaced discount codes: ZetaAPI gives a first-recharge 10% discount with promo code `CC-SWITCH`, and TeamoRouter gives new users 10% off their first top-up. +- **Amux Aggregator Preset**: Added Amux (`api.amux.ai`) as a non-partner aggregator across Claude Code, Claude Desktop, Codex, OpenCode, OpenClaw, and Hermes, with no Gemini preset. Claude Code/Desktop use the bare Anthropic-compatible host, Codex uses native Responses over the `/v1` path, and the remaining three apps default to `gpt-5.5` over the OpenAI-compatible endpoint. Unlike the partner presets in this batch it carries no `isPartner` flag, referral link, or promo copy; the `amuxapi-icon.svg` is registered as an inline `currentColor` icon in `src/icons/extracted/index.ts` and `metadata.ts` rather than a raster URL import. +- **Code0.ai Partner Preset Across All Seven Apps**: Added Code0.ai (`code0.ai`) as a partner aggregator spanning all seven apps — Claude Code, Claude Desktop, Codex, Gemini, OpenCode, OpenClaw, and Hermes. Claude Code, Claude Desktop, and Gemini use the bare `https://code0.ai` host (Anthropic-native passthrough with no model pinning for the two Claude apps, `gemini-3.1-pro-preview` for Gemini), Codex hits native Responses at `https://code0.ai/v1`, and OpenCode/OpenClaw/Hermes default to `gpt-5.5` over the OpenAI-compatible `/v1`. It is marked `isPartner` with a `?source=ccswitch` referral on the API-key signup link, a partner-promotion blurb surfacing the CC Switch test-credit offer in all four locales (zh/en/ja/zh-TW), a sponsor row in all four READMEs, and the `code0.png` icon registered via URL import. +- **NekoCode Partner Preset Across Six Apps**: Added NekoCode (`nekocode.ai`) as a partner aggregator spanning six apps — Claude Code, Claude Desktop, Codex, OpenCode, OpenClaw, and Hermes (no Gemini preset). Claude Code and Claude Desktop use the bare `https://nekocode.ai` host (Anthropic-native passthrough with no model pinning), Codex hits native Responses at `https://nekocode.ai/v1`, and OpenCode/OpenClaw/Hermes default to `gpt-5.5` over the OpenAI-compatible `/v1`. It is marked `isPartner` with a `?aff=CCSWITCH` referral on the API-key signup link (kept off the actual request endpoints), a partner-promotion blurb in all four locales (zh/en/ja/zh-TW), and a sponsor row in all four READMEs, both surfacing the CC Switch offer — 10% off top-ups with promo code `cc-switch` at recharge. The `nekocode-icon.png` icon is registered via URL import. +- **Claude Sonnet 5 Model Pricing**: Seeded a `claude-sonnet-5` pricing row in `schema.rs` at Anthropic list price — $3/$15 per Mtok input/output and $0.30/$3.75 cache read/write, identical to the Sonnet 4.6 rates. The introductory $2/$10 promo (valid through 2026-08-31) is deliberately not seeded, so accounting reflects steady-state list pricing rather than a temporary discount. The row is applied on next app start via `ensure_model_pricing_seeded` and requires no `SCHEMA_VERSION` bump. + +### Changed + +- **Decoupled Codex Model Mapping From the Local-Routing Toggle**: Aligns the Codex provider form with Claude Code — the model-mapping catalog is now independent of route takeover, since native Responses providers (MiMo, Doubao, MiniMax) need it for proxy-less direct connect while Chat providers use the proxy regardless of any per-provider flag. The "Needs Local Routing" toggle is removed: it had no backend field and only gated catalog/reasoning persistence, which is equivalent to whether the mapping is filled. Model mapping is now always shown for non-official providers and persisted whenever the list is non-empty (the backend already keys off `modelCatalog.models`), while reasoning visibility/persistence is gated on the Chat format instead of the toggle. The Chat upstream-format option is marked "routing required" with a refreshed advanced-section hint across all four locales (zh/en/ja/zh-TW), dead `localRouting*` keys are dropped, and the model-mapping block moves above the custom User-Agent block. Also fixes `useCodexConfigState` dropping `supportsParallelToolCalls`/`inputModalities`/`baseInstructions` when loading a saved provider — which silently lost parallel tools, image input, and the official base instructions on edit — by preserving them on load (camelCase and snake_case) and comparing them in the row sync. +- **Auto-Sync Claude Common Config From Live settings.json on Switch**: When switching away from a Claude provider that has `common_config_enabled`, the service now re-extracts the shareable portion of its live `settings.json` and replaces the stored common-config snippet, instead of only writing the snippet one way. This captures config the user added directly in the running app (`enabledPlugins`, `hooks`, `env`, `theme`, shared prefs) so it isn't silently lost on the next switch, and it propagates deletions so a removed key isn't re-injected later. The sync in `services/provider/mod.rs` is scoped strictly to Claude providers with `common_config_enabled`, is skipped when the snippet was explicitly cleared, and all failures are non-fatal (warn-only) and never block the switch. Four integration tests cover capture / delete-sync / opt-out / cleared. +- **Default Sonnet Tier Now Pins to Claude Sonnet 5**: Bumped every default Sonnet pin from `claude-sonnet-4-6`/`claude-sonnet-4.6` to `claude-sonnet-5` across all provider presets (`claudeProviderPresets`, `claudeDesktopProviderPresets`, `hermesProviderPresets`, `openclawProviderPresets`, `opencodeProviderPresets`, and universal `NEWAPI_DEFAULT_MODELS`), covering the `ANTHROPIC_MODEL` / `ANTHROPIC_DEFAULT_SONNET_MODEL` / `ANTHROPIC_DEFAULT_OPUS_MODEL` env keys and prefixed variants like `anthropic/claude-sonnet-5` and `global.anthropic.claude-sonnet-5`. The Claude Desktop `DEFAULT_PROXY_ROUTES` sonnet `route_id` in `claude_desktop_config.rs` also moves to `claude-sonnet-5` (`supports_1m` preserved), and the route-derivation test expectations were updated to match. Non-Anthropic pins (gpt/gemini/glm/sonnet-4-5) were left untouched. +- **Doubao Dated Model Id and YYMMDD Pricing Normalization**: Switched the Doubao (DouBaoSeed) preset model id to the dated form `doubao-seed-2-1-pro-260628` across every app surface (config default, generated Codex catalog, and OpenClaw namespaced refs), because Volcengine Ark rejects the bare `doubao-seed-2-1-pro` with a 404 ("model does not exist or you do not have access to it") even after activation and only accepts the full dated id. Because real usage now arrives with a date suffix, `strip_model_date_suffix` in `usage_stats.rs` was extended to also strip Volcengine's 6-digit YYMMDD form (`-260628`, `-250615`) in addition to the existing 8-digit YYYYMMDD/ISO handling; the 6-digit branch validates month 01-12 and day 01-31 to avoid eating non-date version suffixes like `-123456`, then falls back to `None` for exact matching. This keeps the bare-name seed row as the canonical pricing identity, fixing the $0-cost display for every Volcengine Doubao model. Unit and end-to-end regression tests were added. +- **"Write Common Config" Renamed to "Apply Common Config"**: The original label "Write Common Config" (写入通用配置) was ambiguous about data-flow direction, reading as "write the current config INTO the common config" when the actual behavior is the reverse — the saved common-config snippet is merged INTO this provider's config. The checkbox is renamed to "Apply Common Config" across all four locales (zh/en/ja/zh-TW), including every hint/guide/notice reference and the `CommonConfigEditor` / `GeminiConfigSections` defaultValue fallbacks, and the Gemini hint wording is aligned with the claude/codex "when checked" phrasing. The Japanese user manual (`docs/user-manual/ja/2-providers/2.1-add.md`) and `README_JA.md` were also synced to 共通設定を適用 / 共有設定を適用. (#4829) +- **OpenClaw Doubao Context Window Aligned to 262144**: The OpenClaw DouBaoSeed preset hard-coded `contextWindow` 128000 while the Codex preset/catalog used 262144 for the same model, giving OpenClaw users a too-small window that could compress or truncate long context prematurely. Bumped `openclawProviderPresets.ts` to 262144 and added a cross-preset consistency test asserting the OpenClaw and Codex Doubao context windows stay equal so the two sides cannot silently drift apart again. +- **Volcengine/Doubao/BytePlus Website Links Restored to Official Homepages**: The `websiteUrl` for the 火山Agentplan, BytePlus, and DouBaoSeed presets had accidentally been set to the same invite/console link as `apiKeyUrl`, so the "visit official site" button routed users to the referral/API-key page instead of the product homepage. Restored clean official homepages across all six app preset files — 火山Agentplan to `https://www.volcengine.com/product/ark`, DouBaoSeed to `https://www.volcengine.com/product/doubao`, and BytePlus to `https://www.byteplus.com/en/product/modelark` (dropping the utm params) — while leaving the `apiKeyUrl` referral links intact. +- **Kimi and SiliconFlow Referral Links Refreshed**: Updated two sponsor referral sets across presets and READMEs. Following Moonshot's console rebrand to Kimi, the Kimi preset referral links and the domestic (ZH) README banner moved to the new `platform.kimi.com` domain — the domestic console `platform.moonshot.cn/console` → `platform.kimi.com`, the API-key page → `platform.kimi.com/console/api-keys`, and the Coding Plan link `www.kimi.com/code/docs/` → `www.kimi.com/code/` — and the missing `?aff=cc-switch` attribution param was added to the codex and openclaw entries. API endpoints (`api.moonshot.cn`, `api.kimi.com/coding`) were left unchanged, and the presets have no overseas variant (the separate EN/DE/JA README switch to `platform.kimi.ai` is covered under Docs below). Separately, the SiliconFlow invite code was rotated from `drGuwc9k` to `YflgU2Ve` across all README locales and the claude, claude-desktop, codex, hermes, and openclaw presets. +- **Downscaled Oversized Provider Icons to 256px**: Shrank a batch of bundled provider icons that shipped far larger than their ~32px on-screen render size in `ProviderIcon`, cutting bundle weight with no code, filename, or import changes (aspect ratio preserved, rendered via `` object-contain). Raster PNGs: ZetaAPI 1254×1254/940KB→40KB, relaxcode 1462×1076/1.16MB→42KB, sudocode 2048×2048/432KB→37KB, hermes 512×512/125KB→38KB, claudecn 512×512/109KB→46KB, atlascloud 3525×3300/105KB→9KB. Two "fake vector" SVGs wrapping a single oversized base64 PNG were re-embedded at a 256px max dimension keeping the SVG wrapper: ccsub 1.16MB→60KB and shengsuanyun 212KB→51KB. Also removed `dds.svg`, a 1.4MB genuine 2222-path vector that was never imported in `index.ts` nor referenced under `src/`, so it only bloated the repo without ever shipping. + +### Fixed + +- **Disable web_search for Native Codex Gateways That Reject It**: Some native `/responses` gateways whose first-party models lack OpenAI's hosted `web_search` tool reject it with "tool type 'web_search' is not supported by this gateway phase" (`responses_feature_not_supported`), and Codex sends the tool by default (config-driven, not gated by the catalog's `supports_search_tool`), producing hard 400s. cc-switch now writes the top-level TOML line `web_search = "disabled"` for those vendors via `set_codex_native_web_search_field`, injected alongside `model_catalog_json` at switch time. Scope is a blacklist (default-on): only providers matched by `base_url` host — `CODEX_WEB_SEARCH_REJECT_HOSTS = xiaomimimo.com, longcat.chat, minimax.io, minimaxi.com` — or by model brand prefix — `CODEX_WEB_SEARCH_REJECT_MODEL_PREFIXES = mimo, longcat, minimax, qwen3-coder` — are disabled, so relays serving real GPT, DouBao, general Qwen, and any unknown provider keep Codex's default. The `qwen3-coder` prefix suppresses the tool for the native `qwen3-coder-plus` direct-connect preset (百炼/DashScope marks built-in tools unsupported for the coder series) while general Qwen models sharing the DashScope host stay enabled — matching is on the model axis (after stripping any aggregator `vendor/` path segment like `MiniMaxAI/MiniMax-M3` or `qwen/qwen3-coder-plus`), so it also catches aggregators such as SiliconFlow fronting a reject vendor's model. A blacklist was chosen over a fuzzy "is this GPT?" whitelist because wrongly keeping `web_search` ON fails with a hard 400, and an ownership sentinel means cc-switch only ever removes a `web_search` key whose value equals its own `disabled`, so existing providers need no re-save and switching back re-enables web search. Also corrects the LongCat-2.0-Preview preset context window from 131072 (128K) to its real 1048576 (1M), aligning with the MiMo/Qwen 2^20 convention, and tightens the native Responses preset tests to assert exact model→contextWindow catalogs instead of only checking catalog presence. +- **Strip All Credential-Like Keys From the Shared Claude Common-Config Snippet**: `extract_claude_common_config` previously only redacted `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN`, but Claude providers legitimately carry other credentials (`OPENROUTER_API_KEY`, `GOOGLE_API_KEY`, and possibly OpenAI/Gemini/AWS Bedrock/Vertex secrets), which could leak into the shared snippet and then be injected into other providers. Extraction now pattern-matches and strips any credential-shaped env key (`*_API_KEY` / `*_AUTH_TOKEN` / `*secret*` / `*token*`, etc.), while preserving plural `*_TOKENS` values like `MAX_OUTPUT_TOKENS` as legitimately shareable. This also closes the same pre-existing leak in the manual Extract and one-time auto-extract paths, and is covered by a dedicated credential-stripping unit test. +- **Usage-Script Credentials Persisted Only as Explicit Overrides**: Provider usage scripts store optional `api_key`/`base_url` fields that override the provider's live credentials when querying quota, but these were silently mirroring the provider's own credentials — so copying a provider or editing its main API key/base URL left the usage script pinned to the old endpoint and key, and quota queries kept hitting the stale target. `ProviderService::add`/`update` now run `normalize_usage_script_credential_overrides` before persisting: if the script's trimmed `api_key` or slash-normalized `base_url` matches the provider's resolved usage credentials (or is blank) it is cleared to `None` so the query falls back to `resolve_usage_credentials` from the live config, while genuinely distinct overrides are kept and `template_type == "token_plan"` scripts are left untouched. The deeplink import path gained matching `normalize_deeplink_api_key`/`base_url` helpers, and the frontend now invalidates `usageKeys.script(id, appId)` (including the `originalId` when a provider is renamed) on update so the homepage re-queries with the corrected config instead of the test-time cache. (#4654) +- **Hermes Config Dir Now Resolves Correctly on Windows**: CC Switch hardcoded `~/.hermes` as the Hermes config directory, but Hermes itself resolves it via `get_hermes_home()` — the `HERMES_HOME` env var, then a platform default of `%LOCALAPPDATA%\hermes` on Windows (`~/.hermes` on mac/Linux). On Windows this meant CC Switch wrote provider configs to a path Hermes never reads, so provider switches had no effect. `get_hermes_dir()` now mirrors Hermes' own resolution order — `settings.hermes_config_dir` explicit override, then `HERMES_HOME` taken verbatim (trimmed, non-empty, no `~` expansion, matching Hermes' `Path(val)`), then the platform default reading the actual `LOCALAPPDATA` env var (falling back to `~\AppData\Local\hermes`) on Windows and `~/.hermes` elsewhere. This deliberately re-honors the `HERMES_HOME` that #3470 had dropped, since unlike Codex/Claude the Hermes Windows installer sets `HERMES_HOME` as a first-class mechanism for relocated installs. Config-dir tests were also isolated from ambient `HERMES_HOME`/`LOCALAPPDATA`. (#4680, refs #3178, #3470) +- **Linux Wayland: Override the AppImage's Forced `GDK_BACKEND=x11`**: The AppImage's GTK launch hook (`linuxdeploy-plugin-gtk.sh`) unconditionally exports `GDK_BACKEND=x11` to dodge a historical native-Wayland crash (tauri-apps/tauri#8541). On newer Wayland + NVIDIA setups this forced XWayland leaves the WebKitGTK web content unable to receive pointer events — the GTK title bar stays clickable but the page is dead — and black-screens on resize; the existing `WEBKIT_DISABLE_*` mitigations don't help because the root cause is the forced window backend, not rendering. Since the hook also overrides any user-set `GDK_BACKEND`, there was no way to switch back without unpacking the AppImage. `main.rs` now reads an opt-in `CC_SWITCH_GDK_BACKEND` escape hatch (which the hook never touches) before GTK init: leaving it unset keeps the current x11 behavior unchanged (zero regression), while `CC_SWITCH_GDK_BACKEND=wayland` forces native Wayland. The override is generic, so users on tiling Wayland compositors hitting the inverse input bug can set `CC_SWITCH_GDK_BACKEND=x11`. (#4351, fixes #4350) +- **Get API Key Link Now Shows in Claude Desktop, OpenClaw, and Hermes Forms**: The "Get API Key" link and partner-promotion block below the API key input was only wired for claude/codex/gemini/opencode. Claude Desktop rendered a bare Input that never showed it, and OpenClaw/Hermes were blocked by two gaps: `useApiKeyLink` whitelisted only those four appIds (so the link was suppressed even when a preset carried `apiKeyUrl`), and `useProviderCategory` only parsed the `/^(claude|codex|gemini|opencode)-(\d+)$/` preset-id pattern (so OpenClaw/Hermes category stayed undefined and the cn_official/aggregator/third_party link condition never held). `ClaudeDesktopProviderForm` now calls `useApiKeyLink` and uses the shared `ApiKeySection`; the `useApiKeyLink` whitelist and its `PresetEntry` union add claude-desktop/openclaw/hermes; and `useProviderCategory` now resolves openclaw/hermes preset categories. Additionally, `HermesFormFields` and `OpenClawFormFields` no longer let an "official" category disable the key input, since these apps have no OAuth-only official providers (e.g. Hermes' Nous Research is official but still needs a user-supplied key). +- **Deduplicated Windows Codex npm Shims in Tool Detection**: On Windows, npm installs a tool as three sibling files — `codex.cmd`, `codex.exe`, and an extensionless Unix shim named `codex` — and CC Switch's `tool_executable_candidates` listed all three, so the extensionless shim (which Windows cannot execute directly) was probed as a redundant/failing candidate. `tool_executable_candidates` now appends the extensionless path only when `windows_runnable_sibling_for_extensionless_tool` finds no adjacent `.cmd`/`.exe` sibling, and `resolve_path_default` likewise prefers a runnable `.cmd`/`.exe` sibling before canonicalizing a bare extensionless PATH hit. This keeps version detection and launching anchored to the actually-runnable Windows shim instead of the shadowed Unix one. (#4782) +- **Scroll Bounds for Long Select Dropdowns**: The `SelectContent` popover used `overflow-hidden` with no height cap, so dropdowns with many options (e.g. long model or provider lists) rendered taller than the viewport and clipped their overflowing items with no way to reach them. It now sets `max-h-[min(24rem,var(--radix-select-content-available-height))]` and `overflow-y-auto overflow-x-hidden`, bounding the content to 24rem or the Radix-computed available height and letting the list scroll vertically while still clipping horizontally. (#4798) +- **Date-Range Picker Calendar Stays On-Screen in Narrow Popovers**: The custom date-range picker switched to its two-column layout (date fields | calendar) based on the viewport width via Tailwind's `sm:` (640px) breakpoint, but the popover is clamped to `100vw - 2rem` and anchored to its trigger with `align="end"`, so its real available width is narrower than the viewport. On narrow windows the two-column layout could activate while the popover only had room for one column, pushing the calendar column off the right edge where it was clipped — the month header and 4 of 7 weekday columns cut off and unreachable. The layout now keys off the popover's own inline size via a CSS container query (`w-[620px] max-w-[calc(100vw-2rem)]`) instead of the viewport, so it collapses to one column exactly when the popover itself is narrow, keeping the calendar fully visible at any window width. (#4860) + +### Docs + +- **Documented the `CC_SWITCH_GDK_BACKEND` Escape Hatch in README and User Manual**: Added an FAQ entry for the opt-in `CC_SWITCH_GDK_BACKEND` environment variable (see the corresponding Fixed entry) across all four README locales (`README.md`, `README_ZH.md`, `README_JA.md`, `README_DE.md`) and the zh/en/ja user-manual troubleshooting pages (`docs/user-manual/{zh,en,ja}/5-faq/5.2-questions.md`), explaining how Wayland + NVIDIA users can switch back to native Wayland when the webview goes click-dead and black-screens on resize, and how tiling-Wayland users can set it to `x11` for the inverse input bug. +- **Overseas Kimi READMEs Point to platform.kimi.ai With a Coding Plan Link**: Updated the Kimi K2.7 Code partner section across the English, German, and Japanese READMEs so the banner image and both inline CTAs point to `https://platform.kimi.ai?aff=cc-switch` instead of the old `platform.kimi.com` host, keeping the `aff=cc-switch` referral tag intact. All four localized READMEs (`README.md`, `README_DE.md`, `README_JA.md`, `README_ZH.md`) also gained a new line promoting the Kimi For Coding subscription plan linked to `https://www.kimi.com/code/?aff=cc-switch`; note the ZH README kept its existing `platform.kimi.com` link and only received the Coding Plan addition. +- **GitHub Global Top-100 Milestone Banner in v3.16.4 Release Notes**: Prepended a celebratory callout blockquote to all three localized v3.16.4 release notes (`docs/release-notes/v3.16.4-en.md`, `-ja.md`, `-zh.md`) announcing that CC Switch has entered the global top 100 GitHub projects by star count, thanking users, contributors, and stargazers. This is a docs-only addition to the release-notes header and does not change any product behavior. + ## [3.16.4] - 2026-06-27 Development since v3.16.3 focuses on tightening the Codex proxy path — native OpenAI Responses migration for the major Chinese providers, a decoupled upstream-format selector, zstd request/error-body decompression, and a run of tool-call and OAuth-over-proxy fixes — alongside richer usage and pricing tooling (models.dev pricing import, Volcengine Ark coding/agent-plan quotas, live-tracking date ranges, GLM-5.2/Doubao Seed 2.1 pricing), new proxy and resilience capabilities (custom request header/body overrides, an in-app recovery screen for too-new databases, native Windows ARM64 builds), and a broad wave of preset and branding updates (the SubRouter and OpenCode Go subscriptions, the CTok→ETok rename, Kimi rebranding and prime-partner badges, and a Kimi K2.7 Code sponsor banner). diff --git a/README.md b/README.md index 86f0d0cf0..38094ad73 100644 --- a/README.md +++ b/README.md @@ -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 [![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) @@ -69,6 +69,11 @@ Unlike typical API relay services, TeamoRouter aggregates hundreds of official m 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 this link as a new user to receive 10% off your first top-up. + +ZetaAPI +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 this link 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! + + BytePlus 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 this link to get 500,000 tokens of free inference quota per model. >>中国大陆地区的开发者请点击这里 @@ -124,6 +129,11 @@ TeamoRouter also offers enterprise features including centralized billing, team This project is sponsored by Claude API. Direct Claude API access — connect Claude Code and Agent apps in 3 minutes. New users can claim a free trial credit.Powered by official Anthropic API keys + AWS Bedrock official channels. No reverse engineering, no model degradation. Full support for Opus / Sonnet / Haiku model lineup, with official capabilities preserved including Tool Use, 1M context window, and more. Built for Claude Code power users, Agent engineers, and enterprise engineering teams. Invoicing and dedicated team support available. Click here to register! + +code0.ai +Thanks to code0.ai for sponsoring this project! code0.ai is an AI coding service platform built for developers, supporting Claude Code, Codex, Gemini, and other mainstream AI coding capabilities. It helps individual developers and teams use AI Agents more stably and efficiently for coding, debugging, refactoring, and automation workflows. ccswitch users can contact customer support via the code0.ai website to claim test credits and experience a reliable AI coding service. + + ClaudeCN 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 this link! @@ -165,8 +175,18 @@ TeamoRouter also offers enterprise features including centralized billing, team -ZetaAPI -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 this link 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! +NekoCode +Thanks to NekoCode 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 this link and enter promo code cc-switch during recharge to receive 10% off your top-up! + + + +new-api +Thanks to the open-source AI infrastructure project new-api for its strong support of this project! new-api is an open-source AI infrastructure project from QuantumNous and one of the leading unified LLM access-and-distribution projects by activity and adoption, focused on helping developers, teams, and enterprises build manageable, scalable AI service platforms at lower cost. As a fellow project rooted in the open-source ecosystem, new-api hopes to sponsor and support the continued growth of more outstanding open-source projects. 🌟 Star new-api to show your support: https://github.com/QuantumNous/new-api. Website: https://www.newapi.ai/. + + + +SubRouter +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 this link! @@ -175,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 @@ -199,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 @@ -234,7 +254,7 @@ Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex
Which AI tools does CC Switch support? -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.
@@ -284,6 +304,19 @@ Add an official provider from the preset list. After switching to it, run the Lo +
+Linux (Wayland + NVIDIA): clicks don't register and the window black-screens on resize + +The AppImage forces `GDK_BACKEND=x11` (XWayland) to avoid a historical native-Wayland crash. On newer Wayland + NVIDIA setups this can leave the web content area unclickable (the title-bar buttons still work) and black-screen on resize. Launch with the opt-in escape hatch to switch back to native Wayland: + +```bash +CC_SWITCH_GDK_BACKEND=wayland ./CC-Switch-*.AppImage +``` + +If you launch from a desktop icon, add it to the `.desktop` `Exec=` line (e.g. `env CC_SWITCH_GDK_BACKEND=wayland /path/to/AppImage`) or set it in your session environment. The variable is generic: on tiling Wayland compositors (sway/Hyprland) where clicks don't register, try `CC_SWITCH_GDK_BACKEND=x11` instead. Leaving it unset keeps the default behavior. + +
+ ## Documentation For detailed guides on every feature, check out the **[User Manual](docs/user-manual/en/README.md)** — covering provider management, MCP/Prompts/Skills, proxy & failover, and more. diff --git a/README_DE.md b/README_DE.md index adf3bac55..057d3bce4 100644 --- a/README_DE.md +++ b/README_DE.md @@ -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 [![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) @@ -69,6 +69,11 @@ Anders als typische API-Relay-Dienste bündelt TeamoRouter Hunderte offizieller 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 diesen Link und erhalten Sie 10 % Rabatt auf Ihre erste Aufladung. + +ZetaAPI +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 diesen Link 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! + + BytePlus 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 diesen Link und erhalten Sie pro Modell ein kostenloses Inferenzkontingent von 500.000 Token. >>中国大陆地区的开发者请点击这里 @@ -124,6 +129,11 @@ TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team Dieses Projekt wird von Claude API gesponsert. Direkter Claude-API-Zugriff — verbinden Sie Claude Code und Agent-Apps in 3 Minuten. Neukunden können ein kostenloses Testguthaben einlösen. Betrieben mit offiziellen Anthropic-API-Schlüsseln + offiziellen AWS-Bedrock-Kanälen. Kein Reverse Engineering, keine Modellverschlechterung. Volle Unterstützung der Modellreihe Opus / Sonnet / Haiku, mit erhaltenen offiziellen Fähigkeiten einschließlich Tool Use, 1M-Kontextfenster und mehr. Entwickelt für Claude-Code-Power-User, Agent-Ingenieure und technische Unternehmensteams. Rechnungsstellung und dedizierter Team-Support verfügbar. Klicken Sie hier, um sich zu registrieren! + +code0.ai +Vielen Dank an code0.ai für die Unterstützung dieses Projekts! code0.ai ist eine für Entwickler entwickelte AI-Coding-Service-Plattform, die Claude Code, Codex, Gemini und weitere gängige AI-Coding-Funktionen unterstützt. Sie hilft einzelnen Entwicklern und Teams, AI-Agents stabiler und effizienter für Programmierung, Debugging, Refactoring und Automatisierungs-Workflows zu nutzen. ccswitch-Nutzer können über die code0.ai-Website den Kundensupport kontaktieren, um Testguthaben zu erhalten und einen zuverlässigen AI-Coding-Service zu erleben. + + ClaudeCN 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 diesen Link! @@ -165,8 +175,18 @@ TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team -ZetaAPI -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 diesen Link 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! +NekoCode +Vielen Dank an NekoCode 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 diesen Link und geben Sie beim Aufladen den Gutscheincode cc-switch ein, um 10 % Rabatt auf Ihre Aufladung zu erhalten! + + + +new-api +Vielen Dank an das Open-Source-KI-Infrastrukturprojekt new-api für die tatkräftige Unterstützung dieses Projekts! new-api ist ein Open-Source-KI-Infrastrukturprojekt von QuantumNous und eines der nach Aktivität und Verbreitung führenden Projekte für den einheitlichen Zugang zu und die Verteilung von LLMs, das sich darauf konzentriert, Entwicklern, Teams und Unternehmen beim Aufbau verwaltbarer und skalierbarer KI-Serviceplattformen zu geringeren Kosten zu helfen. Als ein ebenfalls im Open-Source-Ökosystem verwurzeltes Projekt möchte new-api durch Sponsoring die kontinuierliche Weiterentwicklung weiterer herausragender Open-Source-Projekte unterstützen. 🌟 Unterstützen Sie new-api mit einem Star: https://github.com/QuantumNous/new-api. Website: https://www.newapi.ai/. + + + +SubRouter +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 diesen Link! @@ -175,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 @@ -199,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 @@ -234,7 +254,7 @@ Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code
Welche KI-Werkzeuge unterstützt CC Switch? -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.
@@ -284,6 +304,19 @@ Fügen Sie einen offiziellen Anbieter aus der Preset-Liste hinzu. Führen Sie na +
+Linux (Wayland + NVIDIA): Klicks im Webinhalt reagieren nicht, schwarzer Bildschirm beim Größenändern + +Das AppImage erzwingt `GDK_BACKEND=x11` (XWayland), um einen historischen nativen Wayland-Absturz zu vermeiden. Auf neueren Wayland-+-NVIDIA-Systemen kann das dazu führen, dass der Webinhalt nicht anklickbar ist (die Titelleisten-Schaltflächen funktionieren weiterhin) und das Fenster beim Größenändern schwarz wird. Starten Sie mit dem optionalen Notausgang, um zu nativem Wayland zu wechseln: + +```bash +CC_SWITCH_GDK_BACKEND=wayland ./CC-Switch-*.AppImage +``` + +Wenn Sie über ein Desktop-Symbol starten, fügen Sie es der `Exec=`-Zeile der `.desktop`-Datei hinzu (z. B. `env CC_SWITCH_GDK_BACKEND=wayland /pfad/zum/AppImage`) oder setzen Sie es in Ihrer Sitzungsumgebung. Die Variable ist generisch: Auf Tiling-Wayland-Compositors (sway/Hyprland), bei denen Klicks nicht reagieren, versuchen Sie umgekehrt `CC_SWITCH_GDK_BACKEND=x11`. Bleibt sie ungesetzt, bleibt das Standardverhalten erhalten. + +
+ ## Dokumentation Ausführliche Anleitungen zu jeder Funktion finden Sie im **[Benutzerhandbuch](docs/user-manual/en/README.md)** — es deckt Anbieterverwaltung, MCP/Prompts/Skills, Proxy & Failover und mehr ab. diff --git a/README_JA.md b/README_JA.md index 561c80c39..4bf50198b 100644 --- a/README_JA.md +++ b/README_JA.md @@ -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 のオールインワン管理ツール [![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) @@ -69,6 +69,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーティング、利用状況分析、動的なプロバイダー最適化、専任サポートなどのエンタープライズ機能も提供しています。さらにシンプルな体験を求める場合は、Teamo Desktop を使うことで、Claude Code、Codex、Gemini CLI、その他の人気 AI エージェントをワンクリックで利用できます。API キー管理や手動のゲートウェイ設定は不要です。新規ユーザーとしてこちらのリンクから登録すると、初回チャージが 10% オフになります。 + +ZetaAPI +本プロジェクトをご支援いただいている ZetaAPI に感謝します!ZetaAPI は、モデル品質の忠実性、水増しなし、性能劣化なし、公式価格の 35% から利用できる低価格を主な特徴としています。プラットフォームはトラフィックの混在、低品質モデルへの密かな切り替え、虚偽のモデルルーティングを行わず、Claude Code、Codex、Gemini、ChatGPT などの主要 AI モデルに対応しており、モデル品質を維持しながら API 利用コストを大幅に削減できます。同時に、ZetaAPI はエンタープライズ級の SLA 安定性保証、標準 API 互換、1つの Key による複数モデル接続、迅速な導入、従量課金などの機能を提供し、AI プロダクト、コード生成、企業内ツール、カスタマーサポート、コンテンツ生成、自動化ワークフローなどの用途に適しています。万が一、モデル品質が表記内容と一致しないことが確認された場合、ZetaAPI は 10 倍補償保証を提供し、ユーザーがより安定して、透明性高く、安心して利用できる環境を実現します。こちらのリンクから登録し、初回チャージ時にプロモコード CC-SWITCH を使用すると、CC Switch ユーザー限定の初回チャージ 10% オフ特典をご利用いただけます! + + BytePlus Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。このリンクからご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。 >>中国大陆地区的开发者请点击这里 @@ -124,6 +129,11 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ 本プロジェクトは Claude API がスポンサーです。Claude API 直結 — わずか 3 分で Claude Code や Agent アプリに接続可能。新規ユーザーにはテストクレジットを提供しています。Anthropic 公式キーおよび AWS Bedrock 公式チャネルに基づいており、リバースエンジニアリングや性能劣化はありません。Opus / Sonnet / Haiku の全モデルラインナップをサポートし、Tool Use や 1M コンテキストなどの公式機能をすべて保持しています。Claude Code ヘビーユーザー、Agent エンジニア、企業技術チームに最適です。請求書発行およびチーム対応が可能です。こちらから登録してください! + +code0.ai +本プロジェクトをご支援いただいている code0.ai に感謝します!code0.ai は開発者向けの AI コーディングサービスプラットフォームで、Claude Code、Codex、Gemini などの主要な AI コーディング機能に対応しています。個人開発者やチームが、コーディング、デバッグ、リファクタリング、自動化ワークフローで AI Agent をより安定かつ効率的に活用できるよう支援します。ccswitch ユーザーは code0.ai 公式サイト からカスタマーサポートに連絡することで、テストクレジットを受け取り、信頼性の高い AI コーディングサービスを体験できます。 + + ClaudeCN 本プロジェクトのスポンサーである ClaudeCN に感謝いたします!ClaudeCN は、実体のある企業によって運営されるエンタープライズ向け AI ゲートウェイプラットフォームです。Claude、GPT、DeepSeek など主要モデルへの高可用な商用 API アクセスを提供し、企業の調達プロセスにも対応 — 法人振込や正式契約に対応し、コンプライアンス面でも安心してご利用いただけます。こちらからご登録ください! @@ -165,8 +175,18 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ -ZetaAPI -本プロジェクトをご支援いただいている ZetaAPI に感謝します!ZetaAPI は、モデル品質の忠実性、水増しなし、性能劣化なし、公式価格の 35% から利用できる低価格を主な特徴としています。プラットフォームはトラフィックの混在、低品質モデルへの密かな切り替え、虚偽のモデルルーティングを行わず、Claude Code、Codex、Gemini、ChatGPT などの主要 AI モデルに対応しており、モデル品質を維持しながら API 利用コストを大幅に削減できます。同時に、ZetaAPI はエンタープライズ級の SLA 安定性保証、標準 API 互換、1つの Key による複数モデル接続、迅速な導入、従量課金などの機能を提供し、AI プロダクト、コード生成、企業内ツール、カスタマーサポート、コンテンツ生成、自動化ワークフローなどの用途に適しています。万が一、モデル品質が表記内容と一致しないことが確認された場合、ZetaAPI は 10 倍補償保証を提供し、ユーザーがより安定して、透明性高く、安心して利用できる環境を実現します。こちらのリンクから登録し、初回チャージ時にプロモコード CC-SWITCH を使用すると、CC Switch ユーザー限定の初回チャージ 10% オフ特典をご利用いただけます! +NekoCode +本プロジェクトをご支援いただいている NekoCode に感謝します!NekoCode は、Claude や Codex などの AI モデルに対応した、安定性・効率性・信頼性に優れた API 中継サービスを提供しています。料金体系は明瞭で、柔軟な従量課金にも対応しています。CC Switch ユーザー限定の 10%オフ特典:こちらのリンク から登録し、チャージ時にクーポンコード cc-switch を入力すると、チャージが 10%オフになります! + + + +new-api +オープンソースの AI インフラプロジェクト new-api による本プロジェクトへの多大なご支援に感謝します!new-api は QuantumNous(锟腾科技)が開発したオープンソースの AI インフラプロジェクトであり、活発さと利用規模の面でリードする LLM 統合アクセス・配信プロジェクトの一つで、開発者・チーム・企業がより低コストで管理・拡張可能な AI サービスプラットフォームを構築できるよう支援することに注力しています。同じくオープンソースエコシステムに根ざすプロジェクトとして、new-api はスポンサーシップを通じて、より多くの優れたオープンソースプロジェクトの継続的な発展を支援したいと考えています。🌟 new-api への Star で応援をお願いします:https://github.com/QuantumNous/new-api。公式サイト:https://www.newapi.ai/。 + + + +SubRouter +本プロジェクトをご支援いただいている SubRouter に感謝します!SubRouter は、AI サービス事業者向けのマーケットプレイス兼スマートルーティングプラットフォームです。事業者は独立した運営サイトを立ち上げ、プランを公開し、ユーザー・モデル・価格を管理でき、ユーザーはマーケットでサービスを見つけ、統一された API を通じて安定かつ高効率なモデル呼び出しを利用できます。こちらのリンクから登録してください! @@ -175,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 対応のネイティブデスクトップアプリ @@ -199,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 ファイルからワンクリックインストール、カスタムリポジトリ管理、シンボリックリンクとファイルコピーに対応 @@ -234,7 +254,7 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
CC Switch はどの AI ツールに対応していますか? -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 つのツールに対応しています。各ツールに専用のプロバイダプリセットと設定管理が用意されています。
@@ -284,6 +304,19 @@ CC Switch は「最小限の介入」という設計原則に従っています +
+Linux(Wayland + NVIDIA):Web コンテンツがクリックできない・リサイズで黒画面になる + +AppImage は過去のネイティブ Wayland クラッシュを避けるため `GDK_BACKEND=x11`(XWayland)を強制します。新しい Wayland + NVIDIA 環境ではこれが原因で Web コンテンツ領域がクリックできなくなり(タイトルバーのボタンは動作します)、リサイズ時に黒画面になることがあります。内蔵のエスケープハッチでネイティブ Wayland に戻せます: + +```bash +CC_SWITCH_GDK_BACKEND=wayland ./CC-Switch-*.AppImage +``` + +デスクトップアイコンから起動する場合は、`.desktop` の `Exec=` 行に追記するか(例:`env CC_SWITCH_GDK_BACKEND=wayland /path/to/AppImage`)、セッション環境で設定してください。この変数は汎用です:タイル型 Wayland コンポジタ(sway/Hyprland)でクリックが効かない場合は、逆に `CC_SWITCH_GDK_BACKEND=x11` を試してください。未設定の場合は既定の動作のままです。 + +
+ ## ドキュメント 各機能の詳しい使い方については、**[ユーザーマニュアル](docs/user-manual/ja/README.md)** をご覧ください。プロバイダ管理、MCP/Prompts/Skills、プロキシとフェイルオーバーなど、すべての機能を網羅しています。 diff --git a/README_ZH.md b/README_ZH.md index b3e037381..777ac74c8 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -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 的全方位管理工具 [![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) @@ -69,6 +69,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK、智能路由、用量分析、动态提供商优化和专属支持。为了获得更简单的使用体验,Teamo Desktop 支持你一键使用 Claude Code、Codex、Gemini CLI 和其他热门 AI Agent,无需管理 API Key,也无需手动配置网关。新用户通过此链接注册,首次充值可享受 10% 折扣。 + +ZetaAPI +感谢 ZetaAPI 赞助本项目!ZetaAPI 主打模型不掺水、保真不降智、价格低至官方价 35 折,平台不混量、不暗中替换低质量模型、不做虚假路由,支持 Claude Code、Codex、Gemini、ChatGPT 等主流模型接入,帮助用户在保证模型质量的同时大幅降低 API 使用成本。同时,ZetaAPI 提供企业级 SLA 稳定性保障、标准接口兼容、一个 Key 接入多模型、快速集成、按量计费等能力,适用于 AI 产品、代码生成、企业内部工具、客服系统、内容生产和自动化流程等场景。若经验证发现模型质量与标称不符,ZetaAPI 承诺假一赔十,让用户用得更稳定、更透明、更放心。通过此链接注册,并在首次充值时使用优惠码 CC-SWITCH,即可享受 CC Switch 用户专属的首次充值九折优惠! + + HuoShan 感谢火山方舟 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 的用户提供了专属福利:通过此链接订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!>>For developers outside Mainland China, please click here @@ -125,6 +130,11 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK 本项目由 Claude API 赞助。Claude API 直连,三分钟接入 Claude Code 与 Agent 应用 新用户可领取测试额度。基于 Anthropic 官方 Key + AWS Bedrock 官方渠道,非逆向、非降智,支持 Opus / Sonnet / Haiku 全系列模型,保留 Tool Use、1M 上下文等官方能力。适合 Claude Code 深度用户、Agent 工程师与企业技术团队,支持开票和团队对接。点击这里注册! + +code0.ai +感谢 code0.ai 赞助本项目!code0.ai 是专为开发者打造的 AI 编程服务平台,支持 Claude Code、Codex、Gemini 等主流 AI 编程能力,帮助个人开发者和团队更稳定、更高效地使用 AI Agent 完成代码开发、调试与自动化任务。ccswitch 用户可通过 code0.ai 官网 联系客服领取测试额度,体验高效稳定的 AI 编程服务! + + ClaudeCN 感谢 ClaudeCN 赞助本项目!ClaudeCN 由是一家实体企业运营的企业级AI中转平台。平台可提供高可用性的商用API服务,提供Claude、GPT、Deepseek等热门模型,支持企业采购流程,可对公打款、签约,服务合规有保障。点击此链接注册! @@ -166,8 +176,18 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK -ZetaAPI -感谢 ZetaAPI 赞助本项目!ZetaAPI 主打模型不掺水、保真不降智、价格低至官方价 35 折,平台不混量、不暗中替换低质量模型、不做虚假路由,支持 Claude Code、Codex、Gemini、ChatGPT 等主流模型接入,帮助用户在保证模型质量的同时大幅降低 API 使用成本。同时,ZetaAPI 提供企业级 SLA 稳定性保障、标准接口兼容、一个 Key 接入多模型、快速集成、按量计费等能力,适用于 AI 产品、代码生成、企业内部工具、客服系统、内容生产和自动化流程等场景。若经验证发现模型质量与标称不符,ZetaAPI 承诺假一赔十,让用户用得更稳定、更透明、更放心。通过此链接注册,并在首次充值时使用优惠码 CC-SWITCH,即可享受 CC Switch 用户专属的首次充值九折优惠! +NekoCode +感谢 NekoCode 赞助本项目!NekoCode 为开发者提供稳定、高效、可靠的 Claude、Codex 等 AI 模型 API 中转服务,价格透明,接入便捷,支持灵活的按量计费。CC Switch 用户专享 9 折福利:通过 此链接 注册,并在充值时输入优惠码 cc-switch,即可享受充值 9 折优惠! + + + +new-api +感谢开源 AI 基础设施项目 new-api 对本项目的鼎力支持!new-api 是由 QuantumNous(锟腾科技)推出的开源 AI 基础设施项目,也是活跃度与使用规模领先的大模型统一接入与分发项目之一,专注于帮助开发者、团队和企业以更低成本构建可管理、可扩展的 AI 服务平台。作为同样扎根开源生态的项目,new-api 希望通过赞助支持更多优秀开源项目持续发展。🌟 欢迎 Star 支持 new-api:https://github.com/QuantumNous/new-api,官网:https://www.newapi.ai/。 + + + +SubRouter +感谢 SubRouter 赞助本项目!SubRouter 是面向 AI 服务经营者的公开市场与智能路由平台。商家可快速开通独立经营站,发布套餐、管理用户与模型价格;用户可在市场发现服务,并通过统一 API 获得稳定高效的模型调用。通过此链接注册! @@ -176,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 @@ -200,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 文件一键安装,自定义仓库管理,支持软连接和文件复制 @@ -235,7 +255,7 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
CC Switch 支持哪些 AI 工具? -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**。每个工具都有专属的供应商预设和配置管理。
@@ -287,6 +307,19 @@ CC Switch macOS 版本已通过 Apple 代码签名和公证,可直接下载安 +
+Linux(Wayland + NVIDIA):网页内容点不动、缩放后黑屏 + +AppImage 会强制 `GDK_BACKEND=x11`(走 XWayland)以规避历史上的原生 Wayland 崩溃。但在较新的 Wayland + NVIDIA 环境下,这会导致网页内容区点不动(标题栏按钮仍可点)、窗口缩放后黑屏。可用内置的逃生开关切回原生 Wayland: + +```bash +CC_SWITCH_GDK_BACKEND=wayland ./CC-Switch-*.AppImage +``` + +如果你是从桌面图标启动的,请把它写进 `.desktop` 的 `Exec=` 行(如 `env CC_SWITCH_GDK_BACKEND=wayland /path/to/AppImage`),或在会话环境中设置。该变量是通用的:在 tiling Wayland 合成器(sway/Hyprland)下若出现点击失效,可反过来设 `CC_SWITCH_GDK_BACKEND=x11`。不设置则保持默认行为。 + +
+ ## 文档 如需了解各项功能的详细使用方法,请查阅 **[用户手册](docs/user-manual/zh/README.md)** — 涵盖供应商管理、MCP/Prompts/Skills、代理与故障转移等全部功能。 diff --git a/assets/partners/logos/code0.png b/assets/partners/logos/code0.png new file mode 100644 index 000000000..44fa37c7d Binary files /dev/null and b/assets/partners/logos/code0.png differ diff --git a/assets/partners/logos/nekocode-banner.png b/assets/partners/logos/nekocode-banner.png new file mode 100644 index 000000000..eefe28d9a Binary files /dev/null and b/assets/partners/logos/nekocode-banner.png differ diff --git a/assets/partners/logos/newapi-banner.png b/assets/partners/logos/newapi-banner.png new file mode 100644 index 000000000..988a78b82 Binary files /dev/null and b/assets/partners/logos/newapi-banner.png differ diff --git a/assets/partners/logos/subrouter-banner.png b/assets/partners/logos/subrouter-banner.png new file mode 100644 index 000000000..7fef8a00a Binary files /dev/null and b/assets/partners/logos/subrouter-banner.png differ diff --git a/docs/guides/codex-claude-routing-guide-en.md b/docs/guides/codex-claude-routing-guide-en.md new file mode 100644 index 000000000..26fa9529c --- /dev/null +++ b/docs/guides/codex-claude-routing-guide-en.md @@ -0,0 +1,129 @@ +# Using Claude in Codex: CC Switch Local Routing Guide + +> 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. + +## Why local routing is needed + +The newer Codex CLI targets the OpenAI Responses API, while the various Claude-family relay gateways and internal enterprise gateways expose the Anthropic Messages protocol — that is, `/v1/messages`. These two protocols use completely different request bodies, streaming events, and response structures, so putting such a gateway's endpoint directly into Codex configuration can only result in a request to `/responses` coming back 404. + +This feature targets the scenario where all you have is a `/v1/messages` endpoint: you have a key for some Claude-family relay gateway and want to run Claude-family models with Codex's interaction style; or your company has banned the Claude Code client for compliance reasons and kept only an approved Claude-family gateway — the model itself is available, and all that's missing is a permitted client, which Codex can now fill. + +CC Switch's approach is to keep Codex always talking to the local route and still sending Responses API requests; once the route detects that the active provider is Anthropic-format, it converts the request into Anthropic Messages for the upstream, then converts the response back into the Responses shape it returns to Codex. + +![Needs routing marker in the Codex provider list](../images/codex-claude-routing/01-codex-providers-require-routing.png) + +The chain has four main steps: + +1. When Codex is taken over, the local configuration is written as `http://127.0.0.1:15721/v1`, and `wire_api = "responses"` is forcibly kept in place. +2. The provider's `anthropic` upstream format tells the route that the real upstream speaks the Anthropic Messages protocol. +3. The route rewrites `/responses` to `/v1/messages` and converts the Responses request body into an Anthropic request body. +4. After the upstream responds, the route converts the Anthropic JSON or SSE back into the Responses JSON/SSE that Codex understands — reasoning content, tool calls, and images are all within the conversion scope. + +## Prerequisites + +Prepare these three things first: + +- CC Switch installed and able to start (3.17.0 or later). +- Codex CLI installed and run at least once, so the `~/.codex/` directory structure exists. +- An API key that can reach an Anthropic Messages protocol endpoint (`/v1/messages`) — from some Claude-family relay gateway, or an internal enterprise Claude gateway; follow the gateway's documentation for its endpoint and auth method. Note: some providers restrict their Claude API to Claude Code only, so such a key may error out when used through Codex — if you're unsure, check with your provider first. + +The Codex tab currently has no built-in Anthropic preset, so the steps below use the `Custom Configuration` path — just four or five fields from start to finish. + +## Step 1: Add a Codex provider + +Open CC Switch, switch to the top-level `Codex` 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. `Claude Gateway`. +- **API Key**: your gateway key. The real key is stored only in CC Switch and injected by the local route when forwarding, so it never enters Codex's live config. +- **API Request URL**: just the gateway's service root, e.g. `https://claude-gateway.example.com`. It works with or without a trailing `/v1` — the route sends requests to `/v1/messages` automatically; don't assemble `/v1/messages` yourself (if your gateway documentation gives you a complete messages URL, you can turn on the `Full URL` toggle next to it and paste it verbatim). The yellow hint below the address bar, "compatible with OpenAI Response format", is generic copy written for the direct Responses scenario; when you choose the Anthropic format, just fill it in as this guide describes. +- **Default Model**: enter a Claude model id the gateway recognizes, e.g. `claude-sonnet-5`; follow the model names in the gateway's documentation. + +Then expand `Advanced Options` and change `Upstream Format` from the default `Responses (native)` to **`Anthropic Messages (routing required)`**. + +![Codex provider form for a Claude gateway](../images/codex-claude-routing/02-claude-codex-provider-form.png) + +After selecting Anthropic Messages, three supporting fields appear below: + +![Advanced options for the Anthropic upstream](../images/codex-claude-routing/03-anthropic-advanced-options.png) + +- **Auth field**: determines which header carries the API key to the upstream; only one of the two is sent — choose per your gateway's documentation. + - `ANTHROPIC_AUTH_TOKEN (Authorization)`: sends `Authorization: Bearer `. This is the default, and most Claude-family relay gateways use it. + - `ANTHROPIC_API_KEY (x-api-key)`: sends `x-api-key: `. Some gateways that follow Anthropic's native header convention require this. Picking the wrong one usually shows up as 401 / 403. +- **Emulate Claude Code client**: off by default. Turn it on only when the gateway or its upstream restricts usage to "Claude Code only"; when enabled, it spoofs the User-Agent, `anthropic-beta`, and `x-app` headers and injects the Claude Code identity as the first line of the system prompt. Ordinary gateways don't need it; if you're still rejected after enabling it, see "FAQ". +- **Max output tokens**: the Anthropic protocol's `max_tokens` is required, and when a Codex request carries no output ceiling the route falls back to a conservative 8192, which may truncate long answers or deep reasoning (showing up as an incomplete reply, `stop_reason=max_tokens`). If you hit truncation, raise this to the model's real ceiling here — but don't exceed it, or the upstream will 400 outright. + +The `Model Mapping` in the same area is optional: add model ids like `claude-opus-4-8`, `claude-sonnet-5`, and `claude-haiku-4-5-20251001` (use the names your upstream recognizes) one per row, and CC Switch generates a model catalog so Codex's `/model` menu can list them; you can also leave it empty, in which case Codex just requests the default model. + +After you save the provider, a `Needs Routing` marker appears on the card — providers like this only work while local routing is running. + +## Step 2: Enable local routing and take over Codex + +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 `Codex` under `Routing Enabled`. If you only want Codex to use routing, you can leave Claude and Gemini off. + +![Enabling Codex takeover on the local routing page](../images/codex-claude-routing/04-local-route-codex-takeover.png) + +After takeover, CC Switch points Codex's live config at the local route (`base_url = http://127.0.0.1:15721/v1`), with only a placeholder in `auth.json`. The real Claude key stays in the CC Switch provider config and is injected by the local route on forward, using the auth field you selected. + +## Step 3: Switch providers and restart Codex + +Return to the Codex provider list and click `Enable` on the Claude provider. If routing isn't running, CC Switch shows "This provider uses Anthropic Messages API format, requires the routing service to work properly. Start routing first." — just go back to Step 2 and turn it on. + +After switching, restart the current Codex terminal session: `config.toml` and the model catalog are read when the Codex process starts, and a running process isn't guaranteed to hot-load them. + +Inside Codex you can verify step by step: + +- If you configured model mapping, use `/model` to check whether the Claude models now appear in the menu; without a mapping, Codex just uses the default model. +- Send a small question and watch the "Current Provider" on the Settings → Routing page change from "Waiting for first request..." to your Claude provider, with "Total Requests" starting to climb. +- In the usage dashboard, these requests show their model names faithfully as `claude-*`, and you can filter by provider to reconcile token usage. + +## Capabilities and known limitations + +- **Prompt caching is automatic**: the conversion bridge injects standard 5-minute prompt-cache markers (system prompt, tool definitions, and conversation history) per Anthropic's convention, so long conversations don't resend everything at full price each turn — no configuration needed. +- **Reasoning and tools are lossless**: extended thinking content round-trips across the bridge intact, and multi-turn tool calls, image inputs, and PDF inputs are all fully converted. +- **Supports the `[1m]` long-context marker**: when the default model or a model id in the mapping ends with `[1m]` (e.g. `claude-sonnet-5[1m]`), the route strips the marker and automatically adds the corresponding 1M-context beta header, provided the gateway supports that capability. +- **Web search is unavailable**: in Anthropic upstream mode, Codex's built-in `web_search` is deliberately disabled — the conversion layer can't translate it for the Anthropic endpoint, and disabling it avoids presenting the model with a tool that's guaranteed to fail. +- **Truncation is reported faithfully**: when the upstream stops at the output ceiling or the stream is cut off, Codex sees "incomplete" rather than a disguised success, making it easy to notice and raise the max output tokens. + +## FAQ + +**The upstream returns 401 or 403** + +Nine times out of ten the auth field doesn't match what the gateway wants: switch between `ANTHROPIC_AUTH_TOKEN (Authorization)` and `ANTHROPIC_API_KEY (x-api-key)` per your gateway's documentation and try again (most gateways use the default Bearer). Also confirm the key itself is valid and has balance. + +**Codex reports 404 or cannot find `/responses`** + +Usually Codex routing takeover isn't enabled, or you manually wrote the gateway's address directly into Codex — an Anthropic-protocol upstream has no `/responses` endpoint, so that always 404s. Check whether the current provider's `base_url` in `~/.codex/config.toml` points to `http://127.0.0.1:15721/v1`. + +**The upstream returns 404 (routing already enabled)** + +Check the API Request URL: it should be the gateway's service root, not an address carrying another protocol's path such as `/chat/completions`. When the gateway path is unusual, use the `Full URL` toggle to paste the complete messages endpoint directly. + +**Replies often get cut off mid-way** + +This is the default 8192 output ceiling showing up. Raise it in `Max output tokens` under the provider form's Advanced Options (don't exceed the model's/gateway's real ceiling), save, and retry. + +**`/model` doesn't show the Claude models** + +Confirm you've added entries to the model mapping, then restart Codex after saving the provider — the model catalog isn't hot-loaded by a running process. When the default model isn't in the mapping, the menu won't list it, but a direct request still works. + +**Web search doesn't work** + +By design; see "Capabilities and known limitations". For tasks that need web search, switch back to a Responses/Chat-format provider. + +**An error says usage is restricted to Claude Code** + +Some providers restrict their Claude API to the Claude Code client, so it gets rejected when going through this guide's chain via Codex. Try turning on the `Emulate Claude Code client` toggle in Advanced Options; if it still errors after that, the restriction is enforced on the provider's side — check with your provider whether your key can be used outside Claude Code. Keep this toggle off for ordinary gateways. + +## Compliance note + +Before using this in the "company bans the client but keeps only the gateway" scenario, it's worth confirming that doing so complies with your organization's specific policy — whether what's banned is a specific client or a manner of use differs from one place to the next. When using a third-party relay gateway, read the target gateway's terms on billing, compliance, and data retention. + +## References + +- [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) +- This feature comes from community contribution [#5071](https://github.com/farion1231/cc-switch/pull/5071); thanks @yeeyzy. diff --git a/docs/guides/codex-claude-routing-guide-ja.md b/docs/guides/codex-claude-routing-guide-ja.md new file mode 100644 index 000000000..1724af20c --- /dev/null +++ b/docs/guides/codex-claude-routing-guide-ja.md @@ -0,0 +1,129 @@ +# Codex で Claude を使う: CC Switch ローカルルーティングガイド + +> 対象バージョン: CC Switch 3.17.0 以降(「Anthropic Messages 上流」は 3.17.0 から導入)。本記事はリポジトリ内のドキュメントとコードをもとに整理し、Claude 系中継ゲートウェイを例に説明します。スクリーンショットは現在のフロントエンド UI から、実際の API Key が漏れないよう匿名化したサンプルデータで生成しています。 + +## ローカルルーティングが必要な理由 + +新しい Codex CLI は OpenAI Responses API を前提にしています。一方で各種 Claude 系中継ゲートウェイや企業内部ゲートウェイが公開しているのは Anthropic Messages プロトコル、つまり `/v1/messages` です。この 2 つのプロトコルは、リクエストボディ、ストリーミングイベント、レスポンス構造がまったく異なります。この種のゲートウェイのエンドポイントをそのまま Codex 設定に入れても、`/responses` へのリクエストが 404 になるだけです。 + +この機能は「手元にあるのが `/v1/messages` エンドポイントだけ」という場面のためのものです。ある Claude 系中継ゲートウェイの Key を持っていて、Codex の操作感で Claude 系モデルを使いたい。あるいは会社がコンプライアンス方針で Claude Code クライアントを禁止し、承認済みの Claude 系ゲートウェイだけを残している——モデル自体は利用できるのに、許可されたクライアントがないだけです。その空白を Codex で埋められます。 + +CC Switch では、Codex が常にローカルルートへ接続し、Responses API のままリクエストを送るようにします。ルートは現在のプロバイダーが Anthropic 形式だと判定すると、リクエストを Anthropic Messages に変換して上流へ送り、最後にレスポンスを Responses 形式へ戻して Codex に返します。 + +![Codex プロバイダー一覧の「ルーティングが必要」マーク](../images/codex-claude-routing/01-codex-providers-require-routing.png) + +この経路は主に 4 つのステップに分かれます: + +1. Codex を引き継ぐと、ローカル設定は `http://127.0.0.1:15721/v1` に書き換えられ、`wire_api = "responses"` が強制的に維持されます。 +2. プロバイダーの上流フォーマット `anthropic` が、実際の上流は Anthropic Messages プロトコルだとルートに伝えます。 +3. ルートは `/responses` を `/v1/messages` に書き換え、Responses のリクエストボディを Anthropic のリクエストボディへ変換します。 +4. 上流から返ってきた後、ルートは Anthropic の JSON または SSE を Codex が理解できる Responses JSON/SSE へ変換して返します——推論内容、ツール呼び出し、画像もすべて変換対象です。 + +## 事前準備 + +先に次の 3 つを用意してください: + +- インストール済みで起動できる CC Switch(3.17.0 以降)。 +- インストール済みの Codex CLI。少なくとも 1 回は実行し、`~/.codex/` のディレクトリ構造が存在していること。 +- Anthropic Messages プロトコルのエンドポイント(`/v1/messages`)へアクセスできる API Key——ある Claude 系中継ゲートウェイ、または企業内部の Claude ゲートウェイのもの。エンドポイントと認証方式はゲートウェイのドキュメントに従ってください。注意:一部のプロバイダーは Claude API を Claude Code 内でのみ利用できるよう制限しており、この種の Key を Codex で使うとエラーになることがあります。判断がつかない場合は、先にプロバイダーへ問い合わせてください。 + +Codex タブには現時点で Anthropic の内蔵プリセットがないため、以下では「カスタム設定」の手順で進めます。入力する項目は全体でも 4〜5 個です。 + +## Step 1: Codex プロバイダーを追加する + +CC Switch を開き、上部の `Codex` タブへ切り替え、右上のプラスボタンからプロバイダーを追加します。デフォルトの `カスタム設定` のまま、次の項目を入力します: + +- **プロバイダー名**: 任意です。たとえば `Claude Gateway`。 +- **API Key**: あなたのゲートウェイの Key。実際の Key は CC Switch 内にのみ保存され、ローカルルートが転送時に注入するため、Codex の live 設定には入りません。 +- **API エンドポイント**: ゲートウェイのルートアドレスを入力すれば十分です。たとえば `https://claude-gateway.example.com`。`/v1` は付けても付けなくても正しく処理され、ルートが自動的に `/v1/messages` へリクエストを送ります。自分で `/v1/messages` を組み立てないでください(ゲートウェイのドキュメントが完全な messages URL を指定している場合は、隣の `フル URL` スイッチをオンにしてそのまま貼り付けても構いません)。アドレス欄の下に表示される「OpenAI Response 互換」という黄色のヒントは Responses 直結向けの汎用文言です。Anthropic フォーマットを選ぶ場合は本記事のとおりに入力してください。 +- **デフォルトモデル**: ゲートウェイが認識する Claude モデル ID を入力します。たとえば `claude-sonnet-5`。ゲートウェイのドキュメントにあるモデル名に従ってください。 + +続いて `高級オプション` を展開し、`上流フォーマット` をデフォルトの `Responses(ネイティブ)` から **`Anthropic Messages(ルーティング必須)`** に変更します。 + +![Claude ゲートウェイの Codex プロバイダーフォーム](../images/codex-claude-routing/02-claude-codex-provider-form.png) + +Anthropic Messages を選ぶと、下に 3 つの関連フィールドが追加で表示されます: + +![Anthropic 上流の高級オプション](../images/codex-claude-routing/03-anthropic-advanced-options.png) + +- **認証フィールド**: API Key をどのヘッダーで上流へ送るかを決めます。送信されるのはどちらか一方のみで、ゲートウェイのドキュメントに従って選びます。 + - `ANTHROPIC_AUTH_TOKEN(Authorization)`: `Authorization: Bearer ` を送信します。デフォルト値で、多くの Claude 系中継ゲートウェイがこの方式を使います。 + - `ANTHROPIC_API_KEY(x-api-key)`: `x-api-key: ` を送信します。Anthropic ネイティブのヘッダー規約を踏襲する一部のゲートウェイはこちらを要求します。選択を誤ると、通常 401 / 403 という形で現れます。 +- **Claude Code クライアントを模倣**: デフォルトはオフです。ゲートウェイ(またはその上流)が「Claude Code からのみ利用可能」と制限している場合にのみオンにします。オンにすると User-Agent・`anthropic-beta`・`x-app` ヘッダーを偽装し、システムプロンプトの先頭行に Claude Code のアイデンティティを注入します。通常のゲートウェイでは不要です。オンにしても拒否される場合の対処は「よくある質問」を参照してください。 +- **最大出力トークン**: Anthropic プロトコルの `max_tokens` は必須項目です。Codex のリクエストが出力上限を含まない場合、ルートは保守的に 8192 で補います。長い回答や深い思考では切り詰められることがあります(回答が不完全になる、`stop_reason=max_tokens` になる、といった形で現れます)。切り詰められたら、ここでモデルの実際の上限に合わせて引き上げてください。ただし超えないように——超えると上流が直接 400 を返します。 + +同じエリアの `モデルマッピング` は任意です。`claude-opus-4-8`、`claude-sonnet-5`、`claude-haiku-4-5-20251001` のようなモデル ID(上流が認識する名前に従ってください)を 1 行ずつ追加すると、CC Switch がモデルカタログを生成し、Codex の `/model` メニューに一覧表示できるようになります。入力しなくても利用でき、その場合 Codex はデフォルトモデルを直接リクエストします。 + +プロバイダーを保存すると、カードに `ルーティングが必要` のマークが表示されます——この種のプロバイダーは、ローカルルーティングが実行中でなければ正しく動作しません。 + +## Step 2: ローカルルーティングを有効にして Codex をルーティングする + +設定の `ルーティング` ページに入り、`ローカルルーティング` を展開して、2 つのスイッチを設定します: + +1. `ルーティング総スイッチ` をオンにしてローカルサービスを起動します(初回起動時は説明の確認ダイアログが表示されます)。デフォルトアドレスは `127.0.0.1:15721` です。 +2. `ルーティング有効` で `Codex` をオンにします。Codex だけをルーティングしたい場合は、Claude と Gemini はオフのままで構いません。 + +![ローカルルーティング画面で Codex のルーティングを有効化](../images/codex-claude-routing/04-local-route-codex-takeover.png) + +引き継ぎ後、CC Switch は Codex の live 設定をローカルルートへ向け(`base_url = http://127.0.0.1:15721/v1`)、`auth.json` にはプレースホルダーだけが入ります。実際の Claude Key は CC Switch のプロバイダー設定内に残り、ローカルルートが転送時に、あなたが選んだ認証フィールドに従って注入します。 + +## Step 3: プロバイダーを切り替えて Codex を再起動する + +Codex プロバイダー一覧に戻り、Claude プロバイダーの `有効化` をクリックします。ルーティングが実行されていない場合、CC Switch は「このプロバイダーは Anthropic Messages API フォーマットを使用しており、ルーティングサービスが必要です。先にルーティングを起動してください」と表示します——Step 2 に戻ってオンにすれば解決します。 + +切り替え後は、現在の Codex ターミナルセッションを再起動することをおすすめします。`config.toml` とモデルカタログは Codex プロセスの起動時に読み込まれるため、実行中のプロセスがホットロードするとは限りません。 + +Codex に入ったら、段階的に確認できます: + +- モデルマッピングを設定している場合は、`/model` で Claude モデルがメニューに表示されているか確認します。マッピングを設定していない場合、Codex はデフォルトモデルを直接使います。 +- 小さな質問を 1 つ送り、設定 → ルーティングページの「現在のプロバイダー」が「最初のリクエスト待ち」からあなたの Claude プロバイダーに変わり、「総リクエスト数」が増え始めるのを確認します。 +- 使用量ダッシュボードでは、これらのリクエストのモデル名が `claude-*` としてそのまま表示され、プロバイダーで絞り込んで token 使用量を照合できます。 + +## 機能の範囲と既知の制限 + +- **プロンプトキャッシュが自動で有効**: 変換ブリッジは Anthropic 標準に従って 5 分のプロンプトキャッシュマーカー(システムプロンプト、ツール定義、対話履歴)を注入します。長い対話でも毎回全額で再送されることはなく、設定は不要です。 +- **推論とツールを無損失で往復**: extended thinking の内容はブリッジをまたいで往復しても保持され、複数ターンのツール呼び出し、画像、PDF 入力も完全に変換されます。 +- **`[1m]` 長コンテキストマーカーに対応**: デフォルトモデルやモデルマッピングのモデル ID が `[1m]` で終わる場合(例:`claude-sonnet-5[1m]`)、ルートはマーカーを取り除き、対応する 1M コンテキストの beta ヘッダーを自動で補います。ただし、ゲートウェイがその機能に対応していることが前提です。 +- **Web 検索は利用不可**: Anthropic 上流モードでは Codex の内蔵 `web_search` が意図的に無効化されます——変換層が Anthropic エンドポイントへ翻訳できないためで、必ず失敗するツールをモデルに提示しないための措置です。 +- **切り詰めをそのまま報告**: 上流が出力上限で止まったりストリームが切断されたりすると、Codex は偽装された成功ではなく「未完了」を受け取ります。これにより気づきやすくなり、最大出力トークンを引き上げる判断ができます。 + +## よくある質問 + +**上流が 401 または 403 を返す** + +ほとんどの場合、認証フィールドがゲートウェイの要求と一致していません。`ANTHROPIC_AUTH_TOKEN(Authorization)` と `ANTHROPIC_API_KEY(x-api-key)` を、ゲートウェイのドキュメントに従って切り替えて再試行してください(多くのゲートウェイはデフォルトの Bearer です)。あわせて、Key 自体が有効で残高があることも確認してください。 + +**Codex が 404 を返す、または `/responses` が見つからない** + +多くの場合、Codex のルーティング引き継ぎが有効になっていないか、ゲートウェイのエンドポイントを手動で Codex に直接書いています——Anthropic プロトコルの上流には `/responses` エンドポイントが存在しないため、必ず 404 になります。`~/.codex/config.toml` の現在の provider の `base_url` が `http://127.0.0.1:15721/v1` を指しているか確認してください。 + +**上流が 404 を返す(ルーティングは有効)** + +API エンドポイントを確認してください。ゲートウェイのルートアドレスであるべきで、`/chat/completions` のような別プロトコルのパスが付いたアドレスではいけません。ゲートウェイのパスが特殊な場合は、`フル URL` スイッチを使って完全な messages エンドポイントをそのまま貼り付けてください。 + +**回答が途中で切り詰められることが多い** + +これはデフォルトの 8192 出力上限の現れです。プロバイダーフォームの高級オプションにある `最大出力トークン` で引き上げ(モデル / ゲートウェイの実際の上限を超えないように)、保存してから再試行してください。 + +**`/model` に Claude モデルが表示されない** + +モデルマッピングにエントリが追加されていることを確認し、プロバイダーを保存してから Codex を再起動してください——モデルカタログは実行中のプロセスにはホットロードされません。デフォルトモデルがマッピングに含まれていない場合、メニューには表示されませんが、直接リクエストは有効です。 + +**Web 検索が使えない** + +仕様どおりです。「機能の範囲と既知の制限」を参照してください。Web 検索が必要なタスクは、Responses / Chat フォーマットのプロバイダーへ切り替えることをおすすめします。 + +**Claude Code でしか使えないというエラーが出る** + +一部のプロバイダーは、その Claude API を Claude Code クライアント内でのみ利用できるよう制限しており、本ガイドの経路で Codex から使うと拒否されます。高級オプションの `Claude Code クライアントを模倣` スイッチをオンにして試すことはできますが、それでもエラーになる場合、制限はプロバイダーのサーバー側にあります。その Key を Claude Code 以外で使えるかどうか、プロバイダーへ問い合わせて確認してください。通常のゲートウェイでは、このスイッチはオフのままにしてください。 + +## コンプライアンスに関する注意 + +「会社がクライアントを禁止し、ゲートウェイだけを残している」という場面で使う前に、この使い方が所属組織の具体的な方針に沿っているかを確認することをおすすめします——禁止されているのが特定のクライアントなのか、それともある種の利用方法なのかは、組織によって解釈が異なります。サードパーティ中継ゲートウェイを使う場合は、対象ゲートウェイの課金・コンプライアンス・データ保持に関する規約を必ずお読みください。 + +## 参考リンク + +- [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) +- この機能はコミュニティからの貢献 [#5071](https://github.com/farion1231/cc-switch/pull/5071) によるものです。@yeeyzy に感謝します。 diff --git a/docs/guides/codex-claude-routing-guide-zh.md b/docs/guides/codex-claude-routing-guide-zh.md new file mode 100644 index 000000000..73b164c6c --- /dev/null +++ b/docs/guides/codex-claude-routing-guide-zh.md @@ -0,0 +1,129 @@ +# 在 Codex 中用 Claude:CC Switch 本地路由攻略 + +> 适用版本:CC Switch 3.17.0 及以上(「Anthropic Messages 上游」自 3.17.0 引入)。本文根据仓库内文档与代码整理,以 Claude 系中转网关为例演示。截图来自当前前端界面,使用去敏示例数据生成,避免泄露真实 API Key。 + +## 为什么需要本地路由 + +新版 Codex CLI 面向的是 OpenAI Responses API,而各类 Claude 系中转网关、企业内部网关暴露的是 Anthropic Messages 协议,也就是 `/v1/messages`。这两种协议的请求体、流式事件和返回结构完全不同,把这类网关的接口地址直接填进 Codex 配置里,结果只能是请求 `/responses` 返回 404。 + +这个功能面向的就是「手里只有 `/v1/messages` 端点」的场景:你有某个 Claude 系中转网关的 Key,想用 Codex 的交互习惯跑 Claude 系列模型;或者公司出于合规策略禁用了 Claude Code 客户端、只保留了经批准的 Claude 系网关——模型本身可用,缺的只是一个被允许的客户端,现在 Codex 可以补上这个位置。 + +CC Switch 的做法是让 Codex 始终连本机路由,仍以 Responses API 发送请求;路由识别当前供应商是 Anthropic 格式后,把请求转换成 Anthropic Messages 发给上游,再把响应转换回 Responses 形态返回给 Codex。 + +![Codex 供应商列表里的需要路由标记](../images/codex-claude-routing/01-codex-providers-require-routing.png) + +这条链路主要分成四步: + +1. Codex 接管时,本地配置会被写成 `http://127.0.0.1:15721/v1`,并强制保持 `wire_api = "responses"`。 +2. 供应商的上游格式 `anthropic` 会告诉路由:真实上游说的是 Anthropic Messages 协议。 +3. 路由把 `/responses` 改写到 `/v1/messages`,并把 Responses 请求体转换成 Anthropic 请求体。 +4. 上游返回后,路由再把 Anthropic 的 JSON 或 SSE 转回 Codex 能理解的 Responses JSON/SSE——推理内容、工具调用、图片都在转换范围内。 + +## 准备工作 + +你需要先准备好三样东西: + +- 已安装并能启动的 CC Switch(3.17.0 及以上)。 +- 已安装 Codex CLI,并至少运行过一次,让 `~/.codex/` 目录结构存在。 +- 一个能访问 Anthropic Messages 协议端点(`/v1/messages`)的 API Key——来自某个 Claude 系中转网关,或企业内部的 Claude 网关;端点地址和认证方式以网关文档为准。注意:部分供应商会限制其 Claude API 只能在 Claude Code 中使用,这类 Key 走 Codex 可能会报错,拿不准就先咨询供应商。 + +Codex 页签目前没有 Anthropic 内置预设,下面走「自定义配置」路径,全程也就四五个字段。 + +## 第一步:添加 Codex 供应商 + +打开 CC Switch,切到顶部的 `Codex` 标签,点击右上角的加号添加供应商,保持默认的 `自定义配置`,然后填写: + +- **供应商名称**:随意,例如 `Claude Gateway`。 +- **API Key**:你的网关 Key。真实 Key 只保存在 CC Switch 里,由本地路由转发时注入,不会进入 Codex 的 live 配置。 +- **API 请求地址**:填网关服务根地址即可,例如 `https://claude-gateway.example.com`。带不带 `/v1` 都能被正确处理,路由会自动把请求打到 `/v1/messages`;不要自己拼 `/v1/messages`(如果网关文档给的就是完整 messages URL,打开旁边的 `完整 URL` 开关原样粘贴也可以)。地址栏下方那句「兼容 OpenAI Response 格式」的黄色提示是为 Responses 直连场景写的通用文案,选 Anthropic 格式时按本文填写即可。 +- **默认模型**:填网关认识的 Claude 模型 id,例如 `claude-sonnet-5`,以网关文档给出的模型名为准。 + +然后展开 `高级选项`,把 `上游格式` 从默认的 `Responses(原生)` 改成 **`Anthropic Messages(需开启路由)`**。 + +![Claude 网关的 Codex 供应商表单](../images/codex-claude-routing/02-claude-codex-provider-form.png) + +选中 Anthropic Messages 后,下方会多出三个配套字段: + +![Anthropic 上游的高级选项](../images/codex-claude-routing/03-anthropic-advanced-options.png) + +- **认证字段**:决定 API Key 以哪个请求头发给上游,两者只发其一,按网关文档选择。 + - `ANTHROPIC_AUTH_TOKEN(Authorization)`:发 `Authorization: Bearer `,是默认值,多数 Claude 系中转网关用这种。 + - `ANTHROPIC_API_KEY(x-api-key)`:发 `x-api-key: `,部分沿用 Anthropic 原生请求头约定的网关要求这种。选错通常表现为 401 / 403。 +- **模拟 Claude Code 客户端**:默认关闭。仅当网关或其上游限制「只能通过 Claude Code 使用」时才打开,开启后会伪装 User-Agent、`anthropic-beta`、`x-app` 请求头,并在系统提示首行注入 Claude Code 身份。普通网关不需要开;开启后仍被拒的处理见「常见问题」。 +- **最大输出 tokens**:Anthropic 协议的 `max_tokens` 是必填项,而 Codex 请求未携带输出上限时,路由按保守的 8192 兜底,长回答或深度思考可能被截断(表现为回复不完整、`stop_reason=max_tokens`)。遇到截断就在这里按模型真实上限调高,但不要超过——超了上游会直接 400。 + +同区的 `模型映射` 是可选项:把 `claude-opus-4-8`、`claude-sonnet-5`、`claude-haiku-4-5-20251001` 这类模型 id(以你上游认识的名字为准)逐行加进去,CC Switch 会生成模型目录让 Codex 的 `/model` 菜单能列出它们;不填也能用,Codex 会直接请求默认模型。 + +保存供应商后,卡片上会出现 `需要路由` 标记——这类供应商必须在本地路由运行时才能正常工作。 + +## 第二步:开启本地路由并接管 Codex + +进入设置里的 `路由` 页面,展开 `本地路由`,完成两个开关: + +1. 打开 `路由总开关`,启动本地服务(首次开启会弹出一个说明确认框)。默认地址是 `127.0.0.1:15721`。 +2. 在 `路由启用` 中打开 `Codex`。如果只想让 Codex 走路由,可以保持 Claude、Gemini 关闭。 + +![本地路由页面中启用 Codex 接管](../images/codex-claude-routing/04-local-route-codex-takeover.png) + +接管后,CC Switch 会把 Codex 的 live 配置指向本机路由(`base_url = http://127.0.0.1:15721/v1`),`auth.json` 里只有占位符。真实 Claude Key 仍保存在 CC Switch 的供应商配置里,由本地路由在转发时按你选的认证字段注入。 + +## 第三步:切换供应商并重启 Codex + +回到 Codex 供应商列表,点击 Claude 供应商的 `启用`。如果路由没有在运行,CC Switch 会提示「此供应商使用 Anthropic Messages 接口格式,需要路由服务才能正常使用,请先启动路由」——回到第二步打开即可。 + +切换后建议重启当前 Codex 终端会话:`config.toml` 和模型目录是 Codex 进程启动时读取的,运行中的进程不保证热加载。 + +进入 Codex 后可以逐级验证: + +- 配置了模型映射的话,用 `/model` 查看 Claude 模型是否已出现在菜单里;没配映射时 Codex 直接用默认模型。 +- 发一个小问题,观察设置 → 路由页面的「当前 Provider」从「等待首次请求」变成你的 Claude 供应商、「总请求数」开始增长。 +- 用量看板里,这些请求的模型名会如实显示为 `claude-*`,可按供应商筛选核对 token 用量。 + +## 能力边界与已知限制 + +- **提示缓存自动生效**:转换桥会按 Anthropic 标准注入 5 分钟提示缓存标记(系统提示、工具定义与对话历史),长对话不会每轮全价重发,无需任何配置。 +- **推理与工具无损**:extended thinking 内容跨桥往返保留,多轮工具调用、图片与 PDF 输入都被完整转换。 +- **支持 `[1m]` 长上下文标记**:默认模型或模型映射里的模型 id 以 `[1m]` 结尾(如 `claude-sonnet-5[1m]`)时,路由会剥掉标记并自动补发对应的 1M 上下文 beta 头,前提是网关支持该能力。 +- **联网搜索不可用**:Anthropic 上游模式下 Codex 的内置 `web_search` 会被主动禁用——转换层无法把它翻译给 Anthropic 端点,禁用是为了不给模型呈现一个必然失败的工具。 +- **截断如实上报**:上游停在输出上限或流被掐断时,Codex 会看到「未完成」而不是被伪装的成功,方便你察觉并调高最大输出 tokens。 + +## 常见问题 + +**上游返回 401 或 403** + +十有八九是认证字段与网关要求不符:在 `ANTHROPIC_AUTH_TOKEN(Authorization)` 与 `ANTHROPIC_API_KEY(x-api-key)` 之间按网关文档换一个再试(多数网关用默认的 Bearer)。另外确认 Key 本身有效、有余额。 + +**Codex 报 404 或找不到 `/responses`** + +通常是没有开启 Codex 路由接管,或者你手动把网关的地址直接写给了 Codex——Anthropic 协议的上游没有 `/responses` 端点,这样一定 404。检查 `~/.codex/config.toml` 里当前 provider 的 `base_url` 是否指向 `http://127.0.0.1:15721/v1`。 + +**上游返回 404(路由已开启)** + +检查 API 请求地址:应该是网关的服务根地址,而不是带 `/chat/completions` 之类其它协议路径的地址。网关路径特殊时,用 `完整 URL` 开关直接粘贴完整的 messages 端点。 + +**回复经常中途截断** + +这是默认 8192 输出上限的表现。在供应商表单高级选项的 `最大输出 tokens` 里调高(不要超过模型/网关真实上限),保存后重试。 + +**`/model` 看不到 Claude 模型** + +确认模型映射里已添加条目,保存供应商后重启 Codex——模型目录不会被运行中的进程热加载。默认模型不在映射里时菜单不会列出它,但直接请求仍然有效。 + +**联网搜索用不了** + +设计如此,见「能力边界」。需要联网搜索的任务建议切回 Responses/Chat 格式的供应商。 + +**报错提示只能在 Claude Code 中使用** + +部分供应商会限制其 Claude API 只能在 Claude Code 客户端中使用,经 Codex 走本攻略的链路时会被拒绝。可以尝试打开高级选项里的 `模拟 Claude Code 客户端` 开关;若开启后仍然报错,说明限制在供应商服务端,请咨询供应商确认你的 Key 能否在 Claude Code 之外使用。普通网关请保持该开关关闭。 + +## 合规提示 + +在「公司禁客户端、只留网关」的场景下使用前,建议确认这样做符合你所在组织的具体政策——被禁的是特定客户端还是某种使用方式,各家口径不同。使用第三方中转网关时,请阅读目标网关关于计费、合规与数据留存的条款。 + +## 参考链接 + +- [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) +- 功能来自社区贡献 [#5071](https://github.com/farion1231/cc-switch/pull/5071),感谢 @yeeyzy diff --git a/docs/guides/codex-kimi-routing-guide-en.md b/docs/guides/codex-kimi-routing-guide-en.md new file mode 100644 index 000000000..b55cc8c82 --- /dev/null +++ b/docs/guides/codex-kimi-routing-guide-en.md @@ -0,0 +1,112 @@ +# Using Kimi in Codex: CC Switch Local Routing Guide + +> Applies to CC Switch 3.16.5 and nearby versions. This guide is based on the repository documentation and code, and uses Kimi as an example of an OpenAI Chat Completions-compatible API. Screenshots are generated from the current frontend UI with de-identified sample data to avoid exposing a real API key or account balance. + +## Why local routing is needed + +The newer Codex CLI targets the OpenAI Responses API, while both the Kimi Open Platform and Kimi For Coding expose the OpenAI Chat Completions shape, `/chat/completions`. These two protocols use different request bodies, streaming events, and response structures. If you put a Kimi endpoint directly into Codex configuration, the usual result is a 404 on `/responses`, or streaming responses that Codex cannot parse correctly. + +The third-party tools officially supported by Kimi For Coding are Anthropic-compatible coding agents such as Claude Code and Roo Code — Codex is not on the list. To use Kimi inside Codex, you need a protocol conversion layer, and that is exactly what CC Switch Local Routing does. + +CC Switch solves this by making Codex always talk to a local route and continue sending Responses API requests. The route detects whether the active provider is Chat-format, rewrites the request into Chat Completions for the upstream provider, and finally converts the Chat response back into the Responses shape that Codex understands. + +![Needs routing marker in the Codex provider list](../images/codex-kimi-routing/01-codex-providers-require-routing.png) + +The chain has four main steps: + +1. When Codex routing is enabled, the local configuration is written as `http://127.0.0.1:15721/v1`, while `wire_api = "responses"` is kept in place. +2. The provider's `meta.apiFormat = "openai_chat"` tells the route that the real upstream is Chat Completions. +3. The route rewrites `/responses` or `/v1/responses` to `/chat/completions`, and converts the Responses request body into a Chat request body. +4. After the upstream responds, the route converts the Chat JSON or SSE stream back into Responses JSON/SSE. + +## Prerequisites + +Prepare these three things first: + +- CC Switch installed and able to start. +- Codex CLI installed and run at least once, so the `~/.codex/config.toml` directory structure exists. +- A Kimi API key. + +Kimi API keys come from two different places, matching two different built-in presets in CC Switch: + +- **Kimi Open Platform** (platform.kimi.com): pay-as-you-go API keys billed by token usage, matching the `Kimi` preset. The OpenAI-compatible base URL is `https://api.moonshot.cn/v1` and the default model is `kimi-k2.7-code`. +- **Kimi For Coding** (kimi.com/code): a dedicated key generated from the Kimi Code membership benefits, matching the `Kimi For Coding` preset. The base URL is `https://api.kimi.com/coding/v1` and the unified model is `kimi-for-coding`. + +Both presets already contain the correct endpoint and model details, so prefer the presets and do not manually assemble the endpoint path. + +## Step 1: Add a Codex provider + +Open CC Switch, switch to the top-level `Codex` tab, and click the plus button in the upper-right corner to add a provider. + +Depending on which kind of key you have, choose the built-in `Kimi` preset (Open Platform, pay-as-you-go) or `Kimi For Coding` preset (membership subscription). You only need to do two things: + +- Enter the matching Kimi API key. +- Save the provider. + +![Upstream format in the Kimi Codex provider form](../images/codex-kimi-routing/02-kimi-codex-routing-form.png) + +The preset already includes Kimi's request base URL, default model, model menu, thinking/reasoning parameters, and presets `Upstream Format` under `Advanced Options` to `Chat Completions (routing required)`. You can adjust the default model or model display names if needed — for example, the Open Platform preset defaults to `kimi-k2.7-code`, and you can switch to `kimi-k2.7-code-highspeed` following the official documentation. The protocol conversion is handled by the routing layer. + +## Step 2: Enable local routing and route Codex + +Go to the `Routing` page in Settings, expand `Local Routing`, and complete two toggles: + +1. Turn on the main routing switch to start the local service. The default address is `127.0.0.1:15721`. +2. Turn on `Codex` under `Routing Enabled`. If you only want Codex to use local routing, you can leave Claude and Gemini off. + +![Enabling Codex routing on the local routing page](../images/codex-kimi-routing/03-local-route-codex-takeover.png) + +After routing is enabled, CC Switch points Codex's live configuration to the local route and manages authentication with a placeholder. The real Kimi key stays in the CC Switch provider configuration and is injected by the local route while forwarding requests, so you do not need to expose the key in Codex's live configuration. + +## Step 3: Switch providers and restart Codex + +Return to the Codex provider list and click `Enable` on the Kimi provider. If you see the `Needs Routing` marker, that provider must be used while routing is running; when the route is not started, CC Switch shows a prompt saying the routing service is required. + +After switching, restart the current Codex terminal session. This is recommended because: + +- The Codex process may already have read the old `config.toml`. +- After `model_catalog_json` is generated, the `/model` menu usually needs a fresh process before it refreshes. + +Inside Codex, use `/model` to check whether the current model comes from the Kimi preset, such as `Kimi K2.7 Code` or `Kimi For Coding`. The Codex app currently does not support multi-model selection, so it defaults to the first configured model. Then send a small test prompt and confirm that the request count increases in the routing panel, or that a Codex request appears in usage/request logs. + +## How to handle other Chat providers + +Kimi, DeepSeek, MiniMax, SiliconFlow, and other common Chat-format providers already have presets in CC Switch, so use presets first. Only choose custom configuration for providers that are not covered by presets; in that case, fill in the API key, base URL, and models according to the provider's documentation, and set `Upstream Format` under `Advanced Options` to `Chat Completions (routing required)`. + +If the upstream provider directly supports the OpenAI Responses API, set `Upstream Format` to `Responses`; CC Switch then connects through Responses directly without Chat conversion. + +## FAQ + +**Codex reports 404 or cannot find `/responses`** + +Usually Codex routing is not enabled, or the Kimi Chat base URL was written directly into Codex manually — the Kimi upstream has no `/responses` endpoint, so that always 404s. Check whether `~/.codex/config.toml` points to `http://127.0.0.1:15721/v1`. + +**Kimi upstream reports 401 or 403** + +First confirm the key matches the preset: Open Platform keys only work with the `Kimi` preset, and Kimi Code membership keys only work with the `Kimi For Coding` preset. The two key families are not interchangeable. + +**Kimi upstream reports 404** + +If you are using a built-in Kimi preset, first confirm that the active provider really comes from the preset and that Codex routing is enabled. Only custom providers require extra base URL checks: the base URL should be the service root, not the full endpoint path with `/chat/completions`. + +**`/model` does not show Kimi models** + +Restart Codex after saving the provider. CC Switch generates `cc-switch-model-catalog.json` and writes its path to `model_catalog_json`, but a running Codex process may not hot-load the model catalog. +The Codex app currently does not support multi-model selection, so it uses the first configured model by default. + +**Routing is enabled, but requests still go to the wrong provider** + +Confirm that all three states match: the current provider under the Codex tab is Kimi; the local routing service is running; and the Codex toggle is enabled under `Routing Enabled`. + +**Can I use an official OpenAI Codex account through local routing?** + +Not recommended. CC Switch blocks switching to official providers while local routing takeover is enabled, because accessing official APIs through a proxy may create account risk. Routing is mainly intended for third-party, aggregator, or protocol-conversion scenarios. + +## References + +- [CC Switch User Manual: Add Provider](../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) +- [Kimi Open Platform: Using Kimi K2.7 Code in coding tools](https://platform.kimi.com/docs/guide/agent-support) +- [Kimi Code Docs: Overview](https://www.kimi.com/code/docs/) +- [Kimi Code Docs: Using with third-party coding agents](https://www.kimi.com/code/docs/third-party-tools/other-coding-agents.html) diff --git a/docs/guides/codex-kimi-routing-guide-ja.md b/docs/guides/codex-kimi-routing-guide-ja.md new file mode 100644 index 000000000..91f8d5e76 --- /dev/null +++ b/docs/guides/codex-kimi-routing-guide-ja.md @@ -0,0 +1,112 @@ +# Codex で Kimi を使う: CC Switch ローカルルーティングガイド + +> 対象バージョン: CC Switch 3.16.5 およびその前後のバージョン。本記事はリポジトリ内のドキュメントとコードをもとに整理し、OpenAI Chat Completions 互換 API の例として Kimi を使用します。スクリーンショットは現在のフロントエンド UI から、実際の API Key やアカウント残高が漏れないよう匿名化したサンプルデータで生成しています。 + +## ローカルルーティングが必要な理由 + +新しい Codex CLI は OpenAI Responses API を前提にしています。一方で Kimi オープンプラットフォームと Kimi For Coding が実際に公開しているのは、いずれも OpenAI Chat Completions 形式、つまり `/chat/completions` です。この 2 つのプロトコルは、リクエストボディ、ストリーミングイベント、レスポンス構造が異なります。Kimi のエンドポイントをそのまま Codex 設定に入れると、`/responses` へのリクエストが 404 になる、ストリーミングレスポンスを Codex が正しく解析できない、といった問題が起きがちです。 + +Kimi For Coding が公式にサポートするサードパーティツールは、Claude Code や Roo Code など Anthropic 互換のコーディング Agent であり、Codex はリストに含まれていません。Codex で Kimi を使うにはプロトコル変換レイヤーが必要で、それこそが CC Switch のローカルルーティングの役割です。 + +CC Switch では、Codex が常にローカルルートへ接続し、Responses API のままリクエストを送るようにします。ルート内部で現在のプロバイダーが Chat 形式かどうかを判定し、必要ならリクエストを Chat Completions に書き換えて上流へ送り、最後に Chat レスポンスを Codex が理解できる Responses 形式へ戻します。 + +![Codex プロバイダー一覧のローカルルーティング必須マーク](../images/codex-kimi-routing/01-codex-providers-require-routing.png) + +この経路は主に 4 つのステップに分かれます: + +1. Codex ルーティングを有効にすると、ローカル設定は `http://127.0.0.1:15721/v1` に書き換えられ、`wire_api = "responses"` は維持されます。 +2. Provider の `meta.apiFormat = "openai_chat"` が、実際の上流は Chat Completions だとルートに伝えます。 +3. ルートは `/responses` または `/v1/responses` を `/chat/completions` に書き換え、Responses のリクエストボディを Chat のリクエストボディへ変換します。 +4. 上流から返ってきた後、ルートは Chat の JSON または SSE ストリームを Responses JSON/SSE へ変換して返します。 + +## 事前準備 + +先に次の 3 つを用意してください: + +- インストール済みで起動できる CC Switch。 +- インストール済みの Codex CLI。少なくとも 1 回は実行し、`~/.codex/config.toml` のディレクトリ構造が存在していること。 +- Kimi の API Key。 + +Kimi の API Key には 2 つの取得元があり、CC Switch の 2 つの内蔵プリセットに対応します: + +- **Kimi オープンプラットフォーム**(platform.kimi.com): トークン使用量に応じた従量課金の API Key。プリセット `Kimi` に対応し、OpenAI 互換 base URL は `https://api.moonshot.cn/v1`、デフォルトモデルは `kimi-k2.7-code` です。 +- **Kimi For Coding**(kimi.com/code): Kimi メンバーシップの Kimi Code 特典から生成する専用 Key。プリセット `Kimi For Coding` に対応し、base URL は `https://api.kimi.com/coding/v1`、モデルは `kimi-for-coding` に統一されています。 + +どちらのプリセットにも公式情報に基づくエンドポイントとモデルがすでに設定されているため、まずはプリセットを使い、エンドポイントパスを手で組み立てる必要はありません。 + +## Step 1: Codex プロバイダーを追加する + +CC Switch を開き、上部の `Codex` タブへ切り替え、右上のプラスボタンからプロバイダーを追加します。 + +手元の Key の種類に応じて、内蔵プリセットの `Kimi`(オープンプラットフォーム・従量課金)または `Kimi For Coding`(メンバーシップ)を選びます。必要なのは次の 2 つだけです: + +- 対応する Kimi API Key を入力する。 +- プロバイダーを保存する。 + +![Kimi Codex プロバイダーフォームの上流フォーマット設定](../images/codex-kimi-routing/02-kimi-codex-routing-form.png) + +プリセットには Kimi のリクエスト先、デフォルトモデル、モデルメニュー、thinking/reasoning パラメータがすでに含まれており、`高級オプション` の `上流フォーマット` も `Chat Completions(ルーティング必須)` にプリセットされています。必要に応じてデフォルトモデルやモデル表示名を調整できます。たとえばオープンプラットフォームのプリセットはデフォルトが `kimi-k2.7-code` で、公式ドキュメントに従って `kimi-k2.7-code-highspeed` に変更することもできます。プロトコル変換はルーティング層に任せれば十分です。 + +## Step 2: ローカルルーティングを有効にして Codex をルーティングする + +設定の `ルーティング` ページに入り、`ローカルルーティング` を展開して、次の 2 つのスイッチを設定します: + +1. `ルーティング総スイッチ` をオンにしてローカルサービスを起動します。デフォルトアドレスは `127.0.0.1:15721` です。 +2. `ルーティング有効` で `Codex` をオンにします。Codex だけをルーティングしたい場合は、Claude と Gemini はオフのままで構いません。 + +![ローカルルーティング画面で Codex ルーティングを有効化](../images/codex-kimi-routing/03-local-route-codex-takeover.png) + +ルーティングを有効にすると、CC Switch は Codex の live 設定をローカルルートへ向け、認証はプレースホルダーで管理します。実際の Kimi Key は CC Switch の Provider 設定内に残り、ローカルルートが転送時に注入します。そのため、Codex の live 設定に Key を露出させる必要はありません。 + +## Step 3: プロバイダーを切り替えて Codex を再起動する + +Codex プロバイダー一覧に戻り、Kimi プロバイダーの `有効化` をクリックします。`ルーティングが必要` の表示が見える場合、そのプロバイダーはルーティング実行中に使う必要があります。ルーティングが起動していない場合、CC Switch は「ルーティングサービスが必要」という趣旨のメッセージを表示します。 + +切り替え後は、現在の Codex ターミナルセッションを再起動することをおすすめします。理由は次のとおりです: + +- Codex プロセスがすでに古い `config.toml` を読み込んでいる可能性があります。 +- `model_catalog_json` の生成後、`/model` メニューの更新には通常、新しいプロセスが必要です。 + +Codex に入ったら、`/model` で現在のモデルが Kimi プリセット由来かどうかを確認します。たとえば `Kimi K2.7 Code` や `Kimi For Coding` などです。現在の Codex app は複数モデル選択に対応していないため、設定内の最初のモデルをデフォルトで使用します。その後、小さな質問を 1 つ送って、ルーティングパネルのリクエスト数が増えるか、usage / リクエストログに Codex リクエストが出るかを確認します。 + +## 他の Chat プロバイダーの場合 + +Kimi、DeepSeek、MiniMax、SiliconFlow など一般的な Chat 形式プロバイダーは CC Switch にプリセットがあるため、まずはプリセットを使ってください。プリセットにないプロバイダーだけ、カスタム設定を選びます。その場合は相手側のドキュメントに従って API Key、base URL、モデルを入力し、`高級オプション` の `上流フォーマット` を `Chat Completions(ルーティング必須)` に設定します。 + +上流が OpenAI Responses API を直接サポートしている場合は、`上流フォーマット` を `Responses` にすれば、CC Switch は Responses のまま直結でき、Chat 変換は行いません。 + +## よくある質問 + +**Codex が 404 を返す、または `/responses` が見つからない** + +多くの場合、Codex ルーティングが有効になっていないか、Kimi の Chat base URL を手動で Codex に直接書いています。Kimi の上流には `/responses` エンドポイントが存在しないため、必ず 404 になります。`~/.codex/config.toml` が `http://127.0.0.1:15721/v1` を指しているか確認してください。 + +**Kimi 上流が 401 または 403 を返す** + +まず Key とプリセットの組み合わせを確認してください。オープンプラットフォームの Key はプリセット `Kimi` 専用、Kimi Code 特典の Key はプリセット `Kimi For Coding` 専用で、2 種類の Key は相互に使えません。 + +**Kimi 上流が 404 を返す** + +内蔵 Kimi プリセットを使っている場合は、まず現在のプロバイダーが本当にプリセット由来であること、そして Codex ルーティングが有効であることを確認してください。カスタムプロバイダーを使っている場合だけ、base URL を追加で確認します。base URL はサービスのルートであり、`/chat/completions` 付きの完全なエンドポイントパスではありません。 + +**`/model` に Kimi モデルが表示されない** + +プロバイダーを保存した後、Codex を再起動してください。CC Switch は `cc-switch-model-catalog.json` を生成し、そのパスを `model_catalog_json` に書き込みますが、実行中の Codex プロセスがモデルカタログをホットロードするとは限りません。 +現在の Codex app は複数モデル選択に対応していないため、設定内の最初のモデルをデフォルトで使用します。 + +**ルーティングを有効にしたのに、リクエストが別のプロバイダーへ行く** + +次の 3 つの状態が一致しているか確認してください:Codex タブの現在のプロバイダーが Kimi であること、ローカルルーティングサービスが実行中であること、`ルーティング有効` で Codex スイッチがオンであること。 + +**公式 OpenAI Codex アカウントをローカルルーティング経由で使えますか** + +おすすめしません。CC Switch はローカルルーティング有効中、公式プロバイダーへの切り替えをブロックします。プロキシ経由で公式 API にアクセスすると、アカウントリスクが発生する可能性があるためです。ルーティングは主にサードパーティ、集約サービス、またはプロトコル変換のための機能です。 + +## 参考リンク + +- [CC Switch ユーザーマニュアル: プロバイダーの追加](../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) +- [Kimi オープンプラットフォーム: コーディングツールで Kimi K2.7 Code を使う](https://platform.kimi.com/docs/guide/agent-support) +- [Kimi Code ドキュメント: 概要](https://www.kimi.com/code/docs/) +- [Kimi Code ドキュメント: サードパーティ Coding Agent での利用](https://www.kimi.com/code/docs/third-party-tools/other-coding-agents.html) diff --git a/docs/guides/codex-kimi-routing-guide-zh.md b/docs/guides/codex-kimi-routing-guide-zh.md new file mode 100644 index 000000000..0d940125c --- /dev/null +++ b/docs/guides/codex-kimi-routing-guide-zh.md @@ -0,0 +1,112 @@ +# 在 Codex 中用 Kimi:CC Switch 本地路由攻略 + +> 适用版本:CC Switch 3.16.5 及附近版本。本文根据仓库内文档与代码整理,并用 Kimi 作为 OpenAI Chat Completions 兼容接口的示例。截图来自当前前端界面,使用去敏示例数据生成,避免泄露真实 API Key 或账户余额。 + +## 为什么需要本地路由 + +新版 Codex CLI 面向的是 OpenAI Responses API,而 Kimi 开放平台和 Kimi For Coding 实际暴露的都是 OpenAI Chat Completions 形态,也就是 `/chat/completions`。这两种协议的请求体、流式事件和返回结构不同,直接把 Kimi 的接口地址填进 Codex 配置里,常见结果就是请求 `/responses` 返回 404,或者流式响应无法被 Codex 正确解析。 + +Kimi For Coding 官方目前支持的第三方工具是 Claude Code、Roo Code 这类兼容 Anthropic 协议的编程 Agent,并没有覆盖 Codex。所以想在 Codex 里用 Kimi,需要一层协议转换——这正是 CC Switch 本地路由做的事。 + +CC Switch 的做法是让 Codex 始终连本机路由,仍以 Responses API 发送请求;路由在内部识别当前供应商是否是 Chat 格式,再把请求改写成 Chat Completions 发给上游,最后把 Chat 响应转换回 Responses 形态返回给 Codex。 + +![Codex 供应商列表里的需要路由标记](../images/codex-kimi-routing/01-codex-providers-require-routing.png) + +这条链路主要分成四步: + +1. Codex 接管时,本地配置会被写成 `http://127.0.0.1:15721/v1`,并强制保持 `wire_api = "responses"`。 +2. Provider 的 `meta.apiFormat = "openai_chat"` 会告诉路由:真实上游是 Chat Completions。 +3. 路由把 `/responses` 或 `/v1/responses` 改写到 `/chat/completions`,并把 Responses 请求体转换成 Chat 请求体。 +4. 上游返回后,路由再把 Chat 的 JSON 或 SSE 转回 Codex 能理解的 Responses JSON/SSE。 + +## 准备工作 + +你需要先准备好三样东西: + +- 已安装并能启动的 CC Switch。 +- 已安装 Codex CLI,并至少运行过一次,让 `~/.codex/config.toml` 目录结构存在。 +- 一个 Kimi API Key。 + +Kimi 的 API Key 有两个来源,对应 CC Switch 里两个不同的内置预设: + +- **Kimi 开放平台**(platform.kimi.com):按 token 用量计费的 API Key,对应预设 `Kimi`,OpenAI 兼容 base URL 是 `https://api.moonshot.cn/v1`,默认模型 `kimi-k2.7-code`。 +- **Kimi For Coding**(kimi.com/code):Kimi 会员 Kimi Code 权益生成的专用 Key,对应预设 `Kimi For Coding`,base URL 是 `https://api.kimi.com/coding/v1`,模型统一为 `kimi-for-coding`。 + +两个预设都已经按官方信息配好接口地址和模型,请优先使用预设,不需要手动拼接口路径。 + +## 第一步:添加 Codex 供应商 + +打开 CC Switch,切到顶部的 `Codex` 标签,点击右上角的加号添加供应商。 + +按你手里 Key 的类型,在内置预设里选择 `Kimi`(开放平台按量计费)或 `Kimi For Coding`(会员订阅),然后只需要做两件事: + +- 填入对应的 Kimi API Key。 +- 保存供应商。 + +![Kimi Codex 供应商表单中的上游格式设置](../images/codex-kimi-routing/02-kimi-codex-routing-form.png) + +预设已经内置 Kimi 的请求地址、默认模型、模型菜单、thinking/reasoning 参数,并把 `高级选项` 里的 `上游格式` 预设为 `Chat Completions(需开启路由)`。你可以按需调整默认模型或模型显示名——例如开放平台预设默认是 `kimi-k2.7-code`,也可以按官方文档换成 `kimi-k2.7-code-highspeed`;协议转换交给路由层完成即可。 + +## 第二步:开启本地路由并接管 Codex + +进入设置里的 `路由` 页面,展开 `本地路由`,完成两个开关: + +1. 打开 `路由总开关`,启动本地服务。默认地址是 `127.0.0.1:15721`。 +2. 在 `路由启用` 中打开 `Codex`。如果只想让 Codex 走路由,可以保持 Claude、Gemini 关闭。 + +![本地路由页面中启用 Codex 接管](../images/codex-kimi-routing/03-local-route-codex-takeover.png) + +接管后,CC Switch 会把 Codex 的 live 配置指向本机路由,并用占位符管理认证。真实 Kimi Key 仍保存在 CC Switch 的 Provider 配置里,由本地路由在转发时注入,不需要你把 Key 暴露给 Codex live 配置。 + +## 第三步:切换供应商并重启 Codex + +回到 Codex 供应商列表,点击 Kimi 供应商的 `启用`。如果看到 `需要路由` 标记,说明这个供应商必须在路由运行时使用;没有启动路由时,CC Switch 会弹出“需要路由服务才能正常使用”的提示。 + +切换后建议重启当前 Codex 终端会话。原因是: + +- Codex 进程可能已经读取过旧的 `config.toml`。 +- `model_catalog_json` 生成后,`/model` 菜单通常需要新进程才能刷新。 + +进入 Codex 后,可以用 `/model` 查看当前模型是否来自 Kimi 预设,例如 `Kimi K2.7 Code` 或 `Kimi For Coding`。目前 Codex app 不支持多模型选择时,会默认使用配置里的第一个模型。随后发一个小问题,确认路由面板的请求数增长,或者在用量/请求日志里看到 Codex 请求即可。 + +## 其它 Chat 供应商怎么处理 + +Kimi、DeepSeek、MiniMax、SiliconFlow 等常见 Chat 格式供应商在 CC Switch 里已有预设,优先用预设即可。只有预设里没有的供应商,才需要选择自定义配置;这时按对方文档填 API Key、base URL 和模型,并把 `高级选项` 里的 `上游格式` 选为 `Chat Completions(需开启路由)`。 + +如果上游直接支持 OpenAI Responses API,把 `上游格式` 选为 `Responses` 即可;这时 CC Switch 按 Responses 直连,不做 Chat 转换。 + +## 常见问题 + +**Codex 报 404 或找不到 `/responses`** + +通常是没有开启 Codex 接管,或者你手动把 Kimi 的 Chat base URL 直接写给了 Codex——Kimi 上游没有 `/responses` 端点,这样一定会 404。检查 `~/.codex/config.toml` 是否指向 `http://127.0.0.1:15721/v1`。 + +**Kimi 上游报 401 或 403** + +先确认 Key 和预设是否匹配:开放平台的 Key 只能配 `Kimi` 预设,Kimi Code 会员权益的 Key 只能配 `Kimi For Coding` 预设,两套 Key 不能混用。 + +**Kimi 上游报 404** + +如果用的是内置 Kimi 预设,先确认当前供应商确实来自预设,并且 Codex 路由已启用。只有在使用自定义供应商时,才需要额外检查 base URL:它应该是服务根地址,而不是带 `/chat/completions` 的完整接口路径。 + +**`/model` 看不到 Kimi 模型** + +保存供应商后重启 Codex。CC Switch 会生成 `cc-switch-model-catalog.json` 并把路径写入 `model_catalog_json`,但正在运行的 Codex 进程不一定会热加载模型目录。 +目前 Codex app 不支持多模型选择,默认使用配置的第一个模型。 + +**开了路由但请求仍走错供应商** + +确认三处状态一致:Codex 标签下当前供应商是 Kimi;本地路由服务正在运行;`路由启用` 里 Codex 开关已打开。 + +**可以用官方 OpenAI Codex 账号走本地路由吗** + +不建议。CC Switch 会在本地路由接管模式下阻止切到官方供应商,因为用代理访问官方 API 可能带来账号风险。路由主要用于第三方、聚合或协议转换场景。 + +## 参考链接 + +- [CC Switch 用户手册:添加供应商](../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) +- [Kimi 开放平台:在编程工具中使用 Kimi K2.7 Code 模型](https://platform.kimi.com/docs/guide/agent-support) +- [Kimi Code 文档:概览](https://www.kimi.com/code/docs/) +- [Kimi Code 文档:在第三方 Coding Agent 中使用](https://www.kimi.com/code/docs/third-party-tools/other-coding-agents.html) diff --git a/docs/images/codex-claude-routing/01-codex-providers-require-routing.png b/docs/images/codex-claude-routing/01-codex-providers-require-routing.png new file mode 100644 index 000000000..4a2edcdd8 Binary files /dev/null and b/docs/images/codex-claude-routing/01-codex-providers-require-routing.png differ diff --git a/docs/images/codex-claude-routing/02-claude-codex-provider-form.png b/docs/images/codex-claude-routing/02-claude-codex-provider-form.png new file mode 100644 index 000000000..bd192753b Binary files /dev/null and b/docs/images/codex-claude-routing/02-claude-codex-provider-form.png differ diff --git a/docs/images/codex-claude-routing/03-anthropic-advanced-options.png b/docs/images/codex-claude-routing/03-anthropic-advanced-options.png new file mode 100644 index 000000000..0e0bb19d4 Binary files /dev/null and b/docs/images/codex-claude-routing/03-anthropic-advanced-options.png differ diff --git a/docs/images/codex-claude-routing/04-local-route-codex-takeover.png b/docs/images/codex-claude-routing/04-local-route-codex-takeover.png new file mode 100644 index 000000000..f041c7256 Binary files /dev/null and b/docs/images/codex-claude-routing/04-local-route-codex-takeover.png differ diff --git a/docs/images/codex-kimi-routing/01-codex-providers-require-routing.png b/docs/images/codex-kimi-routing/01-codex-providers-require-routing.png new file mode 100644 index 000000000..fb56182bc Binary files /dev/null and b/docs/images/codex-kimi-routing/01-codex-providers-require-routing.png differ diff --git a/docs/images/codex-kimi-routing/02-kimi-codex-routing-form.png b/docs/images/codex-kimi-routing/02-kimi-codex-routing-form.png new file mode 100644 index 000000000..1fd471ad4 Binary files /dev/null and b/docs/images/codex-kimi-routing/02-kimi-codex-routing-form.png differ diff --git a/docs/images/codex-kimi-routing/03-local-route-codex-takeover.png b/docs/images/codex-kimi-routing/03-local-route-codex-takeover.png new file mode 100644 index 000000000..92b27433b Binary files /dev/null and b/docs/images/codex-kimi-routing/03-local-route-codex-takeover.png differ diff --git a/docs/release-notes/v3.16.5-en.md b/docs/release-notes/v3.16.5-en.md new file mode 100644 index 000000000..7b14d1391 --- /dev/null +++ b/docs/release-notes/v3.16.5-en.md @@ -0,0 +1,278 @@ +# CC Switch v3.16.5 + +> The centerpiece of this release is **getting native-Responses direct-connect properly adapted for domestic (Chinese) model providers** — generating Codex model catalogs for providers with native Responses endpoints (Xiaomi MiMo, Volcengine Doubao, Qwen3-Coder, Meituan LongCat, MiniMax) so the Codex desktop app can actually see these models and their built-in tools work, and automatically disabling `web_search` for the few domestic gateways that reject it so requests are no longer hard-rejected. Two other important improvements: when you switch providers, the plugins, environment variables, etc. you added inside the app are **automatically written back to the common config and carried over to the next provider**; and Linux (Wayland + NVIDIA) users hitting the "title bar clicks, page is dead, black screen on resize" problem now have an environment-variable escape hatch. This release also brings Claude Sonnet 5 pricing and a default-tier bump, a two-level grouped session view, and a batch of credential-safety and platform-compatibility fixes. + +**[中文版 →](v3.16.5-zh.md) | [日本語版 →](v3.16.5-ja.md)** + +--- + +## Usage Guides + +The new capabilities in this release land mainly in the Codex provider form, the session panel, and usage / common config. The following docs are worth reading alongside it: + +- **[Can't see custom models in the Codex desktop app?](../guides/codex-desktop-custom-model-visibility-en.md)**: this release reworks **model-catalog generation for native direct-connect** — when a Codex provider connects directly via native Responses (`openai_responses`), CC Switch generates `~/.codex/cc-switch-model-catalog.json` so the Codex desktop app can display the configured custom models and their tools work. If you previously configured a native Codex provider, **re-save it once** to regenerate the catalog (see "Upgrade Notes" below). +- **[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 adds Claude Sonnet 5 pricing and fixes usage-script credentials being persisted as "explicit overrides". +- **[Settings](../user-manual/en/1-getting-started/1.5-settings.md)**: the Codex upstream-format selector and local routing toggle, and Claude's common config (now renamed "Apply Common Config" and auto-synced on switch) all live in the provider form's advanced options. + +--- + +> [!WARNING] +> +> ## Only Official Channels (Please Read) +> +> CC Switch is a **fully free and open-source** desktop app, and we **do not charge users any fees**. Please only obtain the software through the official channels listed below: +> +> | Channel | Only Official | +> | ------------------ | ------------------------------------------------------------------------------ | +> | Website | **[ccswitch.io](https://ccswitch.io)** | +> | Source | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** | +> | Downloads | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** | +> | Author | **[@farion1231](https://github.com/farion1231)** | +> | Report an Imposter | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** | +> +> **Any "CC Switch" website or client that asks you for payment, top-ups, or login credentials is fake.** If you have been tricked into paying, stop the transaction immediately and file a report through GitHub Issues. + +--- + +## Overview + +CC Switch v3.16.5 is a maintenance update following v3.16.4, centered on **making native Codex direct-connect work end-to-end for domestic model providers**. v3.16.4 already switched Qwen / DashScope, Xiaomi MiMo, Volcengine Doubao, Meituan LongCat, and MiniMax to native Responses endpoints; this release goes a step further and generates the **model catalog** Codex needs (`~/.codex/cc-switch-model-catalog.json`), so the Codex desktop app can really see these custom models and invoke their built-in tools, and it fully decouples model mapping from the "local routing" toggle. For the few domestic gateways whose first-party models don't support OpenAI's built-in `web_search` (MiMo, LongCat, MiniMax, Qwen3-Coder), this release also disables that tool automatically, so Codex's default doesn't trigger a hard 400. + +For everyday use, this release makes Claude's **common config auto-sync and carry over when you switch providers** — the plugins, environment variables, theme, etc. you added inside the running app are first written back to the common config and then handed to the next provider, so they're not lost on switch; it adds an escape-hatch environment variable for Linux (Wayland + NVIDIA) users hitting click-dead / black-screen issues; it adds Claude Sonnet 5 pricing and bumps the default Sonnet tier to it; it brings a two-level "provider → project directory" grouped session view; and it fixes a string of credential-safety (the common-config snippet now strips all secrets, usage-script credentials are only kept as explicit overrides), platform-compatibility (Hermes Windows config directory, Windows Codex npm shims), and UI (long dropdown scrolling, narrow-window date-range picker) issues. It also adds several new provider presets, ready to pick out of the box. + +**Release date**: 2026-07-01 + +**Stats**: 36 commits | 93 files changed | +5,678 / -2,804 lines + +--- + +## Highlights + +- **Native Codex direct-connect actually works for domestic model providers**: CC Switch generates a Codex model catalog (`~/.codex/cc-switch-model-catalog.json`) for domestic providers like Xiaomi MiMo, Volcengine Doubao, Qwen3-Coder, Meituan LongCat, and MiniMax, so the Codex desktop app can see these models and their built-in tools work; and it auto-disables `web_search` for the domestic gateways that reject it (MiMo, LongCat, MiniMax, Qwen3-Coder) to avoid hard 400s. **Existing native providers need a one-time re-save** to regenerate the catalog. +- **Common config auto-synced and carried over on switch**: when you switch away from a Claude provider that has the common config enabled, the plugins, environment variables, theme, and hooks you added inside the app are first written back to the common config and then handed to the next provider — no longer overwritten and lost on switch. +- **Escape hatch for Linux Wayland click-dead / black-screen**: when you hit "title bar clicks, page doesn't, black screen on resize" on Wayland + NVIDIA, launch with `CC_SWITCH_GDK_BACKEND=wayland` to switch back to native Wayland (set it to `x11` for the inverse problem on tiling compositors). +- **Claude Sonnet 5**: adds Sonnet 5 pricing and bumps each preset's default Sonnet tier to `claude-sonnet-5`. +- **Categorized session view with grouping**: the session panel gains a two-level "provider → project directory" grouped view, with a tri-state checkbox on each group header for one-click batch selection. +- **New provider presets**: adds Qiniu, FennoAI, ZetaAPI, TeamoRouter, NekoCode, Code0.ai, and Amux presets across the managed apps, ready to pick out of the box. + +--- + +## Added + +### Native Codex Direct-Connect for Domestic Model Providers (Generated Model Catalog) + +This release makes native Codex direct-connect work for domestic providers. After v3.16.4 switched Xiaomi MiMo, Volcengine Doubao, Qwen3-Coder, Meituan LongCat, and MiniMax to native Responses (`apiFormat: "openai_responses"`), this release reverses the then-current "drop the model catalog on native direct-connect" approach: when these providers connect directly without the local proxy, CC Switch generates `~/.codex/cc-switch-model-catalog.json` for them, so the Codex desktop app really shows these custom models and their built-in tools work — without triggering the freeform `apply_patch` (`type=custom`) tool that native gateways like MiMo reject (editing falls back to `shell_command`). Catalog generation is keyed on `apiFormat` and decoupled from the "local routing" toggle, so a native provider persists a catalog without turning on local route mapping, while `openai_chat` keeps the existing Responses↔Chat proxy conversion unchanged. Because Codex's parser requires `base_instructions` on every entry, the native template carries a neutral default that each vendor's official copy overrides (MiMo, MiniMax). **Existing native providers need a one-time re-save to generate a valid catalog** (no database migration). + +Alongside that, for the few domestic gateways whose first-party models don't support OpenAI's built-in `web_search` tool (MiMo, LongCat, MiniMax, Qwen3-Coder), this release auto-disables the tool on switch, so Codex's default doesn't include it and get hard-rejected with a 400 (see "Fixed" below). + +### Categorized Session View with Grouping + +The Session Manager panel gains a grouped view alongside the existing flat list, toggled via a List / ListTree selector in the toolbar, with view mode and expansion state persisted to `localStorage`. Grouping builds a two-level "provider → project directory" hierarchy: sessions are grouped by project directory name, and sessions with no project directory fall into an "unknown directory" bucket. Both levels are collapsible sections, with a "collapse all" button; in batch mode, each group header shows a tri-state checkbox that selects / deselects every selectable session in that group at once, along with a selected / selectable count badge. Copy across all four locales (zh / en / ja / zh-TW) is in sync. The change is entirely front-end, with no backend commands or data-access changes. ([#4776](https://github.com/farion1231/cc-switch/pull/4776)) + +### Claude Sonnet 5 Model Pricing + +Added a `claude-sonnet-5` pricing row in `schema.rs` at Anthropic list pricing — \$3 / \$15 per million input / output tokens and \$0.30 / \$3.75 cache read / write, matching Sonnet 4.6. The introductory \$2 / \$10 promotion (valid through 2026-08-31) is deliberately not seeded, so accounting reflects steady-state list pricing rather than a temporary discount. The row is applied on the app's next start via `ensure_model_pricing_seeded`, with no `SCHEMA_VERSION` bump. + +### New Provider Presets + +This release adds a batch of provider presets; pick one and fill in your own API key to use it: + +- **Qiniu**: covers all seven managed apps (including Gemini), relaying native Claude / GPT / Gemini. +- **FennoAI / ZetaAPI / TeamoRouter / NekoCode**: each covers six apps (Claude, Claude Desktop, Codex, OpenCode, OpenClaw, Hermes). +- **Code0.ai**: covers all seven apps (including Gemini). +- **Amux**: covers six apps. + +Each preset's endpoints and default models are configured for the corresponding app — the Claude family connects directly to an Anthropic-compatible host, Codex uses native Responses, and the rest use the OpenAI-compatible `/v1`. + +--- + +## Changed + +### Common Config Auto-Synced and Carried Over on Provider Switch + +This is a very practical change in this release: when you switch away from a Claude provider that has the common config enabled, the service first **re-extracts the shareable portion from its live `settings.json` and updates the common config**, then hands it to the next provider, instead of only writing one way. As a result, the plugins (`enabledPlugins`), hooks, environment variables (`env`), theme (`theme`), and other shared config you added directly in the running app are not silently lost on switch, but automatically follow along to the next provider; deletions sync too (a removed key is not re-injected). This sync is strictly scoped to Claude providers that have the common config enabled, is skipped when it was explicitly cleared, and all failures are non-fatal (warn only) and never block the switch. + +### Codex Model Mapping Decoupled from the "Local Routing" Toggle + +The Codex provider form aligns with Claude Code — the model-mapping catalog is now independent of route takeover, because native Responses providers (MiMo, Doubao, MiniMax) need it for proxy-less direct-connect, while Chat providers go through the proxy regardless. The "needs local routing" toggle is removed (it had no backend field and only gated catalog / reasoning persistence, which is equivalent to whether the mapping is filled in). Model mapping is now always shown for non-official providers and persisted whenever it's non-empty, while reasoning visibility / persistence is gated on the Chat format instead. Copy across all four locales (zh / en / ja / zh-TW) was rewritten accordingly. As a side fix, `useCodexConfigState` no longer drops `supportsParallelToolCalls` / `inputModalities` / `baseInstructions` when loading a saved provider (which used to silently lose parallel tools, image input, and the official base instructions on edit). + +### Default Sonnet Tier Bumped to Claude Sonnet 5 + +Bumped the default Sonnet tier in each provider preset from `claude-sonnet-4-6` to `claude-sonnet-5` (across the claude / claude-desktop / hermes / openclaw / opencode presets and the universal `NEWAPI_DEFAULT_MODELS`), covering keys like `ANTHROPIC_MODEL` / `ANTHROPIC_DEFAULT_SONNET_MODEL` / `ANTHROPIC_DEFAULT_OPUS_MODEL` and their prefixed variants. Claude Desktop's default-route sonnet `route_id` also migrates to `claude-sonnet-5`. Non-Anthropic pins (gpt / gemini / glm / sonnet-4-5) are left unchanged. + +### Doubao Dated Model Id and Pricing Normalization + +The Doubao (DouBaoSeed) preset's model id switches to the dated form `doubao-seed-2-1-pro-260628` (across all apps), because Volcengine Ark rejects the bare `doubao-seed-2-1-pro` with a 404 and only accepts the full dated id. Since real usage now carries a date suffix, `strip_model_date_suffix` was extended to also strip Volcengine's 6-digit YYMMDD form (validating month 01-12 and day 01-31 to avoid eating non-date version suffixes like `-123456`), so it normalizes back to hit the bare-name seed row in the pricing table, fixing the \$0-cost display for Doubao models. + +### "Write Common Config" Renamed to "Apply Common Config" + +The original label "Write Common Config" was ambiguous about data-flow direction (it read like "write the current config into the common config"), whereas the actual behavior is the reverse — it merges the saved common-config snippet into this provider's config. The checkbox is renamed to "Apply Common Config" across all four locales (zh / en / ja / zh-TW), including every hint / guide / notice reference, with the Japanese user manual and `README_JA.md` synced too. ([#4829](https://github.com/farion1231/cc-switch/pull/4829)) + +### Other Preset and Asset Adjustments + +- **OpenClaw Doubao context aligned to 262144**: OpenClaw's DouBaoSeed preset previously hardcoded 128000 while the Codex side used 262144 for the same model, giving OpenClaw users too small a window; the two are now aligned, with a cross-preset consistency test to prevent drift. +- **Volcengine / Doubao / BytePlus website links corrected**: the "visit website" links on these three presets had been mistakenly set to console / signup links, and are restored to clean product homepages. +- **Downscale oversized provider icons to 256px**: a batch of bundled icons were far larger than their ~32px on-screen render size; downscaling significantly reduces their size with no code / filename / import changes (e.g. ZetaAPI 940KB→40KB, relaxcode 1.16MB→42KB), and the never-referenced 1.4MB `dds.svg` orphan was removed. + +--- + +## Fixed + +### Disable web_search for Native Codex Gateways That Reject It + +Some native `/responses` gateways whose first-party models lack OpenAI's hosted `web_search` tool reject it with "tool type 'web_search' is not supported", and Codex sends the tool by default, producing a hard 400. CC Switch now writes the top-level TOML line `web_search = "disabled"` for those vendors on switch. The scope is a blacklist (default-on): only providers matched by `base_url` host (`xiaomimimo.com`, `longcat.chat`, `minimax.io`, `minimaxi.com`) or by model brand prefix (`mimo`, `longcat`, `minimax`, `qwen3-coder`) are disabled, so relays serving real GPT, Doubao, general Qwen, and any unknown provider keep Codex's default. The `qwen3-coder` prefix only suppresses native `qwen3-coder-plus` (Bailian / DashScope marks built-in tools unsupported for the coder series), while general Qwen sharing the same host stays enabled; matching is on the model axis (stripping any aggregator `vendor/` path segment), so it also catches cases like SiliconFlow fronting a reject vendor's model. A blacklist was chosen over a fuzzy "is this GPT?" whitelist because wrongly keeping `web_search` on fails with a hard 400; an ownership sentinel ensures CC Switch only ever removes a `disabled` value it wrote itself, so existing providers need no re-save and switching back re-enables it. As a side fix, the LongCat-2.0-Preview preset's context window is corrected from 131072 (128K) to the real 1048576 (1M). + +### Strip All Credential-Like Keys from the Shared Claude Common-Config Snippet + +`extract_claude_common_config` previously only redacted `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN`, but Claude providers legitimately carry other credentials (`OPENROUTER_API_KEY`, `GOOGLE_API_KEY`, and possibly OpenAI / Gemini / AWS Bedrock / Vertex secrets), which could leak into the shared snippet and then be injected into other providers. Extraction now pattern-matches and strips any credential-shaped env key (`*_API_KEY` / `*_AUTH_TOKEN` / `*secret*` / `*token*`, etc.), while preserving legitimately shareable plural `*_TOKENS` values like `MAX_OUTPUT_TOKENS`. This also closes the same leak on the manual "Extract" and one-time auto-extract paths. + +### Usage-Script Credentials Persisted Only as Explicit Overrides + +Provider usage scripts store optional `api_key` / `base_url` fields that override the live credentials when querying quota, but they used to silently mirror the provider's own credentials — so copying a provider or editing its main API key / base URL left the usage script pinned to the old endpoint and key, and quota queries kept hitting a stale target. `ProviderService` now normalizes before persisting: if the script's `api_key` or `base_url` matches the provider's resolved usage credentials (or is blank), it is cleared to `None` so queries fall back to the live config; genuinely different overrides are kept (`token_plan` scripts are left alone). The deeplink import path gets matching normalization too, and the front-end invalidates the relevant cache keys on update so the home page re-queries with the corrected config. ([#4654](https://github.com/farion1231/cc-switch/pull/4654)) + +### Hermes Config Directory Resolves Correctly on Windows + +CC Switch hardcoded `~/.hermes` as the Hermes config directory, but Hermes itself resolves it via the `HERMES_HOME` environment variable, then a platform default (`%LOCALAPPDATA%\hermes` on Windows). On Windows this meant CC Switch wrote provider configs to a path Hermes never reads, so provider switches had no effect. `get_hermes_dir()` now mirrors Hermes' own resolution order — explicit override, then `HERMES_HOME` (taken verbatim, no `~` expansion), then the platform default — re-honoring the `HERMES_HOME` that #3470 had dropped (Hermes' Windows installer sets it as the first-class mechanism for relocated installs). ([#4680](https://github.com/farion1231/cc-switch/pull/4680), see #3178, #3470) + +### Linux Wayland: Override the AppImage's Forced `GDK_BACKEND=x11` + +The AppImage's GTK launch hook unconditionally exports `GDK_BACKEND=x11` to dodge a historical native-Wayland crash. On newer Wayland + NVIDIA setups, this forced XWayland leaves the WebKitGTK web content unable to receive pointer events (the title bar clicks, the page is dead) and black-screens on resize, and the existing `WEBKIT_DISABLE_*` mitigations don't help because the root cause is the forced window backend, not rendering. `main.rs` now reads an optional `CC_SWITCH_GDK_BACKEND` escape hatch before GTK init (the AppImage's launch hook never touches it): leaving it unset keeps current behavior (zero regression). When you hit the problem above, launch with it set to switch back to native Wayland: + +```bash +CC_SWITCH_GDK_BACKEND=wayland ./CC-Switch-*.AppImage +``` + +The override is generic — if you're on a tiling Wayland compositor hitting the inverse input problem, set `CC_SWITCH_GDK_BACKEND=x11` instead. ([#4351](https://github.com/farion1231/cc-switch/pull/4351), fixes #4350) + +### "Get API Key" Link Now Shows in Claude Desktop, OpenClaw, and Hermes Forms + +The "Get API Key" link and partner-promotion block below the API key input was only wired for claude / codex / gemini / opencode. Claude Desktop rendered a bare input that never showed it, and OpenClaw / Hermes were blocked by two gaps (the whitelist only listed those four appIds, and category parsing only recognized those four preset-id patterns). Claude Desktop now uses the shared `ApiKeySection`, and both the whitelist and category parsing were extended to claude-desktop / openclaw / hermes; additionally, the Hermes / OpenClaw forms no longer let an "official" category disable the key input (these apps have no OAuth-only official providers — e.g. Hermes' Nous Research is official but still needs a user-supplied key). + +### Deduplicate Windows Codex npm Shims + +On Windows, npm installs a tool as three sibling files — `codex.cmd`, `codex.exe`, and an extensionless Unix shim `codex` — and CC Switch previously listed all three as candidates, so the non-executable extensionless shim was probed as a redundant / failing candidate. It now appends the extensionless path only when there's no runnable `.cmd` / `.exe` sibling adjacent, and path resolution prefers the runnable `.cmd` / `.exe`, anchoring version detection and launch to the actually-runnable Windows shim. ([#4782](https://github.com/farion1231/cc-switch/pull/4782)) + +### Scroll Bounds for Long Select Dropdowns + +`SelectContent` previously used `overflow-hidden` with no height cap, so dropdowns with many options (e.g. long model / provider lists) rendered taller than the viewport and clipped their overflow with no way to reach it. It now sets `max-h-[min(24rem,var(--radix-select-content-available-height))]` and `overflow-y-auto`, bounding the content to 24rem or the Radix-computed available height and allowing vertical scrolling. ([#4798](https://github.com/farion1231/cc-switch/pull/4798)) + +### Date-Range Picker Calendar Stays On-Screen in Narrow Popovers + +The custom date-range picker previously switched to its two-column layout (date fields | calendar) based on the **viewport** width (Tailwind's `sm:` 640px breakpoint), but the popover is clamped to `100vw - 2rem` and anchored to its trigger, so its real available width is narrower than the viewport. On narrow windows the two-column layout could activate while the popover only had room for one column, pushing the calendar column off the right edge where it was clipped (the month header and 4 of 7 weekday columns cut off and unreachable). The layout now keys off the popover's own inline size via a CSS container query, so it collapses to one column exactly when the popover itself is narrow, keeping the calendar fully visible at any window width. ([#4860](https://github.com/farion1231/cc-switch/pull/4860)) + +--- + +## Documentation + +### `CC_SWITCH_GDK_BACKEND` Escape Hatch Documented + +Added an FAQ entry for the optional `CC_SWITCH_GDK_BACKEND` environment variable across all four README languages and the zh / en / ja user-manual troubleshooting pages, explaining how Wayland + NVIDIA users can switch back to native Wayland when the web content goes "click-dead + black screen on resize", and how tiling-Wayland users can set it to `x11` for the inverse input problem. + +### Overseas Kimi READMEs Point to platform.kimi.ai + +The Kimi K2.7 Code partner section in the English, German, and Japanese READMEs now points its banner and inline calls to action at `https://platform.kimi.ai?aff=cc-switch` (keeping the referral tag), and all four READMEs gained a line promoting the Kimi For Coding subscription linked to `https://www.kimi.com/code/?aff=cc-switch`. + +--- + +## Upgrade Notes + +### Existing Native Codex Providers Need a One-Time Re-Save + +This release reworks model-catalog generation for native Responses direct-connect. If you previously configured a Codex provider using native Responses (`openai_responses`), **re-pick the preset or open the provider and save it once** to generate the new `~/.codex/cc-switch-model-catalog.json` — that's what lets the Codex desktop app show the custom models and makes the tools work. This requires no database migration and does not affect providers on the `openai_chat` format. + +### web_search Blacklist Is Default Behavior + +For known-to-reject-`web_search` domestic native gateways (Xiaomi MiMo, Meituan LongCat, MiniMax, Qwen3-Coder), this release automatically writes `web_search = "disabled"` on switch. Relays serving real GPT, Doubao, general Qwen, and unknown providers are unaffected and keep Codex's default. The switch is managed by CC Switch with an ownership sentinel, so switching back to a provider not on the blacklist restores it automatically, with no manual intervention. + +### Default Sonnet Tier Change + +Claude-family providers newly created from a preset now have their default Sonnet tier pointing to `claude-sonnet-5`. Existing configured providers are unaffected and keep their configuration as-is; to switch to Sonnet 5, re-pick the preset and save. + +--- + +## Risk Notice + +This release continues the risk notices from previous versions for reverse-proxy-style features. + +**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. + +**Codex third-party provider Chat routing**: when CC Switch local proxy converts and forwards Codex 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. + +**Claude Desktop third-party provider proxy switching**: when CC Switch's built-in proxy gateway forwards Claude Desktop requests to third-party providers, you must also follow the target provider's billing, compliance, and data-retention terms. + +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.16.5: + +- [#4776](https://github.com/farion1231/cc-switch/pull/4776): add the categorized session view and group management, thanks @alkaid616. +- [#4829](https://github.com/farion1231/cc-switch/pull/4829): rename "Write Common Config" to "Apply Common Config", thanks @arichyx. +- [#4654](https://github.com/farion1231/cc-switch/pull/4654): persist usage-script credentials only as explicit overrides, thanks @yyhhyyyyyy. +- [#4680](https://github.com/farion1231/cc-switch/pull/4680): fix Hermes provider config not taking effect on Windows, thanks @thisTom. +- [#4782](https://github.com/farion1231/cc-switch/pull/4782): deduplicate Windows Codex npm shims, thanks @justjavac. +- [#4798](https://github.com/farion1231/cc-switch/pull/4798): fix long dropdown lists not being scrollable, thanks @xwil1. +- [#4351](https://github.com/farion1231/cc-switch/pull/4351): allow overriding the AppImage's forced `GDK_BACKEND=x11` via `CC_SWITCH_GDK_BACKEND`, thanks @BoneLiu. +- [#4860](https://github.com/farion1231/cc-switch/pull/4860): keep the date-range picker calendar on-screen in narrow popovers, thanks @SaladDay. + +Thanks also to everyone who reported native Codex direct-connect, common config, credential reuse, and platform compatibility issues after the v3.16.4 release. Many of these patches came directly from real-world reproduction clues. + +--- + +## 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.16.5-Windows.msi` | **Recommended** - MSI installer with auto-update | +| `CC-Switch-v3.16.5-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.16.5-macOS.dmg` | **Recommended** - DMG installer, drag to Applications | +| `CC-Switch-v3.16.5-macOS.zip` | Unzip and drag to Applications, Universal Binary | +| `CC-Switch-v3.16.5-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.16.5-Linux-x86_64.AppImage` / `.deb` / `.rpm` +- `CC-Switch-v3.16.5-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` | diff --git a/docs/release-notes/v3.16.5-ja.md b/docs/release-notes/v3.16.5-ja.md new file mode 100644 index 000000000..cd5cd2ce1 --- /dev/null +++ b/docs/release-notes/v3.16.5-ja.md @@ -0,0 +1,278 @@ +# CC Switch v3.16.5 + +> 本リリースの目玉は、**ネイティブ Responses 形式の国産モデルプロバイダーをきちんと適合させたこと**です——ネイティブ Responses endpoint を備えるプロバイダー(小米 MiMo、火山 Doubao、Qwen3-Coder、美団 LongCat、MiniMax)向けに Codex モデルカタログを生成し、Codex デスクトップがこれらのモデルを表示でき、内蔵ツールも正しく動くようにしました。また、`web_search` を拒否する一部の国産ゲートウェイに対しては、そのツールを自動で無効化し、リクエストがハード拒否されないようにします。ほかに 2 つの重要な改善があります: プロバイダーを切り替えるとき、アプリ内で追加したプラグインや環境変数などが**自動的に共通設定へ書き戻され、次のプロバイダーへ引き継がれます**。そして Linux(Wayland + NVIDIA)で「タイトルバーは押せるのにページが反応しない、リサイズで黒画面」になる問題も、環境変数のスイッチで自力回避できるようになりました。本リリースはさらに Claude Sonnet 5 の価格と既定階層の引き上げ、2 段階グルーピングのセッションビュー、そして一連の認証情報の安全性とプラットフォーム互換性の修正を届けます。 + +**[English →](v3.16.5-en.md) | [中文版 →](v3.16.5-zh.md)** + +--- + +## 利用ガイド + +本リリースの新機能は、主に Codex プロバイダーフォーム、セッションパネル、使用量 / 共通設定に収まっています。以下のドキュメントとあわせてご覧ください: + +- **[Codex デスクトップでカスタムモデルが見えない?](../guides/codex-desktop-custom-model-visibility-ja.md)**: 本リリースは**ネイティブ直結時のモデルカタログ生成**を作り直しました——Codex プロバイダーがネイティブ Responses(`openai_responses`)で直結するとき、CC Switch が `~/.codex/cc-switch-model-catalog.json` を生成し、Codex デスクトップが設定済みのカスタムモデルを表示でき、ツールも動くようにします。以前にネイティブ Codex プロバイダーを設定していた場合は、新しいカタログを生成するために**一度保存し直して**ください(下記「アップグレード時の注意」を参照)。 +- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**: 使用量ダッシュボードのデータソースと集計の仕組みを確認できます。本リリースでは Claude Sonnet 5 の価格を追加し、使用量スクリプトの認証情報が「明示的なオーバーライド」として永続化される問題を修正しました。 +- **[設定](../user-manual/ja/1-getting-started/1.5-settings.md)**: Codex の上流形式セレクタとローカルルーティングのトグル、Claude の共通設定(「共通設定を適用」に改名され、切り替え時に自動同期)は、いずれもプロバイダーフォームの高度なオプションにあります。 + +--- + +> [!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.16.5 は v3.16.4 に続くメンテナンスアップデートで、その中心は**国産モデルプロバイダーの Codex ネイティブ直結をエンドツーエンドで通すこと**です。v3.16.4 ですでに千問 / DashScope 百炼、小米 MiMo、火山 Doubao、美団 LongCat、MiniMax をネイティブ Responses endpoint へ切り替えていましたが、本リリースはさらに Codex が必要とする**モデルカタログ**(`~/.codex/cc-switch-model-catalog.json`)を生成し、Codex デスクトップがこれらのカスタムモデルを本当に表示でき、内蔵ツールも呼び出せるようにし、モデルマッピングを「ローカルルーティング」トグルから完全に分離しました。第一方モデルが OpenAI 内蔵の `web_search` に対応しない一部の国産ゲートウェイ(MiMo、LongCat、MiniMax、Qwen3-Coder)については、本リリースがそのツールを自動で無効化し、Codex の既定でそれが付いてハード 400 を招くことを避けます。 + +日々の使い勝手の面では、本リリースは Claude の**共通設定をプロバイダー切り替え時に自動で同期・引き継ぐ**ようにしました——アプリ内で直接追加したプラグイン、環境変数、テーマなどが、まず共通設定へ書き戻され、次のプロバイダーへ渡されるため、切り替え時に失われません。Linux(Wayland + NVIDIA)でクリック無反応 / 黒画面になるユーザー向けに、自力回避できる環境変数を追加し、Claude Sonnet 5 の価格を補って既定の Sonnet 階層をそれへ引き上げ、「プロバイダー → プロジェクトディレクトリ」の 2 段階グルーピングのセッションビューを届け、一連の認証情報の安全性(共通設定スニペットからすべての秘密情報を除去、使用量スクリプトの認証情報は明示的なオーバーライドとしてのみ保持)、プラットフォーム互換性(Hermes の Windows 設定ディレクトリ、Windows の Codex npm シム)、UI(長いドロップダウンのスクロール、狭いウィンドウの日付範囲セレクタ)の問題を修正しました。あわせて、いくつかのプロバイダープリセットも追加し、そのまますぐ選べます。 + +**リリース日**: 2026-07-01 + +**Stats**: 36 commits | 93 files changed | +5,678 / -2,804 lines + +--- + +## ハイライト + +- **国産モデルプロバイダーの Codex ネイティブ直結が本当に使える**: 小米 MiMo、火山 Doubao、Qwen3-Coder、美団 LongCat、MiniMax などの国産プロバイダー向けに Codex モデルカタログ(`~/.codex/cc-switch-model-catalog.json`)を生成し、Codex デスクトップがこれらのモデルを表示でき、内蔵ツールが動くようにします。あわせて `web_search` を拒否する国産ゲートウェイ(MiMo、LongCat、MiniMax、Qwen3-Coder)に対してはそのツールを自動で無効化し、ハード 400 を避けます。**既存のネイティブプロバイダーは、新しいカタログ生成のために一度保存し直す必要があります。** +- **共通設定を切り替え時に自動同期・引き継ぎ**: 共通設定を有効にした Claude プロバイダーから切り替えるとき、アプリ内で追加したプラグイン、環境変数、テーマ、hooks が、まず共通設定へ書き戻され、次のプロバイダーへ渡されます——切り替え時に上書きで失われることがなくなりました。 +- **Linux Wayland のクリック無反応 / 黒画面の回避スイッチ**: Wayland + NVIDIA で「タイトルバーは押せるのにページが反応しない、リサイズで黒画面」に遭遇したら、`CC_SWITCH_GDK_BACKEND=wayland` で起動すればネイティブ Wayland へ切り替えられます(タイリング系コンポジタで逆の問題に遭遇する場合は `x11` に設定)。 +- **Claude Sonnet 5**: Sonnet 5 の価格を追加し、各プリセットの既定 Sonnet 階層を `claude-sonnet-5` へ引き上げます。 +- **セッションの分類ビューとグルーピング**: セッションパネルに「プロバイダー → プロジェクトディレクトリ」の 2 段階グルーピングビューを新設し、グループヘッダーの 3 状態チェックボックスでワンクリックの一括選択に対応します。 +- **新しいプロバイダープリセット**: 七牛云、FennoAI、ZetaAPI、TeamoRouter、NekoCode、Code0.ai、Amux などのプロバイダープリセットを、管理対象アプリ全体に追加し、そのまますぐ選べます。 + +--- + +## 追加機能 + +### 国産モデルプロバイダーの Codex ネイティブ直結(モデルカタログの生成) + +本リリースは、国産プロバイダーの Codex ネイティブ直結を通しました。v3.16.4 で小米 MiMo、火山 Doubao、Qwen3-Coder、美団 LongCat、MiniMax をネイティブ Responses(`apiFormat: "openai_responses"`)へ切り替えたのに続き、本リリースは当時の「ネイティブ直結ならモデルカタログを削除する」やり方を覆しました: これらのプロバイダーがローカルプロキシを経由せず直結するとき、CC Switch が `~/.codex/cc-switch-model-catalog.json` を生成し、Codex デスクトップがこれらのカスタムモデルを本当に表示でき、内蔵ツールも動くようにします——MiMo のようなネイティブゲートウェイが拒否する freeform `apply_patch`(`type=custom`)ツールを発生させません(編集は `shell_command` へフォールバック)。カタログ生成は `apiFormat` で判定され、「ローカルルーティング」トグルから分離されているため、ネイティブプロバイダーはローカルルートマッピングを有効にしなくてもカタログを永続化します。一方 `openai_chat` 形式は、既存の Responses↔Chat プロキシ変換をそのまま保ちます。Codex のパーサーは各項目に `base_instructions` を要求するため、ネイティブテンプレートは中立の既定値を携え、各ベンダーの公式文面がそれを上書きします(MiMo、MiniMax)。**既存のネイティブプロバイダーは、有効なカタログを生成するために一度保存し直す必要があります**(データベース移行は不要)。 + +あわせて、第一方モデルが OpenAI 内蔵の `web_search` ツールに対応しない一部の国産ゲートウェイ(MiMo、LongCat、MiniMax、Qwen3-Coder)については、本リリースが切り替え時にそのツールを自動で無効化し、Codex の既定でそれが付いてハード 400 で拒否されるのを避けます(下記「修正」を参照)。 + +### セッションの分類ビューとグルーピング + +セッション管理パネルに、既存のフラットなリストに加えてグルーピングビューを新設し、ツールバーの List / ListTree セレクタで切り替え、ビューモードと展開状態を `localStorage` に永続化します。グルーピングは「プロバイダー → プロジェクトディレクトリ」の 2 段階階層を構築します: プロジェクトディレクトリ名でグループ化し、プロジェクトディレクトリを持たないセッションは「不明なディレクトリ」バケットに入ります。両方の段階が折りたたみ可能な区画で、「すべて折りたたむ」ボタンを備えます。バッチモードでは、各グループヘッダーに 3 状態チェックボックスが現れ、そのグループ内の選択可能なセッションをすべて一括で選択 / 解除でき、選択数 / 選択可能数のバッジも表示します。4 言語(zh / en / ja / zh-TW)の文言は同期済みです。この変更は完全にフロントエンドで、バックエンドのコマンドやデータアクセス層には手を加えていません。([#4776](https://github.com/farion1231/cc-switch/pull/4776)) + +### Claude Sonnet 5 のモデル価格 + +`schema.rs` に `claude-sonnet-5` の価格行を Anthropic の定価で追加しました——入力 / 出力が 100 万 token あたり \$3 / \$15、キャッシュ読み書きが \$0.30 / \$3.75 で、Sonnet 4.6 と同一です。導入期の \$2 / \$10 プロモーション(2026-08-31 まで有効)は意図的にシードせず、記帳が一時的な割引ではなく定常の定価を反映するようにしています。この行はアプリの次回起動時に `ensure_model_pricing_seeded` 経由で適用され、`SCHEMA_VERSION` の引き上げは不要です。 + +### 新しいプロバイダープリセット + +本リリースは一連のプロバイダープリセットを追加しました。選択して自分の API key を入力すればすぐ使えます: + +- **七牛云(Qiniu)**: 管理対象の 7 アプリすべて(Gemini を含む)をカバーし、ネイティブ Claude / GPT / Gemini を中継します。 +- **FennoAI / ZetaAPI / TeamoRouter / NekoCode**: それぞれ 6 アプリ(Claude、Claude Desktop、Codex、OpenCode、OpenClaw、Hermes)をカバーします。 +- **Code0.ai**: 7 アプリすべて(Gemini を含む)をカバーします。 +- **Amux**: 6 アプリをカバーします。 + +各プリセットの endpoint と既定モデルは対応するアプリに合わせて設定済みです——Claude 系は Anthropic 互換ホストへ直結、Codex はネイティブ Responses、その他は OpenAI 互換の `/v1` を使います。 + +--- + +## 変更 + +### プロバイダー切り替え時に共通設定を自動同期・引き継ぎ + +これは本リリースのとても実用的な変更です: 共通設定を有効にした Claude プロバイダーから切り替えるとき、サービスはまずその live の `settings.json` から**共有可能な部分を再抽出して共通設定を更新し**、次のプロバイダーへ渡します。以前のような一方向の書き込みだけではありません。これにより、実行中のアプリで直接追加したプラグイン(`enabledPlugins`)、hooks、環境変数(`env`)、テーマ(`theme`)などの共有設定が、切り替え時に静かに失われることなく、自動的に次のプロバイダーへ引き継がれます。削除も同期されます(削除されたキーは再注入されません)。この同期は共通設定を有効にした Claude プロバイダーに厳密に限定され、明示的にクリアされた場合はスキップされ、すべての失敗は非致命的(警告のみ)で、切り替えを妨げることは決してありません。 + +### Codex のモデルマッピングと「ローカルルーティング」トグルの分離 + +Codex プロバイダーフォームが Claude Code に揃いました——モデルマッピングカタログがルーティングテイクオーバーから独立しました。ネイティブ Responses プロバイダー(MiMo、Doubao、MiniMax)はプロキシなし直結のためにそれを必要とする一方、Chat プロバイダーはいずれにせよプロキシを経由するためです。「ローカルルーティングが必要」トグルは削除されました(バックエンドのフィールドを持たず、カタログ / 推論の永続化を制御していただけで、これはマッピングが記入されているかどうかと等価です)。モデルマッピングは非公式プロバイダーに対して常に表示され、空でなければ永続化されます。一方、推論能力の表示 / 永続化は Chat 形式で制御されるようになりました。4 言語(zh / en / ja / zh-TW)の文言もあわせて書き直しました。副次的な修正として、`useCodexConfigState` が保存済みプロバイダーを読み込むときに `supportsParallelToolCalls` / `inputModalities` / `baseInstructions` を落とす問題(編集時に並列ツール、画像入力、公式の base instructions を静かに失っていた)も修正しました。 + +### 既定の Sonnet 階層を Claude Sonnet 5 へ引き上げ + +各プロバイダープリセットの既定 Sonnet 階層を `claude-sonnet-4-6` から `claude-sonnet-5` へ引き上げました(claude / claude-desktop / hermes / openclaw / opencode のプリセットとユニバーサルの `NEWAPI_DEFAULT_MODELS` をカバー)。`ANTHROPIC_MODEL` / `ANTHROPIC_DEFAULT_SONNET_MODEL` / `ANTHROPIC_DEFAULT_OPUS_MODEL` などのキーとそのプレフィックス付き変種が対象です。Claude Desktop の既定ルートの sonnet `route_id` もあわせて `claude-sonnet-5` へ移行しました。非 Anthropic の pin(gpt / gemini / glm / sonnet-4-5)はそのままです。 + +### Doubao の日付付き model id と価格の正規化 + +Doubao(DouBaoSeed)プリセットの model id を日付付きの `doubao-seed-2-1-pro-260628` へ切り替えました(各アプリをカバー)。火山方舟が素の `doubao-seed-2-1-pro` を 404 で拒否し、完全な日付付き id のみを受け付けるためです。実際の使用量が日付サフィックスを伴うようになったため、`strip_model_date_suffix` を火山の 6 桁 YYMMDD 形式も剥がせるように拡張し(`-123456` のような非日付のバージョンサフィックスを誤って食わないよう、月 01-12・日 01-31 を検証)、価格表の素の名前のシード行にマッチするよう正規化して、Doubao モデルが \$0 コストで表示される問題を修正しました。 + +###「共通設定を書き込む」を「共通設定を適用」へ改名 + +元のラベル「共通設定を書き込む」はデータの流れの方向が曖昧でした(「現在の設定を共通設定へ書き込む」と読めた)。実際の挙動は逆で——保存済みの共通設定スニペットをこのプロバイダーの設定へマージするものです。チェックボックスを 4 言語(zh / en / ja / zh-TW)で「共通設定を適用」へ改名し、すべてのヒント / ガイド / 説明の参照を含め、日本語ユーザーマニュアルと `README_JA.md` もあわせて同期しました。([#4829](https://github.com/farion1231/cc-switch/pull/4829)) + +### その他のプリセットと素材の調整 + +- **OpenClaw の Doubao コンテキストを 262144 へ整合**: OpenClaw の DouBaoSeed プリセットは以前 128000 をハードコードしていましたが、Codex 側は同モデルで 262144 を使っており、OpenClaw ユーザーのウィンドウが小さすぎました。両者を整合させ、再びずれないようクロスプリセットの一貫性テストを追加しました。 +- **火山 / Doubao / BytePlus の公式サイトリンクを訂正**: これら 3 つのプリセットの「公式サイトを訪問」リンクが、誤ってコンソール / 登録リンクに設定されていたため、クリーンな製品トップページへ戻しました。 +- **過大なプロバイダーアイコンを 256px へダウンスケール**: 一連のバンドルアイコンが、実際の描画サイズ(~32px)よりはるかに大きかったため、ダウンスケールで大幅に容量を削減しました(コード / ファイル名 / インポートの変更なし。例: ZetaAPI 940KB→40KB、relaxcode 1.16MB→42KB)。また、一度も参照されていない 1.4MB の `dds.svg` 孤立ファイルを削除しました。 + +--- + +## 修正 + +### `web_search` を拒否するネイティブ Codex ゲートウェイでは無効化 + +一部のネイティブ `/responses` ゲートウェイは、第一方モデルが OpenAI のホスト型 `web_search` ツールを備えないため、それを「tool type 'web_search' is not supported」で拒否します。そして Codex は既定でそのツールを送るため、ハード 400 を招きます。CC Switch は現在、これらのベンダーに対して切り替え時にトップレベルの TOML 行 `web_search = "disabled"` を書き込みます。スコープはブラックリスト(既定オン)で、`base_url` ホスト(`xiaomimimo.com`、`longcat.chat`、`minimax.io`、`minimaxi.com`)または model のブランドプレフィックス(`mimo`、`longcat`、`minimax`、`qwen3-coder`)に一致するプロバイダーだけが無効化されるため、本物の GPT、Doubao、一般の Qwen、そして未知のプロバイダーはいずれも Codex の既定を保ちます。`qwen3-coder` プレフィックスはネイティブの `qwen3-coder-plus` だけを抑制し(百炼 / DashScope は coder 系で内蔵ツールを非対応と示します)、同じホストを共有する一般の Qwen は有効のままです。マッチングは model 軸で行われ(アグリゲーターの `vendor/` パスセグメントを剥がす)、SiliconFlow が拒否ベンダーのモデルを前面に立てるようなケースも捕捉します。曖昧な「これは GPT か?」というホワイトリストではなくブラックリストを選んだのは、`web_search` を誤ってオンのままにするとハード 400 で失敗するためです。所有権のセンチネルにより、CC Switch は自らが書いた `disabled` 値だけを削除するため、既存プロバイダーは保存し直す必要がなく、切り替えて戻せば再び有効になります。副次的な修正として、LongCat-2.0-Preview プリセットのコンテキストウィンドウを 131072(128K)から本来の 1048576(1M)へ訂正しました。 + +### 共有される Claude 共通設定スニペットからすべての認証情報系キーを除去 + +`extract_claude_common_config` は以前 `ANTHROPIC_API_KEY` と `ANTHROPIC_AUTH_TOKEN` だけをマスクしていましたが、Claude プロバイダーは正当に他の認証情報(`OPENROUTER_API_KEY`、`GOOGLE_API_KEY`、場合によっては OpenAI / Gemini / AWS Bedrock / Vertex の秘密情報)を携えることがあり、それらが共有スニペットへ漏れ、他のプロバイダーへ注入されるおそれがありました。抽出は現在、認証情報の形をした環境変数キー(`*_API_KEY` / `*_AUTH_TOKEN` / `*secret*` / `*token*` など)をパターンマッチで除去し、`MAX_OUTPUT_TOKENS` のような正当に共有可能な複数形 `*_TOKENS` の値は保持します。手動の「抽出」と一度きりの自動抽出の経路にあった同じ漏れも、あわせて塞ぎました。 + +### 使用量スクリプトの認証情報は明示的なオーバーライドとしてのみ永続化 + +プロバイダーの使用量スクリプトは、クォータ照会時に live の認証情報を上書きする任意の `api_key` / `base_url` フィールドを保持しますが、これらが以前はプロバイダー自身の認証情報を静かにミラーしていました——そのため、プロバイダーを複製したり、メインの API key / base URL を編集したりすると、使用量スクリプトが古い endpoint と key に固定されたまま残り、クォータ照会が古い対象を叩き続けていました。`ProviderService` は現在、永続化の前に正規化します: スクリプトの `api_key` または `base_url` がプロバイダーの解決済み使用量認証情報と一致する(または空の)場合は `None` にクリアして、照会が live 設定へフォールバックするようにします。本当に異なるオーバーライドは保持されます(`token_plan` スクリプトはそのまま)。deeplink インポート経路にも同様の正規化を加え、フロントエンドは更新時に関連するキャッシュキーを無効化して、ホームページが訂正済みの設定で再照会するようにします。([#4654](https://github.com/farion1231/cc-switch/pull/4654)) + +### Hermes 設定ディレクトリが Windows で正しく解決されるように + +CC Switch は Hermes の設定ディレクトリとして `~/.hermes` をハードコードしていましたが、Hermes 自身は `HERMES_HOME` 環境変数、次にプラットフォーム既定(Windows では `%LOCALAPPDATA%\hermes`)で解決します。Windows ではこれにより、CC Switch がプロバイダー設定を Hermes が決して読まないパスへ書いていたため、プロバイダーの切り替えが効きませんでした。`get_hermes_dir()` は現在、Hermes 自身の解決順序——明示的なオーバーライド、次に `HERMES_HOME`(そのまま使用、`~` 展開なし)、次にプラットフォーム既定——をミラーし、#3470 が落としていた `HERMES_HOME` を再び尊重します(Hermes の Windows インストーラーは、再配置されたインストールの第一級の仕組みとしてそれを設定します)。([#4680](https://github.com/farion1231/cc-switch/pull/4680)、#3178、#3470 を参照) + +### Linux Wayland: AppImage が強制する `GDK_BACKEND=x11` を上書き可能に + +AppImage の GTK 起動フックは、歴史的なネイティブ Wayland のクラッシュを避けるために `GDK_BACKEND=x11` を無条件でエクスポートします。新しめの Wayland + NVIDIA 環境では、この強制された XWayland により WebKitGTK のウェブコンテンツがポインタイベントを受け取れなくなり(タイトルバーは押せてもページは反応しない)、リサイズで黒画面になります。既存の `WEBKIT_DISABLE_*` の緩和策は効きません。根本原因が描画ではなく、強制されたウィンドウバックエンドだからです。`main.rs` は現在、GTK の初期化前に任意の `CC_SWITCH_GDK_BACKEND` 回避スイッチを読みます(AppImage の起動フックはそれに触れません): 未設定なら現状のまま(回帰ゼロ)。上記の問題に遭遇したら、これを設定してネイティブ Wayland へ切り替えて起動できます: + +```bash +CC_SWITCH_GDK_BACKEND=wayland ./CC-Switch-*.AppImage +``` + +この上書きは汎用です——タイリング系 Wayland コンポジタで逆の入力問題に遭遇する場合は、代わりに `CC_SWITCH_GDK_BACKEND=x11` に設定してください。([#4351](https://github.com/farion1231/cc-switch/pull/4351)、#4350 を修正) + +###「API Key を取得」リンクが Claude Desktop / OpenClaw / Hermes フォームでも表示されるように + +API key 入力欄の下の「API Key を取得」リンクとパートナー宣伝ブロックは、以前 claude / codex / gemini / opencode にしか配線されていませんでした。Claude Desktop はそれを表示しない素の入力欄を描画し、OpenClaw / Hermes は 2 つの欠落(ホワイトリストがその 4 つの appId しか列挙しておらず、カテゴリ解析もその 4 つのプリセット id パターンしか認識しなかった)にブロックされていました。Claude Desktop は現在、共有の `ApiKeySection` を使い、ホワイトリストとカテゴリ解析の両方を claude-desktop / openclaw / hermes へ拡張しました。さらに、Hermes / OpenClaw のフォームは「公式」カテゴリで key 入力欄を無効化しなくなりました(これらのアプリには OAuth 専用の公式プロバイダーが存在しません——例えば Hermes の Nous Research は公式ですが、それでもユーザー入力の key が必要です)。 + +### Windows の Codex npm シムの重複排除 + +Windows では、npm がツールを 3 つの兄弟ファイル——`codex.cmd`、`codex.exe`、拡張子なしの Unix シム `codex`——としてインストールし、CC Switch は以前この 3 つすべてを候補に列挙していたため、実行できない拡張子なしシムが冗長 / 失敗する候補としてプローブされていました。現在は、隣接する実行可能な `.cmd` / `.exe` の兄弟が見つからない場合にのみ拡張子なしパスを追加し、パス解決も実行可能な `.cmd` / `.exe` を優先して、バージョン検出と起動を実際に実行可能な Windows シムに固定します。([#4782](https://github.com/farion1231/cc-switch/pull/4782)) + +### 長い Select ドロップダウンのスクロール境界 + +`SelectContent` は以前、高さの上限なしで `overflow-hidden` を使っていたため、選択肢の多いドロップダウン(長いモデル / プロバイダーのリストなど)がビューポートより高く描画され、あふれた項目が切り取られて届かなくなっていました。現在は `max-h-[min(24rem,var(--radix-select-content-available-height))]` と `overflow-y-auto` を設定し、内容を 24rem または Radix が計算した利用可能高さに収め、縦方向のスクロールを許可します。([#4798](https://github.com/farion1231/cc-switch/pull/4798)) + +### 日付範囲セレクタのカレンダーが狭いポップオーバーでも画面内に収まるように + +カスタム日付範囲セレクタは以前、2 列レイアウト(日付フィールド | カレンダー)への切り替えを**ビューポート**幅(Tailwind の `sm:` 640px ブレークポイント)で判定していましたが、ポップオーバーは `100vw - 2rem` に制限され、トリガーにアンカーされているため、実際に使える幅はビューポートより狭くなります。狭いウィンドウでは、ポップオーバーに 1 列分しか収まらないのに 2 列レイアウトが有効化されることがあり、カレンダー列が右端の外へ押し出されて切り取られていました(月ヘッダーと 7 列中 4 列の曜日が切れて届かない)。レイアウトは現在、CSS コンテナクエリでポップオーバー自身のインラインサイズに基づいて切り替わるため、ポップオーバー自体が狭いときにちょうど 1 列へ折りたたまれ、どのウィンドウ幅でもカレンダーが完全に見えるようになりました。([#4860](https://github.com/farion1231/cc-switch/pull/4860)) + +--- + +## ドキュメント + +### `CC_SWITCH_GDK_BACKEND` 回避スイッチのドキュメント化 + +任意の `CC_SWITCH_GDK_BACKEND` 環境変数について、4 言語すべての README と zh / en / ja のユーザーマニュアルのトラブルシューティングページに FAQ 項目を追加し、Wayland + NVIDIA ユーザーがウェブコンテンツの「クリック無反応 + リサイズで黒画面」時にネイティブ Wayland へ切り替える方法、そしてタイリング系 Wayland ユーザーが逆の入力問題で `x11` に設定する方法を解説しました。 + +### 海外向け Kimi README が platform.kimi.ai を指すように + +英語・ドイツ語・日本語の README の Kimi K2.7 Code パートナー段落で、バナーとインラインの行動喚起を `https://platform.kimi.ai?aff=cc-switch` へ向け(紹介タグは維持)、4 言語すべての README に `https://www.kimi.com/code/?aff=cc-switch` へリンクした Kimi For Coding サブスクリプションの宣伝行を追加しました。 + +--- + +## アップグレード時の注意 + +### 既存のネイティブ Codex プロバイダーは一度保存し直しが必要 + +本リリースは、ネイティブ Responses 直結のモデルカタログ生成を作り直しました。以前にネイティブ Responses(`openai_responses`)を使う Codex プロバイダーを設定していた場合は、新しい `~/.codex/cc-switch-model-catalog.json` を生成するために、**プリセットから選び直すか、プロバイダーを開いて一度保存し直して**ください——それが、Codex デスクトップにカスタムモデルを表示させ、ツールを動かすための鍵です。これはデータベース移行を必要とせず、`openai_chat` 形式のプロバイダーには影響しません。 + +### `web_search` ブラックリストは既定の挙動 + +`web_search` を拒否することが分かっている国産ネイティブゲートウェイ(小米 MiMo、美団 LongCat、MiniMax、Qwen3-Coder)に対しては、本リリースが切り替え時に自動で `web_search = "disabled"` を書き込みます。本物の GPT、Doubao、一般の Qwen、そして未知のプロバイダーは影響を受けず、Codex の既定を保ちます。このスイッチは CC Switch が所有権のセンチネルで管理するため、ブラックリストにないプロバイダーへ切り替えて戻せば自動的に復元され、手動の介入は不要です。 + +### 既定の Sonnet 階層の変化 + +プリセットから新規作成した Claude 系プロバイダーは、既定の Sonnet 階層が `claude-sonnet-5` を指すようになりました。設定済みの既存プロバイダーは影響を受けず、設定はそのまま保たれます。Sonnet 5 へ切り替えたい場合は、プリセットから選び直して保存してください。 + +--- + +## リスク通知 + +本リリースは、リバースプロキシ系機能に関する以前のリスク通知を引き続き適用します。 + +**Codex OAuth リバースプロキシ**: ChatGPT サブスクリプションの Codex OAuth をリバースプロキシ経由で使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 release notes](v3.13.0-ja.md#️-リスクに関する注意事項) を参照してください。 + +**Codex サードパーティプロバイダー Chat ルーティング**: CC Switch ローカルプロキシで Codex リクエストを変換し、サードパーティプロバイダーへ転送する場合、課金・コンプライアンス・データ保持に関する制約はプロバイダーごとに異なります。利用前に対象プロバイダーの利用規約を確認してください。 + +**Claude Desktop サードパーティプロバイダープロキシ切り替え**: CC Switch 内蔵のプロキシゲートウェイで Claude Desktop のリクエストをサードパーティプロバイダーへ転送する場合も、対象プロバイダーの課金・コンプライアンス・データ保持に関する規約に従う必要があります。 + +上記機能を有効化したユーザーは、関連するリスクを自ら負うものとします。CC Switch は、これらの機能の利用によって発生したアカウント制限、警告、サービス停止について責任を負いません。 + +--- + +## 謝辞 + +v3.16.5 で機能と修正を届けてくださった以下のコントリビューターに感謝します: + +- [#4776](https://github.com/farion1231/cc-switch/pull/4776): セッションの分類ビューとグループ管理を追加、@alkaid616 に感謝。 +- [#4829](https://github.com/farion1231/cc-switch/pull/4829):「共通設定を書き込む」を「共通設定を適用」へ改名、@arichyx に感謝。 +- [#4654](https://github.com/farion1231/cc-switch/pull/4654): 使用量スクリプトの認証情報を明示的なオーバーライドとしてのみ永続化、@yyhhyyyyyy に感謝。 +- [#4680](https://github.com/farion1231/cc-switch/pull/4680): Windows で Hermes プロバイダー設定が効かない問題を修正、@thisTom に感謝。 +- [#4782](https://github.com/farion1231/cc-switch/pull/4782): Windows の Codex npm シムを重複排除、@justjavac に感謝。 +- [#4798](https://github.com/farion1231/cc-switch/pull/4798): 長いドロップダウンリストがスクロールできない問題を修正、@xwil1 に感謝。 +- [#4351](https://github.com/farion1231/cc-switch/pull/4351): AppImage が強制する `GDK_BACKEND=x11` を `CC_SWITCH_GDK_BACKEND` で上書き可能に、@BoneLiu に感謝。 +- [#4860](https://github.com/farion1231/cc-switch/pull/4860): 日付範囲セレクタのカレンダーが狭いポップオーバーでも画面内に収まるように、@SaladDay に感謝。 + +v3.16.4 リリース後に 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.16.5-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー | +| `CC-Switch-v3.16.5-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます | + +Windows ARM64 デバイスをお使いの場合は、ファイル名に `arm64` 識別子が含まれる対応する制品を選択してください。 + +### macOS + +| ファイル | 説明 | +| -------------------------------- | ------------------------------------------------------ | +| `CC-Switch-v3.16.5-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ | +| `CC-Switch-v3.16.5-macOS.zip` | 展開して Applications へドラッグ、Universal Binary | +| `CC-Switch-v3.16.5-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.16.5-Linux-x86_64.AppImage` / `.deb` / `.rpm` +- `CC-Switch-v3.16.5-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` | diff --git a/docs/release-notes/v3.16.5-zh.md b/docs/release-notes/v3.16.5-zh.md new file mode 100644 index 000000000..36f522752 --- /dev/null +++ b/docs/release-notes/v3.16.5-zh.md @@ -0,0 +1,278 @@ +# CC Switch v3.16.5 + +> 这一版的重头戏是**让原生 Responses 格式的国产模型供应商真正适配到位**——为小米 MiMo、火山豆包、千问 Qwen3-Coder、美团 LongCat、MiniMax 等具备原生 Responses 端点的供应商生成 Codex 模型目录,让 Codex 桌面能看到这些模型、内置工具也能正常工作,并对少数拒收 `web_search` 的国产网关自动禁用该工具、避免请求被硬性拒绝。另有两处重要改进:切换供应商时,你在应用内新增的插件、环境变量等会**自动回写到通用配置并带给下一个供应商**;Linux(Wayland + NVIDIA)上「标题栏能点、页面点不动、缩放黑屏」的问题,现在也能用一个环境变量开关自救。本版还带来 Claude Sonnet 5 定价与默认档升级、两级分组的会话视图,以及一批凭据安全与平台兼容修复。 + +**[English →](v3.16.5-en.md) | [日本語版 →](v3.16.5-ja.md)** + +--- + +## 使用攻略 + +本版的新能力主要落在 Codex 供应商表单、会话面板与用量 / 通用配置里,建议结合以下文档了解: + +- **[Codex 桌面看不到自定义模型?](../guides/codex-desktop-custom-model-visibility-zh.md)**:本版重做了**原生直连时的模型目录生成**——当 Codex 供应商使用原生 Responses(`openai_responses`)直连时,CC Switch 会生成 `~/.codex/cc-switch-model-catalog.json`,让 Codex 桌面能显示配置的自定义模型、工具也可用。若你此前配过原生 Codex 供应商,请**重新保存一次**以生成新目录(详见下方「升级提醒」)。 +- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:了解用量看板的数据来源与统计口径。本版新增了 Claude Sonnet 5 定价,并修复了用量脚本凭据被当作「显式覆盖」持久化的问题。 +- **[设置](../user-manual/zh/1-getting-started/1.5-settings.md)**:Codex 上游格式选择器与本地路由开关、Claude 通用配置(现更名为「应用通用配置」并支持切换时自动同步)都在供应商表单的高级选项里。 + +--- + +> [!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.16.5 是 v3.16.4 之后的一版维护更新,核心是把**国产模型供应商的 Codex 原生直连做通**。v3.16.4 已经把千问 / 百炼、小米 MiMo、火山豆包、美团 LongCat、MiniMax 等供应商切到了原生 Responses 端点,本版进一步为它们生成 Codex 所需的**模型目录**(`~/.codex/cc-switch-model-catalog.json`),让 Codex 桌面真正能看到这些自定义模型、内置工具也能正常调用,并把模型映射从「本地路由」开关里彻底解耦。针对少数第一方模型不支持 OpenAI 内置 `web_search` 的国产网关(MiMo、LongCat、MiniMax、Qwen3-Coder),本版还会自动禁用该工具,避免 Codex 默认带上它触发硬 400。 + +围绕日常使用体验,本版让 Claude 的**通用配置在切换供应商时自动同步并传递**——你在应用内新增的插件、环境变量、主题等会先回写到通用配置、再带给下一个供应商,不会在切换时丢失;给 Linux(Wayland + NVIDIA)上点击失灵 / 黑屏的用户加了一个可自救的环境变量开关;补上 Claude Sonnet 5 定价并把默认 Sonnet 档升级到它;带来「供应商 → 项目目录」两级分组的会话视图;并修了一串凭据安全(通用配置片段剥离全部密钥、用量脚本凭据仅作显式覆盖)、平台兼容(Hermes Windows 配置目录、Windows Codex npm 影子命令)与界面(长下拉滚动、窄窗口日期选择器)的问题。此外也新增了若干供应商预设,开箱即可选用。 + +**发布日期**:2026-07-01 + +**更新规模**:36 commits | 93 files changed | +5,678 / -2,804 lines + +--- + +## 重点内容 + +- **让国产模型供应商的 Codex 原生直连真正可用**:为小米 MiMo、火山豆包、千问 Qwen3-Coder、美团 LongCat、MiniMax 等国产供应商生成 Codex 模型目录(`~/.codex/cc-switch-model-catalog.json`),让 Codex 桌面能看到这些模型、内置工具可用;并对拒收 `web_search` 的国产网关(MiMo、LongCat、MiniMax、Qwen3-Coder)自动禁用该工具、避免硬 400。**存量原生供应商需重存一次**以生成新目录。 +- **通用配置切换时自动同步并传递**:切走一个启用了通用配置的 Claude 供应商时,你在应用内新增的插件、环境变量、主题、hooks 会先自动回写到通用配置,再带给下一个供应商——不再在切换时被覆盖丢失。 +- **Linux Wayland 点击失灵 / 黑屏的自救开关**:遇到 Wayland + NVIDIA 上「标题栏能点、页面点不动、缩放黑屏」时,用 `CC_SWITCH_GDK_BACKEND=wayland` 启动即可切回原生 Wayland(平铺式合成器上遇到反向问题可设为 `x11`)。 +- **Claude Sonnet 5**:新增 Sonnet 5 定价,并把各预设的默认 Sonnet 档升级到 `claude-sonnet-5`。 +- **会话分类视图与分组管理**:会话面板新增「供应商 → 项目目录」两级分组视图,分组头支持三态复选框一键批量选择。 +- **新增供应商预设**:新增七牛云、FennoAI、ZetaAPI、TeamoRouter、NekoCode、Code0.ai、Amux 等供应商预设,覆盖各受管应用,开箱即可选用。 + +--- + +## 新功能 + +### 国产模型供应商的 Codex 原生直连(生成模型目录) + +本版把国产供应商的 Codex 原生直连做通了。继 v3.16.4 把小米 MiMo、火山豆包、千问 Qwen3-Coder、美团 LongCat、MiniMax 等供应商切换到原生 Responses(`apiFormat: "openai_responses"`)之后,本版推翻了当时「原生直连就删掉模型目录」的做法:这些供应商不经过本地代理直连时,CC Switch 会为它们生成 `~/.codex/cc-switch-model-catalog.json`,让 Codex 桌面真正显示这些自定义模型、内置工具也能用——不会触发像 MiMo 这类原生网关会拒绝的 freeform `apply_patch`(`type=custom`)工具(编辑回退到 `shell_command`)。目录生成按 `apiFormat` 判定、与「本地路由」开关解耦,因此一个原生供应商无需开启本地路由映射也会持久化目录;而 `openai_chat` 格式仍保持既有的 Responses↔Chat 代理转换不变。由于 Codex 解析器要求每个条目都带 `base_instructions`,原生模板携带一个中性默认值、由各厂商官方文案覆盖(MiMo、MiniMax)。**存量原生供应商需重新保存一次以生成有效目录**(无需数据库迁移)。 + +配套地,对少数第一方模型不支持 OpenAI 内置 `web_search` 工具的国产网关(MiMo、LongCat、MiniMax、Qwen3-Coder),本版会在切换时自动禁用该工具,避免 Codex 默认带上它、被网关以硬 400 拒绝(详见下方「修复」)。 + +### 会话分类视图与分组管理 + +会话管理面板在原有平铺列表之外新增了分组视图,通过工具栏的 List / ListTree 选择器切换,视图模式与展开状态都持久化到 `localStorage`。分组构建「供应商 → 项目目录」两级层级:按项目目录名归组,缺少项目目录的会话落入「未知目录」桶。两级都是可折叠区块,并提供「全部折叠」按钮;在批量模式下,每个分组头会出现一个三态复选框,可一键选中 / 取消该组内全部可选会话,并显示已选 / 可选计数徽标。四语(zh / en / ja / zh-TW)文案已同步。该改动完全在前端,不涉及后端命令或数据访问层。([#4776](https://github.com/farion1231/cc-switch/pull/4776)) + +### Claude Sonnet 5 模型定价 + +在 `schema.rs` 里按 Anthropic list 价新增 `claude-sonnet-5` 定价行——输入 / 输出 \$3 / \$15 每百万 token、缓存读写 \$0.30 / \$3.75,与 Sonnet 4.6 一致。介绍期 \$2 / \$10 促销(有效期至 2026-08-31)刻意不入表,让记账反映稳态 list 价而非临时折扣。该行在应用下次启动时通过 `ensure_model_pricing_seeded` 应用,无需 `SCHEMA_VERSION` 变更。 + +### 新增供应商预设 + +本版新增了一批供应商预设,选中后填入自己的 API Key 即可使用: + +- **七牛云(Qiniu)**:覆盖全部 7 个受管应用(含 Gemini),中转原生 Claude / GPT / Gemini。 +- **FennoAI / ZetaAPI / TeamoRouter / NekoCode**:各覆盖 6 个应用(Claude、Claude Desktop、Codex、OpenCode、OpenClaw、Hermes)。 +- **Code0.ai**:覆盖全部 7 个应用(含 Gemini)。 +- **Amux**:覆盖 6 个应用。 + +各预设的端点与默认模型已按对应应用配好——Claude 类走 Anthropic 兼容主机直连、Codex 走原生 Responses、其余走 OpenAI 兼容 `/v1`。 + +--- + +## 变更 + +### 切换供应商时自动同步并传递通用配置 + +这是本版一个很实用的改动:切走一个启用了通用配置的 Claude 供应商时,服务会先从它的 live `settings.json` 里**重新提取可共享部分、更新到通用配置**,再带给下一个供应商,而不再只是单向写入。这样一来,你在运行中的应用里直接新增的插件(`enabledPlugins`)、hooks、环境变量(`env`)、主题(`theme`)等共享配置就不会在切换时被静默丢失,而是自动跟着走到下一个供应商;删除也会同步(移除的键不会被再次注入)。该同步严格限定在启用了通用配置的 Claude 供应商,被显式清空时会跳过,且所有失败都是非致命(仅告警)、永不阻断切换。 + +### Codex 模型映射与「本地路由」开关解耦 + +Codex 供应商表单向 Claude Code 对齐——模型映射目录现在独立于路由接管,因为原生 Responses 供应商(MiMo、豆包、MiniMax)需要它来做无代理直连,而 Chat 供应商无论如何都走代理。「需要本地路由」开关被移除(它没有后端字段,只是门控目录 / 推理的持久化,等价于「映射是否填了」)。模型映射现在对非官方供应商始终显示、非空即持久化,而推理能力的显示 / 持久化改由 Chat 格式门控。四语(zh / en / ja / zh-TW)文案随之重写。顺带修复了 `useCodexConfigState` 在加载已存供应商时丢掉 `supportsParallelToolCalls` / `inputModalities` / `baseInstructions` 的问题(会在编辑时静默丢失并行工具、图像输入与官方 base instructions)。 + +### 默认 Sonnet 档升级到 Claude Sonnet 5 + +把各供应商预设里的默认 Sonnet 档从 `claude-sonnet-4-6` 升级到 `claude-sonnet-5`(覆盖 claude / claude-desktop / hermes / openclaw / opencode 预设与通用 `NEWAPI_DEFAULT_MODELS`),涉及 `ANTHROPIC_MODEL` / `ANTHROPIC_DEFAULT_SONNET_MODEL` / `ANTHROPIC_DEFAULT_OPUS_MODEL` 等键及其带前缀变体。Claude Desktop 的默认路由 sonnet `route_id` 也一并迁移到 `claude-sonnet-5`。非 Anthropic 的 pin(gpt / gemini / glm / sonnet-4-5)保持不变。 + +### 豆包带日期 model id 与定价归一化 + +豆包(DouBaoSeed)预设的 model id 切换到带日期的 `doubao-seed-2-1-pro-260628`(覆盖各应用),因为火山方舟会以 404 拒绝裸名 `doubao-seed-2-1-pro`、只接受完整带日期 id。由于真实用量现在带日期后缀,`strip_model_date_suffix` 扩展为也能剥掉火山的 6 位 YYMMDD 形式(并校验月 01-12、日 01-31 以免误伤 `-123456` 这类非日期版本后缀),从而归一化命中定价表里的裸名 seed 行、修复豆包模型显示 \$0 成本的问题。 + +###「写入通用配置」更名为「应用通用配置」 + +原标签「写入通用配置」在数据流向上有歧义(读起来像「把当前配置写进通用配置」),而实际行为相反——是把已存的通用配置片段合并进本供应商配置。复选框在四语(zh / en / ja / zh-TW)里更名为「应用通用配置」,包括所有提示 / 攻略 / 说明引用,日文用户手册与 `README_JA.md` 也一并同步。([#4829](https://github.com/farion1231/cc-switch/pull/4829)) + +### 其它预设与资源调整 + +- **OpenClaw 豆包上下文对齐 262144**:OpenClaw 的 DouBaoSeed 预设此前硬编码 128000,而 Codex 侧同模型用 262144,导致 OpenClaw 用户窗口偏小;已对齐并加了跨预设一致性测试防止再次漂移。 +- **火山 / 豆包 / BytePlus 官网链接订正**:这三个预设的「访问官网」链接被误设成了控制台 / 注册链接,已恢复为干净的产品主页。 +- **过大的供应商图标降采样到 256px**:一批捆绑图标此前远大于其 ~32px 的实际渲染尺寸,降采样后显著减小体积、无代码 / 文件名 / 导入改动(如 ZetaAPI 940KB→40KB、relaxcode 1.16MB→42KB),并删除了从未被引用的 1.4MB `dds.svg` 孤儿。 + +--- + +## 修复 + +### 对拒收 `web_search` 的原生 Codex 网关禁用该工具 + +一些原生 `/responses` 网关的第一方模型不具备 OpenAI 内置的 `web_search` 工具,会以「tool type 'web_search' is not supported」拒绝,而 Codex 默认就会带上该工具,导致硬 400。CC Switch 现在会为这些厂商写入顶层 TOML 行 `web_search = "disabled"`。作用域是一份黑名单(默认开启):仅命中 `base_url` 主机(`xiaomimimo.com`、`longcat.chat`、`minimax.io`、`minimaxi.com`)或模型品牌前缀(`mimo`、`longcat`、`minimax`、`qwen3-coder`)的供应商会被禁用,因此中转真 GPT、豆包、通用 Qwen 及任何未知供应商都保持 Codex 默认。其中 `qwen3-coder` 前缀只压制原生 `qwen3-coder-plus`(百炼 / DashScope 对 coder 系标记内置工具不支持),共享同一主机的通用 Qwen 保持开启;匹配走模型轴(会剥掉聚合器的 `vendor/` 路径段),因此也能兜住硅基流动这类中转拒收厂商模型的情形。选黑名单而非模糊的「是不是 GPT」白名单,是因为误让 `web_search` 保持开启会以硬 400 失败;同时用归属哨兵保证 CC Switch 只会移除由它自己写入的 `disabled` 值,因此存量供应商无需重存、切回也会重新启用。此外顺带把 LongCat-2.0-Preview 预设的上下文窗口从 131072(128K)订正为真实的 1048576(1M)。 + +### 通用配置片段剥离全部凭据类键 + +`extract_claude_common_config` 此前只脱敏 `ANTHROPIC_API_KEY` 与 `ANTHROPIC_AUTH_TOKEN`,但 Claude 供应商合法地携带其它凭据(`OPENROUTER_API_KEY`、`GOOGLE_API_KEY`,可能还有 OpenAI / Gemini / AWS Bedrock / Vertex 密钥),这些可能泄漏进共享片段、再被注入到其它供应商。提取现在会按模式匹配并剥掉任何凭据形态的环境变量键(`*_API_KEY` / `*_AUTH_TOKEN` / `*secret*` / `*token*` 等),同时保留 `MAX_OUTPUT_TOKENS` 这类合法可共享的复数 `*_TOKENS` 值。手动「提取」与一次性自动提取路径的同一泄漏也一并堵上。 + +### 用量脚本凭据仅作显式覆盖持久化 + +供应商用量脚本存有可选的 `api_key` / `base_url` 字段用于查询配额时覆盖 live 凭据,但它们此前会静默镜像供应商自身的凭据——因此复制供应商或修改主 API key / base URL 后,用量脚本仍 pin 在旧端点旧 key,配额查询一直打向陈旧目标。现在 `ProviderService` 在持久化前会归一化:若脚本的 `api_key` 或 `base_url` 与供应商解析出的用量凭据相同(或为空)就清为 `None`,让查询回退到 live 配置;真正不同的覆盖才保留(`token_plan` 类脚本不动)。deeplink 导入路径也加了对应的归一化,前端在更新时会失效相关缓存键让首页用修正后的配置重新查询。([#4654](https://github.com/farion1231/cc-switch/pull/4654)) + +### Hermes 配置目录在 Windows 上正确解析 + +CC Switch 此前硬编码 `~/.hermes` 作为 Hermes 配置目录,但 Hermes 自身是按 `HERMES_HOME` 环境变量、再退到平台默认(Windows 上 `%LOCALAPPDATA%\hermes`)解析的。在 Windows 上这意味着 CC Switch 把供应商配置写到了 Hermes 根本不读的路径,导致供应商切换无效。`get_hermes_dir()` 现在镜像 Hermes 自己的解析顺序——显式覆盖、`HERMES_HOME`(原样取用、不做 `~` 展开)、平台默认——从而重新尊重被 #3470 丢掉的 `HERMES_HOME`(Hermes 的 Windows 安装器把它作为重定位安装的首要机制)。([#4680](https://github.com/farion1231/cc-switch/pull/4680),参见 #3178、#3470) + +### Linux Wayland:允许覆盖 AppImage 强制的 `GDK_BACKEND=x11` + +AppImage 的 GTK 启动钩子无条件导出 `GDK_BACKEND=x11` 以规避一个历史上的原生 Wayland 崩溃。在较新的 Wayland + NVIDIA 环境上,这个被强制的 XWayland 会让 WebKitGTK 网页内容收不到指针事件(标题栏可点、页面却死了)、并在缩放时黑屏,而既有的 `WEBKIT_DISABLE_*` 缓解不起作用,因为根因是被强制的窗口后端而非渲染。`main.rs` 现在会在 GTK 初始化前读取一个可选的 `CC_SWITCH_GDK_BACKEND` 逃生开关(AppImage 的启动钩子从不改动它):不设保持现状(零回归)。遇到上述问题时,用它切回原生 Wayland 启动即可: + +```bash +CC_SWITCH_GDK_BACKEND=wayland ./CC-Switch-*.AppImage +``` + +该覆盖是通用的——若你在平铺式 Wayland 合成器上遇到的是反向的输入问题,则改设为 `CC_SWITCH_GDK_BACKEND=x11`。([#4351](https://github.com/farion1231/cc-switch/pull/4351),修复 #4350) + +### Claude Desktop / OpenClaw / Hermes 表单显示「获取 API Key」链接 + +API key 输入框下的「获取 API Key」链接与合作推广块此前只对 claude / codex / gemini / opencode 生效。Claude Desktop 渲染的是不显示它的裸输入框,而 OpenClaw / Hermes 则被两处遗漏挡住(白名单只列了那四个 appId、供应商分类解析只认那四类预设 id 模式)。现在 Claude Desktop 改用共享的 `ApiKeySection`,白名单与分类解析都补上了 claude-desktop / openclaw / hermes;此外 Hermes / OpenClaw 表单不再让「官方」分类禁用 key 输入(这两个应用没有只走 OAuth 的官方供应商,如 Hermes 的 Nous Research 虽是官方但仍需用户自填 key)。 + +### 去重 Windows 上的 Codex npm 影子命令 + +在 Windows 上,npm 会把一个工具装成三个同名兄弟文件——`codex.cmd`、`codex.exe` 和一个无扩展名的 Unix shim `codex`——而 CC Switch 此前把三者都列为候选,导致无法直接执行的无扩展名 shim 被当作多余 / 失败候选去探测。现在仅当相邻没有可执行的 `.cmd` / `.exe` 兄弟时才追加无扩展名路径,路径解析也会优先选可执行的 `.cmd` / `.exe`,从而把版本探测与启动锚定到真正可运行的 Windows shim 上。([#4782](https://github.com/farion1231/cc-switch/pull/4782)) + +### 长下拉列表的滚动边界 + +`SelectContent` 弹层此前用 `overflow-hidden` 且没有高度上限,因此选项很多的下拉(如长模型 / 供应商列表)会渲染得比视口还高、把溢出项裁掉且无法触及。现在它设了 `max-h-[min(24rem,var(--radix-select-content-available-height))]` 与 `overflow-y-auto`,把内容限制在 24rem 或 Radix 计算出的可用高度内并允许纵向滚动。([#4798](https://github.com/farion1231/cc-switch/pull/4798)) + +### 日期范围选择器的日历在窄弹层里保持可见 + +自定义日期范围选择器此前按**视口宽度**(Tailwind `sm:` 640px 断点)切换两列布局(日期字段 | 日历),但弹层被夹在 `100vw - 2rem` 且锚定到触发器,实际可用宽度比视口窄。在窄窗口上,两列布局可能在弹层只放得下一列时被激活,把日历列挤出右边界裁掉(月份头与 7 列里的 4 列被切掉且无法触及)。现在布局改用 **CSS 容器查询**按弹层自身的行内尺寸切换,因此只有当弹层本身窄时才收成一列,让日历在任意窗口宽度下都完整可见。([#4860](https://github.com/farion1231/cc-switch/pull/4860)) + +--- + +## 文档 + +### `CC_SWITCH_GDK_BACKEND` 逃生开关文档 + +为可选的 `CC_SWITCH_GDK_BACKEND` 环境变量新增了 FAQ 条目,覆盖全部四种 README 语言与 zh / en / ja 用户手册的排障页,说明 Wayland + NVIDIA 用户如何在网页内容「点击失灵 + 缩放黑屏」时切回原生 Wayland,以及平铺式 Wayland 用户如何设为 `x11` 处理反向输入问题。 + +### Kimi 海外 README 指向 platform.kimi.ai + +英语、德语、日语 README 的 Kimi K2.7 Code 合作段落的横幅与内联行动号召改指 `https://platform.kimi.ai?aff=cc-switch`(保留推荐标签),四语 README 也都新增了一行指向 `https://www.kimi.com/code/?aff=cc-switch` 的 Kimi For Coding 订阅推广。 + +--- + +## 升级提醒 + +### 原生 Codex 供应商需重存一次 + +本版重做了原生 Responses 直连的模型目录生成。如果你此前配过使用原生 Responses(`openai_responses`)的 Codex 供应商,请**重新从预设选择或打开该供应商并保存一次**,以生成新的 `~/.codex/cc-switch-model-catalog.json`——这样 Codex 桌面才能显示自定义模型、工具才可用。此过程无需数据库迁移,也不影响走 `openai_chat` 格式的供应商。 + +### `web_search` 黑名单是默认行为 + +对小米 MiMo、美团 LongCat、MiniMax、千问 Qwen3-Coder 这些已知拒收 `web_search` 的原生网关,本版会在切换时自动写入 `web_search = "disabled"`。中转真 GPT、豆包、通用 Qwen 及未知供应商不受影响、保持 Codex 默认。该开关由 CC Switch 用归属哨兵管理,切回到未命中黑名单的供应商会自动恢复,无需手动干预。 + +### 默认 Sonnet 档变化 + +新从预设创建的 Claude 类供应商,其默认 Sonnet 档现在指向 `claude-sonnet-5`。已配置好的存量供应商不受影响、配置保持原样;如需改用 Sonnet 5,可重新从预设选择一次并保存。 + +--- + +## 风险提示 + +本版本继续沿用此前版本对反向代理类功能的风险提示。 + +**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#️-风险提示)。 + +**Codex 第三方供应商 Chat 路由**:通过 CC Switch 本地代理把 Codex 请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。 + +**Claude Desktop 第三方供应商代理切换**:通过 CC Switch 内置代理网关把 Claude Desktop 的请求转到第三方供应商时,同样需要遵守目标供应商的计费、合规与数据留存约束。 + +用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。 + +--- + +## 致谢 + +感谢以下贡献者在 v3.16.5 中提交的功能与修复: + +- [#4776](https://github.com/farion1231/cc-switch/pull/4776):新增会话分类视图与分组管理,感谢 @alkaid616。 +- [#4829](https://github.com/farion1231/cc-switch/pull/4829):把「写入通用配置」更名为「应用通用配置」,感谢 @arichyx。 +- [#4654](https://github.com/farion1231/cc-switch/pull/4654):让用量脚本凭据仅作显式覆盖持久化,感谢 @yyhhyyyyyy。 +- [#4680](https://github.com/farion1231/cc-switch/pull/4680):修复 Windows 上 Hermes 供应商配置不生效,感谢 @thisTom。 +- [#4782](https://github.com/farion1231/cc-switch/pull/4782):去重 Windows 上的 Codex npm 影子命令,感谢 @justjavac。 +- [#4798](https://github.com/farion1231/cc-switch/pull/4798):修复长下拉列表无法滚动,感谢 @xwil1。 +- [#4351](https://github.com/farion1231/cc-switch/pull/4351):允许通过 `CC_SWITCH_GDK_BACKEND` 覆盖 AppImage 强制的 `GDK_BACKEND=x11`,感谢 @BoneLiu。 +- [#4860](https://github.com/farion1231/cc-switch/pull/4860):让日期范围选择器的日历在窄弹层里保持可见,感谢 @SaladDay。 + +也感谢所有在 v3.16.4 发布后反馈 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.16.5-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 | +| `CC-Switch-v3.16.5-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 | + +Windows ARM64 设备请选择文件名中带 `arm64` 标识的对应制品。 + +### macOS + +| 文件 | 说明 | +| -------------------------------- | --------------------------------------------- | +| `CC-Switch-v3.16.5-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 | +| `CC-Switch-v3.16.5-macOS.zip` | 解压后拖入 Applications,Universal Binary | +| `CC-Switch-v3.16.5-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.16.5-Linux-x86_64.AppImage` / `.deb` / `.rpm` +- `CC-Switch-v3.16.5-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` | diff --git a/docs/release-notes/v3.17.0-en.md b/docs/release-notes/v3.17.0-en.md new file mode 100644 index 000000000..c06f4e255 --- /dev/null +++ b/docs/release-notes/v3.17.0-en.md @@ -0,0 +1,366 @@ +# CC Switch v3.17.0 + +> This release brings a long-awaited capability: **one-click "Projects" switching** — save your current provider, MCP, Skills, and memory files as a single named snapshot, swap the whole set for another one from the title bar or tray with a single click, and have the state of the project you're leaving automatically saved back on the way out. The Codex side gets plenty too: **your official ChatGPT subscription account can now route through the local proxy as well**, getting the same routing and usage statistics as third-party providers; the GPT-5.6 family's context window and Sol / Terra / Luna three-tier pricing land in one step; and a native Anthropic Messages upstream format is added — is Claude Code banned at your company but the Claude API isn't? You can now **use Claude-family models directly inside Codex**. On top of that comes a big wave of correctness fixes: upstream failures no longer turn into "empty replies", cache-write tokens are no longer double-billed, deleted MCP servers no longer come back from the dead, and Kimi For Coding's 256K window finally takes effect for real. + +**[中文版 →](v3.17.0-zh.md) | [日本語版 →](v3.17.0-ja.md)** + +--- + +## Usage Guides + +The new capabilities in this release land mainly in the project switcher at the top of the home page, the Codex provider form, and the usage dashboard. The following docs are worth reading alongside it: + +- **[Using Claude in Codex (local routing guide)](../guides/codex-claude-routing-guide-en.md)**: a new step-by-step guide for this release's native Anthropic Messages upstream. It walks through setting a Codex provider's upstream format to `anthropic` to connect to any Claude-family gateway that only offers `/v1/messages`, and use Claude-family models inside Codex. +- **[Using Kimi inside Codex (local routing guide)](../guides/codex-kimi-routing-guide-en.md)**: a new step-by-step guide added in this release. Newer Codex CLI speaks the OpenAI Responses protocol, while the Kimi Open Platform and Kimi For Coding expose Chat Completions endpoints, so a direct connection usually 404s; the guide walks through using the built-in `Kimi` / `Kimi For Coding` presets together with local routing to handle the protocol conversion. +- **[Codex Official Login Preservation](../guides/codex-official-auth-preservation-guide-en.md)**: understand how CC Switch preserves your official ChatGPT login when you switch to a third-party provider. This release goes a step further — the official account itself can now route through the proxy too (see "Added" below). +- **[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 cache-write billing, fills in Codex subagent session accounting, and adds GPT-5.6 and Hunyuan Hy3 pricing. + +--- + +> [!WARNING] +> +> ## Only Official Channels (Please Read) +> +> CC Switch is a **fully free and open-source** desktop app, and we **do not charge users any fees**. Please only obtain the software through the official channels listed below: +> +> | Channel | Only Official | +> | ------------------ | ------------------------------------------------------------------------------ | +> | Website | **[ccswitch.io](https://ccswitch.io)** | +> | Source | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** | +> | Downloads | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** | +> | Author | **[@farion1231](https://github.com/farion1231)** | +> | Report an Imposter | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** | +> +> **Any "CC Switch" website or client that asks you for payment, top-ups, or login credentials is fake.** If you have been tricked into paying, stop the transaction immediately and file a report through GitHub Issues. + +--- + +## Overview + +CC Switch v3.17.0 is a major feature release following v3.16.5, centered on **Projects**: you can save the current provider, MCP, Skills, and memory-file state of Claude Code / Claude Desktop / Codex as a named snapshot — say a "Development" set for a coding directory and a "Drawing" set for a writing-and-drawing directory — and swap the whole set with one click from the switcher at the top of the home page or the "Projects" tray submenu. Before you switch, the state of the project you're about to leave is automatically saved back, so a project always holds exactly what you last left it as. The second main thread is Codex: **your official ChatGPT subscription account can now be taken over by the local proxy route** (no API key needed — Codex's own login credentials are passed through verbatim and your official login is never overwritten); paired with a corrected client identity, the latest subscription models like `gpt-5.6-luna` no longer falsely 404; GPT-5.6's 372K context-window injection, Sol / Terra / Luna three-tier pricing (including the 1.25× cache-write rate), and preset default models all land together; and the Codex upstream format gains a native Anthropic Messages protocol — which targets a very real scenario: plenty of companies ban the Claude Code client but do **not** ban the Claude API, and those users can now point Codex directly at the Claude API (or any gateway that only offers `/v1/messages`) and keep using Claude-family models inside Codex. + +For everyday-use correctness, this release makes three concentrated waves of fixes. **Proxy bridge**: semantic failures returned inside a 2xx are no longer turned into an empty reply but trigger failover instead; reasoning content, tool results, and the system role round-trip losslessly across the Responses↔Anthropic bridge; prompt-cache breakpoint injection is more thorough, so long conversations no longer resend everything at full price each turn. **Usage billing**: cache-write tokens were previously billed twice — once at the input rate and once at the cache-creation rate — and are now corrected (the database is upgraded to schema v13 so historical data stays consistent); usage and quota queries automatically retry on transient network failures and no longer cache a failure body as real data. **Codex `config.toml`**: MCP servers you deleted in the app no longer come back on provider switch; when parsing a live file fails, sync errors out rather than blanking the whole file; and the "Apply Common Config" merge is moved to the backend so comments and key order are no longer scrambled. Also included: Kimi For Coding's 256K window finally taking effect, Codex subagent and free-tier quota accounting filled in, Zhipu team-plan quota queries, OpenCode form enhancements, and a batch of preset updates. + +**Release date**: 2026-07-13 + +**Stats**: 69 commits | 172 files changed | +21,067 / -2,464 lines + +--- + +## Highlights + +- **One-click "Projects" switching**: save your provider, MCP, Skills, and memory files as a single named snapshot (say one set for coding, another for writing and drawing) and swap the whole thing with one click from the top of the home page or the tray; the state of the project you leave is saved back automatically on switch. Covers three scopes — Claude Code, Claude Desktop, and Codex — that don't interfere with one another. +- **Official Codex account can route through the proxy too**: a Codex session logged in with a ChatGPT subscription can now route through the local proxy, getting routing and usage statistics identical to third-party providers; the official login credentials are never overwritten or stored. +- **GPT-5.6 fully in place**: a 372K context window is auto-injected when Claude Code routes through Codex takeover; Sol / Terra / Luna three-tier pricing is seeded (cache writes billed at 1.25× the input rate); the relevant presets' default models are bumped to the gpt-5.6 family; and, with the client identity corrected, `gpt-5.6-luna` no longer falsely 404s. +- **Use Claude-family models inside Codex (native Anthropic Messages upstream)**: plenty of companies ban the Claude Code client but not the Claude API — now, by setting a Codex provider's upstream format to `anthropic`, you can connect directly to the Claude API or any gateway that only offers `/v1/messages`, with the local proxy handling the two-way Responses↔Anthropic conversion and standard 5-minute prompt-cache injection built in. +- **Proxy bridge correctness fixes**: upstream failures fail closed and trigger failover instead of an empty reply; reasoning / tool results / the system role survive the bridge losslessly; cache writes are no longer double-billed; breakpoint injection is more thorough. +- **Codex config.toml hardening**: deleted MCP servers no longer come back; MCP sync errors out rather than blanking the file on a parse failure; common-config merges preserve comments and key order. +- **Kimi For Coding 256K finally works**: the previous 262144 compaction window never actually took effect (clamped back to Claude Code's 200K default); this release fills in the model-alias routing and window injection; existing providers need to re-apply the preset (see "Upgrade Notes"). + +--- + +## Added + +### Projects: Named Snapshots of a Whole Configuration, Switched in One Click + +This is the headline feature of the release. You can save your current provider, MCP, Skills, and memory-file state as a named "project", then swap the whole set with one click from the project switcher at the top of the home page or the "Projects" tray submenu, instead of toggling each piece by hand. + +Here's a typical scenario: you have one directory for coding and another for writing or drawing. Coding wants one provider, plus MCP like filesystem / GitHub, code-review Skills, and a memory file spelling out your engineering conventions; writing or drawing often wants a different provider, a different set of MCP, and completely different prompts. Bouncing between the two used to mean switching providers, toggling MCP and Skills one by one, and then editing the memory file; now you save the two states as two projects — "Development" and "Drawing" — and when you switch directories to work, one click in CC Switch puts the whole configuration in place. + +Projects span three scopes — Claude Code, Claude Desktop, and Codex (the only dimension CC Switch manages for Claude Desktop is the provider, so its snapshots contain only the provider and applying one touches no other dimension). + +A few design points worth knowing: + +- **Projects are global entities, switched per scope**: the same project records its own current project and snapshot slot separately on the Claude Code / Claude Desktop / Codex sides, so switching projects on the Codex tab never touches Claude's configuration. +- **Switching auto-saves**: before you switch projects, the state of the project you're about to leave in the current scope is first saved back automatically — so a project always holds exactly what you last left it as, with no (and no need for a) manual "update snapshot" button. +- **Applying is best-effort**: applying a snapshot reuses the existing switch primitives (switch the provider first, then do the minimal MCP / Skills diff toggles, then enable memory files); if something a snapshot references has been deleted, it's only warned and skipped, never rolled back wholesale. +- **Auto-disables proxy takeover**: before applying a project, proxy takeover for each app in that scope is disabled first, to avoid the snapshot state fighting with the routing state. + +If you don't use Projects, you can turn off "Show project switcher" under Settings → Home Display, which only hides the home-page entry — the tray submenu and project data are unaffected. It's backed by a new `profiles` table (the database migrates automatically, no manual steps), with UI copy in sync across all four locales. + +### Proxy Route Takeover for the Official Codex ChatGPT Account + +A Codex session using a ChatGPT subscription (OAuth or API-key login) can now route through CC Switch's local proxy too — official-account traffic gets routing, format conversion, and usage statistics identical to third-party providers. Just pick the built-in "OpenAI Official" entry in the provider panel or tray to take it over (if you deleted it before, it's restored automatically when you add a provider); the card badge shows "Official account routing" while routing. + +The implementation deliberately achieves **zero credential storage**: instead of writing any placeholder key to `auth.json`, it projects a dedicated `model_provider` pointing at the local proxy into `config.toml`, and Codex sends its own ChatGPT authorization header to the proxy, which passes it through verbatim to the official endpoint — the credentials on the `codex-official` line are always empty. The official login itself is never overwritten: on takeover, OAuth / API-key material is preserved into the backup, and a 401 / 403 from the official side is treated as a non-retryable error, so failover never quietly moves your conversation to another account. Accordingly, the copy for the "Preserve Codex official login on switch" setting has been updated — under route takeover the official login is always preserved, so that toggle now only governs third-party direct switches that don't route through the proxy. + +### GPT-5.6: Context Window, Preset Defaults, and Three-Tier Pricing + +Three things were done around the GPT-5.6 family: + +- **372K context-window injection**: when Claude Code routes to the ChatGPT Codex (Codex OAuth) backend through proxy takeover, `CLAUDE_CODE_MAX_CONTEXT_TOKENS` and `CLAUDE_CODE_AUTO_COMPACT_WINDOW` (both 372000) are auto-injected into the live `settings.json`, so Claude Code no longer auto-compacts prematurely at its default 200K window nor overflows the upstream. The injection is strictly gated: it only injects when every configured model key points at the gpt-5.6 family (gpt-5.5's catalog window swings between 272K and 372K, so it's deliberately not inherited); your manually set values always win; and it's stripped on switch-away under mirrored conditions, so a program default is never baked into your provider config. +- **Preset default-model bump**: the Codex OAuth presets for Claude Code and Claude Desktop bump their default routes to the gpt-5.6 family (haiku → `gpt-5.6-luna`, main model / sonnet / opus → `gpt-5.6`), and the custom Codex `config.toml` template's default model follows suit. +- **Sol / Terra / Luna three-tier pricing**: the usage dashboard seeds all three tiers at official rates — Sol 5 / 30 / 0.50, Terra 2.50 / 15 / 0.25, Luna 1 / 6 / 0.10 (USD per million tokens, input / output / cache read). Unlike 5.5 and earlier, the 5.6 family bills **prompt-cache writes at 1.25× the input rate** (Sol 6.25 / Terra 3.125 / Luna 1.25), seeded accordingly, with existing rows previously billed at 0 auto-repaired; bare `gpt-5.6` and its per-effort suffix variants align to the Sol rate. + +### Use Claude-Family Models inside Codex: Native Anthropic Messages Upstream + +This feature comes from a very real need: **plenty of companies ban the Claude Code client for compliance reasons, but do not ban the Claude API.** For those users the model itself is available; what's missing is a permitted client — and Codex can now fill that slot. Pick the new `anthropic` option in a Codex provider's upstream-format selector, and you can connect directly to the Claude API or any gateway offering the native Anthropic Messages protocol (`/v1/messages`); the local proxy handles the two-way conversion of requests, responses, and streaming between Responses and Anthropic, and you chat and use tools inside Codex as usual while Claude-family models run behind the scenes. The form provides the supporting pieces: an auth-field selector (`ANTHROPIC_AUTH_TOKEN` sends `Authorization: Bearer`, the default; or `ANTHROPIC_API_KEY` sends `x-api-key`), an optional Claude Code client-impersonation toggle (off by default), and a per-provider max-output-token override (Codex doesn't send `model_max_output_tokens`, and when unset it falls back to a conservative 8192, which may truncate long or heavy-reasoning replies). The conversion bridge auto-injects standard 5-minute prompt-cache markers (system prompt, tools, and history go through the cache instead of being resent at full price each turn), supports the `[1m]` long-context marker with the corresponding beta header, and reports a truncated stream faithfully as incomplete rather than disguising it as success. ([#5071](https://github.com/farion1231/cc-switch/pull/5071)) + +### "Default Model" Field Added to the Codex Provider Form + +The top-level `model` key in `config.toml` is now an editable field in the form: when a new model (e.g. `gpt-5.6`) ships, you can point an existing provider at it directly without waiting for a preset update (presets only affect newly added providers). The field is two-way synced with the TOML editor, its candidate list is the union of the model-mapping catalog and the provider's `/models` endpoint, and when a value isn't in the catalog it offers a one-click "Add to mapping". An explicitly entered value always wins over the implicit backfill from the mapping's first row; model names and `base_url` are TOML-escaped on write, eliminating any chance that remote data from `/models` injects a forged config line. + +### Common-Config Switch Auto-Sync Extended to Codex + +The "on switch-away, write shared preferences from the live config back to the common config" behavior v3.16.5 added for Claude now covers Codex: when you switch away from a Codex provider that has the common config enabled, the shareable portion is first re-extracted from its live `config.toml` to update the common config and then carried to the next provider — the preferences you edited directly in the running Codex config are no longer lost on switch, and deleted keys aren't quietly re-injected. The extractor strictly strips provider-specific and injected content (`model` / `model_provider` / `base_url` / `wire_api`, the entire `[model_providers]` table, the MCP projection, the API-key fallback field, the model-catalog pointer, and the injected `web_search` sentinel), so secrets never enter the shared snippet. All failures only warn and never block the switch. + +### Claude Subagent Model Configuration + +The Claude provider form gains a "subagent" model row that writes `CLAUDE_CODE_SUBAGENT_MODEL`, letting subagents spawned by Claude Code run on a model you specify (usually a cheaper or faster one). It supports the `[1M]` marker; since the subagent model never appears in the `/model` menu, the row shows a "not shown in /model" placeholder with no display-name field. The proxy takeover path and the model mapper support it too: when the request model matches the configured subagent model it's passed through as-is instead of being folded to the default model; the key is also excluded from the shared common config so it doesn't leak across providers. ([#4830](https://github.com/farion1231/cc-switch/pull/4830)) + +### 1M Context Checkbox for the Fallback Model Field + +The Claude form's fallback model field (`ANTHROPIC_MODEL`) now carries the same 1M checkbox that the Sonnet / Opus / Fable tiers have long had: when the fallback model is backed by a 1M window you can declare it faithfully, instead of it being silently treated as 200K. Checking it appends the `[1M]` marker to the model id, unchecking strips it. ([#5124](https://github.com/farion1231/cc-switch/pull/5124), fixes #3679) + +### Zhipu Team-Plan Quota Query + +Zhipu's team plan (team-edition Coding Plan) uses the same quota endpoint but requires `?type=2` and two extra request headers (`bigmodel-organization` / `bigmodel-project`), which the personal-edition query can't reach. The usage-script dialog gains a "Zhipu GLM Team" template; fill in the API key + organization ID + project ID to query team quota, and if any of the three is missing you get a clear prompt to complete it. Copy is in sync across all four locales. ([#5128](https://github.com/farion1231/cc-switch/pull/5128)) + +### OpenCode Form: Headers and Per-Model Token-Limit Editors + +The OpenCode provider form fills in two pieces of config that previously required hand-editing JSON: a **Headers editor** (provider-level `options.headers`, such as the `HTTP-Referer` / `X-Title` that OpenRouter's leaderboard wants, with add/remove rows and case-insensitive deduplication) and **per-model token limits** (`model.limit.context` / `model.limit.output` numeric inputs, cleared to remove). The "extra options" block becomes a collapsible section that auto-expands when it has content; along the way, a fix stops the old placeholder filter from wrongly deleting genuine option keys that start with `option-`. ([#2907](https://github.com/farion1231/cc-switch/pull/2907)) + +### New Model Pricing: Tencent Hunyuan Hy3 + +Seeded pricing for Tencent Hunyuan Hy3 (256K context), released 2026-07-06 (converted from the launch-day list price of CNY 1 / 4 / 0.25 per million tokens); both the `hunyuan-hy3` and `hy3` ids now hit, so their usage no longer shows $0. Note that Hy3 is actually tiered by input length, and the current price table is seeded at the lowest tier, so long-context requests will underestimate cost — to be corrected once the official billing page is clear. + +--- + +## Changed + +### Codex Chat Routing Injects prompt_cache_key to Improve Cache Hits + +When Codex routes through the local proxy and converts to a Chat Completions upstream, it now injects `prompt_cache_key` in a provider-aware way: auto-enabled for Kimi Coding and the OpenAI official endpoint, explicitly on for the Kimi preset, and left off for unknown OpenAI-compatible gateways to avoid a 400 from strict-schema gateways. The key value only ever takes an explicit client value or a real client session ID, and never generates a random UUID (which would drop every request into a different cache bucket, defeating the purpose). Advanced options offer an auto / enabled / disabled tri-state override. + +### Codex Image Capability Auto-Inferred, Manual Toggle Removed + +The generated Codex model catalog now only declares `input_modalities = ["text"]` for models on CC Switch's confirmed **exact text-only roster**; GPT, aliases, new suffix variants, and any unknown model all fail open to `["text", "image"]` — fixing GPT-family models being falsely reported as "images not supported" in the Codex IDE extension. The rectifier's "text-only model precheck" toggle continues to govern only the proxy-side active request rewriting, not the catalog declaration; catalog reverse-import also collapses the inferable capability away, so a future roster correction or a model upgrading to multimodal takes effect automatically. + +### Context-Window Parameters Pinned into Presets, No Longer Form Fields + +The `Codex` (ChatGPT / GPT-5.6) and `Kimi For Coding` presets no longer show the "Max Context Tokens" and "Auto-Compact Window" inputs in the form; the values are pinned directly in the preset env (Codex 372000 / 372000, Kimi For Coding 262144 / 262144) — the vast majority of users never need to touch these two numbers. The two keys are kept in env on purpose: pinning them explicitly makes the local compaction trigger point immune to a remote experimental config dialing it down. The rare users who do want to change the numbers can still edit both keys directly in the provider's JSON editor. + +### Provider Connectivity Configuration Simplified + +Removed the obsolete per-provider `testConfig` overrides (timeout, retry count, degradation delay threshold): the lightweight `base_url` probe now always uses the global connectivity-check config, while automatic failover remains fully driven by the proxy's separate timeout and circuit-breaker settings. The settings UI and interface naming also migrate from "model test" terminology to "connectivity check". + +### Universal (Multi-App) Provider Auto-Synced After Adding + +After adding a universal (multi-app) provider through the "Add Provider" dialog, it's now immediately pushed to each live target config, no longer requiring a manual sync afterward. A sync failure doesn't block the add — the provider is saved, with a non-blocking warning shown if the sync fails. ([#2811](https://github.com/farion1231/cc-switch/pull/2811)) + +### Preset Updates + +- **LongCat-2.0**: the Meituan LongCat presets across the board (Claude Code / Claude Desktop / Codex / Hermes / OpenClaw / OpenCode) upgrade from the retired `LongCat-Flash-Chat` / `LongCat-2.0-Preview` to `LongCat-2.0`, declaring the real 1M (1048576) context window. LongCat-2.0 is a text-only model, and the proxy's media-scrub whitelist is updated in sync — images pasted into a conversation are replaced with an unsupported marker rather than hard-rejected upstream. ([#4838](https://github.com/farion1231/cc-switch/pull/4838)) +- **SudoCode**: the old `sudocode.us` preset is replaced in place by the new sponsor SudoCode at `sudocode.chat`, covering six clients (the Claude family connects directly to Anthropic passthrough; Codex / OpenCode / OpenClaw / Hermes default to `gpt-5.6-sol`). +- **Volcengine / Doubao / BytePlus website links**: reverted v3.16.5's change of these three presets' `websiteUrl` to product homepages, restoring the attribution-parameter campaign / invite links (this is by design). +- **Code0.ai**: the invite link is updated to the new agent signup link; the API endpoint is unchanged. +- **Removed duplicate OpenAI Compatible presets**: the `OpenAI Compatible` custom-template entry was removed from the OpenCode and OpenClaw preset lists — the built-in `custom` provider flow already offers the same starting point, so the selector no longer shows two entries pointing at the same place. Existing providers are unaffected. + +--- + +## Fixed + +### Codex OAuth Client Identity Aligned: Fixes 404 on Latest ChatGPT Models + +When routing through local proxy takeover with an official Codex OAuth account, the latest subscription models (e.g. `gpt-5.6-luna`) previously returned a misleading `404 Model not found` — even though the account had access. The root cause is that ChatGPT's Codex backend does model-group routing by the `originator` + `version` headers, and cc-switch previously self-reported `originator: cc-switch` with no version, which routed it to a group where luna wasn't yet deployed. Takeover requests now send the same `originator: codex_cli_rs` + `version: 0.144.1` as the real Codex CLI, satisfying luna's minimum client-version requirement — confirmed fixed by A/B testing against the real backend. + +### Responses Upstream Failures No Longer Turn into Empty Replies + +When the proxy bridges an Anthropic-format client (Claude Code / Claude Desktop) to an OpenAI Responses upstream, a semantic failure hidden inside an HTTP 2xx body (a `status:"failed"` object, an `error` envelope, or a `response.failed` SSE event before the first output) was previously converted into a silent empty turn. These failures are now recognized as real errors inside the retry loop, so failover can retry with a different provider; a stream that ends cleanly but with incomplete content is faithfully marked truncated rather than complete; a gateway that ignores `stream:true` and returns the whole JSON document is recognized and expanded into a full streaming lifecycle; and when the client history itself is malformed, it errors immediately instead of retrying a guaranteed-to-fail request against every provider. + +### Reasoning, Tool Results, and the System Role Preserved Across the Responses/Anthropic Bridge + +Content crossing the Responses↔Anthropic bridge in multi-turn tool loops is no longer lost or corrupted: encrypted reasoning entries round-trip losslessly (also eliminating the problem where a failed round-trip got the next request rejected upstream); the streaming converter supports the official reasoning event vocabulary and recovers tool arguments from the terminal event when a gateway skips the deltas; a structured tool result's `is_error` flag, images, and PDF documents are fully preserved in both directions instead of being flattened into a single JSON string; and `system` / `developer` messages in history are correctly promoted to Anthropic `system` instead of being silently demoted to user turns. On billing, when the upstream request succeeds but the subsequent conversion fails, usage is still recorded rather than dropped. + +### Cache-Write Tokens No Longer Double-Billed + +The `input_tokens` reported by Codex / Gemini-style providers include both cache reads and cache writes, but the cost calculation previously only subtracted cache reads — so cache-write tokens were **billed twice**, once at the input rate and once at the cache-creation rate. Both are now deducted first, and the cache-write number is no longer lost across format conversions (Chat↔Responses↔Anthropic). To keep historical data consistent, the database adds a column recording the storage semantics of each row's `input_tokens` (schema v12→v13 auto-migration): old rows are recomputed under the old semantics, new rows under the new, and Claude-style rows are unaffected. + +### Stronger Prompt-Cache Breakpoint Injection + +On the proxy paths that inject Anthropic `cache_control` breakpoints (the Codex takeover bridge and the Bedrock native optimizer), the injector now uses the four-breakpoint budget more thoroughly: beyond the tail of tools, the tail of the system prompt, and the latest cacheable message, when budget remains it also anchors an earlier user message, keeping the stable prefix within Anthropic's 20-block lookback window — so long, tool-heavy conversations keep hitting the prompt cache instead of resending the system prompt, tools, and history at full price each turn. Caller-supplied breakpoints are preserved as-is (never removed, reordered, or rewritten); injected markers all use the standard 5-minute TTL. + +### Kimi For Coding's 256K Context Window Actually Takes Effect + +The `CLAUDE_CODE_AUTO_COMPACT_WINDOW=262144` added to the Kimi For Coding preset in 3.16.4 in fact **never took effect**: Claude Code caps unrecognized model ids at a 200K window and takes the compaction window as `min(model window, configured value)`, so 262144 got clamped back to 200K. This release fills in the two missing pieces — the preset now also pins `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, and it explicitly routes each tier's model to the endpoint's `kimi-for-coding` alias (a `claude-` prefixed id makes Claude Code ignore both window parameters; a non-Claude alias is the key to unlocking the large window). Saved providers also get these two window defaults auto-injected on switch, but **the alias routing only lives in the preset** — a provider saved from the old preset is still effectively 200K and needs a one-time re-apply of the preset (see "Upgrade Notes"). + +### Deleted Codex MCP Servers No Longer Come Back + +The authoritative MCP-server data lives in the database, and the `[mcp_servers]` in the Codex live `config.toml` is only a projection re-synced after every write — but on switch-away this projection was baked into the provider snapshot, so a server you deleted in the app came back to life the next time you activated that provider, and per-entry reconciliation could never clear the orphan. On switch-away, `[mcp_servers]` (including the legacy `[mcp.servers]`) is now stripped from the stored snapshot, and an already-polluted snapshot self-heals on its next switch-away. One visible side effect: a hand-written `[mcp_servers.*]` section in a Codex provider config is stripped out of the snapshot on its first switch-away — from now on, define Codex's MCP servers through the MCP manager (see "Upgrade Notes"). + +### More Robust MCP Sync: No File-Blanking on Parse Failure, Per-App Errors + +Two fixes. First, when writing a single MCP server to Codex, if the existing `config.toml` fails to parse, the old logic fell back to an empty document and wrote the whole thing back — **the entire file was blanked** down to that one MCP entry; it now returns a validation error and leaves the file untouched. Second, "Import from App" previously swallowed each importer's error as 0, so a broken Codex config would only show "imported 0 servers"; it now imports best-effort per app and, on failure, reports exactly which app failed. The projection on switch and save is also scoped to the target app only, so one app's live-file parse failure no longer blocks the others by association, nor falsely reports an already-successful switch as a failure. + +### Codex Common-Config Merge Preserves Comments and Key Order + +Checking / unchecking "Apply Common Config" in the Codex provider form previously went through a front-end TOML implementation that reformatted the whole file (parse → merge → serialize): comments were dropped, keys were reordered, and empty table headers like `[model_providers]` appeared out of nowhere — the culprit behind "config.toml keeps getting reordered". The merge now goes through a backend command sharing the same merge semantics as writing the live config, so hand-written formatting fully survives the mid-edit merge; a double guard (operation sequence number + config baseline check) was also added for the fast-switch race introduced by going async, so an earlier-issued-but-later-arriving stale result doesn't overwrite newer state. + +### Managed Claude Takeover Injects Only a Single Auth Placeholder + +When switching from a third-party endpoint to a Codex-managed provider, `~/.claude/settings.json` had both `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` placeholders written, causing Claude Code to warn "Both ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY set" on every launch. It now injects only one: Codex-managed uses `ANTHROPIC_AUTH_TOKEN`, Copilot uses `ANTHROPIC_API_KEY`, and any other token key is cleared. Note: after upgrading, if the live config already carries both keys, the "skip rewrite if config unchanged" short-circuit means the warning may persist — toggle the Claude route off and on once (or switch providers once) to trigger a rewrite (see "Upgrade Notes"). ([#5095](https://github.com/farion1231/cc-switch/pull/5095), fixes #4919) + +### Usage and Quota Queries: Auto-Retry on Transient Failures, No Longer Poison the Cache + +Usage and quota queries frequently showed a "query failed" that a manual refresh couldn't clear, because all transport-layer failures (including a mid-read timeout while reading the response body) were folded into "succeeded, but the result is a failure" — so the front-end's auto-retry never fired, and the failure body was cached as real data. Transport failures now return an error faithfully: react-query's auto-retry kicks in, HTTP 429 is treated as transient like 5xx, retained last-good data expires normally on a 10-minute window, and the footer keeps a retry entry and the real error message in the failure state. (fixes [#3820](https://github.com/farion1231/cc-switch/issues/3820)) + +### Codex Subagent Session Usage Counted in Local Statistics + +The token usage of Codex subagent (spawned agent) sessions previously never made it into local statistics: subagent logs carry the parent thread's `session_id`, so multiple subagents' records collided and were discarded as duplicates. The parser now builds a unique identity from each file's own `thread_id`, and recognizes the replay of the parent thread's history at the start of a subagent log — using it only to recover the cumulative baseline, not to double-bill; archived logs also inherit the sync cursor by filename, so re-parsing only imports the new portion. ([#5187](https://github.com/farion1231/cc-switch/pull/5187)) + +### Codex Free-Tier 30-Day Quota Window Displays Correctly + +Codex free accounts are metered on a rolling 30-day window (rather than the paid tier's weekly window), but the front-end whitelist and tray grouping didn't recognize the `30_day` tier — with a free account's only tier filtered out, the quota footer went entirely blank and the tray showed no quota at all. The 30-day tier now renders correctly in both the footer and the tray, with labels in sync across all four locales. ([#4886](https://github.com/farion1231/cc-switch/pull/4886), fixes #3651) + +### Usage Dashboard Refresh Interval Persisted + +The usage dashboard's auto-refresh interval was previously component-local state that reset to 30 seconds on every restart. It's now persisted via a new app setting, with changes applied optimistically and rolled back automatically on save failure. ([#5057](https://github.com/farion1231/cc-switch/pull/5057)) + +### Fable-Tier Model Keys No Longer Leak into the Common Config + +Fable is the fourth Claude model-mapping tier added in v3.16.3, but its two keys `ANTHROPIC_DEFAULT_FABLE_MODEL(_NAME)` were left off the provider-specific exclusion list — so one provider's Fable model pin could leak into the shared common config and then be injected into other providers. It's now stripped like the haiku / sonnet / opus tiers, and Fable-tier proxy takeover support was filled in along the way (writing a stable role alias on takeover, cleaning up stale values on switch-away). ([#5206](https://github.com/farion1231/cc-switch/pull/5206), fixes #4272) + +### Tool-Schema Default-Type Fallback and API Key Input for Uncategorized Providers + +Two provider-side fixes: when a client-sent tool's `input_schema` lacks a top-level `type` (or is an empty `{}`), the proxy conversion would be rejected by a strict gateway; the root schema now auto-fills `type: "object"` (only the root, leaving nested subschemas untouched); and the issue where an uncategorized provider imported from history or hand-built showed no Claude API Key input on edit is fixed — the field now shows for any provider that isn't an official / cloud-vendor category. ([#5069](https://github.com/farion1231/cc-switch/pull/5069)) + +### Image-Request Fallback for the Text-Only GLM 5.2 Model + +When the local proxy takes over Volcengine Coding Plan running GLM 5.2, image blocks in a request no longer produce an unrecoverable 400: the text-only roster exactly includes `glm-5.2` (deliberately not prefix-matching, so a future multimodal `glm-5.2v` isn't caught), and the preventive path strips images before the request goes out; the gateway's error message that doesn't contain the word image (`Model only support text input`) is also recognized by the reactive path's self-identifying-phrase roster, triggering the media fallback. (fixes [#5025](https://github.com/farion1231/cc-switch/issues/5025)) + +### A Batch of Small Session and Live-Config Sync Fixes + +- **Show renamed Codex session titles**: a session you renamed inside Codex now shows the new title in the session manager instead of falling back to the first message text; reads during concurrent writes no longer fail immediately. ([#4927](https://github.com/farion1231/cc-switch/pull/4927)) +- **OpenCode / OpenClaw / Hermes live edits synced into the store on startup**: editing a live config file directly (changing the base URL, adding a model) was previously never picked up after the first import; it's now diffed against the store on every startup and updated on difference, all non-fatal. ([#4712](https://github.com/farion1231/cc-switch/pull/4712), [#5098](https://github.com/farion1231/cc-switch/pull/5098)) +- **OpenCode session-resume command updated**: the resume command the session manager shows and copies is corrected from the outdated `opencode session resume ` to the current CLI's `opencode -s `. ([#2359](https://github.com/farion1231/cc-switch/pull/2359)) +- **Official providers skip the connectivity probe**: the connectivity check no longer derives a guaranteed-to-fail credential-less first-party endpoint probe for official-category providers (e.g. hitting `chatgpt.com/backend-api/codex` bare); batch checks skip it, and an individual resolve reports a clear error. + +--- + +## Documentation + +### Codex + Kimi Local Routing Guide + +A new step-by-step guide (in Chinese / English / Japanese, with UI screenshots) explaining how to use Kimi inside Codex CLI via CC Switch's local routing: newer Codex CLI speaks the OpenAI Responses protocol, while the Kimi Open Platform (pay-as-you-go, `kimi-k2.7-code`) and Kimi For Coding (membership, `kimi-for-coding`) both expose Chat Completions endpoints, so a direct connection usually 404s on `/responses`. The guide covers the whole flow from adding a provider from the built-in preset to the four-step protocol-conversion chain. + +### README Sponsor Update + +The open-source AI infrastructure project `new-api` joins the sponsor table in the four-language READMEs. + +--- + +## Upgrade Notes + +### Kimi For Coding Providers Need a Preset Re-Apply + +If you're using a provider created from the Kimi For Coding preset, please **re-pick it from the preset and save once**: the key to the 256K window — routing each tier's model to the `kimi-for-coding` alias — only lives in the new preset, so a provider saved from the old preset still compacts prematurely at 200K even after upgrading. + +### Hand-Written Codex `[mcp_servers.*]` Will Be Stripped from the Snapshot + +To root out "deleted MCP servers coming back", switching away from a Codex provider strips the `[mcp_servers]` section from the stored snapshot. If you have an MCP server hand-written directly in a Codex provider config, it will disappear from the snapshot on the first switch-away from that provider — please instead define Codex's MCP servers through the MCP manager (MCP tab), where the entries are authoritative and get projected into the live config automatically. + +### The Dual-Auth-Key Warning May Need a One-Time Manual Rewrite + +If Claude Code still warns "Both ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY set" after upgrading, it's because the takeover logic short-circuits and skips the rewrite when the live config is unchanged. Toggle Claude's route off and on once (or switch providers once) to write the corrected single-placeholder config, and the warning goes away. + +### Automatic Database Migration + +On the first launch of v3.17.0, the database automatically migrates from schema v11 to v13 (adding the projects table and the usage-semantics column), with no manual steps. If you're in the habit of rolling back to an older version, back up `~/.cc-switch/cc-switch.db` first. + +--- + +## Risk Notice + +This release continues the risk notices from previous versions for reverse-proxy-style features. + +**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. The "official ChatGPT account proxy route takeover" added in this release is the same class of usage, so please be aware of the same risk. + +**Codex third-party provider Chat routing**: when CC Switch local proxy converts and forwards Codex 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. + +**Claude Desktop third-party provider proxy switching**: when CC Switch's built-in proxy gateway forwards Claude Desktop requests to third-party providers, you must also follow the target provider's billing, compliance, and data-retention terms. + +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.17.0: + +- [#5071](https://github.com/farion1231/cc-switch/pull/5071): add the native Anthropic Messages protocol as a Codex upstream, thanks @yeeyzy. +- [#4830](https://github.com/farion1231/cc-switch/pull/4830): add Claude subagent model configuration, thanks @AkimioJR. +- [#5124](https://github.com/farion1231/cc-switch/pull/5124): add the 1M checkbox to the fallback model field, thanks @salarkhannn. +- [#5128](https://github.com/farion1231/cc-switch/pull/5128): add Zhipu team-plan quota queries, thanks @zhanxin-xu. +- [#2907](https://github.com/farion1231/cc-switch/pull/2907): add the Headers and token-limit editors to the OpenCode form, thanks @git1677967754. +- [#2811](https://github.com/farion1231/cc-switch/pull/2811): auto-sync universal providers after adding, thanks @hubutui. +- [#4838](https://github.com/farion1231/cc-switch/pull/4838): upgrade the LongCat presets to LongCat-2.0, thanks @solthx. +- [#5095](https://github.com/farion1231/cc-switch/pull/5095): inject only a single auth placeholder on managed Claude takeover, thanks @fengshao1227. +- [#5187](https://github.com/farion1231/cc-switch/pull/5187): count Codex subagent session usage in statistics, thanks @starmiaoa. +- [#4886](https://github.com/farion1231/cc-switch/pull/4886): fix the Codex free-tier 30-day quota window not showing, thanks @SaladDay. +- [#5057](https://github.com/farion1231/cc-switch/pull/5057), [#4927](https://github.com/farion1231/cc-switch/pull/4927), [#2359](https://github.com/farion1231/cc-switch/pull/2359): persist the refresh interval, show renamed session titles, and correct the OpenCode resume command, thanks @makoMakoGo. +- [#5206](https://github.com/farion1231/cc-switch/pull/5206): exclude Fable model keys from the common config, thanks @fzh365. +- [#5069](https://github.com/farion1231/cc-switch/pull/5069): tool-schema default-type fallback and API Key input restoration, thanks @Komikawayi. +- [#4712](https://github.com/farion1231/cc-switch/pull/4712), [#5098](https://github.com/farion1231/cc-switch/pull/5098): sync OpenCode / OpenClaw / Hermes live config on startup, thanks @allenxu09. + +Thanks also to everyone who reported Codex official routing, cache billing, MCP sync, and quota query issues — a good portion of this release's fixes came directly from reproduction clues in these real-world scenarios. + +--- + +## 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.17.0-Windows.msi` | **Recommended** - MSI installer with auto-update | +| `CC-Switch-v3.17.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.17.0-macOS.dmg` | **Recommended** - DMG installer, drag to Applications | +| `CC-Switch-v3.17.0-macOS.zip` | Unzip and drag to Applications, Universal Binary | +| `CC-Switch-v3.17.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.17.0-Linux-x86_64.AppImage` / `.deb` / `.rpm` +- `CC-Switch-v3.17.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` | + + + diff --git a/docs/release-notes/v3.17.0-ja.md b/docs/release-notes/v3.17.0-ja.md new file mode 100644 index 000000000..1ac6df2da --- /dev/null +++ b/docs/release-notes/v3.17.0-ja.md @@ -0,0 +1,363 @@ +# CC Switch v3.17.0 + +> 本リリースでは、長らく待ち望まれていた**「プロジェクト」のワンクリック切り替え**を追加しました。現在のプロバイダー、MCP、Skills、メモリファイルを名前付きスナップショットとしてまとめて保存し、タイトルバーやトレイから別の構成へ一括で切り替えられます。切り替え時には、離れる側のプロジェクトの現在状態も自動保存されます。Codex 側も大きく進化しました。**公式 ChatGPT サブスクリプションアカウントもローカルプロキシ経由でルーティング**できるようになり、サードパーティプロバイダーと同じルーティング・使用量統計を利用できます。GPT-5.6 ファミリーのコンテキストウィンドウと Sol / Terra / Luna の 3 段階価格にも対応しました。さらに、ネイティブ Anthropic Messages 上流形式を追加——勤務先で Claude Code クライアントは禁止されていても Claude API は禁止されていませんか?これで **Codex の中から Claude 系モデルを直接利用できます**。このほか、上流エラーが「空の応答」になる問題、キャッシュ書き込み token の二重課金、削除した MCP サーバーの復活、Kimi For Coding の 256K ウィンドウが実際には効いていなかった問題など、多数の正確性の問題を修正しました。 + +**[English →](v3.17.0-en.md) | [中文版 →](v3.17.0-zh.md)** + +--- + +## 利用ガイド + +本リリースの新機能は、主にホーム画面上部のプロジェクト切り替え、Codex プロバイダーフォーム、使用量ダッシュボードにあります。以下のドキュメントもあわせてご覧ください: + +- **[Codex で Claude を使う(ローカルルーティングガイド)](../guides/codex-claude-routing-guide-ja.md)**:本リリースの「ネイティブ Anthropic Messages 上流」に対応した新しいステップバイステップガイドです。Codex プロバイダーの上流形式を `anthropic` にして、`/v1/messages` のみを提供する Claude 系ゲートウェイへ接続し、Codex の中で Claude 系モデルを使う手順を説明します。 +- **[Codex で Kimi を使う(ローカルルーティングガイド)](../guides/codex-kimi-routing-guide-ja.md)**:新しい Codex CLI は OpenAI Responses 形式を使いますが、Kimi Open Platform と Kimi For Coding は Chat Completions endpoint を提供するため、直接接続すると通常は 404 になります。このガイドでは、内蔵の `Kimi` / `Kimi For Coding` プリセットとローカルルーティングを使ったプロトコル変換を説明します。 +- **[Codex の公式ログインを保持する](../guides/codex-official-auth-preservation-guide-ja.md)**:サードパーティプロバイダーへ切り替える際に、CC Switch が公式 ChatGPT ログインをどう保持するかを説明します。本リリースではさらに、公式アカウント自体をプロキシ経由でルーティングできるようになりました。 +- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**:使用量ダッシュボードのデータソースと集計方法を確認できます。本リリースではキャッシュ書き込み課金を修正し、Codex サブエージェントの使用量を補完し、GPT-5.6 と Hunyuan Hy3 の価格を追加しました。 + +--- + +> [!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.17.0 は v3.16.5 に続く大型機能リリースです。中心となるのは**「プロジェクト」**です。Claude Code / Claude Desktop / Codex の現在のプロバイダー、MCP、Skills、メモリファイルを名前付きスナップショットとして保存できます。たとえば、プログラミング用ディレクトリには「開発」、文章作成やイラスト用ディレクトリには「イラスト」という構成を用意し、ホーム上部またはトレイの「プロジェクト」サブメニューからワンクリックで一括切り替えできます。切り替える前には、離れるプロジェクトの現在状態が自動で書き戻されるため、各プロジェクトには最後に離れた時点の構成が常に保持されます。 + +もう一つの柱は Codex です。**ChatGPT サブスクリプションでログインした公式アカウントもローカルプロキシでルーティング**できるようになりました。API key は不要で、Codex 自身のログイン情報をそのまま転送し、公式ログインを上書きしません。クライアント識別情報も修正され、`gpt-5.6-luna` など最新のサブスクリプションモデルが誤って 404 になる問題を解消しました。GPT-5.6 の 372K コンテキスト注入、Sol / Terra / Luna の 3 段階価格(キャッシュ書き込みは 1.25 倍)、プリセットの既定モデルも同時に対応しています。さらに Codex の上流形式にネイティブ Anthropic Messages を追加しました。これは、**Claude Code クライアントは禁止していても Claude API は禁止していない企業が多い**という現実的な利用場面を想定したものです。Codex を Claude API、または `/v1/messages` のみを提供するゲートウェイへ接続し、Codex の中で Claude 系モデルを使えます。 + +正確性についても、3 つの領域を集中的に改善しました。**プロキシ変換**では、HTTP 2xx 内の上流エラーを空の応答に変換せず failover へ渡し、Responses↔Anthropic 間で reasoning、ツール結果、system role を保持し、プロンプトキャッシュのブレークポイントを強化しました。**使用量と課金**では、キャッシュ書き込み token が入力料金とキャッシュ作成料金の両方で計上されていた問題を修正し、ネットワークの一時障害で使用量キャッシュが壊れないようにしました。**Codex `config.toml`** では、削除した MCP サーバーの復活を防ぎ、解析不能なファイルを同期時に消去しないよう fail-closed にし、共通設定のマージでコメントやキー順序を維持するようにしました。Kimi For Coding の 256K ウィンドウ、Codex サブエージェントと無料プランの使用量表示、Zhipu チームプランのクォータ照会、OpenCode フォームの強化も含まれます。 + +**リリース日**: 2026-07-13 + +**Stats**: 69 commits | 172 files changed | +21,067 / -2,464 lines + +--- + +## ハイライト + +- **「プロジェクト」をワンクリック切り替え**:プロバイダー、MCP、Skills、メモリファイルをまとめて名前付きスナップショットとして保存できます(たとえばプログラミング用と、文章作成・イラスト用)。ホーム上部またはトレイから一括切り替えでき、離れるプロジェクトの現在状態は自動保存されます。Claude Code、Claude Desktop、Codex の各スコープは互いに干渉しません。 +- **Codex の公式アカウントもプロキシルーティング可能に**:ChatGPT サブスクリプションでログインした Codex セッションをローカルプロキシ経由で扱い、サードパーティプロバイダーと同じルーティング・使用量統計を利用できます。公式ログイン情報は保存も上書きもしません。 +- **GPT-5.6 に全面対応**:Claude Code の Codex テイクオーバー時に 372K コンテキストを注入し、Sol / Terra / Luna の価格(キャッシュ書き込みは入力価格の 1.25 倍)を追加。プリセットも GPT-5.6 ファミリーへ更新し、クライアント識別修正で `gpt-5.6-luna` の誤った 404 を解消しました。 +- **Codex で Claude 系モデルを使用(ネイティブ Anthropic Messages 上流)**:Claude Code クライアントは禁止されていても Claude API は禁止されていない企業環境で、Codex の上流形式を `anthropic` に設定すれば Claude API または `/v1/messages` ゲートウェイへ接続できます。ローカルプロキシが Responses↔Anthropic を双方向変換します。 +- **プロキシ変換の正確性を改善**:上流エラーを空の応答にせず failover へ渡し、reasoning / ツール結果 / system role を保持。キャッシュ書き込みの二重課金も修正しました。 +- **Codex `config.toml` を堅牢化**:削除済み MCP サーバーの復活を防ぎ、解析エラー時にファイルを消去せず、共通設定マージでコメントとキー順序を保持します。 +- **Kimi For Coding の 256K が実際に有効に**:以前の 262144 設定は Claude Code の 200K 上限に抑えられていました。本リリースでモデル別名とウィンドウ設定を補完します。既存プロバイダーはプリセットを適用し直してください。 + +--- + +## 追加機能 + +### 「プロジェクト」:構成一式を保存してワンクリック切り替え + +本リリースの中心機能です。現在のプロバイダー、MCP、Skills、メモリファイルを名前付き「プロジェクト」として保存し、ホーム上部のプロジェクト切り替えまたはトレイの「プロジェクト」サブメニューから一括で切り替えられます。項目ごとに手作業で切り替える必要はありません。 + +典型的な例を挙げます。あるディレクトリではプログラミングを行い、別のディレクトリでは文章を書いたりイラストを作ったりするとします。プログラミングには専用のプロバイダー、ファイルシステム / GitHub MCP、コードレビュー Skills、開発ルールを書いたメモリファイルが必要です。一方、文章作成やイラストでは別のプロバイダー、別の MCP、まったく異なるプロンプトを使います。以前は作業を移るたびに、プロバイダーを変え、MCP と Skills を一つずつ切り替え、メモリファイルも変更する必要がありました。今後は 2 つの構成を「開発」と「イラスト」というプロジェクトに保存し、作業ディレクトリを変えるときに **CC Switch で一度クリックするだけ**で構成一式を切り替えられます。ディレクトリに応じて自動切り替えする機能ではなく、ユーザーが明示的に選ぶ仕組みです。 + +設計上のポイント: + +- **プロジェクトは共有、切り替えはスコープ別**:同じプロジェクトでも Claude Code / Claude Desktop / Codex はそれぞれ独立した現在位置とスナップショットを持ちます。Codex タブで切り替えても Claude の設定には触れません。 +- **切り替え時に自動保存**:新しいプロジェクトへ移る前に、離れるプロジェクトの現在状態をそのスコープへ自動で書き戻します。このため手動の「現在状態で更新」ボタンは不要です。 +- **適用はベストエフォート**:既存の切り替え処理を再利用し、プロバイダー、MCP / Skills の差分、メモリファイルの順に適用します。削除済み項目への参照は警告してスキップし、全体をロールバックしません。 +- **プロキシテイクオーバーを自動解除**:スナップショットを適用する前に対象スコープのテイクオーバーを無効化し、ルーティング状態との競合を避けます。 + +プロジェクトを使わない場合は、「設定 → ホームページ表示」の「プロジェクト切り替えを表示」をオフにできます。ホームの入口だけが非表示になり、トレイのサブメニューやプロジェクトデータはそのまま利用できます。新しい `profiles` テーブルへのデータベース移行は自動です。 + +### Codex 公式 ChatGPT アカウントのプロキシルーティング + +ChatGPT サブスクリプション(OAuth または API-key ログイン)で使う Codex セッションを、CC Switch のローカルプロキシ経由でルーティングできるようになりました。サードパーティプロバイダーと同じルーティング、形式変換、使用量統計を利用できます。プロバイダーパネルまたはトレイで内蔵の「OpenAI Official」を選択してください。削除済みの場合は、プロバイダー追加時に自動復元されます。 + +実装では**認証情報を保存しない**ことを重視しています。`auth.json` にダミーの API key を書かず、ローカルプロキシを指す専用 `model_provider` を `config.toml` へ投影します。Codex 自身の ChatGPT Authorization header をプロキシが公式 endpoint へそのまま転送し、`codex-official` 行の認証情報は常に空です。テイクオーバー時も OAuth / API-key 情報をバックアップへ保持します。公式 endpoint の 401 / 403 は再試行不能として扱い、failover が別アカウントへ会話を移したり circuit breaker を汚したりしません。「切り替え時に Codex 公式ログインを保持」設定は、今後、ルーティングを使わないサードパーティへの直接切り替えだけを制御します。 + +### GPT-5.6:コンテキスト、プリセット、3 段階価格 + +- **372K コンテキスト注入**:Claude Code を ChatGPT Codex(Codex OAuth)へルーティングすると、`CLAUDE_CODE_MAX_CONTEXT_TOKENS` と `CLAUDE_CODE_AUTO_COMPACT_WINDOW` を 372000 で注入します。全設定モデルが GPT-5.6 ファミリーの場合だけ適用し、GPT-5.5 や混在マッピングには適用しません。ユーザーの明示設定が常に優先され、切り替え後に既定値が保存設定へ固着しないよう逆変換で除去します。 +- **プリセット更新**:Claude Code / Claude Desktop の Codex OAuth プリセットを GPT-5.6 ファミリーへ更新(haiku → `gpt-5.6-luna`、main / sonnet / opus → `gpt-5.6`)。Codex `config.toml` テンプレートも更新しました。 +- **Sol / Terra / Luna 価格**:100 万 token あたりの入力 / 出力 / キャッシュ読み取りを、Sol 5 / 30 / 0.50、Terra 2.50 / 15 / 0.25、Luna 1 / 6 / 0.10 USD で登録。GPT-5.6 のキャッシュ書き込みは入力の 1.25 倍(6.25 / 3.125 / 1.25)で、既存データベースの誤ったゼロ値も安全に修復します。 + +### Codex で Claude 系モデルを使う:ネイティブ Anthropic Messages 上流 + +この機能は、**Claude Code クライアントを禁止していても Claude API は禁止していない企業が多い**という現実的な要望から生まれました。モデルは利用できるのに、許可されたクライアントがない——その空白を Codex で埋められます。Codex プロバイダーの上流形式で `anthropic` を選ぶと、Claude API またはネイティブ Anthropic Messages(`/v1/messages`)のみを提供するゲートウェイへ接続できます。ローカルプロキシが Responses↔Anthropic のリクエスト、応答、ストリームを双方向変換するため、Codex の操作感のまま Claude 系モデルとの対話やツール利用が可能です。 + +フォームには、認証フィールド選択(既定の `ANTHROPIC_AUTH_TOKEN` は `Authorization: Bearer`、`ANTHROPIC_API_KEY` は `x-api-key`)、任意の Claude Code クライアント偽装(既定オフ)、プロバイダー別 `maxOutputTokens` を追加しました。変換ブリッジは system、tools、history に標準 5 分の `cache_control` を注入し、`[1m]` 長コンテキストマーカーと beta header に対応します。途中で切れたストリームは成功として偽装せず、未完了 / 失敗として報告します。([#5071](https://github.com/farion1231/cc-switch/pull/5071)) + +### Codex プロバイダーフォームに「既定モデル」欄を追加 + +`config.toml` トップレベルの `model` をフォームから編集できます。新モデル(例:`gpt-5.6`)が公開されたとき、プリセット更新を待たずに既存プロバイダーを切り替えられます。TOML エディターと双方向同期し、モデルマッピングと `/models` endpoint の候補を表示します。明示値はマッピング先頭行より常に優先され、remote の model id を書き込む際は TOML エスケープと制御文字除去を行います。 + +### Codex でも切り替え時に共通設定を自動同期 + +v3.16.5 で Claude に追加した live 設定から共通設定への自動書き戻しを Codex にも拡張しました。共通設定を有効にした Codex プロバイダーから離れるとき、live `config.toml` の共有可能な部分を再抽出し、次のプロバイダーへ渡します。`model` / `model_provider` / `base_url` / `wire_api`、`[model_providers]`、MCP 投影、API key、モデルカタログ、CC Switch が注入した `web_search` は除外されるため、プロバイダー固有情報や秘密情報は共有されません。失敗は警告のみで切り替えを妨げません。 + +### Claude サブエージェントモデル設定 + +Claude プロバイダーフォームに「サブエージェント」モデル行を追加し、`CLAUDE_CODE_SUBAGENT_MODEL` を設定できるようにしました。サブエージェントを安価または高速なモデルへ固定でき、`[1M]` マーカーにも対応します。プロキシテイクオーバーとモデルマッパーもこの設定を認識し、共通設定からは除外されるため他プロバイダーへ漏れません。([#4830](https://github.com/farion1231/cc-switch/pull/4830)) + +### フォールバックモデルにも 1M チェックボックス + +Claude フォームのフォールバックモデル(`ANTHROPIC_MODEL`)に、Sonnet / Opus / Fable と同じ 1M チェックボックスを追加しました。チェックすると model id に `[1M]` が付き、解除すると除去されます。([#5124](https://github.com/farion1231/cc-switch/pull/5124)、#3679 を修正) + +### Zhipu チームプランのクォータ照会 + +使用量スクリプトに「Zhipu GLM Team(智谱团队)」テンプレートを追加しました。API Key、組織 ID、プロジェクト ID を入力すると、`?type=2` と必要な request headers を使ってチームプランのクォータを照会します。3 項目のいずれかが欠けている場合は補完を案内します。([#5128](https://github.com/farion1231/cc-switch/pull/5128)) + +### OpenCode フォーム:Headers とモデル別 Token 上限 + +OpenCode プロバイダーフォームに、`options.headers`(`HTTP-Referer` / `X-Title` など)と、モデル別の Context / Output token 上限(`model.limit.context` / `model.limit.output`)の編集 UI を追加しました。「Extra Options」は折りたたみ式になり、既存値があれば自動展開します。旧 draft prefix のため正当な `option-` で始まるキーが消える問題も修正しました。([#2907](https://github.com/farion1231/cc-switch/pull/2907)) + +### Tencent Hunyuan Hy3 の価格 + +2026-07-06 公開の Hunyuan Hy3(256K context)に価格を追加し、使用量が $0 と表示されないようにしました。`hunyuan-hy3` と `hy3` の両方を登録しています。実際の Hy3 は入力長で段階課金されますが、現在の単一価格テーブルでは最安段階を使用するため、長コンテキストでは費用を過小評価する可能性があります。 + +--- + +## 変更 + +### Codex Chat に `prompt_cache_key` を供給 + +Responses→Chat Completions 変換で、プロバイダーを考慮した `prompt_cache_key` を注入します。Kimi Coding と OpenAI 公式 endpoint は自動有効、Kimi プリセットは明示的に有効、未知の互換ゲートウェイは schema 400 を避けるため無効のままです。値はクライアントの明示値または実際の session ID だけを使い、リクエストごとのランダム UUID は生成しません。高度な設定で Auto / Enabled / Disabled を選べます。 + +### Codex の画像対応を自動推定 + +生成した Codex モデルカタログは、CC Switch が確認済みの**完全一致 text-only リスト**にあるモデルだけを `["text"]` として宣言します。GPT、alias、新しい suffix、未知のモデルは `["text", "image"]` へ fail-open します。これにより GPT 系モデルが Codex IDE で「画像非対応」と誤表示される問題を解消しました。整流器の text-only preflight は代理側の本文処理だけを制御し、カタログ宣言には影響しません。 + +### コンテキスト値をフォームではなくプリセットへ固定 + +`Codex`(ChatGPT / GPT-5.6)と `Kimi For Coding` の「Max Context Tokens」「Auto Compact Window」をフォームから外し、プリセット env に固定しました(Codex は 372000 / 372000、Kimi For Coding は 262144 / 262144)。遠隔実験でローカルの圧縮点が引き下げられないよう、2 つの env key 自体は意図的に残しています。必要な場合はプロバイダーの JSON エディターから直接編集できます。 + +### プロバイダー接続確認を簡素化 + +古いプロバイダー別 `testConfig`(timeout、retry、degraded latency)を削除し、軽量 `base_url` probe はグローバル設定だけを使うようにしました。自動 failover は引き続き独立した proxy timeout と circuit breaker 設定で動作します。「model test」関連の UI / API 名も「connectivity check」へ統一しました。 + +### Universal Provider を追加後すぐ live 設定へ同期 + +複数アプリ向け Universal Provider を追加すると、DB 保存後に Claude / Codex / Gemini 等の live 設定へすぐ同期します。同期に失敗しても保存は取り消さず、非ブロッキング警告を表示します。([#2811](https://github.com/farion1231/cc-switch/pull/2811)) + +### プリセット更新 + +- **LongCat-2.0**:Claude Code / Claude Desktop / Codex / Hermes / OpenClaw / OpenCode の既定モデルを廃止済みの名称から `LongCat-2.0` へ更新し、実際の 1M context を宣言しました。text-only モデルとして media sanitizer にも追加し、画像は上流で 400 になる前に非対応マーカーへ置換します。([#4838](https://github.com/farion1231/cc-switch/pull/4838)) +- **SudoCode**:旧 `sudocode.us` プリセットを新しいスポンサー SudoCode(`sudocode.chat`)へ置き換え、6 クライアントをカバーします。Codex / OpenCode / OpenClaw / Hermes の既定は `gpt-5.6-sol` です。 +- **火山 / Doubao / BytePlus**:v3.16.5 で製品ページへ変更した `websiteUrl` を、帰属パラメータ付きのキャンペーン / 招待リンクへ戻しました。これは意図された設定です。 +- **Code0.ai**:招待リンクを新しい agent 登録 URL へ更新。API endpoint は変更していません。 +- **重複した OpenAI Compatible プリセットを削除**:OpenCode / OpenClaw では内蔵 `custom` フローが同じ初期設定を提供するため、重複エントリだけを削除しました。既存プロバイダーには影響しません。 + +--- + +## 修正 + +### Codex OAuth のクライアント識別を修正し最新モデルの 404 を解消 + +公式 Codex OAuth をローカルプロキシで使うと、権限がある `gpt-5.6-luna` が `404 Model not found` になることがありました。ChatGPT Codex backend が `originator` + `version` の組み合わせで model cohort を選ぶ一方、CC Switch は `originator: cc-switch` かつ version なしで送っていたことが原因です。現在は実際の Codex CLI と同じ `originator: codex_cli_rs` + `version: 0.144.1` を送り、luna の最低バージョン要件を満たします。実 endpoint への A/B テストでも修正を確認しました。 + +### Responses 上流エラーを空の応答にしない + +Anthropic クライアントを Responses 上流へつなぐ際、HTTP 2xx body 内の `{status:"failed"}`、error envelope、出力前の `response.failed` SSE event が空の `end_turn` に変換されていました。現在は retry loop 内で実エラーとして検出し、failover が別プロバイダーを選べます。途中終了した stream は不完全または切断エラーとして報告し、`stream:true` を無視して JSON 全体を返す gateway にも対応します。不正なクライアント履歴は再試行不能エラーとして即座に返します。 + +### Responses / Anthropic 間で reasoning・ツール結果・system role を保持 + +暗号化 reasoning を署名付き thinking block として往復し、公式 reasoning stream events、欠落 delta の tool arguments、`is_error`、画像、PDF / `input_file` を保持します。履歴中の `system` / `developer` message は user 発言へ降格せず Anthropic `system` へ移します。上流リクエストが成功した後に変換エラーとなった場合も使用量を記録します。 + +### キャッシュ書き込み token の二重課金を修正 + +Codex / Gemini の `input_tokens` は cache read と cache write を含みますが、以前は read だけを差し引いたため、write token が入力料金と cache creation 料金の両方で計上されていました。現在は両方を差し引き、Chat↔Responses↔Anthropic の変換でも cache write 値を保持します。schema v13 の `input_token_semantics` により、既存行は旧方式、新規行は新方式で安全に計算します。 + +### プロンプトキャッシュのブレークポイント注入を強化 + +ツール末尾、system 末尾、最新の cacheable message に加え、予算があれば古い user message にも anchor を追加します。長い tool-heavy 会話でも安定 prefix が Anthropic の 20-block lookback 内に残ります。呼び出し側が付けた breakpoint は削除・並べ替え・書き換えせず、4 個を超えている場合は警告して自動注入しません。注入 TTL は標準の 5 分です。 + +### Kimi For Coding の 256K context を実際に有効化 + +`CLAUDE_CODE_AUTO_COMPACT_WINDOW=262144` だけでは、未知の非 Claude model を Claude Code が 200K とみなすため 200K に抑えられていました。本リリースは `CLAUDE_CODE_MAX_CONTEXT_TOKENS` を追加し、すべての model tier を `kimi-for-coding` alias へ明示的にルーティングします。既存プロバイダーには window 値を live 設定へ注入しますが、alias は新プリセットにのみ含まれるため、旧プリセット利用者は適用し直す必要があります。 + +### 削除した Codex MCP サーバーが復活しないように + +Codex live `config.toml` の `[mcp_servers]` は DB の投影ですが、切り替え時にプロバイダースナップショットへ保存されていたため、アプリで削除したサーバーが次回有効化時に復活していました。切り替え時に `[mcp_servers]` と旧 `[mcp.servers]` を保存設定から除去し、汚染済みスナップショットも次回切り替えで自己修復します。手書きの `[mcp_servers.*]` も除去対象になるため、今後は MCP 管理画面を使用してください。 + +### MCP 同期:解析エラー時にファイルを消去せず、アプリ別に失敗を報告 + +Codex `config.toml` が解析不能な状態で MCP を 1 件同期すると、以前は空の TOML へフォールバックしてファイル全体を上書きし、その MCP だけを残していました。現在は検証エラーを返し、元ファイルを変更しません。全アプリからのインポートも失敗を 0 件として隠さず、成功分を取り込んだうえで失敗したアプリ名をまとめて報告します。切り替え・保存時の投影は対象アプリだけに限定し、他アプリの破損に巻き込まれません。 + +### Codex 共通設定マージでコメントとキー順序を保持 + +フロントエンドの TOML parse → merge → stringify は、コメントを削除し、キーを並べ替え、空の table header を作っていました。マージを backend の `toml_edit` ベース処理へ移し、フォームプレビューと live 書き込みで同じ意味論を使うようにしました。非同期化に伴う race も operation sequence と config baseline の 2 段階で防ぎます。 + +### 管理下 Claude テイクオーバーでは auth placeholder を 1 つだけ注入 + +Codex 管理プロバイダーへ切り替えると `ANTHROPIC_API_KEY` と `ANTHROPIC_AUTH_TOKEN` の両方が `PROXY_MANAGED` になり、Claude Code が毎回警告していました。現在は Codex で `ANTHROPIC_AUTH_TOKEN`、Copilot で `ANTHROPIC_API_KEY` の 1 つだけを使います。既存の二重設定は短絡処理で自動書き換えされない場合があるため、Claude ルーティングを一度オフ / オンするか、プロバイダーを切り替えてください。([#5095](https://github.com/farion1231/cc-switch/pull/5095)、#4919 を修正) + +### 使用量・クォータ照会の一時障害を再試行し、キャッシュを汚さない + +通信失敗や body 読み取り timeout が `success:false` の正常結果として扱われ、再試行されず、失敗データが cache に保存されていました。現在は一時的な transport error、HTTP 429、5xx をエラーとして返し、react-query の retry を有効にします。最後の正常値は 10 分の保持期間に従い、失敗時も footer に retry 操作と実エラーを表示します。([#3820](https://github.com/farion1231/cc-switch/issues/3820)) + +### Codex サブエージェントの使用量をローカル統計へ追加 + +サブエージェントログは親 thread の `session_id` を持つため、複数サブエージェントの request ID が衝突し重複として破棄されていました。各ファイルの `thread_id` を優先して一意な ID を作り、ログ冒頭の親履歴 replay は累積 baseline の復元だけに使って二重計上を防ぎます。archive 後も元ファイルの cursor を継承します。([#5187](https://github.com/farion1231/cc-switch/pull/5187)) + +### Codex 無料プランの 30 日クォータを footer とトレイに表示 + +無料アカウントの `30_day` window が UI whitelist と tray grouping に含まれず、唯一の tier が消えてクォータ表示全体が空になっていました。30 日 tier を単一の定数で backend / tray / frontend に追加し、4 言語のラベルを用意しました。([#4886](https://github.com/farion1231/cc-switch/pull/4886)、#3651 を修正) + +### 使用量ダッシュボードの更新間隔を永続化 + +自動更新の off / 5s / 10s / 30s / 60s 選択をアプリ設定へ保存し、再起動後も維持します。変更は即時反映し、保存失敗時は元の値へ戻します。([#5057](https://github.com/farion1231/cc-switch/pull/5057)) + +### Fable model key を Claude 共通設定から除外 + +`ANTHROPIC_DEFAULT_FABLE_MODEL` / `_NAME` が除外リストから漏れ、あるプロバイダーの Fable mapping が共通設定経由で他へ流れる可能性がありました。Haiku / Sonnet / Opus と同様に除外し、Fable の proxy takeover role alias も補完しました。([#5206](https://github.com/farion1231/cc-switch/pull/5206)、#4272 を修正) + +### tool schema の既定 `type` と未分類プロバイダーの API Key 欄 + +Anthropic tool の root `input_schema` に `type` がない場合、厳格な OpenAI gateway が変換後の schema を拒否していました。root にだけ `type: "object"` を補い、nested schema は変更しません。また、過去にインポートした category なしのカスタムプロバイダーを編集すると API Key 欄が隠れる問題を修正しました。([#5069](https://github.com/farion1231/cc-switch/pull/5069)) + +### Volcano GLM 5.2 の text-only 画像 400 を回避 + +GLM 5.2 を完全一致の text-only model として登録し、画像 block を上流送信前に置換します。将来の multimodal `glm-5.2v` を誤判定しないよう prefix match は使いません。`Model only support text input` という image の語を含まないエラーも reactive fallback で認識します。([#5025](https://github.com/farion1231/cc-switch/issues/5025)) + +### セッションと live 設定の小修正 + +- **Codex で変更したセッションタイトルを表示**:`session_index.jsonl` / `state_5.sqlite` の保存タイトルを優先し、同時書き込み時は busy timeout を使います。([#4927](https://github.com/farion1231/cc-switch/pull/4927)) +- **OpenCode / OpenClaw / Hermes の live 編集を起動時に DB へ同期**:base URL や model を直接変更した場合も、毎回の起動で差分を取り込みます。([#4712](https://github.com/farion1231/cc-switch/pull/4712)、[#5098](https://github.com/farion1231/cc-switch/pull/5098)) +- **OpenCode の再開コマンドを更新**:`opencode session resume ` を現在の `opencode -s ` へ修正しました。([#2359](https://github.com/farion1231/cc-switch/pull/2359)) +- **公式プロバイダーの接続 probe をスキップ**:認証情報なしでは必ず失敗する公式 endpoint を batch connectivity check で probe しません。 + +--- + +## ドキュメント + +### Codex + Kimi ローカルルーティングガイド + +Kimi を Codex CLI で使うための手順を、中国語 / 英語 / 日本語とスクリーンショット付きで追加しました。Codex は Responses、Kimi Open Platform(`kimi-k2.7-code`)と Kimi For Coding(`kimi-for-coding`)は Chat Completions を使うため、内蔵プリセットから追加し、CC Switch のローカルルーティングで双方向変換する流れを説明します。 + +### README のスポンサー欄を更新 + +オープンソース AI インフラプロジェクト `new-api` を 4 言語 README のスポンサー表へ追加しました。 + +--- + +## アップグレード時の注意 + +### Kimi For Coding プロバイダーはプリセットを適用し直してください + +Kimi For Coding を使用している場合は、**新しいプリセットを選び直して保存**してください。256K を有効にする鍵である `kimi-for-coding` alias への各 model tier のルーティングは新プリセットにのみ含まれます。旧プリセットのままでは、アップグレード後も実際のウィンドウは 200K です。 + +### 手書きの Codex `[mcp_servers.*]` はスナップショットから除去されます + +削除済み MCP サーバーの復活を防ぐため、Codex プロバイダーから切り替えるとき `[mcp_servers]` を保存スナップショットから除去します。手書きした MCP 定義も初回切り替え時に消えるため、今後は MCP 管理画面で定義してください。DB の `mcp_servers` が正本となり、live config へ自動投影されます。 + +### auth 二重キー警告は一度手動で書き換えを発火させる必要があります + +アップグレード後も Claude Code が「Both ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY set」と警告する場合は、live config が一致しているとテイクオーバー処理が書き換えを省略するためです。Claude ルーティングを一度オフにしてからオンに戻すか、プロバイダーを一度切り替えると、単一 placeholder に修正されます。 + +### データベースは自動移行されます + +v3.17.0 の初回起動時に schema v11 から v13(プロジェクトテーブルと input token semantics)へ自動移行します。手動操作は不要です。古いバージョンへ戻す可能性がある場合は、先に `~/.cc-switch/cc-switch.db` をバックアップしてください。 + +--- + +## リスク通知 + +本リリースは、リバースプロキシ系機能に関する以前のリスク通知を引き続き適用します。 + +**Codex OAuth リバースプロキシ**:ChatGPT サブスクリプションの Codex OAuth をリバースプロキシ経由で使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 release notes](v3.13.0-ja.md#️-リスクに関する注意事項) を参照してください。本リリースで追加した「公式 ChatGPT アカウントのプロキシルーティング」も同じ種類の利用方法であり、同様のリスクがあります。 + +**Codex サードパーティプロバイダー Chat ルーティング**:CC Switch ローカルプロキシで Codex リクエストを変換し、サードパーティプロバイダーへ転送する場合、課金・コンプライアンス・データ保持に関する制約はプロバイダーごとに異なります。利用前に対象プロバイダーの利用規約を確認してください。 + +**Claude Desktop サードパーティプロバイダープロキシ切り替え**:CC Switch 内蔵のプロキシゲートウェイで Claude Desktop のリクエストをサードパーティプロバイダーへ転送する場合も、対象プロバイダーの課金・コンプライアンス・データ保持に関する規約に従う必要があります。 + +上記機能を有効化したユーザーは、関連するリスクを自ら負うものとします。CC Switch は、これらの機能の利用によって発生したアカウント制限、警告、サービス停止について責任を負いません。 + +--- + +## 謝辞 + +v3.17.0 で機能と修正を届けてくださった以下のコントリビューターに感謝します: + +- [#5071](https://github.com/farion1231/cc-switch/pull/5071):Codex の上流としてネイティブ Anthropic Messages protocol を追加、@yeeyzy に感謝。 +- [#4830](https://github.com/farion1231/cc-switch/pull/4830):Claude サブエージェントモデル設定を追加、@AkimioJR に感謝。 +- [#5124](https://github.com/farion1231/cc-switch/pull/5124):フォールバックモデルに 1M チェックボックスを追加、@salarkhannn に感謝。 +- [#5128](https://github.com/farion1231/cc-switch/pull/5128):Zhipu チームプランのクォータ照会を追加、@zhanxin-xu に感謝。 +- [#2907](https://github.com/farion1231/cc-switch/pull/2907):OpenCode フォームに headers と token 上限編集を追加、@git1677967754 に感謝。 +- [#2811](https://github.com/farion1231/cc-switch/pull/2811):Universal Provider の追加後自動同期、@hubutui に感謝。 +- [#4838](https://github.com/farion1231/cc-switch/pull/4838):LongCat プリセットを LongCat-2.0 へ更新、@solthx に感謝。 +- [#5095](https://github.com/farion1231/cc-switch/pull/5095):管理下 Claude テイクオーバーの auth placeholder を 1 つに修正、@fengshao1227 に感謝。 +- [#5187](https://github.com/farion1231/cc-switch/pull/5187):Codex サブエージェントの使用量統計を修正、@starmiaoa に感謝。 +- [#4886](https://github.com/farion1231/cc-switch/pull/4886):Codex 無料プランの 30 日クォータ表示を修正、@SaladDay に感謝。 +- [#5057](https://github.com/farion1231/cc-switch/pull/5057)、[#4927](https://github.com/farion1231/cc-switch/pull/4927)、[#2359](https://github.com/farion1231/cc-switch/pull/2359):更新間隔の永続化、変更済みセッションタイトル表示、OpenCode 再開コマンド修正、@makoMakoGo に感謝。 +- [#5206](https://github.com/farion1231/cc-switch/pull/5206):Fable model key を共通設定から除外、@fzh365 に感謝。 +- [#5069](https://github.com/farion1231/cc-switch/pull/5069):tool schema の既定 type と API Key 欄を修正、@Komikawayi に感謝。 +- [#4712](https://github.com/farion1231/cc-switch/pull/4712)、[#5098](https://github.com/farion1231/cc-switch/pull/5098):OpenCode / OpenClaw / Hermes の live 設定同期、@allenxu09 に感謝。 + +Codex 公式ルーティング、キャッシュ課金、MCP 同期、クォータ照会について報告してくださったすべてのユーザーにも感謝します。本リリースの多くの修正は、実際の利用場面から得られた再現情報をもとにしています。 + +--- + +## ダウンロードとインストール + +[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.17.0-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー | +| `CC-Switch-v3.17.0-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます | + +Windows ARM64 デバイスでは、ファイル名に `arm64` が含まれる対応する成果物を選択してください。 + +### macOS + +| ファイル | 説明 | +| -------------------------------- | ------------------------------------------------------ | +| `CC-Switch-v3.17.0-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ | +| `CC-Switch-v3.17.0-macOS.zip` | 展開して Applications へドラッグ、Universal Binary | +| `CC-Switch-v3.17.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.17.0-Linux-x86_64.AppImage` / `.deb` / `.rpm` +- `CC-Switch-v3.17.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` | diff --git a/docs/release-notes/v3.17.0-zh.md b/docs/release-notes/v3.17.0-zh.md new file mode 100644 index 000000000..585711e1d --- /dev/null +++ b/docs/release-notes/v3.17.0-zh.md @@ -0,0 +1,363 @@ +# CC Switch v3.17.0 + +> 这一版带来一个盼了很久的能力:**「项目」一键切换**——把当前的供应商、MCP、Skills、记忆文件整套保存为命名快照,在标题栏或托盘里一键换成另一套,切换时还会自动把你离开的项目当前状态存回去。Codex 侧同样收获颇丰:**官方 ChatGPT 订阅账号现在也能走本地代理路由**,享受与第三方供应商相同的路由与用量统计;GPT-5.6 全家的上下文窗口与 Sol / Terra / Luna 三档定价一步到位;还新增了原生 Anthropic Messages 上游格式——所在企业禁用了 Claude Code、但没有禁用 Claude API?现在可以**在 Codex 里直接用上 Claude 系列模型**。此外是一大波正确性修复:上游失败不再变成「空回复」、缓存写入不再被双重计费、删掉的 MCP 服务器不再复活、Kimi For Coding 的 256K 窗口终于真正生效。 + +**[English →](v3.17.0-en.md) | [日本語版 →](v3.17.0-ja.md)** + +--- + +## 使用攻略 + +本版的新能力主要落在主页顶部的项目切换器、Codex 供应商表单与用量看板里,建议结合以下文档了解: + +- **[在 Codex 中用 Claude(本地路由攻略)](../guides/codex-claude-routing-guide-zh.md)**:本版新增的分步攻略,配合「原生 Anthropic Messages 上游」功能使用。攻略讲解如何把 Codex 供应商的上游格式选为 `anthropic`,接入任何只提供 `/v1/messages` 的 Claude 系网关,在 Codex 里用上 Claude 系列模型。 +- **[在 Codex 里使用 Kimi(本地路由攻略)](../guides/codex-kimi-routing-guide-zh.md)**:本版新增的分步攻略。较新的 Codex CLI 走 OpenAI Responses 协议,而 Kimi 开放平台与 Kimi For Coding 暴露的是 Chat Completions 端点,直连通常 404;攻略讲解如何用内置的 `Kimi` / `Kimi For Coding` 预设配合本地路由完成协议转换。 +- **[Codex 官方登录保留](../guides/codex-official-auth-preservation-guide-zh.md)**:了解 CC Switch 如何在切换第三方供应商时保留你的官方 ChatGPT 登录。本版在此基础上更进一步——官方账号本身也可以走代理路由(见下方「新功能」)。 +- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:了解用量看板的数据来源与统计口径。本版修正了缓存写入计费、补齐了 Codex 子代理会话统计,并新增 GPT-5.6 与混元 Hy3 定价。 + +--- + +> [!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.17.0 是 v3.16.5 之后的一个功能大版本,核心是**「项目」**:你可以把 Claude Code / Claude Desktop / Codex 当前的供应商、MCP、Skills、记忆文件状态保存为命名快照——比如编程目录一套「开发」、写作绘图目录一套「创作」——在主页顶部的切换器或托盘的「项目」子菜单里一键整套切换——切换前会自动把你正要离开的项目状态存回去,所以项目里保存的永远是你上次离开时的样子。第二条主线是 Codex:**官方 ChatGPT 订阅账号现在也能走本地代理路由接管**(不需要 API Key,Codex 自己的登录凭据原样透传,绝不覆盖你的官方登录);配合修正后的客户端身份,`gpt-5.6-luna` 这类最新订阅模型不再误报 404;GPT-5.6 的 372K 上下文窗口注入、Sol / Terra / Luna 三档定价(含 1.25 倍缓存写入费率)与预设默认模型同步就位;Codex 上游格式还新增了原生 Anthropic Messages 协议——它瞄准一个很现实的场景:不少企业禁用了 Claude Code 客户端、但并没有禁用 Claude API,这些用户现在可以让 Codex 直连 Claude API(或任何只提供 `/v1/messages` 的网关),在 Codex 里照常使用 Claude 系列模型。 + +围绕日常使用的正确性,本版做了三波集中修复。**代理桥**:上游在 2xx 里返回的语义失败不再被转成空回复,而是触发 failover;推理内容、工具结果、system 角色跨 Responses↔Anthropic 桥无损往返;提示缓存断点注入更充分,长对话不再每轮全价重发。**用量计费**:缓存写入 token 此前被同时按输入价和缓存创建价双重计费,现已修正(数据库升级到 schema v13 以保证历史数据口径不乱);用量与配额查询遇到网络瞬时失败会自动重试、不再把失败体当真实数据缓存。**Codex `config.toml`**:在应用里删掉的 MCP 服务器不再随供应商切换复活;live 文件解析失败时同步宁可报错也不再清空整个文件;「使用通用配置」的合并挪到后端执行,注释与键序不再被打乱。另有 Kimi For Coding 256K 窗口真正生效、Codex 子代理与免费版配额统计补齐、智谱团队套餐配额查询、OpenCode 表单增强与一批预设更新。 + +**发布日期**:2026-07-13 + +**更新规模**:69 commits | 172 files changed | +21,067 / -2,464 lines + +--- + +## 重点内容 + +- **「项目」一键切换**:把供应商、MCP、Skills、记忆文件整套保存为命名快照(比如编程一套、写作绘图一套),从主页顶部或托盘一键切换;切换时自动保存离开项目的当前状态。覆盖 Claude Code、Claude Desktop、Codex 三个作用域,互不干扰。 +- **Codex 官方账号也能走代理路由**:ChatGPT 订阅登录的 Codex 会话可通过本地代理路由,获得与第三方供应商一致的路由与用量统计;官方登录凭据绝不被覆盖或存储。 +- **GPT-5.6 全面就位**:Claude Code 走 Codex 接管时自动注入 372K 上下文窗口;Sol / Terra / Luna 三档定价入库(缓存写入按 1.25 倍输入价计费);相关预设默认模型升级到 gpt-5.6 家族;修正客户端身份后 `gpt-5.6-luna` 不再误报 404。 +- **在 Codex 里使用 Claude 系列模型(原生 Anthropic Messages 上游)**:不少企业禁用了 Claude Code 客户端、但没有禁用 Claude API——现在把 Codex 供应商的上游格式选为 `anthropic`,即可直连 Claude API 或任何只提供 `/v1/messages` 的网关,本地代理完成 Responses↔Anthropic 双向转换,自带标准 5 分钟提示缓存注入。 +- **代理桥正确性修复**:上游失败 fail-closed 触发 failover 而非空回复;推理 / 工具结果 / system 角色跨桥无损;缓存写入不再双重计费;断点注入更充分。 +- **Codex config.toml 加固**:删掉的 MCP 服务器不再复活;解析失败时 MCP 同步宁可报错也不清空文件;通用配置合并保留注释与键序。 +- **Kimi For Coding 256K 真正生效**:此前的 262144 压缩窗口从未实际生效(被 Claude Code 的 200K 默认钳回),本版补齐模型别名路由与窗口注入;存量供应商需重新套用预设(见「升级提醒」)。 + +--- + +## 新功能 + +### 「项目」:整套配置的命名快照与一键切换 + +这是本版的头号功能。你可以把当前的供应商、MCP、Skills、记忆文件状态保存为一个命名「项目」,之后在主页顶部的项目切换器或托盘的「项目」子菜单里一键整套切换,不必再逐项手动勾选。 + +举个典型场景:你有一个目录用来编程、另一个目录用来写作或绘图。编程时要的是一套供应商,配上文件系统 / GitHub 这类 MCP、代码审查 Skills 和写着工程约定的记忆文件;写作或绘图时往往换另一家供应商、另一组 MCP 和完全不同的提示词。以前在两件事之间来回,意味着切供应商、逐个开关 MCP 和 Skills、再改记忆文件;现在把两套状态分别存成「开发」和「绘图」两个项目,换目录干活时在 CC Switch 里点一下,整套配置随之就位。 + +项目功能覆盖 Claude Code、Claude Desktop 与 Codex 三个作用域(Claude Desktop 由 CC Switch 管理的维度只有供应商,因此其快照只含供应商、应用时不动其它维度)。 + +几个值得了解的设计: + +- **项目是全局实体、按作用域切换**:同一个项目在 Claude Code / Claude Desktop / Codex 三侧各自记录自己的当前项目与快照槽位,在 Codex 页签切换项目绝不会动到 Claude 的配置。 +- **切换即自动保存**:切换项目前,会先把你正要离开的项目在当前作用域下的状态自动存回去——所以项目里保存的永远是你上次离开它时的样子,不需要(也没有)手动「更新快照」按钮。 +- **应用是尽力而为的**:套用快照复用现有的切换原语(先切供应商,再做 MCP / Skills 的最小差异开关,最后启用记忆文件);快照里引用的某项如果已被删除,只会告警跳过,不会整体回滚。 +- **自动关闭代理接管**:套用项目前会先关闭该作用域内各应用的代理接管,避免快照状态和路由状态打架。 + +不用项目功能的用户可以在「设置 → 主页显示」里关闭「显示项目切换」,只隐藏主页入口,托盘子菜单与项目数据不受影响。底层由新的 `profiles` 表支撑(数据库自动迁移,无需手动操作),四语界面文案同步就位。 + +### Codex 官方 ChatGPT 账号的代理路由接管 + +用 ChatGPT 订阅(OAuth 或 API-key 登录)的 Codex 会话,现在也可以走 CC Switch 的本地代理路由了——官方账号流量获得与第三方供应商一致的路由、格式转换与用量统计。在供应商面板或托盘里选择内置的「OpenAI Official」条目进行接管即可(如果你此前删掉过它,添加供应商时会自动恢复);路由中的卡片徽标显示「官方账号路由中」。 + +实现上刻意做到**零凭据存储**:不向 `auth.json` 写任何占位密钥,而是往 `config.toml` 投影一个指向本地代理的专用 `model_provider`,Codex 把自己的 ChatGPT 授权头原样发给代理、代理原样透传给官方端点——`codex-official` 这一行的凭据永远是空的。官方登录本身绝不被覆盖:接管时 OAuth / API-key 材料会保留进备份;官方端返回的 401 / 403 被视为不可重试错误,failover 绝不会把你的对话悄悄挪到另一个账号上。相应地,「切换时保留 Codex 官方登录」这个设置项的文案已更新——路由接管场景下官方登录总是被保留,该开关现在只管不走路由的第三方直切。 + +### GPT-5.6:上下文窗口、预设默认与三档定价 + +围绕 GPT-5.6 家族做了三件事: + +- **372K 上下文窗口注入**:Claude Code 经代理接管路由到 ChatGPT Codex(Codex OAuth)后端时,自动往生效的 `settings.json` 注入 `CLAUDE_CODE_MAX_CONTEXT_TOKENS` 与 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`(均为 372000),让 Claude Code 不再按默认 200K 窗口过早自动压缩、也不再撑爆上游。注入门控严格:只有当所有已配置的模型键都指向 gpt-5.6 家族时才注入(gpt-5.5 的目录窗口在 272K / 372K 间摇摆,故意不继承);你手动设置的值永远优先;切走时按镜像条件剥离,程序默认永远不会固化进你的供应商配置。 +- **预设默认模型升级**:Claude Code 与 Claude Desktop 的 Codex OAuth 预设默认路由升级到 gpt-5.6 家族(haiku → `gpt-5.6-luna`,主模型 / sonnet / opus → `gpt-5.6`),自定义 Codex `config.toml` 模板的默认模型同步跟进。 +- **Sol / Terra / Luna 三档定价**:用量看板按官方价目为三档入库——Sol 5 / 30 / 0.50、Terra 2.50 / 15 / 0.25、Luna 1 / 6 / 0.10(美元每百万 token,输入 / 输出 / 缓存读)。与 5.5 及更早版本不同,5.6 家族的**提示缓存写入按 1.25 倍输入价计费**(Sol 6.25 / Terra 3.125 / Luna 1.25),已按此入库并自动修复此前按 0 计的存量行;裸 `gpt-5.6` 及各 effort 后缀变体按 Sol 价对齐。 + +### 在 Codex 里使用 Claude 系列模型:原生 Anthropic Messages 上游 + +这个功能来自一个很现实的诉求:**不少企业出于合规策略禁用了 Claude Code 客户端,但并没有禁用 Claude API**。对这些用户来说,模型本身是可用的,缺的只是一个被允许的客户端——现在 Codex 可以补上这个位置。在 Codex 供应商的上游格式选择器里选新增的 `anthropic`,即可直连 Claude API 或任何只提供原生 Anthropic Messages 协议(`/v1/messages`)的网关,本地代理完成 Responses↔Anthropic 的请求、响应与流式双向转换,你在 Codex 里照常对话、照常用工具,背后跑的是 Claude 系列模型。表单配套提供:认证字段选择器(`ANTHROPIC_AUTH_TOKEN` 发 `Authorization: Bearer`,默认;或 `ANTHROPIC_API_KEY` 发 `x-api-key`)、可选的 Claude Code 客户端伪装开关(默认关闭)、以及按供应商的最大输出 token 覆盖(Codex 不发 `model_max_output_tokens`,不设置时回退到保守的 8192,可能截断长回复或重思考回复)。转换桥自动注入标准 5 分钟提示缓存标记(系统提示、工具与历史走缓存而非每轮全价重发),支持 `[1m]` 长上下文标记并补发对应 beta 头,截断的流会如实上报为未完成而不是伪装成功。([#5071](https://github.com/farion1231/cc-switch/pull/5071)) + +### Codex 供应商表单新增「默认模型」输入框 + +`config.toml` 顶层的 `model` 键现在是表单里的一个可编辑字段:新模型(如 `gpt-5.6`)发布后,你可以直接把现有供应商指过去,不必等预设更新(预设只影响新添加的供应商)。字段与 TOML 编辑器双向同步,候选列表来自模型映射目录与供应商 `/models` 端点的并集,值不在目录里时提供一键「加入映射」。显式填写的值永远优先于映射第一行的隐式回填;模型名与 `base_url` 写入时做了 TOML 转义,杜绝 `/models` 返回的远端数据注入伪造配置行的可能。 + +### 通用配置切换自动同步扩展到 Codex + +v3.16.5 给 Claude 加的「切走时自动把 live 配置里的共享偏好回写到通用配置」现在覆盖 Codex 了:切走一个启用了通用配置的 Codex 供应商时,会先从它的 live `config.toml` 重新提取可共享部分更新到通用配置,再带给下一个供应商——你直接在运行中的 Codex 配置里改的偏好不再在切换时丢失,删掉的键也不会被悄悄注回。提取器会严格剥离供应商专属与注入内容(`model` / `model_provider` / `base_url` / `wire_api`、整个 `[model_providers]` 表、MCP 投影、API key 兜底字段、模型目录指针与注入的 `web_search` 哨兵),密钥永远不会进入共享片段。所有失败仅告警、绝不阻断切换。 + +### Claude 子代理模型配置 + +Claude 供应商表单新增「子代理」模型行,写入 `CLAUDE_CODE_SUBAGENT_MODEL`,让 Claude Code 派生的子代理跑在你指定的(通常更便宜或更快的)模型上。支持 `[1M]` 标记;由于子代理模型不会出现在 `/model` 菜单里,该行显示「不在 /model 中展示」占位而没有显示名字段。代理接管路径与模型映射器已同步支持:请求模型与配置的子代理模型一致时原样放行,不再被折叠到默认模型;该键也被排除在共享通用配置之外,不会跨供应商泄漏。([#4830](https://github.com/farion1231/cc-switch/pull/4830)) + +### 回退模型字段的 1M 上下文复选框 + +Claude 表单的回退模型字段(`ANTHROPIC_MODEL`)现在带上了 Sonnet / Opus / Fable 各档早已有的 1M 复选框:回退模型背后是 1M 窗口时可以如实声明,不再被静默当作 200K。勾选即在模型 id 后追加 `[1M]` 标记,取消即剥离。([#5124](https://github.com/farion1231/cc-switch/pull/5124),修复 #3679) + +### 智谱团队套餐配额查询 + +智谱的团队套餐(团队版 Coding Plan)走同一个配额端点但需要 `?type=2` 与两个额外请求头(`bigmodel-organization` / `bigmodel-project`),个人版查询够不到。用量脚本弹窗新增「Zhipu GLM Team(智谱团队)」模板,填入 API Key + 组织 ID + 项目 ID 即可查询团队配额;三项缺一会明确提示补全。四语文案同步。([#5128](https://github.com/farion1231/cc-switch/pull/5128)) + +### OpenCode 表单:请求头与模型 Token 上限编辑器 + +OpenCode 供应商表单补上了两块此前只能手改 JSON 的配置:**Headers 编辑器**(供应商级 `options.headers`,如 OpenRouter 排行榜要求的 `HTTP-Referer` / `X-Title`,支持增删行、大小写不敏感去重)与**按模型 Token 上限**(`model.limit.context` / `model.limit.output` 数字输入,清空即移除)。「额外选项」块改为可折叠区,已有内容时自动展开;顺带修复了旧占位符过滤会误删真实以 `option-` 开头的选项键的问题。([#2907](https://github.com/farion1231/cc-switch/pull/2907)) + +### 新增模型定价:腾讯混元 Hy3 + +为 2026-07-06 发布的腾讯混元 Hy3(256K 上下文)入库定价(按发布日牌价 CNY 1 / 4 / 0.25 每百万 token 折算),`hunyuan-hy3` 与 `hy3` 两个 id 都能命中,其用量不再显示 $0。注意 Hy3 实际是按输入长度分档计费,当前单价表按最低档入库,长上下文请求会低估成本,待官方计费页明确后再修正。 + +--- + +## 变更 + +### Codex Chat 路由注入 prompt_cache_key,提升缓存命中 + +Codex 经本地路由转换到 Chat Completions 上游时,现在会按供应商感知地注入 `prompt_cache_key`:Kimi Coding 与 OpenAI 官方端点自动启用、Kimi 预设显式开启,未知的 OpenAI 兼容网关保持关闭以避免严格 schema 网关报 400。键值只取显式客户端值或真实的客户端会话 ID,绝不生成随机 UUID(那会让每个请求落到不同缓存桶、适得其反)。高级选项里提供自动 / 启用 / 禁用三态覆盖。 + +### Codex 图片能力自动推断,去掉手动开关 + +生成的 Codex 模型目录现在只把 CC Switch 确认过的**精确文本-only 名录**内的模型声明为 `input_modalities = ["text"]`;GPT、别名、新后缀变体和一切未知模型一律 fail-open 到 `["text", "image"]`——修复了 GPT 系模型在 Codex IDE 扩展里被误报「不支持图片」的问题。整流器的「纯文本模型预检」开关继续只管代理侧的主动请求改写,不影响目录声明;目录反向导入也会把可推断的能力坍缩掉,未来名录修正或模型升级多模态时自动生效。 + +### 上下文窗口参数钉进预设,不再作为表单字段 + +`Codex`(ChatGPT / GPT-5.6)与 `Kimi For Coding` 预设不再在表单里展示「最大上下文 Tokens」「自动压缩窗口」两个输入框,数值直接钉死在预设 env 里(Codex 372000 / 372000,Kimi For Coding 262144 / 262144)——绝大多数用户从不需要碰这两个数字。两个键刻意保留在 env 里:显式钉住能让本地压缩触发点免疫远端实验性配置的下调。极少数想改数字的用户仍可在供应商的 JSON 编辑器里直接编辑这两个键。 + +### 供应商连通性配置简化 + +移除了过时的按供应商 `testConfig` 覆盖(超时、重试次数、降级延迟阈值):轻量的 `base_url` 探测现在始终使用全局连通性检查配置,自动 failover 仍完全由代理超时与熔断器的独立设置驱动。设置界面与接口命名也从「模型测试」术语统一迁移到「连通性检查」。 + +### 通用(多应用)供应商添加后自动同步 + +通过「添加供应商」弹窗添加通用(多应用)供应商后,现在会立即推送到各 live 目标配置,不再需要手动再点一次同步。同步失败不阻塞添加——供应商已保存但同步失败时给出非阻断的警告提示。([#2811](https://github.com/farion1231/cc-switch/pull/2811)) + +### 预设更新 + +- **LongCat-2.0**:美团 LongCat 预设全线(Claude Code / Claude Desktop / Codex / Hermes / OpenClaw / OpenCode)从已退役的 `LongCat-Flash-Chat` / `LongCat-2.0-Preview` 升级到 `LongCat-2.0`,声明真实的 1M(1048576)上下文窗口。LongCat-2.0 是纯文本模型,代理的媒体清洗白名单已同步收录——粘贴进会话的图片会被替换为不支持标记而不是被上游硬拒。([#4838](https://github.com/farion1231/cc-switch/pull/4838)) +- **SudoCode**:原 `sudocode.us` 预设原位替换为 `sudocode.chat` 的新赞助商 SudoCode,覆盖六个客户端(Claude 系直连 Anthropic 透传,Codex / OpenCode / OpenClaw / Hermes 默认 `gpt-5.6-sol`)。 +- **火山 / 豆包 / BytePlus 官网链接**:撤销了 v3.16.5 把这三个预设 `websiteUrl` 改为产品主页的改动,恢复为带归因参数的活动 / 邀请链接(这是有意为之的设计)。 +- **Code0.ai**:邀请链接更新为新的 agent 注册链接;API 端点不变。 +- **删除重复的 OpenAI Compatible 预设**:OpenCode 与 OpenClaw 预设列表里的 `OpenAI Compatible` 自定义模板条目被移除——内置的 `custom` 供应商流程本就提供相同的起点,选择器里不再出现两个指向同一处的入口。存量供应商不受影响。 + +--- + +## 修复 + +### Codex OAuth 客户端身份对齐:修复最新 ChatGPT 模型 404 + +用官方 Codex OAuth 账号经本地代理接管路由时,最新的订阅模型(如 `gpt-5.6-luna`)此前会返回误导性的 `404 Model not found`——明明账号有权限。根因是 ChatGPT 的 Codex 后端按 `originator` + `version` 头做模型分组路由,而 cc-switch 此前自报 `originator: cc-switch` 且不带版本号,被路由到一个 luna 尚未部署的分组。现在接管请求发送与真实 Codex CLI 一致的 `originator: codex_cli_rs` + `version: 0.144.1`,满足 luna 的最低客户端版本要求,经真实后端 A/B 实测确认修复。 + +### Responses 上游失败不再变成空回复 + +代理把 Anthropic 格式客户端(Claude Code / Claude Desktop)桥接到 OpenAI Responses 上游时,上游藏在 HTTP 2xx 体里的语义失败(`status:"failed"` 对象、`error` 信封、首个输出前的 `response.failed` SSE 事件)此前会被转换成一个悄无声息的空回合。现在这些失败在重试循环内就被识别为真实错误,failover 能够换一个供应商重试;干净结束但内容不完整的流会如实标记为截断而非完成;无视 `stream:true` 直接返回整个 JSON 文档的网关也能被识别并展开为完整的流式生命周期;客户端历史本身格式错误时立即报错,不再拿着必败的请求把每个供应商都重试一遍。 + +### 跨 Responses/Anthropic 桥保留推理、工具结果与 system 角色 + +多轮工具循环里跨 Responses↔Anthropic 桥的内容不再丢失或损坏:加密的推理(reasoning)条目无损往返(往返失败会导致下一轮请求被上游拒绝的问题同步消除);流式转换器支持官方的推理事件词汇表并在网关跳过增量时从终结事件恢复工具参数;结构化工具结果的 `is_error` 标志、图片与 PDF 文档在两个方向都完整保留,不再被压平成一个 JSON 字符串;历史里的 `system` / `developer` 消息被正确提升为 Anthropic `system`,不再被静默降级成用户发言。计费上,上游请求成功但后续转换失败时用量照记,不再漏账。 + +### 缓存写入 token 不再双重计费 + +Codex / Gemini 类供应商上报的 `input_tokens` 同时包含缓存读与缓存写,而成本计算此前只减掉了缓存读——缓存写入 token 被按输入价和缓存创建价**计了两次费**。现在两者都会先行扣除,并且缓存写入数字在跨格式转换(Chat↔Responses↔Anthropic)时不再丢失。为了让历史数据口径不乱,数据库新增一列记录每行 `input_tokens` 的存储语义(schema v12→v13 自动迁移):旧行按旧口径回算、新行按新口径,Claude 类行不受影响。 + +### 更强的提示缓存断点注入 + +在注入 Anthropic `cache_control` 断点的代理路径上(Codex 接管桥与 Bedrock 原生优化器),注入器现在会更充分地使用四个断点预算:除了工具尾、系统尾与最新可缓存消息外,预算有余时再给较早的用户消息加一个锚点,让稳定前缀保持在 Anthropic 20 块回看窗口内——长的、工具密集的对话能持续命中提示缓存,而不是每轮把系统提示、工具与历史全价重发。调用方自带的断点被原样保留(绝不删除、重排或改写);注入的标记一律使用标准 5 分钟 TTL。 + +### Kimi For Coding 的 256K 上下文窗口真正生效 + +Kimi For Coding 预设在 3.16.4 加的 `CLAUDE_CODE_AUTO_COMPACT_WINDOW=262144` 其实**从未生效**:Claude Code 对不认识的模型 id 按 200K 窗口封顶,且压缩窗口取 `min(模型窗口, 设定值)`,262144 被钳回 200K。本版补齐了缺失的两环——预设同时钉上 `CLAUDE_CODE_MAX_CONTEXT_TOKENS`,并把各档模型显式路由到端点的 `kimi-for-coding` 别名(`claude-` 前缀 id 会让 Claude Code 无视这两个窗口参数,非 Claude 别名才是解锁大窗口的关键)。已保存的供应商在切换时也会自动注入这两个窗口默认值,但**别名路由只存在于预设里**——旧预设存下来的供应商实际仍是 200K,需要重新套用一次预设(见「升级提醒」)。 + +### 删除的 Codex MCP 服务器不再复活 + +MCP 服务器的权威数据在数据库里,Codex live `config.toml` 中的 `[mcp_servers]` 只是每次写入后重新同步的投影——但切走供应商时这份投影会被固化进供应商快照,导致你在应用里删掉的服务器在下次激活该供应商时死而复生,且逐条对账永远清不掉这个孤儿。现在切走时会把 `[mcp_servers]`(含旧式 `[mcp.servers]`)从存储快照中剥离,已被污染的快照在下次切走时自愈。一个可见的副作用:手写在 Codex 供应商配置里的 `[mcp_servers.*]` 段会在首次切走时被剥出快照——今后请通过 MCP 管理器定义 Codex 的 MCP 服务器(见「升级提醒」)。 + +### MCP 同步更健壮:解析失败不清空文件、按应用报错 + +两处修复。其一,向 Codex 写入单个 MCP 服务器时,如果现有 `config.toml` 解析失败,旧逻辑会退到空文档再整体写回——**整个文件被清空**、只剩那一个 MCP 条目;现在直接返回校验错误并保持文件原样。其二,「从应用导入」此前把每个导入器的错误吞成 0,坏掉的 Codex 配置只会显示「导入了 0 个服务器」;现在逐应用尽力导入、失败时报出具体是哪个应用出了问题。切换与保存时的投影也改为只针对目标应用,一个应用的 live 文件解析失败不再连坐阻塞其它应用、也不再把已经成功的切换误报为失败。 + +### Codex 通用配置合并保留注释与键序 + +Codex 供应商表单里勾选 / 取消「应用通用配置」此前走前端 TOML 实现整篇重排(解析 → 合并 → 序列化):注释被丢弃、键被重排、还会凭空多出 `[model_providers]` 这类空表头——就是「config.toml 老被重排」的元凶。现在合并走后端命令、与写 live 配置共用同一套合并语义,手写格式在编辑期合并中完整幸存;针对异步化引入的快速切换竞态也加了双重守卫(操作序号 + 配置基线核对),先发后至的旧结果不会覆盖新状态。 + +### 受管 Claude 接管只注入单个 auth 占位符 + +从第三方端点切到 Codex 受管供应商时,`~/.claude/settings.json` 里会同时写入 `ANTHROPIC_API_KEY` 和 `ANTHROPIC_AUTH_TOKEN` 两个占位符,导致 Claude Code 每次启动都警告「Both ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY set」。现在只注入一个:Codex 受管走 `ANTHROPIC_AUTH_TOKEN`、Copilot 走 `ANTHROPIC_API_KEY`,其余 token 键一律清除。注意:升级后如果 live 配置已带着双键,由于「配置未变则跳过重写」的短路逻辑,警告可能仍在——把 Claude 路由开关关再开一次(或切换一次供应商)即可触发重写(见「升级提醒」)。([#5095](https://github.com/farion1231/cc-switch/pull/5095),修复 #4919) + +### 用量与配额查询:瞬时失败可自动重试、不再毒化缓存 + +用量与配额查询频繁出现手动刷新也清不掉的「查询失败」,根因是所有传输层失败(包括读响应体中途超时)都被折叠成了「成功但结果为失败」——前端的自动重试从不触发,失败体还被当作真实数据缓存。现在传输失败如实返回错误:react-query 自动重试生效,HTTP 429 与 5xx 一样按瞬时失败处理,保留的上次成功数据按 10 分钟窗口正常过期,失败状态下页脚保留重试入口与真实错误信息。(修复 [#3820](https://github.com/farion1231/cc-switch/issues/3820)) + +### Codex 子代理会话用量计入本地统计 + +Codex 子代理(spawned agent)会话的 token 用量此前完全没进本地统计:子代理日志里携带的是父线程的 `session_id`,多个子代理的记录互相碰撞、被当作重复丢弃。现在解析器按每个文件自己的 `thread_id` 建立唯一身份,并识别子代理日志开头对父线程历史的重放、只用它恢复累计基线而不重复计费;归档日志也按文件名继承同步游标,重新解析只导入新增部分。([#5187](https://github.com/farion1231/cc-switch/pull/5187)) + +### Codex 免费版 30 天配额窗口正常显示 + +Codex 免费账号按 30 天滚动窗口计量(而非付费版的周窗口),但前端白名单和托盘分组都不认识 `30_day` 这个档位——免费账号唯一的档位被过滤掉后,配额页脚整个空白、托盘也不显示任何配额。现在 30 天档位在页脚和托盘都正常渲染,四语标签同步。([#4886](https://github.com/farion1231/cc-switch/pull/4886),修复 #3651) + +### 用量看板刷新间隔持久化 + +用量看板的自动刷新间隔此前是组件内状态,每次重启都重置回 30 秒。现在通过新的应用设置持久化,改动乐观生效、保存失败自动回滚。([#5057](https://github.com/farion1231/cc-switch/pull/5057)) + +### Fable 档模型键不再泄漏进通用配置 + +Fable 是 v3.16.3 加入的第四个 Claude 模型映射档,但它的 `ANTHROPIC_DEFAULT_FABLE_MODEL(_NAME)` 两个键漏在了供应商专属排除名单之外——某个供应商的 Fable 模型钉选可能泄漏进共享通用配置、再被注入到其它供应商。现已与 haiku / sonnet / opus 三档一样剥离,并顺带补全了 Fable 档的代理接管支持(接管时写入稳定的角色别名、切走时清理陈旧值)。([#5206](https://github.com/farion1231/cc-switch/pull/5206),修复 #4272) + +### 工具 schema 缺省 type 兜底与无分类供应商的 API Key 输入框 + +两个供应商侧修复:客户端发来的工具如果 `input_schema` 缺顶层 `type`(或干脆是空 `{}`),代理转换后会被严格网关拒绝,现在根 schema 自动补 `type: "object"`(只补根、不动嵌套子 schema);历史导入或手工构建的无分类供应商在编辑时看不到 Claude API Key 输入框的问题也已修复——现在只要不是官方 / 云厂商类供应商就显示该字段。([#5069](https://github.com/farion1231/cc-switch/pull/5069)) + +### GLM 5.2 纯文本模型的图片请求兜底 + +本地代理接管火山 Coding Plan 跑 GLM 5.2 时,请求里的图片块不再产生一个无法恢复的 400:文本-only 名录精确收录 `glm-5.2`(刻意不用前缀匹配,未来的多模态 `glm-5.2v` 不受牵连),预防路径在请求到达前剥离图片;网关那句不含 image 字样的报错(`Model only support text input`)也被反应路径的自证短语名录识别,触发媒体兜底。(修复 [#5025](https://github.com/farion1231/cc-switch/issues/5025)) + +### 会话与 live 配置同步小修一组 + +- **显示重命名的 Codex 会话标题**:在 Codex 里重命名过的会话,会话管理器现在显示新标题而不是回退到首条消息文本;并发写入时的读取也不再立即失败。([#4927](https://github.com/farion1231/cc-switch/pull/4927)) +- **OpenCode / OpenClaw / Hermes 的 live 编辑在启动时同步入库**:直接改 live 配置文件(换 base URL、加模型)此前在首次导入后就再也不会被拾取;现在每次启动时对比 live 与库存,差异即更新,全程非致命。([#4712](https://github.com/farion1231/cc-switch/pull/4712)、[#5098](https://github.com/farion1231/cc-switch/pull/5098)) +- **OpenCode 会话恢复命令更新**:会话管理器展示与复制的恢复命令从过时的 `opencode session resume ` 更正为当前 CLI 的 `opencode -s `。([#2359](https://github.com/farion1231/cc-switch/pull/2359)) +- **官方供应商跳过连通性探测**:连通性检查不再对官方类供应商推导出一个无凭据必失败的第一方端点探测(例如裸打 `chatgpt.com/backend-api/codex`),批量检查直接跳过、单独解析明确报错。 + +--- + +## 文档 + +### Codex + Kimi 本地路由攻略 + +新增分步攻略(中 / 英 / 日三语,含界面截图),讲解如何借助 CC Switch 的本地路由在 Codex CLI 里使用 Kimi:较新的 Codex CLI 走 OpenAI Responses 协议,而 Kimi 开放平台(按量付费,`kimi-k2.7-code`)与 Kimi For Coding(会员制,`kimi-for-coding`)暴露的都是 Chat Completions 端点,直连通常在 `/responses` 上 404。攻略覆盖从内置预设添加供应商到四步协议转换链的完整流程。 + +### README 赞助商更新 + +开源 AI 基建项目 `new-api` 加入四语 README 的赞助商表。 + +--- + +## 升级提醒 + +### Kimi For Coding 供应商需重新套用预设 + +如果你在用 Kimi For Coding 预设创建的供应商,请**重新从预设选择一次并保存**:256K 窗口的关键——把各档模型路由到 `kimi-for-coding` 别名——只存在于新版预设里,旧预设存下来的供应商即使升级后实际仍按 200K 窗口过早压缩。 + +### 手写的 Codex `[mcp_servers.*]` 会被剥出快照 + +为了根治「删掉的 MCP 服务器复活」,切走 Codex 供应商时会把 `[mcp_servers]` 段从存储快照中剥离。如果你有直接手写在某个 Codex 供应商配置里的 MCP 服务器,它会在首次切走该供应商时从快照消失——请改用 MCP 管理器(MCP 页签)定义 Codex 的 MCP 服务器,那里的条目才是权威数据、会被自动投影到 live 配置。 + +### 双 auth 键警告可能需要手动触发一次重写 + +如果升级后 Claude Code 仍提示「Both ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY set」,这是因为 live 配置未变时接管逻辑会短路跳过重写。把 Claude 的路由开关关掉再打开一次(或切换一次供应商)即可写入修正后的单占位符配置,警告随之消失。 + +### 数据库自动迁移 + +首次启动 v3.17.0 时数据库会自动从 schema v11 迁移到 v13(新增项目表与用量语义列),无需任何手动操作。如果你有回退到旧版本的习惯,建议先备份 `~/.cc-switch/cc-switch.db`。 + +--- + +## 风险提示 + +本版本继续沿用此前版本对反向代理类功能的风险提示。 + +**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#️-风险提示)。本版新增的「官方 ChatGPT 账号代理路由接管」同样属于此类用法,请知悉相同的风险。 + +**Codex 第三方供应商 Chat 路由**:通过 CC Switch 本地代理把 Codex 请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。 + +**Claude Desktop 第三方供应商代理切换**:通过 CC Switch 内置代理网关把 Claude Desktop 的请求转到第三方供应商时,同样需要遵守目标供应商的计费、合规与数据留存约束。 + +用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。 + +--- + +## 致谢 + +感谢以下贡献者在 v3.17.0 中提交的功能与修复: + +- [#5071](https://github.com/farion1231/cc-switch/pull/5071):新增原生 Anthropic Messages 协议作为 Codex 上游,感谢 @yeeyzy。 +- [#4830](https://github.com/farion1231/cc-switch/pull/4830):新增 Claude 子代理模型配置,感谢 @AkimioJR。 +- [#5124](https://github.com/farion1231/cc-switch/pull/5124):给回退模型字段加上 1M 复选框,感谢 @salarkhannn。 +- [#5128](https://github.com/farion1231/cc-switch/pull/5128):新增智谱团队套餐配额查询,感谢 @zhanxin-xu。 +- [#2907](https://github.com/farion1231/cc-switch/pull/2907):OpenCode 表单新增请求头与 Token 上限编辑器,感谢 @git1677967754。 +- [#2811](https://github.com/farion1231/cc-switch/pull/2811):通用供应商添加后自动同步,感谢 @hubutui。 +- [#4838](https://github.com/farion1231/cc-switch/pull/4838):LongCat 预设升级到 LongCat-2.0,感谢 @solthx。 +- [#5095](https://github.com/farion1231/cc-switch/pull/5095):受管 Claude 接管只注入单个 auth 占位符,感谢 @fengshao1227。 +- [#5187](https://github.com/farion1231/cc-switch/pull/5187):Codex 子代理会话用量计入统计,感谢 @starmiaoa。 +- [#4886](https://github.com/farion1231/cc-switch/pull/4886):修复 Codex 免费版 30 天配额窗口不显示,感谢 @SaladDay。 +- [#5057](https://github.com/farion1231/cc-switch/pull/5057)、[#4927](https://github.com/farion1231/cc-switch/pull/4927)、[#2359](https://github.com/farion1231/cc-switch/pull/2359):刷新间隔持久化、重命名会话标题显示与 OpenCode 恢复命令修正,感谢 @makoMakoGo。 +- [#5206](https://github.com/farion1231/cc-switch/pull/5206):Fable 模型键排除出通用配置,感谢 @fzh365。 +- [#5069](https://github.com/farion1231/cc-switch/pull/5069):工具 schema 缺省 type 兜底与 API Key 输入框恢复,感谢 @Komikawayi。 +- [#4712](https://github.com/farion1231/cc-switch/pull/4712)、[#5098](https://github.com/farion1231/cc-switch/pull/5098):OpenCode / OpenClaw / Hermes live 配置启动同步,感谢 @allenxu09。 + +也感谢所有反馈 Codex 官方路由、缓存计费、MCP 同步与配额查询问题的用户——本版相当一部分修复来自这些真实使用场景里的复现线索。 + +--- + +## 下载与安装 + +访问 [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.17.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 | +| `CC-Switch-v3.17.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 | + +Windows ARM64 设备请选择文件名中带 `arm64` 标识的对应制品。 + +### macOS + +| 文件 | 说明 | +| -------------------------------- | --------------------------------------------- | +| `CC-Switch-v3.17.0-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 | +| `CC-Switch-v3.17.0-macOS.zip` | 解压后拖入 Applications,Universal Binary | +| `CC-Switch-v3.17.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.17.0-Linux-x86_64.AppImage` / `.deb` / `.rpm` +- `CC-Switch-v3.17.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` | diff --git a/docs/user-manual/en/5-faq/5.2-questions.md b/docs/user-manual/en/5-faq/5.2-questions.md index 854fd7751..7a4ba4885 100644 --- a/docs/user-manual/en/5-faq/5.2-questions.md +++ b/docs/user-manual/en/5-faq/5.2-questions.md @@ -29,6 +29,22 @@ chmod +x CC-Switch-*.AppImage ./CC-Switch-*.AppImage --no-sandbox ``` +### Linux: Clicks Don't Register / Black Screen on Resize (Wayland + NVIDIA) + +**Problem**: The web content area is completely unclickable (the title-bar minimize/maximize/close buttons still work), and the window black-screens on resize or maximize-restore. Common on Wayland sessions with an NVIDIA GPU. + +**Cause**: The AppImage's GTK launch hook unconditionally forces `GDK_BACKEND=x11` (XWayland) to dodge a historical native-Wayland crash. On newer Wayland + NVIDIA setups, forced XWayland leaves the WebKitGTK web content unable to receive pointer events. The existing `WEBKIT_DISABLE_*` mitigations don't help here because the root cause is the forced window backend, not rendering. + +**Solution**: Use the dedicated `CC_SWITCH_GDK_BACKEND` environment variable to switch back to native Wayland (it is read before GTK init, and the hook never overrides it): + +```bash +CC_SWITCH_GDK_BACKEND=wayland ./CC-Switch-*.AppImage +``` + +- When launching from a desktop icon, add it to the `.desktop` `Exec=` line (e.g. `env CC_SWITCH_GDK_BACKEND=wayland /path/to/AppImage`) or set it in your session environment — otherwise an icon launch won't see the variable. +- The variable is generic: on tiling Wayland compositors (sway/Hyprland) where clicks don't register, set `CC_SWITCH_GDK_BACKEND=x11` instead. +- Leaving it unset behaves exactly as before (still x11), with no side effects. + ## Provider Issues ### Provider Switch Doesn't Take Effect diff --git a/docs/user-manual/ja/5-faq/5.2-questions.md b/docs/user-manual/ja/5-faq/5.2-questions.md index 92823792c..73fefd001 100644 --- a/docs/user-manual/ja/5-faq/5.2-questions.md +++ b/docs/user-manual/ja/5-faq/5.2-questions.md @@ -29,6 +29,22 @@ chmod +x CC-Switch-*.AppImage ./CC-Switch-*.AppImage --no-sandbox ``` +### Linux:クリックが効かない / リサイズで黒画面(Wayland + NVIDIA) + +**問題**:Web コンテンツ領域がまったくクリックできません(タイトルバーの最小化/最大化/閉じるボタンは動作します)。ウィンドウのリサイズや最大化-復元後に黒画面になります。Wayland セッション + NVIDIA GPU でよく発生します。 + +**原因**:AppImage の GTK 起動フックが、過去のネイティブ Wayland クラッシュを避けるために `GDK_BACKEND=x11`(XWayland)を無条件で強制します。新しい Wayland + NVIDIA 環境では、強制された XWayland によって WebKitGTK の Web コンテンツがポインタイベントを受け取れなくなります。既存の `WEBKIT_DISABLE_*` の緩和策は、根本原因が強制されたウィンドウバックエンドであり描画ではないため、ここでは効きません。 + +**解決方法**:専用の環境変数 `CC_SWITCH_GDK_BACKEND` でネイティブ Wayland に戻します(GTK 初期化前に読み取られ、フックが上書きしません): + +```bash +CC_SWITCH_GDK_BACKEND=wayland ./CC-Switch-*.AppImage +``` + +- デスクトップアイコンから起動する場合は、`.desktop` の `Exec=` 行に追記するか(例:`env CC_SWITCH_GDK_BACKEND=wayland /path/to/AppImage`)、セッション環境で設定してください。そうしないとアイコン起動では変数が読み取られません。 +- この変数は汎用です:タイル型 Wayland コンポジタ(sway/Hyprland)でクリックが効かない場合は、`CC_SWITCH_GDK_BACKEND=x11` を設定してください。 +- 未設定の場合は現状とまったく同じ動作(x11 のまま)で、副作用はありません。 + ## プロバイダーに関する問題 ### プロバイダーを切り替えても反映されない diff --git a/docs/user-manual/zh/5-faq/5.2-questions.md b/docs/user-manual/zh/5-faq/5.2-questions.md index 1d55372a2..a81aca855 100644 --- a/docs/user-manual/zh/5-faq/5.2-questions.md +++ b/docs/user-manual/zh/5-faq/5.2-questions.md @@ -29,6 +29,22 @@ chmod +x CC-Switch-*.AppImage ./CC-Switch-*.AppImage --no-sandbox ``` +### Linux 点击无响应 / 缩放后黑屏(Wayland + NVIDIA) + +**问题**:主界面网页内容区完全点不动(标题栏的最小化/最大化/关闭仍可点),窗口缩放或最大化-还原后黑屏。常见于 Wayland 会话 + NVIDIA 显卡。 + +**原因**:AppImage 的 GTK 启动钩子会无条件强制 `GDK_BACKEND=x11`(走 XWayland)以规避历史上的原生 Wayland 崩溃;但在较新的 Wayland + NVIDIA 环境下,强制 XWayland 反而使 WebKitGTK 的网页内容收不到指针事件。现有的 `WEBKIT_DISABLE_*` 缓解措施对此无效,因为根因是被强制的窗口后端,而非渲染。 + +**解决方法**:用专用环境变量 `CC_SWITCH_GDK_BACKEND` 切回原生 Wayland(该开关在 GTK 初始化前生效,钩子不会覆盖它): + +```bash +CC_SWITCH_GDK_BACKEND=wayland ./CC-Switch-*.AppImage +``` + +- 从桌面图标启动时,把它写进 `.desktop` 的 `Exec=` 行(如 `env CC_SWITCH_GDK_BACKEND=wayland /path/to/AppImage`),或在会话环境中设置,否则图标启动读不到该变量。 +- 该变量是通用的:在 tiling Wayland 合成器(sway/Hyprland)下若反而出现点击失效,可设 `CC_SWITCH_GDK_BACKEND=x11`。 +- 不设置时行为与现状完全一致(仍走 x11),无副作用。 + ## 供应商问题 ### 切换供应商后不生效 diff --git a/package.json b/package.json index bfd6c33f0..31d6964a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-switch", - "version": "3.16.4", + "version": "3.17.0", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "type": "module", "scripts": { diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fab9eb3b7..435b40ef9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,7 @@ packages: [] onlyBuiltDependencies: - '@tailwindcss/oxide' + - esbuild + +ignoredBuiltDependencies: + - msw diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 502e5d769..9d3f77a00 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -758,7 +758,7 @@ dependencies = [ [[package]] name = "cc-switch" -version = "3.16.4" +version = "3.17.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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d1c05ce1a..91599b0ea 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cc-switch" -version = "3.16.4" +version = "3.17.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" diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 2af5333f6..74eac3f84 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -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() { diff --git a/src-tauri/src/claude_desktop_config.rs b/src-tauri/src/claude_desktop_config.rs index 8b3de69f2..9e3edf8b4 100644 --- a/src-tauri/src/claude_desktop_config.rs +++ b/src-tauri/src/claude_desktop_config.rs @@ -46,7 +46,7 @@ pub struct ClaudeDesktopDefaultRoute { pub const DEFAULT_PROXY_ROUTES: &[ClaudeDesktopDefaultRoute] = &[ ClaudeDesktopDefaultRoute { - route_id: "claude-sonnet-4-6", + route_id: "claude-sonnet-5", env_key: "ANTHROPIC_DEFAULT_SONNET_MODEL", supports_1m: true, }, @@ -1966,9 +1966,9 @@ mod tests { }, ), ( - "claude-sonnet-4-6".to_string(), + "claude-sonnet-5".to_string(), ClaudeDesktopModelRoute { - model: "claude-sonnet-4-6".to_string(), + model: "claude-sonnet-5".to_string(), label_override: None, supports_1m: Some(false), }, diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 7369ab651..f53d76be4 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -6,6 +6,7 @@ use crate::config::{ write_json_file, write_text_file, }; use crate::error::AppError; +use crate::model_capabilities::{image_input_capability_from_modalities, ImageInputCapability}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -14,17 +15,23 @@ use std::process::Command; use toml_edit::DocumentMut; pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "custom"; +/// Temporary model-provider id used while the built-in `codex-official` +/// provider is routed through CC Switch. A dedicated id is an ownership +/// marker: unlike a generic localhost `base_url`, it can be detected and +/// cleaned up without mistaking a user's own local provider for takeover. +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"; /// Top-level `config.toml` key that controls Codex's built-in web-search tool. -const CODEX_WEB_SEARCH_FIELD: &str = "web_search"; +pub(crate) const CODEX_WEB_SEARCH_FIELD: &str = "web_search"; /// Value that disables the web-search tool. Some native `/responses` gateways /// reject a `web_search` tool with `responses_feature_not_supported` ("tool type /// 'web_search' is not supported by this gateway phase"), so for those we write /// this per the vendors' official Codex docs. Also doubles as cc-switch's /// ownership sentinel: we only ever remove a `web_search` key whose value equals /// this string, never a user's own setting. -const CODEX_WEB_SEARCH_DISABLED: &str = "disabled"; +pub(crate) const CODEX_WEB_SEARCH_DISABLED: &str = "disabled"; /// Native `/responses` gateways whose first-party models do NOT support the Codex /// `web_search` hosted tool. A BLACKLIST (default-on): everything not listed keeps @@ -105,6 +112,166 @@ struct CodexManagedOAuthLiveAuthMarker { access_token_sha256: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct CodexLiveFileState { + path: PathBuf, + contents: Option>, + #[cfg(unix)] + mode: Option, +} + +impl CodexLiveFileState { + fn capture(path: PathBuf) -> Result { + if !path.exists() { + return Ok(Self { + path, + contents: None, + #[cfg(unix)] + mode: None, + }); + } + + let contents = fs::read(&path).map_err(|error| AppError::io(&path, error))?; + #[cfg(unix)] + let mode = { + use std::os::unix::fs::PermissionsExt; + Some( + fs::metadata(&path) + .map_err(|error| AppError::io(&path, error))? + .permissions() + .mode(), + ) + }; + + Ok(Self { + path, + contents: Some(contents), + #[cfg(unix)] + mode, + }) + } + + fn restore(&self) -> Result<(), AppError> { + match self.contents.as_deref() { + Some(contents) => { + atomic_write(&self.path, contents)?; + #[cfg(unix)] + if let Some(mode) = self.mode { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&self.path, fs::Permissions::from_mode(mode)) + .map_err(|error| AppError::io(&self.path, error))?; + } + Ok(()) + } + None => delete_file(&self.path), + } + } +} + +/// Exact rollback state for a managed Codex live write. The generated catalog +/// and ownership marker are part of the same logical commit as auth/config. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CodexLiveStateSnapshot { + auth: CodexLiveFileState, + config: CodexLiveFileState, + catalog: CodexLiveFileState, + managed_marker: CodexLiveFileState, +} + +impl CodexLiveStateSnapshot { + pub(crate) fn capture() -> Result { + Ok(Self { + auth: CodexLiveFileState::capture(get_codex_auth_path())?, + config: CodexLiveFileState::capture(get_codex_config_path())?, + catalog: CodexLiveFileState::capture(get_codex_model_catalog_path())?, + managed_marker: CodexLiveFileState::capture( + get_codex_managed_oauth_live_auth_marker_path(), + )?, + }) + } + + /// Roll back config/catalog exactly while retaining a demonstrably newer + /// ChatGPT auth generation for the same account. OAuth refresh can advance + /// auth.json after a provider transaction captures its snapshot; restoring + /// that snapshot blindly would invalidate the CLI's newly rotated token. + /// + /// Cross-account writes are still rolled back exactly: an A -> B transaction + /// that fails must restore A even if B refreshed while it was briefly live. + /// The marker follows auth as one generation bundle. + pub(crate) fn restore_preserving_newer_same_account_auth(&self) -> Result<(), AppError> { + let mut failures = Vec::new(); + let current_auth = match CodexLiveFileState::capture(get_codex_auth_path()) { + Ok(state) => Some(state), + Err(error) => { + // Inspection failure must not prevent config/catalog and the + // remaining rollback files from being attempted. + failures.push(format!("inspect current auth: {error}")); + None + } + }; + let snapshot_generation = Self::chatgpt_auth_generation(&self.auth); + let current_generation = current_auth + .as_ref() + .and_then(Self::chatgpt_auth_generation); + let preserve_current_auth = match (snapshot_generation, current_generation) { + (Some((snapshot_account, snapshot_time)), Some((current_account, current_time))) + if snapshot_account == current_account => + { + match (snapshot_time, current_time) { + (Some(snapshot_time), Some(current_time)) => current_time > snapshot_time, + (None, Some(_)) => true, + _ => false, + } + } + _ => false, + }; + + for (label, state) in [("catalog", &self.catalog), ("config", &self.config)] { + if let Err(error) = state.restore() { + failures.push(format!("{label}: {error}")); + } + } + if !preserve_current_auth { + for (label, state) in [ + ("auth", &self.auth), + ("managed marker", &self.managed_marker), + ] { + if let Err(error) = state.restore() { + failures.push(format!("{label}: {error}")); + } + } + } + + if failures.is_empty() { + Ok(()) + } else { + Err(AppError::Message(format!( + "恢复 Codex Live 状态失败: {}", + failures.join("; ") + ))) + } + } + + fn chatgpt_auth_generation(state: &CodexLiveFileState) -> Option<(String, Option)> { + let auth: Value = serde_json::from_slice(state.contents.as_deref()?).ok()?; + if auth.get("auth_mode").and_then(Value::as_str) != Some("chatgpt") { + return None; + } + let account_id = auth + .pointer("/tokens/account_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|account_id| !account_id.is_empty())? + .to_string(); + let last_refresh_ms = auth + .get("last_refresh") + .and_then(Value::as_str) + .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) + .map(|value| value.timestamp_millis()); + Some((account_id, last_refresh_ms)) + } +} + /// Which Codex tool surface the generated model catalog should target. /// /// - `ProxyChat`: cc-switch's proxy takes over and converts Responses<->Chat, @@ -118,14 +285,27 @@ struct CodexManagedOAuthLiveAuthMarker { pub enum CodexCatalogToolProfile { ProxyChat, NativeResponses, + /// Codex talks (through cc-switch's proxy) to a native Anthropic Messages + /// gateway. Like `NativeResponses` it must suppress Codex's freeform custom + /// tools — the Responses→Anthropic transform keeps only `function` tools. + /// Additionally the Codex `web_search` hosted tool is unusable on this path + /// (the transform drops it), so it is always disabled — see + /// `prepare_codex_config_text_with_model_catalog`. + Anthropic, } impl CodexCatalogToolProfile { /// Pick the catalog tool profile from a provider's `apiFormat` meta value. - /// Native (direct) Responses providers must suppress the custom apply_patch - /// tool; everything else keeps the proxy-chat behavior. + /// + /// Prefer [`crate::proxy::providers::codex::resolve_codex_catalog_tool_profile`], + /// which also honors settings-level `apiFormat` and the TOML `wire_api` (matching + /// the proxy router). This string-only mapping is the fallback for non-Anthropic + /// cases. pub fn from_api_format(api_format: Option<&str>) -> Self { match api_format { + Some("anthropic") => CodexCatalogToolProfile::Anthropic, + // Native (direct) Responses gateways reject Codex's freeform custom + // tools (apply_patch, etc.); strip them via the NativeResponses profile. Some("openai_responses") => CodexCatalogToolProfile::NativeResponses, _ => CodexCatalogToolProfile::ProxyChat, } @@ -224,6 +404,38 @@ fn extract_codex_managed_oauth_auth(auth: &Value) -> Option<(String, String)> { Some((account_id.to_string(), access_token.to_string())) } +/// Build the native-shaped ChatGPT auth bundle shared by cc-switch and Codex CLI. +pub fn codex_managed_oauth_auth_value( + account_id: &str, + access_token: &str, + id_token: Option<&str>, + refresh_token: &str, + last_refresh: &str, +) -> Value { + let mut tokens = serde_json::Map::new(); + if let Some(id_token) = id_token { + tokens.insert("id_token".to_string(), Value::String(id_token.to_string())); + } + tokens.insert( + "access_token".to_string(), + Value::String(access_token.to_string()), + ); + tokens.insert( + "refresh_token".to_string(), + Value::String(refresh_token.to_string()), + ); + tokens.insert( + "account_id".to_string(), + Value::String(account_id.to_string()), + ); + json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": Value::Object(tokens), + "last_refresh": last_refresh, + }) +} + pub fn record_codex_managed_oauth_live_auth(auth: &Value) -> Result<(), AppError> { let Some((account_id, access_token)) = extract_codex_managed_oauth_auth(auth) else { return Ok(()); @@ -329,7 +541,7 @@ pub fn codex_live_auth_is_managed_chatgpt_login(auth: &Value, account_id: &str) /// 用陈腐 token 覆盖 CLI 的有效登录(“裸跑 codex” 反复切换场景)。 pub fn read_codex_live_auth_refresh_for_account( account_id: &str, -) -> Option<(String, Option)> { +) -> Option<(String, Option, Option)> { let account_id = account_id.trim(); if account_id.is_empty() { return None; @@ -353,7 +565,59 @@ pub fn read_codex_live_auth_refresh_for_account( .get("id_token") .and_then(|value| value.as_str()) .map(|token| token.to_string()); - Some((refresh_token, id_token)) + let last_refresh_ms = auth + .get("last_refresh") + .and_then(Value::as_str) + .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) + .map(|value| value.timestamp_millis()); + Some((refresh_token, id_token, last_refresh_ms)) +} + +/// Keep Codex CLI's live auth in the same refresh-token generation after the +/// manager refreshes a managed account. +/// +/// The write is compare-and-swap-like: it only proceeds while auth.json still +/// contains the exact refresh token used for the network request. If Codex CLI +/// refreshed concurrently, its newer file wins and is never overwritten. +/// Ownership is preserved separately: a file previously recorded as cc-switch +/// managed gets a new marker fingerprint, while a same-account native login is +/// updated without becoming eligible for deletion on unbind. +pub fn sync_codex_managed_oauth_live_auth_after_refresh( + account_id: &str, + expected_refresh_token: &str, + refreshed_auth: &Value, +) -> Result { + let account_id = account_id.trim(); + let expected_refresh_token = expected_refresh_token.trim(); + if account_id.is_empty() || expected_refresh_token.is_empty() { + return Ok(false); + } + + let auth_path = get_codex_auth_path(); + if !auth_path.exists() { + return Ok(false); + } + let current_auth: Value = read_json_file(&auth_path)?; + if !codex_live_auth_is_managed_chatgpt_login(¤t_auth, account_id) { + return Ok(false); + } + let current_refresh_token = current_auth + .pointer("/tokens/refresh_token") + .and_then(Value::as_str) + .map(str::trim); + if current_refresh_token != Some(expected_refresh_token) { + return Ok(false); + } + + let marker_path = get_codex_managed_oauth_live_auth_marker_path(); + let was_recorded_managed = marker_path.exists() + && codex_auth_matches_recorded_managed_oauth(¤t_auth, account_id)?; + + write_json_file(&auth_path, refreshed_auth)?; + if was_recorded_managed { + record_codex_managed_oauth_live_auth(refreshed_auth)?; + } + Ok(true) } /// 获取 Codex config.toml 路径 @@ -626,6 +890,17 @@ fn extract_codex_top_level_u64(config_text: &str, field: &str) -> Option { .filter(|value| *value > 0) } +fn codex_catalog_input_modalities( + model: &str, + declared_modalities: Option<&[String]>, +) -> Vec { + let modalities = match image_input_capability_from_modalities(model, declared_modalities) { + ImageInputCapability::Unsupported => &["text"][..], + ImageInputCapability::Supported | ImageInputCapability::Unknown => &["text", "image"][..], + }; + modalities.iter().map(|item| (*item).to_string()).collect() +} + fn codex_catalog_model_entry( template: &Value, spec: &CodexCatalogModelSpec, @@ -648,10 +923,22 @@ fn codex_catalog_model_entry( entry_obj.insert("availability_nux".to_string(), Value::Null); entry_obj.insert("upgrade".to_string(), Value::Null); - if profile == CodexCatalogToolProfile::NativeResponses { - // Native `/responses` gateways reject Codex's freeform `apply_patch` - // (type=="custom") tool. Strip any key that would make Codex emit a - // custom/freeform tool, and rely on shell_type="shell_command" for + // Image support is a model capability, not a tool-profile capability. + // Trust hidden preset metadata first, then the confirmed text-only registry; + // every unknown model fails open so GPT/relay aliases are never declared + // text-only merely because a template had a conservative default. + entry_obj.insert( + "input_modalities".to_string(), + json!(codex_catalog_input_modalities( + &spec.model, + spec.input_modalities.as_deref(), + )), + ); + + if profile != CodexCatalogToolProfile::ProxyChat { + // Native `/responses` and Anthropic gateways reject / drop Codex's freeform + // `apply_patch` (type=="custom") tool. Strip any key that would make Codex + // emit a custom/freeform tool, and rely on shell_type="shell_command" for // edits. Defensive even though the native template is already clean // (guards against template drift / an accidental gpt-5.5 clone). // @@ -680,9 +967,6 @@ fn codex_catalog_model_entry( if let Some(parallel) = spec.supports_parallel_tool_calls { entry_obj.insert("supports_parallel_tool_calls".to_string(), json!(parallel)); } - if let Some(modalities) = &spec.input_modalities { - entry_obj.insert("input_modalities".to_string(), json!(modalities)); - } } entry @@ -696,8 +980,9 @@ struct CodexCatalogModelSpec { /// Per-row override for the native template's `supports_parallel_tool_calls` /// (e.g. MiniMax=true, MiMo=false). Only consulted for `NativeResponses`. supports_parallel_tool_calls: Option, - /// Per-row override for the native template's `input_modalities` - /// (e.g. `["text","image"]`). Only consulted for `NativeResponses`. + /// Hidden per-row capability declaration from built-in provider metadata. + /// When omitted, all catalog profiles consult the shared text-only model + /// registry and otherwise default to `["text", "image"]`. input_modalities: Option>, /// Per-row override for the native template's `base_instructions` (the /// model identity / system preamble). Carries each vendor's OFFICIAL value @@ -1078,7 +1363,9 @@ fn codex_model_catalog_from_settings( // no cache dependency); proxy-chat providers keep cloning Codex's gpt-5.5 // entry so the proxy can rewrite custom<->function tools as before. let template = match profile { - CodexCatalogToolProfile::NativeResponses => load_codex_native_responses_template(), + CodexCatalogToolProfile::NativeResponses | CodexCatalogToolProfile::Anthropic => { + load_codex_native_responses_template() + } CodexCatalogToolProfile::ProxyChat => load_codex_model_catalog_template()?, }; Ok(Some(codex_model_catalog_from_specs( @@ -1162,21 +1449,32 @@ pub fn prepare_codex_config_text_with_model_catalog( // (MiMo/LongCat/MiniMax by host or model brand; Qwen3-Coder by model). // Everything else — relays, DouBao, web-search-capable Qwen models, // unknown providers — keeps Codex's default. - let disable_web_search = profile == CodexCatalogToolProfile::NativeResponses - && codex_native_gateway_rejects_web_search(&config_text); + let disable_web_search = match profile { + // The Responses→Anthropic transform silently drops the Codex web_search + // hosted tool, so always disable it here rather than present a dead tool. + CodexCatalogToolProfile::Anthropic => true, + CodexCatalogToolProfile::NativeResponses => { + codex_native_gateway_rejects_web_search(&config_text) + } + CodexCatalogToolProfile::ProxyChat => false, + }; let config_text = set_codex_native_web_search_field(&config_text, disable_web_search)?; write_json_file(&catalog_path, &catalog)?; Ok(config_text) } else { let config_text = set_codex_model_catalog_json_field(config_text, None)?; - set_codex_native_web_search_field(&config_text, false) + // Even without a generated catalog, the Responses→Anthropic transform drops the + // Codex web_search hosted tool, so keep the invariant that an Anthropic provider + // never presents it as a dead tool. + let disable_web_search = profile == CodexCatalogToolProfile::Anthropic; + set_codex_native_web_search_field(&config_text, disable_web_search) } } /// Reverse of `prepare_codex_config_text_with_model_catalog`: read the /// cc-switch–maintained catalog file referenced by `~/.codex/config.toml` and /// convert it back into the simplified shape the frontend table uses: -/// `{ "models": [{ "model", "displayName"?, "contextWindow"? }, ...] }`. +/// `{ "models": [{ "model", "displayName"?, "contextWindow"?, hidden overrides... }, ...] }`. /// /// We only reverse-parse catalogs whose `model_catalog_json` path is the /// cc-switch–generated file (identified by filename @@ -1184,14 +1482,14 @@ pub fn prepare_codex_config_text_with_model_catalog( /// left alone — surfacing its richer structure as the simplified table would /// be a downgrade we can't safely round-trip. /// -/// `displayName` and `contextWindow` are omitted from the returned entry when -/// the on-disk value matches the fallback that +/// `displayName`, `contextWindow`, and `inputModalities` are omitted from the +/// returned entry when the on-disk value matches the fallback that /// `codex_model_catalog_from_settings` injects for unset inputs (slug for -/// display_name, `model_context_window` or 128_000 for context_window). This -/// preserves the "user left it blank" intent across round-trip; an unavoidable -/// edge case is that a user-typed value that happens to equal the fallback -/// will also collapse to blank, but the next save writes the same fallback so -/// behavior stays consistent. +/// display_name, `model_context_window` or 128_000 for context_window, and the +/// shared confirmed-text-only inference for input modalities). This preserves +/// the "user left it blank" intent across round-trip; an unavoidable edge case +/// is that a user-typed value that happens to equal the fallback also collapses +/// to blank, but the next save writes the same fallback so behavior is stable. /// /// All failure modes (missing file, parse error, no `model_catalog_json`, /// entries without `slug`) collapse to `Ok(None)` so callers can treat this @@ -1246,9 +1544,8 @@ pub(crate) fn resolve_cc_switch_catalog_path( } /// Pure reverse-parsing core: convert Codex catalog JSON text back into the -/// frontend's simplified `{ models: [{ model, displayName?, contextWindow? }] }` -/// shape. Returns `None` when the catalog is unparseable, has no `models` -/// array, or yields zero valid entries. +/// frontend's simplified model-mapping shape. Returns `None` when the catalog +/// is unparseable, has no `models` array, or yields zero valid entries. fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) -> Option { let catalog: Value = serde_json::from_str(catalog_text).ok()?; let models = catalog.get("models").and_then(|m| m.as_array())?; @@ -1288,8 +1585,7 @@ fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) -> } // Preserve native-profile per-row overrides so a DB-SSOT-missing - // fallback round-trip doesn't silently drop them (they are ignored by - // the ProxyChat profile, so carrying them is harmless). + // fallback round-trip doesn't silently drop them. if let Some(parallel) = entry .get("supports_parallel_tool_calls") .and_then(|v| v.as_bool()) @@ -1302,7 +1598,8 @@ fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) -> .filter_map(|m| m.as_str()) .map(str::to_string) .collect(); - if !mods.is_empty() { + let inferred = codex_catalog_input_modalities(model, None); + if !mods.is_empty() && mods != inferred { obj.insert("inputModalities".to_string(), json!(mods)); } } @@ -1520,15 +1817,139 @@ pub fn read_codex_live_settings() -> Result { /// backend), `name = "OpenAI"` keeps Codex's `is_openai()` feature gates /// (web search, remote compaction), and `supports_websockets` restores the /// built-in default that custom entries otherwise lose. -fn codex_unified_official_provider_table() -> toml_edit::Table { +fn codex_official_provider_table( + base_url: Option<&str>, + supports_websockets: bool, +) -> toml_edit::Table { let mut table = toml_edit::Table::new(); table["name"] = toml_edit::value("OpenAI"); table["requires_openai_auth"] = toml_edit::value(true); - table["supports_websockets"] = toml_edit::value(true); + table["supports_websockets"] = toml_edit::value(supports_websockets); table["wire_api"] = toml_edit::value("responses"); + if let Some(base_url) = base_url { + table["base_url"] = toml_edit::value(base_url.trim_end_matches('/')); + } table } +fn codex_unified_official_provider_table() -> toml_edit::Table { + codex_official_provider_table(None, true) +} + +fn remove_codex_proxy_placeholders_from_providers(providers: &mut toml_edit::Table) { + for (_, item) in providers.iter_mut() { + if let Some(table) = item.as_table_mut() { + let should_remove = table + .get("experimental_bearer_token") + .and_then(|item| item.as_str()) + == Some(CODEX_PROXY_AUTH_PLACEHOLDER); + if should_remove { + table.remove("experimental_bearer_token"); + } + } else if let Some(table) = item.as_inline_table_mut() { + let should_remove = table + .get("experimental_bearer_token") + .and_then(|value| value.as_str()) + == Some(CODEX_PROXY_AUTH_PLACEHOLDER); + if should_remove { + table.remove("experimental_bearer_token"); + } + } + } +} + +/// Project the built-in Codex official provider through the local proxy while +/// keeping authentication owned by Codex itself. +/// +/// The resulting custom provider explicitly opts into OpenAI authentication, +/// so Codex forwards its existing ChatGPT login to the local `/responses` +/// endpoint. No API key or bearer placeholder is written to `auth.json`. +pub fn apply_codex_official_proxy_route( + config_text: &str, + proxy_base_url: &str, +) -> Result { + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + + // A third-party takeover may have left the proxy placeholder in config.toml. + // The official route must use Codex's native OpenAI login instead. + doc.as_table_mut().remove("experimental_bearer_token"); + doc["model_provider"] = toml_edit::value(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID); + + let mut providers = match doc.as_table_mut().remove("model_providers") { + Some(item) => item.into_table().map_err(|_| { + AppError::Message( + "Invalid Codex config.toml: model_providers must be a table".to_string(), + ) + })?, + None => { + let mut table = toml_edit::Table::new(); + table.set_implicit(true); + table + } + }; + + // Clean only CC Switch's placeholder from every stale provider table. Real + // user bearer tokens are preserved, as are all unrelated provider fields. + remove_codex_proxy_placeholders_from_providers(&mut providers); + + // The local proxy currently exposes HTTP/SSE, not Codex websocket routes. + let table = codex_official_provider_table(Some(proxy_base_url), false); + + providers.insert( + CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID, + toml_edit::Item::Table(table), + ); + doc["model_providers"] = toml_edit::Item::Table(providers); + Ok(doc.to_string()) +} + +/// Whether a live Codex config is the official route projected by CC Switch. +pub fn codex_config_has_official_proxy_route(config_text: &str) -> bool { + if !config_text.contains(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID) { + return false; + } + config_text + .parse::() + .ok() + .and_then(|doc| { + doc.get("model_provider") + .and_then(|item| item.as_str()) + .map(str::to_string) + }) + .as_deref() + == Some(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID) +} + +/// Remove only the official takeover route owned by CC Switch. This is a +/// last-resort crash cleanup when no live backup or provider SSOT is usable. +pub fn remove_codex_official_proxy_route(config_text: &str) -> Result { + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + if doc.get("model_provider").and_then(|item| item.as_str()) + != Some(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID) + { + return Ok(config_text.to_string()); + } + + doc.as_table_mut().remove("model_provider"); + if let Some(item) = doc.as_table_mut().remove("model_providers") { + let mut providers = item.into_table().map_err(|_| { + AppError::Message( + "Invalid Codex config.toml: model_providers must be a table".to_string(), + ) + })?; + providers.remove(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID); + remove_codex_proxy_placeholders_from_providers(&mut providers); + if !providers.is_empty() { + doc["model_providers"] = toml_edit::Item::Table(providers); + } + } + Ok(doc.to_string()) +} + fn table_matches_codex_unified_official_provider(table: &toml_edit::Table) -> bool { table.len() == 4 && table.get("name").and_then(|item| item.as_str()) == Some("OpenAI") @@ -1681,6 +2102,45 @@ pub fn strip_codex_unified_session_bucket_from_settings( Ok(()) } +/// Backfill helper: strip `[mcp_servers]` from a live `{ auth, config }` +/// settings object before it is stored back to the DB. +/// +/// MCP 服务器的 SSOT 是 DB 的 mcp_servers 表,live `config.toml` 里的 +/// `[mcp_servers]` 只是每次写 live 之后由 MCP 同步重新投影的产物。若回填时 +/// 烙进供应商存储配置,已在应用里删除的服务器会随下次激活该供应商被写回 +/// live,而逐条 reconcile 只认识 DB 现存条目、永远清不掉这种孤儿。 +pub fn strip_codex_mcp_servers_from_settings(settings: &mut Value) -> Result<(), AppError> { + let Some(config_text) = settings + .get("config") + .and_then(|value| value.as_str()) + .map(str::to_string) + else { + return Ok(()); + }; + if !config_text.contains("mcp") { + return Ok(()); + } + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + let mut changed = doc.as_table_mut().remove("mcp_servers").is_some(); + // 历史错误格式 [mcp.servers] 一并清理(live 侧 MCP 同步也做同样迁移) + if let Some(mcp_tbl) = doc.get_mut("mcp").and_then(|item| item.as_table_like_mut()) { + if mcp_tbl.remove("servers").is_some() { + changed = true; + } + if mcp_tbl.is_empty() { + doc.as_table_mut().remove("mcp"); + } + } + if changed { + if let Some(obj) = settings.as_object_mut() { + obj.insert("config".to_string(), Value::String(doc.to_string())); + } + } + Ok(()) +} + /// Route a Codex live write between full auth+config or config-only. /// /// Official providers with usable login material own `auth.json`. Third-party @@ -1908,6 +2368,26 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn catalog_tool_profile_from_api_format() { + assert_eq!( + CodexCatalogToolProfile::from_api_format(Some("anthropic")), + CodexCatalogToolProfile::Anthropic + ); + assert_eq!( + CodexCatalogToolProfile::from_api_format(Some("openai_responses")), + CodexCatalogToolProfile::NativeResponses + ); + assert_eq!( + CodexCatalogToolProfile::from_api_format(Some("openai_chat")), + CodexCatalogToolProfile::ProxyChat + ); + assert_eq!( + CodexCatalogToolProfile::from_api_format(None), + CodexCatalogToolProfile::ProxyChat + ); + } + #[test] fn unified_session_bucket_injects_for_empty_official_config() { let injected = inject_codex_unified_session_bucket("").expect("inject"); @@ -1935,6 +2415,98 @@ mod tests { ); } + #[test] + fn official_proxy_route_uses_native_auth_and_local_responses_provider() { + let input = r#"model = "gpt-5.4" +experimental_bearer_token = "PROXY_MANAGED" + +[mcp_servers.example] +command = "example" +"#; + let output = apply_codex_official_proxy_route(input, "http://127.0.0.1:15721/v1") + .expect("apply official proxy route"); + let doc: toml::Value = toml::from_str(&output).expect("parse output"); + + assert_eq!( + doc.get("model_provider").and_then(toml::Value::as_str), + Some(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID) + ); + assert!(doc.get("experimental_bearer_token").is_none()); + assert!( + doc.get("mcp_servers").is_some(), + "unrelated config survives" + ); + + let provider = &doc["model_providers"][CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID]; + assert_eq!( + provider.get("base_url").and_then(toml::Value::as_str), + Some("http://127.0.0.1:15721/v1") + ); + assert_eq!( + provider + .get("requires_openai_auth") + .and_then(toml::Value::as_bool), + Some(true) + ); + assert_eq!( + provider + .get("supports_websockets") + .and_then(toml::Value::as_bool), + Some(false) + ); + assert!(codex_config_has_official_proxy_route(&output)); + } + + #[test] + fn official_proxy_route_cleanup_only_removes_owned_provider() { + let projected = + apply_codex_official_proxy_route("model = \"gpt-5.4\"\n", "http://127.0.0.1:15721/v1") + .expect("project"); + let cleaned = remove_codex_official_proxy_route(&projected).expect("clean"); + let doc: toml::Value = toml::from_str(&cleaned).expect("parse cleaned"); + assert!(doc.get("model_provider").is_none()); + assert!(doc.get("model_providers").is_none()); + assert_eq!( + doc.get("model").and_then(toml::Value::as_str), + Some("gpt-5.4") + ); + } + + #[test] + fn official_proxy_route_rejects_non_table_model_providers_without_panicking() { + for input in [ + "model_providers = 3\n", + "[[model_providers]]\nname = \"broken\"\n", + ] { + let result = apply_codex_official_proxy_route(input, "http://127.0.0.1:15721/v1"); + assert!(result.is_err()); + } + } + + #[test] + fn official_proxy_route_normalizes_inline_tables_and_cleans_stale_placeholder() { + let input = r#"model_provider = "rightcode" +model_providers = { rightcode = { name = "RightCode", experimental_bearer_token = "PROXY_MANAGED" } } +"#; + let projected = apply_codex_official_proxy_route(input, "http://127.0.0.1:15721/v1") + .expect("project inline provider table"); + let projected_doc: toml::Value = toml::from_str(&projected).expect("parse projected"); + assert!(projected_doc["model_providers"]["rightcode"] + .get("experimental_bearer_token") + .is_none()); + assert!(projected_doc["model_providers"] + .get(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID) + .is_some()); + + let cleaned = remove_codex_official_proxy_route(&projected).expect("clean projected"); + let cleaned_doc: toml::Value = toml::from_str(&cleaned).expect("parse cleaned"); + assert!(cleaned_doc.get("model_provider").is_none()); + assert!(cleaned_doc["model_providers"].get("rightcode").is_some()); + assert!(cleaned_doc["model_providers"] + .get(CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID) + .is_none()); + } + #[test] fn unified_session_bucket_preserves_other_keys_and_explicit_routing() { let with_catalog = "model_catalog_json = \"cc-switch-model-catalog.json\"\n"; @@ -2011,6 +2583,41 @@ requires_openai_auth = true assert!(settings.pointer("/auth/tokens/access_token").is_some()); } + #[test] + fn strip_mcp_servers_from_settings_removes_table_and_legacy_form() { + let mut settings = json!({ + "auth": { "OPENAI_API_KEY": "sk-test" }, + "config": "# user comment\nmodel = \"gpt-5.5\"\n\n[mcp_servers.echo]\ntype = \"stdio\"\ncommand = \"echo\"\n\n[mcp.servers.legacy]\ncommand = \"noop\"\n", + }); + strip_codex_mcp_servers_from_settings(&mut settings).expect("strip mcp"); + let config = settings + .get("config") + .and_then(|v| v.as_str()) + .expect("config text"); + assert!(!config.contains("mcp_servers"), "got: {config}"); + assert!( + !config.contains("[mcp"), + "legacy [mcp.servers] gone: {config}" + ); + assert!(config.contains("# user comment"), "comments preserved"); + assert!(config.contains("model = \"gpt-5.5\"")); + } + + #[test] + fn strip_mcp_servers_from_settings_is_noop_without_mcp() { + let original = "# comment\nmodel = \"gpt-5.5\"\n"; + let mut settings = json!({ + "auth": {}, + "config": original, + }); + strip_codex_mcp_servers_from_settings(&mut settings).expect("strip mcp"); + assert_eq!( + settings.get("config").and_then(|v| v.as_str()), + Some(original), + "config text must be byte-identical when nothing is stripped" + ); + } + #[test] fn extract_base_url_prefers_active_provider_section() { let input = r#"model_provider = "azure" @@ -2695,6 +3302,85 @@ base_url = "https://production.api/v1" ); } + #[test] + fn catalog_infers_image_input_independently_of_tool_profile() { + // Start from a deliberately text-only template to prove that every + // profile overwrites template defaults with shared capability logic. + let template = json!({ + "input_modalities": ["text"], + "apply_patch_tool_type": "freeform" + }); + let specs = vec![ + CodexCatalogModelSpec { + model: "gpt-5.4".to_string(), + display_name: "GPT 5.4".to_string(), + context_window: 128_000, + supports_parallel_tool_calls: None, + input_modalities: None, + base_instructions: None, + }, + CodexCatalogModelSpec { + model: "deepseek/deepseek-v4-pro".to_string(), + display_name: "DeepSeek V4 Pro".to_string(), + context_window: 128_000, + supports_parallel_tool_calls: None, + input_modalities: None, + base_instructions: None, + }, + CodexCatalogModelSpec { + model: "glm-5.2v".to_string(), + display_name: "GLM 5.2V".to_string(), + context_window: 128_000, + supports_parallel_tool_calls: None, + input_modalities: None, + base_instructions: None, + }, + CodexCatalogModelSpec { + model: "deepseek-v4-flash".to_string(), + display_name: "Explicit Visual Override".to_string(), + context_window: 128_000, + supports_parallel_tool_calls: None, + input_modalities: Some(vec!["text".to_string(), "image".to_string()]), + base_instructions: None, + }, + CodexCatalogModelSpec { + model: "custom-text-alias".to_string(), + display_name: "Explicit Text Override".to_string(), + context_window: 128_000, + supports_parallel_tool_calls: None, + input_modalities: Some(vec!["text".to_string()]), + base_instructions: None, + }, + ]; + + for profile in [ + CodexCatalogToolProfile::ProxyChat, + CodexCatalogToolProfile::NativeResponses, + CodexCatalogToolProfile::Anthropic, + ] { + let catalog = codex_model_catalog_from_specs(&specs, &template, profile); + let models = catalog["models"].as_array().expect("models array"); + let modalities = |slug: &str| { + models + .iter() + .find(|entry| entry["slug"] == slug) + .and_then(|entry| entry.get("input_modalities")) + .cloned() + .unwrap_or(Value::Null) + }; + + assert_eq!(modalities("gpt-5.4"), json!(["text", "image"])); + assert_eq!(modalities("deepseek/deepseek-v4-pro"), json!(["text"])); + assert_eq!(modalities("glm-5.2v"), json!(["text", "image"])); + assert_eq!( + modalities("deepseek-v4-flash"), + json!(["text", "image"]), + "explicit provider metadata must override the text-only registry" + ); + assert_eq!(modalities("custom-text-alias"), json!(["text"])); + } + } + #[test] fn native_responses_catalog_always_carries_base_instructions() { // Regression guard for the "missing field `base_instructions`" parse @@ -2838,6 +3524,42 @@ web_search = "disabled" ); } + #[test] + fn anthropic_profile_disables_web_search_without_catalog() { + // Regression: even when no model catalog is generated (empty/absent + // modelCatalog), an Anthropic provider must still disable web_search — the + // Responses→Anthropic transform drops the hosted tool, so leaving it on + // exposes a dead tool. The None-catalog branch previously always left it on. + let config = "model = \"claude-sonnet-4-6\"\n"; + let settings = serde_json::json!({}); + + let anthropic = prepare_codex_config_text_with_model_catalog( + &settings, + config, + CodexCatalogToolProfile::Anthropic, + ) + .unwrap(); + let parsed: toml::Value = toml::from_str(&anthropic).unwrap(); + assert_eq!( + parsed.get("web_search").and_then(|v| v.as_str()), + Some("disabled"), + "Anthropic profile must disable web_search even with no catalog" + ); + + // ProxyChat on the same no-catalog path must NOT add a disable line. + let proxy = prepare_codex_config_text_with_model_catalog( + &settings, + config, + CodexCatalogToolProfile::ProxyChat, + ) + .unwrap(); + let parsed: toml::Value = toml::from_str(&proxy).unwrap(); + assert!( + parsed.get("web_search").is_none(), + "ProxyChat profile must not disable web_search on the no-catalog path" + ); + } + #[test] fn web_search_blacklist_disables_only_known_reject_gateways() { let cfg = |model: &str, base_url: &str| { @@ -2850,7 +3572,7 @@ web_search = "disabled" for (model, host) in [ ("mimo-v2.5-pro", "https://api.xiaomimimo.com/v1"), ("mimo-v2.5", "https://token-plan-cn.xiaomimimo.com/v1"), - ("LongCat-2.0-Preview", "https://api.longcat.chat/openai/v1"), + ("LongCat-2.0", "https://api.longcat.chat/openai/v1"), ("MiniMax-M3", "https://api.minimax.io/v1"), ("MiniMax-M3", "https://api.minimaxi.com/v1"), ] { @@ -3008,6 +3730,40 @@ web_search = "disabled" ); } + #[test] + fn build_simplified_catalog_squashes_inferred_modalities_and_keeps_overrides() { + let catalog = r#"{ + "models": [ + { "slug": "gpt-5.4", "input_modalities": ["text", "image"] }, + { "slug": "deepseek-v4-pro", "input_modalities": ["text"] }, + { "slug": "gpt-text-override", "input_modalities": ["text"] }, + { "slug": "deepseek-v4-flash", "input_modalities": ["text", "image"] } + ] + }"#; + + let result = build_simplified_catalog_from_texts("", catalog).expect("entries"); + let models = result.get("models").unwrap().as_array().unwrap(); + + assert!( + models[0].get("inputModalities").is_none(), + "GPT text+image is inferred and must not become a sticky hidden override" + ); + assert!( + models[1].get("inputModalities").is_none(), + "confirmed text-only capability is inferred and must remain registry-driven" + ); + assert_eq!( + models[2].get("inputModalities"), + Some(&json!(["text"])), + "an unknown model explicitly forced to text-only must round-trip" + ); + assert_eq!( + models[3].get("inputModalities"), + Some(&json!(["text", "image"])), + "an explicit image override for a registered text-only model must round-trip" + ); + } + #[test] fn build_simplified_catalog_returns_none_when_unparseable() { assert!(build_simplified_catalog_from_texts("", "not json").is_none()); diff --git a/src-tauri/src/codex_history_migration.rs b/src-tauri/src/codex_history_migration.rs index 648af4d81..9e4f0e023 100644 --- a/src-tauri/src/codex_history_migration.rs +++ b/src-tauri/src/codex_history_migration.rs @@ -6,6 +6,7 @@ use crate::codex_config::{ get_codex_config_dir, read_codex_config_text, CC_SWITCH_CODEX_MODEL_PROVIDER_ID, }; +use crate::codex_state_db::codex_state_db_paths; use crate::config::{atomic_write, copy_file, get_app_config_dir}; use crate::database::{is_official_seed_id, Database}; use crate::error::AppError; @@ -28,7 +29,6 @@ const MIGRATION_NAME: &str = "codex-history-provider-migration-v1"; const OFFICIAL_UNIFY_MIGRATION_NAME: &str = "codex-official-history-unify-v1"; /// 还原操作自身的备份目录(与迁移备份分开,保持迁移账本目录纯净)。 const OFFICIAL_UNIFY_RESTORE_BACKUP_NAME: &str = "codex-official-history-unify-restore-v1"; -const CODEX_STATE_DB_FILENAME: &str = "state_5.sqlite"; /// SQLite 变量上限保守值,IN 列表按此分块。 const STATE_DB_ID_CHUNK: usize = 500; @@ -1116,55 +1116,6 @@ fn migrate_codex_state_dbs( Ok(migrated) } -fn codex_state_db_paths(codex_dir: &Path, config_text: &str) -> Vec { - let mut paths = Vec::new(); - push_unique_path(&mut paths, codex_dir.join(CODEX_STATE_DB_FILENAME)); - // Codex lets SQLite state move away from CODEX_HOME; config takes precedence. - if let Some(sqlite_home) = sqlite_home_from_codex_config(config_text) { - push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME)); - } else if let Some(sqlite_home) = sqlite_home_from_env() { - push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME)); - } - paths -} - -fn push_unique_path(paths: &mut Vec, path: PathBuf) { - if !paths.contains(&path) { - paths.push(path); - } -} - -fn sqlite_home_from_codex_config(config_text: &str) -> Option { - let doc = config_text.parse::().ok()?; - let raw = doc.get("sqlite_home")?.as_str()?.trim(); - if raw.is_empty() { - return None; - } - Some(resolve_user_path(raw)) -} - -fn sqlite_home_from_env() -> Option { - let raw = std::env::var("CODEX_SQLITE_HOME").ok()?; - let raw = raw.trim(); - if raw.is_empty() { - return None; - } - Some(resolve_user_path(raw)) -} - -fn resolve_user_path(raw: &str) -> PathBuf { - if raw == "~" { - return crate::config::get_home_dir(); - } - if let Some(rest) = raw.strip_prefix("~/") { - return crate::config::get_home_dir().join(rest); - } - if let Some(rest) = raw.strip_prefix("~\\") { - return crate::config::get_home_dir().join(rest); - } - PathBuf::from(raw) -} - fn migrate_codex_state_db_provider_bucket( db_path: &Path, codex_dir: &Path, @@ -1329,6 +1280,7 @@ fn relative_backup_path(path: &Path, root: &Path) -> PathBuf { #[cfg(test)] mod tests { use super::*; + use crate::codex_state_db::CODEX_STATE_DB_FILENAME; use crate::provider::Provider; use serial_test::serial; use std::ffi::OsString; @@ -2185,7 +2137,8 @@ base_url = "https://proxy.example/v1" let env_sqlite_home = dir.path().join("env-sqlite-home"); let config_sqlite_home = dir.path().join("config-sqlite-home"); let _guard = EnvVarGuard::set("CODEX_SQLITE_HOME", &env_sqlite_home); - let config_text = format!("sqlite_home = \"{}\"\n", config_sqlite_home.display()); + // TOML 字面量字符串(单引号):Windows 路径含反斜杠,basic string 会解析失败。 + let config_text = format!("sqlite_home = '{}'\n", config_sqlite_home.display()); let paths = codex_state_db_paths(&codex_dir, &config_text); diff --git a/src-tauri/src/codex_state_db.rs b/src-tauri/src/codex_state_db.rs new file mode 100644 index 000000000..da4d69d1f --- /dev/null +++ b/src-tauri/src/codex_state_db.rs @@ -0,0 +1,100 @@ +//! Locating Codex's per-thread state SQLite databases. +//! +//! Codex stores thread metadata in `state_5.sqlite`, normally inside the Codex +//! config dir (`CODEX_HOME` / `~/.codex`). The SQLite location can be moved with +//! the `sqlite_home` key in `config.toml` or the `CODEX_SQLITE_HOME` env var; +//! when set, a second DB lives there. Both history migration and the session +//! list's title lookup need the same resolution, so it lives here once. + +use std::path::{Path, PathBuf}; + +use toml_edit::DocumentMut; + +use crate::config::get_home_dir; + +/// Filename of Codex's per-thread state database. Codex bumps the version +/// number across releases; update this single source of truth when a new state +/// DB version ships. +pub(crate) const CODEX_STATE_DB_FILENAME: &str = "state_5.sqlite"; + +/// Env var that overrides the Codex SQLite state directory. +const CODEX_SQLITE_HOME_ENV: &str = "CODEX_SQLITE_HOME"; + +/// Resolve every candidate `state_5.sqlite` path: the config-dir DB plus, when +/// Codex is configured to keep its SQLite state elsewhere, that DB too. +/// +/// `config_dir` is the Codex config dir (`~/.codex`); `config_text` is the raw +/// `config.toml` contents, used to detect a `sqlite_home` override. +pub(crate) fn codex_state_db_paths(config_dir: &Path, config_text: &str) -> Vec { + let mut paths = Vec::new(); + push_unique_path(&mut paths, config_dir.join(CODEX_STATE_DB_FILENAME)); + // Codex lets SQLite state move away from CODEX_HOME; config takes precedence. + if let Some(sqlite_home) = sqlite_home_from_codex_config(config_text) { + push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME)); + } else if let Some(sqlite_home) = sqlite_home_from_env() { + push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME)); + } + paths +} + +fn push_unique_path(paths: &mut Vec, path: PathBuf) { + if !paths.contains(&path) { + paths.push(path); + } +} + +fn sqlite_home_from_codex_config(config_text: &str) -> Option { + let doc = config_text.parse::().ok()?; + let raw = doc.get("sqlite_home")?.as_str()?.trim(); + if raw.is_empty() { + return None; + } + Some(resolve_user_path(raw)) +} + +fn sqlite_home_from_env() -> Option { + let raw = std::env::var(CODEX_SQLITE_HOME_ENV).ok()?; + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + Some(resolve_user_path(raw)) +} + +fn resolve_user_path(raw: &str) -> PathBuf { + if raw == "~" { + return get_home_dir(); + } + if let Some(rest) = raw.strip_prefix("~/") { + return get_home_dir().join(rest); + } + if let Some(rest) = raw.strip_prefix("~\\") { + return get_home_dir().join(rest); + } + PathBuf::from(raw) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn includes_config_sqlite_home() { + let temp = tempdir().expect("tempdir"); + let sqlite_home = temp.path().join("sqlite-home"); + // 用 TOML 字面量字符串(单引号)承载路径:Windows 路径含反斜杠,basic string(双引号) + // 会把 `\U`/`\s` 等当作非法转义导致解析失败。 + let config_text = format!("sqlite_home = '{}'\n", sqlite_home.display()); + + let paths = codex_state_db_paths(temp.path(), &config_text); + + assert_eq!( + paths, + vec![ + temp.path().join(CODEX_STATE_DB_FILENAME), + sqlite_home.join(CODEX_STATE_DB_FILENAME), + ] + ); + } +} diff --git a/src-tauri/src/commands/codex_oauth.rs b/src-tauri/src/commands/codex_oauth.rs index b0b4aef71..b499709fc 100644 --- a/src-tauri/src/commands/codex_oauth.rs +++ b/src-tauri/src/commands/codex_oauth.rs @@ -52,13 +52,14 @@ pub async fn get_codex_oauth_quota( } }; - Ok(query_codex_quota( + // 瞬时传输失败以 Err 传播(前端 reject → retry + 保留上次成功值)。 + query_codex_quota( &token, Some(&id), "codex_oauth", "Codex OAuth access token expired or rejected. Please re-login via cc-switch.", ) - .await) + .await } /// 获取 Codex OAuth (ChatGPT Plus/Pro) 可用模型列表 diff --git a/src-tauri/src/commands/coding_plan.rs b/src-tauri/src/commands/coding_plan.rs index 49b9b20be..9125bb00e 100644 --- a/src-tauri/src/commands/coding_plan.rs +++ b/src-tauri/src/commands/coding_plan.rs @@ -7,12 +7,19 @@ pub async fn get_coding_plan_quota( // 火山方舟用控制面 AK/SK 签名查询用量;其他供应商不传,沿用 api_key。 access_key_id: Option, secret_access_key: Option, + // 智谱团队版(zhipu_team)靠显式标识路由(base_url 与个人版相同无法区分)。 + coding_plan_provider: Option, + team_organization_id: Option, + team_project_id: Option, ) -> Result { crate::services::coding_plan::get_coding_plan_quota( &base_url, &api_key, access_key_id.as_deref(), secret_access_key.as_deref(), + coding_plan_provider.as_deref(), + team_organization_id.as_deref(), + team_project_id.as_deref(), ) .await } diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index dea8befde..d09377162 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -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 { } 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 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(), @@ -275,6 +286,23 @@ pub async fn get_common_config_snippet( .map_err(|e| e.to_string()) } +/// 对前端编辑器里的 config.toml 文本做通用配置片段的合并/剥离。 +/// 放后端是为了走 toml_edit(保注释、保键序);前端 smol-toml 的 +/// 整文档重序列化会破坏用户手写格式。 +#[tauri::command] +pub async fn update_toml_common_config_snippet( + config_toml: String, + snippet_toml: String, + enabled: bool, +) -> Result { + crate::services::provider::update_toml_common_config_snippet( + &config_toml, + &snippet_toml, + enabled, + ) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn set_common_config_snippet( app_type: String, diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 5d2558e91..f2af78585 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -197,11 +197,5 @@ pub async fn toggle_mcp_app( /// 从所有应用导入 MCP 服务器(复用已有的导入逻辑) #[tauri::command] pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result { - let mut total = 0; - total += McpService::import_from_claude(&state).unwrap_or(0); - total += McpService::import_from_codex(&state).unwrap_or(0); - total += McpService::import_from_gemini(&state).unwrap_or(0); - total += McpService::import_from_opencode(&state).unwrap_or(0); - total += McpService::import_from_hermes(&state).unwrap_or(0); - Ok(total) + McpService::import_from_all_apps(&state).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 77fa0c955..2095014e1 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -111,8 +111,8 @@ pub struct ToolVersion { wsl_distro: Option, } -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", @@ -492,6 +493,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, @@ -638,7 +640,7 @@ fn build_tool_action_line( // (npm/pnpm)或 .exe(volta),静态命令头部是 `npm`(也是 .cmd)、`py` 等—— // 全部加 `call ` 前缀,风格统一且语义正确。含空格的头部已被 `win_quote_path_for_batch` // 加上双引号,call 对带引号的路径解析正常。 - return Ok(format!("call {command}")); + Ok(format!("call {command}")) } #[cfg(not(target_os = "windows"))] @@ -762,6 +764,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 @@ -1069,6 +1072,8 @@ fn default_flag_for_shell(shell: &str) -> &'static str { } } +// 以下 shell 解析辅助函数仅被 macOS/Linux 的终端启动逻辑使用;Windows 非 test 编译下为死代码。 +#[cfg_attr(windows, allow(dead_code))] fn fallback_user_shell() -> &'static str { if cfg!(target_os = "macos") { "/bin/zsh" @@ -1077,6 +1082,7 @@ fn fallback_user_shell() -> &'static str { } } +#[cfg_attr(windows, allow(dead_code))] fn valid_user_shell_path(shell: &str) -> bool { if shell.is_empty() || !shell.starts_with('/') @@ -1100,11 +1106,13 @@ fn is_executable_file(path: &std::path::Path) -> bool { } #[cfg(not(unix))] +#[cfg_attr(windows, allow(dead_code))] fn is_executable_file(path: &std::path::Path) -> bool { path.is_file() } /// 获取用户默认 shell 的完整路径;异常或被污染的 SHELL 回退到平台默认值。 +#[cfg_attr(windows, allow(dead_code))] fn get_user_shell() -> String { std::env::var("SHELL") .ok() @@ -1113,6 +1121,7 @@ fn get_user_shell() -> String { } /// 构建 exec 行:引号保护 shell 路径,交还用户 shell 让其按默认规则加载 rc 配置。 +#[cfg_attr(windows, allow(dead_code))] fn build_exec_line(shell: &str, cwd: Option<&Path>) -> String { let quoted_shell = shell_single_quote(shell); @@ -1132,6 +1141,7 @@ fn build_exec_line(shell: &str, cwd: Option<&Path>) -> String { } /// 构建 provider 命令行:通过用户 shell 的交互模式执行,确保 GUI 启动的终端也加载用户 PATH。 +#[cfg_attr(windows, allow(dead_code))] fn build_provider_command_line(shell: &str, config_path: &str, cwd: Option<&Path>) -> String { let claude_command = format!("claude --settings {}", shell_single_quote(config_path)); let command = cwd @@ -1152,6 +1162,7 @@ fn build_provider_command_line(shell: &str, config_path: &str, cwd: Option<&Path ) } +#[cfg_attr(windows, allow(dead_code))] fn provider_command_flag_for_shell(shell: &str) -> &'static str { match shell.rsplit('/').next().unwrap_or(shell) { "dash" | "sh" => "-c", @@ -1160,6 +1171,7 @@ fn provider_command_flag_for_shell(shell: &str) -> &'static str { } } +#[cfg_attr(windows, allow(dead_code))] fn build_final_shell_cd_command(shell: &str, cwd: Option<&Path>) -> String { if matches!(shell.rsplit('/').next().unwrap_or(shell), "zsh") { return String::new(); @@ -1936,6 +1948,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, @@ -2248,7 +2261,8 @@ fn package_manager_anchored_command_from_paths( /// formula 由 Homebrew 拥有,避免 self-update 尝试改动包管理器管理的安装。 /// ④ 其余支持官方自升级的工具 → ` update/upgrade || <原锚定包管理器命令>`; /// Codex 的 self-update 只在部分 release 可用,所以保留 npm/brew/bun/volta fallback。 -/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI) → 锚定到"那处 bin 目录的 npm"。 +/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI / Grok Build) → 锚定到 +/// "那处 bin 目录的 npm"。 #[cfg(not(target_os = "windows"))] fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> Option { let real_lower = real_target.to_ascii_lowercase(); @@ -2544,6 +2558,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option { "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(), @@ -2731,7 +2746,7 @@ fn launch_terminal_with_env( #[cfg(target_os = "windows")] { launch_windows_terminal(&temp_dir, &config_file, cwd)?; - return Ok(()); + Ok(()) } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] @@ -3252,6 +3267,7 @@ del \"%~f0\" >nul 2>&1 result } +#[cfg_attr(windows, allow(dead_code))] fn shell_single_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\"'\"'")) } @@ -3650,6 +3666,35 @@ 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"), None); + + for shell in [ + LifecycleCommandShell::Posix, + LifecycleCommandShell::WindowsBatch, + ] { + assert_eq!( + tool_action_shell_command_for_shell("grok", ToolLifecycleAction::Install, shell,) + .as_deref(), + Some("npm i -g @xai-official/grok@latest") + ); + assert_eq!( + tool_action_shell_command_for_shell("grok", ToolLifecycleAction::Update, shell,) + .as_deref(), + Some("npm i -g @xai-official/grok@latest") + ); + } + } + #[test] fn test_compare_semver() { use std::cmp::Ordering; @@ -3838,11 +3883,8 @@ mod tests { let (_dir, sub, bin_path) = setup_sibling("Volta", "codex.cmd", &["volta.exe"]); let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path); let volta_full = format!("{}\\volta.exe", sub.to_string_lossy()); - let expected = format!( - "{} update || call {} install @openai/codex", - expect_quoted_path(&bin_path), - expect_quoted_path(&volta_full) - ); + // codex 自 5092fe51 起不在 prefers_official_update:直接锚定包管理器,无 `codex update ||` 前缀。 + let expected = format!("{} install @openai/codex", expect_quoted_path(&volta_full)); assert_eq!(cmd.as_deref(), Some(expected.as_str())); } @@ -3854,9 +3896,9 @@ mod tests { let (_dir, sub, bin_path) = setup_sibling("pnpm", "codex.cmd", &["pnpm.cmd"]); let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path); let pnpm_full = format!("{}\\pnpm.cmd", sub.to_string_lossy()); + // codex 自 5092fe51 起不在 prefers_official_update:直接锚定包管理器,无 `codex update ||` 前缀。 let expected = format!( - "{} update || call {} add -g @openai/codex@latest", - expect_quoted_path(&bin_path), + "{} add -g @openai/codex@latest", expect_quoted_path(&pnpm_full) ); assert_eq!(cmd.as_deref(), Some(expected.as_str())); @@ -3888,9 +3930,21 @@ mod tests { let (_dir, sub, bin_path) = setup_sibling("v22.0.0", "codex.cmd", &["npm.cmd"]); let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path); let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy()); + // codex 自 5092fe51 起不在 prefers_official_update:直接锚定包管理器,无 `codex update ||` 前缀。 let expected = format!( - "{} update || call {} i -g @openai/codex@latest", - expect_quoted_path(&bin_path), + "{} i -g @openai/codex@latest", + expect_quoted_path(&npm_full) + ); + 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())); @@ -3898,10 +3952,11 @@ mod tests { #[test] fn windows_no_sibling_uses_cli_update_without_package_fallback() { - // sibling npm.cmd 不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。 - // 只是没有包管理器 fallback。 - let (_dir, _sub, bin_path) = setup_sibling("", "codex.cmd", &[]); - let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path); + // sibling 包管理器不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。 + // 只是没有包管理器 fallback。用 claude —— codex 自 5092fe51 起一律走 npm 锚定, + // 已不再有官方 self-update 分支,故改用仍在 prefers_official_update 的 claude 覆盖此路径。 + let (_dir, _sub, bin_path) = setup_sibling("", "claude.cmd", &[]); + let cmd = anchored_command_from_paths("claude", &bin_path, &bin_path); let expected = format!("{} update", expect_quoted_path(&bin_path)); assert_eq!(cmd.as_deref(), Some(expected.as_str())); } @@ -3977,9 +4032,9 @@ mod tests { let (_dir, sub, bin_path) = setup_sibling("Program Files", "codex.cmd", &["npm.cmd"]); let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path); let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy()); + // codex 走 npm 锚定(5092fe51),含空格的 npm 全路径仍必须被双引号包裹。 let expected = format!( - "{} update || call {} i -g @openai/codex@latest", - expect_quoted_path(&bin_path), + "{} i -g @openai/codex@latest", expect_quoted_path(&npm_full) ); assert_eq!(cmd.as_deref(), Some(expected.as_str())); @@ -4001,9 +4056,10 @@ mod tests { // 含空格的环境**(否则 sub 本身含空格 + 子目录 `path%foo%` 触发 4 倍 `%` 转义 // 会让 expected 漏引号、假失败)。 let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy()); + // codex 走 npm 锚定(5092fe51):batch 行 = `call i -g ...`, + // 含字面 `%` 的 npm 路径仍须 4 倍转义。 let expected = format!( - "call {} update || call {} i -g @openai/codex@latest", - expect_quoted_path(&bin_path), + "call {} i -g @openai/codex@latest", expect_quoted_path(&npm_full) ); assert_eq!(batch_line, expected); @@ -4214,6 +4270,9 @@ 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_eq!(grok, "npm i -g @xai-official/grok@latest"); } #[test] @@ -4374,6 +4433,21 @@ mod tests { ); } + #[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( + "/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @xai-official/grok@latest" + ) + ); + } + #[test] fn codex_nvm_anchors_to_that_npm() { // Codex 不走 self-update(`codex update` 在 npm 安装上只是裸 `npm install -g`, @@ -4824,6 +4898,12 @@ mod tests { assert_eq!(cmd, "npm i -g @google/gemini-cli@latest"); } + #[test] + fn grok_install_keeps_static_npm() { + let cmd = install_command_for("grok"); + assert_eq!(cmd, "npm i -g @xai-official/grok@latest"); + } + #[test] fn openclaw_install_keeps_static_npm() { let cmd = install_command_for("openclaw"); @@ -4846,6 +4926,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" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c41d7b44b..29a26710e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -18,6 +18,7 @@ mod model_fetch; mod omo; mod openclaw; mod plugin; +mod profile; mod prompt; mod provider; mod proxy; @@ -52,6 +53,7 @@ pub use model_fetch::*; pub use omo::*; pub use openclaw::*; pub use plugin::*; +pub use profile::*; pub use prompt::*; pub use provider::*; pub use proxy::*; diff --git a/src-tauri/src/commands/profile.rs b/src-tauri/src/commands/profile.rs new file mode 100644 index 000000000..41755c932 --- /dev/null +++ b/src-tauri/src/commands/profile.rs @@ -0,0 +1,195 @@ +//! 项目 Profile 管理命令 + +use serde::Serialize; +use tauri::{Emitter, Manager, State}; + +use crate::database::Profile; +use crate::services::profile::{ProfilePayload, ProfileScope, ProfileService}; +use crate::store::AppState; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileDto { + pub id: String, + pub name: String, + pub payload: ProfilePayload, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, +} + +impl From for ProfileDto { + fn from(profile: Profile) -> Self { + // 单条 payload 损坏不应拖垮整个列表:降级为默认值并记日志 + let payload = serde_json::from_str(&profile.payload).unwrap_or_else(|e| { + log::warn!( + "解析 profile '{}' payload 失败,使用默认值: {e}", + profile.id + ); + ProfilePayload::default() + }); + Self { + id: profile.id, + name: profile.name, + payload, + created_at: profile.created_at, + updated_at: profile.updated_at, + } + } +} + +/// 每个分组当前激活的项目 id(未使用项目时为 null) +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CurrentProfileIds { + pub claude: Option, + pub claude_desktop: Option, + pub codex: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfilesResponse { + pub profiles: Vec, + pub current_ids: CurrentProfileIds, +} + +/// Profile 应用完成后的统一收尾:发事件 + 重建托盘菜单 +/// +/// 只对项目所属分组内的应用发 provider-switched。UI 与托盘两个入口必须 +/// 共用此函数,保证事件 payload 形状一致(前端 App.tsx 的 +/// provider-switched 监听依赖该形状)。 +pub fn emit_profile_apply_events( + app: &tauri::AppHandle, + state: &AppState, + profile_id: &str, + scope: ProfileScope, +) { + for app_type in scope.apps().iter() { + let app_str = app_type.as_str(); + let (proxy_enabled, auto_failover_enabled) = state.db.get_proxy_flags_sync(app_str); + let provider_id = crate::settings::get_effective_current_provider(&state.db, app_type) + .ok() + .flatten() + .unwrap_or_default(); + let event_data = serde_json::json!({ + "appType": app_str, + "proxyEnabled": proxy_enabled, + "autoFailoverEnabled": auto_failover_enabled, + "providerId": provider_id, + }); + if let Err(e) = app.emit("provider-switched", event_data) { + log::error!("发射 provider-switched 事件失败: {e}"); + } + } + if let Err(e) = app.emit( + "profile-applied", + serde_json::json!({ "profileId": profile_id, "scope": scope.as_str() }), + ) { + log::error!("发射 profile-applied 事件失败: {e}"); + } + crate::tray::refresh_tray_menu(app); +} + +#[tauri::command] +pub fn list_profiles(state: State<'_, AppState>) -> Result { + let profiles = ProfileService::list(&state).map_err(|e| e.to_string())?; + let current_ids = CurrentProfileIds { + claude: state + .db + .get_current_profile_id(ProfileScope::Claude.as_str()) + .map_err(|e| e.to_string())?, + claude_desktop: state + .db + .get_current_profile_id(ProfileScope::ClaudeDesktop.as_str()) + .map_err(|e| e.to_string())?, + codex: state + .db + .get_current_profile_id(ProfileScope::Codex.as_str()) + .map_err(|e| e.to_string())?, + }; + Ok(ProfilesResponse { + profiles: profiles.into_iter().map(ProfileDto::from).collect(), + current_ids, + }) +} + +#[tauri::command] +pub fn create_profile( + state: State<'_, AppState>, + name: String, + scope: String, +) -> Result { + let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?; + ProfileService::create(&state, &name, scope) + .map(ProfileDto::from) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn update_profile( + state: State<'_, AppState>, + id: String, + name: Option, + resnapshot: Option, + scope: Option, +) -> Result { + let scope = scope + .map(|s| ProfileScope::parse(&s)) + .transpose() + .map_err(|e| e.to_string())?; + ProfileService::update(&state, &id, name, resnapshot.unwrap_or(false), scope) + .map(ProfileDto::from) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn delete_profile(state: State<'_, AppState>, id: String) -> Result<(), String> { + ProfileService::delete(&state, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn clear_current_profile(state: State<'_, AppState>, scope: String) -> Result<(), String> { + let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?; + state + .db + .set_current_profile_id(scope.as_str(), None) + .map_err(|e| e.to_string()) +} + +/// 应用项目快照(只作用于发起页所属分组内的应用)。 +/// +/// 注意:必须保持同步命令(跑在 Tauri 线程池)——`ProviderService::switch` +/// 内部使用 block_on 获取切换锁,放进 async 命令会在运行时线程上 panic。 +#[tauri::command] +pub fn apply_profile( + app: tauri::AppHandle, + state: State<'_, AppState>, + id: String, + scope: String, +) -> Result, String> { + let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?; + let (warnings, should_stop_proxy) = + ProfileService::apply(&state, &id, scope).map_err(|e| e.to_string())?; + + if should_stop_proxy { + // sync 命令线程没有 Tokio runtime,无法直接 await stop(); + // 把停止服务放到 Tauri async runtime,停止后再补发事件刷新 UI。 + let app_handle = app.clone(); + let profile_id = id.clone(); + let proxy_service = state.proxy_service.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = proxy_service.stop().await { + log::warn!("切换项目后停止代理服务失败: {e}"); + } + if let Some(app_state) = app_handle.try_state::() { + emit_profile_apply_events(&app_handle, app_state.inner(), &profile_id, scope); + } + }); + } else { + emit_profile_apply_events(&app, &state, &id, scope); + } + + Ok(warnings) +} diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index d61d677fb..6cbee9d2b 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -231,6 +231,14 @@ pub fn ensure_claude_desktop_official_provider(state: State<'_, AppState>) -> Re .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn ensure_codex_official_provider(state: State<'_, AppState>) -> Result { + state + .db + .ensure_official_seed_by_id(crate::database::CODEX_OFFICIAL_PROVIDER_ID, AppType::Codex) + .map_err(|e| e.to_string()) +} + fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool { let Some(env) = provider .settings_config @@ -381,32 +389,30 @@ pub async fn queryProviderUsage( ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; // inner 可能以两种形式失败: - // 1) 返回 Ok(UsageResult { success: false, .. }) —— 业务失败(401、脚本报错等) - // 2) 返回 Err(String) —— RPC/DB/Copilot fetch_usage 等 transport 层失败 - // 两种都要把"失败"写进 UsageCache 并刷新托盘,让 format_script_summary 的 - // success 守卫生效、suffix 自然消失,避免旧 success 快照长期滞留。 - // 同时保持原始 Err 返回给前端 React Query 的 onError 回调,不吞错误。 + // 1) 返回 Ok(UsageResult { success: false, .. }) —— 确定性失败(401、脚本 + // 报错、未知供应商等)。写进 UsageCache 并刷新托盘,让 + // format_script_summary 的 success 守卫生效、suffix 自然消失。 + // 2) 返回 Err(String) —— 瞬时传输失败(网络/超时)及 DB/Copilot fetch 等。 + // 不写失败快照、不 emit:保留上一份托盘快照,与前端 react-query reject + // 保留上次 data 的语义一致;否则失败快照会经 useUsageCacheBridge 盲写 + // 回 query 缓存,抹掉 reject 本该保留的旧值。 let inner = query_provider_usage_inner(&state, &copilot_state, app_type.clone(), &providerId).await; - let snapshot = match &inner { - Ok(r) => r.clone(), - Err(err_msg) => crate::provider::UsageResult { - success: false, - data: None, - error: Some(err_msg.clone()), - }, - }; - let payload = serde_json::json!({ - "kind": "script", - "appType": app_type.as_str(), - "providerId": &providerId, - "data": &snapshot, - }); - if let Err(e) = app_handle.emit("usage-cache-updated", payload) { - log::error!("emit usage-cache-updated (script) 失败: {e}"); + if let Ok(snapshot) = &inner { + let payload = serde_json::json!({ + "kind": "script", + "appType": app_type.as_str(), + "providerId": &providerId, + "data": snapshot, + }); + if let Err(e) = app_handle.emit("usage-cache-updated", payload) { + log::error!("emit usage-cache-updated (script) 失败: {e}"); + } + state + .usage_cache + .put_script(app_type, providerId, snapshot.clone()); + crate::tray::schedule_tray_refresh(&app_handle); } - state.usage_cache.put_script(app_type, providerId, snapshot); - crate::tray::schedule_tray_refresh(&app_handle); inner } @@ -518,12 +524,20 @@ async fn query_provider_usage_inner( // 其他供应商为 None,service 层沿用 api_key。 let access_key_id = usage_script.and_then(|s| s.access_key_id.clone()); let secret_access_key = usage_script.and_then(|s| s.secret_access_key.clone()); + // 智谱团队版:显式 provider 标识 + 组织/项目 ID(与个人版智谱 base_url 相同, + // 靠 coding_plan_provider == "zhipu_team" 在 service 层路由)。 + let coding_plan_provider = usage_script.and_then(|s| s.coding_plan_provider.clone()); + let team_organization_id = usage_script.and_then(|s| s.team_organization_id.clone()); + let team_project_id = usage_script.and_then(|s| s.team_project_id.clone()); let quota = crate::services::coding_plan::get_coding_plan_quota( &base_url, &api_key, access_key_id.as_deref(), secret_access_key.as_deref(), + coding_plan_provider.as_deref(), + team_organization_id.as_deref(), + team_project_id.as_deref(), ) .await .map_err(|e| format!("Failed to query coding plan: {e}"))?; @@ -888,9 +902,7 @@ mod import_claude_desktop_tests { None, ); let routes = suggested_claude_desktop_routes(&p).expect("routes built"); - let r = routes - .get("claude-sonnet-4-6") - .expect("sonnet route present"); + let r = routes.get("claude-sonnet-5").expect("sonnet route present"); assert_eq!(r.model, "claude-sonnet-4-5-20250929"); assert!( !r.model.to_ascii_lowercase().contains("[1m]"), @@ -909,9 +921,7 @@ mod import_claude_desktop_tests { None, ); let routes = suggested_claude_desktop_routes(&p).expect("routes built"); - let r = routes - .get("claude-sonnet-4-6") - .expect("sonnet route present"); + let r = routes.get("claude-sonnet-5").expect("sonnet route present"); assert_eq!(r.model, "kimi-k2"); assert_eq!(r.label_override.as_deref(), Some("kimi-k2")); // 默认 provider_type 缺省 → supports_1m_default = true @@ -928,9 +938,7 @@ mod import_claude_desktop_tests { None, ); let routes = suggested_claude_desktop_routes(&p).expect("routes built"); - let r = routes - .get("claude-sonnet-4-6") - .expect("sonnet route present"); + let r = routes.get("claude-sonnet-5").expect("sonnet route present"); assert_eq!(r.model, "kimi-k2"); assert_eq!(r.label_override.as_deref(), Some("Kimi K2")); } @@ -945,9 +953,7 @@ mod import_claude_desktop_tests { Some("github_copilot"), ); let routes = suggested_claude_desktop_routes(&p).expect("routes built"); - let r = routes - .get("claude-sonnet-4-6") - .expect("sonnet route present"); + let r = routes.get("claude-sonnet-5").expect("sonnet route present"); assert_eq!(r.model, "gpt-5-codex"); assert_eq!(r.label_override.as_deref(), Some("gpt-5-codex")); assert_eq!(r.supports_1m, Some(true)); @@ -962,9 +968,7 @@ mod import_claude_desktop_tests { Some("github_copilot"), ); let routes = suggested_claude_desktop_routes(&p).expect("routes built"); - let r = routes - .get("claude-sonnet-4-6") - .expect("sonnet route present"); + let r = routes.get("claude-sonnet-5").expect("sonnet route present"); assert_eq!(r.model, "gpt-5-codex"); assert_eq!(r.label_override.as_deref(), Some("gpt-5-codex")); assert_eq!(r.supports_1m, Some(false)); @@ -982,9 +986,7 @@ mod import_claude_desktop_tests { ); let routes = suggested_claude_desktop_routes(&p).expect("routes built"); assert_eq!(routes.len(), 1, "three aliases → one merged route"); - let r = routes - .get("claude-sonnet-4-6") - .expect("merged route present"); + let r = routes.get("claude-sonnet-5").expect("merged route present"); assert_eq!(r.model, "MiniMax-M2"); assert_eq!(r.label_override.as_deref(), Some("MiniMax-M2")); } @@ -1002,9 +1004,7 @@ mod import_claude_desktop_tests { ); let routes = suggested_claude_desktop_routes(&p).expect("routes built"); assert_eq!(routes.len(), 1); - let r = routes - .get("claude-sonnet-4-6") - .expect("merged route present"); + let r = routes.get("claude-sonnet-5").expect("merged route present"); assert_eq!(r.supports_1m, Some(true)); } @@ -1020,12 +1020,12 @@ mod import_claude_desktop_tests { ); let routes = suggested_claude_desktop_routes(&p).expect("routes built"); assert_eq!(routes.len(), 3); - assert_eq!(routes.get("claude-sonnet-4-6").unwrap().model, "GLM-4.6"); + assert_eq!(routes.get("claude-sonnet-5").unwrap().model, "GLM-4.6"); assert_eq!(routes.get("claude-opus-4-8").unwrap().model, "GLM-4-Air"); assert_eq!(routes.get("claude-haiku-4-5").unwrap().model, "GLM-4-Flash"); assert_eq!( routes - .get("claude-sonnet-4-6") + .get("claude-sonnet-5") .unwrap() .label_override .as_deref(), @@ -1045,7 +1045,7 @@ mod import_claude_desktop_tests { let routes = suggested_claude_desktop_routes(&p).expect("routes built"); assert_eq!(routes.len(), 1); let r = routes - .get("claude-sonnet-4-6") + .get("claude-sonnet-5") .expect("fallback route present"); assert_eq!(r.model, "kimi-k2"); assert_eq!(r.label_override.as_deref(), Some("kimi-k2")); @@ -1060,13 +1060,10 @@ mod import_claude_desktop_tests { None, ); let routes = suggested_claude_desktop_routes(&p).expect("routes built"); - assert!(routes.contains_key("claude-sonnet-4-6")); + assert!(routes.contains_key("claude-sonnet-5")); assert!(!routes.contains_key("claude-claude-sonnet-4-5-20250929")); assert_eq!( - routes - .get("claude-sonnet-4-6") - .expect("route") - .label_override, + routes.get("claude-sonnet-5").expect("route").label_override, None ); } @@ -1098,6 +1095,8 @@ mod native_query_credentials_tests { coding_plan_provider: coding_plan_provider.map(str::to_string), access_key_id: None, secret_access_key: None, + team_organization_id: None, + team_project_id: None, } } diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index 93f62b403..05bc08ae4 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -6,6 +6,7 @@ use crate::error::AppError; use crate::proxy::types::*; use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats}; use crate::store::AppState; +use std::str::FromStr; /// 启动代理服务器(仅启动服务,不接管 Live 配置) #[tauri::command] @@ -22,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 { @@ -279,13 +281,18 @@ pub async fn switch_proxy_provider( app_type: String, provider_id: String, ) -> Result<(), String> { - // Block official providers during proxy takeover + // Codex's built-in official provider can use the client's native OpenAI + // login through takeover. Other official providers remain blocked. let provider = state .db .get_provider_by_id(&provider_id, &app_type) .map_err(|e| format!("读取供应商失败: {e}"))? .ok_or_else(|| format!("供应商不存在: {provider_id}"))?; - if provider.category.as_deref() == Some("official") { + let app = crate::app_config::AppType::from_str(&app_type) + .map_err(|e| format!("无效的应用类型: {e}"))?; + if provider.category.as_deref() == Some("official") + && !crate::services::provider::official_provider_supports_proxy_takeover(&app, &provider) + { return Err( "代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)" .to_string(), diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 8e4c08964..f6cdb8111 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -247,7 +247,7 @@ pub async fn install_update_and_restart(app: AppHandle) -> Result "Windows 更新安装失败: {e}。已执行退出前清理,代理或 Live 接管可能已暂停;请重启应用或重新开启代理后再试。" ) })?; - return Ok(true); + Ok(true) } #[cfg(not(target_os = "windows"))] @@ -661,15 +661,6 @@ pub async fn set_optimizer_config( state: tauri::State<'_, crate::AppState>, config: crate::proxy::types::OptimizerConfig, ) -> Result { - // Validate cache_ttl: only allow known values - match config.cache_ttl.as_str() { - "5m" | "1h" => {} - other => { - return Err(format!( - "Invalid cache_ttl value: '{other}'. Allowed values: '5m', '1h'" - )) - } - } state .db .set_optimizer_config(&config) diff --git a/src-tauri/src/commands/stream_check.rs b/src-tauri/src/commands/stream_check.rs index 71759ce01..e9a2a083a 100644 --- a/src-tauri/src/commands/stream_check.rs +++ b/src-tauri/src/commands/stream_check.rs @@ -72,6 +72,12 @@ pub async fn stream_check_all_providers( let mut results = Vec::new(); for (id, provider) in providers { + // Official OAuth providers intentionally have no user-configured probe + // target. Never turn their runtime adapter defaults into unauthenticated + // network probes against first-party endpoints. + if provider.category.as_deref() == Some("official") { + continue; + } if let Some(ids) = &allowed_ids { if !ids.contains(&id) { continue; diff --git a/src-tauri/src/commands/subscription.rs b/src-tauri/src/commands/subscription.rs index 053045b71..d258a8bd4 100644 --- a/src-tauri/src/commands/subscription.rs +++ b/src-tauri/src/commands/subscription.rs @@ -2,17 +2,19 @@ use std::str::FromStr; use tauri::{Emitter, State}; use crate::app_config::AppType; -use crate::services::subscription::{CredentialStatus, SubscriptionQuota}; +use crate::services::subscription::SubscriptionQuota; use crate::store::AppState; /// 查询官方订阅额度 /// /// 读取 CLI 工具已有的 OAuth 凭据并调用官方 API 获取使用额度。 -/// 结果(无论业务失败还是 transport 层 Err)都会写入 `UsageCache`、通知托盘 -/// 刷新,并 emit `usage-cache-updated`,让前端 React Query 与托盘共享同一份 -/// 最新数据。失败快照写入后 `format_subscription_summary` 会通过 `success=false` -/// 守卫返回 `None`,托盘 suffix 自然消失,避免长期滞留旧配额数字。 -/// Err 原样向前端返回,React Query 的 onError 不会被吞掉。 +/// `Ok`(成功或确定性失败)写入 `UsageCache`、通知托盘刷新并 emit +/// `usage-cache-updated`,让前端 React Query 与托盘共享同一份最新数据;失败 +/// 快照写入后 `format_subscription_summary` 会通过 `success=false` 守卫返回 +/// `None`,托盘 suffix 自然消失,避免长期滞留旧配额数字。 +/// `Err`(瞬时传输失败)不写快照、不 emit:保留上一份托盘快照,与前端 +/// react-query reject 保留上次 data 的语义一致(emit 失败快照会经 +/// `useUsageCacheBridge` 盲写回 query 缓存,抹掉本该保留的旧值)。 #[tauri::command] pub async fn get_subscription_quota( app: tauri::AppHandle, @@ -20,22 +22,21 @@ pub async fn get_subscription_quota( tool: String, ) -> Result { let inner = crate::services::subscription::get_subscription_quota(&tool).await; - let snapshot = match &inner { - Ok(q) => q.clone(), - // transport 层 Err —— 凭据状态不明,用 Valid 表达"凭据没问题,是通信/parse 出错"。 - Err(err_msg) => SubscriptionQuota::error(&tool, CredentialStatus::Valid, err_msg.clone()), - }; - if let Ok(app_type) = AppType::from_str(&tool) { - let payload = serde_json::json!({ - "kind": "subscription", - "appType": app_type.as_str(), - "data": &snapshot, - }); - if let Err(e) = app.emit("usage-cache-updated", payload) { - log::error!("emit usage-cache-updated (subscription) 失败: {e}"); + if let Ok(snapshot) = &inner { + if let Ok(app_type) = AppType::from_str(&tool) { + let payload = serde_json::json!({ + "kind": "subscription", + "appType": app_type.as_str(), + "data": snapshot, + }); + if let Err(e) = app.emit("usage-cache-updated", payload) { + log::error!("emit usage-cache-updated (subscription) 失败: {e}"); + } + state + .usage_cache + .put_subscription(app_type, snapshot.clone()); + crate::tray::schedule_tray_refresh(&app); } - state.usage_cache.put_subscription(app_type, snapshot); - crate::tray::schedule_tray_refresh(&app); } inner } diff --git a/src-tauri/src/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs index 07eb199a3..0bd744ba6 100644 --- a/src-tauri/src/database/dao/mcp.rs +++ b/src-tauri/src/database/dao/mcp.rs @@ -13,7 +13,7 @@ impl Database { pub fn get_all_mcp_servers(&self) -> Result, 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, ], diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index 93ca2f578..bb78f3075 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -4,6 +4,7 @@ pub mod failover; pub mod mcp; +pub mod profiles; pub mod prompts; pub mod providers; pub mod providers_seed; @@ -15,5 +16,6 @@ pub mod universal_providers; pub mod usage_rollup; // 所有 DAO 方法都通过 Database impl 提供,无需单独导出 -// 导出 FailoverQueueItem 供外部使用 +// 导出 FailoverQueueItem / Profile 供外部使用 pub use failover::FailoverQueueItem; +pub use profiles::Profile; diff --git a/src-tauri/src/database/dao/profiles.rs b/src-tauri/src/database/dao/profiles.rs new file mode 100644 index 000000000..85e95ef1e --- /dev/null +++ b/src-tauri/src/database/dao/profiles.rs @@ -0,0 +1,207 @@ +//! 项目 Profile 数据访问对象 +//! +//! profiles 表存放全应用共享的项目实体(供应商/MCP/Skills/Prompt 快照), +//! payload 为原始 JSON 文本(按 app 分槽),解析在 service 层进行。 +//! 各应用分组(scope)独立的 current 标记存放于 settings 表(key-value)。 + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use rusqlite::params; + +/// 每个 scope 的 current 标记 key = 前缀 + scope(如 current_profile_id_claude) +const CURRENT_PROFILE_ID_KEY_PREFIX: &str = "current_profile_id_"; + +fn current_profile_key(scope: &str) -> String { + format!("{CURRENT_PROFILE_ID_KEY_PREFIX}{scope}") +} + +/// 项目 Profile 记录(全应用共享,无所属分组) +#[derive(Debug, Clone)] +pub struct Profile { + pub id: String, + pub name: String, + /// 原始 JSON 快照文本(ProfilePayload),解析在 service 层 + pub payload: String, + pub sort_order: Option, + pub created_at: Option, + pub updated_at: Option, +} + +impl Database { + /// 获取所有项目(按 sort_order 优先、created_at 兜底排序) + pub fn get_all_profiles(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare( + "SELECT id, name, payload, sort_order, created_at, updated_at + FROM profiles + ORDER BY sort_order IS NULL, sort_order, created_at, id", + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + let rows = stmt + .query_map([], |row| { + Ok(Profile { + id: row.get(0)?, + name: row.get(1)?, + payload: row.get(2)?, + sort_order: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut profiles = Vec::new(); + for row in rows { + profiles.push(row.map_err(|e| AppError::Database(e.to_string()))?); + } + Ok(profiles) + } + + /// 获取单个项目 + pub fn get_profile(&self, id: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare( + "SELECT id, name, payload, sort_order, created_at, updated_at + FROM profiles WHERE id = ?1", + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + match stmt.query_row(params![id], |row| { + Ok(Profile { + id: row.get(0)?, + name: row.get(1)?, + payload: row.get(2)?, + sort_order: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) + }) { + Ok(profile) => Ok(Some(profile)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(AppError::Database(e.to_string())), + } + } + + /// 保存项目(插入或整行替换) + pub fn save_profile(&self, profile: &Profile) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "INSERT OR REPLACE INTO profiles + (id, name, payload, sort_order, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + profile.id, + profile.name, + profile.payload, + profile.sort_order, + profile.created_at, + profile.updated_at, + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 删除项目,返回是否实际删除了记录 + pub fn delete_profile(&self, id: &str) -> Result { + let conn = lock_conn!(self.conn); + let affected = conn + .execute("DELETE FROM profiles WHERE id = ?1", params![id]) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(affected > 0) + } + + /// 读取某分组当前激活的项目 id(未使用项目时为 None) + pub fn get_current_profile_id(&self, scope: &str) -> Result, AppError> { + self.get_setting(¤t_profile_key(scope)) + } + + /// 设置某分组当前激活的项目 id;None 表示"不使用项目"(删除 key) + pub fn set_current_profile_id(&self, scope: &str, id: Option<&str>) -> Result<(), AppError> { + let key = current_profile_key(scope); + match id { + Some(id) => self.set_setting(&key, id), + None => { + let conn = lock_conn!(self.conn); + conn.execute("DELETE FROM settings WHERE key = ?1", params![key]) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(id: &str, name: &str, sort_order: Option) -> Profile { + Profile { + id: id.to_string(), + name: name.to_string(), + payload: r#"{"providers":{"claude":null,"codex":null}}"#.to_string(), + sort_order, + created_at: Some(1_000), + updated_at: Some(1_000), + } + } + + #[test] + fn test_profile_crud_roundtrip() -> Result<(), AppError> { + let db = Database::memory()?; + + db.save_profile(&sample("a", "Dev", Some(2)))?; + db.save_profile(&sample("b", "Draw", Some(1)))?; + db.save_profile(&sample("c", "Misc", None))?; + + // sort_order 优先,NULL 排最后 + let all = db.get_all_profiles()?; + assert_eq!( + all.iter().map(|p| p.id.as_str()).collect::>(), + vec!["b", "a", "c"] + ); + + let got = db.get_profile("a")?.expect("profile a exists"); + assert_eq!(got.name, "Dev"); + assert!(got.payload.contains("providers")); + + // 整行替换更新 + let mut updated = sample("a", "Dev Renamed", Some(2)); + updated.updated_at = Some(2_000); + db.save_profile(&updated)?; + let got = db.get_profile("a")?.expect("profile a exists"); + assert_eq!(got.name, "Dev Renamed"); + assert_eq!(got.updated_at, Some(2_000)); + + assert!(db.delete_profile("a")?); + assert!(!db.delete_profile("a")?); + assert!(db.get_profile("a")?.is_none()); + Ok(()) + } + + #[test] + fn test_current_profile_id_is_scoped() -> Result<(), AppError> { + let db = Database::memory()?; + + assert_eq!(db.get_current_profile_id("claude")?, None); + assert_eq!(db.get_current_profile_id("codex")?, None); + + // 两个分组的标记互不影响 + db.set_current_profile_id("claude", Some("a"))?; + db.set_current_profile_id("codex", Some("b"))?; + assert_eq!(db.get_current_profile_id("claude")?, Some("a".to_string())); + assert_eq!(db.get_current_profile_id("codex")?, Some("b".to_string())); + + db.set_current_profile_id("claude", None)?; + assert_eq!(db.get_current_profile_id("claude")?, None); + assert_eq!(db.get_current_profile_id("codex")?, Some("b".to_string())); + + // 重复清除应幂等 + db.set_current_profile_id("claude", None)?; + assert_eq!(db.get_current_profile_id("claude")?, None); + Ok(()) + } +} diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index b86611348..1d8ab4d10 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -710,7 +710,9 @@ impl Database { #[cfg(test)] mod ensure_official_seed_tests { use crate::app_config::AppType; - use crate::database::{Database, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID}; + use crate::database::{ + Database, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID, + }; #[test] fn ensure_inserts_when_missing() { @@ -769,6 +771,25 @@ mod ensure_official_seed_tests { ); } + #[test] + fn ensure_recreates_codex_official_seed_after_deletion() { + let db = Database::memory().expect("memory db"); + db.init_default_official_providers().expect("seed"); + db.delete_provider(AppType::Codex.as_str(), CODEX_OFFICIAL_PROVIDER_ID) + .expect("delete Codex official"); + + let inserted = db + .ensure_official_seed_by_id(CODEX_OFFICIAL_PROVIDER_ID, AppType::Codex) + .expect("ensure Codex official"); + assert!(inserted); + let provider = db + .get_provider_by_id(CODEX_OFFICIAL_PROVIDER_ID, AppType::Codex.as_str()) + .expect("query") + .expect("Codex official restored"); + assert_eq!(provider.category.as_deref(), Some("official")); + assert_eq!(provider.settings_config["auth"], serde_json::json!({})); + } + #[test] fn ensure_rejects_unknown_seed() { let db = Database::memory().expect("memory db"); diff --git a/src-tauri/src/database/dao/providers_seed.rs b/src-tauri/src/database/dao/providers_seed.rs index 2babe36e2..8091a1031 100644 --- a/src-tauri/src/database/dao/providers_seed.rs +++ b/src-tauri/src/database/dao/providers_seed.rs @@ -11,6 +11,7 @@ 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) struct OfficialProviderSeed { @@ -49,7 +50,7 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[ settings_config_json: r#"{"env":{}}"#, }, OfficialProviderSeed { - id: "codex-official", + id: CODEX_OFFICIAL_PROVIDER_ID, app_type: AppType::Codex, name: "OpenAI Official", website_url: "https://chatgpt.com/codex", diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index c3c31cf7e..b2f37c126 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -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(()) } @@ -494,6 +507,23 @@ impl Database { Ok(count > 0) } + /// 同步版本:检查是否有任一 app 的 enabled = true + /// + /// 用于 `ProfileService::apply` 等 sync 路径判断是否需要停止代理服务。 + pub fn is_live_takeover_active_sync(&self) -> bool { + let conn = match self.conn.lock() { + Ok(c) => c, + Err(_) => return false, + }; + conn.query_row( + "SELECT COUNT(*) FROM proxy_config WHERE enabled = 1", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0) + > 0 + } + // ==================== Provider Health ==================== /// 获取Provider健康状态 diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 9c94eb5ec..488fde29d 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -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 { - // 获取已有仓库列表 - 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) } } diff --git a/src-tauri/src/database/dao/usage_rollup.rs b/src-tauri/src/database/dao/usage_rollup.rs index eaa472798..1ca576c5d 100644 --- a/src-tauri/src/database/dao/usage_rollup.rs +++ b/src-tauri/src/database/dao/usage_rollup.rs @@ -4,6 +4,7 @@ use crate::database::{lock_conn, Database}; use crate::error::AppError; +use crate::services::sql_helpers::{fresh_input_sql, INPUT_TOKEN_SEMANTICS_FRESH}; use crate::services::usage_stats::effective_usage_log_filter; use chrono::{Duration, Local, TimeZone}; @@ -115,6 +116,8 @@ impl Database { fn do_rollup_and_prune(conn: &rusqlite::Connection, cutoff: i64) -> Result { // Aggregate old logs, merging with any pre-existing rollup rows via LEFT JOIN. let effective_filter = effective_usage_log_filter("l"); + let fresh_detail_input = fresh_input_sql("l"); + let fresh_old_input = fresh_input_sql("old"); // request_model 维度保留路由接管的「客户端别名 → 真实模型」映射, // pricing_model 维度保留写入时的计价基准(request 计价模式下与 model 分叉); // 明细行的这两列可能为 NULL(历史/手工数据),归一为 ''。 @@ -124,15 +127,16 @@ impl Database { request_count, success_count, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, - total_cost_usd, avg_latency_ms) + input_token_semantics, total_cost_usd, avg_latency_ms) SELECT d, a, p, m, rm, pm, COALESCE(old.request_count, 0) + new_req, COALESCE(old.success_count, 0) + new_succ, - COALESCE(old.input_tokens, 0) + new_in, + COALESCE({fresh_old_input}, 0) + new_in, COALESCE(old.output_tokens, 0) + new_out, COALESCE(old.cache_read_tokens, 0) + new_cr, COALESCE(old.cache_creation_tokens, 0) + new_cc, + {INPUT_TOKEN_SEMANTICS_FRESH}, CAST(COALESCE(CAST(old.total_cost_usd AS REAL), 0) + new_cost AS TEXT), CASE WHEN COALESCE(old.request_count, 0) + new_req > 0 THEN (COALESCE(old.avg_latency_ms, 0) * COALESCE(old.request_count, 0) @@ -147,7 +151,7 @@ impl Database { COALESCE(l.pricing_model, '') as pm, COUNT(*) as new_req, SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END) as new_succ, - COALESCE(SUM(l.input_tokens), 0) as new_in, + COALESCE(SUM({fresh_detail_input}), 0) as new_in, COALESCE(SUM(l.output_tokens), 0) as new_out, COALESCE(SUM(l.cache_read_tokens), 0) as new_cr, COALESCE(SUM(l.cache_creation_tokens), 0) as new_cc, @@ -327,7 +331,7 @@ mod tests { let (provider_id, request_count, input_tokens, output_tokens, cache_read_tokens) = &rows[0]; assert_eq!(provider_id, "openai"); assert_eq!(*request_count, 1); - assert_eq!(*input_tokens, 100); + assert_eq!(*input_tokens, 90, "rollup stores normalized fresh input"); assert_eq!(*output_tokens, 20); assert_eq!(*cache_read_tokens, 10); @@ -340,6 +344,40 @@ mod tests { Ok(()) } + #[test] + fn test_rollup_normalizes_total_cache_semantics_to_fresh() -> Result<(), AppError> { + let db = Database::memory()?; + let old_ts = chrono::Utc::now().timestamp() - 40 * 86400; + + { + let conn = crate::database::lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + input_token_semantics, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES ('total-semantics-rollup', 'p1', 'codex', 'gpt-5.5', + 100, 5, 10, 20, 1, '0.10', 100, 200, ?1)", + [old_ts], + )?; + } + + assert_eq!(db.rollup_and_prune(30)?, 1); + + let conn = crate::database::lock_conn!(db.conn); + let row: (i64, i64, i64, i64) = conn.query_row( + "SELECT input_tokens, cache_read_tokens, cache_creation_tokens, + input_token_semantics + FROM usage_daily_rollups WHERE model = 'gpt-5.5'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + )?; + assert_eq!(row, (70, 10, 20, 2)); + + Ok(()) + } + #[test] fn test_rollup_preserves_request_model_dimension() -> Result<(), AppError> { let db = Database::memory()?; diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index a92713210..fb0f8969e 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -32,12 +32,15 @@ mod schema; mod tests; // DAO 类型导出供外部使用 -pub(crate) use dao::providers_seed::{is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID}; +pub(crate) use dao::providers_seed::{ + is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID, +}; pub(crate) use dao::proxy::{ validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST, PRICING_SOURCE_RESPONSE, }; pub use dao::FailoverQueueItem; +pub use dao::Profile; use crate::config::get_app_config_dir; use crate::error::AppError; @@ -49,7 +52,7 @@ use std::sync::Mutex; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 11; +pub(crate) const SCHEMA_VERSION: i32 = 15; /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 604177b40..a26ecdd32 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -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 表 @@ -189,6 +200,7 @@ impl Database { pricing_model TEXT, input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + input_token_semantics INTEGER NOT NULL DEFAULT 0, input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0', cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0', total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER, @@ -275,6 +287,7 @@ impl Database { output_tokens INTEGER NOT NULL DEFAULT 0, cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + input_token_semantics INTEGER NOT NULL DEFAULT 0, total_cost_usd TEXT NOT NULL DEFAULT '0', avg_latency_ms INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model) @@ -295,6 +308,35 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; + // 19. Profiles 表(全应用共享的项目实体,payload 按 app 分槽快照 + // 供应商/MCP/Skills/Prompt;各应用分组的 current 标记在 settings 表) + conn.execute( + "CREATE TABLE IF NOT EXISTS profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + payload TEXT NOT NULL, + sort_order INTEGER, + created_at INTEGER, + updated_at INTEGER + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 修复跑过未发布开发版的库:current 标记曾是全局 key,现按应用分组 + // (随 v12 定稿为 current_profile_id_,不单独 bump 版本) + if conn + .execute( + "INSERT OR REPLACE INTO settings (key, value) + SELECT 'current_profile_id_claude', value FROM settings + WHERE key = 'current_profile_id'", + [], + ) + .is_ok() + { + let _ = conn.execute("DELETE FROM settings WHERE key = 'current_profile_id'", []); + } + // 尝试添加 live_takeover_active 列到 proxy_config 表 let _ = conn.execute( "ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0", @@ -444,6 +486,26 @@ impl Database { Self::migrate_v10_to_v11(conn)?; Self::set_user_version(conn, 11)?; } + 11 => { + log::info!("迁移数据库从 v11 到 v12(添加项目 Profiles 表)"); + Self::migrate_v11_to_v12(conn)?; + Self::set_user_version(conn, 12)?; + } + 12 => { + log::info!("迁移数据库从 v12 到 v13(记录输入 token 缓存语义)"); + 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)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -616,6 +678,7 @@ impl Database { request_model TEXT, input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + input_token_semantics INTEGER NOT NULL DEFAULT 0, input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0', cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0', total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER, @@ -756,12 +819,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, @@ -772,6 +848,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')) )", [])?; @@ -1270,6 +1347,169 @@ impl Database { Ok(()) } + /// v11 -> v12 迁移:添加项目 Profiles 表 + /// 与 create_tables_on_conn 中的建表语句保持一致(IF NOT EXISTS 保证幂等) + fn migrate_v11_to_v12(conn: &Connection) -> Result<(), AppError> { + conn.execute( + "CREATE TABLE IF NOT EXISTS profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + payload TEXT NOT NULL, + sort_order INTEGER, + created_at INTEGER, + updated_at INTEGER + )", + [], + ) + .map_err(|e| AppError::Database(format!("v11 -> v12 创建 profiles 表失败: {e}")))?; + Ok(()) + } + + /// v12 -> v13:记录 input_tokens 是否包含缓存写入。 + /// + /// 默认 0 表示旧版/未知语义;旧 Codex 行只包含 cache read,不包含 + /// cache creation。新代理行会显式写入 1(total-inclusive) 或 2(fresh)。 + fn migrate_v12_to_v13(conn: &Connection) -> Result<(), AppError> { + if Self::table_exists(conn, "proxy_request_logs")? { + Self::add_column_if_missing( + conn, + "proxy_request_logs", + "input_token_semantics", + "INTEGER NOT NULL DEFAULT 0", + )?; + } + if Self::table_exists(conn, "usage_daily_rollups")? { + Self::add_column_if_missing( + conn, + "usage_daily_rollups", + "input_token_semantics", + "INTEGER NOT NULL DEFAULT 0", + )?; + } + 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::, 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(()) + } + /// 插入默认模型定价数据 /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 @@ -1301,6 +1541,15 @@ impl Database { "0.50", "6.25", ), + // Claude Sonnet 5(list 价,与 Sonnet 4.6 一致;促销 $2/$10 至 2026-08-31 不入表) + ( + "claude-sonnet-5", + "Claude Sonnet 5", + "3", + "15", + "0.30", + "3.75", + ), // Claude 4.7 系列 ( "claude-opus-4-7", @@ -1394,6 +1643,25 @@ impl Database { "0.30", "3.75", ), + // GPT-5.6 系列(Sol / Terra / Luna,2026-06 发布) + // 5.6 家族起 cache write 收 1.25× 输入价(此前 GPT 模型写缓存免费,勿回填旧系列) + ("gpt-5.6-sol", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), + ( + "gpt-5.6-terra", + "GPT-5.6 Terra", + "2.50", + "15", + "0.25", + "3.125", + ), + ("gpt-5.6-luna", "GPT-5.6 Luna", "1", "6", "0.10", "1.25"), + // 裸名 gpt-5.6 是 sol 的官方别名;effort 后缀对齐 gpt-5.5 系列的记账形态 + ("gpt-5.6", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), + ("gpt-5.6-low", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), + ("gpt-5.6-medium", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), + ("gpt-5.6-high", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), + ("gpt-5.6-xhigh", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), + ("gpt-5.6-minimal", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), // GPT-5.5 系列 ("gpt-5.5", "GPT-5.5", "5", "30", "0.50", "0"), ("gpt-5.5-low", "GPT-5.5", "5", "30", "0.50", "0"), @@ -1831,6 +2099,9 @@ impl Database { "0.19", "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"), // MiniMax 系列 ("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"), ( @@ -2126,6 +2397,44 @@ impl Database { fn repair_current_model_pricing(conn: &Connection) -> Result<(), AppError> { let pricing_fixes = [ + // 2026-07-12 GPT-5.6 家族 cache write=1.25× 输入价(OpenAI 5.6 起的新规), + // 修正早期 seed 的 0 值;只匹配未被用户改过的行 + ( + "gpt-5.6-sol", + "GPT-5.6 Sol", + "5", + "30", + "0.50", + "6.25", + "5", + "30", + "0.50", + "0", + ), + ( + "gpt-5.6-terra", + "GPT-5.6 Terra", + "2.50", + "15", + "0.25", + "3.125", + "2.50", + "15", + "0.25", + "0", + ), + ( + "gpt-5.6-luna", + "GPT-5.6 Luna", + "1", + "6", + "0.10", + "1.25", + "1", + "6", + "0.10", + "0", + ), // 2026-06-10 全量核价(厂商官方 list 价;CNY 按 ~7.14 折算) // GLM 4.6/4.7:旧值是中转/OpenRouter 折扣价,统一到 Z.ai 官方(与 glm-5/5.1 一致) ( @@ -2593,3 +2902,123 @@ impl Database { Ok(true) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn migrate_v12_to_v13_adds_input_token_semantics_columns() -> Result<(), AppError> { + let conn = Connection::open_in_memory()?; + conn.execute( + "CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY)", + [], + )?; + conn.execute( + "CREATE TABLE usage_daily_rollups (date TEXT PRIMARY KEY)", + [], + )?; + Database::set_user_version(&conn, 12)?; + + Database::apply_schema_migrations_on_conn(&conn)?; + + assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION); + assert!(Database::has_column( + &conn, + "proxy_request_logs", + "input_token_semantics" + )?); + assert!(Database::has_column( + &conn, + "usage_daily_rollups", + "input_token_semantics" + )?); + let log_default: i64 = conn.query_row( + "SELECT dflt_value = '0' FROM pragma_table_info('proxy_request_logs') + WHERE name = 'input_token_semantics'", + [], + |row| row.get(0), + )?; + assert_eq!(log_default, 1); + + 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(()) + } +} diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index c979ee731..e6c39dc9b 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -151,6 +151,38 @@ fn normalize_default(default: &Option) -> Option { .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"); @@ -426,6 +458,62 @@ fn migration_v10_to_v11_rebuilds_rollups_with_request_model_dimension() { ); } +#[test] +fn schema_create_tables_repairs_dev_global_profile_marker() { + let conn = Connection::open_in_memory().expect("open memory db"); + + // 模拟跑过未发布开发版的库:user_version 已是 12(迁移不会再跑), + // 但 current 标记还是全局 key(现按应用分组) + conn.execute_batch( + r#" + CREATE TABLE profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + payload TEXT NOT NULL, + sort_order INTEGER, + created_at INTEGER, + updated_at INTEGER + ); + INSERT INTO profiles (id, name, payload) VALUES ('p1', 'Project A', '{}'); + CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT); + INSERT INTO settings (key, value) VALUES ('current_profile_id', 'p1'); + "#, + ) + .expect("seed dev v12 shape"); + Database::set_user_version(&conn, 12).expect("set user_version=12"); + + Database::create_tables_on_conn(&conn).expect("create tables should repair marker"); + + // 全局 current 标记改名为 claude 组标记,旧 key 删除 + let claude_marker: String = conn + .query_row( + "SELECT value FROM settings WHERE key = 'current_profile_id_claude'", + [], + |row| row.get(0), + ) + .expect("scoped current marker"); + assert_eq!(claude_marker, "p1"); + let old_marker: i64 = conn + .query_row( + "SELECT COUNT(*) FROM settings WHERE key = 'current_profile_id'", + [], + |row| row.get(0), + ) + .expect("count old marker"); + assert_eq!(old_marker, 0); + + // 修复必须幂等:再跑一遍不应破坏已迁移的标记 + Database::create_tables_on_conn(&conn).expect("repair is idempotent"); + let claude_marker: String = conn + .query_row( + "SELECT value FROM settings WHERE key = 'current_profile_id_claude'", + [], + |row| row.get(0), + ) + .expect("scoped current marker survives"); + assert_eq!(claude_marker, "p1"); +} + #[test] fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() { let conn = Connection::open_in_memory().expect("open memory db"); @@ -461,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 @@ -601,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 diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs index 822cd839d..e768e3494 100644 --- a/src-tauri/src/deeplink/mcp.rs +++ b/src-tauri/src/deeplink/mcp.rs @@ -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 { 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 { "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 { 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); + } +} diff --git a/src-tauri/src/deeplink/parser.rs b/src-tauri/src/deeplink/parser.rs index aca40fec6..ee854ff4f 100644 --- a/src-tauri/src/deeplink/parser.rs +++ b/src-tauri/src/deeplink/parser.rs @@ -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}'" ))); } } diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index d41e4654d..193f29ca5 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -145,6 +145,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), @@ -267,6 +268,8 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result 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); @@ -598,6 +631,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)?; @@ -772,6 +806,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 diff --git a/src-tauri/src/deeplink/tests.rs b/src-tauri/src/deeplink/tests.rs index 8ea4b2267..54681b444 100644 --- a/src-tauri/src/deeplink/tests.rs +++ b/src-tauri/src/deeplink/tests.rs @@ -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::().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"; diff --git a/src-tauri/src/grok_config.rs b/src-tauri/src/grok_config.rs new file mode 100644 index 000000000..687eabe2c --- /dev/null +++ b/src-tauri/src/grok_config.rs @@ -0,0 +1,516 @@ +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, + pub env_key: Option, + 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 { + table + .get(key) + .and_then(toml::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +/// Validate the provider-owned Grok Build TOML document. +pub fn validate_config_toml(config_toml: &str) -> Result<(), AppError> { + let document = config_toml.parse::().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.]", + "Grok Build configuration is missing [model.]", + ) + })?; + 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 { + let document = config_toml.parse::().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 { + extract_model_config(config_toml)?.api_key +} + +pub fn extract_base_url(config_toml: &str) -> Option { + Some(extract_model_config(config_toml)?.base_url) +} + +fn update_selected_model_string( + config_toml: &str, + field: &str, + value: &str, +) -> Result { + let mut document = config_toml + .parse::() + .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 { + 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 { + 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::() + .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(()) +} + +pub fn read_grok_live_settings() -> Result { + 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(&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", + ) + })?; + + write_grok_live_settings(&json!({ "config": config })) +} + +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(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 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 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"), + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5942ba785..f2bb94aa9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod claude_mcp; mod claude_plugin; mod codex_config; mod codex_history_migration; +mod codex_state_db; mod commands; mod config; mod database; @@ -13,12 +14,14 @@ mod deeplink; mod error; mod gemini_config; mod gemini_mcp; +mod grok_config; pub mod hermes_config; mod init_status; mod lightweight; #[cfg(target_os = "linux")] mod linux_fix; mod mcp; +mod model_capabilities; mod openclaw_config; mod opencode_config; mod panic_hook; @@ -41,17 +44,21 @@ pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_l pub use commands::open_provider_terminal; pub use commands::*; pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; -pub use database::Database; +pub use database::{Database, Profile}; pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest}; pub use error::AppError; 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}; pub use services::{ + profile::{ProfilePayload, ProfileScope, ProfileService}, + provider::reapply_current_codex_official_live, skill::{migrate_skills_to_ssot, ImportSkillSelection}, ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService, SkillService, SpeedtestService, @@ -669,32 +676,33 @@ pub fn run() { // 1.6. 自动同步 OpenCode / OpenClaw 的 live providers 到数据库 // - // additive 模式(OpenCode / OpenClaw)的 import 函数本身按 id 幂等, - // 已有的 provider 会被跳过,所以每次启动都跑是安全的——既保证新装 - // 用户开箱可见 live 中的供应商,也让外部修改的 live 文件能在重启 - // 后同步到数据库(与之前依赖前端"导入当前配置"按钮手动触发不同)。 + // additive 模式(OpenCode / OpenClaw)的 import 函数按 id 幂等—— + // 新 id 执行导入,已有 id 则更新 settings 和 display name,所以每次 + // 启动都跑是安全的:既保证新装用户开箱可见 live 中的供应商,也让外部 + // 修改的 live 文件能在重启后同步到数据库(与之前依赖前端"导入当前配置" + // 按钮手动触发不同)。 // // 底层 read_*_config 在文件不存在时返回默认空配置,因此新装且无 // live 文件的用户走 Ok(0) 路径,不会产生错误日志噪音。 match crate::services::provider::import_opencode_providers_from_live(&app_state) { Ok(count) if count > 0 => { - log::info!("✓ Imported {count} OpenCode provider(s) from live config"); + log::info!("✓ Synced {count} OpenCode provider(s) from live config"); } - Ok(_) => log::debug!("○ No new OpenCode providers to import"), + Ok(_) => log::debug!("○ No OpenCode provider changes from live config"), Err(e) => log::warn!("✗ Failed to import OpenCode providers: {e}"), } match crate::services::provider::import_openclaw_providers_from_live(&app_state) { Ok(count) if count > 0 => { - log::info!("✓ Imported {count} OpenClaw provider(s) from live config"); + log::info!("✓ Synced {count} OpenClaw provider(s) from live config"); } - Ok(_) => log::debug!("○ No new OpenClaw providers to import"), + Ok(_) => log::debug!("○ No OpenClaw provider changes from live config"), Err(e) => log::warn!("✗ Failed to import OpenClaw providers: {e}"), } match crate::services::provider::import_hermes_providers_from_live(&app_state) { Ok(count) if count > 0 => { - log::info!("✓ Imported {count} Hermes provider(s) from live config"); + log::info!("✓ Synced {count} Hermes provider(s) from live config"); } - Ok(_) => log::debug!("○ No new Hermes providers to import"), + Ok(_) => log::debug!("○ No Hermes provider changes from live config"), Err(e) => log::warn!("✗ Failed to import Hermes providers: {e}"), } @@ -777,6 +785,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"); @@ -802,6 +818,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, @@ -1192,6 +1209,7 @@ pub fn run() { commands::get_claude_desktop_default_routes, commands::import_claude_desktop_providers_from_claude, commands::ensure_claude_desktop_official_provider, + commands::ensure_codex_official_provider, commands::get_claude_config_status, commands::get_config_status, commands::get_claude_code_config_path, @@ -1208,6 +1226,7 @@ pub fn run() { commands::set_claude_common_config_snippet, commands::get_common_config_snippet, commands::set_common_config_snippet, + commands::update_toml_common_config_snippet, commands::extract_common_config_snippet, commands::read_live_provider_settings, commands::get_settings, @@ -1267,6 +1286,13 @@ pub fn run() { commands::enable_prompt, commands::import_prompt_from_file, commands::get_current_prompt_file_content, + // Profile management (项目配置方案) + commands::list_profiles, + commands::create_profile, + commands::update_profile, + commands::delete_profile, + commands::clear_current_profile, + commands::apply_profile, // model list fetch (OpenAI-compatible /v1/models) commands::fetch_models_for_config, // ours: endpoint speed test + custom endpoint management @@ -1723,16 +1749,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!("启动时无需恢复代理状态"); @@ -2050,7 +2085,8 @@ 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, ExitRequestAction}; + use crate::database::Database; #[test] fn no_code_keeps_app_alive_in_tray() { @@ -2076,4 +2112,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"]); + } } diff --git a/src-tauri/src/mcp/claude.rs b/src-tauri/src/mcp/claude.rs index e1971fb73..d55c2de4d 100644 --- a/src-tauri/src/mcp/claude.rs +++ b/src-tauri/src/mcp/claude.rs @@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result claude: false, codex: true, gemini: false, + grokbuild: false, opencode: false, hermes: false, }, @@ -361,14 +362,11 @@ pub fn sync_single_server_to_codex( let mut doc = if config_path.exists() { let content = std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; - // 尝试解析现有配置,如果失败则创建新文档(容错处理) - match content.parse::() { - Ok(doc) => doc, - Err(e) => { - log::warn!("解析 Codex config.toml 失败: {e},将创建新配置"); - toml_edit::DocumentMut::new() - } - } + // 解析失败必须报错而不是用空文档顶替:写回空文档会把用户 + // config.toml 里的其它段落(model/model_providers/注释等)整体清空 + content + .parse::() + .map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {e}")))? } else { toml_edit::DocumentMut::new() }; @@ -559,7 +557,7 @@ fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option Result { +pub(super) fn json_server_to_toml_table(spec: &Value) -> Result { use toml_edit::{Array, Item, Table}; let mut t = Table::new(); diff --git a/src-tauri/src/mcp/gemini.rs b/src-tauri/src/mcp/gemini.rs index 73c2fc840..7b9873037 100644 --- a/src-tauri/src/mcp/gemini.rs +++ b/src-tauri/src/mcp/gemini.rs @@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result bool { + crate::grok_config::get_grok_config_dir().exists() +} + +fn read_config_text() -> Result { + 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 { + 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 { + 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::>(), + )), + 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 { + 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::().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::() { + 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") + ); + } +} diff --git a/src-tauri/src/mcp/hermes.rs b/src-tauri/src/mcp/hermes.rs index 612a10973..24683cb41 100644 --- a/src-tauri/src/mcp/hermes.rs +++ b/src-tauri/src/mcp/hermes.rs @@ -315,6 +315,7 @@ pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result Result, + use_confirmed_registry: bool, +) -> ImageInputCapability { + match declared_support { + Some(true) => ImageInputCapability::Supported, + Some(false) => ImageInputCapability::Unsupported, + None if use_confirmed_registry && is_confirmed_text_only_model(model) => { + ImageInputCapability::Unsupported + } + None => ImageInputCapability::Unknown, + } +} + +/// Resolve a model's image-input capability from the provider settings shapes +/// accepted by the proxy (`modelCatalog.models`, `modelCatalog`, or `models`). +pub(crate) fn image_input_capability_from_settings( + settings: &Value, + model: &str, + use_confirmed_registry: bool, +) -> ImageInputCapability { + resolve_image_input_capability( + model, + declared_model_image_support(settings, model), + use_confirmed_registry, + ) +} + +/// Convert a catalog row's explicit modality list into the shared capability +/// representation, falling back to the text-only registry when omitted. +pub(crate) fn image_input_capability_from_modalities( + model: &str, + modalities: Option<&[String]>, +) -> ImageInputCapability { + let declared_support = modalities.map(|items| { + items + .iter() + .any(|item| item.trim().eq_ignore_ascii_case("image")) + }); + resolve_image_input_capability(model, declared_support, true) +} + +/// Models that CC Switch is willing to advertise to clients as text-only. +/// +/// This registry is deliberately exact and fail-open. A new suffix is not +/// inherited automatically: it remains image-capable until its capability is +/// confirmed, preventing a future `-vision`/`-vl` variant from being blocked by +/// the Codex client before a request can reach the proxy. +pub(crate) fn is_confirmed_text_only_model(model: &str) -> bool { + let normalized = normalize_model_id(model); + let tail = normalized.rsplit('/').next().unwrap_or(normalized.as_str()); + + const CONFIRMED_TAILS: &[&str] = &[ + "ark-code-latest", + "deepseek-chat", + "deepseek-reasoner", + "deepseek-v4-flash", + "deepseek-v4-pro", + "glm-5.1", + // Exact rather than prefix matching: GLM visual models use a `v` + // suffix (for example glm-5.2v), which must remain image-capable. + "glm-5.2", + "kat-coder", + "kat-coder-pro", + "kat-coder-pro v1", + "kat-coder-pro v2", + "kat-coder-pro-v1", + "kat-coder-pro-v2", + "ling-2.5-1t", + "longcat-2.0", + "longcat-flash-chat", + "minimax-m2.7", + "minimax-m2.7-highspeed", + "mimo-v2.5-pro", + "qwen3-coder-480b", + "qwen3-coder-480b-a35b-instruct", + "qwen3-coder-flash", + "qwen3-coder-next", + "qwen3-coder-plus", + "step-3.5-flash", + "step-3.5-flash-2603", + "us.deepseek.r1-v1", + ]; + + CONFIRMED_TAILS.contains(&tail) +} + +fn declared_model_image_support(settings: &Value, model: &str) -> Option { + [ + settings + .get("modelCatalog") + .and_then(|catalog| catalog.get("models")), + settings.get("modelCatalog"), + settings.get("models"), + ] + .into_iter() + .flatten() + .find_map(|value| declared_model_image_support_in_value(value, model)) +} + +fn declared_model_image_support_in_value(value: &Value, model: &str) -> Option { + if let Some(models) = value.as_array() { + return models.iter().find_map(|entry| { + model_entry_matches(entry, None, model).then(|| explicit_image_support(entry))? + }); + } + + let object = value.as_object()?; + object.iter().find_map(|(key, entry)| { + model_entry_matches(entry, Some(key), model).then(|| explicit_image_support(entry))? + }) +} + +fn explicit_image_support(entry: &Value) -> Option { + if let Some(value) = entry + .get("supportsImage") + .or_else(|| entry.get("supports_image")) + .or_else(|| entry.get("vision")) + .and_then(Value::as_bool) + { + return Some(value); + } + + [ + entry.get("input"), + entry.pointer("/modalities/input"), + entry.get("input_modalities"), + entry.get("inputModalities"), + ] + .into_iter() + .flatten() + .find_map(input_modalities_support_image) +} + +fn input_modalities_support_image(value: &Value) -> Option { + let modalities = value.as_array()?; + Some(modalities.iter().any(|item| { + item.as_str() + .map(str::trim) + .is_some_and(|item| item.eq_ignore_ascii_case("image")) + })) +} + +fn model_entry_matches(entry: &Value, key: Option<&str>, model: &str) -> bool { + key.is_some_and(|key| model_ids_match(key, model)) + || ["model", "id", "name"] + .into_iter() + .filter_map(|field| entry.get(field).and_then(Value::as_str)) + .any(|candidate| model_ids_match(candidate, model)) +} + +fn model_ids_match(candidate: &str, model: &str) -> bool { + let candidate = normalize_model_id(candidate); + let model = normalize_model_id(model); + if candidate.is_empty() || model.is_empty() { + return false; + } + if candidate == model { + return true; + } + + let candidate_tail = candidate.rsplit('/').next().unwrap_or(candidate.as_str()); + let model_tail = model.rsplit('/').next().unwrap_or(model.as_str()); + candidate_tail == model_tail || candidate == model_tail || candidate_tail == model +} + +fn normalize_model_id(value: &str) -> String { + let mut normalized = value + .trim() + .trim_start_matches("models/") + .trim() + .to_ascii_lowercase(); + if let Some(stripped) = + normalized.strip_suffix(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER) + { + normalized = stripped.trim().to_string(); + } + normalized +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn gpt_and_unknown_models_remain_unknown_without_declarations() { + for model in ["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "custom-alias"] { + assert_eq!( + resolve_image_input_capability(model, None, true), + ImageInputCapability::Unknown, + "{model} must fail open" + ); + } + } + + #[test] + fn confirmed_text_only_registry_normalizes_namespaces_and_context_markers() { + assert!(is_confirmed_text_only_model("deepseek/deepseek-v4-pro")); + assert!(is_confirmed_text_only_model("GLM-5.2[1M]")); + assert!(is_confirmed_text_only_model("qwen/qwen3-coder-plus")); + assert!(is_confirmed_text_only_model( + "Qwen/Qwen3-Coder-480B-A35B-Instruct" + )); + assert!(is_confirmed_text_only_model("MiniMax-M2.7-Highspeed")); + assert!(is_confirmed_text_only_model("step-3.5-flash-2603")); + assert!(!is_confirmed_text_only_model("glm-5.2v")); + } + + #[test] + fn unconfirmed_family_suffixes_fail_open() { + for model in [ + "minimax-m2.7-vision", + "qwen3-coder-ultra", + "qwen3-coder-vl", + "step-3.5-flash-vision", + ] { + assert!( + !is_confirmed_text_only_model(model), + "unconfirmed variant {model} must not be hard-gated" + ); + } + } + + #[test] + fn explicit_capability_overrides_the_registry() { + assert_eq!( + resolve_image_input_capability("deepseek-v4-pro", Some(true), true), + ImageInputCapability::Supported + ); + assert_eq!( + resolve_image_input_capability("gpt-5.4", Some(false), true), + ImageInputCapability::Unsupported + ); + } + + #[test] + fn provider_settings_support_multiple_capability_shapes() { + let settings = json!({ + "modelCatalog": { + "models": [ + { "model": "vision", "modalities": { "input": ["text", "image"] } }, + { "model": "text", "inputModalities": ["text"] } + ] + } + }); + + assert_eq!( + image_input_capability_from_settings(&settings, "vision", true), + ImageInputCapability::Supported + ); + assert_eq!( + image_input_capability_from_settings(&settings, "text", true), + ImageInputCapability::Unsupported + ); + } +} diff --git a/src-tauri/src/prompt_files.rs b/src-tauri/src/prompt_files.rs index 70f350dc8..6d4a44aae 100644 --- a/src-tauri/src/prompt_files.rs +++ b/src-tauri/src/prompt_files.rs @@ -12,9 +12,9 @@ use crate::opencode_config::get_opencode_dir; pub fn prompt_file_path(app: &AppType) -> Result { 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 { 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 { 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"), }; diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 9eb2195dd..6c1c33a34 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -164,6 +164,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")), @@ -259,6 +264,14 @@ pub struct UsageScript { #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "secretAccessKey")] pub secret_access_key: Option, + /// 智谱团队套餐(Team Plan)的组织 ID(用量查询请求头 bigmodel-organization) + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "teamOrganizationId")] + pub team_organization_id: Option, + /// 智谱团队套餐(Team Plan)的项目 ID(用量查询请求头 bigmodel-project) + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "teamProjectId")] + pub team_project_id: Option, } /// 用量数据 @@ -295,26 +308,6 @@ pub struct UsageResult { pub error: Option, } -/// 供应商单独的连通检测配置 -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ProviderTestConfig { - /// 是否启用单独配置(false 时使用全局配置) - #[serde(default)] - pub enabled: bool, - /// 超时时间(秒) - #[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")] - pub timeout_secs: Option, - /// 降级阈值(毫秒) - #[serde( - rename = "degradedThresholdMs", - skip_serializing_if = "Option::is_none" - )] - pub degraded_threshold_ms: Option, - /// 最大重试次数 - #[serde(rename = "maxRetries", skip_serializing_if = "Option::is_none")] - pub max_retries: Option, -} - /// 认证绑定来源 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] @@ -446,9 +439,6 @@ pub struct ProviderMeta { /// 每月消费限额(USD) #[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")] pub limit_monthly_usd: Option, - /// 供应商单独的模型测试配置 - #[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")] - pub test_config: Option, /// Claude API 格式(仅 Claude 供应商使用) /// - "anthropic": 原生 Anthropic Messages API,直接透传 /// - "openai_chat": OpenAI Chat Completions 格式,需要转换 @@ -472,12 +462,36 @@ pub struct ProviderMeta { /// identity when available; generated session IDs are not sent upstream. #[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")] pub prompt_cache_key: Option, + /// Session-based prompt-cache routing for Codex Responses -> Chat conversions. + /// "auto" enables known-compatible upstreams; "enabled" / "disabled" are overrides. + #[serde(rename = "promptCacheRouting", skip_serializing_if = "Option::is_none")] + pub prompt_cache_routing: Option, /// Codex OAuth FAST mode: inject `service_tier = "priority"` for ChatGPT Codex requests. #[serde(rename = "codexFastMode", skip_serializing_if = "Option::is_none")] pub codex_fast_mode: Option, /// Codex Responses -> Chat Completions reasoning capability metadata. #[serde(rename = "codexChatReasoning", skip_serializing_if = "Option::is_none")] pub codex_chat_reasoning: Option, + /// Codex → Anthropic path: whether to emulate the Claude Code client + /// (User-Agent / anthropic-beta / x-app + injecting the Claude Code system + /// prompt first line). Disabled by default; only an explicit `true` enables it. + #[serde( + rename = "impersonateClaudeCode", + skip_serializing_if = "Option::is_none" + )] + pub impersonate_claude_code: Option, + /// Codex → Anthropic path: override the Anthropic `max_tokens` (output ceiling). + /// + /// Codex does not forward its `model_max_output_tokens` in the Responses + /// request body, so without this the path falls back to a conservative + /// default (8192), which truncates long or thinking-heavy responses + /// (`stop_reason=max_tokens`). When set (>0), this value is injected as the + /// request's `max_output_tokens` before conversion, taking precedence over + /// both any request-supplied value and the default. Kept per-provider on + /// purpose: a global large default would hard-400 on low-output-ceiling + /// models/gateways (and that error is non-retryable). + #[serde(rename = "maxOutputTokens", skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, /// Custom User-Agent for local proxy routing. #[serde(rename = "customUserAgent", skip_serializing_if = "Option::is_none")] pub custom_user_agent: Option, @@ -985,6 +999,30 @@ mod tests { assert!(value.get("pricingModelSource").is_none()); } + #[test] + fn provider_meta_roundtrips_max_output_tokens() { + let meta = ProviderMeta { + max_output_tokens: Some(64000), + ..ProviderMeta::default() + }; + + let value = serde_json::to_value(&meta).expect("serialize ProviderMeta"); + assert_eq!( + value.get("maxOutputTokens").and_then(|v| v.as_u64()), + Some(64000) + ); + assert!(value.get("max_output_tokens").is_none()); + + let parsed: ProviderMeta = serde_json::from_value(value).expect("deserialize ProviderMeta"); + assert_eq!(parsed.max_output_tokens, Some(64000)); + } + + #[test] + fn provider_meta_omits_max_output_tokens_when_none() { + let value = serde_json::to_value(ProviderMeta::default()).expect("serialize ProviderMeta"); + assert!(value.get("maxOutputTokens").is_none()); + } + #[test] fn provider_meta_roundtrips_local_proxy_request_overrides() { let meta = ProviderMeta { diff --git a/src-tauri/src/proxy/cache_injector.rs b/src-tauri/src/proxy/cache_injector.rs index e3274d526..5ed524684 100644 --- a/src-tauri/src/proxy/cache_injector.rs +++ b/src-tauri/src/proxy/cache_injector.rs @@ -7,25 +7,24 @@ use serde_json::{json, Value}; /// 在请求体关键位置注入 cache_control 断点 pub fn inject(body: &mut Value, config: &OptimizerConfig) { - if !config.cache_injection { + if !config.enabled || !config.cache_injection { return; } let existing = count_existing(body); - // 升级已有断点的 TTL - upgrade_existing_ttl(body, &config.cache_ttl); + if existing > 4 { + // Existing markers are caller-owned. Do not silently delete or reorder + // them; surface the invalid/unsupported total and leave validation to + // the upstream provider. + log::warn!( + "[OPT] cache: existing breakpoint count {existing} exceeds the supported total of 4; preserving caller input" + ); + } let mut budget = 4_usize.saturating_sub(existing); if budget == 0 { - if existing > 0 { - log::info!( - "[OPT] cache: ttl-upgrade({existing}->{},existing={existing})", - config.cache_ttl - ); - } else { - log::info!("[OPT] cache: no-op(existing={existing})"); - } + log::info!("[OPT] cache: no-op(existing={existing})"); return; } @@ -37,10 +36,7 @@ pub fn inject(body: &mut Value, config: &OptimizerConfig) { if let Some(last) = tools.last_mut() { if last.get("cache_control").is_none() { if let Some(o) = last.as_object_mut() { - o.insert( - "cache_control".to_string(), - make_cache_control(&config.cache_ttl), - ); + o.insert("cache_control".to_string(), make_cache_control()); } budget -= 1; injected.push("tools"); @@ -64,10 +60,7 @@ pub fn inject(body: &mut Value, config: &OptimizerConfig) { if let Some(last) = system.last_mut() { if last.get("cache_control").is_none() { if let Some(o) = last.as_object_mut() { - o.insert( - "cache_control".to_string(), - make_cache_control(&config.cache_ttl), - ); + o.insert("cache_control".to_string(), make_cache_control()); } budget -= 1; injected.push("system"); @@ -76,32 +69,33 @@ pub fn inject(body: &mut Value, config: &OptimizerConfig) { } } - // (c) 最后一条 assistant 消息的最后一个非 thinking block + // (c) 最后一条可缓存消息的最后一个非 thinking block。工具循环通常以 + // user/tool_result 结束;只标 assistant 会让最新稳定前缀无法命中缓存。 if budget > 0 { if let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) { - if let Some(assistant_msg) = messages - .iter_mut() - .rev() - .find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant")) - { - if let Some(content) = assistant_msg - .get_mut("content") - .and_then(|c| c.as_array_mut()) - { - // 逆序找最后一个非 thinking/redacted_thinking block - if let Some(block) = content.iter_mut().rev().find(|b| { - let bt = b.get("type").and_then(|t| t.as_str()).unwrap_or(""); - bt != "thinking" && bt != "redacted_thinking" - }) { - if block.get("cache_control").is_none() { - if let Some(o) = block.as_object_mut() { - o.insert( - "cache_control".to_string(), - make_cache_control(&config.cache_ttl), - ); - } - injected.push("msgs"); + for message in messages.iter_mut().rev() { + if inject_message_breakpoint(message) { + budget -= 1; + injected.push("msgs-latest"); + break; + } + } + + // (d) A second, older user anchor helps long tool-result turns where + // the stable prefix falls outside Anthropic's 20-block lookback from + // the newest breakpoint. Keep this best-effort and inside the 4-BP cap. + if budget > 0 && messages.len() >= 4 { + let mut user_count = 0; + for message in messages.iter_mut().rev() { + if message.get("role").and_then(Value::as_str) != Some("user") { + continue; + } + user_count += 1; + if user_count == 2 { + if inject_message_breakpoint(message) { + injected.push("msgs-prior-user"); } + break; } } } @@ -112,16 +106,34 @@ pub fn inject(body: &mut Value, config: &OptimizerConfig) { "[OPT] cache: {}bp({},{},pre={existing})", injected.len(), injected.join("+"), - config.cache_ttl, + "5m", ); } -fn make_cache_control(ttl: &str) -> Value { - if ttl == "5m" { - json!({"type": "ephemeral"}) - } else { - json!({"type": "ephemeral", "ttl": ttl}) +fn inject_message_breakpoint(message: &mut Value) -> bool { + let Some(content) = message.get_mut("content").and_then(Value::as_array_mut) else { + return false; + }; + let Some(block) = content.iter_mut().rev().find(|block| { + !matches!( + block.get("type").and_then(Value::as_str), + Some("thinking" | "redacted_thinking") + ) + }) else { + return false; + }; + if block.get("cache_control").is_some() { + return false; } + let Some(object) = block.as_object_mut() else { + return false; + }; + object.insert("cache_control".to_string(), make_cache_control()); + true +} + +fn make_cache_control() -> Value { + json!({"type": "ephemeral"}) } fn count_existing(body: &Value) -> usize { @@ -155,40 +167,6 @@ fn count_existing(body: &Value) -> usize { count } -fn upgrade_existing_ttl(body: &mut Value, ttl: &str) { - let upgrade = |val: &mut Value| { - if let Some(cc) = val.get_mut("cache_control").and_then(|c| c.as_object_mut()) { - if ttl == "5m" { - cc.remove("ttl"); - } else { - cc.insert("ttl".to_string(), json!(ttl)); - } - } - }; - - if let Some(tools) = body.get_mut("tools").and_then(|t| t.as_array_mut()) { - for tool in tools.iter_mut() { - upgrade(tool); - } - } - - if let Some(system) = body.get_mut("system").and_then(|s| s.as_array_mut()) { - for block in system.iter_mut() { - upgrade(block); - } - } - - if let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) { - for msg in messages.iter_mut() { - if let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) { - for block in content.iter_mut() { - upgrade(block); - } - } - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -199,17 +177,16 @@ mod tests { enabled: true, thinking_optimizer: true, cache_injection: true, - cache_ttl: "1h".to_string(), } } #[test] fn test_empty_body_no_injection() { let mut body = json!({"model": "test", "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]}); - let original = body.clone(); inject(&mut body, &default_config()); - // No tools, no system, no assistant → no injection - assert_eq!(body, original); + assert!(body["messages"][0]["content"][0] + .get("cache_control") + .is_some()); } #[test] @@ -230,7 +207,7 @@ mod tests { // tools last element assert!(body["tools"][1].get("cache_control").is_some()); - assert_eq!(body["tools"][1]["cache_control"]["ttl"], "1h"); + assert!(body["tools"][1]["cache_control"].get("ttl").is_none()); // system last element assert!(body["system"][0].get("cache_control").is_some()); // assistant last non-thinking block @@ -240,26 +217,50 @@ mod tests { } #[test] - fn test_existing_four_breakpoints_only_upgrades_ttl() { + fn test_long_history_uses_fourth_prior_user_breakpoint() { + let mut body = json!({ + "model":"test", + "tools":[{"name":"tool1"}], + "system":[{"type":"text","text":"sys"}], + "messages":[ + {"role":"user","content":[{"type":"text","text":"first"}]}, + {"role":"assistant","content":[{"type":"text","text":"answer"}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"c1","content":"result"}]}, + {"role":"assistant","content":[{"type":"text","text":"latest"}]} + ] + }); + + inject(&mut body, &default_config()); + assert_eq!(count_existing(&body), 4); + assert!(body["messages"][0]["content"][0] + .get("cache_control") + .is_some()); + assert!(body["messages"][3]["content"][0] + .get("cache_control") + .is_some()); + } + + #[test] + fn test_existing_four_breakpoints_preserve_caller_ttl() { let mut body = json!({ "model": "test", "tools": [ - {"name": "t1", "cache_control": {"type": "ephemeral", "ttl": "5m"}}, - {"name": "t2", "cache_control": {"type": "ephemeral", "ttl": "5m"}} + {"name": "t1", "cache_control": {"type": "ephemeral", "ttl": "1h"}}, + {"name": "t2", "cache_control": {"type": "ephemeral", "ttl": "1h"}} ], "system": [ - {"type": "text", "text": "sys", "cache_control": {"type": "ephemeral", "ttl": "5m"}} + {"type": "text", "text": "sys", "cache_control": {"type": "ephemeral", "ttl": "1h"}} ], "messages": [ {"role": "assistant", "content": [ - {"type": "text", "text": "ok", "cache_control": {"type": "ephemeral", "ttl": "5m"}} + {"type": "text", "text": "ok", "cache_control": {"type": "ephemeral", "ttl": "1h"}} ]} ] }); inject(&mut body, &default_config()); - // All TTLs upgraded to 1h, no new breakpoints + // Existing markers are caller-owned; only newly injected markers are fixed to 5m. assert_eq!(body["tools"][0]["cache_control"]["ttl"], "1h"); assert_eq!(body["tools"][1]["cache_control"]["ttl"], "1h"); assert_eq!(body["system"][0]["cache_control"]["ttl"], "1h"); @@ -292,6 +293,32 @@ mod tests { .is_some()); } + #[test] + fn test_more_than_four_existing_breakpoints_are_preserved() { + let mut body = json!({ + "model": "test", + "tools": [ + {"name": "t1", "cache_control": {"type": "ephemeral"}}, + {"name": "t2", "cache_control": {"type": "ephemeral"}} + ], + "system": [ + {"type": "text", "text": "s1", "cache_control": {"type": "ephemeral"}}, + {"type": "text", "text": "s2", "cache_control": {"type": "ephemeral"}} + ], + "messages": [{"role": "user", "content": [ + {"type": "text", "text": "m1", "cache_control": {"type": "ephemeral"}}, + {"type": "text", "text": "m2"} + ]}] + }); + + inject(&mut body, &default_config()); + + assert_eq!(count_existing(&body), 5); + assert!(body["messages"][0]["content"][1] + .get("cache_control") + .is_none()); + } + #[test] fn test_system_string_converted_to_array() { let mut body = json!({ @@ -311,18 +338,14 @@ mod tests { } #[test] - fn test_ttl_5m_no_ttl_field() { - let config = OptimizerConfig { - cache_ttl: "5m".to_string(), - ..default_config() - }; + fn test_standard_five_minute_cache_control_omits_ttl() { let mut body = json!({ "model": "test", "tools": [{"name": "tool1"}], "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] }); - inject(&mut body, &config); + inject(&mut body, &default_config()); let cc = &body["tools"][0]["cache_control"]; assert_eq!(cc["type"], "ephemeral"); @@ -348,6 +371,24 @@ mod tests { assert_eq!(body, original); } + #[test] + fn test_optimizer_disabled_no_change() { + let config = OptimizerConfig { + enabled: false, + cache_injection: true, + ..default_config() + }; + let mut body = json!({ + "model":"test", + "tools":[{"name":"tool1"}], + "messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}] + }); + let original = body.clone(); + + inject(&mut body, &config); + assert_eq!(body, original); + } + #[test] fn test_skip_thinking_blocks_in_assistant() { let mut body = json!({ @@ -374,4 +415,23 @@ mod tests { .get("cache_control") .is_none()); } + + #[test] + fn test_injects_latest_tool_result_instead_of_older_assistant() { + let mut body = json!({ + "messages": [ + {"role": "assistant", "content": [{"type": "tool_use", "id": "call_1", "name": "Read", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "call_1", "content": "done"}]} + ] + }); + + inject(&mut body, &default_config()); + + assert!(body["messages"][0]["content"][0] + .get("cache_control") + .is_none()); + assert!(body["messages"][1]["content"][0] + .get("cache_control") + .is_some()); + } } diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 4c2aae719..605465546 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -28,6 +28,7 @@ use crate::{ app_config::AppType, provider::{LocalProxyRequestOverrides, Provider}, }; +use bytes::Bytes; use futures::StreamExt; use http::Extensions; use serde_json::Value; @@ -37,6 +38,22 @@ use tokio::sync::RwLock; const PROXY_AUTH_PLACEHOLDER: &str = "PROXY_MANAGED"; +fn validate_codex_official_authorization(headers: &http::HeaderMap) -> Result<(), ProxyError> { + let authorization = headers + .get(http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(str::trim); + match authorization { + None | Some("") => Err(ProxyError::AuthError( + "Codex 官方登录不可用,请先在 Codex 中完成 ChatGPT 登录".to_string(), + )), + Some(value) if value.contains(PROXY_AUTH_PLACEHOLDER) => Err(ProxyError::AuthError( + "已切换到 OpenAI 官方供应商,请重启 Codex 或新建会话以加载官方登录配置".to_string(), + )), + Some(_) => Ok(()), + } +} + pub struct ForwardResult { pub response: ProxyResponse, pub provider: Provider, @@ -984,7 +1001,7 @@ impl RequestForwarder { // 先分类错误,决定是否计入 provider 健康度 // —— NonRetryable / ClientAbort 是客户端层错误,无论换哪家 provider 都会被拒绝, // 不应污染熔断器和数据库健康度(与 release_permit_neutral 同语义)。 - let category = self.categorize_proxy_error(&e); + let category = self.categorize_proxy_error(&e, provider); match category { ErrorCategory::Retryable => { @@ -1121,6 +1138,20 @@ impl RequestForwarder { == Some("github_copilot") || base_url.contains("githubcopilot.com"); + // 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 | AppType::GrokBuild) + && super::providers::should_convert_codex_responses_to_chat(provider, endpoint); + 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); + + if codex_official_auth_passthrough { + validate_codex_official_authorization(headers)?; + } + // 应用模型映射(独立于格式转换) // Claude Desktop proxy 模式必须先把 Desktop 可见的 claude-* route // 映射成真实上游模型名,并且未知 route 要直接报错,不能使用默认模型兜底。 @@ -1136,12 +1167,23 @@ 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); self.apply_copilot_live_model_resolution(provider, &mut mapped_body) .await; - } else { + } else if !codex_responses_to_anthropic { + // Skip on the Codex→Anthropic path: stripping [1m] here would break both the + // model-catalog match (apply_codex_upstream_model) and the transform's own + // strip+`context-1m` beta detection. The marker is stripped later, on the + // final anthropic_body. mapped_body = super::model_mapper::strip_one_m_suffix_for_upstream_from_body(mapped_body); } @@ -1299,10 +1341,22 @@ impl RequestForwarder { Some(api_format) => super::providers::claude_api_format_needs_transform(api_format), None => adapter.needs_transform(provider), }; - let codex_responses_to_chat = matches!(app_type, AppType::Codex) - && super::providers::should_convert_codex_responses_to_chat(provider, endpoint); + // Codex → Anthropic: Claude Code emulation is off by default and only + // enabled when the user explicitly turns it on in the UI, so requests can + // pass a gateway's "Claude Code only" fingerprint check (User-Agent / + // anthropic-beta / x-app / system prompt first line). Defaulting to off + // avoids leaking the Claude Code fingerprint and identity prompt to + // general-purpose gateways. + let codex_impersonate_claude_code = codex_responses_to_anthropic + && provider + .meta + .as_ref() + .and_then(|meta| meta.impersonate_claude_code) + == Some(true); let (effective_endpoint, passthrough_query) = if codex_responses_to_chat { rewrite_codex_responses_endpoint_to_chat(endpoint) + } else if codex_responses_to_anthropic { + rewrite_codex_responses_endpoint_to_anthropic(endpoint) } else if needs_transform && adapter.name() == "Claude" { let api_format = resolved_claude_api_format .as_deref() @@ -1317,11 +1371,16 @@ impl RequestForwarder { ) }; - let codex_chat_base_is_full_endpoint = codex_responses_to_chat - && base_url - .trim_end_matches('/') - .to_ascii_lowercase() - .ends_with("/chat/completions"); + let codex_chat_base_is_full_endpoint = + codex_responses_to_chat && base_url_is_full_endpoint(&base_url, "/chat/completions"); + + // Defensive fallback mirroring `codex_chat_base_is_full_endpoint`: if a user pastes + // a base URL already ending in the Anthropic `/v1/messages` endpoint but leaves the + // "full URL" switch off, treat it as a full endpoint so we don't double-append + // `/v1/messages` (→ `.../v1/messages/v1/messages`, a non-retryable 400). Matches the + // exact endpoint suffix, so prefixed gateways like `.../api/v1/messages` are covered. + let codex_anthropic_base_is_full_endpoint = + codex_responses_to_anthropic && base_url_is_full_endpoint(&base_url, "/v1/messages"); let url = if matches!(resolved_claude_api_format.as_deref(), Some("gemini_native")) { super::gemini_url::resolve_gemini_native_url( @@ -1329,7 +1388,10 @@ impl RequestForwarder { &effective_endpoint, is_full_url, ) - } else if is_full_url || codex_chat_base_is_full_endpoint { + } else if is_full_url + || codex_chat_base_is_full_endpoint + || codex_anthropic_base_is_full_endpoint + { append_query_to_full_url(&base_url, passthrough_query.as_deref()) } else { adapter.build_url(&base_url, &effective_endpoint) @@ -1344,9 +1406,17 @@ impl RequestForwarder { .filter(|m| !m.is_empty()) .map(str::to_string); + // Codex→Anthropic: when the model name carries the [1m] marker, strip the + // suffix and add the context-1m beta header. + let mut codex_anthropic_one_m = false; + // 转换请求体(如果需要) let mut request_body = if codex_responses_to_chat { let mut mapped_body = mapped_body; + let explicit_prompt_cache_key = mapped_body + .get("prompt_cache_key") + .and_then(|value| value.as_str()) + .map(ToString::to_string); let restored = self .codex_chat_history .enrich_request(&mut mapped_body) @@ -1359,10 +1429,74 @@ impl RequestForwarder { super::providers::apply_codex_chat_upstream_model(provider, &mut mapped_body); let reasoning_config = super::providers::resolve_codex_chat_reasoning_config(provider, &mapped_body); - super::providers::transform_codex_chat::responses_to_chat_completions_with_reasoning( + let mut chat_body = super::providers::transform_codex_chat::responses_to_chat_completions_with_reasoning( mapped_body, reasoning_config.as_ref(), - )? + )?; + super::providers::inject_codex_chat_prompt_cache_key( + provider, + &mut chat_body, + explicit_prompt_cache_key.as_deref(), + self.session_client_provided + .then_some(self.session_id.as_str()), + ); + chat_body + } else if codex_responses_to_anthropic { + let mut mapped_body = mapped_body; + super::providers::apply_codex_upstream_model(provider, &mut mapped_body); + // Per-provider output ceiling override. Codex does not forward its + // `model_max_output_tokens` in the request body, so honor the value + // configured on the provider here — it takes precedence over any + // request-supplied `max_output_tokens` and over the default below. + // Injecting it into the body (rather than overriding after transform) + // lets the thinking-budget clamp size its headroom against the real + // ceiling too. Kept per-provider to avoid a global large default that + // would 400 on low-output-ceiling gateways. + if let Some(max_out) = provider + .meta + .as_ref() + .and_then(|meta| meta.max_output_tokens) + .filter(|v| *v > 0) + { + mapped_body["max_output_tokens"] = Value::from(max_out); + } + // Anthropic requires max_tokens; fall back to this default only when the + // Codex request omits max_output_tokens (rare — Codex normally sends it). + // Kept conservative so a low-output-ceiling model or relay does not hard-400 + // on the fallback (a too-high default 400s and is non-retryable); 8192 is + // accepted by every current Claude model and virtually all gateways. The + // transform clamps any thinking budget below this value. + const DEFAULT_CODEX_ANTHROPIC_MAX_TOKENS: u64 = 8192; + let mut anthropic_body = + super::providers::transform_codex_anthropic::responses_request_to_anthropic( + mapped_body, + DEFAULT_CODEX_ANTHROPIC_MAX_TOKENS, + )?; + // Handle the 1M-context marker [1m]: strip the model-name suffix (the + // gateway doesn't recognize it) and set the flag so the beta header is + // added. apply_codex_upstream_model may have just written back a model + // name carrying [1m] from the provider config, so strip it once more on + // the final body here. + if let Some(model) = anthropic_body.get("model").and_then(|v| v.as_str()) { + let stripped = super::model_mapper::strip_one_m_suffix_for_upstream(model); + if stripped != model { + codex_anthropic_one_m = true; + anthropic_body["model"] = Value::String(stripped.to_string()); + } + } + if codex_impersonate_claude_code { + prepend_claude_code_system_prompt(&mut anthropic_body); + } + // Enable Anthropic prompt caching (no beta header required). Reuse the + // configured TTL rather than silently forcing 5m on this conversion path. + // otherwise system/tools/history are re-sent at full price every round, + // inflating cost and first-token latency. The injector handles the + // string→array `system` conversion and the new-breakpoint budget. + super::cache_injector::inject( + &mut anthropic_body, + &codex_anthropic_cache_config(&self.optimizer_config), + ); + anthropic_body } else if needs_transform { if adapter.name() == "Claude" { let api_format = resolved_claude_api_format @@ -1383,7 +1517,7 @@ impl RequestForwarder { mapped_body }; - if matches!(app_type, AppType::Codex) { + if matches!(app_type, AppType::Codex | AppType::GrokBuild) { self.apply_media_prevention(&mut request_body, provider); } @@ -1419,8 +1553,10 @@ impl RequestForwarder { ); let request_is_streaming = is_streaming_request(&effective_endpoint, &filtered_body, headers); - let force_identity_encoding = - needs_transform || codex_responses_to_chat || request_is_streaming; + let force_identity_encoding = needs_transform + || codex_responses_to_chat + || codex_responses_to_anthropic + || request_is_streaming; // Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充) let mut codex_oauth_account_id: Option = None; @@ -1561,6 +1697,13 @@ impl RequestForwarder { .as_ref() .and_then(|meta| meta.custom_user_agent_header().ok().flatten()) }; + // Codex→Anthropic emulation: when there is no custom UA, override Codex's + // codex_cli_rs UA with the Claude Code UA. + let custom_user_agent = if custom_user_agent.is_none() && codex_impersonate_claude_code { + Some(http::HeaderValue::from_static(CLAUDE_CODE_USER_AGENT)) + } else { + custom_user_agent + }; // --- Copilot 优化器:动态 header 注入 --- if let Some((ref classification, ref det_request_id, ref interaction_id)) = @@ -1646,6 +1789,17 @@ impl RequestForwarder { } else { CLAUDE_CODE_BETA.to_string() }) + } else if codex_impersonate_claude_code || codex_anthropic_one_m { + // Codex→Anthropic: emulation injects the claude-code marker; a [1m] + // model injects the context-1m marker. + let mut betas: Vec<&str> = Vec::new(); + if codex_impersonate_claude_code { + betas.push("claude-code-20250219"); + } + if codex_anthropic_one_m { + betas.push("context-1m-2025-08-07"); + } + Some(betas.join(",")) } else { None }; @@ -1656,6 +1810,7 @@ impl RequestForwarder { let mut ordered_headers = http::HeaderMap::new(); let mut saw_auth = false; let mut saw_accept_encoding = false; + let mut saw_accept = false; let mut saw_user_agent = false; let mut saw_anthropic_beta = false; let mut saw_anthropic_version = false; @@ -1712,6 +1867,17 @@ impl RequestForwarder { || key_str.eq_ignore_ascii_case("x-api-key") || key_str.eq_ignore_ascii_case("x-goog-api-key") { + // The built-in Codex official provider deliberately has no + // credential in CC Switch. `requires_openai_auth = true` makes + // Codex send its native ChatGPT authorization, which must reach + // the fixed official upstream unchanged. Other credential + // headers are still discarded. + if codex_official_auth_passthrough && key_str.eq_ignore_ascii_case("authorization") + { + saw_auth = true; + ordered_headers.append(key.clone(), value.clone()); + continue; + } if !saw_auth { saw_auth = true; for (ah_name, ah_value) in &auth_headers { @@ -1721,6 +1887,37 @@ impl RequestForwarder { continue; } + // --- x-app — during Codex→Anthropic emulation, `cli` is injected uniformly below --- + if codex_impersonate_claude_code && key_str.eq_ignore_ascii_case("x-app") { + continue; + } + + // --- Codex/OpenAI fingerprint headers — never leak to an Anthropic upstream --- + // These are client/session identifiers from the incoming Codex request, + // not Anthropic protocol headers. Forwarding them both leaks identity and + // can defeat strict gateway fingerprint checks. + // The full set lives in `is_codex_client_fingerprint_header` so it stays in one + // place. (HeaderName is lowercased by the http crate, so a direct match is safe.) + if codex_responses_to_anthropic && is_codex_client_fingerprint_header(key_str) { + continue; + } + + // --- accept — force application/json on the Codex→Anthropic path --- + // The Codex CLI sends `Accept: text/event-stream`, whereas a native + // Anthropic client sends `application/json` (streaming is driven by + // the body's stream:true). Strict Anthropic gateways return 406 Not + // Acceptable for an event-stream Accept, so normalize it here. + if codex_responses_to_anthropic && key_str.eq_ignore_ascii_case("accept") { + if !saw_accept { + saw_accept = true; + ordered_headers.append( + http::header::ACCEPT, + http::HeaderValue::from_static("application/json"), + ); + } + continue; + } + // --- accept-encoding — transform / SSE 路径强制 identity,其余保留原值 --- if key_str.eq_ignore_ascii_case("accept-encoding") { if !saw_accept_encoding { @@ -1799,6 +1996,19 @@ impl RequestForwarder { ); } + // On the Codex→Anthropic path, add application/json when Accept is missing (matching a native Anthropic client). + if codex_responses_to_anthropic && !saw_accept { + ordered_headers.append( + http::header::ACCEPT, + http::HeaderValue::from_static("application/json"), + ); + } + + // Codex→Anthropic emulation: inject Claude Code's x-app: cli + if codex_impersonate_claude_code { + ordered_headers.append("x-app", http::HeaderValue::from_static("cli")); + } + if !saw_user_agent { if let Some(ref ua) = custom_user_agent { ordered_headers.append(http::header::USER_AGENT, ua.clone()); @@ -1814,8 +2024,13 @@ impl RequestForwarder { } } - // anthropic-version:仅在缺失时补充默认值 - if should_send_anthropic_headers && !saw_anthropic_version { + // anthropic-version: add the default only when it is missing. + // The Codex→Anthropic path also needs this header. Note this is independent + // of anthropic-beta: the Claude Code-specific beta is only sent when + // impersonation is on (handled above); on the plain Codex→Anthropic path + // (impersonation off) anthropic-version is still required but no beta is sent. + if (should_send_anthropic_headers || codex_responses_to_anthropic) && !saw_anthropic_version + { ordered_headers.append( "anthropic-version", http::HeaderValue::from_static("2023-06-01"), @@ -1958,9 +2173,33 @@ impl RequestForwarder { let status = response.status(); if status.is_success() { - let response = self + let mut response = self .prepare_success_response_for_failover(response, request_is_streaming) .await?; + // Streaming requests normally return SSE. If a compatible gateway + // explicitly returns JSON instead, buffer and validate it inside the retry + // loop as well so a 2xx Anthropic error envelope can still fail over. Do + // not buffer unknown content types: some gateways omit the SSE header. + if codex_responses_to_anthropic && (!request_is_streaming || response.is_json()) { + response = self + .validate_codex_anthropic_success_response(response) + .await?; + } else if matches!( + resolved_claude_api_format.as_deref(), + Some("openai_responses") + ) { + if !request_is_streaming || response.is_json() { + // Claude→Responses gateways can also return a semantic failure in an + // HTTP 2xx Response object. Validate buffered/JSON bodies inside the + // retry loop so an early failure can still select another provider. + response = self.validate_responses_success_response(response).await?; + } else { + // Delay committing the downstream stream until the upstream emits + // either productive output or a valid non-failure terminal event. + // A response.failed/error before output remains failover-safe. + response = self.validate_responses_stream_start(response).await?; + } + } Ok((response, resolved_claude_api_format, outbound_model)) } else { let status_code = status.as_u16(); @@ -2018,6 +2257,141 @@ impl RequestForwarder { Ok(ProxyResponse::buffered(status, headers, body)) } + /// Some Anthropic-compatible gateways return an Anthropic error envelope with + /// HTTP 2xx. Validate it inside the retry loop so the request can fail over to + /// the next provider; the response transformer runs too late for that. + async fn validate_codex_anthropic_success_response( + &self, + response: ProxyResponse, + ) -> Result { + let status = response.status(); + let headers = response.headers().clone(); + let encoding = get_content_encoding(&headers); + let raw = response.bytes().await?; + let decoded = match encoding { + Some(encoding) => match decompress_body(&encoding, &raw) { + Ok(Some(decompressed)) => decompressed, + _ => raw.to_vec(), + }, + None => raw.to_vec(), + }; + + if let Some(message) = codex_anthropic_error_envelope_message(&decoded) { + return Err(ProxyError::TransformError(format!( + "Anthropic upstream returned a 2xx error envelope: {message}" + ))); + } + + Ok(ProxyResponse::buffered(status, headers, raw)) + } + + async fn validate_responses_success_response( + &self, + response: ProxyResponse, + ) -> Result { + let status = response.status(); + let headers = response.headers().clone(); + let encoding = get_content_encoding(&headers); + let raw = response.bytes().await?; + let decoded = match encoding { + Some(encoding) => match decompress_body(&encoding, &raw) { + Ok(Some(decompressed)) => decompressed, + _ => raw.to_vec(), + }, + None => raw.to_vec(), + }; + + if let Some(message) = responses_error_envelope_message(&decoded) { + return Err(ProxyError::TransformError(format!( + "Responses upstream returned a 2xx failure: {message}" + ))); + } + + Ok(ProxyResponse::buffered(status, headers, raw)) + } + + async fn validate_responses_stream_start( + &self, + response: ProxyResponse, + ) -> Result { + const MAX_PRIME_BYTES: usize = 256 * 1024; + + let status = response.status(); + let headers = response.headers().clone(); + let mut stream = Box::pin(response.bytes_stream()); + let mut replay_chunks: Vec = Vec::new(); + let mut parse_buffer = String::new(); + let mut utf8_remainder = Vec::new(); + + loop { + let next = if self.streaming_first_byte_timeout.is_zero() { + stream.next().await + } else { + tokio::time::timeout(self.streaming_first_byte_timeout, stream.next()) + .await + .map_err(|_| { + ProxyError::Timeout(format!( + "Responses stream produced no semantic output within {}s", + self.streaming_first_byte_timeout.as_secs() + )) + })? + }; + + let Some(chunk) = next else { + if let Some(outcome) = inspect_responses_json_document(&parse_buffer) { + outcome?; + let replay = futures::stream::iter(replay_chunks.into_iter().map(Ok)); + return Ok(ProxyResponse::streamed(status, headers, replay)); + } + if !parse_buffer.trim().is_empty() { + if let Some(outcome) = inspect_responses_start_event(parse_buffer.trim()) { + outcome?; + let replay = futures::stream::iter(replay_chunks.into_iter().map(Ok)); + return Ok(ProxyResponse::streamed(status, headers, replay)); + } + } + return Err(ProxyError::ForwardFailed( + "Responses stream ended before producing output or a terminal event" + .to_string(), + )); + }; + let chunk = chunk.map_err(|error| { + ProxyError::ForwardFailed(format!( + "Failed while validating Responses stream start: {error}" + )) + })?; + crate::proxy::sse::append_utf8_safe(&mut parse_buffer, &mut utf8_remainder, &chunk); + replay_chunks.push(chunk); + + // Some compatible gateways ignore `stream:true` and return a complete + // Responses JSON document without a JSON content-type. Recognize that + // shape before looking for SSE delimiters; pretty-printed JSON may itself + // contain blank lines and must stay intact. + if let Some(outcome) = inspect_responses_json_document(&parse_buffer) { + outcome?; + let replay = futures::stream::iter(replay_chunks.into_iter().map(Ok)).chain(stream); + return Ok(ProxyResponse::streamed(status, headers, replay)); + } + + while let Some(block) = crate::proxy::sse::take_sse_block(&mut parse_buffer) { + if let Some(outcome) = inspect_responses_start_event(&block) { + outcome?; + let replay = + futures::stream::iter(replay_chunks.into_iter().map(Ok)).chain(stream); + return Ok(ProxyResponse::streamed(status, headers, replay)); + } + } + + if replay_chunks.iter().map(Bytes::len).sum::() >= MAX_PRIME_BYTES { + log::warn!( + "[Claude/Responses] semantic stream priming exceeded {MAX_PRIME_BYTES} bytes; committing buffered stream" + ); + let replay = futures::stream::iter(replay_chunks.into_iter().map(Ok)).chain(stream); + return Ok(ProxyResponse::streamed(status, headers, replay)); + } + } + } + async fn prime_streaming_response( &self, response: ProxyResponse, @@ -2158,7 +2532,23 @@ impl RequestForwarder { } } - fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory { + fn categorize_proxy_error(&self, error: &ProxyError, provider: &Provider) -> ErrorCategory { + // Authentication belongs to the Codex client for the built-in official + // route. Retrying another provider would silently move the conversation + // away from the selected official account and poison its health state. + if super::providers::is_codex_official_provider(provider) + && (matches!(error, ProxyError::AuthError(_)) + || matches!( + error, + ProxyError::UpstreamError { + status: 401 | 403, + .. + } + )) + { + return ErrorCategory::NonRetryable; + } + match error { // 网络和上游错误:都应该尝试下一个供应商 ProxyError::Timeout(_) => ErrorCategory::Retryable, @@ -2353,6 +2743,242 @@ fn rewrite_codex_responses_endpoint_to_chat(endpoint: &str) -> (String, Option = vec![identity]; + match body.get("system") { + Some(Value::String(existing)) if !existing.is_empty() => { + blocks.push(serde_json::json!({ "type": "text", "text": existing })); + } + Some(Value::Array(existing)) => { + // Idempotent: skip re-injection if the first block is already the identity line. + if existing + .first() + .and_then(|b| b.get("text")) + .and_then(|t| t.as_str()) + == Some(CLAUDE_CODE_SYSTEM_IDENTITY) + { + return; + } + blocks.extend(existing.iter().cloned()); + } + _ => {} + } + body["system"] = Value::Array(blocks); +} + +/// Headers a native Claude Code client never sends but the Codex/OpenAI CLI (and its +/// stainless SDK layer) do. Dropped for every Codex→Anthropic request so the upstream sees a +/// clean Anthropic client fingerprint. Centralized here so the set stays in one place and future +/// additions can't miss a code path. `key_str` is already lowercased by the http crate. +/// Whether `base_url` already ends in `endpoint_suffix` (e.g. `/v1/messages` or +/// `/chat/completions`), ignoring surrounding whitespace, any `?query`/`#fragment`, and a +/// trailing slash. Used to avoid double-appending the endpoint when a user pastes a full +/// URL but leaves the "full URL" switch off (`.../v1/messages` → `.../v1/messages/v1/messages`, +/// a non-retryable 400). `endpoint_suffix` must be lowercase. +fn base_url_is_full_endpoint(base_url: &str, endpoint_suffix: &str) -> bool { + let trimmed = base_url.trim(); + // Match against the path only: a `?query`/`#fragment` on a full endpoint URL must not + // hide the suffix (`.../v1/messages?beta=true` still ends in the endpoint). + let path = match trimmed.split_once(['?', '#']) { + Some((head, _)) => head, + None => trimmed, + }; + path.trim_end_matches('/') + .to_ascii_lowercase() + .ends_with(endpoint_suffix) +} + +fn is_codex_client_fingerprint_header(key_str: &str) -> bool { + matches!( + key_str, + "originator" + | "session_id" + | "session-id" + | "thread-id" + | "conversation_id" + | "chatgpt-account-id" + | "x-openai-subagent" + | "x-client-request-id" + | "openai-beta" + | "openai-organization" + | "openai-project" + ) || key_str.starts_with("x-stainless-") + || key_str.starts_with("x-codex-") +} + +fn codex_anthropic_error_envelope_message(body: &[u8]) -> Option { + let value: Value = serde_json::from_slice(body).ok()?; + if value.get("type").and_then(Value::as_str) != Some("error") && value.get("error").is_none() { + return None; + } + let error = value.get("error").unwrap_or(&value); + let error_type = error.get("type").and_then(Value::as_str).unwrap_or("error"); + let message = error + .get("message") + .and_then(Value::as_str) + .or_else(|| error.as_str()) + .map(str::to_string) + .unwrap_or_else(|| error.to_string()); + Some(format!("{error_type}: {message}")) +} + +fn responses_error_envelope_message(body: &[u8]) -> Option { + let value: Value = serde_json::from_slice(body).ok()?; + let status = value.get("status").and_then(Value::as_str); + let has_error = value.get("error").is_some_and(|error| !error.is_null()); + if !matches!(status, Some("failed" | "cancelled")) && !has_error { + return None; + } + + let error = value.get("error").unwrap_or(&value); + let error_type = error + .get("type") + .and_then(Value::as_str) + .or_else(|| error.get("code").and_then(Value::as_str)) + .unwrap_or_else(|| status.unwrap_or("error")); + let message = error + .get("message") + .and_then(Value::as_str) + .or_else(|| error.as_str()) + .filter(|message| !message.trim().is_empty()) + .unwrap_or(match status { + Some("cancelled") => "response generation was cancelled", + _ => "response generation failed", + }); + Some(format!("{error_type}: {message}")) +} + +/// Prompt caching is part of the Codex→Anthropic protocol bridge rather than an +/// optional Bedrock optimizer. Codex requests do not contain Anthropic +/// `cache_control`, so keep bridge caching on by default while still honoring the +/// dedicated cache-injection switch. Injected breakpoints always use Anthropic's +/// standard 5-minute TTL. +fn codex_anthropic_cache_config(config: &OptimizerConfig) -> OptimizerConfig { + OptimizerConfig { + enabled: true, + thinking_optimizer: false, + cache_injection: config.cache_injection, + } +} + +/// A streaming request may receive a whole JSON document even when the gateway +/// omits `application/json`. `None` means either "not JSON" or "not complete yet"; +/// a parsed document is safe to commit unless it is a semantic failure envelope. +fn inspect_responses_json_document(buffer: &str) -> Option> { + let trimmed = buffer.trim(); + if !matches!(trimmed.as_bytes().first(), Some(b'{') | Some(b'[')) { + return None; + } + let _: Value = serde_json::from_str(trimmed).ok()?; + if let Some(message) = responses_error_envelope_message(trimmed.as_bytes()) { + return Some(Err(ProxyError::TransformError(format!( + "Responses upstream returned a 2xx failure: {message}" + )))); + } + Some(Ok(())) +} + +/// Inspect one complete Responses SSE block while the response is still inside +/// the retry loop. `None` means the event is lifecycle-only and priming should +/// continue; `Some(Ok(()))` means it is safe to commit/replay the stream. +fn inspect_responses_start_event(block: &str) -> Option> { + let mut named_event = None; + let mut data_lines = Vec::new(); + for line in block.lines() { + if let Some(event) = crate::proxy::sse::strip_sse_field(line, "event") { + named_event = Some(event.trim().to_string()); + } else if let Some(data) = crate::proxy::sse::strip_sse_field(line, "data") { + data_lines.push(data); + } + } + if data_lines.is_empty() { + return None; + } + let value: Value = match serde_json::from_str(&data_lines.join("\n")) { + Ok(value) => value, + Err(_) => return None, + }; + let event = named_event + .as_deref() + .filter(|event| !event.is_empty()) + .or_else(|| value.get("type").and_then(Value::as_str)) + .unwrap_or(""); + + let response = value.get("response").unwrap_or(&value); + if matches!( + response.get("status").and_then(Value::as_str), + Some("failed" | "cancelled") + ) || response.get("error").is_some_and(|error| !error.is_null()) + { + let error = response.get("error").unwrap_or(response); + let message = error + .get("message") + .and_then(Value::as_str) + .or_else(|| error.as_str()) + .unwrap_or("Responses upstream failed before output"); + let error_type = error + .get("type") + .and_then(Value::as_str) + .or_else(|| error.get("code").and_then(Value::as_str)) + .or_else(|| response.get("status").and_then(Value::as_str)) + .unwrap_or("upstream_error"); + return Some(Err(ProxyError::TransformError(format!( + "Responses upstream {error_type}: {message}" + )))); + } + + match event { + "response.failed" | "error" => { + let error = response.get("error").unwrap_or(response); + let message = error + .get("message") + .and_then(Value::as_str) + .or_else(|| error.as_str()) + .unwrap_or("Responses upstream emitted an error before output"); + let error_type = error + .get("type") + .and_then(Value::as_str) + .or_else(|| error.get("code").and_then(Value::as_str)) + .unwrap_or("upstream_error"); + Some(Err(ProxyError::TransformError(format!( + "Responses upstream {error_type}: {message}" + )))) + } + "response.created" | "response.in_progress" | "response.queued" => None, + "" => None, + // Productive output, incomplete, and completed terminals are all safe to + // expose. Mid-stream failures after this point are surfaced by the converter + // but intentionally do not switch providers. + _ => Some(Ok(())), + } +} + +/// Rewrite Codex's `/responses` (and variants) to Anthropic's `/v1/messages`, preserving the query. +fn rewrite_codex_responses_endpoint_to_anthropic(endpoint: &str) -> (String, Option) { + let (_path, query) = split_endpoint_and_query(endpoint); + let passthrough_query = query.map(ToString::to_string); + let target_path = "/v1/messages"; + let rewritten = match passthrough_query.as_deref() { + Some(query) if !query.is_empty() => format!("{target_path}?{query}"), + _ => target_path.to_string(), + }; + + (rewritten, passthrough_query) +} + fn rewrite_claude_transform_endpoint( endpoint: &str, api_format: &str, @@ -2756,9 +3382,10 @@ fn log_prompt_cache_trace( .get("stream") .map(value_for_log) .unwrap_or_else(|| "absent".to_string()); + let cache_controls = cache_control_summary(body); log::debug!( - "[CacheTrace] app={}, provider={}, endpoint={}, api_format={}, session_client_provided={}, prompt_cache_key={}, store={}, stream={}, instructions_hash={}, tools_hash={}, input_hash={}, include_hash={}, body_hash={}", + "[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, @@ -2768,13 +3395,54 @@ fn log_prompt_cache_trace( store, stream, short_value_hash(body.get("instructions")), + short_value_hash(body.get("system")), short_value_hash(body.get("tools")), short_value_hash(body.get("input")), + short_value_hash(body.get("messages")), short_value_hash(body.get("include")), + cache_controls, short_value_hash(Some(body)), ); } +fn cache_control_summary(value: &Value) -> String { + fn walk(value: &Value, count: &mut usize, ttls: &mut std::collections::BTreeSet) { + match value { + Value::Object(object) => { + if let Some(cache_control) = object.get("cache_control") { + *count += 1; + let ttl = cache_control + .get("ttl") + .and_then(Value::as_str) + .unwrap_or("default"); + ttls.insert(ttl.to_string()); + } + for child in object.values() { + walk(child, count, ttls); + } + } + Value::Array(items) => { + for child in items { + walk(child, count, ttls); + } + } + _ => {} + } + } + + let mut count = 0; + let mut ttls = std::collections::BTreeSet::new(); + walk(value, &mut count, &mut ttls); + format!( + "count={count},ttls={}", + if ttls.is_empty() { + "none".to_string() + } else { + ttls.into_iter().collect::>().join("|") + } + ) +} + fn value_for_log(value: &Value) -> String { match value { Value::Bool(value) => value.to_string(), @@ -3345,6 +4013,290 @@ mod tests { assert_eq!(passthrough_query.as_deref(), Some("foo=bar")); } + #[test] + fn prepend_claude_code_system_prompt_from_string() { + let mut body = json!({ "system": "You are a Codex agent." }); + prepend_claude_code_system_prompt(&mut body); + let system = body["system"].as_array().unwrap(); + assert_eq!(system[0]["text"], CLAUDE_CODE_SYSTEM_IDENTITY); + assert_eq!(system[1]["text"], "You are a Codex agent."); + } + + #[test] + fn prepend_claude_code_system_prompt_when_absent() { + let mut body = json!({}); + prepend_claude_code_system_prompt(&mut body); + let system = body["system"].as_array().unwrap(); + assert_eq!(system.len(), 1); + assert_eq!(system[0]["text"], CLAUDE_CODE_SYSTEM_IDENTITY); + } + + #[test] + fn prepend_claude_code_system_prompt_is_idempotent() { + let mut body = json!({ "system": "orig" }); + prepend_claude_code_system_prompt(&mut body); + prepend_claude_code_system_prompt(&mut body); + let system = body["system"].as_array().unwrap(); + assert_eq!(system.len(), 2); + assert_eq!(system[0]["text"], CLAUDE_CODE_SYSTEM_IDENTITY); + assert_eq!(system[1]["text"], "orig"); + } + + #[test] + fn rewrite_codex_responses_endpoint_to_anthropic_preserves_query() { + let (endpoint, passthrough_query) = + rewrite_codex_responses_endpoint_to_anthropic("/responses?x=1"); + assert_eq!(endpoint, "/v1/messages?x=1"); + assert_eq!(passthrough_query.as_deref(), Some("x=1")); + + let (endpoint, _) = rewrite_codex_responses_endpoint_to_anthropic("/v1/responses"); + assert_eq!(endpoint, "/v1/messages"); + } + + #[test] + fn codex_anthropic_full_endpoint_guard_avoids_double_messages() { + // On the Codex→Anthropic path a base URL already ending in `/v1/messages` (switch + // off) must be treated as a full endpoint by the real `base_url_is_full_endpoint`. + + // Without the guard, build_url would concatenate the pasted endpoint with the + // rewritten `/v1/messages` target, producing a broken double suffix. + use super::super::providers::ProviderAdapter; + let doubled = super::super::providers::CodexAdapter::new() + .build_url("https://host.example/v1/messages", "/v1/messages"); + assert_eq!(doubled, "https://host.example/v1/messages/v1/messages"); + + // With the guard, the pasted URL is used verbatim (plus preserved query). Includes + // query/fragment/whitespace suffixes, which must not hide the endpoint (fix: a base + // like `.../v1/messages?beta=true` previously evaded the suffix check). + for base in [ + "https://host.example/v1/messages", + "https://host.example/v1/messages/", + "https://host.example/api/v1/messages", // prefixed gateway + "https://host.example/v1/messages?beta=true", + "https://host.example/v1/messages/?beta=true", + "https://host.example/v1/messages#frag", + " https://host.example/v1/messages ", + ] { + assert!( + base_url_is_full_endpoint(base, "/v1/messages"), + "expected full-endpoint match: {base:?}" + ); + } + assert_eq!( + append_query_to_full_url("https://host.example/v1/messages", Some("x=1")), + "https://host.example/v1/messages?x=1" + ); + // A base URL that already carries its own query is preserved verbatim (no double + // `/v1/messages`, query kept). + assert_eq!( + append_query_to_full_url("https://host.example/v1/messages?beta=true", None), + "https://host.example/v1/messages?beta=true" + ); + + // A non-endpoint base (origin/prefix) must NOT match, so build_url still appends. + assert!(!base_url_is_full_endpoint( + "https://host.example", + "/v1/messages" + )); + assert!(!base_url_is_full_endpoint( + "https://host.example/v1", + "/v1/messages" + )); + // The shared helper also backs the Chat path's `/chat/completions` guard. + assert!(base_url_is_full_endpoint( + "https://host.example/v1/chat/completions?api-version=2024", + "/chat/completions" + )); + } + + #[test] + fn codex_client_fingerprint_headers_are_dropped_for_anthropic_upstreams() { + // Codex/OpenAI fingerprints a native Claude Code client never sends → must drop. + for header in [ + "originator", + "session_id", + "session-id", + "thread-id", + "conversation_id", + "chatgpt-account-id", + "x-openai-subagent", + "x-client-request-id", + "x-codex-window-id", + "openai-beta", + "openai-organization", + "openai-project", + "x-stainless-lang", + "x-stainless-runtime", + "x-codex-turn-id", + ] { + assert!( + is_codex_client_fingerprint_header(header), + "expected {header} to be dropped while impersonating Claude Code" + ); + } + + // Headers a real Claude Code client sends (or that the forwarder rebuilds) must + // NOT be caught by the denylist. + for header in [ + "anthropic-version", + "anthropic-beta", + "user-agent", + "accept", + "content-type", + "x-app", + ] { + assert!( + !is_codex_client_fingerprint_header(header), + "{header} must be preserved while impersonating Claude Code" + ); + } + } + + #[test] + fn codex_anthropic_2xx_error_envelope_is_detected_for_failover() { + let body = br#"{"type":"error","error":{"type":"overloaded_error","message":"busy"}}"#; + assert_eq!( + codex_anthropic_error_envelope_message(body).as_deref(), + Some("overloaded_error: busy") + ); + assert!( + codex_anthropic_error_envelope_message(br#"{"type":"message","content":[]}"#).is_none() + ); + } + + #[test] + fn responses_2xx_failure_is_detected_for_failover() { + assert_eq!( + responses_error_envelope_message( + br#"{"status":"failed","error":{"type":"server_error","message":"busy"},"output":[]}"# + ) + .as_deref(), + Some("server_error: busy") + ); + assert_eq!( + responses_error_envelope_message(br#"{"status":"cancelled","output":[]}"#).as_deref(), + Some("cancelled: response generation was cancelled") + ); + assert!(responses_error_envelope_message( + br#"{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[]}"# + ) + .is_none()); + assert!(responses_error_envelope_message( + br#"{"status":"completed","error":null,"output":[]}"# + ) + .is_none()); + } + + #[test] + fn responses_stream_start_semantic_failure_is_retryable() { + let created = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}" + ); + assert!(inspect_responses_start_event(created).is_none()); + + let failed = concat!( + "event: response.failed\n", + "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"boom\"}}}" + ); + assert!(matches!( + inspect_responses_start_event(failed), + Some(Err(ProxyError::TransformError(message))) if message.contains("boom") + )); + + let delta = concat!( + "event: response.output_text.delta\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}" + ); + assert!(matches!(inspect_responses_start_event(delta), Some(Ok(())))); + } + + #[test] + fn responses_stream_start_accepts_unlabelled_whole_json() { + assert!(matches!( + inspect_responses_json_document( + r#"{ + "status": "completed", + + "output": [] + }"# + ), + Some(Ok(())) + )); + assert!(inspect_responses_json_document(r#"{"status":"completed""#).is_none()); + + let failed = inspect_responses_json_document( + r#"{"status":"failed","error":{"message":"backend unavailable"}}"#, + ); + assert!( + matches!(failed, Some(Err(ProxyError::TransformError(message))) if message.contains("backend unavailable")) + ); + } + + #[test] + fn codex_anthropic_cache_is_default_on_but_honors_sub_switch() { + let default = codex_anthropic_cache_config(&OptimizerConfig::default()); + assert!(default.enabled); + assert!(default.cache_injection); + + let disabled = codex_anthropic_cache_config(&OptimizerConfig { + cache_injection: false, + ..OptimizerConfig::default() + }); + assert!(disabled.enabled); + assert!(!disabled.cache_injection); + } + + #[test] + fn invalid_client_history_is_not_retryable() { + let forwarder = test_forwarder(Duration::ZERO, Duration::ZERO); + let provider = test_provider_with_type(None); + assert_eq!( + forwarder.categorize_proxy_error( + &ProxyError::InvalidRequest("invalid historical tool arguments".to_string()), + &provider, + ), + ErrorCategory::NonRetryable + ); + } + + #[test] + fn official_codex_auth_failures_are_not_retryable() { + let forwarder = test_forwarder(Duration::ZERO, Duration::ZERO); + let mut provider = test_provider_with_type(None); + provider.id = "codex-official".to_string(); + provider.category = Some("official".to_string()); + + for error in [ + ProxyError::AuthError("restart Codex".to_string()), + ProxyError::UpstreamError { + status: 401, + body: None, + }, + ProxyError::UpstreamError { + status: 403, + body: None, + }, + ] { + assert_eq!( + forwarder.categorize_proxy_error(&error, &provider), + ErrorCategory::NonRetryable + ); + } + } + + #[test] + fn official_codex_rejects_stale_proxy_placeholder_with_restart_hint() { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::AUTHORIZATION, + HeaderValue::from_static("Bearer PROXY_MANAGED"), + ); + let error = validate_codex_official_authorization(&headers) + .expect_err("stale placeholder must be rejected"); + assert!(matches!(error, ProxyError::AuthError(message) if message.contains("重启 Codex"))); + } + #[test] fn rewrite_codex_responses_compact_endpoint_to_chat_preserves_query() { let (endpoint, passthrough_query) = diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 35c3062ee..4c0b80881 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -18,12 +18,18 @@ use super::{ handler_context::RequestContext, providers::{ codex_chat_common::extract_reasoning_field_text, - codex_chat_history::record_responses_sse_stream, get_adapter, get_claude_api_format, + codex_chat_history::record_responses_sse_stream, + get_adapter, get_claude_api_format, streaming::create_anthropic_sse_stream, + streaming_codex_anthropic::{ + create_responses_sse_stream_from_anthropic_with_context, + responses_sse_events_from_anthropic_message, + }, 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_chat, transform_gemini, transform_responses, + streaming_responses::create_anthropic_sse_stream_from_responses, + transform, transform_codex_anthropic, transform_codex_chat, transform_gemini, + transform_responses, }, response_processor::{ create_logged_passthrough_stream, process_response, read_decoded_body, @@ -277,6 +283,90 @@ fn validate_claude_desktop_gateway_auth( /// Claude 格式转换处理(独有逻辑) /// /// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换 +struct ClaudeUsageLog { + model: String, + request_model: String, + outbound_model: String, + app_type: &'static str, + provider_id: String, + session_id: String, + usage: TokenUsage, + latency_ms: u64, + status_code: u16, + is_streaming: bool, +} + +fn prepare_claude_usage_log( + ctx: &RequestContext, + response: &Value, + status_code: u16, + is_streaming: bool, +) -> Option { + let usage = + TokenUsage::from_claude_response(response).filter(TokenUsage::has_billable_tokens)?; + + let model = response + .get("model") + .and_then(Value::as_str) + .filter(|model| !model.is_empty()) + .map(str::to_string) + .or_else(|| ctx.outbound_model.clone()) + .unwrap_or_else(|| ctx.request_model.clone()); + + Some(ClaudeUsageLog { + model, + request_model: ctx.request_model.clone(), + outbound_model: ctx + .outbound_model + .clone() + .unwrap_or_else(|| ctx.request_model.clone()), + app_type: ctx.app_type_str, + provider_id: ctx.provider.id.clone(), + session_id: ctx.session_id.clone(), + usage, + latency_ms: ctx.latency_ms(), + status_code, + is_streaming, + }) +} + +async fn write_claude_usage_log(state: &ProxyState, log: ClaudeUsageLog) { + log_usage( + state, + &log.provider_id, + log.app_type, + &log.model, + &log.request_model, + &log.outbound_model, + log.usage, + log.latency_ms, + None, + log.is_streaming, + log.status_code, + Some(log.session_id), + ) + .await; +} + +fn spawn_claude_usage_log( + state: &ProxyState, + ctx: &RequestContext, + response: &Value, + status_code: u16, + is_streaming: bool, +) { + if !usage_logging_enabled(state) { + return; + } + let Some(log) = prepare_claude_usage_log(ctx, response, status_code, is_streaming) else { + return; + }; + let state = state.clone(); + tokio::spawn(async move { + write_claude_usage_log(&state, log).await; + }); +} + async fn handle_claude_transform( response: super::hyper_client::ProxyResponse, ctx: &RequestContext, @@ -468,8 +558,20 @@ async fn handle_claude_transform( } }; + // Preserve raw Responses usage so a post-upstream conversion failure still + // records the tokens already consumed by the successful upstream request. + let raw_usage_response = (api_format == "openai_responses").then(|| { + json!({ + "id": upstream_response.get("id").cloned().unwrap_or(Value::Null), + "model": upstream_response.get("model").cloned().unwrap_or(Value::Null), + "usage": transform_responses::build_anthropic_usage_from_responses( + upstream_response.get("usage") + ) + }) + }); + // 根据 api_format 选择非流式转换器 - let anthropic_response = if api_format == "openai_responses" { + let transform_result = if api_format == "openai_responses" { transform_responses::responses_to_anthropic(upstream_response) } else if api_format == "gemini_native" { transform_gemini::gemini_to_anthropic_with_shadow_and_hints( @@ -481,58 +583,28 @@ async fn handle_claude_transform( ) } else { transform::openai_to_anthropic(upstream_response) - } - .map_err(|e| { - log::error!("[Claude] 转换响应失败: {e}"); - e - })?; + }; + let anthropic_response = match transform_result { + Ok(response) => response, + Err(error) => { + log::error!("[Claude] 转换响应失败: {error}"); + if usage_logging_enabled(state) { + if let Some(log) = raw_usage_response.as_ref().and_then(|response| { + prepare_claude_usage_log(ctx, response, status.as_u16(), false) + }) { + // The upstream request already succeeded and consumed tokens. Persist + // usage before returning the terminal transform error to the client. + write_claude_usage_log(state, log).await; + } + } + return Err(error); + } + }; // 记录使用量 // 全 0 usage 不落账(对齐 Codex 流式收集器的 skip):SSE 聚合兜底救回的流 // 在上游缺 stream_options.include_usage 时没有 usage,写入只会产生无意义空行 - if let Some(usage) = - TokenUsage::from_claude_response(&anthropic_response).filter(|u| u.has_billable_tokens()) - { - // 转换后的响应缺失/合成空 model 时,回退到映射后的出站模型(接管真值), - // 再回退到客户端请求别名 - let model = anthropic_response - .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 latency_ms = ctx.latency_ms(); - - 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(); - 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; - } - }); - } + spawn_claude_usage_log(state, ctx, &anthropic_response, status.as_u16(), false); // 构建响应 let mut builder = axum::response::Response::builder().status(status); @@ -686,6 +758,30 @@ pub async fn handle_chat_completions( pub async fn handle_responses( State(state): State, request: axum::extract::Request, +) -> Result { + handle_responses_for_app(state, request, AppType::Codex, "Codex", "codex").await +} + +pub async fn handle_grokbuild_responses( + State(state): State, + request: axum::extract::Request, +) -> Result { + 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 { let (parts, req_body) = request.into_parts(); let method = parts.method.clone(); @@ -702,7 +798,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 @@ -714,7 +810,7 @@ pub async fn handle_responses( let forwarder = ctx.create_forwarder(&state); let mut result = match forwarder .forward_with_retry( - &AppType::Codex, + &app_type, method, &endpoint, body, @@ -739,6 +835,18 @@ pub async fn handle_responses( ctx.provider = result.provider; let response = result.response; + if super::providers::should_convert_codex_responses_to_anthropic(&ctx.provider, &endpoint) { + return handle_codex_anthropic_to_responses_transform( + response, + &ctx, + &state, + is_stream, + connection_guard, + codex_tool_context, + ) + .await; + } + if super::providers::should_convert_codex_responses_to_chat(&ctx.provider, &endpoint) { return handle_codex_chat_to_responses_transform( response, @@ -765,6 +873,30 @@ pub async fn handle_responses( pub async fn handle_responses_compact( State(state): State, request: axum::extract::Request, +) -> Result { + handle_responses_compact_for_app(state, request, AppType::Codex, "Codex", "codex").await +} + +pub async fn handle_grokbuild_responses_compact( + State(state): State, + request: axum::extract::Request, +) -> Result { + 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 { let (parts, req_body) = request.into_parts(); let method = parts.method.clone(); @@ -781,7 +913,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 @@ -793,7 +925,7 @@ pub async fn handle_responses_compact( let forwarder = ctx.create_forwarder(&state); let mut result = match forwarder .forward_with_retry( - &AppType::Codex, + &app_type, method, &endpoint, body, @@ -818,6 +950,18 @@ pub async fn handle_responses_compact( ctx.provider = result.provider; let response = result.response; + if super::providers::should_convert_codex_responses_to_anthropic(&ctx.provider, &endpoint) { + return handle_codex_anthropic_to_responses_transform( + response, + &ctx, + &state, + is_stream, + connection_guard, + codex_tool_context, + ) + .await; + } + if super::providers::should_convert_codex_responses_to_chat(&ctx.provider, &endpoint) { return handle_codex_chat_to_responses_transform( response, @@ -1065,6 +1209,255 @@ async fn handle_codex_chat_to_responses_transform( }) } +/// Response-transform handler for the Codex (Responses) ↔ Anthropic Messages gateway. +/// +/// Parallel to `handle_codex_chat_to_responses_transform`: the upstream speaks +/// Anthropic Messages, and this converts the response back into the Responses form +/// Codex expects (both streaming and non-streaming). Error bodies reuse +/// `handle_codex_chat_error_response` (whose extraction logic also works for +/// Anthropic's `{"error":{type,message}}`). It does not involve codex_chat_history +/// (tool ids round-trip natively through Anthropic). +async fn handle_codex_anthropic_to_responses_transform( + response: super::hyper_client::ProxyResponse, + ctx: &RequestContext, + state: &ProxyState, + is_stream: bool, + connection_guard: Option, + codex_tool_context: transform_codex_chat::CodexToolContext, +) -> Result { + let status = response.status(); + + if !status.is_success() { + return handle_codex_chat_error_response(response, ctx, status).await; + } + + // Preserve live streaming when the gateway marks SSE correctly or omits an + // explicit JSON media type. Explicit JSON is buffered below so 2xx error + // envelopes and gateways that ignore stream:true can be converted faithfully. + if response.is_sse() || (is_stream && !response.is_json()) { + let stream = response.bytes_stream(); + let sse_stream = + create_responses_sse_stream_from_anthropic_with_context(stream, codex_tool_context); + return build_codex_anthropic_sse_response( + sse_stream, + ctx, + state, + status, + 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?; + let body_str = String::from_utf8_lossy(&body_bytes); + let anthropic_response: Value = match serde_json::from_slice(&body_bytes) { + Ok(value) => value, + // Fallback sniffing symmetric to the chat / claude side (#2234): when the + // upstream returns an Anthropic SSE body with an unmarked Content-Type, + // aggregate it back into a message before continuing the conversion. + Err(_) if body_looks_like_sse(&body_str) => { + log::warn!("[Codex] Upstream returned an unmarked Anthropic SSE body, falling back to aggregation"); + transform_codex_anthropic::anthropic_sse_to_message_value(&body_str).map_err(|e| { + log::error!("[Codex] Failed to aggregate Anthropic SSE body: {e}"); + e + })? + } + Err(e) => { + log::error!( + "[Codex] Failed to parse Anthropic upstream response: {e}, body: {body_str}" + ); + return Err(upstream_body_parse_error( + "Failed to parse upstream anthropic response", + &e, + &response_headers, + &body_str, + )); + } + }; + + if is_stream { + let events = + responses_sse_events_from_anthropic_message(&anthropic_response, codex_tool_context); + let sse_stream = futures::stream::iter(events.into_iter().map(Ok::)); + return build_codex_anthropic_sse_response( + sse_stream, + ctx, + state, + status, + connection_guard, + ); + } + + let _connection_guard = connection_guard; + let responses_response = + transform_codex_anthropic::anthropic_response_to_responses_with_context( + anthropic_response, + &codex_tool_context, + ) + .map_err(|e| { + log::error!("[Codex] Failed to convert Anthropic response to Responses: {e}"); + e + })?; + + if let Some(usage) = TokenUsage::from_codex_response_auto(&responses_response) + .filter(TokenUsage::has_billable_tokens) + { + let model = responses_response + .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; + } + }); + } + + strip_entity_headers_for_rebuilt_body(&mut response_headers); + strip_hop_by_hop_response_headers(&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"), + ); + + let response_body = serde_json::to_vec(&responses_response).map_err(|e| { + log::error!("[Codex] Failed to serialize Responses response: {e}"); + ProxyError::TransformError(format!("Failed to serialize responses response: {e}")) + })?; + + builder + .body(axum::body::Body::from(response_body)) + .map_err(|e| { + log::error!("[Codex] Failed to build Responses response: {e}"); + ProxyError::Internal(format!("Failed to build response: {e}")) + }) +} + +fn build_codex_anthropic_sse_response( + sse_stream: impl futures::Stream> + Send + 'static, + ctx: &RequestContext, + state: &ProxyState, + status: StatusCode, + connection_guard: Option, +) -> Result { + let usage_collector = if usage_logging_enabled(state) { + let state = state.clone(); + let provider_id = ctx.provider.id.clone(); + let request_model = ctx.request_model.clone(); + let fallback_model = ctx + .outbound_model + .clone() + .unwrap_or_else(|| ctx.request_model.clone()); + let app_type_str = ctx.app_type_str; + let start_time = ctx.start_time; + let session_id = ctx.session_id.clone(); + + Some(SseUsageCollector::new( + start_time, + Some(codex_stream_usage_event_filter), + move |events, first_token_ms| { + let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap_or_default(); + if !usage.has_billable_tokens() { + log::debug!("[Codex] Anthropic streaming response usage is all-zero or missing, skipping usage recording"); + return; + } + let model = usage + .model + .clone() + .filter(|m| !m.is_empty()) + .unwrap_or_else(|| fallback_model.clone()); + let latency_ms = start_time.elapsed().as_millis() as u64; + + let state = state.clone(); + let provider_id = provider_id.clone(); + let request_model = request_model.clone(); + let outbound_model = fallback_model.clone(); + let session_id = session_id.clone(); + + tokio::spawn(async move { + log_usage( + &state, + &provider_id, + app_type_str, + &model, + &request_model, + &outbound_model, + usage, + latency_ms, + first_token_ms, + true, + status.as_u16(), + Some(session_id), + ) + .await; + }); + }, + )) + } else { + None + }; + + let logged_stream = create_logged_passthrough_stream( + sse_stream, + ctx.tag, + usage_collector, + ctx.streaming_timeout_config(), + connection_guard, + ); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + "Content-Type", + axum::http::HeaderValue::from_static("text/event-stream"), + ); + headers.insert( + "Cache-Control", + axum::http::HeaderValue::from_static("no-cache"), + ); + + let body = axum::body::Body::from_stream(logged_stream); + Ok((headers, body).into_response()) +} + /// 把上游 Chat Completions 的错误响应转换为 Responses API 错误形状。 /// /// 与正常响应分支配套:正常响应已经被改写成 Responses 形式,错误响应若仍保留 diff --git a/src-tauri/src/proxy/hyper_client.rs b/src-tauri/src/proxy/hyper_client.rs index 097542a92..63ff0f998 100644 --- a/src-tauri/src/proxy/hyper_client.rs +++ b/src-tauri/src/proxy/hyper_client.rs @@ -142,6 +142,21 @@ impl ProxyResponse { .unwrap_or(false) } + /// Check whether the response explicitly declares a JSON media type. + pub fn is_json(&self) -> bool { + self.content_type() + .map(|content_type| { + let media_type = content_type + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + media_type == "application/json" || media_type.ends_with("+json") + }) + .unwrap_or(false) + } + /// Consume the response and collect the full body into `Bytes`. pub async fn bytes(self) -> Result { match self { @@ -737,3 +752,27 @@ impl tokio::io::AsyncWrite for WriteFilter { std::task::Poll::Ready(Ok(())) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn buffered_with_content_type(content_type: Option<&str>) -> ProxyResponse { + let mut headers = http::HeaderMap::new(); + if let Some(content_type) = content_type { + headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_str(content_type).unwrap(), + ); + } + ProxyResponse::buffered(http::StatusCode::OK, headers, Bytes::new()) + } + + #[test] + fn json_content_type_detection_accepts_json_suffixes() { + assert!(buffered_with_content_type(Some("application/json; charset=utf-8")).is_json()); + assert!(buffered_with_content_type(Some("application/problem+json")).is_json()); + assert!(!buffered_with_content_type(Some("text/event-stream")).is_json()); + assert!(!buffered_with_content_type(None).is_json()); + } +} diff --git a/src-tauri/src/proxy/media_sanitizer.rs b/src-tauri/src/proxy/media_sanitizer.rs index ad30fd2fc..ec1b4ea0c 100644 --- a/src-tauri/src/proxy/media_sanitizer.rs +++ b/src-tauri/src/proxy/media_sanitizer.rs @@ -1,3 +1,6 @@ +#[cfg(test)] +use crate::model_capabilities::is_confirmed_text_only_model as confirmed_text_only_model; +use crate::model_capabilities::{image_input_capability_from_settings, ImageInputCapability}; use crate::provider::Provider; use crate::proxy::error::ProxyError; use serde_json::{json, Value}; @@ -9,9 +12,9 @@ pub const UNSUPPORTED_IMAGE_MARKER: &str = "[Unsupported Image]"; /// Two paths, both reached only when the caller's media-fallback switch is on: /// - explicit capability from the provider config (modelCatalog / modalities) is /// always trusted — it is declaration-driven, never a guess; -/// - the curated `known_text_only_model` list is a heuristic *prediction* and only -/// runs when `allow_heuristic` is true, so a mislabeled multimodal model cannot -/// have its images silently stripped when the user opts out. +/// - the confirmed text-only registry is used for proactive replacement only +/// when `allow_heuristic` is true. This switch controls silent request-body +/// mutation, not the capability truth advertised by the Codex model catalog. pub fn replace_images_for_text_only_model( body: &mut Value, provider: &Provider, @@ -27,13 +30,9 @@ pub fn replace_images_for_text_only_model( .map(str::trim) .unwrap_or(""); - match explicit_model_image_support(provider, model) { - Some(true) => return 0, - Some(false) => return replace_images_in_body(body), - None => {} - } - - if !allow_heuristic || !known_text_only_model(model) { + if image_input_capability_from_settings(&provider.settings_config, model, allow_heuristic) + != ImageInputCapability::Unsupported + { return 0; } @@ -63,6 +62,19 @@ pub fn is_unsupported_image_error(error: &ProxyError) -> bool { let message = extract_error_text(body); let message = message.to_ascii_lowercase(); + + // 自证性表述:这类短语本身就断言了"仅接受文本",属于模态拒绝,无需再要求 + // 错误提到 image/media 等字样——火山方舟等网关的报错是 + // "Model only support text input",全程不出现 image(issue #5025)。 + // 国产网关的英文常缺三单 s,因此带 s / 不带 s 两种形式都要列。 + const TEXT_ONLY_SELF_EVIDENT_HINTS: &[&str] = &["only support text", "only supports text"]; + if TEXT_ONLY_SELF_EVIDENT_HINTS + .iter() + .any(|hint| message.contains(hint)) + { + return true; + } + let mentions_image = message.contains("image") || message.contains("vision") || message.contains("multimodal") @@ -83,7 +95,6 @@ pub fn is_unsupported_image_error(error: &ProxyError) -> bool { "doesn't support", "do not support", "don't support", - "only supports text", "text only", "text-only", "invalid content type", @@ -225,91 +236,6 @@ fn replace_image_block_with_text_marker(block: &mut Value, text_type: &str) { } } -fn explicit_model_image_support(provider: &Provider, model: &str) -> Option { - let settings = &provider.settings_config; - [ - settings - .get("modelCatalog") - .and_then(|catalog| catalog.get("models")), - settings.get("modelCatalog"), - settings.get("models"), - ] - .into_iter() - .flatten() - .find_map(|value| explicit_model_image_support_in_value(value, model)) -} - -fn known_text_only_model(model: &str) -> bool { - let normalized = normalize_model_id(model); - let tail = normalized.rsplit('/').next().unwrap_or(normalized.as_str()); - - const EXACT_TAILS: &[&str] = &[ - "ark-code-latest", - "deepseek-chat", - "deepseek-reasoner", - "deepseek-v4-flash", - "deepseek-v4-pro", - "glm-5.1", - "kat-coder", - "kat-coder-pro", - "kat-coder-pro v1", - "kat-coder-pro v2", - "kat-coder-pro-v1", - "kat-coder-pro-v2", - "ling-2.5-1t", - "longcat-flash-chat", - "mimo-v2.5-pro", - "us.deepseek.r1-v1", - ]; - - const TAIL_PREFIXES: &[&str] = &["minimax-m2.7", "qwen3-coder", "step-3.5-flash"]; - - EXACT_TAILS.contains(&tail) || TAIL_PREFIXES.iter().any(|prefix| tail.starts_with(prefix)) -} - -fn explicit_model_image_support_in_value(value: &Value, model: &str) -> Option { - if let Some(models) = value.as_array() { - return models.iter().find_map(|entry| { - model_entry_matches(entry, None, model).then(|| explicit_image_support(entry))? - }); - } - - let object = value.as_object()?; - object.iter().find_map(|(key, entry)| { - model_entry_matches(entry, Some(key), model).then(|| explicit_image_support(entry))? - }) -} - -fn explicit_image_support(entry: &Value) -> Option { - if let Some(value) = entry - .get("supportsImage") - .or_else(|| entry.get("supports_image")) - .or_else(|| entry.get("vision")) - .and_then(Value::as_bool) - { - return Some(value); - } - - [ - entry.get("input"), - entry.pointer("/modalities/input"), - entry.get("input_modalities"), - entry.get("inputModalities"), - ] - .into_iter() - .flatten() - .find_map(input_modalities_support_image) -} - -fn input_modalities_support_image(value: &Value) -> Option { - let modalities = value.as_array()?; - Some(modalities.iter().any(|item| { - item.as_str() - .map(str::trim) - .is_some_and(|item| item.eq_ignore_ascii_case("image")) - })) -} - fn extract_error_text(body: &str) -> String { if let Ok(value) = serde_json::from_str::(body) { let candidates = [ @@ -334,43 +260,6 @@ fn extract_error_text(body: &str) -> String { body.to_string() } -fn model_entry_matches(entry: &Value, key: Option<&str>, model: &str) -> bool { - key.is_some_and(|key| model_ids_match(key, model)) - || ["model", "id", "name"] - .into_iter() - .filter_map(|field| entry.get(field).and_then(Value::as_str)) - .any(|candidate| model_ids_match(candidate, model)) -} - -fn model_ids_match(candidate: &str, model: &str) -> bool { - let candidate = normalize_model_id(candidate); - let model = normalize_model_id(model); - if candidate.is_empty() || model.is_empty() { - return false; - } - if candidate == model { - return true; - } - - let candidate_tail = candidate.rsplit('/').next().unwrap_or(candidate.as_str()); - let model_tail = model.rsplit('/').next().unwrap_or(model.as_str()); - candidate_tail == model_tail || candidate == model_tail || candidate_tail == model -} - -fn normalize_model_id(value: &str) -> String { - let mut normalized = value - .trim() - .trim_start_matches("models/") - .trim() - .to_ascii_lowercase(); - if let Some(stripped) = - normalized.strip_suffix(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER) - { - normalized = stripped.trim().to_string(); - } - normalized -} - #[cfg(test)] mod tests { use super::*; @@ -415,7 +304,7 @@ mod tests { } #[test] - fn known_text_only_models_replace_images_before_send() { + fn confirmed_text_only_models_replace_images_before_send() { let provider = provider(json!({})); let mut body = json!({ "model": "deepseek/deepseek-v4-pro", @@ -437,7 +326,7 @@ mod tests { } #[test] - fn known_text_only_models_replace_chat_image_url_before_send() { + fn confirmed_text_only_models_replace_chat_image_url_before_send() { let provider = provider(json!({})); let mut body = json!({ "model": "deepseek-v4-flash", @@ -461,7 +350,7 @@ mod tests { } #[test] - fn known_text_only_models_replace_codex_input_image_before_send() { + fn confirmed_text_only_models_replace_codex_input_image_before_send() { let provider = provider(json!({})); let mut body = json!({ "model": "deepseek-v4-flash", @@ -484,6 +373,15 @@ mod tests { ); } + #[test] + fn longcat_models_are_classified_text_only() { + // LongCat-2.0 (like the retired Flash Chat) is a text-only model; the + // preset ships it in mixed case, so the classifier must normalize first. + assert!(confirmed_text_only_model("LongCat-2.0")); + assert!(confirmed_text_only_model("longcat/LongCat-2.0")); + assert!(confirmed_text_only_model("LongCat-Flash-Chat")); + } + #[test] fn explicit_text_modalities_replace_images_before_send() { let provider = provider(json!({ @@ -637,7 +535,7 @@ mod tests { } #[test] - fn known_text_only_prefixes_replace_images_before_send() { + fn confirmed_text_only_variant_replaces_images_before_send() { let provider = provider(json!({})); let mut body = json!({ "model": "therouter/qwen/qwen3-coder-480b", @@ -717,6 +615,32 @@ mod tests { assert!(is_unsupported_image_error(&error)); } + #[test] + fn detects_text_only_errors_without_image_mention() { + // 火山方舟真实报错(issue #5025):不含 image/media 等字样,且英文缺 + // 三单 s——旧逻辑的 mentions_image 门与 "only supports text" 提示都拦不住。 + let error = ProxyError::UpstreamError { + status: 400, + body: Some( + r#"{"error":{"message":"Model only support text input Request id: 021783"}}"# + .to_string(), + ), + }; + + assert!(is_unsupported_image_error(&error)); + } + + #[test] + fn glm_52_is_classified_text_only() { + // issue #5025:火山 Coding Plan 的 GLM 5.2 是纯文本端点, + // 映射链 glm-5.2[1M] 归一化后尾部为 glm-5.2。 + assert!(confirmed_text_only_model("glm-5.2")); + assert!(confirmed_text_only_model("GLM-5.2[1M]")); + assert!(confirmed_text_only_model("zai-org/GLM-5.2")); + // 未来视觉版(智谱 4v/5v 命名惯例)不能被误判为纯文本。 + assert!(!confirmed_text_only_model("glm-5.2v")); + } + #[test] fn ignores_non_image_errors() { let error = ProxyError::UpstreamError { diff --git a/src-tauri/src/proxy/model_mapper.rs b/src-tauri/src/proxy/model_mapper.rs index b2a18826e..bf1f413fa 100644 --- a/src-tauri/src/proxy/model_mapper.rs +++ b/src-tauri/src/proxy/model_mapper.rs @@ -12,6 +12,7 @@ pub struct ModelMapping { pub sonnet_model: Option, pub opus_model: Option, pub fable_model: Option, + pub subagent_model: Option, pub default_model: Option, } @@ -41,6 +42,11 @@ impl ModelMapping { .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(String::from), + subagent_model: env + .and_then(|e| e.get("CLAUDE_CODE_SUBAGENT_MODEL")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from), default_model: env .and_then(|e| e.get("ANTHROPIC_MODEL")) .and_then(|v| v.as_str()) @@ -55,6 +61,7 @@ impl ModelMapping { || self.sonnet_model.is_some() || self.opus_model.is_some() || self.fable_model.is_some() + || self.subagent_model.is_some() || self.default_model.is_some() } @@ -89,6 +96,13 @@ impl ModelMapping { } } + if let Some(ref m) = self.subagent_model { + if strip_one_m_suffix_for_upstream(original_model) == strip_one_m_suffix_for_upstream(m) + { + return original_model.to_string(); + } + } + // 2. 默认模型 if let Some(ref m) = self.default_model { return m.clone(); @@ -327,6 +341,42 @@ mod tests { assert_eq!(mapped, Some("default-model".to_string())); } + #[test] + fn test_subagent_model_preserved_before_default_fallback() { + let mut provider = create_provider_with_mapping(); + provider.settings_config = json!({ + "env": { + "ANTHROPIC_MODEL": "default-model", + "CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini" + } + }); + + let body = json!({"model": "gpt-5.4-mini"}); + let (result, original, mapped) = apply_model_mapping(body, &provider); + + assert_eq!(result["model"], "gpt-5.4-mini"); + assert_eq!(original, Some("gpt-5.4-mini".to_string())); + assert!(mapped.is_none()); + } + + #[test] + fn test_subagent_model_preserved_with_one_m_suffix_before_default_fallback() { + let mut provider = create_provider_with_mapping(); + provider.settings_config = json!({ + "env": { + "ANTHROPIC_MODEL": "default-model", + "CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini" + } + }); + + let body = json!({"model": "gpt-5.4-mini[1M]"}); + let (result, original, mapped) = apply_model_mapping(body, &provider); + + assert_eq!(result["model"], "gpt-5.4-mini[1M]"); + assert_eq!(original, Some("gpt-5.4-mini[1M]".to_string())); + assert!(mapped.is_none()); + } + #[test] fn test_no_mapping_configured() { let provider = create_provider_without_mapping(); diff --git a/src-tauri/src/proxy/providers/auth.rs b/src-tauri/src/proxy/providers/auth.rs index 6bd9a02a5..04242eb25 100644 --- a/src-tauri/src/proxy/providers/auth.rs +++ b/src-tauri/src/proxy/providers/auth.rs @@ -124,7 +124,7 @@ pub enum AuthStrategy { /// /// - Header: `Authorization: Bearer ` /// - Header: `ChatGPT-Account-Id: ` (来自 forwarder 注入) - /// - Header: `originator: cc-switch` + /// - Header: `originator: codex_cli_rs` + `version: `(成对,后端按此做模型 cohort 路由) /// /// 使用动态获取的 OpenAI access_token(通过 Device Code 流程获取) CodexOAuth, diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index c8db6b919..bfde18b6f 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -24,6 +24,13 @@ const ANTHROPIC_REDACTED_THINKING_PLACEHOLDER: &str = "[redacted thinking]"; // Keep hints lowercase; matching lowercases only the input value. const REASONING_VENDOR_HINTS: &[&str] = &["moonshot", "kimi", "deepseek", "mimo", "xiaomimimo"]; +// ChatGPT Codex 后端按 originator+version 组合做模型 cohort 路由:非官方身份会把 +// gpt-5.6-luna 解析到未部署的内部引擎(HTTP 404 Model not found,openai/codex#31967, +// 本机 A/B 实测确认)。两个头必须成对发送,缺一即 404;version 需 ≥ 目标模型 +// catalog 的 minimal_client_version(luna=0.144.0),新模型抬门槛时同步 bump。 +const CODEX_OAUTH_ORIGINATOR: &str = "codex_cli_rs"; +const CODEX_OAUTH_CLIENT_VERSION: &str = "0.144.1"; + /// 获取 Claude 供应商的 API 格式 /// /// 供 handler/forwarder 外部使用的公开函数。 @@ -666,7 +673,7 @@ impl ProviderAdapter for ClaudeAdapter { fn extract_base_url(&self, provider: &Provider) -> Result { // Codex OAuth: 强制使用 ChatGPT 后端 API 端点(忽略用户配置的 base_url) if self.is_codex_oauth(provider) { - return Ok("https://chatgpt.com/backend-api/codex".to_string()); + return Ok(super::CHATGPT_CODEX_BASE_URL.to_string()); } // 1. 从 env 中获取 @@ -778,9 +785,9 @@ impl ProviderAdapter for ClaudeAdapter { fn build_url(&self, base_url: &str, endpoint: &str) -> String { // Codex OAuth: 所有请求统一走 /responses 端点 - if base_url == "https://chatgpt.com/backend-api/codex" { + if base_url == super::CHATGPT_CODEX_BASE_URL { let _ = endpoint; // 忽略原始 endpoint - return "https://chatgpt.com/backend-api/codex/responses".to_string(); + return format!("{}/responses", super::CHATGPT_CODEX_BASE_URL); } // NOTE: @@ -843,7 +850,11 @@ impl ProviderAdapter for ClaudeAdapter { (HeaderName::from_static("authorization"), hv(&bearer)?), ( HeaderName::from_static("originator"), - HeaderValue::from_static("cc-switch"), + HeaderValue::from_static(CODEX_OAUTH_ORIGINATOR), + ), + ( + HeaderName::from_static("version"), + HeaderValue::from_static(CODEX_OAUTH_CLIENT_VERSION), ), ] } diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index 35ada150a..b68a94df1 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -84,6 +84,158 @@ pub fn should_convert_codex_responses_to_chat(provider: &Provider, endpoint: &st ) && codex_provider_uses_chat_completions(provider) } +/// Whether a converted Codex Responses request may send `prompt_cache_key` to +/// its Chat Completions upstream. Unknown OpenAI-compatible gateways default to +/// false because many reject unsupported request fields with HTTP 400. +pub fn should_send_codex_chat_prompt_cache_key(provider: &Provider) -> bool { + match provider + .meta + .as_ref() + .and_then(|meta| meta.prompt_cache_routing.as_deref()) + .unwrap_or("auto") + { + "enabled" => return true, + "disabled" => return false, + _ => {} + } + + let base_url = provider + .settings_config + .get("base_url") + .or_else(|| provider.settings_config.get("baseURL")) + .and_then(|value| value.as_str()) + .map(ToString::to_string) + .or_else(|| { + provider + .settings_config + .get("config") + .and_then(|value| value.as_str()) + .and_then(extract_codex_base_url_from_toml) + }); + + let Some(base_url) = base_url else { + return false; + }; + let Ok(url) = url::Url::parse(&base_url) else { + return false; + }; + + match url.host_str() { + Some("api.openai.com") => true, + Some("api.kimi.com") => { + let path = url.path().trim_end_matches('/'); + path == "/coding" || path.starts_with("/coding/") + } + _ => false, + } +} + +/// Add a stable cache-routing key after Responses -> Chat conversion. An +/// explicit client key wins; otherwise only a real client-provided session ID +/// is eligible. Generated per-request UUIDs must never be used here. +pub fn inject_codex_chat_prompt_cache_key( + provider: &Provider, + chat_body: &mut JsonValue, + explicit_key: Option<&str>, + client_session_id: Option<&str>, +) -> bool { + if !should_send_codex_chat_prompt_cache_key(provider) { + return false; + } + + let key = explicit_key + .map(str::trim) + .filter(|key| !key.is_empty()) + .or_else(|| { + client_session_id + .map(str::trim) + .filter(|session_id| !session_id.is_empty()) + }); + let Some(key) = key else { + return false; + }; + + chat_body["prompt_cache_key"] = JsonValue::String(key.to_string()); + true +} + +/// Whether this Codex provider's real upstream speaks the native Anthropic +/// Messages protocol (`/v1/messages`). The local Codex client always talks to CC +/// Switch through the Responses API, so CC Switch bridges Responses ⇄ Anthropic. +/// +/// Determined solely from explicit config (apiFormat / wire_api); no base_url +/// guessing — Anthropic gateway addresses vary widely and guessing easily misfires. +pub fn codex_provider_uses_anthropic(provider: &Provider) -> bool { + if let Some(api_format) = provider + .meta + .as_ref() + .and_then(|meta| meta.api_format.as_deref()) + .or_else(|| { + provider + .settings_config + .get("api_format") + .and_then(|v| v.as_str()) + }) + .or_else(|| { + provider + .settings_config + .get("apiFormat") + .and_then(|v| v.as_str()) + }) + { + return is_anthropic_wire_api(api_format); + } + + provider + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .and_then(extract_codex_wire_api_from_toml) + .map(|wire_api| is_anthropic_wire_api(&wire_api)) + .unwrap_or(false) +} + +pub fn should_convert_codex_responses_to_anthropic(provider: &Provider, endpoint: &str) -> bool { + let path = endpoint + .split_once('?') + .map_or(endpoint, |(path, _query)| path); + + matches!( + path, + "/responses" | "/v1/responses" | "/responses/compact" | "/v1/responses/compact" + ) && codex_provider_uses_anthropic(provider) +} + +/// 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`). +pub fn is_codex_official_provider(provider: &Provider) -> bool { + provider.id == crate::database::CODEX_OFFICIAL_PROVIDER_ID + && provider.category.as_deref() == Some("official") +} + +/// Resolve the model-catalog tool profile for a Codex provider using the SAME +/// Anthropic detection as the proxy router ([`codex_provider_uses_anthropic`]), so the +/// generated catalog never disagrees with the routed transform. A provider whose +/// Anthropic upstream is declared only via settings `apiFormat` or TOML `wire_api` +/// (not `meta.api_format`) would otherwise get a `ProxyChat` catalog and emit the +/// freeform `apply_patch` tool that the Anthropic transform then silently drops. +/// Non-Anthropic providers keep the existing `meta.api_format` classification. +pub fn resolve_codex_catalog_tool_profile( + provider: &Provider, +) -> crate::codex_config::CodexCatalogToolProfile { + use crate::codex_config::CodexCatalogToolProfile; + if is_codex_official_provider(provider) { + return CodexCatalogToolProfile::NativeResponses; + } + if codex_provider_uses_anthropic(provider) { + return CodexCatalogToolProfile::Anthropic; + } + CodexCatalogToolProfile::from_api_format( + provider.meta.as_ref().and_then(|m| m.api_format.as_deref()), + ) +} + /// Extract the real upstream model configured for a Codex provider. pub fn codex_provider_upstream_model(provider: &Provider) -> Option { provider @@ -98,7 +250,11 @@ pub fn codex_provider_upstream_model(provider: &Provider) -> Option { .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)) + }) }) } @@ -129,7 +285,13 @@ pub fn apply_codex_chat_upstream_model( if !codex_provider_uses_chat_completions(provider) { return None; } + apply_codex_upstream_model(provider, body) +} +/// Same model-substitution logic as `apply_codex_chat_upstream_model`, but without +/// the chat gating check. Reused by the anthropic conversion path (the forwarder has +/// already confirmed this provider uses anthropic). +pub fn apply_codex_upstream_model(provider: &Provider, body: &mut JsonValue) -> Option { let catalog_model_ids = codex_provider_catalog_model_ids(provider); if let Some(request_model) = body .get("model") @@ -345,6 +507,13 @@ fn is_chat_wire_api(value: &str) -> bool { ) } +fn is_anthropic_wire_api(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "anthropic" | "anthropic_messages" | "anthropic-messages" | "claude" | "messages" + ) +} + fn is_chat_completions_url(value: &str) -> bool { value .trim_end_matches('/') @@ -456,6 +625,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) { @@ -480,6 +652,10 @@ impl ProviderAdapter for CodexAdapter { } fn extract_base_url(&self, provider: &Provider) -> Result { + if is_codex_official_provider(provider) { + return Ok(super::CHATGPT_CODEX_BASE_URL.to_string()); + } + // 1. 尝试直接获取 base_url 字段 if let Some(url) = provider .settings_config @@ -506,6 +682,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('"') { @@ -527,8 +706,28 @@ impl ProviderAdapter for CodexAdapter { } fn extract_auth(&self, provider: &Provider) -> Option { + // 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) + // The two are mutually exclusive to avoid a 401 from the gateway receiving + // both auth headers at once. All other Codex upstreams stay pure Bearer. + let strategy = if codex_provider_uses_anthropic(provider) { + let uses_x_api_key = provider + .meta + .as_ref() + .and_then(|meta| meta.api_key_field.as_deref()) + .map(|field| field.eq_ignore_ascii_case("ANTHROPIC_API_KEY")) + .unwrap_or(false); + if uses_x_api_key { + AuthStrategy::Anthropic + } else { + AuthStrategy::Bearer + } + } else { + AuthStrategy::Bearer + }; self.extract_key(provider) - .map(|key| AuthInfo::new(key, AuthStrategy::Bearer)) + .map(|key| AuthInfo::new(key, strategy)) } fn build_url(&self, base_url: &str, endpoint: &str) -> String { @@ -569,6 +768,15 @@ impl ProviderAdapter for CodexAdapter { ) -> Result, ProxyError> { use super::adapter::auth_header_value; let bearer = format!("Bearer {}", auth.api_key); + // Anthropic gateway: send only x-api-key (anthropic-version is filled in by + // the forwarder). Mutually exclusive with Bearer to avoid a 401 from the + // gateway receiving both auth headers at once. + if auth.strategy == AuthStrategy::Anthropic { + return Ok(vec![( + http::HeaderName::from_static("x-api-key"), + auth_header_value(&auth.api_key)?, + )]); + } Ok(vec![( http::HeaderName::from_static("authorization"), auth_header_value(&bearer)?, @@ -598,6 +806,155 @@ 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": "" })); + provider.id = "codex-official".to_string(); + provider.category = Some("official".to_string()); + let adapter = CodexAdapter::new(); + + assert!(is_codex_official_provider(&provider)); + assert_eq!( + adapter + .extract_base_url(&provider) + .expect("official base url"), + "https://chatgpt.com/backend-api/codex" + ); + assert!(adapter.extract_auth(&provider).is_none()); + assert_eq!( + adapter.build_url( + "https://chatgpt.com/backend-api/codex", + "/responses/compact" + ), + "https://chatgpt.com/backend-api/codex/responses/compact" + ); + } + + #[test] + fn prompt_cache_routing_auto_enables_known_upstreams_only() { + let kimi = create_provider(json!({ + "config": r#" +model_provider = "custom" +[model_providers.custom] +base_url = "https://api.kimi.com/coding/v1" +wire_api = "responses" +"# + })); + let openai = create_provider(json!({ + "base_url": "https://api.openai.com/v1" + })); + let unknown = create_provider(json!({ + "base_url": "https://strict.example.com/v1" + })); + + assert!(should_send_codex_chat_prompt_cache_key(&kimi)); + assert!(should_send_codex_chat_prompt_cache_key(&openai)); + assert!(!should_send_codex_chat_prompt_cache_key(&unknown)); + } + + #[test] + fn prompt_cache_routing_user_override_wins_over_auto_detection() { + let mut kimi = create_provider(json!({ + "base_url": "https://api.kimi.com/coding/v1" + })); + kimi.meta = Some(crate::provider::ProviderMeta { + prompt_cache_routing: Some("disabled".to_string()), + ..Default::default() + }); + assert!(!should_send_codex_chat_prompt_cache_key(&kimi)); + + let mut unknown = create_provider(json!({ + "base_url": "https://strict.example.com/v1" + })); + unknown.meta = Some(crate::provider::ProviderMeta { + prompt_cache_routing: Some("enabled".to_string()), + ..Default::default() + }); + assert!(should_send_codex_chat_prompt_cache_key(&unknown)); + } + + #[test] + fn prompt_cache_key_prefers_explicit_key_then_real_session() { + let provider = create_provider(json!({ + "base_url": "https://api.kimi.com/coding/v1" + })); + let mut explicit_body = json!({ "model": "kimi-for-coding" }); + assert!(inject_codex_chat_prompt_cache_key( + &provider, + &mut explicit_body, + Some("request-key"), + Some("session-key"), + )); + assert_eq!(explicit_body["prompt_cache_key"], "request-key"); + + let mut session_body = json!({ "model": "kimi-for-coding" }); + assert!(inject_codex_chat_prompt_cache_key( + &provider, + &mut session_body, + None, + Some("session-key"), + )); + assert_eq!(session_body["prompt_cache_key"], "session-key"); + } + + #[test] + fn prompt_cache_key_is_not_injected_without_real_session_or_support() { + let kimi = create_provider(json!({ + "base_url": "https://api.kimi.com/coding/v1" + })); + let mut no_session_body = json!({ "model": "kimi-for-coding" }); + assert!(!inject_codex_chat_prompt_cache_key( + &kimi, + &mut no_session_body, + None, + None, + )); + assert!(no_session_body.get("prompt_cache_key").is_none()); + + let unknown = create_provider(json!({ + "base_url": "https://strict.example.com/v1" + })); + let mut unsupported_body = json!({ "model": "other" }); + assert!(!inject_codex_chat_prompt_cache_key( + &unknown, + &mut unsupported_body, + Some("request-key"), + Some("session-key"), + )); + assert!(unsupported_body.get("prompt_cache_key").is_none()); + } + #[test] fn test_extract_base_url_direct() { let adapter = CodexAdapter::new(); @@ -662,6 +1019,197 @@ experimental_bearer_token = "sk-config-key" assert_eq!(url, "https://api.openai.com/v1/responses"); } + // ==================== anthropic upstream detection ==================== + + #[test] + fn test_uses_anthropic_from_settings_api_format() { + let provider = create_provider(json!({ "apiFormat": "anthropic" })); + assert!(codex_provider_uses_anthropic(&provider)); + + let provider = create_provider(json!({ "api_format": "anthropic_messages" })); + assert!(codex_provider_uses_anthropic(&provider)); + } + + #[test] + fn test_uses_anthropic_from_meta_api_format() { + let mut provider = create_provider(json!({})); + provider.meta = Some(crate::provider::ProviderMeta { + api_format: Some("anthropic".to_string()), + ..Default::default() + }); + assert!(codex_provider_uses_anthropic(&provider)); + } + + #[test] + fn test_uses_anthropic_from_toml_wire_api() { + let provider = create_provider(json!({ + "config": r#"model_provider = "custom" + +[model_providers.custom] +wire_api = "anthropic" +"# + })); + assert!(codex_provider_uses_anthropic(&provider)); + } + + #[test] + fn test_anthropic_false_for_chat_and_responses() { + let chat = create_provider(json!({ "apiFormat": "openai_chat" })); + assert!(!codex_provider_uses_anthropic(&chat)); + let responses = create_provider(json!({ "apiFormat": "openai_responses" })); + assert!(!codex_provider_uses_anthropic(&responses)); + } + + #[test] + fn test_anthropic_and_chat_are_mutually_exclusive() { + let anth = create_provider(json!({ "apiFormat": "anthropic" })); + assert!(codex_provider_uses_anthropic(&anth)); + assert!(!codex_provider_uses_chat_completions(&anth)); + + let chat = create_provider(json!({ "apiFormat": "openai_chat" })); + assert!(codex_provider_uses_chat_completions(&chat)); + assert!(!codex_provider_uses_anthropic(&chat)); + } + + #[test] + fn test_should_convert_responses_to_anthropic_path_guard() { + let provider = create_provider(json!({ "apiFormat": "anthropic" })); + assert!(should_convert_codex_responses_to_anthropic( + &provider, + "/responses" + )); + assert!(should_convert_codex_responses_to_anthropic( + &provider, + "/v1/responses/compact" + )); + assert!(should_convert_codex_responses_to_anthropic( + &provider, + "/responses?x=1" + )); + assert!(!should_convert_codex_responses_to_anthropic( + &provider, + "/chat/completions" + )); + } + + #[test] + fn test_resolve_catalog_profile_matches_router() { + use crate::codex_config::CodexCatalogToolProfile; + + // Anthropic declared only via TOML wire_api (no meta.api_format) must still + // resolve to the Anthropic catalog profile — this is the routing/catalog + // divergence that let apply_patch leak through. + let toml_anthropic = create_provider(json!({ + "config": r#"model_provider = "custom" + +[model_providers.custom] +wire_api = "anthropic" +"# + })); + assert_eq!( + resolve_codex_catalog_tool_profile(&toml_anthropic), + CodexCatalogToolProfile::Anthropic + ); + + // Anthropic via settings apiFormat. + let settings_anthropic = create_provider(json!({ "apiFormat": "anthropic" })); + assert_eq!( + resolve_codex_catalog_tool_profile(&settings_anthropic), + CodexCatalogToolProfile::Anthropic + ); + + // Native openai_responses (meta) → NativeResponses; chat → ProxyChat. + let mut native = create_provider(json!({})); + native.meta = Some(crate::provider::ProviderMeta { + api_format: Some("openai_responses".to_string()), + ..Default::default() + }); + assert_eq!( + resolve_codex_catalog_tool_profile(&native), + CodexCatalogToolProfile::NativeResponses + ); + + let chat = create_provider(json!({ "apiFormat": "openai_chat" })); + assert_eq!( + resolve_codex_catalog_tool_profile(&chat), + CodexCatalogToolProfile::ProxyChat + ); + } + + #[test] + fn test_apply_codex_upstream_model_preserves_one_m_catalog_model() { + // Regression for the [1m] path: a request model carrying the [1m] marker must + // match its catalog entry and be preserved (not overridden by the provider + // default) so the transform can later strip [1m] and emit the context-1m beta. + // This only works because the forwarder no longer strips [1m] before this call + // on the Anthropic path. + let provider = create_provider(json!({ + "config": r#"model_provider = "custom" +model = "claude-opus-4-1" + +[model_providers.custom] +wire_api = "anthropic" +"#, + "modelCatalog": { + "models": [ + { "model": "claude-opus-4-1[1m]" } + ] + } + })); + let mut body = json!({ "model": "claude-opus-4-1[1m]", "input": "hi" }); + let result = apply_codex_upstream_model(&provider, &mut body); + assert_eq!(result.as_deref(), Some("claude-opus-4-1[1m]")); + assert_eq!( + body.get("model").and_then(|v| v.as_str()), + Some("claude-opus-4-1[1m]") + ); + } + + #[test] + fn test_anthropic_auth_defaults_to_bearer() { + // No meta.apiKeyField (defaults to ANTHROPIC_AUTH_TOKEN) → Authorization: Bearer only + let adapter = CodexAdapter::new(); + let provider = create_provider(json!({ + "apiFormat": "anthropic", + "auth": { "OPENAI_API_KEY": "sk-anthropic-key-123" } + })); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.strategy, AuthStrategy::Bearer); + + let headers = adapter.get_auth_headers(&auth).unwrap(); + let names: Vec = headers + .iter() + .map(|(name, _)| name.as_str().to_string()) + .collect(); + assert_eq!(names, vec!["authorization".to_string()]); + } + + #[test] + fn test_anthropic_auth_x_api_key_when_selected() { + // meta.apiKeyField = ANTHROPIC_API_KEY → x-api-key only + let adapter = CodexAdapter::new(); + let mut provider = create_provider(json!({ + "apiFormat": "anthropic", + "auth": { "OPENAI_API_KEY": "sk-anthropic-key-123" } + })); + provider.meta = Some(crate::provider::ProviderMeta { + api_format: Some("anthropic".to_string()), + api_key_field: Some("ANTHROPIC_API_KEY".to_string()), + ..Default::default() + }); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.strategy, AuthStrategy::Anthropic); + + let headers = adapter.get_auth_headers(&auth).unwrap(); + let names: Vec = headers + .iter() + .map(|(name, _)| name.as_str().to_string()) + .collect(); + assert_eq!(names, vec!["x-api-key".to_string()]); + } + #[test] fn test_build_url_origin_adds_v1() { let adapter = CodexAdapter::new(); diff --git a/src-tauri/src/proxy/providers/codex_oauth_auth.rs b/src-tauri/src/proxy/providers/codex_oauth_auth.rs index 41a11ba3c..84188c53d 100644 --- a/src-tauri/src/proxy/providers/codex_oauth_auth.rs +++ b/src-tauri/src/proxy/providers/codex_oauth_auth.rs @@ -202,6 +202,10 @@ struct CodexAccountData { /// 与原生浏览器登录保持一致的 tokens 字段形状;刷新时若返回新值则更新。 #[serde(default, skip_serializing_if = "Option::is_none")] pub id_token: Option, + /// 最近一次取得或采纳这组 OAuth token 的时间。用于在 Codex CLI 与 + /// cc-switch 都可能轮换 refresh_token 时拒绝从 live 采纳更旧的一代。 + #[serde(default)] + pub token_updated_at_ms: i64, } /// 公开的账号信息(返回给前端,复用 GitHubAccount 结构) @@ -253,6 +257,9 @@ pub struct CodexOAuthManager { access_tokens: Arc>>, /// 每个账号的刷新锁 refresh_locks: Arc>>>>, + /// 普通 token 解析/采纳持读锁,账号删除/清空持写锁。删除因此会等待 + /// 已在飞 refresh 完成,也不会因过早清理 refresh_locks 产生第二把账号锁。 + lifecycle_lock: Arc>, /// 进行中的 Device Code 流程:device_auth_id -> {user_code, expires_at_ms} /// 过期条目会在 start_device_flow 时被清理,防止放弃的登录流程导致无界增长 pending_device_codes: Arc>>, @@ -272,6 +279,7 @@ impl CodexOAuthManager { default_account_id: Arc::new(RwLock::new(None)), access_tokens: Arc::new(RwLock::new(HashMap::new())), refresh_locks: Arc::new(RwLock::new(HashMap::new())), + lifecycle_lock: Arc::new(RwLock::new(())), pending_device_codes: Arc::new(RwLock::new(HashMap::new())), storage_path, storage_lock: Arc::new(Mutex::new(())), @@ -420,12 +428,6 @@ impl CodexOAuthManager { .exchange_code_for_tokens(&success.authorization_code, &success.code_verifier) .await?; - // 清理 pending device code - { - let mut pending = self.pending_device_codes.write().await; - pending.remove(device_code); - } - let refresh_token = tokens.refresh_token.clone().ok_or_else(|| { CodexOAuthError::TokenFetchFailed("响应缺少 refresh_token".to_string()) })?; @@ -435,8 +437,9 @@ impl CodexOAuthManager { CodexOAuthError::ParseError("无法从 token 中提取 account_id".to_string()) })?; - // 先落账号(写 accounts + 持久化),再按全局 accounts -> access_tokens 顺序、 - // 在存在性确认下写 token 缓存,遵守「缓存条目只对应存在的账号」。 + let obtained_at_ms = chrono::Utc::now().timestamp_millis(); + // 登录提交与该账号的 refresh/adopt 共用一把 generation 锁;账号和 + // access cache 一次写入,旧刷新响应因此不能覆盖新登录链。 let account = self .add_account_internal( account_id.clone(), @@ -444,24 +447,15 @@ impl CodexOAuthManager { email, // 空字符串视为缺失,避免写出空的 id_token tokens.id_token.clone().filter(|t| !t.trim().is_empty()), + Some(CachedAccessToken { + token: tokens.access_token.clone(), + expires_at_ms: compute_expires_at_ms(tokens.expires_in), + obtained_at_ms, + }), + Some(device_code), ) .await?; - { - let accounts = self.accounts.read().await; - if accounts.contains_key(&account_id) { - let mut tokens_cache = self.access_tokens.write().await; - tokens_cache.insert( - account_id.clone(), - CachedAccessToken { - token: tokens.access_token.clone(), - expires_at_ms: compute_expires_at_ms(tokens.expires_in), - obtained_at_ms: chrono::Utc::now().timestamp_millis(), - }, - ); - } - } - Ok(Some(account)) } @@ -520,12 +514,22 @@ impl CodexOAuthManager { .await?; let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return Err(CodexOAuthError::RefreshTokenInvalid); - } - if !status.is_success() { let text = response.text().await.unwrap_or_default(); + let refresh_error_code = extract_refresh_error_code(&text); + if status == reqwest::StatusCode::UNAUTHORIZED + || status == reqwest::StatusCode::FORBIDDEN + || matches!( + refresh_error_code.as_deref(), + Some( + "refresh_token_expired" + | "refresh_token_reused" + | "refresh_token_invalidated" + ) + ) + { + return Err(CodexOAuthError::RefreshTokenInvalid); + } return Err(CodexOAuthError::TokenFetchFailed(format!( "Refresh 失败: {status} - {text}" ))); @@ -544,6 +548,7 @@ impl CodexOAuthManager { &self, account_id: &str, ) -> Result { + let _lifecycle = self.lifecycle_lock.read().await; Ok(self.resolve_valid_cached_token(account_id).await?.token) } @@ -552,14 +557,9 @@ impl CodexOAuthManager { /// 返回完整 `CachedAccessToken`,使 token 与其 `obtained_at_ms` 天然配套(写托管 /// auth.json 的 `last_refresh` 直接取用),避免分两次读缓存造成的错配。 /// - /// 并发正确性:统一按 `accounts -> access_tokens` 顺序加锁——读/写 token 缓存前都 - /// 在 `accounts` 读锁下确认账号仍存在。配合 `remove_account`/`clear_auth` 在 - /// `accounts` 写锁内原子清缓存,杜绝「已删账号的 token 被写回或被继续返回」。 - /// - /// 已知未覆盖边界(ABA,极窄且可恢复):若一次刷新已用旧 refresh_token 在飞(≤30s - /// 超时),期间同一 `account_id` 被 remove 后又重新登录,则旧刷新返回时可能把旧 - /// generation 的 token 写进新账号。需要 generation/version 校验才能彻底关闭;因触发 - /// 需要「刷新在飞窗口内 remove+重加同一账号」且结果可通过重新登录恢复,暂不引入。 + /// 并发正确性:调用方持 lifecycle 读锁;刷新再按 account refresh mutex → + /// accounts → access_tokens → storage 的顺序提交。remove/clear 持 lifecycle 写锁, + /// 因而会等待在飞刷新并阻断同 account_id 的 ABA 重建。 async fn resolve_valid_cached_token( &self, account_id: &str, @@ -582,6 +582,30 @@ impl CodexOAuthManager { let refresh_lock = self.get_refresh_lock(account_id).await; let _guard = refresh_lock.lock().await; + self.resolve_valid_cached_token_under_lock(account_id).await + } + + /// Resolve a token while the caller owns this account's refresh mutex. + /// Keeping this separate lets the full auth-bundle path hold one generation + /// lock across access/id/refresh reads without recursively locking the mutex. + async fn resolve_valid_cached_token_under_lock( + &self, + account_id: &str, + ) -> Result { + // Codex CLI may have advanced the shared refresh-token generation since + // this manager last used the account. Reload it under the same per-account + // lock before deciding whether a network refresh is necessary. + if let Some((live_refresh, live_id_token, live_last_refresh_ms)) = + crate::codex_config::read_codex_live_auth_refresh_for_account(account_id) + { + self.adopt_account_refresh_token_under_lock( + account_id, + live_refresh, + live_id_token, + live_last_refresh_ms, + ) + .await?; + } // double-check(同样在 accounts 读锁下) { @@ -597,7 +621,7 @@ impl CodexOAuthManager { } } - let refresh_token = { + let mut refresh_token = { let accounts = self.accounts.read().await; accounts .get(account_id) @@ -605,33 +629,79 @@ impl CodexOAuthManager { .ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.to_string()))? }; - let new_tokens = self.refresh_with_token(&refresh_token).await?; + let new_tokens = match self.refresh_with_token(&refresh_token).await { + Err(CodexOAuthError::RefreshTokenInvalid) => { + // If Codex CLI refreshed between our pre-read and request, reload + // its newer generation and retry exactly once. Error-code handling + // includes OpenAI's `refresh_token_reused` response. + let Some((live_refresh, live_id_token, live_last_refresh_ms)) = + crate::codex_config::read_codex_live_auth_refresh_for_account(account_id) + .filter(|(token, _, _)| token.trim() != refresh_token.as_str()) + else { + return Err(CodexOAuthError::RefreshTokenInvalid); + }; + let adopted = self + .adopt_account_refresh_token_under_lock( + account_id, + live_refresh.clone(), + live_id_token, + live_last_refresh_ms, + ) + .await?; + if !adopted { + return Err(CodexOAuthError::RefreshTokenInvalid); + } + refresh_token = live_refresh; + self.refresh_with_token(&refresh_token).await? + } + result => result?, + }; + + let obtained_at_ms = chrono::Utc::now().timestamp_millis(); // 如果服务端返回了新的 refresh_token 或 id_token,更新存储 let mut needs_save = false; - { + let (stored_refresh_token, stored_id_token) = { let mut accounts = self.accounts.write().await; - if let Some(account) = accounts.get_mut(account_id) { - if let Some(new_refresh) = new_tokens.refresh_token.clone() { - if new_refresh != account.refresh_token { - account.refresh_token = new_refresh; - needs_save = true; - } - } - // 刷新使用 openid scope,正常会返回新 id_token;为空则视为缺失, - // 保留旧值而非覆盖(旧值的 claims 仍可用于账号/套餐显示)。 - if let Some(new_id_token) = new_tokens - .id_token - .clone() - .filter(|token| !token.trim().is_empty()) - { - if account.id_token.as_deref() != Some(new_id_token.as_str()) { - account.id_token = Some(new_id_token); - needs_save = true; - } + let account = accounts + .get_mut(account_id) + .ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.to_string()))?; + // Device re-login and CLI-token adoption use the same account lock, + // but keep a generation CAS here as defense in depth: a response for + // R0 must never overwrite a newly committed R1/N0 chain. + if account.refresh_token != refresh_token { + return Err(CodexOAuthError::TokenFetchFailed( + "账号凭据已更新,已丢弃旧刷新响应".to_string(), + )); + } + if let Some(new_refresh) = new_tokens + .refresh_token + .clone() + .filter(|token| !token.trim().is_empty()) + { + if new_refresh != account.refresh_token { + account.refresh_token = new_refresh; + needs_save = true; } } - } + // 刷新使用 openid scope,正常会返回新 id_token;为空则视为缺失, + // 保留旧值而非覆盖(旧值的 claims 仍可用于账号/套餐显示)。 + if let Some(new_id_token) = new_tokens + .id_token + .clone() + .filter(|token| !token.trim().is_empty()) + { + if account.id_token.as_deref() != Some(new_id_token.as_str()) { + account.id_token = Some(new_id_token); + needs_save = true; + } + } + if account.token_updated_at_ms != obtained_at_ms { + account.token_updated_at_ms = obtained_at_ms; + needs_save = true; + } + (account.refresh_token.clone(), account.id_token.clone()) + }; if needs_save { self.save_to_disk().await?; } @@ -639,9 +709,31 @@ impl CodexOAuthManager { let cached = CachedAccessToken { token: new_tokens.access_token.clone(), expires_at_ms: compute_expires_at_ms(new_tokens.expires_in), - obtained_at_ms: chrono::Utc::now().timestamp_millis(), + obtained_at_ms, }; + let last_refresh = chrono::DateTime::::from_timestamp_millis(obtained_at_ms) + .unwrap_or_else(chrono::Utc::now) + .to_rfc3339_opts(chrono::SecondsFormat::Nanos, true); + let refreshed_auth = crate::codex_config::codex_managed_oauth_auth_value( + account_id, + &cached.token, + stored_id_token.as_deref(), + &stored_refresh_token, + &last_refresh, + ); + if let Err(err) = crate::codex_config::sync_codex_managed_oauth_live_auth_after_refresh( + account_id, + &refresh_token, + &refreshed_auth, + ) { + // The manager token remains valid; a later provider write will + // retry the live synchronization without rolling it back. + log::warn!( + "[CodexOAuth] 同步刷新后的 Codex live auth 失败(account={account_id}): {err}" + ); + } + // 在 accounts 读锁下确认账号仍存在,再写缓存:与 remove/clear(持 accounts // 写锁并原子清缓存)互斥,杜绝把已删账号的 token 写回缓存。 { @@ -665,13 +757,8 @@ impl CodexOAuthManager { &self, account_id: &str, ) -> Result<(String, Option), CodexOAuthError> { - // 先确保 access_token 有效;刷新过程会顺带更新持久化的 id_token - let access_token = self.get_valid_token_for_account(account_id).await?; - let id_token = { - let accounts = self.accounts.read().await; - accounts.get(account_id).and_then(|a| a.id_token.clone()) - }; - Ok((access_token, id_token)) + let bundle = self.get_valid_token_bundle_for_account(account_id).await?; + Ok((bundle.access_token, bundle.id_token)) } /// 获取写入托管 Codex `auth.json` 所需的完整可刷新 token 束 @@ -684,9 +771,16 @@ impl CodexOAuthManager { &self, account_id: &str, ) -> Result { - // access_token 与其获取时间来自同一次解析(缓存命中即同一条目、刷新即新铸), - // 天然配套,杜绝「旧 token + 新时间戳」的错配。 - let cached = self.resolve_valid_cached_token(account_id).await?; + let _lifecycle = self.lifecycle_lock.read().await; + let refresh_lock = self.get_refresh_lock(account_id).await; + let _refresh_guard = refresh_lock.lock().await; + + // Resolve and read every persistent token field while holding the same + // account generation lock. Otherwise an adoption between these reads + // can create an invalid A0 + R1/ID1 mixed bundle. + let cached = self + .resolve_valid_cached_token_under_lock(account_id) + .await?; let last_refresh = chrono::DateTime::::from_timestamp_millis(cached.obtained_at_ms) .unwrap_or_else(chrono::Utc::now) @@ -718,7 +812,9 @@ impl CodexOAuthManager { account_id: &str, refresh_token: String, id_token: Option, + last_refresh_ms: Option, ) -> Result { + let _lifecycle = self.lifecycle_lock.read().await; let refresh_token = refresh_token.trim().to_string(); if refresh_token.is_empty() { return Ok(false); @@ -727,6 +823,25 @@ impl CodexOAuthManager { // 覆盖我们刚采纳的 CLI 轮换值。 let refresh_lock = self.get_refresh_lock(account_id).await; let _guard = refresh_lock.lock().await; + self.adopt_account_refresh_token_under_lock( + account_id, + refresh_token, + id_token, + last_refresh_ms, + ) + .await + } + + /// Same as `adopt_account_refresh_token`, for callers already holding the + /// per-account refresh lock. + async fn adopt_account_refresh_token_under_lock( + &self, + account_id: &str, + refresh_token: String, + id_token: Option, + last_refresh_ms: Option, + ) -> Result { + let incoming_id_token = id_token.filter(|token| !token.trim().is_empty()); let mut changed = false; { let mut accounts = self.accounts.write().await; @@ -734,16 +849,46 @@ impl CodexOAuthManager { // 不是本 manager 托管的账号:不接管、不落盘。 return Ok(false); }; - if account.refresh_token != refresh_token { + + // A manager refresh may already have advanced the token generation + // while auth.json still contains the older one. Never roll that + // state back during the preflight/write double-build sequence. + let refresh_changed = account.refresh_token != refresh_token; + let id_token_changed = incoming_id_token + .as_ref() + .is_some_and(|token| account.id_token.as_deref() != Some(token.as_str())); + let material_changed = refresh_changed || id_token_changed; + // Once the manager has a dated generation, any different token + // material must carry a *strictly newer* live timestamp. Equality is + // ambiguous at millisecond precision and therefore cannot authorize + // replacing the manager generation either. + let observed_is_newer = account.token_updated_at_ms <= 0 + || last_refresh_ms.is_some_and(|observed| observed > account.token_updated_at_ms); + if material_changed && !observed_is_newer { + return Ok(false); + } + if refresh_changed { account.refresh_token = refresh_token; changed = true; } - if let Some(id_token) = id_token.filter(|token| !token.trim().is_empty()) { + if let Some(id_token) = incoming_id_token { if account.id_token.as_deref() != Some(id_token.as_str()) { account.id_token = Some(id_token); changed = true; } } + if let Some(observed) = last_refresh_ms { + if observed > account.token_updated_at_ms { + account.token_updated_at_ms = observed; + changed = true; + } + } else if material_changed && account.token_updated_at_ms <= 0 { + // One-time migration for stores written before generation + // timestamps existed. Dating the adopted value prevents another + // undated live file from rolling it back later. + account.token_updated_at_ms = chrono::Utc::now().timestamp_millis(); + changed = true; + } // 采纳了 CLI 轮换后的 refresh_token:与之配套的旧 access_token 可能已被 // 服务端提前失效。在同一 accounts 写锁内(accounts -> access_tokens 顺序) // 清缓存,避免释放锁后被快路径读到旧 token;下次按新 refresh_token 重取。 @@ -782,6 +927,10 @@ impl CodexOAuthManager { pub async fn remove_account(&self, account_id: &str) -> Result<(), CodexOAuthError> { log::info!("[CodexOAuth] 移除账号: {account_id}"); + // Wait for all in-flight refresh/adopt operations before deleting. New + // token work is blocked until the account, cache, lock and disk state + // have been removed as one lifecycle transition. + let _lifecycle = self.lifecycle_lock.write().await; { // 在 accounts 写锁内原子清除该账号的 token 缓存(accounts -> access_tokens @@ -810,6 +959,7 @@ impl CodexOAuthManager { } pub async fn set_default_account(&self, account_id: &str) -> Result<(), CodexOAuthError> { + let _lifecycle = self.lifecycle_lock.read().await; { let accounts = self.accounts.read().await; if !accounts.contains_key(account_id) { @@ -829,6 +979,12 @@ impl CodexOAuthManager { pub async fn clear_auth(&self) -> Result<(), CodexOAuthError> { log::info!("[CodexOAuth] 清除所有认证"); + // Acquire lifecycle before storage. Refresh follows lifecycle(read) -> + // account mutex -> storage, so this fixed order cannot deadlock and the + // write guard guarantees no refresh can recreate live/disk state after + // the clear has committed. + let _lifecycle = self.lifecycle_lock.write().await; + // 与 save_to_disk 共用持久化锁:确保「清内存 + 删文件」相对于并发保存原子, // 不会被一个持有旧快照的 save 复活已清除的账号。 let _persist = self.storage_lock.lock().await; @@ -892,24 +1048,21 @@ impl CodexOAuthManager { access_token: &str, id_token: Option<&str>, ) -> Result<(), CodexOAuthError> { + let obtained_at_ms = chrono::Utc::now().timestamp_millis(); self.add_account_internal( account_id.to_string(), "test-refresh-token".to_string(), Some(format!("{account_id}@example.test")), id_token.map(|token| token.to_string()), + Some(CachedAccessToken { + token: access_token.to_string(), + expires_at_ms: obtained_at_ms + 3_600_000, + obtained_at_ms, + }), + None, ) .await?; - let mut tokens = self.access_tokens.write().await; - tokens.insert( - account_id.to_string(), - CachedAccessToken { - token: access_token.to_string(), - expires_at_ms: chrono::Utc::now().timestamp_millis() + 3_600_000, - obtained_at_ms: chrono::Utc::now().timestamp_millis(), - }, - ); - Ok(()) } @@ -921,8 +1074,28 @@ impl CodexOAuthManager { refresh_token: String, email: Option, id_token: Option, + initial_access_token: Option, + pending_device_code: Option<&str>, ) -> Result { + let _lifecycle = self.lifecycle_lock.read().await; + if let Some(device_code) = pending_device_code { + // `clear_auth` owns lifecycle(write) while clearing pending flows. + // Re-check under lifecycle(read) at commit time so a poll that was + // already on the network cannot recreate an account after clear. + if self + .pending_device_codes + .write() + .await + .remove(device_code) + .is_none() + { + return Err(CodexOAuthError::ExpiredToken); + } + } + let refresh_lock = self.get_refresh_lock(&account_id).await; + let _refresh_guard = refresh_lock.lock().await; let now = chrono::Utc::now().timestamp(); + let now_ms = chrono::Utc::now().timestamp_millis(); let data = CodexAccountData { account_id: account_id.clone(), @@ -930,6 +1103,7 @@ impl CodexOAuthManager { refresh_token, authenticated_at: now, id_token, + token_updated_at_ms: now_ms, }; let account = GitHubAccount::from(&data); @@ -937,6 +1111,12 @@ impl CodexOAuthManager { { let mut accounts = self.accounts.write().await; accounts.insert(account_id.clone(), data); + let mut access_tokens = self.access_tokens.write().await; + if let Some(cached) = initial_access_token { + access_tokens.insert(account_id.clone(), cached); + } else { + access_tokens.remove(&account_id); + } } { @@ -1143,6 +1323,19 @@ fn compute_expires_at_ms(expires_in: Option) -> i64 { now_ms + secs * 1000 } +fn extract_refresh_error_code(body: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(body).ok()?; + value + .get("error") + .and_then(|error| match error { + serde_json::Value::Object(object) => object.get("code").and_then(|code| code.as_str()), + serde_json::Value::String(code) => Some(code.as_str()), + _ => None, + }) + .or_else(|| value.get("code").and_then(|code| code.as_str())) + .map(|code| code.to_ascii_lowercase()) +} + /// 解析 JWT 中的 claims fn parse_jwt_claims(token: &str) -> Option { let parts: Vec<&str> = token.split('.').collect(); @@ -1317,6 +1510,8 @@ mod tests { "rt-secret".to_string(), Some("user@example.com".to_string()), None, + None, + None, ) .await .unwrap(); @@ -1340,6 +1535,8 @@ mod tests { "rt".to_string(), Some("a@example.com".to_string()), None, + None, + None, ) .await .unwrap(); @@ -1349,6 +1546,8 @@ mod tests { "rt2".to_string(), Some("b@example.com".to_string()), None, + None, + None, ) .await .unwrap(); @@ -1368,12 +1567,20 @@ mod tests { .await .unwrap(); - // 采纳 Codex CLI 轮换后的 refresh_token / id_token。 + // 采纳带有更新 last_refresh 的 Codex CLI 轮换 refresh_token / id_token。 + let manager_updated_at = manager + .accounts + .read() + .await + .get("acc-1") + .expect("account present") + .token_updated_at_ms; let changed = manager .adopt_account_refresh_token( "acc-1", "rotated-rt".to_string(), Some("id-2".to_string()), + Some(manager_updated_at.saturating_add(1)), ) .await .unwrap(); @@ -1395,14 +1602,208 @@ mod tests { // 未知账号不接管。 assert!(!manager - .adopt_account_refresh_token("acc-unknown", "x".to_string(), None) + .adopt_account_refresh_token("acc-unknown", "x".to_string(), None, None) .await .unwrap()); // 相同值不算变化。 assert!(!manager - .adopt_account_refresh_token("acc-1", "rotated-rt".to_string(), None) + .adopt_account_refresh_token("acc-1", "rotated-rt".to_string(), None, None) .await .unwrap()); } + + #[tokio::test] + async fn adopt_account_refresh_token_rejects_older_live_generation() { + let temp = tempfile::tempdir().unwrap(); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + manager + .add_test_account_with_access_token("acc-1", "access-cached", Some("id-1")) + .await + .unwrap(); + + let manager_updated_at = manager + .accounts + .read() + .await + .get("acc-1") + .expect("account present") + .token_updated_at_ms; + let changed = manager + .adopt_account_refresh_token( + "acc-1", + "stale-live-refresh".to_string(), + None, + Some(manager_updated_at.saturating_sub(1)), + ) + .await + .unwrap(); + + assert!(!changed, "older live state must not roll the manager back"); + assert_eq!( + manager + .accounts + .read() + .await + .get("acc-1") + .expect("account present") + .refresh_token, + "test-refresh-token" + ); + } + + #[tokio::test] + async fn adopt_account_refresh_token_rejects_undated_live_generation() { + let temp = tempfile::tempdir().unwrap(); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + manager + .add_test_account_with_access_token("acc-1", "access-cached", Some("id-1")) + .await + .unwrap(); + + let changed = manager + .adopt_account_refresh_token("acc-1", "ambiguous-live-refresh".to_string(), None, None) + .await + .unwrap(); + + assert!( + !changed, + "an undated live token must not roll back a timestamped manager generation" + ); + assert_eq!( + manager + .accounts + .read() + .await + .get("acc-1") + .expect("account present") + .refresh_token, + "test-refresh-token" + ); + } + + #[tokio::test] + async fn adopt_account_refresh_token_rejects_stale_id_token_with_same_refresh() { + let temp = tempfile::tempdir().unwrap(); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + manager + .add_test_account_with_access_token("acc-1", "access-cached", Some("id-new")) + .await + .unwrap(); + let manager_updated_at = manager + .accounts + .read() + .await + .get("acc-1") + .expect("account present") + .token_updated_at_ms; + + let changed = manager + .adopt_account_refresh_token( + "acc-1", + "test-refresh-token".to_string(), + Some("id-stale".to_string()), + Some(manager_updated_at.saturating_sub(1)), + ) + .await + .unwrap(); + + assert!(!changed); + assert_eq!( + manager + .accounts + .read() + .await + .get("acc-1") + .expect("account present") + .id_token + .as_deref(), + Some("id-new") + ); + } + + #[tokio::test] + async fn adopt_account_refresh_token_rejects_equal_timestamp_generation() { + let temp = tempfile::tempdir().unwrap(); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + manager + .add_test_account_with_access_token("acc-1", "access-cached", Some("id-1")) + .await + .unwrap(); + let manager_updated_at = manager + .accounts + .read() + .await + .get("acc-1") + .expect("account present") + .token_updated_at_ms; + + let changed = manager + .adopt_account_refresh_token( + "acc-1", + "same-millisecond-refresh".to_string(), + None, + Some(manager_updated_at), + ) + .await + .unwrap(); + + assert!(!changed); + assert_eq!( + manager + .accounts + .read() + .await + .get("acc-1") + .expect("account present") + .refresh_token, + "test-refresh-token" + ); + } + + #[tokio::test] + async fn device_commit_rejects_flow_cleared_during_network_poll() { + let temp = tempfile::tempdir().unwrap(); + let manager = CodexOAuthManager::new(temp.path().to_path_buf()); + manager.pending_device_codes.write().await.insert( + "device-auth-id".to_string(), + PendingDeviceCode { + user_code: "ABCD-EFGH".to_string(), + expires_at_ms: chrono::Utc::now().timestamp_millis() + 60_000, + }, + ); + + manager.clear_auth().await.unwrap(); + let result = manager + .add_account_internal( + "acc-after-clear".to_string(), + "refresh-after-clear".to_string(), + None, + None, + None, + Some("device-auth-id"), + ) + .await; + + assert!(matches!(result, Err(CodexOAuthError::ExpiredToken))); + assert!(manager.list_accounts().await.is_empty()); + assert!(!manager.storage_path.exists()); + } + + #[test] + fn refresh_error_code_accepts_openai_error_shapes() { + assert_eq!( + extract_refresh_error_code(r#"{"error":{"code":"refresh_token_reused"}}"#).as_deref(), + Some("refresh_token_reused") + ); + assert_eq!( + extract_refresh_error_code(r#"{"error":"refresh_token_expired"}"#).as_deref(), + Some("refresh_token_expired") + ); + assert_eq!( + extract_refresh_error_code(r#"{"code":"REFRESH_TOKEN_INVALIDATED"}"#).as_deref(), + Some("refresh_token_invalidated") + ); + assert_eq!(extract_refresh_error_code("not json"), None); + } } diff --git a/src-tauri/src/proxy/providers/codex_responses_sse.rs b/src-tauri/src/proxy/providers/codex_responses_sse.rs new file mode 100644 index 000000000..d347e8a49 --- /dev/null +++ b/src-tauri/src/proxy/providers/codex_responses_sse.rs @@ -0,0 +1,399 @@ +//! Shared builders for the OpenAI Responses SSE envelope. +//! +//! The two Codex streaming converters — `streaming_codex_chat` (Chat Completions SSE → +//! Responses SSE) and `streaming_codex_anthropic` (Anthropic Messages SSE → Responses +//! SSE) — have completely different *input* state machines but must emit the identical +//! Responses event stream the Codex client understands. This module owns that output +//! envelope so the two converters cannot drift when an event's shape changes: a wire fix +//! lands here once instead of being mirrored in both files. +//! +//! Each function is pure — it takes primitives or a caller-built `item` `Value` and +//! returns the exact bytes the converters previously constructed inline. Item shapes that +//! vary per converter (including function, namespace, custom, and tool-search calls) +//! are supplied by the caller via the generic +//! `output_item_added` / `output_item_done` helpers. + +use bytes::Bytes; +use serde_json::{json, Value}; + +/// Serialize one Responses SSE event with the standard `event:`/`data:` framing. +pub(crate) fn sse_event(event: &str, data: Value) -> Bytes { + Bytes::from(format!( + "event: {event}\ndata: {}\n\n", + serde_json::to_string(&data).unwrap_or_default() + )) +} + +// --------------------------------------------------------------------------- +// Response lifecycle (created / in_progress / completed / failed) +// --------------------------------------------------------------------------- + +/// `response.created`, wrapping a caller-built `response` object (usage/created_at differ +/// per converter, so the caller supplies the whole object). +pub(crate) fn response_created(response: &Value) -> Bytes { + sse_event( + "response.created", + json!({ "type": "response.created", "response": response }), + ) +} + +/// `response.in_progress`. +pub(crate) fn response_in_progress(response: &Value) -> Bytes { + sse_event( + "response.in_progress", + json!({ "type": "response.in_progress", "response": response }), + ) +} + +/// `response.completed`. +pub(crate) fn response_completed(response: &Value) -> Bytes { + sse_event( + "response.completed", + json!({ "type": "response.completed", "response": response }), + ) +} + +/// `response.failed`. +pub(crate) fn response_failed(response: &Value) -> Bytes { + sse_event( + "response.failed", + json!({ "type": "response.failed", "response": response }), + ) +} + +// --------------------------------------------------------------------------- +// Generic output-item add/done (item value supplied by the caller) +// --------------------------------------------------------------------------- + +/// `response.output_item.added` with a caller-built item (message / reasoning / +/// function_call / custom_tool_call). +pub(crate) fn output_item_added(output_index: u32, item: &Value) -> Bytes { + sse_event( + "response.output_item.added", + json!({ + "type": "response.output_item.added", + "output_index": output_index, + "item": item + }), + ) +} + +/// `response.output_item.done` with a caller-built item. +pub(crate) fn output_item_done(output_index: u32, item: &Value) -> Bytes { + sse_event( + "response.output_item.done", + json!({ + "type": "response.output_item.done", + "output_index": output_index, + "item": item + }), + ) +} + +// --------------------------------------------------------------------------- +// Assistant message (text) lifecycle +// --------------------------------------------------------------------------- + +/// `response.output_item.added` for an in-progress assistant message. +pub(crate) fn message_item_added(output_index: u32, item_id: &str) -> Bytes { + output_item_added( + output_index, + &json!({ + "id": item_id, + "type": "message", + "status": "in_progress", + "role": "assistant", + "content": [] + }), + ) +} + +/// `response.content_part.added` for the (empty) output_text part of a message. +pub(crate) fn message_content_part_added(output_index: u32, item_id: &str) -> Bytes { + sse_event( + "response.content_part.added", + json!({ + "type": "response.content_part.added", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "part": { "type": "output_text", "text": "", "annotations": [] } + }), + ) +} + +/// `response.output_text.delta`. +pub(crate) fn output_text_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes { + sse_event( + "response.output_text.delta", + json!({ + "type": "response.output_text.delta", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "delta": delta + }), + ) +} + +/// The completed assistant-message item value. +pub(crate) fn message_item(item_id: &str, text: &str) -> Value { + json!({ + "id": item_id, + "type": "message", + "status": "completed", + "role": "assistant", + "content": [{ "type": "output_text", "text": text, "annotations": [] }] + }) +} + +/// Close an assistant message: emits `output_text.done` → `content_part.done` → +/// `output_item.done`, and returns the completed item so the caller can record it. +pub(crate) fn message_close(output_index: u32, item_id: &str, text: &str) -> (Vec, Value) { + let item = message_item(item_id, text); + let events = vec![ + sse_event( + "response.output_text.done", + json!({ + "type": "response.output_text.done", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "text": text + }), + ), + sse_event( + "response.content_part.done", + json!({ + "type": "response.content_part.done", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "part": { "type": "output_text", "text": text, "annotations": [] } + }), + ), + output_item_done(output_index, &item), + ]; + (events, item) +} + +// --------------------------------------------------------------------------- +// Reasoning (summary) lifecycle +// --------------------------------------------------------------------------- + +/// `response.output_item.added` for an in-progress reasoning item. +pub(crate) fn reasoning_item_added(output_index: u32, item_id: &str) -> Bytes { + output_item_added( + output_index, + &json!({ + "id": item_id, + "type": "reasoning", + "status": "in_progress", + "summary": [] + }), + ) +} + +/// `response.reasoning_summary_part.added` for the (empty) summary part. +pub(crate) fn reasoning_summary_part_added(output_index: u32, item_id: &str) -> Bytes { + sse_event( + "response.reasoning_summary_part.added", + json!({ + "type": "response.reasoning_summary_part.added", + "item_id": item_id, + "output_index": output_index, + "summary_index": 0, + "part": { "type": "summary_text", "text": "" } + }), + ) +} + +/// `response.reasoning_summary_text.delta`. +pub(crate) fn reasoning_summary_text_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes { + sse_event( + "response.reasoning_summary_text.delta", + json!({ + "type": "response.reasoning_summary_text.delta", + "item_id": item_id, + "output_index": output_index, + "summary_index": 0, + "delta": delta + }), + ) +} + +/// The completed reasoning item value (note: no `status` field, matching both converters). +pub(crate) fn reasoning_item(item_id: &str, text: &str) -> Value { + json!({ + "id": item_id, + "type": "reasoning", + "summary": [{ "type": "summary_text", "text": text }] + }) +} + +/// Close a reasoning item: emits `reasoning_summary_text.done` → +/// `reasoning_summary_part.done` → `output_item.done`, and returns the completed item. +pub(crate) fn reasoning_close(output_index: u32, item_id: &str, text: &str) -> (Vec, Value) { + let item = reasoning_item(item_id, text); + let events = reasoning_close_with_item(output_index, item_id, text, &item, true); + (events, item) +} + +/// Close a reasoning item whose completed shape is supplied by the converter. +/// Anthropic uses this to attach opaque signed/redacted thinking in +/// `encrypted_content` while keeping the standard Responses event lifecycle. +pub(crate) fn reasoning_close_with_item( + output_index: u32, + item_id: &str, + text: &str, + item: &Value, + has_visible_summary: bool, +) -> Vec { + let mut events = Vec::new(); + if has_visible_summary { + events.extend([ + sse_event( + "response.reasoning_summary_text.done", + json!({ + "type": "response.reasoning_summary_text.done", + "item_id": item_id, + "output_index": output_index, + "summary_index": 0, + "text": text + }), + ), + sse_event( + "response.reasoning_summary_part.done", + json!({ + "type": "response.reasoning_summary_part.done", + "item_id": item_id, + "output_index": output_index, + "summary_index": 0, + "part": { "type": "summary_text", "text": text } + }), + ), + ]); + } + events.push(output_item_done(output_index, item)); + events +} + +// --------------------------------------------------------------------------- +// Tool-call argument streaming (item value supplied by the caller) +// --------------------------------------------------------------------------- + +/// `response.function_call_arguments.delta`. +pub(crate) fn function_call_arguments_delta( + output_index: u32, + item_id: &str, + delta: &str, +) -> Bytes { + sse_event( + "response.function_call_arguments.delta", + json!({ + "type": "response.function_call_arguments.delta", + "item_id": item_id, + "output_index": output_index, + "delta": delta + }), + ) +} + +/// `response.function_call_arguments.done`. +pub(crate) fn function_call_arguments_done( + output_index: u32, + item_id: &str, + arguments: &str, +) -> Bytes { + sse_event( + "response.function_call_arguments.done", + json!({ + "type": "response.function_call_arguments.done", + "item_id": item_id, + "output_index": output_index, + "arguments": arguments + }), + ) +} + +/// `response.custom_tool_call_input.delta` (Chat freeform tools only). +pub(crate) fn custom_tool_call_input_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes { + sse_event( + "response.custom_tool_call_input.delta", + json!({ + "type": "response.custom_tool_call_input.delta", + "item_id": item_id, + "output_index": output_index, + "delta": delta + }), + ) +} + +/// `response.custom_tool_call_input.done` (Chat freeform tools only). +pub(crate) fn custom_tool_call_input_done(output_index: u32, item_id: &str, input: &str) -> Bytes { + sse_event( + "response.custom_tool_call_input.done", + json!({ + "type": "response.custom_tool_call_input.done", + "item_id": item_id, + "output_index": output_index, + "input": input + }), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn body(bytes: &Bytes) -> String { + String::from_utf8(bytes.to_vec()).unwrap() + } + + #[test] + fn sse_event_framing() { + let ev = sse_event("response.created", json!({ "a": 1 })); + assert_eq!(body(&ev), "event: response.created\ndata: {\"a\":1}\n\n"); + } + + #[test] + fn message_close_shapes_match_legacy() { + let (events, item) = message_close(2, "resp_1_msg", "hi"); + assert_eq!(events.len(), 3); + assert!(body(&events[0]).contains("\"type\":\"response.output_text.done\"")); + assert!(body(&events[0]).contains("\"text\":\"hi\"")); + assert!(body(&events[1]).contains("\"type\":\"response.content_part.done\"")); + assert!(body(&events[2]).contains("\"type\":\"response.output_item.done\"")); + assert_eq!(item["type"], "message"); + assert_eq!(item["status"], "completed"); + assert_eq!(item["content"][0]["text"], "hi"); + } + + #[test] + fn reasoning_close_item_has_no_status() { + let (events, item) = reasoning_close(0, "rs_1", "because"); + assert_eq!(events.len(), 3); + assert!(body(&events[0]).contains("\"type\":\"response.reasoning_summary_text.done\"")); + assert!(body(&events[1]).contains("\"type\":\"response.reasoning_summary_part.done\"")); + // The completed reasoning item intentionally carries no `status` field. + assert!(item.get("status").is_none()); + assert_eq!(item["summary"][0]["text"], "because"); + } + + #[test] + fn message_item_added_is_in_progress() { + let ev = message_item_added(0, "m1"); + let s = body(&ev); + assert!(s.contains("\"type\":\"response.output_item.added\"")); + assert!(s.contains("\"status\":\"in_progress\"")); + assert!(s.contains("\"role\":\"assistant\"")); + } + + #[test] + fn function_call_argument_events() { + assert!(body(&function_call_arguments_delta(1, "fc_x", "{\"a\":")) + .contains("\"type\":\"response.function_call_arguments.delta\"")); + assert!(body(&function_call_arguments_done(1, "fc_x", "{\"a\":1}")) + .contains("\"arguments\":\"{\\\"a\\\":1}\"")); + } +} diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 7f3d8e1ac..1aeec596f 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -18,17 +18,21 @@ mod codex; pub(crate) mod codex_chat_common; pub mod codex_chat_history; pub mod codex_oauth_auth; +pub(crate) mod codex_responses_sse; pub mod copilot_auth; pub mod copilot_model_map; mod gemini; pub(crate) mod gemini_schema; pub mod gemini_shadow; pub mod models; +pub(crate) mod reasoning_bridge; pub mod streaming; +pub mod streaming_codex_anthropic; pub mod streaming_codex_chat; pub mod streaming_gemini; pub mod streaming_responses; pub mod transform; +pub mod transform_codex_anthropic; pub mod transform_codex_chat; pub mod transform_gemini; pub mod transform_responses; @@ -37,6 +41,8 @@ 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 use adapter::ProviderAdapter; pub use auth::{AuthInfo, AuthStrategy}; @@ -47,8 +53,10 @@ pub use claude::{ }; pub use codex::CodexAdapter; pub use codex::{ - apply_codex_chat_upstream_model, codex_provider_upstream_model, - resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_chat, + 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, }; pub use gemini::GeminiAdapter; @@ -104,7 +112,7 @@ impl ProviderType { } ProviderType::OpenRouter => "https://openrouter.ai/api", ProviderType::GitHubCopilot => "https://api.githubcopilot.com", - ProviderType::CodexOAuth => "https://chatgpt.com/backend-api/codex", + ProviderType::CodexOAuth => CHATGPT_CODEX_BASE_URL, } } @@ -184,10 +192,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, } } @@ -238,10 +244,8 @@ pub fn get_adapter(app_type: &AppType) -> Box { 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()), } } diff --git a/src-tauri/src/proxy/providers/reasoning_bridge.rs b/src-tauri/src/proxy/providers/reasoning_bridge.rs new file mode 100644 index 000000000..690296a68 --- /dev/null +++ b/src-tauri/src/proxy/providers/reasoning_bridge.rs @@ -0,0 +1,131 @@ +//! Opaque reasoning transport helpers shared by the Messages ↔ Responses bridge. +//! +//! The Anthropic Messages protocol has no field for an OpenAI Responses +//! `reasoning` item. To keep stateless tool loops lossless, the complete item is +//! carried in a versioned thinking signature/redacted-thinking payload and +//! restored when the client replays the assistant message. + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde_json::{json, Value}; + +pub(crate) const OPENAI_REASONING_ITEM_PREFIX: &str = "ccswitch-openai-reasoning-v1:"; + +pub(crate) fn reasoning_summary_text(item: &Value) -> String { + item.get("summary") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|part| { + matches!( + part.get("type").and_then(Value::as_str), + Some("summary_text" | "reasoning_text") + ) + .then(|| part.get("text").and_then(Value::as_str)) + .flatten() + }) + .collect::>() + .join("") +} + +pub(crate) fn encode_openai_reasoning_item(item: &Value) -> Option { + if item.get("type").and_then(Value::as_str) != Some("reasoning") { + return None; + } + let bytes = serde_json::to_vec(item).ok()?; + Some(format!( + "{OPENAI_REASONING_ITEM_PREFIX}{}", + URL_SAFE_NO_PAD.encode(bytes) + )) +} + +pub(crate) fn decode_openai_reasoning_item(encoded: &str) -> Option { + let payload = encoded.strip_prefix(OPENAI_REASONING_ITEM_PREFIX)?; + let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?; + let item: Value = serde_json::from_slice(&bytes).ok()?; + (item.get("type").and_then(Value::as_str) == Some("reasoning")).then_some(item) +} + +pub(crate) fn anthropic_block_from_openai_reasoning_item(item: &Value) -> Option { + if item.get("type").and_then(Value::as_str) != Some("reasoning") { + return None; + } + + let text = reasoning_summary_text(item); + let has_encrypted_content = item + .get("encrypted_content") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()); + + if has_encrypted_content { + let envelope = encode_openai_reasoning_item(item)?; + if text.is_empty() { + return Some(json!({ + "type": "redacted_thinking", + "data": envelope + })); + } + return Some(json!({ + "type": "thinking", + "thinking": text, + "signature": envelope + })); + } + + (!text.is_empty()).then(|| { + json!({ + "type": "thinking", + "thinking": text + }) + }) +} + +pub(crate) fn openai_reasoning_item_from_anthropic_block(block: &Value) -> Option { + match block.get("type").and_then(Value::as_str) { + Some("thinking") => block + .get("signature") + .and_then(Value::as_str) + .and_then(decode_openai_reasoning_item), + Some("redacted_thinking") => block + .get("data") + .and_then(Value::as_str) + .and_then(decode_openai_reasoning_item), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn openai_reasoning_item_round_trips_through_thinking_signature() { + let item = json!({ + "id": "rs_1", + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "Need a tool."}], + "encrypted_content": "opaque" + }); + let block = anthropic_block_from_openai_reasoning_item(&item).unwrap(); + assert_eq!(block["type"], "thinking"); + assert_eq!( + openai_reasoning_item_from_anthropic_block(&block), + Some(item) + ); + } + + #[test] + fn encrypted_item_without_summary_uses_redacted_thinking() { + let item = json!({ + "id": "rs_2", + "type": "reasoning", + "summary": [], + "encrypted_content": "opaque" + }); + let block = anthropic_block_from_openai_reasoning_item(&item).unwrap(); + assert_eq!(block["type"], "redacted_thinking"); + assert_eq!( + openai_reasoning_item_from_anthropic_block(&block), + Some(item) + ); + } +} diff --git a/src-tauri/src/proxy/providers/streaming.rs b/src-tauri/src/proxy/providers/streaming.rs index 7f0afb245..ceab7aec5 100644 --- a/src-tauri/src/proxy/providers/streaming.rs +++ b/src-tauri/src/proxy/providers/streaming.rs @@ -80,6 +80,8 @@ struct Usage { struct PromptTokensDetails { #[serde(default)] cached_tokens: u32, + #[serde(default)] + cache_write_tokens: u32, } #[derive(Debug, Clone)] @@ -103,7 +105,7 @@ fn build_anthropic_usage_json(usage: &Usage) -> Value { // OpenAI prompt_tokens 含缓存,Anthropic input_tokens 不含,需减去 cache_read 与 cache_creation // (三桶互斥,恒等 input + cache_read + cache_creation == prompt_tokens)。 let cached = extract_cache_read_tokens(usage).unwrap_or(0); - let cache_creation = usage.cache_creation_input_tokens.unwrap_or(0); + let cache_creation = extract_cache_write_tokens(usage).unwrap_or(0); let input_tokens = usage .prompt_tokens .saturating_sub(cached) @@ -233,7 +235,7 @@ pub fn create_anthropic_sse_stream( if let Some(u) = &chunk.usage { let cached = extract_cache_read_tokens(u).unwrap_or(0); let cache_creation = - u.cache_creation_input_tokens.unwrap_or(0); + extract_cache_write_tokens(u).unwrap_or(0); let input = u .prompt_tokens .saturating_sub(cached) @@ -683,6 +685,18 @@ fn extract_cache_read_tokens(usage: &Usage) -> Option { .filter(|&v| v > 0) } +/// Extract cache-write tokens from direct compatibility fields or OpenAI details. +fn extract_cache_write_tokens(usage: &Usage) -> Option { + if let Some(value) = usage.cache_creation_input_tokens { + return Some(value); + } + usage + .prompt_tokens_details + .as_ref() + .map(|details| details.cache_write_tokens) + .filter(|value| *value > 0) +} + /// 映射停止原因 fn map_stop_reason(finish_reason: Option<&str>) -> Option { finish_reason.map(|r| { @@ -1061,7 +1075,7 @@ mod tests { let input = concat!( "data: {\"id\":\"chatcmpl_cc\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"tool-1\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"{\\\"command\\\":\\\"pwd\\\"}\"}}]}}]}\n\n", "data: {\"id\":\"chatcmpl_cc\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n", - "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":600},\"cache_creation_input_tokens\":300}}\n\n", + "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":600,\"cache_write_tokens\":300}}}\n\n", "data: [DONE]\n\n" ); diff --git a/src-tauri/src/proxy/providers/streaming_codex_anthropic.rs b/src-tauri/src/proxy/providers/streaming_codex_anthropic.rs new file mode 100644 index 000000000..1a4cd1d59 --- /dev/null +++ b/src-tauri/src/proxy/providers/streaming_codex_anthropic.rs @@ -0,0 +1,1194 @@ +//! Anthropic Messages SSE → OpenAI Responses SSE conversion. +//! +//! The opposite direction of `streaming_responses.rs` (Responses SSE → Anthropic SSE): +//! here the Codex client speaks Responses, while the upstream gateway speaks the native +//! Anthropic Messages protocol. The Responses events emitted here have the same shape as +//! those in `streaming_codex_chat.rs` (Chat → Responses); the Codex client only recognizes +//! this set of events. + +use super::codex_responses_sse as sse; +use super::transform_codex_anthropic::{ + build_responses_usage_from_anthropic, map_anthropic_stop_reason_to_status, + responses_reasoning_item_from_anthropic_block, +}; +#[cfg(test)] +use super::transform_codex_anthropic::{ + decode_anthropic_thinking_block, ANTHROPIC_THINKING_ENCRYPTED_PREFIX, +}; +use super::transform_codex_chat::{ + response_tool_call_item_from_chat_name, response_tool_call_item_id_from_chat_name, + CodexToolContext, +}; +use super::transform_responses::sanitize_anthropic_tool_use_input_json; +use crate::proxy::json_canonical::canonicalize_tool_arguments_str; +use crate::proxy::sse::{strip_sse_field, take_sse_block}; +use bytes::Bytes; +use futures::stream::{Stream, StreamExt}; +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BlockKind { + Text, + Tool, + Thinking, +} + +#[derive(Debug)] +struct BlockState { + kind: BlockKind, + output_index: u32, + item_id: String, + call_id: String, + name: String, + accum: String, + /// For `tool_use`: the `input` carried on `content_block_start` (compact JSON), + /// used as a fallback when the gateway sends the full input in the start event and + /// emits no `input_json_delta`. Empty otherwise. + start_input: String, + source_block: Value, + has_visible_summary: bool, + done: bool, +} + +struct AnthropicToResponsesState { + response_started: bool, + completed: bool, + response_id: String, + model: String, + next_output_index: u32, + blocks: BTreeMap, + output_items: Vec<(u32, Value)>, + anthropic_usage: Map, + stop_reason: Option, + stream_truncated: bool, + tool_context: CodexToolContext, +} + +impl Default for AnthropicToResponsesState { + fn default() -> Self { + Self { + response_started: false, + completed: false, + response_id: "resp_ccswitch".to_string(), + model: String::new(), + next_output_index: 0, + blocks: BTreeMap::new(), + output_items: Vec::new(), + anthropic_usage: Map::new(), + stop_reason: None, + stream_truncated: false, + tool_context: CodexToolContext::default(), + } + } +} + +impl AnthropicToResponsesState { + fn with_tool_context(tool_context: CodexToolContext) -> Self { + Self { + tool_context, + ..Self::default() + } + } + + fn next_output_index(&mut self) -> u32 { + let index = self.next_output_index; + self.next_output_index += 1; + index + } + + fn responses_usage(&self) -> Value { + if self.anthropic_usage.is_empty() { + return json!({ + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "output_tokens_details": { "reasoning_tokens": 0 } + }); + } + build_responses_usage_from_anthropic(Some(&Value::Object(self.anthropic_usage.clone()))) + } + + fn base_response(&self, status: &str, output: Vec) -> Value { + json!({ + "id": self.response_id, + "object": "response", + "created_at": 0, + "status": status, + "model": self.model, + "output": output, + "usage": self.responses_usage() + }) + } + + fn merge_usage(&mut self, usage: &Value) { + if let Some(obj) = usage.as_object() { + for (key, value) in obj { + if value.is_null() { + continue; + } + self.anthropic_usage.insert(key.clone(), value.clone()); + } + } + } + + fn ensure_response_started(&mut self) -> Vec { + if self.response_started { + return Vec::new(); + } + self.response_started = true; + let response = self.base_response("in_progress", Vec::new()); + vec![ + sse::response_created(&response), + sse::response_in_progress(&response), + ] + } + + fn handle_message_start(&mut self, data: &Value) -> Vec { + if let Some(message) = data.get("message") { + if let Some(id) = message.get("id").and_then(|v| v.as_str()) { + self.response_id = if id.starts_with("resp_") { + id.to_string() + } else { + format!("resp_{id}") + }; + } + if let Some(model) = message.get("model").and_then(|v| v.as_str()) { + if !model.is_empty() { + self.model = model.to_string(); + } + } + if let Some(usage) = message.get("usage") { + self.merge_usage(usage); + } + } + self.ensure_response_started() + } + + fn handle_content_block_start(&mut self, data: &Value) -> Vec { + let mut events = self.ensure_response_started(); + let Some(index) = data.get("index").and_then(|v| v.as_u64()) else { + return events; + }; + let block = data.get("content_block").unwrap_or(&Value::Null); + let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + match block_type { + "text" => { + let output_index = self.next_output_index(); + let item_id = format!("{}_msg_{output_index}", self.response_id); + events.push(sse::message_item_added(output_index, &item_id)); + events.push(sse::message_content_part_added(output_index, &item_id)); + self.blocks.insert( + index, + BlockState { + kind: BlockKind::Text, + output_index, + item_id, + call_id: String::new(), + name: String::new(), + accum: block + .get("text") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + start_input: String::new(), + source_block: block.clone(), + has_visible_summary: false, + done: false, + }, + ); + } + "tool_use" => { + let output_index = self.next_output_index(); + let call_id = block.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let name = block.get("name").and_then(|v| v.as_str()).unwrap_or(""); + // Some gateways put the full tool input on content_block_start and emit + // no input_json_delta; capture it as a fallback (see close_block). + let start_input = block + .get("input") + .filter(|v| v.as_object().map(|o| !o.is_empty()).unwrap_or(false)) + .map(|v| v.to_string()) + .unwrap_or_default(); + let item_id = + response_tool_call_item_id_from_chat_name(call_id, name, &self.tool_context); + let item = response_tool_call_item_from_chat_name( + &item_id, + "in_progress", + call_id, + name, + "", + None, + &self.tool_context, + ); + events.push(sse::output_item_added(output_index, &item)); + self.blocks.insert( + index, + BlockState { + kind: BlockKind::Tool, + output_index, + item_id, + call_id: call_id.to_string(), + name: name.to_string(), + accum: String::new(), + start_input, + source_block: block.clone(), + has_visible_summary: false, + done: false, + }, + ); + } + "thinking" | "redacted_thinking" => { + let output_index = self.next_output_index(); + let item_id = format!("rs_{}_{output_index}", self.response_id); + events.push(sse::reasoning_item_added(output_index, &item_id)); + let has_visible_summary = block_type == "thinking"; + if has_visible_summary { + events.push(sse::reasoning_summary_part_added(output_index, &item_id)); + } + self.blocks.insert( + index, + BlockState { + kind: BlockKind::Thinking, + output_index, + item_id, + call_id: String::new(), + name: String::new(), + accum: block + .get("thinking") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + start_input: String::new(), + source_block: block.clone(), + has_visible_summary, + done: false, + }, + ); + } + _ => {} + } + + events + } + + fn handle_content_block_delta(&mut self, data: &Value) -> Vec { + let Some(index) = data.get("index").and_then(|v| v.as_u64()) else { + return Vec::new(); + }; + let delta = data.get("delta").unwrap_or(&Value::Null); + let delta_type = delta.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + let Some(block) = self.blocks.get_mut(&index) else { + return Vec::new(); + }; + let output_index = block.output_index; + let item_id = block.item_id.clone(); + + match delta_type { + "text_delta" => { + let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or(""); + block.accum.push_str(text); + vec![sse::output_text_delta(output_index, &item_id, text)] + } + "input_json_delta" => { + let partial = delta + .get("partial_json") + .and_then(|t| t.as_str()) + .unwrap_or(""); + block.accum.push_str(partial); + // The Read tool needs to be sanitized at close time, to avoid emitting pages:"" deltas mid-stream + if block.name == "Read" || self.tool_context.is_custom_tool_chat_name(&block.name) { + return Vec::new(); + } + vec![sse::function_call_arguments_delta( + output_index, + &item_id, + partial, + )] + } + "thinking_delta" => { + let text = delta.get("thinking").and_then(|t| t.as_str()).unwrap_or(""); + block.accum.push_str(text); + block.source_block["thinking"] = json!(block.accum); + vec![sse::reasoning_summary_text_delta( + output_index, + &item_id, + text, + )] + } + "signature_delta" => { + if let Some(signature) = delta.get("signature").and_then(Value::as_str) { + block.source_block["signature"] = json!(signature); + } + Vec::new() + } + _ => Vec::new(), + } + } + + fn handle_content_block_stop(&mut self, data: &Value) -> Vec { + let Some(index) = data.get("index").and_then(|v| v.as_u64()) else { + return Vec::new(); + }; + self.close_block(index) + } + + fn close_block(&mut self, index: u64) -> Vec { + let Some(block) = self.blocks.get_mut(&index) else { + return Vec::new(); + }; + if block.done { + return Vec::new(); + } + block.done = true; + let output_index = block.output_index; + let item_id = block.item_id.clone(); + let kind = block.kind; + let text = block.accum.clone(); + let call_id = block.call_id.clone(); + let name = block.name.clone(); + let source_block = block.source_block.clone(); + let has_visible_summary = block.has_visible_summary; + + match kind { + BlockKind::Text => { + let (events, item) = sse::message_close(output_index, &item_id, &text); + self.output_items.push((output_index, item)); + events + } + BlockKind::Tool => { + // Prefer streamed input_json_delta; fall back to the input carried on + // content_block_start when the gateway emitted no deltas (mirrors the + // non-streaming aggregator's precedence in transform_codex_anthropic). + let raw_input = if text.trim().is_empty() { + block.start_input.clone() + } else { + text + }; + let arguments = if raw_input.trim().is_empty() { + "{}".to_string() + } else if name == "Read" { + sanitize_anthropic_tool_use_input_json("Read", &raw_input) + } else { + canonicalize_tool_arguments_str(&raw_input) + }; + let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&name); + let item = response_tool_call_item_from_chat_name( + &item_id, + if self.stream_truncated { + "incomplete" + } else { + "completed" + }, + &call_id, + &name, + &arguments, + None, + &self.tool_context, + ); + let mut events = Vec::new(); + if !self.stream_truncated { + if is_custom_tool { + let input = item.get("input").and_then(Value::as_str).unwrap_or(""); + events.push(sse::custom_tool_call_input_done( + output_index, + &item_id, + input, + )); + } else { + events.push(sse::function_call_arguments_done( + output_index, + &item_id, + &arguments, + )); + } + } + events.push(sse::output_item_done(output_index, &item)); + self.output_items.push((output_index, item)); + events + } + BlockKind::Thinking => { + let mut source_block = source_block; + if source_block.get("type").and_then(Value::as_str) == Some("thinking") { + source_block["thinking"] = json!(text); + } + let Some(item) = + responses_reasoning_item_from_anthropic_block(&item_id, &source_block) + else { + return Vec::new(); + }; + let events = sse::reasoning_close_with_item( + output_index, + &item_id, + &text, + &item, + has_visible_summary, + ); + self.output_items.push((output_index, item)); + events + } + } + } + + fn handle_message_delta(&mut self, data: &Value) -> Vec { + if let Some(reason) = data.pointer("/delta/stop_reason").and_then(|v| v.as_str()) { + self.stop_reason = Some(reason.to_string()); + } + if let Some(usage) = data.get("usage") { + self.merge_usage(usage); + } + Vec::new() + } + + /// Whether any partial output was produced (completed items, buffered text, or a + /// started tool call). Used to distinguish a truncated-with-output stream (report + /// incomplete) from one that produced nothing (report failed). + fn has_substantive_output(&self) -> bool { + !self.output_items.is_empty() + || self.blocks.values().any(|b| { + !b.accum.trim().is_empty() + || !b.call_id.trim().is_empty() + || !b.name.trim().is_empty() + }) + } + + fn finalize(&mut self) -> Vec { + if self.completed { + return Vec::new(); + } + let mut events = self.ensure_response_started(); + + // Close out any blocks that are still open + let open: Vec = self + .blocks + .iter() + .filter(|(_, b)| !b.done) + .map(|(index, _)| *index) + .collect(); + for index in open { + events.extend(self.close_block(index)); + } + + let (status, incomplete_reason) = + map_anthropic_stop_reason_to_status(self.stop_reason.as_deref()); + + let mut output = self.output_items.clone(); + output.sort_by_key(|(output_index, _)| *output_index); + let output: Vec = output.into_iter().map(|(_, item)| item).collect(); + + let mut response = self.base_response(status, output); + if let Some(reason) = incomplete_reason { + response["incomplete_details"] = json!({ "reason": reason }); + } + + events.push(sse::response_completed(&response)); + self.completed = true; + events + } + + fn failed_event(&mut self, message: String, error_type: Option) -> Option { + if self.completed { + return None; + } + self.completed = true; + let mut error = json!({ "message": message }); + if let Some(error_type) = error_type.filter(|value| !value.is_empty()) { + error["type"] = json!(error_type); + } + let mut output = self.output_items.clone(); + output.sort_by_key(|(output_index, _)| *output_index); + let output: Vec = output.into_iter().map(|(_, item)| item).collect(); + let mut response = self.base_response("failed", output); + response["error"] = error; + Some(sse::response_failed(&response)) + } +} + +fn extract_anthropic_sse_error(value: &Value) -> (String, Option) { + let error = value.get("error").unwrap_or(value); + let message = error + .as_str() + .map(ToString::to_string) + .or_else(|| { + error + .get("message") + .and_then(|v| v.as_str()) + .map(ToString::to_string) + }) + .unwrap_or_else(|| error.to_string()); + let error_type = error + .get("type") + .and_then(|v| v.as_str()) + .map(ToString::to_string); + (message, error_type) +} + +fn process_anthropic_sse_block( + state: &mut AnthropicToResponsesState, + block: &str, +) -> (Vec, bool) { + if block.trim().is_empty() { + return (Vec::new(), false); + } + let mut event_name: Option = None; + let mut data_parts: Vec = Vec::new(); + for line in block.lines() { + if let Some(event) = strip_sse_field(line, "event") { + event_name = Some(event.trim().to_string()); + } + if let Some(data) = strip_sse_field(line, "data") { + data_parts.push(data.to_string()); + } + } + if data_parts.is_empty() { + return (Vec::new(), false); + } + let Ok(data) = serde_json::from_str::(&data_parts.join("\n")) else { + return (Vec::new(), false); + }; + let msg_type = data + .get("type") + .and_then(Value::as_str) + .map(str::to_string) + .or(event_name) + .unwrap_or_default(); + + let events = match msg_type.as_str() { + "message_start" => state.handle_message_start(&data), + "content_block_start" => state.handle_content_block_start(&data), + "content_block_delta" => state.handle_content_block_delta(&data), + "content_block_stop" => state.handle_content_block_stop(&data), + "message_delta" => state.handle_message_delta(&data), + "message_stop" => state.finalize(), + "error" => { + let (message, error_type) = extract_anthropic_sse_error(&data); + return ( + state + .failed_event(message, error_type) + .into_iter() + .collect(), + true, + ); + } + _ => Vec::new(), + }; + (events, false) +} + +fn json_document_candidate(input: &str) -> Option<&str> { + let trimmed = input.trim_start_matches(|ch: char| ch.is_whitespace() || ch == '\u{feff}'); + matches!(trimmed.as_bytes().first(), Some(b'{') | Some(b'[')).then_some(trimmed) +} + +/// Convert a complete non-streaming Anthropic message (or error envelope) into +/// the same Responses SSE lifecycle emitted by the live stream converter. Some +/// compatible gateways ignore `stream:true` and return JSON with HTTP 200. +pub(crate) fn responses_sse_events_from_anthropic_message( + body: &Value, + tool_context: CodexToolContext, +) -> Vec { + let mut state = AnthropicToResponsesState::with_tool_context(tool_context); + if body.get("type").and_then(Value::as_str) == Some("error") || body.get("error").is_some() { + let (message, error_type) = extract_anthropic_sse_error(body); + return state + .failed_event(message, error_type) + .into_iter() + .collect(); + } + + let mut message_start = body.clone(); + message_start["content"] = json!([]); + let mut events = state.handle_message_start(&json!({ + "type": "message_start", + "message": message_start + })); + + if let Some(content) = body.get("content").and_then(Value::as_array) { + for (index, block) in content.iter().enumerate() { + let block_type = block.get("type").and_then(Value::as_str).unwrap_or(""); + let mut start_block = block.clone(); + match block_type { + "text" => start_block["text"] = json!(""), + "thinking" => start_block["thinking"] = json!(""), + _ => {} + } + events.extend(state.handle_content_block_start(&json!({ + "type": "content_block_start", + "index": index, + "content_block": start_block + }))); + + match block_type { + "text" => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + events.extend(state.handle_content_block_delta(&json!({ + "type": "content_block_delta", + "index": index, + "delta": { "type": "text_delta", "text": text } + }))); + } + } + "thinking" => { + if let Some(thinking) = block.get("thinking").and_then(Value::as_str) { + events.extend(state.handle_content_block_delta(&json!({ + "type": "content_block_delta", + "index": index, + "delta": { "type": "thinking_delta", "thinking": thinking } + }))); + } + } + _ => {} + } + + events.extend(state.handle_content_block_stop(&json!({ + "type": "content_block_stop", + "index": index + }))); + } + } + + events.extend(state.handle_message_delta(&json!({ + "type": "message_delta", + "delta": { "stop_reason": body.get("stop_reason").cloned().unwrap_or(Value::Null) } + }))); + events.extend(state.finalize()); + events +} + +/// Convert the upstream Anthropic Messages SSE into the Responses SSE that Codex expects. +#[allow(dead_code)] +pub fn create_responses_sse_stream_from_anthropic( + stream: impl Stream> + Send + 'static, +) -> impl Stream> + Send { + create_responses_sse_stream_from_anthropic_with_context(stream, CodexToolContext::default()) +} + +pub(crate) fn create_responses_sse_stream_from_anthropic_with_context< + E: std::error::Error + Send + 'static, +>( + stream: impl Stream> + Send + 'static, + tool_context: CodexToolContext, +) -> impl Stream> + Send { + async_stream::stream! { + let mut buffer = String::new(); + let mut utf8_remainder: Vec = Vec::new(); + let mut state = AnthropicToResponsesState::with_tool_context(tool_context); + let mut stream_failed = false; + + tokio::pin!(stream); + + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes); + + // A few compatible gateways ignore stream:true and return one + // JSON document. Hold that body intact (including pretty-printed + // blank lines) until EOF instead of discarding it as SSE blocks. + if json_document_candidate(&buffer).is_none() { + while let Some(block) = take_sse_block(&mut buffer) { + let (events, failed) = process_anthropic_sse_block(&mut state, &block); + for event in events { + yield Ok(event); + } + if failed { + stream_failed = true; + break; + } + } + } + + if stream_failed { + break; + } + } + Err(e) => { + if let Some(event) = state.failed_event( + format!("Stream error: {e}"), + Some("stream_error".to_string()), + ) { + yield Ok(event); + } + stream_failed = true; + break; + } + } + } + + // Process a final event even when the upstream omitted the trailing blank + // line. This is common with buffering reverse proxies and must not discard + // the last delta or terminal message_stop. + if !stream_failed && !buffer.trim().is_empty() { + if !state.response_started { + if let Some(candidate) = json_document_candidate(&buffer) { + if let Ok(body) = serde_json::from_str::(candidate) { + for event in responses_sse_events_from_anthropic_message( + &body, + state.tool_context.clone(), + ) { + yield Ok(event); + } + state.completed = true; + } + } + } + if !state.completed { + let (events, failed) = process_anthropic_sse_block(&mut state, &buffer); + for event in events { + yield Ok(event); + } + stream_failed = failed; + } + } + + if !stream_failed && !state.completed { + if state.stop_reason.is_some() { + // message_delta (stop_reason + final usage) arrived but the stream ended + // before message_stop; the turn is semantically complete, finalize normally. + for event in state.finalize() { + yield Ok(event); + } + } else if state.has_substantive_output() { + // Upstream truncated mid-stream (e.g. a proxy closed the connection without + // an I/O error) after emitting partial output. Report it as incomplete so + // Codex does not accept the truncated output as a normal completion. + state.stop_reason = Some("max_tokens".to_string()); + state.stream_truncated = true; + for event in state.finalize() { + yield Ok(event); + } + } else { + // Stream ended before any terminal signal or output: surface a failure. + if let Some(event) = state.failed_event( + "Upstream Anthropic stream ended before message_stop".to_string(), + Some("stream_truncated".to_string()), + ) { + yield Ok(event); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::stream; + + async fn run(input: &str) -> String { + let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from( + input.as_bytes().to_vec(), + ))]); + let converted = create_responses_sse_stream_from_anthropic(upstream); + let chunks: Vec<_> = converted.collect().await; + chunks + .into_iter() + .map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string()) + .collect::() + } + + async fn run_with_context(input: &str, context: CodexToolContext) -> String { + let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from( + input.as_bytes().to_vec(), + ))]); + create_responses_sse_stream_from_anthropic_with_context(upstream, context) + .collect::>() + .await + .into_iter() + .map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string()) + .collect() + } + + fn render_message_events(body: &Value) -> String { + responses_sse_events_from_anthropic_message(body, CodexToolContext::default()) + .into_iter() + .map(|chunk| String::from_utf8_lossy(&chunk).to_string()) + .collect() + } + + #[test] + fn test_json_error_envelope_preserves_upstream_error() { + let merged = render_message_events(&json!({ + "type": "error", + "error": { "type": "authentication_error", "message": "bad key" } + })); + assert!(merged.contains("event: response.failed")); + assert!(merged.contains("authentication_error")); + assert!(merged.contains("bad key")); + assert!(!merged.contains("stream_truncated")); + } + + #[test] + fn test_json_message_becomes_complete_responses_stream() { + let merged = render_message_events(&json!({ + "id": "msg_json", + "type": "message", + "role": "assistant", + "model": "claude", + "content": [{ "type": "text", "text": "Hello" }], + "stop_reason": "end_turn", + "usage": { "input_tokens": 4, "output_tokens": 2 } + })); + assert!(merged.contains("event: response.created")); + assert!(merged.contains("event: response.output_text.delta")); + assert!(merged.contains("\"delta\":\"Hello\"")); + assert!(merged.contains("event: response.completed")); + assert!(merged.contains("\"status\":\"completed\"")); + assert!(!merged.contains("event: response.failed")); + } + + #[tokio::test] + async fn test_raw_json_error_body_is_not_reported_as_truncated_sse() { + let merged = run(concat!( + "{\n", + " \"type\": \"error\",\n\n", + " \"error\": {\"type\":\"overloaded_error\",\"message\":\"busy\"}\n", + "}" + )) + .await; + assert!(merged.contains("event: response.failed")); + assert!(merged.contains("overloaded_error")); + assert!(merged.contains("busy")); + assert!(!merged.contains("stream_truncated")); + } + + #[tokio::test] + async fn test_raw_json_message_body_becomes_responses_stream() { + let merged = run( + r#"{"id":"msg_json","type":"message","role":"assistant","model":"claude","content":[{"type":"text","text":"Hello"}],"stop_reason":"end_turn","usage":{"input_tokens":4,"output_tokens":2}}"#, + ) + .await; + assert!(merged.contains("event: response.output_text.delta")); + assert!(merged.contains("\"delta\":\"Hello\"")); + assert!(merged.contains("event: response.completed")); + assert!(!merged.contains("stream_truncated")); + } + + #[tokio::test] + async fn test_text_stream() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"model\":\"claude\",\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":3}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("event: response.created")); + assert!(merged.contains("\"id\":\"resp_msg_1\"")); + assert!(merged.contains("\"model\":\"claude\"")); + assert!(merged.contains("event: response.output_text.delta")); + assert!(merged.contains("\"delta\":\"Hello\"")); + assert!(merged.contains("event: response.completed")); + assert!(merged.contains("\"status\":\"completed\"")); + assert!(merged.contains("\"input_tokens\":12")); + assert!(merged.contains("\"output_tokens\":3")); + } + + #[tokio::test] + async fn test_truncated_stream_with_output_reports_incomplete() { + // Upstream closes after partial text but before message_delta/message_stop. + // The partial output must be reported as incomplete, not a normal completion. + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_t1\",\"model\":\"claude\",\"usage\":{\"input_tokens\":4}}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("\"delta\":\"partial\"")); + assert!(merged.contains("event: response.completed")); + // The top-level response is incomplete (message output items keep their own + // "completed" status, but the response status must not be "completed"). + assert!(merged.contains("\"status\":\"incomplete\"")); + assert!(merged.contains("\"reason\":\"max_output_tokens\"")); + } + + #[tokio::test] + async fn test_truncated_tool_call_is_not_marked_completed() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_tool_truncated\",\"model\":\"claude\"}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"exec\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"cmd\\\":\"}}\n\n" + ); + + let merged = run(input).await; + assert!(merged.contains("\"status\":\"incomplete\"")); + assert!(!merged.contains("event: response.function_call_arguments.done")); + assert!(merged.contains("\"reason\":\"max_output_tokens\"")); + } + + #[tokio::test] + async fn test_truncated_stream_without_output_reports_failed() { + // Upstream closes before producing any output or terminal signal: report failed. + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_t2\",\"model\":\"claude\"}}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("event: response.failed")); + assert!(merged.contains("stream_truncated")); + assert!(!merged.contains("event: response.completed")); + } + + #[tokio::test] + async fn test_stop_reason_without_message_stop_completes() { + // message_delta carried the stop_reason and final usage, but the stream ended + // before message_stop; the turn is complete and should finalize normally. + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_t3\",\"model\":\"claude\",\"usage\":{\"input_tokens\":4}}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"done\"}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":2}}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("event: response.completed")); + assert!(merged.contains("\"status\":\"completed\"")); + assert!(!merged.contains("event: response.failed")); + } + + #[tokio::test] + async fn test_tool_use_stream() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_2\",\"model\":\"claude\",\"usage\":{\"input_tokens\":5}}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"get_weather\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\\\"Tokyo\\\"}\"}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":7}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("\"type\":\"function_call\"")); + assert!(merged.contains("\"call_id\":\"toolu_1\"")); + assert!(merged.contains("\"name\":\"get_weather\"")); + assert!(merged.contains("event: response.function_call_arguments.delta")); + assert!(merged.contains("event: response.function_call_arguments.done")); + assert!(merged.contains("\"status\":\"completed\"")); + } + + #[tokio::test] + async fn test_tool_use_input_only_in_start_event() { + // Some gateways carry the full tool input on content_block_start and emit no + // input_json_delta. The arguments must still be populated (previously empty) — + // matching the non-streaming aggregator's behavior. + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_si\",\"model\":\"claude\",\"usage\":{\"input_tokens\":5}}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_i\",\"name\":\"get_weather\",\"input\":{\"city\":\"Tokyo\"}}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":3}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("\"name\":\"get_weather\"")); + assert!(merged.contains("event: response.function_call_arguments.done")); + // The tool arguments came from the start event, not from any delta. + assert!(merged.contains("Tokyo")); + assert!(merged.contains("\"status\":\"completed\"")); + } + + #[tokio::test] + async fn test_thinking_stream() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_3\",\"model\":\"claude\"}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"hmm\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig\"}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("\"type\":\"reasoning\"")); + assert!(merged.contains("event: response.reasoning_summary_text.delta")); + assert!(merged.contains("\"delta\":\"hmm\"")); + assert!(merged.contains(ANTHROPIC_THINKING_ENCRYPTED_PREFIX)); + } + + #[tokio::test] + async fn test_thinking_signature_is_preserved() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_sig\",\"model\":\"claude\"}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"hmm\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig_abc\"}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run(input).await; + let encoded_start = merged.find(ANTHROPIC_THINKING_ENCRYPTED_PREFIX).unwrap(); + let encoded = merged[encoded_start..].split('"').next().unwrap(); + let block = decode_anthropic_thinking_block(encoded).unwrap(); + assert_eq!(block["signature"], "sig_abc"); + assert_eq!(block["thinking"], "hmm"); + } + + #[tokio::test] + async fn test_max_tokens_incomplete() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_4\",\"model\":\"claude\"}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"max_tokens\"}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("\"status\":\"incomplete\"")); + assert!(merged.contains("\"reason\":\"max_output_tokens\"")); + } + + #[tokio::test] + async fn test_read_tool_drops_empty_pages() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_5\",\"model\":\"claude\"}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_r\",\"name\":\"Read\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"file_path\\\":\\\"/tmp/x\\\",\\\"pages\\\":\\\"\\\"}\"}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("/tmp/x")); + assert!(!merged.contains("pages")); + } + + #[tokio::test] + async fn test_empty_read_input_finishes_as_empty_object() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_read\",\"model\":\"claude\"}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_r\",\"name\":\"Read\",\"input\":{}}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("\"arguments\":\"{}\"")); + } + + #[tokio::test] + async fn test_namespace_tool_stream_restores_namespace() { + let context = super::super::transform_codex_chat::build_codex_tool_context_from_request( + &json!({ + "tools": [{ + "type": "namespace", + "name": "mcp_files", + "tools": [{"type": "function", "name": "read", "parameters": {"type": "object"}}] + }] + }), + ); + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_ns\",\"model\":\"claude\"}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"mcp_files__read\",\"input\":{}}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run_with_context(input, context).await; + assert!(merged.contains("\"namespace\":\"mcp_files\"")); + assert!(merged.contains("\"name\":\"read\"")); + } + + #[tokio::test] + async fn test_final_event_without_blank_line_is_processed() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_tail\"}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"tail\"}}" + ); + let merged = run(input).await; + assert!(merged.contains("\"delta\":\"tail\"")); + assert!(merged.contains("\"status\":\"incomplete\"")); + } + + #[tokio::test] + async fn test_error_after_message_stop_does_not_emit_second_terminal_event() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_terminal\"}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n", + "event: error\n", + "data: {\"type\":\"error\",\"error\":{\"message\":\"late\"}}\n\n" + ); + let merged = run(input).await; + assert_eq!(merged.matches("event: response.completed").count(), 1); + assert_eq!(merged.matches("event: response.failed").count(), 0); + } + + #[tokio::test] + async fn test_error_event_becomes_failed() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_6\",\"model\":\"claude\"}}\n\n", + "event: error\n", + "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"boom\"}}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("event: response.failed")); + assert!(merged.contains("boom")); + } +} diff --git a/src-tauri/src/proxy/providers/streaming_codex_chat.rs b/src-tauri/src/proxy/providers/streaming_codex_chat.rs index a5326ab13..7755ad1f9 100644 --- a/src-tauri/src/proxy/providers/streaming_codex_chat.rs +++ b/src-tauri/src/proxy/providers/streaming_codex_chat.rs @@ -1,5 +1,6 @@ //! OpenAI Chat Completions SSE → OpenAI Responses SSE conversion. +use super::codex_responses_sse as sse; use super::{ codex_chat_common::{ extract_reasoning_field_text, split_leading_think_block, strip_leading_think_open_tag, @@ -74,6 +75,7 @@ struct ChatToResponsesState { reasoning: ReasoningItemState, inline_think: InlineThinkState, tools: BTreeMap, + next_tool_index_to_add: usize, output_items: Vec<(u32, Value)>, latest_usage: Option, finish_reason: Option, @@ -93,6 +95,7 @@ impl Default for ChatToResponsesState { reasoning: ReasoningItemState::default(), inline_think: InlineThinkState::default(), tools: BTreeMap::new(), + next_tool_index_to_add: 0, output_items: Vec::new(), latest_usage: None, finish_reason: None, @@ -270,20 +273,8 @@ impl ChatToResponsesState { let response = self.base_response("in_progress", Vec::new()); vec![ - sse_event( - "response.created", - json!({ - "type": "response.created", - "response": response - }), - ), - sse_event( - "response.in_progress", - json!({ - "type": "response.in_progress", - "response": self.base_response("in_progress", Vec::new()) - }), - ), + sse::response_created(&response), + sse::response_in_progress(&response), ] } @@ -297,45 +288,16 @@ impl ChatToResponsesState { self.reasoning.item_id = item_id.clone(); self.reasoning.added = true; - events.push(sse_event( - "response.output_item.added", - json!({ - "type": "response.output_item.added", - "output_index": output_index, - "item": { - "id": item_id, - "type": "reasoning", - "status": "in_progress", - "summary": [] - } - }), - )); - events.push(sse_event( - "response.reasoning_summary_part.added", - json!({ - "type": "response.reasoning_summary_part.added", - "item_id": self.reasoning.item_id, - "output_index": output_index, - "summary_index": 0, - "part": { - "type": "summary_text", - "text": "" - } - }), - )); + events.push(sse::reasoning_item_added(output_index, &item_id)); + events.push(sse::reasoning_summary_part_added(output_index, &item_id)); } self.reasoning.text.push_str(delta); let output_index = self.reasoning.output_index.unwrap_or(0); - events.push(sse_event( - "response.reasoning_summary_text.delta", - json!({ - "type": "response.reasoning_summary_text.delta", - "item_id": self.reasoning.item_id, - "output_index": output_index, - "summary_index": 0, - "delta": delta - }), + events.push(sse::reasoning_summary_text_delta( + output_index, + &self.reasoning.item_id, + delta, )); events @@ -351,47 +313,16 @@ impl ChatToResponsesState { self.text.item_id = item_id.clone(); self.text.added = true; - events.push(sse_event( - "response.output_item.added", - json!({ - "type": "response.output_item.added", - "output_index": output_index, - "item": { - "id": item_id, - "type": "message", - "status": "in_progress", - "role": "assistant", - "content": [] - } - }), - )); - events.push(sse_event( - "response.content_part.added", - json!({ - "type": "response.content_part.added", - "item_id": self.text.item_id, - "output_index": output_index, - "content_index": 0, - "part": { - "type": "output_text", - "text": "", - "annotations": [] - } - }), - )); + events.push(sse::message_item_added(output_index, &item_id)); + events.push(sse::message_content_part_added(output_index, &item_id)); } self.text.text.push_str(delta); let output_index = self.text.output_index.unwrap_or(0); - events.push(sse_event( - "response.output_text.delta", - json!({ - "type": "response.output_text.delta", - "item_id": self.text.item_id, - "output_index": output_index, - "content_index": 0, - "delta": delta - }), + events.push(sse::output_text_delta( + output_index, + &self.text.item_id, + delta, )); events @@ -418,16 +349,16 @@ impl ChatToResponsesState { .unwrap_or("") .to_string(); - let mut should_add = false; let mut output_index = None; let mut item_id = String::new(); - let mut pending_arguments = String::new(); let current_name: String; { let state = self.tools.entry(chat_index).or_default(); - if let Some(id) = id_delta { - state.call_id = id; + if let Some(ref id) = id_delta { + if !id.is_empty() { + state.call_id.clone_from(id); + } } if let Some(ref name) = name_delta { if !name.is_empty() { @@ -444,10 +375,7 @@ impl ChatToResponsesState { } } - if !state.added && !state.call_id.is_empty() && !state.name.is_empty() { - should_add = true; - pending_arguments = state.arguments.clone(); - } else if state.added { + if state.added { output_index = state.output_index; item_id = state.item_id.clone(); } @@ -457,26 +385,51 @@ impl ChatToResponsesState { let is_custom_tool = self.tool_context.is_custom_tool_chat_name(¤t_name); let mut events = Vec::new(); - if should_add { + if !args_delta.is_empty() && !is_custom_tool { + if let Some(output_index) = output_index { + events.push(sse::function_call_arguments_delta( + output_index, + &item_id, + &args_delta, + )); + } + } + + events.extend(self.flush_ready_tool_calls()); + + events + } + + fn flush_ready_tool_calls(&mut self) -> Vec { + // Release consecutive Chat indexes so late identity fragments cannot reorder calls. + let mut events = Vec::new(); + loop { + let key = self.next_tool_index_to_add; + let Some(state) = self.tools.get(&key) else { + break; + }; + if state.added || state.done { + self.next_tool_index_to_add += 1; + continue; + } + if state.call_id.is_empty() || state.name.is_empty() { + break; + } + let assigned = self.next_output_index(); - let Some(state) = self.tools.get_mut(&chat_index) else { - return events; + let Some(state) = self.tools.get_mut(&key) else { + continue; }; state.added = true; - if state.call_id.is_empty() { - state.call_id = format!("call_{chat_index}"); - } state.output_index = Some(assigned); - let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&state.name); state.item_id = response_tool_call_item_id_from_chat_name( &state.call_id, &state.name, &self.tool_context, ); - item_id = state.item_id.clone(); let item = response_tool_call_item_from_chat_name( - &item_id, + &state.item_id, "in_progress", &state.call_id, &state.name, @@ -485,38 +438,18 @@ impl ChatToResponsesState { &self.tool_context, ); - events.push(sse_event( - "response.output_item.added", - json!({ - "type": "response.output_item.added", - "output_index": assigned, - "item": item - }), - )); + events.push(sse::output_item_added(assigned, &item)); - if !pending_arguments.is_empty() && !is_custom_tool { - events.push(sse_event( - "response.function_call_arguments.delta", - json!({ - "type": "response.function_call_arguments.delta", - "item_id": state.item_id, - "output_index": assigned, - "delta": pending_arguments - }), - )); - } - } else if !args_delta.is_empty() && !is_custom_tool { - if let Some(output_index) = output_index { - events.push(sse_event( - "response.function_call_arguments.delta", - json!({ - "type": "response.function_call_arguments.delta", - "item_id": item_id, - "output_index": output_index, - "delta": args_delta - }), + if !state.arguments.is_empty() + && !self.tool_context.is_custom_tool_chat_name(&state.name) + { + events.push(sse::function_call_arguments_delta( + assigned, + &state.item_id, + &state.arguments, )); } + self.next_tool_index_to_add += 1; } events @@ -567,13 +500,7 @@ impl ChatToResponsesState { response["incomplete_details"] = json!({ "reason": "max_output_tokens" }); } - events.push(sse_event( - "response.completed", - json!({ - "type": "response.completed", - "response": response - }), - )); + events.push(sse::response_completed(&response)); self.completed = true; events } @@ -586,50 +513,10 @@ impl ChatToResponsesState { let output_index = self.reasoning.output_index.unwrap_or(0); let item_id = self.reasoning.item_id.clone(); let text = self.reasoning.text.clone(); - let item = json!({ - "id": item_id, - "type": "reasoning", - "summary": [{ - "type": "summary_text", - "text": text - }] - }); - self.output_items.push((output_index, item.clone())); + let (events, item) = sse::reasoning_close(output_index, &item_id, &text); + self.output_items.push((output_index, item)); self.reasoning.done = true; - - vec![ - sse_event( - "response.reasoning_summary_text.done", - json!({ - "type": "response.reasoning_summary_text.done", - "item_id": self.reasoning.item_id, - "output_index": output_index, - "summary_index": 0, - "text": self.reasoning.text - }), - ), - sse_event( - "response.reasoning_summary_part.done", - json!({ - "type": "response.reasoning_summary_part.done", - "item_id": self.reasoning.item_id, - "output_index": output_index, - "summary_index": 0, - "part": { - "type": "summary_text", - "text": self.reasoning.text - } - }), - ), - sse_event( - "response.output_item.done", - json!({ - "type": "response.output_item.done", - "output_index": output_index, - "item": item - }), - ), - ] + events } fn finalize_text(&mut self) -> Vec { @@ -638,54 +525,12 @@ impl ChatToResponsesState { } let output_index = self.text.output_index.unwrap_or(0); - let item = json!({ - "id": self.text.item_id, - "type": "message", - "status": "completed", - "role": "assistant", - "content": [{ - "type": "output_text", - "text": self.text.text, - "annotations": [] - }] - }); - self.output_items.push((output_index, item.clone())); + let item_id = self.text.item_id.clone(); + let text = self.text.text.clone(); + let (events, item) = sse::message_close(output_index, &item_id, &text); + self.output_items.push((output_index, item)); self.text.done = true; - - vec![ - sse_event( - "response.output_text.done", - json!({ - "type": "response.output_text.done", - "item_id": self.text.item_id, - "output_index": output_index, - "content_index": 0, - "text": self.text.text - }), - ), - sse_event( - "response.content_part.done", - json!({ - "type": "response.content_part.done", - "item_id": self.text.item_id, - "output_index": output_index, - "content_index": 0, - "part": { - "type": "output_text", - "text": self.text.text, - "annotations": [] - } - }), - ), - sse_event( - "response.output_item.done", - json!({ - "type": "response.output_item.done", - "output_index": output_index, - "item": item - }), - ), - ] + events } fn finalize_tools(&mut self) -> Vec { @@ -742,14 +587,7 @@ impl ChatToResponsesState { Some(&state.reasoning_content), &self.tool_context, ); - add_event = Some(sse_event( - "response.output_item.added", - json!({ - "type": "response.output_item.added", - "output_index": assigned, - "item": item - }), - )); + add_event = Some(sse::output_item_added(assigned, &item)); } if let Some(event) = add_event { @@ -777,44 +615,25 @@ impl ChatToResponsesState { if is_custom_tool { let input = custom_tool_input_from_chat_arguments(&arguments); if !input.is_empty() { - events.push(sse_event( - "response.custom_tool_call_input.delta", - json!({ - "type": "response.custom_tool_call_input.delta", - "item_id": state.item_id, - "output_index": output_index, - "delta": input.clone() - }), + events.push(sse::custom_tool_call_input_delta( + output_index, + &state.item_id, + &input, )); } - events.push(sse_event( - "response.custom_tool_call_input.done", - json!({ - "type": "response.custom_tool_call_input.done", - "item_id": state.item_id, - "output_index": output_index, - "input": input - }), + events.push(sse::custom_tool_call_input_done( + output_index, + &state.item_id, + &input, )); } else { - events.push(sse_event( - "response.function_call_arguments.done", - json!({ - "type": "response.function_call_arguments.done", - "item_id": state.item_id, - "output_index": output_index, - "arguments": arguments - }), + events.push(sse::function_call_arguments_done( + output_index, + &state.item_id, + &arguments, )); } - events.push(sse_event( - "response.output_item.done", - json!({ - "type": "response.output_item.done", - "output_index": output_index, - "item": item - }), - )); + events.push(sse::output_item_done(output_index, &item)); } events @@ -864,13 +683,7 @@ impl ChatToResponsesState { let mut response = self.base_response("failed", self.completed_output_items()); response["error"] = error; - sse_event( - "response.failed", - json!({ - "type": "response.failed", - "response": response - }), - ) + sse::response_failed(&response) } } @@ -1030,13 +843,6 @@ fn extract_chat_sse_error(value: &Value) -> (String, Option) { (message, error_type) } -fn sse_event(event: &str, data: Value) -> Bytes { - Bytes::from(format!( - "event: {event}\ndata: {}\n\n", - serde_json::to_string(&data).unwrap_or_default() - )) -} - #[cfg(test)] mod tests { use super::*; @@ -1057,6 +863,16 @@ mod tests { String::from_utf8(bytes.concat()).unwrap() } + fn parse_sse_events(output: &str) -> Vec { + output + .split("\n\n") + .filter_map(|block| { + let data = block.lines().find_map(|line| line.strip_prefix("data: "))?; + serde_json::from_str(data).ok() + }) + .collect() + } + #[tokio::test] async fn converts_text_chat_sse_to_responses_sse() { let output = collect(vec![ @@ -1129,6 +945,114 @@ mod tests { assert!(output.contains("\"call_id\":\"call_1\"")); } + #[tokio::test] + async fn preserves_tool_identity_across_empty_continuation_deltas() { + let output = collect(vec![ + "data: {\"id\":\"chatcmpl_dashscope\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_dashscope\",\"type\":\"function\",\"function\":{\"name\":\"exec_command\",\"arguments\":\"{\"}}]}}]}\n\n", + "data: {\"id\":\"chatcmpl_dashscope\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"\",\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"\\\"cmd\\\":\\\"date\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: [DONE]\n\n", + ]) + .await; + let events = parse_sse_events(&output); + let added = events + .iter() + .filter(|event| event["type"] == "response.output_item.added") + .collect::>(); + let done = events + .iter() + .find(|event| event["type"] == "response.output_item.done") + .unwrap(); + let completed = events + .iter() + .find(|event| event["type"] == "response.completed") + .unwrap(); + + assert_eq!(added.len(), 1); + for item in [&done["item"], &completed["response"]["output"][0]] { + assert_eq!(item["type"], "function_call"); + assert_eq!(item["name"], "exec_command"); + assert_eq!(item["call_id"], "call_dashscope"); + assert_eq!(item["arguments"], r#"{"cmd":"date"}"#); + } + assert!(!output.contains(r#""name":"""#)); + assert!(!output.contains(r#""call_id":"""#)); + } + + #[tokio::test] + async fn preserves_parallel_tool_order_when_earlier_name_arrives_late() { + let output = collect(vec![ + "data: {\"id\":\"chatcmpl_parallel\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_first\",\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"{\"}}]}}]}\n\n", + "data: {\"id\":\"chatcmpl_parallel\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_second\",\"type\":\"function\",\"function\":{\"name\":\"second_tool\",\"arguments\":\"{\\\"value\\\":2}\"}}]}}]}\n\n", + "data: {\"id\":\"chatcmpl_parallel\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"first_tool\",\"arguments\":\"\\\"value\\\":1}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: [DONE]\n\n", + ]) + .await; + let events = parse_sse_events(&output); + let added = events + .iter() + .filter(|event| event["type"] == "response.output_item.added") + .collect::>(); + let completed = events + .iter() + .find(|event| event["type"] == "response.completed") + .unwrap(); + let items = completed["response"]["output"].as_array().unwrap(); + + assert_eq!(added.len(), 2); + assert_eq!(added[0]["output_index"], 0); + assert_eq!(added[0]["item"]["name"], "first_tool"); + assert_eq!(added[1]["output_index"], 1); + assert_eq!(added[1]["item"]["name"], "second_tool"); + assert_eq!(items[0]["name"], "first_tool"); + assert_eq!(items[0]["call_id"], "call_first"); + assert_eq!(items[0]["arguments"], r#"{"value":1}"#); + assert_eq!(items[1]["name"], "second_tool"); + assert_eq!(items[1]["call_id"], "call_second"); + assert_eq!(items[1]["arguments"], r#"{"value":2}"#); + } + + #[tokio::test] + async fn finalization_keeps_valid_call_after_unnamed_earlier_call() { + let output = collect(vec![ + "data: {\"id\":\"chatcmpl_parallel_missing\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_missing\",\"type\":\"function\",\"function\":{\"arguments\":\"{}\"}}]}}]}\n\n", + "data: {\"id\":\"chatcmpl_parallel_missing\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_valid\",\"type\":\"function\",\"function\":{\"name\":\"exec_command\",\"arguments\":\"{\\\"cmd\\\":\\\"date\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: [DONE]\n\n", + ]) + .await; + let events = parse_sse_events(&output); + let completed = events + .iter() + .find(|event| event["type"] == "response.completed") + .unwrap(); + let items = completed["response"]["output"].as_array().unwrap(); + + assert_eq!(items.len(), 1); + assert_eq!(items[0]["name"], "exec_command"); + assert_eq!(items[0]["call_id"], "call_valid"); + assert_eq!(items[0]["arguments"], r#"{"cmd":"date"}"#); + assert!(!output.contains("call_missing")); + } + + #[tokio::test] + async fn finalization_keeps_non_contiguous_tool_index() { + let output = collect(vec![ + "data: {\"id\":\"chatcmpl_sparse\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":2,\"id\":\"call_sparse\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"README.md\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: [DONE]\n\n", + ]) + .await; + let events = parse_sse_events(&output); + let completed = events + .iter() + .find(|event| event["type"] == "response.completed") + .unwrap(); + let items = completed["response"]["output"].as_array().unwrap(); + + assert_eq!(items.len(), 1); + assert_eq!(items[0]["name"], "read_file"); + assert_eq!(items[0]["call_id"], "call_sparse"); + assert_eq!(items[0]["arguments"], r#"{"path":"README.md"}"#); + } + #[tokio::test] async fn restores_custom_tool_input_stream_events() { let request = json!({ diff --git a/src-tauri/src/proxy/providers/streaming_responses.rs b/src-tauri/src/proxy/providers/streaming_responses.rs index 81615c2cf..1cfae6380 100644 --- a/src-tauri/src/proxy/providers/streaming_responses.rs +++ b/src-tauri/src/proxy/providers/streaming_responses.rs @@ -8,8 +8,9 @@ //! //! 与 Chat Completions 的 delta chunk 模型完全不同,需要独立的状态机处理。 +use super::reasoning_bridge::{encode_openai_reasoning_item, reasoning_summary_text}; use super::transform_responses::{ - build_anthropic_usage_from_responses, map_responses_stop_reason, + build_anthropic_usage_from_responses, map_responses_stop_reason, responses_to_anthropic, sanitize_anthropic_tool_use_input_json, }; use crate::proxy::sse::{strip_sse_field, take_sse_block}; @@ -23,6 +24,182 @@ fn response_object_from_event(data: &Value) -> &Value { data.get("response").unwrap_or(data) } +fn anthropic_sse(event_name: &str, payload: &Value) -> Bytes { + Bytes::from(format!( + "event: {event_name}\ndata: {}\n\n", + serde_json::to_string(payload).unwrap_or_default() + )) +} + +fn responses_error_details(data: &Value, fallback: &str) -> (String, String) { + let response = response_object_from_event(data); + let error = response.get("error").unwrap_or(response); + let message = error + .get("message") + .and_then(Value::as_str) + .or_else(|| error.as_str()) + .filter(|message| !message.trim().is_empty()) + .unwrap_or(fallback) + .to_string(); + let error_type = error + .get("type") + .and_then(Value::as_str) + .or_else(|| error.get("code").and_then(Value::as_str)) + .unwrap_or("upstream_error") + .to_string(); + (message, error_type) +} + +fn anthropic_error_sse(message: &str, error_type: &str) -> Bytes { + anthropic_sse( + "error", + &json!({ + "type": "error", + "error": {"type": error_type, "message": message} + }), + ) +} + +/// Convert a compatible gateway's non-streaming Responses JSON into a complete +/// Anthropic SSE lifecycle. This is used when the client requested streaming but +/// the upstream ignored `stream:true` and returned `application/json`. +fn responses_json_to_anthropic_sse(body: Value) -> Vec { + let message = match responses_to_anthropic(body) { + Ok(message) => message, + Err(error) => { + return vec![anthropic_error_sse( + &error.to_string(), + "response_transform_error", + )] + } + }; + + let usage = message.get("usage").cloned().unwrap_or_else(|| json!({})); + let mut start_usage = usage.clone(); + start_usage["output_tokens"] = json!(0); + let mut events = vec![anthropic_sse( + "message_start", + &json!({ + "type": "message_start", + "message": { + "id": message.get("id").cloned().unwrap_or_else(|| json!("")), + "type": "message", + "role": "assistant", + "model": message.get("model").cloned().unwrap_or_else(|| json!("")), + "usage": start_usage + } + }), + )]; + + if let Some(content) = message.get("content").and_then(Value::as_array) { + for (index, block) in content.iter().enumerate() { + let index = index as u64; + match block.get("type").and_then(Value::as_str) { + Some("text") => { + events.push(anthropic_sse( + "content_block_start", + &json!({"type":"content_block_start","index":index,"content_block":{"type":"text","text":""}}), + )); + if let Some(text) = block.get("text").and_then(Value::as_str) { + if !text.is_empty() { + events.push(anthropic_sse( + "content_block_delta", + &json!({"type":"content_block_delta","index":index,"delta":{"type":"text_delta","text":text}}), + )); + } + } + events.push(anthropic_sse( + "content_block_stop", + &json!({"type":"content_block_stop","index":index}), + )); + } + Some("tool_use") => { + events.push(anthropic_sse( + "content_block_start", + &json!({ + "type":"content_block_start", + "index":index, + "content_block":{ + "type":"tool_use", + "id":block.get("id").cloned().unwrap_or_else(|| json!("")), + "name":block.get("name").cloned().unwrap_or_else(|| json!("")), + "input":{} + } + }), + )); + let input = block.get("input").cloned().unwrap_or_else(|| json!({})); + events.push(anthropic_sse( + "content_block_delta", + &json!({ + "type":"content_block_delta", + "index":index, + "delta":{"type":"input_json_delta","partial_json":serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string())} + }), + )); + events.push(anthropic_sse( + "content_block_stop", + &json!({"type":"content_block_stop","index":index}), + )); + } + Some("thinking") => { + events.push(anthropic_sse( + "content_block_start", + &json!({"type":"content_block_start","index":index,"content_block":{"type":"thinking","thinking":""}}), + )); + if let Some(thinking) = block.get("thinking").and_then(Value::as_str) { + if !thinking.is_empty() { + events.push(anthropic_sse( + "content_block_delta", + &json!({"type":"content_block_delta","index":index,"delta":{"type":"thinking_delta","thinking":thinking}}), + )); + } + } + if let Some(signature) = block.get("signature").and_then(Value::as_str) { + if !signature.is_empty() { + events.push(anthropic_sse( + "content_block_delta", + &json!({"type":"content_block_delta","index":index,"delta":{"type":"signature_delta","signature":signature}}), + )); + } + } + events.push(anthropic_sse( + "content_block_stop", + &json!({"type":"content_block_stop","index":index}), + )); + } + Some("redacted_thinking") => { + events.push(anthropic_sse( + "content_block_start", + &json!({"type":"content_block_start","index":index,"content_block":block}), + )); + events.push(anthropic_sse( + "content_block_stop", + &json!({"type":"content_block_stop","index":index}), + )); + } + _ => {} + } + } + } + + events.push(anthropic_sse( + "message_delta", + &json!({ + "type":"message_delta", + "delta":{ + "stop_reason":message.get("stop_reason").cloned().unwrap_or(Value::Null), + "stop_sequence":null + }, + "usage":usage + }), + )); + events.push(anthropic_sse( + "message_stop", + &json!({"type":"message_stop"}), + )); + events +} + #[inline] fn content_part_key(data: &Value) -> Option { if let (Some(item_id), Some(content_index)) = ( @@ -65,6 +242,20 @@ fn tool_item_key_from_event(data: &Value) -> Option { None } +#[inline] +fn reasoning_item_key(data: &Value, item: Option<&Value>) -> Option { + if let Some(item_id) = item + .and_then(|value| value.get("id")) + .and_then(Value::as_str) + .or_else(|| data.get("item_id").and_then(Value::as_str)) + { + return Some(format!("reasoning:{item_id}")); + } + data.get("output_index") + .and_then(Value::as_u64) + .map(|index| format!("reasoning:out:{index}")) +} + /// Resolve content index for a text/refusal content part event. /// /// Uses `content_part_key` to look up or assign a stable index, falling back to @@ -117,15 +308,67 @@ pub fn create_anthropic_sse_stream_from_responses = HashMap::new(); let mut tool_name_by_index: HashMap = HashMap::new(); let mut tool_args_by_index: HashMap = HashMap::new(); + let mut tool_had_delta: HashSet = HashSet::new(); let mut last_tool_index: Option = None; + let mut reasoning_index_by_item_id: HashMap = HashMap::new(); + let mut reasoning_item_by_index: HashMap = HashMap::new(); + let mut reasoning_text_by_index: HashMap = HashMap::new(); + let mut legacy_reasoning_index: Option = None; + let mut has_substantive_output = false; + let mut terminated = false; + // Append an EOF sentinel so the same parser handles a final SSE event that + // omitted its trailing blank line. The boolean distinguishes the sentinel + // from a legitimate empty upstream chunk. + let stream = stream + .map(|result| (result, false)) + .chain(futures::stream::once(async { + (Ok::(Bytes::new()), true) + })); tokio::pin!(stream); - while let Some(chunk) = stream.next().await { + while let Some((chunk, is_eof)) = stream.next().await { match chunk { Ok(bytes) => { crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes); + // A few compatible gateways ignore stream:true and return one + // JSON document. Hold it intact until EOF, including any pretty- + // printed blank lines that would otherwise look like SSE separators. + let looks_like_json = matches!( + buffer + .trim_start_matches(|ch: char| ch.is_whitespace() || ch == '\u{feff}') + .as_bytes() + .first(), + Some(b'{') | Some(b'[') + ); + if looks_like_json && !is_eof { + continue; + } + if looks_like_json && is_eof { + match serde_json::from_str::(buffer.trim()) { + Ok(body) => { + for event in responses_json_to_anthropic_sse(body) { + yield Ok(event); + } + terminated = true; + } + Err(error) => { + yield Ok(anthropic_error_sse( + &format!("Invalid JSON response from Responses upstream: {error}"), + "response_parse_error", + )); + terminated = true; + } + } + buffer.clear(); + continue; + } + + if is_eof && !buffer.trim().is_empty() { + buffer.push_str("\n\n"); + } + // SSE 事件由 \n\n 分隔 while let Some(block) = take_sse_block(&mut buffer) { if block.trim().is_empty() { @@ -149,7 +392,6 @@ pub fn create_anthropic_sse_stream_from_responses continue, }; + // Official streams use both a named SSE event and `type` in + // the JSON payload. Compatible gateways sometimes omit the + // `event:` line, so fall back to the payload type. + let event_name = event_type + .as_deref() + .filter(|name| !name.is_empty()) + .or_else(|| data.get("type").and_then(Value::as_str)) + .unwrap_or(""); + log::debug!("[Claude/Responses] <<< SSE event: {event_name}"); + // Ignore every event after a terminal response. In particular, + // do not synthesize message_start if a broken gateway emits a + // late delta after response.failed/error. + if terminated { + continue; + } + + let delta_requires_message_start = matches!( + event_name, + "response.output_text.delta" + | "response.refusal.delta" + | "response.function_call_arguments.delta" + | "response.reasoning_summary_text.delta" + | "response.reasoning_text.delta" + | "response.reasoning.delta" + ); + if delta_requires_message_start { + has_substantive_output = true; + } + if delta_requires_message_start && !has_sent_message_start { + yield Ok(anthropic_sse( + "message_start", + &json!({ + "type":"message_start", + "message":{ + "id":message_id.clone().unwrap_or_default(), + "type":"message", + "role":"assistant", + "model":current_model.clone().unwrap_or_default(), + "usage":{"input_tokens":0,"output_tokens":0} + } + }), + )); + has_sent_message_start = true; + } + match event_name { // ================================================ // response.created → message_start @@ -363,6 +650,7 @@ pub fn create_anthropic_sse_stream_from_responses { if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) { + has_tool_use = true; let item_id = data.get("item_id").and_then(|v| v.as_str()); let index = if let Some(id) = item_id { tool_index_by_item_id.get(id).copied() @@ -467,6 +797,16 @@ pub fn create_anthropic_sse_stream_from_responses { + has_tool_use = true; let item_id = data.get("item_id").and_then(|v| v.as_str()); let index = if let Some(id) = item_id { tool_index_by_item_id.get(id).copied() @@ -534,6 +877,7 @@ pub fn create_anthropic_sse_stream_from_responses { + "response.reasoning_summary_text.delta" + | "response.reasoning_text.delta" + | "response.reasoning.delta" => { if let Some(delta) = data .get("delta") .or_else(|| data.get("text")) @@ -624,12 +993,35 @@ pub fn create_anthropic_sse_stream_from_responses { - let key = content_part_key(&data); - let index = if let Some(k) = key { - index_by_key.get(&k).copied() - } else { - fallback_open_index - }; - if let Some(index) = index { - if !open_indices.remove(&index) { - continue; - } - let event = json!({ - "type": "content_block_stop", - "index": index + "response.reasoning_summary_text.done" + | "response.reasoning_text.done" => { + let item_id = data.get("item_id").and_then(Value::as_str); + let item_key = reasoning_item_key(&data, None); + let index = item_id + .and_then(|id| reasoning_index_by_item_id.get(id).copied()) + .or_else(|| { + item_key + .as_ref() + .and_then(|key| index_by_key.get(key).copied()) + }) + .or_else(|| { + (item_id.is_none() && item_key.is_none()) + .then_some(legacy_reasoning_index) + .flatten() }); - let sse = format!("event: content_block_stop\ndata: {}\n\n", - serde_json::to_string(&event).unwrap_or_default()); - yield Ok(Bytes::from(sse)); - if fallback_open_index == Some(index) { - fallback_open_index = None; + if let Some(index) = index { + let already_emitted = reasoning_text_by_index + .get(&index) + .is_some_and(|value| !value.is_empty()); + if !already_emitted { + if let Some(text) = data + .get("text") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + if !open_indices.contains(&index) { + let start_event = json!({ + "type": "content_block_start", + "index": index, + "content_block": {"type": "thinking", "thinking": ""} + }); + let start_sse = format!("event: content_block_start\ndata: {}\n\n", + serde_json::to_string(&start_event).unwrap_or_default()); + yield Ok(Bytes::from(start_sse)); + open_indices.insert(index); + } + reasoning_text_by_index + .entry(index) + .or_default() + .push_str(text); + let event = json!({ + "type": "content_block_delta", + "index": index, + "delta": {"type": "thinking_delta", "thinking": text} + }); + let sse = format!("event: content_block_delta\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse)); + } + } + } + } + + // Legacy gateways do not emit output_item.done, so retain the + // old close behavior for their non-standard done event. + "response.reasoning.done" => { + let item_id = data.get("item_id").and_then(Value::as_str); + let item_key = reasoning_item_key(&data, None); + let index = item_id + .and_then(|id| reasoning_index_by_item_id.get(id).copied()) + .or_else(|| { + item_key + .as_ref() + .and_then(|key| index_by_key.get(key).copied()) + }) + .or_else(|| { + (item_id.is_none() && item_key.is_none()) + .then_some(legacy_reasoning_index) + .flatten() + }); + if let Some(index) = index { + if open_indices.remove(&index) { + let event = json!({"type": "content_block_stop", "index": index}); + let sse = format!("event: content_block_stop\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse)); + } + if legacy_reasoning_index == Some(index) { + legacy_reasoning_index = None; } } } // ================================================ - // response.completed → message_delta + message_stop + // response.completed / response.incomplete → message_delta + message_stop // ================================================ - "response.completed" => { + "response.completed" | "response.incomplete" => { let response_obj = response_object_from_event(&data); + if matches!( + response_obj.get("status").and_then(Value::as_str), + Some("failed" | "cancelled") + ) || response_obj + .get("error") + .is_some_and(|error| !error.is_null()) + { + let (message, error_type) = responses_error_details( + &data, + "Responses upstream returned a failed terminal response", + ); + yield Ok(anthropic_error_sse(&message, &error_type)); + terminated = true; + continue; + } + if !has_sent_message_start { + if let Some(id) = response_obj.get("id").and_then(Value::as_str) { + message_id = Some(id.to_string()); + } + if let Some(model) = + response_obj.get("model").and_then(Value::as_str) + { + current_model = Some(model.to_string()); + } + yield Ok(anthropic_sse( + "message_start", + &json!({ + "type":"message_start", + "message":{ + "id":message_id.clone().unwrap_or_default(), + "type":"message", + "role":"assistant", + "model":current_model.clone().unwrap_or_default(), + "usage":{"input_tokens":0,"output_tokens":0} + } + }), + )); + has_sent_message_start = true; + } + let terminal_status = response_obj + .get("status") + .and_then(Value::as_str) + .or(match event_name { + "response.incomplete" => Some("incomplete"), + "response.completed" => Some("completed"), + _ => None, + }); let stop_reason = map_responses_stop_reason( - response_obj.get("status").and_then(|s| s.as_str()), + terminal_status, has_tool_use, response_obj .pointer("/incomplete_details/reason") @@ -744,6 +1250,24 @@ pub fn create_anthropic_sse_stream_from_responses>> Anthropic SSE: message_stop"); yield Ok(Bytes::from(stop_sse)); + terminated = true; + } + + // ================================================ + // Semantic failures can be carried inside an HTTP 2xx SSE. + // Preserve the upstream details instead of silently ending. + // ================================================ + "response.failed" | "error" => { + let (message, error_type) = responses_error_details( + &data, + if event_name == "response.failed" { + "Responses upstream reported response.failed" + } else { + "Responses upstream emitted an error event" + }, + ); + yield Ok(anthropic_error_sse(&message, &error_type)); + terminated = true; } // Lifecycle events that don't need Anthropic counterparts. @@ -764,7 +1288,171 @@ pub fn create_anthropic_sse_stream_from_responses { + let Some(item) = data.get("item") else { + continue; + }; + match item.get("type").and_then(Value::as_str) { + Some("function_call") => { + has_tool_use = true; + let item_id = item + .get("id") + .and_then(Value::as_str) + .or_else(|| data.get("item_id").and_then(Value::as_str)); + let index = item_id + .and_then(|id| tool_index_by_item_id.get(id).copied()) + .or_else(|| { + tool_item_key_from_event(&data) + .and_then(|key| index_by_key.get(&key).copied()) + }) + .or(last_tool_index); + if let Some(index) = index.filter(|value| open_indices.contains(value)) { + let name = tool_name_by_index + .get(&index) + .map(String::as_str) + .unwrap_or(""); + if !tool_had_delta.contains(&index) || name == "Read" { + let raw = item + .get("arguments") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + tool_args_by_index + .get(&index) + .cloned() + .unwrap_or_default() + }); + let arguments = if name == "Read" { + sanitize_anthropic_tool_use_input_json(name, &raw) + } else { + raw + }; + if !arguments.is_empty() { + let event = json!({ + "type": "content_block_delta", + "index": index, + "delta": { + "type": "input_json_delta", + "partial_json": arguments + } + }); + let sse = format!("event: content_block_delta\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse)); + } + } + open_indices.remove(&index); + let event = json!({"type": "content_block_stop", "index": index}); + let sse = format!("event: content_block_stop\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse)); + if let Some(id) = item_id { + tool_index_by_item_id.remove(id); + } + tool_name_by_index.remove(&index); + tool_args_by_index.remove(&index); + tool_had_delta.remove(&index); + } + } + Some("reasoning") => { + let item_id = item + .get("id") + .and_then(Value::as_str) + .or_else(|| data.get("item_id").and_then(Value::as_str)); + let index = item_id + .and_then(|id| reasoning_index_by_item_id.get(id).copied()) + .or_else(|| { + reasoning_item_key(&data, Some(item)) + .and_then(|key| index_by_key.get(&key).copied()) + }) + .unwrap_or_else(|| { + let assigned = next_content_index; + next_content_index += 1; + assigned + }); + reasoning_item_by_index.insert(index, item.clone()); + + let final_item = reasoning_item_by_index + .get(&index) + .cloned() + .unwrap_or_else(|| item.clone()); + let full_text = reasoning_summary_text(&final_item); + let emitted_text = reasoning_text_by_index + .get(&index) + .cloned() + .unwrap_or_default(); + if emitted_text.is_empty() && !full_text.is_empty() { + let start_event = json!({ + "type": "content_block_start", + "index": index, + "content_block": {"type": "thinking", "thinking": ""} + }); + let start_sse = format!("event: content_block_start\ndata: {}\n\n", + serde_json::to_string(&start_event).unwrap_or_default()); + yield Ok(Bytes::from(start_sse)); + open_indices.insert(index); + let delta_event = json!({ + "type": "content_block_delta", + "index": index, + "delta": {"type": "thinking_delta", "thinking": full_text} + }); + let delta_sse = format!("event: content_block_delta\ndata: {}\n\n", + serde_json::to_string(&delta_event).unwrap_or_default()); + yield Ok(Bytes::from(delta_sse)); + } + + let encrypted = final_item + .get("encrypted_content") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()); + if encrypted { + if let Some(envelope) = encode_openai_reasoning_item(&final_item) { + if open_indices.contains(&index) { + let signature_event = json!({ + "type": "content_block_delta", + "index": index, + "delta": { + "type": "signature_delta", + "signature": envelope + } + }); + let signature_sse = format!("event: content_block_delta\ndata: {}\n\n", + serde_json::to_string(&signature_event).unwrap_or_default()); + yield Ok(Bytes::from(signature_sse)); + } else { + let start_event = json!({ + "type": "content_block_start", + "index": index, + "content_block": { + "type": "redacted_thinking", + "data": envelope + } + }); + let start_sse = format!("event: content_block_start\ndata: {}\n\n", + serde_json::to_string(&start_event).unwrap_or_default()); + yield Ok(Bytes::from(start_sse)); + open_indices.insert(index); + } + } + } + if open_indices.remove(&index) { + let stop_event = json!({"type": "content_block_stop", "index": index}); + let stop_sse = format!("event: content_block_stop\ndata: {}\n\n", + serde_json::to_string(&stop_event).unwrap_or_default()); + yield Ok(Bytes::from(stop_sse)); + } + if let Some(id) = item_id { + reasoning_index_by_item_id.remove(id); + } + reasoning_item_by_index.remove(&index); + reasoning_text_by_index.remove(&index); + } + _ => {} + } + } + "response.reasoning_summary_part.added" + | "response.reasoning_summary_part.done" | "response.in_progress" => {} // Any other unknown/future events — silently skip. @@ -784,10 +1472,66 @@ pub fn create_anthropic_sse_stream_from_responses = open_indices.iter().copied().collect(); + remaining.sort_unstable(); + for index in remaining { + yield Ok(anthropic_sse( + "content_block_stop", + &json!({"type":"content_block_stop","index":index}), + )); + } + if !has_sent_message_start { + yield Ok(anthropic_sse( + "message_start", + &json!({ + "type":"message_start", + "message":{ + "id":message_id.clone().unwrap_or_default(), + "type":"message", + "role":"assistant", + "model":current_model.clone().unwrap_or_default(), + "usage":{"input_tokens":0,"output_tokens":0} + } + }), + )); + } + yield Ok(anthropic_sse( + "message_delta", + &json!({ + "type":"message_delta", + "delta":{"stop_reason":"max_tokens","stop_sequence":null}, + "usage":{"input_tokens":0,"output_tokens":0} + }), + )); + yield Ok(anthropic_sse("message_stop", &json!({"type":"message_stop"}))); + } else { + // A truncated tool/reasoning block cannot be safely finalized: tool + // JSON may be partial and thinking may be missing its signature. + yield Ok(anthropic_error_sse( + "Responses upstream stream ended before a terminal event", + "stream_truncated", + )); + } + } } } @@ -798,6 +1542,16 @@ mod tests { use futures::StreamExt; use std::collections::HashMap; + async fn convert_stream_text(input: impl Into) -> String { + let upstream = stream::iter(vec![Ok::<_, std::io::Error>(input.into())]); + create_anthropic_sse_stream_from_responses(upstream) + .collect::>() + .await + .into_iter() + .map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string()) + .collect() + } + #[test] fn test_map_responses_stop_reason_tool_use() { assert_eq!( @@ -832,6 +1586,152 @@ mod tests { assert_eq!(obj["model"], "gpt-4o"); } + #[tokio::test] + async fn test_response_failed_event_becomes_anthropic_error() { + let input = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5\"}}\n\n", + "event: response.failed\n", + "data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"type\":\"server_error\",\"message\":\"backend exploded\"}}}\n\n" + ); + + let merged = convert_stream_text(input).await; + assert!(merged.contains("event: error")); + assert!(merged.contains("backend exploded")); + assert!(!merged.contains("event: message_stop")); + } + + #[tokio::test] + async fn test_late_delta_after_failure_does_not_emit_message_start() { + let input = concat!( + "event: response.failed\n", + "data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"message\":\"boom\"}}}\n\n", + "event: response.output_text.delta\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"too late\"}\n\n" + ); + + let merged = convert_stream_text(input).await; + assert!(merged.contains("event: error")); + assert!(!merged.contains("event: message_start")); + assert!(!merged.contains("too late")); + } + + #[tokio::test] + async fn test_completed_event_with_failed_status_is_error() { + let input = concat!( + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"failed\",\"error\":{\"type\":\"server_error\",\"message\":\"failed wrapper\"},\"output\":[]}}\n\n" + ); + + let merged = convert_stream_text(input).await; + assert!(merged.contains("event: error")); + assert!(merged.contains("failed wrapper")); + assert!(!merged.contains("event: message_stop")); + } + + #[tokio::test] + async fn test_response_incomplete_event_terminates_with_max_tokens() { + let input = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5\"}}\n\n", + "event: response.incomplete\n", + "data: {\"type\":\"response.incomplete\",\"response\":{\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"max_output_tokens\"},\"usage\":{\"input_tokens\":10,\"output_tokens\":3}}}\n\n" + ); + + let merged = convert_stream_text(input).await; + assert!(merged.contains("\"stop_reason\":\"max_tokens\"")); + assert!(merged.contains("event: message_stop")); + assert!(!merged.contains("event: error")); + } + + #[tokio::test] + async fn test_response_incomplete_event_without_status_uses_event_fallback() { + let input = concat!( + "event: response.incomplete\n", + "data: {\"type\":\"response.incomplete\",\"response\":{\"usage\":{\"output_tokens\":3}}}\n\n" + ); + + let merged = convert_stream_text(input).await; + assert!(merged.contains("\"stop_reason\":\"max_tokens\"")); + assert!(merged.contains("event: message_stop")); + } + + #[tokio::test] + async fn test_final_event_without_blank_line_is_processed() { + let input = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5\"}}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n" + ); + + let merged = convert_stream_text(input).await; + assert!(merged.contains("\"stop_reason\":\"end_turn\"")); + assert_eq!(merged.matches("event: message_stop").count(), 1); + assert!(!merged.contains("stream_truncated")); + } + + #[tokio::test] + async fn test_clean_eof_after_partial_text_is_explicitly_incomplete() { + let input = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5\"}}\n\n", + "event: response.output_text.delta\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n" + ); + + let merged = convert_stream_text(input).await; + assert!(merged.contains("\"stop_reason\":\"max_tokens\"")); + assert!(merged.contains("event: content_block_stop")); + assert!(merged.contains("event: message_stop")); + } + + #[tokio::test] + async fn test_clean_eof_during_tool_arguments_is_error() { + let input = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5\"}}\n\n", + "event: response.function_call_arguments.delta\n", + "data: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_1\",\"call_id\":\"call_1\",\"name\":\"exec\",\"delta\":\"{\\\"cmd\\\":\"}\n\n" + ); + + let merged = convert_stream_text(input).await; + assert!(merged.contains("event: error")); + assert!(merged.contains("stream_truncated")); + assert!(!merged.contains("event: message_stop")); + } + + #[tokio::test] + async fn test_stream_request_with_complete_json_response_is_converted() { + let input = r#"{ + "id":"resp_json", + "status":"completed", + "model":"gpt-5", + "output":[{"type":"message","content":[{"type":"output_text","text":"hello"}]}], + "usage":{"input_tokens":4,"output_tokens":1} + }"#; + + let merged = convert_stream_text(input).await; + assert!(merged.contains("event: message_start")); + assert!(merged.contains("\"text\":\"hello\"")); + assert!(merged.contains("event: message_stop")); + } + + #[tokio::test] + async fn test_stream_request_with_failed_json_response_is_error() { + let input = r#"{ + "id":"resp_json", + "status":"failed", + "error":{"type":"server_error","message":"json backend failed"}, + "output":[] + }"#; + + let merged = convert_stream_text(input).await; + assert!(merged.contains("event: error")); + assert!(merged.contains("json backend failed")); + assert!(!merged.contains("event: message_stop")); + } + #[tokio::test] async fn test_streaming_conversion_with_wrapped_response_events() { let input = concat!( @@ -1010,13 +1910,73 @@ mod tests { ); } + #[tokio::test] + async fn test_streaming_tool_done_arguments_fallback_without_deltas() { + let input = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_done\",\"model\":\"gpt-5.6\"}}\n\n", + "event: response.output_item.added\n", + "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"fc_done\",\"type\":\"function_call\",\"call_id\":\"call_done\",\"name\":\"lookup\",\"arguments\":\"\"}}\n\n", + "event: response.function_call_arguments.done\n", + "data: {\"type\":\"response.function_call_arguments.done\",\"item_id\":\"fc_done\",\"output_index\":0,\"item\":{\"id\":\"fc_done\",\"type\":\"function_call\",\"arguments\":\"{\\\"q\\\":\\\"rust\\\"}\"}}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n" + ); + let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(input))]); + let merged = create_anthropic_sse_stream_from_responses(upstream) + .collect::>() + .await + .into_iter() + .map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string()) + .collect::(); + + assert!(merged.contains("\"partial_json\":\"{\\\"q\\\":\\\"rust\\\"}\"")); + assert_eq!(merged.matches("event: content_block_stop").count(), 1); + } + + #[tokio::test] + async fn test_official_reasoning_events_emit_signature_before_stop() { + let input = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_reason\",\"model\":\"gpt-5.6\"}}\n\n", + "event: response.output_item.added\n", + "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[]}}\n\n", + "event: response.reasoning_summary_part.added\n", + "data: {\"type\":\"response.reasoning_summary_part.added\",\"item_id\":\"rs_1\",\"output_index\":0,\"summary_index\":0,\"part\":{\"type\":\"summary_text\",\"text\":\"\"}}\n\n", + "event: response.reasoning_summary_text.delta\n", + "data: {\"type\":\"response.reasoning_summary_text.delta\",\"item_id\":\"rs_1\",\"output_index\":0,\"summary_index\":0,\"delta\":\"Need a tool.\"}\n\n", + "event: response.reasoning_summary_text.done\n", + "data: {\"type\":\"response.reasoning_summary_text.done\",\"item_id\":\"rs_1\",\"output_index\":0,\"summary_index\":0,\"text\":\"Need a tool.\"}\n\n", + "event: response.output_item.done\n", + "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"Need a tool.\"}],\"encrypted_content\":\"opaque\"}}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n" + ); + let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(input))]); + let merged = create_anthropic_sse_stream_from_responses(upstream) + .collect::>() + .await + .into_iter() + .map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string()) + .collect::(); + + assert!(merged.contains("\"type\":\"thinking_delta\"")); + assert!(merged.contains("\"type\":\"signature_delta\"")); + let signature_position = merged.find("signature_delta").unwrap(); + let stop_position = merged.find("event: content_block_stop").unwrap(); + assert!(signature_position < stop_position); + assert!(!merged[stop_position..].contains("content_block_delta")); + } + #[tokio::test] async fn test_streaming_reasoning_delta_emits_thinking_blocks() { let input = concat!( "event: response.created\n", "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_r\",\"model\":\"o3\",\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}\n\n", "event: response.reasoning.delta\n", - "data: {\"type\":\"response.reasoning.delta\",\"delta\":\"Let me think...\"}\n\n", + "data: {\"type\":\"response.reasoning.delta\",\"delta\":\"Let me \"}\n\n", + "event: response.reasoning.delta\n", + "data: {\"type\":\"response.reasoning.delta\",\"delta\":\"think...\"}\n\n", "event: response.reasoning.done\n", "data: {\"type\":\"response.reasoning.done\"}\n\n", "event: response.content_part.added\n", @@ -1049,8 +2009,9 @@ mod tests { "should emit thinking_delta" ); assert!( - merged.contains("\"thinking\":\"Let me think...\""), - "should contain thinking text" + merged.contains("\"thinking\":\"Let me \"") + && merged.contains("\"thinking\":\"think...\""), + "should contain both thinking deltas" ); assert!( merged.contains("\"type\":\"text_delta\""), @@ -1061,6 +2022,57 @@ mod tests { "should contain text delta" ); assert!(merged.contains("\"stop_reason\":\"end_turn\"")); + + let events: Vec = merged + .split("\n\n") + .filter_map(|block| { + block + .lines() + .find_map(|line| line.strip_prefix("data: ")) + .and_then(|data| serde_json::from_str(data).ok()) + }) + .collect(); + let thinking_starts: Vec<&Value> = events + .iter() + .filter(|event| { + event.get("type").and_then(Value::as_str) == Some("content_block_start") + && event.pointer("/content_block/type").and_then(Value::as_str) + == Some("thinking") + }) + .collect(); + assert_eq!( + thinking_starts.len(), + 1, + "keyless deltas must share one block" + ); + let thinking_index = thinking_starts[0] + .get("index") + .and_then(Value::as_u64) + .unwrap(); + let thinking_delta_indices: Vec = events + .iter() + .filter(|event| { + event.pointer("/delta/type").and_then(Value::as_str) == Some("thinking_delta") + }) + .filter_map(|event| event.get("index").and_then(Value::as_u64)) + .collect(); + assert_eq!(thinking_delta_indices, vec![thinking_index, thinking_index]); + + let stop_position = events + .iter() + .position(|event| { + event.get("type").and_then(Value::as_str) == Some("content_block_stop") + && event.get("index").and_then(Value::as_u64) == Some(thinking_index) + }) + .expect("legacy reasoning done must close the thinking block"); + let text_start_position = events + .iter() + .position(|event| { + event.get("type").and_then(Value::as_str) == Some("content_block_start") + && event.pointer("/content_block/type").and_then(Value::as_str) == Some("text") + }) + .expect("text block must start"); + assert!(stop_position < text_start_position); } #[tokio::test] diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index c13b26cd1..a1c1d023d 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -490,9 +490,21 @@ fn convert_message_to_openai( Ok(result) } -/// 清理 JSON schema(移除不支持的 format) -pub fn clean_schema(mut schema: Value) -> Value { +/// 清理工具参数的 JSON schema,并为根 schema 补齐 OpenAI 要求的 object 类型。 +pub fn clean_schema(schema: Value) -> Value { + clean_schema_inner(schema, true) +} + +fn clean_schema_inner(mut schema: Value, is_root: bool) -> Value { if let Some(obj) = schema.as_object_mut() { + let missing_type = is_root && !obj.contains_key("type"); + if missing_type { + obj.insert("type".to_string(), json!("object")); + } + if missing_type && !obj.contains_key("properties") { + obj.insert("properties".to_string(), json!({})); + } + // 移除 "format": "uri" if obj.get("format").and_then(|v| v.as_str()) == Some("uri") { obj.remove("format"); @@ -501,12 +513,12 @@ pub fn clean_schema(mut schema: Value) -> Value { // 递归清理嵌套 schema if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) { for (_, value) in properties.iter_mut() { - *value = clean_schema(value.clone()); + *value = clean_schema_inner(value.clone(), false); } } if let Some(items) = obj.get_mut("items") { - *items = clean_schema(items.clone()); + *items = clean_schema_inner(items.clone(), false); } } schema @@ -653,7 +665,7 @@ pub fn openai_to_anthropic(body: Value) -> Result { // 不再扣减),若不减则缓存会被计入 input 与各 cache 桶两次。三桶互斥,恒等: // input + cache_read + cache_creation == prompt_tokens(inclusive 上游)。 // 与流式 build_anthropic_usage_json (#2774) 及 transform_gemini 的 saturating_sub 对称。 - // 最终 cache_read:直传字段优先于 nested;cache_creation 仅来自直传字段(OpenAI 无此概念)。 + // 最终 cache_read/cache_creation:直传字段优先于 OpenAI nested details。 let cached = usage .get("cache_read_input_tokens") .and_then(|v| v.as_u64()) @@ -666,6 +678,12 @@ pub fn openai_to_anthropic(body: Value) -> Result { let cache_creation = usage .get("cache_creation_input_tokens") .and_then(|v| v.as_u64()) + .or_else(|| { + usage + .pointer("/prompt_tokens_details/cache_write_tokens") + .or_else(|| usage.pointer("/input_tokens_details/cache_write_tokens")) + .and_then(|v| v.as_u64()) + }) .unwrap_or(0); let input_tokens = usage .get("prompt_tokens") @@ -831,6 +849,75 @@ mod tests { let result = anthropic_to_openai(input).unwrap(); assert_eq!(result["tools"][0]["type"], "function"); assert_eq!(result["tools"][0]["function"]["name"], "get_weather"); + assert_eq!( + result["tools"][0]["function"]["parameters"]["type"], + json!("object") + ); + assert_eq!( + result["tools"][0]["function"]["parameters"]["properties"]["location"]["type"], + json!("string") + ); + } + + #[test] + fn test_anthropic_to_openai_defaults_missing_tool_schema_type() { + let input = json!({ + "model": "claude-3-opus", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "What's the weather?"}], + "tools": [{ + "name": "get_weather", + "description": "Get weather info", + "input_schema": {"properties": {"location": {"type": "string"}}} + }] + }); + + let result = anthropic_to_openai(input).unwrap(); + let parameters = &result["tools"][0]["function"]["parameters"]; + assert_eq!(parameters["type"], json!("object")); + assert_eq!( + parameters["properties"]["location"]["type"], + json!("string") + ); + } + + #[test] + fn test_anthropic_to_openai_defaults_empty_tool_schema() { + let input = json!({ + "model": "claude-3-opus", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Do work"}], + "tools": [{"name": "do_work", "input_schema": {}}] + }); + + let result = anthropic_to_openai(input).unwrap(); + let parameters = &result["tools"][0]["function"]["parameters"]; + assert_eq!(parameters, &json!({"type": "object", "properties": {}})); + } + + #[test] + fn test_clean_schema_only_defaults_root_to_object() { + let schema = json!({ + "properties": { + "nullable_value": { + "anyOf": [{"type": "string"}, {"type": "null"}] + }, + "list": { + "items": {"type": "string"} + } + } + }); + + let result = clean_schema(schema); + assert_eq!(result["type"], json!("object")); + assert_eq!( + result["properties"]["nullable_value"], + json!({"anyOf": [{"type": "string"}, {"type": "null"}]}) + ); + assert_eq!( + result["properties"]["list"], + json!({"items": {"type": "string"}}) + ); } #[test] diff --git a/src-tauri/src/proxy/providers/transform_codex_anthropic.rs b/src-tauri/src/proxy/providers/transform_codex_anthropic.rs new file mode 100644 index 000000000..a45e99aed --- /dev/null +++ b/src-tauri/src/proxy/providers/transform_codex_anthropic.rs @@ -0,0 +1,2718 @@ +//! OpenAI Responses ↔ Anthropic Messages format conversion module (used when the Codex upstream is an Anthropic gateway) +//! +//! Scenario: The Codex CLI only speaks the OpenAI Responses protocol, while the +//! upstream AI gateway only offers the native Anthropic Messages protocol +//! (`/v1/messages`). This module converts the Responses request sent by Codex +//! into an Anthropic request, then converts the Anthropic response back into a +//! Responses response. +//! +//! The direction is exactly the mirror of `transform_responses.rs`: +//! - `transform_responses.rs`: Anthropic request → Responses request, Responses response → Anthropic response +//! - this module: Responses request → Anthropic request, Anthropic response → Responses response + +use super::transform_codex_chat::{ + build_codex_tool_context_from_request, response_tool_call_item_from_chat_name, + response_tool_call_item_id_from_chat_name, CodexToolContext, +}; +use super::transform_responses::{sanitize_anthropic_tool_use_input, TOOL_RESULT_ERROR_MARKER}; +use crate::proxy::error::ProxyError; +use crate::proxy::json_canonical::canonical_json_string; +use crate::proxy::sse::{strip_sse_field, take_sse_block}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashSet}; + +pub(crate) const ANTHROPIC_THINKING_ENCRYPTED_PREFIX: &str = "ccswitch-anthropic-thinking-v1:"; +const TOOL_SEARCH_PROXY_NAME: &str = "tool_search"; + +/// Maps Codex's reasoning.effort to the token budget for Anthropic thinking. +/// +/// Returning `None` indicates an unrecognized effort value—in that case extended +/// thinking should not be enabled (to avoid accidentally swallowing +/// temperature/top_p), keeping normal sampling. +pub(crate) fn effort_to_thinking_budget(effort: &str) -> Option { + match effort.trim().to_ascii_lowercase().as_str() { + "minimal" | "low" => Some(2048), + "medium" => Some(8192), + "high" => Some(16384), + "xhigh" | "max" => Some(24576), + _ => None, + } +} + +fn codex_effort_to_anthropic(effort: &str) -> Option<&'static str> { + match effort.trim().to_ascii_lowercase().as_str() { + "minimal" | "low" => Some("low"), + "medium" => Some("medium"), + "high" => Some("high"), + "xhigh" | "max" => Some("max"), + _ => None, + } +} + +fn reasoning_explicitly_disabled(effort: Option<&str>) -> bool { + matches!( + effort + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("none" | "off" | "disabled") + ) +} + +/// Preserve an Anthropic signed thinking/redacted-thinking block inside the opaque +/// Responses `reasoning.encrypted_content` field so Codex replays it on the next +/// tool-result request. The prefix keeps unrelated providers' ciphertext isolated. +pub(crate) fn encode_anthropic_thinking_block(block: &Value) -> Option { + match block.get("type").and_then(|value| value.as_str()) { + Some("thinking") + if block + .get("signature") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()) => {} + Some("redacted_thinking") + if block + .get("data") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()) => {} + _ => return None, + } + let bytes = serde_json::to_vec(block).ok()?; + Some(format!( + "{ANTHROPIC_THINKING_ENCRYPTED_PREFIX}{}", + URL_SAFE_NO_PAD.encode(bytes) + )) +} + +pub(crate) fn decode_anthropic_thinking_block(encrypted_content: &str) -> Option { + let encoded = encrypted_content.strip_prefix(ANTHROPIC_THINKING_ENCRYPTED_PREFIX)?; + let bytes = URL_SAFE_NO_PAD.decode(encoded).ok()?; + let block: Value = serde_json::from_slice(&bytes).ok()?; + // Reuse the encoder's validation so legacy/malformed bridge envelopes cannot + // replay an unsigned thinking block into an Anthropic tool turn. + encode_anthropic_thinking_block(&block).map(|_| block) +} + +pub(crate) fn responses_reasoning_item_from_anthropic_block( + item_id: &str, + block: &Value, +) -> Option { + let encrypted_content = encode_anthropic_thinking_block(block)?; + let summary = block + .get("thinking") + .and_then(|value| value.as_str()) + .filter(|text| !text.is_empty()) + .map(|text| vec![json!({ "type": "summary_text", "text": text })]) + .unwrap_or_default(); + Some(json!({ + "id": item_id, + "type": "reasoning", + "summary": summary, + "encrypted_content": encrypted_content + })) +} + +/// Anthropic's stop_reason → Responses' (status, incomplete_details.reason) +pub(crate) fn map_anthropic_stop_reason_to_status( + stop_reason: Option<&str>, +) -> (&'static str, Option<&'static str>) { + match stop_reason { + Some("max_tokens") => ("incomplete", Some("max_output_tokens")), + // Safety refusal: report as incomplete to avoid Codex treating it as a normally-completed empty reply. + Some("refusal") => ("incomplete", Some("content_filter")), + Some("model_context_window_exceeded") => ("incomplete", Some("max_output_tokens")), + // pause_turn is unreachable on this path (Codex requests do not declare Anthropic server-side tools); + // if it does occur, log a warning and treat it as completed. + Some("pause_turn") => { + log::warn!("[Codex] Received unexpected Anthropic stop_reason=pause_turn, treating it as completed"); + ("completed", None) + } + _ => ("completed", None), + } +} + +/// Builds Responses usage from Anthropic usage. +/// +/// Anthropic's `input_tokens` is the cache-excluded fresh input. OpenAI Responses +/// reports total input and exposes cache reads/writes as subsets: +/// input_tokens = fresh + cache_read + cache_creation +/// input_tokens_details.cached_tokens = cache_read +/// input_tokens_details.cache_write_tokens = cache_creation +/// +/// The internal billing parser subtracts both subsets before charging the normal +/// input rate, then prices cache reads and writes separately. +pub(crate) fn build_responses_usage_from_anthropic(usage: Option<&Value>) -> Value { + let u = match usage { + Some(v) if v.is_object() => v, + _ => { + return json!({ + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "output_tokens_details": { "reasoning_tokens": 0 } + }) + } + }; + + let fresh_input = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0); + let output = u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0); + let reasoning = u + .pointer("/output_tokens_details/thinking_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let cache_read = u + .get("cache_read_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let cache_creation = u + .get("cache_creation_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let input_tokens = fresh_input + .saturating_add(cache_read) + .saturating_add(cache_creation); + let total_tokens = input_tokens.saturating_add(output); + + let mut result = json!({ + "input_tokens": input_tokens, + "output_tokens": output, + "total_tokens": total_tokens, + "output_tokens_details": { "reasoning_tokens": reasoning } + }); + if cache_read > 0 || cache_creation > 0 { + result["input_tokens_details"] = json!({ + "cached_tokens": cache_read, + "cache_write_tokens": cache_creation + }); + } + // Keep the legacy top-level alias for one compatibility window. New code reads + // the official nested cache_write_tokens field first. + if cache_creation > 0 { + result["cache_creation_input_tokens"] = json!(cache_creation); + } + result +} + +/// OpenAI Responses request → Anthropic Messages request +/// +/// `default_max_tokens`: injected when the Responses body has no +/// `max_output_tokens` (Anthropic's `max_tokens` is required; missing it yields a 400). +fn responses_system_text(item: &Value) -> Vec { + match item.get("content") { + Some(Value::String(text)) if is_meaningful_text(text) => { + vec![text.trim().to_string()] + } + Some(Value::Array(parts)) => parts + .iter() + .filter_map(|part| { + matches!( + part.get("type").and_then(Value::as_str), + Some("input_text" | "output_text" | "text") + ) + .then(|| part.get("text").and_then(Value::as_str)) + .flatten() + .filter(|text| is_meaningful_text(text)) + .map(|text| text.trim().to_string()) + }) + .collect(), + _ => Vec::new(), + } +} + +pub fn responses_request_to_anthropic( + body: Value, + default_max_tokens: u64, +) -> Result { + let mut result = json!({}); + let tool_context = build_codex_tool_context_from_request(&body); + let model = body + .get("model") + .and_then(|value| value.as_str()) + .unwrap_or(""); + + // Pass model through (the upstream model has already been applied by the forwarder) + if let Some(model) = body.get("model").and_then(|m| m.as_str()) { + result["model"] = json!(model); + } + + // instructions and historical system/developer messages → Anthropic system. + // Anthropic messages only accept user/assistant roles; degrading these items to + // user silently changes instruction precedence. + let mut system_parts = Vec::new(); + if let Some(instructions) = body.get("instructions").and_then(|v| v.as_str()) { + if is_meaningful_text(instructions) { + system_parts.push(instructions.trim().to_string()); + } + } + if let Some(items) = body.get("input").and_then(Value::as_array) { + for item in items { + if matches!( + item.get("role").and_then(Value::as_str), + Some("system" | "developer") + ) { + system_parts.extend(responses_system_text(item)); + } + } + } + if !system_parts.is_empty() { + result["system"] = json!(system_parts.join("\n\n")); + } + + // input → messages + let mut messages = match body.get("input") { + Some(Value::Array(items)) => convert_input_to_messages(items, &tool_context)?, + Some(Value::String(text)) if is_meaningful_text(text) => vec![json!({ + "role": "user", + "content": [{ "type": "text", "text": text }] + })], + _ => Vec::new(), + }; + // Anthropic /v1/messages requires messages to be non-empty and the first to be user. + // Normalize the history (compacted/resumed sessions may start with + // assistant/function_call, or input may be entirely reasoning and thus empty after being dropped). + // Drop incomplete tool turns first (they would otherwise 400), then guarantee a leading user. + drop_incomplete_tool_turns(&mut messages); + drop_empty_messages(&mut messages); + ensure_leading_user_message(&mut messages); + if messages.is_empty() { + return Err(ProxyError::InvalidRequest( + "cannot convert Codex request: empty messages".to_string(), + )); + } + trim_trailing_assistant_text(&mut messages); + drop_empty_messages(&mut messages); + if messages.is_empty() { + return Err(ProxyError::InvalidRequest( + "cannot convert Codex request: empty messages".to_string(), + )); + } + let thinking_history_is_valid = trailing_turn_supports_thinking(&messages); + result["messages"] = json!(messages); + + let reasoning_effort = body + .pointer("/reasoning/effort") + .and_then(|value| value.as_str()); + let adaptive_model = crate::proxy::thinking_optimizer::uses_adaptive_thinking(model); + let adaptive_by_default = crate::proxy::thinking_optimizer::adaptive_thinking_is_default(model); + let cannot_disable_thinking = + crate::proxy::thinking_optimizer::thinking_cannot_be_disabled(model); + + // max_output_tokens → max_tokens (required) + let max_tokens = body + .get("max_output_tokens") + .and_then(|v| v.as_u64()) + .filter(|v| *v > 0) + .unwrap_or(default_max_tokens); + let mut thinking_enabled = false; + let mut thinking_budget = reasoning_effort + .and_then(effort_to_thinking_budget) + .unwrap_or(0); + let explicitly_disabled = reasoning_explicitly_disabled(reasoning_effort); + let adaptive_should_think = adaptive_model + && (adaptive_by_default + || reasoning_effort + .and_then(codex_effort_to_anthropic) + .is_some()); + + if !thinking_history_is_valid { + if cannot_disable_thinking { + return Err(ProxyError::InvalidRequest( + "Anthropic model requires thinking, but the tool history has no signed thinking block to replay" + .to_string(), + )); + } + if adaptive_should_think { + result["thinking"] = json!({ "type": "disabled" }); + } + } else if adaptive_should_think && (!explicitly_disabled || cannot_disable_thinking) { + thinking_enabled = true; + result["thinking"] = json!({ "type": "adaptive" }); + if let Some(effort) = reasoning_effort.and_then(codex_effort_to_anthropic) { + result["output_config"] = json!({ "effort": effort }); + } else if explicitly_disabled && cannot_disable_thinking { + // Fable/Mythos cannot turn thinking off. `low` is the closest safe + // representation of Codex's explicit `none` request. + result["output_config"] = json!({ "effort": "low" }); + } + } else if explicitly_disabled { + result["thinking"] = json!({ "type": "disabled" }); + } else if thinking_budget > 0 { + thinking_enabled = true; + // Anthropic requires max_tokens > budget_tokens and budget >= 1024. Reserve + // headroom for the visible answer: cap the thinking budget at half of max_tokens + // so a large derived budget (e.g. 24576 for xhigh) can't consume nearly all of a + // modest max_tokens and leave ~1 output token (an effectively empty completion). + // Do not raise the caller's max_tokens (it may exceed the model's output ceiling + // and 400). If the remaining budget is below Anthropic's 1024 floor, disable + // thinking and restore normal sampling. + let ceiling = max_tokens / 2; + thinking_budget = thinking_budget.min(ceiling); + if thinking_budget < 1024 { + thinking_enabled = false; + } + } + result["max_tokens"] = json!(max_tokens); + + if thinking_enabled && !adaptive_model { + result["thinking"] = json!({ + "type": "enabled", + "budget_tokens": thinking_budget + }); + } + + if !thinking_enabled { + if let Some(v) = body.get("temperature") { + result["temperature"] = v.clone(); + } + if let Some(v) = body.get("top_p") { + result["top_p"] = v.clone(); + } + } + + if let Some(v) = body.get("stream") { + result["stream"] = v.clone(); + } + + // Reuse the Codex tool context so function, namespace, custom, tool_search, and + // dynamically loaded tools all receive stable flat names upstream. + let anth_tools: Vec = tool_context + .chat_tools() + .iter() + .filter_map(chat_tool_to_anthropic_tool) + .collect(); + let has_tools = !anth_tools.is_empty(); + if has_tools { + result["tools"] = json!(anth_tools); + } + + // Only forward tool_choice when tools survived the filter. Anthropic 400s on a + // tool_choice with no tools ("tool_choice may only be specified while providing + // tools"), and that 400 is non-retryable — so a request whose only tools were + // unsupported hosted tools (for example web_search) must drop tool_choice too. + if has_tools { + if let Some(tc) = body.get("tool_choice") { + let mapped = map_tool_choice_to_anthropic(tc, &tool_context); + let forced = matches!( + mapped.get("type").and_then(|value| value.as_str()), + Some("any" | "tool") + ); + if thinking_enabled && forced { + if cannot_disable_thinking { + return Err(ProxyError::InvalidRequest( + "Anthropic model requires adaptive thinking and cannot honor a forced tool_choice" + .to_string(), + )); + } + // Anthropic rejects forced tools while thinking is enabled. Preserve + // the caller's explicit tool constraint and disable thinking for this + // request instead of silently weakening `required`/named selection. + result["thinking"] = json!({ "type": "disabled" }); + result.as_object_mut().unwrap().remove("output_config"); + if let Some(value) = body.get("temperature") { + result["temperature"] = value.clone(); + } + if let Some(value) = body.get("top_p") { + result["top_p"] = value.clone(); + } + } + result["tool_choice"] = mapped; + } + + if body.get("parallel_tool_calls").and_then(Value::as_bool) == Some(false) { + if result.get("tool_choice").is_none() { + result["tool_choice"] = json!({ "type": "auto" }); + } + if let Some(tool_choice) = result.get_mut("tool_choice").and_then(Value::as_object_mut) + { + tool_choice.insert("disable_parallel_tool_use".to_string(), json!(true)); + } + } + } + + Ok(result) +} + +fn chat_tool_to_anthropic_tool(chat_tool: &Value) -> Option { + let function = chat_tool.get("function")?; + let name = function + .get("name") + .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": {} })) + }); + if let Some(description) = function.get("description").and_then(|value| value.as_str()) { + tool["description"] = json!(description); + } + if let Some(strict) = function.get("strict").and_then(|value| value.as_bool()) { + tool["strict"] = json!(strict); + } + Some(tool) +} + +/// tool_choice: Responses → Anthropic (the reverse of `map_tool_choice_to_responses`) +fn map_tool_choice_to_anthropic(tool_choice: &Value, tool_context: &CodexToolContext) -> Value { + match tool_choice { + Value::String(s) => match s.as_str() { + "required" => json!({ "type": "any" }), + "auto" => json!({ "type": "auto" }), + "none" => json!({ "type": "none" }), + _ => json!({ "type": "auto" }), + }, + Value::Object(obj) => match obj.get("type").and_then(|t| t.as_str()) { + Some("function") => { + let name = obj.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let namespace = obj.get("namespace").and_then(|value| value.as_str()); + let upstream_name = tool_context.chat_name_for_response_function(name, namespace); + json!({ "type": "tool", "name": upstream_name }) + } + Some("custom") => json!({ + "type": "tool", + "name": obj.get("name").and_then(|value| value.as_str()).unwrap_or("") + }), + Some("tool_search") => { + json!({ "type": "tool", "name": TOOL_SEARCH_PROXY_NAME }) + } + // Other object shapes (allowed_tools / hosted-tool selectors, etc.) are + // not recognized by Anthropic; downgrade to auto to avoid passing OpenAI's + // raw structure through and causing a 400. + _ => json!({ "type": "auto" }), + }, + _ => json!({ "type": "auto" }), + } +} + +/// Re-nests the flat Responses input[] back into Anthropic messages. +/// +/// - input_text/output_text → text block of the corresponding role +/// - input_image → image block +/// - function_call → assistant's tool_use block (merged with the preceding assistant text into the same message) +/// - function_call_output → user's tool_result block (consecutive ones merged into the same user message) +/// - Anthropic-origin reasoning.encrypted_content → restored signed thinking block +fn convert_input_to_messages( + items: &[Value], + tool_context: &CodexToolContext, +) -> Result, ProxyError> { + let mut messages: Vec = Vec::new(); + + for item in items { + let item_type = item.get("type").and_then(|t| t.as_str()); + if matches!( + item_type, + Some("function_call" | "custom_tool_call" | "tool_search_call") + ) && item.get("status").and_then(Value::as_str) == Some("incomplete") + { + log::warn!( + "[Codex/Anthropic] Dropping incomplete historical tool call: type={}, call_id={}", + item_type.unwrap_or("unknown"), + item.get("call_id") + .and_then(Value::as_str) + .or_else(|| item.get("id").and_then(Value::as_str)) + .unwrap_or("") + ); + continue; + } + + match item_type { + Some("function_call") => { + let call_id = item + .get("call_id") + .and_then(|v| v.as_str()) + .or_else(|| item.get("id").and_then(|v| v.as_str())) + .unwrap_or(""); + let name = item.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let namespace = item.get("namespace").and_then(|value| value.as_str()); + let upstream_name = tool_context.chat_name_for_response_function(name, namespace); + let args_str = item.get("arguments").and_then(|v| v.as_str()).unwrap_or(""); + let input: Value = if args_str.trim().is_empty() { + json!({}) + } else { + serde_json::from_str(args_str).map_err(|error| { + ProxyError::InvalidRequest(format!( + "Invalid function_call arguments for '{name}': {error}" + )) + })? + }; + if !input.is_object() { + return Err(ProxyError::InvalidRequest(format!( + "Function call arguments for '{name}' must be a JSON object" + ))); + } + let input = sanitize_anthropic_tool_use_input(name, input); + push_block( + &mut messages, + "assistant", + json!({ + "type": "tool_use", + "id": call_id, + "name": upstream_name, + "input": input + }), + ); + } + Some("custom_tool_call") => { + let call_id = item + .get("call_id") + .and_then(|value| value.as_str()) + .or_else(|| item.get("id").and_then(|value| value.as_str())) + .unwrap_or(""); + let name = item + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let input = item.get("input").cloned().unwrap_or_else(|| json!("")); + push_block( + &mut messages, + "assistant", + json!({ + "type": "tool_use", + "id": call_id, + "name": name, + "input": { "input": input } + }), + ); + } + Some("tool_search_call") => { + let call_id = item + .get("call_id") + .and_then(|value| value.as_str()) + .or_else(|| item.get("id").and_then(|value| value.as_str())) + .unwrap_or(""); + let input = item + .get("arguments") + .cloned() + .filter(Value::is_object) + .unwrap_or_else(|| json!({})); + push_block( + &mut messages, + "assistant", + json!({ + "type": "tool_use", + "id": call_id, + "name": TOOL_SEARCH_PROXY_NAME, + "input": input + }), + ); + } + Some("function_call_output" | "custom_tool_call_output" | "tool_search_output") => { + let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or(""); + let output = tool_result_content_from_responses_item(item); + let mut block = json!({ + "type": "tool_result", + "tool_use_id": call_id, + "content": output.content + }); + if output.is_error { + block["is_error"] = json!(true); + } + push_tool_result_block(&mut messages, block); + } + Some("input_text") => { + if let Some(text) = item + .get("text") + .and_then(Value::as_str) + .filter(|text| is_meaningful_text(text)) + { + push_block( + &mut messages, + "user", + json!({ "type": "text", "text": text }), + ); + } + } + Some("input_image") => { + if let Some(block) = image_block_from_input_image(item) { + push_block(&mut messages, "user", block); + } + } + Some("reasoning") => { + if let Some(block) = item + .get("encrypted_content") + .and_then(|value| value.as_str()) + .and_then(decode_anthropic_thinking_block) + { + push_assistant_thinking_block(&mut messages, block); + } + } + // message item or an item carrying a role + _ => { + let role = item.get("role").and_then(|r| r.as_str()).unwrap_or("user"); + if matches!(role, "system" | "developer") { + continue; + } + let anth_role = if role == "assistant" { + "assistant" + } else { + "user" + }; + match item.get("content") { + Some(Value::String(text)) if is_meaningful_text(text) => { + push_block( + &mut messages, + anth_role, + json!({ "type": "text", "text": text }), + ); + } + Some(Value::Array(parts)) => { + for part in parts { + let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or(""); + match part_type { + "input_text" | "output_text" => { + if let Some(text) = part + .get("text") + .and_then(|t| t.as_str()) + .filter(|t| is_meaningful_text(t)) + { + push_block( + &mut messages, + anth_role, + json!({ "type": "text", "text": text }), + ); + } + } + "refusal" => { + if let Some(text) = part + .get("refusal") + .and_then(|t| t.as_str()) + .filter(|t| is_meaningful_text(t)) + { + push_block( + &mut messages, + anth_role, + json!({ "type": "text", "text": text }), + ); + } + } + "input_image" => { + if let Some(block) = image_block_from_input_image(part) { + push_block(&mut messages, anth_role, block); + } + } + "input_file" => { + if let Some(block) = document_block_from_input_file(part) { + push_block(&mut messages, anth_role, block); + } + } + _ => {} + } + } + } + _ => {} + } + } + } + } + + Ok(messages) +} + +struct ToolResultContent { + content: Value, + is_error: bool, +} + +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(Value::Array(parts)) => { + let mut content = Vec::new(); + let mut is_error = false; + for part in parts { + match part.get("type").and_then(Value::as_str) { + Some("input_text" | "output_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})); + } + } + } + Some("input_image") => { + if let Some(image) = image_block_from_input_image(part) { + content.push(image); + } else { + content.push(json!({ + "type":"text", + "text":canonical_json_string(part) + })); + } + } + Some("input_file") => { + if let Some(document) = document_block_from_input_file(part) { + content.push(document); + } else { + content.push(json!({ + "type":"text", + "text":canonical_json_string(part) + })); + } + } + _ => content.push(json!({ + "type":"text", + "text":canonical_json_string(part) + })), + } + } + ToolResultContent { + content: Value::Array(content), + is_error, + } + } + Some(value) => ToolResultContent { + content: json!(canonical_json_string(value)), + is_error: false, + }, + None => ToolResultContent { + content: json!(canonical_json_string(item)), + is_error: false, + }, + } +} + +/// 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). +fn ensure_leading_user_message(messages: &mut Vec) { + let leads_with_user = messages + .first() + .and_then(|m| m.get("role")) + .and_then(|r| r.as_str()) + == Some("user"); + if !messages.is_empty() && !leads_with_user { + messages.insert( + 0, + json!({ + "role": "user", + "content": [{ "type": "text", "text": "(continuing the conversation)" }] + }), + ); + } +} + +/// Removes compacted/resumed tool turns that no longer form a complete adjacent +/// assistant `tool_use` → user `tool_result` pair. Anthropic requires every tool +/// call in an assistant turn to be answered together in the immediately following +/// user turn. Dropping the whole incomplete assistant turn also avoids modifying a +/// subset of a signed thinking/tool-use response. +fn drop_incomplete_tool_turns(messages: &mut Vec) { + let original = std::mem::take(messages); + let mut sanitized = Vec::with_capacity(original.len()); + let mut index = 0; + + while index < original.len() { + let message = &original[index]; + let is_assistant = message.get("role").and_then(Value::as_str) == Some("assistant"); + let tool_use_ids = if is_assistant { + message_block_ids(message, "tool_use", "id") + } else { + Vec::new() + }; + + if !tool_use_ids.is_empty() { + let paired_user = original + .get(index + 1) + .filter(|next| next.get("role").and_then(Value::as_str) == Some("user")); + let tool_result_ids = paired_user + .map(|user| message_block_ids(user, "tool_result", "tool_use_id")) + .unwrap_or_default(); + let unique_tool_uses: HashSet<&str> = tool_use_ids.iter().copied().collect(); + let unique_tool_results: HashSet<&str> = tool_result_ids.iter().copied().collect(); + let complete = tool_use_ids.iter().all(|id| !id.is_empty()) + && tool_result_ids.iter().all(|id| !id.is_empty()) + && unique_tool_uses.len() == tool_use_ids.len() + && unique_tool_results.len() == tool_result_ids.len() + && unique_tool_uses == unique_tool_results; + + if complete { + sanitized.push(message.clone()); + sanitized.push(paired_user.unwrap().clone()); + } else if let Some(user) = paired_user { + let mut user = user.clone(); + drop_tool_result_blocks(&mut user); + if message_has_content(&user) { + sanitized.push(user); + } + } + + index += if paired_user.is_some() { 2 } else { 1 }; + continue; + } + + let mut message = message.clone(); + if message.get("role").and_then(Value::as_str) == Some("user") { + // A user message not consumed as the adjacent half of a complete tool + // pair cannot legally retain any tool_result blocks. + drop_tool_result_blocks(&mut message); + } + if message_has_content(&message) { + sanitized.push(message); + } + index += 1; + } + + *messages = sanitized; +} + +fn message_block_ids<'a>(message: &'a Value, block_type: &str, id_field: &str) -> Vec<&'a str> { + message + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|block| block.get("type").and_then(Value::as_str) == Some(block_type)) + .map(|block| block.get(id_field).and_then(Value::as_str).unwrap_or("")) + .collect() +} + +fn drop_tool_result_blocks(message: &mut Value) { + if let Some(content) = message.get_mut("content").and_then(Value::as_array_mut) { + content.retain(|block| block.get("type").and_then(Value::as_str) != Some("tool_result")); + } +} + +fn message_has_content(message: &Value) -> bool { + message + .get("content") + .and_then(Value::as_array) + .map(|content| !content.is_empty()) + .unwrap_or(true) +} + +/// A fresh user prompt can start a new thinking turn. A tool-result continuation +/// may keep thinking enabled only when the preceding assistant turn includes the +/// signed thinking/redacted-thinking block that Anthropic requires callers to replay. +fn trailing_turn_supports_thinking(messages: &[Value]) -> bool { + let Some(last) = messages.last() else { + return false; + }; + if last.get("role").and_then(Value::as_str) != Some("user") { + return false; + } + let mut tool_result_ids = Vec::new(); + if let Some(blocks) = last.get("content").and_then(Value::as_array) { + for block in blocks { + if block.get("type").and_then(Value::as_str) != Some("tool_result") { + continue; + } + let Some(id) = block + .get("tool_use_id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + else { + return false; + }; + tool_result_ids.push(id); + } + } + if tool_result_ids.is_empty() { + return true; + } + + // A tool-result turn answers the immediately preceding assistant tool-use turn. + // Looking any farther back can pick up an unrelated signed thinking block and + // incorrectly re-enable thinking for an unsigned tool call. + let Some(paired_assistant) = messages.get(messages.len().saturating_sub(2)) else { + return false; + }; + if paired_assistant.get("role").and_then(Value::as_str) != Some("assistant") { + return false; + } + let Some(blocks) = paired_assistant.get("content").and_then(Value::as_array) else { + return false; + }; + let has_signed_thinking = blocks.iter().any(|block| { + matches!( + block.get("type").and_then(Value::as_str), + Some("thinking" | "redacted_thinking") + ) + }); + if !has_signed_thinking { + return false; + } + + let paired_tool_use_ids: HashSet<&str> = blocks + .iter() + .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use")) + .filter_map(|block| block.get("id").and_then(Value::as_str)) + .collect(); + tool_result_ids + .iter() + .all(|id| paired_tool_use_ids.contains(id)) +} + +/// Removes whitespace-only assistant prefills and trims trailing whitespace from a +/// real prefill. Anthropic rejects an assistant prefill whose final text ends in +/// whitespace, and Codex may replay an empty assistant text beside a tool call. +fn trim_trailing_assistant_text(messages: &mut [Value]) { + let Some(last) = messages.last_mut() else { + return; + }; + if last.get("role").and_then(Value::as_str) != Some("assistant") { + return; + } + let Some(blocks) = last.get_mut("content").and_then(Value::as_array_mut) else { + return; + }; + let Some(block) = blocks.last_mut() else { + return; + }; + if block.get("type").and_then(Value::as_str) != Some("text") { + return; + } + let Some(text) = block.get("text").and_then(Value::as_str) else { + return; + }; + let trimmed = text.trim_end(); + if trimmed.is_empty() { + blocks.pop(); + } else if trimmed.len() != text.len() { + block["text"] = json!(trimmed); + } +} + +/// Anthropic 400s on a text content block whose text is empty or whitespace-only. +/// Such blocks arise when a prior Responses turn recorded an empty +/// input_text/output_text (e.g. an empty assistant text emitted alongside a +/// tool_use); replaying it verbatim would fail the next follow-up request. +fn is_meaningful_text(text: &str) -> bool { + !text.trim().is_empty() +} + +/// Removes messages whose content array ended up empty (e.g. a turn that carried +/// only empty text that was filtered out). Anthropic 400s on empty content. +fn drop_empty_messages(messages: &mut Vec) { + messages.retain(|msg| { + msg.get("content") + .and_then(|c| c.as_array()) + .map(|arr| !arr.is_empty()) + .unwrap_or(true) + }); +} + +/// Appends a content block to messages: merge if the last message has the same role, otherwise create a new message. +fn push_block(messages: &mut Vec, role: &str, block: Value) { + if let Some(last) = messages.last_mut() { + if last.get("role").and_then(|r| r.as_str()) == Some(role) { + if let Some(arr) = last.get_mut("content").and_then(|c| c.as_array_mut()) { + arr.push(block); + return; + } + } + } + messages.push(json!({ + "role": role, + "content": [block] + })); +} + +/// Appends a tool result to a user turn while preserving Anthropic's required +/// ordering: every tool_result block must precede any text or image blocks. +fn push_tool_result_block(messages: &mut Vec, block: Value) { + if let Some(last) = messages.last_mut() { + if last.get("role").and_then(Value::as_str) == Some("user") { + if let Some(content) = last.get_mut("content").and_then(Value::as_array_mut) { + let insert_at = content + .iter() + .position(|item| { + item.get("type").and_then(Value::as_str) != Some("tool_result") + }) + .unwrap_or(content.len()); + content.insert(insert_at, block); + return; + } + } + } + messages.push(json!({ + "role": "user", + "content": [block] + })); +} + +fn push_assistant_thinking_block(messages: &mut Vec, block: Value) { + if let Some(last) = messages.last_mut() { + if last.get("role").and_then(Value::as_str) == Some("assistant") { + if let Some(content) = last.get_mut("content").and_then(Value::as_array_mut) { + let index = content + .iter() + .take_while(|item| { + matches!( + item.get("type").and_then(Value::as_str), + Some("thinking" | "redacted_thinking") + ) + }) + .count(); + content.insert(index, block); + return; + } + } + } + push_block(messages, "assistant", block); +} + +/// Responses' input_image → Anthropic image block. +fn image_block_from_input_image(part: &Value) -> Option { + let url = part.get("image_url").and_then(|v| { + v.as_str() + .map(str::to_string) + .or_else(|| v.get("url").and_then(|u| u.as_str()).map(str::to_string)) + })?; + + if let Some(rest) = url.strip_prefix("data:") { + // data:;base64, + let (meta, data) = rest.split_once(',')?; + let media_type = meta.split(';').next().unwrap_or("image/png"); + Some(json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": data + } + })) + } else if url.starts_with("http://") || url.starts_with("https://") { + Some(json!({ + "type": "image", + "source": { "type": "url", "url": url } + })) + } else { + None + } +} + +/// Responses' input_file → Anthropic document block. +fn document_block_from_input_file(part: &Value) -> Option { + let filename = part + .get("filename") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()); + + let mut block = if let Some(file_url) = part + .get("file_url") + .and_then(Value::as_str) + .filter(|url| url.starts_with("http://") || url.starts_with("https://")) + { + json!({ + "type":"document", + "source":{"type":"url","url":file_url} + }) + } else { + let file_data = part.get("file_data").and_then(Value::as_str)?; + let rest = file_data.strip_prefix("data:")?; + let (meta, data) = rest.split_once(',')?; + if data.is_empty() { + return None; + } + let media_type = meta.split(';').next().unwrap_or("application/pdf"); + json!({ + "type":"document", + "source":{ + "type":"base64", + "media_type":media_type, + "data":data + } + }) + }; + + if let Some(filename) = filename { + block["title"] = json!(filename); + } + Some(block) +} + +/// Anthropic Messages response → OpenAI Responses response (non-streaming) +#[allow(dead_code)] +pub fn anthropic_response_to_responses(body: Value) -> Result { + anthropic_response_to_responses_with_context(body, &CodexToolContext::default()) +} + +pub(crate) fn anthropic_response_to_responses_with_context( + body: Value, + tool_context: &CodexToolContext, +) -> Result { + if body.get("type").and_then(Value::as_str) == Some("error") || body.get("error").is_some() { + let error = body.get("error").unwrap_or(&body); + let message = error + .get("message") + .and_then(Value::as_str) + .or_else(|| error.as_str()) + .unwrap_or("Anthropic upstream returned an error envelope"); + let error_type = error.get("type").and_then(Value::as_str).unwrap_or("error"); + return Err(ProxyError::TransformError(format!( + "Anthropic upstream {error_type}: {message}" + ))); + } + + let id = body.get("id").and_then(|i| i.as_str()).unwrap_or(""); + let response_id = if id.is_empty() { + "resp_ccswitch".to_string() + } else if id.starts_with("resp_") { + id.to_string() + } else { + format!("resp_{id}") + }; + let model = body.get("model").and_then(|m| m.as_str()).unwrap_or(""); + + let mut output: Vec = Vec::new(); + let mut text_parts: Vec = Vec::new(); + + let flush_text = |output: &mut Vec, text_parts: &mut Vec| { + if !text_parts.is_empty() { + let idx = output.len(); + output.push(json!({ + "id": format!("{response_id}_msg_{idx}"), + "type": "message", + "status": "completed", + "role": "assistant", + "content": std::mem::take(text_parts) + })); + } + }; + + if let Some(blocks) = body.get("content").and_then(|c| c.as_array()) { + for block in blocks { + match block.get("type").and_then(|t| t.as_str()).unwrap_or("") { + "text" => { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + text_parts.push(json!({ + "type": "output_text", + "text": text, + "annotations": [] + })); + } + } + "tool_use" => { + flush_text(&mut output, &mut text_parts); + let call_id = block.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let name = block.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let input = block.get("input").cloned().unwrap_or(json!({})); + let input = sanitize_anthropic_tool_use_input(name, input); + let item_id = + response_tool_call_item_id_from_chat_name(call_id, name, tool_context); + output.push(response_tool_call_item_from_chat_name( + &item_id, + "completed", + call_id, + name, + &canonical_json_string(&input), + None, + tool_context, + )); + } + "thinking" | "redacted_thinking" => { + flush_text(&mut output, &mut text_parts); + let idx = output.len(); + if let Some(item) = responses_reasoning_item_from_anthropic_block( + &format!("rs_{response_id}_{idx}"), + block, + ) { + output.push(item); + } + } + _ => {} + } + } + } + flush_text(&mut output, &mut text_parts); + + let (status, incomplete_reason) = + map_anthropic_stop_reason_to_status(body.get("stop_reason").and_then(|s| s.as_str())); + let usage = build_responses_usage_from_anthropic(body.get("usage")); + + let mut result = json!({ + "id": response_id, + "object": "response", + "created_at": 0, + "status": status, + "model": model, + "output": output, + "usage": usage + }); + if let Some(reason) = incomplete_reason { + result["incomplete_details"] = json!({ "reason": reason }); + } + + Ok(result) +} + +/// Aggregates an Anthropic Messages **SSE stream** (with no Content-Type marker) +/// back into a single Anthropic non-streaming message JSON. +/// +/// Used as a fallback: the upstream returned an SSE body for a `stream:false` +/// request but without the `text/event-stream` header (symmetric to the +/// `body_looks_like_sse` fallback on the chat / claude side, see #2234). The +/// aggregated message can be handed directly to [`anthropic_response_to_responses`]. +/// +/// It also tolerates the last event missing a trailing blank line (truncated +/// stream): after looping over complete event blocks, it processes the residual +/// buffer as the last event. +pub fn anthropic_sse_to_message_value(body: &str) -> Result { + let mut message: Option = None; + // Collect blocks by content index along with the partial_json accumulator for their tool_use. + let mut blocks: BTreeMap = BTreeMap::new(); + let mut json_accum: BTreeMap = BTreeMap::new(); + let mut stop_reason: Option = None; + let mut delta_output_tokens: Option = None; + let mut saw_message_stop = false; + + let mut buffer = body.to_string(); + let process_block = |block: &str, + message: &mut Option, + blocks: &mut BTreeMap, + json_accum: &mut BTreeMap, + stop_reason: &mut Option, + delta_output_tokens: &mut Option, + saw_message_stop: &mut bool| + -> Result<(), ProxyError> { + let mut data = String::new(); + for line in block.lines() { + if let Some(chunk) = strip_sse_field(line, "data") { + if !data.is_empty() { + data.push('\n'); + } + data.push_str(chunk); + } + } + if data.trim().is_empty() || data.trim() == "[DONE]" { + return Ok(()); + } + let value: Value = match serde_json::from_str(data.trim()) { + Ok(v) => v, + Err(_) => return Ok(()), // Skip events that cannot be parsed (ping, etc.) + }; + match value.get("type").and_then(|t| t.as_str()).unwrap_or("") { + "message_start" => { + if let Some(msg) = value.get("message") { + *message = Some(msg.clone()); + } + } + "content_block_start" => { + if let Some(index) = value.get("index").and_then(|v| v.as_u64()) { + let block = value.get("content_block").cloned().unwrap_or(json!({})); + blocks.insert(index, block); + json_accum.entry(index).or_default(); + } + } + "content_block_delta" => { + if let Some(index) = value.get("index").and_then(|v| v.as_u64()) { + let delta = value.get("delta").cloned().unwrap_or(json!({})); + match delta.get("type").and_then(|t| t.as_str()).unwrap_or("") { + "text_delta" => { + if let Some(text) = delta.get("text").and_then(|t| t.as_str()) { + append_str_field( + blocks.entry(index).or_insert(json!({})), + "text", + text, + ); + } + } + "thinking_delta" => { + if let Some(text) = delta.get("thinking").and_then(|t| t.as_str()) { + append_str_field( + blocks.entry(index).or_insert(json!({})), + "thinking", + text, + ); + } + } + "signature_delta" => { + if let Some(sig) = delta.get("signature").and_then(|t| t.as_str()) { + blocks.entry(index).or_insert(json!({}))["signature"] = json!(sig); + } + } + "input_json_delta" => { + if let Some(partial) = + delta.get("partial_json").and_then(|t| t.as_str()) + { + json_accum.entry(index).or_default().push_str(partial); + } + } + _ => {} + } + } + } + "content_block_stop" => { + if let Some(index) = value.get("index").and_then(|v| v.as_u64()) { + if let Some(accum) = json_accum.get(&index) { + if !accum.trim().is_empty() { + let parsed: Value = + serde_json::from_str(accum).unwrap_or_else(|_| json!({})); + if let Some(block) = blocks.get_mut(&index) { + block["input"] = parsed; + } + } + } + } + } + "message_delta" => { + if let Some(reason) = value.pointer("/delta/stop_reason").and_then(|v| v.as_str()) { + *stop_reason = Some(reason.to_string()); + } + if let Some(output) = value + .pointer("/usage/output_tokens") + .and_then(|v| v.as_u64()) + { + *delta_output_tokens = Some(output); + } + } + "message_stop" => *saw_message_stop = true, + "error" => { + let msg = value + .pointer("/error/message") + .and_then(|v| v.as_str()) + .unwrap_or("upstream anthropic SSE error"); + return Err(ProxyError::TransformError(format!( + "anthropic SSE error event: {msg}" + ))); + } + _ => {} + } + Ok(()) + }; + + while let Some(block) = take_sse_block(&mut buffer) { + process_block( + &block, + &mut message, + &mut blocks, + &mut json_accum, + &mut stop_reason, + &mut delta_output_tokens, + &mut saw_message_stop, + )?; + } + // Tolerate the last event missing a trailing blank line (truncated stream). + if !buffer.trim().is_empty() { + process_block( + &buffer.clone(), + &mut message, + &mut blocks, + &mut json_accum, + &mut stop_reason, + &mut delta_output_tokens, + &mut saw_message_stop, + )?; + } + + let mut message = message.ok_or_else(|| { + ProxyError::TransformError( + "anthropic SSE aggregation: missing message_start event".to_string(), + ) + })?; + + if !saw_message_stop && stop_reason.is_none() { + if blocks.is_empty() { + return Err(ProxyError::TransformError( + "anthropic SSE aggregation: stream ended before message_stop".to_string(), + )); + } + // Preserve partial content but make the truncation visible to Codex instead + // of returning a normal completed response. + stop_reason = Some("max_tokens".to_string()); + } + + // Merge in the content blocks (ordered by index), stop_reason, and the cumulative output_tokens. + let content: Vec = blocks.into_values().collect(); + message["content"] = json!(content); + if let Some(reason) = stop_reason { + message["stop_reason"] = json!(reason); + } + if let Some(output) = delta_output_tokens { + // message_delta's usage.output_tokens is a cumulative value, overriding the 0 from message_start. + if let Some(usage) = message.get_mut("usage").and_then(|u| u.as_object_mut()) { + usage.insert("output_tokens".to_string(), json!(output)); + } + } + + Ok(message) +} + +/// Appends content to a string field of a JSON object (creating it if absent). +fn append_str_field(block: &mut Value, field: &str, text: &str) { + let existing = block + .get(field) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + block[field] = json!(format!("{existing}{text}")); +} + +#[cfg(test)] +mod tests { + use super::*; + + // ==================== Request: Responses → Anthropic ==================== + + #[test] + fn test_request_simple_text() { + let input = json!({ + "model": "claude-3-5-sonnet", + "max_output_tokens": 1024, + "input": [ + { "role": "user", "content": [{ "type": "input_text", "text": "Hello" }] } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["model"], "claude-3-5-sonnet"); + assert_eq!(result["max_tokens"], 1024); + assert_eq!(result["messages"][0]["role"], "user"); + assert_eq!(result["messages"][0]["content"][0]["type"], "text"); + assert_eq!(result["messages"][0]["content"][0]["text"], "Hello"); + } + + #[test] + fn test_request_missing_max_output_tokens_injects_default() { + let input = json!({ + "model": "claude", + "input": [{ "role": "user", "content": "Hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["max_tokens"], 4096); + } + + #[test] + fn test_request_instructions_to_system() { + let input = json!({ + "model": "claude", + "max_output_tokens": 100, + "instructions": "You are helpful.", + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["system"], "You are helpful."); + } + + #[test] + fn test_request_system_and_developer_history_are_hoisted() { + let input = json!({ + "model":"claude", + "instructions":"base", + "input":[ + {"role":"system","content":"system history"}, + {"role":"developer","content":[{"type":"input_text","text":"developer history"}]}, + {"role":"user","content":"hi"} + ] + }); + + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!( + result["system"], + "base\n\nsystem history\n\ndeveloper history" + ); + assert_eq!(result["messages"].as_array().unwrap().len(), 1); + assert_eq!(result["messages"][0]["role"], "user"); + } + + #[test] + fn test_request_no_instructions_no_system() { + let input = json!({ + "model": "claude", + "max_output_tokens": 100, + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert!(result.get("system").is_none()); + } + + #[test] + fn test_request_tools_and_filtering() { + let input = json!({ + "model": "claude", + "max_output_tokens": 100, + "input": [{ "role": "user", "content": "hi" }], + "tools": [ + { "type": "function", "name": "get_weather", "description": "d", "parameters": {"type": "object"} }, + { "type": "web_search" }, + { "type": "custom", "name": "apply_patch" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let tools = result["tools"].as_array().unwrap(); + assert_eq!(tools.len(), 2); + assert_eq!(tools[0]["name"], "get_weather"); + assert_eq!(tools[0]["input_schema"]["type"], "object"); + assert!(tools[0].get("parameters").is_none()); + assert_eq!(tools[1]["name"], "apply_patch"); + } + + #[test] + fn test_request_tool_choice_mapping() { + // A function tool must be present, else tool_choice is (correctly) dropped. + let base = |tc: Value| { + json!({ + "model": "c", "max_output_tokens": 100, + "input": [{ "role": "user", "content": "hi" }], + "tools": [{ "type": "function", "name": "x", "parameters": {"type": "object"} }], + "tool_choice": tc + }) + }; + assert_eq!( + responses_request_to_anthropic(base(json!("required")), 4096).unwrap()["tool_choice"], + json!({"type": "any"}) + ); + assert_eq!( + responses_request_to_anthropic(base(json!("auto")), 4096).unwrap()["tool_choice"], + json!({"type": "auto"}) + ); + assert_eq!( + responses_request_to_anthropic(base(json!({"type": "function", "name": "x"})), 4096) + .unwrap()["tool_choice"], + json!({"type": "tool", "name": "x"}) + ); + } + + #[test] + fn test_request_function_call_renests_into_assistant_tool_use() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "role": "assistant", "content": [{ "type": "output_text", "text": "Let me check" }] }, + { "type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"Tokyo\"}" }, + { "type": "function_call_output", "call_id": "call_1", "output": "sunny" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + // The assistant-first history is normalized: a synthetic user message is + // prepended, and the complete assistant tool turn remains intact. + assert_eq!(messages.len(), 3); + assert_eq!(messages[0]["role"], "user"); + assert_eq!(messages[1]["role"], "assistant"); + assert_eq!(messages[1]["content"][0]["type"], "text"); + assert_eq!(messages[1]["content"][1]["type"], "tool_use"); + assert_eq!(messages[1]["content"][1]["id"], "call_1"); + assert_eq!(messages[1]["content"][1]["input"]["city"], "Tokyo"); + } + + #[test] + fn test_request_function_call_outputs_merge_into_one_user_message() { + // Consecutive function_call_output items (each paired with a preceding + // function_call) merge into a single user message of tool_result blocks. + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" }, + { "type": "function_call", "call_id": "c2", "name": "t", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c1", "output": "A" }, + { "type": "function_call_output", "call_id": "c2", "output": "B" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + // Leading assistant history is normalized with a synthetic leading user. + let last = messages.last().unwrap(); + assert_eq!(last["role"], "user"); + let content = last["content"].as_array().unwrap(); + assert_eq!(content.len(), 2); + assert_eq!(content[0]["type"], "tool_result"); + assert_eq!(content[0]["tool_use_id"], "c1"); + assert_eq!(content[0]["content"], "A"); + assert_eq!(content[1]["tool_use_id"], "c2"); + } + + #[test] + fn test_request_unanswered_trailing_tool_use_is_dropped() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "role": "user", "content": "run it" }, + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "user"); + assert_eq!(messages[0]["content"][0]["text"], "run it"); + } + + #[test] + fn test_request_partial_parallel_tool_turn_is_dropped_as_a_unit() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "role": "user", "content": "run both" }, + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" }, + { "type": "function_call", "call_id": "c2", "name": "t", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c1", "output": "one" }, + { "role": "user", "content": [{ "type": "input_text", "text": "continue" }] } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + assert!(messages.iter().all(|message| { + message["content"].as_array().unwrap().iter().all(|block| { + !matches!( + block.get("type").and_then(Value::as_str), + Some("tool_use" | "tool_result") + ) + }) + })); + assert_eq!(messages.last().unwrap()["content"][0]["text"], "continue"); + } + + #[test] + fn test_request_tool_results_precede_user_text() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "role": "user", "content": "run it" }, + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" }, + { "role": "user", "content": [{ "type": "input_text", "text": "then explain" }] }, + { "type": "function_call_output", "call_id": "c1", "output": "ok" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let content = result["messages"].as_array().unwrap().last().unwrap()["content"] + .as_array() + .unwrap(); + assert_eq!(content[0]["type"], "tool_result"); + assert_eq!(content[0]["tool_use_id"], "c1"); + assert_eq!(content[1]["type"], "text"); + assert_eq!(content[1]["text"], "then explain"); + } + + #[test] + fn test_request_orphan_tool_result_dropped() { + // A function_call_output whose matching function_call was dropped (e.g. by + // compaction) becomes an orphan tool_result; it must be removed so Anthropic + // does not 400, while the rest of the turn is preserved. + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "type": "function_call_output", "call_id": "ghost", "output": "X" }, + { "role": "user", "content": [{ "type": "input_text", "text": "hello" }] } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "user"); + let content = messages[0]["content"].as_array().unwrap(); + // Only the text survives; the orphan tool_result is gone. + assert_eq!(content.len(), 1); + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[0]["text"], "hello"); + } + + #[test] + fn test_request_empty_text_blocks_dropped() { + // An empty/whitespace-only assistant text emitted alongside a tool_use must be + // filtered out (Anthropic 400s on empty text blocks), keeping the tool_use, and + // a user turn made up solely of empty text must not leave an empty message. + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "role": "user", "content": [{ "type": "input_text", "text": "hi" }] }, + { "role": "assistant", "content": [{ "type": "output_text", "text": "" }] }, + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c1", "output": "ok" }, + { "role": "user", "content": [{ "type": "input_text", "text": " " }] } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + // Empty assistant text is gone; no message carries an empty text block. + for msg in messages { + for block in msg["content"].as_array().unwrap() { + if block["type"] == "text" { + assert!(!block["text"].as_str().unwrap().trim().is_empty()); + } + } + assert!(!msg["content"].as_array().unwrap().is_empty()); + } + // The whitespace-only trailing user turn collapsed into the tool_result user message. + let last = messages.last().unwrap(); + assert_eq!(last["role"], "user"); + assert_eq!(last["content"][0]["type"], "tool_result"); + } + + #[test] + fn test_request_all_orphan_tool_results_error() { + // If the entire input is orphan tool_results, dropping them empties the + // message list and conversion errors (nothing valid to send to Anthropic). + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "type": "function_call_output", "call_id": "c1", "output": "A" }, + { "type": "function_call_output", "call_id": "c2", "output": "B" } + ] + }); + assert!(responses_request_to_anthropic(input, 4096).is_err()); + } + + #[test] + fn test_request_paired_tool_result_kept() { + // A tool_result whose function_call is present survives the orphan guard. + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c1", "output": "ok" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + let last = messages.last().unwrap(); + assert_eq!(last["role"], "user"); + assert_eq!(last["content"][0]["type"], "tool_result"); + assert_eq!(last["content"][0]["tool_use_id"], "c1"); + } + + #[test] + fn test_request_empty_arguments_parses_to_empty_object() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "" }, + { "type": "function_call_output", "call_id": "c1", "output": "ok" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + // function_call at the head → a synthetic user is prepended, tool_use is in the second assistant message. + assert_eq!(result["messages"][1]["content"][0]["input"], json!({})); + } + + #[test] + fn test_request_invalid_or_non_object_arguments_error() { + for arguments in ["{broken", "[1,2]"] { + let input = json!({ + "model":"c", + "input":[ + {"type":"function_call","call_id":"c1","name":"t","arguments":arguments}, + {"type":"function_call_output","call_id":"c1","output":"ok"} + ] + }); + assert!(matches!( + responses_request_to_anthropic(input, 4096), + Err(ProxyError::InvalidRequest(_)) + )); + } + } + + #[test] + fn test_request_drops_incomplete_tool_call_and_orphaned_output() { + let input = json!({ + "model":"c", + "input":[ + {"role":"user","content":[{"type":"input_text","text":"run it"}]}, + { + "type":"function_call", + "call_id":"c1", + "name":"exec", + "arguments":"{\"cmd\":", + "status":"incomplete" + }, + {"type":"function_call_output","call_id":"c1","output":"never ran"} + ] + }); + + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let serialized = result["messages"].to_string(); + assert!(!serialized.contains("tool_use")); + assert!(!serialized.contains("tool_result")); + assert!(serialized.contains("run it")); + } + + #[test] + fn test_request_image_data_url() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [{ + "role": "user", + "content": [ + { "type": "input_text", "text": "what?" }, + { "type": "input_image", "image_url": "data:image/png;base64,abc123" } + ] + }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let content = result["messages"][0]["content"].as_array().unwrap(); + assert_eq!(content[1]["type"], "image"); + assert_eq!(content[1]["source"]["type"], "base64"); + assert_eq!(content[1]["source"]["media_type"], "image/png"); + assert_eq!(content[1]["source"]["data"], "abc123"); + } + + #[test] + fn test_request_top_level_content_items() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "type": "input_text", "text": "what?" }, + { "type": "input_image", "image_url": "data:image/png;base64,abc123" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "user"); + let content = messages[0]["content"].as_array().unwrap(); + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[0]["text"], "what?"); + assert_eq!(content[1]["type"], "image"); + assert_eq!(content[1]["source"]["type"], "base64"); + assert_eq!(content[1]["source"]["data"], "abc123"); + } + + #[test] + fn test_request_image_http_url() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [{ + "role": "user", + "content": [{ "type": "input_image", "image_url": "https://x/y.png" }] + }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let block = &result["messages"][0]["content"][0]; + assert_eq!(block["type"], "image"); + assert_eq!(block["source"]["type"], "url"); + assert_eq!(block["source"]["url"], "https://x/y.png"); + } + + #[test] + fn test_request_effort_to_thinking_and_drops_temperature() { + let input = json!({ + "model": "c", + // Well above 2× the high-effort budget so the output-headroom cap + // (max_tokens/2) does not clamp it — this test covers effort mapping. + "max_output_tokens": 40000, + "temperature": 0.7, + "top_p": 0.9, + "reasoning": { "effort": "high" }, + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["thinking"]["type"], "enabled"); + assert_eq!(result["thinking"]["budget_tokens"], 16384); + assert!(result.get("temperature").is_none()); + assert!(result.get("top_p").is_none()); + } + + #[test] + fn test_adaptive_thinking_respects_model_defaults() { + let request = |model: &str| { + json!({ + "model": model, + "max_output_tokens": 4096, + "input": [{"role": "user", "content": "hi"}] + }) + }; + let sonnet = responses_request_to_anthropic(request("claude-sonnet-5"), 4096).unwrap(); + assert_eq!(sonnet["thinking"]["type"], "adaptive"); + + let opus = responses_request_to_anthropic(request("claude-opus-4.8"), 4096).unwrap(); + assert!(opus.get("thinking").is_none()); + } + + #[test] + fn test_request_unknown_effort_keeps_sampling_params() { + // An unrecognized effort should not enable thinking, nor swallow temperature/top_p. + let input = json!({ + "model": "c", + "max_output_tokens": 20000, + "temperature": 0.7, + "top_p": 0.9, + "reasoning": { "effort": "turbo" }, + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert!(result.get("thinking").is_none()); + assert_eq!(result["temperature"], 0.7); + assert_eq!(result["top_p"], 0.9); + } + + #[test] + fn test_request_tool_history_disables_thinking() { + // When tool history is present (function_call_output), do not inject thinking + // even if effort is set, and fall back to passing temperature/top_p through, + // to avoid the Anthropic 400 caused by a missing thinking block. + let input = json!({ + "model": "c", + "max_output_tokens": 20000, + "temperature": 0.5, + "reasoning": { "effort": "high" }, + "input": [ + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c1", "output": "ok" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert!(result.get("thinking").is_none()); + assert_eq!(result["temperature"], 0.5); + } + + #[test] + fn test_older_signed_turn_does_not_enable_thinking_for_unsigned_tool_call() { + let encrypted = encode_anthropic_thinking_block(&json!({ + "type": "thinking", + "thinking": "old reasoning", + "signature": "sig_old" + })) + .unwrap(); + let input = json!({ + "model": "claude-sonnet-5", + "max_output_tokens": 4096, + "reasoning": { "effort": "high" }, + "input": [ + { "role": "user", "content": "first question" }, + { "type": "reasoning", "encrypted_content": encrypted }, + { "role": "assistant", "content": [{ "type": "output_text", "text": "first answer" }] }, + { "role": "user", "content": "call the tool" }, + { "type": "function_call", "call_id": "c2", "name": "Read", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c2", "output": "ok" } + ] + }); + + let result = responses_request_to_anthropic(input, 4096).unwrap(); + + assert_eq!(result["thinking"]["type"], "disabled"); + let messages = result["messages"].as_array().unwrap(); + let paired_assistant = &messages[messages.len() - 2]; + assert_eq!(paired_assistant["role"], "assistant"); + assert_eq!(paired_assistant["content"][0]["type"], "tool_use"); + } + + #[test] + fn test_thinking_requires_all_parallel_tool_results_to_match_paired_turn() { + let paired = json!([ + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "reason", "signature": "sig"}, + {"type": "tool_use", "id": "c1", "name": "a", "input": {}}, + {"type": "tool_use", "id": "c2", "name": "b", "input": {}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "c1", "content": "one"}, + {"type": "tool_result", "tool_use_id": "c2", "content": "two"} + ]} + ]); + assert!(trailing_turn_supports_thinking(paired.as_array().unwrap())); + + let mismatched = json!([ + paired[0].clone(), + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "c1", "content": "one"}, + {"type": "tool_result", "tool_use_id": "c3", "content": "three"} + ]} + ]); + assert!(!trailing_turn_supports_thinking( + mismatched.as_array().unwrap() + )); + } + + #[test] + fn test_request_completed_tool_round_reenables_thinking() { + // A *completed* tool round (assistant answered after the tool_result) followed + // by a fresh user question: the trailing turn is text-only, so thinking must be + // re-enabled — unlike a whole-history scan, which would stay off forever. + let input = json!({ + "model": "c", + "max_output_tokens": 20000, + "reasoning": { "effort": "high" }, + "input": [ + { "role": "user", "content": [{ "type": "input_text", "text": "hi" }] }, + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c1", "output": "ok" }, + { "role": "assistant", "content": [{ "type": "output_text", "text": "done" }] }, + { "role": "user", "content": [{ "type": "input_text", "text": "next question" }] } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + // The last message is a text-only user turn → not mid tool-cycle → thinking on. + let messages = result["messages"].as_array().unwrap(); + let last = messages.last().unwrap(); + assert_eq!(last["role"], "user"); + assert_eq!(last["content"][0]["type"], "text"); + assert_eq!(result["thinking"]["type"], "enabled"); + } + + #[test] + fn test_request_custom_tool_survives_with_required_choice() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [{ "role": "user", "content": "hi" }], + "tools": [ + { "type": "web_search" }, + { "type": "custom", "name": "apply_patch" } + ], + "tool_choice": "required" + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["tools"].as_array().unwrap().len(), 1); + assert_eq!(result["tools"][0]["name"], "apply_patch"); + assert_eq!(result["tool_choice"], json!({ "type": "any" })); + } + + #[test] + fn test_request_forced_tool_choice_disables_thinking_instead_of_downgrading() { + let input = json!({ + "model": "c", + "max_output_tokens": 20000, + "reasoning": { "effort": "high" }, + "tools": [{ "type": "function", "name": "x", "parameters": {"type": "object"} }], + "tool_choice": "required", + "input": [ + { "role": "user", "content": [{ "type": "input_text", "text": "hi" }] } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["thinking"]["type"], "disabled"); + assert_eq!(result["tool_choice"], json!({ "type": "any" })); + } + + #[test] + fn test_request_forced_tool_choice_kept_without_thinking() { + // With no effort (thinking off), a forced tool_choice is kept as-is. + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "tools": [{ "type": "function", "name": "x", "parameters": {"type": "object"} }], + "tool_choice": "required", + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["tool_choice"], json!({ "type": "any" })); + } + + #[test] + fn test_request_small_max_tokens_disables_thinking() { + // The chosen effort budget is clamped below max_tokens; after clamping it is + // < 1024 → disable thinking, fall back to sampling, and do not raise the + // caller's max_tokens (to avoid exceeding the model's output ceiling and 400). + let input = json!({ + "model": "c", + "max_output_tokens": 1000, + "temperature": 0.7, + "reasoning": { "effort": "high" }, + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert!(result.get("thinking").is_none()); + assert_eq!(result["max_tokens"], 1000); + assert_eq!(result["temperature"], 0.7); + } + + #[test] + fn test_request_thinking_budget_clamped_below_max_tokens() { + // The chosen effort budget exceeds half of max_tokens, so it is capped at + // max_tokens/2 (reserving the other half for the visible answer) while staying + // >= 1024, so thinking stays enabled. + let input = json!({ + "model": "c", + "max_output_tokens": 5000, + "reasoning": { "effort": "high" }, // budget 16384 + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["thinking"]["type"], "enabled"); + assert_eq!(result["thinking"]["budget_tokens"], 2500); + assert_eq!(result["max_tokens"], 5000); + } + + #[test] + fn test_request_default_max_tokens_leaves_output_headroom() { + // Regression: on the no-max_output_tokens fallback path, a large derived thinking + // budget must not consume nearly all of the default max_tokens. With default 8192 + // and high effort (16384), the budget is capped at 8192/2 = 4096, leaving 4096 for + // the visible answer (previously it clamped to 8191, leaving ~1 output token). + let input = json!({ + "model": "c", + "reasoning": { "effort": "high" }, + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 8192).unwrap(); + assert_eq!(result["thinking"]["type"], "enabled"); + assert_eq!(result["thinking"]["budget_tokens"], 4096); + assert_eq!(result["max_tokens"], 8192); + assert!( + result["max_tokens"].as_u64().unwrap() + - result["thinking"]["budget_tokens"].as_u64().unwrap() + >= 4096, + "at least half of max_tokens must remain for the visible answer" + ); + } + + #[test] + fn test_request_unpaired_tool_turn_is_dropped_before_thinking_gate() { + // A resumed/compacted history ending in an unpaired assistant tool_use is + // removed, leaving the original user prompt as a fresh thinking turn. + let input = json!({ + "model": "c", + "max_output_tokens": 40000, + "reasoning": { "effort": "high" }, + "input": [ + { "role": "user", "content": [{ "type": "input_text", "text": "run it" }] }, + { "type": "function_call", "call_id": "c1", "name": "sh", "arguments": "{}" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "user"); + assert_eq!(result["thinking"]["type"], "enabled"); + } + + #[test] + fn test_request_reasoning_item_dropped() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "type": "reasoning", "id": "rs_1", "encrypted_content": "xxx" }, + { "role": "user", "content": [{ "type": "input_text", "text": "hi" }] } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "user"); + } + + #[test] + fn test_request_drops_openai_only_fields() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "store": false, + "include": ["reasoning.encrypted_content"], + "service_tier": "priority", + "parallel_tool_calls": true, + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert!(result.get("store").is_none()); + assert!(result.get("include").is_none()); + assert!(result.get("service_tier").is_none()); + assert!(result.get("parallel_tool_calls").is_none()); + } + + // ==================== Response: Anthropic → Responses ==================== + + #[test] + fn test_response_text_end_turn() { + let input = json!({ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude", + "content": [{ "type": "text", "text": "Hello!" }], + "stop_reason": "end_turn", + "usage": { "input_tokens": 10, "output_tokens": 5 } + }); + let result = anthropic_response_to_responses(input).unwrap(); + assert_eq!(result["id"], "resp_msg_1"); + assert_eq!(result["status"], "completed"); + assert_eq!(result["output"][0]["type"], "message"); + assert_eq!(result["output"][0]["content"][0]["type"], "output_text"); + assert_eq!(result["output"][0]["content"][0]["text"], "Hello!"); + assert_eq!(result["usage"]["input_tokens"], 10); + assert_eq!(result["usage"]["output_tokens"], 5); + assert_eq!(result["usage"]["total_tokens"], 15); + } + + #[test] + fn test_response_tool_use() { + let input = json!({ + "id": "msg_1", + "content": [{ + "type": "tool_use", + "id": "call_1", + "name": "get_weather", + "input": { "city": "Tokyo" } + }], + "stop_reason": "tool_use", + "usage": { "input_tokens": 10, "output_tokens": 15 } + }); + let result = anthropic_response_to_responses(input).unwrap(); + assert_eq!(result["status"], "completed"); + assert_eq!(result["output"][0]["type"], "function_call"); + assert_eq!(result["output"][0]["call_id"], "call_1"); + assert_eq!(result["output"][0]["name"], "get_weather"); + assert_eq!(result["output"][0]["arguments"], "{\"city\":\"Tokyo\"}"); + } + + #[test] + fn test_response_thinking_becomes_reasoning() { + let input = json!({ + "id": "msg_1", + "content": [ + { "type": "thinking", "thinking": "Let me think", "signature": "sig" }, + { "type": "text", "text": "answer" } + ], + "stop_reason": "end_turn", + "usage": { "input_tokens": 1, "output_tokens": 2 } + }); + let result = anthropic_response_to_responses(input).unwrap(); + assert_eq!(result["output"][0]["type"], "reasoning"); + assert_eq!(result["output"][0]["summary"][0]["text"], "Let me think"); + assert_eq!(result["output"][1]["type"], "message"); + } + + #[test] + fn test_unsigned_thinking_is_not_replayed_as_encrypted_reasoning() { + let input = json!({ + "id":"msg_unsigned", + "content":[ + {"type":"thinking","thinking":"unsigned"}, + {"type":"text","text":"answer"} + ], + "stop_reason":"end_turn" + }); + + let result = anthropic_response_to_responses(input).unwrap(); + assert_eq!(result["output"].as_array().unwrap().len(), 1); + assert_eq!(result["output"][0]["type"], "message"); + } + + #[test] + fn test_response_max_tokens_incomplete() { + let input = json!({ + "id": "msg_1", + "content": [{ "type": "text", "text": "partial" }], + "stop_reason": "max_tokens", + "usage": { "input_tokens": 1, "output_tokens": 2 } + }); + let result = anthropic_response_to_responses(input).unwrap(); + assert_eq!(result["status"], "incomplete"); + assert_eq!(result["incomplete_details"]["reason"], "max_output_tokens"); + } + + #[test] + fn test_response_usage_cache_no_double_count() { + let input = json!({ + "id": "msg_1", + "content": [{ "type": "text", "text": "x" }], + "stop_reason": "end_turn", + "usage": { + "input_tokens": 20, + "output_tokens": 5, + "output_tokens_details": {"thinking_tokens": 3}, + "cache_read_input_tokens": 60, + "cache_creation_input_tokens": 20 + } + }); + let result = anthropic_response_to_responses(input).unwrap(); + // Responses input_tokens is the inclusive total: fresh + read + write. + assert_eq!(result["usage"]["input_tokens"], 100); + assert_eq!(result["usage"]["output_tokens"], 5); + assert_eq!( + result["usage"]["output_tokens_details"]["reasoning_tokens"], + 3 + ); + // total includes input total + output exactly once. + assert_eq!(result["usage"]["total_tokens"], 105); + assert_eq!(result["usage"]["input_tokens_details"]["cached_tokens"], 60); + assert_eq!( + result["usage"]["input_tokens_details"]["cache_write_tokens"], + 20 + ); + // Aggregate cache creation is exposed for downstream billing attribution (counted only once). + assert_eq!(result["usage"]["cache_creation_input_tokens"], 20); + } + + #[test] + fn test_response_read_tool_drops_empty_pages() { + let input = json!({ + "id": "msg_1", + "content": [{ + "type": "tool_use", + "id": "call_1", + "name": "Read", + "input": { "file_path": "/tmp/x", "pages": "" } + }], + "stop_reason": "tool_use" + }); + let result = anthropic_response_to_responses(input).unwrap(); + let args = result["output"][0]["arguments"].as_str().unwrap(); + assert!(args.contains("/tmp/x")); + assert!(!args.contains("pages")); + } + + #[test] + fn test_response_refusal_is_incomplete_content_filter() { + let input = json!({ + "id": "msg_1", + "content": [{ "type": "text", "text": "" }], + "stop_reason": "refusal", + "usage": { "input_tokens": 1, "output_tokens": 0 } + }); + let result = anthropic_response_to_responses(input).unwrap(); + assert_eq!(result["status"], "incomplete"); + assert_eq!(result["incomplete_details"]["reason"], "content_filter"); + } + + #[test] + fn test_signed_thinking_round_trips_through_responses_tool_loop() { + let converted = anthropic_response_to_responses(json!({ + "id": "msg_signed", + "model": "claude-sonnet-5", + "stop_reason": "tool_use", + "content": [ + {"type": "thinking", "thinking": "check", "signature": "sig_123"}, + {"type": "tool_use", "id": "call_1", "name": "Read", "input": {"path": "/tmp/a"}} + ] + })) + .unwrap(); + let reasoning = converted["output"][0].clone(); + assert!(reasoning["encrypted_content"] + .as_str() + .unwrap() + .starts_with(ANTHROPIC_THINKING_ENCRYPTED_PREFIX)); + + let replay = responses_request_to_anthropic( + json!({ + "model": "claude-sonnet-5", + "max_output_tokens": 4096, + "reasoning": {"effort": "high"}, + "input": [ + reasoning, + converted["output"][1].clone(), + {"type": "function_call_output", "call_id": "call_1", "output": "ok"} + ] + }), + 4096, + ) + .unwrap(); + assert_eq!(replay["thinking"]["type"], "adaptive"); + assert_eq!(replay["messages"][1]["content"][0]["type"], "thinking"); + assert_eq!(replay["messages"][1]["content"][0]["signature"], "sig_123"); + } + + #[test] + fn test_redacted_thinking_is_preserved_without_visible_summary() { + let response = anthropic_response_to_responses(json!({ + "id": "msg_redacted", + "content": [{"type": "redacted_thinking", "data": "opaque"}] + })) + .unwrap(); + assert_eq!(response["output"][0]["summary"], json!([])); + assert!(response["output"][0]["encrypted_content"].is_string()); + } + + #[test] + fn test_namespace_tool_response_restores_namespace() { + let context = build_codex_tool_context_from_request(&json!({ + "tools": [{ + "type": "namespace", + "name": "mcp_files", + "tools": [{"type": "function", "name": "read", "parameters": {"type": "object"}}] + }] + })); + let response = anthropic_response_to_responses_with_context( + json!({ + "id": "msg_ns", + "content": [{"type": "tool_use", "id": "call_1", "name": "mcp_files__read", "input": {}}] + }), + &context, + ) + .unwrap(); + assert_eq!(response["output"][0]["type"], "function_call"); + assert_eq!(response["output"][0]["name"], "read"); + assert_eq!(response["output"][0]["namespace"], "mcp_files"); + } + + #[test] + fn test_success_status_error_envelope_is_not_completed() { + let error = anthropic_response_to_responses(json!({ + "type": "error", + "error": {"type": "overloaded_error", "message": "busy"} + })) + .unwrap_err(); + assert!(error.to_string().contains("overloaded_error")); + } + + #[test] + fn test_context_window_stop_reason_is_incomplete() { + let response = anthropic_response_to_responses(json!({ + "id": "msg_ctx", + "stop_reason": "model_context_window_exceeded", + "content": [{"type": "text", "text": "partial"}] + })) + .unwrap(); + assert_eq!(response["status"], "incomplete"); + assert_eq!( + response["incomplete_details"]["reason"], + "max_output_tokens" + ); + } + + #[test] + fn test_request_parallel_tool_calls_false_disables_parallel_use() { + let response = responses_request_to_anthropic( + json!({ + "model": "c", + "input": [{"role": "user", "content": "hi"}], + "tools": [{"type": "function", "name": "x", "parameters": {"type": "object"}}], + "parallel_tool_calls": false + }), + 4096, + ) + .unwrap(); + assert_eq!(response["tool_choice"]["type"], "auto"); + assert_eq!(response["tool_choice"]["disable_parallel_tool_use"], true); + } + + #[test] + fn test_request_trims_trailing_assistant_whitespace() { + let response = responses_request_to_anthropic( + json!({ + "model": "c", + "input": [ + {"role": "user", "content": "continue"}, + {"role": "assistant", "content": [{"type": "output_text", "text": "prefix \n"}]} + ] + }), + 4096, + ) + .unwrap(); + assert_eq!(response["messages"][1]["content"][0]["text"], "prefix"); + } + + #[test] + fn test_structured_tool_output_preserves_text_and_image_blocks() { + 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": "output_text", "text": "result"}, + {"type": "input_image", "image_url": "data:image/png;base64,abc"} + ]} + ] + }), + 4096, + ) + .unwrap(); + let content = &response["messages"][2]["content"][0]["content"]; + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[1]["type"], "image"); + } + + #[test] + fn test_structured_tool_output_restores_error_file_and_unknown_parts() { + 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":"input_text","text":TOOL_RESULT_ERROR_MARKER}, + {"type":"input_text","text":"failed"}, + {"type":"input_file","file_url":"https://example.com/log.pdf","filename":"log.pdf"}, + {"type":"future_part","payload":{"x":1}} + ]} + ] + }), + 4096, + ) + .unwrap(); + + let tool_result = &response["messages"][2]["content"][0]; + assert_eq!(tool_result["is_error"], true); + assert_eq!( + tool_result["content"][0], + json!({"type":"text","text":"failed"}) + ); + assert_eq!(tool_result["content"][1]["type"], "document"); + assert_eq!(tool_result["content"][1]["source"]["type"], "url"); + assert_eq!(tool_result["content"][2]["type"], "text"); + assert!(tool_result["content"][2]["text"] + .as_str() + .unwrap() + .contains("future_part")); + } + + // ==================== Request normalization: non-empty & first is user ==================== + + #[test] + fn test_request_empty_messages_errors() { + // input is entirely reasoning (dropped) → messages is empty → error explicitly rather than leaving it to the upstream 400. + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "instructions": "sys", + "input": [ + { "type": "reasoning", "id": "rs_1", "encrypted_content": "xxx" } + ] + }); + assert!(responses_request_to_anthropic(input, 4096).is_err()); + } + + #[test] + fn test_request_assistant_first_gets_leading_user() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "role": "assistant", "content": [{ "type": "output_text", "text": "hi" }] } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + assert_eq!(messages[0]["role"], "user"); + assert_eq!(messages[1]["role"], "assistant"); + } + + // ==================== tools / tool_choice edge cases ==================== + + #[test] + fn test_request_tool_without_description_or_params() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [{ "role": "user", "content": "hi" }], + "tools": [ { "type": "function", "name": "noop" } ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let tool = &result["tools"][0]; + // Do not emit an explicit null description; input_schema falls back to a valid object schema. + assert!(tool.get("description").is_none()); + assert_eq!(tool["input_schema"]["type"], "object"); + } + + #[test] + fn test_request_unknown_object_tool_choice_degrades_to_auto() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [{ "role": "user", "content": "hi" }], + "tools": [{ "type": "function", "name": "x", "parameters": {"type": "object"} }], + "tool_choice": { "type": "allowed_tools", "tools": [] } + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["tool_choice"], json!({ "type": "auto" })); + } + + // ==================== SSE aggregation fallback ==================== + + #[test] + fn test_anthropic_sse_aggregation_text_and_usage() { + let sse = "event: message_start\n\ +data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\n\ +event: content_block_start\n\ +data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n\ +event: content_block_delta\n\ +data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n\ +event: content_block_delta\n\ +data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n\n\ +event: content_block_stop\n\ +data: {\"type\":\"content_block_stop\",\"index\":0}\n\n\ +event: message_delta\n\ +data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":7}}\n\n\ +event: message_stop\n\ +data: {\"type\":\"message_stop\"}\n\n"; + let msg = anthropic_sse_to_message_value(sse).unwrap(); + assert_eq!(msg["content"][0]["type"], "text"); + assert_eq!(msg["content"][0]["text"], "Hello world"); + assert_eq!(msg["stop_reason"], "end_turn"); + assert_eq!(msg["usage"]["input_tokens"], 10); + assert_eq!(msg["usage"]["output_tokens"], 7); + + // The aggregated result can be converted directly into Responses. + let resp = anthropic_response_to_responses(msg).unwrap(); + assert_eq!(resp["status"], "completed"); + assert_eq!(resp["output"][0]["content"][0]["text"], "Hello world"); + } + + #[test] + fn test_anthropic_sse_aggregation_tool_use_partial_json() { + let sse = "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"c\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n\ +data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{}}}\n\n\ +data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\"}}\n\n\ +data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"Tokyo\\\"}\"}}\n\n\ +data: {\"type\":\"content_block_stop\",\"index\":0}\n\n\ +data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":3}}\n\n"; + let msg = anthropic_sse_to_message_value(sse).unwrap(); + assert_eq!(msg["content"][0]["type"], "tool_use"); + assert_eq!(msg["content"][0]["name"], "get_weather"); + assert_eq!(msg["content"][0]["input"]["city"], "Tokyo"); + assert_eq!(msg["stop_reason"], "tool_use"); + } + + #[test] + fn test_anthropic_sse_aggregation_tool_use_input_only_in_start() { + // Parity guard with the live streaming emitter's + // `test_tool_use_input_only_in_start_event`: a gateway that carries the full tool + // `input` on content_block_start and emits NO input_json_delta must still resolve + // the same arguments (the empty-accum fallback keeps the start-carried input). + let sse = "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"c\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n\ +data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Tokyo\"}}}\n\n\ +data: {\"type\":\"content_block_stop\",\"index\":0}\n\n\ +data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":3}}\n\n"; + let msg = anthropic_sse_to_message_value(sse).unwrap(); + assert_eq!(msg["content"][0]["type"], "tool_use"); + assert_eq!(msg["content"][0]["name"], "get_weather"); + // Identical to the deltas-only case above — neither path may drop start input. + assert_eq!(msg["content"][0]["input"]["city"], "Tokyo"); + assert_eq!(msg["stop_reason"], "tool_use"); + } + + #[test] + fn test_anthropic_sse_aggregation_missing_message_start_errors() { + let sse = "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n"; + assert!(anthropic_sse_to_message_value(sse).is_err()); + } + + #[test] + fn test_anthropic_sse_aggregation_error_event_errors() { + let sse = "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"overloaded\"}}\n\n"; + assert!(anthropic_sse_to_message_value(sse).is_err()); + } + + #[test] + fn test_anthropic_sse_aggregation_tolerates_missing_trailing_blank_line() { + // The last event missing a trailing blank line (truncated stream) should still be processed. + let sse = "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"c\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n\ +data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"hi\"}}\n\n\ +data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":2}}"; + let msg = anthropic_sse_to_message_value(sse).unwrap(); + assert_eq!(msg["stop_reason"], "end_turn"); + assert_eq!(msg["usage"]["output_tokens"], 2); + } + + #[test] + fn test_anthropic_sse_aggregation_truncated_output_is_incomplete() { + let sse = "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n\ +data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"partial\"}}\n\n"; + let message = anthropic_sse_to_message_value(sse).unwrap(); + let response = anthropic_response_to_responses(message).unwrap(); + assert_eq!(response["status"], "incomplete"); + assert_eq!( + response["incomplete_details"]["reason"], + "max_output_tokens" + ); + } + + #[test] + fn test_anthropic_sse_aggregation_truncated_without_output_errors() { + let sse = + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n"; + assert!(anthropic_sse_to_message_value(sse).is_err()); + } +} diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index b8bd0e1c8..d3acebce2 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -80,7 +80,11 @@ impl CodexToolContext { .is_some_and(|spec| matches!(&spec.kind, CodexToolKind::Custom)) } - fn chat_name_for_response_function(&self, name: &str, namespace: Option<&str>) -> String { + pub(crate) fn chat_name_for_response_function( + &self, + name: &str, + namespace: Option<&str>, + ) -> String { if let Some(namespace) = namespace.filter(|value| !value.is_empty()) { if let Some(chat_name) = self .namespace_name_to_chat_name @@ -1092,6 +1096,26 @@ fn serialize_tool_definition_for_description(tool: &Value) -> String { canonical_json_string(tool) } +/// Normalize a function's `parameters` JSON Schema so `type` is always `"object"`. +/// +/// Some Responses tools carry `parameters: null` or `parameters: {"type": null}`, +/// but OpenAI Chat Completions strictly requires `{"type": "object", "properties": {...}}`. +fn normalize_function_parameters(params: Option<&Value>) -> Value { + let mut params = match params { + Some(Value::Object(obj)) => Value::Object(obj.clone()), + _ => json!({"type": "object", "properties": {}}), + }; + if let Some(obj) = params.as_object_mut() { + match obj.get("type").and_then(|v| v.as_str()) { + Some("object") => {} + _ => { + obj.insert("type".to_string(), json!("object")); + } + } + } + params +} + fn responses_function_tool_to_chat_tool(tool: &Value, chat_name: &str) -> Option { if tool.get("type").and_then(|v| v.as_str()) != Some("function") { return None; @@ -1106,6 +1130,14 @@ fn responses_function_tool_to_chat_tool(tool: &Value, chat_name: &str) -> Option .get_mut("function") .and_then(|value| value.as_object_mut()) { + // Ensure parameters.type is "object" for strict OpenAI-compatible providers + if let Some(params) = obj.get("parameters") { + let normalized = normalize_function_parameters(Some(params)); + if normalized != *params { + obj.insert("parameters".to_string(), normalized); + } + } + obj.insert("name".to_string(), json!(chat_name)); if let Some(strict) = tool.get("strict").cloned() { obj.entry("strict".to_string()).or_insert(strict); @@ -1117,7 +1149,7 @@ fn responses_function_tool_to_chat_tool(tool: &Value, chat_name: &str) -> Option let mut function = json!({ "name": chat_name, "description": tool.get("description").cloned().unwrap_or(Value::Null), - "parameters": tool.get("parameters").cloned().unwrap_or_else(|| json!({})) + "parameters": normalize_function_parameters(tool.get("parameters")) }); if let Some(strict) = tool.get("strict") { function["strict"] = strict.clone(); @@ -1625,12 +1657,26 @@ pub(crate) fn chat_usage_to_responses_usage(usage: Option<&Value>) -> Value { "total_tokens": total_tokens }); - if let Some(cached) = usage + let cached = usage .pointer("/prompt_tokens_details/cached_tokens") .or_else(|| usage.pointer("/input_tokens_details/cached_tokens")) .and_then(|v| v.as_u64()) - { - result["input_tokens_details"] = json!({ "cached_tokens": cached }); + .unwrap_or(0); + let cache_write = usage + .pointer("/prompt_tokens_details/cache_write_tokens") + .or_else(|| usage.pointer("/input_tokens_details/cache_write_tokens")) + .and_then(|v| v.as_u64()) + .or_else(|| { + usage + .get("cache_creation_input_tokens") + .and_then(|v| v.as_u64()) + }) + .unwrap_or(0); + if cached > 0 || cache_write > 0 { + result["input_tokens_details"] = json!({ + "cached_tokens": cached, + "cache_write_tokens": cache_write + }); } if let Some(details) = usage @@ -1649,8 +1695,8 @@ pub(crate) fn chat_usage_to_responses_usage(usage: Option<&Value>) -> Value { if let Some(cache_read) = usage.get("cache_read_input_tokens") { result["cache_read_input_tokens"] = cache_read.clone(); } - if let Some(cache_creation) = usage.get("cache_creation_input_tokens") { - result["cache_creation_input_tokens"] = cache_creation.clone(); + if cache_write > 0 { + result["cache_creation_input_tokens"] = json!(cache_write); } result @@ -2731,7 +2777,7 @@ mod tests { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15, - "prompt_tokens_details": {"cached_tokens": 3} + "prompt_tokens_details": {"cached_tokens": 3, "cache_write_tokens": 2} } }); @@ -2755,6 +2801,10 @@ mod tests { assert_eq!(result["usage"]["input_tokens"], 10); assert_eq!(result["usage"]["output_tokens"], 5); assert_eq!(result["usage"]["input_tokens_details"]["cached_tokens"], 3); + assert_eq!( + result["usage"]["input_tokens_details"]["cache_write_tokens"], + 2 + ); } #[test] diff --git a/src-tauri/src/proxy/providers/transform_responses.rs b/src-tauri/src/proxy/providers/transform_responses.rs index d7379cd6e..916a0dbf3 100644 --- a/src-tauri/src/proxy/providers/transform_responses.rs +++ b/src-tauri/src/proxy/providers/transform_responses.rs @@ -11,6 +11,134 @@ use crate::proxy::{error::ProxyError, json_canonical::canonical_json_string}; use serde_json::{json, Value}; +use super::reasoning_bridge::{ + anthropic_block_from_openai_reasoning_item, openai_reasoning_item_from_anthropic_block, +}; + +pub(crate) const TOOL_RESULT_ERROR_MARKER: &str = "[cc-switch:tool-result-error]"; + +fn anthropic_image_to_responses_part(block: &Value) -> Option { + let source = block.get("source")?; + match source.get("type").and_then(Value::as_str) { + Some("url") => source + .get("url") + .and_then(Value::as_str) + .filter(|url| url.starts_with("http://") || url.starts_with("https://")) + .map(|url| json!({"type":"input_image","image_url":url})), + Some("base64") | None => { + let data = source.get("data").and_then(Value::as_str)?; + if data.is_empty() { + return None; + } + let media_type = source + .get("media_type") + .and_then(Value::as_str) + .unwrap_or("image/png"); + Some(json!({ + "type":"input_image", + "image_url":format!("data:{media_type};base64,{data}") + })) + } + _ => None, + } +} + +fn anthropic_document_to_responses_part(block: &Value) -> Option { + let source = block.get("source")?; + let filename = block + .get("title") + .or_else(|| block.get("filename")) + .and_then(Value::as_str) + .unwrap_or("document.pdf"); + match source.get("type").and_then(Value::as_str) { + Some("url") => source + .get("url") + .and_then(Value::as_str) + .filter(|url| url.starts_with("http://") || url.starts_with("https://")) + .map(|url| json!({"type":"input_file","file_url":url,"filename":filename})), + Some("base64") => { + let data = source.get("data").and_then(Value::as_str)?; + if data.is_empty() { + return None; + } + let media_type = source + .get("media_type") + .and_then(Value::as_str) + .unwrap_or("application/pdf"); + Some(json!({ + "type":"input_file", + "file_data":format!("data:{media_type};base64,{data}"), + "filename":filename + })) + } + _ => None, + } +} + +fn anthropic_tool_result_to_responses_output(block: &Value) -> Value { + let is_error = block.get("is_error").and_then(Value::as_bool) == Some(true); + let content = block.get("content"); + + if !is_error { + if let Some(Value::String(text)) = content { + return json!(text); + } + } + + let mut output = Vec::new(); + if is_error { + output.push(json!({"type":"input_text","text":TOOL_RESULT_ERROR_MARKER})); + } + + match content { + Some(Value::String(text)) => { + output.push(json!({"type":"input_text","text":text})); + } + Some(Value::Array(blocks)) => { + for part in blocks { + match part.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = part.get("text").and_then(Value::as_str) { + output.push(json!({"type":"input_text","text":text})); + } + } + Some("image") => { + if let Some(image) = anthropic_image_to_responses_part(part) { + output.push(image); + } else { + output.push(json!({ + "type":"input_text", + "text":canonical_json_string(part) + })); + } + } + Some("document") => { + if let Some(file) = anthropic_document_to_responses_part(part) { + output.push(file); + } else { + output.push(json!({ + "type":"input_text", + "text":canonical_json_string(part) + })); + } + } + _ => output.push(json!({ + "type":"input_text", + "text":canonical_json_string(part) + })), + } + } + } + Some(value) => output.push(json!({ + "type":"input_text", + "text":canonical_json_string(value) + })), + None => {} + } + + Value::Array(output) +} + pub(crate) fn sanitize_anthropic_tool_use_input(name: &str, input: Value) -> Value { if name != "Read" { return input; @@ -247,6 +375,37 @@ pub(crate) fn map_responses_stop_reason( }) } +fn responses_error_message(body: &Value, fallback: &str) -> String { + body.pointer("/error/message") + .and_then(Value::as_str) + .or_else(|| body.get("message").and_then(Value::as_str)) + .or_else(|| body.get("error").and_then(Value::as_str)) + .filter(|message| !message.trim().is_empty()) + .unwrap_or(fallback) + .to_string() +} + +fn validate_responses_terminal_status(body: &Value) -> Result<(), ProxyError> { + let status = body.get("status").and_then(Value::as_str); + let has_error = body.get("error").is_some_and(|error| !error.is_null()); + + match status { + Some("failed") => Err(ProxyError::TransformError(format!( + "Responses upstream failed: {}", + responses_error_message(body, "response generation failed") + ))), + Some("cancelled") => Err(ProxyError::TransformError(format!( + "Responses upstream cancelled the response: {}", + responses_error_message(body, "response generation was cancelled") + ))), + _ if has_error => Err(ProxyError::TransformError(format!( + "Responses upstream returned an error envelope: {}", + responses_error_message(body, "unknown upstream error") + ))), + _ => Ok(()), + } +} + /// Build Anthropic-style usage JSON from Responses API usage, including cache tokens. /// /// **Robustness Features**: @@ -259,10 +418,11 @@ pub(crate) fn map_responses_stop_reason( /// 1. input_tokens: Anthropic `input_tokens` → OpenAI `prompt_tokens` → default 0 /// 2. output_tokens: Anthropic `output_tokens` → OpenAI `completion_tokens` → default 0 /// 3. cache_read_input_tokens: Direct field → nested input_tokens_details.cached_tokens → prompt_tokens_details.cached_tokens -/// 4. cache_creation_input_tokens: Direct field only +/// 4. cache_creation_input_tokens: Direct field → nested +/// input_tokens_details.cache_write_tokens → prompt_tokens_details.cache_write_tokens /// /// **Cache Token Priority Order**: -/// 1. OpenAI nested details (`input_tokens_details.cached_tokens`, `prompt_tokens_details.cached_tokens`) as initial value +/// 1. OpenAI nested details (`cached_tokens`, `cache_write_tokens`) as initial values /// 2. Direct Anthropic-style fields (`cache_read_input_tokens`, `cache_creation_input_tokens`) override if present /// /// **Logging**: @@ -345,6 +505,19 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val result["cache_read_input_tokens"] = json!(cached); } } + // GPT-5.6+ reports cache writes in the nested OpenAI token-details object. + // Treat writes as Anthropic cache creation so the downstream client and + // billing layer can distinguish them from fresh input. + let nested_cache_write = u + .pointer("/input_tokens_details/cache_write_tokens") + .and_then(|v| v.as_u64()) + .or_else(|| { + u.pointer("/prompt_tokens_details/cache_write_tokens") + .and_then(|v| v.as_u64()) + }); + if let Some(cache_write) = nested_cache_write { + result["cache_creation_input_tokens"] = json!(cache_write); + } // Step 2: Direct Anthropic-style fields override (authoritative if present) // These preserve cache tokens even if input/output_tokens are missing @@ -354,6 +527,9 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val if let Some(v) = u.get("cache_creation_input_tokens") { result["cache_creation_input_tokens"] = v.clone(); } + if let Some(v) = u.get("cache_creation") { + result["cache_creation"] = v.clone(); + } // OpenAI/Responses 的 input(prompt_tokens/input_tokens)含缓存命中,Anthropic input_tokens 不含 // → 减去 cache_read 与 cache_creation,使其成为 fresh input。本函数在计量意义上是 claude 专属 @@ -381,13 +557,15 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val /// - user/assistant 的 text 内容 → 对应 role 的 message item /// - tool_use 从 assistant message 中"提升"为独立的 function_call item /// - tool_result 从 user message 中"提升"为独立的 function_call_output item -/// - thinking blocks → 丢弃 +/// - bridge-owned thinking blocks → restore the original Responses reasoning item +/// - unrelated native thinking blocks → 丢弃 fn convert_messages_to_input(messages: &[Value]) -> Result, ProxyError> { let mut input = Vec::new(); for msg in messages { let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user"); let content = msg.get("content"); + let message_input_start = input.len(); match content { // 字符串内容 @@ -425,17 +603,22 @@ fn convert_messages_to_input(messages: &[Value]) -> Result, ProxyErro } "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(""); - message_content.push(json!({ - "type": "input_image", - "image_url": format!("data:{media_type};base64,{data}") - })); + if let Some(image) = anthropic_image_to_responses_part(block) { + message_content.push(image); + } else { + log::warn!( + "[Responses] Unsupported or invalid Anthropic image block" + ); + } + } + + "document" => { + if let Some(file) = anthropic_document_to_responses_part(block) { + message_content.push(file); + } else { + log::warn!( + "[Responses] Unsupported or invalid Anthropic document block" + ); } } @@ -477,11 +660,7 @@ fn convert_messages_to_input(messages: &[Value]) -> Result, ProxyErro .get("tool_use_id") .and_then(|i| i.as_str()) .unwrap_or(""); - let output = match block.get("content") { - Some(Value::String(s)) => s.clone(), - Some(v) => canonical_json_string(v), - None => String::new(), - }; + let output = anthropic_tool_result_to_responses_output(block); input.push(json!({ "type": "function_call_output", @@ -490,8 +669,19 @@ fn convert_messages_to_input(messages: &[Value]) -> Result, ProxyErro })); } - "thinking" => { - // 丢弃 thinking blocks(与 openai_chat 一致) + "thinking" | "redacted_thinking" => { + if let Some(reasoning_item) = + openai_reasoning_item_from_anthropic_block(block) + { + if !message_content.is_empty() { + input.push(json!({ + "role": role, + "content": message_content.clone() + })); + message_content.clear(); + } + input.push(reasoning_item); + } } _ => {} @@ -512,6 +702,26 @@ fn convert_messages_to_input(messages: &[Value]) -> Result, ProxyErro input.push(json!({ "role": role })); } } + + // A replayed reasoning item is only valid when the same assistant + // generation also contains a following message/function call item. + // Reasoning-only incomplete turns otherwise brick the next request with + // "reasoning item ... without its required following item". + if role == "assistant" { + let mut has_generated_follower = false; + for index in (message_input_start..input.len()).rev() { + let item_type = input[index].get("type").and_then(Value::as_str); + let is_assistant_message = + input[index].get("role").and_then(Value::as_str) == Some("assistant"); + if item_type == Some("reasoning") { + if !has_generated_follower { + input.remove(index); + } + } else if item_type == Some("function_call") || is_assistant_message { + has_generated_follower = true; + } + } + } } Ok(input) @@ -519,12 +729,18 @@ fn convert_messages_to_input(messages: &[Value]) -> Result, ProxyErro /// OpenAI Responses 响应 → Anthropic 响应 pub fn responses_to_anthropic(body: Value) -> Result { + // A Responses failure can arrive inside an HTTP 2xx response object. Reject it + // before looking at `output`; otherwise `{status:"failed", output:[]}` becomes + // a successful empty Anthropic `end_turn` and hides the upstream error. + validate_responses_terminal_status(&body)?; + let output = body .get("output") .and_then(|o| o.as_array()) .ok_or_else(|| ProxyError::TransformError("No output in response".to_string()))?; let mut content = Vec::new(); + let response_completed = body.get("status").and_then(Value::as_str) == Some("completed"); let mut has_tool_use = false; for item in output { @@ -559,7 +775,42 @@ pub fn responses_to_anthropic(body: Value) -> Result { .get("arguments") .and_then(|a| a.as_str()) .unwrap_or("{}"); - let input: Value = serde_json::from_str(args_str).unwrap_or(json!({})); + let input: Value = if args_str.trim().is_empty() { + json!({}) + } else { + match serde_json::from_str(args_str) { + Ok(value) => value, + Err(error) if !response_completed => { + log::warn!( + "[Responses] Replacing incomplete function_call '{name}' arguments with an empty object: {error}" + ); + json!({}) + } + Err(error) => { + return Err(ProxyError::TransformError(format!( + "Invalid function_call arguments for '{name}': {error}" + ))) + } + } + }; + if !input.is_object() { + if !response_completed { + log::warn!( + "[Responses] Replacing incomplete function_call '{name}' non-object arguments with an empty object" + ); + content.push(json!({ + "type": "tool_use", + "id": call_id, + "name": name, + "input": {} + })); + has_tool_use = true; + continue; + } + return Err(ProxyError::TransformError(format!( + "Function call arguments for '{name}' must be a JSON object" + ))); + } let input = sanitize_anthropic_tool_use_input(name, input); content.push(json!({ @@ -572,26 +823,8 @@ pub fn responses_to_anthropic(body: Value) -> Result { } "reasoning" => { - // 映射 reasoning summary → thinking block - if let Some(summary) = item.get("summary").and_then(|s| s.as_array()) { - let thinking_text: String = summary - .iter() - .filter_map(|s| { - if s.get("type").and_then(|t| t.as_str()) == Some("summary_text") { - s.get("text").and_then(|t| t.as_str()) - } else { - None - } - }) - .collect::>() - .join(""); - - if !thinking_text.is_empty() { - content.push(json!({ - "type": "thinking", - "thinking": thinking_text - })); - } + if let Some(block) = anthropic_block_from_openai_reasoning_item(item) { + content.push(block); } } @@ -770,10 +1003,51 @@ mod tests { assert_eq!(result["tools"][0]["type"], "function"); assert_eq!(result["tools"][0]["name"], "get_weather"); assert!(result["tools"][0].get("parameters").is_some()); + assert_eq!(result["tools"][0]["parameters"]["type"], json!("object")); + assert_eq!( + result["tools"][0]["parameters"]["properties"]["location"]["type"], + json!("string") + ); // input_schema should not appear assert!(result["tools"][0].get("input_schema").is_none()); } + #[test] + fn test_anthropic_to_responses_defaults_missing_tool_schema_type() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Weather?"}], + "tools": [{ + "name": "get_weather", + "description": "Get weather info", + "input_schema": {"properties": {"location": {"type": "string"}}} + }] + }); + + let result = anthropic_to_responses(input, None, false, false).unwrap(); + let parameters = &result["tools"][0]["parameters"]; + assert_eq!(parameters["type"], json!("object")); + assert_eq!( + parameters["properties"]["location"]["type"], + json!("string") + ); + } + + #[test] + fn test_anthropic_to_responses_defaults_empty_tool_schema() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Do work"}], + "tools": [{"name": "do_work", "input_schema": {}}] + }); + + let result = anthropic_to_responses(input, None, false, false).unwrap(); + let parameters = &result["tools"][0]["parameters"]; + assert_eq!(parameters, &json!({"type": "object", "properties": {}})); + } + #[test] fn test_anthropic_to_responses_tool_choice_any_to_required() { let input = json!({ @@ -855,6 +1129,34 @@ mod tests { assert_eq!(input_arr[0]["output"], "Sunny, 25°C"); } + #[test] + fn test_anthropic_to_responses_tool_result_preserves_blocks_and_error() { + let input = json!({ + "model":"gpt-5", + "messages":[{"role":"user","content":[{ + "type":"tool_result", + "tool_use_id":"call_1", + "is_error":true, + "content":[ + {"type":"text","text":"command failed"}, + {"type":"image","source":{"type":"url","url":"https://example.com/error.png"}}, + {"type":"document","title":"trace.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}} + ] + }]}] + }); + + let result = anthropic_to_responses(input, None, false, false).unwrap(); + let output = result["input"][0]["output"].as_array().unwrap(); + assert_eq!(output[0]["text"], TOOL_RESULT_ERROR_MARKER); + assert_eq!( + output[1], + json!({"type":"input_text","text":"command failed"}) + ); + assert_eq!(output[2]["image_url"], "https://example.com/error.png"); + assert_eq!(output[3]["type"], "input_file"); + assert_eq!(output[3]["filename"], "trace.pdf"); + } + #[test] fn test_anthropic_to_responses_thinking_discarded() { let input = json!({ @@ -900,6 +1202,25 @@ mod tests { assert_eq!(content[1]["image_url"], "data:image/png;base64,abc123"); } + #[test] + fn test_anthropic_to_responses_url_image_and_document() { + let input = json!({ + "model":"gpt-5", + "messages":[{"role":"user","content":[ + {"type":"image","source":{"type":"url","url":"https://example.com/a.png"}}, + {"type":"document","title":"manual.pdf","source":{"type":"url","url":"https://example.com/manual.pdf"}} + ]}] + }); + + let result = anthropic_to_responses(input, None, false, false).unwrap(); + let content = result["input"][0]["content"].as_array().unwrap(); + assert_eq!(content[0]["type"], "input_image"); + assert_eq!(content[0]["image_url"], "https://example.com/a.png"); + assert_eq!(content[1]["type"], "input_file"); + assert_eq!(content[1]["file_url"], "https://example.com/manual.pdf"); + assert_eq!(content[1]["filename"], "manual.pdf"); + } + #[test] fn test_responses_to_anthropic_simple() { let input = json!({ @@ -952,6 +1273,65 @@ mod tests { assert_eq!(result["stop_reason"], "tool_use"); } + #[test] + fn test_completed_function_call_empty_arguments_normalizes_to_object() { + let input = json!({ + "id": "resp_empty_args", + "status": "completed", + "model": "gpt-5.6", + "output": [{ + "type": "function_call", + "call_id": "call_1", + "name": "ping", + "arguments": "" + }], + "usage": {"input_tokens": 10, "output_tokens": 2} + }); + let result = responses_to_anthropic(input).unwrap(); + assert_eq!(result["content"][0]["input"], json!({})); + } + + #[test] + fn test_incomplete_function_call_invalid_arguments_uses_empty_object() { + let input = json!({ + "id": "resp_partial_args", + "status": "incomplete", + "incomplete_details": {"reason": "max_output_tokens"}, + "model": "gpt-5.6", + "output": [{ + "type": "function_call", + "call_id": "call_1", + "name": "dangerous_tool", + "arguments": "{\"path\":" + }], + "usage": {"input_tokens": 10, "output_tokens": 2} + }); + let result = responses_to_anthropic(input).unwrap(); + assert_eq!(result["content"][0]["type"], "tool_use"); + assert_eq!(result["content"][0]["input"], json!({})); + assert_eq!(result["stop_reason"], "max_tokens"); + } + + #[test] + fn test_completed_function_call_invalid_arguments_is_error() { + let input = json!({ + "id": "resp_bad_args", + "status": "completed", + "model": "gpt-5.6", + "output": [{ + "type": "function_call", + "call_id": "call_1", + "name": "broken_tool", + "arguments": "{\"path\":" + }], + "usage": {"input_tokens": 10, "output_tokens": 2} + }); + assert!(matches!( + responses_to_anthropic(input), + Err(ProxyError::TransformError(_)) + )); + } + #[test] fn test_responses_to_anthropic_read_drops_empty_pages() { let input = json!({ @@ -1057,6 +1437,115 @@ mod tests { assert_eq!(result["content"][1]["text"], "The answer is 42"); } + #[test] + fn test_encrypted_reasoning_round_trips_through_anthropic_history() { + let original = json!({ + "type": "reasoning", + "id": "rs_123", + "summary": [{"type": "summary_text", "text": "Need a tool."}], + "encrypted_content": "opaque-ciphertext" + }); + let response = json!({ + "id": "resp_123", + "status": "completed", + "model": "gpt-5.6", + "output": [original.clone()], + "usage": {"input_tokens": 10, "output_tokens": 2} + }); + + let anthropic = responses_to_anthropic(response).unwrap(); + let thinking = anthropic["content"][0].clone(); + assert_eq!(thinking["type"], "thinking"); + assert!(thinking["signature"] + .as_str() + .is_some_and(|value| value.starts_with("ccswitch-openai-reasoning-v1:"))); + + let replay = anthropic_to_responses( + json!({ + "model": "gpt-5.6", + "messages": [{"role": "assistant", "content": [ + thinking, + {"type": "tool_use", "id": "call_1", "name": "lookup", "input": {}} + ]}] + }), + None, + true, + false, + ) + .unwrap(); + assert_eq!(replay["input"][0], original); + assert_eq!(replay["input"][1]["type"], "function_call"); + } + + #[test] + fn test_reasoning_only_assistant_turn_is_not_replayed() { + let item = json!({ + "type": "reasoning", + "id": "rs_orphan", + "summary": [], + "encrypted_content": "opaque" + }); + let block = anthropic_block_from_openai_reasoning_item(&item).unwrap(); + let replay = anthropic_to_responses( + json!({ + "model": "gpt-5.6", + "messages": [ + {"role": "assistant", "content": [block]}, + {"role": "user", "content": "continue"} + ] + }), + None, + true, + false, + ) + .unwrap(); + + assert_eq!(replay["input"].as_array().unwrap().len(), 1); + assert_eq!(replay["input"][0]["role"], "user"); + } + + #[test] + fn test_responses_failed_status_is_not_silent_empty_success() { + let input = json!({ + "id": "resp_failed", + "status": "failed", + "error": {"type": "server_error", "message": "backend exploded"}, + "output": [], + "usage": {"input_tokens": 10, "output_tokens": 0} + }); + + let error = responses_to_anthropic(input).unwrap_err(); + assert!( + matches!(error, ProxyError::TransformError(message) if message.contains("backend exploded")) + ); + } + + #[test] + fn test_responses_error_envelope_preserves_upstream_message() { + let input = json!({ + "error": {"type": "rate_limit_error", "message": "too many requests"} + }); + + let error = responses_to_anthropic(input).unwrap_err(); + assert!( + matches!(error, ProxyError::TransformError(message) if message.contains("too many requests")) + ); + } + + #[test] + fn test_responses_cancelled_status_is_not_end_turn() { + let input = json!({ + "id": "resp_cancelled", + "status": "cancelled", + "output": [] + }); + + let error = responses_to_anthropic(input).unwrap_err(); + assert!( + matches!(error, ProxyError::TransformError(message) if message.contains("cancelled")) + ); + } + #[test] fn test_responses_to_anthropic_incomplete_status() { let input = json!({ @@ -1669,6 +2158,21 @@ mod tests { assert_eq!(result["cache_read_input_tokens"], json!(80)); } + #[test] + fn test_build_usage_cache_write_tokens_from_nested_details() { + let result = build_anthropic_usage_from_responses(Some(&json!({ + "input_tokens": 100, + "output_tokens": 10, + "input_tokens_details": { + "cached_tokens": 30, + "cache_write_tokens": 20 + } + }))); + assert_eq!(result["input_tokens"], json!(50)); + assert_eq!(result["cache_read_input_tokens"], json!(30)); + assert_eq!(result["cache_creation_input_tokens"], json!(20)); + } + #[test] fn test_build_usage_cache_tokens_direct_override() { let result = build_anthropic_usage_from_responses(Some(&json!({ diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index 6ef02bc81..1d9bdc37c 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -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` / diff --git a/src-tauri/src/proxy/thinking_optimizer.rs b/src-tauri/src/proxy/thinking_optimizer.rs index 7bbe5d3ba..4f8374a5a 100644 --- a/src-tauri/src/proxy/thinking_optimizer.rs +++ b/src-tauri/src/proxy/thinking_optimizer.rs @@ -7,7 +7,7 @@ use serde_json::{json, Value}; /// /// 三路径分发: /// - skip: haiku 模型直接跳过 -/// - adaptive: opus-4-8 / opus-4-7 / opus-4-6 / sonnet-4-6 使用 adaptive thinking +/// - adaptive: current adaptive-thinking Claude models use adaptive thinking /// - legacy: 其他模型注入 enabled thinking + budget_tokens pub fn optimize(body: &mut Value, config: &OptimizerConfig) { if !config.thinking_optimizer { @@ -73,13 +73,42 @@ pub fn optimize(body: &mut Value, config: &OptimizerConfig) { } } -fn uses_adaptive_thinking(model: &str) -> bool { - let normalized = model.replace('.', "-"); - ["opus-4-8", "opus-4-7", "opus-4-6", "sonnet-4-6"] +pub(crate) fn uses_adaptive_thinking(model: &str) -> bool { + let normalized = normalize_model_name(model); + [ + "fable-5", + "mythos-5", + "mythos-preview", + "sonnet-5", + "opus-4-8", + "opus-4-7", + "opus-4-6", + "sonnet-4-6", + ] + .iter() + .any(|needle| normalized.contains(needle)) +} + +/// Models where omitting `thinking` still leaves adaptive thinking enabled. +pub(crate) fn adaptive_thinking_is_default(model: &str) -> bool { + let normalized = normalize_model_name(model); + ["fable-5", "mythos-5", "mythos-preview", "sonnet-5"] .iter() .any(|needle| normalized.contains(needle)) } +/// Models that reject `thinking: {"type":"disabled"}`. +pub(crate) fn thinking_cannot_be_disabled(model: &str) -> bool { + let normalized = normalize_model_name(model); + ["fable-5", "mythos-5"] + .iter() + .any(|needle| normalized.contains(needle)) +} + +fn normalize_model_name(model: &str) -> String { + model.trim().to_ascii_lowercase().replace(['.', '_'], "-") +} + /// 追加 beta 标识到 anthropic_beta 数组(去重) fn append_beta(body: &mut Value, beta: &str) { match body.get_mut("anthropic_beta") { @@ -108,7 +137,6 @@ mod tests { enabled: true, thinking_optimizer: true, cache_injection: true, - cache_ttl: "1h".to_string(), } } @@ -117,7 +145,6 @@ mod tests { enabled: true, thinking_optimizer: false, cache_injection: true, - cache_ttl: "1h".to_string(), } } @@ -139,6 +166,21 @@ mod tests { assert!(betas.iter().any(|v| v == "context-1m-2025-08-07")); } + #[test] + fn current_generation_models_use_adaptive_thinking() { + for model in [ + "claude-sonnet-5", + "anthropic/claude-fable-5", + "claude-mythos-5", + "claude-opus-4.8", + ] { + assert!(uses_adaptive_thinking(model), "model={model}"); + } + assert!(adaptive_thinking_is_default("claude-sonnet-5")); + assert!(thinking_cannot_be_disabled("claude-fable-5")); + assert!(!thinking_cannot_be_disabled("claude-sonnet-5")); + } + #[test] fn test_adaptive_opus_4_6() { let mut body = json!({ diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index f2dfc5800..933db3c53 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -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, } @@ -219,11 +220,11 @@ pub struct RectifierConfig { /// 让对话不中断。总开关,管辖「显式声明 text-only」与「上游报错后兜底」两条事实驱动路径。 #[serde(default = "default_true")] pub request_media_fallback: bool, - /// 请求整流:启发式 text-only 模型名匹配(默认开启) + /// 请求整流:确认纯文本注册表的发送前降级(默认开启) /// - /// 在模型未声明能力时,按内置模型名列表预测性地剥离图片(发送前)。 - /// 受 request_media_fallback 管辖;单独关闭后仅保留「显式声明」与「上游兜底」, - /// 避免内置列表把多模态模型误判成 text-only 而静默剥图。 + /// 在模型未声明能力时,按内置的确认纯文本注册表预先剥离图片。 + /// 受 request_media_fallback 管辖;单独关闭只停用代理的注册表预判, + /// 仍保留「显式声明」与「上游兜底」,且不改变 Codex 模型目录声明。 #[serde(default = "default_true")] pub request_media_heuristic: bool, } @@ -264,13 +265,6 @@ pub struct OptimizerConfig { /// Cache 注入子开关(总开关开启后默认生效) #[serde(default = "default_true")] pub cache_injection: bool, - /// Cache TTL: "5m" | "1h"(默认 "1h") - #[serde(default = "default_cache_ttl")] - pub cache_ttl: String, -} - -fn default_cache_ttl() -> String { - "1h".to_string() } impl Default for OptimizerConfig { @@ -279,7 +273,6 @@ impl Default for OptimizerConfig { enabled: false, thinking_optimizer: true, cache_injection: true, - cache_ttl: "1h".to_string(), } } } diff --git a/src-tauri/src/proxy/usage/calculator.rs b/src-tauri/src/proxy/usage/calculator.rs index c53a8ef95..c25671310 100644 --- a/src-tauri/src/proxy/usage/calculator.rs +++ b/src-tauri/src/proxy/usage/calculator.rs @@ -59,7 +59,7 @@ impl CostCalculator { pricing: &ModelPricing, cost_multiplier: Decimal, ) -> CostBreakdown { - let input_includes_cache_read = matches!(app_type, "codex" | "gemini"); + let input_includes_cache_read = matches!(app_type, "codex" | "gemini" | "grokbuild"); Self::calculate_with_cache_semantics( usage, pricing, @@ -76,10 +76,13 @@ impl CostCalculator { ) -> CostBreakdown { let million = Decimal::from(1_000_000); - // OpenAI/Gemini 风格的 input_tokens 包含缓存命中,需要扣除后再按输入价计费; + // OpenAI/Gemini 风格的 input_tokens 包含缓存读取和写入,需要扣除后再按输入价计费; // Claude/Anthropic 风格的 input_tokens 已经是 fresh input,不能再次扣减。 let billable_input_tokens = if input_includes_cache_read { - usage.input_tokens.saturating_sub(usage.cache_read_tokens) + usage + .input_tokens + .saturating_sub(usage.cache_read_tokens) + .saturating_sub(usage.cache_creation_tokens) } else { usage.input_tokens }; @@ -197,15 +200,34 @@ mod tests { let cost = CostCalculator::calculate_for_app("codex", &usage, &pricing, multiplier); - // Codex/OpenAI 语义:input_tokens 包含 cached_tokens,需要扣除 cache_read_tokens - assert_eq!(cost.input_cost, Decimal::from_str("0.0024").unwrap()); + // Codex/OpenAI 语义:input_tokens 包含 cache read/write,两桶都需扣除。 + assert_eq!(cost.input_cost, Decimal::from_str("0.0021").unwrap()); assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap()); assert_eq!(cost.cache_read_cost, Decimal::from_str("0.00006").unwrap()); assert_eq!( cost.cache_creation_cost, Decimal::from_str("0.000375").unwrap() ); - assert_eq!(cost.total_cost, Decimal::from_str("0.010335").unwrap()); + 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] diff --git a/src-tauri/src/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs index baa352670..549d81cf4 100644 --- a/src-tauri/src/proxy/usage/logger.rs +++ b/src-tauri/src/proxy/usage/logger.rs @@ -4,6 +4,7 @@ use super::calculator::{CostBreakdown, CostCalculator, ModelPricing}; use super::parser::TokenUsage; 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 rust_decimal::Decimal; use std::str::FromStr; @@ -70,15 +71,22 @@ impl<'a> UsageLogger<'a> { }; let created_at = chrono::Utc::now().timestamp(); + let input_token_semantics = + if matches!(log.app_type.as_str(), "codex" | "gemini" | "grokbuild") { + INPUT_TOKEN_SEMANTICS_TOTAL + } else { + INPUT_TOKEN_SEMANTICS_FRESH + }; conn.execute( "INSERT OR REPLACE 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)", + ) 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, @@ -90,6 +98,7 @@ impl<'a> UsageLogger<'a> { log.usage.output_tokens, log.usage.cache_read_tokens, log.usage.cache_creation_tokens, + input_token_semantics, input_cost, output_cost, cache_read_cost, @@ -451,4 +460,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(()) + } } diff --git a/src-tauri/src/proxy/usage/parser.rs b/src-tauri/src/proxy/usage/parser.rs index 40f77a4d7..45120b8ae 100644 --- a/src-tauri/src/proxy/usage/parser.rs +++ b/src-tauri/src/proxy/usage/parser.rs @@ -9,6 +9,24 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +fn openai_cache_read_tokens(usage: &Value) -> u32 { + usage + .get("cache_read_input_tokens") + .or_else(|| usage.pointer("/input_tokens_details/cached_tokens")) + .or_else(|| usage.pointer("/prompt_tokens_details/cached_tokens")) + .and_then(Value::as_u64) + .unwrap_or(0) as u32 +} + +fn openai_cache_write_tokens(usage: &Value) -> u32 { + usage + .get("cache_creation_input_tokens") + .or_else(|| usage.pointer("/input_tokens_details/cache_write_tokens")) + .or_else(|| usage.pointer("/prompt_tokens_details/cache_write_tokens")) + .and_then(Value::as_u64) + .unwrap_or(0) as u32 +} + /// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致 pub const SESSION_REQUEST_ID_PREFIX: &str = "session:"; @@ -250,25 +268,14 @@ impl TokenUsage { .and_then(|v| v.as_str()) .map(|s| s.to_string()); - let cached_tokens = usage - .get("cache_read_input_tokens") - .and_then(|v| v.as_u64()) - .or_else(|| { - usage - .get("input_tokens_details") - .and_then(|d| d.get("cached_tokens")) - .and_then(|v| v.as_u64()) - }) - .unwrap_or(0) as u32; + let cached_tokens = openai_cache_read_tokens(usage); + let cache_write_tokens = openai_cache_write_tokens(usage); Some(Self { input_tokens: input_tokens? as u32, output_tokens: output_tokens? as u32, cache_read_tokens: cached_tokens, - cache_creation_tokens: usage - .get("cache_creation_input_tokens") - .and_then(|v| v.as_u64()) - .unwrap_or(0) as u32, + cache_creation_tokens: cache_write_tokens, model, message_id: None, }) @@ -285,19 +292,13 @@ impl TokenUsage { let output_tokens = usage.get("output_tokens")?.as_u64()? as u32; // 获取 cached_tokens (可能在 cache_read_input_tokens 或 input_tokens_details 中) - let cached_tokens = usage - .get("cache_read_input_tokens") - .and_then(|v| v.as_u64()) - .or_else(|| { - usage - .get("input_tokens_details") - .and_then(|d| d.get("cached_tokens")) - .and_then(|v| v.as_u64()) - }) - .unwrap_or(0) as u32; + let cached_tokens = openai_cache_read_tokens(usage); + let cache_write_tokens = openai_cache_write_tokens(usage); - // 调整 input_tokens: 减去 cached_tokens - let adjusted_input = input_tokens.saturating_sub(cached_tokens); + // 调整 input_tokens: OpenAI total input 同时包含 cache read/write 两桶。 + let adjusted_input = input_tokens + .saturating_sub(cached_tokens) + .saturating_sub(cache_write_tokens); // 提取响应中的模型名称 let model = body @@ -309,10 +310,7 @@ impl TokenUsage { input_tokens: adjusted_input, output_tokens, cache_read_tokens: cached_tokens, - cache_creation_tokens: usage - .get("cache_creation_input_tokens") - .and_then(|v| v.as_u64()) - .unwrap_or(0) as u32, + cache_creation_tokens: cache_write_tokens, model, message_id: None, }) @@ -391,11 +389,8 @@ impl TokenUsage { let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64())?; // 获取 cached_tokens (可能在 prompt_tokens_details 中) - let cached_tokens = usage - .get("prompt_tokens_details") - .and_then(|d| d.get("cached_tokens")) - .and_then(|v| v.as_u64()) - .unwrap_or(0) as u32; + let cached_tokens = openai_cache_read_tokens(usage); + let cache_write_tokens = openai_cache_write_tokens(usage); // 提取响应中的模型名称 let model = body @@ -407,7 +402,7 @@ impl TokenUsage { input_tokens: prompt_tokens as u32, output_tokens: completion_tokens as u32, cache_read_tokens: cached_tokens, - cache_creation_tokens: 0, + cache_creation_tokens: cache_write_tokens, model, message_id: None, }) @@ -797,6 +792,30 @@ mod tests { assert_eq!(usage.cache_read_tokens, 300); } + #[test] + fn test_codex_response_parsing_cache_write_tokens_in_details() { + let response = json!({ + "usage": { + "input_tokens": 1000, + "output_tokens": 500, + "input_tokens_details": { + "cached_tokens": 300, + "cache_write_tokens": 200 + } + } + }); + + let usage = TokenUsage::from_codex_response(&response).unwrap(); + assert_eq!(usage.input_tokens, 1000); + assert_eq!(usage.cache_read_tokens, 300); + assert_eq!(usage.cache_creation_tokens, 200); + + let adjusted = TokenUsage::from_codex_response_adjusted(&response).unwrap(); + assert_eq!(adjusted.input_tokens, 500); + assert_eq!(adjusted.cache_read_tokens, 300); + assert_eq!(adjusted.cache_creation_tokens, 200); + } + #[test] fn test_codex_response_adjusted() { let response = json!({ diff --git a/src-tauri/src/resources/codex_native_responses_template.json b/src-tauri/src/resources/codex_native_responses_template.json index a513fe1db..644a5123b 100644 --- a/src-tauri/src/resources/codex_native_responses_template.json +++ b/src-tauri/src/resources/codex_native_responses_template.json @@ -32,7 +32,8 @@ "effective_context_window_percent": 95, "experimental_supported_tools": [], "input_modalities": [ - "text" + "text", + "image" ], "supports_search_tool": false } diff --git a/src-tauri/src/services/balance.rs b/src-tauri/src/services/balance.rs index edeba3888..8639b1d98 100644 --- a/src-tauri/src/services/balance.rs +++ b/src-tauri/src/services/balance.rs @@ -2,6 +2,12 @@ //! //! 支持 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 的账户余额查询。 //! 返回 UsageResult 格式,与现有用量系统无缝对接。 +//! +//! 错误通道语义(与 coding_plan / subscription 两个服务保持一致): +//! - `Err(String)` = 瞬时传输失败(网络不可达/超时/读体中断)。前端 invoke reject, +//! react-query 触发 retry 并保留上一次成功的 data(天然 keep-last-good)。 +//! - `Ok(success:false)` = 确定性失败(空 key/未知供应商/鉴权/非 2xx/响应体非法 JSON), +//! 立即透出错误文案。判定按 reqwest 错误种类在折叠点完成,不依赖错误文案匹配。 use crate::provider::{UsageData, UsageResult}; use std::time::Duration; @@ -65,7 +71,7 @@ fn make_auth_error(status: reqwest::StatusCode) -> UsageResult { // GET https://api.deepseek.com/user/balance // Response: { balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }], is_available } -async fn query_deepseek(api_key: &str) -> UsageResult { +async fn query_deepseek(api_key: &str) -> Result { let client = crate::proxy::http_client::get(); let resp = client @@ -78,21 +84,27 @@ async fn query_deepseek(api_key: &str) -> UsageResult { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return make_auth_error(status); + return Ok(make_auth_error(status)); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; let is_available = body @@ -126,18 +138,18 @@ async fn query_deepseek(api_key: &str) -> UsageResult { } } - UsageResult { + Ok(UsageResult { success: true, data: if data.is_empty() { None } else { Some(data) }, error: None, - } + }) } // ── StepFun ───────────────────────────────────────────────── // GET https://api.stepfun.com/v1/accounts // Response: { object, type, balance, total_cash_balance, total_voucher_balance } -async fn query_stepfun(api_key: &str) -> UsageResult { +async fn query_stepfun(api_key: &str) -> Result { let client = crate::proxy::http_client::get(); let resp = client @@ -150,26 +162,32 @@ async fn query_stepfun(api_key: &str) -> UsageResult { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return make_auth_error(status); + return Ok(make_auth_error(status)); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; let balance = parse_f64_field(&body, "balance").unwrap_or(0.0); - UsageResult { + Ok(UsageResult { success: true, data: Some(vec![UsageData { plan_name: Some("StepFun".to_string()), @@ -182,14 +200,14 @@ async fn query_stepfun(api_key: &str) -> UsageResult { extra: None, }]), error: None, - } + }) } // ── SiliconFlow ───────────────────────────────────────────── // GET https://api.siliconflow.cn/v1/user/info (or .com for EN) // Response: { code, data: { balance, chargeBalance, totalBalance, status } } -async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult { +async fn query_siliconflow(api_key: &str, is_cn: bool) -> Result { let client = crate::proxy::http_client::get(); let domain = if is_cn { @@ -209,26 +227,32 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return make_auth_error(status); + return Ok(make_auth_error(status)); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; let data = match body.get("data") { Some(d) => d, - None => return make_error("Missing 'data' field in response".to_string()), + None => return Ok(make_error("Missing 'data' field in response".to_string())), }; let total_balance = parse_f64_field(data, "totalBalance").unwrap_or(0.0); @@ -240,7 +264,7 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult { "SiliconFlow (EN)" }; - UsageResult { + Ok(UsageResult { success: true, data: Some(vec![UsageData { plan_name: Some(plan_name.to_string()), @@ -253,14 +277,14 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult { extra: None, }]), error: None, - } + }) } // ── OpenRouter ────────────────────────────────────────────── // GET https://openrouter.ai/api/v1/credits // Response: { data: { total_credits, total_usage } } -async fn query_openrouter(api_key: &str) -> UsageResult { +async fn query_openrouter(api_key: &str) -> Result { let client = crate::proxy::http_client::get(); let resp = client @@ -273,21 +297,27 @@ async fn query_openrouter(api_key: &str) -> UsageResult { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return make_auth_error(status); + return Ok(make_auth_error(status)); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; let data = body.get("data").unwrap_or(&body); @@ -295,7 +325,7 @@ async fn query_openrouter(api_key: &str) -> UsageResult { let total_usage = parse_f64_field(data, "total_usage").unwrap_or(0.0); let remaining = total_credits - total_usage; - UsageResult { + Ok(UsageResult { success: true, data: Some(vec![UsageData { plan_name: Some("OpenRouter".to_string()), @@ -312,7 +342,7 @@ async fn query_openrouter(api_key: &str) -> UsageResult { extra: None, }]), error: None, - } + }) } // ── Novita AI ─────────────────────────────────────────────── @@ -320,7 +350,7 @@ async fn query_openrouter(api_key: &str) -> UsageResult { // Response: { availableBalance, cashBalance, creditLimit, outstandingInvoices } // 金额单位:0.0001 USD -async fn query_novita(api_key: &str) -> UsageResult { +async fn query_novita(api_key: &str) -> Result { let client = crate::proxy::http_client::get(); let resp = client @@ -333,27 +363,33 @@ async fn query_novita(api_key: &str) -> UsageResult { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return make_auth_error(status); + return Ok(make_auth_error(status)); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; // Novita 金额单位为 0.0001 USD,需除以 10000 转为 USD let available = parse_f64_field(&body, "availableBalance").unwrap_or(0.0) / 10000.0; - UsageResult { + Ok(UsageResult { success: true, data: Some(vec![UsageData { plan_name: Some("Novita AI".to_string()), @@ -370,7 +406,7 @@ async fn query_novita(api_key: &str) -> UsageResult { extra: None, }]), error: None, - } + }) } // ── 工具函数 ──────────────────────────────────────────────── @@ -385,6 +421,8 @@ fn parse_f64_field(obj: &serde_json::Value, field: &str) -> Option { // ── 公开入口 ──────────────────────────────────────────────── +/// 查询余额。瞬时传输失败返回 `Err`(前端 reject → retry + 保留上次成功值), +/// 确定性失败返回 `Ok(success:false)`(见模块级文档)。 pub async fn get_balance(base_url: &str, api_key: &str) -> Result { if api_key.trim().is_empty() { return Ok(UsageResult { @@ -405,14 +443,12 @@ pub async fn get_balance(base_url: &str, api_key: &str) -> Result query_deepseek(api_key).await, BalanceProvider::StepFun => query_stepfun(api_key).await, BalanceProvider::SiliconFlow => query_siliconflow(api_key, true).await, BalanceProvider::SiliconFlowEn => query_siliconflow(api_key, false).await, BalanceProvider::OpenRouter => query_openrouter(api_key).await, BalanceProvider::NovitaAI => query_novita(api_key).await, - }; - - Ok(result) + } } diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index 19098db67..63bd727d2 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -99,7 +99,7 @@ fn make_error(msg: String) -> SubscriptionQuota { // ── Kimi For Coding ───────────────────────────────────────── -async fn query_kimi(api_key: &str) -> SubscriptionQuota { +async fn query_kimi(api_key: &str) -> Result { let client = crate::proxy::http_client::get(); let resp = client @@ -112,12 +112,12 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return SubscriptionQuota { + return Ok(SubscriptionQuota { tool: "coding_plan".to_string(), credential_status: CredentialStatus::Expired, credential_message: Some("Invalid API key".to_string()), @@ -126,17 +126,23 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { extra_usage: None, error: Some(format!("Authentication failed (HTTP {status})")), queried_at: Some(now_millis()), - }; + }); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; let mut tiers = Vec::new(); @@ -187,7 +193,7 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { }); } - SubscriptionQuota { + Ok(SubscriptionQuota { tool: "coding_plan".to_string(), credential_status: CredentialStatus::Valid, credential_message: None, @@ -196,7 +202,7 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { extra_usage: None, error: None, queried_at: Some(now_millis()), - } + }) } // ── 智谱 GLM ──────────────────────────────────────────────── @@ -307,7 +313,7 @@ fn zhipu_quota_base(base_url: &str) -> &'static str { } } -async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota { +async fn query_zhipu(base_url: &str, api_key: &str) -> Result { let client = crate::proxy::http_client::get(); let url = format!( "{}/api/monitor/usage/quota/limit", @@ -325,12 +331,12 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return SubscriptionQuota { + return Ok(SubscriptionQuota { tool: "coding_plan".to_string(), credential_status: CredentialStatus::Expired, credential_message: Some("Invalid API key".to_string()), @@ -339,19 +345,32 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota { extra_usage: None, error: Some(format!("Authentication failed (HTTP {status})")), queried_at: Some(now_millis()), - }; + }); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; + Ok(zhipu_quota_from_body(&body)) +} + +/// 解析智谱额度响应体(个人版与团队版共用同一 shape)。 +/// 仅在 HTTP 成功、body 已完整读取并解析为 JSON 后调用——本函数不做任何网络 IO, +/// 故无瞬时失败通道,确定性失败直接落进 `Ok(success:false)`。 +fn zhipu_quota_from_body(body: &serde_json::Value) -> SubscriptionQuota { // 检查业务级别错误 if body.get("success").and_then(|v| v.as_bool()) == Some(false) { let msg = body @@ -388,7 +407,7 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota { // ── MiniMax ───────────────────────────────────────────────── -async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { +async fn query_minimax(api_key: &str, is_cn: bool) -> Result { let client = crate::proxy::http_client::get(); let api_domain = if is_cn { @@ -408,12 +427,12 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return SubscriptionQuota { + return Ok(SubscriptionQuota { tool: "coding_plan".to_string(), credential_status: CredentialStatus::Expired, credential_message: Some("Invalid API key".to_string()), @@ -422,17 +441,23 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { extra_usage: None, error: Some(format!("Authentication failed (HTTP {status})")), queried_at: Some(now_millis()), - }; + }); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; // 检查业务级别错误 @@ -446,14 +471,14 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { .get("status_msg") .and_then(|v| v.as_str()) .unwrap_or("Unknown error"); - return make_error(format!("API error (code {status_code}): {msg}")); + return Ok(make_error(format!("API error (code {status_code}): {msg}"))); } } // 提取纯函数便于无 mock 单元测试;新接口直接给"剩余百分比",反转为已用百分比 let tiers = parse_minimax_tiers(&body); - SubscriptionQuota { + Ok(SubscriptionQuota { tool: "coding_plan".to_string(), credential_status: CredentialStatus::Valid, credential_message: None, @@ -462,12 +487,12 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { extra_usage: None, error: None, queried_at: Some(now_millis()), - } + }) } // ── ZenMux ────────────────────────────────────────────────── -async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota { +async fn query_zenmux(base_url: &str, api_key: &str) -> Result { let client = crate::proxy::http_client::get(); let resp = client @@ -480,12 +505,12 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota { let resp = match resp { Ok(r) => r, - Err(e) => return make_error(format!("Network error: {e}")), + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return SubscriptionQuota { + return Ok(SubscriptionQuota { tool: "coding_plan".to_string(), credential_status: CredentialStatus::Expired, credential_message: Some("Invalid API key".to_string()), @@ -494,17 +519,23 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota { extra_usage: None, error: Some(format!("Authentication failed (HTTP {status})")), queried_at: Some(now_millis()), - }; + }); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return make_error(format!("API error (HTTP {status}): {body}")); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, - Err(e) => return make_error(format!("Failed to parse response: {e}")), + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), }; // 检查业务级别错误 @@ -513,12 +544,12 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota { .get("message") .and_then(|v| v.as_str()) .unwrap_or("Unknown error"); - return make_error(format!("API error: {msg}")); + return Ok(make_error(format!("API error: {msg}"))); } let data = match body.get("data") { Some(d) => d, - None => return make_error("Missing 'data' field in response".to_string()), + None => return Ok(make_error("Missing 'data' field in response".to_string())), }; let mut tiers = Vec::new(); @@ -581,7 +612,7 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota { String::new() }; - SubscriptionQuota { + Ok(SubscriptionQuota { tool: "coding_plan".to_string(), credential_status: CredentialStatus::Valid, credential_message: if plan_info.is_empty() { @@ -594,7 +625,7 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota { extra_usage: None, error: None, queried_at: Some(now_millis()), - } + }) } /// 从 `/coding_plan/remains` 响应中解析 MiniMax 编程套餐的额度 tier。 @@ -690,8 +721,11 @@ enum VolcCall { /// 硬鉴权失败(HTTP 401/403 或 AccessDenied/Signature 等错误码)——两个 plan /// 共用凭据,命中即停。 Auth(String), - /// 网络 / 非鉴权 HTTP 错误 / 解析失败——记录后可继续尝试另一个 plan。 + /// 非鉴权 HTTP 错误 / 响应体非法 JSON——记录后可继续尝试另一个 plan。 Soft(String), + /// 瞬时传输失败(网络/超时/读体中断)——同 host 的另一个 plan 大概率同样 + /// 失败,调用方应立即以 `Err` 传播(前端 reject → retry + 保留上次成功值)。 + Transient(String), } /// 从数据面 base_url 提取控制面 OpenAPI 所需的 Region(如 @@ -889,7 +923,7 @@ async fn volcengine_openapi_call( let resp = match resp { Ok(r) => r, - Err(e) => return VolcCall::Soft(format!("Network error: {e}")), + Err(e) => return VolcCall::Transient(format!("Network error: {e}")), }; let status = resp.status(); @@ -916,7 +950,13 @@ async fn volcengine_openapi_call( return VolcCall::Soft(format!("API error (HTTP {status}): {raw}")); } - let body: serde_json::Value = match resp.json().await { + // 同 Bearer 路径:先 bytes() 再解析——读体失败是瞬时(Transient),解析失败 + // 是确定性(Soft)。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return VolcCall::Transient(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, Err(e) => return VolcCall::Soft(format!("Failed to parse response: {e}")), }; @@ -1058,7 +1098,7 @@ async fn query_volcengine( base_url: &str, access_key_id: &str, secret_access_key: &str, -) -> SubscriptionQuota { +) -> Result { let region = volcengine_region(base_url); let mut soft_errors: Vec = Vec::new(); // 2xx + 无 Error 信封但解析不出额度时,截断原始响应用于诊断(区分"真没订阅" @@ -1071,7 +1111,8 @@ async fn query_volcengine( // 1) Agent Plan:GetAFPUsage match volcengine_openapi_call(®ion, access_key_id, secret_access_key, "GetAFPUsage").await { - VolcCall::Auth(detail) => return volcengine_auth_error(detail), + VolcCall::Auth(detail) => return Ok(volcengine_auth_error(detail)), + VolcCall::Transient(detail) => return Err(format!("GetAFPUsage: {detail}")), VolcCall::Soft(detail) => soft_errors.push(format!("GetAFPUsage: {detail}")), VolcCall::Body(body) => { let result = body.get("Result").unwrap_or(&body); @@ -1083,7 +1124,7 @@ async fn query_volcengine( .map(str::trim) .filter(|s| !s.is_empty()) .map(|s| format!("Agent Plan {s}")); - return volcengine_success(tiers, plan); + return Ok(volcengine_success(tiers, plan)); } empty_responses.push(summarize("GetAFPUsage", &body)); } @@ -1098,32 +1139,33 @@ async fn query_volcengine( ) .await { - VolcCall::Auth(detail) => return volcengine_auth_error(detail), + VolcCall::Auth(detail) => return Ok(volcengine_auth_error(detail)), + VolcCall::Transient(detail) => return Err(format!("GetCodingPlanUsage: {detail}")), VolcCall::Soft(detail) => soft_errors.push(format!("GetCodingPlanUsage: {detail}")), VolcCall::Body(body) => { let result = body.get("Result").unwrap_or(&body); let tiers = parse_coding_plan_tiers(result); if !tiers.is_empty() { - return volcengine_success(tiers, Some("Coding Plan".to_string())); + return Ok(volcengine_success(tiers, Some("Coding Plan".to_string()))); } empty_responses.push(summarize("GetCodingPlanUsage", &body)); } } if !soft_errors.is_empty() { - make_error(soft_errors.join("; ")) + Ok(make_error(soft_errors.join("; "))) } else if !empty_responses.is_empty() { // 签名已通过、请求到达业务层,但响应里没有可解析的额度。带上原始响应, // 便于核对真实字段名/包裹层,或确认确实未订阅。 - make_error(format!( + Ok(make_error(format!( "No active subscription found (signature OK). Raw: {}", empty_responses.join(" || ") - )) + ))) } else { - make_error( + Ok(make_error( "No active Agent Plan or Coding Plan subscription found for this credential" .to_string(), - ) + )) } } @@ -1143,12 +1185,115 @@ fn coding_plan_not_found(error: &str) -> SubscriptionQuota { } } +// ── 智谱团队套餐(Team Plan)────────────────────────────────── +// +// 与个人版的差异仅在请求构造(参考 token-monitor/src/shared/zaiTeamLimits.js): +// - 固定走国内站 open.bigmodel.cn(团队版仅存在于国内站,z.ai 国际站无 team 档) +// - 同一 quota 路径加 `?type=2` +// - 额外请求头 bigmodel-organization / bigmodel-project(两者 + api_key 缺一不可) +// 响应 shape 与个人版完全一致 → 复用 zhipu_quota_from_body / parse_zhipu_token_tiers。 +const ZHIPU_TEAM_QUOTA_URL: &str = "https://open.bigmodel.cn/api/monitor/usage/quota/limit"; + +async fn query_zhipu_team( + api_key: &str, + organization_id: &str, + project_id: &str, +) -> Result { + query_zhipu_team_at(ZHIPU_TEAM_QUOTA_URL, api_key, organization_id, project_id).await +} + +/// 团队版额度查询。`quota_url_base` 为不含 query 的 quota 端点;团队版与个人版同路径, +/// 靠 `?type=2` 区分(在此拼上)。拆出 url 参数便于用本地 server 测试请求形状。 +async fn query_zhipu_team_at( + quota_url_base: &str, + api_key: &str, + organization_id: &str, + project_id: &str, +) -> Result { + let client = crate::proxy::http_client::get(); + let url = format!("{quota_url_base}?type=2"); + + let resp = client + .get(&url) + .header("Authorization", api_key) // 与个人版一致:智谱不加 Bearer 前缀 + .header("bigmodel-organization", organization_id) + .header("bigmodel-project", project_id) + .header("Content-Type", "application/json") + .header("Accept-Language", "en-US,en") + .timeout(std::time::Duration::from_secs(15)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return Err(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Ok(SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Expired, + credential_message: Some("Invalid API key".to_string()), + success: false, + tiers: vec![], + extra_usage: None, + error: Some(format!("Authentication failed (HTTP {status})")), + queried_at: Some(now_millis()), + }); + } + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Ok(make_error(format!("API error (HTTP {status}): {body}"))); + } + + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { + Ok(v) => v, + Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))), + }; + + Ok(zhipu_quota_from_body(&body)) +} + +/// 查询编程套餐额度。瞬时传输失败(网络/超时/读体中断)返回 `Err`(前端 reject → +/// retry + 保留上次成功值);确定性失败(凭据缺失/未知域名/鉴权/非 2xx/业务错误) +/// 返回 `Ok(success:false)` 立即透出文案。判定按 reqwest 错误种类在折叠点完成。 +/// +/// `coding_plan_provider` 显式标识用于无法靠 base_url 区分的供应商(当前为智谱团队版 +/// `zhipu_team`——其 base_url 与个人版智谱相同);其余情况走 `detect_provider`。 pub async fn get_coding_plan_quota( base_url: &str, api_key: &str, access_key_id: Option<&str>, secret_access_key: Option<&str>, + coding_plan_provider: Option<&str>, + team_organization_id: Option<&str>, + team_project_id: Option<&str>, ) -> Result { + // 智谱团队版:base_url 与个人版智谱(open.bigmodel.cn)相同,detect_provider 无法 + // 区分,必须靠显式 coding_plan_provider == "zhipu_team" 路由。需 api_key + 组织 ID + // + 项目 ID 三者齐全,缺任一返回 NotFound 引导补全。 + if coding_plan_provider + .map(|p| p.eq_ignore_ascii_case("zhipu_team")) + .unwrap_or(false) + { + let organization_id = team_organization_id.unwrap_or("").trim(); + let project_id = team_project_id.unwrap_or("").trim(); + if api_key.trim().is_empty() || organization_id.is_empty() || project_id.is_empty() { + return Ok(coding_plan_not_found( + "Zhipu team plan needs the API key + organization ID + project ID", + )); + } + return query_zhipu_team(api_key, organization_id, project_id).await; + } + let provider = match detect_provider(base_url) { Some(p) => p, // 域名未命中已知套餐供应商(如第三方中转站):给出明确错误而非静默失败 @@ -1165,7 +1310,7 @@ pub async fn get_coding_plan_quota( "Volcengine usage query needs the account AccessKey ID + Secret (not the inference API key)", )); } - return Ok(query_volcengine(base_url, ak, sk).await); + return query_volcengine(base_url, ak, sk).await; } // 其余供应商:数据面 Bearer api_key。 @@ -1174,7 +1319,7 @@ pub async fn get_coding_plan_quota( return Ok(coding_plan_not_found("API key is empty")); } - let quota = match provider { + match provider { CodingPlanProvider::Kimi => query_kimi(api_key).await, CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => { query_zhipu(base_url, api_key).await @@ -1186,18 +1331,16 @@ pub async fn get_coding_plan_quota( CodingPlanProvider::Volcengine => { unreachable!("volcengine handled via AK/SK branch above") } - }; - - Ok(quota) + } } #[cfg(test)] mod tests { use super::{ parse_afp_tiers, parse_coding_plan_tiers, parse_minimax_tiers, parse_zhipu_token_tiers, - volcengine_canonical_query, volcengine_is_auth_error_code, volcengine_region, - volcengine_response_error, volcengine_sign, zhipu_quota_base, TIER_FIVE_HOUR, TIER_MONTHLY, - TIER_WEEKLY_LIMIT, + query_zhipu_team_at, volcengine_canonical_query, volcengine_is_auth_error_code, + volcengine_region, volcengine_response_error, volcengine_sign, zhipu_quota_base, + TIER_FIVE_HOUR, TIER_MONTHLY, TIER_WEEKLY_LIMIT, }; use serde_json::json; @@ -1806,4 +1949,289 @@ mod tests { let ok_body = json!({ "ResponseMetadata": { "RequestId": "x" }, "Result": {} }); assert!(volcengine_response_error(&ok_body).is_none()); } + + // ── 传输层错误通道语义:瞬时 → Err(前端 reject/retry),确定性 → Ok(success:false) ── + // + // 借 ZenMux 分支可指向任意 base_url 的特性,用本地 listener 驱动真实 HTTP + // 路径,锁定 send 失败 / 读体中断 / 4xx / 非法 JSON 各自落在哪条通道。 + // balance / subscription 服务与本文件共用同一折叠模式,这里的用例同时充当 + // 三个服务的语义回归锚。 + + use super::get_coding_plan_quota; + use crate::services::subscription::CredentialStatus; + use std::io::{Read, Write}; + + /// 测试进程内可能有其他用例临时 set_var HTTP_PROXY(http_client 的 + /// loopback 检测测试),NO_PROXY 保证本地回环请求始终直连。 + fn ensure_no_proxy_for_loopback() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::env::set_var("NO_PROXY", "127.0.0.1,localhost"); + std::env::set_var("no_proxy", "127.0.0.1,localhost"); + }); + } + + /// 起一个只服务一次连接的本地 HTTP server。`response=None` 表示读完请求 + /// 直接断开(模拟响应前连接中断)。返回可命中 ZenMux 分支的 base_url。 + fn spawn_once_server(response: Option) -> (String, std::thread::JoinHandle<()>) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind local listener"); + let port = listener.local_addr().expect("local addr").port(); + let handle = std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 2048]; + let _ = stream.read(&mut buf); + if let Some(resp) = response { + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + } + } + }); + (format!("http://127.0.0.1:{port}/zenmux"), handle) + } + + fn http_response(status_line: &str, body: &str) -> String { + format!( + "HTTP/1.1 {status_line}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ) + } + + #[tokio::test] + async fn transient_connection_refused_returns_err() { + ensure_no_proxy_for_loopback(); + // 绑定后立刻释放端口 → 连接被拒(send 失败) + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("local addr").port(); + drop(listener); + + let result = get_coding_plan_quota( + &format!("http://127.0.0.1:{port}/zenmux"), + "k", + None, + None, + None, + None, + None, + ) + .await; + let err = result.expect_err("send 失败必须走 Err 通道(瞬时,前端 reject 后重试)"); + assert!(err.contains("Network error"), "err={err}"); + } + + #[tokio::test] + async fn transient_connection_closed_before_response_returns_err() { + ensure_no_proxy_for_loopback(); + let (base_url, handle) = spawn_once_server(None); + + let result = get_coding_plan_quota(&base_url, "k", None, None, None, None, None).await; + let err = result.expect_err("响应前连接中断必须走 Err 通道(瞬时)"); + assert!(err.contains("Network error"), "err={err}"); + handle.join().expect("server thread"); + } + + #[tokio::test] + async fn transient_truncated_body_returns_err() { + ensure_no_proxy_for_loopback(); + // 声明 content-length: 100 但只写一小段就断开 → 读体中断。 + // 锁定 bytes() 先于解析:这类失败必须走 Err(瞬时),不能因 reqwest 把 + // 读体错误包成 decode 而被误判成确定性的 "Failed to parse response"。 + let (base_url, handle) = spawn_once_server(Some( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 100\r\n\r\npartial" + .to_string(), + )); + + let result = get_coding_plan_quota(&base_url, "k", None, None, None, None, None).await; + let err = result.expect_err("读体中断必须走 Err 通道(瞬时,前端 reject 后重试)"); + assert!(err.contains("Failed to read response"), "err={err}"); + handle.join().expect("server thread"); + } + + #[tokio::test] + async fn deterministic_http_401_stays_ok_with_auth_error() { + ensure_no_proxy_for_loopback(); + let (base_url, handle) = spawn_once_server(Some(http_response("401 Unauthorized", "{}"))); + + let quota = get_coding_plan_quota(&base_url, "k", None, None, None, None, None) + .await + .expect("鉴权失败是确定性失败,必须保持 Ok(success:false) 展示文案"); + assert!(!quota.success); + assert!(matches!(quota.credential_status, CredentialStatus::Expired)); + let err = quota.error.expect("应有错误文案"); + assert!(err.contains("Authentication failed (HTTP 401"), "err={err}"); + handle.join().expect("server thread"); + } + + #[tokio::test] + async fn deterministic_http_429_stays_ok_with_status_in_error() { + ensure_no_proxy_for_loopback(); + let (base_url, handle) = + spawn_once_server(Some(http_response("429 Too Many Requests", "slow down"))); + + let quota = get_coding_plan_quota(&base_url, "k", None, None, None, None, None) + .await + .expect("非 2xx 保持 Ok(success:false),状态码留在文案里交前端分类"); + assert!(!quota.success); + // 前端 isTransientUsageError 靠 /http\s+(\d{3})/ 提取状态码把 429 归瞬时, + // 文案格式是跨层契约,勿改。 + let err = quota.error.expect("应有错误文案"); + assert!(err.contains("HTTP 429"), "err={err}"); + handle.join().expect("server thread"); + } + + #[tokio::test] + async fn deterministic_invalid_json_body_stays_ok_with_parse_error() { + ensure_no_proxy_for_loopback(); + // 完整读到响应体但不是 JSON → is_decode → 确定性解析失败 + let (base_url, handle) = spawn_once_server(Some(http_response("200 OK", "not-json"))); + + let quota = get_coding_plan_quota(&base_url, "k", None, None, None, None, None) + .await + .expect("完整但非法的响应体是确定性失败,必须保持 Ok(success:false)"); + assert!(!quota.success); + let err = quota.error.expect("应有错误文案"); + assert!(err.contains("Failed to parse response"), "err={err}"); + handle.join().expect("server thread"); + } + + // ── 智谱团队套餐(Team Plan)── + + /// 起一个只服务一次连接的本地 HTTP server,捕获原始请求文本(请求行 + 头), + /// 用于断言 team 查询发出的 URL query 与组织/项目请求头。`response=None` 时只捕获不回包。 + fn spawn_request_capturing_server( + response: Option, + ) -> ( + String, + std::sync::Arc>>, + std::thread::JoinHandle<()>, + ) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind local listener"); + let port = listener.local_addr().expect("local addr").port(); + let captured = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let captured_clone = captured.clone(); + let handle = std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf: Vec = Vec::new(); + let mut tmp = [0u8; 4096]; + // GET 无 body,读到 header 末尾(\r\n\r\n)即可 + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut tmp) { + Ok(0) | Err(_) => break, + Ok(n) => { + buf.extend_from_slice(&tmp[..n]); + if buf.len() > 16 * 1024 { + break; + } + } + } + } + *captured_clone.lock().unwrap() = Some(String::from_utf8_lossy(&buf).into_owned()); + if let Some(resp) = response { + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + } + } + }); + (format!("http://127.0.0.1:{port}/team"), captured, handle) + } + + #[tokio::test] + async fn zhipu_team_missing_creds_returns_not_found() { + // 团队版必需 api_key + 组织 ID + 项目 ID,缺任一在触网前返回 NotFound(不联网)。 + let cases: [(&str, Option<&str>, Option<&str>); 3] = [ + ("key", Some("org"), None), // 缺 project + ("key", None, Some("proj")), // 缺 organization + (" ", Some("org"), Some("proj")), // 空 api_key + ]; + for (api_key, org, project) in cases { + let q = get_coding_plan_quota( + "https://open.bigmodel.cn/api/coding", + api_key, + None, + None, + Some("zhipu_team"), + org, + project, + ) + .await + .expect("凭据缺失是确定性失败,保持 Ok(success:false)"); + assert!(!q.success, "应失败: api_key={api_key:?}"); + assert!( + matches!(q.credential_status, CredentialStatus::NotFound), + "应为 NotFound: api_key={api_key:?}" + ); + } + + // 标识大小写不敏感(eq_ignore_ascii_case),大写仍命中 team 分支并返回引导文案。 + let msg = get_coding_plan_quota( + "https://open.bigmodel.cn/api/coding", + "key", + None, + None, + Some("Zhipu_Team"), + None, + None, + ) + .await + .expect("ok") + .error + .expect("应有错误文案"); + assert!( + msg.contains("API key + organization ID + project ID"), + "err={msg}" + ); + } + + #[tokio::test] + async fn zhipu_team_request_carries_type2_and_org_project_headers() { + ensure_no_proxy_for_loopback(); + // 响应 shape 与个人版一致:两条 TOKENS_LIMIT(unit 3/6)→ five_hour + weekly。 + let body = serde_json::json!({ + "success": true, + "data": { + "level": "max", + "limits": [ + { "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 26.0 }, + { "type": "TOKENS_LIMIT", "unit": 6, "number": 1, "percentage": 5.0 } + ] + } + }); + let body_str = body.to_string(); + let resp = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body_str}", + body_str.len() + ); + let (base, captured, handle) = spawn_request_capturing_server(Some(resp)); + + let quota = query_zhipu_team_at(&base, "team-key", "org-xxx", "proj_xxx") + .await + .expect("2xx + 合法 body 应成功"); + handle.join().expect("server thread"); + + // 请求形状契约(与 token-monitor zaiTeamLimits 对齐): + // 同路径加 ?type=2 + bigmodel-organization / bigmodel-project 头 + 鉴权头。 + // reqwest/hyper 发头会小写化,故整体转小写做包含匹配。 + let raw = captured.lock().unwrap().clone().expect("应捕获到请求"); + let raw_lc = raw.to_lowercase(); + assert!(raw_lc.contains("/team?type=2"), "缺 ?type=2: {raw}"); + assert!( + raw_lc.contains("bigmodel-organization: org-xxx"), + "缺组织头: {raw}" + ); + assert!( + raw_lc.contains("bigmodel-project: proj_xxx"), + "缺项目头: {raw}" + ); + assert!( + raw_lc.contains("authorization: team-key"), + "缺鉴权头: {raw}" + ); + + // 解析复用个人版 zhipu_quota_from_body / parse_zhipu_token_tiers。 + assert!(quota.success); + assert_eq!(quota.tiers.len(), 2); + assert_eq!(quota.tiers[0].name, TIER_FIVE_HOUR); + assert_eq!(quota.tiers[0].utilization, 26.0); + assert_eq!(quota.tiers[1].name, TIER_WEEKLY_LIMIT); + assert_eq!(quota.tiers[1].utilization, 5.0); + } } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 93565d28f..7d465810a 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -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 @@ -159,9 +161,7 @@ impl ConfigService { } let cfg_text = settings.get("config").and_then(Value::as_str); - let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format( - provider.meta.as_ref().and_then(|m| m.api_format.as_deref()), - ); + let profile = crate::proxy::providers::resolve_codex_catalog_tool_profile(provider); crate::codex_config::write_codex_provider_live_with_catalog( &provider.settings_config, diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index ed07c56fe..b57591b82 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -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)?; } @@ -176,21 +187,55 @@ impl McpService { Ok(()) } - /// 手动同步所有启用的 MCP 服务器到对应的应用 + /// 手动同步所有启用的 MCP 服务器到对应的应用。 + /// + /// Best-effort:单个应用投影失败(如 ~/.claude.json 坏 JSON)不阻断 + /// 其余应用——各应用的 live 文件互相独立,一处损坏没有理由让其他 + /// 应用的 MCP 状态陈旧。全部跑完后若有失败,聚合成一个错误上报, + /// 保留调用方的可见性。 pub fn sync_all_enabled(state: &AppState) -> Result<(), AppError> { let servers = Self::get_all_servers(state)?; + let mut failures: Vec = Vec::new(); for app in AppType::all() { - if matches!(app, AppType::OpenClaw | AppType::ClaudeDesktop) { - continue; + if let Err(err) = Self::project_servers_to_app(state, &servers, &app) { + log::warn!("同步 MCP 到 {app:?} 失败: {err}"); + failures.push(format!("{}: {err}", app.as_str())); } + } - for server in servers.values() { - if server.apps.is_enabled_for(&app) { - Self::sync_server_to_app(state, server, &app)?; - } else { - Self::remove_server_from_app(state, &server.id, &app)?; - } + if failures.is_empty() { + Ok(()) + } else { + Err(AppError::Message(format!( + "部分应用 MCP 同步失败: {}", + failures.join("; ") + ))) + } + } + + /// 只把启用状态投影到单个应用。某个应用的 live 被整体重写后用它做 + /// 定向重投影,避免把无关应用的失败面(如 ~/.claude.json 坏 JSON) + /// 牵连进目标应用的关键路径。 + pub fn sync_enabled_for_app(state: &AppState, app: &AppType) -> Result<(), AppError> { + let servers = Self::get_all_servers(state)?; + Self::project_servers_to_app(state, &servers, app) + } + + fn project_servers_to_app( + state: &AppState, + servers: &IndexMap, + app: &AppType, + ) -> Result<(), AppError> { + if matches!(app, AppType::OpenClaw | AppType::ClaudeDesktop) { + return Ok(()); + } + + for server in servers.values() { + if server.apps.is_enabled_for(app) { + Self::sync_server_to_app(state, server, app)?; + } else { + Self::remove_server_from_app(state, &server.id, app)?; } } @@ -359,6 +404,32 @@ impl McpService { Ok(new_count) } + /// 从 Grok Build 的 `[mcp_servers]` 导入 MCP。 + pub fn import_from_grokbuild(state: &AppState) -> Result { + 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 { // 创建临时 MultiAppConfig 用于导入 @@ -434,4 +505,42 @@ impl McpService { Ok(new_count) } + + /// 从所有支持 MCP 的应用导入服务器,返回新导入的数量。 + /// + /// Best-effort:单个应用导入失败(如坏 config.toml)不阻断其余应用; + /// 全部跑完后若有失败,聚合成一个错误上报——历史实现逐应用 + /// `unwrap_or(0)` 吞错,坏文件只会表现为"导入成功 0 个",用户 + /// 无从得知哪个应用出了问题。 + pub fn import_from_all_apps(state: &AppState) -> Result { + let mut total = 0; + let mut failures: Vec = Vec::new(); + + let results: [(&str, Result); 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)), + ]; + for (app, result) in results { + match result { + Ok(count) => total += count, + Err(err) => { + log::warn!("从 {app} 导入 MCP 失败: {err}"); + failures.push(format!("{app}: {err}")); + } + } + } + + if failures.is_empty() { + Ok(total) + } else { + Err(AppError::Message(format!( + "已导入 {total} 个,部分应用导入失败: {}", + failures.join("; ") + ))) + } + } } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index ae43e079c..838c93b57 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -7,6 +7,7 @@ pub mod env_manager; pub mod mcp; pub mod model_fetch; pub mod omo; +pub mod profile; pub mod prompt; pub mod provider; pub mod proxy; diff --git a/src-tauri/src/services/profile.rs b/src-tauri/src/services/profile.rs new file mode 100644 index 000000000..cd12423f2 --- /dev/null +++ b/src-tauri/src/services/profile.rs @@ -0,0 +1,656 @@ +//! 项目 Profile 编排服务 +//! +//! Profile 是**全应用共享的项目实体**(用户拥有的项目就那几个),payload +//! 按 app 分槽存配置快照(供应商 / MCP / Skills / Prompt)。快照与应用 +//! 均**按分组(scope)操作**:Claude Code 与 Codex 的工作目录往往不同 +//! (各在各的项目里),因此各组独立指向自己的当前项目、只拍/只应用组内 +//! 槽位,互不牵连;重命名/删除作用于共享实体本身。 +//! 应用(apply)时复用现有切换原语批量落地: +//! - 供应商:`ProviderService::switch`(内建代理接管热切换与接管下禁切官方) +//! - MCP:`McpService::toggle_app`(改标志 + 单 server 物化) +//! - Skills:`SkillService::toggle_app`(改标志 + 单 skill 物化) +//! - Prompt:`PromptService::enable_prompt`(互斥激活 + 原子写 live) +//! +//! apply 为 best-effort:单项失败收集为 warning 继续,不整体回滚。 + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +use crate::app_config::AppType; +use crate::database::Profile; +use crate::error::AppError; +use crate::services::{McpService, PromptService, ProviderService, SkillService}; +use crate::store::AppState; + +/// Profile 操作的应用分组:项目实体全应用共享,但快照/应用/当前指针按组进行。 +/// +/// Claude Code 与 Claude Desktop 的供应商在 cc-switch 中是独立切换的, +/// 因此各自拥有独立的项目分组。两者 live 文件零交集 +///(`~/.claude` / `Application Support/Claude-3p`),分组切换互不干扰。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProfileScope { + Claude, + #[serde(rename = "claude-desktop")] + ClaudeDesktop, + Codex, +} + +impl ProfileScope { + /// 全部分组(扩展新分组时同步扩展 apps/for_app 与前端 scope.ts 镜像) + pub const ALL: [ProfileScope; 3] = [ + ProfileScope::Claude, + ProfileScope::ClaudeDesktop, + ProfileScope::Codex, + ]; + + pub fn as_str(&self) -> &'static str { + match self { + ProfileScope::Claude => "claude", + ProfileScope::ClaudeDesktop => "claude-desktop", + ProfileScope::Codex => "codex", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "claude" => Ok(ProfileScope::Claude), + "claude-desktop" => Ok(ProfileScope::ClaudeDesktop), + "codex" => Ok(ProfileScope::Codex), + other => Err(AppError::InvalidInput(format!( + "Unknown profile scope: {other}" + ))), + } + } + + /// 组内受管应用(快照与 apply 只作用于这些 app 的槽位) + pub fn apps(&self) -> &'static [AppType] { + match self { + ProfileScope::Claude => &[AppType::Claude], + ProfileScope::ClaudeDesktop => &[AppType::ClaudeDesktop], + ProfileScope::Codex => &[AppType::Codex], + } + } + + /// 应用页 → 所属分组(Profile 不支持的应用返回 None) + pub fn for_app(app: &AppType) -> Option { + match app { + AppType::Claude => Some(ProfileScope::Claude), + AppType::ClaudeDesktop => Some(ProfileScope::ClaudeDesktop), + AppType::Codex => Some(ProfileScope::Codex), + _ => None, + } + } +} + +/// 按 app 分槽的载荷容器;字段名与 AppType 的 serde 形式一致 +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PerApp { + pub claude: T, + #[serde(rename = "claude-desktop")] + pub claude_desktop: T, + pub codex: T, +} + +impl PerApp { + pub fn get(&self, app: &AppType) -> Option<&T> { + match app { + AppType::Claude => Some(&self.claude), + AppType::ClaudeDesktop => Some(&self.claude_desktop), + AppType::Codex => Some(&self.codex), + _ => None, + } + } + + pub fn get_mut(&mut self, app: &AppType) -> Option<&mut T> { + match app { + AppType::Claude => Some(&mut self.claude), + AppType::ClaudeDesktop => Some(&mut self.claude_desktop), + AppType::Codex => Some(&mut self.codex), + _ => None, + } + } +} + +/// Profile 的 JSON 快照结构(与前端 TS 类型严格对应) +/// +/// 所有槽位都是 Option:None = 该侧从未拍过快照(应用时不动), +/// 与"拍到的就是空集/无激活项"(Some(空),应用时清空启用)严格区分—— +/// 在 Codex 页选中一个只在 Claude 页建过的项目不能误清 Codex 的启用状态。 +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ProfilePayload { + /// 每 app 的当前供应商 id + pub providers: PerApp>, + /// 每 app 启用的 MCP server id 集合 + pub mcp: PerApp>>, + /// 每 app 启用的 Skill id 集合 + pub skills: PerApp>>, + /// 每 app 激活的 prompt id + pub prompts: PerApp>, +} + +impl ProfilePayload { + /// 用另一份快照覆盖本载荷中某分组的槽位,其余分组原样保留 + /// ("以当前状态更新"只更新发起页所属分组,避免把别的应用 + /// 正处于其他项目的状态串进来) + pub fn merge_scope_from(&mut self, other: &ProfilePayload, scope: ProfileScope) { + for app in scope.apps() { + if let (Some(dst), Some(src)) = (self.providers.get_mut(app), other.providers.get(app)) + { + *dst = src.clone(); + } + if let (Some(dst), Some(src)) = (self.mcp.get_mut(app), other.mcp.get(app)) { + *dst = src.clone(); + } + if let (Some(dst), Some(src)) = (self.skills.get_mut(app), other.skills.get(app)) { + *dst = src.clone(); + } + if let (Some(dst), Some(src)) = (self.prompts.get_mut(app), other.prompts.get(app)) { + *dst = src.clone(); + } + } + } + + /// 某分组是否拍过快照(任一槽位非 None 即视为拍过) + pub fn scope_captured(&self, scope: ProfileScope) -> bool { + scope.apps().iter().any(|app| { + self.providers.get(app).is_some_and(|s| s.is_some()) + || self.mcp.get(app).is_some_and(|s| s.is_some()) + || self.skills.get(app).is_some_and(|s| s.is_some()) + || self.prompts.get(app).is_some_and(|s| s.is_some()) + }) + } +} + +/// 计算从当前启用状态到目标集合的最小 toggle 集 +/// +/// 返回 (需要执行的 (id, enabled) 列表, payload 中已不存在于 DB 的悬空 id 列表) +fn plan_toggles( + current: &[(String, bool)], + target_ids: &[String], +) -> (Vec<(String, bool)>, Vec) { + let existing: HashSet<&str> = current.iter().map(|(id, _)| id.as_str()).collect(); + let target: HashSet<&str> = target_ids.iter().map(|s| s.as_str()).collect(); + + let toggles = current + .iter() + .filter(|(id, enabled)| target.contains(id.as_str()) != *enabled) + .map(|(id, enabled)| (id.clone(), !enabled)) + .collect(); + + let dangling = target_ids + .iter() + .filter(|id| !existing.contains(id.as_str())) + .cloned() + .collect(); + + (toggles, dangling) +} + +pub struct ProfileService; + +impl ProfileService { + /// 抓取分组内应用的当前配置状态生成快照(组外槽位保持默认值) + pub fn snapshot_current( + state: &AppState, + scope: ProfileScope, + ) -> Result { + let mut payload = ProfilePayload::default(); + let mcp_servers = state.db.get_all_mcp_servers()?; + let skills = state.db.get_all_installed_skills()?; + + for app in scope.apps().iter() { + if let Some(slot) = payload.providers.get_mut(app) { + *slot = crate::settings::get_effective_current_provider(&state.db, app)?; + } + if let Some(slot) = payload.mcp.get_mut(app) { + *slot = Some( + mcp_servers + .values() + .filter(|s| s.apps.is_enabled_for(app)) + .map(|s| s.id.clone()) + .collect(), + ); + } + if let Some(slot) = payload.skills.get_mut(app) { + *slot = Some( + skills + .values() + .filter(|s| s.apps.is_enabled_for(app)) + .map(|s| s.id.clone()) + .collect(), + ); + } + if let Some(slot) = payload.prompts.get_mut(app) { + *slot = state + .db + .get_prompts(app.as_str())? + .values() + .find(|p| p.enabled) + .map(|p| p.id.clone()); + } + } + Ok(payload) + } + + /// 列出所有项目(项目实体全应用共享,current 标记按分组单独读取) + pub fn list(state: &AppState) -> Result, AppError> { + state.db.get_all_profiles() + } + + /// 创建新项目:只拍发起页所属分组的当前状态,其余分组槽位留 None + /// (其他应用可能正处于别的项目,不能替用户拍进来) + pub fn create(state: &AppState, name: &str, scope: ProfileScope) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(AppError::InvalidInput("Profile name is empty".to_string())); + } + let payload = Self::snapshot_current(state, scope)?; + let now = chrono::Utc::now().timestamp(); + let profile = Profile { + id: uuid::Uuid::new_v4().to_string(), + name: name.to_string(), + payload: serde_json::to_string(&payload) + .map_err(|e| AppError::Config(format!("序列化 profile payload 失败: {e}")))?, + sort_order: None, + created_at: Some(now), + updated_at: Some(now), + }; + state.db.save_profile(&profile)?; + Ok(profile) + } + + /// 更新项目:重命名(作用于共享实体)和/或以当前状态重拍快照 + /// (resnapshot 只覆盖 scope 分组的槽位,其余分组原样保留; + /// 快照重拍仅由 [`Self::apply`] 切换前的自动保存触发,UI 不再暴露手动入口) + pub fn update( + state: &AppState, + id: &str, + name: Option, + resnapshot: bool, + scope: Option, + ) -> Result { + let mut profile = state + .db + .get_profile(id)? + .ok_or_else(|| AppError::InvalidInput(format!("Profile not found: {id}")))?; + + if let Some(name) = name { + let name = name.trim().to_string(); + if name.is_empty() { + return Err(AppError::InvalidInput("Profile name is empty".to_string())); + } + profile.name = name; + } + if resnapshot { + let scope = scope.ok_or_else(|| { + AppError::InvalidInput("Resnapshot requires a profile scope".to_string()) + })?; + let mut payload: ProfilePayload = serde_json::from_str(&profile.payload) + .map_err(|e| AppError::Config(format!("解析 profile payload 失败: {e}")))?; + payload.merge_scope_from(&Self::snapshot_current(state, scope)?, scope); + profile.payload = serde_json::to_string(&payload) + .map_err(|e| AppError::Config(format!("序列化 profile payload 失败: {e}")))?; + } + profile.updated_at = Some(chrono::Utc::now().timestamp()); + state.db.save_profile(&profile)?; + Ok(profile) + } + + /// 删除项目;若删除的是某分组当前激活项目,一并清除该分组的激活标记 + pub fn delete(state: &AppState, id: &str) -> Result<(), AppError> { + state.db.delete_profile(id)?; + for scope in ProfileScope::ALL { + if state.db.get_current_profile_id(scope.as_str())?.as_deref() == Some(id) { + state.db.set_current_profile_id(scope.as_str(), None)?; + } + } + Ok(()) + } + + /// 应用项目快照(best-effort,返回 warnings) + /// + /// 只作用于发起页所属分组内的应用,不碰其他分组的配置与 current 标记。 + /// 该分组从未拍过快照时不改动任何配置,仅标记 current 并返回提示 + /// (下次从该项目切走时,自动保存会补拍该侧快照)。 + /// + /// **切换前会自动保存旧项目**:若当前分组已绑定到另一个项目,先把当前 + /// 状态写入那个旧项目(仅当前分组槽位),再加载目标项目。这样切走后 + /// 旧项目仍保留离开时的配置,回来时状态一致。自动保存失败时作为 warning + /// 继续,不阻塞切换。 + /// + /// 应用指定项目的快照到当前分组内的所有应用。 + /// + /// 返回 `(warnings, should_stop_proxy)`:当当前分组内所有接管都被关闭、且 + /// 其它应用也没有接管时,建议调用者停止代理服务,以便 Claude Desktop 的 + /// "本地路由"总开关同步显示为关闭。 + pub fn apply( + state: &AppState, + profile_id: &str, + scope: ProfileScope, + ) -> Result<(Vec, bool), AppError> { + let mut warnings = Vec::new(); + + // 自动保存旧项目当前状态(仅当前分组),失败不阻塞切换 + if let Some(current_id) = state.db.get_current_profile_id(scope.as_str())? { + if current_id != profile_id { + if let Err(e) = Self::update(state, ¤t_id, None, true, Some(scope)) { + warnings.push(format!( + "autosave profile '{current_id}' before switch failed: {e}" + )); + } + } + } + + let profile = state + .db + .get_profile(profile_id)? + .ok_or_else(|| AppError::InvalidInput(format!("Profile not found: {profile_id}")))?; + let payload: ProfilePayload = serde_json::from_str(&profile.payload) + .map_err(|e| AppError::Config(format!("解析 profile payload 失败: {e}")))?; + + if !payload.scope_captured(scope) { + warnings.push(format!( + "no {} configuration captured in this project yet; marked as current without changes (it will be saved automatically when you switch away)", + scope.as_str() + )); + } + + for app in scope.apps().iter() { + let app_str = app.as_str(); + + // 1. 切换项目前无条件关闭当前应用的代理接管。 + // 接管态下 live 文件属于代理;用户希望切换工作目录时总是退出当前 + // 代理环境,再按快照写入真实供应商配置。 + if let Err(e) = state.proxy_service.disable_takeover_for_app_sync(app) { + warnings.push(format!( + "[{app_str}] auto-disable proxy takeover before profile switch failed: {e}" + )); + } + + // 2. 供应商 + if let Some(Some(target_pid)) = payload.providers.get(app) { + let providers = state.db.get_all_providers(app_str)?; + if !providers.contains_key(target_pid) { + warnings.push(format!( + "[{app_str}] provider '{target_pid}' no longer exists, skipped" + )); + } else { + let current = crate::settings::get_effective_current_provider(&state.db, app)?; + if current.as_deref() != Some(target_pid.as_str()) { + match ProviderService::switch(state, app.clone(), target_pid) { + Ok(result) => warnings.extend(result.warnings), + Err(e) => warnings.push(format!( + "[{app_str}] switch provider '{target_pid}' failed: {e}" + )), + } + } + } + } + + // 3. MCP diff(最小 toggle:仅动目标态≠当前态的条目;None = 该侧未拍过,不动) + if let Some(Some(target_ids)) = payload.mcp.get(app) { + let servers = state.db.get_all_mcp_servers()?; + let current: Vec<(String, bool)> = servers + .values() + .map(|s| (s.id.clone(), s.apps.is_enabled_for(app))) + .collect(); + let (toggles, dangling) = plan_toggles(¤t, target_ids); + for id in dangling { + warnings.push(format!("[{app_str}] MCP '{id}' no longer exists, skipped")); + } + for (id, enabled) in toggles { + if let Err(e) = McpService::toggle_app(state, &id, app.clone(), enabled) { + warnings.push(format!( + "[{app_str}] toggle MCP '{id}' -> {enabled} failed: {e}" + )); + } + } + } + + // 4. Skills diff(SkillService 返回 anyhow::Result,收进 warning) + if let Some(Some(target_ids)) = payload.skills.get(app) { + let skills = state.db.get_all_installed_skills()?; + let current: Vec<(String, bool)> = skills + .values() + .map(|s| (s.id.clone(), s.apps.is_enabled_for(app))) + .collect(); + let (toggles, dangling) = plan_toggles(¤t, target_ids); + for id in dangling { + warnings.push(format!( + "[{app_str}] skill '{id}' no longer exists, skipped" + )); + } + for (id, enabled) in toggles { + if let Err(e) = SkillService::toggle_app(&state.db, &id, app, enabled) { + warnings.push(format!( + "[{app_str}] toggle skill '{id}' -> {enabled} failed: {e}" + )); + } + } + } + + // 5. Prompt(None = 不动;已激活则幂等跳过,避免无谓的文件写与备份) + if let Some(Some(target_prompt)) = payload.prompts.get(app) { + let prompts = state.db.get_prompts(app_str)?; + match prompts.get(target_prompt) { + None => warnings.push(format!( + "[{app_str}] prompt '{target_prompt}' no longer exists, skipped" + )), + Some(p) if p.enabled => {} + Some(_) => { + if let Err(e) = + PromptService::enable_prompt(state, app.clone(), target_prompt) + { + warnings.push(format!( + "[{app_str}] enable prompt '{target_prompt}' failed: {e}" + )); + } + } + } + } + } + + state + .db + .set_current_profile_id(scope.as_str(), Some(profile_id))?; + + // 当前分组内所有接管已关闭;若其它应用也无接管,可停止代理服务。 + let should_stop_proxy = !state.db.is_live_takeover_active_sync(); + + Ok((warnings, should_stop_proxy)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn test_payload_serde_roundtrip() { + let payload = ProfilePayload { + providers: PerApp { + claude: Some("p1".into()), + claude_desktop: Some("d1".into()), + codex: None, + }, + mcp: PerApp { + claude: Some(ids(&["m1", "m2"])), + claude_desktop: Some(vec![]), + codex: None, + }, + skills: PerApp { + claude: Some(vec![]), + claude_desktop: Some(vec![]), + codex: Some(ids(&["s1"])), + }, + prompts: PerApp { + claude: None, + claude_desktop: None, + codex: Some("pr1".into()), + }, + }; + let json = serde_json::to_string(&payload).unwrap(); + // per-app key 必须与 AppType 的 serde 形式一致(claude-desktop 是连字符) + assert!(json.contains("\"claude\"")); + assert!(json.contains("\"claude-desktop\"")); + assert!(json.contains("\"codex\"")); + let back: ProfilePayload = serde_json::from_str(&json).unwrap(); + assert_eq!(back, payload); + } + + #[test] + fn test_payload_tolerates_missing_fields() { + // 前向兼容:旧版/部分字段缺失时应落到 None("该侧未拍过")而不是报错, + // 应用时对缺失槽位不做任何改动 + let back: ProfilePayload = + serde_json::from_str(r#"{"providers":{"claude":"p1"},"mcp":{"claude":["m1"]}}"#) + .unwrap(); + assert_eq!(back.providers.claude, Some("p1".to_string())); + assert_eq!(back.providers.claude_desktop, None); + assert_eq!(back.providers.codex, None); + assert_eq!(back.mcp.claude, Some(ids(&["m1"]))); + assert_eq!(back.mcp.claude_desktop, None); + assert_eq!(back.mcp.codex, None, "missing slot means untouched"); + assert_eq!(back.prompts.codex, None); + + let empty: ProfilePayload = serde_json::from_str("{}").unwrap(); + assert_eq!(empty, ProfilePayload::default()); + } + + #[test] + fn test_merge_scope_from_only_touches_scope_slots() { + // 项目 A:两侧都已拍过快照 + let mut payload = ProfilePayload { + providers: PerApp { + claude: Some("p1".into()), + claude_desktop: Some("d1".into()), + codex: Some("c1".into()), + }, + mcp: PerApp { + claude: Some(ids(&["m1"])), + claude_desktop: Some(vec![]), + codex: Some(ids(&["m9"])), + }, + ..Default::default() + }; + // 在 Claude 页"以当前状态更新":只覆盖 claude 组槽位 + let fresh = ProfilePayload { + providers: PerApp { + claude: Some("p2".into()), + claude_desktop: None, + codex: Some("SHOULD-NOT-LEAK".into()), + }, + mcp: PerApp { + claude: Some(ids(&["m2"])), + claude_desktop: Some(vec![]), + codex: None, + }, + ..Default::default() + }; + payload.merge_scope_from(&fresh, ProfileScope::Claude); + + assert_eq!(payload.providers.claude, Some("p2".to_string())); + assert_eq!( + payload.providers.claude_desktop, + Some("d1".to_string()), + "claude-desktop slot is in its own scope, untouched by claude merge" + ); + assert_eq!(payload.mcp.claude, Some(ids(&["m2"]))); + // codex 侧完好:既没被覆盖也没被 fresh 的值污染 + assert_eq!(payload.providers.codex, Some("c1".to_string())); + assert_eq!(payload.mcp.codex, Some(ids(&["m9"]))); + } + + #[test] + fn test_scope_captured_detects_per_scope_snapshot() { + let mut payload = ProfilePayload::default(); + assert!(!payload.scope_captured(ProfileScope::Claude)); + assert!(!payload.scope_captured(ProfileScope::ClaudeDesktop)); + assert!(!payload.scope_captured(ProfileScope::Codex)); + + // 只拍过 claude 组(哪怕拍到的是空集) + payload.mcp.claude = Some(vec![]); + assert!(payload.scope_captured(ProfileScope::Claude)); + assert!(!payload.scope_captured(ProfileScope::ClaudeDesktop)); + assert!(!payload.scope_captured(ProfileScope::Codex)); + + // Desktop 槽位属于独立的 claude-desktop 组 + let mut desktop_only = ProfilePayload::default(); + desktop_only.providers.claude_desktop = Some("d1".into()); + assert!(desktop_only.scope_captured(ProfileScope::ClaudeDesktop)); + assert!(!desktop_only.scope_captured(ProfileScope::Claude)); + } + + #[test] + fn test_per_app_get_only_supports_profile_apps() { + let per: PerApp> = PerApp::default(); + assert!(per.get(&AppType::Claude).is_some()); + assert!(per.get(&AppType::ClaudeDesktop).is_some()); + assert!(per.get(&AppType::Codex).is_some()); + assert!(per.get(&AppType::Gemini).is_none()); + } + + #[test] + fn test_scope_serde_and_parse_roundtrip() { + for scope in ProfileScope::ALL { + // DB 存储字符串(as_str/parse)与 JSON 序列化必须是同一形式 + assert_eq!( + serde_json::to_string(&scope).unwrap(), + format!("\"{}\"", scope.as_str()) + ); + assert_eq!(ProfileScope::parse(scope.as_str()).unwrap(), scope); + } + assert!(ProfileScope::parse("gemini").is_err()); + assert!(ProfileScope::parse("").is_err()); + } + + #[test] + fn test_scope_app_grouping() { + // Claude Code 与 Claude Desktop 各自独立成组; + // 组内应用与 for_app 反向映射必须一致 + assert_eq!(ProfileScope::Claude.apps(), &[AppType::Claude]); + assert_eq!( + ProfileScope::ClaudeDesktop.apps(), + &[AppType::ClaudeDesktop] + ); + assert_eq!(ProfileScope::Codex.apps(), &[AppType::Codex]); + for scope in ProfileScope::ALL { + for app in scope.apps() { + assert_eq!(ProfileScope::for_app(app), Some(scope)); + } + } + assert_eq!(ProfileScope::for_app(&AppType::Gemini), None); + } + + #[test] + fn test_plan_toggles_minimal_diff() { + let current = vec![ + ("a".to_string(), true), // 目标含 a:不动 + ("b".to_string(), false), // 目标含 b:开 + ("c".to_string(), true), // 目标不含 c:关 + ("d".to_string(), false), // 目标不含 d:不动 + ]; + let (toggles, dangling) = plan_toggles(¤t, &ids(&["a", "b", "ghost"])); + assert_eq!( + toggles, + vec![("b".to_string(), true), ("c".to_string(), false)] + ); + assert_eq!(dangling, ids(&["ghost"])); + } + + #[test] + fn test_plan_toggles_empty_target_disables_all_enabled() { + let current = vec![("a".to_string(), true), ("b".to_string(), false)]; + let (toggles, dangling) = plan_toggles(¤t, &[]); + assert_eq!(toggles, vec![("a".to_string(), false)]); + assert!(dangling.is_empty()); + } +} diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 0266cd01b..659adaaed 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -23,6 +23,148 @@ use super::gemini_auth::{ }; use super::normalize_claude_models_in_value; +/// ChatGPT Codex catalogs gpt-5.6 at a 372K context window with a ~353K +/// effective budget (openai/codex#31860), far below the 1.05M API spec. +/// Declare the catalog window for both knobs: Claude Code's built-in output +/// reserve and compact buffer already keep the actual compact trigger +/// (~278K-339K) below the effective budget, so anything lower only wastes +/// usable context. +const CODEX_OAUTH_CLAUDE_MAX_CONTEXT_TOKENS: &str = "372000"; +const CODEX_OAUTH_CLAUDE_AUTO_COMPACT_WINDOW: &str = "372000"; +const KIMI_FOR_CODING_CONTEXT_TOKENS: &str = "262144"; + +/// Model env keys Claude Code may route requests through. The defaults above +/// are calibrated against gpt-5.6's Codex catalog, so every configured model +/// must belong to that family before they are injected — gpt-5.5's upstream +/// catalog oscillates between 272K and 372K and must not inherit them. +const CODEX_OAUTH_MODEL_ENV_KEYS: [&str; 6] = [ + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL", + "CLAUDE_CODE_SUBAGENT_MODEL", +]; + +fn provider_env_targets_gpt56(provider_env: Option<&serde_json::Map>) -> bool { + let Some(env) = provider_env else { + return false; + }; + let mut saw_model = false; + for key in CODEX_OAUTH_MODEL_ENV_KEYS { + let Some(value) = env.get(key) else { + continue; + }; + let Some(model) = value.as_str() else { + return false; + }; + let model = model.trim(); + if model.is_empty() { + continue; + } + saw_model = true; + if !model.to_ascii_lowercase().starts_with("gpt-5.6") { + return false; + } + } + saw_model +} + +fn is_kimi_for_coding_provider(provider: &Provider) -> bool { + provider + .settings_config + .pointer("/env/ANTHROPIC_BASE_URL") + .and_then(Value::as_str) + .map(str::trim) + .map(|url| url.trim_end_matches('/')) + == Some("https://api.kimi.com/coding") +} + +/// Claude Code assigns unknown non-Claude model ids a 200K context window. +/// Codex OAuth deliberately exposes GPT ids through Claude Code, so enrich the +/// effective live settings for both newly-created and already-saved providers. +/// Explicit user values always win; the defaults are only injected when every +/// configured model targets gpt-5.6. +fn apply_codex_oauth_claude_context_defaults(settings: &mut Value, provider: &Provider) { + if !provider.is_codex_oauth() { + return; + } + + // Read provider-owned values before mutably borrowing the effective + // settings. This also deliberately prevents a legacy common-config + // snippet from overriding model-specific context limits. + let provider_env = provider + .settings_config + .get("env") + .and_then(Value::as_object); + let Some(root) = settings.as_object_mut() else { + return; + }; + let env = root.entry("env".to_string()).or_insert_with(|| json!({})); + let Some(env) = env.as_object_mut() else { + log::warn!( + "Cannot apply Codex OAuth Claude context defaults for '{}': env is not an object", + provider.id + ); + return; + }; + + let inject_defaults = provider_env_targets_gpt56(provider_env); + for (key, default_value) in [ + ( + "CLAUDE_CODE_MAX_CONTEXT_TOKENS", + CODEX_OAUTH_CLAUDE_MAX_CONTEXT_TOKENS, + ), + ( + "CLAUDE_CODE_AUTO_COMPACT_WINDOW", + CODEX_OAUTH_CLAUDE_AUTO_COMPACT_WINDOW, + ), + ] { + match provider_env.and_then(|provider_env| provider_env.get(key)) { + Some(value) => { + env.insert(key.to_string(), value.clone()); + } + None if inject_defaults => { + env.insert(key.to_string(), Value::String(default_value.to_string())); + } + // 老模型不注入默认值,同时剥掉遗留共享片段可能带进来的值 + None => { + env.remove(key); + } + } + } +} + +/// Kimi For Coding serves a 256K window, but Claude Code caps unknown models at +/// 200K unless `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set — and that env is ignored +/// for `claude-`-prefixed ids, so these defaults only bite when the provider also +/// routes the endpoint's `kimi-for-coding` alias (the preset does). Keep the +/// defaults provider-owned so an old shared snippet cannot override them. +fn apply_kimi_for_coding_context_defaults(settings: &mut Value, provider: &Provider) { + if !is_kimi_for_coding_provider(provider) { + return; + } + + let provider_env = provider + .settings_config + .get("env") + .and_then(Value::as_object); + let Some(env) = settings.get_mut("env").and_then(Value::as_object_mut) else { + return; + }; + + for key in [ + "CLAUDE_CODE_MAX_CONTEXT_TOKENS", + "CLAUDE_CODE_AUTO_COMPACT_WINDOW", + ] { + let value = provider_env + .and_then(|provider_env| provider_env.get(key)) + .cloned() + .unwrap_or_else(|| Value::String(KIMI_FOR_CODING_CONTEXT_TOKENS.to_string())); + env.insert(key.to_string(), value); + } +} + pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value { let mut v = settings.clone(); if let Some(obj) = v.as_object_mut() { @@ -308,6 +450,40 @@ fn remove_toml_table_like(target: &mut dyn TableLike, source: &dyn TableLike) { } } +/// 前端表单勾选/取消"使用通用配置"时,对编辑器里的 config.toml 文本做 +/// 结构化合并/剥离。必须在后端用 toml_edit 做:前端 smol-toml 只能 +/// parse → merge → 整文档重序列化,注释全丢、键序重排,还会生成多余的 +/// 空父表头(如 `[model_providers]`)。 +pub fn update_toml_common_config_snippet( + config_toml: &str, + snippet_toml: &str, + enabled: bool, +) -> Result { + let trimmed = snippet_toml.trim(); + if trimmed.is_empty() { + return Ok(config_toml.to_string()); + } + + let mut target_doc = if config_toml.trim().is_empty() { + DocumentMut::new() + } else { + config_toml + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))? + }; + let source_doc = trimmed + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex common config snippet: {e}")))?; + + if enabled { + merge_toml_table_like(target_doc.as_table_mut(), source_doc.as_table()); + } else { + remove_toml_table_like(target_doc.as_table_mut(), source_doc.as_table()); + } + + Ok(target_doc.to_string()) +} + fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: &str) -> bool { let trimmed = snippet.trim(); if trimmed.is_empty() { @@ -349,7 +525,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, } } @@ -419,9 +599,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()), } } @@ -476,9 +658,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()), } } @@ -505,6 +689,11 @@ pub(crate) fn build_effective_settings_with_common_config( } } + if matches!(app_type, AppType::Claude) { + apply_codex_oauth_claude_context_defaults(&mut effective_settings, provider); + apply_kimi_for_coding_context_defaults(&mut effective_settings, provider); + } + Ok(effective_settings) } @@ -613,11 +802,16 @@ fn get_codex_managed_oauth_live_auth_value( ) -> Result { std::thread::spawn(move || { tauri::async_runtime::block_on(async move { - if let Some((refresh_token, id_token)) = + if let Some((refresh_token, id_token, last_refresh_ms)) = crate::codex_config::read_codex_live_auth_refresh_for_account(&account_id) { if let Err(err) = manager - .adopt_account_refresh_token(&account_id, refresh_token, id_token) + .adopt_account_refresh_token( + &account_id, + refresh_token, + id_token, + last_refresh_ms, + ) .await { log::warn!( @@ -659,28 +853,13 @@ pub(crate) fn codex_managed_oauth_live_auth( // 与原生 Codex 浏览器登录的形状对齐:tokens 字段顺序 id_token、access_token、 // refresh_token、account_id,并带顶层 last_refresh。**必须**包含 refresh_token, // 否则 Codex CLI 在 access_token 过期后无法自刷新(“裸跑 codex” 会静默失效)。 - let mut tokens = serde_json::Map::new(); - if let Some(id_token) = id_token { - tokens.insert("id_token".to_string(), Value::String(id_token.to_string())); - } - tokens.insert( - "access_token".to_string(), - Value::String(access_token.to_string()), - ); - tokens.insert( - "refresh_token".to_string(), - Value::String(refresh_token.to_string()), - ); - tokens.insert( - "account_id".to_string(), - Value::String(account_id.to_string()), - ); - json!({ - "auth_mode": "chatgpt", - "OPENAI_API_KEY": null, - "tokens": Value::Object(tokens), - "last_refresh": last_refresh, - }) + crate::codex_config::codex_managed_oauth_auth_value( + account_id, + access_token, + id_token, + refresh_token, + last_refresh, + ) } pub(crate) fn strip_common_config_from_live_settings( @@ -725,11 +904,90 @@ pub(crate) fn strip_common_config_from_live_settings( restore_live_settings_for_provider_backfill(app_type, provider, backfill_settings) } +/// 与 `apply_codex_oauth_claude_context_defaults` 严格对称:注入产物只活在 +/// live,切走回填时必须剥掉,否则程序默认值会固化成供应商的"用户显式值", +/// 之后调整默认值或更换模型时旧值永远压住新默认。仅当"注入会发生且注入的 +/// 就是这个值、且存储配置本来没有显式值"时才剥;用户显式存储的值和手改 +/// live 成其他数字的值都保留。 +fn strip_injected_codex_oauth_context_defaults(settings: &mut Value, provider: &Provider) { + if !provider.is_codex_oauth() { + return; + } + let provider_env = provider + .settings_config + .get("env") + .and_then(Value::as_object); + if !provider_env_targets_gpt56(provider_env) { + return; + } + let Some(env) = settings.get_mut("env").and_then(Value::as_object_mut) else { + return; + }; + for (key, default_value) in [ + ( + "CLAUDE_CODE_MAX_CONTEXT_TOKENS", + CODEX_OAUTH_CLAUDE_MAX_CONTEXT_TOKENS, + ), + ( + "CLAUDE_CODE_AUTO_COMPACT_WINDOW", + CODEX_OAUTH_CLAUDE_AUTO_COMPACT_WINDOW, + ), + ] { + let stored_explicit = provider_env.is_some_and(|e| e.contains_key(key)); + if stored_explicit { + continue; + } + if env.get(key).and_then(Value::as_str) == Some(default_value) { + env.remove(key); + } + } +} + +fn strip_injected_kimi_for_coding_context_defaults(settings: &mut Value, provider: &Provider) { + if !is_kimi_for_coding_provider(provider) { + return; + } + let provider_env = provider + .settings_config + .get("env") + .and_then(Value::as_object); + let Some(env) = settings.get_mut("env").and_then(Value::as_object_mut) else { + return; + }; + for key in [ + "CLAUDE_CODE_MAX_CONTEXT_TOKENS", + "CLAUDE_CODE_AUTO_COMPACT_WINDOW", + ] { + if provider_env.is_some_and(|provider_env| provider_env.contains_key(key)) { + continue; + } + if env.get(key).and_then(Value::as_str) == Some(KIMI_FOR_CODING_CONTEXT_TOKENS) { + env.remove(key); + } + } +} + fn restore_live_settings_for_provider_backfill( app_type: &AppType, provider: &Provider, live_settings: Value, ) -> Value { + if matches!(app_type, AppType::Claude) { + let mut settings = live_settings; + strip_injected_codex_oauth_context_defaults(&mut settings, provider); + 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; } @@ -753,6 +1011,15 @@ fn restore_live_settings_for_provider_backfill( strip_codex_managed_oauth_auth_for_backfill(provider, &mut settings); + // MCP 服务器归 DB mcp_servers 表所有,live 里的 [mcp_servers] 是同步投影; + // 回填时剥掉,否则已删除的服务器会随供应商快照复活(逐条 reconcile 清不掉孤儿)。 + if let Err(err) = crate::codex_config::strip_codex_mcp_servers_from_settings(&mut settings) { + log::warn!( + "Failed to strip mcp_servers while backfilling '{}': {err}", + provider.id + ); + } + // 统一会话开关注入的共享 `custom` 路由只属于 live 配置;切换回填时 // 必须剥掉,否则官方供应商的存储配置被污染,关闭开关后无法还原。 if provider.category.as_deref() == Some("official") { @@ -951,12 +1218,11 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re .ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?; let config_str = obj.get("config").and_then(|v| v.as_str()); - // Native (direct) Responses providers must suppress Codex's freeform - // apply_patch custom tool via the generated catalog; chat/proxy - // providers keep the default tool set. Keyed on provider.meta.apiFormat. - let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format( - provider.meta.as_ref().and_then(|m| m.api_format.as_deref()), - ); + // Native (direct) Responses and Anthropic providers must suppress Codex's + // freeform apply_patch custom tool via the generated catalog; chat/proxy + // providers keep the default tool set. Uses the same Anthropic detection as + // the proxy router (apiFormat meta/settings + TOML wire_api). + let profile = crate::proxy::providers::resolve_codex_catalog_tool_profile(provider); crate::codex_config::write_codex_provider_live_with_catalog( &provider.settings_config, @@ -978,6 +1244,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; @@ -1138,7 +1407,10 @@ pub(crate) fn sync_current_provider_for_app_to_live( } } - McpService::sync_all_enabled(state)?; + // 本函数语义是"把这个应用同步到 live",MCP 重投影也只针对该应用; + // 全量 sync_all_enabled 会把无关应用的 live 损坏牵连进来。投影失败 + // 上抛(不降级):这里没有已变更的 DB 状态需要保护,调用方重试即可。 + McpService::sync_enabled_for_app(state, app_type)?; Ok(()) } @@ -1206,8 +1478,10 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { } } - // MCP sync - McpService::sync_all_enabled(state)?; + // MCP sync(best-effort 逐应用投影,内部已聚合失败)。错误暂存到 + // Skill 同步之后再返回:MCP 的失败不该跳过 Skill 同步,但调用方 + //(配置导入 / 云同步恢复)需要知道结果不完整。 + let mcp_result = McpService::sync_all_enabled(state); // Skill sync for app_type in AppType::all() { @@ -1217,7 +1491,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { } } - Ok(()) + mcp_result } /// Read current live settings for an app type @@ -1303,6 +1577,7 @@ pub fn read_live_settings(app_type: AppType) -> Result { 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}; @@ -1371,6 +1646,11 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result crate::codex_config::read_codex_live_settings()?, + AppType::GrokBuild => { + let mut settings = crate::grok_config::read_grok_live_settings()?; + 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() { @@ -1603,15 +1883,10 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result v, @@ -1621,13 +1896,36 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result { + let display_name = config.name.clone().unwrap_or_else(|| existing.name.clone()); + if existing.settings_config != settings_config || existing.name != display_name + { + let mut provider = existing; + provider.name = display_name; + provider.settings_config = settings_config; + if let Err(e) = state.db.save_provider("opencode", &provider) { + log::warn!( + "Failed to update OpenCode provider '{id}' from live config: {e}" + ); + } else { + updated += 1; + log::info!("Updated OpenCode provider '{id}' from live config"); + } + } + } + Ok(None) => { + log::warn!("OpenCode provider '{id}' disappeared while importing live config") + } + Err(e) => log::warn!("Failed to look up OpenCode provider '{id}': {e}"), + } + continue; + } + // Create provider - let mut provider = Provider::with_id( - id.clone(), - config.name.clone().unwrap_or_else(|| id.clone()), - settings_config, - None, - ); + let display_name = config.name.clone().unwrap_or_else(|| id.clone()); + let mut provider = Provider::with_id(id.clone(), display_name, settings_config, None); provider.meta = Some(crate::provider::ProviderMeta { live_config_managed: Some(true), ..Default::default() @@ -1643,7 +1941,7 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result Result Result v, @@ -1688,6 +1981,30 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result { + if existing.settings_config != settings_config { + let mut provider = existing; + provider.settings_config = settings_config; + if let Err(e) = state.db.save_provider("openclaw", &provider) { + log::warn!( + "Failed to update OpenClaw provider '{id}' from live config: {e}" + ); + } else { + updated += 1; + log::info!("Updated OpenClaw provider '{id}' from live config"); + } + } + } + Ok(None) => { + log::warn!("OpenClaw provider '{id}' disappeared while importing live config") + } + Err(e) => log::warn!("Failed to look up OpenClaw provider '{id}': {e}"), + } + continue; + } + // Determine display name: use first model name if available, otherwise use id let display_name = config .models @@ -1712,7 +2029,7 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result Result Result { + if existing.settings_config != config { + let mut provider = existing; + provider.settings_config = config; + if let Err(e) = state.db.save_provider("hermes", &provider) { + log::warn!( + "Failed to update Hermes provider '{name}' from live config: {e}" + ); + } else { + updated += 1; + log::info!("Updated Hermes provider '{name}' from live config"); + } + } + } + Ok(None) => { + log::warn!("Hermes provider '{name}' disappeared while importing live config") + } + Err(e) => log::warn!("Failed to look up Hermes provider '{name}': {e}"), + } continue; } @@ -1761,7 +2097,7 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result bool { + matches!(app_type, AppType::Codex) + && crate::proxy::providers::is_codex_official_provider(provider) +} + /// 统一会话开关变更后,立即按新开关状态重写当前官方 Codex 供应商的 /// live 配置,使开关即时生效(无需等下一次切换)。 /// 当前供应商非官方(或不存在)时为 no-op:注入只作用于官方配置, @@ -83,6 +92,18 @@ pub fn reapply_current_codex_official_live(state: &AppState) -> Result Provider { + Provider { + id: id.to_string(), + name: format!("Provider {id}"), + settings_config: json!({ + "api": "openai-chat", + "base_url": "https://api.example.com/v1", + "api_key": "test-key", + "models": { + "gpt-4o": { + "name": "GPT-4o" + } + } + }), + website_url: None, + category: Some("custom".to_string()), + created_at: Some(1), + sort_index: Some(0), + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + } + } + fn opencode_provider(id: &str) -> Provider { Provider { id: id.to_string(), @@ -674,6 +723,9 @@ mod tests { "AWS_BEARER_TOKEN_BEDROCK": "bedrock-tok", "ANTHROPIC_BASE_URL": "https://example.com", "ANTHROPIC_MODEL": "claude-x", + "CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini", + "CLAUDE_CODE_MAX_CONTEXT_TOKENS": "400000", + "CLAUDE_CODE_AUTO_COMPACT_WINDOW": "400000", // 可共享、非机密配置(复数 _TOKENS 不应被误剥) "ENABLE_TOOL_SEARCH": "true", "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "8192" @@ -716,6 +768,15 @@ mod tests { // 端点/模型(provider-specific 非机密)也应剥掉 assert!(env.and_then(|e| e.get("ANTHROPIC_BASE_URL")).is_none()); assert!(env.and_then(|e| e.get("ANTHROPIC_MODEL")).is_none()); + assert!(env + .and_then(|e| e.get("CLAUDE_CODE_SUBAGENT_MODEL")) + .is_none()); + assert!(env + .and_then(|e| e.get("CLAUDE_CODE_MAX_CONTEXT_TOKENS")) + .is_none()); + assert!(env + .and_then(|e| e.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW")) + .is_none()); // 可共享的非机密配置必须保留(含复数 _TOKENS 不被误剥) assert_eq!( @@ -732,6 +793,56 @@ mod tests { assert_eq!(value.get("includeCoAuthoredBy"), Some(&json!(false))); } + /// Regression for issue #4272: Fable tier env keys must not enter the shared + /// Claude common-config snippet (same class as haiku/sonnet/opus model pins). + #[test] + fn extract_claude_common_config_strips_fable_model_env_keys() { + let settings = json!({ + "env": { + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped", + "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": "Haiku Mapped", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped[1M]", + "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Sonnet Mapped", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped[1M]", + "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "Opus Mapped", + "ANTHROPIC_DEFAULT_FABLE_MODEL": "deepseek-v4-flash[1M]", + "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME": "deepseek-v4-flash", + "ANTHROPIC_MODEL": "default-mapped", + "ENABLE_TOOL_SEARCH": "true" + }, + "theme": "dark" + }); + + let snippet = ProviderService::extract_claude_common_config(&settings) + .expect("extract should succeed"); + let value: Value = serde_json::from_str(&snippet).expect("snippet is valid JSON"); + let env = value.get("env"); + + for stripped in [ + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", + "ANTHROPIC_DEFAULT_FABLE_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", + "ANTHROPIC_MODEL", + ] { + assert!( + env.and_then(|e| e.get(stripped)).is_none(), + "provider-specific model key {stripped} must not enter common config" + ); + } + + assert_eq!( + env.and_then(|e| e.get("ENABLE_TOOL_SEARCH")) + .and_then(|v| v.as_str()), + Some("true") + ); + assert_eq!(value.get("theme").and_then(|v| v.as_str()), Some("dark")); + } + #[test] fn validate_provider_settings_rejects_negative_cost_multiplier() { let mut provider = Provider::with_id( @@ -781,10 +892,18 @@ mod tests { } #[test] - fn extract_codex_common_config_preserves_mcp_servers_base_url() { + fn extract_codex_common_config_strips_provider_fields_and_injected_artifacts() { + // 顶层 experimental_bearer_token 模拟无活跃路由时的 fallback 注入; + // web_search = "disabled" 是 cc-switch 对黑名单网关注入的哨兵; + // 顶层 wire_api 模拟无 model_provider 时的 fallback 写法; + // [mcp.servers] 是历史错误格式,sync_all_enabled 清不掉它。 let config_toml = r#"model_provider = "azure" model = "gpt-4" +wire_api = "chat" disable_response_storage = true +experimental_bearer_token = "sk-live-secret" +model_catalog_json = "cc-switch-model-catalog.json" +web_search = "disabled" [model_providers.azure] name = "Azure OpenAI" @@ -793,6 +912,9 @@ wire_api = "responses" [mcp_servers.my_server] base_url = "http://localhost:8080" + +[mcp.servers.legacy_server] +command = "legacy-cmd" "#; let settings = json!({ "config": config_toml }); @@ -815,9 +937,51 @@ base_url = "http://localhost:8080" !extracted.contains("[model_providers"), "should remove entire model_providers table" ); + // MCP 归 DB mcp_servers 表所有,不得进共享片段(含历史错误格式 [mcp.servers]) assert!( - extracted.contains("http://localhost:8080"), - "should keep mcp_servers.* base_url" + !extracted.contains("mcp_servers") && !extracted.contains("http://localhost:8080"), + "should strip mcp_servers from the shared snippet, got: {extracted}" + ); + assert!( + !extracted.contains("[mcp") && !extracted.contains("legacy-cmd"), + "should strip the legacy [mcp.servers] form from the shared snippet, got: {extracted}" + ); + // 顶层 wire_api 是供应商路由语义(model_providers 整表已剥, + // 剩余任何 wire_api 都意味着泄漏) + assert!( + !extracted.contains("wire_api"), + "should strip top-level wire_api from the shared snippet, got: {extracted}" + ); + // 注入产物不得进共享片段(bearer token 泄漏为密钥级问题) + assert!( + !extracted.contains("experimental_bearer_token") + && !extracted.contains("sk-live-secret"), + "should strip top-level fallback bearer token, got: {extracted}" + ); + assert!( + !extracted.contains("model_catalog_json"), + "should strip catalog projection pointer, got: {extracted}" + ); + assert!( + !extracted.contains("web_search"), + "should strip the cc-switch web_search disabled sentinel, got: {extracted}" + ); + // 真正可共享的键保留 + assert!( + extracted.contains("disable_response_storage = true"), + "shareable keys must survive extraction, got: {extracted}" + ); + } + + #[test] + fn extract_codex_common_config_keeps_user_set_web_search() { + let config_toml = "web_search = \"enabled\"\ndisable_response_storage = true\n"; + let settings = json!({ "config": config_toml }); + let extracted = ProviderService::extract_codex_common_config(&settings) + .expect("extract should succeed"); + assert!( + extracted.contains("web_search = \"enabled\""), + "a user-set web_search value is a shareable preference, got: {extracted}" ); } @@ -946,6 +1110,135 @@ base_url = "http://localhost:8080" ); } + #[tokio::test] + #[serial] + async fn update_current_codex_provider_refreshes_and_clears_catalog_during_takeover() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let state = AppState::new(db.clone()); + + let mut original = Provider::with_id( + "p1".into(), + "Codex A".into(), + json!({ + "auth": { "OPENAI_API_KEY": "token-a" }, + "config": r#"model_provider = "custom" +model = "old-model" + +[model_providers.custom] +name = "Codex A" +base_url = "https://api.a.example/v1" +wire_api = "responses" +requires_openai_auth = true +"#, + "modelCatalog": { + "models": [{ "model": "old-model" }] + } + }), + None, + ); + original.meta = Some(ProviderMeta { + api_format: Some("openai_responses".into()), + ..Default::default() + }); + db.save_provider("codex", &original).expect("save provider"); + db.set_current_provider("codex", "p1") + .expect("set current provider"); + crate::settings::set_current_provider(&AppType::Codex, Some("p1")) + .expect("set local current provider"); + + db.update_proxy_config(ProxyConfig { + live_takeover_active: true, + listen_port: 0, + ..Default::default() + }) + .await + .expect("update proxy config"); + { + let mut config = db + .get_proxy_config_for_app("codex") + .await + .expect("get app proxy config"); + config.enabled = true; + db.update_proxy_config_for_app(config) + .await + .expect("enable Codex proxy config"); + } + db.save_live_backup( + "codex", + &serde_json::to_string(&original.settings_config).expect("serialize backup"), + ) + .await + .expect("seed live backup"); + + state + .proxy_service + .start() + .await + .expect("start proxy service"); + state + .proxy_service + .sync_codex_live_from_provider_while_proxy_active(&original) + .await + .expect("seed taken-over Codex live config"); + assert!( + state + .proxy_service + .detect_takeover_in_live_config_for_app(&AppType::Codex), + "seeded Codex live config should be recognized as takeover-owned" + ); + + let mut updated = original.clone(); + updated.settings_config["config"] = json!( + r#"model_provider = "custom" +model = "gpt-5.4" + +[model_providers.custom] +name = "Codex A" +base_url = "https://api.updated.example/v1" +wire_api = "responses" +requires_openai_auth = true +"# + ); + updated.settings_config["modelCatalog"] = json!({ + "models": [{ "model": "gpt-5.4", "displayName": "GPT 5.4" }] + }); + + ProviderService::update(&state, AppType::Codex, None, updated.clone()) + .expect("update current Codex provider mapping"); + + let catalog_path = crate::codex_config::get_codex_model_catalog_path(); + let catalog: Value = read_json_file(&catalog_path).expect("read generated catalog"); + assert_eq!(catalog["models"][0]["slug"], "gpt-5.4"); + assert_eq!( + catalog["models"][0]["input_modalities"], + json!(["text", "image"]), + "unknown/GPT models must fail open to image input" + ); + let live_config = fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read Codex config.toml"); + assert!(live_config.contains("model_catalog_json")); + + updated.settings_config["modelCatalog"] = json!({ "models": [] }); + ProviderService::update(&state, AppType::Codex, None, updated) + .expect("remove current Codex provider mapping"); + + let live_config = fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read Codex config.toml after mapping removal"); + assert!( + !live_config.contains("model_catalog_json"), + "removing mappings during takeover must clear the stale catalog pointer" + ); + + state + .proxy_service + .stop() + .await + .expect("stop proxy service"); + } + #[cfg(any(target_os = "macos", windows))] #[tokio::test] #[serial] @@ -1353,6 +1646,433 @@ base_url = "http://localhost:8080" }); } + #[test] + #[serial] + fn managed_codex_switch_db_current_failure_restores_live_bundle_and_current() { + with_test_home(|state, _| { + crate::settings::reload_settings().expect("reload settings"); + tauri::async_runtime::block_on(async { + state + .codex_oauth_manager + .add_test_account_with_access_token( + "acct-managed-a", + "managed-token-a", + Some("managed-id-token-a"), + ) + .await + .expect("seed first managed Codex OAuth account"); + state + .codex_oauth_manager + .add_test_account_with_access_token( + "acct-managed-b", + "managed-token-b", + Some("managed-id-token-b"), + ) + .await + .expect("seed second managed Codex OAuth account"); + }); + + let managed_provider = |id: &str, account_id: &str, model: &str| { + let mut provider = Provider::with_id( + id.to_string(), + format!("Managed {id}"), + json!({ + "auth": {}, + "config": format!("model = \"{model}\"\n"), + "modelCatalog": { + "models": [{ "model": model }] + } + }), + None, + ); + provider.category = Some("official".to_string()); + provider.meta = Some(ProviderMeta { + auth_binding: Some(AuthBinding { + source: AuthBindingSource::ManagedAccount, + auth_provider: Some("codex_oauth".to_string()), + account_id: Some(account_id.to_string()), + }), + ..Default::default() + }); + provider + }; + + let provider_a = managed_provider("managed-a", "acct-managed-a", "gpt-5.4-managed-a"); + let provider_b = managed_provider("managed-b", "acct-managed-b", "gpt-5.4-managed-b"); + state + .db + .save_provider(AppType::Codex.as_str(), &provider_a) + .expect("save first managed provider"); + state + .db + .save_provider(AppType::Codex.as_str(), &provider_b) + .expect("save second managed provider"); + + ProviderService::switch(state, AppType::Codex, &provider_a.id) + .expect("activate first managed provider"); + let auth_before: Value = read_json_file(&crate::codex_config::get_codex_auth_path()) + .expect("read first managed auth"); + assert!( + crate::codex_config::get_codex_config_path().exists(), + "baseline must include config.toml" + ); + assert!( + crate::codex_config::get_codex_model_catalog_path().exists(), + "baseline must include the generated model catalog" + ); + assert!( + crate::codex_config::codex_auth_matches_recorded_managed_oauth( + &auth_before, + "acct-managed-a", + ) + .expect("check first managed auth marker"), + "baseline must include a marker owned by the first managed account" + ); + let live_before = crate::codex_config::CodexLiveStateSnapshot::capture() + .expect("capture auth/config/catalog/marker before failed switch"); + + { + let conn = state.db.conn.lock().expect("lock database"); + conn.execute_batch( + "CREATE TRIGGER reject_managed_b_current_update + BEFORE UPDATE OF is_current ON providers + WHEN NEW.app_type = 'codex' + AND NEW.id = 'managed-b' + AND NEW.is_current = 1 + BEGIN + SELECT RAISE(ABORT, 'forced managed Codex current failure'); + END;", + ) + .expect("install current-provider failure trigger"); + } + + let error = ProviderService::switch(state, AppType::Codex, &provider_b.id) + .expect_err("DB current failure should abort managed switch"); + assert!( + error + .to_string() + .contains("forced managed Codex current failure"), + "switch should surface the DB commit failure, got: {error}" + ); + + let live_after = crate::codex_config::CodexLiveStateSnapshot::capture() + .expect("capture auth/config/catalog/marker after rollback"); + assert_eq!( + live_after, live_before, + "failed switch must exactly restore auth, config, catalog, and managed marker" + ); + assert_eq!( + crate::settings::get_current_provider(&AppType::Codex).as_deref(), + Some(provider_a.id.as_str()), + "failed switch must restore the device-local current provider" + ); + assert_eq!( + state + .db + .get_current_provider(AppType::Codex.as_str()) + .expect("read DB current after rollback") + .as_deref(), + Some(provider_a.id.as_str()), + "failed switch must keep the DB current provider unchanged" + ); + }); + } + + #[test] + #[serial] + fn managed_codex_takeover_update_db_failure_restores_backup_live_and_binding() { + with_test_home(|state, _| { + crate::settings::reload_settings().expect("reload settings"); + tauri::async_runtime::block_on(async { + state + .codex_oauth_manager + .add_test_account_with_access_token( + "acct-managed-a", + "managed-token-a", + Some("managed-id-a"), + ) + .await + .expect("seed managed account A"); + state + .codex_oauth_manager + .add_test_account_with_access_token( + "acct-managed-b", + "managed-token-b", + Some("managed-id-b"), + ) + .await + .expect("seed managed account B"); + }); + + let mut provider = Provider::with_id( + crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), + "OpenAI Official A".to_string(), + json!({ + "auth": {}, + "config": "model = \"gpt-5.4\"\n" + }), + None, + ); + provider.category = Some("official".to_string()); + provider.meta = Some(ProviderMeta { + auth_binding: Some(AuthBinding { + source: AuthBindingSource::ManagedAccount, + auth_provider: Some("codex_oauth".to_string()), + account_id: Some("acct-managed-a".to_string()), + }), + ..Default::default() + }); + state + .db + .save_provider(AppType::Codex.as_str(), &provider) + .expect("save official provider A"); + state + .db + .set_current_provider(AppType::Codex.as_str(), &provider.id) + .expect("set DB current"); + crate::settings::set_current_provider(&AppType::Codex, Some(&provider.id)) + .expect("set local current"); + + tauri::async_runtime::block_on(async { + state + .db + .update_proxy_config(ProxyConfig { + listen_port: 15_721, + ..Default::default() + }) + .await + .expect("set proxy port"); + state + .db + .save_live_backup( + AppType::Codex.as_str(), + &serde_json::to_string(&json!({ + "config": "model = \"gpt-5.4\"\n" + })) + .expect("serialize baseline backup"), + ) + .await + .expect("save baseline backup"); + state + .proxy_service + .sync_codex_live_from_provider_while_proxy_active(&provider) + .await + .expect("seed managed takeover live"); + }); + + let backup_before = + tauri::async_runtime::block_on(state.db.get_live_backup(AppType::Codex.as_str())) + .expect("read baseline backup") + .expect("baseline backup exists"); + let live_before = crate::codex_config::CodexLiveStateSnapshot::capture() + .expect("capture managed takeover live"); + + { + let conn = state.db.conn.lock().expect("lock database"); + conn.execute_batch( + "CREATE TRIGGER reject_managed_takeover_provider_update + BEFORE UPDATE ON providers + WHEN NEW.app_type = 'codex' + AND NEW.id = 'codex-official' + AND NEW.name = 'OpenAI Official B' + BEGIN + SELECT RAISE(ABORT, 'forced managed takeover provider failure'); + END;", + ) + .expect("install provider failure trigger"); + } + + let mut updated = provider.clone(); + updated.name = "OpenAI Official B".to_string(); + updated + .meta + .as_mut() + .and_then(|meta| meta.auth_binding.as_mut()) + .expect("managed binding") + .account_id = Some("acct-managed-b".to_string()); + + let error = ProviderService::update(state, AppType::Codex, None, updated) + .expect_err("DB failure should abort takeover update"); + assert!( + error + .to_string() + .contains("forced managed takeover provider failure"), + "update should surface DB failure: {error}" + ); + + let saved = state + .db + .get_provider_by_id(&provider.id, AppType::Codex.as_str()) + .expect("read provider after rollback") + .expect("provider still exists"); + assert_eq!(saved.name, "OpenAI Official A"); + assert_eq!( + saved + .meta + .as_ref() + .and_then(|meta| meta.managed_account_id_for("codex_oauth")), + Some("acct-managed-a".to_string()) + ); + + let backup_after = + tauri::async_runtime::block_on(state.db.get_live_backup(AppType::Codex.as_str())) + .expect("read backup after rollback") + .expect("backup still exists"); + assert_eq!(backup_after.original_config, backup_before.original_config); + assert_eq!( + crate::codex_config::CodexLiveStateSnapshot::capture() + .expect("capture live after rollback"), + live_before, + "failed takeover update must restore auth/config/catalog/marker exactly" + ); + }); + } + + #[test] + #[serial] + fn managed_codex_update_rechecks_current_after_waiting_for_switch_lock() { + with_test_home(|state, _| { + crate::settings::reload_settings().expect("reload settings"); + tauri::async_runtime::block_on(async { + state + .codex_oauth_manager + .add_test_account_with_access_token( + "acct-managed-a", + "managed-token-a", + Some("managed-id-a"), + ) + .await + .expect("seed managed account A"); + state + .codex_oauth_manager + .add_test_account_with_access_token( + "acct-managed-b", + "managed-token-b", + Some("managed-id-b"), + ) + .await + .expect("seed managed account B"); + }); + + let mut official = Provider::with_id( + crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), + "OpenAI Official".to_string(), + json!({ "auth": {}, "config": "model = \"gpt-5.4\"\n" }), + None, + ); + official.category = Some("official".to_string()); + official.meta = Some(ProviderMeta { + auth_binding: Some(AuthBinding { + source: AuthBindingSource::ManagedAccount, + auth_provider: Some("codex_oauth".to_string()), + account_id: Some("acct-managed-a".to_string()), + }), + ..Default::default() + }); + state + .db + .save_provider(AppType::Codex.as_str(), &official) + .expect("save official A"); + state + .db + .set_current_provider(AppType::Codex.as_str(), &official.id) + .expect("set official current"); + crate::settings::set_current_provider(&AppType::Codex, Some(&official.id)) + .expect("set local official current"); + + let mut third_party = Provider::with_id( + "third-party-current".to_string(), + "Third Party".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "sk-third" }, + "config": r#"model_provider = "third" +[model_providers.third] +name = "Third" +base_url = "https://third.example/v1" +wire_api = "responses" +"# + }), + None, + ); + third_party.category = Some("custom".to_string()); + state + .db + .save_provider(AppType::Codex.as_str(), &third_party) + .expect("save third party"); + + let mut updated = official.clone(); + updated + .meta + .as_mut() + .and_then(|meta| meta.auth_binding.as_mut()) + .expect("managed binding") + .account_id = Some("acct-managed-b".to_string()); + + let switch_guard = tauri::async_runtime::block_on( + state + .proxy_service + .lock_switch_for_app(AppType::Codex.as_str()), + ); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (update_result, live_after_switch) = std::thread::scope(|scope| { + let updater = scope.spawn(move || { + started_tx.send(()).expect("signal updater start"); + ProviderService::update(state, AppType::Codex, None, updated) + }); + started_rx.recv().expect("wait for updater"); + + // This emulates a switch that already owns the per-app lock and + // commits a different current target before the queued update is + // allowed to inspect current/existing state. + state + .db + .set_current_provider(AppType::Codex.as_str(), &third_party.id) + .expect("switch DB current to third party"); + crate::settings::set_current_provider( + &AppType::Codex, + Some(third_party.id.as_str()), + ) + .expect("switch local current to third party"); + write_live_with_common_config_for_state(state, &AppType::Codex, &third_party) + .expect("write third-party live"); + let live_after_switch = crate::codex_config::CodexLiveStateSnapshot::capture() + .expect("capture third-party live"); + + drop(switch_guard); + let result = updater.join().expect("join managed updater"); + (result, live_after_switch) + }); + + update_result.expect("save queued non-current managed row"); + assert_eq!( + state + .db + .get_current_provider(AppType::Codex.as_str()) + .expect("read DB current") + .as_deref(), + Some(third_party.id.as_str()) + ); + assert_eq!( + crate::codex_config::CodexLiveStateSnapshot::capture() + .expect("capture live after queued update"), + live_after_switch, + "queued provider edit must not rewrite the newly switched current live" + ); + let saved_official = state + .db + .get_provider_by_id(&official.id, AppType::Codex.as_str()) + .expect("read saved official") + .expect("official exists"); + assert_eq!( + saved_official + .meta + .as_ref() + .and_then(|meta| meta.managed_account_id_for("codex_oauth")), + Some("acct-managed-b".to_string()) + ); + }); + } + #[test] #[serial] fn switch_to_managed_codex_official_with_unresolvable_account_keeps_current_unchanged() { @@ -1445,6 +2165,40 @@ base_url = "http://localhost:8080" }); } + #[test] + #[serial] + fn import_opencode_providers_from_live_updates_existing_provider_from_live() { + with_test_home(|state, _| { + let provider = opencode_provider("existing-opencode"); + state + .db + .save_provider(AppType::OpenCode.as_str(), &provider) + .expect("seed existing opencode provider"); + + let mut live_settings = provider.settings_config.clone(); + live_settings.as_object_mut().unwrap().remove("name"); + live_settings["npm"] = Value::String("@ai-sdk/anthropic".to_string()); + live_settings["models"]["gpt-4o"]["name"] = Value::String("Claude Sonnet".to_string()); + crate::opencode_config::set_provider(&provider.id, live_settings) + .expect("seed edited live opencode provider"); + + let updated = import_opencode_providers_from_live(state) + .expect("import opencode providers from live"); + assert_eq!(updated, 1); + + let saved = state + .db + .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) + .expect("query updated opencode provider") + .expect("opencode provider should exist"); + assert_eq!(saved.name, provider.name); + assert_eq!(saved.settings_config["npm"], json!("@ai-sdk/anthropic")); + assert_eq!( + saved.settings_config["models"]["gpt-4o"]["name"], + json!("Claude Sonnet") + ); + }); + } #[test] #[serial] fn import_openclaw_providers_from_live_marks_provider_as_live_managed() { @@ -1479,6 +2233,89 @@ base_url = "http://localhost:8080" }); } + #[test] + #[serial] + fn import_openclaw_providers_from_live_updates_existing_provider_from_live() { + with_test_home(|state, _| { + let mut provider = openclaw_provider("existing-openclaw"); + provider.settings_config["models"] = json!([ + { + "id": "claude-sonnet-4", + "name": "Claude Sonnet 4" + } + ]); + state + .db + .save_provider(AppType::OpenClaw.as_str(), &provider) + .expect("seed existing openclaw provider"); + + let mut live_settings = provider.settings_config.clone(); + live_settings["baseUrl"] = Value::String("https://api.example.com/v1".to_string()); + live_settings["models"][0]["name"] = Value::String("Claude Sonnet 4.1".to_string()); + crate::openclaw_config::set_provider(&provider.id, live_settings) + .expect("seed edited live openclaw provider"); + + let updated = import_openclaw_providers_from_live(state) + .expect("import openclaw providers from live"); + assert_eq!(updated, 1); + + let saved = state + .db + .get_provider_by_id(&provider.id, AppType::OpenClaw.as_str()) + .expect("query updated openclaw provider") + .expect("openclaw provider should exist"); + assert_eq!(saved.name, provider.name); + assert_eq!( + saved.settings_config["baseUrl"], + json!("https://api.example.com/v1") + ); + assert_eq!( + saved.settings_config["models"][0]["name"], + json!("Claude Sonnet 4.1") + ); + }); + } + + #[test] + #[serial] + fn import_hermes_providers_from_live_updates_existing_provider_from_live() { + with_test_home(|state, _| { + let provider = hermes_provider("existing-hermes"); + state + .db + .save_provider(AppType::Hermes.as_str(), &provider) + .expect("seed existing hermes provider"); + + let mut live_settings = provider.settings_config.clone(); + live_settings["base_url"] = Value::String("https://api.hermes.example/v1".to_string()); + live_settings["models"]["gpt-4o"]["name"] = Value::String("GPT-4o Updated".to_string()); + crate::hermes_config::set_provider(&provider.id, live_settings) + .expect("seed edited live hermes provider"); + + let updated = import_hermes_providers_from_live(state) + .expect("import hermes providers from live"); + assert_eq!(updated, 1); + + let saved = state + .db + .get_provider_by_id(&provider.id, AppType::Hermes.as_str()) + .expect("query updated hermes provider") + .expect("hermes provider should exist"); + assert_eq!(saved.name, provider.name); + assert_eq!( + saved.settings_config["base_url"], + json!("https://api.hermes.example/v1") + ); + // models are denormalized from YAML dict to UI-friendly array by + // get_providers(), so access by index rather than dict key + assert_eq!( + saved.settings_config["models"][0]["name"], + json!("GPT-4o Updated") + ); + assert_eq!(saved.settings_config["models"][0]["id"], json!("gpt-4o")); + }); + } + #[test] #[serial] fn legacy_additive_provider_still_errors_on_live_config_parse_failure() { @@ -1712,25 +2549,101 @@ impl ProviderService { } /// 提交 current(settings/DB)前的预检:若目标是托管 Codex official provider, - /// 先解析一次有效 live 配置(会联网换取并缓存 token)。失败即返回 Err,从而避免 - /// 留下「DB/UI 指向新 provider,但 live 仍是旧 provider」的不一致状态;后续真正 - /// 写 live 会复用缓存的 token。非托管路径为空操作。 + /// 先解析一次有效 live 配置(会联网换取并缓存 token)。同时返回这份已解析配置, + /// 让后续落盘直接复用同一 token bundle,避免一次操作重复解析/刷新。 fn preflight_managed_codex_live( state: &AppState, app_type: &AppType, provider: &Provider, - ) -> Result<(), AppError> { + ) -> Result, AppError> { if matches!(app_type, AppType::Codex) && Self::managed_codex_oauth_account_id(provider).is_some() { - build_effective_provider_for_live_with_codex_oauth_manager( + return build_effective_provider_for_live_with_codex_oauth_manager( state.db.as_ref(), app_type, provider, &state.codex_oauth_manager, - )?; + ) + .map(Some); + } + Ok(None) + } + + fn write_preflighted_or_current_live( + state: &AppState, + app_type: &AppType, + provider: &Provider, + preflighted_provider: Option<&Provider>, + ) -> Result<(), AppError> { + if let Some(effective_provider) = preflighted_provider { + live::write_live_snapshot(app_type, effective_provider) + } else { + write_live_with_common_config_for_state(state, app_type, provider) + } + } + + fn managed_codex_transaction_error( + operation: &str, + error: AppError, + snapshot: &crate::codex_config::CodexLiveStateSnapshot, + restore_local_current: Option<(&AppType, Option<&str>)>, + ) -> AppError { + let mut rollback_failures = Vec::new(); + if let Some((app_type, previous_local_current)) = restore_local_current { + if let Err(rollback_error) = + crate::settings::set_current_provider(app_type, previous_local_current) + { + rollback_failures.push(format!("恢复本地 current 失败: {rollback_error}")); + } + } + if let Err(rollback_error) = snapshot.restore_preserving_newer_same_account_auth() { + rollback_failures.push(rollback_error.to_string()); + } + + if rollback_failures.is_empty() { + error + } else { + AppError::Message(format!( + "{operation}失败: {error}; 回滚同时失败: {}", + rollback_failures.join("; ") + )) + } + } + + fn managed_codex_takeover_transaction_error( + state: &AppState, + operation: &str, + error: AppError, + snapshot: &crate::codex_config::CodexLiveStateSnapshot, + previous_backup: Option<&crate::proxy::types::LiveBackup>, + ) -> AppError { + let mut rollback_failures = Vec::new(); + let backup_restore = match previous_backup { + Some(backup) => futures::executor::block_on( + state + .db + .save_live_backup(AppType::Codex.as_str(), &backup.original_config), + ), + None => { + futures::executor::block_on(state.db.delete_live_backup(AppType::Codex.as_str())) + } + }; + if let Err(rollback_error) = backup_restore { + rollback_failures.push(format!("恢复 Codex Live 备份失败: {rollback_error}")); + } + if let Err(rollback_error) = snapshot.restore_preserving_newer_same_account_auth() { + rollback_failures.push(rollback_error.to_string()); + } + + if rollback_failures.is_empty() { + error + } else { + AppError::Message(format!( + "{operation}失败: {error}; 回滚同时失败: {}", + rollback_failures.join("; ") + )) } - Ok(()) } fn unbound_managed_codex_oauth_account_id( @@ -1921,12 +2834,41 @@ impl ProviderService { let current = state.db.get_current_provider(app_type.as_str())?; if current.is_none() { // 预检托管 Codex token:失败则不将其设为 current(见 switch)。 - Self::preflight_managed_codex_live(state, &app_type, &provider)?; - // No current provider, set as current and sync - state - .db - .set_current_provider(app_type.as_str(), &provider.id)?; - write_live_with_common_config_for_state(state, &app_type, &provider)?; + let preflighted_provider = + Self::preflight_managed_codex_live(state, &app_type, &provider)?; + if preflighted_provider.is_some() { + let snapshot = crate::codex_config::CodexLiveStateSnapshot::capture()?; + if let Err(error) = Self::write_preflighted_or_current_live( + state, + &app_type, + &provider, + preflighted_provider.as_ref(), + ) { + return Err(Self::managed_codex_transaction_error( + "写入首个托管 Codex provider", + error, + &snapshot, + None, + )); + } + if let Err(error) = state + .db + .set_current_provider(app_type.as_str(), &provider.id) + { + return Err(Self::managed_codex_transaction_error( + "设置首个托管 Codex provider 为 current", + error, + &snapshot, + None, + )); + } + } else { + // No current provider, set as current and sync. + state + .db + .set_current_provider(app_type.as_str(), &provider.id)?; + write_live_with_common_config_for_state(state, &app_type, &provider)?; + } } Ok(true) @@ -1942,6 +2884,19 @@ impl ProviderService { let mut provider = provider; let original_id = original_id.unwrap_or(provider.id.as_str()).to_string(); let provider_id_changed = original_id != provider.id; + // Serialize the read/decide/commit window for every Codex update. We do + // not yet know whether the stored row is managed (the request may be an + // unbind), so the existing row and effective current must both be read + // only after this lock is held. Non-managed Codex updates release it + // before entering the legacy path, whose proxy helpers take the lock + // themselves. + let codex_update_switch_guard = if matches!(app_type, AppType::Codex) { + Some(futures::executor::block_on( + state.proxy_service.lock_switch_for_app(app_type.as_str()), + )) + } else { + None + }; let existing_provider = state .db .get_provider_by_id(&original_id, app_type.as_str())?; @@ -2092,12 +3047,125 @@ impl ProviderService { crate::settings::get_effective_current_provider(&state.db, &app_type)?; let is_current = effective_current.as_deref() == Some(provider.id.as_str()); - // 编辑当前托管 Codex provider:写库前先预检 token(见 preflight_managed_codex_live), - // 失败则不落库、不改动 live,避免 DB 与 live 不一致。 - if is_current { - Self::preflight_managed_codex_live(state, &app_type, &provider)?; + let existing_managed_codex_account_id = existing_provider + .as_ref() + .and_then(Self::managed_codex_oauth_account_id); + let target_managed_codex_account_id = Self::managed_codex_oauth_account_id(&provider); + let managed_codex_update = matches!(app_type, AppType::Codex) + && (existing_managed_codex_account_id.is_some() + || target_managed_codex_account_id.is_some()); + + if managed_codex_update { + // A non-current managed row still commits under the same lock: once + // the row is saved, a waiting switch observes the new binding. If we + // released first, a switch could activate the old binding and leave + // DB current/live inconsistent with the subsequent save. + if !is_current { + state.db.save_provider(app_type.as_str(), &provider)?; + return Ok(true); + } + + // The lock acquired before reading existing/current spans the + // complete direct/takeover transaction. Backup update and takeover + // Live sync therefore cannot expose a gap to concurrent hot-switch. + let previous_backup = + futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))?; + let has_live_backup = previous_backup.is_some(); + let live_taken_over = state + .proxy_service + .detect_takeover_in_live_config_for_app(&app_type); + let preflighted_provider = + Self::preflight_managed_codex_live(state, &app_type, &provider)?; + // Capture after preflight: a legitimate refresh may have advanced + // auth.json, and rollback must never restore the older generation. + let snapshot = crate::codex_config::CodexLiveStateSnapshot::capture()?; + + if !has_live_backup && !live_taken_over { + let commit_result = (|| { + Self::write_preflighted_or_current_live( + state, + &app_type, + &provider, + preflighted_provider.as_ref(), + )?; + if let Some(account_id) = unbound_codex_managed_account_id.as_deref() { + crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?; + } + state.db.save_provider(app_type.as_str(), &provider)?; + Ok::<(), AppError>(()) + })(); + if let Err(error) = commit_result { + return Err(Self::managed_codex_transaction_error( + "更新托管 Codex provider", + error, + &snapshot, + None, + )); + } + + if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) { + log::warn!( + "保存供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}" + ); + } + return Ok(true); + } + + let commit_result = (|| { + futures::executor::block_on( + state.proxy_service.update_live_backup_from_provider_inner( + app_type.as_str(), + &provider, + unbound_codex_managed_account_id.as_deref(), + ), + ) + .map_err(|error| AppError::Message(format!("更新 Live 备份失败: {error}")))?; + + if live_taken_over { + futures::executor::block_on( + state + .proxy_service + .sync_codex_live_from_provider_while_proxy_active(&provider), + ) + .map_err(|error| { + AppError::Message(format!("同步 Codex Live 配置失败: {error}")) + })?; + } else { + // A backup without a takeover marker is a recoverable + // half-takeover state. Keep the actual Live bundle aligned + // with the edited current provider as well as the backup. + Self::write_preflighted_or_current_live( + state, + &app_type, + &provider, + preflighted_provider.as_ref(), + )?; + } + + if let Some(account_id) = unbound_codex_managed_account_id.as_deref() { + crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?; + } + + // DB is the final commit. Every fallible side effect above can be + // restored exactly while the previous provider row is untouched. + state.db.save_provider(app_type.as_str(), &provider)?; + Ok::<(), AppError>(()) + })(); + if let Err(error) = commit_result { + return Err(Self::managed_codex_takeover_transaction_error( + state, + "更新接管中的托管 Codex provider", + error, + &snapshot, + previous_backup.as_ref(), + )); + } + + return Ok(true); } + drop(codex_update_switch_guard); + // Save to database state.db.save_provider(app_type.as_str(), &provider)?; @@ -2148,23 +3216,44 @@ impl ProviderService { } } - if matches!(app_type, AppType::Claude) - && futures::executor::block_on(state.proxy_service.is_running()) - { - futures::executor::block_on( - state - .proxy_service - .sync_claude_live_from_provider_while_proxy_active(&provider), - ) - .map_err(|e| AppError::Message(format!("同步 Claude Live 配置失败: {e}")))?; + if futures::executor::block_on(state.proxy_service.is_running()) { + if matches!(app_type, AppType::Claude) { + futures::executor::block_on( + state + .proxy_service + .sync_claude_live_from_provider_while_proxy_active(&provider), + ) + .map_err(|e| { + AppError::Message(format!("同步 Claude Live 配置失败: {e}")) + })?; + } else if live_taken_over && matches!(app_type, AppType::Codex) { + // Codex model mappings are projected into a generated + // model_catalog_json file. Refresh takeover-owned Live + // immediately so adding/removing mappings cannot leave + // the previous catalog pointer and capabilities active. + futures::executor::block_on( + state + .proxy_service + .sync_codex_live_from_provider_while_proxy_active(&provider), + ) + .map_err(|e| AppError::Message(format!("同步 Codex Live 配置失败: {e}")))?; + } } } else { write_live_with_common_config_for_state(state, &app_type, &provider)?; if let Some(account_id) = unbound_codex_managed_account_id.as_deref() { crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?; } - // Sync MCP - McpService::sync_all_enabled(state)?; + // 重写 live 后只重投影本应用的 MCP:全量 sync_all_enabled 会把 + // 无关应用的 live 损坏(如 ~/.claude.json 坏 JSON)牵连进保存 + // 流程。走到这里 DB 与 live 都已按新配置落盘,保存事实上已 + // 成功;投影失败降级为警告,避免制造"保存失败"假象(MCP + // 投影可自愈:下次切换 / 任一 MCP 启停都会重新投影)。 + if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) { + log::warn!( + "保存供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}" + ); + } } } @@ -2337,14 +3426,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 @@ -2362,7 +3453,10 @@ impl ProviderService { // Block switching to official providers when proxy takeover is active. // Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans. - if should_hot_switch && _provider.category.as_deref() == Some("official") { + if should_hot_switch + && _provider.category.as_deref() == Some("official") + && !official_provider_supports_proxy_takeover(&app_type, _provider) + { return Err(AppError::localized( "switch.official_blocked_by_proxy", "代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。", @@ -2477,25 +3571,72 @@ impl ProviderService { } // 提交 current 前预检托管 Codex token(见 preflight_managed_codex_live)。 - Self::preflight_managed_codex_live(state, &app_type, provider)?; + let preflighted_provider = Self::preflight_managed_codex_live(state, &app_type, provider)?; + let target_managed_codex_account_id = Self::managed_codex_oauth_account_id(provider); + let use_managed_codex_transaction = matches!(app_type, AppType::Codex) + && (current_managed_codex_account_id.is_some() + || target_managed_codex_account_id.is_some()); - // Additive mode apps skip setting is_current (no such concept) - if !app_type.is_additive_mode() { - // Update local settings (device-level, takes priority) - crate::settings::set_current_provider(&app_type, Some(id))?; - - // Update database is_current (as default for new devices) - state.db.set_current_provider(app_type.as_str(), id)?; - } - - // Sync to live (write_gemini_live handles security flag internally for Gemini) - write_live_with_common_config_for_state(state, &app_type, provider)?; - if matches!(app_type, AppType::Codex) - && Self::managed_codex_oauth_account_id(provider).is_none() - { - if let Some(account_id) = current_managed_codex_account_id.as_deref() { - crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?; + if use_managed_codex_transaction { + // auth/config/catalog/marker form one logical live commit. Write them + // before current, then restore the exact four-file snapshot on any + // failure so native logins and CLI-rotated tokens are not reconstructed + // from a stale provider row. + let snapshot = crate::codex_config::CodexLiveStateSnapshot::capture()?; + let live_result = (|| { + Self::write_preflighted_or_current_live( + state, + &app_type, + provider, + preflighted_provider.as_ref(), + )?; + if target_managed_codex_account_id.is_none() { + if let Some(account_id) = current_managed_codex_account_id.as_deref() { + crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?; + } + } + Ok::<(), AppError>(()) + })(); + if let Err(error) = live_result { + return Err(Self::managed_codex_transaction_error( + "写入 Codex Live", + error, + &snapshot, + None, + )); } + + let previous_local_current = crate::settings::get_current_provider(&app_type); + if let Err(error) = crate::settings::set_current_provider(&app_type, Some(id)) { + return Err(Self::managed_codex_transaction_error( + "更新本地 current", + error, + &snapshot, + Some((&app_type, previous_local_current.as_deref())), + )); + } + if let Err(error) = state.db.set_current_provider(app_type.as_str(), id) { + return Err(Self::managed_codex_transaction_error( + "更新数据库 current", + error, + &snapshot, + Some((&app_type, previous_local_current.as_deref())), + )); + } + } else { + // Additive mode apps skip setting is_current (no such concept). + if !app_type.is_additive_mode() { + crate::settings::set_current_provider(&app_type, Some(id))?; + state.db.set_current_provider(app_type.as_str(), id)?; + } + + // Sync to live (write_gemini_live handles security flag internally for Gemini). + Self::write_preflighted_or_current_live( + state, + &app_type, + provider, + preflighted_provider.as_ref(), + )?; } // Hermes is additive, so "switching" doesn't overwrite a live config file @@ -2552,8 +3693,16 @@ impl ProviderService { } } - // Sync MCP - McpService::sync_all_enabled(state)?; + // 切换重写了目标应用的 live,只重投影该应用的 MCP(Codex 的 + // [mcp_servers] 与 live 同文件,整体替换后必须补回;其余应用的 + // MCP 文件独立于 live,投影是幂等维护)。不用全量 sync_all_enabled: + // 无关应用的 live 损坏(如 ~/.claude.json 坏 JSON)不该阻断切换。 + // 走到这里 DB is_current 与 live 都已落盘,切换事实上已成功; + // 投影失败上抛会让前端报"切换失败"制造分裂假象,故降级为警告 + // (MCP 投影可自愈:下次切换 / 任一 MCP 启停都会重新投影)。 + if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) { + log::warn!("切换供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}"); + } Ok(result) } @@ -2699,9 +3848,12 @@ impl ProviderService { /// 读到的 live 一定是"片段 + 本地改动"的超集,重提取只会丢掉用户真正删掉的键, /// 不会误删其它供应商共享的内容。 /// - /// **作用域**:仅 Claude。Codex 的 live 是 TOML 且端点藏在 `[model_providers]` - /// 表里(现有提取器不剥),自动同步会泄漏端点并与 modelCatalog / 统一会话桶 / - /// auth 还原逻辑冲突;Gemini 暂未纳入。两者如需支持应各自单独验证后再加。 + /// **作用域**:Claude + Codex。Codex 提取器(`extract_codex_common_config`) + /// 已剥离全部供应商专属与 cc-switch 注入内容:`model` / `model_provider` / + /// 顶层 `base_url` / 整张 `model_providers` 表(含端点与统一会话桶)、 + /// `mcp_servers`(SSOT 在 DB 表)、顶层 `experimental_bearer_token` + /// fallback、`model_catalog_json`、`web_search = "disabled"` 哨兵——密钥与 + /// 注入产物不会进共享片段。Gemini 暂未纳入,如需支持应单独验证后再加。 /// /// 仅对**显式勾选"写入通用配置"**(`meta.common_config_enabled == Some(true)`)的 /// 供应商生效;用户**显式清空**过片段(`_cleared`)时跳过,避免把用户主动清掉的 @@ -2713,8 +3865,8 @@ impl ProviderService { live_config: &Value, result: &mut SwitchResult, ) { - // 作用域限定 Claude(见函数文档)。 - if !matches!(app_type, AppType::Claude) { + // 作用域限定 Claude + Codex(见函数文档)。 + if !matches!(app_type, AppType::Claude | AppType::Codex) { return; } @@ -2803,6 +3955,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 @@ -2819,6 +3972,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 @@ -2890,6 +4044,16 @@ impl ProviderService { "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", + // Fable 是 v3.16.3 新增的第四档模型映射,与 haiku/sonnet/opus 同属供应商专属, + // 不得进入通用配置片段,否则会污染其它供应商(issue #4272)。 + "ANTHROPIC_DEFAULT_FABLE_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", + "CLAUDE_CODE_SUBAGENT_MODEL", + // Context limits follow the actual upstream model. Sharing these + // across providers can cap GPT/Kimi to the wrong window and make + // Claude Code compact too early or miss the upstream limit. + "CLAUDE_CODE_MAX_CONTEXT_TOKENS", + "CLAUDE_CODE_AUTO_COMPACT_WINDOW", "ANTHROPIC_BASE_URL", ]; @@ -2966,10 +4130,48 @@ impl ProviderService { root.remove("model_provider"); // Legacy/alt formats might use a top-level base_url. root.remove("base_url"); + // wire_api 与 base_url 同属供应商路由语义:无 model_provider 时 + // update_codex_toml_field / 前端 setCodexWireApi 都会把它落在顶层, + // 进了片段会改写其它供应商的协议选择(chat vs responses)。 + root.remove("wire_api"); // Remove entire model_providers table (provider-specific configuration) root.remove("model_providers"); + // MCP 服务器归 DB mcp_servers 表所有:进了共享片段会绕过按应用的 + // 启用状态被合并进所有勾选通用配置的供应商,且在通用配置编辑框里 + // 显示为一份"重复"的 MCP 配置。 + root.remove("mcp_servers"); + // 历史错误格式 [mcp.servers] 一并剥离(与 strip_codex_mcp_servers_from_settings + // 一致):sync_all_enabled 只管理 [mcp_servers.*],legacy 形态一旦进了 + // 片段就会被合并进所有供应商,且没有任何同步路径能清掉这个孤儿。 + if let Some(mcp_tbl) = root + .get_mut("mcp") + .and_then(|item| item.as_table_like_mut()) + { + mcp_tbl.remove("servers"); + if mcp_tbl.is_empty() { + root.remove("mcp"); + } + } + + // cc-switch 写 live 时注入的产物一律不进共享片段: + // - experimental_bearer_token 正常写在 [model_providers.] 内(上面 + // 整表已剥),但无活跃路由 / 内建保留 id / 路由表缺失三种 fallback + // 会落在顶层——不剥等于把 API 密钥写进共享片段。 + root.remove("experimental_bearer_token"); + // - model_catalog_json 指向按供应商生成的 catalog 投影文件(DB 为 SSOT)。 + root.remove("model_catalog_json"); + // - web_search 只剥 cc-switch 注入的 "disabled" 哨兵;用户手设的其它值 + // 属于可共享偏好,保留。 + if root + .get(crate::codex_config::CODEX_WEB_SEARCH_FIELD) + .and_then(|item| item.as_str()) + == Some(crate::codex_config::CODEX_WEB_SEARCH_DISABLED) + { + root.remove(crate::codex_config::CODEX_WEB_SEARCH_FIELD); + } + // Clean up multiple empty lines (keep at most one blank line). let mut cleaned = String::new(); let mut blank_run = 0usize; @@ -3241,6 +4443,26 @@ 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", + ) + })?; + crate::grok_config::validate_config_toml(config)?; + } AppType::OpenCode => { // OpenCode uses a different config structure: { npm, options, models } // Basic validation - must be an object @@ -3337,6 +4559,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)?; diff --git a/src-tauri/src/services/provider/usage.rs b/src-tauri/src/services/provider/usage.rs index 7388249b8..9e985cc21 100644 --- a/src-tauri/src/services/provider/usage.rs +++ b/src-tauri/src/services/provider/usage.rs @@ -57,6 +57,18 @@ pub(crate) async fn execute_and_format_usage_result( }) } Err(err) => { + // 瞬时传输失败(send 失败/超时、读体中断)以 Err 传播,让前端 invoke + // reject → react-query retry 并保留上次成功值;按错误 key 判定而非 + // 文案匹配。其余脚本/配置/HTTP 业务错误折叠成 success:false 展示文案。 + if let AppError::Localized { key, .. } = &err { + if matches!( + *key, + "usage_script.request_failed" | "usage_script.read_response_failed" + ) { + return Err(err); + } + } + let lang = settings::get_settings() .language .unwrap_or_else(|| "zh".to_string()); diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 921de55e5..091257bc1 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -29,7 +29,7 @@ const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED"; /// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名, /// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管, /// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。 -const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [ +const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 12] = [ "ANTHROPIC_MODEL", "ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留 "ANTHROPIC_DEFAULT_HAIKU_MODEL", @@ -38,13 +38,16 @@ const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [ "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", - // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型 - "ANTHROPIC_SMALL_FAST_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", + "ANTHROPIC_SMALL_FAST_MODEL", // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型 + "CLAUDE_CODE_SUBAGENT_MODEL", ]; const CLAUDE_TAKEOVER_HAIKU_MODEL: &str = "claude-haiku-4-5"; const CLAUDE_TAKEOVER_SONNET_MODEL: &str = "claude-sonnet-4-6"; const CLAUDE_TAKEOVER_OPUS_MODEL: &str = "claude-opus-4-8"; +const CLAUDE_TAKEOVER_FABLE_MODEL: &str = "claude-fable-5"; // 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。 const CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = "[1M]"; @@ -204,17 +207,22 @@ impl ProxyService { for key in token_keys { env.remove(key); } - env.insert( - "ANTHROPIC_API_KEY".to_string(), - json!(PROXY_TOKEN_PLACEHOLDER), - ); + // 只注入一个认证键:两者同时存在会触发 Claude Code 的 + // "Both ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY set" 警告(#4919)。 + // - Codex 系保留 AUTH_TOKEN:缺该键 Claude Code 会弹登录提示(#3784)。 + // 无条件注入而非"已存在才保留":热切换路径传入的是 provider + // settings(预设不含该键),且旧版接管已把存量用户 live 中的键删光。 + // - Copilot 仅 API_KEY:避免与 /login 管理的 key 冲突(#1049)。 if keep_auth_token { - // 无条件注入而非"已存在才保留":热切换路径传入的是 provider - // settings(预设不含该键),且旧版接管已把存量用户 live 中的键删光。 env.insert( "ANTHROPIC_AUTH_TOKEN".to_string(), json!(PROXY_TOKEN_PLACEHOLDER), ); + } else { + env.insert( + "ANTHROPIC_API_KEY".to_string(), + json!(PROXY_TOKEN_PLACEHOLDER), + ); } } } @@ -236,8 +244,12 @@ impl ProxyService { let opus_model = Self::claude_env_string(env, "ANTHROPIC_DEFAULT_OPUS_MODEL") .or(default_model) .or(small_fast_model); + // Fable 未配置时不写稳定别名;映射侧会 fable→opus 降级(与官方一致)。 + let fable_model = Self::claude_env_string(env, "ANTHROPIC_DEFAULT_FABLE_MODEL"); - let mut fields = Vec::with_capacity(6); + let subagent_model = Self::claude_env_string(env, "CLAUDE_CODE_SUBAGENT_MODEL"); + + let mut fields = Vec::with_capacity(9); Self::push_claude_takeover_role_fields( &mut fields, env, @@ -265,6 +277,18 @@ impl ProxyService { true, opus_model, ); + Self::push_claude_takeover_role_fields( + &mut fields, + env, + "ANTHROPIC_DEFAULT_FABLE_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", + CLAUDE_TAKEOVER_FABLE_MODEL, + true, + fable_model, + ); + if let Some(subagent_model) = subagent_model { + fields.push(("CLAUDE_CODE_SUBAGENT_MODEL", subagent_model.to_string())); + } fields } @@ -351,48 +375,55 @@ impl ProxyService { provider: &Provider, ) -> Result<(), String> { let existing_live = self.read_codex_live().ok(); - let mut effective_settings = build_effective_settings_with_common_config( + let mut effective_settings = build_effective_provider_for_live_with_codex_oauth_manager( self.db.as_ref(), &AppType::Codex, provider, + &self.codex_oauth_manager, ) - .map_err(|e| format!("构建 codex 有效配置失败: {e}"))?; + .map_err(|e| format!("构建 codex 有效配置失败: {e}"))? + .settings_config; if let Some(existing_live) = existing_live.as_ref() { - Self::preserve_codex_mcp_servers_from_existing_config( + Self::preserve_toml_mcp_servers_from_existing_config( &mut effective_settings, existing_live, )?; } let (_, proxy_codex_base_url) = self.build_proxy_urls().await?; - if let Some(auth) = effective_settings - .get_mut("auth") - .and_then(|v| v.as_object_mut()) - { - auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); - } else if let Some(root) = effective_settings.as_object_mut() { - root.insert( - "auth".to_string(), - json!({ "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER }), - ); - } - - let config_str = effective_settings - .get("config") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let updated_config = Self::apply_codex_proxy_toml_config_for_provider( - config_str, + Self::apply_codex_takeover_fields_for_provider( + &mut effective_settings, &proxy_codex_base_url, - Some(provider), - ); - effective_settings["config"] = json!(updated_config); - Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider)); + provider, + )?; self.write_codex_takeover_live_for_provider(&effective_settings, Some(provider))?; Ok(()) } + pub async fn sync_grok_live_from_provider_while_proxy_active( + &self, + provider: &Provider, + ) -> Result<(), String> { + let existing_live = self.read_grok_live().ok(); + let mut effective_settings = build_effective_settings_with_common_config( + self.db.as_ref(), + &AppType::GrokBuild, + provider, + ) + .map_err(|e| format!("构建 Grok Build 有效配置失败: {e}"))?; + if let Some(existing_live) = existing_live.as_ref() { + Self::preserve_toml_mcp_servers_from_existing_config( + &mut effective_settings, + existing_live, + )?; + } + let (proxy_url, _) = self.build_proxy_urls().await?; + let proxy_grok_base_url = format!("{}/grokbuild/v1", proxy_url.trim_end_matches('/')); + Self::apply_grok_takeover_fields(&mut effective_settings, &proxy_grok_base_url)?; + self.write_grok_live(&effective_settings) + } + fn get_current_provider_for_app(&self, app_type: &AppType) -> Result, String> { let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type) .map_err(|e| format!("获取 {app_type:?} 当前供应商失败: {e}"))? @@ -405,6 +436,133 @@ impl ProxyService { .map_err(|e| format!("读取 {app_type:?} 当前供应商失败: {e}")) } + fn should_preserve_current_codex_auth(&self) -> Result { + // Unknown current state is handled conservatively: preserving the live + // auth file cannot roll a refresh generation back, while restoring an + // unclassified legacy backup can. A concrete non-official provider is + // the only case where its stored auth should replace the live file. + Ok(self + .get_current_provider_for_app(&AppType::Codex)? + .as_ref() + .is_none_or(crate::proxy::providers::is_codex_official_provider)) + } + + /// Official Codex auth is a live, independently rotating login. A takeover + /// backup may restore config/catalog, but must never freeze refresh tokens + /// that Codex CLI can advance while the proxy is active. + fn strip_current_official_codex_auth_from_backup( + &self, + config: &mut Value, + ) -> Result<(), String> { + if self.should_preserve_current_codex_auth()? { + if let Some(root) = config.as_object_mut() { + root.remove("auth"); + } + } + Ok(()) + } + + fn write_codex_restore_backup(&self, config: &Value) -> Result<(), String> { + if !self.should_preserve_current_codex_auth()? { + return self.write_codex_live(config); + } + + let mut config_only = config.clone(); + if let Some(root) = config_only.as_object_mut() { + root.remove("auth"); + } + self.write_codex_live_verbatim(&config_only) + } + + async fn rollback_failed_takeover_activation( + &self, + app_type: &AppType, + codex_snapshot: Option<&crate::codex_config::CodexLiveStateSnapshot>, + ) -> Result<(), String> { + if let Some(snapshot) = codex_snapshot { + return snapshot + .restore_preserving_newer_same_account_auth() + .map_err(|error| error.to_string()); + } + self.restore_live_config_for_app_inner(app_type).await + } + + async fn refresh_active_target_from_current_provider(&self, app_type: &AppType) { + let Ok(Some(provider)) = self.get_current_provider_for_app(app_type) else { + return; + }; + if let Some(server) = self.server.read().await.as_ref() { + server + .set_active_target(app_type.as_str(), &provider.id, &provider.name) + .await; + } + } + + async fn rollback_hot_switch_preparation( + &self, + app_type: &AppType, + previous_backup: Option<&LiveBackup>, + previous_provider_id: Option<&str>, + should_sync_backup: bool, + live_taken_over: bool, + previous_codex_live_state: Option<&crate::codex_config::CodexLiveStateSnapshot>, + ) { + if !should_sync_backup { + return; + } + + let rollback_result = match previous_backup { + Some(backup) => { + self.db + .save_live_backup(app_type.as_str(), &backup.original_config) + .await + } + None => self.db.delete_live_backup(app_type.as_str()).await, + }; + if let Err(error) = rollback_result { + log::error!("{} 热切换失败后恢复原备份失败: {error}", app_type.as_str()); + } + + if let Some(previous_live) = previous_codex_live_state { + if let Err(error) = previous_live.restore_preserving_newer_same_account_auth() { + log::error!( + "{} 热切换失败后恢复 Codex Live 状态失败: {error}", + app_type.as_str() + ); + } + return; + } + + let Some(previous_provider_id) = previous_provider_id else { + return; + }; + let Ok(Some(previous_provider)) = self + .db + .get_provider_by_id(previous_provider_id, app_type.as_str()) + else { + return; + }; + + let live_result = if matches!(app_type, AppType::Claude) { + self.sync_claude_live_from_provider_while_proxy_active(&previous_provider) + .await + } else if live_taken_over && matches!(app_type, AppType::Codex) { + self.sync_codex_live_from_provider_while_proxy_active(&previous_provider) + .await + } else if live_taken_over && matches!(app_type, AppType::GrokBuild) { + self.sync_grok_live_from_provider_while_proxy_active(&previous_provider) + .await + } else { + Ok(()) + }; + if let Err(error) = live_result { + log::error!( + "{} 热切换失败后恢复原 Live 配置失败: {error}", + app_type.as_str() + ); + } + } + fn require_current_provider_for_app(&self, app_type: &AppType) -> Result { self.get_current_provider_for_app(app_type)? .ok_or_else(|| format!("{app_type:?} 当前供应商不存在,无法接管 Live 配置")) @@ -613,6 +771,12 @@ impl ProxyService { .await .map(|c| c.enabled) .unwrap_or(false); + let grokbuild_enabled = self + .db + .get_proxy_config_for_app("grokbuild") + .await + .map(|c| c.enabled) + .unwrap_or(false); // OpenCode and OpenClaw don't support proxy features, always return false let opencode_enabled = false; let openclaw_enabled = false; @@ -621,6 +785,7 @@ impl ProxyService { claude: claude_enabled, codex: codex_enabled, gemini: gemini_enabled, + grokbuild: grokbuild_enabled, opencode: opencode_enabled, openclaw: openclaw_enabled, }) @@ -670,6 +835,7 @@ impl ProxyService { // 只看占位符会把半接管/旧端口残留误判为可复用,导致开启接管后 // live 文件仍停留在普通供应商配置。 if has_backup && live_matches_current_proxy { + self.refresh_active_target_from_current_provider(&app).await; return Ok(()); } restore_existing_backup_before_takeover = has_backup; @@ -692,10 +858,26 @@ impl ProxyService { } } + // The persistent Official backup intentionally excludes auth.json + // because its refresh token keeps rotating during takeover. Keep an + // exact in-memory pre-write snapshot for activation failures so a + // partial managed write can still restore the user's prior login. + let codex_live_before_takeover = if matches!(&app, AppType::Codex) { + Some( + crate::codex_config::CodexLiveStateSnapshot::capture() + .map_err(|error| format!("捕获 Codex 接管前状态失败: {error}"))?, + ) + } else { + None + }; + // 5) 写入接管配置(仅当前 app) if let Err(e) = self.takeover_live_config_strict(&app).await { log::error!("{app_type_str} 接管 Live 配置失败,尝试恢复: {e}"); - match self.restore_live_config_for_app_inner(&app).await { + match self + .rollback_failed_takeover_activation(&app, codex_live_before_takeover.as_ref()) + .await + { Ok(()) => { // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源 let _ = self.db.delete_live_backup(app_type_str).await; @@ -710,26 +892,52 @@ impl ProxyService { } // 6) 设置 proxy_config.enabled = true - let mut updated_config = self - .db - .get_proxy_config_for_app(app_type_str) - .await - .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; - updated_config.enabled = true; - self.db - .update_proxy_config_for_app(updated_config) - .await - .map_err(|e| format!("设置 {app_type_str} enabled 状态失败: {e}"))?; + let enable_result = async { + let mut updated_config = self + .db + .get_proxy_config_for_app(app_type_str) + .await + .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; + updated_config.enabled = true; + self.db + .update_proxy_config_for_app(updated_config) + .await + .map_err(|e| format!("设置 {app_type_str} enabled 状态失败: {e}")) + } + .await; + if let Err(error) = enable_result { + log::error!("{app_type_str} 提交接管状态失败,尝试恢复 Live: {error}"); + match self + .rollback_failed_takeover_activation(&app, codex_live_before_takeover.as_ref()) + .await + { + Ok(()) => { + let _ = self.db.delete_live_backup(app_type_str).await; + } + Err(restore_error) => { + log::error!( + "{app_type_str} 恢复 Live 配置失败,将保留备份以便下次恢复: {restore_error}" + ); + } + } + return Err(error); + } // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能) let _ = self.db.set_live_takeover_active(true).await; + self.refresh_active_target_from_current_provider(&app).await; + // 8) Warn if the current provider is official (risk of account ban via proxy) if let Ok(Some(current_id)) = crate::settings::get_effective_current_provider(&self.db, &app) { if let Ok(Some(provider)) = self.db.get_provider_by_id(¤t_id, app_type_str) { - if provider.category.as_deref() == Some("official") { + if provider.category.as_deref() == Some("official") + && !crate::services::provider::official_provider_supports_proxy_takeover( + &app, &provider, + ) + { if let Some(handle) = self.app_handle.read().await.as_ref() { let _ = handle.emit( "proxy-official-warning", @@ -809,6 +1017,46 @@ impl ProxyService { Ok(()) } + /// 同步关闭指定应用的 Live 接管(恢复配置并清标志,不停止代理服务)。 + /// + /// 用于 `ProfileService::apply` 等 sync 路径:调用者所在线程可能没有 Tokio + /// runtime,无法执行 `set_takeover_for_app(false)` 里的停止服务/等待任务等 + /// Tokio IO。这里只恢复 Live 文件、删除备份、清除 DB 接管标志,让后续 + /// `ProviderService::switch` 能正常写入官方供应商配置。 + /// + /// 代理服务本身保持运行;当最后一个应用也关闭接管后,下次用户手动关闭 + /// 代理或程序退出时会自然停止。 + pub fn disable_takeover_for_app_sync(&self, app_type: &AppType) -> Result<(), String> { + let app_type_str = app_type.as_str(); + + // 1) 恢复原始 Live 配置(备份 → SSOT → 清理占位符 三层兜底) + futures::executor::block_on(self.restore_live_config_for_app_with_fallback_inner(app_type)) + .map_err(|e| format!("恢复 {app_type_str} Live 配置失败: {e}"))?; + + // 2) 删除该 app 的备份 + futures::executor::block_on(self.db.delete_live_backup(app_type_str)) + .map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?; + + // 3) 设置 proxy_config.enabled = false + let mut config = + futures::executor::block_on(self.db.get_proxy_config_for_app(app_type_str)) + .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; + if config.enabled { + config.enabled = false; + futures::executor::block_on(self.db.update_proxy_config_for_app(config)) + .map_err(|e| format!("清除 {app_type_str} enabled 状态失败: {e}"))?; + } + + // 4) 清除该应用的健康状态 + futures::executor::block_on(self.db.clear_provider_health_for_app(app_type_str)) + .map_err(|e| format!("清除 {app_type_str} 健康状态失败: {e}"))?; + + // 5) 清旧标志 + let _ = futures::executor::block_on(self.db.set_live_takeover_active(false)); + + Ok(()) + } + /// 同步 Live 配置中的 Token 到数据库 /// /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。 @@ -818,6 +1066,7 @@ impl ProxyService { AppType::Claude => self.read_claude_live()?, AppType::Codex => self.read_codex_live()?, AppType::Gemini => self.read_gemini_live()?, + AppType::GrokBuild => self.read_grok_live()?, _ => return Err("该应用不支持代理功能".to_string()), }; @@ -935,6 +1184,12 @@ impl ProxyService { if let Ok(Some(mut provider)) = self.db.get_provider_by_id(&provider_id, "codex") { + // The built-in official row is a routing capability, not + // a credential store. Its auth must remain empty even + // when the live Codex login uses OPENAI_API_KEY mode. + if crate::proxy::providers::is_codex_official_provider(&provider) { + return Ok(()); + } if let Some(token) = live_config .get("auth") .and_then(|v| v.get("OPENAI_API_KEY")) @@ -1032,6 +1287,49 @@ impl ProxyService { } } } + AppType::GrokBuild => { + let provider_id = + crate::settings::get_effective_current_provider(&self.db, &AppType::GrokBuild) + .map_err(|e| format!("获取 Grok Build 当前供应商失败: {e}"))?; + + if let Some(provider_id) = provider_id { + if let Ok(Some(mut provider)) = + self.db.get_provider_by_id(&provider_id, "grokbuild") + { + let live_config_toml = live_config + .get("config") + .and_then(Value::as_str) + .unwrap_or_default(); + if let Some(token) = + crate::grok_config::extract_inline_api_key(live_config_toml) + { + if !token.is_empty() && token != PROXY_TOKEN_PLACEHOLDER { + if let Some(provider_config) = provider + .settings_config + .get("config") + .and_then(Value::as_str) + { + let updated = + crate::grok_config::update_api_key(provider_config, &token) + .map_err(|e| { + format!("更新 Grok Build API Key 失败: {e}") + })?; + provider.settings_config["config"] = json!(updated); + self.db + .update_provider_settings_config( + "grokbuild", + &provider_id, + &provider.settings_config, + ) + .map_err(|e| { + format!("同步 Grok Build Token 到数据库失败: {e}") + })?; + } + } + } + } + } + } _ => {} } @@ -1054,6 +1352,11 @@ impl ProxyService { .await?; } + if let Ok(live_config) = self.read_grok_live() { + self.sync_live_config_to_provider(&AppType::GrokBuild, &live_config) + .await?; + } + log::info!("Live 配置 Token 同步完成"); Ok(()) } @@ -1106,7 +1409,7 @@ impl ProxyService { .map_err(|e| format!("清除接管状态失败: {e}"))?; // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复) - for app_type in ["claude", "codex", "gemini"] { + for app_type in ["claude", "codex", "gemini", "grokbuild"] { if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await { if config.enabled { config.enabled = false; @@ -1190,10 +1493,11 @@ impl ProxyService { } // Codex - if let Ok(config) = self.read_codex_live() { + if let Ok(mut config) = self.read_codex_live() { if Self::live_has_proxy_placeholder_for_app(&AppType::Codex, &config) { log::warn!("codex Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live"); } else { + self.strip_current_official_codex_auth_from_backup(&mut config)?; let json_str = serde_json::to_string(&config) .map_err(|e| format!("序列化 Codex 配置失败: {e}"))?; self.db @@ -1217,16 +1521,31 @@ impl ProxyService { } } + // Grok Build + if let Ok(config) = self.read_grok_live() { + if Self::live_has_proxy_placeholder_for_app(&AppType::GrokBuild, &config) { + log::warn!("grokbuild Live 已被代理接管,不备份;下次 stop 会从 SSOT 重建 Live"); + } else { + let json_str = serde_json::to_string(&config) + .map_err(|e| format!("序列化 Grok Build 配置失败: {e}"))?; + self.db + .save_live_backup("grokbuild", &json_str) + .await + .map_err(|e| format!("备份 Grok Build 配置失败: {e}"))?; + } + } + log::info!("已备份所有应用的 Live 配置"); Ok(()) } /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误) async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> { - let (app_type_str, config) = match app_type { + let (app_type_str, mut config) = match app_type { AppType::Claude => ("claude", self.read_claude_live()?), AppType::Codex => ("codex", self.read_codex_live()?), AppType::Gemini => ("gemini", self.read_gemini_live()?), + AppType::GrokBuild => ("grokbuild", self.read_grok_live()?), _ => return Err("该应用不支持代理功能".to_string()), }; @@ -1239,6 +1558,10 @@ impl ProxyService { return Ok(()); } + if matches!(app_type, AppType::Codex) { + self.strip_current_official_codex_auth_from_backup(&mut config)?; + } + let json_str = serde_json::to_string(&config) .map_err(|e| format!("序列化 {app_type_str} 配置失败: {e}"))?; self.db @@ -1288,6 +1611,21 @@ impl ProxyService { Ok((proxy_url, proxy_codex_base_url)) } + fn apply_grok_takeover_fields(config: &mut Value, proxy_base_url: &str) -> Result<(), String> { + let config_toml = config + .get("config") + .and_then(Value::as_str) + .ok_or_else(|| "Grok Build 配置缺少 config 字段".to_string())?; + let updated = crate::grok_config::apply_proxy_takeover( + config_toml, + proxy_base_url, + PROXY_TOKEN_PLACEHOLDER, + ) + .map_err(|e| format!("更新 Grok Build 接管配置失败: {e}"))?; + config["config"] = json!(updated); + Ok(()) + } + /// 接管各应用的 Live 配置(写入代理地址) /// /// 代理服务器的路由已经根据 API 端点自动区分应用类型: @@ -1298,6 +1636,7 @@ impl ProxyService { /// 因此不需要在 URL 中添加应用前缀。 async fn takeover_live_configs(&self) -> Result<(), String> { let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?; + let proxy_grok_base_url = format!("{}/grokbuild/v1", proxy_url.trim_end_matches('/')); // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token) if let Ok(mut live_config) = self.read_claude_live() { @@ -1312,34 +1651,11 @@ impl ProxyService { log::info!("Claude Live 配置已接管,代理地址: {proxy_url}"); } - // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token) - if let Ok(mut live_config) = self.read_codex_live() { - // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符) - if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) { - auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); - } - - // 2. 修改 config.toml 中的 base_url - let config_str = live_config - .get("config") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let codex_provider = self - .get_current_provider_for_app(&AppType::Codex) - .ok() - .flatten(); - let updated_config = Self::apply_codex_proxy_toml_config_for_provider( - config_str, - &proxy_codex_base_url, - codex_provider.as_ref(), - ); - live_config["config"] = json!(updated_config); - Self::attach_codex_model_catalog_from_provider( - &mut live_config, - codex_provider.as_ref(), - ); - - self.write_codex_takeover_live_for_provider(&live_config, codex_provider.as_ref())?; + // Codex: project the selected provider through the local Responses endpoint. + if self.read_codex_live().is_ok() { + let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?; + self.sync_codex_live_from_provider_while_proxy_active(&codex_provider) + .await?; log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}"); } @@ -1359,12 +1675,20 @@ impl ProxyService { log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}"); } + // Grok Build: keep its own provider namespace while reusing Responses forwarding. + if let Ok(mut live_config) = self.read_grok_live() { + Self::apply_grok_takeover_fields(&mut live_config, &proxy_grok_base_url)?; + self.write_grok_live(&live_config)?; + log::info!("Grok Build Live 配置已接管,代理地址: {proxy_grok_base_url}"); + } + Ok(()) } /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误) async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> { let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?; + let proxy_grok_base_url = format!("{}/grokbuild/v1", proxy_url.trim_end_matches('/')); match app_type { AppType::Claude => { @@ -1381,29 +1705,10 @@ impl ProxyService { log::info!("Claude Live 配置已接管,代理地址: {proxy_url}"); } AppType::Codex => { - let mut live_config = self.read_codex_live()?; - - if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) { - auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); - } - - let config_str = live_config - .get("config") - .and_then(|v| v.as_str()) - .unwrap_or(""); + self.read_codex_live()?; let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?; - let updated_config = Self::apply_codex_proxy_toml_config_for_provider( - config_str, - &proxy_codex_base_url, - Some(&codex_provider), - ); - live_config["config"] = json!(updated_config); - Self::attach_codex_model_catalog_from_provider( - &mut live_config, - Some(&codex_provider), - ); - - self.write_codex_takeover_live_for_provider(&live_config, Some(&codex_provider))?; + self.sync_codex_live_from_provider_while_proxy_active(&codex_provider) + .await?; log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}"); } AppType::Gemini => { @@ -1422,6 +1727,12 @@ impl ProxyService { self.write_gemini_live(&live_config)?; log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}"); } + AppType::GrokBuild => { + let mut live_config = self.read_grok_live()?; + Self::apply_grok_takeover_fields(&mut live_config, &proxy_grok_base_url)?; + self.write_grok_live(&live_config)?; + log::info!("Grok Build Live 配置已接管,代理地址: {proxy_grok_base_url}"); + } _ => return Err("该应用不支持代理功能".to_string()), } @@ -1430,7 +1741,8 @@ impl ProxyService { /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过) async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> { - let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?; + let (proxy_url, _) = self.build_proxy_urls().await?; + let proxy_grok_base_url = format!("{}/grokbuild/v1", proxy_url.trim_end_matches('/')); match app_type { AppType::Claude => { @@ -1456,37 +1768,10 @@ impl ProxyService { let _ = self.write_claude_live(&live_config); } } - AppType::Codex => { - if let Ok(mut live_config) = self.read_codex_live() { - if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) - { - auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); - } - - let config_str = live_config - .get("config") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let codex_provider = self - .get_current_provider_for_app(&AppType::Codex) - .ok() - .flatten(); - let updated_config = Self::apply_codex_proxy_toml_config_for_provider( - config_str, - &proxy_codex_base_url, - codex_provider.as_ref(), - ); - live_config["config"] = json!(updated_config); - Self::attach_codex_model_catalog_from_provider( - &mut live_config, - codex_provider.as_ref(), - ); - - let _ = self.write_codex_takeover_live_for_provider( - &live_config, - codex_provider.as_ref(), - ); - } + AppType::Codex if self.read_codex_live().is_ok() => { + let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?; + self.sync_codex_live_from_provider_while_proxy_active(&codex_provider) + .await?; } AppType::Gemini => { if let Ok(mut live_config) = self.read_gemini_live() { @@ -1503,6 +1788,12 @@ impl ProxyService { let _ = self.write_gemini_live(&live_config); } } + AppType::GrokBuild => { + if let Ok(mut live_config) = self.read_grok_live() { + Self::apply_grok_takeover_fields(&mut live_config, &proxy_grok_base_url)?; + let _ = self.write_grok_live(&live_config); + } + } _ => {} } @@ -1523,7 +1814,7 @@ impl ProxyService { if let Ok(Some(backup)) = self.db.get_live_backup("codex").await { let config: Value = serde_json::from_str(&backup.original_config) .map_err(|e| format!("解析 Codex 备份失败: {e}"))?; - self.write_codex_live(&config)?; + self.write_codex_restore_backup(&config)?; log::info!("Codex Live 配置已恢复"); } } @@ -1535,6 +1826,14 @@ impl ProxyService { log::info!("Gemini Live 配置已恢复"); } } + AppType::GrokBuild => { + if let Ok(Some(backup)) = self.db.get_live_backup("grokbuild").await { + let config: Value = serde_json::from_str(&backup.original_config) + .map_err(|e| format!("解析 Grok Build 备份失败: {e}"))?; + self.write_grok_live(&config)?; + log::info!("Grok Build Live 配置已恢复"); + } + } _ => {} } @@ -1545,7 +1844,12 @@ impl ProxyService { async fn restore_live_configs(&self) -> Result<(), String> { let mut errors = Vec::new(); - for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] { + for app_type in [ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::GrokBuild, + ] { if let Err(e) = self .restore_live_config_for_app_with_fallback(&app_type) .await @@ -1570,7 +1874,7 @@ impl ProxyService { .await } - async fn restore_live_config_for_app_with_fallback_inner( + pub(crate) async fn restore_live_config_for_app_with_fallback_inner( &self, app_type: &AppType, ) -> Result<(), String> { @@ -1632,8 +1936,9 @@ impl ProxyService { fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> { match app_type { AppType::Claude => self.write_claude_live(config), - AppType::Codex => self.write_codex_live(config), + AppType::Codex => self.write_codex_restore_backup(config), AppType::Gemini => self.write_gemini_live(config), + AppType::GrokBuild => self.write_grok_live(config), _ => Err("该应用不支持代理功能".to_string()), } } @@ -1652,6 +1957,10 @@ impl ProxyService { Ok(config) => Self::is_gemini_live_taken_over(&config), Err(_) => false, }, + AppType::GrokBuild => match self.read_grok_live() { + Ok(config) => Self::is_grok_live_taken_over(&config), + Err(_) => false, + }, _ => false, } } @@ -1707,6 +2016,7 @@ impl ProxyService { AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(), AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(), AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(), + AppType::GrokBuild => self.cleanup_grok_takeover_placeholders_in_live(), _ => Ok(()), } } @@ -1766,6 +2076,7 @@ impl ProxyService { app_type: &AppType, ) -> Result { let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?; + let proxy_grok_base_url = format!("{}/grokbuild/v1", proxy_url.trim_end_matches('/')); match app_type { AppType::Claude => { @@ -1787,7 +2098,7 @@ impl ProxyService { Self::proxy_urls_match(url, &proxy_codex_base_url) }) }); - Ok(Self::codex_live_has_proxy_placeholder(&config) && base_url_matches) + Ok(Self::is_codex_live_taken_over(&config) && base_url_matches) } AppType::Gemini => { let config = self.read_gemini_live()?; @@ -1798,6 +2109,19 @@ impl ProxyService { .is_some_and(|url| Self::proxy_urls_match(url, &proxy_url)); Ok(Self::is_gemini_live_taken_over(&config) && base_url_matches) } + AppType::GrokBuild => { + let config = self.read_grok_live()?; + let base_url_matches = + config + .get("config") + .and_then(Value::as_str) + .is_some_and(|config_toml| { + crate::grok_config::base_url_matches(config_toml, |url| { + Self::proxy_urls_match(url, &proxy_grok_base_url) + }) + }); + Ok(Self::is_grok_live_taken_over(&config) && base_url_matches) + } _ => Ok(false), } } @@ -1850,6 +2174,8 @@ impl ProxyService { token == PROXY_TOKEN_PLACEHOLDER }) .map_err(|e| format!("清理 Codex 接管占位符失败: {e}"))?; + let updated = crate::codex_config::remove_codex_official_proxy_route(&updated) + .map_err(|e| format!("清理 Codex 官方接管路由失败: {e}"))?; config["config"] = json!(updated); } @@ -1886,10 +2212,27 @@ impl ProxyService { Ok(()) } + fn cleanup_grok_takeover_placeholders_in_live(&self) -> Result<(), String> { + let config = self.read_grok_live()?; + let Some(config_toml) = config.get("config").and_then(Value::as_str) else { + return Ok(()); + }; + if !crate::grok_config::has_proxy_placeholder(config_toml, PROXY_TOKEN_PLACEHOLDER) { + return Ok(()); + } + + // A valid provider snapshot should normally restore before this fallback. + // Clearing the token prevents a stale local route from looking usable. + let updated = crate::grok_config::update_api_key(config_toml, "") + .map_err(|e| format!("清理 Grok Build 接管占位符失败: {e}"))?; + crate::config::write_text_file(&crate::grok_config::get_grok_config_path(), &updated) + .map_err(|e| format!("写入 Grok Build 配置失败: {e}")) + } + /// 检查是否处于 Live 接管模式 pub async fn is_takeover_active(&self) -> Result { let status = self.get_takeover_status().await?; - Ok(status.claude || status.codex || status.gemini) + Ok(status.claude || status.codex || status.gemini || status.grokbuild) } /// 从异常退出中恢复(启动时调用) @@ -1939,6 +2282,12 @@ impl ProxyService { } } + if let Ok(config) = self.read_grok_live() { + if Self::is_grok_live_taken_over(&config) { + return true; + } + } + false } @@ -1983,6 +2332,10 @@ impl ProxyService { fn is_codex_live_taken_over(config: &Value) -> bool { Self::codex_live_has_proxy_placeholder(config) + || config + .get("config") + .and_then(|v| v.as_str()) + .is_some_and(crate::codex_config::codex_config_has_official_proxy_route) } fn is_gemini_live_taken_over(config: &Value) -> bool { @@ -1993,6 +2346,15 @@ impl ProxyService { env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) } + fn is_grok_live_taken_over(config: &Value) -> bool { + config + .get("config") + .and_then(Value::as_str) + .is_some_and(|config_toml| { + crate::grok_config::has_proxy_placeholder(config_toml, PROXY_TOKEN_PLACEHOLDER) + }) + } + /// 判断给定的 Live/备份配置是否已被代理接管(包含占位符) /// /// 用途:检测"备份里存的其实是代理配置"这种异常历史状态。 @@ -2002,8 +2364,9 @@ impl ProxyService { fn live_has_proxy_placeholder_for_app(app_type: &AppType, config: &Value) -> bool { match app_type { AppType::Claude => Self::is_claude_live_taken_over(config), - AppType::Codex => Self::codex_live_has_proxy_placeholder(config), + AppType::Codex => Self::is_codex_live_taken_over(config), AppType::Gemini => Self::is_gemini_live_taken_over(config), + AppType::GrokBuild => Self::is_grok_live_taken_over(config), _ => false, } } @@ -2034,7 +2397,7 @@ impl ProxyService { } /// 仅供已持有 per-app 切换锁的调用方使用。 - async fn update_live_backup_from_provider_inner( + pub(crate) async fn update_live_backup_from_provider_inner( &self, app_type: &str, provider: &Provider, @@ -2067,9 +2430,16 @@ impl ProxyService { .map_err(|e| format!("解析 {app_type} 现有备份失败: {e}")) }) .transpose()?; + // A stale takeover marker can survive without its DB backup (for + // example after a partial cleanup). In that abnormal state the live + // auth file is still the only copy of the user's login, so use it as + // the preservation source instead of replacing it with the empty + // official seed snapshot. + let existing_backup_value = + existing_backup_value.or_else(|| self.read_codex_live().ok()); if let Some(existing_value) = existing_backup_value.as_ref() { - Self::preserve_codex_mcp_servers_from_existing_config( + Self::preserve_toml_mcp_servers_from_existing_config( &mut effective_settings, existing_value, )?; @@ -2079,11 +2449,16 @@ impl ProxyService { existing_value, account_id, )?; - } else { - Self::preserve_codex_oauth_auth_in_backup( + } else if provider + .meta + .as_ref() + .and_then(|meta| meta.managed_account_id_for("codex_oauth")) + .is_none() + { + Self::preserve_codex_auth_in_backup( &mut effective_settings, existing_value, - provider, + crate::proxy::providers::is_codex_official_provider(provider), )?; } } @@ -2095,6 +2470,35 @@ impl ProxyService { &mut effective_settings, ) .map_err(|e| format!("注入统一会话路由失败: {e}"))?; + + if crate::proxy::providers::is_codex_official_provider(provider) { + // auth.json keeps rotating independently while takeover is + // active. Persisting it in the DB backup would later roll a + // valid R1 generation back to the frozen R0 generation. + if let Some(root) = effective_settings.as_object_mut() { + root.remove("auth"); + } + } + } + + if matches!(app_type_enum, AppType::GrokBuild) { + let existing_value = self + .db + .get_live_backup(app_type) + .await + .map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))? + .map(|backup| { + serde_json::from_str::(&backup.original_config) + .map_err(|e| format!("解析 {app_type} 现有备份失败: {e}")) + }) + .transpose()? + .or_else(|| self.read_grok_live().ok()); + if let Some(existing_value) = existing_value.as_ref() { + Self::preserve_toml_mcp_servers_from_existing_config( + &mut effective_settings, + existing_value, + )?; + } } let backup_json = match app_type_enum { @@ -2102,6 +2506,8 @@ impl ProxyService { .map_err(|e| format!("序列化 Claude 配置失败: {e}"))?, AppType::Codex => serde_json::to_string(&effective_settings) .map_err(|e| format!("序列化 Codex 配置失败: {e}"))?, + AppType::GrokBuild => serde_json::to_string(&effective_settings) + .map_err(|e| format!("序列化 Grok Build 配置失败: {e}"))?, AppType::Gemini => { // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。 let env_backup = if let Some(env) = effective_settings.get("env") { @@ -2120,19 +2526,6 @@ impl ProxyService { .await .map_err(|e| format!("更新 {app_type} 备份失败: {e}"))?; - if matches!(app_type_enum, AppType::Codex) - && provider - .meta - .as_ref() - .and_then(|meta| meta.managed_account_id_for("codex_oauth")) - .is_some() - { - if let Some(auth) = effective_settings.get("auth") { - crate::codex_config::record_codex_managed_oauth_live_auth(auth) - .map_err(|e| format!("记录 Codex 托管认证标记失败: {e}"))?; - } - } - log::info!("已更新 {app_type} Live 备份(热切换)"); Ok(()) } @@ -2159,19 +2552,25 @@ impl ProxyService { .map_err(|e| format!("读取供应商失败: {e}"))? .ok_or_else(|| format!("供应商不存在: {provider_id}"))?; - // Defense-in-depth: block official providers during proxy takeover - if provider.category.as_deref() == Some("official") { + // Defense-in-depth: only the built-in Codex official provider supports + // native OpenAI auth passthrough during takeover. + if provider.category.as_deref() == Some("official") + && !crate::services::provider::official_provider_supports_proxy_takeover( + &app_type_enum, + &provider, + ) + { return Err( "代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)" .to_string(), ); } - let logical_target_changed = + let previous_provider_id = crate::settings::get_effective_current_provider(&self.db, &app_type_enum) - .map_err(|e| format!("读取当前供应商失败: {e}"))? - .as_deref() - != Some(provider_id); + .map_err(|e| format!("读取当前供应商失败: {e}"))?; + let previous_local_provider_id = crate::settings::get_current_provider(&app_type_enum); + let logical_target_changed = previous_provider_id.as_deref() != Some(provider_id); let has_backup = self .db @@ -2182,48 +2581,122 @@ impl ProxyService { let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum); let should_sync_backup = has_backup || live_taken_over; - self.db - .set_current_provider(app_type_enum.as_str(), provider_id) - .map_err(|e| format!("更新当前供应商失败: {e}"))?; - crate::settings::set_current_provider(&app_type_enum, Some(provider_id)) - .map_err(|e| format!("更新本地当前供应商失败: {e}"))?; + // All fallible backup/live writes must finish before committing the logical + // current provider. Otherwise a failed hot switch leaves the UI pointing at + // the new provider while the proxy still serves the old one (and the next + // query may fall back to the first provider). + let previous_backup = if should_sync_backup { + self.db + .get_live_backup(app_type_enum.as_str()) + .await + .map_err(|e| format!("读取 {app_type} 原备份失败: {e}"))? + } else { + None + }; + let previous_codex_live_state = + if should_sync_backup && matches!(app_type_enum, AppType::Codex) { + Some( + crate::codex_config::CodexLiveStateSnapshot::capture() + .map_err(|error| format!("捕获 Codex 热切换前状态失败: {error}"))?, + ) + } else { + None + }; - if should_sync_backup { - self.update_live_backup_from_provider_inner(app_type, &provider, None) - .await?; + let prepare_result: Result<(), String> = async { + if should_sync_backup { + self.update_live_backup_from_provider_inner(app_type, &provider, None) + .await?; - if matches!(app_type_enum, AppType::Claude) { - self.sync_claude_live_from_provider_while_proxy_active(&provider) - .await?; - } else if live_taken_over && matches!(app_type_enum, AppType::Codex) { - self.sync_codex_live_from_provider_while_proxy_active(&provider) - .await?; + if matches!(app_type_enum, AppType::Claude) { + self.sync_claude_live_from_provider_while_proxy_active(&provider) + .await?; + } else if live_taken_over && matches!(app_type_enum, AppType::Codex) { + self.sync_codex_live_from_provider_while_proxy_active(&provider) + .await?; + } else if live_taken_over && matches!(app_type_enum, AppType::GrokBuild) { + self.sync_grok_live_from_provider_while_proxy_active(&provider) + .await?; + } } + + if has_backup && !live_taken_over && matches!(app_type_enum, AppType::Codex) { + let effective_settings = + build_effective_provider_for_live_with_codex_oauth_manager( + self.db.as_ref(), + &AppType::Codex, + &provider, + &self.codex_oauth_manager, + ) + .map_err(|e| format!("构建 Codex 有效配置失败: {e}"))? + .settings_config; + let auth = effective_settings + .get("auth") + .ok_or_else(|| "Codex 供应商缺少 auth 配置".to_string())?; + let config_str = effective_settings.get("config").and_then(|v| v.as_str()); + let profile = + crate::proxy::providers::resolve_codex_catalog_tool_profile(&provider); + + crate::codex_config::write_codex_provider_live_with_catalog( + &effective_settings, + provider.category.as_deref(), + auth, + config_str, + profile, + ) + .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; + } + + Ok(()) + } + .await; + + if let Err(error) = prepare_result { + self.rollback_hot_switch_preparation( + &app_type_enum, + previous_backup.as_ref(), + previous_provider_id.as_deref(), + should_sync_backup, + live_taken_over, + previous_codex_live_state.as_ref(), + ) + .await; + return Err(error); } - if has_backup && !live_taken_over && matches!(app_type_enum, AppType::Codex) { - let effective_settings = build_effective_settings_with_common_config( - self.db.as_ref(), - &AppType::Codex, - &provider, + if let Err(error) = crate::settings::set_current_provider(&app_type_enum, Some(provider_id)) + { + self.rollback_hot_switch_preparation( + &app_type_enum, + previous_backup.as_ref(), + previous_provider_id.as_deref(), + should_sync_backup, + live_taken_over, + previous_codex_live_state.as_ref(), ) - .map_err(|e| format!("构建 Codex 有效配置失败: {e}"))?; - let auth = effective_settings - .get("auth") - .ok_or_else(|| "Codex 供应商缺少 auth 配置".to_string())?; - let config_str = effective_settings.get("config").and_then(|v| v.as_str()); - let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format( - provider.meta.as_ref().and_then(|m| m.api_format.as_deref()), - ); - - crate::codex_config::write_codex_provider_live_with_catalog( - &effective_settings, - provider.category.as_deref(), - auth, - config_str, - profile, + .await; + return Err(format!("更新本地当前供应商失败: {error}")); + } + if let Err(error) = self + .db + .set_current_provider(app_type_enum.as_str(), provider_id) + { + if let Err(rollback_error) = crate::settings::set_current_provider( + &app_type_enum, + previous_local_provider_id.as_deref(), + ) { + log::error!("数据库切换失败后恢复本地当前供应商失败: {rollback_error}"); + } + self.rollback_hot_switch_preparation( + &app_type_enum, + previous_backup.as_ref(), + previous_provider_id.as_deref(), + should_sync_backup, + live_taken_over, + previous_codex_live_state.as_ref(), ) - .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; + .await; + return Err(format!("更新当前供应商失败: {error}")); } if let Some(server) = self.server.read().await.as_ref() { @@ -2242,13 +2715,13 @@ impl ProxyService { self.switch_locks.lock_for_app(app_type).await } - fn preserve_codex_mcp_servers_from_existing_config( + fn preserve_toml_mcp_servers_from_existing_config( target_settings: &mut Value, existing_config: &Value, ) -> Result<(), String> { let target_obj = target_settings .as_object_mut() - .ok_or_else(|| "Codex 备份必须是 JSON 对象".to_string())?; + .ok_or_else(|| "TOML 应用备份必须是 JSON 对象".to_string())?; let target_config = target_obj .get("config") @@ -2259,7 +2732,7 @@ impl ProxyService { } else { target_config .parse::() - .map_err(|e| format!("解析新的 Codex config.toml 失败: {e}"))? + .map_err(|e| format!("解析新的 config.toml 失败: {e}"))? }; let existing_config = existing_config @@ -2273,7 +2746,7 @@ impl ProxyService { let existing_doc = existing_config .parse::() - .map_err(|e| format!("解析现有 Codex 备份失败: {e}"))?; + .map_err(|e| format!("解析现有 config.toml 备份失败: {e}"))?; if let Some(existing_mcp_servers) = existing_doc.get("mcp_servers") { match target_doc.get_mut("mcp_servers") { @@ -2289,7 +2762,7 @@ impl ProxyService { } } else { log::warn!( - "Codex config contains a non-table mcp_servers section; skipping MCP merge" + "config.toml contains a non-table mcp_servers section; skipping MCP merge" ); } } @@ -2328,28 +2801,19 @@ impl ProxyService { Ok(()) } - fn preserve_codex_oauth_auth_in_backup( + fn preserve_codex_auth_in_backup( target_settings: &mut Value, existing_backup: &Value, - provider: &Provider, + preserve_api_key: bool, ) -> Result<(), String> { - if !crate::settings::preserve_codex_official_auth_on_switch() { - return Ok(()); - } - - if provider - .meta - .as_ref() - .and_then(|meta| meta.managed_account_id_for("codex_oauth")) - .map(|id| !id.trim().is_empty()) - .unwrap_or(false) - { - return Ok(()); - } - let Some(existing_auth) = existing_backup .get("auth") - .filter(|auth| crate::codex_config::codex_auth_has_oauth_login_material(auth)) + .filter(|auth| { + !Self::codex_auth_has_proxy_placeholder(auth) + && (crate::codex_config::codex_auth_has_oauth_login_material(auth) + || (preserve_api_key + && crate::codex_config::codex_auth_has_login_material(auth))) + }) .cloned() else { return Ok(()); @@ -2391,33 +2855,69 @@ impl ProxyService { // ==================== Live 配置读写辅助方法 ==================== - /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现) - fn update_toml_base_url(toml_str: &str, new_url: &str) -> String { - crate::codex_config::update_codex_toml_field(toml_str, "base_url", new_url) - .unwrap_or_else(|_| toml_str.to_string()) - } - /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。 /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。 fn apply_codex_proxy_toml_config_for_provider( toml_str: &str, proxy_url: &str, provider: Option<&Provider>, - ) -> String { - let updated = Self::update_toml_base_url(toml_str, proxy_url); + ) -> Result { + if provider.is_some_and(crate::proxy::providers::is_codex_official_provider) { + return crate::codex_config::apply_codex_official_proxy_route(toml_str, proxy_url) + .map_err(|e| format!("生成 Codex 官方接管配置失败: {e}")); + } + + let updated = crate::codex_config::update_codex_toml_field(toml_str, "base_url", proxy_url) + .map_err(|e| format!("更新 Codex 代理地址失败: {e}"))?; let mut updated = crate::codex_config::update_codex_toml_field(&updated, "wire_api", "responses") - .unwrap_or(updated); + .map_err(|e| format!("更新 Codex wire_api 失败: {e}"))?; if let Some(upstream_model) = provider.and_then(crate::proxy::providers::codex_provider_upstream_model) { updated = crate::codex_config::update_codex_toml_field(&updated, "model", &upstream_model) - .unwrap_or(updated); + .map_err(|e| format!("更新 Codex 上游模型失败: {e}"))?; } - updated + Ok(updated) + } + + fn apply_codex_takeover_auth_placeholder(settings: &mut Value, provider: Option<&Provider>) { + if provider.is_some_and(crate::proxy::providers::is_codex_official_provider) { + return; + } + + if let Some(auth) = settings.get_mut("auth").and_then(|v| v.as_object_mut()) { + auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); + } else if let Some(root) = settings.as_object_mut() { + root.insert( + "auth".to_string(), + json!({ "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER }), + ); + } + } + + fn apply_codex_takeover_fields_for_provider( + settings: &mut Value, + proxy_base_url: &str, + provider: &Provider, + ) -> Result<(), String> { + Self::apply_codex_takeover_auth_placeholder(settings, Some(provider)); + let config_text = settings + .get("config") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string(); + let projected = Self::apply_codex_proxy_toml_config_for_provider( + &config_text, + proxy_base_url, + Some(provider), + )?; + settings["config"] = json!(projected); + Self::attach_codex_model_catalog_from_provider(settings, Some(provider)); + Ok(()) } fn attach_codex_model_catalog_from_provider( @@ -2517,9 +3017,7 @@ impl ProxyService { .get("auth") .ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?; let config_str = config.get("config").and_then(|v| v.as_str()); - let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format( - provider.meta.as_ref().and_then(|m| m.api_format.as_deref()), - ); + let profile = crate::proxy::providers::resolve_codex_catalog_tool_profile(provider); crate::codex_config::write_codex_provider_live_with_catalog( config, @@ -2540,27 +3038,60 @@ impl ProxyService { config: &Value, provider: Option<&Provider>, ) -> Result<(), String> { - if crate::settings::preserve_codex_official_auth_on_switch() { - if let Some(auth) = config - .get("auth") - .filter(|auth| Self::codex_auth_has_proxy_placeholder(auth)) - { - let config_str = config.get("config").and_then(|v| v.as_str()).unwrap_or(""); - let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format( - provider.and_then(|p| p.meta.as_ref()?.api_format.as_deref()), - ); - let prepared_config = - crate::codex_config::prepare_codex_live_config_text_with_optional_catalog( - config, config_str, profile, - ) - .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; - let live_config = - crate::codex_config::prepare_codex_provider_live_config(auth, &prepared_config) - .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; - crate::codex_config::write_codex_live_config_atomic(Some(&live_config)) - .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; + let official_passthrough = + provider.is_some_and(crate::proxy::providers::is_codex_official_provider); + let managed_official = official_passthrough + && provider + .and_then(|provider| provider.meta.as_ref()) + .and_then(|meta| meta.managed_account_id_for("codex_oauth")) + .is_some_and(|account_id| !account_id.trim().is_empty()); + let placeholder_auth = config + .get("auth") + .is_some_and(Self::codex_auth_has_proxy_placeholder); + + // Takeover must never overwrite Codex's long-lived ChatGPT login. For + // third-party providers the placeholder is moved into config.toml; for + // codex-official no placeholder is needed because requires_openai_auth + // makes Codex supply its native authorization. + if official_passthrough || placeholder_auth { + let config_str = config.get("config").and_then(|v| v.as_str()).unwrap_or(""); + let profile = provider + .map(crate::proxy::providers::resolve_codex_catalog_tool_profile) + .unwrap_or(crate::codex_config::CodexCatalogToolProfile::ProxyChat); + let prepared_config = + crate::codex_config::prepare_codex_live_config_text_with_optional_catalog( + config, config_str, profile, + ) + .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; + if managed_official { + let auth = config + .get("auth") + .ok_or_else(|| "Codex 托管官方配置缺少 auth 字段".to_string())?; + // An explicitly managed official account is different from the + // unbound native-login passthrough: the selected account owns + // auth.json and must replace any previously active account. + crate::codex_config::write_codex_live_for_provider( + Some("official"), + auth, + Some(&prepared_config), + ) + .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; + crate::codex_config::record_codex_managed_oauth_live_auth(auth) + .map_err(|e| format!("记录 Codex 托管认证标记失败: {e}"))?; return Ok(()); } + let live_config = if official_passthrough { + prepared_config + } else { + crate::codex_config::prepare_codex_provider_live_config( + config.get("auth").unwrap_or(&Value::Null), + &prepared_config, + ) + .map_err(|e| format!("写入 Codex 配置失败: {e}"))? + }; + crate::codex_config::write_codex_live_config_atomic(Some(&live_config)) + .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; + return Ok(()); } self.write_codex_live_for_provider(config, provider) @@ -2604,9 +3135,11 @@ impl ProxyService { match (auth, prepared_cfg.as_deref()) { (Some(auth), Some(cfg)) => { - let auth_path = get_codex_auth_path(); if auth.as_object().is_some_and(|obj| obj.is_empty()) { - let _ = crate::config::delete_file(&auth_path); + // An empty provider snapshot must not destroy an existing + // Codex login. This is especially important when takeover + // switches from an API-key-backed official session to the + // built-in empty `codex-official` seed. let config_path = get_codex_config_path(); crate::config::write_text_file(&config_path, cfg) .map_err(|e| format!("写入 Codex config 失败: {e}"))?; @@ -2651,6 +3184,16 @@ impl ProxyService { Ok(()) } + fn read_grok_live(&self) -> Result { + crate::grok_config::read_grok_live_settings() + .map_err(|e| format!("读取 Grok Build 配置失败: {e}")) + } + + fn write_grok_live(&self, config: &Value) -> Result<(), String> { + crate::grok_config::write_grok_live_settings(config) + .map_err(|e| format!("写入 Grok Build 配置失败: {e}")) + } + // ==================== 原有方法 ==================== /// 获取服务器状态 @@ -2747,6 +3290,11 @@ impl ProxyService { .await?; updated_any = true; } + if takeover.grokbuild { + self.takeover_live_config_best_effort(&AppType::GrokBuild) + .await?; + updated_any = true; + } if updated_any { log::info!("已同步更新 Live 配置中的代理地址"); @@ -2960,7 +3508,8 @@ mod tests { "ANTHROPIC_MODEL": "claude-sonnet-4.6", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4.5", "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4.6", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-sonnet-4.6" + "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-sonnet-4.6", + "CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4.6[1M]" } }), None, @@ -2980,7 +3529,8 @@ mod tests { "ANTHROPIC_DEFAULT_SONNET_MODEL": "stale-sonnet", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet", "ANTHROPIC_DEFAULT_OPUS_MODEL": "stale-opus", - "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "Stale Opus" + "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "Stale Opus", + "CLAUDE_CODE_SUBAGENT_MODEL": "stale-subagent" } }); ProxyService::apply_claude_takeover_fields_for_provider( @@ -3020,10 +3570,53 @@ mod tests { "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", Some("claude-sonnet-4.6"), ); + assert_env_str( + env, + "CLAUDE_CODE_SUBAGENT_MODEL", + Some("claude-sonnet-4.6[1M]"), + ); assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER)); assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None); } + #[test] + fn managed_account_claude_takeover_removes_stale_subagent_model_when_provider_omits_it() { + let mut provider = Provider::with_id( + "codex".to_string(), + "Codex".to_string(), + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "provider-sonnet" + } + }), + None, + ); + provider.meta = Some(ProviderMeta { + provider_type: Some("codex_oauth".to_string()), + ..Default::default() + }); + + let mut live_config = json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://stale.example.com", + "ANTHROPIC_API_KEY": "stale-key", + "CLAUDE_CODE_SUBAGENT_MODEL": "stale-subagent" + } + }); + ProxyService::apply_claude_takeover_fields_for_provider( + &mut live_config, + "http://127.0.0.1:15721", + &provider, + ); + + let env = live_config + .get("env") + .and_then(|value| value.as_object()) + .expect("env should exist"); + assert_env_str(env, "CLAUDE_CODE_SUBAGENT_MODEL", None); + } + #[test] fn managed_account_claude_takeover_sources_codex_models_from_provider() { let mut provider = Provider::with_id( @@ -3087,7 +3680,8 @@ mod tests { assert_env_str(env, "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", Some("gpt-5.4")); assert_env_str(env, "ANTHROPIC_DEFAULT_OPUS_MODEL", Some("claude-opus-4-8")); assert_env_str(env, "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", Some("gpt-5.4")); - assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER)); + // Codex 系只保留 AUTH_TOKEN;双键会触发 Claude Code 告警(#4919) + assert_env_str(env, "ANTHROPIC_API_KEY", None); assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER)); } @@ -3120,7 +3714,7 @@ mod tests { .get("env") .and_then(|value| value.as_object()) .expect("env should exist"); - assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER)); + assert_env_str(env, "ANTHROPIC_API_KEY", None); assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER)); } @@ -3152,10 +3746,49 @@ mod tests { .get("env") .and_then(|value| value.as_object()) .expect("env should exist"); - assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER)); + assert_env_str(env, "ANTHROPIC_API_KEY", None); assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER)); } + // #4919 复现场景:从第三方 Claude 供应商(live 已有 AUTH_TOKEN)切换到 + // Codex 受管供应商时,只应保留 AUTH_TOKEN 占位符,不得同时写入 API_KEY。 + #[test] + fn managed_account_claude_takeover_codex_from_third_party_keeps_single_auth_key() { + let mut provider = Provider::with_id( + "codex".to_string(), + "Codex".to_string(), + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex" + } + }), + None, + ); + provider.meta = Some(ProviderMeta { + provider_type: Some("codex_oauth".to_string()), + ..Default::default() + }); + + let mut live_config = json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic", + "ANTHROPIC_AUTH_TOKEN": "sk-third-party" + } + }); + ProxyService::apply_claude_takeover_fields_for_provider( + &mut live_config, + "http://127.0.0.1:15721", + &provider, + ); + + let env = live_config + .get("env") + .and_then(|value| value.as_object()) + .expect("env should exist"); + assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER)); + assert_env_str(env, "ANTHROPIC_API_KEY", None); + } + #[test] fn managed_account_claude_takeover_copilot_removes_stale_auth_token() { let mut provider = Provider::with_id( @@ -3544,6 +4177,233 @@ wire_api = "responses" .expect("reset settings"); } + #[tokio::test] + #[serial] + async fn codex_takeover_hot_switches_between_builtin_official_and_third_party() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + // Exercise the default setting: takeover itself must now preserve native + // auth regardless of the legacy compatibility toggle. + crate::settings::update_settings(crate::settings::AppSettings::default()) + .expect("reset settings"); + + let db = Arc::new(Database::memory().expect("init db")); + use_ephemeral_proxy_port(&db).await; + let service = ProxyService::new(db.clone()); + let oauth_auth = json!({ + "auth_mode": "chatgpt", + "tokens": { + "id_token": "oauth-id", + "access_token": "oauth-access" + } + }); + crate::codex_config::write_codex_live_atomic(&oauth_auth, Some("model = \"gpt-5.4\"\n")) + .expect("seed official live config"); + + let mut official = Provider::with_id( + "codex-official".to_string(), + "OpenAI Official".to_string(), + json!({ "auth": {}, "config": "model = \"gpt-5.4\"\n" }), + None, + ); + official.category = Some("official".to_string()); + db.save_provider("codex", &official) + .expect("save official provider"); + + let mut third_party = Provider::with_id( + "rightcode".to_string(), + "RightCode".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "rightcode-key" }, + "config": r#"model_provider = "rightcode" + +[model_providers.rightcode] +name = "RightCode" +base_url = "https://rightcode.example/v1" +wire_api = "responses" +"# + }), + None, + ); + third_party.category = Some("custom".to_string()); + db.save_provider("codex", &third_party) + .expect("save third-party provider"); + db.set_current_provider("codex", "codex-official") + .expect("set current provider"); + crate::settings::set_current_provider(&AppType::Codex, Some("codex-official")) + .expect("set local current provider"); + + service + .set_takeover_for_app("codex", true) + .await + .expect("enable official takeover"); + + let read_auth = || -> Value { + crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) + .expect("read live auth") + }; + assert_eq!(read_auth(), oauth_auth); + let official_live = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read official takeover config"); + assert!(crate::codex_config::codex_config_has_official_proxy_route( + &official_live + )); + assert!(official_live.contains("requires_openai_auth = true")); + assert!(!official_live.contains(PROXY_TOKEN_PLACEHOLDER)); + + service + .hot_switch_provider("codex", "rightcode") + .await + .expect("switch to third-party provider"); + assert_eq!( + read_auth(), + oauth_auth, + "third-party route must preserve OAuth" + ); + let third_party_live = + std::fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read third-party takeover config"); + assert!(third_party_live.contains(PROXY_TOKEN_PLACEHOLDER)); + assert!(!crate::codex_config::codex_config_has_official_proxy_route( + &third_party_live + )); + + service + .hot_switch_provider("codex", "codex-official") + .await + .expect("switch back to official provider"); + assert_eq!( + read_auth(), + oauth_auth, + "official switch must reuse native OAuth" + ); + let official_live = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read restored official takeover config"); + assert!(crate::codex_config::codex_config_has_official_proxy_route( + &official_live + )); + assert!(!official_live.contains(PROXY_TOKEN_PLACEHOLDER)); + + service + .set_takeover_for_app("codex", false) + .await + .expect("disable takeover"); + assert_eq!(read_auth(), oauth_auth); + } + + #[test] + fn codex_takeover_backup_preserves_api_key_login_material() { + let mut target = json!({ "auth": {}, "config": "" }); + let existing = json!({ + "auth": { "OPENAI_API_KEY": "sk-real" }, + "config": "model = \"gpt-5.4\"\n" + }); + + ProxyService::preserve_codex_auth_in_backup(&mut target, &existing, true) + .expect("preserve API-key auth"); + assert_eq!(target["auth"]["OPENAI_API_KEY"], "sk-real"); + } + + #[tokio::test] + #[serial] + async fn codex_official_backup_rebuild_keeps_live_auth_out_of_backup() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + crate::codex_config::write_codex_live_atomic( + &json!({ "OPENAI_API_KEY": "sk-real" }), + Some("model = \"gpt-5.4\"\n"), + ) + .expect("seed live auth"); + let mut official = Provider::with_id( + crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), + "OpenAI Official".to_string(), + json!({ "auth": {}, "config": "" }), + None, + ); + official.category = Some("official".to_string()); + + service + .update_live_backup_from_provider_inner("codex", &official, None) + .await + .expect("rebuild backup"); + let backup = db + .get_live_backup("codex") + .await + .expect("read backup") + .expect("backup exists"); + let value: Value = serde_json::from_str(&backup.original_config).expect("parse backup"); + assert!( + value.get("auth").is_none(), + "official login material must remain live-only" + ); + } + + #[test] + #[serial] + fn codex_empty_restore_snapshot_does_not_delete_existing_auth_json() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db); + let auth = json!({ "OPENAI_API_KEY": "sk-real" }); + crate::codex_config::write_codex_live_atomic(&auth, Some("model = \"gpt-5.4\"\n")) + .expect("seed auth"); + + service + .write_codex_live_verbatim(&json!({ + "auth": {}, + "config": "model = \"gpt-5.4-mini\"\n" + })) + .expect("restore empty snapshot"); + + let live_auth: Value = + crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) + .expect("read auth"); + assert_eq!(live_auth, auth); + } + + #[tokio::test] + #[serial] + async fn codex_sync_live_does_not_store_credentials_in_builtin_official_row() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + let mut official = Provider::with_id( + crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), + "OpenAI Official".to_string(), + json!({ "auth": {}, "config": "" }), + None, + ); + official.category = Some("official".to_string()); + db.save_provider("codex", &official).expect("save official"); + db.set_current_provider("codex", crate::database::CODEX_OFFICIAL_PROVIDER_ID) + .expect("set current"); + crate::settings::set_current_provider( + &AppType::Codex, + Some(crate::database::CODEX_OFFICIAL_PROVIDER_ID), + ) + .expect("set local current"); + crate::codex_config::write_codex_live_atomic( + &json!({ "OPENAI_API_KEY": "sk-real" }), + Some("model = \"gpt-5.4\"\n"), + ) + .expect("seed live auth"); + + service + .sync_live_to_provider(&AppType::Codex) + .await + .expect("sync live"); + + let stored = db + .get_provider_by_id(crate::database::CODEX_OFFICIAL_PROVIDER_ID, "codex") + .expect("read official") + .expect("official exists"); + assert_eq!(stored.settings_config["auth"], json!({})); + } + #[tokio::test] #[serial] async fn codex_set_takeover_for_app_preserves_oauth_auth_json_when_preserve_enabled() { @@ -3624,6 +4484,108 @@ wire_api = "responses" .expect("reset settings"); } + #[tokio::test] + #[serial] + async fn codex_takeover_enabled_commit_failure_restores_native_live_bundle() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + use_ephemeral_proxy_port(&db).await; + let service = ProxyService::new(db.clone()); + service + .codex_oauth_manager + .add_test_account_with_access_token( + "acct-managed", + "managed-access", + Some("managed-id"), + ) + .await + .expect("seed managed account"); + + let native_auth = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "native-id", + "access_token": "native-access", + "refresh_token": "native-refresh", + "account_id": "acct-native" + }, + "last_refresh": "2026-01-01T00:00:00Z" + }); + let native_config = "model = \"gpt-5.4\"\n"; + crate::codex_config::write_codex_live_atomic(&native_auth, Some(native_config)) + .expect("seed native live bundle"); + + let mut provider = Provider::with_id( + crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), + "OpenAI Official".to_string(), + json!({ "auth": {}, "config": native_config }), + None, + ); + provider.category = Some("official".to_string()); + provider.meta = Some(ProviderMeta { + auth_binding: Some(AuthBinding { + source: AuthBindingSource::ManagedAccount, + auth_provider: Some("codex_oauth".to_string()), + account_id: Some("acct-managed".to_string()), + }), + ..Default::default() + }); + db.save_provider("codex", &provider) + .expect("save managed official"); + db.set_current_provider("codex", &provider.id) + .expect("set DB current"); + crate::settings::set_current_provider(&AppType::Codex, Some(&provider.id)) + .expect("set local current"); + + { + let conn = db.conn.lock().expect("lock database"); + conn.execute_batch( + "CREATE TRIGGER reject_codex_takeover_enabled_commit + BEFORE UPDATE OF enabled ON proxy_config + WHEN NEW.app_type = 'codex' AND NEW.enabled = 1 + BEGIN + SELECT RAISE(ABORT, 'forced Codex takeover enabled failure'); + END;", + ) + .expect("install enabled failure trigger"); + } + + let error = service + .set_takeover_for_app("codex", true) + .await + .expect_err("enabled DB failure must abort takeover"); + service.stop().await.expect("stop test proxy"); + + assert!( + error.contains("forced Codex takeover enabled failure"), + "surface DB failure: {error}" + ); + let restored = service + .read_codex_live() + .expect("read restored live bundle"); + assert_eq!(restored.get("auth"), Some(&native_auth)); + assert_eq!( + restored.get("config").and_then(Value::as_str), + Some(native_config) + ); + assert!( + db.get_live_backup("codex") + .await + .expect("read backup") + .is_none(), + "successful in-memory rollback should clean the takeover backup" + ); + assert!( + !db.get_proxy_config_for_app("codex") + .await + .expect("read Codex proxy config") + .enabled + ); + } + #[tokio::test] #[serial] async fn codex_sync_current_to_live_during_takeover_preserves_oauth_auth_json() { @@ -3994,7 +4956,7 @@ wire_api = "responses" #[tokio::test] #[serial] - async fn codex_takeover_preserve_disabled_uses_legacy_auth_write_path() { + async fn codex_takeover_preserves_native_auth_even_when_legacy_toggle_is_disabled() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); crate::settings::update_settings(crate::settings::AppSettings { @@ -4058,26 +5020,15 @@ wire_api = "responses" crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) .expect("read live auth"); assert_eq!( - live_auth - .get("OPENAI_API_KEY") - .and_then(|value| value.as_str()), - Some(PROXY_TOKEN_PLACEHOLDER), - "disabled preservation should keep the legacy auth.json takeover placeholder" - ); - assert_eq!( - live_auth - .get("tokens") - .and_then(|tokens| tokens.get("access_token")) - .and_then(|value| value.as_str()), - Some("oauth-access"), - "the new config-only takeover branch must not run when preservation is disabled" + live_auth, oauth_auth, + "takeover must preserve native OAuth independently of the legacy toggle" ); let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read live config"); assert!( - !live_config.contains(PROXY_TOKEN_PLACEHOLDER), - "disabled preservation should not move the takeover placeholder into config.toml" + live_config.contains(PROXY_TOKEN_PLACEHOLDER), + "third-party takeover should carry its local placeholder in config.toml" ); crate::settings::update_settings(crate::settings::AppSettings::default()) @@ -4249,7 +5200,8 @@ requires_openai_auth = true "#; let new_url = "http://127.0.0.1:5000/v1"; - let output = ProxyService::update_toml_base_url(input, new_url); + let output = crate::codex_config::update_codex_toml_field(input, "base_url", new_url) + .expect("update base_url"); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); @@ -4290,7 +5242,8 @@ wire_api = "chat" let proxy_url = "http://127.0.0.1:5000/v1"; let output = - ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None); + ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None) + .expect("apply proxy config"); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); @@ -4309,6 +5262,51 @@ wire_api = "chat" ); } + #[test] + fn apply_codex_proxy_toml_config_routes_builtin_official_with_native_auth() { + let mut provider = Provider::with_id( + "codex-official".to_string(), + "OpenAI Official".to_string(), + json!({ "auth": {}, "config": "" }), + None, + ); + provider.category = Some("official".to_string()); + let proxy_url = "http://127.0.0.1:5000/v1"; + + let output = ProxyService::apply_codex_proxy_toml_config_for_provider( + "experimental_bearer_token = \"PROXY_MANAGED\"\n", + proxy_url, + Some(&provider), + ) + .expect("apply official proxy config"); + let parsed: toml::Value = toml::from_str(&output).expect("valid official route"); + let route_id = crate::codex_config::CC_SWITCH_CODEX_OFFICIAL_PROXY_PROVIDER_ID; + let route = &parsed["model_providers"][route_id]; + + assert_eq!(parsed["model_provider"].as_str(), Some(route_id)); + assert_eq!(route["base_url"].as_str(), Some(proxy_url)); + assert_eq!(route["requires_openai_auth"].as_bool(), Some(true)); + assert!(parsed.get("experimental_bearer_token").is_none()); + } + + #[test] + fn apply_codex_proxy_toml_config_fails_closed_for_invalid_official_config() { + let mut provider = Provider::with_id( + crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), + "OpenAI Official".to_string(), + json!({ "auth": {}, "config": "" }), + None, + ); + provider.category = Some("official".to_string()); + + let result = ProxyService::apply_codex_proxy_toml_config_for_provider( + "model_providers = 3\n", + "http://127.0.0.1:5000/v1", + Some(&provider), + ); + assert!(result.is_err()); + } + #[test] fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() { let input = r#" @@ -4338,7 +5336,8 @@ wire_api = "responses" input, proxy_url, Some(&provider), - ); + ) + .expect("apply chat proxy config"); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); @@ -4384,7 +5383,8 @@ wire_api = "responses" input, "http://127.0.0.1:5000/v1", Some(&provider), - ); + ) + .expect("apply responses proxy config"); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); @@ -4429,7 +5429,8 @@ wire_api = "responses" input, "http://127.0.0.1:5000/v1", Some(&provider), - ); + ) + .expect("restore responses model"); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); @@ -4446,7 +5447,8 @@ model = "gpt-5.1-codex" "#; let new_url = "http://127.0.0.1:5000/v1"; - let output = ProxyService::update_toml_base_url(input, new_url); + let output = crate::codex_config::update_codex_toml_field(input, "base_url", new_url) + .expect("update top-level base_url"); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); @@ -4667,7 +5669,8 @@ model = "gpt-5.1-codex" "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": "DeepSeek V4 Flash", "ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-pro[1M]", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "DeepSeek V4 Pro", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-ultra [1m]" + "ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-ultra [1m]", + "CLAUDE_CODE_SUBAGENT_MODEL": "deepseek-v4-pro[1M]" }, "permissions": { "allow": ["Read"] } }), @@ -4694,7 +5697,8 @@ model = "gpt-5.1-codex" "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721", "ANTHROPIC_API_KEY": PROXY_TOKEN_PLACEHOLDER, "ANTHROPIC_MODEL": "stale-model", - "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet" + "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet", + "CLAUDE_CODE_SUBAGENT_MODEL": "stale-subagent" }, "permissions": { "allow": ["Bash"] } })) @@ -4777,6 +5781,13 @@ model = "gpt-5.1-codex" Some("deepseek-v4-ultra"), "implicit display names should strip the local 1M marker" ); + assert_eq!( + live_env + .get("CLAUDE_CODE_SUBAGENT_MODEL") + .and_then(|v| v.as_str()), + Some("deepseek-v4-pro[1M]"), + "subagent model should follow the target provider during hot switch" + ); let backup = db .get_live_backup("claude") @@ -5083,8 +6094,7 @@ base_url = "https://codex.example/v1" #[tokio::test] #[serial] - async fn update_live_backup_from_provider_uses_managed_codex_oauth_binding_over_existing_oauth_backup( - ) { + async fn update_live_backup_from_managed_official_does_not_freeze_oauth_tokens() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); crate::settings::update_settings(crate::settings::AppSettings { @@ -5124,7 +6134,7 @@ base_url = "https://codex.example/v1" .expect("seed live backup"); let mut provider = Provider::with_id( - "openai-official".to_string(), + crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), "OpenAI Official".to_string(), json!({ "auth": {}, @@ -5155,31 +6165,352 @@ base_url = "https://codex.example/v1" let stored: Value = serde_json::from_str(&backup.original_config).expect("parse backup json"); - assert_eq!( - stored - .pointer("/auth/tokens/access_token") - .and_then(|value| value.as_str()), - Some("managed-token"), - "explicit managed account binding must replace stale native OAuth backup auth" - ); - assert_eq!( - stored - .pointer("/auth/tokens/account_id") - .and_then(|value| value.as_str()), - Some("acct-managed") - ); - assert_eq!( - stored - .pointer("/auth/tokens/id_token") - .and_then(|value| value.as_str()), - Some("managed-id-token"), - "managed backup auth should carry the account id_token to match native login" + assert!( + stored.get("auth").is_none(), + "official OAuth generations must remain live-only so restore cannot roll them back" ); } #[tokio::test] #[serial] - async fn update_live_backup_clearing_managed_codex_auth_preserves_native_backup_auth() { + async fn codex_takeover_switch_to_managed_official_replaces_native_account() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + db.update_proxy_config(ProxyConfig { + listen_port: 15_721, + ..Default::default() + }) + .await + .expect("set proxy port"); + let service = ProxyService::new(db); + service + .codex_oauth_manager + .add_test_account_with_access_token( + "acct-managed", + "managed-access", + Some("managed-id"), + ) + .await + .expect("seed managed account"); + + let native_auth = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "native-id", + "access_token": "native-access", + "refresh_token": "native-refresh", + "account_id": "acct-native" + }, + "last_refresh": "2026-01-01T00:00:00Z" + }); + crate::codex_config::write_codex_live_atomic(&native_auth, Some("model = \"gpt-5.4\"\n")) + .expect("seed native auth"); + + let mut provider = Provider::with_id( + crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), + "OpenAI Official".to_string(), + json!({ "auth": {}, "config": "model = \"gpt-5.4\"\n" }), + None, + ); + provider.category = Some("official".to_string()); + provider.meta = Some(ProviderMeta { + auth_binding: Some(AuthBinding { + source: AuthBindingSource::ManagedAccount, + auth_provider: Some("codex_oauth".to_string()), + account_id: Some("acct-managed".to_string()), + }), + ..Default::default() + }); + + service + .sync_codex_live_from_provider_while_proxy_active(&provider) + .await + .expect("sync managed official takeover"); + + let live_auth: Value = + crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) + .expect("read managed live auth"); + assert_eq!( + live_auth + .pointer("/tokens/account_id") + .and_then(Value::as_str), + Some("acct-managed") + ); + assert_eq!( + live_auth + .pointer("/tokens/access_token") + .and_then(Value::as_str), + Some("managed-access") + ); + assert!( + crate::codex_config::codex_auth_matches_recorded_managed_oauth( + &live_auth, + "acct-managed" + ) + .expect("read managed marker"), + "takeover write must record ownership of the managed auth" + ); + } + + #[tokio::test] + #[serial] + async fn codex_takeover_unbound_official_preserves_native_account() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + db.update_proxy_config(ProxyConfig { + listen_port: 15_721, + ..Default::default() + }) + .await + .expect("set proxy port"); + let service = ProxyService::new(db); + let native_auth = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "native-id", + "access_token": "native-access", + "refresh_token": "native-refresh", + "account_id": "acct-native" + }, + "last_refresh": "2026-01-01T00:00:00Z" + }); + crate::codex_config::write_codex_live_atomic(&native_auth, Some("model = \"gpt-5.4\"\n")) + .expect("seed native auth"); + + let mut provider = Provider::with_id( + crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), + "OpenAI Official".to_string(), + json!({ "auth": {}, "config": "model = \"gpt-5.4\"\n" }), + None, + ); + provider.category = Some("official".to_string()); + + service + .sync_codex_live_from_provider_while_proxy_active(&provider) + .await + .expect("sync unbound official takeover"); + + let live_auth: Value = + crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) + .expect("read native live auth"); + assert_eq!(live_auth, native_auth); + } + + #[tokio::test] + #[serial] + async fn restoring_official_codex_takeover_preserves_rotated_live_auth() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + let mut provider = Provider::with_id( + crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), + "OpenAI Official".to_string(), + json!({ "auth": {}, "config": "" }), + None, + ); + provider.category = Some("official".to_string()); + provider.meta = Some(ProviderMeta { + auth_binding: Some(AuthBinding { + source: AuthBindingSource::ManagedAccount, + auth_provider: Some("codex_oauth".to_string()), + account_id: Some("acct-managed".to_string()), + }), + ..Default::default() + }); + db.save_provider("codex", &provider) + .expect("save official provider"); + db.set_current_provider("codex", &provider.id) + .expect("set DB current"); + crate::settings::set_current_provider(&AppType::Codex, Some(&provider.id)) + .expect("set local current"); + + let stale_backup_auth = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "id-r0", + "access_token": "access-r0", + "refresh_token": "refresh-r0", + "account_id": "acct-managed" + }, + "last_refresh": "2026-01-01T00:00:00Z" + }); + db.save_live_backup( + "codex", + &serde_json::to_string(&json!({ + "auth": stale_backup_auth, + "config": "model = \"gpt-5.4\"\n" + })) + .expect("serialize stale backup"), + ) + .await + .expect("save stale backup"); + + let rotated_live_auth = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "id-r1", + "access_token": "access-r1", + "refresh_token": "refresh-r1", + "account_id": "acct-managed" + }, + "last_refresh": "2026-01-02T00:00:00Z" + }); + crate::codex_config::write_codex_live_atomic( + &rotated_live_auth, + Some( + r#"model_provider = "cc-switch" +[model_providers.cc-switch] +base_url = "http://127.0.0.1:15721/v1" +wire_api = "responses" +"#, + ), + ) + .expect("seed rotated takeover live"); + + service + .restore_live_config_for_app_with_fallback(&AppType::Codex) + .await + .expect("restore official Codex config"); + + let restored = service.read_codex_live().expect("read restored Codex live"); + assert_eq!(restored.get("auth"), Some(&rotated_live_auth)); + assert_eq!( + restored.get("config").and_then(Value::as_str), + Some("model = \"gpt-5.4\"\n") + ); + } + + #[test] + #[serial] + fn codex_snapshot_rollback_preserves_newer_same_account_generation() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let auth_r0 = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "id-r0", + "access_token": "access-r0", + "refresh_token": "refresh-r0", + "account_id": "acct-a" + }, + "last_refresh": "2026-01-01T00:00:00Z" + }); + crate::codex_config::write_codex_live_atomic(&auth_r0, Some("model = \"before\"\n")) + .expect("seed R0 live"); + crate::codex_config::record_codex_managed_oauth_live_auth(&auth_r0) + .expect("record R0 marker"); + let snapshot = crate::codex_config::CodexLiveStateSnapshot::capture() + .expect("capture transaction snapshot"); + + let auth_r1 = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "id-r1", + "access_token": "access-r1", + "refresh_token": "refresh-r1", + "account_id": "acct-a" + }, + "last_refresh": "2026-01-02T00:00:00Z" + }); + crate::codex_config::write_codex_live_atomic(&auth_r1, Some("model = \"after\"\n")) + .expect("write concurrent R1 live"); + crate::codex_config::record_codex_managed_oauth_live_auth(&auth_r1) + .expect("record R1 marker"); + + snapshot + .restore_preserving_newer_same_account_auth() + .expect("selective rollback"); + + let restored: Value = + crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) + .expect("read restored auth"); + assert_eq!(restored, auth_r1, "newer same-account auth must survive"); + assert!( + crate::codex_config::codex_auth_matches_recorded_managed_oauth(&restored, "acct-a") + .expect("check R1 marker") + ); + assert_eq!( + std::fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read rolled-back config"), + "model = \"before\"\n" + ); + } + + #[test] + #[serial] + fn codex_snapshot_rollback_restores_previous_cross_account_generation() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let auth_a = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "id-a", + "access_token": "access-a", + "refresh_token": "refresh-a", + "account_id": "acct-a" + }, + "last_refresh": "2026-01-01T00:00:00Z" + }); + crate::codex_config::write_codex_live_atomic(&auth_a, Some("model = \"before\"\n")) + .expect("seed account A live"); + crate::codex_config::record_codex_managed_oauth_live_auth(&auth_a) + .expect("record A marker"); + let snapshot = crate::codex_config::CodexLiveStateSnapshot::capture() + .expect("capture account A snapshot"); + + let auth_b = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "id-b", + "access_token": "access-b", + "refresh_token": "refresh-b", + "account_id": "acct-b" + }, + "last_refresh": "2026-01-02T00:00:00Z" + }); + crate::codex_config::write_codex_live_atomic(&auth_b, Some("model = \"after\"\n")) + .expect("write account B live"); + crate::codex_config::record_codex_managed_oauth_live_auth(&auth_b) + .expect("record B marker"); + + snapshot + .restore_preserving_newer_same_account_auth() + .expect("cross-account rollback"); + + let restored: Value = + crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) + .expect("read restored auth"); + assert_eq!(restored, auth_a, "failed A to B change must restore A"); + assert!( + crate::codex_config::codex_auth_matches_recorded_managed_oauth(&restored, "acct-a") + .expect("check restored A marker") + ); + assert_eq!( + std::fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read rolled-back config"), + "model = \"before\"\n" + ); + } + + #[tokio::test] + #[serial] + async fn update_live_backup_clearing_managed_codex_auth_keeps_official_auth_live_only() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); crate::settings::update_settings(crate::settings::AppSettings { @@ -5211,7 +6542,7 @@ base_url = "https://codex.example/v1" .expect("seed live backup"); let mut provider = Provider::with_id( - "openai-official".to_string(), + crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), "OpenAI Official".to_string(), json!({ "auth": {}, @@ -5238,10 +6569,9 @@ base_url = "https://codex.example/v1" let stored: Value = serde_json::from_str(&backup.original_config).expect("parse backup json"); - assert_eq!( - stored.get("auth"), - Some(&native_auth), - "unbinding should not overwrite a later native Codex login in the backup" + assert!( + stored.get("auth").is_none(), + "official native auth must not be frozen into the restore backup" ); } @@ -5962,11 +7292,226 @@ requires_openai_auth = true let message = err.to_string(); assert!( - message.contains("写入 Codex 配置失败") || message.contains("原子替换失败"), + message.contains("写入 Codex 配置失败") + || message.contains("原子替换失败") + || (message.contains("捕获 Codex 热切换前状态失败") + && message.contains("cc-switch-model-catalog.json")), "switch should surface catalog write failure, got: {message}" ); } + #[tokio::test] + #[serial] + async fn codex_direct_live_write_rolls_back_when_provider_commit_fails() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let state = crate::store::AppState::new(db.clone()); + db.update_proxy_config(ProxyConfig { + listen_port: 0, + ..Default::default() + }) + .await + .expect("set test proxy config"); + state + .proxy_service + .start() + .await + .expect("start proxy server"); + + let provider_a = Provider::with_id( + "a".to_string(), + "Provider A".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "key-a" }, + "config": "model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"Provider A\"\nbase_url = \"https://a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n" + }), + None, + ); + let provider_b = Provider::with_id( + "b".to_string(), + "Provider B".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "key-b" }, + "config": "model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"Provider B\"\nbase_url = \"https://b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n" + }), + None, + ); + db.save_provider("codex", &provider_a) + .expect("save provider a"); + db.save_provider("codex", &provider_b) + .expect("save provider b"); + db.set_current_provider("codex", "a") + .expect("set current provider a"); + crate::settings::set_current_provider(&AppType::Codex, Some("a")) + .expect("set local current provider a"); + state + .proxy_service + .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a)) + .expect("seed direct live config"); + db.save_live_backup( + "codex", + &serde_json::to_string(&provider_a.settings_config).expect("serialize backup"), + ) + .await + .expect("seed restored backup"); + let original_live = state + .proxy_service + .read_codex_live() + .expect("read original live config"); + + { + let conn = db.conn.lock().expect("lock database"); + conn.execute_batch( + "CREATE TRIGGER reject_codex_current_update + BEFORE UPDATE OF is_current ON providers + WHEN NEW.app_type = 'codex' + BEGIN + SELECT RAISE(ABORT, 'forced current-provider commit failure'); + END;", + ) + .expect("install failure trigger"); + } + + let error = crate::services::provider::ProviderService::switch(&state, AppType::Codex, "b") + .expect_err("database commit should fail"); + state.proxy_service.stop().await.expect("stop proxy server"); + + assert!(error + .to_string() + .contains("forced current-provider commit failure")); + assert_eq!( + state + .proxy_service + .read_codex_live() + .expect("read rolled-back live config"), + original_live, + "commit failure must restore the exact direct Live snapshot" + ); + assert_eq!( + db.get_current_provider("codex") + .expect("read current provider") + .as_deref(), + Some("a") + ); + assert_eq!( + crate::settings::get_current_provider(&AppType::Codex).as_deref(), + Some("a") + ); + } + + #[tokio::test] + #[serial] + async fn codex_active_takeover_hot_switch_failure_restores_native_official_auth() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + use_ephemeral_proxy_port(&db).await; + let service = ProxyService::new(db.clone()); + service + .codex_oauth_manager + .add_test_account_with_access_token( + "acct-managed-b", + "managed-access-b", + Some("managed-id-b"), + ) + .await + .expect("seed managed account B"); + + let native_auth = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "native-id", + "access_token": "native-access", + "refresh_token": "native-refresh", + "account_id": "acct-native" + }, + "last_refresh": "2026-01-01T00:00:00Z" + }); + crate::codex_config::write_codex_live_atomic(&native_auth, Some("model = \"gpt-5.4\"\n")) + .expect("seed native official live"); + + let mut official = Provider::with_id( + crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), + "OpenAI Official".to_string(), + json!({ "auth": {}, "config": "model = \"gpt-5.4\"\n" }), + None, + ); + official.category = Some("official".to_string()); + db.save_provider("codex", &official) + .expect("save unbound official"); + db.set_current_provider("codex", &official.id) + .expect("set DB current"); + crate::settings::set_current_provider(&AppType::Codex, Some(&official.id)) + .expect("set local current"); + + service + .set_takeover_for_app("codex", true) + .await + .expect("enable unbound official takeover"); + let backup_before = db + .get_live_backup("codex") + .await + .expect("read original backup") + .expect("backup exists"); + let live_before = crate::codex_config::CodexLiveStateSnapshot::capture() + .expect("capture native takeover live"); + + official.meta = Some(ProviderMeta { + auth_binding: Some(AuthBinding { + source: AuthBindingSource::ManagedAccount, + auth_provider: Some("codex_oauth".to_string()), + account_id: Some("acct-managed-b".to_string()), + }), + ..Default::default() + }); + db.save_provider("codex", &official) + .expect("persist managed target before hot-switch projection"); + { + let conn = db.conn.lock().expect("lock database"); + conn.execute_batch( + "CREATE TRIGGER reject_managed_official_current_commit + BEFORE UPDATE OF is_current ON providers + WHEN NEW.app_type = 'codex' + BEGIN + SELECT RAISE(ABORT, 'forced managed official current failure'); + END;", + ) + .expect("install current failure trigger"); + } + + let error = service + .hot_switch_provider("codex", &official.id) + .await + .expect_err("current commit failure should abort hot-switch"); + service.stop().await.expect("stop test proxy"); + + assert!(error.contains("forced managed official current failure")); + assert_eq!( + db.get_live_backup("codex") + .await + .expect("read rolled-back backup") + .expect("backup remains") + .original_config, + backup_before.original_config + ); + assert_eq!( + crate::codex_config::CodexLiveStateSnapshot::capture() + .expect("capture rolled-back takeover live"), + live_before, + "failed projection must restore native auth/config/catalog/marker exactly" + ); + assert_eq!( + db.get_current_provider("codex") + .expect("read DB current") + .as_deref(), + Some(official.id.as_str()) + ); + } + /// Regression: turning proxy takeover off restores Live from the backup. The /// backup snapshot is `read_codex_live_settings()` output (`{auth, config}`, /// never an inline `modelCatalog`). The restore must NOT route the config @@ -6413,4 +7958,148 @@ experimental_bearer_token = "PROXY_MANAGED" ); } } + + fn grok_provider_config(base_url: &str, api_key: &str) -> Value { + json!({ + "config": format!( + "[models]\ndefault = \"grok-4.5\"\n\n[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nbase_url = \"{base_url}\"\nname = \"Grok\"\napi_key = \"{api_key}\"\napi_backend = \"responses\"\ncontext_window = 500000\n" + ) + }) + } + + #[tokio::test] + #[serial] + async fn hot_switch_grokbuild_updates_backup_and_current_provider() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + let provider_a = Provider::with_id( + "grok-a".to_string(), + "Grok A".to_string(), + grok_provider_config("https://a.example.com/v1", "a-key"), + None, + ); + let provider_b = Provider::with_id( + "grok-b".to_string(), + "Grok B".to_string(), + grok_provider_config("https://b.example.com/v1", "b-key"), + None, + ); + db.save_provider("grokbuild", &provider_a) + .expect("save provider a"); + db.save_provider("grokbuild", &provider_b) + .expect("save provider b"); + db.set_current_provider("grokbuild", "grok-a") + .expect("set db current"); + crate::settings::set_current_provider(&AppType::GrokBuild, Some("grok-a")) + .expect("set local current"); + let mut original_settings = provider_a.settings_config.clone(); + original_settings["config"] = json!(format!( + "{}\n[mcp_servers.demo]\ncommand = \"demo\"\n", + original_settings["config"] + .as_str() + .expect("provider config") + )); + db.save_live_backup( + "grokbuild", + &serde_json::to_string(&original_settings).expect("serialize backup"), + ) + .await + .expect("seed backup"); + + service + .hot_switch_provider("grokbuild", "grok-b") + .await + .expect("hot switch Grok Build"); + + assert_eq!( + crate::settings::get_effective_current_provider(&db, &AppType::GrokBuild) + .expect("read current") + .as_deref(), + Some("grok-b") + ); + let backup = db + .get_live_backup("grokbuild") + .await + .expect("read backup") + .expect("backup exists"); + let backup: Value = serde_json::from_str(&backup.original_config).expect("parse backup"); + assert!(backup["config"] + .as_str() + .is_some_and(|config| config.contains("https://b.example.com/v1"))); + assert!(backup["config"] + .as_str() + .is_some_and(|config| config.contains("[mcp_servers.demo]"))); + } + + #[tokio::test] + #[serial] + async fn failed_hot_switch_grokbuild_keeps_previous_current_and_backup() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + let provider_a = Provider::with_id( + "grok-a".to_string(), + "Grok A".to_string(), + grok_provider_config("https://a.example.com/v1", "a-key"), + None, + ); + let provider_b = Provider::with_id( + "grok-b".to_string(), + "Broken Grok".to_string(), + json!({ "config": "not valid toml = [" }), + None, + ); + db.save_provider("grokbuild", &provider_a) + .expect("save provider a"); + db.save_provider("grokbuild", &provider_b) + .expect("save provider b"); + db.set_current_provider("grokbuild", "grok-a") + .expect("set db current"); + crate::settings::set_current_provider(&AppType::GrokBuild, Some("grok-a")) + .expect("set local current"); + + let original_backup = + serde_json::to_string(&provider_a.settings_config).expect("serialize backup"); + db.save_live_backup("grokbuild", &original_backup) + .await + .expect("seed backup"); + let takeover = crate::grok_config::apply_proxy_takeover( + provider_a.settings_config["config"] + .as_str() + .expect("provider config"), + "http://127.0.0.1:15721/grokbuild/v1", + PROXY_TOKEN_PLACEHOLDER, + ) + .expect("build takeover config"); + service + .write_grok_live(&json!({ "config": takeover })) + .expect("seed taken-over live"); + + service + .hot_switch_provider("grokbuild", "grok-b") + .await + .expect_err("invalid Grok config must fail"); + + assert_eq!( + crate::settings::get_effective_current_provider(&db, &AppType::GrokBuild) + .expect("read current") + .as_deref(), + Some("grok-a") + ); + assert_eq!( + crate::settings::get_current_provider(&AppType::GrokBuild).as_deref(), + Some("grok-a") + ); + let backup = db + .get_live_backup("grokbuild") + .await + .expect("read backup") + .expect("backup exists"); + assert_eq!(backup.original_config, original_backup); + } } diff --git a/src-tauri/src/services/session_usage_codex.rs b/src-tauri/src/services/session_usage_codex.rs index f23be19c3..5757f9ded 100644 --- a/src-tauri/src/services/session_usage_codex.rs +++ b/src-tauri/src/services/session_usage_codex.rs @@ -9,7 +9,7 @@ //! ``` //! //! ## 解析的事件类型 -//! - `session_meta` → 提取 session_id +//! - `session_meta` → 提取唯一 thread_id(子代理的 session_id 指向父线程) //! - `turn_context` → 提取当前 model //! - `event_msg` (type=token_count) → 提取累计 token 用量,计算 delta @@ -28,6 +28,8 @@ use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::time::SystemTime; +const CODEX_THREAD_REQUEST_ID_PREFIX: &str = "codex_session:thread-v1"; + /// 累计 token 用量(跟踪 total_token_usage 字段) #[derive(Debug, Clone, Default)] struct CumulativeTokens { @@ -52,10 +54,162 @@ impl DeltaTokens { /// 单文件解析时的运行状态 struct FileParseState { - session_id: Option, + thread_id: Option, current_model: String, prev_total: Option, event_index: u32, + history_replay_boundary: Option, +} + +/// Codex 子代理日志中的 `id` 是当前线程的唯一 ID,`session_id` 则指向父线程。 +#[derive(Debug, Clone, PartialEq, Eq)] +struct CodexSessionIdentity { + thread_id: String, + carries_history_snapshot: bool, +} + +fn parse_codex_session_identity(payload: &serde_json::Value) -> Option { + let thread_id = payload + .get("id") + .or_else(|| payload.get("thread_id")) + .or_else(|| payload.get("threadId")) + .or_else(|| payload.get("session_id")) + .or_else(|| payload.get("sessionId")) + .and_then(|value| value.as_str())? + .to_string(); + let session_id = payload + .get("session_id") + .or_else(|| payload.get("sessionId")) + .and_then(|value| value.as_str()); + let carries_history_snapshot = payload + .get("forked_from_id") + .and_then(|value| value.as_str()) + .is_some_and(|value| !value.is_empty()) + || payload + .get("source") + .and_then(|source| source.get("subagent")) + .is_some() + || session_id.is_some_and(|session_id| session_id != thread_id); + + Some(CodexSessionIdentity { + thread_id, + carries_history_snapshot, + }) +} + +fn read_codex_session_identity(file_path: &Path) -> Option { + let file = fs::File::open(file_path).ok()?; + + for line in BufReader::new(file).lines() { + let Ok(line) = line else { + continue; + }; + if !line.contains("\"session_meta\"") { + continue; + } + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if value.get("type").and_then(|value| value.as_str()) != Some("session_meta") { + continue; + } + if let Some(identity) = value.get("payload").and_then(parse_codex_session_identity) { + return Some(identity); + } + } + + None +} + +/// fork/子代理日志会先重放父线程历史,再以接管事件开始当前线程。 +/// 返回接管事件所在行;此前的 token_count 只用于恢复累计值基线。 +fn codex_history_replay_boundary( + file_path: &Path, + identity: Option<&CodexSessionIdentity>, +) -> Option { + if !identity.is_some_and(|identity| identity.carries_history_snapshot) { + return None; + } + + let file = fs::File::open(file_path).ok()?; + for (index, line) in BufReader::new(file).lines().enumerate() { + let Ok(line) = line else { + continue; + }; + if !line.contains("\"thread_settings_applied\"") + && !line.contains("\"inter_agent_communication") + { + continue; + } + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + let Some(event_type) = value.get("type").and_then(|value| value.as_str()) else { + continue; + }; + let is_replay_boundary = event_type.starts_with("inter_agent_communication") + || (event_type == "event_msg" + && value + .get("payload") + .and_then(|payload| payload.get("type")) + .and_then(|value| value.as_str()) + == Some("thread_settings_applied")); + if is_replay_boundary { + return Some(index as i64 + 1); + } + } + + None +} + +fn is_history_snapshot_event(state: &FileParseState, line_offset: i64) -> bool { + state + .history_replay_boundary + .is_some_and(|boundary| line_offset < boundary) +} + +fn get_codex_sync_state(db: &Database, file_path: &Path) -> Result<(i64, i64), AppError> { + let file_path_str = file_path.to_string_lossy().to_string(); + let state = get_sync_state(db, &file_path_str)?; + if state != (0, 0) + || file_path + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + != Some("archived_sessions") + { + return Ok(state); + } + + let Some(file_name) = file_path.file_name().and_then(|name| name.to_str()) else { + return Ok(state); + }; + let slash_suffix = format!("/{file_name}"); + let backslash_suffix = format!("\\{file_name}"); + let conn = lock_conn!(db.conn); + let inherited = conn.query_row( + "SELECT last_modified, last_line_offset + FROM session_log_sync + WHERE file_path <> ?1 + AND (substr(file_path, -length(?2)) = ?2 + OR substr(file_path, -length(?3)) = ?3) + ORDER BY last_line_offset DESC, last_modified DESC + LIMIT 1", + rusqlite::params![file_path_str, slash_suffix, backslash_suffix], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ); + drop(conn); + + match inherited { + Ok(inherited) => { + update_sync_state(db, &file_path_str, inherited.0, inherited.1)?; + Ok(inherited) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(state), + Err(error) => Err(AppError::Database(format!( + "查询 Codex 归档文件同步状态失败: {error}" + ))), + } } /// 归一化 Codex 模型名 @@ -238,23 +392,27 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), let file_modified = metadata_modified_nanos(&metadata); // 检查同步状态 - let (last_modified, last_offset) = get_sync_state(db, &file_path_str)?; + let (last_modified, last_offset) = get_codex_sync_state(db, file_path)?; // 文件未变化则跳过 if file_modified <= last_modified { return Ok((0, 0)); } + let identity = read_codex_session_identity(file_path); + // 打开文件逐行解析 let file = fs::File::open(file_path).map_err(|e| AppError::Config(format!("无法打开文件: {e}")))?; let reader = BufReader::new(file); + let history_replay_boundary = codex_history_replay_boundary(file_path, identity.as_ref()); let mut state = FileParseState { - session_id: None, + thread_id: identity.map(|identity| identity.thread_id), current_model: "unknown".to_string(), prev_total: None, event_index: 0, + history_replay_boundary, }; let mut line_offset: i64 = 0; @@ -296,16 +454,11 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), }; match event_type { - "session_meta" if state.session_id.is_none() => { - let payload = value.get("payload"); - state.session_id = payload - .and_then(|p| { - p.get("session_id") - .or_else(|| p.get("sessionId")) - .or_else(|| p.get("id")) - }) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); + "session_meta" if state.thread_id.is_none() => { + state.thread_id = value + .get("payload") + .and_then(parse_codex_session_identity) + .map(|identity| identity.thread_id); } "turn_context" => { if let Some(payload) = value.get("payload") { @@ -383,16 +536,28 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), continue; // 跳过 task 边界的零 delta 事件 } + // 所有非零事件都占据稳定序号,包括已同步事件与 replay 快照。 state.event_index += 1; + // replay 快照更新了 prev_total,但不是当前线程的新用量。 + if is_history_snapshot_event(&state, line_offset) { + if line_offset > last_offset { + skipped += 1; + } + continue; + } + // 跳过已处理的行(但仍需解析以恢复状态) if line_offset <= last_offset { continue; } // 生成唯一 request_id - let session_id_str = state.session_id.as_deref().unwrap_or("unknown"); - let request_id = format!("codex_session:{}:{}", session_id_str, state.event_index); + let thread_id = state.thread_id.as_deref().unwrap_or("unknown"); + let request_id = format!( + "{CODEX_THREAD_REQUEST_ID_PREFIX}:{thread_id}:{}", + state.event_index + ); // 提取时间戳 let timestamp = value @@ -405,7 +570,7 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), &request_id, &delta, &state.current_model, - state.session_id.as_deref(), + state.thread_id.as_deref(), timestamp.as_deref(), ) { Ok(true) => imported += 1, @@ -549,6 +714,56 @@ fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option>() + .join("\n") + + "\n"; + fs::write(path, contents).unwrap(); + } + + fn session_meta(thread_id: &str, session_id: &str) -> serde_json::Value { + serde_json::json!({ + "timestamp": "2026-07-10T03:00:00Z", + "type": "session_meta", + "payload": { + "id": thread_id, + "session_id": session_id, + "source": if thread_id == session_id { + serde_json::Value::String("cli".to_string()) + } else { + serde_json::json!({ "subagent": {} }) + } + } + }) + } + + fn turn_context() -> serde_json::Value { + serde_json::json!({ + "timestamp": "2026-07-10T03:00:01Z", + "type": "turn_context", + "payload": { "model": "gpt-5.6-sol" } + }) + } + + fn token_count(input: u64, cached: u64, output: u64) -> serde_json::Value { + serde_json::json!({ + "timestamp": "2026-07-10T03:00:02Z", + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { "total_token_usage": { + "input_tokens": input, + "cached_input_tokens": cached, + "output_tokens": output + }} + } + }) + } #[test] fn test_delta_first_event() { @@ -659,6 +874,159 @@ mod tests { assert!(files.is_empty()); } + #[test] + fn test_subagent_identity_prefers_unique_thread_id() { + let identity = + parse_codex_session_identity(session_meta("child", "parent").get("payload").unwrap()) + .unwrap(); + + assert_eq!(identity.thread_id, "child"); + assert!(identity.carries_history_snapshot); + } + + #[test] + fn test_subagent_replay_only_establishes_token_baseline() -> Result<(), AppError> { + let db = Database::memory()?; + let temp = tempdir().unwrap(); + let child = temp.path().join("child.jsonl"); + write_jsonl( + &child, + &[ + session_meta("child", "parent"), + turn_context(), + token_count(1_000, 900, 100), + token_count(1_200, 1_000, 120), + serde_json::json!({ + "timestamp": "2026-07-10T03:00:03Z", + "type": "event_msg", + "payload": { "type": "thread_settings_applied" } + }), + token_count(1_300, 1_050, 150), + ], + ); + + assert_eq!(sync_single_codex_file(&db, &child)?, (1, 2)); + + let conn = lock_conn!(db.conn); + let usage: (i64, i64, i64) = conn.query_row( + "SELECT input_tokens, cache_read_tokens, output_tokens + FROM proxy_request_logs + WHERE request_id = 'codex_session:thread-v1:child:3'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + assert_eq!(usage, (100, 50, 30)); + + Ok(()) + } + + #[test] + fn test_subagents_under_same_parent_use_distinct_request_ids() -> Result<(), AppError> { + let db = Database::memory()?; + let temp = tempdir().unwrap(); + let child_a = temp.path().join("child-a.jsonl"); + let child_b = temp.path().join("child-b.jsonl"); + write_jsonl( + &child_a, + &[ + session_meta("child-a", "parent"), + turn_context(), + token_count(100, 50, 10), + ], + ); + write_jsonl( + &child_b, + &[ + session_meta("child-b", "parent"), + turn_context(), + token_count(200, 100, 20), + ], + ); + + assert_eq!(sync_single_codex_file(&db, &child_a)?, (1, 0)); + assert_eq!(sync_single_codex_file(&db, &child_b)?, (1, 0)); + + let conn = lock_conn!(db.conn); + let request_ids = conn + .prepare( + "SELECT request_id FROM proxy_request_logs + WHERE data_source = 'codex_session' ORDER BY request_id", + )? + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + assert_eq!( + request_ids, + vec![ + "codex_session:thread-v1:child-a:1", + "codex_session:thread-v1:child-b:1" + ] + ); + + Ok(()) + } + + #[test] + fn test_archived_log_inherits_cursor_and_only_imports_appended_usage() -> Result<(), AppError> { + let db = Database::memory()?; + let temp = tempdir().unwrap(); + let sessions = temp.path().join("sessions"); + let archived = temp.path().join("archived_sessions"); + fs::create_dir_all(&sessions).unwrap(); + fs::create_dir_all(&archived).unwrap(); + let source = sessions.join("rollout-parent.jsonl"); + let archived_file = archived.join("rollout-parent.jsonl"); + write_jsonl( + &archived_file, + &[ + session_meta("parent", "parent"), + turn_context(), + token_count(100, 50, 10), + token_count(200, 100, 20), + ], + ); + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + input_tokens, output_tokens, cache_read_tokens, + total_cost_usd, latency_ms, status_code, session_id, + created_at, data_source + ) VALUES ('codex_session:parent:2', '_codex_session', 'codex', + 'gpt-5.6-sol', 'gpt-5.6-sol', 999, 99, 0, '0', 0, + 200, 'parent', 1, 'codex_session')", + [], + )?; + } + let source_path = source.to_string_lossy().to_string(); + update_sync_state(&db, &source_path, 1, 3)?; + + assert_eq!(sync_single_codex_file(&db, &archived_file)?, (1, 0)); + assert_eq!(sync_single_codex_file(&db, &archived_file)?, (0, 0)); + + let conn = lock_conn!(db.conn); + let old_row_count: i64 = conn.query_row( + "SELECT COUNT(*) FROM proxy_request_logs + WHERE request_id = 'codex_session:parent:2'", + [], + |row| row.get(0), + )?; + assert_eq!(old_row_count, 1); + let usage: (i64, i64, i64) = conn.query_row( + "SELECT input_tokens, cache_read_tokens, output_tokens + FROM proxy_request_logs + WHERE request_id = 'codex_session:thread-v1:parent:2'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + assert_eq!(usage, (100, 50, 10)); + drop(conn); + assert_eq!(get_sync_state(&db, &archived_file.to_string_lossy())?.1, 4); + + Ok(()) + } + #[test] fn test_insert_codex_session_skips_matching_proxy_log() -> Result<(), AppError> { let db = Database::memory()?; diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 0e90e7bb7..85761c75f 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -374,20 +374,15 @@ fn parse_branch_from_source_url(source_url: Option<&str>) -> Option { /// 获取 `~/.agents/skills/` 目录(存在时返回) fn get_agents_skills_dir() -> Option { - dirs::home_dir() - .map(|h| h.join(".agents").join("skills")) - .filter(|p| p.exists()) + let dir = crate::config::get_home_dir().join(".agents").join("skills"); + dir.exists().then_some(dir) } /// 解析 `~/.agents/.skill-lock.json`,返回 skill_name -> 仓库信息 fn parse_agents_lock() -> HashMap { - let path = match dirs::home_dir() { - Some(h) => h.join(".agents").join(".skill-lock.json"), - None => { - log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件"); - return HashMap::new(); - } - }; + let path = crate::config::get_home_dir() + .join(".agents") + .join(".skill-lock.json"); let content = match fs::read_to_string(&path) { Ok(c) => c, Err(e) => { @@ -482,12 +477,7 @@ impl SkillService { let dir = match location { SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"), SkillStorageLocation::Unified => { - let home = dirs::home_dir().context(format_skill_error( - "GET_HOME_DIR_FAILED", - &[], - Some("checkPermission"), - ))?; - home.join(".agents").join("skills") + crate::config::get_home_dir().join(".agents").join("skills") } }; fs::create_dir_all(&dir)?; @@ -521,6 +511,11 @@ impl SkillService { return Ok(custom.join("skills")); } } + AppType::GrokBuild => { + if let Some(custom) = crate::settings::get_grok_override_dir() { + return Ok(custom.join("skills")); + } + } AppType::OpenCode => { if let Some(custom) = crate::settings::get_opencode_override_dir() { return Ok(custom.join("skills")); @@ -538,18 +533,17 @@ impl SkillService { } } - // 默认路径:回退到用户主目录下的标准位置 - let home = dirs::home_dir().context(format_skill_error( - "GET_HOME_DIR_FAILED", - &[], - Some("checkPermission"), - ))?; + // 默认路径:回退到用户主目录下的标准位置。 + // 必须走 get_home_dir()(可被 CC_SWITCH_TEST_HOME 覆盖):Windows 上 dirs::home_dir() + // 走 Known Folder API,测试无法隔离真实用户目录。 + let home = crate::config::get_home_dir(); Ok(match app { AppType::Claude => home.join(".claude").join("skills"), AppType::ClaudeDesktop => home.join(".claude-desktop").join("skills"), AppType::Codex => home.join(".codex").join("skills"), AppType::Gemini => home.join(".gemini").join("skills"), + AppType::GrokBuild => home.join(".grok").join("skills"), AppType::OpenCode => home.join(".config").join("opencode").join("skills"), AppType::OpenClaw => home.join(".openclaw").join("skills"), AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"), @@ -1167,8 +1161,7 @@ impl SkillService { let new_dir = match target { SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"), SkillStorageLocation::Unified => { - let home = dirs::home_dir().context("Cannot determine home directory")?; - home.join(".agents").join("skills") + crate::config::get_home_dir().join(".agents").join("skills") } }; fs::create_dir_all(&new_dir)?; @@ -3069,6 +3062,36 @@ mod tests { .expect("write SKILL.md"); } + #[test] + // serial:与 backup/s3_sync/deeplink 等同样读写进程级 CC_SWITCH_TEST_HOME 的测试互斥, + // EnvGuard 只负责恢复不提供互斥。 + #[serial_test::serial] + fn get_app_skills_dir_honors_test_home_override() { + // 回归:曾直呼 dirs::home_dir() 绕过 CC_SWITCH_TEST_HOME——Unix 上碰巧跟 $HOME + // 一致所以测试能过,Windows 上 dirs 走 Known Folder API,测试隔离整体失效 + // (tests/skill_sync.rs 扫到 runner 真实用户目录)。 + struct EnvGuard(Option); + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + } + } + let temp = tempdir().expect("tempdir"); + let _guard = EnvGuard(std::env::var_os("CC_SWITCH_TEST_HOME")); + std::env::set_var("CC_SWITCH_TEST_HOME", temp.path()); + + let dir = + SkillService::get_app_skills_dir(&AppType::Claude).expect("resolve claude skills dir"); + assert!( + dir.starts_with(temp.path()), + "skills dir must live under the overridden test home, got {}", + dir.display() + ); + } + #[test] fn resolve_skill_source_dir_returns_repo_root_for_root_level_skill() { let temp = tempdir().expect("tempdir"); diff --git a/src-tauri/src/services/sql_helpers.rs b/src-tauri/src/services/sql_helpers.rs index 7b006aef8..a32049161 100644 --- a/src-tauri/src/services/sql_helpers.rs +++ b/src-tauri/src/services/sql_helpers.rs @@ -16,15 +16,18 @@ /// style provider not added here) shows up loudly as a too-low cache hit /// rate, which is easier to catch than the silent over-deduction that /// would happen with the opposite default. -const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini"]; +const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini", "grokbuild"]; + +pub(crate) const INPUT_TOKEN_SEMANTICS_LEGACY: i64 = 0; +pub(crate) const INPUT_TOKEN_SEMANTICS_TOTAL: i64 = 1; +pub(crate) const INPUT_TOKEN_SEMANTICS_FRESH: i64 = 2; /// Build an SQL expression that returns the cache-normalized `input_tokens` /// for a single row in `proxy_request_logs` or `usage_daily_rollups`. /// -/// For rows whose `app_type` is in [`CACHE_INCLUSIVE_APP_TYPES`] and -/// `input_tokens >= cache_read_tokens`, returns -/// `input_tokens - cache_read_tokens`. For all other rows the original -/// `input_tokens` is returned unchanged. +/// Legacy rows subtract cache reads only. New total-inclusive rows subtract +/// both cache reads and writes. Rollups normalized to fresh input are returned +/// unchanged. /// /// Pass an empty string to reference the columns directly (no alias), /// or a table alias such as `"l"` to emit `l.input_tokens` style references. @@ -40,7 +43,15 @@ pub fn fresh_input_sql(alias: &str) -> String { .collect::>() .join(", "); format!( - "CASE WHEN {prefix}app_type IN ({app_type_list}) AND {prefix}input_tokens >= {prefix}cache_read_tokens \ + "CASE \ + WHEN {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_FRESH} THEN {prefix}input_tokens \ + WHEN {prefix}app_type IN ({app_type_list}) \ + AND {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_TOTAL} \ + AND {prefix}input_tokens >= ({prefix}cache_read_tokens + {prefix}cache_creation_tokens) \ + THEN ({prefix}input_tokens - {prefix}cache_read_tokens - {prefix}cache_creation_tokens) \ + WHEN {prefix}app_type IN ({app_type_list}) \ + AND {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_LEGACY} \ + AND {prefix}input_tokens >= {prefix}cache_read_tokens \ THEN ({prefix}input_tokens - {prefix}cache_read_tokens) \ ELSE {prefix}input_tokens END" ) @@ -60,7 +71,8 @@ mod tests { input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, cache_read_tokens INTEGER NOT NULL DEFAULT 0, - cache_creation_tokens INTEGER NOT NULL DEFAULT 0 + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + input_token_semantics INTEGER NOT NULL DEFAULT 0 );", ) .unwrap(); @@ -81,6 +93,7 @@ mod tests { assert!(!sql.contains(".")); assert!(sql.contains("'codex'")); assert!(sql.contains("'gemini'")); + assert!(sql.contains("'grokbuild'")); } #[test] @@ -100,6 +113,13 @@ mod tests { [], ) .unwrap(); + // Grok Build uses OpenAI Responses semantics too. + conn.execute( + "INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens) + VALUES ('grok-1', 'grokbuild', 700, 250)", + [], + ) + .unwrap(); // Claude row: Anthropic semantics — input_tokens already excludes cache. conn.execute( "INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens) @@ -111,8 +131,8 @@ mod tests { let expr = fresh_input_sql("l"); let sql = format!("SELECT COALESCE(SUM({expr}), 0) FROM proxy_request_logs l"); let total: i64 = conn.query_row(&sql, [], |r| r.get(0)).unwrap(); - // Codex: 1000-600=400; Gemini: 800-300=500; Claude: 200 unchanged. - assert_eq!(total, 400 + 500 + 200); + // Codex: 400; Gemini: 500; Grok Build: 450; Claude: 200 unchanged. + assert_eq!(total, 400 + 500 + 450 + 200); } #[test] @@ -131,4 +151,38 @@ mod tests { let value: i64 = conn.query_row(&sql, [], |r| r.get(0)).unwrap(); assert_eq!(value, 100); } + + #[test] + fn fresh_input_subtracts_cache_write_for_total_semantics() { + let conn = setup_conn(); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, app_type, input_tokens, cache_read_tokens, + cache_creation_tokens, input_token_semantics + ) VALUES ('codex-total', 'codex', 1000, 300, 200, ?1)", + [INPUT_TOKEN_SEMANTICS_TOTAL], + ) + .unwrap(); + let expr = fresh_input_sql("l"); + let sql = format!("SELECT {expr} FROM proxy_request_logs l"); + let value: i64 = conn.query_row(&sql, [], |row| row.get(0)).unwrap(); + assert_eq!(value, 500); + } + + #[test] + fn fresh_input_keeps_normalized_rollup_value() { + let conn = setup_conn(); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, app_type, input_tokens, cache_read_tokens, + cache_creation_tokens, input_token_semantics + ) VALUES ('codex-fresh', 'codex', 500, 300, 200, ?1)", + [INPUT_TOKEN_SEMANTICS_FRESH], + ) + .unwrap(); + let expr = fresh_input_sql("l"); + let sql = format!("SELECT {expr} FROM proxy_request_logs l"); + let value: i64 = conn.query_row(&sql, [], |row| row.get(0)).unwrap(); + assert_eq!(value, 500); + } } diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 76aa9d0a3..5db28335b 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -92,19 +92,12 @@ impl StreamCheckService { config: &StreamCheckConfig, base_url_override: Option, ) -> Result { - let effective = Self::merge_provider_config(provider, config); - let mut last_result: Option = None; - for attempt in 0..=effective.max_retries { + for attempt in 0..=config.max_retries { let start = Instant::now(); - let result = Self::check_once( - app_type, - provider, - &effective, - base_url_override.clone(), - start, - ) - .await?; + let result = + Self::check_once(app_type, provider, config, base_url_override.clone(), start) + .await?; if result.success { return Ok(StreamCheckResult { @@ -114,7 +107,7 @@ impl StreamCheckService { } // 仅超时 / abort 类网络抖动值得重试;连接被拒、DNS 失败等立即返回。 - if Self::should_retry(&result.message) && attempt < effective.max_retries { + if Self::should_retry(&result.message) && attempt < config.max_retries { last_result = Some(result); continue; } @@ -132,31 +125,11 @@ impl StreamCheckService { http_status: None, model_used: String::new(), tested_at: chrono::Utc::now().timestamp(), - retry_count: effective.max_retries, + retry_count: config.max_retries, error_category: None, })) } - /// 合并供应商单独配置(`meta.testConfig`,仅当 `enabled`)与全局配置。 - fn merge_provider_config(provider: &Provider, global: &StreamCheckConfig) -> StreamCheckConfig { - let tc = provider - .meta - .as_ref() - .and_then(|m| m.test_config.as_ref()) - .filter(|tc| tc.enabled); - - match tc { - Some(tc) => StreamCheckConfig { - timeout_secs: tc.timeout_secs.unwrap_or(global.timeout_secs), - max_retries: tc.max_retries.unwrap_or(global.max_retries), - degraded_threshold_ms: tc - .degraded_threshold_ms - .unwrap_or(global.degraded_threshold_ms), - }, - None => global.clone(), - } - } - /// 单次连通性探测。 async fn check_once( app_type: &AppType, @@ -193,6 +166,12 @@ impl StreamCheckService { /// 没有 cc-switch 能可靠探测的目标——这类供应商的连通检测按钮在前端已隐藏 /// (见 `ProviderCard.tsx`),故此处对其提取失败直接报错即可,不做官方端点回退。 fn resolve_base_url(app_type: &AppType, provider: &Provider) -> Result { + if provider.category.as_deref() == Some("official") { + return Err(AppError::Message( + "Official providers do not expose a reachability-check target".to_string(), + )); + } + match app_type { // 累加模式应用的 settings_config 结构与 Claude/Codex/Gemini 不同, // 不走 adapter,直接按各自约定提取 base_url。 @@ -474,48 +453,6 @@ mod tests { assert_eq!(r.status, HealthStatus::Degraded); } - #[test] - fn test_merge_provider_config_override_and_default() { - use crate::provider::{ProviderMeta, ProviderTestConfig}; - - let global = StreamCheckConfig::default(); - - // 无 testConfig → 用全局 - let p = make_provider(serde_json::json!({})); - let merged = StreamCheckService::merge_provider_config(&p, &global); - assert_eq!(merged.timeout_secs, global.timeout_secs); - - // testConfig 启用并覆盖部分字段 - let mut p2 = make_provider(serde_json::json!({})); - p2.meta = Some(ProviderMeta { - test_config: Some(ProviderTestConfig { - enabled: true, - timeout_secs: Some(20), - degraded_threshold_ms: Some(3000), - max_retries: None, - }), - ..Default::default() - }); - let merged2 = StreamCheckService::merge_provider_config(&p2, &global); - assert_eq!(merged2.timeout_secs, 20); - assert_eq!(merged2.degraded_threshold_ms, 3000); - assert_eq!(merged2.max_retries, global.max_retries); // 未覆盖 → 全局 - - // testConfig 存在但未启用 → 忽略,用全局 - let mut p3 = make_provider(serde_json::json!({})); - p3.meta = Some(ProviderMeta { - test_config: Some(ProviderTestConfig { - enabled: false, - timeout_secs: Some(99), - degraded_threshold_ms: None, - max_retries: None, - }), - ..Default::default() - }); - let merged3 = StreamCheckService::merge_provider_config(&p3, &global); - assert_eq!(merged3.timeout_secs, global.timeout_secs); - } - #[test] fn test_resolve_opencode_base_url_explicit_wins() { let p = make_provider(serde_json::json!({ @@ -579,5 +516,10 @@ mod tests { // 不会走到这里;不做官方端点回退(避免给忘填地址的第三方误显绿灯)。 let empty = make_provider(serde_json::json!({ "env": {} })); assert!(StreamCheckService::resolve_base_url(&AppType::Claude, &empty).is_err()); + + let mut official = make_provider(serde_json::json!({ "auth": {}, "config": "" })); + official.id = crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(); + official.category = Some("official".to_string()); + assert!(StreamCheckService::resolve_base_url(&AppType::Codex, &official).is_err()); } } diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 5528455e6..c6c88cfd7 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -312,6 +312,12 @@ pub const TIER_WEEKLY_LIMIT: &str = "weekly_limit"; /// 映射到 `subscription.monthly`。 pub const TIER_MONTHLY: &str = "monthly"; +/// Codex 免费方案的 30 天(月)滚动窗口 tier 名。付费方案的次要窗口是 7 天 +/// (`seven_day`),免费方案则是 30 天。由 `window_seconds_to_tier_name` 产出、 +/// tray 的月分组渲染、前端 `TIER_I18N_KEYS` 映射到 `subscription.thirtyDay` +/// 三处共用同一标识。见 #3651。 +pub const TIER_THIRTY_DAY: &str = "30_day"; + /// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。 pub const TIER_GEMINI_PRO: &str = "gemini_pro"; pub const TIER_GEMINI_FLASH: &str = "gemini_flash"; @@ -325,7 +331,11 @@ const KNOWN_TIERS: &[&str] = &[ ]; /// 查询 Claude 官方订阅额度 -async fn query_claude_quota(access_token: &str) -> SubscriptionQuota { +/// +/// 瞬时传输失败(网络/超时/读体中断)返回 `Err`(前端 reject → retry + 保留上次 +/// 成功值);确定性失败(鉴权/非 2xx/响应体非法 JSON)返回 `Ok(success:false)`。 +/// codex/gemini 两个查询函数遵守同一约定。 +async fn query_claude_quota(access_token: &str) -> Result { let client = crate::proxy::http_client::get(); let resp = client @@ -339,42 +349,42 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota { let resp = match resp { Ok(r) => r, - Err(e) => { - return SubscriptionQuota::error( - "claude", - CredentialStatus::Valid, - format!("Network error: {e}"), - ); - } + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "claude", CredentialStatus::Expired, format!("Authentication failed (HTTP {status}). Please re-login with Claude CLI."), - ); + )); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "claude", CredentialStatus::Valid, format!("API error (HTTP {status}): {body}"), - ); + )); } - let body: serde_json::Value = match resp.json().await { + // 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 + // 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。 + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read API response: {e}")), + }; + let body: serde_json::Value = match serde_json::from_slice(&raw) { Ok(v) => v, Err(e) => { - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "claude", CredentialStatus::Valid, format!("Failed to parse API response: {e}"), - ); + )); } }; @@ -429,7 +439,7 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota { }) }); - SubscriptionQuota { + Ok(SubscriptionQuota { tool: "claude".to_string(), credential_status: CredentialStatus::Valid, credential_message: None, @@ -438,7 +448,7 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota { extra_usage, error: None, queried_at: Some(now_millis()), - } + }) } // ── Codex 凭据读取 ────────────────────────────────────── @@ -632,8 +642,12 @@ struct CodexUsageResponse { /// 根据窗口秒数映射到 tier 名称(与 Claude 的命名兼容以复用前端 i18n) fn window_seconds_to_tier_name(secs: i64) -> String { match secs { - 18000 => "five_hour".to_string(), - 604800 => "seven_day".to_string(), + 18000 => TIER_FIVE_HOUR.to_string(), + 604800 => TIER_SEVEN_DAY.to_string(), + // Codex 免费方案的 30 天窗口。显式映射到常量,与 tray 月分组、前端 + // TIER_I18N_KEYS 保持同一标识(否则动态回退虽也得到 "30_day",但字符串 + // 分散在多处、易和托盘/前端白名单脱节)。见 #3651。 + 2_592_000 => TIER_THIRTY_DAY.to_string(), s => { let hours = s / 3600; if hours >= 24 { @@ -660,7 +674,7 @@ pub(crate) async fn query_codex_quota( account_id: Option<&str>, tool_label: &str, expired_message: &str, -) -> SubscriptionQuota { +) -> Result { let client = crate::proxy::http_client::get(); let mut req = client @@ -675,42 +689,40 @@ pub(crate) async fn query_codex_quota( let resp = match req.timeout(std::time::Duration::from_secs(15)).send().await { Ok(r) => r, - Err(e) => { - return SubscriptionQuota::error( - tool_label, - CredentialStatus::Valid, - format!("Network error: {e}"), - ); - } + Err(e) => return Err(format!("Network error: {e}")), }; let status = resp.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( tool_label, CredentialStatus::Expired, format!("{expired_message} (HTTP {status})"), - ); + )); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( tool_label, CredentialStatus::Valid, format!("API error (HTTP {status}): {body}"), - ); + )); } - let body: CodexUsageResponse = match resp.json().await { + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read API response: {e}")), + }; + let body: CodexUsageResponse = match serde_json::from_slice(&raw) { Ok(v) => v, Err(e) => { - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( tool_label, CredentialStatus::Valid, format!("Failed to parse API response: {e}"), - ); + )); } }; @@ -736,7 +748,7 @@ pub(crate) async fn query_codex_quota( } } - SubscriptionQuota { + Ok(SubscriptionQuota { tool: tool_label.to_string(), credential_status: CredentialStatus::Valid, credential_message: None, @@ -745,7 +757,7 @@ pub(crate) async fn query_codex_quota( extra_usage: None, error: None, queried_at: Some(now_millis()), - } + }) } // ── Gemini 凭据读取 ────────────────────────────────────── @@ -1039,7 +1051,7 @@ fn classify_gemini_model(model_id: &str) -> &str { /// 两步 API 调用: /// 1. loadCodeAssist → 获取 cloudaicompanionProject /// 2. retrieveUserQuota → 获取按模型分桶的配额数据 -async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota { +async fn query_gemini_quota(access_token: &str) -> Result { let client = crate::proxy::http_client::get(); // ── Step 1: loadCodeAssist 获取项目 ID ── @@ -1059,42 +1071,40 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota { let load_resp = match load_resp { Ok(r) => r, - Err(e) => { - return SubscriptionQuota::error( - "gemini", - CredentialStatus::Valid, - format!("Network error (loadCodeAssist): {e}"), - ); - } + Err(e) => return Err(format!("Network error (loadCodeAssist): {e}")), }; let load_status = load_resp.status(); if load_status == reqwest::StatusCode::UNAUTHORIZED || load_status == reqwest::StatusCode::FORBIDDEN { - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "gemini", CredentialStatus::Expired, format!("Authentication failed (HTTP {load_status}). Please re-login with Gemini CLI."), - ); + )); } if !load_status.is_success() { let body = load_resp.text().await.unwrap_or_default(); - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "gemini", CredentialStatus::Valid, format!("loadCodeAssist failed (HTTP {load_status}): {body}"), - ); + )); } - let load_body: GeminiLoadCodeAssistResponse = match load_resp.json().await { + let load_raw = match load_resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read loadCodeAssist response: {e}")), + }; + let load_body: GeminiLoadCodeAssistResponse = match serde_json::from_slice(&load_raw) { Ok(v) => v, Err(e) => { - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "gemini", CredentialStatus::Valid, format!("Failed to parse loadCodeAssist response: {e}"), - ); + )); } }; @@ -1120,42 +1130,40 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota { let quota_resp = match quota_resp { Ok(r) => r, - Err(e) => { - return SubscriptionQuota::error( - "gemini", - CredentialStatus::Valid, - format!("Network error (retrieveUserQuota): {e}"), - ); - } + Err(e) => return Err(format!("Network error (retrieveUserQuota): {e}")), }; let quota_status = quota_resp.status(); if quota_status == reqwest::StatusCode::UNAUTHORIZED || quota_status == reqwest::StatusCode::FORBIDDEN { - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "gemini", CredentialStatus::Expired, format!("Authentication failed (HTTP {quota_status})."), - ); + )); } if !quota_status.is_success() { let body = quota_resp.text().await.unwrap_or_default(); - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "gemini", CredentialStatus::Valid, format!("retrieveUserQuota failed (HTTP {quota_status}): {body}"), - ); + )); } - let quota_data: GeminiQuotaResponse = match quota_resp.json().await { + let quota_raw = match quota_resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read quota response: {e}")), + }; + let quota_data: GeminiQuotaResponse = match serde_json::from_slice("a_raw) { Ok(v) => v, Err(e) => { - return SubscriptionQuota::error( + return Ok(SubscriptionQuota::error( "gemini", CredentialStatus::Valid, format!("Failed to parse quota response: {e}"), - ); + )); } }; @@ -1203,7 +1211,7 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota { tiers.sort_by_key(|t| sort_order(&t.name)); - SubscriptionQuota { + Ok(SubscriptionQuota { tool: "gemini".to_string(), credential_status: CredentialStatus::Valid, credential_message: None, @@ -1212,12 +1220,16 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota { extra_usage: None, error: None, queried_at: Some(now_millis()), - } + }) } // ── 入口函数 ────────────────────────────────────────────── /// 查询指定 CLI 工具的官方订阅额度 +/// +/// 瞬时传输失败以 `Err` 传播(前端 reject → retry + 保留上次成功值)。Expired +/// 分支的"过期也试一把"重试同样用 `?` 传播瞬时错误——不能折叠成"已过期", +/// 否则一次网络抖动会被误报成确定性的凭据过期。 pub async fn get_subscription_quota(tool: &str) -> Result { match tool { "claude" => { @@ -1233,7 +1245,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result { // 即使过期也尝试调用 API(token 可能实际上仍有效) if let Some(token) = token { - let result = query_claude_quota(&token).await; + let result = query_claude_quota(&token).await?; if result.success { return Ok(result); } @@ -1246,7 +1258,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result { let token = token.expect("token must be Some when status is Valid"); - Ok(query_claude_quota(&token).await) + query_claude_quota(&token).await } } } @@ -1269,7 +1281,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result Result { let token = token.expect("token must be Some when status is Valid"); - Ok(query_codex_quota( + query_codex_quota( &token, account_id.as_deref(), "codex", "Authentication failed. Please re-login with Codex CLI.", ) - .await) + .await } } } @@ -1306,12 +1318,12 @@ pub async fn get_subscription_quota(tool: &str) -> Result Result { let token = token.expect("token must be Some when status is Valid"); - Ok(query_gemini_quota(&token).await) + query_gemini_quota(&token).await } } } @@ -1340,3 +1352,21 @@ fn now_millis() -> i64 { .unwrap_or_default() .as_millis() as i64 } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn window_seconds_map_to_expected_tier_names() { + // 官方特例窗口 + assert_eq!(window_seconds_to_tier_name(18000), TIER_FIVE_HOUR); + assert_eq!(window_seconds_to_tier_name(604800), TIER_SEVEN_DAY); + // Codex 免费方案的次要窗口是 30 天(30 * 24 * 3600 = 2_592_000 秒)。 + // 前端 TIER_I18N_KEYS 与 tray 月分组都需要认得 "30_day",见 #3651。 + assert_eq!(window_seconds_to_tier_name(2_592_000), TIER_THIRTY_DAY); + // 其他窗口按小时/天回退命名 + assert_eq!(window_seconds_to_tier_name(3600), "1_hour"); + assert_eq!(window_seconds_to_tier_name(86400), "1_day"); + } +} diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index a1d75bd21..d0760f2c4 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -5,7 +5,9 @@ use crate::database::{lock_conn, Database}; use crate::error::AppError; use crate::proxy::usage::calculator::ModelPricing; -use crate::services::sql_helpers::fresh_input_sql; +use crate::services::sql_helpers::{ + fresh_input_sql, INPUT_TOKEN_SEMANTICS_FRESH, INPUT_TOKEN_SEMANTICS_TOTAL, +}; use chrono::{Local, NaiveDate, TimeZone, Timelike}; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; @@ -135,6 +137,9 @@ pub struct RequestLogDetail { pub output_tokens: u32, pub cache_read_tokens: u32, pub cache_creation_tokens: u32, + /// Internal storage semantics; omitted from the UI/API payload. + #[serde(skip)] + pub input_token_semantics: i64, pub input_cost_usd: String, pub output_cost_usd: String, pub cache_read_cost_usd: String, @@ -154,15 +159,15 @@ pub struct RequestLogDetail { pub pricing_model: Option, } -/// 把 25 列的查询结果映射为 `RequestLogDetail`。 +/// 把 26 列的查询结果映射为 `RequestLogDetail`。 /// -/// 调用方的 SELECT **必须**按以下顺序返回 25 列: +/// 调用方的 SELECT **必须**按以下顺序返回 26 列: /// `request_id, provider_id, provider_name, app_type, model, request_model, /// cost_multiplier, input_tokens, output_tokens, cache_read_tokens, /// cache_creation_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd, /// cache_creation_cost_usd, total_cost_usd, is_streaming, latency_ms, /// first_token_ms, duration_ms, status_code, error_message, created_at, -/// data_source, pricing_model` +/// data_source, pricing_model, input_token_semantics` /// /// 不需要 provider_name 时(如 backfill)SELECT `NULL AS provider_name` 占位即可。 fn row_to_request_log_detail(row: &rusqlite::Row<'_>) -> rusqlite::Result { @@ -194,6 +199,7 @@ fn row_to_request_log_detail(row: &rusqlite::Row<'_>) -> rusqlite::Result(25)?, }) } @@ -1526,7 +1532,8 @@ impl Database { l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens, l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd, l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms, - l.status_code, l.error_message, l.created_at, l.data_source, l.pricing_model + l.status_code, l.error_message, l.created_at, l.data_source, l.pricing_model, + l.input_token_semantics FROM proxy_request_logs l LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type {where_clause} @@ -1569,7 +1576,8 @@ impl Database { input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, is_streaming, latency_ms, first_token_ms, duration_ms, - status_code, error_message, created_at, l.data_source, l.pricing_model + status_code, error_message, created_at, l.data_source, l.pricing_model, + l.input_token_semantics FROM proxy_request_logs l LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type WHERE l.request_id = ?" @@ -1725,7 +1733,7 @@ impl Database { input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, is_streaming, latency_ms, first_token_ms, duration_ms, status_code, error_message, created_at, - data_source, pricing_model + data_source, pricing_model, input_token_semantics FROM proxy_request_logs WHERE CAST(total_cost_usd AS REAL) <= 0 AND (input_tokens > 0 OR output_tokens > 0 @@ -1806,15 +1814,21 @@ impl Database { let million = rust_decimal::Decimal::from(1_000_000u64); // 与 CostCalculator::calculate_for_app 保持一致的计算逻辑: - // 1. Codex/Gemini 的 input_tokens 包含 cache_read_tokens,需要扣除后按输入价计费 + // 1. 历史 Codex/Gemini 行只包含 cache read;新 total 行还包含 cache write。 // 2. Claude/Anthropic 的 input_tokens 已经是 fresh input,不能再次扣减 // 3. 各项成本是基础成本(不含倍率),倍率只作用于最终总价 - let input_includes_cache_read = matches!(log.app_type.as_str(), "codex" | "gemini"); - let billable_input_tokens = if input_includes_cache_read { - (log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64) - } else { - log.input_tokens as u64 - }; + let cache_inclusive_app = matches!(log.app_type.as_str(), "codex" | "gemini"); + let billable_input_tokens = + if !cache_inclusive_app || log.input_token_semantics == INPUT_TOKEN_SEMANTICS_FRESH { + log.input_tokens as u64 + } else if log.input_token_semantics == INPUT_TOKEN_SEMANTICS_TOTAL { + (log.input_tokens as u64) + .saturating_sub(log.cache_read_tokens as u64) + .saturating_sub(log.cache_creation_tokens as u64) + } else { + // v12 and earlier: input included cache reads but excluded cache writes. + (log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64) + }; let input_cost = rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million; let output_cost = @@ -2492,6 +2506,77 @@ mod tests { Ok(()) } + #[test] + fn test_backfill_distinguishes_legacy_and_total_cache_semantics() -> Result<(), AppError> { + let db = Database::memory()?; + + { + let conn = lock_conn!(db.conn); + // v12 mirror row: input = fresh + read; creation was reported separately. + insert_usage_log( + &conn, + "legacy-cache-semantics", + "codex", + "p1", + "gpt-5.5", + "proxy", + 1000, + 800_000, + 0, + 600_000, + 200_000, + 200, + "0", + )?; + // v13 proxy row: input = fresh + read + creation. + insert_usage_log( + &conn, + "total-cache-semantics", + "codex", + "p1", + "gpt-5.5", + "proxy", + 1001, + 1_000_000, + 0, + 600_000, + 200_000, + 200, + "0", + )?; + conn.execute( + "UPDATE proxy_request_logs + SET input_token_semantics = ?1 + WHERE request_id = 'total-cache-semantics'", + [INPUT_TOKEN_SEMANTICS_TOTAL], + )?; + } + + assert_eq!(db.backfill_missing_usage_costs()?, 2); + + let conn = lock_conn!(db.conn); + let mut stmt = conn.prepare( + "SELECT request_id, input_cost_usd + FROM proxy_request_logs + WHERE request_id IN ('legacy-cache-semantics', 'total-cache-semantics') + ORDER BY request_id", + )?; + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? + .collect::, _>>()?; + assert_eq!( + rows, + vec![ + ("legacy-cache-semantics".to_string(), "1.000000".to_string()), + ("total-cache-semantics".to_string(), "1.000000".to_string()), + ] + ); + + Ok(()) + } + #[test] fn test_backfill_missing_usage_costs_uses_stored_multiplier() -> Result<(), AppError> { let db = Database::memory()?; diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index c7ca1f714..839e94fb0 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -4,7 +4,7 @@ pub mod terminal; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -use providers::{claude, codex, gemini, hermes, openclaw, opencode}; +use providers::{claude, codex, gemini, grokbuild, hermes, openclaw, opencode}; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -56,13 +56,14 @@ pub struct DeleteSessionOutcome { } pub fn scan_sessions() -> Vec { - let (r1, r2, r3, r4, r5, r6) = std::thread::scope(|s| { + let (r1, r2, r3, r4, r5, r6, r7) = std::thread::scope(|s| { let h1 = s.spawn(codex::scan_sessions); let h2 = s.spawn(claude::scan_sessions); let h3 = s.spawn(opencode::scan_sessions); let h4 = s.spawn(openclaw::scan_sessions); let h5 = s.spawn(gemini::scan_sessions); let h6 = s.spawn(hermes::scan_sessions); + let h7 = s.spawn(grokbuild::scan_sessions); ( h1.join().unwrap_or_default(), h2.join().unwrap_or_default(), @@ -70,6 +71,7 @@ pub fn scan_sessions() -> Vec { h4.join().unwrap_or_default(), h5.join().unwrap_or_default(), h6.join().unwrap_or_default(), + h7.join().unwrap_or_default(), ) }); @@ -80,6 +82,7 @@ pub fn scan_sessions() -> Vec { sessions.extend(r4); sessions.extend(r5); sessions.extend(r6); + sessions.extend(r7); sessions.sort_by(|a, b| { let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0); @@ -106,6 +109,7 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result opencode::load_messages(path), "openclaw" => openclaw::load_messages(path), "gemini" => gemini::load_messages(path), + "grokbuild" => grokbuild::load_messages(path), "hermes" => hermes::load_messages(path), _ => Err(format!("Unsupported provider: {provider_id}")), } @@ -165,6 +169,9 @@ fn delete_session_with_roots( openclaw::delete_session(&validated_root, &validated_source, session_id) } "gemini" => gemini::delete_session(&validated_root, &validated_source, session_id), + "grokbuild" => { + grokbuild::delete_session(&validated_root, &validated_source, session_id) + } "hermes" => hermes::delete_session(&validated_root, &validated_source, session_id), _ => Err(format!("Unsupported provider: {provider_id}")), }; @@ -194,6 +201,7 @@ fn provider_roots(provider_id: &str) -> Result, String> { "opencode" => vec![opencode::get_opencode_data_dir()], "openclaw" => vec![crate::openclaw_config::get_openclaw_dir().join("agents")], "gemini" => vec![crate::gemini_config::get_gemini_dir().join("tmp")], + "grokbuild" => grokbuild::session_roots(), "hermes" => vec![crate::hermes_config::get_hermes_dir().join("sessions")], _ => return Err(format!("Unsupported provider: {provider_id}")), }; diff --git a/src-tauri/src/session_manager/providers/codex.rs b/src-tauri/src/session_manager/providers/codex.rs index 616a30458..e2383fe6c 100644 --- a/src-tauri/src/session_manager/providers/codex.rs +++ b/src-tauri/src/session_manager/providers/codex.rs @@ -1,12 +1,17 @@ +use std::collections::HashMap; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::sync::LazyLock; +use std::time::Duration; use regex::Regex; +use rusqlite::Connection; +use serde::Deserialize; use serde_json::Value; -use crate::codex_config::get_codex_config_dir; +use crate::codex_config::{get_codex_config_dir, read_codex_config_text}; +use crate::codex_state_db::codex_state_db_paths; use crate::session_manager::{SessionMessage, SessionMeta}; use super::utils::{ @@ -15,6 +20,7 @@ use super::utils::{ }; const PROVIDER_ID: &str = "codex"; +const CODEX_SESSION_INDEX_FILENAME: &str = "session_index.jsonl"; const VSCODE_CONTEXT_PREFIX: &str = "# Context from my IDE setup:"; const CODEX_REQUEST_MARKER: &str = "my request for codex"; @@ -23,6 +29,12 @@ static UUID_RE: LazyLock = LazyLock::new(|| { .unwrap() }); +#[derive(Deserialize)] +struct SessionIndexEntry { + id: String, + thread_name: String, +} + pub fn scan_sessions() -> Vec { let roots = session_roots(); scan_sessions_in_roots(&roots) @@ -37,6 +49,14 @@ pub fn session_roots() -> Vec { } fn scan_sessions_in_roots(roots: &[PathBuf]) -> Vec { + let thread_titles = load_thread_titles(); + scan_sessions_in_roots_with_titles(roots, &thread_titles) +} + +fn scan_sessions_in_roots_with_titles( + roots: &[PathBuf], + thread_titles: &HashMap, +) -> Vec { let mut files = Vec::new(); for root in roots { collect_jsonl_files(root, &mut files); @@ -44,7 +64,7 @@ fn scan_sessions_in_roots(roots: &[PathBuf]) -> Vec { let mut sessions = Vec::new(); for path in files { - if let Some(meta) = parse_session(&path) { + if let Some(meta) = parse_session_with_titles(&path, thread_titles) { sessions.push(meta); } } @@ -52,6 +72,135 @@ fn scan_sessions_in_roots(roots: &[PathBuf]) -> Vec { sessions } +fn load_thread_titles() -> HashMap { + let config_dir = get_codex_config_dir(); + let config_text = read_codex_config_text().unwrap_or_default(); + let db_paths = codex_state_db_paths(&config_dir, &config_text); + load_thread_titles_from_paths(&config_dir.join(CODEX_SESSION_INDEX_FILENAME), &db_paths) +} + +fn load_thread_titles_from_paths( + session_index_path: &Path, + db_paths: &[PathBuf], +) -> HashMap { + let mut titles = load_thread_titles_from_session_index(session_index_path); + for db_path in db_paths { + titles.extend(load_thread_titles_from_db(db_path)); + } + titles +} + +fn load_thread_titles_from_session_index(index_path: &Path) -> HashMap { + if !index_path.exists() { + return HashMap::new(); + } + + let file = match File::open(index_path) { + Ok(file) => file, + Err(err) => { + log::warn!( + "Failed to open Codex session index {}: {err}", + index_path.display() + ); + return HashMap::new(); + } + }; + + let reader = BufReader::new(file); + let mut titles = HashMap::new(); + for line in reader.lines() { + let line = match line { + Ok(line) => line, + Err(_) => continue, + }; + let Ok(entry) = serde_json::from_str::(line.trim()) else { + continue; + }; + let id = entry.id.trim(); + let title = entry.thread_name.trim(); + if !id.is_empty() && !title.is_empty() { + titles.insert(id.to_string(), title.to_string()); + } + } + + titles +} + +fn load_thread_titles_from_db(db_path: &Path) -> HashMap { + if !db_path.exists() { + return HashMap::new(); + } + + let conn = match Connection::open_with_flags( + db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) { + Ok(conn) => conn, + Err(err) => { + log::warn!( + "Failed to open Codex state database {}: {err}", + db_path.display() + ); + return HashMap::new(); + } + }; + // Codex keeps this DB open and write-locked while running; without a busy + // timeout a read during a write fails immediately and titles silently drop. + if let Err(err) = conn.busy_timeout(Duration::from_secs(2)) { + log::warn!( + "Failed to set Codex state database busy timeout for {}: {err}", + db_path.display() + ); + return HashMap::new(); + } + + // Mirror Codex's own `distinct_thread_metadata_title`: keep a title only + // when it differs from the first user message. Push the comparison into SQL + // (NULL-safe) so we never SELECT the unbounded `first_user_message` blob — + // it can grow large enough to OOM (openai/codex#29007). + let mut stmt = match conn.prepare( + "SELECT id, title FROM threads \ + WHERE title <> '' \ + AND (first_user_message IS NULL OR TRIM(title) <> TRIM(first_user_message))", + ) { + Ok(stmt) => stmt, + Err(err) => { + log::warn!( + "Failed to prepare Codex thread title query for {}: {err}", + db_path.display() + ); + return HashMap::new(); + } + }; + + let rows = match stmt.query_map([], |row| { + let id: String = row.get(0)?; + let title: String = row.get(1)?; + Ok((id, title)) + }) { + Ok(rows) => rows, + Err(err) => { + log::warn!( + "Failed to query Codex thread titles from {}: {err}", + db_path.display() + ); + return HashMap::new(); + } + }; + + rows.flatten() + .filter_map(|(id, title)| { + let id = id.trim(); + let title = title.trim(); + if id.is_empty() || title.is_empty() { + None + } else { + Some((id.to_string(), title.to_string())) + } + }) + .collect() +} + pub fn load_messages(path: &Path) -> Result, String> { let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?; let reader = BufReader::new(file); @@ -141,6 +290,13 @@ pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result Option { + parse_session_with_titles(path, &HashMap::new()) +} + +fn parse_session_with_titles( + path: &Path, + thread_titles: &HashMap, +) -> Option { let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; let mut session_id: Option = None; @@ -233,8 +389,10 @@ fn parse_session(path: &Path) -> Option { let session_id = session_id.or_else(|| infer_session_id_from_filename(path)); let session_id = session_id?; - let title = first_user_message - .map(|t| truncate_summary(&t, TITLE_MAX_CHARS)) + let title = thread_titles + .get(&session_id) + .map(|t| truncate_summary(t, TITLE_MAX_CHARS)) + .or_else(|| first_user_message.map(|t| truncate_summary(&t, TITLE_MAX_CHARS))) .or_else(|| { project_dir .as_deref() @@ -366,6 +524,7 @@ fn collect_jsonl_files(root: &Path, files: &mut Vec) { #[cfg(test)] mod tests { use super::*; + use crate::codex_state_db::CODEX_STATE_DB_FILENAME; use tempfile::tempdir; fn write_codex_session(path: &Path, session_id: &str, message: &str) { @@ -443,6 +602,166 @@ mod tests { assert_eq!(meta.title.as_deref(), Some("How do I deploy?")); } + #[test] + fn parse_session_prefers_thread_title() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"How do I deploy?\"}}\n" + ), + ) + .expect("write"); + + let mut thread_titles = HashMap::new(); + thread_titles.insert( + "test-id".to_string(), + "Renamed deployment thread".to_string(), + ); + + let meta = parse_session_with_titles(&path, &thread_titles).unwrap(); + assert_eq!(meta.title.as_deref(), Some("Renamed deployment thread")); + } + + #[test] + fn load_thread_titles_from_state_db_trims_and_filters_titles() { + let temp = tempdir().expect("tempdir"); + let db_path = temp.path().join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&db_path).expect("open sqlite db"); + conn.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT NOT NULL, first_user_message TEXT NOT NULL)", + [], + ) + .expect("create threads table"); + conn.execute( + "INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)", + ("thread-1", " Renamed Codex thread ", "First prompt"), + ) + .expect("insert renamed thread"); + conn.execute( + "INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)", + ("thread-2", " ", "First prompt"), + ) + .expect("insert blank thread"); + conn.execute( + "INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)", + ("thread-3", " First prompt ", "First prompt"), + ) + .expect("insert first-message title"); + drop(conn); + + let titles = load_thread_titles_from_db(&db_path); + + assert_eq!( + titles.get("thread-1").map(String::as_str), + Some("Renamed Codex thread") + ); + assert!(!titles.contains_key("thread-2")); + assert!(!titles.contains_key("thread-3")); + } + + #[test] + fn load_thread_titles_from_state_db_keeps_title_when_first_user_message_null() { + let temp = tempdir().expect("tempdir"); + let db_path = temp.path().join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&db_path).expect("open sqlite db"); + // Codex stores first_user_message as a nullable column (Option); + // a renamed thread can have a title before any first message is synced. + conn.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT NOT NULL, first_user_message TEXT)", + [], + ) + .expect("create threads table"); + conn.execute( + "INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, NULL)", + ("thread-1", "Renamed thread"), + ) + .expect("insert renamed thread without first message"); + conn.execute( + "INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)", + ("thread-2", "First prompt", "First prompt"), + ) + .expect("insert first-message title"); + drop(conn); + + let titles = load_thread_titles_from_db(&db_path); + + // Kept: title present and no first message to compare against. + assert_eq!( + titles.get("thread-1").map(String::as_str), + Some("Renamed thread") + ); + // Filtered: title equals the first user message. + assert!(!titles.contains_key("thread-2")); + } + + #[test] + fn load_thread_titles_from_session_index_uses_latest_name() { + let temp = tempdir().expect("tempdir"); + let index_path = temp.path().join(CODEX_SESSION_INDEX_FILENAME); + std::fs::write( + &index_path, + concat!( + "{\"id\":\"thread-1\",\"thread_name\":\"Old name\",\"updated_at\":\"2026-07-01T00:00:00Z\"}\n", + "{\"id\":\"thread-2\",\"thread_name\":\" \",\"updated_at\":\"2026-07-01T00:00:00Z\"}\n", + "not json\n", + "{\"id\":\"thread-1\",\"thread_name\":\" New name \",\"updated_at\":\"2026-07-02T00:00:00Z\"}\n" + ), + ) + .expect("write session index"); + + let titles = load_thread_titles_from_session_index(&index_path); + + assert_eq!(titles.get("thread-1").map(String::as_str), Some("New name")); + assert!(!titles.contains_key("thread-2")); + } + + #[test] + fn load_thread_titles_prefers_state_db_explicit_title_over_session_index() { + let temp = tempdir().expect("tempdir"); + let index_path = temp.path().join(CODEX_SESSION_INDEX_FILENAME); + std::fs::write( + &index_path, + concat!( + "{\"id\":\"thread-1\",\"thread_name\":\"Legacy name\",\"updated_at\":\"2026-07-01T00:00:00Z\"}\n", + "{\"id\":\"thread-2\",\"thread_name\":\"Legacy fallback\",\"updated_at\":\"2026-07-01T00:00:00Z\"}\n" + ), + ) + .expect("write session index"); + + let db_path = temp.path().join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&db_path).expect("open sqlite db"); + conn.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT NOT NULL, first_user_message TEXT NOT NULL)", + [], + ) + .expect("create threads table"); + conn.execute( + "INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)", + ("thread-1", "SQLite name", "First prompt"), + ) + .expect("insert sqlite title"); + conn.execute( + "INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)", + ("thread-2", "First prompt", "First prompt"), + ) + .expect("insert first-message sqlite title"); + drop(conn); + + let titles = load_thread_titles_from_paths(&index_path, &[db_path]); + + assert_eq!( + titles.get("thread-1").map(String::as_str), + Some("SQLite name") + ); + assert_eq!( + titles.get("thread-2").map(String::as_str), + Some("Legacy fallback") + ); + } + #[test] fn parse_session_skips_agents_md_injection() { let temp = tempdir().expect("tempdir"); diff --git a/src-tauri/src/session_manager/providers/grokbuild.rs b/src-tauri/src/session_manager/providers/grokbuild.rs new file mode 100644 index 000000000..d3462b2d3 --- /dev/null +++ b/src-tauri/src/session_manager/providers/grokbuild.rs @@ -0,0 +1,296 @@ +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use serde_json::Value; + +use crate::session_manager::{SessionMessage, SessionMeta}; + +use super::utils::{extract_text, parse_timestamp_to_ms, truncate_summary, TITLE_MAX_CHARS}; + +#[derive(Debug, Deserialize)] +struct GrokSessionInfo { + id: String, + #[serde(default)] + cwd: Option, +} + +#[derive(Debug, Deserialize)] +struct GrokSessionSummary { + info: GrokSessionInfo, + #[serde(default)] + session_summary: Option, + #[serde(default)] + generated_title: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + updated_at: Option, + #[serde(default)] + last_active_at: Option, +} + +pub fn session_roots() -> Vec { + let config_dir = crate::grok_config::get_grok_config_dir(); + vec![ + config_dir.join("sessions"), + config_dir.join("archived_sessions"), + ] +} + +pub fn scan_sessions() -> Vec { + let mut summaries = Vec::new(); + for root in session_roots() { + collect_summary_files(&root, &mut summaries); + } + summaries + .into_iter() + .filter_map(|path| parse_summary(&path)) + .collect() +} + +pub fn load_messages(path: &Path) -> Result, String> { + let session_dir = path + .parent() + .ok_or_else(|| format!("Invalid Grok Build session path: {}", path.display()))?; + let chat_path = session_dir.join("chat_history.jsonl"); + let file = File::open(&chat_path) + .map_err(|e| format!("Failed to open Grok Build chat history: {e}"))?; + let reader = BufReader::new(file); + let mut messages = Vec::new(); + + for line in reader.lines().map_while(Result::ok) { + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + let kind = value.get("type").and_then(Value::as_str).unwrap_or(""); + let role = match kind { + "system" | "user" | "assistant" | "tool" => kind, + // Reasoning records can contain encrypted/internal state and are not + // conversation messages shown by Grok's own history view. + _ => continue, + }; + let content = value.get("content").map(extract_text).unwrap_or_default(); + if content.trim().is_empty() { + continue; + } + let ts = value + .get("timestamp") + .or_else(|| value.get("ts")) + .and_then(parse_timestamp_to_ms); + messages.push(SessionMessage { + role: role.to_string(), + content, + ts, + }); + } + + Ok(messages) +} + +pub fn delete_session(root: &Path, path: &Path, session_id: &str) -> Result { + if !path.starts_with(root) { + return Err(format!( + "Grok Build session source is outside the session root: {}", + path.display() + )); + } + if path.file_name().and_then(|name| name.to_str()) != Some("summary.json") { + return Err(format!( + "Unexpected Grok Build session source: {}", + path.display() + )); + } + let summary = read_summary(path)?; + if summary.info.id != session_id { + return Err(format!( + "Grok Build session ID mismatch: expected {session_id}, found {}", + summary.info.id + )); + } + let session_dir = path + .parent() + .ok_or_else(|| format!("Invalid Grok Build session path: {}", path.display()))?; + if session_dir == root || !session_dir.starts_with(root) { + return Err(format!( + "Refusing to delete Grok Build session directory outside its root: {}", + session_dir.display() + )); + } + if session_dir.file_name().and_then(|name| name.to_str()) != Some(session_id) { + return Err(format!( + "Grok Build session directory does not match session ID: {}", + session_dir.display() + )); + } + std::fs::remove_dir_all(session_dir).map_err(|e| { + format!( + "Failed to delete Grok Build session directory {}: {e}", + session_dir.display() + ) + })?; + Ok(true) +} + +fn collect_summary_files(root: &Path, files: &mut Vec) { + let Ok(entries) = std::fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_summary_files(&path, files); + } else if path.file_name().and_then(|name| name.to_str()) == Some("summary.json") { + files.push(path); + } + } +} + +fn read_summary(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read Grok Build session summary: {e}"))?; + serde_json::from_str(&text) + .map_err(|e| format!("Failed to parse Grok Build session summary: {e}")) +} + +fn parse_summary(path: &Path) -> Option { + let summary = read_summary(path).ok()?; + let session_id = summary.info.id; + let title = summary + .generated_title + .as_deref() + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + summary + .session_summary + .as_deref() + .filter(|value| !value.trim().is_empty()) + }) + .map(|value| truncate_summary(value, TITLE_MAX_CHARS)); + let session_summary = summary + .session_summary + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|value| truncate_summary(value, 160)); + let created_at = summary.created_at.as_ref().and_then(parse_timestamp_to_ms); + let last_active_at = summary + .last_active_at + .as_ref() + .or(summary.updated_at.as_ref()) + .and_then(parse_timestamp_to_ms); + + Some(SessionMeta { + provider_id: "grokbuild".to_string(), + session_id: session_id.clone(), + title, + summary: session_summary, + project_dir: summary.info.cwd, + created_at, + last_active_at, + source_path: Some(path.to_string_lossy().to_string()), + resume_command: Some(format!("grok --resume {session_id}")), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn scans_native_grokbuild_session_layout() { + let temp = tempdir().expect("tempdir"); + let sessions_dir = temp.path().join("sessions"); + let session_id = "019f6af2-18b0-7673-958e-d25be650e172"; + let session_dir = sessions_dir.join("encoded-project").join(session_id); + std::fs::create_dir_all(&session_dir).expect("create session dir"); + std::fs::write( + session_dir.join("summary.json"), + format!( + r#"{{"info":{{"id":"{session_id}","cwd":"C:/work"}},"session_summary":"hello grok","generated_title":"Grok session","created_at":"2026-07-16T12:00:00Z","last_active_at":"2026-07-16T12:00:01Z"}}"# + ), + ) + .expect("write summary"); + let mut files = Vec::new(); + collect_summary_files(&sessions_dir, &mut files); + let sessions = files + .iter() + .filter_map(|path| parse_summary(path)) + .collect::>(); + + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].provider_id, "grokbuild"); + assert_eq!(sessions[0].session_id, session_id); + assert_eq!(sessions[0].title.as_deref(), Some("Grok session")); + let expected_resume = format!("grok --resume {session_id}"); + assert_eq!( + sessions[0].resume_command.as_deref(), + Some(expected_resume.as_str()) + ); + } + + #[test] + fn loads_native_grokbuild_chat_history() { + let temp = tempdir().expect("tempdir"); + let summary_path = temp.path().join("summary.json"); + std::fs::write(&summary_path, "{}").expect("write summary placeholder"); + std::fs::write( + temp.path().join("chat_history.jsonl"), + concat!( + "{\"type\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}\n", + "{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"private\"}]}\n", + "{\"type\":\"assistant\",\"content\":\"Hi there\"}\n" + ), + ) + .expect("write chat history"); + + let messages = load_messages(&summary_path).expect("load messages"); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].role, "user"); + assert_eq!(messages[0].content, "hello"); + assert_eq!(messages[1].content, "Hi there"); + } + + #[test] + fn delete_session_removes_only_the_matching_session_directory() { + let temp = tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + let session_id = "session-to-delete"; + let session_dir = root.join("project").join(session_id); + let sibling_dir = root.join("project").join("session-to-keep"); + std::fs::create_dir_all(&session_dir).expect("create session directory"); + std::fs::create_dir_all(&sibling_dir).expect("create sibling directory"); + let summary_path = session_dir.join("summary.json"); + std::fs::write( + &summary_path, + format!(r#"{{"info":{{"id":"{session_id}"}}}}"#), + ) + .expect("write summary"); + std::fs::write(sibling_dir.join("keep.txt"), "keep").expect("write sibling file"); + + let deleted = delete_session(&root, &summary_path, session_id).expect("delete session"); + + assert!(deleted); + assert!(!session_dir.exists()); + assert!(sibling_dir.exists()); + } + + #[test] + fn delete_session_rejects_remove_dir_all_target_outside_root() { + let temp = tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + let outside_dir = temp.path().join("outside").join("session-outside"); + std::fs::create_dir_all(&root).expect("create root"); + std::fs::create_dir_all(&outside_dir).expect("create outside directory"); + let summary_path = outside_dir.join("summary.json"); + std::fs::write(&summary_path, r#"{"info":{"id":"session-outside"}}"#) + .expect("write summary"); + + let error = delete_session(&root, &summary_path, "session-outside") + .expect_err("outside path must be rejected"); + + assert!(error.contains("outside the session root")); + assert!(outside_dir.exists()); + } +} diff --git a/src-tauri/src/session_manager/providers/mod.rs b/src-tauri/src/session_manager/providers/mod.rs index 394fdd1f8..6a16fb5a8 100644 --- a/src-tauri/src/session_manager/providers/mod.rs +++ b/src-tauri/src/session_manager/providers/mod.rs @@ -1,6 +1,7 @@ pub mod claude; pub mod codex; pub mod gemini; +pub mod grokbuild; pub mod hermes; pub mod openclaw; pub mod opencode; diff --git a/src-tauri/src/session_manager/providers/opencode.rs b/src-tauri/src/session_manager/providers/opencode.rs index 2cfad00f8..eee9b982d 100644 --- a/src-tauri/src/session_manager/providers/opencode.rs +++ b/src-tauri/src/session_manager/providers/opencode.rs @@ -149,7 +149,7 @@ fn scan_sessions_sqlite() -> Vec { created_at: Some(created), last_active_at: Some(updated), source_path: Some(format!("sqlite:{db_display}:{session_id}")), - resume_command: Some(format!("opencode session resume {session_id}")), + resume_command: Some(format!("opencode -s {session_id}")), }); } sessions @@ -473,7 +473,7 @@ fn parse_session(storage: &Path, path: &Path) -> Option { created_at, last_active_at: updated_at.or(created_at), source_path: Some(source_path), - resume_command: Some(format!("opencode session resume {session_id}")), + resume_command: Some(format!("opencode -s {session_id}")), }) } @@ -825,6 +825,10 @@ mod tests { sessions[1].source_path.as_deref(), Some(expected_source.as_str()) ); + assert_eq!( + sessions[1].resume_command.as_deref(), + Some("opencode -s ses_1") + ); } #[test] diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index ffa00909f..98ac3d7ea 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,6 +1,5 @@ use serde::{Deserialize, Serialize}; use std::fs; -use std::io::Write; use std::path::PathBuf; use std::sync::{OnceLock, RwLock}; @@ -40,6 +39,8 @@ pub struct VisibleApps { #[serde(default = "default_true")] pub gemini: bool, #[serde(default = "default_true")] + pub grokbuild: bool, + #[serde(default = "default_true")] pub opencode: bool, #[serde(default = "default_true")] pub openclaw: bool, @@ -54,6 +55,7 @@ impl Default for VisibleApps { claude_desktop: true, codex: true, gemini: true, + grokbuild: true, opencode: true, openclaw: true, hermes: false, // 默认不显示,需用户手动启用 @@ -69,6 +71,7 @@ impl VisibleApps { AppType::ClaudeDesktop => self.claude_desktop, AppType::Codex => self.codex, AppType::Gemini => self.gemini, + AppType::GrokBuild => self.grokbuild, AppType::OpenCode => self.opencode, AppType::OpenClaw => self.openclaw, AppType::Hermes => self.hermes, @@ -366,12 +369,14 @@ pub struct AppSettings { /// User has confirmed the usage query first-run notice #[serde(default, skip_serializing_if = "Option::is_none")] pub usage_confirmed: Option, - /// User has confirmed the stream check first-run notice #[serde(default, skip_serializing_if = "Option::is_none")] - pub stream_check_confirmed: Option, + pub usage_dashboard_refresh_interval_ms: Option, /// Whether to show the failover toggle independently on the main page #[serde(default)] pub enable_failover_toggle: bool, + /// Whether to show the project profile switcher on the main page header + #[serde(default = "default_show_profile_switcher")] + pub show_profile_switcher: bool, /// Keep Codex ChatGPT login material in auth.json when switching to third-party providers. /// Opt-in: defaults to false so third-party switches cleanly overwrite auth.json. #[serde(default)] @@ -410,6 +415,8 @@ pub struct AppSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub gemini_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub grok_config_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub opencode_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub openclaw_config_dir: Option, @@ -429,6 +436,9 @@ pub struct AppSettings { /// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_gemini: Option, + /// 当前 Grok Build 供应商 ID(本地存储,优先于数据库 is_current) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_provider_grokbuild: Option, /// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_opencode: Option, @@ -488,6 +498,10 @@ fn default_minimize_to_tray_on_close() -> bool { true } +fn default_show_profile_switcher() -> bool { + true +} + impl Default for AppSettings { fn default() -> Self { Self { @@ -501,8 +515,9 @@ impl Default for AppSettings { enable_local_proxy: false, proxy_confirmed: None, usage_confirmed: None, - stream_check_confirmed: None, + usage_dashboard_refresh_interval_ms: None, enable_failover_toggle: false, + show_profile_switcher: true, preserve_codex_official_auth_on_switch: false, unify_codex_session_history: false, unify_codex_migrate_existing: None, @@ -514,6 +529,7 @@ impl Default for AppSettings { claude_config_dir: None, codex_config_dir: None, gemini_config_dir: None, + grok_config_dir: None, opencode_config_dir: None, openclaw_config_dir: None, hermes_config_dir: None, @@ -521,6 +537,7 @@ impl Default for AppSettings { current_provider_claude_desktop: None, current_provider_codex: None, current_provider_gemini: None, + current_provider_grokbuild: None, current_provider_opencode: None, current_provider_openclaw: None, current_provider_hermes: None, @@ -569,6 +586,13 @@ impl AppSettings { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + self.grok_config_dir = self + .grok_config_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.opencode_config_dir = self .opencode_config_dir .as_ref() @@ -653,6 +677,7 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { #[cfg(unix)] { use std::fs::OpenOptions; + use std::io::Write; use std::os::unix::fs::OpenOptionsExt; let mut file = OpenOptions::new() @@ -876,6 +901,14 @@ pub fn get_gemini_override_dir() -> Option { .map(|p| resolve_override_path(p)) } +pub fn get_grok_override_dir() -> Option { + let settings = settings_store().read().ok()?; + settings + .grok_config_dir + .as_ref() + .map(|p| resolve_override_path(p)) +} + pub fn get_opencode_override_dir() -> Option { let settings = settings_store().read().ok()?; settings @@ -933,6 +966,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::ClaudeDesktop => settings.current_provider_claude_desktop.clone(), AppType::Codex => settings.current_provider_codex.clone(), AppType::Gemini => settings.current_provider_gemini.clone(), + AppType::GrokBuild => settings.current_provider_grokbuild.clone(), AppType::OpenCode => settings.current_provider_opencode.clone(), AppType::OpenClaw => settings.current_provider_openclaw.clone(), AppType::Hermes => settings.current_provider_hermes.clone(), @@ -950,6 +984,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::ClaudeDesktop => settings.current_provider_claude_desktop = id_owned.clone(), AppType::Codex => settings.current_provider_codex = id_owned.clone(), AppType::Gemini => settings.current_provider_gemini = id_owned.clone(), + AppType::GrokBuild => settings.current_provider_grokbuild = id_owned.clone(), AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(), AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(), AppType::Hermes => settings.current_provider_hermes = id_owned.clone(), diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index ea614838a..741a5ebb6 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -19,8 +19,13 @@ const W_TIER_NAMES: &[&str] = &[ crate::services::subscription::TIER_SEVEN_DAY_OPUS, crate::services::subscription::TIER_SEVEN_DAY_SONNET, ]; -// 火山方舟 Agent/Coding Plan 的月窗口(5h/周/月 三档)。 -const M_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_MONTHLY]; +// 月窗口分组:火山方舟 Agent/Coding Plan 的月窗口(5h/周/月 三档), +// 以及 Codex 免费方案的 30 天窗口(#3651)——两者都归入 "m" 档,避免免费 +// Codex 账号在托盘里空白(前端 footer 能看到、托盘却不显示的不对称)。 +const M_TIER_NAMES: &[&str] = &[ + crate::services::subscription::TIER_MONTHLY, + crate::services::subscription::TIER_THIRTY_DAY, +]; const GEMINI_PRO_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_PRO]; const GEMINI_FLASH_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_FLASH]; const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] = @@ -49,6 +54,43 @@ pub struct TrayTexts { pub lightweight_mode: &'static str, pub quit: &'static str, pub _auto_label: &'static str, + pub projects_label: &'static str, + pub no_project_label: &'static str, +} + +/// 将系统区域标识映射为托盘支持的语言码。 +/// +/// 镜像前端 `i18n/getInitialLanguage` 的判定顺序,确保首次安装 +/// (`settings.language` 尚未写入)时托盘语言与界面语言一致: +/// 繁中系统(zh-TW/HK/MO/Hant)→ `zh-TW`,其余 zh → `zh`, +/// 日文 → `ja`,英文 → `en`,未知区域回退到 `zh`(与前端默认一致)。 +fn map_locale_to_tray_language(locale: &str) -> &'static str { + let locale = locale.to_lowercase(); + if locale == "zh" { + "zh" + } else if locale.starts_with("zh-tw") + || locale.starts_with("zh-hk") + || locale.starts_with("zh-mo") + || locale.starts_with("zh-hant") + { + "zh-TW" + } else if locale.starts_with("zh") { + "zh" + } else if locale.starts_with("ja") { + "ja" + } else if locale.starts_with("en") { + "en" + } else { + "zh" + } +} + +/// 读取系统区域并映射为托盘语言码;取不到区域时回退到 `zh`。 +fn detect_system_tray_language() -> &'static str { + sys_locale::get_locale() + .as_deref() + .map(map_locale_to_tray_language) + .unwrap_or("zh") } impl TrayTexts { @@ -61,6 +103,8 @@ impl TrayTexts { lightweight_mode: "Lightweight Mode", quit: "Quit", _auto_label: "Auto (Failover)", + projects_label: "Projects", + no_project_label: "No project", }, "ja" => Self { show_main: "メインウィンドウを開く", @@ -69,6 +113,8 @@ impl TrayTexts { lightweight_mode: "軽量モード", quit: "終了", _auto_label: "自動 (フェイルオーバー)", + projects_label: "プロジェクト", + no_project_label: "プロジェクトを使用しない", }, "zh-TW" => Self { show_main: "開啟主介面", @@ -77,6 +123,8 @@ impl TrayTexts { lightweight_mode: "輕量模式", quit: "退出", _auto_label: "自動 (故障轉移)", + projects_label: "專案", + no_project_label: "不使用專案", }, _ => Self { show_main: "打开主界面", @@ -85,6 +133,8 @@ impl TrayTexts { lightweight_mode: "轻量模式", quit: "退出", _auto_label: "自动 (故障转移)", + projects_label: "项目", + no_project_label: "不使用项目", }, } } @@ -103,7 +153,7 @@ pub struct TrayAppSection { pub const AUTO_SUFFIX: &str = "auto"; pub const TRAY_ID: &str = "cc-switch"; -pub const TRAY_SECTIONS: [TrayAppSection; 3] = [ +pub const TRAY_SECTIONS: [TrayAppSection; 4] = [ TrayAppSection { app_type: AppType::Claude, prefix: "claude_", @@ -125,6 +175,13 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [ header_label: "Gemini", log_name: "Gemini", }, + TrayAppSection { + app_type: AppType::GrokBuild, + prefix: "grokbuild_", + empty_id: "grokbuild_empty", + header_label: "Grok Build", + log_name: "Grok Build", + }, ]; /// 配色阈值(与前端 `utilizationColor` 语义一致)。 @@ -321,6 +378,95 @@ fn sort_providers( sorted } +/// 处理项目 Profile 托盘事件,返回是否已处理 +/// +/// 事件 id 形如 `profile__`(同一项目在各分组子菜单里各有一项, +/// 应用时只作用于该分组);`profile_none_` 表示某分组"不使用项目" +/// (只清该分组标记,不动配置)。 +pub fn handle_profile_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool { + let Some(suffix) = event_id.strip_prefix("profile_") else { + return false; + }; + + if let Some(scope_str) = suffix.strip_prefix("none_") { + let Ok(scope) = crate::services::profile::ProfileScope::parse(scope_str) else { + log::error!("未知的项目分组托盘事件: {event_id}"); + return true; + }; + if let Some(app_state) = app.try_state::() { + if let Err(e) = app_state.db.set_current_profile_id(scope.as_str(), None) { + log::error!("清除当前项目失败: {e}"); + } + } + // 通知主窗口刷新(profileId=null 表示该分组已清除当前项目) + if let Err(e) = app.emit( + "profile-applied", + serde_json::json!({ "profileId": null, "scope": scope.as_str() }), + ) { + log::error!("发射 profile-applied 事件失败: {e}"); + } + refresh_tray_menu(app); + return true; + } + + // scope 是固定枚举字符串(不含下划线),uuid 只含连字符,首个下划线即分界 + let Some((scope_str, profile_id)) = suffix.split_once('_') else { + log::error!("无法解析项目托盘事件: {event_id}"); + return true; + }; + let Ok(scope) = crate::services::profile::ProfileScope::parse(scope_str) else { + log::error!("未知的项目分组托盘事件: {event_id}"); + return true; + }; + + log::info!("应用项目: {profile_id}({scope_str} 组)"); + let app_handle = app.clone(); + let profile_id = profile_id.to_string(); + tauri::async_runtime::spawn_blocking(move || { + let Some(app_state) = app_handle.try_state::() else { + return; + }; + match crate::services::profile::ProfileService::apply(app_state.inner(), &profile_id, scope) + { + Ok((warnings, should_stop_proxy)) => { + for warning in &warnings { + log::warn!("[Profile] 应用项目 {profile_id} 警告: {warning}"); + } + + if should_stop_proxy { + let app_handle2 = app_handle.clone(); + let proxy_service = app_state.proxy_service.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = proxy_service.stop().await { + log::warn!("托盘切换项目后停止代理服务失败: {e}"); + } + if let Some(state) = app_handle2.try_state::() { + crate::commands::emit_profile_apply_events( + &app_handle2, + state.inner(), + &profile_id, + scope, + ); + } + }); + } else { + crate::commands::emit_profile_apply_events( + &app_handle, + app_state.inner(), + &profile_id, + scope, + ); + } + } + Err(e) => { + log::error!("应用项目 {profile_id} 失败: {e}"); + refresh_tray_menu(&app_handle); + } + } + }); + true +} + /// 处理供应商托盘事件 pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool { for section in TRAY_SECTIONS.iter() { @@ -493,7 +639,13 @@ pub fn create_tray_menu( app_state: &AppState, ) -> Result, AppError> { let app_settings = crate::settings::get_settings(); - let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh")); + // 用户未显式设置语言(首次安装)时,按系统区域回退而非硬编码简体, + // 否则繁中系统的托盘会固定显示简体直到用户手动切换一次。 + let language: &str = match app_settings.language.as_deref() { + Some(lang) => lang, + None => detect_system_tray_language(), + }; + let tray_texts = TrayTexts::from_language(language); // Get visible apps setting, default to all visible let visible_apps = app_settings.visible_apps.unwrap_or_default(); @@ -569,8 +721,12 @@ pub fn create_tray_menu( for (id, provider) in sort_providers(&providers) { let is_current = current_id == *id; - let is_official_blocked = - is_app_taken_over && provider.category.as_deref() == Some("official"); + let is_official_blocked = is_app_taken_over + && provider.category.as_deref() == Some("official") + && !crate::services::provider::official_provider_supports_proxy_takeover( + §ion.app_type, + provider, + ); let label = if is_official_blocked { format!("{} \u{26D4}", &provider.name) // ⛔ emoji } else { @@ -600,6 +756,90 @@ pub fn create_tray_menu( menu_builder = menu_builder.separator(); } + // 项目 Profile 子菜单:项目列表全应用共享,按分组嵌套子菜单各自勾选/应用 + // (组内应用可见且存在项目时才显示该组) + { + use crate::services::profile::ProfileScope; + + let any_scope_visible = ProfileScope::ALL.iter().any(|scope| { + scope + .apps() + .iter() + .any(|app_type| visible_apps.is_visible(app_type)) + }); + let profiles = if any_scope_visible { + app_state.db.get_all_profiles()? + } else { + Vec::new() + }; + + let mut scope_submenus = Vec::new(); + for scope in ProfileScope::ALL { + if profiles.is_empty() + || !scope + .apps() + .iter() + .any(|app_type| visible_apps.is_visible(app_type)) + { + continue; + } + let current_profile_id = app_state + .db + .get_current_profile_id(scope.as_str())? + .unwrap_or_default(); + // 分组标签用产品名,不进 i18n + let scope_label = match scope { + ProfileScope::Claude => "Claude Code", + ProfileScope::ClaudeDesktop => "Claude Desktop", + ProfileScope::Codex => "Codex", + }; + let mut scope_builder = SubmenuBuilder::with_id( + app, + format!("submenu_profiles_{}", scope.as_str()), + scope_label, + ); + for profile in &profiles { + let item = CheckMenuItem::with_id( + app, + format!("profile_{}_{}", scope.as_str(), profile.id), + &profile.name, + true, + current_profile_id == profile.id, + None::<&str>, + ) + .map_err(|e| AppError::Message(format!("创建项目菜单项失败: {e}")))?; + scope_builder = scope_builder.item(&item); + } + let none_item = CheckMenuItem::with_id( + app, + format!("profile_none_{}", scope.as_str()), + tray_texts.no_project_label, + true, + current_profile_id.is_empty(), + None::<&str>, + ) + .map_err(|e| AppError::Message(format!("创建不使用项目菜单项失败: {e}")))?; + let scope_submenu = scope_builder + .separator() + .item(&none_item) + .build() + .map_err(|e| AppError::Message(format!("构建项目分组子菜单失败: {e}")))?; + scope_submenus.push(scope_submenu); + } + + if !scope_submenus.is_empty() { + let mut profiles_builder = + SubmenuBuilder::with_id(app, "submenu_profiles", tray_texts.projects_label); + for scope_submenu in &scope_submenus { + profiles_builder = profiles_builder.item(scope_submenu); + } + let profiles_submenu = profiles_builder + .build() + .map_err(|e| AppError::Message(format!("构建项目子菜单失败: {e}")))?; + menu_builder = menu_builder.item(&profiles_submenu).separator(); + } + } + let lightweight_item = CheckMenuItem::with_id( app, "lightweight_mode", @@ -745,6 +985,9 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { app.exit(0); } _ => { + if handle_profile_tray_event(app, event_id) { + return; + } if handle_provider_tray_event(app, event_id) { return; } @@ -877,12 +1120,13 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) { #[cfg(test)] mod tests { - use super::{format_script_summary, format_subscription_summary, TRAY_ID}; + use super::{format_script_summary, format_subscription_summary, TRAY_ID, TRAY_SECTIONS}; + use crate::app_config::AppType; use crate::provider::{UsageData, UsageResult}; use crate::services::subscription::{ CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH, TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_MONTHLY, TIER_SEVEN_DAY, TIER_SEVEN_DAY_OPUS, - TIER_SEVEN_DAY_SONNET, TIER_WEEKLY_LIMIT, + TIER_SEVEN_DAY_SONNET, TIER_THIRTY_DAY, TIER_WEEKLY_LIMIT, }; #[test] @@ -891,6 +1135,71 @@ mod tests { assert_ne!(TRAY_ID, "main"); } + #[test] + fn locale_maps_traditional_chinese_variants_to_zh_tw() { + use super::map_locale_to_tray_language; + for locale in [ + "zh-TW", + "zh-HK", + "zh-MO", + "zh-Hant", + "zh-Hant-TW", + "zh-hant-hk", + ] { + assert_eq!( + map_locale_to_tray_language(locale), + "zh-TW", + "expected {locale} -> zh-TW" + ); + } + } + + #[test] + fn locale_maps_simplified_chinese_variants_to_zh() { + use super::map_locale_to_tray_language; + for locale in ["zh", "zh-CN", "zh-SG", "zh-Hans", "zh-Hans-CN"] { + assert_eq!( + map_locale_to_tray_language(locale), + "zh", + "expected {locale} -> zh" + ); + } + } + + #[test] + fn locale_maps_japanese_and_english() { + use super::map_locale_to_tray_language; + assert_eq!(map_locale_to_tray_language("ja-JP"), "ja"); + assert_eq!(map_locale_to_tray_language("ja"), "ja"); + assert_eq!(map_locale_to_tray_language("en-US"), "en"); + assert_eq!(map_locale_to_tray_language("en"), "en"); + } + + #[test] + fn locale_unknown_falls_back_to_zh() { + use super::map_locale_to_tray_language; + // 与前端 getInitialLanguage 的默认值保持一致。 + for locale in ["de-DE", "fr", "ko-KR", ""] { + assert_eq!( + map_locale_to_tray_language(locale), + "zh", + "expected {locale} -> zh (default)" + ); + } + } + + #[test] + fn tray_sections_include_grokbuild_provider_switching() { + let section = TRAY_SECTIONS + .iter() + .find(|section| section.app_type == AppType::GrokBuild) + .expect("Grok Build tray section should exist"); + + assert_eq!(section.prefix, "grokbuild_"); + assert_eq!(section.empty_id, "grokbuild_empty"); + assert_eq!(section.header_label, "Grok Build"); + } + fn make_quota(tool: &str, success: bool, tiers: Vec) -> SubscriptionQuota { SubscriptionQuota { tool: tool.to_string(), @@ -964,6 +1273,16 @@ mod tests { assert!(s.contains("l80%"), "expected l80% in {s}"); } + #[test] + fn codex_summary_thirty_day_only_still_renders() { + // Codex 免费方案的唯一 tier 是 30 天窗口。前端 footer 已能显示(TIER_I18N_KEYS + // 有 "30_day"),托盘也必须能显示——否则就是这条不变量要防的非对称:footer + // 能看到、托盘却空白。30_day 归入 "m" 月分组。见 #3651。 + let quota = make_quota("codex", true, vec![tier(TIER_THIRTY_DAY, 85.0)]); + let s = format_subscription_summary("a).expect("should format"); + assert!(s.contains("m85%"), "expected m85% in {s}"); + } + #[test] fn gemini_summary_emoji_reflects_highest_tier_including_lite() { // lite 是利用率最高的那条 → emoji 必须是红色,不能被 pro/flash 掩盖。 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8ba4c9a7a..5f7a87a0d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CC Switch", - "version": "3.16.4", + "version": "3.17.0", "identifier": "com.ccswitch.desktop", "build": { "frontendDist": "../dist", diff --git a/src-tauri/tests/app_type_parse.rs b/src-tauri/tests/app_type_parse.rs index 4abd316cc..e96632cc3 100644 --- a/src-tauri/tests/app_type_parse.rs +++ b/src-tauri/tests/app_type_parse.rs @@ -6,6 +6,14 @@ use cc_switch_lib::AppType; fn parse_known_apps_case_insensitive_and_trim() { assert!(matches!(AppType::from_str("claude"), Ok(AppType::Claude))); assert!(matches!(AppType::from_str("codex"), Ok(AppType::Codex))); + assert!(matches!( + AppType::from_str("grokbuild"), + Ok(AppType::GrokBuild) + )); + assert!(matches!( + AppType::from_str("Grok-Build"), + Ok(AppType::GrokBuild) + )); assert!(matches!( AppType::from_str(" ClAuDe \n"), Ok(AppType::Claude) diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index e1e81d24e..8c1f560d9 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -497,6 +497,42 @@ fn sync_enabled_to_codex_returns_error_on_invalid_toml() { } } +#[test] +fn sync_single_server_to_codex_fails_closed_on_invalid_toml() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let path = cc_switch_lib::get_codex_config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create codex dir"); + } + // 含用户内容 + 语法错误的 config.toml:同步必须报错且不得覆盖文件 + let broken = "model = \"gpt-5.5\"\ninvalid = [\n"; + fs::write(&path, broken).expect("write invalid config"); + + let config = MultiAppConfig::default(); + let err = cc_switch_lib::sync_single_server_to_codex( + &config, + "srv", + &json!({ "type": "stdio", "command": "echo" }), + ) + .expect_err("sync should fail instead of wiping the file"); + match err { + cc_switch_lib::AppError::McpValidation(msg) => { + assert!( + msg.contains("config.toml"), + "error message should mention config.toml" + ); + } + other => panic!("unexpected error: {other:?}"), + } + + let text = fs::read_to_string(&path).expect("read config.toml"); + assert_eq!( + text, broken, + "invalid config.toml must be left untouched on sync failure" + ); +} + #[test] fn sync_codex_provider_missing_auth_returns_error() { let _guard = test_mutex().lock().expect("acquire test mutex"); @@ -712,6 +748,7 @@ command = "echo" claude: false, codex: false, // 初始未启用 gemini: false, + grokbuild: false, opencode: false, hermes: false, }, @@ -841,6 +878,7 @@ fn import_from_claude_merges_into_config() { claude: false, // 初始未启用 codex: false, gemini: false, + grokbuild: false, opencode: false, hermes: false, }, diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index 3bcf29e6a..c533b0f38 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -227,6 +227,7 @@ command = "echo" claude: false, codex: true, gemini: false, + grokbuild: false, opencode: false, hermes: false, }, @@ -287,6 +288,56 @@ fn import_mcp_from_claude_invalid_json_preserves_state() { ); } +/// "从应用导入"是 best-effort:单个应用的坏配置文件不阻断其余应用的 +/// 导入,但失败必须聚合上报——历史实现逐应用 `unwrap_or(0)` 吞错, +/// 坏 config.toml 只会表现为"导入成功 0 个",用户无从得知出了什么问题。 +#[test] +fn import_from_all_apps_reports_broken_app_but_imports_the_rest() { + use support::create_test_state; + + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + // 好的 ~/.claude.json:应正常导入 + let claude_json = json!({ + "mcpServers": { + "alpha": { "type": "stdio", "command": "echo" } + } + }); + fs::write( + get_claude_mcp_path(), + serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"), + ) + .expect("seed ~/.claude.json"); + + // 坏的 ~/.codex/config.toml:解析必然失败 + let codex_dir = home.join(".codex"); + fs::create_dir_all(&codex_dir).expect("create codex dir"); + fs::write(codex_dir.join("config.toml"), "not = = valid toml") + .expect("seed broken codex config"); + + let state = create_test_state().expect("create test state"); + + let err = McpService::import_from_all_apps(&state) + .expect_err("broken codex config must surface, not be swallowed as zero imports"); + let message = err.to_string(); + assert!( + message.contains("codex"), + "aggregated error should name the failing app, got: {message}" + ); + + // Codex 的失败不阻断 Claude:alpha 应已入库并启用 Claude + let servers = state.db.get_all_mcp_servers().expect("get all mcp servers"); + let entry = servers + .get("alpha") + .expect("claude server imported despite codex failure"); + assert!( + entry.apps.claude, + "imported server should have Claude app enabled" + ); +} + #[test] fn set_mcp_enabled_for_codex_writes_live_config() { let _guard = test_mutex().lock().expect("acquire test mutex"); @@ -321,6 +372,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() { claude: false, codex: false, // 初始未启用 gemini: false, + grokbuild: false, opencode: false, hermes: false, }, @@ -386,6 +438,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() { claude: false, codex: false, gemini: false, + grokbuild: false, opencode: false, hermes: false, }, @@ -431,6 +484,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() { claude: true, codex: false, gemini: false, + grokbuild: false, opencode: false, hermes: false, }, @@ -465,6 +519,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() { claude: false, codex: false, gemini: false, + grokbuild: false, opencode: false, hermes: false, }, @@ -598,6 +653,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() { claude: false, codex: false, gemini: false, + grokbuild: false, opencode: false, hermes: false, }, @@ -653,6 +709,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() { claude: false, codex: false, gemini: false, + grokbuild: false, opencode: false, hermes: false, }, @@ -708,6 +765,7 @@ fn explicit_default_claude_dir_keeps_default_split_mcp_path() { claude: true, codex: false, gemini: false, + grokbuild: false, opencode: false, hermes: false, }, @@ -764,6 +822,7 @@ fn custom_claude_dir_writes_mcp_inside_config_dir() { claude: true, codex: false, gemini: false, + grokbuild: false, opencode: false, hermes: false, }, @@ -843,6 +902,7 @@ fn custom_claude_dir_sync_does_not_copy_default_profile() { claude: true, codex: false, gemini: false, + grokbuild: false, opencode: false, hermes: false, }, @@ -982,6 +1042,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() claude: false, codex: false, gemini: false, + grokbuild: false, opencode: false, hermes: false, }, @@ -1004,6 +1065,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() claude: true, codex: false, gemini: false, + grokbuild: false, opencode: false, hermes: false, }, diff --git a/src-tauri/tests/profile_roundtrip.rs b/src-tauri/tests/profile_roundtrip.rs new file mode 100644 index 000000000..1f85f4987 --- /dev/null +++ b/src-tauri/tests/profile_roundtrip.rs @@ -0,0 +1,814 @@ +//! 项目 Profile 快照/应用的端到端集成测试 +//! +//! 全链路 apply 会写 live 配置文件——support.rs 已把 HOME 指向临时目录,安全。 + +use std::fs; + +use serde_json::json; + +use cc_switch_lib::{ + AppType, InstalledSkill, McpServer, McpService, ProfilePayload, ProfileScope, ProfileService, + Prompt, PromptService, Provider, ProviderService, SkillApps, SkillService, +}; + +#[path = "support.rs"] +mod support; +use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex}; + +fn claude_provider(id: &str, token: &str) -> Provider { + Provider::with_id( + id.to_string(), + id.to_uppercase(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": token, + "ANTHROPIC_BASE_URL": "https://api.test" + } + }), + None, + ) +} + +/// Claude Desktop 供应商:无 meta 时默认 Direct 模式,只要求 env 里有 token + base_url +fn desktop_provider(id: &str, token: &str) -> Provider { + Provider::with_id( + id.to_string(), + id.to_uppercase(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": token, + "ANTHROPIC_BASE_URL": "https://desktop.test" + } + }), + None, + ) +} + +fn mcp_server(id: &str, claude_enabled: bool) -> McpServer { + serde_json::from_value(json!({ + "id": id, + "name": id, + "server": { "command": "echo", "args": [] }, + "apps": { "claude": claude_enabled } + })) + .expect("construct mcp server") +} + +fn prompt(id: &str, enabled: bool) -> Prompt { + Prompt { + id: id.to_string(), + name: id.to_uppercase(), + content: format!("# prompt {id}\n"), + description: None, + enabled, + created_at: Some(1_000), + updated_at: Some(1_000), + } +} + +fn installed_skill(id: &str, directory: &str, claude_enabled: bool) -> InstalledSkill { + InstalledSkill { + id: id.to_string(), + name: id.to_string(), + description: None, + directory: directory.to_string(), + repo_owner: None, + repo_name: None, + repo_branch: None, + readme_url: None, + apps: SkillApps { + claude: claude_enabled, + ..Default::default() + }, + installed_at: 1_000, + content_hash: None, + updated_at: 0, + } +} + +fn write_ssot_skill(directory: &str) { + let dir = SkillService::get_ssot_dir() + .expect("resolve skills SSOT dir") + .join(directory); + fs::create_dir_all(&dir).expect("create skill dir"); + fs::write( + dir.join("SKILL.md"), + format!("---\nname: {directory}\ndescription: Test skill\n---\n"), + ) + .expect("write SKILL.md"); +} + +#[test] +fn profile_snapshot_apply_roundtrip_restores_configuration() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let state = create_test_state().expect("create test state"); + + // ---- 种子数据:2 个 Claude 供应商(p1 为当前)+ 2 个 MCP + 1 个 Skill + 2 个 Prompt ---- + state + .db + .save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1")) + .expect("save provider p1"); + state + .db + .save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2")) + .expect("save provider p2"); + state + .db + .set_current_provider(AppType::Claude.as_str(), "p1") + .expect("set current provider p1"); + + // Claude Desktop 只有供应商一个活跃维度(MCP/Skills/Prompt 对它不适用) + state + .db + .save_provider( + AppType::ClaudeDesktop.as_str(), + &desktop_provider("d1", "dk-1"), + ) + .expect("save desktop provider d1"); + state + .db + .save_provider( + AppType::ClaudeDesktop.as_str(), + &desktop_provider("d2", "dk-2"), + ) + .expect("save desktop provider d2"); + state + .db + .set_current_provider(AppType::ClaudeDesktop.as_str(), "d1") + .expect("set current desktop provider d1"); + + // 让 live settings.json 与 p1 一致(switch_normal 回填需要) + let claude_dir = home.join(".claude"); + fs::create_dir_all(&claude_dir).expect("create .claude dir"); + fs::write( + claude_dir.join("settings.json"), + serde_json::to_string_pretty(&claude_provider("p1", "key-1").settings_config) + .expect("serialize p1 settings"), + ) + .expect("seed live settings.json"); + + state + .db + .save_mcp_server(&mcp_server("m1", true)) + .expect("save mcp m1"); + state + .db + .save_mcp_server(&mcp_server("m2", false)) + .expect("save mcp m2"); + + write_ssot_skill("test-skill"); + state + .db + .save_skill(&installed_skill("local:test-skill", "test-skill", true)) + .expect("save skill"); + + state + .db + .save_prompt(AppType::Claude.as_str(), &prompt("pr1", true)) + .expect("save prompt pr1"); + state + .db + .save_prompt(AppType::Claude.as_str(), &prompt("pr2", false)) + .expect("save prompt pr2"); + + // ---- 保存项目 A(在 Claude 页新建:只拍 Claude 当前状态)---- + let profile_a = ProfileService::create(&state, "Project A", ProfileScope::Claude) + .expect("create profile A"); + let payload: ProfilePayload = + serde_json::from_str(&profile_a.payload).expect("parse profile A payload"); + assert_eq!(payload.providers.claude.as_deref(), Some("p1")); + assert_eq!(payload.mcp.claude, Some(vec!["m1".to_string()])); + assert_eq!( + payload.skills.claude, + Some(vec!["local:test-skill".to_string()]) + ); + assert_eq!(payload.prompts.claude.as_deref(), Some("pr1")); + assert_eq!( + payload.providers.codex, None, + "codex side not captured when creating from the claude group" + ); + assert_eq!(payload.mcp.codex, None, "uncaptured side stays None"); + assert_eq!( + payload.providers.claude_desktop, None, + "claude desktop has its own profile scope" + ); + + // ---- 改动全部四类配置(走真实切换路径)---- + ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2"); + // Desktop 现在有自己的项目分组;Claude 分组 apply 不应再影响 Desktop + #[cfg(any(target_os = "macos", windows))] + ProviderService::switch(&state, AppType::ClaudeDesktop, "d2").expect("switch desktop to d2"); + McpService::toggle_app(&state, "m1", AppType::Claude, false).expect("disable m1"); + McpService::toggle_app(&state, "m2", AppType::Claude, true).expect("enable m2"); + SkillService::toggle_app(&state.db, "local:test-skill", &AppType::Claude, false) + .expect("disable skill"); + PromptService::enable_prompt(&state, AppType::Claude, "pr2").expect("enable pr2"); + + // ---- 应用项目 A(Claude 组):只复原 Claude 侧 ---- + let (warnings, _) = ProfileService::apply(&state, &profile_a.id, ProfileScope::Claude) + .expect("apply profile A"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let current = state + .db + .get_current_provider(AppType::Claude.as_str()) + .expect("get current provider"); + assert_eq!(current.as_deref(), Some("p1"), "provider restored to p1"); + + // Claude 分组不再管理 Desktop:apply 后 Desktop 保持切换前的状态不变。 + // macOS/Windows 上上面已切到 d2;Linux(CI)不支持 Desktop 切换、那行被 cfg 门控 + // 编译剔除,Desktop 仍是种子值 d1。两种情况都验证 claude-scope apply 不会动 Desktop。 + let current_desktop = state + .db + .get_current_provider(AppType::ClaudeDesktop.as_str()) + .expect("get current desktop provider"); + #[cfg(any(target_os = "macos", windows))] + let expected_desktop = "d2"; + #[cfg(not(any(target_os = "macos", windows)))] + let expected_desktop = "d1"; + assert_eq!( + current_desktop.as_deref(), + Some(expected_desktop), + "desktop provider untouched by claude-scope apply" + ); + + let servers = state.db.get_all_mcp_servers().expect("get mcp servers"); + assert!(servers.get("m1").expect("m1").apps.claude, "m1 re-enabled"); + assert!(!servers.get("m2").expect("m2").apps.claude, "m2 disabled"); + + let skills = state.db.get_all_installed_skills().expect("get skills"); + assert!( + skills.get("local:test-skill").expect("skill").apps.claude, + "skill re-enabled" + ); + + let prompts = state + .db + .get_prompts(AppType::Claude.as_str()) + .expect("get prompts"); + assert!(prompts.get("pr1").expect("pr1").enabled, "pr1 re-enabled"); + assert!(!prompts.get("pr2").expect("pr2").enabled, "pr2 disabled"); + + let live_prompt = fs::read_to_string(claude_dir.join("CLAUDE.md")).expect("read CLAUDE.md"); + assert_eq!( + live_prompt, + prompt("pr1", true).content, + "live memory file restored" + ); + + assert_eq!( + state + .db + .get_current_profile_id("claude") + .expect("get current profile id") + .as_deref(), + Some(profile_a.id.as_str()), + "profile A marked as current for claude scope" + ); + assert_eq!( + state + .db + .get_current_profile_id("codex") + .expect("get codex current profile id"), + None, + "codex scope marker untouched by claude-group apply" + ); +} + +#[test] +fn shared_profile_sides_are_isolated_and_mergeable() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let state = create_test_state().expect("create test state"); + + // 种子:Claude 侧有当前供应商 + 启用的 MCP + state + .db + .save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1")) + .expect("save provider p1"); + state + .db + .set_current_provider(AppType::Claude.as_str(), "p1") + .expect("set current provider p1"); + let claude_dir = home.join(".claude"); + fs::create_dir_all(&claude_dir).expect("create .claude dir"); + fs::write( + claude_dir.join("settings.json"), + serde_json::to_string_pretty(&claude_provider("p1", "key-1").settings_config) + .expect("serialize p1 settings"), + ) + .expect("seed live settings.json"); + state + .db + .save_mcp_server(&mcp_server("m1", true)) + .expect("save mcp m1"); + + // 在 Codex 页新建项目:快照不应捕获 Claude 侧的任何状态 + let project = ProfileService::create(&state, "Shared Project", ProfileScope::Codex) + .expect("create project from codex tab"); + let payload: ProfilePayload = + serde_json::from_str(&project.payload).expect("parse project payload"); + assert_eq!( + payload.providers.claude, None, + "claude slot not captured by codex-side snapshot" + ); + assert_eq!(payload.mcp.claude, None); + assert_eq!(payload.providers.claude_desktop, None); + assert_eq!(payload.mcp.codex, Some(vec![]), "codex side captured"); + + // 按 Codex 组应用:只动 codex 组的 current 标记,Claude 侧原样不动 + let (warnings, _) = ProfileService::apply(&state, &project.id, ProfileScope::Codex) + .expect("apply project on codex side"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + assert_eq!( + state + .db + .get_current_provider(AppType::Claude.as_str()) + .expect("get claude current provider") + .as_deref(), + Some("p1"), + "claude provider untouched" + ); + let servers = state.db.get_all_mcp_servers().expect("get mcp servers"); + assert!( + servers.get("m1").expect("m1").apps.claude, + "claude MCP untouched" + ); + assert_eq!( + state + .db + .get_current_profile_id("codex") + .expect("get codex current profile id") + .as_deref(), + Some(project.id.as_str()) + ); + assert_eq!( + state + .db + .get_current_profile_id("claude") + .expect("get claude current profile id"), + None, + "claude scope marker untouched by codex-side apply" + ); + + // 同一共享项目在 Claude 页应用:该侧未拍过快照 → 不动配置、标记 current、返回提示 + let (warnings, _) = ProfileService::apply(&state, &project.id, ProfileScope::Claude) + .expect("apply project on claude side"); + assert_eq!(warnings.len(), 1, "uncaptured side yields one hint"); + assert!(warnings[0].contains("no claude configuration captured")); + let servers = state.db.get_all_mcp_servers().expect("get mcp servers"); + assert!( + servers.get("m1").expect("m1").apps.claude, + "claude MCP still untouched by uncaptured apply" + ); + assert_eq!( + state + .db + .get_current_profile_id("claude") + .expect("get claude current profile id") + .as_deref(), + Some(project.id.as_str()), + "claude side now bound to the shared project" + ); + + // 在 Claude 页"以当前状态更新":补拍 claude 侧,codex 侧快照原样保留 + let updated = + ProfileService::update(&state, &project.id, None, true, Some(ProfileScope::Claude)) + .expect("resnapshot claude side"); + let payload: ProfilePayload = + serde_json::from_str(&updated.payload).expect("parse updated payload"); + assert_eq!(payload.providers.claude.as_deref(), Some("p1")); + assert_eq!(payload.mcp.claude, Some(vec!["m1".to_string()])); + assert_eq!( + payload.mcp.codex, + Some(vec![]), + "codex side snapshot preserved by claude-side resnapshot" + ); +} + +#[test] +fn profile_apply_reports_dangling_references_and_continues() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let state = create_test_state().expect("create test state"); + + state + .db + .save_mcp_server(&mcp_server("m1", false)) + .expect("save mcp m1"); + + // 手工构造引用了不存在资源的 payload + let payload = json!({ + "providers": { "claude": "ghost-provider" }, + "mcp": { "claude": ["m1", "ghost-mcp"] }, + "skills": { "claude": ["ghost-skill"] }, + "prompts": { "claude": "ghost-prompt" } + }); + let profile = cc_switch_lib::Profile { + id: "dangling-test".to_string(), + name: "Dangling".to_string(), + payload: payload.to_string(), + sort_order: None, + created_at: Some(1_000), + updated_at: Some(1_000), + }; + state.db.save_profile(&profile).expect("save profile"); + + let (warnings, _) = ProfileService::apply(&state, "dangling-test", ProfileScope::Claude) + .expect("apply succeeds"); + assert_eq!( + warnings.len(), + 4, + "each dangling reference yields one warning: {warnings:?}" + ); + + // 有效条目照常生效:m1 被启用 + let servers = state.db.get_all_mcp_servers().expect("get mcp servers"); + assert!( + servers.get("m1").expect("m1").apps.claude, + "m1 enabled despite warnings" + ); + + // best-effort 完成后仍标记为所属分组的当前项目 + assert_eq!( + state + .db + .get_current_profile_id("claude") + .expect("get current profile id") + .as_deref(), + Some("dangling-test") + ); +} + +#[test] +fn clear_current_profile_only_clears_scoped_marker() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let state = create_test_state().expect("create test state"); + + state + .db + .set_current_profile_id("claude", Some("claude-profile")) + .expect("set claude current profile"); + state + .db + .set_current_profile_id("codex", Some("codex-profile")) + .expect("set codex current profile"); + + // 清除 claude 组不影响 codex 组 + state + .db + .set_current_profile_id("claude", None) + .expect("clear claude current profile"); + assert_eq!( + state + .db + .get_current_profile_id("claude") + .expect("get claude current profile id"), + None + ); + assert_eq!( + state + .db + .get_current_profile_id("codex") + .expect("get codex current profile id") + .as_deref(), + Some("codex-profile") + ); +} + +#[test] +fn switching_profile_autosaves_previous_profile_state() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let state = create_test_state().expect("create test state"); + + // ---- 种子:Claude 侧两套供应商 / MCP / Prompt ---- + state + .db + .save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1")) + .expect("save provider p1"); + state + .db + .save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2")) + .expect("save provider p2"); + state + .db + .set_current_provider(AppType::Claude.as_str(), "p1") + .expect("set current provider p1"); + + let claude_dir = home.join(".claude"); + fs::create_dir_all(&claude_dir).expect("create .claude dir"); + fs::write( + claude_dir.join("settings.json"), + serde_json::to_string_pretty(&claude_provider("p1", "key-1").settings_config) + .expect("serialize p1 settings"), + ) + .expect("seed live settings.json"); + + state + .db + .save_mcp_server(&mcp_server("m1", true)) + .expect("save mcp m1"); + state + .db + .save_mcp_server(&mcp_server("m2", false)) + .expect("save mcp m2"); + + state + .db + .save_prompt(AppType::Claude.as_str(), &prompt("pr1", true)) + .expect("save prompt pr1"); + state + .db + .save_prompt(AppType::Claude.as_str(), &prompt("pr2", false)) + .expect("save prompt pr2"); + + // ---- Project A:状态 X(p1 / m1 / pr1)---- + let project_a = ProfileService::create(&state, "Project A", ProfileScope::Claude) + .expect("create project A"); + let (warnings, _) = ProfileService::apply(&state, &project_a.id, ProfileScope::Claude) + .expect("apply project A"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + // ---- 在 A 下改到状态 Y(p2 / m2 / pr2),然后据此创建 Project B ---- + ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2"); + McpService::toggle_app(&state, "m1", AppType::Claude, false).expect("disable m1"); + McpService::toggle_app(&state, "m2", AppType::Claude, true).expect("enable m2"); + PromptService::enable_prompt(&state, AppType::Claude, "pr2").expect("enable pr2"); + + let project_b = ProfileService::create(&state, "Project B", ProfileScope::Claude) + .expect("create project B"); + + // ---- 从 A 切换到 B:自动把当前状态 Y 保存到 A,再加载 B 的 Y ---- + let (warnings, _) = ProfileService::apply(&state, &project_b.id, ProfileScope::Claude) + .expect("switch to project B"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + assert_eq!( + state + .db + .get_current_provider(AppType::Claude.as_str()) + .expect("get current provider") + .as_deref(), + Some("p2"), + "provider switched to p2" + ); + let servers = state.db.get_all_mcp_servers().expect("get mcp servers"); + assert!(!servers.get("m1").expect("m1").apps.claude, "m1 disabled"); + assert!(servers.get("m2").expect("m2").apps.claude, "m2 enabled"); + let prompts = state + .db + .get_prompts(AppType::Claude.as_str()) + .expect("get prompts"); + assert!(!prompts.get("pr1").expect("pr1").enabled, "pr1 disabled"); + assert!(prompts.get("pr2").expect("pr2").enabled, "pr2 enabled"); + + // Project A 被自动保存为离开时的状态 Y + let saved_a = state + .db + .get_profile(&project_a.id) + .expect("get project A") + .expect("project A exists"); + let payload_a: ProfilePayload = + serde_json::from_str(&saved_a.payload).expect("parse project A payload"); + assert_eq!(payload_a.providers.claude.as_deref(), Some("p2")); + assert_eq!(payload_a.mcp.claude, Some(vec!["m2".to_string()])); + assert_eq!(payload_a.prompts.claude.as_deref(), Some("pr2")); + + // ---- 在 B 下改回状态 X,再切换回 A ---- + ProviderService::switch(&state, AppType::Claude, "p1").expect("switch to p1"); + McpService::toggle_app(&state, "m1", AppType::Claude, true).expect("enable m1"); + McpService::toggle_app(&state, "m2", AppType::Claude, false).expect("disable m2"); + PromptService::enable_prompt(&state, AppType::Claude, "pr1").expect("enable pr1"); + + let (warnings, _) = ProfileService::apply(&state, &project_a.id, ProfileScope::Claude) + .expect("switch back to project A"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + // 切回 A 时:先自动保存 B 为状态 X,再加载 A 的上次离开状态 Y + assert_eq!( + state + .db + .get_current_provider(AppType::Claude.as_str()) + .expect("get current provider") + .as_deref(), + Some("p2"), + "project A restored to the state when we left it (p2)" + ); + let servers = state.db.get_all_mcp_servers().expect("get mcp servers"); + assert!( + !servers.get("m1").expect("m1").apps.claude, + "m1 stays disabled" + ); + assert!( + servers.get("m2").expect("m2").apps.claude, + "m2 stays enabled" + ); + let prompts = state + .db + .get_prompts(AppType::Claude.as_str()) + .expect("get prompts"); + assert!( + !prompts.get("pr1").expect("pr1").enabled, + "pr1 stays disabled" + ); + assert!( + prompts.get("pr2").expect("pr2").enabled, + "pr2 stays enabled" + ); + + // Project B 被自动保存为离开时的状态 X + let saved_b = state + .db + .get_profile(&project_b.id) + .expect("get project B") + .expect("project B exists"); + let payload_b: ProfilePayload = + serde_json::from_str(&saved_b.payload).expect("parse project B payload"); + assert_eq!(payload_b.providers.claude.as_deref(), Some("p1")); + assert_eq!(payload_b.mcp.claude, Some(vec!["m1".to_string()])); + assert_eq!(payload_b.prompts.claude.as_deref(), Some("pr1")); +} + +#[test] +fn profile_switch_auto_disables_takeover_before_apply() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let state = create_test_state().expect("create test state"); + + // 使用临时端口,避免测试机器端口冲突 + futures::executor::block_on(async { + let mut proxy_config = state.db.get_proxy_config().await.expect("get proxy config"); + proxy_config.listen_port = 0; + state + .db + .update_proxy_config(proxy_config) + .await + .expect("set ephemeral proxy port"); + }); + + // ---- 两个 Claude 供应商:custom1 与 custom2 ---- + let mut custom1 = claude_provider("custom1", "custom-key-1"); + custom1.category = Some("custom".to_string()); + state + .db + .save_provider(AppType::Claude.as_str(), &custom1) + .expect("save custom1 provider"); + + let mut custom2 = claude_provider("custom2", "custom-key-2"); + custom2.category = Some("custom".to_string()); + state + .db + .save_provider(AppType::Claude.as_str(), &custom2) + .expect("save custom2 provider"); + + // 初始状态:custom1 + 代理接管 + ProviderService::switch(&state, AppType::Claude, "custom1").expect("switch to custom1"); + let rt = tokio::runtime::Runtime::new().expect("create tokio runtime"); + rt.block_on(state.proxy_service.set_takeover_for_app("claude", true)) + .expect("enable claude takeover"); + + let (proxy_enabled_before, _) = state.db.get_proxy_flags_sync("claude"); + assert!( + proxy_enabled_before, + "takeover should be active before apply" + ); + + // ---- 构造一个目标为 custom2 的项目快照 ---- + let project = ProfileService::create(&state, "Custom2 Project", ProfileScope::Claude) + .expect("create project"); + let mut project = state + .db + .get_profile(&project.id) + .expect("get project") + .expect("project exists"); + let mut payload: ProfilePayload = + serde_json::from_str(&project.payload).expect("parse project payload"); + payload.providers.claude = Some("custom2".to_string()); + project.payload = serde_json::to_string(&payload).expect("serialize payload"); + state + .db + .save_profile(&project) + .expect("save updated project"); + + // ---- 应用项目:应无条件自动关闭接管,再切换到 custom2 ---- + let (warnings, _) = ProfileService::apply(&state, &project.id, ProfileScope::Claude) + .expect("apply custom2 project"); + assert!( + warnings.is_empty(), + "switching project should not warn: {warnings:?}" + ); + + // 接管已关闭 + let (proxy_enabled_after, _) = state.db.get_proxy_flags_sync("claude"); + assert!( + !proxy_enabled_after, + "proxy takeover should be auto-disabled before applying profile" + ); + + // 当前供应商已切到 custom2 + assert_eq!( + state + .db + .get_current_provider(AppType::Claude.as_str()) + .expect("get current provider") + .as_deref(), + Some("custom2"), + "current provider should be custom2" + ); + + // live 配置应指向 custom2 的真实 endpoint,而非代理地址 + let settings_path = home.join(".claude/settings.json"); + let settings: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&settings_path).expect("read settings")) + .expect("parse settings"); + let base_url = settings + .get("env") + .and_then(|e| e.get("ANTHROPIC_BASE_URL")) + .and_then(|v| v.as_str()); + assert_eq!( + base_url, + Some("https://api.test"), + "live config should point to real endpoint after auto-disable" + ); +} + +#[cfg(any(target_os = "macos", windows))] +#[test] +fn claude_desktop_profile_scope_is_independent() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let state = create_test_state().expect("create test state"); + + state + .db + .save_provider( + AppType::ClaudeDesktop.as_str(), + &desktop_provider("d1", "dk-1"), + ) + .expect("save desktop provider d1"); + state + .db + .save_provider( + AppType::ClaudeDesktop.as_str(), + &desktop_provider("d2", "dk-2"), + ) + .expect("save desktop provider d2"); + state + .db + .set_current_provider(AppType::ClaudeDesktop.as_str(), "d1") + .expect("set current desktop provider d1"); + + // 在 Desktop 页新建项目:只拍 Desktop 供应商 + let project = ProfileService::create(&state, "Desktop Project", ProfileScope::ClaudeDesktop) + .expect("create desktop profile"); + let payload: ProfilePayload = + serde_json::from_str(&project.payload).expect("parse desktop payload"); + assert_eq!(payload.providers.claude_desktop.as_deref(), Some("d1")); + assert_eq!(payload.providers.claude, None, "claude slot untouched"); + assert_eq!(payload.providers.codex, None, "codex slot untouched"); + + // 切到 d2 + ProviderService::switch(&state, AppType::ClaudeDesktop, "d2").expect("switch desktop to d2"); + + // 应用 Desktop 项目:恢复 d1 + let (warnings, _) = ProfileService::apply(&state, &project.id, ProfileScope::ClaudeDesktop) + .expect("apply desktop profile"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + assert_eq!( + state + .db + .get_current_provider(AppType::ClaudeDesktop.as_str()) + .expect("get current desktop provider") + .as_deref(), + Some("d1"), + "desktop provider restored by desktop-scope apply" + ); + assert_eq!( + state + .db + .get_current_profile_id(ProfileScope::ClaudeDesktop.as_str()) + .expect("get desktop current profile id") + .as_deref(), + Some(project.id.as_str()), + "desktop scope marker set" + ); +} diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 29eb199c5..baf532419 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -19,6 +19,81 @@ fn settings_path(home: &Path) -> PathBuf { home.join(".cc-switch").join("settings.json") } +fn grokbuild_config(name: &str, endpoint: &str, api_key: &str) -> String { + format!( + r#"[models] +default = "grok-4.5" + +[model."grok-4.5"] +model = "grok-4.5" +base_url = "{endpoint}" +name = "{name}" +api_key = "{api_key}" +api_backend = "responses" +context_window = 500000 +"# + ) +} + +#[test] +fn grokbuild_import_and_switch_write_live_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + let live_path = home.join(".grok").join("config.toml"); + std::fs::create_dir_all(live_path.parent().expect("grok config dir")) + .expect("create grok config dir"); + let imported_config = grokbuild_config("Imported", "https://old.example/v1", "old-key"); + std::fs::write(&live_path, &imported_config).expect("seed Grok Build config"); + + let state = create_test_state().expect("create test state"); + import_default_config_test_hook(&state, AppType::GrokBuild) + .expect("import Grok Build default provider"); + + let imported = state + .db + .get_provider_by_id("default", AppType::GrokBuild.as_str()) + .expect("query imported provider") + .expect("imported provider exists"); + assert_eq!( + imported + .settings_config + .get("config") + .and_then(|value| value.as_str()), + Some(imported_config.as_str()) + ); + + let next_config = grokbuild_config("Relay", "https://new.example/v1", "new-key"); + state + .db + .save_provider( + AppType::GrokBuild.as_str(), + &Provider::with_id( + "relay".to_string(), + "Relay".to_string(), + json!({ "config": next_config }), + None, + ), + ) + .expect("save second Grok Build provider"); + + switch_provider_test_hook(&state, AppType::GrokBuild, "relay") + .expect("switch Grok Build provider"); + + assert_eq!( + std::fs::read_to_string(&live_path).expect("read switched Grok Build config"), + next_config + ); + assert_eq!( + state + .db + .get_current_provider(AppType::GrokBuild.as_str()) + .expect("read Grok Build current provider") + .as_deref(), + Some("relay") + ); +} + #[test] fn codex_startup_import_fresh_install_imports_once_and_syncs_current_setting() { let _guard = test_mutex().lock().expect("acquire test mutex"); @@ -300,6 +375,7 @@ command = "say" claude: false, codex: true, // 启用 Codex gemini: false, + grokbuild: false, opencode: false, hermes: false, }, diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 4111e19ba..bbb11cf02 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -164,6 +164,7 @@ command = "say" claude: false, codex: true, gemini: false, + grokbuild: false, opencode: false, hermes: false, }, @@ -881,6 +882,348 @@ requires_openai_auth = true ); } +#[test] +fn reapply_codex_official_live_resyncs_mcp_servers() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let live_auth = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { "access_token": "official-oauth-token", "account_id": "acct" } + }); + write_codex_live_atomic(&live_auth, Some("")).expect("seed official live auth"); + + let mut initial_config = MultiAppConfig::default(); + { + let manager = initial_config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + let mut official = Provider::with_id( + "official-provider".to_string(), + "Official".to_string(), + json!({ + "auth": { + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { "access_token": "official-oauth-token", "account_id": "acct" } + }, + "config": "" + }), + None, + ); + official.category = Some("official".to_string()); + manager + .providers + .insert("official-provider".to_string(), official); + } + let servers = initial_config + .mcp + .servers + .get_or_insert_with(Default::default); + servers.insert( + "echo-server".into(), + McpServer { + id: "echo-server".into(), + name: "Echo Server".into(), + server: json!({ + "type": "stdio", + "command": "echo" + }), + apps: McpApps { + claude: false, + codex: true, + gemini: false, + grokbuild: false, + opencode: false, + hermes: false, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ); + + let state = create_test_state_with_config(&initial_config).expect("create test state"); + + ProviderService::switch(&state, AppType::Codex, "official-provider") + .expect("switch to official provider"); + let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path()) + .expect("read config.toml after switch"); + assert!( + live.contains("mcp_servers.echo-server"), + "switch should sync enabled MCP servers into live" + ); + + // 统一会话开关变更触发的 reapply 会整体重写 live config.toml(有意设计), + // 写完必须重新投影 DB 里启用的 MCP,否则用户的 MCP 会静默失效。 + let reapplied = + cc_switch_lib::reapply_current_codex_official_live(&state).expect("reapply official live"); + assert!( + reapplied, + "current provider is official, reapply should run" + ); + + let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path()) + .expect("read config.toml after reapply"); + assert!( + live.contains("mcp_servers.echo-server"), + "reapply must re-project enabled MCP servers after the full live rewrite, got: {live}" + ); +} + +/// reapply 走到 MCP 投影时 live 已按新开关状态落盘、开关事实上已生效: +/// ① 投影失败若上抛,save_settings 会回滚开关设置,制造"设置=旧值、 +/// live=新桶"的会话分裂——必须降级为警告而不是失败; +/// ② 投影必须只针对 Codex:sync_all_enabled 按 AppType::all() 顺序短路, +/// Claude 排在 Codex 前面,损坏的 ~/.claude.json 会在轮到 Codex 之前 +/// 报错——若吞错了事,刚被整体重写清掉的 [mcp_servers] 就无人补回, +/// Codex MCP 静默消失。这里用坏 JSON 的 ~/.claude.json 复现该场景。 +#[test] +fn reapply_codex_official_live_projects_mcp_despite_broken_claude_json() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let live_auth = json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { "access_token": "official-oauth-token", "account_id": "acct" } + }); + write_codex_live_atomic(&live_auth, Some("")).expect("seed official live auth"); + + let mut initial_config = MultiAppConfig::default(); + { + let manager = initial_config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + let mut official = Provider::with_id( + "official-provider".to_string(), + "Official".to_string(), + json!({ + "auth": { + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { "access_token": "official-oauth-token", "account_id": "acct" } + }, + "config": "" + }), + None, + ); + official.category = Some("official".to_string()); + manager + .providers + .insert("official-provider".to_string(), official); + } + let servers = initial_config + .mcp + .servers + .get_or_insert_with(Default::default); + servers.insert( + "echo-server".into(), + McpServer { + id: "echo-server".into(), + name: "Echo Server".into(), + server: json!({ + "type": "stdio", + "command": "echo" + }), + apps: McpApps { + claude: false, + codex: true, + gemini: false, + grokbuild: false, + opencode: false, + hermes: false, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ); + + let state = create_test_state_with_config(&initial_config).expect("create test state"); + + // 先切换建立"当前=官方"的前置状态,再破坏 claude 文件, + // 让损坏只作用于被测的 reapply 路径。 + ProviderService::switch(&state, AppType::Codex, "official-provider") + .expect("switch to official provider"); + + // 破坏 ~/.claude.json:坏 JSON 能通过 should_sync_claude_mcp 门控 + // (文件存在即过),但 read_mcp_servers_map 解析必然报错。 + // 注意 codex-only 的服务器也会触发 claude 的 remove 分支读该文件。 + let claude_json = cc_switch_lib::get_claude_mcp_path(); + std::fs::write(&claude_json, "{ not valid json").expect("seed broken claude json"); + + let reapplied = cc_switch_lib::reapply_current_codex_official_live(&state) + .expect("MCP projection failure must degrade to a warning, not fail the toggle"); + assert!( + reapplied, + "current provider is official, reapply should run" + ); + + // 定向投影不碰 claude:Codex 的 MCP 投影必须完成,不能被 + // 无关应用的损坏文件阻断后静默丢失。 + let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path()) + .expect("read config.toml after reapply"); + assert!( + live.contains("mcp_servers.echo-server"), + "codex MCP projection must not be blocked by a broken ~/.claude.json, got: {live}" + ); + + // 顺带锁定:定向投影不应把手改坏的 ~/.claude.json 触碰或"修复"。 + let claude_after = std::fs::read_to_string(&claude_json).expect("read claude json"); + assert_eq!( + claude_after, "{ not valid json", + "codex-only projection must not touch claude's live file" + ); +} + +/// 切换供应商与 reapply 是同一类场景:live 整体重写后只需重投影本应用 +/// 的 MCP。历史实现走全量 sync_all_enabled + `?`,损坏的 ~/.claude.json +/// 会让"切 Codex"直接报切换失败——而此时 DB is_current 与 live 都已 +/// 落盘,切换事实上成功,报错只制造分裂假象。 +#[test] +fn switch_codex_projects_mcp_despite_broken_claude_json() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + write_codex_live_atomic(&json!({ "OPENAI_API_KEY": "sk-old" }), Some("")) + .expect("seed codex live"); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.providers.insert( + "p".to_string(), + Provider::with_id( + "p".to_string(), + "P".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "sk-p" }, + "config": "model = \"gpt-5.5\"\n" + }), + None, + ), + ); + } + let servers = config.mcp.servers.get_or_insert_with(Default::default); + servers.insert( + "echo-server".into(), + McpServer { + id: "echo-server".into(), + name: "Echo Server".into(), + server: json!({ + "type": "stdio", + "command": "echo" + }), + apps: McpApps { + claude: false, + codex: true, + gemini: false, + grokbuild: false, + opencode: false, + hermes: false, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ); + + let state = create_test_state_with_config(&config).expect("create test state"); + + // 坏 JSON 能通过 should_sync_claude_mcp 门控(文件存在即过), + // 但 read_mcp_servers_map 解析必然报错;codex-only 服务器也会 + // 触发 claude 的 remove 分支去读这个文件。 + let claude_json = cc_switch_lib::get_claude_mcp_path(); + std::fs::write(&claude_json, "{ not valid json").expect("seed broken claude json"); + + ProviderService::switch(&state, AppType::Codex, "p") + .expect("broken ~/.claude.json must not fail an unrelated codex switch"); + + let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path()) + .expect("read config.toml after switch"); + assert!( + live.contains("mcp_servers.echo-server"), + "switch must re-project codex MCP after the full live rewrite, got: {live}" + ); + + let claude_after = std::fs::read_to_string(&claude_json).expect("read claude json"); + assert_eq!( + claude_after, "{ not valid json", + "codex-only projection must not touch claude's live file" + ); +} + +/// sync_all_enabled 的全量语义(配置导入 / 云同步恢复):单个应用的 +/// live 损坏不阻断其余应用的投影,但失败必须聚合上报——调用方需要 +/// 知道结果不完整。历史实现按 AppType::all() 顺序 `?` 短路,Claude +/// 排在 Codex 前面,一份坏 ~/.claude.json 会让所有后续应用的 MCP +/// 状态永远陈旧。 +#[test] +fn sync_all_enabled_reports_broken_app_but_projects_the_rest() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + write_codex_live_atomic(&json!({ "OPENAI_API_KEY": "sk" }), Some("")).expect("seed codex live"); + + let mut config = MultiAppConfig::default(); + let servers = config.mcp.servers.get_or_insert_with(Default::default); + servers.insert( + "echo-server".into(), + McpServer { + id: "echo-server".into(), + name: "Echo Server".into(), + server: json!({ + "type": "stdio", + "command": "echo" + }), + apps: McpApps { + claude: false, + codex: true, + gemini: false, + grokbuild: false, + opencode: false, + hermes: false, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ); + + let state = create_test_state_with_config(&config).expect("create test state"); + + let claude_json = cc_switch_lib::get_claude_mcp_path(); + std::fs::write(&claude_json, "{ not valid json").expect("seed broken claude json"); + + let err = cc_switch_lib::McpService::sync_all_enabled(&state) + .expect_err("broken claude live must surface as an aggregated error"); + let message = err.to_string(); + assert!( + message.contains("claude"), + "aggregated error should name the failing app, got: {message}" + ); + + // Claude 的失败不能阻断 Codex:best-effort 必须继续投影其余应用。 + let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path()) + .expect("read config.toml after sync_all_enabled"); + assert!( + live.contains("mcp_servers.echo-server"), + "codex projection must proceed despite the broken claude file, got: {live}" + ); +} + #[test] fn provider_service_switch_codex_official_accounts_write_auth_json() { let _guard = test_mutex().lock().expect("acquire test mutex"); @@ -1911,6 +2254,268 @@ fn switch_claude_syncs_deletions_from_live_into_common_config() { ); } +/// Codex 版切换自动回写:live 里新增的共享键被捕获进通用配置片段并传递给 +/// 下一个供应商;供应商专属字段、密钥与 cc-switch 注入产物绝不进片段; +/// 回填后旧供应商的存储配置不残留片段内容(autosync 先于 strip,值必然匹配)。 +#[test] +fn switch_codex_syncs_shared_keys_from_live_into_common_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + // A 激活状态下的 live:A 专属路由 + 已共享的 [tui] + 用户刚加的 + // disable_response_storage + cc-switch 注入产物 + MCP 同步投影 + // + 顶层 wire_api(无 model_provider 时的 fallback 写法,属 A 的路由语义) + // + 历史错误格式 [mcp.servers](sync_all_enabled 清不掉的孤儿形态) + let live_config = r#"model = "gpt-5.5" +model_provider = "aprov" +wire_api = "chat" +experimental_bearer_token = "sk-a-live-secret" +model_catalog_json = "cc-switch-model-catalog.json" +web_search = "disabled" +disable_response_storage = true + +[tui] +notifications = true + +[model_providers.aprov] +name = "A Prov" +base_url = "https://a.example/v1" +wire_api = "responses" + +[mcp_servers.echo] +type = "stdio" +command = "echo" + +[mcp.servers.ghost-legacy] +command = "ghost-cmd" +"#; + write_codex_live_atomic(&json!({ "OPENAI_API_KEY": "sk-a" }), Some(live_config)) + .expect("seed codex live config"); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "a".to_string(); + let mut provider_a = Provider::with_id( + "a".to_string(), + "A".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "sk-a" }, + "config": "model = \"gpt-5.5\"\nmodel_provider = \"aprov\"\n\n[model_providers.aprov]\nname = \"A Prov\"\nbase_url = \"https://a.example/v1\"\nwire_api = \"responses\"\n" + }), + None, + ); + provider_a.meta = Some(ProviderMeta { + common_config_enabled: Some(true), + ..Default::default() + }); + manager.providers.insert("a".to_string(), provider_a); + let mut provider_b = Provider::with_id( + "b".to_string(), + "B".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "sk-b" }, + "config": "model = \"gpt-5.5\"\nmodel_provider = \"bprov\"\n\n[model_providers.bprov]\nname = \"B Prov\"\nbase_url = \"https://b.example/v1\"\nwire_api = \"responses\"\n" + }), + None, + ); + provider_b.meta = Some(ProviderMeta { + common_config_enabled: Some(true), + ..Default::default() + }); + manager.providers.insert("b".to_string(), provider_b); + } + + let state = create_test_state_with_config(&config).expect("create test state"); + state + .db + .set_config_snippet( + AppType::Codex.as_str(), + Some("[tui]\nnotifications = true\n".to_string()), + ) + .expect("seed codex common config snippet"); + + ProviderService::switch(&state, AppType::Codex, "b").expect("switch should succeed"); + + // 片段:捕获新增共享键、保留既有共享键;专属字段/密钥/注入产物一律不进 + let snippet = state + .db + .get_config_snippet(AppType::Codex.as_str()) + .expect("read snippet") + .expect("snippet present"); + assert!( + snippet.contains("disable_response_storage = true"), + "newly added shared key should be captured, got: {snippet}" + ); + assert!( + snippet.contains("notifications = true"), + "previously shared key should be preserved, got: {snippet}" + ); + for forbidden in [ + "experimental_bearer_token", + "sk-a-live-secret", + "model_catalog_json", + "web_search", + "mcp_servers", + "model_providers", + "model_provider", + "wire_api", + "ghost-legacy", + ] { + assert!( + !snippet.contains(forbidden), + "'{forbidden}' must never enter the shared snippet, got: {snippet}" + ); + } + + // B 的 live:共享键传递到位,A 的密钥/投影不得跟过来 + let live_after = std::fs::read_to_string(cc_switch_lib::get_codex_config_path()) + .expect("read config.toml after switch"); + assert!( + live_after.contains("disable_response_storage = true"), + "shared key should propagate to the next provider's live, got: {live_after}" + ); + assert!( + live_after.contains("model_provider = \"bprov\""), + "live should be provider B's own config, got: {live_after}" + ); + assert!( + !live_after.contains("sk-a-live-secret"), + "provider A's bearer token must not leak into B's live, got: {live_after}" + ); + assert!( + !live_after.contains("mcp_servers"), + "no DB-enabled MCP servers, so live must not resurrect stale entries, got: {live_after}" + ); + assert!( + !live_after.contains("ghost-legacy"), + "the legacy [mcp.servers] orphan must not propagate to B's live, got: {live_after}" + ); + assert!( + !live_after.contains("wire_api = \"chat\""), + "provider A's top-level wire_api must not rewrite B's protocol, got: {live_after}" + ); + + // A 的存储配置:回填后不残留片段内容 / MCP 投影 / 注入产物 + let providers = state + .db + .get_all_providers(AppType::Codex.as_str()) + .expect("read providers after switch"); + let stored_a = providers.get("a").expect("provider a exists"); + let stored_a_config = stored_a + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + assert!( + stored_a_config.contains("model_provider = \"aprov\""), + "provider-owned routing must survive backfill, got: {stored_a_config}" + ); + // 顶层 wire_api 是 A 自己的路由语义:不进片段,但回填时留在 A 的快照里 + assert!( + stored_a_config.contains("wire_api = \"chat\""), + "provider-owned top-level wire_api must survive backfill, got: {stored_a_config}" + ); + for forbidden in [ + "disable_response_storage", + "notifications", + "mcp_servers", + "experimental_bearer_token", + "ghost-legacy", + ] { + assert!( + !stored_a_config.contains(forbidden), + "'{forbidden}' must be stripped from the stored provider config on backfill, got: {stored_a_config}" + ); + } +} + +/// Codex 版删除同步:用户在 live 里删掉一个已共享的键后,切换应把删除 +/// 同步进通用配置,且不会在切到下一个供应商时被重新注入(否则"删不掉")。 +#[test] +fn switch_codex_syncs_deletions_from_live_into_common_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + // 片段里有两个共享键,但用户已在 live 里删掉 disable_response_storage + let live_config = r#"model_provider = "aprov" + +[tui] +notifications = true + +[model_providers.aprov] +name = "A Prov" +base_url = "https://a.example/v1" +wire_api = "responses" +"#; + write_codex_live_atomic(&json!({ "OPENAI_API_KEY": "sk-a" }), Some(live_config)) + .expect("seed codex live config"); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "a".to_string(); + for (id, name, prov_key) in [("a", "A", "aprov"), ("b", "B", "bprov")] { + let mut provider = Provider::with_id( + id.to_string(), + name.to_string(), + json!({ + "auth": { "OPENAI_API_KEY": format!("sk-{id}") }, + "config": format!("model_provider = \"{prov_key}\"\n\n[model_providers.{prov_key}]\nname = \"{name} Prov\"\nbase_url = \"https://{id}.example/v1\"\nwire_api = \"responses\"\n") + }), + None, + ); + provider.meta = Some(ProviderMeta { + common_config_enabled: Some(true), + ..Default::default() + }); + manager.providers.insert(id.to_string(), provider); + } + } + + let state = create_test_state_with_config(&config).expect("create test state"); + state + .db + .set_config_snippet( + AppType::Codex.as_str(), + Some("disable_response_storage = true\n\n[tui]\nnotifications = true\n".to_string()), + ) + .expect("seed codex common config snippet"); + + ProviderService::switch(&state, AppType::Codex, "b").expect("switch should succeed"); + + let snippet = state + .db + .get_config_snippet(AppType::Codex.as_str()) + .expect("read snippet") + .expect("snippet present"); + assert!( + !snippet.contains("disable_response_storage"), + "deleted shared key must be removed from the snippet, got: {snippet}" + ); + assert!( + snippet.contains("notifications = true"), + "kept shared key should remain in the snippet, got: {snippet}" + ); + + let live_after = std::fs::read_to_string(cc_switch_lib::get_codex_config_path()) + .expect("read config.toml after switch"); + assert!( + !live_after.contains("disable_response_storage"), + "deleted shared key must not be re-injected into the next provider, got: {live_after}" + ); + assert!( + live_after.contains("notifications = true"), + "kept shared key should propagate to the next provider, got: {live_after}" + ); +} + /// 未勾选"写入通用配置"的供应商,其 live 改动不应自动污染通用配置片段。 #[test] fn switch_claude_does_not_sync_common_config_for_opted_out_provider() { diff --git a/src-tauri/tests/support.rs b/src-tauri/tests/support.rs index 35ff1ee3a..d9b2f0259 100644 --- a/src-tauri/tests/support.rs +++ b/src-tauri/tests/support.rs @@ -31,6 +31,7 @@ pub fn reset_test_fs() { ".codex", ".cc-switch", ".gemini", + ".grok", ".config", ".openclaw", "profiles", diff --git a/src/App.tsx b/src/App.tsx index 0eaef98ad..1ac823033 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,6 +58,7 @@ import { DRAG_REGION_STYLE, } from "@/lib/platform"; import { AppSwitcher } from "@/components/AppSwitcher"; +import { ProfileSwitcher } from "@/components/profiles/ProfileSwitcher"; import { ProviderList } from "@/components/providers/ProviderList"; import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; import { EditProviderDialog } from "@/components/providers/EditProviderDialog"; @@ -126,6 +127,7 @@ const VALID_APPS: AppId[] = [ "claude-desktop", "codex", "gemini", + "grokbuild", "opencode", "openclaw", "hermes", @@ -193,6 +195,7 @@ function App() { "claude-desktop": true, codex: true, gemini: true, + grokbuild: true, opencode: true, openclaw: true, hermes: true, @@ -203,6 +206,7 @@ function App() { if (visibleApps["claude-desktop"]) return "claude-desktop"; if (visibleApps.codex) return "codex"; if (visibleApps.gemini) return "gemini"; + if (visibleApps.grokbuild) return "grokbuild"; if (visibleApps.opencode) return "opencode"; if (visibleApps.openclaw) return "openclaw"; if (visibleApps.hermes) return "hermes"; @@ -221,6 +225,7 @@ function App() { currentView === "sessions" && sharedFeatureApp !== "claude" && sharedFeatureApp !== "codex" && + sharedFeatureApp !== "grokbuild" && sharedFeatureApp !== "opencode" && sharedFeatureApp !== "openclaw" && sharedFeatureApp !== "gemini" && @@ -290,6 +295,7 @@ function App() { const hasSessionSupport = sharedFeatureApp === "claude" || sharedFeatureApp === "codex" || + sharedFeatureApp === "grokbuild" || sharedFeatureApp === "opencode" || sharedFeatureApp === "openclaw" || sharedFeatureApp === "gemini" || @@ -381,6 +387,19 @@ function App() { } }); + // 应用项目后刷新相关缓存(providers 由既有 provider-switched 监听承接; + // proxy 状态由后端直接改 DB,不走 mutation,必须显式刷新) + useTauriEvent("profile-applied", async () => { + await queryClient.invalidateQueries({ queryKey: ["profiles"] }); + await queryClient.invalidateQueries({ queryKey: ["mcp", "all"] }); + await queryClient.invalidateQueries({ queryKey: ["skills"] }); + await queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] }); + await queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + await queryClient.invalidateQueries({ + queryKey: ["providers", "claude-desktop"], + }); + }); + useTauriEvent( "webdav-sync-status-updated", async (payload) => { @@ -1249,6 +1268,15 @@ function App() { )} )} + {currentView === "providers" && + (settingsData?.showProfileSwitcher ?? true) && ( +
+ +
+ )}
{ diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index 7d46bc376..e5ce8ecaf 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -27,6 +27,8 @@ export const TIER_I18N_KEYS: Record = { seven_day: "subscription.sevenDay", seven_day_opus: "subscription.sevenDayOpus", seven_day_sonnet: "subscription.sevenDaySonnet", + // Codex 免费方案的次要窗口是 30 天(付费方案为 7 天) + "30_day": "subscription.thirtyDay", // Gemini 模型分类 gemini_pro: "subscription.geminiPro", gemini_flash: "subscription.geminiFlash", diff --git a/src/components/UsageFooter.tsx b/src/components/UsageFooter.tsx index 397ab784c..293f5de33 100644 --- a/src/components/UsageFooter.tsx +++ b/src/components/UsageFooter.tsx @@ -65,6 +65,7 @@ const UsageFooter: React.FC = ({ const { data: usage, isFetching: loading, + isError, lastQueriedAt, refetch, } = useUsageQuery(providerId, appId, { @@ -86,11 +87,13 @@ const UsageFooter: React.FC = ({ return () => clearInterval(interval); }, [lastQueriedAt]); - // 只在启用用量查询且有数据时显示 - if (!usageEnabled || !usage) return null; + // 只在启用用量查询且有数据时显示。后端把瞬时传输失败转成了 reject:有缓存 + // 成功值时 react-query 保留 data 照常展示;首次查询就失败则 data 为空—— + // 此时(isError)仍要渲染失败态给出重试入口,否则 footer 整体消失、无从重查。 + if (!usageEnabled || (!usage && !isError)) return null; - // 错误状态 - if (!usage.success) { + // 错误状态(业务失败,或无缓存成功值的 reject) + if (!usage || !usage.success) { if (inline) { return (
@@ -115,7 +118,7 @@ const UsageFooter: React.FC = ({
- {usage.error || t("usage.queryFailed")} + {usage?.error || t("usage.queryFailed")}
{/* 刷新按钮 */} diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index e05521cff..8430a3e87 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -8,11 +8,13 @@ import { usageApi, settingsApi, type AppId } from "@/lib/api"; import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot"; import { useSettingsQuery } from "@/lib/query"; import { resolveManagedAccountId } from "@/lib/authBinding"; +import { extractErrorMessage } from "@/utils/errorUtils"; import { useDarkMode } from "@/hooks/useDarkMode"; import { extractCodexBaseUrl, extractCodexExperimentalBearerToken, } from "@/utils/providerConfigUtils"; +import { parseGrokBuildConfig } from "@/utils/grokBuildConfig"; import JsonEditor from "./JsonEditor"; import * as prettier from "prettier/standalone"; import * as parserBabel from "prettier/parser-babel"; @@ -38,6 +40,8 @@ import { formatUsageDataSummary } from "@/utils/usageDisplay"; */ const VOLCENGINE_KEY_CONSOLE_URL = "https://console.volcengine.com/iam/keymanage"; +// 智谱团队套餐用量页(组织 ID / 项目 ID 在此页 URL 或管理后台可见) +const ZHIPU_TEAM_USAGE_URL = "https://bigmodel.cn/coding-plan/team/usage-stats"; interface UsageScriptModalProps { provider: Provider; @@ -259,6 +263,15 @@ const UsageScriptModal: React.FC = ({ apiKey: env.GEMINI_API_KEY || env.GOOGLE_API_KEY, baseUrl: env.GOOGLE_GEMINI_BASE_URL, }; + } else if (appId === "grokbuild") { + const grokConfig = parseGrokBuildConfig( + (config as any).config, + provider.name, + ); + return { + apiKey: grokConfig.apiKey, + baseUrl: grokConfig.baseUrl, + }; } else if (appId === "hermes") { // Hermes: settingsConfig 顶层扁平(snake_case,对应 config.yaml) return { @@ -552,6 +565,7 @@ const UsageScriptModal: React.FC = ({ // 另用账号 AK/SK 签名查询控制面用量。 const isZenMux = script.codingPlanProvider === "zenmux"; const isVolcengine = script.codingPlanProvider === "volcengine"; + const isZhipuTeam = script.codingPlanProvider === "zhipu_team"; const baseUrl = isZenMux ? (script.baseUrl ?? "") : (providerCredentials.baseUrl ?? ""); @@ -564,6 +578,9 @@ const UsageScriptModal: React.FC = ({ apiKey, isVolcengine ? script.accessKeyId : undefined, isVolcengine ? script.secretAccessKey : undefined, + isZhipuTeam ? script.codingPlanProvider : undefined, + isZhipuTeam ? script.teamOrganizationId : undefined, + isZhipuTeam ? script.teamProjectId : undefined, ); if (quota.success && quota.tiers.length > 0) { const summary = quota.tiers @@ -663,8 +680,10 @@ const UsageScriptModal: React.FC = ({ ); } } catch (error: any) { + // 后端命令 Err(String) 时 invoke 以裸字符串 reject(如瞬时网络失败), + // 直接读 .message 会得到 undefined。 toast.error( - `${t("usageScript.testFailed")}: ${error?.message || t("common.unknown")}`, + `${t("usageScript.testFailed")}: ${extractErrorMessage(error) || t("common.unknown")}`, { duration: 5000, }, @@ -743,9 +762,10 @@ const UsageScriptModal: React.FC = ({ providerCredentials.baseUrl, ); const provider = script.codingPlanProvider || autoDetected || "kimi"; - // ZenMux 保留手填 baseUrl/apiKey;火山保留账号 AK/SK;其余清除。 + // ZenMux 保留手填 baseUrl/apiKey;火山保留账号 AK/SK;智谱团队保留组织/项目 ID;其余清除。 const isZenMux = provider === "zenmux"; const isVolcengine = provider === "volcengine"; + const isZhipuTeam = provider === "zhipu_team"; setScript({ ...script, code: "", @@ -755,6 +775,10 @@ const UsageScriptModal: React.FC = ({ userId: undefined, accessKeyId: isVolcengine ? script.accessKeyId : undefined, secretAccessKey: isVolcengine ? script.secretAccessKey : undefined, + teamOrganizationId: isZhipuTeam + ? script.teamOrganizationId + : undefined, + teamProjectId: isZhipuTeam ? script.teamProjectId : undefined, codingPlanProvider: provider, }); } else if (presetName === TEMPLATE_TYPES.BALANCE) { @@ -1356,6 +1380,76 @@ const UsageScriptModal: React.FC = ({
)} + {/* 智谱团队套餐:需组织 ID + 项目 ID(api_key 沿用供应商推理凭据) */} + {selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && + script.codingPlanProvider === "zhipu_team" && ( +
+
+

+ {t("usageScript.credentialsConfig")} +

+

+ {t("usageScript.zhipuTeamHint")} +

+

+ {t("usageScript.zhipuTeamConsoleLink")}{" "} + +

+
+ +
+
+ + + setScript({ + ...script, + teamOrganizationId: e.target.value, + }) + } + placeholder={t("usageScript.organizationIdPlaceholder")} + autoComplete="off" + className="border-white/10" + /> +
+ +
+ + + setScript({ + ...script, + teamProjectId: e.target.value, + }) + } + placeholder={t("usageScript.projectIdPlaceholder")} + autoComplete="off" + className="border-white/10" + /> +
+
+
+ )} + {/* 通用配置(始终显示) */}
{/* 超时时间 */} diff --git a/src/components/common/FullScreenPanel.tsx b/src/components/common/FullScreenPanel.tsx index 88101d8a3..96bf95fd5 100644 --- a/src/components/common/FullScreenPanel.tsx +++ b/src/components/common/FullScreenPanel.tsx @@ -28,6 +28,25 @@ interface FullScreenPanelProps { const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px - match App.tsx const HEADER_HEIGHT = 64; // px - match App.tsx +let bodyScrollLockCount = 0; +let bodyOverflowBeforeFirstLock: string | null = null; + +const lockBodyScroll = () => { + if (bodyScrollLockCount === 0) { + bodyOverflowBeforeFirstLock = document.body.style.overflow; + document.body.style.overflow = "hidden"; + } + bodyScrollLockCount += 1; +}; + +const unlockBodyScroll = () => { + bodyScrollLockCount = Math.max(0, bodyScrollLockCount - 1); + if (bodyScrollLockCount === 0) { + document.body.style.overflow = bodyOverflowBeforeFirstLock ?? ""; + bodyOverflowBeforeFirstLock = null; + } +}; + /** * Reusable full-screen panel component * Handles portal rendering, header with back button, and footer @@ -42,12 +61,10 @@ export const FullScreenPanel: React.FC = ({ contentClassName, }) => { React.useEffect(() => { - if (isOpen) { - document.body.style.overflow = "hidden"; - } - return () => { - document.body.style.overflow = ""; - }; + if (!isOpen) return; + + lockBodyScroll(); + return unlockBodyScroll; }, [isOpen]); // ESC 键关闭面板 diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 0fd784b05..778de7d59 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -42,7 +42,7 @@ const McpFormModal: React.FC = ({ onClose, existingIds = [], defaultFormat = "json", - defaultEnabledApps = ["claude", "codex", "gemini"], + defaultEnabledApps = ["claude", "codex", "gemini", "grokbuild"], }) => { const { t } = useTranslation(); const { formatTomlError, validateTomlConfig, validateJsonConfig } = @@ -65,17 +65,22 @@ const McpFormModal: React.FC = ({ claude: boolean; codex: boolean; gemini: boolean; + grokbuild: boolean; opencode: boolean; openclaw: boolean; hermes: boolean; }>(() => { if (initialData?.apps) { - return { ...initialData.apps }; + return { + ...initialData.apps, + grokbuild: initialData.apps.grokbuild ?? false, + }; } return { claude: defaultEnabledApps.includes("claude"), codex: defaultEnabledApps.includes("codex"), gemini: defaultEnabledApps.includes("gemini"), + grokbuild: defaultEnabledApps.includes("grokbuild"), opencode: defaultEnabledApps.includes("opencode"), openclaw: defaultEnabledApps.includes("openclaw"), hermes: defaultEnabledApps.includes("hermes"), @@ -566,6 +571,22 @@ const McpFormModal: React.FC = ({
+
+ + setEnabledApps({ ...enabledApps, grokbuild: checked }) + } + /> + +
+
void; +} + +type PendingConfirm = { + id: string; + name: string; +}; + +/** + * 项目管理对话框(纯快照式) + * + * 项目列表全应用共享,重命名/删除作用于共享实体。 + * 快照内容由切换时的自动保存维护,不提供手动重拍入口。 + */ +export function ProfileManageDialog({ + isOpen, + onClose, +}: ProfileManageDialogProps) { + const { t } = useTranslation(); + const { data } = useProfilesQuery(); + const updateMutation = useUpdateProfileMutation(); + const deleteMutation = useDeleteProfileMutation(); + + const [editingId, setEditingId] = useState(null); + const [editingName, setEditingName] = useState(""); + const [confirm, setConfirm] = useState(null); + + const profiles = data?.profiles ?? []; + + const startRename = (id: string, name: string) => { + setEditingId(id); + setEditingName(name); + }; + + const cancelRename = () => { + setEditingId(null); + setEditingName(""); + }; + + const saveRename = () => { + const name = editingName.trim(); + if (!name || !editingId) return; + updateMutation.mutate({ id: editingId, name }, { onSuccess: cancelRename }); + }; + + const handleConfirm = () => { + if (!confirm) return; + deleteMutation.mutate(confirm.id); + setConfirm(null); + }; + + return ( + <> + { + if (!open) { + cancelRename(); + onClose(); + } + }} + > + + + {t("profiles.manageTitle")} + + {t("profiles.manageDescription")} + + +
+ {profiles.length === 0 && ( +
+ {t("profiles.empty")} +
+ )} + {profiles.map((profile) => ( +
+ {editingId === profile.id ? ( + <> + setEditingName(e.target.value)} + className="h-7 flex-1" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") saveRename(); + if (e.key === "Escape") cancelRename(); + }} + /> + + + + ) : ( + <> + + {profile.name} + + + + + )} +
+ ))} +
+ + + +
+
+ + {confirm && ( + setConfirm(null)} + /> + )} + + ); +} diff --git a/src/components/profiles/ProfileSwitcher.tsx b/src/components/profiles/ProfileSwitcher.tsx new file mode 100644 index 000000000..8273f76c3 --- /dev/null +++ b/src/components/profiles/ProfileSwitcher.tsx @@ -0,0 +1,253 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Check, + ChevronsUpDown, + FolderCog, + FolderOpen, + Plus, + X, +} from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import type { AppId } from "@/lib/api/types"; +import { + useApplyProfileMutation, + useClearProfileMutation, + useCreateProfileMutation, + useProfilesQuery, +} from "@/lib/query/profiles"; +import { ProfileManageDialog } from "./ProfileManageDialog"; +import { APP_PROFILE_SCOPE, hasScopeSnapshot } from "./scope"; +import type { CurrentProfileIds, ProfileScope } from "@/lib/api/profiles"; + +const CURRENT_ID_KEY: Record = { + claude: "claude", + "claude-desktop": "claudeDesktop", + codex: "codex", +}; + +interface ProfileSwitcherProps { + activeApp: AppId; +} + +/** + * 项目 Profile 切换器(header 左侧入口) + * + * 项目列表全应用共享(用户拥有的项目就那几个),但切换按分组进行: + * Claude 组(Claude Code 的供应商/MCP/Skills/记忆文件 + Claude Desktop + * 的供应商)与 Codex 组各自指向自己的当前项目、只应用组内快照。 + * 与右侧 AppSwitcher(仅切换查看的应用)语义不同。 + */ +export function ProfileSwitcher({ activeApp }: ProfileSwitcherProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [isManageOpen, setIsManageOpen] = useState(false); + const [newName, setNewName] = useState(""); + + const { data } = useProfilesQuery(); + const applyMutation = useApplyProfileMutation(); + const clearMutation = useClearProfileMutation(); + const createMutation = useCreateProfileMutation(); + + // Profile 仅作用于受支持的应用——在其他应用的标签页展示会误导用户 + // 以为当前应用也被切换了,因此只在有所属分组的应用页面渲染 + const scope = APP_PROFILE_SCOPE[activeApp]; + if (!scope) { + return null; + } + + const profiles = data?.profiles ?? []; + const currentId = data?.currentIds?.[CURRENT_ID_KEY[scope]] ?? null; + const currentProfile = profiles.find((p) => p.id === currentId); + + const handleApply = (id: string) => { + setOpen(false); + if (id !== currentId) { + applyMutation.mutate({ id, scope }); + } + }; + + const closeCreateDialog = () => { + setIsCreateOpen(false); + setNewName(""); + }; + + const handleCreate = () => { + const name = newName.trim(); + if (!name) return; + createMutation.mutate({ name, scope }, { onSuccess: closeCreateDialog }); + }; + + return ( + <> + + + + + + + + + {t("profiles.empty")} + {profiles.length > 0 && ( + + {profiles.map((profile) => ( + handleApply(profile.id)} + > + + {profile.name} + {!hasScopeSnapshot(profile, scope) && ( + + {t("profiles.noSnapshotForScope")} + + )} + + ))} + + )} +
+ + { + setOpen(false); + setIsCreateOpen(true); + }} + > + + {t("profiles.createFromCurrent")} + + {currentId && ( + { + setOpen(false); + clearMutation.mutate(scope); + }} + > + + {t("profiles.none")} + + )} + {profiles.length > 0 && ( + { + setOpen(false); + setIsManageOpen(true); + }} + > + + {t("profiles.manage")} + + )} + + + + + + + { + if (!open) closeCreateDialog(); + }} + > + + + {t("profiles.createFromCurrent")} + + {t(`profiles.createDescription.${scope}`)} + + +
+ setNewName(e.target.value)} + placeholder={t("profiles.namePlaceholder")} + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + /> +
+ + + + +
+
+ + setIsManageOpen(false)} + /> + + ); +} diff --git a/src/components/profiles/scope.ts b/src/components/profiles/scope.ts new file mode 100644 index 000000000..623983bba --- /dev/null +++ b/src/components/profiles/scope.ts @@ -0,0 +1,36 @@ +import type { AppId } from "@/lib/api/types"; +import type { PerApp, Profile, ProfileScope } from "@/lib/api/profiles"; + +/** + * 应用页 → 所属项目分组(后端 ProfileScope::for_app 的前端镜像,两处同步) + * + * 不在映射里的应用不支持 Profile,其标签页不渲染切换器。 + */ +export const APP_PROFILE_SCOPE: Partial> = { + claude: "claude", + "claude-desktop": "claude-desktop", + codex: "codex", +}; + +/** 分组内的 payload 槽位 key(后端 ProfileScope::apps 的前端镜像) */ +const SCOPE_SLOT_KEYS: Record)[]> = { + claude: ["claude"], + "claude-desktop": ["claude-desktop"], + codex: ["codex"], +}; + +/** + * 项目在某分组是否拍过快照(任一槽位非 null 即视为拍过) + * + * 未拍过的项目在该分组应用时不改动配置,只绑定 current 标记。 + */ +export function hasScopeSnapshot(profile: Profile, scope: ProfileScope) { + const { providers, mcp, skills, prompts } = profile.payload; + return SCOPE_SLOT_KEYS[scope].some( + (app) => + providers[app] !== null || + mcp[app] !== null || + skills[app] !== null || + prompts[app] !== null, + ); +} diff --git a/src/components/prompts/PromptFormModal.tsx b/src/components/prompts/PromptFormModal.tsx index 391670cae..dd09fdb30 100644 --- a/src/components/prompts/PromptFormModal.tsx +++ b/src/components/prompts/PromptFormModal.tsx @@ -35,6 +35,7 @@ const PromptFormModal: React.FC = ({ "claude-desktop": "CLAUDE.md", codex: "AGENTS.md", gemini: "GEMINI.md", + grokbuild: "AGENTS.md", opencode: "AGENTS.md", hermes: "AGENTS.md", }; diff --git a/src/components/prompts/PromptFormPanel.tsx b/src/components/prompts/PromptFormPanel.tsx index 1486dff06..b9bc9db09 100644 --- a/src/components/prompts/PromptFormPanel.tsx +++ b/src/components/prompts/PromptFormPanel.tsx @@ -29,6 +29,7 @@ const PromptFormPanel: React.FC = ({ "claude-desktop": "CLAUDE.md", codex: "AGENTS.md", gemini: "GEMINI.md", + grokbuild: "AGENTS.md", opencode: "AGENTS.md", openclaw: "AGENTS.md", hermes: "AGENTS.md", diff --git a/src/components/prompts/PromptPanel.tsx b/src/components/prompts/PromptPanel.tsx index 77e4edb03..7f28d3490 100644 --- a/src/components/prompts/PromptPanel.tsx +++ b/src/components/prompts/PromptPanel.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { FileText } from "lucide-react"; import { type AppId } from "@/lib/api"; import { usePromptActions } from "@/hooks/usePromptActions"; +import { useTauriEvent } from "@/hooks/useTauriEvent"; import PromptListItem from "./PromptListItem"; import PromptFormPanel from "./PromptFormPanel"; import { ConfirmDialog } from "../ConfirmDialog"; @@ -59,6 +60,9 @@ const PromptPanel = React.forwardRef( }; }, [appId, reload]); + // 应用项目 Profile 会切换激活的 prompt(prompts 非 react-query,需主动 reload) + useTauriEvent("profile-applied", reload); + const handleAdd = () => { setEditingId(null); setIsFormOpen(true); diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index d7460f2fb..1800ac1f3 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -20,6 +20,7 @@ import { codexProviderPresets } from "@/config/codexProviderPresets"; import { geminiProviderPresets } from "@/config/geminiProviderPresets"; import { claudeDesktopProviderPresets } from "@/config/claudeDesktopProviderPresets"; import { extractCodexBaseUrl } from "@/utils/providerConfigUtils"; +import { extractGrokBuildBaseUrl } from "@/utils/grokBuildConfig"; import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets"; import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; import type { ManagedAuthProvider } from "@/lib/api"; @@ -33,6 +34,7 @@ interface AddProviderDialogProps { providerKey?: string; suggestedDefaults?: OpenClawSuggestedDefaults; ensureClaudeDesktopOfficialSeed?: boolean; + ensureCodexOfficialSeed?: boolean; }, ) => Promise | void; } @@ -49,6 +51,7 @@ export function AddProviderDialog({ appId !== "opencode" && appId !== "openclaw" && appId !== "hermes" && + appId !== "grokbuild" && appId !== "claude-desktop"; const [activeTab, setActiveTab] = useState<"app-specific" | "universal">( "app-specific", @@ -77,14 +80,6 @@ export function AddProviderDialog({ async (provider: UniversalProvider) => { try { await universalProvidersApi.upsert(provider); - toast.success( - t("universalProvider.addSuccess", { - defaultValue: "统一供应商添加成功", - }), - ); - setUniversalFormOpen(false); - setSelectedUniversalPreset(null); - onOpenChange(false); } catch (error) { console.error( "[AddProviderDialog] Failed to save universal provider", @@ -95,7 +90,31 @@ export function AddProviderDialog({ defaultValue: "统一供应商添加失败", }), ); + return; } + + try { + await universalProvidersApi.sync(provider.id); + toast.success( + t("universalProvider.addedAndSynced", { + defaultValue: "统一供应商已添加并同步", + }), + ); + } catch (error) { + console.error( + "[AddProviderDialog] Provider saved but sync failed", + error, + ); + toast.warning( + t("universalProvider.addedButSyncFailed", { + defaultValue: "统一供应商已添加,但同步失败", + }), + ); + } + + setUniversalFormOpen(false); + setSelectedUniversalPreset(null); + onOpenChange(false); }, [t, onOpenChange], ); @@ -117,6 +136,7 @@ export function AddProviderDialog({ providerKey?: string; suggestedDefaults?: OpenClawSuggestedDefaults; ensureClaudeDesktopOfficialSeed?: boolean; + ensureCodexOfficialSeed?: boolean; } = { name: values.name.trim(), notes: values.notes?.trim() || undefined, @@ -138,6 +158,14 @@ export function AddProviderDialog({ preset?.category === "official"; } + if (appId === "codex" && values.presetId) { + const presetIndex = parseInt(values.presetId.replace("codex-", "")); + const preset = codexProviderPresets[presetIndex]; + providerData.ensureCodexOfficialSeed = + values.presetCategory === "official" && + preset?.category === "official"; + } + // OpenCode/OpenClaw: pass providerKey for ID generation if ( (appId === "opencode" || appId === "openclaw" || appId === "hermes") && @@ -246,6 +274,11 @@ export function AddProviderDialog({ if (env?.GOOGLE_GEMINI_BASE_URL) { addUrl(env.GOOGLE_GEMINI_BASE_URL); } + } else if (appId === "grokbuild") { + const config = parsedConfig.config as string | undefined; + if (config) { + addUrl(extractGrokBuildBaseUrl(config)); + } } else if (appId === "opencode") { const options = parsedConfig.options as | Record diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index 0934963d5..19b8dd212 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -196,7 +196,7 @@ export function EditProviderDialog({ }, [ open, // 修复:编辑保存后再次打开显示旧数据,依赖 open 确保每次打开时重新读取最新 provider 数据 provider?.id, // 只依赖 ID,provider 对象更新不会触发重新计算 - provider?.meta, // 需要依赖 meta 以便正确初始化 testConfig + provider?.meta, // 供应商元数据变化时重新初始化表单 initialSettingsConfig, ]); diff --git a/src/components/providers/HealthStatusIndicator.tsx b/src/components/providers/HealthStatusIndicator.tsx index 0fd319f2c..6612daa71 100644 --- a/src/components/providers/HealthStatusIndicator.tsx +++ b/src/components/providers/HealthStatusIndicator.tsx @@ -1,6 +1,6 @@ import React from "react"; import { cn } from "@/lib/utils"; -import type { HealthStatus } from "@/lib/api/model-test"; +import type { HealthStatus } from "@/lib/api/connectivity-check"; import { useTranslation } from "react-i18next"; interface HealthStatusIndicatorProps { diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 23bd7c308..a73cf246c 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -22,10 +22,13 @@ import { extractCodexBaseUrl, extractCodexExperimentalBearerToken, extractCodexWireApi, + isCodexAnthropicWireApi, isCodexChatWireApi, } from "@/utils/providerConfigUtils"; +import { supportsOfficialProxyTakeover } from "@/utils/providerCapabilities"; import { useProviderHealth } from "@/lib/query/failover"; import { useUsageQuery } from "@/lib/query/queries"; +import { resolveProviderIcon } from "@/utils/providerIcon"; interface DragHandleProps { attributes: DraggableAttributes; @@ -207,8 +210,14 @@ export function ProviderCard({ // 并不兑现(绕过 UI 即可切换)→ 属虚保护,却以误伤 category 缺失的自定义供应商为代价。 // 3) 预设导入的官方一定带 category="official",category 缺失的「真官方」现实中≈不存在。 // 真官方就该有显式 category;手动新建官方应引导标注,而不是靠空字段猜。 + const supportsOfficialRouting = supportsOfficialProxyTakeover( + appId, + provider, + ); const isOfficialBlockedByProxy = - isProxyTakeover && provider.category === "official"; + isProxyTakeover && + provider.category === "official" && + !supportsOfficialRouting; const isCopilot = provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT || provider.meta?.usage_script?.templateType === "github_copilot"; @@ -220,11 +229,16 @@ export function ProviderCard({ provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH; const codexNeedsRouting = useMemo(() => { if (appId !== "codex" || provider.category === "official") return false; - if (provider.meta?.apiFormat === "openai_chat") return true; + if ( + provider.meta?.apiFormat === "openai_chat" || + provider.meta?.apiFormat === "anthropic" + ) + return true; const config = (provider.settingsConfig as Record)?.config; return ( typeof config === "string" && - isCodexChatWireApi(extractCodexWireApi(config)) + (isCodexChatWireApi(extractCodexWireApi(config)) || + isCodexAnthropicWireApi(extractCodexWireApi(config))) ); }, [ appId, @@ -337,7 +351,11 @@ export function ProviderCard({
)} - {appId === "codex" && provider.category === "official" && ( - - {t("codex.noRoutingSupport", { - defaultValue: "不支持路由", - })} + {appId === "codex" && supportsOfficialRouting && ( + + {isProxyTakeover + ? t("codex.officialRouting", { + defaultValue: "官方账号路由", + }) + : t("codex.nativeLogin", { + defaultValue: "Codex 登录", + })} )} + {appId === "codex" && + provider.category === "official" && + !supportsOfficialRouting && ( + + {t("codex.noRoutingSupport", { + defaultValue: "不支持路由", + })} + + )} + {isProxyRunning && isInFailoverQueue && health && ( void; // Speed Test Endpoints @@ -200,6 +201,7 @@ export function ClaudeFormFields({ defaultOpusModelName, defaultFableModel, defaultFableModelName, + subagentModel, onModelChange, speedTestEndpoints, apiFormat, @@ -225,6 +227,7 @@ export function ClaudeFormFields({ defaultSonnetModel || defaultOpusModel || defaultFableModel || + subagentModel || apiFormat !== "anthropic" || apiKeyField !== "ANTHROPIC_AUTH_TOKEN" || customUserAgent || @@ -248,6 +251,7 @@ export function ClaudeFormFields({ const [codexOauthModels, setCodexOauthModels] = useState([]); const [codexOauthModelsLoading, setCodexOauthModelsLoading] = useState(false); const codexOauthModelsRequestRef = useRef(0); + const fallbackUsesOneM = hasClaudeOneMMarker(claudeModel); // 通用模型获取(非 Copilot 供应商) const [fetchedModels, setFetchedModels] = useState([]); @@ -517,12 +521,12 @@ export function ClaudeFormFields({ }; type ModelRoleRow = { - role: "sonnet" | "opus" | "fable" | "haiku"; + role: "sonnet" | "opus" | "fable" | "haiku" | "subagent"; label: string; model: string; - displayName: string; + displayName?: string; modelField: ClaudeModelEnvField; - displayNameField: ClaudeModelEnvField; + displayNameField?: ClaudeModelEnvField; inputId: string; supportsOneM: boolean; }; @@ -568,6 +572,16 @@ export function ClaudeFormFields({ inputId: "claudeDefaultHaikuModel", supportsOneM: false, }, + { + role: "subagent", + label: t("providerForm.modelRoleSubagent", { + defaultValue: "Subagent", + }), + model: subagentModel, + modelField: "CLAUDE_CODE_SUBAGENT_MODEL", + inputId: "claudeCodeSubagentModel", + supportsOneM: true, + }, ]; const handleRoleModelChange = (row: ModelRoleRow, value: string) => { @@ -576,10 +590,10 @@ export function ClaudeFormFields({ ? value : stripClaudeOneMMarker(value); const nextModelBase = stripClaudeOneMMarker(normalizedValue).trim(); - const displayName = row.displayName.trim(); + const displayName = row.displayName?.trim() ?? ""; const shouldSyncDisplayName = !displayName || displayName === oldModelBase; onModelChange(row.modelField, normalizedValue); - if (shouldSyncDisplayName) { + if (row.displayNameField && shouldSyncDisplayName) { onModelChange(row.displayNameField, nextModelBase); } }; @@ -831,17 +845,20 @@ export function ClaudeFormFields({ defaultSonnetModel || defaultOpusModel || defaultFableModel || - defaultHaikuModel; + defaultHaikuModel || + subagentModel; if (value) { for (const row of modelRoleRows) { const roleValue = row.supportsOneM ? value : stripClaudeOneMMarker(value); onModelChange(row.modelField, roleValue); - onModelChange( - row.displayNameField, - stripClaudeOneMMarker(roleValue), - ); + if (row.displayNameField) { + onModelChange( + row.displayNameField, + stripClaudeOneMMarker(roleValue), + ); + } } toast.success( t("providerForm.quickSetSuccess", { @@ -855,7 +872,8 @@ export function ClaudeFormFields({ !defaultHaikuModel && !defaultSonnetModel && !defaultOpusModel && - !defaultFableModel + !defaultFableModel && + !subagentModel } className="h-7 gap-1" > @@ -923,19 +941,30 @@ export function ClaudeFormFields({
{row.label}
- - onModelChange(row.displayNameField, event.target.value) - } - placeholder={ - modelBase || - t("providerForm.modelDisplayNamePlaceholder", { - defaultValue: "例如 DeepSeek V4 Pro", - }) - } - autoComplete="off" - /> + {row.displayNameField ? ( + + onModelChange( + row.displayNameField!, + event.target.value, + ) + } + placeholder={ + modelBase || + t("providerForm.modelDisplayNamePlaceholder", { + defaultValue: "例如 DeepSeek V4 Pro", + }) + } + autoComplete="off" + /> + ) : ( +
+ {t("providerForm.modelNoDisplayName", { + defaultValue: "不显示在 /model 菜单", + })} +
+ )} {renderModelInput( row.inputId, modelBase, @@ -973,12 +1002,35 @@ export function ClaudeFormFields({ defaultValue: "默认兜底模型", })} - {renderModelInput( - "claudeModel", - claudeModel, - "ANTHROPIC_MODEL", - t("providerForm.modelPlaceholder", { defaultValue: "" }), - )} +
+ {renderModelInput( + "claudeModel", + stripClaudeOneMMarker(claudeModel), + "ANTHROPIC_MODEL", + t("providerForm.modelPlaceholder", { defaultValue: "" }), + (value) => + onModelChange( + "ANTHROPIC_MODEL", + setClaudeOneMMarker(value, fallbackUsesOneM), + ), + )} + +

{t("providerForm.fallbackModelHint", { defaultValue: diff --git a/src/components/providers/forms/CodexCommonConfigModal.tsx b/src/components/providers/forms/CodexCommonConfigModal.tsx index 0c0207d91..e1ccc6b0b 100644 --- a/src/components/providers/forms/CodexCommonConfigModal.tsx +++ b/src/components/providers/forms/CodexCommonConfigModal.tsx @@ -9,7 +9,7 @@ interface CodexCommonConfigModalProps { isOpen: boolean; onClose: () => void; value: string; - onSave: (value: string) => boolean; + onSave: (value: string) => boolean | Promise; error?: string; onExtract?: () => void; isExtracting?: boolean; @@ -58,8 +58,8 @@ export const CodexCommonConfigModal: React.FC = ({ onClose(); }; - const handleSave = () => { - if (onSave(draftValue)) { + const handleSave = async () => { + if (await onSave(draftValue)) { onClose(); } }; diff --git a/src/components/providers/forms/CodexConfigEditor.tsx b/src/components/providers/forms/CodexConfigEditor.tsx index af458aaa1..86b88b7bf 100644 --- a/src/components/providers/forms/CodexConfigEditor.tsx +++ b/src/components/providers/forms/CodexConfigEditor.tsx @@ -22,11 +22,11 @@ interface CodexConfigEditorProps { useCommonConfig: boolean; - onCommonConfigToggle: (checked: boolean) => void; + onCommonConfigToggle: (checked: boolean) => void | Promise; commonConfigSnippet: string; - onCommonConfigSnippetChange: (value: string) => boolean; + onCommonConfigSnippetChange: (value: string) => boolean | Promise; onCommonConfigErrorClear: () => void; diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index 70c91e141..8af3bf8a7 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { FormLabel } from "@/components/ui/form"; @@ -37,18 +37,22 @@ import { CustomUserAgentField } from "./CustomUserAgentField"; import { LocalProxyRequestOverridesField } from "./LocalProxyRequestOverridesField"; import { cn } from "@/lib/utils"; import type { + ClaudeApiKeyField, CodexApiFormat, CodexCatalogModel, CodexChatReasoning, + PromptCacheRoutingMode, ProviderCategory, } from "@/types"; import type { ManagedAuthProvider } from "@/lib/api"; +import type { AppId } from "@/lib/api"; interface EndpointCandidate { url: string; } interface CodexFormFieldsProps { + appId?: AppId; providerId?: string; // API Key codexApiKey: string; @@ -76,12 +80,27 @@ interface CodexFormFieldsProps { autoSelect: boolean; onAutoSelectChange: (checked: boolean) => void; + // Default model (config.toml top-level `model`) + codexModel?: string; + onModelChange?: (model: string) => void; + // API Format // Note: wire_api is always "responses" for Codex; apiFormat controls proxy-layer conversion apiFormat: CodexApiFormat; onApiFormatChange: (format: CodexApiFormat) => void; + // Auth field for the Anthropic Messages upstream (only used when apiFormat === "anthropic") + anthropicAuthField: ClaudeApiKeyField; + onAnthropicAuthFieldChange: (value: ClaudeApiKeyField) => void; + // Anthropic path: whether to emulate the Claude Code client + impersonateClaudeCode: boolean; + onImpersonateClaudeCodeChange: (value: boolean) => void; + // Anthropic path: output ceiling override (empty string = use default). Digits only. + maxOutputTokens: string; + onMaxOutputTokensChange: (value: string) => void; codexChatReasoning?: CodexChatReasoning; onCodexChatReasoningChange?: (value: CodexChatReasoning) => void; + promptCacheRouting: PromptCacheRoutingMode; + onPromptCacheRoutingChange: (value: PromptCacheRoutingMode) => void; // Model Catalog catalogModels?: CodexCatalogModel[]; @@ -145,6 +164,7 @@ function catalogRowsMatchModels( } export function CodexFormFields({ + appId = "codex", providerId, codexApiKey, onApiKeyChange, @@ -168,10 +188,20 @@ export function CodexFormFields({ onCustomEndpointsChange, autoSelect, onAutoSelectChange, + codexModel = "", + onModelChange, apiFormat, onApiFormatChange, + anthropicAuthField, + onAnthropicAuthFieldChange, + impersonateClaudeCode, + onImpersonateClaudeCodeChange, + maxOutputTokens, + onMaxOutputTokensChange, codexChatReasoning = {}, onCodexChatReasoningChange, + promptCacheRouting, + onPromptCacheRoutingChange, catalogModels = [], onCatalogModelsChange, speedTestEndpoints, @@ -186,9 +216,19 @@ export function CodexFormFields({ const [fetchedModels, setFetchedModels] = useState([]); const [isFetchingModels, setIsFetchingModels] = useState(false); + // 拉取请求序号:请求身份(Base URL / 完整地址开关 / API Key / 自定义 UA) + // 一变即自增,清空旧列表并作废在途响应——/models 结果可能按 Key 的模型 + // 授权返回,换号后残留旧列表会误导选择 + const fetchModelsSeqRef = useRef(0); + + useEffect(() => { + fetchModelsSeqRef.current += 1; + setFetchedModels((prev) => (prev.length === 0 ? prev : [])); + }, [codexBaseUrl, isFullUrl, codexApiKey, customUserAgent]); // 思考能力随 Chat 格式显示(仅 Chat Completions 转换路径用得上);模型映射常驻 //(填了才生成 catalog)。两者都已与「路由接管」概念解耦。 const isChatFormat = apiFormat === "openai_chat"; + const isAnthropicFormat = apiFormat === "anthropic"; const canEditCatalog = Boolean(onCatalogModelsChange); const canEditReasoning = Boolean(onCodexChatReasoningChange); const supportsThinking = @@ -206,8 +246,11 @@ export function CodexFormFields({ hasRequestOverrides || catalogModels.length > 0 || apiFormat === "openai_responses" || + isAnthropicFormat || supportsThinking || - supportsEffort; + supportsEffort || + promptCacheRouting !== "auto" || + !!maxOutputTokens; const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue); // 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠) @@ -283,6 +326,7 @@ export function CodexFormFields({ }); return; } + const seq = ++fetchModelsSeqRef.current; setIsFetchingModels(true); fetchModelsForConfig( codexBaseUrl, @@ -292,6 +336,7 @@ export function CodexFormFields({ customUserAgent, ) .then((models) => { + if (seq !== fetchModelsSeqRef.current) return; setFetchedModels(models); if (models.length === 0) { toast.info(t("providerForm.fetchModelsEmpty")); @@ -302,6 +347,7 @@ export function CodexFormFields({ } }) .catch((err) => { + if (seq !== fetchModelsSeqRef.current) return; console.warn("[ModelFetch] Failed:", err); showFetchModelsError(err, t); }) @@ -326,6 +372,47 @@ export function CodexFormFields({ setCatalogRows((current) => current.filter((_, i) => i !== index)); }, []); + // 默认模型下拉建议 = 模型映射的"实际请求模型"列 ∪ 拉取到的 /models 列表 + const defaultModelSuggestions = useMemo(() => { + const seen = new Set(); + const suggestions: FetchedModel[] = []; + for (const row of catalogRows) { + const id = row.model.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + suggestions.push({ + id, + ownedBy: t("codexConfig.modelMappingTitle", { + defaultValue: "模型映射", + }), + }); + } + for (const model of fetchedModels) { + if (seen.has(model.id)) continue; + seen.add(model.id); + suggestions.push(model); + } + return suggestions; + }, [catalogRows, fetchedModels, t]); + + // 填了映射时才提示"默认模型不在映射中"(无映射的供应商本来就直接请求任意模型名) + const trimmedDefaultModel = codexModel.trim(); + const isDefaultModelOutsideCatalog = + catalogRows.length > 0 && + !!trimmedDefaultModel && + !catalogRows.some((row) => row.model.trim() === trimmedDefaultModel); + + const handleAddDefaultModelToCatalog = useCallback(() => { + if (!onCatalogModelsChange || !trimmedDefaultModel) return; + setCatalogRows((current) => [ + ...current, + createCatalogRow({ + model: trimmedDefaultModel, + displayName: trimmedDefaultModel, + }), + ]); + }, [onCatalogModelsChange, trimmedDefaultModel]); + const renderCatalogActionButtons = (onAdd: () => void, addLabel: string) => (

+ {defaultModelSuggestions.length > 0 && ( + onModelChange(id)} + /> + )} +
+

+ {t("codexConfig.defaultModelHint", { + defaultValue: + "Codex 默认请求的模型,随时可改,无需等待预设更新。留空且配置了模型映射时,默认使用映射第一行。", + })} +

+ {isDefaultModelOutsideCatalog && ( +

+ {t("codexConfig.defaultModelNotInCatalog", { + defaultValue: + "该模型不在模型映射中,Codex 的 /model 菜单不会列出它(直接请求仍然有效)。", + })} + +

+ )} +
+ )} + {/* 高级选项 —— 上游格式/模型映射/思考能力/自定义 UA;预设供应商通常无需展开 */} {category !== "official" && ( + + {t("codexConfig.upstreamFormatAnthropic", { + defaultValue: "Anthropic Messages(需开启路由)", + })} +

{t("codexConfig.upstreamFormatHint", { defaultValue: - "供应商原生是 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat(需开启路由接管才能转换为 Chat Completions)。", + "供应商原生是 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat;供应商只提供原生 Anthropic Messages 协议就选 Anthropic Messages。Chat 与 Anthropic Messages 均需开启路由接管才能转换为 Responses。", })}

+ + {isAnthropicFormat && ( +
+ + {t("codexConfig.anthropicAuthFieldLabel", { + defaultValue: "认证字段", + })} + + +

+ {t("codexConfig.anthropicAuthFieldHint", { + defaultValue: + "选择网关接收 API Key 的请求头:ANTHROPIC_AUTH_TOKEN 发送 Authorization: Bearer;ANTHROPIC_API_KEY 发送 x-api-key。两者只发其一。", + })} +

+
+ )} + + {isAnthropicFormat && ( +
+
+ + {t("codexConfig.impersonateClaudeCodeLabel", { + defaultValue: "模拟 Claude Code 客户端", + })} + +

+ {t("codexConfig.impersonateClaudeCodeHint", { + defaultValue: + "网关或其上游限制只能通过 Claude Code 使用时开启:伪装 User-Agent、anthropic-beta、x-app 请求头,并在系统提示首行注入 Claude Code 身份。", + })} +

+
+ +
+ )} + + {isAnthropicFormat && ( +
+ + {t("codexConfig.maxOutputTokensLabel", { + defaultValue: "最大输出 tokens", + })} + + + onMaxOutputTokensChange( + event.target.value.replace(/[^\d]/g, ""), + ) + } + placeholder={t("codexConfig.maxOutputTokensPlaceholder", { + defaultValue: "留空则使用默认 8192", + })} + /> +

+ {t("codexConfig.maxOutputTokensHint", { + defaultValue: + "Codex 不会把 model_max_output_tokens 写进请求体,默认上限 8192 容易在长回答或深度思考时被截断(stop_reason=max_tokens)。此处设置会作为 Anthropic 的 max_tokens 覆盖请求值。请勿超过该模型/网关的真实输出上限,否则可能 400。留空使用默认 8192。", + })} +

+
+ )}
)} @@ -497,6 +754,49 @@ export function CodexFormFields({ shouldShowSpeedTest && "border-t border-border-default pt-3", )} > +
+ + {t("codexConfig.promptCacheRoutingLabel", { + defaultValue: "提示词缓存路由", + })} + + +

+ {t("codexConfig.promptCacheRoutingHint", { + defaultValue: + "自动模式仅对已确认兼容的上游发送 prompt_cache_key;开启可用于其他兼容网关,关闭可避免严格网关因未知字段返回 400。只使用客户端提供的稳定会话 ID。", + })} +

+
+
{t("codexConfig.reasoningGroupTitle", { @@ -739,7 +1039,7 @@ export function CodexFormFields({ {/* 端点测速弹窗 - Codex */} {shouldShowSpeedTest && isEndpointModalOpen && ( = ({ accounts, defaultAccountId, isStatusSuccess, + isStatusError, hasAnyAccount, pollingState, deviceCode, @@ -82,6 +83,7 @@ export const CodexOAuthSection: React.FC = ({ setDefaultAccount, cancelAuth, logout, + refetchStatus, } = useCodexOauth(); const copyUserCode = async () => { @@ -132,7 +134,8 @@ export const CodexOAuthSection: React.FC = ({ (account) => account.id === selectedAccountId && account.reauth_required, ); - const accountSelect = onAccountSelect && + const accountSelect = isStatusSuccess && + onAccountSelect && (mode === "select" || hasAnyAccount || noneOptionLabel) && (