From 032c86e2b976d3826e39aad44b1964b8ad0fd08d Mon Sep 17 00:00:00 2001 From: mxyhi Date: Sun, 8 Mar 2026 10:19:31 +0800 Subject: [PATCH 1/7] feat(core): make retry cooldown configurable --- .../token_proxy_core/src/proxy/config/mod.rs | 2 + .../src/proxy/config/types.rs | 15 ++ .../src/proxy/config/types.test.rs | 7 + .../token_proxy_core/src/proxy/http.test.rs | 2 + .../token_proxy_core/src/proxy/server.test.rs | 231 +++++++++++++++++- crates/token_proxy_core/src/proxy/service.rs | 4 +- .../src/proxy/upstream.test.rs | 43 +++- .../src/proxy/upstream/result.rs | 16 +- .../src/proxy/upstream/utils.rs | 31 ++- .../src/proxy/upstream_selector.rs | 13 +- .../src/proxy/upstream_selector.test.rs | 18 +- 11 files changed, 348 insertions(+), 34 deletions(-) diff --git a/crates/token_proxy_core/src/proxy/config/mod.rs b/crates/token_proxy_core/src/proxy/config/mod.rs index 34d0d0f..db4f344 100644 --- a/crates/token_proxy_core/src/proxy/config/mod.rs +++ b/crates/token_proxy_core/src/proxy/config/mod.rs @@ -5,6 +5,7 @@ mod normalize; mod types; use crate::paths::TokenProxyPaths; +use std::time::Duration; const DEFAULT_MAX_REQUEST_BODY_BYTES: u64 = 20 * 1024 * 1024; @@ -67,6 +68,7 @@ fn build_runtime_config(config: ProxyConfigFile) -> Result local_api_key: config.local_api_key, log_level, max_request_body_bytes, + retryable_failure_cooldown: Duration::from_secs(config.retryable_failure_cooldown_secs), upstream_strategy: config.upstream_strategy, upstreams, kiro_preferred_endpoint: config.kiro_preferred_endpoint, diff --git a/crates/token_proxy_core/src/proxy/config/types.rs b/crates/token_proxy_core/src/proxy/config/types.rs index 42d57b4..f536182 100644 --- a/crates/token_proxy_core/src/proxy/config/types.rs +++ b/crates/token_proxy_core/src/proxy/config/types.rs @@ -30,6 +30,14 @@ fn default_log_level() -> LogLevel { LogLevel::Silent } +fn default_retryable_failure_cooldown_secs() -> u64 { + 15 +} + +fn is_default_retryable_failure_cooldown_secs(value: &u64) -> bool { + *value == default_retryable_failure_cooldown_secs() +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum InboundApiFormat { @@ -182,6 +190,11 @@ pub struct ProxyConfigFile { pub log_level: LogLevel, #[serde(skip_serializing_if = "Option::is_none")] pub max_request_body_bytes: Option, + #[serde( + default = "default_retryable_failure_cooldown_secs", + skip_serializing_if = "is_default_retryable_failure_cooldown_secs" + )] + pub retryable_failure_cooldown_secs: u64, #[serde(default)] pub tray_token_rate: TrayTokenRateConfig, #[serde(default)] @@ -204,6 +217,7 @@ impl Default for ProxyConfigFile { antigravity_user_agent: None, log_level: LogLevel::default(), max_request_body_bytes: None, + retryable_failure_cooldown_secs: default_retryable_failure_cooldown_secs(), tray_token_rate: TrayTokenRateConfig::default(), upstream_strategy: UpstreamStrategy::PriorityFillFirst, upstreams: Vec::new(), @@ -218,6 +232,7 @@ pub struct ProxyConfig { pub local_api_key: Option, pub log_level: LogLevel, pub max_request_body_bytes: usize, + pub retryable_failure_cooldown: std::time::Duration, pub upstream_strategy: UpstreamStrategy, pub upstreams: HashMap, pub kiro_preferred_endpoint: Option, diff --git a/crates/token_proxy_core/src/proxy/config/types.test.rs b/crates/token_proxy_core/src/proxy/config/types.test.rs index 3630fe2..9b951cd 100644 --- a/crates/token_proxy_core/src/proxy/config/types.test.rs +++ b/crates/token_proxy_core/src/proxy/config/types.test.rs @@ -158,3 +158,10 @@ fn test_upstream_url() { "https://api.example.com/openai/v1/messages" ); } + +#[test] +fn proxy_config_file_defaults_retryable_failure_cooldown_to_15_seconds() { + let config = ProxyConfigFile::default(); + + assert_eq!(config.retryable_failure_cooldown_secs, 15); +} diff --git a/crates/token_proxy_core/src/proxy/http.test.rs b/crates/token_proxy_core/src/proxy/http.test.rs index 2a873c4..dfdc4f1 100644 --- a/crates/token_proxy_core/src/proxy/http.test.rs +++ b/crates/token_proxy_core/src/proxy/http.test.rs @@ -10,6 +10,7 @@ fn config_with_local(key: &str) -> ProxyConfig { local_api_key: Some(key.to_string()), log_level: LogLevel::Silent, max_request_body_bytes: 1024, + retryable_failure_cooldown: std::time::Duration::from_secs(15), upstream_strategy: crate::proxy::config::UpstreamStrategy::PriorityFillFirst, upstreams: HashMap::new(), kiro_preferred_endpoint: None, @@ -24,6 +25,7 @@ fn config_without_local() -> ProxyConfig { local_api_key: None, log_level: LogLevel::Silent, max_request_body_bytes: 1024, + retryable_failure_cooldown: std::time::Duration::from_secs(15), upstream_strategy: crate::proxy::config::UpstreamStrategy::PriorityFillFirst, upstreams: HashMap::new(), kiro_preferred_endpoint: None, diff --git a/crates/token_proxy_core/src/proxy/server.test.rs b/crates/token_proxy_core/src/proxy/server.test.rs index 1a1748e..f24566e 100644 --- a/crates/token_proxy_core/src/proxy/server.test.rs +++ b/crates/token_proxy_core/src/proxy/server.test.rs @@ -123,6 +123,7 @@ fn config_with_runtime_upstreams( local_api_key: None, log_level: LogLevel::Silent, max_request_body_bytes: 20 * 1024 * 1024, + retryable_failure_cooldown: std::time::Duration::from_secs(15), upstream_strategy: UpstreamStrategy::PriorityRoundRobin, upstreams: provider_map, kiro_preferred_endpoint: None, @@ -263,12 +264,16 @@ async fn build_test_state_handle(config: ProxyConfig, data_dir: PathBuf) -> Prox } } } + let retryable_failure_cooldown = config.retryable_failure_cooldown; let state = Arc::new(ProxyState { config, http_clients: super::super::http_client::ProxyHttpClients::new().expect("http clients"), log: Arc::new(super::super::log::LogWriter::new(None)), cursors, - upstream_selector: super::super::upstream_selector::UpstreamSelectorRuntime::new(), + upstream_selector: + super::super::upstream_selector::UpstreamSelectorRuntime::new_with_cooldown( + retryable_failure_cooldown, + ), request_detail: Arc::new(super::super::request_detail::RequestDetailCapture::new( None, )), @@ -416,6 +421,48 @@ fn responses_request_falls_back_from_403_to_codex() { )); } +#[test] +fn responses_request_falls_back_from_401_to_codex() { + run_async(assert_responses_retry_fallback_status( + StatusCode::UNAUTHORIZED, + )); +} + +#[test] +fn responses_request_falls_back_from_404_to_codex() { + run_async(assert_responses_retry_fallback_status( + StatusCode::NOT_FOUND, + )); +} + +#[test] +fn responses_request_falls_back_from_408_to_codex() { + run_async(assert_responses_retry_fallback_status( + StatusCode::REQUEST_TIMEOUT, + )); +} + +#[test] +fn responses_request_falls_back_from_422_to_codex() { + run_async(assert_responses_retry_fallback_status( + StatusCode::UNPROCESSABLE_ENTITY, + )); +} + +#[test] +fn responses_request_falls_back_from_504_to_codex() { + run_async(assert_responses_retry_fallback_status( + StatusCode::GATEWAY_TIMEOUT, + )); +} + +#[test] +fn responses_request_falls_back_from_524_to_codex() { + run_async(assert_responses_retry_fallback_status( + StatusCode::from_u16(524).expect("524"), + )); +} + #[test] fn responses_request_skips_recently_failed_same_provider_upstream() { run_async(async { @@ -500,6 +547,90 @@ fn responses_request_skips_recently_failed_same_provider_upstream() { }); } +#[test] +fn responses_request_cooldowns_same_provider_upstream_after_401() { + run_async(async { + let primary = spawn_mock_upstream( + StatusCode::UNAUTHORIZED, + json!({ + "error": { "message": "primary unauthorized" } + }), + ) + .await; + let secondary = spawn_mock_upstream( + StatusCode::OK, + json!({ + "id": "resp_from_secondary", + "object": "response", + "created_at": 123, + "model": "gpt-5", + "status": "completed", + "output": [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [ + { "type": "output_text", "text": "from secondary" } + ] + } + ], + "usage": { "input_tokens": 1, "output_tokens": 2, "total_tokens": 3 } + }), + ) + .await; + + let mut config = config_with_runtime_upstreams(&[ + ( + PROVIDER_RESPONSES, + 10, + "responses-primary", + primary.base_url.as_str(), + FORMATS_RESPONSES, + ), + ( + PROVIDER_RESPONSES, + 10, + "responses-secondary", + secondary.base_url.as_str(), + FORMATS_RESPONSES, + ), + ]); + config.upstream_strategy = UpstreamStrategy::PriorityFillFirst; + + let data_dir = next_test_data_dir("responses_same_provider_cooldown_401"); + let state = build_test_state_handle(config, data_dir.clone()).await; + + let (first_status, first_json) = send_responses_request(state.clone()).await; + let (second_status, second_json) = send_responses_request(state).await; + + let primary_requests = primary.requests(); + let secondary_requests = secondary.requests(); + + primary.abort(); + secondary.abort(); + let _ = std::fs::remove_dir_all(&data_dir); + + assert_eq!(first_status, StatusCode::OK); + assert_eq!(second_status, StatusCode::OK); + assert_eq!( + first_json["output"][0]["content"][0]["text"].as_str(), + Some("from secondary") + ); + assert_eq!( + second_json["output"][0]["content"][0]["text"].as_str(), + Some("from secondary") + ); + assert_eq!( + primary_requests.len(), + 1, + "401 should cool down the upstream to avoid repeatedly hitting the same invalid account" + ); + assert_eq!(secondary_requests.len(), 2); + }); +} + #[test] fn responses_request_does_not_cooldown_same_provider_upstream_after_400() { run_async(async { @@ -584,6 +715,104 @@ fn responses_request_does_not_cooldown_same_provider_upstream_after_400() { }); } +#[test] +fn responses_request_reload_resets_existing_cooldown_and_applies_new_duration() { + run_async(async { + let primary = spawn_mock_upstream( + StatusCode::UNAUTHORIZED, + json!({ + "error": { "message": "primary unauthorized" } + }), + ) + .await; + let secondary = spawn_mock_upstream( + StatusCode::OK, + json!({ + "id": "resp_from_secondary", + "object": "response", + "created_at": 123, + "model": "gpt-5", + "status": "completed", + "output": [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [ + { "type": "output_text", "text": "from secondary" } + ] + } + ], + "usage": { "input_tokens": 1, "output_tokens": 2, "total_tokens": 3 } + }), + ) + .await; + + let mut config = config_with_runtime_upstreams(&[ + ( + PROVIDER_RESPONSES, + 10, + "responses-primary", + primary.base_url.as_str(), + FORMATS_RESPONSES, + ), + ( + PROVIDER_RESPONSES, + 10, + "responses-secondary", + secondary.base_url.as_str(), + FORMATS_RESPONSES, + ), + ]); + config.upstream_strategy = UpstreamStrategy::PriorityFillFirst; + config.retryable_failure_cooldown = std::time::Duration::from_secs(15); + + let data_dir = next_test_data_dir("responses_same_provider_reload_resets_cooldown"); + let state = build_test_state_handle(config.clone(), data_dir.clone()).await; + + let _ = send_responses_request(state.clone()).await; + let _ = send_responses_request(state.clone()).await; + + let primary_requests_before_reload = primary.requests(); + assert_eq!( + primary_requests_before_reload.len(), + 1, + "pre-reload second request should skip cooled-down upstream" + ); + + let mut reloaded_config = config; + reloaded_config.retryable_failure_cooldown = std::time::Duration::ZERO; + let reloaded_state_handle = + build_test_state_handle(reloaded_config, data_dir.clone()).await; + let reloaded_state = { + let guard = reloaded_state_handle.read().await; + guard.clone() + }; + { + let mut guard = state.write().await; + *guard = reloaded_state; + } + + let _ = send_responses_request(state.clone()).await; + let _ = send_responses_request(state).await; + + let primary_requests = primary.requests(); + let secondary_requests = secondary.requests(); + + primary.abort(); + secondary.abort(); + let _ = std::fs::remove_dir_all(&data_dir); + + assert_eq!( + primary_requests.len(), + 3, + "reload should clear old cooldowns, and zero cooldown should allow primary to be retried on every later request" + ); + assert_eq!(secondary_requests.len(), 4); + }); +} + #[test] fn chat_fallback_requires_format_conversion_enabled() { let config = config_with_providers(&[(PROVIDER_RESPONSES, FORMATS_RESPONSES)]); diff --git a/crates/token_proxy_core/src/proxy/service.rs b/crates/token_proxy_core/src/proxy/service.rs index cc7c1fc..7d8732e 100644 --- a/crates/token_proxy_core/src/proxy/service.rs +++ b/crates/token_proxy_core/src/proxy/service.rs @@ -365,11 +365,13 @@ async fn build_proxy_state( let codex_accounts = ctx.codex_accounts.clone(); let antigravity_accounts = ctx.antigravity_accounts.clone(); Ok(Arc::new(ProxyState { + upstream_selector: super::upstream_selector::UpstreamSelectorRuntime::new_with_cooldown( + config.retryable_failure_cooldown, + ), config, http_clients, log, cursors, - upstream_selector: super::upstream_selector::UpstreamSelectorRuntime::new(), request_detail, token_rate, kiro_accounts, diff --git a/crates/token_proxy_core/src/proxy/upstream.test.rs b/crates/token_proxy_core/src/proxy/upstream.test.rs index af7492d..ed68e1b 100644 --- a/crates/token_proxy_core/src/proxy/upstream.test.rs +++ b/crates/token_proxy_core/src/proxy/upstream.test.rs @@ -8,14 +8,49 @@ fn retryable_status_matches_proxy_policy() { assert!(is_retryable_status(StatusCode::TOO_MANY_REQUESTS)); assert!(is_retryable_status(StatusCode::TEMPORARY_REDIRECT)); assert!(is_retryable_status(StatusCode::INTERNAL_SERVER_ERROR)); + assert!(is_retryable_status(StatusCode::UNAUTHORIZED)); + assert!(is_retryable_status(StatusCode::NOT_FOUND)); + assert!(is_retryable_status(StatusCode::REQUEST_TIMEOUT)); + assert!(is_retryable_status(StatusCode::UNPROCESSABLE_ENTITY)); + assert!(is_retryable_status(StatusCode::GATEWAY_TIMEOUT)); + assert!(is_retryable_status( + StatusCode::from_u16(524).expect("524") + )); +} - // Exclude 504/524 timeouts from retries. - assert!(!is_retryable_status(StatusCode::GATEWAY_TIMEOUT)); - assert!(!is_retryable_status( +#[test] +fn cooldown_status_matches_proxy_policy() { + assert!(result::should_cooldown_retryable_status( + StatusCode::UNAUTHORIZED + )); + assert!(result::should_cooldown_retryable_status( + StatusCode::FORBIDDEN + )); + assert!(result::should_cooldown_retryable_status( + StatusCode::REQUEST_TIMEOUT + )); + assert!(result::should_cooldown_retryable_status( + StatusCode::TOO_MANY_REQUESTS + )); + assert!(result::should_cooldown_retryable_status( + StatusCode::GATEWAY_TIMEOUT + )); + assert!(result::should_cooldown_retryable_status( StatusCode::from_u16(524).expect("524") )); - assert!(!is_retryable_status(StatusCode::UNAUTHORIZED)); + assert!(!result::should_cooldown_retryable_status( + StatusCode::BAD_REQUEST + )); + assert!(!result::should_cooldown_retryable_status( + StatusCode::NOT_FOUND + )); + assert!(!result::should_cooldown_retryable_status( + StatusCode::UNPROCESSABLE_ENTITY + )); + assert!(!result::should_cooldown_retryable_status( + StatusCode::TEMPORARY_REDIRECT + )); } #[test] diff --git a/crates/token_proxy_core/src/proxy/upstream/result.rs b/crates/token_proxy_core/src/proxy/upstream/result.rs index 3da4bbb..73ed666 100644 --- a/crates/token_proxy_core/src/proxy/upstream/result.rs +++ b/crates/token_proxy_core/src/proxy/upstream/result.rs @@ -13,6 +13,18 @@ use crate::proxy::response::{build_proxy_response, build_proxy_response_buffered use crate::proxy::token_rate::TokenRateTracker; use crate::proxy::RequestMeta; +pub(super) fn should_cooldown_retryable_status(status: StatusCode) -> bool { + // cooldown 只用于“更像上游账号/节点短时异常”的错误,避免把请求内容问题扩散到后续请求。 + // 因此 400/404/422/307 虽然可在当前请求内换路重试,但不会跨请求冷却整个 upstream。 + matches!( + status, + StatusCode::UNAUTHORIZED + | StatusCode::FORBIDDEN + | StatusCode::REQUEST_TIMEOUT + | StatusCode::TOO_MANY_REQUESTS + ) || status.is_server_error() +} + pub(super) async fn handle_upstream_result( upstream_res: Result, meta: &RequestMeta, @@ -45,9 +57,7 @@ pub(super) async fn handle_upstream_result( message: format!("Upstream responded with {}", response.status()), response: Some(response), is_timeout: false, - should_cooldown: status == StatusCode::FORBIDDEN - || status == StatusCode::TOO_MANY_REQUESTS - || status.is_server_error(), + should_cooldown: should_cooldown_retryable_status(status), } } Ok(res) => { diff --git a/crates/token_proxy_core/src/proxy/upstream/utils.rs b/crates/token_proxy_core/src/proxy/upstream/utils.rs index d375fec..7369b46 100644 --- a/crates/token_proxy_core/src/proxy/upstream/utils.rs +++ b/crates/token_proxy_core/src/proxy/upstream/utils.rs @@ -63,20 +63,19 @@ pub(super) fn is_retryable_error(err: &reqwest::Error) -> bool { } pub(super) fn is_retryable_status(status: StatusCode) -> bool { - // 基于 new-api 的重试策略:400/429/307/5xx(排除 504/524);额外允许 403 触发 fallback。 - if status == StatusCode::BAD_REQUEST - || status == StatusCode::FORBIDDEN - || status == StatusCode::TOO_MANY_REQUESTS - || status == StatusCode::TEMPORARY_REDIRECT - { - return true; - } - if status == StatusCode::GATEWAY_TIMEOUT { - return false; - } - if status.as_u16() == 524 { - // Cloudflare timeout. - return false; - } - status.is_server_error() + // 为了尽量提供“无反馈”的自动切换体验,以下错误都允许继续尝试下一个渠道: + // - 显式可回退的鉴权/路由/请求超时/语义校验错误:401/404/408/422 + // - 配额/权限/重定向:400/403/429/307 + // - 所有 5xx,包括 504 与 Cloudflare 524。 + matches!( + status, + StatusCode::BAD_REQUEST + | StatusCode::UNAUTHORIZED + | StatusCode::FORBIDDEN + | StatusCode::NOT_FOUND + | StatusCode::REQUEST_TIMEOUT + | StatusCode::UNPROCESSABLE_ENTITY + | StatusCode::TOO_MANY_REQUESTS + | StatusCode::TEMPORARY_REDIRECT + ) || status.is_server_error() } diff --git a/crates/token_proxy_core/src/proxy/upstream_selector.rs b/crates/token_proxy_core/src/proxy/upstream_selector.rs index 4f75c7b..47e8e02 100644 --- a/crates/token_proxy_core/src/proxy/upstream_selector.rs +++ b/crates/token_proxy_core/src/proxy/upstream_selector.rs @@ -6,8 +6,6 @@ use std::{ use super::{config::UpstreamRuntime, config::UpstreamStrategy}; -const RETRYABLE_FAILURE_COOLDOWN: Duration = Duration::from_secs(15); - #[derive(Hash, PartialEq, Eq)] struct CooldownKey { provider: String, @@ -23,14 +21,17 @@ impl CooldownKey { } } -#[derive(Default)] pub(crate) struct UpstreamSelectorRuntime { + retryable_failure_cooldown: Duration, cooldowns: Mutex>, } impl UpstreamSelectorRuntime { - pub(crate) fn new() -> Self { - Self::default() + pub(crate) fn new_with_cooldown(retryable_failure_cooldown: Duration) -> Self { + Self { + retryable_failure_cooldown, + cooldowns: Mutex::new(HashMap::new()), + } } pub(crate) fn order_group( @@ -53,7 +54,7 @@ impl UpstreamSelectorRuntime { self.mark_cooldown_until( provider, upstream_id, - Instant::now() + RETRYABLE_FAILURE_COOLDOWN, + Instant::now() + self.retryable_failure_cooldown, ); } diff --git a/crates/token_proxy_core/src/proxy/upstream_selector.test.rs b/crates/token_proxy_core/src/proxy/upstream_selector.test.rs index 3ce57b6..7e5cd87 100644 --- a/crates/token_proxy_core/src/proxy/upstream_selector.test.rs +++ b/crates/token_proxy_core/src/proxy/upstream_selector.test.rs @@ -22,7 +22,7 @@ fn runtime(id: &str) -> UpstreamRuntime { #[test] fn cooled_upstream_moves_behind_ready_candidates() { - let selector = UpstreamSelectorRuntime::new(); + let selector = UpstreamSelectorRuntime::new_with_cooldown(Duration::from_secs(15)); let items = vec![runtime("a"), runtime("b"), runtime("c")]; selector.mark_cooldown_until("responses", "a", Instant::now() + Duration::from_secs(10)); @@ -34,7 +34,7 @@ fn cooled_upstream_moves_behind_ready_candidates() { #[test] fn all_cooled_upstreams_probe_earliest_expiry_first() { - let selector = UpstreamSelectorRuntime::new(); + let selector = UpstreamSelectorRuntime::new_with_cooldown(Duration::from_secs(15)); let items = vec![runtime("a"), runtime("b"), runtime("c")]; selector.mark_cooldown_until("responses", "a", Instant::now() + Duration::from_secs(30)); @@ -48,7 +48,7 @@ fn all_cooled_upstreams_probe_earliest_expiry_first() { #[test] fn clear_cooldown_restores_base_order() { - let selector = UpstreamSelectorRuntime::new(); + let selector = UpstreamSelectorRuntime::new_with_cooldown(Duration::from_secs(15)); let items = vec![runtime("a"), runtime("b")]; selector.mark_cooldown_until("responses", "a", Instant::now() + Duration::from_secs(10)); @@ -58,3 +58,15 @@ fn clear_cooldown_restores_base_order() { assert_eq!(order, vec![0, 1]); } + +#[test] +fn zero_retryable_failure_cooldown_disables_cross_request_cooling() { + let selector = UpstreamSelectorRuntime::new_with_cooldown(Duration::ZERO); + let items = vec![runtime("a"), runtime("b")]; + + selector.mark_retryable_failure("responses", "a"); + + let order = selector.order_group(UpstreamStrategy::PriorityFillFirst, "responses", &items, 0); + + assert_eq!(order, vec![0, 1]); +} From 054b591d0b73d050ce22976ec9bcec308d91739b Mon Sep 17 00:00:00 2001 From: mxyhi Date: Sun, 8 Mar 2026 10:19:47 +0800 Subject: [PATCH 2/7] feat(config): expose retry cooldown setting --- README.md | 4 ++- README.zh-CN.md | 4 ++- messages/en.json | 3 ++ messages/zh.json | 3 ++ src-tauri/tauri.conf.dev.json | 2 +- src/features/config/cards/proxy-core-card.tsx | 17 +++++++++++ src/features/config/form.test.ts | 17 +++++++++++ src/features/config/form.ts | 30 +++++++++++++++++++ src/features/config/types.ts | 2 ++ 9 files changed, 79 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6839e0a..acc087d 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Notes: | `app_proxy_url` | `null` | Proxy for app updater & as placeholder for upstreams (`"$app_proxy_url"`). Supports `http/https/socks5/socks5h`. | | `log_level` | `silent` | `silent|error|warn|info|debug|trace`; debug/trace log request headers (auth redacted) and small bodies (≤64KiB). Release builds force `silent`. | | `max_request_body_bytes` | `20971520` (20 MiB) | 0 = fallback to default. Protects inbound body size. | +| `retryable_failure_cooldown_secs` | `15` | Cooldown window after retryable failures that should temporarily sideline an upstream. `0` disables cooldown. Saving config resets current cooldown state. | | `tray_token_rate.enabled` | `true` | macOS tray live rate; harmless elsewhere. | | `tray_token_rate.format` | `split` | `combined` (`total`), `split` (`↑in ↓out`), `both` (`total | ↑in ↓out`). | | `upstream_strategy` | `priority_fill_first` | `priority_fill_first` (default) keeps trying the highest-priority group in list order; `priority_round_robin` rotates within each priority group. | @@ -147,7 +148,8 @@ Notes: ## Load balancing & retries - Priorities: higher `priority` groups first; inside a group use list order (fill-first) or round-robin (if `priority_round_robin`). -- Retryable conditions: network timeout/connect errors, or status 400/403/429/307/5xx **except** 504/524. Retries stay within the same provider's priority groups. +- Retryable conditions: network timeout/connect errors, or status 400/401/403/404/408/422/429/307/5xx (including 504/524). Retries stay within the same provider's priority groups. +- Cooldown conditions: `401/403/408/429/5xx` will temporarily move the failed upstream behind ready peers for `retryable_failure_cooldown_secs` (default `15`); `400/404/422/307` stay retryable but do not trigger cross-request cooldown. - `/v1/messages` only: after the chosen native provider is exhausted (retryable errors), the proxy can fall back to the other native provider (`anthropic` ↔ `kiro`) if it is configured. ## Observability diff --git a/README.zh-CN.md b/README.zh-CN.md index 893afdb..adadd00 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -100,6 +100,7 @@ pnpm exec tsc --noEmit | `app_proxy_url` | `null` | 应用更新 & 上游可复用的代理;支持 `http/https/socks5/socks5h`;可在 upstream `proxy_url` 用 `"$app_proxy_url"` 占位 | | `log_level` | `silent` | `silent|error|warn|info|debug|trace`;debug/trace 会记录请求头(鉴权打码)与小体积请求体(≤64KiB);release 强制 `silent` | | `max_request_body_bytes` | `20971520` (20 MiB) | 0 表示回落到默认;保护入站体积 | +| `retryable_failure_cooldown_secs` | `15` | 对适合短时降级的可重试失败施加冷却窗口;`0` 表示关闭冷却。保存配置后会重置当前冷却状态 | | `tray_token_rate.enabled` | `true` | macOS 托盘实时速率;其他平台无害 | | `tray_token_rate.format` | `split` | `combined`(总数) / `split`(↑入 ↓出) / `both`(总数 | ↑入 ↓出) | | `upstream_strategy` | `priority_fill_first` | `priority_fill_first` 默认先填满高优先级;`priority_round_robin` 在同组内轮询 | @@ -147,7 +148,8 @@ pnpm exec tsc --noEmit ## 负载均衡与重试 - 优先级:高优先级组先尝试;组内按列表顺序(fill-first)或轮询(round-robin) -- 可重试条件:网络超时/连接错误,或状态码 400/403/429/307/5xx(排除 504/524);重试只在同一 provider 的优先级组内进行 +- 可重试条件:网络超时/连接错误,或状态码 400/401/403/404/408/422/429/307/5xx(包含 504/524);重试只在同一 provider 的优先级组内进行 +- 冷却条件:`401/403/408/429/5xx` 会让失败 upstream 在 `retryable_failure_cooldown_secs`(默认 `15`)内被暂时后置;`400/404/422/307` 仍可重试,但不会触发跨请求冷却 - 仅 `/v1/messages`:当命中的 native provider(`anthropic`/`kiro`)被耗尽(仍是可重试错误)时,若另一个 native provider 已配置,会自动 fallback(Anthropic ↔ Kiro) ## 可观测性 diff --git a/messages/en.json b/messages/en.json index 5674646..e0412f2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -54,6 +54,8 @@ "proxy_core_app_proxy_url_help": "Used by the app and optional upstream proxy reuse. Supports http(s)://, socks5://, socks5h://. Upstreams can use {placeholder} to reference this proxy. Leave empty to skip proxy.", "proxy_core_kiro_preferred_endpoint_label": "Kiro preferred endpoint", "proxy_core_kiro_preferred_endpoint_help": "Sets the default Kiro endpoint (IDE = CodeWhisperer, CLI = Amazon Q).", + "proxy_core_retryable_failure_cooldown_secs_label": "Retryable failure cooldown (seconds)", + "proxy_core_retryable_failure_cooldown_secs_help": "Default is 15. Applies cross-request cooldown to 401/403/408/429/5xx; saving config resets the current cooldown state. Use 0 to disable cooldown.", "proxy_core_format_conversion_title": "Format Conversion", "proxy_core_format_conversion_desc": "Auto-convert OpenAI/Anthropic/Gemini formats when the preferred provider is missing.", "proxy_core_format_conversion_aria": "Enable format conversion", @@ -418,6 +420,7 @@ "logs_detail_response": "Error response", "error_host_required": "Host is required.", "error_port_range": "Port must be between 1 and 65535.", + "error_retryable_failure_cooldown_secs_integer": "Retryable failure cooldown must be a non-negative integer.", "error_upstream_at_least_one": "At least one upstream is required.", "error_upstream_at_least_one_enabled": "At least one enabled upstream is required.", "error_upstream_id_required": "Upstream id is required.", diff --git a/messages/zh.json b/messages/zh.json index 8c78423..00af02b 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -54,6 +54,8 @@ "proxy_core_app_proxy_url_help": "用于应用使用与上游代理复用。支持 http(s)://、socks5://、socks5h://。上游可填写 {placeholder} 引用应用代理;留空不使用代理。", "proxy_core_kiro_preferred_endpoint_label": "Kiro 默认端点", "proxy_core_kiro_preferred_endpoint_help": "设置默认 Kiro 端点(IDE=CodeWhisperer,CLI=Amazon Q)。", + "proxy_core_retryable_failure_cooldown_secs_label": "可重试失败冷却(秒)", + "proxy_core_retryable_failure_cooldown_secs_help": "默认 15。对 401/403/408/429/5xx 触发跨请求冷却;保存配置后会重置当前冷却状态。填 0 可关闭冷却。", "proxy_core_format_conversion_title": "格式转换", "proxy_core_format_conversion_desc": "当首选提供商缺失时,自动转换 OpenAI/Anthropic/Gemini 格式。", "proxy_core_format_conversion_aria": "启用格式转换", @@ -419,6 +421,7 @@ "logs_detail_response": "错误响应", "error_host_required": "Host 为必填。", "error_port_range": "端口必须在 1 到 65535 之间。", + "error_retryable_failure_cooldown_secs_integer": "可重试失败冷却必须是大于等于 0 的整数。", "error_upstream_at_least_one": "至少需要一个上游。", "error_upstream_at_least_one_enabled": "至少需要一个启用的上游。", "error_upstream_id_required": "上游 ID 为必填。", diff --git a/src-tauri/tauri.conf.dev.json b/src-tauri/tauri.conf.dev.json index 9a5e0fc..89a7678 100644 --- a/src-tauri/tauri.conf.dev.json +++ b/src-tauri/tauri.conf.dev.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Token Proxy (dev)", - "version": "0.1.36", + "version": "0.1.48", "identifier": "com.mxyhi.token-proxy.dev", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/features/config/cards/proxy-core-card.tsx b/src/features/config/cards/proxy-core-card.tsx index 2c26e8c..1909c9c 100644 --- a/src/features/config/cards/proxy-core-card.tsx +++ b/src/features/config/cards/proxy-core-card.tsx @@ -118,6 +118,23 @@ function ProxyCoreFields({ {m.proxy_core_kiro_preferred_endpoint_help()}

+
+ + + onChange({ retryableFailureCooldownSecs: event.target.value }) + } + placeholder="15" + inputMode="numeric" + /> +

+ {m.proxy_core_retryable_failure_cooldown_secs_help()} +

+
); } diff --git a/src/features/config/form.test.ts b/src/features/config/form.test.ts index 47e309f..20c3de8 100644 --- a/src/features/config/form.test.ts +++ b/src/features/config/form.test.ts @@ -20,6 +20,13 @@ describe("config/form", () => { expect(validate({ ...EMPTY_FORM, port: "9208" }).valid).toBe(true); }); + it("validates retryable failure cooldown as non-negative integer", () => { + expect(validate({ ...EMPTY_FORM, retryableFailureCooldownSecs: "-1" }).valid).toBe(false); + expect(validate({ ...EMPTY_FORM, retryableFailureCooldownSecs: "" }).valid).toBe(false); + expect(validate({ ...EMPTY_FORM, retryableFailureCooldownSecs: "0" }).valid).toBe(true); + expect(validate({ ...EMPTY_FORM, retryableFailureCooldownSecs: "15" }).valid).toBe(true); + }); + it("requires upstream id for enabled upstreams", () => { const upstream = createEmptyUpstream(); const result = validate({ ...EMPTY_FORM, upstreams: [upstream] }); @@ -82,6 +89,7 @@ describe("config/form", () => { expect(payload.host).toBe("127.0.0.1"); expect(payload.local_api_key).toBeNull(); + expect(payload.retryable_failure_cooldown_secs).toBe(15); expect(payload.upstreams[0]?.id).toBe("upstream-1"); expect(payload.upstreams[0]?.providers).toEqual(["openai", "openai-response"]); expect(payload.upstreams[0]?.base_url).toBe("https://example.com"); @@ -89,4 +97,13 @@ describe("config/form", () => { // openai_chat 对 openai 是 native 格式,应被清理;unknown provider 也应被丢弃。 expect(payload.upstreams[0]?.convert_from_map).toBeUndefined(); }); + + it("serializes retryable failure cooldown seconds", () => { + const payload = toPayload({ + ...EMPTY_FORM, + retryableFailureCooldownSecs: "30", + }); + + expect(payload.retryable_failure_cooldown_secs).toBe(30); + }); }); diff --git a/src/features/config/form.ts b/src/features/config/form.ts index a30bcab..126bb36 100644 --- a/src/features/config/form.ts +++ b/src/features/config/form.ts @@ -18,6 +18,7 @@ const DEFAULT_TRAY_TOKEN_RATE: TrayTokenRateConfig = { }; const INTEGER_PATTERN = /^-?\d+$/; +const NON_NEGATIVE_INTEGER_PATTERN = /^\d+$/; let modelMappingCounter = 0; const TRAY_TOKEN_RATE_FORMAT_VALUES: ReadonlySet = new Set( @@ -58,6 +59,7 @@ const KNOWN_CONFIG_KEYS: ReadonlySet = new Set([ "antigravity_process_names", "antigravity_user_agent", "log_level", + "retryable_failure_cooldown_secs", "tray_token_rate", "upstream_strategy", "upstreams", @@ -74,6 +76,7 @@ export const EMPTY_FORM: ConfigForm = { antigravityProcessNames: "", antigravityUserAgent: "", logLevel: "silent", + retryableFailureCooldownSecs: "15", trayTokenRate: { ...DEFAULT_TRAY_TOKEN_RATE }, upstreamStrategy: "priority_fill_first", upstreams: [], @@ -143,6 +146,7 @@ export function toForm(config: ProxyConfigFile): ConfigForm { antigravityProcessNames: joinListInput(config.antigravity_process_names), antigravityUserAgent: config.antigravity_user_agent ?? "", logLevel: config.log_level ?? "silent", + retryableFailureCooldownSecs: String(config.retryable_failure_cooldown_secs ?? 15), trayTokenRate: normalizeTrayTokenRate(config.tray_token_rate), upstreamStrategy: config.upstream_strategy, upstreams: config.upstreams.map((upstream) => ({ @@ -183,6 +187,9 @@ export function toPayload(form: ConfigForm): ProxyConfigFile { ? form.antigravityUserAgent.trim() : null, log_level: form.logLevel, + retryable_failure_cooldown_secs: parseRetryableFailureCooldownSecs( + form.retryableFailureCooldownSecs, + ), tray_token_rate: form.trayTokenRate, upstream_strategy: form.upstreamStrategy, upstreams: form.upstreams.map((upstream) => { @@ -226,6 +233,12 @@ export function validate(form: ConfigForm) { if (form.appProxyUrl.trim() && !isValidProxyUrl(form.appProxyUrl.trim())) { return { valid: false, message: m.error_app_proxy_url_invalid() }; } + if (!isValidRetryableFailureCooldownSecs(form.retryableFailureCooldownSecs)) { + return { + valid: false, + message: m.error_retryable_failure_cooldown_secs_integer(), + }; + } const ids = new Set(); for (const upstream of form.upstreams) { @@ -509,3 +522,20 @@ function parseOptionalInt(value: string) { const number = Number.parseInt(trimmed, 10); return Number.isFinite(number) ? number : null; } + +function isValidRetryableFailureCooldownSecs(value: string) { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + return NON_NEGATIVE_INTEGER_PATTERN.test(trimmed); +} + +function parseRetryableFailureCooldownSecs(value: string) { + const trimmed = value.trim(); + if (!NON_NEGATIVE_INTEGER_PATTERN.test(trimmed)) { + return 15; + } + const number = Number.parseInt(trimmed, 10); + return Number.isFinite(number) ? number : 15; +} diff --git a/src/features/config/types.ts b/src/features/config/types.ts index 84cd1bc..9f1be09 100644 --- a/src/features/config/types.ts +++ b/src/features/config/types.ts @@ -84,6 +84,7 @@ export type ProxyConfigFileBase = { antigravity_process_names?: string[]; antigravity_user_agent?: string | null; log_level?: LogLevel; + retryable_failure_cooldown_secs?: number; tray_token_rate: TrayTokenRateConfig; upstream_strategy: UpstreamStrategy; upstreams: UpstreamConfig[]; @@ -151,6 +152,7 @@ export type ConfigForm = { antigravityProcessNames: string; antigravityUserAgent: string; logLevel: LogLevel; + retryableFailureCooldownSecs: string; trayTokenRate: TrayTokenRateConfig; upstreamStrategy: UpstreamStrategy; upstreams: UpstreamForm[]; From 1ed68b3ea12d9ee73947c02a79e943c5680348d3 Mon Sep 17 00:00:00 2001 From: mxyhi Date: Sun, 8 Mar 2026 10:19:59 +0800 Subject: [PATCH 3/7] refactor(config): improve upstreams table readability --- .../config/cards/upstreams/constants.test.ts | 21 +++ .../config/cards/upstreams/constants.ts | 16 +-- .../config/cards/upstreams/table.test.tsx | 109 +++++++++++++++ src/features/config/cards/upstreams/table.tsx | 128 +++++++++++------- 4 files changed, 216 insertions(+), 58 deletions(-) create mode 100644 src/features/config/cards/upstreams/constants.test.ts create mode 100644 src/features/config/cards/upstreams/table.test.tsx diff --git a/src/features/config/cards/upstreams/constants.test.ts b/src/features/config/cards/upstreams/constants.test.ts new file mode 100644 index 0000000..68e6b8e --- /dev/null +++ b/src/features/config/cards/upstreams/constants.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { UPSTREAM_COLUMNS } from "@/features/config/cards/upstreams/constants"; + +describe("upstreams/constants", () => { + it("adjusts id, provider, account, and priority column widths", () => { + const idColumn = UPSTREAM_COLUMNS.find((column) => column.id === "id"); + const providerColumn = UPSTREAM_COLUMNS.find((column) => column.id === "provider"); + const accountColumn = UPSTREAM_COLUMNS.find((column) => column.id === "account"); + const priorityColumn = UPSTREAM_COLUMNS.find((column) => column.id === "priority"); + + expect(idColumn?.headerClassName).toBe("w-[12rem]"); + expect(idColumn?.cellClassName).toBe("w-[12rem] max-w-[12rem]"); + expect(providerColumn?.headerClassName).toBe("w-[10rem]"); + expect(providerColumn?.cellClassName).toBe("w-[10rem] max-w-[10rem]"); + expect(accountColumn?.headerClassName).toBe("w-[7.5rem]"); + expect(accountColumn?.cellClassName).toBe("w-[7.5rem] max-w-[7.5rem]"); + expect(priorityColumn?.headerClassName).toBe("w-[6rem]"); + expect(priorityColumn?.cellClassName).toBe("w-[6rem]"); + }); +}); diff --git a/src/features/config/cards/upstreams/constants.ts b/src/features/config/cards/upstreams/constants.ts index 69811d1..839e124 100644 --- a/src/features/config/cards/upstreams/constants.ts +++ b/src/features/config/cards/upstreams/constants.ts @@ -6,22 +6,22 @@ export const UPSTREAM_COLUMNS: readonly UpstreamColumnDefinition[] = [ id: "id", label: () => m.upstreams_column_id(), defaultVisible: true, - headerClassName: "w-[14rem]", - cellClassName: "w-[14rem] max-w-[14rem]", + headerClassName: "w-[12rem]", + cellClassName: "w-[12rem] max-w-[12rem]", }, { id: "provider", label: () => m.upstreams_column_provider(), defaultVisible: true, - headerClassName: "w-[12rem]", - cellClassName: "w-[12rem] max-w-[12rem]", + headerClassName: "w-[10rem]", + cellClassName: "w-[10rem] max-w-[10rem]", }, { id: "account", label: () => m.upstreams_column_account(), defaultVisible: true, - headerClassName: "w-[14rem]", - cellClassName: "w-[14rem] max-w-[14rem]", + headerClassName: "w-[7.5rem]", + cellClassName: "w-[7.5rem] max-w-[7.5rem]", }, { id: "baseUrl", label: () => m.upstreams_column_base_url(), defaultVisible: false, cellClassName: "min-w-[18rem]" }, { id: "apiKey", label: () => m.upstreams_column_api_key(), defaultVisible: false, cellClassName: "min-w-[18rem]" }, @@ -30,8 +30,8 @@ export const UPSTREAM_COLUMNS: readonly UpstreamColumnDefinition[] = [ id: "priority", label: () => m.upstreams_column_priority(), defaultVisible: true, - headerClassName: "w-[8rem]", - cellClassName: "w-[8rem]", + headerClassName: "w-[6rem]", + cellClassName: "w-[6rem]", }, { id: "status", diff --git a/src/features/config/cards/upstreams/table.test.tsx b/src/features/config/cards/upstreams/table.test.tsx new file mode 100644 index 0000000..059ec86 --- /dev/null +++ b/src/features/config/cards/upstreams/table.test.tsx @@ -0,0 +1,109 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it } from "vitest"; + +import { UPSTREAM_COLUMNS } from "@/features/config/cards/upstreams/constants"; +import { UpstreamsTable } from "@/features/config/cards/upstreams/table"; +import type { UpstreamForm } from "@/features/config/types"; + +const LONG_ID = "codex-account-with-a-very-long-upstream-id-for-tooltip"; +const LONG_EMAIL = "very.long.codex.account.email.for.tooltip@example.com"; + +afterEach(() => { + cleanup(); +}); + +function buildUpstream(): UpstreamForm { + return { + id: LONG_ID, + providers: ["codex"], + baseUrl: "https://api.example.com/v1", + apiKey: "", + filterPromptCacheRetention: false, + filterSafetyIdentifier: false, + kiroAccountId: "", + codexAccountId: "codex-1.json", + antigravityAccountId: "", + preferredEndpoint: "", + proxyUrl: "", + priority: "10", + enabled: true, + modelMappings: [], + convertFromMap: {}, + overrides: { header: [] }, + }; +} + +describe("upstreams/table", () => { + it("shows tooltip for truncated id cells on hover", async () => { + const user = userEvent.setup(); + + render( + undefined} + onCopy={() => undefined} + onToggleEnabled={() => undefined} + onDelete={() => undefined} + /> + ); + + const idCell = screen.getByText(LONG_ID); + await user.hover(idCell); + expect(await screen.findByRole("tooltip")).toHaveTextContent(LONG_ID); + }); + + it("shows tooltip for truncated account cells on hover", async () => { + const user = userEvent.setup(); + + render( + undefined} + onCopy={() => undefined} + onToggleEnabled={() => undefined} + onDelete={() => undefined} + /> + ); + + const accountCell = screen.getByText(LONG_EMAIL); + await user.hover(accountCell); + expect(await screen.findByRole("tooltip")).toHaveTextContent(LONG_EMAIL); + }); +}); diff --git a/src/features/config/cards/upstreams/table.tsx b/src/features/config/cards/upstreams/table.tsx index e79d54e..4129f79 100644 --- a/src/features/config/cards/upstreams/table.tsx +++ b/src/features/config/cards/upstreams/table.tsx @@ -1,3 +1,6 @@ +import type { ReactElement } from "react"; + +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import { Ban, Check, Columns3, Copy, Eye, EyeOff, Pencil, Trash2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -10,6 +13,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { getUpstreamLabel, toMaskedApiKey, @@ -36,11 +40,38 @@ type UpstreamsToolbarProps = { const UPSTREAM_STRATEGY_VALUES: ReadonlySet = new Set( UPSTREAM_STRATEGIES.map((strategy) => strategy.value) ); +const CELL_PLACEHOLDER = "—"; +const TOOLTIP_CONTENT_CLASS = "max-w-[560px] whitespace-pre-wrap break-words"; function toUpstreamStrategy(value: string): UpstreamStrategy | null { return UPSTREAM_STRATEGY_VALUES.has(value) ? (value as UpstreamStrategy) : null; } +type CellTooltipProps = { + content: string; + disabled?: boolean; + children: ReactElement; +}; + +function shouldDisableTooltip(content: string) { + const trimmed = content.trim(); + return trimmed.length === 0 || trimmed === CELL_PLACEHOLDER; +} + +function CellTooltip({ content, disabled, children }: CellTooltipProps) { + if (disabled || shouldDisableTooltip(content)) { + return children; + } + return ( + + {children} + + {content} + + + ); +} + export function UpstreamsToolbar({ apiKeyVisible, showApiKeys, @@ -141,10 +172,13 @@ type CodexAccountMap = Map; type AntigravityAccountMap = Map; function renderTextCell(value: string, placeholder: string) { - return value.trim() ? ( - {value} - ) : ( - {placeholder} + const trimmed = value.trim(); + return ( + + + {trimmed || placeholder} + + ); } @@ -167,58 +201,50 @@ function renderAccountCell( if (provider === "kiro") { const accountId = upstream.kiroAccountId.trim(); if (!accountId) { - return {m.kiro_account_unset()}; + return renderTextCell("", m.kiro_account_unset()); } const account = kiroAccounts.get(accountId); if (!account) { - return {m.kiro_account_missing()}; + return renderTextCell("", m.kiro_account_missing()); } - return {account.account_id}; + return renderTextCell(account.account_id, m.kiro_account_unset()); } if (provider === "codex") { const accountId = upstream.codexAccountId.trim(); if (!accountId) { - return {m.codex_account_unset()}; + return renderTextCell("", m.codex_account_unset()); } const account = codexAccounts.get(accountId); if (!account) { - return {m.codex_account_missing()}; + return renderTextCell("", m.codex_account_missing()); } const label = account.email?.trim() ? account.email : account.account_id; - return {label}; + return renderTextCell(label, m.codex_account_unset()); } if (provider === "antigravity") { const accountId = upstream.antigravityAccountId.trim(); if (!accountId) { - return {m.antigravity_account_unset()}; + return renderTextCell("", m.antigravity_account_unset()); } const account = antigravityAccounts.get(accountId); if (!account) { - return {m.antigravity_account_missing()}; + return renderTextCell("", m.antigravity_account_missing()); } const label = account.email?.trim() ? account.email : account.account_id; - return {label}; + return renderTextCell(label, m.antigravity_account_unset()); } - return ; + return renderTextCell("", CELL_PLACEHOLDER); } function renderApiKeyCell(upstream: UpstreamForm, showApiKeys: boolean) { const value = showApiKeys ? upstream.apiKey : toMaskedApiKey(upstream.apiKey); - return value.trim() ? ( - {value} - ) : ( - {m.common_optional()} - ); + return renderTextCell(value, m.common_optional()); } function renderProxyUrlCell(upstream: UpstreamForm, showApiKeys: boolean) { const rawValue = upstream.proxyUrl; const value = showApiKeys ? rawValue : toMaskedProxyUrl(rawValue); - return value.trim() ? ( - {value} - ) : ( - {m.upstreams_proxy_direct()} - ); + return renderTextCell(value, m.upstreams_proxy_direct()); } function renderUpstreamCell( @@ -364,7 +390,7 @@ function UpstreamsTableRow({ key={column.id} className={["px-3 py-2 align-top", column.cellClassName].filter(Boolean).join(" ")} > -
+
{renderUpstreamCell( column.id, upstream, @@ -449,30 +475,32 @@ export function UpstreamsTable({ }: UpstreamsTableProps) { const sortedUpstreams = sortUpstreamsByPriority(upstreams); return ( -
- - - - {sortedUpstreams.map((entry, displayIndex) => ( - - ))} - -
-
+ +
+ + + + {sortedUpstreams.map((entry, displayIndex) => ( + + ))} + +
+
+
); } From 4993945b229ba6e6bd95f59b295bec0ea52debc1 Mon Sep 17 00:00:00 2001 From: mxyhi Date: Sun, 8 Mar 2026 10:20:16 +0800 Subject: [PATCH 4/7] refactor(config): auto-save config changes --- src/features/config/AppView.test.tsx | 89 +++++++++++ src/features/config/AppView.tsx | 20 +-- src/features/config/ConfigScreen.test.tsx | 176 +++++++++++++++++++++ src/features/config/ConfigScreen.tsx | 30 +++- src/features/config/config-screen-state.ts | 16 +- 5 files changed, 308 insertions(+), 23 deletions(-) create mode 100644 src/features/config/AppView.test.tsx create mode 100644 src/features/config/ConfigScreen.test.tsx diff --git a/src/features/config/AppView.test.tsx b/src/features/config/AppView.test.tsx new file mode 100644 index 0000000..c53a847 --- /dev/null +++ b/src/features/config/AppView.test.tsx @@ -0,0 +1,89 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { AppView } from "@/features/config/AppView"; +import { EMPTY_FORM } from "@/features/config/form"; +import type { ProxyServiceStatus } from "@/features/config/types"; +import { m } from "@/paraglide/messages.js"; + +vi.mock("@/components/app-sidebar", () => ({ + AppSidebar: () =>
, +})); + +vi.mock("@/components/site-header", () => ({ + SiteHeader: ({ title }: { title: string }) =>
{title}
, +})); + +vi.mock("@/features/config/cards", () => ({ + ClientSetupCard: () =>
, + ConfigFileCard: () =>
, + AutoStartCard: () =>
, + ProjectLinksCard: () =>
, + ProxyCoreCard: () =>
, + TrayTokenRateCard: () =>
, + UpdateCard: () =>
, + UpstreamsCard: () =>
, + ValidationCard: () =>
, +})); + +vi.mock("@/features/dashboard/DashboardPanel", () => ({ + DashboardPanel: () =>
, +})); + +vi.mock("@/features/logs/LogsPanel", () => ({ + LogsPanel: () =>
, +})); + +vi.mock("@/features/providers/ProvidersPanel", () => ({ + ProvidersPanel: () =>
, +})); + +const IDLE_PROXY_STATUS: ProxyServiceStatus = { + state: "stopped", + addr: null, + last_error: null, +}; + +describe("config/AppView", () => { + it("removes the manual save button from the config toolbar", () => { + render( + undefined} + onToggleUpstreamKeys={() => undefined} + onFormChange={() => undefined} + onStrategyChange={() => undefined} + onAutoStartChange={() => undefined} + onAddUpstream={() => undefined} + onRemoveUpstream={() => undefined} + onChangeUpstream={() => undefined} + onReload={() => undefined} + onProxyServiceRefresh={() => undefined} + onProxyServiceStart={() => undefined} + onProxyServiceStop={() => undefined} + onProxyServiceRestart={() => undefined} + onProxyServiceReload={() => undefined} + /> + ); + + expect(screen.getByRole("button", { name: m.common_refresh() })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: m.common_save() })).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/config/AppView.tsx b/src/features/config/AppView.tsx index 0adcc93..6d299fe 100644 --- a/src/features/config/AppView.tsx +++ b/src/features/config/AppView.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, Loader2, RefreshCw } from "lucide-react"; +import { AlertCircle, RefreshCw } from "lucide-react"; import { useMemo, type CSSProperties } from "react"; import { AppSidebar } from "@/components/app-sidebar"; @@ -63,7 +63,6 @@ type AppViewProps = { proxyServiceMessage: string; status: "idle" | "loading" | "saving" | "saved" | "error"; statusMessage: string; - canSave: boolean; isDirty: boolean; validation: { valid: boolean; message: string }; onToggleLocalKey: () => void; @@ -77,7 +76,6 @@ type AppViewProps = { index: number, patch: Partial ) => void; - onSave: () => void; onReload: () => void; onProxyServiceRefresh: () => void; onProxyServiceStart: () => void; @@ -89,23 +87,18 @@ type AppViewProps = { type ConfigToolbarProps = { section: ConfigSection; status: AppViewProps["status"]; - canSave: boolean; isDirty: boolean; onReload: () => void; - onSave: () => void; }; function ConfigToolbar({ section, status, - canSave, isDirty, onReload, - onSave, }: ConfigToolbarProps) { const isLoading = status === "loading"; - const isSaving = status === "saving"; - const canReload = !isLoading && !isSaving; + const canReload = status !== "saving" && !isLoading; return (
{m.common_refresh()} )} -
); @@ -287,10 +273,8 @@ function ConfigSectionContent({ ({ + setAppProxyUrlMock: vi.fn<(value: string) => void>(), +})); + +vi.mock("@/features/update/updater", () => ({ + useUpdater: () => ({ + state: { + status: "idle", + statusMessage: "", + lastCheckedAt: "", + updateInfo: null, + updateHandle: null, + downloadState: { downloaded: 0, total: 0 }, + lastCheckSource: null, + appProxyUrl: "", + appProxyUrlReady: true, + }, + actions: { + setAppProxyUrl: setAppProxyUrlMock, + checkForUpdate: async () => undefined, + downloadAndInstall: async () => undefined, + relaunchApp: async () => undefined, + }, + }), +})); + +vi.mock("@/features/config/AppView", () => ({ + AppView: ({ + form, + isDirty, + status, + statusMessage, + onFormChange, + }: { + form: ConfigForm; + isDirty: boolean; + status: "idle" | "loading" | "saving" | "saved" | "error"; + statusMessage: string; + onFormChange: (patch: Partial) => void; + }) => ( +
+ + onFormChange({ host: event.target.value })} + /> +
{status}
+
{String(isDirty)}
+
{statusMessage}
+
+ ), +})); + +const PROXY_STATUS: ProxyServiceStatus = { + state: "running", + addr: "127.0.0.1:9208", + last_error: null, +}; + +describe("config/ConfigScreen auto save", () => { + beforeEach(() => { + setAppProxyUrlMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.mocked(invoke).mockReset(); + }); + + async function waitForAutoSaveWindow() { + await new Promise((resolve) => { + window.setTimeout(resolve, 1200); + }); + } + + it("auto saves only after edits settle", async () => { + const invokeMock = vi.mocked(invoke); + const config = { ...toPayload(EMPTY_FORM), host: "10.0.0.1" }; + + invokeMock.mockImplementation(async (command, args) => { + if (command === "read_proxy_config") { + return { path: "/tmp/config.json", config }; + } + if (command === "proxy_status") { + return PROXY_STATUS; + } + if (command === "write_proxy_config") { + return { ...PROXY_STATUS, args }; + } + throw new Error(`unexpected command: ${command}`); + }); + + render( + + + + ); + + const hostInput = screen.getByLabelText("host"); + await waitFor(() => { + expect(hostInput).toHaveValue("10.0.0.1"); + }); + + fireEvent.change(hostInput, { target: { value: "10.0.0.2" } }); + fireEvent.change(hostInput, { target: { value: "10.0.0.3" } }); + + expect( + invokeMock.mock.calls.filter(([command]) => command === "write_proxy_config") + ).toHaveLength(0); + + await waitForAutoSaveWindow(); + + await waitFor(() => { + const writeCalls = invokeMock.mock.calls.filter(([command]) => command === "write_proxy_config"); + expect(writeCalls).toHaveLength(1); + expect(writeCalls[0]?.[1]).toMatchObject({ + config: expect.objectContaining({ host: "10.0.0.3" }), + }); + }); + }); + + it("does not retry the same failed auto save endlessly", async () => { + const invokeMock = vi.mocked(invoke); + const config = { ...toPayload(EMPTY_FORM), host: "10.0.0.1" }; + + invokeMock.mockImplementation(async (command) => { + if (command === "read_proxy_config") { + return { path: "/tmp/config.json", config }; + } + if (command === "proxy_status") { + return PROXY_STATUS; + } + if (command === "write_proxy_config") { + throw new Error("disk full"); + } + throw new Error(`unexpected command: ${command}`); + }); + + render( + + + + ); + + const hostInput = screen.getByLabelText("host"); + await waitFor(() => { + expect(hostInput).toHaveValue("10.0.0.1"); + }); + fireEvent.change(hostInput, { target: { value: "10.0.0.9" } }); + + await waitForAutoSaveWindow(); + + await waitFor(() => { + expect( + invokeMock.mock.calls.filter(([command]) => command === "write_proxy_config") + ).toHaveLength(1); + }); + + await waitForAutoSaveWindow(); + + expect( + invokeMock.mock.calls.filter(([command]) => command === "write_proxy_config") + ).toHaveLength(1); + expect(screen.getByTestId("status-message")).toHaveTextContent("disk full"); + }); +}); diff --git a/src/features/config/ConfigScreen.tsx b/src/features/config/ConfigScreen.tsx index 36ccce2..d24d405 100644 --- a/src/features/config/ConfigScreen.tsx +++ b/src/features/config/ConfigScreen.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { AppView } from "@/features/config/AppView"; import { @@ -23,6 +23,7 @@ type ProxyServiceState = ReturnType; type ConfigListActions = ReturnType; type ConfigActions = ReturnType; type ProxyServiceActions = ReturnType; +const CONFIG_AUTO_SAVE_DELAY_MS = 800; type AppViewArgs = { activeSectionId: ConfigSectionId; @@ -60,7 +61,6 @@ function buildAppViewProps({ proxyServiceMessage: proxyService.proxyServiceMessage, status: state.status, statusMessage: state.statusMessage, - canSave: derived.canSave, isDirty: derived.isDirty, validation: derived.validation, onToggleLocalKey: () => state.setShowLocalKey((value) => !value), @@ -72,7 +72,6 @@ function buildAppViewProps({ onAddUpstream: listActions.addUpstream, onRemoveUpstream: listActions.removeUpstream, onChangeUpstream: listActions.updateUpstream, - onSave: configActions.saveConfig, onReload: configActions.loadConfig, onProxyServiceRefresh: proxyActions.refreshProxyStatus, onProxyServiceStart: proxyActions.startProxy, @@ -83,6 +82,8 @@ function buildAppViewProps({ } export function ConfigScreen({ activeSectionId }: ConfigScreenProps) { + const lastObservedAutoSaveKeyRef = useRef(""); + const lastAttemptedAutoSaveKeyRef = useRef(""); const state = useConfigState(); const derived = useConfigDerived( state.form, @@ -142,6 +143,29 @@ export function ConfigScreen({ activeSectionId }: ConfigScreenProps) { void refreshProxyStatus(); }, [refreshProxyStatus]); + useEffect(() => { + if (derived.autoSaveKey === lastObservedAutoSaveKeyRef.current) { + return; + } + lastObservedAutoSaveKeyRef.current = derived.autoSaveKey; + lastAttemptedAutoSaveKeyRef.current = ""; + }, [derived.autoSaveKey]); + + useEffect(() => { + if (!derived.canAutoSave || !derived.autoSaveKey) { + return; + } + if (derived.autoSaveKey === lastAttemptedAutoSaveKeyRef.current) { + return; + } + const timerId = window.setTimeout(() => { + // 失败后不应对同一份草稿无限重试;只有用户继续编辑形成新草稿时,才重新进入自动保存。 + lastAttemptedAutoSaveKeyRef.current = derived.autoSaveKey; + void configActions.saveConfig(); + }, CONFIG_AUTO_SAVE_DELAY_MS); + return () => window.clearTimeout(timerId); + }, [configActions.saveConfig, derived.autoSaveKey, derived.canAutoSave]); + const appViewProps = buildAppViewProps({ activeSectionId, state, diff --git a/src/features/config/config-screen-state.ts b/src/features/config/config-screen-state.ts index b0f9421..00edeae 100644 --- a/src/features/config/config-screen-state.ts +++ b/src/features/config/config-screen-state.ts @@ -236,16 +236,28 @@ export function useConfigDerived( return Array.from(providers); }, [form.upstreams]); - const canSave = status !== "saving" && validation.valid && isDirty; + const autoSaveKey = useMemo(() => { + const segments: string[] = []; + if (configDirty && currentPayload) { + segments.push(`config:${stableStringify(currentPayload as JsonValue)}`); + } + if (autoStartDirty) { + segments.push(`autostart:${autoStartEnabled ? "enabled" : "disabled"}`); + } + return segments.join("|"); + }, [autoStartDirty, autoStartEnabled, configDirty, currentPayload]); + + const canAutoSave = status !== "saving" && validation.valid && isDirty; return { validation, currentPayload, configDirty, autoStartDirty, + autoSaveKey, isDirty, statusBadge, - canSave, + canAutoSave, providerOptions, }; } From 4498d159d86d55e67a784d4e91af067710dce781 Mon Sep 17 00:00:00 2001 From: mxyhi Date: Sun, 8 Mar 2026 11:16:50 +0800 Subject: [PATCH 5/7] fix(proxy): auto-apply saved config safely --- README.md | 4 +- README.zh-CN.md | 4 +- .../token_proxy_core/src/proxy/config/mod.rs | 18 ++- .../src/proxy/config/mod.test.rs | 11 ++ crates/token_proxy_core/src/proxy/service.rs | 84 ++++++++++---- .../src/proxy/service.test.rs | 58 ++++++++++ .../src/proxy/upstream_selector.rs | 5 +- .../src/proxy/upstream_selector.test.rs | 13 +++ messages/en.json | 9 +- messages/zh.json | 9 +- src-tauri/src/lib.rs | 42 +++---- src/features/config/AppView.test.tsx | 98 +++++++++++++++- src/features/config/AppView.tsx | 43 +++++-- src/features/config/ConfigScreen.test.tsx | 107 +++++++++++++++++- src/features/config/ConfigScreen.tsx | 2 + src/features/config/config-screen-actions.ts | 4 +- src/features/config/config-screen-state.ts | 5 +- 17 files changed, 436 insertions(+), 80 deletions(-) create mode 100644 crates/token_proxy_core/src/proxy/config/mod.test.rs create mode 100644 crates/token_proxy_core/src/proxy/service.test.rs diff --git a/README.md b/README.md index acc087d..f534f2e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Local AI API gateway for OpenAI / Gemini / Anthropic. Runs on your machine, keep ## Quick start (macOS) 1) Install: move `Token Proxy.app` to `/Applications`. If blocked: `xattr -cr /Applications/Token\ Proxy.app`. 2) Launch the app. The proxy starts automatically. -3) Open **Config File** tab, edit and save (writes `config.jsonc` in the Tauri config dir). Defaults are usable; just paste your upstream API keys. +3) Open **Config File** tab, edit and save (writes `config.jsonc` in the Tauri config dir). Defaults are usable; just paste your upstream API keys. Running proxies auto-apply the new config via reload or restart when needed. 4) Call via curl (example with local auth): ```bash curl -X POST \ @@ -100,7 +100,7 @@ Notes: | `app_proxy_url` | `null` | Proxy for app updater & as placeholder for upstreams (`"$app_proxy_url"`). Supports `http/https/socks5/socks5h`. | | `log_level` | `silent` | `silent|error|warn|info|debug|trace`; debug/trace log request headers (auth redacted) and small bodies (≤64KiB). Release builds force `silent`. | | `max_request_body_bytes` | `20971520` (20 MiB) | 0 = fallback to default. Protects inbound body size. | -| `retryable_failure_cooldown_secs` | `15` | Cooldown window after retryable failures that should temporarily sideline an upstream. `0` disables cooldown. Saving config resets current cooldown state. | +| `retryable_failure_cooldown_secs` | `15` | Cooldown window after retryable failures that should temporarily sideline an upstream. `0` disables cooldown. Reloading or restarting the running proxy resets current cooldown state. | | `tray_token_rate.enabled` | `true` | macOS tray live rate; harmless elsewhere. | | `tray_token_rate.format` | `split` | `combined` (`total`), `split` (`↑in ↓out`), `both` (`total | ↑in ↓out`). | | `upstream_strategy` | `priority_fill_first` | `priority_fill_first` (default) keeps trying the highest-priority group in list order; `priority_round_robin` rotates within each priority group. | diff --git a/README.zh-CN.md b/README.zh-CN.md index adadd00..22999c7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -26,7 +26,7 @@ ## 快速上手(macOS) 1) 安装:把 `Token Proxy.app` 放到 `/Applications`。若被拦截,执行 `xattr -cr /Applications/Token\ Proxy.app`。 2) 启动应用,代理会自动运行。 -3) 打开 **Config File** 标签,编辑并保存(写入 Tauri 配置目录下的 `config.jsonc`)。默认配置可用,只需填入上游 API Key。 +3) 打开 **Config File** 标签,编辑并保存(写入 Tauri 配置目录下的 `config.jsonc`)。默认配置可用,只需填入上游 API Key。若代理正在运行,保存后会按需自动 reload 或重启。 4) 发请求(本地鉴权示例): ```bash curl -X POST \ @@ -100,7 +100,7 @@ pnpm exec tsc --noEmit | `app_proxy_url` | `null` | 应用更新 & 上游可复用的代理;支持 `http/https/socks5/socks5h`;可在 upstream `proxy_url` 用 `"$app_proxy_url"` 占位 | | `log_level` | `silent` | `silent|error|warn|info|debug|trace`;debug/trace 会记录请求头(鉴权打码)与小体积请求体(≤64KiB);release 强制 `silent` | | `max_request_body_bytes` | `20971520` (20 MiB) | 0 表示回落到默认;保护入站体积 | -| `retryable_failure_cooldown_secs` | `15` | 对适合短时降级的可重试失败施加冷却窗口;`0` 表示关闭冷却。保存配置后会重置当前冷却状态 | +| `retryable_failure_cooldown_secs` | `15` | 对适合短时降级的可重试失败施加冷却窗口;`0` 表示关闭冷却。重载或重启运行中的代理会重置当前冷却状态 | | `tray_token_rate.enabled` | `true` | macOS 托盘实时速率;其他平台无害 | | `tray_token_rate.format` | `split` | `combined`(总数) / `split`(↑入 ↓出) / `both`(总数 | ↑入 ↓出) | | `upstream_strategy` | `priority_fill_first` | `priority_fill_first` 默认先填满高优先级;`priority_round_robin` 在同组内轮询 | diff --git a/crates/token_proxy_core/src/proxy/config/mod.rs b/crates/token_proxy_core/src/proxy/config/mod.rs index db4f344..753d600 100644 --- a/crates/token_proxy_core/src/proxy/config/mod.rs +++ b/crates/token_proxy_core/src/proxy/config/mod.rs @@ -5,7 +5,7 @@ mod normalize; mod types; use crate::paths::TokenProxyPaths; -use std::time::Duration; +use std::time::{Duration, Instant}; const DEFAULT_MAX_REQUEST_BODY_BYTES: u64 = 20 * 1024 * 1024; @@ -68,7 +68,9 @@ fn build_runtime_config(config: ProxyConfigFile) -> Result local_api_key: config.local_api_key, log_level, max_request_body_bytes, - retryable_failure_cooldown: Duration::from_secs(config.retryable_failure_cooldown_secs), + retryable_failure_cooldown: resolve_retryable_failure_cooldown( + config.retryable_failure_cooldown_secs, + )?, upstream_strategy: config.upstream_strategy, upstreams, kiro_preferred_endpoint: config.kiro_preferred_endpoint, @@ -76,6 +78,14 @@ fn build_runtime_config(config: ProxyConfigFile) -> Result }) } +fn resolve_retryable_failure_cooldown(value: u64) -> Result { + let duration = Duration::from_secs(value); + if Instant::now().checked_add(duration).is_none() { + return Err("retryable_failure_cooldown_secs is too large.".to_string()); + } + Ok(duration) +} + fn resolve_max_request_body_bytes(value: Option) -> usize { let value = value.unwrap_or(DEFAULT_MAX_REQUEST_BODY_BYTES); let value = if value == 0 { @@ -98,3 +108,7 @@ fn normalize_app_proxy_url(value: Option<&str>) -> Result, String scheme => Err(format!("app_proxy_url scheme is not supported: {scheme}.")), } } + +#[cfg(test)] +#[path = "mod.test.rs"] +mod tests; diff --git a/crates/token_proxy_core/src/proxy/config/mod.test.rs b/crates/token_proxy_core/src/proxy/config/mod.test.rs new file mode 100644 index 0000000..2414dfc --- /dev/null +++ b/crates/token_proxy_core/src/proxy/config/mod.test.rs @@ -0,0 +1,11 @@ +use super::*; + +#[test] +fn build_runtime_config_rejects_retryable_failure_cooldown_that_overflows_instant() { + let mut config = ProxyConfigFile::default(); + config.retryable_failure_cooldown_secs = u64::MAX; + + let result = build_runtime_config(config); + + assert!(result.is_err()); +} diff --git a/crates/token_proxy_core/src/proxy/service.rs b/crates/token_proxy_core/src/proxy/service.rs index 7d8732e..af8f24e 100644 --- a/crates/token_proxy_core/src/proxy/service.rs +++ b/crates/token_proxy_core/src/proxy/service.rs @@ -44,6 +44,13 @@ pub struct ProxyServiceHandle { inner: Arc, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProxyConfigApplyBehavior { + SavedOnly, + Reload, + Restart, +} + impl ProxyServiceHandle { pub fn new() -> Self { Self { @@ -70,6 +77,13 @@ impl ProxyServiceHandle { pub async fn reload(&self, ctx: &ProxyContext) -> Result { self.inner.reload(ctx).await } + + pub async fn reload_behavior( + &self, + ctx: &ProxyContext, + ) -> Result { + self.inner.reload_behavior(ctx).await + } } #[derive(Clone, Serialize, Debug)] @@ -148,6 +162,15 @@ impl ProxyService { inner.reload(ctx).await?; Ok(inner.status()) } + + async fn reload_behavior( + &self, + ctx: &ProxyContext, + ) -> Result { + let mut inner = self.inner.lock().await; + inner.refresh_if_finished().await; + inner.reload_behavior(ctx).await + } } struct ProxyServiceInner { @@ -248,35 +271,22 @@ impl ProxyServiceInner { return self.start(ctx).await; } let loaded_config = ProxyConfig::load(ctx.paths.as_ref()).await?; + let current_running_config = self.current_running_config().await; let addr = loaded_config.addr(); - let current_addr = self - .running + let current_addr = current_running_config .as_ref() - .map(|running| running.addr.as_str()) + .map(|(current_addr, _)| current_addr.as_str()) .unwrap_or_default() .to_string(); tracing::debug!(addr = %addr, current_addr = %current_addr, "proxy reload config loaded"); - if addr != current_addr { - // host/port 变更无法热更新监听地址;退化为安全重启。 + if classify_reload_behavior(current_running_config, &loaded_config) + == ProxyConfigApplyBehavior::Restart + { tracing::info!( addr = %addr, current_addr = %current_addr, - "proxy reload detected addr change, restarting" - ); - return self.restart(ctx).await; - } - let current_max_request_body_bytes = if let Some(running) = self.running.as_ref() { - let guard = running.state_handle.read().await; - guard.config.max_request_body_bytes - } else { - loaded_config.max_request_body_bytes - }; - if loaded_config.max_request_body_bytes != current_max_request_body_bytes { - tracing::info!( - new_max_request_body_bytes = loaded_config.max_request_body_bytes, - current_max_request_body_bytes = current_max_request_body_bytes, - "proxy reload detected body limit change, restarting" + "proxy reload detected restart-required config change" ); return self.restart(ctx).await; } @@ -298,6 +308,21 @@ impl ProxyServiceInner { Ok(()) } + async fn reload_behavior( + &mut self, + ctx: &ProxyContext, + ) -> Result { + let loaded_config = ProxyConfig::load(ctx.paths.as_ref()).await?; + let current_running_config = self.current_running_config().await; + Ok(classify_reload_behavior(current_running_config, &loaded_config)) + } + + async fn current_running_config(&self) -> Option<(String, usize)> { + let running = self.running.as_ref()?; + let guard = running.state_handle.read().await; + Some((running.addr.clone(), guard.config.max_request_body_bytes)) + } + async fn finish_task(&mut self, mut running: RunningProxy) { if let Some(tx) = running.shutdown_tx.take() { let _ = tx.send(()); @@ -379,3 +404,22 @@ async fn build_proxy_state( antigravity_accounts, })) } + +fn classify_reload_behavior( + current_running_config: Option<(String, usize)>, + loaded_config: &ProxyConfig, +) -> ProxyConfigApplyBehavior { + let Some((current_addr, current_max_request_body_bytes)) = current_running_config else { + return ProxyConfigApplyBehavior::SavedOnly; + }; + if loaded_config.addr() != current_addr + || loaded_config.max_request_body_bytes != current_max_request_body_bytes + { + return ProxyConfigApplyBehavior::Restart; + } + ProxyConfigApplyBehavior::Reload +} + +#[cfg(test)] +#[path = "service.test.rs"] +mod tests; diff --git a/crates/token_proxy_core/src/proxy/service.test.rs b/crates/token_proxy_core/src/proxy/service.test.rs new file mode 100644 index 0000000..66df7e1 --- /dev/null +++ b/crates/token_proxy_core/src/proxy/service.test.rs @@ -0,0 +1,58 @@ +use super::*; +use crate::logging::LogLevel; +use std::collections::HashMap; +use std::time::Duration; + +fn config_with_addr_and_body_limit(host: &str, port: u16, max_request_body_bytes: usize) -> ProxyConfig { + ProxyConfig { + host: host.to_string(), + port, + local_api_key: None, + log_level: LogLevel::Silent, + max_request_body_bytes, + retryable_failure_cooldown: Duration::from_secs(15), + upstream_strategy: crate::proxy::config::UpstreamStrategy::PriorityFillFirst, + upstreams: HashMap::new(), + kiro_preferred_endpoint: None, + antigravity_user_agent: None, + } +} + +#[test] +fn classify_reload_behavior_returns_reload_for_hot_reload_safe_changes() { + let current = config_with_addr_and_body_limit("127.0.0.1", 9208, 1024); + let next = config_with_addr_and_body_limit("127.0.0.1", 9208, 1024); + + let action = classify_reload_behavior(Some((current.addr(), current.max_request_body_bytes)), &next); + + assert_eq!(action, ProxyConfigApplyBehavior::Reload); +} + +#[test] +fn classify_reload_behavior_restarts_when_addr_changes() { + let current = config_with_addr_and_body_limit("127.0.0.1", 9208, 1024); + let next = config_with_addr_and_body_limit("127.0.0.1", 9300, 1024); + + let action = classify_reload_behavior(Some((current.addr(), current.max_request_body_bytes)), &next); + + assert_eq!(action, ProxyConfigApplyBehavior::Restart); +} + +#[test] +fn classify_reload_behavior_restarts_when_body_limit_changes() { + let current = config_with_addr_and_body_limit("127.0.0.1", 9208, 1024); + let next = config_with_addr_and_body_limit("127.0.0.1", 9208, 2048); + + let action = classify_reload_behavior(Some((current.addr(), current.max_request_body_bytes)), &next); + + assert_eq!(action, ProxyConfigApplyBehavior::Restart); +} + +#[test] +fn classify_reload_behavior_skips_apply_when_proxy_is_stopped() { + let next = config_with_addr_and_body_limit("127.0.0.1", 9208, 1024); + + let action = classify_reload_behavior(None, &next); + + assert_eq!(action, ProxyConfigApplyBehavior::SavedOnly); +} diff --git a/crates/token_proxy_core/src/proxy/upstream_selector.rs b/crates/token_proxy_core/src/proxy/upstream_selector.rs index 47e8e02..9fd228c 100644 --- a/crates/token_proxy_core/src/proxy/upstream_selector.rs +++ b/crates/token_proxy_core/src/proxy/upstream_selector.rs @@ -51,10 +51,13 @@ impl UpstreamSelectorRuntime { } pub(crate) fn mark_retryable_failure(&self, provider: &str, upstream_id: &str) { + let Some(until) = Instant::now().checked_add(self.retryable_failure_cooldown) else { + return; + }; self.mark_cooldown_until( provider, upstream_id, - Instant::now() + self.retryable_failure_cooldown, + until, ); } diff --git a/crates/token_proxy_core/src/proxy/upstream_selector.test.rs b/crates/token_proxy_core/src/proxy/upstream_selector.test.rs index 7e5cd87..49c6d8d 100644 --- a/crates/token_proxy_core/src/proxy/upstream_selector.test.rs +++ b/crates/token_proxy_core/src/proxy/upstream_selector.test.rs @@ -70,3 +70,16 @@ fn zero_retryable_failure_cooldown_disables_cross_request_cooling() { assert_eq!(order, vec![0, 1]); } + +#[test] +fn extreme_retryable_failure_cooldown_does_not_panic() { + let selector = UpstreamSelectorRuntime::new_with_cooldown(Duration::from_secs(u64::MAX)); + let items = vec![runtime("a"), runtime("b")]; + + let result = std::panic::catch_unwind(|| { + selector.mark_retryable_failure("responses", "a"); + selector.order_group(UpstreamStrategy::PriorityFillFirst, "responses", &items, 0) + }); + + assert!(result.is_ok()); +} diff --git a/messages/en.json b/messages/en.json index e0412f2..9b7eab4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -25,6 +25,7 @@ "config_status_saved": "Saved", "config_status_idle": "Idle", "config_request_failed_title": "Request failed", + "config_retry_save": "Retry save", "config_invalid_configuration": "Invalid configuration.", "sidebar_hide": "Hide sidebar", "sidebar_show": "Show sidebar", @@ -55,7 +56,7 @@ "proxy_core_kiro_preferred_endpoint_label": "Kiro preferred endpoint", "proxy_core_kiro_preferred_endpoint_help": "Sets the default Kiro endpoint (IDE = CodeWhisperer, CLI = Amazon Q).", "proxy_core_retryable_failure_cooldown_secs_label": "Retryable failure cooldown (seconds)", - "proxy_core_retryable_failure_cooldown_secs_help": "Default is 15. Applies cross-request cooldown to 401/403/408/429/5xx; saving config resets the current cooldown state. Use 0 to disable cooldown.", + "proxy_core_retryable_failure_cooldown_secs_help": "Default is 15. Applies cross-request cooldown to 401/403/408/429/5xx after the running proxy loads this config. Reloading or restarting resets the current cooldown state. Use 0 to disable cooldown.", "proxy_core_format_conversion_title": "Format Conversion", "proxy_core_format_conversion_desc": "Auto-convert OpenAI/Anthropic/Gemini formats when the preferred provider is missing.", "proxy_core_format_conversion_aria": "Enable format conversion", @@ -272,7 +273,7 @@ "config_file_desc": "Disk location and maintenance actions.", "config_file_location_label": "Location", "config_file_help_1": "Use the toolbar to save changes back to the JSONC file.", - "config_file_help_2": "Saving triggers an automatic proxy reload (and safe restart if needed).", + "config_file_help_2": "Saving writes the JSONC file and automatically reloads or restarts a running proxy when needed. Stopped proxies stay stopped.", "config_file_last_saved_at": "Last saved at {time}", "config_file_unsaved_notice": "You have unsaved changes. Reload will ask for confirmation to avoid overwriting your edits.", "config_file_discard_title": "Discard unsaved changes?", @@ -332,8 +333,8 @@ "proxy_service_desc": "Safe stop/restart and manual config reload.", "proxy_service_state_label": "State", "proxy_service_addr_label": "Addr", - "proxy_service_help_1": "The proxy reads configuration from the JSONC file on disk. Save your changes first to apply them.", - "proxy_service_help_2": "Saving the config triggers an automatic reload (and safe restart if host/port changes).", + "proxy_service_help_1": "The proxy reads configuration from the JSONC file on disk. Save first; running proxies apply the latest disk config automatically.", + "proxy_service_help_2": "Use Reload or Restart here when you want to re-apply disk config manually, especially while the proxy is stopped.", "proxy_service_start": "Start", "proxy_service_stop": "Stop", "proxy_service_restart": "Restart", diff --git a/messages/zh.json b/messages/zh.json index 00af02b..152044b 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -25,6 +25,7 @@ "config_status_saved": "已保存", "config_status_idle": "空闲", "config_request_failed_title": "请求失败", + "config_retry_save": "重试保存", "config_invalid_configuration": "配置无效。", "sidebar_hide": "隐藏侧栏", "sidebar_show": "显示侧栏", @@ -55,7 +56,7 @@ "proxy_core_kiro_preferred_endpoint_label": "Kiro 默认端点", "proxy_core_kiro_preferred_endpoint_help": "设置默认 Kiro 端点(IDE=CodeWhisperer,CLI=Amazon Q)。", "proxy_core_retryable_failure_cooldown_secs_label": "可重试失败冷却(秒)", - "proxy_core_retryable_failure_cooldown_secs_help": "默认 15。对 401/403/408/429/5xx 触发跨请求冷却;保存配置后会重置当前冷却状态。填 0 可关闭冷却。", + "proxy_core_retryable_failure_cooldown_secs_help": "默认 15。运行中的代理加载该配置后,会对 401/403/408/429/5xx 触发跨请求冷却;重载或重启代理会重置当前冷却状态。填 0 可关闭冷却。", "proxy_core_format_conversion_title": "格式转换", "proxy_core_format_conversion_desc": "当首选提供商缺失时,自动转换 OpenAI/Anthropic/Gemini 格式。", "proxy_core_format_conversion_aria": "启用格式转换", @@ -273,7 +274,7 @@ "config_file_desc": "磁盘位置与维护操作。", "config_file_location_label": "位置", "config_file_help_1": "使用顶部工具栏将更改保存回 JSONC 文件。", - "config_file_help_2": "保存会触发代理自动 reload(必要时安全重启)。", + "config_file_help_2": "保存会写入 JSONC 文件;如果代理正在运行,会按需自动 reload 或重启。已停止的代理仍保持停止。", "config_file_last_saved_at": "上次保存时间:{time}", "config_file_unsaved_notice": "你有未保存的更改。为避免覆盖编辑内容,reload 会先弹出确认。", "config_file_discard_title": "丢弃未保存的更改?", @@ -333,8 +334,8 @@ "proxy_service_desc": "安全停止/重启与手动 reload 配置。", "proxy_service_state_label": "状态", "proxy_service_addr_label": "地址", - "proxy_service_help_1": "代理从磁盘上的 JSONC 文件读取配置。请先保存更改再应用。", - "proxy_service_help_2": "保存配置会触发自动 reload(如果 host/port 变化则安全重启)。", + "proxy_service_help_1": "代理从磁盘上的 JSONC 文件读取配置。请先保存;运行中的代理会自动应用最新磁盘配置。", + "proxy_service_help_2": "当你想手动重新应用磁盘配置时,可在这里使用 Reload 或 Restart,尤其是在代理已停止时。", "proxy_service_start": "启动", "proxy_service_stop": "停止", "proxy_service_restart": "重启", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index db31f79..5206491 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -156,7 +156,7 @@ async fn write_opencode_config( } #[tauri::command] -async fn write_proxy_config( +async fn save_proxy_config( app: tauri::AppHandle, proxy_service: tauri::State<'_, ProxyServiceHandle>, tray_state: tauri::State<'_, tray::TrayState>, @@ -164,14 +164,14 @@ async fn write_proxy_config( app_proxy_state: tauri::State<'_, app_proxy::AppProxyState>, config: proxy::config::ProxyConfigFile, ) -> Result { - tracing::debug!("write_proxy_config start"); + tracing::debug!("save_proxy_config start"); let start = Instant::now(); - tracing::debug!("write_proxy_config apply_config start"); + tracing::debug!("save_proxy_config apply_config start"); let apply_start = Instant::now(); tray_state.apply_config(&config.tray_token_rate).await; tracing::debug!( elapsed_ms = apply_start.elapsed().as_millis(), - "write_proxy_config apply_config done" + "save_proxy_config apply_config done" ); let log_level = config.log_level; let app_proxy_url = proxy::config::app_proxy_url_from_config(&config) @@ -179,38 +179,28 @@ async fn write_proxy_config( .flatten(); let paths = app.state::>(); if let Err(err) = proxy::config::write_config(paths.inner().as_ref(), config).await { - tracing::error!(error = %err, "write_proxy_config save failed"); + tracing::error!(error = %err, "save_proxy_config save failed"); tray_state.apply_error("保存失败", &err); return Err(err); } tracing::debug!( elapsed_ms = start.elapsed().as_millis(), - "write_proxy_config saved" + "save_proxy_config saved" ); - let reload_start = Instant::now(); logging_state.apply_level(log_level); app_proxy::set(&app_proxy_state, app_proxy_url).await; let proxy_context = app.state::(); - match proxy_service.reload(proxy_context.inner()).await { - Ok(status) => { - tracing::debug!( - elapsed_ms = reload_start.elapsed().as_millis(), - state = ?status.state, - "write_proxy_config reloaded" - ); - tray_state.apply_status(&status); - Ok(status) + let status = match proxy_service.reload_behavior(proxy_context.inner()).await? { + proxy::service::ProxyConfigApplyBehavior::SavedOnly => proxy_service.status().await, + proxy::service::ProxyConfigApplyBehavior::Reload => { + proxy_service.reload(proxy_context.inner()).await? } - Err(err) => { - tracing::error!( - elapsed_ms = reload_start.elapsed().as_millis(), - error = %err, - "write_proxy_config reload failed" - ); - tray_state.apply_error("重载失败", &err); - Err(err) + proxy::service::ProxyConfigApplyBehavior::Restart => { + proxy_service.restart(proxy_context.inner()).await? } - } + }; + tray_state.apply_status(&status); + Ok(status) } #[tauri::command] @@ -763,7 +753,7 @@ pub fn run() { write_claude_code_settings, write_codex_config, write_opencode_config, - write_proxy_config, + save_proxy_config, read_dashboard_snapshot, read_request_log_detail, read_request_detail_capture, diff --git a/src/features/config/AppView.test.tsx b/src/features/config/AppView.test.tsx index c53a847..4d16be5 100644 --- a/src/features/config/AppView.test.tsx +++ b/src/features/config/AppView.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { AppView } from "@/features/config/AppView"; @@ -45,7 +45,7 @@ const IDLE_PROXY_STATUS: ProxyServiceStatus = { }; describe("config/AppView", () => { - it("removes the manual save button from the config toolbar", () => { + it("does not show a persistent save button when there are pending edits", () => { render( { proxyServiceMessage="" status="idle" statusMessage="" - isDirty={false} + canSave + isDirty validation={{ valid: true, message: "" }} onToggleLocalKey={() => undefined} onToggleUpstreamKeys={() => undefined} @@ -75,6 +76,7 @@ describe("config/AppView", () => { onRemoveUpstream={() => undefined} onChangeUpstream={() => undefined} onReload={() => undefined} + onSave={() => undefined} onProxyServiceRefresh={() => undefined} onProxyServiceStart={() => undefined} onProxyServiceStop={() => undefined} @@ -86,4 +88,94 @@ describe("config/AppView", () => { expect(screen.getByRole("button", { name: m.common_refresh() })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: m.common_save() })).not.toBeInTheDocument(); }); + + it("shows retry action only inside error alert for dirty draft", () => { + const onSave = vi.fn(); + + render( + undefined} + onToggleUpstreamKeys={() => undefined} + onFormChange={() => undefined} + onStrategyChange={() => undefined} + onAutoStartChange={() => undefined} + onAddUpstream={() => undefined} + onRemoveUpstream={() => undefined} + onChangeUpstream={() => undefined} + onReload={() => undefined} + onSave={onSave} + onProxyServiceRefresh={() => undefined} + onProxyServiceStart={() => undefined} + onProxyServiceStop={() => undefined} + onProxyServiceRestart={() => undefined} + onProxyServiceReload={() => undefined} + /> + ); + + fireEvent.click(screen.getByRole("button", { name: m.config_retry_save() })); + + expect(onSave).toHaveBeenCalledTimes(1); + }); + + it("does not render informational save alerts", () => { + render( + undefined} + onToggleUpstreamKeys={() => undefined} + onFormChange={() => undefined} + onStrategyChange={() => undefined} + onAutoStartChange={() => undefined} + onAddUpstream={() => undefined} + onRemoveUpstream={() => undefined} + onChangeUpstream={() => undefined} + onReload={() => undefined} + onSave={() => undefined} + onProxyServiceRefresh={() => undefined} + onProxyServiceStart={() => undefined} + onProxyServiceStop={() => undefined} + onProxyServiceRestart={() => undefined} + onProxyServiceReload={() => undefined} + /> + ); + + expect(screen.queryByText("should not be shown")).not.toBeInTheDocument(); + }); }); diff --git a/src/features/config/AppView.tsx b/src/features/config/AppView.tsx index 6d299fe..7dc2132 100644 --- a/src/features/config/AppView.tsx +++ b/src/features/config/AppView.tsx @@ -63,6 +63,7 @@ type AppViewProps = { proxyServiceMessage: string; status: "idle" | "loading" | "saving" | "saved" | "error"; statusMessage: string; + canSave: boolean; isDirty: boolean; validation: { valid: boolean; message: string }; onToggleLocalKey: () => void; @@ -76,6 +77,7 @@ type AppViewProps = { index: number, patch: Partial ) => void; + onSave: () => void; onReload: () => void; onProxyServiceRefresh: () => void; onProxyServiceStart: () => void; @@ -98,7 +100,8 @@ function ConfigToolbar({ onReload, }: ConfigToolbarProps) { const isLoading = status === "loading"; - const canReload = status !== "saving" && !isLoading; + const isSaving = status === "saving"; + const canReload = !isSaving && !isLoading; return (
void; }; -function StatusAlert({ statusMessage }: StatusAlertProps) { - if (!statusMessage) { +function StatusAlert({ + status, + statusMessage, + canSave, + isDirty, + onSave, +}: StatusAlertProps) { + if (status !== "error" || !statusMessage) { return null; } + + const canRetrySave = isDirty && canSave; + return (