mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
fix(pi): localize UI and isolate credential health
This commit is contained in:
@@ -111,8 +111,7 @@ pub(crate) async fn handle_pi_native(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut saw_health_failure = false;
|
let mut provider_health_failure = None;
|
||||||
let mut reached_network = false;
|
|
||||||
for candidate in attempts[index..provider_end].iter().cloned() {
|
for candidate in attempts[index..provider_end].iter().cloned() {
|
||||||
if !network_budget.has_remaining() {
|
if !network_budget.has_remaining() {
|
||||||
break;
|
break;
|
||||||
@@ -155,16 +154,12 @@ pub(crate) async fn handle_pi_native(
|
|||||||
}
|
}
|
||||||
if let Some(pending) = pending_retryable.take() {
|
if let Some(pending) = pending_retryable.take() {
|
||||||
if pending.provider_id != provider_id {
|
if pending.provider_id != provider_id {
|
||||||
record_provider_result(
|
settle_provider_health(
|
||||||
&state,
|
&state,
|
||||||
route.catalog_epoch,
|
route.catalog_epoch,
|
||||||
&pending.provider_id,
|
&pending.provider_id,
|
||||||
pending.used_half_open_permit,
|
pending.used_half_open_permit,
|
||||||
false,
|
pending.provider_health.clone(),
|
||||||
Some(format!(
|
|
||||||
"Pi upstream returned retryable status {}",
|
|
||||||
pending.response.status()
|
|
||||||
)),
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
@@ -182,7 +177,6 @@ pub(crate) async fn handle_pi_native(
|
|||||||
.headers(outgoing_headers)
|
.headers(outgoing_headers)
|
||||||
.body(body.clone())
|
.body(body.clone())
|
||||||
.send();
|
.send();
|
||||||
reached_network = true;
|
|
||||||
let response = match if timeout_seconds > 0 {
|
let response = match if timeout_seconds > 0 {
|
||||||
tokio::time::timeout(Duration::from_secs(u64::from(timeout_seconds)), send)
|
tokio::time::timeout(Duration::from_secs(u64::from(timeout_seconds)), send)
|
||||||
.await
|
.await
|
||||||
@@ -192,25 +186,31 @@ pub(crate) async fn handle_pi_native(
|
|||||||
} {
|
} {
|
||||||
Ok(Ok(response)) => response,
|
Ok(Ok(response)) => response,
|
||||||
Ok(Err(error)) => {
|
Ok(Err(error)) => {
|
||||||
saw_health_failure = true;
|
let error = if error.is_timeout() {
|
||||||
last_error = Some(if error.is_timeout() {
|
|
||||||
"Pi upstream request timed out".to_string()
|
"Pi upstream request timed out".to_string()
|
||||||
} else {
|
} else {
|
||||||
"Pi upstream request failed before response".to_string()
|
"Pi upstream request failed before response".to_string()
|
||||||
});
|
};
|
||||||
|
provider_health_failure = Some(error.clone());
|
||||||
|
last_error = Some(error);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Err(()) => {
|
Err(()) => {
|
||||||
saw_health_failure = true;
|
let error = "Pi upstream response-header timeout".to_string();
|
||||||
last_error = Some("Pi upstream response-header timeout".to_string());
|
provider_health_failure = Some(error.clone());
|
||||||
|
last_error = Some(error);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
if retryable_status(status) && network_budget.has_remaining() {
|
if retryable_status(status) && network_budget.has_remaining() {
|
||||||
saw_health_failure = true;
|
let error = format!("Pi upstream returned retryable status {status}");
|
||||||
last_error = Some(format!("Pi upstream returned retryable status {status}"));
|
let status_health = provider_health_disposition(status);
|
||||||
|
if status_health == ProviderHealthDisposition::Unhealthy {
|
||||||
|
provider_health_failure = Some(error.clone());
|
||||||
|
}
|
||||||
|
last_error = Some(error);
|
||||||
let selected_is_failover = materialized.is_failover;
|
let selected_is_failover = materialized.is_failover;
|
||||||
pending_retryable = Some(PendingRetryableResponse {
|
pending_retryable = Some(PendingRetryableResponse {
|
||||||
response,
|
response,
|
||||||
@@ -218,10 +218,16 @@ pub(crate) async fn handle_pi_native(
|
|||||||
provider_id: provider_id.clone(),
|
provider_id: provider_id.clone(),
|
||||||
used_half_open_permit: permit.used_half_open_permit,
|
used_half_open_permit: permit.used_half_open_permit,
|
||||||
selected_is_failover,
|
selected_is_failover,
|
||||||
|
provider_health: ProviderHealthOutcome::from_status(
|
||||||
|
status,
|
||||||
|
provider_health_failure.as_deref(),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let selected_is_failover = materialized.is_failover;
|
let selected_is_failover = materialized.is_failover;
|
||||||
|
let provider_health =
|
||||||
|
ProviderHealthOutcome::from_status(status, provider_health_failure.as_deref());
|
||||||
match prepare_response(
|
match prepare_response(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
response,
|
response,
|
||||||
@@ -236,19 +242,18 @@ pub(crate) async fn handle_pi_native(
|
|||||||
route.app_config.non_streaming_timeout,
|
route.app_config.non_streaming_timeout,
|
||||||
permit.used_half_open_permit,
|
permit.used_half_open_permit,
|
||||||
selected_is_failover,
|
selected_is_failover,
|
||||||
|
provider_health.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(prepared) => {
|
Ok(prepared) => {
|
||||||
if !prepared.finalization_deferred {
|
if !prepared.finalization_deferred {
|
||||||
let health_success = !retryable_status(status);
|
settle_provider_health(
|
||||||
record_provider_result(
|
|
||||||
&state,
|
&state,
|
||||||
route.catalog_epoch,
|
route.catalog_epoch,
|
||||||
&provider_id,
|
&provider_id,
|
||||||
permit.used_half_open_permit,
|
permit.used_half_open_permit,
|
||||||
health_success,
|
provider_health,
|
||||||
(!health_success).then(|| format!("Pi upstream returned {status}")),
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
record_request_finish(
|
record_request_finish(
|
||||||
@@ -263,7 +268,7 @@ pub(crate) async fn handle_pi_native(
|
|||||||
return Ok(prepared.response);
|
return Ok(prepared.response);
|
||||||
}
|
}
|
||||||
Err(ProxyError::ForwardFailed(error)) | Err(ProxyError::Timeout(error)) => {
|
Err(ProxyError::ForwardFailed(error)) | Err(ProxyError::Timeout(error)) => {
|
||||||
saw_health_failure = true;
|
provider_health_failure = Some(error.clone());
|
||||||
last_error = Some(error);
|
last_error = Some(error);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -273,9 +278,7 @@ pub(crate) async fn handle_pi_native(
|
|||||||
route.catalog_epoch,
|
route.catalog_epoch,
|
||||||
&provider_id,
|
&provider_id,
|
||||||
permit.used_half_open_permit,
|
permit.used_half_open_permit,
|
||||||
reached_network,
|
provider_health_failure.clone(),
|
||||||
saw_health_failure,
|
|
||||||
Some(error.to_string()),
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
record_request_finish(&state, false, false, Some(error.to_string())).await;
|
record_request_finish(&state, false, false, Some(error.to_string())).await;
|
||||||
@@ -293,9 +296,7 @@ pub(crate) async fn handle_pi_native(
|
|||||||
route.catalog_epoch,
|
route.catalog_epoch,
|
||||||
&provider_id,
|
&provider_id,
|
||||||
permit.used_half_open_permit,
|
permit.used_half_open_permit,
|
||||||
reached_network,
|
provider_health_failure,
|
||||||
saw_health_failure,
|
|
||||||
last_error.clone(),
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -307,6 +308,7 @@ pub(crate) async fn handle_pi_native(
|
|||||||
let provider_id = pending.provider_id.clone();
|
let provider_id = pending.provider_id.clone();
|
||||||
let used_half_open_permit = pending.used_half_open_permit;
|
let used_half_open_permit = pending.used_half_open_permit;
|
||||||
let selected_is_failover = pending.selected_is_failover;
|
let selected_is_failover = pending.selected_is_failover;
|
||||||
|
let provider_health = pending.provider_health;
|
||||||
match prepare_response(
|
match prepare_response(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
pending.response,
|
pending.response,
|
||||||
@@ -321,18 +323,18 @@ pub(crate) async fn handle_pi_native(
|
|||||||
route.app_config.non_streaming_timeout,
|
route.app_config.non_streaming_timeout,
|
||||||
used_half_open_permit,
|
used_half_open_permit,
|
||||||
selected_is_failover,
|
selected_is_failover,
|
||||||
|
provider_health.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(prepared) => {
|
Ok(prepared) => {
|
||||||
if !prepared.finalization_deferred {
|
if !prepared.finalization_deferred {
|
||||||
record_provider_result(
|
settle_provider_health(
|
||||||
&state,
|
&state,
|
||||||
route.catalog_epoch,
|
route.catalog_epoch,
|
||||||
&provider_id,
|
&provider_id,
|
||||||
used_half_open_permit,
|
used_half_open_permit,
|
||||||
false,
|
provider_health,
|
||||||
Some(format!("Pi upstream returned {status}")),
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
record_request_finish(
|
record_request_finish(
|
||||||
@@ -374,6 +376,7 @@ struct PendingRetryableResponse {
|
|||||||
provider_id: String,
|
provider_id: String,
|
||||||
used_half_open_permit: bool,
|
used_half_open_permit: bool,
|
||||||
selected_is_failover: bool,
|
selected_is_failover: bool,
|
||||||
|
provider_health: ProviderHealthOutcome,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -497,24 +500,114 @@ async fn record_provider_result(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ProviderHealthDisposition {
|
||||||
|
Healthy,
|
||||||
|
Unhealthy,
|
||||||
|
Neutral,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct ProviderHealthOutcome {
|
||||||
|
disposition: ProviderHealthDisposition,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderHealthOutcome {
|
||||||
|
fn from_status(status: StatusCode, prior_failure: Option<&str>) -> Self {
|
||||||
|
match provider_health_disposition(status) {
|
||||||
|
ProviderHealthDisposition::Healthy => Self {
|
||||||
|
disposition: ProviderHealthDisposition::Healthy,
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
ProviderHealthDisposition::Unhealthy => Self {
|
||||||
|
disposition: ProviderHealthDisposition::Unhealthy,
|
||||||
|
error: Some(format!("Pi upstream returned {status}")),
|
||||||
|
},
|
||||||
|
ProviderHealthDisposition::Neutral => prior_failure.map_or_else(
|
||||||
|
|| Self {
|
||||||
|
disposition: ProviderHealthDisposition::Neutral,
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
|error| Self {
|
||||||
|
// A credential rejection is neutral by itself, but it
|
||||||
|
// cannot erase a real failure from an earlier endpoint
|
||||||
|
// covered by the same provider-level permit.
|
||||||
|
disposition: ProviderHealthDisposition::Unhealthy,
|
||||||
|
error: Some(error.to_string()),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
provider_id: &str,
|
||||||
|
used_half_open_permit: bool,
|
||||||
|
outcome: ProviderHealthOutcome,
|
||||||
|
) {
|
||||||
|
match outcome.disposition {
|
||||||
|
ProviderHealthDisposition::Healthy => {
|
||||||
|
record_provider_result(
|
||||||
|
state,
|
||||||
|
catalog_epoch,
|
||||||
|
provider_id,
|
||||||
|
used_half_open_permit,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
ProviderHealthDisposition::Unhealthy => {
|
||||||
|
record_provider_result(
|
||||||
|
state,
|
||||||
|
catalog_epoch,
|
||||||
|
provider_id,
|
||||||
|
used_half_open_permit,
|
||||||
|
false,
|
||||||
|
outcome.error,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
ProviderHealthDisposition::Neutral => {
|
||||||
|
state
|
||||||
|
.provider_router
|
||||||
|
.release_permit_neutral(provider_id, "pi", used_half_open_permit)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn release_or_record_provider(
|
async fn release_or_record_provider(
|
||||||
state: &ProxyState,
|
state: &ProxyState,
|
||||||
catalog_epoch: u64,
|
catalog_epoch: u64,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
used_half_open_permit: bool,
|
used_half_open_permit: bool,
|
||||||
reached_network: bool,
|
provider_health_failure: Option<String>,
|
||||||
saw_health_failure: bool,
|
|
||||||
error: Option<String>,
|
|
||||||
) {
|
) {
|
||||||
if reached_network && saw_health_failure {
|
if provider_health_failure.is_some() {
|
||||||
record_provider_result(
|
record_provider_result(
|
||||||
state,
|
state,
|
||||||
catalog_epoch,
|
catalog_epoch,
|
||||||
provider_id,
|
provider_id,
|
||||||
used_half_open_permit,
|
used_half_open_permit,
|
||||||
false,
|
false,
|
||||||
error,
|
provider_health_failure,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
@@ -714,6 +807,7 @@ struct PiStreamFinalization {
|
|||||||
content_is_sse: bool,
|
content_is_sse: bool,
|
||||||
used_half_open_permit: bool,
|
used_half_open_permit: bool,
|
||||||
selected_is_failover: bool,
|
selected_is_failover: bool,
|
||||||
|
complete_provider_health: ProviderHealthOutcome,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PiStreamTermination {
|
enum PiStreamTermination {
|
||||||
@@ -723,7 +817,7 @@ enum PiStreamTermination {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct PiStreamDisposition {
|
struct PiStreamDisposition {
|
||||||
provider_success: Option<bool>,
|
provider_health: ProviderHealthDisposition,
|
||||||
provider_error: Option<String>,
|
provider_error: Option<String>,
|
||||||
request_success: bool,
|
request_success: bool,
|
||||||
request_error: Option<String>,
|
request_error: Option<String>,
|
||||||
@@ -731,29 +825,25 @@ struct PiStreamDisposition {
|
|||||||
|
|
||||||
fn pi_stream_disposition(
|
fn pi_stream_disposition(
|
||||||
status: StatusCode,
|
status: StatusCode,
|
||||||
|
complete_provider_health: &ProviderHealthOutcome,
|
||||||
termination: &PiStreamTermination,
|
termination: &PiStreamTermination,
|
||||||
) -> PiStreamDisposition {
|
) -> PiStreamDisposition {
|
||||||
match termination {
|
match termination {
|
||||||
PiStreamTermination::Complete { .. } => {
|
PiStreamTermination::Complete { .. } => PiStreamDisposition {
|
||||||
let provider_success = !retryable_status(status);
|
provider_health: complete_provider_health.disposition,
|
||||||
PiStreamDisposition {
|
provider_error: complete_provider_health.error.clone(),
|
||||||
provider_success: Some(provider_success),
|
request_success: status.is_success(),
|
||||||
provider_error: (!provider_success)
|
request_error: (!status.is_success()).then(|| format!("Pi upstream returned {status}")),
|
||||||
.then(|| format!("Pi upstream returned {status}")),
|
},
|
||||||
request_success: status.is_success(),
|
|
||||||
request_error: (!status.is_success())
|
|
||||||
.then(|| format!("Pi upstream returned {status}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PiStreamTermination::UpstreamFailure { message } => PiStreamDisposition {
|
PiStreamTermination::UpstreamFailure { message } => PiStreamDisposition {
|
||||||
provider_success: Some(false),
|
provider_health: ProviderHealthDisposition::Unhealthy,
|
||||||
provider_error: Some(message.clone()),
|
provider_error: Some(message.clone()),
|
||||||
request_success: false,
|
request_success: false,
|
||||||
request_error: Some(message.clone()),
|
request_error: Some(message.clone()),
|
||||||
},
|
},
|
||||||
PiStreamTermination::DownstreamDropped => PiStreamDisposition {
|
PiStreamTermination::DownstreamDropped => PiStreamDisposition {
|
||||||
// A downstream cancellation says nothing about upstream health.
|
// A downstream cancellation says nothing about upstream health.
|
||||||
provider_success: None,
|
provider_health: ProviderHealthDisposition::Neutral,
|
||||||
provider_error: None,
|
provider_error: None,
|
||||||
request_success: false,
|
request_success: false,
|
||||||
request_error: Some(
|
request_error: Some(
|
||||||
@@ -801,27 +891,19 @@ impl PiStreamFinalization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn apply(self, termination: PiStreamTermination) {
|
async fn apply(self, termination: PiStreamTermination) {
|
||||||
let disposition = pi_stream_disposition(self.status, &termination);
|
let disposition =
|
||||||
if let Some(provider_success) = disposition.provider_success {
|
pi_stream_disposition(self.status, &self.complete_provider_health, &termination);
|
||||||
record_provider_result(
|
settle_provider_health(
|
||||||
&self.state,
|
&self.state,
|
||||||
self.catalog_epoch,
|
self.catalog_epoch,
|
||||||
&self.candidate.provider_id,
|
&self.candidate.provider_id,
|
||||||
self.used_half_open_permit,
|
self.used_half_open_permit,
|
||||||
provider_success,
|
ProviderHealthOutcome {
|
||||||
disposition.provider_error,
|
disposition: disposition.provider_health,
|
||||||
)
|
error: disposition.provider_error,
|
||||||
.await;
|
},
|
||||||
} else {
|
)
|
||||||
self.state
|
.await;
|
||||||
.provider_router
|
|
||||||
.release_permit_neutral(
|
|
||||||
&self.candidate.provider_id,
|
|
||||||
"pi",
|
|
||||||
self.used_half_open_permit,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
record_request_finish(
|
record_request_finish(
|
||||||
&self.state,
|
&self.state,
|
||||||
disposition.request_success,
|
disposition.request_success,
|
||||||
@@ -921,6 +1003,7 @@ async fn prepare_response(
|
|||||||
non_streaming_timeout_seconds: u32,
|
non_streaming_timeout_seconds: u32,
|
||||||
used_half_open_permit: bool,
|
used_half_open_permit: bool,
|
||||||
selected_is_failover: bool,
|
selected_is_failover: bool,
|
||||||
|
complete_provider_health: ProviderHealthOutcome,
|
||||||
) -> Result<PreparedPiResponse, ProxyError> {
|
) -> Result<PreparedPiResponse, ProxyError> {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let headers = filtered_response_headers(response.headers());
|
let headers = filtered_response_headers(response.headers());
|
||||||
@@ -1019,6 +1102,7 @@ async fn prepare_response(
|
|||||||
streaming_idle_timeout_seconds,
|
streaming_idle_timeout_seconds,
|
||||||
used_half_open_permit,
|
used_half_open_permit,
|
||||||
selected_is_failover,
|
selected_is_failover,
|
||||||
|
complete_provider_health,
|
||||||
);
|
);
|
||||||
let mut builder = Response::builder().status(status);
|
let mut builder = Response::builder().status(status);
|
||||||
*builder
|
*builder
|
||||||
@@ -1138,6 +1222,7 @@ fn logged_body_stream(
|
|||||||
streaming_idle_timeout_seconds: u32,
|
streaming_idle_timeout_seconds: u32,
|
||||||
used_half_open_permit: bool,
|
used_half_open_permit: bool,
|
||||||
selected_is_failover: bool,
|
selected_is_failover: bool,
|
||||||
|
complete_provider_health: ProviderHealthOutcome,
|
||||||
) -> impl futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static {
|
) -> impl futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static {
|
||||||
// Construct the guard before the generator is polled. Axum may drop a
|
// Construct the guard before the generator is polled. Axum may drop a
|
||||||
// response body without ever polling it when the client disconnects after
|
// response body without ever polling it when the client disconnects after
|
||||||
@@ -1154,6 +1239,7 @@ fn logged_body_stream(
|
|||||||
content_is_sse,
|
content_is_sse,
|
||||||
used_half_open_permit,
|
used_half_open_permit,
|
||||||
selected_is_failover,
|
selected_is_failover,
|
||||||
|
complete_provider_health,
|
||||||
});
|
});
|
||||||
async_stream::stream! {
|
async_stream::stream! {
|
||||||
let mut captured = Vec::new();
|
let mut captured = Vec::new();
|
||||||
@@ -1358,6 +1444,57 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn credential_rejections_remain_retryable_but_health_neutral() {
|
||||||
|
let app = axum::Router::new()
|
||||||
|
.route(
|
||||||
|
"/unauthorized",
|
||||||
|
axum::routing::get(|| async { StatusCode::UNAUTHORIZED }),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/forbidden",
|
||||||
|
axum::routing::get(|| async { StatusCode::FORBIDDEN }),
|
||||||
|
);
|
||||||
|
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||||
|
.await
|
||||||
|
.expect("bind local Pi capture endpoint");
|
||||||
|
let address = listener.local_addr().expect("local capture address");
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.await
|
||||||
|
.expect("serve local Pi capture endpoint");
|
||||||
|
});
|
||||||
|
|
||||||
|
for (path, expected) in [
|
||||||
|
("unauthorized", StatusCode::UNAUTHORIZED),
|
||||||
|
("forbidden", StatusCode::FORBIDDEN),
|
||||||
|
] {
|
||||||
|
let response = reqwest::get(format!("http://{address}/{path}"))
|
||||||
|
.await
|
||||||
|
.expect("request local Pi capture endpoint");
|
||||||
|
assert_eq!(response.status(), expected);
|
||||||
|
assert!(retryable_status(response.status()));
|
||||||
|
assert_eq!(
|
||||||
|
ProviderHealthOutcome::from_status(response.status(), None),
|
||||||
|
ProviderHealthOutcome {
|
||||||
|
disposition: ProviderHealthDisposition::Neutral,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
ProviderHealthOutcome::from_status(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Some("earlier endpoint failed"),
|
||||||
|
),
|
||||||
|
ProviderHealthOutcome {
|
||||||
|
disposition: ProviderHealthDisposition::Unhealthy,
|
||||||
|
error: Some("earlier endpoint failed".to_string()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
server.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sse_comments_do_not_cross_the_commit_fence() {
|
fn sse_comments_do_not_cross_the_commit_fence() {
|
||||||
assert!(!contains_semantic_sse_event(
|
assert!(!contains_semantic_sse_event(
|
||||||
@@ -1497,33 +1634,61 @@ mod tests {
|
|||||||
fn streaming_health_waits_for_the_terminal_outcome() {
|
fn streaming_health_waits_for_the_terminal_outcome() {
|
||||||
let complete = pi_stream_disposition(
|
let complete = pi_stream_disposition(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
|
&ProviderHealthOutcome::from_status(StatusCode::OK, None),
|
||||||
&PiStreamTermination::Complete {
|
&PiStreamTermination::Complete {
|
||||||
captured: Some(Vec::new()),
|
captured: Some(Vec::new()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert_eq!(complete.provider_success, Some(true));
|
assert_eq!(complete.provider_health, ProviderHealthDisposition::Healthy);
|
||||||
assert!(complete.request_success);
|
assert!(complete.request_success);
|
||||||
|
|
||||||
let truncated = pi_stream_disposition(
|
let truncated = pi_stream_disposition(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
|
&ProviderHealthOutcome::from_status(StatusCode::OK, None),
|
||||||
&PiStreamTermination::UpstreamFailure {
|
&PiStreamTermination::UpstreamFailure {
|
||||||
message: "truncated".to_string(),
|
message: "truncated".to_string(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert_eq!(truncated.provider_success, Some(false));
|
assert_eq!(
|
||||||
|
truncated.provider_health,
|
||||||
|
ProviderHealthDisposition::Unhealthy
|
||||||
|
);
|
||||||
assert!(!truncated.request_success);
|
assert!(!truncated.request_success);
|
||||||
assert_eq!(truncated.provider_error.as_deref(), Some("truncated"));
|
assert_eq!(truncated.provider_error.as_deref(), Some("truncated"));
|
||||||
|
|
||||||
let client_drop =
|
let client_drop = pi_stream_disposition(
|
||||||
pi_stream_disposition(StatusCode::OK, &PiStreamTermination::DownstreamDropped);
|
StatusCode::OK,
|
||||||
assert_eq!(client_drop.provider_success, None);
|
&ProviderHealthOutcome::from_status(StatusCode::OK, None),
|
||||||
|
&PiStreamTermination::DownstreamDropped,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client_drop.provider_health,
|
||||||
|
ProviderHealthDisposition::Neutral
|
||||||
|
);
|
||||||
assert!(!client_drop.request_success);
|
assert!(!client_drop.request_success);
|
||||||
|
|
||||||
let non_retryable_error = pi_stream_disposition(
|
let non_retryable_error = pi_stream_disposition(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
|
&ProviderHealthOutcome::from_status(StatusCode::BAD_REQUEST, None),
|
||||||
&PiStreamTermination::Complete { captured: None },
|
&PiStreamTermination::Complete { captured: None },
|
||||||
);
|
);
|
||||||
assert_eq!(non_retryable_error.provider_success, Some(true));
|
assert_eq!(
|
||||||
|
non_retryable_error.provider_health,
|
||||||
|
ProviderHealthDisposition::Healthy
|
||||||
|
);
|
||||||
assert!(!non_retryable_error.request_success);
|
assert!(!non_retryable_error.request_success);
|
||||||
|
|
||||||
|
for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] {
|
||||||
|
let credential_rejection = pi_stream_disposition(
|
||||||
|
status,
|
||||||
|
&ProviderHealthOutcome::from_status(status, None),
|
||||||
|
&PiStreamTermination::Complete { captured: None },
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
credential_rejection.provider_health,
|
||||||
|
ProviderHealthDisposition::Neutral
|
||||||
|
);
|
||||||
|
assert!(!credential_rejection.request_success);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+144
-2
@@ -431,6 +431,7 @@
|
|||||||
"themeSystem": "システム",
|
"themeSystem": "システム",
|
||||||
"importExport": "SQL インポート/エクスポート",
|
"importExport": "SQL インポート/エクスポート",
|
||||||
"importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします(インポートは CC Switch がエクスポートしたバックアップのみ対応)。",
|
"importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします(インポートは CC Switch がエクスポートしたバックアップのみ対応)。",
|
||||||
|
"piImportExportBoundary": "Pi のプロバイダーと管理設定は移行できます。ネイティブ models.json の所有権は、同じキーが空の場合にのみ再構築されます。ネイティブ指示ファイル、プロンプトファイル、Skill の配置、セッションは端末内に残り、インポートでは上書きされません。",
|
||||||
"exportConfig": "SQL バックアップをエクスポート",
|
"exportConfig": "SQL バックアップをエクスポート",
|
||||||
"selectConfigFile": "SQL ファイルを選択",
|
"selectConfigFile": "SQL ファイルを選択",
|
||||||
"noFileSelected": "ファイルが選択されていません。",
|
"noFileSelected": "ファイルが選択されていません。",
|
||||||
@@ -767,6 +768,8 @@
|
|||||||
"openclawConfigDirDescription": "OpenClaw の設定ディレクトリ(openclaw.json)を上書きします。",
|
"openclawConfigDirDescription": "OpenClaw の設定ディレクトリ(openclaw.json)を上書きします。",
|
||||||
"hermesConfigDir": "Hermes 設定ディレクトリ",
|
"hermesConfigDir": "Hermes 設定ディレクトリ",
|
||||||
"hermesConfigDirDescription": "Hermes の設定ディレクトリ(config.yaml)を上書きします。",
|
"hermesConfigDirDescription": "Hermes の設定ディレクトリ(config.yaml)を上書きします。",
|
||||||
|
"piConfigDir": "Pi 設定ディレクトリ",
|
||||||
|
"piConfigDirDescription": "Pi の設定ディレクトリ(models.json、指示ファイル、プロンプト、Skills、セッション)を上書きします。",
|
||||||
"browsePlaceholderClaude": "例: /home/<your-username>/.claude",
|
"browsePlaceholderClaude": "例: /home/<your-username>/.claude",
|
||||||
"browsePlaceholderCodex": "例: /home/<your-username>/.codex",
|
"browsePlaceholderCodex": "例: /home/<your-username>/.codex",
|
||||||
"browsePlaceholderGemini": "例: /home/<your-username>/.gemini",
|
"browsePlaceholderGemini": "例: /home/<your-username>/.gemini",
|
||||||
@@ -774,6 +777,7 @@
|
|||||||
"browsePlaceholderOpencode": "例: /home/<your-username>/.config/opencode",
|
"browsePlaceholderOpencode": "例: /home/<your-username>/.config/opencode",
|
||||||
"browsePlaceholderOpenclaw": "例: /home/<your-username>/.openclaw",
|
"browsePlaceholderOpenclaw": "例: /home/<your-username>/.openclaw",
|
||||||
"browsePlaceholderHermes": "例: /home/<your-username>/.hermes",
|
"browsePlaceholderHermes": "例: /home/<your-username>/.hermes",
|
||||||
|
"browsePlaceholderPi": "例: /home/<your-username>/.pi/agent",
|
||||||
"browseDirectory": "ディレクトリを選択",
|
"browseDirectory": "ディレクトリを選択",
|
||||||
"resetDefault": "デフォルトに戻す(保存後に反映)",
|
"resetDefault": "デフォルトに戻す(保存後に反映)",
|
||||||
"checkForUpdates": "アップデートを確認",
|
"checkForUpdates": "アップデートを確認",
|
||||||
@@ -884,7 +888,119 @@
|
|||||||
"grokbuild": "Grok Build",
|
"grokbuild": "Grok Build",
|
||||||
"opencode": "OpenCode",
|
"opencode": "OpenCode",
|
||||||
"openclaw": "OpenClaw",
|
"openclaw": "OpenClaw",
|
||||||
"hermes": "Hermes"
|
"hermes": "Hermes",
|
||||||
|
"pi": "Pi"
|
||||||
|
},
|
||||||
|
"pi": {
|
||||||
|
"form": {
|
||||||
|
"providerKey": "プロバイダーキー",
|
||||||
|
"providerKeyHint": "Pi が models.json で使用する固定キーです。作成後は変更できません。",
|
||||||
|
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||||
|
"displayName": "表示名",
|
||||||
|
"nameRequired": "表示名を入力してください",
|
||||||
|
"providerApi": "プロバイダー API",
|
||||||
|
"providerBaseUrl": "プロバイダーのベース URL",
|
||||||
|
"manageEndpoints": "フェイルオーバーエンドポイントを管理",
|
||||||
|
"credential": "認証情報",
|
||||||
|
"credentialHint": "Pi はリテラル、$ENV 参照、または !command 式を受け付けます。遅延値は Pi が解決した後にのみ検証されます。",
|
||||||
|
"website": "Web サイト",
|
||||||
|
"authHeader": "認証情報を Authorization として送信",
|
||||||
|
"authHeaderHint": "対応する API ファミリーでは Pi の authHeader 動作を使用します。Anthropic OAuth 認証情報は、検証済みのゲートウェイ転送方式で処理されます。",
|
||||||
|
"headers": "カスタムヘッダー(JSON)",
|
||||||
|
"headersStringValues": "カスタムヘッダーの値はすべて文字列で指定してください",
|
||||||
|
"jsonObjectRequired": "{{label}} は JSON オブジェクトで指定してください",
|
||||||
|
"nonFiniteNumber": "{{label}} に有限でない数値が含まれています",
|
||||||
|
"absoluteHttpUrlRequired": "{{label}} は絶対 HTTP または HTTPS URL で指定してください",
|
||||||
|
"models": "モデル",
|
||||||
|
"modelsHint": "各モデルはプロバイダーの API とベース URL を継承できます。モデルオブジェクト全体が models.json に保持されます。",
|
||||||
|
"addModel": "モデルを追加",
|
||||||
|
"modelNumber": "モデル {{index}}",
|
||||||
|
"removeModel": "モデルを削除",
|
||||||
|
"modelId": "モデル ID",
|
||||||
|
"modelIdRequired": "モデル {{index}} に ID が必要です",
|
||||||
|
"modelName": "表示名",
|
||||||
|
"modelApi": "モデル API",
|
||||||
|
"modelBaseUrl": "モデルのベース URL",
|
||||||
|
"modelBaseUrlFor": "モデル {{id}} のベース URL",
|
||||||
|
"inherit": "プロバイダーから継承",
|
||||||
|
"inheritanceHint": "上書きのないモデルは、このプロバイダー値を継承します。",
|
||||||
|
"effectiveApiRequired": "モデル {{id}} には、直接またはプロバイダーから継承した API が必要です",
|
||||||
|
"effectiveBaseUrlRequired": "モデル {{id}} には、直接またはプロバイダーから継承したベース URL が必要です",
|
||||||
|
"duplicateModel": "モデル ID {{id}} が重複しています",
|
||||||
|
"modelRequired": "モデルを 1 つ以上追加してください",
|
||||||
|
"modelAdditionalConfig": "モデル {{id}} の追加設定(JSON)",
|
||||||
|
"additionalConfig": "プロバイダーの追加設定(JSON)",
|
||||||
|
"additionalConfigHint": "未認識の Pi フィールドは保持されます。上に表示された管理対象フィールドが優先されます。"
|
||||||
|
},
|
||||||
|
"native": {
|
||||||
|
"title": "Pi ネイティブモデルカタログ",
|
||||||
|
"description": "認証済み分類器を通じて実際の Pi models.json を検査します。明示的な操作時のみインポートし、Pi のネイティブファイルを正とします。",
|
||||||
|
"empty": "Pi models.json にプロバイダーがありません。",
|
||||||
|
"defaultProvider": "デフォルトプロバイダー",
|
||||||
|
"defaultModel": "デフォルトモデル",
|
||||||
|
"setDefault": "デフォルトに設定",
|
||||||
|
"currentDefault": "現在のデフォルト",
|
||||||
|
"defaultSaved": "Pi のデフォルトモデルを更新しました",
|
||||||
|
"defaultSaveFailed": "Pi のデフォルトモデルを更新できませんでした",
|
||||||
|
"unmanagedDefault": "Pi は現在、管理対象外のプロバイダー {{provider}} / {{model}} を選択しています。インポートするか、管理対象のデフォルトを選択してください。",
|
||||||
|
"imported": "Pi プロバイダーをインポートしました",
|
||||||
|
"importFailed": "Pi プロバイダーをインポートできませんでした",
|
||||||
|
"managed": "管理対象",
|
||||||
|
"management": {
|
||||||
|
"importable": "インポート可能",
|
||||||
|
"managed": "管理対象",
|
||||||
|
"unsupported": "未対応"
|
||||||
|
},
|
||||||
|
"gateway": {
|
||||||
|
"proxyable": "ゲートウェイ利用可能",
|
||||||
|
"direct_only": "直接接続のみ",
|
||||||
|
"unknown": "不明"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"prompts": {
|
||||||
|
"nativeTitle": "Pi ネイティブプロンプトリソース",
|
||||||
|
"nativeDescription": "ファイルの存在が有効状態を表します。SYSTEM.md は Pi のシステムプロンプトを置換し、APPEND_SYSTEM.md は追記します。/template-name は prompts ディレクトリのファイルを展開します。",
|
||||||
|
"agentsLibrary": "AGENTS.md ライブラリ",
|
||||||
|
"agentsLibraryDescription": "有効なライブラリエントリは AGENTS.md に反映されます。外部変更が検出された場合は、切り替える前にインポートする必要があります。",
|
||||||
|
"libraryDriftTitle": "AGENTS.md とライブラリが一致しません",
|
||||||
|
"libraryDriftNative": "Pi が使用中のネイティブ AGENTS.md は、ライブラリに保存された選択内容と異なります。調整すると、実際の内容をそのままインポートして選択します。",
|
||||||
|
"libraryDriftMissing": "AGENTS.md がないため、ライブラリに有効な選択が残っていても Pi はグローバルプロンプトを使用していません。",
|
||||||
|
"reconcileLibrary": "調整",
|
||||||
|
"libraryReconciled": "AGENTS.md ライブラリを Pi のネイティブ状態と調整しました",
|
||||||
|
"libraryReconcileFailed": "AGENTS.md ライブラリを調整できませんでした",
|
||||||
|
"systemOverride": "システムプロンプトの置換",
|
||||||
|
"systemOverrideDescription": "SYSTEM.md が存在する間、Pi の組み込みシステムプロンプトを完全に置き換えます。",
|
||||||
|
"systemAppend": "システムプロンプトの追記",
|
||||||
|
"systemAppendDescription": "APPEND_SYSTEM.md が存在する間、Pi のシステムプロンプトに追記されます。",
|
||||||
|
"active": "有効",
|
||||||
|
"inactive": "無効",
|
||||||
|
"activateOverride": "置換を有効化",
|
||||||
|
"activateOverrideTitle": "{{filename}} を有効にしますか?",
|
||||||
|
"activateOverrideMessage": "{{filename}} を作成すると、Pi の組み込みシステムプロンプトが完全に置き換わります。意図した操作であることを確認してください。",
|
||||||
|
"deactivate": "無効化",
|
||||||
|
"deactivateTitle": "{{filename}} を無効にしますか?",
|
||||||
|
"deactivateMessage": "{{filename}} を削除します。Pi への適用は直ちに停止します。",
|
||||||
|
"instructionPlaceholder": "Pi が使用する Markdown 内容",
|
||||||
|
"blankInstruction": "空白以外の内容を入力するか、ファイルを無効化して削除してください。",
|
||||||
|
"loadFirst": "変更する前に現在の Pi ファイルを読み込んでください",
|
||||||
|
"fileSaved": "{{filename}} を保存しました",
|
||||||
|
"fileDeactivated": "{{filename}} を無効化しました",
|
||||||
|
"saveFailed": "Pi 指示ファイルを保存できませんでした",
|
||||||
|
"deleteFailed": "Pi 指示ファイルを無効化できませんでした",
|
||||||
|
"templates": "プロンプトテンプレート",
|
||||||
|
"templatesDescription": "prompts/<slug>.md の各ファイルは、Pi で /<slug> として呼び出せます。",
|
||||||
|
"newTemplate": "新しいプロンプトテンプレート",
|
||||||
|
"templateSlug": "テンプレートスラッグ(例: review)",
|
||||||
|
"templateContent": "テンプレート Markdown",
|
||||||
|
"createTemplate": "テンプレートを作成",
|
||||||
|
"templateCreated": "Pi プロンプトテンプレートを作成しました",
|
||||||
|
"templateSaved": "/{{slug}} を保存しました",
|
||||||
|
"templateDeleted": "/{{slug}} を削除しました",
|
||||||
|
"templateSaveFailed": "Pi プロンプトテンプレートを保存できませんでした",
|
||||||
|
"templateDeleteFailed": "Pi プロンプトテンプレートを削除できませんでした",
|
||||||
|
"deleteTemplateTitle": "/{{slug}} を削除しますか?",
|
||||||
|
"deleteTemplateMessage": "prompts/{{slug}}.md を完全に削除します。"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"grokBuild": {
|
"grokBuild": {
|
||||||
"apiBackend": "API Backend",
|
"apiBackend": "API Backend",
|
||||||
@@ -923,6 +1039,8 @@
|
|||||||
"batchDeleting": "削除中...",
|
"batchDeleting": "削除中...",
|
||||||
"loadingSessions": "セッションを読み込み中...",
|
"loadingSessions": "セッションを読み込み中...",
|
||||||
"noSessions": "セッションが見つかりません",
|
"noSessions": "セッションが見つかりません",
|
||||||
|
"piRelativeSessionDir": "Pi の sessionDir は Pi を起動したディレクトリからの相対パスであるため、グローバルブラウザーから安全に列挙できません。すべての Pi セッションをここで表示するには、sessionDir に絶対パスを設定してください:",
|
||||||
|
"piDiscoveryUnavailable": "Pi セッションを検査できませんでした: {{error}}",
|
||||||
"selectSession": "セッションを選択してください",
|
"selectSession": "セッションを選択してください",
|
||||||
"noSummary": "概要なし",
|
"noSummary": "概要なし",
|
||||||
"lastActive": "最終アクティブ",
|
"lastActive": "最終アクティブ",
|
||||||
@@ -1503,7 +1621,8 @@
|
|||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
"gemini": "Gemini",
|
"gemini": "Gemini",
|
||||||
"opencode": "OpenCode",
|
"opencode": "OpenCode",
|
||||||
"grokbuild": "Grok Build"
|
"grokbuild": "Grok Build",
|
||||||
|
"pi": "Pi"
|
||||||
},
|
},
|
||||||
"rawInputLabel": "原始",
|
"rawInputLabel": "原始",
|
||||||
"rebuildCodex": {
|
"rebuildCodex": {
|
||||||
@@ -2377,6 +2496,18 @@
|
|||||||
"importSelected": "選択をインポート ({{count}})",
|
"importSelected": "選択をインポート ({{count}})",
|
||||||
"noUnmanagedFound": "インポートするスキルが見つかりませんでした。すべてのスキルは CC Switch で管理されています。",
|
"noUnmanagedFound": "インポートするスキルが見つかりませんでした。すべてのスキルは CC Switch で管理されています。",
|
||||||
"unmanagedAvailable": "インポート可能なスキルがあります",
|
"unmanagedAvailable": "インポート可能なスキルがあります",
|
||||||
|
"piStatus": {
|
||||||
|
"active": "有効",
|
||||||
|
"inactive": "無効",
|
||||||
|
"unmanagedActive": "有効(管理対象外)",
|
||||||
|
"desiredButMissing": "有効化済み(Pi で未検出)",
|
||||||
|
"foreignConflict": "管理対象外の配置先によりブロック",
|
||||||
|
"staleDeployment": "管理対象の配置が外部で変更されました",
|
||||||
|
"shadowed": "同名の別の Skill により隠されています",
|
||||||
|
"invalid": "Skill マニフェストが無効です",
|
||||||
|
"inspecting": "検査中...",
|
||||||
|
"inspectionUnavailable": "実際の状態を検査できません"
|
||||||
|
},
|
||||||
"foundIn": "発見場所",
|
"foundIn": "発見場所",
|
||||||
"local": "ローカル",
|
"local": "ローカル",
|
||||||
"uninstallConfirm": "「{{name}}」をアンインストールしますか?すべてのアプリからこのスキルが削除され、削除前にローカルバックアップが自動作成されます。",
|
"uninstallConfirm": "「{{name}}」をアンインストールしますか?すべてのアプリからこのスキルが削除され、削除前にローカルバックアップが自動作成されます。",
|
||||||
@@ -2440,6 +2571,7 @@
|
|||||||
"homepage": "ホームページ",
|
"homepage": "ホームページ",
|
||||||
"endpoint": "API エンドポイント",
|
"endpoint": "API エンドポイント",
|
||||||
"apiKey": "API Key",
|
"apiKey": "API Key",
|
||||||
|
"api": "ネイティブ API プロトコル",
|
||||||
"icon": "アイコン",
|
"icon": "アイコン",
|
||||||
"model": "モデル",
|
"model": "モデル",
|
||||||
"haikuModel": "Haiku モデル",
|
"haikuModel": "Haiku モデル",
|
||||||
@@ -2553,6 +2685,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"proxy": {
|
"proxy": {
|
||||||
|
"piGateway": {
|
||||||
|
"title": "Pi ゲートウェイ認証情報",
|
||||||
|
"description": "Pi とこのローカルゲートウェイ間でのみ使用する端末固有の認証情報をローテーションします。",
|
||||||
|
"rotate": "認証情報をローテーション",
|
||||||
|
"confirmTitle": "Pi ゲートウェイ認証情報をローテーションしますか?",
|
||||||
|
"confirmMessage": "現在のローカル認証情報は直ちに無効になります。実行中の Pi プロセスは投影済みの認証情報をメモリに保持しているため、再起動が必要です。",
|
||||||
|
"rotateSuccess": "Pi ゲートウェイ認証情報をローテーションしました",
|
||||||
|
"rotateFailed": "Pi ゲートウェイ認証情報をローテーションできませんでした",
|
||||||
|
"restartNotice": "次のリクエストを送信する前に、実行中のすべての Pi プロセスを再起動してください。"
|
||||||
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"serviceAddress": "サービスアドレス",
|
"serviceAddress": "サービスアドレス",
|
||||||
"addressCopied": "アドレスをコピーしました",
|
"addressCopied": "アドレスをコピーしました",
|
||||||
|
|||||||
+144
-2
@@ -431,6 +431,7 @@
|
|||||||
"themeSystem": "跟隨系統",
|
"themeSystem": "跟隨系統",
|
||||||
"importExport": "SQL 匯入匯出",
|
"importExport": "SQL 匯入匯出",
|
||||||
"importExportHint": "匯入/匯出資料庫 SQL 備份(僅支援匯入由 CC Switch 匯出的備份),便於備份或遷移。",
|
"importExportHint": "匯入/匯出資料庫 SQL 備份(僅支援匯入由 CC Switch 匯出的備份),便於備份或遷移。",
|
||||||
|
"piImportExportBoundary": "Pi 供應商與受管設定可移轉;僅在相同原生 key 為空時重建 models.json 所有權。原生指示檔案、提示檔案、Skill 部署與工作階段都保留在本機,匯入時絕不覆寫。",
|
||||||
"exportConfig": "匯出 SQL 備份",
|
"exportConfig": "匯出 SQL 備份",
|
||||||
"selectConfigFile": "選擇 SQL 檔案",
|
"selectConfigFile": "選擇 SQL 檔案",
|
||||||
"noFileSelected": "尚未選擇設定檔。",
|
"noFileSelected": "尚未選擇設定檔。",
|
||||||
@@ -767,6 +768,8 @@
|
|||||||
"openclawConfigDirDescription": "覆寫 OpenClaw 設定目錄 (openclaw.json)。",
|
"openclawConfigDirDescription": "覆寫 OpenClaw 設定目錄 (openclaw.json)。",
|
||||||
"hermesConfigDir": "Hermes 設定目錄",
|
"hermesConfigDir": "Hermes 設定目錄",
|
||||||
"hermesConfigDirDescription": "覆寫 Hermes 設定目錄 (config.yaml)。",
|
"hermesConfigDirDescription": "覆寫 Hermes 設定目錄 (config.yaml)。",
|
||||||
|
"piConfigDir": "Pi 設定目錄",
|
||||||
|
"piConfigDirDescription": "覆寫 Pi 設定目錄(models.json、指示檔案、提示、Skills 與工作階段)。",
|
||||||
"browsePlaceholderClaude": "例如:/home/<您的帳號>/.claude",
|
"browsePlaceholderClaude": "例如:/home/<您的帳號>/.claude",
|
||||||
"browsePlaceholderCodex": "例如:/home/<您的帳號>/.codex",
|
"browsePlaceholderCodex": "例如:/home/<您的帳號>/.codex",
|
||||||
"browsePlaceholderGemini": "例如:/home/<您的帳號>/.gemini",
|
"browsePlaceholderGemini": "例如:/home/<您的帳號>/.gemini",
|
||||||
@@ -774,6 +777,7 @@
|
|||||||
"browsePlaceholderOpencode": "例如:/home/<您的帳號>/.config/opencode",
|
"browsePlaceholderOpencode": "例如:/home/<您的帳號>/.config/opencode",
|
||||||
"browsePlaceholderOpenclaw": "例如:/home/<您的帳號>/.openclaw",
|
"browsePlaceholderOpenclaw": "例如:/home/<您的帳號>/.openclaw",
|
||||||
"browsePlaceholderHermes": "例如:/home/<您的帳號>/.hermes",
|
"browsePlaceholderHermes": "例如:/home/<您的帳號>/.hermes",
|
||||||
|
"browsePlaceholderPi": "例如:/home/<您的帳號>/.pi/agent",
|
||||||
"browseDirectory": "瀏覽目錄",
|
"browseDirectory": "瀏覽目錄",
|
||||||
"resetDefault": "還原預設目錄(需儲存後生效)",
|
"resetDefault": "還原預設目錄(需儲存後生效)",
|
||||||
"checkForUpdates": "檢查更新",
|
"checkForUpdates": "檢查更新",
|
||||||
@@ -885,7 +889,119 @@
|
|||||||
"grokbuild": "Grok Build",
|
"grokbuild": "Grok Build",
|
||||||
"opencode": "OpenCode",
|
"opencode": "OpenCode",
|
||||||
"openclaw": "OpenClaw",
|
"openclaw": "OpenClaw",
|
||||||
"hermes": "Hermes"
|
"hermes": "Hermes",
|
||||||
|
"pi": "Pi"
|
||||||
|
},
|
||||||
|
"pi": {
|
||||||
|
"form": {
|
||||||
|
"providerKey": "供應商識別碼",
|
||||||
|
"providerKeyHint": "Pi 在 models.json 中使用的固定識別碼,建立後不可修改。",
|
||||||
|
"providerKeyRequired": "供應商識別碼為必填",
|
||||||
|
"displayName": "顯示名稱",
|
||||||
|
"nameRequired": "顯示名稱為必填",
|
||||||
|
"providerApi": "供應商 API",
|
||||||
|
"providerBaseUrl": "供應商基礎 URL",
|
||||||
|
"manageEndpoints": "管理故障轉移端點",
|
||||||
|
"credential": "憑證",
|
||||||
|
"credentialHint": "Pi 支援字面值、$ENV 參照或 !command 運算式;延遲值僅在 Pi 解析後驗證。",
|
||||||
|
"website": "網站",
|
||||||
|
"authHeader": "透過 Authorization 傳送憑證",
|
||||||
|
"authHeaderHint": "對支援的 API 類別使用 Pi 的 authHeader 行為。Anthropic OAuth 憑證由閘道依已驗證的傳輸方式處理。",
|
||||||
|
"headers": "自訂標頭(JSON)",
|
||||||
|
"headersStringValues": "每個自訂標頭值都必須是字串",
|
||||||
|
"jsonObjectRequired": "{{label}} 必須是 JSON 物件",
|
||||||
|
"nonFiniteNumber": "{{label}} 包含非有限數值",
|
||||||
|
"absoluteHttpUrlRequired": "{{label}} 必須是絕對 HTTP 或 HTTPS URL",
|
||||||
|
"models": "模型",
|
||||||
|
"modelsHint": "每個模型可繼承供應商的 API 與基礎 URL;完整模型物件會保留在 models.json 中。",
|
||||||
|
"addModel": "新增模型",
|
||||||
|
"modelNumber": "模型 {{index}}",
|
||||||
|
"removeModel": "移除模型",
|
||||||
|
"modelId": "模型 ID",
|
||||||
|
"modelIdRequired": "模型 {{index}} 需要 ID",
|
||||||
|
"modelName": "顯示名稱",
|
||||||
|
"modelApi": "模型 API",
|
||||||
|
"modelBaseUrl": "模型基礎 URL",
|
||||||
|
"modelBaseUrlFor": "模型 {{id}} 的基礎 URL",
|
||||||
|
"inherit": "繼承供應商設定",
|
||||||
|
"inheritanceHint": "沒有覆寫值的模型會繼承此供應商設定。",
|
||||||
|
"effectiveApiRequired": "模型 {{id}} 必須直接設定或從供應商繼承 API",
|
||||||
|
"effectiveBaseUrlRequired": "模型 {{id}} 必須直接設定或從供應商繼承基礎 URL",
|
||||||
|
"duplicateModel": "模型 ID {{id}} 重複",
|
||||||
|
"modelRequired": "請至少新增一個模型",
|
||||||
|
"modelAdditionalConfig": "模型 {{id}} 的附加設定(JSON)",
|
||||||
|
"additionalConfig": "供應商附加設定(JSON)",
|
||||||
|
"additionalConfigHint": "無法識別的 Pi 欄位會原樣保留;以上受控欄位優先。"
|
||||||
|
},
|
||||||
|
"native": {
|
||||||
|
"title": "Pi 原生模型目錄",
|
||||||
|
"description": "透過已認證的分類器檢查實際 Pi models.json。僅在明確操作時匯入,並始終以 Pi 原生檔案為準。",
|
||||||
|
"empty": "Pi models.json 中沒有供應商。",
|
||||||
|
"defaultProvider": "預設供應商",
|
||||||
|
"defaultModel": "預設模型",
|
||||||
|
"setDefault": "設為預設",
|
||||||
|
"currentDefault": "目前預設",
|
||||||
|
"defaultSaved": "Pi 預設模型已更新",
|
||||||
|
"defaultSaveFailed": "更新 Pi 預設模型失敗",
|
||||||
|
"unmanagedDefault": "Pi 目前選擇的是未受管供應商 {{provider}} / {{model}}。請匯入或選擇受管的預設項目。",
|
||||||
|
"imported": "Pi 供應商已匯入",
|
||||||
|
"importFailed": "匯入 Pi 供應商失敗",
|
||||||
|
"managed": "受管",
|
||||||
|
"management": {
|
||||||
|
"importable": "可匯入",
|
||||||
|
"managed": "受管",
|
||||||
|
"unsupported": "不支援"
|
||||||
|
},
|
||||||
|
"gateway": {
|
||||||
|
"proxyable": "閘道已就緒",
|
||||||
|
"direct_only": "僅限直接連線",
|
||||||
|
"unknown": "未知"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"prompts": {
|
||||||
|
"nativeTitle": "Pi 原生提示資源",
|
||||||
|
"nativeDescription": "檔案存在即代表啟用。SYSTEM.md 會取代 Pi 的系統提示,APPEND_SYSTEM.md 會附加內容,而 /template-name 會展開 prompts 目錄中的檔案。",
|
||||||
|
"agentsLibrary": "AGENTS.md 提示庫",
|
||||||
|
"agentsLibraryDescription": "已啟用的提示庫項目會投影至 AGENTS.md。偵測到外部修改時,必須先匯入才能切換。",
|
||||||
|
"libraryDriftTitle": "AGENTS.md 與提示庫不一致",
|
||||||
|
"libraryDriftNative": "Pi 正在使用的原生 AGENTS.md 內容不是提示庫中儲存的選取項目。協調後會匯入並選取完全相同的即時內容。",
|
||||||
|
"libraryDriftMissing": "AGENTS.md 不存在,因此即使提示庫仍有啟用的選取項目,Pi 目前也未使用全域提示。",
|
||||||
|
"reconcileLibrary": "協調",
|
||||||
|
"libraryReconciled": "AGENTS.md 提示庫已與 Pi 原生狀態協調",
|
||||||
|
"libraryReconcileFailed": "協調 AGENTS.md 提示庫失敗",
|
||||||
|
"systemOverride": "系統提示取代",
|
||||||
|
"systemOverrideDescription": "SYSTEM.md 存在期間會完全取代 Pi 內建的系統提示。",
|
||||||
|
"systemAppend": "系統提示附加",
|
||||||
|
"systemAppendDescription": "APPEND_SYSTEM.md 存在期間會附加至 Pi 的系統提示。",
|
||||||
|
"active": "已啟用",
|
||||||
|
"inactive": "未啟用",
|
||||||
|
"activateOverride": "啟用取代",
|
||||||
|
"activateOverrideTitle": "啟用 {{filename}}?",
|
||||||
|
"activateOverrideMessage": "建立 {{filename}} 會完全取代 Pi 內建的系統提示。請確認這是您預期的操作。",
|
||||||
|
"deactivate": "停用",
|
||||||
|
"deactivateTitle": "停用 {{filename}}?",
|
||||||
|
"deactivateMessage": "此操作會刪除 {{filename}},Pi 將立即停止套用。",
|
||||||
|
"instructionPlaceholder": "供 Pi 使用的 Markdown 內容",
|
||||||
|
"blankInstruction": "請輸入非空白內容;如要移除檔案,請將其停用。",
|
||||||
|
"loadFirst": "變更前請先載入目前的 Pi 檔案",
|
||||||
|
"fileSaved": "{{filename}} 已儲存",
|
||||||
|
"fileDeactivated": "{{filename}} 已停用",
|
||||||
|
"saveFailed": "儲存 Pi 指示檔案失敗",
|
||||||
|
"deleteFailed": "停用 Pi 指示檔案失敗",
|
||||||
|
"templates": "提示範本",
|
||||||
|
"templatesDescription": "每個 prompts/<slug>.md 檔案都可在 Pi 中透過 /<slug> 呼叫。",
|
||||||
|
"newTemplate": "新增提示範本",
|
||||||
|
"templateSlug": "範本識別碼(例如:review)",
|
||||||
|
"templateContent": "範本 Markdown",
|
||||||
|
"createTemplate": "建立範本",
|
||||||
|
"templateCreated": "Pi 提示範本已建立",
|
||||||
|
"templateSaved": "/{{slug}} 已儲存",
|
||||||
|
"templateDeleted": "/{{slug}} 已刪除",
|
||||||
|
"templateSaveFailed": "儲存 Pi 提示範本失敗",
|
||||||
|
"templateDeleteFailed": "刪除 Pi 提示範本失敗",
|
||||||
|
"deleteTemplateTitle": "刪除 /{{slug}}?",
|
||||||
|
"deleteTemplateMessage": "此操作會永久刪除 prompts/{{slug}}.md。"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"grokBuild": {
|
"grokBuild": {
|
||||||
"apiBackend": "API Backend",
|
"apiBackend": "API Backend",
|
||||||
@@ -924,6 +1040,8 @@
|
|||||||
"batchDeleting": "批次刪除中...",
|
"batchDeleting": "批次刪除中...",
|
||||||
"loadingSessions": "載入工作階段中...",
|
"loadingSessions": "載入工作階段中...",
|
||||||
"noSessions": "未發現工作階段",
|
"noSessions": "未發現工作階段",
|
||||||
|
"piRelativeSessionDir": "Pi 的 sessionDir 是相對於啟動 Pi 時所在的目錄,因此全域瀏覽器無法安全列舉。請將 sessionDir 設為絕對路徑,之後即可在此瀏覽所有 Pi 工作階段:",
|
||||||
|
"piDiscoveryUnavailable": "無法檢查 Pi 工作階段:{{error}}",
|
||||||
"selectSession": "請選擇工作階段檢視詳情",
|
"selectSession": "請選擇工作階段檢視詳情",
|
||||||
"noSummary": "暫無摘要",
|
"noSummary": "暫無摘要",
|
||||||
"lastActive": "最近活躍",
|
"lastActive": "最近活躍",
|
||||||
@@ -1504,7 +1622,8 @@
|
|||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
"gemini": "Gemini",
|
"gemini": "Gemini",
|
||||||
"opencode": "OpenCode",
|
"opencode": "OpenCode",
|
||||||
"grokbuild": "Grok Build"
|
"grokbuild": "Grok Build",
|
||||||
|
"pi": "Pi"
|
||||||
},
|
},
|
||||||
"rawInputLabel": "原始",
|
"rawInputLabel": "原始",
|
||||||
"rebuildCodex": {
|
"rebuildCodex": {
|
||||||
@@ -2378,6 +2497,18 @@
|
|||||||
"importSelected": "匯入已選 ({{count}})",
|
"importSelected": "匯入已選 ({{count}})",
|
||||||
"noUnmanagedFound": "未發現需要匯入的技能。所有技能已在 CC Switch 統一管理中。",
|
"noUnmanagedFound": "未發現需要匯入的技能。所有技能已在 CC Switch 統一管理中。",
|
||||||
"unmanagedAvailable": "發現可匯入的技能",
|
"unmanagedAvailable": "發現可匯入的技能",
|
||||||
|
"piStatus": {
|
||||||
|
"active": "已生效",
|
||||||
|
"inactive": "未啟用",
|
||||||
|
"unmanagedActive": "已生效但未受管",
|
||||||
|
"desiredButMissing": "已啟用但 Pi 未發現",
|
||||||
|
"foreignConflict": "遭未受管的目的地阻擋",
|
||||||
|
"staleDeployment": "受管部署已被外部修改",
|
||||||
|
"shadowed": "遭另一個同名 Skill 遮蔽",
|
||||||
|
"invalid": "Skill 資訊清單無效",
|
||||||
|
"inspecting": "檢查中...",
|
||||||
|
"inspectionUnavailable": "無法檢查實際狀態"
|
||||||
|
},
|
||||||
"foundIn": "發現於",
|
"foundIn": "發現於",
|
||||||
"local": "本地",
|
"local": "本地",
|
||||||
"uninstallConfirm": "確定要解除安裝技能 \"{{name}}\" 嗎?這將從所有應用程式中移除該技能,並在刪除前自動建立本地備份。",
|
"uninstallConfirm": "確定要解除安裝技能 \"{{name}}\" 嗎?這將從所有應用程式中移除該技能,並在刪除前自動建立本地備份。",
|
||||||
@@ -2441,6 +2572,7 @@
|
|||||||
"homepage": "官網位址",
|
"homepage": "官網位址",
|
||||||
"endpoint": "API 端點",
|
"endpoint": "API 端點",
|
||||||
"apiKey": "API 金鑰",
|
"apiKey": "API 金鑰",
|
||||||
|
"api": "原生 API 通訊協定",
|
||||||
"icon": "圖示",
|
"icon": "圖示",
|
||||||
"model": "模型",
|
"model": "模型",
|
||||||
"haikuModel": "Haiku 模型",
|
"haikuModel": "Haiku 模型",
|
||||||
@@ -2554,6 +2686,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"proxy": {
|
"proxy": {
|
||||||
|
"piGateway": {
|
||||||
|
"title": "Pi 閘道憑證",
|
||||||
|
"description": "輪換僅用於 Pi 與此本機閘道之間通訊的裝置端憑證。",
|
||||||
|
"rotate": "輪換憑證",
|
||||||
|
"confirmTitle": "輪換 Pi 閘道憑證?",
|
||||||
|
"confirmMessage": "目前的本機憑證會立即失效。執行中的 Pi 處理程序仍在記憶體中保留已投影的憑證,因此必須重新啟動。",
|
||||||
|
"rotateSuccess": "Pi 閘道憑證已輪換",
|
||||||
|
"rotateFailed": "Pi 閘道憑證輪換失敗",
|
||||||
|
"restartNotice": "再次傳送請求前,請重新啟動所有執行中的 Pi 處理程序。"
|
||||||
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"serviceAddress": "服務位址",
|
"serviceAddress": "服務位址",
|
||||||
"addressCopied": "位址已複製",
|
"addressCopied": "位址已複製",
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import en from "@/i18n/locales/en.json";
|
||||||
|
import ja from "@/i18n/locales/ja.json";
|
||||||
|
import zhTW from "@/i18n/locales/zh-TW.json";
|
||||||
|
import zh from "@/i18n/locales/zh.json";
|
||||||
|
|
||||||
|
type TranslationTree = Record<string, unknown>;
|
||||||
|
|
||||||
|
function flattenStrings(
|
||||||
|
value: unknown,
|
||||||
|
path: string[] = [],
|
||||||
|
result = new Map<string, string>(),
|
||||||
|
): Map<string, string> {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
result.set(path.join("."), value);
|
||||||
|
} else if (typeof value === "object" && value !== null) {
|
||||||
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
flattenStrings(child, [...path, key], result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolationVariables(value: string): string[] {
|
||||||
|
return Array.from(
|
||||||
|
value.matchAll(/\{\{\s*([^}]+?)\s*\}\}/g),
|
||||||
|
([, name]) => name,
|
||||||
|
).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const reference = flattenStrings(en);
|
||||||
|
const piKeysOutsideNamespace = new Set([
|
||||||
|
"apps.pi",
|
||||||
|
"deeplink.api",
|
||||||
|
"sessionManager.piDiscoveryUnavailable",
|
||||||
|
"sessionManager.piRelativeSessionDir",
|
||||||
|
"settings.browsePlaceholderPi",
|
||||||
|
"settings.piConfigDir",
|
||||||
|
"settings.piConfigDirDescription",
|
||||||
|
"settings.piImportExportBoundary",
|
||||||
|
"usage.appFilter.pi",
|
||||||
|
]);
|
||||||
|
const piReference = new Map(
|
||||||
|
[...reference].filter(
|
||||||
|
([key]) =>
|
||||||
|
key.startsWith("pi.") ||
|
||||||
|
key.startsWith("proxy.piGateway.") ||
|
||||||
|
key.startsWith("skills.piStatus.") ||
|
||||||
|
piKeysOutsideNamespace.has(key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const locales = [
|
||||||
|
["zh", zh],
|
||||||
|
["ja", ja],
|
||||||
|
["zh-TW", zhTW],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe("locale coverage", () => {
|
||||||
|
it.each(locales)("covers every Pi translation key in %s", (_name, tree) => {
|
||||||
|
const translations = flattenStrings(tree as TranslationTree);
|
||||||
|
const missing = [...piReference.keys()].filter(
|
||||||
|
(key) => !translations.has(key),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(locales)(
|
||||||
|
"preserves every Pi interpolation variable in %s",
|
||||||
|
(_name, tree) => {
|
||||||
|
const translations = flattenStrings(tree as TranslationTree);
|
||||||
|
const mismatched = [...piReference].flatMap(([key, expected]) => {
|
||||||
|
const actual = translations.get(key);
|
||||||
|
return actual !== undefined &&
|
||||||
|
interpolationVariables(actual).join("\0") !==
|
||||||
|
interpolationVariables(expected).join("\0")
|
||||||
|
? [key]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mismatched).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user