mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 03:32:25 +08:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84f8c87795 | |||
| 655bee48a0 | |||
| ddb0cb19cd | |||
| a54a52ab8a | |||
| d05e224910 | |||
| c7d37320f5 | |||
| 63b1a20eef | |||
| f35b47c1cf | |||
| 697bcf8e0f | |||
| afd58e31d8 | |||
| 469d6e0f59 | |||
| 1a3b97f018 | |||
| 9ab526dabf | |||
| 9d71e355da | |||
| ac764b4818 | |||
| 13ea497ab0 | |||
| 8383076791 | |||
| ebbf141fc7 | |||
| fbf52cff73 | |||
| a4bba43f73 | |||
| 28529620f4 | |||
| b3a20e58b0 | |||
| 58dd376dc4 | |||
| 56a66eea36 | |||
| 8ae1ce8558 | |||
| f42534ed26 | |||
| e3f80a98f3 | |||
| f07edc7680 | |||
| a354f08a3d | |||
| 4bfb3fc30d | |||
| c49cf96a16 | |||
| b884595a23 | |||
| 4317bd9981 |
+2
-1
@@ -15,6 +15,7 @@
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.js text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.jsx text eol=lf
|
||||
|
||||
# HTML/CSS files
|
||||
@@ -35,4 +36,4 @@
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.exe binary
|
||||
*.dll binary
|
||||
*.dll binary
|
||||
|
||||
@@ -5,6 +5,57 @@ 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.
|
||||
|
||||
@@ -72,11 +72,6 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
||||
<td>Thanks to RunAPI for sponsoring this project! RunAPI is a high-performance and reliable AI model API gateway — one API key gives you access to 150+ mainstream models including OpenAI, Claude, Gemini, DeepSeek, and Grok, with prices as low as 10% of the official rate and excellent stability. It works seamlessly with Claude Code, OpenClaw, and other tools. Exclusive benefit for CC Switch users: register via <a href="https://runapi.co/register?aff=iOKB">this link</a> and enjoy a 10% discount on your first top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>Thanks to Unity2.ai for sponsoring this project! Unity2.ai is a high-performance AI model API relay platform for individual developers, teams, and enterprises. Long trusted by leading companies in China, it serves over 30 billion tokens per day and supports high concurrency at the 5,000 RPM level. It offers balance-based billing, first top-up bonuses, bundle subscriptions, corporate invoicing, and dedicated support. Register via <a href="https://unity2.ai/register?source=ccs">this link</a> to get $2 in credits, plus another $10 for joining the official group — up to $12 in free credits!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>Thanks to Shengsuanyun for sponsoring this project! Shengsuanyun is a super factory serving AI Native Teams — an industrial-grade AI task parallel execution platform. Its model marketplace aggregates Claude, ChatGPT, Gemini, and other domestic and international LLM and multimedia model capabilities with direct supply. Absolutely no reverse engineering or dilution — platform-wide model SLA availability reaches 99.7%, with <a href="https://watch.shengsuanyun.com/status/shengsuanyun">monitoring dashboards</a> showing green across the board. It also offers enterprise-grade custom gateways for fine-grained team cost and permission management, smart routing, security protection, and BYOK (Bring Your Own Key) hosting. The platform charges on a pay-per-use and tokens plan (coming soon) basis, with invoicing available. Register via <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">this link</a> as a new user to receive ¥10 in credits plus a 10% bonus on your first top-up.</td>
|
||||
|
||||
@@ -72,11 +72,6 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
||||
<td>Danke an RunAPI für die Unterstützung dieses Projekts! RunAPI ist ein leistungsstarkes und zuverlässiges KI-Modell-API-Gateway — ein API-Schlüssel gibt Ihnen Zugriff auf mehr als 150 gängige Modelle, darunter OpenAI, Claude, Gemini, DeepSeek und Grok, zu Preisen ab 10 % des offiziellen Tarifs und mit ausgezeichneter Stabilität. Es arbeitet nahtlos mit Claude Code, OpenClaw und weiteren Werkzeugen zusammen. Exklusiver Vorteil für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://runapi.co/register?aff=iOKB">diesen Link</a> und erhalten Sie 10 % Rabatt auf Ihre erste Aufladung!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>Danke an Unity2.ai für die Unterstützung dieses Projekts! Unity2.ai ist eine leistungsstarke AI-Modell-API-Relay-Plattform für Einzelentwickler, Teams und Unternehmen. Sie wird seit Langem von führenden Unternehmen in China genutzt, verarbeitet täglich über 30 Milliarden Tokens und unterstützt hohe Parallelität auf 5.000-RPM-Niveau. Geboten werden Guthaben-Abrechnung, Ersteinzahlungsbonus, Kombi-Abonnements, Firmenrechnungen und persönliche Betreuung. Registrieren Sie sich über <a href="https://unity2.ai/register?source=ccs">diesen Link</a> und erhalten Sie $2 Guthaben, plus weitere $10 für den Beitritt zur offiziellen Gruppe — bis zu $12 Gratis-Guthaben!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>Danke an Shengsuanyun für die Unterstützung dieses Projekts! Shengsuanyun ist eine Superfabrik für KI-native Teams — eine Plattform zur parallelen Ausführung von KI-Aufgaben in industrieller Qualität. Ihr Modellmarktplatz bündelt die Fähigkeiten von Claude, ChatGPT, Gemini und weiteren in- und ausländischen LLM- und Multimedia-Modellen mit Direktbezug. Absolut kein Reverse Engineering und keine Verwässerung — die plattformweite Modell-SLA-Verfügbarkeit erreicht 99,7 %, und die <a href="https://watch.shengsuanyun.com/status/shengsuanyun">Monitoring-Dashboards</a> zeigen durchgehend grün an. Es bietet außerdem unternehmensgerechte, anpassbare Gateways für fein abgestufte Kosten- und Berechtigungsverwaltung im Team, intelligentes Routing, Sicherheitsschutz und BYOK-Hosting (Bring Your Own Key). Die Plattform rechnet nach Nutzung sowie über einen Token-Plan (in Kürze verfügbar) ab, und Rechnungsstellung ist möglich. Registrieren Sie sich über <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">diesen Link</a> als Neukunde und erhalten Sie ein Guthaben von ¥10 sowie 10 % Bonus auf Ihre erste Aufladung.</td>
|
||||
|
||||
@@ -72,11 +72,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
<td>本プロジェクトのスポンサーである RunAPI に感謝いたします!RunAPI は高効率で安定した AI モデル API ゲートウェイです。一つの API Key で、OpenAI、Claude、Gemini、DeepSeek、Grok など 150 種類以上の主要モデルにアクセス可能。料金は公式価格の最大 10%、安定性にも優れ、Claude Code や OpenClaw などのツールとシームレスに連携できます。CC Switch ユーザー限定特典:<a href="https://runapi.co/register?aff=iOKB">こちらのリンク</a>から登録し、初回チャージで 10% オフの割引をお楽しみいただけます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>Unity2.ai のご支援に感謝します!Unity2.ai は個人開発者・チーム・企業向けの高性能 AI モデル API リレープラットフォームです。中国の大手企業に長年利用されており、1 日 300 億トークン以上を処理し、5000 RPM クラスの高並列に対応しています。残高課金、初回チャージボーナス、組み合わせサブスクリプション、企業向け請求書発行、専任サポートを提供。<a href="https://unity2.ai/register?source=ccs">こちらのリンク</a>から登録すると $2 のクレジット、公式グループへの参加でさらに $10、最大 $12 の無料クレジットがもらえます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>胜算雲(Shengsuanyun)のご支援に感謝します!胜算雲は AI ネイティブチーム向けのスーパーファクトリーであり、産業グレードの AI タスク並列実行プラットフォームです。モデルマーケットプレイスでは Claude、ChatGPT、Gemini をはじめとする国内外の LLM およびマルチメディアモデルの計算リソースを集約・直接提供。リバースエンジニアリングや品質低下は一切なく、プラットフォーム全体のモデル SLA 可用性は 99.7% に達し、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">監視ダッシュボード</a>は常時グリーン表示です。さらにエンタープライズ向けカスタムゲートウェイを提供し、チームのきめ細かなコスト・権限管理、スマートルーティング、セキュリティ保護、BYOK(自社キー持ち込み)ホスティングを実現します。従量課金およびトークンプラン(近日公開)対応で、請求書発行にも対応。<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">このリンク</a>から新規登録すると 10 元分のクレジットと初回チャージ 10% ボーナスが付与されます。</td>
|
||||
|
||||
@@ -72,11 +72,6 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
<td>感谢 RunAPI 赞助本项目!RunAPI 是高效稳定的 AI 模型 API 中转平台,一个 API Key 即可访问 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型,低至 1 折,极其稳定,可以无缝兼容 Claude Code、OpenClaw 等工具。RunAPI为CC switch的用户提供了特别福利,使用<a href="https://runapi.co/register?aff=iOKB">此链接</a>注册并首次充值即可享受10%的优惠折扣!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>感谢 Unity2.ai 赞助了本项目!Unity2.ai 是面向个人开发者、团队和企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。支持余额计费、首充赠额、组合订阅、企业开票和专属对接。通过<a href="https://unity2.ai/register?source=ccs">此链接</a>注册可领取 $2 余额,加入官方群再送 $10 余额,最高可领 $12 免费额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>感谢胜算云赞助了本项目!胜算云是专为AI Native Teams服务的超级工厂,工业级AI任务并行执行平台,模型商城集采直供聚合接入了Claude、Chatgpt、Gemini等海内外LLM及图片视频多媒体模型算力,绝无逆向掺水、全站模型SLA可用性高达99.7%、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">监测接口</a>日常全绿。更有企业级专属定制网关,实现团队精细化成本与权限管控,智能路由+安全防护+BYOK企业自带密钥托管。平台按量及tokens plan(即将上线)计费,可开票,使用<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">此链接</a>注册新用户可获10元模力及首充10%赠送。</td>
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
# Using DeepSeek-Style Chat APIs in Codex: CC Switch Local Routing Guide
|
||||
|
||||
> Applies to CC Switch 3.16.0 and nearby versions. This guide is based on the repository documentation and code, and uses DeepSeek as an example of an OpenAI Chat Completions-compatible API. Screenshots are generated from the current frontend UI with de-identified sample data to avoid exposing a real API key or account balance.
|
||||
> Applies to CC Switch 3.19.1 and later. This guide is based on the repository documentation and code. Screenshots are generated with de-identified sample data to avoid exposing a real API key or account balance.
|
||||
>
|
||||
> **Important change as of 3.19.1**: the DeepSeek preset now connects directly over native Responses and no longer needs local routing. The routing-conversion path is not obsolete, though — it remains the only way to reach `deepseek-v4-pro`, it still applies to providers saved before the upgrade, and it is how Chat-format providers such as Kimi and Zhipu GLM work. Read the next section first to find out which case you are in.
|
||||
|
||||
## First, check whether you still need this guide
|
||||
|
||||
There is exactly one way to tell: look for the `Needs Routing` badge on the Codex provider card.
|
||||
|
||||

|
||||
|
||||
- **Has the `Needs Routing` badge** → this provider is Chat-format, and the whole guide applies.
|
||||
- **No badge** → it already connects directly over native Responses, so the routing steps here are pointless for it. Just use it.
|
||||
- **Has the `No Routing Support` badge** → this is an official provider, and CC Switch blocks it from going through local routing (see the FAQ at the end).
|
||||
|
||||
The badge is driven by the API format recorded when the provider was saved, so upgrading CC Switch **does not** change how an existing provider behaves. For DeepSeek specifically, there are three cases after upgrading to 3.19.1:
|
||||
|
||||
| Your situation | Routing needed? | Notes |
|
||||
|---|---|---|
|
||||
| A DeepSeek provider saved before 3.19.1 | **Yes**, badge still shown | Preset changes only affect newly created providers; a saved configuration is kept as-is. To move it to a direct connection, see the end of Step 1 |
|
||||
| A DeepSeek provider created from the preset after 3.19.1 | No | Connects directly to `api.deepseek.com` and picks up DeepSeek's official model catalog |
|
||||
| You want to use `deepseek-v4-pro` | **Yes** | DeepSeek has not opened its Codex integration for that model yet (early August 2026 by their own estimate), so a direct connection fails upstream; it has to go through Chat + routing |
|
||||
|
||||
Beyond DeepSeek, plenty of providers are still Chat-format — Kimi, Zhipu GLM, SiliconFlow, ModelScope and others — and this guide applies to them in full. Just substitute the relevant preset wherever DeepSeek appears below.
|
||||
|
||||
## Why local routing is needed
|
||||
|
||||
The newer Codex CLI targets the OpenAI Responses API, while DeepSeek, Kimi, MiniMax, SiliconFlow, and many other providers expose the OpenAI Chat Completions shape, usually `/chat/completions`. These two protocols use different request bodies, streaming events, and response structures. If you put a Chat endpoint directly into Codex configuration, common results include an incorrect model list, 404/400 requests, or streaming responses that Codex cannot parse correctly.
|
||||
The newer Codex CLI targets the OpenAI Responses API, while many providers expose the OpenAI Chat Completions shape, usually `/chat/completions`. These two protocols use different request bodies, streaming events, and response structures. If you put a Chat endpoint directly into Codex configuration, common results include an incorrect model list, 404/400 requests, or streaming responses that Codex cannot parse correctly.
|
||||
|
||||
CC Switch solves this by making Codex always talk to a local route and continue sending Responses API requests. The route detects whether the active provider is Chat-format, rewrites the request into Chat Completions for the upstream provider, and finally converts the Chat response back into the Responses shape that Codex understands.
|
||||
|
||||

|
||||
|
||||
The chain has four main steps:
|
||||
|
||||
1. When Codex routing is enabled, the local configuration is written as `http://127.0.0.1:15721/v1`, while `wire_api = "responses"` is kept in place.
|
||||
@@ -17,56 +37,69 @@ The chain has four main steps:
|
||||
3. The route rewrites `/responses` or `/v1/responses` to `/chat/completions`, and converts the Responses request body into a Chat request body.
|
||||
4. After the upstream responds, the route converts the Chat JSON or SSE stream back into Responses JSON/SSE.
|
||||
|
||||
For a provider that is natively Responses — such as the current DeepSeek preset — steps 2 through 4 never happen: the request goes straight to the upstream with no format rewriting at all.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Prepare these three things first:
|
||||
|
||||
- CC Switch installed and able to start.
|
||||
- Codex CLI installed and run at least once, so the `~/.codex/config.toml` directory structure exists.
|
||||
- An API key from DeepSeek or another Chat Completions provider.
|
||||
- An API key for the provider you want to use.
|
||||
|
||||
DeepSeek's official documentation currently lists the OpenAI-compatible base URL as `https://api.deepseek.com` (other providers often use a base URL with a `/v1` suffix), and the Chat API path as `/chat/completions`. CC Switch's DeepSeek preset already contains these details, so prefer the preset and do not manually assemble the endpoint path.
|
||||
Taking DeepSeek as the example, its official documentation lists the OpenAI-compatible base URL as `https://api.deepseek.com` (other providers commonly use a base URL with a `/v1` suffix or a longer path — Zhipu GLM, for instance, uses `https://open.bigmodel.cn/api/coding/paas/v4`), and the Chat API path as `/chat/completions`. CC Switch's presets already carry these details, so prefer a preset and do not manually assemble the endpoint path.
|
||||
|
||||
## Step 1: Add a Codex provider
|
||||
|
||||
Open CC Switch, switch to the top-level `Codex` tab, and click the plus button in the upper-right corner to add a provider.
|
||||
|
||||
Choose the built-in `DeepSeek` preset. You only need to do two things:
|
||||
**Using a preset** (recommended): select the provider in the preset list, enter your API key, and save. The preset already carries the request base URL, default model, and model menu, and sets the upstream format for you; once a Chat-format preset is saved, the `Needs Routing` badge appears on its card. Thinking/reasoning parameters are preconfigured by the preset — you do not need to fill them in.
|
||||
|
||||
- Enter your DeepSeek API key.
|
||||
- Save the provider.
|
||||
**Using a custom configuration**: enter the API key and base URL from the provider's documentation, then expand `Advanced Options` at the bottom of the form and set `Upstream Format` to `Chat Completions (routing required)`. The dropdown offers three options:
|
||||
|
||||

|
||||
- `Responses (native)` — the upstream natively supports the Responses API; connects directly with no conversion and no routing.
|
||||
- `Chat Completions (routing required)` — the case this guide covers.
|
||||
- `Anthropic Messages (routing required)` — the upstream only offers the native Anthropic protocol, which the route converts as well.
|
||||
|
||||
The preset already includes DeepSeek's request base URL, default model, model menu, thinking/reasoning parameters, and automatically enables `Needs Local Routing`. You can adjust the default model or model display names if needed; the protocol conversion is handled by the routing layer.
|
||||
Only `Responses (native)` works without routing takeover; the other two require it. For custom providers, CC Switch infers the thinking parameters from the provider name and address; you only need to expand `Reasoning Capability` and override them manually when that inference is wrong.
|
||||
|
||||
> **Converting an existing DeepSeek provider**: change `Upstream Format` to `Responses (native)` — there is no need to delete and recreate it. The next time you switch to it, CC Switch recognises the `deepseek.com` address and applies DeepSeek's official model catalog, so freeform `apply_patch`, the GPT-5 harness, the low/high/max reasoning levels, and web_search all take effect as usual.
|
||||
>
|
||||
> The one small difference is the context window: a provider's own saved model rows take priority, so the `1000000` stored before 3.19.1 overrides the officially declared `1048576`, costing you a little over 40k tokens. If that bothers you, open `Advanced Options` → `Model Mapping` and change that row's `Context Window` to `1048576`, or simply create a fresh provider from the preset.
|
||||
>
|
||||
> Conversely, to use `deepseek-v4-pro`, change `Upstream Format` back to `Chat Completions`.
|
||||
>
|
||||
> One more thing: the official model catalog used by the direct connection requires Codex CLI **0.144.0 or newer** (the freeform `apply_patch` registration it carries needs that release), and CC Switch does not verify this for you. The generated catalog file also grows to roughly 75 KB, because it contains the full GPT-5 harness text.
|
||||
|
||||
## Step 2: Enable local routing and route Codex
|
||||
|
||||
Go to the `Routing` page in Settings, expand `Local Routing`, and complete two toggles:
|
||||
|
||||
1. Turn on the main routing switch to start the local service. The default address is `127.0.0.1:15721`.
|
||||
1. Turn on the `Routing Master Switch` to start the local service. The default address is `127.0.0.1:15721`.
|
||||
2. Turn on `Codex` under `Routing Enabled`. If you only want Codex to use local routing, you can leave Claude and Gemini off.
|
||||
|
||||

