diff --git a/src-tauri/src/proxy/pi_handler.rs b/src-tauri/src/proxy/pi_handler.rs index 143f8d099..f153aa169 100644 --- a/src-tauri/src/proxy/pi_handler.rs +++ b/src-tauri/src/proxy/pi_handler.rs @@ -204,9 +204,10 @@ pub(crate) async fn handle_pi_native( }; let status = response.status(); - if retryable_status(status) && network_budget.has_remaining() { + let status_disposition = upstream_status_disposition(status); + if status_disposition.is_retryable() && network_budget.has_remaining() { let error = format!("Pi upstream returned retryable status {status}"); - let status_health = provider_health_disposition(status); + let status_health = status_disposition.provider_health(); if status_health == ProviderHealthDisposition::Unhealthy { provider_health_failure = Some(error.clone()); } @@ -223,7 +224,17 @@ pub(crate) async fn handle_pi_native( provider_health_failure.as_deref(), ), }); - continue; + match status_disposition { + UpstreamStatusDisposition::RetryEndpoint => continue, + // Every endpoint in one provider group is cloned from the + // same credential plan. Preserve this response as a + // fallback, but reserve the remaining network budget for + // a provider that can own a different credential. + UpstreamStatusDisposition::RetryProvider => break, + UpstreamStatusDisposition::ReturnResponse => { + unreachable!("a non-retryable status cannot enter the retry branch") + } + } } let selected_is_failover = materialized.is_failover; let provider_health = @@ -507,6 +518,54 @@ enum ProviderHealthDisposition { Neutral, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UpstreamStatusDisposition { + ReturnResponse, + RetryEndpoint, + RetryProvider, +} + +impl UpstreamStatusDisposition { + const fn is_retryable(self) -> bool { + !matches!(self, Self::ReturnResponse) + } + + const fn provider_health(self) -> ProviderHealthDisposition { + match self { + Self::ReturnResponse => ProviderHealthDisposition::Healthy, + Self::RetryEndpoint => ProviderHealthDisposition::Unhealthy, + Self::RetryProvider => ProviderHealthDisposition::Neutral, + } + } +} + +fn upstream_status_disposition(status: StatusCode) -> UpstreamStatusDisposition { + if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) { + // Pi's provider owns authentication (API key or OAuth), while its + // custom endpoints only replace the URL. Authentication rejection is + // therefore neutral for endpoint health and can only benefit from a + // distinct provider credential. + return UpstreamStatusDisposition::RetryProvider; + } + if (!status.is_client_error() && !status.is_server_error()) + || matches!( + status, + StatusCode::BAD_REQUEST + | StatusCode::METHOD_NOT_ALLOWED + | StatusCode::NOT_ACCEPTABLE + | StatusCode::PAYLOAD_TOO_LARGE + | StatusCode::URI_TOO_LONG + | StatusCode::UNSUPPORTED_MEDIA_TYPE + | StatusCode::UNPROCESSABLE_ENTITY + | StatusCode::NOT_IMPLEMENTED + ) + { + UpstreamStatusDisposition::ReturnResponse + } else { + UpstreamStatusDisposition::RetryEndpoint + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct ProviderHealthOutcome { disposition: ProviderHealthDisposition, @@ -515,7 +574,7 @@ struct ProviderHealthOutcome { impl ProviderHealthOutcome { fn from_status(status: StatusCode, prior_failure: Option<&str>) -> Self { - match provider_health_disposition(status) { + match upstream_status_disposition(status).provider_health() { ProviderHealthDisposition::Healthy => Self { disposition: ProviderHealthDisposition::Healthy, error: None, @@ -541,19 +600,6 @@ impl ProviderHealthOutcome { } } -fn provider_health_disposition(status: StatusCode) -> ProviderHealthDisposition { - if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) { - // Authentication/authorization belongs to the candidate credential, - // not endpoint availability. Failover may still try another - // credential, but this provider's health and circuit stay unchanged. - ProviderHealthDisposition::Neutral - } else if retryable_status(status) { - ProviderHealthDisposition::Unhealthy - } else { - ProviderHealthDisposition::Healthy - } -} - async fn settle_provider_health( state: &ProxyState, catalog_epoch: u64, @@ -773,23 +819,6 @@ fn merge_candidate_headers(incoming: &HeaderMap, candidate: &HeaderMap) -> Heade merged } -fn retryable_status(status: StatusCode) -> bool { - if !status.is_client_error() && !status.is_server_error() { - return false; - } - !matches!( - status, - StatusCode::BAD_REQUEST - | StatusCode::METHOD_NOT_ALLOWED - | StatusCode::NOT_ACCEPTABLE - | StatusCode::PAYLOAD_TOO_LARGE - | StatusCode::URI_TOO_LONG - | StatusCode::UNSUPPORTED_MEDIA_TYPE - | StatusCode::UNPROCESSABLE_ENTITY - | StatusCode::NOT_IMPLEMENTED - ) -} - struct PreparedPiResponse { response: Response, finalization_deferred: bool, @@ -1418,9 +1447,14 @@ mod tests { #[test] fn retry_policy_matches_pi_contract_matrix() { + for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] { + assert_eq!( + upstream_status_disposition(status), + UpstreamStatusDisposition::RetryProvider, + "{status}" + ); + } for status in [ - StatusCode::UNAUTHORIZED, - StatusCode::FORBIDDEN, StatusCode::NOT_FOUND, StatusCode::REQUEST_TIMEOUT, StatusCode::CONFLICT, @@ -1428,7 +1462,11 @@ mod tests { StatusCode::IM_A_TEAPOT, StatusCode::BAD_GATEWAY, ] { - assert!(retryable_status(status), "{status}"); + assert_eq!( + upstream_status_disposition(status), + UpstreamStatusDisposition::RetryEndpoint, + "{status}" + ); } for status in [ StatusCode::BAD_REQUEST, @@ -1440,7 +1478,11 @@ mod tests { StatusCode::UNPROCESSABLE_ENTITY, StatusCode::NOT_IMPLEMENTED, ] { - assert!(!retryable_status(status), "{status}"); + assert_eq!( + upstream_status_disposition(status), + UpstreamStatusDisposition::ReturnResponse, + "{status}" + ); } } @@ -1473,7 +1515,10 @@ mod tests { .await .expect("request local Pi capture endpoint"); assert_eq!(response.status(), expected); - assert!(retryable_status(response.status())); + assert_eq!( + upstream_status_disposition(response.status()), + UpstreamStatusDisposition::RetryProvider + ); assert_eq!( ProviderHealthOutcome::from_status(response.status(), None), ProviderHealthOutcome { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7848b5667..2bab68dc0 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -798,7 +798,7 @@ "officialWebsite": "公式サイト", "github": "GitHub", "manualInstallCommands": "手動インストールコマンド", - "oneClickInstallHint": "Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes をインストールまたは更新", + "oneClickInstallHint": "Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes / Pi をインストールまたは更新", "localEnvCheck": "ローカル環境チェック", "updateAllTools": "すべて更新({{count}})", "currentVersion": "現在のバージョン", @@ -1012,7 +1012,7 @@ }, "sessionManager": { "title": "セッション管理", - "subtitle": "Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes のセッションを管理", + "subtitle": "Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes / Pi のセッションを管理", "searchPlaceholder": "内容・ディレクトリ・ID で検索", "searchSessions": "セッションを検索", "providerFilterAll": "すべて", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 274230082..8ff6b291f 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -799,7 +799,7 @@ "github": "GitHub", "manualInstallCommands": "手動安裝指令", "oneClickInstall": "一鍵安裝", - "oneClickInstallHint": "安裝或更新 Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes", + "oneClickInstallHint": "安裝或更新 Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes / Pi", "localEnvCheck": "本地環境檢查", "updateAllTools": "全部更新({{count}})", "currentVersion": "目前版本", @@ -1013,7 +1013,7 @@ }, "sessionManager": { "title": "工作階段管理", - "subtitle": "管理 Claude Code、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 與 Hermes 工作階段紀錄", + "subtitle": "管理 Claude Code、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes 與 Pi 工作階段紀錄", "searchPlaceholder": "搜尋對話內容、目錄或 ID", "searchSessions": "搜尋工作階段", "providerFilterAll": "全部", diff --git a/tests/config/localeCoverage.test.ts b/tests/config/localeCoverage.test.ts index 032a5b5fe..685f36fc0 100644 --- a/tests/config/localeCoverage.test.ts +++ b/tests/config/localeCoverage.test.ts @@ -49,6 +49,9 @@ const piReference = new Map( piKeysOutsideNamespace.has(key), ), ); +const piProductReferences = new Map( + [...reference].filter(([, value]) => /\bPi\b/.test(value)), +); const locales = [ ["zh", zh], ["ja", ja], @@ -81,4 +84,17 @@ describe("locale coverage", () => { expect(mismatched).toEqual([]); }, ); + + it.each(locales)( + "preserves explicit Pi product mentions in %s", + (_name, tree) => { + const translations = flattenStrings(tree as TranslationTree); + const missingMentions = [...piProductReferences.keys()].filter((key) => { + const actual = translations.get(key); + return actual === undefined || !/\bPi\b/.test(actual); + }); + + expect(missingMentions).toEqual([]); + }, + ); });