Compare commits

...

25 Commits

Author SHA1 Message Date
Jason 52f0ff3961 fix(test): use preferred_filename after OMO field rename
The merge from main brought in #1746 which renamed
OmoVariant.filename → preferred_filename, but the test helper
omo_config_path() was not updated, breaking compilation of all
new provider tests.
2026-04-01 21:12:17 +08:00
YoVinchen 544e9bf937 fix(provider): fix additive provider delete/switch regressions and redundancy
- fix(delete): replace stale live_config_managed flag check with
  check_live_config_exists so providers written to live before the
  flag-flip logic was introduced are still cleaned up on delete
- fix(switch): make write_live_with_common_config return Err instead of
  silently returning Ok when config structure is invalid, preventing
  live_config_managed from being incorrectly flipped to true
- fix(update): block provider key rename for OMO/OMO Slim categories to
  prevent orphaned current-state markers breaking OMO file syncs
- fix(switch): flip live_config_managed to true after successful live
  write for DB-only additive providers so sync_all_providers_to_live
  includes them on future syncs; roll back live write if DB update fails
- refactor(delete): merge symmetric OMO/OMO-Slim blocks into single
  match-on-variant path; hoist DB read to top of additive branch
- refactor(remove_from_live_config): merge OMO/OMO-Slim if/else-if
  into single match-on-variant path
- refactor(switch_normal): merge two OMO/OMO-Slim if blocks into one
  OpenCode guard with (enable, disable) variant pair
- fix(update): remove redundant duplicate return Ok(true) after OMO
  current-state write
2026-04-01 14:43:50 +08:00
YoVinchen 48fb00d81b Merge branch 'refs/heads/main' into fix/openclaw-serialize-panic 2026-04-01 09:03:34 +08:00
Jason 854266e3c4 docs: add ChefShop AI sponsor to all README versions
Add ChefShop AI as a new sponsor with logo and descriptions in Chinese, English, and Japanese README files.
2026-03-31 23:07:51 +08:00
Jason af8f907467 fix(proxy): serialize per-app provider switches to prevent state corruption
Concurrent failover switches for the same app could cause is_current,
local settings, and Live backup to point at different providers.

- Add SwitchLockManager with per-app mutexes (different apps still parallel)
- Unify scattered switch logic into ProxyService::hot_switch_provider
- Fix TOCTOU in set_current_provider via mutate_settings
- Add logical_target_changed to skip redundant UI refreshes
- Add tests for serialization and restore-waits-for-switch scenarios
2026-03-31 22:46:42 +08:00
Dex Miller 6a083cdd1c fix(omo): adapt to oh-my-openagent rename with backward compatibility (#1746)
* fix(omo): adapt to oh-my-openagent rename with backward compatibility

Closes https://github.com/farion1231/cc-switch/issues/1733

* fix(omo): prioritize oh-my-openagent config over legacy oh-my-opencode
2026-03-31 16:32:49 +08:00
Dex Miller c68476d0ea fix(provider): show persistent config highlight for additive-mode providers (#1747)
* fix(provider): show persistent config highlight for additive-mode providers

Closes https://github.com/farion1231/cc-switch/issues/1692

* fix(provider): limit persistent config highlight to opencode only
2026-03-31 16:21:04 +08:00
Dex Miller 96a0a37331 feat(terminal): add directory picker before launching Claude terminal (#1752)
* Add directory picker before launching Claude terminal

* fix(terminal): preserve cwd path and strip Windows verbatim prefix

- Stop trimming non-empty paths so directories with leading/trailing
  spaces on Unix are handled correctly
- Strip \\?\ extended-length prefix from canonicalized paths on Windows
  to prevent batch script cd failures

* fix(terminal): restore UNC paths when stripping Windows verbatim prefix

Handle \\?\UNC\server\share form separately from regular \\?\ prefix,
converting it back to \\server\share so network/WSL directory paths
remain valid in batch cd commands.

* fix(terminal): use pushd for UNC paths in Windows batch launcher

`cmd.exe` cannot set a UNC path (e.g. `\\wsl$\...`) as the current
directory via `cd /d`; it errors with "CMD does not support UNC paths
as current directories". Switch to `pushd` which temporarily maps the
UNC share to a drive letter.

Rename `build_windows_cd_command` → `build_windows_cwd_command` to
reflect the broader semantics. Extract `build_windows_cwd_command_str`
and `is_windows_unc_path` helpers for testability, and add unit tests
covering drive paths, UNC paths, and batch metacharacter escaping.

Also fix minor style issues: sort mod declarations alphabetically,
add missing EOF newline in lightweight.rs, add explicit type annotation
in streaming_responses test, and reformat tray menu builder chain.
2026-03-31 15:10:08 +08:00
YoVinchen fb70f6d4b7 refactor(provider): unify OMO variant updates with atomic file-then-db writes and rollback
Consolidate the duplicated omo/omo-slim update branches into a single
match on the variant. Write the OMO config file from the in-memory
provider state *before* persisting to the database, so a file-write or
plugin-sync failure leaves the database unchanged. If `add_plugin`
fails after the config file is already written, roll back to the
previous on-disk contents via snapshot/restore.

Also:
- `sync_all_providers_to_live` now skips db-only providers
  (`live_config_managed == Some(false)`) instead of attempting to write
  them to live config.
- `import_{opencode,openclaw}_providers_from_live` mark imported
  providers as `live_config_managed: Some(true)` so they are correctly
  recognized during subsequent syncs.
- Extract OmoService helpers: `profile_data_from_provider`,
  `snapshot_config_file`, `restore_config_file`, `write_profile_config`,
  and the new public `write_provider_config_to_file`.
- Add 9 new tests covering sync skip, legacy restore, import marking,
  OMO persistence, file-write failure, and plugin-sync rollback.
2026-03-31 10:13:00 +08:00
YoVinchen fda88b2e42 fix(provider): distinguish legacy providers from db-only when tolerating live config errors
Change `ProviderMeta.live_config_managed` from `bool` to `Option<bool>`
to introduce a three-state semantic:
- `Some(true)`: provider has been written to live config
- `Some(false)`: explicitly db-only, never written to live config
- `None`: legacy data or unknown state (pre-existing providers)

Previously, legacy providers defaulted to `live_config_managed = false`
via `#[serde(default)]`, which silently swallowed live config parse
errors. This could mask genuine configuration issues for providers that
had actually been synced to live config before the field was introduced.

Now, only providers with an explicit `Some(false)` marker tolerate parse
errors; legacy `None` providers surface errors as before, preserving
safety for already-managed configurations.

Also wrap the `ensureQueryData` call for live provider IDs during
duplication in a try/catch so that a malformed config file shows a
user-facing toast instead of silently failing.

Add tests for both the legacy error propagation path and the frontend
duplication failure scenario.
2026-03-30 22:38:32 +08:00
Alexlangl 8e2ffbc845 feat: add bulk delete for session manager (#1693)
* feat: add bulk delete for session manager

* fix: address batch delete review issues

* fix: keep session list in sync after batch delete
2026-03-30 22:15:57 +08:00
Alexlangl 4f7ea76347 fix: preserve WebDAV password display and validate MKCOL 405 (#1685)
* fix: preserve WebDAV password display and validate MKCOL 405

* fix: scope WebDAV password preservation to post-save refresh
2026-03-30 22:13:21 +08:00
YoVinchen 98c8255dbb refactor(provider): simplify live_config_managed and deduplicate tolerant live config checks
- Change live_config_managed from Option<bool> to bool with #[serde(default)]
- Extract repeated tolerant live config query into check_live_config_exists helper
- Fix duplicate key generation to also check live-only provider IDs
- Fix updateProvider test to match new { provider, originalId } call signature
- Add streaming_responses test type annotation for compiler inference
2026-03-30 16:23:50 +08:00
YoVinchen dbb943852d style: fix clippy warnings in live.rs and tray.rs 2026-03-29 22:01:07 +08:00
YoVinchen cc070327ff Merge branch 'main' into fix/openclaw-serialize-panic
# Conflicts:
#	src-tauri/src/proxy/providers/claude.rs
#	src-tauri/src/services/stream_check.rs
2026-03-29 21:15:54 +08:00
Dex Miller 67e074c0a7 refactor(proxy): transparent header forwarding via hyper client (#1714)
* style(frontend): reformat provider forms, constants and hooks

Apply prettier formatting across 5 frontend files. No logic changes.

Changed files:
- AddProviderDialog.tsx: reformat generic type annotation and callback
- ClaudeFormFields.tsx: consolidate multi-line useState and Collapsible props
- CodexConfigSections.tsx: expand single-line React imports to multi-line,
  collapse removeCodexTopLevelField() call
- constants.ts: merge TemplateType into single line
- useSkills.ts: expand single-line TanStack Query imports to multi-line,
  reformat uninstallSkill mutationFn chain

* deps(proxy): add hyper ecosystem crates and manual decompression libs

reqwest internally normalizes all header names to lowercase and does not
preserve insertion order, causing proxied requests to differ from the
original client requests. To achieve transparent header forwarding with
original casing and order, introduce lower-level hyper HTTP client libs.

New dependencies:
- hyper-util 0.1: TokioExecutor + legacy Client with
  preserve_header_case support for HTTP/1.1
- hyper-rustls 0.27: rustls-based TLS connector for hyper
- http 1 / http-body 1 / http-body-util 0.1: HTTP type crates for
  hyper 1.x request/response construction
- flate2 1: manual gzip/deflate decompression (replaces reqwest auto)
- brotli 7: manual brotli decompression

Changed dependencies:
- serde_json: enable preserve_order feature to keep JSON field order
- reqwest: drop gzip feature to prevent reqwest from overriding the
  client's original accept-encoding header

* refactor(proxy): use hyper client for header-case preserving forwarding

Previously the proxy used reqwest for all upstream requests. reqwest
normalizes header names to lowercase and reorders them internally,
making proxied requests distinguishable from direct CLI requests.
Some upstream providers are sensitive to these differences.

This commit replaces reqwest with a hyper-based HTTP client on the
default (non-proxy) path, achieving wire-level header fidelity:

Server layer (server.rs):
- Replace axum::serve with a manual hyper HTTP/1.1 accept loop
- Enable preserve_header_case(true) so incoming header casing is
  captured in a HeaderCaseMap extension on each request
- Bridge hyper requests to axum Router via tower::Service

New hyper client module (hyper_client.rs):
- Lazy-initialized hyper-util Client with preserve_header_case
- ProxyResponse enum wrapping both hyper::Response and reqwest::Response
  behind a unified interface (status, headers, bytes, bytes_stream)
- send_request() builds requests with ordered HeaderMap + case map

Request handlers (handlers.rs):
- Switch from (HeaderMap, Json<Value>) extractors to raw
  axum::extract::Request to preserve Extensions (containing the
  HeaderCaseMap from the accept loop)
- Pass extensions through the forwarding chain

Forwarder (forwarder.rs):
- Remove HEADER_BLACKLIST array; replace with ordered header iteration
  that preserves original header sequence and casing
- Build ordered_headers by iterating client headers, skipping only
  auth/host/content-length, and inserting auth headers at the original
  authorization position to maintain order
- Handle anthropic-beta (ensure claude-code-20250219 tag) and
  anthropic-version (passthrough or default) inline during iteration
- Remove should_force_identity_encoding() — accept-encoding is now
  transparently forwarded to upstream
- Use hyper client by default; fall back to reqwest only when an
  HTTP/SOCKS5 proxy tunnel is configured

Provider adapters (adapter.rs, claude.rs, codex.rs, gemini.rs):
- Replace add_auth_headers(RequestBuilder) -> RequestBuilder with
  get_auth_headers(AuthInfo) -> Vec<(HeaderName, HeaderValue)>
- Adapters now return header pairs instead of mutating a reqwest builder
- Claude adapter: merge Anthropic/ClaudeAuth/Bearer into single branch;
  move Copilot fingerprint headers into get_auth_headers

Response processing (response_processor.rs):
- Add manual decompression (gzip/deflate/brotli via flate2 + brotli)
  for non-streaming responses, since reqwest auto-decompression is now
  disabled to allow accept-encoding passthrough
- Add compressed-SSE warning log for streaming responses
- Accept ProxyResponse instead of reqwest::Response

HTTP client (http_client.rs):
- Disable reqwest auto-decompression (.no_gzip/.no_brotli/.no_deflate)
  on both global and per-provider clients

Streaming adapters (streaming.rs, streaming_responses.rs):
- Generalize stream error type from reqwest::Error to generic E: Error

Misc:
- log_codes.rs: add SRV-005 (ACCEPT_ERR) and SRV-006 (CONN_ERR)
- stream_check.rs: reformat copilot header lines
- transform.rs: fix trailing whitespace alignment

* fix(lint): resolve 35 clippy warnings across Rust codebase

Fix all clippy warnings reported by `cargo clippy --lib`:

- codex_config.rs: fix doc_overindented_list_items (3 spaces -> 2)
- commands/copilot.rs: inline format args in 2 log::error! calls
- commands/provider.rs: inline format args in 3 map_err closures
- proxy/hyper_client.rs: inline format arg in log::debug! call
- proxy/providers/copilot_auth.rs: inline format args in 16 locations
  (log macros, format! in headers, error constructors)
- proxy/thinking_optimizer.rs: inline format args in 2 log::info! calls
- services/skill.rs: inline format args in log::debug! call
- services/webdav_sync.rs: inline format args in 6 format! calls
  (version compat messages, download limit messages)
- services/webdav_sync/archive.rs: inline format args in 2 format! calls
- session_manager/providers/opencode.rs: inline format args in
  source_path format!

All fixes use the clippy::uninlined_format_args suggestion pattern:
  format!("msg: {}", var)  ->  format!("msg: {var}")

* deps(proxy): add raw HTTP write and native TLS cert dependencies

Add crates required for the raw TCP/TLS write path that bypasses
hyper's header encoder to preserve original header name casing:

- httparse: parse raw TCP peek bytes to capture header casings
- tokio-rustls + rustls: direct TLS connections for raw write path
- webpki-roots: Mozilla CA bundle baseline
- rustls-native-certs: load system keychain CAs (trusts proxy MITM
  certificates from Clash, mitmproxy, etc.)

* fix(proxy): address code review feedback on response handling

Fixes from PR #1714 code review:

- Extract `read_decoded_body()` and `strip_entity_headers_for_rebuilt_body()`
  in response_processor to properly clean content-encoding/content-length
  headers after decompression
- Reuse `read_decoded_body()` in handlers.rs for Claude transform path,
  ensuring compressed responses are decoded before format conversion
- Make `build_proxy_url_from_config()` public so forwarder can pass proxy
  URL to the hyper raw write path
- Add `has_system_proxy_env()` utility with test coverage
- Add 50ms backoff after accept() failures in server.rs to prevent
  tight-loop CPU spin on transient socket errors

* feat(proxy): implement raw TCP/TLS write with HTTP CONNECT tunnel

Rewrite hyper_client with a two-tier strategy for header case preservation:

Primary path (raw write):
- Peek raw TCP bytes in server.rs to capture OriginalHeaderCases before
  hyper lowercases them
- Build raw HTTP/1.1 request bytes with exact original header name casing
- Write directly to TLS stream, then use WriteFilter to let hyper parse
  the response while discarding its duplicate request writes
- Support HTTP CONNECT tunneling through upstream proxies, so header case
  is preserved even when a proxy (Clash, V2Ray) is configured

Fallback path (hyper-util Client):
- Used when OriginalHeaderCases is empty or raw write fails
- Configured with title_case_headers(true) for best-effort casing

TLS improvements:
- Load native system certificates alongside webpki roots so proxy MITM
  CAs (installed in system keychain) are trusted through CONNECT tunnels

Key types added:
- OriginalHeaderCases: maps lowercase name → original wire-casing bytes
- WriteFilter<S>: AsyncRead+AsyncWrite wrapper that discards writes
- connect_via_proxy(): HTTP CONNECT tunnel establishment
- ExtensionDebugMarker: diagnostic marker for extension chain debugging

* refactor(proxy): route requests through hyper with proxy-aware forwarding

Rework forwarder request dispatch to always prefer the hyper raw write
path (header case preservation) over reqwest:

Request routing:
- HTTP/HTTPS proxy: hyper raw write through CONNECT tunnel (case preserved)
- SOCKS5 proxy: reqwest fallback (CONNECT not supported for SOCKS5)
- No proxy: hyper raw write direct connection

Header handling improvements:
- Replace host header in-place at original position instead of
  skip-and-append, preserving client's header ordering
- Preserve client's original accept-encoding for transparent passthrough;
  only force identity encoding when transform path needs decompression
- Add should_force_identity_encoding() to centralize the decision
- Remove hardcoded 'br, gzip, deflate' override that masked client values

Proxy URL resolution (priority order):
1. Provider-specific proxy config (if enabled)
2. Global proxy URL configured in CC Switch
3. Direct connection (no proxy)

* chore(proxy): remove dead code, redundant tests and debug scaffolding

- Inline should_force_identity_encoding() (was just `needs_transform`)
  and delete its 5 test cases
- Remove ExtensionDebugMarker diagnostic type
- Remove unused has_system_proxy_env() and its test
- Remove strip_entity_headers test
- Simplify hyper path: remove redundant is_socks_proxy ternary
- Update hyper_client module doc to reflect CONNECT tunnel support

* fix(proxy): block direct-connect fallback and complete CONNECT tunnel support

* feat(hooks): improve proxy requirement warnings with specific reasons

- Remove redundant OpenAI format hint toast messages
- Add detailed reason detection for proxy requirements (OpenAI Chat, OpenAI Responses, full URL mode)
- Update i18n files with new reason-specific keys

* style(*): format code with prettier

- Remove extra whitespace in http_client.rs
- Fix formatting issues in useProviderActions.ts

* fix(proxy): post-merge fixes for forward return type and clippy warnings

- Restore forward() return type to (ProxyResponse, Option<String>)
  to pass claude_api_format through to callers
- Inline format args in log::warn! macro (clippy::uninlined_format_args)
- Suppress too_many_arguments for check_claude_stream

* refactor(proxy): preserve original header wire order and add non-streaming body timeout

- Rewrite build_raw_request to emit headers in original
  client-sent sequence instead of hash-map order
- Remove unused OriginalHeaderCases::get_all method
- Add body_timeout to read_decoded_body to prevent
  requests hanging when upstream stalls after headers
2026-03-29 20:26:15 +08:00
ruokeqx b1c7fe5563 feat: add lightweight mode (#1739) 2026-03-29 18:35:23 +08:00
makoMakoGo 210bff96c2 fix(env): detect bun global bin paths in CLI scan (#1742) 2026-03-29 15:52:37 +08:00
makoMakoGo 3b33e6921b fix: correct opencode kimi-for-coding preset (#1738) 2026-03-29 10:31:23 +08:00
Zhou Mengze 8d5f72757e fix: 修复 Copilot 作为 Claude 时 OpenAI 模型的 Responses 分流 (#1735)
* fix: route copilot claude openai models to responses

* fix(i18n): add copilotProxyHint translation key for all locales

The copilotProxyHint message was using inline defaultValue with Chinese
text, which would show Chinese to English and Japanese users. Added
proper translation keys in zh/en/ja locale files and removed the
hardcoded defaultValue fallback.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-03-29 10:29:54 +08:00
Dex Miller 8cae7b7b73 feat(proxy): add full URL mode and refactor endpoint rewriting (#1561)
* feat(proxy): add full URL mode and refactor endpoint rewriting

- Add `isFullUrl` provider meta to treat base_url as complete API endpoint
- Remove hardcoded `?beta=true` from Claude adapter, pass through from client
- Refactor forwarder endpoint rewriting with proper query string handling
- Block provider switching when proxy is required but not running
- Add full URL toggle UI in endpoint field with i18n (zh/en/ja)

* refactor(proxy): remove beta query handling

* fix(proxy): strip beta query when rewriting Claude endpoints

* feat(codex): complete full URL support

* refactor(ui): refine full URL endpoint hint
2026-03-28 15:52:02 +08:00
YoVinchen d61c24255b style: apply rustfmt formatting to proxy and provider modules
Reformat chained .header() calls in ClaudeAdapter and StreamCheckService
for consistent alignment. Reorder imports alphabetically in stream_check.
Fix trailing whitespace in transform.rs and merge import lines in
provider/mod.rs.
2026-03-28 01:51:42 +08:00
YoVinchen b3bb020d3c fix(openclaw): replace json-five serializer to prevent panic on empty collections
json-five 0.3.1 panics when pretty-printing nested empty maps/arrays.
Switch value_to_rt_value() to serde_json::to_string_pretty() which
produces valid JSON5 output without the panic. Add regression test for
removing the last provider (empty providers map).
2026-03-28 01:51:42 +08:00
YoVinchen 31278e8916 test(provider): add integration tests for additive provider key flows
Cover openclaw provider duplication scenario to verify that a generated
provider key is assigned automatically. Add MSW handlers for
get_openclaw_live_provider_ids, get_openclaw_default_model,
scan_openclaw_config_health, and check_env_conflicts endpoints.
Update EditProviderDialog mock to pass originalId alongside provider.
2026-03-28 01:49:15 +08:00
YoVinchen e9ead2841d feat(provider): support additive provider key lifecycle management
Add `addToLive` parameter to add_provider so callers can opt out of
writing to the live config (e.g. when duplicating an inactive provider).
Add `originalId` parameter to update_provider to support provider key
renames — the old key is removed from live config before the new one
is written.

Frontend: ProviderForm now exposes provider-key input for openclaw app
type, and EditProviderDialog forwards originalId on save. Deep-link
import passes addToLive=true to preserve existing behavior.
2026-03-28 01:49:07 +08:00
87 changed files with 6074 additions and 1291 deletions
+5
View File
@@ -100,6 +100,11 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
<td>Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click <a href="https://ctok.ai">here</a> to register!</td> <td>Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click <a href="https://ctok.ai">here</a> to register!</td>
</tr> </tr>
<tr>
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
<td>Thanks to ChefShop AI for sponsoring this project! ChefShop AI is a premium account service provider tailored for heavy AI subscription users. The platform offers official top-up and stable account services for mainstream large models including ChatGPT Plus/Pro, Claude Max, Grok Super/Heavy, and Gemini. Click <a href="https://chefshop.ai">here</a> to purchase!</td>
</tr>
</table> </table>
</details> </details>
+5
View File
@@ -100,6 +100,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
<td>CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://ctok.ai">こちら</a>から登録してください!</td> <td>CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://ctok.ai">こちら</a>から登録してください!</td>
</tr> </tr>
<tr>
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
<td>ChefShop AI のご支援に感謝します!ChefShop AI は、AI ヘビーユーザー向けにカスタマイズされたプレミアムアカウントサービスプロバイダーです。ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy、Gemini など主流の大規模モデルの公式チャージと安定したアカウントサービスを提供しています。<a href="https://chefshop.ai">こちら</a>から購入してください!</td>
</tr>
</table> </table>
</details> </details>
+5
View File
@@ -101,6 +101,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
<td>感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://ctok.ai">这里</a>注册!</td> <td>感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://ctok.ai">这里</a>注册!</td>
</tr> </tr>
<tr>
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
<td>感谢 厨师长AI小铺 赞助了本项目!厨师长AI小铺 是一家专为 AI 重度订阅用户量身定制的优质账号服务商。平台提供涵盖 ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy 以及 Gemini 等主流大模型的官方代充与稳定成品账号服务。点击<a href="https://chefshop.ai">这里</a>购买!</td>
</tr>
</table> </table>
</details> </details>
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

+90 -39
View File
@@ -137,18 +137,6 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "async-compression"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "async-executor" name = "async-executor"
version = "1.14.0" version = "1.14.0"
@@ -324,6 +312,28 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e84ce723ab67259cfeb9877c6a639ee9eb7a27b28123abd71db7f0d5d0cc9d86"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a442ece363113bd4bd4c8b18977a7798dd4d3c3383f34fb61936960e8f4ad8"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.7.9" version = "0.7.9"
@@ -496,6 +506,17 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "brotli"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor 4.0.3",
]
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "8.0.2" version = "8.0.2"
@@ -504,7 +525,17 @@ checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
dependencies = [ dependencies = [
"alloc-no-stdlib", "alloc-no-stdlib",
"alloc-stdlib", "alloc-stdlib",
"brotli-decompressor", "brotli-decompressor 5.0.0",
]
[[package]]
name = "brotli-decompressor"
version = "4.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
] ]
[[package]] [[package]]
@@ -691,11 +722,19 @@ dependencies = [
"auto-launch", "auto-launch",
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"brotli 7.0.0",
"bytes", "bytes",
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",
"flate2",
"futures", "futures",
"http",
"http-body",
"http-body-util",
"httparse",
"hyper", "hyper",
"hyper-rustls",
"hyper-util",
"indexmap 2.13.0", "indexmap 2.13.0",
"json-five", "json-five",
"json5", "json5",
@@ -708,6 +747,8 @@ dependencies = [
"rquickjs", "rquickjs",
"rusqlite", "rusqlite",
"rust_decimal", "rust_decimal",
"rustls",
"rustls-native-certs",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
@@ -726,6 +767,7 @@ dependencies = [
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-rustls",
"toml 0.8.23", "toml 0.8.23",
"toml_edit 0.22.27", "toml_edit 0.22.27",
"tower 0.4.13", "tower 0.4.13",
@@ -733,6 +775,7 @@ dependencies = [
"url", "url",
"uuid", "uuid",
"webkit2gtk", "webkit2gtk",
"webpki-roots 0.26.11",
"winreg 0.52.0", "winreg 0.52.0",
"zip 2.4.2", "zip 2.4.2",
] ]
@@ -800,6 +843,15 @@ dependencies = [
"inout", "inout",
] ]
[[package]]
name = "cmake"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -810,23 +862,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "compression-codecs"
version = "0.4.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
dependencies = [
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@@ -1573,6 +1608,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "funty" name = "funty"
version = "2.0.0" version = "2.0.0"
@@ -2206,12 +2247,14 @@ dependencies = [
"http", "http",
"hyper", "hyper",
"hyper-util", "hyper-util",
"log",
"rustls", "rustls",
"rustls-native-certs",
"rustls-pki-types", "rustls-pki-types",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots", "webpki-roots 1.0.6",
] ]
[[package]] [[package]]
@@ -4200,7 +4243,7 @@ dependencies = [
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams 0.4.2", "wasm-streams 0.4.2",
"web-sys", "web-sys",
"webpki-roots", "webpki-roots 1.0.6",
] ]
[[package]] [[package]]
@@ -4410,6 +4453,8 @@ version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [ dependencies = [
"aws-lc-rs",
"log",
"once_cell", "once_cell",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -4473,6 +4518,7 @@ version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [ dependencies = [
"aws-lc-rs",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
"untrusted", "untrusted",
@@ -4715,6 +4761,7 @@ version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [ dependencies = [
"indexmap 2.13.0",
"itoa", "itoa",
"memchr", "memchr",
"serde", "serde",
@@ -5325,7 +5372,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"brotli", "brotli 8.0.2",
"ico", "ico",
"json-patch", "json-patch",
"plist", "plist",
@@ -5613,7 +5660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"brotli", "brotli 8.0.2",
"cargo_metadata", "cargo_metadata",
"ctor", "ctor",
"dunce", "dunce",
@@ -6017,18 +6064,13 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [ dependencies = [
"async-compression",
"bitflags 2.11.0", "bitflags 2.11.0",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util",
"iri-string", "iri-string",
"pin-project-lite", "pin-project-lite",
"tokio",
"tokio-util",
"tower 0.5.3", "tower 0.5.3",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
@@ -6564,6 +6606,15 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.6",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.6" version = "1.0.6"
+14 -2
View File
@@ -23,7 +23,7 @@ test-hooks = []
tauri-build = { version = "2.4.0", features = [] } tauri-build = { version = "2.4.0", features = [] }
[dependencies] [dependencies]
serde_json = "1.0" serde_json = { version = "1.0", features = ["preserve_order"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
log = "0.4" log = "0.4"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
@@ -38,7 +38,9 @@ tauri-plugin-deep-link = "2"
dirs = "5.0" dirs = "5.0"
toml = "0.8" toml = "0.8"
toml_edit = "0.22" toml_edit = "0.22"
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks", "gzip"] } reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
flate2 = "1"
brotli = "7"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
futures = "0.3" futures = "0.3"
async-stream = "0.3" async-stream = "0.3"
@@ -47,6 +49,16 @@ axum = "0.7"
tower = "0.4" tower = "0.4"
tower-http = { version = "0.5", features = ["cors"] } tower-http = { version = "0.5", features = ["cors"] }
hyper = { version = "1.0", features = ["full"] } hyper = { version = "1.0", features = ["full"] }
hyper-util = { version = "0.1", features = ["tokio", "http1", "client-legacy"] }
hyper-rustls = { version = "0.27", features = ["http1", "tls12", "ring", "webpki-tokio"] }
http = "1"
http-body = "1"
http-body-util = "0.1"
httparse = "1"
tokio-rustls = "0.26"
rustls = "0.23"
webpki-roots = "0.26"
rustls-native-certs = "0.8"
regex = "1.10" regex = "1.10"
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] } rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
thiserror = "2.0" thiserror = "2.0"
+1 -1
View File
@@ -141,7 +141,7 @@ pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
/// ///
/// Supported fields: /// Supported fields:
/// - `"base_url"`: writes to `[model_providers.<current>].base_url` if `model_provider` exists, /// - `"base_url"`: writes to `[model_providers.<current>].base_url` if `model_provider` exists,
/// otherwise falls back to top-level `base_url`. /// otherwise falls back to top-level `base_url`.
/// - `"model"`: writes to top-level `model` field. /// - `"model"`: writes to top-level `model` field.
/// ///
/// Empty value removes the field. /// Empty value removes the field.
+2 -2
View File
@@ -49,7 +49,7 @@ pub async fn copilot_poll_for_auth(
Ok(false) Ok(false)
} }
Err(e) => { Err(e) => {
log::error!("[CopilotAuth] 轮询失败: {}", e); log::error!("[CopilotAuth] 轮询失败: {e}");
Err(e.to_string()) Err(e.to_string())
} }
} }
@@ -70,7 +70,7 @@ pub async fn copilot_poll_for_account(
Ok(None) Ok(None)
} }
Err(e) => { Err(e) => {
log::error!("[CopilotAuth] 轮询失败: {}", e); log::error!("[CopilotAuth] 轮询失败: {e}");
Err(e.to_string()) Err(e.to_string())
} }
} }
+14
View File
@@ -0,0 +1,14 @@
#[tauri::command]
pub fn enter_lightweight_mode(app: tauri::AppHandle) -> Result<(), String> {
crate::lightweight::enter_lightweight_mode(&app)
}
#[tauri::command]
pub fn exit_lightweight_mode(app: tauri::AppHandle) -> Result<(), String> {
crate::lightweight::exit_lightweight_mode(&app)
}
#[tauri::command]
pub fn is_lightweight_mode() -> bool {
crate::lightweight::is_lightweight_mode()
}
+188 -13
View File
@@ -6,7 +6,7 @@ use crate::services::ProviderService;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::{Path, PathBuf};
use std::str::FromStr; use std::str::FromStr;
use tauri::AppHandle; use tauri::AppHandle;
use tauri::State; use tauri::State;
@@ -500,7 +500,8 @@ fn extend_from_path_list(
/// OpenCode install.sh 路径优先级(见 https://github.com/anomalyco/opencode README: /// OpenCode install.sh 路径优先级(见 https://github.com/anomalyco/opencode README:
/// $OPENCODE_INSTALL_DIR > $XDG_BIN_DIR > $HOME/bin > $HOME/.opencode/bin /// $OPENCODE_INSTALL_DIR > $XDG_BIN_DIR > $HOME/bin > $HOME/.opencode/bin
/// 额外扫描 Go 安装路径(~/go/bin、$GOPATH/*/bin /// 额外扫描 Bun 默认全局安装路径(~/.bun/bin
/// 和 Go 安装路径(~/go/bin、$GOPATH/*/bin)。
fn opencode_extra_search_paths( fn opencode_extra_search_paths(
home: &Path, home: &Path,
opencode_install_dir: Option<std::ffi::OsString>, opencode_install_dir: Option<std::ffi::OsString>,
@@ -515,6 +516,7 @@ fn opencode_extra_search_paths(
if !home.as_os_str().is_empty() { if !home.as_os_str().is_empty() {
push_unique_path(&mut paths, home.join("bin")); push_unique_path(&mut paths, home.join("bin"));
push_unique_path(&mut paths, home.join(".opencode").join("bin")); push_unique_path(&mut paths, home.join(".opencode").join("bin"));
push_unique_path(&mut paths, home.join(".bun").join("bin"));
push_unique_path(&mut paths, home.join("go").join("bin")); push_unique_path(&mut paths, home.join("go").join("bin"));
} }
@@ -718,8 +720,10 @@ pub async fn open_provider_terminal(
state: State<'_, crate::store::AppState>, state: State<'_, crate::store::AppState>,
app: String, app: String,
#[allow(non_snake_case)] providerId: String, #[allow(non_snake_case)] providerId: String,
cwd: Option<String>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
let launch_cwd = resolve_launch_cwd(cwd)?;
// 获取提供商配置 // 获取提供商配置
let providers = ProviderService::list(state.inner(), app_type.clone()) let providers = ProviderService::list(state.inner(), app_type.clone())
@@ -734,7 +738,8 @@ pub async fn open_provider_terminal(
let env_vars = extract_env_vars_from_config(config, &app_type); let env_vars = extract_env_vars_from_config(config, &app_type);
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名 // 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?; launch_terminal_with_env(env_vars, &providerId, launch_cwd.as_deref())
.map_err(|e| format!("启动终端失败: {e}"))?;
Ok(true) Ok(true)
} }
@@ -789,11 +794,49 @@ fn extract_env_vars_from_config(
env_vars env_vars
} }
fn resolve_launch_cwd(cwd: Option<String>) -> Result<Option<PathBuf>, String> {
let Some(raw_path) = cwd.filter(|value| !value.trim().is_empty()) else {
return Ok(None);
};
if raw_path.contains('\n') || raw_path.contains('\r') {
return Err("目录路径包含非法换行符".to_string());
}
let path = Path::new(&raw_path);
if !path.exists() {
return Err(format!("目录不存在: {raw_path}"));
}
let resolved = std::fs::canonicalize(path).map_err(|e| format!("解析目录失败: {e}"))?;
if !resolved.is_dir() {
return Err(format!("选择的路径不是文件夹: {}", resolved.display()));
}
// Strip Windows extended-length prefix that canonicalize produces,
// as it can break batch scripts and other shell commands.
// Special-case \\?\UNC\server\share -> \\server\share for network/WSL paths.
#[cfg(target_os = "windows")]
let resolved = {
let s = resolved.to_string_lossy();
if let Some(unc) = s.strip_prefix(r"\\?\UNC\") {
PathBuf::from(format!(r"\\{unc}"))
} else if let Some(stripped) = s.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
resolved
}
};
Ok(Some(resolved))
}
/// 创建临时配置文件并启动 claude 终端 /// 创建临时配置文件并启动 claude 终端
/// 使用 --settings 参数传入提供商特定的 API 配置 /// 使用 --settings 参数传入提供商特定的 API 配置
fn launch_terminal_with_env( fn launch_terminal_with_env(
env_vars: Vec<(String, String)>, env_vars: Vec<(String, String)>,
provider_id: &str, provider_id: &str,
cwd: Option<&Path>,
) -> Result<(), String> { ) -> Result<(), String> {
let temp_dir = std::env::temp_dir(); let temp_dir = std::env::temp_dir();
let config_file = temp_dir.join(format!( let config_file = temp_dir.join(format!(
@@ -807,19 +850,19 @@ fn launch_terminal_with_env(
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
launch_macos_terminal(&config_file)?; launch_macos_terminal(&config_file, cwd)?;
Ok(()) Ok(())
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
launch_linux_terminal(&config_file)?; launch_linux_terminal(&config_file, cwd)?;
Ok(()) Ok(())
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
launch_windows_terminal(&temp_dir, &config_file)?; launch_windows_terminal(&temp_dir, &config_file, cwd)?;
return Ok(()); return Ok(());
} }
@@ -849,7 +892,7 @@ fn write_claude_config(
/// macOS: 根据用户首选终端启动 /// macOS: 根据用户首选终端启动
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> { fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let preferred = crate::settings::get_preferred_terminal(); let preferred = crate::settings::get_preferred_terminal();
@@ -858,18 +901,21 @@ fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
let temp_dir = std::env::temp_dir(); let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id())); let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy(); let config_path = config_file.to_string_lossy();
let cd_command = build_shell_cd_command(cwd);
// Write the shell script to a temp file // Write the shell script to a temp file
let script_content = format!( let script_content = format!(
r#"#!/bin/bash r#"#!/bin/bash
trap 'rm -f "{config_path}" "{script_file}"' EXIT trap 'rm -f "{config_path}" "{script_file}"' EXIT
{cd_command}
echo "Using provider-specific claude config:" echo "Using provider-specific claude config:"
echo "{config_path}" echo "{config_path}"
claude --settings "{config_path}" claude --settings "{config_path}"
exec bash --norc --noprofile exec bash --norc --noprofile
"#, "#,
config_path = config_path, config_path = config_path,
script_file = script_file.display() script_file = script_file.display(),
cd_command = cd_command,
); );
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?; std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
@@ -1005,7 +1051,7 @@ fn launch_macos_open_app(
/// Linux: 根据用户首选终端启动 /// Linux: 根据用户首选终端启动
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> { fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::process::Command; use std::process::Command;
@@ -1027,17 +1073,20 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
let temp_dir = std::env::temp_dir(); let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id())); let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy(); let config_path = config_file.to_string_lossy();
let cd_command = build_shell_cd_command(cwd);
let script_content = format!( let script_content = format!(
r#"#!/bin/bash r#"#!/bin/bash
trap 'rm -f "{config_path}" "{script_file}"' EXIT trap 'rm -f "{config_path}" "{script_file}"' EXIT
{cd_command}
echo "Using provider-specific claude config:" echo "Using provider-specific claude config:"
echo "{config_path}" echo "{config_path}"
claude --settings "{config_path}" claude --settings "{config_path}"
exec bash --norc --noprofile exec bash --norc --noprofile
"#, "#,
config_path = config_path, config_path = config_path,
script_file = script_file.display() script_file = script_file.display(),
cd_command = cd_command,
); );
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?; std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
@@ -1116,22 +1165,28 @@ fn which_command(cmd: &str) -> bool {
fn launch_windows_terminal( fn launch_windows_terminal(
temp_dir: &std::path::Path, temp_dir: &std::path::Path,
config_file: &std::path::Path, config_file: &std::path::Path,
cwd: Option<&Path>,
) -> Result<(), String> { ) -> Result<(), String> {
let preferred = crate::settings::get_preferred_terminal(); let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("cmd"); let terminal = preferred.as_deref().unwrap_or("cmd");
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id())); let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&"); let config_path_for_batch = escape_windows_batch_value(&config_file.to_string_lossy());
let cwd_command = build_windows_cwd_command(cwd);
let content = format!( let content = format!(
"@echo off "@echo off
{cwd_command}
echo Using provider-specific claude config: echo Using provider-specific claude config:
echo {} echo {}
claude --settings \"{}\" claude --settings \"{}\"
del \"{}\" >nul 2>&1 del \"{}\" >nul 2>&1
del \"%~f0\" >nul 2>&1 del \"%~f0\" >nul 2>&1
", ",
config_path_for_batch, config_path_for_batch, config_path_for_batch config_path_for_batch,
config_path_for_batch,
config_path_for_batch,
cwd_command = cwd_command,
); );
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?; std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
@@ -1162,6 +1217,55 @@ del \"%~f0\" >nul 2>&1
result result
} }
fn build_shell_cd_command(cwd: Option<&Path>) -> String {
cwd.map(|dir| {
format!(
"cd {} || exit 1\n",
shell_single_quote(&dir.to_string_lossy())
)
})
.unwrap_or_default()
}
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn is_windows_unc_path(path: &str) -> bool {
path.starts_with(r"\\")
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn build_windows_cwd_command_str(path: &str) -> String {
let escaped = escape_windows_batch_value(path);
if is_windows_unc_path(path) {
// `cmd.exe` cannot make a UNC path current via `cd`; `pushd` maps it first.
format!("pushd \"{escaped}\" || exit /b 1\r\n")
} else {
format!("cd /d \"{escaped}\" || exit /b 1\r\n")
}
}
#[cfg(target_os = "windows")]
fn build_windows_cwd_command(cwd: Option<&Path>) -> String {
cwd.map(|dir| build_windows_cwd_command_str(&dir.to_string_lossy()))
.unwrap_or_default()
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn escape_windows_batch_value(value: &str) -> String {
value
.replace('^', "^^")
.replace('%', "%%")
.replace('&', "^&")
.replace('|', "^|")
.replace('<', "^<")
.replace('>', "^>")
.replace('(', "^(")
.replace(')', "^)")
}
/// Windows: Run a start command with common error handling /// Windows: Run a start command with common error handling
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> { fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
@@ -1280,6 +1384,7 @@ mod tests {
assert_eq!(paths[1], PathBuf::from("/xdg/bin")); assert_eq!(paths[1], PathBuf::from("/xdg/bin"));
assert!(paths.contains(&PathBuf::from("/home/tester/bin"))); assert!(paths.contains(&PathBuf::from("/home/tester/bin")));
assert!(paths.contains(&PathBuf::from("/home/tester/.opencode/bin"))); assert!(paths.contains(&PathBuf::from("/home/tester/.opencode/bin")));
assert!(paths.contains(&PathBuf::from("/home/tester/.bun/bin")));
assert!(paths.contains(&PathBuf::from("/home/tester/go/bin"))); assert!(paths.contains(&PathBuf::from("/home/tester/go/bin")));
assert!(paths.contains(&PathBuf::from("/go/path1/bin"))); assert!(paths.contains(&PathBuf::from("/go/path1/bin")));
assert!(paths.contains(&PathBuf::from("/go/path2/bin"))); assert!(paths.contains(&PathBuf::from("/go/path2/bin")));
@@ -1290,7 +1395,7 @@ mod tests {
let home = PathBuf::from("/home/tester"); let home = PathBuf::from("/home/tester");
let same_dir = Some(std::ffi::OsString::from("/same/path")); let same_dir = Some(std::ffi::OsString::from("/same/path"));
let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir.clone(), None); let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir, None);
let count = paths let count = paths
.iter() .iter()
@@ -1299,6 +1404,18 @@ mod tests {
assert_eq!(count, 1); assert_eq!(count, 1);
} }
#[test]
fn opencode_extra_search_paths_deduplicates_bun_default_dir() {
let home = PathBuf::from("/home/tester");
let paths = opencode_extra_search_paths(&home, None, None, None);
let count = paths
.iter()
.filter(|path| **path == PathBuf::from("/home/tester/.bun/bin"))
.count();
assert_eq!(count, 1);
}
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
#[test] #[test]
fn tool_executable_candidates_non_windows_uses_plain_binary_name() { fn tool_executable_candidates_non_windows_uses_plain_binary_name() {
@@ -1323,4 +1440,62 @@ mod tests {
] ]
); );
} }
#[test]
fn resolve_launch_cwd_accepts_existing_directory() {
let resolved =
resolve_launch_cwd(Some(std::env::temp_dir().to_string_lossy().into_owned()))
.expect("temp dir should resolve")
.expect("temp dir should be present");
assert!(resolved.is_dir());
}
#[test]
fn resolve_launch_cwd_rejects_missing_directory() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock should be after epoch")
.as_nanos();
let missing = std::env::temp_dir().join(format!("cc-switch-missing-{unique}"));
let error = resolve_launch_cwd(Some(missing.to_string_lossy().into_owned()))
.expect_err("missing directory should fail");
assert!(error.contains("目录不存在"));
}
#[test]
fn build_shell_cd_command_quotes_spaces_and_single_quotes() {
let command = build_shell_cd_command(Some(Path::new("/tmp/project O'Brien")));
assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n");
}
#[test]
fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
let command = build_windows_cwd_command_str(r"C:\work\repo");
assert_eq!(command, "cd /d \"C:\\work\\repo\" || exit /b 1\r\n");
}
#[test]
fn build_windows_cwd_command_str_uses_pushd_for_unc_paths() {
let command = build_windows_cwd_command_str(r"\\wsl$\Ubuntu\home\coder\repo");
assert_eq!(
command,
"pushd \"\\\\wsl$\\Ubuntu\\home\\coder\\repo\" || exit /b 1\r\n"
);
}
#[test]
fn build_windows_cwd_command_str_escapes_batch_metacharacters() {
let command = build_windows_cwd_command_str(r"\\server\share\100%&(test)");
assert_eq!(
command,
"pushd \"\\\\server\\share\\100%%^&^(test^)\" || exit /b 1\r\n"
);
}
} }
+2
View File
@@ -22,6 +22,7 @@ pub mod skill;
mod stream_check; mod stream_check;
mod sync_support; mod sync_support;
mod lightweight;
mod usage; mod usage;
mod webdav_sync; mod webdav_sync;
mod workspace; mod workspace;
@@ -47,6 +48,7 @@ pub use settings::*;
pub use skill::*; pub use skill::*;
pub use stream_check::*; pub use stream_check::*;
pub use lightweight::*;
pub use usage::*; pub use usage::*;
pub use webdav_sync::*; pub use webdav_sync::*;
pub use workspace::*; pub use workspace::*;
+9 -5
View File
@@ -36,9 +36,11 @@ pub fn add_provider(
state: State<'_, AppState>, state: State<'_, AppState>,
app: String, app: String,
provider: Provider, provider: Provider,
#[allow(non_snake_case)] addToLive: Option<bool>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string()) ProviderService::add(state.inner(), app_type, provider, addToLive.unwrap_or(true))
.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@@ -46,9 +48,11 @@ pub fn update_provider(
state: State<'_, AppState>, state: State<'_, AppState>,
app: String, app: String,
provider: Provider, provider: Provider,
#[allow(non_snake_case)] originalId: Option<String>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string()) ProviderService::update(state.inner(), app_type, originalId.as_deref(), provider)
.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@@ -159,7 +163,7 @@ pub async fn queryProviderUsage(
let providers = state let providers = state
.db .db
.get_all_providers(app_type.as_str()) .get_all_providers(app_type.as_str())
.map_err(|e| format!("Failed to get providers: {}", e))?; .map_err(|e| format!("Failed to get providers: {e}"))?;
let provider = providers.get(&providerId); let provider = providers.get(&providerId);
let is_copilot = provider let is_copilot = provider
@@ -182,11 +186,11 @@ pub async fn queryProviderUsage(
Some(account_id) => auth_manager Some(account_id) => auth_manager
.fetch_usage_for_account(account_id) .fetch_usage_for_account(account_id)
.await .await
.map_err(|e| format!("Failed to fetch Copilot usage: {}", e))?, .map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?,
None => auth_manager None => auth_manager
.fetch_usage() .fetch_usage()
.await .await
.map_err(|e| format!("Failed to fetch Copilot usage: {}", e))?, .map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?,
}; };
let premium = &usage.quota_snapshots.premium_interactions; let premium = &usage.quota_snapshots.premium_interactions;
let used = premium.entitlement - premium.remaining; let used = premium.entitlement - premium.remaining;
@@ -74,3 +74,12 @@ pub async fn delete_session(
.await .await
.map_err(|e| format!("Failed to delete session: {e}"))? .map_err(|e| format!("Failed to delete session: {e}"))?
} }
#[tauri::command]
pub async fn delete_sessions(
items: Vec<session_manager::DeleteSessionRequest>,
) -> Result<Vec<session_manager::DeleteSessionOutcome>, String> {
tauri::async_runtime::spawn_blocking(move || session_manager::delete_sessions(&items))
.await
.map_err(|e| format!("Failed to delete sessions: {e}"))
}
+98 -15
View File
@@ -26,8 +26,22 @@ pub async fn stream_check_provider(
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?; .ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?; let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
let result = let claude_api_format_override = resolve_claude_api_format_override(
StreamCheckService::check_with_retry(&app_type, provider, &config, auth_override).await?; &app_type,
provider,
&config,
&copilot_state,
auth_override.as_ref(),
)
.await?;
let result = StreamCheckService::check_with_retry(
&app_type,
provider,
&config,
auth_override,
claude_api_format_override,
)
.await?;
// 记录日志 // 记录日志
let _ = let _ =
@@ -73,19 +87,40 @@ pub async fn stream_check_all_providers(
} }
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?; let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
let result = let claude_api_format_override = resolve_claude_api_format_override(
StreamCheckService::check_with_retry(&app_type, &provider, &config, auth_override) &app_type,
.await &provider,
.unwrap_or_else(|e| StreamCheckResult { &config,
status: HealthStatus::Failed, &copilot_state,
success: false, auth_override.as_ref(),
message: e.to_string(), )
response_time_ms: None, .await
http_status: None, .unwrap_or_else(|e| {
model_used: String::new(), log::warn!(
tested_at: chrono::Utc::now().timestamp(), "[StreamCheck] Failed to resolve Claude API format override for {}: {}",
retry_count: 0, provider.id,
}); e
);
None
});
let result = StreamCheckService::check_with_retry(
&app_type,
&provider,
&config,
auth_override,
claude_api_format_override,
)
.await
.unwrap_or_else(|e| StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: e.to_string(),
response_time_ms: None,
http_status: None,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
retry_count: 0,
});
let _ = state let _ = state
.db .db
@@ -154,3 +189,51 @@ async fn resolve_copilot_auth_override(
crate::proxy::providers::AuthStrategy::GitHubCopilot, crate::proxy::providers::AuthStrategy::GitHubCopilot,
))) )))
} }
async fn resolve_claude_api_format_override(
app_type: &AppType,
provider: &crate::provider::Provider,
config: &StreamCheckConfig,
copilot_state: &State<'_, CopilotAuthState>,
auth_override: Option<&crate::proxy::providers::AuthInfo>,
) -> Result<Option<String>, AppError> {
if *app_type != AppType::Claude {
return Ok(None);
}
let is_copilot = auth_override
.map(|auth| auth.strategy == crate::proxy::providers::AuthStrategy::GitHubCopilot)
.unwrap_or(false);
if !is_copilot {
return Ok(None);
}
let model_id = StreamCheckService::resolve_effective_test_model(app_type, provider, config);
let auth_manager = copilot_state.0.read().await;
let account_id = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
let vendor_result = match account_id.as_deref() {
Some(id) => {
auth_manager
.get_model_vendor_for_account(id, &model_id)
.await
}
None => auth_manager.get_model_vendor(&model_id).await,
};
let api_format = match vendor_result {
Ok(Some(vendor)) if vendor.eq_ignore_ascii_case("openai") => "openai_responses",
Ok(Some(_)) | Ok(None) => "openai_chat",
Err(err) => {
log::warn!(
"[StreamCheck] Failed to resolve Copilot model vendor for {model_id}: {err}. Falling back to chat/completions"
);
"openai_chat"
}
};
Ok(Some(api_format.to_string()))
}
+1 -1
View File
@@ -110,7 +110,7 @@ pub fn import_provider_from_deeplink(
let provider_id = provider.id.clone(); let provider_id = provider.id.clone();
// Use ProviderService to add the provider // Use ProviderService to add the provider
ProviderService::add(state, app_type.clone(), provider)?; ProviderService::add(state, app_type.clone(), provider, true)?;
// Add extra endpoints as custom endpoints (skip first one as it's the primary) // Add extra endpoints as custom endpoints (skip first one as it's the primary)
for ep in all_endpoints.iter().skip(1) { for ep in all_endpoints.iter().skip(1) {
+29
View File
@@ -12,6 +12,7 @@ mod error;
mod gemini_config; mod gemini_config;
mod gemini_mcp; mod gemini_mcp;
mod init_status; mod init_status;
mod lightweight;
mod mcp; mod mcp;
mod openclaw_config; mod openclaw_config;
mod opencode_config; mod opencode_config;
@@ -204,6 +205,12 @@ pub fn run() {
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg)); log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
} }
if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
// Check for deep link URL in args (mainly for Windows/Linux command line) // Check for deep link URL in args (mainly for Windows/Linux command line)
let mut found_deeplink = false; let mut found_deeplink = false;
for arg in &args { for arg in &args {
@@ -615,6 +622,12 @@ pub fn run() {
let urls = event.urls(); let urls = event.urls();
log::info!("Received {} URL(s)", urls.len()); log::info!("Received {} URL(s)", urls.len());
if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(&app_handle) {
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
for (i, url) in urls.iter().enumerate() { for (i, url) in urls.iter().enumerate() {
let url_str = url.as_str(); let url_str = url.as_str();
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str)); log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
@@ -1008,6 +1021,7 @@ pub fn run() {
commands::list_sessions, commands::list_sessions,
commands::get_session_messages, commands::get_session_messages,
commands::delete_session, commands::delete_session,
commands::delete_sessions,
commands::launch_session_terminal, commands::launch_session_terminal,
commands::get_tool_versions, commands::get_tool_versions,
// Provider terminal // Provider terminal
@@ -1085,6 +1099,10 @@ pub fn run() {
commands::delete_daily_memory_file, commands::delete_daily_memory_file,
commands::search_daily_memory_files, commands::search_daily_memory_files,
commands::open_workspace_directory, commands::open_workspace_directory,
// lightweight mode (for testing or low-resource environments)
commands::enter_lightweight_mode,
commands::exit_lightweight_mode,
commands::is_lightweight_mode,
]); ]);
let app = builder let app = builder
@@ -1135,6 +1153,10 @@ pub fn run() {
let _ = window.show(); let _ = window.show();
let _ = window.set_focus(); let _ = window.set_focus();
tray::apply_tray_policy(app_handle, true); tray::apply_tray_policy(app_handle, true);
} else if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle) {
log::error!("退出轻量模式重建窗口失败: {e}");
}
} }
} }
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://... // 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...
@@ -1144,6 +1166,13 @@ pub fn run() {
log::info!("RunEvent::Opened with URL: {url_str}"); log::info!("RunEvent::Opened with URL: {url_str}");
if url_str.starts_with("ccswitch://") { if url_str.starts_with("ccswitch://") {
if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle)
{
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
// 解析并广播深链接事件,复用与 single_instance 相同的逻辑 // 解析并广播深链接事件,复用与 single_instance 相同的逻辑
match crate::deeplink::parse_deeplink_url(&url_str) { match crate::deeplink::parse_deeplink_url(&url_str) {
Ok(request) => { Ok(request) => {
+90
View File
@@ -0,0 +1,90 @@
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::Manager;
static LIGHTWEIGHT_MODE: AtomicBool = AtomicBool::new(false);
pub fn enter_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_skip_taskbar(true);
}
}
#[cfg(target_os = "macos")]
{
crate::tray::apply_tray_policy(app, false);
}
if let Some(window) = app.get_webview_window("main") {
window
.destroy()
.map_err(|e| format!("销毁主窗口失败: {e}"))?;
}
// else: already in lightweight mode or window not found, just set the flag
LIGHTWEIGHT_MODE.store(true, Ordering::Release);
crate::tray::refresh_tray_menu(app);
log::info!("进入轻量模式");
Ok(())
}
pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
use tauri::WebviewWindowBuilder;
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
#[cfg(target_os = "macos")]
{
crate::tray::apply_tray_policy(app, true);
}
LIGHTWEIGHT_MODE.store(false, Ordering::Release);
crate::tray::refresh_tray_menu(app);
log::info!("退出轻量模式");
return Ok(());
}
let window_config = app
.config()
.app
.windows
.iter()
.find(|w| w.label == "main")
.ok_or("主窗口配置未找到")?;
WebviewWindowBuilder::from_config(app, window_config)
.map_err(|e| format!("加载主窗口配置失败: {e}"))?
.visible(true)
.build()
.map_err(|e| format!("创建主窗口失败: {e}"))?;
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
}
#[cfg(target_os = "windows")]
{
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_skip_taskbar(false);
}
}
#[cfg(target_os = "macos")]
{
crate::tray::apply_tray_policy(app, true);
}
LIGHTWEIGHT_MODE.store(false, Ordering::Release);
crate::tray::refresh_tray_menu(app);
log::info!("退出轻量模式");
Ok(())
}
pub fn is_lightweight_mode() -> bool {
LIGHTWEIGHT_MODE.load(Ordering::Acquire)
}
+38 -6
View File
@@ -8,7 +8,6 @@ use crate::error::AppError;
use crate::settings::{effective_backup_retain_count, get_openclaw_override_dir}; use crate::settings::{effective_backup_retain_count, get_openclaw_override_dir};
use chrono::Local; use chrono::Local;
use indexmap::IndexMap; use indexmap::IndexMap;
use json_five::parser::{FormatConfiguration, TrailingComma};
use json_five::rt::parser::{ use json_five::rt::parser::{
from_str as rt_from_str, JSONKeyValuePair as RtJSONKeyValuePair, from_str as rt_from_str, JSONKeyValuePair as RtJSONKeyValuePair,
JSONObjectContext as RtJSONObjectContext, JSONText as RtJSONText, JSONValue as RtJSONValue, JSONObjectContext as RtJSONObjectContext, JSONText as RtJSONText, JSONValue as RtJSONValue,
@@ -490,11 +489,11 @@ fn derive_entry_separator(leading_ws: &str) -> String {
} }
fn value_to_rt_value(value: &Value, parent_indent: &str) -> Result<RtJSONValue, AppError> { fn value_to_rt_value(value: &Value, parent_indent: &str) -> Result<RtJSONValue, AppError> {
let source = json_five::to_string_formatted( // `json-five` 0.3.1 can panic when pretty-printing nested empty maps/arrays.
value, // Serialize with `serde_json` instead; the resulting JSON is valid JSON5 and
FormatConfiguration::with_indent(2, TrailingComma::NONE), // can still be parsed back into the round-trip AST we use for insertion.
) let source = serde_json::to_string_pretty(value)
.map_err(|e| AppError::Config(format!("Failed to serialize JSON5 section: {e}")))?; .map_err(|e| AppError::Config(format!("Failed to serialize JSON section: {e}")))?;
let adjusted = reindent_json5_block(&source, parent_indent); let adjusted = reindent_json5_block(&source, parent_indent);
let text = rt_from_str(&adjusted).map_err(|e| { let text = rt_from_str(&adjusted).map_err(|e| {
@@ -1051,4 +1050,37 @@ mod tests {
assert!(err.to_string().contains("OpenClaw config changed on disk")); assert!(err.to_string().contains("OpenClaw config changed on disk"));
}); });
} }
#[test]
fn remove_last_provider_writes_empty_providers_without_panic() {
let source = r#"{
models: {
mode: 'merge',
providers: {
'1-copy': {
api: 'anthropic-messages',
},
},
},
}
"#;
with_test_paths(source, |_| {
let outcome = remove_provider("1-copy").unwrap();
assert!(outcome.backup_path.is_some());
let config = read_openclaw_config().unwrap();
let providers = config
.get("models")
.and_then(|models| models.get("providers"))
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
assert!(providers.is_empty());
let written = fs::read_to_string(get_openclaw_config_path()).unwrap();
assert!(written.contains("\"providers\": {}"));
});
}
} }
+47 -23
View File
@@ -6,6 +6,32 @@ use indexmap::IndexMap;
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use std::path::PathBuf; use std::path::PathBuf;
const STANDARD_OMO_PLUGIN_PREFIXES: [&str; 2] = ["oh-my-openagent", "oh-my-opencode"];
const SLIM_OMO_PLUGIN_PREFIXES: [&str; 1] = ["oh-my-opencode-slim"];
fn matches_plugin_prefix(plugin_name: &str, prefix: &str) -> bool {
plugin_name == prefix
|| plugin_name
.strip_prefix(prefix)
.map(|suffix| suffix.starts_with('@'))
.unwrap_or(false)
}
fn matches_any_plugin_prefix(plugin_name: &str, prefixes: &[&str]) -> bool {
prefixes
.iter()
.any(|prefix| matches_plugin_prefix(plugin_name, prefix))
}
fn canonicalize_plugin_name(plugin_name: &str) -> String {
if let Some(suffix) = plugin_name.strip_prefix("oh-my-opencode") {
if suffix.is_empty() || suffix.starts_with('@') {
return format!("oh-my-openagent{suffix}");
}
}
plugin_name.to_string()
}
pub fn get_opencode_dir() -> PathBuf { pub fn get_opencode_dir() -> PathBuf {
if let Some(override_dir) = get_opencode_override_dir() { if let Some(override_dir) = get_opencode_override_dir() {
return override_dir; return override_dir;
@@ -140,58 +166,56 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> { pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?; let mut config = read_opencode_config()?;
let normalized_plugin_name = canonicalize_plugin_name(plugin_name);
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut()); let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
match plugins { match plugins {
Some(arr) => { Some(arr) => {
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins // Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
if plugin_name.starts_with("oh-my-opencode") if matches_any_plugin_prefix(&normalized_plugin_name, &STANDARD_OMO_PLUGIN_PREFIXES) {
&& !plugin_name.starts_with("oh-my-opencode-slim")
{
// Adding standard OMO -> remove all Slim variants
arr.retain(|v| {
v.as_str()
.map(|s| !s.starts_with("oh-my-opencode-slim"))
.unwrap_or(true)
});
} else if plugin_name.starts_with("oh-my-opencode-slim") {
// Adding Slim -> remove all standard OMO variants (but keep slim)
arr.retain(|v| { arr.retain(|v| {
v.as_str() v.as_str()
.map(|s| { .map(|s| {
!s.starts_with("oh-my-opencode") || s.starts_with("oh-my-opencode-slim") !matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
})
.unwrap_or(true)
});
} else if matches_any_plugin_prefix(&normalized_plugin_name, &SLIM_OMO_PLUGIN_PREFIXES)
{
arr.retain(|v| {
v.as_str()
.map(|s| {
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
}) })
.unwrap_or(true) .unwrap_or(true)
}); });
} }
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name)); let already_exists = arr
.iter()
.any(|v| v.as_str() == Some(normalized_plugin_name.as_str()));
if !already_exists { if !already_exists {
arr.push(Value::String(plugin_name.to_string())); arr.push(Value::String(normalized_plugin_name));
} }
} }
None => { None => {
config["plugin"] = json!([plugin_name]); config["plugin"] = json!([normalized_plugin_name]);
} }
} }
write_opencode_config(&config) write_opencode_config(&config)
} }
pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> { pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
let mut config = read_opencode_config()?; let mut config = read_opencode_config()?;
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) { if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
arr.retain(|v| { arr.retain(|v| {
v.as_str() v.as_str()
.map(|s| { .map(|s| !matches_any_plugin_prefix(s, prefixes))
if !s.starts_with(prefix) {
return true; // Keep: doesn't match prefix at all
}
let rest = &s[prefix.len()..];
rest.starts_with('-')
})
.unwrap_or(true) .unwrap_or(true)
}); });
+7 -1
View File
@@ -275,12 +275,18 @@ pub struct ProviderMeta {
/// Claude 认证字段名("ANTHROPIC_AUTH_TOKEN" 或 "ANTHROPIC_API_KEY" /// Claude 认证字段名("ANTHROPIC_AUTH_TOKEN" 或 "ANTHROPIC_API_KEY"
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")] #[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
pub api_key_field: Option<String>, pub api_key_field: Option<String>,
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
pub is_full_url: Option<bool>,
/// Prompt cache key for OpenAI-compatible endpoints. /// Prompt cache key for OpenAI-compatible endpoints.
/// When set, injected into converted requests to improve cache hit rate. /// When set, injected into converted requests to improve cache hit rate.
/// If not set, provider ID is used automatically during format conversion. /// If not set, provider ID is used automatically during format conversion.
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")] #[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>, pub prompt_cache_key: Option<String>,
/// 累加模式应用中,该 provider 是否已写入 live config。
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
pub live_config_managed: Option<bool>,
/// 供应商类型标识(用于特殊供应商检测) /// 供应商类型标识(用于特殊供应商检测)
/// - "github_copilot": GitHub Copilot 供应商 /// - "github_copilot": GitHub Copilot 供应商
#[serde(rename = "providerType", skip_serializing_if = "Option::is_none")] #[serde(rename = "providerType", skip_serializing_if = "Option::is_none")]
-1
View File
@@ -68,7 +68,6 @@ pub enum ProxyError {
StreamIdleTimeout(u64), StreamIdleTimeout(u64),
/// 认证错误 /// 认证错误
#[allow(dead_code)]
#[error("认证失败: {0}")] #[error("认证失败: {0}")]
AuthError(String), AuthError(String),
+11 -23
View File
@@ -2,15 +2,12 @@
//! //!
//! 处理故障转移成功后的供应商切换逻辑,包括: //! 处理故障转移成功后的供应商切换逻辑,包括:
//! - 去重控制(避免多个请求同时触发) //! - 去重控制(避免多个请求同时触发)
//! - 数据库更新
//! - 托盘菜单更新 //! - 托盘菜单更新
//! - 前端事件发射 //! - 前端事件发射
//! - Live 备份更新
use crate::database::Database; use crate::database::Database;
use crate::error::AppError; use crate::error::AppError;
use std::collections::HashSet; use std::collections::HashSet;
use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
use tokio::sync::RwLock; use tokio::sync::RwLock;
@@ -98,30 +95,21 @@ impl FailoverSwitchManager {
log::info!("[FO-001] 切换: {app_type} → {provider_name}"); log::info!("[FO-001] 切换: {app_type} → {provider_name}");
// 1. 更新数据库 is_current let mut switched = false;
self.db.set_current_provider(app_type, provider_id)?;
// 2. 更新本地 settings(设备级)
let app_type_enum = crate::app_config::AppType::from_str(app_type)
.map_err(|_| AppError::Message(format!("无效的应用类型: {app_type}")))?;
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?;
// 3. 更新托盘菜单和发射事件
if let Some(app) = app_handle { if let Some(app) = app_handle {
// 更新托盘菜单
if let Some(app_state) = app.try_state::<crate::store::AppState>() { if let Some(app_state) = app.try_state::<crate::store::AppState>() {
// 更新 Live 备份(确保代理停止时恢复正确配置) switched = app_state
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) { .proxy_service
if let Err(e) = app_state .hot_switch_provider(app_type, provider_id)
.proxy_service .await
.update_live_backup_from_provider(app_type, &provider) .map_err(AppError::Message)?
.await .logical_target_changed;
{
log::warn!("[FO-003] Live 备份更新失败: {e}"); if !switched {
} return Ok(false);
} }
// 重建托盘菜单
if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) { if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") { if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) { if let Err(e) = tray.set_menu(Some(new_menu)) {
@@ -142,6 +130,6 @@ impl FailoverSwitchManager {
} }
} }
Ok(true) Ok(switched)
} }
} }
+524 -186
View File
@@ -2,6 +2,7 @@
//! //!
//! 负责将请求转发到上游Provider,支持故障转移 //! 负责将请求转发到上游Provider,支持故障转移
use super::hyper_client::ProxyResponse;
use super::{ use super::{
body_filter::filter_private_params_with_whitelist, body_filter::filter_private_params_with_whitelist,
error::*, error::*,
@@ -19,68 +20,16 @@ use super::{
use crate::commands::CopilotAuthState; use crate::commands::CopilotAuthState;
use crate::proxy::providers::copilot_auth::CopilotAuthManager; use crate::proxy::providers::copilot_auth::CopilotAuthManager;
use crate::{app_config::AppType, provider::Provider}; use crate::{app_config::AppType, provider::Provider};
use reqwest::Response; use http::Extensions;
use serde_json::Value; use serde_json::Value;
use std::sync::Arc; use std::sync::Arc;
use tauri::Manager; use tauri::Manager;
use tokio::sync::RwLock; use tokio::sync::RwLock;
/// Headers 黑名单 - 不透传到上游的 Headers
///
/// 精简版黑名单,只过滤必须覆盖或可能导致问题的 header
/// 参考成功透传的请求,保留更多原始 header
///
/// 注意:客户端 IP 类(x-forwarded-for, x-real-ip)默认透传
const HEADER_BLACKLIST: &[&str] = &[
// 认证类(会被覆盖)
"authorization",
"x-api-key",
"x-goog-api-key",
// 连接类(由 HTTP 客户端管理)
"host",
"content-length",
"transfer-encoding",
// 编码类(会被覆盖为 identity)
"accept-encoding",
// 代理转发类(保留 x-forwarded-for 和 x-real-ip
"x-forwarded-host",
"x-forwarded-port",
"x-forwarded-proto",
"forwarded",
// CDN/云服务商特定头
"cf-connecting-ip",
"cf-ipcountry",
"cf-ray",
"cf-visitor",
"true-client-ip",
"fastly-client-ip",
"x-azure-clientip",
"x-azure-fdid",
"x-azure-ref",
"akamai-origin-hop",
"x-akamai-config-log-detail",
// 请求追踪类
"x-request-id",
"x-correlation-id",
"x-trace-id",
"x-amzn-trace-id",
"x-b3-traceid",
"x-b3-spanid",
"x-b3-parentspanid",
"x-b3-sampled",
"traceparent",
"tracestate",
// anthropic 特定头单独处理,避免重复
"anthropic-beta",
"anthropic-version",
// 客户端 IP 单独处理(默认透传)
"x-forwarded-for",
"x-real-ip",
];
pub struct ForwardResult { pub struct ForwardResult {
pub response: Response, pub response: ProxyResponse,
pub provider: Provider, pub provider: Provider,
pub claude_api_format: Option<String>,
} }
pub struct ForwardError { pub struct ForwardError {
@@ -149,6 +98,7 @@ impl RequestForwarder {
endpoint: &str, endpoint: &str,
body: Value, body: Value,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
extensions: Extensions,
providers: Vec<Provider>, providers: Vec<Provider>,
) -> Result<ForwardResult, ForwardError> { ) -> Result<ForwardResult, ForwardError> {
// 获取适配器 // 获取适配器
@@ -225,11 +175,12 @@ impl RequestForwarder {
endpoint, endpoint,
&provider_body, &provider_body,
&headers, &headers,
&extensions,
adapter.as_ref(), adapter.as_ref(),
) )
.await .await
{ {
Ok(response) => { Ok((response, claude_api_format)) => {
// 成功:记录成功并更新熔断器 // 成功:记录成功并更新熔断器
let _ = self let _ = self
.router .router
@@ -283,6 +234,7 @@ impl RequestForwarder {
return Ok(ForwardResult { return Ok(ForwardResult {
response, response,
provider: provider.clone(), provider: provider.clone(),
claude_api_format,
}); });
} }
Err(e) => { Err(e) => {
@@ -353,11 +305,12 @@ impl RequestForwarder {
endpoint, endpoint,
&provider_body, &provider_body,
&headers, &headers,
&extensions,
adapter.as_ref(), adapter.as_ref(),
) )
.await .await
{ {
Ok(response) => { Ok((response, claude_api_format)) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功"); log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
// 记录成功 // 记录成功
let _ = self let _ = self
@@ -416,6 +369,7 @@ impl RequestForwarder {
return Ok(ForwardResult { return Ok(ForwardResult {
response, response,
provider: provider.clone(), provider: provider.clone(),
claude_api_format,
}); });
} }
Err(retry_err) => { Err(retry_err) => {
@@ -550,11 +504,12 @@ impl RequestForwarder {
endpoint, endpoint,
&provider_body, &provider_body,
&headers, &headers,
&extensions,
adapter.as_ref(), adapter.as_ref(),
) )
.await .await
{ {
Ok(response) => { Ok((response, claude_api_format)) => {
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功"); log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
let _ = self let _ = self
.router .router
@@ -606,6 +561,7 @@ impl RequestForwarder {
return Ok(ForwardResult { return Ok(ForwardResult {
response, response,
provider: provider.clone(), provider: provider.clone(),
claude_api_format,
}); });
} }
Err(retry_err) => { Err(retry_err) => {
@@ -787,13 +743,24 @@ impl RequestForwarder {
endpoint: &str, endpoint: &str,
body: &Value, body: &Value,
headers: &axum::http::HeaderMap, headers: &axum::http::HeaderMap,
extensions: &Extensions,
adapter: &dyn ProviderAdapter, adapter: &dyn ProviderAdapter,
) -> Result<Response, ProxyError> { ) -> Result<(ProxyResponse, Option<String>), ProxyError> {
// 使用适配器提取 base_url // 使用适配器提取 base_url
let base_url = adapter.extract_base_url(provider)?; let base_url = adapter.extract_base_url(provider)?;
// 检查是否需要格式转换 let is_full_url = provider
let needs_transform = adapter.needs_transform(provider); .meta
.as_ref()
.and_then(|meta| meta.is_full_url)
.unwrap_or(false);
// 应用模型映射(独立于格式转换)
let (mapped_body, _original_model, _mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
let mapped_body = normalize_thinking_type(mapped_body);
// 确定有效端点 // 确定有效端点
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀) // GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
@@ -803,37 +770,53 @@ impl RequestForwarder {
.and_then(|m| m.provider_type.as_deref()) .and_then(|m| m.provider_type.as_deref())
== Some("github_copilot") == Some("github_copilot")
|| base_url.contains("githubcopilot.com"); || base_url.contains("githubcopilot.com");
let effective_endpoint = let resolved_claude_api_format = if adapter.name() == "Claude" {
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" { Some(
if is_copilot { self.resolve_claude_api_format(provider, &mapped_body, is_copilot)
// GitHub Copilot uses /chat/completions without /v1 prefix .await,
"/chat/completions" )
} else { } else {
// 根据 api_format 选择目标端点 None
let api_format = super::providers::get_claude_api_format(provider); };
if api_format == "openai_responses" { let needs_transform = match resolved_claude_api_format.as_deref() {
"/v1/responses" Some(api_format) => super::providers::claude_api_format_needs_transform(api_format),
} else { None => adapter.needs_transform(provider),
"/v1/chat/completions" };
} let (effective_endpoint, passthrough_query) =
} if needs_transform && adapter.name() == "Claude" {
let api_format = resolved_claude_api_format
.as_deref()
.unwrap_or_else(|| super::providers::get_claude_api_format(provider));
rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot)
} else { } else {
endpoint (
endpoint.to_string(),
split_endpoint_and_query(endpoint)
.1
.map(ToString::to_string),
)
}; };
// 使用适配器构建 URL let url = if is_full_url {
let url = adapter.build_url(&base_url, effective_endpoint); append_query_to_full_url(&base_url, passthrough_query.as_deref())
} else {
// 应用模型映射(独立于格式转换) adapter.build_url(&base_url, &effective_endpoint)
let (mapped_body, _original_model, _mapped_model) = };
super::model_mapper::apply_model_mapping(body.clone(), provider);
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
let mapped_body = normalize_thinking_type(mapped_body);
// 转换请求体(如果需要) // 转换请求体(如果需要)
let request_body = if needs_transform { let request_body = if needs_transform {
adapter.transform_request(mapped_body, provider)? if adapter.name() == "Claude" {
let api_format = resolved_claude_api_format
.as_deref()
.unwrap_or_else(|| super::providers::get_claude_api_format(provider));
super::providers::transform_claude_request_for_api_format(
mapped_body,
provider,
api_format,
)?
} else {
adapter.transform_request(mapped_body, provider)?
}
} else { } else {
mapped_body mapped_body
}; };
@@ -841,87 +824,11 @@ impl RequestForwarder {
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游 // 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
// 默认使用空白名单,过滤所有 _ 前缀字段 // 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = filter_private_params_with_whitelist(request_body, &[]); let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
let force_identity_encoding = needs_transform
|| should_force_identity_encoding(&effective_endpoint, &filtered_body, headers);
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端 // 获取认证头(提前准备,用于内联替换)
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref()); let auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
let client = super::http_client::get_for_provider(proxy_config);
let mut request = client.post(&url);
// 只有当 timeout > 0 时才设置请求超时
// Duration::ZERO 在 reqwest 中表示"立刻超时"而不是"禁用超时"
// 故障转移关闭时会传入 0,此时应该使用 client 的默认超时(600秒)
if !self.non_streaming_timeout.is_zero() {
request = request.timeout(self.non_streaming_timeout);
}
// 过滤黑名单 Headers,保护隐私并避免冲突
for (key, value) in headers {
let key_str = key.as_str();
if HEADER_BLACKLIST
.iter()
.any(|h| key_str.eq_ignore_ascii_case(h))
{
continue;
}
// Copilot 请求:过滤会由 add_auth_headers 注入的固定指纹头,
// 防止客户端原始头与注入头重复(reqwest header() 是追加语义)
if is_copilot
&& (key_str.eq_ignore_ascii_case("user-agent")
|| key_str.eq_ignore_ascii_case("editor-version")
|| key_str.eq_ignore_ascii_case("editor-plugin-version")
|| key_str.eq_ignore_ascii_case("copilot-integration-id")
|| key_str.eq_ignore_ascii_case("x-github-api-version")
|| key_str.eq_ignore_ascii_case("openai-intent"))
{
continue;
}
request = request.header(key, value);
}
// 处理 anthropic-beta Header(仅 Claude
// 关键:确保包含 claude-code-20250219 标记,这是上游服务验证请求来源的依据
// 如果客户端发送的 beta 标记中没有包含 claude-code-20250219,需要补充
if adapter.name() == "Claude" {
const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
let beta_value = if let Some(beta) = headers.get("anthropic-beta") {
if let Ok(beta_str) = beta.to_str() {
// 检查是否已包含 claude-code-20250219
if beta_str.contains(CLAUDE_CODE_BETA) {
beta_str.to_string()
} else {
// 补充 claude-code-20250219
format!("{CLAUDE_CODE_BETA},{beta_str}")
}
} else {
CLAUDE_CODE_BETA.to_string()
}
} else {
// 如果客户端没有发送,使用默认值
CLAUDE_CODE_BETA.to_string()
};
request = request.header("anthropic-beta", &beta_value);
}
// 客户端 IP 透传(默认开启)
if let Some(xff) = headers.get("x-forwarded-for") {
if let Ok(xff_str) = xff.to_str() {
request = request.header("x-forwarded-for", xff_str);
}
}
if let Some(real_ip) = headers.get("x-real-ip") {
if let Ok(real_ip_str) = real_ip.to_str() {
request = request.header("x-real-ip", real_ip_str);
}
}
// 流式请求保守禁用压缩,避免上游压缩 SSE 在连接中断时触发解压错误。
// 非流式请求不显式设置 Accept-Encoding,让 reqwest 自动协商压缩并透明解压。
if should_force_identity_encoding(effective_endpoint, &filtered_body, headers) {
request = request.header("accept-encoding", "identity");
}
// 使用适配器添加认证头
if let Some(mut auth) = adapter.extract_auth(provider) {
// GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token // GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token
if auth.strategy == AuthStrategy::GitHubCopilot { if auth.strategy == AuthStrategy::GitHubCopilot {
if let Some(app_handle) = &self.app_handle { if let Some(app_handle) = &self.app_handle {
@@ -972,17 +879,211 @@ impl RequestForwarder {
)); ));
} }
} }
request = adapter.add_auth_headers(request, &auth); adapter.get_auth_headers(&auth)
} else {
Vec::new()
};
// Copilot 指纹头名(由 get_auth_headers 注入,需在原始头中去重)
let copilot_fingerprint_headers: &[&str] = if is_copilot {
&[
"user-agent",
"editor-version",
"editor-plugin-version",
"copilot-integration-id",
"x-github-api-version",
"openai-intent",
]
} else {
&[]
};
// 预计算上游 host 值(用于在原位替换 host header
let upstream_host = url
.parse::<http::Uri>()
.ok()
.and_then(|u| u.authority().map(|a| a.to_string()));
// 预计算 anthropic-beta 值(仅 Claude
let anthropic_beta_value = if adapter.name() == "Claude" {
const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
Some(if let Some(beta) = headers.get("anthropic-beta") {
if let Ok(beta_str) = beta.to_str() {
if beta_str.contains(CLAUDE_CODE_BETA) {
beta_str.to_string()
} else {
format!("{CLAUDE_CODE_BETA},{beta_str}")
}
} else {
CLAUDE_CODE_BETA.to_string()
}
} else {
CLAUDE_CODE_BETA.to_string()
})
} else {
None
};
// ============================================================
// 构建有序 HeaderMap — 内联替换,保持客户端原始顺序
// ============================================================
let mut ordered_headers = http::HeaderMap::new();
let mut saw_auth = false;
let mut saw_accept_encoding = false;
let mut saw_anthropic_beta = false;
let mut saw_anthropic_version = false;
for (key, value) in headers {
let key_str = key.as_str();
// --- host — 原位替换为上游 host(保持客户端原始位置) ---
if key_str.eq_ignore_ascii_case("host") {
if let Some(ref host_val) = upstream_host {
if let Ok(hv) = http::HeaderValue::from_str(host_val) {
ordered_headers.append(key.clone(), hv);
}
}
continue;
}
// --- 连接 / 追踪 / CDN 类 — 无条件跳过 ---
if matches!(
key_str,
"content-length"
| "transfer-encoding"
| "x-forwarded-host"
| "x-forwarded-port"
| "x-forwarded-proto"
| "forwarded"
| "cf-connecting-ip"
| "cf-ipcountry"
| "cf-ray"
| "cf-visitor"
| "true-client-ip"
| "fastly-client-ip"
| "x-azure-clientip"
| "x-azure-fdid"
| "x-azure-ref"
| "akamai-origin-hop"
| "x-akamai-config-log-detail"
| "x-request-id"
| "x-correlation-id"
| "x-trace-id"
| "x-amzn-trace-id"
| "x-b3-traceid"
| "x-b3-spanid"
| "x-b3-parentspanid"
| "x-b3-sampled"
| "traceparent"
| "tracestate"
) {
continue;
}
// --- 认证类 — 用 adapter 提供的认证头替换(在原始位置) ---
if key_str.eq_ignore_ascii_case("authorization")
|| key_str.eq_ignore_ascii_case("x-api-key")
|| key_str.eq_ignore_ascii_case("x-goog-api-key")
{
if !saw_auth {
saw_auth = true;
for (ah_name, ah_value) in &auth_headers {
ordered_headers.append(ah_name.clone(), ah_value.clone());
}
}
continue;
}
// --- accept-encoding — transform / SSE 路径强制 identity,其余保留原值 ---
if key_str.eq_ignore_ascii_case("accept-encoding") {
if !saw_accept_encoding {
saw_accept_encoding = true;
if force_identity_encoding {
ordered_headers.append(
http::header::ACCEPT_ENCODING,
http::HeaderValue::from_static("identity"),
);
} else {
ordered_headers.append(key.clone(), value.clone());
}
}
continue;
}
// --- anthropic-beta — 用重建值替换(确保含 claude-code 标记) ---
if key_str.eq_ignore_ascii_case("anthropic-beta") {
if !saw_anthropic_beta {
saw_anthropic_beta = true;
if let Some(ref beta_val) = anthropic_beta_value {
if let Ok(hv) = http::HeaderValue::from_str(beta_val) {
ordered_headers.append("anthropic-beta", hv);
}
}
}
continue;
}
// --- anthropic-version — 透传客户端值 ---
if key_str.eq_ignore_ascii_case("anthropic-version") {
saw_anthropic_version = true;
ordered_headers.append(key.clone(), value.clone());
continue;
}
// --- Copilot 指纹头 — 跳过(由 auth_headers 提供) ---
if copilot_fingerprint_headers
.iter()
.any(|h| key_str.eq_ignore_ascii_case(h))
{
continue;
}
// --- 默认:透传 ---
ordered_headers.append(key.clone(), value.clone());
} }
// anthropic-version 统一处理(仅 Claude):优先使用客户端的版本号,否则使用默认值 // 如果原始请求中没有认证头,在末尾追加
// 注意:只设置一次,避免重复 if !saw_auth && !auth_headers.is_empty() {
if adapter.name() == "Claude" { for (ah_name, ah_value) in &auth_headers {
let version_str = headers ordered_headers.append(ah_name.clone(), ah_value.clone());
.get("anthropic-version") }
.and_then(|v| v.to_str().ok()) }
.unwrap_or("2023-06-01");
request = request.header("anthropic-version", version_str); // transform / SSE 路径在缺失时补 identity;普通透传不主动补 accept-encoding
if !saw_accept_encoding && force_identity_encoding {
ordered_headers.append(
http::header::ACCEPT_ENCODING,
http::HeaderValue::from_static("identity"),
);
}
// 如果原始请求中没有 anthropic-beta 且有值需要添加,追加
if !saw_anthropic_beta {
if let Some(ref beta_val) = anthropic_beta_value {
if let Ok(hv) = http::HeaderValue::from_str(beta_val) {
ordered_headers.append("anthropic-beta", hv);
}
}
}
// anthropic-version:仅在缺失时补充默认值
if adapter.name() == "Claude" && !saw_anthropic_version {
ordered_headers.append(
"anthropic-version",
http::HeaderValue::from_static("2023-06-01"),
);
}
// 序列化请求体
let body_bytes = serde_json::to_vec(&filtered_body)
.map_err(|e| ProxyError::Internal(format!("Failed to serialize request body: {e}")))?;
// 确保 content-type 存在
if !ordered_headers.contains_key(http::header::CONTENT_TYPE) {
ordered_headers.insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static("application/json"),
);
} }
// 输出请求信息日志 // 输出请求信息日志
@@ -1000,25 +1101,75 @@ impl RequestForwarder {
); );
} }
// 确定超时
let timeout = if self.non_streaming_timeout.is_zero() {
std::time::Duration::from_secs(600) // 默认 600 秒
} else {
self.non_streaming_timeout
};
// 解析上游代理 URL(供应商单独代理 > 全局代理 > 无)
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
let upstream_proxy_url: Option<String> = proxy_config
.filter(|c| c.enabled)
.and_then(super::http_client::build_proxy_url_from_config)
.or_else(super::http_client::get_current_proxy_url);
// SOCKS5 代理不支持 CONNECT 隧道,需要用 reqwest
let is_socks_proxy = upstream_proxy_url
.as_deref()
.map(|u| u.starts_with("socks5"))
.unwrap_or(false);
let uri: http::Uri = url
.parse()
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?;
// 发送请求 // 发送请求
let response = request.json(&filtered_body).send().await.map_err(|e| { let response = if is_socks_proxy {
if e.is_timeout() { // SOCKS5 代理:只能走 reqwest(不支持 header case 保留)
ProxyError::Timeout(format!("请求超时: {e}")) log::debug!("[Forwarder] Using reqwest for SOCKS5 proxy");
} else if e.is_connect() { let client = super::http_client::get_for_provider(proxy_config);
ProxyError::ForwardFailed(format!("连接失败: {e}")) let mut request = client.post(&url);
} else { if !self.non_streaming_timeout.is_zero() {
ProxyError::ForwardFailed(e.to_string()) request = request.timeout(self.non_streaming_timeout);
} }
})?; for (key, value) in &ordered_headers {
request = request.header(key, value);
}
let reqwest_resp = request.body(body_bytes).send().await.map_err(|e| {
if e.is_timeout() {
ProxyError::Timeout(format!("请求超时: {e}"))
} else if e.is_connect() {
ProxyError::ForwardFailed(format!("连接失败: {e}"))
} else {
ProxyError::ForwardFailed(e.to_string())
}
})?;
ProxyResponse::Reqwest(reqwest_resp)
} else {
// HTTP 代理或直连:走 hyper raw write(保持 header 大小写)
// 如果有 HTTP 代理,hyper_client 会用 CONNECT 隧道穿过代理
super::hyper_client::send_request(
uri,
http::Method::POST,
ordered_headers,
extensions.clone(),
body_bytes,
timeout,
upstream_proxy_url.as_deref(),
)
.await?
};
// 检查响应状态 // 检查响应状态
let status = response.status(); let status = response.status();
if status.is_success() { if status.is_success() {
Ok(response) Ok((response, resolved_claude_api_format))
} else { } else {
let status_code = status.as_u16(); let status_code = status.as_u16();
let body_text = response.text().await.ok(); let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok();
Err(ProxyError::UpstreamError { Err(ProxyError::UpstreamError {
status: status_code, status: status_code,
@@ -1027,6 +1178,68 @@ impl RequestForwarder {
} }
} }
async fn resolve_claude_api_format(
&self,
provider: &Provider,
body: &Value,
is_copilot: bool,
) -> String {
if !is_copilot {
return super::providers::get_claude_api_format(provider).to_string();
}
let model = body.get("model").and_then(|value| value.as_str());
if let Some(model_id) = model {
if self
.is_copilot_openai_vendor_model(provider, model_id)
.await
{
return "openai_responses".to_string();
}
}
"openai_chat".to_string()
}
async fn is_copilot_openai_vendor_model(&self, provider: &Provider, model_id: &str) -> bool {
let Some(app_handle) = &self.app_handle else {
log::debug!("[Copilot] AppHandle unavailable, fallback to chat/completions");
return false;
};
let copilot_state = app_handle.state::<CopilotAuthState>();
let copilot_auth = copilot_state.0.read().await;
let account_id = provider
.meta
.as_ref()
.and_then(|m| m.managed_account_id_for("github_copilot"));
let vendor_result = match account_id.as_deref() {
Some(id) => {
copilot_auth
.get_model_vendor_for_account(id, model_id)
.await
}
None => copilot_auth.get_model_vendor(model_id).await,
};
match vendor_result {
Ok(Some(vendor)) => vendor.eq_ignore_ascii_case("openai"),
Ok(None) => {
log::debug!(
"[Copilot] Model vendor unavailable for {model_id}, fallback to chat/completions"
);
false
}
Err(err) => {
log::warn!(
"[Copilot] Failed to resolve model vendor for {model_id}, fallback to chat/completions: {err}"
);
false
}
}
}
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory { fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
match error { match error {
// 网络和上游错误:都应该尝试下一个供应商 // 网络和上游错误:都应该尝试下一个供应商
@@ -1173,6 +1386,78 @@ fn extract_json_error_message(body: &Value) -> Option<String> {
.find_map(|value| value.as_str().map(ToString::to_string)) .find_map(|value| value.as_str().map(ToString::to_string))
} }
fn split_endpoint_and_query(endpoint: &str) -> (&str, Option<&str>) {
endpoint
.split_once('?')
.map_or((endpoint, None), |(path, query)| (path, Some(query)))
}
fn strip_beta_query(query: Option<&str>) -> Option<String> {
let filtered = query.map(|query| {
query
.split('&')
.filter(|pair| !pair.is_empty() && !pair.starts_with("beta="))
.collect::<Vec<_>>()
.join("&")
});
match filtered.as_deref() {
Some("") | None => None,
Some(_) => filtered,
}
}
fn is_claude_messages_path(path: &str) -> bool {
matches!(path, "/v1/messages" | "/claude/v1/messages")
}
fn rewrite_claude_transform_endpoint(
endpoint: &str,
api_format: &str,
is_copilot: bool,
) -> (String, Option<String>) {
let (path, query) = split_endpoint_and_query(endpoint);
let passthrough_query = if is_claude_messages_path(path) {
strip_beta_query(query)
} else {
query.map(ToString::to_string)
};
if !is_claude_messages_path(path) {
return (endpoint.to_string(), passthrough_query);
}
let target_path = if is_copilot && api_format == "openai_responses" {
"/v1/responses"
} else if is_copilot {
"/chat/completions"
} else if api_format == "openai_responses" {
"/v1/responses"
} else {
"/v1/chat/completions"
};
let rewritten = match passthrough_query.as_deref() {
Some(query) if !query.is_empty() => format!("{target_path}?{query}"),
_ => target_path.to_string(),
};
(rewritten, passthrough_query)
}
fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String {
match query {
Some(query) if !query.is_empty() => {
if base_url.contains('?') {
format!("{base_url}&{query}")
} else {
format!("{base_url}?{query}")
}
}
_ => base_url.to_string(),
}
}
fn should_force_identity_encoding( fn should_force_identity_encoding(
endpoint: &str, endpoint: &str,
body: &Value, body: &Value,
@@ -1213,7 +1498,8 @@ fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use axum::http::{header::ACCEPT, HeaderMap, HeaderValue}; use axum::http::header::{HeaderValue, ACCEPT};
use axum::http::HeaderMap;
use serde_json::json; use serde_json::json;
#[test] #[test]
@@ -1281,6 +1567,58 @@ mod tests {
assert_eq!(summary, "line1 line2..."); assert_eq!(summary, "line1 line2...");
} }
#[test]
fn rewrite_claude_transform_endpoint_strips_beta_for_chat_completions() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
"/v1/messages?beta=true&foo=bar",
"openai_chat",
false,
);
assert_eq!(endpoint, "/v1/chat/completions?foo=bar");
assert_eq!(passthrough_query.as_deref(), Some("foo=bar"));
}
#[test]
fn rewrite_claude_transform_endpoint_strips_beta_for_responses() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
"/claude/v1/messages?beta=true&x-id=1",
"openai_responses",
false,
);
assert_eq!(endpoint, "/v1/responses?x-id=1");
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
}
#[test]
fn rewrite_claude_transform_endpoint_uses_copilot_path() {
let (endpoint, passthrough_query) =
rewrite_claude_transform_endpoint("/v1/messages?beta=true&x-id=1", "anthropic", true);
assert_eq!(endpoint, "/chat/completions?x-id=1");
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
}
#[test]
fn rewrite_claude_transform_endpoint_uses_copilot_responses_path() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
"/v1/messages?beta=true&x-id=1",
"openai_responses",
true,
);
assert_eq!(endpoint, "/v1/responses?x-id=1");
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
}
#[test]
fn append_query_to_full_url_preserves_existing_query_string() {
let url = append_query_to_full_url("https://relay.example/api?foo=bar", Some("x-id=1"));
assert_eq!(url, "https://relay.example/api?foo=bar&x-id=1");
}
#[test] #[test]
fn force_identity_for_stream_flag_requests() { fn force_identity_for_stream_flag_requests() {
let headers = HeaderMap::new(); let headers = HeaderMap::new();
+112 -29
View File
@@ -18,7 +18,10 @@ use super::{
streaming_responses::create_anthropic_sse_stream_from_responses, transform, streaming_responses::create_anthropic_sse_stream_from_responses, transform,
transform_responses, transform_responses,
}, },
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector}, response_processor::{
create_logged_passthrough_stream, process_response, read_decoded_body,
strip_entity_headers_for_rebuilt_body, SseUsageCollector,
},
server::ProxyState, server::ProxyState,
types::*, types::*,
usage::parser::TokenUsage, usage::parser::TokenUsage,
@@ -27,6 +30,7 @@ use super::{
use crate::app_config::AppType; use crate::app_config::AppType;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use bytes::Bytes; use bytes::Bytes;
use http_body_util::BodyExt;
use serde_json::{json, Value}; use serde_json::{json, Value};
// ============================================================================ // ============================================================================
@@ -61,12 +65,28 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退) /// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
pub async fn handle_messages( pub async fn handle_messages(
State(state): State<ProxyState>, State(state): State<ProxyState>,
headers: axum::http::HeaderMap, request: axum::extract::Request,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let (parts, body) = request.into_parts();
let uri = parts.uri;
let headers = parts.headers;
let extensions = parts.extensions;
let body_bytes = body
.collect()
.await
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
.to_bytes();
let body: Value = serde_json::from_slice(&body_bytes)
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx = let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?; RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
let endpoint = uri
.path_and_query()
.map(|path_and_query| path_and_query.as_str())
.unwrap_or(uri.path());
let is_stream = body let is_stream = body
.get("stream") .get("stream")
.and_then(|s| s.as_bool()) .and_then(|s| s.as_bool())
@@ -77,9 +97,10 @@ pub async fn handle_messages(
let result = match forwarder let result = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Claude, &AppType::Claude,
"/v1/messages", endpoint,
body.clone(), body.clone(),
headers, headers,
extensions,
ctx.get_providers(), ctx.get_providers(),
) )
.await .await
@@ -95,6 +116,11 @@ pub async fn handle_messages(
}; };
ctx.provider = result.provider; ctx.provider = result.provider;
let api_format = result
.claude_api_format
.as_deref()
.unwrap_or_else(|| get_claude_api_format(&ctx.provider))
.to_string();
let response = result.response; let response = result.response;
// 检查是否需要格式转换(OpenRouter 等中转服务) // 检查是否需要格式转换(OpenRouter 等中转服务)
@@ -103,7 +129,8 @@ pub async fn handle_messages(
// Claude 特有:格式转换处理 // Claude 特有:格式转换处理
if needs_transform { if needs_transform {
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await; return handle_claude_transform(response, &ctx, &state, &body, is_stream, &api_format)
.await;
} }
// 通用响应处理(透传模式) // 通用响应处理(透传模式)
@@ -114,14 +141,14 @@ pub async fn handle_messages(
/// ///
/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换 /// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换
async fn handle_claude_transform( async fn handle_claude_transform(
response: reqwest::Response, response: super::hyper_client::ProxyResponse,
ctx: &RequestContext, ctx: &RequestContext,
state: &ProxyState, state: &ProxyState,
_original_body: &Value, _original_body: &Value,
is_stream: bool, is_stream: bool,
api_format: &str,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let status = response.status(); let status = response.status();
let api_format = get_claude_api_format(&ctx.provider);
if is_stream { if is_stream {
// 根据 api_format 选择流式转换器 // 根据 api_format 选择流式转换器
@@ -199,12 +226,14 @@ async fn handle_claude_transform(
} }
// 非流式响应转换 (OpenAI/Responses → Anthropic) // 非流式响应转换 (OpenAI/Responses → Anthropic)
let response_headers = response.headers().clone(); let body_timeout =
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
let body_bytes = response.bytes().await.map_err(|e| { std::time::Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
log::error!("[Claude] 读取响应体失败: {e}"); } else {
ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) std::time::Duration::ZERO
})?; };
let (mut response_headers, _status, body_bytes) =
read_decoded_body(response, ctx.tag, body_timeout).await?;
let body_str = String::from_utf8_lossy(&body_bytes); let body_str = String::from_utf8_lossy(&body_bytes);
@@ -257,13 +286,10 @@ async fn handle_claude_transform(
// 构建响应 // 构建响应
let mut builder = axum::response::Response::builder().status(status); let mut builder = axum::response::Response::builder().status(status);
strip_entity_headers_for_rebuilt_body(&mut response_headers);
for (key, value) in response_headers.iter() { for (key, value) in response_headers.iter() {
if key.as_str().to_lowercase() != "content-length" builder = builder.header(key, value);
&& key.as_str().to_lowercase() != "transfer-encoding"
{
builder = builder.header(key, value);
}
} }
builder = builder.header("content-type", "application/json"); builder = builder.header("content-type", "application/json");
@@ -280,6 +306,13 @@ async fn handle_claude_transform(
}) })
} }
fn endpoint_with_query(uri: &axum::http::Uri, endpoint: &str) -> String {
match uri.query() {
Some(query) => format!("{endpoint}?{query}"),
None => endpoint.to_string(),
}
}
// ============================================================================ // ============================================================================
// Codex API 处理器 // Codex API 处理器
// ============================================================================ // ============================================================================
@@ -287,11 +320,23 @@ async fn handle_claude_transform(
/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI /// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI
pub async fn handle_chat_completions( pub async fn handle_chat_completions(
State(state): State<ProxyState>, State(state): State<ProxyState>,
headers: axum::http::HeaderMap, request: axum::extract::Request,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let (parts, req_body) = request.into_parts();
let uri = parts.uri;
let headers = parts.headers;
let extensions = parts.extensions;
let body_bytes = req_body
.collect()
.await
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
.to_bytes();
let body: Value = serde_json::from_slice(&body_bytes)
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx = let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let endpoint = endpoint_with_query(&uri, "/chat/completions");
let is_stream = body let is_stream = body
.get("stream") .get("stream")
@@ -302,9 +347,10 @@ pub async fn handle_chat_completions(
let result = match forwarder let result = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Codex, &AppType::Codex,
"/chat/completions", &endpoint,
body, body,
headers, headers,
extensions,
ctx.get_providers(), ctx.get_providers(),
) )
.await .await
@@ -328,11 +374,23 @@ pub async fn handle_chat_completions(
/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传) /// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传)
pub async fn handle_responses( pub async fn handle_responses(
State(state): State<ProxyState>, State(state): State<ProxyState>,
headers: axum::http::HeaderMap, request: axum::extract::Request,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let (parts, req_body) = request.into_parts();
let uri = parts.uri;
let headers = parts.headers;
let extensions = parts.extensions;
let body_bytes = req_body
.collect()
.await
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
.to_bytes();
let body: Value = serde_json::from_slice(&body_bytes)
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx = let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let endpoint = endpoint_with_query(&uri, "/responses");
let is_stream = body let is_stream = body
.get("stream") .get("stream")
@@ -343,9 +401,10 @@ pub async fn handle_responses(
let result = match forwarder let result = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Codex, &AppType::Codex,
"/responses", &endpoint,
body, body,
headers, headers,
extensions,
ctx.get_providers(), ctx.get_providers(),
) )
.await .await
@@ -369,11 +428,23 @@ pub async fn handle_responses(
/// 处理 /v1/responses/compact 请求(OpenAI Responses Compact API - Codex CLI 透传) /// 处理 /v1/responses/compact 请求(OpenAI Responses Compact API - Codex CLI 透传)
pub async fn handle_responses_compact( pub async fn handle_responses_compact(
State(state): State<ProxyState>, State(state): State<ProxyState>,
headers: axum::http::HeaderMap, request: axum::extract::Request,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let (parts, req_body) = request.into_parts();
let uri = parts.uri;
let headers = parts.headers;
let extensions = parts.extensions;
let body_bytes = req_body
.collect()
.await
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
.to_bytes();
let body: Value = serde_json::from_slice(&body_bytes)
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx = let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let endpoint = endpoint_with_query(&uri, "/responses/compact");
let is_stream = body let is_stream = body
.get("stream") .get("stream")
@@ -384,9 +455,10 @@ pub async fn handle_responses_compact(
let result = match forwarder let result = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Codex, &AppType::Codex,
"/responses/compact", &endpoint,
body, body,
headers, headers,
extensions,
ctx.get_providers(), ctx.get_providers(),
) )
.await .await
@@ -415,9 +487,19 @@ pub async fn handle_responses_compact(
pub async fn handle_gemini( pub async fn handle_gemini(
State(state): State<ProxyState>, State(state): State<ProxyState>,
uri: axum::http::Uri, uri: axum::http::Uri,
headers: axum::http::HeaderMap, request: axum::extract::Request,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let (parts, req_body) = request.into_parts();
let headers = parts.headers;
let extensions = parts.extensions;
let body_bytes = req_body
.collect()
.await
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
.to_bytes();
let body: Value = serde_json::from_slice(&body_bytes)
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
// Gemini 的模型名称在 URI 中 // Gemini 的模型名称在 URI 中
let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini") let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini")
.await? .await?
@@ -441,6 +523,7 @@ pub async fn handle_gemini(
endpoint, endpoint,
body, body,
headers, headers,
extensions,
ctx.get_providers(), ctx.get_providers(),
) )
.await .await
+10 -2
View File
@@ -219,7 +219,12 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
.timeout(Duration::from_secs(600)) .timeout(Duration::from_secs(600))
.connect_timeout(Duration::from_secs(30)) .connect_timeout(Duration::from_secs(30))
.pool_max_idle_per_host(10) .pool_max_idle_per_host(10)
.tcp_keepalive(Duration::from_secs(60)); .tcp_keepalive(Duration::from_secs(60))
// 禁用 reqwest 自动解压:防止 reqwest 覆盖客户端原始 accept-encoding header。
// 响应解压由 response_processor 根据 content-encoding 手动处理。
.no_gzip()
.no_brotli()
.no_deflate();
// 有代理地址则使用代理,否则跟随系统代理 // 有代理地址则使用代理,否则跟随系统代理
if let Some(url) = proxy_url { if let Some(url) = proxy_url {
@@ -332,7 +337,7 @@ pub fn mask_url(url: &str) -> String {
/// 根据供应商单独代理配置构建代理 URL /// 根据供应商单独代理配置构建代理 URL
/// ///
/// 将 ProviderProxyConfig 转换为代理 URL 字符串 /// 将 ProviderProxyConfig 转换为代理 URL 字符串
fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> { pub fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> {
let proxy_type = config.proxy_type.as_deref().unwrap_or("http"); let proxy_type = config.proxy_type.as_deref().unwrap_or("http");
let host = config.proxy_host.as_deref()?; let host = config.proxy_host.as_deref()?;
let port = config.proxy_port?; let port = config.proxy_port?;
@@ -387,6 +392,9 @@ pub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) ->
.connect_timeout(Duration::from_secs(30)) .connect_timeout(Duration::from_secs(30))
.pool_max_idle_per_host(10) .pool_max_idle_per_host(10)
.tcp_keepalive(Duration::from_secs(60)) .tcp_keepalive(Duration::from_secs(60))
.no_gzip()
.no_brotli()
.no_deflate()
.proxy(proxy) .proxy(proxy)
.build() .build()
{ {
+685
View File
@@ -0,0 +1,685 @@
//! Hyper-based HTTP client for proxy forwarding
//!
//! Uses raw TCP/TLS writes to preserve exact original header name casing.
//! Supports HTTP CONNECT tunneling through upstream proxies.
//! Falls back to hyper-util Client (title-case headers) when raw write is not feasible.
use super::ProxyError;
use bytes::Bytes;
use futures::stream::Stream;
use http_body_util::BodyExt;
use hyper_rustls::HttpsConnectorBuilder;
use hyper_util::{client::legacy::Client, rt::TokioExecutor};
use std::sync::OnceLock;
/// Our own header case map: maps lowercase header name → original wire-casing bytes.
///
/// This is a backup mechanism independent of hyper's internal `HeaderCaseMap` (which is
/// `pub(crate)` and cannot be directly inspected or constructed from outside hyper).
///
/// Populated in `server.rs` by peeking at raw TCP bytes before hyper parses them.
/// Used in `send_request` to manually write headers with original casing when hyper's
/// own mechanism fails.
#[derive(Clone, Debug, Default)]
pub(crate) struct OriginalHeaderCases {
/// Ordered list of (lowercase_name, original_wire_bytes) pairs.
/// Multiple entries with the same name are allowed (for repeated headers).
pub cases: Vec<(String, Vec<u8>)>,
}
impl OriginalHeaderCases {
/// Parse raw HTTP request bytes (from TcpStream::peek) to extract original header casings.
pub fn from_raw_bytes(buf: &[u8]) -> Self {
let mut headers_buf = [httparse::EMPTY_HEADER; 128];
let mut req = httparse::Request::new(&mut headers_buf);
// We don't care if parsing is partial — we just want the header names we can get
let _ = req.parse(buf);
let mut cases = Vec::new();
for header in req.headers.iter() {
if header.name.is_empty() {
break;
}
cases.push((
header.name.to_ascii_lowercase(),
header.name.as_bytes().to_vec(),
));
}
Self { cases }
}
}
type HyperClient = Client<
hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>,
http_body_util::Full<Bytes>,
>;
/// Lazily-initialized hyper client with header-case preservation enabled.
fn global_hyper_client() -> &'static HyperClient {
static CLIENT: OnceLock<HyperClient> = OnceLock::new();
CLIENT.get_or_init(|| {
let connector = HttpsConnectorBuilder::new()
.with_webpki_roots()
.https_or_http()
.enable_http1()
.build();
Client::builder(TokioExecutor::new())
.http1_preserve_header_case(true)
.http1_title_case_headers(true)
.build(connector)
})
}
/// Unified response wrapper that can hold either a hyper or reqwest response.
///
/// The hyper variant is used for the main (direct) path with header-case preservation.
/// The reqwest variant is the fallback when an upstream HTTP/SOCKS5 proxy is configured.
pub enum ProxyResponse {
Hyper(hyper::Response<hyper::body::Incoming>),
Reqwest(reqwest::Response),
}
impl ProxyResponse {
pub fn status(&self) -> http::StatusCode {
match self {
Self::Hyper(r) => r.status(),
Self::Reqwest(r) => r.status(),
}
}
pub fn headers(&self) -> &http::HeaderMap {
match self {
Self::Hyper(r) => r.headers(),
Self::Reqwest(r) => r.headers(),
}
}
/// Shortcut: extract `content-type` header value as `&str`.
pub fn content_type(&self) -> Option<&str> {
self.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
}
/// Check if the response is an SSE stream.
pub fn is_sse(&self) -> bool {
self.content_type()
.map(|ct| ct.contains("text/event-stream"))
.unwrap_or(false)
}
/// Consume the response and collect the full body into `Bytes`.
pub async fn bytes(self) -> Result<Bytes, ProxyError> {
match self {
Self::Hyper(r) => {
let collected = r.into_body().collect().await.map_err(|e| {
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
})?;
Ok(collected.to_bytes())
}
Self::Reqwest(r) => r.bytes().await.map_err(|e| {
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
}),
}
}
/// Consume the response and return a byte-chunk stream (for SSE pass-through).
pub fn bytes_stream(self) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
use futures::StreamExt;
match self {
Self::Hyper(r) => {
let body = r.into_body();
let stream = futures::stream::unfold(body, |mut body| async {
match body.frame().await {
Some(Ok(frame)) => {
if let Ok(data) = frame.into_data() {
if data.is_empty() {
Some((Ok(Bytes::new()), body))
} else {
Some((Ok(data), body))
}
} else {
Some((Ok(Bytes::new()), body))
}
}
Some(Err(e)) => Some((Err(std::io::Error::other(e.to_string())), body)),
None => None,
}
})
.filter(|result| {
futures::future::ready(!matches!(result, Ok(ref b) if b.is_empty()))
});
Box::pin(stream)
as std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>
}
Self::Reqwest(r) => {
let stream = r
.bytes_stream()
.map(|r| r.map_err(|e| std::io::Error::other(e.to_string())));
Box::pin(stream)
}
}
}
}
/// Send an HTTP request with header-case preservation.
///
/// Uses a two-tier strategy:
/// 1. Primary: raw HTTP/1.1 write via TLS stream with exact original header casing
/// (from `OriginalHeaderCases` captured by peek in server.rs), then hand off to
/// hyper for response parsing.
/// 2. Fallback: hyper-util Client with `title_case_headers(true)` when raw write
/// isn't feasible (e.g., missing original cases).
///
/// The caller is expected to include `Host` in the supplied `headers` at the
/// correct position.
///
/// `proxy_url`: optional upstream HTTP proxy URL (e.g. `http://127.0.0.1:7890`).
/// When set, the raw write path uses HTTP CONNECT tunneling through the proxy,
/// so header-case preservation works even when an upstream proxy is configured.
pub async fn send_request(
uri: http::Uri,
method: http::Method,
headers: http::HeaderMap,
original_extensions: http::Extensions,
body: Vec<u8>,
timeout: std::time::Duration,
proxy_url: Option<&str>,
) -> Result<ProxyResponse, ProxyError> {
// Extract our own OriginalHeaderCases if available
let original_cases = original_extensions.get::<OriginalHeaderCases>().cloned();
let has_cases = original_cases
.as_ref()
.map(|c| !c.cases.is_empty())
.unwrap_or(false);
log::debug!(
"[HyperClient] Sending request: uri={uri}, header_count={}, \
has_host={}, has_original_cases={has_cases}, proxy={:?}",
headers.len(),
headers.contains_key(http::header::HOST),
proxy_url,
);
if has_cases {
// Primary path: use raw write + hyper handshake for exact header casing
let result = tokio::time::timeout(
timeout,
send_raw_request(
&uri,
&method,
&headers,
original_cases.as_ref().unwrap(),
&body,
proxy_url,
),
)
.await
.map_err(|_| ProxyError::Timeout(format!("请求超时: {}s", timeout.as_secs())))?;
match result {
Ok(resp) => return Ok(resp),
Err(e) => {
if proxy_url.is_some() {
// Don't bypass configured proxy with direct connect fallback
return Err(e);
}
log::warn!("[HyperClient] Raw write failed, falling back to hyper-util: {e}");
// Fall through to hyper-util Client
}
}
}
// Fallback: hyper-util Client (title-case headers, no proxy support)
let mut req = http::Request::builder()
.method(method)
.uri(&uri)
.body(http_body_util::Full::new(Bytes::from(body)))
.map_err(|e| ProxyError::ForwardFailed(format!("Failed to build request: {e}")))?;
*req.headers_mut() = headers;
*req.extensions_mut() = original_extensions;
let client = global_hyper_client();
let resp = tokio::time::timeout(timeout, client.request(req))
.await
.map_err(|_| ProxyError::Timeout(format!("请求超时: {}s", timeout.as_secs())))?
.map_err(|e| ProxyError::ForwardFailed(format!("上游请求失败: {e}")))?;
Ok(ProxyResponse::Hyper(resp))
}
/// TCP or TLS stream returned by `connect_via_proxy`.
///
/// When the proxy URL uses `https://`, the connection to the proxy itself is
/// TLS-wrapped before sending the CONNECT request. The enum lets
/// `send_raw_request` work with either variant generically.
enum ProxyStream {
Tcp(tokio::net::TcpStream),
Tls(Box<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>),
}
impl tokio::io::AsyncRead for ProxyStream {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match self.get_mut() {
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_read(cx, buf),
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_read(cx, buf),
}
}
}
impl tokio::io::AsyncWrite for ProxyStream {
fn poll_write(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
match self.get_mut() {
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_write(cx, buf),
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_write(cx, buf),
}
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match self.get_mut() {
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_flush(cx),
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_flush(cx),
}
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match self.get_mut() {
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_shutdown(cx),
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_shutdown(cx),
}
}
}
/// Send request via raw TCP/TLS with exact original header casing.
///
/// When `proxy_url` is provided, establishes an HTTP CONNECT tunnel through
/// the proxy first, then performs TLS + raw write through the tunnel.
/// This preserves header casing even when an upstream proxy is configured.
async fn send_raw_request(
uri: &http::Uri,
method: &http::Method,
headers: &http::HeaderMap,
original_cases: &OriginalHeaderCases,
body: &[u8],
proxy_url: Option<&str>,
) -> Result<ProxyResponse, ProxyError> {
use tokio::io::AsyncWriteExt;
let scheme = uri.scheme_str().unwrap_or("https");
let host = uri
.host()
.ok_or_else(|| ProxyError::ForwardFailed("URI has no host".into()))?;
let port = uri
.port_u16()
.unwrap_or(if scheme == "https" { 443 } else { 80 });
let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
// Build raw HTTP request bytes
let raw = build_raw_request(method, path_and_query, headers, original_cases, body);
// Establish TCP connection — either direct or through HTTP CONNECT proxy
let stream = if let Some(proxy) = proxy_url {
connect_via_proxy(proxy, host, port).await?
} else {
ProxyStream::Tcp(
tokio::net::TcpStream::connect((host, port))
.await
.map_err(|e| ProxyError::ForwardFailed(format!("TCP connect failed: {e}")))?,
)
};
if scheme == "https" {
let tls_connector = global_tls_connector();
let server_name = rustls::pki_types::ServerName::try_from(host.to_string())
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid server name: {e}")))?;
let mut tls_stream = tls_connector
.connect(server_name, stream)
.await
.map_err(|e| ProxyError::ForwardFailed(format!("TLS handshake failed: {e}")))?;
tls_stream
.write_all(&raw)
.await
.map_err(|e| ProxyError::ForwardFailed(format!("Write failed: {e}")))?;
let filtered = WriteFilter::new(tls_stream);
do_hyper_response(filtered, method.clone()).await
} else {
let mut stream = stream;
stream
.write_all(&raw)
.await
.map_err(|e| ProxyError::ForwardFailed(format!("Write failed: {e}")))?;
let filtered = WriteFilter::new(stream);
do_hyper_response(filtered, method.clone()).await
}
}
/// Establish a connection through an HTTP CONNECT proxy tunnel.
///
/// 1. Connect TCP to the proxy server (TLS-wrapped when `https://` proxy)
/// 2. Send `CONNECT host:port HTTP/1.1` with optional `Proxy-Authorization`
/// 3. Read the proxy's 200 response (407 → `AuthError`)
/// 4. Return the tunneled stream (ready for target TLS handshake + raw write)
async fn connect_via_proxy(
proxy_url: &str,
target_host: &str,
target_port: u16,
) -> Result<ProxyStream, ProxyError> {
use base64::Engine;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let parsed = url::Url::parse(proxy_url)
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid proxy URL: {e}")))?;
let proxy_host = parsed
.host_str()
.ok_or_else(|| ProxyError::ForwardFailed("Proxy URL has no host".into()))?;
let proxy_port = parsed
.port()
.unwrap_or(if parsed.scheme() == "https" { 443 } else { 80 });
// Build Proxy-Authorization header if credentials are present
let proxy_auth = if !parsed.username().is_empty() {
let password = parsed.password().unwrap_or("");
let credentials = format!("{}:{}", parsed.username(), password);
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
Some(format!("Proxy-Authorization: Basic {encoded}\r\n"))
} else {
None
};
// Connect to the proxy
let tcp = tokio::net::TcpStream::connect((proxy_host, proxy_port))
.await
.map_err(|e| ProxyError::ForwardFailed(format!("Proxy TCP connect failed: {e}")))?;
// Wrap with TLS if the proxy URL uses https://
let mut stream: ProxyStream = if parsed.scheme() == "https" {
let tls_connector = global_tls_connector();
let server_name = rustls::pki_types::ServerName::try_from(proxy_host.to_string())
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid proxy server name: {e}")))?;
let tls_stream = tls_connector
.connect(server_name, tcp)
.await
.map_err(|e| ProxyError::ForwardFailed(format!("Proxy TLS handshake failed: {e}")))?;
ProxyStream::Tls(Box::new(tls_stream))
} else {
ProxyStream::Tcp(tcp)
};
// Send CONNECT request
let mut connect_req = format!(
"CONNECT {target_host}:{target_port} HTTP/1.1\r\n\
Host: {target_host}:{target_port}\r\n"
);
if let Some(auth) = &proxy_auth {
connect_req.push_str(auth);
}
connect_req.push_str("\r\n");
stream
.write_all(connect_req.as_bytes())
.await
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT write failed: {e}")))?;
// Read the proxy's response status line
let mut reader = BufReader::new(&mut stream);
let mut status_line = String::new();
reader
.read_line(&mut status_line)
.await
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT read failed: {e}")))?;
// Expect "HTTP/1.1 200 ..." or "HTTP/1.0 200 ..."
if !status_line.contains(" 200 ") {
if status_line.contains(" 407 ") {
return Err(ProxyError::AuthError(format!(
"Proxy authentication required (407): {}",
status_line.trim()
)));
}
return Err(ProxyError::ForwardFailed(format!(
"Proxy CONNECT rejected: {}",
status_line.trim()
)));
}
// Drain remaining response headers (until empty line)
loop {
let mut line = String::new();
reader
.read_line(&mut line)
.await
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT header read: {e}")))?;
if line.trim().is_empty() {
break;
}
}
// BufReader might have buffered data; drop it to get raw stream back.
// Since CONNECT response is headers-only (no body), and we read until \r\n\r\n,
// the BufReader buffer should be empty at this point.
drop(reader);
log::debug!(
"[HyperClient] CONNECT tunnel established via {proxy_host}:{proxy_port} -> {target_host}:{target_port}"
);
Ok(stream)
}
/// Lazily-initialized TLS connector for raw connections.
///
/// Loads both webpki roots AND native system certificates so that
/// proxy MITM CAs (e.g. Clash, mitmproxy) installed in the system
/// keychain are trusted through the CONNECT tunnel.
fn global_tls_connector() -> &'static tokio_rustls::TlsConnector {
static CONNECTOR: OnceLock<tokio_rustls::TlsConnector> = OnceLock::new();
CONNECTOR.get_or_init(|| {
let mut root_store = rustls::RootCertStore::empty();
// Baseline: Mozilla/webpki roots
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
// Native system certs (includes user-installed proxy CAs)
let native = rustls_native_certs::load_native_certs();
let (added, _errors) = root_store.add_parsable_certificates(native.certs);
log::debug!("[HyperClient] TLS root store: webpki + {added} native certs");
let config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
tokio_rustls::TlsConnector::from(std::sync::Arc::new(config))
})
}
/// Build raw HTTP/1.1 request bytes with original header casing.
fn build_raw_request(
method: &http::Method,
path_and_query: &str,
headers: &http::HeaderMap,
original_cases: &OriginalHeaderCases,
body: &[u8],
) -> Vec<u8> {
let mut raw = Vec::with_capacity(4096 + body.len());
// Request line
raw.extend_from_slice(method.as_str().as_bytes());
raw.extend_from_slice(b" ");
raw.extend_from_slice(path_and_query.as_bytes());
raw.extend_from_slice(b" HTTP/1.1\r\n");
// Headers with original casing, emitted in original wire order.
//
// Strategy:
// 1. Walk `original_cases.cases` in order — this preserves the exact
// header sequence the client sent. For each entry, emit the stored
// original-casing name plus the current value from `headers` (the
// proxy may have rewritten the value, e.g. Authorization).
// Repeated headers with the same name are handled by tracking a
// per-name value cursor so we step through `get_all()` in order.
// 2. After the original headers, append any headers that exist in
// `headers` but were not present in the original request (i.e. added
// by the proxy). These are emitted in lowercase.
//
// This replaces the old `for name in headers.keys()` loop which iterated
// in hash-map order, destroying the original header sequence.
let mut emitted: std::collections::HashSet<String> =
std::collections::HashSet::with_capacity(original_cases.cases.len());
// Per-name cursor: how many values we have already emitted for each name.
let mut value_cursor: std::collections::HashMap<String, usize> =
std::collections::HashMap::with_capacity(original_cases.cases.len());
for (lower_name, orig_name_bytes) in &original_cases.cases {
if let Ok(header_name) = http::header::HeaderName::from_bytes(lower_name.as_bytes()) {
let all_values: Vec<_> = headers.get_all(&header_name).iter().collect();
let cursor = value_cursor.entry(lower_name.clone()).or_insert(0);
if let Some(value) = all_values.get(*cursor) {
raw.extend_from_slice(orig_name_bytes);
raw.extend_from_slice(b": ");
raw.extend_from_slice(value.as_bytes());
raw.extend_from_slice(b"\r\n");
*cursor += 1;
emitted.insert(lower_name.clone());
}
}
}
// Append proxy-added headers (not present in the original request).
for name in headers.keys() {
let lower = name.as_str().to_ascii_lowercase();
if !emitted.contains(&lower) {
for value in headers.get_all(name) {
raw.extend_from_slice(name.as_str().as_bytes());
raw.extend_from_slice(b": ");
raw.extend_from_slice(value.as_bytes());
raw.extend_from_slice(b"\r\n");
}
emitted.insert(lower);
}
}
// Add Content-Length if not already present
if !headers.contains_key(http::header::CONTENT_LENGTH) {
raw.extend_from_slice(b"Content-Length: ");
raw.extend_from_slice(body.len().to_string().as_bytes());
raw.extend_from_slice(b"\r\n");
}
// End of headers + body
raw.extend_from_slice(b"\r\n");
raw.extend_from_slice(body);
raw
}
/// Use hyper's low-level client to parse the response on a stream where we've
/// already written the request.
///
/// `WriteFilter` discards any writes from hyper (it would try to send its own
/// request encoding), while passing reads through transparently.
async fn do_hyper_response<S>(
stream: WriteFilter<S>,
method: http::Method,
) -> Result<ProxyResponse, ProxyError>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let io = hyper_util::rt::TokioIo::new(stream);
let (mut sender, conn) = hyper::client::conn::http1::Builder::new()
.preserve_header_case(true)
.handshake::<_, http_body_util::Full<Bytes>>(io)
.await
.map_err(|e| ProxyError::ForwardFailed(format!("Handshake failed: {e}")))?;
// Spawn the connection driver (reads responses from the stream)
tokio::spawn(async move {
if let Err(e) = conn.await {
log::debug!("[HyperClient] raw conn driver error: {e}");
}
});
// Send a dummy request through hyper — hyper will encode this and try to write it,
// but WriteFilter discards all writes. Hyper will then read the response from the stream.
let dummy_req = http::Request::builder()
.method(method)
.uri("/")
.body(http_body_util::Full::new(Bytes::new()))
.map_err(|e| ProxyError::ForwardFailed(format!("Build dummy request: {e}")))?;
let resp = sender
.send_request(dummy_req)
.await
.map_err(|e| ProxyError::ForwardFailed(format!("Response parse failed: {e}")))?;
Ok(ProxyResponse::Hyper(resp))
}
/// A stream wrapper that discards all writes but passes reads through.
///
/// This lets hyper's connection driver think it sent a request (its encoded bytes
/// go to /dev/null), while correctly parsing the response that the upstream server
/// sends in reply to our raw-written request.
struct WriteFilter<S> {
inner: S,
}
impl<S> WriteFilter<S> {
fn new(inner: S) -> Self {
Self { inner }
}
}
impl<S: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for WriteFilter<S> {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
// Pass reads through to the underlying stream
let inner = std::pin::Pin::new(&mut self.get_mut().inner);
inner.poll_read(cx, buf)
}
}
impl<S: Unpin> tokio::io::AsyncWrite for WriteFilter<S> {
fn poll_write(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
// Discard all writes — pretend they succeeded
std::task::Poll::Ready(Ok(buf.len()))
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
}
+2
View File
@@ -26,6 +26,8 @@ pub mod srv {
pub const STOPPED: &str = "SRV-002"; pub const STOPPED: &str = "SRV-002";
pub const STOP_TIMEOUT: &str = "SRV-003"; pub const STOP_TIMEOUT: &str = "SRV-003";
pub const TASK_ERROR: &str = "SRV-004"; pub const TASK_ERROR: &str = "SRV-004";
pub const ACCEPT_ERR: &str = "SRV-005";
pub const CONN_ERR: &str = "SRV-006";
} }
/// 转发器日志码 /// 转发器日志码
+2
View File
@@ -14,6 +14,7 @@ pub mod handler_context;
mod handlers; mod handlers;
mod health; mod health;
pub mod http_client; pub mod http_client;
pub mod hyper_client;
pub mod log_codes; pub mod log_codes;
pub mod model_mapper; pub mod model_mapper;
pub mod provider_router; pub mod provider_router;
@@ -23,6 +24,7 @@ pub mod response_processor;
pub(crate) mod server; pub(crate) mod server;
pub mod session; pub mod session;
pub(crate) mod sse; pub(crate) mod sse;
pub(crate) mod switch_lock;
pub mod thinking_budget_rectifier; pub mod thinking_budget_rectifier;
pub mod thinking_optimizer; pub mod thinking_optimizer;
pub mod thinking_rectifier; pub mod thinking_rectifier;
+4 -85
View File
@@ -5,7 +5,6 @@
use super::auth::AuthInfo; use super::auth::AuthInfo;
use crate::provider::Provider; use crate::provider::Provider;
use crate::proxy::error::ProxyError; use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder;
use serde_json::Value; use serde_json::Value;
/// 供应商适配器 Trait /// 供应商适配器 Trait
@@ -14,116 +13,36 @@ use serde_json::Value;
/// - URL 构建 /// - URL 构建
/// - 认证信息提取和头部注入 /// - 认证信息提取和头部注入
/// - 请求/响应格式转换(可选) /// - 请求/响应格式转换(可选)
///
/// # 示例
///
/// ```ignore
/// pub struct ClaudeAdapter;
///
/// impl ProviderAdapter for ClaudeAdapter {
/// fn name(&self) -> &'static str { "Claude" }
///
/// fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
/// // 从 provider 配置中提取 base_url
/// }
///
/// fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
/// // 从 provider 配置中提取认证信息
/// }
///
/// fn build_url(&self, base_url: &str, endpoint: &str) -> String {
/// format!("{}{}", base_url.trim_end_matches('/'), endpoint)
/// }
///
/// fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
/// // 添加认证头
/// }
/// }
/// ```
pub trait ProviderAdapter: Send + Sync { pub trait ProviderAdapter: Send + Sync {
/// 适配器名称(用于日志和调试) /// 适配器名称(用于日志和调试)
fn name(&self) -> &'static str; fn name(&self) -> &'static str;
/// 从 Provider 配置中提取 base_url /// 从 Provider 配置中提取 base_url
///
/// # Arguments
/// * `provider` - Provider 配置
///
/// # Returns
/// * `Ok(String)` - 提取到的 base_url(已去除尾部斜杠)
/// * `Err(ProxyError)` - 提取失败
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError>; fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError>;
/// 从 Provider 配置中提取认证信息 /// 从 Provider 配置中提取认证信息
///
/// # Arguments
/// * `provider` - Provider 配置
///
/// # Returns
/// * `Some(AuthInfo)` - 提取到的认证信息
/// * `None` - 未找到认证信息
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo>; fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo>;
/// 构建请求 URL /// 构建请求 URL
///
/// # Arguments
/// * `base_url` - 基础 URL
/// * `endpoint` - 请求端点(如 `/v1/messages`
///
/// # Returns
/// 完整的请求 URL
fn build_url(&self, base_url: &str, endpoint: &str) -> String; fn build_url(&self, base_url: &str, endpoint: &str) -> String;
/// 添加认证头到请求 /// Return auth headers as `(name, value)` pairs.
/// ///
/// # Arguments /// The forwarder inserts these at the position of the original auth header
/// * `request` - reqwest RequestBuilder /// so that header order is preserved.
/// * `auth` - 认证信息 fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)>;
///
/// # Returns
/// 添加了认证头的 RequestBuilder
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder;
/// 是否需要格式转换 /// 是否需要格式转换
///
/// 默认返回 `false`(透传模式)。
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。
///
/// # Arguments
/// * `provider` - Provider 配置
fn needs_transform(&self, _provider: &Provider) -> bool { fn needs_transform(&self, _provider: &Provider) -> bool {
false false
} }
/// 转换请求体 /// 转换请求体
///
/// 将请求体从一种格式转换为另一种格式(如 Anthropic → OpenAI)。
/// 默认实现直接返回原始请求体(透传)。
///
/// # Arguments
/// * `body` - 原始请求体
/// * `provider` - Provider 配置(用于获取模型映射等)
///
/// # Returns
/// * `Ok(Value)` - 转换后的请求体
/// * `Err(ProxyError)` - 转换失败
fn transform_request(&self, body: Value, _provider: &Provider) -> Result<Value, ProxyError> { fn transform_request(&self, body: Value, _provider: &Provider) -> Result<Value, ProxyError> {
Ok(body) Ok(body)
} }
/// 转换响应体 /// 转换响应体
///
/// 将响应体从一种格式转换为另一种格式(如 OpenAI → Anthropic)。
/// 默认实现直接返回原始响应体(透传)。
///
/// # Arguments
/// * `body` - 原始响应体
///
/// # Returns
/// * `Ok(Value)` - 转换后的响应体
/// * `Err(ProxyError)` - 转换失败
///
/// Note: 响应转换将在 handler 层集成,目前预留接口
#[allow(dead_code)] #[allow(dead_code)]
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> { fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
Ok(body) Ok(body)
+97 -63
View File
@@ -16,7 +16,6 @@
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType}; use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider; use crate::provider::Provider;
use crate::proxy::error::ProxyError; use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder;
/// 获取 Claude 供应商的 API 格式 /// 获取 Claude 供应商的 API 格式
/// ///
@@ -66,6 +65,30 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
} }
} }
pub fn claude_api_format_needs_transform(api_format: &str) -> bool {
matches!(api_format, "openai_chat" | "openai_responses")
}
pub fn transform_claude_request_for_api_format(
body: serde_json::Value,
provider: &Provider,
api_format: &str,
) -> Result<serde_json::Value, ProxyError> {
let cache_key = provider
.meta
.as_ref()
.and_then(|m| m.prompt_cache_key.as_deref())
.unwrap_or(&provider.id);
match api_format {
"openai_responses" => {
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
}
"openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)),
_ => Ok(body),
}
}
/// Claude 适配器 /// Claude 适配器
pub struct ClaudeAdapter; pub struct ClaudeAdapter;
@@ -298,7 +321,7 @@ impl ProviderAdapter for ClaudeAdapter {
// //
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。 // 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。 // 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
//
let mut base = format!( let mut base = format!(
"{}/{}", "{}/{}",
base_url.trim_end_matches('/'), base_url.trim_end_matches('/'),
@@ -310,52 +333,53 @@ impl ProviderAdapter for ClaudeAdapter {
base = base.replace("/v1/v1", "/v1"); base = base.replace("/v1/v1", "/v1");
} }
// GitHub Copilot 不需要 ?beta=true 参数 base
if base_url.contains("githubcopilot.com") {
return base;
}
// 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
// 注意:不要为 OpenAI Chat Completions (/v1/chat/completions) 添加此参数
// 当 apiFormat="openai_chat" 时,请求会转发到 /v1/chat/completions
// 但该端点是 OpenAI 标准,不支持 ?beta=true 参数
if endpoint.contains("/v1/messages")
&& !endpoint.contains("/v1/chat/completions")
&& !endpoint.contains('?')
{
format!("{base}?beta=true")
} else {
base
}
} }
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder { fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
use http::{HeaderName, HeaderValue};
// 注意:anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值) // 注意:anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值)
// 这里不再设置 anthropic-version,避免 header 重复 let bearer = format!("Bearer {}", auth.api_key);
match auth.strategy { match auth.strategy {
// Anthropic 官方: Authorization Bearer + x-api-key AuthStrategy::Anthropic | AuthStrategy::ClaudeAuth | AuthStrategy::Bearer => {
AuthStrategy::Anthropic => request vec![(
.header("Authorization", format!("Bearer {}", auth.api_key)) HeaderName::from_static("authorization"),
.header("x-api-key", &auth.api_key), HeaderValue::from_str(&bearer).unwrap(),
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key )]
AuthStrategy::ClaudeAuth => {
request.header("Authorization", format!("Bearer {}", auth.api_key))
} }
// OpenRouter: Bearer AuthStrategy::GitHubCopilot => {
AuthStrategy::Bearer => { vec![
request.header("Authorization", format!("Bearer {}", auth.api_key)) (
HeaderName::from_static("authorization"),
HeaderValue::from_str(&bearer).unwrap(),
),
(
HeaderName::from_static("editor-version"),
HeaderValue::from_static(super::copilot_auth::COPILOT_EDITOR_VERSION),
),
(
HeaderName::from_static("editor-plugin-version"),
HeaderValue::from_static(super::copilot_auth::COPILOT_PLUGIN_VERSION),
),
(
HeaderName::from_static("copilot-integration-id"),
HeaderValue::from_static(super::copilot_auth::COPILOT_INTEGRATION_ID),
),
(
HeaderName::from_static("user-agent"),
HeaderValue::from_static(super::copilot_auth::COPILOT_USER_AGENT),
),
(
HeaderName::from_static("x-github-api-version"),
HeaderValue::from_static(super::copilot_auth::COPILOT_API_VERSION),
),
(
HeaderName::from_static("openai-intent"),
HeaderValue::from_static("conversation-panel"),
),
]
} }
// GitHub Copilot: Bearer + 统一指纹头 _ => vec![],
AuthStrategy::GitHubCopilot => request
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("editor-version", super::copilot_auth::COPILOT_EDITOR_VERSION)
.header("editor-plugin-version", super::copilot_auth::COPILOT_PLUGIN_VERSION)
.header("copilot-integration-id", super::copilot_auth::COPILOT_INTEGRATION_ID)
.header("user-agent", super::copilot_auth::COPILOT_USER_AGENT)
.header("x-github-api-version", super::copilot_auth::COPILOT_API_VERSION)
.header("openai-intent", "conversation-panel"),
_ => request,
} }
} }
@@ -380,19 +404,7 @@ impl ProviderAdapter for ClaudeAdapter {
body: serde_json::Value, body: serde_json::Value,
provider: &Provider, provider: &Provider,
) -> Result<serde_json::Value, ProxyError> { ) -> Result<serde_json::Value, ProxyError> {
// Use meta.prompt_cache_key if set by user, otherwise fall back to provider.id transform_claude_request_for_api_format(body, provider, self.get_api_format(provider))
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> { fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
@@ -578,23 +590,20 @@ mod tests {
#[test] #[test]
fn test_build_url_anthropic() { fn test_build_url_anthropic() {
let adapter = ClaudeAdapter::new(); let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages"); let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true"); assert_eq!(url, "https://api.anthropic.com/v1/messages");
} }
#[test] #[test]
fn test_build_url_openrouter() { fn test_build_url_openrouter() {
let adapter = ClaudeAdapter::new(); let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages"); let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true"); assert_eq!(url, "https://openrouter.ai/api/v1/messages");
} }
#[test] #[test]
fn test_build_url_no_beta_for_other_endpoints() { fn test_build_url_no_beta_for_other_endpoints() {
let adapter = ClaudeAdapter::new(); let adapter = ClaudeAdapter::new();
// 非 /v1/messages 端点不添加 ?beta=true
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete"); let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
assert_eq!(url, "https://api.anthropic.com/v1/complete"); assert_eq!(url, "https://api.anthropic.com/v1/complete");
} }
@@ -602,16 +611,20 @@ mod tests {
#[test] #[test]
fn test_build_url_preserve_existing_query() { fn test_build_url_preserve_existing_query() {
let adapter = ClaudeAdapter::new(); let adapter = ClaudeAdapter::new();
// 已有查询参数时不重复添加
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar"); let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar"); assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
} }
#[test]
fn test_build_url_no_beta_for_github_copilot() {
let adapter = ClaudeAdapter::new();
let url = adapter.build_url("https://api.githubcopilot.com", "/v1/messages");
assert_eq!(url, "https://api.githubcopilot.com/v1/messages");
}
#[test] #[test]
fn test_build_url_no_beta_for_openai_chat_completions() { fn test_build_url_no_beta_for_openai_chat_completions() {
let adapter = ClaudeAdapter::new(); let adapter = ClaudeAdapter::new();
// OpenAI Chat Completions 端点不添加 ?beta=true
// 这是 Nvidia 等 apiFormat="openai_chat" 供应商使用的端点
let url = adapter.build_url("https://integrate.api.nvidia.com", "/v1/chat/completions"); let url = adapter.build_url("https://integrate.api.nvidia.com", "/v1/chat/completions");
assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions"); assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions");
} }
@@ -797,4 +810,25 @@ mod tests {
// GitHub Copilot always needs transform // GitHub Copilot always needs transform
assert!(adapter.needs_transform(&copilot)); assert!(adapter.needs_transform(&copilot));
} }
#[test]
fn test_transform_claude_request_for_api_format_responses() {
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
}
}));
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128
});
let transformed =
transform_claude_request_for_api_format(body, &provider, "openai_responses").unwrap();
assert_eq!(transformed["model"], "gpt-5.4");
assert!(transformed.get("input").is_some());
assert!(transformed.get("max_output_tokens").is_some());
}
} }
+6 -3
View File
@@ -9,7 +9,6 @@ use super::{AuthInfo, AuthStrategy, ProviderAdapter};
use crate::provider::Provider; use crate::provider::Provider;
use crate::proxy::error::ProxyError; use crate::proxy::error::ProxyError;
use regex::Regex; use regex::Regex;
use reqwest::RequestBuilder;
use std::sync::LazyLock; use std::sync::LazyLock;
/// 官方 Codex 客户端 User-Agent 正则 /// 官方 Codex 客户端 User-Agent 正则
@@ -174,8 +173,12 @@ impl ProviderAdapter for CodexAdapter {
url url
} }
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder { fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
request.header("Authorization", format!("Bearer {}", auth.api_key)) let bearer = format!("Bearer {}", auth.api_key);
vec![(
http::HeaderName::from_static("authorization"),
http::HeaderValue::from_str(&bearer).unwrap(),
)]
} }
} }
+122 -23
View File
@@ -310,6 +310,8 @@ pub struct CopilotAuthManager {
refresh_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>, refresh_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
/// Copilot Token 缓存(key = GitHub user ID,内存缓存,自动刷新) /// Copilot Token 缓存(key = GitHub user ID,内存缓存,自动刷新)
copilot_tokens: Arc<RwLock<HashMap<String, CopilotToken>>>, copilot_tokens: Arc<RwLock<HashMap<String, CopilotToken>>>,
/// Copilot Models 缓存(key = GitHub user ID,仅进程内复用)
copilot_models: Arc<RwLock<HashMap<String, Vec<CopilotModel>>>>,
/// HTTP 客户端 /// HTTP 客户端
http_client: Client, http_client: Client,
/// 存储路径 /// 存储路径
@@ -330,6 +332,7 @@ impl CopilotAuthManager {
default_account_id: Arc::new(RwLock::new(None)), default_account_id: Arc::new(RwLock::new(None)),
refresh_locks: Arc::new(RwLock::new(HashMap::new())), refresh_locks: Arc::new(RwLock::new(HashMap::new())),
copilot_tokens: Arc::new(RwLock::new(HashMap::new())), copilot_tokens: Arc::new(RwLock::new(HashMap::new())),
copilot_models: Arc::new(RwLock::new(HashMap::new())),
http_client: Client::new(), http_client: Client::new(),
storage_path, storage_path,
pending_migration: Arc::new(RwLock::new(None)), pending_migration: Arc::new(RwLock::new(None)),
@@ -338,7 +341,7 @@ impl CopilotAuthManager {
// 尝试从磁盘加载(同步,不发起网络请求) // 尝试从磁盘加载(同步,不发起网络请求)
if let Err(e) = manager.load_from_disk_sync() { if let Err(e) = manager.load_from_disk_sync() {
log::warn!("[CopilotAuth] 加载存储失败: {}", e); log::warn!("[CopilotAuth] 加载存储失败: {e}");
} }
manager manager
@@ -361,7 +364,7 @@ impl CopilotAuthManager {
/// 移除指定账号 /// 移除指定账号
pub async fn remove_account(&self, account_id: &str) -> Result<(), CopilotAuthError> { pub async fn remove_account(&self, account_id: &str) -> Result<(), CopilotAuthError> {
log::info!("[CopilotAuth] 移除账号: {}", account_id); log::info!("[CopilotAuth] 移除账号: {account_id}");
{ {
let mut accounts = self.accounts.write().await; let mut accounts = self.accounts.write().await;
@@ -375,6 +378,10 @@ impl CopilotAuthManager {
let mut tokens = self.copilot_tokens.write().await; let mut tokens = self.copilot_tokens.write().await;
tokens.remove(account_id); tokens.remove(account_id);
} }
{
let mut models = self.copilot_models.write().await;
models.remove(account_id);
}
{ {
let mut refresh_locks = self.refresh_locks.write().await; let mut refresh_locks = self.refresh_locks.write().await;
refresh_locks.remove(account_id); refresh_locks.remove(account_id);
@@ -475,8 +482,7 @@ impl CopilotAuthManager {
let status = response.status(); let status = response.status();
let text = response.text().await.unwrap_or_default(); let text = response.text().await.unwrap_or_default();
return Err(CopilotAuthError::NetworkError(format!( return Err(CopilotAuthError::NetworkError(format!(
"GitHub 设备码请求失败: {} - {}", "GitHub 设备码请求失败: {status} - {text}"
status, text
))); )));
} }
@@ -574,10 +580,7 @@ impl CopilotAuthManager {
} }
// 需要刷新 // 需要刷新
log::info!( log::info!("[CopilotAuth] 账号 {account_id} 的 Copilot Token 需要刷新");
"[CopilotAuth] 账号 {} 的 Copilot Token 需要刷新",
account_id
);
let refresh_lock = self.get_refresh_lock(account_id).await; let refresh_lock = self.get_refresh_lock(account_id).await;
let _refresh_guard = refresh_lock.lock().await; let _refresh_guard = refresh_lock.lock().await;
@@ -629,15 +632,36 @@ impl CopilotAuthManager {
pub async fn fetch_models_for_account( pub async fn fetch_models_for_account(
&self, &self,
account_id: &str, account_id: &str,
) -> Result<Vec<CopilotModel>, CopilotAuthError> {
self.ensure_migration_complete().await?;
{
let models = self.copilot_models.read().await;
if let Some(cached) = models.get(account_id) {
return Ok(cached.clone());
}
}
let models = self.fetch_models_for_account_uncached(account_id).await?;
{
let mut cache = self.copilot_models.write().await;
cache.insert(account_id.to_string(), models.clone());
}
Ok(models)
}
async fn fetch_models_for_account_uncached(
&self,
account_id: &str,
) -> Result<Vec<CopilotModel>, CopilotAuthError> { ) -> Result<Vec<CopilotModel>, CopilotAuthError> {
let copilot_token = self.get_valid_token_for_account(account_id).await?; let copilot_token = self.get_valid_token_for_account(account_id).await?;
log::info!("[CopilotAuth] 获取账号 {} 的 Copilot 可用模型", account_id); log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 可用模型");
let response = self let response = self
.http_client .http_client
.get(COPILOT_MODELS_URL) .get(COPILOT_MODELS_URL)
.header("Authorization", format!("Bearer {}", copilot_token)) .header("Authorization", format!("Bearer {copilot_token}"))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("copilot-integration-id", "vscode-chat") .header("copilot-integration-id", "vscode-chat")
.header("editor-version", COPILOT_EDITOR_VERSION) .header("editor-version", COPILOT_EDITOR_VERSION)
@@ -651,8 +675,7 @@ impl CopilotAuthManager {
let status = response.status(); let status = response.status();
let text = response.text().await.unwrap_or_default(); let text = response.text().await.unwrap_or_default();
return Err(CopilotAuthError::CopilotTokenFetchFailed(format!( return Err(CopilotAuthError::CopilotTokenFetchFailed(format!(
"获取模型列表失败: {} - {}", "获取模型列表失败: {status} - {text}"
status, text
))); )));
} }
@@ -678,6 +701,18 @@ impl CopilotAuthManager {
Ok(models) Ok(models)
} }
pub async fn get_model_vendor_for_account(
&self,
account_id: &str,
model_id: &str,
) -> Result<Option<String>, CopilotAuthError> {
let models = self.fetch_models_for_account(account_id).await?;
Ok(models
.into_iter()
.find(|model| model.id == model_id)
.map(|model| model.vendor))
}
/// 获取 Copilot 可用模型列表(向后兼容:使用第一个账号) /// 获取 Copilot 可用模型列表(向后兼容:使用第一个账号)
pub async fn fetch_models(&self) -> Result<Vec<CopilotModel>, CopilotAuthError> { pub async fn fetch_models(&self) -> Result<Vec<CopilotModel>, CopilotAuthError> {
match self.resolve_default_account_id().await { match self.resolve_default_account_id().await {
@@ -686,6 +721,16 @@ impl CopilotAuthManager {
} }
} }
pub async fn get_model_vendor(
&self,
model_id: &str,
) -> Result<Option<String>, CopilotAuthError> {
match self.resolve_default_account_id().await {
Some(id) => self.get_model_vendor_for_account(&id, model_id).await,
None => Err(CopilotAuthError::GitHubTokenInvalid),
}
}
/// 获取指定账号的 Copilot 使用量信息 /// 获取指定账号的 Copilot 使用量信息
pub async fn fetch_usage_for_account( pub async fn fetch_usage_for_account(
&self, &self,
@@ -699,12 +744,12 @@ impl CopilotAuthManager {
.ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))? .ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?
}; };
log::info!("[CopilotAuth] 获取账号 {} 的 Copilot 使用量", account_id); log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 使用量");
let response = self let response = self
.http_client .http_client
.get(COPILOT_USAGE_URL) .get(COPILOT_USAGE_URL)
.header("Authorization", format!("token {}", github_token)) .header("Authorization", format!("token {github_token}"))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("editor-version", COPILOT_EDITOR_VERSION) .header("editor-version", COPILOT_EDITOR_VERSION)
.header("editor-plugin-version", COPILOT_PLUGIN_VERSION) .header("editor-plugin-version", COPILOT_PLUGIN_VERSION)
@@ -721,8 +766,7 @@ impl CopilotAuthManager {
let status = response.status(); let status = response.status();
let text = response.text().await.unwrap_or_default(); let text = response.text().await.unwrap_or_default();
return Err(CopilotAuthError::CopilotTokenFetchFailed(format!( return Err(CopilotAuthError::CopilotTokenFetchFailed(format!(
"获取使用量失败: {} - {}", "获取使用量失败: {status} - {text}"
status, text
))); )));
} }
@@ -950,7 +994,7 @@ impl CopilotAuthManager {
let response = self let response = self
.http_client .http_client
.get(GITHUB_USER_URL) .get(GITHUB_USER_URL)
.header("Authorization", format!("token {}", github_token)) .header("Authorization", format!("token {github_token}"))
.header("User-Agent", COPILOT_USER_AGENT) .header("User-Agent", COPILOT_USER_AGENT)
.header("Editor-Version", COPILOT_EDITOR_VERSION) .header("Editor-Version", COPILOT_EDITOR_VERSION)
.header("Editor-Plugin-Version", COPILOT_PLUGIN_VERSION) .header("Editor-Plugin-Version", COPILOT_PLUGIN_VERSION)
@@ -977,12 +1021,12 @@ impl CopilotAuthManager {
github_token: &str, github_token: &str,
account_id: &str, account_id: &str,
) -> Result<(), CopilotAuthError> { ) -> Result<(), CopilotAuthError> {
log::debug!("[CopilotAuth] 获取账号 {} 的 Copilot Token", account_id); log::debug!("[CopilotAuth] 获取账号 {account_id} 的 Copilot Token");
let response = self let response = self
.http_client .http_client
.get(COPILOT_TOKEN_URL) .get(COPILOT_TOKEN_URL)
.header("Authorization", format!("token {}", github_token)) .header("Authorization", format!("token {github_token}"))
.header("User-Agent", COPILOT_USER_AGENT) .header("User-Agent", COPILOT_USER_AGENT)
.header("Editor-Version", COPILOT_EDITOR_VERSION) .header("Editor-Version", COPILOT_EDITOR_VERSION)
.header("Editor-Plugin-Version", COPILOT_PLUGIN_VERSION) .header("Editor-Plugin-Version", COPILOT_PLUGIN_VERSION)
@@ -1001,8 +1045,7 @@ impl CopilotAuthManager {
let status = response.status(); let status = response.status();
let text = response.text().await.unwrap_or_default(); let text = response.text().await.unwrap_or_default();
return Err(CopilotAuthError::CopilotTokenFetchFailed(format!( return Err(CopilotAuthError::CopilotTokenFetchFailed(format!(
"{}: {}", "{status}: {text}"
status, text
))); )));
} }
@@ -1085,7 +1128,7 @@ impl CopilotAuthManager {
.fetch_copilot_token_with_github_token(&legacy_token, &account_id) .fetch_copilot_token_with_github_token(&legacy_token, &account_id)
.await .await
{ {
log::warn!("[CopilotAuth] 迁移时验证 Copilot 订阅失败: {}", e); log::warn!("[CopilotAuth] 迁移时验证 Copilot 订阅失败: {e}");
} }
// 添加账号 // 添加账号
@@ -1099,7 +1142,7 @@ impl CopilotAuthManager {
"Legacy Copilot auth migration failed: {e}" "Legacy Copilot auth migration failed: {e}"
))) )))
.await; .await;
log::warn!("[CopilotAuth] 迁移失败,旧 token 可能已失效: {}", e); log::warn!("[CopilotAuth] 迁移失败,旧 token 可能已失效: {e}");
} }
} }
@@ -1143,6 +1186,7 @@ impl CopilotAuthManager {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tempfile::tempdir;
#[test] #[test]
fn test_copilot_token_expiry() { fn test_copilot_token_expiry() {
@@ -1315,4 +1359,59 @@ mod tests {
Some("67890".to_string()) Some("67890".to_string())
); );
} }
#[tokio::test]
async fn test_get_model_vendor_from_cache() {
let temp_dir = tempdir().unwrap();
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
{
let mut default_account_id = manager.default_account_id.write().await;
*default_account_id = Some("12345".to_string());
}
{
let mut accounts = manager.accounts.write().await;
accounts.insert(
"12345".to_string(),
GitHubAccountData {
github_token: "gho_test".to_string(),
user: GitHubUser {
login: "alice".to_string(),
id: 12345,
avatar_url: None,
},
authenticated_at: 1700000000,
},
);
}
{
let mut models = manager.copilot_models.write().await;
models.insert(
"12345".to_string(),
vec![
CopilotModel {
id: "gpt-5.4".to_string(),
name: "GPT-5.4".to_string(),
vendor: "OpenAI".to_string(),
model_picker_enabled: true,
},
CopilotModel {
id: "claude-sonnet-4".to_string(),
name: "Claude Sonnet 4".to_string(),
vendor: "Anthropic".to_string(),
model_picker_enabled: true,
},
],
);
}
let vendor = manager
.get_model_vendor_for_account("12345", "gpt-5.4")
.await
.unwrap();
assert_eq!(vendor.as_deref(), Some("OpenAI"));
let default_vendor = manager.get_model_vendor("claude-sonnet-4").await.unwrap();
assert_eq!(default_vendor.as_deref(), Some("Anthropic"));
}
} }
+16 -8
View File
@@ -9,7 +9,6 @@
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType}; use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider; use crate::provider::Provider;
use crate::proxy::error::ProxyError; use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder;
/// Gemini 适配器 /// Gemini 适配器
pub struct GeminiAdapter; pub struct GeminiAdapter;
@@ -217,17 +216,26 @@ impl ProviderAdapter for GeminiAdapter {
url url
} }
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder { fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
use http::{HeaderName, HeaderValue};
match auth.strategy { match auth.strategy {
// OAuth Bearer 认证
AuthStrategy::GoogleOAuth => { AuthStrategy::GoogleOAuth => {
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key); let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
request vec![
.header("Authorization", format!("Bearer {token}")) (
.header("x-goog-api-client", "GeminiCLI/1.0") HeaderName::from_static("authorization"),
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
),
(
HeaderName::from_static("x-goog-api-client"),
HeaderValue::from_static("GeminiCLI/1.0"),
),
]
} }
// API Key 认证 _ => vec![(
_ => request.header("x-goog-api-key", &auth.api_key), HeaderName::from_static("x-goog-api-key"),
HeaderValue::from_str(&auth.api_key).unwrap(),
)],
} }
} }
} }
+4 -1
View File
@@ -30,7 +30,10 @@ use serde::{Deserialize, Serialize};
// 公开导出 // 公开导出
pub use adapter::ProviderAdapter; pub use adapter::ProviderAdapter;
pub use auth::{AuthInfo, AuthStrategy}; pub use auth::{AuthInfo, AuthStrategy};
pub use claude::{get_claude_api_format, ClaudeAdapter}; pub use claude::{
claude_api_format_needs_transform, get_claude_api_format,
transform_claude_request_for_api_format, ClaudeAdapter,
};
pub use codex::CodexAdapter; pub use codex::CodexAdapter;
pub use gemini::GeminiAdapter; pub use gemini::GeminiAdapter;
+8 -4
View File
@@ -88,8 +88,8 @@ struct ToolBlockState {
} }
/// 创建 Anthropic SSE 流 /// 创建 Anthropic SSE 流
pub fn create_anthropic_sse_stream( pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static, stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send { ) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! { async_stream::stream! {
let mut buffer = String::new(); let mut buffer = String::new();
@@ -598,7 +598,9 @@ mod tests {
"data: [DONE]\n\n" "data: [DONE]\n\n"
); );
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]); let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream(upstream); let converted = create_anthropic_sse_stream(upstream);
let chunks: Vec<_> = converted.collect().await; let chunks: Vec<_> = converted.collect().await;
@@ -686,7 +688,9 @@ mod tests {
"data: [DONE]\n\n" "data: [DONE]\n\n"
); );
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]); let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream(upstream); let converted = create_anthropic_sse_stream(upstream);
let chunks: Vec<_> = converted.collect().await; let chunks: Vec<_> = converted.collect().await;
let merged = chunks let merged = chunks
@@ -96,8 +96,8 @@ fn resolve_content_index(
/// ///
/// 状态机跟踪: message_id, current_model, has_sent_message_start, item/content index map /// 状态机跟踪: message_id, current_model, has_sent_message_start, item/content index map
/// SSE 解析支持 named events (event: + data: 行) /// SSE 解析支持 named events (event: + data: 行)
pub fn create_anthropic_sse_stream_from_responses( pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send + 'static>(
stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static, stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send { ) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! { async_stream::stream! {
let mut buffer = String::new(); let mut buffer = String::new();
@@ -109,6 +109,7 @@ pub fn create_anthropic_sse_stream_from_responses(
let mut index_by_key: HashMap<String, u32> = HashMap::new(); let mut index_by_key: HashMap<String, u32> = HashMap::new();
let mut open_indices: HashSet<u32> = HashSet::new(); let mut open_indices: HashSet<u32> = HashSet::new();
let mut fallback_open_index: Option<u32> = None; let mut fallback_open_index: Option<u32> = None;
let mut current_text_index: Option<u32> = None;
let mut tool_index_by_item_id: HashMap<String, u32> = HashMap::new(); let mut tool_index_by_item_id: HashMap<String, u32> = HashMap::new();
let mut last_tool_index: Option<u32> = None; let mut last_tool_index: Option<u32> = None;
@@ -218,12 +219,18 @@ pub fn create_anthropic_sse_stream_from_responses(
if let Some(part) = data.get("part") { if let Some(part) = data.get("part") {
let part_type = part.get("type").and_then(|t| t.as_str()); let part_type = part.get("type").and_then(|t| t.as_str());
if matches!(part_type, Some("output_text") | Some("refusal")) { if matches!(part_type, Some("output_text") | Some("refusal")) {
let index = resolve_content_index( let index = if let Some(index) = current_text_index {
&data, index
&mut next_content_index, } else {
&mut index_by_key, let index = resolve_content_index(
&mut fallback_open_index, &data,
); &mut next_content_index,
&mut index_by_key,
&mut fallback_open_index,
);
current_text_index = Some(index);
index
};
if open_indices.contains(&index) { if open_indices.contains(&index) {
continue; continue;
@@ -250,12 +257,18 @@ pub fn create_anthropic_sse_stream_from_responses(
// ================================================ // ================================================
"response.output_text.delta" => { "response.output_text.delta" => {
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) { if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
let index = resolve_content_index( let index = if let Some(index) = current_text_index {
&data, index
&mut next_content_index, } else {
&mut index_by_key, let index = resolve_content_index(
&mut fallback_open_index, &data,
); &mut next_content_index,
&mut index_by_key,
&mut fallback_open_index,
);
current_text_index = Some(index);
index
};
if !open_indices.contains(&index) { if !open_indices.contains(&index) {
let start_event = json!({ let start_event = json!({
@@ -290,12 +303,18 @@ pub fn create_anthropic_sse_stream_from_responses(
// ================================================ // ================================================
"response.refusal.delta" => { "response.refusal.delta" => {
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) { if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
let index = resolve_content_index( let index = if let Some(index) = current_text_index {
&data, index
&mut next_content_index, } else {
&mut index_by_key, let index = resolve_content_index(
&mut fallback_open_index, &data,
); &mut next_content_index,
&mut index_by_key,
&mut fallback_open_index,
);
current_text_index = Some(index);
index
};
if !open_indices.contains(&index) { if !open_indices.contains(&index) {
let start_event = json!({ let start_event = json!({
@@ -329,29 +348,7 @@ pub fn create_anthropic_sse_stream_from_responses(
// ================================================ // ================================================
// response.content_part.done → content_block_stop // response.content_part.done → content_block_stop
// ================================================ // ================================================
"response.content_part.done" => { "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 (function_call) → content_block_start (tool_use)
@@ -361,6 +358,20 @@ pub fn create_anthropic_sse_stream_from_responses(
let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or(""); let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
if item_type == "function_call" { if item_type == "function_call" {
has_tool_use = true; has_tool_use = true;
if let Some(index) = current_text_index.take() {
if open_indices.remove(&index) {
let stop_event = json!({
"type": "content_block_stop",
"index": index
});
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
serde_json::to_string(&stop_event).unwrap_or_default());
yield Ok(Bytes::from(stop_sse));
}
if fallback_open_index == Some(index) {
fallback_open_index = None;
}
}
// 确保 message_start 已发送 // 确保 message_start 已发送
if !has_sent_message_start { if !has_sent_message_start {
let start_event = json!({ let start_event = json!({
@@ -521,12 +532,14 @@ pub fn create_anthropic_sse_stream_from_responses(
// response.refusal.done → content_block_stop // response.refusal.done → content_block_stop
// ================================================ // ================================================
"response.refusal.done" => { "response.refusal.done" => {
let key = content_part_key(&data); let index = current_text_index.take().or_else(|| {
let index = if let Some(k) = key { let key = content_part_key(&data);
index_by_key.get(&k).copied() if let Some(k) = key {
} else { index_by_key.get(&k).copied()
fallback_open_index } else {
}; fallback_open_index
}
});
if let Some(index) = index { if let Some(index) = index {
if !open_indices.remove(&index) { if !open_indices.remove(&index) {
continue; continue;
@@ -553,6 +566,20 @@ pub fn create_anthropic_sse_stream_from_responses(
.or_else(|| data.get("text")) .or_else(|| data.get("text"))
.and_then(|d| d.as_str()) .and_then(|d| d.as_str())
{ {
if let Some(index) = current_text_index.take() {
if open_indices.remove(&index) {
let stop_event = json!({
"type": "content_block_stop",
"index": index
});
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
serde_json::to_string(&stop_event).unwrap_or_default());
yield Ok(Bytes::from(stop_sse));
}
if fallback_open_index == Some(index) {
fallback_open_index = None;
}
}
let index = resolve_content_index( let index = resolve_content_index(
&data, &data,
&mut next_content_index, &mut next_content_index,
@@ -674,8 +701,23 @@ pub fn create_anthropic_sse_stream_from_responses(
// Lifecycle events that don't need Anthropic counterparts. // Lifecycle events that don't need Anthropic counterparts.
// Listed explicitly so new events trigger a match-completeness review. // Listed explicitly so new events trigger a match-completeness review.
"response.output_text.done" "response.output_text.done" => {
| "response.output_item.done" if let Some(index) = current_text_index.take() {
if open_indices.remove(&index) {
let stop_event = json!({
"type": "content_block_stop",
"index": index
});
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
serde_json::to_string(&stop_event).unwrap_or_default());
yield Ok(Bytes::from(stop_sse));
}
if fallback_open_index == Some(index) {
fallback_open_index = None;
}
}
}
"response.output_item.done"
| "response.in_progress" => {} | "response.in_progress" => {}
// Any other unknown/future events — silently skip. // Any other unknown/future events — silently skip.
@@ -758,7 +800,9 @@ mod tests {
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":3}}}\n\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 upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream_from_responses(upstream); let converted = create_anthropic_sse_stream_from_responses(upstream);
let chunks: Vec<_> = converted.collect().await; let chunks: Vec<_> = converted.collect().await;
@@ -800,7 +844,9 @@ mod tests {
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":8,\"output_tokens\":4}}}\n\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 upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream_from_responses(upstream); let converted = create_anthropic_sse_stream_from_responses(upstream);
let chunks: Vec<_> = converted.collect().await; let chunks: Vec<_> = converted.collect().await;
let merged = chunks let merged = chunks
@@ -871,7 +917,9 @@ mod tests {
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":10}}}\n\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 upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream_from_responses(upstream); let converted = create_anthropic_sse_stream_from_responses(upstream);
let chunks: Vec<_> = converted.collect().await; let chunks: Vec<_> = converted.collect().await;
let merged = chunks let merged = chunks
@@ -902,4 +950,83 @@ mod tests {
); );
assert!(merged.contains("\"stop_reason\":\"end_turn\"")); assert!(merged.contains("\"stop_reason\":\"end_turn\""));
} }
#[tokio::test]
async fn test_streaming_text_parts_are_merged_into_one_text_block() {
let input = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_merge\",\"model\":\"gpt-5.4\",\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}\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\":\"\",\"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.content_part.added\n",
"data: {\"type\":\"response.content_part.added\",\"part\":{\"type\":\"output_text\",\"text\":\"\"},\"output_index\":0,\"content_index\":1}\n\n",
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"\",\"output_index\":0,\"content_index\":1}\n\n",
"event: response.content_part.done\n",
"data: {\"type\":\"response.content_part.done\",\"output_index\":0,\"content_index\":1}\n\n",
"event: response.output_text.done\n",
"data: {\"type\":\"response.output_text.done\",\"output_index\":0,\"content_index\":1}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":2}}}\n\n"
);
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream_from_responses(upstream);
let chunks: Vec<_> = converted.collect().await;
let events: Vec<Value> = chunks
.into_iter()
.flat_map(|chunk| {
let bytes = chunk.unwrap();
let text = String::from_utf8_lossy(bytes.as_ref()).to_string();
text.split("\n\n")
.filter_map(|block| {
block.lines().find_map(|line| {
strip_sse_field(line, "data")
.and_then(|payload| serde_json::from_str::<Value>(payload).ok())
})
})
.collect::<Vec<_>>()
})
.collect();
let text_starts = 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("text")
})
.count();
let text_stops = events
.iter()
.filter(|event| {
event.get("type").and_then(|v| v.as_str()) == Some("content_block_stop")
})
.count();
let text_deltas: Vec<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("text_delta")
})
.filter_map(|event| {
event
.pointer("/delta/text")
.and_then(|v| v.as_str())
.map(ToString::to_string)
})
.collect();
assert_eq!(text_starts, 1);
assert_eq!(text_stops, 1);
assert_eq!(text_deltas, vec!["".to_string(), "".to_string()]);
}
} }
+1 -1
View File
@@ -50,7 +50,7 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
"medium" => Some("medium"), "medium" => Some("medium"),
"high" => Some("high"), "high" => Some("high"),
"max" => Some("xhigh"), // OpenAI xhigh = maximum reasoning effort "max" => Some("xhigh"), // OpenAI xhigh = maximum reasoning effort
_ => None, // unknown value — do not inject _ => None, // unknown value — do not inject
}; };
} }
+131 -29
View File
@@ -5,17 +5,19 @@
use super::{ use super::{
handler_config::UsageParserConfig, handler_config::UsageParserConfig,
handler_context::{RequestContext, StreamingTimeoutConfig}, handler_context::{RequestContext, StreamingTimeoutConfig},
hyper_client::ProxyResponse,
server::ProxyState, server::ProxyState,
sse::strip_sse_field, sse::strip_sse_field,
usage::parser::TokenUsage, usage::parser::TokenUsage,
ProxyError, ProxyError,
}; };
use axum::http::header::HeaderMap;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use bytes::Bytes; use bytes::Bytes;
use futures::stream::{Stream, StreamExt}; use futures::stream::{Stream, StreamExt};
use reqwest::header::HeaderMap;
use serde_json::Value; use serde_json::Value;
use std::{ use std::{
io::Read,
sync::{ sync::{
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
Arc, Arc,
@@ -24,24 +26,123 @@ use std::{
}; };
use tokio::sync::Mutex; use tokio::sync::Mutex;
// ============================================================================
// 响应解压
// ============================================================================
/// 根据 content-encoding 解压响应体字节
///
/// reqwest 自动解压已禁用(为了透传 accept-encoding),需要手动解压。
fn decompress_body(content_encoding: &str, body: &[u8]) -> Result<Vec<u8>, std::io::Error> {
match content_encoding {
"gzip" | "x-gzip" => {
let mut decoder = flate2::read::GzDecoder::new(body);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed)?;
Ok(decompressed)
}
"deflate" => {
let mut decoder = flate2::read::DeflateDecoder::new(body);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed)?;
Ok(decompressed)
}
"br" => {
let mut decompressed = Vec::new();
brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?;
Ok(decompressed)
}
_ => {
log::warn!("未知的 content-encoding: {content_encoding},跳过解压");
Ok(body.to_vec())
}
}
}
/// 从响应头提取 content-encoding(忽略 identity 和 chunked
fn get_content_encoding(headers: &HeaderMap) -> Option<String> {
headers
.get("content-encoding")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty() && s != "identity")
}
/// 移除在重建响应体后会失真的实体头。
pub(crate) fn strip_entity_headers_for_rebuilt_body(headers: &mut HeaderMap) {
headers.remove(axum::http::header::CONTENT_ENCODING);
headers.remove(axum::http::header::CONTENT_LENGTH);
headers.remove(axum::http::header::TRANSFER_ENCODING);
}
/// 读取响应体并在需要时解压,确保 headers 与返回 body 一致。
///
/// `body_timeout`: 整包超时。当非零时用 `tokio::time::timeout` 包住 `.bytes()` 调用,
/// 防止上游发完响应头后卡住 body 导致请求永远挂住。
/// 传入 `Duration::ZERO` 表示不启用超时(故障转移关闭时)。
pub(crate) async fn read_decoded_body(
response: ProxyResponse,
tag: &str,
body_timeout: Duration,
) -> Result<(HeaderMap, http::StatusCode, Bytes), ProxyError> {
let mut headers = response.headers().clone();
let status = response.status();
let raw_bytes = if body_timeout.is_zero() {
response.bytes().await?
} else {
tokio::time::timeout(body_timeout, response.bytes())
.await
.map_err(|_| {
ProxyError::Timeout(format!(
"响应体读取超时: {}s(上游发完响应头后 body 未到达)",
body_timeout.as_secs()
))
})??
};
log::debug!(
"[{tag}] 已接收上游响应体: status={}, bytes={}, headers={}",
status.as_u16(),
raw_bytes.len(),
format_headers(&headers)
);
let mut body_bytes = raw_bytes.clone();
let mut decoded = false;
if let Some(encoding) = get_content_encoding(&headers) {
log::debug!("[{tag}] 解压非流式响应: content-encoding={encoding}");
match decompress_body(&encoding, &raw_bytes) {
Ok(decompressed) => {
body_bytes = Bytes::from(decompressed);
decoded = true;
}
Err(e) => {
log::warn!("[{tag}] 解压失败 ({encoding}): {e},使用原始数据");
}
}
}
if decoded {
strip_entity_headers_for_rebuilt_body(&mut headers);
}
Ok((headers, status, body_bytes))
}
// ============================================================================ // ============================================================================
// 公共接口 // 公共接口
// ============================================================================ // ============================================================================
/// 检测响应是否为 SSE 流式响应 /// 检测响应是否为 SSE 流式响应
#[inline] #[inline]
pub fn is_sse_response(response: &reqwest::Response) -> bool { pub fn is_sse_response(response: &ProxyResponse) -> bool {
response response.is_sse()
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(|ct| ct.contains("text/event-stream"))
.unwrap_or(false)
} }
/// 处理流式响应 /// 处理流式响应
pub async fn handle_streaming( pub async fn handle_streaming(
response: reqwest::Response, response: ProxyResponse,
ctx: &RequestContext, ctx: &RequestContext,
state: &ProxyState, state: &ProxyState,
parser_config: &UsageParserConfig, parser_config: &UsageParserConfig,
@@ -53,6 +154,15 @@ pub async fn handle_streaming(
status.as_u16(), status.as_u16(),
format_headers(response.headers()) format_headers(response.headers())
); );
// 检查流式响应是否被压缩(SSE 通常不压缩,如果压缩则 SSE 解析会失败)
if let Some(encoding) = get_content_encoding(response.headers()) {
log::warn!(
"[{}] 流式响应含 content-encoding={encoding}SSE 解析可能失败。\
accept-encoding SSE ",
ctx.tag
);
}
let mut builder = axum::response::Response::builder().status(status); let mut builder = axum::response::Response::builder().status(status);
// 复制响应头 // 复制响应头
@@ -61,9 +171,7 @@ pub async fn handle_streaming(
} }
// 创建字节流 // 创建字节流
let stream = response let stream = response.bytes_stream();
.bytes_stream()
.map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string())));
// 创建使用量收集器 // 创建使用量收集器
let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config); let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);
@@ -87,26 +195,20 @@ pub async fn handle_streaming(
/// 处理非流式响应 /// 处理非流式响应
pub async fn handle_non_streaming( pub async fn handle_non_streaming(
response: reqwest::Response, response: ProxyResponse,
ctx: &RequestContext, ctx: &RequestContext,
state: &ProxyState, state: &ProxyState,
parser_config: &UsageParserConfig, parser_config: &UsageParserConfig,
) -> Result<Response, ProxyError> { ) -> Result<Response, ProxyError> {
let response_headers = response.headers().clone(); // 整包超时:仅在故障转移开启且配置值非零时生效
let status = response.status(); let body_timeout =
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
// 读取响应体 Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
let body_bytes = response.bytes().await.map_err(|e| { } else {
log::error!("[{}] 读取响应失败: {e}", ctx.tag); Duration::ZERO
ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) };
})?; let (response_headers, status, body_bytes) =
log::debug!( read_decoded_body(response, ctx.tag, body_timeout).await?;
"[{}] 已接收上游响应体: status={}, bytes={}, headers={}",
ctx.tag,
status.as_u16(),
body_bytes.len(),
format_headers(&response_headers)
);
log::debug!( log::debug!(
"[{}] 上游响应体内容: {}", "[{}] 上游响应体内容: {}",
@@ -190,7 +292,7 @@ pub async fn handle_non_streaming(
/// ///
/// 根据响应类型自动选择流式或非流式处理 /// 根据响应类型自动选择流式或非流式处理
pub async fn process_response( pub async fn process_response(
response: reqwest::Response, response: ProxyResponse,
ctx: &RequestContext, ctx: &RequestContext,
state: &ProxyState, state: &ProxyState,
parser_config: &UsageParserConfig, parser_config: &UsageParserConfig,
+76 -7
View File
@@ -1,6 +1,12 @@
//! HTTP代理服务器 //! HTTP代理服务器
//! //!
//! 基于Axum的HTTP服务器,处理代理请求 //! 基于Axum的HTTP服务器,处理代理请求
//!
//! Uses a manual hyper HTTP/1.1 accept loop with `preserve_header_case(true)` so
//! that the original header-name casing from the CLI client is captured in a
//! `HeaderCaseMap` extension. This map is later forwarded to the upstream via
//! the hyper-based HTTP client, producing wire-level header casing identical to
//! a direct (non-proxied) CLI request.
use super::{ use super::{
failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv, failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,
@@ -12,6 +18,7 @@ use axum::{
routing::{get, post}, routing::{get, post},
Router, Router,
}; };
use hyper_util::rt::TokioIo;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{oneshot, RwLock}; use tokio::sync::{oneshot, RwLock};
@@ -114,15 +121,77 @@ impl ProxyServer {
// 记录启动时间 // 记录启动时间
*self.state.start_time.write().await = Some(std::time::Instant::now()); *self.state.start_time.write().await = Some(std::time::Instant::now());
// 启动服务器 // 启动服务器 — 使用手动 hyper HTTP/1.1 accept loop
// 开启 preserve_header_case 以捕获客户端请求头的原始大小写
let state = self.state.clone(); let state = self.state.clone();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
axum::serve(listener, app) let mut shutdown_rx = shutdown_rx;
.with_graceful_shutdown(async { loop {
shutdown_rx.await.ok(); tokio::select! {
}) result = listener.accept() => {
.await let (stream, _remote_addr) = match result {
.ok(); Ok(v) => v,
Err(e) => {
log::error!("[{SRV}] accept 失败: {e}", SRV = log_srv::ACCEPT_ERR);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
continue;
}
};
let app = app.clone();
tokio::spawn(async move {
// Peek raw TCP bytes to capture original header casing
// before hyper parses (and lowercases) the header names.
let original_cases = {
let mut peek_buf = vec![0u8; 8192];
match stream.peek(&mut peek_buf).await {
Ok(n) => {
let cases = super::hyper_client::OriginalHeaderCases::from_raw_bytes(&peek_buf[..n]);
log::debug!(
"[ProxyServer] Peeked {} bytes, captured {} header casings",
n, cases.cases.len()
);
cases
}
Err(e) => {
log::debug!("[ProxyServer] peek failed (non-fatal): {e}");
super::hyper_client::OriginalHeaderCases::default()
}
}
};
// service_fn 将 axum Routertower::Service)桥接到 hyper
let service = hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
let mut router = app.clone();
let cases = original_cases.clone();
async move {
// 将 hyper::body::Incoming 转为 axum::body::Body,保留 extensions
let (mut parts, body) = req.into_parts();
// Insert our own header case map alongside hyper's internal one
parts.extensions.insert(cases);
let body = axum::body::Body::new(body);
let axum_req = http::Request::from_parts(parts, body);
<Router as tower::Service<http::Request<axum::body::Body>>>::call(&mut router, axum_req).await
}
});
if let Err(e) = hyper::server::conn::http1::Builder::new()
.preserve_header_case(true)
.serve_connection(TokioIo::new(stream), service)
.await
{
// Connection reset / broken pipe 等在代理场景下很常见,debug 级别
log::debug!("[{SRV}] connection error: {e}", SRV = log_srv::CONN_ERR);
}
});
}
_ = &mut shutdown_rx => {
break;
}
}
}
// 服务器停止后更新状态 // 服务器停止后更新状态
state.status.write().await.running = false; state.status.write().await.running = false;
+42
View File
@@ -0,0 +1,42 @@
//! Per-app switch lock
//!
//! 确保同一应用同时只有一个供应商切换操作在执行,
//! 防止并发切换导致 is_current 与 Live 备份不一致。
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{Mutex, OwnedMutexGuard, RwLock};
/// 每个应用类型一把互斥锁,保证同一应用的切换操作串行执行。
///
/// 不同应用之间(如 Claude 和 Codex)可以并行切换。
#[derive(Clone, Default)]
pub struct SwitchLockManager {
locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
}
impl SwitchLockManager {
pub fn new() -> Self {
Self::default()
}
/// 获取指定应用的切换锁。
///
/// 返回 `OwnedMutexGuard`,持有期间同一 `app_type` 的其他切换会排队等待。
pub async fn lock_for_app(&self, app_type: &str) -> OwnedMutexGuard<()> {
let lock = {
let locks = self.locks.read().await;
if let Some(lock) = locks.get(app_type) {
lock.clone()
} else {
drop(locks);
let mut locks = self.locks.write().await;
locks
.entry(app_type.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
};
lock.lock_owned().await
}
}
+2 -2
View File
@@ -25,7 +25,7 @@ pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
} }
if model.contains("opus-4-6") || model.contains("sonnet-4-6") { if model.contains("opus-4-6") || model.contains("sonnet-4-6") {
log::info!("[OPT] thinking: adaptive({})", model); log::info!("[OPT] thinking: adaptive({model})");
body["thinking"] = json!({"type": "adaptive"}); body["thinking"] = json!({"type": "adaptive"});
body["output_config"] = json!({"effort": "max"}); body["output_config"] = json!({"effort": "max"});
append_beta(body, "context-1m-2025-08-07"); append_beta(body, "context-1m-2025-08-07");
@@ -33,7 +33,7 @@ pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
} }
// legacy path // legacy path
log::info!("[OPT] thinking: legacy({})", model); log::info!("[OPT] thinking: legacy({model})");
let max_tokens = body let max_tokens = body
.get("max_tokens") .get("max_tokens")
+150 -44
View File
@@ -1,6 +1,7 @@
use crate::config::write_json_file; use crate::config::{atomic_write, write_json_file};
use crate::error::AppError; use crate::error::AppError;
use crate::opencode_config::get_opencode_dir; use crate::opencode_config::get_opencode_dir;
use crate::provider::Provider;
use crate::store::AppState; use crate::store::AppState;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Map, Value}; use serde_json::{Map, Value};
@@ -21,33 +22,41 @@ type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>);
// ── Variant descriptor ───────────────────────────────────────── // ── Variant descriptor ─────────────────────────────────────────
pub struct OmoVariant { pub struct OmoVariant {
pub filename: &'static str, pub preferred_filename: &'static str,
pub config_candidates: &'static [&'static str],
pub category: &'static str, pub category: &'static str,
pub provider_prefix: &'static str, pub provider_prefix: &'static str,
pub plugin_name: &'static str, pub plugin_name: &'static str,
pub plugin_prefix: &'static str, pub plugin_prefixes: &'static [&'static str],
pub has_categories: bool, pub has_categories: bool,
pub label: &'static str, pub label: &'static str,
pub import_label: &'static str, pub import_label: &'static str,
} }
pub const STANDARD: OmoVariant = OmoVariant { pub const STANDARD: OmoVariant = OmoVariant {
filename: "oh-my-opencode.jsonc", preferred_filename: "oh-my-openagent.jsonc",
config_candidates: &[
"oh-my-openagent.jsonc",
"oh-my-openagent.json",
"oh-my-opencode.jsonc",
"oh-my-opencode.json",
],
category: "omo", category: "omo",
provider_prefix: "omo-", provider_prefix: "omo-",
plugin_name: "oh-my-opencode@latest", plugin_name: "oh-my-openagent@latest",
plugin_prefix: "oh-my-opencode", plugin_prefixes: &["oh-my-openagent", "oh-my-opencode"],
has_categories: true, has_categories: true,
label: "OMO", label: "OMO",
import_label: "Imported", import_label: "Imported",
}; };
pub const SLIM: OmoVariant = OmoVariant { pub const SLIM: OmoVariant = OmoVariant {
filename: "oh-my-opencode-slim.jsonc", preferred_filename: "oh-my-opencode-slim.jsonc",
config_candidates: &["oh-my-opencode-slim.jsonc", "oh-my-opencode-slim.json"],
category: "omo-slim", category: "omo-slim",
provider_prefix: "omo-slim-", provider_prefix: "omo-slim-",
plugin_name: "oh-my-opencode-slim@latest", plugin_name: "oh-my-opencode-slim@latest",
plugin_prefix: "oh-my-opencode-slim", plugin_prefixes: &["oh-my-opencode-slim"],
has_categories: false, has_categories: false,
label: "OMO Slim", label: "OMO Slim",
import_label: "Imported Slim", import_label: "Imported Slim",
@@ -60,22 +69,27 @@ pub struct OmoService;
impl OmoService { impl OmoService {
// ── Path helpers ──────────────────────────────────────── // ── Path helpers ────────────────────────────────────────
fn config_candidates(v: &OmoVariant, base_dir: &Path) -> Vec<PathBuf> {
v.config_candidates
.iter()
.map(|name| base_dir.join(name))
.collect()
}
fn find_existing_config_path(v: &OmoVariant, base_dir: &Path) -> Option<PathBuf> {
Self::config_candidates(v, base_dir)
.into_iter()
.find(|path| path.exists())
}
fn config_path(v: &OmoVariant) -> PathBuf { fn config_path(v: &OmoVariant) -> PathBuf {
get_opencode_dir().join(v.filename) let base_dir = get_opencode_dir();
Self::find_existing_config_path(v, &base_dir)
.unwrap_or_else(|| base_dir.join(v.preferred_filename))
} }
fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> { fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {
let config_path = Self::config_path(v); Self::find_existing_config_path(v, &get_opencode_dir()).ok_or(AppError::OmoConfigNotFound)
if config_path.exists() {
return Ok(config_path);
}
let json_path = config_path.with_extension("json");
if json_path.exists() {
return Ok(json_path);
}
Err(AppError::OmoConfigNotFound)
} }
fn read_jsonc_object(path: &Path) -> Result<Map<String, Value>, AppError> { fn read_jsonc_object(path: &Path) -> Result<Map<String, Value>, AppError> {
@@ -120,44 +134,102 @@ impl OmoService {
} }
} }
// ── Public API (variant-parameterized) ───────────────── fn profile_data_from_provider(provider: &Provider, v: &OmoVariant) -> OmoProfileData {
let agents = provider.settings_config.get("agents").cloned();
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> { let categories = if v.has_categories {
let config_path = Self::config_path(v); provider.settings_config.get("categories").cloned()
if config_path.exists() { } else {
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?; None
log::info!("{} config file deleted: {config_path:?}", v.label); };
} let other_fields = provider.settings_config.get("otherFields").cloned();
crate::opencode_config::remove_plugin_by_prefix(v.plugin_prefix)?; (agents, categories, other_fields)
Ok(())
} }
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> { fn snapshot_config_file(path: &Path) -> Result<Option<Vec<u8>>, AppError> {
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?; if !path.exists() {
let profile_data = current_omo.as_ref().map(|p| { return Ok(None);
let agents = p.settings_config.get("agents").cloned(); }
let categories = if v.has_categories {
p.settings_config.get("categories").cloned()
} else {
None
};
let other_fields = p.settings_config.get("otherFields").cloned();
(agents, categories, other_fields)
});
let merged = Self::build_config(v, profile_data.as_ref()); std::fs::read(path)
.map(Some)
.map_err(|e| AppError::io(path, e))
}
fn restore_config_file(path: &Path, snapshot: Option<&[u8]>) -> Result<(), AppError> {
match snapshot {
Some(bytes) => atomic_write(path, bytes),
None => {
if path.exists() {
std::fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
}
Ok(())
}
}
}
fn write_profile_config(
v: &OmoVariant,
profile_data: Option<&OmoProfileData>,
) -> Result<(), AppError> {
let merged = Self::build_config(v, profile_data);
let config_path = Self::config_path(v); let config_path = Self::config_path(v);
if let Some(parent) = config_path.parent() { if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
} }
let previous_contents = Self::snapshot_config_file(&config_path)?;
write_json_file(&config_path, &merged)?; write_json_file(&config_path, &merged)?;
crate::opencode_config::add_plugin(v.plugin_name)?; if let Err(err) = crate::opencode_config::add_plugin(v.plugin_name) {
if let Err(rollback_err) =
Self::restore_config_file(&config_path, previous_contents.as_deref())
{
log::warn!(
"Failed to roll back {} config after plugin sync error: {}",
v.label,
rollback_err
);
}
return Err(err);
}
log::info!("{} config written to {config_path:?}", v.label); log::info!("{} config written to {config_path:?}", v.label);
Ok(()) Ok(())
} }
// ── Public API (variant-parameterized) ─────────────────
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
let base_dir = get_opencode_dir();
let mut deleted_paths = Vec::new();
for config_path in Self::config_candidates(v, &base_dir) {
if config_path.exists() {
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
deleted_paths.push(config_path);
}
}
if !deleted_paths.is_empty() {
log::info!("{} config files deleted: {deleted_paths:?}", v.label);
}
crate::opencode_config::remove_plugins_by_prefixes(v.plugin_prefixes)?;
Ok(())
}
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?;
let profile_data = current_omo
.as_ref()
.map(|provider| Self::profile_data_from_provider(provider, v));
Self::write_profile_config(v, profile_data.as_ref())
}
pub fn write_provider_config_to_file(
provider: &Provider,
v: &OmoVariant,
) -> Result<(), AppError> {
let profile_data = Self::profile_data_from_provider(provider, v);
Self::write_profile_config(v, Some(&profile_data))
}
fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value { fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value {
let mut result = Map::new(); let mut result = Map::new();
if let Some((agents, categories, other_fields)) = profile_data { if let Some((agents, categories, other_fields)) = profile_data {
@@ -451,4 +523,38 @@ mod tests {
assert!(obj.contains_key("agents")); assert!(obj.contains_key("agents"));
assert!(obj.contains_key("disabled_agents")); assert!(obj.contains_key("disabled_agents"));
} }
#[test]
fn test_find_existing_config_prefers_new_name_over_old() {
let dir = tempfile::tempdir().unwrap();
let old_path = dir.path().join("oh-my-opencode.jsonc");
let new_path = dir.path().join("oh-my-openagent.jsonc");
// Create both old and new files
std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
std::fs::write(&new_path, r#"{"agents":{}}"#).unwrap();
let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
assert_eq!(
found.unwrap(),
new_path,
"When both old and new config files exist, the new name (oh-my-openagent) must be preferred"
);
}
#[test]
fn test_find_existing_config_falls_back_to_old_name() {
let dir = tempfile::tempdir().unwrap();
let old_path = dir.path().join("oh-my-opencode.jsonc");
// Only old file exists
std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
assert_eq!(
found.unwrap(),
old_path,
"When only the old config file exists, it should still be found"
);
}
} }
+42 -14
View File
@@ -33,6 +33,19 @@ pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value {
v v
} }
pub(crate) fn provider_exists_in_live_config(
app_type: &AppType,
provider_id: &str,
) -> Result<bool, AppError> {
match app_type {
AppType::OpenCode => crate::opencode_config::get_providers()
.map(|providers| providers.contains_key(provider_id)),
AppType::OpenClaw => crate::openclaw_config::get_providers()
.map(|providers| providers.contains_key(provider_id)),
_ => Ok(false),
}
}
fn json_is_subset(target: &Value, source: &Value) -> bool { fn json_is_subset(target: &Value, source: &Value) -> bool {
match source { match source {
Value::Object(source_map) => { Value::Object(source_map) => {
@@ -727,10 +740,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
provider.id provider.id
); );
} else { } else {
log::error!( return Err(AppError::Message(format!(
"OpenCode provider '{}' has invalid config structure, skipping write", "OpenCode provider '{}' has invalid config structure for live config (must contain 'npm' or 'options')",
provider.id provider.id
); )));
} }
} }
} }
@@ -769,10 +782,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
provider.id provider.id
); );
} else { } else {
log::error!( return Err(AppError::Message(format!(
"OpenClaw provider '{}' has invalid config structure, skipping write", "OpenClaw provider '{}' has invalid config structure for live config (must contain 'baseUrl', 'api', or 'models')",
provider.id provider.id
); )));
} }
} }
} }
@@ -787,23 +800,30 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
/// Used for OpenCode and other additive mode applications. /// Used for OpenCode and other additive mode applications.
fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> { fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> {
let providers = state.db.get_all_providers(app_type.as_str())?; let providers = state.db.get_all_providers(app_type.as_str())?;
let mut synced_count = 0usize;
for provider in providers.values() { for provider in providers.values() {
if provider
.meta
.as_ref()
.and_then(|meta| meta.live_config_managed)
== Some(false)
{
continue;
}
if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) { if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) {
log::warn!( log::warn!(
"Failed to sync {:?} provider '{}' to live: {e}", "Failed to sync {:?} provider '{}' to live: {e}",
app_type, app_type,
provider.id provider.id
); );
// Continue syncing other providers, don't abort continue;
} }
synced_count += 1;
} }
log::info!( log::info!("Synced {synced_count} {app_type:?} providers to live config");
"Synced {} {:?} providers to live config",
providers.len(),
app_type
);
Ok(()) Ok(())
} }
@@ -1207,12 +1227,16 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
}; };
// Create provider // Create provider
let provider = Provider::with_id( let mut provider = Provider::with_id(
id.clone(), id.clone(),
config.name.clone().unwrap_or_else(|| id.clone()), config.name.clone().unwrap_or_else(|| id.clone()),
settings_config, settings_config,
None, None,
); );
provider.meta = Some(crate::provider::ProviderMeta {
live_config_managed: Some(true),
..Default::default()
});
// Save to database // Save to database
if let Err(e) = state.db.save_provider("opencode", &provider) { if let Err(e) = state.db.save_provider("opencode", &provider) {
@@ -1277,7 +1301,11 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
.unwrap_or_else(|| id.clone()); .unwrap_or_else(|| id.clone());
// Create provider // Create provider
let provider = Provider::with_id(id.clone(), display_name, settings_config, None); let mut provider = Provider::with_id(id.clone(), display_name, settings_config, None);
provider.meta = Some(crate::provider::ProviderMeta {
live_config_managed: Some(true),
..Default::default()
});
// Save to database // Save to database
if let Err(e) = state.db.save_provider("openclaw", &provider) { if let Err(e) = state.db.save_provider("openclaw", &provider) {
File diff suppressed because it is too large Load Diff
+280 -39
View File
@@ -7,6 +7,7 @@ use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
use crate::database::Database; use crate::database::Database;
use crate::provider::Provider; use crate::provider::Provider;
use crate::proxy::server::ProxyServer; use crate::proxy::server::ProxyServer;
use crate::proxy::switch_lock::SwitchLockManager;
use crate::proxy::types::*; use crate::proxy::types::*;
use crate::services::provider::{ use crate::services::provider::{
build_effective_settings_with_common_config, write_live_with_common_config, build_effective_settings_with_common_config, write_live_with_common_config,
@@ -39,6 +40,12 @@ pub struct ProxyService {
server: Arc<RwLock<Option<ProxyServer>>>, server: Arc<RwLock<Option<ProxyServer>>>,
/// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新 /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新
app_handle: Arc<RwLock<Option<tauri::AppHandle>>>, app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,
switch_locks: SwitchLockManager,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct HotSwitchOutcome {
pub logical_target_changed: bool,
} }
impl ProxyService { impl ProxyService {
@@ -47,6 +54,7 @@ impl ProxyService {
db, db,
server: Arc::new(RwLock::new(None)), server: Arc::new(RwLock::new(None)),
app_handle: Arc::new(RwLock::new(None)), app_handle: Arc::new(RwLock::new(None)),
switch_locks: SwitchLockManager::new(),
} }
} }
@@ -1100,6 +1108,11 @@ impl ProxyService {
/// 恢复指定应用的 Live 配置(若无备份则不做任何操作) /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)
async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> { async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {
let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;
self.restore_live_config_for_app_inner(app_type).await
}
async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {
match app_type { match app_type {
AppType::Claude => { AppType::Claude => {
if let Ok(Some(backup)) = self.db.get_live_backup("claude").await { if let Ok(Some(backup)) = self.db.get_live_backup("claude").await {
@@ -1159,6 +1172,15 @@ impl ProxyService {
async fn restore_live_config_for_app_with_fallback( async fn restore_live_config_for_app_with_fallback(
&self, &self,
app_type: &AppType, app_type: &AppType,
) -> Result<(), String> {
let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;
self.restore_live_config_for_app_with_fallback_inner(app_type)
.await
}
async fn restore_live_config_for_app_with_fallback_inner(
&self,
app_type: &AppType,
) -> Result<(), String> { ) -> Result<(), String> {
let app_type_str = app_type.as_str(); let app_type_str = app_type.as_str();
@@ -1487,6 +1509,17 @@ impl ProxyService {
&self, &self,
app_type: &str, app_type: &str,
provider: &Provider, provider: &Provider,
) -> Result<(), String> {
let _guard = self.switch_locks.lock_for_app(app_type).await;
self.update_live_backup_from_provider_inner(app_type, provider)
.await
}
/// 仅供已持有 per-app 切换锁的调用方使用。
async fn update_live_backup_from_provider_inner(
&self,
app_type: &str,
provider: &Provider,
) -> Result<(), String> { ) -> Result<(), String> {
let app_type_enum = let app_type_enum =
AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?; AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?;
@@ -1540,6 +1573,69 @@ impl ProxyService {
Ok(()) Ok(())
} }
pub async fn hot_switch_provider(
&self,
app_type: &str,
provider_id: &str,
) -> Result<HotSwitchOutcome, String> {
let _guard = self.switch_locks.lock_for_app(app_type).await;
let app_type_enum =
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
let provider = self
.db
.get_provider_by_id(provider_id, app_type)
.map_err(|e| format!("读取供应商失败: {e}"))?
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
let logical_target_changed =
crate::settings::get_effective_current_provider(&self.db, &app_type_enum)
.map_err(|e| format!("读取当前供应商失败: {e}"))?
.as_deref()
!= Some(provider_id);
let has_backup = self
.db
.get_live_backup(app_type_enum.as_str())
.await
.map_err(|e| format!("读取 {app_type} 备份失败: {e}"))?
.is_some();
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
let should_sync_backup = has_backup || live_taken_over;
self.db
.set_current_provider(app_type_enum.as_str(), provider_id)
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
if should_sync_backup {
self.update_live_backup_from_provider_inner(app_type, &provider)
.await?;
if matches!(app_type_enum, AppType::Claude) {
if let Err(e) = self.cleanup_claude_model_overrides_in_live() {
log::warn!("清理 Claude Live 模型字段失败(不影响热切换结果): {e}");
}
}
}
if let Some(server) = self.server.read().await.as_ref() {
server
.set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
.await;
}
Ok(HotSwitchOutcome {
logical_target_changed,
})
}
#[cfg(test)]
async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {
self.switch_locks.lock_for_app(app_type).await
}
fn preserve_codex_mcp_servers_in_backup( fn preserve_codex_mcp_servers_in_backup(
target_settings: &mut Value, target_settings: &mut Value,
existing_backup: &Value, existing_backup: &Value,
@@ -1607,47 +1703,13 @@ impl ProxyService {
app_type: &str, app_type: &str,
provider_id: &str, provider_id: &str,
) -> Result<(), String> { ) -> Result<(), String> {
// 代理模式切换供应商(热切换): let outcome = self.hot_switch_provider(app_type, provider_id).await?;
// - 更新 SSOT(数据库 is_current
// - 同步本地 settings(设备级 current_provider_*
// - 若该应用正处于接管模式,则同步更新 Live 备份(用于停止代理时恢复)
let app_type_enum =
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
self.db if outcome.logical_target_changed {
.set_current_provider(app_type_enum.as_str(), provider_id) log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
.map_err(|e| format!("更新当前供应商失败: {e}"))?; } else {
log::debug!("代理模式:{app_type} 已对齐到目标供应商 {provider_id}");
// 同步本地 settings(设备级优先)
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
// 仅在确实处于接管状态时才更新 Live 备份,避免无接管时误写覆盖 Live
let has_backup = self
.db
.get_live_backup(app_type_enum.as_str())
.await
.ok()
.flatten()
.is_some();
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
// 同步更新 Live 备份(用于 stop_with_restore 恢复)
if has_backup || live_taken_over {
self.update_live_backup_from_provider(app_type, &provider)
.await?;
}
// 同步更新 ProxyStatus.active_targets(用于 UI 立即反映切换目标)
if let Some(server) = self.server.read().await.as_ref() {
server
.set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
.await;
}
} }
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
Ok(()) Ok(())
} }
@@ -2193,6 +2255,185 @@ model = "gpt-5.1-codex"
assert_eq!(backup.original_config, expected); assert_eq!(backup.original_config, expected);
} }
#[tokio::test]
#[serial]
async fn hot_switch_provider_serializes_same_app_switches() {
use tokio::time::{sleep, Duration};
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
let provider_a = Provider::with_id(
"a".to_string(),
"A".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
None,
);
let provider_b = Provider::with_id(
"b".to_string(),
"B".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
None,
);
let provider_c = Provider::with_id(
"c".to_string(),
"C".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "c-key" } }),
None,
);
db.save_provider("claude", &provider_a)
.expect("save provider a");
db.save_provider("claude", &provider_b)
.expect("save provider b");
db.save_provider("claude", &provider_c)
.expect("save provider c");
db.set_current_provider("claude", "a")
.expect("set current provider");
crate::settings::set_current_provider(&AppType::Claude, Some("a"))
.expect("set local current provider");
db.save_live_backup("claude", "{\"env\":{}}")
.await
.expect("seed live backup");
let guard = service.lock_switch_for_test("claude").await;
let service_for_b = service.clone();
let service_for_c = service.clone();
let switch_b = tokio::spawn(async move {
service_for_b
.hot_switch_provider("claude", "b")
.await
.expect("switch to b")
});
sleep(Duration::from_millis(20)).await;
let switch_c = tokio::spawn(async move {
service_for_c
.hot_switch_provider("claude", "c")
.await
.expect("switch to c")
});
sleep(Duration::from_millis(20)).await;
drop(guard);
let outcome_b = switch_b.await.expect("join switch b");
let outcome_c = switch_c.await.expect("join switch c");
assert!(outcome_b.logical_target_changed);
assert!(outcome_c.logical_target_changed);
assert_eq!(
crate::settings::get_effective_current_provider(&db, &AppType::Claude)
.expect("effective current"),
Some("c".to_string())
);
assert_eq!(
crate::settings::get_current_provider(&AppType::Claude).as_deref(),
Some("c")
);
assert_eq!(
db.get_current_provider("claude").expect("db current"),
Some("c".to_string())
);
let backup = db
.get_live_backup("claude")
.await
.expect("get live backup")
.expect("backup exists");
let expected = serde_json::to_string(&provider_c.settings_config).expect("serialize");
assert_eq!(backup.original_config, expected);
}
#[tokio::test]
#[serial]
async fn restore_waits_for_hot_switch_and_restores_latest_backup() {
use tokio::time::{sleep, Duration};
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
let provider_a = Provider::with_id(
"a".to_string(),
"A".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
None,
);
let provider_b = Provider::with_id(
"b".to_string(),
"B".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
None,
);
db.save_provider("claude", &provider_a)
.expect("save provider a");
db.save_provider("claude", &provider_b)
.expect("save provider b");
db.set_current_provider("claude", "a")
.expect("set current provider");
crate::settings::set_current_provider(&AppType::Claude, Some("a"))
.expect("set local current provider");
db.save_live_backup(
"claude",
&serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"),
)
.await
.expect("seed live backup");
service
.write_claude_live(&json!({ "env": { "ANTHROPIC_API_KEY": "stale" } }))
.expect("seed live file");
let guard = service.lock_switch_for_test("claude").await;
let service_for_switch = service.clone();
let service_for_restore = service.clone();
let switch_to_b = tokio::spawn(async move {
service_for_switch
.hot_switch_provider("claude", "b")
.await
.expect("switch to b")
});
sleep(Duration::from_millis(20)).await;
let restore = tokio::spawn(async move {
service_for_restore
.restore_live_config_for_app_with_fallback(&AppType::Claude)
.await
.expect("restore claude live")
});
sleep(Duration::from_millis(20)).await;
drop(guard);
let outcome = switch_to_b.await.expect("join switch");
restore.await.expect("join restore");
assert!(outcome.logical_target_changed);
assert_eq!(
crate::settings::get_effective_current_provider(&db, &AppType::Claude)
.expect("effective current"),
Some("b".to_string())
);
let backup = db
.get_live_backup("claude")
.await
.expect("get live backup")
.expect("backup exists");
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
assert_eq!(backup.original_config, expected);
assert_eq!(
service.read_claude_live().expect("read live"),
provider_b.settings_config
);
}
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn update_live_backup_from_provider_applies_claude_common_config() { async fn update_live_backup_from_provider_applies_claude_common_config() {
+1 -1
View File
@@ -957,7 +957,7 @@ impl SkillService {
if source_path.is_none() { if source_path.is_none() {
source_path = Some(skill_path); source_path = Some(skill_path);
} }
log::debug!("Skill '{}' found in source '{}'", dir_name, label); log::debug!("Skill '{dir_name}' found in source '{label}'");
} }
} }
+163 -51
View File
@@ -12,8 +12,9 @@ use std::time::Instant;
use crate::app_config::AppType; use crate::app_config::AppType;
use crate::error::AppError; use crate::error::AppError;
use crate::provider::Provider; use crate::provider::Provider;
use crate::proxy::providers::transform::anthropic_to_openai;
use crate::proxy::providers::copilot_auth; use crate::proxy::providers::copilot_auth;
use crate::proxy::providers::transform::anthropic_to_openai;
use crate::proxy::providers::transform_responses::anthropic_to_responses;
use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy}; use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy};
/// 健康状态枚举 /// 健康状态枚举
@@ -87,15 +88,21 @@ impl StreamCheckService {
provider: &Provider, provider: &Provider,
config: &StreamCheckConfig, config: &StreamCheckConfig,
auth_override: Option<AuthInfo>, auth_override: Option<AuthInfo>,
claude_api_format_override: Option<String>,
) -> Result<StreamCheckResult, AppError> { ) -> Result<StreamCheckResult, AppError> {
// 合并供应商单独配置和全局配置 // 合并供应商单独配置和全局配置
let effective_config = Self::merge_provider_config(provider, config); let effective_config = Self::merge_provider_config(provider, config);
let mut last_result = None; let mut last_result = None;
for attempt in 0..=effective_config.max_retries { for attempt in 0..=effective_config.max_retries {
let result = let result = Self::check_once(
Self::check_once(app_type, provider, &effective_config, auth_override.clone()) app_type,
.await; provider,
&effective_config,
auth_override.clone(),
claude_api_format_override.clone(),
)
.await;
match &result { match &result {
Ok(r) if r.success => { Ok(r) if r.success => {
@@ -184,6 +191,7 @@ impl StreamCheckService {
provider: &Provider, provider: &Provider,
config: &StreamCheckConfig, config: &StreamCheckConfig,
auth_override: Option<AuthInfo>, auth_override: Option<AuthInfo>,
claude_api_format_override: Option<String>,
) -> Result<StreamCheckResult, AppError> { ) -> Result<StreamCheckResult, AppError> {
let start = Instant::now(); let start = Instant::now();
let adapter = get_adapter(app_type); let adapter = get_adapter(app_type);
@@ -214,6 +222,7 @@ impl StreamCheckService {
test_prompt, test_prompt,
request_timeout, request_timeout,
provider, provider,
claude_api_format_override.as_deref(),
) )
.await .await
} }
@@ -225,6 +234,7 @@ impl StreamCheckService {
&model_to_test, &model_to_test,
test_prompt, test_prompt,
request_timeout, request_timeout,
provider,
) )
.await .await
} }
@@ -293,6 +303,7 @@ impl StreamCheckService {
/// 根据供应商的 api_format 选择请求格式: /// 根据供应商的 api_format 选择请求格式:
/// - "anthropic" (默认): Anthropic Messages API (/v1/messages) /// - "anthropic" (默认): Anthropic Messages API (/v1/messages)
/// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions) /// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions)
#[allow(clippy::too_many_arguments)]
async fn check_claude_stream( async fn check_claude_stream(
client: &Client, client: &Client,
base_url: &str, base_url: &str,
@@ -301,6 +312,7 @@ impl StreamCheckService {
test_prompt: &str, test_prompt: &str,
timeout: std::time::Duration, timeout: std::time::Duration,
provider: &Provider, provider: &Provider,
claude_api_format_override: Option<&str>,
) -> Result<(u16, String), AppError> { ) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let is_github_copilot = auth.strategy == AuthStrategy::GitHubCopilot; let is_github_copilot = auth.strategy == AuthStrategy::GitHubCopilot;
@@ -318,37 +330,31 @@ impl StreamCheckService {
}) })
.unwrap_or("anthropic"); .unwrap_or("anthropic");
let is_openai_chat = is_github_copilot || api_format == "openai_chat"; let effective_api_format = claude_api_format_override.unwrap_or(api_format);
// URL: let is_full_url = provider
// - GitHub Copilot: /chat/completions (no /v1 prefix) .meta
// - OpenAI-compatible: /v1/chat/completions .as_ref()
// - Anthropic native: /v1/messages?beta=true .and_then(|meta| meta.is_full_url)
let url = if is_github_copilot { .unwrap_or(false);
format!("{base}/chat/completions") let is_openai_chat = effective_api_format == "openai_chat";
} else if is_openai_chat { let is_openai_responses = effective_api_format == "openai_responses";
if base.ends_with("/v1") { let url =
format!("{base}/chat/completions") Self::resolve_claude_stream_url(base, auth.strategy, effective_api_format, is_full_url);
} else {
format!("{base}/v1/chat/completions")
}
} else {
// ?beta=true is required by some relay services to verify request origin
if base.ends_with("/v1") {
format!("{base}/messages?beta=true")
} else {
format!("{base}/v1/messages?beta=true")
}
};
// Build from Anthropic-native shape first, then convert for OpenAI-compatible targets. let max_tokens = if is_openai_responses { 16 } else { 1 };
// Build from Anthropic-native shape first, then convert for configured targets.
let anthropic_body = json!({ let anthropic_body = json!({
"model": model, "model": model,
"max_tokens": 1, "max_tokens": max_tokens,
"messages": [{ "role": "user", "content": test_prompt }], "messages": [{ "role": "user", "content": test_prompt }],
"stream": true "stream": true
}); });
let body = if is_openai_chat { let body = if is_openai_responses {
anthropic_to_responses(anthropic_body, Some(&provider.id))
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
} else if is_openai_chat {
anthropic_to_openai(anthropic_body, Some(&provider.id)) anthropic_to_openai(anthropic_body, Some(&provider.id))
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
} else { } else {
@@ -365,12 +371,18 @@ impl StreamCheckService {
.header("accept-encoding", "identity") .header("accept-encoding", "identity")
.header("user-agent", copilot_auth::COPILOT_USER_AGENT) .header("user-agent", copilot_auth::COPILOT_USER_AGENT)
.header("editor-version", copilot_auth::COPILOT_EDITOR_VERSION) .header("editor-version", copilot_auth::COPILOT_EDITOR_VERSION)
.header("editor-plugin-version", copilot_auth::COPILOT_PLUGIN_VERSION) .header(
.header("copilot-integration-id", copilot_auth::COPILOT_INTEGRATION_ID) "editor-plugin-version",
copilot_auth::COPILOT_PLUGIN_VERSION,
)
.header(
"copilot-integration-id",
copilot_auth::COPILOT_INTEGRATION_ID,
)
.header("x-github-api-version", copilot_auth::COPILOT_API_VERSION) .header("x-github-api-version", copilot_auth::COPILOT_API_VERSION)
.header("openai-intent", "conversation-panel"); .header("openai-intent", "conversation-panel");
} else if is_openai_chat { } else if is_openai_chat || is_openai_responses {
// OpenAI-compatible: Bearer auth + standard headers only // OpenAI-compatible targets: Bearer auth + SSE headers only
request_builder = request_builder request_builder = request_builder
.header("authorization", format!("Bearer {}", auth.api_key)) .header("authorization", format!("Bearer {}", auth.api_key))
.header("content-type", "application/json") .header("content-type", "application/json")
@@ -455,18 +467,14 @@ impl StreamCheckService {
model: &str, model: &str,
test_prompt: &str, test_prompt: &str,
timeout: std::time::Duration, timeout: std::time::Duration,
provider: &Provider,
) -> Result<(u16, String), AppError> { ) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/'); let is_full_url = provider
// Codex CLI 的 base_url 语义:base_url 是 API base(可能已包含 /v1 或其他自定义前缀), .meta
// Responses 端点为 `/responses`。 .as_ref()
// .and_then(|meta| meta.is_full_url)
// 兼容:如果 base_url 配成纯 origin(如 https://api.openai.com),则需要补 `/v1`。 .unwrap_or(false);
// 优先尝试 `{base}/responses`,若 404 再回退 `{base}/v1/responses`。 let urls = Self::resolve_codex_stream_urls(base_url, is_full_url);
let urls = if base.ends_with("/v1") {
vec![format!("{base}/responses")]
} else {
vec![format!("{base}/responses"), format!("{base}/v1/responses")]
};
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式) // 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model); let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
@@ -724,30 +732,64 @@ impl StreamCheckService {
} }
} }
#[cfg(test)]
fn resolve_claude_stream_url( fn resolve_claude_stream_url(
base_url: &str, base_url: &str,
auth_strategy: AuthStrategy, auth_strategy: AuthStrategy,
api_format: &str, api_format: &str,
is_full_url: bool,
) -> String { ) -> String {
if is_full_url {
return base_url.to_string();
}
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let is_github_copilot = auth_strategy == AuthStrategy::GitHubCopilot; let is_github_copilot = auth_strategy == AuthStrategy::GitHubCopilot;
let is_openai_chat = is_github_copilot || api_format == "openai_chat";
if is_github_copilot { if is_github_copilot && api_format == "openai_responses" {
format!("{base}/v1/responses")
} else if is_github_copilot {
format!("{base}/chat/completions") format!("{base}/chat/completions")
} else if is_openai_chat { } else if api_format == "openai_responses" {
if base.ends_with("/v1") {
format!("{base}/responses")
} else {
format!("{base}/v1/responses")
}
} else if api_format == "openai_chat" {
if base.ends_with("/v1") { if base.ends_with("/v1") {
format!("{base}/chat/completions") format!("{base}/chat/completions")
} else { } else {
format!("{base}/v1/chat/completions") format!("{base}/v1/chat/completions")
} }
} else if base.ends_with("/v1") { } else if base.ends_with("/v1") {
format!("{base}/messages?beta=true") format!("{base}/messages")
} else { } else {
format!("{base}/v1/messages?beta=true") format!("{base}/v1/messages")
} }
} }
fn resolve_codex_stream_urls(base_url: &str, is_full_url: bool) -> Vec<String> {
if is_full_url {
return vec![base_url.to_string()];
}
let base = base_url.trim_end_matches('/');
if base.ends_with("/v1") {
vec![format!("{base}/responses")]
} else {
vec![format!("{base}/responses"), format!("{base}/v1/responses")]
}
}
pub(crate) fn resolve_effective_test_model(
app_type: &AppType,
provider: &Provider,
config: &StreamCheckConfig,
) -> String {
let effective_config = Self::merge_provider_config(provider, config);
Self::resolve_test_model(app_type, provider, &effective_config)
}
} }
#[cfg(test)] #[cfg(test)]
@@ -851,36 +893,106 @@ mod tests {
assert_eq!(bearer, AuthStrategy::Bearer); assert_eq!(bearer, AuthStrategy::Bearer);
} }
#[test]
fn test_resolve_claude_stream_url_for_full_url_mode() {
let url = StreamCheckService::resolve_claude_stream_url(
"https://relay.example/v1/chat/completions",
AuthStrategy::Bearer,
"openai_chat",
true,
);
assert_eq!(url, "https://relay.example/v1/chat/completions");
}
#[test] #[test]
fn test_resolve_claude_stream_url_for_github_copilot() { fn test_resolve_claude_stream_url_for_github_copilot() {
let url = StreamCheckService::resolve_claude_stream_url( let url = StreamCheckService::resolve_claude_stream_url(
"https://api.githubcopilot.com", "https://api.githubcopilot.com",
AuthStrategy::GitHubCopilot, AuthStrategy::GitHubCopilot,
"anthropic", "openai_chat",
false,
); );
assert_eq!(url, "https://api.githubcopilot.com/chat/completions"); assert_eq!(url, "https://api.githubcopilot.com/chat/completions");
} }
#[test]
fn test_resolve_claude_stream_url_for_github_copilot_responses() {
let url = StreamCheckService::resolve_claude_stream_url(
"https://api.githubcopilot.com",
AuthStrategy::GitHubCopilot,
"openai_responses",
false,
);
assert_eq!(url, "https://api.githubcopilot.com/v1/responses");
}
#[test] #[test]
fn test_resolve_claude_stream_url_for_openai_chat() { fn test_resolve_claude_stream_url_for_openai_chat() {
let url = StreamCheckService::resolve_claude_stream_url( let url = StreamCheckService::resolve_claude_stream_url(
"https://example.com/v1", "https://example.com/v1",
AuthStrategy::Bearer, AuthStrategy::Bearer,
"openai_chat", "openai_chat",
false,
); );
assert_eq!(url, "https://example.com/v1/chat/completions"); assert_eq!(url, "https://example.com/v1/chat/completions");
} }
#[test]
fn test_resolve_claude_stream_url_for_openai_responses() {
let url = StreamCheckService::resolve_claude_stream_url(
"https://example.com/v1",
AuthStrategy::Bearer,
"openai_responses",
false,
);
assert_eq!(url, "https://example.com/v1/responses");
}
#[test] #[test]
fn test_resolve_claude_stream_url_for_anthropic() { fn test_resolve_claude_stream_url_for_anthropic() {
let url = StreamCheckService::resolve_claude_stream_url( let url = StreamCheckService::resolve_claude_stream_url(
"https://api.anthropic.com", "https://api.anthropic.com",
AuthStrategy::Anthropic, AuthStrategy::Anthropic,
"anthropic", "anthropic",
false,
); );
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true"); assert_eq!(url, "https://api.anthropic.com/v1/messages");
}
#[test]
fn test_resolve_codex_stream_urls_for_full_url_mode() {
let urls = StreamCheckService::resolve_codex_stream_urls(
"https://relay.example/custom/responses",
true,
);
assert_eq!(urls, vec!["https://relay.example/custom/responses"]);
}
#[test]
fn test_resolve_codex_stream_urls_for_v1_base() {
let urls =
StreamCheckService::resolve_codex_stream_urls("https://api.openai.com/v1", false);
assert_eq!(urls, vec!["https://api.openai.com/v1/responses"]);
}
#[test]
fn test_resolve_codex_stream_urls_for_origin_base() {
let urls = StreamCheckService::resolve_codex_stream_urls("https://api.openai.com", false);
assert_eq!(
urls,
vec![
"https://api.openai.com/responses",
"https://api.openai.com/v1/responses",
]
);
} }
} }
+4 -3
View File
@@ -204,10 +204,11 @@ pub async fn ensure_remote_directories(
s if s == StatusCode::CREATED || s.is_success() => { s if s == StatusCode::CREATED || s.is_success() => {
log::info!("[WebDAV] MKCOL ok: {}", redact_url(&dir_url)); log::info!("[WebDAV] MKCOL ok: {}", redact_url(&dir_url));
} }
// 405 commonly means "already exists" on many WebDAV servers
StatusCode::METHOD_NOT_ALLOWED => {}
// Ambiguous — verify directory actually exists via PROPFIND // Ambiguous — verify directory actually exists via PROPFIND
s if s == StatusCode::CONFLICT || s.is_redirection() => { s if s == StatusCode::METHOD_NOT_ALLOWED
|| s == StatusCode::CONFLICT
|| s.is_redirection() =>
{
if !propfind_exists(&client, &dir_url, auth).await? { if !propfind_exists(&client, &dir_url, auth).await? {
return Err(webdav_status_error("MKCOL", status, &dir_url)); return Err(webdav_status_error("MKCOL", status, &dir_url));
} }
+6 -13
View File
@@ -463,12 +463,10 @@ fn validate_manifest_compat(manifest: &SyncManifest, layout: RemoteLayout) -> Re
return Err(localized( return Err(localized(
"webdav.sync.manifest_db_version_incompatible", "webdav.sync.manifest_db_version_incompatible",
format!( format!(
"远端数据库快照版本不兼容: db-v{} (本地 db-v{DB_COMPAT_VERSION})", "远端数据库快照版本不兼容: db-v{db_compat_version} (本地 db-v{DB_COMPAT_VERSION})"
db_compat_version
), ),
format!( format!(
"Remote database snapshot version is incompatible: db-v{} (local db-v{DB_COMPAT_VERSION})", "Remote database snapshot version is incompatible: db-v{db_compat_version} (local db-v{DB_COMPAT_VERSION})"
db_compat_version
), ),
)); ));
} }
@@ -476,12 +474,10 @@ fn validate_manifest_compat(manifest: &SyncManifest, layout: RemoteLayout) -> Re
return Err(localized( return Err(localized(
"webdav.sync.manifest_db_version_incompatible", "webdav.sync.manifest_db_version_incompatible",
format!( format!(
"远端数据库快照版本不兼容: db-v{} (本地最高支持 db-v{DB_COMPAT_VERSION})", "远端数据库快照版本不兼容: db-v{db_compat_version} (本地最高支持 db-v{DB_COMPAT_VERSION})"
db_compat_version
), ),
format!( format!(
"Remote database snapshot version is incompatible: db-v{} (local supports up to db-v{DB_COMPAT_VERSION})", "Remote database snapshot version is incompatible: db-v{db_compat_version} (local supports up to db-v{DB_COMPAT_VERSION})"
db_compat_version
), ),
)); ));
} }
@@ -661,11 +657,8 @@ fn validate_artifact_size_limit(artifact_name: &str, size: u64) -> Result<(), Ap
let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024; let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024;
return Err(localized( return Err(localized(
"webdav.sync.artifact_too_large", "webdav.sync.artifact_too_large",
format!("artifact {artifact_name} 超过下载上限({} MB", max_mb), format!("artifact {artifact_name} 超过下载上限({max_mb} MB"),
format!( format!("Artifact {artifact_name} exceeds download limit ({max_mb} MB)"),
"Artifact {artifact_name} exceeds download limit ({} MB)",
max_mb
),
)); ));
} }
Ok(()) Ok(())
@@ -350,8 +350,8 @@ fn copy_entry_with_total_limit<R: Read, W: Write>(
let max_mb = max_total_bytes / 1024 / 1024; let max_mb = max_total_bytes / 1024 / 1024;
return Err(localized( return Err(localized(
"webdav.sync.skills_zip_too_large", "webdav.sync.skills_zip_too_large",
format!("skills.zip 解压后体积超过上限({} MB", max_mb), format!("skills.zip 解压后体积超过上限({max_mb} MB"),
format!("skills.zip extracted size exceeds limit ({} MB)", max_mb), format!("skills.zip extracted size exceeds limit ({max_mb} MB)"),
)); ));
} }
+105 -1
View File
@@ -1,7 +1,7 @@
pub mod providers; pub mod providers;
pub mod terminal; pub mod terminal;
use serde::Serialize; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use providers::{claude, codex, gemini, openclaw, opencode}; use providers::{claude, codex, gemini, openclaw, opencode};
@@ -36,6 +36,25 @@ pub struct SessionMessage {
pub ts: Option<i64>, pub ts: Option<i64>,
} }
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteSessionRequest {
pub provider_id: String,
pub session_id: String,
pub source_path: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteSessionOutcome {
pub provider_id: String,
pub session_id: String,
pub source_path: String,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub fn scan_sessions() -> Vec<SessionMeta> { pub fn scan_sessions() -> Vec<SessionMeta> {
let (r1, r2, r3, r4, r5) = std::thread::scope(|s| { let (r1, r2, r3, r4, r5) = std::thread::scope(|s| {
let h1 = s.spawn(codex::scan_sessions); let h1 = s.spawn(codex::scan_sessions);
@@ -99,6 +118,16 @@ pub fn delete_session(
delete_session_with_root(provider_id, session_id, Path::new(source_path), &root) delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)
} }
pub fn delete_sessions(requests: &[DeleteSessionRequest]) -> Vec<DeleteSessionOutcome> {
collect_delete_session_outcomes(requests, |request| {
delete_session(
&request.provider_id,
&request.session_id,
&request.source_path,
)
})
}
fn delete_session_with_root( fn delete_session_with_root(
provider_id: &str, provider_id: &str,
session_id: &str, session_id: &str,
@@ -147,6 +176,41 @@ fn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf, Strin
.map_err(|e| format!("Failed to resolve {label} {}: {e}", path.display())) .map_err(|e| format!("Failed to resolve {label} {}: {e}", path.display()))
} }
fn collect_delete_session_outcomes<F>(
requests: &[DeleteSessionRequest],
mut deleter: F,
) -> Vec<DeleteSessionOutcome>
where
F: FnMut(&DeleteSessionRequest) -> Result<bool, String>,
{
requests
.iter()
.map(|request| match deleter(request) {
Ok(true) => DeleteSessionOutcome {
provider_id: request.provider_id.clone(),
session_id: request.session_id.clone(),
source_path: request.source_path.clone(),
success: true,
error: None,
},
Ok(false) => DeleteSessionOutcome {
provider_id: request.provider_id.clone(),
session_id: request.session_id.clone(),
source_path: request.source_path.clone(),
success: false,
error: Some("Session was not deleted".to_string()),
},
Err(error) => DeleteSessionOutcome {
provider_id: request.provider_id.clone(),
session_id: request.session_id.clone(),
source_path: request.source_path.clone(),
success: false,
error: Some(error),
},
})
.collect()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -175,4 +239,44 @@ mod tests {
assert!(err.contains("session source not found")); assert!(err.contains("session source not found"));
} }
#[test]
fn batch_delete_collects_successes_and_failures_in_order() {
let requests = vec![
DeleteSessionRequest {
provider_id: "codex".to_string(),
session_id: "s1".to_string(),
source_path: "/tmp/s1".to_string(),
},
DeleteSessionRequest {
provider_id: "claude".to_string(),
session_id: "s2".to_string(),
source_path: "/tmp/s2".to_string(),
},
DeleteSessionRequest {
provider_id: "gemini".to_string(),
session_id: "s3".to_string(),
source_path: "/tmp/s3".to_string(),
},
];
let outcomes = collect_delete_session_outcomes(&requests, |request| {
match request.session_id.as_str() {
"s1" => Ok(true),
"s2" => Err("boom".to_string()),
_ => Ok(false),
}
});
assert_eq!(outcomes.len(), 3);
assert!(outcomes[0].success);
assert_eq!(outcomes[0].error, None);
assert!(!outcomes[1].success);
assert_eq!(outcomes[1].error.as_deref(), Some("boom"));
assert!(!outcomes[2].success);
assert_eq!(
outcomes[2].error.as_deref(),
Some("Session was not deleted")
);
}
} }
@@ -148,7 +148,7 @@ fn scan_sessions_sqlite() -> Vec<SessionMeta> {
}, },
created_at: Some(created), created_at: Some(created),
last_active_at: Some(updated), last_active_at: Some(updated),
source_path: Some(format!("sqlite:{}:{}", db_display, session_id)), source_path: Some(format!("sqlite:{db_display}:{session_id}")),
resume_command: Some(format!("opencode session resume {session_id}")), resume_command: Some(format!("opencode session resume {session_id}")),
}); });
} }
+8 -11
View File
@@ -584,17 +584,14 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
/// 这是设备级别的设置,不随数据库同步。 /// 这是设备级别的设置,不随数据库同步。
/// 传入 `None` 会清除当前供应商设置。 /// 传入 `None` 会清除当前供应商设置。
pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppError> { pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppError> {
let mut settings = get_settings(); let id_owned = id.map(|s| s.to_string());
mutate_settings(|settings| match app_type {
match app_type { AppType::Claude => settings.current_provider_claude = id_owned.clone(),
AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()), AppType::Codex => settings.current_provider_codex = id_owned.clone(),
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()), AppType::Gemini => settings.current_provider_gemini = id_owned.clone(),
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()), AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(),
AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()), AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(),
AppType::OpenClaw => settings.current_provider_openclaw = id.map(|s| s.to_string()), })
}
update_settings(settings)
} }
/// 获取有效的当前供应商 ID(验证存在性) /// 获取有效的当前供应商 ID(验证存在性)
+43
View File
@@ -14,6 +14,7 @@ use crate::store::AppState;
pub struct TrayTexts { pub struct TrayTexts {
pub show_main: &'static str, pub show_main: &'static str,
pub no_provider_hint: &'static str, pub no_provider_hint: &'static str,
pub lightweight_mode: &'static str,
pub quit: &'static str, pub quit: &'static str,
pub _auto_label: &'static str, pub _auto_label: &'static str,
} }
@@ -24,6 +25,7 @@ impl TrayTexts {
"en" => Self { "en" => Self {
show_main: "Open main window", show_main: "Open main window",
no_provider_hint: " (No providers yet, please add them from the main window)", no_provider_hint: " (No providers yet, please add them from the main window)",
lightweight_mode: "Lightweight Mode",
quit: "Quit", quit: "Quit",
_auto_label: "Auto (Failover)", _auto_label: "Auto (Failover)",
}, },
@@ -31,12 +33,14 @@ impl TrayTexts {
show_main: "メインウィンドウを開く", show_main: "メインウィンドウを開く",
no_provider_hint: no_provider_hint:
" (プロバイダーがまだありません。メイン画面から追加してください)", " (プロバイダーがまだありません。メイン画面から追加してください)",
lightweight_mode: "軽量モード",
quit: "終了", quit: "終了",
_auto_label: "自動 (フェイルオーバー)", _auto_label: "自動 (フェイルオーバー)",
}, },
_ => Self { _ => Self {
show_main: "打开主界面", show_main: "打开主界面",
no_provider_hint: " (无供应商,请在主界面添加)", no_provider_hint: " (无供应商,请在主界面添加)",
lightweight_mode: "轻量模式",
quit: "退出", quit: "退出",
_auto_label: "自动 (故障转移)", _auto_label: "自动 (故障转移)",
}, },
@@ -382,6 +386,18 @@ pub fn create_tray_menu(
menu_builder = menu_builder.separator(); menu_builder = menu_builder.separator();
} }
let lightweight_item = CheckMenuItem::with_id(
app,
"lightweight_mode",
tray_texts.lightweight_mode,
true,
crate::lightweight::is_lightweight_mode(),
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建轻量模式菜单失败: {e}")))?;
menu_builder = menu_builder.item(&lightweight_item).separator();
// 退出菜单(分隔符已在上面的 section 循环中添加) // 退出菜单(分隔符已在上面的 section 循环中添加)
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>) let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?; .map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
@@ -393,6 +409,20 @@ pub fn create_tray_menu(
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}"))) .map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
} }
pub fn refresh_tray_menu(app: &tauri::AppHandle) {
use crate::store::AppState;
if let Some(state) = app.try_state::<AppState>() {
if let Ok(new_menu) = create_tray_menu(app, state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
log::error!("刷新托盘菜单失败: {e}");
}
}
}
}
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) { pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
use tauri::ActivationPolicy; use tauri::ActivationPolicy;
@@ -430,6 +460,19 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
{ {
apply_tray_policy(app, true); apply_tray_policy(app, true);
} }
} else if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
}
"lightweight_mode" => {
if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
log::error!("退出轻量模式失败: {e}");
}
} else if let Err(e) = crate::lightweight::enter_lightweight_mode(app) {
log::error!("进入轻量模式失败: {e}");
} }
} }
"quit" => { "quit" => {
+50 -8
View File
@@ -255,7 +255,7 @@ function App() {
deleteProvider, deleteProvider,
saveUsageScript, saveUsageScript,
setAsDefaultModel, setAsDefaultModel,
} = useProviderActions(activeApp); } = useProviderActions(activeApp, isProxyRunning);
const disableOmoMutation = useDisableCurrentOmo(); const disableOmoMutation = useDisableCurrentOmo();
const handleDisableOmo = () => { const handleDisableOmo = () => {
@@ -533,8 +533,14 @@ function App() {
} }
}; };
const handleEditProvider = async (provider: Provider) => { const handleEditProvider = async ({
await updateProvider(provider); provider,
originalId,
}: {
provider: Provider;
originalId?: string;
}) => {
await updateProvider(provider, originalId);
setEditingProvider(null); setEditingProvider(null);
}; };
@@ -571,7 +577,7 @@ function App() {
setConfirmAction(null); setConfirmAction(null);
}; };
const generateUniqueOpencodeKey = ( const generateUniqueProviderCopyKey = (
originalKey: string, originalKey: string,
existingKeys: string[], existingKeys: string[],
): string => { ): string => {
@@ -594,6 +600,7 @@ function App() {
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> & { const duplicatedProvider: Omit<Provider, "id" | "createdAt"> & {
providerKey?: string; providerKey?: string;
addToLive?: boolean;
} = { } = {
name: `${provider.name} copy`, name: `${provider.name} copy`,
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝 settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
@@ -607,12 +614,40 @@ function App() {
iconColor: provider.iconColor, iconColor: provider.iconColor,
}; };
if (activeApp === "opencode") { if (activeApp === "opencode" || activeApp === "openclaw") {
const existingKeys = Object.keys(providers); let liveProviderIds: string[] = [];
duplicatedProvider.providerKey = generateUniqueOpencodeKey( try {
liveProviderIds =
activeApp === "opencode"
? await queryClient.ensureQueryData({
queryKey: ["opencodeLiveProviderIds"],
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
})
: await queryClient.ensureQueryData({
queryKey: openclawKeys.liveProviderIds,
queryFn: () => providersApi.getOpenClawLiveProviderIds(),
});
} catch (error) {
console.error(
"[App] Failed to load live provider IDs for duplication",
error,
);
const errorMessage = extractErrorMessage(error);
toast.error(
t("provider.duplicateLiveIdsLoadFailed", {
defaultValue: "读取配置中的供应商标识失败,请先修复配置后再试",
}) + (errorMessage ? `: ${errorMessage}` : ""),
);
return;
}
const existingKeys = Array.from(
new Set([...Object.keys(providers), ...liveProviderIds]),
);
duplicatedProvider.providerKey = generateUniqueProviderCopyKey(
provider.id, provider.id,
existingKeys, existingKeys,
); );
duplicatedProvider.addToLive = false;
} }
if (provider.sortIndex !== undefined) { if (provider.sortIndex !== undefined) {
@@ -648,7 +683,14 @@ function App() {
const handleOpenTerminal = async (provider: Provider) => { const handleOpenTerminal = async (provider: Provider) => {
try { try {
await providersApi.openTerminal(provider.id, activeApp); const selectedDir = await settingsApi.pickDirectory();
if (!selectedDir) {
return;
}
await providersApi.openTerminal(provider.id, activeApp, {
cwd: selectedDir,
});
toast.success( toast.success(
t("provider.terminalOpened", { t("provider.terminalOpened", {
defaultValue: "终端已打开", defaultValue: "终端已打开",
@@ -42,9 +42,9 @@ export function AddProviderDialog({
const { t } = useTranslation(); const { t } = useTranslation();
// OpenCode and OpenClaw don't support universal providers // OpenCode and OpenClaw don't support universal providers
const showUniversalTab = appId !== "opencode" && appId !== "openclaw"; const showUniversalTab = appId !== "opencode" && appId !== "openclaw";
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
"app-specific" | "universal" "app-specific",
>("app-specific"); );
const [universalFormOpen, setUniversalFormOpen] = useState(false); const [universalFormOpen, setUniversalFormOpen] = useState(false);
const [selectedUniversalPreset, setSelectedUniversalPreset] = const [selectedUniversalPreset, setSelectedUniversalPreset] =
useState<UniversalProviderPreset | null>(null); useState<UniversalProviderPreset | null>(null);
@@ -284,9 +284,7 @@ export function AddProviderDialog({
{showUniversalTab ? ( {showUniversalTab ? (
<Tabs <Tabs
value={activeTab} value={activeTab}
onValueChange={(v) => onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
setActiveTab(v as "app-specific" | "universal")
}
> >
<TabsList className="grid w-full grid-cols-2 mb-6"> <TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="app-specific"> <TabsTrigger value="app-specific">
@@ -14,7 +14,10 @@ interface EditProviderDialogProps {
open: boolean; open: boolean;
provider: Provider | null; provider: Provider | null;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSubmit: (provider: Provider) => Promise<void> | void; onSubmit: (payload: {
provider: Provider;
originalId?: string;
}) => Promise<void> | void;
appId: AppId; appId: AppId;
isProxyTakeover?: boolean; // 代理接管模式下不读取 live(避免显示被接管后的代理配置) isProxyTakeover?: boolean; // 代理接管模式下不读取 live(避免显示被接管后的代理配置)
} }
@@ -165,9 +168,15 @@ export function EditProviderDialog({
string, string,
unknown unknown
>; >;
const nextProviderId =
(appId === "opencode" || appId === "openclaw") &&
values.providerKey?.trim()
? values.providerKey.trim()
: provider.id;
const updatedProvider: Provider = { const updatedProvider: Provider = {
...provider, ...provider,
id: nextProviderId,
name: values.name.trim(), name: values.name.trim(),
notes: values.notes?.trim() || undefined, notes: values.notes?.trim() || undefined,
websiteUrl: values.websiteUrl?.trim() || undefined, websiteUrl: values.websiteUrl?.trim() || undefined,
@@ -179,10 +188,13 @@ export function EditProviderDialog({
...(values.meta ? { meta: values.meta } : {}), ...(values.meta ? { meta: values.meta } : {}),
}; };
await onSubmit(updatedProvider); await onSubmit({
provider: updatedProvider,
originalId: provider.id,
});
onOpenChange(false); onOpenChange(false);
}, },
[onSubmit, onOpenChange, provider], [appId, onSubmit, onOpenChange, provider],
); );
if (!provider || !initialData) { if (!provider || !initialData) {
+11 -4
View File
@@ -123,6 +123,7 @@ export function ProviderCard({
// OMO and OMO Slim share the same card behavior // OMO and OMO Slim share the same card behavior
const isAnyOmo = isOmo || isOmoSlim; const isAnyOmo = isOmo || isOmoSlim;
const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo; const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo;
const isAdditiveMode = appId === "opencode" && !isAnyOmo;
const { data: health } = useProviderHealth(provider.id, appId); const { data: health } = useProviderHealth(provider.id, appId);
@@ -209,9 +210,12 @@ export function ProviderCard({
: isCurrent; : isCurrent;
const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider; const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;
const hasPersistentConfigHighlight = isAdditiveMode && isInConfig;
const shouldUseBlue = const shouldUseBlue =
(isAnyOmo && isActiveProvider) || (isAnyOmo && isActiveProvider) ||
(!isAnyOmo && !isProxyTakeover && isActiveProvider); (!isAnyOmo &&
!isProxyTakeover &&
(isActiveProvider || hasPersistentConfigHighlight));
return ( return (
<div <div
@@ -224,7 +228,8 @@ export function ProviderCard({
shouldUseGreen && shouldUseGreen &&
"border-emerald-500/60 shadow-sm shadow-emerald-500/10", "border-emerald-500/60 shadow-sm shadow-emerald-500/10",
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10", shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
!isActiveProvider && "hover:shadow-sm", !(isActiveProvider || hasPersistentConfigHighlight) &&
"hover:shadow-sm",
dragHandleProps?.isDragging && dragHandleProps?.isDragging &&
"cursor-grabbing border-primary shadow-lg scale-105 z-10", "cursor-grabbing border-primary shadow-lg scale-105 z-10",
)} )}
@@ -234,8 +239,10 @@ export function ProviderCard({
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none", "absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
shouldUseGreen && "from-emerald-500/10", shouldUseGreen && "from-emerald-500/10",
shouldUseBlue && "from-blue-500/10", shouldUseBlue && "from-blue-500/10",
!isActiveProvider && "from-primary/10", !shouldUseGreen && !shouldUseBlue && "from-primary/10",
isActiveProvider ? "opacity-100" : "opacity-0", isActiveProvider || hasPersistentConfigHighlight
? "opacity-100"
: "opacity-0",
)} )}
/> />
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@@ -108,6 +108,10 @@ interface ClaudeFormFieldsProps {
// Auth Field (ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY) // Auth Field (ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY)
apiKeyField: ClaudeApiKeyField; apiKeyField: ClaudeApiKeyField;
onApiKeyFieldChange: (field: ClaudeApiKeyField) => void; onApiKeyFieldChange: (field: ClaudeApiKeyField) => void;
// Full URL mode
isFullUrl: boolean;
onFullUrlChange: (value: boolean) => void;
} }
export function ClaudeFormFields({ export function ClaudeFormFields({
@@ -149,6 +153,8 @@ export function ClaudeFormFields({
onApiFormatChange, onApiFormatChange,
apiKeyField, apiKeyField,
onApiKeyFieldChange, onApiKeyFieldChange,
isFullUrl,
onFullUrlChange,
}: ClaudeFormFieldsProps) { }: ClaudeFormFieldsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const hasAnyAdvancedValue = !!( const hasAnyAdvancedValue = !!(
@@ -160,8 +166,7 @@ export function ClaudeFormFields({
apiFormat !== "anthropic" || apiFormat !== "anthropic" ||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN" apiKeyField !== "ANTHROPIC_AUTH_TOKEN"
); );
const [advancedExpanded, setAdvancedExpanded] = const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
useState(hasAnyAdvancedValue);
// 预设填充高级值后自动展开(仅从折叠→展开,不会自动折叠) // 预设填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
useEffect(() => { useEffect(() => {
@@ -379,6 +384,9 @@ export function ClaudeFormFields({
: t("providerForm.apiHint") : t("providerForm.apiHint")
} }
onManageClick={() => onEndpointModalToggle(true)} onManageClick={() => onEndpointModalToggle(true)}
showFullUrlToggle={true}
isFullUrl={isFullUrl}
onFullUrlChange={onFullUrlChange}
/> />
)} )}
@@ -400,10 +408,7 @@ export function ClaudeFormFields({
{/* 高级选项(API 格式 + 认证字段 + 模型映射) */} {/* 高级选项(API 格式 + 认证字段 + 模型映射) */}
{shouldShowModelSelector && ( {shouldShowModelSelector && (
<Collapsible <Collapsible open={advancedExpanded} onOpenChange={setAdvancedExpanded}>
open={advancedExpanded}
onOpenChange={setAdvancedExpanded}
>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button <Button
type="button" type="button"
@@ -1,4 +1,10 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import JsonEditor from "@/components/JsonEditor"; import JsonEditor from "@/components/JsonEditor";
import { import {
@@ -175,10 +181,7 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
} }
} else { } else {
toml = removeCodexTopLevelField(toml, "model_context_window"); toml = removeCodexTopLevelField(toml, "model_context_window");
toml = removeCodexTopLevelField( toml = removeCodexTopLevelField(toml, "model_auto_compact_token_limit");
toml,
"model_auto_compact_token_limit",
);
} }
handleLocalChange(toml); handleLocalChange(toml);
}, },
@@ -22,6 +22,8 @@ interface CodexFormFieldsProps {
shouldShowSpeedTest: boolean; shouldShowSpeedTest: boolean;
codexBaseUrl: string; codexBaseUrl: string;
onBaseUrlChange: (url: string) => void; onBaseUrlChange: (url: string) => void;
isFullUrl: boolean;
onFullUrlChange: (value: boolean) => void;
isEndpointModalOpen: boolean; isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void; onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void; onCustomEndpointsChange?: (endpoints: string[]) => void;
@@ -49,6 +51,8 @@ export function CodexFormFields({
shouldShowSpeedTest, shouldShowSpeedTest,
codexBaseUrl, codexBaseUrl,
onBaseUrlChange, onBaseUrlChange,
isFullUrl,
onFullUrlChange,
isEndpointModalOpen, isEndpointModalOpen,
onEndpointModalToggle, onEndpointModalToggle,
onCustomEndpointsChange, onCustomEndpointsChange,
@@ -93,6 +97,9 @@ export function CodexFormFields({
onChange={onBaseUrlChange} onChange={onBaseUrlChange}
placeholder={t("providerForm.codexApiEndpointPlaceholder")} placeholder={t("providerForm.codexApiEndpointPlaceholder")}
hint={t("providerForm.codexApiHint")} hint={t("providerForm.codexApiHint")}
showFullUrlToggle
isFullUrl={isFullUrl}
onFullUrlChange={onFullUrlChange}
onManageClick={() => onEndpointModalToggle(true)} onManageClick={() => onEndpointModalToggle(true)}
/> />
)} )}
+152 -24
View File
@@ -1,13 +1,14 @@
import { useEffect, useMemo, useState, useCallback } from "react"; import { useEffect, useMemo, useState, useCallback } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import type { AppId } from "@/lib/api"; import { providersApi, type AppId } from "@/lib/api";
import type { import type {
ProviderCategory, ProviderCategory,
ProviderMeta, ProviderMeta,
@@ -91,6 +92,7 @@ import {
normalizePricingSource, normalizePricingSource,
} from "./helpers/opencodeFormUtils"; } from "./helpers/opencodeFormUtils";
import { resolveManagedAccountId } from "@/lib/authBinding"; import { resolveManagedAccountId } from "@/lib/authBinding";
import { useOpenClawLiveProviderIds } from "@/hooks/useOpenClaw";
type PresetEntry = { type PresetEntry = {
id: string; id: string;
@@ -162,6 +164,11 @@ export function ProviderForm({
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>( const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
() => initialData?.meta?.endpointAutoSelect ?? true, () => initialData?.meta?.endpointAutoSelect ?? true,
); );
const supportsFullUrl = appId === "claude" || appId === "codex";
const [localIsFullUrl, setLocalIsFullUrl] = useState<boolean>(() => {
if (!supportsFullUrl) return false;
return initialData?.meta?.isFullUrl ?? false;
});
const [testConfig, setTestConfig] = useState<ProviderTestConfig>( const [testConfig, setTestConfig] = useState<ProviderTestConfig>(
() => initialData?.meta?.testConfig ?? { enabled: false }, () => initialData?.meta?.testConfig ?? { enabled: false },
@@ -201,6 +208,9 @@ export function ProviderForm({
setDraftCustomEndpoints([]); setDraftCustomEndpoints([]);
} }
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true); setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
setLocalIsFullUrl(
supportsFullUrl ? (initialData?.meta?.isFullUrl ?? false) : false,
);
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false }); setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false }); setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });
setPricingConfig({ setPricingConfig({
@@ -212,7 +222,7 @@ export function ProviderForm({
initialData?.meta?.pricingModelSource, initialData?.meta?.pricingModelSource,
), ),
}); });
}, [appId, initialData]); }, [appId, initialData, supportsFullUrl]);
const defaultValues: ProviderFormData = useMemo( const defaultValues: ProviderFormData = useMemo(
() => ({ () => ({
@@ -569,6 +579,15 @@ export function ProviderForm({
existingOpencodeKeys, existingOpencodeKeys,
} = useOmoModelSource({ isOmoCategory: isAnyOmoCategory, providerId }); } = useOmoModelSource({ isOmoCategory: isAnyOmoCategory, providerId });
const {
data: opencodeLiveProviderIds = [],
isLoading: isOpencodeLiveProviderIdsLoading,
} = useQuery({
queryKey: ["opencodeLiveProviderIds"],
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
enabled: appId === "opencode" && !isAnyOmoCategory,
});
const opencodeForm = useOpencodeFormState({ const opencodeForm = useOpencodeFormState({
initialData, initialData,
appId, appId,
@@ -597,6 +616,78 @@ export function ProviderForm({
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config), onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
getSettingsConfig: () => form.getValues("settingsConfig"), getSettingsConfig: () => form.getValues("settingsConfig"),
}); });
const {
data: openclawLiveProviderIds = [],
isLoading: isOpenclawLiveProviderIdsLoading,
} = useOpenClawLiveProviderIds(appId === "openclaw");
const additiveExistingProviderKeys = useMemo(() => {
if (appId === "opencode" && !isAnyOmoCategory) {
return Array.from(
new Set(
[...existingOpencodeKeys, ...opencodeLiveProviderIds].filter(
(key) => key !== providerId,
),
),
);
}
if (appId === "openclaw") {
return Array.from(
new Set(
[
...openclawForm.existingOpenclawKeys,
...openclawLiveProviderIds,
].filter((key) => key !== providerId),
),
);
}
return [];
}, [
appId,
existingOpencodeKeys,
isAnyOmoCategory,
openclawForm.existingOpenclawKeys,
openclawLiveProviderIds,
opencodeLiveProviderIds,
providerId,
]);
const isProviderKeyLockStateLoading = useMemo(() => {
if (!isEditMode) return false;
if (appId === "opencode" && !isAnyOmoCategory) {
return isOpencodeLiveProviderIdsLoading;
}
if (appId === "openclaw") {
return isOpenclawLiveProviderIdsLoading;
}
return false;
}, [
appId,
isAnyOmoCategory,
isEditMode,
isOpenclawLiveProviderIdsLoading,
isOpencodeLiveProviderIdsLoading,
]);
const isProviderKeyLocked = useMemo(() => {
if (!isEditMode || !providerId) return false;
if (appId === "opencode" && !isAnyOmoCategory) {
return opencodeLiveProviderIds.includes(providerId);
}
if (appId === "openclaw") {
return openclawLiveProviderIds.includes(providerId);
}
return false;
}, [
appId,
isAnyOmoCategory,
isEditMode,
openclawLiveProviderIds,
opencodeLiveProviderIds,
providerId,
]);
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
@@ -633,9 +724,17 @@ export function ProviderForm({
toast.error(t("opencode.providerKeyInvalid")); toast.error(t("opencode.providerKeyInvalid"));
return; return;
} }
if (isProviderKeyLockStateLoading) {
toast.error(
t("providerForm.providerKeyStatusLoading", {
defaultValue: "正在加载供应商标识状态,请稍后再试",
}),
);
return;
}
if ( if (
!isEditMode && !isProviderKeyLocked &&
existingOpencodeKeys.includes(opencodeForm.opencodeProviderKey) additiveExistingProviderKeys.includes(opencodeForm.opencodeProviderKey)
) { ) {
toast.error(t("opencode.providerKeyDuplicate")); toast.error(t("opencode.providerKeyDuplicate"));
return; return;
@@ -657,11 +756,17 @@ export function ProviderForm({
toast.error(t("openclaw.providerKeyInvalid")); toast.error(t("openclaw.providerKeyInvalid"));
return; return;
} }
if (isProviderKeyLockStateLoading) {
toast.error(
t("providerForm.providerKeyStatusLoading", {
defaultValue: "正在加载供应商标识状态,请稍后再试",
}),
);
return;
}
if ( if (
!isEditMode && !isProviderKeyLocked &&
openclawForm.existingOpenclawKeys.includes( additiveExistingProviderKeys.includes(openclawForm.openclawProviderKey)
openclawForm.openclawProviderKey,
)
) { ) {
toast.error(t("openclaw.providerKeyDuplicate")); toast.error(t("openclaw.providerKeyDuplicate"));
return; return;
@@ -941,6 +1046,10 @@ export function ProviderForm({
localApiKeyField !== "ANTHROPIC_AUTH_TOKEN" localApiKeyField !== "ANTHROPIC_AUTH_TOKEN"
? localApiKeyField ? localApiKeyField
: undefined, : undefined,
isFullUrl:
supportsFullUrl && category !== "official" && localIsFullUrl
? true
: undefined,
}; };
await onSubmit(payload); await onSubmit(payload);
@@ -1180,6 +1289,7 @@ export function ProviderForm({
} }
setLocalApiKeyField(preset.apiKeyField ?? "ANTHROPIC_AUTH_TOKEN"); setLocalApiKeyField(preset.apiKeyField ?? "ANTHROPIC_AUTH_TOKEN");
setLocalIsFullUrl(false);
form.reset({ form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name, name: preset.nameKey ? t(preset.nameKey) : preset.name,
@@ -1240,12 +1350,14 @@ export function ProviderForm({
) )
} }
placeholder={t("opencode.providerKeyPlaceholder")} placeholder={t("opencode.providerKeyPlaceholder")}
disabled={isEditMode} disabled={
isProviderKeyLocked || isProviderKeyLockStateLoading
}
className={ className={
(existingOpencodeKeys.includes( (additiveExistingProviderKeys.includes(
opencodeForm.opencodeProviderKey, opencodeForm.opencodeProviderKey,
) && ) &&
!isEditMode) || !isProviderKeyLocked) ||
(opencodeForm.opencodeProviderKey.trim() !== "" && (opencodeForm.opencodeProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test( !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
opencodeForm.opencodeProviderKey, opencodeForm.opencodeProviderKey,
@@ -1254,10 +1366,10 @@ export function ProviderForm({
: "" : ""
} }
/> />
{existingOpencodeKeys.includes( {additiveExistingProviderKeys.includes(
opencodeForm.opencodeProviderKey, opencodeForm.opencodeProviderKey,
) && ) &&
!isEditMode && ( !isProviderKeyLocked && (
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">
{t("opencode.providerKeyDuplicate")} {t("opencode.providerKeyDuplicate")}
</p> </p>
@@ -1271,16 +1383,21 @@ export function ProviderForm({
</p> </p>
)} )}
{!( {!(
existingOpencodeKeys.includes( additiveExistingProviderKeys.includes(
opencodeForm.opencodeProviderKey, opencodeForm.opencodeProviderKey,
) && !isEditMode ) && !isProviderKeyLocked
) && ) &&
(opencodeForm.opencodeProviderKey.trim() === "" || (opencodeForm.opencodeProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test( /^[a-z0-9]+(-[a-z0-9]+)*$/.test(
opencodeForm.opencodeProviderKey, opencodeForm.opencodeProviderKey,
)) && ( )) && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("opencode.providerKeyHint")} {isProviderKeyLocked
? t("opencode.providerKeyLockedHint", {
defaultValue:
"该供应商已添加到应用配置中,供应商标识不可修改",
})
: t("opencode.providerKeyHint")}
</p> </p>
)} )}
</div> </div>
@@ -1299,12 +1416,14 @@ export function ProviderForm({
) )
} }
placeholder={t("openclaw.providerKeyPlaceholder")} placeholder={t("openclaw.providerKeyPlaceholder")}
disabled={isEditMode} disabled={
isProviderKeyLocked || isProviderKeyLockStateLoading
}
className={ className={
(openclawForm.existingOpenclawKeys.includes( (additiveExistingProviderKeys.includes(
openclawForm.openclawProviderKey, openclawForm.openclawProviderKey,
) && ) &&
!isEditMode) || !isProviderKeyLocked) ||
(openclawForm.openclawProviderKey.trim() !== "" && (openclawForm.openclawProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test( !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
openclawForm.openclawProviderKey, openclawForm.openclawProviderKey,
@@ -1313,10 +1432,10 @@ export function ProviderForm({
: "" : ""
} }
/> />
{openclawForm.existingOpenclawKeys.includes( {additiveExistingProviderKeys.includes(
openclawForm.openclawProviderKey, openclawForm.openclawProviderKey,
) && ) &&
!isEditMode && ( !isProviderKeyLocked && (
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">
{t("openclaw.providerKeyDuplicate")} {t("openclaw.providerKeyDuplicate")}
</p> </p>
@@ -1330,16 +1449,21 @@ export function ProviderForm({
</p> </p>
)} )}
{!( {!(
openclawForm.existingOpenclawKeys.includes( additiveExistingProviderKeys.includes(
openclawForm.openclawProviderKey, openclawForm.openclawProviderKey,
) && !isEditMode ) && !isProviderKeyLocked
) && ) &&
(openclawForm.openclawProviderKey.trim() === "" || (openclawForm.openclawProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test( /^[a-z0-9]+(-[a-z0-9]+)*$/.test(
openclawForm.openclawProviderKey, openclawForm.openclawProviderKey,
)) && ( )) && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("openclaw.providerKeyHint")} {isProviderKeyLocked
? t("openclaw.providerKeyLockedHint", {
defaultValue:
"该供应商已添加到应用配置中,供应商标识不可修改",
})
: t("openclaw.providerKeyHint")}
</p> </p>
)} )}
</div> </div>
@@ -1402,6 +1526,8 @@ export function ProviderForm({
onApiFormatChange={handleApiFormatChange} onApiFormatChange={handleApiFormatChange}
apiKeyField={localApiKeyField} apiKeyField={localApiKeyField}
onApiKeyFieldChange={handleApiKeyFieldChange} onApiKeyFieldChange={handleApiKeyFieldChange}
isFullUrl={localIsFullUrl}
onFullUrlChange={setLocalIsFullUrl}
/> />
)} )}
@@ -1418,6 +1544,8 @@ export function ProviderForm({
shouldShowSpeedTest={shouldShowSpeedTest} shouldShowSpeedTest={shouldShowSpeedTest}
codexBaseUrl={codexBaseUrl} codexBaseUrl={codexBaseUrl}
onBaseUrlChange={handleCodexBaseUrlChange} onBaseUrlChange={handleCodexBaseUrlChange}
isFullUrl={localIsFullUrl}
onFullUrlChange={setLocalIsFullUrl}
isEndpointModalOpen={isCodexEndpointModalOpen} isEndpointModalOpen={isCodexEndpointModalOpen}
onEndpointModalToggle={setIsCodexEndpointModalOpen} onEndpointModalToggle={setIsCodexEndpointModalOpen}
onCustomEndpointsChange={ onCustomEndpointsChange={
@@ -65,7 +65,7 @@ export function ProviderPresetSelector({
case "omo": case "omo":
return t("providerForm.omoHint", { return t("providerForm.omoHint", {
defaultValue: defaultValue:
"💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc", "💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc",
}); });
default: default:
return t("providerPreset.hint", { return t("providerPreset.hint", {
@@ -1,7 +1,8 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form"; import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Zap } from "lucide-react"; import { Switch } from "@/components/ui/switch";
import { Link2, Zap } from "lucide-react";
interface EndpointFieldProps { interface EndpointFieldProps {
id: string; id: string;
@@ -13,6 +14,9 @@ interface EndpointFieldProps {
showManageButton?: boolean; showManageButton?: boolean;
onManageClick?: () => void; onManageClick?: () => void;
manageButtonLabel?: string; manageButtonLabel?: string;
showFullUrlToggle?: boolean;
isFullUrl?: boolean;
onFullUrlChange?: (value: boolean) => void;
} }
export function EndpointField({ export function EndpointField({
@@ -25,18 +29,56 @@ export function EndpointField({
showManageButton = true, showManageButton = true,
onManageClick, onManageClick,
manageButtonLabel, manageButtonLabel,
showFullUrlToggle = false,
isFullUrl = false,
onFullUrlChange,
}: EndpointFieldProps) { }: EndpointFieldProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const defaultManageLabel = t("providerForm.manageAndTest", { const defaultManageLabel = t("providerForm.manageAndTest", {
defaultValue: "管理和测速", defaultValue: "管理和测速",
}); });
const effectiveHint =
showFullUrlToggle && isFullUrl
? t("providerForm.fullUrlHint", {
defaultValue:
"💡 请填写完整请求 URL,并且必须开启代理后使用;代理将直接使用此 URL,不拼接路径",
})
: hint;
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-2">
<FormLabel htmlFor={id}>{label}</FormLabel> <div className="flex flex-wrap items-center gap-3">
{showManageButton && onManageClick && ( <FormLabel htmlFor={id}>{label}</FormLabel>
{showFullUrlToggle && onFullUrlChange ? (
<div className="flex items-center gap-2 rounded-full border border-border/70 bg-muted/30 px-2.5 py-1">
<Link2
className={`h-3.5 w-3.5 ${
isFullUrl ? "text-primary" : "text-muted-foreground"
}`}
/>
<span
className={`text-xs font-medium ${
isFullUrl ? "text-foreground" : "text-muted-foreground"
}`}
>
{t("providerForm.fullUrlLabel", {
defaultValue: "完整 URL",
})}
</span>
<Switch
checked={isFullUrl}
onCheckedChange={onFullUrlChange}
aria-label={t("providerForm.fullUrlLabel", {
defaultValue: "完整 URL",
})}
className="h-5 w-9"
/>
</div>
) : null}
</div>
{showManageButton && onManageClick ? (
<button <button
type="button" type="button"
onClick={onManageClick} onClick={onManageClick}
@@ -45,7 +87,7 @@ export function EndpointField({
<Zap className="h-3.5 w-3.5" /> <Zap className="h-3.5 w-3.5" />
{manageButtonLabel || defaultManageLabel} {manageButtonLabel || defaultManageLabel}
</button> </button>
)} ) : null}
</div> </div>
<Input <Input
id={id} id={id}
@@ -55,9 +97,11 @@ export function EndpointField({
placeholder={placeholder} placeholder={placeholder}
autoComplete="off" autoComplete="off"
/> />
{hint ? ( {effectiveHint ? (
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg"> <div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
<p className="text-xs text-amber-600 dark:text-amber-400">{hint}</p> <p className="text-xs text-amber-600 dark:text-amber-400">
{effectiveHint}
</p>
</div> </div>
) : null} ) : null}
</div> </div>
+61 -34
View File
@@ -1,5 +1,6 @@
import { ChevronRight, Clock } from "lucide-react"; import { ChevronRight, Clock } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -19,13 +20,21 @@ import {
interface SessionItemProps { interface SessionItemProps {
session: SessionMeta; session: SessionMeta;
isSelected: boolean; isSelected: boolean;
selectionMode: boolean;
isChecked: boolean;
isCheckDisabled?: boolean;
onSelect: (key: string) => void; onSelect: (key: string) => void;
onToggleChecked: (checked: boolean) => void;
} }
export function SessionItem({ export function SessionItem({
session, session,
isSelected, isSelected,
selectionMode,
isChecked,
isCheckDisabled = false,
onSelect, onSelect,
onToggleChecked,
}: SessionItemProps) { }: SessionItemProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const title = formatSessionTitle(session); const title = formatSessionTitle(session);
@@ -33,46 +42,64 @@ export function SessionItem({
const sessionKey = getSessionKey(session); const sessionKey = getSessionKey(session);
return ( return (
<button <div
type="button"
onClick={() => onSelect(sessionKey)}
className={cn( className={cn(
"w-full text-left rounded-lg px-3 py-2.5 transition-all group", "flex items-start gap-2 rounded-lg px-3 py-2.5 transition-all group",
isSelected isSelected
? "bg-primary/10 border border-primary/30" ? "bg-primary/10 border border-primary/30"
: "hover:bg-muted/60 border border-transparent", : "hover:bg-muted/60 border border-transparent",
)} )}
> >
<div className="flex items-center gap-2 mb-1"> {selectionMode && (
<Tooltip> <div className="shrink-0 pt-0.5">
<TooltipTrigger asChild> <Checkbox
<span className="shrink-0"> checked={isChecked}
<ProviderIcon disabled={isCheckDisabled}
icon={getProviderIconName(session.providerId)} aria-label={t("sessionManager.selectForBatch", {
name={session.providerId} defaultValue: "选择会话",
size={18} })}
/> onCheckedChange={(checked) => onToggleChecked(Boolean(checked))}
</span> />
</TooltipTrigger> </div>
<TooltipContent> )}
{getProviderLabel(session.providerId, t)} <button
</TooltipContent> type="button"
</Tooltip> onClick={() => onSelect(sessionKey)}
<span className="text-sm font-medium truncate flex-1">{title}</span> className="min-w-0 flex-1 text-left"
<ChevronRight >
className={cn( <div className="flex items-center gap-2 mb-1">
"size-4 text-muted-foreground/50 shrink-0 transition-transform", <Tooltip>
isSelected && "text-primary rotate-90", <TooltipTrigger asChild>
)} <span className="shrink-0">
/> <ProviderIcon
</div> icon={getProviderIconName(session.providerId)}
name={session.providerId}
size={18}
/>
</span>
</TooltipTrigger>
<TooltipContent>
{getProviderLabel(session.providerId, t)}
</TooltipContent>
</Tooltip>
<span className="text-sm font-medium truncate flex-1">{title}</span>
<ChevronRight
className={cn(
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
isSelected && "text-primary rotate-90",
)}
/>
</div>
<div className="flex items-center gap-1 text-[11px] text-muted-foreground"> <div className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Clock className="size-3" /> <Clock className="size-3" />
<span> <span>
{lastActive ? formatRelativeTime(lastActive, t) : t("common.unknown")} {lastActive
</span> ? formatRelativeTime(lastActive, t)
</div> : t("common.unknown")}
</button> </span>
</div>
</button>
</div>
); );
} }
+551 -175
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useSessionSearch } from "@/hooks/useSessionSearch"; import { useSessionSearch } from "@/hooks/useSessionSearch";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import { import {
Copy, Copy,
RefreshCw, RefreshCw,
@@ -12,6 +13,7 @@ import {
Clock, Clock,
FolderOpen, FolderOpen,
X, X,
CheckSquare,
} from "lucide-react"; } from "lucide-react";
import { import {
useDeleteSessionMutation, useDeleteSessionMutation,
@@ -63,6 +65,7 @@ type ProviderFilter =
export function SessionManagerPage({ appId }: { appId: string }) { export function SessionManagerPage({ appId }: { appId: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const { data, isLoading, refetch } = useSessionsQuery(); const { data, isLoading, refetch } = useSessionsQuery();
const sessions = data ?? []; const sessions = data ?? [];
const detailRef = useRef<HTMLDivElement | null>(null); const detailRef = useRef<HTMLDivElement | null>(null);
@@ -73,7 +76,14 @@ export function SessionManagerPage({ appId }: { appId: string }) {
); );
const [tocDialogOpen, setTocDialogOpen] = useState(false); const [tocDialogOpen, setTocDialogOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<SessionMeta | null>(null); const [deleteTargets, setDeleteTargets] = useState<SessionMeta[] | null>(
null,
);
const [selectedSessionKeys, setSelectedSessionKeys] = useState<Set<string>>(
() => new Set(),
);
const [isBatchDeleting, setIsBatchDeleting] = useState(false);
const [selectionMode, setSelectionMode] = useState(false);
const searchInputRef = useRef<HTMLInputElement | null>(null); const searchInputRef = useRef<HTMLInputElement | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -122,6 +132,25 @@ export function SessionManagerPage({ appId }: { appId: string }) {
selectedSession?.sourcePath, selectedSession?.sourcePath,
); );
const deleteSessionMutation = useDeleteSessionMutation(); const deleteSessionMutation = useDeleteSessionMutation();
const isDeleting = deleteSessionMutation.isPending || isBatchDeleting;
useEffect(() => {
const validKeys = new Set(
sessions.map((session) => getSessionKey(session)),
);
setSelectedSessionKeys((current) => {
let changed = false;
const next = new Set<string>();
current.forEach((key) => {
if (validKeys.has(key)) {
next.add(key);
} else {
changed = true;
}
});
return changed ? next : current;
});
}, [sessions]);
// 提取用户消息用于目录 // 提取用户消息用于目录
const userMessagesToc = useMemo(() => { const userMessagesToc = useMemo(() => {
@@ -194,16 +223,195 @@ export function SessionManagerPage({ appId }: { appId: string }) {
}; };
const handleDeleteConfirm = async () => { const handleDeleteConfirm = async () => {
if (!deleteTarget?.sourcePath || deleteSessionMutation.isPending) { if (!deleteTargets || deleteTargets.length === 0 || isDeleting) {
return; return;
} }
setDeleteTarget(null); const targets = deleteTargets.filter((session) => session.sourcePath);
await deleteSessionMutation.mutateAsync({ setDeleteTargets(null);
providerId: deleteTarget.providerId,
sessionId: deleteTarget.sessionId, if (targets.length === 0) {
sourcePath: deleteTarget.sourcePath, return;
}
if (targets.length === 1) {
const [target] = targets;
await deleteSessionMutation.mutateAsync({
providerId: target.providerId,
sessionId: target.sessionId,
sourcePath: target.sourcePath!,
});
setSelectedSessionKeys((current) => {
const next = new Set(current);
next.delete(getSessionKey(target));
return next;
});
return;
}
setIsBatchDeleting(true);
try {
const results = await sessionsApi.deleteMany(
targets.map((session) => ({
providerId: session.providerId,
sessionId: session.sessionId,
sourcePath: session.sourcePath!,
})),
);
const deletedKeys = results
.filter((result) => result.success)
.map(
(result) =>
`${result.providerId}:${result.sessionId}:${result.sourcePath ?? ""}`,
);
const failedErrors = results
.filter((result) => !result.success)
.map((result) => result.error || t("common.unknown"));
if (deletedKeys.length > 0) {
const deletedKeySet = new Set(deletedKeys);
queryClient.setQueryData<SessionMeta[]>(["sessions"], (current) =>
(current ?? []).filter(
(session) => !deletedKeySet.has(getSessionKey(session)),
),
);
}
results
.filter((result) => result.success)
.forEach((result) => {
queryClient.removeQueries({
queryKey: ["sessionMessages", result.providerId, result.sourcePath],
});
});
setSelectedSessionKeys((current) => {
const next = new Set(current);
deletedKeys.forEach((key) => next.delete(key));
return next;
});
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
if (deletedKeys.length > 0) {
toast.success(
t("sessionManager.batchDeleteSuccess", {
defaultValue: "已删除 {{count}} 个会话",
count: deletedKeys.length,
}),
);
}
if (failedErrors.length > 0) {
toast.error(
t("sessionManager.batchDeleteFailed", {
defaultValue: "{{failed}} 个会话删除失败",
failed: failedErrors.length,
}),
{
description: failedErrors[0],
},
);
}
} catch (error) {
toast.error(
extractErrorMessage(error) ||
t("sessionManager.batchDeleteRequestFailed", {
defaultValue: "批量删除失败,请稍后重试",
}),
);
} finally {
setIsBatchDeleting(false);
}
};
const deletableFilteredSessions = useMemo(
() => filteredSessions.filter((session) => Boolean(session.sourcePath)),
[filteredSessions],
);
const selectedSessions = useMemo(
() =>
sessions.filter((session) =>
selectedSessionKeys.has(getSessionKey(session)),
),
[sessions, selectedSessionKeys],
);
const selectedDeletableSessions = useMemo(
() => selectedSessions.filter((session) => Boolean(session.sourcePath)),
[selectedSessions],
);
useEffect(() => {
if (!selectionMode) return;
const visibleKeys = new Set(
deletableFilteredSessions.map((session) => getSessionKey(session)),
);
setSelectedSessionKeys((current) => {
let changed = false;
const next = new Set<string>();
current.forEach((key) => {
if (visibleKeys.has(key)) {
next.add(key);
} else {
changed = true;
}
});
return changed ? next : current;
}); });
}, [deletableFilteredSessions, selectionMode]);
const allFilteredSelected =
deletableFilteredSessions.length > 0 &&
deletableFilteredSessions.every((session) =>
selectedSessionKeys.has(getSessionKey(session)),
);
const toggleSessionChecked = (session: SessionMeta, checked: boolean) => {
if (!session.sourcePath) return;
const key = getSessionKey(session);
setSelectedSessionKeys((current) => {
const next = new Set(current);
if (checked) {
next.add(key);
} else {
next.delete(key);
}
return next;
});
};
const handleToggleSelectAll = () => {
setSelectedSessionKeys((current) => {
const next = new Set(current);
if (allFilteredSelected) {
deletableFilteredSessions.forEach((session) =>
next.delete(getSessionKey(session)),
);
} else {
deletableFilteredSessions.forEach((session) =>
next.add(getSessionKey(session)),
);
}
return next;
});
};
const openBatchDeleteDialog = () => {
if (selectedDeletableSessions.length === 0) return;
setDeleteTargets(selectedDeletableSessions);
};
const exitSelectionMode = () => {
setSelectionMode(false);
setSelectedSessionKeys(new Set());
}; };
return ( return (
@@ -219,174 +427,315 @@ export function SessionManagerPage({ appId }: { appId: string }) {
<Card className="flex flex-col flex-1 min-h-0 overflow-hidden"> <Card className="flex flex-col flex-1 min-h-0 overflow-hidden">
<CardHeader className="py-2 px-3 border-b"> <CardHeader className="py-2 px-3 border-b">
{isSearchOpen ? ( {isSearchOpen ? (
<div className="relative flex-1"> <div className="flex items-center gap-2">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" /> <div className="relative flex-1">
<Input <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
ref={searchInputRef} <Input
value={search} ref={searchInputRef}
onChange={(event) => setSearch(event.target.value)} value={search}
placeholder={t("sessionManager.searchPlaceholder")} onChange={(event) => setSearch(event.target.value)}
className="h-8 pl-8 pr-8 text-sm" placeholder={t("sessionManager.searchPlaceholder")}
autoFocus className="h-8 pl-8 pr-8 text-sm"
onKeyDown={(e) => { autoFocus
if (e.key === "Escape") { onKeyDown={(e) => {
if (e.key === "Escape") {
setIsSearchOpen(false);
setSearch("");
}
}}
onBlur={() => {
if (search.trim() === "") {
setIsSearchOpen(false);
}
}}
/>
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
onClick={() => {
setIsSearchOpen(false); setIsSearchOpen(false);
setSearch(""); setSearch("");
} }}
}} >
onBlur={() => { <X className="size-3" />
if (search.trim() === "") { </Button>
setIsSearchOpen(false);
}
}}
/>
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
onClick={() => {
setIsSearchOpen(false);
setSearch("");
}}
>
<X className="size-3" />
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<CardTitle className="text-sm font-medium">
{t("sessionManager.sessionList")}
</CardTitle>
<Badge variant="secondary" className="text-xs">
{filteredSessions.length}
</Badge>
</div> </div>
<div className="flex items-center gap-1"> {selectionMode && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="secondary"
size="icon" size="icon"
className="size-7" className="size-7 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:hover:bg-blue-950/60"
onClick={() => { aria-label={t("sessionManager.exitBatchModeTooltip", {
setIsSearchOpen(true); defaultValue: "退出批量管理",
setTimeout( })}
() => searchInputRef.current?.focus(), onClick={exitSelectionMode}
0,
);
}}
> >
<Search className="size-3.5" /> <CheckSquare className="size-3.5" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{t("sessionManager.searchSessions")} {t("sessionManager.exitBatchModeTooltip", {
defaultValue: "退出批量管理",
})}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)}
<Select </div>
value={providerFilter} ) : (
onValueChange={(value) => <div className="flex flex-col gap-2">
setProviderFilter(value as ProviderFilter) <div className="flex items-center justify-between gap-2">
} <div className="flex items-center gap-2 min-w-0">
> <CardTitle className="text-sm font-medium whitespace-nowrap">
{t("sessionManager.sessionList")}
</CardTitle>
<Badge variant="secondary" className="text-xs">
{filteredSessions.length}
</Badge>
</div>
<div className="flex items-center gap-1 shrink-0">
{(selectionMode ||
deletableFilteredSessions.length > 0) && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={selectionMode ? "secondary" : "ghost"}
size="icon"
className={
selectionMode
? "size-7 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:hover:bg-blue-950/60"
: "size-7"
}
aria-label={
selectionMode
? t("sessionManager.exitBatchModeTooltip", {
defaultValue: "退出批量管理",
})
: t("sessionManager.manageBatchTooltip", {
defaultValue: "批量管理",
})
}
onClick={() => {
if (selectionMode) {
exitSelectionMode();
} else {
setSelectionMode(true);
}
}}
>
<CheckSquare className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{selectionMode
? t("sessionManager.exitBatchModeTooltip", {
defaultValue: "退出批量管理",
})
: t("sessionManager.manageBatchTooltip", {
defaultValue: "批量管理",
})}
</TooltipContent>
</Tooltip>
)}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted"> <Button
<ProviderIcon variant="ghost"
icon={ size="icon"
providerFilter === "all" className="size-7"
? "apps" onClick={() => {
: getProviderIconName(providerFilter) setIsSearchOpen(true);
} setTimeout(
name={providerFilter} () => searchInputRef.current?.focus(),
size={14} 0,
/> );
</SelectTrigger> }}
>
<Search className="size-3.5" />
</Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{providerFilter === "all" {t("sessionManager.searchSessions")}
? t("sessionManager.providerFilterAll")
: providerFilter}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-2">
<ProviderIcon icon="apps" name="all" size={14} />
<span>
{t("sessionManager.providerFilterAll")}
</span>
</div>
</SelectItem>
<SelectItem value="codex">
<div className="flex items-center gap-2">
<ProviderIcon
icon="openai"
name="codex"
size={14}
/>
<span>Codex</span>
</div>
</SelectItem>
<SelectItem value="claude">
<div className="flex items-center gap-2">
<ProviderIcon
icon="claude"
name="claude"
size={14}
/>
<span>Claude Code</span>
</div>
</SelectItem>
<SelectItem value="opencode">
<div className="flex items-center gap-2">
<ProviderIcon
icon="opencode"
name="opencode"
size={14}
/>
<span>OpenCode</span>
</div>
</SelectItem>
<SelectItem value="openclaw">
<div className="flex items-center gap-2">
<ProviderIcon
icon="openclaw"
name="openclaw"
size={14}
/>
<span>OpenClaw</span>
</div>
</SelectItem>
<SelectItem value="gemini">
<div className="flex items-center gap-2">
<ProviderIcon
icon="gemini"
name="gemini"
size={14}
/>
<span>Gemini CLI</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Tooltip> <Select
<TooltipTrigger asChild> value={providerFilter}
<Button onValueChange={(value) =>
variant="ghost" setProviderFilter(value as ProviderFilter)
size="icon" }
className="size-7" >
onClick={() => void refetch()} <Tooltip>
> <TooltipTrigger asChild>
<RefreshCw className="size-3.5" /> <SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
</Button> <ProviderIcon
</TooltipTrigger> icon={
<TooltipContent>{t("common.refresh")}</TooltipContent> providerFilter === "all"
</Tooltip> ? "apps"
: getProviderIconName(providerFilter)
}
name={providerFilter}
size={14}
/>
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>
{providerFilter === "all"
? t("sessionManager.providerFilterAll")
: providerFilter}
</TooltipContent>
</Tooltip>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-2">
<ProviderIcon
icon="apps"
name="all"
size={14}
/>
<span>
{t("sessionManager.providerFilterAll")}
</span>
</div>
</SelectItem>
<SelectItem value="codex">
<div className="flex items-center gap-2">
<ProviderIcon
icon="openai"
name="codex"
size={14}
/>
<span>Codex</span>
</div>
</SelectItem>
<SelectItem value="claude">
<div className="flex items-center gap-2">
<ProviderIcon
icon="claude"
name="claude"
size={14}
/>
<span>Claude Code</span>
</div>
</SelectItem>
<SelectItem value="opencode">
<div className="flex items-center gap-2">
<ProviderIcon
icon="opencode"
name="opencode"
size={14}
/>
<span>OpenCode</span>
</div>
</SelectItem>
<SelectItem value="openclaw">
<div className="flex items-center gap-2">
<ProviderIcon
icon="openclaw"
name="openclaw"
size={14}
/>
<span>OpenClaw</span>
</div>
</SelectItem>
<SelectItem value="gemini">
<div className="flex items-center gap-2">
<ProviderIcon
icon="gemini"
name="gemini"
size={14}
/>
<span>Gemini CLI</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => void refetch()}
>
<RefreshCw className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.refresh")}</TooltipContent>
</Tooltip>
</div>
</div> </div>
{selectionMode && (
<div className="grid gap-3 rounded-md border bg-muted/40 px-3 py-2.5">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="text-xs">
{t("sessionManager.selectedCount", {
defaultValue: "已选 {{count}} 项",
count: selectedDeletableSessions.length,
})}
</Badge>
<span className="truncate">
{t("sessionManager.batchModeHint", {
defaultValue: "勾选要删除的会话",
})}
</span>
</div>
<div className="grid gap-3 min-[520px]:grid-cols-[minmax(0,1fr)_auto] min-[520px]:items-center">
<div className="flex flex-wrap items-center gap-2">
{deletableFilteredSessions.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs whitespace-nowrap"
onClick={handleToggleSelectAll}
>
{allFilteredSelected
? t("sessionManager.clearFilteredSelection", {
defaultValue: "取消全选",
})
: t("sessionManager.selectAllFiltered", {
defaultValue: "全选当前",
})}
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs whitespace-nowrap"
onClick={() => setSelectedSessionKeys(new Set())}
>
{t("sessionManager.clearSelection", {
defaultValue: "清空已选",
})}
</Button>
</div>
<Button
variant="destructive"
size="sm"
className="h-7 gap-1.5 px-2.5 whitespace-nowrap justify-self-start min-[520px]:justify-self-end"
onClick={openBatchDeleteDialog}
disabled={
isDeleting ||
selectedDeletableSessions.length === 0
}
>
<Trash2 className="size-3.5" />
<span className="text-xs">
{isBatchDeleting
? t("sessionManager.batchDeleting", {
defaultValue: "删除中...",
})
: t("sessionManager.deleteSelected", {
defaultValue: "批量删除",
})}
</span>
</Button>
</div>
</div>
)}
</div> </div>
)} )}
</CardHeader> </CardHeader>
@@ -416,7 +765,15 @@ export function SessionManagerPage({ appId }: { appId: string }) {
key={getSessionKey(session)} key={getSessionKey(session)}
session={session} session={session}
isSelected={isSelected} isSelected={isSelected}
selectionMode={selectionMode}
isChecked={selectedSessionKeys.has(
getSessionKey(session),
)}
isCheckDisabled={!session.sourcePath}
onSelect={setSelectedKey} onSelect={setSelectedKey}
onToggleChecked={(checked) =>
toggleSessionChecked(session, checked)
}
/> />
); );
})} })}
@@ -548,15 +905,16 @@ export function SessionManagerPage({ appId }: { appId: string }) {
size="sm" size="sm"
variant="destructive" variant="destructive"
className="gap-1.5" className="gap-1.5"
onClick={() => setDeleteTarget(selectedSession)} onClick={() =>
setDeleteTargets([selectedSession])
}
disabled={ disabled={
!selectedSession.sourcePath || !selectedSession.sourcePath || isDeleting
deleteSessionMutation.isPending
} }
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
<span className="hidden sm:inline"> <span className="hidden sm:inline">
{deleteSessionMutation.isPending {isDeleting
? t("sessionManager.deleting", { ? t("sessionManager.deleting", {
defaultValue: "删除中...", defaultValue: "删除中...",
}) })
@@ -685,29 +1043,47 @@ export function SessionManagerPage({ appId }: { appId: string }) {
</div> </div>
</div> </div>
<ConfirmDialog <ConfirmDialog
isOpen={Boolean(deleteTarget)} isOpen={Boolean(deleteTargets)}
title={t("sessionManager.deleteConfirmTitle", { title={
defaultValue: "删除会话", deleteTargets && deleteTargets.length > 1
})} ? t("sessionManager.batchDeleteConfirmTitle", {
message={ defaultValue: "批量删除会话",
deleteTarget })
? t("sessionManager.deleteConfirmMessage", { : t("sessionManager.deleteConfirmTitle", {
defaultValue: defaultValue: "删除会话",
"将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。", })
title: formatSessionTitle(deleteTarget), }
sessionId: deleteTarget.sessionId, message={
deleteTargets && deleteTargets.length > 1
? t("sessionManager.batchDeleteConfirmMessage", {
defaultValue:
"将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。",
count: deleteTargets.length,
})
: deleteTargets?.[0]
? t("sessionManager.deleteConfirmMessage", {
defaultValue:
"将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
title: formatSessionTitle(deleteTargets[0]),
sessionId: deleteTargets[0].sessionId,
})
: ""
}
confirmText={
deleteTargets && deleteTargets.length > 1
? t("sessionManager.batchDeleteConfirmAction", {
defaultValue: "删除所选会话",
})
: t("sessionManager.deleteConfirmAction", {
defaultValue: "删除会话",
}) })
: ""
} }
confirmText={t("sessionManager.deleteConfirmAction", {
defaultValue: "删除会话",
})}
cancelText={t("common.cancel", { defaultValue: "取消" })} cancelText={t("common.cancel", { defaultValue: "取消" })}
variant="destructive" variant="destructive"
onConfirm={() => void handleDeleteConfirm()} onConfirm={() => void handleDeleteConfirm()}
onCancel={() => { onCancel={() => {
if (!deleteSessionMutation.isPending) { if (!isDeleting) {
setDeleteTarget(null); setDeleteTargets(null);
} }
}} }}
/> />
+59 -10
View File
@@ -98,6 +98,20 @@ function formatDbCompatVersion(version?: number | null): string | null {
return typeof version === "number" ? `db-v${version}` : null; return typeof version === "number" ? `db-v${version}` : null;
} }
function buildPasswordPreservationKey(values: {
baseUrl?: string | null;
username?: string | null;
remoteRoot?: string | null;
profile?: string | null;
}) {
return JSON.stringify({
baseUrl: values.baseUrl ?? "",
username: values.username ?? "",
remoteRoot: values.remoteRoot ?? "cc-switch-sync",
profile: values.profile ?? "default",
});
}
// ─── Types ────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────
type ActionState = type ActionState =
@@ -167,6 +181,10 @@ export function WebdavSyncSection({
const [passwordTouched, setPasswordTouched] = useState(false); const [passwordTouched, setPasswordTouched] = useState(false);
const [justSaved, setJustSaved] = useState(false); const [justSaved, setJustSaved] = useState(false);
const justSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const justSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingPasswordPreservationRef = useRef<{
key: string;
password: string;
} | null>(null);
// Local form state — credentials are only persisted on explicit "Save". // Local form state — credentials are only persisted on explicit "Save".
const [form, setForm] = useState(() => ({ const [form, setForm] = useState(() => ({
@@ -205,13 +223,36 @@ export function WebdavSyncSection({
// Sync form when config is loaded/updated from backend, but not while user is editing // Sync form when config is loaded/updated from backend, but not while user is editing
useEffect(() => { useEffect(() => {
if (!config || dirty) return; if (!config || dirty) return;
setForm({ setForm(() => {
baseUrl: config.baseUrl ?? "", const nextBaseUrl = config.baseUrl ?? "";
username: config.username ?? "", const nextUsername = config.username ?? "";
password: config.password ?? "", const nextRemoteRoot = config.remoteRoot ?? "cc-switch-sync";
remoteRoot: config.remoteRoot ?? "cc-switch-sync", const nextProfile = config.profile ?? "default";
profile: config.profile ?? "default", const nextKey = buildPasswordPreservationKey({
autoSync: config.autoSync ?? false, baseUrl: nextBaseUrl,
username: nextUsername,
remoteRoot: nextRemoteRoot,
profile: nextProfile,
});
const shouldPreserveRedactedPassword =
!config.password &&
pendingPasswordPreservationRef.current?.key === nextKey &&
!!pendingPasswordPreservationRef.current.password;
const nextPassword = shouldPreserveRedactedPassword
? pendingPasswordPreservationRef.current!.password
: (config.password ?? "");
pendingPasswordPreservationRef.current = null;
return {
baseUrl: nextBaseUrl,
username: nextUsername,
password: nextPassword,
remoteRoot: nextRemoteRoot,
profile: nextProfile,
autoSync: config.autoSync ?? false,
};
}); });
setPasswordTouched(false); setPasswordTouched(false);
setPresetId(detectPreset(config.baseUrl ?? "")); setPresetId(detectPreset(config.baseUrl ?? ""));
@@ -289,12 +330,13 @@ export function WebdavSyncSection({
enabled: true, enabled: true,
baseUrl, baseUrl,
username: form.username.trim(), username: form.username.trim(),
password: form.password, // 未重新触碰密码时,提交空值让后端沿用已保存密码,表单里的值仅用于 UI 显示
password: passwordTouched ? form.password : "",
remoteRoot: form.remoteRoot.trim() || "cc-switch-sync", remoteRoot: form.remoteRoot.trim() || "cc-switch-sync",
profile: form.profile.trim() || "default", profile: form.profile.trim() || "default",
autoSync: form.autoSync, autoSync: form.autoSync,
}; };
}, [form]); }, [form, passwordTouched]);
// ─── Handlers ─────────────────────────────────────────── // ─── Handlers ───────────────────────────────────────────
@@ -326,6 +368,12 @@ export function WebdavSyncSection({
return; return;
} }
setActionState("saving"); setActionState("saving");
pendingPasswordPreservationRef.current = form.password
? {
key: buildPasswordPreservationKey(settings),
password: form.password,
}
: null;
try { try {
await settingsApi.webdavSyncSaveSettings(settings, passwordTouched); await settingsApi.webdavSyncSaveSettings(settings, passwordTouched);
setDirty(false); setDirty(false);
@@ -339,6 +387,7 @@ export function WebdavSyncSection({
}, 2000); }, 2000);
await queryClient.invalidateQueries(); await queryClient.invalidateQueries();
} catch (error) { } catch (error) {
pendingPasswordPreservationRef.current = null;
toast.error( toast.error(
t("settings.webdavSync.saveFailed", { t("settings.webdavSync.saveFailed", {
error: (error as Error)?.message ?? String(error), error: (error as Error)?.message ?? String(error),
@@ -362,7 +411,7 @@ export function WebdavSyncSection({
} finally { } finally {
setActionState("idle"); setActionState("idle");
} }
}, [buildSettings, passwordTouched, queryClient, t]); }, [buildSettings, form.password, passwordTouched, queryClient, t]);
/** Fetch remote info, then open upload confirmation dialog. */ /** Fetch remote info, then open upload confirmation dialog. */
const handleUploadClick = useCallback(async () => { const handleUploadClick = useCallback(async () => {
+1 -2
View File
@@ -11,5 +11,4 @@ export const TEMPLATE_TYPES = {
GITHUB_COPILOT: "github_copilot", GITHUB_COPILOT: "github_copilot",
} as const; } as const;
export type TemplateType = export type TemplateType = (typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES];
(typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES];
+5 -5
View File
@@ -453,10 +453,10 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
websiteUrl: "https://www.kimi.com/coding/docs/", websiteUrl: "https://www.kimi.com/coding/docs/",
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
settingsConfig: { settingsConfig: {
npm: "@ai-sdk/openai-compatible", npm: "@ai-sdk/anthropic",
name: "Kimi For Coding", name: "Kimi For Coding",
options: { options: {
baseURL: "https://api.kimi.com/v1", baseURL: "https://api.kimi.com/coding/v1",
apiKey: "", apiKey: "",
setCacheKey: true, setCacheKey: true,
}, },
@@ -470,8 +470,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
templateValues: { templateValues: {
baseURL: { baseURL: {
label: "Base URL", label: "Base URL",
placeholder: "https://api.kimi.com/v1", placeholder: "https://api.kimi.com/coding/v1",
defaultValue: "https://api.kimi.com/v1", defaultValue: "https://api.kimi.com/coding/v1",
editorValue: "", editorValue: "",
}, },
apiKey: { apiKey: {
@@ -1343,7 +1343,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
{ {
name: "Oh My OpenCode", name: "Oh My OpenCode",
websiteUrl: "https://github.com/code-yeongyu/oh-my-opencode", websiteUrl: "https://github.com/code-yeongyu/oh-my-openagent",
settingsConfig: { settingsConfig: {
npm: "", npm: "",
options: {}, options: {},
+56 -9
View File
@@ -23,7 +23,7 @@ import { openclawKeys } from "@/hooks/useOpenClaw";
* Hook for managing provider actions (add, update, delete, switch) * Hook for managing provider actions (add, update, delete, switch)
* Extracts business logic from App.tsx * Extracts business logic from App.tsx
*/ */
export function useProviderActions(activeApp: AppId) { export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -65,6 +65,7 @@ export function useProviderActions(activeApp: AppId) {
provider: Omit<Provider, "id"> & { provider: Omit<Provider, "id"> & {
providerKey?: string; providerKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults; suggestedDefaults?: OpenClawSuggestedDefaults;
addToLive?: boolean;
}, },
) => { ) => {
await addProviderMutation.mutateAsync(provider); await addProviderMutation.mutateAsync(provider);
@@ -120,8 +121,8 @@ export function useProviderActions(activeApp: AppId) {
// 更新供应商 // 更新供应商
const updateProvider = useCallback( const updateProvider = useCallback(
async (provider: Provider) => { async (provider: Provider, originalId?: string) => {
await updateProviderMutation.mutateAsync(provider); await updateProviderMutation.mutateAsync({ provider, originalId });
// 更新托盘菜单(失败不影响主操作) // 更新托盘菜单(失败不影响主操作)
try { try {
@@ -139,6 +140,52 @@ export function useProviderActions(activeApp: AppId) {
// 切换供应商 // 切换供应商
const switchProvider = useCallback( const switchProvider = useCallback(
async (provider: Provider) => { async (provider: Provider) => {
const isCopilotProvider =
activeApp === "claude" &&
provider.meta?.providerType === "github_copilot";
// Determine why this provider requires the proxy
let proxyRequiredReason: string | null = null;
if (!isProxyRunning && provider.category !== "official") {
if (isCopilotProvider) {
proxyRequiredReason = t("notifications.proxyReasonCopilot", {
defaultValue: "使用 GitHub Copilot 作为 Claude 供应商",
});
} else if (
provider.meta?.apiFormat === "openai_chat" &&
activeApp === "claude"
) {
proxyRequiredReason = t("notifications.proxyReasonOpenAIChat", {
defaultValue: "使用 OpenAI Chat 接口格式",
});
} else if (
provider.meta?.apiFormat === "openai_responses" &&
activeApp === "claude"
) {
proxyRequiredReason = t("notifications.proxyReasonOpenAIResponses", {
defaultValue: "使用 OpenAI Responses 接口格式",
});
} else if (
provider.meta?.isFullUrl &&
(activeApp === "claude" || activeApp === "codex")
) {
proxyRequiredReason = t("notifications.proxyReasonFullUrl", {
defaultValue: "开启了完整 URL 连接模式",
});
}
}
if (proxyRequiredReason) {
toast.warning(
t("notifications.proxyRequiredForSwitch", {
reason: proxyRequiredReason,
defaultValue:
"此供应商{{reason}},需要代理服务才能正常使用,请先启动代理",
}),
);
return;
}
try { try {
const result = await switchProviderMutation.mutateAsync(provider.id); const result = await switchProviderMutation.mutateAsync(provider.id);
await syncClaudePlugin(provider); await syncClaudePlugin(provider);
@@ -158,15 +205,15 @@ export function useProviderActions(activeApp: AppId) {
if ( if (
activeApp === "claude" && activeApp === "claude" &&
provider.category !== "official" && provider.category !== "official" &&
(provider.meta?.apiFormat === "openai_chat" || (isCopilotProvider ||
provider.meta?.apiFormat === "openai_chat" ||
provider.meta?.apiFormat === "openai_responses") provider.meta?.apiFormat === "openai_responses")
) { ) {
// OpenAI format provider: show proxy hint // OpenAI format provider: show proxy hint
toast.info( toast.info(
t("notifications.openAIFormatHint", { isCopilotProvider
defaultValue: ? t("notifications.copilotProxyHint")
"此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用", : t("notifications.openAIFormatHint"),
}),
{ {
duration: 5000, duration: 5000,
closeButton: true, closeButton: true,
@@ -192,7 +239,7 @@ export function useProviderActions(activeApp: AppId) {
// 错误提示由 mutation 处理 // 错误提示由 mutation 处理
} }
}, },
[switchProviderMutation, syncClaudePlugin, activeApp, t], [switchProviderMutation, syncClaudePlugin, activeApp, isProxyRunning, t],
); );
// 删除供应商 // 删除供应商
+10 -8
View File
@@ -1,4 +1,9 @@
import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query"; import {
useMutation,
useQuery,
useQueryClient,
keepPreviousData,
} from "@tanstack/react-query";
import { import {
skillsApi, skillsApi,
type SkillBackupEntry, type SkillBackupEntry,
@@ -108,13 +113,10 @@ export function useInstallSkill() {
export function useUninstallSkill() { export function useUninstallSkill() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({ id, skillKey }: { id: string; skillKey: string }) =>
id, skillsApi
skillKey, .uninstallUnified(id)
}: { .then((result) => ({ ...result, skillKey })),
id: string;
skillKey: string;
}) => skillsApi.uninstallUnified(id).then((result) => ({ ...result, skillKey })),
onSuccess: ({ skillKey }, _vars) => { onSuccess: ({ skillKey }, _vars) => {
// 直接更新 installed 缓存,移除该 skill // 直接更新 installed 缓存,移除该 skill
queryClient.setQueryData<InstalledSkill[]>( queryClient.setQueryData<InstalledSkill[]>(
+31 -4
View File
@@ -174,8 +174,13 @@
"deleteFailed": "Failed to delete provider: {{error}}", "deleteFailed": "Failed to delete provider: {{error}}",
"settingsSaved": "Settings saved", "settingsSaved": "Settings saved",
"settingsSaveFailed": "Failed to save settings: {{error}}", "settingsSaveFailed": "Failed to save settings: {{error}}",
"openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled", "proxyRequiredForSwitch": "This provider {{reason}}, requires the proxy service to work properly. Start the proxy first.",
"proxyReasonCopilot": "uses GitHub Copilot as a Claude provider",
"proxyReasonOpenAIChat": "uses OpenAI Chat API format",
"proxyReasonOpenAIResponses": "uses OpenAI Responses API format",
"proxyReasonFullUrl": "has full URL connection mode enabled",
"openAIFormatHint": "This provider uses OpenAI-compatible format and requires the proxy service to be enabled", "openAIFormatHint": "This provider uses OpenAI-compatible format and requires the proxy service to be enabled",
"copilotProxyHint": "GitHub Copilot as a Claude provider always requires the local proxy; the proxy automatically selects Chat Completions or Responses based on the current model.",
"openLinkFailed": "Failed to open link", "openLinkFailed": "Failed to open link",
"openclawModelsRegistered": "Models have been registered to /model list", "openclawModelsRegistered": "Models have been registered to /model list",
"openclawDefaultModelSet": "Set as default model", "openclawDefaultModelSet": "Set as default model",
@@ -608,6 +613,16 @@
"searchSessions": "Search sessions", "searchSessions": "Search sessions",
"providerFilterAll": "All", "providerFilterAll": "All",
"sessionList": "Sessions", "sessionList": "Sessions",
"manageBatchTooltip": "Enter batch management",
"exitBatchModeTooltip": "Exit batch management",
"batchModeHint": "Select sessions to delete",
"selectForBatch": "Select session",
"selectedCount": "{{count}} selected",
"selectAllFiltered": "Select all",
"clearFilteredSelection": "Clear selection",
"clearSelection": "Clear",
"deleteSelected": "Delete",
"batchDeleting": "Deleting...",
"loadingSessions": "Loading sessions...", "loadingSessions": "Loading sessions...",
"noSessions": "No sessions found", "noSessions": "No sessions found",
"selectSession": "Select a session to view details", "selectSession": "Select a session to view details",
@@ -636,6 +651,12 @@
"deleteConfirmAction": "Delete session", "deleteConfirmAction": "Delete session",
"sessionDeleted": "Session deleted", "sessionDeleted": "Session deleted",
"deleteFailed": "Failed to delete session: {{error}}", "deleteFailed": "Failed to delete session: {{error}}",
"batchDeleteConfirmTitle": "Delete selected sessions",
"batchDeleteConfirmMessage": "This will permanently delete {{count}} selected local sessions.\n\nThis action cannot be undone.",
"batchDeleteConfirmAction": "Delete selected",
"batchDeleteSuccess": "Deleted {{count}} sessions",
"batchDeleteFailed": "{{failed}} sessions could not be deleted",
"batchDeleteRequestFailed": "Batch delete failed. Please try again later.",
"loadingMessages": "Loading transcript...", "loadingMessages": "Loading transcript...",
"emptySession": "No messages available", "emptySession": "No messages available",
"clickToCopyPath": "Click to copy path", "clickToCopyPath": "Click to copy path",
@@ -693,7 +714,7 @@
"aggregatorApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset", "aggregatorApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
"thirdPartyApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset", "thirdPartyApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
"customApiKeyHint": "💡 Custom configuration requires manually filling all necessary fields", "customApiKeyHint": "💡 Custom configuration requires manually filling all necessary fields",
"omoHint": "💡 OMO config manages Agent model assignments and writes to oh-my-opencode.jsonc", "omoHint": "💡 OMO config manages Agent model assignments and supports both oh-my-openagent.jsonc and oh-my-opencode.jsonc",
"officialHint": "💡 Official provider uses browser login, no API Key needed", "officialHint": "💡 Official provider uses browser login, no API Key needed",
"getApiKey": "Get API Key", "getApiKey": "Get API Key",
"partnerPromotion": { "partnerPromotion": {
@@ -743,6 +764,10 @@
"anthropicReasoningModel": "Reasoning Model (Thinking)", "anthropicReasoningModel": "Reasoning Model (Thinking)",
"apiFormat": "API Format", "apiFormat": "API Format",
"apiFormatHint": "Select the input format for the provider's API", "apiFormatHint": "Select the input format for the provider's API",
"fullUrlLabel": "Full URL",
"fullUrlEnabled": "Full URL Mode",
"fullUrlDisabled": "Mark as Full URL",
"fullUrlHint": "💡 Enter the full request URL. This mode requires the proxy to be enabled, and the proxy will use the URL as-is without appending a path",
"apiFormatAnthropic": "Anthropic Messages (Native)", "apiFormatAnthropic": "Anthropic Messages (Native)",
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)", "apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)",
"apiFormatOpenAIResponses": "OpenAI Responses API (Requires proxy)", "apiFormatOpenAIResponses": "OpenAI Responses API (Requires proxy)",
@@ -908,7 +933,8 @@
"modelsRequired": "Please add at least one model", "modelsRequired": "Please add at least one model",
"providerKey": "Provider Key", "providerKey": "Provider Key",
"providerKeyPlaceholder": "my-provider", "providerKeyPlaceholder": "my-provider",
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.", "providerKeyHint": "Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.",
"providerKeyLockedHint": "This provider has already been added to the app config, so its key can no longer be changed.",
"providerKeyRequired": "Provider key is required", "providerKeyRequired": "Provider key is required",
"providerKeyDuplicate": "This key is already in use", "providerKeyDuplicate": "This key is already in use",
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.", "providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
@@ -1367,7 +1393,8 @@
"backupCreated": "Backup created: {{path}}", "backupCreated": "Backup created: {{path}}",
"providerKey": "Provider Key", "providerKey": "Provider Key",
"providerKeyPlaceholder": "my-provider", "providerKeyPlaceholder": "my-provider",
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.", "providerKeyHint": "Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.",
"providerKeyLockedHint": "This provider has already been added to the app config, so its key can no longer be changed.",
"providerKeyRequired": "Provider key is required", "providerKeyRequired": "Provider key is required",
"providerKeyDuplicate": "This key is already in use", "providerKeyDuplicate": "This key is already in use",
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.", "providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
+31 -4
View File
@@ -174,8 +174,13 @@
"deleteFailed": "プロバイダーの削除に失敗しました: {{error}}", "deleteFailed": "プロバイダーの削除に失敗しました: {{error}}",
"settingsSaved": "設定を保存しました", "settingsSaved": "設定を保存しました",
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}", "settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
"openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です", "proxyRequiredForSwitch": "このプロバイダーは{{reason}}、プロキシサービスが必要です。先にプロキシを起動してください",
"proxyReasonCopilot": "GitHub Copilot を Claude プロバイダーとして使用しており",
"proxyReasonOpenAIChat": "OpenAI Chat API フォーマットを使用しており",
"proxyReasonOpenAIResponses": "OpenAI Responses API フォーマットを使用しており",
"proxyReasonFullUrl": "完全 URL 接続モードが有効になっており",
"openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です", "openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です",
"copilotProxyHint": "GitHub Copilot を Claude プロバイダーとして使用する場合、ローカルプロキシが常に必要です。プロキシは現在のモデルに応じて Chat Completions または Responses を自動的に選択します。",
"openLinkFailed": "リンクを開けませんでした", "openLinkFailed": "リンクを開けませんでした",
"openclawModelsRegistered": "モデルが /model リストに登録されました", "openclawModelsRegistered": "モデルが /model リストに登録されました",
"openclawDefaultModelSet": "デフォルトモデルに設定しました", "openclawDefaultModelSet": "デフォルトモデルに設定しました",
@@ -608,6 +613,16 @@
"searchSessions": "セッションを検索", "searchSessions": "セッションを検索",
"providerFilterAll": "すべて", "providerFilterAll": "すべて",
"sessionList": "セッション一覧", "sessionList": "セッション一覧",
"manageBatchTooltip": "一括管理に入る",
"exitBatchModeTooltip": "一括管理を終了",
"batchModeHint": "削除するセッションを選択",
"selectForBatch": "セッションを選択",
"selectedCount": "{{count}} 件を選択中",
"selectAllFiltered": "一覧を全選択",
"clearFilteredSelection": "全選択を解除",
"clearSelection": "クリア",
"deleteSelected": "削除",
"batchDeleting": "削除中...",
"loadingSessions": "セッションを読み込み中...", "loadingSessions": "セッションを読み込み中...",
"noSessions": "セッションが見つかりません", "noSessions": "セッションが見つかりません",
"selectSession": "セッションを選択してください", "selectSession": "セッションを選択してください",
@@ -636,6 +651,12 @@
"deleteConfirmAction": "セッションを削除", "deleteConfirmAction": "セッションを削除",
"sessionDeleted": "セッションを削除しました", "sessionDeleted": "セッションを削除しました",
"deleteFailed": "セッションの削除に失敗しました: {{error}}", "deleteFailed": "セッションの削除に失敗しました: {{error}}",
"batchDeleteConfirmTitle": "選択したセッションを削除",
"batchDeleteConfirmMessage": "選択した {{count}} 件のローカルセッションを完全に削除します。\n\nこの操作は元に戻せません。",
"batchDeleteConfirmAction": "選択した項目を削除",
"batchDeleteSuccess": "{{count}} 件のセッションを削除しました",
"batchDeleteFailed": "{{failed}} 件のセッションを削除できませんでした",
"batchDeleteRequestFailed": "一括削除に失敗しました。しばらくしてから再試行してください。",
"loadingMessages": "内容を読み込み中...", "loadingMessages": "内容を読み込み中...",
"emptySession": "表示できる内容がありません", "emptySession": "表示できる内容がありません",
"clickToCopyPath": "クリックしてパスをコピー", "clickToCopyPath": "クリックしてパスをコピー",
@@ -693,7 +714,7 @@
"aggregatorApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです", "aggregatorApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"thirdPartyApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです", "thirdPartyApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"customApiKeyHint": "💡 カスタム設定では必要な項目をすべて手動で入力してください", "customApiKeyHint": "💡 カスタム設定では必要な項目をすべて手動で入力してください",
"omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-opencode.jsonc に書き込みます", "omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-openagent.jsonc / oh-my-opencode.jsonc の両方に対応します",
"officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です", "officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です",
"getApiKey": "API Key を取得", "getApiKey": "API Key を取得",
"partnerPromotion": { "partnerPromotion": {
@@ -743,6 +764,10 @@
"anthropicReasoningModel": "推論モデル(Thinking", "anthropicReasoningModel": "推論モデル(Thinking",
"apiFormat": "API フォーマット", "apiFormat": "API フォーマット",
"apiFormatHint": "プロバイダー API の入力フォーマットを選択", "apiFormatHint": "プロバイダー API の入力フォーマットを選択",
"fullUrlLabel": "フル URL",
"fullUrlEnabled": "フル URL モード",
"fullUrlDisabled": "フル URL として設定",
"fullUrlHint": "💡 完全なリクエスト URL を入力してください。このモードはプロキシを有効にして使用する必要があり、プロキシはこの URL をそのまま使用し、パスを追加しません",
"apiFormatAnthropic": "Anthropic Messages(ネイティブ)", "apiFormatAnthropic": "Anthropic Messages(ネイティブ)",
"apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)", "apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)",
"apiFormatOpenAIResponses": "OpenAI Responses API(プロキシが必要)", "apiFormatOpenAIResponses": "OpenAI Responses API(プロキシが必要)",
@@ -908,7 +933,8 @@
"modelsRequired": "モデルを少なくとも1つ追加してください", "modelsRequired": "モデルを少なくとも1つ追加してください",
"providerKey": "プロバイダーキー", "providerKey": "プロバイダーキー",
"providerKeyPlaceholder": "my-provider", "providerKeyPlaceholder": "my-provider",
"providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。", "providerKeyHint": "設定ファイルの一意の識別子です。小文字、数字、ハイフンのみ使用できます。",
"providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。",
"providerKeyRequired": "プロバイダーキーを入力してください", "providerKeyRequired": "プロバイダーキーを入力してください",
"providerKeyDuplicate": "このキーは既に使用されています", "providerKeyDuplicate": "このキーは既に使用されています",
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。", "providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。",
@@ -1367,7 +1393,8 @@
"backupCreated": "バックアップを作成しました: {{path}}", "backupCreated": "バックアップを作成しました: {{path}}",
"providerKey": "プロバイダーキー", "providerKey": "プロバイダーキー",
"providerKeyPlaceholder": "my-provider", "providerKeyPlaceholder": "my-provider",
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。", "providerKeyHint": "設定ファイル内のユニーク識別子。小文字、数字、ハイフンのみ使用可能。",
"providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。",
"providerKeyRequired": "プロバイダーキーを入力してください", "providerKeyRequired": "プロバイダーキーを入力してください",
"providerKeyDuplicate": "このキーは既に使用されています", "providerKeyDuplicate": "このキーは既に使用されています",
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。", "providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
+31 -4
View File
@@ -174,8 +174,13 @@
"deleteFailed": "删除供应商失败:{{error}}", "deleteFailed": "删除供应商失败:{{error}}",
"settingsSaved": "设置已保存", "settingsSaved": "设置已保存",
"settingsSaveFailed": "保存设置失败:{{error}}", "settingsSaveFailed": "保存设置失败:{{error}}",
"openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用", "proxyRequiredForSwitch": "此供应商{{reason}},需要代理服务才能正常使用,请先启动代理",
"proxyReasonCopilot": "使用 GitHub Copilot 作为 Claude 供应商",
"proxyReasonOpenAIChat": "使用 OpenAI Chat 接口格式",
"proxyReasonOpenAIResponses": "使用 OpenAI Responses 接口格式",
"proxyReasonFullUrl": "开启了完整 URL 连接模式",
"openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用", "openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用",
"copilotProxyHint": "GitHub Copilot 作为 Claude 供应商时始终需要本地代理;代理会根据当前模型自动选择 Chat Completions 或 Responses。",
"openLinkFailed": "链接打开失败", "openLinkFailed": "链接打开失败",
"openclawModelsRegistered": "模型已注册到 /model 列表", "openclawModelsRegistered": "模型已注册到 /model 列表",
"openclawDefaultModelSet": "已设为默认模型", "openclawDefaultModelSet": "已设为默认模型",
@@ -608,6 +613,16 @@
"searchSessions": "搜索会话", "searchSessions": "搜索会话",
"providerFilterAll": "全部", "providerFilterAll": "全部",
"sessionList": "会话列表", "sessionList": "会话列表",
"manageBatchTooltip": "进入批量管理",
"exitBatchModeTooltip": "退出批量管理",
"batchModeHint": "勾选要删除的会话",
"selectForBatch": "选择会话",
"selectedCount": "已选 {{count}} 项",
"selectAllFiltered": "全选当前",
"clearFilteredSelection": "取消全选",
"clearSelection": "清空已选",
"deleteSelected": "批量删除",
"batchDeleting": "删除中...",
"loadingSessions": "加载会话中...", "loadingSessions": "加载会话中...",
"noSessions": "未发现会话", "noSessions": "未发现会话",
"selectSession": "请选择会话查看详情", "selectSession": "请选择会话查看详情",
@@ -636,6 +651,12 @@
"deleteConfirmAction": "删除会话", "deleteConfirmAction": "删除会话",
"sessionDeleted": "会话已删除", "sessionDeleted": "会话已删除",
"deleteFailed": "删除会话失败: {{error}}", "deleteFailed": "删除会话失败: {{error}}",
"batchDeleteConfirmTitle": "批量删除会话",
"batchDeleteConfirmMessage": "将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。",
"batchDeleteConfirmAction": "删除所选会话",
"batchDeleteSuccess": "已删除 {{count}} 个会话",
"batchDeleteFailed": "{{failed}} 个会话删除失败",
"batchDeleteRequestFailed": "批量删除失败,请稍后重试",
"loadingMessages": "加载会话内容中...", "loadingMessages": "加载会话内容中...",
"emptySession": "该会话暂无可展示内容", "emptySession": "该会话暂无可展示内容",
"clickToCopyPath": "点击复制路径", "clickToCopyPath": "点击复制路径",
@@ -693,7 +714,7 @@
"aggregatorApiKeyHint": "💡 只需填写 API Key,请求地址已预设", "aggregatorApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
"thirdPartyApiKeyHint": "💡 只需填写 API Key,请求地址已预设", "thirdPartyApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
"customApiKeyHint": "💡 自定义配置需手动填写所有必要字段", "customApiKeyHint": "💡 自定义配置需手动填写所有必要字段",
"omoHint": "💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc", "omoHint": "💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc",
"officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key", "officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key",
"getApiKey": "获取 API Key", "getApiKey": "获取 API Key",
"partnerPromotion": { "partnerPromotion": {
@@ -743,6 +764,10 @@
"anthropicReasoningModel": "推理模型 (Thinking)", "anthropicReasoningModel": "推理模型 (Thinking)",
"apiFormat": "API 格式", "apiFormat": "API 格式",
"apiFormatHint": "选择供应商 API 的输入格式", "apiFormatHint": "选择供应商 API 的输入格式",
"fullUrlLabel": "完整 URL",
"fullUrlEnabled": "完整 URL 模式",
"fullUrlDisabled": "标记为完整 URL",
"fullUrlHint": "💡 请填写完整请求 URL,并且必须开启代理后使用;代理将直接使用此 URL,不拼接路径",
"apiFormatAnthropic": "Anthropic Messages (原生)", "apiFormatAnthropic": "Anthropic Messages (原生)",
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)", "apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)",
"apiFormatOpenAIResponses": "OpenAI Responses API (需开启代理)", "apiFormatOpenAIResponses": "OpenAI Responses API (需开启代理)",
@@ -908,7 +933,8 @@
"modelsRequired": "请至少添加一个模型配置", "modelsRequired": "请至少添加一个模型配置",
"providerKey": "供应商标识", "providerKey": "供应商标识",
"providerKeyPlaceholder": "my-provider", "providerKeyPlaceholder": "my-provider",
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符", "providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
"providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改",
"providerKeyRequired": "请填写供应商标识", "providerKeyRequired": "请填写供应商标识",
"providerKeyDuplicate": "此标识已被使用,请更换", "providerKeyDuplicate": "此标识已被使用,请更换",
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符", "providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
@@ -1367,7 +1393,8 @@
"backupCreated": "已创建备份:{{path}}", "backupCreated": "已创建备份:{{path}}",
"providerKey": "供应商标识", "providerKey": "供应商标识",
"providerKeyPlaceholder": "my-provider", "providerKeyPlaceholder": "my-provider",
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符", "providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
"providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改",
"providerKeyRequired": "请填写供应商标识", "providerKeyRequired": "请填写供应商标识",
"providerKeyDuplicate": "此标识已被使用,请更换", "providerKeyDuplicate": "此标识已被使用,请更换",
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符", "providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
+31 -6
View File
@@ -21,6 +21,10 @@ export interface SwitchResult {
warnings: string[]; warnings: string[];
} }
export interface OpenTerminalOptions {
cwd?: string;
}
export const providersApi = { export const providersApi = {
async getAll(appId: AppId): Promise<Record<string, Provider>> { async getAll(appId: AppId): Promise<Record<string, Provider>> {
return await invoke("get_providers", { app: appId }); return await invoke("get_providers", { app: appId });
@@ -30,12 +34,24 @@ export const providersApi = {
return await invoke("get_current_provider", { app: appId }); return await invoke("get_current_provider", { app: appId });
}, },
async add(provider: Provider, appId: AppId): Promise<boolean> { async add(
return await invoke("add_provider", { provider, app: appId }); provider: Provider,
appId: AppId,
addToLive?: boolean,
): Promise<boolean> {
return await invoke("add_provider", { provider, app: appId, addToLive });
}, },
async update(provider: Provider, appId: AppId): Promise<boolean> { async update(
return await invoke("update_provider", { provider, app: appId }); provider: Provider,
appId: AppId,
originalId?: string,
): Promise<boolean> {
return await invoke("update_provider", {
provider,
app: appId,
originalId,
});
}, },
async delete(id: string, appId: AppId): Promise<boolean> { async delete(id: string, appId: AppId): Promise<boolean> {
@@ -83,8 +99,17 @@ export const providersApi = {
* *
* 使 API * 使 API
*/ */
async openTerminal(providerId: string, appId: AppId): Promise<boolean> { async openTerminal(
return await invoke("open_provider_terminal", { providerId, app: appId }); providerId: string,
appId: AppId,
options?: OpenTerminalOptions,
): Promise<boolean> {
const { cwd } = options ?? {};
return await invoke("open_provider_terminal", {
providerId,
app: appId,
cwd,
});
}, },
/** /**
+11
View File
@@ -7,6 +7,11 @@ export interface DeleteSessionOptions {
sourcePath: string; sourcePath: string;
} }
export interface DeleteSessionResult extends DeleteSessionOptions {
success: boolean;
error?: string;
}
export const sessionsApi = { export const sessionsApi = {
async list(): Promise<SessionMeta[]> { async list(): Promise<SessionMeta[]> {
return await invoke("list_sessions"); return await invoke("list_sessions");
@@ -28,6 +33,12 @@ export const sessionsApi = {
}); });
}, },
async deleteMany(
items: DeleteSessionOptions[],
): Promise<DeleteSessionResult[]> {
return await invoke("delete_sessions", { items });
},
async launchTerminal(options: { async launchTerminal(options: {
command: string; command: string;
cwd?: string | null; cwd?: string | null;
+4
View File
@@ -47,6 +47,10 @@ export const settingsApi = {
await invoke("open_config_folder", { app: appId }); await invoke("open_config_folder", { app: appId });
}, },
async pickDirectory(defaultPath?: string): Promise<string | null> {
return await invoke("pick_directory", { defaultPath });
},
async selectConfigDirectory(defaultPath?: string): Promise<string | null> { async selectConfigDirectory(defaultPath?: string): Promise<string | null> {
return await invoke("pick_directory", { defaultPath }); return await invoke("pick_directory", { defaultPath });
}, },
+14 -5
View File
@@ -15,7 +15,10 @@ export const useAddProviderMutation = (appId: AppId) => {
return useMutation({ return useMutation({
mutationFn: async ( mutationFn: async (
providerInput: Omit<Provider, "id"> & { providerKey?: string }, providerInput: Omit<Provider, "id"> & {
providerKey?: string;
addToLive?: boolean;
},
) => { ) => {
let id: string; let id: string;
@@ -36,7 +39,7 @@ export const useAddProviderMutation = (appId: AppId) => {
id = generateUUID(); id = generateUUID();
} }
const { providerKey: _providerKey, ...rest } = providerInput; const { providerKey: _providerKey, addToLive, ...rest } = providerInput;
const newProvider: Provider = { const newProvider: Provider = {
...rest, ...rest,
@@ -45,7 +48,7 @@ export const useAddProviderMutation = (appId: AppId) => {
}; };
delete (newProvider as any).providerKey; delete (newProvider as any).providerKey;
await providersApi.add(newProvider, appId); await providersApi.add(newProvider, appId, addToLive);
return newProvider; return newProvider;
}, },
onSuccess: async () => { onSuccess: async () => {
@@ -107,8 +110,14 @@ export const useUpdateProviderMutation = (appId: AppId) => {
const { t } = useTranslation(); const { t } = useTranslation();
return useMutation({ return useMutation({
mutationFn: async (provider: Provider) => { mutationFn: async ({
await providersApi.update(provider, appId); provider,
originalId,
}: {
provider: Provider;
originalId?: string;
}) => {
await providersApi.update(provider, appId, originalId);
return provider; return provider;
}, },
onSuccess: async () => { onSuccess: async () => {
+2
View File
@@ -163,6 +163,8 @@ export interface ProviderMeta {
authBinding?: AuthBinding; authBinding?: AuthBinding;
// Claude 认证字段名 // Claude 认证字段名
apiKeyField?: ClaudeApiKeyField; apiKeyField?: ClaudeApiKeyField;
// 是否将 base_url 视为完整 API 端点(代理直接使用此 URL,不拼接路径)
isFullUrl?: boolean;
// Prompt cache key for OpenAI-compatible endpoints (improves cache hit rate) // Prompt cache key for OpenAI-compatible endpoints (improves cache hit rate)
promptCacheKey?: string; promptCacheKey?: string;
// 供应商类型(用于识别 Copilot 等特殊供应商) // 供应商类型(用于识别 Copilot 等特殊供应商)
+1 -1
View File
@@ -246,7 +246,7 @@ export const OMO_DISABLEABLE_SKILLS = [
] as const; ] as const;
export const OMO_DEFAULT_SCHEMA_URL = export const OMO_DEFAULT_SCHEMA_URL =
"https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"; "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json";
export const OMO_SISYPHUS_AGENT_PLACEHOLDER = `{ export const OMO_SISYPHUS_AGENT_PLACEHOLDER = `{
"disabled": false, "disabled": false,
+151 -8
View File
@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { import {
act,
fireEvent, fireEvent,
render, render,
screen, screen,
@@ -8,6 +9,7 @@ import {
} from "@testing-library/react"; } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage"; import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
import { sessionsApi } from "@/lib/api/sessions";
import type { SessionMessage, SessionMeta } from "@/types"; import type { SessionMessage, SessionMeta } from "@/types";
import { setSessionFixtures } from "../msw/state"; import { setSessionFixtures } from "../msw/state";
@@ -62,16 +64,19 @@ const renderPage = () => {
}, },
}); });
return render( return {
<QueryClientProvider client={client}> client,
<SessionManagerPage appId="codex" /> ...render(
</QueryClientProvider>, <QueryClientProvider client={client}>
); <SessionManagerPage appId="codex" />
</QueryClientProvider>,
),
};
}; };
const openSearch = () => { const openSearch = () => {
const searchButton = Array.from(screen.getAllByRole("button")).find((button) => const searchButton = Array.from(screen.getAllByRole("button")).find(
button.querySelector(".lucide-search"), (button) => button.querySelector(".lucide-search"),
); );
if (!searchButton) { if (!searchButton) {
@@ -81,10 +86,23 @@ const openSearch = () => {
fireEvent.click(searchButton); fireEvent.click(searchButton);
}; };
const closeSearch = () => {
const closeButton = Array.from(screen.getAllByRole("button")).find(
(button) => button.querySelector(".lucide-x"),
);
if (!closeButton) {
throw new Error("Search close button not found");
}
fireEvent.click(closeButton);
};
describe("SessionManagerPage", () => { describe("SessionManagerPage", () => {
beforeEach(() => { beforeEach(() => {
toastSuccessMock.mockReset(); toastSuccessMock.mockReset();
toastErrorMock.mockReset(); toastErrorMock.mockReset();
Element.prototype.scrollIntoView = vi.fn();
const sessions: SessionMeta[] = [ const sessions: SessionMeta[] = [
{ {
@@ -178,11 +196,136 @@ describe("SessionManagerPage", () => {
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(), expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(),
); );
expect(screen.getByText("sessionManager.selectSession")).toBeInTheDocument(); expect(
screen.getByText("sessionManager.selectSession"),
).toBeInTheDocument();
expect( expect(
screen.queryByText("sessionManager.emptySession"), screen.queryByText("sessionManager.emptySession"),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(toastErrorMock).not.toHaveBeenCalled(); expect(toastErrorMock).not.toHaveBeenCalled();
expect(toastSuccessMock).toHaveBeenCalled(); expect(toastSuccessMock).toHaveBeenCalled();
}); });
it("restores batch delete controls when deleteMany rejects", async () => {
const deleteManySpy = vi
.spyOn(sessionsApi, "deleteMany")
.mockRejectedValueOnce(new Error("network error"));
renderPage();
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "Alpha Session" }),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
fireEvent.click(screen.getByRole("button", { name: /批量删除/i }));
const dialog = screen.getByTestId("confirm-dialog");
fireEvent.click(
within(dialog).getByRole("button", { name: /删除所选会话/i }),
);
await waitFor(() =>
expect(toastErrorMock).toHaveBeenCalledWith("network error"),
);
await waitFor(() =>
expect(
screen.getByRole("button", { name: /批量删除/i }),
).not.toBeDisabled(),
);
deleteManySpy.mockRestore();
});
it("keeps the exit batch mode button visible when search hides all sessions", async () => {
renderPage();
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "Alpha Session" }),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
openSearch();
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "NoSuchSession" },
});
await waitFor(() => expect(screen.queryByText("Alpha Session")).toBeNull());
expect(screen.getByRole("button", { name: /退出批量管理/i })).toBeVisible();
});
it("drops hidden selections when search narrows the result set", async () => {
renderPage();
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "Alpha Session" }),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
expect(screen.getByText("已选 2 项")).toBeInTheDocument();
openSearch();
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "Alpha" },
});
await waitFor(() =>
expect(screen.queryByText("Beta Session")).not.toBeInTheDocument(),
);
closeSearch();
await waitFor(() =>
expect(screen.getByText("已选 1 项")).toBeInTheDocument(),
);
});
it("removes successfully deleted sessions from the UI before refetch completes", async () => {
const view = renderPage();
let resolveInvalidate!: () => void;
const invalidateSpy = vi
.spyOn(view.client, "invalidateQueries")
.mockImplementation(
() =>
new Promise((resolve) => {
resolveInvalidate = () => resolve(undefined);
}),
);
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "Alpha Session" }),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
fireEvent.click(screen.getByRole("button", { name: /批量删除/i }));
const dialog = screen.getByTestId("confirm-dialog");
fireEvent.click(
within(dialog).getByRole("button", { name: /删除所选会话/i }),
);
await waitFor(() => {
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument();
expect(screen.queryByText("Beta Session")).not.toBeInTheDocument();
});
await act(async () => {
resolveInvalidate();
});
invalidateSpy.mockRestore();
});
}); });
+108 -2
View File
@@ -104,11 +104,12 @@ function renderSection(config?: WebDavSyncSettings) {
mutations: { retry: false }, mutations: { retry: false },
}, },
}); });
return render( const view = render(
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
<WebdavSyncSection config={config} /> <WebdavSyncSection config={config} />
</QueryClientProvider>, </QueryClientProvider>,
); );
return { ...view, client };
} }
describe("WebdavSyncSection", () => { describe("WebdavSyncSection", () => {
@@ -204,7 +205,7 @@ describe("WebdavSyncSection", () => {
expect.objectContaining({ expect.objectContaining({
baseUrl: "https://dav.example.com/dav/", baseUrl: "https://dav.example.com/dav/",
username: "alice", username: "alice",
password: "secret", password: "",
autoSync: false, autoSync: false,
}), }),
false, false,
@@ -222,6 +223,111 @@ describe("WebdavSyncSection", () => {
); );
}); });
it("preserves password only for the single post-save refresh", async () => {
const view = renderSection(baseConfig);
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
await waitFor(() => {
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
});
view.rerender(
<QueryClientProvider client={view.client}>
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
</QueryClientProvider>,
);
expect(
(
screen.getByPlaceholderText(
"settings.webdavSync.passwordPlaceholder",
) as HTMLInputElement
).value,
).toBe("secret");
view.rerender(
<QueryClientProvider client={view.client}>
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
</QueryClientProvider>,
);
expect(
(
screen.getByPlaceholderText(
"settings.webdavSync.passwordPlaceholder",
) as HTMLInputElement
).value,
).toBe("");
});
it("does not preserve password after a later external config refresh", async () => {
const view = renderSection(baseConfig);
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
await waitFor(() => {
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
});
view.rerender(
<QueryClientProvider client={view.client}>
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
</QueryClientProvider>,
);
expect(
(
screen.getByPlaceholderText(
"settings.webdavSync.passwordPlaceholder",
) as HTMLInputElement
).value,
).toBe("secret");
view.rerender(
<QueryClientProvider client={view.client}>
<WebdavSyncSection
config={{ ...baseConfig, username: "bob", password: "" }}
/>
</QueryClientProvider>,
);
expect(
(
screen.getByPlaceholderText(
"settings.webdavSync.passwordPlaceholder",
) as HTMLInputElement
).value,
).toBe("");
});
it("does not submit a preserved password again when testing without touching it", async () => {
const view = renderSection(baseConfig);
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
await waitFor(() => {
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
});
view.rerender(
<QueryClientProvider client={view.client}>
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.test" }));
await waitFor(() => {
expect(settingsApiMock.webdavTestConnection).toHaveBeenLastCalledWith(
expect.objectContaining({
password: "",
}),
true,
);
});
});
it("saves auto sync as true after toggle", async () => { it("saves auto sync as true after toggle", async () => {
renderSection(baseConfig); renderSection(baseConfig);
@@ -65,4 +65,19 @@ describe("AWS Bedrock OpenCode Provider Presets", () => {
modelIds.some((id) => id.includes("anthropic.claude")), modelIds.some((id) => id.includes("anthropic.claude")),
).toBe(true); ).toBe(true);
}); });
it("Kimi For Coding preset should use Anthropic with the coding endpoint", () => {
const kimiForCodingPreset = opencodeProviderPresets.find(
(p) => p.name === "Kimi For Coding",
);
expect(kimiForCodingPreset).toBeDefined();
expect(kimiForCodingPreset!.settingsConfig.npm).toBe("@ai-sdk/anthropic");
expect(kimiForCodingPreset!.settingsConfig.options?.baseURL).toBe(
"https://api.kimi.com/coding/v1",
);
expect(kimiForCodingPreset!.templateValues?.baseURL.defaultValue).toBe(
"https://api.kimi.com/coding/v1",
);
});
}); });
+54 -1
View File
@@ -7,11 +7,15 @@ import type { Provider, UsageScript } from "@/types";
const toastSuccessMock = vi.fn(); const toastSuccessMock = vi.fn();
const toastErrorMock = vi.fn(); const toastErrorMock = vi.fn();
const toastInfoMock = vi.fn();
const toastWarningMock = vi.fn();
vi.mock("sonner", () => ({ vi.mock("sonner", () => ({
toast: { toast: {
success: (...args: unknown[]) => toastSuccessMock(...args), success: (...args: unknown[]) => toastSuccessMock(...args),
error: (...args: unknown[]) => toastErrorMock(...args), error: (...args: unknown[]) => toastErrorMock(...args),
info: (...args: unknown[]) => toastInfoMock(...args),
warning: (...args: unknown[]) => toastWarningMock(...args),
}, },
})); }));
@@ -116,6 +120,8 @@ beforeEach(() => {
openclawApiSetDefaultModelMock.mockReset(); openclawApiSetDefaultModelMock.mockReset();
toastSuccessMock.mockReset(); toastSuccessMock.mockReset();
toastErrorMock.mockReset(); toastErrorMock.mockReset();
toastInfoMock.mockReset();
toastWarningMock.mockReset();
addProviderMutation.isPending = false; addProviderMutation.isPending = false;
updateProviderMutation.isPending = false; updateProviderMutation.isPending = false;
@@ -163,7 +169,10 @@ describe("useProviderActions", () => {
await result.current.updateProvider(provider); await result.current.updateProvider(provider);
}); });
expect(updateProviderMutateAsync).toHaveBeenCalledWith(provider); expect(updateProviderMutateAsync).toHaveBeenCalledWith({
provider,
originalId: undefined,
});
expect(providersApiUpdateTrayMenuMock).toHaveBeenCalledTimes(1); expect(providersApiUpdateTrayMenuMock).toHaveBeenCalledTimes(1);
}); });
@@ -185,6 +194,50 @@ describe("useProviderActions", () => {
expect(settingsApiApplyMock).not.toHaveBeenCalled(); expect(settingsApiApplyMock).not.toHaveBeenCalled();
}); });
it("blocks switching providers that require proxy when proxy is not running", async () => {
const { wrapper } = createWrapper();
const provider = createProvider({
category: "custom",
meta: {
apiFormat: "openai_chat",
},
});
const { result } = renderHook(() => useProviderActions("claude", false), {
wrapper,
});
await act(async () => {
await result.current.switchProvider(provider);
});
expect(switchProviderMutateAsync).not.toHaveBeenCalled();
expect(toastWarningMock).toHaveBeenCalledTimes(1);
expect(settingsApiGetMock).not.toHaveBeenCalled();
});
it("blocks switching Codex full URL providers when proxy is not running", async () => {
const { wrapper } = createWrapper();
const provider = createProvider({
category: "custom",
meta: {
isFullUrl: true,
},
});
const { result } = renderHook(() => useProviderActions("codex", false), {
wrapper,
});
await act(async () => {
await result.current.switchProvider(provider);
});
expect(switchProviderMutateAsync).not.toHaveBeenCalled();
expect(toastWarningMock).toHaveBeenCalledTimes(1);
expect(settingsApiGetMock).not.toHaveBeenCalled();
});
it("should sync plugin config when switching Claude provider with integration enabled", async () => { it("should sync plugin config when switching Claude provider with integration enabled", async () => {
switchProviderMutateAsync.mockResolvedValueOnce(undefined); switchProviderMutateAsync.mockResolvedValueOnce(undefined);
settingsApiGetMock.mockResolvedValueOnce({ settingsApiGetMock.mockResolvedValueOnce({
+104 -3
View File
@@ -2,7 +2,13 @@ import { Suspense, type ComponentType } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
import { resetProviderState } from "../msw/state"; import { providersApi } from "@/lib/api/providers";
import {
resetProviderState,
setCurrentProviderId,
setLiveProviderIds,
setProviders,
} from "../msw/state";
import { emitTauriEvent } from "../msw/tauriMocks"; import { emitTauriEvent } from "../msw/tauriMocks";
const toastSuccessMock = vi.fn(); const toastSuccessMock = vi.fn();
@@ -75,8 +81,11 @@ vi.mock("@/components/providers/EditProviderDialog", () => ({
<button <button
onClick={() => onClick={() =>
onSubmit({ onSubmit({
...provider, provider: {
name: `${provider.name}-edited`, ...provider,
name: `${provider.name}-edited`,
},
originalId: provider.id,
}) })
} }
> >
@@ -114,6 +123,7 @@ vi.mock("@/components/AppSwitcher", () => ({
<span>{activeApp}</span> <span>{activeApp}</span>
<button onClick={() => onSwitch("claude")}>switch-claude</button> <button onClick={() => onSwitch("claude")}>switch-claude</button>
<button onClick={() => onSwitch("codex")}>switch-codex</button> <button onClick={() => onSwitch("codex")}>switch-codex</button>
<button onClick={() => onSwitch("openclaw")}>switch-openclaw</button>
</div> </div>
), ),
})); }));
@@ -230,4 +240,95 @@ describe("App integration with MSW", () => {
expect(toastErrorMock).toHaveBeenCalled(); expect(toastErrorMock).toHaveBeenCalled();
}); });
}); });
it("duplicates openclaw providers with a generated key that avoids live-only ids", async () => {
setProviders("openclaw", {
deepseek: {
id: "deepseek",
name: "DeepSeek",
settingsConfig: {
baseUrl: "https://api.deepseek.com",
apiKey: "test-key",
api: "openai-completions",
models: [],
},
category: "custom",
sortIndex: 0,
createdAt: Date.now(),
},
});
setCurrentProviderId("openclaw", "deepseek");
setLiveProviderIds("openclaw", ["deepseek-copy"]);
const { default: App } = await import("@/App");
renderApp(App);
fireEvent.click(screen.getByText("switch-openclaw"));
await waitFor(() =>
expect(screen.getByTestId("provider-list").textContent).toContain(
"deepseek",
),
);
fireEvent.click(screen.getByText("duplicate"));
await waitFor(() => {
const providerList = screen.getByTestId("provider-list").textContent;
expect(providerList).toContain("deepseek-copy-2");
expect(providerList).toContain("DeepSeek copy");
});
expect(toastErrorMock).not.toHaveBeenCalledWith(
expect.stringContaining("Provider key is required for openclaw"),
);
});
it("shows toast when duplicate cannot load live provider ids", async () => {
setProviders("openclaw", {
deepseek: {
id: "deepseek",
name: "DeepSeek",
settingsConfig: {
baseUrl: "https://api.deepseek.com",
apiKey: "test-key",
api: "openai-completions",
models: [],
},
category: "custom",
sortIndex: 0,
createdAt: Date.now(),
},
});
setCurrentProviderId("openclaw", "deepseek");
const liveIdsSpy = vi
.spyOn(providersApi, "getOpenClawLiveProviderIds")
.mockRejectedValueOnce(new Error("broken config"));
const { default: App } = await import("@/App");
renderApp(App);
fireEvent.click(screen.getByText("switch-openclaw"));
await waitFor(() =>
expect(screen.getByTestId("provider-list").textContent).toContain(
"deepseek",
),
);
fireEvent.click(screen.getByText("duplicate"));
await waitFor(() => {
expect(toastErrorMock).toHaveBeenCalledWith(
expect.stringContaining("读取配置中的供应商标识失败"),
);
});
expect(screen.getByTestId("provider-list").textContent).not.toContain(
"deepseek-copy",
);
liveIdsSpy.mockRestore();
});
}); });
+40
View File
@@ -6,6 +6,7 @@ import {
deleteProvider, deleteProvider,
deleteSession, deleteSession,
getCurrentProviderId, getCurrentProviderId,
getLiveProviderIds,
getSessionMessages, getSessionMessages,
getProviders, getProviders,
listProviders, listProviders,
@@ -67,6 +68,20 @@ export const handlers = [
http.post(`${TAURI_ENDPOINT}/update_tray_menu`, () => success(true)), http.post(`${TAURI_ENDPOINT}/update_tray_menu`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/get_opencode_live_provider_ids`, () =>
success(getLiveProviderIds("opencode")),
),
http.post(`${TAURI_ENDPOINT}/get_openclaw_live_provider_ids`, () =>
success(getLiveProviderIds("openclaw")),
),
http.post(`${TAURI_ENDPOINT}/get_openclaw_default_model`, () =>
success({ primary: null, fallback: [] }),
),
http.post(`${TAURI_ENDPOINT}/scan_openclaw_config_health`, () => success([])),
http.post(`${TAURI_ENDPOINT}/switch_provider`, async ({ request }) => { http.post(`${TAURI_ENDPOINT}/switch_provider`, async ({ request }) => {
const { id, app } = await withJson<{ id: string; app: AppId }>(request); const { id, app } = await withJson<{ id: string; app: AppId }>(request);
const providers = listProviders(app); const providers = listProviders(app);
@@ -129,6 +144,29 @@ export const handlers = [
return success(deleteSession(providerId, sessionId, sourcePath)); return success(deleteSession(providerId, sessionId, sourcePath));
}), }),
http.post(`${TAURI_ENDPOINT}/delete_sessions`, async ({ request }) => {
const { items = [] } = await withJson<{
items?: {
providerId: string;
sessionId: string;
sourcePath: string;
}[];
}>(request);
return success(
items.map((item) => ({
providerId: item.providerId,
sessionId: item.sessionId,
sourcePath: item.sourcePath,
success: deleteSession(
item.providerId,
item.sessionId,
item.sourcePath,
),
})),
);
}),
// MCP APIs // MCP APIs
http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => { http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => {
const { app } = await withJson<{ app: AppId }>(request); const { app } = await withJson<{ app: AppId }>(request);
@@ -174,6 +212,8 @@ export const handlers = [
http.post(`${TAURI_ENDPOINT}/get_settings`, () => success(getSettings())), http.post(`${TAURI_ENDPOINT}/get_settings`, () => success(getSettings())),
http.post(`${TAURI_ENDPOINT}/check_env_conflicts`, () => success([])),
http.post(`${TAURI_ENDPOINT}/save_settings`, async ({ request }) => { http.post(`${TAURI_ENDPOINT}/save_settings`, async ({ request }) => {
const { settings } = await withJson<{ settings: Settings }>(request); const { settings } = await withJson<{ settings: Settings }>(request);
setSettings(settings); setSettings(settings);
+20
View File
@@ -10,6 +10,7 @@ import type {
type ProvidersByApp = Record<AppId, Record<string, Provider>>; type ProvidersByApp = Record<AppId, Record<string, Provider>>;
type CurrentProviderState = Record<AppId, string>; type CurrentProviderState = Record<AppId, string>;
type McpConfigState = Record<AppId, Record<string, McpServer>>; type McpConfigState = Record<AppId, Record<string, McpServer>>;
type LiveProviderIdsByApp = Record<"opencode" | "openclaw", string[]>;
const createDefaultProviders = (): ProvidersByApp => ({ const createDefaultProviders = (): ProvidersByApp => ({
claude: { claude: {
@@ -77,6 +78,10 @@ const createDefaultCurrent = (): CurrentProviderState => ({
let providers = createDefaultProviders(); let providers = createDefaultProviders();
let current = createDefaultCurrent(); let current = createDefaultCurrent();
let liveProviderIds: LiveProviderIdsByApp = {
opencode: [],
openclaw: [],
};
let settingsState: Settings = { let settingsState: Settings = {
showInTray: true, showInTray: true,
minimizeToTrayOnClose: true, minimizeToTrayOnClose: true,
@@ -184,6 +189,10 @@ const cloneProviders = (value: ProvidersByApp) =>
export const resetProviderState = () => { export const resetProviderState = () => {
providers = createDefaultProviders(); providers = createDefaultProviders();
current = createDefaultCurrent(); current = createDefaultCurrent();
liveProviderIds = {
opencode: [],
openclaw: [],
};
sessionsState = createDefaultSessions(); sessionsState = createDefaultSessions();
sessionMessagesState = createDefaultSessionMessages(); sessionMessagesState = createDefaultSessionMessages();
settingsState = { settingsState = {
@@ -243,6 +252,17 @@ export const getProviders = (appType: AppId) =>
export const getCurrentProviderId = (appType: AppId) => current[appType] ?? ""; export const getCurrentProviderId = (appType: AppId) => current[appType] ?? "";
export const getLiveProviderIds = (appType: "opencode" | "openclaw") => [
...liveProviderIds[appType],
];
export const setLiveProviderIds = (
appType: "opencode" | "openclaw",
ids: string[],
) => {
liveProviderIds[appType] = [...ids];
};
export const setCurrentProviderId = (appType: AppId, providerId: string) => { export const setCurrentProviderId = (appType: AppId, providerId: string) => {
current[appType] = providerId; current[appType] = providerId;
}; };