Merge branch 'refs/heads/main' into feat/proxy-url-refactor-full-url-mode

# Conflicts:
#	src-tauri/src/provider.rs
#	src-tauri/src/proxy/forwarder.rs
#	src-tauri/src/proxy/usage/parser.rs
#	src/components/providers/forms/ClaudeFormFields.tsx
#	src/components/providers/forms/ProviderForm.tsx
#	src/components/providers/forms/hooks/useApiKeyState.ts
#	src/i18n/locales/en.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/zh.json
#	src/types.ts
#	src/utils/providerConfigUtils.ts
This commit is contained in:
YoVinchen
2026-03-09 23:16:12 +08:00
107 changed files with 9633 additions and 1161 deletions
+121
View File
@@ -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
+3 -3
View File
@@ -4,7 +4,7 @@
### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode & OpenClaw
[![Version](https://img.shields.io/badge/version-3.11.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.12.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](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
View File
@@ -4,7 +4,7 @@
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw のオールインワン管理ツール
[![Version](https://img.shields.io/badge/version-3.11.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.12.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](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
View File
@@ -4,7 +4,7 @@
### Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw 的全方位管理工具
[![Version](https://img.shields.io/badge/version-3.11.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.12.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](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)
### 供应商管理
+238
View File
@@ -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` |
+238
View File
@@ -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` |
+238
View File
@@ -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 backupWebDAV 同步时使用 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 安装和自动更新 |
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### HomebrewmacOS
```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` |
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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+ 対応
## コントリビュート
+3 -3
View File
@@ -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
View File
@@ -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": {
+18 -1
View File
@@ -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"
+3 -1
View File
@@ -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"
+84 -10
View File
@@ -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,
+24 -5
View File
@@ -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())
}
+17
View File
@@ -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}"))?
}
+323 -32
View File
@@ -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(())
}
}
+1
View File
@@ -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 供外部使用
@@ -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)
+220
View File
@@ -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(())
}
}
+101 -1
View File
@@ -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);
+52
View File
@@ -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 返回的模型名称标准化后一致
+30
View File
@@ -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"
);
}
+9 -4
View File
@@ -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}");
}
}
});
@@ -1038,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
@@ -1054,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,
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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,6 +239,7 @@ 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 供应商使用)
@@ -243,6 +250,11 @@ pub struct ProviderMeta {
/// 是否将 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 {
+208 -9
View File
@@ -6,6 +6,7 @@ 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},
@@ -701,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());
@@ -764,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),
@@ -815,7 +820,13 @@ impl RequestForwarder {
None => (endpoint, None),
};
if path == "/v1/messages" || path == "/claude/v1/messages" {
// 转换到 chat/completions 时剥离 beta=trueAnthropic 专有参数)
// 转换到 OpenAI 兼容端点时剥离 beta=trueAnthropic 专有参数)
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="))
@@ -823,8 +834,8 @@ impl RequestForwarder {
.join("&")
});
match filtered.as_deref() {
Some(q) if !q.is_empty() => format!("/v1/chat/completions?{q}"),
_ => "/v1/chat/completions".to_string(),
Some(q) if !q.is_empty() => format!("{target_path}?{q}"),
_ => target_path.to_string(),
}
} else {
endpoint.to_string()
@@ -1016,3 +1027,191 @@ fn is_bedrock_provider(provider: &Provider) -> bool {
.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...");
}
}
+27 -9
View File
@@ -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};
// ============================================================================
@@ -114,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,
@@ -123,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 = {
@@ -193,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| {
@@ -203,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
})?;
+1
View File
@@ -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";
}
/// 故障转移日志码
+96 -47
View File
@@ -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 认证模式
@@ -291,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)
}
}
}
@@ -585,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!({
+3 -1
View File
@@ -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;
+513 -97
View File
@@ -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(
// 处理 reasoningthinking
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\""));
}
}
+320 -28
View File
@@ -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);
}
}
+15
View File
@@ -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();
+6 -4
View File
@@ -111,8 +111,9 @@ impl TokenUsage {
}
// 从 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())
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;
}
@@ -120,8 +121,9 @@ impl TokenUsage {
// 从 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())
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;
}
+688 -2
View File
@@ -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(&current_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(&current_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"]);
}
}
+79 -7
View File
@@ -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(&current_id).cloned() {
current_provider.settings_config = live_config;
current_provider.settings_config =
strip_common_config_from_live_settings(
state.db.as_ref(),
&app_type,
&current_provider,
live_config,
);
if let Err(e) =
state.db.save_provider(app_type.as_str(), &current_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
+2 -2
View File
@@ -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)
+200 -45
View File
@@ -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);
+2 -1
View File
@@ -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");
}
+242 -55
View File
@@ -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)
}
@@ -576,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"]);
}
@@ -619,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,
@@ -635,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]
@@ -672,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"
+88 -1
View File
@@ -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());
}
}
+9 -1
View File
@@ -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 -1
View File
@@ -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",
+24 -13
View File
@@ -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" ||
@@ -544,6 +555,9 @@ function App() {
await queryClient.invalidateQueries({
queryKey: openclawKeys.liveProviderIds,
});
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
}
toast.success(
t("notifications.removeFromConfigSuccess", {
@@ -967,23 +981,17 @@ 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
@@ -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>
+5 -2
View File
@@ -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">
+4 -1
View File
@@ -183,7 +183,10 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
{/* 扩展字段 extra */}
{firstUsage.extra && (
<span className="text-gray-500 dark:text-gray-400 truncate max-w-[150px]" title={firstUsage.extra}>
<span
className="text-gray-500 dark:text-gray-400 truncate max-w-[150px]"
title={firstUsage.extra}
>
{firstUsage.extra}
</span>
)}
@@ -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">
+58 -126
View File
@@ -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;
+84 -22
View File
@@ -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 { allow, deny, ...other } = config;
const newConfig: OpenClawToolsConfig = {
...other,
profile: config.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"
+71
View File
@@ -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) {
@@ -180,9 +180,11 @@ 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}
@@ -228,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">
@@ -254,20 +261,19 @@ export function ClaudeFormFields({
<SelectContent>
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
{t("providerForm.authFieldAuthToken", {
defaultValue: "Auth Token (默认)",
defaultValue: "ANTHROPIC_AUTH_TOKEN默认",
})}
</SelectItem>
<SelectItem value="ANTHROPIC_API_KEY">
{t("providerForm.authFieldApiKey", {
defaultValue: "API Key",
defaultValue: "ANTHROPIC_API_KEY",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("providerForm.authFieldHint", {
defaultValue:
"大多数第三方供应商使用 Auth Token;少数供应商需要 API Key",
defaultValue: "选择写入配置的认证环境变量名",
})}
</p>
</div>
@@ -112,7 +112,7 @@ 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"
/>
@@ -42,6 +42,10 @@ interface OpenClawFormFieldsProps {
// Models
models: OpenClawModel[];
onModelsChange: (models: OpenClawModel[]) => void;
// User-Agent
userAgent: boolean;
onUserAgentChange: (checked: boolean) => void;
}
export function OpenClawFormFields({
@@ -58,6 +62,8 @@ export function OpenClawFormFields({
onApiChange,
models,
onModelsChange,
userAgent,
onUserAgentChange,
}: OpenClawFormFieldsProps) {
const { t } = useTranslation();
const [expandedModels, setExpandedModels] = useState<Record<number, boolean>>(
@@ -205,6 +211,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">
+60 -38
View File
@@ -241,6 +241,13 @@ 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";
@@ -259,48 +266,20 @@ export function ProviderForm({
() => {
if (appId !== "claude") return "ANTHROPIC_AUTH_TOKEN";
if (initialData?.meta?.apiKeyField) return initialData.meta.apiKeyField;
try {
const config = initialData?.settingsConfig;
if (
config?.env &&
(config.env as Record<string, unknown>).ANTHROPIC_API_KEY !==
undefined
)
return "ANTHROPIC_API_KEY";
} catch {}
// 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 handleApiKeyFieldChange = useCallback(
(field: ClaudeApiKeyField) => {
setLocalApiKeyField(field);
try {
const config = JSON.parse(form.getValues("settingsConfig") || "{}") as {
env?: Record<string, unknown>;
};
const env = (config.env ?? {}) as Record<string, unknown>;
const oldField =
field === "ANTHROPIC_API_KEY"
? "ANTHROPIC_AUTH_TOKEN"
: "ANTHROPIC_API_KEY";
if (oldField in env) {
env[field] = env[oldField];
delete env[oldField];
config.env = env;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
}
} catch {}
},
[form],
);
const {
apiKey,
handleApiKeyChange,
showApiKey: shouldShowApiKey,
} = useApiKeyState({
initialConfig: form.getValues("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config),
onConfigChange: handleSettingsConfigChange,
selectedPresetId,
category,
appType: appId,
@@ -312,7 +291,7 @@ export function ProviderForm({
category,
settingsConfig: form.getValues("settingsConfig"),
codexConfig: "",
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
onSettingsConfigChange: handleSettingsConfigChange,
onCodexConfigChange: () => {},
});
@@ -325,9 +304,32 @@ export function ProviderForm({
handleModelChange,
} = useModelState({
settingsConfig: form.getValues("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config),
onConfigChange: handleSettingsConfigChange,
});
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,
@@ -422,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 {
@@ -435,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",
});
@@ -453,6 +457,8 @@ export function ProviderForm({
codexConfig,
onConfigChange: handleCodexConfigChange,
initialData: appId === "codex" ? initialData : undefined,
initialEnabled:
appId === "codex" ? initialData?.meta?.commonConfigEnabled : undefined,
selectedPresetId: selectedPresetId ?? undefined,
});
@@ -536,6 +542,8 @@ export function ProviderForm({
envStringToObj,
envObjToString,
initialData: appId === "gemini" ? initialData : undefined,
initialEnabled:
appId === "gemini" ? initialData?.meta?.commonConfigEnabled : undefined,
selectedPresetId: selectedPresetId ?? undefined,
});
@@ -858,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,
@@ -873,7 +889,9 @@ export function ProviderForm({
? localApiFormat
: undefined,
apiKeyField:
appId === "claude" && category !== "official"
appId === "claude" &&
category !== "official" &&
localApiKeyField !== "ANTHROPIC_AUTH_TOKEN"
? localApiKeyField
: undefined,
isFullUrl:
@@ -1118,6 +1136,8 @@ export function ProviderForm({
setLocalApiFormat("anthropic");
}
setLocalApiKeyField(preset.apiKeyField ?? "ANTHROPIC_AUTH_TOKEN");
form.reset({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
@@ -1442,6 +1462,8 @@ export function ProviderForm({
onApiChange={openclawForm.handleOpenclawApiChange}
models={openclawForm.openclawModels}
onModelsChange={openclawForm.handleOpenclawModelsChange}
userAgent={openclawForm.openclawUserAgent}
onUserAgentChange={openclawForm.handleOpenclawUserAgentChange}
/>
)}
@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from "react";
import type { ProviderCategory, ClaudeApiKeyField } from "@/types";
import type { ProviderCategory } from "@/types";
import {
getApiKeyFromConfig,
setApiKeyInConfig,
@@ -12,7 +12,7 @@ interface UseApiKeyStateProps {
selectedPresetId: string | null;
category?: ProviderCategory;
appType?: string;
apiKeyField?: ClaudeApiKeyField;
apiKeyField?: string;
}
/**
@@ -60,7 +60,7 @@ export function useApiKeyState({
initialConfig || "{}",
key.trim(),
{
// 最佳实践:仅在新增模式”且“非官方类别时补齐缺失字段
// 最佳实践:仅在"新增模式"且"非官方类别"时补齐缺失字段
// - 新增模式:selectedPresetId !== null
// - 非官方类别:category !== undefined && category !== "official"
// - 官方类别:不创建字段(UI 也会禁用输入框)
@@ -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(() => {
@@ -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,
};
}
@@ -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>
+5
View File
@@ -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 配置,让该应用请求走本地代理`,
});
+80 -1
View File
@@ -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>
);
}
+40 -1
View File
@@ -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>
);
}
+7 -3
View File
@@ -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>
+100 -8
View File
@@ -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>
);
}
@@ -145,7 +145,7 @@ export function UniversalProviderFormModal({
// 计算 Codex 配置 JSON 预览
const codexConfigJson = useMemo(() => {
if (!codexEnabled) return null;
const model = models.codex?.model || "gpt-4o";
const model = models.codex?.model || "gpt-5.4";
const reasoningEffort = models.codex?.reasoningEffort || "high";
// 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API
const codexBaseUrl = baseUrl.endsWith("/v1")
@@ -591,7 +591,7 @@ requires_openai_auth = true`;
onChange={(e) =>
updateModel("codex", "model", e.target.value)
}
placeholder="gpt-4o"
placeholder="gpt-5.4"
/>
</div>
<div className="space-y-1">
@@ -24,7 +24,7 @@ export function ModelTestConfigPanel() {
maxRetries: "2",
degradedThresholdMs: "6000",
claudeModel: "claude-haiku-4-5-20251001",
codexModel: "gpt-5.1-codex@low",
codexModel: "gpt-5.4@low",
geminiModel: "gemini-3-pro-preview",
testPrompt: "Who are you?",
});
+63 -5
View File
@@ -47,7 +47,8 @@ export interface ProviderPreset {
// Claude API 格式(仅 Claude 供应商使用)
// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
apiFormat?: "anthropic" | "openai_chat";
// - "openai_responses": OpenAI Responses API 格式,需要格式转换
apiFormat?: "anthropic" | "openai_chat" | "openai_responses";
}
export const providerPresets: ProviderPreset[] = [
@@ -133,12 +134,13 @@ export const providerPresets: ProviderPreset[] = [
icon: "bailian",
iconColor: "#624AFF",
},
{
{
name: "Bailian For Coding",
websiteUrl: "https://bailian.console.aliyun.com",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
ANTHROPIC_BASE_URL:
"https://coding.dashscope.aliyuncs.com/apps/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
},
},
@@ -358,6 +360,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "siliconflow",
icon: "siliconflow",
iconColor: "#6E29F6",
},
@@ -376,6 +380,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "siliconflow",
icon: "siliconflow",
iconColor: "#000000",
},
@@ -493,8 +499,8 @@ export const providerPresets: ProviderPreset[] = [
},
{
name: "AICoding",
websiteUrl: "https://www.aicoding.sh",
apiKeyUrl: "https://www.aicoding.sh/i/CCSWITCH",
websiteUrl: "https://aicoding.sh",
apiKeyUrl: "https://aicoding.sh/i/CCSWITCH",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.aicoding.sh",
@@ -546,6 +552,58 @@ export const providerPresets: ProviderPreset[] = [
icon: "sssaicode",
iconColor: "#000000",
},
{
name: "Ucloud",
websiteUrl: "https://www.compshare.cn",
apiKeyUrl:
"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.modelverse.cn",
ANTHROPIC_AUTH_TOKEN: "",
},
},
endpointCandidates: ["https://api.modelverse.cn"],
category: "aggregator",
isPartner: true, // 合作伙伴
partnerPromotionKey: "ucloud", // 促销信息 i18n key
icon: "ucloud",
iconColor: "#000000",
},
{
name: "Micu",
websiteUrl: "https://www.openclaudecode.cn",
apiKeyUrl: "https://www.openclaudecode.cn",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://www.openclaudecode.cn",
ANTHROPIC_AUTH_TOKEN: "",
},
},
endpointCandidates: ["https://www.openclaudecode.cn"],
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "micu", // 促销信息 i18n key
icon: "micu",
iconColor: "#000000",
},
{
name: "X-Code API",
websiteUrl: "https://www.x-code.cn",
apiKeyUrl: "https://www.x-code.cn",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://www.x-code.cn",
ANTHROPIC_AUTH_TOKEN: "",
},
},
endpointCandidates: ["https://www.x-code.cn"],
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "x-code", // 促销信息 i18n key
icon: "x-code",
iconColor: "#000000",
},
{
name: "OpenRouter",
websiteUrl: "https://openrouter.ai",
+68 -16
View File
@@ -40,7 +40,7 @@ export function generateThirdPartyAuth(apiKey: string): Record<string, any> {
export function generateThirdPartyConfig(
providerName: string,
baseUrl: string,
modelName = "gpt-5.1-codex",
modelName = "gpt-5.4",
): string {
// 清理供应商名称,确保符合TOML键名规范
const cleanProviderName =
@@ -85,7 +85,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
isOfficial: true,
auth: generateThirdPartyAuth(""),
config: `model_provider = "azure"
model = "gpt-5.2"
model = "gpt-5.4"
model_reasoning_effort = "high"
disable_response_storage = true
@@ -113,7 +113,7 @@ requires_openai_auth = true`,
config: generateThirdPartyConfig(
"aihubmix",
"https://aihubmix.com/v1",
"gpt-5.2",
"gpt-5.4",
),
endpointCandidates: [
"https://aihubmix.com/v1",
@@ -128,7 +128,7 @@ requires_openai_auth = true`,
config: generateThirdPartyConfig(
"dmxapi",
"https://www.dmxapi.cn/v1",
"gpt-5.2",
"gpt-5.4",
),
endpointCandidates: ["https://www.dmxapi.cn/v1"],
isPartner: true, // 合作伙伴
@@ -143,7 +143,7 @@ requires_openai_auth = true`,
config: generateThirdPartyConfig(
"packycode",
"https://www.packyapi.com/v1",
"gpt-5.2",
"gpt-5.4",
),
endpointCandidates: [
"https://www.packyapi.com/v1",
@@ -161,7 +161,7 @@ requires_openai_auth = true`,
config: generateThirdPartyConfig(
"cubence",
"https://api.cubence.com/v1",
"gpt-5.2",
"gpt-5.4",
),
endpointCandidates: [
"https://api.cubence.com/v1",
@@ -184,7 +184,7 @@ requires_openai_auth = true`,
config: generateThirdPartyConfig(
"aigocode",
"https://api.aigocode.com",
"gpt-5.2",
"gpt-5.4",
),
endpointCandidates: ["https://api.aigocode.com"],
isPartner: true, // 合作伙伴
@@ -200,7 +200,7 @@ requires_openai_auth = true`,
config: generateThirdPartyConfig(
"rightcode",
"https://right.codes/codex/v1",
"gpt-5.2",
"gpt-5.4",
),
category: "third_party",
isPartner: true,
@@ -216,7 +216,7 @@ requires_openai_auth = true`,
config: generateThirdPartyConfig(
"aicodemirror",
"https://api.aicodemirror.com/api/codex/backend-api/codex",
"gpt-5.2",
"gpt-5.4",
),
endpointCandidates: [
"https://api.aicodemirror.com/api/codex/backend-api/codex",
@@ -229,13 +229,13 @@ requires_openai_auth = true`,
},
{
name: "AICoding",
websiteUrl: "https://www.aicoding.sh",
apiKeyUrl: "https://www.aicoding.sh/i/CCSWITCH",
websiteUrl: "https://aicoding.sh",
apiKeyUrl: "https://aicoding.sh/i/CCSWITCH",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"aicoding",
"https://api.aicoding.sh",
"gpt-5.3-codex",
"gpt-5.4",
),
endpointCandidates: ["https://api.aicoding.sh"],
isPartner: true,
@@ -251,7 +251,7 @@ requires_openai_auth = true`,
config: generateThirdPartyConfig(
"crazyrouter",
"https://crazyrouter.com/v1",
"gpt-5.3-codex",
"gpt-5.4",
),
endpointCandidates: ["https://crazyrouter.com/v1"],
isPartner: true,
@@ -266,8 +266,8 @@ requires_openai_auth = true`,
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"sssaicode",
"https://node-hk.sssaicode.com/api",
"gpt-5.3-codex",
"https://node-hk.sssaicode.com/api/v1",
"gpt-5.4",
),
endpointCandidates: [
"https://node-hk.sssaicode.com/api/v1",
@@ -280,6 +280,58 @@ requires_openai_auth = true`,
icon: "sssaicode",
iconColor: "#000000",
},
{
name: "Ucloud",
websiteUrl: "https://www.compshare.cn",
apiKeyUrl:
"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"ucloud",
"https://api.modelverse.cn/v1",
"gpt-5.4",
),
endpointCandidates: ["https://api.modelverse.cn/v1"],
category: "aggregator",
isPartner: true, // 合作伙伴
partnerPromotionKey: "ucloud", // 促销信息 i18n key
icon: "ucloud",
iconColor: "#000000",
},
{
name: "Micu",
websiteUrl: "https://www.openclaudecode.cn",
apiKeyUrl: "https://www.openclaudecode.cn/v1",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"micu",
"https://www.openclaudecode.cn/v1",
"gpt-5.4",
),
endpointCandidates: ["https://www.openclaudecode.cn/v1"],
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "micu", // 促销信息 i18n key
icon: "micu",
iconColor: "#000000",
},
{
name: "X-Code API",
websiteUrl: "https://www.x-code.cn",
apiKeyUrl: "https://www.x-code.cn",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"x-code",
"https://www.x-code.cn/v1",
"gpt-5.4",
),
endpointCandidates: ["https://www.x-code.cn/v1"],
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "x-code", // 促销信息 i18n key
icon: "x-code",
iconColor: "#000000",
},
{
name: "OpenRouter",
websiteUrl: "https://openrouter.ai",
@@ -288,7 +340,7 @@ requires_openai_auth = true`,
config: generateThirdPartyConfig(
"openrouter",
"https://openrouter.ai/api/v1",
"gpt-5.2",
"gpt-5.4",
),
category: "aggregator",
icon: "openrouter",
+1 -1
View File
@@ -14,7 +14,7 @@ export interface CodexTemplate {
*/
export function getCodexCustomTemplate(): CodexTemplate {
const config = `model_provider = "custom"
model = "gpt-5.2"
model = "gpt-5.4"
model_reasoning_effort = "high"
disable_response_storage = true
+2 -2
View File
@@ -141,8 +141,8 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
},
{
name: "AICoding",
websiteUrl: "https://www.aicoding.sh",
apiKeyUrl: "https://www.aicoding.sh/i/CCSWITCH",
websiteUrl: "https://aicoding.sh",
apiKeyUrl: "https://aicoding.sh/i/CCSWITCH",
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://api.aicoding.sh",
+83 -2
View File
@@ -736,6 +736,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
],
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "siliconflow",
icon: "siliconflow",
iconColor: "#6E29F6",
templateValues: {
@@ -770,6 +772,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
],
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "siliconflow",
icon: "siliconflow",
iconColor: "#000000",
templateValues: {
@@ -1085,8 +1089,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
{
name: "AICoding",
websiteUrl: "https://www.aicoding.sh",
apiKeyUrl: "https://www.aicoding.sh/i/CCSWITCH",
websiteUrl: "https://aicoding.sh",
apiKeyUrl: "https://aicoding.sh/i/CCSWITCH",
settingsConfig: {
baseUrl: "https://api.aicoding.sh",
apiKey: "",
@@ -1221,6 +1225,83 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
},
{
name: "Ucloud",
websiteUrl: "https://www.compshare.cn",
apiKeyUrl:
"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch",
settingsConfig: {
baseUrl: "https://api.modelverse.cn/v1",
apiKey: "",
api: "anthropic-messages",
models: [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
cost: { input: 5, output: 25 },
},
],
},
category: "aggregator",
isPartner: true, // 合作伙伴
partnerPromotionKey: "ucloud", // 促销信息 i18n key
icon: "ucloud",
iconColor: "#000000",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
suggestedDefaults: {
model: {
primary: "ucloud/claude-opus-4-6",
},
modelCatalog: {
"ucloud/claude-opus-4-6": { alias: "Opus" },
},
},
},
{
name: "Micu",
websiteUrl: "https://www.openclaudecode.cn",
apiKeyUrl: "https://www.openclaudecode.cn",
settingsConfig: {
baseUrl: "https://www.openclaudecode.cn",
apiKey: "",
api: "anthropic-messages",
models: [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
cost: { input: 5, output: 25 },
},
],
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "micu",
icon: "micu",
iconColor: "#000000",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
suggestedDefaults: {
model: {
primary: "micu/claude-opus-4-6",
},
modelCatalog: {
"micu/claude-opus-4-6": { alias: "Opus" },
},
},
},
// ========== Cloud Providers ==========
{
name: "AWS Bedrock",
+64 -173
View File
@@ -124,174 +124,11 @@ export const OPENCODE_PRESET_MODEL_VARIANTS: Record<
],
"@ai-sdk/openai": [
{
id: "gpt-5",
name: "GPT-5",
id: "gpt-5.4",
name: "GPT-5.4",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "low",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "high",
},
},
},
{
id: "gpt-5.1",
name: "GPT-5.1",
contextLimit: 400000,
outputLimit: 272000,
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "low",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "high",
},
},
},
{
id: "gpt-5.1-codex",
name: "GPT-5.1 Codex",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
options: { include: ["reasoning.encrypted_content"], store: false },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
{
id: "gpt-5.1-codex-max",
name: "GPT-5.1 Codex Max",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
options: { include: ["reasoning.encrypted_content"], store: false },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
xhigh: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
{
id: "gpt-5.2",
name: "GPT-5.2",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
xhigh: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
{
id: "gpt-5.2-codex",
name: "GPT-5.2 Codex",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
options: { include: ["reasoning.encrypted_content"], store: false },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
xhigh: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
{
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
options: { include: ["reasoning.encrypted_content"], store: false },
variants: {
low: {
reasoningEffort: "low",
@@ -1110,11 +947,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
apiKey: "",
},
models: {
"gpt-5.2": { name: "GPT-5.2" },
"gpt-5.2-codex": {
name: "GPT-5.2 Codex",
options: { include: ["reasoning.encrypted_content"], store: false },
},
"gpt-5.4": { name: "GPT-5.4" },
},
},
category: "third_party",
@@ -1161,8 +994,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
{
name: "AICoding",
websiteUrl: "https://www.aicoding.sh",
apiKeyUrl: "https://www.aicoding.sh/i/CCSWITCH",
websiteUrl: "https://aicoding.sh",
apiKeyUrl: "https://aicoding.sh/i/CCSWITCH",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "AICoding",
@@ -1225,7 +1058,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
npm: "@ai-sdk/anthropic",
name: "SSSAiCode",
options: {
baseURL: "https://node-hk.sssaicode.com/api",
baseURL: "https://node-hk.sssaicode.com/api/v1",
apiKey: "",
},
models: {
@@ -1246,6 +1079,64 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
},
{
name: "Micu",
websiteUrl: "https://www.openclaudecode.cn",
apiKeyUrl: "https://www.openclaudecode.cn/v1",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "Micu",
options: {
baseURL: "https://www.openclaudecode.cn/v1",
apiKey: "",
},
models: {
"claude-opus-4-6": { name: "Claude Opus 4.6" },
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
},
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "micu",
icon: "micu",
iconColor: "#000000",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "X-Code API",
websiteUrl: "https://www.x-code.cn",
apiKeyUrl: "https://www.x-code.cn",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "X-Code API",
options: {
baseURL: "https://www.x-code.cn/v1",
apiKey: "",
},
models: {
"claude-opus-4-6": { name: "Claude Opus 4.6" },
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
},
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "x-code",
icon: "x-code",
iconColor: "#000000",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "AWS Bedrock",
websiteUrl: "https://aws.amazon.com/bedrock/",
+1 -1
View File
@@ -46,7 +46,7 @@ const NEWAPI_DEFAULT_MODELS: UniversalProviderModels = {
opusModel: "claude-sonnet-4-20250514",
},
codex: {
model: "gpt-4o",
model: "gpt-5.4",
reasoningEffort: "high",
},
gemini: {
+13
View File
@@ -18,6 +18,7 @@ export const openclawKeys = {
env: ["openclaw", "env"] as const,
tools: ["openclaw", "tools"] as const,
agentsDefaults: ["openclaw", "agentsDefaults"] as const,
health: ["openclaw", "health"] as const,
};
// ============================================================
@@ -81,6 +82,15 @@ export function useOpenClawAgentsDefaults() {
});
}
export function useOpenClawHealth(enabled: boolean) {
return useQuery({
queryKey: openclawKeys.health,
queryFn: () => openclawApi.scanHealth(),
staleTime: 30_000,
enabled,
});
}
// ============================================================
// Mutation hooks
// ============================================================
@@ -95,6 +105,7 @@ export function useSaveOpenClawEnv() {
mutationFn: (env: OpenClawEnvConfig) => openclawApi.setEnv(env),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: openclawKeys.env });
queryClient.invalidateQueries({ queryKey: openclawKeys.health });
},
});
}
@@ -109,6 +120,7 @@ export function useSaveOpenClawTools() {
mutationFn: (tools: OpenClawToolsConfig) => openclawApi.setTools(tools),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: openclawKeys.tools });
queryClient.invalidateQueries({ queryKey: openclawKeys.health });
},
});
}
@@ -126,6 +138,7 @@ export function useSaveOpenClawAgentsDefaults() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: openclawKeys.agentsDefaults });
queryClient.invalidateQueries({ queryKey: openclawKeys.defaultModel });
queryClient.invalidateQueries({ queryKey: openclawKeys.health });
},
});
}
+17 -6
View File
@@ -80,6 +80,9 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
const existingCatalog = (await openclawApi.getModelCatalog()) || {};
const mergedCatalog = { ...existingCatalog, ...modelCatalog };
await openclawApi.setModelCatalog(mergedCatalog);
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
modelsRegistered = true;
}
@@ -88,6 +91,9 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
const existingDefault = await openclawApi.getDefaultModel();
if (!existingDefault?.primary) {
await openclawApi.setDefaultModel(model);
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
}
}
@@ -109,7 +115,7 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
}
}
},
[addProviderMutation, activeApp, t],
[addProviderMutation, activeApp, queryClient, t],
);
// 更新供应商
@@ -138,7 +144,8 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
activeApp === "claude" &&
provider.category !== "official" &&
(provider.meta?.isFullUrl ||
provider.meta?.apiFormat === "openai_chat") &&
provider.meta?.apiFormat === "openai_chat" ||
provider.meta?.apiFormat === "openai_responses") &&
!isProxyRunning
) {
toast.warning(
@@ -168,13 +175,14 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
if (
activeApp === "claude" &&
provider.category !== "official" &&
provider.meta?.apiFormat === "openai_chat"
(provider.meta?.apiFormat === "openai_chat" ||
provider.meta?.apiFormat === "openai_responses")
) {
// OpenAI Chat 格式供应商:显示代理提示
// OpenAI format provider: show proxy hint
toast.info(
t("notifications.openAIChatFormatHint", {
t("notifications.openAIFormatHint", {
defaultValue:
"此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
"此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用",
}),
{
duration: 5000,
@@ -274,6 +282,9 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
await queryClient.invalidateQueries({
queryKey: openclawKeys.defaultModel,
});
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
toast.success(
t("notifications.openclawDefaultModelSet", {
defaultValue: "已设为默认模型",
+171 -19
View File
@@ -38,7 +38,9 @@
"reset": "Reset",
"actions": "Actions",
"deleting": "Deleting...",
"auto": "Auto"
"auto": "Auto",
"enabled": "Enabled",
"notSet": "Not Set"
},
"apiKeyInput": {
"placeholder": "Enter API Key",
@@ -170,6 +172,7 @@
"settingsSaved": "Settings saved",
"settingsSaveFailed": "Failed to save settings: {{error}}",
"openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled",
"openAIFormatHint": "This provider uses OpenAI-compatible format and requires the proxy service to be enabled",
"proxyRequiredForSwitch": "This provider requires the proxy service. Please start the proxy first",
"openLinkFailed": "Failed to open link",
"openclawModelsRegistered": "Models have been registered to /model list",
@@ -188,6 +191,11 @@
"message": "Local proxy is an advanced feature. Please make sure you understand how it works before enabling.\n\nWe recommend consulting the relevant documentation or your provider for proper configuration.",
"confirm": "I understand, enable"
},
"failover": {
"title": "Enable Failover",
"message": "Failover is an advanced feature. Please make sure you understand how it works before enabling.\n\nWe recommend configuring provider priorities in the failover queue first.",
"confirm": "I understand, enable"
},
"usage": {
"title": "Configure Usage Query",
"message": "Usage query requires a custom script or API parameters. Please make sure you have obtained the necessary information from your provider.\n\nIf unsure how to configure, please consult your provider's documentation first.",
@@ -197,6 +205,11 @@
"title": "Model Health Check",
"message": "Health check tests provider connectivity by sending a direct API request. The following may cause check failures:\n\n• Official providers (uses OAuth login, no standalone API Key)\n• Some relay services (verify requests come from Claude Code CLI)\n• AWS Bedrock (uses IAM signature authentication)\n\nA failed check does not mean the provider is unusable — it only means it cannot be verified via a standalone request. Please refer to actual behavior within the application.",
"confirm": "I understand, proceed"
},
"autoSync": {
"title": "Enable Auto Sync",
"message": "When auto sync is enabled, every database change will be automatically uploaded to the WebDAV server.\n\nThis may result in significant network traffic. Please ensure your network and WebDAV service can handle frequent data transfers.",
"confirm": "I understand, enable"
}
},
"settings": {
@@ -215,6 +228,8 @@
"description": "Control proxy service toggle, view status and port info",
"enableFeature": "Show Proxy Toggle on Main Page",
"enableFeatureDescription": "When enabled, the proxy and failover toggles will appear at the top of the main page",
"enableFailoverToggle": "Show Failover Toggle on Main Page",
"enableFailoverToggleDescription": "When enabled, the failover toggle will appear independently at the top of the main page",
"running": "Running",
"stopped": "Stopped"
},
@@ -397,7 +412,7 @@
"saveAndTestSuccess": "Config saved, connection OK",
"saveAndTestFailed": "Config saved, but connection test failed: {{error}}",
"noRemoteData": "No sync data found on the remote server",
"incompatibleVersion": "Remote data version incompatible (v{{version}}), current supports v2",
"incompatibleVersion": "Remote data is incompatible (protocol v{{protocolVersion}}, database {{dbCompatVersion}}). This client supports protocol v2 / db-v6.",
"unsaved": "Unsaved",
"saved": "Saved",
"unsavedChanges": "Please save config first",
@@ -408,7 +423,10 @@
"title": "Restore from Cloud",
"deviceName": "Uploaded by",
"createdAt": "Uploaded at",
"path": "Remote path",
"dbCompat": "DB compatibility",
"artifacts": "Contents",
"legacyNotice": "A legacy remote path was detected. After restoring, the next upload will write to the new v2/db-v6 path.",
"warning": "This will overwrite all local data and skill configurations",
"confirm": "Confirm Restore"
},
@@ -421,7 +439,10 @@
"existingData": "Existing cloud data",
"deviceName": "Uploaded by",
"createdAt": "Uploaded at",
"path": "Remote path",
"dbCompat": "DB compatibility",
"warning": "This will overwrite existing sync data on the remote server",
"legacyNotice": "Legacy remote data was detected. This upload will write to the new v2/db-v6 path and will not overwrite the legacy path.",
"confirm": "Confirm Upload"
}
},
@@ -568,7 +589,8 @@
"pricingLoadFailed": "Failed to load pricing defaults: {{error}}",
"defaultCostMultiplierRequired": "Default multiplier is required",
"defaultCostMultiplierInvalid": "Invalid multiplier format"
}
},
"saveFailedGeneric": "Save failed, please try again"
},
"apps": {
"claude": "Claude",
@@ -604,6 +626,14 @@
"projectDirCopied": "Directory copied",
"copySourcePath": "Copy source file",
"sourcePathCopied": "Source file copied",
"delete": "Delete session",
"deleting": "Deleting...",
"deleteTooltip": "Permanently delete this local session record",
"deleteConfirmTitle": "Delete session",
"deleteConfirmMessage": "This will permanently delete the local session \"{{title}}\"\nSession ID: {{sessionId}}\n\nThis action cannot be undone.",
"deleteConfirmAction": "Delete session",
"sessionDeleted": "Session deleted",
"deleteFailed": "Failed to delete session: {{error}}",
"loadingMessages": "Loading transcript...",
"emptySession": "No messages available",
"clickToCopyPath": "Click to copy path",
@@ -614,7 +644,14 @@
"daysAgo": "{{count}} days ago",
"roleUser": "User",
"roleSystem": "System",
"roleTool": "Tool"
"roleTool": "Tool",
"resume": "Resume Session",
"resumeTooltip": "Resume this session in terminal",
"noResumeCommand": "This session cannot be resumed",
"copyCommand": "Copy Command",
"copyMessage": "Copy Message",
"messageCopied": "Message copied",
"conversationHistory": "Conversation History"
},
"console": {
"providerSwitchReceived": "Received provider switch event:",
@@ -668,7 +705,11 @@
"aicodemirror": "AICodeMirror is an official partner of CC Switch. Register using this link to get 20% off!",
"aicoding": "AI Coding offers an exclusive discount for CC Switch users — 10% off your first top-up!",
"crazyrouter": "CrazyRouter offers an exclusive bonus for CC Switch users — 30% extra credit on your first top-up!",
"sssaicode": "SSAI Code offers an exclusive bonus for CC Switch users — $10 extra credit on every top-up!"
"sssaicode": "SSAI Code offers an exclusive bonus for CC Switch users — $10 extra credit on every top-up!",
"siliconflow": "SiliconFlow is an official partner of CC Switch",
"ucloud": "UCloud offers an exclusive bonus for CC Switch users — register via this link to get ¥5 platform trial credit!",
"micu": "Micu is an official partner of CC Switch",
"x-code": "XCodeAPI offers a special bonus for CC Switch users — register via this link and get 10% extra credit on your first order (contact admin to claim)"
},
"parameterConfig": "Parameter Config - {{name}} *",
"mainModel": "Main Model (optional)",
@@ -701,6 +742,12 @@
"fullUrlHint": "Proxy will use this URL as-is",
"apiFormatAnthropic": "Anthropic Messages (Native)",
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)",
"apiFormatOpenAIResponses": "OpenAI Responses API (Requires proxy)",
"authField": "Auth Field",
"authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN (Default)",
"authFieldApiKey": "ANTHROPIC_API_KEY",
"authFieldHint": "Select the authentication env variable name for the config",
"apiHintResponses": "💡 Fill in OpenAI Responses API compatible service endpoint, avoid trailing slash",
"anthropicDefaultHaikuModel": "Default Haiku Model",
"anthropicDefaultSonnetModel": "Default Sonnet Model",
"anthropicDefaultOpusModel": "Default Opus Model",
@@ -736,7 +783,8 @@
"pleaseAddEndpoint": "Please add an endpoint first",
"testUnavailable": "Speed test unavailable",
"noResult": "No result returned",
"testFailed": "Speed test failed: {{error}}"
"testFailed": "Speed test failed: {{error}}",
"empty": "No endpoints"
},
"providerAdvanced": {
"testConfig": "Model Test Config",
@@ -780,7 +828,9 @@
"extractNoCommonConfig": "No common config available to extract from editor",
"extractFailed": "Extract failed: {{error}}",
"saveFailed": "Save failed: {{error}}",
"modelNameHint": "Specify the model to use, will be auto-updated in config.toml"
"modelNameHint": "Specify the model to use, will be auto-updated in config.toml",
"modelName": "Model Name",
"modelNamePlaceholder": "e.g., gpt-5-codex"
},
"geminiConfig": {
"envFile": "Environment Variables (.env)",
@@ -925,7 +975,28 @@
"costBreakdown": "Cost Breakdown",
"performance": "Performance",
"latency": "Latency",
"errorMessage": "Error Message"
"errorMessage": "Error Message",
"requests": "Requests",
"tokens": "Tokens",
"avgCost": "Average Cost",
"avgLatency": "Average Latency",
"successRate": "Success Rate",
"requestId": "Request ID",
"never": "Never",
"modelId": "Model ID",
"modelIdRequired": "Model ID is required",
"inputCostPerMillion": "Input Cost (per million tokens, USD)",
"outputCostPerMillion": "Output Cost (per million tokens, USD)",
"invalidPrice": "Price must be non-negative",
"invalidTimeRange": "Please select complete start/end time",
"invalidTimeRangeOrder": "Start time cannot be later than end time",
"timeRangeTooLarge": "Time range is too large, please narrow it down",
"addPricing": "Add Pricing",
"editPricing": "Edit Pricing",
"pricingAdded": "Pricing added",
"pricingUpdated": "Pricing updated",
"cacheReadCostPerMillion": "Cache Read Cost (per million tokens, USD)",
"cacheCreationCostPerMillion": "Cache Write Cost (per million tokens, USD)"
},
"usageScript": {
"title": "Configure Usage Query",
@@ -1245,6 +1316,7 @@
}
},
"openclaw": {
"backupCreated": "Backup created: {{path}}",
"providerKey": "Provider Key",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
@@ -1275,9 +1347,15 @@
"cacheWriteCost": "Cache Write Cost ($/M tokens)",
"cacheCostHint": "Cache costs are used to calculate Prompt Caching costs. Leave empty if not using caching.",
"modelsHint": "Configure the models supported by this provider. Model ID is used for API calls, Display Name for the interface.",
"userAgent": "Send User-Agent",
"userAgentHint": "Some providers require a browser User-Agent header to work properly.",
"env": {
"title": "Environment Variables",
"description": "Manage environment variables in openclaw.json (API keys, custom variables, etc.)",
"editorHint": "Edit the full env section as JSON. Nested objects such as env.vars and env.shellEnv are supported.",
"objectRequired": "OpenClaw env must be a JSON object.",
"invalidJson": "OpenClaw env must be valid JSON.",
"empty": "OpenClaw env cannot be empty. Use {} for an empty object.",
"keyPlaceholder": "Variable name",
"valuePlaceholder": "Value",
"add": "Add Variable",
@@ -1290,6 +1368,14 @@
"title": "Tool Permissions",
"description": "Manage tool permissions in openclaw.json (allow/deny lists)",
"profile": "Permission Profile",
"profileMinimal": "Minimal",
"profileCoding": "Coding",
"profileMessaging": "Messaging",
"profileFull": "Full",
"profileUnset": "Not set",
"unsupportedProfileTitle": "Unsupported tools profile detected",
"unsupportedProfileDescription": "The current tools.profile value '{{value}}' is not in the supported OpenClaw list. It will be preserved until you choose a new value.",
"unsupportedProfileLabel": "unsupported",
"profiles": {
"default": "Default",
"strict": "Strict",
@@ -1322,10 +1408,22 @@
"timeout": "Timeout (seconds)",
"contextTokens": "Context Tokens",
"maxConcurrent": "Max Concurrent",
"legacyTimeoutTitle": "Legacy timeout detected",
"legacyTimeoutDescription": "This config still uses agents.defaults.timeout. Saving here will migrate it to timeoutSeconds.",
"saveSuccess": "Agents config saved",
"saveFailed": "Failed to save agents config",
"loadFailed": "Failed to load agents config"
}
},
"health": {
"title": "OpenClaw config warnings detected",
"invalidToolsProfile": "tools.profile contains an unsupported value. OpenClaw currently expects minimal, coding, messaging, or full.",
"legacyTimeout": "agents.defaults.timeout is deprecated. Save the Agents panel to migrate it to timeoutSeconds.",
"stringifiedEnvVars": "env.vars should be an object, but the current value looks stringified or malformed.",
"stringifiedShellEnv": "env.shellEnv should be an object, but the current value looks stringified or malformed.",
"parseFailed": "openclaw.json could not be parsed as valid JSON5. Fix the file before editing it here."
},
"primaryModel": "Primary Model",
"fallbackModel": "Fallback Model"
},
"env": {
"warning": {
@@ -1545,7 +1643,14 @@
"skillsPath": "Skills Path",
"hint": "This will add the Skill repository to the list.",
"hintDetail": "After adding, you can install specific Skills from the Skills management page."
}
},
"usageScript": "Usage Query",
"usageScriptEnabled": "Enabled",
"usageScriptDisabled": "Disabled",
"usageApiKey": "Usage API Key",
"usageBaseUrl": "Usage Query URL",
"usageAutoInterval": "Auto Query",
"usageAutoIntervalValue": "Every {{minutes}} minutes"
},
"iconPicker": {
"search": "Search Icons",
@@ -1564,7 +1669,8 @@
"selectIcon": "Select Icon",
"preview": "Preview",
"clickToChange": "Click to change icon",
"clickToSelect": "Click to select icon"
"clickToSelect": "Click to select icon",
"color": "Icon Color"
},
"migration": {
"success": "Configuration migrated successfully",
@@ -1701,7 +1807,12 @@
"hint": "Select apps to take over — once enabled, requests from that app will be routed through the local proxy",
"enabled": "{{app}} takeover enabled",
"disabled": "{{app}} takeover disabled",
"failed": "Failed to toggle takeover"
"failed": "Failed to toggle takeover",
"tooltip": {
"active": "{{appLabel}} is intercepting - {{address}}:{{port}}\nSwitch provider for hot switching",
"broken": "{{appLabel}} is intercepting, but proxy service is not running",
"inactive": "Intercept {{appLabel}}'s live config to route requests through local proxy"
}
},
"failover": {
"proxyRequired": "Proxy service must be started to configure failover",
@@ -1751,8 +1862,28 @@
"successThresholdLabel": "Recovery Success Threshold",
"successThresholdExplain": "In half-open state, close circuit breaker after this many successes, making provider available again",
"errorRateLabel": "Error Rate Threshold",
"errorRateExplain": "Open circuit breaker when error rate exceeds this value, even if failure threshold not reached"
}
"errorRateExplain": "Open circuit breaker when error rate exceeds this value, even if failure threshold not reached",
"maxRetries": "Max Retries",
"timeoutSettings": "Timeout Settings",
"streamingFirstByte": "Streaming First Byte Timeout",
"streamingIdle": "Streaming Idle Timeout",
"nonStreaming": "Non-Streaming Timeout",
"maxRetriesHint": "Number of retries on request failure (0-10)",
"streamingFirstByteHint": "Max time to wait for first data chunk, range 1-120s, default 60s",
"streamingIdleHint": "Max interval between data chunks, range 60-600s, 0 to disable (prevents mid-stream stalls)",
"nonStreamingHint": "Total timeout for non-streaming requests, range 60-1200s, default 600s (10 min)"
},
"logging": {
"enabled": "Logging enabled",
"disabled": "Logging disabled",
"failed": "Failed to toggle logging"
},
"server": {
"started": "Proxy service started - {{address}}:{{port}}",
"startFailed": "Failed to start proxy service: {{detail}}"
},
"stoppedWithRestore": "Proxy service stopped, all takeover configs restored",
"stopWithRestoreFailed": "Stop failed: {{detail}}"
},
"streamCheck": {
"configSaved": "Health check config saved",
@@ -1765,7 +1896,11 @@
"timeout": "Timeout (seconds)",
"maxRetries": "Max Retries",
"degradedThreshold": "Degraded Threshold (ms)",
"testPrompt": "Test Prompt"
"testPrompt": "Test Prompt",
"operational": "{{providerName}} is operational ({{responseTimeMs}}ms)",
"degraded": "{{providerName}} is slow ({{responseTimeMs}}ms)",
"failed": "{{providerName}} check failed: {{message}}",
"error": "{{providerName}} check error: {{error}}"
},
"proxyConfig": {
"proxyEnabled": "Proxy Enabled",
@@ -1787,11 +1922,28 @@
"failureThreshold": "Failure Threshold",
"successThreshold": "Success Threshold",
"timeoutSeconds": "Timeout (seconds)",
"errorRateThreshold": "Error Rate Threshold",
"minRequests": "Min Requests",
"errorRateThreshold": "Error Rate Threshold (%)",
"minRequests": "Minimum Requests",
"validationFailed": "The following fields are out of valid range: {{fields}}",
"configSaved": "Circuit breaker config saved",
"saveFailed": "Failed to save"
"configSaved": "Circuit breaker configuration saved",
"saveFailed": "Save failed",
"loading": "Loading...",
"title": "Circuit Breaker Configuration",
"description": "Adjust circuit breaker parameters to control fault detection and recovery behavior",
"failureThresholdHint": "How many consecutive failures trigger the circuit breaker",
"timeoutSecondsHint": "How long to wait before attempting recovery (half-open state)",
"successThresholdHint": "How many successes in half-open state to close the circuit breaker",
"errorRateThresholdHint": "Open circuit breaker when error rate exceeds this value",
"minRequestsHint": "Minimum requests before calculating error rate",
"saveConfig": "Save Configuration",
"instructionsTitle": "Configuration Instructions",
"instructions": {
"failureThreshold": "Circuit breaker opens when consecutive failures reach this count",
"timeout": "After circuit breaker opens, wait this time before attempting half-open",
"successThreshold": "In half-open state, close circuit breaker when successes reach this count",
"errorRate": "Circuit breaker opens when error rate exceeds this value",
"minRequests": "Error rate is only calculated after request count reaches this value"
}
},
"universalProvider": {
"title": "Universal Provider",
+172 -20
View File
@@ -38,7 +38,9 @@
"reset": "リセット",
"actions": "操作",
"deleting": "削除中...",
"auto": "自動"
"auto": "自動",
"enabled": "有効",
"notSet": "未設定"
},
"apiKeyInput": {
"placeholder": "API Key を入力",
@@ -170,6 +172,7 @@
"settingsSaved": "設定を保存しました",
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
"openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です",
"openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です",
"proxyRequiredForSwitch": "このプロバイダーにはプロキシが必要です。先にプロキシを起動してください",
"openLinkFailed": "リンクを開けませんでした",
"openclawModelsRegistered": "モデルが /model リストに登録されました",
@@ -188,6 +191,11 @@
"message": "ローカルプロキシは上級機能です。有効にする前に、その仕組みを理解していることをご確認ください。\n\n適切な設定方法については、関連ドキュメントまたはプロバイダーにご相談ください。",
"confirm": "理解しました、有効にする"
},
"failover": {
"title": "フェイルオーバーの有効化",
"message": "フェイルオーバーは上級機能です。有効にする前に、その仕組みを理解していることをご確認ください。\n\nフェイルオーバーキューでプロバイダーの優先順位を先に設定することをお勧めします。",
"confirm": "理解しました、有効にする"
},
"usage": {
"title": "使用量クエリの設定",
"message": "使用量クエリにはカスタムスクリプトまたは API パラメータが必要です。プロバイダーから必要な情報を取得していることをご確認ください。\n\n設定方法が不明な場合は、プロバイダーのドキュメントを先にご確認ください。",
@@ -197,6 +205,11 @@
"title": "モデルヘルスチェック",
"message": "ヘルスチェックは API リクエストを直接送信してプロバイダーの接続性をテストします。以下の場合、チェックが失敗する可能性があります:\n\n• 公式プロバイダー(OAuth ログイン使用、独立した API キーなし)\n• 一部の中継サービス(リクエストが Claude Code CLI からのものか検証)\n• AWS BedrockIAM 署名認証を使用)\n\nチェック失敗はプロバイダーが使用不能であることを意味しません。独立したリクエストでの検証ができないことを示すだけです。アプリ内の実際の動作を基準にしてください。",
"confirm": "理解しました、続行する"
},
"autoSync": {
"title": "自動同期を有効にする",
"message": "自動同期を有効にすると、データベースの変更ごとに WebDAV サーバーへ自動アップロードされます。\n\nネットワークトラフィックが大幅に増加する可能性があります。ネットワーク環境と WebDAV サービスが頻繁なデータ転送に対応できることをご確認ください。",
"confirm": "理解しました、有効にする"
}
},
"settings": {
@@ -215,6 +228,8 @@
"description": "プロキシサービスの切り替え、ステータスとポート情報を表示",
"enableFeature": "メインページにプロキシ切り替えを表示",
"enableFeatureDescription": "有効にすると、メインページ上部にプロキシとフェイルオーバーの切り替えが表示されます",
"enableFailoverToggle": "メインページにフェイルオーバー切り替えを表示",
"enableFailoverToggleDescription": "有効にすると、メインページ上部にフェイルオーバー切り替えが独立して表示されます",
"running": "実行中",
"stopped": "停止中"
},
@@ -397,7 +412,7 @@
"saveAndTestSuccess": "設定を保存しました。接続正常です",
"saveAndTestFailed": "設定を保存しましたが、接続テストに失敗しました:{{error}}",
"noRemoteData": "クラウドに同期データが見つかりません",
"incompatibleVersion": "リモートデータのバージョンに互換性がありません(v{{version}})。現在 v2 をサポートしています",
"incompatibleVersion": "リモートデータに互換性がありません(プロトコル v{{protocolVersion}}、データベース {{dbCompatVersion}})。このクライアントは protocol v2 / db-v6 をサポートしています",
"unsaved": "未保存",
"saved": "保存済み",
"unsavedChanges": "先に設定を保存してください",
@@ -408,7 +423,10 @@
"title": "クラウドから復元",
"deviceName": "アップロード元",
"createdAt": "アップロード日時",
"path": "リモートパス",
"dbCompat": "DB 互換レイヤー",
"artifacts": "内容",
"legacyNotice": "旧レイアウトのリモートパスを検出しました。復元後、次回のアップロードは新しい v2/db-v6 パスに書き込まれます。",
"warning": "ローカルのすべてのデータとスキル設定が上書きされます",
"confirm": "復元を実行"
},
@@ -421,7 +439,10 @@
"existingData": "クラウドの既存データ",
"deviceName": "アップロード元",
"createdAt": "アップロード日時",
"path": "リモートパス",
"dbCompat": "DB 互換レイヤー",
"warning": "リモートの既存同期データが上書きされます",
"legacyNotice": "旧レイアウトのリモートデータを検出しました。今回のアップロードは新しい v2/db-v6 パスに書き込み、旧パスは上書きしません。",
"confirm": "アップロードを実行"
}
},
@@ -568,7 +589,8 @@
"pricingLoadFailed": "課金設定の読み込みに失敗しました: {{error}}",
"defaultCostMultiplierRequired": "デフォルト倍率は必須です",
"defaultCostMultiplierInvalid": "デフォルト倍率の形式が正しくありません"
}
},
"saveFailedGeneric": "保存に失敗しました。もう一度お試しください"
},
"apps": {
"claude": "Claude",
@@ -604,6 +626,14 @@
"projectDirCopied": "ディレクトリをコピーしました",
"copySourcePath": "元ファイルをコピー",
"sourcePathCopied": "元ファイルをコピーしました",
"delete": "セッションを削除",
"deleting": "削除中...",
"deleteTooltip": "このローカルセッション記録を完全に削除します",
"deleteConfirmTitle": "セッションを削除",
"deleteConfirmMessage": "ローカルセッション「{{title}}」を完全に削除します\nSession ID: {{sessionId}}\n\nこの操作は元に戻せません。",
"deleteConfirmAction": "セッションを削除",
"sessionDeleted": "セッションを削除しました",
"deleteFailed": "セッションの削除に失敗しました: {{error}}",
"loadingMessages": "内容を読み込み中...",
"emptySession": "表示できる内容がありません",
"clickToCopyPath": "クリックしてパスをコピー",
@@ -614,7 +644,14 @@
"daysAgo": "{{count}}日前",
"roleUser": "ユーザー",
"roleSystem": "システム",
"roleTool": "ツール"
"roleTool": "ツール",
"resume": "セッションを再開",
"resumeTooltip": "ターミナルでこのセッションを再開",
"noResumeCommand": "このセッションは再開できません",
"copyCommand": "コマンドをコピー",
"copyMessage": "メッセージをコピー",
"messageCopied": "メッセージがコピーされました",
"conversationHistory": "会話履歴"
},
"console": {
"providerSwitchReceived": "プロバイダー切り替えイベントを受信:",
@@ -668,7 +705,11 @@
"aicodemirror": "AICodeMirror は CC Switch の公式パートナーです。このリンクから登録すると20%オフ!",
"aicoding": "AI Coding は CC Switch ユーザー向けに特別割引を提供しています。初回チャージが 10% オフ!",
"crazyrouter": "CrazyRouter は CC Switch ユーザー向けに特別ボーナスを提供しています。初回チャージで 30% の追加クレジットがもらえます!",
"sssaicode": "SSAI Code は CC Switch ユーザー向けに特別ボーナスを提供しています。チャージごとに $10 の追加クレジットがもらえます!"
"sssaicode": "SSAI Code は CC Switch ユーザー向けに特別ボーナスを提供しています。チャージごとに $10 の追加クレジットがもらえます!",
"siliconflow": "SiliconFlow は CC Switch の公式パートナーです",
"ucloud": "UCloud は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、5元のプラットフォーム体験クレジットがもらえます!",
"micu": "Micu は CC Switch の公式パートナーです",
"x-code": "XCodeAPI は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、初回注文で 10% の追加クレジットがもらえます(管理者に連絡して受け取り)"
},
"parameterConfig": "パラメーター設定 - {{name}} *",
"mainModel": "メインモデル(任意)",
@@ -701,6 +742,12 @@
"fullUrlHint": "プロキシはこの URL をそのまま使用",
"apiFormatAnthropic": "Anthropic Messages(ネイティブ)",
"apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)",
"apiFormatOpenAIResponses": "OpenAI Responses API(プロキシが必要)",
"authField": "認証フィールド",
"authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(デフォルト)",
"authFieldApiKey": "ANTHROPIC_API_KEY",
"authFieldHint": "設定に書き込む認証環境変数名を選択",
"apiHintResponses": "💡 OpenAI Responses API 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください",
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
"anthropicDefaultOpusModel": "既定 Opus モデル",
@@ -736,7 +783,8 @@
"pleaseAddEndpoint": "まずエンドポイントを追加してください",
"testUnavailable": "速度テストを実行できません",
"noResult": "結果がありません",
"testFailed": "速度テストに失敗しました: {{error}}"
"testFailed": "速度テストに失敗しました: {{error}}",
"empty": "エンドポイントがありません"
},
"providerAdvanced": {
"testConfig": "モデルテスト設定",
@@ -780,7 +828,9 @@
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
"extractFailed": "抽出に失敗しました: {{error}}",
"saveFailed": "保存に失敗しました: {{error}}",
"modelNameHint": "使用するモデルを指定します。config.toml に自動更新されます"
"modelNameHint": "使用するモデルを指定します。config.toml に自動更新されます",
"modelName": "モデル名",
"modelNamePlaceholder": "例: gpt-5-codex"
},
"geminiConfig": {
"envFile": "環境変数 (.env)",
@@ -925,7 +975,28 @@
"costBreakdown": "コスト明細",
"performance": "パフォーマンス",
"latency": "レイテンシー",
"errorMessage": "エラーメッセージ"
"errorMessage": "エラーメッセージ",
"requests": "リクエスト数",
"tokens": "トークン",
"avgCost": "平均コスト",
"avgLatency": "平均レイテンシ",
"successRate": "成功率",
"requestId": "リクエスト ID",
"never": "なし",
"modelId": "モデル ID",
"modelIdRequired": "モデル ID は必須です",
"inputCostPerMillion": "入力コスト(100万トークンあたり、USD)",
"outputCostPerMillion": "出力コスト(100万トークンあたり、USD)",
"invalidPrice": "価格は負でない数値である必要があります",
"invalidTimeRange": "開始/終了時刻を完全に選択してください",
"invalidTimeRangeOrder": "開始時刻は終了時刻より前である必要があります",
"timeRangeTooLarge": "時間範囲が大きすぎます。範囲を縮小してください",
"addPricing": "価格設定を追加",
"editPricing": "価格設定を編集",
"pricingAdded": "価格設定が追加されました",
"pricingUpdated": "価格設定が更新されました",
"cacheReadCostPerMillion": "キャッシュ読み取りコスト(100万トークンあたり、USD)",
"cacheCreationCostPerMillion": "キャッシュ書き込みコスト(100万トークンあたり、USD)"
},
"usageScript": {
"title": "利用状況を設定",
@@ -1245,6 +1316,7 @@
}
},
"openclaw": {
"backupCreated": "バックアップを作成しました: {{path}}",
"providerKey": "プロバイダーキー",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
@@ -1275,9 +1347,15 @@
"cacheWriteCost": "キャッシュ書込コスト ($/M トークン)",
"cacheCostHint": "キャッシュコストは Prompt Caching のコスト計算に使用されます。キャッシュを使用しない場合は空欄のままにしてください。",
"modelsHint": "このプロバイダーがサポートするモデルを設定します。モデル ID は API 呼び出しに、表示名はインターフェースに使用されます。",
"userAgent": "User-Agent を送信",
"userAgentHint": "一部のプロバイダーはブラウザの User-Agent ヘッダーが必要です。",
"env": {
"title": "環境変数",
"description": "openclaw.json の環境変数設定を管理(APIキー、カスタム変数など)",
"editorHint": "env セクション全体を JSON として編集します。env.vars や env.shellEnv などのネストしたオブジェクトにも対応しています。",
"objectRequired": "OpenClaw の env は JSON オブジェクトである必要があります。",
"invalidJson": "OpenClaw の env は有効な JSON である必要があります。",
"empty": "OpenClaw の env は空にできません。空オブジェクトの場合は {} を使用してください。",
"keyPlaceholder": "変数名",
"valuePlaceholder": "値",
"add": "変数を追加",
@@ -1290,6 +1368,14 @@
"title": "ツール権限",
"description": "openclaw.json のツール権限設定を管理(許可/拒否リスト)",
"profile": "権限プロファイル",
"profileMinimal": "最小",
"profileCoding": "コーディング",
"profileMessaging": "メッセージ",
"profileFull": "フルアクセス",
"profileUnset": "未設定",
"unsupportedProfileTitle": "未対応のツールプロファイルを検出しました",
"unsupportedProfileDescription": "現在の tools.profile の値 '{{value}}' は OpenClaw の対応リストにありません。新しい値を選択するまでこの値を保持します。",
"unsupportedProfileLabel": "未対応",
"profiles": {
"default": "デフォルト",
"strict": "厳格",
@@ -1322,10 +1408,22 @@
"timeout": "タイムアウト(秒)",
"contextTokens": "コンテキストトークン数",
"maxConcurrent": "最大同時実行数",
"legacyTimeoutTitle": "旧タイムアウト設定を検出しました",
"legacyTimeoutDescription": "この設定ではまだ agents.defaults.timeout を使用しています。ここで保存すると timeoutSeconds に移行されます。",
"saveSuccess": "Agents 設定を保存しました",
"saveFailed": "Agents 設定の保存に失敗しました",
"loadFailed": "Agents 設定の読み込みに失敗しました"
}
},
"health": {
"title": "OpenClaw 設定の警告を検出しました",
"invalidToolsProfile": "tools.profile に未対応の値が設定されています。OpenClaw が現在サポートしているのは minimal、coding、messaging、full です。",
"legacyTimeout": "agents.defaults.timeout は非推奨です。Agents パネルを保存すると timeoutSeconds に移行されます。",
"stringifiedEnvVars": "env.vars はオブジェクトである必要がありますが、現在の値は文字列化または破損しているようです。",
"stringifiedShellEnv": "env.shellEnv はオブジェクトである必要がありますが、現在の値は文字列化または破損しているようです。",
"parseFailed": "openclaw.json を有効な JSON5 として解析できませんでした。ここで編集する前にファイルを修正してください。"
},
"primaryModel": "プライマリモデル",
"fallbackModel": "フォールバックモデル"
},
"env": {
"warning": {
@@ -1545,7 +1643,14 @@
"skillsPath": "スキルパス",
"hint": "この操作でスキルリポジトリが一覧に追加されます。",
"hintDetail": "追加後、スキル管理ページから個別のスキルをインストールできます。"
}
},
"usageScript": "使用量クエリ",
"usageScriptEnabled": "有効",
"usageScriptDisabled": "無効",
"usageApiKey": "使用量 API キー",
"usageBaseUrl": "使用量クエリ URL",
"usageAutoInterval": "自動クエリ",
"usageAutoIntervalValue": "{{minutes}} 分ごと"
},
"iconPicker": {
"search": "アイコンを検索",
@@ -1564,7 +1669,8 @@
"selectIcon": "アイコンを選択",
"preview": "プレビュー",
"clickToChange": "クリックでアイコンを変更",
"clickToSelect": "クリックでアイコンを選択"
"clickToSelect": "クリックでアイコンを選択",
"color": "アイコンカラー"
},
"migration": {
"success": "設定の移行が完了しました",
@@ -1701,7 +1807,12 @@
"hint": "テイクオーバーするアプリを選択します。有効にすると、そのアプリのリクエストはローカルプロキシ経由で転送されます",
"enabled": "{{app}} テイクオーバー有効",
"disabled": "{{app}} テイクオーバー無効",
"failed": "テイクオーバーの切り替えに失敗しました"
"failed": "テイクオーバーの切り替えに失敗しました",
"tooltip": {
"active": "{{appLabel}} がインターセプト中 - {{address}}:{{port}}\nホットスイッチングのためプロバイダを切り替え",
"broken": "{{appLabel}} がインターセプト中ですが、プロキシサービスが実行されていません",
"inactive": "{{appLabel}} のライブ設定をインターセプトしてリクエストをローカルプロキシ経由でルーティング"
}
},
"failover": {
"proxyRequired": "フェイルオーバーを設定するには、プロキシサービスを先に起動する必要があります",
@@ -1751,8 +1862,28 @@
"successThresholdLabel": "回復成功しきい値",
"successThresholdExplain": "半開状態でこの回数成功するとサーキットブレーカーが閉じ、プロバイダーが再び利用可能になります",
"errorRateLabel": "エラー率しきい値",
"errorRateExplain": "失敗しきい値に達していなくても、エラー率がこの値を超えるとサーキットブレーカーが開きます"
}
"errorRateExplain": "失敗しきい値に達していなくても、エラー率がこの値を超えるとサーキットブレーカーが開きます",
"maxRetries": "最大リトライ回数",
"timeoutSettings": "タイムアウト設定",
"streamingFirstByte": "ストリーミング最初のバイトタイムアウト",
"streamingIdle": "ストリーミングアイドルタイムアウト",
"nonStreaming": "非ストリーミングタイムアウト",
"maxRetriesHint": "リクエスト失敗時のリトライ回数(0-10)",
"streamingFirstByteHint": "最初のデータチャンクを待つ最大時間、範囲 1-120 秒、デフォルト 60 秒",
"streamingIdleHint": "データチャンク間の最大間隔、範囲 60-600 秒、0 で無効化(途中停止を防止)",
"nonStreamingHint": "非ストリーミングリクエストの合計タイムアウト、範囲 60-1200 秒、デフォルト 600 秒(10 分)"
},
"logging": {
"enabled": "ログ記録が有効になりました",
"disabled": "ログ記録が無効になりました",
"failed": "ログ状態の切り替えに失敗しました"
},
"server": {
"started": "プロキシサービスが開始されました - {{address}}:{{port}}",
"startFailed": "プロキシサービスの開始に失敗しました: {{detail}}"
},
"stoppedWithRestore": "プロキシサービスが停止し、すべてのテイクオーバー設定が復元されました",
"stopWithRestoreFailed": "停止に失敗しました: {{detail}}"
},
"streamCheck": {
"configSaved": "ヘルスチェック設定を保存しました",
@@ -1765,7 +1896,11 @@
"timeout": "タイムアウト(秒)",
"maxRetries": "最大リトライ回数",
"degradedThreshold": "劣化しきい値(ミリ秒)",
"testPrompt": "テストプロンプト"
"testPrompt": "テストプロンプト",
"operational": "{{providerName}} は正常に動作しています ({{responseTimeMs}}ms)",
"degraded": "{{providerName}} の応答が遅いです ({{responseTimeMs}}ms)",
"failed": "{{providerName}} のチェックに失敗しました: {{message}}",
"error": "{{providerName}} のチェックでエラーが発生しました: {{error}}"
},
"proxyConfig": {
"proxyEnabled": "プロキシ有効",
@@ -1784,14 +1919,31 @@
"nonStreaming": "非ストリーミングタイムアウト"
},
"circuitBreaker": {
"failureThreshold": "失敗しきい値",
"successThreshold": "成功しきい値",
"failureThreshold": "失敗値",
"successThreshold": "成功値",
"timeoutSeconds": "タイムアウト(秒)",
"errorRateThreshold": "エラー率しきい値",
"errorRateThreshold": "エラー率閾値 (%)",
"minRequests": "最小リクエスト数",
"validationFailed": "以下のフィールドが有効範囲外です: {{fields}}",
"configSaved": "サーキットブレーカー設定保存ました",
"saveFailed": "保存に失敗しました"
"configSaved": "サーキットブレーカー設定保存されました",
"saveFailed": "保存に失敗しました",
"loading": "読み込み中...",
"title": "サーキットブレーカー設定",
"description": "サーキットブレーカーパラメータを調整して、障害検出と復旧動作を制御します",
"failureThresholdHint": "連続失敗後にサーキットブレーカーを開く回数",
"timeoutSecondsHint": "サーキットブレーカーを開いた後、復旧を試みるまでの時間(半開状態)",
"successThresholdHint": "半開状態で成功してサーキットブレーカーを閉じる回数",
"errorRateThresholdHint": "エラー率がこの値を超えるとサーキットブレーカーを開く",
"minRequestsHint": "エラー率を計算する前の最小リクエスト数",
"saveConfig": "設定を保存",
"instructionsTitle": "設定説明",
"instructions": {
"failureThreshold": "連続失敗がこの回数に達するとサーキットブレーカーが開く",
"timeout": "サーキットブレーカーを開いた後、この時間待機してから半開を試みる",
"successThreshold": "半開状態で成功がこの回数に達するとサーキットブレーカーを閉じる",
"errorRate": "エラー率がこの値を超えるとサーキットブレーカーが開く",
"minRequests": "リクエスト数がこの値に達した後にのみエラー率が計算される"
}
},
"universalProvider": {
"title": "統合プロバイダー",
+170 -18
View File
@@ -38,7 +38,9 @@
"reset": "重置",
"actions": "操作",
"deleting": "删除中...",
"auto": "自动"
"auto": "自动",
"enabled": "已开启",
"notSet": "未设置"
},
"apiKeyInput": {
"placeholder": "请输入API Key",
@@ -170,6 +172,7 @@
"settingsSaved": "设置已保存",
"settingsSaveFailed": "保存设置失败:{{error}}",
"openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
"openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用",
"proxyRequiredForSwitch": "此供应商需要代理服务,请先启动代理",
"openLinkFailed": "链接打开失败",
"openclawModelsRegistered": "模型已注册到 /model 列表",
@@ -188,6 +191,11 @@
"message": "本地代理是一项高级功能,启用前请确保您已了解其工作原理。\n\n建议先查阅相关文档或咨询您的供应商,以获取正确的配置方式。",
"confirm": "我已了解,继续启用"
},
"failover": {
"title": "启用故障转移功能",
"message": "故障转移是一项高级功能,启用前请确保您已了解其工作原理。\n\n建议先在故障转移队列中配置好供应商优先级。",
"confirm": "我已了解,继续启用"
},
"usage": {
"title": "配置用量查询",
"message": "用量查询需要配置专用的查询脚本或 API 参数,请确保您已从供应商处获取相关信息。\n\n如不确定如何配置,请先查阅供应商文档。",
@@ -197,6 +205,11 @@
"title": "模型健康检测",
"message": "健康检测通过直接发送 API 请求来测试供应商连通性,以下情况可能导致检测失败:\n\n• 官方供应商(使用 OAuth 登录,无独立 API Key)\n• 部分中转服务(会校验请求是否来自 Claude Code CLI\n• AWS Bedrock(使用 IAM 签名认证)\n\n检测失败不代表供应商不可用,仅表示无法通过独立请求验证。请以应用内的实际情况为准。",
"confirm": "我已了解,继续检测"
},
"autoSync": {
"title": "开启自动同步",
"message": "开启自动同步后,每次数据库变更都会自动上传到 WebDAV 服务器。\n\n这可能会产生较高的网络流量消耗,请确保您的网络环境和 WebDAV 服务支持频繁的数据传输。",
"confirm": "我已了解,继续开启"
}
},
"settings": {
@@ -215,6 +228,8 @@
"description": "控制代理服务开关、查看状态与端口信息",
"enableFeature": "在主页面显示本地代理开关",
"enableFeatureDescription": "开启后,主页面顶部将显示代理和故障转移开关",
"enableFailoverToggle": "在主页面显示故障转移开关",
"enableFailoverToggleDescription": "开启后,主页面顶部将独立显示故障转移开关",
"running": "运行中",
"stopped": "已停止"
},
@@ -397,7 +412,7 @@
"saveAndTestSuccess": "配置已保存,连接正常",
"saveAndTestFailed": "配置已保存,但连接测试失败:{{error}}",
"noRemoteData": "云端没有找到同步数据",
"incompatibleVersion": "远端数据版本不兼容(v{{version}}),当前支持 v2",
"incompatibleVersion": "远端数据版本不兼容(协议 v{{protocolVersion}},数据库 {{dbCompatVersion}}),当前支持协议 v2 / db-v6",
"unsaved": "未保存",
"saved": "已保存",
"unsavedChanges": "请先保存配置",
@@ -408,7 +423,10 @@
"title": "从云端恢复",
"deviceName": "上传设备",
"createdAt": "上传时间",
"path": "远端路径",
"dbCompat": "数据库兼容层",
"artifacts": "包含内容",
"legacyNotice": "检测到旧版云端路径。恢复完成后,下次上传将写入新路径 v2/db-v6。",
"warning": "恢复将覆盖本地所有数据和技能配置",
"confirm": "确认恢复"
},
@@ -421,7 +439,10 @@
"existingData": "云端已有数据",
"deviceName": "上传设备",
"createdAt": "上传时间",
"path": "远端路径",
"dbCompat": "数据库兼容层",
"warning": "将覆盖云端已有的同步数据",
"legacyNotice": "检测到旧版云端路径数据。本次上传将写入新路径 v2/db-v6,不会覆盖旧路径。",
"confirm": "确认上传"
}
},
@@ -568,7 +589,8 @@
"pricingLoadFailed": "加载计费配置失败:{{error}}",
"defaultCostMultiplierRequired": "默认倍率不能为空",
"defaultCostMultiplierInvalid": "默认倍率格式不正确"
}
},
"saveFailedGeneric": "保存失败,请重试"
},
"apps": {
"claude": "Claude",
@@ -604,6 +626,14 @@
"projectDirCopied": "目录已复制",
"copySourcePath": "复制原始文件",
"sourcePathCopied": "原始文件已复制",
"delete": "删除会话",
"deleting": "删除中...",
"deleteTooltip": "永久删除此本地会话记录",
"deleteConfirmTitle": "删除会话",
"deleteConfirmMessage": "将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
"deleteConfirmAction": "删除会话",
"sessionDeleted": "会话已删除",
"deleteFailed": "删除会话失败: {{error}}",
"loadingMessages": "加载会话内容中...",
"emptySession": "该会话暂无可展示内容",
"clickToCopyPath": "点击复制路径",
@@ -614,7 +644,14 @@
"daysAgo": "{{count}} 天前",
"roleUser": "用户",
"roleSystem": "系统",
"roleTool": "工具"
"roleTool": "工具",
"resume": "恢复会话",
"resumeTooltip": "在终端中恢复此会话",
"noResumeCommand": "此会话无法恢复",
"copyCommand": "复制命令",
"copyMessage": "复制消息",
"messageCopied": "已复制消息内容",
"conversationHistory": "对话记录"
},
"console": {
"providerSwitchReceived": "收到供应商切换事件:",
@@ -668,7 +705,11 @@
"aicodemirror": "AICodeMirror 是 CC Switch 的官方合作伙伴,使用此链接注册可享受8折优惠!",
"aicoding": "AI Coding 为 CC Switch 的用户提供了特殊优惠,首次充值可以享受 9 折优惠!",
"crazyrouter": "CrazyRouter 为 CC Switch 的用户提供了特殊优惠,首次充值赠予 30% 额外额度!",
"sssaicode": "SSAI Code 为 CC Switch 的用户提供了特殊优惠,每次充值赠予额外 $10 额度!"
"sssaicode": "SSAI Code 为 CC Switch 的用户提供了特殊优惠,每次充值赠予额外 $10 额度!",
"siliconflow": "硅基流动是 CC Switch 的官方合作伙伴",
"ucloud": "优云智算为CC Switch 的用户提供了特殊优惠,通过此链接注册,可以获得五元平台体验金!",
"micu": "Micu 是 CC Switch 的官方合作伙伴",
"x-code": "XCodeAPI 为CC Switch 的用户提供特别福利,使用此链接注册后首单加赠10%的额度(联系站长领取)"
},
"parameterConfig": "参数配置 - {{name}} *",
"mainModel": "主模型 (可选)",
@@ -701,6 +742,12 @@
"fullUrlHint": "代理将直接使用此 URL,不拼接路径",
"apiFormatAnthropic": "Anthropic Messages (原生)",
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)",
"apiFormatOpenAIResponses": "OpenAI Responses API (需开启代理)",
"authField": "认证字段",
"authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(默认)",
"authFieldApiKey": "ANTHROPIC_API_KEY",
"authFieldHint": "选择写入配置的认证环境变量名",
"apiHintResponses": "💡 填写兼容 OpenAI Responses API 的服务端点地址,不要以斜杠结尾",
"anthropicDefaultHaikuModel": "Haiku 默认模型",
"anthropicDefaultSonnetModel": "Sonnet 默认模型",
"anthropicDefaultOpusModel": "Opus 默认模型",
@@ -736,7 +783,8 @@
"pleaseAddEndpoint": "请先添加端点",
"testUnavailable": "测速功能不可用",
"noResult": "未返回结果",
"testFailed": "测速失败: {{error}}"
"testFailed": "测速失败: {{error}}",
"empty": "暂无端点"
},
"providerAdvanced": {
"testConfig": "模型测试配置",
@@ -780,7 +828,9 @@
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
"extractFailed": "提取失败: {{error}}",
"saveFailed": "保存失败: {{error}}",
"modelNameHint": "指定使用的模型,将自动更新到 config.toml 中"
"modelNameHint": "指定使用的模型,将自动更新到 config.toml 中",
"modelName": "模型名称",
"modelNamePlaceholder": "例如: gpt-5-codex"
},
"geminiConfig": {
"envFile": "环境变量 (.env)",
@@ -925,7 +975,28 @@
"costBreakdown": "成本明细",
"performance": "性能信息",
"latency": "延迟",
"errorMessage": "错误信息"
"errorMessage": "错误信息",
"requests": "请求数",
"tokens": "Tokens",
"avgCost": "平均成本",
"avgLatency": "平均延迟",
"successRate": "成功率",
"requestId": "请求 ID",
"never": "从不",
"modelId": "模型 ID",
"modelIdRequired": "模型 ID 不能为空",
"inputCostPerMillion": "输入成本 (每百万 tokens, USD)",
"outputCostPerMillion": "输出成本 (每百万 tokens, USD)",
"invalidPrice": "价格必须为非负数",
"invalidTimeRange": "请选择完整的开始/结束时间",
"invalidTimeRangeOrder": "开始时间不能晚于结束时间",
"timeRangeTooLarge": "时间范围过大,请缩小范围",
"addPricing": "新增定价",
"editPricing": "编辑定价",
"pricingAdded": "定价已添加",
"pricingUpdated": "定价已更新",
"cacheReadCostPerMillion": "缓存读取成本 (每百万 tokens, USD)",
"cacheCreationCostPerMillion": "缓存写入成本 (每百万 tokens, USD)"
},
"usageScript": {
"title": "配置用量查询",
@@ -1245,6 +1316,7 @@
}
},
"openclaw": {
"backupCreated": "已创建备份:{{path}}",
"providerKey": "供应商标识",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
@@ -1275,9 +1347,15 @@
"cacheWriteCost": "缓存写入价格 ($/M tokens)",
"cacheCostHint": "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
"modelsHint": "配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。",
"userAgent": "发送 User-Agent",
"userAgentHint": "部分供应商需要浏览器 User-Agent 才能正常访问。",
"env": {
"title": "环境变量",
"description": "管理 openclaw.json 中的环境变量配置(API Key、自定义变量等)",
"editorHint": "以 JSON 形式编辑整个 env 节点。支持 env.vars、env.shellEnv 等嵌套对象。",
"objectRequired": "OpenClaw 的 env 必须是 JSON 对象。",
"invalidJson": "OpenClaw 的 env 必须是合法 JSON。",
"empty": "OpenClaw 的 env 不能为空。空对象请使用 {}。",
"keyPlaceholder": "变量名",
"valuePlaceholder": "值",
"add": "添加变量",
@@ -1290,6 +1368,14 @@
"title": "工具权限",
"description": "管理 openclaw.json 中的工具权限配置(允许/拒绝列表)",
"profile": "权限模式",
"profileMinimal": "最小权限",
"profileCoding": "编码",
"profileMessaging": "对话",
"profileFull": "完全访问",
"profileUnset": "未设置",
"unsupportedProfileTitle": "检测到不受支持的工具配置",
"unsupportedProfileDescription": "当前 tools.profile 的值“{{value}}”不在 OpenClaw 支持列表内。在你手动选择新值之前,它会被保留。",
"unsupportedProfileLabel": "不受支持",
"profiles": {
"default": "默认",
"strict": "严格",
@@ -1322,10 +1408,22 @@
"timeout": "超时时间(秒)",
"contextTokens": "上下文 Token 数",
"maxConcurrent": "最大并发数",
"legacyTimeoutTitle": "检测到旧版超时字段",
"legacyTimeoutDescription": "当前配置仍在使用 agents.defaults.timeout。保存本页面时会迁移为 timeoutSeconds。",
"saveSuccess": "Agents 配置已保存",
"saveFailed": "保存 Agents 配置失败",
"loadFailed": "读取 Agents 配置失败"
}
},
"health": {
"title": "检测到 OpenClaw 配置警告",
"invalidToolsProfile": "tools.profile 使用了不受支持的值。OpenClaw 当前只支持 minimal、coding、messaging、full。",
"legacyTimeout": "agents.defaults.timeout 已废弃。打开并保存 Agents 面板即可迁移到 timeoutSeconds。",
"stringifiedEnvVars": "env.vars 应为对象,但当前值看起来像被字符串化或已损坏。",
"stringifiedShellEnv": "env.shellEnv 应为对象,但当前值看起来像被字符串化或已损坏。",
"parseFailed": "openclaw.json 不是合法 JSON5。请先修复文件,再通过这里编辑。"
},
"primaryModel": "默认模型",
"fallbackModel": "回退模型"
},
"env": {
"warning": {
@@ -1545,7 +1643,14 @@
"skillsPath": "Skills 路径",
"hint": "此操作将添加 Skill 仓库到列表。",
"hintDetail": "添加后,您可以在 Skills 管理界面中选择安装具体的 Skill。"
}
},
"usageScript": "用量查询",
"usageScriptEnabled": "已启用",
"usageScriptDisabled": "未启用",
"usageApiKey": "用量 API Key",
"usageBaseUrl": "用量查询地址",
"usageAutoInterval": "自动查询",
"usageAutoIntervalValue": "每 {{minutes}} 分钟"
},
"iconPicker": {
"search": "搜索图标",
@@ -1564,7 +1669,8 @@
"selectIcon": "选择图标",
"preview": "预览",
"clickToChange": "点击更换图标",
"clickToSelect": "点击选择图标"
"clickToSelect": "点击选择图标",
"color": "图标颜色"
},
"migration": {
"success": "配置迁移成功",
@@ -1701,7 +1807,12 @@
"hint": "选择要接管的应用,启用后该应用的请求将通过本地代理转发",
"enabled": "{{app}} 接管已启用",
"disabled": "{{app}} 接管已关闭",
"failed": "切换接管状态失败"
"failed": "切换接管状态失败",
"tooltip": {
"active": "{{appLabel}} 已接管 - {{address}}:{{port}}\n切换该应用供应商为热切换",
"broken": "{{appLabel}} 已接管,但代理服务未运行",
"inactive": "接管 {{appLabel}} 的 Live 配置,让该应用请求走本地代理"
}
},
"failover": {
"proxyRequired": "需要先启动代理服务才能配置故障转移",
@@ -1751,8 +1862,28 @@
"successThresholdLabel": "恢复成功阈值",
"successThresholdExplain": "半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用",
"errorRateLabel": "错误率阈值",
"errorRateExplain": "错误率超过此值时,即使未达到失败阈值也会打开熔断器"
}
"errorRateExplain": "错误率超过此值时,即使未达到失败阈值也会打开熔断器",
"maxRetries": "最大重试次数",
"timeoutSettings": "超时配置",
"streamingFirstByte": "流式首字节超时",
"streamingIdle": "流式静默超时",
"nonStreaming": "非流式超时",
"maxRetriesHint": "请求失败时的重试次数(0-10",
"streamingFirstByteHint": "等待首个数据块的最大时间,范围 1-120 秒,默认 60 秒",
"streamingIdleHint": "数据块之间的最大间隔,范围 60-600 秒,填 0 禁用(防止中途卡住)",
"nonStreamingHint": "非流式请求的总超时时间,范围 60-1200 秒,默认 600 秒(10 分钟)"
},
"logging": {
"enabled": "日志记录已启用",
"disabled": "日志记录已关闭",
"failed": "切换日志状态失败"
},
"server": {
"started": "代理服务已启动 - {{address}}:{{port}}",
"startFailed": "启动代理服务失败: {{detail}}"
},
"stoppedWithRestore": "代理服务已关闭,已恢复所有接管配置",
"stopWithRestoreFailed": "停止失败: {{detail}}"
},
"streamCheck": {
"configSaved": "健康检查配置已保存",
@@ -1765,7 +1896,11 @@
"timeout": "超时时间(秒)",
"maxRetries": "最大重试次数",
"degradedThreshold": "降级阈值(毫秒)",
"testPrompt": "检查提示词"
"testPrompt": "检查提示词",
"operational": "{{providerName}} 运行正常 ({{responseTimeMs}}ms)",
"degraded": "{{providerName}} 响应较慢 ({{responseTimeMs}}ms)",
"failed": "{{providerName}} 检查失败: {{message}}",
"error": "{{providerName}} 检查出错: {{error}}"
},
"proxyConfig": {
"proxyEnabled": "代理总开关",
@@ -1786,12 +1921,29 @@
"circuitBreaker": {
"failureThreshold": "失败阈值",
"successThreshold": "成功阈值",
"timeoutSeconds": "超时时间",
"errorRateThreshold": "错误率阈值",
"timeoutSeconds": "超时时间(秒)",
"errorRateThreshold": "错误率阈值 (%)",
"minRequests": "最小请求数",
"validationFailed": "以下字段超出有效范围: {{fields}}",
"configSaved": "熔断器配置已保存",
"saveFailed": "保存失败"
"saveFailed": "保存失败",
"loading": "加载中...",
"title": "熔断器配置",
"description": "调整熔断器参数以控制故障检测和恢复行为",
"failureThresholdHint": "连续失败多少次后打开熔断器",
"timeoutSecondsHint": "熔断器打开后多久尝试恢复(半开状态)",
"successThresholdHint": "半开状态下成功多少次后关闭熔断器",
"errorRateThresholdHint": "错误率超过此值时打开熔断器",
"minRequestsHint": "计算错误率前的最小请求数",
"saveConfig": "保存配置",
"instructionsTitle": "配置说明",
"instructions": {
"failureThreshold": "连续失败达到此次数时,熔断器打开",
"timeout": "熔断器打开后,等待此时间后尝试半开",
"successThreshold": "半开状态下,成功达到此次数时关闭熔断器",
"errorRate": "错误率超过此值时,熔断器打开",
"minRequests": "只有请求数达到此值后才计算错误率"
}
},
"universalProvider": {
"title": "统一供应商",
+3
View File
@@ -60,6 +60,9 @@ export const icons: Record<string, string> = {
aihubmix: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AiHubMix</title><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#006FFB"></path><path clip-rule="evenodd" d="M11.24 8.393c.095-.644.302-1.47.624-2.48L12 5.496l.136.417c.322 1.01.53 1.836.624 2.48.071.472.071 1.072 0 1.8-.072.731-.072 1.336 0 1.814.106.7.426 1.281.96 1.744a2.795 2.795 0 001.89.708 2.78 2.78 0 002.034-.84c.56-.559.842-1.234.848-2.024.003-.7.075-1.472.216-2.316.069-.422.14-.775.21-1.06l.095-.384.168.356a7.862 7.862 0 01.76 3.244v.16a7.84 7.84 0 01-.624 3.089 7.952 7.952 0 01-4.228 4.228 7.841 7.841 0 01-3.089.623 7.84 7.84 0 01-3.089-.623 7.952 7.952 0 01-4.228-4.228 7.84 7.84 0 01-.623-3.09v-.159a7.862 7.862 0 01.759-3.244l.169-.356.093.385c.072.284.143.637.211 1.059.141.844.213 1.616.216 2.316.006.79.29 1.465.848 2.024.563.56 1.241.84 2.035.84.715 0 1.345-.236 1.889-.708a2.79 2.79 0 00.96-1.744c.073-.478.073-1.083 0-1.814-.071-.728-.071-1.328 0-1.8zm.76 9.694c1.097 0 2.125-.26 3.085-.778a6.379 6.379 0 001.77-1.399c.063-.07-.01-.178-.101-.153-.37.1-.75.15-1.144.15a4.236 4.236 0 01-2.18-.59 4.253 4.253 0 01-1.35-1.233.099.099 0 00-.16 0 4.253 4.253 0 01-1.35 1.232 4.236 4.236 0 01-2.18.591c-.393 0-.774-.05-1.143-.15-.091-.025-.165.083-.102.153a6.38 6.38 0 001.77 1.399c.96.518 1.988.778 3.085.778z" fill="#fff" fill-rule="evenodd"></path></svg>`,
opencode: `<svg height="1em" width="1em" style="flex:none;line-height:1" viewBox="0 0 240 300" xmlns="http://www.w3.org/2000/svg"><title>OpenCode</title><g clip-path="url(#clip0_1401_86274)"><mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300"><path d="M240 0H0V300H240V0Z" fill="white"/></mask><g mask="url(#mask0_1401_86274)"><path d="M180 240H60V120H180V240Z" fill="#CFCECD"/><path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/></g></g><defs><clipPath id="clip0_1401_86274"><rect width="240" height="300" fill="white"/></clipPath></defs></svg>`,
siliconflow: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>SiliconCloud</title><path clip-rule="evenodd" d="M22.956 6.521H12.522c-.577 0-1.044.468-1.044 1.044v3.13c0 .577-.466 1.044-1.043 1.044H1.044c-.577 0-1.044.467-1.044 1.044v4.174C0 17.533.467 18 1.044 18h10.434c.577 0 1.044-.467 1.044-1.043v-3.13c0-.578.466-1.044 1.043-1.044h9.391c.577 0 1.044-.467 1.044-1.044V7.565c0-.576-.467-1.044-1.044-1.044z" fill="#6E29F6" fill-rule="evenodd"></path></svg>`,
"x-code": `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" style="flex:none;line-height:1" viewBox="0 0 32 32"><title>X-Code</title><path d="M0 0h32v32H0Z" fill="#E9F5FC"/><path d="M16.5 7.625c.77-.008 1.55-.015 2.34-.023 2.64.487 3.55 1.285 5.16 3.398v1.32h-8v-1c-.99.495-.99.495-2 .995-.47 2.356-.47 2.356-.63 5.063l-.22 2.785c-.05.71-.1 1.42-.15 2.152h2l.99-3h8c-1.88 3.875-1.88 3.875-3 5-1.96.203-1.96.203-4.38.25-.79.026-1.58.052-2.4.078-2.22-.328-2.22-.328-3.98-1.531-1.63-2.37-1.63-3.94-1.61-6.797.008-.866.015-1.733.024-2.625.38-2.375.38-2.375 1.62-4.172 2.22-1.53 3.58-1.612 5.94-1.586Z" fill="#243FC5"/><path d="M17 1c1.44-.081 2.87-.14 4.31-.188 1.2-.052 1.2-.052 2.43-.105C26 1 26 1 27.79 2.387 29 4 29 4 29 6h-8V4c-1.32.33-2.64.66-4 1V1Z" fill="#219DF4"/><path d="M14 1c-1.96 2.29-3.92 4.58-7 8-.37 2.328-.7 4.662-1 7H2C1.1 8.539 1.1 8.539 3.13 5.066 6.49 1.414 8.9.272 14 1Z" fill="#1448D6"/><path d="M27 20h4c.47 2.647.4 4.37-1.05 6.668-2.18 2.478-3.82 4.133-7.18 4.71H17v-4l3.31-.688C24.57 24.797 24.91 23.778 27 20Z" fill="#0E49D9"/><path d="M2 19c4.53 3.678 8.39 7.41 12 12H8.01c-.42-.447-.84-.895-1.28-1.355C2.46 25.016 2.46 25.016 1 19Z" fill="#0B89F0"/><path d="M11 9v5l-2-1c-.36-2.313-.36-2.313-.31-5 .004-.887.008-1.773.01-2.688C9 3 9 3 11 1v8Z" fill="#1939C2"/><path d="M2 7h4v9H2V7Z" fill="#059CF3"/><path d="M17 27h6l2 4h-8v-4Z" fill="#0773EE"/><path d="M17 1h8l-2 4h-6V1Z" fill="#0CB7F7"/><path d="M16 19h8l-2 4h-7l1-4Z" fill="#2F4AC8"/><path d="M7 4l3 1-4 5-4-1 5-5Z" fill="#053FD7"/></svg>`,
micu: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" style="flex:none;line-height:1" viewBox="0 0 241.39 240.6"><title>Micu</title><defs><style>.mc-1{fill:#068cde}.mc-2{fill:#fff}.mc-3{fill:#02a6ff}</style></defs><g><path class="mc-1" d="M226.14,157.63c-3.62,0-7.24,0-10.95,0v-24.96c5.2,0,10.17,0,15.13,0,5.55-.01,8.52-4.01,10.18-8.16,1.34-3.37,1.36-7.51-1.34-11.1-3.16-4.2-7.23-5.72-12.25-5.63-3.87.07-7.74.01-11.66.01v-24.79c6.56-.43,12.93.45,19.3-1.13.4-.42.85-1.05,1.44-1.49,4.61-3.47,6.48-9.22,4.67-14.51-1.63-4.76-6.67-8.03-12.22-7.99-4.37.03-8.74,0-13.42,0,0-5.79.1-11.16-.04-16.54-.08-3.23-1.09-6.23-3.22-8.79-7.6-9.17-17.84-6.02-28.13-6.29-.39-.23-.63-1.14-.45-2.35.73-4.72.37-9.44-.24-14.13-.51-3.95-5.79-9.24-9.1-9.56-10.42-1-15.88,5.21-15.83,14.89.02,3.54,0,7.08,0,10.92h-24.44c-.76-1.03-.45-2.13-.42-3.19.15-5.2.71-10.35-1.11-15.5-2.15-6.08-11.68-9.27-17.05-5.79-5.27,3.41-7.22,7.95-6.99,13.99.14,3.56.53,7.22-.44,10.6h-23.98c-.22-.38-.37-.51-.37-.66-.05-4.56,0-9.12-.14-13.68-.15-5.18-4.72-10.76-9.31-11.57-8.81-1.55-15.64,4.23-15.64,13.24,0,4.11,0,8.21,0,12.61-4.92,0-9.32.08-13.72-.02-10.41-.22-18.81,7.79-17.84,18.57.39,4.32.06,8.7.06,13.34-5.17,0-9.9-.05-14.63.01-5.36.07-11.08,5.57-11.83,10.1-1.39,8.44,6.23,15.25,14.55,14.78,3.86-.22,7.74-.04,11.75-.04v24.92c-4.45,0-8.67-.07-12.89.02-3.2.07-6.5.62-8.75,2.93-3.6,3.69-6.1,8.11-4.12,13.5,2.13,5.8,6.42,8.43,12.98,8.43,4.21,0,8.42,0,12.83,0v24.95c-3.48,0-6.79-.12-10.08.03-3.74.18-7.43.36-10.78,2.69-4.11,2.87-6.48,8.21-5.32,12.83,1.1,4.36,7.17,9.83,11.59,9.41,4.82-.46,9.72-.1,14.69-.1,0,5.18.51,9.88-.1,14.44-1.36,10,8.64,18.06,17.22,17.5,4.62-.3,9.28-.05,14.15-.05,1.26,9.11-3.28,19.95,9.5,25.9,10.27,1.43,15.74-3.33,15.75-15.14,0-3.45,0-6.9,0-10.55h24.94c0,3.39,0,6.6,0,9.8,0,3.81.26,7.41,2.73,10.76,3.09,4.2,8.64,6.68,14,4.87,3.62-1.22,8.31-5.43,8.3-10.7,0-4.85,0-9.7,0-14.7h24.92c0,3.98-.14,7.7.03,11.41.22,4.83,1.4,9.35,5.68,12.32,5.16,3.59,12.81,2.96,17.28-2.41,3.93-7.43,2.05-14.57,2.39-21.58,5.71,0,11.1.03,16.49-.02,1.98-.02,4.05-.06,5.8-1.09,6.78-3.99,9.8-9.94,9.36-17.81-.23-4.18-.04-8.39-.04-12.56,8.59-1.68,18.23,2.96,24.73-6.3.08-.31.31-1.1.5-1.9.97-4.19,1.82-8.06-1.59-12.01-3.49-4.05-7.66-5.05-12.52-5.04ZM168.3,160.54c0,3.41-1.48,4.58-4.69,4.49-4.41-.12-8.82-.09-13.23-.05-3.15.03-4.43-1.39-4.41-4.56.06-16.85.02-33.69-.03-50.54,0-.99.44-2.13-.73-3.15-4.49,13.97-8.94,27.8-13.44,41.79h-21.8c-4.39-13.78-8.8-27.62-13.55-42.54-.15,1.94-.29,2.89-.29,3.84-.01,16.68-.08,33.36.04,50.04.03,3.7-1.18,5.38-5.05,5.16-5.51-.31-11.08.38-16.22-.44-1.24-1.59-1.21-2.95-1.21-4.26.07-27.3.18-54.6.22-81.9,0-2.16.89-3.46,2.97-3.48,9.9-.06,19.8,0,29.7.05.31,0,.63.19,1.39.44,4.22,14.29,8.51,28.79,12.8,43.3.29.05.58.1.87.15,4.53-14.59,9.07-29.17,13.66-43.95,10.35,0,20.48-.03,30.62.03,1.76.01,2.38,1.37,2.42,2.92.08,2.57.1,5.14.1,7.71-.06,24.98-.16,49.95-.15,74.93Z"/><rect class="mc-3" x="48.86" y="48.46" width="143.67" height="143.67" rx="10.57" ry="10.57"/><path class="mc-2" d="M165.55,75.28c-10.14-.06-20.27-.03-30.62-.03-4.59,14.78-9.12,29.36-13.66,43.95-.29-.05-.58-.1-.87-.15-4.29-14.51-8.58-29.01-12.8-43.3-.77-.25-1.08-.44-1.39-.44-9.9-.04-19.8-.1-29.7-.05-2.08.01-2.96,1.32-2.97,3.48-.04,27.3-.15,54.6-.22,81.9,0,1.31-.03,2.67,1.21,4.26,5.13.82,10.7.13,16.22.44,3.87.22,5.07-1.46,5.05-5.16-.12-16.68-.06-33.36-.04-50.04,0-.95.14-1.9.29-3.84,4.75,14.91,9.16,28.76,13.55,42.54h21.8c4.5-13.99,8.94-27.82,13.44-41.79,1.18,1.01.73,2.16.73,3.15.04,16.85.09,33.69.03,50.54-.01,3.17,1.26,4.59,4.41,4.56,4.41-.04,8.82-.07,13.23.05,3.21.09,4.69-1.08,4.69-4.49-.01-24.98.09-49.95.15-74.93,0-2.57-.02-5.14-.1-7.71-.05-1.55-.67-2.91-2.42-2.92Z"/></g></svg>`,
ucloud: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="1em" width="1em" style="flex:none;line-height:1" viewBox="0 0 172.11 172.11"><title>UCloud</title><defs><style>.cls-1{fill:url(#uc-g56)}.cls-2{fill:url(#uc-g37)}.cls-3{fill:url(#uc-g37-2)}.cls-4{fill:url(#uc-g37-3)}.cls-5{fill:url(#uc-g37-4)}.cls-6{fill:url(#uc-g37-5)}.cls-7{fill:url(#uc-g37-6)}.cls-8{fill:url(#uc-g37-7)}.cls-9{fill:#fff}.cls-10{fill:url(#uc-g38)}</style><linearGradient id="uc-g56" x1="86.06" y1="-6.73" x2="86.06" y2="185.53" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32303a"/><stop offset=".36" stop-color="#34323d"/><stop offset=".62" stop-color="#3a3946"/><stop offset=".85" stop-color="#444556"/><stop offset="1" stop-color="#4e5065"/></linearGradient><linearGradient id="uc-g37" x1="143.96" y1="73.06" x2="71.52" y2="34.1" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4043ff"/><stop offset="1" stop-color="#f0f5fa"/></linearGradient><linearGradient id="uc-g37-2" x1="104.88" y1="118.84" x2="71.68" y2="66.44" xlink:href="#uc-g37"/><linearGradient id="uc-g37-3" x1="95.68" y1="72.87" x2="33.68" y2="76.43" xlink:href="#uc-g37"/><linearGradient id="uc-g37-4" x1="70" y1="130.18" x2="46.68" y2="73.38" xlink:href="#uc-g37"/><linearGradient id="uc-g37-5" x1="107.49" y1="106.07" x2="147.27" y2="159.57" xlink:href="#uc-g37"/><linearGradient id="uc-g37-6" x1="106.69" y1="50.6" x2="142.65" y2="131.51" xlink:href="#uc-g37"/><linearGradient id="uc-g37-7" x1="111.35" y1="152.42" x2="82.55" y2="89.87" xlink:href="#uc-g37"/><linearGradient id="uc-g38" x1="64.73" y1="89.4" x2="83.39" y2="89.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5b5dfe"/><stop offset=".18" stop-color="#5b5dfe" stop-opacity=".99"/><stop offset=".31" stop-color="#5b5dfe" stop-opacity=".95"/><stop offset=".43" stop-color="#5b5cfe" stop-opacity=".89"/><stop offset=".54" stop-color="#5b5cfe" stop-opacity=".8"/><stop offset=".64" stop-color="#5b5bfe" stop-opacity=".68"/><stop offset=".74" stop-color="#5b5afe" stop-opacity=".54"/><stop offset=".83" stop-color="#5a59ff" stop-opacity=".38"/><stop offset=".92" stop-color="#5a58ff" stop-opacity=".19"/><stop offset="1" stop-color="#5a57ff" stop-opacity="0"/></linearGradient></defs><rect class="cls-1" width="172.11" height="172.11" rx="46.24"/><polygon class="cls-2" points="124.1 51.65 104.14 63.16 104.08 63.12 84.2 51.65 104.08 40.17 104.14 40.14 124.1 51.65"/><path class="cls-3" d="M111.61,90.51l-.1-.06-2.92-1.69a8.9,8.9,0,0,1-4.46-7.69l0-17.95L84.2,51.65l-.12.07V69.59a8.91,8.91,0,0,1-4.45,7.71L64.26,86.17v23l12.4-7.15,3.09-1.78a8.92,8.92,0,0,1,8.91,0l15.42,8.91.06,0,19.93-11.49h0Z"/><path class="cls-4" d="M84.08,66v3.55a8.91,8.91,0,0,1-4.45,7.71L64.26,86.17,44.32,74.68v0L64.26,63.17l12.41,7.15A4.94,4.94,0,0,0,84.08,66Z"/><polygon class="cls-5" points="64.26 86.17 64.26 109.2 44.32 97.7 44.32 74.68 64.26 86.17"/><polygon class="cls-6" points="124.1 97.7 124.1 120.72 124.08 120.72 104.14 132.23 104.08 132.21 104.08 109.25 104.14 109.2 124.08 97.7 124.1 97.7"/><path class="cls-7" d="M124.1,51.65v23h0l-12.48,7.21c-3.28,1.89-3,6.87-3,6.87a8.9,8.9,0,0,1-4.46-7.69l0-17.89.06,0Z"/><path class="cls-8" d="M104.08,109.18v23L84.2,120.72l-.12-.07V106.33a4.94,4.94,0,0,0-7.41-4.28l3-1.72a8.89,8.89,0,0,1,8.87,0Z"/><path class="cls-9" d="M85.28,81.09V91.24a2.56,2.56,0,0,0,3.85,2.22l8.81-5.09a2.56,2.56,0,0,0,0-4.44l-8.82-5.06A2.56,2.56,0,0,0,85.28,81.09Z"/><path class="cls-10" d="M84.08,69.59a8.91,8.91,0,0,1-4.45,7.71L64.26,86.17v23l12.4-7.15,3.09-1.78a8.82,8.82,0,0,1,4.33-1.19Z"/></svg>`,
sssaicode: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 512 512"><title>SSAI Code</title><defs><linearGradient id="ssc-gradLeft" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#0ff5ce" /><stop offset="100%" stop-color="#147a8a" /></linearGradient><linearGradient id="ssc-gradRight" x1="100%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#d0e4f5" /><stop offset="100%" stop-color="#6a9ec4" /></linearGradient><linearGradient id="ssc-gradTop" x1="50%" y1="0%" x2="50%" y2="100%"><stop offset="0%" stop-color="#a0d8e8" /><stop offset="100%" stop-color="#4aafbf" /></linearGradient><linearGradient id="ssc-gradText" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#0ff5ce" /><stop offset="35%" stop-color="#4abfcf" /><stop offset="65%" stop-color="#7badd4" /><stop offset="100%" stop-color="#c0daf0" /></linearGradient><linearGradient id="ssc-gradS" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#f0f8ff" /><stop offset="100%" stop-color="#6cbfcf" /></linearGradient><filter id="ssc-glow" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="4" result="blur" /><feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge></filter><pattern id="ssc-binL" x="0" y="0" width="55" height="16" patternUnits="userSpaceOnUse" patternTransform="rotate(-3)"><text x="0" y="11" font-family="monospace" font-size="8" fill="rgba(0,255,210,0.25)">1001 1101</text></pattern><pattern id="ssc-binR" x="0" y="0" width="55" height="16" patternUnits="userSpaceOnUse" patternTransform="rotate(3)"><text x="0" y="11" font-family="monospace" font-size="8" fill="rgba(180,210,240,0.25)">0110 1011</text></pattern><pattern id="ssc-binT" x="0" y="0" width="50" height="16" patternUnits="userSpaceOnUse"><text x="2" y="11" font-family="monospace" font-size="8" fill="rgba(120,200,220,0.2)">10 110</text></pattern></defs><rect width="512" height="512" rx="72" fill="#08080e" /><polygon points="90,350 250,350 170,228" fill="url(#ssc-gradLeft)" opacity="0.8" /><polygon points="90,350 250,350 170,228" fill="url(#ssc-binL)" /><polygon points="262,350 422,350 342,228" fill="url(#ssc-gradRight)" opacity="0.8" /><polygon points="262,350 422,350 342,228" fill="url(#ssc-binR)" /><polygon points="176,290 336,290 256,168" fill="none" stroke="url(#ssc-gradTop)" stroke-width="2.5" opacity="0.85" /><polygon points="192,280 320,280 256,184" fill="none" stroke="url(#ssc-gradTop)" stroke-width="0.8" opacity="0.35" /><text x="256" y="316" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="120" font-weight="bold" fill="url(#ssc-gradS)" filter="url(#ssc-glow)">S</text><text x="256" y="425" text-anchor="middle" font-family="'Helvetica Neue', 'Segoe UI', Arial, sans-serif" font-size="40" font-weight="300" letter-spacing="5" fill="url(#ssc-gradText)">SSSAiCode</text></svg>`,
catcoder: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>KwaiKAT</title><path d="M20.42 19.311h3.418V1l-6.781 4.177-6.778-4.111.026 7.868h3.418l-.026-2.222 3.42 2.035 3.303-2.035v12.6z"></path><path d="M3.064 10.734c2.784-2.07 6.942-2.394 9.941.907l.01.01.01.013 9.16 12.24h-3.84l-7.69-10.217c-1.63-1.737-3.891-1.689-5.515-.638-1.624 1.05-2.563 3.073-1.548 5.28 1.494 3.246 6.152 3.275 7.725.108l.032-.064 2.02 2.629c-2.98 3.968-9.329 3.926-12.165-.552-2.395-3.78-.926-7.645 1.86-9.716z"></path></svg>`,
mcp: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelContextProtocol</title><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path></svg>`,
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 241.39 240.6">
<defs>
<style>
.cls-1 {
fill: #068cde;
}
.cls-2 {
fill: #fff;
}
.cls-3 {
fill: #02a6ff;
}
</style>
</defs>
<g id="_图层_1-2" data-name="图层 1">
<path class="cls-1" d="M226.14,157.63c-3.62,0-7.24,0-10.95,0v-24.96c5.2,0,10.17,0,15.13,0,5.55-.01,8.52-4.01,10.18-8.16,1.34-3.37,1.36-7.51-1.34-11.1-3.16-4.2-7.23-5.72-12.25-5.63-3.87.07-7.74.01-11.66.01v-24.79c6.56-.43,12.93.45,19.3-1.13.4-.42.85-1.05,1.44-1.49,4.61-3.47,6.48-9.22,4.67-14.51-1.63-4.76-6.67-8.03-12.22-7.99-4.37.03-8.74,0-13.42,0,0-5.79.1-11.16-.04-16.54-.08-3.23-1.09-6.23-3.22-8.79-7.6-9.17-17.84-6.02-28.13-6.29-.39-.23-.63-1.14-.45-2.35.73-4.72.37-9.44-.24-14.13-.51-3.95-5.79-9.24-9.1-9.56-10.42-1-15.88,5.21-15.83,14.89.02,3.54,0,7.08,0,10.92h-24.44c-.76-1.03-.45-2.13-.42-3.19.15-5.2.71-10.35-1.11-15.5-2.15-6.08-11.68-9.27-17.05-5.79-5.27,3.41-7.22,7.95-6.99,13.99.14,3.56.53,7.22-.44,10.6h-23.98c-.22-.38-.37-.51-.37-.66-.05-4.56,0-9.12-.14-13.68-.15-5.18-4.72-10.76-9.31-11.57-8.81-1.55-15.64,4.23-15.64,13.24,0,4.11,0,8.21,0,12.61-4.92,0-9.32.08-13.72-.02-10.41-.22-18.81,7.79-17.84,18.57.39,4.32.06,8.7.06,13.34-5.17,0-9.9-.05-14.63.01-5.36.07-11.08,5.57-11.83,10.1-1.39,8.44,6.23,15.25,14.55,14.78,3.86-.22,7.74-.04,11.75-.04v24.92c-4.45,0-8.67-.07-12.89.02-3.2.07-6.5.62-8.75,2.93-3.6,3.69-6.1,8.11-4.12,13.5,2.13,5.8,6.42,8.43,12.98,8.43,4.21,0,8.42,0,12.83,0v24.95c-3.48,0-6.79-.12-10.08.03-3.74.18-7.43.36-10.78,2.69-4.11,2.87-6.48,8.21-5.32,12.83,1.1,4.36,7.17,9.83,11.59,9.41,4.82-.46,9.72-.1,14.69-.1,0,5.18.51,9.88-.1,14.44-1.36,10,8.64,18.06,17.22,17.5,4.62-.3,9.28-.05,14.15-.05,1.26,9.11-3.28,19.95,9.5,25.9,10.27,1.43,15.74-3.33,15.75-15.14,0-3.45,0-6.9,0-10.55h24.94c0,3.39,0,6.6,0,9.8,0,3.81.26,7.41,2.73,10.76,3.09,4.2,8.64,6.68,14,4.87,3.62-1.22,8.31-5.43,8.3-10.7,0-4.85,0-9.7,0-14.7h24.92c0,3.98-.14,7.7.03,11.41.22,4.83,1.4,9.35,5.68,12.32,5.16,3.59,12.81,2.96,17.28-2.41,3.93-7.43,2.05-14.57,2.39-21.58,5.71,0,11.1.03,16.49-.02,1.98-.02,4.05-.06,5.8-1.09,6.78-3.99,9.8-9.94,9.36-17.81-.23-4.18-.04-8.39-.04-12.56,8.59-1.68,18.23,2.96,24.73-6.3.08-.31.31-1.1.5-1.9.97-4.19,1.82-8.06-1.59-12.01-3.49-4.05-7.66-5.05-12.52-5.04ZM168.3,160.54c0,3.41-1.48,4.58-4.69,4.49-4.41-.12-8.82-.09-13.23-.05-3.15.03-4.43-1.39-4.41-4.56.06-16.85.02-33.69-.03-50.54,0-.99.44-2.13-.73-3.15-4.49,13.97-8.94,27.8-13.44,41.79h-21.8c-4.39-13.78-8.8-27.62-13.55-42.54-.15,1.94-.29,2.89-.29,3.84-.01,16.68-.08,33.36.04,50.04.03,3.7-1.18,5.38-5.05,5.16-5.51-.31-11.08.38-16.22-.44-1.24-1.59-1.21-2.95-1.21-4.26.07-27.3.18-54.6.22-81.9,0-2.16.89-3.46,2.97-3.48,9.9-.06,19.8,0,29.7.05.31,0,.63.19,1.39.44,4.22,14.29,8.51,28.79,12.8,43.3.29.05.58.1.87.15,4.53-14.59,9.07-29.17,13.66-43.95,10.35,0,20.48-.03,30.62.03,1.76.01,2.38,1.37,2.42,2.92.08,2.57.1,5.14.1,7.71-.06,24.98-.16,49.95-.15,74.93Z"/>
<rect class="cls-3" x="48.86" y="48.46" width="143.67" height="143.67" rx="10.57" ry="10.57"/>
<path class="cls-2" d="M165.55,75.28c-10.14-.06-20.27-.03-30.62-.03-4.59,14.78-9.12,29.36-13.66,43.95-.29-.05-.58-.1-.87-.15-4.29-14.51-8.58-29.01-12.8-43.3-.77-.25-1.08-.44-1.39-.44-9.9-.04-19.8-.1-29.7-.05-2.08.01-2.96,1.32-2.97,3.48-.04,27.3-.15,54.6-.22,81.9,0,1.31-.03,2.67,1.21,4.26,5.13.82,10.7.13,16.22.44,3.87.22,5.07-1.46,5.05-5.16-.12-16.68-.06-33.36-.04-50.04,0-.95.14-1.9.29-3.84,4.75,14.91,9.16,28.76,13.55,42.54h21.8c4.5-13.99,8.94-27.82,13.44-41.79,1.18,1.01.73,2.16.73,3.15.04,16.85.09,33.69.03,50.54-.01,3.17,1.26,4.59,4.41,4.56,4.41-.04,8.82-.07,13.23.05,3.21.09,4.69-1.08,4.69-4.49-.01-24.98.09-49.95.15-74.93,0-2.57-.02-5.14-.1-7.71-.05-1.55-.67-2.91-2.42-2.92Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 172.11 172.11"><defs><style>.cls-1{fill:url(#未命名的渐变_56);}.cls-2{fill:url(#未命名的渐变_37);}.cls-3{fill:url(#未命名的渐变_37-2);}.cls-4{fill:url(#未命名的渐变_37-3);}.cls-5{fill:url(#未命名的渐变_37-4);}.cls-6{fill:url(#未命名的渐变_37-5);}.cls-7{fill:url(#未命名的渐变_37-6);}.cls-8{fill:url(#未命名的渐变_37-7);}.cls-9{fill:#fff;}.cls-10{fill:url(#未命名的渐变_38);}</style><linearGradient id="未命名的渐变_56" x1="86.06" y1="-6.73" x2="86.06" y2="185.53" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32303a"/><stop offset="0.36" stop-color="#34323d"/><stop offset="0.62" stop-color="#3a3946"/><stop offset="0.85" stop-color="#444556"/><stop offset="1" stop-color="#4e5065"/></linearGradient><linearGradient id="未命名的渐变_37" x1="143.96" y1="73.06" x2="71.52" y2="34.1" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4043ff"/><stop offset="1" stop-color="#f0f5fa"/></linearGradient><linearGradient id="未命名的渐变_37-2" x1="104.88" y1="118.84" x2="71.68" y2="66.44" xlink:href="#未命名的渐变_37"/><linearGradient id="未命名的渐变_37-3" x1="95.68" y1="72.87" x2="33.68" y2="76.43" xlink:href="#未命名的渐变_37"/><linearGradient id="未命名的渐变_37-4" x1="70" y1="130.18" x2="46.68" y2="73.38" xlink:href="#未命名的渐变_37"/><linearGradient id="未命名的渐变_37-5" x1="107.49" y1="106.07" x2="147.27" y2="159.57" xlink:href="#未命名的渐变_37"/><linearGradient id="未命名的渐变_37-6" x1="106.69" y1="50.6" x2="142.65" y2="131.51" xlink:href="#未命名的渐变_37"/><linearGradient id="未命名的渐变_37-7" x1="111.35" y1="152.42" x2="82.55" y2="89.87" xlink:href="#未命名的渐变_37"/><linearGradient id="未命名的渐变_38" x1="64.73" y1="89.4" x2="83.39" y2="89.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5b5dfe"/><stop offset="0.18" stop-color="#5b5dfe" stop-opacity="0.99"/><stop offset="0.31" stop-color="#5b5dfe" stop-opacity="0.95"/><stop offset="0.43" stop-color="#5b5cfe" stop-opacity="0.89"/><stop offset="0.54" stop-color="#5b5cfe" stop-opacity="0.8"/><stop offset="0.64" stop-color="#5b5bfe" stop-opacity="0.68"/><stop offset="0.74" stop-color="#5b5afe" stop-opacity="0.54"/><stop offset="0.83" stop-color="#5a59ff" stop-opacity="0.38"/><stop offset="0.92" stop-color="#5a58ff" stop-opacity="0.19"/><stop offset="1" stop-color="#5a57ff" stop-opacity="0"/></linearGradient></defs><title>资源 13</title><g id="图层_2" data-name="图层 2"><g id="图层_1-2" data-name="图层 1"><rect class="cls-1" width="172.11" height="172.11" rx="46.24"/><polygon class="cls-2" points="124.1 51.65 104.14 63.16 104.08 63.12 84.2 51.65 104.08 40.17 104.14 40.14 124.1 51.65"/><path class="cls-3" d="M111.61,90.51l-.1-.06-2.92-1.69a8.9,8.9,0,0,1-4.46-7.69l0-17.95L84.2,51.65l-.12.07V69.59a8.91,8.91,0,0,1-4.45,7.71L64.26,86.17v23l12.4-7.15,3.09-1.78a8.92,8.92,0,0,1,8.91,0l15.42,8.91.06,0,19.93-11.49h0Z"/><path class="cls-4" d="M84.08,66v3.55a8.91,8.91,0,0,1-4.45,7.71L64.26,86.17,44.32,74.68v0L64.26,63.17l12.41,7.15A4.94,4.94,0,0,0,84.08,66Z"/><polygon class="cls-5" points="64.26 86.17 64.26 109.2 44.32 97.7 44.32 74.68 64.26 86.17"/><polygon class="cls-6" points="124.1 97.7 124.1 120.72 124.08 120.72 104.14 132.23 104.08 132.21 104.08 109.25 104.14 109.2 124.08 97.7 124.1 97.7"/><path class="cls-7" d="M124.1,51.65v23h0l-12.48,7.21c-3.28,1.89-3,6.87-3,6.87a8.9,8.9,0,0,1-4.46-7.69l0-17.89.06,0Z"/><path class="cls-8" d="M104.08,109.18v23L84.2,120.72l-.12-.07V106.33a4.94,4.94,0,0,0-7.41-4.28l3-1.72a8.89,8.89,0,0,1,8.87,0Z"/><path class="cls-9" d="M85.28,81.09V91.24a2.56,2.56,0,0,0,3.85,2.22l8.81-5.09a2.56,2.56,0,0,0,0-4.44l-8.82-5.06A2.56,2.56,0,0,0,85.28,81.09Z"/><path class="cls-10" d="M84.08,69.59a8.91,8.91,0,0,1-4.45,7.71L64.26,86.17v23l12.4-7.15,3.09-1.78a8.82,8.82,0,0,1,4.33-1.19Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<path d="M0 0 C10.56 0 21.12 0 32 0 C32 10.56 32 21.12 32 32 C21.44 32 10.88 32 0 32 C0 21.44 0 10.88 0 0 Z " fill="#E9F5FC" transform="translate(0,0)"/>
<path d="M0 0 C0.7734375 -0.00773438 1.546875 -0.01546875 2.34375 -0.0234375 C4.98245826 0.4641499 5.89157252 1.26136133 7.5 3.375 C7.5 4.035 7.5 4.695 7.5 5.375 C4.86 5.375 2.22 5.375 -0.5 5.375 C-0.5 4.715 -0.5 4.055 -0.5 3.375 C-1.49 3.87 -1.49 3.87 -2.5 4.375 C-2.96751788 6.73099235 -2.96751788 6.73099235 -3.125 9.4375 C-3.19976562 10.35660156 -3.27453125 11.27570312 -3.3515625 12.22265625 C-3.40054687 12.93292969 -3.44953125 13.64320312 -3.5 14.375 C-2.84 14.375 -2.18 14.375 -1.5 14.375 C-1.17 13.385 -0.84 12.395 -0.5 11.375 C2.14 11.375 4.78 11.375 7.5 11.375 C7.83 12.365 8.16 13.355 8.5 14.375 C9.16 14.705 9.82 15.035 10.5 15.375 C10.5 14.385 10.5 13.395 10.5 12.375 C11.82 12.375 13.14 12.375 14.5 12.375 C15.07570686 15.08904664 14.79565729 16.83296164 13.46875 19.265625 C10.63940674 22.8167395 8.44343278 24.19169857 3.94140625 25.06640625 C-1.09124454 25.30083101 -5.51954121 25.45246753 -10.02734375 23 C-13.19959195 20.09372762 -14.38901731 18.24700687 -14.9375 13.9375 C-14.793125 13.091875 -14.64875 12.24625 -14.5 11.375 C-11.86 13.355 -9.22 15.335 -6.5 17.375 C-6.5 16.385 -6.5 15.395 -6.5 14.375 C-6.83 14.045 -7.16 13.715 -7.5 13.375 C-7.703125 11.1953125 -7.703125 11.1953125 -7.75 8.5 C-7.77578125 7.61570312 -7.8015625 6.73140625 -7.828125 5.8203125 C-7.5 3.375 -7.5 3.375 -6.265625 1.578125 C-4.03939632 0.06113732 -2.67866058 -0.02678661 0 0 Z " fill="#EAF3FB" transform="translate(16.5,7.625)"/>
<path d="M0 0 C5.28 0 10.56 0 16 0 C16 10.56 16 21.12 16 32 C13.36 32 10.72 32 8 32 C8.42152344 31.55269531 8.84304687 31.10539062 9.27734375 30.64453125 C13.5377979 26.01600234 13.5377979 26.01600234 15 20 C13.68 20 12.36 20 11 20 C11 20.99 11 21.98 11 23 C9.68 22.34 8.36 21.68 7 21 C7.33 20.34 7.66 19.68 8 19 C6.700625 19.12375 5.40125 19.2475 4.0625 19.375 C2.96615234 19.47941406 2.96615234 19.47941406 1.84765625 19.5859375 C1.23792969 19.72257812 0.62820312 19.85921875 0 20 C-0.33 20.66 -0.66 21.32 -1 22 C-1.66 22 -2.32 22 -3 22 C-3 18.7 -3 15.4 -3 12 C-2.01 11.67 -1.02 11.34 0 11 C0 11.66 0 12.32 0 13 C2.64 13 5.28 13 8 13 C7.12355567 10.82271927 7.12355567 10.82271927 5 9 C0.32120751 8.25069618 -3.02499663 8.34999775 -7 11 C-7 8 -7 8 -5.46875 6.39453125 C-4.8190625 5.87246094 -4.169375 5.35039063 -3.5 4.8125 C-2.8503125 4.28269531 -2.200625 3.75289063 -1.53125 3.20703125 C-1.0259375 2.80871094 -0.520625 2.41039062 0 2 C0 3.32 0 4.64 0 6 C1.918125 5.938125 1.918125 5.938125 3.875 5.875 C6.0546875 5.8046875 6.0546875 5.8046875 8 6 C10 8 10 8 10.125 10.625 C10.08375 11.40875 10.0425 12.1925 10 13 C11.65 13 13.3 13 15 13 C13.53713212 6.51171219 13.53713212 6.51171219 9 2 C6.00593079 1.38980973 3.04998138 1.13034108 0 1 C0 0.67 0 0.34 0 0 Z " fill="#F3F6FC" transform="translate(16,0)"/>
<path d="M0 0 C0.7734375 -0.00773438 1.546875 -0.01546875 2.34375 -0.0234375 C4.98245826 0.4641499 5.89157252 1.26136133 7.5 3.375 C7.5 4.035 7.5 4.695 7.5 5.375 C4.86 5.375 2.22 5.375 -0.5 5.375 C-0.5 4.715 -0.5 4.055 -0.5 3.375 C-1.49 3.87 -1.49 3.87 -2.5 4.375 C-2.96751788 6.73099235 -2.96751788 6.73099235 -3.125 9.4375 C-3.19976563 10.35660156 -3.27453125 11.27570312 -3.3515625 12.22265625 C-3.40054687 12.93292969 -3.44953125 13.64320312 -3.5 14.375 C-2.84 14.375 -2.18 14.375 -1.5 14.375 C-1.17 13.385 -0.84 12.395 -0.5 11.375 C2.14 11.375 4.78 11.375 7.5 11.375 C5.625 15.25 5.625 15.25 4.5 16.375 C2.5390625 16.578125 2.5390625 16.578125 0.125 16.625 C-0.66648437 16.65078125 -1.45796875 16.6765625 -2.2734375 16.703125 C-4.5 16.375 -4.5 16.375 -6.265625 15.171875 C-7.89392995 12.80155767 -7.90051705 11.23290924 -7.875 8.375 C-7.88273438 7.50875 -7.89046875 6.6425 -7.8984375 5.75 C-7.5 3.375 -7.5 3.375 -6.2578125 1.5859375 C-4.03658417 0.05575798 -2.68137219 -0.02681372 0 0 Z " fill="#243FC5" transform="translate(16.5,7.625)"/>
<path d="M0 0 C4.29 0 8.58 0 13 0 C13 0.33 13 0.66 13 1 C11.63875 1.37125 11.63875 1.37125 10.25 1.75 C5.85279848 3.42719983 5.85279848 3.42719983 3 7 C2.66982425 10.69051287 2.77068363 14.30545853 3 18 C2.34 18 1.68 18 1 18 C2.27040826 24.22500049 4.65519565 27.37183885 9 32 C6.03 32 3.06 32 0 32 C0 21.44 0 10.88 0 0 Z " fill="#FAFCFD" transform="translate(0,0)"/>
<path d="M0 0 C6.82771306 -0.70631514 6.82771306 -0.70631514 9.8671875 1.125 C12.83250747 3.8491217 13.92030982 5.28278834 14.375 9.375 C14.25125 10.24125 14.1275 11.1075 14 12 C12.35 12 10.7 12 9 12 C8.814375 11.05125 8.62875 10.1025 8.4375 9.125 C7.26599195 5.61628301 7.26599195 5.61628301 3.375 4.625 C2.26125 4.41875 1.1475 4.2125 0 4 C0 2.68 0 1.36 0 0 Z " fill="#219DF4" transform="translate(17,1)"/>
<path d="M0 0 C-0.97866338 1.14626322 -1.95794587 2.29199788 -2.9375 3.4375 C-3.48277344 4.07558594 -4.02804688 4.71367188 -4.58984375 5.37109375 C-5.36795695 6.26991149 -6.15936528 7.15936528 -7 8 C-7.36760731 10.32817964 -7.70241581 12.6618385 -8 15 C-9.32 15 -10.64 15 -12 15 C-12.89533679 7.5388601 -12.89533679 7.5388601 -10.875 4.06640625 C-7.51165489 0.41402367 -5.09930851 -0.72847264 0 0 Z " fill="#1448D6" transform="translate(14,1)"/>
<path d="M0 0 C1.32 0 2.64 0 4 0 C4.46703469 2.64652992 4.39884037 4.36924133 2.9453125 6.66796875 C0.76718269 9.14632727 -0.85756827 10.80140524 -4.1796875 11.37890625 C-6.375 11.375 -6.375 11.375 -10 11 C-10 9.68 -10 8.36 -10 7 C-8.906875 6.773125 -7.81375 6.54625 -6.6875 6.3125 C-2.4291168 4.79680428 -2.08798785 3.77826372 0 0 Z " fill="#0E49D9" transform="translate(27,20)"/>
<path d="M0 0 C4.52615993 3.67832017 8.39424999 7.41086363 12 12 C9.00710226 12.42755682 6.86261474 12.55953389 4.265625 10.875 C1.27173282 8.13772715 0.08435644 6.75920797 -0.375 2.625 C-0.25125 1.75875 -0.1275 0.8925 0 0 Z " fill="#0B89F0" transform="translate(2,19)"/>
<path d="M0 0 C1.43655854 -0.08131463 2.8744483 -0.13933559 4.3125 -0.1875 C5.51326172 -0.23970703 5.51326172 -0.23970703 6.73828125 -0.29296875 C9 0 9 0 10.79296875 1.38671875 C12 3 12 3 12 5 C9.36 5 6.72 5 4 5 C4 4.34 4 3.68 4 3 C2.68 3.33 1.36 3.66 0 4 C0 2.68 0 1.36 0 0 Z " fill="#3852CA" transform="translate(12,8)"/>
<path d="M0 0 C5.28 0 10.56 0 16 0 C16 3.96 16 7.92 16 12 C15.67 12 15.34 12 15 12 C14.7525 11.0925 14.505 10.185 14.25 9.25 C12.88118077 5.69107 12.12750908 4.16519859 9 2 C6.00675528 1.38480808 3.05004295 1.13034372 0 1 C0 0.67 0 0.34 0 0 Z " fill="#FEFEFE" transform="translate(16,0)"/>
<path d="M0 0 C0.33 0 0.66 0 1 0 C1 3.96 1 7.92 1 12 C-1.64 12 -4.28 12 -7 12 C-6.57847656 11.55269531 -6.15695313 11.10539062 -5.72265625 10.64453125 C-1.4622021 6.01600234 -1.4622021 6.01600234 0 0 Z " fill="#FDFEFE" transform="translate(31,20)"/>
<path d="M0 0 C0.33 0 0.66 0 1 0 C1 4.62 1 9.24 1 14 C0.01 13.34 -0.98 12.68 -2 12 C-2.36328125 9.6875 -2.36328125 9.6875 -2.3125 7 C-2.30863281 6.113125 -2.30476562 5.22625 -2.30078125 4.3125 C-2 2 -2 2 0 0 Z " fill="#1939C2" transform="translate(11,9)"/>
<path d="M0 0 C1.98 0.99 1.98 0.99 4 2 C4 4.31 4 6.62 4 9 C2.68 9 1.36 9 0 9 C0 6.03 0 3.06 0 0 Z " fill="#059CF3" transform="translate(2,7)"/>
<path d="M0 0 C1.98 0 3.96 0 6 0 C6.66 1.32 7.32 2.64 8 4 C5.36 4 2.72 4 0 4 C0 2.68 0 1.36 0 0 Z " fill="#0773EE" transform="translate(17,27)"/>
<path d="M0 0 C2.64 0 5.28 0 8 0 C7.34 1.32 6.68 2.64 6 4 C4.02 4 2.04 4 0 4 C0 2.68 0 1.36 0 0 Z " fill="#0CB7F7" transform="translate(17,1)"/>
<path d="M0 0 C2.64 0 5.28 0 8 0 C7.34 1.32 6.68 2.64 6 4 C3.69 3.67 1.38 3.34 -1 3 C-0.67 2.01 -0.34 1.02 0 0 Z " fill="#2F4AC8" transform="translate(16,19)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.01 2.32 1.02 3.64 0 5 C-1.32 4.67 -2.64 4.34 -4 4 C-2.68 2.68 -1.36 1.36 0 0 Z " fill="#053FD7" transform="translate(7,4)"/>
</svg>

After

Width:  |  Height:  |  Size: 7.6 KiB

+21 -5
View File
@@ -5,6 +5,8 @@ import type {
OpenClawAgentsDefaults,
OpenClawEnvConfig,
OpenClawToolsConfig,
OpenClawHealthWarning,
OpenClawWriteOutcome,
} from "@/types";
/**
@@ -30,7 +32,9 @@ export const openclawApi = {
/**
* Set default model configuration (agents.defaults.model)
*/
async setDefaultModel(model: OpenClawDefaultModel): Promise<void> {
async setDefaultModel(
model: OpenClawDefaultModel,
): Promise<OpenClawWriteOutcome> {
return await invoke("set_openclaw_default_model", { model });
},
@@ -49,7 +53,7 @@ export const openclawApi = {
*/
async setModelCatalog(
catalog: Record<string, OpenClawModelCatalogEntry>,
): Promise<void> {
): Promise<OpenClawWriteOutcome> {
return await invoke("set_openclaw_model_catalog", { catalog });
},
@@ -63,7 +67,9 @@ export const openclawApi = {
/**
* Set full agents.defaults config (all fields)
*/
async setAgentsDefaults(defaults: OpenClawAgentsDefaults): Promise<void> {
async setAgentsDefaults(
defaults: OpenClawAgentsDefaults,
): Promise<OpenClawWriteOutcome> {
return await invoke("set_openclaw_agents_defaults", { defaults });
},
@@ -81,7 +87,7 @@ export const openclawApi = {
/**
* Set env config (env section of openclaw.json)
*/
async setEnv(env: OpenClawEnvConfig): Promise<void> {
async setEnv(env: OpenClawEnvConfig): Promise<OpenClawWriteOutcome> {
return await invoke("set_openclaw_env", { env });
},
@@ -99,7 +105,17 @@ export const openclawApi = {
/**
* Set tools config (tools section of openclaw.json)
*/
async setTools(tools: OpenClawToolsConfig): Promise<void> {
async setTools(tools: OpenClawToolsConfig): Promise<OpenClawWriteOutcome> {
return await invoke("set_openclaw_tools", { tools });
},
async scanHealth(): Promise<OpenClawHealthWarning[]> {
return await invoke("scan_openclaw_config_health");
},
async getLiveProvider(
providerId: string,
): Promise<Record<string, unknown> | null> {
return await invoke("get_openclaw_live_provider", { providerId });
},
};
+15
View File
@@ -1,6 +1,12 @@
import { invoke } from "@tauri-apps/api/core";
import type { SessionMessage, SessionMeta } from "@/types";
export interface DeleteSessionOptions {
providerId: string;
sessionId: string;
sourcePath: string;
}
export const sessionsApi = {
async list(): Promise<SessionMeta[]> {
return await invoke("list_sessions");
@@ -13,6 +19,15 @@ export const sessionsApi = {
return await invoke("get_session_messages", { providerId, sourcePath });
},
async delete(options: DeleteSessionOptions): Promise<boolean> {
const { providerId, sessionId, sourcePath } = options;
return await invoke("delete_session", {
providerId,
sessionId,
sourcePath,
});
},
async launchTerminal(options: {
command: string;
cwd?: string | null;
+1 -1
View File
@@ -102,7 +102,7 @@ export const settingsApi = {
return await invoke("import_config_from_file", { filePath });
},
// ─── WebDAV v2 sync ───────────────────────────────────────
// ─── WebDAV sync ──────────────────────────────────────────
async webdavTestConnection(
settings: WebDavSyncSettings,
+67 -2
View File
@@ -1,9 +1,10 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { providersApi, settingsApi, type AppId } from "@/lib/api";
import { providersApi, sessionsApi, settingsApi, type AppId } from "@/lib/api";
import type { DeleteSessionOptions } from "@/lib/api/sessions";
import type { SwitchResult } from "@/lib/api/providers";
import type { Provider, Settings } from "@/types";
import type { Provider, SessionMeta, Settings } from "@/types";
import { extractErrorMessage } from "@/utils/errorUtils";
import { generateUUID } from "@/utils/uuid";
import { openclawKeys } from "@/hooks/useOpenClaw";
@@ -65,6 +66,12 @@ export const useAddProviderMutation = (appId: AppId) => {
});
}
if (appId === "openclaw") {
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
}
try {
await providersApi.updateTrayMenu();
} catch (trayError) {
@@ -106,6 +113,11 @@ export const useUpdateProviderMutation = (appId: AppId) => {
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["providers", appId] });
if (appId === "openclaw") {
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
}
toast.success(
t("notifications.updateSuccess", {
defaultValue: "供应商更新成功",
@@ -153,6 +165,12 @@ export const useDeleteProviderMutation = (appId: AppId) => {
});
}
if (appId === "openclaw") {
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
}
try {
await providersApi.updateTrayMenu();
} catch (trayError) {
@@ -213,6 +231,9 @@ export const useSwitchProviderMutation = (appId: AppId) => {
await queryClient.invalidateQueries({
queryKey: openclawKeys.defaultModel,
});
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
}
try {
@@ -247,6 +268,50 @@ export const useSwitchProviderMutation = (appId: AppId) => {
});
};
export const useDeleteSessionMutation = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (input: DeleteSessionOptions) => {
await sessionsApi.delete(input);
return input;
},
onSuccess: async (input) => {
queryClient.setQueryData<SessionMeta[]>(["sessions"], (current) =>
(current ?? []).filter(
(session) =>
!(
session.providerId === input.providerId &&
session.sessionId === input.sessionId &&
session.sourcePath === input.sourcePath
),
),
);
queryClient.removeQueries({
queryKey: ["sessionMessages", input.providerId, input.sourcePath],
});
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
toast.success(
t("sessionManager.sessionDeleted", {
defaultValue: "会话已删除",
}),
);
},
onError: (error: Error) => {
const detail = extractErrorMessage(error) || t("common.unknown");
toast.error(
t("sessionManager.deleteFailed", {
defaultValue: "删除会话失败: {{error}}",
error: detail,
}),
);
},
});
};
export const useSaveSettingsMutation = () => {
const queryClient = useQueryClient();
+41 -11
View File
@@ -126,6 +126,8 @@ export interface ProviderProxyConfig {
export interface ProviderMeta {
// 自定义端点:以 URL 为键,值为端点信息
custom_endpoints?: Record<string, CustomEndpoint>;
// 是否在切换/同步到 live 时应用通用配置片段
commonConfigEnabled?: boolean;
// 用量查询脚本配置
usage_script?: UsageScript;
// 请求地址管理:测速后自动选择最佳端点
@@ -145,13 +147,14 @@ export interface ProviderMeta {
// Claude API 格式(仅 Claude 供应商使用)
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
apiFormat?: "anthropic" | "openai_chat";
// Claude 认证字段名(仅 Claude 供应商使用)
// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商
// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
apiKeyField?: "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
// - "openai_responses": OpenAI Responses API 格式,需要格式转换
apiFormat?: "anthropic" | "openai_chat" | "openai_responses";
// Claude 认证字段名
apiKeyField?: ClaudeApiKeyField;
// 是否将 base_url 视为完整 API 端点(代理直接使用此 URL,不拼接路径)
isFullUrl?: boolean;
// Prompt cache key for OpenAI-compatible endpoints (improves cache hit rate)
promptCacheKey?: string;
}
// Skill 同步方式
@@ -160,11 +163,10 @@ export type SkillSyncMethod = "auto" | "symlink" | "copy";
// Claude API 格式类型
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
export type ClaudeApiFormat = "anthropic" | "openai_chat";
// - "openai_responses": OpenAI Responses API 格式,需要格式转换
export type ClaudeApiFormat = "anthropic" | "openai_chat" | "openai_responses";
// Claude 认证字段类型
// - "ANTHROPIC_AUTH_TOKEN": 大多数第三方/聚合供应商使用(默认)
// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
// 主页面显示的应用配置
@@ -176,7 +178,7 @@ export interface VisibleApps {
openclaw: boolean;
}
// WebDAV v2 同步状态
// WebDAV 同步状态
export interface WebDavSyncStatus {
lastSyncAt?: number | null;
lastError?: string | null;
@@ -186,7 +188,7 @@ export interface WebDavSyncStatus {
lastRemoteManifestHash?: string | null;
}
// WebDAV v2 同步配置
// WebDAV 同步配置
export interface WebDavSyncSettings {
enabled?: boolean;
autoSync?: boolean;
@@ -198,14 +200,20 @@ export interface WebDavSyncSettings {
status?: WebDavSyncStatus;
}
export type RemoteSnapshotLayout = "current" | "legacy";
// 远端快照信息(下载前预览)
export interface RemoteSnapshotInfo {
deviceName: string;
createdAt: string;
snapshotId: string;
version: number;
protocolVersion: number;
dbCompatVersion?: number | null;
compatible: boolean;
artifacts: string[];
layout: RemoteSnapshotLayout;
remotePath: string;
}
// 应用设置类型(用于设置对话框与 Tauri API)
@@ -232,6 +240,12 @@ export interface Settings {
usageConfirmed?: boolean;
// User has confirmed the stream check first-run notice
streamCheckConfirmed?: boolean;
// Whether to show the failover toggle independently on the main page
enableFailoverToggle?: boolean;
// User has confirmed the failover toggle first-run notice
failoverConfirmed?: boolean;
// User has confirmed the auto-sync traffic warning
autoSyncConfirmed?: boolean;
// 首选语言(可选,默认中文)
language?: "en" | "zh" | "ja";
@@ -490,6 +504,19 @@ export interface OpenClawModelCatalogEntry {
alias?: string;
}
export interface OpenClawHealthWarning {
code: string;
message: string;
path?: string;
}
export interface OpenClawWriteOutcome {
backupPath?: string;
warnings: OpenClawHealthWarning[];
}
export type OpenClawToolsProfile = "minimal" | "coding" | "messaging" | "full";
// OpenClaw 供应商配置(settings_config 结构)
// 对应 OpenClaw 的 models.providers.<provider-id> 配置
export interface OpenClawProviderConfig {
@@ -497,12 +524,15 @@ export interface OpenClawProviderConfig {
apiKey?: string; // API 密钥
api?: string; // API 协议类型(如 "openai-completions"、"anthropic"
models?: OpenClawModel[]; // 可用模型列表
headers?: Record<string, string>; // 自定义请求头(如 User-Agent
}
// OpenClaw agents.defaults 完整配置
export interface OpenClawAgentsDefaults {
model?: OpenClawDefaultModel;
models?: Record<string, OpenClawModelCatalogEntry>;
timeoutSeconds?: number;
timeout?: number;
[key: string]: unknown; // preserve unknown fields
}
@@ -513,7 +543,7 @@ export interface OpenClawEnvConfig {
// OpenClaw tools 配置(openclaw.json 的 tools 节点)
export interface OpenClawToolsConfig {
profile?: string;
profile?: OpenClawToolsProfile | string;
allow?: string[];
deny?: string[];
[key: string]: unknown; // preserve unknown fields
+7 -7
View File
@@ -37,7 +37,7 @@ export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
display: "Hephaestus",
descKey: "omo.agentDesc.hephaestus",
tooltipKey: "omo.agentTooltip.hephaestus",
recommended: "gpt-5.3-codex",
recommended: "gpt-5.4",
group: "main",
},
{
@@ -61,7 +61,7 @@ export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
display: "Oracle",
descKey: "omo.agentDesc.oracle",
tooltipKey: "omo.agentTooltip.oracle",
recommended: "gpt-5.2",
recommended: "gpt-5.4",
group: "sub",
},
{
@@ -101,7 +101,7 @@ export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
display: "Momus",
descKey: "omo.agentDesc.momus",
tooltipKey: "omo.agentTooltip.momus",
recommended: "gpt-5.2",
recommended: "gpt-5.4",
group: "sub",
},
{
@@ -126,14 +126,14 @@ export const OMO_BUILTIN_CATEGORIES: OmoCategoryDef[] = [
display: "Ultrabrain",
descKey: "omo.categoryDesc.ultrabrain",
tooltipKey: "omo.categoryTooltip.ultrabrain",
recommended: "gpt-5.3-codex",
recommended: "gpt-5.4",
},
{
key: "deep",
display: "Deep",
descKey: "omo.categoryDesc.deep",
tooltipKey: "omo.categoryTooltip.deep",
recommended: "gpt-5.3-codex",
recommended: "gpt-5.4",
},
{
key: "artistry",
@@ -327,7 +327,7 @@ export const OMO_SLIM_BUILTIN_AGENTS: OmoAgentDef[] = [
display: "Oracle",
descKey: "omo.slimAgentDesc.oracle",
tooltipKey: "omo.slimAgentTooltip.oracle",
recommended: "gpt-5.2",
recommended: "gpt-5.4",
group: "sub",
},
{
@@ -359,7 +359,7 @@ export const OMO_SLIM_BUILTIN_AGENTS: OmoAgentDef[] = [
display: "Fixer",
descKey: "omo.slimAgentDesc.fixer",
tooltipKey: "omo.slimAgentTooltip.fixer",
recommended: "gpt-5.3-codex",
recommended: "gpt-5.4",
group: "sub",
},
];

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