Compare commits

..

50 Commits

Author SHA1 Message Date
SaladDay 0a7a4aac49 test(pi): drop contract guards that policed the process only
Three checks existed to keep the staged rollout honest and would fail on any
legitimate future change:

- schema.rs / migration.rs / backup.rs pinned by SHA-256
- the impl Database write-surface symbol inventory pinned by name
- specific restore test function names asserted to exist

Behavioural assertions, the DML column-authority scanner, the type barriers
and the pinned oracle fixture hashes stay: those catch real regressions. The
infra path list survives because the DML scanner still needs it.
2026-08-03 08:44:45 +00:00
SaladDay fff5d7ede9 fix(pi): align credential retry scope 2026-08-03 08:32:41 +00:00
SaladDay a2fa86bea1 fix(pi): localize UI and isolate credential health 2026-08-03 08:07:19 +00:00
SaladDay a8246da3d6 test(pi): retire process-only guards from the contract suites
The suites were written as executable contracts for the implementation
process. Three of their checks only policed that process and would fail on
any legitimate future change:

- schema.rs/migration.rs/backup.rs pinned by SHA-256
- the impl Database write-surface symbol inventory pinned by name
- specific restore test function names asserted to exist

The behavioural assertions, the DML column-authority scanner, the type
barriers and the pinned oracle fixture hashes are kept: those catch real
regressions.

Headers are rewritten as contract documentation; the stale red/green
ledgers no longer described reality.
2026-08-03 08:01:24 +00:00
SaladDay 56acfd266e docs(pi): drop process-only handoff documents
The handoff, contracts, restructure ruling, reviewer spec and main-project
contract were scaffolding for the implementation process, not deliverables.
Their technical invariants now live in the code and the certification suites.

Kept: the extensions design appendix (future work) and the restore hardening
debt register (known issues in shipped code).

Certification suite comments that pointed at the removed restructure ruling
are made self-contained.
2026-08-03 07:56:09 +00:00
SaladDay 43dc18a2c5 fix(pi): unify gateway and shared-file boundaries 2026-08-03 07:30:16 +00:00
SaladDay a221e086a3 fix(pi): bind native publication to durable ownership 2026-08-03 05:17:33 +00:00
SaladDay 0b609158d7 fix(pi): bind native and prompt recovery to revisions 2026-08-03 04:43:44 +00:00
SaladDay c94983d5fa fix(pi): reconcile prompt authority without partial state 2026-08-03 04:09:23 +00:00
SaladDay 692ef41883 fix(pi): bind recovery to durable native authority 2026-08-03 04:09:18 +00:00
SaladDay 914fc99b4c feat(pi): expose explicit prompt library reconciliation 2026-08-03 03:23:57 +00:00
SaladDay 2ba603fb77 fix(pi): treat stale skill cleanup as post-commit state 2026-08-03 03:23:51 +00:00
SaladDay 40e087c072 fix(pi): harden gateway publication and retry responses 2026-08-03 03:23:48 +00:00
SaladDay 1e9e59051a fix(pi): refresh desired takeover state after failures 2026-08-03 02:08:52 +00:00
SaladDay 5360061bb6 fix(pi): reconcile native resources across directory moves 2026-08-03 02:08:44 +00:00
SaladDay 969cdaed5b fix(pi): make catalog compensation conflict-safe 2026-08-03 02:08:38 +00:00
SaladDay 57ba6beb43 fix(pi): harden gateway runtime boundaries 2026-08-03 02:08:32 +00:00
SaladDay 93eddd5521 docs(pi): refresh final scope audit 2026-08-03 00:38:38 +00:00
SaladDay 9f910270ef fix(pi): surface pending takeover state 2026-08-03 00:38:34 +00:00
SaladDay 9d0e75d56a fix(pi): restore catalog and takeover authority boundaries 2026-08-03 00:38:29 +00:00
SaladDay 26447effa2 fix(pi): preserve skill ownership through install failures 2026-08-03 00:38:06 +00:00
SaladDay cf978c1346 fix(pi): make shared file commits collision safe 2026-08-03 00:38:02 +00:00
SaladDay 6953f9fffd fix(pi): harden gateway failover and command isolation 2026-08-03 00:37:57 +00:00
SaladDay 816a8b3928 docs(pi): record parity and pinned behavior evidence 2026-08-02 22:52:40 +00:00
SaladDay a88a58b516 feat(pi): expose first-class desktop workflows 2026-08-02 22:52:35 +00:00
SaladDay 7609ff799e feat(pi): add native catalog and gateway data plane 2026-08-02 22:52:26 +00:00
SaladDay 26a95aeb05 docs(pi): define main project contract 2026-08-02 17:05:14 +00:00
SaladDay 9fffc8853d fix(pi): close native inspection review gaps 2026-08-02 16:40:45 +00:00
SaladDay d2ad7995a3 fix(pi): fail closed on unrepresentable compat spread 2026-08-02 16:16:14 +00:00
SaladDay bd3e2e54fc docs(pi): adopt incremental review scope 2026-08-02 15:47:02 +00:00
SaladDay 2a0a8125eb fix(pi): certify pinned native inspection semantics 2026-08-02 15:24:48 +00:00
SaladDay 15c735f040 fix(pi): mirror pinned native runtime semantics 2026-08-02 13:56:18 +00:00
SaladDay 2f856b0ffb fix(pi): validate deferred headers after resolution 2026-08-02 12:05:25 +00:00
SaladDay 9b5a146323 refactor(pi): certify native inspection boundary 2026-08-02 10:12:00 +00:00
SaladDay 2533db035a docs(database): close restore hardening prerequisite 2026-08-02 10:10:45 +00:00
SaladDay 3fa6b1f158 refactor(database): scope untrusted restore to N/N-1 2026-08-02 05:29:48 +00:00
SaladDay ce6edf68a0 fix(database): reject hidden migration source columns 2026-08-01 19:56:17 +00:00
SaladDay c036c6359e refactor(database): construct untrusted migrations from source specs 2026-08-01 19:45:36 +00:00
SaladDay 29cccd5bd3 fix(database): fail closed across restore migrations
Classify every historical migration boundary, thread the restore context through the full chain, reject repair or synthesis for untrusted inputs, and add SQL/binary public-entry certification for damaged and valid legacy fixtures.
2026-08-01 17:04:54 +00:00
SaladDay 28530ff641 fix(database): enforce canonical restore trust boundaries 2026-08-01 16:01:00 +00:00
SaladDay 89961dff28 fix(database): close canonical restore review gaps 2026-08-01 14:12:08 +00:00
SaladDay 9a87512224 refactor(database): certify canonical restore boundary 2026-08-01 12:55:39 +00:00
SaladDay 2841811700 refactor(database): complete prerequisite B 2026-08-01 11:44:50 +00:00
SaladDay 22c010079a fix(provider): enforce durable identity boundaries 2026-08-01 09:09:14 +00:00
SaladDay 37e78166c1 refactor(provider): complete prerequisite A ownership 2026-08-01 08:37:49 +00:00
SaladDay 2bc92e0f79 refactor(provider): certify typed write ownership
Freeze prerequisite A as a component-level certification unit. Add the immutable v5 certification suite, split create/update row DTOs, preserve immutable creation time, map strict-create races to AppError::Conflict, make aggregate compensation insert-or-restore, and enforce reconcile preconditions through a single-lock transaction primitive.

Old save_provider callsite classification remains exhaustively recorded in 4f78451405575158ff6562c7021c7f31f2860780; this checkpoint does not add or reclassify an omitted legacy callsite. It tightens the remaining reconciliation classifications there: default live import is [create]; OpenCode/OpenClaw/Hermes existing branches are [update] and absent branches are [create]; universal Claude/Codex/Gemini branches are [create/update] selected from an observed fingerprint. The sealed compensation helper remains the only [restore] path. The old reconcile_provider_record symbol is deleted.

Remaining update_provider_settings_config callsites are classified as [update]: codex_history_migration updates an already-read Codex row; proxy token synchronization updates already-read Claude, Codex, Gemini, and GrokBuild rows. Each now uses ProviderKey plus ProviderRowUpdate, explicitly removes hydrated endpoint projections, preserves endpoint authority, and fails on a missing row instead of silently succeeding.
2026-08-01 07:48:57 +00:00
SaladDay 10f2dacbe4 config(pi): execute pinned native schema and composer oracles
Evaluate raw Pi documents with the vendored upstream TypeBox schema and replay composer/transport expectations captured by actually executing Pi ab366ebe94cacd419d986be454f12b1b9913aaca.

Bind all 252 canonical fields to successful raw and own-layer composer evidence (70 provider, 92 model, 90 override), fail closed where pinned runtime context is unavailable, expose structured inspection, and enforce module/write/restore boundaries with negative fixtures.
2026-07-31 18:07:21 +00:00
SaladDay 4f78451405 storage(pi): isolate typed provider writes and canonical restore
Replace the generic Provider save/upsert surface with strict typed
create/update/rename/endpoint operations. Keep aggregate hydration read-only,
preserve nullable endpoint timestamps end to end, and make service create own
the initial endpoint set atomically.

Restore SQL and binary backups only through UntrustedScratch, migrate and copy
fixed data columns into a fresh CanonicalStage, validate the canonical result,
then publish through the Backup API. The imported schema is never eligible to
become the live schema.

Old save_provider callsite classification
==========================================

Inventory authority: abandoned 5a385fc8 tree. The old definition at
src-tauri/src/database/dao/providers.rs:180 is deleted and is not a callsite.

Production callsites:

- src-tauri/src/commands/provider.rs:253 [create] Claude Desktop import creates
  one absent aggregate; it now strict-inserts the row and initial endpoints in
  one transaction.
- src-tauri/src/database/dao/providers.rs:638 [create] official seed first
  proves absence, then strict-creates; a racing insert is a conflict.
- src-tauri/src/database/dao/providers.rs:704 [create] on-demand seed first
  proves absence, then strict-creates; it cannot overwrite an existing row.
- src-tauri/src/services/omo.rs:291 [create] OMO import constructs a new
  aggregate and strict-creates it; OMO is not eligible for rename.
- src-tauri/src/services/provider/endpoints.rs:85 [update] endpoint last-used
  is not a Provider-row save; it now calls the exact touch endpoint operation.
- src-tauri/src/services/provider/live.rs:1567 [create/update] default live
  import is reconciliation: read first, then strict create or strict update.
- src-tauri/src/services/provider/live.rs:1743 [update] an existing OpenCode
  live provider follows the strict row-update branch.
- src-tauri/src/services/provider/live.rs:1770 [create] a new OpenCode live
  provider follows the strict aggregate-create branch.
- src-tauri/src/services/provider/live.rs:1825 [update] an existing OpenClaw
  live provider follows the strict row-update branch.
- src-tauri/src/services/provider/live.rs:1858 [create] a new OpenClaw live
  provider follows the strict aggregate-create branch.
- src-tauri/src/services/provider/live.rs:1900 [update] an existing Hermes live
  provider follows the strict row-update branch.
- src-tauri/src/services/provider/live.rs:1926 [create] a new Hermes live
  provider follows the strict aggregate-create branch.
- src-tauri/src/services/provider/mod.rs:2568 [create] ProviderService::add owns
  strict aggregate creation and all initial endpoints.
- src-tauri/src/services/provider/mod.rs:2680 [rename] an additive DB-only key
  change now uses the dedicated transactional rename after eligibility checks.
- src-tauri/src/services/provider/mod.rs:2711 [update] OMO edit updates exactly
  the existing main row after its live-file coordination.
- src-tauri/src/services/provider/mod.rs:2740 [update] additive-provider edit
  updates exactly the existing main row after resolving live ownership.
- src-tauri/src/services/provider/mod.rs:2750 [update] switch-mode edit updates
  exactly the existing main row and never inserts.
- src-tauri/src/services/provider/mod.rs:2948 [update] remove-from-live changes
  only the existing provider's live-managed marker.
- src-tauri/src/services/provider/mod.rs:3120 [update] switch backfill updates
  only the existing current provider row.
- src-tauri/src/services/provider/mod.rs:3174 [update] successful additive
  switch changes only the existing live-managed marker.
- src-tauri/src/services/provider/mod.rs:3315 [update] common-config migration
  updates only each already-read existing row.
- src-tauri/src/services/provider/mod.rs:3895 [update] Gemini credential scrub
  updates only each already-read existing row.
- src-tauri/src/services/provider/mod.rs:4082 [update] sort ordering is routed
  to the dedicated sort-index state operation, not row replacement.
- src-tauri/src/services/provider/mod.rs:4636 [create/update] universal-to-
  Claude reconciliation reads the target and selects strict create or update.
- src-tauri/src/services/provider/mod.rs:4651 [create/update] universal-to-
  Codex reconciliation reads the target and selects strict create or update.
- src-tauri/src/services/provider/mod.rs:4665 [create/update] universal-to-
  Gemini reconciliation reads the target and selects strict create or update.

Required indirect ownership paths:

- src-tauri/src/deeplink/provider.rs [create] the old indirect flow called
  ProviderService::add and then appended endpoints one by one. It now supplies
  every non-primary endpoint to one strict aggregate create, so hydration is
  complete atomically and a duplicate is zero-side-effect.
- [restore] no old generic-save callsite is reclassified as restore. Exact
  aggregate replacement exists only as the sealed
  restore_provider_aggregate_on_tx compensation primitive.

Test-only callsites:

Every item below is classified [test]. Each is fixture setup, not a production
write authority, and is migrated to a real ProviderService entry where the
behavior is under test or to the cfg(test)-only typed fixture reconciler where
the test merely needs pre-existing rows.

- src-tauri/src/codex_history_migration.rs:1442 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:1452 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2174 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2176 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2199 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2219 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2247 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2267 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2288 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2320 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2393 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2449 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2498 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2555 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2604 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2625 [test] migration fixture setup.
- src-tauri/src/database/dao/providers.rs:754 [test] DAO fixture setup.
- src-tauri/src/proxy/provider_router.rs:351 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:352 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:377 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:378 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:410 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:411 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:447 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:448 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:488 [test] router fixture setup.
- src-tauri/src/services/provider/mod.rs:485 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:586 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:813 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:825 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1472 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1607 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1737 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1945 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1952 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1978 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2006 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2056 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2130 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2167 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2207 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2235 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2270 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2320 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2362 [test] service fixture setup.
- src-tauri/src/services/proxy.rs:3762 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:3948 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4034 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4095 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4114 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4263 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4341 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4421 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4533 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4651 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4787 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5264 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5320 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5385 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5387 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5460 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5462 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5611 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5613 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5615 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5698 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5700 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5998 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6000 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6173 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6175 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6417 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6419 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6553 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6555 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6635 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6637 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6919 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:7173 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:7175 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:7240 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:7242 [test] proxy fixture setup.
- src-tauri/tests/profile_roundtrip.rs:112 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:116 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:126 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:133 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:292 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:501 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:505 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:670 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:677 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:762 [test] Linux Desktop fixture create.
- src-tauri/tests/profile_roundtrip.rs:769 [test] Linux Desktop fixture create.
- src-tauri/tests/provider_commands.rs:69 [test] command fixture create.
- src-tauri/tests/provider_service.rs:2927 [test] service fixture create.
2026-07-31 18:06:59 +00:00
SaladDay 3dc188313e domain(pi): add managed model and capability contracts 2026-07-31 17:52:59 +00:00
SaladDay 119d795121 docs(pi): freeze support contracts and extension design 2026-07-31 17:52:24 +00:00
195 changed files with 56101 additions and 5462 deletions
-51
View File
@@ -5,57 +5,6 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.19.1] - 2026-07-31
Development since v3.19.0 is a maintenance pass rather than a feature wave — and the first release in this project's history that deletes more than it adds. Three Chinese Codex gateways move onto native Responses and no longer need local routing takeover: DeepSeek connects straight to `api.deepseek.com` and brings with it a general mechanism for mirroring a vendor's own model catalog verbatim, so the GPT-5 harness and the freeform `apply_patch` registration stay self-consistent instead of collapsing into the neutral template; Volcengine's Ark Coding Plan follows now that the official documentation confirms `/api/coding/v3` serves Responses; and Tencent Hunyuan's TokenHub joins as a new preset. The correctness sweep covers four failures visible in the field: Claude Desktop usage has been counted twice since v3.18.0, once as a proxy row and once as an imported session row (#5938); switching back to the built-in official Codex provider left a third-party key in `auth.json`, so Codex authenticated to the official endpoint with the wrong credential and returned 401 without ever falling through to its login screen; `grok update` failed from Settings with a bare `os error 2` because a GUI-launched app cannot see node; and Grok Build's proxy takeover returned 404 on any non-Responses backend while every request minted a fresh session id (#5677). Rounding it out, nine interface strings that rendered as Simplified Chinese in every language are localized and the Traditional Chinese About page gains the thirty tool-manager strings it was missing, eight models that silently billed at zero gain built-in prices, deep-link import confirmations mask more and truncate less, and 3,166 lines of superseded code plus four npm dependencies are removed.
**Stats**: 12 commits | 71 files changed | +2,324 insertions | -3,680 deletions
### Added
- **Official Vendor Model Catalogs Mirrored for Native Responses Providers**: Codex reads model capabilities from a catalog file, and CC Switch generated every provider's catalog from a neutral template — which is right for an aggregator, but strips capabilities a vendor's own integration depends on. A vendor whose published catalog is bundled with the app is now mirrored verbatim instead. DeepSeek is the first: `src-tauri/src/resources/codex_deepseek_catalog_template.json` carries its `deepseek-v4-flash` and `deepseek-v4-pro` entries with `apply_patch_tool_type: "freeform"`, `web_search_tool_type: "text"`, `supports_search_tool: true`, the low/high/max reasoning levels, and the 17,644-character GPT-5 harness in `base_instructions` and `model_messages` — which has to travel together with the freeform tool registration, because the harness instructs the model to use `apply_patch`. The gate is deliberately narrow: the provider must resolve to the native-Responses profile **and** its `base_url` must be on `deepseek.com`. Matching is by host only and never by model brand, because these entries grant capabilities that an aggregator reselling the same model may not implement. Explicit per-model overrides in the provider's own catalog still win, and an unrecognised model id clones the flagship entry while keeping its own slug. Every other provider profile emits exactly the catalog it did before.
- **Tencent Hunyuan (TokenHub) Codex Preset**: A "Tencent Hunyuan" entry joins the Codex preset picker under the Opensource Official category, between Bailian and StepFun. It writes `https://tokenhub.tencentmaas.com/v1` with `wire_api = "responses"` and the `disable_response_storage = true` that TokenHub requires, declares `hy3` and `hy3-preview` at a 256K context window (rather than accepting Codex's 128K default), and marks both text-only so `view_image` payloads are never sent to a model that cannot read them. Because it is a native Responses provider, Codex talks to the gateway directly and no local routing is needed; the generated catalog uses the neutral native template, which pins `shell_type = "shell_command"` and drops the freeform `apply_patch` registration that native gateways reject. The address manager and latency test see two candidates from the start — the primary `.com` host and the official `.cn` backup. The region-scoped international site is deliberately excluded, since API keys do not carry across sites. Note that the API key must be a TokenHub key created with the Hy3 scope; Coding Plan and Token Plan subscription keys do not work against this endpoint.
- **Built-In Pricing for Eight Models That Billed at Zero**: `gpt-5.3-codex-spark`, `gemini-3.5-flash-lite`, `kimi-k2.7-code-highspeed` (2x its `kimi-k2.7-code` base, matching Kimi's Turbo pattern), `glm-5-turbo`, `glm-5v-turbo` and `qwen3.6-flash` had no row in the built-in pricing table and could not be reached by the prefix fallback, so every request against them was recorded at zero cost. Two more rows, bare `claude-opus-4-6` and `claude-sonnet-4-6`, close a subtler gap: model-id resolution strips a date suffix but never adds one, so a log row carrying the undated id matched nothing at all. All eight are seeded insert-if-absent, so a price you already customized is untouched.
- **Grok Build Joins the Failover Tabs and the Environment-Conflict Check**: The failover settings gain a fourth tab for Grok Build alongside Claude Code, Codex and Gemini, and the startup environment-conflict banner now detects a shell-exported `XAI_API_KEY` or `GROK_DEFAULT_MODEL` — variables that silently override whatever provider is selected. Detection distinguishes exact names from prefixes, so CC Switch's own `GROK_BIN_DIR` and `GROK_HOME` are not reported.
### Changed
- **DeepSeek and Volcengine Ark Coding Plan Connect to Codex Directly, Without Local Routing**: Both presets were declared OpenAI Chat, which made them takeover-required: the provider card carried the "needs routing" badge, switching with the proxy off raised the routing prompt, and every request travelled Codex → local proxy → Responses-to-Chat conversion → upstream. Both vendors now publish official Codex integrations confirming their endpoints serve the Responses API — DeepSeek's `api.deepseek.com` and Volcengine's `/api/coding/v3` — so both presets declare native Responses, the badge and the prompt disappear, and Codex connects straight to the gateway. The generated `config.toml` is unchanged in both cases, since it already emitted `wire_api = "responses"`; what changes is the catalog profile and, for DeepSeek, the context window, which moves from 1,000,000 to the vendor's own 1,048,576. BytePlus deliberately stays on Chat routing until the international site's documentation is verified separately. The Volcengine preset also gained a comment recording a billing rule worth knowing: the pay-as-you-go `/api/v3` endpoint must never be added to this preset's backup addresses, because it bills separately instead of drawing down plan quota.
- **Catalog `displayName` and `contextWindow` Are Now Explicit-Only**: Both fields previously carried local defaults — the model id and a 128,000-token window — applied before a vendor value could be consulted, so a mirrored catalog would have had its 1M window overwritten with 128K. They are now optional, with the fallbacks applied one level down at entry construction, which makes "left blank" mean "keep whatever the vendor declared". Providers that set the fields explicitly, and every non-mirrored profile, emit identical catalogs to before.
### Fixed
- **Claude Desktop Usage Was Counted Twice**: Claude Desktop traffic through the local gateway landed in the usage dashboard once as a proxy row and once more as an imported transcript row, so its tokens, cost and request counts read roughly double. The regression shipped in v3.18.0: proxy dedup ids were scoped as `session:{app_type}:{provider_id}:{message_id}` for every app except `claude`, which put `claude-desktop` in its own namespace while the transcript importer kept writing the same Claude message under the bare `session:{message_id}` shape with `app_type = 'claude'`. All three dedup defenses failed at once — the primary-key convergence that lets a proxy row absorb an existing session row, the write-side fingerprint probe, and the read-side filter, the latter two both comparing `app_type` with strict equality. The two apps now share the bare namespace again, and the two comparisons are widened by a one-way rule where a `claude` session row can be absorbed by a `claude-desktop` proxy row but never the reverse. Because the read-side filter is also what daily rollups aggregate through, already-stored duplicates stop being counted without any row being rewritten or deleted — see Upgrade notes for the retention limit on that. For Codex, Gemini and OpenCode the widened comparison collapses to the previous exact match, and quota checks keep strict app matching. (#5938, #5951)
- **Switching Back to the Official Codex Provider Stranded You on a 401 With No Login Screen**: With Codex API-key preservation off (the default), switching to a third-party provider writes that vendor's key into `~/.codex/auth.json`. Switching afterwards to a built-in official provider — whose stored credentials are empty — took the config-only write path, so `config.toml` was replaced while the third-party `OPENAI_API_KEY` stayed on disk. Codex then authenticated to the official endpoint with a foreign key and got 401, and because `auth.json` existed it never fell through to its own login screen, leaving no way out from inside the app. After a successful switch to an official Codex provider, CC Switch now deletes an `auth.json` that contains only an `OPENAI_API_KEY` with no first-class credential beside it — OAuth tokens, a personal access token, an agent identity or a Bedrock key all mark the file as real and leave it untouched, while metadata such as `auth_mode`, `last_refresh` or an account id can no longer shield a stale key. Deleting rather than writing `{}` is deliberate: an empty object resolves to ChatGPT mode without tokens and errors at bootstrap, whereas a missing file yields the login screen. The cleanup runs only after the outgoing provider has been backfilled into the database, so the removed key is preserved and comes back when that provider is selected again. Live-config reads were relaxed in the same change so the post-cleanup state — no `auth.json`, an existing `config.toml` — is no longer reported as "Codex is not installed".
- **Grok Build Upgrades From Settings Failed With a Bare `os error 2`**: Upgrading Grok Build from Settings → About failed with `Error: No such file or directory (os error 2)` and nothing else. The cause is an asymmetry between how CC Switch probes tools and how it runs them: probing goes through a login shell, which sources the user's rc files and therefore sees nvm, Homebrew and Volta, while lifecycle scripts ran under a non-login shell inheriting the narrow PATH a GUI app is launched with. That normally does not matter, because anchored commands invoke binaries by absolute path — but grok 0.2.112 moved self-update onto npm distribution, so `grok update` now spawns `npm view` and `npm i -g` internally, and npm resolves node through its own shebang. The inner spawn returned ENOENT, which grok surfaced as the bare error. Lifecycle commands on macOS and Linux now run with the login shell's real PATH merged ahead of the inherited one, read through `/usr/bin/env` rather than by echoing the variable, because fish stores PATH as a list and would emit space-separated segments. Native Grok's update additionally chains the official xAI installer as a fallback — deliberately not `npm i -g`, which shares both of the primary's failure modes; the installer is the only node-free path, lands in the same location, and rewrites the CLI's own `installer` setting back to `internal`, healing users whom an earlier npm fallback had moved onto npm distribution.
- **Grok Build Proxy Takeover Returned 404, and Every Request Looked Like a New Session**: Enabling takeover on a Grok Build provider whose API format was OpenAI Chat or Anthropic produced an immediate 404 with no failover and no usage record — takeover rewrote the base URL and key but left the backend field alone, so the CLI posted to a route the proxy does not register. Takeover now also pins the backend to Responses; the per-provider downgrade to Chat Completions still happens inside the forwarder, and the forced value reverts with the whole live config when the proxy stops. Separately, proxy session extraction recognised only Codex and OpenAI clients, so every Grok Build turn minted a fresh session id marked as not client-provided, which suppressed prompt-cache key injection and per-session grouping in the usage dashboard. Grok's own headers are now read — the conversation id first, then the session id, ignoring the per-request id — under a distinct `grokbuild_` prefix so its rows cannot collide with Codex's. (#5677)
- **Nine Interface Strings Rendered as Simplified Chinese in Every Language**: Nine strings appeared in Simplified Chinese regardless of the selected language, English and Japanese included. Each call site used the inline-default form with a Chinese literal, but the key existed in none of the four locale files — and i18next resolves a key through the language chain before it considers an inline default, so the English fallback never engaged and the Chinese literal won everywhere. The affected strings cover the Grok Build provider form's validation toast, the failover tooltip shown when an app is not yet taken over, the warning raised when stopping Claude Desktop routing while another app holds takeover and the reason line that explains why routing must start, the duplicate-provider id read failure, the empty Codex common-config error, the routing service's stop and stop-failed toasts, and the "unpriced" cost label the usage tables show for a request that carries tokens but computes to zero. All nine now exist in Chinese, English, Japanese and Traditional Chinese. (#5960)
- **The Traditional Chinese About Page Fell Back to English in the Tool Manager**: With the interface set to Traditional Chinese, the tool management section of the About page rendered in English — version rows, install and update buttons, result toasts, the install-conflict diagnosis and the entire upgrade-confirmation dialog. The panel was built out across three earlier changes that added labels to Chinese, English and Japanese only, and because i18next falls back to English rather than failing, thirty missing keys were invisible in testing. This gap shipped in every release from v3.16.0 through v3.19.0. All thirty strings are now translated, the install hint was brought in line with the other locales, and a new locale test asserts that every tool-management label exists in all four languages with matching interpolation variables, so this class of drift fails the suite instead of shipping. (#5943)
- **Seeded Model Prices Disagreed With Vendor List Prices**: Costs are frozen at log time from the built-in pricing table, so a stale seed quietly mis-bills every subsequent request. Four rows were corrected: `deepseek-chat` and `deepseek-reasoner` are now legacy aliases of V4 Flash at $0.14/$0.28 per million input/output with $0.0028 cache read (from $0.27/$1.10 and $0.55/$2.19); `minimax-m3` halves to $0.30/$1.20, the official standard tier; and `gpt-5.6-luna` drops 80% to $0.20/$1.20 while `gpt-5.6-terra` drops 20% to $2/$12, following OpenAI's 2026-07-30 price cut, with `gpt-5.6-sol` deliberately unchanged and the family's cache-write ratio preserved. The repairs rewrite a row only when all four of its cost columns still hold the exact previous built-in values, so a price you edited yourself — or one set by models.dev sync — is never touched.
### Security
- **Deep-Link Import Confirmations Mask More and Truncate Less**: A continuation of the `ccswitch://` confirmation hardening in v3.19.0. Config previews are now built by one shared module that masks secrets recursively through nested TOML tables and JSON objects, which fixes two opposite defects: a Grok Build import rendered no configuration preview at all, while a Codex import printed embedded `api_key` values in the clear. The 300-character truncation on the configuration preview is gone — the full document now renders inside a scrollable box, closing the last place the confirmation could hide part of what it was about to write. Masking itself is stricter everywhere it is used, including the MCP import confirmation: the sensitive-name matcher gained `AUTHORIZATION`, `COOKIE` and `CREDENTIAL` alongside exact matches for `AUTH` and `BEARER`, masked values reveal four leading characters instead of eight, and a value of eight characters or fewer is now replaced entirely rather than shown. Finally, the frontend Base64 decoder no longer trims surrounding whitespace, which could discard a `+` that URL decoding had turned into a space — the same class of frontend/backend decoder divergence fixed in v3.19.0, where the dialog shows one thing and the importer writes another.
### Internal
- **Superseded Code Removed: 3,166 Lines, Fourteen Modules and Four Dependencies**: A deletion pass over code that had no caller. On the Rust side it drops the provider icon inference table, a placeholder health checker, a second never-wired SSE implementation with its own stream and non-stream handlers, two unused proxy session types, and four unreferenced usage parsers along with a dead cost-calculation entry point — the live billing path, its auto-detecting parsers and session id extraction are untouched. Twenty-two `#[allow(dead_code)]` suppressions, which are why the compiler never flagged any of it, go with them. On the front end, fourteen modules with no importer are deleted, including a prompt form modal and a repository manager both superseded by panel rewrites, a duplicate proxy config hook, a circuit-breaker panel that never had an importer in the project's history, and three schema files; their strings are removed identically from all four locales. The Tauri commands behind them are deliberately kept. Four unused npm dependencies are dropped. Two icon-extraction scripts that regenerated the hand-curated icon index are removed, and the index header now states that automatic regeneration is intentionally unsupported. A companion change consolidates proxy status and takeover state onto a single React Query layer — a second, parallel set of hooks over the same commands had zero call sites, so its query keys never had an observer and the invalidations aimed at them were no-ops. Query key strings are byte-identical and the surviving hook keeps its existing poll behavior. (#5916, #5928)
### Upgrade notes
- No database schema migration in this release — `SCHEMA_VERSION` stays at 16, so no pre-migration backup is triggered.
- Claude Desktop's double-counted usage corrects itself retroactively, but only within the detail-row retention window. The fix suppresses duplicate rows at query time rather than rewriting or deleting anything, so every affected day whose detail rows are still present recovers its correct total on next launch. Detail rows older than 30 days are aggregated into daily rollups and pruned, and a day already rolled up by a build without this fix keeps its inflated figure permanently. The regression entered with v3.18.0 (2026-07-21).
- The eight newly priced models are re-costed retroactively where possible: startup backfills requests that were logged with zero cost, so dashboard totals for those models will rise. Rows already rolled up and pruned stay at zero. The four repriced models work the other way — backfill only touches zero-cost rows, so requests already logged keep the old rate and only new requests bill at the new one; historical and current spend for the same model will differ. Manual price edits, models.dev sync values and deletion tombstones are re-applied after seeding and always win.
- Preset changes apply only to newly created providers. A DeepSeek or Volcengine Ark Coding Plan provider already saved keeps its stored API format, keeps requiring local routing, and keeps its old catalog; re-create it from the preset, or switch the API format in the provider form, to get the direct connection. A provider that is already on native Responses with a `deepseek.com` base URL picks up the mirrored official catalog on its next switch without any re-save, because the gate reads the live configuration.
- `deepseek-v4-pro` cannot be used over the direct connection yet. It stays listed in the preset, and in the mirrored vendor catalog, but DeepSeek has not opened its Codex integration for that model — early August 2026 by their own estimate — so selecting it while the provider is on native Responses fails upstream. Use `deepseek-v4-flash`, which is the preset default and unaffected. To use pro today, switch that provider's API format back to OpenAI Chat and enable local routing takeover: that is the path DeepSeek took before this release, and the proxy's Responses-to-Chat conversion serves pro exactly as it did.
- The mirrored DeepSeek catalog declares a minimum Codex client version of 0.144.0, which CC Switch does not verify — the freeform `apply_patch` registration it carries needs that release or newer. The generated catalog file also grows to roughly 75 KB for the two mirrored models, since each carries the full harness text.
- Because DeepSeek, Volcengine Ark Coding Plan and Tencent Hunyuan no longer require takeover, their traffic can bypass the local proxy entirely, and per-request proxy usage accounting does not see it. The usage itself is neither lost nor indistinguishable — Codex session-log import still records it — but that path carries no provider identity: all Codex usage that did not go through the local proxy is grouped under a single `Codex (Session)` entry, official-subscription consumption included. So a provider moving from routed to direct also moves its usage out from under its own name. Model attribution is unaffected: every row keeps its model id, and the usage panel's per-model table still separates `deepseek-v4-flash`, `hy3`, `ark-code-latest` and the official GPT models into their own rows with their own token and cost figures. Keep local routing takeover enabled only if you need the provider dimension specifically — for instance to compare the same model across several aggregators.
- The stale Codex `auth.json` cleanup is gated on the incoming provider carrying the explicit official category and on the outgoing provider being backfilled successfully. An official entry created by hand without that category, or a switch whose backfill failed, still leaves the residue behind.
- Enabling proxy takeover on a Grok Build provider now rewrites its backend to Responses in the live configuration. The stored provider row is untouched and the live file is restored from its backup when the proxy stops.
- On macOS and Linux, every tool install and update triggered from Settings → About now runs with the login shell's PATH merged ahead of the inherited one, so a binary a lifecycle script resolves by name may resolve differently than before; each action also spawns one extra shell to read that PATH, which executes the user's interactive startup files. Windows is unchanged. Users on grok 0.2.112 or later may see two enumerated installations — the native one plus a global npm package that `grok update` created itself; they are kept in sync upstream and report the same version.
- Environment-conflict detection for Claude Code, Codex and Gemini moved from substring to prefix matching, so variables that merely contain an app's name — `MY_ANTHROPIC_API_KEY`, `OLD_GEMINI_API_KEY` — are no longer reported as conflicts.
- Deep-link import confirmations now reveal less of each secret than before: four leading characters instead of eight, and values of eight characters or fewer are masked entirely.
## [3.19.0] - 2026-07-30
Development since v3.18.0 is headlined by a security-hardening wave and a proxy correctness fix with immediate field impact. On the security side (#5811 and follow-ups): skill installs from GitHub repositories are hardened against zip-slip and repository-coordinate path traversal with hard archive limits; a Gemini common-config credential leak is closed and a one-time startup cleanup scrubs keys that already leaked into other providers' configs; imported SQL backups now run under a SQLite authorizer that denies `ATTACH` and every other statement that can reach outside the import database; common-config snippet handling no longer follows `__proto__` into the global prototype; terminal launch escapes project paths with POSIX single quotes so a directory name can no longer inject commands; and `ccswitch://` import confirmations show every argument, env var, URL, and the complete usage-script body — nothing hidden by truncation, nor by a Base64 variant the dialog could not decode, credential-shaped values masked — flag risky values, and import usage scripts disabled by default. On the proxy side, tool-result images are lifted out of stringified tool text and re-emitted as native media on every conversion bridge, ending the ~9,000× token inflation that pushed Codex sessions past their context window after a few screenshots (#4465, #5663). Usage statistics gain opt-in automatic price sync from models.dev with a persistent local override file (#5734), plus coverage of Grok CLI's official OAuth mode imported from session logs and SuperGrok subscription quota on provider cards. Rounding it out: in-app updates are served from a Cloudflare R2 mirror at `dl.ccswitch.io` with GitHub as fallback, Codex usage import reuses parsed parent rollouts across forked sessions (#5626), preset defaults advance to Claude Opus 5, GPT-5.6 Sol, and Gemini 3.6 Flash, the OpenClaw Kimi For Coding base URL is corrected, and the GPT-in-Claude-Code routing guide is now available in English and Japanese.
+85
View File
@@ -0,0 +1,85 @@
# Pi Extensions 一等支持设计附录
> 状态:仅设计,不含实现。主工程不实现 extensions;themes 明确不在范围内。
> authority:任何上游路径、加载顺序、启用规则或 tool 组合语义,在实施前都必须
> 由 pin `ab366ebe94cacd419d986be454f12b1b9913aaca` 的 oracle 或
> `scripts/pi-transport-capture.mjs` 实际执行确认,本文不以源码阅读代替证据。
## 目标
未来让用户在 cc-switch 中观察、导入、启用和停用 Pi extensions,同时满足:
1. 原生文件/目录是真相,`exists = active`,不制造与 Pi 分叉的 enabled 影子状态;
2. 只接管 cc-switch 明确拥有或用户显式、可验证采用的内容;
3. extension 提供的 tools 与 pinned core tools 分开展示,不伪装为 MCP server;
4. 所有写操作可并发检测、可补偿,portable import 不覆盖目标设备的未知资产;
5. 复用前置 C inspection 与当前 Skill/Prompt 的共享文件、fingerprint、ownership
和协调器原语。
## 模块边界
```text
PiExtensionInspector (只读、oracle 驱动)
├── NativeExtensionObservation
│ path / fingerprint / manifest / contributed tools
│ validity / reasons / ownership
└── PiExtensionCoordinator (唯一写入口)
├── exact-content adoption
├── CAS + atomic replace
├── ownership ledger transaction
├── catalog epoch / UI invalidation
└── compensation on partial failure
```
- `PiExtensionInspector` 只负责原生观察和结构化诊断;不得写数据库或文件。
- `PiExtensionCoordinator` 是唯一写入口;命令、deeplink、portable reconcile 与
UI mutation 都调用它,禁止各自复制目录。
- 通用 `shared_file`、Skill tree fingerprint 与 ownership ledger 可复用;
extension-specific manifest/加载规则必须先新增捕获向量,不能借 Skill 规则猜测。
- gateway 只消费 coordinator 发布后的 immutable runtime snapshotextension
不能在请求中途直接改 candidate/header 计划。
## 状态模型
建议公开三个正交维度:
- `discovery`: `absent | active | invalid | unknown`,只来自 native observation
- `ownership`: `external | adoptable_exact | managed | conflict`
- `capability`: `inspectable | manageable | unsupported | unknown`
不得增加独立 `enabled` 布尔值。用户点击“停用”时,语义是对受管原生资产执行可逆
移除;外部资产只能显式采用后再管理。内容变化导致 fingerprint 不匹配时进入
`conflict`,不得覆盖。
## Tools 与 MCP
- capture 已确认 pinned core tool inventory 为
`bash/edit/find/grep/ls/read/write`;未来 capture 应分别记录每个 extension
注入前后的 tool inventory 与来源。
- UI 将 tools 按 `core` / `extension:<id>` 分组,并展示冲突与覆盖次序的实测结果。
- MCP 页面仍不为 Pi 建虚假 registry。即使某个 extension 通过自身机制连接外部
tool,也属于 extension capability,除非未来 pinned Pi 真正提供 MCP registry
且由新证据和契约明确升级。
## Portable 与冲突策略
- 备份只携带 cc-switch 拥有的 extension 描述、内容 hash 和期望状态,不携带绝对
目录、设备 token 或未知外部目录。
- 导入先观察目标设备;missing 可部署,exact 可采用,different 必须 conflict
绝不“最后写入者获胜”。
- 多 extension 贡献同名 tool、command 或资源时 fail-closed;只有 oracle/capture
证明 Pi 的确定 precedence 且产品明确展示该覆盖时,才允许自动解析。
## 实施前验收
1. 扩展 transport capture:发现路径、空/损坏 manifest、启停、重复 ID、资源覆盖、
tool inventory、相对路径和 symlink 负例。
2. 冻结 schema/oracle provenance,建立 lossless raw observation;未知形状为
`unknown`,不得整目录连坐隐藏合法兄弟。
3. 服务级测试覆盖显式采用、并发 CAS loser、写后补偿、portable reconcile、
外部冲突、目录越界与 UI 的 `exists = active`
4. 中英文 UI 与可访问性完成后,再进入独立实现与盲审。
Themes 不与 extensions 共用该项目:其资源语义、预览与安全面另行立项。
-369
View File
@@ -1,369 +0,0 @@
# CC Switch v3.19.1
> The through-line of this release is **tying off the loose ends of the last one**: three Chinese Codex gateways have been confirmed to support the Responses API natively, so **local routing takeover is no longer required** — the DeepSeek and Volcengine Ark Coding Plan presets move from routed to direct, and the newly added Tencent Hunyuan TokenHub is direct from the start. Four failures you could hit in daily use are fixed: **Claude Desktop usage has been counted twice since v3.18.0** (the historical numbers correct themselves after upgrading, but there is a 30-day window — see "Upgrade Notes"), switching back to the official Codex provider left you stuck on a 401 with no login screen, upgrading Grok Build from Settings failed with nothing but `os error 2`, and enabling takeover on Grok Build returned a straight 404. On top of that, eight models that had been billed at $0 gain built-in pricing, and 39 interface strings have their language problem fixed. This release has **no database migration**, and it is the first release in this project's history that deletes more than it adds.
**[中文版 →](v3.19.1-zh.md) | [日本語版 →](v3.19.1-ja.md)**
---
## Highlights: What You Can Do Now
- **Connect DeepSeek, Volcengine Ark Coding Plan, and Tencent Hunyuan directly inside Codex**: all three vendors' official Codex documentation now confirms their endpoints serve the Responses API natively. The existing DeepSeek and Volcengine Ark Coding Plan presets move from Chat format to native format, so the "needs routing" badge on the provider card and the prompt on switching both disappear and requests no longer pass through the local proxy's protocol conversion; Tencent Hunyuan TokenHub is new in this release and is native from the start. **Note that DeepSeek V4 Pro cannot use the direct connection yet** — the vendor has not opened its Codex integration for that model. Use V4 Flash for direct connections (it is the preset default); see [Upgrade Notes](#deepseek-v4-pro-cannot-use-the-direct-connection-yet).
- **Let DeepSeek use the model catalog DeepSeek itself publishes**: the new "official vendor catalog mirroring" mechanism serves a vendor's published `models.json` verbatim to that vendor's own endpoint, so the freeform `apply_patch` registration and its companion GPT-5 harness stay together instead of being flattened into the neutral template. The match is on host only, never on model name — the same model resold by an aggregator may not implement the same capabilities.
- **Get correct Claude Desktop usage numbers**: since v3.18.0, Claude Desktop traffic through the local gateway has been recorded twice in the dashboard — once from the proxy and once from session-log import — roughly doubling its tokens, cost, and request counts. After this fix, every day whose detail rows are still present returns to the correct number automatically, **with no rebuild required**.
- **Sign in normally after switching back to the official Codex provider**: previously, switching from a third-party provider back to the built-in official Codex entry left the third-party key in `~/.codex/auth.json`. Codex would use it against the official endpoint and get a reliable 401 — and because the file existed, it never fell back to its own login screen, leaving no way out from inside the app.
- **Upgrade Grok Build from the Settings page**: since 0.2.112, `grok update` performs its distribution by calling npm internally, but an app launched from the GUI cannot see node, so the upgrade only ever reported `Error: No such file or directory (os error 2)`.
- **Enable takeover on Grok Build without hitting a 404**: a Grok Build provider whose API format had been changed by hand to OpenAI Chat or Anthropic would, once takeover was enabled, send requests to a route the proxy does not register — a straight 404, with no failover and no usage record. On top of that, every Grok Build request used to be treated as a new session, which disabled both cache-key injection and per-session grouping.
- **See the real cost of eight models that had been billed at $0**: `gpt-5.3-codex-spark`, `gemini-3.5-flash-lite`, `kimi-k2.7-code-highspeed`, `glm-5-turbo`, `glm-5v-turbo`, `qwen3.6-flash`, plus the undated `claude-opus-4-6` / `claude-sonnet-4-6`.
- **Read the About page's tool manager in Traditional Chinese**: 30 strings had only been added for Simplified Chinese, English, and Japanese and were missing for Traditional Chinese; because i18next silently falls back to English, that panel had been half-English since v3.16.0. A further 9 strings showed Simplified Chinese **in every language**.
- **Move back and forth between the official subscription and DeepSeek instead of choosing one**: `auth.json` and `config.toml` are single-slot files — Codex itself cannot hold a second set of credentials. A vendor's one-click script rewrites that configuration to be its own, whereas CC Switch snapshots and restores it per provider — which is the most practical difference between the two approaches; see [the comparison below](#importing-through-cc-switch-vs-running-the-official-script).
---
## Usage Guides
The changes in this release center on how Codex connects and on how usage is counted. The following docs are worth reading alongside it:
- **[Local Routing](../user-manual/en/4-proxy/4.2-routing.md)**: which providers need takeover enabled, and what takeover does. After this release, DeepSeek, Volcengine Ark Coding Plan, and Tencent Hunyuan no longer need it.
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: the usage dashboard's data sources and how the statistics are counted — useful for understanding how the Claude Desktop double count happened and why some historical days cannot be corrected.
- **[Using Chat-Format APIs Like DeepSeek in Codex](../guides/codex-deepseek-routing-guide-en.md)**: this guide explains how local routing converts Responses into Chat Completions. The mechanism still applies to Kimi, MiniMax, SiliconFlow, and other providers that remain Chat-shaped, but **the parts that use DeepSeek as the example no longer apply to this release** — DeepSeek now connects directly and needs no routing.
---
> [!WARNING]
>
> ## Only Official Channels (Please Read)
>
> CC Switch is a **fully free and open-source** desktop app, and we **do not charge users any fees**. Please only obtain the software through the official channels listed below:
>
> | Channel | Only Official |
> | ------------------ | ------------------------------------------------------------------------------ |
> | Website | **[ccswitch.io](https://ccswitch.io)** |
> | Source | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
> | Downloads | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
> | Author | **[@farion1231](https://github.com/farion1231)** |
> | Report an Imposter | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
>
> **Any "CC Switch" website or client that asks you for payment, top-ups, or login credentials is fake.** If you have been tricked into paying, stop the transaction immediately and file a report through GitHub Issues.
---
## Overview
CC Switch v3.19.1 is a maintenance release along three lines. The first is Chinese Codex gateways moving to native Responses as a group: DeepSeek connects directly to `api.deepseek.com` and brings a reusable mechanism with it — mirroring the model catalog a vendor publishes itself, so the freeform `apply_patch` registration and its companion GPT-5 harness stay self-consistent rather than being folded into the neutral template; Volcengine's Ark Coding Plan endpoint `/api/coding/v3` follows now that the official documentation confirms it; and Tencent Hunyuan's TokenHub joins as a new preset. None of the three needs local routing takeover any more.
The second is four failures visible in the field: Claude Desktop usage has been recorded twice since v3.18.0 ([#5938](https://github.com/farion1231/cc-switch/issues/5938)); switching back to the built-in official Codex provider left a third-party `auth.json` behind, producing a 401 with no login screen; `grok update` reported nothing but `os error 2` when run from the GUI; and Grok Build's proxy takeover returned 404 on non-Responses backends while every request was treated as a new session ([#5677](https://github.com/farion1231/cc-switch/pull/5677)). The third is weight loss: 3,166 lines of code with no remaining caller and 4 unused npm dependencies are deleted — this is the first release in the project's history that deletes more than it adds. Beyond that, deep-link import confirmations mask more and truncate less, eight models that had been billed at $0 gain pricing, and four built-in prices are realigned with vendor list prices. This release has **no database schema migration** (the version stays at v16), so upgrading is light.
**Release date**: 2026-07-31
**Change size**: 12 commits | 71 files changed | +2,324 / -3,680 lines
---
## Added
### Official Vendor Model Catalog Mirroring (DeepSeek First)
Codex reads model capabilities from a catalog file, and CC Switch previously generated that catalog from a neutral template for every provider — which is right for an aggregator, but strips the capabilities a vendor's own integration depends on. Now, for any vendor whose official catalog is bundled with the app, that vendor's own file is mirrored directly.
DeepSeek is the first: the bundled file carries the `deepseek-v4-flash` and `deepseek-v4-pro` entries, preserving `apply_patch_tool_type: "freeform"`, `web_search_tool_type: "text"`, `supports_search_tool: true`, the low / high / max reasoning tiers, and the 17,644-character GPT-5 harness in `base_instructions` and `model_messages`**that harness has to travel together with the freeform tool registration**, because the harness itself instructs the model to use `apply_patch`; splitting either half leaves the pair inconsistent.
The gate is deliberately narrow: the provider must resolve to the native Responses profile **and** its `base_url` must be on `deepseek.com`. **Matching is by host, not by model brand** — the same model resold by an aggregator may not implement the same capabilities, and granting by brand would hand capabilities to a service that never implemented them. Entries a provider pins in its own catalog still win; an unrecognized model ID clones the flagship entry but keeps its own name. Catalogs generated for every other profile are byte-identical to before.
### Tencent Hunyuan (TokenHub) Codex Preset
The Codex preset picker gains "Tencent Hunyuan" under the Opensource Official category, between Bailian and StepFun. Selecting it writes `https://tokenhub.tencentmaas.com/v1`, `wire_api = "responses"`, and the `disable_response_storage = true` that TokenHub requires; it declares the two models `hy3` and `hy3-preview` with a **256K** context window (rather than accepting Codex's 128K default), and marks them text-only — Codex will no longer send `view_image` payloads to a model that cannot read them.
Because it is a native Responses provider, Codex connects to the gateway directly with no local routing; the generated catalog uses the neutral native template, which pins `shell_type = "shell_command"` and drops the freeform `apply_patch` registration that native gateways reject. The address manager and latency test have two candidates from the start: the primary domain and the official `.cn` backup. The regionally separate international site is deliberately excluded, because API keys do not carry across sites.
Note that the **API key must be a TokenHub key with Hy3 access enabled**; Coding Plan and Token Plan subscription keys do not work against this endpoint.
### Built-In Pricing for Eight Models That Had Been Billed at $0
`gpt-5.3-codex-spark`, `gemini-3.5-flash-lite`, `kimi-k2.7-code-highspeed` (2x the `kimi-k2.7-code` baseline, following Kimi's Turbo convention), `glm-5-turbo`, `glm-5v-turbo`, and `qwen3.6-flash` had no row at all in the built-in pricing table, and the prefix fallback could not reach them either, so every request against them was recorded at zero cost.
Two more rows — the undated `claude-opus-4-6` and `claude-sonnet-4-6` — close a subtler gap: model ID resolution only **strips** a date suffix, it never **adds** one, so a log carrying an undated ID matched nothing. All eight rows are seeded insert-if-absent, so any price you edited is untouched.
### Grok Build Joins the Failover Tabs and Environment-Conflict Detection
The failover section in Settings gains a fourth Grok Build tab alongside Claude Code, Codex, and Gemini. The environment-conflict banner shown at startup also begins detecting `XAI_API_KEY` and `GROK_DEFAULT_MODEL` — two variables that silently override whichever provider you selected in the app. Detection distinguishes exact names from prefixes, so CC Switch's own `GROK_BIN_DIR` and `GROK_HOME` are not falsely reported.
---
## Changed
### DeepSeek and Volcengine Ark Coding Plan Connect to Codex Directly, With No Local Routing
Both presets were previously marked as OpenAI Chat format, which made both of them "needs takeover": the provider card carried the "needs routing" badge, switching without the proxy running raised a prompt, and every request travelled Codex → local proxy → Responses-to-Chat → upstream.
Both vendors' official Codex integration docs now confirm their endpoints serve the Responses API — DeepSeek's `api.deepseek.com` and Volcengine's `/api/coding/v3` — so both presets are declared native Responses, the badge and the prompt disappear, and Codex connects to the gateway directly. Neither vendor's generated `config.toml` changes (it was already `wire_api = "responses"`); what changes is the catalog generation profile, and for DeepSeek the context window, which is realigned from 1,000,000 to the vendor's own 1,048,576.
BytePlus's international site deliberately stays on Chat routing until its documentation is verified separately. The Volcengine preset also carries a billing note worth knowing: the pay-as-you-go `/api/v3` endpoint **must never** be added to this preset's backup addresses — it bills separately and does not draw down plan quota.
### Catalog Display Name and Context Window Are Now Explicit-Only
Both fields previously carried local defaults — the model ID and a 128,000-token window — applied before the vendor's value had a chance to participate, so a mirrored catalog's 1M window would have been overwritten with 128K. They are now optional, with the fallbacks moved down to entry construction, so "left blank" genuinely means "keep the value the vendor declared". Providers that set the two fields explicitly, and every non-mirrored profile, generate exactly the catalog they did before.
---
## Importing Through CC Switch vs. Running the Official Script
DeepSeek publishes a one-click Codex setup script. It works, it takes backups, and it comes with a restore menu. **If this machine is only ever going to use DeepSeek, running the official script is perfectly fine.** What CC Switch addresses is a different situation: you want to move back and forth between several providers.
### Switching Providers Swaps Login State and Configuration as a Set, With No Manual Backup
`~/.codex/auth.json` and `~/.codex/config.toml` are both **single-slot files** — Codex itself has no multi-credential storage, and one configuration can only describe one provider. When you switch away from a provider, CC Switch snapshots the contents of both files into that provider's record; when you switch back, it writes them back whole. So "ChatGPT subscription → DeepSeek → back to the subscription" normally does not require another `codex login`, and moving between third-party providers requires no manual step at all. Doing the same thing by hand means copying both files before and after every switch; miss it once, and the overwritten OAuth credentials can only be recovered by signing in again.
The official script makes a different trade-off: it rewrites `config.toml` into a DeepSeek-specific configuration — pinning `preferred_auth_method = "apikey"` and `forced_login_method = "api"` at the top level to fix authentication to API key, and **deleting any existing `[profiles.*]` from `config.toml`** (Codex's own built-in mechanism for switching between providers). Your ChatGPT credentials themselves are not deleted — `auth.json` is untouched — but they cannot be used under that configuration; returning to the subscription means running the script's restore menu for a whole-file rollback, and that rollback also discards any hand edits you made to `config.toml` after installation. The script itself can only switch between flash and pro; there is no "move to a third provider" option.
### After Switching Providers, Your Old Sessions Are Still in `codex resume`
Codex sorts its resume list into drawers by the `model_provider` recorded in each session. Every third-party Codex provider CC Switch creates — DeepSeek, Kimi, an aggregator, it makes no difference — writes the same identifier `custom`, so however you switch among them, `codex resume` keeps showing the full history. On its first launch, CC Switch also performs a one-time migration that folds known per-vendor buckets (including the `deepseek` one the official script writes) into this shared bucket, backing the original files up to `~/.cc-switch/backups/` first.
**There is a clear boundary here**: that migration runs once, on CC Switch's first launch. **If you install CC Switch first and only later run the official script**, those `deepseek`-tagged sessions will not be folded in — they stay in their own drawer. Separately, a provider identifier you wrote by hand that is not on the known list is deliberately left alone.
### Official-Subscription Sessions Are Already Interleaved With Third-Party Ones in CC Switch
CC Switch's **Sessions panel scans the session directory directly and does not read `model_provider`**, so Codex sessions produced during official-subscription use have always been in the same list as third-party ones — searchable, resumable, deletable — **with no toggle required**.
If you additionally want **Codex's own `codex resume` list** to merge official and third-party sessions, that is a separate matter: Settings → General → Codex App Enhancements → **"Unified Codex session history"**, off by default. Enabling it affects new sessions only; moving existing official sessions across as well requires ticking "Also migrate existing official session history" in the enable dialog (also unchecked by default). Both are pre-existing features, not new in this release; for the edge cases see the [Unified Codex Session History guide](../guides/codex-unified-session-history-guide-en.md).
> **Two shared prerequisites, stated up front so they do not confuse you later:**
>
> **First, everything above applies to the Codex directory CC Switch points at.** That is `~/.codex` by default and can be changed in Settings. **CC Switch does not read the `CODEX_HOME` environment variable** — if you use that variable to point Codex somewhere else, CC Switch cannot see those sessions, and provider switches will be written into a directory the CLI is not using. To change the directory, use CC Switch's own config-directory setting.
>
> **Second, appearing in the same list does not mean a session can be resumed.** Codex's reasoning content (`encrypted_content`) can only be decrypted by the backend that produced it, so continuing an old session on a different provider may fail — that is upstream's design, not something CC Switch can work around.
---
## Fixed
### Claude Desktop Usage Was Counted Twice
Claude Desktop traffic through the local gateway landed twice in the usage dashboard — once as a proxy row and once as a session-import row — so its tokens, cost, and request counts were roughly doubled.
This is a regression introduced in v3.18.0: the proxy-side dedup ID carried a scope prefix for every app except `claude`, written as `session:{app}:{provider}:{message_id}`, which put `claude-desktop` in its own namespace; meanwhile the session importer kept writing the same Claude message in the bare `session:{message_id}` form with `app_type = 'claude'`. Three dedup defenses failed at once: the primary-key convergence that lets a proxy row absorb an existing session row, the write-side fingerprint probe, and the read-side filter — the latter two both comparing app type with strict equality.
The two apps now share the bare namespace again, and the two comparisons are widened by a one-way rule: a `claude` session row can be absorbed by a `claude-desktop` proxy row, **but not the reverse**. Because the read-side filter is exactly the one daily rollups aggregate through, duplicate rows already in the database stop being counted, **with no row rewritten or deleted** — this self-healing has a retention limit, see "Upgrade Notes". For Codex, Gemini, and OpenCode the widened comparison degenerates to the previous exact match, and quota checks still use strict matching. ([#5938](https://github.com/farion1231/cc-switch/issues/5938), [#5951](https://github.com/farion1231/cc-switch/pull/5951))
### Switching Back to the Official Codex Provider Left You Stuck on a 401 With No Login Screen
With the Codex API-key preservation toggle off (the default), switching to a third-party provider writes that vendor's key into `~/.codex/auth.json`. Switching afterwards to the built-in official provider — whose stored credentials are empty — took the config-only branch, so `config.toml` was replaced while the third-party `OPENAI_API_KEY` stayed on disk as it was. Codex then used that foreign key against the official endpoint and got a reliable 401; and because `auth.json` existed, it never fell back to its own login screen, leaving no way out from inside the app.
Now, after a successful switch to an official Codex provider, if `auth.json` contains only an `OPENAI_API_KEY` with no first-class credential beside it, the file is deleted — an OAuth token, a personal access token, an agent identity, or a Bedrock key all mark the file as genuine and leave it fully intact, while metadata such as `auth_mode`, `last_refresh`, or an account ID can no longer "shield" a stale key.
**Deleting the file rather than writing `{}`** is deliberate: an empty object is read by Codex as ChatGPT mode without tokens and errors at startup, whereas a missing file is equivalent to being signed out and goes straight to the login flow. The cleanup runs only after the previous provider has been successfully backfilled into the database, so the deleted key is not lost — it is stored in that provider's record and comes back when you select it again. The same change also relaxed live-config reads: the post-cleanup state (no `auth.json`, a `config.toml` present) is no longer reported as "Codex is not installed".
### Upgrading Grok Build From the Settings Page Reported Nothing but `os error 2`
Upgrading Grok Build under Settings → About failed with `Error: No such file or directory (os error 2)` and no further information.
The root cause is an asymmetry between the probe path and the execution path: probing goes through a login shell, which reads the user's rc files and therefore sees nvm, Homebrew, and Volta, while lifecycle scripts ran under a non-login shell inheriting the very narrow PATH a GUI-launched app starts with. That would normally not matter, because anchored commands invoke their target by absolute path — but grok 0.2.112 moved self-update onto npm distribution, so `grok update` internally invokes `npm view` and `npm i -g`, and npm in turn resolves node through its shebang. The inner call returned ENOENT, and grok surfaced it verbatim as that `os error 2`.
Lifecycle commands on macOS and Linux now merge the login shell's real PATH ahead of the inherited one, read by executing `/usr/bin/env` rather than echoing the variable — because fish stores PATH as a list, and echoing would yield space-separated segments. For a natively installed Grok, the upgrade chain additionally falls back to the official xAI installer, **deliberately not `npm i -g`**: npm shares both of the primary path's failure modes (no node, a mirror missing the package) and would fail alongside it; the official installer is the only route that does not depend on node, lands in the same place, and rewrites the CLI's own `installer` setting back to `internal`, incidentally healing users an earlier npm fallback had moved onto npm distribution.
### Grok Build Returned 404 With Takeover Enabled, and Every Request Looked Like a New Session
Enabling takeover on a Grok Build provider whose API format had been changed to OpenAI Chat or Anthropic produced an immediate 404, with no failover and no usage record — takeover rewrote the address and the key but left the backend field alone, so the CLI sent requests to a route the proxy does not register. Takeover now pins the backend to Responses as well; the per-provider downgrade to Chat Completions still happens in the forwarding layer, and the forced value is restored along with the whole live-config backup when the proxy stops.
The other problem was that the proxy's session detection recognized only Codex and OpenAI clients, so every Grok Build turn generated a new session ID marked "not client-provided", which suppressed both cache-key injection and per-session grouping in the dashboard. Grok's own headers are now read — the conversation ID first, then the session ID, ignoring the one that changes per request — under a separate prefix, so the records cannot collide with Codex's. ([#5677](https://github.com/farion1231/cc-switch/pull/5677))
### Nine Interface Strings Showed Simplified Chinese in Every Language
Nine strings displayed Simplified Chinese regardless of the interface language, in English and Japanese interfaces alike. Every call site used the "inline default value" form with a Chinese literal as the default, but the corresponding key was in **none** of the four locale files — and i18next walks the whole language chain before it considers an inline default, so the English fallback never got a chance and the Chinese literal won in every language.
The affected strings cover the Grok Build provider form's required-field validation, the failover hover tooltip shown when an app is not yet under takeover, the warning raised when stopping Claude Desktop routing while another app holds takeover along with its reason line, the provider-identifier read failure, the empty Codex common-config error, the routing service's stopped and stop-failed toasts, and the "unpriced" label the usage tables put on requests that carry tokens but compute to zero cost. All nine keys now exist in Simplified Chinese, English, Japanese, and Traditional Chinese. ([#5960](https://github.com/farion1231/cc-switch/pull/5960))
### The Traditional Chinese About Page's Tool Manager Fell Back to English
With the interface language set to Traditional Chinese, the tool management section of the About page displayed English — the version rows, the install and update buttons, the result toasts, the install-conflict diagnosis, and the entire upgrade confirmation dialog. That panel was built out across three separate changes, each adding strings for Simplified Chinese, English, and Japanese only; and because i18next's strategy is to fall back to English rather than error, the 30 missing keys were completely invisible in testing. **This gap had shipped in every release from v3.16.0 through v3.19.0.**
All 30 strings are now translated and the install hint is aligned with the other languages; a new locale test requires every tool-management string to exist in all four languages with matching interpolation variables, so this kind of drift will fail the test suite instead of shipping. ([#5943](https://github.com/farion1231/cc-switch/pull/5943))
### Built-In Pricing Had Drifted From Vendor List Prices
Cost is frozen against the built-in pricing table at the moment a request is logged, so a stale seeded price silently miscounts every subsequent request. Four rows are corrected in this release: `deepseek-chat` and `deepseek-reasoner` are now legacy aliases of V4 Flash at $0.14 input / $0.28 output per million tokens with $0.0028 cache read (previously $0.27/$1.10 and $0.55/$2.19); `minimax-m3` is halved to $0.30/$1.20 per the official standard tier; `gpt-5.6-luna` drops 80% to $0.20/$1.20 and `gpt-5.6-terra` drops 20% to $2/$12 following OpenAI's 2026-07-30 price cut, with `gpt-5.6-sol` deliberately unchanged and the family's cache-write ratio preserved.
The repair only rewrites a row when all four of its price columns **still equal the previous built-in values exactly**, so a price you edited yourself — or one written by models.dev sync — is never touched.
---
## Security Hardening
### Deep-Link Import Confirmations: Stricter Masking, Less Truncation
This continues the `ccswitch://` confirmation hardening from v3.19.0. Config previews are now built by a single shared module that recursively masks secrets inside nested TOML tables and JSON objects, fixing two defects that pointed in opposite directions at once: a Grok Build import **rendered no config preview at all**, while a Codex import **printed embedded `api_key` values in the clear**.
The 300-character truncation on the config preview is gone; the full content now renders in a scrollable box — closing the last place the confirmation could hide part of what it was about to write. The masking itself is stricter everywhere it is used, **including the MCP import confirmation**: sensitive-name matching adds `AUTHORIZATION`, `COOKIE`, and `CREDENTIAL`, plus exact matches for `AUTH` and `BEARER`; the plaintext prefix shown after masking shrinks from 8 characters to 4; and values of 8 characters or fewer are now replaced entirely rather than displayed as they are.
Finally, the frontend's Base64 decoder no longer trims leading and trailing whitespace — which may well be a `+` that URL decoding turned into a space. This is the same class of frontend/backend decoder divergence fixed in v3.19.0: what the confirmation displays is one thing, what the importer writes is another.
---
## Internal
### 3,166 Lines With No Caller, 14 Modules, and 4 Dependencies Removed
A pass over code with no remaining caller. On the backend it deletes the provider icon inference table, a placeholder health checker, a never-wired SSE implementation (with its own streaming and non-streaming handlers), two unused proxy session types, four unreferenced usage parsers, and a dead cost-calculation entry point — **the live billing path, its auto-detecting parsers, and session ID extraction are all untouched**. The 22 `#[allow(dead_code)]` suppressions that had kept the compiler quiet about all of it go too.
On the frontend, 14 modules with no importer are deleted, including a prompt form modal and a repository manager both superseded by panel rewrites, a duplicate proxy config hook, a circuit-breaker panel that never had an importer in the project's history, and three schema files; their strings are removed from all four languages in step. The Tauri commands behind these modules are deliberately kept. Four unused npm dependencies are also dropped. Two scripts that regenerated the hand-curated icon index are removed, and the index file's header now states that automatic regeneration is intentionally unsupported.
A companion change consolidates proxy state and takeover state onto a single query layer — there had been a second parallel set of hooks over the same commands with zero callers, whose query keys never had an observer, so every invalidation aimed at them was a no-op. The query key strings are unchanged character for character, and the surviving hook keeps its existing polling behavior. ([#5916](https://github.com/farion1231/cc-switch/pull/5916), [#5928](https://github.com/farion1231/cc-switch/pull/5928))
---
## Upgrade Notes
### No Database Migration in This Release
v3.19.1 contains no schema migration (the version stays at v16), so no pre-upgrade backup is triggered and the upgrade is ready to use immediately.
### The Claude Desktop Double-Count Self-Heal Has a 30-Day Window (Please Read)
The fix suppresses duplicate rows at query time rather than rewriting or deleting data, so **every day whose detail rows are still present returns to the correct total on the next launch**, with no rebuild needed.
But detail rows older than 30 days are aggregated into daily rollups and pruned, and a rollup is computed once, under whatever rules were in effect at aggregation time. **Any day already aggregated by a build without this fix keeps its inflated number permanently.** The regression entered with v3.18.0 (2026-07-21), so the sooner you upgrade, the more of the historical range is recovered.
### The New Pricing Affects Historical Data in Two Different Ways
The eight newly priced models are **recalculated retroactively**: at startup, requests recorded with zero cost have their cost filled in, so the dashboard numbers for those models will **go up**. Detail rows that were already aggregated and pruned cannot be recalculated and stay at zero.
The four repriced models work in the opposite direction: the backfill only touches zero-cost rows, so requests already recorded keep the old price and only new requests bill at the new one — historical and new spend for the same model will not match. Both paths protect your own pricing: the repair only changes rows still at the original built-in value, while manual edits, models.dev sync values, and deletion tombstones in `~/.cc-switch/model-pricing.json` are replayed after seeding and repair and always win.
### Preset Changes Only Affect Newly Created Providers
An already saved DeepSeek or Volcengine Ark Coding Plan provider keeps the API format it stored, still needs local routing, and still uses the old catalog. To use the direct connection, re-create the provider from the preset, or change the API format to native Responses in the provider form's advanced section.
That said, **a provider already on native Responses whose address is on `deepseek.com` picks up the mirrored official catalog on its next switch**, with no re-save required — because the check reads the live configuration.
### DeepSeek V4 Pro Cannot Use the Direct Connection Yet
The preset still lists `deepseek-v4-pro`, and the vendor's own published catalog carries it too, but **DeepSeek has not opened its Codex integration for pro**; their stated timing is early August 2026. Until then, selecting pro in direct-connection mode fails upstream — use `deepseek-v4-flash`, which is also the preset's default model.
If you need pro right now, change that provider's API format back to "OpenAI Chat" and enable local routing takeover. This is exactly the path DeepSeek used before v3.19.1: the local proxy converts the Responses requests Codex sends into Chat Completions, and pro is unaffected on that route.
### Two Prerequisites for the Official DeepSeek Catalog
The mirrored catalog declares a minimum Codex client version of 0.144.0, and **CC Switch does not verify this itself** — the freeform `apply_patch` registration it carries requires that version or newer. Separately, the generated catalog file grows to roughly 75 KB (for the two mirrored models), because each entry carries the full harness text.
### After Going Direct, Usage Attribution Moves From the Provider Name to `Codex (Session)`
DeepSeek, Volcengine Ark Coding Plan, and Tencent Hunyuan no longer need takeover, so their traffic can bypass the local proxy entirely and the proxy's per-request records no longer see them.
**The usage itself is neither lost nor indistinguishable** — Codex's session-log import records it as before, only that path does not carry provider identity: all Codex usage that did not go through the local proxy is grouped under an entry named `Codex (Session)`, official-subscription consumption included. In other words, once DeepSeek moves from routed to direct, its usage moves out from under the name "DeepSeek" and into `Codex (Session)`.
**To tell them apart, look at the model**: every usage record carries its own model ID, and the usage panel's per-model statistics list them row by row — `deepseek-v4-flash`, `hy3`, `ark-code-latest`, and the official subscription's GPT models each get their own row, with separate cost and token figures. Only when the dimension you actually need is **per provider** (comparing the same model across several aggregators, for instance) do you need to keep using local routing takeover — that route records the real provider name.
### Two Prerequisites for the Stale Codex Credential Cleanup
The cleanup runs only when the incoming provider carries the explicit official category **and** the outgoing provider was backfilled successfully. An entry created by hand without the official category, or a switch whose backfill failed, still leaves the residue on disk.
### Enabling Takeover on Grok Build Rewrites the Backend Field
Enabling takeover on a Grok Build provider now rewrites the backend field in the live configuration to Responses. The provider record stored in the database is unaffected, and the live file is restored in full from its backup when the proxy stops.
### PATH Changes for Tool Installs and Upgrades (macOS / Linux Only)
Every tool install and upgrade triggered from Settings → About now merges the login shell's PATH ahead of the inherited one, so a program a lifecycle script resolves by name may resolve differently than before; each action also starts one extra shell to read that PATH, which executes your interactive startup files. **Windows is unaffected.**
Users on grok 0.2.112 or later may see two installation records — the native one, plus the global npm package `grok update` created itself; upstream keeps them in sync and they report the same version.
### Environment-Conflict Detection Now Matches Differently
Detection for Claude Code, Codex, and Gemini has been tightened from "contains" to "prefix", so variables that merely contain an app name — `MY_ANTHROPIC_API_KEY`, `OLD_GEMINI_API_KEY` — are **no longer reported as conflicts**. Detection for Grok Build was added at the same time.
### Deep-Link Import Confirmations Show Less of Each Secret
The plaintext prefix shown after masking shrinks from 8 characters to 4, and values of 8 characters or fewer are masked entirely. This affects the MCP import confirmation as well.
---
## Risk Notice
### Carried-Over Notices
**xAI Grok OAuth sign-in**: reuses the public OAuth client identity of the official Grok CLI; using it could lead to account restriction or suspension — see the [v3.18.0 release notes](v3.18.0-en.md#risk-notice) for details.
**Codex OAuth reverse proxy**: using a ChatGPT subscription's Codex OAuth through a reverse proxy may violate OpenAI's terms of service. See the [v3.13.0 release notes](v3.13.0-en.md#-risk-notice) for details.
**SuperGrok quota queries**: the quota display on provider cards depends on a non-public billing endpoint at grok.com and may stop working once xAI changes the interface — see the [v3.19.0 release notes](v3.19.0-en.md#risk-notice) for details.
**Third-party provider routing**: when the CC Switch local proxy converts and forwards Codex, Claude Desktop, or Grok Build requests to a third-party provider, each provider has different constraints on billing, compliance, and data retention. Please read the target provider's terms of service before use.
By enabling these features, users accept the associated risks. CC Switch is not responsible for any account restriction, warning, or service suspension resulting from their use.
---
## Thanks
Most of the fixes in this release came from outside contributors — five of the six PRs are not mine.
### Code Contributions
- [#5677](https://github.com/farion1231/cc-switch/pull/5677): finishing off Grok Build's proxy takeover and deep-link integration — the backend field, session identity, the failover tab, and environment-variable detection, plus a fix for the credential leak in config previews along the way. Thanks to @YUZHEthefool. This is the broadest single piece of work in the release.
- [#5951](https://github.com/farion1231/cc-switch/pull/5951): the Claude Desktop double-count fix. Thanks to @Komikawayi. Pinpointing which change in v3.18.0 made all three dedup defenses fail at once was the most patient piece of investigation in this release.
- [#5916](https://github.com/farion1231/cc-switch/pull/5916), [#5928](https://github.com/farion1231/cc-switch/pull/5928): removing 3,166 lines of code with no callers and the duplicate proxy query layer. Thanks to @SaladDay.
- [#5943](https://github.com/farion1231/cc-switch/pull/5943): completing the Traditional Chinese tool-management strings and adding a test that prevents locale drift. Thanks to @yovinchen.
- [#5960](https://github.com/farion1231/cc-switch/pull/5960): completing the 9 strings that showed Simplified Chinese in every language. Thanks to @mhy1227.
### Issue Reports
Thanks to @Alaric-L for reporting in [#5938](https://github.com/farion1231/cc-switch/issues/5938) that every Claude Desktop request produced an extra log row sourced from `session_log`, causing tokens to be counted twice — that report pinpointed the data source, and the most important usage fix in this release was located directly from it.
---
## Download & Install
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) and download the build for your system, or get it from the official site [ccswitch.io](https://ccswitch.io) (downloads are distributed through Cloudflare edge nodes and do not depend on GitHub being reachable).
### System Requirements
| System | Minimum Version | Architecture |
| ------- | -------------------- | ----------------------------------- |
| Windows | Windows 10 and later | x64 / ARM64 |
| macOS | macOS 12 (Monterey)+ | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 / ARM64 |
### Windows
| File | Description |
| ---------------------------------------- | ------------------------------------------------ |
| `CC-Switch-v3.19.1-Windows.msi` | **Recommended** - MSI installer with auto-update |
| `CC-Switch-v3.19.1-Windows-Portable.zip` | Portable build, unzip and run |
Windows ARM64 devices should pick the artifact whose file name carries the `arm64` tag.
### macOS
| File | Description |
| -------------------------------- | ----------------------------------------------------- |
| `CC-Switch-v3.19.1-macOS.dmg` | **Recommended** - DMG installer, drag to Applications |
| `CC-Switch-v3.19.1-macOS.zip` | Unzip and drag to Applications, Universal Binary |
| `CC-Switch-v3.19.1-macOS.tar.gz` | For Homebrew install and auto-update |
Homebrew install:
```bash
brew install --cask cc-switch
```
Upgrade:
```bash
brew upgrade --cask cc-switch
```
### Linux
Linux assets are available for both **x86_64** and **ARM64** (`aarch64`). Choose the file whose architecture tag matches your machine's `uname -m` output:
- `CC-Switch-v3.19.1-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- `CC-Switch-v3.19.1-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` |
-369
View File
@@ -1,369 +0,0 @@
# CC Switch v3.19.1
> 本リリースの主軸は**前バージョンの積み残しを片付けること**です。中国系 Codex ゲートウェイ 3 社が Responses API のネイティブ対応を確認できたため、**ローカルルーティングの引き継ぎが不要**になりました——DeepSeek と火山方舟 Coding Plan のプリセットはローカルルーティング経由から直接接続へ変更、新規追加の Tencent Hunyuan TokenHub は最初から直接接続です。日常的に遭遇しうる 4 つの不具合も修正しました——**Claude Desktop の使用量が v3.18.0 以降 2 重に計上されていた問題**(アップグレード後に過去の数値も自動で正常化しますが 30 日の期限があります。「アップグレード時の注意」を参照)、公式 Codex プロバイダに戻すと 401 のままログイン画面も表示されなくなる問題、設定ページからの Grok Build アップグレードが `os error 2` の一言で失敗する問題、Grok Build で引き継ぎを有効にすると 404 になる問題です。さらに、これまで $0 で計上されていた 8 モデルに組み込み価格を追加し、UI 文言 39 件の言語の問題を修正しました。本リリースに**データベースマイグレーションはなく**、また本プロジェクト史上初めて削除行数が追加行数を上回ったリリースでもあります。
**[English →](v3.19.1-en.md) | [中文版 →](v3.19.1-zh.md)**
---
## ハイライト:本リリースでできること
- **DeepSeek・火山方舟 Coding Plan・Tencent Hunyuan を Codex から直接接続する**:3 社の公式 Codex ドキュメントで、エンドポイントが Responses API をネイティブ提供していることが確認できました。DeepSeek と火山方舟 Coding Plan の既存プリセットは Chat 形式からネイティブ形式に変わり、プロバイダカードの「ルーティングが必要」バッジと切り替え時の確認メッセージがなくなり、リクエストはローカルプロキシのプロトコル変換を通らなくなります。Tencent Hunyuan TokenHub は本リリースで追加されたプリセットで、最初からネイティブ形式です。**DeepSeek V4 Pro はまだ直接接続で使えない点にご注意ください**——ベンダー側で Codex 連携がまだ開放されていません。直接接続では V4 Flash(プリセットの既定モデル)をご利用ください。詳細は[アップグレード時の注意](#deepseek-v4-pro-はまだ直接接続で使えません)を参照してください。
- **DeepSeek に DeepSeek 自身が公開したモデルカタログを使わせる**:新しい「公式ベンダーカタログのミラーリング」機構は、ベンダーが公開している `models.json` をそのまま当該ベンダーのエンドポイント向けに配信します。これにより freeform な `apply_patch` の登録と、対になる GPT-5 ハーネスがセットのまま保持され、中立テンプレートに押し込められることがなくなります。判定はホスト名のみで行い、モデル名では行いません——同じモデルでも、再販するアグリゲータが同じ機能を実装しているとは限らないためです。
- **Claude Desktop の正しい使用量を得る**:v3.18.0 以降、ローカルゲートウェイ経由の Claude Desktop のトラフィックはダッシュボードに 2 回記録されていました——1 回はプロキシから、もう 1 回はセッションログの取り込みから——token・費用・リクエスト数がおよそ 2 倍になっていました。本リリースの修正後、明細行が残っている日は自動的に正しい数値に戻ります。**再構築の操作は不要です。**
- **公式 Codex プロバイダに戻した後に正常にログインする**:これまでサードパーティのプロバイダから組み込みの公式 Codex エントリに戻すと、サードパーティのキーが `~/.codex/auth.json` に残っていました。Codex はそのキーで公式エンドポイントにリクエストし、確実に 401 になります——しかもファイルが存在するため Codex 自身のログイン画面にも戻らず、アプリ内から抜け出す手段がありませんでした。
- **設定ページから Grok Build をアップグレードする**:`grok update` は 0.2.112 以降、内部で npm を呼び出して配布を行うようになりましたが、GUI から起動したアプリからは node が見えないため、アップグレードは `Error: No such file or directory (os error 2)` の一言で失敗していました。
- **Grok Build で 404 にならずに引き継ぎを有効にする**:API 形式を手動で OpenAI Chat または Anthropic に変更した Grok Build プロバイダで引き継ぎを有効にすると、プロキシが登録していないルートにリクエストが送られ、そのまま 404 になっていました。フェイルオーバーも使用量の記録もありません。さらに Grok Build のリクエストはこれまで毎回新しいセッションとして扱われ、キャッシュキーの注入とセッション単位の集計がどちらも効かなくなっていました。
- **これまで $0 で計上されていた 8 モデルの実際のコストを見る**:`gpt-5.3-codex-spark``gemini-3.5-flash-lite``kimi-k2.7-code-highspeed``glm-5-turbo``glm-5v-turbo``qwen3.6-flash`、および日付サフィックスのない `claude-opus-4-6` / `claude-sonnet-4-6` です。
- **繁体字中国語の UI で「バージョン情報」ページのツール管理を読む**:30 件の文言が簡体字中国語・英語・日本語にしか追加されておらず、繁体字中国語が漏れていました。i18next は黙って英語にフォールバックするため、このパネルは v3.16.0 以降ずっと半分英語のままでした。さらに 9 件の文言は**すべての言語で**簡体字中国語が表示されていました。
- **公式サブスクリプションと DeepSeek を二者択一ではなく行き来する**:`auth.json``config.toml` はどちらもスロットが 1 つしかないファイルで、Codex 自身は 2 組目の認証情報を保持できません。ベンダーのワンクリックスクリプトはこの設定を自分専用に書き換えますが、CC Switch はプロバイダごとにまとめてスナップショットして復元します——これが公式スクリプトとの最も実際的な違いです。詳細は[後述の比較](#cc-switch-経由の導入と公式スクリプトの違い)を参照してください。
---
## 利用ガイド
本リリースの変更は Codex の接続方式と使用量の集計基準に集中しています。以下のドキュメントとあわせてお読みください:
- **[ローカルルーティング](../user-manual/ja/4-proxy/4.2-routing.md)**:どのプロバイダで引き継ぎが必要か、引き継ぎが何をするか。本リリース以降、DeepSeek・火山方舟 Coding Plan・Tencent Hunyuan には不要になります。
- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**:使用量ダッシュボードのデータソースと集計基準。Claude Desktop の 2 重計上がどう起きたか、修正後もなぜ一部の過去日を戻せないかを理解する助けになります。
- **[Codex で DeepSeek のような Chat 形式 API を使う](../guides/codex-deepseek-routing-guide-ja.md)**:このガイドはローカルルーティングが Responses を Chat Completions に変換する仕組みを説明したものです。仕組みの部分は Kimi・MiniMax・SiliconFlow など Chat 形式のままのプロバイダには引き続き当てはまりますが、**DeepSeek を例として扱っている部分は本リリースには当てはまりません**——DeepSeek は直接接続になり、ルーティングは不要です。
---
> [!WARNING]
>
> ## 唯一の公式チャネル(必ずお読みください)
>
> CC Switch は**完全に無料・オープンソース**のデスクトップアプリで、**ユーザーから料金を徴収することはありません**。本ソフトウェアは下記の公式チャネルからのみ入手してください:
>
> | チャネル | 唯一の公式 |
> | ------------ | ------------------------------------------------------------------------------ |
> | 公式サイト | **[ccswitch.io](https://ccswitch.io)** |
> | ソースコード | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
> | ダウンロード | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
> | 偽サイト通報 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
>
> **料金請求・チャージ・認証情報の提供を求める「CC Switch」サイトやクライアントはすべて偽物です。** 支払いを誘導された場合は直ちに操作を中止し、GitHub Issues からご報告ください。
---
## 概要
CC Switch v3.19.1 はメンテナンスリリースで、軸は 3 つあります。1 つ目は中国系 Codex ゲートウェイがまとめてネイティブ Responses に移行したことです。DeepSeek は `api.deepseek.com` に直接接続し、あわせて再利用可能な機構をもたらしました——ベンダー自身が公開するモデルカタログをそのままミラーリングして配信し、freeform な `apply_patch` の登録と対になる GPT-5 ハーネスが中立テンプレートに畳み込まれず、整合したまま保たれます。火山方舟の Coding Plan エンドポイント `/api/coding/v3` も公式ドキュメントでの確認を経て追随し、Tencent Hunyuan の TokenHub が新しいプリセットとして加わりました。3 つとも、もうローカルルーティングの引き継ぎを有効にする必要はありません。
2 つ目は実際に遭遇しうる 4 つの不具合の修正です。Claude Desktop の使用量が v3.18.0 以降 2 回記録されていた問題([#5938](https://github.com/farion1231/cc-switch/issues/5938))、組み込みの公式 Codex プロバイダに戻すとサードパーティの `auth.json` が残り、401 になったうえログイン画面も出なくなる問題、`grok update` が GUI 環境では `os error 2` としか報告しない問題、そして Grok Build のプロキシ引き継ぎが Responses 以外のバックエンドで 404 になり、かつ毎回のリクエストが新規セッション扱いになっていた問題([#5677](https://github.com/farion1231/cc-switch/pull/5677))です。3 つ目は減量です。呼び出し元が 1 つもないコード 3,166 行と、未使用の npm 依存 4 件を削除しました——本プロジェクト史上初めて、削除行数が追加行数を上回ったリリースです。このほか、ディープリンクのインポート確認ダイアログはマスクをより厳しく、切り詰めをより少なくし、$0 で計上されていた 8 モデルに価格を追加し、組み込み価格 4 件をベンダーの定価に合わせ直しました。本リリースに**データベーススキーマのマイグレーションはなく**(バージョンは v16 のまま)、アップグレードは軽量です。
**リリース日**2026-07-31
**変更規模**12 commits | 71 files changed | +2,324 / -3,680 lines
---
## 追加機能
### 公式ベンダーモデルカタログのミラーリング(DeepSeek が第 1 号)
Codex はカタログファイルからモデルの機能を読み取りますが、CC Switch はこれまでどのプロバイダに対しても中立テンプレートからカタログを生成していました。アグリゲータにはこれが正しいものの、ベンダー自身の連携が前提としている機能が落ちてしまいます。本リリースからは、公式カタログをアプリに同梱しているベンダーについては、そのベンダー自身のファイルをそのまま配信します。
DeepSeek が第 1 号です。同梱ファイルは `deepseek-v4-flash``deepseek-v4-pro` の 2 エントリを持ち、`apply_patch_tool_type: "freeform"``web_search_tool_type: "text"``supports_search_tool: true`、low / high / max の 3 段階の推論レベル、そして `base_instructions``model_messages` に入っている 17,644 文字の GPT-5 ハーネスを保持します——**このハーネスは freeform なツール登録とセットで扱う必要があります**。ハーネス自体がモデルに `apply_patch` の使用を指示しているため、どちらか一方だけを取り出すと整合しなくなるからです。
判定条件は意図的に狭くしてあります。プロバイダがネイティブ Responses の区分に該当し、**かつ** `base_url``deepseek.com` 上にあることが条件です。**判定はホスト名で行い、モデルのブランド名では行いません**——同じモデルを再販するアグリゲータが同じ機能を実装しているとは限らず、ブランド名で付与することは、実装していないサービスに機能を与えることになるからです。プロバイダが自身のカタログで明示した項目は引き続き優先されます。未知のモデル ID の場合はフラッグシップのエントリを複製したうえで、そのモデル自身の名称を保ちます。他のすべての区分で生成されるカタログは、変更前とバイト単位で同一です。
### Tencent HunyuanTokenHubCodex プリセット
Codex のプリセット選択に「Tencent Hunyuan」が追加され、「オープンソース公式」カテゴリの Bailian と StepFun の間に入ります。選択すると `https://tokenhub.tencentmaas.com/v1``wire_api = "responses"`、および TokenHub が必須としている `disable_response_storage = true` が書き込まれます。モデルは `hy3``hy3-preview` の 2 つを宣言し、コンテキストウィンドウは **256K**Codex の既定である 128K を受け入れるのではなく)、さらにテキスト専用として印を付けます——Codex が `view_image` の画像ペイロードを、画像を読めないモデルに送ることはなくなります。
ネイティブ Responses のプロバイダであるため、Codex はローカルルーティングなしでゲートウェイに直接接続します。生成されるカタログは中立のネイティブテンプレートを使い、`shell_type = "shell_command"` を固定し、ネイティブゲートウェイが受け付けない freeform な `apply_patch` の登録を外します。アドレス管理と速度計測には最初から 2 つの候補があります——メインドメインと、公式のバックアップである `.cn` ドメインです。地域が独立している国際サイトは、API キーがサイト間で共通ではないため意図的に除外しています。
**API キーは Hy3 の権限が有効な TokenHub のキーである必要がある**点にご注意ください。Coding Plan や Token Plan のサブスクリプションキーは、このエンドポイントでは利用できません。
### $0 で計上されていた 8 モデルに組み込み価格を追加
`gpt-5.3-codex-spark``gemini-3.5-flash-lite``kimi-k2.7-code-highspeed`Kimi の Turbo の慣例に従い `kimi-k2.7-code` の基準価格の 2 倍)、`glm-5-turbo``glm-5v-turbo``qwen3.6-flash` は組み込み価格テーブルに行そのものが存在せず、プレフィックスによるフォールバックでも届かなかったため、これらへのリクエストはすべてコスト 0 として記録されていました。
さらに 2 行——日付サフィックスのない `claude-opus-4-6``claude-sonnet-4-6`——は、より分かりにくい隙間を塞ぐものです。モデル ID の解決は日付サフィックスを**取り除く**ことしかせず、**付け足す**ことはないため、日付なしの ID を持つログはどれにも一致しませんでした。8 行はいずれも「存在しない場合のみ挿入」で投入されるため、変更済みの価格は影響を受けません。
### Grok Build がフェイルオーバータブと環境変数の競合検出に加わりました
設定ページのフェイルオーバーに、Claude Code・Codex・Gemini に続く 4 つ目の Grok Build タブが追加されました。起動時の環境変数競合バナーも `XAI_API_KEY``GROK_DEFAULT_MODEL` の検出を開始します——この 2 つはアプリで選んだプロバイダを黙って上書きしてしまう変数です。検出は完全一致とプレフィックスを区別するため、CC Switch 自身が使う `GROK_BIN_DIR``GROK_HOME` が誤検出されることはありません。
---
## 変更
### DeepSeek と火山方舟 Coding Plan が Codex に直接接続、ローカルルーティング不要に
両プリセットはこれまで OpenAI Chat 形式として扱われていたため、どちらも「引き継ぎが必要」でした。プロバイダカードには「ルーティングが必要」バッジが付き、プロキシを起動せずに切り替えると確認が出て、リクエストは毎回 Codex → ローカルプロキシ → Responses から Chat への変換 → 上流、という経路をたどっていました。
両社の公式 Codex 連携ドキュメントで、エンドポイントが Responses API を提供していることが確認できました——DeepSeek の `api.deepseek.com` と火山方舟の `/api/coding/v3` です。これに伴い両プリセットはネイティブ Responses として宣言され、バッジと確認メッセージはなくなり、Codex はゲートウェイに直接接続します。両社とも生成される `config.toml` に変化はありません(もともと `wire_api = "responses"` でした)。変わるのはカタログの生成区分と、DeepSeek についてはコンテキストウィンドウで、1,000,000 からベンダー自身の 1,048,576 に合わせ直しました。
BytePlus の国際サイトは、ドキュメントを個別に確認するまで意図的に Chat ルーティングのままとします。火山方舟のプリセットには、知っておく価値のある課金上の注記も残しました。従量課金の `/api/v3` エンドポイントは、このプリセットのバックアップアドレスに**絶対に追加してはいけません**——別勘定で課金され、プランの残量を消費しないためです。
### カタログの表示名とコンテキストウィンドウが「明示した場合のみ有効」に
この 2 つのフィールドには、これまでローカルの既定値——モデル ID と 128,000 のウィンドウ——が付いており、しかもベンダーの値が関与する前に適用されていたため、ミラーリングしたカタログの 1M のウィンドウが 128K に上書きされてしまう状態でした。現在この 2 つは省略可能になり、フォールバックはエントリ構築の層まで下ろされたため、「空のままにする」ことが本当に「ベンダーが宣言した値を使う」を意味するようになりました。この 2 つを明示したプロバイダ、およびミラーリング以外のすべての区分では、生成されるカタログは以前とまったく同じです。
---
## CC Switch 経由の導入と公式スクリプトの違い
DeepSeek は Codex のワンクリック導入スクリプトを公開しています。これはきちんと動き、バックアップも取り、復元メニューも備えています。**このマシンで DeepSeek だけを使うつもりなら、公式スクリプトを実行して何の問題もありません。** CC Switch が扱うのは別の場面です——複数のプロバイダを行き来したい場合です。
### プロバイダを切り替えるとき、ログイン状態と設定がまとめて入れ替わる(自分でバックアップする必要はありません)
`~/.codex/auth.json``~/.codex/config.toml` はどちらも**スロットが 1 つしかないファイル**です——Codex 自身に複数の認証情報を保持する仕組みはなく、1 つの設定は 1 つのプロバイダにしか対応できません。CC Switch はあるプロバイダから切り替えるとき、この 2 ファイルの内容をまとめてそのプロバイダのレコードにスナップショットし、戻すときにまとめて書き戻します。そのため「ChatGPT サブスクリプション → DeepSeek → サブスクリプションに戻る」で通常 `codex login` をやり直す必要はなく、サードパーティ間の行き来には手作業がまったく不要です。同じことを手作業で行う場合は、切り替えのたびに前後でこの 2 ファイルをコピーする必要があり、一度でも漏らせば、上書きされた OAuth の認証情報はログインし直す以外に取り戻せません。
公式スクリプトのトレードオフは異なります。`config.toml` を DeepSeek 専用の設定に作り替え——トップレベルに `preferred_auth_method = "apikey"``forced_login_method = "api"` を固定して認証方式を API キーに固定し、さらに **`config.toml` にすでにある `[profiles.*]` を削除します**(Codex 自身が持つ、複数プロバイダを切り替える仕組みです)。ChatGPT のログイン認証情報そのものは削除されず、`auth.json` はそのまま残りますが、その設定のもとでは使えません。サブスクリプションに戻すにはスクリプトの復元メニューで全体をロールバックする必要があり、そのロールバックではインストール後に `config.toml` へ加えた手動の変更もあわせて失われます。スクリプト自体も flash と pro の切り替えしかできず、「3 つ目のプロバイダに移る」という選択肢はありません。
### プロバイダを切り替えた後も、`codex resume` に以前のセッションが残る
Codex は各セッションに記録された `model_provider` によって、再開一覧を引き出しのように分けています。CC Switch が作成するサードパーティの Codex プロバイダは——DeepSeek でも Kimi でもアグリゲータでも——すべて同じ識別子 `custom` を書き込むため、それらの間をどう切り替えても `codex resume` からは常に全履歴が見えます。CC Switch は初回起動時に一度だけ移行処理も行い、ベンダーごとに分かれていた既知の古いセッション(公式スクリプトが書き込む `deepseek` もこれに含まれます)をこの共有の引き出しへまとめます。その際、元のファイルは先に `~/.cc-switch/backups/` へバックアップされます。
**ここには明確な境界があります**。この移行は CC Switch の初回起動時に一度だけ実行されます。**先に CC Switch をインストールし、その後で公式スクリプトを実行した場合**、`deepseek` の識別子を持つそれらのセッションはもうまとめられず、自分の引き出しに残ったままになります。また、手書きで指定した、既知の一覧にないプロバイダ識別子は、CC Switch が意図的に変更しません。
### 公式サブスクリプションのセッションは、CC Switch ではもともとサードパーティと同じ一覧に並んでいます
CC Switch の**セッション管理パネルはセッションディレクトリを直接スキャンし、`model_provider` を読みません**。そのため公式サブスクリプションの利用中に生まれた Codex セッションは、以前からサードパーティのセッションと同じ一覧にあり、検索・再開・削除ができます——**どのスイッチも有効にする必要はありません**。
さらに **Codex 自身の `codex resume` の一覧**でも公式とサードパーティをまとめたい場合は、それは別の話になります。設定 → 一般 → Codex アプリ拡張 → **「Codex セッション履歴を統一」**で、既定はオフです。有効にすると新しいセッションのみが対象になります。既存の公式セッションもあわせて移すには、有効化の確認ダイアログで「既存の公式セッション履歴もあわせて移行する」にチェックを入れる必要があります(こちらも既定ではチェックなしです)。この 2 つはどちらも既存機能で、本リリースでの新規追加ではありません。境界となるケースについては[「Codex セッション履歴の統一」ガイド](../guides/codex-unified-session-history-guide-ja.md)を参照してください。
> **共通の前提が 2 つあります。後で戸惑わないよう先に明記します:**
>
> **1. すべては CC Switch が参照している Codex ディレクトリが基準です。** 既定は `~/.codex` で、設定から変更できます。**CC Switch は `CODEX_HOME` 環境変数を読みません**——この変数で Codex を別の場所に向けている場合、そちらのセッションは CC Switch からは見えず、プロバイダの切り替えも CLI が使っていないディレクトリに書き込まれます。ディレクトリを変更するときは、CC Switch 自身の「設定ファイルのディレクトリ」設定をお使いください。
>
> **2. 同じ一覧に並ぶことは、必ず再開できることを意味しません。** Codex の推論内容(`encrypted_content`)は、それを生成したバックエンドでしか復号できないため、別のプロバイダで以前のセッションを続けようとすると失敗することがあります——これは上流の設計であり、CC Switch が回避できるものではありません。
---
## 修正
### Claude Desktop の使用量が 2 回計上されていた問題
ローカルゲートウェイ経由の Claude Desktop のトラフィックは、使用量ダッシュボードに 2 回入っていました——1 回はプロキシの行、もう 1 回はセッション記録の取り込みの行です——その結果、token・費用・リクエスト数がおよそ 2 倍になっていました。
これは v3.18.0 で入った回帰です。プロキシ側の重複排除 ID は `claude` 以外のすべてのアプリにスコープのプレフィックスを付け、`session:{アプリ}:{プロバイダ}:{メッセージID}` の形にしていました。これにより `claude-desktop` が独立した名前空間に入る一方、セッションの取り込み側は同じ Claude のメッセージを、素の `session:{メッセージID}` の形かつ `app_type = 'claude'` で書き続けていました。その結果、3 つの重複排除の防御が同時に破られました——プロキシの行が既存のセッション行を吸収するための主キーの収束、書き込み側のフィンガープリント照合、読み取り側のフィルタです。後の 2 つはどちらもアプリ種別を厳密な等価比較で判定していました。
現在は 2 つのアプリが再び素の名前空間を共有し、2 か所の比較は一方向の規則で緩和されています。`claude` のセッション行は `claude-desktop` のプロキシ行に吸収されうる一方、**その逆は成立しません**。読み取り側のフィルタは日次ロールアップの集計が通るものと同一であるため、すでに保存済みの重複行も計上されなくなります。**行の書き換えも削除も行いません**——この自動修復には保持期間の制限があります。「アップグレード時の注意」を参照してください。Codex・Gemini・OpenCode では緩和後の比較はもとの厳密一致に退化し、上限チェックは引き続き厳密一致を使います。([#5938](https://github.com/farion1231/cc-switch/issues/5938)、[#5951](https://github.com/farion1231/cc-switch/pull/5951)
### 公式 Codex プロバイダに戻すと 401 のまま、ログイン画面も出ない問題
Codex の API キー保持スイッチがオフのとき(既定はオフです)、サードパーティのプロバイダに切り替えると相手のキーが `~/.codex/auth.json` に書き込まれます。その後、組み込みの公式プロバイダ——保存されている認証情報は空です——に切り替えると「設定だけを書く」分岐を通るため、`config.toml` は置き換えられる一方、サードパーティの `OPENAI_API_KEY` はそのままディスクに残りました。Codex はその外部のキーで公式エンドポイントにリクエストして確実に 401 になり、さらに `auth.json` が存在するため自身のログイン画面にも戻らず、アプリ内には出口がありませんでした。
現在は、公式 Codex プロバイダへの切り替えが成功した後、`auth.json` の中身が `OPENAI_API_KEY` のみで、その横に第一級の認証情報が何もない場合、このファイルを削除します——OAuth トークン、パーソナルアクセストークン、agent の識別情報、Bedrock のキーのいずれかがあれば本物の認証情報であることを示すため、完全に保持されます。一方、`auth_mode``last_refresh`・アカウント ID のような単なるメタデータは、古くなったキーを「かばう」ことがもうできません。
**`{}` を書き込むのではなくファイルを削除する**のは意図的な選択です。空のオブジェクトは Codex にトークンのない ChatGPT モードと判定され、起動時にエラーになります。ファイルがない状態こそが未ログインと等価で、そのままログインの流れに入ります。このクリーンアップは、以前のプロバイダがデータベースへ正常に書き戻された後にのみ実行されるため、削除されたキーは失われていません——そのプロバイダのレコードに保存されており、再び選択すれば戻ってきます。同じ変更でライブ設定の読み取りも緩和され、クリーンアップ後の状態(`auth.json` がなく `config.toml` がある)が「Codex がインストールされていません」と報告されることはなくなりました。
### 設定ページからの Grok Build アップグレードが `os error 2` の一言で失敗する問題
設定 → バージョン情報から Grok Build をアップグレードすると、`Error: No such file or directory (os error 2)` で失敗し、それ以外の情報は何も出ませんでした。
根本原因は、検出の経路と実行の経路の非対称性です。検出はログインシェルを通るためユーザーの rc ファイルを読み、nvm・Homebrew・Volta が見えます。一方、ライフサイクルのスクリプトは非ログインシェルで実行され、GUI アプリの起動時に受け継がれる非常に狭い PATH を引き継ぎます。本来これは問題になりません。アンカーされたコマンドは対象のプログラムを絶対パスで呼び出すからです——しかし grok 0.2.112 は自己更新を npm 配布に移し、`grok update` は内部で `npm view``npm i -g` を呼び出すようになりました。そして npm 自身は shebang を通じて node を解決します。内側の呼び出しが ENOENT を返し、grok はそれをそのまま `os error 2` として投げていました。
現在、macOS と Linux のライフサイクルコマンドは、ログインシェルの実際の PATH を、引き継いだ PATH の前に結合します。読み取りは変数をエコーするのではなく `/usr/bin/env` を実行して行います——fish は PATH をリストとして保持しており、エコーするとスペース区切りの断片になってしまうためです。ネイティブインストールの Grok については、アップグレードの連鎖に公式 xAI インストーラーをフォールバックとして追加しました。**あえて `npm i -g` は使いません**:npm は主経路と同じ 2 つの失敗モード(node がない、ミラーにパッケージがない)を共有しており、一緒に失敗するからです。公式インストーラーは node に依存しない唯一の経路で、配置先も同じであり、さらに CLI 自身の `installer` 設定を `internal` に書き戻すため、初期の npm フォールバックによって npm 配布に切り替わってしまったユーザーも、ついでに元に戻ります。
### Grok Build で引き継ぎを有効にすると 404 になり、毎回のリクエストが新規セッションのように扱われる問題
API 形式を OpenAI Chat または Anthropic に変更した Grok Build プロバイダで引き継ぎを有効にすると、ただちに 404 になり、フェイルオーバーも使用量の記録もありませんでした——引き継ぎはアドレスとキーを書き換える一方、バックエンドのフィールドには手を付けなかったため、CLI がプロキシの登録していないルートにリクエストを送っていたのです。現在は引き継ぎがバックエンドも Responses に固定します。個別のプロバイダに応じて Chat Completions へ落とす処理は引き続き転送層で行われ、この強制された値はプロキシ停止時にライブ設定のバックアップとともに元へ戻ります。
もう 1 つの問題は、プロキシのセッション判定が Codex と OpenAI のクライアントしか認識していなかったことです。そのため Grok Build のリクエストは毎回新しいセッション ID を生成して「クライアント提供ではない」と印を付けられ、キャッシュキーの注入とダッシュボードのセッション単位の集計がどちらも効かなくなっていました。現在は Grok 自身のヘッダを読みます——まずセッションが属する会話の ID、次にセッション ID を見て、リクエストごとに変わる ID は無視します——さらに専用のプレフィックスを使い、Codex の記録と衝突しないようにしています。([#5677](https://github.com/farion1231/cc-switch/pull/5677)
### 9 件の UI 文言がすべての言語で簡体字中国語を表示していた問題
9 件の文字列が、UI の言語にかかわらず簡体字中国語で表示されていました。英語や日本語の UI でも同様です。各呼び出し箇所は「インラインの既定値」の書き方を使い、既定値には中国語のリテラルが入っていましたが、対応するキーは 4 つの言語ファイルの**どれにも存在しませんでした**——そして i18next は言語チェーンをすべてたどってからインラインの既定値を検討するため、英語へのフォールバックが働く機会がそもそもなく、中国語のリテラルがすべての言語で勝っていました。
対象の文言は、Grok Build プロバイダフォームの必須項目チェック、アプリがまだ引き継がれていないときのフェイルオーバーのホバー説明、別のアプリが引き継ぎを保持している状態で Claude Desktop のルーティングを停止しようとしたときの警告とその理由の説明、プロバイダ識別子の読み取り失敗、Codex 共通設定が空のときのエラー、ルーティングサービスの停止と停止失敗の 2 つの通知、そして使用量テーブルで「token はあるがコストが 0 と算出された」リクエストに付く「未計算」のラベルにわたります。9 件のキーは現在、簡体字中国語・英語・日本語・繁体字中国語のすべてに存在します。([#5960](https://github.com/farion1231/cc-switch/pull/5960)
### 繁体字中国語の「バージョン情報」ページでツール管理が英語にフォールバックしていた問題
UI の言語を繁体字中国語に設定すると、「バージョン情報」ページのツール管理の領域が英語で表示されていました——バージョンの行、インストールと更新のボタン、結果の通知、インストール競合の診断、そして更新確認ダイアログ全体です。このパネルは 3 回の変更を経て段階的に作られましたが、そのたびに簡体字中国語・英語・日本語だけが追加されていました。i18next の方針はエラーにせず英語へフォールバックすることなので、欠けていた 30 件のキーはテストからまったく見えませんでした。**この欠落は v3.16.0 から v3.19.0 までずっと出荷され続けていました。**
30 件の文言はすべて翻訳され、インストールの説明も他の言語と揃えました。新しく追加した言語テストは、ツール管理のすべての文言が 4 言語に存在し、かつ補間変数が一致することを要求するため、この種のずれは今後リリースされる前にテストで失敗します。([#5943](https://github.com/farion1231/cc-switch/pull/5943)
### 組み込み価格がベンダーの定価とずれていた問題
コストはログに書き込む時点で組み込み価格テーブルに従って確定するため、古い初期値は以後のすべてのリクエストを黙って誤って計算します。本リリースでは 4 行を修正しました。`deepseek-chat``deepseek-reasoner` は V4 Flash の旧称エイリアスとなり、100 万 token あたり入力 $0.14 / 出力 $0.28、キャッシュ読み取り $0.0028 になりました(従来は $0.27/$1.10 と $0.55/$2.19)。`minimax-m3` は公式の標準区分に合わせて半額の $0.30/$1.20 に。`gpt-5.6-luna` は OpenAI の 2026-07-30 の値下げに合わせて 80% 引き下げて $0.20/$1.20、`gpt-5.6-terra` は 20% 引き下げて $2/$12 とし、`gpt-5.6-sol` は意図的に据え置き、このシリーズのキャッシュ書き込みの比率も維持しています。
この修正は、行の 4 つの価格列が**すべて従来の組み込み値と等しいままである場合にのみ**書き換えます。そのため、自分で変更した価格——あるいは models.dev の同期によって書き込まれた価格——が触られることはありません。
---
## セキュリティ強化
### ディープリンクのインポート確認:マスクをより厳しく、切り詰めをより少なく
これは v3.19.0 の `ccswitch://` 確認ダイアログ強化の続きです。設定のプレビューは共有モジュールで一元的に構築するようになり、入れ子になった TOML テーブルや JSON オブジェクトの中の秘匿値も再帰的にマスクします。これにより、方向の異なる 2 つの欠陥が同時に解消されました。Grok Build のインポートはこれまで設定のプレビューが**まったく描画されず**、Codex のインポートは埋め込まれた `api_key` を**平文で表示**していました。
設定プレビューにあった 300 文字の切り詰めは撤廃され、全文がスクロール可能な枠内に描画されるようになりました——確認ダイアログが「これから何を書き込むか」を隠しうる最後の箇所が塞がれます。マスク自体も、使われているすべての箇所で厳しくなりました。**MCP のインポート確認ダイアログも含みます**:秘匿キー名の照合に `AUTHORIZATION``COOKIE``CREDENTIAL` と、完全一致の `AUTH``BEARER` を追加し、マスク後に表示される平文のプレフィックスは 8 文字から 4 文字に縮小、長さが 8 文字以下の値は、そのまま表示するのではなく全体を置き換えるようになりました。
最後に、フロントエンドの Base64 デコーダーは前後の空白を切り落とさなくなりました——それは URL デコードによって `+` が空白になったものである可能性があるためです。これは v3.19.0 で修正したのと同じ種類の、フロントエンドとバックエンドのデコード基準のずれです。確認ダイアログが表示するものと、インポーターが書き込むものが食い違ってしまいます。
---
## 内部変更
### 呼び出し元のないコード 3,166 行、14 モジュール、依存 4 件を削除
「呼び出し元が 1 つもない」コードを対象にした整理です。バックエンドではプロバイダのアイコン推定テーブル、プレースホルダーのヘルスチェッカー、一度も接続されていない SSE 実装(ストリーミングと非ストリーミングのハンドラを含みます)、未使用のプロキシセッション型 2 つ、参照のない使用量パーサー 4 つ、そして使われていないコスト計算の入口を削除しました——**本番の課金経路、その自動判別パーサー、セッション ID の抽出はすべて手つかずです**。コンパイラが今まで警告しなかった原因である 22 か所の `#[allow(dead_code)]` も、あわせて削除しました。
フロントエンドではインポート元のない 14 モジュールを削除しました。パネルへの作り直しで置き換えられたプロンプトフォームのモーダルとリポジトリマネージャ、重複したプロキシ設定の hook、プロジェクトの歴史上一度もインポート元を持たなかったサーキットブレーカーのパネル、そして 3 つの schema ファイルが含まれます。これらの文言も 4 言語から同時に削除しました。これらのモジュールの背後にある Tauri コマンドは意図的に残しています。未使用の npm 依存 4 件も削除しました。手動管理のアイコンインデックスを再生成する 2 つのスクリプトを削除し、インデックスファイルのヘッダには「自動再生成は意図的にサポートしない」と明記しました。
あわせて、プロキシの状態と引き継ぎの状態を単一のクエリ層に統合しました——これまで同じコマンドを対象とする 2 つ目の hook 群が並存していましたが、呼び出し元はゼロで、そのクエリキーには観測者が一度も付かず、それらを対象とした無効化はすべて空振りでした。クエリキーの文字列は一字も変わっておらず、残した hook は従来のポーリング動作を維持しています。([#5916](https://github.com/farion1231/cc-switch/pull/5916)、[#5928](https://github.com/farion1231/cc-switch/pull/5928)
---
## アップグレード時の注意
### 本リリースにデータベースマイグレーションはありません
v3.19.1 にスキーマのマイグレーションは含まれず(バージョンは v16 のまま)、アップグレード前のバックアップも発生しないため、そのまますぐに利用できます。
### Claude Desktop の 2 重計上の自動修復には 30 日の期限があります(要確認)
この修正はデータを書き換えたり削除したりするのではなく、クエリ時に重複行を抑制します。そのため**明細行が残っている日は、次回起動時に正しい合計へ戻ります**。再構築の操作は必要ありません。
ただし明細行は 30 日を超えると日次ロールアップに集計され、削除されます。そしてロールアップは集計時点で有効だった基準で一度計算され、そのまま固定されます。**この修正を含まないバージョンですでに集計されてしまった日は、水増しされた数値が永久に残ります。** この回帰は v3.18.0(2026-07-21)で入ったため、アップグレードが早いほど、取り戻せる過去の範囲は広くなります。
### 新しい価格が過去のデータに与える 2 通りの影響
新たに価格を追加した 8 モデルは**遡って再計算されます**。起動時にコストが 0 と記録されているリクエストへコストが補われるため、これらのモデルのダッシュボードの数値は**上がります**。すでに集計・削除された明細行は再計算できず、0 のままです。
価格を変更した 4 モデルは逆方向です。補正は 0 コストの行しか扱わないため、すでに記録済みのリクエストは旧価格のままで、新しいリクエストのみが新価格で計上されます——同じモデルでも、過去の費用と今後の費用は一致しません。どちらの経路でもご自身の価格は保護されます。修正は元の組み込み値のままの行しか変更せず、`~/.cc-switch/model-pricing.json` にある手動での価格変更・models.dev の同期値・削除の記録は、投入と修正の後に再適用され、常に優先されます。
### プリセットの変更は新規作成のプロバイダにのみ影響します
すでに保存済みの DeepSeek や火山方舟 Coding Plan のプロバイダは、保存されている API 形式を保ち、引き続きローカルルーティングを必要とし、従来のカタログを使います。直接接続にするには、プリセットからプロバイダを作り直すか、プロバイダフォームの詳細設定で API 形式をネイティブ Responses に変更してください。
ただし、**すでにネイティブ Responses で、アドレスが `deepseek.com` 上にあるプロバイダは、次回の切り替え時に自動でミラーリングされた公式カタログを使うようになります**。保存し直す必要はありません——判定はライブ設定を読むためです。
### DeepSeek V4 Pro はまだ直接接続で使えません
プリセットには引き続き `deepseek-v4-pro` が並んでおり、ベンダー自身が公開しているカタログにも含まれていますが、**DeepSeek 側で pro 向けの Codex 連携がまだ開放されていません**。公式に示されている時期は 2026 年 8 月上旬です。それまでは直接接続モードで pro を選ぶと上流でエラーになります——プリセットの既定モデルでもある `deepseek-v4-flash` をご利用ください。
いま pro をどうしても使いたい場合は、そのプロバイダの API 形式を「OpenAI Chat」に戻し、ローカルルーティングの引き継ぎを有効にしてください。これは v3.19.1 より前に DeepSeek がずっと通っていた経路そのものです。ローカルプロキシが Codex の送る Responses リクエストを Chat Completions に変換するため、pro はこの経路では影響を受けません。
### DeepSeek 公式カタログの 2 つの前提
ミラーリングされたカタログは Codex クライアントの最低バージョンとして 0.144.0 を宣言していますが、**CC Switch 自身は検証を行いません**——カタログが持つ freeform な `apply_patch` の登録には、このバージョン以降が必要です。また、生成されるカタログファイルは(ミラーリングされた 2 モデルで)約 75 KB まで大きくなります。各エントリがハーネスの全文を含むためです。
### 直接接続の後、使用量の帰属はプロバイダ名から `Codex (Session)` に変わります
DeepSeek・火山方舟 Coding Plan・Tencent Hunyuan は引き継ぎを必要としなくなったため、そのトラフィックはローカルプロキシを完全に迂回でき、プロキシ側のリクエスト単位の記録からは見えなくなります。
**使用量そのものは失われませんし、区別もできます**——Codex のセッションログの取り込みが従来どおり記録します。ただしこの経路はプロバイダの識別情報を持ちません。ローカルプロキシを通らなかった Codex の使用量はすべて `Codex (Session)` という名前の項目にまとめられ、公式サブスクリプションの消費もこの行に入ります。つまり DeepSeek がルーティング経由から直接接続に変わると、その使用量は「DeepSeek」という名前の下から `Codex (Session)` に移ります。
**区別するにはモデルを見てください**:使用量の各レコードはそれぞれのモデル ID を保持しており、使用量パネルの「モデル統計」がモデルごとに 1 行ずつ表示します——`deepseek-v4-flash``hy3``ark-code-latest` と公式サブスクリプションの GPT 系は、それぞれ別の行になり、費用も token も分かれています。**プロバイダ単位**という軸そのものが必要な場合(たとえば同じモデルを複数のアグリゲータ間で比較したい場合)にのみ、引き続きローカルルーティングの引き継ぎを使う必要があります——この経路は実際のプロバイダ名を記録します。
### Codex の古い認証情報のクリーンアップには 2 つの前提があります
クリーンアップは「切り替え先のプロバイダに明示的な公式カテゴリが付いている」**かつ**「切り替え元のプロバイダが正常に書き戻された」場合にのみ実行されます。手動で作成し公式カテゴリを付けていないエントリや、書き戻しに失敗した切り替えでは、古い認証情報はディスクに残ったままです。
### Grok Build で引き継ぎを有効にするとバックエンドのフィールドが書き換わります
Grok Build のプロバイダで引き継ぎを有効にすると、ライブ設定のバックエンドのフィールドが Responses に書き換えられるようになりました。データベースに保存されているプロバイダのレコードは影響を受けず、プロキシ停止時にライブファイルはバックアップから丸ごと復元されます。
### ツールのインストールと更新における PATH の変化(macOS / Linux のみ)
設定 → バージョン情報から実行するツールのインストールと更新はすべて、ログインシェルの PATH を、引き継いだ PATH の前に結合するようになりました。そのためライフサイクルのスクリプトが名前で解決するプログラムが、以前と異なるものになる可能性があります。また各操作でこの PATH を読むためのシェルが 1 つ余分に起動され、対話用の起動ファイルが実行されます。**Windows は影響を受けません。**
grok 0.2.112 以降を使っているユーザーは、インストールの記録が 2 つ見えることがあります——ネイティブのものと、`grok update` 自身が作成したグローバルの npm パッケージです。これらは上流によって同期が保たれ、同じバージョンを報告します。
### 環境変数の競合検出の照合基準が変わりました
Claude Code・Codex・Gemini の検出は「部分一致」から「プレフィックス」に引き締められました。そのため、単に名前にアプリ名を含むだけの変数——`MY_ANTHROPIC_API_KEY``OLD_GEMINI_API_KEY`——は**競合として報告されなくなります**。あわせて Grok Build の検出を追加しました。
### ディープリンクのインポート確認で表示される秘匿値が減ります
マスク後に表示される平文のプレフィックスは 8 文字から 4 文字に縮小され、長さが 8 文字以下の値は全体がマスクされます。これは MCP のインポート確認ダイアログにも影響します。
---
## リスク通知
### 継続してお伝えしている注意事項
**xAI Grok OAuth サインイン**:公式 Grok CLI の公開 OAuth クライアント識別情報を再利用しており、利用によってアカウントの制限や停止につながる恐れがあります——詳細は [v3.18.0 release notes](v3.18.0-ja.md#リスク通知) を参照してください。
**Codex OAuth リバースプロキシ**:ChatGPT サブスクリプションの Codex OAuth をリバースプロキシ経由で使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 release notes](v3.13.0-ja.md#-リスクに関する注意事項) を参照してください。
**SuperGrok の残量照会**:プロバイダカードの残量表示は grok.com の非公開の課金エンドポイントに依存しており、xAI がインターフェースを変更すると機能しなくなる可能性があります——詳細は [v3.19.0 release notes](v3.19.0-ja.md#リスク通知) を参照してください。
**サードパーティプロバイダへのルーティング**:CC Switch のローカルプロキシで Codex・Claude Desktop・Grok Build のリクエストを変換してサードパーティのプロバイダへ転送する場合、課金・コンプライアンス・データ保持に関する制約はプロバイダごとに異なります。利用前に対象プロバイダの利用規約をお読みください。
上記の機能を有効にした時点で、ユーザーは関連するリスクを自ら引き受けることになります。CC Switch は、これらの機能の利用に起因するアカウントの制限・警告・サービス停止について、一切の責任を負いません。
---
## 謝辞
本リリースの修正は、その大半が外部のコントリビューターによるものです——6 つの PR のうち 5 つは私が書いたものではありません。
### コード貢献
- [#5677](https://github.com/farion1231/cc-switch/pull/5677)Grok Build のプロキシ引き継ぎとディープリンク連携の仕上げ——バックエンドのフィールド、セッションの識別、フェイルオーバータブ、環境変数の検出、さらに設定プレビューでの秘匿値の漏れの修正まで。@YUZHEthefool さんに感謝します。本リリースで最も広い範囲をカバーした仕事です。
- [#5951](https://github.com/farion1231/cc-switch/pull/5951)Claude Desktop の使用量 2 重計上の修正。@Komikawayi さんに感謝します。v3.18.0 のどの変更が 3 つの重複排除の防御を同時に破ったのかを突き止めたのは、本リリースで最も根気を要した調査でした。
- [#5916](https://github.com/farion1231/cc-switch/pull/5916)、[#5928](https://github.com/farion1231/cc-switch/pull/5928):呼び出し元のないコード 3,166 行と、重複したプロキシのクエリ層の削除。@SaladDay さんに感謝します。
- [#5943](https://github.com/farion1231/cc-switch/pull/5943):繁体字中国語のツール管理の文言を補い、言語のずれを防ぐテストを追加。@yovinchen さんに感謝します。
- [#5960](https://github.com/farion1231/cc-switch/pull/5960):すべての言語で簡体字中国語が表示されていた 9 件の文言の補完。@mhy1227 さんに感謝します。
### 問題報告
[#5938](https://github.com/farion1231/cc-switch/issues/5938) で、Claude Desktop のリクエストごとに `session_log` を出所とするログが 1 行余分に生まれ、token が 2 回集計されていることを報告してくださった @Alaric-L さんに感謝します——データソースまで特定された報告で、本リリースで最も重要な使用量の修正はこの報告から直接たどり着いたものです。
---
## ダウンロードとインストール
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から、お使いのシステムに対応するビルドをダウンロードするか、公式サイト [ccswitch.io](https://ccswitch.io) から入手してください(ダウンロードは Cloudflare のエッジノード経由で配信され、GitHub への到達性に依存しません)。
### システム要件
| システム | 最低バージョン | アーキテクチャ |
| -------- | ------------------------ | ----------------------------------- |
| Windows | Windows 10 以降 | x64 / ARM64 |
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 下表を参照 | x64 / ARM64 |
### Windows
| ファイル | 説明 |
| ---------------------------------------- | -------------------------------------------- |
| `CC-Switch-v3.19.1-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー |
| `CC-Switch-v3.19.1-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます |
Windows ARM64 デバイスでは、ファイル名に `arm64` が含まれる対応する成果物を選択してください。
### macOS
| ファイル | 説明 |
| -------------------------------- | ------------------------------------------------------ |
| `CC-Switch-v3.19.1-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ |
| `CC-Switch-v3.19.1-macOS.zip` | 展開して Applications へドラッグ、Universal Binary |
| `CC-Switch-v3.19.1-macOS.tar.gz` | Homebrew インストールと自動更新用 |
Homebrew インストール:
```bash
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
Linux アセットは **x86_64****ARM64**`aarch64`)の両方を提供します。ファイル名のアーキテクチャ識別子を、マシンの `uname -m` 出力に合わせて選択してください:
- `CC-Switch-v3.19.1-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- `CC-Switch-v3.19.1-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` |
-369
View File
@@ -1,369 +0,0 @@
# CC Switch v3.19.1
> 这一版的主线是**把上一版的尾巴收干净**:三家国产 Codex 网关经确认原生支持 Responses API,**不用再开本地路由接管**——DeepSeek 与火山方舟 Coding Plan 的预设从走本地路由改为直连,新加入的腾讯混元 TokenHub 一上来就是直连;四个能在日常里撞上的故障被修掉——**Claude Desktop 用量自 v3.18.0 起被算了两遍**(升级后历史数字会自动回正,但有 30 天窗口,见「升级提醒」)、切回官方 Codex 会卡在 401 且看不到登录界面、从设置页升级 Grok Build 只报一句 `os error 2`、Grok Build 开启接管后直接 404。另有 8 个此前一直按 $0 记账的模型补上内置定价,39 个界面文案的语言问题被修正。本版**没有数据库迁移**,并且是本项目第一个删除量超过新增量的版本。
**[English →](v3.19.1-en.md) | [日本語版 →](v3.19.1-ja.md)**
---
## 重点内容:你现在可以
- **让 DeepSeek、火山方舟 Coding Plan、腾讯混元在 Codex 里直连**:三家的官方 Codex 文档都已确认端点原生提供 Responses API。DeepSeek 与火山方舟 Coding Plan 的既有预设从 Chat 格式改为原生格式,供应商卡片上的「需要路由」标记与切换时的提示随之消失,请求不再经过本地代理的协议转换;腾讯混元 TokenHub 是本版新增的预设,从一开始就是原生格式。**注意 DeepSeek V4 Pro 暂时还不能直连**——厂商侧尚未开通它的 Codex 集成,直连请用 V4 Flash(预设默认),详见[升级提醒](#deepseek-v4-pro-暂时还不能直连)。
- **让 DeepSeek 用上 DeepSeek 自己发布的模型目录**:新的「官方厂商目录镜像」机制把厂商公布的 `models.json` 原样下发给该厂商自己的端点,freeform `apply_patch` 与配套的 GPT-5 提示词框架成套保留,不再被压成中性模板。判定只认域名、不认模型名——同一个模型在聚合站上未必实现同样的能力。
- **拿到正确的 Claude Desktop 用量数字**:自 v3.18.0 起,经本地网关的 Claude Desktop 流量在看板里被记了两遍——一遍来自代理、一遍来自会话日志导入,token、费用与请求数约翻倍。本版修好后,明细行还在的日子会自动回到正确数字,**不需要重建**。
- **切回官方 Codex 之后能正常登录**:此前从第三方供应商切回内置的官方 Codex 条目时,第三方的 key 会留在 `~/.codex/auth.json` 里,Codex 拿着它去请求官方端点,稳定 401——又因为文件存在,它不会退回自己的登录界面,在应用里没有出路。
- **从设置页把 Grok Build 升上去**`grok update` 自 0.2.112 起改为内部调用 npm 完成分发,而图形界面启动的应用看不到 node,升级只会报一句 `Error: No such file or directory (os error 2)`
- **给 Grok Build 开启接管而不是撞上 404**API 格式被手动改成 OpenAI Chat 或 Anthropic 的 Grok Build 供应商,开启接管后请求会打到一个代理没有注册的路由上,直接 404,且没有故障转移、没有用量记录。同时,Grok Build 的每次请求此前都被当成新会话,缓存键注入与按会话聚合都失效了。
- **看到 8 个此前一直按 $0 记账的模型的真实成本**:`gpt-5.3-codex-spark``gemini-3.5-flash-lite``kimi-k2.7-code-highspeed``glm-5-turbo``glm-5v-turbo``qwen3.6-flash`,以及不带日期后缀的 `claude-opus-4-6` / `claude-sonnet-4-6`
- **在繁体中文界面里看懂「关于」页的工具管理**:30 个只补了简中 / 英文 / 日文的文案漏了繁体中文,因为 i18next 会静默回落英文,这块面板自 v3.16.0 起一直是半英文的。另有 9 个文案在**所有语言下**都显示简体中文。
- **在官方订阅与 DeepSeek 之间来回切,而不是二选一**:`auth.json``config.toml` 都是单槽文件,Codex 自己存不下第二份凭据。厂商的一键脚本会把这份配置改造成自己专用的,而 CC Switch 是按供应商整段快照与还原——这也是它和官方脚本最实际的区别,详见[下文对照](#用-cc-switch-接入和直接跑官方脚本有什么不同)。
---
## 使用攻略
本版的改动集中在 Codex 的连接方式与用量统计口径上,建议结合以下文档了解:
- **[本地路由](../user-manual/zh/4-proxy/4.2-routing.md)**:哪些供应商需要开启接管、接管做了什么。本版之后 DeepSeek、火山方舟 Coding Plan 与腾讯混元都不再需要它。
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:用量看板的数据来源与统计口径,理解 Claude Desktop 双算是怎么发生的、修复后为什么部分历史日期无法回正。
- **[在 Codex 中用 DeepSeek 这类 Chat 格式 API](../guides/codex-deepseek-routing-guide-zh.md)**:这篇攻略讲的是本地路由如何把 Responses 转换成 Chat Completions,机制部分对 Kimi、MiniMax、SiliconFlow 等仍是 Chat 形态的供应商依旧适用;但**其中以 DeepSeek 作为示例的部分已不适用于本版**——DeepSeek 现在走直连,不需要路由。
---
> [!WARNING]
>
> ## 唯一官方渠道声明(请务必阅读)
>
> CC Switch 是**完全免费、开源**的桌面应用,**不会向用户收取任何费用**。请仅通过下列官方渠道获取本软件:
>
> | 类别 | 唯一官方 |
> | -------- | ------------------------------------------------------------------------------ |
> | 官网 | **[ccswitch.io](https://ccswitch.io)** |
> | 源码 | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
> | 下载 | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
> | 举报山寨 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
>
> **任何向你收费、要求充值、或索取登录凭据的"CC Switch"网站或客户端均为假冒**。如果你被诱导支付了费用,请立即停止操作并通过 GitHub Issues 反馈。
---
## 概览
CC Switch v3.19.1 是一次维护性发布,主线有三条。第一条是国产 Codex 网关集体转向原生 ResponsesDeepSeek 直连 `api.deepseek.com`,并带来一个可复用的机制——把厂商自己发布的模型目录原样镜像下发,让 freeform `apply_patch` 与配套的 GPT-5 提示词框架保持自洽,而不是被折叠成中性模板;火山方舟的 Coding Plan 端点 `/api/coding/v3` 在官方文档确认后跟进;腾讯混元的 TokenHub 作为新预设加入。三者都不再需要开启本地路由接管。
第二条是四个现场可见的故障修复:Claude Desktop 的用量自 v3.18.0 起被记两遍([#5938](https://github.com/farion1231/cc-switch/issues/5938));切回内置官方 Codex 供应商会留下第三方的 `auth.json`,导致 401 且看不到登录界面;`grok update` 在图形界面下只报 `os error 2`Grok Build 的代理接管在非 Responses 后端上 404,且每次请求都被当作新会话([#5677](https://github.com/farion1231/cc-switch/pull/5677))。第三条是减重:3,166 行已无任何调用方的代码与 4 个未使用的 npm 依赖被删除——本版是本项目第一个删除量超过新增量的版本。此外,深链导入确认框的脱敏更严、截断更少,8 个此前按 $0 记账的模型补上定价,4 个内置定价与厂商牌价重新对齐。本版**没有数据库 schema 迁移**(版本号保持 v16),升级轻量。
**发布日期**2026-07-31
**更新规模**12 commits | 71 files changed | +2,324 / -3,680 lines
---
## 新功能
### 官方厂商模型目录镜像(DeepSeek 首发)
Codex 从一个目录文件读取模型能力,而 CC Switch 此前对所有供应商都用中性模板生成这个目录——对聚合站这是对的,但会剥掉厂商自家集成所依赖的能力。现在,凡是随应用内置了官方目录的厂商,直接镜像下发它自己的那一份。
DeepSeek 是第一家:内置文件带着 `deepseek-v4-flash``deepseek-v4-pro` 两个条目,保留 `apply_patch_tool_type: "freeform"``web_search_tool_type: "text"``supports_search_tool: true`、low / high / max 三档思考强度,以及 `base_instructions``model_messages` 里那份 17,644 字符的 GPT-5 提示词框架——**这份框架必须与 freeform 工具注册一起走**,因为框架本身就在指导模型使用 `apply_patch`,拆开任何一半都会不自洽。
判定条件刻意收得很窄:供应商必须落在原生 Responses 档**并且** `base_url``deepseek.com` 上。**只认域名、不认模型品牌**——同一个模型在转售它的聚合站上未必实现同样的能力,按品牌授予等于把能力凭空发给了没有实现它的服务。供应商自己在目录里写死的条目仍然优先;遇到不认识的模型 ID 会克隆旗舰条目,但保留它自己的名称。其它所有档位生成的目录与改动前逐字节一致。
### 腾讯混元(TokenHubCodex 预设
Codex 的预设选择器里新增「Tencent Hunyuan」,归入「开源官方」分类,位于百炼与阶跃之间。选中即写好 `https://tokenhub.tencentmaas.com/v1``wire_api = "responses"` 与 TokenHub 强制要求的 `disable_response_storage = true`;声明 `hy3``hy3-preview` 两个模型,上下文窗口 **256K**(而不是接受 Codex 的 128K 默认值),并标记为纯文本——Codex 不会再把 `view_image` 的图片载荷发给读不了图的模型。
因为是原生 Responses 供应商,Codex 直连网关、无需本地路由;生成的目录走中性原生模板,会固定 `shell_type = "shell_command"` 并去掉原生网关拒收的 freeform `apply_patch` 注册。地址管理器与测速里从一开始就有两个候选:主域名与官方备用的 `.cn` 域名;区域独立的国际站刻意排除在外,因为 API Key 不跨站通用。
注意 **API Key 需要是开通了 Hy3 权限的 TokenHub key**Coding Plan 与 Token Plan 的订阅 key 在这个端点上用不了。
### 8 个此前按 $0 记账的模型补上内置定价
`gpt-5.3-codex-spark``gemini-3.5-flash-lite``kimi-k2.7-code-highspeed`(按 Kimi 的 Turbo 惯例,取 `kimi-k2.7-code` 基准价的 2 倍)、`glm-5-turbo``glm-5v-turbo``qwen3.6-flash` 在内置定价表里根本没有行,前缀回退也够不着,因此每一次请求都被记成零成本。
另外两行 —— 不带日期后缀的 `claude-opus-4-6``claude-sonnet-4-6` —— 补的是一个更隐蔽的缺口:模型 ID 解析只会**剥掉**日期后缀、从不**补上**,所以一条带着无日期 ID 的日志谁也匹配不到。八行全部按「不存在才插入」播种,你改过的价格不受影响。
### Grok Build 加入故障转移页签与环境变量冲突检测
设置页的故障转移在 Claude Code、Codex、Gemini 之外新增第四个 Grok Build 页签。启动时的环境变量冲突横幅也开始检测 `XAI_API_KEY``GROK_DEFAULT_MODEL`——这两个变量会静默盖掉你在应用里选的供应商。检测区分了精确名与前缀,所以 CC Switch 自己用的 `GROK_BIN_DIR``GROK_HOME` 不会被误报。
---
## 变更
### DeepSeek 与火山方舟 Coding Plan 改为直连 Codex,不再需要本地路由
两家的预设此前都标记为 OpenAI Chat 格式,因此都是「需要接管」的:供应商卡片带着「需要路由」标记,未开代理就切换会弹提示,每个请求都要走 Codex → 本地代理 → Responses 转 Chat → 上游这条链路。
现在两家的官方 Codex 集成文档都已确认端点提供 Responses API——DeepSeek 的 `api.deepseek.com` 与火山方舟的 `/api/coding/v3`——两个预设随之声明为原生 Responses,标记与提示消失,Codex 直连网关。两家写出的 `config.toml` 都没有变化(本来就是 `wire_api = "responses"`),变的是目录生成档位;DeepSeek 另外把上下文窗口从 1,000,000 对齐到厂商自己的 1,048,576。
BytePlus 国际站刻意保持 Chat 路由不变,等国际站文档单独核实后再说。火山预设里还留了一条值得知道的计费注记:按量计费的 `/api/v3` 端点**绝不能**加进这个预设的备用地址——它单独计费,不走套餐额度。
### 目录的显示名与上下文窗口改为「显式才生效」
这两个字段此前带着本地默认值——模型 ID 与 128,000 的窗口——并且在厂商值有机会参与之前就应用了,镜像目录里 1M 的窗口会被 128K 覆盖掉。现在它们是可选的,回退挪到条目构造那一层,于是「留空」才真正等于「沿用厂商声明的值」。显式写了这两个字段的供应商,以及所有非镜像档位,生成的目录与之前完全一致。
---
## 用 CC Switch 接入,和直接跑官方脚本有什么不同
DeepSeek 官方提供了一条 Codex 一键接入脚本,它能用、会备份、也带恢复菜单。**如果你这台机器就打算专心用 DeepSeek,跑官方脚本没有任何问题。** CC Switch 解决的是另一个场景:你要在多个供应商之间来回切。
### 换供应商时,登录态与配置整套换,不用自己备份
`~/.codex/auth.json``~/.codex/config.toml` 都是**单槽文件**——Codex 本身没有多凭据存储,一份配置只能对应一个供应商。CC Switch 在你切走某个供应商时,把这一对文件的内容整段快照进那个供应商的记录里;切回来时再整段写回。所以「ChatGPT 订阅 → DeepSeek → 切回订阅」通常不需要重新 `codex login`,第三方之间来回切则完全无需手工动作。手工做同一件事,你得在每次切换前后各拷贝一次这两个文件,漏一次,被覆盖的 OAuth 凭据就只能重新登录找回。
官方脚本的取舍不同:它把 `config.toml` 改造成 DeepSeek 专用配置——顶层写死 `preferred_auth_method = "apikey"``forced_login_method = "api"`,把认证方式固定为 API Key,并且**删除 `config.toml` 里已有的 `[profiles.*]`**(Codex 自带的多供应商切换机制)。你的 ChatGPT 登录凭据本身没有被删,`auth.json` 原封不动;但在这份配置下用不上,想回订阅需要跑脚本的恢复菜单整体回滚——回滚会连带丢掉安装之后你对 `config.toml` 的任何手改。脚本本身也只能在 flash 与 pro 之间切换,没有「换到第三个供应商」这一档。
### 换供应商之后,`codex resume` 里的旧会话还在
Codex 的续聊列表按会话里记录的 `model_provider` 分抽屉。CC Switch 创建的所有第三方 Codex 供应商——不管是 DeepSeek、Kimi 还是聚合站——都写同一个标识 `custom`,所以在它们之间怎么换,`codex resume` 一直能看到全部历史。CC Switch 首次启动时还会做一次性迁移,把已知的按厂商分桶的旧会话(官方脚本写入的 `deepseek` 也在其中)折进这个共享桶,原文件先备份到 `~/.cc-switch/backups/`
**这里有一条明确边界**:这个迁移只在 CC Switch 首次启动时跑一次。**如果你先装了 CC Switch、之后才去跑官方脚本**,那批带 `deepseek` 标识的会话不会再被折进来,它们会留在自己的抽屉里。另外,你手写的、不在已知名单里的供应商标识,CC Switch 刻意不去改动它。
### 官方订阅的会话,在 CC Switch 里本来就和第三方混排
CC Switch 的**会话管理面板直接扫描会话目录、不读 `model_provider`**,所以官方订阅期间产生的 Codex 会话一直和第三方会话在同一个列表里,可搜索、可续聊、可删除——**不需要开任何开关**。
如果你还希望 **Codex 自己的 `codex resume` 列表**也把官方与第三方合并,那是另一件事:设置 → 通用 → Codex 应用增强 → **「统一 Codex 会话历史」**,默认关闭。开启后只影响新会话;已有的官方会话要一并迁入,需要在开启确认框里再勾选「同时迁入现有官方会话历史」(同样默认不勾)。这两项都是既有功能、不是本版新增,边界场景见[《统一 Codex 会话历史》攻略](../guides/codex-unified-session-history-guide-zh.md)。
> **两个共同前提,先说清楚免得你事后困惑:**
>
> **一、以 CC Switch 指向的 Codex 目录为准。** 默认是 `~/.codex`,可在设置里改。**CC Switch 不读 `CODEX_HOME` 环境变量**——如果你用这个变量把 Codex 指到别处,那边的会话它看不见,供应商切换也会写进 CLI 没在用的目录里。要换目录请用 CC Switch 自己的「配置文件目录」设置。
>
> **二、出现在同一个列表里,不等于一定能续聊。** Codex 的推理内容(`encrypted_content`)只有产生它的后端能解密,跨供应商继续一段旧会话可能失败——这是上游的设计,不是 CC Switch 能绕过的。
---
## 修复
### Claude Desktop 的用量被算了两遍
经本地网关的 Claude Desktop 流量在用量看板里落两次——一次是代理行,一次是会话记录导入行——于是它的 token、费用与请求数大约翻倍。
这是 v3.18.0 引入的回归:代理侧的去重 ID 对除 `claude` 之外的所有应用都带上作用域前缀,写成 `session:{应用}:{供应商}:{消息ID}`,这就把 `claude-desktop` 放进了独立命名空间;而会话导入器仍然以裸的 `session:{消息ID}` 形态、`app_type = 'claude'` 写同一条 Claude 消息。三道去重防线因此同时失守:让代理行吸收已有会话行的主键收敛、写入侧的指纹探测、读取侧的过滤器——后两者都在用严格相等比较应用类型。
现在两个应用重新共用裸命名空间,两处比较则按单向规则放宽:`claude` 的会话行可以被 `claude-desktop` 的代理行吸收,**反过来不成立**。由于读取侧的过滤器也正是日报聚合所使用的那一个,已经入库的重复行会停止被计入,**不改写、不删除任何一行**——这条自愈有保留期限制,见「升级提醒」。对 Codex、Gemini、OpenCode 而言放宽后的比较退化为原来的精确匹配,额度检查仍使用严格匹配。([#5938](https://github.com/farion1231/cc-switch/issues/5938)、[#5951](https://github.com/farion1231/cc-switch/pull/5951)
### 切回官方 Codex 供应商会卡在 401、看不到登录界面
在 Codex API Key 保留开关关闭时(默认如此),切换到第三方供应商会把对方的 key 写进 `~/.codex/auth.json`。之后再切到内置的官方供应商——它存的凭据是空的——会走「只写配置」这条分支,于是 `config.toml` 被替换,而第三方的 `OPENAI_API_KEY` 原样留在盘上。Codex 随后拿着这把外来的 key 去请求官方端点,稳定 401;又因为 `auth.json` 存在,它不会退回自己的登录界面,在应用里找不到出路。
现在,成功切到官方 Codex 供应商之后,如果 `auth.json` 里只有一个 `OPENAI_API_KEY`、旁边没有任何一等凭据,这个文件会被删除——OAuth 令牌、个人访问令牌、agent 身份、Bedrock key 中的任何一个都标志着这是一份真实凭据,会被完整保留;而 `auth_mode``last_refresh`、账号 ID 这类纯元数据不再能「挡住」一把过期的 key。
**选择删除文件而不是写入 `{}`**:空对象会被 Codex 判定为没有令牌的 ChatGPT 模式并在启动时报错,而文件缺失才等价于未登录、直接进登录流程。清理只在旧供应商已成功回填进数据库之后执行,所以被删掉的 key 并没有丢——它存进了那个供应商的记录里,再次选中它就会回来。同一处改动还放宽了 live 配置读取:清理之后的状态(没有 `auth.json`、有 `config.toml`)不再被报成「Codex 未安装」。
### 从设置页升级 Grok Build 只报一句 `os error 2`
在设置 → 关于里升级 Grok Build 会失败于 `Error: No such file or directory (os error 2)`,没有任何其它信息。
根因是探测与执行两条路径的不对称:探测走登录 shell,会读取用户的 rc 文件,因此看得见 nvm、Homebrew、Volta;而生命周期脚本跑在非登录 shell 下,继承的是图形界面应用启动时那份很窄的 PATH。这本来无所谓,因为锚定命令都用绝对路径调用目标程序——但 grok 0.2.112 把自更新改到了 npm 分发上,`grok update` 内部会调起 `npm view``npm i -g`,而 npm 自己又要通过 shebang 解析 node。内层调用返回 ENOENT,grok 就把它原样抛成了那句 `os error 2`
现在 macOS 与 Linux 上的生命周期命令会把登录 shell 的真实 PATH 并到继承的那份前面,读取方式是执行 `/usr/bin/env` 而不是回显变量——因为 fish 把 PATH 存成列表,回显会得到空格分隔的片段。原生安装的 Grok 还给升级链追加了官方 xAI 安装脚本作为兜底,**刻意不用 `npm i -g`**:npm 与主路径共享同样两种失败模式(没有 node、镜像源缺包),会一起失败;官方安装脚本是唯一不依赖 node 的路径,落点相同,并且会把 CLI 自己的 `installer` 设置改回 `internal`,顺带治好被早期 npm 兜底切到 npm 分发上的用户。
### Grok Build 开启接管后 404,且每次请求都像新会话
在 API 格式被改成 OpenAI Chat 或 Anthropic 的 Grok Build 供应商上开启接管,会立刻得到 404,没有故障转移也没有用量记录——接管改写了地址与 key,却没有动后端字段,于是 CLI 把请求发到了代理没有注册的路由上。现在接管会同时把后端固定为 Responses;针对具体供应商降级到 Chat Completions 的动作仍然发生在转发层,而这个被强制的值会随整份 live 配置的备份在代理停止时还原。
另一个问题是代理的会话识别此前只认 Codex 与 OpenAI 客户端,因此 Grok Build 的每一轮都会生成一个新的会话 ID 并标记为「非客户端提供」,这同时压掉了缓存键注入与看板里的按会话聚合。现在会读取 Grok 自己的头——先会话所属的对话 ID,再会话 ID,忽略每请求变化的那个——并使用独立前缀,避免与 Codex 的记录撞车。([#5677](https://github.com/farion1231/cc-switch/pull/5677)
### 9 个界面文案在所有语言下都显示简体中文
有 9 个字符串无论界面语言是什么都显示简体中文,英文与日文界面同样如此。每个调用点都用了「内联默认值」的写法、默认值是中文字面量,但对应的键在四个语言文件里**一个都没有**——而 i18next 会先走完语言链才考虑内联默认值,于是英文回退根本没有机会生效,中文字面量在所有语言下都赢了。
受影响的文案覆盖 Grok Build 供应商表单的必填校验提示、应用尚未接管时的故障转移悬停提示、另一个应用持有接管时停止 Claude Desktop 路由的警告及其原因说明、供应商标识读取失败提示、Codex 通用配置为空的错误、路由服务的停止与停止失败两个提示,以及用量表格里给「有 token 但算出来是零成本」的请求打的「未定价」标签。9 个键现在在简中、英文、日文、繁中里都有了。([#5960](https://github.com/farion1231/cc-switch/pull/5960)
### 繁体中文的「关于」页工具管理回落成英文
界面语言设为繁体中文时,「关于」页的工具管理区块显示英文——版本行、安装与更新按钮、结果提示、安装冲突诊断,以及整个升级确认弹窗。这块面板由三次改动逐步建成,每次都只补了简中、英文、日文;而 i18next 的策略是回落英文而不是报错,于是 30 个缺失的键在测试里完全不可见。**这个缺口自 v3.16.0 起一直带到 v3.19.0。**
30 个文案现在全部译好,安装提示也与其它语言对齐;新增的语言测试要求每一个工具管理文案在四种语言下都存在、且插值变量一致,这类漂移以后会在测试里失败而不是发出去。([#5943](https://github.com/farion1231/cc-switch/pull/5943)
### 内置定价与厂商牌价脱节
成本在写入日志时就按内置定价表冻结,所以一个过期的播种价会静默地把之后每一次请求都算错。本版修正四行:`deepseek-chat``deepseek-reasoner` 现在是 V4 Flash 的旧称别名,每百万 token $0.14 输入 / $0.28 输出、缓存读 $0.0028(原为 $0.27/$1.10 与 $0.55/$2.19);`minimax-m3` 按官方标准档减半到 $0.30/$1.20`gpt-5.6-luna` 按 OpenAI 2026-07-30 的降价下调 80% 到 $0.20/$1.20`gpt-5.6-terra` 下调 20% 到 $2/$12`gpt-5.6-sol` 刻意不动,该系列的缓存写入比例保持不变。
修复只在一行的四个价格列**仍然全等于此前的内置值**时才改写它,所以你自己改过的价格——或者由 models.dev 同步写入的价格——绝不会被动到。
---
## 安全加固
### 深链导入确认框:脱敏更严,截断更少
这是 v3.19.0 那轮 `ccswitch://` 确认框加固的延续。配置预览改由一个共享模块统一构建,会递归地对嵌套 TOML 表与 JSON 对象里的密钥脱敏,一次修好两个方向相反的缺陷:Grok Build 的导入此前**完全不渲染**配置预览,而 Codex 的导入会把内嵌的 `api_key` **明文打印**出来。
配置预览上 300 字符的截断被移除,完整内容现在渲染在可滚动的框里——补上了确认框最后一处可能隐藏「即将写入什么」的地方。脱敏本身在所有使用它的位置都更严了,**包括 MCP 导入确认框**:敏感键名匹配新增 `AUTHORIZATION``COOKIE``CREDENTIAL`,以及精确匹配的 `AUTH``BEARER`;脱敏后显示的明文前缀从 8 个字符缩到 4 个;长度不超过 8 个字符的值现在整体替换,而不是原样显示。
最后,前端的 Base64 解码器不再裁掉首尾空白——那有可能是 URL 解码把 `+` 变成的空格。这与 v3.19.0 修过的是同一类前后端解码口径分歧:确认框显示的是一回事,导入器写进去的是另一回事。
---
## 内部
### 删掉 3,166 行已无调用方的代码、14 个模块与 4 个依赖
一轮针对「没有任何调用方」的清理。后端删除了供应商图标推断表、一个占位的健康检查器、一套从未接线的 SSE 实现(含它自己的流式与非流式处理器)、两个未使用的代理会话类型,以及四个无引用的用量解析器与一个死的成本计算入口——**线上计费路径、它的自动识别解析器与会话 ID 提取全部原封不动**。22 处 `#[allow(dead_code)]` 抑制(正是它们让编译器一直没报警)随之删除。
前端删除 14 个无导入方的模块,包括已被面板改版取代的提示词表单弹窗与仓库管理器、一个重复的代理配置 hook、一个在项目历史上从未有过导入方的熔断器面板,以及三个 schema 文件;它们的文案在四种语言里同步删除。这些模块背后的 Tauri 命令刻意保留。另外删除 4 个未使用的 npm 依赖。两个会重新生成手工维护的图标索引的脚本被移除,索引文件头改为写明「刻意不支持自动重生成」。
配套的一处改动把代理状态与接管状态合并到单一的查询层——此前有第二套并行的 hook 覆盖同样的命令,但零调用方,它的查询键从来没有观察者,针对它们的失效调用全是空转。查询键字符串逐字未变,保留下来的 hook 维持原有的轮询行为。([#5916](https://github.com/farion1231/cc-switch/pull/5916)、[#5928](https://github.com/farion1231/cc-switch/pull/5928)
---
## 升级提醒
### 本版没有数据库迁移
v3.19.1 不含 schema 迁移(版本号保持 v16),不会触发升级前备份,升级即用。
### Claude Desktop 双算的自愈有 30 天窗口(请读)
修复是在查询时抑制重复行,而不是改写或删除数据,所以**明细行还在的每一天都会在下次启动后恢复正确总数**,不需要任何重建操作。
但明细行超过 30 天会被聚合进日报并清理,而日报是按聚合当时生效的口径算一次就固定下来的。**已经被没有此修复的版本聚合掉的日期,会永久保留虚高的数字。** 这个回归自 v3.18.0(2026-07-21)进入,所以越早升级、能救回的历史区间越完整。
### 新定价对历史数据的两种不同影响
八个新补定价的模型会被**回溯补算**:启动时会给成本记为零的请求补上成本,因此这些模型的看板数字会**上升**。已经聚合并清理掉的明细行无法补算,保持为零。
四个改价的模型方向相反:补算只处理零成本行,所以已经记录的请求保持旧价,只有新请求按新价计费——同一个模型的历史花费与新增花费会不一致。两条路径都保护你自己的定价:修复只改仍是原内置值的行,而 `~/.cc-switch/model-pricing.json` 里的手工改价、models.dev 同步值与删除墓碑会在播种与修复之后重放,始终优先。
### 预设变更只影响新建供应商
已经保存的 DeepSeek 或火山方舟 Coding Plan 供应商保持它存的 API 格式,仍然需要本地路由,也仍用旧目录。想用直连,请从预设重新创建供应商,或在供应商表单的高级区把 API 格式改为原生 Responses。
不过,**已经是原生 Responses、且地址在 `deepseek.com` 上的供应商,下次切换时就会自动用上镜像的官方目录**,不需要重新保存——因为判定读的是 live 配置。
### DeepSeek V4 Pro 暂时还不能直连
预设里仍然列着 `deepseek-v4-pro`,厂商自己发布的目录也带着它,但 **DeepSeek 侧针对 pro 的 Codex 集成尚未开通**,官方给出的时间是 2026 年 8 月初。在那之前,直连模式下选 pro 会在上游报错——请用 `deepseek-v4-flash`,它也是预设的默认模型。
如果你现在就要用 pro,把这个供应商的 API 格式改回「OpenAI Chat」并开启本地路由接管即可。这正是 v3.19.1 之前 DeepSeek 一直走的那条路:本地代理会把 Codex 发出的 Responses 请求转换成 Chat Completionspro 在这条路上不受影响。
### DeepSeek 官方目录的两个前提
镜像的目录声明了 Codex 客户端最低版本 0.144.0**CC Switch 自己不做校验**——它携带的 freeform `apply_patch` 注册需要这个版本或更新。另外,生成的目录文件会涨到约 75 KB(两个镜像模型),因为每个条目都带着完整的提示词框架文本。
### 直连之后,用量的归属会从供应商名变成 `Codex (Session)`
DeepSeek、火山方舟 Coding Plan 与腾讯混元不再需要接管,它们的流量可以完全绕过本地代理,代理侧的逐请求记录因此看不到它们。
**用量本身不会丢,也仍然分得清**——Codex 的会话日志导入照常记录,只是这条路径不携带供应商身份:所有没走本地代理的 Codex 用量会一起归入名为 `Codex (Session)` 的条目,官方订阅的消耗也在这一行里。也就是说,DeepSeek 从走路由改为直连之后,它的用量会从「DeepSeek」这个名字下移到 `Codex (Session)`
**要区分它们,看模型**:每条用量记录都带着自己的模型 ID,用量面板的「模型统计」按模型逐行列出——`deepseek-v4-flash``hy3``ark-code-latest` 与官方订阅的 GPT 系列各归各行,费用与 token 都是分开的。只有当你需要的正是**按供应商**这个维度(比如同一个模型在多家聚合站之间比价),才需要继续用本地路由接管——这条路会记录真实的供应商名。
### Codex 残留凭据清理的两个前提
清理只在「切入的供应商带有显式的官方分类」**且**「切出的供应商已成功回填」时执行。手工创建、没有标记官方分类的条目,或者回填失败的那次切换,残留仍会留在盘上。
### Grok Build 开启接管会改写后端字段
在 Grok Build 供应商上开启接管,现在会把 live 配置里的后端字段改写为 Responses。数据库里存的供应商记录不受影响,代理停止时 live 文件会从备份整体还原。
### 工具安装与升级的 PATH 变化(仅 macOS / Linux
设置 → 关于里触发的每一次工具安装与升级,现在都会把登录 shell 的 PATH 并到继承的那份前面,因此生命周期脚本按名称解析到的程序有可能与之前不同;每次操作还会多启动一个 shell 来读取这份 PATH,这会执行你的交互式启动文件。**Windows 不受影响。**
使用 grok 0.2.112 及以上版本的用户可能会看到两份安装记录——原生的那份,加上 `grok update` 自己创建的全局 npm 包;它们由上游保持同步,版本号一致。
### 环境变量冲突检测的匹配口径变了
Claude Code、Codex、Gemini 的检测从「包含」收紧为「前缀」,因此仅仅名字里含有应用名的变量——`MY_ANTHROPIC_API_KEY``OLD_GEMINI_API_KEY`——**不再被报为冲突**。同时新增了 Grok Build 的检测。
### 深链导入确认框显示的密钥更少
脱敏后显示的明文前缀从 8 个字符缩到 4 个,长度不超过 8 个字符的值整体脱敏。这也影响 MCP 导入确认框。
---
## 风险提示
### 沿用的提示
**xAI Grok OAuth 登录**:复用官方 Grok CLI 的公开 OAuth 客户端身份,使用可能导致账号被限制或封禁——详见 [v3.18.0 release notes](v3.18.0-zh.md#风险提示)。
**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#-风险提示)。
**SuperGrok 配额查询**:供应商卡片的配额展示依赖 grok.com 的非公开计费端点,xAI 调整接口后可能失效——详见 [v3.19.0 release notes](v3.19.0-zh.md#风险提示)。
**第三方供应商路由**:通过 CC Switch 本地代理把 Codex、Claude Desktop 或 Grok Build 的请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。
用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
---
## 致谢
这一版的修复大半来自外部贡献者——六个 PR 里有五个不是我写的。
### 代码贡献
- [#5677](https://github.com/farion1231/cc-switch/pull/5677)Grok Build 的代理接管与深链集成收尾——补齐后端字段、会话身份识别、故障转移页签与环境变量检测,并顺带修好了配置预览里的密钥泄漏,感谢 @YUZHEthefool。这是本版覆盖面最广的一份工作。
- [#5951](https://github.com/farion1231/cc-switch/pull/5951)Claude Desktop 用量双算修复,感谢 @Komikawayi。定位到 v3.18.0 的哪一处改动让三道去重防线同时失守,是本版最需要耐心的一次排查。
- [#5916](https://github.com/farion1231/cc-switch/pull/5916)、[#5928](https://github.com/farion1231/cc-switch/pull/5928):删除 3,166 行无调用方代码与重复的代理查询层,感谢 @SaladDay
- [#5943](https://github.com/farion1231/cc-switch/pull/5943):补齐繁体中文的工具管理文案,并新增防止语言漂移的测试,感谢 @yovinchen
- [#5960](https://github.com/farion1231/cc-switch/pull/5960):补齐 9 个在所有语言下都显示简体中文的文案,感谢 @mhy1227
### 问题反馈
感谢 @Alaric-L 在 [#5938](https://github.com/farion1231/cc-switch/issues/5938) 中报告 Claude Desktop 的每次请求都多出一条 `session_log` 来源的日志、导致 token 被统计两遍——这条反馈精确到了数据来源,本版最重要的用量修复直接由它定位。
---
## 下载与安装
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本,或从官网 [ccswitch.io](https://ccswitch.io) 获取(下载经 Cloudflare 边缘节点分发,不依赖 GitHub 可达)。
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | -------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 / ARM64 |
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 / ARM64 |
### Windows
| 文件 | 说明 |
| ---------------------------------------- | ----------------------------------- |
| `CC-Switch-v3.19.1-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.19.1-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
Windows ARM64 设备请选择文件名中带 `arm64` 标识的对应制品。
### macOS
| 文件 | 说明 |
| -------------------------------- | --------------------------------------------- |
| `CC-Switch-v3.19.1-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
| `CC-Switch-v3.19.1-macOS.zip` | 解压后拖入 ApplicationsUniversal Binary |
| `CC-Switch-v3.19.1-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
Homebrew 安装:
```bash
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
Linux 资产同时提供 **x86_64****ARM64**`aarch64`)两种架构。资产文件名中包含架构标识,请按你机器的 `uname -m` 输出选择对应版本:
- `CC-Switch-v3.19.1-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- `CC-Switch-v3.19.1-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` |
+43
View File
@@ -0,0 +1,43 @@
# Restore 加固工程:债务登记表(自 Pi 工程移交)
> 状态:立项挂起的独立未来工程。由用户裁决(2026-08-02)自 Pi 支持工程移交:
> Pi 的 PR 聚焦业务点,restore 存量产品面的对抗加固不属于其范围。
> 移交时点:前置 B 终止 checkpoint `3fa6b1f158b4e609b1588affb366bd90fb11878e`。
> 表中 D1–D4 为 **Pi 工程之前即存在的产品面债务**(非 Pi 引入、非回归);
> **D5 例外**:该死代码由 Pi 工程自身的 `4f784514`(typed provider writes /
> canonical restore)引入,属本工程遗留物,未来工程处理时应优先直接删除。
> Pi 耦合面(设备本地台账重建、弱化 schema 不可发布、token 不入可迁移产物)
> 已实现并在两套认证套件中持续全绿。
## Pi 工程期间已完成并保留的加固(随 Pi PR 交付)
- Data-only canonical restore:`UntrustedScratch`/`CanonicalStage` 类型屏障,
导入 schema 永不成为发布权威;
- `MigrationSourceSpec`(v16/v17)源规格先行 + 全函数 typed 映射;
- 不可信恢复版本门:仅接受 user_version N/N1(16/17),含升级前备份回滚专测;
- `MigrationRunContext { LocalUpgrade, UntrustedRestore }` 语义分离;
- 值域强制(sort_index、circuit_error_rate_threshold ∈ [0,1] 等)、
auto_vacuum 继承、设备本地台账 canonical 重建;
- 恶意 schema 语料、预算、文件边界等既有测试全绿(Rust 2372 passed)。
## 未决 finding(终止时点,均已双 reviewer/主审确认)
| # | 严重度 | 描述 | 现有缓解 |
|---|---|---|---|
| D1 | High | `Database` 未持有不可变 live 路径:配置目录变更"稍后重启"期间,旧连接与新目录缓存并存,safety backup 与 canonical publish 可作用于不同库(mod.rs:90、backup.rs:2295、app_store.rs:104、useSettings.ts:363)。根治需 `DatabaseHome`/`BackupRepository` 不可变注入架构 | 无(建议未来工程首项;临时缓解可为"待重启状态禁用备份/恢复操作") |
| D2 | High | restore 行校验未复用写面校验链:可发布空 ID/app_type(`ProviderKey::new` 会拒)、空白或尾斜杠 URL 的 endpoint(尾斜杠规范化在 `NewProviderAggregate::from_input` 而非 `NewEndpoint` 内,恢复路径两者都绕过),恢复后无法被 remove/touch 命中(backup.rs:549) | 写面自身拒绝此类值;仅恢复路径可引入 |
| D3 | High | safety backup 直写可预测最终文件名;dangling symlink 绕过 `Path::exists()`,`Connection::open` 跟随链接;nofollow 检查晚于内容写出(backup.rs:2307) | 需本机文件系统攻击者前置 |
| D4 | Medium | 全表 decoder sweep 不完整:`proxy_live_backup.original_config` 非法 JSON、`usage_daily_rollups.date` 非法日期可发布,生产读取时失败(backup.rs:633) | 读取侧失败即暴露,无静默数据错 |
| D5 | Medium | 取消标志固定 false,无 setter/外部 token,cancellation check 不可触发(backup.rs:1248) | 纯死代码,建议未来删除或接真实 token |
## 一并挂起的既定立项
- v1–v15 历史备份导入(缩围裁决 2026-08-02;`MigrationSourceSpec` 架构为其地基);
- 主审否决记录:两位 reviewer 建议"升级前备份失败应阻断启动",因违反
"LocalUpgrade 既有行为不变"裁决未采信——未来工程重议时须重新评估。
## 机制锁定
Pi PR 内 restore 面已被前置 A 认证套件的 infra SHA-256 基线重冻
(schema.rs/migration.rs/backup.rs @ 3fa6b1f1 状态):任何再触碰即认证红,
防止范围回潮。未来工程启动时由其裁决方解冻。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.19.1",
"version": "3.19.0",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
File diff suppressed because it is too large Load Diff
+776
View File
@@ -0,0 +1,776 @@
#!/usr/bin/env node
/**
* Pi transport request-capture harness.
*
* 现有的 native-oracle 只执行 pinned Pi 的 schema evaluator / composer /
* config-value resolver,**不执行** adapter 与厂商 SDK 的头合并,因此
* "Pi 实际发出什么认证头" 一直只能靠读源码推断。本脚本补上这一层:
* 起一个本地 HTTP 抓包端点当 baseUrl,用 pinned Pi 的 adapter 真发一次
* 请求,记录实际发出的 header。
*
* 用法:
* PI_CHECKOUT=/path/to/pinned/pi node scripts/pi-transport-capture.mjs
*
* 不含任何密钥:测试用的 apiKey 是本地抓包用的假值;若要打真实端点,
* 通过环境变量传入(PI_CAPTURE_BASE_URL / PI_CAPTURE_API_KEY),不要写进文件。
*
* 输出为 JSON,可作为 transport 断言的出处依据。若要升级为受冻结的
* oracle 夹具,请比照 scripts/generate-pi-native-oracle.mjs 补 provenance
* (pinned commit、源码哈希、bundler 版本)。
*/
import { createServer } from "node:http";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
const PI = process.env.PI_CHECKOUT;
const EXPECTED_PI_COMMIT = "ab366ebe94cacd419d986be454f12b1b9913aaca";
if (!PI) {
console.error(
"PI_CHECKOUT must point at a pinned Pi checkout (with node_modules).",
);
process.exit(2);
}
const piCommit = execFileSync("git", ["-C", PI, "rev-parse", "HEAD"], {
encoding: "utf8",
}).trim();
if (piCommit !== EXPECTED_PI_COMMIT) {
throw new Error(
`Pi checkout pin mismatch: expected ${EXPECTED_PI_COMMIT}, got ${piCommit}`,
);
}
const requireFromPi = createRequire(join(PI, "package.json"));
const { buildSync, version: esbuildVersion } = requireFromPi("esbuild");
const codingAgentPackagePath = join(PI, "packages/coding-agent/package.json");
const codingAgentPackageBytes = readFileSync(codingAgentPackagePath);
const codingAgentPackage = JSON.parse(codingAgentPackageBytes.toString("utf8"));
const distributionMetadata = {
source: "packages/coding-agent/package.json",
sha256: createHash("sha256").update(codingAgentPackageBytes).digest("hex"),
name: codingAgentPackage.name,
version: codingAgentPackage.version,
bin: codingAgentPackage.bin,
piConfig: codingAgentPackage.piConfig,
};
const ANTHROPIC_SSE =
'event: message_start\ndata: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"m","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}}\n\n' +
'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}\n\n' +
'event: message_stop\ndata: {"type":"message_stop"}\n\n';
const OPENAI_SSE =
'data: {"type":"response.completed","response":{"id":"r","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n' +
"data: [DONE]\n\n";
const captured = [];
const server = createServer((request, response) => {
const chunks = [];
request.on("data", (chunk) => chunks.push(chunk));
request.on("end", () => {
captured.push({ url: request.url, headers: { ...request.headers } });
response.writeHead(200, { "content-type": "text/event-stream" });
response.end(request.url.includes("messages") ? ANTHROPIC_SSE : OPENAI_SSE);
});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const baseUrl =
process.env.PI_CAPTURE_BASE_URL ??
`http://127.0.0.1:${server.address().port}`;
const harnessDirectory = mkdtempSync(join(PI, ".cc-switch-transport-capture-"));
process.on("exit", () =>
rmSync(harnessDirectory, { recursive: true, force: true }),
);
const aiShimPath = join(harnessDirectory, "pi-ai-shim.mjs");
const compatShimPath = join(harnessDirectory, "pi-ai-compat-shim.mjs");
writeFileSync(
aiShimPath,
[
'export function lazyStream() { throw new Error("transport shim must not execute during compat capture"); }',
"let uuidSequence = 0;",
'export function uuidv7() { uuidSequence += 1; return `00000000-0000-7000-8000-${String(uuidSequence).padStart(12, "0")}`; }',
"export class EventStream {}",
"export class ModelsError extends Error {}",
"export function validateToolArguments() { return undefined; }",
'export function contentText(value) { return typeof value === "string" ? value : ""; }',
'export function retryAssistantCall() { throw new Error("resource capture must not call AI"); }',
"export function parseStreamingJson() { return undefined; }",
"export function modelsAreEqual(left, right) { return left === right; }",
"export function createModels() { return {}; }",
"export function getBuiltinModelDataGeneratedAt() { return undefined; }",
"export function builtinProviders() { return []; }",
"export function radiusProvider() { return undefined; }",
"",
].join("\n"),
);
writeFileSync(
compatShimPath,
[
'export function getApiProvider() { throw new Error("transport shim must not execute during compat capture"); }',
"export function clampThinkingLevel(value) { return value; }",
"export async function cleanupSessionResources() {}",
"export function getSupportedThinkingLevels() { return []; }",
"export function isContextOverflow() { return false; }",
"export function isRetryableAssistantError() { return false; }",
"export function modelsAreEqual(left, right) { return left === right; }",
"export function resetApiProviders() {}",
'export function streamSimple() { throw new Error("resource capture must not call AI"); }',
'export function stream() { throw new Error("resource capture must not call AI"); }',
'export function completeSimple() { throw new Error("resource capture must not call AI"); }',
"",
].join("\n"),
);
const entryPoint = join(harnessDirectory, "entry.mjs");
writeFileSync(
entryPoint,
[
`export { streamSimple as anthropicMessages } from "${PI}/packages/ai/src/api/anthropic-messages.ts";`,
`export { streamSimple as openaiResponses } from "${PI}/packages/ai/src/api/openai-responses.ts";`,
`export { streamSimple as openaiCompletions } from "${PI}/packages/ai/src/api/openai-completions.ts";`,
`export { streamSimple as googleGenerativeAi } from "${PI}/packages/ai/src/api/google-generative-ai.ts";`,
`export { composeModelProvider } from "${PI}/packages/coding-agent/src/core/provider-composer.ts";`,
`export { resolveConfigValueOrThrow } from "${PI}/packages/coding-agent/src/core/resolve-config-value.ts";`,
`export { loadSkills } from "${PI}/packages/coding-agent/src/core/skills.ts";`,
`export { loadPromptTemplates } from "${PI}/packages/coding-agent/src/core/prompt-templates.ts";`,
`export { SessionManager } from "${PI}/packages/coding-agent/src/core/session-manager.ts";`,
`export { parseArgs } from "${PI}/packages/coding-agent/src/cli/args.ts";`,
`export { createAllToolDefinitions } from "${PI}/packages/coding-agent/src/core/tools/index.ts";`,
].join("\n"),
);
const bundlePath = join(harnessDirectory, "bundle.mjs");
buildSync({
entryPoints: [entryPoint],
bundle: true,
platform: "node",
format: "esm",
outfile: bundlePath,
external: ["node:*"],
packages: "external",
alias: {
"@earendil-works/pi-ai": aiShimPath,
"@earendil-works/pi-ai/compat": compatShimPath,
},
logLevel: "silent",
});
const adapters = await import(pathToFileURL(bundlePath).href);
// ResourceLoader pulls the complete coding-agent resource graph. Bundle the
// real pinned ResourceLoader separately with broad AI stubs; the captured
// instruction behavior remains real while unrelated generated model data is
// kept outside this resource-only probe.
const resourceEntryPoint = join(harnessDirectory, "resource-entry.mjs");
const resourceBundlePath = join(harnessDirectory, "resource-bundle.mjs");
writeFileSync(
resourceEntryPoint,
`export { DefaultResourceLoader } from "${PI}/packages/coding-agent/src/core/resource-loader.ts";\n`,
);
buildSync({
entryPoints: [resourceEntryPoint],
bundle: true,
platform: "node",
format: "esm",
outfile: resourceBundlePath,
external: ["node:*"],
packages: "external",
alias: {
"@earendil-works/pi-ai/providers/all": aiShimPath,
"@earendil-works/pi-ai/oauth": aiShimPath,
"@earendil-works/pi-ai": aiShimPath,
"@earendil-works/pi-ai/compat": compatShimPath,
},
logLevel: "silent",
});
const resourceAdapters = await import(pathToFileURL(resourceBundlePath).href);
const API_BY_ADAPTER = {
anthropicMessages: "anthropic-messages",
openaiResponses: "openai-responses",
openaiCompletions: "openai-completions",
googleGenerativeAi: "google-generative-ai",
};
/** 每个用例只改变凭证与显式 header,其余保持最小合法模型。 */
const CASES = [
["anthropicMessages", "plain-key", "sk-ant-api03-plain", {}],
["anthropicMessages", "oauth-token", "sk-ant-oat01-token", {}],
[
"anthropicMessages",
"oauth-with-explicit-x-api-key",
"sk-ant-oat01-token",
{ "x-api-key": "explicit-secret" },
],
[
"anthropicMessages",
"oauth-with-explicit-authorization",
"sk-ant-oat01-token",
{ authorization: "Bearer configured" },
],
[
"anthropicMessages",
"explicit-x-api-key",
"synthesized-secret",
{ "x-api-key": "explicit-secret" },
],
[
"anthropicMessages",
"explicit-authorization",
"synthesized-secret",
{ authorization: "Bearer configured" },
],
["openaiResponses", "plain-key", "sk-plain", {}],
["openaiResponses", "oauth-shaped-token", "sk-ant-oat01-not-anthropic", {}],
[
"openaiResponses",
"explicit-authorization",
"synthesized-secret",
{ authorization: "Bearer configured" },
],
[
"openaiCompletions",
"explicit-authorization",
"synthesized-secret",
{ authorization: "Bearer configured" },
],
[
"openaiCompletions",
"explicit-x-api-key",
"synthesized-secret",
{ "x-api-key": "explicit-secret" },
],
["googleGenerativeAi", "plain-key", "google-plain", {}],
[
"googleGenerativeAi",
"explicit-x-goog-api-key",
"google-synthesized",
{ "x-goog-api-key": "google-explicit" },
],
];
const results = [];
for (const [adapter, label, apiKey, headers] of CASES) {
const model = {
id: "m",
name: "m",
api: API_BY_ADAPTER[adapter],
provider: "candidate",
baseUrl,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
const before = captured.length;
let error;
try {
const stream = adapters[adapter](
model,
{ messages: [{ role: "user", content: "hi" }] },
{
apiKey: process.env.PI_CAPTURE_API_KEY ?? apiKey,
headers,
maxTokens: 16,
},
);
for await (const _event of stream) {
// drain
}
} catch (caught) {
error = String(caught);
}
const request =
captured.length > before ? captured[captured.length - 1] : undefined;
results.push({
adapter: API_BY_ADAPTER[adapter],
case: label,
requestSent: Boolean(request),
requestUrl: request?.url,
error: request ? undefined : error,
authHeaders: request
? Object.fromEntries(
Object.entries(request.headers).filter(([name]) =>
[
"authorization",
"x-api-key",
"x-goog-api-key",
"anthropic-beta",
"anthropic-version",
"openai-beta",
].includes(name),
),
)
: undefined,
});
}
const compatInput = {
api: "openai-responses",
baseUrl: "https://compat.example/v1",
apiKey: "literal",
compat: {
openRouterRouting: ["first", "second"],
chatTemplateKwargs: "ab",
baseOnly: true,
},
models: [{ id: "m", compat: { supportsStore: true } }],
modelOverrides: {
m: {
compat: {
openRouterRouting: null,
chatTemplateKwargs: { named: true },
overlayOnly: true,
},
},
},
};
const compatProvider = adapters.composeModelProvider(
"compat-spread",
undefined,
{
getProvider(providerId) {
return providerId === "compat-spread" ? compatInput : undefined;
},
},
undefined,
);
const compatSpread = compatProvider.getModels()[0].compat;
const minimalProvider = adapters.composeModelProvider(
"minimal-provider",
undefined,
{
getProvider(providerId) {
return providerId === "minimal-provider"
? {
name: "Minimal provider",
api: "openai-responses",
baseUrl: "https://minimal.example/v1",
apiKey: "literal",
models: [{ id: "minimal-model", name: "Minimal model" }],
}
: undefined;
},
},
undefined,
);
const minimalModel = minimalProvider.getModels()[0];
const minimalProviderComposition = {
id: minimalModel.id,
name: minimalModel.name,
provider: minimalModel.provider,
api: minimalModel.api,
baseUrl: minimalModel.baseUrl,
reasoning: minimalModel.reasoning,
input: minimalModel.input,
cost: minimalModel.cost,
contextWindow: minimalModel.contextWindow,
maxTokens: minimalModel.maxTokens,
};
function jsonSafeJavaScriptValue(value) {
if (typeof value === "string") {
const codeUnits = Array.from({ length: value.length }, (_, index) =>
value.charCodeAt(index),
);
const hasLoneSurrogate = codeUnits.some((unit, index) => {
if (unit >= 0xd800 && unit <= 0xdbff) {
return !(
index + 1 < codeUnits.length &&
codeUnits[index + 1] >= 0xdc00 &&
codeUnits[index + 1] <= 0xdfff
);
}
if (unit >= 0xdc00 && unit <= 0xdfff) {
return !(
index > 0 &&
codeUnits[index - 1] >= 0xd800 &&
codeUnits[index - 1] <= 0xdbff
);
}
return false;
});
return hasLoneSurrogate
? {
$javascriptStringUtf16: codeUnits.map((unit) =>
unit.toString(16).padStart(4, "0"),
),
}
: value;
}
if (Array.isArray(value)) {
return value.map(jsonSafeJavaScriptValue);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
jsonSafeJavaScriptValue(child),
]),
);
}
return value;
}
function captureCompatSpread(label, baseValue, overlayValue) {
const providerId = `compat-${label}`;
const provider = adapters.composeModelProvider(
providerId,
undefined,
{
getProvider(candidate) {
if (candidate !== providerId) return undefined;
return {
api: "openai-responses",
baseUrl: "https://compat.example/v1",
apiKey: "literal",
compat: { chatTemplateKwargs: baseValue },
models: [{ id: "m" }],
modelOverrides: {
m: { compat: { chatTemplateKwargs: overlayValue } },
},
};
},
},
undefined,
);
return {
label,
baseValue,
overlayValue,
result: jsonSafeJavaScriptValue(
provider.getModels()[0].compat.chatTemplateKwargs,
),
};
}
const compatEdgeCases = [
captureCompatSpread("ascii-string-to-string", "ab", "cd"),
captureCompatSpread("astral-string-to-object", "😀", { named: true }),
captureCompatSpread("astral-string-fully-overridden", "😀", {
0: "repaired-high",
1: "repaired-low",
named: true,
}),
captureCompatSpread("string-to-array", "ab", ["first", "second"]),
];
const resolverInputs = [
"literal-secret",
"cash$money",
"café$literal",
"$$literal-$!bang",
"prefix-${PI_CAPTURE_MISSING}-suffix",
];
const resolverCases = resolverInputs.map((input) => {
try {
return {
input,
status: "success",
result: adapters.resolveConfigValueOrThrow(
input,
"transport capture",
{},
),
};
} catch (error) {
return {
input,
status: "error",
error: error instanceof Error ? error.message : String(error),
};
}
});
// Execute Pi's real discovery entry point. The directories are deliberately
// created in reverse order: a deterministic winner must come from Pi's
// discovery ordering, not filesystem insertion order.
const skillProbeRoot = join(harnessDirectory, "skill-discovery");
const skillAgentDir = join(skillProbeRoot, "agent");
const skillProjectDir = join(skillProbeRoot, "project");
for (const directory of [
skillProjectDir,
join(skillAgentDir, "skills", "b-second"),
join(skillAgentDir, "skills", "a-first"),
]) {
mkdirSync(directory, { recursive: true });
}
writeFileSync(
join(skillAgentDir, "skills", "b-second", "SKILL.md"),
"---\nname: duplicate\ndescription: second\n---\nsecond\n",
);
writeFileSync(
join(skillAgentDir, "skills", "a-first", "SKILL.md"),
"---\nname: duplicate\ndescription: first\n---\nfirst\n",
);
const skillDiscovery = jsonSafeJavaScriptValue(
await adapters.loadSkills({
cwd: skillProjectDir,
agentDir: skillAgentDir,
skillPaths: [],
includeDefaults: true,
}),
);
const promptAgentDir = join(harnessDirectory, "prompt-agent");
const promptProjectDir = join(harnessDirectory, "prompt-project");
mkdirSync(join(promptAgentDir, "prompts", "nested"), { recursive: true });
mkdirSync(promptProjectDir, { recursive: true });
writeFileSync(
join(promptAgentDir, "prompts", "review.md"),
"---\ndescription: Review captured changes\nargument-hint: <range>\n---\nReview $1\n",
);
writeFileSync(join(promptAgentDir, "prompts", "empty.md"), "");
writeFileSync(
join(promptAgentDir, "prompts", "nested", "ignored.md"),
"nested",
);
const promptTemplateDiscovery = jsonSafeJavaScriptValue(
adapters
.loadPromptTemplates({
cwd: promptProjectDir,
agentDir: promptAgentDir,
promptPaths: [],
includeDefaults: true,
})
.map((template) => ({
name: template.name,
description: template.description,
argumentHint: template.argumentHint,
content: template.content,
source: template.sourceInfo?.source,
scope: template.sourceInfo?.scope,
relativeFile:
template.filePath ===
join(promptAgentDir, "prompts", `${template.name}.md`)
? `prompts/${template.name}.md`
: template.filePath,
})),
);
// File presence, including a zero-byte file, is the native activation state
// for Pi's global instruction resources. Execute the real resource loader so
// cc-switch does not infer that rule from a parser implementation.
const instructionAgentDir = join(harnessDirectory, "instruction-agent");
const instructionProjectDir = join(harnessDirectory, "instruction-project");
mkdirSync(instructionAgentDir, { recursive: true });
mkdirSync(instructionProjectDir, { recursive: true });
for (const filename of ["AGENTS.md", "SYSTEM.md", "APPEND_SYSTEM.md"]) {
writeFileSync(join(instructionAgentDir, filename), "");
}
const instructionLoader = new resourceAdapters.DefaultResourceLoader({
cwd: instructionProjectDir,
agentDir: instructionAgentDir,
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
});
await instructionLoader.reload();
const emptyInstructionFiles = {
agentsFiles: instructionLoader.getAgentsFiles().agentsFiles.map((entry) => ({
relativeFile: entry.path.startsWith(instructionAgentDir)
? entry.path.slice(instructionAgentDir.length + 1)
: entry.path,
content: entry.content,
})),
systemPrompt: instructionLoader.getSystemPrompt(),
systemPromptSource: instructionLoader.getSystemPromptSource()?.path,
appendSystemPrompt: instructionLoader.getAppendSystemPrompt(),
appendSystemPromptSources: instructionLoader
.getAppendSystemPromptSources()
.map((entry) => entry.path),
};
// Exercise Pi's real SessionManager instead of inferring sessionDir or JSONL
// shape from its TypeScript source. Relative sessionDir is resolved against the
// launching process cwd, while the header keeps the explicit project cwd.
const sessionProjectDir = join(harnessDirectory, "session project");
mkdirSync(sessionProjectDir, { recursive: true });
const originalCwd = process.cwd();
process.chdir(sessionProjectDir);
const capturedSession = adapters.SessionManager.create(
sessionProjectDir,
".pi/sessions",
{ id: "cc-switch-capture-session" },
);
capturedSession.appendSessionInfo("Captured session");
capturedSession.appendMessage({
role: "user",
content: [{ type: "text", text: "captured question" }],
timestamp: 1_700_000_000_000,
});
capturedSession.appendMessage({
role: "assistant",
content: [{ type: "text", text: "captured answer" }],
api: "openai-responses",
provider: "capture",
model: "capture-model",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 1_700_000_001_000,
});
const capturedSessionFile = capturedSession.getSessionFile();
if (!capturedSessionFile) {
throw new Error("pinned SessionManager did not persist the capture session");
}
const capturedSessionPath = resolve(capturedSessionFile);
const parsedSessionArgs = adapters.parseArgs([
"--session",
capturedSessionPath,
]);
const parsedVersionArgs = adapters.parseArgs(["--version"]);
const sessionCliSemantics = {
argv: ["--session", capturedSessionPath],
parsedSession: parsedSessionArgs.session,
diagnostics: parsedSessionArgs.diagnostics,
versionArgv: ["--version"],
parsedVersion: parsedVersionArgs.version,
versionDiagnostics: parsedVersionArgs.diagnostics,
};
const capturedSessionLines = capturedSessionFile
? readFileSync(capturedSessionFile, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line))
: [];
const sessionDirectorySemantics = {
processCwd: sessionProjectDir,
suppliedProjectCwd: sessionProjectDir,
suppliedSessionDir: ".pi/sessions",
resolvedSessionDir: capturedSession.getSessionDir(),
headerKeys: Object.keys(capturedSession.getHeader() ?? {}).sort(),
entryShapes: capturedSession.getEntries().map((entry) => ({
type: entry.type,
keys: Object.keys(entry).sort(),
messageRole: entry.type === "message" ? entry.message.role : undefined,
messageKeys:
entry.type === "message" ? Object.keys(entry.message).sort() : undefined,
})),
persistedLineTypes: capturedSessionLines.map((entry) => entry.type),
listAllCount: (
await adapters.SessionManager.listAll(capturedSession.getSessionDir())
).length,
};
const branchedSession = adapters.SessionManager.create(
sessionProjectDir,
".pi/sessions",
{ id: "cc-switch-capture-branch" },
);
const branchRootId = branchedSession.appendMessage({
role: "user",
content: [{ type: "text", text: "branch root" }],
timestamp: 1_700_000_002_000,
});
branchedSession.appendSessionInfo("Abandoned branch name");
branchedSession.appendMessage({
role: "assistant",
content: [{ type: "text", text: "abandoned answer" }],
api: "openai-responses",
provider: "capture",
model: "capture-model",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 1_700_000_003_000,
});
branchedSession.branch(branchRootId);
branchedSession.appendMessage({
role: "user",
content: [{ type: "text", text: "active branch" }],
timestamp: 1_700_000_004_000,
});
const sessionBranchSemantics = {
sessionName: branchedSession.getSessionName(),
activeEntryTypes: branchedSession.getBranch().map((entry) => entry.type),
};
const malformedSessionFile = join(
capturedSession.getSessionDir(),
"cc-switch-capture-malformed.jsonl",
);
writeFileSync(
malformedSessionFile,
[
JSON.stringify(capturedSession.getHeader()),
"{not valid json",
...capturedSession
.getEntries()
.map((entry) => JSON.stringify(entry)),
"",
].join("\n"),
);
let malformedSessionSemantics;
try {
const malformedSession = adapters.SessionManager.open(malformedSessionFile);
malformedSessionSemantics = {
status: "accepted",
entryTypes: malformedSession.getEntries().map((entry) => entry.type),
};
} catch (error) {
malformedSessionSemantics = {
status: "rejected",
error: error instanceof Error ? error.message : String(error),
};
}
process.chdir(originalCwd);
// The executed built-in tool factory is the authoritative core inventory.
// Extension-provided tools remain possible, but Pi exposes no native MCP
// registry for cc-switch to mirror as an app toggle.
const nativeToolInventory = Object.values(
adapters.createAllToolDefinitions(sessionProjectDir),
)
.map((tool) => tool.name)
.sort();
server.close();
console.log(
JSON.stringify(
{
bundler: `esbuild@${esbuildVersion}`,
piCheckout: PI,
piCommit,
distributionMetadata,
baseUrl,
results,
minimalProviderComposition,
compatSpread,
compatEdgeCases,
resolverCases,
skillDiscovery,
promptTemplateDiscovery,
emptyInstructionFiles,
sessionDirectorySemantics,
sessionCliSemantics,
sessionBranchSemantics,
malformedSessionSemantics,
nativeToolInventory,
},
null,
2,
),
);
+789 -769
View File
File diff suppressed because it is too large Load Diff
+32 -22
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.19.1"
version = "3.19.0"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
@@ -23,7 +23,8 @@ test-hooks = []
tauri-build = { version = "2.4.0", features = [] }
[dependencies]
serde_json = { version = "1.0", features = ["preserve_order"] }
serde_json = { version = "1.0", features = ["arbitrary_precision", "preserve_order"] }
jsonc-parser = { version = "0.33", features = ["cst", "serde_json"] }
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
chrono = { version = "0.4", features = ["serde"] }
@@ -36,21 +37,21 @@ tauri-plugin-dialog = "2"
tauri-plugin-store = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-window-state = "2"
dirs = "6.0"
toml = "1.0"
toml_edit = "0.25"
dirs = "5.0"
toml = "0.8"
toml_edit = "0.22"
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
arboard = "3.6"
flate2 = "1"
brotli = "8"
brotli = "7"
zstd = "0.13"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
futures = "0.3"
async-stream = "0.3"
bytes = "1.5"
axum = "0.8"
tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] }
axum = "0.7"
tower = "0.4"
tower-http = { version = "0.5", features = ["cors"] }
hyper = { version = "1.0", features = ["full"] }
hyper-util = { version = "0.1", features = ["tokio", "http1", "client-legacy"] }
hyper-rustls = { version = "0.27", features = ["http1", "tls12", "ring", "webpki-tokio"] }
@@ -60,26 +61,27 @@ http-body-util = "0.1"
httparse = "1"
tokio-rustls = "0.26"
rustls = "0.23"
webpki-roots = "1.0"
webpki-roots = "0.26"
rustls-native-certs = "0.8"
regex = "1.10"
rquickjs = { version = "0.12", features = ["array-buffer", "classes"] }
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
thiserror = "2.0"
anyhow = "1.0"
zip = "4.6"
zip = "2.2"
serde_yaml = "0.9"
tempfile = "3"
url = "2.5"
auto-launch = "0.6"
auto-launch = "0.5"
once_cell = "1.21.3"
base64 = "0.23"
rusqlite = { version = "0.40", features = ["bundled", "backup", "hooks"] }
base64 = "0.22"
rusqlite = { version = "0.31", features = ["bundled", "backup", "hooks", "limits"] }
indexmap = { version = "2", features = ["serde"] }
rust_decimal = "1.33"
uuid = { version = "1.11", features = ["v4"] }
sha2 = "0.11"
hmac = "0.13"
json5 = "1.3"
sha2 = "0.10"
libc = "0.2"
hmac = "0.12"
json5 = "0.4"
json-five = "0.3.1"
sys-locale = "0.3"
@@ -90,10 +92,17 @@ tauri-plugin-single-instance = "2"
webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.55"
winreg = "0.52"
windows-sys = { version = "0.61", features = [
"Wdk_Foundation",
"Wdk_Storage_FileSystem",
"Win32_Globalization",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_JobObjects",
"Win32_System_IO",
"Win32_System_Threading",
"Win32_UI_Shell",
] }
@@ -101,8 +110,8 @@ windows-sys = { version = "0.61", features = [
rquickjs = { version = "0.8", features = ["bindgen"] }
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSColor"] }
objc2 = "0.5"
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
# Optimize release binary size to help reduce AppImage footprint
[profile.release]
@@ -114,5 +123,6 @@ panic = "unwind"
strip = "symbols"
[dev-dependencies]
serial_test = "4"
serial_test = "3"
tempfile = "3"
syn = { version = "2", features = ["full", "visit"] }
+35 -30
View File
@@ -32,6 +32,7 @@ impl McpApps {
AppType::OpenCode => self.opencode,
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
AppType::Hermes => self.hermes,
AppType::Pi => false, // Pi core has no native MCP registry.
AppType::ClaudeDesktop => false,
}
}
@@ -46,6 +47,7 @@ impl McpApps {
AppType::OpenCode => self.opencode = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
AppType::Hermes => self.hermes = enabled,
AppType::Pi => {} // Pi core has no native MCP registry.
AppType::ClaudeDesktop => {} // Claude Desktop 3P provider config doesn't support MCP here
}
}
@@ -100,6 +102,8 @@ pub struct SkillApps {
pub opencode: bool,
#[serde(default)]
pub hermes: bool,
#[serde(default)]
pub pi: bool,
}
impl SkillApps {
@@ -112,6 +116,7 @@ impl SkillApps {
AppType::GrokBuild => self.grokbuild,
AppType::OpenCode => self.opencode,
AppType::Hermes => self.hermes,
AppType::Pi => self.pi,
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
AppType::ClaudeDesktop => false,
}
@@ -126,6 +131,7 @@ impl SkillApps {
AppType::GrokBuild => self.grokbuild = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::Hermes => self.hermes = enabled,
AppType::Pi => self.pi = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
AppType::ClaudeDesktop => {} // Claude Desktop 3P profiles don't use CC Switch skill sync
}
@@ -152,6 +158,9 @@ impl SkillApps {
if self.hermes {
apps.push(AppType::Hermes);
}
if self.pi {
apps.push(AppType::Pi);
}
apps
}
@@ -163,6 +172,7 @@ impl SkillApps {
&& !self.grokbuild
&& !self.opencode
&& !self.hermes
&& !self.pi
}
/// 仅启用指定应用(其他应用设为禁用)
@@ -357,6 +367,8 @@ pub struct PromptRoot {
pub openclaw: PromptConfig,
#[serde(default)]
pub hermes: PromptConfig,
#[serde(default)]
pub pi: PromptConfig,
}
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
@@ -381,6 +393,7 @@ pub enum AppType {
OpenCode,
OpenClaw,
Hermes,
Pi,
}
impl AppType {
@@ -394,6 +407,7 @@ impl AppType {
AppType::OpenCode => "opencode",
AppType::OpenClaw => "openclaw",
AppType::Hermes => "hermes",
AppType::Pi => "pi",
}
}
@@ -419,6 +433,7 @@ impl AppType {
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
AppType::Pi,
]
.into_iter()
}
@@ -438,10 +453,11 @@ impl FromStr for AppType {
"opencode" => Ok(AppType::OpenCode),
"openclaw" => Ok(AppType::OpenClaw),
"hermes" => Ok(AppType::Hermes),
"pi" => Ok(AppType::Pi),
other => Err(AppError::localized(
"unsupported_app",
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."),
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes, pi"),
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes, pi."),
)),
}
}
@@ -467,6 +483,9 @@ pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hermes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pi: Option<String>,
}
impl CommonConfigSnippets {
@@ -481,6 +500,7 @@ impl CommonConfigSnippets {
AppType::OpenCode => self.opencode.as_ref(),
AppType::OpenClaw => self.openclaw.as_ref(),
AppType::Hermes => self.hermes.as_ref(),
AppType::Pi => self.pi.as_ref(),
}
}
@@ -495,6 +515,7 @@ impl CommonConfigSnippets {
AppType::OpenCode => self.opencode = snippet,
AppType::OpenClaw => self.openclaw = snippet,
AppType::Hermes => self.hermes = snippet,
AppType::Pi => self.pi = snippet,
}
}
}
@@ -539,6 +560,7 @@ impl Default for MultiAppConfig {
apps.insert("opencode".to_string(), ProviderManager::default());
apps.insert("openclaw".to_string(), ProviderManager::default());
apps.insert("hermes".to_string(), ProviderManager::default());
apps.insert("pi".to_string(), ProviderManager::default());
Self {
version: 2,
@@ -626,6 +648,12 @@ impl MultiAppConfig {
.insert("gemini".to_string(), ProviderManager::default());
updated = true;
}
if !config.apps.contains_key("pi") {
config
.apps
.insert("pi".to_string(), ProviderManager::default());
updated = true;
}
// 执行 MCP 迁移(v3.6.x → v3.7.0
let migrated = config.migrate_mcp_to_unified()?;
@@ -691,34 +719,6 @@ impl MultiAppConfig {
}
}
/// 获取指定客户端的 MCP 配置(不可变引用)
pub fn mcp_for(&self, app: &AppType) -> &McpConfig {
match app {
AppType::Claude => &self.mcp.claude,
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,
}
}
/// 获取指定客户端的 MCP 配置(可变引用)
pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {
match app {
AppType::Claude => &mut self.mcp.claude,
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,
}
}
/// 创建默认配置并自动导入已存在的提示词文件
fn default_with_auto_import() -> Result<Self, AppError> {
log::info!("首次启动,创建默认配置并检测提示词文件");
@@ -733,6 +733,7 @@ impl MultiAppConfig {
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)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Pi)?;
Ok(config)
}
@@ -757,6 +758,7 @@ impl MultiAppConfig {
|| !self.prompts.opencode.prompts.is_empty()
|| !self.prompts.openclaw.prompts.is_empty()
|| !self.prompts.hermes.prompts.is_empty()
|| !self.prompts.pi.prompts.is_empty()
{
return Ok(false);
}
@@ -772,6 +774,7 @@ impl MultiAppConfig {
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
AppType::Pi,
] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
@@ -846,6 +849,7 @@ impl MultiAppConfig {
AppType::OpenCode => &mut config.prompts.opencode.prompts,
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
AppType::Hermes => &mut config.prompts.hermes.prompts,
AppType::Pi => &mut config.prompts.pi.prompts,
};
prompts.insert(id, prompt);
@@ -889,6 +893,7 @@ impl MultiAppConfig {
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
AppType::Pi => continue, // Pi didn't exist in v3.6.x, skip
};
for (id, entry) in old_servers {
File diff suppressed because it is too large Load Diff
+38 -538
View File
@@ -417,93 +417,6 @@ pub fn codex_auth_has_oauth_login_material(auth: &Value) -> bool {
})
}
/// True only when the auth carries material Codex itself authenticates with
/// ahead of the API-key fallback: OAuth tokens or another first-class login
/// carrier. Unlike `codex_auth_has_oauth_login_material`, pure metadata such
/// as `last_refresh` or `tokens.account_id` does NOT count — metadata must not
/// shield a stale third-party `OPENAI_API_KEY` from post-switch cleanup.
pub fn codex_auth_has_credential_login_material(auth: &Value) -> bool {
let Some(obj) = auth.as_object() else {
return false;
};
let value_present = |value: &Value| match value {
Value::Null => false,
Value::String(text) => !text.trim().is_empty(),
Value::Array(items) => !items.is_empty(),
Value::Object(map) => !map.is_empty(),
_ => true,
};
if ["personal_access_token", "agent_identity", "bedrock_api_key"]
.iter()
.any(|key| obj.get(*key).is_some_and(value_present))
{
return true;
}
obj.get("tokens")
.and_then(Value::as_object)
.is_some_and(|tokens| {
["id_token", "access_token", "refresh_token"]
.iter()
.any(|key| tokens.get(*key).is_some_and(value_present))
})
}
/// True when live `auth.json` is the shape a preserve-off third-party switch
/// leaves behind: an `OPENAI_API_KEY` (possibly alongside metadata like
/// `auth_mode` / `last_refresh`) with no real login credential next to it.
pub fn codex_live_auth_is_stale_third_party_residue(live_auth: &Value) -> bool {
if codex_auth_has_credential_login_material(live_auth) {
return false;
}
live_auth
.get("OPENAI_API_KEY")
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|key| !key.is_empty())
}
/// After a normal switch to an official provider that carries no login
/// material of its own, delete a live `auth.json` that only holds a stale
/// third-party API key, so Codex shows its login screen instead of sending
/// the wrong key to the official endpoint (401 with no way to re-login).
///
/// Deleting the file — not writing `{}` — is deliberate: Codex resolves an
/// empty object to ChatGPT mode without tokens and errors at bootstrap,
/// while a missing file yields NotAuthenticated and the login screen,
/// matching Codex's own logout.
///
/// Callers must only invoke this after the outgoing provider was
/// successfully backfilled into the DB — that backfill holds the only other
/// copy of the third-party key. The switch backfill intentionally lacks the
/// proxy-side "no credentials in the builtin official row" guard
/// (`services/proxy.rs` `sync_live_config_to_provider`): that asymmetry is
/// what heals official API-key logins into the DB row, and this cleanup's
/// safety depends on it — do not align the two guards.
///
/// Returns Ok(true) when the file was deleted.
pub fn clear_stale_codex_live_auth_after_official_switch(
db_auth: &Value,
) -> Result<bool, AppError> {
if codex_auth_has_login_material(db_auth) {
// A material-carrying official provider gets a full auth write;
// nothing stale can remain.
return Ok(false);
}
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return Ok(false);
}
let live_auth: Value = read_json_file(&auth_path)?;
if !codex_live_auth_is_stale_third_party_residue(&live_auth) {
return Ok(false);
}
delete_file(&auth_path)?;
Ok(true)
}
pub fn should_restore_codex_provider_token_for_backfill(
category: Option<&str>,
template_settings: &Value,
@@ -553,20 +466,17 @@ fn codex_catalog_model_entry(
spec: &CodexCatalogModelSpec,
priority: usize,
profile: CodexCatalogToolProfile,
default_context_window: u64,
) -> Value {
let mut entry = template.clone();
let Some(entry_obj) = entry.as_object_mut() else {
return json!({});
};
let display_name = spec.display_name.as_deref().unwrap_or(&spec.model);
let context_window = spec.context_window.unwrap_or(default_context_window);
entry_obj.insert("slug".to_string(), json!(spec.model));
entry_obj.insert("display_name".to_string(), json!(display_name));
entry_obj.insert("description".to_string(), json!(display_name));
entry_obj.insert("context_window".to_string(), json!(context_window));
entry_obj.insert("max_context_window".to_string(), json!(context_window));
entry_obj.insert("display_name".to_string(), json!(spec.display_name));
entry_obj.insert("description".to_string(), json!(spec.display_name));
entry_obj.insert("context_window".to_string(), json!(spec.context_window));
entry_obj.insert("max_context_window".to_string(), json!(spec.context_window));
entry_obj.insert("priority".to_string(), json!(1000 + priority));
entry_obj.insert("additional_speed_tiers".to_string(), json!([]));
entry_obj.insert("service_tiers".to_string(), json!([]));
@@ -625,13 +535,8 @@ fn codex_catalog_model_entry(
#[derive(Debug, Clone, PartialEq, Eq)]
struct CodexCatalogModelSpec {
model: String,
/// Explicit user value only. Entries fall back to the model id — except
/// official vendor catalog entries, which keep the vendor's display name.
display_name: Option<String>,
/// Explicit user value only. Entries fall back to the config's
/// `model_context_window` (or 128k) — except official vendor catalog
/// entries, which keep the vendor's declared window.
context_window: Option<u64>,
display_name: String,
context_window: u64,
/// 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<bool>,
@@ -647,7 +552,7 @@ struct CodexCatalogModelSpec {
base_instructions: Option<String>,
}
fn codex_catalog_model_specs(settings: &Value) -> Vec<CodexCatalogModelSpec> {
fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCatalogModelSpec> {
let Some(models) = settings
.get("modelCatalog")
.and_then(|catalog| catalog.get("models"))
@@ -656,6 +561,8 @@ fn codex_catalog_model_specs(settings: &Value) -> Vec<CodexCatalogModelSpec> {
return Vec::new();
};
let default_context_window =
extract_codex_top_level_u64(config_text, "model_context_window").unwrap_or(128_000);
let mut seen = std::collections::HashSet::new();
let mut specs = Vec::new();
@@ -679,12 +586,13 @@ fn codex_catalog_model_specs(settings: &Value) -> Vec<CodexCatalogModelSpec> {
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string);
.unwrap_or(model);
let context_window = parse_codex_positive_u64(
model_config
.get("contextWindow")
.or_else(|| model_config.get("context_window")),
);
)
.unwrap_or(default_context_window);
let supports_parallel_tool_calls = model_config
.get("supportsParallelToolCalls")
@@ -713,7 +621,7 @@ fn codex_catalog_model_specs(settings: &Value) -> Vec<CodexCatalogModelSpec> {
specs.push(CodexCatalogModelSpec {
model: model.to_string(),
display_name,
display_name: display_name.to_string(),
context_window,
supports_parallel_tool_calls,
input_modalities,
@@ -983,122 +891,6 @@ fn load_codex_native_responses_template() -> Value {
serde_json::from_str(text).expect("bundled codex native responses template must be valid JSON")
}
/// Hosts whose native `/responses` gateway publishes an OFFICIAL Codex model
/// catalog (models.json) that cc-switch mirrors verbatim. Matched against
/// `base_url` ONLY — deliberately NOT by model brand, unlike
/// `CODEX_WEB_SEARCH_REJECT_MODEL_PREFIXES`: the official entries GRANT
/// capabilities (freeform `apply_patch`, vendor harness), and an aggregator
/// merely hosting the same model may not honor them. The safe failure
/// direction for aggregators is the neutral template (degraded but working);
/// wrongly granting freeform apply_patch would reintroduce the custom-tool
/// rejection bug.
const CODEX_DEEPSEEK_OFFICIAL_CATALOG_HOSTS: &[&str] = &["deepseek.com"];
/// Bundled copy of DeepSeek's official Codex models.json — the exact file
/// their one-click integration script writes (api-docs.deepseek.com →
/// quick_start/agent_integrations/codex): freeform apply_patch, GPT-5 harness
/// base_instructions, low/high/max reasoning levels, web_search supported,
/// 1m context. Declares `minimal_client_version` 0.144.0.
fn load_codex_deepseek_official_catalog_models() -> Vec<Value> {
let text = include_str!("resources/codex_deepseek_catalog_template.json");
let catalog: Value =
serde_json::from_str(text).expect("bundled DeepSeek official catalog must be valid JSON");
catalog
.get("models")
.and_then(|models| models.as_array())
.cloned()
.unwrap_or_default()
}
/// Official vendor catalog entries for the provider in `config_text`, if its
/// gateway ships one. Only the `NativeResponses` profile qualifies: ProxyChat
/// runs through cc-switch's converter (gpt-5.5 template contract) and the
/// Anthropic transform drops custom tools, so both must keep their existing
/// templates. Host-driven like the web_search blacklist, so existing providers
/// pick it up on their next switch without a re-save.
fn codex_official_vendor_catalog_models(
config_text: &str,
profile: CodexCatalogToolProfile,
) -> Option<Vec<Value>> {
if profile != CodexCatalogToolProfile::NativeResponses {
return None;
}
let base_url = extract_codex_base_url(config_text)?.to_ascii_lowercase();
if CODEX_DEEPSEEK_OFFICIAL_CATALOG_HOSTS
.iter()
.any(|host| base_url.contains(host))
{
let models = load_codex_deepseek_official_catalog_models();
if !models.is_empty() {
return Some(models);
}
}
None
}
/// Build one catalog entry from an official vendor catalog: match the user's
/// model id against the vendor entries by slug; an unknown id clones the
/// vendor's first (flagship) entry so it keeps the gateway's capability
/// profile without impersonating the flagship. The official entry is
/// authoritative — no tool-profile stripping — but explicit per-row user
/// overrides still win.
fn codex_vendor_catalog_model_entry(
vendor_models: &[Value],
spec: &CodexCatalogModelSpec,
priority: usize,
) -> Value {
let matched = vendor_models.iter().find(|entry| {
entry
.get("slug")
.and_then(|slug| slug.as_str())
.is_some_and(|slug| slug.eq_ignore_ascii_case(&spec.model))
});
let mut entry = match matched {
Some(found) => found.clone(),
None => vendor_models.first().cloned().unwrap_or_else(|| json!({})),
};
let Some(entry_obj) = entry.as_object_mut() else {
return json!({});
};
if matched.is_none() {
let display_name = spec.display_name.as_deref().unwrap_or(&spec.model);
entry_obj.insert("slug".to_string(), json!(spec.model));
entry_obj.insert("display_name".to_string(), json!(display_name));
entry_obj.insert("description".to_string(), json!(display_name));
entry_obj.insert("priority".to_string(), json!(1000 + priority));
}
// Explicit user overrides win over the official entry; absent values keep
// the vendor's declarations (context window, modalities, harness, ...).
if let Some(display_name) = spec.display_name.as_deref() {
entry_obj.insert("display_name".to_string(), json!(display_name));
}
if let Some(context_window) = spec.context_window {
entry_obj.insert("context_window".to_string(), json!(context_window));
entry_obj.insert("max_context_window".to_string(), json!(context_window));
}
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.as_deref() {
entry_obj.insert("input_modalities".to_string(), json!(modalities));
}
if let Some(base_instructions) = spec
.base_instructions
.as_deref()
.map(str::trim)
.filter(|text| !text.is_empty())
{
entry_obj.insert("base_instructions".to_string(), json!(base_instructions));
}
// Defensive: if a future codex parser requires a field the vendor file
// predates, backfill only whitelisted parser-required keys.
fill_template_fields_from_static(&mut entry);
entry
}
/// Fields Codex's external-catalog parser REQUIRES (no serde default): when
/// one is missing Codex rejects the whole catalog file at startup ("missing
/// field ..."). `base_instructions` is the other known required field; the
@@ -1181,14 +973,11 @@ fn codex_model_catalog_from_specs(
specs: &[CodexCatalogModelSpec],
template: &Value,
profile: CodexCatalogToolProfile,
default_context_window: u64,
) -> Value {
let entries: Vec<Value> = specs
.iter()
.enumerate()
.map(|(index, spec)| {
codex_catalog_model_entry(template, spec, index, profile, default_context_window)
})
.map(|(index, spec)| codex_catalog_model_entry(template, spec, index, profile))
.collect();
json!({ "models": entries })
@@ -1199,28 +988,11 @@ fn codex_model_catalog_from_settings(
config_text: &str,
profile: CodexCatalogToolProfile,
) -> Result<Option<Value>, AppError> {
let specs = codex_catalog_model_specs(settings);
let specs = codex_catalog_model_specs(settings, config_text);
if specs.is_empty() {
return Ok(None);
}
// Vendors that publish an OFFICIAL Codex models.json for their native
// `/responses` gateway get it mirrored verbatim instead of the neutral
// template: its freeform apply_patch, vendor harness base_instructions and
// reasoning levels are load-bearing (the harness tells the model to use
// apply_patch, so catalog and harness must stay consistent).
if let Some(vendor_models) = codex_official_vendor_catalog_models(config_text, profile) {
let entries: Vec<Value> = specs
.iter()
.enumerate()
.map(|(index, spec)| codex_vendor_catalog_model_entry(&vendor_models, spec, index))
.collect();
return Ok(Some(json!({ "models": entries })));
}
let default_context_window =
extract_codex_top_level_u64(config_text, "model_context_window").unwrap_or(128_000);
// Native providers use the bundled clean template (no freeform apply_patch,
// no cache dependency); proxy-chat providers keep cloning Codex's gpt-5.5
// entry so the proxy can rewrite custom<->function tools as before.
@@ -1231,10 +1003,7 @@ fn codex_model_catalog_from_settings(
CodexCatalogToolProfile::ProxyChat => load_codex_model_catalog_template()?,
};
Ok(Some(codex_model_catalog_from_specs(
&specs,
&template,
profile,
default_context_window,
&specs, &template, profile,
)))
}
@@ -1655,9 +1424,7 @@ fn remove_codex_experimental_bearer_token(config_text: &str) -> Result<String, A
/// Read the current Codex live settings as a `{ auth, config }` object.
///
/// Missing `auth.json` collapses to `{}` so a config-only third-party install
/// is still importable; both files missing is treated as "no live install".
/// A `config.toml` that exists but is empty is a valid state — e.g. the
/// official seed after stale-auth cleanup — and must stay readable.
/// is still importable; both files empty is treated as "no live install".
pub fn read_codex_live_settings() -> Result<Value, AppError> {
let auth_path = get_codex_auth_path();
let auth_present = auth_path.exists();
@@ -1667,7 +1434,7 @@ pub fn read_codex_live_settings() -> Result<Value, AppError> {
json!({})
};
let cfg_text = read_and_validate_codex_config_text()?;
if !auth_present && !get_codex_config_path().exists() {
if !auth_present && cfg_text.trim().is_empty() {
return Err(AppError::localized(
"codex.live.missing",
"Codex 配置文件不存在",
@@ -2636,65 +2403,6 @@ experimental_bearer_token = "stale-table-key"
);
}
#[test]
fn credential_login_material_only_counts_real_credentials() {
assert!(codex_auth_has_credential_login_material(&json!({
"tokens": { "access_token": "t" }
})));
assert!(codex_auth_has_credential_login_material(&json!({
"tokens": { "refresh_token": "r" }
})));
assert!(codex_auth_has_credential_login_material(&json!({
"personal_access_token": "pat"
})));
// API key and pure metadata are not credentials in this predicate's
// sense — they must not shield a stale key from cleanup.
assert!(!codex_auth_has_credential_login_material(&json!({
"OPENAI_API_KEY": "sk-x"
})));
assert!(!codex_auth_has_credential_login_material(&json!({
"OPENAI_API_KEY": "sk-x",
"last_refresh": "2026-01-01T00:00:00Z",
"tokens": { "account_id": "acct-meta-only" }
})));
assert!(!codex_auth_has_credential_login_material(&json!({})));
}
#[test]
fn stale_third_party_residue_detection() {
// Shapes a preserve-off third-party switch leaves behind: cleared.
assert!(codex_live_auth_is_stale_third_party_residue(&json!({
"OPENAI_API_KEY": "sk-third-party"
})));
assert!(codex_live_auth_is_stale_third_party_residue(&json!({
"auth_mode": "apikey",
"OPENAI_API_KEY": "sk-third-party"
})));
assert!(codex_live_auth_is_stale_third_party_residue(&json!({
"OPENAI_API_KEY": "sk-third-party",
"last_refresh": "2026-01-01T00:00:00Z",
"tokens": { "account_id": "acct-meta-only" }
})));
// Anything carrying a real credential must survive untouched.
assert!(!codex_live_auth_is_stale_third_party_residue(&json!({
"OPENAI_API_KEY": "sk-x",
"tokens": { "access_token": "t" }
})));
assert!(!codex_live_auth_is_stale_third_party_residue(&json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": { "access_token": "official-oauth-token" }
})));
// Nothing to clear.
assert!(!codex_live_auth_is_stale_third_party_residue(&json!({})));
assert!(!codex_live_auth_is_stale_third_party_residue(&json!({
"OPENAI_API_KEY": ""
})));
}
#[test]
fn prepare_provider_live_config_does_not_create_incomplete_provider_table() {
let input = r#"model_provider = "vendor_x"
@@ -3127,18 +2835,14 @@ base_url = "https://production.api/v1"
fill_template_fields_from_static(&mut template);
let specs = vec![CodexCatalogModelSpec {
model: "k3".to_string(),
display_name: Some("Kimi K3".to_string()),
context_window: Some(262_144),
display_name: "Kimi K3".to_string(),
context_window: 262_144,
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
}];
let catalog = codex_model_catalog_from_specs(
&specs,
&template,
CodexCatalogToolProfile::ProxyChat,
128_000,
);
let catalog =
codex_model_catalog_from_specs(&specs, &template, CodexCatalogToolProfile::ProxyChat);
assert_eq!(
catalog["models"][0]
.get("supports_reasoning_summaries")
@@ -3194,13 +2898,9 @@ base_url = "https://production.api/v1"
]
}
});
let specs = codex_catalog_model_specs(&settings);
let catalog = codex_model_catalog_from_specs(
&specs,
&template,
CodexCatalogToolProfile::ProxyChat,
128_000,
);
let specs = codex_catalog_model_specs(&settings, r#"model_context_window = 128000"#);
let catalog =
codex_model_catalog_from_specs(&specs, &template, CodexCatalogToolProfile::ProxyChat);
let models = catalog
.get("models")
.and_then(|value| value.as_array())
@@ -3333,40 +3033,40 @@ base_url = "https://production.api/v1"
let specs = vec![
CodexCatalogModelSpec {
model: "gpt-5.4".to_string(),
display_name: Some("GPT 5.4".to_string()),
context_window: Some(128_000),
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: Some("DeepSeek V4 Pro".to_string()),
context_window: Some(128_000),
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: Some("GLM 5.2V".to_string()),
context_window: Some(128_000),
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: Some("Explicit Visual Override".to_string()),
context_window: Some(128_000),
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: Some("Explicit Text Override".to_string()),
context_window: Some(128_000),
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,
@@ -3378,7 +3078,7 @@ base_url = "https://production.api/v1"
CodexCatalogToolProfile::NativeResponses,
CodexCatalogToolProfile::Anthropic,
] {
let catalog = codex_model_catalog_from_specs(&specs, &template, profile, 128_000);
let catalog = codex_model_catalog_from_specs(&specs, &template, profile);
let models = catalog["models"].as_array().expect("models array");
let modalities = |slug: &str| {
models
@@ -3429,205 +3129,6 @@ base_url = "https://production.api/v1"
);
}
const DEEPSEEK_NATIVE_CONFIG: &str = r#"model = "deepseek-v4-flash"
model_provider = "custom"
[model_providers.custom]
name = "deepseek"
base_url = "https://api.deepseek.com"
wire_api = "responses"
"#;
#[test]
fn deepseek_host_native_catalog_mirrors_official_entries() {
// DeepSeek publishes an official Codex models.json (freeform
// apply_patch + GPT-5 harness + low/high/max reasoning levels). For a
// deepseek.com native provider the generated catalog must mirror it
// verbatim instead of the stripped neutral template — the harness
// tells the model to use apply_patch, so stripping the tool while
// keeping the harness would be self-inconsistent.
let settings = json!({
"modelCatalog": {
"models": [
{ "model": "deepseek-v4-flash", "displayName": "DeepSeek V4 Flash" },
{ "model": "deepseek-v4-pro", "contextWindow": 500_000 }
]
}
});
let catalog = codex_model_catalog_from_settings(
&settings,
DEEPSEEK_NATIVE_CONFIG,
CodexCatalogToolProfile::NativeResponses,
)
.expect("vendor catalog generation should not error")
.expect("non-empty modelCatalog must yield a catalog");
let flash = &catalog["models"][0];
assert_eq!(
flash.get("slug").and_then(|v| v.as_str()),
Some("deepseek-v4-flash")
);
assert_eq!(
flash.get("apply_patch_tool_type").and_then(|v| v.as_str()),
Some("freeform"),
"official DeepSeek entries keep the freeform apply_patch grant"
);
assert!(
flash
.get("base_instructions")
.and_then(|v| v.as_str())
.is_some_and(|s| s.starts_with("You are Codex, an agent based on GPT-5")),
"official GPT-5 harness must survive verbatim"
);
let efforts: Vec<&str> = flash["supported_reasoning_levels"]
.as_array()
.expect("official reasoning levels array")
.iter()
.filter_map(|level| level.get("effort").and_then(|v| v.as_str()))
.collect();
assert_eq!(efforts, vec!["low", "high", "max"]);
assert_eq!(flash.get("supports_search_tool"), Some(&json!(true)));
assert_eq!(
flash.get("web_search_tool_type").and_then(|v| v.as_str()),
Some("text")
);
assert_eq!(
flash.get("supports_reasoning_summaries"),
Some(&json!(true))
);
assert_eq!(flash.get("input_modalities"), Some(&json!(["text"])));
assert!(
flash.get("model_messages").is_some(),
"official entries are mirrored verbatim, incl. model_messages"
);
// No explicit contextWindow on the row: the official 1m window must
// survive instead of being clobbered by the 128k default.
assert_eq!(
flash.get("context_window").and_then(|v| v.as_u64()),
Some(1_048_576)
);
// Explicit user display name still wins over the official one.
assert_eq!(
flash.get("display_name").and_then(|v| v.as_str()),
Some("DeepSeek V4 Flash")
);
let pro = &catalog["models"][1];
assert_eq!(
pro.get("slug").and_then(|v| v.as_str()),
Some("deepseek-v4-pro")
);
// Explicit user context window override wins…
assert_eq!(
pro.get("context_window").and_then(|v| v.as_u64()),
Some(500_000)
);
assert_eq!(
pro.get("max_context_window").and_then(|v| v.as_u64()),
Some(500_000)
);
// …while the untouched official display name is kept.
assert_eq!(
pro.get("display_name").and_then(|v| v.as_str()),
Some("DeepSeek-V4-Pro")
);
}
#[test]
fn deepseek_official_catalog_unknown_model_clones_flagship() {
// A user-added model id the official file doesn't know keeps the
// gateway's capability profile (clone of the flagship entry) without
// impersonating it: own slug/name, demoted priority, and the official
// context window rather than the 128k synthetic default.
let settings = json!({
"modelCatalog": { "models": [{ "model": "deepseek-v4-lite" }] }
});
let catalog = codex_model_catalog_from_settings(
&settings,
DEEPSEEK_NATIVE_CONFIG,
CodexCatalogToolProfile::NativeResponses,
)
.expect("vendor catalog generation should not error")
.expect("non-empty modelCatalog must yield a catalog");
let entry = &catalog["models"][0];
assert_eq!(
entry.get("slug").and_then(|v| v.as_str()),
Some("deepseek-v4-lite")
);
assert_eq!(
entry.get("display_name").and_then(|v| v.as_str()),
Some("deepseek-v4-lite")
);
assert!(
entry
.get("priority")
.and_then(|v| v.as_u64())
.is_some_and(|p| p >= 1000),
"clones must sort after official entries"
);
assert_eq!(
entry.get("apply_patch_tool_type").and_then(|v| v.as_str()),
Some("freeform")
);
assert_eq!(
entry.get("context_window").and_then(|v| v.as_u64()),
Some(1_048_576),
"absent contextWindow keeps the flagship's official window"
);
assert!(entry
.get("base_instructions")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.trim().is_empty()));
}
#[test]
fn official_vendor_catalog_gated_by_native_profile_and_host() {
// The official mirror is a capability GRANT, so the gate must be
// narrow: native `/responses` profile AND the vendor's own host. Chat
// runs through the proxy converter (gpt-5.5 contract), the Anthropic
// transform drops custom tools, and aggregators hosting the same
// model may reject freeform tools — all of them keep their templates.
assert!(codex_official_vendor_catalog_models(
DEEPSEEK_NATIVE_CONFIG,
CodexCatalogToolProfile::NativeResponses
)
.is_some_and(|models| !models.is_empty()));
for profile in [
CodexCatalogToolProfile::ProxyChat,
CodexCatalogToolProfile::Anthropic,
] {
assert!(
codex_official_vendor_catalog_models(DEEPSEEK_NATIVE_CONFIG, profile).is_none(),
"only the NativeResponses profile may mirror the official catalog"
);
}
let minimax_config = r#"model = "MiniMax-M3"
model_provider = "custom"
[model_providers.custom]
name = "minimax"
base_url = "https://api.minimaxi.com/v1"
wire_api = "responses"
"#;
assert!(
codex_official_vendor_catalog_models(
minimax_config,
CodexCatalogToolProfile::NativeResponses
)
.is_none(),
"non-DeepSeek native hosts keep the neutral template"
);
assert!(
codex_official_vendor_catalog_models("", CodexCatalogToolProfile::NativeResponses)
.is_none()
);
}
#[test]
fn proxy_chat_profile_still_keeps_apply_patch() {
// Regression guard for Mode A: the proxy-chat profile must keep the
@@ -3635,8 +3136,8 @@ wire_api = "responses"
let template = load_codex_native_responses_template();
let specs = vec![CodexCatalogModelSpec {
model: "x".to_string(),
display_name: Some("x".to_string()),
context_window: Some(128_000),
display_name: "x".to_string(),
context_window: 128_000,
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
@@ -3650,7 +3151,6 @@ wire_api = "responses"
&specs,
&proxy_template,
CodexCatalogToolProfile::ProxyChat,
128_000,
);
assert_eq!(
catalog["models"][0]
+52 -19
View File
@@ -10,6 +10,10 @@ 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;
use crate::services::provider::{
provider_row_fingerprint, provider_to_mutation_input,
reconcile_provider_record_with_precondition, ReconcilePrecondition,
};
use crate::settings::{
CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration,
CodexThirdPartyHistoryProviderBucketMigration,
@@ -663,7 +667,8 @@ fn migrate_codex_provider_templates_to_custom(
let providers = db.get_all_providers("codex")?;
let mut migrated_provider_ids = Vec::new();
for (_, provider) in providers {
for (_, mut provider) in providers {
let observed_fingerprint = provider_row_fingerprint(&provider);
if provider.category.as_deref() == Some("official")
|| is_official_seed_id(&provider.id)
|| provider.is_codex_oauth()
@@ -694,8 +699,21 @@ fn migrate_codex_provider_templates_to_custom(
};
backup_provider_settings_config(&provider.id, &provider.settings_config, backup_root)?;
obj.insert("config".to_string(), Value::String(migrated_config_text));
db.update_provider_settings_config("codex", &provider.id, &settings)?;
migrated_provider_ids.push(provider.id);
let provider_id = provider.id.clone();
provider.settings_config = settings;
if let Some(meta) = provider.meta.as_mut() {
meta.custom_endpoints.clear();
}
let input = provider_to_mutation_input(provider);
reconcile_provider_record_with_precondition(
db,
"codex",
input,
ReconcilePrecondition::ExpectPresent {
fingerprint: observed_fingerprint,
},
)?;
migrated_provider_ids.push(provider_id);
}
Ok(CodexProviderTemplateBucketMigrationOutcome {
@@ -1439,7 +1457,8 @@ base_url = "https://proxy.example/v1"
),
];
for provider in providers {
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
}
let mut official = Provider::with_id(
@@ -1449,7 +1468,8 @@ base_url = "https://proxy.example/v1"
None,
);
official.category = Some("official".to_string());
db.save_provider("codex", &official).expect("save official");
db.reconcile_provider_fixture("codex", &official)
.expect("save official");
let source_provider_ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert_eq!(
@@ -2171,9 +2191,10 @@ base_url = "https://proxy.example/v1"
);
official.category = Some("official".to_string());
db.save_provider("codex", &third_party)
db.reconcile_provider_fixture("codex", &third_party)
.expect("save third-party");
db.save_provider("codex", &official).expect("save official");
db.reconcile_provider_fixture("codex", &official)
.expect("save official");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(ids.contains("rightcode"));
@@ -2196,7 +2217,8 @@ base_url = "https://proxy.example/v1"
);
provider.category = Some("aggregator".to_string());
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(!ids.contains("my-private-relay"));
@@ -2216,7 +2238,8 @@ base_url = "https://proxy.example/v1"
);
provider.category = Some("aggregator".to_string());
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(!ids.contains("my-private-relay"));
@@ -2244,7 +2267,8 @@ model_provider = "my-private-relay"
);
provider.category = Some("aggregator".to_string());
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(!ids.contains("my-private-relay"));
@@ -2264,7 +2288,8 @@ model_provider = "my-private-relay"
);
provider.category = Some("aggregator".to_string());
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(ids.contains("aihubmix"));
@@ -2285,7 +2310,8 @@ model_provider = "my-private-relay"
);
provider.category = Some("aggregator".to_string());
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(ids.contains("ccswitch"));
@@ -2317,7 +2343,8 @@ model = "gpt-5.4"
}),
None,
);
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let (outcome, backup_dir) = migrate_provider_templates_for_test(&db);
assert_eq!(outcome.migrated_provider_ids, vec!["legacy".to_string()]);
@@ -2390,7 +2417,8 @@ base_url = "https://aihubmix.example/v1"
}),
None,
);
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
assert_eq!(
@@ -2446,7 +2474,8 @@ base_url = "http://localhost:8080/v1"
}),
None,
);
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
assert!(outcome.migrated_provider_ids.is_empty());
@@ -2495,7 +2524,8 @@ base_url = "https://proxy.example/v1"
}),
None,
);
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
assert!(outcome.migrated_provider_ids.is_empty());
@@ -2552,7 +2582,8 @@ model_provider = "aihubmix"
}),
None,
);
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let (outcome, _backup_dir) = migrate_provider_templates_for_test(&db);
assert_eq!(outcome.migrated_provider_ids, vec!["profiled".to_string()]);
@@ -2601,7 +2632,8 @@ model_provider = "aihubmix"
provider.category = Some("custom".to_string());
provider.created_at = Some(1);
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(!ids.contains("my-private-relay"));
@@ -2622,7 +2654,8 @@ model_provider = "aihubmix"
);
provider.category = Some("custom".to_string());
db.save_provider("codex", &provider).expect("save provider");
db.reconcile_provider_fixture("codex", &provider)
.expect("save provider");
let ids = collect_source_model_provider_ids(&db).expect("collect ids");
assert!(!ids.contains("my-local-relay"));
+14
View File
@@ -135,6 +135,18 @@ pub async fn get_config_status(
Ok(ConfigStatus { exists, path })
}
AppType::Pi => {
let config_path =
crate::pi_config::native::get_pi_models_path().map_err(|e| e.to_string())?;
let path = crate::pi_config::native::get_pi_agent_dir()
.map_err(|e| e.to_string())?
.to_string_lossy()
.to_string();
Ok(ConfigStatus {
exists: config_path.exists(),
path,
})
}
}
}
@@ -156,6 +168,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
AppType::Pi => crate::pi_config::native::get_pi_agent_dir().map_err(|e| e.to_string())?,
};
Ok(dir.to_string_lossy().to_string())
@@ -174,6 +187,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
AppType::Pi => crate::pi_config::native::get_pi_agent_dir().map_err(|e| e.to_string())?,
};
if !config_dir.exists() {
+173
View File
@@ -2,6 +2,7 @@
//!
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
use crate::app_config::AppType;
use crate::database::FailoverQueueItem;
use crate::provider::Provider;
use crate::store::AppState;
@@ -39,6 +40,50 @@ pub async fn add_to_failover_queue(
app_type: String,
provider_id: String,
) -> Result<(), String> {
if app_type == "pi" {
let _guard = state
.proxy_service
.lock_switch_for_app(AppType::Pi.as_str())
.await;
if state
.db
.get_provider_aggregate("pi", &provider_id)
.map_err(|error| error.to_string())?
.is_none()
{
return Err(format!("Pi provider does not exist: {provider_id}"));
}
let was_member = state
.db
.is_in_failover_queue("pi", &provider_id)
.map_err(|error| error.to_string())?;
let epoch = state.proxy_service.begin_pi_catalog_mutation().await;
if let Err(error) = state.db.add_to_failover_queue("pi", &provider_id) {
let _ = state
.proxy_service
.reconcile_pi_runtime_at_epoch(epoch)
.await;
return Err(error.to_string());
}
if let Err(error) = state
.proxy_service
.reconcile_pi_runtime_at_epoch(epoch)
.await
{
if !was_member {
let _ = state.db.remove_from_failover_queue("pi", &provider_id);
}
let rollback_epoch = state.proxy_service.begin_pi_catalog_mutation().await;
let _ = state
.proxy_service
.reconcile_pi_runtime_at_epoch(rollback_epoch)
.await;
return Err(format!(
"Pi failover queue changed but runtime publication failed: {error}"
));
}
return Ok(());
}
state
.db
.add_to_failover_queue(&app_type, &provider_id)
@@ -52,6 +97,42 @@ pub async fn remove_from_failover_queue(
app_type: String,
provider_id: String,
) -> Result<(), String> {
if app_type == "pi" {
let _guard = state
.proxy_service
.lock_switch_for_app(AppType::Pi.as_str())
.await;
let was_member = state
.db
.is_in_failover_queue("pi", &provider_id)
.map_err(|error| error.to_string())?;
let epoch = state.proxy_service.begin_pi_catalog_mutation().await;
if let Err(error) = state.db.remove_from_failover_queue("pi", &provider_id) {
let _ = state
.proxy_service
.reconcile_pi_runtime_at_epoch(epoch)
.await;
return Err(error.to_string());
}
if let Err(error) = state
.proxy_service
.reconcile_pi_runtime_at_epoch(epoch)
.await
{
if was_member {
let _ = state.db.add_to_failover_queue("pi", &provider_id);
}
let rollback_epoch = state.proxy_service.begin_pi_catalog_mutation().await;
let _ = state
.proxy_service
.reconcile_pi_runtime_at_epoch(rollback_epoch)
.await;
return Err(format!(
"Pi failover queue changed but runtime publication failed: {error}"
));
}
return Ok(());
}
state
.db
.remove_from_failover_queue(&app_type, &provider_id)
@@ -64,6 +145,9 @@ pub async fn get_auto_failover_enabled(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<bool, String> {
if app_type == "pi" {
return Ok(crate::settings::get_pi_proxy_settings().auto_failover_enabled);
}
state
.db
.get_proxy_config_for_app(&app_type)
@@ -86,6 +170,10 @@ pub async fn set_auto_failover_enabled(
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
);
if app_type == "pi" {
return set_pi_auto_failover_enabled(&app, state.inner(), enabled).await;
}
// 读取当前配置
let mut config = state
.db
@@ -180,3 +268,88 @@ pub async fn set_auto_failover_enabled(
Ok(())
}
async fn set_pi_auto_failover_enabled(
app: &tauri::AppHandle,
state: &AppState,
enabled: bool,
) -> Result<(), String> {
let _guard = state
.proxy_service
.lock_switch_for_app(AppType::Pi.as_str())
.await;
let previous_config = crate::settings::get_pi_proxy_settings();
if enabled && !crate::settings::pi_takeover_enabled() {
return Err("Pi gateway takeover must be enabled before failover".to_string());
}
let mut auto_added = None;
if enabled
&& state
.db
.get_failover_queue("pi")
.map_err(|error| error.to_string())?
.is_empty()
{
let current =
crate::services::pi_catalog::PiCatalogCoordinator::current_native_provider(state)
.map_err(|error| error.to_string())?
.ok_or_else(|| {
"Pi failover queue is empty and no current provider is selected".to_string()
})?;
state
.db
.add_to_failover_queue("pi", &current)
.map_err(|error| error.to_string())?;
auto_added = Some(current);
}
let mut next = previous_config.clone();
next.auto_failover_enabled = enabled;
let epoch = state.proxy_service.begin_pi_catalog_mutation().await;
if let Err(error) = crate::settings::update_pi_proxy_settings(next) {
if let Some(provider_id) = auto_added {
let _ = state.db.remove_from_failover_queue("pi", &provider_id);
}
let _ = state
.proxy_service
.reconcile_pi_runtime_at_epoch(epoch)
.await;
return Err(error.to_string());
}
if let Err(error) = state
.proxy_service
.reconcile_pi_runtime_at_epoch(epoch)
.await
{
let _ = crate::settings::update_pi_proxy_settings(previous_config);
if let Some(provider_id) = auto_added {
let _ = state.db.remove_from_failover_queue("pi", &provider_id);
}
let rollback_epoch = state.proxy_service.begin_pi_catalog_mutation().await;
let _ = state
.proxy_service
.reconcile_pi_runtime_at_epoch(rollback_epoch)
.await;
return Err(format!(
"Pi failover preference changed but runtime publication failed: {error}"
));
}
let _ = app.emit(
"provider-switched",
serde_json::json!({
"appType": "pi",
"providerId":
crate::services::pi_catalog::PiCatalogCoordinator::current_native_provider(state)
.map_err(|error| error.to_string())?,
"source": "failoverPreferenceChanged"
}),
);
if let Ok(new_menu) = crate::tray::create_tray_menu(app, state) {
if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) {
let _ = tray.set_menu(Some(new_menu));
}
}
Ok(())
}
+87 -17
View File
@@ -44,26 +44,56 @@ pub async fn import_config_from_file(
state: State<'_, AppState>,
) -> Result<Value, String> {
let db = state.db.clone();
let db_for_sync = db.clone();
tauri::async_runtime::spawn_blocking(move || {
let path_buf = PathBuf::from(&filePath);
let backup_id = db.import_sql(&path_buf)?;
let warning = post_sync_warning_from_result(Ok(run_post_import_sync(db_for_sync)));
if let Some(msg) = warning.as_ref() {
log::warn!("[Import] post-import sync warning: {msg}");
let app_state = state.inner().clone();
let pi_guard = app_state
.proxy_service
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
.await;
app_state
.proxy_service
.prepare_pi_portable_import_under_lock(&pi_guard)
.await
.map_err(|error| format!("导入前恢复 Pi 直连投影失败: {error}"))?;
let import_path = filePath.clone();
let import_result =
tauri::async_runtime::spawn_blocking(move || db.import_sql(&PathBuf::from(import_path)))
.await
.map_err(|error| AppError::Message(format!("SQL import task failed: {error}")))
.and_then(|result| result);
let backup_id = match import_result {
Ok(backup_id) => backup_id,
Err(error) => {
let recovery = app_state
.proxy_service
.recover_pi_after_aborted_portable_import_under_lock(&pi_guard)
.await;
return Err(match recovery {
Ok(()) => error.to_string(),
Err(recovery) => {
format!("{error}; Pi gateway recovery after aborted import failed: {recovery}")
}
});
}
Ok::<_, AppError>(success_payload_with_warning(backup_id, warning))
})
.await
.map_err(|e| format!("导入配置失败: {e}"))?
.map_err(|e: AppError| e.to_string())
};
drop(pi_guard);
let sync_state = app_state.clone();
let warning = post_sync_warning_from_result(
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(&sync_state))
.await
.map_err(|error| error.to_string()),
);
if let Some(msg) = warning.as_ref() {
log::warn!("[Import] post-import sync warning: {msg}");
}
Ok(success_payload_with_warning(backup_id, warning))
}
#[tauri::command]
pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<Value, String> {
let db = state.db.clone();
let app_state = state.inner().clone();
tauri::async_runtime::spawn_blocking(move || {
let app_state = AppState::new(db);
ProviderService::sync_current_to_live(&app_state)?;
Ok::<_, AppError>(json!({
"success": true,
@@ -154,10 +184,50 @@ pub async fn restore_db_backup(
filename: String,
) -> Result<String, String> {
let db = state.db.clone();
tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename))
let app_state = state.inner().clone();
let pi_guard = app_state
.proxy_service
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
.await;
app_state
.proxy_service
.prepare_pi_portable_import_under_lock(&pi_guard)
.await
.map_err(|e| format!("Restore failed: {e}"))?
.map_err(|e: AppError| e.to_string())
.map_err(|error| format!("Restore preparation failed: {error}"))?;
let restore_result =
tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename))
.await
.map_err(|error| AppError::Message(format!("Restore task failed: {error}")))
.and_then(|result| result);
let restored = match restore_result {
Ok(restored) => restored,
Err(error) => {
let recovery = app_state
.proxy_service
.recover_pi_after_aborted_portable_import_under_lock(&pi_guard)
.await;
return Err(match recovery {
Ok(()) => error.to_string(),
Err(recovery) => {
format!("{error}; Pi gateway recovery after aborted restore failed: {recovery}")
}
});
}
};
drop(pi_guard);
let sync_state = app_state.clone();
match tauri::async_runtime::spawn_blocking(move || run_post_import_sync(&sync_state)).await {
Ok(Ok(())) => {}
Ok(Err(error)) => {
log::warn!("[Restore] database restored but post-restore sync failed: {error}");
}
Err(error) => {
log::warn!("[Restore] database restored but post-restore sync task failed: {error}");
}
}
Ok(restored)
}
/// Rename a database backup file
+53 -218
View File
@@ -111,8 +111,8 @@ pub struct ToolVersion {
wsl_distro: Option<String>,
}
const VALID_TOOLS: [&str; 7] = [
"claude", "codex", "gemini", "grok", "opencode", "openclaw", "hermes",
const VALID_TOOLS: [&str; 8] = [
"claude", "codex", "gemini", "grok", "opencode", "openclaw", "hermes", "pi",
];
#[derive(Debug, Clone, serde::Deserialize)]
@@ -214,16 +214,11 @@ fn run_tool_lifecycle_silently(command_line: &str, _label: &str) -> Result<(), S
use std::process::Command;
// command_line 是 bash 风格脚本(含 `set -e` 与多行命令);强制用 bash 执行,
// 避免用户默认 shell 为 fish/zsh 时 `set -e` 等语义不一致。
let mut cmd = Command::new("bash");
cmd.arg("-c").arg(command_line);
// GUI App 继承的是 launchd 的窄 PATH,而锚定探测用的是登录 shell —— 见
// `login_shell_path` doc。命令自身用绝对路径不受影响,但工具**内部** spawn 的
// npm/node 等子进程、以及 install 链里裸的 npm fallback 都需要真实 PATH。
if let Some(login_path) = login_shell_path() {
let inherited = std::env::var("PATH").unwrap_or_default();
cmd.env("PATH", merge_path_segments(&login_path, &inherited));
}
let output = cmd.output().map_err(|e| format!("启动安装进程失败: {e}"))?;
let output = Command::new("bash")
.arg("-c")
.arg(command_line)
.output()
.map_err(|e| format!("启动安装进程失败: {e}"))?;
finish_lifecycle_output(&output)
}
@@ -433,6 +428,7 @@ fn tool_display_name(tool: &str) -> &'static str {
"opencode" => "OpenCode",
"openclaw" => "OpenClaw",
"hermes" => "Hermes",
"pi" => "Pi",
_ => "Unknown",
}
}
@@ -513,6 +509,7 @@ fn npm_install_command_for(tool: &str) -> Option<&'static str> {
"grok" => Some("npm i -g @xai-official/grok@latest"),
"opencode" => Some("npm i -g opencode-ai@latest"),
"openclaw" => Some("npm i -g openclaw@latest"),
"pi" => Some("npm i -g @earendil-works/pi-coding-agent@latest"),
_ => None,
}
}
@@ -807,6 +804,9 @@ async fn get_single_tool_version_impl(
}
"openclaw" => fetch_npm_latest_for_tool(&client, "openclaw", tool, local).await,
"hermes" => fetch_pypi_latest_version(&client, "hermes-agent").await,
"pi" => {
fetch_npm_latest_for_tool(&client, "@earendil-works/pi-coding-agent", tool, local).await
}
_ => None,
};
@@ -1860,73 +1860,6 @@ fn first_abs_path_line(raw: &str) -> Option<&str> {
raw.lines().map(str::trim).find(|l| l.starts_with('/'))
}
/// 从 `env` 输出里取 `PATH=` 那行的值。要求值以 `/` 开头——PATH 首段必是绝对路径,
/// 这条约束顺带跳过"某个多行值的环境变量恰好有一行以 `PATH=` 开头"的污染,
/// 与 `first_abs_path_line` 对交互式 shell 噪音的容错同理。
#[cfg(not(target_os = "windows"))]
fn path_line_from_env_output(raw: &str) -> Option<&str> {
raw.lines()
.filter_map(|line| line.strip_prefix("PATH="))
.find(|value| value.starts_with('/'))
}
/// 合并两段 PATH`primary` 全部保留在前,`extra` 中未出现过的段按序追加。
/// 空段直接丢弃(`a::b` 里的空段在 POSIX 下语义是"当前目录",注入时不该带上)。
#[cfg(not(target_os = "windows"))]
fn merge_path_segments(primary: &str, extra: &str) -> String {
let mut seen = std::collections::HashSet::new();
let mut merged: Vec<&str> = Vec::new();
for segment in primary.split(':').chain(extra.split(':')) {
if segment.is_empty() || !seen.insert(segment) {
continue;
}
merged.push(segment);
}
merged.join(":")
}
/// 用与 `resolve_path_default` 相同的登录 shell 解析用户的真实 PATH
/// 供 `run_tool_lifecycle_silently` 注入给安装/升级脚本。
///
/// **要解决的不对称**:探测阶段(`try_get_version` / `resolve_path_default`)跑的是
/// `$SHELL -lic`,会读 `.zshrc`/`.zprofile`,看得到 nvm / homebrew / volta;而执行阶段
/// 是非登录 `bash -c`,继承的是 launchd 给 GUI App 的 PATH,通常只有
/// `/usr/bin:/bin:/usr/sbin:/sbin`。锚定命令自己用绝对路径调用执行体,本不受这条影响
/// ——但有两类漏网:
/// 1. **执行体在内部再 spawn 第三方 CLI**`grok update` 靠 `npm view` 查最新版本
/// (见 `grok_native_update_command`),npm 又是 `#!/usr/bin/env node` 脚本;
/// 未来任何 self-update 内部调 node/git/python 同理。
/// 2. **install 分支的 `<官方 installer> || npm i -g <pkg>@latest`**`||` 右侧是裸命令,
/// 窄 PATH 下必然 exit 127,等于没有兜底。
///
/// 把执行阶段的 PATH 拉平到探测阶段的水平,一次消除这两类。
///
/// **用 `/usr/bin/env` 而不是 `echo $PATH`**`$PATH` 在 fish 里是 list 类型,
/// `"$PATH"` 展开成空格分隔而非冒号分隔;`env` 打印的则是子进程的真实环境,
/// 任何 shell 下格式都正确。写绝对路径又绕过了 alias / function / PATH 三重不确定性
/// (交互式 shell 会加载用户 alias)。
///
/// 解析不到时返回 `None`,调用方保持原有行为(不注入),不引入新的失败模式。
#[cfg(not(target_os = "windows"))]
fn login_shell_path() -> Option<String> {
use std::process::Command;
let shell = std::env::var("SHELL")
.ok()
.filter(|s| is_valid_shell(s))
.unwrap_or_else(|| "sh".to_string());
let flag = default_flag_for_shell(&shell);
let out = Command::new(shell)
.arg(flag)
.arg("/usr/bin/env")
.output()
.ok()?;
if !out.status.success() {
return None;
}
let raw = decode_command_output(&out.stdout);
Some(path_line_from_env_output(&raw)?.to_string())
}
/// 用与 `try_get_version` 相同的登录 shell 解析 PATH 默认命中的可执行文件路径,
/// canonicalize 后作为"命令行默认 / 升级目标"的锚点(与升级会作用的那处对齐)。
#[cfg(not(target_os = "windows"))]
@@ -2071,6 +2004,7 @@ fn npm_package_for(tool: &str) -> Option<&'static str> {
"grok" => Some("@xai-official/grok"),
"opencode" => Some("opencode-ai"),
"openclaw" => Some("openclaw"),
"pi" => Some("@earendil-works/pi-coding-agent"),
_ => None,
}
}
@@ -2277,64 +2211,6 @@ fn anchored_official_update_command(tool: &str, bin_path: &str) -> Option<String
official_update_args(tool).map(|args| format!("{} {args}", win_quote_path_for_batch(bin_path)))
}
/// Grok Build 原生安装的升级命令 = `<bin 绝对> update || <官方 installer>`。
///
/// **为什么唯独 native Grok 的 self-update 需要 fallback**claude native / hermes 都没有):
/// `grok update` 虽然是自包含 Rust 二进制的子命令,**却把 npm 当成自己的分发管道**——
/// 先 spawn `npm view @xai-official/grok version --json` 查最新版,再 spawn
/// `npm i -g @xai-official/grok@<version>` 安装,由该包的 `postinstall.js` 从平台 optional
/// 依赖里解出二进制、安置成 `~/.grok/bin/grok-<version>` 并 relink `grok`。而 `npm` 自身是
/// `#!/usr/bin/env node` 脚本 → **native 安装也隐式硬依赖 PATH 里同时有 `npm` + `node`**。
/// GUI 进程 PATH 由 launchd 给、`run_tool_lifecycle_silently` 又是非登录 `bash -c`
/// nvm/homebrew 下的 node+npm 均不可见 → grok 内部 spawn 得到 ENOENT,只向用户抛出
/// 费解的 `Error: No such file or directory (os error 2)`(实测复现)。
///
/// **这是上游换了机制**:0.2.111 及更早版本自更新是直接下载二进制到 `~/.grok/downloads/`
/// (彼时 `is_grok_native_install` 假设的 "native = 不碰 npm" 成立),0.2.112 起改走上述 npm
/// 管道(落点随之从 `downloads/` 变为 `bin/`)。**副作用**:npm 全局包那一处安装是
/// `grok update` 自己装出来的,非用户手动所为,故 native 用户也会被 enumerate 到两处;
/// 两处由同一次 postinstall 同步,版本恒等。
///
/// 这正是 `anchored_command_from_paths` 那条"绝对路径 + 必要时把解释器目录放 PATH 首位"
/// 不变量没覆盖的第三类:**执行体自身既不需要解释器、也已用绝对路径,却在内部再 spawn
/// 第三方 CLI**。`login_shell_path` 的 PATH 注入已让绝大多数机器上的 primary 直接成功;
/// 这条 fallback 覆盖的是"这台机器根本没装 node"——用官方 installer 装的用户完全可能如此。
///
/// **fallback 必须是官方 installer,不能是 `npm i -g`**:后者与 primary **同源**——primary
/// 失败的两种现实原因(本机无 node;npm registry 指向未同步该 tarball 的镜像,见
/// npmmirror dist-tag 事故)都会让 npm fallback 一并失败,`||` 形同虚设。官方 installer
/// 是唯一 node-free 的独立路径(只需 `curl`,在 `/usr/bin`,窄 PATH 下可用),且落点同为
/// `~/.grok/bin`,锚定语义分毫不动 —— 真正的降级冗余要求 fallback 与 primary **失败模式不相关**。
///
/// **这条 fallback 还兼具第三重作用:修复上游锚点(勿在重构时丢掉)**。
/// grok 的更新路径由 `~/.grok/config.toml` 的 `[cli] installer` 决定(`npm` / `internal` /
/// `gh-release`),而官方 install.sh 会**无条件把该字段覆写为 `internal`**(其 awk 段落先插入
/// `installer = "internal"`、再跳过 `[cli]` 段里已有的 `installer`/`channel` 行)。于是:
/// 用户一旦因 install 端的 npm fallback 被切进 npm 模式(postinstall.js 会写 `installer = "npm"`
/// 并在每次 npm 更新时重写,自我巩固),只要 npm 路径出任何问题,这里的 `||` 就会把他拉回
/// node-free 的 internal 模式,并顺带修好 npm 模式漏更新的 `~/.grok/bin/agent` launcher。
/// **实测验证**2026-07-30,窄 PATH + `installer = "npm"`):primary 抛 `os error 2` → fallback
/// 接管 → 装上最新版 → config 写回 `internal` → agent 对齐 → `.zshrc` 幂等更新不重复。
/// ⇒ install 端保留 npm fallback 是安全的(它是 x.ai 不可达时的唯一退路,防火墙场景需要),
/// 其副作用由本链自愈;**把这里换成 npm fallback 会同时废掉降级冗余和这条自愈路径**。
#[cfg(not(target_os = "windows"))]
fn grok_native_update_command(update: String) -> String {
chain_update_commands(
update,
GROK_INSTALL_UNIX.to_string(),
LifecycleCommandShell::Posix,
)
}
/// Windows 版同上,fallback 换成官方 PowerShell installer。
/// **不走 `chain_update_commands`**:它会给 `||` 右侧加 `call`,而这里的 fallback 是
/// `powershell.exe`(不是 `.cmd`/`.bat`),不需要 `call`——与 `hermes_update_windows_command`
/// 同一理由。
#[cfg(target_os = "windows")]
fn grok_native_update_command(update: String) -> String {
format!("{update} || {}", grok_install_windows_command())
}
/// 哪些工具的"官方 self-update"优先于包管理器升级(生成 `<tool> update || <pkg-mgr>`)。
///
/// **codex 刻意不在此列**`codex update` 在 npm 安装上只是裸 `npm install -g
@@ -2489,9 +2365,7 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
return anchored_official_update_command(tool, bin_path);
}
if tool == "grok" && is_grok_native_install(bin_path, real_target) {
return Some(grok_native_update_command(
anchored_official_update_command(tool, bin_path)?,
));
return anchored_official_update_command(tool, bin_path);
}
let package_command = package_manager_anchored_command_from_paths(tool, bin_path, real_target);
if brew_formula_from_path(real_target).is_some() {
@@ -2570,9 +2444,7 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
return anchored_official_update_command(tool, bin_path);
}
if tool == "grok" && is_grok_native_install(bin_path, real_target) {
return Some(grok_native_update_command(
anchored_official_update_command(tool, bin_path)?,
));
return anchored_official_update_command(tool, bin_path);
}
let package_command = package_manager_anchored_command_from_paths(tool, bin_path);
if prefers_official_update(tool, LifecycleCommandShell::WindowsBatch) {
@@ -2669,11 +2541,6 @@ fn installer_with_npm_fallback(installer: &str, tool: &str) -> String {
fn posix_install_command_for(tool: &str) -> String {
match tool {
"claude" => installer_with_npm_fallback(CLAUDE_INSTALL_UNIX, tool),
// Grok 的 npm fallback **会切换用户的分发模式**(该包 postinstall 把
// `~/.grok/config.toml` 的 `[cli] installer` 写成 `npm`,此后 `grok update` 一律走
// npm、隐式依赖 node)。仍然保留它:官方 installer 不可达(防火墙 / x.ai 被拦)时
// 这是唯一退路,而副作用可自愈——`grok_native_update_command` 的 `||` 官方 installer
// 会在 npm 路径出问题时把 `installer` 覆写回 `internal`(见该函数 doc 的实测记录)。
"grok" => installer_with_npm_fallback(GROK_INSTALL_UNIX, tool),
"opencode" => installer_with_npm_fallback(OPENCODE_INSTALL_UNIX, tool),
"hermes" => HERMES_INSTALL_UNIX.to_string(),
@@ -2789,6 +2656,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option<String> {
"opencode" => crate::settings::get_opencode_override_dir(),
"openclaw" => crate::settings::get_openclaw_override_dir(),
"hermes" => crate::settings::get_hermes_override_dir(),
"pi" => crate::settings::get_pi_override_dir(),
_ => None,
}?;
@@ -3926,6 +3794,24 @@ mod tests {
);
}
#[test]
fn pi_lifecycle_metadata_matches_pinned_distribution() {
let requested = vec!["unsupported".to_string(), "pi".to_string()];
assert_eq!(normalize_requested_tools(&requested), vec!["pi"]);
assert_eq!(tool_display_name("pi"), "Pi");
assert_eq!(
npm_package_for("pi"),
Some("@earendil-works/pi-coding-agent")
);
assert_eq!(
npm_install_command_for("pi"),
Some("npm i -g @earendil-works/pi-coding-agent@latest")
);
// The verified distribution exposes `pi --version`, but no updater
// contract is assumed; upgrades stay on the package-manager path.
assert_eq!(official_update_args("pi"), None);
}
#[test]
fn test_compare_semver() {
use std::cmp::Ordering;
@@ -4182,24 +4068,11 @@ mod tests {
}
#[test]
fn grok_native_windows_uses_self_update_with_installer_fallback() {
// sibling 有 npm.cmd 也**不能**拿它当 fallback:`grok update` 本身就是靠 npm
// 分发的(见 grok_native_update_command doc),npm fallback 与 primary 同源、
// 会一起失败。fallback 必须是官方 PowerShell installer —— 唯一不经 npm 的路径。
fn grok_native_windows_uses_self_update() {
let (_dir, _sub, bin_path) = setup_sibling(".grok/bin", "grok.exe", &["npm.cmd"]);
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path).unwrap();
let expected = format!(
"{} update || {}",
expect_quoted_path(&bin_path),
grok_install_windows_command()
);
assert_eq!(cmd, expected);
// fallback 是 powershell.exe 不是 .cmd/.bat —— `||` 右侧不该有 `call`。
assert!(
!cmd.contains("|| call"),
"powershell needs no `call`: {cmd}"
);
assert!(!cmd.contains("npm"), "npm must not be the fallback: {cmd}");
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path);
let expected = format!("{} update", expect_quoted_path(&bin_path));
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
#[test]
@@ -4669,7 +4542,7 @@ mod tests {
}
#[test]
fn grok_native_installer_uses_self_update_with_installer_fallback() {
fn grok_native_installer_uses_self_update() {
// ~/.grok/bin/grok is a launcher symlink into ~/.grok/downloads.
// Updating it through npm would create or mutate a different install.
let cmd = anchored_command_from_paths(
@@ -4677,25 +4550,7 @@ mod tests {
"/Users/me/.grok/bin/grok",
"/Users/me/.grok/downloads/grok-macos-aarch64",
);
assert_eq!(
cmd.as_deref(),
Some(format!("/Users/me/.grok/bin/grok update || {GROK_INSTALL_UNIX}").as_str())
);
}
#[test]
fn grok_native_update_falls_back_to_installer_not_npm() {
// 反向锁定:`grok update` 内部靠 `npm view` + `npm i -g` 完成升级,GUI 的窄
// PATH 下会 ENOENT。fallback 必须是官方 installer —— 换成 `npm i -g` 就与
// primary 同源(无 node / 镜像缺 tarball 时一起失败),`||` 形同虚设。
let cmd = anchored_command_from_paths(
"grok",
"/Users/me/.grok/bin/grok",
"/Users/me/.grok/downloads/grok-macos-aarch64",
)
.expect("native grok should anchor");
assert!(cmd.contains("x.ai/cli/install.sh"), "{cmd}");
assert!(!cmd.contains("npm"), "npm must not be the fallback: {cmd}");
assert_eq!(cmd.as_deref(), Some("/Users/me/.grok/bin/grok update"));
}
#[test]
@@ -4705,10 +4560,7 @@ mod tests {
"/Users/me/bin/grok",
"/Users/me/.grok/downloads/grok-macos-aarch64",
);
assert_eq!(
cmd.as_deref(),
Some(format!("/Users/me/bin/grok update || {GROK_INSTALL_UNIX}").as_str())
);
assert_eq!(cmd.as_deref(), Some("/Users/me/bin/grok update"));
}
#[test]
@@ -5179,35 +5031,6 @@ mod tests {
assert_eq!(first_abs_path_line("welcome\nbye\n"), None);
}
#[test]
fn path_line_from_env_output_survives_shell_noise() {
// `$SHELL -lic /usr/bin/env` 的 stdout 前面可能有交互式 rc 的欢迎语。
let raw = "🚀 Welcome back, Jason!\nSHELL=/bin/zsh\nPATH=/opt/homebrew/bin:/usr/bin\nHOME=/Users/me\n";
assert_eq!(
path_line_from_env_output(raw),
Some("/opt/homebrew/bin:/usr/bin")
);
// 多行值的环境变量里恰好有一行以 `PATH=` 开头时,「值须以 / 开头」把它筛掉。
let poisoned = "SOME_SCRIPT=line1\nPATH=not-a-path\nPATH=/usr/bin:/bin\n";
assert_eq!(path_line_from_env_output(poisoned), Some("/usr/bin:/bin"));
// 完全没有 PATH 行 → None,调用方保持不注入。
assert_eq!(path_line_from_env_output("HOME=/Users/me\n"), None);
}
#[test]
fn merge_path_segments_dedupes_preserving_login_order() {
// 登录 shell 的段全部在前且保序;继承 PATH 里的新段追加在后。
assert_eq!(
merge_path_segments(
"/Users/me/.nvm/versions/node/v22/bin:/usr/bin:/bin",
"/usr/bin:/bin:/usr/sbin"
),
"/Users/me/.nvm/versions/node/v22/bin:/usr/bin:/bin:/usr/sbin"
);
// 空段(`a::b` 在 POSIX 下意为当前目录)不该被注入。
assert_eq!(merge_path_segments("/usr/bin::/bin", ""), "/usr/bin:/bin");
}
#[test]
fn is_conflicting_thresholds() {
let make = |version: Option<&str>, runnable: bool| ToolInstallation {
@@ -5331,6 +5154,13 @@ mod tests {
assert_eq!(cmd, "npm i -g openclaw@latest");
}
#[test]
fn pi_install_uses_the_verified_pinned_package() {
let cmd = install_command_for("pi");
assert_eq!(cmd, "npm i -g @earendil-works/pi-coding-agent@latest");
assert!(!cmd.contains("||"));
}
#[test]
fn update_fallbacks_use_official_cli_only_when_supported() {
assert_eq!(
@@ -5360,6 +5190,11 @@ mod tests {
static_fallback_command("openclaw"),
"openclaw update --yes || npm i -g openclaw@latest"
);
assert_eq!(
static_fallback_command("pi"),
"npm i -g @earendil-works/pi-coding-agent@latest"
);
assert!(!static_fallback_command("pi").contains("pi update"));
}
#[test]
+2
View File
@@ -17,6 +17,7 @@ mod misc;
mod model_fetch;
mod omo;
mod openclaw;
mod pi;
mod plugin;
mod profile;
mod prompt;
@@ -53,6 +54,7 @@ pub use misc::*;
pub use model_fetch::*;
pub use omo::*;
pub use openclaw::*;
pub(crate) use pi::*;
pub use plugin::*;
pub use profile::*;
pub use prompt::*;
+76
View File
@@ -0,0 +1,76 @@
use crate::pi_config::model::PiNativeDiagnostic;
use crate::pi_config::native_settings::{read_pi_native_defaults, PiNativeDefaults};
use crate::services::pi_catalog::{PiCatalogCoordinator, PiCatalogMutation};
use crate::session_manager::providers::pi::PiSessionDiscovery;
use crate::store::AppState;
use tauri::State;
/// Read-only diagnostics come exclusively from the Pre-C certified inspection
/// service. This command does not infer manageability or gateway status.
#[tauri::command]
pub(crate) fn get_pi_native_catalog(
state: State<'_, AppState>,
) -> Result<Vec<PiNativeDiagnostic>, String> {
PiCatalogCoordinator::inspect_native(state.inner()).map_err(|error| error.to_string())
}
#[tauri::command]
pub(crate) fn import_pi_native_provider(
state: State<'_, AppState>,
#[allow(non_snake_case)] providerKey: String,
#[allow(non_snake_case)] expectedFingerprint: String,
) -> Result<String, String> {
let result = PiCatalogCoordinator::apply(
state.inner(),
PiCatalogMutation::ImportNative {
provider_key: providerKey,
expected_fingerprint: expectedFingerprint,
},
)
.map_err(|error| error.to_string())?;
result
.provider_id
.ok_or_else(|| "Pi import did not return a provider id".to_string())
}
#[tauri::command]
pub(crate) fn set_pi_default_model(
state: State<'_, AppState>,
#[allow(non_snake_case)] providerId: String,
#[allow(non_snake_case)] modelId: String,
) -> Result<bool, String> {
PiCatalogCoordinator::apply(
state.inner(),
PiCatalogMutation::SetDefault {
provider_id: providerId,
model_id: modelId,
},
)
.map(|_| true)
.map_err(|error| error.to_string())
}
#[tauri::command]
pub(crate) fn get_pi_native_defaults() -> Result<PiNativeDefaults, String> {
read_pi_native_defaults().map_err(|error| error.to_string())
}
#[tauri::command]
pub(crate) fn get_pi_session_discovery() -> PiSessionDiscovery {
crate::session_manager::providers::pi::session_discovery()
}
/// Explicitly rotate the device-local gateway bearer and republish every
/// managed Pi projection. Existing Pi processes must restart because they
/// retain the previous projected credential in memory.
#[tauri::command]
pub(crate) async fn reset_pi_gateway_credential(
state: State<'_, AppState>,
) -> Result<bool, String> {
state
.proxy_service
.rotate_pi_gateway_token()
.await
.map(|()| true)
.map_err(|error| error.to_string())
}
+63 -1
View File
@@ -5,7 +5,11 @@ use tauri::State;
use crate::app_config::AppType;
use crate::prompt::Prompt;
use crate::services::PromptService;
use crate::services::pi_prompt_files::{
PiPromptFileKind, PiPromptFileService, PiPromptFileSnapshot, PiPromptTemplate,
PiPromptTemplateService,
};
use crate::services::prompt::{PiPromptLibraryStatus, PromptService};
use crate::store::AppState;
#[tauri::command]
@@ -62,3 +66,61 @@ pub async fn get_current_prompt_file_content(app: String) -> Result<Option<Strin
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
PromptService::get_current_file_content(app_type).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_pi_prompt_library_status(
state: State<'_, AppState>,
) -> Result<PiPromptLibraryStatus, String> {
PromptService::get_pi_library_status(&state).map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn reconcile_pi_prompt_library(state: State<'_, AppState>) -> Result<(), String> {
PromptService::reconcile_pi_library(&state).map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn get_pi_prompt_file(kind: PiPromptFileKind) -> Result<PiPromptFileSnapshot, String> {
PiPromptFileService::read(kind).map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn replace_pi_prompt_file(
kind: PiPromptFileKind,
#[allow(non_snake_case)] expectedRevision: String,
content: String,
) -> Result<PiPromptFileSnapshot, String> {
PiPromptFileService::replace(kind, &expectedRevision, &content)
.map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn delete_pi_prompt_file(
kind: PiPromptFileKind,
#[allow(non_snake_case)] expectedRevision: String,
) -> Result<bool, String> {
PiPromptFileService::delete(kind, &expectedRevision).map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn list_pi_prompt_templates() -> Result<Vec<PiPromptTemplate>, String> {
PiPromptTemplateService::list().map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn upsert_pi_prompt_template(
slug: String,
#[allow(non_snake_case)] expectedRevision: String,
content: String,
) -> Result<PiPromptTemplate, String> {
PiPromptTemplateService::upsert(&slug, &expectedRevision, &content)
.map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn delete_pi_prompt_template(
slug: String,
#[allow(non_snake_case)] expectedRevision: String,
) -> Result<bool, String> {
PiPromptTemplateService::delete(&slug, &expectedRevision).map_err(|error| error.to_string())
}
+11 -4
View File
@@ -4,8 +4,9 @@ use tauri::{Emitter, Manager, State};
use crate::app_config::AppType;
use crate::commands::copilot::CopilotAuthState;
use crate::commands::xai_oauth::XaiOAuthState;
use crate::database::NewProviderAggregate;
use crate::error::AppError;
use crate::provider::{ClaudeDesktopMode, Provider};
use crate::provider::{ClaudeDesktopMode, Provider, ProviderMutationInput};
use crate::services::{
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
};
@@ -39,7 +40,7 @@ pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<S
pub fn add_provider(
state: State<'_, AppState>,
app: String,
provider: Provider,
provider: ProviderMutationInput,
#[allow(non_snake_case)] addToLive: Option<bool>,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
@@ -51,7 +52,7 @@ pub fn add_provider(
pub fn update_provider(
state: State<'_, AppState>,
app: String,
provider: Provider,
provider: ProviderMutationInput,
#[allow(non_snake_case)] originalId: Option<String>,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
@@ -250,7 +251,13 @@ pub fn import_claude_desktop_providers_from_claude(
state
.db
.save_provider(AppType::ClaudeDesktop.as_str(), &desktop_provider)
.create_provider(
NewProviderAggregate::from_input(
AppType::ClaudeDesktop.as_str(),
crate::services::provider::provider_to_mutation_input(desktop_provider),
)
.map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
imported += 1;
}
+58
View File
@@ -26,6 +26,7 @@ pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(),
|| takeover.grokbuild
|| takeover.opencode
|| takeover.openclaw
|| takeover.pi
{
return Err(
"仍有应用处于代理接管状态,请先在设置中关闭对应应用接管后再停止本地路由。".to_string(),
@@ -120,6 +121,9 @@ pub async fn get_proxy_config_for_app(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<AppProxyConfig, String> {
if app_type == "pi" {
return Ok(crate::settings::get_pi_app_proxy_config());
}
let db = &state.db;
db.get_proxy_config_for_app(&app_type)
.await
@@ -138,6 +142,60 @@ pub async fn update_proxy_config_for_app(
let app_type = config.app_type.clone();
let circuit_config = CircuitBreakerConfig::from(&config);
if app_type == "pi" {
let _guard = state
.proxy_service
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
.await;
let previous = crate::settings::get_pi_proxy_settings();
if config.enabled != crate::settings::pi_takeover_enabled() {
return Err(
"Pi enabled state is owned by set_proxy_takeover_for_app, not proxy config"
.to_string(),
);
}
let next = crate::settings::PiProxySettings {
auto_failover_enabled: config.auto_failover_enabled,
max_retries: config.max_retries,
streaming_first_byte_timeout: config.streaming_first_byte_timeout,
streaming_idle_timeout: config.streaming_idle_timeout,
non_streaming_timeout: config.non_streaming_timeout,
circuit_failure_threshold: config.circuit_failure_threshold,
circuit_success_threshold: config.circuit_success_threshold,
circuit_timeout_seconds: config.circuit_timeout_seconds,
circuit_error_rate_threshold: config.circuit_error_rate_threshold,
circuit_min_requests: config.circuit_min_requests,
};
let epoch = state.proxy_service.begin_pi_catalog_mutation().await;
if let Err(error) = crate::settings::update_pi_proxy_settings(next) {
let _ = state
.proxy_service
.reconcile_pi_runtime_at_epoch(epoch)
.await;
return Err(error.to_string());
}
if let Err(error) = state
.proxy_service
.reconcile_pi_runtime_at_epoch(epoch)
.await
{
let _ = crate::settings::update_pi_proxy_settings(previous);
let rollback_epoch = state.proxy_service.begin_pi_catalog_mutation().await;
let _ = state
.proxy_service
.reconcile_pi_runtime_at_epoch(rollback_epoch)
.await;
return Err(format!(
"Pi proxy config changed but runtime publication failed: {error}"
));
}
state
.proxy_service
.update_circuit_breaker_config_for_app(&app_type, circuit_config)
.await?;
return Ok(());
}
db.update_proxy_config_for_app(config)
.await
.map_err(|e| e.to_string())?;
+31 -5
View File
@@ -107,18 +107,44 @@ pub async fn s3_sync_upload(state: State<'_, AppState>) -> Result<Value, String>
#[tauri::command]
pub async fn s3_sync_download(state: State<'_, AppState>) -> Result<Value, String> {
let db = state.db.clone();
let db_for_sync = db.clone();
let app_state = state.inner().clone();
let mut settings = require_enabled_s3_settings()?;
let _auto_sync_suppression = crate::services::s3_auto_sync::AutoSyncSuppressionGuard::new();
let pi_guard = app_state
.proxy_service
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
.await;
app_state
.proxy_service
.prepare_pi_portable_import_under_lock(&pi_guard)
.await
.map_err(|error| format!("S3 下载前恢复 Pi 直连投影失败: {error}"))?;
let sync_result = run_with_s3_lock(s3_sync_service::download(&db, &mut settings)).await;
let mut result = map_sync_result(sync_result, |error| {
persist_sync_error(&mut settings, error, "manual")
})?;
let mut result = match sync_result {
Ok(result) => result,
Err(error) => {
persist_sync_error(&mut settings, &error, "manual");
let recovery = app_state
.proxy_service
.recover_pi_after_aborted_portable_import_under_lock(&pi_guard)
.await;
return Err(match recovery {
Ok(()) => error.to_string(),
Err(recovery) => {
format!(
"{error}; Pi gateway recovery after aborted S3 download failed: {recovery}"
)
}
});
}
};
drop(pi_guard);
// Post-download sync is best-effort: snapshot restore has already succeeded.
let sync_state = app_state.clone();
let warning = post_sync_warning_from_result(
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(db_for_sync))
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(&sync_state))
.await
.map_err(|e| e.to_string()),
);
+59 -2
View File
@@ -48,6 +48,13 @@ fn merge_settings_for_save(
// 开关)后、前端 query 缓存刷新前的一次全量保存会把旧 marker 重放回来,
// 重新开启时被"复活"的标记挡住而漏迁。
incoming.local_migrations = existing.local_migrations.clone();
// Pi gateway credential is an installation secret. Settings IPC can
// neither observe it (frontend projection clears it) nor mutate it.
incoming.pi_gateway_token = existing.pi_gateway_token.clone();
// Pi proxy behavior is committed through the proxy commands so a generic
// settings round-trip cannot bypass the switch/epoch publication boundary.
incoming.pi_takeover_enabled = existing.pi_takeover_enabled;
incoming.pi_proxy = existing.pi_proxy.clone();
incoming
}
@@ -63,12 +70,29 @@ pub async fn save_settings(
state: tauri::State<'_, crate::store::AppState>,
settings: crate::settings::AppSettings,
) -> Result<bool, String> {
// The frontend settings projection intentionally cannot mutate Pi's
// takeover bit or gateway secret. Serialize the read/merge/write with Pi
// catalog mutations so a concurrent toggle cannot be overwritten by a
// stale full-settings payload.
let pi_guard = state
.proxy_service
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
.await;
let existing = crate::settings::get_settings();
let merged = merge_settings_for_save(settings, &existing);
let unify_codex_changed =
merged.unify_codex_session_history != existing.unify_codex_session_history;
let unify_codex_enabled = merged.unify_codex_session_history;
crate::settings::update_settings(merged).map_err(|e| e.to_string())?;
state
.proxy_service
.replace_settings_with_pi_directory_boundary_under_lock(
&pi_guard,
&existing,
merged.clone(),
)
.await
.map_err(|e| e.to_string())?;
drop(pi_guard);
// 统一会话开关变更时立即重写当前官方 Codex 供应商的 live 配置,
// 不必等下一次切换才生效。
@@ -82,7 +106,18 @@ pub async fn save_settings(
crate::services::provider::reapply_current_codex_official_live(state.inner())
{
log::warn!("统一 Codex 会话历史开关变更后重写 live 配置失败,回滚设置: {err}");
if let Err(rollback_err) = crate::settings::update_settings(existing) {
let pi_guard = state
.proxy_service
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
.await;
let current = crate::settings::get_settings();
if let Err(rollback_err) = state
.proxy_service
.replace_settings_with_pi_directory_boundary_under_lock(
&pi_guard, &current, existing,
)
.await
{
log::error!("回滚统一会话开关设置失败: {rollback_err}");
}
return Err(format!(
@@ -618,6 +653,28 @@ mod tests {
assert!(merged.local_migrations.is_none());
}
#[test]
fn save_settings_cannot_bypass_pi_gateway_publication_ownership() {
let existing = AppSettings {
pi_takeover_enabled: true,
pi_proxy: crate::settings::PiProxySettings {
max_retries: 7,
..crate::settings::PiProxySettings::default()
},
..AppSettings::default()
};
let incoming = AppSettings {
pi_takeover_enabled: false,
pi_proxy: crate::settings::PiProxySettings::default(),
..AppSettings::default()
};
let merged = merge_settings_for_save(incoming, &existing);
assert!(merged.pi_takeover_enabled);
assert_eq!(merged.pi_proxy.max_retries, 7);
}
}
/// 获取开机自启状态
+9
View File
@@ -11,7 +11,9 @@ use crate::services::skill::{
SkillService, SkillStorageLocation, SkillUninstallResult, SkillUpdateInfo,
SkillsShSearchResult,
};
use crate::services::skill_deployment::{PiSkillDeploymentService, SkillAppStatus};
use crate::store::AppState;
use std::collections::BTreeMap;
use std::str::FromStr;
use std::sync::Arc;
use tauri::State;
@@ -32,6 +34,13 @@ pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<Instal
SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_pi_skill_statuses(
app_state: State<'_, AppState>,
) -> Result<BTreeMap<String, SkillAppStatus>, String> {
PiSkillDeploymentService::inspect_all(&app_state.db).map_err(|error| error.to_string())
}
#[tauri::command]
pub fn get_skill_backups() -> Result<Vec<SkillBackupEntry>, String> {
SkillService::list_backups().map_err(|e| e.to_string())
+5 -7
View File
@@ -1,15 +1,13 @@
use serde_json::{json, Value};
use std::sync::Arc;
use crate::database::Database;
use crate::error::AppError;
use crate::services::provider::ProviderService;
use crate::services::PromptService;
use crate::settings;
use crate::store::AppState;
use serde_json::{json, Value};
pub(crate) fn run_post_import_sync(db: Arc<Database>) -> Result<(), AppError> {
let app_state = AppState::new(db);
ProviderService::sync_current_to_live(&app_state)?;
pub(crate) fn run_post_import_sync(app_state: &AppState) -> Result<(), AppError> {
PromptService::reconcile_pi_portable_import(app_state)?;
ProviderService::sync_current_to_live(app_state)?;
settings::reload_settings()?;
Ok(())
}
+31 -5
View File
@@ -115,18 +115,44 @@ pub async fn webdav_sync_upload(state: State<'_, AppState>) -> Result<Value, Str
#[tauri::command]
pub async fn webdav_sync_download(state: State<'_, AppState>) -> Result<Value, String> {
let db = state.db.clone();
let db_for_sync = db.clone();
let app_state = state.inner().clone();
let mut settings = require_enabled_webdav_settings()?;
let _auto_sync_suppression = crate::services::webdav_auto_sync::AutoSyncSuppressionGuard::new();
let pi_guard = app_state
.proxy_service
.lock_switch_for_app(crate::app_config::AppType::Pi.as_str())
.await;
app_state
.proxy_service
.prepare_pi_portable_import_under_lock(&pi_guard)
.await
.map_err(|error| format!("WebDAV 下载前恢复 Pi 直连投影失败: {error}"))?;
let sync_result = run_with_webdav_lock(webdav_sync_service::download(&db, &mut settings)).await;
let mut result = map_sync_result(sync_result, |error| {
persist_sync_error(&mut settings, error, "manual")
})?;
let mut result = match sync_result {
Ok(result) => result,
Err(error) => {
persist_sync_error(&mut settings, &error, "manual");
let recovery = app_state
.proxy_service
.recover_pi_after_aborted_portable_import_under_lock(&pi_guard)
.await;
return Err(match recovery {
Ok(()) => error.to_string(),
Err(recovery) => {
format!(
"{error}; Pi gateway recovery after aborted WebDAV download failed: {recovery}"
)
}
});
}
};
drop(pi_guard);
// Post-download sync is best-effort: snapshot restore has already succeeded.
let sync_state = app_state.clone();
let warning = post_sync_warning_from_result(
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(db_for_sync))
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(&sync_state))
.await
.map_err(|e| e.to_string()),
);
+95 -35
View File
@@ -295,6 +295,22 @@ pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> {
/// 原子写入:写入临时文件后 rename 替换,避免半写状态
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
atomic_write_durable(path, data, None)
}
/// Durable same-directory atomic replacement.
///
/// Existing permissions are preserved. `new_file_mode` controls only a newly
/// created Unix file (settings and other local secrets pass `0o600`). The
/// temporary file is created exclusively, synced before replacement, and the
/// containing directory is synced afterwards on Unix.
pub(crate) fn atomic_write_durable(
path: &Path,
data: &[u8],
new_file_mode: Option<u32>,
) -> Result<(), AppError> {
#[cfg(not(unix))]
let _ = new_file_mode;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
@@ -302,51 +318,95 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
let parent = path
.parent()
.ok_or_else(|| AppError::Config("无效的路径".to_string()))?;
let mut tmp = parent.to_path_buf();
let file_name = path
.file_name()
.ok_or_else(|| AppError::Config("无效的文件名".to_string()))?
.to_string_lossy()
.to_string();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
tmp.push(format!("{file_name}.tmp.{ts}"));
let tmp = parent.join(format!(
".{file_name}.{}.tmp",
uuid::Uuid::new_v4().simple()
));
{
let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
f.write_all(data).map_err(|e| AppError::io(&tmp, e))?;
f.flush().map_err(|e| AppError::io(&tmp, e))?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(path) {
let perm = meta.permissions().mode();
let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm));
let result = (|| -> Result<(), AppError> {
let mut options = fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(new_file_mode.unwrap_or(0o666));
}
}
let mut file = options
.open(&tmp)
.map_err(|error| AppError::io(&tmp, error))?;
file.write_all(data)
.map_err(|error| AppError::io(&tmp, error))?;
file.flush().map_err(|error| AppError::io(&tmp, error))?;
file.sync_all().map_err(|error| AppError::io(&tmp, error))?;
drop(file);
#[cfg(windows)]
{
// Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性)
if path.exists() {
let _ = fs::remove_file(path);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(path)
.map(|metadata| metadata.permissions().mode())
.unwrap_or_else(|_| new_file_mode.unwrap_or(0o666));
fs::set_permissions(&tmp, fs::Permissions::from_mode(mode))
.map_err(|error| AppError::io(&tmp, error))?;
}
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
source: e,
})?;
}
#[cfg(not(windows))]
{
fs::rename(&tmp, path).map_err(|e| AppError::IoContext {
context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()),
source: e,
})?;
replace_file_atomically(&tmp, path)?;
#[cfg(unix)]
fs::File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|error| AppError::io(parent, error))?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&tmp);
}
result
}
#[cfg(not(windows))]
fn replace_file_atomically(temp_path: &Path, path: &Path) -> Result<(), AppError> {
fs::rename(temp_path, path).map_err(|source| AppError::IoContext {
context: format!(
"原子替换失败: {} -> {}",
temp_path.display(),
path.display()
),
source,
})
}
#[cfg(windows)]
fn replace_file_atomically(temp_path: &Path, path: &Path) -> Result<(), AppError> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::{
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
};
let source: Vec<u16> = temp_path.as_os_str().encode_wide().chain(Some(0)).collect();
let destination: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
// SAFETY: both buffers are NUL-terminated and remain alive for the
// duration of this synchronous Win32 call.
let moved = unsafe {
MoveFileExW(
source.as_ptr(),
destination.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if moved == 0 {
return Err(AppError::IoContext {
context: format!(
"原子替换失败: {} -> {}",
temp_path.display(),
path.display()
),
source: std::io::Error::last_os_error(),
});
}
Ok(())
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6
View File
@@ -4,12 +4,18 @@
pub mod failover;
pub mod mcp;
pub(crate) mod pi_catalog;
pub mod pi_projections;
pub mod profiles;
pub mod prompts;
pub mod provider_write;
#[cfg(test)]
mod provider_write_certification;
pub mod providers;
pub mod providers_seed;
pub mod proxy;
pub mod settings;
pub mod skill_deployments;
pub mod skills;
pub mod stream_check;
pub mod universal_providers;
+276
View File
@@ -0,0 +1,276 @@
//! Transactional database half of Pi catalog coordination.
//!
//! Provider row/endpoint SQL remains owned by the certified provider-write
//! primitives. This module only composes those primitives with Pi's exact-key
//! ownership ledger in one SQLite transaction.
use super::pi_projections::PiProviderProjection;
use super::provider_write::{
insert_endpoint, insert_row, restore_provider_aggregate_on_tx, NewEndpoint,
NewProviderAggregate, ProviderKey, ProviderRowUpdate,
};
use super::providers::delete_provider_on_tx;
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::provider::{ProviderAggregate, ProviderMutationInput};
use indexmap::IndexMap;
use rusqlite::params;
impl Database {
pub(crate) fn restore_pi_catalog_snapshot(
&self,
aggregates: &IndexMap<String, ProviderAggregate>,
projections: &[PiProviderProjection],
current_provider: Option<&str>,
) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
tx.execute("DELETE FROM pi_provider_projections", [])
.map_err(|error| AppError::Database(error.to_string()))?;
let current_ids = {
let mut statement = tx
.prepare("SELECT id FROM providers WHERE app_type = 'pi'")
.map_err(|error| AppError::Database(error.to_string()))?;
let ids = statement
.query_map([], |row| row.get::<_, String>(0))
.map_err(|error| AppError::Database(error.to_string()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| AppError::Database(error.to_string()))?;
ids
};
for provider_id in current_ids
.iter()
.filter(|provider_id| !aggregates.contains_key(provider_id.as_str()))
{
// Only rows created after the snapshot are removed. Updating
// providers which existed in the snapshot preserves dependent
// provider_health history instead of triggering ON DELETE CASCADE.
delete_provider_on_tx(&tx, "pi", provider_id)?;
}
for aggregate in aggregates.values() {
let key = ProviderKey::new("pi", aggregate.provider.id.clone())?;
let mut input = provider_mutation_input(aggregate);
if let Some(meta) = input.meta.as_mut() {
meta.custom_endpoints.clear();
}
let row = ProviderRowUpdate::from_input(&input)?;
let endpoints = aggregate
.endpoints
.values()
.cloned()
.map(NewEndpoint::try_from)
.collect::<Result<Vec<_>, _>>()?;
restore_provider_aggregate_on_tx(
&tx,
&key,
&row,
aggregate.provider.created_at,
aggregate.provider.sort_index,
current_provider == Some(key.id()),
aggregate.provider.in_failover_queue,
&endpoints,
)?;
}
for projection in projections {
tx.execute(
"INSERT INTO pi_provider_projections
(provider_id, provider_key, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4)",
params![
projection.provider_id,
projection.provider_key,
projection.created_at,
projection.updated_at
],
)
.map_err(|error| AppError::Database(error.to_string()))?;
}
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
pub(crate) fn create_pi_catalog_provider(
&self,
input: NewProviderAggregate,
provider_key: &str,
) -> Result<PiProviderProjection, AppError> {
if input.key.app_type() != "pi" || provider_key.trim().is_empty() {
return Err(AppError::InvalidInput(
"Pi catalog create requires app_type=pi and a non-empty native key".to_string(),
));
}
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
insert_row(
&tx,
&input.key,
&input.row.content,
input.row.created_at,
input.sort_index,
false,
input.in_failover_queue,
)?;
for endpoint in &input.initial_endpoints {
insert_endpoint(&tx, &input.key, endpoint)?;
}
let now = chrono::Utc::now().timestamp_millis();
tx.execute(
"INSERT INTO pi_provider_projections
(provider_id, provider_key, created_at, updated_at)
VALUES (?1, ?2, ?3, ?3)",
params![input.key.id(), provider_key, now],
)
.map_err(|error| match &error {
rusqlite::Error::SqliteFailure(code, _)
if matches!(
code.extended_code,
rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY
| rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
) =>
{
AppError::Conflict(format!(
"Pi native provider key '{provider_key}' is already claimed"
))
}
_ => AppError::Database(error.to_string()),
})?;
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(PiProviderProjection {
provider_id: input.key.id().to_string(),
provider_key: provider_key.to_string(),
created_at: now,
updated_at: now,
})
}
pub(crate) fn update_pi_catalog_provider(
&self,
key: &ProviderKey,
row: &ProviderRowUpdate,
) -> Result<(), AppError> {
if key.app_type() != "pi" {
return Err(AppError::InvalidInput(
"Pi catalog update requires app_type=pi".to_string(),
));
}
self.update_provider(key, row)
}
pub(crate) fn delete_pi_catalog_provider(
&self,
provider_id: &str,
) -> Result<Option<PiProviderProjection>, AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
let projection = tx
.query_row(
"SELECT provider_id, provider_key, created_at, updated_at
FROM pi_provider_projections
WHERE provider_id = ?1",
[provider_id],
|row| {
Ok(PiProviderProjection {
provider_id: row.get(0)?,
provider_key: row.get(1)?,
created_at: row.get(2)?,
updated_at: row.get(3)?,
})
},
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))?;
delete_provider_on_tx(&tx, "pi", provider_id)?;
tx.execute(
"DELETE FROM pi_provider_projections WHERE provider_id = ?1",
[provider_id],
)
.map_err(|error| AppError::Database(error.to_string()))?;
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(projection)
}
pub(crate) fn restore_pi_catalog_provider(
&self,
aggregate: &ProviderAggregate,
was_current: bool,
projection: Option<&PiProviderProjection>,
) -> Result<(), AppError> {
let key = ProviderKey::new("pi", aggregate.provider.id.clone())?;
let mut input = provider_mutation_input(aggregate);
if let Some(meta) = input.meta.as_mut() {
meta.custom_endpoints.clear();
}
let row = ProviderRowUpdate::from_input(&input)?;
let endpoints = aggregate
.endpoints
.values()
.cloned()
.map(NewEndpoint::try_from)
.collect::<Result<Vec<_>, _>>()?;
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
restore_provider_aggregate_on_tx(
&tx,
&key,
&row,
aggregate.provider.created_at,
aggregate.provider.sort_index,
was_current,
aggregate.provider.in_failover_queue,
&endpoints,
)?;
tx.execute(
"DELETE FROM pi_provider_projections WHERE provider_id = ?1",
[key.id()],
)
.map_err(|error| AppError::Database(error.to_string()))?;
if let Some(projection) = projection {
tx.execute(
"INSERT INTO pi_provider_projections
(provider_id, provider_key, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4)",
params![
projection.provider_id,
projection.provider_key,
projection.created_at,
projection.updated_at
],
)
.map_err(|error| AppError::Database(error.to_string()))?;
}
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
}
fn provider_mutation_input(aggregate: &ProviderAggregate) -> ProviderMutationInput {
let provider = &aggregate.provider;
ProviderMutationInput {
id: provider.id.clone(),
name: provider.name.clone(),
settings_config: provider.settings_config.clone(),
website_url: provider.website_url.clone(),
category: provider.category.clone(),
created_at: provider.created_at,
sort_index: provider.sort_index,
notes: provider.notes.clone(),
meta: provider.meta.clone(),
icon: provider.icon.clone(),
icon_color: provider.icon_color.clone(),
in_failover_queue: provider.in_failover_queue,
}
}
use rusqlite::OptionalExtension;
@@ -0,0 +1,204 @@
//! Device-local ownership ledger for exact keys in Pi's shared models.json.
// The projection writer is introduced in a later contract-ordered commit.
#![allow(dead_code)]
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use indexmap::IndexMap;
use rusqlite::{params, OptionalExtension};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PiProviderProjection {
pub provider_id: String,
pub provider_key: String,
pub created_at: i64,
pub updated_at: i64,
}
fn decode_projection(row: &rusqlite::Row<'_>) -> rusqlite::Result<PiProviderProjection> {
Ok(PiProviderProjection {
provider_id: row.get(0)?,
provider_key: row.get(1)?,
created_at: row.get(2)?,
updated_at: row.get(3)?,
})
}
impl Database {
pub(crate) fn get_pi_projection(
&self,
provider_id: &str,
) -> Result<Option<PiProviderProjection>, AppError> {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT provider_id, provider_key, created_at, updated_at
FROM pi_provider_projections WHERE provider_id = ?1",
[provider_id],
decode_projection,
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))
}
pub(crate) fn get_pi_projection_for_key(
&self,
provider_key: &str,
) -> Result<Option<PiProviderProjection>, AppError> {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT provider_id, provider_key, created_at, updated_at
FROM pi_provider_projections WHERE provider_key = ?1",
[provider_key],
decode_projection,
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))
}
pub(crate) fn get_pi_projection_manifest(
&self,
) -> Result<IndexMap<String, PiProviderProjection>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT provider_id, provider_key, created_at, updated_at
FROM pi_provider_projections ORDER BY provider_id",
)
.map_err(|error| AppError::Database(error.to_string()))?;
let rows = stmt
.query_map([], decode_projection)
.map_err(|error| AppError::Database(error.to_string()))?;
let mut manifest = IndexMap::new();
for row in rows {
let projection = row.map_err(|error| AppError::Database(error.to_string()))?;
manifest.insert(projection.provider_id.clone(), projection);
}
Ok(manifest)
}
/// Claim an exact key. Existing exact claims are idempotent; either-side
/// collisions fail and are never rewritten.
pub(crate) fn claim_pi_projection_key(
&self,
provider_id: &str,
provider_key: &str,
) -> Result<PiProviderProjection, AppError> {
if provider_id.trim().is_empty() || provider_key.trim().is_empty() {
return Err(AppError::Config(
"Pi projection provider id and key must be non-empty".to_string(),
));
}
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
let by_provider = tx
.query_row(
"SELECT provider_id, provider_key, created_at, updated_at
FROM pi_provider_projections WHERE provider_id = ?1",
[provider_id],
decode_projection,
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))?;
if let Some(existing) = by_provider {
if existing.provider_key != provider_key {
return Err(AppError::Config(format!(
"Pi provider '{provider_id}' already owns key '{}', not '{provider_key}'",
existing.provider_key
)));
}
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))?;
return Ok(existing);
}
if let Some(existing_owner) = tx
.query_row(
"SELECT provider_id FROM pi_provider_projections WHERE provider_key = ?1",
[provider_key],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))?
{
return Err(AppError::Config(format!(
"Pi key '{provider_key}' is already owned by provider '{existing_owner}'"
)));
}
let now = chrono::Utc::now().timestamp_millis();
tx.execute(
"INSERT INTO pi_provider_projections
(provider_id, provider_key, created_at, updated_at)
VALUES (?1, ?2, ?3, ?3)",
params![provider_id, provider_key, now],
)
.map_err(|error| AppError::Database(error.to_string()))?;
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(PiProviderProjection {
provider_id: provider_id.to_string(),
provider_key: provider_key.to_string(),
created_at: now,
updated_at: now,
})
}
pub(crate) fn delete_pi_projection_key(
&self,
provider_id: &str,
expected_key: &str,
) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let removed = conn
.execute(
"DELETE FROM pi_provider_projections
WHERE provider_id = ?1 AND provider_key = ?2",
params![provider_id, expected_key],
)
.map_err(|error| AppError::Database(error.to_string()))?;
if removed == 0
&& conn
.query_row(
"SELECT 1 FROM pi_provider_projections WHERE provider_id = ?1",
[provider_id],
|_| Ok(()),
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))?
.is_some()
{
return Err(AppError::Config(format!(
"refusing to delete Pi projection '{provider_id}': expected key changed"
)));
}
Ok(removed == 1)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn projection_claims_are_exact_idempotent_and_collision_safe() -> Result<(), AppError> {
let db = Database::memory()?;
let first = db.claim_pi_projection_key("provider-a", "native-a")?;
let repeated = db.claim_pi_projection_key("provider-a", "native-a")?;
assert_eq!(first, repeated);
assert!(db
.claim_pi_projection_key("provider-a", "native-b")
.is_err());
assert!(db
.claim_pi_projection_key("provider-b", "native-a")
.is_err());
assert_eq!(db.get_pi_projection_manifest()?.len(), 1);
assert!(db.delete_pi_projection_key("provider-a", "wrong").is_err());
assert!(db.get_pi_projection("provider-a")?.is_some());
assert!(db.delete_pi_projection_key("provider-a", "native-a")?);
assert!(db.get_pi_projection_for_key("native-a")?.is_none());
Ok(())
}
}
+164 -40
View File
@@ -6,51 +6,106 @@ use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::prompt::Prompt;
use indexmap::IndexMap;
use rusqlite::params;
use rusqlite::{params, Connection, Transaction};
fn query_prompts(conn: &Connection, app_type: &str) -> Result<IndexMap<String, Prompt>, AppError> {
let mut stmt = conn
.prepare(
"SELECT id, name, content, description, enabled, created_at, updated_at
FROM prompts WHERE app_type = ?1
ORDER BY created_at ASC, id ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let prompt_iter = stmt
.query_map(params![app_type], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let content: String = row.get(2)?;
let description: Option<String> = row.get(3)?;
let enabled: bool = row.get(4)?;
let created_at: Option<i64> = row.get(5)?;
let updated_at: Option<i64> = row.get(6)?;
Ok((
id.clone(),
Prompt {
id,
name,
content,
description,
enabled,
created_at,
updated_at,
},
))
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut prompts = IndexMap::new();
for prompt_res in prompt_iter {
let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?;
prompts.insert(id, prompt);
}
Ok(prompts)
}
fn validate_prompt_selection(prompts: &IndexMap<String, Prompt>) -> Result<(), AppError> {
if prompts.values().filter(|prompt| prompt.enabled).count() > 1 {
return Err(AppError::InvalidInput(
"at most one prompt may be enabled for an app".to_string(),
));
}
Ok(())
}
fn replace_prompt_rows(
transaction: &Transaction<'_>,
app_type: &str,
prompts: &IndexMap<String, Prompt>,
) -> Result<(), AppError> {
transaction
.execute("DELETE FROM prompts WHERE app_type = ?1", [app_type])
.map_err(|error| AppError::Database(error.to_string()))?;
let mut statement = transaction
.prepare(
"INSERT OR REPLACE INTO prompts (
id, app_type, name, content, description, enabled, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)
.map_err(|error| AppError::Database(error.to_string()))?;
for prompt in prompts.values() {
statement
.execute(params![
prompt.id,
app_type,
prompt.name,
prompt.content,
prompt.description,
prompt.enabled,
prompt.created_at,
prompt.updated_at,
])
.map_err(|error| AppError::Database(error.to_string()))?;
}
Ok(())
}
fn prompt_libraries_equal(
left: &IndexMap<String, Prompt>,
right: &IndexMap<String, Prompt>,
) -> bool {
left.len() == right.len()
&& left
.iter()
.all(|(id, prompt)| right.get(id) == Some(prompt))
}
impl Database {
/// 获取指定应用类型的所有提示词
pub fn get_prompts(&self, app_type: &str) -> Result<IndexMap<String, Prompt>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT id, name, content, description, enabled, created_at, updated_at
FROM prompts WHERE app_type = ?1
ORDER BY created_at ASC, id ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let prompt_iter = stmt
.query_map(params![app_type], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let content: String = row.get(2)?;
let description: Option<String> = row.get(3)?;
let enabled: bool = row.get(4)?;
let created_at: Option<i64> = row.get(5)?;
let updated_at: Option<i64> = row.get(6)?;
Ok((
id.clone(),
Prompt {
id,
name,
content,
description,
enabled,
created_at,
updated_at,
},
))
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut prompts = IndexMap::new();
for prompt_res in prompt_iter {
let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?;
prompts.insert(id, prompt);
}
Ok(prompts)
query_prompts(&conn, app_type)
}
/// 保存提示词
@@ -75,6 +130,75 @@ impl Database {
Ok(())
}
/// Persist a complete prompt-library selection atomically.
///
/// Pi projects the single enabled row into AGENTS.md. A sequence of
/// individual `save_prompt` calls can expose two enabled rows (or none) to
/// concurrent readers, so selection changes use one SQLite transaction.
pub(crate) fn save_prompt_selection(
&self,
app_type: &str,
prompts: &IndexMap<String, Prompt>,
) -> Result<(), AppError> {
validate_prompt_selection(prompts)?;
let mut conn = lock_conn!(self.conn);
let transaction = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
replace_prompt_rows(&transaction, app_type, prompts)?;
transaction
.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
/// Atomically publish a complete prompt library only while its full
/// before-image still matches. This is the database half of Pi's
/// native-file/portable-library compare-and-swap boundary.
pub(crate) fn compare_exchange_prompt_selection(
&self,
app_type: &str,
expected: &IndexMap<String, Prompt>,
replacement: &IndexMap<String, Prompt>,
) -> Result<(), AppError> {
validate_prompt_selection(replacement)?;
self.compare_exchange_prompt_selection_unchecked(app_type, expected, replacement)
}
/// Restore a captured before-image only if the database still contains the
/// exact attempted projection. The before-image may predate the current
/// single-selection invariant, so compensation must preserve it byte for
/// byte instead of refusing to restore legacy rows.
pub(crate) fn restore_prompt_selection_if_attempted(
&self,
app_type: &str,
attempted: &IndexMap<String, Prompt>,
before: &IndexMap<String, Prompt>,
) -> Result<(), AppError> {
self.compare_exchange_prompt_selection_unchecked(app_type, attempted, before)
}
fn compare_exchange_prompt_selection_unchecked(
&self,
app_type: &str,
expected: &IndexMap<String, Prompt>,
replacement: &IndexMap<String, Prompt>,
) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let transaction = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
let observed = query_prompts(&transaction, app_type)?;
if !prompt_libraries_equal(&observed, expected) {
return Err(AppError::Conflict(format!(
"{app_type} prompt library changed since it was read"
)));
}
replace_prompt_rows(&transaction, app_type, replacement)?;
transaction
.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
/// 删除提示词
pub fn delete_prompt(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
@@ -0,0 +1,655 @@
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::provider::{ProviderMeta, ProviderMutationInput};
use crate::settings::CustomEndpoint;
use rusqlite::{params, OptionalExtension, Transaction};
use serde_json::Value;
use std::collections::HashSet;
use super::providers::{StoredProviderRow, PROVIDER_SELECT};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderKey {
app_type: String,
id: String,
}
impl ProviderKey {
pub fn new(app_type: impl Into<String>, id: impl Into<String>) -> Result<Self, AppError> {
let app_type = app_type.into();
let id = id.into();
if app_type.trim().is_empty() || id.trim().is_empty() {
return Err(AppError::InvalidInput(
"provider app type and id must be non-empty".to_string(),
));
}
Ok(Self { app_type, id })
}
pub fn app_type(&self) -> &str {
&self.app_type
}
pub fn id(&self) -> &str {
&self.id
}
}
#[derive(Debug, Clone)]
pub struct ProviderRowUpdate {
pub(super) name: String,
pub(super) settings_config: Value,
pub(super) website_url: Option<String>,
pub(super) category: Option<String>,
pub(super) notes: Option<String>,
pub(super) meta: ProviderMeta,
pub(super) icon: Option<String>,
pub(super) icon_color: Option<String>,
}
impl ProviderRowUpdate {
pub fn from_input(input: &ProviderMutationInput) -> Result<Self, AppError> {
let meta = input.meta.clone().unwrap_or_default();
if !meta.custom_endpoints.is_empty() {
return Err(AppError::InvalidInput(
"provider update must not contain customEndpoints; use endpoint operations"
.to_string(),
));
}
Ok(Self {
name: input.name.clone(),
settings_config: input.settings_config.clone(),
website_url: input.website_url.clone(),
category: input.category.clone(),
notes: input.notes.clone(),
meta,
icon: input.icon.clone(),
icon_color: input.icon_color.clone(),
})
}
}
#[derive(Debug, Clone)]
pub struct ProviderRowCreate {
pub(super) content: ProviderRowUpdate,
pub(super) created_at: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct NewEndpoint {
pub(super) url: String,
pub(super) added_at: Option<i64>,
pub(super) last_used: Option<i64>,
}
impl NewEndpoint {
pub fn new(
url: impl Into<String>,
added_at: Option<i64>,
last_used: Option<i64>,
) -> Result<Self, AppError> {
let url = url.into();
if url.trim().is_empty() {
return Err(AppError::InvalidInput(
"provider endpoint URL cannot be empty".to_string(),
));
}
Ok(Self {
url,
added_at,
last_used,
})
}
pub fn now(url: impl Into<String>) -> Result<Self, AppError> {
Self::new(url, Some(chrono::Utc::now().timestamp_millis()), None)
}
}
impl TryFrom<CustomEndpoint> for NewEndpoint {
type Error = AppError;
fn try_from(endpoint: CustomEndpoint) -> Result<Self, Self::Error> {
Self::new(endpoint.url, endpoint.added_at, endpoint.last_used)
}
}
#[derive(Debug, Clone)]
pub struct NewProviderAggregate {
pub(super) key: ProviderKey,
pub(super) row: ProviderRowCreate,
pub(super) sort_index: Option<usize>,
pub(super) in_failover_queue: bool,
pub(super) initial_endpoints: Vec<NewEndpoint>,
}
impl NewProviderAggregate {
pub fn from_input(app_type: &str, mut input: ProviderMutationInput) -> Result<Self, AppError> {
let endpoints = input
.meta
.as_mut()
.map(|meta| std::mem::take(&mut meta.custom_endpoints))
.unwrap_or_default();
let mut seen = HashSet::with_capacity(endpoints.len());
let mut initial_endpoints = Vec::with_capacity(endpoints.len());
for (key, endpoint) in endpoints {
let normalized_key = key.trim().trim_end_matches('/').to_string();
let normalized_url = endpoint.url.trim().trim_end_matches('/').to_string();
if normalized_key != normalized_url {
return Err(AppError::InvalidInput(format!(
"provider endpoint key '{key}' must match endpoint URL '{}'",
endpoint.url
)));
}
if !seen.insert(normalized_url.clone()) {
return Err(AppError::InvalidInput(format!(
"duplicate initial provider endpoint '{}'",
endpoint.url
)));
}
initial_endpoints.push(NewEndpoint::new(
normalized_url,
endpoint.added_at,
endpoint.last_used,
)?);
}
let key = ProviderKey::new(app_type, input.id.clone())?;
let row = ProviderRowCreate {
content: ProviderRowUpdate::from_input(&input)?,
created_at: input.created_at,
};
Ok(Self {
key,
row,
sort_index: input.sort_index,
in_failover_queue: input.in_failover_queue,
initial_endpoints,
})
}
}
#[derive(Debug, Clone)]
pub struct RenameProvider {
source: ProviderKey,
target_id: String,
row: ProviderRowUpdate,
}
impl RenameProvider {
pub fn from_input(
source: ProviderKey,
input: &ProviderMutationInput,
) -> Result<Self, AppError> {
if !matches!(source.app_type(), "opencode" | "openclaw") {
return Err(AppError::InvalidInput(
"provider key changes are restricted to additive OpenCode/OpenClaw providers"
.to_string(),
));
}
if source.id() == input.id {
return Err(AppError::InvalidInput(
"provider rename requires a different target id".to_string(),
));
}
if input.id.trim().is_empty() {
return Err(AppError::InvalidInput(
"provider target id must be non-empty".to_string(),
));
}
let mut row = ProviderRowUpdate::from_input(input)?;
// A successful key change always remains DB-only. The service owns
// the corresponding live-file absence check, while the DAO persists
// the durable half of that invariant.
row.meta.live_config_managed = Some(false);
Ok(Self {
source,
target_id: input.id.clone(),
row,
})
}
}
fn encode_row(row: &ProviderRowUpdate) -> Result<(String, String), AppError> {
let settings_config = serde_json::to_string(&row.settings_config).map_err(|error| {
AppError::Database(format!("failed to serialize settings_config: {error}"))
})?;
let meta = serde_json::to_string(&row.meta).map_err(|error| {
AppError::Database(format!("failed to serialize provider meta: {error}"))
})?;
Ok((settings_config, meta))
}
pub(super) fn insert_row(
tx: &Transaction<'_>,
key: &ProviderKey,
row: &ProviderRowUpdate,
created_at: Option<i64>,
sort_index: Option<usize>,
is_current: bool,
in_failover_queue: bool,
) -> Result<(), AppError> {
let (settings_config, meta) = encode_row(row)?;
tx.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta,
is_current, in_failover_queue
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14
)",
params![
key.id,
key.app_type,
row.name,
settings_config,
row.website_url,
row.category,
created_at,
sort_index,
row.notes,
row.icon,
row.icon_color,
meta,
is_current,
in_failover_queue,
],
)
.map_err(|error| match &error {
rusqlite::Error::SqliteFailure(code, _)
if matches!(
code.extended_code,
rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY
| rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
) =>
{
AppError::Conflict(format!(
"provider '{}/{}' already exists",
key.app_type, key.id
))
}
_ => AppError::Database(error.to_string()),
})?;
Ok(())
}
pub(super) fn insert_endpoint(
tx: &Transaction<'_>,
key: &ProviderKey,
endpoint: &NewEndpoint,
) -> Result<(), AppError> {
tx.execute(
"INSERT INTO provider_endpoints
(provider_id, app_type, url, added_at, last_used)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
key.id,
key.app_type,
endpoint.url,
endpoint.added_at,
endpoint.last_used
],
)
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(())
}
/// Exact aggregate replacement is sealed inside the DAO parent module. The
/// catalog compensation coordinator introduced with the ordered mutation
/// pipeline is the only intended caller.
#[allow(dead_code)]
// The certification contract keeps immutable creation time separate from the
// mutable row DTO and calls this sealed helper directly with the full snapshot.
#[allow(clippy::too_many_arguments)]
pub(super) fn restore_provider_aggregate_on_tx(
tx: &Transaction<'_>,
key: &ProviderKey,
row: &ProviderRowUpdate,
created_at: Option<i64>,
sort_index: Option<usize>,
is_current: bool,
in_failover_queue: bool,
endpoints: &[NewEndpoint],
) -> Result<(), AppError> {
let updated = update_row(tx, key, row)?;
if updated == 0 {
insert_row(
tx,
key,
row,
created_at,
sort_index,
is_current,
in_failover_queue,
)?;
} else {
// Exact compensation is the only path allowed to restore immutable
// creation time after a prior aggregate mutation.
tx.execute(
"UPDATE providers SET created_at = ?1 WHERE id = ?2 AND app_type = ?3",
params![created_at, key.id, key.app_type],
)
.map_err(|error| AppError::Database(error.to_string()))?;
}
tx.execute(
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2",
params![key.id, key.app_type],
)
.map_err(|error| AppError::Database(error.to_string()))?;
for endpoint in endpoints {
insert_endpoint(tx, key, endpoint)?;
}
// State and order are maintained by their dedicated authorities. Exact
// compensation may restore their captured values without exposing them in
// ProviderRowUpdate.
tx.execute(
"UPDATE providers
SET sort_index = ?1, is_current = ?2, in_failover_queue = ?3
WHERE id = ?4 AND app_type = ?5",
params![
sort_index,
is_current,
in_failover_queue,
key.id,
key.app_type
],
)
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(())
}
pub(super) fn update_row(
tx: &Transaction<'_>,
key: &ProviderKey,
row: &ProviderRowUpdate,
) -> Result<usize, AppError> {
let (settings_config, meta) = encode_row(row)?;
tx.execute(
"UPDATE providers SET
name = ?1,
settings_config = ?2,
website_url = ?3,
category = ?4,
notes = ?5,
icon = ?6,
icon_color = ?7,
meta = ?8
WHERE id = ?9 AND app_type = ?10",
params![
row.name,
settings_config,
row.website_url,
row.category,
row.notes,
row.icon,
row.icon_color,
meta,
key.id,
key.app_type,
],
)
.map_err(|error| AppError::Database(error.to_string()))
}
impl Database {
pub fn create_provider(&self, input: NewProviderAggregate) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
insert_row(
&tx,
&input.key,
&input.row.content,
input.row.created_at,
input.sort_index,
false,
input.in_failover_queue,
)?;
for endpoint in &input.initial_endpoints {
insert_endpoint(&tx, &input.key, endpoint)?;
}
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
pub fn update_provider(
&self,
key: &ProviderKey,
row: &ProviderRowUpdate,
) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
if update_row(&tx, key, row)? != 1 {
return Err(AppError::NotFound(format!(
"provider '{}/{}'",
key.app_type, key.id
)));
}
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
pub(crate) fn update_provider_if_content_fingerprint(
&self,
key: &ProviderKey,
expected_fingerprint: &str,
row: &ProviderRowUpdate,
) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
let current = tx
.query_row(
&format!("{PROVIDER_SELECT} WHERE id = ?1 AND app_type = ?2"),
params![key.id, key.app_type],
StoredProviderRow::from_row,
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))?
.ok_or_else(|| AppError::NotFound(format!("provider '{}/{}'", key.app_type, key.id)))?
.decode(key.app_type())?;
if current.row_content_fingerprint() != expected_fingerprint {
return Err(AppError::Conflict(format!(
"provider '{}/{}' changed since it was read",
key.app_type, key.id
)));
}
if update_row(&tx, key, row)? != 1 {
return Err(AppError::NotFound(format!(
"provider '{}/{}'",
key.app_type, key.id
)));
}
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
pub(crate) fn rename_db_only_additive_provider(
&self,
input: RenameProvider,
) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
let source_state = tx
.query_row(
"SELECT sort_index, is_current, in_failover_queue, category, created_at, meta
FROM providers
WHERE id = ?1 AND app_type = ?2",
params![input.source.id, input.source.app_type],
|row| {
Ok((
row.get::<_, Option<usize>>(0)?,
row.get::<_, bool>(1)?,
row.get::<_, bool>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, Option<i64>>(4)?,
row.get::<_, String>(5)?,
))
},
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))?
.ok_or_else(|| {
AppError::NotFound(format!(
"provider '{}/{}'",
input.source.app_type, input.source.id
))
})?;
if matches!(source_state.3.as_deref(), Some("omo" | "omo-slim")) {
return Err(AppError::InvalidInput(
"OMO/OMO Slim providers cannot be renamed".to_string(),
));
}
let source_meta: ProviderMeta = if source_state.5.trim().is_empty() {
ProviderMeta::default()
} else {
serde_json::from_str(&source_state.5).map_err(|error| {
AppError::Database(format!(
"invalid meta for provider '{}/{}': {error}",
input.source.app_type, input.source.id
))
})?
};
if source_meta.live_config_managed == Some(true) {
return Err(AppError::Conflict(format!(
"provider '{}/{}' became live-managed before rename",
input.source.app_type, input.source.id
)));
}
let target = ProviderKey::new(&input.source.app_type, &input.target_id)?;
insert_row(
&tx,
&target,
&input.row,
source_state.4,
source_state.0,
source_state.1,
source_state.2,
)?;
tx.execute(
"INSERT INTO provider_endpoints
(provider_id, app_type, url, added_at, last_used)
SELECT ?1, app_type, url, added_at, last_used
FROM provider_endpoints
WHERE provider_id = ?2 AND app_type = ?3
ORDER BY id",
params![target.id, input.source.id, input.source.app_type],
)
.map_err(|error| AppError::Database(error.to_string()))?;
if tx
.execute(
"DELETE FROM providers WHERE id = ?1 AND app_type = ?2",
params![input.source.id, input.source.app_type],
)
.map_err(|error| AppError::Database(error.to_string()))?
!= 1
{
return Err(AppError::NotFound(format!(
"provider '{}/{}'",
input.source.app_type, input.source.id
)));
}
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
pub fn add_provider_endpoint(
&self,
key: &ProviderKey,
endpoint: NewEndpoint,
) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
insert_endpoint(&tx, key, &endpoint)?;
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
pub fn remove_provider_endpoint(&self, key: &ProviderKey, url: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
if conn
.execute(
"DELETE FROM provider_endpoints
WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3",
params![key.id, key.app_type, url],
)
.map_err(|error| AppError::Database(error.to_string()))?
!= 1
{
return Err(AppError::NotFound(format!(
"provider endpoint '{}/{}/{}'",
key.app_type, key.id, url
)));
}
Ok(())
}
pub fn touch_provider_endpoint(
&self,
key: &ProviderKey,
url: &str,
at: i64,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
if conn
.execute(
"UPDATE provider_endpoints
SET last_used = ?1
WHERE provider_id = ?2 AND app_type = ?3 AND url = ?4",
params![at, key.id, key.app_type, url],
)
.map_err(|error| AppError::Database(error.to_string()))?
!= 1
{
return Err(AppError::NotFound(format!(
"provider endpoint '{}/{}/{}'",
key.app_type, key.id, url
)));
}
Ok(())
}
pub(crate) fn update_provider_sort_index(
&self,
updates: &[(ProviderKey, usize)],
) -> Result<(), AppError> {
let mut seen = std::collections::HashSet::with_capacity(updates.len());
for (key, _) in updates {
if !seen.insert((key.app_type().to_string(), key.id().to_string())) {
return Err(AppError::InvalidInput(format!(
"duplicate provider sort update for '{}/{}'",
key.app_type(),
key.id()
)));
}
}
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
for (key, sort_index) in updates {
if tx
.execute(
"UPDATE providers SET sort_index = ?1 WHERE id = ?2 AND app_type = ?3",
params![sort_index, key.id, key.app_type],
)
.map_err(|error| AppError::Database(error.to_string()))?
!= 1
{
return Err(AppError::NotFound(format!(
"provider '{}/{}'",
key.app_type, key.id
)));
}
}
tx.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22 -3
View File
@@ -25,9 +25,8 @@ impl Database {
.map_err(|e| AppError::Database(e.to_string()))?;
if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
Ok(Some(
row.get(0).map_err(|e| AppError::Database(e.to_string()))?,
))
row.get::<_, Option<String>>(0)
.map_err(|e| AppError::Database(e.to_string()))
} else {
Ok(None)
}
@@ -325,3 +324,23 @@ impl Database {
self.set_setting("log_config", &json)
}
}
#[cfg(test)]
mod tests {
use crate::database::{lock_conn, Database};
use crate::error::AppError;
#[test]
fn null_setting_value_hydrates_as_absent() -> Result<(), crate::error::AppError> {
let database = Database::memory()?;
{
let conn = lock_conn!(database.conn);
conn.execute(
"INSERT INTO settings (key, value) VALUES ('nullable-setting', NULL)",
[],
)?;
}
assert_eq!(database.get_setting("nullable-setting")?, None);
Ok(())
}
}
@@ -0,0 +1,290 @@
//! Device-local evidence for Pi Skill deployments.
// Pi skill reconciliation consumes this ledger in a later contract-ordered commit.
#![allow(dead_code)]
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use rusqlite::{params, OptionalExtension};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SkillDeploymentMethod {
Symlink,
Copy,
}
impl SkillDeploymentMethod {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Symlink => "symlink",
Self::Copy => "copy",
}
}
}
impl FromStr for SkillDeploymentMethod {
type Err = AppError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"symlink" => Ok(Self::Symlink),
"copy" => Ok(Self::Copy),
_ => Err(AppError::Database(format!(
"unknown Pi Skill deployment method '{value}'"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SkillDeployment {
pub skill_id: String,
pub destination: String,
pub destination_key: String,
pub method: SkillDeploymentMethod,
pub source_identity: String,
pub deployed_digest: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
fn decode_deployment(row: &rusqlite::Row<'_>) -> rusqlite::Result<SkillDeployment> {
let method: String = row.get(3)?;
let method = method.parse().map_err(|error: AppError| {
rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Text, Box::new(error))
})?;
Ok(SkillDeployment {
skill_id: row.get(0)?,
destination: row.get(1)?,
destination_key: row.get(2)?,
method,
source_identity: row.get(4)?,
deployed_digest: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
}
impl Database {
pub(crate) fn set_pi_skill_desired(
&self,
skill_id: &str,
desired_enabled: bool,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
let changed = conn
.execute(
"UPDATE skills SET enabled_pi = ?1 WHERE id = ?2",
params![desired_enabled, skill_id],
)
.map_err(|error| AppError::Database(error.to_string()))?;
if changed != 1 {
return Err(AppError::Conflict(format!(
"Pi Skill '{skill_id}' disappeared before desired state was saved"
)));
}
Ok(())
}
pub(crate) fn get_pi_skill_deployment(
&self,
skill_id: &str,
destination_key: &str,
) -> Result<Option<SkillDeployment>, AppError> {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT skill_id, destination, destination_key, method,
source_identity, deployed_digest, created_at, updated_at
FROM skill_deployments
WHERE app_type = 'pi' AND skill_id = ?1 AND destination_key = ?2",
params![skill_id, destination_key],
decode_deployment,
)
.optional()
.map_err(|error| AppError::Database(error.to_string()))
}
pub(crate) fn get_pi_skill_deployments(
&self,
skill_id: &str,
) -> Result<Vec<SkillDeployment>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT skill_id, destination, destination_key, method,
source_identity, deployed_digest, created_at, updated_at
FROM skill_deployments
WHERE app_type = 'pi' AND skill_id = ?1
ORDER BY created_at, destination_key",
)
.map_err(|error| AppError::Database(error.to_string()))?;
let rows = stmt
.query_map([skill_id], decode_deployment)
.map_err(|error| AppError::Database(error.to_string()))?;
rows.map(|row| row.map_err(|error| AppError::Database(error.to_string())))
.collect()
}
pub(crate) fn save_pi_skill_deployment(
&self,
deployment: &SkillDeployment,
) -> Result<(), AppError> {
self.save_pi_skill_deployment_with_desired(deployment, None)
}
/// Commit ledger evidence and, for a user toggle, the desired Pi bit in
/// the same SQLite transaction. Filesystem publication happens before
/// this point; a failed transaction is therefore safe to compensate by
/// restoring the staged destination without exposing split DB authority.
pub(crate) fn save_pi_skill_deployment_with_desired(
&self,
deployment: &SkillDeployment,
desired_enabled: Option<bool>,
) -> Result<(), AppError> {
if deployment.skill_id.trim().is_empty()
|| deployment.destination.trim().is_empty()
|| deployment.destination_key.trim().is_empty()
|| deployment.source_identity.trim().is_empty()
{
return Err(AppError::Config(
"Pi Skill deployment identity fields must be non-empty".to_string(),
));
}
let mut conn = lock_conn!(self.conn);
let transaction = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
transaction
.execute(
"INSERT INTO skill_deployments (
app_type, skill_id, destination, destination_key, method,
source_identity, deployed_digest, created_at, updated_at
) VALUES ('pi', ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(app_type, skill_id, destination_key) DO UPDATE SET
destination = excluded.destination,
method = excluded.method,
source_identity = excluded.source_identity,
deployed_digest = excluded.deployed_digest,
updated_at = excluded.updated_at",
params![
deployment.skill_id,
deployment.destination,
deployment.destination_key,
deployment.method.as_str(),
deployment.source_identity,
deployment.deployed_digest,
deployment.created_at,
deployment.updated_at,
],
)
.map_err(|error| AppError::Database(error.to_string()))?;
if let Some(desired_enabled) = desired_enabled {
let changed = transaction
.execute(
"UPDATE skills SET enabled_pi = ?1 WHERE id = ?2",
params![desired_enabled, deployment.skill_id],
)
.map_err(|error| AppError::Database(error.to_string()))?;
if changed != 1 {
return Err(AppError::Conflict(format!(
"Pi Skill '{}' disappeared before deployment commit",
deployment.skill_id
)));
}
}
transaction
.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
pub(crate) fn delete_pi_skill_deployment(
&self,
skill_id: &str,
destination_key: &str,
) -> Result<bool, AppError> {
self.delete_pi_skill_deployment_with_desired(skill_id, destination_key, None)
}
pub(crate) fn delete_pi_skill_deployment_with_desired(
&self,
skill_id: &str,
destination_key: &str,
desired_enabled: Option<bool>,
) -> Result<bool, AppError> {
let mut conn = lock_conn!(self.conn);
let transaction = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
if let Some(desired_enabled) = desired_enabled {
let changed = transaction
.execute(
"UPDATE skills SET enabled_pi = ?1 WHERE id = ?2",
params![desired_enabled, skill_id],
)
.map_err(|error| AppError::Database(error.to_string()))?;
if changed != 1 {
return Err(AppError::Conflict(format!(
"Pi Skill '{skill_id}' disappeared before deployment cleanup"
)));
}
}
let removed = transaction
.execute(
"DELETE FROM skill_deployments
WHERE app_type = 'pi' AND skill_id = ?1 AND destination_key = ?2",
params![skill_id, destination_key],
)
.map_err(|error| AppError::Database(error.to_string()))?
== 1;
transaction
.commit()
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(removed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn deployment(skill_id: &str, destination_key: &str) -> SkillDeployment {
SkillDeployment {
skill_id: skill_id.into(),
destination: format!("/tmp/{destination_key}"),
destination_key: destination_key.into(),
method: SkillDeploymentMethod::Copy,
source_identity: format!("source:{skill_id}"),
deployed_digest: Some("sha256:initial".into()),
created_at: 10,
updated_at: 10,
}
}
#[test]
fn skill_ledger_preserves_created_at_and_rejects_destination_collision() -> Result<(), AppError>
{
let db = Database::memory()?;
db.save_pi_skill_deployment(&deployment("one", "destination"))?;
let mut updated = deployment("one", "destination");
updated.updated_at = 20;
updated.deployed_digest = Some("sha256:updated".into());
db.save_pi_skill_deployment(&updated)?;
let saved = db
.get_pi_skill_deployment("one", "destination")?
.expect("deployment");
assert_eq!(saved.created_at, 10);
assert_eq!(saved.updated_at, 20);
assert_eq!(saved.deployed_digest.as_deref(), Some("sha256:updated"));
assert!(db
.save_pi_skill_deployment(&deployment("two", "destination"))
.is_err());
assert_eq!(db.get_pi_skill_deployments("one")?.len(), 1);
assert!(db.delete_pi_skill_deployment("one", "destination")?);
Ok(())
}
}
+84 -13
View File
@@ -23,7 +23,8 @@ impl Database {
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
enabled_opencode, enabled_hermes, enabled_pi,
installed_at, content_hash, updated_at
FROM skills ORDER BY name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -46,10 +47,11 @@ impl Database {
grokbuild: row.get(11)?,
opencode: row.get(12)?,
hermes: row.get(13)?,
pi: row.get(14)?,
},
installed_at: row.get(14)?,
content_hash: row.get(15)?,
updated_at: row.get::<_, i64>(16).unwrap_or(0),
installed_at: row.get(15)?,
content_hash: row.get(16)?,
updated_at: row.get::<_, i64>(17).unwrap_or(0),
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -69,7 +71,8 @@ impl Database {
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
enabled_opencode, enabled_hermes, enabled_pi,
installed_at, content_hash, updated_at
FROM skills WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -91,10 +94,11 @@ impl Database {
grokbuild: row.get(11)?,
opencode: row.get(12)?,
hermes: row.get(13)?,
pi: row.get(14)?,
},
installed_at: row.get(14)?,
content_hash: row.get(15)?,
updated_at: row.get::<_, i64>(16).unwrap_or(0),
installed_at: row.get(15)?,
content_hash: row.get(16)?,
updated_at: row.get::<_, i64>(17).unwrap_or(0),
})
});
@@ -109,11 +113,28 @@ impl Database {
pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO skills
"INSERT INTO skills
(id, name, description, directory, repo_owner, repo_name, repo_branch,
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, ?17)",
enabled_pi, installed_at, content_hash, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
directory = excluded.directory,
repo_owner = excluded.repo_owner,
repo_name = excluded.repo_name,
repo_branch = excluded.repo_branch,
readme_url = excluded.readme_url,
enabled_claude = excluded.enabled_claude,
enabled_codex = excluded.enabled_codex,
enabled_gemini = excluded.enabled_gemini,
enabled_grokbuild = excluded.enabled_grokbuild,
enabled_opencode = excluded.enabled_opencode,
enabled_hermes = excluded.enabled_hermes,
installed_at = excluded.installed_at,
content_hash = excluded.content_hash,
updated_at = excluded.updated_at",
params![
skill.id,
skill.name,
@@ -129,6 +150,7 @@ impl Database {
skill.apps.grokbuild,
skill.apps.opencode,
skill.apps.hermes,
skill.apps.pi,
skill.installed_at,
skill.content_hash,
skill.updated_at,
@@ -160,8 +182,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_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],
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6, enabled_pi = ?7 WHERE id = ?8",
params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, apps.pi, id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
@@ -262,3 +284,52 @@ impl Database {
Ok(count)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn installed_skill() -> InstalledSkill {
InstalledSkill {
id: "owner/repo:skill".into(),
name: "Skill".into(),
description: Some("before".into()),
directory: "skill".into(),
repo_owner: Some("owner".into()),
repo_name: Some("repo".into()),
repo_branch: Some("main".into()),
readme_url: None,
apps: SkillApps::default(),
installed_at: 10,
content_hash: Some("sha256:before".into()),
updated_at: 11,
}
}
#[test]
fn legacy_skill_save_preserves_pi_desired_state() -> Result<(), AppError> {
let db = Database::memory()?;
let mut skill = installed_skill();
db.save_skill(&skill)?;
{
let conn = lock_conn!(db.conn);
conn.execute(
"UPDATE skills SET enabled_pi = 1 WHERE id = ?1",
[&skill.id],
)?;
}
skill.name = "Updated".into();
skill.content_hash = Some("sha256:after".into());
db.save_skill(&skill)?;
let conn = lock_conn!(db.conn);
let saved: (String, String, bool) = conn.query_row(
"SELECT name, content_hash, enabled_pi FROM skills WHERE id = ?1",
[&skill.id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(saved, ("Updated".into(), "sha256:after".into(), true));
Ok(())
}
}
+3 -2
View File
@@ -2,6 +2,7 @@
//!
//! 将旧版 config.json (MultiAppConfig) 数据迁移到 SQLite 数据库。
use super::schema::MigrationRunContext;
use super::{lock_conn, to_json_string, Database};
use crate::app_config::MultiAppConfig;
use crate::error::AppError;
@@ -28,8 +29,8 @@ impl Database {
pub fn migrate_from_json_dry_run(config: &MultiAppConfig) -> Result<(), AppError> {
let mut conn =
Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
Self::create_tables_on_conn(&conn)?;
Self::apply_schema_migrations_on_conn(&conn)?;
Self::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)?;
Self::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)?;
let tx = conn
.transaction()
+933
View File
@@ -0,0 +1,933 @@
//! Declarative source schemas for N/N-1 untrusted database migration.
//!
//! The restore pipeline validates one of these versioned shapes before any
//! migration DDL runs. This module is intentionally independent from the
//! current-schema factory: source recognition must never become true because
//! current tables were created into the untrusted database.
//!
//! Only the current and immediately previous schema versions are accepted.
//! Importing v1..v15 backups is a separate future “historical backup import”
//! project. Local in-place upgrades still use the complete migration chain in
//! `schema.rs`; this module only gates the untrusted SQL/binary restore paths.
//!
//! SQLite storage classes belong to values rather than columns. Accordingly,
//! the declaration spelling (`BOOLEAN` versus `INTEGER`, for example) is not
//! an authority boundary; every populated value is checked with `typeof`, while
//! the exact table and column-name sets are checked independently.
use super::{Database, SCHEMA_VERSION};
use crate::error::AppError;
use rusqlite::{Connection, OptionalExtension};
use std::collections::{BTreeMap, BTreeSet};
const EARLIEST_UNTRUSTED_RESTORE_VERSION: i32 = 16;
const LATEST_DECLARED_SOURCE_VERSION: i32 = 17;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MigrationStorageClass {
Integer,
Real,
Text,
}
impl MigrationStorageClass {
const fn sqlite_name(self) -> &'static str {
match self {
Self::Integer => "integer",
Self::Real => "real",
Self::Text => "text",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct MigrationSourceColumnSpec {
pub(crate) name: &'static str,
pub(crate) declared_type: &'static str,
pub(crate) storage: MigrationStorageClass,
pub(crate) nullable: bool,
}
impl MigrationSourceColumnSpec {
const fn new(
name: &'static str,
declared_type: &'static str,
storage: MigrationStorageClass,
nullable: bool,
) -> Self {
Self {
name,
declared_type,
storage,
nullable,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct MigrationSourceTableSpec {
pub(crate) name: &'static str,
pub(crate) columns: &'static [MigrationSourceColumnSpec],
}
#[derive(Debug, Clone)]
pub(crate) struct MigrationSourceSpec {
pub(crate) version: i32,
pub(crate) tables: Vec<&'static MigrationSourceTableSpec>,
}
macro_rules! text {
($name:literal) => {
MigrationSourceColumnSpec::new($name, "TEXT", MigrationStorageClass::Text, false)
};
}
macro_rules! nullable_text {
($name:literal) => {
MigrationSourceColumnSpec::new($name, "TEXT", MigrationStorageClass::Text, true)
};
}
macro_rules! integer {
($name:literal) => {
MigrationSourceColumnSpec::new($name, "INTEGER", MigrationStorageClass::Integer, false)
};
}
macro_rules! nullable_integer {
($name:literal) => {
MigrationSourceColumnSpec::new($name, "INTEGER", MigrationStorageClass::Integer, true)
};
}
macro_rules! boolean {
($name:literal) => {
MigrationSourceColumnSpec::new($name, "BOOLEAN", MigrationStorageClass::Integer, false)
};
}
macro_rules! real {
($name:literal) => {
MigrationSourceColumnSpec::new($name, "REAL", MigrationStorageClass::Real, false)
};
}
macro_rules! table {
($name:ident, $sql_name:literal, [$($column:expr),* $(,)?]) => {
const $name: MigrationSourceTableSpec = MigrationSourceTableSpec {
name: $sql_name,
columns: &[$($column),*],
};
};
}
table!(
PROVIDERS_V2,
"providers",
[
text!("id"),
text!("app_type"),
text!("name"),
text!("settings_config"),
nullable_text!("website_url"),
nullable_text!("category"),
nullable_integer!("created_at"),
nullable_integer!("sort_index"),
nullable_text!("notes"),
nullable_text!("icon"),
nullable_text!("icon_color"),
text!("meta"),
boolean!("is_current"),
boolean!("in_failover_queue"),
text!("cost_multiplier"),
nullable_text!("limit_daily_usd"),
nullable_text!("limit_monthly_usd"),
nullable_text!("provider_type"),
]
);
table!(
PROVIDER_ENDPOINTS_V16,
"provider_endpoints",
[
integer!("id"),
text!("provider_id"),
text!("app_type"),
text!("url"),
nullable_integer!("added_at"),
]
);
table!(
PROVIDER_ENDPOINTS_V17,
"provider_endpoints",
[
integer!("id"),
text!("provider_id"),
text!("app_type"),
text!("url"),
nullable_integer!("added_at"),
nullable_integer!("last_used"),
]
);
table!(
MCP_V15,
"mcp_servers",
[
text!("id"),
text!("name"),
text!("server_config"),
nullable_text!("description"),
nullable_text!("homepage"),
nullable_text!("docs"),
text!("tags"),
boolean!("enabled_claude"),
boolean!("enabled_codex"),
boolean!("enabled_gemini"),
boolean!("enabled_grokbuild"),
boolean!("enabled_opencode"),
boolean!("enabled_hermes"),
]
);
table!(
PROMPTS,
"prompts",
[
text!("id"),
text!("app_type"),
text!("name"),
text!("content"),
nullable_text!("description"),
boolean!("enabled"),
nullable_integer!("created_at"),
nullable_integer!("updated_at"),
]
);
table!(
SKILLS_V15,
"skills",
[
text!("id"),
text!("name"),
nullable_text!("description"),
text!("directory"),
nullable_text!("repo_owner"),
nullable_text!("repo_name"),
nullable_text!("repo_branch"),
nullable_text!("readme_url"),
boolean!("enabled_claude"),
boolean!("enabled_codex"),
boolean!("enabled_gemini"),
boolean!("enabled_grokbuild"),
boolean!("enabled_opencode"),
boolean!("enabled_hermes"),
integer!("installed_at"),
nullable_text!("content_hash"),
integer!("updated_at"),
]
);
table!(
SKILLS_V17,
"skills",
[
text!("id"),
text!("name"),
nullable_text!("description"),
text!("directory"),
nullable_text!("repo_owner"),
nullable_text!("repo_name"),
nullable_text!("repo_branch"),
nullable_text!("readme_url"),
boolean!("enabled_claude"),
boolean!("enabled_codex"),
boolean!("enabled_gemini"),
boolean!("enabled_grokbuild"),
boolean!("enabled_opencode"),
boolean!("enabled_hermes"),
boolean!("enabled_pi"),
integer!("installed_at"),
nullable_text!("content_hash"),
integer!("updated_at"),
]
);
table!(
SKILL_REPOS,
"skill_repos",
[
text!("owner"),
text!("name"),
text!("branch"),
boolean!("enabled"),
]
);
table!(
SETTINGS,
"settings",
[text!("key"), nullable_text!("value")]
);
table!(
PROXY_CONFIG_V14,
"proxy_config",
[
text!("app_type"),
boolean!("proxy_enabled"),
text!("listen_address"),
integer!("listen_port"),
boolean!("enable_logging"),
boolean!("enabled"),
boolean!("auto_failover_enabled"),
integer!("max_retries"),
integer!("streaming_first_byte_timeout"),
integer!("streaming_idle_timeout"),
integer!("non_streaming_timeout"),
integer!("circuit_failure_threshold"),
integer!("circuit_success_threshold"),
integer!("circuit_timeout_seconds"),
real!("circuit_error_rate_threshold"),
integer!("circuit_min_requests"),
text!("created_at"),
text!("updated_at"),
text!("default_cost_multiplier"),
text!("pricing_model_source"),
boolean!("live_takeover_active"),
]
);
table!(
PROVIDER_HEALTH,
"provider_health",
[
text!("provider_id"),
text!("app_type"),
boolean!("is_healthy"),
integer!("consecutive_failures"),
nullable_text!("last_success_at"),
nullable_text!("last_failure_at"),
nullable_text!("last_error"),
text!("updated_at"),
]
);
table!(
PROXY_LOG_V13,
"proxy_request_logs",
[
text!("request_id"),
text!("provider_id"),
text!("app_type"),
text!("model"),
nullable_text!("request_model"),
nullable_text!("pricing_model"),
integer!("input_tokens"),
integer!("output_tokens"),
integer!("cache_read_tokens"),
integer!("cache_creation_tokens"),
integer!("input_token_semantics"),
text!("input_cost_usd"),
text!("output_cost_usd"),
text!("cache_read_cost_usd"),
text!("cache_creation_cost_usd"),
text!("total_cost_usd"),
integer!("latency_ms"),
nullable_integer!("first_token_ms"),
nullable_integer!("duration_ms"),
integer!("status_code"),
nullable_text!("error_message"),
nullable_text!("session_id"),
nullable_text!("provider_type"),
boolean!("is_streaming"),
text!("cost_multiplier"),
integer!("created_at"),
text!("data_source"),
]
);
table!(
MODEL_PRICING,
"model_pricing",
[
text!("model_id"),
text!("display_name"),
text!("input_cost_per_million"),
text!("output_cost_per_million"),
text!("cache_read_cost_per_million"),
text!("cache_creation_cost_per_million"),
]
);
table!(
STREAM_CHECK_LOGS,
"stream_check_logs",
[
integer!("id"),
text!("provider_id"),
text!("provider_name"),
text!("app_type"),
text!("status"),
boolean!("success"),
text!("message"),
nullable_integer!("response_time_ms"),
nullable_integer!("http_status"),
nullable_text!("model_used"),
nullable_integer!("retry_count"),
integer!("tested_at"),
]
);
table!(
PROXY_LIVE_BACKUP,
"proxy_live_backup",
[
text!("app_type"),
text!("original_config"),
text!("backed_up_at"),
]
);
table!(
USAGE_ROLLUPS_V13,
"usage_daily_rollups",
[
text!("date"),
text!("app_type"),
text!("provider_id"),
text!("model"),
text!("request_model"),
text!("pricing_model"),
integer!("request_count"),
integer!("success_count"),
integer!("input_tokens"),
integer!("output_tokens"),
integer!("cache_read_tokens"),
integer!("cache_creation_tokens"),
integer!("input_token_semantics"),
text!("total_cost_usd"),
integer!("avg_latency_ms"),
]
);
table!(
SESSION_LOG_SYNC,
"session_log_sync",
[
text!("file_path"),
integer!("last_modified"),
integer!("last_line_offset"),
integer!("last_synced_at"),
]
);
table!(
PROFILES,
"profiles",
[
text!("id"),
text!("name"),
text!("payload"),
nullable_integer!("sort_order"),
nullable_integer!("created_at"),
nullable_integer!("updated_at"),
]
);
table!(
PI_PROVIDER_PROJECTIONS,
"pi_provider_projections",
[
text!("provider_id"),
text!("provider_key"),
integer!("created_at"),
integer!("updated_at"),
]
);
table!(
SKILL_DEPLOYMENTS,
"skill_deployments",
[
text!("app_type"),
text!("skill_id"),
text!("destination"),
text!("destination_key"),
text!("method"),
text!("source_identity"),
nullable_text!("deployed_digest"),
integer!("created_at"),
integer!("updated_at"),
]
);
fn replace_table(
tables: &mut Vec<&'static MigrationSourceTableSpec>,
replacement: &'static MigrationSourceTableSpec,
) {
if let Some(existing) = tables
.iter_mut()
.find(|table| table.name == replacement.name)
{
*existing = replacement;
} else {
tables.push(replacement);
}
}
fn ensure_source_specs_match_current_schema() -> Result<(), AppError> {
if SCHEMA_VERSION != LATEST_DECLARED_SOURCE_VERSION {
return Err(AppError::Config(format!(
"schema version {SCHEMA_VERSION} has no reviewed MigrationSourceSpec; \
latest declared source version is {LATEST_DECLARED_SOURCE_VERSION}"
)));
}
Ok(())
}
pub(crate) fn require_supported_untrusted_restore_version(version: i32) -> Result<(), AppError> {
ensure_source_specs_match_current_schema()?;
if version < EARLIEST_UNTRUSTED_RESTORE_VERSION {
return Err(AppError::InvalidInput(format!(
"备份版本过旧: source user_version={version}; untrusted restore accepts only \
user_version {EARLIEST_UNTRUSTED_RESTORE_VERSION} or \
{LATEST_DECLARED_SOURCE_VERSION}"
)));
}
if version > LATEST_DECLARED_SOURCE_VERSION {
return Err(AppError::InvalidInput(format!(
"restore source user_version={version} is newer than supported \
user_version {LATEST_DECLARED_SOURCE_VERSION}"
)));
}
Ok(())
}
pub(crate) fn migration_source_spec(version: i32) -> Result<MigrationSourceSpec, AppError> {
require_supported_untrusted_restore_version(version)?;
let mut tables = vec![
&PROVIDERS_V2,
&PROVIDER_ENDPOINTS_V16,
&MCP_V15,
&PROMPTS,
&SKILLS_V15,
&SKILL_REPOS,
&SETTINGS,
&PROXY_CONFIG_V14,
&PROVIDER_HEALTH,
&PROXY_LOG_V13,
&MODEL_PRICING,
&STREAM_CHECK_LOGS,
&PROXY_LIVE_BACKUP,
&USAGE_ROLLUPS_V13,
&SESSION_LOG_SYNC,
&PROFILES,
];
if version == LATEST_DECLARED_SOURCE_VERSION {
replace_table(&mut tables, &PROVIDER_ENDPOINTS_V17);
replace_table(&mut tables, &SKILLS_V17);
tables.extend([&PI_PROVIDER_PROJECTIONS, &SKILL_DEPLOYMENTS]);
}
tables.sort_by_key(|table| table.name);
Ok(MigrationSourceSpec { version, tables })
}
/// Materialize an exact declared source shape for migration certification.
///
/// This is deliberately a source-spec fixture factory, not an alias for the
/// canonical current-schema factory. Keeping it here lets all migration tests
/// exercise the same versioned declarations that guard production restores.
#[cfg(test)]
pub(crate) fn exact_migration_source_for_test(version: i32) -> Result<Connection, AppError> {
let spec = migration_source_spec(version)?;
let connection = Connection::open_in_memory()?;
for table in &spec.tables {
let columns = table
.columns
.iter()
.map(|column| {
format!(
"{} {}",
quoted_identifier(column.name),
column.declared_type
)
})
.collect::<Vec<_>>()
.join(", ");
connection.execute(
&format!("CREATE TABLE {} ({columns})", quoted_identifier(table.name)),
[],
)?;
}
Database::set_user_version(&connection, version)?;
Ok(connection)
}
/// Pi-managed provider input executed by the pinned upstream composer oracle,
/// plus one unknown field that makes each migration-version sentinel distinct.
#[cfg(test)]
pub(crate) fn pinned_pi_provider_settings_for_test(version: i32) -> String {
format!(
concat!(
"{{\"api\":\"google-generative-ai\",",
"\"baseUrl\":\"https://fractional.example/v1\",",
"\"apiKey\":\"literal-secret\",",
"\"models\":[{{\"id\":\"fractional\",",
"\"contextWindow\":128000.5,\"maxTokens\":16384.25,",
"\"cost\":{{\"input\":0.125,\"output\":0.375,",
"\"cacheRead\":0.0625,\"cacheWrite\":0.1875}}}}],",
"\"migrationVersion\":{version}}}"
),
version = version
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct MigrationStructuralColumnAddition {
table: &'static str,
column: &'static str,
storage: MigrationStorageClass,
nullable: bool,
}
const V16_TO_V17_STRUCTURAL_COLUMNS: &[MigrationStructuralColumnAddition] = &[
MigrationStructuralColumnAddition {
table: "provider_endpoints",
column: "last_used",
storage: MigrationStorageClass::Integer,
nullable: true,
},
MigrationStructuralColumnAddition {
table: "skills",
column: "enabled_pi",
storage: MigrationStorageClass::Integer,
nullable: false,
},
];
const V16_TO_V17_EMPTY_STRUCTURAL_TABLES: &[&str] =
&["pi_provider_projections", "skill_deployments"];
/// Mechanically prove that the only accepted migration consumes every v16
/// source column and introduces no undeclared v17 shape.
///
/// Common columns are identity mappings: SQLite storage class must remain
/// unchanged, and a nullable source may never map into a non-null target.
/// Target-only columns and tables are version-owned structural additions listed
/// above; the behavior suite separately proves their NULL/false/empty values.
pub(crate) fn validate_migration_mapping_completeness(version: i32) -> Result<(), AppError> {
require_supported_untrusted_restore_version(version)?;
if version == SCHEMA_VERSION {
return Ok(());
}
if version != EARLIEST_UNTRUSTED_RESTORE_VERSION {
return Err(AppError::Config(format!(
"untrusted migration completeness is defined only for v{}->v{}; got v{version}",
EARLIEST_UNTRUSTED_RESTORE_VERSION, LATEST_DECLARED_SOURCE_VERSION
)));
}
let source = migration_source_spec(version)?;
let target = migration_source_spec(version + 1)?;
let source_tables: BTreeMap<_, _> = source
.tables
.iter()
.map(|table| (table.name, *table))
.collect();
let target_tables: BTreeMap<_, _> = target
.tables
.iter()
.map(|table| (table.name, *table))
.collect();
for source_table in &source.tables {
let target_table = target_tables.get(source_table.name).ok_or_else(|| {
AppError::Config(format!(
"v{version}->v{} drops source table {}",
version + 1,
source_table.name
))
})?;
for source_column in source_table.columns {
let target_column = target_table
.columns
.iter()
.find(|column| column.name == source_column.name)
.ok_or_else(|| {
AppError::Config(format!(
"v{version}->v{} does not consume source column {}.{}",
version + 1,
source_table.name,
source_column.name
))
})?;
if target_column.storage != source_column.storage {
return Err(AppError::Config(format!(
"v{version}->v{} changes storage for identity column {}.{}",
version + 1,
source_table.name,
source_column.name
)));
}
if source_column.nullable && !target_column.nullable {
return Err(AppError::Config(format!(
"v{version}->v{} narrows NULL semantics for identity column {}.{}",
version + 1,
source_table.name,
source_column.name
)));
}
}
}
for target_table in &target.tables {
let Some(source_table) = source_tables.get(target_table.name) else {
if !V16_TO_V17_EMPTY_STRUCTURAL_TABLES.contains(&target_table.name) {
return Err(AppError::Config(format!(
"v{version}->v{} introduces undeclared structural table {}",
version + 1,
target_table.name
)));
}
continue;
};
for target_column in target_table.columns {
if source_table
.columns
.iter()
.any(|column| column.name == target_column.name)
{
continue;
}
let addition = V16_TO_V17_STRUCTURAL_COLUMNS
.iter()
.find(|addition| {
addition.table == target_table.name && addition.column == target_column.name
})
.ok_or_else(|| {
AppError::Config(format!(
"v{version}->v{} introduces undeclared structural column {}.{}",
version + 1,
target_table.name,
target_column.name
))
})?;
if addition.storage != target_column.storage
|| addition.nullable != target_column.nullable
{
return Err(AppError::Config(format!(
"v{version}->v{} structural column {}.{} does not match its declaration",
version + 1,
target_table.name,
target_column.name
)));
}
}
}
for addition in V16_TO_V17_STRUCTURAL_COLUMNS {
let source_has_column = source_tables.get(addition.table).is_some_and(|table| {
table
.columns
.iter()
.any(|column| column.name == addition.column)
});
let target_has_column = target_tables.get(addition.table).is_some_and(|table| {
table.columns.iter().any(|column| {
column.name == addition.column
&& column.storage == addition.storage
&& column.nullable == addition.nullable
})
});
if source_has_column || !target_has_column {
return Err(AppError::Config(format!(
"v{version}->v{} structural column declaration is stale: {}.{}",
version + 1,
addition.table,
addition.column
)));
}
}
for table in V16_TO_V17_EMPTY_STRUCTURAL_TABLES {
if source_tables.contains_key(table) || !target_tables.contains_key(table) {
return Err(AppError::Config(format!(
"v{version}->v{} structural table declaration is stale: {table}",
version + 1
)));
}
}
Ok(())
}
fn quoted_identifier(identifier: &str) -> String {
format!("\"{}\"", identifier.replace('"', "\"\""))
}
impl Database {
pub(crate) fn validate_untrusted_migration_source(conn: &Connection) -> Result<i32, AppError> {
let version = Self::get_user_version(conn)?;
require_supported_untrusted_restore_version(version)?;
let spec = migration_source_spec(version)?;
if version == EARLIEST_UNTRUSTED_RESTORE_VERSION {
validate_migration_mapping_completeness(version)?;
}
Self::validate_migration_source_spec(conn, &spec)?;
Ok(version)
}
pub(crate) fn validate_migration_source_version(
conn: &Connection,
version: i32,
) -> Result<(), AppError> {
let spec = migration_source_spec(version)?;
Self::validate_migration_source_spec(conn, &spec)
}
fn validate_migration_source_spec(
conn: &Connection,
spec: &MigrationSourceSpec,
) -> Result<(), AppError> {
// Never express the internal-table boundary with SQL LIKE: `_` is a
// wildcard there, so `sqliteX` would be mistaken for `sqlite_*`.
// Enumerating the internal tables that this SQLite build may own keeps
// every other table—including case variants and forged reserved names—
// inside the exact source inventory.
let observed_tables = conn
.prepare(
"SELECT name FROM main.sqlite_schema
WHERE type = 'table'
ORDER BY name",
)
.and_then(|mut statement| {
statement
.query_map([], |row| row.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()
})
.map_err(|error| {
AppError::InvalidInput(format!(
"inspect untrusted v{} table set: {error}",
spec.version
))
})?
.into_iter()
.filter(|name| !super::is_sqlite_internal_table_name(name))
.collect::<Vec<_>>();
let expected_tables: Vec<_> = spec
.tables
.iter()
.map(|table| table.name.to_string())
.collect();
if observed_tables != expected_tables {
let observed: BTreeSet<_> = observed_tables.iter().cloned().collect();
let expected: BTreeSet<_> = expected_tables.iter().cloned().collect();
let missing: Vec<_> = expected.difference(&observed).cloned().collect();
let extra: Vec<_> = observed.difference(&expected).cloned().collect();
return Err(AppError::InvalidInput(format!(
"untrusted v{} source table set mismatch; missing={missing:?}, extra={extra:?}",
spec.version
)));
}
for table in &spec.tables {
// `table_info` omits generated and virtual-table hidden columns.
// `table_xinfo` is required for an exact source column set; a
// generated replacement for an expected column must not acquire
// authority merely because its name and observed values match.
let pragma = format!("PRAGMA main.table_xinfo({})", quoted_identifier(table.name));
let observed_columns = conn
.prepare(&pragma)
.and_then(|mut statement| {
statement
.query_map([], |row| {
Ok((row.get::<_, String>(1)?, row.get::<_, i64>(6)?))
})?
.collect::<Result<Vec<_>, _>>()
})
.map_err(|error| {
AppError::InvalidInput(format!(
"inspect untrusted v{} table {} columns: {error}",
spec.version, table.name
))
})?;
let observed_by_name: BTreeSet<_> = observed_columns
.iter()
.map(|(name, _)| name.clone())
.collect();
let hidden_or_generated = observed_columns
.iter()
.filter(|(_, hidden)| *hidden != 0)
.map(|(name, hidden)| format!("{name}:{hidden}"))
.collect::<Vec<_>>();
let expected_by_name: BTreeSet<_> = table
.columns
.iter()
.map(|column| column.name.to_string())
.collect();
if observed_by_name.len() != observed_columns.len()
|| observed_by_name != expected_by_name
|| !hidden_or_generated.is_empty()
{
let missing: Vec<_> = expected_by_name
.difference(&observed_by_name)
.cloned()
.collect();
let extra: Vec<_> = observed_by_name
.difference(&expected_by_name)
.cloned()
.collect();
return Err(AppError::InvalidInput(format!(
"untrusted v{} source column set mismatch for {}; missing={missing:?}, \
extra={extra:?}, generated_or_hidden={hidden_or_generated:?}",
spec.version, table.name,
)));
}
for column in table.columns {
let table_name = quoted_identifier(table.name);
let column_name = quoted_identifier(column.name);
let mismatch_predicate = if column.nullable {
format!(
"typeof({column_name}) NOT IN ('null', '{}')",
column.storage.sqlite_name()
)
} else {
format!(
"typeof({column_name}) <> '{}'",
column.storage.sqlite_name()
)
};
let mismatch_sql = format!(
"SELECT typeof({column_name}) FROM {table_name}
WHERE {mismatch_predicate} LIMIT 1"
);
let mismatch = conn
.query_row(&mismatch_sql, [], |row| row.get::<_, String>(0))
.optional()
.map_err(|error| {
AppError::InvalidInput(format!(
"validate untrusted v{} storage for {}.{}: {error}",
spec.version, table.name, column.name
))
})?;
if let Some(observed_storage) = mismatch {
return Err(AppError::InvalidInput(format!(
"untrusted v{} source storage mismatch for {}.{}: expected {}{}, got {}",
spec.version,
table.name,
column.name,
column.storage.sqlite_name(),
if column.nullable { " or null" } else { "" },
observed_storage
)));
}
}
}
Ok(())
}
}
+157 -13
View File
@@ -24,14 +24,23 @@
//! ```
pub(crate) mod backup;
#[cfg(test)]
mod backup_restore_certification;
#[cfg(test)]
mod backup_restore_certification_ext;
mod dao;
mod migration;
mod migration_source;
mod schema;
#[cfg(test)]
mod tests;
// DAO 类型导出供外部使用
pub(crate) use dao::pi_projections::PiProviderProjection;
pub use dao::provider_write::{
NewEndpoint, NewProviderAggregate, ProviderKey, ProviderRowUpdate, RenameProvider,
};
pub(crate) use dao::providers_seed::{
is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID,
GROKBUILD_OFFICIAL_PROVIDER_ID,
@@ -40,20 +49,23 @@ pub(crate) use dao::proxy::{
validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST,
PRICING_SOURCE_RESPONSE,
};
pub(crate) use dao::skill_deployments::{SkillDeployment, SkillDeploymentMethod};
pub use dao::FailoverQueueItem;
pub use dao::Profile;
use crate::config::get_app_config_dir;
use crate::error::AppError;
use rusqlite::{hooks::Action, Connection};
use rusqlite::{hooks::Action, Connection, OpenFlags};
use serde::Serialize;
use std::sync::Mutex;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
// DAO 方法通过 impl Database 提供,无需额外导出
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 16;
pub(crate) const SCHEMA_VERSION: i32 = 17;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
@@ -79,6 +91,85 @@ pub(crate) use lock_conn;
/// rusqlite::Connection 本身不是 Sync 的,因此需要这层包装。
pub struct Database {
pub(crate) conn: Mutex<Connection>,
// Keep this field after `conn`: field drop order closes the writable
// connection before releasing its process-local ownership lease.
_live_write_lease: Option<LiveDatabaseWriteLease>,
}
static LIVE_DATABASE_WRITERS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
const SQLITE_INTERNAL_TABLE_NAMES: &[&str] = &["sqlite_sequence", "sqlite_stat1", "sqlite_stat4"];
pub(crate) fn is_sqlite_internal_table_name(name: &str) -> bool {
SQLITE_INTERNAL_TABLE_NAMES.contains(&name)
}
struct LiveDatabaseWriteLease {
identity: PathBuf,
}
impl LiveDatabaseWriteLease {
fn acquire(path: &Path) -> Result<Self, AppError> {
let identity = live_database_identity(path)?;
let mut writers = LIVE_DATABASE_WRITERS
.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !writers.insert(identity.clone()) {
return Err(AppError::Conflict(format!(
"live database already has a writable connection: {}",
identity.display()
)));
}
Ok(Self { identity })
}
}
impl Drop for LiveDatabaseWriteLease {
fn drop(&mut self) {
LIVE_DATABASE_WRITERS
.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&self.identity);
}
}
fn live_database_identity(path: &Path) -> Result<PathBuf, AppError> {
match std::fs::canonicalize(path) {
Ok(identity) => Ok(identity),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
match std::fs::symlink_metadata(path) {
Ok(_) => {
// Do not treat an existing broken symlink as a new
// database path: SQLite could follow it to an alias
// outside the lease identity selected below.
return Err(AppError::io(path, error));
}
Err(metadata_error) if metadata_error.kind() == std::io::ErrorKind::NotFound => {}
Err(metadata_error) => return Err(AppError::io(path, metadata_error)),
}
// A new database has no file identity yet. Canonicalizing the
// already-created parent still collapses relative and symlinked
// directory aliases before the single writable open occurs.
let parent = path.parent().ok_or_else(|| {
AppError::Config(format!(
"live database path has no parent: {}",
path.display()
))
})?;
let filename = path.file_name().ok_or_else(|| {
AppError::Config(format!(
"live database path has no filename: {}",
path.display()
))
})?;
let parent =
std::fs::canonicalize(parent).map_err(|error| AppError::io(parent, error))?;
Ok(parent.join(filename))
}
Err(error) => Err(AppError::io(path, error)),
}
}
fn register_db_change_hook(conn: &Connection) {
@@ -106,6 +197,7 @@ impl Database {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let live_write_lease = LiveDatabaseWriteLease::acquire(&db_path)?;
let conn = Connection::open(&db_path).map_err(|e| AppError::Database(e.to_string()))?;
// 启用外键约束
@@ -121,10 +213,13 @@ impl Database {
let db = Self {
conn: Mutex::new(conn),
_live_write_lease: Some(live_write_lease),
};
db.create_tables()?;
// Pre-migration backup: only when upgrading from an existing database
// Pre-migration backup: only when upgrading from an existing database.
// This must precede the current-schema factory. Otherwise a v16 file
// would be backed up after v17-only tables had been synthesized, making
// the nominal v16 rollback image fail its exact source specification.
{
let conn = lock_conn!(db.conn);
let version = Self::get_user_version(&conn)?;
@@ -139,6 +234,7 @@ impl Database {
}
}
db.create_tables()?;
db.apply_schema_migrations()?;
if let Err(e) = db.ensure_incremental_auto_vacuum() {
log::warn!("Failed to ensure incremental auto-vacuum: {e}");
@@ -177,7 +273,8 @@ impl Database {
if !db_path.exists() {
return Ok(None);
}
let conn = Connection::open(db_path).map_err(|e| AppError::Database(e.to_string()))?;
let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| AppError::Database(e.to_string()))?;
let version = Self::get_user_version(&conn)?;
Ok((version > SCHEMA_VERSION).then_some(version))
}
@@ -195,8 +292,14 @@ impl Database {
let db = Self {
conn: Mutex::new(conn),
_live_write_lease: None,
};
db.create_tables()?;
// Keep the test database structurally identical to a fresh production
// database. Marking the base DDL as current without running the
// migration chain creates a false-current schema and makes restore
// tests certify columns that do not actually exist.
db.apply_schema_migrations()?;
db.ensure_model_pricing_seeded()?;
Ok(db)
@@ -208,14 +311,19 @@ impl Database {
}
fn has_user_tables(conn: &Connection) -> Result<bool, AppError> {
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
[],
|row| row.get(0),
)
let mut statement = conn
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table'")
.map_err(|e| AppError::Database(format!("读取表数量失败: {e}")))?;
Ok(count > 0)
let names = statement
.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| AppError::Database(format!("读取表数量失败: {e}")))?;
for name in names {
let name = name.map_err(|e| AppError::Database(format!("读取表数量失败: {e}")))?;
if !is_sqlite_internal_table_name(&name) {
return Ok(true);
}
}
Ok(false)
}
pub(crate) fn ensure_incremental_auto_vacuum_on_conn(
@@ -293,3 +401,39 @@ impl Database {
Ok(count == 0)
}
}
#[cfg(test)]
impl Database {
/// Test-fixture reconciliation helper. Production code cannot call this:
/// provider writes there must choose a typed create or update operation.
pub(crate) fn reconcile_provider_fixture(
&self,
app_type: &str,
provider: &crate::provider::Provider,
) -> Result<(), AppError> {
let mut input = crate::provider::ProviderMutationInput {
id: provider.id.clone(),
name: provider.name.clone(),
settings_config: provider.settings_config.clone(),
website_url: provider.website_url.clone(),
category: provider.category.clone(),
created_at: provider.created_at,
sort_index: provider.sort_index,
notes: provider.notes.clone(),
meta: provider.meta.clone(),
icon: provider.icon.clone(),
icon_color: provider.icon_color.clone(),
in_failover_queue: provider.in_failover_queue,
};
if self.get_provider_aggregate(app_type, &input.id)?.is_some() {
if let Some(meta) = input.meta.as_mut() {
meta.custom_endpoints.clear();
}
let key = ProviderKey::new(app_type, input.id.clone())?;
let row = ProviderRowUpdate::from_input(&input)?;
self.update_provider(&key, &row)
} else {
self.create_provider(NewProviderAggregate::from_input(app_type, input)?)
}
}
}
File diff suppressed because it is too large Load Diff
+33 -16
View File
@@ -1,7 +1,10 @@
#![cfg(test)]
//! 数据库模块测试
//!
//! 包含 Schema 迁移和基本功能的测试。
use super::schema::MigrationRunContext;
use super::*;
use crate::app_config::MultiAppConfig;
use crate::provider::{Provider, ProviderManager};
@@ -187,13 +190,15 @@ fn existing_skill_repo_selection_is_not_supplemented() {
fn schema_migration_sets_user_version_when_missing() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
.expect("create tables");
assert_eq!(
Database::get_user_version(&conn).expect("read version before"),
0
);
Database::apply_schema_migrations_on_conn(&conn).expect("apply migration");
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)
.expect("apply migration");
assert_eq!(
Database::get_user_version(&conn).expect("read version after"),
@@ -204,11 +209,12 @@ fn schema_migration_sets_user_version_when_missing() {
#[test]
fn schema_migration_rejects_future_version() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
.expect("create tables");
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
let err =
Database::apply_schema_migrations_on_conn(&conn).expect_err("should reject higher version");
let err = Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)
.expect_err("should reject higher version");
assert!(
err.to_string().contains("数据库版本过新"),
"unexpected error: {err}"
@@ -223,7 +229,8 @@ fn schema_migration_adds_missing_columns_for_providers() {
conn.execute_batch(LEGACY_SCHEMA_SQL)
.expect("seed old schema");
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)
.expect("apply migrations");
// 验证关键新增列已补齐
for (table, column) in [
@@ -262,7 +269,8 @@ fn schema_migration_aligns_column_defaults_and_types() {
conn.execute_batch(LEGACY_SCHEMA_SQL)
.expect("seed old schema");
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)
.expect("apply migrations");
let is_current = get_column_info(&conn, "providers", "is_current");
assert_eq!(is_current.r#type, "BOOLEAN");
@@ -303,7 +311,8 @@ fn schema_migration_aligns_column_defaults_and_types() {
#[test]
fn schema_create_tables_include_pricing_model_columns() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
.expect("create tables");
let multiplier = get_column_info(&conn, "proxy_config", "default_cost_multiplier");
assert_eq!(multiplier.r#type, "TEXT");
@@ -352,7 +361,8 @@ fn schema_migration_v4_adds_pricing_model_columns() {
.expect("seed v4 schema");
Database::set_user_version(&conn, 4).expect("set user_version=4");
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)
.expect("apply migrations");
let multiplier = get_column_info(&conn, "proxy_config", "default_cost_multiplier");
assert_eq!(multiplier.r#type, "TEXT");
@@ -414,7 +424,8 @@ fn migration_v10_to_v11_rebuilds_rollups_with_request_model_dimension() {
.expect("seed v10 rollup table");
Database::set_user_version(&conn, 10).expect("set user_version=10");
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)
.expect("apply migrations");
// 新列存在且 NOT NULL DEFAULT ''
let request_model = get_column_info(&conn, "usage_daily_rollups", "request_model");
@@ -482,7 +493,8 @@ fn schema_create_tables_repairs_dev_global_profile_marker() {
.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");
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
.expect("create tables should repair marker");
// 全局 current 标记改名为 claude 组标记,旧 key 删除
let claude_marker: String = conn
@@ -503,7 +515,8 @@ fn schema_create_tables_repairs_dev_global_profile_marker() {
assert_eq!(old_marker, 0);
// 修复必须幂等:再跑一遍不应破坏已迁移的标记
Database::create_tables_on_conn(&conn).expect("repair is idempotent");
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
.expect("repair is idempotent");
let claude_marker: String = conn
.query_row(
"SELECT value FROM settings WHERE key = 'current_profile_id_claude'",
@@ -539,7 +552,8 @@ fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
)
.expect("seed legacy proxy_config");
Database::create_tables_on_conn(&conn).expect("create tables should repair proxy_config");
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
.expect("create tables should repair proxy_config");
assert!(
Database::has_column(&conn, "proxy_config", "app_type").expect("check app_type"),
@@ -603,8 +617,10 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
.expect("seed legacy skill");
// 按应用启动流程:先 create_tables(补齐新增表),再 apply_schema_migrations(按 user_version 迁移)
Database::create_tables_on_conn(&conn).expect("create tables");
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
.expect("create tables");
Database::apply_schema_migrations_on_conn(&conn, MigrationRunContext::LocalUpgrade)
.expect("apply migrations");
assert_eq!(
Database::get_user_version(&conn).expect("user_version after migration"),
@@ -899,7 +915,8 @@ fn ensure_incremental_auto_vacuum_rebuilds_existing_file_db() {
let conn = Connection::open(&path).expect("open temp db");
conn.execute("PRAGMA auto_vacuum = NONE;", [])
.expect("set none auto_vacuum");
Database::create_tables_on_conn(&conn).expect("create tables");
Database::create_tables_on_conn(&conn, MigrationRunContext::LocalUpgrade)
.expect("create tables");
assert_eq!(
Database::get_auto_vacuum_mode(&conn).expect("auto_vacuum before rebuild"),
+4
View File
@@ -66,6 +66,10 @@ pub struct DeepLinkImportRequest {
/// Optional model name
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Native API identifier. Pi provider links require this explicitly;
/// cc-switch never infers a protocol from a URL or model name.
#[serde(skip_serializing_if = "Option::is_none")]
pub api: Option<String>,
/// Optional notes/description
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
+27 -4
View File
@@ -81,10 +81,10 @@ fn parse_provider_deeplink(
// Validate app type
if !matches!(
app.as_str(),
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes" | "pi"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', 'hermes', or 'pi', got '{app}'"
)));
}
@@ -116,6 +116,7 @@ fn parse_provider_deeplink(
// Extract optional fields
let model = params.get("model").cloned();
let api = params.get("api").cloned();
let notes = params.get("notes").cloned();
let haiku_model = params.get("haikuModel").cloned();
let sonnet_model = params.get("sonnetModel").cloned();
@@ -127,6 +128,24 @@ fn parse_provider_deeplink(
let config = params.get("config").cloned();
let config_format = params.get("configFormat").cloned();
let config_url = params.get("configUrl").cloned();
if app == "pi" {
if model.as_deref().is_none_or(|value| value.trim().is_empty()) {
return Err(AppError::InvalidInput(
"Pi provider deep links require a non-empty 'model' parameter".to_string(),
));
}
if api.as_deref().is_none_or(|value| value.trim().is_empty()) {
return Err(AppError::InvalidInput(
"Pi provider deep links require an explicit non-empty 'api' parameter".to_string(),
));
}
if config.is_some() || config_url.is_some() {
return Err(AppError::InvalidInput(
"Pi provider deep links use explicit endpoint/api/model fields; embedded or remote config payloads are not supported"
.to_string(),
));
}
}
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
// Extract usage script fields (v3.9+)
@@ -153,6 +172,7 @@ fn parse_provider_deeplink(
api_key,
icon,
model,
api,
notes,
haiku_model,
sonnet_model,
@@ -190,10 +210,10 @@ fn parse_prompt_deeplink(
// Validate app type
if !matches!(
app.as_str(),
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes" | "pi"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', 'hermes', or 'pi', got '{app}'"
)));
}
@@ -225,6 +245,7 @@ fn parse_prompt_deeplink(
endpoint: None,
api_key: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -298,6 +319,7 @@ fn parse_mcp_deeplink(
endpoint: None,
api_key: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -353,6 +375,7 @@ fn parse_skill_deeplink(
endpoint: None,
api_key: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
+64 -16
View File
@@ -109,27 +109,35 @@ pub fn import_provider_from_deeplink(
let provider_id = provider.id.clone();
// Use ProviderService to add the provider
ProviderService::add(state, app_type.clone(), provider, true)?;
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
for ep in all_endpoints.iter().skip(1) {
let normalized = ep.trim().trim_end_matches('/').to_string();
// All endpoints supplied by one import request belong to the same create
// intent. Put the non-primary endpoints into the initial aggregate so the
// provider row and its complete endpoint set commit atomically.
let initial_endpoints = &mut provider
.meta
.get_or_insert_with(ProviderMeta::default)
.custom_endpoints;
for endpoint in all_endpoints.iter().skip(1) {
let normalized = endpoint.trim().trim_end_matches('/').to_string();
if !normalized.is_empty() {
if let Err(e) = ProviderService::add_custom_endpoint(
state,
app_type.clone(),
&provider_id,
initial_endpoints.insert(
normalized.clone(),
) {
log::warn!(
"Failed to add custom endpoint '{}': {e}",
crate::url_for_log(&normalized)
);
}
crate::settings::CustomEndpoint {
url: normalized,
added_at: Some(timestamp),
last_used: None,
},
);
}
}
// ProviderService owns the strict aggregate create.
ProviderService::add(
state,
app_type.clone(),
crate::services::provider::provider_to_mutation_input(provider),
true,
)?;
// If enabled=true, set as current provider
if merged_request.enabled.unwrap_or(false) {
ProviderService::switch(state, app_type.clone(), &provider_id)?;
@@ -152,6 +160,7 @@ pub(crate) fn build_provider_from_request(
AppType::OpenCode => build_opencode_settings(request),
AppType::OpenClaw => build_additive_app_settings(request),
AppType::Hermes => build_hermes_settings(request),
AppType::Pi => build_pi_settings(request)?,
};
// Build usage script configuration if provided
@@ -583,6 +592,45 @@ fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
json!(config)
}
/// Pi deep links intentionally carry one explicit model, endpoint and native
/// API identifier. Map only that closed subset; richer Pi catalogs use native
/// inspection/import or the Pi editor. No URL/model heuristic may invent the
/// protocol or model identity.
fn build_pi_settings(request: &DeepLinkImportRequest) -> Result<serde_json::Value, AppError> {
let endpoint = get_primary_endpoint(request);
let model = request
.model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::InvalidInput(
"Pi provider deep links require a non-empty model identifier".to_string(),
)
})?;
let api = request
.api
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::InvalidInput(
"Pi provider deep links require an explicit API identifier".to_string(),
)
})?;
Ok(json!({
"name": request.name,
"baseUrl": endpoint,
"apiKey": request.api_key,
"api": api,
"models": [{
"id": model,
"name": model
}]
}))
}
// =============================================================================
// Config Merge Logic
// =============================================================================
+101 -1
View File
@@ -1,9 +1,11 @@
#![cfg(test)]
//! Deep link module tests
use super::mcp::parse_mcp_apps;
use super::parser::parse_deeplink_url;
use super::prompt::import_prompt_from_deeplink;
use super::provider::parse_and_merge_config;
use super::provider::{import_provider_from_deeplink, parse_and_merge_config};
use super::utils::{infer_homepage_from_endpoint, validate_url};
use super::DeepLinkImportRequest;
use crate::AppType;
@@ -87,6 +89,61 @@ fn test_parse_deeplink_with_notes() {
assert_eq!(request.notes, Some("Test notes".to_string()));
}
#[test]
fn pi_provider_deeplink_requires_and_preserves_explicit_native_identity() {
use super::provider::build_provider_from_request;
let request = parse_deeplink_url(
"ccswitch://v1/import?resource=provider&app=pi&name=Pi%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-test&model=opaque-model&api=future-native-api",
)
.expect("parse explicit Pi provider link");
assert_eq!(request.app.as_deref(), Some("pi"));
assert_eq!(request.api.as_deref(), Some("future-native-api"));
assert_eq!(request.model.as_deref(), Some("opaque-model"));
let provider = build_provider_from_request(&AppType::Pi, &request).expect("build Pi provider");
assert_eq!(
provider.settings_config,
serde_json::json!({
"name": "Pi Provider",
"baseUrl": "https://api.example.com/v1",
"apiKey": "sk-test",
"api": "future-native-api",
"models": [{
"id": "opaque-model",
"name": "opaque-model"
}]
}),
"deeplinks must not invent a model, protocol, capability, pricing, or limit field"
);
}
#[test]
fn pi_provider_deeplink_rejects_implicit_model_or_protocol() {
let missing_api = "ccswitch://v1/import?resource=provider&app=pi&name=Pi&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test&model=opaque-model";
assert!(parse_deeplink_url(missing_api)
.expect_err("Pi api must be explicit")
.to_string()
.contains("'api'"));
let missing_model = "ccswitch://v1/import?resource=provider&app=pi&name=Pi&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test&api=openai-responses";
assert!(parse_deeplink_url(missing_model)
.expect_err("Pi model must be explicit")
.to_string()
.contains("'model'"));
}
#[test]
fn pi_prompt_deeplink_is_accepted_by_the_shared_prompt_path() {
let content = BASE64_STANDARD.encode("Pinned Pi AGENTS content");
let url = format!(
"ccswitch://v1/import?resource=prompt&app=pi&name=Pi%20AGENTS&content={content}&enabled=false"
);
let request = parse_deeplink_url(&url).expect("parse Pi prompt deeplink");
assert_eq!(request.app.as_deref(), Some("pi"));
assert_eq!(request.content.as_deref(), Some(content.as_str()));
}
#[test]
fn test_parse_grokbuild_provider() {
use super::provider::build_provider_from_request;
@@ -208,6 +265,7 @@ fn test_build_gemini_provider_with_model() {
api_key: Some("test-api-key".to_string()),
icon: None,
model: Some("gemini-2.0-flash".to_string()),
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -261,6 +319,7 @@ fn test_build_gemini_provider_without_model() {
api_key: Some("test-api-key".to_string()),
icon: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -307,6 +366,7 @@ fn test_deeplink_usage_script_does_not_copy_provider_credentials() {
api_key: Some("sk-main".to_string()),
icon: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -354,6 +414,7 @@ fn usage_script_request(code: &str, usage_enabled: Option<bool>) -> DeepLinkImpo
api_key: Some("sk-main".to_string()),
icon: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -437,6 +498,7 @@ fn test_deeplink_usage_script_omits_explicit_credentials_that_match_provider() {
api_key: Some("sk-main".to_string()),
icon: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -485,6 +547,7 @@ fn test_deeplink_usage_script_preserves_distinct_usage_credentials() {
api_key: Some("sk-main".to_string()),
icon: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -538,6 +601,7 @@ fn test_parse_and_merge_config_claude() {
api_key: None,
icon: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -661,6 +725,7 @@ fn test_parse_and_merge_config_url_override() {
api_key: Some("sk-new".to_string()), // URL param should override
icon: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -724,6 +789,7 @@ fn test_build_claude_provider_preserves_custom_env_fields() {
icon: None,
// URL param: must win over the same key in config (haiku-from-config)
model: Some("main-model".to_string()),
api: None,
notes: None,
haiku_model: Some("haiku-from-url".to_string()),
sonnet_model: None,
@@ -779,6 +845,7 @@ fn test_build_claude_provider_without_config_unchanged() {
api_key: Some("sk".to_string()),
icon: None,
model: None,
api: None,
notes: None,
haiku_model: None,
sonnet_model: None,
@@ -952,6 +1019,39 @@ fn test_parse_multiple_endpoints_comma_separated() {
assert!(endpoint.contains("https://api3.example.com"));
}
#[test]
#[serial_test::serial]
fn provider_deeplink_creates_all_initial_endpoints_in_one_aggregate() {
let _test_home = TestHomeGuard::new();
let request = parse_deeplink_url(
"ccswitch://v1/import?resource=provider&app=claude&name=Endpoint%20Aggregate&endpoint=https%3A%2F%2Fprimary.example.com,https%3A%2F%2Fsecond.example.com%2F,https%3A%2F%2Fthird.example.com&apiKey=sk-test",
)
.expect("parse provider deeplink");
let state = AppState::new(Arc::new(Database::memory().expect("create memory db")));
let provider_id =
import_provider_from_deeplink(&state, request).expect("import provider aggregate");
let aggregate = state
.db
.get_provider_aggregate(AppType::Claude.as_str(), &provider_id)
.expect("read provider aggregate")
.expect("provider exists");
assert_eq!(aggregate.endpoints.len(), 2);
assert_eq!(
aggregate.endpoints["https://second.example.com"].url,
"https://second.example.com"
);
assert_eq!(
aggregate.endpoints["https://third.example.com"].url,
"https://third.example.com"
);
assert!(aggregate
.endpoints
.values()
.all(|endpoint| endpoint.added_at.is_some() && endpoint.last_used.is_none()));
}
#[test]
fn test_parse_single_endpoint_backward_compatible() {
// Old format with single endpoint should still work
+7
View File
@@ -9,6 +9,13 @@ pub enum AppError {
Config(String),
#[error("无效输入: {0}")]
InvalidInput(String),
#[error("未找到: {0}")]
NotFound(String),
/// 结构化冲突:并发前置期望失败(如 reconcile 的 ExpectAbsent 撞上竞争
/// 创建、ExpectPresent 的指纹过期)。调用方据此重读重试或上浮,不得解析
/// Database(String) 文本。由前置工程 A 认证契约引入(T9)。
#[error("并发冲突: {0}")]
Conflict(String),
#[error("IO 错误: {path}: {source}")]
Io {
path: String,
+80 -15
View File
@@ -25,6 +25,7 @@ mod model_capabilities;
mod openclaw_config;
mod opencode_config;
mod panic_hook;
mod pi_config;
mod prompt;
mod prompt_files;
mod provider;
@@ -38,14 +39,18 @@ mod tray;
mod usage_events;
mod usage_script;
#[cfg(test)]
mod architecture_tests;
pub use app_config::{AppType, InstalledSkill, McpApps, McpServer, MultiAppConfig, SkillApps};
pub use codex_config::{
get_codex_auth_path, get_codex_config_path, read_codex_live_settings, write_codex_live_atomic,
};
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
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, Profile};
pub use database::{
Database, NewEndpoint, NewProviderAggregate, Profile, ProviderKey, ProviderRowUpdate,
RenameProvider,
};
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
pub use error::AppError;
pub use grok_config::get_grok_config_path;
@@ -57,7 +62,7 @@ pub use mcp::{
sync_single_server_to_gemini, sync_single_server_to_grokbuild,
};
pub use prompt::Prompt;
pub use provider::{Provider, ProviderMeta};
pub use provider::{Provider, ProviderAggregate, ProviderMeta, ProviderMutationInput};
pub use services::{
profile::{ProfilePayload, ProfileScope, ProfileService},
provider::reapply_current_codex_official_live,
@@ -946,6 +951,7 @@ pub fn run() {
crate::app_config::AppType::OpenCode,
crate::app_config::AppType::OpenClaw,
crate::app_config::AppType::Hermes,
crate::app_config::AppType::Pi,
] {
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
@@ -1324,6 +1330,12 @@ pub fn run() {
commands::remove_provider_from_live_config,
commands::switch_provider,
commands::import_default_config,
commands::get_pi_native_catalog,
commands::import_pi_native_provider,
commands::set_pi_default_model,
commands::get_pi_native_defaults,
commands::get_pi_session_discovery,
commands::reset_pi_gateway_credential,
commands::get_claude_desktop_status,
commands::get_claude_desktop_default_routes,
commands::import_claude_desktop_providers_from_claude,
@@ -1408,6 +1420,14 @@ pub fn run() {
commands::enable_prompt,
commands::import_prompt_from_file,
commands::get_current_prompt_file_content,
commands::get_pi_prompt_library_status,
commands::reconcile_pi_prompt_library,
commands::get_pi_prompt_file,
commands::replace_pi_prompt_file,
commands::delete_pi_prompt_file,
commands::list_pi_prompt_templates,
commands::upsert_pi_prompt_template,
commands::delete_pi_prompt_template,
// Profile management (项目配置方案)
commands::list_profiles,
commands::create_profile,
@@ -1462,6 +1482,7 @@ pub fn run() {
commands::restore_env_backup,
// Skill management (v3.10.0+ unified)
commands::get_installed_skills,
commands::get_pi_skill_statuses,
commands::get_skill_backups,
commands::delete_skill_backup,
commands::install_skill_unified,
@@ -1827,7 +1848,11 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
}
};
let live_taken_over = proxy_service.detect_takeover_in_live_configs();
let needs_restore = has_backups || live_taken_over;
let needs_restore = cleanup_before_exit_needed(
has_backups,
live_taken_over,
crate::settings::pi_takeover_enabled(),
);
if needs_restore {
log::info!("检测到接管残留,开始恢复 Live 配置(保留代理状态)...");
@@ -1851,6 +1876,14 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
}
}
fn cleanup_before_exit_needed(
has_live_backups: bool,
legacy_live_taken_over: bool,
pi_takeover_enabled: bool,
) -> bool {
has_live_backups || legacy_live_taken_over || pi_takeover_enabled
}
/// 主动从系统托盘移除托盘图标。
///
/// `std::process::exit` 会绕过 Tauri 运行时,触发不了 `TrayIcon::drop()`
@@ -1881,7 +1914,10 @@ pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
/// 则自动启动代理服务并接管对应应用的 Live 配置。
const PROXY_STARTUP_APP_TYPES: [&str; 4] = ["claude", "codex", "gemini", "grokbuild"];
async fn enabled_proxy_apps_on_startup(db: &database::Database) -> Vec<&'static str> {
async fn enabled_proxy_apps_on_startup(
db: &database::Database,
pi_takeover_enabled: bool,
) -> Vec<&'static str> {
let mut apps = Vec::new();
for app_type in PROXY_STARTUP_APP_TYPES {
if db
@@ -1892,12 +1928,16 @@ async fn enabled_proxy_apps_on_startup(db: &database::Database) -> Vec<&'static
apps.push(app_type);
}
}
if pi_takeover_enabled {
apps.push("pi");
}
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;
let apps_to_restore =
enabled_proxy_apps_on_startup(&state.db, crate::settings::pi_takeover_enabled()).await;
if apps_to_restore.is_empty() {
log::debug!("启动时无需恢复代理状态");
@@ -1918,7 +1958,15 @@ async fn restore_proxy_state_on_startup(state: &store::AppState) {
}
Err(e) => {
log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}");
// 失败时清除该应用的状态,避免下次启动再次尝试
// Pi desired state is device-local user intent. Keep it
// pending/degraded so a transient bind or projection failure
// is retried on the next startup.
if app_type == "pi" {
continue;
}
// Legacy live-config apps retain their historical cleanup
// behavior because their enabled bit also describes a live
// file takeover, not an independent desired/operational pair.
if let Err(clear_err) = state
.proxy_service
.set_takeover_for_app(app_type, false)
@@ -1988,6 +2036,7 @@ fn initialize_common_config_snippets(state: &store::AppState) {
.unwrap_or(true);
if should_run_legacy_migration {
let mut legacy_migration_succeeded = true;
for app_type in [
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
@@ -2001,11 +2050,14 @@ fn initialize_common_config_snippets(state: &store::AppState) {
"✗ Failed to migrate legacy common-config usage for {}: {e}",
app_type.as_str()
);
legacy_migration_succeeded = false;
}
}
if let Err(e) = state.db.set_legacy_common_config_migrated(true) {
log::warn!("✗ Failed to persist legacy common-config migration flag: {e}");
if legacy_migration_succeeded {
if let Err(e) = state.db.set_legacy_common_config_migrated(true) {
log::warn!("✗ Failed to persist legacy common-config migration flag: {e}");
}
}
}
}
@@ -2216,9 +2268,9 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
#[cfg(test)]
mod tests {
use super::{
classify_exit_request, enabled_proxy_apps_on_startup, redact_url_for_log,
redact_url_for_log_with_secrets, redact_url_origin_for_log, runtime_log_level_allows,
ExitRequestAction,
classify_exit_request, cleanup_before_exit_needed, enabled_proxy_apps_on_startup,
redact_url_for_log, redact_url_for_log_with_secrets, redact_url_origin_for_log,
runtime_log_level_allows, ExitRequestAction,
};
use crate::database::Database;
@@ -2340,8 +2392,21 @@ mod tests {
.await
.expect("enable Grok Build proxy config");
let apps = enabled_proxy_apps_on_startup(&db).await;
let apps = enabled_proxy_apps_on_startup(&db, false).await;
assert_eq!(apps, vec!["grokbuild"]);
}
#[tokio::test]
async fn startup_restore_republishes_persisted_pi_takeover() {
let db = Database::memory().expect("initialize database");
let apps = enabled_proxy_apps_on_startup(&db, true).await;
assert_eq!(apps, vec!["pi"]);
}
#[test]
fn process_exit_cleanup_includes_pi_takeover_without_legacy_live_backups() {
assert!(cleanup_before_exit_needed(false, false, true));
assert!(!cleanup_before_exit_needed(false, false, false));
}
}
+979
View File
@@ -0,0 +1,979 @@
//! Credential-blind Pi native model composition.
//!
//! The only Pi-layer input is [`PiRawValidProvider`]. This module does not
//! import managed DTOs or gateway families, and it never resolves credentials,
//! environment variables, commands, files, or network resources.
#![allow(dead_code)]
use super::{
merge_pi_compat,
raw_schema::{PiRawApiId, PiRawValidProvider},
};
use serde_json::{json, Map, Value};
use std::collections::{BTreeMap, HashSet};
const PROVIDER_FIELDS: &[&str] = &[
"name",
"baseUrl",
"apiKey",
"api",
"oauth",
"headers",
"compat",
"authHeader",
"models",
"modelOverrides",
];
const MODEL_FIELDS: &[&str] = &[
"id",
"name",
"baseUrl",
"api",
"reasoning",
"thinkingLevelMap",
"input",
"cost",
"contextWindow",
"maxTokens",
"headers",
"compat",
];
const OVERRIDE_FIELDS: &[&str] = &[
"name",
"reasoning",
"thinkingLevelMap",
"input",
"cost",
"contextWindow",
"maxTokens",
"headers",
"compat",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PiComposerStatus {
Composed,
Failed,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PiComposerReasonCode {
CatalogRequired,
MissingExplicitModels,
MissingEffectiveApi,
MissingEffectiveEndpoint,
NonPositiveModelLimit,
UnrepresentableCompat,
CompositionFailed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PiComposerReason {
pub code: PiComposerReasonCode,
pub json_pointer: String,
}
/// One configured header together with the source pointer that Pi resolves.
///
/// `headers` remains the pinned composer's flattened observable result, while
/// these entries retain the provider-vs-model boundary needed to reproduce
/// the later `ModelRuntime` merge on the wire.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PiComposedHeader {
pub name: String,
pub value: String,
pub json_pointer: String,
}
/// The lossless native result of pinned Pi composition.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct PiComposedNativeModel {
pub id: String,
pub name: String,
pub api: PiRawApiId,
pub provider: String,
pub base_url: String,
pub reasoning: bool,
pub thinking_level_map: Option<Value>,
pub input: Value,
pub cost: Value,
pub context_window: Value,
pub max_tokens: Value,
pub headers: BTreeMap<String, String>,
pub provider_headers: Vec<PiComposedHeader>,
pub model_headers: Vec<PiComposedHeader>,
pub compat: Option<Value>,
pub api_key: Option<String>,
pub oauth: Option<Value>,
pub auth_header: bool,
pub provider_extra: BTreeMap<String, Value>,
pub model_extra: BTreeMap<String, Value>,
pub override_extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct PiNativeComposition {
pub status: PiComposerStatus,
pub provider_id: Option<String>,
pub provider_name: Option<String>,
pub provider_base_url: Option<String>,
pub models: Vec<PiComposedNativeModel>,
pub ignored_override_keys: Vec<String>,
pub reasons: Vec<PiComposerReason>,
}
impl PiNativeComposition {
pub(super) fn unavailable_without_valid_raw() -> Self {
Self {
status: PiComposerStatus::Unknown,
provider_id: None,
provider_name: None,
provider_base_url: None,
models: Vec::new(),
ignored_override_keys: Vec::new(),
reasons: Vec::new(),
}
}
pub(super) fn catalog_required(pointer: &str) -> Self {
Self {
status: PiComposerStatus::Unknown,
provider_id: None,
provider_name: None,
provider_base_url: None,
models: Vec::new(),
ignored_override_keys: Vec::new(),
reasons: vec![PiComposerReason {
code: PiComposerReasonCode::CatalogRequired,
json_pointer: pointer.to_string(),
}],
}
}
fn failed(code: PiComposerReasonCode, pointer: impl Into<String>) -> Self {
Self {
status: PiComposerStatus::Failed,
provider_id: None,
provider_name: None,
provider_base_url: None,
models: Vec::new(),
ignored_override_keys: Vec::new(),
reasons: vec![PiComposerReason {
code,
json_pointer: pointer.into(),
}],
}
}
fn unknown(code: PiComposerReasonCode, pointer: impl Into<String>) -> Self {
Self {
status: PiComposerStatus::Unknown,
provider_id: None,
provider_name: None,
provider_base_url: None,
models: Vec::new(),
ignored_override_keys: Vec::new(),
reasons: vec![PiComposerReason {
code,
json_pointer: pointer.into(),
}],
}
}
}
pub(super) fn compose_explicit_custom_catalog(
provider_id: &str,
provider: &PiRawValidProvider,
) -> PiNativeComposition {
let Some(provider_object) = provider.raw().as_object() else {
return PiNativeComposition::failed(PiComposerReasonCode::CompositionFailed, "");
};
let Some(definitions) = provider_object
.get("models")
.and_then(Value::as_array)
.filter(|models| !models.is_empty())
else {
return PiNativeComposition::failed(PiComposerReasonCode::MissingExplicitModels, "/models");
};
let provider_api = provider_object.get("api").and_then(Value::as_str);
let provider_base_url = provider_object.get("baseUrl").and_then(Value::as_str);
if provider_object.get("oauth").and_then(Value::as_str) == Some("radius")
&& provider_base_url.is_none()
{
return PiNativeComposition::failed(
PiComposerReasonCode::MissingEffectiveEndpoint,
"/baseUrl",
);
}
let provider_compat = provider_object.get("compat").cloned();
let provider_header_entries = header_entries(provider_object.get("headers"), "/headers");
let provider_headers = provider_header_entries
.iter()
.map(|entry| (entry.name.clone(), entry.value.clone()))
.collect::<BTreeMap<_, _>>();
let provider_extra = unknown_fields(provider_object, PROVIDER_FIELDS);
let api_key = provider_object
.get("apiKey")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let oauth = provider_object.get("oauth").cloned();
let auth_header = provider_object
.get("authHeader")
.and_then(Value::as_bool)
.unwrap_or(false);
let overrides = provider_object
.get("modelOverrides")
.and_then(Value::as_object);
let mut models: Vec<PiComposedNativeModel> = Vec::with_capacity(definitions.len());
for (index, definition_value) in definitions.iter().enumerate() {
let Some(definition) = definition_value.as_object() else {
return PiNativeComposition::failed(
PiComposerReasonCode::CompositionFailed,
format!("/models/{index}"),
);
};
let Some(id) = definition.get("id").and_then(Value::as_str) else {
return PiNativeComposition::failed(
PiComposerReasonCode::CompositionFailed,
format!("/models/{index}/id"),
);
};
let existing_index = models.iter().position(|model| model.id == id);
let defaults = existing_index
.and_then(|position| models.get(position))
.or_else(|| models.first());
let api_value = definition
.get("api")
.and_then(Value::as_str)
.or(provider_api)
.or_else(|| defaults.map(|model| model.api.as_str()));
let Some(api_value) = api_value else {
return PiNativeComposition::failed(
PiComposerReasonCode::MissingEffectiveApi,
format!("/models/{index}/api"),
);
};
let Some(api) = PiRawApiId::new(api_value) else {
return PiNativeComposition::failed(
PiComposerReasonCode::MissingEffectiveApi,
format!("/models/{index}/api"),
);
};
let base_url = definition
.get("baseUrl")
.and_then(Value::as_str)
.or(provider_base_url)
.or_else(|| defaults.map(|model| model.base_url.as_str()));
let Some(base_url) = base_url.filter(|value| !value.is_empty()) else {
return PiNativeComposition::failed(
PiComposerReasonCode::MissingEffectiveEndpoint,
format!("/models/{index}/baseUrl"),
);
};
for (field, code) in [
("contextWindow", PiComposerReasonCode::NonPositiveModelLimit),
("maxTokens", PiComposerReasonCode::NonPositiveModelLimit),
] {
if definition
.get(field)
.and_then(Value::as_f64)
.is_some_and(|value| value <= 0.0)
{
return PiNativeComposition::failed(code, format!("/models/{index}/{field}"));
}
}
let compat =
match merge_pi_compat(provider_compat.clone(), definition.get("compat").cloned()) {
Ok(compat) => compat,
Err(_) => {
return PiNativeComposition::unknown(
PiComposerReasonCode::UnrepresentableCompat,
format!("/models/{index}/compat"),
)
}
};
let model = PiComposedNativeModel {
id: id.to_string(),
name: definition
.get("name")
.and_then(Value::as_str)
.unwrap_or(id)
.to_string(),
api,
provider: provider_id.to_string(),
base_url: base_url.to_string(),
reasoning: definition
.get("reasoning")
.and_then(Value::as_bool)
.unwrap_or(false),
thinking_level_map: definition.get("thinkingLevelMap").cloned(),
input: definition
.get("input")
.cloned()
.unwrap_or_else(|| json!(["text"])),
cost: definition.get("cost").cloned().unwrap_or_else(default_cost),
context_window: definition
.get("contextWindow")
.cloned()
.unwrap_or_else(|| json!(128000)),
max_tokens: definition
.get("maxTokens")
.cloned()
.unwrap_or_else(|| json!(16384)),
headers: BTreeMap::new(),
provider_headers: provider_header_entries.clone(),
model_headers: Vec::new(),
compat,
api_key: api_key.clone(),
oauth: oauth.clone(),
auth_header,
provider_extra: provider_extra.clone(),
model_extra: unknown_fields(definition, MODEL_FIELDS),
override_extra: BTreeMap::new(),
};
if let Some(existing_index) = existing_index {
models[existing_index] = model;
} else {
models.push(model);
}
}
for model in &mut models {
// Pinned Pi's rawModelHeaders uses Array.find, so duplicate model
// definitions obtain request headers from the first definition even
// though the later definition replaces the composed model slot.
let (definition_index, definition) = definitions
.iter()
.enumerate()
.find_map(|(index, definition)| {
definition
.as_object()
.filter(|definition| {
definition.get("id").and_then(Value::as_str) == Some(model.id.as_str())
})
.map(|definition| (index, definition))
})
.expect("raw-valid composed model has a source definition");
let model_override =
overrides.and_then(|overrides| overrides.get(&model.id).and_then(Value::as_object));
// rawModelHeaders constructs one case-sensitive JavaScript object from
// override headers followed by the first matching model definition.
// Exact-name replacement keeps its insertion slot; differently-cased
// names remain distinct until ModelRuntime performs its later
// case-insensitive HTTP merge.
let mut model_headers = Vec::new();
if let Some(model_override) = model_override {
overlay_header_entries(
&mut model_headers,
header_entries(
model_override.get("headers"),
&format!("/modelOverrides/{}/headers", escape_json_pointer(&model.id)),
),
);
}
overlay_header_entries(
&mut model_headers,
header_entries(
definition.get("headers"),
&format!("/models/{definition_index}/headers"),
),
);
let mut headers = provider_headers.clone();
for entry in &model_headers {
headers.insert(entry.name.clone(), entry.value.clone());
}
model.headers = headers;
model.model_headers = model_headers;
if let Some(model_override) = model_override {
if let Some(name) = model_override.get("name").and_then(Value::as_str) {
model.name = name.to_string();
}
if let Some(reasoning) = model_override.get("reasoning").and_then(Value::as_bool) {
model.reasoning = reasoning;
}
if let Some(override_map) = model_override
.get("thinkingLevelMap")
.and_then(Value::as_object)
{
let mut merged = model
.thinking_level_map
.take()
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
merged.extend(override_map.clone());
model.thinking_level_map = Some(Value::Object(merged));
}
if let Some(input) = model_override.get("input") {
model.input = input.clone();
}
if let Some(cost) = model_override.get("cost").and_then(Value::as_object) {
model.cost = merge_cost(&model.cost, cost);
}
if let Some(context_window) = model_override.get("contextWindow") {
model.context_window = context_window.clone();
}
if let Some(max_tokens) = model_override.get("maxTokens") {
model.max_tokens = max_tokens.clone();
}
model.compat = match merge_pi_compat(
model.compat.clone(),
model_override.get("compat").cloned(),
) {
Ok(compat) => compat,
Err(_) => {
return PiNativeComposition::unknown(
PiComposerReasonCode::UnrepresentableCompat,
format!("/modelOverrides/{}/compat", escape_json_pointer(&model.id)),
)
}
};
model.override_extra = unknown_fields(model_override, OVERRIDE_FIELDS);
}
}
let model_ids = models
.iter()
.map(|model| model.id.as_str())
.collect::<HashSet<_>>();
let ignored_override_keys = overrides
.into_iter()
.flat_map(|overrides| overrides.keys())
.filter(|model_id| !model_ids.contains(model_id.as_str()))
.cloned()
.collect();
PiNativeComposition {
status: PiComposerStatus::Composed,
provider_id: Some(provider_id.to_string()),
provider_name: Some(
provider_object
.get("name")
.and_then(Value::as_str)
.unwrap_or(provider_id)
.to_string(),
),
provider_base_url: provider_base_url.map(ToOwned::to_owned),
models,
ignored_override_keys,
reasons: Vec::new(),
}
}
fn default_cost() -> Value {
json!({
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
})
}
fn merge_cost(base: &Value, overlay: &Map<String, Value>) -> Value {
let base = base.as_object();
let mut merged = Map::new();
for key in ["input", "output", "cacheRead", "cacheWrite", "tiers"] {
if let Some(value) = overlay
.get(key)
.or_else(|| base.and_then(|base| base.get(key)))
{
merged.insert(key.to_string(), value.clone());
}
}
Value::Object(merged)
}
fn header_entries(value: Option<&Value>, base_pointer: &str) -> Vec<PiComposedHeader> {
value
.and_then(Value::as_object)
.into_iter()
.flat_map(|object| object.iter())
.filter_map(|(name, value)| {
value.as_str().map(|value| PiComposedHeader {
name: name.clone(),
value: value.to_string(),
json_pointer: format!("{base_pointer}/{}", escape_json_pointer(name)),
})
})
.collect()
}
fn overlay_header_entries(
base: &mut Vec<PiComposedHeader>,
overlay: impl IntoIterator<Item = PiComposedHeader>,
) {
for entry in overlay {
if let Some(existing) = base.iter_mut().find(|existing| existing.name == entry.name) {
*existing = entry;
} else {
base.push(entry);
}
}
}
fn escape_json_pointer(segment: &str) -> String {
segment.replace('~', "~0").replace('/', "~1")
}
fn unknown_fields(object: &Map<String, Value>, recognized: &[&str]) -> BTreeMap<String, Value> {
object
.iter()
.filter(|(key, _)| !recognized.contains(&key.as_str()))
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pi_config::raw_schema::{evaluate_provider_value, PiRawValidity};
use serde::Deserialize;
const COMPOSER_ORACLE_SOURCE: &str =
include_str!("../../../tests/fixtures/pi/native-oracle/composer-oracle-v1.json");
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ComposerOracle {
cases: Vec<ComposerOracleCase>,
fail_closed_cases: Vec<FailClosedCase>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ComposerOracleCase {
id: String,
provider_id: String,
input: Value,
execution: Execution,
#[serde(default)]
auth_execution: Option<Value>,
#[serde(default)]
expected: Option<Value>,
#[serde(default)]
expected_error: Option<String>,
}
#[derive(Deserialize)]
struct Execution {
status: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct FailClosedCase {
id: String,
rust_expected_status: String,
reason_code: String,
}
fn model_as_oracle_value(model: &PiComposedNativeModel) -> Value {
let mut object = Map::new();
object.insert("id".into(), json!(model.id));
object.insert("name".into(), json!(model.name));
object.insert("api".into(), json!(model.api.as_str()));
object.insert("provider".into(), json!(model.provider));
object.insert("baseUrl".into(), json!(model.base_url));
object.insert("reasoning".into(), json!(model.reasoning));
if let Some(thinking) = &model.thinking_level_map {
object.insert("thinkingLevelMap".into(), thinking.clone());
}
object.insert("input".into(), model.input.clone());
object.insert("cost".into(), model.cost.clone());
object.insert("contextWindow".into(), model.context_window.clone());
object.insert("maxTokens".into(), model.max_tokens.clone());
object.insert("authHeader".into(), json!(model.auth_header));
if let Some(compat) = &model.compat {
object.insert("compat".into(), compat.clone());
}
if !model.headers.is_empty() {
object.insert("headers".into(), json!(model.headers));
}
Value::Object(object)
}
fn provider_as_oracle_value(composition: &PiNativeComposition) -> Value {
let mut object = Map::new();
object.insert(
"id".into(),
json!(composition
.provider_id
.as_ref()
.expect("composed provider id")),
);
object.insert(
"name".into(),
json!(composition
.provider_name
.as_ref()
.expect("composed provider name")),
);
if let Some(base_url) = &composition.provider_base_url {
object.insert("baseUrl".into(), json!(base_url));
}
Value::Object(object)
}
fn json_numbers_equal(left: &Value, right: &Value) -> bool {
match (left, right) {
(Value::Number(left), Value::Number(right)) => left.as_f64() == right.as_f64(),
(Value::Array(left), Value::Array(right)) => {
left.len() == right.len()
&& left
.iter()
.zip(right)
.all(|(left, right)| json_numbers_equal(left, right))
}
(Value::Object(left), Value::Object(right)) => {
left.len() == right.len()
&& left.iter().all(|(key, left)| {
right
.get(key)
.is_some_and(|right| json_numbers_equal(left, right))
})
}
_ => left == right,
}
}
#[test]
fn rust_composer_matches_actual_pinned_upstream_execution() {
let oracle: ComposerOracle =
serde_json::from_str(COMPOSER_ORACLE_SOURCE).expect("parse composer oracle");
for case in oracle.cases {
let raw = evaluate_provider_value(&case.input);
if case.execution.status == "error" {
assert!(
case.expected_error.is_some(),
"upstream error vector '{}' records its actual error",
case.id
);
match raw.validity {
PiRawValidity::Invalid => {}
PiRawValidity::Valid => {
let result = compose_explicit_custom_catalog(
&case.provider_id,
raw.valid_provider.as_ref().expect("raw-valid provider"),
);
assert_eq!(
result.status,
PiComposerStatus::Failed,
"raw-valid upstream error case '{}'",
case.id
);
}
PiRawValidity::Unknown => {
panic!("oracle case '{}' unexpectedly became Unknown", case.id)
}
}
continue;
}
assert_eq!(raw.validity, PiRawValidity::Valid, "case '{}'", case.id);
let result = compose_explicit_custom_catalog(
&case.provider_id,
raw.valid_provider.as_ref().expect("raw-valid provider"),
);
assert_eq!(
result.status,
PiComposerStatus::Composed,
"case '{}'",
case.id
);
let auth_execution = case
.auth_execution
.as_ref()
.expect("successful composer case records actual auth execution");
assert_eq!(
auth_execution.pointer("/status").and_then(Value::as_str),
Some("success"),
"case '{}'",
case.id
);
let actual_resolved_key = auth_execution
.pointer("/result/auth/apiKey")
.and_then(Value::as_str)
.expect("successful literal composer vector resolves an API key");
assert!(
result
.models
.iter()
.all(|model| { model.api_key.as_deref() == Some(actual_resolved_key) }),
"case '{}' preserves the same literal key that pinned Pi resolved",
case.id
);
if case
.input
.get("authHeader")
.and_then(Value::as_bool)
.unwrap_or(false)
{
let expected_bearer = format!("Bearer {actual_resolved_key}");
assert_eq!(
auth_execution
.pointer("/result/auth/headers/Authorization")
.and_then(Value::as_str),
Some(expected_bearer.as_str()),
"case '{}' uses pinned Pi authHeader behavior",
case.id
);
}
let actual = json!({
"provider": provider_as_oracle_value(&result),
"models": result
.models
.iter()
.map(model_as_oracle_value)
.collect::<Vec<_>>(),
"ignoredOverrideKeys": result.ignored_override_keys,
});
let expected = case.expected.expect("successful upstream expected output");
assert!(
json_numbers_equal(&actual, &expected),
"oracle case '{}'\nactual: {actual:#}\nexpected: {expected:#}",
case.id
);
}
}
#[test]
fn unavailable_upstream_catalog_semantics_are_explicitly_unknown() {
let oracle: ComposerOracle =
serde_json::from_str(COMPOSER_ORACLE_SOURCE).expect("parse composer oracle");
assert_eq!(oracle.fail_closed_cases.len(), 2);
for case in oracle.fail_closed_cases {
assert_eq!(case.rust_expected_status, "unknown", "case '{}'", case.id);
assert_eq!(case.reason_code, "catalog_required", "case '{}'", case.id);
let result = PiNativeComposition::catalog_required("/models");
assert_eq!(result.status, PiComposerStatus::Unknown);
assert_eq!(
result.reasons[0].code,
PiComposerReasonCode::CatalogRequired
);
}
}
#[test]
fn credential_expressions_are_preserved_without_execution() {
let value = json!({
"api": "openai-responses",
"baseUrl": "https://example.test/v1",
"apiKey": "!read-secret",
"oauth": "radius",
"authHeader": true,
"headers": {"x-tenant": "${TENANT}"},
"models": [{"id": "m"}]
});
let raw = evaluate_provider_value(&value);
let composed = compose_explicit_custom_catalog(
"deferred",
raw.valid_provider.as_ref().expect("raw-valid"),
);
assert_eq!(composed.status, PiComposerStatus::Composed);
assert_eq!(composed.models[0].api_key.as_deref(), Some("!read-secret"));
assert_eq!(composed.models[0].oauth, Some(json!("radius")));
assert!(composed.models[0].auth_header);
assert_eq!(composed.models[0].headers["x-tenant"], "${TENANT}");
}
#[test]
fn pinned_cost_override_reconstructs_only_known_cost_members() {
let value = json!({
"api": "anthropic-messages",
"baseUrl": "https://cost.example",
"apiKey": "literal",
"models": [{
"id": "m",
"cost": {
"input": 1,
"output": 2,
"cacheRead": 0.1,
"cacheWrite": 0.2,
"futureRate": 9
}
}],
"modelOverrides": {
"m": {"cost": {"output": 3}}
}
});
let raw = evaluate_provider_value(&value);
let composed = compose_explicit_custom_catalog(
"cost-shape",
raw.valid_provider.as_ref().expect("raw-valid"),
);
assert_eq!(
composed.models[0].cost,
json!({
"input": 1,
"output": 3,
"cacheRead": 0.1,
"cacheWrite": 0.2
}),
"pinned applyModelOverride drops unknown base cost keys when an override exists"
);
}
#[test]
fn compat_spread_matches_pinned_composer_request_capture() {
let value = json!({
"api": "openai-responses",
"baseUrl": "https://compat.example/v1",
"apiKey": "literal",
"compat": {
"openRouterRouting": ["first", "second"],
"chatTemplateKwargs": "ab",
"baseOnly": true
},
"models": [{
"id": "m",
"compat": {"supportsStore": true}
}],
"modelOverrides": {
"m": {
"compat": {
"openRouterRouting": null,
"chatTemplateKwargs": {"named": true},
"overlayOnly": true
}
}
}
});
let raw = evaluate_provider_value(&value);
let composed = compose_explicit_custom_catalog(
"compat-spread",
raw.valid_provider.as_ref().expect("raw-valid"),
);
assert_eq!(
composed.models[0].compat,
Some(json!({
"openRouterRouting": {"0": "first", "1": "second"},
"chatTemplateKwargs": {"0": "a", "1": "b", "named": true},
"baseOnly": true,
"supportsStore": true,
"overlayOnly": true
})),
"captured by scripts/pi-transport-capture.mjs at the pinned Pi commit"
);
}
#[test]
fn compat_spread_fails_closed_when_pinned_output_requires_lone_surrogates() {
let value = json!({
"api": "openai-responses",
"baseUrl": "https://compat.example/v1",
"apiKey": "literal",
"compat": {"chatTemplateKwargs": "😀"},
"models": [{"id": "m"}],
"modelOverrides": {
"m": {"compat": {"chatTemplateKwargs": {"named": true}}}
}
});
let raw = evaluate_provider_value(&value);
let composition = compose_explicit_custom_catalog(
"compat-surrogate",
raw.valid_provider.as_ref().expect("raw-valid"),
);
assert_eq!(composition.status, PiComposerStatus::Unknown);
assert_eq!(
composition.reasons,
vec![PiComposerReason {
code: PiComposerReasonCode::UnrepresentableCompat,
json_pointer: "/modelOverrides/m/compat".to_string(),
}],
"capture records UTF-16 d83d/de00 as two lone-surrogate values, which \
serde_json::Value cannot represent"
);
}
#[test]
fn header_layers_retain_runtime_precedence_and_source_pointers() {
let value = json!({
"api": "anthropic-messages",
"baseUrl": "https://headers.example",
"apiKey": "literal",
"headers": {"authorization": "Bearer provider"},
"models": [{
"id": "m",
"headers": {"Authorization": "Bearer model"}
}],
"modelOverrides": {
"m": {"headers": {"x-layer": "override"}}
}
});
let raw = evaluate_provider_value(&value);
let composed = compose_explicit_custom_catalog(
"header-layers",
raw.valid_provider.as_ref().expect("raw-valid"),
);
let model = &composed.models[0];
assert_eq!(
model.provider_headers[0].json_pointer,
"/headers/authorization"
);
assert_eq!(
model
.model_headers
.iter()
.map(|entry| (entry.name.as_str(), entry.value.as_str()))
.collect::<Vec<_>>(),
vec![("x-layer", "override"), ("Authorization", "Bearer model")]
);
}
#[test]
fn unknown_provider_model_and_override_fields_are_retained_losslessly() {
let value = json!({
"api": "future-wire-v9",
"baseUrl": "https://example.test/v9",
"apiKey": "literal",
"futureProviderShape": {
"nested": [1, {"flag": true}]
},
"models": [{
"id": "m",
"futureModelShape": {
"mode": "novel",
"threshold": 0.125
}
}],
"modelOverrides": {
"m": {
"futureOverrideShape": [
null,
{"preserve": "exactly"}
]
}
}
});
let raw = evaluate_provider_value(&value);
let composed = compose_explicit_custom_catalog(
"lossless",
raw.valid_provider.as_ref().expect("raw-valid"),
);
assert_eq!(composed.status, PiComposerStatus::Composed);
let model = &composed.models[0];
assert_eq!(
model.provider_extra["futureProviderShape"],
json!({"nested": [1, {"flag": true}]})
);
assert_eq!(
model.model_extra["futureModelShape"],
json!({"mode": "novel", "threshold": 0.125})
);
assert_eq!(
model.override_extra["futureOverrideShape"],
json!([null, {"preserve": "exactly"}])
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
//! Pi Coding Agent integration boundaries.
//!
//! This module deliberately separates the managed control-plane model from
//! Pi's shared files and from the proxy data plane. Callers must use the
//! typed model resolver rather than reimplementing provider/model inheritance.
use indexmap::IndexMap;
use serde_json::{Map, Value};
pub(crate) mod composer;
pub(crate) mod document;
pub(crate) mod gateway;
pub(crate) mod model;
pub(crate) mod native;
#[cfg(test)]
mod native_inspection_certification;
pub(crate) mod native_settings;
pub(crate) mod raw_schema;
pub(crate) mod shared_file;
const PI_COMPAT_NESTED_SPREAD_KEYS: [&str; 3] = [
"openRouterRouting",
"vercelGatewayRouting",
"chatTemplateKwargs",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PiCompatMergeError;
#[derive(Debug, Clone, PartialEq)]
enum JavaScriptSpreadValue {
Json(Value),
LoneSurrogate,
}
type JavaScriptSpreadMap = IndexMap<String, JavaScriptSpreadValue>;
/// Mirror pinned Pi's `mergeCompat` JavaScript object-spread semantics.
///
/// Arrays expose numeric enumerable properties, strings expose character
/// properties, objects expose their own fields, and the remaining JSON
/// primitives expose none. Existing key positions are retained when an
/// overlay replaces their values, matching object spread.
///
/// A JavaScript string is indexed by UTF-16 code unit. Spreading an astral
/// character therefore creates lone-surrogate string values, which cannot be
/// represented by Rust `String` or `serde_json::Value`. That shape is rejected
/// explicitly so callers can fail closed instead of emitting a different
/// composed model.
fn merge_pi_compat(
base: Option<Value>,
overlay: Option<Value>,
) -> Result<Option<Value>, PiCompatMergeError> {
let Some(overlay) = overlay else {
return Ok(base);
};
if !javascript_truthy(&overlay) {
return Ok(base);
}
let mut merged = javascript_object_spread(base.as_ref());
merged.extend(javascript_object_spread(Some(&overlay)));
for key in PI_COMPAT_NESTED_SPREAD_KEYS {
let base_value = javascript_property(base.as_ref(), key);
let overlay_value = javascript_property(Some(&overlay), key);
if base_value.is_some_and(javascript_is_object)
|| overlay_value.is_some_and(javascript_is_object)
{
let mut nested = javascript_object_spread(base_value);
nested.extend(javascript_object_spread(overlay_value));
merged.insert(
key.to_string(),
JavaScriptSpreadValue::Json(Value::Object(finish_javascript_object_spread(
nested,
)?)),
);
}
}
Ok(Some(Value::Object(finish_javascript_object_spread(
merged,
)?)))
}
fn javascript_truthy(value: &Value) -> bool {
match value {
Value::Null | Value::Bool(false) => false,
Value::Number(value) => value.as_f64().is_none_or(|value| value != 0.0),
Value::String(value) => !value.is_empty(),
Value::Bool(true) | Value::Array(_) | Value::Object(_) => true,
}
}
fn javascript_is_object(value: &Value) -> bool {
matches!(value, Value::Array(_) | Value::Object(_))
}
fn javascript_property<'a>(value: Option<&'a Value>, key: &str) -> Option<&'a Value> {
value.and_then(Value::as_object)?.get(key)
}
fn javascript_object_spread(value: Option<&Value>) -> JavaScriptSpreadMap {
match value {
Some(Value::Object(object)) => object
.iter()
.map(|(key, value)| (key.clone(), JavaScriptSpreadValue::Json(value.clone())))
.collect(),
Some(Value::Array(values)) => values
.iter()
.enumerate()
.map(|(index, value)| {
(
index.to_string(),
JavaScriptSpreadValue::Json(value.clone()),
)
})
.collect(),
Some(Value::String(value)) => value
.encode_utf16()
.enumerate()
.map(|(index, unit)| {
let value = char::from_u32(u32::from(unit))
.map(|character| {
JavaScriptSpreadValue::Json(Value::String(character.to_string()))
})
.unwrap_or(JavaScriptSpreadValue::LoneSurrogate);
(index.to_string(), value)
})
.collect(),
Some(Value::Null | Value::Bool(_) | Value::Number(_)) | None => IndexMap::new(),
}
}
fn finish_javascript_object_spread(
spread: JavaScriptSpreadMap,
) -> Result<Map<String, Value>, PiCompatMergeError> {
spread
.into_iter()
.map(|(key, value)| match value {
JavaScriptSpreadValue::Json(value) => Ok((key, value)),
JavaScriptSpreadValue::LoneSurrogate => Err(PiCompatMergeError),
})
.collect()
}
#[cfg(test)]
mod compat_spread_tests {
use super::*;
use serde_json::json;
#[test]
fn compat_nested_values_follow_javascript_object_spread() {
let merged = merge_pi_compat(
Some(json!({
"openRouterRouting": ["first", "second"],
"chatTemplateKwargs": "ab",
"baseOnly": true
})),
Some(json!({
"openRouterRouting": null,
"chatTemplateKwargs": {"named": true},
"overlayOnly": true
})),
)
.expect("representable compat spread")
.expect("truthy overlay produces an object");
assert_eq!(
merged,
json!({
"openRouterRouting": {"0": "first", "1": "second"},
"chatTemplateKwargs": {"0": "a", "1": "b", "named": true},
"baseOnly": true,
"overlayOnly": true
})
);
}
#[test]
fn compat_falsy_overlay_returns_base_without_spreading() {
let base = Some(json!({"openRouterRouting": ["kept"]}));
assert_eq!(merge_pi_compat(base.clone(), Some(Value::Null)), Ok(base));
}
#[test]
fn compat_spread_rejects_unrepresentable_javascript_surrogates() {
assert_eq!(
merge_pi_compat(
Some(json!({"chatTemplateKwargs": "😀"})),
Some(json!({"chatTemplateKwargs": {"named": true}})),
),
Err(PiCompatMergeError)
);
}
#[test]
fn compat_spread_checks_surrogates_after_later_properties_override_them() {
assert_eq!(
merge_pi_compat(
Some(json!({"chatTemplateKwargs": "😀"})),
Some(json!({
"chatTemplateKwargs": {
"0": "repaired-high",
"1": "repaired-low",
"named": true
}
})),
),
Ok(Some(json!({
"chatTemplateKwargs": {
"0": "repaired-high",
"1": "repaired-low",
"named": true
}
})))
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,834 @@
#![cfg(test)]
//! 只读 native inspection 契约测试。
//!
//! ## 目标
//! **pinned Pi 决定什么是合法**。本仓库的 DTO 形状、网关支持范围、头部策略
//! 都不得成为"合法性"的来源:schema 接受的,managed 不得拒绝也不得丢值;
//! Pi 会发出的,网关不得降级;Pi 不接受的形态,我们也不假装支持。
//!
//! ## 六条裁决及其上游证据
//! C1【无损性】pinned schema 对 `thinkingLevelMap` 只约束 7 个标准键
//! (string|null;oracle 实证 `low: 2` 非法),额外键无约束(oracle 实证
//! `future: {nested:true}` 合法);`cost`/tier 同样接受未来键。managed 与
//! **effective 边界**(`effective_pi_model` 是 projection/routing/failover
//! 的共同入口)都必须无损,**空容器与缺席必须保持可区分**(`{}` 之于
//! thinkingLevelMap、`[]` 之于 cost.tiers 同理)。
//! 据此取代两个既有测试中把收窄固化为断言的部分:
//! `managed_narrowing_rejects_duplicates_or_unknown_thinking_keys` 与
//! `unknown_thinking_shape_is_lossless_for_composer_and_narrowed_separately`
//! (授权改写、可改名;DuplicateModelId 与 composer 无损两个语义由本套件
//! 直接接管)。若 `InvalidThinkingLevel` 变体因此不再可构造,授权移除。
//! C2【认证头】authorization / x-api-key / x-goog-api-key 是候选认证头,
//! 不是 protected。取值次序据 pinned SDK 与 composer 源码:authHeader 未
//! 设时显式头优先于 apiKey 合成值(Anthropic/OpenAI SDK 按"合成 auth →
//! 显式 headers"合并,后项覆盖);authHeader:true 时合成 Bearer 反过来
//! 优先(pinned provider-composer 在自定义头之后写入,且只写
//! Authorization、不动 x-api-key)。**header-only 凭证对四族都不是 Pi 原生
//! 可请求形态**:pinned `ModelRuntime.prepareRequest()` 先解析 auth,得不到
//! AuthResult 即抛 "Provider is not configured",在合并 headers 之前返回,
//! 而 headers 本身永不产生 AuthResult(Google adapter 更是无条件要 apiKey)。
//! 故无 apiKey 时维持 MissingCredential 降级,但认证头本身仍不得被报为
//! ProtectedHeader。
//! C3【传输层】放宽认证头不得连带放宽传输层:逐跳头完整覆盖并以 `proxy-`
//! **前缀**拒绝;契约 header 六分类中的 Gateway/HTTP owned(proxy trace /
//! CDN 客户端身份 / 分布式追踪)同样拒绝,清单与生产 forwarder 无条件
//! 剥离的集合对齐。
//! C4【deferred 值的校验时机】pinned `resolveConfigValueOrThrow()` 先执行
//! `!command` / 展开 `${ENV}`,再使用结果;**从不按 HTTP 头规则校验原始
//! 表达式**(命令输出 trim,环境模板不 trim,解析结果亦不做头合法性校验)。
//! 因此原始表达式含头非法字符、而解析结果合法的配置必须被接受;头合法性
//! 校验只能发生在物化之后(这是网关自身的传输约束,保留)。**字面量值仍在
//! 判定期校验,且该规则对 credential 与 header 一视同仁**——判定期说
//! "可代理"而每次物化必然失败,是判定层与执行层自相矛盾。
//!
//! C5【凭证种类,2026-08-02 新增,**已 request-capture 实证**】pinned
//! Anthropic 传输层以 `apiKey.includes("sk-ant-oat")`(子串,非前缀)判定
//! OAuth,命中则以 `Authorization: Bearer` 发送、**不发 x-api-key**,并附
//! `anthropic-beta: claude-code-20250219,oauth-2025-04-20,...`;**models.json
//! 里的字面量 apiKey 同样会走该分支**;该判定**只在 Anthropic 族**,同形
//! token 在 OpenAI 族仍按普通 Bearer 发送。因此网关不得把这类凭证当普通
//! x-api-key 代理:字面量命中即判定期 DirectOnly 并给结构化理由(不得是
//! MissingCredential);deferred 凭证判定期不可知,则**物化期解析出命中值
//! 时必须失败**,绝不发出错误的认证形态。
//! **完整 OAuth 传输(Bearer + oauth beta 值)不在前置 C 范围**——按
//! 项目范围划分,gateway 数据面属主工程,且需要
//! 先补 request-capture oracle。本工程只保证判定诚实、不发错凭证。
//! C6【entry 隔离,2026-08-02 新增】pinned Pi 逐 entry 做 TypeBox 判定,
//! 单个 entry 的取值错误(如 `contextWindow: 1e400`)只令该 entry 非法;
//! 整文件解析失败会让合法的兄弟 entry 被连坐隐藏,违反四层判定"每个
//! entry 独立"的核心设计。
//!
//! ## 实现方义务(不在本文件断言,交盲审核查)
//! O1 `compat` 需复现 JavaScript object-spread 对嵌套值(尤其数组)的语义;
//! O2 架构扫描器:cfg 布尔语义(`cfg(not(test))` 的生产代码必须被扫描)、
//! 不得按 `tests/` 路径整体跳过文件、嵌套模块须继承父层归属。
//!
//! ## 上游实证(request-capture,2026-08-02)
//! `scripts/pi-transport-capture.mjs` 以本地抓包端点作 baseUrl,用 pinned Pi
//! 的 adapter 真发请求,实测矩阵(据此 C2/C5 不再是"读源码推断"):
//! - anthropic 普通 key → `x-api-key: <key>`;
//! - anthropic `sk-ant-oat...` → `authorization: Bearer <token>` +
//! `anthropic-beta: claude-code-20250219,oauth-2025-04-20,...`,**无 x-api-key**;
//! - anthropic apiKey + 显式 `x-api-key` → 发**显式值**(显式覆盖合成);
//! - anthropic apiKey + 显式 `authorization` → 两者**并存**
//! (`authorization` 取显式值,`x-api-key` 取合成值);
//! - openai responses/completions + 显式 `authorization` → 发**显式值**;
//! - openai + `sk-ant-oat` 形状 token → 仍是普通 `Bearer`,无 OAuth 特殊处理;
//! - openai completions + 显式 `x-api-key` → 与合成 `authorization` **并存**。
//!
//! ## 残余
//! Google 族两值并存的优先级、头名大小写变体未实测;命令输出 trim 与环境模板
//! 不 trim 的差异属数据面语义,本只读面不断言;完整 OAuth 传输实现按范围表
//! 归主工程(harness 已就位,可直接扩为受冻结的 transport oracle);
//! 其余按盲审 finding 处理。
use super::composer::compose_explicit_custom_catalog;
use super::gateway::{assess_composition, PiGatewayCapability, PiGatewayReasonCode};
use super::model::{
effective_pi_model, validate_pi_managed_provider, PiConfigError, PiManagedAssessment,
PiManagedProviderConfig, PiManagementStatus, PiRawNativeValidity,
};
use super::native::{inspect_pi_native_catalog, inspect_pi_native_entry};
use super::raw_schema::evaluate_provider_value;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root")
.to_path_buf()
}
fn write_catalog(value: &Value) -> (tempfile::TempDir, PathBuf) {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("models.json");
fs::write(&path, serde_json::to_string_pretty(value).expect("encode")).expect("write");
(temp, path)
}
fn composed_catalog(value: Value) -> super::composer::PiNativeComposition {
let raw = evaluate_provider_value(&value);
compose_explicit_custom_catalog(
"candidate",
raw.valid_provider.as_ref().expect("raw-valid input"),
)
}
fn has_gateway_reason(
gateway: &super::gateway::PiGatewayAssessment,
code: PiGatewayReasonCode,
) -> bool {
gateway.reasons.iter().any(|reason| reason.code == code)
}
// ---------------------------------------------------------------------------
// pinned 夹具冻结——oracle 是上游出处工件,不得为过测试再生成
// ---------------------------------------------------------------------------
const PINNED_FIXTURES: &[(&str, &str)] = &[
(
"tests/fixtures/pi/native-oracle/composer-oracle-v1.json",
"f7e54bb84e5fd6d50e5762dc304834410fa73ef608c2f9c42475c5983f8e0cf5",
),
(
"tests/fixtures/pi/native-oracle/field-coverage-v1.json",
"b8b85e611cf1dbef86c611df185ba8ac2d64160087d0c6e47747f838a0fafe42",
),
(
"tests/fixtures/pi/native-oracle/provenance-v1.json",
"6b2f9570ecc58d54ebe3da094530fee1c8c0d4a8265fa9c3199218582cb8dbcb",
),
(
"tests/fixtures/pi/native-oracle/provider-schema.snapshot.json",
"e498c9f1b344eee1bd3c3ba74d1b648dcb835378cfad92800ec80078b825745c",
),
(
"tests/fixtures/pi/native-oracle/raw-oracle-v1.json",
"5aaa37160f96a0fe50867d900ca38c73f13aba769e156a883324368d9dbeeb9a",
),
(
"tests/fixtures/pi/native-oracle/transport-oracle-v1.json",
"b2c816e53b60da5cd6352d2c23934939e9f6dd0077971488fe9dd36fa723e855",
),
(
"tests/fixtures/pi/module-boundaries-v1.json",
"a69ab84fc0db323d5eb8ddc63555a9c69613dda865962f077cd8691639951b4d",
),
];
#[test]
fn certify_pinned_fixtures_are_frozen() {
for (relative, expected) in PINNED_FIXTURES {
let bytes = fs::read(repo_root().join(relative))
.unwrap_or_else(|e| panic!("read fixture {relative}: {e}"));
assert_eq!(
&format!("{:x}", Sha256::digest(bytes)),
expected,
"pinned fixture '{relative}' drifted; fixtures are upstream provenance \
artifacts and may only change under adjudication"
);
}
}
// ---------------------------------------------------------------------------
// 被取代测试中必须保留的语义,由本套件直接接管
// ---------------------------------------------------------------------------
#[test]
fn certify_duplicate_model_id_rejection_is_preserved() {
let config: PiManagedProviderConfig = serde_json::from_value(json!({
"api": "anthropic-messages",
"baseUrl": "https://dup.example",
"apiKey": "literal",
"models": [{"id": "same"}, {"id": "same"}]
}))
.expect("deserialize managed provider");
assert_eq!(
validate_pi_managed_provider(&config),
Err(PiConfigError::DuplicateModelId("same".into())),
"duplicate model ids must keep being rejected"
);
}
#[test]
fn certify_composer_thinking_losslessness_guard() {
let odd_map = json!({"high": "h", "future": {"opaque": true}});
let composition = composed_catalog(json!({
"api": "anthropic-messages",
"baseUrl": "https://thinking.example",
"apiKey": "literal",
"models": [{"id": "m", "thinkingLevelMap": odd_map}]
}));
assert_eq!(
composition.models[0].thinking_level_map.as_ref(),
Some(&odd_map),
"composer keeps the raw thinkingLevelMap value verbatim"
);
}
// ---------------------------------------------------------------------------
// C1:schema 合法值必须无损直到 effective 边界
// ---------------------------------------------------------------------------
#[test]
fn certify_managed_losslessness_through_effective_boundary() {
// 标准键只取 schema 允许的 string|null;额外键覆盖全部 JSON 类型。
let model_map = json!({
"high": "native-high",
"medium": null,
"future-level": "textual",
"vendor": {"opaque": {"nested": true}},
"budget": 42,
"enabled": true
});
// 与 model_map 共有 "high",用于绑定 override 的覆盖方向。
let override_map = json!({
"high": "override-high",
"low": "override-low",
"another-future": [1, "two", null]
});
let cost = json!({
"input": 1.5,
"output": 2.5,
"cacheRead": 0.5,
"cacheWrite": 0.25,
"futureRate": 9.0,
"tiers": [{
"inputTokensAbove": 100.0,
"input": 1.0,
"output": 2.0,
"cacheRead": 0.5,
"cacheWrite": 0.25,
"futureTierField": "opaque"
}]
});
let catalog = json!({
"providers": {
"thinking": {
"api": "anthropic-messages",
"baseUrl": "https://thinking.example",
"apiKey": "literal",
"models": [
{"id": "m", "thinkingLevelMap": model_map.clone(), "cost": cost.clone()},
{"id": "empty-map", "thinkingLevelMap": {}},
{"id": "absent-map"}
],
"modelOverrides": {"m": {"thinkingLevelMap": override_map.clone()}}
}
}
});
let (_temp, path) = write_catalog(&catalog);
let inspection = inspect_pi_native_entry(&path, "thinking", &BTreeMap::new())
.expect("inspect")
.expect("entry present");
assert_eq!(
inspection.diagnostic.raw_validity,
PiRawNativeValidity::Valid,
"the pinned schema accepts additional thinkingLevelMap and cost members"
);
assert_eq!(
inspection.diagnostic.managed_assessment,
PiManagedAssessment::Manageable,
"managed must not reject what the executed pin accepts"
);
assert_eq!(
inspection.diagnostic.management_status,
PiManagementStatus::Importable
);
// 以序列化后的字符串码断言,便于 InvalidThinkingLevel 变体被整体移除。
let reasons = serde_json::to_value(&inspection.diagnostic.reasons).expect("serialize reasons");
assert!(
!reasons
.as_array()
.expect("reasons array")
.iter()
.any(|reason| reason["code"] == "invalid_thinking_level"),
"no invalid_thinking_level reason may fire for schema-valid input"
);
let managed = inspection.managed_config.expect("managed config");
let round_trip = serde_json::to_value(&managed).expect("serialize managed config");
assert_eq!(
round_trip.pointer("/models/0/thinkingLevelMap"),
Some(&model_map),
"model thinkingLevelMap must round-trip losslessly"
);
assert_eq!(
round_trip.pointer("/modelOverrides/m/thinkingLevelMap"),
Some(&override_map),
"override thinkingLevelMap must round-trip losslessly"
);
assert_eq!(
round_trip.pointer("/models/0/cost"),
Some(&cost),
"cost and tier members must round-trip losslessly, including future keys"
);
// 空对象与缺席是两种原生形态,序列化必须保持可区分。
assert_eq!(
round_trip.pointer("/models/1/thinkingLevelMap"),
Some(&json!({})),
"an explicitly empty thinkingLevelMap must survive as an empty object"
);
assert_eq!(
round_trip.pointer("/models/2/thinkingLevelMap"),
None,
"an absent thinkingLevelMap must stay absent"
);
// effective 是 projection / runtime / routing / failover 的共同入口:
// DTO 修好后在这里二次收窄同样是丢值。
let effective = effective_pi_model(&managed, "m").expect("effective model");
let effective_value = serde_json::to_value(&effective).expect("serialize effective model");
let mut merged = model_map.as_object().expect("model map").clone();
for (key, value) in override_map.as_object().expect("override map") {
merged.insert(key.clone(), value.clone());
}
assert_eq!(
effective_value.pointer("/thinkingLevelMap"),
Some(&Value::Object(merged)),
"the effective model must carry the merged map losslessly, with override \
entries winning on shared keys"
);
assert_eq!(
effective_value.pointer("/cost"),
Some(&cost),
"the effective model must not drop cost members either"
);
}
// ---------------------------------------------------------------------------
// C2:候选认证头不是 protected
// ---------------------------------------------------------------------------
#[test]
fn certify_auth_candidate_headers_are_not_protected() {
// (a) Anthropic:显式 x-api-key 不得被拒,取值优先于 apiKey 合成值。
let explicit = composed_catalog(json!({
"api": "anthropic-messages",
"baseUrl": "https://anthropic.example",
"apiKey": "synthesized-secret",
"headers": {"x-api-key": "explicit-secret"},
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&explicit);
assert!(
!has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
"x-api-key is candidate-auth, not protected"
);
assert_eq!(gateway.capability, PiGatewayCapability::Proxyable);
let materialized = gateway.plans[0]
.materialize(&|_: &str| None)
.expect("materialize literal candidate");
assert_eq!(
materialized.headers[&http::HeaderName::from_static("x-api-key")],
http::HeaderValue::from_static("explicit-secret"),
"explicit config header value takes precedence over synthesized family auth"
);
// 认证头永远不进 failover 协议身份。
if let Some((_, protocol_headers)) = materialized.failover_protocol_identity() {
assert!(
!protocol_headers.contains_key(http::HeaderName::from_static("x-api-key")),
"auth headers must stay out of the failover protocol identity"
);
}
// (b) OpenAI-Responses:显式 authorization 同理。
let bearer = composed_catalog(json!({
"api": "openai-responses",
"baseUrl": "https://openai.example/v1",
"apiKey": "synthesized-secret",
"headers": {"authorization": "Bearer configured-token"},
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&bearer);
assert!(
!has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
"authorization is candidate-auth, not protected"
);
assert_eq!(gateway.capability, PiGatewayCapability::Proxyable);
assert_eq!(
gateway.plans[0]
.materialize(&|_: &str| None)
.expect("materialize")
.headers[&http::HeaderName::from_static("authorization")],
http::HeaderValue::from_static("Bearer configured-token")
);
// (c) Google:显式认证头与 apiKey 并存,不得拒绝、不得降级
// (取值优先级不断言——Google SDK 顺序无上游证据)。
let google = composed_catalog(json!({
"api": "google-generative-ai",
"baseUrl": "https://gemini.example",
"apiKey": "literal",
"headers": {"x-goog-api-key": "explicit-secret"},
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&google);
assert!(
!has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
"x-goog-api-key is candidate-auth, not protected"
);
assert_eq!(gateway.capability, PiGatewayCapability::Proxyable);
}
// ---------------------------------------------------------------------------
// C2:authHeader:true 时合成 Bearer 覆盖显式 Authorization
// ---------------------------------------------------------------------------
#[test]
fn certify_auth_header_bearer_overrides_explicit_authorization() {
let composition = composed_catalog(json!({
"api": "anthropic-messages",
"baseUrl": "https://anthropic.example",
"apiKey": "synthesized-secret",
"authHeader": true,
"headers": {"authorization": "Bearer explicit-token"},
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&composition);
assert_eq!(
gateway.capability,
PiGatewayCapability::Proxyable,
"an explicit authorization header must not downgrade an authHeader model"
);
let materialized = gateway.plans[0]
.materialize(&|_: &str| None)
.expect("materialize literal candidate");
assert_eq!(
materialized.headers[&http::HeaderName::from_static("authorization")],
http::HeaderValue::from_static("Bearer synthesized-secret"),
"with authHeader:true the synthesized Bearer wins (pinned composer writes it \
after the explicit headers)"
);
assert_eq!(
materialized.headers[&http::HeaderName::from_static("x-api-key")],
http::HeaderValue::from_static("synthesized-secret"),
"the Bearer step only rewrites Authorization; family auth stays synthesized"
);
}
// ---------------------------------------------------------------------------
// C3:传输层与网关自有身份头
// ---------------------------------------------------------------------------
/// 逐跳/传输头。末四项是合成名字:精确枚举无法覆盖,必须按 `proxy-` 前缀拒绝。
const HOP_BY_HOP_HEADERS: &[&str] = &[
"host",
"connection",
"content-length",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"proxy-future-extension",
"proxy-tenant-routing",
"proxy-x9",
];
/// Gateway/HTTP owned:proxy trace / CDN 客户端身份 / 分布式追踪。
/// 与生产 forwarder 无条件剥离的集合对齐,两侧同进退。
const GATEWAY_OWNED_HEADERS: &[&str] = &[
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-port",
"x-forwarded-proto",
"x-real-ip",
"cf-connecting-ip",
"cf-ipcountry",
"cf-ray",
"cf-visitor",
"true-client-ip",
"fastly-client-ip",
"x-azure-clientip",
"x-azure-fdid",
"x-azure-ref",
"akamai-origin-hop",
"x-akamai-config-log-detail",
"x-request-id",
"x-correlation-id",
"x-trace-id",
"x-amzn-trace-id",
"x-b3-traceid",
"x-b3-spanid",
"x-b3-parentspanid",
"x-b3-sampled",
"traceparent",
"tracestate",
];
#[test]
fn certify_transport_owned_headers_stay_protected() {
let cases = HOP_BY_HOP_HEADERS
.iter()
.map(|header| ("hop-by-hop", *header))
.chain(
GATEWAY_OWNED_HEADERS
.iter()
.map(|header| ("gateway-owned", *header)),
);
for (class, header) in cases {
let composition = composed_catalog(json!({
"api": "openai-responses",
"baseUrl": "https://openai.example/v1",
"apiKey": "literal",
"headers": {header: "value"},
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&composition);
assert!(
has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
"{class} header '{header}' must be reported as ProtectedHeader"
);
assert_eq!(
gateway.capability,
PiGatewayCapability::DirectOnly,
"{class} header '{header}' must keep the model DirectOnly"
);
}
}
// ---------------------------------------------------------------------------
// C2:header-only 凭证四族皆非 Pi 原生可请求形态
// ---------------------------------------------------------------------------
#[test]
fn certify_header_only_credentials_stay_direct_only() {
// pinned ModelRuntime.prepareRequest() 先解析 auth,得不到 AuthResult 即抛
// "Provider is not configured",在合并 headers 之前返回;headers 永不产生
// AuthResult。因此"只有认证头、无 apiKey"必须降级——但认证头本身依然是
// candidate-auth,不得被报为 ProtectedHeader。
for (api, header) in [
("anthropic-messages", "x-api-key"),
("openai-completions", "authorization"),
("openai-responses", "authorization"),
("google-generative-ai", "x-goog-api-key"),
] {
let composition = composed_catalog(json!({
"api": api,
"baseUrl": "https://example.test/v1",
"headers": {header: "header-secret"},
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&composition);
assert_eq!(
gateway.capability,
PiGatewayCapability::DirectOnly,
"{api}: header-only credentials are not a requestable pinned Pi form"
);
assert!(
has_gateway_reason(&gateway, PiGatewayReasonCode::MissingCredential),
"{api}: a missing apiKey must be reported as MissingCredential"
);
assert!(
!has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader),
"{api}: the auth header itself must not be reported as protected"
);
}
}
// ---------------------------------------------------------------------------
// C4:deferred 值只能在物化之后校验
// ---------------------------------------------------------------------------
#[test]
fn certify_deferred_header_values_are_validated_after_resolution() {
// 原始表达式含头非法字符(非可见 ASCII),解析结果合法。pinned Pi 先执行
// 再用结果,从不校验原始表达式,故这类配置必须被接受。
let expression = "!echo café";
let deferred = composed_catalog(json!({
"api": "openai-responses",
"baseUrl": "https://openai.example/v1",
"apiKey": "literal",
"headers": {"x-tenant": expression},
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&deferred);
assert!(
!has_gateway_reason(&gateway, PiGatewayReasonCode::InvalidHeaderValue),
"a deferred expression must not be validated as an HTTP header value before \
it is resolved"
);
assert_eq!(gateway.capability, PiGatewayCapability::Proxyable);
let materialized = gateway.plans[0]
.materialize(&|value: &str| (value == expression).then(|| "resolved-secret".to_string()))
.expect("materialize resolved candidate");
assert_eq!(
materialized.headers[&http::HeaderName::from_static("x-tenant")],
http::HeaderValue::from_static("resolved-secret"),
"the resolved value is what reaches the candidate"
);
// 防过度放宽:字面量(非 deferred)含头非法字符仍必须当场拒绝。
let literal = composed_catalog(json!({
"api": "openai-responses",
"baseUrl": "https://openai.example/v1",
"apiKey": "literal",
"headers": {"x-tenant": "café"},
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&literal);
assert!(
has_gateway_reason(&gateway, PiGatewayReasonCode::InvalidHeaderValue),
"a literal header value outside visible ASCII must still be rejected"
);
}
// ---------------------------------------------------------------------------
// C5:OAuth 凭证绝不能按 x-api-key 代理
// ---------------------------------------------------------------------------
#[test]
fn certify_oauth_credentials_are_never_proxied_as_api_key() {
// 字面量命中:判定期即可知,必须 DirectOnly 并给出结构化理由——
// 而不是宣称可代理再发出错误的认证形态。
let literal = composed_catalog(json!({
"api": "anthropic-messages",
"baseUrl": "https://anthropic.example",
"apiKey": "sk-ant-oat01-example-token",
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&literal);
assert_eq!(
gateway.capability,
PiGatewayCapability::DirectOnly,
"pinned Pi sends an sk-ant-oat credential as an OAuth Bearer with oauth beta \
headers; proxying it as x-api-key would send the wrong auth form"
);
assert!(
!gateway.reasons.is_empty(),
"the downgrade must carry a structured reason"
);
assert!(
!has_gateway_reason(&gateway, PiGatewayReasonCode::MissingCredential),
"the credential is present; MissingCredential would misreport the cause"
);
// deferred 凭证:判定期不可知,允许 Proxyable;但物化解析出命中值时必须
// 失败,绝不发出错误的认证形态。
let deferred = composed_catalog(json!({
"api": "anthropic-messages",
"baseUrl": "https://anthropic.example",
"apiKey": "!load-token",
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&deferred);
assert_eq!(
gateway.capability,
PiGatewayCapability::Proxyable,
"a deferred credential's kind is unknowable at plan time"
);
assert!(
gateway.plans[0]
.materialize(&|_: &str| Some("sk-ant-oat01-resolved".to_string()))
.is_err(),
"materialising a resolved OAuth credential must fail rather than send it as \
a plain api key"
);
// 防过度收窄:普通 Anthropic key 不受影响;非 Anthropic 族不适用该判定
// (pinned 的 includes 检查只在 Anthropic 传输层)。
for (api, key) in [
("anthropic-messages", "sk-ant-api03-plain"),
("openai-responses", "sk-ant-oat01-not-anthropic"),
] {
let plain = composed_catalog(json!({
"api": api,
"baseUrl": "https://plain.example/v1",
"apiKey": key,
"models": [{"id": "m"}]
}));
assert_eq!(
assess_composition(&plain).capability,
PiGatewayCapability::Proxyable,
"{api}: the OAuth rule must not over-reach"
);
}
}
// ---------------------------------------------------------------------------
// C4 扩展:字面量凭证必须在判定期校验
// ---------------------------------------------------------------------------
#[test]
fn certify_literal_credentials_are_validated_at_plan_time() {
// 判定期宣称"可代理"、而每次物化必然失败,是判定层与执行层自相矛盾。
let illegal = composed_catalog(json!({
"api": "openai-responses",
"baseUrl": "https://openai.example/v1",
"apiKey": "café",
"models": [{"id": "m"}]
}));
let gateway = assess_composition(&illegal);
assert!(
gateway.plans.is_empty() || gateway.plans[0].materialize(&|_: &str| None).is_err(),
"sanity: this literal credential can never materialise"
);
assert_eq!(
gateway.capability,
PiGatewayCapability::DirectOnly,
"a literal credential that can never materialise must not be judged proxyable"
);
assert!(
!gateway.reasons.is_empty(),
"the downgrade must carry a structured reason"
);
// 对称约束:deferred 凭证仍不得因原始表达式在判定期被拒(C4)。
let deferred = composed_catalog(json!({
"api": "openai-responses",
"baseUrl": "https://openai.example/v1",
"apiKey": "!echo café",
"models": [{"id": "m"}]
}));
assert_eq!(
assess_composition(&deferred).capability,
PiGatewayCapability::Proxyable,
"a deferred credential must not be validated as a header value before it is \
resolved"
);
}
// ---------------------------------------------------------------------------
// C1 扩展:空容器与缺席必须可区分
// ---------------------------------------------------------------------------
#[test]
fn certify_empty_containers_stay_distinct_from_absent() {
let base_rates = json!({
"input": 1.0, "output": 2.0, "cacheRead": 0.5, "cacheWrite": 0.25
});
let mut with_empty = base_rates.as_object().expect("rates").clone();
with_empty.insert("tiers".into(), json!([]));
let catalog = json!({
"providers": {
"tiers": {
"api": "anthropic-messages",
"baseUrl": "https://tiers.example",
"apiKey": "literal",
"models": [
{"id": "empty-tiers", "cost": Value::Object(with_empty)},
{"id": "absent-tiers", "cost": base_rates.clone()}
]
}
}
});
let (_temp, path) = write_catalog(&catalog);
let managed = inspect_pi_native_entry(&path, "tiers", &BTreeMap::new())
.expect("inspect")
.expect("entry present")
.managed_config
.expect("managed config");
let round_trip = serde_json::to_value(&managed).expect("serialize managed config");
assert_eq!(
round_trip.pointer("/models/0/cost/tiers"),
Some(&json!([])),
"an explicitly empty tiers list must survive as an empty list"
);
assert_eq!(
round_trip.pointer("/models/1/cost/tiers"),
None,
"an absent tiers list must stay absent"
);
}
// ---------------------------------------------------------------------------
// C6:单个 entry 的错误不得连坐兄弟 entry
// ---------------------------------------------------------------------------
#[test]
fn certify_one_bad_entry_does_not_hide_its_siblings() {
// pinned Pi 逐 entry 判定:`contextWindow: 1e400` 只令该 entry 非法。
// 整文件解析失败会让合法条目一并消失,破坏"每个 entry 独立"的判定设计。
let source = r#"{
"providers": {
"healthy": {
"api": "anthropic-messages",
"baseUrl": "https://healthy.example",
"apiKey": "literal",
"models": [{"id": "m"}]
},
"overflow": {
"api": "anthropic-messages",
"baseUrl": "https://overflow.example",
"apiKey": "literal",
"models": [{"id": "m", "contextWindow": 1e400}]
}
}
}"#;
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("models.json");
fs::write(&path, source).expect("write");
let diagnostics = inspect_pi_native_catalog(&path, &BTreeMap::new())
.expect("one malformed entry must not fail the whole catalog");
assert_eq!(diagnostics.len(), 2, "both entries must still be reported");
let healthy = diagnostics
.iter()
.find(|diagnostic| diagnostic.provider_key == "healthy")
.expect("healthy entry present");
assert_eq!(
healthy.raw_validity,
PiRawNativeValidity::Valid,
"a legal sibling must not be hidden by a malformed entry"
);
assert_eq!(healthy.management_status, PiManagementStatus::Importable);
let overflow = diagnostics
.iter()
.find(|diagnostic| diagnostic.provider_key == "overflow")
.expect("overflow entry present");
assert_ne!(
overflow.raw_validity,
PiRawNativeValidity::Valid,
"the out-of-range contextWindow entry itself must not be judged valid"
);
}
+324
View File
@@ -0,0 +1,324 @@
//! Exact-field access to Pi's shared `settings.json`.
//!
//! cc-switch owns only `defaultProvider` and `defaultModel`. Every other field
//! remains Pi/user-owned and survives each mutation unchanged.
use crate::error::AppError;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use super::shared_file::{
compare_exchange_shared_file_bytes, delete_shared_file, read_shared_file, replace_shared_file,
SharedFileSnapshot,
};
const MAX_PI_SETTINGS_BYTES: u64 = 1024 * 1024;
const MAX_WRITE_ATTEMPTS: usize = 3;
static SETTINGS_WRITE_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PiNativeDefaults {
#[serde(skip_serializing_if = "Option::is_none")]
pub default_provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_dir: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PiNativeDefaultsReceipt {
path: PathBuf,
before: SharedFileSnapshot,
after: SharedFileSnapshot,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PiNativeDefaultsRollback {
Restored,
Superseded,
}
impl PiNativeDefaultsReceipt {
/// Restore the exact file revision replaced by this write. A newer Pi/user
/// edit wins and is reported as Superseded rather than being overwritten.
pub(crate) fn rollback(&self) -> Result<PiNativeDefaultsRollback, AppError> {
let result = match self.before.bytes.as_deref() {
Some(bytes) => replace_shared_file(
&self.path,
&self.after.revision,
bytes,
MAX_PI_SETTINGS_BYTES,
None,
"Pi settings rollback",
)
.map(|_| ()),
None => delete_shared_file(
&self.path,
&self.after.revision,
MAX_PI_SETTINGS_BYTES,
"Pi settings rollback",
)
.map(|_| ()),
};
match result {
Ok(()) => Ok(PiNativeDefaultsRollback::Restored),
Err(AppError::Conflict(_)) => Ok(PiNativeDefaultsRollback::Superseded),
Err(error) => Err(error),
}
}
}
pub(crate) fn get_pi_settings_path() -> Result<PathBuf, AppError> {
Ok(super::native::get_pi_agent_dir()?.join("settings.json"))
}
pub(crate) fn read_pi_native_defaults() -> Result<PiNativeDefaults, AppError> {
read_pi_native_defaults_at(&get_pi_settings_path()?)
}
pub(crate) fn read_pi_native_defaults_at(path: &Path) -> Result<PiNativeDefaults, AppError> {
let document = read_settings_document(path)?;
let root = document.as_object().ok_or_else(|| {
AppError::Config(format!(
"Pi settings root must be an object: {}",
path.display()
))
})?;
Ok(PiNativeDefaults {
default_provider: optional_string(root, "defaultProvider", path)?,
default_model: optional_string(root, "defaultModel", path)?,
session_dir: optional_string(root, "sessionDir", path)?,
})
}
pub(crate) fn set_pi_native_default_with_receipt(
provider_key: &str,
model_id: &str,
) -> Result<PiNativeDefaultsReceipt, AppError> {
if provider_key.trim().is_empty() || model_id.trim().is_empty() {
return Err(AppError::InvalidInput(
"Pi default provider and model must be non-empty".to_string(),
));
}
mutate_settings_document(&get_pi_settings_path()?, |root| {
root.insert(
"defaultProvider".to_string(),
Value::String(provider_key.to_string()),
);
root.insert(
"defaultModel".to_string(),
Value::String(model_id.to_string()),
);
Ok(())
})
}
fn optional_string(
root: &Map<String, Value>,
key: &str,
path: &Path,
) -> Result<Option<String>, AppError> {
match root.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(value)) => Ok(Some(value.clone())),
Some(_) => Err(AppError::Config(format!(
"Pi settings field '{key}' must be a string: {}",
path.display()
))),
}
}
fn mutate_settings_document(
path: &Path,
mut mutator: impl FnMut(&mut Map<String, Value>) -> Result<(), AppError>,
) -> Result<PiNativeDefaultsReceipt, AppError> {
let _guard = SETTINGS_WRITE_LOCK
.lock()
.map_err(|error| AppError::Config(format!("Pi settings lock is poisoned: {error}")))?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| AppError::io(parent, error))?;
}
for _ in 0..MAX_WRITE_ATTEMPTS {
let before = read_shared_file(path, MAX_PI_SETTINGS_BYTES, "Pi settings")?;
let mut document = match before.bytes.as_deref() {
Some(bytes) => {
serde_json::from_slice(bytes).map_err(|error| AppError::json(path, error))?
}
None => Value::Object(Map::new()),
};
let root = document.as_object_mut().ok_or_else(|| {
AppError::Config(format!(
"Pi settings root must be an object: {}",
path.display()
))
})?;
mutator(root)?;
let mut serialized = serde_json::to_vec_pretty(&document)
.map_err(|source| AppError::JsonSerialize { source })?;
serialized.push(b'\n');
match compare_exchange_shared_file_bytes(
path,
before.bytes.as_deref(),
&serialized,
MAX_PI_SETTINGS_BYTES,
None,
"Pi settings",
) {
Ok(after) => {
return Ok(PiNativeDefaultsReceipt {
path: path.to_path_buf(),
before,
after,
})
}
Err(AppError::Conflict(_)) => continue,
Err(error) => return Err(error),
}
}
Err(AppError::Conflict(format!(
"Pi settings changed concurrently too many times: {}",
path.display()
)))
}
fn read_settings_document(path: &Path) -> Result<Value, AppError> {
match read_shared_file(path, MAX_PI_SETTINGS_BYTES, "Pi settings")?.bytes {
Some(bytes) => serde_json::from_slice(&bytes).map_err(|error| AppError::json(path, error)),
None => Ok(Value::Object(Map::new())),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn default_patch_preserves_every_unowned_field() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("settings.json");
fs::write(
&path,
serde_json::to_vec_pretty(&json!({
"theme": "custom",
"packages": ["npm:foreign"],
"sessionDir": "/tmp/pi-sessions",
"defaultProvider": "old",
"defaultModel": "old-model"
}))
.expect("serialize"),
)
.expect("write");
mutate_settings_document(&path, |root| {
root.insert("defaultProvider".into(), json!("managed"));
root.insert("defaultModel".into(), json!("model"));
Ok(())
})
.expect("mutate");
let saved: Value = serde_json::from_slice(&fs::read(&path).expect("read")).expect("parse");
assert_eq!(saved["theme"], "custom");
assert_eq!(saved["packages"], json!(["npm:foreign"]));
assert_eq!(saved["sessionDir"], "/tmp/pi-sessions");
assert_eq!(saved["defaultProvider"], "managed");
assert_eq!(saved["defaultModel"], "model");
}
#[test]
fn external_rename_during_settings_patch_is_reparsed_before_retry() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("settings.json");
fs::write(
&path,
br#"{"theme":"before","defaultProvider":"old","defaultModel":"old"}"#,
)
.expect("seed");
crate::pi_config::shared_file::replace_before_next_compare_exchange(
&path,
br#"{"theme":"external","packages":["foreign"],"defaultProvider":"old","defaultModel":"old"}"#,
);
mutate_settings_document(&path, |root| {
root.insert("defaultProvider".into(), json!("managed"));
root.insert("defaultModel".into(), json!("model"));
Ok(())
})
.expect("retry mutation");
let saved: Value = serde_json::from_slice(&fs::read(&path).expect("read")).expect("parse");
assert_eq!(saved["theme"], "external");
assert_eq!(saved["packages"], json!(["foreign"]));
assert_eq!(saved["defaultProvider"], "managed");
assert_eq!(saved["defaultModel"], "model");
}
#[cfg(unix)]
#[test]
fn settings_symlink_is_rejected() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().expect("tempdir");
let target = temp.path().join("target.json");
let path = temp.path().join("settings.json");
fs::write(&target, "{}").expect("target");
symlink(&target, &path).expect("symlink");
assert!(read_pi_native_defaults_at(&path).is_err());
}
#[test]
fn rollback_receipt_never_overwrites_a_newer_external_default() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("settings.json");
fs::write(
&path,
serde_json::to_vec_pretty(&json!({
"theme": "before",
"defaultProvider": "old",
"defaultModel": "old-model"
}))
.expect("serialize"),
)
.expect("write");
let receipt = mutate_settings_document(&path, |root| {
root.insert("defaultProvider".into(), json!("attempted"));
root.insert("defaultModel".into(), json!("attempted-model"));
Ok(())
})
.expect("write attempted defaults");
fs::write(
&path,
serde_json::to_vec_pretty(&json!({
"theme": "external",
"defaultProvider": "external",
"defaultModel": "external-model"
}))
.expect("serialize external"),
)
.expect("external write");
assert_eq!(
receipt.rollback().expect("rollback decision"),
PiNativeDefaultsRollback::Superseded
);
assert_eq!(
read_pi_native_defaults_at(&path)
.expect("live defaults")
.default_provider
.as_deref(),
Some("external")
);
assert_eq!(
read_settings_document(&path).expect("live document")["theme"],
"external"
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Prompt {
pub id: String,
pub name: String,
+6 -1
View File
@@ -26,6 +26,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::OpenCode => get_opencode_dir(),
AppType::OpenClaw => get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
AppType::Pi => crate::pi_config::native::get_pi_agent_dir()?,
AppType::ClaudeDesktop => unreachable!("handled above"),
};
@@ -33,7 +34,11 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::Claude => "CLAUDE.md",
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
AppType::GrokBuild
| AppType::OpenCode
| AppType::OpenClaw
| AppType::Hermes
| AppType::Pi => "AGENTS.md",
AppType::ClaudeDesktop => unreachable!("handled above"),
};
+148
View File
@@ -43,6 +43,84 @@ pub struct Provider {
pub in_failover_queue: bool,
}
/// IPC/service input for creating or editing a provider.
///
/// This deliberately is not the hydrated [`Provider`] read projection. In
/// particular, callers cannot pass a DAO aggregate back into the provider-row
/// writer without first crossing the service boundary, where endpoint
/// ownership is checked.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderMutationInput {
pub id: String,
pub name: String,
#[serde(rename = "settingsConfig")]
pub settings_config: Value,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "websiteUrl")]
pub website_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "createdAt")]
pub created_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "sortIndex")]
pub sort_index: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")]
pub icon_color: Option<String>,
#[serde(default)]
#[serde(rename = "inFailoverQueue")]
pub in_failover_queue: bool,
}
impl From<ProviderMutationInput> for Provider {
fn from(input: ProviderMutationInput) -> Self {
Self {
id: input.id,
name: input.name,
settings_config: input.settings_config,
website_url: input.website_url,
category: input.category,
created_at: input.created_at,
sort_index: input.sort_index,
notes: input.notes,
meta: input.meta,
icon: input.icon,
icon_color: input.icon_color,
in_failover_queue: input.in_failover_queue,
}
}
}
/// A provider row and every endpoint owned by that row.
///
/// SQLite stores endpoints separately from provider metadata. This aggregate
/// is the only lossless DAO boundary; legacy `Provider` reads are projections
/// of it for API compatibility.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderAggregate {
pub provider: Provider,
#[serde(default)]
pub endpoints: IndexMap<String, crate::settings::CustomEndpoint>,
}
impl ProviderAggregate {
pub(crate) fn into_provider(mut self) -> Provider {
self.provider
.meta
.get_or_insert_with(ProviderMeta::default)
.custom_endpoints = self.endpoints.into_iter().collect();
self.provider
}
}
impl Provider {
/// 从现有ID创建供应商
pub fn with_id(
@@ -67,6 +145,72 @@ impl Provider {
}
}
pub(crate) fn row_content_fingerprint(&self) -> String {
use sha2::{Digest, Sha256};
fn hash_canonical(value: &serde_json::Value, hasher: &mut Sha256) {
match value {
serde_json::Value::Null => hasher.update(b"n"),
serde_json::Value::Bool(value) => {
hasher.update(b"b");
hasher.update([*value as u8]);
}
serde_json::Value::Number(value) => {
let text = value.to_string();
hasher.update(b"#");
hasher.update((text.len() as u64).to_le_bytes());
hasher.update(text.as_bytes());
}
serde_json::Value::String(value) => {
hasher.update(b"s");
hasher.update((value.len() as u64).to_le_bytes());
hasher.update(value.as_bytes());
}
serde_json::Value::Array(items) => {
hasher.update(b"[");
hasher.update((items.len() as u64).to_le_bytes());
for item in items {
hash_canonical(item, hasher);
}
hasher.update(b"]");
}
serde_json::Value::Object(map) => {
hasher.update(b"{");
hasher.update((map.len() as u64).to_le_bytes());
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for key in keys {
hasher.update((key.len() as u64).to_le_bytes());
hasher.update(key.as_bytes());
hash_canonical(&map[key.as_str()], hasher);
}
hasher.update(b"}");
}
}
}
let mut meta = serde_json::to_value(&self.meta).unwrap_or(serde_json::Value::Null);
if let serde_json::Value::Object(map) = &mut meta {
map.remove("custom_endpoints");
map.remove("customEndpoints");
}
let mut hasher = Sha256::new();
for part in [
serde_json::Value::String(self.name.clone()),
self.settings_config.clone(),
serde_json::to_value(&self.website_url).unwrap_or(serde_json::Value::Null),
serde_json::to_value(&self.category).unwrap_or(serde_json::Value::Null),
serde_json::to_value(&self.notes).unwrap_or(serde_json::Value::Null),
serde_json::to_value(&self.icon).unwrap_or(serde_json::Value::Null),
serde_json::to_value(&self.icon_color).unwrap_or(serde_json::Value::Null),
meta,
] {
hash_canonical(&part, &mut hasher);
hasher.update([0u8]);
}
format!("{:x}", hasher.finalize())
}
pub fn is_codex_oauth(&self) -> bool {
self.provider_type() == Some("codex_oauth")
}
@@ -191,6 +335,10 @@ impl Provider {
str_at(settings.get("base_url")),
str_at(settings.get("api_key")),
),
AppType::Pi => (
str_at(settings.get("baseUrl")),
str_at(settings.get("apiKey")),
),
// OpenClaw (openclaw.json) flattens credentials at the top level, camelCase.
AppType::OpenClaw => (
str_at(settings.get("baseUrl")),
+7
View File
@@ -4,6 +4,7 @@
use crate::app_config::AppType;
use crate::proxy::usage::parser::TokenUsage;
use crate::proxy::usage::InputTokenSemantics;
use serde_json::Value;
/// 使用量解析器类型别名
@@ -31,6 +32,8 @@ pub struct UsageParserConfig {
pub model_extractor: StreamModelExtractor,
/// 流式 usage 事件预过滤器
pub stream_event_filter: Option<StreamUsageEventFilter>,
/// Semantics of `TokenUsage::input_tokens` produced by these parsers.
pub input_token_semantics: InputTokenSemantics,
/// 应用类型字符串(用于日志记录)
pub app_type_str: &'static str,
}
@@ -141,6 +144,7 @@ pub const CLAUDE_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
response_parser: TokenUsage::from_claude_response,
model_extractor: claude_model_extractor,
stream_event_filter: Some(claude_stream_usage_event_filter),
input_token_semantics: InputTokenSemantics::FreshExcludesCache,
app_type_str: "claude",
};
@@ -150,6 +154,7 @@ pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
response_parser: TokenUsage::from_openai_response,
model_extractor: openai_model_extractor,
stream_event_filter: Some(openai_stream_usage_event_filter),
input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets,
app_type_str: "codex",
};
@@ -159,6 +164,7 @@ pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
response_parser: TokenUsage::from_codex_response_auto,
model_extractor: codex_auto_model_extractor,
stream_event_filter: Some(codex_stream_usage_event_filter),
input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets,
app_type_str: "codex",
};
@@ -168,6 +174,7 @@ pub const GEMINI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
response_parser: TokenUsage::from_gemini_response,
model_extractor: gemini_model_extractor,
stream_event_filter: Some(gemini_stream_usage_event_filter),
input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets,
app_type_str: "gemini",
};
+11 -1
View File
@@ -39,7 +39,7 @@ use super::{
server::ProxyState,
sse::{strip_sse_field, take_sse_block},
types::*,
usage::parser::TokenUsage,
usage::{parser::TokenUsage, InputTokenSemantics},
ProxyError,
};
use crate::app_config::AppType;
@@ -338,6 +338,7 @@ async fn write_claude_usage_log(state: &ProxyState, log: ClaudeUsageLog) {
&log.model,
&log.request_model,
&log.outbound_model,
InputTokenSemantics::FreshExcludesCache,
log.usage,
log.latency_ms,
None,
@@ -465,6 +466,7 @@ async fn handle_claude_transform(
&model,
&request_model,
&outbound_model,
InputTokenSemantics::FreshExcludesCache,
usage,
latency_ms,
first_token_ms,
@@ -1133,6 +1135,7 @@ async fn handle_codex_responses_namespace_restore(
&model,
&request_model,
&outbound_model,
InputTokenSemantics::TotalIncludesCacheBuckets,
usage,
latency_ms,
None,
@@ -1245,6 +1248,7 @@ async fn handle_codex_chat_to_responses_transform(
&model,
&request_model,
&outbound_model,
InputTokenSemantics::TotalIncludesCacheBuckets,
usage,
latency_ms,
first_token_ms,
@@ -1366,6 +1370,7 @@ async fn handle_codex_chat_to_responses_transform(
&model,
&request_model,
&outbound_model,
InputTokenSemantics::TotalIncludesCacheBuckets,
usage,
latency_ms,
None,
@@ -1531,6 +1536,7 @@ async fn handle_codex_anthropic_to_responses_transform(
&model,
&request_model,
&outbound_model,
InputTokenSemantics::TotalIncludesCacheBuckets,
usage,
latency_ms,
None,
@@ -1618,6 +1624,7 @@ fn build_codex_anthropic_sse_response(
&model,
&request_model,
&outbound_model,
InputTokenSemantics::TotalIncludesCacheBuckets,
usage,
latency_ms,
first_token_ms,
@@ -2590,6 +2597,7 @@ fn log_forward_error(
is_streaming,
Some(ctx.session_id.clone()),
None,
InputTokenSemantics::FreshExcludesCache,
) {
log::warn!("记录失败请求日志失败: {e}");
}
@@ -2607,6 +2615,7 @@ async fn log_usage(
model: &str,
request_model: &str,
outbound_model: &str,
input_token_semantics: InputTokenSemantics,
usage: TokenUsage,
latency_ms: u64,
first_token_ms: Option<u64>,
@@ -2640,6 +2649,7 @@ async fn log_usage(
model.to_string(),
request_model.to_string(),
pricing_model.to_string(),
input_token_semantics,
usage,
multiplier,
latency_ms,
+2
View File
@@ -21,6 +21,8 @@ pub(crate) mod json_canonical;
pub mod log_codes;
pub mod media_sanitizer;
pub mod model_mapper;
pub(crate) mod pi_handler;
pub(crate) mod pi_runtime;
pub mod provider_router;
pub mod providers;
pub mod response_processor;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+31 -12
View File
@@ -29,6 +29,16 @@ impl ProviderRouter {
}
}
async fn app_proxy_config(
&self,
app_type: &str,
) -> Result<crate::proxy::types::AppProxyConfig, AppError> {
if app_type == AppType::Pi.as_str() {
return Ok(crate::settings::get_pi_app_proxy_config());
}
self.db.get_proxy_config_for_app(app_type).await
}
/// 选择可用的供应商(支持故障转移)
///
/// 返回按优先级排序的可用供应商列表:
@@ -40,7 +50,7 @@ impl ProviderRouter {
let mut circuit_open_count = 0usize;
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
let auto_failover_enabled = match self.app_proxy_config(app_type).await {
Ok(config) => config.auto_failover_enabled,
Err(e) => {
log::error!("[{app_type}] 读取 proxy_config 失败: {e},默认禁用故障转移");
@@ -132,7 +142,7 @@ impl ProviderRouter {
error_msg: Option<String>,
) -> Result<(), AppError> {
// 1. 按应用独立获取熔断器配置
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
let failure_threshold = match self.app_proxy_config(app_type).await {
Ok(app_config) => app_config.circuit_failure_threshold,
Err(_) => 5, // 默认值
};
@@ -251,7 +261,7 @@ impl ProviderRouter {
let app_type = key.split(':').next().unwrap_or("claude");
// 按应用独立读取熔断器配置
let config = match self.db.get_proxy_config_for_app(app_type).await {
let config = match self.app_proxy_config(app_type).await {
Ok(app_config) => crate::proxy::circuit_breaker::CircuitBreakerConfig {
failure_threshold: app_config.circuit_failure_threshold,
success_threshold: app_config.circuit_success_threshold,
@@ -348,8 +358,10 @@ mod tests {
let provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.reconcile_provider_fixture("claude", &provider_a)
.unwrap();
db.reconcile_provider_fixture("claude", &provider_b)
.unwrap();
db.set_current_provider("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap();
@@ -374,8 +386,10 @@ mod tests {
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
provider_b.sort_index = Some(1);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.reconcile_provider_fixture("claude", &provider_a)
.unwrap();
db.reconcile_provider_fixture("claude", &provider_b)
.unwrap();
db.set_current_provider("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap();
@@ -407,8 +421,10 @@ mod tests {
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
provider_b.sort_index = Some(1);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.reconcile_provider_fixture("claude", &provider_a)
.unwrap();
db.reconcile_provider_fixture("claude", &provider_b)
.unwrap();
db.set_current_provider("claude", "a").unwrap();
// 只把 b 加入故障转移队列(模拟“当前供应商不在队列里”的常见配置)
@@ -444,8 +460,10 @@ mod tests {
let provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.reconcile_provider_fixture("claude", &provider_a)
.unwrap();
db.reconcile_provider_fixture("claude", &provider_b)
.unwrap();
db.add_to_failover_queue("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap();
@@ -485,7 +503,8 @@ mod tests {
let provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
db.save_provider("claude", &provider_a).unwrap();
db.reconcile_provider_fixture("claude", &provider_a)
.unwrap();
db.add_to_failover_queue("claude", "a").unwrap();
// 启用自动故障转移
+10 -2
View File
@@ -205,7 +205,11 @@ impl ProviderType {
ProviderType::Gemini
}
AppType::GrokBuild => ProviderType::Codex,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => ProviderType::Codex,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::Pi => {
// Generic callers cannot infer Pi's wire family from AppType;
// the dedicated Pi runtime routes by effective model API.
ProviderType::Codex
}
}
}
@@ -259,7 +263,11 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
AppType::GrokBuild => Box::new(CodexAdapter::new()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Box::new(CodexAdapter::new()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::Pi => {
// Pi requests use the dedicated per-model adapter path. Keep the
// generic fallback deterministic for non-routing utilities.
Box::new(CodexAdapter::new())
}
}
}
+16 -5
View File
@@ -254,11 +254,11 @@ pub async fn handle_non_streaming(
spawn_log_usage(
state,
ctx,
parser_config.input_token_semantics,
usage,
&model,
&ctx.request_model,
status.as_u16(),
false,
);
} else {
let model = json_value
@@ -271,11 +271,11 @@ pub async fn handle_non_streaming(
spawn_log_usage(
state,
ctx,
parser_config.input_token_semantics,
TokenUsage::default(),
&model,
&ctx.request_model,
status.as_u16(),
false,
);
log::debug!(
"[{}] 未能解析 usage 信息,跳过记录",
@@ -291,11 +291,11 @@ pub async fn handle_non_streaming(
spawn_log_usage(
state,
ctx,
parser_config.input_token_semantics,
TokenUsage::default(),
ctx.outbound_model.as_deref().unwrap_or(&ctx.request_model),
&ctx.request_model,
status.as_u16(),
false,
);
}
} else {
@@ -488,6 +488,7 @@ pub(crate) fn create_usage_collector(
let start_time = ctx.start_time;
let stream_parser = parser_config.stream_parser;
let model_extractor = parser_config.model_extractor;
let input_token_semantics = parser_config.input_token_semantics;
let session_id = ctx.session_id.clone();
Some(SseUsageCollector::new(
@@ -512,6 +513,7 @@ pub(crate) fn create_usage_collector(
&model,
&request_model,
&outbound_model,
input_token_semantics,
usage,
latency_ms,
first_token_ms,
@@ -538,6 +540,7 @@ pub(crate) fn create_usage_collector(
&model,
&request_model,
&outbound_model,
input_token_semantics,
TokenUsage::default(),
latency_ms,
first_token_ms,
@@ -557,11 +560,11 @@ pub(crate) fn create_usage_collector(
fn spawn_log_usage(
state: &ProxyState,
ctx: &RequestContext,
input_token_semantics: super::usage::InputTokenSemantics,
usage: TokenUsage,
model: &str,
request_model: &str,
status_code: u16,
is_streaming: bool,
) {
// Check enable_logging before spawning the log task
if let Ok(config) = state.config.try_read() {
@@ -591,10 +594,11 @@ fn spawn_log_usage(
&model,
&request_model,
&outbound_model,
input_token_semantics,
usage,
latency_ms,
None,
is_streaming,
false,
status_code,
Some(session_id),
)
@@ -624,6 +628,7 @@ async fn log_usage_internal(
model: &str,
request_model: &str,
outbound_model: &str,
input_token_semantics: super::usage::InputTokenSemantics,
usage: TokenUsage,
latency_ms: u64,
first_token_ms: Option<u64>,
@@ -661,6 +666,7 @@ async fn log_usage_internal(
model.to_string(),
request_model.to_string(),
pricing_model.to_string(),
input_token_semantics,
usage,
multiplier,
latency_ms,
@@ -1001,6 +1007,8 @@ mod tests {
codex_chat_history: Arc::new(CodexChatHistoryStore::default()),
app_handle: None,
failover_manager: Arc::new(FailoverSwitchManager::new(db)),
pi_runtime: Arc::new(crate::proxy::pi_runtime::PiRuntimeStore::default()),
pi_server_generation: 0,
}
}
@@ -1072,6 +1080,7 @@ mod tests {
"resp-model",
"req-model",
"req-model",
crate::proxy::usage::InputTokenSemantics::FreshExcludesCache,
usage,
10,
None,
@@ -1142,6 +1151,7 @@ mod tests {
"resp-model",
"req-model",
"outbound-model",
crate::proxy::usage::InputTokenSemantics::FreshExcludesCache,
usage,
10,
None,
@@ -1222,6 +1232,7 @@ mod tests {
"resp-model",
"req-model",
"req-model",
crate::proxy::usage::InputTokenSemantics::FreshExcludesCache,
usage,
10,
None,
+22
View File
@@ -12,6 +12,7 @@ use super::{
failover_switch::FailoverSwitchManager,
handlers,
log_codes::srv as log_srv,
pi_runtime::PiRuntimeStore,
provider_router::ProviderRouter,
providers::{codex_chat_history::CodexChatHistoryStore, gemini_shadow::GeminiShadowStore},
types::*,
@@ -48,6 +49,11 @@ pub struct ProxyState {
pub app_handle: Option<tauri::AppHandle>,
/// 故障转移切换管理器
pub failover_manager: Arc<FailoverSwitchManager>,
/// Immutable Pi catalog publication point shared with `ProxyService`.
pub pi_runtime: Arc<PiRuntimeStore>,
/// Listener instance identity. A runtime built for an older listener can
/// never admit requests through this state.
pub pi_server_generation: u64,
}
/// 代理HTTP服务器
@@ -57,6 +63,7 @@ pub struct ProxyServer {
shutdown_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
/// 服务器任务句柄,用于等待服务器实际关闭
server_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
pi_server_generation: u64,
}
impl ProxyServer {
@@ -64,6 +71,8 @@ impl ProxyServer {
config: ProxyConfig,
db: Arc<Database>,
app_handle: Option<tauri::AppHandle>,
pi_runtime: Arc<PiRuntimeStore>,
pi_server_generation: u64,
) -> Self {
// 创建共享的 ProviderRouter(熔断器状态将跨所有请求保持)
let provider_router = Arc::new(ProviderRouter::new(db.clone()));
@@ -81,6 +90,8 @@ impl ProxyServer {
codex_chat_history: Arc::new(CodexChatHistoryStore::default()),
app_handle,
failover_manager,
pi_runtime,
pi_server_generation,
};
Self {
@@ -88,9 +99,14 @@ impl ProxyServer {
state,
shutdown_tx: Arc::new(RwLock::new(None)),
server_handle: Arc::new(RwLock::new(None)),
pi_server_generation,
}
}
pub(crate) fn pi_server_generation(&self) -> u64 {
self.pi_server_generation
}
pub async fn start(&self) -> Result<ProxyServerInfo, ProxyError> {
// 检查是否已在运行
if self.shutdown_tx.read().await.is_some() {
@@ -364,6 +380,12 @@ impl ProxyServer {
.route("/gemini/v1beta/*path", any(handlers::handle_gemini))
// Gemini 的 GA 版本也叫 /v1,给原 SDK 留一条出口
.route("/gemini/v1/*path", any(handlers::handle_gemini))
// Pi native SDK requests retain their family-specific path below
// the opaque provider route token.
.route(
"/pi/:route_token/*path",
any(super::pi_handler::handle_pi_native),
)
// 提高默认请求体大小限制(避免 413 Payload Too Large
.layer(DefaultBodyLimit::max(200 * 1024 * 1024))
.with_state(self.state.clone())
+20 -3
View File
@@ -4,15 +4,32 @@
//! 防止并发切换导致 is_current 与 Live 备份不一致。
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::{Arc, OnceLock};
use tokio::sync::{Mutex, OwnedMutexGuard, RwLock};
type PerAppLocks = Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>;
/// 每个应用类型一把互斥锁,保证同一应用的切换操作串行执行。
///
/// 不同应用之间(如 Claude 和 Codex)可以并行切换。
#[derive(Clone, Default)]
#[derive(Clone)]
pub struct SwitchLockManager {
locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
locks: PerAppLocks,
}
impl Default for SwitchLockManager {
fn default() -> Self {
// Some commands construct a short-lived AppState around the shared
// database before running a blocking sync. A per-ProxyService map
// would give those paths a different lock and defeat serialization
// with provider rename/switch operations in the primary AppState.
static LOCKS: OnceLock<PerAppLocks> = OnceLock::new();
Self {
locks: LOCKS
.get_or_init(|| Arc::new(RwLock::new(HashMap::new())))
.clone(),
}
}
}
impl SwitchLockManager {
+11
View File
@@ -116,6 +116,17 @@ pub struct ProxyTakeoverStatus {
pub grokbuild: bool,
pub opencode: bool,
pub openclaw: bool,
pub pi: bool,
pub pi_operational_state: PiTakeoverOperationalState,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PiTakeoverOperationalState {
#[default]
Disabled,
Active,
Degraded,
}
/// Provider健康状态
+32 -20
View File
@@ -3,6 +3,7 @@
//! 使用高精度 Decimal 类型避免浮点数精度问题
use super::parser::TokenUsage;
use super::semantics::InputTokenSemantics;
use rust_decimal::Decimal;
use std::str::FromStr;
@@ -46,13 +47,17 @@ impl CostCalculator {
pricing: &ModelPricing,
cost_multiplier: Decimal,
) -> CostBreakdown {
Self::calculate_with_cache_semantics(usage, pricing, cost_multiplier, false)
Self::calculate_with_input_semantics(
InputTokenSemantics::FreshExcludesCache,
usage,
pricing,
cost_multiplier,
)
}
/// 按 app_type 选择输入 token 语义后计算成本。
///
/// Codex/OpenAI Responses 与 Gemini 的输入 token 字段包含 cache read 部分;
/// Claude/Anthropic 的 input_tokens 已经是 fresh input。
/// Compatibility helper for existing callers. Live request paths use
/// [`Self::calculate_with_input_semantics`] so product app ownership never
/// stands in for the actual response parser/wire family.
pub fn calculate_for_app(
app_type: &str,
usage: &TokenUsage,
@@ -61,32 +66,37 @@ impl CostCalculator {
) -> CostBreakdown {
let input_includes_cache_read =
crate::services::sql_helpers::is_cache_inclusive_app(app_type);
Self::calculate_with_cache_semantics(
Self::calculate_with_input_semantics(
if input_includes_cache_read {
InputTokenSemantics::TotalIncludesCacheBuckets
} else {
InputTokenSemantics::FreshExcludesCache
},
usage,
pricing,
cost_multiplier,
input_includes_cache_read,
)
}
fn calculate_with_cache_semantics(
pub fn calculate_with_input_semantics(
input_semantics: InputTokenSemantics,
usage: &TokenUsage,
pricing: &ModelPricing,
cost_multiplier: Decimal,
input_includes_cache_read: bool,
) -> CostBreakdown {
let million = Decimal::from(1_000_000);
// 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)
.saturating_sub(usage.cache_creation_tokens)
} else {
usage.input_tokens
};
let billable_input_tokens =
if input_semantics == InputTokenSemantics::TotalIncludesCacheBuckets {
usage
.input_tokens
.saturating_sub(usage.cache_read_tokens)
.saturating_sub(usage.cache_creation_tokens)
} else {
usage.input_tokens
};
// 各项基础成本(不含倍率)
let input_cost =
@@ -112,13 +122,15 @@ impl CostCalculator {
}
}
pub fn try_calculate_for_app(
app_type: &str,
pub fn try_calculate_with_input_semantics(
input_semantics: InputTokenSemantics,
usage: &TokenUsage,
pricing: Option<&ModelPricing>,
cost_multiplier: Decimal,
) -> Option<CostBreakdown> {
pricing.map(|p| Self::calculate_for_app(app_type, usage, p, cost_multiplier))
pricing.map(|pricing| {
Self::calculate_with_input_semantics(input_semantics, usage, pricing, cost_multiplier)
})
}
}
+21 -10
View File
@@ -2,9 +2,9 @@
use super::calculator::{CostBreakdown, CostCalculator, ModelPricing};
use super::parser::TokenUsage;
use super::semantics::InputTokenSemantics;
use crate::database::{Database, PRICING_SOURCE_REQUEST, PRICING_SOURCE_RESPONSE};
use crate::error::AppError;
use crate::services::sql_helpers::{INPUT_TOKEN_SEMANTICS_FRESH, INPUT_TOKEN_SEMANTICS_TOTAL};
use crate::services::usage_stats::{find_model_pricing_row, is_placeholder_pricing_model};
use rusqlite::OptionalExtension;
use rust_decimal::Decimal;
@@ -72,6 +72,9 @@ pub struct RequestLog {
/// 用 model/request_model 猜——路由接管下三者可能各不相同。
/// 错误行(未计价)为空字符串。
pub pricing_model: String,
/// Copied from the response parser/wire family at request admission.
/// Product app ownership is intentionally not consulted at write time.
pub input_token_semantics: InputTokenSemantics,
pub usage: TokenUsage,
pub cost: Option<CostBreakdown>,
pub latency_ms: u64,
@@ -121,12 +124,7 @@ impl<'a> UsageLogger<'a> {
};
let created_at = chrono::Utc::now().timestamp();
let input_token_semantics =
if crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str()) {
INPUT_TOKEN_SEMANTICS_TOTAL
} else {
INPUT_TOKEN_SEMANTICS_FRESH
};
let input_token_semantics = log.input_token_semantics.stored_value();
let semantic = UsageSemantic::from_log(log, input_token_semantics);
let existing = Self::load_existing_semantic(&conn, &log.request_id)?;
@@ -266,6 +264,7 @@ impl<'a> UsageLogger<'a> {
status_code: u16,
error_message: String,
latency_ms: u64,
input_token_semantics: InputTokenSemantics,
) -> Result<(), AppError> {
let request_model = model.clone();
let log = RequestLog {
@@ -276,6 +275,7 @@ impl<'a> UsageLogger<'a> {
request_model,
// 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行)
pricing_model: String::new(),
input_token_semantics,
usage: TokenUsage::default(),
cost: None,
latency_ms,
@@ -307,6 +307,7 @@ impl<'a> UsageLogger<'a> {
is_streaming: bool,
session_id: Option<String>,
provider_type: Option<String>,
input_token_semantics: InputTokenSemantics,
) -> Result<(), AppError> {
let request_model = model.clone();
let log = RequestLog {
@@ -317,6 +318,7 @@ impl<'a> UsageLogger<'a> {
request_model,
// 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行)
pricing_model: String::new(),
input_token_semantics,
usage: TokenUsage::default(),
cost: None,
latency_ms,
@@ -451,6 +453,7 @@ impl<'a> UsageLogger<'a> {
model: String,
request_model: String,
pricing_model: String,
input_token_semantics: InputTokenSemantics,
usage: TokenUsage,
cost_multiplier: Decimal,
latency_ms: u64,
@@ -471,8 +474,8 @@ impl<'a> UsageLogger<'a> {
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0: {pricing_model}");
}
let cost = CostCalculator::try_calculate_for_app(
&app_type,
let cost = CostCalculator::try_calculate_with_input_semantics(
input_token_semantics,
&usage,
pricing.as_ref(),
cost_multiplier,
@@ -485,6 +488,7 @@ impl<'a> UsageLogger<'a> {
model,
request_model,
pricing_model,
input_token_semantics,
usage,
cost,
latency_ms,
@@ -513,6 +517,7 @@ mod tests {
model: "gpt-5.6".to_string(),
request_model: "gpt-5.6".to_string(),
pricing_model: "gpt-5.6".to_string(),
input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets,
usage: TokenUsage {
input_tokens,
output_tokens: 5,
@@ -566,6 +571,7 @@ mod tests {
"test-model".to_string(),
"req-model".to_string(),
"test-model".to_string(),
InputTokenSemantics::FreshExcludesCache,
usage,
Decimal::from(1),
100,
@@ -751,6 +757,7 @@ mod tests {
500,
"Internal Server Error".to_string(),
50,
InputTokenSemantics::FreshExcludesCache,
)?;
// 验证错误记录已插入
@@ -778,6 +785,7 @@ mod tests {
model: "grok-4.5".to_string(),
request_model: "grok-4.5".to_string(),
pricing_model: String::new(),
input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets,
usage: TokenUsage::default(),
cost: None,
latency_ms: 1,
@@ -798,7 +806,10 @@ mod tests {
[],
|row| row.get(0),
)?;
assert_eq!(semantics, INPUT_TOKEN_SEMANTICS_TOTAL);
assert_eq!(
semantics,
InputTokenSemantics::TotalIncludesCacheBuckets.stored_value()
);
Ok(())
}
}
+3
View File
@@ -5,6 +5,7 @@
pub mod calculator;
pub mod logger;
pub mod parser;
pub mod semantics;
// 仅导出内部使用的类型,避免未使用警告
#[allow(unused_imports)]
@@ -13,3 +14,5 @@ pub use calculator::{CostBreakdown, CostCalculator, ModelPricing};
pub use logger::{RequestLog, UsageLogger};
#[allow(unused_imports)]
pub use parser::TokenUsage;
#[allow(unused_imports)]
pub use semantics::InputTokenSemantics;
+32
View File
@@ -0,0 +1,32 @@
//! Input-token semantics carried with every live proxy request.
//!
//! `app_type` is a product/UI ownership dimension. It must not decide whether
//! an upstream's input count already contains cache buckets: Pi can route the
//! same logical app through four different wire families.
use crate::pi_config::gateway::PiGatewayApiFamily;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i64)]
pub enum InputTokenSemantics {
/// OpenAI Responses/Completions and Google usage totals include cached
/// input. Fresh input is total minus the reported cache buckets.
TotalIncludesCacheBuckets = 1,
/// Anthropic reports fresh input separately from cache reads/creation.
FreshExcludesCache = 2,
}
impl InputTokenSemantics {
pub const fn stored_value(self) -> i64 {
self as i64
}
pub const fn for_pi_family(family: PiGatewayApiFamily) -> Self {
match family {
PiGatewayApiFamily::AnthropicMessages => Self::FreshExcludesCache,
PiGatewayApiFamily::OpenAiCompletions
| PiGatewayApiFamily::OpenAiResponses
| PiGatewayApiFamily::GoogleGenerativeAi => Self::TotalIncludesCacheBuckets,
}
}
}
File diff suppressed because one or more lines are too long
+4
View File
@@ -138,6 +138,10 @@ impl ConfigService {
AppType::Hermes => {
// Hermes uses additive mode, no live sync needed
}
AppType::Pi => {
// Pi's shared models/settings documents are owned by the
// catalog coordinator, never by this legacy live-sync path.
}
}
Ok(())
+47 -1
View File
@@ -147,6 +147,13 @@ impl McpService {
AppType::Hermes => {
mcp::sync_single_server_to_hermes(&Default::default(), &server.id, &server.server)?;
}
AppType::Pi => {
return Err(AppError::localized(
"mcp.pi.unsupported",
"固定版本的 Pi 核心没有原生 MCP 注册表",
"The pinned Pi core has no native MCP registry",
));
}
}
Ok(())
}
@@ -183,6 +190,13 @@ impl McpService {
AppType::Hermes => {
mcp::remove_server_from_hermes(id)?;
}
AppType::Pi => {
return Err(AppError::localized(
"mcp.pi.unsupported",
"固定版本的 Pi 核心没有原生 MCP 注册表",
"The pinned Pi core has no native MCP registry",
));
}
}
Ok(())
}
@@ -227,7 +241,10 @@ impl McpService {
servers: &IndexMap<String, McpServer>,
app: &AppType,
) -> Result<(), AppError> {
if matches!(app, AppType::OpenClaw | AppType::ClaudeDesktop) {
if matches!(
app,
AppType::OpenClaw | AppType::ClaudeDesktop | AppType::Pi
) {
return Ok(());
}
@@ -544,3 +561,32 @@ impl McpService {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::Database;
use std::sync::Arc;
#[test]
fn global_mcp_projection_treats_pi_as_explicitly_not_applicable() {
let state = AppState::new(Arc::new(Database::memory().expect("database")));
let mut servers = IndexMap::new();
servers.insert(
"example".to_string(),
McpServer {
id: "example".to_string(),
name: "Example".to_string(),
server: serde_json::json!({"command": "example"}),
apps: Default::default(),
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
McpService::project_servers_to_app(&state, &servers, &AppType::Pi)
.expect("Pi is intentionally outside the pinned core MCP registry");
}
}
+3
View File
@@ -8,6 +8,8 @@ pub mod mcp;
pub mod model_fetch;
pub mod model_pricing;
pub mod omo;
pub(crate) mod pi_catalog;
pub mod pi_prompt_files;
pub mod profile;
pub mod prompt;
pub mod provider;
@@ -21,6 +23,7 @@ pub mod session_usage_gemini;
pub mod session_usage_grokbuild;
pub mod session_usage_opencode;
pub mod skill;
pub(crate) mod skill_deployment;
pub mod speedtest;
pub mod sql_helpers;
pub mod stream_check;
+5 -1
View File
@@ -1,4 +1,5 @@
use crate::config::{atomic_write, write_json_file};
use crate::database::NewProviderAggregate;
use crate::error::AppError;
use crate::opencode_config::get_opencode_dir;
use crate::provider::Provider;
@@ -288,7 +289,10 @@ impl OmoService {
in_failover_queue: false,
};
state.db.save_provider("opencode", &provider)?;
state.db.create_provider(NewProviderAggregate::from_input(
"opencode",
crate::services::provider::provider_to_mutation_input(provider.clone()),
)?)?;
state
.db
.set_omo_provider_current("opencode", &provider.id, v.category)?;
File diff suppressed because it is too large Load Diff
+433
View File
@@ -0,0 +1,433 @@
//! Pi native instruction files and prompt templates.
//!
//! AGENTS.md is also the Prompt-library projection. SYSTEM.md and
//! APPEND_SYSTEM.md are direct native resources: file presence is activation
//! and there is no shadow enabled flag.
use crate::error::AppError;
use crate::pi_config::native::get_pi_agent_dir;
use crate::pi_config::shared_file::{delete_shared_file, read_shared_file, replace_shared_file};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock};
use tokio::sync::{Mutex, OwnedMutexGuard};
const MAX_PROMPT_FILE_BYTES: u64 = 1024 * 1024;
const MAX_TEMPLATE_SLUG_BYTES: usize = 128;
static INSTRUCTION_FILE_LOCK: LazyLock<Arc<Mutex<()>>> = LazyLock::new(|| Arc::new(Mutex::new(())));
pub(crate) type PiInstructionFileGuard = OwnedMutexGuard<()>;
pub(crate) fn lock_instruction_files() -> Result<PiInstructionFileGuard, AppError> {
Ok(futures::executor::block_on(
INSTRUCTION_FILE_LOCK.clone().lock_owned(),
))
}
pub(crate) async fn lock_instruction_files_async() -> PiInstructionFileGuard {
INSTRUCTION_FILE_LOCK.clone().lock_owned().await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PiPromptFileKind {
GlobalContext,
SystemOverride,
SystemAppend,
}
impl PiPromptFileKind {
fn filename(self) -> &'static str {
match self {
Self::GlobalContext => "AGENTS.md",
Self::SystemOverride => "SYSTEM.md",
Self::SystemAppend => "APPEND_SYSTEM.md",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiPromptFileSnapshot {
pub kind: PiPromptFileKind,
pub path: String,
pub exists: bool,
pub revision: String,
pub content: String,
}
pub struct PiPromptFileService;
impl PiPromptFileService {
pub fn read(kind: PiPromptFileKind) -> Result<PiPromptFileSnapshot, AppError> {
let guard = lock_instruction_files()?;
Self::read_under_guard(&guard, kind)
}
pub fn replace(
kind: PiPromptFileKind,
expected_revision: &str,
content: &str,
) -> Result<PiPromptFileSnapshot, AppError> {
if kind == PiPromptFileKind::GlobalContext {
return Err(AppError::InvalidInput(
"Pi AGENTS.md is managed through the Prompt library".to_string(),
));
}
validate_direct_instruction_content(content)?;
let guard = lock_instruction_files()?;
Self::replace_under_guard(&guard, kind, expected_revision, content)
}
pub fn delete(kind: PiPromptFileKind, expected_revision: &str) -> Result<bool, AppError> {
if kind == PiPromptFileKind::GlobalContext {
return Err(AppError::InvalidInput(
"Pi AGENTS.md is managed through the Prompt library".to_string(),
));
}
let guard = lock_instruction_files()?;
Self::delete_under_guard(&guard, kind, expected_revision)
}
pub(crate) fn read_under_guard(
_guard: &PiInstructionFileGuard,
kind: PiPromptFileKind,
) -> Result<PiPromptFileSnapshot, AppError> {
Self::read_at(&get_pi_agent_dir()?, kind)
}
pub(crate) fn replace_under_guard(
_guard: &PiInstructionFileGuard,
kind: PiPromptFileKind,
expected_revision: &str,
content: &str,
) -> Result<PiPromptFileSnapshot, AppError> {
Self::replace_at(&get_pi_agent_dir()?, kind, expected_revision, content)
}
pub(crate) fn delete_under_guard(
_guard: &PiInstructionFileGuard,
kind: PiPromptFileKind,
expected_revision: &str,
) -> Result<bool, AppError> {
Self::delete_at(&get_pi_agent_dir()?, kind, expected_revision)
}
fn read_at(root: &Path, kind: PiPromptFileKind) -> Result<PiPromptFileSnapshot, AppError> {
let path = root.join(kind.filename());
let snapshot = read_shared_file(&path, MAX_PROMPT_FILE_BYTES, "Pi prompt file")?;
let exists = snapshot.exists();
let content = match snapshot.bytes {
Some(bytes) => String::from_utf8(bytes).map_err(|error| {
AppError::InvalidInput(format!(
"Pi prompt file must be UTF-8 ({}): {error}",
path.display()
))
})?,
None => String::new(),
};
Ok(PiPromptFileSnapshot {
kind,
path: path.to_string_lossy().into_owned(),
exists,
revision: snapshot.revision,
content,
})
}
fn replace_at(
root: &Path,
kind: PiPromptFileKind,
expected_revision: &str,
content: &str,
) -> Result<PiPromptFileSnapshot, AppError> {
fs::create_dir_all(root).map_err(|error| AppError::io(root, error))?;
let path = root.join(kind.filename());
replace_shared_file(
&path,
expected_revision,
content.as_bytes(),
MAX_PROMPT_FILE_BYTES,
Some(0o600),
"Pi prompt file",
)?;
Self::read_at(root, kind)
}
fn delete_at(
root: &Path,
kind: PiPromptFileKind,
expected_revision: &str,
) -> Result<bool, AppError> {
delete_shared_file(
&root.join(kind.filename()),
expected_revision,
MAX_PROMPT_FILE_BYTES,
"Pi prompt file",
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiPromptTemplate {
pub slug: String,
pub content: String,
pub revision: String,
}
pub struct PiPromptTemplateService;
impl PiPromptTemplateService {
pub fn list() -> Result<Vec<PiPromptTemplate>, AppError> {
Self::list_at(&get_pi_agent_dir()?.join("prompts"))
}
pub fn upsert(
slug: &str,
expected_revision: &str,
content: &str,
) -> Result<PiPromptTemplate, AppError> {
Self::upsert_at(
&get_pi_agent_dir()?.join("prompts"),
slug,
expected_revision,
content,
)
}
pub fn delete(slug: &str, expected_revision: &str) -> Result<bool, AppError> {
validate_template_slug(slug)?;
delete_shared_file(
&template_path(&get_pi_agent_dir()?.join("prompts"), slug),
expected_revision,
MAX_PROMPT_FILE_BYTES,
"Pi prompt template",
)
}
fn list_at(dir: &Path) -> Result<Vec<PiPromptTemplate>, AppError> {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(AppError::io(dir, error)),
};
let mut templates = Vec::new();
for entry in entries {
let entry = entry.map_err(|error| AppError::io(dir, error))?;
let path = entry.path();
let Some(slug) = path.file_stem().and_then(|value| value.to_str()) else {
continue;
};
if path.extension().and_then(|value| value.to_str()) != Some("md")
|| validate_template_slug(slug).is_err()
{
continue;
}
let snapshot = read_shared_file(&path, MAX_PROMPT_FILE_BYTES, "Pi prompt template")?;
let Some(bytes) = snapshot.bytes else {
continue;
};
let content = String::from_utf8(bytes).map_err(|error| {
AppError::InvalidInput(format!(
"Pi prompt template must be UTF-8 ({}): {error}",
path.display()
))
})?;
templates.push(PiPromptTemplate {
slug: slug.to_string(),
content,
revision: snapshot.revision,
});
}
templates.sort_by(|left, right| left.slug.cmp(&right.slug));
Ok(templates)
}
fn upsert_at(
dir: &Path,
slug: &str,
expected_revision: &str,
content: &str,
) -> Result<PiPromptTemplate, AppError> {
validate_template_slug(slug)?;
fs::create_dir_all(dir).map_err(|error| AppError::io(dir, error))?;
let snapshot = replace_shared_file(
&template_path(dir, slug),
expected_revision,
content.as_bytes(),
MAX_PROMPT_FILE_BYTES,
Some(0o600),
"Pi prompt template",
)?;
Ok(PiPromptTemplate {
slug: slug.to_string(),
content: content.to_string(),
revision: snapshot.revision,
})
}
}
fn template_path(dir: &Path, slug: &str) -> PathBuf {
dir.join(format!("{slug}.md"))
}
fn validate_direct_instruction_content(content: &str) -> Result<(), AppError> {
if content.trim().is_empty() {
Err(AppError::InvalidInput(
"Pi SYSTEM.md and APPEND_SYSTEM.md content cannot be blank; delete the file to deactivate it"
.to_string(),
))
} else {
Ok(())
}
}
fn validate_template_slug(slug: &str) -> Result<(), AppError> {
let valid = !slug.is_empty()
&& slug.len() <= MAX_TEMPLATE_SLUG_BYTES
&& slug != "."
&& slug != ".."
&& slug.trim() == slug
&& !slug.starts_with('.')
&& !slug.ends_with('.')
&& !slug
.chars()
.any(|character| character.is_control() || matches!(character, '/' | '\\'));
if valid {
Ok(())
} else {
Err(AppError::InvalidInput(
"Pi prompt-template slug must be one visible filename (1-128 UTF-8 bytes)".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_instruction_files_use_presence_and_revision_as_native_state() {
let temp = tempfile::tempdir().expect("tempdir");
for kind in [
PiPromptFileKind::GlobalContext,
PiPromptFileKind::SystemOverride,
PiPromptFileKind::SystemAppend,
] {
let missing = PiPromptFileService::read_at(temp.path(), kind).expect("missing");
assert!(!missing.exists);
// scripts/pi-transport-capture.mjs executes pinned Pi's
// DefaultResourceLoader at
// ab366ebe94cacd419d986be454f12b1b9913aaca and records all three
// zero-byte files as present resources.
let empty = PiPromptFileService::replace_at(temp.path(), kind, "missing", "")
.expect("create empty instruction file");
assert!(empty.exists);
assert_eq!(empty.content, "");
let saved =
PiPromptFileService::replace_at(temp.path(), kind, &empty.revision, "content")
.expect("replace");
assert!(saved.exists);
assert_eq!(saved.content, "content");
assert!(PiPromptFileService::delete_at(temp.path(), kind, "missing").is_err());
assert!(
PiPromptFileService::delete_at(temp.path(), kind, &saved.revision).expect("delete")
);
}
}
#[test]
fn direct_instruction_save_rejects_blank_content_without_redefining_native_presence() {
for content in ["", " \n\t"] {
assert!(validate_direct_instruction_content(content).is_err());
}
assert!(validate_direct_instruction_content("# Explicit override").is_ok());
}
#[cfg(unix)]
#[test]
fn direct_instruction_entry_never_reports_failure_with_its_attempt_live() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp
.path()
.join(PiPromptFileKind::SystemOverride.filename());
crate::pi_config::shared_file::fail_next_parent_sync_for_test(&path);
PiPromptFileService::replace_at(
temp.path(),
PiPromptFileKind::SystemOverride,
"missing",
"created",
)
.expect_err("failed create must be compensated");
assert!(!path.exists());
let before = PiPromptFileService::replace_at(
temp.path(),
PiPromptFileKind::SystemOverride,
"missing",
"before",
)
.expect("seed");
crate::pi_config::shared_file::fail_next_parent_sync_for_test(&path);
PiPromptFileService::replace_at(
temp.path(),
PiPromptFileKind::SystemOverride,
&before.revision,
"after",
)
.expect_err("failed replace must restore its before-image");
assert_eq!(
fs::read_to_string(&path).expect("before restored"),
"before"
);
let before = PiPromptFileService::read_at(temp.path(), PiPromptFileKind::SystemOverride)
.expect("snapshot");
crate::pi_config::shared_file::fail_next_parent_sync_for_test(&path);
PiPromptFileService::delete_at(
temp.path(),
PiPromptFileKind::SystemOverride,
&before.revision,
)
.expect_err("failed delete must restore its before-image");
assert_eq!(
fs::read_to_string(&path).expect("before restored"),
"before"
);
}
#[test]
fn templates_reject_ambiguous_or_traversing_slugs() {
for slug in [
"",
".",
"..",
".hidden",
"trailing.",
" padded",
"a/b",
r"a\b",
] {
assert!(validate_template_slug(slug).is_err(), "{slug:?}");
}
for slug in ["review-pr", "release.v2", "评审", "SYSTEM"] {
assert!(validate_template_slug(slug).is_ok(), "{slug:?}");
}
}
#[test]
fn empty_template_is_present_and_round_trips_like_pinned_pi() {
// scripts/pi-transport-capture.mjs executes pinned Pi
// ab366ebe94cacd419d986be454f12b1b9913aaca and confirms that an empty
// prompts/empty.md is discovered as an active template.
let temp = tempfile::tempdir().expect("tempdir");
let created = PiPromptTemplateService::upsert_at(temp.path(), "empty", "missing", "")
.expect("create empty template");
assert_eq!(created.content, "");
let listed = PiPromptTemplateService::list_at(temp.path()).expect("list templates");
assert_eq!(listed, vec![created]);
}
}
File diff suppressed because it is too large Load Diff
+9 -15
View File
@@ -5,6 +5,7 @@
use std::time::{SystemTime, UNIX_EPOCH};
use crate::app_config::AppType;
use crate::database::{NewEndpoint, ProviderKey};
use crate::error::AppError;
use crate::settings::CustomEndpoint;
use crate::store::AppState;
@@ -47,9 +48,10 @@ pub fn add_custom_endpoint(
));
}
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
state
.db
.add_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
.add_provider_endpoint(&key, NewEndpoint::now(normalized)?)?;
Ok(())
}
@@ -61,9 +63,8 @@ pub fn remove_custom_endpoint(
url: String,
) -> Result<(), AppError> {
let normalized = url.trim().trim_end_matches('/').to_string();
state
.db
.remove_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
state.db.remove_provider_endpoint(&key, &normalized)?;
Ok(())
}
@@ -76,17 +77,10 @@ pub fn update_endpoint_last_used(
) -> Result<(), AppError> {
let normalized = url.trim().trim_end_matches('/').to_string();
// Get provider, update last_used, save back
let mut providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get_mut(provider_id) {
if let Some(meta) = provider.meta.as_mut() {
if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {
endpoint.last_used = Some(now_millis());
state.db.save_provider(app_type.as_str(), provider)?;
}
}
}
Ok(())
let key = ProviderKey::new(app_type.as_str(), provider_id)?;
state
.db
.touch_provider_endpoint(&key, &normalized, now_millis())
}
/// Get current timestamp in milliseconds
+96 -11
View File
@@ -19,7 +19,10 @@ use crate::store::AppState;
use super::gemini_auth::{
detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType,
};
use super::normalize_claude_models_in_value;
use super::{
normalize_claude_models_in_value, provider_row_fingerprint, provider_to_mutation_input,
reconcile_provider_record_with_precondition, ReconcilePrecondition,
};
/// 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.
@@ -527,6 +530,7 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet:
| AppType::OpenCode
| AppType::OpenClaw
| AppType::Hermes
| AppType::Pi
| AppType::ClaudeDesktop => false,
}
}
@@ -601,6 +605,7 @@ pub(crate) fn remove_common_config_from_settings(
| AppType::OpenCode
| AppType::OpenClaw
| AppType::Hermes
| AppType::Pi
| AppType::ClaudeDesktop => Ok(settings.clone()),
}
}
@@ -660,6 +665,7 @@ fn apply_common_config_to_settings(
| AppType::OpenCode
| AppType::OpenClaw
| AppType::Hermes
| AppType::Pi
| AppType::ClaudeDesktop => Ok(settings.clone()),
}
}
@@ -1162,6 +1168,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
crate::hermes_config::set_provider(&provider.id, provider.settings_config.clone())?;
log::debug!("Hermes provider '{}' written to live config", provider.id);
}
AppType::Pi => {
return Err(AppError::localized(
"pi.live.requires_catalog_coordinator",
"Pi 的共享 models.json 必须通过 Pi 目录协调器写入",
"Pi's shared models.json must be written through the Pi catalog coordinator",
));
}
}
Ok(())
}
@@ -1278,7 +1291,17 @@ fn sync_current_provider_for_app_respecting_takeover(
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// Sync providers based on mode
for app_type in AppType::all() {
if matches!(app_type, AppType::Pi) {
crate::services::pi_catalog::PiCatalogCoordinator::reconcile_portable_import(state)?;
continue;
}
if app_type.is_additive_mode() {
// Provider rename and every additive live mutation share this
// per-app lock. Acquire it before reading the catalog so a key
// cannot be renamed after this sync captured a stale provider map.
let _guard = futures::executor::block_on(
state.proxy_service.lock_switch_for_app(app_type.as_str()),
);
// Additive mode: sync ALL providers
sync_all_providers_to_live(state, &app_type)?;
} else {
@@ -1417,6 +1440,11 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
let config = crate::hermes_config::yaml_to_json(&yaml_config)?;
Ok(config)
}
AppType::Pi => Err(AppError::localized(
"pi.live.requires_catalog_inspection",
"Pi 的共享 models.json 必须通过 Pi 原生目录检查服务读取",
"Pi's shared models.json must be read through the Pi native catalog inspection service",
)),
}
}
@@ -1525,6 +1553,13 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
"config": config_obj
})
}
AppType::Pi => {
return Err(AppError::localized(
"pi.import.requires_catalog_coordinator",
"Pi 原生供应商必须通过 Pi 目录导入流程导入",
"Native Pi providers must be imported through the Pi catalog import flow",
));
}
// OpenCode, OpenClaw and Hermes use additive mode and are handled by early return above
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
unreachable!("additive mode apps are handled by early return")
@@ -1564,7 +1599,12 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
.to_string(),
);
state.db.save_provider(app_type.as_str(), &provider)?;
reconcile_provider_record_with_precondition(
&state.db,
app_type.as_str(),
provider_to_mutation_input(provider.clone()),
ReconcilePrecondition::ExpectAbsent,
)?;
state
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
@@ -1732,15 +1772,25 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
};
if existing_ids.contains(&id) {
match state.db.get_provider_by_id(&id, "opencode") {
match state.db.get_provider_aggregate("opencode", &id) {
Ok(Some(existing)) => {
let existing = existing.provider;
let display_name = config.name.clone().unwrap_or_else(|| existing.name.clone());
if existing.settings_config != settings_config || existing.name != display_name
{
let fingerprint = provider_row_fingerprint(&existing);
let mut provider = existing;
provider.name = display_name;
provider.settings_config = settings_config;
if let Err(e) = state.db.save_provider("opencode", &provider) {
if let Some(meta) = provider.meta.as_mut() {
meta.custom_endpoints.clear();
}
if let Err(e) = reconcile_provider_record_with_precondition(
&state.db,
"opencode",
provider_to_mutation_input(provider),
ReconcilePrecondition::ExpectPresent { fingerprint },
) {
log::warn!(
"Failed to update OpenCode provider '{id}' from live config: {e}"
);
@@ -1767,7 +1817,12 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
});
// Save to database
if let Err(e) = state.db.save_provider("opencode", &provider) {
if let Err(e) = reconcile_provider_record_with_precondition(
&state.db,
"opencode",
provider_to_mutation_input(provider),
ReconcilePrecondition::ExpectAbsent,
) {
log::warn!("Failed to import OpenCode provider '{id}': {e}");
continue;
}
@@ -1817,12 +1872,22 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
};
if existing_ids.contains(&id) {
match state.db.get_provider_by_id(&id, "openclaw") {
match state.db.get_provider_aggregate("openclaw", &id) {
Ok(Some(existing)) => {
let existing = existing.provider;
if existing.settings_config != settings_config {
let fingerprint = provider_row_fingerprint(&existing);
let mut provider = existing;
provider.settings_config = settings_config;
if let Err(e) = state.db.save_provider("openclaw", &provider) {
if let Some(meta) = provider.meta.as_mut() {
meta.custom_endpoints.clear();
}
if let Err(e) = reconcile_provider_record_with_precondition(
&state.db,
"openclaw",
provider_to_mutation_input(provider),
ReconcilePrecondition::ExpectPresent { fingerprint },
) {
log::warn!(
"Failed to update OpenClaw provider '{id}' from live config: {e}"
);
@@ -1855,7 +1920,12 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
});
// Save to database
if let Err(e) = state.db.save_provider("openclaw", &provider) {
if let Err(e) = reconcile_provider_record_with_precondition(
&state.db,
"openclaw",
provider_to_mutation_input(provider),
ReconcilePrecondition::ExpectAbsent,
) {
log::warn!("Failed to import OpenClaw provider '{id}': {e}");
continue;
}
@@ -1892,12 +1962,22 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result<usize, AppE
}
if existing_ids.contains(&name) {
match state.db.get_provider_by_id(&name, "hermes") {
match state.db.get_provider_aggregate("hermes", &name) {
Ok(Some(existing)) => {
let existing = existing.provider;
if existing.settings_config != config {
let fingerprint = provider_row_fingerprint(&existing);
let mut provider = existing;
provider.settings_config = config;
if let Err(e) = state.db.save_provider("hermes", &provider) {
if let Some(meta) = provider.meta.as_mut() {
meta.custom_endpoints.clear();
}
if let Err(e) = reconcile_provider_record_with_precondition(
&state.db,
"hermes",
provider_to_mutation_input(provider),
ReconcilePrecondition::ExpectPresent { fingerprint },
) {
log::warn!(
"Failed to update Hermes provider '{name}' from live config: {e}"
);
@@ -1923,7 +2003,12 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result<usize, AppE
});
// Save to database
if let Err(e) = state.db.save_provider("hermes", &provider) {
if let Err(e) = reconcile_provider_record_with_precondition(
&state.db,
"hermes",
provider_to_mutation_input(provider),
ReconcilePrecondition::ExpectAbsent,
) {
log::warn!("Failed to import Hermes provider '{name}': {e}");
continue;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+662 -58
View File
@@ -565,6 +565,9 @@ impl SkillService {
return Ok(custom.join("skills"));
}
}
AppType::Pi => {
return Ok(crate::pi_config::native::get_pi_agent_dir()?.join("skills"));
}
}
// 默认路径:回退到用户主目录下的标准位置。
@@ -581,6 +584,7 @@ impl SkillService {
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"),
AppType::Pi => crate::pi_config::native::get_pi_agent_dir()?.join("skills"),
})
}
@@ -637,8 +641,19 @@ impl SkillService {
// 同一仓库的同名 skill,返回现有记录(可能需要更新启用状态)
let mut updated = existing.clone();
updated.apps.set_enabled_for(current_app, true);
if matches!(current_app, AppType::Pi) {
let guard = crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
crate::services::skill_deployment::PiSkillDeploymentService::toggle_under_guard(
&guard,
db,
&mut updated,
true,
)
.map_err(|error| anyhow!(error.to_string()))?;
return Ok(updated);
}
db.save_skill(&updated)?;
Self::sync_to_app_dir(&updated.directory, current_app)?;
Self::sync_installed_skill_to_app(db, &updated, current_app)?;
log::info!(
"Skill {} 已存在,更新 {:?} 启用状态",
updated.name,
@@ -671,6 +686,7 @@ impl SkillService {
}
let dest = ssot_dir.join(&install_name);
let destination_preexisted = dest.exists();
let mut repo_branch = skill.repo_branch.clone();
@@ -788,11 +804,39 @@ impl SkillService {
updated_at: 0,
};
// 保存到数据库
db.save_skill(&installed_skill)?;
// 同步到当前应用目录
Self::sync_to_app_dir(&install_name, current_app)?;
let installed_skill = if matches!(current_app, AppType::Pi) {
let guard =
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
let mut persisted = installed_skill.clone();
// Let the deployment coordinator commit the desired bit and ledger
// evidence together. Until then the row is deliberately disabled.
persisted.apps.pi = false;
if let Err(error) = db.save_skill(&persisted) {
if !destination_preexisted {
let _ = fs::remove_dir_all(&dest);
}
return Err(error.into());
}
if let Err(error) =
crate::services::skill_deployment::PiSkillDeploymentService::toggle_under_guard(
&guard,
db,
&mut persisted,
true,
)
{
let _ = db.delete_skill(&persisted.id);
if !destination_preexisted {
let _ = fs::remove_dir_all(&dest);
}
return Err(anyhow!(error.to_string()));
}
persisted
} else {
db.save_skill(&installed_skill)?;
Self::sync_installed_skill_to_app(db, &installed_skill, current_app)?;
installed_skill
};
log::info!(
"Skill {} 安装成功,已启用 {:?}",
@@ -810,6 +854,8 @@ impl SkillService {
/// 2. 从 SSOT 删除
/// 3. 从数据库删除
pub fn uninstall(db: &Arc<Database>, id: &str) -> Result<SkillUninstallResult> {
let deployment_guard =
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
// 获取 skill 信息
let skill = db
.get_installed_skill(id)?
@@ -828,8 +874,15 @@ impl SkillService {
let backup_path = Self::create_uninstall_backup(&skill)?
.map(|path| path.to_string_lossy().to_string());
crate::services::skill_deployment::PiSkillDeploymentService::remove_before_uninstall_under_guard(
&deployment_guard,
db,
&skill,
)
.map_err(|error| anyhow!(error.to_string()))?;
// 从所有应用目录删除
for app in AppType::all() {
for app in AppType::all().filter(|app| !matches!(app, AppType::Pi)) {
let _ = Self::remove_from_app(&directory, &app);
}
@@ -1113,15 +1166,40 @@ impl SkillService {
))
})?;
// All Pi deployment mutations, SSOT replacement, and ledger
// reconciliation share one process boundary. Downloading remains
// outside the lock so a slow network cannot block toggles.
let deployment_guard =
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
// 备份旧文件
let _ = Self::create_uninstall_backup(&skill);
// 删除旧 SSOT 目录并复制新文件
// Stage the exact old SSOT tree in the same directory. Reconstructing
// it from the remote source is not a rollback: local files may differ.
let dest = ssot_dir.join(&skill.directory);
if dest.exists() {
fs::remove_dir_all(&dest)?;
let staged_previous = if fs::symlink_metadata(&dest).is_ok() {
let staged = ssot_dir.join(format!(
".{}.cc-switch-update-{}",
skill.directory,
uuid::Uuid::new_v4().simple()
));
fs::rename(&dest, &staged)?;
Some(staged)
} else {
None
};
if let Err(error) = Self::copy_dir_recursive(&source, &dest) {
if let Some(staged) = staged_previous.as_deref() {
fs::rename(staged, &dest).with_context(|| {
format!(
"Skill update copy failed ({error}); restoring {} also failed",
dest.display()
)
})?;
}
return Err(error);
}
Self::copy_dir_recursive(&source, &dest)?;
// 计算新哈希 + 解析新元数据
let new_hash = Self::compute_dir_hash(&dest).ok();
@@ -1151,14 +1229,71 @@ impl SkillService {
updated_at: chrono::Utc::now().timestamp(),
};
db.save_skill(&updated_skill)?;
if let Err(error) = db.save_skill(&updated_skill) {
let _ = Self::remove_path(&dest);
if let Some(staged) = staged_previous.as_deref() {
fs::rename(staged, &dest).with_context(|| {
format!(
"Skill metadata update failed ({error}); restoring {} also failed",
dest.display()
)
})?;
}
return Err(error.into());
}
// 同步到所有已启用的应用目录
for app in updated_skill.apps.enabled_apps() {
if let Err(e) = Self::sync_to_app_dir(&updated_skill.directory, &app) {
// Pi is the consistency-critical consumer: update its owned
// deployment before best-effort legacy app copies.
if updated_skill.apps.pi {
if let Err(error) =
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_skill_under_guard(
&deployment_guard,
db,
&updated_skill,
)
{
let db_rollback = db.save_skill(&skill);
let file_rollback = Self::remove_path(&dest).and_then(|_| {
if let Some(staged) = staged_previous.as_deref() {
fs::rename(staged, &dest).map_err(anyhow::Error::from)
} else {
Ok(())
}
});
return match (db_rollback, file_rollback) {
(Ok(()), Ok(())) => Err(anyhow!(error.to_string())),
(db_result, file_result) => Err(anyhow!(
"Pi Skill update failed ({error}); DB rollback: {}; file rollback: {}",
db_result
.err()
.map_or_else(|| "ok".to_string(), |value| value.to_string()),
file_result
.err()
.map_or_else(|| "ok".to_string(), |value| value.to_string())
)),
};
}
}
// 同步到所有已启用的其他应用目录
for app in updated_skill
.apps
.enabled_apps()
.into_iter()
.filter(|app| !matches!(app, AppType::Pi))
{
if let Err(e) = Self::sync_installed_skill_to_app(db, &updated_skill, &app) {
log::warn!("同步更新后的 skill 到 {:?} 失败: {e}", app);
}
}
if let Some(staged) = staged_previous {
if let Err(error) = Self::remove_path(&staged) {
log::warn!(
"Failed to remove committed Skill update rollback staging '{}': {error}",
staged.display()
);
}
}
log::info!("Skill {} 更新成功", updated_skill.name);
Ok(updated_skill)
@@ -1201,7 +1336,10 @@ impl SkillService {
/// 迁移 Skill 存储位置(在两个 SSOT 目录间移动文件)
///
/// 安全策略:先移文件,后改设置。中途崩溃时设置仍指向旧目录。
/// Safety strategy: copy first while the old SSOT remains live, switch the
/// setting, reconcile every app, then delete the old trees. Keeping both
/// roots during reconciliation lets the Pi ownership ledger verify its old
/// symlink/copy before atomically replacing it.
pub fn migrate_storage(
db: &Arc<Database>,
target: SkillStorageLocation,
@@ -1215,6 +1353,9 @@ impl SkillService {
});
}
let deployment_guard =
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
// 1. 解析旧目录和新目录(不改设置)
let old_dir = Self::get_ssot_dir()?;
let new_dir = match target {
@@ -1225,18 +1366,18 @@ impl SkillService {
};
fs::create_dir_all(&new_dir)?;
// 2. 逐个移动 skill 目录
// 2. Copy every valid tree. Do not rename/delete the old root before
// Pi has verified the ownership identity recorded in its ledger.
let skills = db.get_all_installed_skills()?;
let mut result = MigrationResult {
migrated_count: 0,
skipped_count: 0,
errors: vec![],
};
let mut copied = Vec::<(PathBuf, PathBuf)>::new();
for skill in skills.values() {
// 下面是 rename 与 remove_dir_all,脏 directory 可把任意目录搬走或删掉。
// 软失败:本函数已有 errors 收集通道,记一条继续处理其余 skill,
// 不要整体中断——用户只是在切换存储位置。
// Invalid DB rows are reported but never joined to either root.
let directory = match Self::require_valid_directory(&skill.directory) {
Ok(directory) => directory,
Err(err) => {
@@ -1253,32 +1394,90 @@ impl SkillService {
result.skipped_count += 1;
continue;
}
if dst.exists() {
result.skipped_count += 1;
continue;
if fs::symlink_metadata(&dst).is_ok() {
for (_, copied_destination) in copied.iter().rev() {
let _ = Self::remove_path(copied_destination);
}
return Err(anyhow!(
"Skill storage target already contains an unowned entry: {}",
dst.display()
));
}
// 优先 rename(同文件系统原子操作),失败则 copy+delete
match fs::rename(&src, &dst) {
Ok(()) => result.migrated_count += 1,
Err(_) => match Self::copy_dir_recursive(&src, &dst) {
Ok(()) => {
let _ = fs::remove_dir_all(&src);
result.migrated_count += 1;
}
Err(e) => {
result.errors.push(format!("{}: {e}", skill.directory));
}
},
if let Err(error) = Self::copy_dir_recursive(&src, &dst) {
let _ = Self::remove_path(&dst);
for (_, copied_destination) in copied.iter().rev() {
let _ = Self::remove_path(copied_destination);
}
return Err(error);
}
copied.push((src, dst));
result.migrated_count += 1;
}
// 3. 文件移动完成后才持久化设置
crate::settings::set_skill_storage_location(target)?;
// 3. Switch authority only after every new tree is complete.
if let Err(error) = crate::settings::set_skill_storage_location(target) {
for (_, copied_destination) in copied.iter().rev() {
let _ = Self::remove_path(copied_destination);
}
return Err(error.into());
}
// 4. 刷新所有应用目录的 symlink(指向新 SSOT
for app in AppType::all() {
let _ = Self::sync_to_app(db, &app);
// 4. Reconcile Pi under the same mutex, then all legacy app views.
let reconcile_result =
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all_under_guard(
&deployment_guard,
db,
)
.map_err(|error| anyhow!(error.to_string()))
.and_then(|()| {
for app in AppType::all().filter(|app| !matches!(app, AppType::Pi)) {
Self::sync_to_app(db, &app)?;
}
Ok(())
});
if let Err(error) = reconcile_result {
let mut rollback_errors = Vec::new();
if let Err(rollback) = crate::settings::set_skill_storage_location(current) {
rollback_errors.push(format!("settings: {rollback}"));
} else {
if let Err(rollback) =
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all_under_guard(
&deployment_guard,
db,
)
{
rollback_errors.push(format!("Pi deployment: {rollback}"));
}
for app in AppType::all().filter(|app| !matches!(app, AppType::Pi)) {
if let Err(rollback) = Self::sync_to_app(db, &app) {
rollback_errors.push(format!("{app:?}: {rollback}"));
}
}
}
for (_, copied_destination) in copied.iter().rev() {
if let Err(rollback) = Self::remove_path(copied_destination) {
rollback_errors.push(format!("{}: {rollback}", copied_destination.display()));
}
}
return if rollback_errors.is_empty() {
Err(error)
} else {
Err(anyhow!(
"Skill storage migration failed ({error}); rollback failures: {}",
rollback_errors.join("; ")
))
};
}
// 5. Only after every consumer points at the new root may the old
// sources be removed. Cleanup errors are visible but do not roll back
// an already-consistent authority switch.
for (old_source, _) in &copied {
if let Err(error) = Self::remove_path(old_source) {
result
.errors
.push(format!("{}: {error}", old_source.display()));
}
}
log::info!(
@@ -1403,7 +1602,7 @@ impl SkillService {
}
if !restored_skill.apps.is_empty() {
if let Err(err) = Self::sync_to_app_dir(&restored_skill.directory, current_app) {
if let Err(err) = Self::sync_installed_skill_to_app(db, &restored_skill, current_app) {
let _ = db.delete_skill(&restored_skill.id);
let _ = fs::remove_dir_all(&restore_path);
return Err(err);
@@ -1429,6 +1628,14 @@ impl SkillService {
.get_installed_skill(id)?
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
if matches!(app, AppType::Pi) {
crate::services::skill_deployment::PiSkillDeploymentService::toggle(
db, &mut skill, enabled,
)
.map_err(|error| anyhow!(error.to_string()))?;
return Ok(());
}
// 更新状态
skill.apps.set_enabled_for(app, enabled);
@@ -1517,6 +1724,11 @@ impl SkillService {
db: &Arc<Database>,
imports: Vec<ImportSkillSelection>,
) -> Result<Vec<InstalledSkill>> {
// Import can explicitly acquire or release Pi filesystem ownership.
// Serialize the source scan, SSOT establishment, ownership decision and
// desired-state transaction with every other Pi deployment operation.
let deployment_guard =
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
let ssot_dir = Self::get_ssot_dir()?;
let agents_lock = parse_agents_lock();
let mut imported = Vec::new();
@@ -1579,8 +1791,12 @@ impl SkillService {
// 复制到 SSOT
let dest = ssot_dir.join(&dir_name);
if !dest.exists() {
Self::copy_dir_recursive(&source, &dest)?;
let created_ssot = !dest.exists();
if created_ssot {
if let Err(error) = Self::copy_dir_recursive(&source, &dest) {
let _ = Self::remove_path(&dest);
return Err(error);
}
}
// 解析元数据
@@ -1588,7 +1804,7 @@ impl SkillService {
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
// 启用状态仅信任用户本次显式选择,不再根据“在哪些位置找到”自动推断。
let apps = selection.apps;
let requested_apps = selection.apps;
// 从 lock 文件提取仓库信息
let (id, repo_owner, repo_name, repo_branch, readme_url) =
@@ -1599,7 +1815,8 @@ impl SkillService {
let content_hash = Self::compute_dir_hash(&ssot_skill_dir).ok();
// 创建记录
let skill = InstalledSkill {
let previous = db.get_installed_skill(&id)?;
let mut skill = InstalledSkill {
id,
name,
description,
@@ -1608,14 +1825,54 @@ impl SkillService {
repo_name,
repo_branch,
readme_url,
apps,
// save_skill intentionally preserves an existing Pi desired bit.
// For a new row keep it disabled until the deployment ledger and
// desired bit can commit in one transaction below.
apps: SkillApps {
pi: previous.as_ref().is_some_and(|installed| installed.apps.pi),
..requested_apps.clone()
},
installed_at: chrono::Utc::now().timestamp(),
content_hash,
updated_at: 0,
};
// 保存到数据库
db.save_skill(&skill)?;
if let Err(error) = db.save_skill(&skill) {
if created_ssot {
let _ = Self::remove_path(&dest);
}
return Err(error.into());
}
if let Err(error) = crate::services::skill_deployment::PiSkillDeploymentService::import_desired_state_under_guard(
&deployment_guard,
db,
&mut skill,
requested_apps.pi,
) {
let db_rollback = if let Some(previous) = previous.as_ref() {
db.save_skill(previous).map(|_| ())
} else {
db.delete_skill(&skill.id).map(|_| ())
};
let file_rollback = if created_ssot {
Self::remove_path(&dest)
} else {
Ok(())
};
return match (db_rollback, file_rollback) {
(Ok(()), Ok(())) => Err(anyhow!(error.to_string())),
(db_result, file_result) => Err(anyhow!(
"Pi Skill import failed ({error}); DB rollback: {}; SSOT rollback: {}",
db_result
.err()
.map_or_else(|| "ok".to_string(), |value| value.to_string()),
file_result
.err()
.map_or_else(|| "ok".to_string(), |value| value.to_string())
)),
};
}
imported.push(skill);
}
@@ -1655,6 +1912,19 @@ impl SkillService {
crate::settings::get_skill_sync_method()
}
fn sync_installed_skill_to_app(
db: &Arc<Database>,
skill: &InstalledSkill,
app: &AppType,
) -> Result<()> {
if matches!(app, AppType::Pi) {
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_skill(db, skill)
.map_err(|error| anyhow!(error.to_string()))
} else {
Self::sync_to_app_dir(&skill.directory, app)
}
}
/// 同步 Skill 到应用目录(使用 symlink 或 copy
///
/// 根据配置和平台选择最佳同步方式:
@@ -1665,6 +1935,11 @@ impl SkillService {
if matches!(app, AppType::ClaudeDesktop) {
return Ok(());
}
if matches!(app, AppType::Pi) {
return Err(anyhow!(
"Pi Skill deployment requires the ownership ledger; use the database-aware reconciler"
));
}
// directory 可能来自被污染的 DB 行(如同步导入的远端快照),join 前必须校验。
let directory = Self::require_valid_directory(directory)?;
@@ -1861,6 +2136,10 @@ impl SkillService {
if matches!(app, AppType::ClaudeDesktop) {
return Ok(());
}
if matches!(app, AppType::Pi) {
return crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all(db)
.map_err(|error| anyhow!(error.to_string()));
}
let skills = db.get_all_installed_skills()?;
let ssot_dir = Self::get_ssot_dir()?;
@@ -2113,7 +2392,7 @@ impl SkillService {
}
/// 静态方法:解析技能元数据
fn parse_skill_metadata_static(path: &Path) -> Result<SkillMetadata> {
pub(crate) fn parse_skill_metadata_static(path: &Path) -> Result<SkillMetadata> {
let content = fs::read_to_string(path)?;
let content = content.trim_start_matches('\u{feff}');
@@ -2733,6 +3012,35 @@ impl SkillService {
Ok(())
}
/// Copy into a unique sibling and publish with an OS no-replace rename.
/// A concurrent installer can win, but its directory is never overwritten.
fn copy_dir_noreplace(src: &Path, dest: &Path) -> Result<()> {
let parent = dest
.parent()
.ok_or_else(|| anyhow!("Skill destination has no parent: {}", dest.display()))?;
fs::create_dir_all(parent)?;
let name = dest
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| anyhow!("Skill destination has an invalid name: {}", dest.display()))?;
let staged = parent.join(format!(
".{name}.cc-switch-install-{}",
uuid::Uuid::new_v4().simple()
));
if let Err(error) = Self::copy_dir_recursive(src, &staged) {
let _ = fs::remove_dir_all(&staged);
return Err(error);
}
if let Err(error) = crate::pi_config::shared_file::publish_path_noreplace(&staged, dest) {
let _ = fs::remove_dir_all(&staged);
return Err(anyhow!(
"Skill destination was created concurrently ({}): {error}",
dest.display()
));
}
Ok(())
}
fn resolve_uninstall_backup_source(skill: &InstalledSkill) -> Result<Option<PathBuf>> {
// 返回值会被整目录复制进 ~/.cc-switch/skill-backups/ 并由 get_skill_backups
// 在界面上列出——脏 directory 在这里等于任意文件读取 + 外泄通道。
@@ -2996,6 +3304,10 @@ impl SkillService {
let ssot_dir = Self::get_ssot_dir()?;
let mut installed = Vec::new();
let existing_skills = db.get_all_installed_skills()?;
let mut claimed_directories = existing_skills
.values()
.map(|skill| skill.directory.to_ascii_lowercase())
.collect::<HashSet<_>>();
let zip_stem = zip_path
.file_stem()
.and_then(|s| s.to_str())
@@ -3062,6 +3374,32 @@ impl SkillService {
);
continue;
}
if claimed_directories.contains(&install_name.to_ascii_lowercase()) {
log::warn!(
"Skill directory '{}' appears more than once in the archive, skipping",
install_name
);
continue;
}
if matches!(current_app, AppType::Pi)
&& meta.as_ref().is_none_or(|metadata| {
metadata
.name
.as_deref()
.is_none_or(|name| name.trim().is_empty())
|| metadata
.description
.as_deref()
.is_none_or(|description| description.trim().is_empty())
})
{
return Err(anyhow!(format_skill_error(
"INVALID_SKILL_DIRECTORY",
&[("directory", &install_name)],
Some("checkSkillManifest"),
)));
}
let (name, description) = match meta {
Some(m) => (
@@ -3071,18 +3409,35 @@ impl SkillService {
None => (install_name.clone(), None),
};
let deployment_guard = matches!(current_app, AppType::Pi)
.then(crate::services::skill_deployment::PiSkillDeploymentService::operation_guard);
let pi_source_digest = if deployment_guard.is_some() {
Some(
crate::services::skill_deployment::PiSkillDeploymentService::source_digest(
&skill_dir,
)
.map_err(|error| anyhow!(error.to_string()))?,
)
} else {
None
};
// 复制到 SSOT
let dest = ssot_dir.join(&install_name);
if dest.exists() {
let _ = fs::remove_dir_all(&dest);
if fs::symlink_metadata(&dest).is_ok() {
return Err(anyhow!(format_skill_error(
"SKILL_DIRECTORY_CONFLICT",
&[("directory", &install_name)],
Some("uninstallFirst"),
)));
}
Self::copy_dir_recursive(&skill_dir, &dest)?;
Self::copy_dir_noreplace(&skill_dir, &dest)?;
// 计算内容哈希
let content_hash = Self::compute_dir_hash(&dest).ok();
// 创建 InstalledSkill 记录
let skill = InstalledSkill {
let mut skill = InstalledSkill {
id: format!("local:{install_name}"),
name,
description,
@@ -3097,17 +3452,75 @@ impl SkillService {
updated_at: 0,
};
// 保存到数据库
db.save_skill(&skill)?;
// 同步到当前应用目录
Self::sync_to_app_dir(&install_name, current_app)?;
if let Some(guard) = deployment_guard.as_ref() {
// The coordinator commits Pi desired state and ownership
// evidence together. Until then the portable row is inert.
skill.apps.pi = false;
if let Err(error) = db.save_skill(&skill) {
let cleanup =
crate::services::skill_deployment::PiSkillDeploymentService::remove_source_if_unchanged(
&dest,
pi_source_digest
.as_deref()
.expect("Pi ZIP publication has a source digest"),
);
return match cleanup {
Ok(()) => Err(error.into()),
Err(cleanup) => Err(anyhow!(
"Pi Skill ZIP database write failed ({error}); SSOT rollback failed ({cleanup})"
)),
};
}
if let Err(error) =
crate::services::skill_deployment::PiSkillDeploymentService::toggle_under_guard(
guard, db, &mut skill, true,
)
{
if let Err(deployment_rollback) =
crate::services::skill_deployment::PiSkillDeploymentService::remove_before_uninstall_under_guard(
guard, db, &skill,
)
{
return Err(anyhow!(
"Pi Skill ZIP install failed ({error}); native ownership rollback failed ({deployment_rollback}); the DB row and SSOT were retained as recovery evidence"
));
}
let db_rollback = db.delete_skill(&skill.id);
if !matches!(&db_rollback, Ok(true)) {
return Err(anyhow!(
"Pi Skill ZIP install failed ({error}); DB rollback failed ({}); SSOT was retained",
match db_rollback {
Ok(false) => "row missing".to_string(),
Err(value) => value.to_string(),
Ok(true) => unreachable!(),
}
));
}
let file_rollback =
crate::services::skill_deployment::PiSkillDeploymentService::remove_source_if_unchanged(
&dest,
pi_source_digest
.as_deref()
.expect("Pi ZIP publication has a source digest"),
);
return match file_rollback {
Ok(()) => Err(anyhow!(error.to_string())),
Err(file_error) => Err(anyhow!(
"Pi Skill ZIP install failed ({error}); SSOT rollback failed ({file_error})"
)),
};
}
} else {
db.save_skill(&skill)?;
Self::sync_installed_skill_to_app(db, &skill, current_app)?;
}
log::info!(
"Skill {} installed from ZIP, enabled for {:?}",
skill.name,
current_app
);
claimed_directories.insert(install_name.to_ascii_lowercase());
installed.push(skill);
}
@@ -4013,6 +4426,34 @@ mod tests {
);
}
#[test]
fn no_replace_directory_publish_preserves_an_existing_destination() {
let temp = tempdir().expect("tempdir");
let source = temp.path().join("source");
let destination = temp.path().join("destination");
fs::create_dir(&source).expect("source");
fs::create_dir(&destination).expect("destination");
fs::write(source.join("SKILL.md"), "managed").expect("source manifest");
fs::write(destination.join("SKILL.md"), "external").expect("external manifest");
SkillService::copy_dir_noreplace(&source, &destination)
.expect_err("an existing destination must win atomically");
assert_eq!(
fs::read_to_string(destination.join("SKILL.md")).expect("external destination"),
"external"
);
assert!(
fs::read_dir(temp.path())
.expect("temp root")
.all(|entry| !entry
.expect("entry")
.file_name()
.to_string_lossy()
.contains("cc-switch-install")),
"a rejected staged publication must be cleaned"
);
}
#[test]
fn extract_local_zip_hands_back_a_guard_that_owns_the_tree() {
use std::io::Write;
@@ -4142,6 +4583,169 @@ mod tests {
}
}
#[test]
#[serial_test::serial]
fn importing_a_native_pi_skill_adopts_exact_content_and_can_disable_it() {
struct PiDirGuard(Option<std::ffi::OsString>);
impl Drop for PiDirGuard {
fn drop(&mut self) {
match self.0.take() {
Some(value) => std::env::set_var("PI_CODING_AGENT_DIR", value),
None => std::env::remove_var("PI_CODING_AGENT_DIR"),
}
}
}
struct StorageLocationGuard(SkillStorageLocation);
impl Drop for StorageLocationGuard {
fn drop(&mut self) {
let _ = crate::settings::set_skill_storage_location(self.0);
}
}
let temp = tempdir().expect("tempdir");
let _home_guard = TestHomeGuard::set(temp.path());
let pi_agent_dir = temp.path().join("pi-agent");
let _pi_dir_guard = PiDirGuard(std::env::var_os("PI_CODING_AGENT_DIR"));
std::env::set_var("PI_CODING_AGENT_DIR", &pi_agent_dir);
let _storage_guard = StorageLocationGuard(crate::settings::get_skill_storage_location());
crate::settings::set_skill_storage_location(SkillStorageLocation::CcSwitch)
.expect("select isolated SSOT");
let native = pi_agent_dir.join("skills").join("native-skill");
write_skill(&native, "Native Skill");
fs::write(native.join("details.txt"), "pinned native bytes").expect("native detail");
let db = Arc::new(Database::memory().expect("memory db"));
let imported = SkillService::import_from_apps(
&db,
vec![ImportSkillSelection {
directory: "native-skill".to_string(),
apps: SkillApps::only(&AppType::Pi),
}],
)
.expect("explicit Pi import should adopt the exact native tree");
assert_eq!(imported.len(), 1);
assert!(imported[0].apps.pi);
let statuses =
crate::services::skill_deployment::PiSkillDeploymentService::inspect_all(&db)
.expect("inspect Pi deployment");
let status = statuses
.get(&imported[0].id)
.expect("imported status must exist");
assert_eq!(
status.ownership,
crate::services::skill_deployment::PiSkillOwnership::Owned
);
assert_eq!(
status.discovery,
crate::services::skill_deployment::PiSkillDiscovery::Active
);
assert!(status.effectively_discovered);
SkillService::toggle_app(&db, &imported[0].id, &AppType::Pi, false)
.expect("owned imported Pi Skill can be disabled");
assert!(
!native.exists(),
"disabling an explicitly adopted native tree removes that owned deployment"
);
assert!(
SkillService::get_ssot_dir()
.expect("SSOT")
.join("native-skill")
.join("SKILL.md")
.is_file(),
"disabling Pi must preserve the managed SSOT"
);
assert!(
!db.get_installed_skill(&imported[0].id)
.expect("read imported row")
.expect("row")
.apps
.pi
);
}
#[test]
#[serial_test::serial]
fn pi_zip_collision_rolls_back_database_and_ssot_without_touching_native_skill() {
use std::io::Write;
use zip::write::SimpleFileOptions;
struct PiDirGuard(Option<std::ffi::OsString>);
impl Drop for PiDirGuard {
fn drop(&mut self) {
match self.0.take() {
Some(value) => std::env::set_var("PI_CODING_AGENT_DIR", value),
None => std::env::remove_var("PI_CODING_AGENT_DIR"),
}
}
}
struct StorageLocationGuard(SkillStorageLocation);
impl Drop for StorageLocationGuard {
fn drop(&mut self) {
let _ = crate::settings::set_skill_storage_location(self.0);
}
}
let temp = tempdir().expect("tempdir");
let _home_guard = TestHomeGuard::set(temp.path());
let pi_agent_dir = temp.path().join("pi-agent");
let _pi_dir_guard = PiDirGuard(std::env::var_os("PI_CODING_AGENT_DIR"));
std::env::set_var("PI_CODING_AGENT_DIR", &pi_agent_dir);
let _storage_guard = StorageLocationGuard(crate::settings::get_skill_storage_location());
crate::settings::set_skill_storage_location(SkillStorageLocation::CcSwitch)
.expect("isolated SSOT");
let native = pi_agent_dir.join("skills").join("collision");
write_skill(&native, "Native collision");
fs::write(native.join("native.txt"), "must survive").expect("native bytes");
let mut archive = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut archive));
let options = SimpleFileOptions::default();
zip.start_file("collision/SKILL.md", options)
.expect("manifest entry");
zip.write_all(b"---\nname: Imported\ndescription: Imported collision\n---\n")
.expect("manifest bytes");
zip.start_file("collision/imported.txt", options)
.expect("payload entry");
zip.write_all(b"must not remain").expect("payload bytes");
zip.finish().expect("finish zip");
}
let zip_path = temp.path().join("collision.zip");
fs::write(&zip_path, archive).expect("write zip");
let db = Arc::new(Database::memory().expect("database"));
SkillService::install_from_zip(&db, &zip_path, &AppType::Pi)
.expect_err("unowned native collision must fail");
assert_eq!(
fs::read_to_string(native.join("native.txt")).expect("native survives"),
"must survive"
);
assert!(
db.get_installed_skill("local:collision")
.expect("read skill")
.is_none(),
"failed ZIP install must not leave desired state"
);
assert!(
db.get_pi_skill_deployments("local:collision")
.expect("read ledger")
.is_empty(),
"failed ZIP install must not create ownership evidence"
);
assert!(
!SkillService::get_ssot_dir()
.expect("SSOT")
.join("collision")
.exists(),
"failed ZIP install must compensate its SSOT copy"
);
}
fn poisoned_skill(id: &str, directory: &str) -> InstalledSkill {
InstalledSkill {
id: id.to_string(),
File diff suppressed because it is too large Load Diff
+35 -2
View File
@@ -54,8 +54,7 @@ pub fn fresh_input_sql(alias: &str) -> String {
format!(
"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} \
WHEN {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}) \
@@ -144,6 +143,40 @@ mod tests {
assert_eq!(total, 400 + 500 + 450 + 200);
}
#[test]
fn stored_wire_semantics_override_logical_pi_app_type() {
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
('pi-openai', 'pi', 1000, 700, 100, 1),
('pi-anthropic', 'pi', 1000, 700, 100, 2)",
[],
)
.unwrap();
let sql = format!(
"SELECT request_id, {} FROM proxy_request_logs ORDER BY request_id",
fresh_input_sql("")
);
let values: Vec<(String, i64)> = conn
.prepare(&sql)
.unwrap()
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(
values,
vec![
("pi-anthropic".to_string(), 1000),
("pi-openai".to_string(), 200),
]
);
}
#[test]
fn fresh_input_handles_codex_with_cache_exceeding_input() {
// Defensive: if a malformed Codex row somehow has cache > input,
+56
View File
@@ -181,6 +181,7 @@ impl StreamCheckService {
}
AppType::OpenClaw => Self::extract_openclaw_base_url(provider),
AppType::Hermes => Self::extract_hermes_base_url(provider),
AppType::Pi => Self::extract_pi_base_url(provider),
AppType::ClaudeDesktop => ClaudeAdapter::new()
.extract_base_url(provider)
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}"))),
@@ -323,6 +324,31 @@ impl StreamCheckService {
})
}
/// Pi endpoint inheritance is owned by the pinned composer. Reachability
/// checks deliberately consume its first effective model instead of
/// reimplementing provider/model fallback rules.
fn extract_pi_base_url(provider: &Provider) -> Result<String, AppError> {
let config: crate::pi_config::model::PiManagedProviderConfig =
serde_json::from_value(provider.settings_config.clone()).map_err(|error| {
AppError::InvalidInput(format!(
"Pi provider '{}' is not a managed native configuration: {error}",
provider.id
))
})?;
let composition =
crate::pi_config::native::compose_managed_pi_provider(&provider.id, &config)?;
composition
.models
.first()
.map(|model| model.base_url.clone())
.ok_or_else(|| {
AppError::InvalidInput(format!(
"Pi provider '{}' has no effective models",
provider.id
))
})
}
/// OpenCode: `{ npm, options: { baseURL, apiKey }, ... }`
///
/// 用户未显式填 `options.baseURL` 时,按 `npm`AI SDK 包)回退到包自带默认端点。
@@ -501,6 +527,36 @@ mod tests {
);
}
#[test]
fn pi_reachability_uses_composer_effective_model_endpoint() {
let provider = make_provider(serde_json::json!({
"name": "Pi",
"api": "openai-responses",
"baseUrl": "https://provider.example/v1",
"apiKey": "literal",
"models": [{
"id": "model",
"name": "Model",
"baseUrl": "https://model.example/custom",
"reasoning": false,
"input": ["text"],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 128000,
"maxTokens": 8192
}]
}));
assert_eq!(
StreamCheckService::resolve_base_url(&AppType::Pi, &provider).unwrap(),
"https://model.example/custom"
);
}
#[test]
fn test_resolve_base_url_uses_explicit_url_or_errors_when_missing() {
// 有显式 base_url → 直接用
+67 -19
View File
@@ -137,8 +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)]
/// Persisted request-level semantics used by both pricing and UI cache
/// normalization. This must cross IPC; app-type inference is only a legacy
/// fallback for rows written before the semantics column existed.
pub input_token_semantics: i64,
pub input_cost_usd: String,
pub output_cost_usd: String,
@@ -1653,10 +1654,10 @@ impl Database {
let detail_sql = format!(
"SELECT l.request_id, l.provider_id, {detail_pname} as provider_name, l.app_type, l.model,
l.request_model, l.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, l.data_source, l.pricing_model,
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.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
@@ -1897,19 +1898,18 @@ impl Database {
// 1. 历史 cache-inclusive 行只包含 cache read;新 total 行还包含 cache write。
// 2. Claude/Anthropic 的 input_tokens 已经是 fresh input,不能再次扣减
// 3. 各项成本是基础成本(不含倍率),倍率只作用于最终总价
let cache_inclusive_app =
crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str());
let billable_input_tokens =
if !cache_inclusive_app || log.input_token_semantics == INPUT_TOKEN_SEMANTICS_FRESH {
log.input_tokens as u64
} 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 billable_input_tokens = if 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 if crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str()) {
// v12 and earlier: input included cache reads but excluded cache writes.
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64)
} else {
log.input_tokens as u64
};
let input_cost =
rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million;
let output_cost =
@@ -2407,6 +2407,54 @@ mod tests {
Ok(())
}
#[test]
fn paginated_and_detail_ipc_serialize_persisted_input_semantics() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
insert_usage_log(
&conn,
"pi-semantics-ipc",
"pi",
"pi-provider",
"gpt-test",
"request",
1,
1_000,
5,
800,
0,
200,
"0",
)?;
conn.execute(
"UPDATE proxy_request_logs
SET input_token_semantics = ?1
WHERE request_id = 'pi-semantics-ipc'",
[INPUT_TOKEN_SEMANTICS_TOTAL],
)?;
}
let page = db.get_request_logs(&LogFilters::default(), 0, 10)?;
let page_json =
serde_json::to_value(&page).map_err(|error| AppError::Database(error.to_string()))?;
assert_eq!(
page_json["data"][0]["inputTokenSemantics"],
INPUT_TOKEN_SEMANTICS_TOTAL
);
let detail = db
.get_request_detail("pi-semantics-ipc")?
.expect("request detail");
let detail_json =
serde_json::to_value(detail).map_err(|error| AppError::Database(error.to_string()))?;
assert_eq!(
detail_json["inputTokenSemantics"],
INPUT_TOKEN_SEMANTICS_TOTAL
);
Ok(())
}
fn create_legacy_nullable_logs_table(conn: &Connection) -> Result<(), AppError> {
conn.execute(
"CREATE TABLE proxy_request_logs (
+8 -2
View File
@@ -4,7 +4,7 @@ pub mod terminal;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use providers::{claude, codex, gemini, grokbuild, hermes, openclaw, opencode};
use providers::{claude, codex, gemini, grokbuild, hermes, openclaw, opencode, pi};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -56,7 +56,7 @@ pub struct DeleteSessionOutcome {
}
pub fn scan_sessions() -> Vec<SessionMeta> {
let (r1, r2, r3, r4, r5, r6, r7) = std::thread::scope(|s| {
let (r1, r2, r3, r4, r5, r6, r7, r8) = 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);
@@ -64,6 +64,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
let h5 = s.spawn(gemini::scan_sessions);
let h6 = s.spawn(hermes::scan_sessions);
let h7 = s.spawn(grokbuild::scan_sessions);
let h8 = s.spawn(pi::scan_sessions);
(
h1.join().unwrap_or_default(),
h2.join().unwrap_or_default(),
@@ -72,6 +73,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
h5.join().unwrap_or_default(),
h6.join().unwrap_or_default(),
h7.join().unwrap_or_default(),
h8.join().unwrap_or_default(),
)
});
@@ -83,6 +85,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
sessions.extend(r5);
sessions.extend(r6);
sessions.extend(r7);
sessions.extend(r8);
sessions.sort_by(|a, b| {
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
@@ -111,6 +114,7 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
"gemini" => gemini::load_messages(path),
"grokbuild" => grokbuild::load_messages(path),
"hermes" => hermes::load_messages(path),
"pi" => pi::load_messages(path),
_ => Err(format!("Unsupported provider: {provider_id}")),
}
}
@@ -173,6 +177,7 @@ fn delete_session_with_roots(
grokbuild::delete_session(&validated_root, &validated_source, session_id)
}
"hermes" => hermes::delete_session(&validated_root, &validated_source, session_id),
"pi" => pi::delete_session(&validated_root, &validated_source, session_id),
_ => Err(format!("Unsupported provider: {provider_id}")),
};
}
@@ -203,6 +208,7 @@ fn provider_roots(provider_id: &str) -> Result<Vec<PathBuf>, String> {
"gemini" => vec![crate::gemini_config::get_gemini_dir().join("tmp")],
"grokbuild" => grokbuild::session_roots(),
"hermes" => vec![crate::hermes_config::get_hermes_dir().join("sessions")],
"pi" => pi::session_roots(),
_ => return Err(format!("Unsupported provider: {provider_id}")),
};

Some files were not shown because too many files have changed in this diff Show More