mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
fix(pi): align credential retry scope
This commit is contained in:
@@ -204,9 +204,10 @@ pub(crate) async fn handle_pi_native(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let status = response.status();
|
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 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 {
|
if status_health == ProviderHealthDisposition::Unhealthy {
|
||||||
provider_health_failure = Some(error.clone());
|
provider_health_failure = Some(error.clone());
|
||||||
}
|
}
|
||||||
@@ -223,7 +224,17 @@ pub(crate) async fn handle_pi_native(
|
|||||||
provider_health_failure.as_deref(),
|
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 selected_is_failover = materialized.is_failover;
|
||||||
let provider_health =
|
let provider_health =
|
||||||
@@ -507,6 +518,54 @@ enum ProviderHealthDisposition {
|
|||||||
Neutral,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct ProviderHealthOutcome {
|
struct ProviderHealthOutcome {
|
||||||
disposition: ProviderHealthDisposition,
|
disposition: ProviderHealthDisposition,
|
||||||
@@ -515,7 +574,7 @@ struct ProviderHealthOutcome {
|
|||||||
|
|
||||||
impl ProviderHealthOutcome {
|
impl ProviderHealthOutcome {
|
||||||
fn from_status(status: StatusCode, prior_failure: Option<&str>) -> Self {
|
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 {
|
ProviderHealthDisposition::Healthy => Self {
|
||||||
disposition: ProviderHealthDisposition::Healthy,
|
disposition: ProviderHealthDisposition::Healthy,
|
||||||
error: None,
|
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(
|
async fn settle_provider_health(
|
||||||
state: &ProxyState,
|
state: &ProxyState,
|
||||||
catalog_epoch: u64,
|
catalog_epoch: u64,
|
||||||
@@ -773,23 +819,6 @@ fn merge_candidate_headers(incoming: &HeaderMap, candidate: &HeaderMap) -> Heade
|
|||||||
merged
|
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 {
|
struct PreparedPiResponse {
|
||||||
response: Response,
|
response: Response,
|
||||||
finalization_deferred: bool,
|
finalization_deferred: bool,
|
||||||
@@ -1418,9 +1447,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn retry_policy_matches_pi_contract_matrix() {
|
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 [
|
for status in [
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
StatusCode::REQUEST_TIMEOUT,
|
StatusCode::REQUEST_TIMEOUT,
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
@@ -1428,7 +1462,11 @@ mod tests {
|
|||||||
StatusCode::IM_A_TEAPOT,
|
StatusCode::IM_A_TEAPOT,
|
||||||
StatusCode::BAD_GATEWAY,
|
StatusCode::BAD_GATEWAY,
|
||||||
] {
|
] {
|
||||||
assert!(retryable_status(status), "{status}");
|
assert_eq!(
|
||||||
|
upstream_status_disposition(status),
|
||||||
|
UpstreamStatusDisposition::RetryEndpoint,
|
||||||
|
"{status}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for status in [
|
for status in [
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
@@ -1440,7 +1478,11 @@ mod tests {
|
|||||||
StatusCode::UNPROCESSABLE_ENTITY,
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
StatusCode::NOT_IMPLEMENTED,
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
] {
|
] {
|
||||||
assert!(!retryable_status(status), "{status}");
|
assert_eq!(
|
||||||
|
upstream_status_disposition(status),
|
||||||
|
UpstreamStatusDisposition::ReturnResponse,
|
||||||
|
"{status}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1473,7 +1515,10 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("request local Pi capture endpoint");
|
.expect("request local Pi capture endpoint");
|
||||||
assert_eq!(response.status(), expected);
|
assert_eq!(response.status(), expected);
|
||||||
assert!(retryable_status(response.status()));
|
assert_eq!(
|
||||||
|
upstream_status_disposition(response.status()),
|
||||||
|
UpstreamStatusDisposition::RetryProvider
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ProviderHealthOutcome::from_status(response.status(), None),
|
ProviderHealthOutcome::from_status(response.status(), None),
|
||||||
ProviderHealthOutcome {
|
ProviderHealthOutcome {
|
||||||
|
|||||||
@@ -798,7 +798,7 @@
|
|||||||
"officialWebsite": "公式サイト",
|
"officialWebsite": "公式サイト",
|
||||||
"github": "GitHub",
|
"github": "GitHub",
|
||||||
"manualInstallCommands": "手動インストールコマンド",
|
"manualInstallCommands": "手動インストールコマンド",
|
||||||
"oneClickInstallHint": "Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes をインストールまたは更新",
|
"oneClickInstallHint": "Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes / Pi をインストールまたは更新",
|
||||||
"localEnvCheck": "ローカル環境チェック",
|
"localEnvCheck": "ローカル環境チェック",
|
||||||
"updateAllTools": "すべて更新({{count}})",
|
"updateAllTools": "すべて更新({{count}})",
|
||||||
"currentVersion": "現在のバージョン",
|
"currentVersion": "現在のバージョン",
|
||||||
@@ -1012,7 +1012,7 @@
|
|||||||
},
|
},
|
||||||
"sessionManager": {
|
"sessionManager": {
|
||||||
"title": "セッション管理",
|
"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 で検索",
|
"searchPlaceholder": "内容・ディレクトリ・ID で検索",
|
||||||
"searchSessions": "セッションを検索",
|
"searchSessions": "セッションを検索",
|
||||||
"providerFilterAll": "すべて",
|
"providerFilterAll": "すべて",
|
||||||
|
|||||||
@@ -799,7 +799,7 @@
|
|||||||
"github": "GitHub",
|
"github": "GitHub",
|
||||||
"manualInstallCommands": "手動安裝指令",
|
"manualInstallCommands": "手動安裝指令",
|
||||||
"oneClickInstall": "一鍵安裝",
|
"oneClickInstall": "一鍵安裝",
|
||||||
"oneClickInstallHint": "安裝或更新 Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes",
|
"oneClickInstallHint": "安裝或更新 Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes / Pi",
|
||||||
"localEnvCheck": "本地環境檢查",
|
"localEnvCheck": "本地環境檢查",
|
||||||
"updateAllTools": "全部更新({{count}})",
|
"updateAllTools": "全部更新({{count}})",
|
||||||
"currentVersion": "目前版本",
|
"currentVersion": "目前版本",
|
||||||
@@ -1013,7 +1013,7 @@
|
|||||||
},
|
},
|
||||||
"sessionManager": {
|
"sessionManager": {
|
||||||
"title": "工作階段管理",
|
"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",
|
"searchPlaceholder": "搜尋對話內容、目錄或 ID",
|
||||||
"searchSessions": "搜尋工作階段",
|
"searchSessions": "搜尋工作階段",
|
||||||
"providerFilterAll": "全部",
|
"providerFilterAll": "全部",
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ const piReference = new Map(
|
|||||||
piKeysOutsideNamespace.has(key),
|
piKeysOutsideNamespace.has(key),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const piProductReferences = new Map(
|
||||||
|
[...reference].filter(([, value]) => /\bPi\b/.test(value)),
|
||||||
|
);
|
||||||
const locales = [
|
const locales = [
|
||||||
["zh", zh],
|
["zh", zh],
|
||||||
["ja", ja],
|
["ja", ja],
|
||||||
@@ -81,4 +84,17 @@ describe("locale coverage", () => {
|
|||||||
expect(mismatched).toEqual([]);
|
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([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user