mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 21:54:58 +08:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 831d2c962c | |||
| 75b4ef2299 | |||
| fab9874b2c | |||
| 84668e2307 | |||
| b4033fdd29 | |||
| 68da178811 | |||
| 471c0d9990 | |||
| a9c36381fc | |||
| 3b3d1cd0ba | |||
| 5c32ec58be | |||
| 625dea9c62 | |||
| 333b82b4c4 | |||
| 92f62e884c | |||
| 44096b0e44 | |||
| 99ef870077 | |||
| 82438decf6 | |||
| e5838e0a10 | |||
| cc15d7b1e3 | |||
| 3fa3558bd4 | |||
| dd971246be | |||
| 55509286eb | |||
| 5014c2a744 | |||
| 9084e1ecc8 | |||
| 02669cfbac | |||
| 9092e97bc2 | |||
| 032a8203fd | |||
| 6d078e7f33 | |||
| fc6f2af4c6 | |||
| c54515742f | |||
| 7dbceeafe6 | |||
| 31bfecde39 | |||
| 41c3f845cb | |||
| c0737f2cfe | |||
| bf40b0138c | |||
| 3f711d6504 | |||
| 8c3f18a9bd | |||
| e18db31752 | |||
| c0fe0d6e07 | |||
| 7e6f803035 | |||
| b4fdd5fc0d | |||
| 1573474e3a | |||
| 50a2bd29e6 | |||
| 11f70f676e | |||
| a30e2096bb | |||
| fb8996d19c | |||
| 33d5f6985d | |||
| 95a74020e1 | |||
| 078a81b867 | |||
| a89f433dde | |||
| 8217bfff50 | |||
| 788c221d39 | |||
| a86f22422b | |||
| 6427ab2128 | |||
| 87b08ce242 |
@@ -24,3 +24,4 @@ flatpak/cc-switch.deb
|
||||
flatpak-build/
|
||||
flatpak-repo/
|
||||
.worktrees/
|
||||
.spec-workflow/
|
||||
+121
@@ -9,6 +9,127 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [3.12.0] - 2026-03-09
|
||||
|
||||
### Feature Release
|
||||
|
||||
This release restores the **Model Health Check (Stream Check)** UI, adds **OpenAI Responses API** format conversion, introduces the **Bedrock Optimizer** for thinking + cache injection, expands provider presets (Ucloud, Micu, X-Code API, Novita, Bailian For Coding), overhauls **OpenClaw config panels** with a JSON5 round-trip write engine, enhances **WebDAV sync** with dual-layer versioning, and delivers a comprehensive **i18n audit** fixing 69 missing keys alongside 20+ bug fixes.
|
||||
|
||||
**Stats**: 56 commits | 221 files changed | +20,582 insertions | -8,026 deletions
|
||||
|
||||
### Added
|
||||
|
||||
#### Stream Check (Model Health Check)
|
||||
|
||||
- **Restore Stream Check UI**: Brought back the model health check (Stream Check) panel for testing provider endpoint availability with live streaming validation
|
||||
- **First-Run Confirmation**: Added a confirmation dialog on first use of Stream Check to inform users about the feature's purpose and network requests
|
||||
- **OpenAI Chat Format Support**: Stream Check now supports `openai_chat` api_format, enabling health checks for providers using OpenAI-compatible endpoints
|
||||
|
||||
#### OpenAI Responses API
|
||||
|
||||
- **Responses API Format Conversion**: New `api_format = "openai_responses"` option enabling Anthropic Messages ↔ OpenAI Responses API bidirectional conversion for providers that implement the Responses API
|
||||
- **Responses API Deduplication**: Deduplicated and improved the Responses API conversion logic, consolidating shared transformation code
|
||||
|
||||
#### Bedrock Optimizer
|
||||
|
||||
- **Bedrock Request Optimizer**: PRE-SEND optimizer that injects thinking parameters and cache control blocks into AWS Bedrock requests, enabling extended thinking and prompt caching on Bedrock endpoints (#1301)
|
||||
|
||||
#### OpenClaw Enhancements
|
||||
|
||||
- **JSON5 Round-Trip Write Engine**: Overhauled OpenClaw config panels with a JSON5 round-trip write engine that preserves comments, formatting, and ordering when saving configuration changes
|
||||
- **Config Panel Improvements**: Redesigned EnvPanel as a full JSON editor, added `tools.profile` selection to ToolsPanel, introduced OpenClawHealthBanner for config validation warnings, and added legacy timeout migration support in Agents Defaults
|
||||
- **Agent Model Dropdown**: Replaced text inputs with dropdown selects for OpenClaw agent model configuration, offering a curated list of available models
|
||||
- **User-Agent Toggle**: Added a User-Agent header toggle for OpenClaw, defaulting to off to avoid potential compatibility issues with certain providers
|
||||
|
||||
#### Provider Presets
|
||||
|
||||
- **Ucloud**: Added Ucloud partner provider preset for Claude, Codex, and OpenClaw with endpointCandidates, unified apiKeyUrl, refreshed model defaults, and OpenClaw `templateValues` / `suggestedDefaults`
|
||||
- **Micu**: Added Micu partner provider preset for Claude, Codex, OpenClaw, and OpenCode with OpenClaw `templateValues` / `suggestedDefaults`
|
||||
- **X-Code API**: Added X-Code API partner provider preset for Claude, Codex, and OpenCode with endpointCandidates
|
||||
- **Novita**: Added Novita provider presets and icon across all supported apps (#1192)
|
||||
- **Bailian For Coding**: Added Bailian For Coding preset configuration (#1263)
|
||||
- **SiliconFlow Partner Badge**: Added partner badge designation for SiliconFlow provider presets
|
||||
- **Model Role Badges**: Added model role badges (e.g., Opus, Sonnet) to provider presets and reordered presets to prioritize Opus models
|
||||
|
||||
#### WebDAV Sync
|
||||
|
||||
- **Dual-Layer Versioning**: Added protocol v2 + db-v6 dual-layer versioning to WebDAV sync, enabling backward-compatible sync format evolution and automatic migration detection
|
||||
- **Auto-Sync Confirmation**: Added a confirmation dialog when toggling WebDAV auto-sync on/off to prevent accidental changes
|
||||
|
||||
#### Usage & Data
|
||||
|
||||
- **Daily Rollups & Auto-Vacuum**: Added usage daily rollups for aggregated statistics, incremental auto-vacuum for storage management, and sync-aware backup that coordinates with WebDAV sync cycles
|
||||
- **UsageFooter Extra Fields**: Added extra field display in UsageFooter component for normal mode, showing additional usage metadata (#1137)
|
||||
|
||||
#### Session Management
|
||||
|
||||
- **Session Deletion**: Added session deletion with per-provider cleanup and path safety validation, allowing users to remove individual conversation sessions
|
||||
|
||||
#### UI & Config
|
||||
|
||||
- **Auth Field Selector**: Restored Claude provider auth field selector supporting both AUTH_TOKEN and API_KEY authentication modes
|
||||
- **Failover Toggle**: Moved failover toggle to display independently on the main page with a confirmation dialog for enabling/disabling
|
||||
- **Common Config Auto-Extract**: Auto-extract Common Config Snippets from live configuration files on first run, seeding initial common config without manual setup
|
||||
- **New Provider Page Improvements**: Improved the new provider page with API endpoint and model name fields (#1155)
|
||||
|
||||
### Changed
|
||||
|
||||
#### Architecture
|
||||
|
||||
- **Common Config Runtime Overlay**: Common Config is now applied as a runtime overlay during provider switching instead of being materialized (merged) into each provider's stored config. This preserves the original provider config in the database and applies common settings dynamically at write time
|
||||
- **First-Run Auto-Extract**: On first run, Common Config Snippets are automatically extracted from the current live configuration files, eliminating the need for manual initial setup
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Proxy & Streaming
|
||||
|
||||
- **OpenAI Streaming Conversion**: Fixed OpenAI ChatCompletion → Anthropic Messages streaming conversion that could produce malformed events under certain response structures
|
||||
- **Codex /responses/compact Route**: Added support for Codex `/responses/compact` route in proxy forwarding (#1194)
|
||||
- **Codex Common Config TOML Merge**: Fixed Codex Common Config to use structural TOML merge/subset instead of raw string comparison, correctly handling key ordering and formatting differences
|
||||
- **Proxy Forwarder Failure Logs**: Improved proxy forwarder failure logging with more descriptive error messages
|
||||
|
||||
#### Provider & Preset
|
||||
|
||||
- **X-Code Rename**: Renamed "X-Code" provider to "X-Code API" for consistency with the official branding
|
||||
- **SSSAiCode Missing /v1**: Added missing `/v1` path to SSSAiCode default endpoint for Codex and OpenCode
|
||||
- **AICoding URL Fix**: Removed `www` prefix from aicoding.sh provider URLs to match the correct domain
|
||||
- **New Provider Page Input Handling**: Fixed the new provider page so API endpoint / model fields handle line-break deletion correctly and added the missing `codexConfig.modelNameHint` i18n key for zh/en/ja
|
||||
|
||||
#### Platform
|
||||
|
||||
- **Cache Hit Token Statistics**: Fixed missing token statistics for cache hits in streaming responses (#1244)
|
||||
- **Minimize-to-Tray Auto Exit**: Fixed issue where the application would automatically exit after being minimized to the system tray for a period of time (#1245)
|
||||
|
||||
#### i18n & Localization
|
||||
|
||||
- **Comprehensive i18n Audit**: Added 69 missing i18n keys and fixed hardcoded Chinese strings across the application, improving localization coverage for all three languages (zh/en/ja)
|
||||
- **Model Test Panel i18n**: Corrected i18n key paths for model test panel title and description
|
||||
- **JSON5 Slash Escaping**: Normalized JSON5 slash escaping and added i18n support for OpenClaw panel labels
|
||||
|
||||
#### UI
|
||||
|
||||
- **Skills Count Display**: Fixed skills count not displaying correctly when adding new skills (#1295)
|
||||
- **Endpoint Speed Test**: Removed HTTP status code display from endpoint speed test results to reduce visual noise
|
||||
- **Outline Button Text Tone**: Aligned outline button text color tone with usage refresh control for visual consistency (#1222)
|
||||
|
||||
### Performance
|
||||
|
||||
- **OpenClaw Config Write Skip**: Skip backup and atomic write when OpenClaw configuration content is unchanged, avoiding unnecessary I/O operations
|
||||
|
||||
### Documentation
|
||||
|
||||
- **User Manual i18n**: Restructured user manual for internationalization and added complete EN/JA translations alongside the existing ZH documentation
|
||||
- **User Manual OpenClaw**: Added OpenClaw coverage and completed settings documentation for the user manual
|
||||
- **UCloud CompShare Sponsor**: Added UCloud CompShare as a sponsor partner
|
||||
- **Docs Directory Reorganization**: Reorganized docs directory structure, added user manual links to all three README files, removed cross-language links from user manual sections, and synced README features across EN/ZH/JA
|
||||
|
||||
### Maintenance
|
||||
|
||||
- **Periodic Maintenance Timer**: Consolidated periodic maintenance timers into a unified scheduler, combining vacuum and rollup operations into a single timer
|
||||
- **OpenClaw Save Toast**: Removed backup path display from OpenClaw save toasts for cleaner notification messages
|
||||
|
||||
---
|
||||
|
||||
## [3.11.1] - 2026-02-28
|
||||
|
||||
### Hotfix Release
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode & OpenClaw
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -56,7 +56,7 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Ucloud" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Ucloud" width="150"></a></td>
|
||||
<td>Thanks to UCloud CompShare for sponsoring this project! CompShare is UCloud's AI cloud platform, providing stable and comprehensive domestic and international model APIs with just one key. Featuring cost-effective monthly and pay-as-you-go Coding Plan packages at 60-80% off official prices. Supports Claude Code, Codex, and API access. Enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing. Users who register via <a href="https://www.compshare.cn/?ytag=GPU_YY_YX_git_cc-switch">this link</a> will receive a free 5 CNY platform trial credit!</td>
|
||||
</tr>
|
||||
|
||||
@@ -106,7 +106,7 @@ Modern AI-powered coding relies on CLI tools like Claude Code, Codex, Gemini CLI
|
||||
|
||||
## Features
|
||||
|
||||
[Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.11.1-en.md)
|
||||
[Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.12.0-en.md)
|
||||
|
||||
### Provider Management
|
||||
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw のオールインワン管理ツール
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -56,7 +56,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Ucloud" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Ucloud" width="150"></a></td>
|
||||
<td>UCloud CompShare のご支援に感謝します!CompShare は UCloud 傘下の AI クラウドプラットフォームで、国内外の安定した包括的なモデル API を 1 つのキーだけで利用可能。月額・従量課金のコストパフォーマンスに優れた Coding Plan パッケージを提供し、公式価格の 60〜80% オフで利用できます。Claude Code、Codex および API アクセスに対応。エンタープライズ級の高同時接続、24 時間年中無休のテクニカルサポート、セルフサービス請求書発行に対応。<a href="https://www.compshare.cn/?ytag=GPU_YY_YX_git_cc-switch">こちらのリンク</a>から登録すると、無料で 5 元分のプラットフォーム体験クレジットがもらえます!</td>
|
||||
</tr>
|
||||
|
||||
@@ -106,7 +106,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
|
||||
## 特長
|
||||
|
||||
[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.11.1-ja.md)
|
||||
[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.12.0-ja.md)
|
||||
|
||||
### プロバイダ管理
|
||||
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
### Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw 的全方位管理工具
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -57,7 +57,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Ucloud" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Ucloud" width="150"></a></td>
|
||||
<td>感谢优云智算赞助了本项目!优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按量的高性价比 Coding Plan 套餐,基于官方2~5折优惠。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过<a href="https://www.compshare.cn/?ytag=GPU_YY_YX_git_cc-switch">此链接</a>注册的用户,可得免费5元平台体验金!</td>
|
||||
</tr>
|
||||
|
||||
@@ -107,7 +107,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
|
||||
## 功能特性
|
||||
|
||||
[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.11.1-zh.md)
|
||||
[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.12.0-zh.md)
|
||||
|
||||
### 供应商管理
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# CC Switch v3.12.0
|
||||
|
||||
> Stream Check Returns, OpenAI Responses API Arrives, and OpenClaw / WebDAV Get a Major Upgrade
|
||||
|
||||
**[中文版 →](v3.12.0-zh.md) | [日本語版 →](v3.12.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.12.0 is a feature release focused on provider compatibility, OpenClaw editing, Common Config usability, and sync/data reliability. It restores the **Model Health Check (Stream Check)** UI with improved stability, adds **OpenAI Responses API** format conversion, expands provider presets for **Ucloud**, **Micu**, **X-Code API**, **Novita**, and **Bailian For Coding**, and upgrades **WebDAV sync** with dual-layer versioning.
|
||||
|
||||
**Release Date**: 2026-03-09
|
||||
|
||||
**Update Scale**: 56 commits | 221 files changed | +20,582 / -8,026 lines
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Stream Check returns**: Restored the model health check UI, added first-run confirmation, and fixed `openai_chat` provider support
|
||||
- **OpenAI Responses API**: Added `api_format = "openai_responses"` with bidirectional conversion and shared conversion cleanup — simply select the Responses API format when adding a provider and enable proxy takeover, and you can use GPT-series models in Claude Code!
|
||||
- **OpenClaw overhaul**: Introduced JSON5 round-trip config editing, a config health banner, better agent model selection, and a User-Agent toggle
|
||||
- **Preset expansion**: Added Ucloud, Micu, X-Code API, Novita, and Bailian For Coding updates, plus SiliconFlow partner badge and model-role badges
|
||||
- **Sync and maintenance improvements**: Added WebDAV protocol v2 + db-v6 versioning, daily rollups, incremental auto-vacuum, and sync-aware backup
|
||||
- **Common Config usability improvements**: After updating a Common Config Snippet, it is now automatically applied when switching providers — no more manual checkbox needed
|
||||
|
||||
---
|
||||
|
||||
## Main Features
|
||||
|
||||
### Model Health Check (Stream Check)
|
||||
|
||||
Restored the Stream Check panel for live provider validation, improving the reliability of provider management.
|
||||
|
||||
- Restored Stream Check UI panel with single and batch provider availability testing
|
||||
- Added first-run confirmation dialog to prevent unsupported providers from showing misleading errors
|
||||
- Fixed detection compatibility for `openai_chat` API format providers
|
||||
|
||||
### OpenAI Responses API
|
||||
|
||||
Added native support for providers using the OpenAI Responses API with a new `openai_responses` API format.
|
||||
|
||||
- New `api_format = "openai_responses"` provider format option
|
||||
- Bidirectional Anthropic Messages <-> OpenAI Responses API format conversion
|
||||
- Consolidated shared conversion logic to reduce code duplication
|
||||
|
||||
### Bedrock Request Optimizer
|
||||
|
||||
Added a PRE-SEND phase request optimizer for AWS Bedrock providers to improve compatibility and performance.
|
||||
|
||||
- PRE-SEND thinking + cache injection optimizer (#1301, thanks @keithyt06)
|
||||
|
||||
### OpenClaw Config Enhancements
|
||||
|
||||
Comprehensive upgrade to the OpenClaw configuration editing experience with richer management capabilities.
|
||||
|
||||
- JSON5 round-trip write-back: preserves comments and formatting when editing configs
|
||||
- EnvPanel JSON editing mode and `tools.profile` selection support
|
||||
- New config validation warnings and config health status checks
|
||||
- Improved agent model dropdown with recommended model fill from provider presets
|
||||
- User-Agent toggle: optionally append OpenClaw identifier to requests (defaults to off)
|
||||
- Legacy timeout configuration auto-migration
|
||||
|
||||
### Provider Presets
|
||||
|
||||
New and expanded provider presets covering more providers and use cases.
|
||||
|
||||
- **Ucloud**: Added `endpointCandidates` and OpenClaw defaults, refreshed `templateValues` / `suggestedDefaults`
|
||||
- **Micu**: Added preset defaults and OpenClaw recommended models
|
||||
- **X-Code API**: Added Claude presets and `endpointCandidates`
|
||||
- **Novita**: New provider preset (#1192, thanks @Alex-wuhu)
|
||||
- **Bailian For Coding**: New provider preset (#1263, thanks @suki135246)
|
||||
- **SiliconFlow**: Added partner badge
|
||||
- **Model Role Badges**: Provider presets now support model-role badge display
|
||||
|
||||
### WebDAV Sync Enhancements
|
||||
|
||||
WebDAV sync introduces dual-layer versioning for improved sync reliability and data safety.
|
||||
|
||||
- New WebDAV protocol v2 + db-v6 dual-layer versioning
|
||||
- Confirmation dialog when toggling WebDAV auto-sync on/off to prevent accidental changes
|
||||
- Sync-aware backup: uses a sync-specific backup variant that skips local-only table data
|
||||
|
||||
### Usage & Data
|
||||
|
||||
Enhanced usage statistics and data maintenance capabilities for finer-grained data management, significantly reducing database growth rate.
|
||||
|
||||
- Daily rollups: aggregate usage data by day to reduce storage overhead
|
||||
- Auto-vacuum: incremental database cleanup to maintain database health
|
||||
- UsageFooter extra statistics fields (#1137, thanks @bugparty)
|
||||
|
||||
### Other New Features
|
||||
|
||||
- **Session Deletion**: Per-provider session cleanup with path safety validation
|
||||
- **Claude Auth Field Selector**: Restored authentication field selector
|
||||
- **Failover Toggle on Main Page**: Moved the failover toggle to display independently on the main page with a first-use confirmation dialog
|
||||
- **Common Config Auto-Extract**: On first run, automatically extracts common config snippets from live config files
|
||||
- **New Provider Page Improvements**: Improved new provider page experience (#1155, thanks @wugeer)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Improvements
|
||||
|
||||
### Common Config Runtime Overlay
|
||||
|
||||
Common Config Snippets are now applied as a runtime overlay instead of being materialized into stored provider configs.
|
||||
|
||||
**Before**: Common Config content was merged directly into each provider's `settings_config` on save or switch. This caused shared configuration to be duplicated across every provider entry, requiring manual sync when changes were needed.
|
||||
|
||||
**After**: Common Config is only injected as a runtime overlay when switching providers and writing to the live file — provider entries themselves no longer contain shared configuration. This means modifying Common Config takes effect immediately without updating each provider individually.
|
||||
|
||||
### Common Config Auto-Extract
|
||||
|
||||
On first run, if no Common Config Snippet exists in the database, one is automatically extracted from the current live config. This ensures users upgrading from older versions do not lose their existing shared configuration settings.
|
||||
|
||||
### Periodic Maintenance Timer Consolidation
|
||||
|
||||
Consolidated daily rollups and auto-vacuum into a unified periodic maintenance timer, eliminating resource contention and complexity from multiple independent timers.
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Proxy & Streaming
|
||||
|
||||
- Fixed OpenAI ChatCompletion -> Anthropic Messages streaming conversion
|
||||
- Added Codex `/responses/compact` route support (#1194, thanks @Tsukumi233)
|
||||
- Improved TOML config merge logic to prevent key-value loss
|
||||
- Improved proxy forwarder failure logs with additional diagnostic information
|
||||
|
||||
### Provider & Preset Fixes
|
||||
|
||||
- Renamed X-Code to X-Code API for consistent branding
|
||||
- Fixed SSSAiCode `/v1` path issue
|
||||
- Removed incorrect `www` prefix from AICoding URLs
|
||||
- Fixed new provider page line-break deletion issue (#1155, thanks @wugeer)
|
||||
|
||||
### Platform Fixes
|
||||
|
||||
- Fixed cache hit token statistics not being reported (#1244, thanks @a1398394385)
|
||||
- Fixed minimize-to-tray causing auto exit after some time (#1245, thanks @YewFence)
|
||||
|
||||
### i18n Fixes
|
||||
|
||||
- Added 69 missing translation keys and removed remaining hardcoded Chinese strings
|
||||
- Fixed model test panel i18n issues
|
||||
- Normalized JSON5 slash escaping to prevent i18n string parsing errors
|
||||
|
||||
### UI Fixes
|
||||
|
||||
- Fixed Skills count display (#1295, thanks @fzzv)
|
||||
- Removed HTTP status code display from endpoint speed test to reduce visual noise
|
||||
- Fixed outline button styling (#1222, thanks @Sube-py)
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
- Skip unnecessary OpenClaw config writes when config is unchanged, reducing disk I/O
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- Restructured the user manual for i18n and added complete EN/JA coverage
|
||||
- Added OpenClaw usage documentation and completed settings documentation
|
||||
- Added UCloud sponsor information
|
||||
- Reorganized the docs directory and synced README feature sections across EN/ZH/JA
|
||||
|
||||
---
|
||||
|
||||
## Notes & Considerations
|
||||
|
||||
- **Common Config now uses runtime overlay**: Common Config Snippets are no longer materialized into each provider's stored config. They are dynamically applied at switch time. Modifying Common Config takes effect immediately without updating each provider.
|
||||
- **Stream Check requires first-use confirmation**: A confirmation dialog appears when using the model health check for the first time. Testing proceeds only after confirmation.
|
||||
- **OpenClaw User-Agent toggle defaults to off**: The User-Agent identifier must be manually enabled in the OpenClaw configuration.
|
||||
|
||||
---
|
||||
|
||||
## Special Thanks
|
||||
|
||||
Thanks to all contributors for their contributions to this release!
|
||||
|
||||
@keithyt06 @bugparty @Alex-wuhu @suki135246 @Tsukumi233 @wugeer @fzzv @Sube-py @a1398394385 @YewFence
|
||||
|
||||
---
|
||||
|
||||
## Download & Installation
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
|
||||
|
||||
### System Requirements
|
||||
|
||||
| System | Minimum Version | Architecture |
|
||||
| ------- | ------------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 or later | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------------------- | ---------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.0-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||
| `CC-Switch-v3.12.0-Windows-Portable.zip` | Portable version, extract and run, no registry write |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------- | -------------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.0-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.12.0-macOS.tar.gz` | For Homebrew installation and auto-update |
|
||||
|
||||
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it, then go to "System Settings" -> "Privacy & Security" -> click "Open Anyway", and it will open normally afterwards.
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Update:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| Distribution | Recommended Format | Installation Method |
|
||||
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||
| 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` | Add execute permission and run directly, or use AUR |
|
||||
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,238 @@
|
||||
# CC Switch v3.12.0
|
||||
|
||||
> Stream Check が復活し、OpenAI Responses API に対応、OpenClaw と WebDAV も大幅強化
|
||||
|
||||
**[中文版 →](v3.12.0-zh.md) | [English →](v3.12.0-en.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.12.0 は、プロバイダー互換性、OpenClaw の設定編集、共通設定の使い勝手、同期とデータ保守性を強化する機能リリースです。安定性を強化した **Model Health Check (Stream Check)** UI を復元し、**OpenAI Responses API** 形式変換を追加、**Ucloud**、**Micu**、**X-Code API**、**Novita**、**Bailian For Coding** などのプリセットを拡張し、**WebDAV 同期** に二層バージョニングを導入しました。
|
||||
|
||||
**リリース日**: 2026-03-09
|
||||
|
||||
**更新規模**: 56 commits | 221 files changed | +20,582 / -8,026 lines
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- **Stream Check 復活**: モデルヘルスチェック UI を復元し、初回確認ダイアログを追加、`openai_chat` プロバイダー対応も修正
|
||||
- **OpenAI Responses API**: `api_format = "openai_responses"` を追加し、双方向変換と共有変換ロジックの整理を実施 — プロバイダー追加時に Responses API フォーマットを選択してプロキシテイクオーバーを有効にするだけで、Claude Code で GPT シリーズモデルが使えます!
|
||||
- **OpenClaw パネル強化**: JSON5 round-trip 編集、設定ヘルスバナー、改良された Agent Model 選択、User-Agent トグルを導入
|
||||
- **プリセット拡張**: Ucloud、Micu、X-Code API、Novita、Bailian For Coding を追加・更新し、SiliconFlow partner badge とモデルロールバッジも追加
|
||||
- **同期と保守の改善**: WebDAV protocol v2 + db-v6、daily rollups、incremental auto-vacuum、sync-aware backup を追加
|
||||
- **共通設定の使い勝手向上**: 共通設定スニペットを更新すると、プロバイダー切り替え時に自動的に反映されるようになりました。手動でチェックを入れ直す必要はありません
|
||||
|
||||
---
|
||||
|
||||
## 主な機能
|
||||
|
||||
### モデルヘルスチェック (Stream Check)
|
||||
|
||||
Stream Check パネルを復元し、プロバイダーの可用性をリアルタイムで検証できるようにしました。
|
||||
|
||||
- Stream Check UI パネルを復元し、単一またはバッチでのプロバイダー可用性検出をサポート
|
||||
- 初回使用確認ダイアログを追加、ヘルスチェック非対応プロバイダーの誤検出によるユーザー混乱を防止
|
||||
- `openai_chat` API フォーマットプロバイダーの検出互換性を修正
|
||||
|
||||
### OpenAI Responses API
|
||||
|
||||
新しい `openai_responses` API フォーマットを追加し、OpenAI Responses API を使用するプロバイダーのネイティブサポートを提供します。
|
||||
|
||||
- `api_format = "openai_responses"` プロバイダーフォーマットオプションを追加
|
||||
- Anthropic Messages <-> OpenAI Responses API の双方向フォーマット変換をサポート
|
||||
- 共有変換ロジックを整理し、重複コードを削減
|
||||
|
||||
### Bedrock リクエストオプティマイザー
|
||||
|
||||
AWS Bedrock プロバイダー向けに PRE-SEND フェーズのリクエスト最適化を追加し、互換性とパフォーマンスを向上させました。
|
||||
|
||||
- PRE-SEND thinking + cache injection オプティマイザー(#1301、@keithyt06 に感謝)
|
||||
|
||||
### OpenClaw 設定強化
|
||||
|
||||
OpenClaw の設定編集体験を全面的にアップグレードし、より豊富な設定管理をサポートします。
|
||||
|
||||
- JSON5 round-trip 書き戻し: 編集時にコメントとフォーマットを保持
|
||||
- EnvPanel の JSON 編集モードと `tools.profile` 選択をサポート
|
||||
- 設定検証バナーと設定ヘルスステータスチェックを追加
|
||||
- Agent モデルのドロップダウン改善、プロバイダープリセットから推奨モデルを自動入力
|
||||
- User-Agent トグル: リクエストに OpenClaw 識別子を付加する機能(デフォルトオフ)
|
||||
- Legacy timeout 設定の自動マイグレーション
|
||||
|
||||
### プロバイダープリセット
|
||||
|
||||
新規および既存のプロバイダープリセットを拡張し、より多くのプロバイダーとユースケースをカバーします。
|
||||
|
||||
- **Ucloud**: `endpointCandidates` および OpenClaw デフォルト値を追加、`templateValues` / `suggestedDefaults` を更新
|
||||
- **Micu**: プリセットデフォルト値および OpenClaw 推奨モデルを追加
|
||||
- **X-Code API**: Claude プリセットおよび `endpointCandidates` を追加
|
||||
- **Novita**: プロバイダープリセットを追加(#1192、@Alex-wuhu に感謝)
|
||||
- **Bailian For Coding**: プロバイダープリセットを追加(#1263、@suki135246 に感謝)
|
||||
- **SiliconFlow**: partner badge 識別を追加
|
||||
- **モデルロールバッジ**: プロバイダープリセットでモデルロール badge 表示をサポート
|
||||
|
||||
### WebDAV 同期強化
|
||||
|
||||
WebDAV 同期に二層バージョン管理を導入し、同期の信頼性とデータ安全性を向上させました。
|
||||
|
||||
- WebDAV protocol v2 + db-v6 二層バージョン管理を追加
|
||||
- WebDAV auto-sync の切り替え時に確認ダイアログを表示し、誤操作を防止
|
||||
- sync-aware backup: 同期時にローカル専用テーブルを除外した sync バリアントバックアップを使用
|
||||
|
||||
### 使用量とデータ
|
||||
|
||||
使用量統計とデータ保守機能を強化し、より精密なデータ管理を実現、データベースの増加速度を大幅に抑制します。
|
||||
|
||||
- Daily rollups: 日次で使用量データを集計し、ストレージ使用量を削減
|
||||
- Auto-vacuum: インクリメンタルなデータベースクリーンアップ、データベースの健全性を維持
|
||||
- UsageFooter に追加統計フィールドを追加(#1137、@bugparty に感謝)
|
||||
|
||||
### その他の新機能
|
||||
|
||||
- **セッション削除**: プロバイダー単位のクリーンアップとパス安全性検証付きのセッション削除
|
||||
- **Claude auth field selector 復元**: 認証フィールドセレクターを復元
|
||||
- **Failover トグルをメインページへ移動**: failover toggle を設定パネルからメインページに独立表示し、初回確認ダイアログを追加
|
||||
- **共通設定の自動抽出**: 初回起動時に live config から共通設定スニペットを自動抽出
|
||||
- **新規プロバイダーページの改善**: 新規プロバイダーページの体験を最適化(#1155、@wugeer に感謝)
|
||||
|
||||
---
|
||||
|
||||
## アーキテクチャ改善
|
||||
|
||||
### Common Config ランタイムオーバーレイ
|
||||
|
||||
共通設定スニペット(Common Config Snippet)をランタイムオーバーレイ方式に変更し、保存済みプロバイダー設定への物理マージを廃止しました。
|
||||
|
||||
**変更前**: Common Config の内容は保存時または切り替え時に各プロバイダーの `settings_config` に直接マージされていました。これにより共通設定が各プロバイダーエントリーにコピーされ、変更時には一つずつ同期する必要がありました。
|
||||
|
||||
**変更後**: Common Config はプロバイダー切り替え時に live ファイルへ書き込む際のみ runtime overlay として注入され、プロバイダーエントリー自体には共通設定を含みません。つまり Common Config の変更は即座に反映され、各プロバイダーを個別に更新する必要はありません。
|
||||
|
||||
### Common Config 初回自動抽出
|
||||
|
||||
初回起動時にデータベースに Common Config Snippet がまだ存在しない場合、現在の live config から自動抽出します。これにより旧バージョンからアップグレードしたユーザーの既存の共通設定が失われないことを保証します。
|
||||
|
||||
### 定期メンテナンスタイマー統合
|
||||
|
||||
daily rollups と auto-vacuum を統一の定期メンテナンスタイマーに統合し、複数の独立タイマーによるリソース競合と複雑さを回避しました。
|
||||
|
||||
---
|
||||
|
||||
## バグ修正
|
||||
|
||||
### プロキシとストリーミング
|
||||
|
||||
- OpenAI ChatCompletion -> Anthropic Messages のストリーミング変換問題を修正
|
||||
- Codex `/responses/compact` ルーティングをサポート(#1194、@Tsukumi233 に感謝)
|
||||
- TOML 設定マージロジックを改善し、キー値の欠落を回避
|
||||
- proxy forwarder の失敗ログを改善し、診断情報を追加
|
||||
|
||||
### プロバイダーとプリセットの修正
|
||||
|
||||
- X-Code を X-Code API にリネームし、ブランド名称を統一
|
||||
- SSSAiCode の `/v1` パス問題を修正
|
||||
- AICoding URL の誤った `www` プレフィックスを削除
|
||||
- 新規プロバイダーページの改行削除問題を修正(#1155、@wugeer に感謝)
|
||||
|
||||
### プラットフォーム修正
|
||||
|
||||
- cache hit token の統計欠落を修正(#1244、@a1398394385 に感謝)
|
||||
- 最小化後しばらくすると自動終了する問題を修正(#1245、@YewFence に感謝)
|
||||
|
||||
### i18n 修正
|
||||
|
||||
- 69 個の欠落翻訳キーを補完し、残りのハードコード中国語を除去
|
||||
- model test panel の i18n 問題を修正
|
||||
- JSON5 slash escaping を正規化し、国際化文字列の解析異常を回避
|
||||
|
||||
### UI 修正
|
||||
|
||||
- Skills カウント表示の問題を修正(#1295、@fzzv に感謝)
|
||||
- endpoint speed test から HTTP ステータスコード表示を削除し、視覚的ノイズを軽減
|
||||
- outline button のスタイル問題を修正(#1222、@Sube-py に感謝)
|
||||
|
||||
---
|
||||
|
||||
## パフォーマンス
|
||||
|
||||
- OpenClaw 設定が未変更の場合に不要な書き込みをスキップし、ディスク I/O を削減
|
||||
|
||||
---
|
||||
|
||||
## ドキュメント
|
||||
|
||||
- ユーザーマニュアルを i18n 対応で再構成し、EN/JA の内容を拡充
|
||||
- OpenClaw の説明を追加し、設定ドキュメントを補完
|
||||
- UCloud スポンサー情報を追加
|
||||
- docs ディレクトリを再編成し、EN/ZH/JA の README 機能説明を同期
|
||||
|
||||
---
|
||||
|
||||
## 注意事項
|
||||
|
||||
- **Common Config はランタイムオーバーレイに変更**: 共通設定スニペットは各プロバイダー設定への物理マージではなく、切り替え時に動的にオーバーレイされます。Common Config の変更は即座に反映され、各プロバイダーを個別に更新する必要はありません。
|
||||
- **Stream Check は初回使用時に確認が必要**: 初回使用時にモデルヘルスチェックの確認ダイアログが表示され、確認後に使用可能になります。
|
||||
- **OpenClaw の User-Agent トグルはデフォルトオフ**: OpenClaw 設定で User-Agent 識別子の付加機能を手動で有効にする必要があります。
|
||||
|
||||
---
|
||||
|
||||
## 謝辞
|
||||
|
||||
以下のコントリビューターの皆様、このリリースへの貢献に感謝します!
|
||||
|
||||
@keithyt06 @bugparty @Alex-wuhu @suki135246 @Tsukumi233 @wugeer @fzzv @Sube-py @a1398394385 @YewFence
|
||||
|
||||
---
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
|
||||
|
||||
### システム要件
|
||||
|
||||
| システム | 最小バージョン | アーキテクチャ |
|
||||
| -------- | -------------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表参照 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ---------------------------------------- | ---------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
|
||||
| `CC-Switch-v3.12.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| -------------------------------- | ----------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.0-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.12.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| 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,238 @@
|
||||
# CC Switch v3.12.0
|
||||
|
||||
> Stream Check 回归,OpenAI Responses API 上线,OpenClaw 与 WebDAV 迎来一次大升级
|
||||
|
||||
**[English →](v3.12.0-en.md) | [日本語版 →](v3.12.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.12.0 是一个功能版本,重点提升供应商兼容性、OpenClaw 配置编辑体验、通用配置功能使用体验,以及同步与数据维护能力。本次恢复了增强稳定性后的 **模型健康检查(Stream Check)** UI,新增 **OpenAI Responses API** 格式转换,扩展了 **Ucloud**、**Micu**、**X-Code API**、**Novita**、**Bailian For Coding** 等供应商预设,并为 **WebDAV 同步** 引入双层版本控制。
|
||||
|
||||
**发布日期**:2026-03-09
|
||||
|
||||
**更新规模**:56 commits | 221 files changed | +20,582 / -8,026 lines
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- **Stream Check 回归**:恢复模型健康检查 UI,新增首次使用确认,并修复 `openai_chat` 供应商检测
|
||||
- **OpenAI Responses API**:新增 `api_format = "openai_responses"`,支持双向格式转换并整理共享转换逻辑,只需要在添加供应商的时候选择 Response 接口格式并开启代理接管,您就可以在 Claude Code 中使用 gpt 系列模型了!
|
||||
- **OpenClaw 面板升级**:引入 JSON5 round-trip 配置编辑、配置健康提示、改进后的 Agent Model 选择和 User-Agent 开关
|
||||
- **预设扩展**:补充 Ucloud、Micu、X-Code API、Novita、Bailian For Coding 预设,并新增 SiliconFlow partner badge 与模型角色标识
|
||||
- **同步与维护增强**:新增 WebDAV protocol v2 + db-v6 双层版本、daily rollups、增量 auto-vacuum 和 sync-aware backup
|
||||
- **通用配置功能使用体验优化**:现在通用配置片段更新之后,会在切换供应商时自动同步到新的供应商,不需要再手动勾选。
|
||||
|
||||
---
|
||||
|
||||
## 主要功能
|
||||
|
||||
### 模型健康检查 Stream Check
|
||||
|
||||
恢复 Stream Check 面板,用于实时验证供应商可用性,增强供应商管理的可靠性。
|
||||
|
||||
- 恢复 Stream Check UI 面板,支持单个或批量检测供应商可用性
|
||||
- 新增首次使用确认对话框,避免不支持健康检查的供应商报错误导用户
|
||||
- 修复 `openai_chat` API 格式供应商的检测兼容性
|
||||
|
||||
### OpenAI Responses API
|
||||
|
||||
新增 `openai_responses` API 格式,为使用 OpenAI Responses API 的供应商提供原生支持。
|
||||
|
||||
- 新增 `api_format = "openai_responses"` 供应商格式选项
|
||||
- 支持 Anthropic Messages <-> OpenAI Responses API 双向格式转换
|
||||
- 整理共享转换逻辑,减少重复代码
|
||||
|
||||
### Bedrock 请求优化器
|
||||
|
||||
为 AWS Bedrock 供应商新增 PRE-SEND 阶段请求优化器,提升兼容性和性能。
|
||||
|
||||
- PRE-SEND thinking + cache injection 优化器(#1301,感谢 @keithyt06)
|
||||
|
||||
### OpenClaw 配置增强
|
||||
|
||||
OpenClaw 配置编辑体验全面升级,支持更丰富的配置管理。
|
||||
|
||||
- JSON5 round-trip 写回:编辑配置时保留注释和格式
|
||||
- EnvPanel 支持 JSON 编辑模式和 `tools.profile` 选择
|
||||
- 新增配置校验提示和配置健康状态检查
|
||||
- Agent 模型下拉框改进,支持从供应商预设填充推荐模型
|
||||
- User-Agent 开关:可选在请求中附加 User-Agent 标识(默认关闭)
|
||||
- Legacy timeout 配置自动迁移
|
||||
|
||||
### 供应商预设 Preset
|
||||
|
||||
新增和扩展多组供应商预设,覆盖更多供应商和使用场景。
|
||||
|
||||
- **Ucloud**:新增 `endpointCandidates` 以及 OpenClaw 默认值,刷新 `templateValues` / `suggestedDefaults`
|
||||
- **Micu**:新增预设默认值及 OpenClaw 推荐模型
|
||||
- **X-Code API**:新增 Claude 预设及 `endpointCandidates`
|
||||
- **Novita**:新增供应商预设(#1192,感谢 @Alex-wuhu)
|
||||
- **Bailian For Coding**:新增供应商预设(#1263,感谢 @suki135246)
|
||||
- **SiliconFlow**:新增 partner badge 标识
|
||||
- **模型角色标识**:供应商预设支持模型角色 badge 显示
|
||||
|
||||
### WebDAV 同步增强
|
||||
|
||||
WebDAV 同步引入双层版本控制,提升同步可靠性和数据安全性。
|
||||
|
||||
- 新增 WebDAV protocol v2 + db-v6 双层版本控制
|
||||
- 切换 WebDAV auto-sync 时弹出确认对话框,防止误操作
|
||||
- sync-aware backup:WebDAV 同步时使用 sync 变体备份,跳过仅本地使用的表数据
|
||||
|
||||
### 用量与数据
|
||||
|
||||
用量统计和数据维护能力增强,数据管理更精细,极大降低数据库增长速度。
|
||||
|
||||
- Daily rollups:按天汇总用量数据,减少存储占用
|
||||
- Auto-vacuum:增量式数据库清理,保持数据库健康
|
||||
- UsageFooter 新增额外统计字段(#1137,感谢 @bugparty)
|
||||
|
||||
### 其他新功能
|
||||
|
||||
- **会话删除**:按供应商清理会话记录,带路径安全校验
|
||||
- **Claude auth field selector 恢复**:恢复认证字段选择器
|
||||
- **Failover 开关独立显示**:将 failover toggle 从设置面板移到主页独立展示,并新增首次确认对话框
|
||||
- **通用配置自动抽取**:首次运行时自动从 live config 中抽取通用配置片段
|
||||
- **新供应商页面改进**:优化新建供应商页面体验(#1155,感谢 @wugeer)
|
||||
|
||||
---
|
||||
|
||||
## 架构改进
|
||||
|
||||
### Common Config 运行时叠加
|
||||
|
||||
通用配置片段(Common Config Snippet)改为运行时叠加方式应用,不再物化写入每个供应商配置。
|
||||
|
||||
**变更前**:Common Config 内容在保存或切换时直接合并写入每个供应商的 `settings_config`。这导致公共配置被复制到每个供应商条目中,修改时需要逐一同步。
|
||||
|
||||
**变更后**:Common Config 仅在切换供应商写入 live 文件时以 runtime overlay 方式注入,供应商条目本身不包含公共配置。这意味着修改 Common Config 后立即生效,无需逐一更新每个供应商。
|
||||
|
||||
### 通用配置首次自动抽取
|
||||
|
||||
首次运行时,如果数据库中尚无 Common Config Snippet,会自动从当前 live config 中抽取通用配置。这确保了从旧版本升级的用户不会丢失已有的通用配置设置。
|
||||
|
||||
### 定期维护定时器整合
|
||||
|
||||
将 daily rollups 和 auto-vacuum 整合到统一的定期维护定时器中,避免多个独立定时器带来的资源竞争和复杂度。
|
||||
|
||||
---
|
||||
|
||||
## Bug 修复
|
||||
|
||||
### 代理与流式转换
|
||||
|
||||
- 修复 OpenAI ChatCompletion -> Anthropic Messages 流式转换问题
|
||||
- 新增 Codex `/responses/compact` 路由支持(#1194,感谢 @Tsukumi233)
|
||||
- 改进 TOML 配置合并逻辑,避免键值丢失
|
||||
- 改进 proxy forwarder 失败日志,增加更多诊断信息
|
||||
|
||||
### 供应商预设修复
|
||||
|
||||
- X-Code 更名为 X-Code API,统一品牌命名
|
||||
- 修复 SSSAiCode `/v1` 路径问题
|
||||
- 移除 AICoding URL 错误的 `www` 前缀
|
||||
- 优化新建供应商页面换行删除问题(#1155,感谢 @wugeer)
|
||||
|
||||
### 平台修复
|
||||
|
||||
- 修复 cache hit token 统计缺失(#1244,感谢 @a1398394385)
|
||||
- 修复最小化到托盘后一段时间自动退出的问题(#1245,感谢 @YewFence)
|
||||
|
||||
### i18n 修复
|
||||
|
||||
- 补齐 69 个缺失翻译 key,清理剩余硬编码中文
|
||||
- 修复 model test panel 的 i18n 问题
|
||||
- 规范 JSON5 slash escaping,避免国际化字符串解析异常
|
||||
|
||||
### UI 修复
|
||||
|
||||
- 修复 Skills 计数显示问题(#1295,感谢 @fzzv)
|
||||
- 移除 endpoint speed test 的 HTTP 状态码显示,减少视觉噪音
|
||||
- 修复 outline button 样式问题(#1222,感谢 @Sube-py)
|
||||
|
||||
---
|
||||
|
||||
## 性能优化
|
||||
|
||||
- OpenClaw 配置未变化时跳过无意义写入,减少磁盘 I/O
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
- 重构用户手册以支持国际化,补齐 EN/JA 完整内容
|
||||
- 新增 OpenClaw 使用说明,补完设置章节
|
||||
- 新增 UCloud 赞助商信息
|
||||
- 重组 docs 目录结构,同步 EN/ZH/JA README 的功能说明
|
||||
|
||||
---
|
||||
|
||||
## 说明与注意事项
|
||||
|
||||
- **Common Config 改为运行时叠加**:通用配置片段不再物化写入每个供应商配置,而是在切换时动态叠加。修改 Common Config 后立即生效,无需逐一更新供应商。
|
||||
- **Stream Check 首次使用需确认**:首次使用模型健康检查时会弹出确认对话框,确认后方可使用。
|
||||
- **OpenClaw User-Agent 开关默认关闭**:需要在 OpenClaw 配置中手动开启 User-Agent 标识附加功能。
|
||||
|
||||
---
|
||||
|
||||
## 特别感谢
|
||||
|
||||
感谢以下贡献者为本版本做出的贡献!
|
||||
|
||||
@keithyt06 @bugparty @Alex-wuhu @suki135246 @Tsukumi233 @wugeer @fzzv @Sube-py @a1398394385 @YewFence
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | ----------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------------- | ----------------------------------- |
|
||||
| `CC-Switch-v3.12.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.12.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.0-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
|
||||
| `CC-Switch-v3.12.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||
|
||||
### Homebrew(macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| 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` |
|
||||
@@ -12,9 +12,9 @@
|
||||
|
||||
## Version / 版本 / バージョン
|
||||
|
||||
- Documentation version: v3.11.1
|
||||
- Last updated: 2026-03-02
|
||||
- Compatible with CC Switch v3.11.1+
|
||||
- Documentation version: v3.12.0
|
||||
- Last updated: 2026-03-09
|
||||
- Compatible with CC Switch v3.12.0+
|
||||
|
||||
## Links
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ Presets are pre-configured provider templates that only require an API Key to us
|
||||
| Bailian | Alibaba Cloud Bailian (Qwen) |
|
||||
| Kimi | Moonshot Kimi model |
|
||||
| Kimi For Coding | Kimi coding-specific model |
|
||||
| StepFun | StepFun model |
|
||||
| ModelScope | ModelScope community |
|
||||
| KAT-Coder | KAT-Coder model |
|
||||
| Longcat | Longcat AI |
|
||||
@@ -92,6 +93,7 @@ Presets are pre-configured provider templates that only require an API Key to us
|
||||
| Bailian | Alibaba Cloud Bailian |
|
||||
| Kimi k2.5 | Moonshot Kimi-k2.5 model |
|
||||
| Kimi For Coding | Kimi coding-specific model |
|
||||
| StepFun | StepFun model |
|
||||
| ModelScope | ModelScope community |
|
||||
| KAT-Coder | KAT-Coder model |
|
||||
| Longcat | Longcat AI |
|
||||
@@ -124,6 +126,7 @@ Presets are pre-configured provider templates that only require an API Key to us
|
||||
| Qwen Coder | Qwen coding model |
|
||||
| Kimi k2.5 | Moonshot Kimi-k2.5 model |
|
||||
| Kimi For Coding | Kimi coding-specific model |
|
||||
| StepFun | StepFun model |
|
||||
| MiniMax | MiniMax model |
|
||||
| MiniMax en | MiniMax (English version) |
|
||||
| KAT-Coder | KAT-Coder model |
|
||||
|
||||
@@ -238,10 +238,14 @@ CC Switch includes preset official prices for common models (per million tokens)
|
||||
| gemini-2.5-pro | $1.25 | $10 | $0.125 |
|
||||
| gemini-2.5-flash | $0.30 | $2.50 | $0.03 |
|
||||
|
||||
**Chinese Provider Models (CNY)**:
|
||||
**Chinese Provider Models**:
|
||||
|
||||
> Note: Currency follows each provider's official pricing page. StepFun is currently listed in USD.
|
||||
|
||||
| Model | Input | Output | Cache Read |
|
||||
|-------|-------|--------|------------|
|
||||
| **StepFun** | | | |
|
||||
| step-3.5-flash | $0.10 | $0.30 | $0.02 |
|
||||
| **DeepSeek** | | | |
|
||||
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
|
||||
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
|
||||
|
||||
@@ -99,9 +99,9 @@ CC Switch User Manual
|
||||
|
||||
## Version Information
|
||||
|
||||
- Documentation version: v3.11.1
|
||||
- Last updated: 2026-03-02
|
||||
- Applicable to CC Switch v3.11.1+
|
||||
- Documentation version: v3.12.0
|
||||
- Last updated: 2026-03-09
|
||||
- Applicable to CC Switch v3.12.0+
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
| 百炼 | アリクラウド百炼(通义千問) |
|
||||
| Kimi | Moonshot Kimi モデル |
|
||||
| Kimi For Coding | Kimi プログラミング専用モデル |
|
||||
| StepFun | StepFun モデル |
|
||||
| ModelScope | 魔搭コミュニティ |
|
||||
| KAT-Coder | KAT-Coder モデル |
|
||||
| Longcat | Longcat AI |
|
||||
@@ -92,6 +93,7 @@
|
||||
| 百炼 | アリクラウド百炼 |
|
||||
| Kimi k2.5 | Moonshot Kimi-k2.5 モデル |
|
||||
| Kimi For Coding | Kimi プログラミング専用モデル |
|
||||
| StepFun | StepFun モデル |
|
||||
| ModelScope | 魔搭コミュニティ |
|
||||
| KAT-Coder | KAT-Coder モデル |
|
||||
| Longcat | Longcat AI |
|
||||
@@ -124,6 +126,7 @@
|
||||
| Qwen Coder | 通义千問コーディングモデル |
|
||||
| Kimi k2.5 | Moonshot Kimi-k2.5 モデル |
|
||||
| Kimi For Coding | Kimi プログラミング専用モデル |
|
||||
| StepFun | StepFun モデル |
|
||||
| MiniMax | MiniMax モデル |
|
||||
| MiniMax en | MiniMax(英語版) |
|
||||
| KAT-Coder | KAT-Coder モデル |
|
||||
|
||||
@@ -238,10 +238,14 @@ CC Switch は一般的なモデルの公式価格(100 万 Token あたり)
|
||||
| gemini-2.5-pro | $1.25 | $10 | $0.125 |
|
||||
| gemini-2.5-flash | $0.30 | $2.50 | $0.03 |
|
||||
|
||||
**中国メーカーのモデル(人民元)**:
|
||||
**中国メーカーのモデル**:
|
||||
|
||||
> 注: 通貨は各プロバイダーの公式料金ページに従います。StepFun は現在 USD 表記です。
|
||||
|
||||
| モデル | 入力 | 出力 | キャッシュ読取 |
|
||||
|------|------|------|----------|
|
||||
| **StepFun** | | | |
|
||||
| step-3.5-flash | $0.10 | $0.30 | $0.02 |
|
||||
| **DeepSeek** | | | |
|
||||
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
|
||||
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
|
||||
|
||||
@@ -99,9 +99,9 @@ CC Switch ユーザーマニュアル
|
||||
|
||||
## バージョン情報
|
||||
|
||||
- ドキュメントバージョン:v3.11.1
|
||||
- 最終更新:2026-03-02
|
||||
- CC Switch v3.11.1+ 対応
|
||||
- ドキュメントバージョン:v3.12.0
|
||||
- 最終更新:2026-03-09
|
||||
- CC Switch v3.12.0+ 対応
|
||||
|
||||
## コントリビュート
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
| 百炼 | 阿里云百炼(通义千问) |
|
||||
| Kimi | Moonshot Kimi 模型 |
|
||||
| Kimi For Coding | Kimi 编程专用模型 |
|
||||
| StepFun | 阶跃星辰 Step模型 |
|
||||
| ModelScope | 魔搭社区 |
|
||||
| KAT-Coder | KAT-Coder 模型 |
|
||||
| Longcat | Longcat AI |
|
||||
@@ -92,6 +93,7 @@
|
||||
| 百炼 | 阿里云百炼 |
|
||||
| Kimi k2.5 | Moonshot Kimi-k2.5 模型 |
|
||||
| Kimi For Coding | Kimi 编程专用模型 |
|
||||
| StepFun | 阶跃星辰 Step模型 |
|
||||
| ModelScope | 魔搭社区 |
|
||||
| KAT-Coder | KAT-Coder 模型 |
|
||||
| Longcat | Longcat AI |
|
||||
@@ -124,6 +126,7 @@
|
||||
| Qwen Coder | 通义千问编码模型 |
|
||||
| Kimi k2.5 | Moonshot Kimi-k2.5 模型 |
|
||||
| Kimi For Coding | Kimi 编程专用模型 |
|
||||
| StepFun | 阶跃星辰 Step模型 |
|
||||
| MiniMax | MiniMax 模型 |
|
||||
| MiniMax en | MiniMax(英文版) |
|
||||
| KAT-Coder | KAT-Coder 模型 |
|
||||
@@ -352,4 +355,3 @@ CC Switch 支持两种方式导入供应商配置:
|
||||
- 🔴 红色:延迟 > 1000ms(较慢)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -238,10 +238,14 @@ CC Switch 预设了常用模型的官方价格(每百万 Token):
|
||||
| gemini-2.5-pro | $1.25 | $10 | $0.125 |
|
||||
| gemini-2.5-flash | $0.30 | $2.50 | $0.03 |
|
||||
|
||||
**中国厂商模型(人民币)**:
|
||||
**中国厂商模型**:
|
||||
|
||||
> 注:币种遵循各供应商官方定价页面。StepFun 当前按美元列出。
|
||||
|
||||
| 模型 | 输入 | 输出 | 缓存读取 |
|
||||
|------|------|------|----------|
|
||||
| **StepFun** | | | |
|
||||
| step-3.5-flash | $0.10 | $0.30 | $0.02 |
|
||||
| **DeepSeek** | | | |
|
||||
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
|
||||
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
|
||||
|
||||
@@ -99,9 +99,9 @@
|
||||
|
||||
## 版本信息
|
||||
|
||||
- 文档版本:v3.11.1
|
||||
- 最后更新:2026-02-28
|
||||
- 适用于 CC Switch v3.11.1+
|
||||
- 文档版本:v3.12.0
|
||||
- 最后更新:2026-03-09
|
||||
- 适用于 CC Switch v3.12.0+
|
||||
|
||||
## 贡献
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.11.1",
|
||||
"version": "3.12.0",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -6,7 +6,7 @@ const ICONS_TO_EXTRACT = {
|
||||
// AI 服务商(必需)
|
||||
aiProviders: [
|
||||
'openai', 'anthropic', 'claude', 'google', 'gemini',
|
||||
'deepseek', 'kimi', 'moonshot', 'zhipu', 'minimax',
|
||||
'deepseek', 'kimi', 'moonshot', 'stepfun', 'zhipu', 'minimax',
|
||||
'baidu', 'alibaba', 'tencent', 'meta', 'microsoft',
|
||||
'cohere', 'perplexity', 'mistral', 'huggingface'
|
||||
],
|
||||
@@ -60,6 +60,9 @@ ALL_ICONS.forEach(iconName => {
|
||||
fs.copyFileSync(sourceFile, targetFile);
|
||||
console.log(` ✓ ${iconName}.svg`);
|
||||
extracted++;
|
||||
} else if (fs.existsSync(targetFile)) {
|
||||
console.log(` ✓ ${iconName}.svg (kept local custom icon)`);
|
||||
extracted++;
|
||||
} else {
|
||||
console.log(` ✗ ${iconName}.svg (not found)`);
|
||||
notFound.push(iconName);
|
||||
@@ -110,6 +113,7 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
|
||||
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
|
||||
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
|
||||
stepfun: { name: 'stepfun', displayName: 'StepFun', category: 'ai-provider', keywords: ['stepfun', 'step', 'jieyue', '阶跃星辰'], defaultColor: '#005AFF' },
|
||||
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
|
||||
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
|
||||
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
|
||||
|
||||
@@ -10,7 +10,7 @@ const KEEP_LIST = [
|
||||
'openai', 'anthropic', 'claude', 'google', 'gemini', 'gemma', 'palm',
|
||||
'microsoft', 'azure', 'copilot', 'meta', 'llama',
|
||||
'alibaba', 'qwen', 'tencent', 'hunyuan', 'baidu', 'wenxin',
|
||||
'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi',
|
||||
'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi', 'stepfun',
|
||||
'zhipu', 'chatglm', 'glm', 'minimax', 'mistral', 'cohere',
|
||||
'perplexity', 'huggingface', 'midjourney', 'stability',
|
||||
'xai', 'grok', 'yi', 'zeroone', 'ollama',
|
||||
|
||||
@@ -15,6 +15,7 @@ const KNOWN_METADATA = {
|
||||
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
|
||||
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
|
||||
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
|
||||
stepfun: { name: 'stepfun', displayName: 'StepFun', category: 'ai-provider', keywords: ['stepfun', 'step', 'jieyue', '阶跃星辰'], defaultColor: '#005AFF' },
|
||||
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
|
||||
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
|
||||
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
|
||||
|
||||
Generated
+18
-1
@@ -701,7 +701,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.11.1"
|
||||
version = "3.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -714,6 +714,7 @@ dependencies = [
|
||||
"futures",
|
||||
"hyper",
|
||||
"indexmap 2.11.4",
|
||||
"json-five",
|
||||
"json5",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
@@ -2510,6 +2511,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "json-five"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "865f2d01a4549c1fd8c60640c03ae5249eb374cd8cde8b905628d4b1af95c87c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"unicode-general-category",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "json-patch"
|
||||
version = "3.0.1"
|
||||
@@ -6001,6 +6012,12 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-general-category"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.19"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.11.1"
|
||||
version = "3.12.0"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
@@ -13,6 +13,7 @@ rust-version = "1.85.0"
|
||||
[lib]
|
||||
name = "cc_switch_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
@@ -63,6 +64,7 @@ rust_decimal = "1.33"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
sha2 = "0.10"
|
||||
json5 = "0.4"
|
||||
json-five = "0.3.1"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
@@ -28,6 +28,39 @@ fn invalid_json_format_error(error: serde_json::Error) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_toml_format_error(error: toml_edit::TomlError) -> String {
|
||||
let lang = settings::get_settings()
|
||||
.language
|
||||
.unwrap_or_else(|| "zh".to_string());
|
||||
|
||||
match lang.as_str() {
|
||||
"en" => format!("Invalid TOML format: {error}"),
|
||||
"ja" => format!("TOML形式が無効です: {error}"),
|
||||
_ => format!("无效的 TOML 格式: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_common_config_snippet(app_type: &str, snippet: &str) -> Result<(), String> {
|
||||
if snippet.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match app_type {
|
||||
"claude" | "gemini" | "omo" | "omo-slim" => {
|
||||
serde_json::from_str::<serde_json::Value>(snippet)
|
||||
.map_err(invalid_json_format_error)?;
|
||||
}
|
||||
"codex" => {
|
||||
snippet
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(invalid_toml_format_error)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||
@@ -213,16 +246,12 @@ pub async fn set_common_config_snippet(
|
||||
snippet: String,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<(), String> {
|
||||
if !snippet.trim().is_empty() {
|
||||
match app_type.as_str() {
|
||||
"claude" | "gemini" | "omo" | "omo-slim" => {
|
||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||
.map_err(invalid_json_format_error)?;
|
||||
}
|
||||
"codex" => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let old_snippet = state
|
||||
.db
|
||||
.get_config_snippet(&app_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
validate_common_config_snippet(&app_type, &snippet)?;
|
||||
|
||||
let value = if snippet.trim().is_empty() {
|
||||
None
|
||||
@@ -230,11 +259,35 @@ pub async fn set_common_config_snippet(
|
||||
Some(snippet)
|
||||
};
|
||||
|
||||
if matches!(app_type.as_str(), "claude" | "codex" | "gemini") {
|
||||
if let Some(legacy_snippet) = old_snippet
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
let app = AppType::from_str(&app_type).map_err(|e| e.to_string())?;
|
||||
crate::services::provider::ProviderService::migrate_legacy_common_config_usage(
|
||||
state.inner(),
|
||||
app,
|
||||
legacy_snippet,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(&app_type, value)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if matches!(app_type.as_str(), "claude" | "codex" | "gemini") {
|
||||
let app = AppType::from_str(&app_type).map_err(|e| e.to_string())?;
|
||||
crate::services::provider::ProviderService::sync_current_provider_for_app(
|
||||
state.inner(),
|
||||
app,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
if app_type == "omo"
|
||||
&& state
|
||||
.db
|
||||
@@ -264,6 +317,27 @@ pub async fn set_common_config_snippet(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_common_config_snippet;
|
||||
|
||||
#[test]
|
||||
fn validate_common_config_snippet_accepts_comment_only_codex_snippet() {
|
||||
validate_common_config_snippet("codex", "# comment only\n")
|
||||
.expect("comment-only codex snippet should be valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_common_config_snippet_rejects_invalid_codex_snippet() {
|
||||
let err = validate_common_config_snippet("codex", "[broken")
|
||||
.expect_err("invalid codex snippet should be rejected");
|
||||
assert!(
|
||||
err.contains("TOML") || err.contains("toml") || err.contains("格式"),
|
||||
"expected TOML validation error, got {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn extract_common_config_snippet(
|
||||
appType: String,
|
||||
|
||||
@@ -26,6 +26,21 @@ pub fn get_openclaw_live_provider_ids() -> Result<Vec<String>, String> {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get a single OpenClaw provider fragment from live config.
|
||||
#[tauri::command]
|
||||
pub fn get_openclaw_live_provider(
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
openclaw_config::get_provider(&providerId).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Scan openclaw.json for known configuration hazards.
|
||||
#[tauri::command]
|
||||
pub fn scan_openclaw_config_health() -> Result<Vec<openclaw_config::OpenClawHealthWarning>, String>
|
||||
{
|
||||
openclaw_config::scan_openclaw_config_health().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agents Configuration Commands
|
||||
// ============================================================================
|
||||
@@ -41,7 +56,7 @@ pub fn get_openclaw_default_model() -> Result<Option<openclaw_config::OpenClawDe
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_default_model(
|
||||
model: openclaw_config::OpenClawDefaultModel,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
|
||||
openclaw_config::set_default_model(&model).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -56,7 +71,7 @@ pub fn get_openclaw_model_catalog(
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_model_catalog(
|
||||
catalog: HashMap<String, openclaw_config::OpenClawModelCatalogEntry>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
|
||||
openclaw_config::set_model_catalog(&catalog).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -71,7 +86,7 @@ pub fn get_openclaw_agents_defaults(
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_agents_defaults(
|
||||
defaults: openclaw_config::OpenClawAgentsDefaults,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
|
||||
openclaw_config::set_agents_defaults(&defaults).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -87,7 +102,9 @@ pub fn get_openclaw_env() -> Result<openclaw_config::OpenClawEnvConfig, String>
|
||||
|
||||
/// Set OpenClaw env config (env section of openclaw.json)
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_env(env: openclaw_config::OpenClawEnvConfig) -> Result<(), String> {
|
||||
pub fn set_openclaw_env(
|
||||
env: openclaw_config::OpenClawEnvConfig,
|
||||
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
|
||||
openclaw_config::set_env_config(&env).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -103,6 +120,8 @@ pub fn get_openclaw_tools() -> Result<openclaw_config::OpenClawToolsConfig, Stri
|
||||
|
||||
/// Set OpenClaw tools config (tools section of openclaw.json)
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_tools(tools: openclaw_config::OpenClawToolsConfig) -> Result<(), String> {
|
||||
pub fn set_openclaw_tools(
|
||||
tools: openclaw_config::OpenClawToolsConfig,
|
||||
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
|
||||
openclaw_config::set_tools_config(&tools).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -57,3 +57,20 @@ pub async fn launch_session_terminal(
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_session(
|
||||
providerId: String,
|
||||
sessionId: String,
|
||||
sourcePath: String,
|
||||
) -> Result<bool, String> {
|
||||
let provider_id = providerId.clone();
|
||||
let session_id = sessionId.clone();
|
||||
let source_path = sourcePath.clone();
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
session_manager::delete_session(&provider_id, &session_id, &source_path)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete session: {e}"))?
|
||||
}
|
||||
|
||||
@@ -145,6 +145,36 @@ pub async fn set_rectifier_config(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取优化器配置
|
||||
#[tauri::command]
|
||||
pub async fn get_optimizer_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> Result<crate::proxy::types::OptimizerConfig, String> {
|
||||
state.db.get_optimizer_config().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置优化器配置
|
||||
#[tauri::command]
|
||||
pub async fn set_optimizer_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
config: crate::proxy::types::OptimizerConfig,
|
||||
) -> Result<bool, String> {
|
||||
// Validate cache_ttl: only allow known values
|
||||
match config.cache_ttl.as_str() {
|
||||
"5m" | "1h" => {}
|
||||
other => {
|
||||
return Err(format!(
|
||||
"Invalid cache_ttl value: '{other}'. Allowed values: '5m', '1h'"
|
||||
))
|
||||
}
|
||||
}
|
||||
state
|
||||
.db
|
||||
.set_optimizer_config(&config)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取日志配置
|
||||
#[tauri::command]
|
||||
pub async fn get_log_config(
|
||||
|
||||
@@ -15,6 +15,16 @@ use tempfile::NamedTempFile;
|
||||
|
||||
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
||||
|
||||
/// Tables that are local-only (not synced via WebDAV snapshots).
|
||||
/// Schema is still exported, but data rows are skipped.
|
||||
const LOCAL_ONLY_TABLES: &[&str] = &[
|
||||
"proxy_request_logs",
|
||||
"stream_check_logs",
|
||||
"provider_health",
|
||||
"proxy_live_backup",
|
||||
"usage_daily_rollups",
|
||||
];
|
||||
|
||||
/// A database backup entry for the UI
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -25,10 +35,16 @@ pub struct BackupEntry {
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串)
|
||||
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串,完整导出)
|
||||
pub fn export_sql_string(&self) -> Result<String, AppError> {
|
||||
let snapshot = self.snapshot_to_memory()?;
|
||||
Self::dump_sql(&snapshot)
|
||||
Self::dump_sql(&snapshot, &[])
|
||||
}
|
||||
|
||||
/// Export SQL for sync (WebDAV), skipping local-only tables' data
|
||||
pub fn export_sql_string_for_sync(&self) -> Result<String, AppError> {
|
||||
let snapshot = self.snapshot_to_memory()?;
|
||||
Self::dump_sql(&snapshot, LOCAL_ONLY_TABLES)
|
||||
}
|
||||
|
||||
/// 导出为 SQLite 兼容的 SQL 文本
|
||||
@@ -58,12 +74,32 @@ impl Database {
|
||||
|
||||
/// 从 SQL 字符串导入,返回生成的备份 ID(若无备份则为空字符串)
|
||||
pub fn import_sql_string(&self, sql_raw: &str) -> Result<String, AppError> {
|
||||
self.import_sql_string_inner(sql_raw, &[])
|
||||
}
|
||||
|
||||
/// Import SQL generated for sync, then restore local-only tables from the
|
||||
/// current device snapshot before replacing the main database.
|
||||
pub(crate) fn import_sql_string_for_sync(&self, sql_raw: &str) -> Result<String, AppError> {
|
||||
self.import_sql_string_inner(sql_raw, LOCAL_ONLY_TABLES)
|
||||
}
|
||||
|
||||
fn import_sql_string_inner(
|
||||
&self,
|
||||
sql_raw: &str,
|
||||
preserve_tables: &[&str],
|
||||
) -> Result<String, AppError> {
|
||||
let sql_content = sql_raw.trim_start_matches('\u{feff}');
|
||||
Self::validate_cc_switch_sql_export(sql_content)?;
|
||||
|
||||
// 导入前备份现有数据库
|
||||
let backup_path = self.backup_database_file()?;
|
||||
|
||||
let local_snapshot = if preserve_tables.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.snapshot_to_memory()?)
|
||||
};
|
||||
|
||||
// 在临时数据库执行导入,确保失败不会污染主库
|
||||
let temp_file = NamedTempFile::new().map_err(|e| AppError::IoContext {
|
||||
context: "创建临时数据库文件失败".to_string(),
|
||||
@@ -81,6 +117,9 @@ impl Database {
|
||||
Self::create_tables_on_conn(&temp_conn)?;
|
||||
Self::apply_schema_migrations_on_conn(&temp_conn)?;
|
||||
Self::validate_basic_state(&temp_conn)?;
|
||||
if let Some(local_snapshot) = local_snapshot.as_ref() {
|
||||
Self::restore_tables(local_snapshot, &temp_conn, preserve_tables)?;
|
||||
}
|
||||
|
||||
// 使用 Backup 将临时库原子写回主库
|
||||
{
|
||||
@@ -129,42 +168,121 @@ impl Database {
|
||||
))
|
||||
}
|
||||
|
||||
fn restore_tables(
|
||||
source_conn: &Connection,
|
||||
target_conn: &Connection,
|
||||
tables: &[&str],
|
||||
) -> Result<(), AppError> {
|
||||
for table in tables {
|
||||
if !Self::table_exists(source_conn, table)? || !Self::table_exists(target_conn, table)?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let columns = Self::get_table_columns(source_conn, table)?;
|
||||
if columns.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
target_conn
|
||||
.execute(&format!("DELETE FROM \"{table}\""), [])
|
||||
.map_err(|e| AppError::Database(format!("清空表 {table} 失败: {e}")))?;
|
||||
|
||||
let placeholders = (1..=columns.len())
|
||||
.map(|idx| format!("?{idx}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let cols = columns
|
||||
.iter()
|
||||
.map(|column| format!("\"{column}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let insert_sql = format!("INSERT INTO \"{table}\" ({cols}) VALUES ({placeholders})");
|
||||
|
||||
let mut stmt = source_conn
|
||||
.prepare(&format!("SELECT * FROM \"{table}\""))
|
||||
.map_err(|e| AppError::Database(format!("读取表 {table} 失败: {e}")))?;
|
||||
let mut rows = stmt
|
||||
.query([])
|
||||
.map_err(|e| AppError::Database(format!("查询表 {table} 数据失败: {e}")))?;
|
||||
|
||||
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||||
let mut values = Vec::with_capacity(columns.len());
|
||||
for idx in 0..columns.len() {
|
||||
values.push(
|
||||
row.get::<_, rusqlite::types::Value>(idx)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
|
||||
target_conn
|
||||
.execute(&insert_sql, rusqlite::params_from_iter(values.iter()))
|
||||
.map_err(|e| AppError::Database(format!("恢复表 {table} 数据失败: {e}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Periodic backup: create a new backup if the latest one is older than the configured interval
|
||||
pub(crate) fn periodic_backup_if_needed(&self) -> Result<(), AppError> {
|
||||
let interval_hours = crate::settings::effective_backup_interval_hours();
|
||||
if interval_hours == 0 {
|
||||
return Ok(()); // Auto-backup disabled
|
||||
}
|
||||
if interval_hours > 0 {
|
||||
let backup_dir = get_app_config_dir().join("backups");
|
||||
if !backup_dir.exists() {
|
||||
self.backup_database_file()?;
|
||||
} else {
|
||||
let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| {
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
||||
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
|
||||
.max()
|
||||
});
|
||||
|
||||
let backup_dir = get_app_config_dir().join("backups");
|
||||
if !backup_dir.exists() {
|
||||
self.backup_database_file()?;
|
||||
return Ok(());
|
||||
}
|
||||
let interval_secs = u64::from(interval_hours) * 3600;
|
||||
let needs_backup = match latest {
|
||||
None => true,
|
||||
Some(last_modified) => {
|
||||
last_modified.elapsed().unwrap_or_default()
|
||||
> std::time::Duration::from_secs(interval_secs)
|
||||
}
|
||||
};
|
||||
|
||||
let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| {
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
||||
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
|
||||
.max()
|
||||
});
|
||||
|
||||
let interval_secs = u64::from(interval_hours) * 3600;
|
||||
let needs_backup = match latest {
|
||||
None => true,
|
||||
Some(last_modified) => {
|
||||
last_modified.elapsed().unwrap_or_default()
|
||||
> std::time::Duration::from_secs(interval_secs)
|
||||
if needs_backup {
|
||||
log::info!(
|
||||
"Periodic backup: latest backup is older than {interval_hours} hours, creating new backup"
|
||||
);
|
||||
self.backup_database_file()?;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if needs_backup {
|
||||
log::info!(
|
||||
"Periodic backup: latest backup is older than {interval_hours} hours, creating new backup"
|
||||
);
|
||||
self.backup_database_file()?;
|
||||
}
|
||||
|
||||
// Periodic maintenance is always enabled, regardless of auto-backup settings.
|
||||
let mut reclaimed_rows = 0u64;
|
||||
match self.cleanup_old_stream_check_logs(7) {
|
||||
Ok(deleted) => {
|
||||
reclaimed_rows += deleted;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Periodic stream_check_logs cleanup failed: {e}");
|
||||
}
|
||||
}
|
||||
match self.rollup_and_prune(30) {
|
||||
Ok(deleted) => {
|
||||
reclaimed_rows += deleted;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Periodic rollup_and_prune failed: {e}");
|
||||
}
|
||||
}
|
||||
if reclaimed_rows > 0 {
|
||||
let conn = lock_conn!(self.conn);
|
||||
if let Err(e) = conn.execute_batch("PRAGMA incremental_vacuum;") {
|
||||
log::warn!("Periodic incremental vacuum failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -258,7 +376,7 @@ impl Database {
|
||||
}
|
||||
|
||||
/// 导出数据库为 SQL 文本
|
||||
fn dump_sql(conn: &Connection) -> Result<String, AppError> {
|
||||
fn dump_sql(conn: &Connection, skip_tables: &[&str]) -> Result<String, AppError> {
|
||||
let mut output = String::new();
|
||||
let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let user_version: i64 = conn
|
||||
@@ -306,6 +424,9 @@ impl Database {
|
||||
|
||||
// 导出数据
|
||||
for table in tables {
|
||||
if skip_tables.iter().any(|t| *t == table) {
|
||||
continue;
|
||||
}
|
||||
let columns = Self::get_table_columns(conn, &table)?;
|
||||
if columns.is_empty() {
|
||||
continue;
|
||||
@@ -557,3 +678,173 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::settings::{update_settings, AppSettings};
|
||||
use serial_test::serial;
|
||||
|
||||
#[test]
|
||||
fn sync_import_preserves_local_only_tables() -> Result<(), AppError> {
|
||||
let remote_db = Database::memory()?;
|
||||
{
|
||||
let conn = crate::database::lock_conn!(remote_db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
||||
VALUES ('remote-provider', 'claude', 'Remote Provider', '{}', '{}')",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
let remote_sql = remote_db.export_sql_string_for_sync()?;
|
||||
|
||||
let local_db = Database::memory()?;
|
||||
{
|
||||
let conn = crate::database::lock_conn!(local_db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
||||
VALUES ('local-provider', 'claude', 'Local Provider', '{}', '{}')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES ('req-1', 'local-provider', 'claude', 'claude-3', 100, 50, '0.01', 120, 200, 1000)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO usage_daily_rollups (
|
||||
date, app_type, provider_id, model, request_count, success_count,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
total_cost_usd, avg_latency_ms
|
||||
) VALUES ('2026-03-01', 'claude', 'local-provider', 'claude-3', 7, 7, 700, 350, 0, 0, '0.07', 120)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO stream_check_logs (
|
||||
provider_id, provider_name, app_type, status, success, message,
|
||||
response_time_ms, http_status, model_used, retry_count, tested_at
|
||||
) VALUES ('local-provider', 'Local Provider', 'claude', 'operational', 1, 'ok', 42, 200, 'claude-3', 0, 1000)",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
local_db.import_sql_string_for_sync(&remote_sql)?;
|
||||
|
||||
let remote_provider_exists: i64 = {
|
||||
let conn = crate::database::lock_conn!(local_db.conn);
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM providers WHERE id = 'remote-provider' AND app_type = 'claude'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?
|
||||
};
|
||||
assert_eq!(
|
||||
remote_provider_exists, 1,
|
||||
"remote config should be imported"
|
||||
);
|
||||
|
||||
let (request_logs, rollups, stream_logs): (i64, i64, i64) = {
|
||||
let conn = crate::database::lock_conn!(local_db.conn);
|
||||
let request_logs =
|
||||
conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
let rollups =
|
||||
conn.query_row("SELECT COUNT(*) FROM usage_daily_rollups", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
let stream_logs =
|
||||
conn.query_row("SELECT COUNT(*) FROM stream_check_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
(request_logs, rollups, stream_logs)
|
||||
};
|
||||
assert_eq!(request_logs, 1, "local request logs should be preserved");
|
||||
assert_eq!(rollups, 1, "local rollups should be preserved");
|
||||
assert_eq!(
|
||||
stream_logs, 1,
|
||||
"local stream check logs should be preserved"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn periodic_maintenance_runs_even_when_auto_backup_disabled() -> Result<(), AppError> {
|
||||
let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||||
let test_home =
|
||||
std::env::temp_dir().join("cc-switch-periodic-maintenance-backup-disabled-test");
|
||||
let _ = std::fs::remove_dir_all(&test_home);
|
||||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||||
|
||||
let mut settings = AppSettings::default();
|
||||
settings.backup_interval_hours = Some(0);
|
||||
update_settings(settings).expect("disable auto backup");
|
||||
|
||||
let db = Database::memory()?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let old_ts = now - 40 * 86400;
|
||||
let old_stream_ts = now - 8 * 86400;
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES ('old-req', 'p1', 'claude', 'claude-3', 100, 50, '0.01', 100, 200, ?1)",
|
||||
[old_ts],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO stream_check_logs (
|
||||
provider_id, provider_name, app_type, status, success, message,
|
||||
response_time_ms, http_status, model_used, retry_count, tested_at
|
||||
) VALUES ('p1', 'Provider 1', 'claude', 'operational', 1, 'ok', 42, 200, 'claude-3', 0, ?1)",
|
||||
[old_stream_ts],
|
||||
)?;
|
||||
}
|
||||
|
||||
db.periodic_backup_if_needed()?;
|
||||
|
||||
let (remaining_request_logs, stream_logs, rollups): (i64, i64, i64) = {
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let remaining_request_logs =
|
||||
conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
let stream_logs =
|
||||
conn.query_row("SELECT COUNT(*) FROM stream_check_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
let rollups =
|
||||
conn.query_row("SELECT COUNT(*) FROM usage_daily_rollups", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
(remaining_request_logs, stream_logs, rollups)
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
remaining_request_logs, 0,
|
||||
"old request logs should still be pruned when auto backup is disabled"
|
||||
);
|
||||
assert_eq!(
|
||||
stream_logs, 0,
|
||||
"old stream check logs should still be pruned when auto backup is disabled"
|
||||
);
|
||||
assert_eq!(rollups, 1, "old request logs should be rolled up");
|
||||
|
||||
match old_test_home {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod settings;
|
||||
pub mod skills;
|
||||
pub mod stream_check;
|
||||
pub mod universal_providers;
|
||||
pub mod usage_rollup;
|
||||
|
||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||
// 导出 FailoverQueueItem 供外部使用
|
||||
|
||||
@@ -187,6 +187,29 @@ impl Database {
|
||||
self.set_setting("rectifier_config", &json)
|
||||
}
|
||||
|
||||
// --- 优化器配置 ---
|
||||
|
||||
/// 获取优化器配置
|
||||
///
|
||||
/// 返回优化器配置,如果不存在则返回默认值(默认关闭)
|
||||
pub fn get_optimizer_config(&self) -> Result<crate::proxy::types::OptimizerConfig, AppError> {
|
||||
match self.get_setting("optimizer_config")? {
|
||||
Some(json) => serde_json::from_str(&json)
|
||||
.map_err(|e| AppError::Database(format!("解析优化器配置失败: {e}"))),
|
||||
None => Ok(crate::proxy::types::OptimizerConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新优化器配置
|
||||
pub fn set_optimizer_config(
|
||||
&self,
|
||||
config: &crate::proxy::types::OptimizerConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let json = serde_json::to_string(config)
|
||||
.map_err(|e| AppError::Database(format!("序列化优化器配置失败: {e}")))?;
|
||||
self.set_setting("optimizer_config", &json)
|
||||
}
|
||||
|
||||
// --- 日志配置 ---
|
||||
|
||||
/// 获取日志配置
|
||||
|
||||
@@ -48,6 +48,23 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete stream check logs older than `retain_days` days.
|
||||
/// Returns the number of deleted rows.
|
||||
pub fn cleanup_old_stream_check_logs(&self, retain_days: i64) -> Result<u64, AppError> {
|
||||
let cutoff = chrono::Utc::now().timestamp() - retain_days * 86400;
|
||||
let conn = lock_conn!(self.conn);
|
||||
let deleted = conn
|
||||
.execute(
|
||||
"DELETE FROM stream_check_logs WHERE tested_at < ?1",
|
||||
[cutoff],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
if deleted > 0 {
|
||||
log::info!("Cleaned up {deleted} stream_check_logs older than {retain_days} days");
|
||||
}
|
||||
Ok(deleted as u64)
|
||||
}
|
||||
|
||||
/// 保存流式检查配置
|
||||
pub fn save_stream_check_config(&self, config: &StreamCheckConfig) -> Result<(), AppError> {
|
||||
let json = serde_json::to_string(config)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Usage rollup DAO
|
||||
//!
|
||||
//! Aggregates proxy_request_logs into daily rollups and prunes old detail rows.
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
|
||||
impl Database {
|
||||
/// Aggregate proxy_request_logs older than `retain_days` into usage_daily_rollups,
|
||||
/// then delete the aggregated detail rows.
|
||||
/// Returns the number of deleted detail rows.
|
||||
pub fn rollup_and_prune(&self, retain_days: i64) -> Result<u64, AppError> {
|
||||
let cutoff = chrono::Utc::now().timestamp() - retain_days * 86400;
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// Check if there are any rows to process
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM proxy_request_logs WHERE created_at < ?1",
|
||||
[cutoff],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
if count == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Use a savepoint for atomicity
|
||||
conn.execute("SAVEPOINT rollup_prune;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let result = Self::do_rollup_and_prune(&conn, cutoff);
|
||||
|
||||
match result {
|
||||
Ok(deleted) => {
|
||||
conn.execute("RELEASE rollup_prune;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
if deleted > 0 {
|
||||
log::info!(
|
||||
"Rolled up and pruned {deleted} proxy_request_logs (retain={retain_days}d)"
|
||||
);
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
Err(e) => {
|
||||
conn.execute("ROLLBACK TO rollup_prune;", []).ok();
|
||||
conn.execute("RELEASE rollup_prune;", []).ok();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn do_rollup_and_prune(conn: &rusqlite::Connection, cutoff: i64) -> Result<u64, AppError> {
|
||||
// Aggregate old logs, merging with any pre-existing rollup rows via LEFT JOIN.
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO usage_daily_rollups
|
||||
(date, app_type, provider_id, model,
|
||||
request_count, success_count,
|
||||
input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_creation_tokens,
|
||||
total_cost_usd, avg_latency_ms)
|
||||
SELECT
|
||||
d, a, p, m,
|
||||
COALESCE(old.request_count, 0) + new_req,
|
||||
COALESCE(old.success_count, 0) + new_succ,
|
||||
COALESCE(old.input_tokens, 0) + new_in,
|
||||
COALESCE(old.output_tokens, 0) + new_out,
|
||||
COALESCE(old.cache_read_tokens, 0) + new_cr,
|
||||
COALESCE(old.cache_creation_tokens, 0) + new_cc,
|
||||
CAST(COALESCE(CAST(old.total_cost_usd AS REAL), 0) + new_cost AS TEXT),
|
||||
CASE WHEN COALESCE(old.request_count, 0) + new_req > 0
|
||||
THEN (COALESCE(old.avg_latency_ms, 0) * COALESCE(old.request_count, 0)
|
||||
+ new_lat * new_req)
|
||||
/ (COALESCE(old.request_count, 0) + new_req)
|
||||
ELSE 0 END
|
||||
FROM (
|
||||
SELECT
|
||||
date(created_at, 'unixepoch', 'localtime') as d,
|
||||
app_type as a, provider_id as p, model as m,
|
||||
COUNT(*) as new_req,
|
||||
SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) as new_succ,
|
||||
COALESCE(SUM(input_tokens), 0) as new_in,
|
||||
COALESCE(SUM(output_tokens), 0) as new_out,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as new_cr,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as new_cc,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as new_cost,
|
||||
COALESCE(AVG(latency_ms), 0) as new_lat
|
||||
FROM proxy_request_logs WHERE created_at < ?1
|
||||
GROUP BY d, a, p, m
|
||||
) agg
|
||||
LEFT JOIN usage_daily_rollups old
|
||||
ON old.date = agg.d AND old.app_type = agg.a
|
||||
AND old.provider_id = agg.p AND old.model = agg.m",
|
||||
[cutoff],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Rollup aggregation failed: {e}")))?;
|
||||
|
||||
// Delete the aggregated detail rows
|
||||
let deleted = conn
|
||||
.execute(
|
||||
"DELETE FROM proxy_request_logs WHERE created_at < ?1",
|
||||
[cutoff],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Pruning old logs failed: {e}")))?;
|
||||
|
||||
Ok(deleted as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
|
||||
#[test]
|
||||
fn test_rollup_and_prune() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let old_ts = now - 40 * 86400; // 40 days ago
|
||||
let recent_ts = now - 5 * 86400; // 5 days ago
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
for i in 0..5 {
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES (?1, 'p1', 'claude', 'claude-3', 100, 50, '0.01', 100, 200, ?2)",
|
||||
rusqlite::params![format!("old-{i}"), old_ts + i as i64],
|
||||
)?;
|
||||
}
|
||||
for i in 0..3 {
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES (?1, 'p1', 'claude', 'claude-3', 200, 100, '0.02', 150, 200, ?2)",
|
||||
rusqlite::params![format!("recent-{i}"), recent_ts + i as i64],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
let deleted = db.rollup_and_prune(30)?;
|
||||
assert_eq!(deleted, 5);
|
||||
|
||||
// Verify rollup data
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT request_count FROM usage_daily_rollups WHERE app_type = 'claude'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(count, 5);
|
||||
|
||||
// Verify recent logs untouched
|
||||
let remaining: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
assert_eq!(remaining, 3);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_noop_when_no_old_data() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
assert_eq!(db.rollup_and_prune(30)?, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_merges_with_existing() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let old_ts = now - 40 * 86400;
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let date_str = chrono::DateTime::from_timestamp(old_ts, 0)
|
||||
.unwrap()
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO usage_daily_rollups
|
||||
(date, app_type, provider_id, model, request_count, success_count,
|
||||
input_tokens, output_tokens, total_cost_usd, avg_latency_ms)
|
||||
VALUES (?1, 'claude', 'p1', 'claude-3', 10, 10, 1000, 500, '0.10', 100)",
|
||||
[&date_str],
|
||||
)?;
|
||||
for i in 0..3 {
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES (?1, 'p1', 'claude', 'claude-3', 100, 50, '0.01', 200, 200, ?2)",
|
||||
rusqlite::params![format!("merge-{i}"), old_ts + i as i64],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
let deleted = db.rollup_and_prune(30)?;
|
||||
assert_eq!(deleted, 3);
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let (count, input): (i64, i64) = conn.query_row(
|
||||
"SELECT request_count, input_tokens FROM usage_daily_rollups
|
||||
WHERE app_type = 'claude' AND provider_id = 'p1'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
assert_eq!(count, 13, "10 existing + 3 new");
|
||||
assert_eq!(input, 1300, "1000 existing + 300 new");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 5;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 6;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
@@ -89,6 +89,7 @@ impl Database {
|
||||
/// 数据库文件位于 `~/.cc-switch/cc-switch.db`
|
||||
pub fn init() -> Result<Self, AppError> {
|
||||
let db_path = get_app_config_dir().join("cc-switch.db");
|
||||
let db_exists = db_path.exists();
|
||||
|
||||
// 确保父目录存在
|
||||
if let Some(parent) = db_path.parent() {
|
||||
@@ -100,6 +101,12 @@ impl Database {
|
||||
// 启用外键约束
|
||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
if !db_exists {
|
||||
// For a brand-new database, configure incremental auto-vacuum
|
||||
// before creating any tables so no rebuild is needed later.
|
||||
conn.execute("PRAGMA auto_vacuum = INCREMENTAL;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
register_db_change_hook(&conn);
|
||||
|
||||
let db = Self {
|
||||
@@ -123,8 +130,26 @@ impl Database {
|
||||
}
|
||||
|
||||
db.apply_schema_migrations()?;
|
||||
if let Err(e) = db.ensure_incremental_auto_vacuum() {
|
||||
log::warn!("Failed to ensure incremental auto-vacuum: {e}");
|
||||
}
|
||||
db.ensure_model_pricing_seeded()?;
|
||||
|
||||
// Startup cleanup: prune old logs and reclaim space
|
||||
if let Err(e) = db.cleanup_old_stream_check_logs(7) {
|
||||
log::warn!("Startup stream_check_logs cleanup failed: {e}");
|
||||
}
|
||||
if let Err(e) = db.rollup_and_prune(30) {
|
||||
log::warn!("Startup rollup_and_prune failed: {e}");
|
||||
}
|
||||
// Reclaim disk space after cleanup
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
if let Err(e) = conn.execute_batch("PRAGMA incremental_vacuum;") {
|
||||
log::warn!("Startup incremental vacuum failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
@@ -135,6 +160,8 @@ impl Database {
|
||||
// 启用外键约束
|
||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute("PRAGMA auto_vacuum = INCREMENTAL;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
register_db_change_hook(&conn);
|
||||
|
||||
let db = Self {
|
||||
@@ -146,6 +173,79 @@ impl Database {
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
pub(crate) fn get_auto_vacuum_mode(conn: &Connection) -> Result<i32, AppError> {
|
||||
conn.query_row("PRAGMA auto_vacuum;", [], |row| row.get(0))
|
||||
.map_err(|e| AppError::Database(format!("读取 auto_vacuum 失败: {e}")))
|
||||
}
|
||||
|
||||
fn has_user_tables(conn: &Connection) -> Result<bool, AppError> {
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("读取表数量失败: {e}")))?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_incremental_auto_vacuum_on_conn(
|
||||
conn: &Connection,
|
||||
) -> Result<bool, AppError> {
|
||||
let mode = Self::get_auto_vacuum_mode(conn)?;
|
||||
if mode == 2 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let has_tables = Self::has_user_tables(conn)?;
|
||||
conn.execute("PRAGMA auto_vacuum = INCREMENTAL;", [])
|
||||
.map_err(|e| AppError::Database(format!("设置 auto_vacuum 失败: {e}")))?;
|
||||
|
||||
if !has_tables {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
conn.execute("VACUUM;", [])
|
||||
.map_err(|e| AppError::Database(format!("执行 VACUUM 失败: {e}")))?;
|
||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||
.map_err(|e| AppError::Database(format!("恢复 foreign_keys 失败: {e}")))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_incremental_auto_vacuum(&self) -> Result<bool, AppError> {
|
||||
let mode = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
Self::get_auto_vacuum_mode(&conn)?
|
||||
};
|
||||
if mode == 2 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let has_tables = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
Self::has_user_tables(&conn)?
|
||||
};
|
||||
if has_tables {
|
||||
log::info!(
|
||||
"Detected auto_vacuum={mode}, rebuilding database to enable incremental vacuum"
|
||||
);
|
||||
self.backup_database_file()?;
|
||||
}
|
||||
|
||||
let rebuilt = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
Self::ensure_incremental_auto_vacuum_on_conn(&conn)?
|
||||
};
|
||||
|
||||
if rebuilt {
|
||||
log::info!("Incremental auto-vacuum enabled after database rebuild");
|
||||
} else {
|
||||
log::info!("Incremental auto-vacuum configured for new database");
|
||||
}
|
||||
|
||||
Ok(rebuilt)
|
||||
}
|
||||
|
||||
/// 检查 MCP 服务器表是否为空
|
||||
pub fn is_mcp_table_empty(&self) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
@@ -241,6 +241,27 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 17. Usage Daily Rollups 表 (日聚合统计)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS usage_daily_rollups (
|
||||
date TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (date, app_type, provider_id, model)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 尝试添加 live_takeover_active 列到 proxy_config 表
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
|
||||
@@ -360,6 +381,11 @@ impl Database {
|
||||
Self::migrate_v4_to_v5(conn)?;
|
||||
Self::set_user_version(conn, 5)?;
|
||||
}
|
||||
5 => {
|
||||
log::info!("迁移数据库从 v5 到 v6(使用量聚合表)");
|
||||
Self::migrate_v5_to_v6(conn)?;
|
||||
Self::set_user_version(conn, 6)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -914,6 +940,32 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v5 -> v6 迁移:添加使用量日聚合表
|
||||
fn migrate_v5_to_v6(conn: &Connection) -> Result<(), AppError> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS usage_daily_rollups (
|
||||
date TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (date, app_type, provider_id, model)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 usage_daily_rollups 表失败: {e}")))?;
|
||||
|
||||
log::info!("v5 -> v6 迁移完成:已添加使用量日聚合表");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
@@ -1189,6 +1241,15 @@ impl Database {
|
||||
"0.03",
|
||||
"0",
|
||||
),
|
||||
// StepFun 系列
|
||||
(
|
||||
"step-3.5-flash",
|
||||
"Step 3.5 Flash",
|
||||
"0.10",
|
||||
"0.30",
|
||||
"0.02",
|
||||
"0",
|
||||
),
|
||||
// ====== 国产模型 (CNY/1M tokens) ======
|
||||
// Doubao (字节跳动)
|
||||
(
|
||||
|
||||
@@ -9,6 +9,7 @@ use indexmap::IndexMap;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
const LEGACY_SCHEMA_SQL: &str = r#"
|
||||
CREATE TABLE providers (
|
||||
@@ -633,3 +634,32 @@ fn schema_model_pricing_is_seeded_on_init() {
|
||||
gemini_count
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_incremental_auto_vacuum_rebuilds_existing_file_db() {
|
||||
let temp = NamedTempFile::new().expect("create temp db file");
|
||||
let path = temp.path().to_path_buf();
|
||||
|
||||
let conn = Connection::open(&path).expect("open temp db");
|
||||
conn.execute("PRAGMA auto_vacuum = NONE;", [])
|
||||
.expect("set none auto_vacuum");
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
|
||||
assert_eq!(
|
||||
Database::get_auto_vacuum_mode(&conn).expect("auto_vacuum before rebuild"),
|
||||
0,
|
||||
"existing file db should start with NONE auto_vacuum"
|
||||
);
|
||||
|
||||
let rebuilt =
|
||||
Database::ensure_incremental_auto_vacuum_on_conn(&conn).expect("enable incremental mode");
|
||||
assert!(rebuilt, "existing db should require rebuild via VACUUM");
|
||||
drop(conn);
|
||||
|
||||
let reopened = Connection::open(&path).expect("reopen temp db");
|
||||
assert_eq!(
|
||||
Database::get_auto_vacuum_mode(&reopened).expect("auto_vacuum after rebuild"),
|
||||
2,
|
||||
"file db should persist INCREMENTAL auto_vacuum after VACUUM rebuild"
|
||||
);
|
||||
}
|
||||
|
||||
+11
-4
@@ -805,16 +805,18 @@ pub fn run() {
|
||||
log::warn!("Periodic backup failed on startup: {e}");
|
||||
}
|
||||
|
||||
// Periodic backup timer: check every hour while the app is running
|
||||
// Periodic maintenance timer: run once per day while the app is running
|
||||
let db_for_timer = state.db.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval =
|
||||
tokio::time::interval(std::time::Duration::from_secs(3600));
|
||||
const PERIODIC_MAINTENANCE_INTERVAL_SECS: u64 = 24 * 60 * 60;
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(
|
||||
PERIODIC_MAINTENANCE_INTERVAL_SECS,
|
||||
));
|
||||
interval.tick().await; // skip immediate first tick (already checked above)
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(e) = db_for_timer.periodic_backup_if_needed() {
|
||||
log::warn!("Periodic backup timer failed: {e}");
|
||||
log::warn!("Periodic maintenance timer failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -886,6 +888,8 @@ pub fn run() {
|
||||
commands::save_settings,
|
||||
commands::get_rectifier_config,
|
||||
commands::set_rectifier_config,
|
||||
commands::get_optimizer_config,
|
||||
commands::set_optimizer_config,
|
||||
commands::get_log_config,
|
||||
commands::set_log_config,
|
||||
commands::restart_app,
|
||||
@@ -1036,6 +1040,7 @@ pub fn run() {
|
||||
// Session manager
|
||||
commands::list_sessions,
|
||||
commands::get_session_messages,
|
||||
commands::delete_session,
|
||||
commands::launch_session_terminal,
|
||||
commands::get_tool_versions,
|
||||
// Provider terminal
|
||||
@@ -1052,6 +1057,8 @@ pub fn run() {
|
||||
// OpenClaw specific
|
||||
commands::import_openclaw_providers_from_live,
|
||||
commands::get_openclaw_live_provider_ids,
|
||||
commands::get_openclaw_live_provider,
|
||||
commands::scan_openclaw_config_health,
|
||||
commands::get_openclaw_default_model,
|
||||
commands::set_openclaw_default_model,
|
||||
commands::get_openclaw_model_catalog,
|
||||
|
||||
+719
-211
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,12 @@ pub struct ProviderMeta {
|
||||
/// 自定义端点列表(按 URL 去重存储)
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
|
||||
/// 是否在写入 live 时应用通用配置片段
|
||||
#[serde(
|
||||
rename = "commonConfigEnabled",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub common_config_enabled: Option<bool>,
|
||||
/// 用量查询脚本配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_script: Option<UsageScript>,
|
||||
@@ -233,8 +239,22 @@ pub struct ProviderMeta {
|
||||
/// Claude API 格式(仅 Claude 供应商使用)
|
||||
/// - "anthropic": 原生 Anthropic Messages API,直接透传
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
||||
/// - "openai_responses": OpenAI Responses API 格式,需要转换
|
||||
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
||||
pub api_format: Option<String>,
|
||||
/// Claude 认证字段名(仅 Claude 供应商使用)
|
||||
/// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商
|
||||
/// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
|
||||
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
|
||||
pub api_key_field: Option<String>,
|
||||
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
|
||||
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
|
||||
pub is_full_url: Option<bool>,
|
||||
/// Prompt cache key for OpenAI-compatible endpoints.
|
||||
/// When set, injected into converted requests to improve cache hit rate.
|
||||
/// If not set, provider ID is used automatically during format conversion.
|
||||
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Cache 断点注入器
|
||||
//!
|
||||
//! 在请求转发前自动注入 cache_control 标记,启用 Bedrock Prompt Caching
|
||||
|
||||
use super::types::OptimizerConfig;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 在请求体关键位置注入 cache_control 断点
|
||||
pub fn inject(body: &mut Value, config: &OptimizerConfig) {
|
||||
if !config.cache_injection {
|
||||
return;
|
||||
}
|
||||
|
||||
let existing = count_existing(body);
|
||||
|
||||
// 升级已有断点的 TTL
|
||||
upgrade_existing_ttl(body, &config.cache_ttl);
|
||||
|
||||
let mut budget = 4_usize.saturating_sub(existing);
|
||||
if budget == 0 {
|
||||
if existing > 0 {
|
||||
log::info!(
|
||||
"[OPT] cache: ttl-upgrade({existing}->{},existing={existing})",
|
||||
config.cache_ttl
|
||||
);
|
||||
} else {
|
||||
log::info!("[OPT] cache: no-op(existing={existing})");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut injected = Vec::new();
|
||||
|
||||
// (a) tools 末尾
|
||||
if budget > 0 {
|
||||
if let Some(tools) = body.get_mut("tools").and_then(|t| t.as_array_mut()) {
|
||||
if let Some(last) = tools.last_mut() {
|
||||
if last.get("cache_control").is_none() {
|
||||
if let Some(o) = last.as_object_mut() {
|
||||
o.insert(
|
||||
"cache_control".to_string(),
|
||||
make_cache_control(&config.cache_ttl),
|
||||
);
|
||||
}
|
||||
budget -= 1;
|
||||
injected.push("tools");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (b) system 末尾
|
||||
if budget > 0 {
|
||||
// 字符串 system → 转为数组
|
||||
if body.get("system").and_then(|s| s.as_str()).is_some() {
|
||||
let text = body["system"].as_str().unwrap().to_string();
|
||||
body["system"] = json!([{"type": "text", "text": text}]);
|
||||
}
|
||||
|
||||
if let Some(system) = body.get_mut("system").and_then(|s| s.as_array_mut()) {
|
||||
if let Some(last) = system.last_mut() {
|
||||
if last.get("cache_control").is_none() {
|
||||
if let Some(o) = last.as_object_mut() {
|
||||
o.insert(
|
||||
"cache_control".to_string(),
|
||||
make_cache_control(&config.cache_ttl),
|
||||
);
|
||||
}
|
||||
budget -= 1;
|
||||
injected.push("system");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (c) 最后一条 assistant 消息的最后一个非 thinking block
|
||||
if budget > 0 {
|
||||
if let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) {
|
||||
if let Some(assistant_msg) = messages
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
|
||||
{
|
||||
if let Some(content) = assistant_msg
|
||||
.get_mut("content")
|
||||
.and_then(|c| c.as_array_mut())
|
||||
{
|
||||
// 逆序找最后一个非 thinking/redacted_thinking block
|
||||
if let Some(block) = content.iter_mut().rev().find(|b| {
|
||||
let bt = b.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
bt != "thinking" && bt != "redacted_thinking"
|
||||
}) {
|
||||
if block.get("cache_control").is_none() {
|
||||
if let Some(o) = block.as_object_mut() {
|
||||
o.insert(
|
||||
"cache_control".to_string(),
|
||||
make_cache_control(&config.cache_ttl),
|
||||
);
|
||||
}
|
||||
injected.push("msgs");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[OPT] cache: {}bp({},{},pre={existing})",
|
||||
injected.len(),
|
||||
injected.join("+"),
|
||||
config.cache_ttl,
|
||||
);
|
||||
}
|
||||
|
||||
fn make_cache_control(ttl: &str) -> Value {
|
||||
if ttl == "5m" {
|
||||
json!({"type": "ephemeral"})
|
||||
} else {
|
||||
json!({"type": "ephemeral", "ttl": ttl})
|
||||
}
|
||||
}
|
||||
|
||||
fn count_existing(body: &Value) -> usize {
|
||||
let mut count = 0;
|
||||
|
||||
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
|
||||
count += tools
|
||||
.iter()
|
||||
.filter(|t| t.get("cache_control").is_some())
|
||||
.count();
|
||||
}
|
||||
|
||||
if let Some(system) = body.get("system").and_then(|s| s.as_array()) {
|
||||
count += system
|
||||
.iter()
|
||||
.filter(|b| b.get("cache_control").is_some())
|
||||
.count();
|
||||
}
|
||||
|
||||
if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) {
|
||||
for msg in messages {
|
||||
if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
|
||||
count += content
|
||||
.iter()
|
||||
.filter(|b| b.get("cache_control").is_some())
|
||||
.count();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
fn upgrade_existing_ttl(body: &mut Value, ttl: &str) {
|
||||
let upgrade = |val: &mut Value| {
|
||||
if let Some(cc) = val.get_mut("cache_control").and_then(|c| c.as_object_mut()) {
|
||||
if ttl == "5m" {
|
||||
cc.remove("ttl");
|
||||
} else {
|
||||
cc.insert("ttl".to_string(), json!(ttl));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(tools) = body.get_mut("tools").and_then(|t| t.as_array_mut()) {
|
||||
for tool in tools.iter_mut() {
|
||||
upgrade(tool);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(system) = body.get_mut("system").and_then(|s| s.as_array_mut()) {
|
||||
for block in system.iter_mut() {
|
||||
upgrade(block);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) {
|
||||
for msg in messages.iter_mut() {
|
||||
if let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) {
|
||||
for block in content.iter_mut() {
|
||||
upgrade(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn default_config() -> OptimizerConfig {
|
||||
OptimizerConfig {
|
||||
enabled: true,
|
||||
thinking_optimizer: true,
|
||||
cache_injection: true,
|
||||
cache_ttl: "1h".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_body_no_injection() {
|
||||
let mut body = json!({"model": "test", "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]});
|
||||
let original = body.clone();
|
||||
inject(&mut body, &default_config());
|
||||
// No tools, no system, no assistant → no injection
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_three_breakpoints() {
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"tools": [{"name": "tool1"}, {"name": "tool2"}],
|
||||
"system": [{"type": "text", "text": "sys prompt"}],
|
||||
"messages": [
|
||||
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "text", "text": "hello"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
|
||||
inject(&mut body, &default_config());
|
||||
|
||||
// tools last element
|
||||
assert!(body["tools"][1].get("cache_control").is_some());
|
||||
assert_eq!(body["tools"][1]["cache_control"]["ttl"], "1h");
|
||||
// system last element
|
||||
assert!(body["system"][0].get("cache_control").is_some());
|
||||
// assistant last non-thinking block
|
||||
assert!(body["messages"][1]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_existing_four_breakpoints_only_upgrades_ttl() {
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"tools": [
|
||||
{"name": "t1", "cache_control": {"type": "ephemeral", "ttl": "5m"}},
|
||||
{"name": "t2", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
],
|
||||
"system": [
|
||||
{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
],
|
||||
"messages": [
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "text", "text": "ok", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
]}
|
||||
]
|
||||
});
|
||||
|
||||
inject(&mut body, &default_config());
|
||||
|
||||
// All TTLs upgraded to 1h, no new breakpoints
|
||||
assert_eq!(body["tools"][0]["cache_control"]["ttl"], "1h");
|
||||
assert_eq!(body["tools"][1]["cache_control"]["ttl"], "1h");
|
||||
assert_eq!(body["system"][0]["cache_control"]["ttl"], "1h");
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][0]["cache_control"]["ttl"],
|
||||
"1h"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_existing_two_injects_two_more() {
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"tools": [
|
||||
{"name": "t1", "cache_control": {"type": "ephemeral"}},
|
||||
{"name": "t2", "cache_control": {"type": "ephemeral"}}
|
||||
],
|
||||
"system": [{"type": "text", "text": "sys"}],
|
||||
"messages": [
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "ok"}]}
|
||||
]
|
||||
});
|
||||
|
||||
inject(&mut body, &default_config());
|
||||
|
||||
// budget = 4 - 2 = 2, inject system + msgs
|
||||
assert!(body["system"][0].get("cache_control").is_some());
|
||||
assert!(body["messages"][0]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_string_converted_to_array() {
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"system": "You are a helpful assistant",
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
|
||||
});
|
||||
|
||||
inject(&mut body, &default_config());
|
||||
|
||||
assert!(body["system"].is_array());
|
||||
let sys = body["system"].as_array().unwrap();
|
||||
assert_eq!(sys.len(), 1);
|
||||
assert_eq!(sys[0]["type"], "text");
|
||||
assert_eq!(sys[0]["text"], "You are a helpful assistant");
|
||||
assert!(sys[0].get("cache_control").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ttl_5m_no_ttl_field() {
|
||||
let config = OptimizerConfig {
|
||||
cache_ttl: "5m".to_string(),
|
||||
..default_config()
|
||||
};
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"tools": [{"name": "tool1"}],
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
|
||||
});
|
||||
|
||||
inject(&mut body, &config);
|
||||
|
||||
let cc = &body["tools"][0]["cache_control"];
|
||||
assert_eq!(cc["type"], "ephemeral");
|
||||
assert!(cc.get("ttl").is_none() || cc["ttl"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_no_change() {
|
||||
let config = OptimizerConfig {
|
||||
cache_injection: false,
|
||||
..default_config()
|
||||
};
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"tools": [{"name": "tool1"}],
|
||||
"system": [{"type": "text", "text": "sys"}],
|
||||
"messages": [{"role": "assistant", "content": [{"type": "text", "text": "ok"}]}]
|
||||
});
|
||||
let original = body.clone();
|
||||
|
||||
inject(&mut body, &config);
|
||||
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skip_thinking_blocks_in_assistant() {
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"messages": [
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "thinking", "thinking": "hmm"},
|
||||
{"type": "text", "text": "result"},
|
||||
{"type": "redacted_thinking", "data": "xxx"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
|
||||
inject(&mut body, &default_config());
|
||||
|
||||
// Should inject on "text" block (last non-thinking), not on thinking/redacted_thinking
|
||||
assert!(body["messages"][0]["content"][1]
|
||||
.get("cache_control")
|
||||
.is_some());
|
||||
assert!(body["messages"][0]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_none());
|
||||
assert!(body["messages"][0]["content"][2]
|
||||
.get("cache_control")
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,14 @@ use super::{
|
||||
body_filter::filter_private_params_with_whitelist,
|
||||
error::*,
|
||||
failover_switch::FailoverSwitchManager,
|
||||
log_codes::fwd as log_fwd,
|
||||
provider_router::ProviderRouter,
|
||||
providers::{get_adapter, ProviderAdapter, ProviderType},
|
||||
thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget},
|
||||
thinking_rectifier::{
|
||||
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
|
||||
},
|
||||
types::{ProxyStatus, RectifierConfig},
|
||||
types::{OptimizerConfig, ProxyStatus, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use crate::{app_config::AppType, provider::Provider};
|
||||
@@ -97,6 +98,8 @@ pub struct RequestForwarder {
|
||||
current_provider_id_at_start: String,
|
||||
/// 整流器配置
|
||||
rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
optimizer_config: OptimizerConfig,
|
||||
/// 非流式请求超时(秒)
|
||||
non_streaming_timeout: std::time::Duration,
|
||||
}
|
||||
@@ -114,6 +117,7 @@ impl RequestForwarder {
|
||||
_streaming_first_byte_timeout: u64,
|
||||
_streaming_idle_timeout: u64,
|
||||
rectifier_config: RectifierConfig,
|
||||
optimizer_config: OptimizerConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
router,
|
||||
@@ -123,6 +127,7 @@ impl RequestForwarder {
|
||||
app_handle,
|
||||
current_provider_id_at_start,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
||||
}
|
||||
}
|
||||
@@ -139,7 +144,7 @@ impl RequestForwarder {
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
endpoint: &str,
|
||||
mut body: Value,
|
||||
body: Value,
|
||||
headers: axum::http::HeaderMap,
|
||||
providers: Vec<Provider>,
|
||||
) -> Result<ForwardResult, ForwardError> {
|
||||
@@ -183,6 +188,22 @@ impl RequestForwarder {
|
||||
continue;
|
||||
}
|
||||
|
||||
// PRE-SEND 优化器:每个 provider 独立决定是否优化
|
||||
// clone body 以避免 Bedrock 优化字段泄漏到非 Bedrock provider(failover 场景)
|
||||
let mut provider_body =
|
||||
if self.optimizer_config.enabled && is_bedrock_provider(provider) {
|
||||
let mut b = body.clone();
|
||||
if self.optimizer_config.thinking_optimizer {
|
||||
super::thinking_optimizer::optimize(&mut b, &self.optimizer_config);
|
||||
}
|
||||
if self.optimizer_config.cache_injection {
|
||||
super::cache_injector::inject(&mut b, &self.optimizer_config);
|
||||
}
|
||||
b
|
||||
} else {
|
||||
body.clone()
|
||||
};
|
||||
|
||||
attempted_providers += 1;
|
||||
|
||||
// 更新状态中的当前Provider信息
|
||||
@@ -196,7 +217,13 @@ impl RequestForwarder {
|
||||
|
||||
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
|
||||
match self
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.forward(
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
&headers,
|
||||
adapter.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
@@ -296,7 +323,7 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
// 首次触发:整流请求体
|
||||
let rectified = rectify_anthropic_request(&mut body);
|
||||
let rectified = rectify_anthropic_request(&mut provider_body);
|
||||
|
||||
// 整流未生效:继续尝试 budget 整流路径,避免误判后短路
|
||||
if !rectified.applied {
|
||||
@@ -318,7 +345,13 @@ impl RequestForwarder {
|
||||
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.forward(
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
&headers,
|
||||
adapter.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
@@ -472,7 +505,7 @@ impl RequestForwarder {
|
||||
});
|
||||
}
|
||||
|
||||
let budget_rectified = rectify_thinking_budget(&mut body);
|
||||
let budget_rectified = rectify_thinking_budget(&mut provider_body);
|
||||
if !budget_rectified.applied {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-014] budget 整流器触发但无可整流内容,不做无意义重试"
|
||||
@@ -509,7 +542,13 @@ impl RequestForwarder {
|
||||
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.forward(
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
&headers,
|
||||
adapter.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
@@ -663,13 +702,13 @@ impl RequestForwarder {
|
||||
Some(format!("Provider {} 失败: {}", provider.name, e));
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"[{}] [FWD-001] Provider {} 失败,切换下一个 ({}/{})",
|
||||
app_type_str,
|
||||
provider.name,
|
||||
let (log_code, log_message) = build_retryable_failure_log(
|
||||
&provider.name,
|
||||
attempted_providers,
|
||||
providers.len()
|
||||
providers.len(),
|
||||
&e,
|
||||
);
|
||||
log::warn!("[{app_type_str}] [{log_code}] {log_message}");
|
||||
|
||||
last_error = Some(e);
|
||||
last_provider = Some(provider.clone());
|
||||
@@ -726,7 +765,11 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!("[{app_type_str}] [FWD-002] 所有 Provider 均失败");
|
||||
if let Some((log_code, log_message)) =
|
||||
build_terminal_failure_log(attempted_providers, providers.len(), last_error.as_ref())
|
||||
{
|
||||
log::warn!("[{app_type_str}] [{log_code}] {log_message}");
|
||||
}
|
||||
|
||||
Err(ForwardError {
|
||||
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
|
||||
@@ -749,15 +792,61 @@ impl RequestForwarder {
|
||||
// 检查是否需要格式转换
|
||||
let needs_transform = adapter.needs_transform(provider);
|
||||
|
||||
let effective_endpoint =
|
||||
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
|
||||
"/v1/chat/completions"
|
||||
// 检查 isFullUrl 模式:直接使用 base_url 作为完整 API 端点
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.is_full_url)
|
||||
.unwrap_or(false);
|
||||
|
||||
let url = if is_full_url {
|
||||
// 全链接模式:直接使用 base_url,将客户端 query 追加
|
||||
let query = endpoint.split_once('?').map(|(_, q)| q);
|
||||
match query {
|
||||
Some(q) if !q.is_empty() => {
|
||||
if base_url.contains('?') {
|
||||
format!("{base_url}&{q}")
|
||||
} else {
|
||||
format!("{base_url}?{q}")
|
||||
}
|
||||
}
|
||||
_ => base_url.clone(),
|
||||
}
|
||||
} else {
|
||||
// 正常模式:endpoint 可能带 query string,需拆分路径和参数
|
||||
let effective_endpoint: String = if needs_transform && adapter.name() == "Claude" {
|
||||
let (path, query) = match endpoint.split_once('?') {
|
||||
Some((p, q)) => (p, Some(q)),
|
||||
None => (endpoint, None),
|
||||
};
|
||||
if path == "/v1/messages" || path == "/claude/v1/messages" {
|
||||
// 转换到 OpenAI 兼容端点时剥离 beta=true(Anthropic 专有参数)
|
||||
let api_format = super::providers::get_claude_api_format(provider);
|
||||
let target_path = if api_format == "openai_responses" {
|
||||
"/v1/responses"
|
||||
} else {
|
||||
"/v1/chat/completions"
|
||||
};
|
||||
let filtered = query.map(|q| {
|
||||
q.split('&')
|
||||
.filter(|p| !p.starts_with("beta="))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
});
|
||||
match filtered.as_deref() {
|
||||
Some(q) if !q.is_empty() => format!("{target_path}?{q}"),
|
||||
_ => target_path.to_string(),
|
||||
}
|
||||
} else {
|
||||
endpoint.to_string()
|
||||
}
|
||||
} else {
|
||||
endpoint
|
||||
endpoint.to_string()
|
||||
};
|
||||
|
||||
// 使用适配器构建 URL
|
||||
let url = adapter.build_url(&base_url, effective_endpoint);
|
||||
// 使用适配器构建 URL
|
||||
adapter.build_url(&base_url, &effective_endpoint)
|
||||
};
|
||||
|
||||
// 应用模型映射(独立于格式转换)
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
@@ -927,3 +1016,202 @@ fn extract_error_message(error: &ProxyError) -> Option<String> {
|
||||
_ => Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测 Provider 是否为 Bedrock(通过 CLAUDE_CODE_USE_BEDROCK 环境变量判断)
|
||||
fn is_bedrock_provider(provider: &Provider) -> bool {
|
||||
provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|e| e.get("CLAUDE_CODE_USE_BEDROCK"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn build_retryable_failure_log(
|
||||
provider_name: &str,
|
||||
attempted_providers: usize,
|
||||
total_providers: usize,
|
||||
error: &ProxyError,
|
||||
) -> (&'static str, String) {
|
||||
let error_summary = summarize_proxy_error(error);
|
||||
|
||||
if total_providers <= 1 {
|
||||
(
|
||||
log_fwd::SINGLE_PROVIDER_FAILED,
|
||||
format!("Provider {provider_name} 请求失败: {error_summary}"),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
log_fwd::PROVIDER_FAILED_RETRY,
|
||||
format!(
|
||||
"Provider {provider_name} 失败,继续尝试下一个 ({attempted_providers}/{total_providers}): {error_summary}"
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_terminal_failure_log(
|
||||
attempted_providers: usize,
|
||||
total_providers: usize,
|
||||
last_error: Option<&ProxyError>,
|
||||
) -> Option<(&'static str, String)> {
|
||||
if total_providers <= 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let error_summary = last_error
|
||||
.map(summarize_proxy_error)
|
||||
.unwrap_or_else(|| "未知错误".to_string());
|
||||
|
||||
Some((
|
||||
log_fwd::ALL_PROVIDERS_FAILED,
|
||||
format!(
|
||||
"已尝试 {attempted_providers}/{total_providers} 个 Provider,均失败。最后错误: {error_summary}"
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn summarize_proxy_error(error: &ProxyError) -> String {
|
||||
match error {
|
||||
ProxyError::UpstreamError { status, body } => {
|
||||
let body_summary = body
|
||||
.as_deref()
|
||||
.map(summarize_upstream_body)
|
||||
.filter(|summary| !summary.is_empty());
|
||||
|
||||
match body_summary {
|
||||
Some(summary) => format!("上游 HTTP {status}: {summary}"),
|
||||
None => format!("上游 HTTP {status}"),
|
||||
}
|
||||
}
|
||||
ProxyError::Timeout(message) => {
|
||||
format!("请求超时: {}", summarize_text_for_log(message, 180))
|
||||
}
|
||||
ProxyError::ForwardFailed(message) => {
|
||||
format!("请求转发失败: {}", summarize_text_for_log(message, 180))
|
||||
}
|
||||
ProxyError::TransformError(message) => {
|
||||
format!("响应转换失败: {}", summarize_text_for_log(message, 180))
|
||||
}
|
||||
ProxyError::ConfigError(message) => {
|
||||
format!("配置错误: {}", summarize_text_for_log(message, 180))
|
||||
}
|
||||
ProxyError::AuthError(message) => {
|
||||
format!("认证失败: {}", summarize_text_for_log(message, 180))
|
||||
}
|
||||
_ => summarize_text_for_log(&error.to_string(), 180),
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_upstream_body(body: &str) -> String {
|
||||
if let Ok(json_body) = serde_json::from_str::<Value>(body) {
|
||||
if let Some(message) = extract_json_error_message(&json_body) {
|
||||
return summarize_text_for_log(&message, 180);
|
||||
}
|
||||
|
||||
if let Ok(compact_json) = serde_json::to_string(&json_body) {
|
||||
return summarize_text_for_log(&compact_json, 180);
|
||||
}
|
||||
}
|
||||
|
||||
summarize_text_for_log(body, 180)
|
||||
}
|
||||
|
||||
fn extract_json_error_message(body: &Value) -> Option<String> {
|
||||
let candidates = [
|
||||
body.pointer("/error/message"),
|
||||
body.pointer("/message"),
|
||||
body.pointer("/detail"),
|
||||
body.pointer("/error"),
|
||||
];
|
||||
|
||||
candidates
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|value| value.as_str().map(ToString::to_string))
|
||||
}
|
||||
|
||||
fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
|
||||
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let trimmed = normalized.trim();
|
||||
|
||||
if trimmed.chars().count() <= max_chars {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
|
||||
let truncated: String = trimmed.chars().take(max_chars).collect();
|
||||
let truncated = truncated.trim_end();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn single_provider_retryable_log_uses_single_provider_code() {
|
||||
let error = ProxyError::UpstreamError {
|
||||
status: 429,
|
||||
body: Some(r#"{"error":{"message":"rate limit exceeded"}}"#.to_string()),
|
||||
};
|
||||
|
||||
let (code, message) = build_retryable_failure_log("PackyCode-response", 1, 1, &error);
|
||||
|
||||
assert_eq!(code, log_fwd::SINGLE_PROVIDER_FAILED);
|
||||
assert!(message.contains("Provider PackyCode-response 请求失败"));
|
||||
assert!(message.contains("上游 HTTP 429"));
|
||||
assert!(message.contains("rate limit exceeded"));
|
||||
assert!(!message.contains("切换下一个"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_provider_retryable_log_keeps_failover_wording() {
|
||||
let error = ProxyError::Timeout("upstream timed out after 30s".to_string());
|
||||
|
||||
let (code, message) = build_retryable_failure_log("primary", 1, 3, &error);
|
||||
|
||||
assert_eq!(code, log_fwd::PROVIDER_FAILED_RETRY);
|
||||
assert!(message.contains("继续尝试下一个 (1/3)"));
|
||||
assert!(message.contains("请求超时"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_provider_has_no_terminal_all_failed_log() {
|
||||
assert!(build_terminal_failure_log(1, 1, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_provider_terminal_log_contains_last_error_summary() {
|
||||
let error = ProxyError::ForwardFailed("connection reset by peer".to_string());
|
||||
|
||||
let (code, message) =
|
||||
build_terminal_failure_log(2, 2, Some(&error)).expect("expected terminal log");
|
||||
|
||||
assert_eq!(code, log_fwd::ALL_PROVIDERS_FAILED);
|
||||
assert!(message.contains("已尝试 2/2 个 Provider,均失败"));
|
||||
assert!(message.contains("connection reset by peer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_upstream_body_prefers_json_message() {
|
||||
let body = json!({
|
||||
"error": {
|
||||
"message": "invalid_request_error: unsupported field"
|
||||
},
|
||||
"request_id": "req_123"
|
||||
});
|
||||
|
||||
let summary = summarize_upstream_body(&body.to_string());
|
||||
|
||||
assert_eq!(summary, "invalid_request_error: unsupported field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_text_for_log_collapses_whitespace_and_truncates() {
|
||||
let summary = summarize_text_for_log("line1\n\n line2 line3", 12);
|
||||
|
||||
assert_eq!(summary, "line1 line2...");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::proxy::{
|
||||
extract_session_id,
|
||||
forwarder::RequestForwarder,
|
||||
server::ProxyState,
|
||||
types::{AppProxyConfig, RectifierConfig},
|
||||
types::{AppProxyConfig, OptimizerConfig, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use axum::http::HeaderMap;
|
||||
@@ -59,6 +59,8 @@ pub struct RequestContext {
|
||||
pub session_id: String,
|
||||
/// 整流器配置
|
||||
pub rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
pub optimizer_config: OptimizerConfig,
|
||||
}
|
||||
|
||||
impl RequestContext {
|
||||
@@ -93,6 +95,7 @@ impl RequestContext {
|
||||
|
||||
// 从数据库读取整流器配置
|
||||
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
|
||||
let optimizer_config = state.db.get_optimizer_config().unwrap_or_default();
|
||||
|
||||
let current_provider_id =
|
||||
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||
@@ -156,6 +159,7 @@ impl RequestContext {
|
||||
app_type,
|
||||
session_id,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -216,6 +220,7 @@ impl RequestContext {
|
||||
first_byte_timeout,
|
||||
idle_timeout,
|
||||
self.rectifier_config.clone(),
|
||||
self.optimizer_config.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,11 @@ use super::{
|
||||
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
|
||||
},
|
||||
handler_context::RequestContext,
|
||||
providers::{get_adapter, streaming::create_anthropic_sse_stream, transform},
|
||||
providers::{
|
||||
get_adapter, get_claude_api_format, streaming::create_anthropic_sse_stream,
|
||||
streaming_responses::create_anthropic_sse_stream_from_responses, transform,
|
||||
transform_responses,
|
||||
},
|
||||
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
|
||||
server::ProxyState,
|
||||
types::*,
|
||||
@@ -22,6 +26,7 @@ use super::{
|
||||
};
|
||||
use crate::app_config::AppType;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use bytes::Bytes;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
// ============================================================================
|
||||
@@ -56,12 +61,19 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
|
||||
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
|
||||
pub async fn handle_messages(
|
||||
State(state): State<ProxyState>,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
|
||||
|
||||
// 提取完整路径+query(如 "/v1/messages?beta=true"),透传客户端 URI
|
||||
let endpoint = uri
|
||||
.path_and_query()
|
||||
.map(|pq| pq.as_str())
|
||||
.unwrap_or(uri.path());
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
.and_then(|s| s.as_bool())
|
||||
@@ -72,7 +84,7 @@ pub async fn handle_messages(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Claude,
|
||||
"/v1/messages",
|
||||
endpoint,
|
||||
body.clone(),
|
||||
headers,
|
||||
ctx.get_providers(),
|
||||
@@ -107,7 +119,7 @@ pub async fn handle_messages(
|
||||
|
||||
/// Claude 格式转换处理(独有逻辑)
|
||||
///
|
||||
/// 处理 OpenRouter 旧 OpenAI 兼容接口的回退方案(当前默认不启用)
|
||||
/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换
|
||||
async fn handle_claude_transform(
|
||||
response: reqwest::Response,
|
||||
ctx: &RequestContext,
|
||||
@@ -116,11 +128,18 @@ async fn handle_claude_transform(
|
||||
is_stream: bool,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let status = response.status();
|
||||
let api_format = get_claude_api_format(&ctx.provider);
|
||||
|
||||
if is_stream {
|
||||
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
|
||||
// 根据 api_format 选择流式转换器
|
||||
let stream = response.bytes_stream();
|
||||
let sse_stream = create_anthropic_sse_stream(stream);
|
||||
let sse_stream: Box<
|
||||
dyn futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + Unpin,
|
||||
> = if api_format == "openai_responses" {
|
||||
Box::new(Box::pin(create_anthropic_sse_stream_from_responses(stream)))
|
||||
} else {
|
||||
Box::new(Box::pin(create_anthropic_sse_stream(stream)))
|
||||
};
|
||||
|
||||
// 创建使用量收集器
|
||||
let usage_collector = {
|
||||
@@ -186,7 +205,7 @@ async fn handle_claude_transform(
|
||||
return Ok((headers, body).into_response());
|
||||
}
|
||||
|
||||
// 非流式响应转换 (OpenAI → Anthropic)
|
||||
// 非流式响应转换 (OpenAI/Responses → Anthropic)
|
||||
let response_headers = response.headers().clone();
|
||||
|
||||
let body_bytes = response.bytes().await.map_err(|e| {
|
||||
@@ -196,12 +215,18 @@ async fn handle_claude_transform(
|
||||
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
|
||||
let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||
log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}");
|
||||
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
|
||||
let upstream_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||
log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}");
|
||||
ProxyError::TransformError(format!("Failed to parse upstream response: {e}"))
|
||||
})?;
|
||||
|
||||
let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| {
|
||||
// 根据 api_format 选择非流式转换器
|
||||
let anthropic_response = if api_format == "openai_responses" {
|
||||
transform_responses::responses_to_anthropic(upstream_response)
|
||||
} else {
|
||||
transform::openai_to_anthropic(upstream_response)
|
||||
}
|
||||
.map_err(|e| {
|
||||
log::error!("[Claude] 转换响应失败: {e}");
|
||||
e
|
||||
})?;
|
||||
|
||||
@@ -32,6 +32,7 @@ pub mod srv {
|
||||
pub mod fwd {
|
||||
pub const PROVIDER_FAILED_RETRY: &str = "FWD-001";
|
||||
pub const ALL_PROVIDERS_FAILED: &str = "FWD-002";
|
||||
pub const SINGLE_PROVIDER_FAILED: &str = "FWD-003";
|
||||
}
|
||||
|
||||
/// 故障转移日志码
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
|
||||
|
||||
pub mod body_filter;
|
||||
pub mod cache_injector;
|
||||
pub mod circuit_breaker;
|
||||
pub mod error;
|
||||
pub mod error_mapper;
|
||||
@@ -22,6 +23,7 @@ pub mod response_processor;
|
||||
pub(crate) mod server;
|
||||
pub mod session;
|
||||
pub mod thinking_budget_rectifier;
|
||||
pub mod thinking_optimizer;
|
||||
pub mod thinking_rectifier;
|
||||
pub(crate) mod types;
|
||||
pub mod usage;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Claude (Anthropic) Provider Adapter
|
||||
//!
|
||||
//! 支持透传模式和 OpenAI Chat Completions 格式转换模式
|
||||
//! 支持透传模式和 OpenAI 格式转换模式
|
||||
//!
|
||||
//! ## API 格式
|
||||
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
|
||||
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
|
||||
//! - **openai_responses**: OpenAI Responses API 格式,需要 Anthropic ↔ Responses 转换
|
||||
//!
|
||||
//! ## 认证模式
|
||||
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
||||
@@ -16,6 +17,54 @@ use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
|
||||
/// 获取 Claude 供应商的 API 格式
|
||||
///
|
||||
/// 供 handler/forwarder 外部使用的公开函数。
|
||||
/// 优先级:meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic"
|
||||
pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if let Some(api_format) = meta.api_format.as_deref() {
|
||||
return match api_format {
|
||||
"openai_chat" => "openai_chat",
|
||||
"openai_responses" => "openai_responses",
|
||||
_ => "anthropic",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Backward compatibility: legacy settings_config.api_format
|
||||
if let Some(api_format) = provider
|
||||
.settings_config
|
||||
.get("api_format")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return match api_format {
|
||||
"openai_chat" => "openai_chat",
|
||||
"openai_responses" => "openai_responses",
|
||||
_ => "anthropic",
|
||||
};
|
||||
}
|
||||
|
||||
// 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string)
|
||||
let raw = provider.settings_config.get("openrouter_compat_mode");
|
||||
let enabled = match raw {
|
||||
Some(serde_json::Value::Bool(v)) => *v,
|
||||
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
|
||||
Some(serde_json::Value::String(value)) => {
|
||||
let normalized = value.trim().to_lowercase();
|
||||
normalized == "true" || normalized == "1"
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if enabled {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude 适配器
|
||||
pub struct ClaudeAdapter;
|
||||
|
||||
@@ -57,48 +106,9 @@ impl ClaudeAdapter {
|
||||
/// 从 provider.meta.api_format 读取格式设置:
|
||||
/// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
/// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||||
fn get_api_format(&self, provider: &Provider) -> &'static str {
|
||||
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if let Some(api_format) = meta.api_format.as_deref() {
|
||||
return if api_format == "openai_chat" {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Backward compatibility: legacy settings_config.api_format
|
||||
if let Some(api_format) = provider
|
||||
.settings_config
|
||||
.get("api_format")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return if api_format == "openai_chat" {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
};
|
||||
}
|
||||
|
||||
// 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string)
|
||||
let raw = provider.settings_config.get("openrouter_compat_mode");
|
||||
let enabled = match raw {
|
||||
Some(serde_json::Value::Bool(v)) => *v,
|
||||
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
|
||||
Some(serde_json::Value::String(value)) => {
|
||||
let normalized = value.trim().to_lowercase();
|
||||
normalized == "true" || normalized == "1"
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if enabled {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
}
|
||||
get_claude_api_format(provider)
|
||||
}
|
||||
|
||||
/// 检测是否为仅 Bearer 认证模式
|
||||
@@ -251,6 +261,8 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
//
|
||||
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||
//
|
||||
// ?beta=true 不再由代理注入,而是由客户端自身发送并透传。
|
||||
|
||||
let mut base = format!(
|
||||
"{}/{}",
|
||||
@@ -263,19 +275,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
base = base.replace("/v1/v1", "/v1");
|
||||
}
|
||||
|
||||
// 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数
|
||||
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
|
||||
// 注意:不要为 OpenAI Chat Completions (/v1/chat/completions) 添加此参数
|
||||
// 当 apiFormat="openai_chat" 时,请求会转发到 /v1/chat/completions,
|
||||
// 但该端点是 OpenAI 标准,不支持 ?beta=true 参数
|
||||
if endpoint.contains("/v1/messages")
|
||||
&& !endpoint.contains("/v1/chat/completions")
|
||||
&& !endpoint.contains('?')
|
||||
{
|
||||
format!("{base}?beta=true")
|
||||
} else {
|
||||
base
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
@@ -301,20 +301,45 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
fn needs_transform(&self, provider: &Provider) -> bool {
|
||||
// 根据 api_format 配置决定是否需要格式转换
|
||||
// - "anthropic" (默认): 直接透传,无需转换
|
||||
// - "openai_chat": 需要 Anthropic ↔ OpenAI 格式转换
|
||||
self.get_api_format(provider) == "openai_chat"
|
||||
// - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换
|
||||
// - "openai_responses": 需要 Anthropic ↔ OpenAI Responses API 格式转换
|
||||
matches!(
|
||||
self.get_api_format(provider),
|
||||
"openai_chat" | "openai_responses"
|
||||
)
|
||||
}
|
||||
|
||||
fn transform_request(
|
||||
&self,
|
||||
body: serde_json::Value,
|
||||
_provider: &Provider,
|
||||
provider: &Provider,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
super::transform::anthropic_to_openai(body)
|
||||
// Use meta.prompt_cache_key if set by user, otherwise fall back to provider.id
|
||||
let cache_key = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
.unwrap_or(&provider.id);
|
||||
|
||||
match self.get_api_format(provider) {
|
||||
"openai_responses" => {
|
||||
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
|
||||
}
|
||||
_ => super::transform::anthropic_to_openai(body, Some(cache_key)),
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
||||
super::transform::openai_to_anthropic(body)
|
||||
// Heuristic: detect response format by presence of top-level fields.
|
||||
// The ProviderAdapter trait's transform_response doesn't receive the Provider
|
||||
// config, so we can't check api_format here. Instead we rely on the fact that
|
||||
// Responses API always returns "output" while Chat Completions returns "choices".
|
||||
// This is safe because the two formats are structurally disjoint.
|
||||
if body.get("output").is_some() {
|
||||
super::transform_responses::responses_to_anthropic(body)
|
||||
} else {
|
||||
super::transform::openai_to_anthropic(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,23 +512,21 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_url_anthropic() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// /v1/messages 端点会自动添加 ?beta=true 参数
|
||||
// ?beta=true 不再由代理注入,而是由客户端自身发送并透传
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_openrouter() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// /v1/messages 端点会自动添加 ?beta=true 参数
|
||||
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
|
||||
assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true");
|
||||
assert_eq!(url, "https://openrouter.ai/api/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_beta_for_other_endpoints() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// 非 /v1/messages 端点不添加 ?beta=true
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/complete");
|
||||
}
|
||||
@@ -511,16 +534,14 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_url_preserve_existing_query() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// 已有查询参数时不重复添加
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
|
||||
// 客户端携带的 query 参数原样透传
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?beta=true");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_beta_for_openai_chat_completions() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// OpenAI Chat Completions 端点不添加 ?beta=true
|
||||
// 这是 Nvidia 等 apiFormat="openai_chat" 供应商使用的端点
|
||||
let url = adapter.build_url("https://integrate.api.nvidia.com", "/v1/chat/completions");
|
||||
assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions");
|
||||
}
|
||||
@@ -599,6 +620,20 @@ mod tests {
|
||||
);
|
||||
assert!(adapter.needs_transform(&openai_chat_provider));
|
||||
|
||||
// OpenAI Responses format in meta: needs transform
|
||||
let openai_responses_provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("openai_responses".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(adapter.needs_transform(&openai_responses_provider));
|
||||
|
||||
// meta takes precedence over legacy settings_config fields
|
||||
let meta_precedence_over_settings = create_provider_with_meta(
|
||||
json!({
|
||||
|
||||
@@ -18,7 +18,9 @@ mod codex;
|
||||
mod gemini;
|
||||
pub mod models;
|
||||
pub mod streaming;
|
||||
pub mod streaming_responses;
|
||||
pub mod transform;
|
||||
pub mod transform_responses;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::provider::Provider;
|
||||
@@ -27,7 +29,7 @@ use serde::{Deserialize, Serialize};
|
||||
// 公开导出
|
||||
pub use adapter::ProviderAdapter;
|
||||
pub use auth::{AuthInfo, AuthStrategy};
|
||||
pub use claude::ClaudeAdapter;
|
||||
pub use claude::{get_claude_api_format, ClaudeAdapter};
|
||||
pub use codex::CodexAdapter;
|
||||
pub use gemini::GeminiAdapter;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// OpenAI 流式响应数据结构
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -60,6 +61,29 @@ struct Usage {
|
||||
prompt_tokens: u32,
|
||||
#[serde(default)]
|
||||
completion_tokens: u32,
|
||||
#[serde(default)]
|
||||
prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
/// Some compatible servers return Anthropic-style cache fields directly
|
||||
#[serde(default)]
|
||||
cache_read_input_tokens: Option<u32>,
|
||||
#[serde(default)]
|
||||
cache_creation_input_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
/// Nested token details from OpenAI format
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PromptTokensDetails {
|
||||
#[serde(default)]
|
||||
cached_tokens: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ToolBlockState {
|
||||
anthropic_index: u32,
|
||||
id: String,
|
||||
name: String,
|
||||
started: bool,
|
||||
pending_args: String,
|
||||
}
|
||||
|
||||
/// 创建 Anthropic SSE 流
|
||||
@@ -70,10 +94,12 @@ pub fn create_anthropic_sse_stream(
|
||||
let mut buffer = String::new();
|
||||
let mut message_id = None;
|
||||
let mut current_model = None;
|
||||
let mut content_index = 0;
|
||||
let mut next_content_index: u32 = 0;
|
||||
let mut has_sent_message_start = false;
|
||||
let mut current_block_type: Option<String> = None;
|
||||
let mut tool_call_id = None;
|
||||
let mut current_non_tool_block_type: Option<&'static str> = None;
|
||||
let mut current_non_tool_block_index: Option<u32> = None;
|
||||
let mut tool_blocks_by_index: HashMap<usize, ToolBlockState> = HashMap::new();
|
||||
let mut open_tool_block_indices: HashSet<u32> = HashSet::new();
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
@@ -115,6 +141,21 @@ pub fn create_anthropic_sse_stream(
|
||||
|
||||
if let Some(choice) = chunk.choices.first() {
|
||||
if !has_sent_message_start {
|
||||
// Build usage with cache tokens if available from first chunk
|
||||
let mut start_usage = json!({
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
});
|
||||
if let Some(u) = &chunk.usage {
|
||||
start_usage["input_tokens"] = json!(u.prompt_tokens);
|
||||
if let Some(cached) = extract_cache_read_tokens(u) {
|
||||
start_usage["cache_read_input_tokens"] = json!(cached);
|
||||
}
|
||||
if let Some(created) = u.cache_creation_input_tokens {
|
||||
start_usage["cache_creation_input_tokens"] = json!(created);
|
||||
}
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
@@ -122,10 +163,7 @@ pub fn create_anthropic_sse_stream(
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": current_model.clone().unwrap_or_default(),
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
}
|
||||
"usage": start_usage
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: message_start\ndata: {}\n\n",
|
||||
@@ -136,10 +174,21 @@ pub fn create_anthropic_sse_stream(
|
||||
|
||||
// 处理 reasoning(thinking)
|
||||
if let Some(reasoning) = &choice.delta.reasoning {
|
||||
if current_block_type.is_none() {
|
||||
if current_non_tool_block_type != Some("thinking") {
|
||||
if let Some(index) = current_non_tool_block_index.take() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
let index = next_content_index;
|
||||
next_content_index += 1;
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": content_index,
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "thinking",
|
||||
"thinking": ""
|
||||
@@ -148,57 +197,17 @@ pub fn create_anthropic_sse_stream(
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
current_block_type = Some("thinking".to_string());
|
||||
current_non_tool_block_type = Some("thinking");
|
||||
current_non_tool_block_index = Some(index);
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": content_index,
|
||||
"delta": {
|
||||
"type": "thinking_delta",
|
||||
"thinking": reasoning
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
|
||||
// 处理文本内容
|
||||
if let Some(content) = &choice.delta.content {
|
||||
if !content.is_empty() {
|
||||
if current_block_type.as_deref() != Some("text") {
|
||||
if current_block_type.is_some() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": content_index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
content_index += 1;
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": content_index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
current_block_type = Some("text".to_string());
|
||||
}
|
||||
|
||||
if let Some(index) = current_non_tool_block_index {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": content_index,
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "text_delta",
|
||||
"text": content
|
||||
"type": "thinking_delta",
|
||||
"thinking": reasoning
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
@@ -207,76 +216,288 @@ pub fn create_anthropic_sse_stream(
|
||||
}
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if let Some(tool_calls) = &choice.delta.tool_calls {
|
||||
for tool_call in tool_calls {
|
||||
if let Some(id) = &tool_call.id {
|
||||
if current_block_type.is_some() {
|
||||
// 处理文本内容
|
||||
if let Some(content) = &choice.delta.content {
|
||||
if !content.is_empty() {
|
||||
if current_non_tool_block_type != Some("text") {
|
||||
if let Some(index) = current_non_tool_block_index.take() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": content_index
|
||||
"index": index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
content_index += 1;
|
||||
}
|
||||
|
||||
tool_call_id = Some(id.clone());
|
||||
let index = next_content_index;
|
||||
next_content_index += 1;
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
current_non_tool_block_type = Some("text");
|
||||
current_non_tool_block_index = Some(index);
|
||||
}
|
||||
|
||||
if let Some(function) = &tool_call.function {
|
||||
if let Some(name) = &function.name {
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": content_index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": tool_call_id.clone().unwrap_or_default(),
|
||||
"name": name
|
||||
if let Some(index) = current_non_tool_block_index {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "text_delta",
|
||||
"text": content
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if let Some(tool_calls) = &choice.delta.tool_calls {
|
||||
if let Some(index) = current_non_tool_block_index.take() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
current_non_tool_block_type = None;
|
||||
|
||||
for tool_call in tool_calls {
|
||||
let (
|
||||
anthropic_index,
|
||||
id,
|
||||
name,
|
||||
should_start,
|
||||
pending_after_start,
|
||||
immediate_delta,
|
||||
) = {
|
||||
let state = tool_blocks_by_index
|
||||
.entry(tool_call.index)
|
||||
.or_insert_with(|| {
|
||||
let index = next_content_index;
|
||||
next_content_index += 1;
|
||||
ToolBlockState {
|
||||
anthropic_index: index,
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
started: false,
|
||||
pending_args: String::new(),
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
current_block_type = Some("tool_use".to_string());
|
||||
|
||||
if let Some(id) = &tool_call.id {
|
||||
state.id = id.clone();
|
||||
}
|
||||
if let Some(function) = &tool_call.function {
|
||||
if let Some(name) = &function.name {
|
||||
state.name = name.clone();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(args) = &function.arguments {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": content_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": args
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
let should_start =
|
||||
!state.started
|
||||
&& !state.id.is_empty()
|
||||
&& !state.name.is_empty();
|
||||
if should_start {
|
||||
state.started = true;
|
||||
}
|
||||
let pending_after_start = if should_start
|
||||
&& !state.pending_args.is_empty()
|
||||
{
|
||||
Some(std::mem::take(&mut state.pending_args))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let args_delta = tool_call
|
||||
.function
|
||||
.as_ref()
|
||||
.and_then(|f| f.arguments.clone());
|
||||
let immediate_delta = if let Some(args) = args_delta {
|
||||
if state.started {
|
||||
Some(args)
|
||||
} else {
|
||||
state.pending_args.push_str(&args);
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(
|
||||
state.anthropic_index,
|
||||
state.id.clone(),
|
||||
state.name.clone(),
|
||||
should_start,
|
||||
pending_after_start,
|
||||
immediate_delta,
|
||||
)
|
||||
};
|
||||
|
||||
if should_start {
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": anthropic_index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": id,
|
||||
"name": name
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
open_tool_block_indices.insert(anthropic_index);
|
||||
}
|
||||
|
||||
if let Some(args) = pending_after_start {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": anthropic_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": args
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
|
||||
if let Some(args) = immediate_delta {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": anthropic_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": args
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 finish_reason
|
||||
if let Some(finish_reason) = &choice.finish_reason {
|
||||
if current_block_type.is_some() {
|
||||
if let Some(index) = current_non_tool_block_index.take() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": content_index
|
||||
"index": index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
current_non_tool_block_type = None;
|
||||
|
||||
// Late start for blocks that accumulated args before id/name arrived.
|
||||
let mut late_tool_starts: Vec<(u32, String, String, String)> =
|
||||
Vec::new();
|
||||
for (tool_idx, state) in tool_blocks_by_index.iter_mut() {
|
||||
if state.started {
|
||||
continue;
|
||||
}
|
||||
let has_payload = !state.pending_args.is_empty()
|
||||
|| !state.id.is_empty()
|
||||
|| !state.name.is_empty();
|
||||
if !has_payload {
|
||||
continue;
|
||||
}
|
||||
let fallback_id = if state.id.is_empty() {
|
||||
format!("tool_call_{tool_idx}")
|
||||
} else {
|
||||
state.id.clone()
|
||||
};
|
||||
let fallback_name = if state.name.is_empty() {
|
||||
"unknown_tool".to_string()
|
||||
} else {
|
||||
state.name.clone()
|
||||
};
|
||||
state.started = true;
|
||||
let pending = std::mem::take(&mut state.pending_args);
|
||||
late_tool_starts.push((
|
||||
state.anthropic_index,
|
||||
fallback_id,
|
||||
fallback_name,
|
||||
pending,
|
||||
));
|
||||
}
|
||||
late_tool_starts.sort_unstable_by_key(|(index, _, _, _)| *index);
|
||||
for (index, id, name, pending) in late_tool_starts {
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": id,
|
||||
"name": name
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
open_tool_block_indices.insert(index);
|
||||
if !pending.is_empty() {
|
||||
let delta_event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": pending
|
||||
}
|
||||
});
|
||||
let delta_sse = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&delta_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(delta_sse));
|
||||
}
|
||||
}
|
||||
|
||||
if !open_tool_block_indices.is_empty() {
|
||||
let mut tool_indices: Vec<u32> =
|
||||
open_tool_block_indices.iter().copied().collect();
|
||||
tool_indices.sort_unstable();
|
||||
for index in tool_indices {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
open_tool_block_indices.clear();
|
||||
}
|
||||
|
||||
let stop_reason = map_stop_reason(Some(finish_reason));
|
||||
// 构建 usage 信息,包含 input_tokens 和 output_tokens
|
||||
let usage_json = chunk.usage.as_ref().map(|u| json!({
|
||||
"input_tokens": u.prompt_tokens,
|
||||
"output_tokens": u.completion_tokens
|
||||
}));
|
||||
// Build usage with cache token fields
|
||||
let usage_json = chunk.usage.as_ref().map(|u| {
|
||||
let mut uj = json!({
|
||||
"input_tokens": u.prompt_tokens,
|
||||
"output_tokens": u.completion_tokens
|
||||
});
|
||||
if let Some(cached) = extract_cache_read_tokens(u) {
|
||||
uj["cache_read_input_tokens"] = json!(cached);
|
||||
}
|
||||
if let Some(created) = u.cache_creation_input_tokens {
|
||||
uj["cache_creation_input_tokens"] = json!(created);
|
||||
}
|
||||
uj
|
||||
});
|
||||
let event = json!({
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
@@ -314,15 +535,210 @@ pub fn create_anthropic_sse_stream(
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract cache_read tokens from Usage, checking both direct field and nested details
|
||||
fn extract_cache_read_tokens(usage: &Usage) -> Option<u32> {
|
||||
// Direct field takes priority (compatible servers)
|
||||
if let Some(v) = usage.cache_read_input_tokens {
|
||||
return Some(v);
|
||||
}
|
||||
// OpenAI standard: prompt_tokens_details.cached_tokens
|
||||
usage
|
||||
.prompt_tokens_details
|
||||
.as_ref()
|
||||
.map(|d| d.cached_tokens)
|
||||
.filter(|&v| v > 0)
|
||||
}
|
||||
|
||||
/// 映射停止原因
|
||||
fn map_stop_reason(finish_reason: Option<&str>) -> Option<String> {
|
||||
finish_reason.map(|r| {
|
||||
match r {
|
||||
"tool_calls" => "tool_use",
|
||||
"tool_calls" | "function_call" => "tool_use",
|
||||
"stop" => "end_turn",
|
||||
"length" => "max_tokens",
|
||||
_ => "end_turn",
|
||||
"content_filter" => "end_turn",
|
||||
other => {
|
||||
log::warn!("[Claude/OpenRouter] Unknown finish_reason in streaming: {other}");
|
||||
"end_turn"
|
||||
}
|
||||
}
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::stream;
|
||||
use futures::StreamExt;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_map_stop_reason_legacy_and_filtered_values() {
|
||||
assert_eq!(
|
||||
map_stop_reason(Some("function_call")),
|
||||
Some("tool_use".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_stop_reason(Some("content_filter")),
|
||||
Some("end_turn".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_tool_calls_routed_by_index() {
|
||||
let input = concat!(
|
||||
"data: {\"id\":\"chatcmpl_1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"first_tool\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"second_tool\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"{\\\"b\\\":2}\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"a\\\":1}\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":8,\"completion_tokens\":4}}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let converted = create_anthropic_sse_stream(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
let events: Vec<Value> = merged
|
||||
.split("\n\n")
|
||||
.filter_map(|block| {
|
||||
let data = block.lines().find_map(|line| line.strip_prefix("data: "))?;
|
||||
serde_json::from_str::<Value>(data).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut tool_index_by_call: HashMap<String, u64> = HashMap::new();
|
||||
for event in &events {
|
||||
if event.get("type").and_then(|v| v.as_str()) == Some("content_block_start")
|
||||
&& event
|
||||
.pointer("/content_block/type")
|
||||
.and_then(|v| v.as_str())
|
||||
== Some("tool_use")
|
||||
{
|
||||
if let (Some(call_id), Some(index)) = (
|
||||
event.pointer("/content_block/id").and_then(|v| v.as_str()),
|
||||
event.get("index").and_then(|v| v.as_u64()),
|
||||
) {
|
||||
tool_index_by_call.insert(call_id.to_string(), index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(tool_index_by_call.len(), 2);
|
||||
assert_ne!(
|
||||
tool_index_by_call.get("call_0"),
|
||||
tool_index_by_call.get("call_1")
|
||||
);
|
||||
|
||||
let deltas: Vec<(u64, String)> = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_delta")
|
||||
&& event.pointer("/delta/type").and_then(|v| v.as_str())
|
||||
== Some("input_json_delta")
|
||||
})
|
||||
.filter_map(|event| {
|
||||
let index = event.get("index").and_then(|v| v.as_u64())?;
|
||||
let partial_json = event
|
||||
.pointer("/delta/partial_json")
|
||||
.and_then(|v| v.as_str())?
|
||||
.to_string();
|
||||
Some((index, partial_json))
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(deltas.len(), 2);
|
||||
let second_idx = deltas
|
||||
.iter()
|
||||
.find_map(|(index, payload)| (payload == "{\"b\":2}").then_some(*index))
|
||||
.unwrap();
|
||||
let first_idx = deltas
|
||||
.iter()
|
||||
.find_map(|(index, payload)| (payload == "{\"a\":1}").then_some(*index))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(second_idx, *tool_index_by_call.get("call_1").unwrap());
|
||||
assert_eq!(first_idx, *tool_index_by_call.get("call_0").unwrap());
|
||||
|
||||
assert!(events.iter().any(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("message_delta")
|
||||
&& event.pointer("/delta/stop_reason").and_then(|v| v.as_str()) == Some("tool_use")
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_delays_tool_start_until_id_and_name_ready() {
|
||||
let input = concat!(
|
||||
"data: {\"id\":\"chatcmpl_2\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"a\\\":\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_2\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"first_tool\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_2\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"1}\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_2\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":6,\"completion_tokens\":2}}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let converted = create_anthropic_sse_stream(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
let events: Vec<Value> = merged
|
||||
.split("\n\n")
|
||||
.filter_map(|block| {
|
||||
let data = block.lines().find_map(|line| line.strip_prefix("data: "))?;
|
||||
serde_json::from_str::<Value>(data).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let starts: Vec<&Value> = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_start")
|
||||
&& event
|
||||
.pointer("/content_block/type")
|
||||
.and_then(|v| v.as_str())
|
||||
== Some("tool_use")
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(
|
||||
starts[0]
|
||||
.pointer("/content_block/id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(""),
|
||||
"call_0"
|
||||
);
|
||||
assert_eq!(
|
||||
starts[0]
|
||||
.pointer("/content_block/name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(""),
|
||||
"first_tool"
|
||||
);
|
||||
|
||||
let deltas: Vec<&str> = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_delta")
|
||||
&& event.pointer("/delta/type").and_then(|v| v.as_str())
|
||||
== Some("input_json_delta")
|
||||
})
|
||||
.filter_map(|event| {
|
||||
event
|
||||
.pointer("/delta/partial_json")
|
||||
.and_then(|v| v.as_str())
|
||||
})
|
||||
.collect();
|
||||
assert!(deltas.contains(&"{\"a\":"));
|
||||
assert!(deltas.contains(&"1}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,902 @@
|
||||
//! OpenAI Responses API 流式转换模块
|
||||
//!
|
||||
//! 实现 Responses API SSE → Anthropic SSE 格式转换。
|
||||
//!
|
||||
//! Responses API 使用命名事件 (named events) 的生命周期模型:
|
||||
//! response.created → output_item.added → content_part.added →
|
||||
//! output_text.delta → content_part.done → output_item.done → response.completed
|
||||
//!
|
||||
//! 与 Chat Completions 的 delta chunk 模型完全不同,需要独立的状态机处理。
|
||||
|
||||
use super::transform_responses::{build_anthropic_usage_from_responses, map_responses_stop_reason};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[inline]
|
||||
fn response_object_from_event(data: &Value) -> &Value {
|
||||
data.get("response").unwrap_or(data)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn content_part_key(data: &Value) -> Option<String> {
|
||||
if let (Some(item_id), Some(content_index)) = (
|
||||
data.get("item_id").and_then(|v| v.as_str()),
|
||||
data.get("content_index").and_then(|v| v.as_u64()),
|
||||
) {
|
||||
return Some(format!("part:{item_id}:{content_index}"));
|
||||
}
|
||||
if let (Some(output_index), Some(content_index)) = (
|
||||
data.get("output_index").and_then(|v| v.as_u64()),
|
||||
data.get("content_index").and_then(|v| v.as_u64()),
|
||||
) {
|
||||
return Some(format!("part:out:{output_index}:{content_index}"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn tool_item_key_from_added(data: &Value, item: &Value) -> Option<String> {
|
||||
if let Some(item_id) = item.get("id").and_then(|v| v.as_str()) {
|
||||
return Some(format!("tool:{item_id}"));
|
||||
}
|
||||
if let Some(item_id) = data.get("item_id").and_then(|v| v.as_str()) {
|
||||
return Some(format!("tool:{item_id}"));
|
||||
}
|
||||
if let Some(output_index) = data.get("output_index").and_then(|v| v.as_u64()) {
|
||||
return Some(format!("tool:out:{output_index}"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn tool_item_key_from_event(data: &Value) -> Option<String> {
|
||||
if let Some(item_id) = data.get("item_id").and_then(|v| v.as_str()) {
|
||||
return Some(format!("tool:{item_id}"));
|
||||
}
|
||||
if let Some(output_index) = data.get("output_index").and_then(|v| v.as_u64()) {
|
||||
return Some(format!("tool:out:{output_index}"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve content index for a text/refusal content part event.
|
||||
///
|
||||
/// Uses `content_part_key` to look up or assign a stable index, falling back to
|
||||
/// `fallback_open_index` when no key is available.
|
||||
#[inline]
|
||||
fn resolve_content_index(
|
||||
data: &Value,
|
||||
next_content_index: &mut u32,
|
||||
index_by_key: &mut HashMap<String, u32>,
|
||||
fallback_open_index: &mut Option<u32>,
|
||||
) -> u32 {
|
||||
if let Some(k) = content_part_key(data) {
|
||||
if let Some(existing) = index_by_key.get(&k).copied() {
|
||||
existing
|
||||
} else {
|
||||
let assigned = *next_content_index;
|
||||
*next_content_index += 1;
|
||||
index_by_key.insert(k, assigned);
|
||||
assigned
|
||||
}
|
||||
} else if let Some(existing) = *fallback_open_index {
|
||||
existing
|
||||
} else {
|
||||
let assigned = *next_content_index;
|
||||
*next_content_index += 1;
|
||||
*fallback_open_index = Some(assigned);
|
||||
assigned
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建从 Responses API SSE 到 Anthropic SSE 的转换流
|
||||
///
|
||||
/// 状态机跟踪: message_id, current_model, has_sent_message_start, item/content index map
|
||||
/// SSE 解析支持 named events (event: + data: 行)
|
||||
pub fn create_anthropic_sse_stream_from_responses(
|
||||
stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut message_id: Option<String> = None;
|
||||
let mut current_model: Option<String> = None;
|
||||
let mut has_sent_message_start = false;
|
||||
let mut has_tool_use = false;
|
||||
let mut next_content_index: u32 = 0;
|
||||
let mut index_by_key: HashMap<String, u32> = HashMap::new();
|
||||
let mut open_indices: HashSet<u32> = HashSet::new();
|
||||
let mut fallback_open_index: Option<u32> = None;
|
||||
let mut tool_index_by_item_id: HashMap<String, u32> = HashMap::new();
|
||||
let mut last_tool_index: Option<u32> = None;
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
|
||||
// SSE 事件由 \n\n 分隔
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let block = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
if block.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析 SSE 块:提取 event: 和 data: 行
|
||||
let mut event_type: Option<String> = None;
|
||||
let mut data_parts: Vec<String> = Vec::new();
|
||||
|
||||
for line in block.lines() {
|
||||
if let Some(evt) = line.strip_prefix("event: ") {
|
||||
event_type = Some(evt.trim().to_string());
|
||||
} else if let Some(d) = line.strip_prefix("data: ") {
|
||||
data_parts.push(d.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if data_parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data_str = data_parts.join("\n");
|
||||
let event_name = event_type.as_deref().unwrap_or("");
|
||||
|
||||
// 解析 JSON 数据
|
||||
let data: Value = match serde_json::from_str(&data_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
log::debug!("[Claude/Responses] <<< SSE event: {event_name}");
|
||||
|
||||
match event_name {
|
||||
// ================================================
|
||||
// response.created → message_start
|
||||
// ================================================
|
||||
"response.created" => {
|
||||
let response_obj = response_object_from_event(&data);
|
||||
if let Some(id) = response_obj.get("id").and_then(|i| i.as_str()) {
|
||||
message_id = Some(id.to_string());
|
||||
}
|
||||
if let Some(model) =
|
||||
response_obj.get("model").and_then(|m| m.as_str())
|
||||
{
|
||||
current_model = Some(model.to_string());
|
||||
}
|
||||
|
||||
has_sent_message_start = true;
|
||||
// Build usage with cache tokens if available
|
||||
let start_usage = build_anthropic_usage_from_responses(
|
||||
response_obj.get("usage"),
|
||||
);
|
||||
|
||||
let event = json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": message_id.clone().unwrap_or_default(),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": current_model.clone().unwrap_or_default(),
|
||||
"usage": start_usage
|
||||
}
|
||||
});
|
||||
let sse = format!("event: message_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
log::debug!("[Claude/Responses] >>> Anthropic SSE: message_start");
|
||||
yield Ok(Bytes::from(sse));
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.content_part.added → content_block_start (text)
|
||||
// ================================================
|
||||
"response.content_part.added" => {
|
||||
// 确保 message_start 已发送
|
||||
if !has_sent_message_start {
|
||||
let start_event = json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": message_id.clone().unwrap_or_default(),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": current_model.clone().unwrap_or_default(),
|
||||
"usage": { "input_tokens": 0, "output_tokens": 0 }
|
||||
}
|
||||
});
|
||||
let sse = format!("event: message_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&start_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
has_sent_message_start = true;
|
||||
}
|
||||
|
||||
if let Some(part) = data.get("part") {
|
||||
let part_type = part.get("type").and_then(|t| t.as_str());
|
||||
if matches!(part_type, Some("output_text") | Some("refusal")) {
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
|
||||
if open_indices.contains(&index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
});
|
||||
let sse = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
open_indices.insert(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.output_text.delta → content_block_delta (text_delta)
|
||||
// ================================================
|
||||
"response.output_text.delta" => {
|
||||
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
|
||||
if !open_indices.contains(&index) {
|
||||
let start_event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
});
|
||||
let start_sse = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&start_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(start_sse));
|
||||
open_indices.insert(index);
|
||||
}
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "text_delta",
|
||||
"text": delta
|
||||
}
|
||||
});
|
||||
let sse = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.refusal.delta → content_block_delta (text_delta)
|
||||
// ================================================
|
||||
"response.refusal.delta" => {
|
||||
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
|
||||
if !open_indices.contains(&index) {
|
||||
let start_event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
});
|
||||
let start_sse = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&start_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(start_sse));
|
||||
open_indices.insert(index);
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "text_delta",
|
||||
"text": delta
|
||||
}
|
||||
});
|
||||
let sse = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.content_part.done → content_block_stop
|
||||
// ================================================
|
||||
"response.content_part.done" => {
|
||||
let key = content_part_key(&data);
|
||||
let index = if let Some(k) = key {
|
||||
index_by_key.get(&k).copied()
|
||||
} else {
|
||||
fallback_open_index
|
||||
};
|
||||
if let Some(index) = index {
|
||||
if !open_indices.remove(&index) {
|
||||
continue;
|
||||
}
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
if fallback_open_index == Some(index) {
|
||||
fallback_open_index = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.output_item.added (function_call) → content_block_start (tool_use)
|
||||
// ================================================
|
||||
"response.output_item.added" => {
|
||||
if let Some(item) = data.get("item") {
|
||||
let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if item_type == "function_call" {
|
||||
has_tool_use = true;
|
||||
// 确保 message_start 已发送
|
||||
if !has_sent_message_start {
|
||||
let start_event = json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": message_id.clone().unwrap_or_default(),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": current_model.clone().unwrap_or_default(),
|
||||
"usage": { "input_tokens": 0, "output_tokens": 0 }
|
||||
}
|
||||
});
|
||||
let sse = format!("event: message_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&start_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
has_sent_message_start = true;
|
||||
}
|
||||
|
||||
let call_id = item.get("call_id").and_then(|i| i.as_str()).unwrap_or("");
|
||||
let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
let index = if let Some(k) = tool_item_key_from_added(&data, item) {
|
||||
if let Some(existing) = index_by_key.get(&k).copied() {
|
||||
existing
|
||||
} else {
|
||||
let assigned = next_content_index;
|
||||
next_content_index += 1;
|
||||
index_by_key.insert(k, assigned);
|
||||
assigned
|
||||
}
|
||||
} else {
|
||||
let assigned = next_content_index;
|
||||
next_content_index += 1;
|
||||
assigned
|
||||
};
|
||||
if let Some(item_id) = item
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| data.get("item_id").and_then(|v| v.as_str()))
|
||||
{
|
||||
tool_index_by_item_id.insert(item_id.to_string(), index);
|
||||
}
|
||||
last_tool_index = Some(index);
|
||||
|
||||
if open_indices.contains(&index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": call_id,
|
||||
"name": name
|
||||
}
|
||||
});
|
||||
let sse = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
open_indices.insert(index);
|
||||
}
|
||||
// message type output_item.added is handled via content_part.added
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.function_call_arguments.delta → content_block_delta (input_json_delta)
|
||||
// ================================================
|
||||
"response.function_call_arguments.delta" => {
|
||||
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
|
||||
let item_id = data.get("item_id").and_then(|v| v.as_str());
|
||||
let index = if let Some(id) = item_id {
|
||||
tool_index_by_item_id.get(id).copied()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.or_else(|| {
|
||||
tool_item_key_from_event(&data)
|
||||
.and_then(|k| index_by_key.get(&k).copied())
|
||||
})
|
||||
.or(last_tool_index)
|
||||
.unwrap_or_else(|| {
|
||||
let assigned = next_content_index;
|
||||
next_content_index += 1;
|
||||
assigned
|
||||
});
|
||||
|
||||
if !open_indices.contains(&index) {
|
||||
let start_event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": data
|
||||
.get("call_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.or(item_id)
|
||||
.unwrap_or(""),
|
||||
"name": data
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
}
|
||||
});
|
||||
let start_sse = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&start_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(start_sse));
|
||||
open_indices.insert(index);
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": delta
|
||||
}
|
||||
});
|
||||
let sse = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.function_call_arguments.done → content_block_stop
|
||||
// ================================================
|
||||
"response.function_call_arguments.done" => {
|
||||
let item_id = data.get("item_id").and_then(|v| v.as_str());
|
||||
let index = if let Some(id) = item_id {
|
||||
tool_index_by_item_id.get(id).copied()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.or_else(|| {
|
||||
tool_item_key_from_event(&data)
|
||||
.and_then(|k| index_by_key.get(&k).copied())
|
||||
})
|
||||
.or(last_tool_index);
|
||||
if let Some(index) = index {
|
||||
if !open_indices.remove(&index) {
|
||||
continue;
|
||||
}
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
if let Some(item_id) = item_id {
|
||||
tool_index_by_item_id.remove(item_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.refusal.done → content_block_stop
|
||||
// ================================================
|
||||
"response.refusal.done" => {
|
||||
let key = content_part_key(&data);
|
||||
let index = if let Some(k) = key {
|
||||
index_by_key.get(&k).copied()
|
||||
} else {
|
||||
fallback_open_index
|
||||
};
|
||||
if let Some(index) = index {
|
||||
if !open_indices.remove(&index) {
|
||||
continue;
|
||||
}
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
if fallback_open_index == Some(index) {
|
||||
fallback_open_index = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.reasoning.delta → content_block_delta (thinking_delta)
|
||||
// ================================================
|
||||
"response.reasoning.delta" => {
|
||||
if let Some(delta) = data
|
||||
.get("delta")
|
||||
.or_else(|| data.get("text"))
|
||||
.and_then(|d| d.as_str())
|
||||
{
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
|
||||
if !open_indices.contains(&index) {
|
||||
let start_event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "thinking",
|
||||
"thinking": ""
|
||||
}
|
||||
});
|
||||
let start_sse = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&start_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(start_sse));
|
||||
open_indices.insert(index);
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "thinking_delta",
|
||||
"thinking": delta
|
||||
}
|
||||
});
|
||||
let sse = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.reasoning.done → content_block_stop
|
||||
// ================================================
|
||||
"response.reasoning.done" => {
|
||||
let key = content_part_key(&data);
|
||||
let index = if let Some(k) = key {
|
||||
index_by_key.get(&k).copied()
|
||||
} else {
|
||||
fallback_open_index
|
||||
};
|
||||
if let Some(index) = index {
|
||||
if !open_indices.remove(&index) {
|
||||
continue;
|
||||
}
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
if fallback_open_index == Some(index) {
|
||||
fallback_open_index = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.completed → message_delta + message_stop
|
||||
// ================================================
|
||||
"response.completed" => {
|
||||
let response_obj = response_object_from_event(&data);
|
||||
let stop_reason = map_responses_stop_reason(
|
||||
response_obj.get("status").and_then(|s| s.as_str()),
|
||||
has_tool_use,
|
||||
response_obj
|
||||
.pointer("/incomplete_details/reason")
|
||||
.and_then(|r| r.as_str()),
|
||||
);
|
||||
|
||||
// Best effort: close any dangling blocks before message_delta/message_stop.
|
||||
if !open_indices.is_empty() {
|
||||
let mut remaining: Vec<u32> = open_indices.iter().copied().collect();
|
||||
remaining.sort_unstable();
|
||||
for index in remaining {
|
||||
let stop_event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&stop_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(stop_sse));
|
||||
open_indices.remove(&index);
|
||||
}
|
||||
}
|
||||
fallback_open_index = None;
|
||||
|
||||
let usage_json = response_obj.get("usage").map(|u| {
|
||||
build_anthropic_usage_from_responses(Some(u))
|
||||
});
|
||||
|
||||
// Emit message_delta (with usage + stop_reason)
|
||||
let delta_event = json!({
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": null
|
||||
},
|
||||
"usage": usage_json
|
||||
});
|
||||
let sse = format!("event: message_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&delta_event).unwrap_or_default());
|
||||
log::debug!("[Claude/Responses] >>> Anthropic SSE: message_delta");
|
||||
yield Ok(Bytes::from(sse));
|
||||
|
||||
// Emit message_stop
|
||||
let stop_event = json!({"type": "message_stop"});
|
||||
let stop_sse = format!("event: message_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&stop_event).unwrap_or_default());
|
||||
log::debug!("[Claude/Responses] >>> Anthropic SSE: message_stop");
|
||||
yield Ok(Bytes::from(stop_sse));
|
||||
}
|
||||
|
||||
// Lifecycle events that don't need Anthropic counterparts.
|
||||
// Listed explicitly so new events trigger a match-completeness review.
|
||||
"response.output_text.done"
|
||||
| "response.output_item.done"
|
||||
| "response.in_progress" => {}
|
||||
|
||||
// Any other unknown/future events — silently skip.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Responses stream error: {e}");
|
||||
let error_event = json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "stream_error",
|
||||
"message": format!("Stream error: {e}")
|
||||
}
|
||||
});
|
||||
let sse = format!("event: error\ndata: {}\n\n",
|
||||
serde_json::to_string(&error_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::stream;
|
||||
use futures::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_map_responses_stop_reason_tool_use() {
|
||||
assert_eq!(
|
||||
map_responses_stop_reason(Some("completed"), true, None),
|
||||
Some("tool_use")
|
||||
);
|
||||
assert_eq!(
|
||||
map_responses_stop_reason(Some("completed"), false, None),
|
||||
Some("end_turn")
|
||||
);
|
||||
assert_eq!(
|
||||
map_responses_stop_reason(Some("incomplete"), false, Some("max_output_tokens")),
|
||||
Some("max_tokens")
|
||||
);
|
||||
assert_eq!(
|
||||
map_responses_stop_reason(Some("incomplete"), false, Some("content_filter")),
|
||||
Some("end_turn")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_object_from_event_with_wrapper() {
|
||||
let data = json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_1",
|
||||
"model": "gpt-4o"
|
||||
}
|
||||
});
|
||||
let obj = response_object_from_event(&data);
|
||||
assert_eq!(obj["id"], "resp_1");
|
||||
assert_eq!(obj["model"], "gpt-4o");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_conversion_with_wrapped_response_events() {
|
||||
let input = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-4o\",\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n",
|
||||
"event: response.output_item.added\n",
|
||||
"data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"get_weather\"}}\n\n",
|
||||
"event: response.function_call_arguments.delta\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"city\\\":\\\"Tokyo\\\"}\"}\n\n",
|
||||
"event: response.function_call_arguments.done\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.done\"}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":3}}}\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
assert!(merged.contains("\"type\":\"message_start\""));
|
||||
assert!(merged.contains("\"id\":\"resp_1\""));
|
||||
assert!(merged.contains("\"model\":\"gpt-4o\""));
|
||||
assert!(merged.contains("\"type\":\"tool_use\""));
|
||||
assert!(merged.contains("\"name\":\"get_weather\""));
|
||||
assert!(merged.contains("\"type\":\"input_json_delta\""));
|
||||
assert!(merged.contains("\"stop_reason\":\"tool_use\""));
|
||||
assert!(merged.contains("\"input_tokens\":12"));
|
||||
assert!(merged.contains("\"output_tokens\":3"));
|
||||
assert!(merged.contains("\"type\":\"message_stop\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_conversion_interleaved_tool_deltas_by_item_id() {
|
||||
let input = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_2\",\"model\":\"gpt-4o\"}}\n\n",
|
||||
"event: response.output_item.added\n",
|
||||
"data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_1\",\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"first_tool\"}}\n\n",
|
||||
"event: response.output_item.added\n",
|
||||
"data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_2\",\"type\":\"function_call\",\"call_id\":\"call_2\",\"name\":\"second_tool\"}}\n\n",
|
||||
"event: response.function_call_arguments.delta\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_2\",\"delta\":\"{\\\"b\\\":2}\"}\n\n",
|
||||
"event: response.function_call_arguments.delta\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_1\",\"delta\":\"{\\\"a\\\":1}\"}\n\n",
|
||||
"event: response.function_call_arguments.done\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.done\",\"item_id\":\"fc_1\"}\n\n",
|
||||
"event: response.function_call_arguments.done\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.done\",\"item_id\":\"fc_2\"}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":8,\"output_tokens\":4}}}\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
let events: Vec<Value> = merged
|
||||
.split("\n\n")
|
||||
.filter_map(|block| {
|
||||
let data = block.lines().find_map(|line| line.strip_prefix("data: "))?;
|
||||
serde_json::from_str::<Value>(data).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut tool_index_by_call: HashMap<String, u64> = HashMap::new();
|
||||
for event in &events {
|
||||
if event.get("type").and_then(|v| v.as_str()) == Some("content_block_start") {
|
||||
let cb = event.get("content_block");
|
||||
if cb.and_then(|v| v.get("type")).and_then(|v| v.as_str()) == Some("tool_use") {
|
||||
if let (Some(call_id), Some(index)) = (
|
||||
cb.and_then(|v| v.get("id")).and_then(|v| v.as_str()),
|
||||
event.get("index").and_then(|v| v.as_u64()),
|
||||
) {
|
||||
tool_index_by_call.insert(call_id.to_string(), index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let delta_indices: Vec<u64> = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_delta")
|
||||
&& event.pointer("/delta/type").and_then(|v| v.as_str())
|
||||
== Some("input_json_delta")
|
||||
})
|
||||
.filter_map(|event| event.get("index").and_then(|v| v.as_u64()))
|
||||
.collect();
|
||||
|
||||
assert_eq!(delta_indices.len(), 2);
|
||||
assert_eq!(delta_indices[0], *tool_index_by_call.get("call_2").unwrap());
|
||||
assert_eq!(delta_indices[1], *tool_index_by_call.get("call_1").unwrap());
|
||||
assert_ne!(
|
||||
tool_index_by_call.get("call_1"),
|
||||
tool_index_by_call.get("call_2")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_reasoning_delta_emits_thinking_blocks() {
|
||||
let input = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_r\",\"model\":\"o3\",\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}\n\n",
|
||||
"event: response.reasoning.delta\n",
|
||||
"data: {\"type\":\"response.reasoning.delta\",\"delta\":\"Let me think...\"}\n\n",
|
||||
"event: response.reasoning.done\n",
|
||||
"data: {\"type\":\"response.reasoning.done\"}\n\n",
|
||||
"event: response.content_part.added\n",
|
||||
"data: {\"type\":\"response.content_part.added\",\"part\":{\"type\":\"output_text\",\"text\":\"\"},\"output_index\":0,\"content_index\":0}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"42\",\"output_index\":0,\"content_index\":0}\n\n",
|
||||
"event: response.content_part.done\n",
|
||||
"data: {\"type\":\"response.content_part.done\",\"output_index\":0,\"content_index\":0}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":10}}}\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
// Should contain thinking block start, thinking delta, and text content
|
||||
assert!(
|
||||
merged.contains("\"type\":\"thinking\""),
|
||||
"should emit thinking content_block_start"
|
||||
);
|
||||
assert!(
|
||||
merged.contains("\"type\":\"thinking_delta\""),
|
||||
"should emit thinking_delta"
|
||||
);
|
||||
assert!(
|
||||
merged.contains("\"thinking\":\"Let me think...\""),
|
||||
"should contain thinking text"
|
||||
);
|
||||
assert!(
|
||||
merged.contains("\"type\":\"text_delta\""),
|
||||
"should also emit text content"
|
||||
);
|
||||
assert!(
|
||||
merged.contains("\"text\":\"42\""),
|
||||
"should contain text delta"
|
||||
);
|
||||
assert!(merged.contains("\"stop_reason\":\"end_turn\""));
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ use crate::proxy::error::ProxyError;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Anthropic 请求 → OpenAI 请求
|
||||
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
///
|
||||
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
|
||||
pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
|
||||
@@ -23,10 +25,14 @@ pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
// 单个字符串
|
||||
messages.push(json!({"role": "system", "content": text}));
|
||||
} else if let Some(arr) = system.as_array() {
|
||||
// 多个 system message
|
||||
// 多个 system message — preserve cache_control for compatible proxies
|
||||
for msg in arr {
|
||||
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
|
||||
messages.push(json!({"role": "system", "content": text}));
|
||||
let mut sys_msg = json!({"role": "system", "content": text});
|
||||
if let Some(cc) = msg.get("cache_control") {
|
||||
sys_msg["cache_control"] = cc.clone();
|
||||
}
|
||||
messages.push(sys_msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,14 +73,18 @@ pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
.iter()
|
||||
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
|
||||
.map(|t| {
|
||||
json!({
|
||||
let mut tool = json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
|
||||
"description": t.get("description"),
|
||||
"parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({})))
|
||||
}
|
||||
})
|
||||
});
|
||||
if let Some(cc) = t.get("cache_control") {
|
||||
tool["cache_control"] = cc.clone();
|
||||
}
|
||||
tool
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -87,6 +97,11 @@ pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
result["tool_choice"] = v.clone();
|
||||
}
|
||||
|
||||
// Inject prompt_cache_key for improved cache routing on OpenAI-compatible endpoints
|
||||
if let Some(key) = cache_key {
|
||||
result["prompt_cache_key"] = json!(key);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -122,7 +137,11 @@ fn convert_message_to_openai(
|
||||
match block_type {
|
||||
"text" => {
|
||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||
content_parts.push(json!({"type": "text", "text": text}));
|
||||
let mut part = json!({"type": "text", "text": text});
|
||||
if let Some(cc) = block.get("cache_control") {
|
||||
part["cache_control"] = cc.clone();
|
||||
}
|
||||
content_parts.push(part);
|
||||
}
|
||||
}
|
||||
"image" => {
|
||||
@@ -184,8 +203,14 @@ fn convert_message_to_openai(
|
||||
if content_parts.is_empty() {
|
||||
msg["content"] = Value::Null;
|
||||
} else if content_parts.len() == 1 {
|
||||
if let Some(text) = content_parts[0].get("text") {
|
||||
msg["content"] = text.clone();
|
||||
// When cache_control is present, keep array format to preserve it
|
||||
let has_cache_control = content_parts[0].get("cache_control").is_some();
|
||||
if !has_cache_control {
|
||||
if let Some(text) = content_parts[0].get("text") {
|
||||
msg["content"] = text.clone();
|
||||
} else {
|
||||
msg["content"] = json!(content_parts);
|
||||
}
|
||||
} else {
|
||||
msg["content"] = json!(content_parts);
|
||||
}
|
||||
@@ -210,7 +235,7 @@ fn convert_message_to_openai(
|
||||
}
|
||||
|
||||
/// 清理 JSON schema(移除不支持的 format)
|
||||
fn clean_schema(mut schema: Value) -> Value {
|
||||
pub fn clean_schema(mut schema: Value) -> Value {
|
||||
if let Some(obj) = schema.as_object_mut() {
|
||||
// 移除 "format": "uri"
|
||||
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
|
||||
@@ -247,16 +272,49 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
.ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?;
|
||||
|
||||
let mut content = Vec::new();
|
||||
let mut has_tool_use = false;
|
||||
|
||||
// 文本内容
|
||||
if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
|
||||
if !text.is_empty() {
|
||||
content.push(json!({"type": "text", "text": text}));
|
||||
// 文本/拒绝内容
|
||||
if let Some(msg_content) = message.get("content") {
|
||||
if let Some(text) = msg_content.as_str() {
|
||||
if !text.is_empty() {
|
||||
content.push(json!({"type": "text", "text": text}));
|
||||
}
|
||||
} else if let Some(parts) = msg_content.as_array() {
|
||||
for part in parts {
|
||||
let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
match part_type {
|
||||
"text" | "output_text" => {
|
||||
if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
|
||||
if !text.is_empty() {
|
||||
content.push(json!({"type": "text", "text": text}));
|
||||
}
|
||||
}
|
||||
}
|
||||
"refusal" => {
|
||||
if let Some(refusal) = part.get("refusal").and_then(|r| r.as_str()) {
|
||||
if !refusal.is_empty() {
|
||||
content.push(json!({"type": "text", "text": refusal}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Some providers put refusal at message-level.
|
||||
if let Some(refusal) = message.get("refusal").and_then(|r| r.as_str()) {
|
||||
if !refusal.is_empty() {
|
||||
content.push(json!({"type": "text", "text": refusal}));
|
||||
}
|
||||
}
|
||||
|
||||
// 工具调用
|
||||
// 工具调用(tool_calls)
|
||||
if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
|
||||
if !tool_calls.is_empty() {
|
||||
has_tool_use = true;
|
||||
}
|
||||
for tc in tool_calls {
|
||||
let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
||||
let empty_obj = json!({});
|
||||
@@ -276,6 +334,36 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
}));
|
||||
}
|
||||
}
|
||||
// 兼容旧格式(function_call)
|
||||
if !has_tool_use {
|
||||
if let Some(function_call) = message.get("function_call") {
|
||||
let id = function_call
|
||||
.get("id")
|
||||
.and_then(|i| i.as_str())
|
||||
.unwrap_or("");
|
||||
let name = function_call
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("");
|
||||
let has_arguments = function_call.get("arguments").is_some();
|
||||
|
||||
let input = match function_call.get("arguments") {
|
||||
Some(Value::String(s)) => serde_json::from_str(s).unwrap_or(json!({})),
|
||||
Some(v @ Value::Object(_)) | Some(v @ Value::Array(_)) => v.clone(),
|
||||
_ => json!({}),
|
||||
};
|
||||
|
||||
if !name.is_empty() || has_arguments {
|
||||
content.push(json!({
|
||||
"type": "tool_use",
|
||||
"id": id,
|
||||
"name": name,
|
||||
"input": input
|
||||
}));
|
||||
has_tool_use = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 映射 finish_reason → stop_reason
|
||||
let stop_reason = choice
|
||||
@@ -284,11 +372,18 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
.map(|r| match r {
|
||||
"stop" => "end_turn",
|
||||
"length" => "max_tokens",
|
||||
"tool_calls" => "tool_use",
|
||||
other => other,
|
||||
});
|
||||
"tool_calls" | "function_call" => "tool_use",
|
||||
"content_filter" => "end_turn",
|
||||
other => {
|
||||
log::warn!(
|
||||
"[Claude/OpenAI] Unknown finish_reason in non-streaming response: {other}"
|
||||
);
|
||||
"end_turn"
|
||||
}
|
||||
})
|
||||
.or(if has_tool_use { Some("tool_use") } else { None });
|
||||
|
||||
// usage
|
||||
// usage — map cache tokens from OpenAI format to Anthropic format
|
||||
let usage = body.get("usage").cloned().unwrap_or(json!({}));
|
||||
let input_tokens = usage
|
||||
.get("prompt_tokens")
|
||||
@@ -299,6 +394,26 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
let mut usage_json = json!({
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens
|
||||
});
|
||||
|
||||
// OpenAI standard: prompt_tokens_details.cached_tokens
|
||||
if let Some(cached) = usage
|
||||
.pointer("/prompt_tokens_details/cached_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
{
|
||||
usage_json["cache_read_input_tokens"] = json!(cached);
|
||||
}
|
||||
// Some compatible servers return these fields directly
|
||||
if let Some(v) = usage.get("cache_read_input_tokens") {
|
||||
usage_json["cache_read_input_tokens"] = v.clone();
|
||||
}
|
||||
if let Some(v) = usage.get("cache_creation_input_tokens") {
|
||||
usage_json["cache_creation_input_tokens"] = v.clone();
|
||||
}
|
||||
|
||||
let result = json!({
|
||||
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
|
||||
"type": "message",
|
||||
@@ -307,10 +422,7 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
"model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""),
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens
|
||||
}
|
||||
"usage": usage_json
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
@@ -328,7 +440,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["model"], "claude-3-opus");
|
||||
assert_eq!(result["max_tokens"], 1024);
|
||||
assert_eq!(result["messages"][0]["role"], "user");
|
||||
@@ -344,7 +456,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
@@ -366,7 +478,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["tools"][0]["type"], "function");
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||
}
|
||||
@@ -385,7 +497,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "assistant");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
@@ -405,7 +517,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "tool");
|
||||
assert_eq!(msg["tool_call_id"], "call_123");
|
||||
@@ -477,7 +589,187 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["model"], "gpt-4o");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_with_cache_key() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, Some("provider-123")).unwrap();
|
||||
assert_eq!(result["prompt_cache_key"], "provider-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_no_cache_key() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert!(result.get("prompt_cache_key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_cache_control_preserved() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "System prompt", "cache_control": {"type": "ephemeral"}}
|
||||
],
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
]
|
||||
}],
|
||||
"tools": [{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"input_schema": {"type": "object"},
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
// System message cache_control preserved
|
||||
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
|
||||
// Text block cache_control preserved
|
||||
assert_eq!(
|
||||
result["messages"][1]["content"][0]["cache_control"]["type"],
|
||||
"ephemeral"
|
||||
);
|
||||
assert_eq!(
|
||||
result["messages"][1]["content"][0]["cache_control"]["ttl"],
|
||||
"5m"
|
||||
);
|
||||
// Tool cache_control preserved
|
||||
assert_eq!(result["tools"][0]["cache_control"]["type"], "ephemeral");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_with_cache_tokens() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Hello!"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 80
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["usage"]["input_tokens"], 100);
|
||||
assert_eq!(result["usage"]["output_tokens"], 50);
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_with_direct_cache_fields() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Hello!"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"cache_read_input_tokens": 60,
|
||||
"cache_creation_input_tokens": 20
|
||||
}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
|
||||
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_finish_reason_content_filter_maps_end_turn() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Blocked"},
|
||||
"finish_reason": "content_filter"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 1}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["stop_reason"], "end_turn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_with_legacy_function_call() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"function_call": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"location\":\"Tokyo\"}"
|
||||
}
|
||||
},
|
||||
"finish_reason": "function_call"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["content"][0]["type"], "tool_use");
|
||||
assert_eq!(result["content"][0]["name"], "get_weather");
|
||||
assert_eq!(result["content"][0]["input"]["location"], "Tokyo");
|
||||
assert_eq!(result["stop_reason"], "tool_use");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_with_content_parts_and_refusal() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello"},
|
||||
{"type": "refusal", "refusal": "I can't do that"}
|
||||
]
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["content"][0]["type"], "text");
|
||||
assert_eq!(result["content"][0]["text"], "Hello");
|
||||
assert_eq!(result["content"][1]["type"], "text");
|
||||
assert_eq!(result["content"][1]["text"], "I can't do that");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,900 @@
|
||||
//! OpenAI Responses API 格式转换模块
|
||||
//!
|
||||
//! 实现 Anthropic Messages ↔ OpenAI Responses API 格式转换。
|
||||
//! Responses API 是 OpenAI 2025 年推出的新一代 API,采用扁平化的 input/output 结构。
|
||||
//!
|
||||
//! 与 Chat Completions 的主要差异:
|
||||
//! - tool_use/tool_result 从 message content 中"提升"为顶层 input item
|
||||
//! - system prompt 使用 `instructions` 字段而非 system role message
|
||||
//! - usage 字段命名与 Anthropic 一致 (input_tokens/output_tokens)
|
||||
|
||||
use crate::proxy::error::ProxyError;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Anthropic 请求 → OpenAI Responses 请求
|
||||
///
|
||||
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
|
||||
pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
|
||||
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
|
||||
result["model"] = json!(model);
|
||||
}
|
||||
|
||||
// system → instructions (Responses API 使用 instructions 字段)
|
||||
if let Some(system) = body.get("system") {
|
||||
let instructions = if let Some(text) = system.as_str() {
|
||||
text.to_string()
|
||||
} else if let Some(arr) = system.as_array() {
|
||||
arr.iter()
|
||||
.filter_map(|msg| msg.get("text").and_then(|t| t.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if !instructions.is_empty() {
|
||||
result["instructions"] = json!(instructions);
|
||||
}
|
||||
}
|
||||
|
||||
// messages → input
|
||||
if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) {
|
||||
let input = convert_messages_to_input(msgs)?;
|
||||
result["input"] = json!(input);
|
||||
}
|
||||
|
||||
// max_tokens → max_output_tokens
|
||||
if let Some(v) = body.get("max_tokens") {
|
||||
result["max_output_tokens"] = v.clone();
|
||||
}
|
||||
|
||||
// 直接透传的参数
|
||||
if let Some(v) = body.get("temperature") {
|
||||
result["temperature"] = v.clone();
|
||||
}
|
||||
if let Some(v) = body.get("top_p") {
|
||||
result["top_p"] = v.clone();
|
||||
}
|
||||
if let Some(v) = body.get("stream") {
|
||||
result["stream"] = v.clone();
|
||||
}
|
||||
|
||||
// stop_sequences → 丢弃 (Responses API 不支持)
|
||||
|
||||
// 转换 tools (过滤 BatchTool)
|
||||
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
|
||||
let response_tools: Vec<Value> = tools
|
||||
.iter()
|
||||
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
|
||||
.map(|t| {
|
||||
json!({
|
||||
"type": "function",
|
||||
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
|
||||
"description": t.get("description"),
|
||||
"parameters": super::transform::clean_schema(
|
||||
t.get("input_schema").cloned().unwrap_or(json!({}))
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !response_tools.is_empty() {
|
||||
result["tools"] = json!(response_tools);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(v) = body.get("tool_choice") {
|
||||
result["tool_choice"] = map_tool_choice_to_responses(v);
|
||||
}
|
||||
|
||||
// Inject prompt_cache_key for improved cache routing on OpenAI-compatible endpoints
|
||||
if let Some(key) = cache_key {
|
||||
result["prompt_cache_key"] = json!(key);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn map_tool_choice_to_responses(tool_choice: &Value) -> Value {
|
||||
match tool_choice {
|
||||
Value::String(_) => tool_choice.clone(),
|
||||
Value::Object(obj) => match obj.get("type").and_then(|t| t.as_str()) {
|
||||
// Anthropic "any" means at least one tool call is required
|
||||
Some("any") => json!("required"),
|
||||
Some("auto") => json!("auto"),
|
||||
Some("none") => json!("none"),
|
||||
// Anthropic forced tool -> Responses function tool selector
|
||||
Some("tool") => {
|
||||
let name = obj.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
json!({
|
||||
"type": "function",
|
||||
"name": name
|
||||
})
|
||||
}
|
||||
_ => tool_choice.clone(),
|
||||
},
|
||||
_ => tool_choice.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_responses_stop_reason(
|
||||
status: Option<&str>,
|
||||
has_tool_use: bool,
|
||||
incomplete_reason: Option<&str>,
|
||||
) -> Option<&'static str> {
|
||||
status.map(|s| match s {
|
||||
"completed" => {
|
||||
if has_tool_use {
|
||||
"tool_use"
|
||||
} else {
|
||||
"end_turn"
|
||||
}
|
||||
}
|
||||
"incomplete" => {
|
||||
if matches!(
|
||||
incomplete_reason,
|
||||
Some("max_output_tokens") | Some("max_tokens")
|
||||
) || incomplete_reason.is_none()
|
||||
{
|
||||
"max_tokens"
|
||||
} else {
|
||||
"end_turn"
|
||||
}
|
||||
}
|
||||
_ => "end_turn",
|
||||
})
|
||||
}
|
||||
|
||||
/// Build Anthropic-style usage JSON from Responses API usage, including cache tokens.
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. OpenAI nested details (`input_tokens_details.cached_tokens`, `prompt_tokens_details.cached_tokens`) as initial value
|
||||
/// 2. Direct Anthropic-style fields (`cache_read_input_tokens`, `cache_creation_input_tokens`) override if present
|
||||
pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Value {
|
||||
let u = match usage {
|
||||
Some(v) if !v.is_null() => v,
|
||||
_ => {
|
||||
return json!({
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let input = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let output = u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
|
||||
let mut result = json!({
|
||||
"input_tokens": input,
|
||||
"output_tokens": output
|
||||
});
|
||||
|
||||
// Step 1: OpenAI nested details as fallback
|
||||
// OpenAI Responses API: input_tokens_details.cached_tokens
|
||||
if let Some(cached) = u
|
||||
.pointer("/input_tokens_details/cached_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
{
|
||||
result["cache_read_input_tokens"] = json!(cached);
|
||||
}
|
||||
// OpenAI standard: prompt_tokens_details.cached_tokens
|
||||
if let Some(cached) = u
|
||||
.pointer("/prompt_tokens_details/cached_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
{
|
||||
if result.get("cache_read_input_tokens").is_none() {
|
||||
result["cache_read_input_tokens"] = json!(cached);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Direct Anthropic-style fields override (authoritative if present)
|
||||
if let Some(v) = u.get("cache_read_input_tokens") {
|
||||
result["cache_read_input_tokens"] = v.clone();
|
||||
}
|
||||
if let Some(v) = u.get("cache_creation_input_tokens") {
|
||||
result["cache_creation_input_tokens"] = v.clone();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 将 Anthropic messages 数组转换为 Responses API input 数组
|
||||
///
|
||||
/// 核心转换逻辑:
|
||||
/// - user/assistant 的 text 内容 → 对应 role 的 message item
|
||||
/// - tool_use 从 assistant message 中"提升"为独立的 function_call item
|
||||
/// - tool_result 从 user message 中"提升"为独立的 function_call_output item
|
||||
/// - thinking blocks → 丢弃
|
||||
fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyError> {
|
||||
let mut input = Vec::new();
|
||||
|
||||
for msg in messages {
|
||||
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
|
||||
let content = msg.get("content");
|
||||
|
||||
match content {
|
||||
// 字符串内容
|
||||
Some(Value::String(text)) => {
|
||||
let content_type = if role == "assistant" {
|
||||
"output_text"
|
||||
} else {
|
||||
"input_text"
|
||||
};
|
||||
input.push(json!({
|
||||
"role": role,
|
||||
"content": [{ "type": content_type, "text": text }]
|
||||
}));
|
||||
}
|
||||
|
||||
// 数组内容(多模态/工具调用)
|
||||
Some(Value::Array(blocks)) => {
|
||||
let mut message_content = Vec::new();
|
||||
|
||||
for block in blocks {
|
||||
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
match block_type {
|
||||
"text" => {
|
||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||
let content_type = if role == "assistant" {
|
||||
"output_text"
|
||||
} else {
|
||||
"input_text"
|
||||
};
|
||||
// OpenAI Responses API does not accept Anthropic cache_control
|
||||
// under input[].content[].
|
||||
message_content.push(json!({ "type": content_type, "text": text }));
|
||||
}
|
||||
}
|
||||
|
||||
"image" => {
|
||||
if let Some(source) = block.get("source") {
|
||||
let media_type = source
|
||||
.get("media_type")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("image/png");
|
||||
let data =
|
||||
source.get("data").and_then(|d| d.as_str()).unwrap_or("");
|
||||
message_content.push(json!({
|
||||
"type": "input_image",
|
||||
"image_url": format!("data:{media_type};base64,{data}")
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
"tool_use" => {
|
||||
// 先刷新已累积的消息内容
|
||||
if !message_content.is_empty() {
|
||||
input.push(json!({
|
||||
"role": role,
|
||||
"content": message_content.clone()
|
||||
}));
|
||||
message_content.clear();
|
||||
}
|
||||
|
||||
// 提升为独立的 function_call item
|
||||
let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
||||
let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
let arguments = block.get("input").cloned().unwrap_or(json!({}));
|
||||
|
||||
input.push(json!({
|
||||
"type": "function_call",
|
||||
"call_id": id,
|
||||
"name": name,
|
||||
"arguments": serde_json::to_string(&arguments).unwrap_or_default()
|
||||
}));
|
||||
}
|
||||
|
||||
"tool_result" => {
|
||||
// 先刷新已累积的消息内容
|
||||
if !message_content.is_empty() {
|
||||
input.push(json!({
|
||||
"role": role,
|
||||
"content": message_content.clone()
|
||||
}));
|
||||
message_content.clear();
|
||||
}
|
||||
|
||||
// 提升为独立的 function_call_output item
|
||||
let call_id = block
|
||||
.get("tool_use_id")
|
||||
.and_then(|i| i.as_str())
|
||||
.unwrap_or("");
|
||||
let output = match block.get("content") {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(v) => serde_json::to_string(v).unwrap_or_default(),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
input.push(json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
"output": output
|
||||
}));
|
||||
}
|
||||
|
||||
"thinking" => {
|
||||
// 丢弃 thinking blocks(与 openai_chat 一致)
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新剩余的消息内容
|
||||
if !message_content.is_empty() {
|
||||
input.push(json!({
|
||||
"role": role,
|
||||
"content": message_content
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
_ => {
|
||||
// 无内容或 null
|
||||
input.push(json!({ "role": role }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
/// OpenAI Responses 响应 → Anthropic 响应
|
||||
pub fn responses_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
let output = body
|
||||
.get("output")
|
||||
.and_then(|o| o.as_array())
|
||||
.ok_or_else(|| ProxyError::TransformError("No output in response".to_string()))?;
|
||||
|
||||
let mut content = Vec::new();
|
||||
|
||||
let mut has_tool_use = false;
|
||||
for item in output {
|
||||
let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
match item_type {
|
||||
"message" => {
|
||||
if let Some(msg_content) = item.get("content").and_then(|c| c.as_array()) {
|
||||
for block in msg_content {
|
||||
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if block_type == "output_text" {
|
||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||
if !text.is_empty() {
|
||||
content.push(json!({"type": "text", "text": text}));
|
||||
}
|
||||
}
|
||||
} else if block_type == "refusal" {
|
||||
if let Some(refusal) = block.get("refusal").and_then(|t| t.as_str()) {
|
||||
if !refusal.is_empty() {
|
||||
content.push(json!({"type": "text", "text": refusal}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"function_call" => {
|
||||
let call_id = item.get("call_id").and_then(|i| i.as_str()).unwrap_or("");
|
||||
let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
let args_str = item
|
||||
.get("arguments")
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("{}");
|
||||
let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
|
||||
|
||||
content.push(json!({
|
||||
"type": "tool_use",
|
||||
"id": call_id,
|
||||
"name": name,
|
||||
"input": input
|
||||
}));
|
||||
has_tool_use = true;
|
||||
}
|
||||
|
||||
"reasoning" => {
|
||||
// 映射 reasoning summary → thinking block
|
||||
if let Some(summary) = item.get("summary").and_then(|s| s.as_array()) {
|
||||
let thinking_text: String = summary
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
if s.get("type").and_then(|t| t.as_str()) == Some("summary_text") {
|
||||
s.get("text").and_then(|t| t.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
if !thinking_text.is_empty() {
|
||||
content.push(json!({
|
||||
"type": "thinking",
|
||||
"thinking": thinking_text
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// status → stop_reason
|
||||
let stop_reason = map_responses_stop_reason(
|
||||
body.get("status").and_then(|s| s.as_str()),
|
||||
has_tool_use,
|
||||
body.pointer("/incomplete_details/reason")
|
||||
.and_then(|r| r.as_str()),
|
||||
);
|
||||
|
||||
let usage_json = build_anthropic_usage_from_responses(body.get("usage"));
|
||||
|
||||
let result = json!({
|
||||
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""),
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": null,
|
||||
"usage": usage_json
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_simple() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["model"], "gpt-4o");
|
||||
assert_eq!(result["max_output_tokens"], 1024);
|
||||
assert_eq!(result["input"][0]["role"], "user");
|
||||
assert_eq!(result["input"][0]["content"][0]["type"], "input_text");
|
||||
assert_eq!(result["input"][0]["content"][0]["text"], "Hello");
|
||||
// stop_sequences should not appear
|
||||
assert!(result.get("stop_sequences").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_with_system_string() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"system": "You are a helpful assistant.",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["instructions"], "You are a helpful assistant.");
|
||||
// system should not appear in input
|
||||
assert_eq!(result["input"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_with_system_array() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "Part 1"},
|
||||
{"type": "text", "text": "Part 2"}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["instructions"], "Part 1\n\nPart 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_with_tools() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Weather?"}],
|
||||
"tools": [{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather info",
|
||||
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["tools"][0]["type"], "function");
|
||||
assert_eq!(result["tools"][0]["name"], "get_weather");
|
||||
assert!(result["tools"][0].get("parameters").is_some());
|
||||
// input_schema should not appear
|
||||
assert!(result["tools"][0].get("input_schema").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_tool_choice_any_to_required() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Weather?"}],
|
||||
"tool_choice": {"type": "any"}
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["tool_choice"], "required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_tool_choice_tool_to_function() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Weather?"}],
|
||||
"tool_choice": {"type": "tool", "name": "get_weather"}
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["tool_choice"]["type"], "function");
|
||||
assert_eq!(result["tool_choice"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_tool_use_lifting() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "Let me check"},
|
||||
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// Should produce: assistant message (text) + function_call item
|
||||
assert_eq!(input_arr.len(), 2);
|
||||
|
||||
// First: assistant message with output_text
|
||||
assert_eq!(input_arr[0]["role"], "assistant");
|
||||
assert_eq!(input_arr[0]["content"][0]["type"], "output_text");
|
||||
assert_eq!(input_arr[0]["content"][0]["text"], "Let me check");
|
||||
|
||||
// Second: function_call item (lifted from message)
|
||||
assert_eq!(input_arr[1]["type"], "function_call");
|
||||
assert_eq!(input_arr[1]["call_id"], "call_123");
|
||||
assert_eq!(input_arr[1]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_tool_result_lifting() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "call_123", "content": "Sunny, 25°C"}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// Should produce: function_call_output item (lifted)
|
||||
assert_eq!(input_arr.len(), 1);
|
||||
assert_eq!(input_arr[0]["type"], "function_call_output");
|
||||
assert_eq!(input_arr[0]["call_id"], "call_123");
|
||||
assert_eq!(input_arr[0]["output"], "Sunny, 25°C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_thinking_discarded() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Let me think..."},
|
||||
{"type": "text", "text": "The answer is 42"}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// thinking should be discarded, only text remains
|
||||
assert_eq!(input_arr.len(), 1);
|
||||
assert_eq!(input_arr[0]["content"][0]["type"], "output_text");
|
||||
assert_eq!(input_arr[0]["content"][0]["text"], "The answer is 42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_image() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What is this?"},
|
||||
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "abc123"}}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
let content = result["input"][0]["content"].as_array().unwrap();
|
||||
|
||||
assert_eq!(content[0]["type"], "input_text");
|
||||
assert_eq!(content[1]["type"], "input_image");
|
||||
assert_eq!(content[1]["image_url"], "data:image/png;base64,abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_simple() {
|
||||
let input = json!({
|
||||
"id": "resp_123",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"model": "gpt-4o",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"id": "msg_123",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "Hello!"}]
|
||||
}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["id"], "resp_123");
|
||||
assert_eq!(result["type"], "message");
|
||||
assert_eq!(result["content"][0]["type"], "text");
|
||||
assert_eq!(result["content"][0]["text"], "Hello!");
|
||||
assert_eq!(result["stop_reason"], "end_turn");
|
||||
assert_eq!(result["usage"]["input_tokens"], 10);
|
||||
assert_eq!(result["usage"]["output_tokens"], 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_with_function_call() {
|
||||
let input = json!({
|
||||
"id": "resp_123",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"model": "gpt-4o",
|
||||
"output": [{
|
||||
"type": "function_call",
|
||||
"id": "fc_123",
|
||||
"call_id": "call_123",
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"location\": \"Tokyo\"}",
|
||||
"status": "completed"
|
||||
}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 15}
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["content"][0]["type"], "tool_use");
|
||||
assert_eq!(result["content"][0]["id"], "call_123");
|
||||
assert_eq!(result["content"][0]["name"], "get_weather");
|
||||
assert_eq!(result["content"][0]["input"]["location"], "Tokyo");
|
||||
assert_eq!(result["stop_reason"], "tool_use");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_with_refusal_block() {
|
||||
let input = json!({
|
||||
"id": "resp_123",
|
||||
"status": "completed",
|
||||
"model": "gpt-4o",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"content": [{"type": "refusal", "refusal": "I can't help with that."}]
|
||||
}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5}
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["content"][0]["type"], "text");
|
||||
assert_eq!(result["content"][0]["text"], "I can't help with that.");
|
||||
assert_eq!(result["stop_reason"], "end_turn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_with_reasoning() {
|
||||
let input = json!({
|
||||
"id": "resp_123",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"model": "gpt-4o",
|
||||
"output": [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "rs_123",
|
||||
"summary": [
|
||||
{"type": "summary_text", "text": "Thinking about the problem..."}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_123",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "The answer is 42"}]
|
||||
}
|
||||
],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 20}
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
// Should have thinking + text
|
||||
assert_eq!(result["content"][0]["type"], "thinking");
|
||||
assert_eq!(
|
||||
result["content"][0]["thinking"],
|
||||
"Thinking about the problem..."
|
||||
);
|
||||
assert_eq!(result["content"][1]["type"], "text");
|
||||
assert_eq!(result["content"][1]["text"], "The answer is 42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_incomplete_status() {
|
||||
let input = json!({
|
||||
"id": "resp_123",
|
||||
"status": "incomplete",
|
||||
"model": "gpt-4o",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"content": [{"type": "output_text", "text": "Partial..."}]
|
||||
}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 4096}
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["stop_reason"], "max_tokens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_incomplete_non_token_reason() {
|
||||
let input = json!({
|
||||
"id": "resp_123",
|
||||
"status": "incomplete",
|
||||
"incomplete_details": {"reason": "content_filter"},
|
||||
"model": "gpt-4o",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"content": [{"type": "output_text", "text": "Blocked"}]
|
||||
}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 1}
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["stop_reason"], "end_turn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_passthrough() {
|
||||
let input = json!({
|
||||
"model": "o3-mini",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["model"], "o3-mini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_with_cache_key() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, Some("my-provider-id")).unwrap();
|
||||
assert_eq!(result["prompt_cache_key"], "my-provider-id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_strip_cache_control_on_tools() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Weather?"}],
|
||||
"tools": [{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"input_schema": {"type": "object"},
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert!(result["tools"][0].get("cache_control").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_strip_cache_control_on_text() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral"}}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert!(result["input"][0]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_with_cache_tokens() {
|
||||
let input = json!({
|
||||
"id": "resp_123",
|
||||
"status": "completed",
|
||||
"model": "gpt-4o",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"content": [{"type": "output_text", "text": "Hello!"}]
|
||||
}],
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 80
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["usage"]["input_tokens"], 100);
|
||||
assert_eq!(result["usage"]["output_tokens"], 50);
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_with_direct_cache_fields() {
|
||||
let input = json!({
|
||||
"id": "resp_123",
|
||||
"status": "completed",
|
||||
"model": "gpt-4o",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"content": [{"type": "output_text", "text": "Hello!"}]
|
||||
}],
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"cache_read_input_tokens": 60,
|
||||
"cache_creation_input_tokens": 20
|
||||
}
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
|
||||
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
|
||||
}
|
||||
}
|
||||
@@ -283,6 +283,11 @@ fn create_usage_collector(
|
||||
status_code: u16,
|
||||
parser_config: &UsageParserConfig,
|
||||
) -> SseUsageCollector {
|
||||
let logging_enabled = state
|
||||
.config
|
||||
.try_read()
|
||||
.map(|c| c.enable_logging)
|
||||
.unwrap_or(true);
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let request_model = ctx.request_model.clone();
|
||||
@@ -294,6 +299,9 @@ fn create_usage_collector(
|
||||
let session_id = ctx.session_id.clone();
|
||||
|
||||
SseUsageCollector::new(start_time, move |events, first_token_ms| {
|
||||
if !logging_enabled {
|
||||
return;
|
||||
}
|
||||
if let Some(usage) = stream_parser(&events) {
|
||||
let model = model_extractor(&events, &request_model);
|
||||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||
@@ -358,6 +366,13 @@ fn spawn_log_usage(
|
||||
status_code: u16,
|
||||
is_streaming: bool,
|
||||
) {
|
||||
// Check enable_logging before spawning the log task
|
||||
if let Ok(config) = state.config.try_read() {
|
||||
if !config.enable_logging {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let app_type_str = ctx.app_type_str.to_string();
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Thinking 优化器
|
||||
|
||||
use super::types::OptimizerConfig;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 根据模型类型自动优化 thinking 配置
|
||||
///
|
||||
/// 三路径分发:
|
||||
/// - skip: haiku 模型直接跳过
|
||||
/// - adaptive: opus-4-6 / sonnet-4-6 使用 adaptive thinking
|
||||
/// - legacy: 其他模型注入 enabled thinking + budget_tokens
|
||||
pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
|
||||
if !config.thinking_optimizer {
|
||||
return;
|
||||
}
|
||||
|
||||
let model = match body.get("model").and_then(|m| m.as_str()) {
|
||||
Some(m) => m.to_lowercase(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
if model.contains("haiku") {
|
||||
log::info!("[OPT] thinking: skip(haiku)");
|
||||
return;
|
||||
}
|
||||
|
||||
if model.contains("opus-4-6") || model.contains("sonnet-4-6") {
|
||||
log::info!("[OPT] thinking: adaptive({})", model);
|
||||
body["thinking"] = json!({"type": "adaptive"});
|
||||
body["output_config"] = json!({"effort": "max"});
|
||||
append_beta(body, "context-1m-2025-08-07");
|
||||
return;
|
||||
}
|
||||
|
||||
// legacy path
|
||||
log::info!("[OPT] thinking: legacy({})", model);
|
||||
|
||||
let max_tokens = body
|
||||
.get("max_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(16384);
|
||||
|
||||
let budget_target = max_tokens.saturating_sub(1);
|
||||
|
||||
let thinking_type = body
|
||||
.get("thinking")
|
||||
.and_then(|t| t.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
match thinking_type.as_deref() {
|
||||
None | Some("disabled") => {
|
||||
body["thinking"] = json!({
|
||||
"type": "enabled",
|
||||
"budget_tokens": budget_target
|
||||
});
|
||||
append_beta(body, "interleaved-thinking-2025-05-14");
|
||||
}
|
||||
Some("enabled") => {
|
||||
let current_budget = body
|
||||
.get("thinking")
|
||||
.and_then(|t| t.get("budget_tokens"))
|
||||
.and_then(|b| b.as_u64())
|
||||
.unwrap_or(0);
|
||||
if current_budget < budget_target {
|
||||
body["thinking"]["budget_tokens"] = json!(budget_target);
|
||||
}
|
||||
append_beta(body, "interleaved-thinking-2025-05-14");
|
||||
}
|
||||
_ => {
|
||||
append_beta(body, "interleaved-thinking-2025-05-14");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加 beta 标识到 anthropic_beta 数组(去重)
|
||||
fn append_beta(body: &mut Value, beta: &str) {
|
||||
match body.get("anthropic_beta") {
|
||||
Some(Value::Array(arr)) => {
|
||||
if arr.iter().any(|v| v.as_str() == Some(beta)) {
|
||||
return;
|
||||
}
|
||||
body["anthropic_beta"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!(beta));
|
||||
}
|
||||
Some(Value::Null) | None => {
|
||||
body["anthropic_beta"] = json!([beta]);
|
||||
}
|
||||
_ => {
|
||||
body["anthropic_beta"] = json!([beta]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn enabled_config() -> OptimizerConfig {
|
||||
OptimizerConfig {
|
||||
enabled: true,
|
||||
thinking_optimizer: true,
|
||||
cache_injection: true,
|
||||
cache_ttl: "1h".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled_config() -> OptimizerConfig {
|
||||
OptimizerConfig {
|
||||
enabled: true,
|
||||
thinking_optimizer: false,
|
||||
cache_injection: true,
|
||||
cache_ttl: "1h".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adaptive_opus_4_6() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-opus-4-6-20250514-v1:0",
|
||||
"max_tokens": 16384,
|
||||
"thinking": {"type": "enabled", "budget_tokens": 8000},
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
assert!(body["thinking"].get("budget_tokens").is_none());
|
||||
assert_eq!(body["output_config"]["effort"], "max");
|
||||
let betas = body["anthropic_beta"].as_array().unwrap();
|
||||
assert!(betas.iter().any(|v| v == "context-1m-2025-08-07"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adaptive_sonnet_4_6() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-sonnet-4-6-20250514-v1:0",
|
||||
"max_tokens": 16384,
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
assert!(body["thinking"].get("budget_tokens").is_none());
|
||||
assert_eq!(body["output_config"]["effort"], "max");
|
||||
let betas = body["anthropic_beta"].as_array().unwrap();
|
||||
assert!(betas.iter().any(|v| v == "context-1m-2025-08-07"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_sonnet_4_5_thinking_null() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-sonnet-4-5-20250514-v1:0",
|
||||
"max_tokens": 16384,
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 16383);
|
||||
let betas = body["anthropic_beta"].as_array().unwrap();
|
||||
assert!(betas.iter().any(|v| v == "interleaved-thinking-2025-05-14"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_budget_too_small_upgraded() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-sonnet-4-5-20250514-v1:0",
|
||||
"max_tokens": 16384,
|
||||
"thinking": {"type": "enabled", "budget_tokens": 1024},
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 16383);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skip_haiku() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-haiku-4-5-20250514-v1:0",
|
||||
"max_tokens": 8192,
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
let original = body.clone();
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_optimizer_disabled() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-opus-4-6-20250514-v1:0",
|
||||
"max_tokens": 16384,
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
let original = body.clone();
|
||||
|
||||
optimize(&mut body, &disabled_config());
|
||||
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adaptive_dedup_beta() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-opus-4-6-20250514-v1:0",
|
||||
"max_tokens": 16384,
|
||||
"anthropic_beta": ["context-1m-2025-08-07"],
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
let betas = body["anthropic_beta"].as_array().unwrap();
|
||||
let count = betas
|
||||
.iter()
|
||||
.filter(|v| v == &&json!("context-1m-2025-08-07"))
|
||||
.count();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_disabled_thinking_injected() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-sonnet-4-5-20250514-v1:0",
|
||||
"max_tokens": 8192,
|
||||
"thinking": {"type": "disabled"},
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 8191);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_default_max_tokens() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-sonnet-4-5-20250514-v1:0",
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 16383);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_beta_null_field() {
|
||||
let mut body = json!({
|
||||
"model": "anthropic.claude-opus-4-6-20250514-v1:0",
|
||||
"anthropic_beta": null,
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
optimize(&mut body, &enabled_config());
|
||||
|
||||
let betas = body["anthropic_beta"].as_array().unwrap();
|
||||
assert!(betas.iter().any(|v| v == "context-1m-2025-08-07"));
|
||||
}
|
||||
}
|
||||
@@ -233,6 +233,42 @@ impl Default for RectifierConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求优化器配置
|
||||
///
|
||||
/// 存储在 settings 表中,key = "optimizer_config"
|
||||
/// 仅对 Bedrock provider 生效(CLAUDE_CODE_USE_BEDROCK = "1")
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OptimizerConfig {
|
||||
/// 总开关(默认关闭,用户需手动启用)
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Thinking 优化子开关(总开关开启后默认生效)
|
||||
#[serde(default = "default_true")]
|
||||
pub thinking_optimizer: bool,
|
||||
/// Cache 注入子开关(总开关开启后默认生效)
|
||||
#[serde(default = "default_true")]
|
||||
pub cache_injection: bool,
|
||||
/// Cache TTL: "5m" | "1h"(默认 "1h")
|
||||
#[serde(default = "default_cache_ttl")]
|
||||
pub cache_ttl: String,
|
||||
}
|
||||
|
||||
fn default_cache_ttl() -> String {
|
||||
"1h".to_string()
|
||||
}
|
||||
|
||||
impl Default for OptimizerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
thinking_optimizer: true,
|
||||
cache_injection: true,
|
||||
cache_ttl: "1h".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志配置
|
||||
///
|
||||
/// 存储在 settings 表的 log_config 字段中(JSON 格式)
|
||||
|
||||
@@ -109,6 +109,25 @@ impl TokenUsage {
|
||||
usage.input_tokens = input as u32;
|
||||
}
|
||||
}
|
||||
// 从 message_delta 中处理缓存命中(cache_read_input_tokens)
|
||||
if usage.cache_read_tokens == 0 {
|
||||
if let Some(cache_read) = delta_usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
{
|
||||
usage.cache_read_tokens = cache_read as u32;
|
||||
}
|
||||
}
|
||||
// 从 message_delta 中处理缓存创建(cache_creation_input_tokens)
|
||||
// 注: 现在 zhipu 没有返回 cache_creation_input_tokens 字段
|
||||
if usage.cache_creation_tokens == 0 {
|
||||
if let Some(cache_creation) = delta_usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
{
|
||||
usage.cache_creation_tokens = cache_creation as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use toml_edit::{DocumentMut, Item, TableLike};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
|
||||
use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file};
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::services::mcp::McpService;
|
||||
@@ -31,6 +33,537 @@ pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
fn json_is_subset(target: &Value, source: &Value) -> bool {
|
||||
match source {
|
||||
Value::Object(source_map) => {
|
||||
let Some(target_map) = target.as_object() else {
|
||||
return false;
|
||||
};
|
||||
source_map.iter().all(|(key, source_value)| {
|
||||
target_map
|
||||
.get(key)
|
||||
.is_some_and(|target_value| json_is_subset(target_value, source_value))
|
||||
})
|
||||
}
|
||||
Value::Array(source_arr) => {
|
||||
let Some(target_arr) = target.as_array() else {
|
||||
return false;
|
||||
};
|
||||
json_array_contains_subset(target_arr, source_arr)
|
||||
}
|
||||
_ => target == source,
|
||||
}
|
||||
}
|
||||
|
||||
fn json_array_contains_subset(target_arr: &[Value], source_arr: &[Value]) -> bool {
|
||||
let mut matched = vec![false; target_arr.len()];
|
||||
|
||||
source_arr.iter().all(|source_item| {
|
||||
if let Some((index, _)) = target_arr.iter().enumerate().find(|(index, target_item)| {
|
||||
!matched[*index] && json_is_subset(target_item, source_item)
|
||||
}) {
|
||||
matched[index] = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn json_remove_array_items(target_arr: &mut Vec<Value>, source_arr: &[Value]) {
|
||||
for source_item in source_arr {
|
||||
if let Some(index) = target_arr
|
||||
.iter()
|
||||
.position(|target_item| json_is_subset(target_item, source_item))
|
||||
{
|
||||
target_arr.remove(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_deep_merge(target: &mut Value, source: &Value) {
|
||||
match (target, source) {
|
||||
(Value::Object(target_map), Value::Object(source_map)) => {
|
||||
for (key, source_value) in source_map {
|
||||
match target_map.get_mut(key) {
|
||||
Some(target_value) => json_deep_merge(target_value, source_value),
|
||||
None => {
|
||||
target_map.insert(key.clone(), source_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(target_value, source_value) => {
|
||||
*target_value = source_value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_deep_remove(target: &mut Value, source: &Value) {
|
||||
let (Some(target_map), Some(source_map)) = (target.as_object_mut(), source.as_object()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (key, source_value) in source_map {
|
||||
let mut remove_key = false;
|
||||
|
||||
if let Some(target_value) = target_map.get_mut(key) {
|
||||
if source_value.is_object() && target_value.is_object() {
|
||||
json_deep_remove(target_value, source_value);
|
||||
remove_key = target_value.as_object().is_some_and(|obj| obj.is_empty());
|
||||
} else if let (Some(target_arr), Some(source_arr)) =
|
||||
(target_value.as_array_mut(), source_value.as_array())
|
||||
{
|
||||
json_remove_array_items(target_arr, source_arr);
|
||||
remove_key = target_arr.is_empty();
|
||||
} else if json_is_subset(target_value, source_value) {
|
||||
remove_key = true;
|
||||
}
|
||||
}
|
||||
|
||||
if remove_key {
|
||||
target_map.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toml_value_is_subset(target: &toml_edit::Value, source: &toml_edit::Value) -> bool {
|
||||
match (target, source) {
|
||||
(toml_edit::Value::String(target), toml_edit::Value::String(source)) => {
|
||||
target.value() == source.value()
|
||||
}
|
||||
(toml_edit::Value::Integer(target), toml_edit::Value::Integer(source)) => {
|
||||
target.value() == source.value()
|
||||
}
|
||||
(toml_edit::Value::Float(target), toml_edit::Value::Float(source)) => {
|
||||
target.value() == source.value()
|
||||
}
|
||||
(toml_edit::Value::Boolean(target), toml_edit::Value::Boolean(source)) => {
|
||||
target.value() == source.value()
|
||||
}
|
||||
(toml_edit::Value::Datetime(target), toml_edit::Value::Datetime(source)) => {
|
||||
target.value() == source.value()
|
||||
}
|
||||
(toml_edit::Value::Array(target), toml_edit::Value::Array(source)) => {
|
||||
toml_array_contains_subset(target, source)
|
||||
}
|
||||
(toml_edit::Value::InlineTable(target), toml_edit::Value::InlineTable(source)) => {
|
||||
source.iter().all(|(key, source_item)| {
|
||||
target
|
||||
.get(key)
|
||||
.is_some_and(|target_item| toml_value_is_subset(target_item, source_item))
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn toml_array_contains_subset(target: &toml_edit::Array, source: &toml_edit::Array) -> bool {
|
||||
let mut matched = vec![false; target.len()];
|
||||
let target_items: Vec<&toml_edit::Value> = target.iter().collect();
|
||||
|
||||
source.iter().all(|source_item| {
|
||||
if let Some((index, _)) = target_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(index, target_item)| {
|
||||
!matched[*index] && toml_value_is_subset(target_item, source_item)
|
||||
})
|
||||
{
|
||||
matched[index] = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn toml_remove_array_items(target: &mut toml_edit::Array, source: &toml_edit::Array) {
|
||||
for source_item in source.iter() {
|
||||
let index = {
|
||||
let target_items: Vec<&toml_edit::Value> = target.iter().collect();
|
||||
target_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, target_item)| toml_value_is_subset(target_item, source_item))
|
||||
.map(|(index, _)| index)
|
||||
};
|
||||
|
||||
if let Some(index) = index {
|
||||
target.remove(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toml_item_is_subset(target: &Item, source: &Item) -> bool {
|
||||
if let Some(source_table) = source.as_table_like() {
|
||||
let Some(target_table) = target.as_table_like() else {
|
||||
return false;
|
||||
};
|
||||
return source_table.iter().all(|(key, source_item)| {
|
||||
target_table
|
||||
.get(key)
|
||||
.is_some_and(|target_item| toml_item_is_subset(target_item, source_item))
|
||||
});
|
||||
}
|
||||
|
||||
match (target.as_value(), source.as_value()) {
|
||||
(Some(target_value), Some(source_value)) => {
|
||||
toml_value_is_subset(target_value, source_value)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_toml_item(target: &mut Item, source: &Item) {
|
||||
if let Some(source_table) = source.as_table_like() {
|
||||
if let Some(target_table) = target.as_table_like_mut() {
|
||||
merge_toml_table_like(target_table, source_table);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
*target = source.clone();
|
||||
}
|
||||
|
||||
fn merge_toml_table_like(target: &mut dyn TableLike, source: &dyn TableLike) {
|
||||
for (key, source_item) in source.iter() {
|
||||
match target.get_mut(key) {
|
||||
Some(target_item) => merge_toml_item(target_item, source_item),
|
||||
None => {
|
||||
target.insert(key, source_item.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_toml_item(target: &mut Item, source: &Item) {
|
||||
if let Some(source_table) = source.as_table_like() {
|
||||
if let Some(target_table) = target.as_table_like_mut() {
|
||||
remove_toml_table_like(target_table, source_table);
|
||||
if target_table.is_empty() {
|
||||
*target = Item::None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(source_value) = source.as_value() {
|
||||
let mut remove_item = false;
|
||||
|
||||
if let Some(target_value) = target.as_value_mut() {
|
||||
match (target_value, source_value) {
|
||||
(toml_edit::Value::Array(target_arr), toml_edit::Value::Array(source_arr)) => {
|
||||
toml_remove_array_items(target_arr, source_arr);
|
||||
remove_item = target_arr.is_empty();
|
||||
}
|
||||
(target_value, source_value)
|
||||
if toml_value_is_subset(target_value, source_value) =>
|
||||
{
|
||||
remove_item = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if remove_item {
|
||||
*target = Item::None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_toml_table_like(target: &mut dyn TableLike, source: &dyn TableLike) {
|
||||
let keys: Vec<String> = source.iter().map(|(key, _)| key.to_string()).collect();
|
||||
|
||||
for key in keys {
|
||||
let mut remove_key = false;
|
||||
if let (Some(target_item), Some(source_item)) = (target.get_mut(&key), source.get(&key)) {
|
||||
remove_toml_item(target_item, source_item);
|
||||
remove_key = target_item.is_none()
|
||||
|| target_item
|
||||
.as_table_like()
|
||||
.is_some_and(|table_like| table_like.is_empty());
|
||||
}
|
||||
|
||||
if remove_key {
|
||||
target.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: &str) -> bool {
|
||||
let trimmed = snippet.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => match serde_json::from_str::<Value>(trimmed) {
|
||||
Ok(source) if source.is_object() => json_is_subset(settings, &source),
|
||||
_ => false,
|
||||
},
|
||||
AppType::Codex => {
|
||||
let config_toml = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
if config_toml.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let target_doc = match config_toml.parse::<DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let source_doc = match trimmed.parse::<DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
toml_item_is_subset(target_doc.as_item(), source_doc.as_item())
|
||||
}
|
||||
AppType::Gemini => match serde_json::from_str::<Value>(trimmed) {
|
||||
Ok(Value::Object(source_map)) => {
|
||||
let Some(target_map) = settings.get("env").and_then(Value::as_object) else {
|
||||
return false;
|
||||
};
|
||||
source_map.iter().all(|(key, source_value)| {
|
||||
target_map
|
||||
.get(key)
|
||||
.is_some_and(|target_value| json_is_subset(target_value, source_value))
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
AppType::OpenCode | AppType::OpenClaw => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn provider_uses_common_config(
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
snippet: Option<&str>,
|
||||
) -> bool {
|
||||
match provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.common_config_enabled)
|
||||
{
|
||||
Some(explicit) => explicit && snippet.is_some_and(|value| !value.trim().is_empty()),
|
||||
None => snippet.is_some_and(|value| {
|
||||
settings_contain_common_config(app_type, &provider.settings_config, value)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remove_common_config_from_settings(
|
||||
app_type: &AppType,
|
||||
settings: &Value,
|
||||
snippet: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
let trimmed = snippet.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(settings.clone());
|
||||
}
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let source = serde_json::from_str::<Value>(trimmed)
|
||||
.map_err(|e| AppError::Message(format!("Invalid Claude common config: {e}")))?;
|
||||
let mut result = settings.clone();
|
||||
json_deep_remove(&mut result, &source);
|
||||
Ok(result)
|
||||
}
|
||||
AppType::Codex => {
|
||||
let mut result = settings.clone();
|
||||
let config_toml = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
let mut target_doc = if config_toml.trim().is_empty() {
|
||||
DocumentMut::new()
|
||||
} else {
|
||||
config_toml.parse::<DocumentMut>().map_err(|e| {
|
||||
AppError::Message(format!(
|
||||
"Invalid Codex config.toml while removing common config: {e}"
|
||||
))
|
||||
})?
|
||||
};
|
||||
let source_doc = trimmed.parse::<DocumentMut>().map_err(|e| {
|
||||
AppError::Message(format!("Invalid Codex common config snippet: {e}"))
|
||||
})?;
|
||||
|
||||
remove_toml_table_like(target_doc.as_table_mut(), source_doc.as_table());
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("config".to_string(), Value::String(target_doc.to_string()));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::Gemini => {
|
||||
let source = serde_json::from_str::<Value>(trimmed)
|
||||
.map_err(|e| AppError::Message(format!("Invalid Gemini common config: {e}")))?;
|
||||
let mut result = settings.clone();
|
||||
if let Some(env) = result.get_mut("env") {
|
||||
json_deep_remove(env, &source);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_common_config_to_settings(
|
||||
app_type: &AppType,
|
||||
settings: &Value,
|
||||
snippet: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
let trimmed = snippet.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(settings.clone());
|
||||
}
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let source = serde_json::from_str::<Value>(trimmed)
|
||||
.map_err(|e| AppError::Message(format!("Invalid Claude common config: {e}")))?;
|
||||
let mut result = settings.clone();
|
||||
json_deep_merge(&mut result, &source);
|
||||
Ok(result)
|
||||
}
|
||||
AppType::Codex => {
|
||||
let mut result = settings.clone();
|
||||
let config_toml = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
let mut target_doc = if config_toml.trim().is_empty() {
|
||||
DocumentMut::new()
|
||||
} else {
|
||||
config_toml.parse::<DocumentMut>().map_err(|e| {
|
||||
AppError::Message(format!(
|
||||
"Invalid Codex config.toml while applying common config: {e}"
|
||||
))
|
||||
})?
|
||||
};
|
||||
let source_doc = trimmed.parse::<DocumentMut>().map_err(|e| {
|
||||
AppError::Message(format!("Invalid Codex common config snippet: {e}"))
|
||||
})?;
|
||||
|
||||
merge_toml_table_like(target_doc.as_table_mut(), source_doc.as_table());
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("config".to_string(), Value::String(target_doc.to_string()));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::Gemini => {
|
||||
let source = serde_json::from_str::<Value>(trimmed)
|
||||
.map_err(|e| AppError::Message(format!("Invalid Gemini common config: {e}")))?;
|
||||
let mut result = settings.clone();
|
||||
if let Some(env) = result.get_mut("env") {
|
||||
json_deep_merge(env, &source);
|
||||
} else if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("env".to_string(), source);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_live_with_common_config(
|
||||
db: &Database,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
let snippet = db.get_config_snippet(app_type.as_str())?;
|
||||
let mut effective_provider = provider.clone();
|
||||
|
||||
if provider_uses_common_config(app_type, provider, snippet.as_deref()) {
|
||||
if let Some(snippet_text) = snippet.as_deref() {
|
||||
match apply_common_config_to_settings(app_type, &provider.settings_config, snippet_text)
|
||||
{
|
||||
Ok(settings) => effective_provider.settings_config = settings,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to apply common config for {} provider '{}': {err}",
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
write_live_snapshot(app_type, &effective_provider)
|
||||
}
|
||||
|
||||
pub(crate) fn strip_common_config_from_live_settings(
|
||||
db: &Database,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
live_settings: Value,
|
||||
) -> Value {
|
||||
let snippet = match db.get_config_snippet(app_type.as_str()) {
|
||||
Ok(snippet) => snippet,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to load common config for {} while backfilling '{}': {err}",
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
return live_settings;
|
||||
}
|
||||
};
|
||||
|
||||
if !provider_uses_common_config(app_type, provider, snippet.as_deref()) {
|
||||
return live_settings;
|
||||
}
|
||||
|
||||
let Some(snippet_text) = snippet.as_deref() else {
|
||||
return live_settings;
|
||||
};
|
||||
|
||||
match remove_common_config_from_settings(app_type, &live_settings, snippet_text) {
|
||||
Ok(settings) => settings,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to strip common config for {} provider '{}': {err}",
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
live_settings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_provider_common_config_for_storage(
|
||||
db: &Database,
|
||||
app_type: &AppType,
|
||||
provider: &mut Provider,
|
||||
) -> Result<(), AppError> {
|
||||
let uses_common_config = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.common_config_enabled)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !uses_common_config {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(snippet) = db.get_config_snippet(app_type.as_str())? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if snippet.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match remove_common_config_from_settings(app_type, &provider.settings_config, &snippet) {
|
||||
Ok(settings) => provider.settings_config = settings,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to normalize common config before saving {} provider '{}': {err}",
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Live configuration snapshot for backup/restore
|
||||
#[derive(Clone)]
|
||||
#[allow(dead_code)]
|
||||
@@ -245,7 +778,7 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<()
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
|
||||
for provider in providers.values() {
|
||||
if let Err(e) = write_live_snapshot(app_type, provider) {
|
||||
if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) {
|
||||
log::warn!(
|
||||
"Failed to sync {:?} provider '{}' to live: {e}",
|
||||
app_type,
|
||||
@@ -263,6 +796,30 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn sync_current_provider_for_app_to_live(
|
||||
state: &AppState,
|
||||
app_type: &AppType,
|
||||
) -> Result<(), AppError> {
|
||||
if app_type.is_additive_mode() {
|
||||
sync_all_providers_to_live(state, app_type)?;
|
||||
} else {
|
||||
let current_id = match crate::settings::get_effective_current_provider(&state.db, app_type)?
|
||||
{
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get(¤t_id) {
|
||||
write_live_with_common_config(state.db.as_ref(), app_type, provider)?;
|
||||
}
|
||||
}
|
||||
|
||||
McpService::sync_all_enabled(state)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync current provider to live configuration
|
||||
///
|
||||
/// 使用有效的当前供应商 ID(验证过存在性)。
|
||||
@@ -286,7 +843,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get(¤t_id) {
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
|
||||
}
|
||||
// Note: get_effective_current_provider already validates existence,
|
||||
// so providers.get() should always succeed here
|
||||
@@ -742,3 +1299,132 @@ pub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppEr
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn claude_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields() {
|
||||
let settings = json!({
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-test"
|
||||
}
|
||||
});
|
||||
let snippet = r#"{
|
||||
"includeCoAuthoredBy": false,
|
||||
"env": {
|
||||
"CLAUDE_CODE_USE_BEDROCK": "1"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let applied =
|
||||
apply_common_config_to_settings(&AppType::Claude, &settings, snippet).unwrap();
|
||||
assert_eq!(applied["includeCoAuthoredBy"], json!(false));
|
||||
assert_eq!(applied["env"]["CLAUDE_CODE_USE_BEDROCK"], json!("1"));
|
||||
|
||||
let stripped =
|
||||
remove_common_config_from_settings(&AppType::Claude, &applied, snippet).unwrap();
|
||||
assert_eq!(stripped, settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields() {
|
||||
let settings = json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "sk-test"
|
||||
},
|
||||
"config": "model_provider = \"openai\"\n[general]\nmodel = \"gpt-5\"\n"
|
||||
});
|
||||
let snippet = "[shared]\nreasoning = \"medium\"\n";
|
||||
|
||||
let applied = apply_common_config_to_settings(&AppType::Codex, &settings, snippet).unwrap();
|
||||
let applied_config = applied["config"].as_str().unwrap_or_default();
|
||||
assert!(applied_config.contains("[shared]"));
|
||||
assert!(applied_config.contains("reasoning = \"medium\""));
|
||||
|
||||
let stripped =
|
||||
remove_common_config_from_settings(&AppType::Codex, &applied, snippet).unwrap();
|
||||
assert_eq!(stripped, settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_common_config_flag_overrides_legacy_subset_detection() {
|
||||
let mut provider = Provider::with_id(
|
||||
"claude-test".to_string(),
|
||||
"Claude Test".to_string(),
|
||||
json!({
|
||||
"includeCoAuthoredBy": false
|
||||
}),
|
||||
None,
|
||||
);
|
||||
provider.meta = Some(crate::provider::ProviderMeta {
|
||||
common_config_enabled: Some(false),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert!(
|
||||
!provider_uses_common_config(
|
||||
&AppType::Claude,
|
||||
&provider,
|
||||
Some(r#"{ "includeCoAuthoredBy": false }"#),
|
||||
),
|
||||
"explicit false should win over legacy subset detection"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_common_config_array_subset_detection_and_strip_preserve_extra_items() {
|
||||
let settings = json!({
|
||||
"allowedTools": ["tool1", "tool2"]
|
||||
});
|
||||
let snippet = r#"{
|
||||
"allowedTools": ["tool1"]
|
||||
}"#;
|
||||
|
||||
assert!(
|
||||
settings_contain_common_config(&AppType::Claude, &settings, snippet),
|
||||
"array subset should be detected for legacy providers"
|
||||
);
|
||||
|
||||
let stripped =
|
||||
remove_common_config_from_settings(&AppType::Claude, &settings, snippet).unwrap();
|
||||
assert_eq!(
|
||||
stripped,
|
||||
json!({
|
||||
"allowedTools": ["tool2"]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_common_config_array_subset_detection_and_strip_preserve_extra_items() {
|
||||
let settings = json!({
|
||||
"auth": {},
|
||||
"config": "allowed_tools = [\"tool1\", \"tool2\"]\n"
|
||||
});
|
||||
let snippet = "allowed_tools = [\"tool1\"]\n";
|
||||
|
||||
assert!(
|
||||
settings_contain_common_config(&AppType::Codex, &settings, snippet),
|
||||
"TOML array subset should be detected for legacy providers"
|
||||
);
|
||||
|
||||
let stripped =
|
||||
remove_common_config_from_settings(&AppType::Codex, &settings, snippet).unwrap();
|
||||
assert_eq!(stripped["auth"], json!({}));
|
||||
let stripped_config = stripped["config"].as_str().unwrap_or_default();
|
||||
let parsed = stripped_config
|
||||
.parse::<DocumentMut>()
|
||||
.expect("stripped codex config should remain valid TOML");
|
||||
let allowed_tools = parsed["allowed_tools"]
|
||||
.as_array()
|
||||
.expect("allowed_tools should remain an array");
|
||||
let values: Vec<&str> = allowed_tools
|
||||
.iter()
|
||||
.map(|value| value.as_str().expect("tool id should be string"))
|
||||
.collect();
|
||||
assert_eq!(values, vec!["tool2"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ pub use live::{
|
||||
|
||||
// Internal re-exports (pub(crate))
|
||||
pub(crate) use live::sanitize_claude_settings_for_live;
|
||||
pub(crate) use live::write_live_snapshot;
|
||||
pub(crate) use live::{
|
||||
normalize_provider_common_config_for_storage, strip_common_config_from_live_settings,
|
||||
sync_current_provider_for_app_to_live, write_live_with_common_config,
|
||||
};
|
||||
|
||||
// Internal re-exports
|
||||
use live::{
|
||||
@@ -167,6 +170,7 @@ impl ProviderService {
|
||||
// Normalize Claude model keys
|
||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||
Self::validate_provider_settings(&app_type, &provider)?;
|
||||
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
|
||||
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
@@ -181,7 +185,7 @@ impl ProviderService {
|
||||
// Users must explicitly switch/apply an OMO provider to activate it.
|
||||
return Ok(true);
|
||||
}
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -192,7 +196,7 @@ impl ProviderService {
|
||||
state
|
||||
.db
|
||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
@@ -208,6 +212,7 @@ impl ProviderService {
|
||||
// Normalize Claude model keys
|
||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||
Self::validate_provider_settings(&app_type, &provider)?;
|
||||
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
|
||||
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
@@ -244,7 +249,7 @@ impl ProviderService {
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -273,7 +278,7 @@ impl ProviderService {
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||
} else {
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
}
|
||||
@@ -560,7 +565,13 @@ impl ProviderService {
|
||||
// Only backfill when switching to a different provider
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
current_provider.settings_config = live_config;
|
||||
current_provider.settings_config =
|
||||
strip_common_config_from_live_settings(
|
||||
state.db.as_ref(),
|
||||
&app_type,
|
||||
¤t_provider,
|
||||
live_config,
|
||||
);
|
||||
if let Err(e) =
|
||||
state.db.save_provider(app_type.as_str(), ¤t_provider)
|
||||
{
|
||||
@@ -585,7 +596,7 @@ impl ProviderService {
|
||||
}
|
||||
|
||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
|
||||
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
@@ -598,6 +609,67 @@ impl ProviderService {
|
||||
sync_current_to_live(state)
|
||||
}
|
||||
|
||||
pub fn sync_current_provider_for_app(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
) -> Result<(), AppError> {
|
||||
sync_current_provider_for_app_to_live(state, &app_type)
|
||||
}
|
||||
|
||||
pub fn migrate_legacy_common_config_usage(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
legacy_snippet: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if app_type.is_additive_mode() || legacy_snippet.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
|
||||
for provider in providers.values() {
|
||||
if provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.common_config_enabled)
|
||||
.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if !live::provider_uses_common_config(&app_type, provider, Some(legacy_snippet)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut updated_provider = provider.clone();
|
||||
updated_provider
|
||||
.meta
|
||||
.get_or_insert_with(Default::default)
|
||||
.common_config_enabled = Some(true);
|
||||
|
||||
match live::remove_common_config_from_settings(
|
||||
&app_type,
|
||||
&updated_provider.settings_config,
|
||||
legacy_snippet,
|
||||
) {
|
||||
Ok(settings) => updated_provider.settings_config = settings,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to normalize legacy common config for {} provider '{}': {err}",
|
||||
app_type.as_str(),
|
||||
updated_provider.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
.db
|
||||
.save_provider(app_type.as_str(), &updated_provider)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract common config snippet from current provider
|
||||
///
|
||||
/// Extracts the current provider's configuration and removes provider-specific fields
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::database::Database;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::server::ProxyServer;
|
||||
use crate::proxy::types::*;
|
||||
use crate::services::provider::write_live_snapshot;
|
||||
use crate::services::provider::write_live_with_common_config;
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -1266,7 +1266,7 @@ impl ProxyService {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
write_live_snapshot(app_type, provider)
|
||||
write_live_with_common_config(self.db.as_ref(), app_type, provider)
|
||||
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
|
||||
@@ -313,8 +313,19 @@ impl StreamCheckService {
|
||||
|
||||
let is_openai_chat = api_format == "openai_chat";
|
||||
|
||||
// URL: /v1/chat/completions for openai_chat, /v1/messages?beta=true for anthropic
|
||||
let url = if is_openai_chat {
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.is_full_url)
|
||||
.unwrap_or(false);
|
||||
|
||||
// URL rules:
|
||||
// - full URL mode: use base_url as-is
|
||||
// - openai_chat: /v1/chat/completions
|
||||
// - anthropic: /v1/messages?beta=true
|
||||
let url = if is_full_url {
|
||||
base_url.to_string()
|
||||
} else if is_openai_chat {
|
||||
if base.ends_with("/v1") {
|
||||
format!("{base}/chat/completions")
|
||||
} else {
|
||||
|
||||
@@ -142,20 +142,60 @@ impl Database {
|
||||
(String::new(), Vec::new())
|
||||
};
|
||||
|
||||
// Build rollup WHERE clause using date strings (use ? for sequential binding)
|
||||
let (rollup_where, rollup_params) = if start_date.is_some() || end_date.is_some() {
|
||||
let mut conditions: Vec<String> = Vec::new();
|
||||
let mut params = Vec::new();
|
||||
|
||||
if let Some(start) = start_date {
|
||||
conditions.push("date >= date(?, 'unixepoch', 'localtime')".to_string());
|
||||
params.push(start);
|
||||
}
|
||||
if let Some(end) = end_date {
|
||||
conditions.push("date <= date(?, 'unixepoch', 'localtime')".to_string());
|
||||
params.push(end);
|
||||
}
|
||||
|
||||
(format!("WHERE {}", conditions.join(" AND ")), params)
|
||||
} else {
|
||||
(String::new(), Vec::new())
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT
|
||||
COUNT(*) as total_requests,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,
|
||||
COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END), 0) as success_count
|
||||
FROM proxy_request_logs
|
||||
{where_clause}"
|
||||
COALESCE(d.total_requests, 0) + COALESCE(r.total_requests, 0),
|
||||
COALESCE(d.total_cost, 0) + COALESCE(r.total_cost, 0),
|
||||
COALESCE(d.total_input_tokens, 0) + COALESCE(r.total_input_tokens, 0),
|
||||
COALESCE(d.total_output_tokens, 0) + COALESCE(r.total_output_tokens, 0),
|
||||
COALESCE(d.total_cache_creation_tokens, 0) + COALESCE(r.total_cache_creation_tokens, 0),
|
||||
COALESCE(d.total_cache_read_tokens, 0) + COALESCE(r.total_cache_read_tokens, 0),
|
||||
COALESCE(d.success_count, 0) + COALESCE(r.success_count, 0)
|
||||
FROM
|
||||
(SELECT
|
||||
COUNT(*) as total_requests,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,
|
||||
COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END), 0) as success_count
|
||||
FROM proxy_request_logs {where_clause}) d,
|
||||
(SELECT
|
||||
COALESCE(SUM(request_count), 0) as total_requests,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,
|
||||
COALESCE(SUM(success_count), 0) as success_count
|
||||
FROM usage_daily_rollups {rollup_where}) r"
|
||||
);
|
||||
|
||||
let result = conn.query_row(&sql, rusqlite::params_from_iter(params_vec), |row| {
|
||||
// Combine params: detail params first, then rollup params
|
||||
let mut all_params: Vec<i64> = params_vec;
|
||||
all_params.extend(rollup_params);
|
||||
|
||||
let result = conn.query_row(&sql, rusqlite::params_from_iter(all_params), |row| {
|
||||
let total_requests: i64 = row.get(0)?;
|
||||
let total_cost: f64 = row.get(1)?;
|
||||
let total_input_tokens: i64 = row.get(2)?;
|
||||
@@ -220,6 +260,7 @@ impl Database {
|
||||
bucket_count = 1;
|
||||
}
|
||||
|
||||
// Query detail logs
|
||||
let sql = "
|
||||
SELECT
|
||||
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
|
||||
@@ -264,6 +305,68 @@ impl Database {
|
||||
map.insert(bucket_idx, stat);
|
||||
}
|
||||
|
||||
// Also query rollup data (daily granularity, only useful for daily buckets)
|
||||
if bucket_seconds >= 86400 {
|
||||
let rollup_sql = "
|
||||
SELECT
|
||||
CAST((CAST(strftime('%s', date) AS INTEGER) - ?1) / ?3 AS INTEGER) as bucket_idx,
|
||||
COALESCE(SUM(request_count), 0),
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0),
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0),
|
||||
COALESCE(SUM(input_tokens), 0),
|
||||
COALESCE(SUM(output_tokens), 0),
|
||||
COALESCE(SUM(cache_creation_tokens), 0),
|
||||
COALESCE(SUM(cache_read_tokens), 0)
|
||||
FROM usage_daily_rollups
|
||||
WHERE date >= date(?1, 'unixepoch', 'localtime') AND date <= date(?2, 'unixepoch', 'localtime')
|
||||
GROUP BY bucket_idx
|
||||
ORDER BY bucket_idx ASC";
|
||||
|
||||
let mut rstmt = conn.prepare(rollup_sql)?;
|
||||
let rrows = rstmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
(
|
||||
row.get::<_, i64>(1)? as u64,
|
||||
row.get::<_, f64>(2)?,
|
||||
row.get::<_, i64>(3)? as u64,
|
||||
row.get::<_, i64>(4)? as u64,
|
||||
row.get::<_, i64>(5)? as u64,
|
||||
row.get::<_, i64>(6)? as u64,
|
||||
row.get::<_, i64>(7)? as u64,
|
||||
),
|
||||
))
|
||||
})?;
|
||||
|
||||
for row in rrows {
|
||||
let (mut bucket_idx, (req, cost, tok, inp, out, cc, cr)) = row?;
|
||||
if bucket_idx < 0 {
|
||||
continue;
|
||||
}
|
||||
if bucket_idx >= bucket_count {
|
||||
bucket_idx = bucket_count - 1;
|
||||
}
|
||||
let entry = map.entry(bucket_idx).or_insert_with(|| DailyStats {
|
||||
date: String::new(),
|
||||
request_count: 0,
|
||||
total_cost: "0.000000".to_string(),
|
||||
total_tokens: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cache_creation_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
});
|
||||
entry.request_count += req;
|
||||
let existing_cost: f64 = entry.total_cost.parse().unwrap_or(0.0);
|
||||
entry.total_cost = format!("{:.6}", existing_cost + cost);
|
||||
entry.total_tokens += tok;
|
||||
entry.total_input_tokens += inp;
|
||||
entry.total_output_tokens += out;
|
||||
entry.total_cache_creation_tokens += cc;
|
||||
entry.total_cache_read_tokens += cr;
|
||||
}
|
||||
}
|
||||
|
||||
let mut stats = Vec::with_capacity(bucket_count as usize);
|
||||
for i in 0..bucket_count {
|
||||
let bucket_start_ts = start_ts + i * bucket_seconds;
|
||||
@@ -298,23 +401,46 @@ impl Database {
|
||||
pub fn get_provider_stats(&self) -> Result<Vec<ProviderStats>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// UNION detail logs + rollup data, then aggregate
|
||||
let sql = "SELECT
|
||||
l.provider_id,
|
||||
p.name as provider_name,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost,
|
||||
COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count,
|
||||
COALESCE(AVG(l.latency_ms), 0) as avg_latency
|
||||
FROM proxy_request_logs l
|
||||
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
|
||||
GROUP BY l.provider_id, l.app_type
|
||||
ORDER BY total_cost DESC";
|
||||
provider_id, app_type, provider_name,
|
||||
SUM(request_count) as request_count,
|
||||
SUM(total_tokens) as total_tokens,
|
||||
SUM(total_cost) as total_cost,
|
||||
SUM(success_count) as success_count,
|
||||
CASE WHEN SUM(request_count) > 0
|
||||
THEN SUM(latency_sum) / SUM(request_count)
|
||||
ELSE 0 END as avg_latency
|
||||
FROM (
|
||||
SELECT l.provider_id, l.app_type,
|
||||
p.name as provider_name,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost,
|
||||
COALESCE(SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END), 0) as success_count,
|
||||
COALESCE(SUM(l.latency_ms), 0) as latency_sum
|
||||
FROM proxy_request_logs l
|
||||
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
|
||||
GROUP BY l.provider_id, l.app_type
|
||||
UNION ALL
|
||||
SELECT r.provider_id, r.app_type,
|
||||
p2.name as provider_name,
|
||||
COALESCE(SUM(r.request_count), 0),
|
||||
COALESCE(SUM(r.input_tokens + r.output_tokens), 0),
|
||||
COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0),
|
||||
COALESCE(SUM(r.success_count), 0),
|
||||
COALESCE(SUM(r.avg_latency_ms * r.request_count), 0)
|
||||
FROM usage_daily_rollups r
|
||||
LEFT JOIN providers p2 ON r.provider_id = p2.id AND r.app_type = p2.app_type
|
||||
GROUP BY r.provider_id, r.app_type
|
||||
)
|
||||
GROUP BY provider_id, app_type
|
||||
ORDER BY total_cost DESC";
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let request_count: i64 = row.get(2)?;
|
||||
let success_count: i64 = row.get(5)?;
|
||||
let request_count: i64 = row.get(3)?;
|
||||
let success_count: i64 = row.get(6)?;
|
||||
let success_rate = if request_count > 0 {
|
||||
(success_count as f32 / request_count as f32) * 100.0
|
||||
} else {
|
||||
@@ -324,13 +450,13 @@ impl Database {
|
||||
Ok(ProviderStats {
|
||||
provider_id: row.get(0)?,
|
||||
provider_name: row
|
||||
.get::<_, Option<String>>(1)?
|
||||
.get::<_, Option<String>>(2)?
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
request_count: request_count as u64,
|
||||
total_tokens: row.get::<_, i64>(3)? as u64,
|
||||
total_cost: format!("{:.6}", row.get::<_, f64>(4)?),
|
||||
total_tokens: row.get::<_, i64>(4)? as u64,
|
||||
total_cost: format!("{:.6}", row.get::<_, f64>(5)?),
|
||||
success_rate,
|
||||
avg_latency_ms: row.get::<_, f64>(6)? as u64,
|
||||
avg_latency_ms: row.get::<_, f64>(7)? as u64,
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -346,14 +472,29 @@ impl Database {
|
||||
pub fn get_model_stats(&self) -> Result<Vec<ModelStats>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// UNION detail logs + rollup data
|
||||
let sql = "SELECT
|
||||
model,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost
|
||||
FROM proxy_request_logs
|
||||
GROUP BY model
|
||||
ORDER BY total_cost DESC";
|
||||
SUM(request_count) as request_count,
|
||||
SUM(total_tokens) as total_tokens,
|
||||
SUM(total_cost) as total_cost
|
||||
FROM (
|
||||
SELECT model,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost
|
||||
FROM proxy_request_logs
|
||||
GROUP BY model
|
||||
UNION ALL
|
||||
SELECT model,
|
||||
COALESCE(SUM(request_count), 0),
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0),
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
|
||||
FROM usage_daily_rollups
|
||||
GROUP BY model
|
||||
)
|
||||
GROUP BY model
|
||||
ORDER BY total_cost DESC";
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
@@ -607,26 +748,40 @@ impl Database {
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
|
||||
// 计算今日使用量
|
||||
// 计算今日使用量 (detail logs + rollup)
|
||||
let daily_usage: f64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
|
||||
FROM proxy_request_logs
|
||||
WHERE provider_id = ? AND app_type = ?
|
||||
AND date(datetime(created_at, 'unixepoch', 'localtime')) = date('now', 'localtime')",
|
||||
params![provider_id, app_type],
|
||||
"SELECT COALESCE(SUM(cost), 0) FROM (
|
||||
SELECT CAST(total_cost_usd AS REAL) as cost
|
||||
FROM proxy_request_logs
|
||||
WHERE provider_id = ? AND app_type = ?
|
||||
AND date(datetime(created_at, 'unixepoch', 'localtime')) = date('now', 'localtime')
|
||||
UNION ALL
|
||||
SELECT CAST(total_cost_usd AS REAL)
|
||||
FROM usage_daily_rollups
|
||||
WHERE provider_id = ? AND app_type = ?
|
||||
AND date = date('now', 'localtime')
|
||||
)",
|
||||
params![provider_id, app_type, provider_id, app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
// 计算本月使用量
|
||||
// 计算本月使用量 (detail logs + rollup)
|
||||
let monthly_usage: f64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
|
||||
FROM proxy_request_logs
|
||||
WHERE provider_id = ? AND app_type = ?
|
||||
AND strftime('%Y-%m', datetime(created_at, 'unixepoch', 'localtime')) = strftime('%Y-%m', 'now', 'localtime')",
|
||||
params![provider_id, app_type],
|
||||
"SELECT COALESCE(SUM(cost), 0) FROM (
|
||||
SELECT CAST(total_cost_usd AS REAL) as cost
|
||||
FROM proxy_request_logs
|
||||
WHERE provider_id = ? AND app_type = ?
|
||||
AND strftime('%Y-%m', datetime(created_at, 'unixepoch', 'localtime')) = strftime('%Y-%m', 'now', 'localtime')
|
||||
UNION ALL
|
||||
SELECT CAST(total_cost_usd AS REAL)
|
||||
FROM usage_daily_rollups
|
||||
WHERE provider_id = ? AND app_type = ?
|
||||
AND strftime('%Y-%m', date) = strftime('%Y-%m', 'now', 'localtime')
|
||||
)",
|
||||
params![provider_id, app_type, provider_id, app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
@@ -478,6 +478,7 @@ mod tests {
|
||||
&[
|
||||
"cc switch-sync".to_string(),
|
||||
"v2".to_string(),
|
||||
"db-v6".to_string(),
|
||||
"default profile".to_string(),
|
||||
"manifest.json".to_string(),
|
||||
],
|
||||
@@ -485,7 +486,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://dav.example.com/remote.php/dav/files/demo/cc%20switch-sync/v2/default%20profile/manifest.json"
|
||||
"https://dav.example.com/remote.php/dav/files/demo/cc%20switch-sync/v2/db-v6/default%20profile/manifest.json"
|
||||
);
|
||||
assert!(!url.contains("//cc"), "should not have double-slash");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! WebDAV v2 sync protocol layer.
|
||||
//! WebDAV v2 sync protocol layer with DB compatibility subdirectories.
|
||||
//!
|
||||
//! Implements manifest-based synchronization on top of the HTTP transport
|
||||
//! primitives in [`super::webdav`]. Artifact set: `db.sql` + `skills.zip`.
|
||||
@@ -31,6 +31,8 @@ use archive::{
|
||||
|
||||
const PROTOCOL_FORMAT: &str = "cc-switch-webdav-sync";
|
||||
const PROTOCOL_VERSION: u32 = 2;
|
||||
const DB_COMPAT_VERSION: u32 = 6;
|
||||
const LEGACY_DB_COMPAT_VERSION: u32 = 5;
|
||||
const REMOTE_DB_SQL: &str = "db.sql";
|
||||
const REMOTE_SKILLS_ZIP: &str = "skills.zip";
|
||||
const REMOTE_MANIFEST: &str = "manifest.json";
|
||||
@@ -76,6 +78,8 @@ fn io_context_localized(
|
||||
struct SyncManifest {
|
||||
format: String,
|
||||
version: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
db_compat_version: Option<u32>,
|
||||
device_name: String,
|
||||
created_at: String,
|
||||
artifacts: BTreeMap<String, ArtifactMeta>,
|
||||
@@ -95,6 +99,28 @@ struct LocalSnapshot {
|
||||
manifest_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RemoteLayout {
|
||||
Current,
|
||||
Legacy,
|
||||
}
|
||||
|
||||
impl RemoteLayout {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Current => "current",
|
||||
Self::Legacy => "legacy",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteSnapshot {
|
||||
layout: RemoteLayout,
|
||||
manifest: SyncManifest,
|
||||
manifest_bytes: Vec<u8>,
|
||||
manifest_etag: Option<String>,
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────
|
||||
|
||||
/// Check WebDAV connectivity and ensure remote directory structure.
|
||||
@@ -102,7 +128,7 @@ pub async fn check_connection(settings: &WebDavSyncSettings) -> Result<(), AppEr
|
||||
settings.validate()?;
|
||||
let auth = auth_for(settings);
|
||||
test_connection(&settings.base_url, &auth).await?;
|
||||
let dir_segs = remote_dir_segments(settings);
|
||||
let dir_segs = remote_dir_segments(settings, RemoteLayout::Current);
|
||||
ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -114,19 +140,19 @@ pub async fn upload(
|
||||
) -> Result<Value, AppError> {
|
||||
settings.validate()?;
|
||||
let auth = auth_for(settings);
|
||||
let dir_segs = remote_dir_segments(settings);
|
||||
let dir_segs = remote_dir_segments(settings, RemoteLayout::Current);
|
||||
ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;
|
||||
|
||||
let snapshot = build_local_snapshot(db, settings)?;
|
||||
|
||||
// Upload order: artifacts first, manifest last (best-effort consistency)
|
||||
let db_url = remote_file_url(settings, REMOTE_DB_SQL)?;
|
||||
let db_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_DB_SQL)?;
|
||||
put_bytes(&db_url, &auth, snapshot.db_sql, "application/sql").await?;
|
||||
|
||||
let skills_url = remote_file_url(settings, REMOTE_SKILLS_ZIP)?;
|
||||
let skills_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_SKILLS_ZIP)?;
|
||||
put_bytes(&skills_url, &auth, snapshot.skills_zip, "application/zip").await?;
|
||||
|
||||
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
||||
let manifest_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_MANIFEST)?;
|
||||
put_bytes(
|
||||
&manifest_url,
|
||||
&auth,
|
||||
@@ -160,9 +186,7 @@ pub async fn download(
|
||||
) -> Result<Value, AppError> {
|
||||
settings.validate()?;
|
||||
let auth = auth_for(settings);
|
||||
|
||||
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
||||
let (manifest_bytes, etag) = get_bytes(&manifest_url, &auth, MAX_MANIFEST_BYTES)
|
||||
let snapshot = find_remote_snapshot(settings, &auth)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
localized(
|
||||
@@ -172,52 +196,64 @@ pub async fn download(
|
||||
)
|
||||
})?;
|
||||
|
||||
let manifest: SyncManifest =
|
||||
serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json {
|
||||
path: REMOTE_MANIFEST.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
validate_manifest_compat(&manifest)?;
|
||||
validate_manifest_compat(&snapshot.manifest, snapshot.layout)?;
|
||||
|
||||
// Download and verify artifacts
|
||||
let db_sql = download_and_verify(settings, &auth, REMOTE_DB_SQL, &manifest.artifacts).await?;
|
||||
let skills_zip =
|
||||
download_and_verify(settings, &auth, REMOTE_SKILLS_ZIP, &manifest.artifacts).await?;
|
||||
let db_sql = download_and_verify(
|
||||
settings,
|
||||
&auth,
|
||||
snapshot.layout,
|
||||
REMOTE_DB_SQL,
|
||||
&snapshot.manifest.artifacts,
|
||||
)
|
||||
.await?;
|
||||
let skills_zip = download_and_verify(
|
||||
settings,
|
||||
&auth,
|
||||
snapshot.layout,
|
||||
REMOTE_SKILLS_ZIP,
|
||||
&snapshot.manifest.artifacts,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Apply snapshot
|
||||
apply_snapshot(db, &db_sql, &skills_zip)?;
|
||||
|
||||
let manifest_hash = sha256_hex(&manifest_bytes);
|
||||
let _persisted =
|
||||
persist_sync_success_best_effort(settings, manifest_hash, etag, persist_sync_success);
|
||||
Ok(serde_json::json!({ "status": "downloaded" }))
|
||||
let manifest_hash = sha256_hex(&snapshot.manifest_bytes);
|
||||
let _persisted = persist_sync_success_best_effort(
|
||||
settings,
|
||||
manifest_hash,
|
||||
snapshot.manifest_etag,
|
||||
persist_sync_success,
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"status": "downloaded",
|
||||
"sourceLayout": snapshot.layout.as_str(),
|
||||
"sourcePath": remote_dir_display(settings, snapshot.layout),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Fetch remote manifest info without downloading artifacts.
|
||||
pub async fn fetch_remote_info(settings: &WebDavSyncSettings) -> Result<Option<Value>, AppError> {
|
||||
settings.validate()?;
|
||||
let auth = auth_for(settings);
|
||||
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
||||
|
||||
let Some((bytes, _)) = get_bytes(&manifest_url, &auth, MAX_MANIFEST_BYTES).await? else {
|
||||
let Some(snapshot) = find_remote_snapshot(settings, &auth).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let manifest: SyncManifest = serde_json::from_slice(&bytes).map_err(|e| AppError::Json {
|
||||
path: REMOTE_MANIFEST.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let compatible = validate_manifest_compat(&manifest).is_ok();
|
||||
let compatible = validate_manifest_compat(&snapshot.manifest, snapshot.layout).is_ok();
|
||||
let db_compat_version = effective_db_compat_version(&snapshot.manifest, snapshot.layout);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"deviceName": manifest.device_name,
|
||||
"createdAt": manifest.created_at,
|
||||
"snapshotId": manifest.snapshot_id,
|
||||
"version": manifest.version,
|
||||
"deviceName": snapshot.manifest.device_name,
|
||||
"createdAt": snapshot.manifest.created_at,
|
||||
"snapshotId": snapshot.manifest.snapshot_id,
|
||||
"version": snapshot.manifest.version,
|
||||
"protocolVersion": snapshot.manifest.version,
|
||||
"dbCompatVersion": db_compat_version,
|
||||
"compatible": compatible,
|
||||
"artifacts": manifest.artifacts.keys().collect::<Vec<_>>(),
|
||||
"artifacts": snapshot.manifest.artifacts.keys().collect::<Vec<_>>(),
|
||||
"layout": snapshot.layout.as_str(),
|
||||
"remotePath": remote_dir_display(settings, snapshot.layout),
|
||||
});
|
||||
|
||||
Ok(Some(payload))
|
||||
@@ -267,7 +303,7 @@ fn build_local_snapshot(
|
||||
_settings: &WebDavSyncSettings,
|
||||
) -> Result<LocalSnapshot, AppError> {
|
||||
// Export database to SQL string
|
||||
let sql_string = db.export_sql_string()?;
|
||||
let sql_string = db.export_sql_string_for_sync()?;
|
||||
let db_sql = sql_string.into_bytes();
|
||||
|
||||
// Pack skills into deterministic ZIP
|
||||
@@ -304,6 +340,7 @@ fn build_local_snapshot(
|
||||
let manifest = SyncManifest {
|
||||
format: PROTOCOL_FORMAT.to_string(),
|
||||
version: PROTOCOL_VERSION,
|
||||
db_compat_version: Some(DB_COMPAT_VERSION),
|
||||
device_name: detect_system_device_name().unwrap_or_else(|| "Unknown Device".to_string()),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
artifacts,
|
||||
@@ -384,7 +421,13 @@ fn normalize_device_name(raw: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_manifest_compat(manifest: &SyncManifest) -> Result<(), AppError> {
|
||||
fn effective_db_compat_version(manifest: &SyncManifest, layout: RemoteLayout) -> Option<u32> {
|
||||
manifest
|
||||
.db_compat_version
|
||||
.or_else(|| (layout == RemoteLayout::Legacy).then_some(LEGACY_DB_COMPAT_VERSION))
|
||||
}
|
||||
|
||||
fn validate_manifest_compat(manifest: &SyncManifest, layout: RemoteLayout) -> Result<(), AppError> {
|
||||
if manifest.format != PROTOCOL_FORMAT {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_format_incompatible",
|
||||
@@ -408,14 +451,87 @@ fn validate_manifest_compat(manifest: &SyncManifest) -> Result<(), AppError> {
|
||||
),
|
||||
));
|
||||
}
|
||||
let Some(db_compat_version) = effective_db_compat_version(manifest, layout) else {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_db_version_missing",
|
||||
"远端 manifest 缺少数据库兼容版本",
|
||||
"Remote manifest is missing the database compatibility version.",
|
||||
));
|
||||
};
|
||||
match layout {
|
||||
RemoteLayout::Current if db_compat_version != DB_COMPAT_VERSION => {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_db_version_incompatible",
|
||||
format!(
|
||||
"远端数据库快照版本不兼容: db-v{} (本地 db-v{DB_COMPAT_VERSION})",
|
||||
db_compat_version
|
||||
),
|
||||
format!(
|
||||
"Remote database snapshot version is incompatible: db-v{} (local db-v{DB_COMPAT_VERSION})",
|
||||
db_compat_version
|
||||
),
|
||||
));
|
||||
}
|
||||
RemoteLayout::Legacy if db_compat_version > DB_COMPAT_VERSION => {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_db_version_incompatible",
|
||||
format!(
|
||||
"远端数据库快照版本不兼容: db-v{} (本地最高支持 db-v{DB_COMPAT_VERSION})",
|
||||
db_compat_version
|
||||
),
|
||||
format!(
|
||||
"Remote database snapshot version is incompatible: db-v{} (local supports up to db-v{DB_COMPAT_VERSION})",
|
||||
db_compat_version
|
||||
),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_remote_snapshot(
|
||||
settings: &WebDavSyncSettings,
|
||||
auth: &WebDavAuth,
|
||||
) -> Result<Option<RemoteSnapshot>, AppError> {
|
||||
if let Some(snapshot) = fetch_remote_snapshot(settings, auth, RemoteLayout::Current).await? {
|
||||
return Ok(Some(snapshot));
|
||||
}
|
||||
fetch_remote_snapshot(settings, auth, RemoteLayout::Legacy).await
|
||||
}
|
||||
|
||||
async fn fetch_remote_snapshot(
|
||||
settings: &WebDavSyncSettings,
|
||||
auth: &WebDavAuth,
|
||||
layout: RemoteLayout,
|
||||
) -> Result<Option<RemoteSnapshot>, AppError> {
|
||||
let manifest_url = remote_file_url(settings, layout, REMOTE_MANIFEST)?;
|
||||
let Some((manifest_bytes, manifest_etag)) =
|
||||
get_bytes(&manifest_url, auth, MAX_MANIFEST_BYTES).await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let manifest: SyncManifest =
|
||||
serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json {
|
||||
path: REMOTE_MANIFEST.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
Ok(Some(RemoteSnapshot {
|
||||
layout,
|
||||
manifest,
|
||||
manifest_bytes,
|
||||
manifest_etag,
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── Download & verify ───────────────────────────────────────
|
||||
|
||||
async fn download_and_verify(
|
||||
settings: &WebDavSyncSettings,
|
||||
auth: &WebDavAuth,
|
||||
layout: RemoteLayout,
|
||||
artifact_name: &str,
|
||||
artifacts: &BTreeMap<String, ArtifactMeta>,
|
||||
) -> Result<Vec<u8>, AppError> {
|
||||
@@ -428,7 +544,7 @@ async fn download_and_verify(
|
||||
})?;
|
||||
validate_artifact_size_limit(artifact_name, meta.size)?;
|
||||
|
||||
let url = remote_file_url(settings, artifact_name)?;
|
||||
let url = remote_file_url(settings, layout, artifact_name)?;
|
||||
let (bytes, _) = get_bytes(&url, auth, MAX_SYNC_ARTIFACT_BYTES as usize)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
@@ -492,7 +608,7 @@ fn apply_snapshot(
|
||||
// 先替换 skills,再导入数据库;若导入失败则回滚 skills,避免“半恢复”。
|
||||
restore_skills_zip(skills_zip)?;
|
||||
|
||||
if let Err(db_err) = db.import_sql_string(sql_str) {
|
||||
if let Err(db_err) = db.import_sql_string_for_sync(sql_str) {
|
||||
if let Err(rollback_err) = restore_skills_from_backup(&skills_backup) {
|
||||
return Err(localized(
|
||||
"webdav.sync.db_import_and_rollback_failed",
|
||||
@@ -510,20 +626,32 @@ fn apply_snapshot(
|
||||
|
||||
// ─── Remote path helpers ─────────────────────────────────────
|
||||
|
||||
fn remote_dir_segments(settings: &WebDavSyncSettings) -> Vec<String> {
|
||||
fn remote_dir_segments(settings: &WebDavSyncSettings, layout: RemoteLayout) -> Vec<String> {
|
||||
let mut segs = Vec::new();
|
||||
segs.extend(path_segments(&settings.remote_root).map(str::to_string));
|
||||
segs.push(format!("v{PROTOCOL_VERSION}"));
|
||||
if layout == RemoteLayout::Current {
|
||||
segs.push(format!("db-v{DB_COMPAT_VERSION}"));
|
||||
}
|
||||
segs.extend(path_segments(&settings.profile).map(str::to_string));
|
||||
segs
|
||||
}
|
||||
|
||||
fn remote_file_url(settings: &WebDavSyncSettings, file_name: &str) -> Result<String, AppError> {
|
||||
let mut segs = remote_dir_segments(settings);
|
||||
fn remote_file_url(
|
||||
settings: &WebDavSyncSettings,
|
||||
layout: RemoteLayout,
|
||||
file_name: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let mut segs = remote_dir_segments(settings, layout);
|
||||
segs.extend(path_segments(file_name).map(str::to_string));
|
||||
build_remote_url(&settings.base_url, &segs)
|
||||
}
|
||||
|
||||
fn remote_dir_display(settings: &WebDavSyncSettings, layout: RemoteLayout) -> String {
|
||||
let segs = remote_dir_segments(settings, layout);
|
||||
format!("/{}", segs.join("/"))
|
||||
}
|
||||
|
||||
fn auth_for(settings: &WebDavSyncSettings) -> WebDavAuth {
|
||||
auth_from_credentials(&settings.username, &settings.password)
|
||||
}
|
||||
@@ -533,11 +661,8 @@ fn validate_artifact_size_limit(artifact_name: &str, size: u64) -> Result<(), Ap
|
||||
let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024;
|
||||
return Err(localized(
|
||||
"webdav.sync.artifact_too_large",
|
||||
format!("artifact {artifact_name} 超过下载上限({} MB)", max_mb),
|
||||
format!(
|
||||
"Artifact {artifact_name} exceeds download limit ({} MB)",
|
||||
max_mb
|
||||
),
|
||||
format!("artifact {artifact_name} 超过下载上限({max_mb} MB)"),
|
||||
format!("Artifact {artifact_name} exceeds download limit ({max_mb} MB)"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
@@ -579,13 +704,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_dir_segments_uses_v2() {
|
||||
fn remote_dir_segments_uses_current_layout() {
|
||||
let settings = WebDavSyncSettings {
|
||||
remote_root: "cc-switch-sync".to_string(),
|
||||
profile: "default".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
};
|
||||
let segs = remote_dir_segments(&settings);
|
||||
let segs = remote_dir_segments(&settings, RemoteLayout::Current);
|
||||
assert_eq!(segs, vec!["cc-switch-sync", "v2", "db-v6", "default"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_dir_segments_uses_legacy_layout() {
|
||||
let settings = WebDavSyncSettings {
|
||||
remote_root: "cc-switch-sync".to_string(),
|
||||
profile: "default".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
};
|
||||
let segs = remote_dir_segments(&settings, RemoteLayout::Legacy);
|
||||
assert_eq!(segs, vec!["cc-switch-sync", "v2", "default"]);
|
||||
}
|
||||
|
||||
@@ -622,13 +758,14 @@ mod tests {
|
||||
assert!(!ok);
|
||||
}
|
||||
|
||||
fn manifest_with(format: &str, version: u32) -> SyncManifest {
|
||||
fn manifest_with(format: &str, version: u32, db_compat_version: Option<u32>) -> SyncManifest {
|
||||
let mut artifacts = BTreeMap::new();
|
||||
artifacts.insert("db.sql".to_string(), artifact("abc", 1));
|
||||
artifacts.insert("skills.zip".to_string(), artifact("def", 2));
|
||||
SyncManifest {
|
||||
format: format.to_string(),
|
||||
version,
|
||||
db_compat_version,
|
||||
device_name: "My MacBook".to_string(),
|
||||
created_at: "2026-02-12T00:00:00Z".to_string(),
|
||||
artifacts,
|
||||
@@ -638,20 +775,63 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_accepts_supported_manifest() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION);
|
||||
assert!(validate_manifest_compat(&manifest).is_ok());
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_wrong_format() {
|
||||
let manifest = manifest_with("other-format", PROTOCOL_VERSION);
|
||||
assert!(validate_manifest_compat(&manifest).is_err());
|
||||
let manifest = manifest_with("other-format", PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_wrong_version() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION + 1);
|
||||
assert!(validate_manifest_compat(&manifest).is_err());
|
||||
let manifest = manifest_with(
|
||||
PROTOCOL_FORMAT,
|
||||
PROTOCOL_VERSION + 1,
|
||||
Some(DB_COMPAT_VERSION),
|
||||
);
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_accepts_legacy_manifest_without_db_compat() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None);
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_current_manifest_with_wrong_db_compat() {
|
||||
let manifest = manifest_with(
|
||||
PROTOCOL_FORMAT,
|
||||
PROTOCOL_VERSION,
|
||||
Some(LEGACY_DB_COMPAT_VERSION),
|
||||
);
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_legacy_manifest_from_newer_db_generation() {
|
||||
let manifest = manifest_with(
|
||||
PROTOCOL_FORMAT,
|
||||
PROTOCOL_VERSION,
|
||||
Some(DB_COMPAT_VERSION + 1),
|
||||
);
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_db_compat_version_defaults_legacy_layout_to_v5() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None);
|
||||
assert_eq!(
|
||||
effective_db_compat_version(&manifest, RemoteLayout::Legacy),
|
||||
Some(LEGACY_DB_COMPAT_VERSION)
|
||||
);
|
||||
assert_eq!(
|
||||
effective_db_compat_version(&manifest, RemoteLayout::Current),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -675,12 +855,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn manifest_serialization_uses_device_name_only() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION);
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
|
||||
let value = serde_json::to_value(&manifest).expect("serialize manifest");
|
||||
assert!(
|
||||
value.get("deviceName").is_some(),
|
||||
"manifest should contain deviceName"
|
||||
);
|
||||
assert_eq!(
|
||||
value.get("dbCompatVersion").and_then(|v| v.as_u64()),
|
||||
Some(DB_COMPAT_VERSION as u64)
|
||||
);
|
||||
assert!(
|
||||
value.get("deviceId").is_none(),
|
||||
"manifest should not contain deviceId"
|
||||
|
||||
@@ -350,8 +350,8 @@ fn copy_entry_with_total_limit<R: Read, W: Write>(
|
||||
let max_mb = max_total_bytes / 1024 / 1024;
|
||||
return Err(localized(
|
||||
"webdav.sync.skills_zip_too_large",
|
||||
format!("skills.zip 解压后体积超过上限({} MB)", max_mb),
|
||||
format!("skills.zip extracted size exceeds limit ({} MB)", max_mb),
|
||||
format!("skills.zip 解压后体积超过上限({max_mb} MB)"),
|
||||
format!("skills.zip extracted size exceeds limit ({max_mb} MB)"),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ pub mod providers;
|
||||
pub mod terminal;
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use providers::{claude, codex, gemini, openclaw, opencode};
|
||||
|
||||
@@ -79,3 +79,90 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
|
||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_session(
|
||||
provider_id: &str,
|
||||
session_id: &str,
|
||||
source_path: &str,
|
||||
) -> Result<bool, String> {
|
||||
let root = provider_root(provider_id)?;
|
||||
delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)
|
||||
}
|
||||
|
||||
fn delete_session_with_root(
|
||||
provider_id: &str,
|
||||
session_id: &str,
|
||||
source_path: &Path,
|
||||
root: &Path,
|
||||
) -> Result<bool, String> {
|
||||
let validated_root = canonicalize_existing_path(root, "session root")?;
|
||||
let validated_source = canonicalize_existing_path(source_path, "session source")?;
|
||||
|
||||
if !validated_source.starts_with(&validated_root) {
|
||||
return Err(format!(
|
||||
"Session source path is outside provider root: {}",
|
||||
source_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
match provider_id {
|
||||
"codex" => codex::delete_session(&validated_root, &validated_source, session_id),
|
||||
"claude" => claude::delete_session(&validated_root, &validated_source, session_id),
|
||||
"opencode" => opencode::delete_session(&validated_root, &validated_source, session_id),
|
||||
"openclaw" => openclaw::delete_session(&validated_root, &validated_source, session_id),
|
||||
"gemini" => gemini::delete_session(&validated_root, &validated_source, session_id),
|
||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_root(provider_id: &str) -> Result<PathBuf, String> {
|
||||
let root = match provider_id {
|
||||
"codex" => crate::codex_config::get_codex_config_dir().join("sessions"),
|
||||
"claude" => crate::config::get_claude_config_dir().join("projects"),
|
||||
"opencode" => opencode::get_opencode_data_dir(),
|
||||
"openclaw" => crate::openclaw_config::get_openclaw_dir().join("agents"),
|
||||
"gemini" => crate::gemini_config::get_gemini_dir().join("tmp"),
|
||||
_ => return Err(format!("Unsupported provider: {provider_id}")),
|
||||
};
|
||||
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
fn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf, String> {
|
||||
if !path.exists() {
|
||||
return Err(format!("{label} not found: {}", path.display()));
|
||||
}
|
||||
|
||||
path.canonicalize()
|
||||
.map_err(|e| format!("Failed to resolve {label} {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn rejects_source_path_outside_provider_root() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
let outside = tempdir().expect("tempdir");
|
||||
let source = outside.path().join("session.jsonl");
|
||||
std::fs::write(&source, "{}").expect("write source");
|
||||
|
||||
let err = delete_session_with_root("codex", "session-1", &source, root.path())
|
||||
.expect_err("expected outside-root path to be rejected");
|
||||
|
||||
assert!(err.contains("outside provider root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_source_path() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
let missing = root.path().join("missing.jsonl");
|
||||
|
||||
let err = delete_session_with_root("codex", "session-1", &missing, root.path())
|
||||
.expect_err("expected missing source path to fail");
|
||||
|
||||
assert!(err.contains("session source not found"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,41 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
let meta = parse_session(path).ok_or_else(|| {
|
||||
format!(
|
||||
"Failed to parse Claude session metadata: {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if meta.session_id != session_id {
|
||||
return Err(format!(
|
||||
"Claude session ID mismatch: expected {session_id}, found {}",
|
||||
meta.session_id
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(stem) = path.file_stem() {
|
||||
let sibling = path.parent().unwrap_or_else(|| Path::new("")).join(stem);
|
||||
remove_path_if_exists(&sibling).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete Claude session sidecar {}: {e}",
|
||||
sibling.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
std::fs::remove_file(path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete Claude session file {}: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
if is_agent_session(path) {
|
||||
return None;
|
||||
@@ -187,3 +222,50 @@ fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_path_if_exists(path: &Path) -> std::io::Result<()> {
|
||||
match std::fs::metadata(path) {
|
||||
Ok(meta) => {
|
||||
if meta.is_dir() {
|
||||
std::fs::remove_dir_all(path)
|
||||
} else {
|
||||
std::fs::remove_file(path)
|
||||
}
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn delete_session_removes_main_file_and_sidecar_directory() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("abc123-session.jsonl");
|
||||
let sidecar = temp.path().join("abc123-session");
|
||||
let subagents = sidecar.join("subagents");
|
||||
let tool_results = sidecar.join("tool-results");
|
||||
|
||||
std::fs::create_dir_all(&subagents).expect("create subagents");
|
||||
std::fs::create_dir_all(&tool_results).expect("create tool-results");
|
||||
std::fs::write(subagents.join("agent-1.jsonl"), "{}").expect("write subagent");
|
||||
std::fs::write(tool_results.join("tool-1.txt"), "result").expect("write tool result");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"sessionId\":\"session-123\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write session");
|
||||
|
||||
delete_session(temp.path(), &path, "session-123").expect("delete session");
|
||||
|
||||
assert!(!path.exists());
|
||||
assert!(!sidecar.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,27 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
let meta = parse_session(path)
|
||||
.ok_or_else(|| format!("Failed to parse Codex session metadata: {}", path.display()))?;
|
||||
|
||||
if meta.session_id != session_id {
|
||||
return Err(format!(
|
||||
"Codex session ID mismatch: expected {session_id}, found {}",
|
||||
meta.session_id
|
||||
));
|
||||
}
|
||||
|
||||
std::fs::remove_file(path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete Codex session file {}: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||
|
||||
@@ -192,3 +213,30 @@ fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn delete_session_removes_jsonl_file() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp
|
||||
.path()
|
||||
.join("rollout-2026-03-06T21-50-12-019cc369-bd7c-7891-b371-7b20b4fe0b18.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"019cc369-bd7c-7891-b371-7b20b4fe0b18\",\"cwd\":\"/tmp/project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"hello\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write session");
|
||||
|
||||
delete_session(temp.path(), &path, "019cc369-bd7c-7891-b371-7b20b4fe0b18")
|
||||
.expect("delete session");
|
||||
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,31 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
let meta = parse_session(path).ok_or_else(|| {
|
||||
format!(
|
||||
"Failed to parse Gemini session metadata: {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if meta.session_id != session_id {
|
||||
return Err(format!(
|
||||
"Gemini session ID mismatch: expected {session_id}, found {}",
|
||||
meta.session_id
|
||||
));
|
||||
}
|
||||
|
||||
std::fs::remove_file(path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete Gemini session file {}: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let data = std::fs::read_to_string(path).ok()?;
|
||||
let value: Value = serde_json::from_str(&data).ok()?;
|
||||
@@ -115,3 +140,36 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
resume_command: Some(format!("gemini --resume {session_id}")),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn delete_session_removes_json_file() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-2026-03-06T10-17-test.json");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{
|
||||
"sessionId": "gemini-session-123",
|
||||
"startTime": "2026-03-06T10:17:58.000Z",
|
||||
"lastUpdated": "2026-03-06T10:20:00.000Z",
|
||||
"messages": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"timestamp": "2026-03-06T10:17:58.000Z",
|
||||
"type": "user",
|
||||
"content": "hello"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("write session");
|
||||
|
||||
delete_session(temp.path(), &path, "gemini-session-123").expect("delete session");
|
||||
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ use std::path::Path;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::openclaw_config::get_openclaw_dir;
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
use crate::{
|
||||
config::write_json_file,
|
||||
session_manager::{SessionMessage, SessionMeta},
|
||||
};
|
||||
|
||||
use super::utils::{
|
||||
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
|
||||
@@ -115,6 +118,37 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
let meta = parse_session(path).ok_or_else(|| {
|
||||
format!(
|
||||
"Failed to parse OpenClaw session metadata: {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if meta.session_id != session_id {
|
||||
return Err(format!(
|
||||
"OpenClaw session ID mismatch: expected {session_id}, found {}",
|
||||
meta.session_id
|
||||
));
|
||||
}
|
||||
|
||||
let index_path = path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(""))
|
||||
.join("sessions.json");
|
||||
prune_sessions_index(&index_path, session_id, path)?;
|
||||
|
||||
std::fs::remove_file(path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete OpenClaw session file {}: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||
|
||||
@@ -206,3 +240,92 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume
|
||||
})
|
||||
}
|
||||
|
||||
fn prune_sessions_index(
|
||||
index_path: &Path,
|
||||
session_id: &str,
|
||||
source_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
if !index_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(index_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to read OpenClaw sessions index {}: {e}",
|
||||
index_path.display()
|
||||
)
|
||||
})?;
|
||||
let mut index: serde_json::Map<String, Value> =
|
||||
serde_json::from_str(&content).map_err(|e| {
|
||||
format!(
|
||||
"Failed to parse OpenClaw sessions index {}: {e}",
|
||||
index_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let source = source_path.to_string_lossy();
|
||||
index.retain(|_, entry| {
|
||||
let same_id = entry.get("sessionId").and_then(Value::as_str) == Some(session_id);
|
||||
let same_file = entry.get("sessionFile").and_then(Value::as_str) == Some(source.as_ref());
|
||||
!(same_id || same_file)
|
||||
});
|
||||
|
||||
write_json_file(index_path, &index).map_err(|e| {
|
||||
format!(
|
||||
"Failed to update OpenClaw sessions index {}: {e}",
|
||||
index_path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn delete_session_updates_index_and_removes_jsonl() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let sessions_dir = temp.path().join("main").join("sessions");
|
||||
std::fs::create_dir_all(&sessions_dir).expect("create sessions dir");
|
||||
|
||||
let session_path = sessions_dir.join("session-123.jsonl");
|
||||
std::fs::write(
|
||||
&session_path,
|
||||
concat!(
|
||||
"{\"type\":\"session\",\"id\":\"session-123\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write session");
|
||||
std::fs::write(
|
||||
sessions_dir.join("sessions.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"agent:main:main": {{
|
||||
"sessionId": "session-123",
|
||||
"sessionFile": "{}"
|
||||
}},
|
||||
"agent:main:other": {{
|
||||
"sessionId": "session-456",
|
||||
"sessionFile": "{}/session-456.jsonl"
|
||||
}}
|
||||
}}"#,
|
||||
session_path.display(),
|
||||
sessions_dir.display()
|
||||
),
|
||||
)
|
||||
.expect("write index");
|
||||
|
||||
delete_session(temp.path(), &session_path, "session-123").expect("delete session");
|
||||
|
||||
assert!(!session_path.exists());
|
||||
let updated: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(sessions_dir.join("sessions.json")).expect("read index"),
|
||||
)
|
||||
.expect("parse index");
|
||||
assert!(updated.get("agent:main:main").is_none());
|
||||
assert!(updated.get("agent:main:other").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const PROVIDER_ID: &str = "opencode";
|
||||
///
|
||||
/// Respects `XDG_DATA_HOME` on all platforms; falls back to
|
||||
/// `~/.local/share/opencode/storage/`.
|
||||
fn get_opencode_data_dir() -> PathBuf {
|
||||
pub(crate) fn get_opencode_data_dir() -> PathBuf {
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("opencode").join("storage");
|
||||
@@ -111,6 +111,71 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub fn delete_session(storage: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
if path.file_name().and_then(|name| name.to_str()) != Some(session_id) {
|
||||
return Err(format!(
|
||||
"OpenCode session path does not match session ID: expected {session_id}, found {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let mut message_files = Vec::new();
|
||||
collect_json_files(path, &mut message_files);
|
||||
|
||||
let mut message_ids = Vec::new();
|
||||
for message_path in &message_files {
|
||||
let data = match std::fs::read_to_string(message_path) {
|
||||
Ok(data) => data,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&data) {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let Some(message_id) = value.get("id").and_then(Value::as_str) {
|
||||
message_ids.push(message_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
for message_id in &message_ids {
|
||||
let part_dir = storage.join("part").join(message_id);
|
||||
remove_dir_all_if_exists(&part_dir).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete OpenCode part directory {}: {e}",
|
||||
part_dir.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let session_diff_path = storage
|
||||
.join("session_diff")
|
||||
.join(format!("{session_id}.json"));
|
||||
remove_file_if_exists(&session_diff_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete OpenCode session diff {}: {e}",
|
||||
session_diff_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
remove_dir_all_if_exists(path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete OpenCode message directory {}: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(session_file) = find_session_file(storage, session_id) {
|
||||
remove_file_if_exists(&session_file).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete OpenCode session file {}: {e}",
|
||||
session_file.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(storage: &Path, path: &Path) -> Option<SessionMeta> {
|
||||
let data = std::fs::read_to_string(path).ok()?;
|
||||
let value: Value = serde_json::from_str(&data).ok()?;
|
||||
@@ -274,3 +339,97 @@ fn collect_json_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_session_file(storage: &Path, session_id: &str) -> Option<PathBuf> {
|
||||
let session_root = storage.join("session");
|
||||
let mut files = Vec::new();
|
||||
collect_json_files(&session_root, &mut files);
|
||||
let expected = format!("{session_id}.json");
|
||||
|
||||
files
|
||||
.into_iter()
|
||||
.find(|path| path.file_name().and_then(|name| name.to_str()) == Some(expected.as_str()))
|
||||
}
|
||||
|
||||
fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_dir_all_if_exists(path: &Path) -> std::io::Result<()> {
|
||||
match std::fs::remove_dir_all(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn delete_session_removes_session_diff_messages_and_parts() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let storage = temp.path();
|
||||
let project_id = "project-123";
|
||||
let session_id = "ses_123";
|
||||
let session_dir = storage.join("session").join(project_id);
|
||||
let message_dir = storage.join("message").join(session_id);
|
||||
let session_diff = storage
|
||||
.join("session_diff")
|
||||
.join(format!("{session_id}.json"));
|
||||
let part_dir = storage.join("part").join("msg_1");
|
||||
let session_file = session_dir.join(format!("{session_id}.json"));
|
||||
|
||||
std::fs::create_dir_all(&session_dir).expect("create session dir");
|
||||
std::fs::create_dir_all(&message_dir).expect("create message dir");
|
||||
std::fs::create_dir_all(&part_dir).expect("create part dir");
|
||||
std::fs::create_dir_all(storage.join("project")).expect("create project dir");
|
||||
std::fs::create_dir_all(storage.join("session_diff")).expect("create session diff dir");
|
||||
|
||||
std::fs::write(
|
||||
&session_file,
|
||||
format!(
|
||||
r#"{{
|
||||
"id": "{session_id}",
|
||||
"projectID": "{project_id}",
|
||||
"directory": "/tmp/project",
|
||||
"time": {{ "created": 1, "updated": 2 }}
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
.expect("write session file");
|
||||
std::fs::write(
|
||||
message_dir.join("msg_1.json"),
|
||||
format!(r#"{{"id":"msg_1","sessionID":"{session_id}","role":"user"}}"#),
|
||||
)
|
||||
.expect("write message file");
|
||||
std::fs::write(
|
||||
part_dir.join("prt_1.json"),
|
||||
r#"{"id":"prt_1","messageID":"msg_1"}"#,
|
||||
)
|
||||
.expect("write part file");
|
||||
std::fs::write(&session_diff, "[]").expect("write session diff");
|
||||
std::fs::write(
|
||||
storage.join("project").join(format!("{project_id}.json")),
|
||||
r#"{"id":"project-123"}"#,
|
||||
)
|
||||
.expect("write project file");
|
||||
|
||||
delete_session(storage, &message_dir, session_id).expect("delete session");
|
||||
|
||||
assert!(!session_file.exists());
|
||||
assert!(!message_dir.exists());
|
||||
assert!(!session_diff.exists());
|
||||
assert!(!part_dir.exists());
|
||||
assert!(storage
|
||||
.join("project")
|
||||
.join(format!("{project_id}.json"))
|
||||
.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ fn default_profile() -> String {
|
||||
"default".to_string()
|
||||
}
|
||||
|
||||
/// WebDAV v2 同步设置
|
||||
/// WebDAV 同步设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebDavSyncSettings {
|
||||
@@ -199,6 +199,12 @@ pub struct AppSettings {
|
||||
/// User has confirmed the stream check first-run notice
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stream_check_confirmed: Option<bool>,
|
||||
/// Whether to show the failover toggle independently on the main page
|
||||
#[serde(default)]
|
||||
pub enable_failover_toggle: bool,
|
||||
/// User has confirmed the failover toggle first-run notice
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub failover_confirmed: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
|
||||
@@ -286,6 +292,8 @@ impl Default for AppSettings {
|
||||
proxy_confirmed: None,
|
||||
usage_confirmed: None,
|
||||
stream_check_confirmed: None,
|
||||
enable_failover_toggle: false,
|
||||
failover_confirmed: None,
|
||||
language: None,
|
||||
visible_apps: None,
|
||||
claude_config_dir: None,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.11.1",
|
||||
"version": "3.12.0",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+27
-16
@@ -33,7 +33,7 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
||||
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||
import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||
import { openclawKeys, useOpenClawHealth } from "@/hooks/useOpenClaw";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { useAutoCompact } from "@/hooks/useAutoCompact";
|
||||
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||
@@ -70,6 +70,7 @@ import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel";
|
||||
import EnvPanel from "@/components/openclaw/EnvPanel";
|
||||
import ToolsPanel from "@/components/openclaw/ToolsPanel";
|
||||
import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel";
|
||||
import OpenClawHealthBanner from "@/components/openclaw/OpenClawHealthBanner";
|
||||
|
||||
type View =
|
||||
| "providers"
|
||||
@@ -229,6 +230,16 @@ function App() {
|
||||
});
|
||||
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||
const currentProviderId = data?.currentProviderId ?? "";
|
||||
const isOpenClawView =
|
||||
activeApp === "openclaw" &&
|
||||
(currentView === "providers" ||
|
||||
currentView === "workspace" ||
|
||||
currentView === "sessions" ||
|
||||
currentView === "openclawEnv" ||
|
||||
currentView === "openclawTools" ||
|
||||
currentView === "openclawAgents");
|
||||
const { data: openclawHealthWarnings = [] } =
|
||||
useOpenClawHealth(isOpenClawView);
|
||||
const hasSkillsSupport = true;
|
||||
const hasSessionSupport =
|
||||
activeApp === "claude" ||
|
||||
@@ -244,7 +255,7 @@ function App() {
|
||||
deleteProvider,
|
||||
saveUsageScript,
|
||||
setAsDefaultModel,
|
||||
} = useProviderActions(activeApp);
|
||||
} = useProviderActions(activeApp, isProxyRunning);
|
||||
|
||||
const disableOmoMutation = useDisableCurrentOmo();
|
||||
const handleDisableOmo = () => {
|
||||
@@ -544,6 +555,9 @@ function App() {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: openclawKeys.liveProviderIds,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: openclawKeys.health,
|
||||
});
|
||||
}
|
||||
toast.success(
|
||||
t("notifications.removeFromConfigSuccess", {
|
||||
@@ -967,31 +981,25 @@ function App() {
|
||||
<div className="flex flex-1 min-w-0 items-center justify-end gap-1.5">
|
||||
{currentView === "providers" &&
|
||||
activeApp !== "opencode" &&
|
||||
activeApp !== "openclaw" &&
|
||||
settingsData?.enableLocalProxy && (
|
||||
activeApp !== "openclaw" && (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
>
|
||||
<ProxyToggle activeApp={activeApp} />
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
isCurrentAppTakeoverActive
|
||||
? "opacity-100 max-w-[100px] scale-100"
|
||||
: "opacity-0 max-w-0 scale-75 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
{settingsData?.enableLocalProxy && (
|
||||
<ProxyToggle activeApp={activeApp} />
|
||||
)}
|
||||
{settingsData?.enableFailoverToggle && (
|
||||
<FailoverToggle activeApp={activeApp} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className="flex flex-1 min-w-0 overflow-x-hidden justify-end items-center"
|
||||
className="flex flex-1 min-w-0 overflow-x-hidden items-center"
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
className="flex shrink-0 items-center gap-1.5 ml-auto"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
>
|
||||
{currentView === "prompts" && (
|
||||
@@ -1225,6 +1233,9 @@ function App() {
|
||||
</header>
|
||||
|
||||
<main className="flex-1 min-h-0 flex flex-col overflow-y-auto animate-fade-in">
|
||||
{isOpenClawView && openclawHealthWarnings.length > 0 && (
|
||||
<OpenClawHealthBanner warnings={openclawHealthWarnings} />
|
||||
)}
|
||||
{renderContent()}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ColorPickerProps {
|
||||
value?: string;
|
||||
@@ -28,12 +29,14 @@ const DEFAULT_PRESETS = [
|
||||
export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
value = "#4285F4",
|
||||
onValueChange,
|
||||
label = "图标颜色",
|
||||
label,
|
||||
presets = DEFAULT_PRESETS,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const displayLabel = label ?? t("providerIcon.color", "图标颜色");
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label>{label}</Label>
|
||||
<Label>{displayLabel}</Label>
|
||||
|
||||
{/* 颜色预设 */}
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
|
||||
@@ -180,6 +180,16 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
||||
{firstUsage.unit}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 扩展字段 extra */}
|
||||
{firstUsage.extra && (
|
||||
<span
|
||||
className="text-gray-500 dark:text-gray-400 truncate max-w-[150px]"
|
||||
title={firstUsage.extra}
|
||||
>
|
||||
{firstUsage.extra}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Save, Plus, Trash2 } from "lucide-react";
|
||||
import { Save, Plus, Trash2, TriangleAlert } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useOpenClawAgentsDefaults,
|
||||
@@ -10,6 +10,7 @@ import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import type { OpenClawAgentsDefaults } from "@/types";
|
||||
import { useOpenClawModelOptions } from "./hooks/useOpenClawModelOptions";
|
||||
import { getOpenClawTimeoutInputValue } from "./utils";
|
||||
|
||||
const UNSET_SENTINEL = "__unset__";
|
||||
|
||||
@@ -50,9 +52,16 @@ const AgentsDefaultsPanel: React.FC = () => {
|
||||
|
||||
// Extract known extra fields
|
||||
setWorkspace(String(agentsData.workspace ?? ""));
|
||||
setTimeout_(String(agentsData.timeout ?? ""));
|
||||
setTimeout_(getOpenClawTimeoutInputValue(agentsData));
|
||||
setContextTokens(String(agentsData.contextTokens ?? ""));
|
||||
setMaxConcurrent(String(agentsData.maxConcurrent ?? ""));
|
||||
} else {
|
||||
setPrimaryModel("");
|
||||
setFallbacks([]);
|
||||
setWorkspace("");
|
||||
setTimeout_("");
|
||||
setContextTokens("");
|
||||
setMaxConcurrent("");
|
||||
}
|
||||
}, [agentsData]);
|
||||
|
||||
@@ -146,8 +155,9 @@ const AgentsDefaultsPanel: React.FC = () => {
|
||||
};
|
||||
|
||||
const timeoutNum = timeout.trim() ? parseNum(timeout) : undefined;
|
||||
if (timeoutNum !== undefined) updated.timeout = timeoutNum;
|
||||
else delete updated.timeout;
|
||||
if (timeoutNum !== undefined) updated.timeoutSeconds = timeoutNum;
|
||||
else delete updated.timeoutSeconds;
|
||||
delete updated.timeout;
|
||||
|
||||
const ctxNum = contextTokens.trim() ? parseNum(contextTokens) : undefined;
|
||||
if (ctxNum !== undefined) updated.contextTokens = ctxNum;
|
||||
@@ -180,6 +190,11 @@ const AgentsDefaultsPanel: React.FC = () => {
|
||||
}
|
||||
|
||||
const noModels = modelOptions.length === 0 && !modelsLoading;
|
||||
const hasLegacyTimeout =
|
||||
agentsData !== undefined &&
|
||||
agentsData !== null &&
|
||||
typeof agentsData.timeout === "number" &&
|
||||
typeof agentsData.timeoutSeconds !== "number";
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-4 pb-8">
|
||||
@@ -187,6 +202,23 @@ const AgentsDefaultsPanel: React.FC = () => {
|
||||
{t("openclaw.agents.description")}
|
||||
</p>
|
||||
|
||||
{hasLegacyTimeout && (
|
||||
<Alert className="mb-4 border-amber-500/30 bg-amber-500/5">
|
||||
<TriangleAlert className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t("openclaw.agents.legacyTimeoutTitle", {
|
||||
defaultValue: "Legacy timeout detected",
|
||||
})}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("openclaw.agents.legacyTimeoutDescription", {
|
||||
defaultValue:
|
||||
"This config still uses agents.defaults.timeout. Saving here will migrate it to timeoutSeconds.",
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Model Configuration Card */}
|
||||
<div className="rounded-xl border border-border bg-card p-5 mb-4">
|
||||
<h3 className="text-sm font-medium mb-4">
|
||||
|
||||
@@ -1,96 +1,71 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2, Save, Eye, EyeOff } from "lucide-react";
|
||||
import { Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useOpenClawEnv, useSaveOpenClawEnv } from "@/hooks/useOpenClaw";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { OpenClawEnvConfig } from "@/types";
|
||||
|
||||
interface EnvEntry {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
isNew?: boolean;
|
||||
}
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import { parseOpenClawEnvEditorValue } from "./utils";
|
||||
|
||||
const EnvPanel: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: envData, isLoading } = useOpenClawEnv();
|
||||
const saveEnvMutation = useSaveOpenClawEnv();
|
||||
const [entries, setEntries] = useState<EnvEntry[]>([]);
|
||||
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set());
|
||||
const [editorValue, setEditorValue] = useState("{}");
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (envData) {
|
||||
const items: EnvEntry[] = Object.entries(envData).map(([key, value]) => ({
|
||||
id: crypto.randomUUID(),
|
||||
key,
|
||||
value: String(value ?? ""),
|
||||
}));
|
||||
setEntries(items.length > 0 ? items : []);
|
||||
}
|
||||
const nextValue =
|
||||
envData && Object.keys(envData).length > 0
|
||||
? JSON.stringify(envData, null, 2)
|
||||
: "{}";
|
||||
setEditorValue(nextValue);
|
||||
}, [envData]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const env: OpenClawEnvConfig = {};
|
||||
const seen = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const trimmedKey = entry.key.trim();
|
||||
if (trimmedKey) {
|
||||
if (seen.has(trimmedKey)) {
|
||||
toast.error(t("openclaw.env.duplicateKey", { key: trimmedKey }));
|
||||
return;
|
||||
}
|
||||
seen.add(trimmedKey);
|
||||
env[trimmedKey] = entry.value;
|
||||
}
|
||||
}
|
||||
const env = parseOpenClawEnvEditorValue(editorValue);
|
||||
await saveEnvMutation.mutateAsync(env);
|
||||
toast.success(t("openclaw.env.saveSuccess"));
|
||||
} catch (error) {
|
||||
const detail = extractErrorMessage(error);
|
||||
let description = detail || undefined;
|
||||
if (detail === "OPENCLAW_ENV_EMPTY") {
|
||||
description = t("openclaw.env.empty", {
|
||||
defaultValue:
|
||||
"OpenClaw env cannot be empty. Use {} for an empty object.",
|
||||
});
|
||||
} else if (detail === "OPENCLAW_ENV_INVALID_JSON") {
|
||||
description = t("openclaw.env.invalidJson", {
|
||||
defaultValue: "OpenClaw env must be valid JSON.",
|
||||
});
|
||||
} else if (detail === "OPENCLAW_ENV_OBJECT_REQUIRED") {
|
||||
description = t("openclaw.env.objectRequired", {
|
||||
defaultValue: "OpenClaw env must be a JSON object.",
|
||||
});
|
||||
}
|
||||
toast.error(t("openclaw.env.saveFailed"), {
|
||||
description: detail || undefined,
|
||||
description,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
setEntries((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), key: "", value: "", isNew: true },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
setEntries((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateEntry = (index: number, field: "key" | "value", val: string) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, [field]: val } : entry,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleVisibility = (key: string) => {
|
||||
setVisibleKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isApiKey = (key: string) => /key|token|secret|password/i.test(key);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
|
||||
@@ -106,66 +81,23 @@ const EnvPanel: React.FC = () => {
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t("openclaw.env.description")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{entries.map((entry, index) => {
|
||||
const sensitive = isApiKey(entry.key);
|
||||
const visibilityId = entry.key || `__new_${index}`;
|
||||
const visible = visibleKeys.has(visibilityId);
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<div className="w-[200px] flex-shrink-0">
|
||||
<Input
|
||||
value={entry.key}
|
||||
onChange={(e) => updateEntry(index, "key", e.target.value)}
|
||||
placeholder={t("openclaw.env.keyPlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
autoFocus={entry.isNew}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-1">
|
||||
<Input
|
||||
type={sensitive && !visible ? "password" : "text"}
|
||||
value={entry.value}
|
||||
onChange={(e) => updateEntry(index, "value", e.target.value)}
|
||||
placeholder={t("openclaw.env.valuePlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{sensitive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-9 w-9 text-muted-foreground"
|
||||
onClick={() => toggleVisibility(visibilityId)}
|
||||
>
|
||||
{visible ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeEntry(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
{t("openclaw.env.editorHint", {
|
||||
defaultValue:
|
||||
"Edit the full env section as JSON. Nested objects such as env.vars and env.shellEnv are supported.",
|
||||
})}
|
||||
</div>
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Button variant="outline" size="sm" onClick={addEntry}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t("openclaw.env.add")}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<JsonEditor
|
||||
value={editorValue}
|
||||
onChange={setEditorValue}
|
||||
darkMode={isDarkMode}
|
||||
rows={18}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import type { OpenClawHealthWarning } from "@/types";
|
||||
|
||||
interface OpenClawHealthBannerProps {
|
||||
warnings: OpenClawHealthWarning[];
|
||||
}
|
||||
|
||||
function getWarningText(
|
||||
code: string,
|
||||
fallback: string,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
) {
|
||||
switch (code) {
|
||||
case "invalid_tools_profile":
|
||||
return t("openclaw.health.invalidToolsProfile", {
|
||||
defaultValue:
|
||||
"tools.profile contains an unsupported value. OpenClaw currently expects minimal, coding, messaging, or full.",
|
||||
});
|
||||
case "legacy_agents_timeout":
|
||||
return t("openclaw.health.legacyTimeout", {
|
||||
defaultValue:
|
||||
"agents.defaults.timeout is deprecated. Save the Agents panel to migrate it to timeoutSeconds.",
|
||||
});
|
||||
case "stringified_env_vars":
|
||||
return t("openclaw.health.stringifiedEnvVars", {
|
||||
defaultValue:
|
||||
"env.vars should be an object, but the current value looks stringified or malformed.",
|
||||
});
|
||||
case "stringified_env_shell_env":
|
||||
return t("openclaw.health.stringifiedShellEnv", {
|
||||
defaultValue:
|
||||
"env.shellEnv should be an object, but the current value looks stringified or malformed.",
|
||||
});
|
||||
case "config_parse_failed":
|
||||
return t("openclaw.health.parseFailed", {
|
||||
defaultValue:
|
||||
"openclaw.json could not be parsed as valid JSON5. Fix the file before editing it here.",
|
||||
});
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const OpenClawHealthBanner: React.FC<OpenClawHealthBannerProps> = ({
|
||||
warnings,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
warnings.map((warning) => ({
|
||||
...warning,
|
||||
text: getWarningText(warning.code, warning.message, t),
|
||||
})),
|
||||
[t, warnings],
|
||||
);
|
||||
|
||||
if (warnings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-4">
|
||||
<Alert className="border-amber-500/30 bg-amber-500/5">
|
||||
<TriangleAlert className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t("openclaw.health.title", {
|
||||
defaultValue: "OpenClaw config warnings detected",
|
||||
})}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
{items.map((warning) => (
|
||||
<li key={`${warning.code}:${warning.path ?? warning.message}`}>
|
||||
{warning.text}
|
||||
{warning.path ? ` (${warning.path})` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenClawHealthBanner;
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2, Save } from "lucide-react";
|
||||
import { Plus, Trash2, Save, TriangleAlert } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useOpenClawTools, useSaveOpenClawTools } from "@/hooks/useOpenClaw";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -14,15 +15,20 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { OpenClawToolsConfig } from "@/types";
|
||||
import type { OpenClawToolsConfig, OpenClawToolsProfile } from "@/types";
|
||||
import {
|
||||
getOpenClawToolsProfileSelectValue,
|
||||
getOpenClawUnsupportedProfile,
|
||||
OPENCLAW_TOOL_PROFILES,
|
||||
OPENCLAW_UNSET_PROFILE,
|
||||
OPENCLAW_UNSUPPORTED_PROFILE,
|
||||
} from "./utils";
|
||||
|
||||
interface ListItem {
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const PROFILE_OPTIONS = ["default", "strict", "permissive", "custom"];
|
||||
|
||||
const ToolsPanel: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: toolsData, isLoading } = useOpenClawTools();
|
||||
@@ -35,29 +41,50 @@ const ToolsPanel: React.FC = () => {
|
||||
if (toolsData) {
|
||||
setConfig(toolsData);
|
||||
setAllowList(
|
||||
(toolsData.allow ?? []).map((v) => ({
|
||||
(toolsData.allow ?? []).map((value) => ({
|
||||
id: crypto.randomUUID(),
|
||||
value: v,
|
||||
value,
|
||||
})),
|
||||
);
|
||||
setDenyList(
|
||||
(toolsData.deny ?? []).map((v) => ({
|
||||
(toolsData.deny ?? []).map((value) => ({
|
||||
id: crypto.randomUUID(),
|
||||
value: v,
|
||||
value,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [toolsData]);
|
||||
|
||||
const unsupportedProfile = getOpenClawUnsupportedProfile(config.profile);
|
||||
|
||||
const profileLabels = useMemo<Record<OpenClawToolsProfile, string>>(
|
||||
() => ({
|
||||
minimal: t("openclaw.tools.profileMinimal", {
|
||||
defaultValue: "Minimal",
|
||||
}),
|
||||
coding: t("openclaw.tools.profileCoding", {
|
||||
defaultValue: "Coding",
|
||||
}),
|
||||
messaging: t("openclaw.tools.profileMessaging", {
|
||||
defaultValue: "Messaging",
|
||||
}),
|
||||
full: t("openclaw.tools.profileFull", {
|
||||
defaultValue: "Full",
|
||||
}),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const { profile, allow, deny, ...other } = config;
|
||||
const newConfig: OpenClawToolsConfig = {
|
||||
...other,
|
||||
profile: config.profile,
|
||||
profile,
|
||||
allow: allowList.map((item) => item.value).filter((s) => s.trim()),
|
||||
deny: denyList.map((item) => item.value).filter((s) => s.trim()),
|
||||
};
|
||||
|
||||
await saveToolsMutation.mutateAsync(newConfig);
|
||||
toast.success(t("openclaw.tools.saveSuccess"));
|
||||
} catch (error) {
|
||||
@@ -101,29 +128,66 @@ const ToolsPanel: React.FC = () => {
|
||||
{t("openclaw.tools.description")}
|
||||
</p>
|
||||
|
||||
{/* Profile selector */}
|
||||
{unsupportedProfile && (
|
||||
<Alert className="mb-6 border-amber-500/30 bg-amber-500/5">
|
||||
<TriangleAlert className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t("openclaw.tools.unsupportedProfileTitle", {
|
||||
defaultValue: "Unsupported tools profile",
|
||||
})}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("openclaw.tools.unsupportedProfileDescription", {
|
||||
value: unsupportedProfile,
|
||||
defaultValue:
|
||||
"The current tools.profile value '{{value}}' is not in the supported OpenClaw list. It will be preserved until you choose a new value.",
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<Label className="mb-2 block">{t("openclaw.tools.profile")}</Label>
|
||||
<Select
|
||||
value={config.profile ?? "default"}
|
||||
onValueChange={(val) =>
|
||||
setConfig((prev) => ({ ...prev, profile: val }))
|
||||
}
|
||||
value={getOpenClawToolsProfileSelectValue(config.profile)}
|
||||
onValueChange={(value) => {
|
||||
if (value === OPENCLAW_UNSUPPORTED_PROFILE) return;
|
||||
if (value === OPENCLAW_UNSET_PROFILE) {
|
||||
setConfig((prev) => ({ ...prev, profile: undefined }));
|
||||
return;
|
||||
}
|
||||
setConfig((prev) => ({ ...prev, profile: value }));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectTrigger className="w-[220px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROFILE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{t(`openclaw.tools.profiles.${opt}`)}
|
||||
<SelectItem value={OPENCLAW_UNSET_PROFILE}>
|
||||
{t("openclaw.tools.profileUnset", {
|
||||
defaultValue: "Not set",
|
||||
})}
|
||||
</SelectItem>
|
||||
{unsupportedProfile && (
|
||||
<SelectItem
|
||||
value={OPENCLAW_UNSUPPORTED_PROFILE}
|
||||
disabled={true}
|
||||
>{`${unsupportedProfile} (${t(
|
||||
"openclaw.tools.unsupportedProfileLabel",
|
||||
{
|
||||
defaultValue: "unsupported",
|
||||
},
|
||||
)})`}</SelectItem>
|
||||
)}
|
||||
{OPENCLAW_TOOL_PROFILES.map((profile) => (
|
||||
<SelectItem key={profile} value={profile}>
|
||||
{profileLabels[profile]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Allow list */}
|
||||
<div className="mb-6">
|
||||
<Label className="mb-2 block">{t("openclaw.tools.allowList")}</Label>
|
||||
<div className="space-y-2">
|
||||
@@ -163,7 +227,6 @@ const ToolsPanel: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deny list */}
|
||||
<div className="mb-6">
|
||||
<Label className="mb-2 block">{t("openclaw.tools.denyList")}</Label>
|
||||
<div className="space-y-2">
|
||||
@@ -203,7 +266,6 @@ const ToolsPanel: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
OpenClawAgentsDefaults,
|
||||
OpenClawEnvConfig,
|
||||
OpenClawToolsProfile,
|
||||
} from "@/types";
|
||||
|
||||
export const OPENCLAW_TOOL_PROFILES: OpenClawToolsProfile[] = [
|
||||
"minimal",
|
||||
"coding",
|
||||
"messaging",
|
||||
"full",
|
||||
];
|
||||
|
||||
export const OPENCLAW_UNSUPPORTED_PROFILE = "__unsupported_profile__";
|
||||
export const OPENCLAW_UNSET_PROFILE = "__unset_profile__";
|
||||
|
||||
export function parseOpenClawEnvEditorValue(raw: string): OpenClawEnvConfig {
|
||||
if (!raw.trim()) {
|
||||
throw new Error("OPENCLAW_ENV_EMPTY");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("OPENCLAW_ENV_INVALID_JSON");
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("OPENCLAW_ENV_OBJECT_REQUIRED");
|
||||
}
|
||||
return parsed as OpenClawEnvConfig;
|
||||
}
|
||||
|
||||
export function isOpenClawToolsProfile(
|
||||
profile?: string,
|
||||
): profile is OpenClawToolsProfile {
|
||||
return (
|
||||
typeof profile === "string" &&
|
||||
OPENCLAW_TOOL_PROFILES.includes(profile as OpenClawToolsProfile)
|
||||
);
|
||||
}
|
||||
|
||||
export function getOpenClawToolsProfileSelectValue(profile?: string): string {
|
||||
if (!profile) {
|
||||
return OPENCLAW_UNSET_PROFILE;
|
||||
}
|
||||
return isOpenClawToolsProfile(profile)
|
||||
? profile
|
||||
: OPENCLAW_UNSUPPORTED_PROFILE;
|
||||
}
|
||||
|
||||
export function getOpenClawUnsupportedProfile(profile?: string): string | null {
|
||||
if (!profile || isOpenClawToolsProfile(profile)) {
|
||||
return null;
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function getOpenClawTimeoutInputValue(
|
||||
defaults?: OpenClawAgentsDefaults | null,
|
||||
): string {
|
||||
const timeoutSeconds =
|
||||
typeof defaults?.timeoutSeconds === "number"
|
||||
? defaults.timeoutSeconds
|
||||
: undefined;
|
||||
const legacyTimeout =
|
||||
typeof defaults?.timeout === "number" ? defaults.timeout : undefined;
|
||||
const value = timeoutSeconds ?? legacyTimeout;
|
||||
return value === undefined ? "" : String(value);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ProviderForm,
|
||||
type ProviderFormValues,
|
||||
} from "@/components/providers/forms/ProviderForm";
|
||||
import { providersApi, vscodeApi, type AppId } from "@/lib/api";
|
||||
import { openclawApi, providersApi, vscodeApi, type AppId } from "@/lib/api";
|
||||
|
||||
interface EditProviderDialogProps {
|
||||
open: boolean;
|
||||
@@ -73,6 +73,26 @@ export function EditProviderDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
if (appId === "openclaw") {
|
||||
try {
|
||||
const live = await openclawApi.getLiveProvider(provider.id);
|
||||
if (!cancelled && live && typeof live === "object") {
|
||||
setLiveSettings(live);
|
||||
} else if (!cancelled) {
|
||||
setLiveSettings(null);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLiveSettings(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setHasLoadedLive(true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentId = await providersApi.getCurrent(appId);
|
||||
if (currentId && provider.id === currentId) {
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField } from "./shared";
|
||||
import type { ProviderCategory, ClaudeApiFormat } from "@/types";
|
||||
import type {
|
||||
ProviderCategory,
|
||||
ClaudeApiFormat,
|
||||
ClaudeApiKeyField,
|
||||
} from "@/types";
|
||||
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
|
||||
|
||||
interface EndpointCandidate {
|
||||
@@ -68,6 +72,14 @@ interface ClaudeFormFieldsProps {
|
||||
// API Format (for third-party providers that use OpenAI Chat Completions format)
|
||||
apiFormat: ClaudeApiFormat;
|
||||
onApiFormatChange: (format: ClaudeApiFormat) => void;
|
||||
|
||||
// Auth Key Field (ANTHROPIC_AUTH_TOKEN vs ANTHROPIC_API_KEY)
|
||||
apiKeyField: ClaudeApiKeyField;
|
||||
onApiKeyFieldChange: (field: ClaudeApiKeyField) => void;
|
||||
|
||||
// Full URL mode
|
||||
isFullUrl: boolean;
|
||||
onFullUrlChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function ClaudeFormFields({
|
||||
@@ -102,6 +114,10 @@ export function ClaudeFormFields({
|
||||
speedTestEndpoints,
|
||||
apiFormat,
|
||||
onApiFormatChange,
|
||||
apiKeyField,
|
||||
onApiKeyFieldChange,
|
||||
isFullUrl,
|
||||
onFullUrlChange,
|
||||
}: ClaudeFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -164,11 +180,16 @@ export function ClaudeFormFields({
|
||||
onChange={onBaseUrlChange}
|
||||
placeholder={t("providerForm.apiEndpointPlaceholder")}
|
||||
hint={
|
||||
apiFormat === "openai_chat"
|
||||
? t("providerForm.apiHintOAI")
|
||||
: t("providerForm.apiHint")
|
||||
apiFormat === "openai_responses"
|
||||
? t("providerForm.apiHintResponses")
|
||||
: apiFormat === "openai_chat"
|
||||
? t("providerForm.apiHintOAI")
|
||||
: t("providerForm.apiHint")
|
||||
}
|
||||
onManageClick={() => onEndpointModalToggle(true)}
|
||||
showFullUrlToggle={true}
|
||||
isFullUrl={isFullUrl}
|
||||
onFullUrlChange={onFullUrlChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -209,6 +230,11 @@ export function ClaudeFormFields({
|
||||
defaultValue: "OpenAI Chat Completions (需转换)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_responses">
|
||||
{t("providerForm.apiFormatOpenAIResponses", {
|
||||
defaultValue: "OpenAI Responses API (需转换)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -219,6 +245,40 @@ export function ClaudeFormFields({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 认证字段选择(仅非官方供应商显示) */}
|
||||
{shouldShowModelSelector && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="apiKeyField">
|
||||
{t("providerForm.authField", { defaultValue: "认证字段" })}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={apiKeyField}
|
||||
onValueChange={(v) => onApiKeyFieldChange(v as ClaudeApiKeyField)}
|
||||
>
|
||||
<SelectTrigger id="apiKeyField" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
|
||||
{t("providerForm.authFieldAuthToken", {
|
||||
defaultValue: "ANTHROPIC_AUTH_TOKEN(默认)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="ANTHROPIC_API_KEY">
|
||||
{t("providerForm.authFieldApiKey", {
|
||||
defaultValue: "ANTHROPIC_API_KEY",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.authFieldHint", {
|
||||
defaultValue: "选择写入配置的认证环境变量名",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模型选择器 */}
|
||||
{shouldShowModelSelector && (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -112,14 +112,18 @@ export function CodexFormFields({
|
||||
value={modelName}
|
||||
onChange={(e) => onModelNameChange(e.target.value)}
|
||||
placeholder={t("codexConfig.modelNamePlaceholder", {
|
||||
defaultValue: "例如: gpt-5-codex",
|
||||
defaultValue: "例如: gpt-5.4",
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-border-default bg-background text-foreground rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("codexConfig.modelNameHint", {
|
||||
defaultValue: "指定使用的模型,将自动更新到 config.toml 中",
|
||||
})}
|
||||
{modelName.trim()
|
||||
? t("codexConfig.modelNameHint", {
|
||||
defaultValue: "指定使用的模型,将自动更新到 config.toml 中",
|
||||
})
|
||||
: t("providerForm.modelHint", {
|
||||
defaultValue: "💡 留空将使用供应商的默认模型",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ApiKeySection } from "./shared";
|
||||
import { openclawApiProtocols } from "@/config/openclawProviderPresets";
|
||||
import type { ProviderCategory, OpenClawModel } from "@/types";
|
||||
@@ -42,6 +43,10 @@ interface OpenClawFormFieldsProps {
|
||||
// Models
|
||||
models: OpenClawModel[];
|
||||
onModelsChange: (models: OpenClawModel[]) => void;
|
||||
|
||||
// User-Agent
|
||||
userAgent: boolean;
|
||||
onUserAgentChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function OpenClawFormFields({
|
||||
@@ -58,6 +63,8 @@ export function OpenClawFormFields({
|
||||
onApiChange,
|
||||
models,
|
||||
onModelsChange,
|
||||
userAgent,
|
||||
onUserAgentChange,
|
||||
}: OpenClawFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedModels, setExpandedModels] = useState<Record<number, boolean>>(
|
||||
@@ -95,6 +102,7 @@ export function OpenClawFormFields({
|
||||
contextWindow: undefined,
|
||||
maxTokens: undefined,
|
||||
cost: undefined,
|
||||
input: ["text"],
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -205,6 +213,21 @@ export function OpenClawFormFields({
|
||||
partnerPromotionKey={partnerPromotionKey}
|
||||
/>
|
||||
|
||||
{/* User-Agent */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>
|
||||
{t("openclaw.userAgent", { defaultValue: "发送 User-Agent" })}
|
||||
</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("openclaw.userAgentHint", {
|
||||
defaultValue: "部分供应商需要浏览器 User-Agent 才能正常访问。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={userAgent} onCheckedChange={onUserAgentChange} />
|
||||
</div>
|
||||
|
||||
{/* Models Editor */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -318,7 +341,66 @@ export function OpenClawFormFields({
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 pt-2">
|
||||
{/* Context Window, Max Tokens and Reasoning row */}
|
||||
{/* Reasoning, Input Types row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.reasoning", {
|
||||
defaultValue: "推理模式",
|
||||
})}
|
||||
</label>
|
||||
<div className="flex items-center h-9 gap-2">
|
||||
<Switch
|
||||
checked={model.reasoning ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleModelChange(index, "reasoning", checked)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{model.reasoning
|
||||
? t("openclaw.reasoningOn", {
|
||||
defaultValue: "启用",
|
||||
})
|
||||
: t("openclaw.reasoningOff", {
|
||||
defaultValue: "关闭",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.inputTypes", {
|
||||
defaultValue: "输入类型",
|
||||
})}
|
||||
</label>
|
||||
{/* "text" is checked by default but can be unchecked —
|
||||
some models genuinely don't support text input, and
|
||||
OpenClaw works fine with an empty or image-only array. */}
|
||||
<div className="flex items-center gap-4 h-9">
|
||||
{(["text", "image"] as const).map((type) => (
|
||||
<label
|
||||
key={type}
|
||||
className="flex items-center gap-1.5 cursor-pointer select-none"
|
||||
>
|
||||
<Checkbox
|
||||
checked={(model.input ?? ["text"]).includes(type)}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = model.input ?? ["text"];
|
||||
const next = checked
|
||||
? [...new Set([...current, type])]
|
||||
: current.filter((v) => v !== type);
|
||||
handleModelChange(index, "input", next);
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">{type}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* Context Window and Max Tokens row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
@@ -362,30 +444,7 @@ export function OpenClawFormFields({
|
||||
placeholder="32000"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.reasoning", {
|
||||
defaultValue: "推理模式",
|
||||
})}
|
||||
</label>
|
||||
<div className="flex items-center h-9 gap-2">
|
||||
<Switch
|
||||
checked={model.reasoning ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleModelChange(index, "reasoning", checked)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{model.reasoning
|
||||
? t("openclaw.reasoningOn", {
|
||||
defaultValue: "启用",
|
||||
})
|
||||
: t("openclaw.reasoningOff", {
|
||||
defaultValue: "关闭",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* Cost row */}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ProviderTestConfig,
|
||||
ProviderProxyConfig,
|
||||
ClaudeApiFormat,
|
||||
ClaudeApiKeyField,
|
||||
} from "@/types";
|
||||
import {
|
||||
providerPresets,
|
||||
@@ -196,6 +197,9 @@ export function ProviderForm({
|
||||
setDraftCustomEndpoints([]);
|
||||
}
|
||||
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
|
||||
setLocalIsFullUrl(
|
||||
appId === "claude" ? (initialData?.meta?.isFullUrl ?? false) : false,
|
||||
);
|
||||
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
|
||||
setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });
|
||||
setPricingConfig({
|
||||
@@ -237,16 +241,49 @@ export function ProviderForm({
|
||||
mode: "onSubmit",
|
||||
});
|
||||
|
||||
const handleSettingsConfigChange = useCallback(
|
||||
(config: string) => {
|
||||
form.setValue("settingsConfig", config);
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
|
||||
if (appId !== "claude") return "anthropic";
|
||||
return initialData?.meta?.apiFormat ?? "anthropic";
|
||||
});
|
||||
|
||||
const [localIsFullUrl, setLocalIsFullUrl] = useState<boolean>(() => {
|
||||
if (appId !== "claude") return false;
|
||||
return initialData?.meta?.isFullUrl ?? false;
|
||||
});
|
||||
|
||||
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
|
||||
setLocalApiFormat(format);
|
||||
}, []);
|
||||
|
||||
const [localApiKeyField, setLocalApiKeyField] = useState<ClaudeApiKeyField>(
|
||||
() => {
|
||||
if (appId !== "claude") return "ANTHROPIC_AUTH_TOKEN";
|
||||
if (initialData?.meta?.apiKeyField) return initialData.meta.apiKeyField;
|
||||
// Infer from existing config env
|
||||
const env = (initialData?.settingsConfig as Record<string, unknown>)
|
||||
?.env as Record<string, unknown> | undefined;
|
||||
if (env?.ANTHROPIC_API_KEY !== undefined) return "ANTHROPIC_API_KEY";
|
||||
return "ANTHROPIC_AUTH_TOKEN";
|
||||
},
|
||||
);
|
||||
const {
|
||||
apiKey,
|
||||
handleApiKeyChange,
|
||||
showApiKey: shouldShowApiKey,
|
||||
} = useApiKeyState({
|
||||
initialConfig: form.getValues("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
onConfigChange: handleSettingsConfigChange,
|
||||
selectedPresetId,
|
||||
category,
|
||||
appType: appId,
|
||||
apiKeyField: appId === "claude" ? localApiKeyField : undefined,
|
||||
});
|
||||
|
||||
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
|
||||
@@ -254,7 +291,7 @@ export function ProviderForm({
|
||||
category,
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
codexConfig: "",
|
||||
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
onSettingsConfigChange: handleSettingsConfigChange,
|
||||
onCodexConfigChange: () => {},
|
||||
});
|
||||
|
||||
@@ -267,18 +304,32 @@ export function ProviderForm({
|
||||
handleModelChange,
|
||||
} = useModelState({
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
onConfigChange: handleSettingsConfigChange,
|
||||
});
|
||||
|
||||
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
|
||||
if (appId !== "claude") return "anthropic";
|
||||
return initialData?.meta?.apiFormat ?? "anthropic";
|
||||
});
|
||||
|
||||
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
|
||||
setLocalApiFormat(format);
|
||||
}, []);
|
||||
const handleApiKeyFieldChange = useCallback(
|
||||
(field: ClaudeApiKeyField) => {
|
||||
const prev = localApiKeyField;
|
||||
setLocalApiKeyField(field);
|
||||
|
||||
// Swap the env key name in settingsConfig
|
||||
try {
|
||||
const raw = form.getValues("settingsConfig");
|
||||
const config = JSON.parse(raw || "{}");
|
||||
if (config?.env && prev in config.env) {
|
||||
const value = config.env[prev];
|
||||
delete config.env[prev];
|
||||
config.env[field] = value;
|
||||
const updated = JSON.stringify(config, null, 2);
|
||||
form.setValue("settingsConfig", updated);
|
||||
handleSettingsConfigChange(updated);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors during editing
|
||||
}
|
||||
},
|
||||
[localApiKeyField, form, handleSettingsConfigChange],
|
||||
);
|
||||
const {
|
||||
codexAuth,
|
||||
codexConfig,
|
||||
@@ -373,7 +424,7 @@ export function ProviderForm({
|
||||
selectedPresetId: appId === "claude" ? selectedPresetId : null,
|
||||
presetEntries: appId === "claude" ? presetEntries : [],
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
onConfigChange: handleSettingsConfigChange,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -386,8 +437,10 @@ export function ProviderForm({
|
||||
handleExtract: handleClaudeExtract,
|
||||
} = useCommonConfigSnippet({
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
onConfigChange: handleSettingsConfigChange,
|
||||
initialData: appId === "claude" ? initialData : undefined,
|
||||
initialEnabled:
|
||||
appId === "claude" ? initialData?.meta?.commonConfigEnabled : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
enabled: appId === "claude",
|
||||
});
|
||||
@@ -404,6 +457,8 @@ export function ProviderForm({
|
||||
codexConfig,
|
||||
onConfigChange: handleCodexConfigChange,
|
||||
initialData: appId === "codex" ? initialData : undefined,
|
||||
initialEnabled:
|
||||
appId === "codex" ? initialData?.meta?.commonConfigEnabled : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
});
|
||||
|
||||
@@ -487,6 +542,8 @@ export function ProviderForm({
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
initialData: appId === "gemini" ? initialData : undefined,
|
||||
initialEnabled:
|
||||
appId === "gemini" ? initialData?.meta?.commonConfigEnabled : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
});
|
||||
|
||||
@@ -809,6 +866,14 @@ export function ProviderForm({
|
||||
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
|
||||
payload.meta = {
|
||||
...(baseMeta ?? {}),
|
||||
commonConfigEnabled:
|
||||
appId === "claude"
|
||||
? useCommonConfig
|
||||
: appId === "codex"
|
||||
? useCodexCommonConfigFlag
|
||||
: appId === "gemini"
|
||||
? useGeminiCommonConfigFlag
|
||||
: undefined,
|
||||
endpointAutoSelect,
|
||||
testConfig: testConfig.enabled ? testConfig : undefined,
|
||||
proxyConfig: proxyConfig.enabled ? proxyConfig : undefined,
|
||||
@@ -823,6 +888,16 @@ export function ProviderForm({
|
||||
appId === "claude" && category !== "official"
|
||||
? localApiFormat
|
||||
: undefined,
|
||||
apiKeyField:
|
||||
appId === "claude" &&
|
||||
category !== "official" &&
|
||||
localApiKeyField !== "ANTHROPIC_AUTH_TOKEN"
|
||||
? localApiKeyField
|
||||
: undefined,
|
||||
isFullUrl:
|
||||
appId === "claude" && category !== "official" && localIsFullUrl
|
||||
? true
|
||||
: undefined,
|
||||
};
|
||||
|
||||
onSubmit(payload);
|
||||
@@ -1061,6 +1136,8 @@ export function ProviderForm({
|
||||
setLocalApiFormat("anthropic");
|
||||
}
|
||||
|
||||
setLocalApiKeyField(preset.apiKeyField ?? "ANTHROPIC_AUTH_TOKEN");
|
||||
|
||||
form.reset({
|
||||
name: preset.name,
|
||||
websiteUrl: preset.websiteUrl ?? "",
|
||||
@@ -1266,6 +1343,10 @@ export function ProviderForm({
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
apiFormat={localApiFormat}
|
||||
onApiFormatChange={handleApiFormatChange}
|
||||
apiKeyField={localApiKeyField}
|
||||
onApiKeyFieldChange={handleApiKeyFieldChange}
|
||||
isFullUrl={localIsFullUrl}
|
||||
onFullUrlChange={setLocalIsFullUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1381,6 +1462,8 @@ export function ProviderForm({
|
||||
onApiChange={openclawForm.handleOpenclawApiChange}
|
||||
models={openclawForm.openclawModels}
|
||||
onModelsChange={openclawForm.handleOpenclawModelsChange}
|
||||
userAgent={openclawForm.openclawUserAgent}
|
||||
onUserAgentChange={openclawForm.handleOpenclawUserAgentChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ interface UseApiKeyStateProps {
|
||||
selectedPresetId: string | null;
|
||||
category?: ProviderCategory;
|
||||
appType?: string;
|
||||
apiKeyField?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,10 +25,11 @@ export function useApiKeyState({
|
||||
selectedPresetId,
|
||||
category,
|
||||
appType,
|
||||
apiKeyField,
|
||||
}: UseApiKeyStateProps) {
|
||||
const [apiKey, setApiKey] = useState(() => {
|
||||
if (initialConfig) {
|
||||
return getApiKeyFromConfig(initialConfig, appType);
|
||||
return getApiKeyFromConfig(initialConfig, appType, apiKeyField);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
@@ -44,11 +46,11 @@ export function useApiKeyState({
|
||||
}
|
||||
|
||||
// 从配置中提取 API Key(如果不存在则返回空字符串)
|
||||
const extracted = getApiKeyFromConfig(initialConfig, appType);
|
||||
const extracted = getApiKeyFromConfig(initialConfig, appType, apiKeyField);
|
||||
if (extracted !== apiKey) {
|
||||
setApiKey(extracted);
|
||||
}
|
||||
}, [initialConfig, appType, apiKey]);
|
||||
}, [initialConfig, appType, apiKeyField, apiKey]);
|
||||
|
||||
const handleApiKeyChange = useCallback(
|
||||
(key: string) => {
|
||||
@@ -58,7 +60,7 @@ export function useApiKeyState({
|
||||
initialConfig || "{}",
|
||||
key.trim(),
|
||||
{
|
||||
// 最佳实践:仅在“新增模式”且“非官方类别”时补齐缺失字段
|
||||
// 最佳实践:仅在"新增模式"且"非官方类别"时补齐缺失字段
|
||||
// - 新增模式:selectedPresetId !== null
|
||||
// - 非官方类别:category !== undefined && category !== "official"
|
||||
// - 官方类别:不创建字段(UI 也会禁用输入框)
|
||||
@@ -68,12 +70,20 @@ export function useApiKeyState({
|
||||
category !== undefined &&
|
||||
category !== "official",
|
||||
appType,
|
||||
apiKeyField,
|
||||
},
|
||||
);
|
||||
|
||||
onConfigChange(configString);
|
||||
},
|
||||
[initialConfig, selectedPresetId, category, appType, onConfigChange],
|
||||
[
|
||||
initialConfig,
|
||||
selectedPresetId,
|
||||
category,
|
||||
appType,
|
||||
apiKeyField,
|
||||
onConfigChange,
|
||||
],
|
||||
);
|
||||
|
||||
const showApiKey = useCallback(
|
||||
|
||||
@@ -60,10 +60,8 @@ export function useBaseUrlState({
|
||||
if (!codexConfig) return;
|
||||
|
||||
const extracted = extractCodexBaseUrl(codexConfig) || "";
|
||||
if (extracted !== codexBaseUrl) {
|
||||
setCodexBaseUrl(extracted);
|
||||
}
|
||||
}, [appType, category, codexConfig, codexBaseUrl]);
|
||||
setCodexBaseUrl((prev) => (prev === extracted ? prev : extracted));
|
||||
}, [appType, category, codexConfig]);
|
||||
|
||||
// 从Claude配置同步到 state(Gemini)
|
||||
useEffect(() => {
|
||||
@@ -116,7 +114,7 @@ export function useBaseUrlState({
|
||||
const sanitized = url.trim();
|
||||
setCodexBaseUrl(sanitized);
|
||||
|
||||
if (!sanitized || !onCodexConfigChange) {
|
||||
if (!onCodexConfigChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ interface UseCodexCommonConfigProps {
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
initialEnabled?: boolean;
|
||||
selectedPresetId?: string;
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ export function useCodexCommonConfig({
|
||||
codexConfig,
|
||||
onConfigChange,
|
||||
initialData,
|
||||
initialEnabled,
|
||||
selectedPresetId,
|
||||
}: UseCodexCommonConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -42,11 +44,14 @@ export function useCodexCommonConfig({
|
||||
const isUpdatingFromCommonConfig = useRef(false);
|
||||
// 用于跟踪新建模式是否已初始化默认勾选
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
// 用于跟踪编辑模式是否已初始化显式开关/预览
|
||||
const hasInitializedEditMode = useRef(false);
|
||||
|
||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||
useEffect(() => {
|
||||
hasInitializedNewMode.current = false;
|
||||
}, [selectedPresetId]);
|
||||
hasInitializedEditMode.current = false;
|
||||
}, [selectedPresetId, initialEnabled]);
|
||||
|
||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
||||
useEffect(() => {
|
||||
@@ -107,10 +112,39 @@ export function useCodexCommonConfig({
|
||||
typeof initialData.settingsConfig.config === "string"
|
||||
? initialData.settingsConfig.config
|
||||
: "";
|
||||
const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet);
|
||||
const inferredHasCommon = hasTomlCommonConfigSnippet(
|
||||
config,
|
||||
commonConfigSnippet,
|
||||
);
|
||||
const hasCommon = initialEnabled ?? inferredHasCommon;
|
||||
setUseCommonConfig(hasCommon);
|
||||
|
||||
if (hasCommon && !inferredHasCommon && !hasInitializedEditMode.current) {
|
||||
hasInitializedEditMode.current = true;
|
||||
const { updatedConfig, error } = updateTomlCommonConfigSnippet(
|
||||
codexConfig,
|
||||
commonConfigSnippet,
|
||||
true,
|
||||
);
|
||||
if (!error) {
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(updatedConfig);
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
}
|
||||
} else {
|
||||
hasInitializedEditMode.current = true;
|
||||
}
|
||||
}
|
||||
}, [initialData, commonConfigSnippet, isLoading]);
|
||||
}, [
|
||||
codexConfig,
|
||||
commonConfigSnippet,
|
||||
initialData,
|
||||
initialEnabled,
|
||||
isLoading,
|
||||
onConfigChange,
|
||||
]);
|
||||
|
||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
||||
useEffect(() => {
|
||||
|
||||
@@ -74,10 +74,8 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
return;
|
||||
}
|
||||
const extracted = extractCodexBaseUrl(codexConfig) || "";
|
||||
if (extracted !== codexBaseUrl) {
|
||||
setCodexBaseUrl(extracted);
|
||||
}
|
||||
}, [codexConfig, codexBaseUrl]);
|
||||
setCodexBaseUrl((prev) => (prev === extracted ? prev : extracted));
|
||||
}, [codexConfig]);
|
||||
|
||||
// 与 TOML 配置保持模型名称同步
|
||||
useEffect(() => {
|
||||
@@ -85,10 +83,8 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
return;
|
||||
}
|
||||
const extracted = extractCodexModelName(codexConfig) || "";
|
||||
if (extracted !== codexModelName) {
|
||||
setCodexModelName(extracted);
|
||||
}
|
||||
}, [codexConfig, codexModelName]);
|
||||
setCodexModelName((prev) => (prev === extracted ? prev : extracted));
|
||||
}, [codexConfig]);
|
||||
|
||||
// 获取 API Key(从 auth JSON)
|
||||
const getCodexAuthApiKey = useCallback((authString: string): string => {
|
||||
@@ -165,10 +161,6 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
const sanitized = url.trim();
|
||||
setCodexBaseUrl(sanitized);
|
||||
|
||||
if (!sanitized) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdatingCodexBaseUrlRef.current = true;
|
||||
setCodexConfig((prev) => setCodexBaseUrlInConfig(prev, sanitized));
|
||||
setTimeout(() => {
|
||||
@@ -184,10 +176,6 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
const trimmed = modelName.trim();
|
||||
setCodexModelName(trimmed);
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdatingCodexModelNameRef.current = true;
|
||||
setCodexConfig((prev) => setCodexModelNameInConfig(prev, trimmed));
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -18,6 +18,7 @@ interface UseCommonConfigSnippetProps {
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
initialEnabled?: boolean;
|
||||
selectedPresetId?: string;
|
||||
/** When false, the hook skips all logic and returns disabled state. Default: true */
|
||||
enabled?: boolean;
|
||||
@@ -31,6 +32,7 @@ export function useCommonConfigSnippet({
|
||||
settingsConfig,
|
||||
onConfigChange,
|
||||
initialData,
|
||||
initialEnabled,
|
||||
selectedPresetId,
|
||||
enabled = true,
|
||||
}: UseCommonConfigSnippetProps) {
|
||||
@@ -47,12 +49,15 @@ export function useCommonConfigSnippet({
|
||||
const isUpdatingFromCommonConfig = useRef(false);
|
||||
// 用于跟踪新建模式是否已初始化默认勾选
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
// 用于跟踪编辑模式是否已初始化显式开关/预览
|
||||
const hasInitializedEditMode = useRef(false);
|
||||
|
||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
hasInitializedNewMode.current = false;
|
||||
}, [selectedPresetId, enabled]);
|
||||
hasInitializedEditMode.current = false;
|
||||
}, [selectedPresetId, enabled, initialEnabled]);
|
||||
|
||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
||||
useEffect(() => {
|
||||
@@ -115,13 +120,40 @@ export function useCommonConfigSnippet({
|
||||
if (!enabled) return;
|
||||
if (initialData && !isLoading) {
|
||||
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
||||
const hasCommon = hasCommonConfigSnippet(
|
||||
const inferredHasCommon = hasCommonConfigSnippet(
|
||||
configString,
|
||||
commonConfigSnippet,
|
||||
);
|
||||
const hasCommon = initialEnabled ?? inferredHasCommon;
|
||||
setUseCommonConfig(hasCommon);
|
||||
|
||||
if (hasCommon && !inferredHasCommon && !hasInitializedEditMode.current) {
|
||||
hasInitializedEditMode.current = true;
|
||||
const { updatedConfig, error } = updateCommonConfigSnippet(
|
||||
settingsConfig,
|
||||
commonConfigSnippet,
|
||||
true,
|
||||
);
|
||||
if (!error) {
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(updatedConfig);
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
}
|
||||
} else {
|
||||
hasInitializedEditMode.current = true;
|
||||
}
|
||||
}
|
||||
}, [enabled, initialData, commonConfigSnippet, isLoading]);
|
||||
}, [
|
||||
enabled,
|
||||
initialData,
|
||||
initialEnabled,
|
||||
commonConfigSnippet,
|
||||
isLoading,
|
||||
onConfigChange,
|
||||
settingsConfig,
|
||||
]);
|
||||
|
||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
||||
useEffect(() => {
|
||||
|
||||
@@ -19,6 +19,7 @@ interface UseGeminiCommonConfigProps {
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
initialEnabled?: boolean;
|
||||
selectedPresetId?: string;
|
||||
}
|
||||
|
||||
@@ -43,6 +44,7 @@ export function useGeminiCommonConfig({
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
initialData,
|
||||
initialEnabled,
|
||||
selectedPresetId,
|
||||
}: UseGeminiCommonConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -58,11 +60,14 @@ export function useGeminiCommonConfig({
|
||||
const isUpdatingFromCommonConfig = useRef(false);
|
||||
// 用于跟踪新建模式是否已初始化默认勾选
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
// 用于跟踪编辑模式是否已初始化显式开关/预览
|
||||
const hasInitializedEditMode = useRef(false);
|
||||
|
||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||
useEffect(() => {
|
||||
hasInitializedNewMode.current = false;
|
||||
}, [selectedPresetId]);
|
||||
hasInitializedEditMode.current = false;
|
||||
}, [selectedPresetId, initialEnabled]);
|
||||
|
||||
const parseSnippetEnv = useCallback(
|
||||
(
|
||||
@@ -220,20 +225,46 @@ export function useGeminiCommonConfig({
|
||||
: {};
|
||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
||||
if (parsed.error) return;
|
||||
const hasCommon = hasEnvCommonConfigSnippet(
|
||||
const inferredHasCommon = hasEnvCommonConfigSnippet(
|
||||
env,
|
||||
parsed.env as Record<string, string>,
|
||||
);
|
||||
const hasCommon = initialEnabled ?? inferredHasCommon;
|
||||
setUseCommonConfig(hasCommon);
|
||||
|
||||
if (
|
||||
hasCommon &&
|
||||
!inferredHasCommon &&
|
||||
!hasInitializedEditMode.current
|
||||
) {
|
||||
hasInitializedEditMode.current = true;
|
||||
const currentEnv = envStringToObj(envValue);
|
||||
const merged = applySnippetToEnv(currentEnv, parsed.env);
|
||||
const nextEnvString = envObjToString(merged);
|
||||
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onEnvChange(nextEnvString);
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
} else {
|
||||
hasInitializedEditMode.current = true;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}
|
||||
}, [
|
||||
applySnippetToEnv,
|
||||
commonConfigSnippet,
|
||||
envObjToString,
|
||||
envStringToObj,
|
||||
envValue,
|
||||
hasEnvCommonConfigSnippet,
|
||||
initialData,
|
||||
initialEnabled,
|
||||
isLoading,
|
||||
onEnvChange,
|
||||
parseSnippetEnv,
|
||||
]);
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ interface UseOpenclawFormStateParams {
|
||||
getSettingsConfig: () => string;
|
||||
}
|
||||
|
||||
export const OPENCLAW_DEFAULT_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0";
|
||||
|
||||
export interface OpenclawFormState {
|
||||
openclawProviderKey: string;
|
||||
setOpenclawProviderKey: (key: string) => void;
|
||||
@@ -21,16 +24,19 @@ export interface OpenclawFormState {
|
||||
openclawApiKey: string;
|
||||
openclawApi: string;
|
||||
openclawModels: OpenClawModel[];
|
||||
openclawUserAgent: boolean;
|
||||
existingOpenclawKeys: string[];
|
||||
handleOpenclawBaseUrlChange: (baseUrl: string) => void;
|
||||
handleOpenclawApiKeyChange: (apiKey: string) => void;
|
||||
handleOpenclawApiChange: (api: string) => void;
|
||||
handleOpenclawModelsChange: (models: OpenClawModel[]) => void;
|
||||
handleOpenclawUserAgentChange: (enabled: boolean) => void;
|
||||
resetOpenclawState: (config?: {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
api?: string;
|
||||
models?: OpenClawModel[];
|
||||
headers?: Record<string, string>;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
@@ -92,6 +98,16 @@ export function useOpenclawFormState({
|
||||
return parseOpenclawField<OpenClawModel[]>(initialData, "models", []);
|
||||
});
|
||||
|
||||
const [openclawUserAgent, setOpenclawUserAgent] = useState<boolean>(() => {
|
||||
if (appId !== "openclaw") return true;
|
||||
const headers = parseOpenclawField<Record<string, string>>(
|
||||
initialData,
|
||||
"headers",
|
||||
{},
|
||||
);
|
||||
return "User-Agent" in headers;
|
||||
});
|
||||
|
||||
const updateOpenclawConfig = useCallback(
|
||||
(updater: (config: Record<string, any>) => void) => {
|
||||
try {
|
||||
@@ -147,18 +163,35 @@ export function useOpenclawFormState({
|
||||
[updateOpenclawConfig],
|
||||
);
|
||||
|
||||
const handleOpenclawUserAgentChange = useCallback(
|
||||
(enabled: boolean) => {
|
||||
setOpenclawUserAgent(enabled);
|
||||
updateOpenclawConfig((config) => {
|
||||
if (enabled) {
|
||||
config.headers = { "User-Agent": OPENCLAW_DEFAULT_USER_AGENT };
|
||||
} else {
|
||||
delete config.headers;
|
||||
}
|
||||
});
|
||||
},
|
||||
[updateOpenclawConfig],
|
||||
);
|
||||
|
||||
const resetOpenclawState = useCallback(
|
||||
(config?: {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
api?: string;
|
||||
models?: OpenClawModel[];
|
||||
headers?: Record<string, string>;
|
||||
}) => {
|
||||
setOpenclawProviderKey("");
|
||||
setOpenclawBaseUrl(config?.baseUrl || "");
|
||||
setOpenclawApiKey(config?.apiKey || "");
|
||||
setOpenclawApi(config?.api || "openai-completions");
|
||||
setOpenclawModels(config?.models || []);
|
||||
const ua = config?.headers ? "User-Agent" in config.headers : false;
|
||||
setOpenclawUserAgent(ua);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -170,11 +203,13 @@ export function useOpenclawFormState({
|
||||
openclawApiKey,
|
||||
openclawApi,
|
||||
openclawModels,
|
||||
openclawUserAgent,
|
||||
existingOpenclawKeys,
|
||||
handleOpenclawBaseUrlChange,
|
||||
handleOpenclawApiKeyChange,
|
||||
handleOpenclawApiChange,
|
||||
handleOpenclawModelsChange,
|
||||
handleOpenclawUserAgentChange,
|
||||
resetOpenclawState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Zap } from "lucide-react";
|
||||
import { Zap, Link2 } from "lucide-react";
|
||||
|
||||
interface EndpointFieldProps {
|
||||
id: string;
|
||||
@@ -13,6 +13,9 @@ interface EndpointFieldProps {
|
||||
showManageButton?: boolean;
|
||||
onManageClick?: () => void;
|
||||
manageButtonLabel?: string;
|
||||
showFullUrlToggle?: boolean;
|
||||
isFullUrl?: boolean;
|
||||
onFullUrlChange?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function EndpointField({
|
||||
@@ -25,6 +28,9 @@ export function EndpointField({
|
||||
showManageButton = true,
|
||||
onManageClick,
|
||||
manageButtonLabel,
|
||||
showFullUrlToggle = false,
|
||||
isFullUrl = false,
|
||||
onFullUrlChange,
|
||||
}: EndpointFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -55,6 +61,35 @@ export function EndpointField({
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{showFullUrlToggle && onFullUrlChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFullUrlChange(!isFullUrl)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-md border transition-colors ${
|
||||
isFullUrl
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"
|
||||
}`}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{isFullUrl
|
||||
? t("providerForm.fullUrlEnabled", {
|
||||
defaultValue: "完整 URL 模式",
|
||||
})
|
||||
: t("providerForm.fullUrlDisabled", {
|
||||
defaultValue: "标记为完整 URL",
|
||||
})}
|
||||
</button>
|
||||
{isFullUrl && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("providerForm.fullUrlHint", {
|
||||
defaultValue: "代理将直接使用此 URL,不拼接路径",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hint ? (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">{hint}</p>
|
||||
|
||||
@@ -146,15 +146,24 @@ export function CircuitBreakerConfigPanel() {
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">加载中...</div>;
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("circuitBreaker.loading", "加载中...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">熔断器配置</h3>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("circuitBreaker.title", "熔断器配置")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
调整熔断器参数以控制故障检测和恢复行为
|
||||
{t(
|
||||
"circuitBreaker.description",
|
||||
"调整熔断器参数以控制故障检测和恢复行为",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -163,7 +172,9 @@ export function CircuitBreakerConfigPanel() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 失败阈值 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="failureThreshold">失败阈值</Label>
|
||||
<Label htmlFor="failureThreshold">
|
||||
{t("circuitBreaker.failureThreshold", "失败阈值")}
|
||||
</Label>
|
||||
<Input
|
||||
id="failureThreshold"
|
||||
type="number"
|
||||
@@ -175,13 +186,18 @@ export function CircuitBreakerConfigPanel() {
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
连续失败多少次后打开熔断器
|
||||
{t(
|
||||
"circuitBreaker.failureThresholdHint",
|
||||
"连续失败多少次后打开熔断器",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 超时时间 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timeoutSeconds">超时时间(秒)</Label>
|
||||
<Label htmlFor="timeoutSeconds">
|
||||
{t("circuitBreaker.timeoutSeconds", "超时时间(秒)")}
|
||||
</Label>
|
||||
<Input
|
||||
id="timeoutSeconds"
|
||||
type="number"
|
||||
@@ -193,13 +209,18 @@ export function CircuitBreakerConfigPanel() {
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
熔断器打开后多久尝试恢复(半开状态)
|
||||
{t(
|
||||
"circuitBreaker.timeoutSecondsHint",
|
||||
"熔断器打开后多久尝试恢复(半开状态)",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 成功阈值 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="successThreshold">成功阈值</Label>
|
||||
<Label htmlFor="successThreshold">
|
||||
{t("circuitBreaker.successThreshold", "成功阈值")}
|
||||
</Label>
|
||||
<Input
|
||||
id="successThreshold"
|
||||
type="number"
|
||||
@@ -211,13 +232,18 @@ export function CircuitBreakerConfigPanel() {
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
半开状态下成功多少次后关闭熔断器
|
||||
{t(
|
||||
"circuitBreaker.successThresholdHint",
|
||||
"半开状态下成功多少次后关闭熔断器",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 错误率阈值 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="errorRateThreshold">错误率阈值 (%)</Label>
|
||||
<Label htmlFor="errorRateThreshold">
|
||||
{t("circuitBreaker.errorRateThreshold", "错误率阈值 (%)")}
|
||||
</Label>
|
||||
<Input
|
||||
id="errorRateThreshold"
|
||||
type="number"
|
||||
@@ -230,13 +256,18 @@ export function CircuitBreakerConfigPanel() {
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
错误率超过此值时打开熔断器
|
||||
{t(
|
||||
"circuitBreaker.errorRateThresholdHint",
|
||||
"错误率超过此值时打开熔断器",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 最小请求数 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minRequests">最小请求数</Label>
|
||||
<Label htmlFor="minRequests">
|
||||
{t("circuitBreaker.minRequests", "最小请求数")}
|
||||
</Label>
|
||||
<Input
|
||||
id="minRequests"
|
||||
type="number"
|
||||
@@ -248,42 +279,75 @@ export function CircuitBreakerConfigPanel() {
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
计算错误率前的最小请求数
|
||||
{t("circuitBreaker.minRequestsHint", "计算错误率前的最小请求数")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleSave} disabled={updateConfig.isPending}>
|
||||
{updateConfig.isPending ? "保存中..." : "保存配置"}
|
||||
{updateConfig.isPending
|
||||
? t("common.saving", "保存中...")
|
||||
: t("circuitBreaker.saveConfig", "保存配置")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={updateConfig.isPending}
|
||||
>
|
||||
重置
|
||||
{t("common.reset", "重置")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 说明信息 */}
|
||||
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
|
||||
<h4 className="font-medium">配置说明</h4>
|
||||
<h4 className="font-medium">
|
||||
{t("circuitBreaker.instructionsTitle", "配置说明")}
|
||||
</h4>
|
||||
<ul className="space-y-1 text-muted-foreground">
|
||||
<li>
|
||||
• <strong>失败阈值</strong>:连续失败达到此次数时,熔断器打开
|
||||
•{" "}
|
||||
<strong>{t("circuitBreaker.failureThreshold", "失败阈值")}</strong>
|
||||
:
|
||||
{t(
|
||||
"circuitBreaker.instructions.failureThreshold",
|
||||
"连续失败达到此次数时,熔断器打开",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
• <strong>超时时间</strong>:熔断器打开后,等待此时间后尝试半开
|
||||
• <strong>{t("circuitBreaker.timeoutSeconds", "超时时间")}</strong>
|
||||
:
|
||||
{t(
|
||||
"circuitBreaker.instructions.timeout",
|
||||
"熔断器打开后,等待此时间后尝试半开",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
• <strong>成功阈值</strong>:半开状态下,成功达到此次数时关闭熔断器
|
||||
•{" "}
|
||||
<strong>{t("circuitBreaker.successThreshold", "成功阈值")}</strong>
|
||||
:
|
||||
{t(
|
||||
"circuitBreaker.instructions.successThreshold",
|
||||
"半开状态下,成功达到此次数时关闭熔断器",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
• <strong>错误率阈值</strong>:错误率超过此值时,熔断器打开
|
||||
•{" "}
|
||||
<strong>
|
||||
{t("circuitBreaker.errorRateThreshold", "错误率阈值")}
|
||||
</strong>
|
||||
:
|
||||
{t(
|
||||
"circuitBreaker.instructions.errorRate",
|
||||
"错误率超过此值时,熔断器打开",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
• <strong>最小请求数</strong>:只有请求数达到此值后才计算错误率
|
||||
• <strong>{t("circuitBreaker.minRequests", "最小请求数")}</strong>:
|
||||
{t(
|
||||
"circuitBreaker.instructions.minRequests",
|
||||
"只有请求数达到此值后才计算错误率",
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -44,12 +44,17 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
|
||||
const tooltipText = takeoverEnabled
|
||||
? isRunning
|
||||
? t("proxy.takeover.tooltip.active", {
|
||||
appLabel,
|
||||
address: status?.address,
|
||||
port: status?.port,
|
||||
defaultValue: `${appLabel} 已接管 - ${status?.address}:${status?.port}\n切换该应用供应商为热切换`,
|
||||
})
|
||||
: t("proxy.takeover.tooltip.broken", {
|
||||
appLabel,
|
||||
defaultValue: `${appLabel} 已接管,但代理服务未运行`,
|
||||
})
|
||||
: t("proxy.takeover.tooltip.inactive", {
|
||||
appLabel,
|
||||
defaultValue: `接管 ${appLabel} 的 Live 配置,让该应用请求走本地代理`,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,13 +7,19 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Play,
|
||||
Trash2,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
FolderOpen,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useSessionMessagesQuery, useSessionsQuery } from "@/lib/query";
|
||||
import {
|
||||
useDeleteSessionMutation,
|
||||
useSessionMessagesQuery,
|
||||
useSessionsQuery,
|
||||
} from "@/lib/query";
|
||||
import { sessionsApi } from "@/lib/api";
|
||||
import type { SessionMeta } from "@/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -25,6 +31,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -66,6 +73,7 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
);
|
||||
const [tocDialogOpen, setTocDialogOpen] = useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SessionMeta | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -113,6 +121,7 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
selectedSession?.providerId,
|
||||
selectedSession?.sourcePath,
|
||||
);
|
||||
const deleteSessionMutation = useDeleteSessionMutation();
|
||||
|
||||
// 提取用户消息用于目录
|
||||
const userMessagesToc = useMemo(() => {
|
||||
@@ -184,6 +193,19 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget?.sourcePath || deleteSessionMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteTarget(null);
|
||||
await deleteSessionMutation.mutateAsync({
|
||||
providerId: deleteTarget.providerId,
|
||||
sessionId: deleteTarget.sessionId,
|
||||
sourcePath: deleteTarget.sourcePath,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="mx-auto px-4 sm:px-6 flex flex-col h-[calc(100vh-8rem)]">
|
||||
@@ -517,6 +539,36 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="gap-1.5"
|
||||
onClick={() => setDeleteTarget(selectedSession)}
|
||||
disabled={
|
||||
!selectedSession.sourcePath ||
|
||||
deleteSessionMutation.isPending
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<span className="hidden sm:inline">
|
||||
{deleteSessionMutation.isPending
|
||||
? t("sessionManager.deleting", {
|
||||
defaultValue: "删除中...",
|
||||
})
|
||||
: t("sessionManager.delete", {
|
||||
defaultValue: "删除会话",
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("sessionManager.deleteTooltip", {
|
||||
defaultValue: "永久删除此本地会话记录",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -629,6 +681,33 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
isOpen={Boolean(deleteTarget)}
|
||||
title={t("sessionManager.deleteConfirmTitle", {
|
||||
defaultValue: "删除会话",
|
||||
})}
|
||||
message={
|
||||
deleteTarget
|
||||
? t("sessionManager.deleteConfirmMessage", {
|
||||
defaultValue:
|
||||
"将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
|
||||
title: formatSessionTitle(deleteTarget),
|
||||
sessionId: deleteTarget.sessionId,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
confirmText={t("sessionManager.deleteConfirmAction", {
|
||||
defaultValue: "删除会话",
|
||||
})}
|
||||
cancelText={t("common.cancel", { defaultValue: "取消" })}
|
||||
variant="destructive"
|
||||
onConfirm={() => void handleDeleteConfirm()}
|
||||
onCancel={() => {
|
||||
if (!deleteSessionMutation.isPending) {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Server, Activity, Zap, Globe } from "lucide-react";
|
||||
import { Server, Activity, Zap, Globe, ShieldAlert } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
|
||||
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
|
||||
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { ToggleRow } from "@/components/ui/toggle-row";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import type { SettingsFormState } from "@/hooks/useSettings";
|
||||
|
||||
@@ -30,6 +31,7 @@ export function ProxyTabContent({
|
||||
}: ProxyTabContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showProxyConfirm, setShowProxyConfirm] = useState(false);
|
||||
const [showFailoverConfirm, setShowFailoverConfirm] = useState(false);
|
||||
|
||||
const {
|
||||
isRunning,
|
||||
@@ -62,6 +64,23 @@ export function ProxyTabContent({
|
||||
}
|
||||
};
|
||||
|
||||
const handleFailoverToggleChange = (checked: boolean) => {
|
||||
if (checked && !settings?.failoverConfirmed) {
|
||||
setShowFailoverConfirm(true);
|
||||
} else {
|
||||
void onAutoSave({ enableFailoverToggle: checked });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFailoverConfirm = async () => {
|
||||
setShowFailoverConfirm(false);
|
||||
try {
|
||||
await onAutoSave({ failoverConfirmed: true, enableFailoverToggle: true });
|
||||
} catch (error) {
|
||||
console.error("Failover confirm failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
@@ -131,6 +150,16 @@ export function ProxyTabContent({
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<div className="space-y-6">
|
||||
<ToggleRow
|
||||
icon={<ShieldAlert className="h-4 w-4 text-orange-500" />}
|
||||
title={t("settings.advanced.proxy.enableFailoverToggle")}
|
||||
description={t(
|
||||
"settings.advanced.proxy.enableFailoverToggleDescription",
|
||||
)}
|
||||
checked={settings?.enableFailoverToggle ?? false}
|
||||
onCheckedChange={handleFailoverToggleChange}
|
||||
/>
|
||||
|
||||
{!isRunning && (
|
||||
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
@@ -274,6 +303,16 @@ export function ProxyTabContent({
|
||||
onConfirm={() => void handleProxyConfirm()}
|
||||
onCancel={() => setShowProxyConfirm(false)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showFailoverConfirm}
|
||||
variant="info"
|
||||
title={t("confirm.failover.title")}
|
||||
message={t("confirm.failover.message")}
|
||||
confirmText={t("confirm.failover.confirm")}
|
||||
onConfirm={() => void handleFailoverConfirm()}
|
||||
onCancel={() => setShowFailoverConfirm(false)}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { settingsApi, type RectifierConfig } from "@/lib/api/settings";
|
||||
import {
|
||||
settingsApi,
|
||||
type RectifierConfig,
|
||||
type OptimizerConfig,
|
||||
} from "@/lib/api/settings";
|
||||
|
||||
export function RectifierConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
@@ -12,6 +16,12 @@ export function RectifierConfigPanel() {
|
||||
requestThinkingSignature: true,
|
||||
requestThinkingBudget: true,
|
||||
});
|
||||
const [optimizerConfig, setOptimizerConfig] = useState<OptimizerConfig>({
|
||||
enabled: false,
|
||||
thinkingOptimizer: true,
|
||||
cacheInjection: true,
|
||||
cacheTtl: "1h",
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -20,6 +30,10 @@ export function RectifierConfigPanel() {
|
||||
.then(setConfig)
|
||||
.catch((e) => console.error("Failed to load rectifier config:", e))
|
||||
.finally(() => setIsLoading(false));
|
||||
settingsApi
|
||||
.getOptimizerConfig()
|
||||
.then(setOptimizerConfig)
|
||||
.catch((e) => console.error("Failed to load optimizer config:", e));
|
||||
}, []);
|
||||
|
||||
const handleChange = async (updates: Partial<RectifierConfig>) => {
|
||||
@@ -34,6 +48,18 @@ export function RectifierConfigPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptimizerChange = async (updates: Partial<OptimizerConfig>) => {
|
||||
const newConfig = { ...optimizerConfig, ...updates };
|
||||
setOptimizerConfig(newConfig);
|
||||
try {
|
||||
await settingsApi.setOptimizerConfig(newConfig);
|
||||
} catch (e) {
|
||||
console.error("Failed to save optimizer config:", e);
|
||||
toast.error(String(e));
|
||||
setOptimizerConfig(optimizerConfig);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
@@ -86,6 +112,94 @@ export function RectifierConfigPanel() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6 mt-6">
|
||||
<div className="space-y-1 mb-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("settings.advanced.optimizer.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.advanced.optimizer.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t("settings.advanced.optimizer.enabled")}</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={optimizerConfig.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleOptimizerChange({ enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pl-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>
|
||||
{t("settings.advanced.optimizer.thinkingOptimizer")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"settings.advanced.optimizer.thinkingOptimizerDescription",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={optimizerConfig.thinkingOptimizer}
|
||||
disabled={!optimizerConfig.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleOptimizerChange({ thinkingOptimizer: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t("settings.advanced.optimizer.cacheInjection")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.advanced.optimizer.cacheInjectionDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={optimizerConfig.cacheInjection}
|
||||
disabled={!optimizerConfig.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleOptimizerChange({ cacheInjection: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{optimizerConfig.cacheInjection && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t("settings.advanced.optimizer.cacheTtl")}</Label>
|
||||
</div>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={optimizerConfig.cacheTtl}
|
||||
disabled={
|
||||
!optimizerConfig.enabled || !optimizerConfig.cacheInjection
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleOptimizerChange({ cacheTtl: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="5m">
|
||||
{t("settings.advanced.optimizer.cacheTtl5m")}
|
||||
</option>
|
||||
<option value="1h">
|
||||
{t("settings.advanced.optimizer.cacheTtl1h")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,7 +380,11 @@ export function SettingsPage({
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<WebdavSyncSection config={settings?.webdavSync} />
|
||||
<WebdavSyncSection
|
||||
config={settings?.webdavSync}
|
||||
settings={settings}
|
||||
onAutoSave={handleAutoSave}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -393,10 +397,10 @@ export function SettingsPage({
|
||||
<FlaskConical className="h-5 w-5 text-emerald-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("modelTest.title")}
|
||||
{t("settings.advanced.modelTest.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("modelTest.description")}
|
||||
{t("settings.advanced.modelTest.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import type { SettingsFormState } from "@/hooks/useSettings";
|
||||
import type { RemoteSnapshotInfo, WebDavSyncSettings } from "@/types";
|
||||
|
||||
// ─── WebDAV service presets ─────────────────────────────────
|
||||
@@ -92,6 +94,10 @@ function formatDate(rfc3339: string): string {
|
||||
return Number.isNaN(d.getTime()) ? rfc3339 : d.toLocaleString();
|
||||
}
|
||||
|
||||
function formatDbCompatVersion(version?: number | null): string | null {
|
||||
return typeof version === "number" ? `db-v${version}` : null;
|
||||
}
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
type ActionState =
|
||||
@@ -106,6 +112,8 @@ type DialogType = "upload" | "download" | null;
|
||||
|
||||
interface WebdavSyncSectionProps {
|
||||
config?: WebDavSyncSettings;
|
||||
settings?: SettingsFormState;
|
||||
onAutoSave?: (updates: Partial<SettingsFormState>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
// ─── ActionButton ───────────────────────────────────────────
|
||||
@@ -147,7 +155,11 @@ function ActionButton({
|
||||
|
||||
// ─── Main component ─────────────────────────────────────────
|
||||
|
||||
export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
||||
export function WebdavSyncSection({
|
||||
config,
|
||||
settings,
|
||||
onAutoSave,
|
||||
}: WebdavSyncSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [actionState, setActionState] = useState<ActionState>("idle");
|
||||
@@ -176,6 +188,7 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
||||
// Confirmation dialog state
|
||||
const [dialogType, setDialogType] = useState<DialogType>(null);
|
||||
const [remoteInfo, setRemoteInfo] = useState<RemoteSnapshotInfo | null>(null);
|
||||
const [showAutoSyncConfirm, setShowAutoSyncConfirm] = useState(false);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setDialogType(null);
|
||||
@@ -240,15 +253,34 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
||||
}
|
||||
}, [form.baseUrl, presetId]);
|
||||
|
||||
const handleAutoSyncChange = useCallback((checked: boolean) => {
|
||||
setForm((prev) => ({ ...prev, autoSync: checked }));
|
||||
const handleAutoSyncChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (checked && !settings?.autoSyncConfirmed) {
|
||||
setShowAutoSyncConfirm(true);
|
||||
return;
|
||||
}
|
||||
setForm((prev) => ({ ...prev, autoSync: checked }));
|
||||
setDirty(true);
|
||||
setJustSaved(false);
|
||||
if (justSavedTimerRef.current) {
|
||||
clearTimeout(justSavedTimerRef.current);
|
||||
justSavedTimerRef.current = null;
|
||||
}
|
||||
},
|
||||
[settings?.autoSyncConfirmed],
|
||||
);
|
||||
|
||||
const handleAutoSyncConfirm = useCallback(async () => {
|
||||
setShowAutoSyncConfirm(false);
|
||||
await onAutoSave?.({ autoSyncConfirmed: true });
|
||||
setForm((prev) => ({ ...prev, autoSync: true }));
|
||||
setDirty(true);
|
||||
setJustSaved(false);
|
||||
if (justSavedTimerRef.current) {
|
||||
clearTimeout(justSavedTimerRef.current);
|
||||
justSavedTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
}, [onAutoSave]);
|
||||
|
||||
const buildSettings = useCallback((): WebDavSyncSettings | null => {
|
||||
const baseUrl = form.baseUrl.trim();
|
||||
@@ -395,7 +427,10 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
||||
if (!info.compatible) {
|
||||
toast.error(
|
||||
t("settings.webdavSync.incompatibleVersion", {
|
||||
version: info.version,
|
||||
protocolVersion: info.protocolVersion,
|
||||
dbCompatVersion:
|
||||
formatDbCompatVersion(info.dbCompatVersion) ??
|
||||
t("common.unknown"),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
@@ -450,6 +485,11 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
||||
const lastError = config?.status?.lastError?.trim();
|
||||
const showAutoSyncError =
|
||||
!!lastError && config?.status?.lastErrorSource === "auto";
|
||||
const currentRemotePath = `/${form.remoteRoot.trim() || "cc-switch-sync"}/v2/db-v6/${form.profile.trim() || "default"}`;
|
||||
const remoteDbCompatDisplay = formatDbCompatVersion(
|
||||
remoteInfo?.dbCompatVersion,
|
||||
);
|
||||
const remoteIsLegacy = remoteInfo?.layout === "legacy";
|
||||
|
||||
// ─── Render ─────────────────────────────────────────────
|
||||
|
||||
@@ -720,8 +760,7 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
||||
{t("settings.webdavSync.confirmUpload.targetPath")}
|
||||
{": "}
|
||||
<code className="ml-1 text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
/{form.remoteRoot.trim() || "cc-switch-sync"}/v2/
|
||||
{form.profile.trim() || "default"}
|
||||
{currentRemotePath}
|
||||
</code>
|
||||
</p>
|
||||
{remoteInfo && (
|
||||
@@ -742,14 +781,35 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
||||
{t("settings.webdavSync.confirmUpload.createdAt")}
|
||||
</dt>
|
||||
<dd>{formatDate(remoteInfo.createdAt)}</dd>
|
||||
<dt className="font-medium text-foreground">
|
||||
{t("settings.webdavSync.confirmUpload.path")}
|
||||
</dt>
|
||||
<dd>
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded">
|
||||
{remoteInfo.remotePath}
|
||||
</code>
|
||||
</dd>
|
||||
{remoteDbCompatDisplay && (
|
||||
<>
|
||||
<dt className="font-medium text-foreground">
|
||||
{t("settings.webdavSync.confirmUpload.dbCompat")}
|
||||
</dt>
|
||||
<dd>{remoteDbCompatDisplay}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
{remoteInfo && (
|
||||
{remoteInfo && !remoteIsLegacy && (
|
||||
<p className="text-destructive font-medium">
|
||||
{t("settings.webdavSync.confirmUpload.warning")}
|
||||
</p>
|
||||
)}
|
||||
{remoteInfo && remoteIsLegacy && (
|
||||
<p className="font-medium text-amber-600 dark:text-amber-400">
|
||||
{t("settings.webdavSync.confirmUpload.legacyNotice")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -793,12 +853,33 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
||||
{t("settings.webdavSync.confirmDownload.createdAt")}
|
||||
</dt>
|
||||
<dd>{formatDate(remoteInfo.createdAt)}</dd>
|
||||
<dt className="font-medium text-foreground">
|
||||
{t("settings.webdavSync.confirmDownload.path")}
|
||||
</dt>
|
||||
<dd>
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{remoteInfo.remotePath}
|
||||
</code>
|
||||
</dd>
|
||||
{remoteDbCompatDisplay && (
|
||||
<>
|
||||
<dt className="font-medium text-foreground">
|
||||
{t("settings.webdavSync.confirmDownload.dbCompat")}
|
||||
</dt>
|
||||
<dd>{remoteDbCompatDisplay}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="font-medium text-foreground">
|
||||
{t("settings.webdavSync.confirmDownload.artifacts")}
|
||||
</dt>
|
||||
<dd>{remoteInfo.artifacts.join(", ")}</dd>
|
||||
</dl>
|
||||
)}
|
||||
{remoteInfo?.layout === "legacy" && (
|
||||
<p className="font-medium text-amber-600 dark:text-amber-400">
|
||||
{t("settings.webdavSync.confirmDownload.legacyNotice")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-destructive font-medium">
|
||||
{t("settings.webdavSync.confirmDownload.warning")}
|
||||
</p>
|
||||
@@ -815,6 +896,17 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ─── Auto-sync confirmation dialog ────────────────── */}
|
||||
<ConfirmDialog
|
||||
isOpen={showAutoSyncConfirm}
|
||||
variant="info"
|
||||
title={t("confirm.autoSync.title")}
|
||||
message={t("confirm.autoSync.message")}
|
||||
confirmText={t("confirm.autoSync.confirm")}
|
||||
onConfirm={() => void handleAutoSyncConfirm()}
|
||||
onCancel={() => setShowAutoSyncConfirm(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,10 +164,20 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
const handleAddRepo = async (repo: SkillRepo) => {
|
||||
try {
|
||||
await addRepoMutation.mutateAsync(repo);
|
||||
// Await discovery so we can report the real count
|
||||
const { data: freshSkills } = await refetchDiscoverable();
|
||||
const count =
|
||||
freshSkills?.filter(
|
||||
(s) =>
|
||||
s.repoOwner === repo.owner &&
|
||||
s.repoName === repo.name &&
|
||||
(s.repoBranch || "main") === (repo.branch || "main"),
|
||||
).length ?? 0;
|
||||
toast.success(
|
||||
t("skills.repo.addSuccess", {
|
||||
owner: repo.owner,
|
||||
name: repo.name,
|
||||
count,
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user