|
||||
|
||||
After routing is enabled, CC Switch points Codex's live configuration to the local route and manages authentication with a placeholder. The real DeepSeek key stays in the CC Switch provider configuration and is injected by the local route while forwarding requests, so you do not need to expose the key in Codex's live configuration.
|
||||
After routing is enabled, CC Switch points Codex's live configuration to the local route and manages authentication with a placeholder. The real API key stays in the CC Switch provider configuration and is injected by the local route while forwarding requests, so you do not need to expose the key in Codex's live configuration.
|
||||
|
||||
## Step 3: Switch providers and restart Codex
|
||||
|
||||
Return to the Codex provider list and click `Enable` on the DeepSeek provider. If you see the `Needs Routing` marker, that provider must be used while routing is running; when the route is not started, CC Switch shows a prompt saying the routing service is required.
|
||||
Return to the Codex provider list and click `Enable` on the provider you want. If it carries the `Needs Routing` marker while routing is not running, CC Switch shows a prompt saying the routing service is required.
|
||||
|
||||
After switching, restart the current Codex terminal session. This is recommended because:
|
||||
|
||||
- The Codex process may already have read the old `config.toml`.
|
||||
- After `model_catalog_json` is generated, the `/model` menu usually needs a fresh process before it refreshes.
|
||||
|
||||
Inside Codex, use `/model` to check whether the current model comes from the DeepSeek preset, such as `DeepSeek V4 Flash`. The Codex app currently does not support multi-model selection, so it defaults to the first configured model. Then send a small test prompt and confirm that the request count increases in the routing panel, or that a Codex request appears in usage/request logs.
|
||||
Inside Codex, use `/model` to check whether the current model comes from the matching preset. Then send a small test prompt and confirm that the request count increases in the routing panel, or that a Codex request appears in usage/request logs.
|
||||
|
||||
## How to handle other Chat providers
|
||||
## Usage attribution changes once you connect directly
|
||||
|
||||
DeepSeek, Kimi, MiniMax, SiliconFlow, and other common Chat-format providers already have presets in CC Switch, so use presets first. Only choose custom configuration for providers that are not covered by presets; in that case, fill in the API key, base URL, and models according to the provider's documentation, and set `API Format` to `OpenAI Chat Completions (requires routing)`.
|
||||
This is worth calling out on its own: once a provider connects directly, its requests no longer pass through the local route, so the per-request proxy usage accounting cannot see them.
|
||||
|
||||
If the upstream provider directly supports the OpenAI Responses API, you do not need to enable `Needs Local Routing`; CC Switch can connect through Responses directly without Chat conversion.
|
||||
The usage itself is not lost — 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 entry named `Codex (Session)`. **To tell them apart, look at the model**: every usage row keeps its own model id, and the usage panel's `Model Stats` table lists them per model, with separate cost and token figures.
|
||||
|
||||
If you genuinely need to reconcile by provider — comparing the same model across several aggregators, for instance — keep `Upstream Format` on Chat and leave routing takeover enabled.
|
||||
|
||||
## FAQ
|
||||
|
||||
@@ -74,18 +107,22 @@ If the upstream provider directly supports the OpenAI Responses API, you do not
|
||||
|
||||
Usually Codex routing is not enabled, or the upstream Chat base URL was written directly into Codex manually. Check whether `~/.codex/config.toml` points to `http://127.0.0.1:15721/v1`.
|
||||
|
||||
**DeepSeek upstream reports 404**
|
||||
**The upstream reports 404**
|
||||
|
||||
If you are using the built-in DeepSeek preset, first confirm that the active provider really comes from the preset and that Codex routing is enabled. Only custom providers require extra base URL checks: the base URL should be the service root, not the full endpoint path with `/chat/completions`.
|
||||
If you are using a built-in preset, first confirm that the active provider really comes from the preset and that Codex routing is enabled. Only custom providers require extra base URL checks: it should be the service endpoint given in the provider's documentation, not the full endpoint path with `/chat/completions`.
|
||||
|
||||
**`/model` does not show DeepSeek models**
|
||||
**Switching to `deepseek-v4-pro` fails upstream**
|
||||
|
||||
DeepSeek has not opened its Codex integration for that model yet. Change that provider's `Upstream Format` back to `Chat Completions (routing required)` and enable routing takeover — that is exactly the path DeepSeek took before 3.19.1, and the route's Responses-to-Chat conversion serves pro as it always did. Alternatively use `deepseek-v4-flash`, which is the preset default and unaffected.
|
||||
|
||||
**`/model` does not show the provider's models**
|
||||
|
||||
Restart Codex after saving the provider. CC Switch generates `cc-switch-model-catalog.json` and writes its path to `model_catalog_json`, but a running Codex process may not hot-load the model catalog.
|
||||
The Codex app currently does not support multi-model selection, so it uses the first configured model by default.
|
||||
|
||||
**Routing is enabled, but requests still go to the wrong provider**
|
||||
|
||||
Confirm that all three states match: the current provider under the Codex tab is DeepSeek; the local routing service is running; and the Codex toggle is enabled under `Routing Enabled`.
|
||||
Confirm that all three states match: the current provider under the Codex tab is the right one; the local routing service is running; and the Codex toggle is enabled under `Routing Enabled`.
|
||||
|
||||
**Can I use an official OpenAI Codex account through local routing?**
|
||||
|
||||
@@ -96,6 +133,8 @@ Not recommended. CC Switch blocks switching to official providers while local ro
|
||||
- [CC Switch User Manual: Add Provider](../user-manual/en/2-providers/2.1-add.md)
|
||||
- [CC Switch User Manual: Proxy Service](../user-manual/en/4-proxy/4.1-service.md)
|
||||
- [CC Switch User Manual: App Routing](../user-manual/en/4-proxy/4.2-routing.md)
|
||||
- [DeepSeek API Docs: Integrate with Codex](https://api-docs.deepseek.com/quick_start/agent_integrations/codex/) (official Codex integration notes, including `wire_api = "responses"` and model coverage)
|
||||
- [DeepSeek API Docs: Using the Responses API](https://api-docs.deepseek.com/guides/responses_api/)
|
||||
- [DeepSeek API Docs: Your First API Call](https://api-docs.deepseek.com/)
|
||||
- [DeepSeek API Docs: Create Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)
|
||||
- [DeepSeek API Docs: Multi-round Conversation](https://api-docs.deepseek.com/guides/multi_round_chat)
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
# Codex で DeepSeek などの Chat 形式 API を使う: CC Switch ローカルルーティングガイド
|
||||
|
||||
> 対象バージョン: CC Switch 3.16.0 およびその前後のバージョン。本記事はリポジトリ内のドキュメントとコードをもとに整理し、OpenAI Chat Completions 互換 API の例として DeepSeek を使用します。スクリーンショットは現在のフロントエンド UI から、実際の API Key やアカウント残高が漏れないよう匿名化したサンプルデータで生成しています。
|
||||
> 対象バージョン: CC Switch 3.19.1 以降。本記事はリポジトリ内のドキュメントとコードをもとに整理しています。スクリーンショットは実際の API Key やアカウント残高が漏れないよう匿名化したサンプルデータで生成しています。
|
||||
>
|
||||
> **3.19.1 からの重要な変更**: DeepSeek プリセットはネイティブ Responses による直結に変わり、ローカルルーティングを必要としなくなりました。ただし、このルーティング変換の経路がなくなったわけではありません——`deepseek-v4-pro` を使う場合、アップグレード前に保存したプロバイダー、そして Kimi や Zhipu GLM など Chat 形式のままのプロバイダーでは、引き続きこの経路が必要です。まず次のセクションで、自分がどのケースに当てはまるかを確認してください。
|
||||
|
||||
## まず、このガイドが今も必要か確認する
|
||||
|
||||
判別方法は 1 つだけです。Codex プロバイダーカードに `ルーティングが必要` バッジが付いているかを見てください。
|
||||
|
||||

|
||||
|
||||
- **`ルーティングが必要` バッジあり** → このプロバイダーは Chat 形式で、本記事がそのまま当てはまります。
|
||||
- **バッジなし** → すでにネイティブ Responses で直結しているため、本記事のルーティング手順は不要です。そのまま使えます。
|
||||
- **`ルーティング非対応` バッジあり** → 公式プロバイダーであり、CC Switch はローカルルーティング経由での使用をブロックします(末尾のよくある質問を参照)。
|
||||
|
||||
このバッジはプロバイダー保存時に記録された API 形式で決まるため、CC Switch をアップグレードしても既存プロバイダーの挙動は**変わりません**。DeepSeek に関しては、3.19.1 へのアップグレード後に次の 3 つのケースがあります。
|
||||
|
||||
| あなたの状況 | ルーティングは必要か | 説明 |
|
||||
|---|---|---|
|
||||
| 3.19.1 より前に保存した DeepSeek プロバイダー | **必要**(バッジも表示されたまま) | プリセットの変更は新規作成したプロバイダーにのみ影響し、保存済みの設定はそのまま維持されます。直結に変えたい場合は Step 1 の末尾を参照してください |
|
||||
| 3.19.1 以降にプリセットから新規作成した DeepSeek | 不要 | `api.deepseek.com` へ直結し、DeepSeek 公式のモデルカタログを取得します |
|
||||
| `deepseek-v4-pro` を使いたい | **必要** | DeepSeek はこのモデルの Codex 連携をまだ開放しておらず(公式の見込みでは 2026 年 8 月初旬)、直結では上流エラーになります。Chat + ルーティングを通す必要があります |
|
||||
|
||||
DeepSeek 以外にも、Kimi、Zhipu GLM、SiliconFlow、ModelScope など多くのプロバイダーが Chat 形式のままであり、本記事はそれらにも完全に当てはまります。以下の DeepSeek を該当のプリセットに読み替えてください。
|
||||
|
||||
## ローカルルーティングが必要な理由
|
||||
|
||||
新しい Codex CLI は OpenAI Responses API を前提にしています。一方で DeepSeek、Kimi、MiniMax、SiliconFlow など多くのプロバイダーが実際に公開しているのは OpenAI Chat Completions 形式、つまり `/chat/completions` です。この 2 つのプロトコルは、リクエストボディ、ストリーミングイベント、レスポンス構造が異なります。Chat エンドポイントをそのまま Codex 設定に入れると、モデル一覧が合わない、リクエストが 404/400 になる、ストリーミングレスポンスを Codex が正しく解析できない、といった問題が起きがちです。
|
||||
新しい Codex CLI は OpenAI Responses API を前提にしています。一方で多くのプロバイダーが実際に公開しているのは OpenAI Chat Completions 形式、つまり `/chat/completions` です。この 2 つのプロトコルは、リクエストボディ、ストリーミングイベント、レスポンス構造が異なります。Chat エンドポイントをそのまま Codex 設定に入れると、モデル一覧が合わない、リクエストが 404/400 になる、ストリーミングレスポンスを Codex が正しく解析できない、といった問題が起きがちです。
|
||||
|
||||
CC Switch では、Codex が常にローカルルートへ接続し、Responses API のままリクエストを送るようにします。ルート内部で現在のプロバイダーが Chat 形式かどうかを判定し、必要ならリクエストを Chat Completions に書き換えて上流へ送り、最後に Chat レスポンスを Codex が理解できる Responses 形式へ戻します。
|
||||
|
||||

|
||||
|
||||
この経路は主に 4 つのステップに分かれます:
|
||||
|
||||
1. Codex ルーティングを有効にすると、ローカル設定は `http://127.0.0.1:15721/v1` に書き換えられ、`wire_api = "responses"` は維持されます。
|
||||
@@ -17,28 +37,39 @@ CC Switch では、Codex が常にローカルルートへ接続し、Responses
|
||||
3. ルートは `/responses` または `/v1/responses` を `/chat/completions` に書き換え、Responses のリクエストボディを Chat のリクエストボディへ変換します。
|
||||
4. 上流から返ってきた後、ルートは Chat の JSON または SSE ストリームを Responses JSON/SSE へ変換して返します。
|
||||
|
||||
上流がもともとネイティブ Responses のプロバイダー(現在の DeepSeek プリセットなど)では、ステップ 2〜4 は発生しません。リクエストはそのまま上流へ送られ、形式の書き換えは一切行われません。
|
||||
|
||||
## 事前準備
|
||||
|
||||
先に次の 3 つを用意してください:
|
||||
|
||||
- インストール済みで起動できる CC Switch。
|
||||
- インストール済みの Codex CLI。少なくとも 1 回は実行し、`~/.codex/config.toml` のディレクトリ構造が存在していること。
|
||||
- DeepSeek または同種の Chat Completions プロバイダーの API Key。
|
||||
- 対象プロバイダーの API Key。
|
||||
|
||||
DeepSeek 公式ドキュメントでは、OpenAI 互換 base URL は現在 `https://api.deepseek.com`(他のプロバイダーでは `/v1` 付きの base URL もよくあります)、Chat API のパスは `/chat/completions` と記載されています。CC Switch の DeepSeek プリセットにはこれらの情報がすでに入っているため、まずはプリセットを使い、エンドポイントパスを手で組み立てる必要はありません。
|
||||
DeepSeek を例にとると、公式ドキュメントでは OpenAI 互換 base URL は `https://api.deepseek.com`(他のプロバイダーでは `/v1` 付きやより長いパスの base URL もよくあります。たとえば Zhipu GLM は `https://open.bigmodel.cn/api/coding/paas/v4` です)、Chat API のパスは `/chat/completions` と記載されています。CC Switch のプリセットにはこれらの情報がすでに入っているため、まずはプリセットを使い、エンドポイントパスを手で組み立てる必要はありません。
|
||||
|
||||
## Step 1: Codex プロバイダーを追加する
|
||||
|
||||
CC Switch を開き、上部の `Codex` タブへ切り替え、右上のプラスボタンからプロバイダーを追加します。
|
||||
|
||||
内蔵プリセットの `DeepSeek` を選びます。必要なのは次の 2 つだけです:
|
||||
**プリセットを使う**(推奨): プリセット一覧から対象プロバイダーを選び、API Key を入力して保存するだけです。プリセットにはリクエスト先、デフォルトモデル、モデルメニューがすでに含まれ、上流フォーマットも自動的に設定されます。Chat 形式のプリセットは保存するとカードに `ルーティングが必要` バッジが表示されます。thinking / reasoning パラメータもプリセットで設定済みのため、手動入力は不要です。
|
||||
|
||||
- DeepSeek API Key を入力する。
|
||||
- プロバイダーを保存する。
|
||||
**カスタム設定を使う**: 相手側のドキュメントに従って API Key と base URL を入力し、フォーム下部の `高級オプション` を展開して、`上流フォーマット` を `Chat Completions(ルーティング必須)` に設定します。このドロップダウンには 3 つの選択肢があります:
|
||||
|
||||

|
||||
- `Responses(ネイティブ)`——上流が Responses API をネイティブにサポートしている場合。変換なしで直結でき、ルーティングは不要です。
|
||||
- `Chat Completions(ルーティング必須)`——本記事が扱うケースです。
|
||||
- `Anthropic Messages(ルーティング必須)`——上流がネイティブ Anthropic プロトコルのみを提供している場合。こちらもルーティングが変換します。
|
||||
|
||||
プリセットには DeepSeek のリクエスト先、デフォルトモデル、モデルメニュー、thinking/reasoning パラメータがすでに含まれており、`ローカルルーティングが必要` も自動的に有効になります。必要に応じてデフォルトモデルやモデル表示名を調整できますが、プロトコル変換はルーティング層に任せれば十分です。
|
||||
ルーティング接管が不要なのは `Responses(ネイティブ)` だけで、残りの 2 つは必要です。カスタムプロバイダーの thinking パラメータは CC Switch が名前とアドレスから自動推論するため、推論が正しくないときにだけ `思考能力` を展開して手動で上書きしてください。
|
||||
|
||||
> **既存の DeepSeek プロバイダーを移行する場合**: `上流フォーマット` を `Responses(ネイティブ)` に変更するだけでよく、削除して作り直す必要はありません。次回そのプロバイダーへ切り替えたとき、CC Switch が `deepseek.com` のアドレスを認識して DeepSeek 公式のモデルカタログを適用するため、freeform `apply_patch`、GPT-5 harness、low/high/max の思考レベル、web_search はいずれも通常どおり有効になります。
|
||||
>
|
||||
> 唯一わずかに異なるのはコンテキストウィンドウです。プロバイダー自身が保存したモデル行のほうが優先されるため、3.19.1 より前に保存された `1000000` が公式宣言の `1048576` を上書きし、4 万トークン強が失われます。気になる場合は `高級オプション` → `モデルマッピング` でその行の `コンテキストウィンドウ` を `1048576` に変更するか、いっそプリセットから新規作成してください。
|
||||
>
|
||||
> 逆に `deepseek-v4-pro` を使いたい場合は、`上流フォーマット` を `Chat Completions` に戻します。
|
||||
>
|
||||
> なお、直結で使われる公式モデルカタログは Codex CLI **0.144.0 以降**を必要とします(カタログに含まれる freeform `apply_patch` の登録がこのバージョンを要求します)。CC Switch はこれを検証しません。また、生成されるカタログファイルは GPT-5 harness の全文を含むため 75 KB 程度まで大きくなります。
|
||||
|
||||
## Step 2: ローカルルーティングを有効にして Codex をルーティングする
|
||||
|
||||
@@ -49,24 +80,26 @@ CC Switch を開き、上部の `Codex` タブへ切り替え、右上のプラ
|
||||
|
||||

|
||||
|
||||
ルーティングを有効にすると、CC Switch は Codex の live 設定をローカルルートへ向け、認証はプレースホルダーで管理します。実際の DeepSeek Key は CC Switch の Provider 設定内に残り、ローカルルートが転送時に注入します。そのため、Codex の live 設定に Key を露出させる必要はありません。
|
||||
ルーティングを有効にすると、CC Switch は Codex の live 設定をローカルルートへ向け、認証はプレースホルダーで管理します。実際の API Key は CC Switch の Provider 設定内に残り、ローカルルートが転送時に注入します。そのため、Codex の live 設定に Key を露出させる必要はありません。
|
||||
|
||||
## Step 3: プロバイダーを切り替えて Codex を再起動する
|
||||
|
||||
Codex プロバイダー一覧に戻り、DeepSeek プロバイダーの `有効化` をクリックします。`ルーティングが必要` の表示が見える場合、そのプロバイダーはルーティング実行中に使う必要があります。ルーティングが起動していない場合、CC Switch は「ルーティングサービスが必要」という趣旨のメッセージを表示します。
|
||||
Codex プロバイダー一覧に戻り、対象プロバイダーの `有効化` をクリックします。`ルーティングが必要` の表示があるのにルーティングが起動していない場合、CC Switch は「ルーティングサービスが必要」という趣旨のメッセージを表示します。
|
||||
|
||||
切り替え後は、現在の Codex ターミナルセッションを再起動することをおすすめします。理由は次のとおりです:
|
||||
|
||||
- Codex プロセスがすでに古い `config.toml` を読み込んでいる可能性があります。
|
||||
- `model_catalog_json` の生成後、`/model` メニューの更新には通常、新しいプロセスが必要です。
|
||||
|
||||
Codex に入ったら、`/model` で現在のモデルが DeepSeek プリセット由来かどうかを確認します。たとえば `DeepSeek V4 Flash` などです。現在の Codex app は複数モデル選択に対応していないため、設定内の最初のモデルをデフォルトで使用します。その後、小さな質問を 1 つ送って、ルーティングパネルのリクエスト数が増えるか、usage / リクエストログに Codex リクエストが出るかを確認します。
|
||||
Codex に入ったら、`/model` で現在のモデルが該当プリセット由来かどうかを確認します。その後、小さな質問を 1 つ送って、ルーティングパネルのリクエスト数が増えるか、usage / リクエストログに Codex リクエストが出るかを確認します。
|
||||
|
||||
## 他の Chat プロバイダーの場合
|
||||
## 直結に切り替えると、使用量の帰属が変わる
|
||||
|
||||
DeepSeek、Kimi、MiniMax、SiliconFlow など一般的な Chat 形式プロバイダーは CC Switch にプリセットがあるため、まずはプリセットを使ってください。プリセットにないプロバイダーだけ、カスタム設定を選びます。その場合は相手側のドキュメントに従って API Key、base URL、モデルを入力し、`API 形式` を `OpenAI Chat Completions (ルーティングが必要)` に設定します。
|
||||
これは単独で触れておく価値があります。プロバイダーが直結に変わると、そのリクエストはローカルルートを通らなくなるため、リクエスト単位で計上するプロキシ使用量統計からは見えなくなります。
|
||||
|
||||
上流が OpenAI Responses API を直接サポートしている場合は、`ローカルルーティングが必要` を有効にする必要はありません。その場合、CC Switch は Responses のまま直結でき、Chat 変換は行いません。
|
||||
使用量そのものが失われるわけではありません——Codex のセッションログ取り込みは通常どおり記録します——ただしこの経路はプロバイダーの識別情報を持ちません。ローカルプロキシを通らなかった Codex の使用量は、すべて `Codex (Session)` という 1 つの項目にまとめられます。**見分けるにはモデルを見てください**: 各使用量レコードはそれぞれのモデル ID を保持しており、使用量パネルの `モデル統計` がモデルごとに行を分けて表示するため、費用もトークンも別々に確認できます。
|
||||
|
||||
プロバイダー単位で突き合わせる必要が本当にある場合(複数の集約サービスで同じモデルを比較する場合など)は、`上流フォーマット` を Chat のままにして、ルーティング接管を有効にしておいてください。
|
||||
|
||||
## よくある質問
|
||||
|
||||
@@ -74,18 +107,22 @@ DeepSeek、Kimi、MiniMax、SiliconFlow など一般的な Chat 形式プロバ
|
||||
|
||||
多くの場合、Codex ルーティングが有効になっていないか、上流 Chat base URL を手動で Codex に直接書いています。`~/.codex/config.toml` が `http://127.0.0.1:15721/v1` を指しているか確認してください。
|
||||
|
||||
**DeepSeek 上流が 404 を返す**
|
||||
**上流が 404 を返す**
|
||||
|
||||
内蔵 DeepSeek プリセットを使っている場合は、まず現在のプロバイダーが本当にプリセット由来であること、そして Codex ルーティングが有効であることを確認してください。カスタムプロバイダーを使っている場合だけ、base URL を追加で確認します。base URL はサービスのルートであり、`/chat/completions` 付きの完全なエンドポイントパスではありません。
|
||||
内蔵プリセットを使っている場合は、まず現在のプロバイダーが本当にプリセット由来であること、そして Codex ルーティングが有効であることを確認してください。カスタムプロバイダーを使っている場合だけ、base URL を追加で確認します。base URL は相手側のドキュメントが示すサービスエンドポイントであり、`/chat/completions` 付きの完全なエンドポイントパスではありません。
|
||||
|
||||
**`/model` に DeepSeek モデルが表示されない**
|
||||
**`deepseek-v4-pro` に切り替えると上流エラーになる**
|
||||
|
||||
DeepSeek はこのモデルの Codex 連携をまだ開放していません。そのプロバイダーの `上流フォーマット` を `Chat Completions(ルーティング必須)` に戻し、ルーティング接管を有効にしてください——これは 3.19.1 より前に DeepSeek が通っていた経路そのもので、ルートの Responses→Chat 変換は従来どおり pro に対応しています。または、プリセットのデフォルト値であり影響を受けない `deepseek-v4-flash` を使ってください。
|
||||
|
||||
**`/model` にプロバイダーのモデルが表示されない**
|
||||
|
||||
プロバイダーを保存した後、Codex を再起動してください。CC Switch は `cc-switch-model-catalog.json` を生成し、そのパスを `model_catalog_json` に書き込みますが、実行中の Codex プロセスがモデルカタログをホットロードするとは限りません。
|
||||
現在の Codex app は複数モデル選択に対応していないため、設定内の最初のモデルをデフォルトで使用します。
|
||||
|
||||
**ルーティングを有効にしたのに、リクエストが別のプロバイダーへ行く**
|
||||
|
||||
次の 3 つの状態が一致しているか確認してください:Codex タブの現在のプロバイダーが DeepSeek であること、ローカルルーティングサービスが実行中であること、`ルーティング有効` で Codex スイッチがオンであること。
|
||||
次の 3 つの状態が一致しているか確認してください:Codex タブの現在のプロバイダーが正しいこと、ローカルルーティングサービスが実行中であること、`ルーティング有効` で Codex スイッチがオンであること。
|
||||
|
||||
**公式 OpenAI Codex アカウントをローカルルーティング経由で使えますか**
|
||||
|
||||
@@ -96,6 +133,8 @@ DeepSeek、Kimi、MiniMax、SiliconFlow など一般的な Chat 形式プロバ
|
||||
- [CC Switch ユーザーマニュアル: プロバイダーの追加](../user-manual/ja/2-providers/2.1-add.md)
|
||||
- [CC Switch ユーザーマニュアル: プロキシサービス](../user-manual/ja/4-proxy/4.1-service.md)
|
||||
- [CC Switch ユーザーマニュアル: アプリケーションルーティング](../user-manual/ja/4-proxy/4.2-routing.md)
|
||||
- [DeepSeek API Docs: Integrate with Codex](https://api-docs.deepseek.com/quick_start/agent_integrations/codex/)(公式 Codex 連携の説明。`wire_api = "responses"` と対応モデルの範囲を含む)
|
||||
- [DeepSeek API Docs: Using the Responses API](https://api-docs.deepseek.com/guides/responses_api/)
|
||||
- [DeepSeek API Docs: Your First API Call](https://api-docs.deepseek.com/)
|
||||
- [DeepSeek API Docs: Create Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)
|
||||
- [DeepSeek API Docs: Multi-round Conversation](https://api-docs.deepseek.com/guides/multi_round_chat)
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
# 在 Codex 中用 DeepSeek 这类 Chat 格式 API:CC Switch 本地路由攻略
|
||||
|
||||
> 适用版本:CC Switch 3.16.0 及附近版本。本文根据仓库内文档与代码整理,并用 DeepSeek 作为 OpenAI Chat Completions 兼容接口的示例。截图来自当前前端界面,使用去敏示例数据生成,避免泄露真实 API Key 或账户余额。
|
||||
> 适用版本:CC Switch 3.19.1 及以上。本文根据仓库内文档与代码整理。截图使用去敏示例数据生成,避免泄露真实 API Key 或账户余额。
|
||||
>
|
||||
> **3.19.1 起有重要变化**:DeepSeek 预设已改为原生 Responses 直连,不再需要本地路由。但这条路由转换的路径并没有作废——它仍是 `deepseek-v4-pro`、升级前已保存的供应商,以及 Kimi、智谱 GLM 等 Chat 格式供应商的必经之路。请先读下一节,确认你属于哪种情况。
|
||||
|
||||
## 先确认你是否还需要这篇攻略
|
||||
|
||||
判断方法只有一个,看 Codex 供应商卡片上有没有 `需要路由` 徽章:
|
||||
|
||||

|
||||
|
||||
- **带 `需要路由` 徽章** → 这个供应商走 Chat 格式,本文全部适用。
|
||||
- **没有徽章** → 它已经是 Responses 原生直连,本文的路由步骤对它没有意义,可以直接用。
|
||||
- **带 `不支持路由` 徽章** → 这是官方供应商,CC Switch 会阻止它走本地路由(见文末常见问题)。
|
||||
|
||||
徽章由供应商保存时记录的 API 格式决定,所以升级 CC Switch **不会**改变已有供应商的行为。具体到 DeepSeek,升级到 3.19.1 之后有三种情况:
|
||||
|
||||
| 你的情况 | 是否需要路由 | 说明 |
|
||||
|---|---|---|
|
||||
| 3.19.1 之前保存的 DeepSeek 供应商 | **需要**,仍带徽章 | 预设改动只影响新建的供应商,已保存的配置原样保留;想改走直连见第一步末尾 |
|
||||
| 3.19.1 之后用预设新建的 DeepSeek | 不需要 | 直连 `api.deepseek.com`,并会拿到 DeepSeek 官方的模型目录 |
|
||||
| 想用 `deepseek-v4-pro` | **需要** | DeepSeek 官方尚未为该模型开通 Codex 集成(官方预计 2026 年 8 月初),直连会上游报错;必须走 Chat + 路由 |
|
||||
|
||||
除 DeepSeek 外,Kimi、智谱 GLM、SiliconFlow、ModelScope 等大量供应商仍是 Chat 格式,本文对它们完全适用——把下文中的 DeepSeek 换成对应预设即可。
|
||||
|
||||
## 为什么需要本地路由
|
||||
|
||||
新版 Codex CLI 面向的是 OpenAI Responses API,而 DeepSeek、Kimi、MiniMax、SiliconFlow 等很多供应商实际暴露的是 OpenAI Chat Completions 形态,也就是 `/chat/completions`。这两种协议的请求体、流式事件和返回结构不同,直接把 Chat 接口填进 Codex 配置里,常见结果就是模型列表不对、请求 404/400,或者流式响应无法被 Codex 正确解析。
|
||||
新版 Codex CLI 面向的是 OpenAI Responses API,而很多供应商实际暴露的是 OpenAI Chat Completions 形态,也就是 `/chat/completions`。这两种协议的请求体、流式事件和返回结构不同,直接把 Chat 接口填进 Codex 配置里,常见结果就是模型列表不对、请求 404/400,或者流式响应无法被 Codex 正确解析。
|
||||
|
||||
CC Switch 的做法是让 Codex 始终连本机路由,仍以 Responses API 发送请求;路由在内部识别当前供应商是否是 Chat 格式,再把请求改写成 Chat Completions 发给上游,最后把 Chat 响应转换回 Responses 形态返回给 Codex。
|
||||
|
||||

|
||||
|
||||
这条链路主要分成四步:
|
||||
|
||||
1. Codex 接管时,本地配置会被写成 `http://127.0.0.1:15721/v1`,并强制保持 `wire_api = "responses"`。
|
||||
@@ -17,28 +37,39 @@ CC Switch 的做法是让 Codex 始终连本机路由,仍以 Responses API 发
|
||||
3. 路由把 `/responses` 或 `/v1/responses` 改写到 `/chat/completions`,并把 Responses 请求体转换成 Chat 请求体。
|
||||
4. 上游返回后,路由再把 Chat 的 JSON 或 SSE 转回 Codex 能理解的 Responses JSON/SSE。
|
||||
|
||||
供应商原生就是 Responses 的(如现在的 DeepSeek 预设),第 2~4 步不发生:请求直接发往上游,不做任何格式改写。
|
||||
|
||||
## 准备工作
|
||||
|
||||
你需要先准备好三样东西:
|
||||
|
||||
- 已安装并能启动的 CC Switch。
|
||||
- 已安装 Codex CLI,并至少运行过一次,让 `~/.codex/config.toml` 目录结构存在。
|
||||
- DeepSeek 或同类 Chat Completions 供应商的 API Key。
|
||||
- 目标供应商的 API Key。
|
||||
|
||||
DeepSeek 官方文档目前写明 OpenAI 兼容 base URL 是 `https://api.deepseek.com`(其他供应商常见的是带 `/v1` 后缀的 base URL),Chat API 路径是 `/chat/completions`;CC Switch 的 DeepSeek 预设已经按这些信息配好,请优先使用预设,不需要手动拼接口路径。
|
||||
以 DeepSeek 为例,官方文档写明 OpenAI 兼容 base URL 是 `https://api.deepseek.com`(其他供应商常见的是带 `/v1` 或更长路径的 base URL,例如智谱 GLM 是 `https://open.bigmodel.cn/api/coding/paas/v4`),Chat API 路径是 `/chat/completions`。CC Switch 的预设已按这些信息配好,请优先使用预设,不需要手动拼接口路径。
|
||||
|
||||
## 第一步:添加 Codex 供应商
|
||||
|
||||
打开 CC Switch,切到顶部的 `Codex` 标签,点击右上角的加号添加供应商。
|
||||
|
||||
选择内置预设里的 `DeepSeek`,只需要做两件事:
|
||||
**用预设**(推荐):在预设列表里选中目标供应商,填入 API Key,保存即可。预设已经内置请求地址、默认模型、模型菜单,并会自动设好上游格式;Chat 格式的预设保存后卡片上就会出现 `需要路由` 徽章。思考参数(thinking / reasoning)预设已自动配置好,不需要手动填。
|
||||
|
||||
- 填入 DeepSeek API Key。
|
||||
- 保存供应商。
|
||||
**用自定义配置**:按对方文档填 API Key 和 base URL,然后展开表单底部的 `高级选项`,把 `上游格式` 选为 `Chat Completions(需开启路由)`。这个下拉共有三个选项:
|
||||
|
||||

|
||||
- `Responses(原生)`——上游原生支持 Responses API,直连不转换,无需路由。
|
||||
- `Chat Completions(需开启路由)`——本文讲的情况。
|
||||
- `Anthropic Messages(需开启路由)`——上游只提供原生 Anthropic 协议,同样由路由转换。
|
||||
|
||||
预设已经内置 DeepSeek 的请求地址、默认模型、模型菜单、thinking/reasoning 参数,并会自动打开 `需要本地路由映射`。你可以按需调整默认模型或模型显示名;协议转换交给路由层完成即可。
|
||||
只有 `Responses(原生)` 不需要开启路由接管,另外两个都需要。自定义供应商的思考参数由 CC Switch 按名称与地址自动推断,只有在识别不准时才需要展开 `思考能力` 手动覆盖。
|
||||
|
||||
> **改造已有的 DeepSeek 供应商**:把 `上游格式` 改成 `Responses(原生)` 即可,不必删掉重建。下次切换到它时,CC Switch 会认出 `deepseek.com` 地址并套用 DeepSeek 官方的模型目录,freeform `apply_patch`、GPT-5 harness、low/high/max 思考档与 web_search 都会照常生效。
|
||||
>
|
||||
> 唯一的小差别是上下文窗口:供应商自己保存的模型行优先级更高,3.19.1 之前存下的 `1000000` 会盖掉官方声明的 `1048576`,少 4 万多 token。介意的话,在 `高级选项` → `模型映射` 里把该行的 `上下文窗口` 改成 `1048576` 就行,或者干脆用预设新建一个。
|
||||
>
|
||||
> 反过来,想用 `deepseek-v4-pro`,就把 `上游格式` 改回 `Chat Completions`。
|
||||
>
|
||||
> 另外,直连所用的官方模型目录要求 Codex CLI **0.144.0 或更新**(它带的 freeform `apply_patch` 注册需要这个版本),CC Switch 不会替你校验;生成的目录文件也会涨到 75 KB 左右,因为其中包含完整的 GPT-5 harness 文本。
|
||||
|
||||
## 第二步:开启本地路由并接管 Codex
|
||||
|
||||
@@ -49,24 +80,26 @@ DeepSeek 官方文档目前写明 OpenAI 兼容 base URL 是 `https://api.deepse
|
||||
|
||||

|
||||
|
||||
接管后,CC Switch 会把 Codex 的 live 配置指向本机路由,并用占位符管理认证。真实 DeepSeek Key 仍保存在 CC Switch 的 Provider 配置里,由本地路由在转发时注入,不需要你把 Key 暴露给 Codex live 配置。
|
||||
接管后,CC Switch 会把 Codex 的 live 配置指向本机路由,并用占位符管理认证。真实 API Key 仍保存在 CC Switch 的 Provider 配置里,由本地路由在转发时注入,不需要你把 Key 暴露给 Codex live 配置。
|
||||
|
||||
## 第三步:切换供应商并重启 Codex
|
||||
|
||||
回到 Codex 供应商列表,点击 DeepSeek 供应商的 `启用`。如果看到 `需要路由` 标记,说明这个供应商必须在路由运行时使用;没有启动路由时,CC Switch 会弹出“需要路由服务才能正常使用”的提示。
|
||||
回到 Codex 供应商列表,点击目标供应商的 `启用`。如果它带 `需要路由` 标记而路由没有启动,CC Switch 会弹出“需要路由服务才能正常使用”的提示。
|
||||
|
||||
切换后建议重启当前 Codex 终端会话。原因是:
|
||||
|
||||
- Codex 进程可能已经读取过旧的 `config.toml`。
|
||||
- `model_catalog_json` 生成后,`/model` 菜单通常需要新进程才能刷新。
|
||||
|
||||
进入 Codex 后,可以用 `/model` 查看当前模型是否来自 DeepSeek 预设,例如 `DeepSeek V4 Flash`。目前 Codex app 不支持多模型选择时,会默认使用配置里的第一个模型。随后发一个小问题,确认路由面板的请求数增长,或者在用量/请求日志里看到 Codex 请求即可。
|
||||
进入 Codex 后,可以用 `/model` 查看当前模型是否来自对应预设。随后发一个小问题,确认路由面板的请求数增长,或者在用量/请求日志里看到 Codex 请求即可。
|
||||
|
||||
## 其它 Chat 供应商怎么处理
|
||||
## 走直连之后,用量归属会变
|
||||
|
||||
DeepSeek、Kimi、MiniMax、SiliconFlow 等常见 Chat 格式供应商在 CC Switch 里已有预设,优先用预设即可。只有预设里没有的供应商,才需要选择自定义配置;这时按对方文档填 API Key、base URL 和模型,并把 `API 格式` 选为 `OpenAI Chat Completions (需开启路由)`。
|
||||
这一点值得单独提醒:供应商改走直连后,它的请求不再经过本地路由,按请求计费的代理用量统计也就看不到它了。
|
||||
|
||||
如果上游直接支持 OpenAI Responses API,就不需要打开 `需要本地路由映射`;这时 CC Switch 可以按 Responses 直连,不做 Chat 转换。
|
||||
用量本身不会丢——Codex 的会话日志导入照常记录——但这条路径不携带供应商身份:所有没走本地代理的 Codex 用量会一起归入名为 `Codex (Session)` 的条目。**要区分它们,看模型**:每条用量记录都带着自己的模型 ID,用量面板的「模型统计」按模型逐行列出,费用与 token 都是分开的。
|
||||
|
||||
如果你确实需要按供应商维度对账(比如比较多个聚合商上的同一个模型),那就保持 `上游格式` 为 Chat 并开着路由接管。
|
||||
|
||||
## 常见问题
|
||||
|
||||
@@ -74,18 +107,22 @@ DeepSeek、Kimi、MiniMax、SiliconFlow 等常见 Chat 格式供应商在 CC Swi
|
||||
|
||||
通常是没有开启 Codex 接管,或者你手动把上游 Chat base URL 直接写给了 Codex。检查 `~/.codex/config.toml` 是否指向 `http://127.0.0.1:15721/v1`。
|
||||
|
||||
**DeepSeek 上游报 404**
|
||||
**上游报 404**
|
||||
|
||||
如果用的是内置 DeepSeek 预设,先确认当前供应商确实来自预设,并且 Codex 路由已启用。只有在使用自定义供应商时,才需要额外检查 base URL:它应该是服务根地址,而不是带 `/chat/completions` 的完整接口路径。
|
||||
如果用的是内置预设,先确认当前供应商确实来自预设,并且 Codex 路由已启用。只有在使用自定义供应商时,才需要额外检查 base URL:它应该是对方文档给出的服务端点,而不是带 `/chat/completions` 的完整接口路径。
|
||||
|
||||
**`/model` 看不到 DeepSeek 模型**
|
||||
**切到 `deepseek-v4-pro` 后上游报错**
|
||||
|
||||
DeepSeek 官方尚未为该模型开通 Codex 集成。把这个供应商的 `上游格式` 改回 `Chat Completions(需开启路由)` 并开启路由接管即可——这正是 3.19.1 之前 DeepSeek 走的路径,路由的 Responses→Chat 转换照常支持 pro。或者改用 `deepseek-v4-flash`,它是预设默认值,不受影响。
|
||||
|
||||
**`/model` 看不到供应商的模型**
|
||||
|
||||
保存供应商后重启 Codex。CC Switch 会生成 `cc-switch-model-catalog.json` 并把路径写入 `model_catalog_json`,但正在运行的 Codex 进程不一定会热加载模型目录。
|
||||
目前 Codex app 不支持多模型选择,默认使用配置的第一个模型。
|
||||
|
||||
**开了路由但请求仍走错供应商**
|
||||
|
||||
确认三处状态一致:Codex 标签下当前供应商是 DeepSeek;本地路由服务正在运行;`路由启用` 里 Codex 开关已打开。
|
||||
确认三处状态一致:Codex 标签下当前供应商正确;本地路由服务正在运行;`路由启用` 里 Codex 开关已打开。
|
||||
|
||||
**可以用官方 OpenAI Codex 账号走本地路由吗**
|
||||
|
||||
@@ -96,6 +133,8 @@ DeepSeek、Kimi、MiniMax、SiliconFlow 等常见 Chat 格式供应商在 CC Swi
|
||||
- [CC Switch 用户手册:添加供应商](../user-manual/zh/2-providers/2.1-add.md)
|
||||
- [CC Switch 用户手册:代理服务](../user-manual/zh/4-proxy/4.1-service.md)
|
||||
- [CC Switch 用户手册:应用路由](../user-manual/zh/4-proxy/4.2-routing.md)
|
||||
- [DeepSeek API 文档:Integrate with Codex](https://api-docs.deepseek.com/quick_start/agent_integrations/codex/)(官方 Codex 集成说明,含 `wire_api = "responses"` 与模型支持范围)
|
||||
- [DeepSeek API 文档:Using the Responses API](https://api-docs.deepseek.com/guides/responses_api/)
|
||||
- [DeepSeek API 文档:Your First API Call](https://api-docs.deepseek.com/)
|
||||
- [DeepSeek API 文档:Create Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)
|
||||
- [DeepSeek API 文档:Multi-round Conversation](https://api-docs.deepseek.com/guides/multi_round_chat)
|
||||
|
||||
@@ -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 snapshot;extension
|
||||
不能在请求中途直接改 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 共用该项目:其资源语义、预览与安全面另行立项。
|
||||
@@ -0,0 +1,369 @@
|
||||
# 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, and **has been updated for this release**. It now opens with a check for which case you are in: a DeepSeek provider created from the preset connects directly and needs no routing, while one saved before the upgrade — and `deepseek-v4-pro` — still does. The mechanism applies in full to Kimi, Zhipu GLM, SiliconFlow, and other providers that remain Chat-shaped.
|
||||
|
||||
---
|
||||
|
||||
> [!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` |
|
||||
@@ -0,0 +1,369 @@
|
||||
# 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 に変換する仕組みを説明したもので、**本リリースに合わせて更新済み**です。冒頭に判定セクションを追加しました——プリセットから新規作成した DeepSeek は直結でルーティング不要ですが、アップグレード前に保存したプロバイダと `deepseek-v4-pro` は引き続き必要です。仕組みの部分は Kimi・Zhipu GLM・SiliconFlow など Chat 形式のままのプロバイダにはそのまま当てはまります。
|
||||
|
||||
---
|
||||
|
||||
> [!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 Hunyuan(TokenHub)Codex プリセット
|
||||
|
||||
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` |
|
||||
@@ -0,0 +1,369 @@
|
||||
# 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,**已针对本版更新**。开头新增了一节判定:用预设新建的 DeepSeek 走直连不需要路由,但升级前保存的供应商、以及要用 `deepseek-v4-pro` 时仍然需要;机制部分对 Kimi、智谱 GLM、SiliconFlow 等仍是 Chat 形态的供应商完全适用。
|
||||
|
||||
---
|
||||
|
||||
> [!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 网关集体转向原生 Responses:DeepSeek 直连 `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 会克隆旗舰条目,但保留它自己的名称。其它所有档位生成的目录与改动前逐字节一致。
|
||||
|
||||
### 腾讯混元(TokenHub)Codex 预设
|
||||
|
||||
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 Completions,pro 在这条路上不受影响。
|
||||
|
||||
### 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` | 解压后拖入 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` |
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.19.0",
|
||||
"version": "3.19.1",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,789 @@
|
||||
#!/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, expandPromptTemplate } 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", "release notes.md"),
|
||||
"This spaced filename cannot be addressed as one slash-command token.\n",
|
||||
);
|
||||
writeFileSync(join(promptAgentDir, "prompts", "release.v2.md"), "Release $1\n");
|
||||
writeFileSync(join(promptAgentDir, "prompts", "评审.md"), "评审 $1\n");
|
||||
writeFileSync(join(promptAgentDir, "prompts", "empty.md"), "");
|
||||
writeFileSync(
|
||||
join(promptAgentDir, "prompts", "nested", "ignored.md"),
|
||||
"nested",
|
||||
);
|
||||
const loadedPromptTemplates = adapters.loadPromptTemplates({
|
||||
cwd: promptProjectDir,
|
||||
agentDir: promptAgentDir,
|
||||
promptPaths: [],
|
||||
includeDefaults: true,
|
||||
});
|
||||
const promptTemplateDiscovery = jsonSafeJavaScriptValue(
|
||||
loadedPromptTemplates.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,
|
||||
})),
|
||||
);
|
||||
const promptTemplateExpansion = [
|
||||
"/review captured-range",
|
||||
"/release notes",
|
||||
"/release.v2 captured-range",
|
||||
"/评审 变更",
|
||||
].map((input) => ({
|
||||
input,
|
||||
result: adapters.expandPromptTemplate(input, loadedPromptTemplates),
|
||||
}));
|
||||
|
||||
// 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,
|
||||
promptTemplateExpansion,
|
||||
emptyInstructionFiles,
|
||||
sessionDirectorySemantics,
|
||||
sessionCliSemantics,
|
||||
sessionBranchSemantics,
|
||||
malformedSessionSemantics,
|
||||
nativeToolInventory,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
Generated
+14
-2
@@ -758,7 +758,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.19.0"
|
||||
version = "3.19.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -783,6 +783,8 @@ dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"json-five",
|
||||
"json5",
|
||||
"jsonc-parser",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
@@ -799,6 +801,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha2",
|
||||
"syn 2.0.117",
|
||||
"sys-locale",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
@@ -2798,6 +2801,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonc-parser"
|
||||
version = "0.33.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0560e3f9a9a03ea6b6e90b41138c5db9e21526c99eb192c1a26c68176593285"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonptr"
|
||||
version = "0.6.3"
|
||||
@@ -4686,7 +4698,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.19.0"
|
||||
version = "3.19.1"
|
||||
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"] }
|
||||
@@ -78,6 +79,7 @@ indexmap = { version = "2", features = ["serde"] }
|
||||
rust_decimal = "1.33"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
sha2 = "0.10"
|
||||
libc = "0.2"
|
||||
hmac = "0.12"
|
||||
json5 = "0.4"
|
||||
json-five = "0.3.1"
|
||||
@@ -94,6 +96,9 @@ winreg = "0.52"
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Globalization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_JobObjects",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
|
||||
@@ -116,3 +121,4 @@ strip = "symbols"
|
||||
[dev-dependencies]
|
||||
serial_test = "3"
|
||||
tempfile = "3"
|
||||
syn = { version = "2", features = ["full", "visit"] }
|
||||
|
||||
+35
-30
@@ -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
+538
-38
@@ -417,6 +417,93 @@ 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,
|
||||
@@ -466,17 +553,20 @@ 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!(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("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("priority".to_string(), json!(1000 + priority));
|
||||
entry_obj.insert("additional_speed_tiers".to_string(), json!([]));
|
||||
entry_obj.insert("service_tiers".to_string(), json!([]));
|
||||
@@ -535,8 +625,13 @@ fn codex_catalog_model_entry(
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct CodexCatalogModelSpec {
|
||||
model: String,
|
||||
display_name: String,
|
||||
context_window: u64,
|
||||
/// 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>,
|
||||
/// 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>,
|
||||
@@ -552,7 +647,7 @@ struct CodexCatalogModelSpec {
|
||||
base_instructions: Option<String>,
|
||||
}
|
||||
|
||||
fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCatalogModelSpec> {
|
||||
fn codex_catalog_model_specs(settings: &Value) -> Vec<CodexCatalogModelSpec> {
|
||||
let Some(models) = settings
|
||||
.get("modelCatalog")
|
||||
.and_then(|catalog| catalog.get("models"))
|
||||
@@ -561,8 +656,6 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCa
|
||||
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();
|
||||
|
||||
@@ -586,13 +679,12 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCa
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or(model);
|
||||
.map(str::to_string);
|
||||
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")
|
||||
@@ -621,7 +713,7 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCa
|
||||
|
||||
specs.push(CodexCatalogModelSpec {
|
||||
model: model.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
display_name,
|
||||
context_window,
|
||||
supports_parallel_tool_calls,
|
||||
input_modalities,
|
||||
@@ -891,6 +983,122 @@ 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
|
||||
@@ -973,11 +1181,14 @@ 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))
|
||||
.map(|(index, spec)| {
|
||||
codex_catalog_model_entry(template, spec, index, profile, default_context_window)
|
||||
})
|
||||
.collect();
|
||||
|
||||
json!({ "models": entries })
|
||||
@@ -988,11 +1199,28 @@ fn codex_model_catalog_from_settings(
|
||||
config_text: &str,
|
||||
profile: CodexCatalogToolProfile,
|
||||
) -> Result<Option<Value>, AppError> {
|
||||
let specs = codex_catalog_model_specs(settings, config_text);
|
||||
let specs = codex_catalog_model_specs(settings);
|
||||
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.
|
||||
@@ -1003,7 +1231,10 @@ fn codex_model_catalog_from_settings(
|
||||
CodexCatalogToolProfile::ProxyChat => load_codex_model_catalog_template()?,
|
||||
};
|
||||
Ok(Some(codex_model_catalog_from_specs(
|
||||
&specs, &template, profile,
|
||||
&specs,
|
||||
&template,
|
||||
profile,
|
||||
default_context_window,
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -1424,7 +1655,9 @@ 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 empty is treated as "no live 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.
|
||||
pub fn read_codex_live_settings() -> Result<Value, AppError> {
|
||||
let auth_path = get_codex_auth_path();
|
||||
let auth_present = auth_path.exists();
|
||||
@@ -1434,7 +1667,7 @@ pub fn read_codex_live_settings() -> Result<Value, AppError> {
|
||||
json!({})
|
||||
};
|
||||
let cfg_text = read_and_validate_codex_config_text()?;
|
||||
if !auth_present && cfg_text.trim().is_empty() {
|
||||
if !auth_present && !get_codex_config_path().exists() {
|
||||
return Err(AppError::localized(
|
||||
"codex.live.missing",
|
||||
"Codex 配置文件不存在",
|
||||
@@ -2403,6 +2636,65 @@ 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"
|
||||
@@ -2835,14 +3127,18 @@ base_url = "https://production.api/v1"
|
||||
fill_template_fields_from_static(&mut template);
|
||||
let specs = vec![CodexCatalogModelSpec {
|
||||
model: "k3".to_string(),
|
||||
display_name: "Kimi K3".to_string(),
|
||||
context_window: 262_144,
|
||||
display_name: Some("Kimi K3".to_string()),
|
||||
context_window: Some(262_144),
|
||||
supports_parallel_tool_calls: None,
|
||||
input_modalities: None,
|
||||
base_instructions: None,
|
||||
}];
|
||||
let catalog =
|
||||
codex_model_catalog_from_specs(&specs, &template, CodexCatalogToolProfile::ProxyChat);
|
||||
let catalog = codex_model_catalog_from_specs(
|
||||
&specs,
|
||||
&template,
|
||||
CodexCatalogToolProfile::ProxyChat,
|
||||
128_000,
|
||||
);
|
||||
assert_eq!(
|
||||
catalog["models"][0]
|
||||
.get("supports_reasoning_summaries")
|
||||
@@ -2898,9 +3194,13 @@ base_url = "https://production.api/v1"
|
||||
]
|
||||
}
|
||||
});
|
||||
let specs = codex_catalog_model_specs(&settings, r#"model_context_window = 128000"#);
|
||||
let catalog =
|
||||
codex_model_catalog_from_specs(&specs, &template, CodexCatalogToolProfile::ProxyChat);
|
||||
let specs = codex_catalog_model_specs(&settings);
|
||||
let catalog = codex_model_catalog_from_specs(
|
||||
&specs,
|
||||
&template,
|
||||
CodexCatalogToolProfile::ProxyChat,
|
||||
128_000,
|
||||
);
|
||||
let models = catalog
|
||||
.get("models")
|
||||
.and_then(|value| value.as_array())
|
||||
@@ -3033,40 +3333,40 @@ base_url = "https://production.api/v1"
|
||||
let specs = vec![
|
||||
CodexCatalogModelSpec {
|
||||
model: "gpt-5.4".to_string(),
|
||||
display_name: "GPT 5.4".to_string(),
|
||||
context_window: 128_000,
|
||||
display_name: Some("GPT 5.4".to_string()),
|
||||
context_window: Some(128_000),
|
||||
supports_parallel_tool_calls: None,
|
||||
input_modalities: None,
|
||||
base_instructions: None,
|
||||
},
|
||||
CodexCatalogModelSpec {
|
||||
model: "deepseek/deepseek-v4-pro".to_string(),
|
||||
display_name: "DeepSeek V4 Pro".to_string(),
|
||||
context_window: 128_000,
|
||||
display_name: Some("DeepSeek V4 Pro".to_string()),
|
||||
context_window: Some(128_000),
|
||||
supports_parallel_tool_calls: None,
|
||||
input_modalities: None,
|
||||
base_instructions: None,
|
||||
},
|
||||
CodexCatalogModelSpec {
|
||||
model: "glm-5.2v".to_string(),
|
||||
display_name: "GLM 5.2V".to_string(),
|
||||
context_window: 128_000,
|
||||
display_name: Some("GLM 5.2V".to_string()),
|
||||
context_window: Some(128_000),
|
||||
supports_parallel_tool_calls: None,
|
||||
input_modalities: None,
|
||||
base_instructions: None,
|
||||
},
|
||||
CodexCatalogModelSpec {
|
||||
model: "deepseek-v4-flash".to_string(),
|
||||
display_name: "Explicit Visual Override".to_string(),
|
||||
context_window: 128_000,
|
||||
display_name: Some("Explicit Visual Override".to_string()),
|
||||
context_window: Some(128_000),
|
||||
supports_parallel_tool_calls: None,
|
||||
input_modalities: Some(vec!["text".to_string(), "image".to_string()]),
|
||||
base_instructions: None,
|
||||
},
|
||||
CodexCatalogModelSpec {
|
||||
model: "custom-text-alias".to_string(),
|
||||
display_name: "Explicit Text Override".to_string(),
|
||||
context_window: 128_000,
|
||||
display_name: Some("Explicit Text Override".to_string()),
|
||||
context_window: Some(128_000),
|
||||
supports_parallel_tool_calls: None,
|
||||
input_modalities: Some(vec!["text".to_string()]),
|
||||
base_instructions: None,
|
||||
@@ -3078,7 +3378,7 @@ base_url = "https://production.api/v1"
|
||||
CodexCatalogToolProfile::NativeResponses,
|
||||
CodexCatalogToolProfile::Anthropic,
|
||||
] {
|
||||
let catalog = codex_model_catalog_from_specs(&specs, &template, profile);
|
||||
let catalog = codex_model_catalog_from_specs(&specs, &template, profile, 128_000);
|
||||
let models = catalog["models"].as_array().expect("models array");
|
||||
let modalities = |slug: &str| {
|
||||
models
|
||||
@@ -3129,6 +3429,205 @@ 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
|
||||
@@ -3136,8 +3635,8 @@ base_url = "https://production.api/v1"
|
||||
let template = load_codex_native_responses_template();
|
||||
let specs = vec![CodexCatalogModelSpec {
|
||||
model: "x".to_string(),
|
||||
display_name: "x".to_string(),
|
||||
context_window: 128_000,
|
||||
display_name: Some("x".to_string()),
|
||||
context_window: Some(128_000),
|
||||
supports_parallel_tool_calls: None,
|
||||
input_modalities: None,
|
||||
base_instructions: None,
|
||||
@@ -3151,6 +3650,7 @@ base_url = "https://production.api/v1"
|
||||
&specs,
|
||||
&proxy_template,
|
||||
CodexCatalogToolProfile::ProxyChat,
|
||||
128_000,
|
||||
);
|
||||
assert_eq!(
|
||||
catalog["models"][0]
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,294 @@ 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 selected_provider = set_pi_auto_failover_enabled_inner(state, enabled).await?;
|
||||
|
||||
let _ = app.emit(
|
||||
"provider-switched",
|
||||
serde_json::json!({
|
||||
"appType": "pi",
|
||||
"providerId": selected_provider,
|
||||
"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(())
|
||||
}
|
||||
|
||||
async fn set_pi_auto_failover_enabled_inner(
|
||||
state: &AppState,
|
||||
enabled: bool,
|
||||
) -> Result<Option<String>, 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 previous_provider =
|
||||
crate::services::pi_catalog::PiCatalogCoordinator::current_native_provider(state)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut auto_added = None;
|
||||
let mut switched_primary = false;
|
||||
let selected_provider = if enabled {
|
||||
let previous_provider = previous_provider.clone().ok_or_else(|| {
|
||||
"Pi has no current provider, so failover cannot select queue P1".to_string()
|
||||
})?;
|
||||
let mut queue = state
|
||||
.db
|
||||
.get_failover_queue("pi")
|
||||
.map_err(|error| error.to_string())?;
|
||||
if queue.is_empty() {
|
||||
state
|
||||
.db
|
||||
.add_to_failover_queue("pi", &previous_provider)
|
||||
.map_err(|error| error.to_string())?;
|
||||
auto_added = Some(previous_provider.clone());
|
||||
queue = state
|
||||
.db
|
||||
.get_failover_queue("pi")
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
let primary = queue
|
||||
.first()
|
||||
.map(|item| item.provider_id.clone())
|
||||
.ok_or_else(|| "Pi failover queue is empty".to_string())?;
|
||||
if primary != previous_provider {
|
||||
if let Err(error) = set_pi_default_under_switch_guard(state, &guard, &primary) {
|
||||
if let Some(provider_id) = auto_added.take() {
|
||||
let _ = state.db.remove_from_failover_queue("pi", &provider_id);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
switched_primary = true;
|
||||
}
|
||||
Some(primary)
|
||||
} else {
|
||||
previous_provider.clone()
|
||||
};
|
||||
|
||||
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.take() {
|
||||
let _ = state.db.remove_from_failover_queue("pi", &provider_id);
|
||||
}
|
||||
let rollback = rollback_pi_failover_primary(
|
||||
state,
|
||||
&guard,
|
||||
previous_provider.as_deref(),
|
||||
switched_primary,
|
||||
Some(epoch),
|
||||
)
|
||||
.await;
|
||||
return Err(with_pi_failover_rollback(error.to_string(), rollback));
|
||||
}
|
||||
if let Err(error) = state
|
||||
.proxy_service
|
||||
.reconcile_pi_runtime_at_epoch(epoch)
|
||||
.await
|
||||
{
|
||||
let settings_rollback = crate::settings::update_pi_proxy_settings(previous_config)
|
||||
.err()
|
||||
.map(|rollback_error| rollback_error.to_string());
|
||||
if let Some(provider_id) = auto_added.take() {
|
||||
let _ = state.db.remove_from_failover_queue("pi", &provider_id);
|
||||
}
|
||||
let primary_rollback = rollback_pi_failover_primary(
|
||||
state,
|
||||
&guard,
|
||||
previous_provider.as_deref(),
|
||||
switched_primary,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let rollback = settings_rollback.or(primary_rollback);
|
||||
return Err(with_pi_failover_rollback(
|
||||
format!("Pi failover preference changed but runtime publication failed: {error}"),
|
||||
rollback,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(selected_provider)
|
||||
}
|
||||
|
||||
fn set_pi_default_under_switch_guard(
|
||||
state: &AppState,
|
||||
guard: &tokio::sync::OwnedMutexGuard<()>,
|
||||
provider_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let aggregate = state
|
||||
.db
|
||||
.get_provider_aggregate(AppType::Pi.as_str(), provider_id)
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| format!("Pi provider does not exist: {provider_id}"))?;
|
||||
let config: crate::pi_config::model::PiManagedProviderConfig =
|
||||
serde_json::from_value(aggregate.provider.settings_config)
|
||||
.map_err(|error| format!("managed Pi provider '{provider_id}' is invalid: {error}"))?;
|
||||
let model_id = config
|
||||
.models
|
||||
.first()
|
||||
.map(|model| model.id.clone())
|
||||
.ok_or_else(|| format!("Pi provider '{provider_id}' has no selectable models"))?;
|
||||
crate::services::pi_catalog::PiCatalogCoordinator::apply_under_switch_guard(
|
||||
state,
|
||||
guard,
|
||||
crate::services::pi_catalog::PiCatalogMutation::SetDefault {
|
||||
provider_id: provider_id.to_string(),
|
||||
model_id,
|
||||
},
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
async fn rollback_pi_failover_primary(
|
||||
state: &AppState,
|
||||
guard: &tokio::sync::OwnedMutexGuard<()>,
|
||||
previous_provider: Option<&str>,
|
||||
switched_primary: bool,
|
||||
pending_epoch: Option<u64>,
|
||||
) -> Option<String> {
|
||||
if switched_primary {
|
||||
let previous_provider =
|
||||
previous_provider.expect("switching P1 requires a previous Pi provider");
|
||||
return set_pi_default_under_switch_guard(state, guard, previous_provider)
|
||||
.err()
|
||||
.map(|error| format!("primary rollback failed: {error}"));
|
||||
}
|
||||
|
||||
let epoch = match pending_epoch {
|
||||
Some(epoch) => epoch,
|
||||
None => state.proxy_service.begin_pi_catalog_mutation().await,
|
||||
};
|
||||
state
|
||||
.proxy_service
|
||||
.reconcile_pi_runtime_at_epoch(epoch)
|
||||
.await
|
||||
.err()
|
||||
.map(|error| format!("runtime rollback failed: {error}"))
|
||||
}
|
||||
|
||||
fn with_pi_failover_rollback(error: String, rollback: Option<String>) -> String {
|
||||
match rollback {
|
||||
Some(rollback) => format!("{error}; {rollback}"),
|
||||
None => error,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::Database;
|
||||
use crate::provider::ProviderMutationInput;
|
||||
use crate::services::pi_catalog::{PiCatalogCoordinator, PiCatalogMutation};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct TestHome(Option<std::ffi::OsString>);
|
||||
|
||||
impl TestHome {
|
||||
fn install(path: &std::path::Path) -> Result<Self, crate::error::AppError> {
|
||||
let previous = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", path);
|
||||
crate::settings::reload_settings()?;
|
||||
Ok(Self(previous))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestHome {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
let _ = crate::settings::reload_settings();
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_input(id: &str) -> ProviderMutationInput {
|
||||
ProviderMutationInput {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
settings_config: json!({
|
||||
"name": id,
|
||||
"api": "openai-responses",
|
||||
"baseUrl": format!("https://{id}.example/v1"),
|
||||
"apiKey": "literal-key",
|
||||
"models": [{"id": format!("{id}-model"), "name": id}]
|
||||
}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: Some(1),
|
||||
sort_index: Some(0),
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: Some("pi".to_string()),
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn enabling_pi_failover_selects_queue_p1() -> Result<(), crate::error::AppError> {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let _home = TestHome::install(temp.path())?;
|
||||
let mut settings = crate::settings::get_settings();
|
||||
settings.pi_config_dir = Some(temp.path().join("pi-agent").to_string_lossy().into_owned());
|
||||
settings.pi_takeover_enabled = false;
|
||||
crate::settings::update_settings(settings)?;
|
||||
|
||||
let state = AppState::new(Arc::new(Database::memory()?));
|
||||
for (provider_id, activate_if_first) in [("provider-a", true), ("provider-b", false)] {
|
||||
PiCatalogCoordinator::apply(
|
||||
&state,
|
||||
PiCatalogMutation::CreateProvider {
|
||||
input: managed_input(provider_id),
|
||||
provider_key: provider_id.to_string(),
|
||||
activate_if_first,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
state.db.add_to_failover_queue("pi", "provider-b")?;
|
||||
let mut proxy_config = state.db.get_global_proxy_config().await?;
|
||||
proxy_config.listen_port = 0;
|
||||
state.db.update_global_proxy_config(proxy_config).await?;
|
||||
state
|
||||
.proxy_service
|
||||
.set_takeover_for_app("pi", true)
|
||||
.await
|
||||
.map_err(crate::error::AppError::Message)?;
|
||||
|
||||
let selected = set_pi_auto_failover_enabled_inner(&state, true)
|
||||
.await
|
||||
.map_err(crate::error::AppError::Message)?;
|
||||
|
||||
assert_eq!(selected.as_deref(), Some("provider-b"));
|
||||
assert_eq!(
|
||||
PiCatalogCoordinator::current_native_provider(&state)?.as_deref(),
|
||||
Some("provider-b")
|
||||
);
|
||||
assert!(crate::settings::get_pi_proxy_settings().auto_failover_enabled);
|
||||
state
|
||||
.proxy_service
|
||||
.set_takeover_for_app("pi", false)
|
||||
.await
|
||||
.map_err(crate::error::AppError::Message)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::database::backup::BackupEntry;
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::services::provider::ProviderService;
|
||||
use crate::services::skill_deployment::PiSkillDeploymentService;
|
||||
use crate::store::AppState;
|
||||
|
||||
// ─── File import/export ──────────────────────────────────────
|
||||
@@ -25,7 +26,7 @@ pub async fn export_config_to_file(
|
||||
let db = state.db.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let target_path = PathBuf::from(&filePath);
|
||||
db.export_sql(&target_path)?;
|
||||
db.export_portable_sql(&target_path)?;
|
||||
Ok::<_, AppError>(json!({
|
||||
"success": true,
|
||||
"message": "SQL exported successfully",
|
||||
@@ -44,26 +45,57 @@ 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}");
|
||||
}
|
||||
Ok::<_, AppError>(success_payload_with_warning(backup_id, warning))
|
||||
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 || {
|
||||
PiSkillDeploymentService::import_portable_sql(&db, &PathBuf::from(import_path))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("导入配置失败: {e}"))?
|
||||
.map_err(|e: AppError| e.to_string())
|
||||
.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}")
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
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 +186,51 @@ 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 || {
|
||||
PiSkillDeploymentService::restore_binary_backup_without_pi_ownership(&db, &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
|
||||
|
||||
+255
-16
@@ -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,11 +214,16 @@ 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 output = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(command_line)
|
||||
.output()
|
||||
.map_err(|e| format!("启动安装进程失败: {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}"))?;
|
||||
finish_lifecycle_output(&output)
|
||||
}
|
||||
|
||||
@@ -428,6 +433,7 @@ fn tool_display_name(tool: &str) -> &'static str {
|
||||
"opencode" => "OpenCode",
|
||||
"openclaw" => "OpenClaw",
|
||||
"hermes" => "Hermes",
|
||||
"pi" => "Pi",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
@@ -508,6 +514,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,
|
||||
}
|
||||
}
|
||||
@@ -802,6 +809,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,
|
||||
};
|
||||
|
||||
@@ -1855,6 +1865,73 @@ 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"))]
|
||||
@@ -1999,6 +2076,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,
|
||||
}
|
||||
}
|
||||
@@ -2205,6 +2283,64 @@ 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
|
||||
@@ -2359,7 +2495,9 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
}
|
||||
if tool == "grok" && is_grok_native_install(bin_path, real_target) {
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
return Some(grok_native_update_command(
|
||||
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() {
|
||||
@@ -2438,7 +2576,9 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
}
|
||||
if tool == "grok" && is_grok_native_install(bin_path, real_target) {
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
return Some(grok_native_update_command(
|
||||
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) {
|
||||
@@ -2535,6 +2675,11 @@ 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(),
|
||||
@@ -2650,6 +2795,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,
|
||||
}?;
|
||||
|
||||
@@ -3787,6 +3933,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;
|
||||
@@ -4043,11 +4207,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_native_windows_uses_self_update() {
|
||||
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 的路径。
|
||||
let (_dir, _sub, bin_path) = setup_sibling(".grok/bin", "grok.exe", &["npm.cmd"]);
|
||||
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path);
|
||||
let expected = format!("{} update", expect_quoted_path(&bin_path));
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
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}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4517,7 +4694,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_native_installer_uses_self_update() {
|
||||
fn grok_native_installer_uses_self_update_with_installer_fallback() {
|
||||
// ~/.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(
|
||||
@@ -4525,7 +4702,25 @@ mod tests {
|
||||
"/Users/me/.grok/bin/grok",
|
||||
"/Users/me/.grok/downloads/grok-macos-aarch64",
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some("/Users/me/.grok/bin/grok update"));
|
||||
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}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4535,7 +4730,10 @@ mod tests {
|
||||
"/Users/me/bin/grok",
|
||||
"/Users/me/.grok/downloads/grok-macos-aarch64",
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some("/Users/me/bin/grok update"));
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some(format!("/Users/me/bin/grok update || {GROK_INSTALL_UNIX}").as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5006,6 +5204,35 @@ 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 {
|
||||
@@ -5129,6 +5356,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!(
|
||||
@@ -5158,6 +5392,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]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,61 @@ 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,
|
||||
..previous.clone()
|
||||
};
|
||||
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())?;
|
||||
@@ -152,6 +211,9 @@ async fn get_default_cost_multiplier_internal(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
) -> Result<String, AppError> {
|
||||
if app_type == "pi" {
|
||||
return Ok(crate::settings::get_pi_default_cost_multiplier());
|
||||
}
|
||||
let db = &state.db;
|
||||
db.get_default_cost_multiplier(app_type).await
|
||||
}
|
||||
@@ -180,6 +242,9 @@ async fn set_default_cost_multiplier_internal(
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if app_type == "pi" {
|
||||
return crate::settings::set_pi_default_cost_multiplier(value);
|
||||
}
|
||||
let db = &state.db;
|
||||
db.set_default_cost_multiplier(app_type, value).await
|
||||
}
|
||||
@@ -209,6 +274,9 @@ async fn get_pricing_model_source_internal(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
) -> Result<String, AppError> {
|
||||
if app_type == "pi" {
|
||||
return Ok(crate::settings::get_pi_pricing_model_source());
|
||||
}
|
||||
let db = &state.db;
|
||||
db.get_pricing_model_source(app_type).await
|
||||
}
|
||||
@@ -237,6 +305,9 @@ async fn set_pricing_model_source_internal(
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if app_type == "pi" {
|
||||
return crate::settings::set_pi_pricing_model_source(value);
|
||||
}
|
||||
let db = &state.db;
|
||||
db.set_pricing_model_source(app_type, value).await
|
||||
}
|
||||
@@ -453,3 +524,61 @@ pub async fn get_circuit_breaker_stats(
|
||||
let _ = (state, provider_id, app_type);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::{lock_conn, Database};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct TestHome(Option<std::ffi::OsString>);
|
||||
|
||||
impl TestHome {
|
||||
fn install(path: &std::path::Path) -> Result<Self, AppError> {
|
||||
let previous = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", path);
|
||||
crate::settings::reload_settings()?;
|
||||
Ok(Self(previous))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestHome {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
let _ = crate::settings::reload_settings();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn pi_pricing_round_trips_without_out_of_schema_proxy_row() -> Result<(), AppError> {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let _home = TestHome::install(temp.path())?;
|
||||
let state = AppState::new(Arc::new(Database::memory()?));
|
||||
|
||||
set_default_cost_multiplier_test_hook(&state, "pi", "1.25").await?;
|
||||
set_pricing_model_source_test_hook(&state, "pi", "request").await?;
|
||||
|
||||
assert_eq!(
|
||||
get_default_cost_multiplier_test_hook(&state, "pi").await?,
|
||||
"1.25"
|
||||
);
|
||||
assert_eq!(
|
||||
get_pricing_model_source_test_hook(&state, "pi").await?,
|
||||
"request"
|
||||
);
|
||||
let conn = lock_conn!(state.db.conn);
|
||||
let pi_rows: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'pi'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
assert_eq!(pi_rows, 0);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
|
||||
@@ -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, ¤t, 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取开机自启状态
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
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)?;
|
||||
settings::reload_settings()?;
|
||||
Ok(())
|
||||
pub(crate) fn run_post_import_sync(app_state: &AppState) -> Result<(), AppError> {
|
||||
// Provider synchronization reopens/reconciles Pi's runtime admission after
|
||||
// the pre-import boundary closed it. Run that recovery first, then execute
|
||||
// every remaining independent projection even if one of them fails.
|
||||
run_post_import_steps(
|
||||
|| ProviderService::sync_current_to_live(app_state),
|
||||
|| PromptService::reconcile_pi_portable_import(app_state),
|
||||
settings::reload_settings,
|
||||
)
|
||||
}
|
||||
|
||||
fn run_post_import_steps(
|
||||
live_sync: impl FnOnce() -> Result<(), AppError>,
|
||||
prompt_sync: impl FnOnce() -> Result<(), AppError>,
|
||||
settings_reload: impl FnOnce() -> Result<(), AppError>,
|
||||
) -> Result<(), AppError> {
|
||||
let mut failures = Vec::new();
|
||||
for (stage, result) in [
|
||||
("live", live_sync()),
|
||||
("pi_prompt", prompt_sync()),
|
||||
("settings", settings_reload()),
|
||||
] {
|
||||
if let Err(error) = result {
|
||||
failures.push(format!("{stage}={error}"));
|
||||
}
|
||||
}
|
||||
if failures.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Config(format!(
|
||||
"post-import reconciliation incomplete: {}",
|
||||
failures.join("; ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn post_sync_warning<E: std::fmt::Display>(err: E) -> String {
|
||||
@@ -55,8 +82,9 @@ pub(crate) fn success_payload_with_warning(backup_id: String, warning: Option<St
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{attach_warning, post_sync_warning_from_result};
|
||||
use super::{attach_warning, post_sync_warning_from_result, run_post_import_steps};
|
||||
use serde_json::json;
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[test]
|
||||
fn post_sync_warning_from_result_returns_none_on_success() {
|
||||
@@ -94,4 +122,29 @@ mod tests {
|
||||
Some("post sync warning")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_import_steps_recover_live_first_and_do_not_short_circuit() {
|
||||
let calls = RefCell::new(Vec::new());
|
||||
let error = run_post_import_steps(
|
||||
|| {
|
||||
calls.borrow_mut().push("live");
|
||||
Ok(())
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("prompt");
|
||||
Err(crate::error::AppError::Config("invalid AGENTS.md".into()))
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("settings");
|
||||
Err(crate::error::AppError::Config("reload failed".into()))
|
||||
},
|
||||
)
|
||||
.expect_err("independent failures must be reported");
|
||||
|
||||
assert_eq!(*calls.borrow(), ["live", "prompt", "settings"]);
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("pi_prompt="));
|
||||
assert!(message.contains("settings="));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
|
||||
+122
-35
@@ -295,6 +295,23 @@ 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 when `required_file_mode` is `None`.
|
||||
/// Sensitive callers pass an explicit mode (for example `0o600`), which is
|
||||
/// enforced for both new and existing Unix files before the replacement
|
||||
/// becomes visible. 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],
|
||||
required_file_mode: Option<u32>,
|
||||
) -> Result<(), AppError> {
|
||||
#[cfg(not(unix))]
|
||||
let _ = required_file_mode;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
@@ -302,51 +319,97 @@ 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(required_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))?;
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性)
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(path);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = required_file_mode.unwrap_or_else(|| {
|
||||
fs::metadata(path)
|
||||
.map(|metadata| metadata.permissions().mode())
|
||||
.unwrap_or(0o666)
|
||||
});
|
||||
file.set_permissions(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,
|
||||
})?;
|
||||
}
|
||||
file.sync_all().map_err(|error| AppError::io(&tmp, error))?;
|
||||
drop(file);
|
||||
|
||||
#[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(())
|
||||
}
|
||||
@@ -500,6 +563,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn sensitive_atomic_write_tightens_an_existing_file_before_publish() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("settings.json");
|
||||
fs::write(&path, b"old").expect("seed settings");
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o644))
|
||||
.expect("make legacy settings permissive");
|
||||
|
||||
atomic_write_durable(&path, b"new-secret", Some(0o600)).expect("replace settings");
|
||||
|
||||
assert_eq!(fs::read(&path).expect("read settings"), b"new-secret");
|
||||
assert_eq!(
|
||||
fs::metadata(&path)
|
||||
.expect("settings metadata")
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_json_keys_produces_identical_output_for_different_insertion_orders() {
|
||||
// 核心保证:同一逻辑配置无论键的插入顺序如何,写出的字节序列必须一致。
|
||||
|
||||
@@ -4,12 +4,19 @@
|
||||
|
||||
pub mod failover;
|
||||
pub mod mcp;
|
||||
pub(crate) mod pi_catalog;
|
||||
pub(crate) mod pi_portable_state;
|
||||
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;
|
||||
|
||||
@@ -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,702 @@
|
||||
//! Portable SQL boundary for Pi's device-local ownership evidence.
|
||||
//!
|
||||
//! Provider and Skill configuration is portable. Exact `models.json` claims and
|
||||
//! native Skill deployment receipts are not: they describe files on one device
|
||||
//! and must never become ownership proof on another. This adapter keeps that
|
||||
//! policy outside the frozen generic backup/restore implementation while giving
|
||||
//! manual SQL, WebDAV, and S3 one shared boundary.
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::database::SkillDeploymentMethod;
|
||||
use crate::database::{lock_conn, Database, PiProviderProjection, SkillDeployment};
|
||||
use crate::error::AppError;
|
||||
use rusqlite::{Connection, OpenFlags, OptionalExtension};
|
||||
use std::fmt::Write as _;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const DEVICE_LOCAL_INSERT_PREFIXES: &[&str] = &[
|
||||
"INSERT INTO \"pi_provider_projections\"",
|
||||
"INSERT INTO \"skill_deployments\"",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PiDeviceLocalState {
|
||||
projections: Vec<PiProviderProjection>,
|
||||
skill_deployments: Vec<SkillDeployment>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Export a user-portable SQL backup without device-local ownership rows.
|
||||
pub(crate) fn export_portable_sql_string(&self) -> Result<String, AppError> {
|
||||
strip_device_local_insert_statements(&self.export_sql_string()?)
|
||||
}
|
||||
|
||||
/// Export a cloud-sync SQL snapshot without device-local ownership rows.
|
||||
pub(crate) fn export_portable_sql_string_for_sync(&self) -> Result<String, AppError> {
|
||||
strip_device_local_insert_statements(&self.export_sql_string_for_sync()?)
|
||||
}
|
||||
|
||||
pub(crate) fn export_portable_sql(&self, target_path: &Path) -> Result<(), AppError> {
|
||||
let dump = self.export_portable_sql_string()?;
|
||||
if let Some(parent) = target_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| AppError::io(parent, error))?;
|
||||
}
|
||||
crate::config::atomic_write(target_path, dump.as_bytes())
|
||||
}
|
||||
|
||||
/// Import a user-portable SQL backup while retaining this device's evidence.
|
||||
pub(crate) fn import_portable_sql(&self, source_path: &Path) -> Result<String, AppError> {
|
||||
let local = self.capture_pi_device_local_state()?;
|
||||
let sql =
|
||||
fs::read_to_string(source_path).map_err(|error| AppError::io(source_path, error))?;
|
||||
self.import_sql_string(&append_pi_device_local_state(&sql, &local)?)
|
||||
}
|
||||
|
||||
/// Import a cloud-sync snapshot while retaining this device's evidence.
|
||||
pub(crate) fn import_portable_sql_string_for_sync(
|
||||
&self,
|
||||
sql: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let local = self.capture_pi_device_local_state()?;
|
||||
self.import_sql_string_for_sync(&append_pi_device_local_state(sql, &local)?)
|
||||
}
|
||||
|
||||
/// Fail closed before the legacy whole-database restore can import
|
||||
/// device-local Pi ownership evidence.
|
||||
///
|
||||
/// Canonical binary restore hardening is a separate project. Until that
|
||||
/// boundary can retain the receiving device's ledgers atomically, binary
|
||||
/// restore is supported only when neither side carries ownership rows.
|
||||
pub(crate) fn ensure_binary_restore_has_no_pi_ownership(
|
||||
&self,
|
||||
filename: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if filename.contains("..")
|
||||
|| filename.contains('/')
|
||||
|| filename.contains('\\')
|
||||
|| !filename.ends_with(".db")
|
||||
{
|
||||
return Err(AppError::InvalidInput(
|
||||
"Invalid backup filename".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
{
|
||||
let conn = lock_conn!(self.conn);
|
||||
if pi_device_local_rows_exist(&conn)? {
|
||||
return Err(binary_restore_ownership_error(
|
||||
"当前数据库",
|
||||
"current database",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let backup_path = crate::config::get_app_config_dir()
|
||||
.join("backups")
|
||||
.join(filename);
|
||||
if !backup_path.exists() {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Backup file not found: {filename}"
|
||||
)));
|
||||
}
|
||||
let source = Connection::open_with_flags(
|
||||
&backup_path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|error| AppError::Database(format!("无法只读检查备份: {error}")))?;
|
||||
if pi_device_local_rows_exist(&source)? {
|
||||
return Err(binary_restore_ownership_error(
|
||||
"所选备份",
|
||||
"selected backup",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn capture_pi_device_local_state(&self) -> Result<PiDeviceLocalState, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let projections = {
|
||||
let mut statement = 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 = statement
|
||||
.query_map([], |row| {
|
||||
Ok(PiProviderProjection {
|
||||
provider_id: row.get(0)?,
|
||||
provider_key: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
};
|
||||
|
||||
let skill_deployments = {
|
||||
let mut statement = conn
|
||||
.prepare(
|
||||
"SELECT skill_id, destination, destination_key, method,
|
||||
source_identity, deployed_digest, created_at, updated_at
|
||||
FROM skill_deployments
|
||||
WHERE app_type = 'pi'
|
||||
ORDER BY skill_id, destination_key",
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
let method = row.get::<_, String>(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)?,
|
||||
})
|
||||
})
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
};
|
||||
|
||||
Ok(PiDeviceLocalState {
|
||||
projections,
|
||||
skill_deployments,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn pi_device_local_rows_exist(conn: &Connection) -> Result<bool, AppError> {
|
||||
let schema_object_exists = |name: &str| -> Result<bool, AppError> {
|
||||
conn.query_row(
|
||||
"SELECT 1 FROM sqlite_master
|
||||
WHERE name = ?1 COLLATE NOCASE",
|
||||
[name],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.optional()
|
||||
.map(|row| row.is_some())
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
};
|
||||
|
||||
let projections_exist = if schema_object_exists("pi_provider_projections")? {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM pi_provider_projections LIMIT 1)",
|
||||
[],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if projections_exist {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if schema_object_exists("skill_deployments")? {
|
||||
return conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM skill_deployments
|
||||
WHERE app_type = 'pi'
|
||||
LIMIT 1
|
||||
)",
|
||||
[],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()));
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Merge receiving-device evidence into the same temporary database that the
|
||||
/// generic importer validates and publishes. No live database replacement can
|
||||
/// occur before these statements have succeeded.
|
||||
fn append_pi_device_local_state(sql: &str, local: &PiDeviceLocalState) -> Result<String, AppError> {
|
||||
ensure_sql_append_boundary(sql)?;
|
||||
let mut merged = String::with_capacity(sql.len() + 1024);
|
||||
merged.push_str(sql);
|
||||
// The leading newline closes a trailing `--` comment; the standalone
|
||||
// semicolon terminates a final statement that omitted its delimiter.
|
||||
merged.push_str(
|
||||
"\n;\n\
|
||||
DROP TABLE IF EXISTS pi_provider_projections;\n\
|
||||
CREATE TABLE pi_provider_projections (\n\
|
||||
provider_id TEXT PRIMARY KEY,\n\
|
||||
provider_key TEXT NOT NULL UNIQUE,\n\
|
||||
created_at INTEGER NOT NULL,\n\
|
||||
updated_at INTEGER NOT NULL\n\
|
||||
);\n\
|
||||
DROP TABLE IF EXISTS skill_deployments;\n\
|
||||
CREATE TABLE skill_deployments (\n\
|
||||
app_type TEXT NOT NULL CHECK (app_type = 'pi'),\n\
|
||||
skill_id TEXT NOT NULL,\n\
|
||||
destination TEXT NOT NULL,\n\
|
||||
destination_key TEXT NOT NULL,\n\
|
||||
method TEXT NOT NULL CHECK (method IN ('symlink', 'copy')),\n\
|
||||
source_identity TEXT NOT NULL,\n\
|
||||
deployed_digest TEXT,\n\
|
||||
created_at INTEGER NOT NULL,\n\
|
||||
updated_at INTEGER NOT NULL,\n\
|
||||
PRIMARY KEY (app_type, skill_id, destination_key),\n\
|
||||
UNIQUE (app_type, destination_key)\n\
|
||||
);\n",
|
||||
);
|
||||
|
||||
for projection in &local.projections {
|
||||
writeln!(
|
||||
merged,
|
||||
"INSERT INTO pi_provider_projections \
|
||||
(provider_id, provider_key, created_at, updated_at) \
|
||||
VALUES ({}, {}, {}, {});",
|
||||
sql_text(&projection.provider_id)?,
|
||||
sql_text(&projection.provider_key)?,
|
||||
projection.created_at,
|
||||
projection.updated_at,
|
||||
)
|
||||
.expect("writing to String cannot fail");
|
||||
}
|
||||
for deployment in &local.skill_deployments {
|
||||
writeln!(
|
||||
merged,
|
||||
"INSERT INTO skill_deployments \
|
||||
(app_type, skill_id, destination, destination_key, method, \
|
||||
source_identity, deployed_digest, created_at, updated_at) \
|
||||
VALUES ('pi', {}, {}, {}, {}, {}, {}, {}, {});",
|
||||
sql_text(&deployment.skill_id)?,
|
||||
sql_text(&deployment.destination)?,
|
||||
sql_text(&deployment.destination_key)?,
|
||||
sql_text(deployment.method.as_str())?,
|
||||
sql_text(&deployment.source_identity)?,
|
||||
sql_optional_text(deployment.deployed_digest.as_deref())?,
|
||||
deployment.created_at,
|
||||
deployment.updated_at,
|
||||
)
|
||||
.expect("writing to String cannot fail");
|
||||
}
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
fn sql_optional_text(value: Option<&str>) -> Result<String, AppError> {
|
||||
value.map_or_else(|| Ok("NULL".to_string()), sql_text)
|
||||
}
|
||||
|
||||
fn sql_text(value: &str) -> Result<String, AppError> {
|
||||
if value.contains('\0') {
|
||||
return Err(AppError::InvalidInput(
|
||||
"device-local Pi ownership text cannot contain NUL".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(format!("'{}'", value.replace('\'', "''")))
|
||||
}
|
||||
|
||||
/// SQLite accepts an unterminated block comment at EOF. Reject such input so
|
||||
/// it cannot swallow the receiving-device statements appended above. Other
|
||||
/// unterminated quoted forms are rejected here as a clearer pre-publish error.
|
||||
fn ensure_sql_append_boundary(sql: &str) -> Result<(), AppError> {
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum State {
|
||||
Normal,
|
||||
SingleQuote,
|
||||
DoubleQuote,
|
||||
Backtick,
|
||||
Bracket,
|
||||
LineComment,
|
||||
BlockComment,
|
||||
}
|
||||
|
||||
let bytes = sql.as_bytes();
|
||||
let mut state = State::Normal;
|
||||
let mut cursor = 0;
|
||||
while cursor < bytes.len() {
|
||||
let current = bytes[cursor];
|
||||
let next = bytes.get(cursor + 1).copied();
|
||||
match state {
|
||||
State::Normal => match (current, next) {
|
||||
(b'\'', _) => state = State::SingleQuote,
|
||||
(b'"', _) => state = State::DoubleQuote,
|
||||
(b'`', _) => state = State::Backtick,
|
||||
(b'[', _) => state = State::Bracket,
|
||||
(b'-', Some(b'-')) => {
|
||||
state = State::LineComment;
|
||||
cursor += 1;
|
||||
}
|
||||
(b'/', Some(b'*')) => {
|
||||
state = State::BlockComment;
|
||||
cursor += 1;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
State::SingleQuote if current == b'\'' => {
|
||||
if next == Some(b'\'') {
|
||||
cursor += 1;
|
||||
} else {
|
||||
state = State::Normal;
|
||||
}
|
||||
}
|
||||
State::DoubleQuote if current == b'"' => {
|
||||
if next == Some(b'"') {
|
||||
cursor += 1;
|
||||
} else {
|
||||
state = State::Normal;
|
||||
}
|
||||
}
|
||||
State::Backtick if current == b'`' => {
|
||||
if next == Some(b'`') {
|
||||
cursor += 1;
|
||||
} else {
|
||||
state = State::Normal;
|
||||
}
|
||||
}
|
||||
State::Bracket if current == b']' => state = State::Normal,
|
||||
State::LineComment if current == b'\n' => state = State::Normal,
|
||||
State::BlockComment if current == b'*' && next == Some(b'/') => {
|
||||
state = State::Normal;
|
||||
cursor += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
if matches!(state, State::Normal | State::LineComment) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::InvalidInput(
|
||||
"portable SQL ends inside an unterminated quoted value or comment".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn binary_restore_ownership_error(source_zh: &str, source_en: &str) -> AppError {
|
||||
AppError::localized(
|
||||
"pi.binary_restore_device_ownership_unsupported",
|
||||
format!(
|
||||
"为防止历史设备所有权记录覆盖当前 Pi 原生文件,{source_zh}含 Pi 所有权状态时不能使用数据库备份恢复;请改用可移植 SQL 导入"
|
||||
),
|
||||
format!(
|
||||
"Binary database restore is unavailable because the {source_en} contains device-local Pi ownership state; use portable SQL import instead"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Remove complete INSERT statements for the two device-local tables from SQL
|
||||
/// generated by `Database::dump_sql`. Values may contain quotes, semicolons, or
|
||||
/// newlines, so line filtering is insufficient; statement boundaries are found
|
||||
/// only outside SQLite single-quoted literals. Unknown output fails closed.
|
||||
fn strip_device_local_insert_statements(sql: &str) -> Result<String, AppError> {
|
||||
let bytes = sql.as_bytes();
|
||||
let mut output = String::with_capacity(sql.len());
|
||||
let mut statement_start = 0;
|
||||
let mut cursor = 0;
|
||||
let mut in_string = false;
|
||||
|
||||
while cursor < bytes.len() {
|
||||
match bytes[cursor] {
|
||||
b'\'' if in_string && bytes.get(cursor + 1) == Some(&b'\'') => {
|
||||
cursor += 2;
|
||||
continue;
|
||||
}
|
||||
b'\'' => in_string = !in_string,
|
||||
b';' if !in_string => {
|
||||
let statement_end = cursor + 1;
|
||||
let statement = &sql[statement_start..statement_end];
|
||||
if !DEVICE_LOCAL_INSERT_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| statement.trim_start().starts_with(prefix))
|
||||
{
|
||||
output.push_str(statement);
|
||||
}
|
||||
statement_start = statement_end;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
if in_string {
|
||||
return Err(AppError::Config(
|
||||
"portable SQL export ended inside a quoted value".to_string(),
|
||||
));
|
||||
}
|
||||
output.push_str(&sql[statement_start..]);
|
||||
if DEVICE_LOCAL_INSERT_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| output.contains(prefix))
|
||||
{
|
||||
return Err(AppError::Config(
|
||||
"portable SQL export retained device-local Pi ownership rows".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn deployment(skill_id: &str, destination_key: &str) -> SkillDeployment {
|
||||
SkillDeployment {
|
||||
skill_id: skill_id.to_string(),
|
||||
destination: format!("/device/{destination_key}"),
|
||||
destination_key: destination_key.to_string(),
|
||||
method: SkillDeploymentMethod::Copy,
|
||||
source_identity: format!("path:/source/{skill_id};digest:sha256:one"),
|
||||
deployed_digest: Some("sha256:one".to_string()),
|
||||
created_at: 10,
|
||||
updated_at: 20,
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_portable_provider(db: &Database) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO providers
|
||||
(id, app_type, name, settings_config, meta, is_current)
|
||||
VALUES ('portable-sentinel', 'codex', 'Portable sentinel', '{}', '{}', 0)",
|
||||
[],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_restore_poison_trigger(sql: &str) -> String {
|
||||
let insertion = sql
|
||||
.rfind("COMMIT;")
|
||||
.expect("CC Switch dump must contain a final COMMIT");
|
||||
let mut poisoned = sql.to_string();
|
||||
poisoned.insert_str(
|
||||
insertion,
|
||||
"CREATE TRIGGER poison_pi_restore \
|
||||
BEFORE INSERT ON pi_provider_projections \
|
||||
BEGIN SELECT RAISE(ABORT, 'poisoned local restore'); END;\n",
|
||||
);
|
||||
poisoned
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_sql_scrubber_handles_multiline_quoted_values() -> Result<(), AppError> {
|
||||
let sql = concat!(
|
||||
"-- CC Switch SQLite 导出\n",
|
||||
"CREATE TABLE \"pi_provider_projections\" (value TEXT);\n",
|
||||
"INSERT INTO \"pi_provider_projections\" (value) VALUES ('one;\n",
|
||||
"two ''quoted''');\n",
|
||||
"CREATE TABLE \"providers\" (value TEXT);\n",
|
||||
"INSERT INTO \"providers\" (value) VALUES ('portable;\nvalue');\n",
|
||||
"COMMIT;\n",
|
||||
);
|
||||
|
||||
let scrubbed = strip_device_local_insert_statements(sql)?;
|
||||
assert!(!scrubbed.contains("INSERT INTO \"pi_provider_projections\""));
|
||||
assert!(scrubbed.contains("CREATE TABLE \"pi_provider_projections\""));
|
||||
assert!(scrubbed.contains("INSERT INTO \"providers\""));
|
||||
assert!(scrubbed.contains("'portable;\nvalue'"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_exports_keep_schema_but_omit_device_evidence() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
db.claim_pi_projection_key("local-provider", "local-key")?;
|
||||
db.save_pi_skill_deployment(&deployment("local-skill", "local-destination"))?;
|
||||
|
||||
for exported in [
|
||||
db.export_portable_sql_string()?,
|
||||
db.export_portable_sql_string_for_sync()?,
|
||||
] {
|
||||
assert!(exported.contains("CREATE TABLE pi_provider_projections"));
|
||||
assert!(exported.contains("CREATE TABLE skill_deployments"));
|
||||
assert!(!exported.contains("INSERT INTO \"pi_provider_projections\""));
|
||||
assert!(!exported.contains("INSERT INTO \"skill_deployments\""));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_import_discards_remote_evidence_and_restores_local_evidence() -> Result<(), AppError> {
|
||||
let remote = Database::memory()?;
|
||||
seed_portable_provider(&remote)?;
|
||||
remote.claim_pi_projection_key("remote-provider", "shared-key")?;
|
||||
remote.save_pi_skill_deployment(&deployment("remote-skill", "remote-destination"))?;
|
||||
// Model an older remote snapshot created before portable row scrubbing.
|
||||
let remote_sql = add_restore_poison_trigger(&remote.export_sql_string()?);
|
||||
|
||||
let local = Database::memory()?;
|
||||
local.claim_pi_projection_key("local-provider", "shared-key")?;
|
||||
local.save_pi_skill_deployment(&deployment("local-skill", "local-destination"))?;
|
||||
local.import_portable_sql_string_for_sync(&remote_sql)?;
|
||||
|
||||
assert_eq!(
|
||||
local
|
||||
.get_pi_projection_for_key("shared-key")?
|
||||
.map(|projection| projection.provider_id),
|
||||
Some("local-provider".to_string())
|
||||
);
|
||||
assert!(local.get_pi_projection("remote-provider")?.is_none());
|
||||
assert_eq!(
|
||||
local.get_pi_skill_deployments("local-skill")?,
|
||||
vec![deployment("local-skill", "local-destination")]
|
||||
);
|
||||
assert!(local.get_pi_skill_deployments("remote-skill")?.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_sql_import_preserves_local_evidence() -> Result<(), AppError> {
|
||||
let remote = Database::memory()?;
|
||||
seed_portable_provider(&remote)?;
|
||||
remote.claim_pi_projection_key("remote-provider", "remote-key")?;
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("portable.sql");
|
||||
fs::write(
|
||||
&path,
|
||||
add_restore_poison_trigger(&remote.export_sql_string()?),
|
||||
)
|
||||
.expect("write SQL backup");
|
||||
|
||||
let local = Database::memory()?;
|
||||
local.claim_pi_projection_key("local-provider", "local-key")?;
|
||||
local.import_portable_sql(&path)?;
|
||||
|
||||
assert!(local.get_pi_projection("remote-provider")?.is_none());
|
||||
assert_eq!(
|
||||
local
|
||||
.get_pi_projection("local-provider")?
|
||||
.map(|projection| projection.provider_key),
|
||||
Some("local-key".to_string())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_restore_guard_rejects_live_ownership_before_opening_the_backup(
|
||||
) -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
db.claim_pi_projection_key("local-provider", "local-key")?;
|
||||
|
||||
let error = db
|
||||
.ensure_binary_restore_has_no_pi_ownership("missing.db")
|
||||
.expect_err("live ownership must reject before inspecting a source");
|
||||
assert!(error.to_string().contains("portable SQL"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_restore_guard_rejects_backup_ownership_but_allows_empty_tables(
|
||||
) -> Result<(), AppError> {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let empty_path = temp.path().join("empty.db");
|
||||
let owned_path = temp.path().join("owned.db");
|
||||
for path in [&empty_path, &owned_path] {
|
||||
let conn =
|
||||
Connection::open(path).map_err(|error| AppError::Database(error.to_string()))?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE PI_PROVIDER_PROJECTIONS (
|
||||
provider_id TEXT PRIMARY KEY,
|
||||
provider_key TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE SKILL_DEPLOYMENTS (
|
||||
app_type TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
}
|
||||
let owned =
|
||||
Connection::open(&owned_path).map_err(|error| AppError::Database(error.to_string()))?;
|
||||
owned
|
||||
.execute(
|
||||
"INSERT INTO pi_provider_projections (provider_id, provider_key)
|
||||
VALUES ('historical-provider', 'native-key')",
|
||||
[],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
|
||||
let empty = Connection::open_with_flags(
|
||||
&empty_path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
assert!(!pi_device_local_rows_exist(&empty)?);
|
||||
let owned = Connection::open_with_flags(
|
||||
&owned_path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
assert!(pi_device_local_rows_exist(&owned)?);
|
||||
drop(owned);
|
||||
|
||||
let owned =
|
||||
Connection::open(&owned_path).map_err(|error| AppError::Database(error.to_string()))?;
|
||||
owned
|
||||
.execute("DELETE FROM pi_provider_projections", [])
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
owned
|
||||
.execute("INSERT INTO skill_deployments (app_type) VALUES ('pi')", [])
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
drop(owned);
|
||||
let skill_only = Connection::open_with_flags(
|
||||
&owned_path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
assert!(pi_device_local_rows_exist(&skill_only)?);
|
||||
|
||||
let view_backed =
|
||||
Connection::open_in_memory().map_err(|error| AppError::Database(error.to_string()))?;
|
||||
view_backed
|
||||
.execute_batch(
|
||||
"CREATE VIEW PI_PROVIDER_PROJECTIONS AS
|
||||
SELECT 'view-provider' AS provider_id, 'view-key' AS provider_key;",
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
assert!(
|
||||
pi_device_local_rows_exist(&view_backed)?,
|
||||
"a reserved-name view must not bypass binary ownership rejection"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_import_rejects_an_unclosed_comment_before_live_replacement() -> Result<(), AppError>
|
||||
{
|
||||
let remote = Database::memory()?;
|
||||
seed_portable_provider(&remote)?;
|
||||
let malicious = format!("{}/*", remote.export_sql_string()?);
|
||||
|
||||
let local = Database::memory()?;
|
||||
local.claim_pi_projection_key("local-provider", "local-key")?;
|
||||
let error = local
|
||||
.import_portable_sql_string_for_sync(&malicious)
|
||||
.expect_err("the appended ownership program must not be swallowed");
|
||||
assert!(error.to_string().contains("unterminated"));
|
||||
assert_eq!(
|
||||
local
|
||||
.get_pi_projection("local-provider")?
|
||||
.map(|projection| projection.provider_key),
|
||||
Some("local-key".to_string())
|
||||
);
|
||||
assert!(
|
||||
local
|
||||
.get_provider_by_id("portable-sentinel", "codex")?
|
||||
.is_none(),
|
||||
"the remote database must not be published"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,308 @@
|
||||
//! 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 get_all_pi_skill_deployments(&self) -> 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'
|
||||
ORDER BY skill_id, created_at, destination_key",
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], 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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ mod schema;
|
||||
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,6 +44,7 @@ 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;
|
||||
|
||||
@@ -53,7 +58,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// 当前 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> {
|
||||
@@ -197,6 +202,11 @@ impl Database {
|
||||
conn: Mutex::new(conn),
|
||||
};
|
||||
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)
|
||||
@@ -293,3 +303,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)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,10 @@ impl Database {
|
||||
app_type TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
added_at INTEGER,
|
||||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
||||
last_used INTEGER,
|
||||
FOREIGN KEY (provider_id, app_type)
|
||||
REFERENCES providers(id, app_type) ON DELETE CASCADE,
|
||||
UNIQUE (provider_id, app_type, url)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
@@ -97,6 +100,7 @@ impl Database {
|
||||
enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_pi BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
||||
content_hash TEXT,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
@@ -105,6 +109,36 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// Reserve the v17 device-local ledgers here so later stacked features
|
||||
// never mutate the semantics of an already-published migration.
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS pi_provider_projections (
|
||||
provider_id TEXT PRIMARY KEY,
|
||||
provider_key TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS skill_deployments (
|
||||
app_type TEXT NOT NULL CHECK (app_type = 'pi'),
|
||||
skill_id TEXT NOT NULL,
|
||||
destination TEXT NOT NULL,
|
||||
destination_key TEXT NOT NULL,
|
||||
method TEXT NOT NULL CHECK (method IN ('symlink', 'copy')),
|
||||
source_identity TEXT NOT NULL,
|
||||
deployed_digest TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (app_type, skill_id, destination_key),
|
||||
UNIQUE (app_type, destination_key)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 6. Skill Repos 表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS skill_repos (
|
||||
@@ -511,6 +545,13 @@ impl Database {
|
||||
Self::migrate_v15_to_v16(conn)?;
|
||||
Self::set_user_version(conn, 16)?;
|
||||
}
|
||||
16 => {
|
||||
log::info!(
|
||||
"迁移数据库从 v16 到 v17(规范化 provider endpoint 并预留设备本地 ledger)"
|
||||
);
|
||||
Self::migrate_v16_to_v17(conn)?;
|
||||
Self::set_user_version(conn, 17)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -1523,6 +1564,112 @@ impl Database {
|
||||
crate::services::session_usage_codex::reset_codex_usage_on_conn(conn, &codex_dir)
|
||||
}
|
||||
|
||||
/// v16 -> v17: make endpoint rows a lossless, uniquely owned child
|
||||
/// collection. The device-local ledger DDL is reserved in the same
|
||||
/// migration because later stacked PRs must not rewrite a released
|
||||
/// user_version step.
|
||||
fn migrate_v16_to_v17(conn: &Connection) -> Result<(), AppError> {
|
||||
if Self::table_exists(conn, "provider_endpoints")? {
|
||||
Self::add_column_if_missing(conn, "provider_endpoints", "last_used", "INTEGER")?;
|
||||
conn.execute_batch(
|
||||
"UPDATE provider_endpoints AS kept
|
||||
SET added_at = (
|
||||
SELECT MIN(other.added_at)
|
||||
FROM provider_endpoints AS other
|
||||
WHERE other.provider_id = kept.provider_id
|
||||
AND other.app_type = kept.app_type
|
||||
AND other.url = kept.url
|
||||
),
|
||||
last_used = (
|
||||
SELECT MAX(other.last_used)
|
||||
FROM provider_endpoints AS other
|
||||
WHERE other.provider_id = kept.provider_id
|
||||
AND other.app_type = kept.app_type
|
||||
AND other.url = kept.url
|
||||
)
|
||||
WHERE kept.id = (
|
||||
SELECT MIN(other.id)
|
||||
FROM provider_endpoints AS other
|
||||
WHERE other.provider_id = kept.provider_id
|
||||
AND other.app_type = kept.app_type
|
||||
AND other.url = kept.url
|
||||
);
|
||||
DELETE FROM provider_endpoints
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id)
|
||||
FROM provider_endpoints
|
||||
GROUP BY provider_id, app_type, url
|
||||
);
|
||||
DROP TABLE IF EXISTS provider_endpoints_v17_canonical;
|
||||
CREATE TABLE provider_endpoints_v17_canonical (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
added_at INTEGER,
|
||||
last_used INTEGER,
|
||||
FOREIGN KEY (provider_id, app_type)
|
||||
REFERENCES providers(id, app_type) ON DELETE CASCADE,
|
||||
UNIQUE (provider_id, app_type, url)
|
||||
);
|
||||
INSERT INTO provider_endpoints_v17_canonical
|
||||
(id, provider_id, app_type, url, added_at, last_used)
|
||||
SELECT id, provider_id, app_type, url, added_at, last_used
|
||||
FROM provider_endpoints;
|
||||
DROP TABLE provider_endpoints;
|
||||
ALTER TABLE provider_endpoints_v17_canonical
|
||||
RENAME TO provider_endpoints;",
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
} else {
|
||||
conn.execute(
|
||||
"CREATE TABLE provider_endpoints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
added_at INTEGER,
|
||||
last_used INTEGER,
|
||||
FOREIGN KEY (provider_id, app_type)
|
||||
REFERENCES providers(id, app_type) ON DELETE CASCADE,
|
||||
UNIQUE (provider_id, app_type, url)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
}
|
||||
if Self::table_exists(conn, "skills")? {
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"skills",
|
||||
"enabled_pi",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
}
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS pi_provider_projections (
|
||||
provider_id TEXT PRIMARY KEY,
|
||||
provider_key TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skill_deployments (
|
||||
app_type TEXT NOT NULL CHECK (app_type = 'pi'),
|
||||
skill_id TEXT NOT NULL,
|
||||
destination TEXT NOT NULL,
|
||||
destination_key TEXT NOT NULL,
|
||||
method TEXT NOT NULL CHECK (method IN ('symlink', 'copy')),
|
||||
source_identity TEXT NOT NULL,
|
||||
deployed_digest TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (app_type, skill_id, destination_key),
|
||||
UNIQUE (app_type, destination_key)
|
||||
);",
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
@@ -1574,7 +1721,23 @@ impl Database {
|
||||
"0.50",
|
||||
"6.25",
|
||||
),
|
||||
// Claude 4.6 系列
|
||||
// Claude 4.6 系列(裸 id 行覆盖无日期后缀的日志变体,与 dated 行同价)
|
||||
(
|
||||
"claude-opus-4-6",
|
||||
"Claude Opus 4.6",
|
||||
"5",
|
||||
"25",
|
||||
"0.50",
|
||||
"6.25",
|
||||
),
|
||||
(
|
||||
"claude-sonnet-4-6",
|
||||
"Claude Sonnet 4.6",
|
||||
"3",
|
||||
"15",
|
||||
"0.30",
|
||||
"3.75",
|
||||
),
|
||||
(
|
||||
"claude-opus-4-6-20260206",
|
||||
"Claude Opus 4.6",
|
||||
@@ -1661,15 +1824,16 @@ impl Database {
|
||||
// GPT-5.6 系列(Sol / Terra / Luna,2026-06 发布)
|
||||
// 5.6 家族起 cache write 收 1.25× 输入价(此前 GPT 模型写缓存免费,勿回填旧系列)
|
||||
("gpt-5.6-sol", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"),
|
||||
// 2026-07-30 OpenAI 降价:luna -80%、terra -20%,sol 不变(Fast mode 2× 价不入表)
|
||||
("gpt-5.6-terra", "GPT-5.6 Terra", "2", "12", "0.20", "2.50"),
|
||||
(
|
||||
"gpt-5.6-terra",
|
||||
"GPT-5.6 Terra",
|
||||
"2.50",
|
||||
"15",
|
||||
"gpt-5.6-luna",
|
||||
"GPT-5.6 Luna",
|
||||
"0.20",
|
||||
"1.20",
|
||||
"0.02",
|
||||
"0.25",
|
||||
"3.125",
|
||||
),
|
||||
("gpt-5.6-luna", "GPT-5.6 Luna", "1", "6", "0.10", "1.25"),
|
||||
// 裸名 gpt-5.6 是 sol 的官方别名;effort 后缀对齐 gpt-5.5 系列的记账形态
|
||||
("gpt-5.6", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"),
|
||||
("gpt-5.6-low", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"),
|
||||
@@ -1729,6 +1893,14 @@ impl Database {
|
||||
),
|
||||
// GPT-5.3 Codex 系列
|
||||
("gpt-5.3-codex", "GPT-5.3 Codex", "1.75", "14", "0.175", "0"),
|
||||
(
|
||||
"gpt-5.3-codex-spark",
|
||||
"GPT-5.3 Codex Spark",
|
||||
"1.75",
|
||||
"14",
|
||||
"0.175",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5.3-codex-low",
|
||||
"GPT-5.3 Codex",
|
||||
@@ -1873,6 +2045,14 @@ impl Database {
|
||||
"0.15",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gemini-3.5-flash-lite",
|
||||
"Gemini 3.5 Flash Lite",
|
||||
"0.30",
|
||||
"2.50",
|
||||
"0.03",
|
||||
"0",
|
||||
),
|
||||
// Gemini 3.1 系列
|
||||
(
|
||||
"gemini-3.1-pro-preview",
|
||||
@@ -2062,20 +2242,21 @@ impl Database {
|
||||
"0",
|
||||
),
|
||||
("deepseek-v3", "DeepSeek V3", "0.28", "1.11", "0.028", "0"),
|
||||
// deepseek-chat / deepseek-reasoner 自 2026-07 起为 V4 Flash 的 legacy 别名(同价)
|
||||
(
|
||||
"deepseek-chat",
|
||||
"DeepSeek Chat",
|
||||
"0.27",
|
||||
"1.10",
|
||||
"0.07",
|
||||
"0.14",
|
||||
"0.28",
|
||||
"0.0028",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"deepseek-reasoner",
|
||||
"DeepSeek Reasoner",
|
||||
"0.55",
|
||||
"2.19",
|
||||
"0.14",
|
||||
"0.28",
|
||||
"0.0028",
|
||||
"0",
|
||||
),
|
||||
// DeepSeek V4 系列(官方 CNY 按 1 USD ≈ 7.14 折算)
|
||||
@@ -2123,6 +2304,15 @@ impl Database {
|
||||
"0.19",
|
||||
"0",
|
||||
),
|
||||
// HighSpeed 加速档=本体 2 倍价(Kimi 官方一贯模式,同 K2 Turbo)
|
||||
(
|
||||
"kimi-k2.7-code-highspeed",
|
||||
"Kimi K2.7 Code HighSpeed",
|
||||
"1.90",
|
||||
"8.00",
|
||||
"0.38",
|
||||
"0",
|
||||
),
|
||||
("kimi-k3", "Kimi K3", "3.00", "15.00", "0.30", "0"),
|
||||
// Kimi For Coding 套餐里 K3 的裸名(无 kimi- 前缀),同标准 list 价
|
||||
("k3", "Kimi K3", "3.00", "15.00", "0.30", "0"),
|
||||
@@ -2165,13 +2355,15 @@ impl Database {
|
||||
"0.06",
|
||||
"0.375",
|
||||
),
|
||||
("minimax-m3", "MiniMax M3", "0.60", "2.40", "0.12", "0"),
|
||||
("minimax-m3", "MiniMax M3", "0.30", "1.20", "0.06", "0"),
|
||||
// GLM (智谱)
|
||||
("glm-4.7", "GLM-4.7", "0.6", "2.2", "0.11", "0"),
|
||||
("glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0"),
|
||||
("glm-5", "GLM-5", "1", "3.2", "0.2", "0"),
|
||||
("glm-5.1", "GLM-5.1", "1.4", "4.4", "0.26", "0"),
|
||||
("glm-5.2", "GLM-5.2", "1.4", "4.4", "0.26", "0"),
|
||||
("glm-5-turbo", "GLM-5-Turbo", "1.2", "4", "0.24", "0"),
|
||||
("glm-5v-turbo", "GLM-5V-Turbo", "1.2", "4", "0.24", "0"),
|
||||
// MiMo (小米)
|
||||
(
|
||||
"mimo-v2-flash",
|
||||
@@ -2202,6 +2394,14 @@ impl Database {
|
||||
"0.065",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3.6-flash",
|
||||
"Qwen3.6 Flash",
|
||||
"0.1875",
|
||||
"1.125",
|
||||
"0.0375",
|
||||
"0",
|
||||
),
|
||||
("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0.052", "0"),
|
||||
("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"),
|
||||
(
|
||||
@@ -2429,6 +2629,95 @@ impl Database {
|
||||
|
||||
fn repair_current_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||||
let pricing_fixes = [
|
||||
// 2026-07-30 OpenAI GPT-5.6 降价:luna -80%、terra -20%(sol 不变)。
|
||||
// 每档两条守卫:主守卫匹配 ≥v3.19(已跑过 07-12 cache_write 修正),
|
||||
// 0 态守卫匹配 <v3.19 直升用户(cache_write 仍为旧 seed 的 0)
|
||||
(
|
||||
"gpt-5.6-luna",
|
||||
"GPT-5.6 Luna",
|
||||
"0.20",
|
||||
"1.20",
|
||||
"0.02",
|
||||
"0.25",
|
||||
"1",
|
||||
"6",
|
||||
"0.10",
|
||||
"1.25",
|
||||
),
|
||||
(
|
||||
"gpt-5.6-luna",
|
||||
"GPT-5.6 Luna",
|
||||
"0.20",
|
||||
"1.20",
|
||||
"0.02",
|
||||
"0.25",
|
||||
"1",
|
||||
"6",
|
||||
"0.10",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5.6-terra",
|
||||
"GPT-5.6 Terra",
|
||||
"2",
|
||||
"12",
|
||||
"0.20",
|
||||
"2.50",
|
||||
"2.50",
|
||||
"15",
|
||||
"0.25",
|
||||
"3.125",
|
||||
),
|
||||
(
|
||||
"gpt-5.6-terra",
|
||||
"GPT-5.6 Terra",
|
||||
"2",
|
||||
"12",
|
||||
"0.20",
|
||||
"2.50",
|
||||
"2.50",
|
||||
"15",
|
||||
"0.25",
|
||||
"0",
|
||||
),
|
||||
// 2026-07-31 models.dev 审计核价:DeepSeek V4 发布后 chat/reasoner 降为 V4 Flash
|
||||
// 别名价;MiniMax M3 官方 standard 档 0.3/1.2(旧值疑似录了加速档)
|
||||
(
|
||||
"deepseek-chat",
|
||||
"DeepSeek Chat",
|
||||
"0.14",
|
||||
"0.28",
|
||||
"0.0028",
|
||||
"0",
|
||||
"0.27",
|
||||
"1.10",
|
||||
"0.07",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"deepseek-reasoner",
|
||||
"DeepSeek Reasoner",
|
||||
"0.14",
|
||||
"0.28",
|
||||
"0.0028",
|
||||
"0",
|
||||
"0.55",
|
||||
"2.19",
|
||||
"0.14",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"minimax-m3",
|
||||
"MiniMax M3",
|
||||
"0.30",
|
||||
"1.20",
|
||||
"0.06",
|
||||
"0",
|
||||
"0.60",
|
||||
"2.40",
|
||||
"0.12",
|
||||
"0",
|
||||
),
|
||||
// 2026-07-12 GPT-5.6 家族 cache write=1.25× 输入价(OpenAI 5.6 起的新规),
|
||||
// 修正早期 seed 的 0 值;只匹配未被用户改过的行
|
||||
(
|
||||
@@ -3080,7 +3369,7 @@ mod tests {
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, 16);
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
let counts: (i64, i64, i64, i64) = conn.query_row(
|
||||
"SELECT
|
||||
(SELECT COUNT(*) FROM proxy_request_logs WHERE data_source = 'codex_session'),
|
||||
@@ -3093,4 +3382,67 @@ mod tests {
|
||||
assert_eq!(counts, (0, 1, 0, 1));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_v16_to_v17_preserves_endpoint_metadata_and_starts_ledgers_empty(
|
||||
) -> Result<(), AppError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE providers (
|
||||
id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
PRIMARY KEY (id, app_type)
|
||||
);
|
||||
CREATE TABLE provider_endpoints (
|
||||
id INTEGER PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
added_at INTEGER
|
||||
);
|
||||
CREATE TABLE skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO providers (id, app_type) VALUES ('provider', 'pi');
|
||||
INSERT INTO provider_endpoints
|
||||
(id, provider_id, app_type, url, added_at)
|
||||
VALUES
|
||||
(1, 'provider', 'pi', 'https://duplicate.test', 20),
|
||||
(2, 'provider', 'pi', 'https://duplicate.test', 10);
|
||||
INSERT INTO skills (id, enabled_codex) VALUES ('existing', 1);",
|
||||
)?;
|
||||
Database::set_user_version(&conn, 16)?;
|
||||
|
||||
Database::apply_schema_migrations_on_conn(&conn)?;
|
||||
|
||||
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
|
||||
assert!(Database::has_column(
|
||||
&conn,
|
||||
"provider_endpoints",
|
||||
"last_used"
|
||||
)?);
|
||||
assert!(Database::has_column(&conn, "skills", "enabled_pi")?);
|
||||
assert!(Database::table_exists(&conn, "pi_provider_projections")?);
|
||||
assert!(Database::table_exists(&conn, "skill_deployments")?);
|
||||
let endpoint: (i64, Option<i64>) = conn.query_row(
|
||||
"SELECT COUNT(*), MIN(added_at)
|
||||
FROM provider_endpoints
|
||||
WHERE provider_id = 'provider'
|
||||
AND app_type = 'pi'
|
||||
AND url = 'https://duplicate.test'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
assert_eq!(endpoint, (1, Some(10)));
|
||||
let ledgers: (i64, i64) = conn.query_row(
|
||||
"SELECT
|
||||
(SELECT COUNT(*) FROM pi_provider_projections),
|
||||
(SELECT COUNT(*) FROM skill_deployments)",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
assert_eq!(ledgers, (0, 0));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![cfg(test)]
|
||||
|
||||
//! 数据库模块测试
|
||||
//!
|
||||
//! 包含 Schema 迁移和基本功能的测试。
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -287,7 +287,8 @@ pub fn apply_proxy_takeover(
|
||||
token_placeholder: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let updated = update_selected_model_string(config_toml, "base_url", proxy_base_url)?;
|
||||
update_selected_model_string(&updated, "api_key", token_placeholder)
|
||||
let updated = update_selected_model_string(&updated, "api_key", token_placeholder)?;
|
||||
update_selected_model_string(&updated, "api_backend", DEFAULT_API_BACKEND)
|
||||
}
|
||||
|
||||
pub fn update_api_key(config_toml: &str, api_key: &str) -> Result<String, AppError> {
|
||||
@@ -512,8 +513,12 @@ context_window = 500000
|
||||
|
||||
#[test]
|
||||
fn takeover_preserves_env_key_profile_and_injects_inline_placeholder() {
|
||||
let direct_config = valid_env_key_config().replace(
|
||||
"api_backend = \"responses\"",
|
||||
"api_backend = \"chat_completions\"",
|
||||
);
|
||||
let updated = apply_proxy_takeover(
|
||||
valid_env_key_config(),
|
||||
&direct_config,
|
||||
"http://127.0.0.1:15721/grokbuild/v1",
|
||||
"PROXY_MANAGED",
|
||||
)
|
||||
@@ -523,6 +528,7 @@ context_window = 500000
|
||||
assert_eq!(selected.profile, "grok-env");
|
||||
assert_eq!(selected.env_key.as_deref(), Some("GROK_TEST_API_KEY"));
|
||||
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
|
||||
assert_eq!(selected.api_backend, DEFAULT_API_BACKEND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+82
-13
@@ -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,12 +39,20 @@ 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, write_codex_live_atomic};
|
||||
pub use codex_config::{
|
||||
get_codex_auth_path, get_codex_config_path, read_codex_live_settings, 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;
|
||||
@@ -55,7 +64,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,
|
||||
@@ -944,6 +953,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,
|
||||
@@ -1322,6 +1332,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,
|
||||
@@ -1406,6 +1422,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,
|
||||
@@ -1460,6 +1484,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,
|
||||
@@ -1825,7 +1850,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 配置(保留代理状态)...");
|
||||
@@ -1849,6 +1878,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()`,
|
||||
@@ -1879,7 +1916,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
|
||||
@@ -1890,12 +1930,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!("启动时无需恢复代理状态");
|
||||
@@ -1916,7 +1960,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)
|
||||
@@ -1986,6 +2038,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,
|
||||
@@ -1999,11 +2052,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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2214,9 +2270,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;
|
||||
|
||||
@@ -2338,8 +2394,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! 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> {
|
||||
// Native model identifiers are opaque exact strings under pinned Pi's
|
||||
// schema. Do not trim a schema-valid whitespace or edge-whitespace ID.
|
||||
if provider_key.trim().is_empty() || model_id.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]
|
||||
#[serial_test::serial]
|
||||
fn native_default_preserves_a_pinned_exact_whitespace_model_id() {
|
||||
struct EnvGuard(Option<std::ffi::OsString>);
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let _home = EnvGuard(std::env::var_os("CC_SWITCH_TEST_HOME"));
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
|
||||
|
||||
set_pi_native_default_with_receipt("provider", " ")
|
||||
.expect("pinned exact model id is accepted");
|
||||
let defaults = read_pi_native_defaults().expect("native defaults");
|
||||
assert_eq!(defaults.default_provider.as_deref(), Some("provider"));
|
||||
assert_eq!(defaults.default_model.as_deref(), Some(" "));
|
||||
assert!(set_pi_native_default_with_receipt("provider", "").is_err());
|
||||
}
|
||||
|
||||
#[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,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,
|
||||
|
||||
@@ -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,13 +34,40 @@ 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 => "AGENTS.md",
|
||||
AppType::Hermes => "SOUL.md",
|
||||
AppType::Pi => "AGENTS.md",
|
||||
AppType::ClaudeDesktop => unreachable!("handled above"),
|
||||
};
|
||||
|
||||
Ok(base_dir.join(filename))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hermes_prompt_file_uses_soul_md() {
|
||||
let path = prompt_file_path(&AppType::Hermes).expect("Hermes prompt path");
|
||||
|
||||
assert_eq!(
|
||||
path.file_name().and_then(|name| name.to_str()),
|
||||
Some("SOUL.md")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_prompt_file_uses_agents_md() {
|
||||
let path = prompt_file_path(&AppType::Pi).expect("Pi prompt path");
|
||||
|
||||
assert_eq!(
|
||||
path.file_name().and_then(|name| name.to_str()),
|
||||
Some("AGENTS.md")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_base_dir_with_fallback(
|
||||
primary_path: PathBuf,
|
||||
fallback_dir: &str,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -87,6 +231,17 @@ impl Provider {
|
||||
|| self.claude_base_url_contains("chatgpt.com/backend-api/codex")
|
||||
}
|
||||
|
||||
/// Whether the provider form's "auth field" was explicitly set to
|
||||
/// ANTHROPIC_API_KEY. The form only persists `meta.apiKeyField` for the
|
||||
/// non-default choice, so `None` means the default ANTHROPIC_AUTH_TOKEN.
|
||||
pub fn claude_uses_api_key_field(&self) -> bool {
|
||||
self.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.api_key_field.as_deref())
|
||||
.map(|field| field.eq_ignore_ascii_case("ANTHROPIC_API_KEY"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> Option<&str> {
|
||||
self.meta.as_ref().and_then(|m| m.provider_type.as_deref())
|
||||
}
|
||||
@@ -191,6 +346,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")),
|
||||
|
||||
@@ -1183,6 +1183,15 @@ impl RequestForwarder {
|
||||
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
|
||||
self.apply_copilot_live_model_resolution(provider, &mut mapped_body)
|
||||
.await;
|
||||
// Strip the [1M] context marker after Copilot normalization/resolve.
|
||||
// A user's mapped value (e.g. "gpt-5.6-sol[1M]") carries [1M] as a
|
||||
// Claude Code context-capability declaration that upstream APIs reject
|
||||
// as part of the model name. The preceding normalization step already
|
||||
// rewrites claude-xxx[1M] into the "-1m" dash form Copilot accepts, and
|
||||
// the strip helper only touches the "[1m]" bracket form, so "-1m"
|
||||
// variants pass through unchanged.
|
||||
mapped_body =
|
||||
super::model_mapper::strip_one_m_suffix_for_upstream_from_body(mapped_body);
|
||||
} else if !codex_responses_to_anthropic {
|
||||
// Skip on the Codex→Anthropic path: stripping [1m] here would break both the
|
||||
// model-catalog match (apply_codex_upstream_model) and the transform's own
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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>,
|
||||
@@ -2630,7 +2639,7 @@ async fn log_usage(
|
||||
model
|
||||
};
|
||||
|
||||
let dedup_scope = (app_type != "claude").then_some((app_type, provider_id));
|
||||
let dedup_scope = super::usage::parser::dedup_scope_for_app(app_type, provider_id);
|
||||
let request_id = usage.dedup_request_id(dedup_scope);
|
||||
|
||||
if let Err(e) = logger.log_with_calculation(
|
||||
@@ -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,
|
||||
|
||||
@@ -10,8 +10,15 @@ use std::net::IpAddr;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 全局 HTTP 客户端实例
|
||||
static GLOBAL_CLIENT: OnceCell<RwLock<Client>> = OnceCell::new();
|
||||
#[derive(Clone)]
|
||||
struct GlobalClients {
|
||||
standard: Client,
|
||||
no_redirect: Client,
|
||||
}
|
||||
|
||||
/// 全局 HTTP 客户端实例。Pi 网关使用同一代理配置下的 no-redirect 客户端,
|
||||
/// 防止 307/308 把凭证、自定义头和请求体重放到另一个 origin。
|
||||
static GLOBAL_CLIENTS: OnceCell<RwLock<GlobalClients>> = OnceCell::new();
|
||||
|
||||
/// 当前代理 URL(用于日志和状态查询)
|
||||
static CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();
|
||||
@@ -52,10 +59,10 @@ fn get_proxy_port() -> u16 {
|
||||
/// 传入 None 或空字符串表示直连
|
||||
pub fn init(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||
let client = build_client(effective_url)?;
|
||||
let clients = build_clients(effective_url)?;
|
||||
|
||||
// 尝试初始化全局客户端,如果已存在则记录警告并使用 apply_proxy 更新
|
||||
if GLOBAL_CLIENT.set(RwLock::new(client.clone())).is_err() {
|
||||
if GLOBAL_CLIENTS.set(RwLock::new(clients)).is_err() {
|
||||
log::warn!(
|
||||
"[GlobalProxy] [GP-003] Already initialized, updating instead: {}",
|
||||
effective_url
|
||||
@@ -91,8 +98,8 @@ pub fn init(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
/// 验证成功返回 Ok(()),失败返回错误信息
|
||||
pub fn validate_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||
// 只调用 build_client 来验证,但不应用
|
||||
build_client(effective_url)?;
|
||||
// 同时验证标准与 no-redirect 客户端,保证应用配置时不会只更新一半。
|
||||
build_clients(effective_url)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -105,15 +112,15 @@ pub fn validate_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
/// * `proxy_url` - 代理 URL,None 或空字符串表示直连
|
||||
pub fn apply_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||
let new_client = build_client(effective_url)?;
|
||||
let new_clients = build_clients(effective_url)?;
|
||||
|
||||
// 更新客户端
|
||||
if let Some(lock) = GLOBAL_CLIENT.get() {
|
||||
let mut client = lock.write().map_err(|e| {
|
||||
if let Some(lock) = GLOBAL_CLIENTS.get() {
|
||||
let mut clients = lock.write().map_err(|e| {
|
||||
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
|
||||
"Failed to update proxy: lock poisoned".to_string()
|
||||
})?;
|
||||
*client = new_client;
|
||||
*clients = new_clients;
|
||||
} else {
|
||||
// 如果还没初始化,则初始化
|
||||
return init(proxy_url);
|
||||
@@ -148,54 +155,42 @@ pub fn apply_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
/// * `proxy_url` - 新的代理 URL,None 或空字符串表示直连
|
||||
#[allow(dead_code)]
|
||||
pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> {
|
||||
let effective_url = proxy_url.filter(|s| !s.trim().is_empty());
|
||||
let new_client = build_client(effective_url)?;
|
||||
|
||||
// 更新客户端
|
||||
if let Some(lock) = GLOBAL_CLIENT.get() {
|
||||
let mut client = lock.write().map_err(|e| {
|
||||
log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}");
|
||||
"Failed to update proxy: lock poisoned".to_string()
|
||||
})?;
|
||||
*client = new_client;
|
||||
} else {
|
||||
// 如果还没初始化,则初始化
|
||||
return init(proxy_url);
|
||||
}
|
||||
|
||||
// 更新代理 URL 记录
|
||||
if let Some(lock) = CURRENT_PROXY_URL.get() {
|
||||
let mut url = lock.write().map_err(|e| {
|
||||
log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}");
|
||||
"Failed to update proxy URL record: lock poisoned".to_string()
|
||||
})?;
|
||||
*url = effective_url.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] Updated: {}",
|
||||
effective_url
|
||||
.map(mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
apply_proxy(proxy_url)
|
||||
}
|
||||
|
||||
/// 获取全局 HTTP 客户端
|
||||
///
|
||||
/// 返回配置了代理的客户端(如果已配置代理),否则返回跟随系统代理的客户端。
|
||||
pub fn get() -> Client {
|
||||
GLOBAL_CLIENT
|
||||
GLOBAL_CLIENTS
|
||||
.get()
|
||||
.and_then(|lock| lock.read().ok())
|
||||
.map(|c| c.clone())
|
||||
.map(|clients| clients.standard.clone())
|
||||
.unwrap_or_else(|| {
|
||||
log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback");
|
||||
build_client(None).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取禁用自动重定向的全局客户端。
|
||||
///
|
||||
/// 与 `get` 不同,这个安全边界在锁毒化或构建失败时 fail closed;调用方不得
|
||||
/// 回退到会自动跟随跨 origin 重定向的默认客户端。
|
||||
pub(crate) fn get_no_redirect() -> Result<Client, String> {
|
||||
if let Some(lock) = GLOBAL_CLIENTS.get() {
|
||||
return lock
|
||||
.read()
|
||||
.map(|clients| clients.no_redirect.clone())
|
||||
.map_err(|error| {
|
||||
log::error!("[GlobalProxy] [GP-005] Failed to acquire read lock: {error}");
|
||||
"Failed to read no-redirect HTTP client: lock poisoned".to_string()
|
||||
});
|
||||
}
|
||||
|
||||
log::warn!("[GlobalProxy] [GP-006] Client not initialized, using no-redirect fallback");
|
||||
build_no_redirect_client(None)
|
||||
}
|
||||
|
||||
/// 获取当前代理 URL
|
||||
///
|
||||
/// 返回当前配置的代理 URL,None 表示直连。
|
||||
@@ -214,6 +209,24 @@ pub fn is_proxy_enabled() -> bool {
|
||||
|
||||
/// 构建 HTTP 客户端
|
||||
fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
||||
build_client_with_redirect_policy(proxy_url, true)
|
||||
}
|
||||
|
||||
fn build_no_redirect_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
||||
build_client_with_redirect_policy(proxy_url, false)
|
||||
}
|
||||
|
||||
fn build_clients(proxy_url: Option<&str>) -> Result<GlobalClients, String> {
|
||||
Ok(GlobalClients {
|
||||
standard: build_client(proxy_url)?,
|
||||
no_redirect: build_no_redirect_client(proxy_url)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_client_with_redirect_policy(
|
||||
proxy_url: Option<&str>,
|
||||
follow_redirects: bool,
|
||||
) -> Result<Client, String> {
|
||||
let mut builder = Client::builder()
|
||||
.timeout(Duration::from_secs(600))
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
@@ -225,6 +238,9 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
||||
.no_brotli()
|
||||
.no_deflate()
|
||||
.no_zstd();
|
||||
if !follow_redirects {
|
||||
builder = builder.redirect(reqwest::redirect::Policy::none());
|
||||
}
|
||||
|
||||
// 有代理地址则使用代理,否则跟随系统代理
|
||||
if let Some(url) = proxy_url {
|
||||
@@ -337,7 +353,9 @@ pub fn mask_url(url: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
@@ -392,6 +410,52 @@ mod tests {
|
||||
assert!(result.is_err(), "Should reject invalid proxy scheme");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_redirect_client_exposes_redirect_without_replaying_request() {
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.expect("bind redirect server");
|
||||
let address = listener.local_addr().expect("server address");
|
||||
let hits = Arc::new(AtomicUsize::new(0));
|
||||
let server_hits = Arc::clone(&hits);
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept request");
|
||||
let hit = server_hits.fetch_add(1, Ordering::SeqCst);
|
||||
let mut request = [0_u8; 2048];
|
||||
let _ = stream.read(&mut request).await.expect("read request");
|
||||
let response = if hit == 0 {
|
||||
format!(
|
||||
"HTTP/1.1 307 Temporary Redirect\r\n\
|
||||
Location: http://{address}/redirected\r\n\
|
||||
Content-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
} else {
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string()
|
||||
};
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.expect("write response");
|
||||
}
|
||||
});
|
||||
|
||||
let client = build_no_redirect_client(None).expect("build no-redirect client");
|
||||
let response = client
|
||||
.get(format!("http://{address}/initial"))
|
||||
.send()
|
||||
.await
|
||||
.expect("send request");
|
||||
assert_eq!(response.status(), reqwest::StatusCode::TEMPORARY_REDIRECT);
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
assert_eq!(
|
||||
hits.load(Ordering::SeqCst),
|
||||
1,
|
||||
"the redirected endpoint must not receive a replay"
|
||||
);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_proxy_points_to_loopback() {
|
||||
// 设置 CC Switch 代理端口为 15721(默认值)
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
// 启用自动故障转移
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
@@ -642,7 +647,7 @@ async fn log_usage_internal(
|
||||
model
|
||||
};
|
||||
|
||||
let dedup_scope = (app_type != "claude").then_some((app_type, provider_id));
|
||||
let dedup_scope = super::usage::parser::dedup_scope_for_app(app_type, provider_id);
|
||||
let request_id = usage.dedup_request_id(dedup_scope);
|
||||
|
||||
log::debug!(
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
+130
-10
@@ -7,6 +7,7 @@
|
||||
//! 支持从客户端请求中提取 Session ID,用于关联同一对话的多个请求:
|
||||
//! - Claude: 从 `metadata.user_id` (格式: `user_xxx_session_yyy`) 或 `metadata.session_id` 提取
|
||||
//! - Codex: 从 headers 中的 `session_id` / `x-session-id` 或 `metadata.session_id` 提取
|
||||
//! - Grok Build: 从 headers 中的 `x-grok-conv-id` / `x-grok-session-id` 提取
|
||||
//! - 其他: 生成新的 UUID
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
@@ -23,7 +24,7 @@ pub enum SessionIdSource {
|
||||
MetadataUserId,
|
||||
/// 从 metadata.session_id 提取
|
||||
MetadataSessionId,
|
||||
/// 从 headers 提取 (Codex)
|
||||
/// 从 headers 提取
|
||||
Header,
|
||||
/// 新生成
|
||||
Generated,
|
||||
@@ -56,6 +57,11 @@ pub struct SessionIdResult {
|
||||
/// 2. `metadata.session_id`
|
||||
/// 3. 生成新 UUID
|
||||
///
|
||||
/// ### Grok Build 请求
|
||||
/// 1. Headers: `x-grok-conv-id` 或 `x-grok-session-id`
|
||||
/// 2. `metadata.session_id`
|
||||
/// 3. 生成新 UUID
|
||||
///
|
||||
/// ## 示例
|
||||
///
|
||||
/// ```ignore
|
||||
@@ -73,9 +79,15 @@ pub fn extract_session_id(
|
||||
}
|
||||
}
|
||||
|
||||
// Codex 请求特殊处理
|
||||
if client_format == "codex" || client_format == "openai" {
|
||||
if let Some(result) = extract_codex_session(headers, body) {
|
||||
// Responses 请求特殊处理。Grok Build 使用与 Codex 相同的客户端协议,
|
||||
// 但保留独立前缀,避免统计和缓存键跨应用碰撞。
|
||||
if matches!(client_format, "codex" | "openai" | "grokbuild") {
|
||||
let prefix = if client_format == "grokbuild" {
|
||||
"grokbuild"
|
||||
} else {
|
||||
"codex"
|
||||
};
|
||||
if let Some(result) = extract_responses_session(headers, body, prefix) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -111,16 +123,28 @@ fn extract_claude_session(
|
||||
extract_from_metadata(body)
|
||||
}
|
||||
|
||||
/// 提取 Codex Session ID
|
||||
fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option<SessionIdResult> {
|
||||
/// 提取 Responses 客户端的 Session ID
|
||||
fn extract_responses_session(
|
||||
headers: &HeaderMap,
|
||||
body: &serde_json::Value,
|
||||
prefix: &str,
|
||||
) -> Option<SessionIdResult> {
|
||||
// 1. 从 headers 提取
|
||||
for header_name in &["session_id", "x-session-id"] {
|
||||
let header_names: &[&str] = if prefix == "grokbuild" {
|
||||
// Conversation ID 跨多轮请求保持稳定;session ID 作为客户端缺少
|
||||
// conversation ID 时的回退。x-grok-req-id 是逐请求 ID,不能用于聚合。
|
||||
&["x-grok-conv-id", "x-grok-session-id"]
|
||||
} else {
|
||||
&["session_id", "x-session-id"]
|
||||
};
|
||||
for header_name in header_names {
|
||||
if let Some(value) = headers.get(*header_name) {
|
||||
if let Ok(session_id) = value.to_str() {
|
||||
// Codex Session ID 通常较长(UUID 格式)
|
||||
let session_id = session_id.trim();
|
||||
// Responses 客户端的 Session ID 通常较长(UUID 格式)
|
||||
if session_id.len() > 20 {
|
||||
return Some(SessionIdResult {
|
||||
session_id: format!("codex_{session_id}"),
|
||||
session_id: format!("{prefix}_{session_id}"),
|
||||
source: SessionIdSource::Header,
|
||||
client_provided: true,
|
||||
});
|
||||
@@ -135,9 +159,10 @@ fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Optio
|
||||
.and_then(|m| m.get("session_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let session_id = session_id.trim();
|
||||
if session_id.len() > 10 {
|
||||
return Some(SessionIdResult {
|
||||
session_id: format!("codex_{session_id}"),
|
||||
session_id: format!("{prefix}_{session_id}"),
|
||||
source: SessionIdSource::MetadataSessionId,
|
||||
client_provided: true,
|
||||
});
|
||||
@@ -302,6 +327,101 @@ mod tests {
|
||||
assert!(!result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_keeps_existing_response_session_headers() {
|
||||
let body = json!({ "input": "Write a function" });
|
||||
|
||||
for header_name in ["session_id", "x-session-id"] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header_name,
|
||||
"d937243f-2702-4f20-97b6-c9682235ab81".parse().unwrap(),
|
||||
);
|
||||
|
||||
let result = extract_session_id(&headers, &body, "codex");
|
||||
|
||||
assert_eq!(
|
||||
result.session_id,
|
||||
"codex_d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
);
|
||||
assert_eq!(result.source, SessionIdSource::Header);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grokbuild_prefers_conversation_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-grok-conv-id",
|
||||
"conv-724f4275-584e-43af-ad46-b5e7509a3ca2".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"x-grok-session-id",
|
||||
"session-d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
let body = json!({ "input": "Write a function" });
|
||||
|
||||
let result = extract_session_id(&headers, &body, "grokbuild");
|
||||
|
||||
assert_eq!(
|
||||
result.session_id,
|
||||
"grokbuild_conv-724f4275-584e-43af-ad46-b5e7509a3ca2"
|
||||
);
|
||||
assert_eq!(result.source, SessionIdSource::Header);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grokbuild_falls_back_to_session_header() {
|
||||
let body = json!({ "input": "Write a function" });
|
||||
|
||||
for conversation_id in ["", " "] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-grok-conv-id", conversation_id.parse().unwrap());
|
||||
headers.insert(
|
||||
"x-grok-session-id",
|
||||
"session-d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let result = extract_session_id(&headers, &body, "grokbuild");
|
||||
|
||||
assert_eq!(
|
||||
result.session_id,
|
||||
"grokbuild_session-d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
);
|
||||
assert_eq!(result.source, SessionIdSource::Header);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grokbuild_ignores_request_and_codex_session_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-grok-req-id",
|
||||
"request-724f4275-584e-43af-ad46-b5e7509a3ca2"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"x-session-id",
|
||||
"codex-d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
let body = json!({ "input": "Write a function" });
|
||||
|
||||
let result = extract_session_id(&headers, &body, "grokbuild");
|
||||
|
||||
assert_eq!(result.source, SessionIdSource::Generated);
|
||||
assert!(!result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_generates_new_when_not_found() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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健康状态
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -360,14 +362,17 @@ impl<'a> UsageLogger<'a> {
|
||||
} else {
|
||||
app_type
|
||||
};
|
||||
let default_multiplier_raw =
|
||||
let default_multiplier_raw = if default_app_type == "pi" {
|
||||
crate::settings::get_pi_default_cost_multiplier()
|
||||
} else {
|
||||
match self.db.get_default_cost_multiplier(default_app_type).await {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
log::warn!("[USG-003] 获取默认倍率失败 (app_type={app_type}): {e}");
|
||||
"1".to_string()
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
let default_multiplier = match Decimal::from_str(&default_multiplier_raw) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
@@ -378,14 +383,17 @@ impl<'a> UsageLogger<'a> {
|
||||
}
|
||||
};
|
||||
|
||||
let default_pricing_source_raw =
|
||||
let default_pricing_source_raw = if default_app_type == "pi" {
|
||||
crate::settings::get_pi_pricing_model_source()
|
||||
} else {
|
||||
match self.db.get_pricing_model_source(default_app_type).await {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}");
|
||||
PRICING_SOURCE_RESPONSE.to_string()
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
let default_pricing_source = if default_pricing_source_raw == PRICING_SOURCE_RESPONSE
|
||||
|| default_pricing_source_raw == PRICING_SOURCE_REQUEST
|
||||
{
|
||||
@@ -451,6 +459,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 +480,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 +494,7 @@ impl<'a> UsageLogger<'a> {
|
||||
model,
|
||||
request_model,
|
||||
pricing_model,
|
||||
input_token_semantics,
|
||||
usage,
|
||||
cost,
|
||||
latency_ms,
|
||||
@@ -513,6 +523,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 +577,7 @@ mod tests {
|
||||
"test-model".to_string(),
|
||||
"req-model".to_string(),
|
||||
"test-model".to_string(),
|
||||
InputTokenSemantics::FreshExcludesCache,
|
||||
usage,
|
||||
Decimal::from(1),
|
||||
100,
|
||||
@@ -687,6 +699,57 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_proxy_replaces_matching_session_log_row() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, input_tokens,
|
||||
output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
latency_ms, status_code, created_at, data_source
|
||||
) VALUES ('session:msg_desktop', '_session', 'claude',
|
||||
'claude-sonnet-4-5', 10, 5, 2, 1, 0, 200, 1, 'session_log')",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cache_read_tokens: 2,
|
||||
cache_creation_tokens: 1,
|
||||
model: Some("claude-sonnet-4-5".to_string()),
|
||||
message_id: Some("msg_desktop".to_string()),
|
||||
};
|
||||
let request_id = usage.dedup_request_id(crate::proxy::usage::parser::dedup_scope_for_app(
|
||||
"claude-desktop",
|
||||
"desktop-provider",
|
||||
));
|
||||
let mut proxy_log = request_log(&request_id, 10);
|
||||
proxy_log.provider_id = "desktop-provider".to_string();
|
||||
proxy_log.app_type = "claude-desktop".to_string();
|
||||
proxy_log.model = "claude-sonnet-4-5".to_string();
|
||||
proxy_log.request_model = "claude-sonnet-4-5".to_string();
|
||||
proxy_log.pricing_model = "claude-sonnet-4-5".to_string();
|
||||
proxy_log.usage = usage;
|
||||
|
||||
UsageLogger::new(&db).log_request(&proxy_log)?;
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let (count, source, app_type): (i64, String, String) = conn.query_row(
|
||||
"SELECT COUNT(*), data_source, app_type FROM proxy_request_logs
|
||||
WHERE request_id = 'session:msg_desktop'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)?;
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(source, "proxy");
|
||||
assert_eq!(app_type, "claude-desktop");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_error() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
@@ -700,6 +763,7 @@ mod tests {
|
||||
500,
|
||||
"Internal Server Error".to_string(),
|
||||
50,
|
||||
InputTokenSemantics::FreshExcludesCache,
|
||||
)?;
|
||||
|
||||
// 验证错误记录已插入
|
||||
@@ -727,6 +791,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,
|
||||
@@ -747,7 +812,10 @@ mod tests {
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(semantics, INPUT_TOKEN_SEMANTICS_TOTAL);
|
||||
assert_eq!(
|
||||
semantics,
|
||||
InputTokenSemantics::TotalIncludesCacheBuckets.stored_value()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,6 +30,16 @@ fn openai_cache_write_tokens(usage: &Value) -> u32 {
|
||||
/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致
|
||||
pub const SESSION_REQUEST_ID_PREFIX: &str = "session:";
|
||||
|
||||
/// Claude Code and Claude Desktop share Claude message ids with the session
|
||||
/// importer, so both use the bare `session:{message_id}` namespace. Other
|
||||
/// apps retain app/provider scoping to avoid collisions between upstreams.
|
||||
pub fn dedup_scope_for_app<'a>(
|
||||
app_type: &'a str,
|
||||
provider_id: &'a str,
|
||||
) -> Option<(&'a str, &'a str)> {
|
||||
(!matches!(app_type, "claude" | "claude-desktop")).then_some((app_type, provider_id))
|
||||
}
|
||||
|
||||
fn response_id(body: &Value, field: &str) -> Option<String> {
|
||||
body.get(field)
|
||||
.and_then(Value::as_str)
|
||||
@@ -479,6 +489,25 @@ mod tests {
|
||||
.starts_with("session:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_apps_share_the_session_request_id_namespace() {
|
||||
let usage = TokenUsage {
|
||||
message_id: Some("msg_123".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for app_type in ["claude", "claude-desktop"] {
|
||||
assert_eq!(
|
||||
usage.dedup_request_id(dedup_scope_for_app(app_type, "provider-a")),
|
||||
"session:msg_123"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
usage.dedup_request_id(dedup_scope_for_app("codex", "provider-a")),
|
||||
"session:codex:provider-a:msg_123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_parsers_recover_ids_from_envelope_chunks() {
|
||||
let openai = vec![
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
|
||||
@@ -31,25 +31,46 @@ pub fn check_env_conflicts(app: &str) -> Result<Vec<EnvConflict>, String> {
|
||||
Ok(conflicts)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EnvKeyword {
|
||||
Exact(&'static str),
|
||||
Prefix(&'static str),
|
||||
}
|
||||
|
||||
/// Get relevant keywords for each app
|
||||
fn get_keywords_for_app(app: &str) -> Vec<&str> {
|
||||
fn get_keywords_for_app(app: &str) -> Vec<EnvKeyword> {
|
||||
match app.to_lowercase().as_str() {
|
||||
"claude" => vec!["ANTHROPIC"],
|
||||
"codex" => vec!["OPENAI"],
|
||||
"gemini" => vec!["GEMINI", "GOOGLE_GEMINI"],
|
||||
"claude" => vec![EnvKeyword::Prefix("ANTHROPIC")],
|
||||
"codex" => vec![EnvKeyword::Prefix("OPENAI")],
|
||||
"gemini" => vec![
|
||||
EnvKeyword::Prefix("GEMINI"),
|
||||
EnvKeyword::Prefix("GOOGLE_GEMINI"),
|
||||
],
|
||||
"grokbuild" | "grok" => vec![
|
||||
EnvKeyword::Exact("XAI_API_KEY"),
|
||||
EnvKeyword::Exact("GROK_DEFAULT_MODEL"),
|
||||
],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_env_keyword(name: &str, keywords: &[EnvKeyword]) -> bool {
|
||||
let upper_name = name.to_uppercase();
|
||||
keywords.iter().any(|keyword| match keyword {
|
||||
EnvKeyword::Exact(name) => upper_name == *name,
|
||||
EnvKeyword::Prefix(prefix) => upper_name.starts_with(prefix),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check system environment variables (Windows Registry or Unix env)
|
||||
#[cfg(target_os = "windows")]
|
||||
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
// Check HKEY_CURRENT_USER\Environment
|
||||
if let Ok(hkcu) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment") {
|
||||
for (name, value) in hkcu.enum_values().filter_map(Result::ok) {
|
||||
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
|
||||
if matches_env_keyword(&name, keywords) {
|
||||
conflicts.push(EnvConflict {
|
||||
var_name: name.clone(),
|
||||
var_value: value.to_string(),
|
||||
@@ -65,7 +86,7 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
.open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment")
|
||||
{
|
||||
for (name, value) in hklm.enum_values().filter_map(Result::ok) {
|
||||
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
|
||||
if matches_env_keyword(&name, keywords) {
|
||||
conflicts.push(EnvConflict {
|
||||
var_name: name.clone(),
|
||||
var_value: value.to_string(),
|
||||
@@ -80,12 +101,12 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
// Check current process environment
|
||||
for (key, value) in std::env::vars() {
|
||||
if keywords.iter().any(|k| key.to_uppercase().contains(k)) {
|
||||
if matches_env_keyword(&key, keywords) {
|
||||
conflicts.push(EnvConflict {
|
||||
var_name: key,
|
||||
var_value: value,
|
||||
@@ -100,7 +121,7 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
|
||||
/// Check shell configuration files for environment variable exports (Unix only)
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
fn check_shell_configs(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
@@ -131,7 +152,7 @@ fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
let var_value = export_line[eq_pos + 1..].trim();
|
||||
|
||||
// Check if variable name contains any keyword
|
||||
if keywords.iter().any(|k| var_name.to_uppercase().contains(k)) {
|
||||
if matches_env_keyword(var_name, keywords) {
|
||||
conflicts.push(EnvConflict {
|
||||
var_name: var_name.to_string(),
|
||||
var_value: var_value
|
||||
@@ -157,12 +178,58 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_keywords() {
|
||||
assert_eq!(get_keywords_for_app("claude"), vec!["ANTHROPIC"]);
|
||||
assert_eq!(get_keywords_for_app("codex"), vec!["OPENAI"]);
|
||||
assert_eq!(
|
||||
get_keywords_for_app("claude"),
|
||||
vec![EnvKeyword::Prefix("ANTHROPIC")]
|
||||
);
|
||||
assert_eq!(
|
||||
get_keywords_for_app("codex"),
|
||||
vec![EnvKeyword::Prefix("OPENAI")]
|
||||
);
|
||||
assert_eq!(
|
||||
get_keywords_for_app("gemini"),
|
||||
vec!["GEMINI", "GOOGLE_GEMINI"]
|
||||
vec![
|
||||
EnvKeyword::Prefix("GEMINI"),
|
||||
EnvKeyword::Prefix("GOOGLE_GEMINI")
|
||||
]
|
||||
);
|
||||
assert_eq!(get_keywords_for_app("unknown"), Vec::<&str>::new());
|
||||
assert_eq!(
|
||||
get_keywords_for_app("grokbuild"),
|
||||
vec![
|
||||
EnvKeyword::Exact("XAI_API_KEY"),
|
||||
EnvKeyword::Exact("GROK_DEFAULT_MODEL")
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
get_keywords_for_app("grok"),
|
||||
get_keywords_for_app("grokbuild")
|
||||
);
|
||||
assert_eq!(get_keywords_for_app("unknown"), Vec::<EnvKeyword>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_keywords_only_match_credentials() {
|
||||
let keywords = get_keywords_for_app("grokbuild");
|
||||
|
||||
assert!(matches_env_keyword("XAI_API_KEY", &keywords));
|
||||
assert!(matches_env_keyword("xai_api_key", &keywords));
|
||||
assert!(matches_env_keyword("GROK_DEFAULT_MODEL", &keywords));
|
||||
assert!(matches_env_keyword("grok_default_model", &keywords));
|
||||
assert!(!matches_env_keyword("MY_XAI_API_KEY", &keywords));
|
||||
assert!(!matches_env_keyword("XAI_API_KEY_BACKUP", &keywords));
|
||||
assert!(!matches_env_keyword("MY_GROK_DEFAULT_MODEL", &keywords));
|
||||
assert!(!matches_env_keyword("GROK_DEFAULT_MODEL_BACKUP", &keywords));
|
||||
assert!(!matches_env_keyword("GROK_BIN_DIR", &keywords));
|
||||
assert!(!matches_env_keyword("GROK_HOME", &keywords));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broad_app_keywords_match_only_at_the_start() {
|
||||
let keywords = get_keywords_for_app("claude");
|
||||
|
||||
assert!(matches_env_keyword("ANTHROPIC_API_KEY", &keywords));
|
||||
assert!(matches_env_keyword("anthropic_base_url", &keywords));
|
||||
assert!(!matches_env_keyword("MY_ANTHROPIC_API_KEY", &keywords));
|
||||
assert!(!matches_env_keyword("NOT_ANTHROPIC", &keywords));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -0,0 +1,483 @@
|
||||
//! 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> {
|
||||
let _guard = lock_instruction_files()?;
|
||||
Self::list_at(&get_pi_agent_dir()?.join("prompts"))
|
||||
}
|
||||
|
||||
pub fn upsert(
|
||||
slug: &str,
|
||||
expected_revision: &str,
|
||||
content: &str,
|
||||
) -> Result<PiPromptTemplate, AppError> {
|
||||
let _guard = lock_instruction_files()?;
|
||||
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)?;
|
||||
let _guard = lock_instruction_files()?;
|
||||
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> {
|
||||
// Pinned Pi's request-capture discovers `release notes.md`, but
|
||||
// expandPromptTemplate("/release notes", ...) leaves the command
|
||||
// unchanged because slash-command names are one token. Keep the managed
|
||||
// namespace both callable and portable across Unix and Windows.
|
||||
let windows_basename = slug
|
||||
.split_once('.')
|
||||
.map_or(slug, |(basename, _extension)| basename);
|
||||
let windows_basename = windows_basename.to_ascii_lowercase();
|
||||
let windows_reserved = matches!(
|
||||
windows_basename.as_str(),
|
||||
"con"
|
||||
| "prn"
|
||||
| "aux"
|
||||
| "nul"
|
||||
| "com1"
|
||||
| "com2"
|
||||
| "com3"
|
||||
| "com4"
|
||||
| "com5"
|
||||
| "com6"
|
||||
| "com7"
|
||||
| "com8"
|
||||
| "com9"
|
||||
| "lpt1"
|
||||
| "lpt2"
|
||||
| "lpt3"
|
||||
| "lpt4"
|
||||
| "lpt5"
|
||||
| "lpt6"
|
||||
| "lpt7"
|
||||
| "lpt8"
|
||||
| "lpt9"
|
||||
);
|
||||
let valid = !slug.is_empty()
|
||||
&& slug.len() <= MAX_TEMPLATE_SLUG_BYTES
|
||||
&& slug != "."
|
||||
&& slug != ".."
|
||||
&& !slug.starts_with('.')
|
||||
&& !slug.ends_with('.')
|
||||
&& !windows_reserved
|
||||
&& !slug.chars().any(|character| {
|
||||
character.is_control()
|
||||
|| character.is_whitespace()
|
||||
|| matches!(
|
||||
character,
|
||||
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
|
||||
)
|
||||
});
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::InvalidInput(
|
||||
"Pi prompt-template slug must be one portable slash-command token (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",
|
||||
"internal space",
|
||||
"tab\tname",
|
||||
"a/b",
|
||||
r"a\b",
|
||||
"bad:name",
|
||||
"bad*name",
|
||||
"CON",
|
||||
"con.anything",
|
||||
"LPT9",
|
||||
"nul.json",
|
||||
] {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -459,7 +459,7 @@ impl ProfileService {
|
||||
.set_current_profile_id(scope.as_str(), Some(profile_id))?;
|
||||
|
||||
// 当前分组内所有接管已关闭;若其它应用也无接管,可停止代理服务。
|
||||
let should_stop_proxy = !state.db.is_live_takeover_active_sync();
|
||||
let should_stop_proxy = !state.proxy_service.shared_listener_is_desired_sync();
|
||||
|
||||
Ok((warnings, should_stop_proxy))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user