mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
feat: improve failover rules and request timeline
This commit is contained in:
@@ -83,15 +83,6 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
|
|||||||
"chatgpt_web_image".to_string(),
|
"chatgpt_web_image".to_string(),
|
||||||
serde_json::Value::Bool(true),
|
serde_json::Value::Bool(true),
|
||||||
);
|
);
|
||||||
extra_fields.insert(
|
|
||||||
"local_failover_policy".to_string(),
|
|
||||||
serde_json::json!({
|
|
||||||
"stop_status_codes": [400, 401, 403, 429, 500, 502, 503, 504],
|
|
||||||
"error_stop_patterns": [
|
|
||||||
{ "pattern": ".*" }
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let upstream_is_stream = resolved
|
let upstream_is_stream = resolved
|
||||||
.provider_request_body
|
.provider_request_body
|
||||||
|
|||||||
@@ -105,15 +105,6 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
|
|||||||
.eq_ignore_ascii_case("chatgpt_web")
|
.eq_ignore_ascii_case("chatgpt_web")
|
||||||
{
|
{
|
||||||
extra_fields.insert("chatgpt_web_image".to_string(), serde_json::json!(true));
|
extra_fields.insert("chatgpt_web_image".to_string(), serde_json::json!(true));
|
||||||
extra_fields.insert(
|
|
||||||
"local_failover_policy".to_string(),
|
|
||||||
serde_json::json!({
|
|
||||||
"stop_status_codes": [400, 401, 403, 429, 500, 502, 503, 504],
|
|
||||||
"error_stop_patterns": [
|
|
||||||
{ "pattern": ".*" }
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let super::request::LocalOpenAiChatCandidatePayloadParts {
|
let super::request::LocalOpenAiChatCandidatePayloadParts {
|
||||||
client_api_format,
|
client_api_format,
|
||||||
|
|||||||
-9
@@ -102,15 +102,6 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
|
|||||||
.eq_ignore_ascii_case("chatgpt_web")
|
.eq_ignore_ascii_case("chatgpt_web")
|
||||||
{
|
{
|
||||||
extra_fields.insert("chatgpt_web_image".to_string(), json!(true));
|
extra_fields.insert("chatgpt_web_image".to_string(), json!(true));
|
||||||
extra_fields.insert(
|
|
||||||
"local_failover_policy".to_string(),
|
|
||||||
json!({
|
|
||||||
"stop_status_codes": [400, 401, 403, 429, 500, 502, 503, 504],
|
|
||||||
"error_stop_patterns": [
|
|
||||||
{ "pattern": ".*" }
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
insert_provider_stream_event_api_format(
|
insert_provider_stream_event_api_format(
|
||||||
&mut extra_fields,
|
&mut extra_fields,
|
||||||
|
|||||||
@@ -1007,6 +1007,100 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn provider_failover_rules_can_stop_rate_limit_status() {
|
||||||
|
let result = ExecutionResult {
|
||||||
|
request_id: "req-1".to_string(),
|
||||||
|
candidate_id: None,
|
||||||
|
status_code: 429,
|
||||||
|
headers: Default::default(),
|
||||||
|
body: None,
|
||||||
|
telemetry: None,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
let local_report_context = serde_json::json!({
|
||||||
|
"candidate_index": 0,
|
||||||
|
"retry_index": 0,
|
||||||
|
});
|
||||||
|
let state = build_state_with_provider_config(Some(serde_json::json!({
|
||||||
|
"failover_rules": {
|
||||||
|
"stop_on_status_codes": [429]
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
let plan = sample_plan();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
should_stop_local_candidate_failover_sync(
|
||||||
|
&state,
|
||||||
|
&plan,
|
||||||
|
"openai_chat_sync",
|
||||||
|
Some(&local_report_context),
|
||||||
|
&result,
|
||||||
|
Some("{\"error\":{\"message\":\"rate limited\"}}"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!should_retry_next_local_candidate_sync(
|
||||||
|
&state,
|
||||||
|
&plan,
|
||||||
|
"openai_chat_sync",
|
||||||
|
Some(&local_report_context),
|
||||||
|
&result,
|
||||||
|
Some("{\"error\":{\"message\":\"rate limited\"}}"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn status_only_error_stop_rule_can_stop_rate_limit_status() {
|
||||||
|
let result = ExecutionResult {
|
||||||
|
request_id: "req-1".to_string(),
|
||||||
|
candidate_id: None,
|
||||||
|
status_code: 429,
|
||||||
|
headers: Default::default(),
|
||||||
|
body: None,
|
||||||
|
telemetry: None,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
let local_report_context = serde_json::json!({
|
||||||
|
"candidate_index": 0,
|
||||||
|
"retry_index": 0,
|
||||||
|
});
|
||||||
|
let state = build_state_with_provider_config(Some(serde_json::json!({
|
||||||
|
"failover_rules": {
|
||||||
|
"error_stop_patterns": [
|
||||||
|
{"status_codes": [429]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
let plan = sample_plan();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
should_stop_local_candidate_failover_sync(
|
||||||
|
&state,
|
||||||
|
&plan,
|
||||||
|
"openai_chat_sync",
|
||||||
|
Some(&local_report_context),
|
||||||
|
&result,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!should_retry_next_local_candidate_sync(
|
||||||
|
&state,
|
||||||
|
&plan,
|
||||||
|
"openai_chat_sync",
|
||||||
|
Some(&local_report_context),
|
||||||
|
&result,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_local_failover_policy_reads_regex_rules() {
|
fn resolve_local_failover_policy_reads_regex_rules() {
|
||||||
let state = build_state_with_provider_config(Some(serde_json::json!({
|
let state = build_state_with_provider_config(Some(serde_json::json!({
|
||||||
@@ -1039,6 +1133,32 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_local_failover_policy_reads_status_only_error_stop_rules() {
|
||||||
|
let state = build_state_with_provider_config(Some(serde_json::json!({
|
||||||
|
"failover_rules": {
|
||||||
|
"success_failover_patterns": [
|
||||||
|
{"status_codes": [200]}
|
||||||
|
],
|
||||||
|
"error_stop_patterns": [
|
||||||
|
{"status_codes": [429]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
let plan = sample_plan();
|
||||||
|
let runtime = tokio::runtime::Runtime::new().expect("runtime should build");
|
||||||
|
|
||||||
|
let policy = runtime.block_on(resolve_local_failover_policy(&state, &plan, None));
|
||||||
|
assert!(policy.success_failover_patterns.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
policy.error_stop_patterns,
|
||||||
|
vec![LocalFailoverRegexRule {
|
||||||
|
pattern: String::new(),
|
||||||
|
status_codes: [429].into_iter().collect(),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn success_failover_pattern_can_retry_sync_candidate() {
|
async fn success_failover_pattern_can_retry_sync_candidate() {
|
||||||
let result = ExecutionResult {
|
let result = ExecutionResult {
|
||||||
@@ -1125,11 +1245,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn chatgpt_web_report_context_stops_local_sync_failover_on_transport_errors() {
|
async fn report_context_failover_policy_does_not_override_provider_config() {
|
||||||
let result = ExecutionResult {
|
let result = ExecutionResult {
|
||||||
request_id: "req-1".to_string(),
|
request_id: "req-1".to_string(),
|
||||||
candidate_id: None,
|
candidate_id: None,
|
||||||
status_code: 503,
|
status_code: 429,
|
||||||
headers: Default::default(),
|
headers: Default::default(),
|
||||||
body: None,
|
body: None,
|
||||||
telemetry: None,
|
telemetry: None,
|
||||||
@@ -1150,7 +1270,7 @@ mod tests {
|
|||||||
let plan = sample_plan();
|
let plan = sample_plan();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
should_stop_local_candidate_failover_sync(
|
should_retry_next_local_candidate_sync(
|
||||||
&state,
|
&state,
|
||||||
&plan,
|
&plan,
|
||||||
"openai_image_sync",
|
"openai_image_sync",
|
||||||
@@ -1161,7 +1281,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!should_retry_next_local_candidate_sync(
|
!should_stop_local_candidate_failover_sync(
|
||||||
&state,
|
&state,
|
||||||
&plan,
|
&plan,
|
||||||
"openai_image_sync",
|
"openai_image_sync",
|
||||||
|
|||||||
@@ -3921,10 +3921,14 @@ mod tests {
|
|||||||
StreamFrame, StreamFramePayload, StreamFrameType,
|
StreamFrame, StreamFramePayload, StreamFrameType,
|
||||||
};
|
};
|
||||||
use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
|
use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
|
||||||
|
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||||
use aether_data::repository::usage::InMemoryUsageReadRepository;
|
use aether_data::repository::usage::InMemoryUsageReadRepository;
|
||||||
use aether_data_contracts::repository::candidates::{
|
use aether_data_contracts::repository::candidates::{
|
||||||
RequestCandidateReadRepository, RequestCandidateStatus,
|
RequestCandidateReadRepository, RequestCandidateStatus,
|
||||||
};
|
};
|
||||||
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
|
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||||
|
};
|
||||||
use aether_data_contracts::repository::usage::UsageReadRepository;
|
use aether_data_contracts::repository::usage::UsageReadRepository;
|
||||||
use aether_usage_runtime::UsageRuntimeConfig;
|
use aether_usage_runtime::UsageRuntimeConfig;
|
||||||
use async_stream::stream;
|
use async_stream::stream;
|
||||||
@@ -3954,6 +3958,77 @@ mod tests {
|
|||||||
use crate::tunnel::{tunnel_protocol, TunnelProxyConn};
|
use crate::tunnel::{tunnel_protocol, TunnelProxyConn};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
fn provider_catalog_stop_429_for_plan(
|
||||||
|
plan: &ExecutionPlan,
|
||||||
|
) -> InMemoryProviderCatalogReadRepository {
|
||||||
|
let provider_type = plan.provider_name.as_deref().unwrap_or("custom");
|
||||||
|
let provider = StoredProviderCatalogProvider::new(
|
||||||
|
plan.provider_id.clone(),
|
||||||
|
plan.provider_id.clone(),
|
||||||
|
Some("https://provider.example".to_string()),
|
||||||
|
provider_type.to_string(),
|
||||||
|
)
|
||||||
|
.expect("provider should build")
|
||||||
|
.with_transport_fields(
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
Some(3),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(json!({
|
||||||
|
"failover_rules": {
|
||||||
|
"stop_status_codes": [429]
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
let endpoint = StoredProviderCatalogEndpoint::new(
|
||||||
|
plan.endpoint_id.clone(),
|
||||||
|
plan.provider_id.clone(),
|
||||||
|
plan.provider_api_format.clone(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("endpoint should build")
|
||||||
|
.with_transport_fields(
|
||||||
|
"https://provider.example".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(2),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("endpoint transport should build");
|
||||||
|
let key = StoredProviderCatalogKey::new(
|
||||||
|
plan.key_id.clone(),
|
||||||
|
plan.provider_id.clone(),
|
||||||
|
plan.key_id.clone(),
|
||||||
|
"api_key".to_string(),
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("key should build")
|
||||||
|
.with_transport_fields(
|
||||||
|
Some(json!([plan.provider_api_format.clone()])),
|
||||||
|
"plain-upstream-key".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(json!({ "openai:chat": 1 })),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("key transport should build");
|
||||||
|
|
||||||
|
InMemoryProviderCatalogReadRepository::seed(vec![provider], vec![endpoint], vec![key])
|
||||||
|
}
|
||||||
|
|
||||||
fn test_decision() -> GatewayControlDecision {
|
fn test_decision() -> GatewayControlDecision {
|
||||||
GatewayControlDecision::synthetic(
|
GatewayControlDecision::synthetic(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
@@ -5458,6 +5533,14 @@ mod tests {
|
|||||||
transport_profile: None,
|
transport_profile: None,
|
||||||
timeouts: None,
|
timeouts: None,
|
||||||
};
|
};
|
||||||
|
let state = state.with_data_state_for_tests(
|
||||||
|
crate::data::GatewayDataState::with_request_candidate_and_usage_repository_for_tests(
|
||||||
|
Arc::clone(&request_candidate_repository),
|
||||||
|
Arc::clone(&usage_repository),
|
||||||
|
)
|
||||||
|
.with_provider_catalog_reader(Arc::new(provider_catalog_stop_429_for_plan(&plan)))
|
||||||
|
.with_encryption_key_for_tests("development-key"),
|
||||||
|
);
|
||||||
let trailer_error = connect_json_frame(
|
let trailer_error = connect_json_frame(
|
||||||
2,
|
2,
|
||||||
br#"{"error":{"code":"resource_exhausted","message":"an internal error occurred"}}"#,
|
br#"{"error":{"code":"resource_exhausted","message":"an internal error occurred"}}"#,
|
||||||
@@ -5493,10 +5576,7 @@ mod tests {
|
|||||||
"client_api_format": "claude:messages",
|
"client_api_format": "claude:messages",
|
||||||
"needs_conversion": true,
|
"needs_conversion": true,
|
||||||
"has_envelope": true,
|
"has_envelope": true,
|
||||||
"envelope_name": "windsurf:GetChatMessage",
|
"envelope_name": "windsurf:GetChatMessage"
|
||||||
"local_failover_policy": {
|
|
||||||
"stop_status_codes": [429]
|
|
||||||
}
|
|
||||||
})),
|
})),
|
||||||
crate::clock::current_unix_ms(),
|
crate::clock::current_unix_ms(),
|
||||||
Instant::now(),
|
Instant::now(),
|
||||||
@@ -5590,6 +5670,14 @@ mod tests {
|
|||||||
transport_profile: None,
|
transport_profile: None,
|
||||||
timeouts: None,
|
timeouts: None,
|
||||||
};
|
};
|
||||||
|
let state = state.with_data_state_for_tests(
|
||||||
|
crate::data::GatewayDataState::with_request_candidate_and_usage_repository_for_tests(
|
||||||
|
Arc::clone(&request_candidate_repository),
|
||||||
|
Arc::clone(&usage_repository),
|
||||||
|
)
|
||||||
|
.with_provider_catalog_reader(Arc::new(provider_catalog_stop_429_for_plan(&plan)))
|
||||||
|
.with_encryption_key_for_tests("development-key"),
|
||||||
|
);
|
||||||
let connect_error = connect_json_frame(
|
let connect_error = connect_json_frame(
|
||||||
2,
|
2,
|
||||||
br#"{"error":{"code":"resource_exhausted","message":"quota exhausted"}}"#,
|
br#"{"error":{"code":"resource_exhausted","message":"quota exhausted"}}"#,
|
||||||
@@ -5624,10 +5712,7 @@ mod tests {
|
|||||||
"client_api_format": "claude:messages",
|
"client_api_format": "claude:messages",
|
||||||
"needs_conversion": true,
|
"needs_conversion": true,
|
||||||
"has_envelope": true,
|
"has_envelope": true,
|
||||||
"envelope_name": "windsurf:GetChatMessage",
|
"envelope_name": "windsurf:GetChatMessage"
|
||||||
"local_failover_policy": {
|
|
||||||
"stop_status_codes": [429]
|
|
||||||
}
|
|
||||||
})),
|
})),
|
||||||
crate::clock::current_unix_ms(),
|
crate::clock::current_unix_ms(),
|
||||||
Instant::now(),
|
Instant::now(),
|
||||||
|
|||||||
@@ -61,11 +61,8 @@ pub(crate) fn classify_local_failover(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if input.status_code >= 400
|
if input.status_code >= 400
|
||||||
&& input.response_text.is_some_and(|text| {
|
&& policy.error_stop_patterns.iter().any(|rule| {
|
||||||
policy
|
local_failover_regex_rule_matches(rule, input.response_text, input.status_code)
|
||||||
.error_stop_patterns
|
|
||||||
.iter()
|
|
||||||
.any(|rule| local_failover_regex_rule_matches(rule, text, input.status_code))
|
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
return LocalFailoverClassification::StopErrorPattern;
|
return LocalFailoverClassification::StopErrorPattern;
|
||||||
@@ -76,7 +73,7 @@ pub(crate) fn classify_local_failover(
|
|||||||
policy
|
policy
|
||||||
.success_failover_patterns
|
.success_failover_patterns
|
||||||
.iter()
|
.iter()
|
||||||
.any(|rule| local_failover_regex_rule_matches(rule, text, input.status_code))
|
.any(|rule| local_failover_regex_rule_matches(rule, Some(text), input.status_code))
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
return LocalFailoverClassification::RetrySuccessPattern;
|
return LocalFailoverClassification::RetrySuccessPattern;
|
||||||
@@ -190,14 +187,23 @@ fn first_non_empty_json_text(
|
|||||||
|
|
||||||
fn local_failover_regex_rule_matches(
|
fn local_failover_regex_rule_matches(
|
||||||
rule: &LocalFailoverRegexRule,
|
rule: &LocalFailoverRegexRule,
|
||||||
response_text: &str,
|
response_text: Option<&str>,
|
||||||
status_code: u16,
|
status_code: u16,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if !rule.status_codes.is_empty() && !rule.status_codes.contains(&status_code) {
|
if !rule.status_codes.is_empty() && !rule.status_codes.contains(&status_code) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Regex::new(&rule.pattern)
|
let pattern = rule.pattern.trim();
|
||||||
|
if pattern.is_empty() {
|
||||||
|
return !rule.status_codes.is_empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(response_text) = response_text else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
Regex::new(pattern)
|
||||||
.ok()
|
.ok()
|
||||||
.is_some_and(|regex| regex.is_match(response_text))
|
.is_some_and(|regex| regex.is_match(response_text))
|
||||||
}
|
}
|
||||||
@@ -260,6 +266,50 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifier_detects_error_stop_pattern_without_status_codes_on_any_error_status() {
|
||||||
|
let policy = LocalFailoverPolicy {
|
||||||
|
error_stop_patterns: vec![LocalFailoverRegexRule {
|
||||||
|
pattern: "content_policy_violation".to_string(),
|
||||||
|
status_codes: BTreeSet::new(),
|
||||||
|
}],
|
||||||
|
..LocalFailoverPolicy::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
for status_code in [400, 429, 503] {
|
||||||
|
assert_eq!(
|
||||||
|
classify_local_failover(
|
||||||
|
&policy,
|
||||||
|
LocalFailoverInput::new(
|
||||||
|
status_code,
|
||||||
|
Some("{\"error\":\"content_policy_violation\"}")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
LocalFailoverClassification::StopErrorPattern
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifier_detects_status_only_error_stop_rule_without_response_text() {
|
||||||
|
let policy = LocalFailoverPolicy {
|
||||||
|
error_stop_patterns: vec![LocalFailoverRegexRule {
|
||||||
|
pattern: String::new(),
|
||||||
|
status_codes: [429].into_iter().collect(),
|
||||||
|
}],
|
||||||
|
..LocalFailoverPolicy::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
classify_local_failover(&policy, LocalFailoverInput::new(429, None)),
|
||||||
|
LocalFailoverClassification::StopErrorPattern
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_local_failover(&policy, LocalFailoverInput::new(503, None)),
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classifier_detects_success_continue_status_code() {
|
fn classifier_detects_success_continue_status_code() {
|
||||||
let policy = LocalFailoverPolicy {
|
let policy = LocalFailoverPolicy {
|
||||||
|
|||||||
@@ -25,27 +25,8 @@ pub(crate) struct LocalFailoverRegexRule {
|
|||||||
pub(crate) async fn resolve_local_failover_policy(
|
pub(crate) async fn resolve_local_failover_policy(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
plan: &ExecutionPlan,
|
plan: &ExecutionPlan,
|
||||||
report_context: Option<&serde_json::Value>,
|
_report_context: Option<&serde_json::Value>,
|
||||||
) -> LocalFailoverPolicy {
|
) -> LocalFailoverPolicy {
|
||||||
if let Some(policy) = local_failover_policy_from_report_context(report_context) {
|
|
||||||
debug!(
|
|
||||||
event_name = "local_failover_policy_loaded",
|
|
||||||
log_type = "debug",
|
|
||||||
request_id = %plan.request_id,
|
|
||||||
provider_id = %plan.provider_id,
|
|
||||||
endpoint_id = %plan.endpoint_id,
|
|
||||||
key_id = %plan.key_id,
|
|
||||||
source = "report_context",
|
|
||||||
max_retries = ?policy.max_retries,
|
|
||||||
stop_status_code_count = policy.stop_status_codes.len(),
|
|
||||||
continue_status_code_count = policy.continue_status_codes.len(),
|
|
||||||
success_failover_pattern_count = policy.success_failover_patterns.len(),
|
|
||||||
error_stop_pattern_count = policy.error_stop_patterns.len(),
|
|
||||||
"gateway loaded local failover policy from report context"
|
|
||||||
);
|
|
||||||
return policy;
|
|
||||||
}
|
|
||||||
|
|
||||||
let transport = match state
|
let transport = match state
|
||||||
.read_provider_transport_snapshot(&plan.provider_id, &plan.endpoint_id, &plan.key_id)
|
.read_provider_transport_snapshot(&plan.provider_id, &plan.endpoint_id, &plan.key_id)
|
||||||
.await
|
.await
|
||||||
@@ -201,31 +182,39 @@ fn parse_regex_rules(
|
|||||||
rules: &serde_json::Map<String, serde_json::Value>,
|
rules: &serde_json::Map<String, serde_json::Value>,
|
||||||
key: &str,
|
key: &str,
|
||||||
) -> Vec<LocalFailoverRegexRule> {
|
) -> Vec<LocalFailoverRegexRule> {
|
||||||
|
let allow_status_only = key == "error_stop_patterns";
|
||||||
rules
|
rules
|
||||||
.get(key)
|
.get(key)
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flat_map(|items| items.iter())
|
.flat_map(|items| items.iter())
|
||||||
.filter_map(parse_regex_rule)
|
.filter_map(|value| parse_regex_rule(value, allow_status_only))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_regex_rule(value: &serde_json::Value) -> Option<LocalFailoverRegexRule> {
|
fn parse_regex_rule(
|
||||||
|
value: &serde_json::Value,
|
||||||
|
allow_status_only: bool,
|
||||||
|
) -> Option<LocalFailoverRegexRule> {
|
||||||
let object = value.as_object()?;
|
let object = value.as_object()?;
|
||||||
let pattern = object
|
let pattern = object
|
||||||
.get("pattern")
|
.get("pattern")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())?;
|
.unwrap_or_default();
|
||||||
|
let status_codes: BTreeSet<u16> = object
|
||||||
|
.get("status_codes")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|values| values.iter())
|
||||||
|
.filter_map(|value| parse_u64_value(value).and_then(|value| u16::try_from(value).ok()))
|
||||||
|
.collect();
|
||||||
|
if pattern.is_empty() && (!allow_status_only || status_codes.is_empty()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
Some(LocalFailoverRegexRule {
|
Some(LocalFailoverRegexRule {
|
||||||
pattern: pattern.to_string(),
|
pattern: pattern.to_string(),
|
||||||
status_codes: object
|
status_codes,
|
||||||
.get("status_codes")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.into_iter()
|
|
||||||
.flat_map(|values| values.iter())
|
|
||||||
.filter_map(|value| parse_u64_value(value).and_then(|value| u16::try_from(value).ok()))
|
|
||||||
.collect(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,93 @@ pub fn decode_json_data_line(line: &[u8]) -> Option<Value> {
|
|||||||
serde_json::from_str(data_line).ok()
|
serde_json::from_str(data_line).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn unsupported_stream_event_message(payload: &Value) -> String {
|
||||||
|
const BASE_MESSAGE: &str = "Unsupported provider stream event cannot be converted losslessly";
|
||||||
|
match unsupported_stream_event_diagnostic(payload) {
|
||||||
|
Some(diagnostic) if !diagnostic.is_empty() => format!("{BASE_MESSAGE}: {diagnostic}"),
|
||||||
|
_ => BASE_MESSAGE.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unsupported_stream_event_diagnostic(payload: &Value) -> Option<String> {
|
||||||
|
let mut details = Vec::new();
|
||||||
|
if let Some((path, value)) = unsupported_stream_event_primary_field(payload) {
|
||||||
|
details.push(format!("field {path} = {value}"));
|
||||||
|
} else if let Some(path) = unsupported_stream_event_single_field(payload) {
|
||||||
|
details.push(format!("field {path} is unsupported"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(fields) = unsupported_stream_event_field_list(payload) {
|
||||||
|
details.push(format!("fields: {fields}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if details.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(details.join("; "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unsupported_stream_event_primary_field(payload: &Value) -> Option<(&'static str, String)> {
|
||||||
|
const STRING_FIELD_PATHS: &[(&str, &str)] = &[
|
||||||
|
("$.item.type", "/item/type"),
|
||||||
|
("$.content_block.type", "/content_block/type"),
|
||||||
|
("$.delta.type", "/delta/type"),
|
||||||
|
("$.part.type", "/part/type"),
|
||||||
|
("$.payload.type", "/payload/type"),
|
||||||
|
("$.type", "/type"),
|
||||||
|
("$.event", "/event"),
|
||||||
|
];
|
||||||
|
|
||||||
|
STRING_FIELD_PATHS
|
||||||
|
.iter()
|
||||||
|
.find_map(|(display_path, pointer)| {
|
||||||
|
payload
|
||||||
|
.pointer(pointer)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(|value| (*display_path, json!(value.trim()).to_string()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unsupported_stream_event_single_field(payload: &Value) -> Option<String> {
|
||||||
|
let object = payload.as_object()?;
|
||||||
|
if object.len() != 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
object.keys().next().map(|key| json_path_key(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unsupported_stream_event_field_list(payload: &Value) -> Option<String> {
|
||||||
|
let object = payload.as_object()?;
|
||||||
|
if object.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let fields = object
|
||||||
|
.keys()
|
||||||
|
.take(8)
|
||||||
|
.map(|key| key.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
if object.len() > 8 {
|
||||||
|
Some(format!("{fields}, ..."))
|
||||||
|
} else {
|
||||||
|
Some(fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_path_key(key: &str) -> String {
|
||||||
|
if !key.is_empty()
|
||||||
|
&& key.chars().enumerate().all(|(index, ch)| {
|
||||||
|
ch == '_' || ch.is_ascii_alphabetic() || (index > 0 && ch.is_ascii_digit())
|
||||||
|
})
|
||||||
|
{
|
||||||
|
format!("$.{key}")
|
||||||
|
} else {
|
||||||
|
format!("$[{}]", json!(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve_identity(
|
pub fn resolve_identity(
|
||||||
response_id: Option<&str>,
|
response_id: Option<&str>,
|
||||||
model: Option<&str>,
|
model: Option<&str>,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use crate::formats::shared::error_body::{
|
|||||||
use crate::formats::shared::sse::encode_json_sse;
|
use crate::formats::shared::sse::encode_json_sse;
|
||||||
use crate::formats::shared::stream_core::common::{
|
use crate::formats::shared::stream_core::common::{
|
||||||
decode_json_data_line, openai_stream_terminal_error_body, openai_stream_terminal_error_message,
|
decode_json_data_line, openai_stream_terminal_error_body, openai_stream_terminal_error_message,
|
||||||
CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
|
unsupported_stream_event_message, CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
|
||||||
};
|
};
|
||||||
use crate::formats::shared::AiSurfaceFinalizeError;
|
use crate::formats::shared::AiSurfaceFinalizeError;
|
||||||
|
|
||||||
@@ -95,9 +95,9 @@ impl StreamingStandardFormatMatrix {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if matches!(&frame.event, CanonicalStreamEvent::UnknownEvent(_)) {
|
if let CanonicalStreamEvent::UnknownEvent(payload) = &frame.event {
|
||||||
self.terminated = true;
|
self.terminated = true;
|
||||||
out.extend(client.emit_unknown_event()?);
|
out.extend(client.emit_unknown_event(payload)?);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
out.extend(client.emit(frame)?);
|
out.extend(client.emit(frame)?);
|
||||||
@@ -403,10 +403,10 @@ impl ClientStreamEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_unknown_event(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
fn emit_unknown_event(&mut self, payload: &Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||||
let Some(error_body) = build_core_error_body_for_client_format(
|
let Some(error_body) = build_core_error_body_for_client_format(
|
||||||
self.api_format(),
|
self.api_format(),
|
||||||
"Unsupported provider stream event cannot be converted losslessly",
|
&unsupported_stream_event_message(payload),
|
||||||
Some("unsupported_stream_event"),
|
Some("unsupported_stream_event"),
|
||||||
LocalCoreSyncErrorKind::ServerError,
|
LocalCoreSyncErrorKind::ServerError,
|
||||||
) else {
|
) else {
|
||||||
@@ -422,7 +422,8 @@ impl ClientStreamEmitter {
|
|||||||
let Some(error_body) = build_core_error_body_for_client_format(
|
let Some(error_body) = build_core_error_body_for_client_format(
|
||||||
self.api_format(),
|
self.api_format(),
|
||||||
&format!(
|
&format!(
|
||||||
"Unsupported provider stream finish reason {finish_reason:?} cannot be converted losslessly"
|
"Unsupported provider stream finish reason cannot be converted losslessly: field $.finish_reason = {}",
|
||||||
|
serde_json::json!(finish_reason)
|
||||||
),
|
),
|
||||||
Some("unsupported_finish_reason"),
|
Some("unsupported_finish_reason"),
|
||||||
LocalCoreSyncErrorKind::ServerError,
|
LocalCoreSyncErrorKind::ServerError,
|
||||||
@@ -962,6 +963,10 @@ mod tests {
|
|||||||
sse.contains("Unsupported provider stream event cannot be converted losslessly"),
|
sse.contains("Unsupported provider stream event cannot be converted losslessly"),
|
||||||
"{client_api_format}: {sse}"
|
"{client_api_format}: {sse}"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
sse.contains("field $.type = \\\"response.future.delta\\\""),
|
||||||
|
"{sse}"
|
||||||
|
);
|
||||||
assert!(sse.contains(marker), "{client_api_format}: {sse}");
|
assert!(sse.contains(marker), "{client_api_format}: {sse}");
|
||||||
assert!(matrix
|
assert!(matrix
|
||||||
.finish(&report_context)
|
.finish(&report_context)
|
||||||
@@ -1212,6 +1217,10 @@ mod tests {
|
|||||||
sse.contains("Unsupported provider stream finish reason"),
|
sse.contains("Unsupported provider stream finish reason"),
|
||||||
"{client_api_format}: {sse}"
|
"{client_api_format}: {sse}"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
sse.contains("field $.finish_reason = \\\"future_reason\\\""),
|
||||||
|
"{client_api_format}: {sse}"
|
||||||
|
);
|
||||||
assert!(sse.contains("future_reason"), "{client_api_format}: {sse}");
|
assert!(sse.contains("future_reason"), "{client_api_format}: {sse}");
|
||||||
assert!(sse.contains(marker), "{client_api_format}: {sse}");
|
assert!(sse.contains(marker), "{client_api_format}: {sse}");
|
||||||
assert!(matrix
|
assert!(matrix
|
||||||
@@ -1278,6 +1287,10 @@ mod tests {
|
|||||||
sse.contains("Unsupported provider stream finish reason"),
|
sse.contains("Unsupported provider stream finish reason"),
|
||||||
"{client_api_format}: {sse}"
|
"{client_api_format}: {sse}"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
sse.contains("field $.finish_reason = \\\"OTHER\\\""),
|
||||||
|
"{client_api_format}: {sse}"
|
||||||
|
);
|
||||||
assert!(sse.contains("OTHER"), "{client_api_format}: {sse}");
|
assert!(sse.contains("OTHER"), "{client_api_format}: {sse}");
|
||||||
assert!(sse.contains(marker), "{client_api_format}: {sse}");
|
assert!(sse.contains(marker), "{client_api_format}: {sse}");
|
||||||
assert!(matrix
|
assert!(matrix
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ use crate::formats::shared::response::{
|
|||||||
};
|
};
|
||||||
use crate::formats::shared::stream_core::common::{
|
use crate::formats::shared::stream_core::common::{
|
||||||
content_part_from_openai_image_generation_item, gemini_usage_metadata_from_usage,
|
content_part_from_openai_image_generation_item, gemini_usage_metadata_from_usage,
|
||||||
map_openai_finish_reason_to_gemini, parse_json_arguments_value, CanonicalContentPart,
|
map_openai_finish_reason_to_gemini, parse_json_arguments_value,
|
||||||
CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
|
unsupported_stream_event_message, CanonicalContentPart, CanonicalStreamEvent,
|
||||||
|
CanonicalStreamFrame, CanonicalUsage,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
@@ -1583,11 +1584,11 @@ fn ensure_no_unknown_provider_stream_events(
|
|||||||
};
|
};
|
||||||
for raw_line in text.lines() {
|
for raw_line in text.lines() {
|
||||||
let frames = push_line(raw_line.as_bytes().to_vec())?;
|
let frames = push_line(raw_line.as_bytes().to_vec())?;
|
||||||
if frames
|
if let Some(payload) = frames.iter().find_map(|frame| match &frame.event {
|
||||||
.iter()
|
CanonicalStreamEvent::UnknownEvent(payload) => Some(payload),
|
||||||
.any(|frame| matches!(frame.event, CanonicalStreamEvent::UnknownEvent(_)))
|
_ => None,
|
||||||
{
|
}) {
|
||||||
return Err(unsupported_stream_event_finalize_error());
|
return Err(unsupported_stream_event_finalize_error(payload));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -3046,8 +3047,8 @@ fn try_aggregate_gemini_stream_sync_response(
|
|||||||
content,
|
content,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
CanonicalStreamEvent::UnknownEvent(_) => {
|
CanonicalStreamEvent::UnknownEvent(payload) => {
|
||||||
return Err(unsupported_stream_event_finalize_error())
|
return Err(unsupported_stream_event_finalize_error(&payload))
|
||||||
}
|
}
|
||||||
CanonicalStreamEvent::ReasoningSummaryDone => {}
|
CanonicalStreamEvent::ReasoningSummaryDone => {}
|
||||||
CanonicalStreamEvent::Finish {
|
CanonicalStreamEvent::Finish {
|
||||||
@@ -3076,8 +3077,8 @@ fn try_aggregate_gemini_stream_sync_response(
|
|||||||
model_version = Some(Value::String(frame.model.clone()));
|
model_version = Some(Value::String(frame.model.clone()));
|
||||||
}
|
}
|
||||||
match frame.event {
|
match frame.event {
|
||||||
CanonicalStreamEvent::UnknownEvent(_) => {
|
CanonicalStreamEvent::UnknownEvent(payload) => {
|
||||||
return Err(unsupported_stream_event_finalize_error())
|
return Err(unsupported_stream_event_finalize_error(&payload))
|
||||||
}
|
}
|
||||||
CanonicalStreamEvent::Finish {
|
CanonicalStreamEvent::Finish {
|
||||||
finish_reason: frame_finish_reason,
|
finish_reason: frame_finish_reason,
|
||||||
@@ -3140,8 +3141,8 @@ fn try_aggregate_gemini_stream_sync_response(
|
|||||||
Ok(Some(Value::Object(response)))
|
Ok(Some(Value::Object(response)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unsupported_stream_event_finalize_error() -> AiSurfaceFinalizeError {
|
fn unsupported_stream_event_finalize_error(payload: &Value) -> AiSurfaceFinalizeError {
|
||||||
AiSurfaceFinalizeError::new("Unsupported provider stream event cannot be converted losslessly")
|
AiSurfaceFinalizeError::new(unsupported_stream_event_message(payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_gemini_text_part(parts: &mut Vec<Value>, text: String, thought: bool) {
|
fn append_gemini_text_part(parts: &mut Vec<Value>, text: String, thought: bool) {
|
||||||
@@ -3608,6 +3609,9 @@ mod tests {
|
|||||||
assert!(error
|
assert!(error
|
||||||
.to_string()
|
.to_string()
|
||||||
.contains("Unsupported provider stream event cannot be converted losslessly"));
|
.contains("Unsupported provider stream event cannot be converted losslessly"));
|
||||||
|
assert!(error
|
||||||
|
.to_string()
|
||||||
|
.contains("field $.futurePart is unsupported"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -5140,6 +5144,9 @@ mod tests {
|
|||||||
assert!(error
|
assert!(error
|
||||||
.to_string()
|
.to_string()
|
||||||
.contains("Unsupported provider stream event cannot be converted losslessly"));
|
.contains("Unsupported provider stream event cannot be converted losslessly"));
|
||||||
|
assert!(error
|
||||||
|
.to_string()
|
||||||
|
.contains("field $.type = \"future_event\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -5175,6 +5182,9 @@ mod tests {
|
|||||||
assert!(error
|
assert!(error
|
||||||
.to_string()
|
.to_string()
|
||||||
.contains("Unsupported provider stream event cannot be converted losslessly"));
|
.contains("Unsupported provider stream event cannot be converted losslessly"));
|
||||||
|
assert!(error
|
||||||
|
.to_string()
|
||||||
|
.contains("field $.type = \"response.future.delta\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -774,8 +774,17 @@ export interface FailoverRuleItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FailoverRulesConfig {
|
export interface FailoverRulesConfig {
|
||||||
success_failover_patterns: FailoverRuleItem[]
|
max_retries?: number
|
||||||
error_stop_patterns: FailoverRuleItem[]
|
stop_status_codes?: number[]
|
||||||
|
stop_on_status_codes?: number[]
|
||||||
|
early_stop_status_codes?: number[]
|
||||||
|
non_retryable_status_codes?: number[]
|
||||||
|
continue_on_status_codes?: number[]
|
||||||
|
retryable_status_codes?: number[]
|
||||||
|
retry_on_status_codes?: number[]
|
||||||
|
continue_status_codes?: number[]
|
||||||
|
success_failover_patterns?: FailoverRuleItem[]
|
||||||
|
error_stop_patterns?: FailoverRuleItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderWithEndpointsSummary {
|
export interface ProviderWithEndpointsSummary {
|
||||||
|
|||||||
@@ -19,45 +19,95 @@
|
|||||||
HTTP 200 但响应体匹配正则时,视为失败并触发转移
|
HTTP 200 但响应体匹配正则时,视为失败并触发转移
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
class="shrink-0"
|
size="sm"
|
||||||
@click="addRule('success')"
|
class="h-7 text-xs px-2"
|
||||||
>
|
:title="successJsonMode ? '切回成功转移表单' : '切到成功转移 JSON'"
|
||||||
<Plus class="w-4 h-4 mr-1" />
|
@click="toggleSuccessJsonMode"
|
||||||
添加
|
>
|
||||||
</Button>
|
<Code2 class="w-3 h-3 mr-1" />
|
||||||
|
{{ successJsonMode ? '表单' : 'JSON' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="successJsonMode"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2 text-xs"
|
||||||
|
title="格式化成功转移 JSON"
|
||||||
|
@click="formatSuccessJsonDraft"
|
||||||
|
>
|
||||||
|
<AlignLeft class="w-3 h-3 mr-1" />
|
||||||
|
格式化
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="!successJsonMode"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 text-xs px-2"
|
||||||
|
@click="addRule('success')"
|
||||||
|
>
|
||||||
|
<Plus class="w-3 h-3 mr-1" />
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="successPatterns.length === 0"
|
v-if="successJsonMode"
|
||||||
class="text-xs text-muted-foreground px-3 py-4 border border-dashed rounded-lg text-center"
|
class="space-y-2"
|
||||||
>
|
>
|
||||||
暂无规则
|
<Textarea
|
||||||
</div>
|
:model-value="successJsonDraft"
|
||||||
|
class="min-h-[160px] font-mono text-xs leading-relaxed"
|
||||||
<div
|
spellcheck="false"
|
||||||
v-for="(rule, index) in successPatterns"
|
placeholder="[{ "pattern": "relay:.*格式错误" }]"
|
||||||
:key="'s-' + index"
|
@update:model-value="updateSuccessJsonDraft"
|
||||||
class="flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
v-model="rule.pattern"
|
|
||||||
placeholder="例如: relay:.*格式错误"
|
|
||||||
size="sm"
|
|
||||||
class="font-mono text-xs flex-1"
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<div
|
||||||
variant="ghost"
|
v-if="successJsonError"
|
||||||
size="sm"
|
class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||||
class="shrink-0 h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
|
||||||
@click="removeRule('success', index)"
|
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3.5 h-3.5" />
|
{{ successJsonError }}
|
||||||
</Button>
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
仅管理成功转移规则;JSON 应为数组,<code class="bg-muted px-1 rounded">pattern</code> 必填。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-if="successPatterns.length === 0"
|
||||||
|
class="text-xs text-muted-foreground px-3 py-4 border border-dashed rounded-lg text-center"
|
||||||
|
>
|
||||||
|
暂无规则
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(rule, index) in successPatterns"
|
||||||
|
:key="'s-' + index"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-model="rule.pattern"
|
||||||
|
placeholder="例如: relay:.*格式错误"
|
||||||
|
size="sm"
|
||||||
|
class="font-mono text-xs flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="shrink-0 h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||||
|
@click="removeRule('success', index)"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 错误终止规则 -->
|
<!-- 错误终止规则 -->
|
||||||
@@ -68,54 +118,104 @@
|
|||||||
错误终止规则
|
错误终止规则
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-xs text-muted-foreground mt-0.5">
|
<p class="text-xs text-muted-foreground mt-0.5">
|
||||||
HTTP 非 200 且响应体匹配正则时,停止转移并直接返回错误。可选填状态码缩小匹配范围
|
HTTP 非 200 且规则命中时,停止转移并直接返回错误。状态码不填则所有错误状态都尝试匹配正则
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
class="shrink-0"
|
size="sm"
|
||||||
@click="addRule('error')"
|
class="h-7 text-xs px-2"
|
||||||
>
|
:title="errorJsonMode ? '切回错误终止表单' : '切到错误终止 JSON'"
|
||||||
<Plus class="w-4 h-4 mr-1" />
|
@click="toggleErrorJsonMode"
|
||||||
添加
|
>
|
||||||
</Button>
|
<Code2 class="w-3 h-3 mr-1" />
|
||||||
|
{{ errorJsonMode ? '表单' : 'JSON' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="errorJsonMode"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2 text-xs"
|
||||||
|
title="格式化错误终止 JSON"
|
||||||
|
@click="formatErrorJsonDraft"
|
||||||
|
>
|
||||||
|
<AlignLeft class="w-3 h-3 mr-1" />
|
||||||
|
格式化
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="!errorJsonMode"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 text-xs px-2"
|
||||||
|
@click="addRule('error')"
|
||||||
|
>
|
||||||
|
<Plus class="w-3 h-3 mr-1" />
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="errorPatterns.length === 0"
|
v-if="errorJsonMode"
|
||||||
class="text-xs text-muted-foreground px-3 py-4 border border-dashed rounded-lg text-center"
|
class="space-y-2"
|
||||||
>
|
>
|
||||||
暂无规则
|
<Textarea
|
||||||
|
:model-value="errorJsonDraft"
|
||||||
|
class="min-h-[180px] font-mono text-xs leading-relaxed"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="[{ "pattern": "content_policy_violation" }, { "status_codes": [429, 500, 503], "pattern": "" }]"
|
||||||
|
@update:model-value="updateErrorJsonDraft"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="errorJsonError"
|
||||||
|
class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||||
|
>
|
||||||
|
{{ errorJsonError }}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
仅管理错误终止规则;JSON 应为数组,<code class="bg-muted px-1 rounded">status_codes</code> 选填数组,不填则所有错误状态都尝试匹配 <code class="bg-muted px-1 rounded">pattern</code>;两者至少填一个。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<template v-else>
|
||||||
v-for="(rule, index) in errorPatterns"
|
<div
|
||||||
:key="'e-' + index"
|
v-if="errorPatterns.length === 0"
|
||||||
class="flex items-center gap-1"
|
class="text-xs text-muted-foreground px-3 py-4 border border-dashed rounded-lg text-center"
|
||||||
>
|
|
||||||
<Input
|
|
||||||
v-model="statusCodeInputs[index]"
|
|
||||||
placeholder="状态码 (可选)"
|
|
||||||
size="sm"
|
|
||||||
class="font-mono text-xs w-28 shrink-0"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
v-model="rule.pattern"
|
|
||||||
placeholder="例如: content_policy_violation"
|
|
||||||
size="sm"
|
|
||||||
class="font-mono text-xs flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="shrink-0 h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
|
||||||
@click="removeRule('error', index)"
|
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3.5 h-3.5" />
|
暂无规则
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div
|
||||||
|
v-for="(rule, index) in errorPatterns"
|
||||||
|
:key="'e-' + index"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-model="statusCodeInputs[index]"
|
||||||
|
placeholder="状态码 (选填,可多个)"
|
||||||
|
size="sm"
|
||||||
|
class="font-mono text-xs w-40 shrink-0"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="rule.pattern"
|
||||||
|
placeholder="正则内容 (选填)"
|
||||||
|
size="sm"
|
||||||
|
class="font-mono text-xs flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="shrink-0 h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||||
|
@click="removeRule('error', index)"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -143,12 +243,13 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
|
Textarea,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { GitBranch, Plus, Trash2 } from 'lucide-vue-next'
|
import { AlignLeft, Code2, GitBranch, Plus, Trash2 } from 'lucide-vue-next'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { updateProvider, type ProviderWithEndpointsSummary } from '@/api/endpoints'
|
import { updateProvider, type ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import type { FailoverRuleItem } from '@/api/endpoints/types'
|
import type { FailoverRuleItem, FailoverRulesConfig } from '@/api/endpoints/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -166,15 +267,105 @@ const saving = ref(false)
|
|||||||
const successPatterns = ref<FailoverRuleItem[]>([])
|
const successPatterns = ref<FailoverRuleItem[]>([])
|
||||||
const errorPatterns = ref<FailoverRuleItem[]>([])
|
const errorPatterns = ref<FailoverRuleItem[]>([])
|
||||||
const statusCodeInputs = ref<string[]>([])
|
const statusCodeInputs = ref<string[]>([])
|
||||||
|
const successJsonMode = ref(false)
|
||||||
|
const successJsonDraft = ref('')
|
||||||
|
const successJsonError = ref<string | null>(null)
|
||||||
|
const successJsonDirty = ref(false)
|
||||||
|
const errorJsonMode = ref(false)
|
||||||
|
const errorJsonDraft = ref('')
|
||||||
|
const errorJsonError = ref<string | null>(null)
|
||||||
|
const errorJsonDirty = ref(false)
|
||||||
|
|
||||||
|
const TOP_LEVEL_STOP_STATUS_CODE_KEYS = [
|
||||||
|
'stop_status_codes',
|
||||||
|
'stop_on_status_codes',
|
||||||
|
'early_stop_status_codes',
|
||||||
|
'non_retryable_status_codes',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const MANAGED_FAILOVER_RULE_KEYS = [
|
||||||
|
'success_failover_patterns',
|
||||||
|
'error_stop_patterns',
|
||||||
|
...TOP_LEVEL_STOP_STATUS_CODE_KEYS,
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueStatusCodes(codes: number[]): number[] {
|
||||||
|
return codes.filter((code, index, values) =>
|
||||||
|
Number.isInteger(code) && code >= 100 && code <= 599 && values.indexOf(code) === index
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTopLevelStopStatusCodes(rules: ProviderWithEndpointsSummary['failover_rules']): number[] {
|
||||||
|
if (!rules) return []
|
||||||
|
return uniqueStatusCodes(TOP_LEVEL_STOP_STATUS_CODE_KEYS.flatMap(key => rules[key] || []))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPersistableFailoverValue(value: unknown): boolean {
|
||||||
|
if (value === null || value === undefined) return false
|
||||||
|
if (Array.isArray(value)) return value.length > 0
|
||||||
|
if (typeof value === 'string') return value.trim().length > 0
|
||||||
|
if (isJsonObject(value)) return Object.values(value).some(hasPersistableFailoverValue)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreservedFailoverRules(
|
||||||
|
rules: ProviderWithEndpointsSummary['failover_rules'],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!isJsonObject(rules)) return {}
|
||||||
|
const preserved: Record<string, unknown> = { ...rules }
|
||||||
|
for (const key of MANAGED_FAILOVER_RULE_KEYS) {
|
||||||
|
delete preserved[key]
|
||||||
|
}
|
||||||
|
return preserved
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNextFailoverRules(
|
||||||
|
filteredSuccess: FailoverRuleItem[],
|
||||||
|
filteredError: FailoverRuleItem[],
|
||||||
|
): FailoverRulesConfig | null {
|
||||||
|
const nextRules = buildPreservedFailoverRules(props.provider?.failover_rules)
|
||||||
|
if (filteredSuccess.length > 0) {
|
||||||
|
nextRules.success_failover_patterns = filteredSuccess
|
||||||
|
}
|
||||||
|
if (filteredError.length > 0) {
|
||||||
|
nextRules.error_stop_patterns = filteredError
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(nextRules).some(hasPersistableFailoverValue)
|
||||||
|
? nextRules as FailoverRulesConfig
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => [props.open, props.provider], () => {
|
watch(() => [props.open, props.provider], () => {
|
||||||
if (props.open && props.provider) {
|
if (props.open && props.provider) {
|
||||||
const rules = props.provider.failover_rules
|
const rules = props.provider.failover_rules
|
||||||
successPatterns.value = (rules?.success_failover_patterns || []).map(r => ({ ...r }))
|
successPatterns.value = (rules?.success_failover_patterns || []).map(r => ({
|
||||||
errorPatterns.value = (rules?.error_stop_patterns || []).map(r => ({ ...r }))
|
...r,
|
||||||
|
pattern: r.pattern || '',
|
||||||
|
}))
|
||||||
|
errorPatterns.value = (rules?.error_stop_patterns || []).map(r => ({
|
||||||
|
...r,
|
||||||
|
pattern: r.pattern || '',
|
||||||
|
}))
|
||||||
|
const topLevelStopStatusCodes = collectTopLevelStopStatusCodes(rules)
|
||||||
|
if (topLevelStopStatusCodes.length > 0) {
|
||||||
|
errorPatterns.value.push({
|
||||||
|
pattern: '',
|
||||||
|
description: '',
|
||||||
|
status_codes: topLevelStopStatusCodes,
|
||||||
|
})
|
||||||
|
}
|
||||||
statusCodeInputs.value = errorPatterns.value.map(r =>
|
statusCodeInputs.value = errorPatterns.value.map(r =>
|
||||||
r.status_codes?.length ? r.status_codes.join(',') : ''
|
r.status_codes?.length ? r.status_codes.join(',') : ''
|
||||||
)
|
)
|
||||||
|
successJsonMode.value = false
|
||||||
|
errorJsonMode.value = false
|
||||||
|
refreshSuccessJsonDraft()
|
||||||
|
refreshErrorJsonDraft()
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
@@ -201,9 +392,16 @@ function handleClose() {
|
|||||||
emit('update:open', false)
|
emit('update:open', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStatusCodes(input: string): { valid: true; codes?: number[] } | { valid: false; reason: string } {
|
function parseStatusCodes(
|
||||||
|
input: string,
|
||||||
|
required = false,
|
||||||
|
): { valid: true; codes: number[] } | { valid: false; reason: string } {
|
||||||
const trimmed = input.trim()
|
const trimmed = input.trim()
|
||||||
if (!trimmed) return { valid: true }
|
if (!trimmed) {
|
||||||
|
return required
|
||||||
|
? { valid: false, reason: '状态码不能为空' }
|
||||||
|
: { valid: true, codes: [] }
|
||||||
|
}
|
||||||
const parts = trimmed.split(/[,\s]+/)
|
const parts = trimmed.split(/[,\s]+/)
|
||||||
const codes: number[] = []
|
const codes: number[] = []
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
@@ -213,11 +411,21 @@ function parseStatusCodes(input: string): { valid: true; codes?: number[] } | {
|
|||||||
if (n < 100 || n > 599) return { valid: false, reason: `${n} 不在 100-599 范围内` }
|
if (n < 100 || n > 599) return { valid: false, reason: `${n} 不在 100-599 范围内` }
|
||||||
codes.push(n)
|
codes.push(n)
|
||||||
}
|
}
|
||||||
return { valid: true, codes: codes.length > 0 ? codes : undefined }
|
const uniqueCodes = Array.from(new Set(codes))
|
||||||
|
return uniqueCodes.length > 0
|
||||||
|
? { valid: true, codes: uniqueCodes }
|
||||||
|
: required
|
||||||
|
? { valid: false, reason: '状态码不能为空' }
|
||||||
|
: { valid: true, codes: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
function validatePattern(pattern: string): string | null {
|
function validatePattern(pattern: string): string | null {
|
||||||
if (!pattern.trim()) return '正则表达式不能为空'
|
if (!pattern.trim()) return '正则表达式不能为空'
|
||||||
|
return validateOptionalPattern(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOptionalPattern(pattern: string): string | null {
|
||||||
|
if (!pattern.trim()) return null
|
||||||
try {
|
try {
|
||||||
new RegExp(pattern)
|
new RegExp(pattern)
|
||||||
return null
|
return null
|
||||||
@@ -226,12 +434,301 @@ function validatePattern(pattern: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSuccessJsonRulesFromForm(): FailoverRuleItem[] {
|
||||||
|
return successPatterns.value
|
||||||
|
.map(rule => ({
|
||||||
|
...rule,
|
||||||
|
pattern: rule.pattern.trim(),
|
||||||
|
status_codes: rule.status_codes?.length ? uniqueStatusCodes(rule.status_codes) : undefined,
|
||||||
|
}))
|
||||||
|
.filter(rule => rule.pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildErrorJsonRulesFromForm(): FailoverRuleItem[] {
|
||||||
|
return errorPatterns.value
|
||||||
|
.map((rule, index) => {
|
||||||
|
const parsed = parseStatusCodes(statusCodeInputs.value[index] || '')
|
||||||
|
const statusCodes = parsed.valid
|
||||||
|
? parsed.codes
|
||||||
|
: uniqueStatusCodes(rule.status_codes || [])
|
||||||
|
return {
|
||||||
|
...rule,
|
||||||
|
pattern: rule.pattern.trim(),
|
||||||
|
status_codes: statusCodes.length > 0 ? statusCodes : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(rule => rule.pattern || (rule.status_codes?.length || 0) > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyRuleArray(rules: FailoverRuleItem[]): string {
|
||||||
|
return JSON.stringify(rules, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSuccessJsonDraft() {
|
||||||
|
successJsonDraft.value = stringifyRuleArray(buildSuccessJsonRulesFromForm())
|
||||||
|
successJsonError.value = null
|
||||||
|
successJsonDirty.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshErrorJsonDraft() {
|
||||||
|
errorJsonDraft.value = stringifyRuleArray(buildErrorJsonRulesFromForm())
|
||||||
|
errorJsonError.value = null
|
||||||
|
errorJsonDirty.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSuccessJsonDraft(value: string) {
|
||||||
|
successJsonDraft.value = value
|
||||||
|
successJsonDirty.value = true
|
||||||
|
successJsonError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateErrorJsonDraft(value: string) {
|
||||||
|
errorJsonDraft.value = value
|
||||||
|
errorJsonDirty.value = true
|
||||||
|
errorJsonError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonRuleArray(
|
||||||
|
parsed: unknown,
|
||||||
|
key: 'success_failover_patterns' | 'error_stop_patterns',
|
||||||
|
): { root: Record<string, unknown>; value: unknown[]; error: string | null } {
|
||||||
|
if (Array.isArray(parsed)) return { root: {}, value: parsed, error: null }
|
||||||
|
if (!isJsonObject(parsed)) return { root: {}, value: [], error: '规则 JSON 必须是数组或对象' }
|
||||||
|
const root = isJsonObject(parsed.failover_rules) ? parsed.failover_rules : parsed
|
||||||
|
const raw = root[key]
|
||||||
|
if (raw === undefined || raw === null) return { root, value: [], error: null }
|
||||||
|
if (!Array.isArray(raw)) return { root, value: [], error: `${key} 必须是数组或 null` }
|
||||||
|
return { root, value: raw, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonStatusCodes(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
required: boolean,
|
||||||
|
): { codes: number[]; error: string | null } {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return required
|
||||||
|
? { codes: [], error: `${label}status_codes 必填` }
|
||||||
|
: { codes: [], error: null }
|
||||||
|
}
|
||||||
|
if (!Array.isArray(value)) return { codes: [], error: `${label}status_codes 必须是数组` }
|
||||||
|
const codes: number[] = []
|
||||||
|
for (const item of value) {
|
||||||
|
if (!Number.isInteger(item)) {
|
||||||
|
return { codes: [], error: `${label}status_codes 只能包含整数` }
|
||||||
|
}
|
||||||
|
const code = item as number
|
||||||
|
if (code < 100 || code > 599) {
|
||||||
|
return { codes: [], error: `${label}status_codes 只能填写 100-599` }
|
||||||
|
}
|
||||||
|
codes.push(code)
|
||||||
|
}
|
||||||
|
const uniqueCodes = uniqueStatusCodes(codes)
|
||||||
|
if (required && uniqueCodes.length === 0) {
|
||||||
|
return { codes: [], error: `${label}status_codes 必填` }
|
||||||
|
}
|
||||||
|
return { codes: uniqueCodes, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonSuccessRule(rule: unknown, index: number): { rule: FailoverRuleItem | null; error: string | null } {
|
||||||
|
const label = `成功转移 JSON 第 ${index + 1} 条:`
|
||||||
|
if (!isJsonObject(rule)) return { rule: null, error: `${label}必须是对象` }
|
||||||
|
if (typeof rule.pattern !== 'string' || !rule.pattern.trim()) {
|
||||||
|
return { rule: null, error: `${label}pattern 必填` }
|
||||||
|
}
|
||||||
|
const pattern = rule.pattern.trim()
|
||||||
|
const patternError = validatePattern(pattern)
|
||||||
|
if (patternError) return { rule: null, error: `${label}${patternError}` }
|
||||||
|
const status = parseJsonStatusCodes(rule.status_codes, label, false)
|
||||||
|
if (status.error) return { rule: null, error: status.error }
|
||||||
|
|
||||||
|
return {
|
||||||
|
rule: {
|
||||||
|
pattern,
|
||||||
|
description: typeof rule.description === 'string' ? rule.description : undefined,
|
||||||
|
status_codes: status.codes.length > 0 ? status.codes : undefined,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonErrorRule(rule: unknown, index: number): { rule: FailoverRuleItem | null; error: string | null } {
|
||||||
|
const label = `错误终止 JSON 第 ${index + 1} 条:`
|
||||||
|
if (!isJsonObject(rule)) return { rule: null, error: `${label}必须是对象` }
|
||||||
|
const status = parseJsonStatusCodes(rule.status_codes, label, false)
|
||||||
|
if (status.error) return { rule: null, error: status.error }
|
||||||
|
if (rule.pattern !== undefined && rule.pattern !== null && typeof rule.pattern !== 'string') {
|
||||||
|
return { rule: null, error: `${label}pattern 必须是字符串` }
|
||||||
|
}
|
||||||
|
const pattern = typeof rule.pattern === 'string' ? rule.pattern.trim() : ''
|
||||||
|
const patternError = validateOptionalPattern(pattern)
|
||||||
|
if (patternError) return { rule: null, error: `${label}${patternError}` }
|
||||||
|
if (status.codes.length === 0 && !pattern) {
|
||||||
|
return { rule: null, error: `${label}status_codes 和 pattern 至少填写一个` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rule: {
|
||||||
|
pattern,
|
||||||
|
description: typeof rule.description === 'string' ? rule.description : undefined,
|
||||||
|
status_codes: status.codes.length > 0 ? status.codes : undefined,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSuccessRulesJsonDraft(draft: string): { value: FailoverRuleItem[] | null; error: string | null } {
|
||||||
|
const raw = draft.trim()
|
||||||
|
if (!raw) return { value: [], error: null }
|
||||||
|
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { value: null, error: error instanceof Error ? error.message : 'JSON 格式无效' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = readJsonRuleArray(parsed, 'success_failover_patterns')
|
||||||
|
if (rules.error) return { value: null, error: rules.error }
|
||||||
|
|
||||||
|
const normalized: FailoverRuleItem[] = []
|
||||||
|
for (let i = 0; i < rules.value.length; i++) {
|
||||||
|
const parsedRule = parseJsonSuccessRule(rules.value[i], i)
|
||||||
|
if (parsedRule.error || !parsedRule.rule) return { value: null, error: parsedRule.error }
|
||||||
|
normalized.push(parsedRule.rule)
|
||||||
|
}
|
||||||
|
return { value: normalized, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseErrorRulesJsonDraft(draft: string): { value: FailoverRuleItem[] | null; error: string | null } {
|
||||||
|
const raw = draft.trim()
|
||||||
|
if (!raw) return { value: [], error: null }
|
||||||
|
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { value: null, error: error instanceof Error ? error.message : 'JSON 格式无效' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = readJsonRuleArray(parsed, 'error_stop_patterns')
|
||||||
|
if (rules.error) return { value: null, error: rules.error }
|
||||||
|
|
||||||
|
const normalized: FailoverRuleItem[] = []
|
||||||
|
for (let i = 0; i < rules.value.length; i++) {
|
||||||
|
const parsedRule = parseJsonErrorRule(rules.value[i], i)
|
||||||
|
if (parsedRule.error || !parsedRule.rule) return { value: null, error: parsedRule.error }
|
||||||
|
normalized.push(parsedRule.rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of TOP_LEVEL_STOP_STATUS_CODE_KEYS) {
|
||||||
|
const status = parseJsonStatusCodes(rules.root[key], `${key}:`, false)
|
||||||
|
if (status.error) return { value: null, error: status.error }
|
||||||
|
if (status.codes.length > 0) {
|
||||||
|
normalized.push({
|
||||||
|
pattern: '',
|
||||||
|
description: '',
|
||||||
|
status_codes: status.codes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: normalized, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySuccessJsonDraft(options: { notify?: boolean; notifyError?: boolean } = {}): boolean {
|
||||||
|
const notifyError = options.notifyError !== false
|
||||||
|
const parsed = parseSuccessRulesJsonDraft(successJsonDraft.value)
|
||||||
|
if (!parsed.value) {
|
||||||
|
successJsonError.value = parsed.error
|
||||||
|
if (notifyError) showError(parsed.error || '成功转移规则 JSON 无效', '验证失败')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
successPatterns.value = parsed.value.map(rule => ({ ...rule }))
|
||||||
|
successJsonDraft.value = stringifyRuleArray(parsed.value)
|
||||||
|
successJsonError.value = null
|
||||||
|
successJsonDirty.value = false
|
||||||
|
if (options.notify !== false) success('成功转移 JSON 已应用')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyErrorJsonDraft(options: { notify?: boolean; notifyError?: boolean } = {}): boolean {
|
||||||
|
const notifyError = options.notifyError !== false
|
||||||
|
const parsed = parseErrorRulesJsonDraft(errorJsonDraft.value)
|
||||||
|
if (!parsed.value) {
|
||||||
|
errorJsonError.value = parsed.error
|
||||||
|
if (notifyError) showError(parsed.error || '错误终止规则 JSON 无效', '验证失败')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
errorPatterns.value = parsed.value.map(rule => ({ ...rule }))
|
||||||
|
statusCodeInputs.value = errorPatterns.value.map(rule => rule.status_codes?.join(',') || '')
|
||||||
|
errorJsonDraft.value = stringifyRuleArray(parsed.value)
|
||||||
|
errorJsonError.value = null
|
||||||
|
errorJsonDirty.value = false
|
||||||
|
if (options.notify !== false) success('错误终止 JSON 已应用')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSuccessJsonMode() {
|
||||||
|
if (successJsonMode.value) {
|
||||||
|
if (successJsonDirty.value && !applySuccessJsonDraft({ notify: false })) return
|
||||||
|
successJsonMode.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshSuccessJsonDraft()
|
||||||
|
successJsonMode.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleErrorJsonMode() {
|
||||||
|
if (errorJsonMode.value) {
|
||||||
|
if (errorJsonDirty.value && !applyErrorJsonDraft({ notify: false })) return
|
||||||
|
errorJsonMode.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshErrorJsonDraft()
|
||||||
|
errorJsonMode.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSuccessJsonDraft() {
|
||||||
|
const currentDraft = successJsonDraft.value
|
||||||
|
const parsed = parseSuccessRulesJsonDraft(currentDraft)
|
||||||
|
if (!parsed.value) {
|
||||||
|
successJsonError.value = parsed.error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const formattedDraft = stringifyRuleArray(parsed.value)
|
||||||
|
successJsonDraft.value = formattedDraft
|
||||||
|
successJsonError.value = null
|
||||||
|
if (formattedDraft !== currentDraft) {
|
||||||
|
successJsonDirty.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatErrorJsonDraft() {
|
||||||
|
const currentDraft = errorJsonDraft.value
|
||||||
|
const parsed = parseErrorRulesJsonDraft(currentDraft)
|
||||||
|
if (!parsed.value) {
|
||||||
|
errorJsonError.value = parsed.error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const formattedDraft = stringifyRuleArray(parsed.value)
|
||||||
|
errorJsonDraft.value = formattedDraft
|
||||||
|
errorJsonError.value = null
|
||||||
|
if (formattedDraft !== currentDraft) {
|
||||||
|
errorJsonDirty.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!props.provider) return
|
if (!props.provider) return
|
||||||
|
|
||||||
|
if (successJsonMode.value && !applySuccessJsonDraft({ notify: false })) return
|
||||||
|
if (errorJsonMode.value && !applyErrorJsonDraft({ notify: false })) return
|
||||||
|
|
||||||
// Validate patterns
|
// Validate patterns
|
||||||
const allPatterns = [...successPatterns.value, ...errorPatterns.value]
|
for (const rule of successPatterns.value) {
|
||||||
for (const rule of allPatterns) {
|
|
||||||
const err = validatePattern(rule.pattern)
|
const err = validatePattern(rule.pattern)
|
||||||
if (err) {
|
if (err) {
|
||||||
showError(err, '验证失败')
|
showError(err, '验证失败')
|
||||||
@@ -247,23 +744,39 @@ async function handleSave() {
|
|||||||
showError(`状态码格式错误: ${result.reason},请输入 100-599 之间的整数,多个用逗号分隔`, '验证失败')
|
showError(`状态码格式错误: ${result.reason},请输入 100-599 之间的整数,多个用逗号分隔`, '验证失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
errorPatterns.value[i].status_codes = result.codes
|
const patternErr = validateOptionalPattern(errorPatterns.value[i].pattern)
|
||||||
|
if (patternErr) {
|
||||||
|
showError(patternErr, '验证失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pattern = errorPatterns.value[i].pattern.trim()
|
||||||
|
if (result.codes.length === 0 && !pattern) {
|
||||||
|
showError(`第 ${i + 1} 条错误终止规则:状态码和正则内容至少填写一个`, '验证失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errorPatterns.value[i].status_codes = result.codes.length > 0 ? result.codes : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const filteredSuccess = successPatterns.value.filter(r => r.pattern.trim())
|
const filteredSuccess = successPatterns.value
|
||||||
const filteredError = errorPatterns.value.filter(r => r.pattern.trim())
|
.map(r => ({ ...r, pattern: r.pattern.trim() }))
|
||||||
|
.filter(r => r.pattern)
|
||||||
|
const filteredError = errorPatterns.value
|
||||||
|
.map(r => {
|
||||||
|
const statusCodes = r.status_codes?.length ? r.status_codes : undefined
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
pattern: r.pattern.trim(),
|
||||||
|
status_codes: statusCodes,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(r => r.pattern || (r.status_codes?.length || 0) > 0)
|
||||||
|
|
||||||
const hasRules = filteredSuccess.length > 0 || filteredError.length > 0
|
const nextFailoverRules = buildNextFailoverRules(filteredSuccess, filteredError)
|
||||||
|
|
||||||
await updateProvider(props.provider.id, {
|
await updateProvider(props.provider.id, {
|
||||||
failover_rules: hasRules
|
failover_rules: nextFailoverRules,
|
||||||
? {
|
|
||||||
success_failover_patterns: filteredSuccess,
|
|
||||||
error_stop_patterns: filteredError,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
success('故障转移规则已保存')
|
success('故障转移规则已保存')
|
||||||
|
|||||||
@@ -1627,11 +1627,23 @@ const antigravityQuotaDialogKey = ref<EndpointAPIKey | null>(null)
|
|||||||
|
|
||||||
// 故障转移规则
|
// 故障转移规则
|
||||||
const failoverRulesDialogOpen = ref(false)
|
const failoverRulesDialogOpen = ref(false)
|
||||||
|
const FAILOVER_RULE_ARRAY_KEYS = [
|
||||||
|
'success_failover_patterns',
|
||||||
|
'error_stop_patterns',
|
||||||
|
'stop_status_codes',
|
||||||
|
'stop_on_status_codes',
|
||||||
|
'early_stop_status_codes',
|
||||||
|
'non_retryable_status_codes',
|
||||||
|
'continue_on_status_codes',
|
||||||
|
'retryable_status_codes',
|
||||||
|
'retry_on_status_codes',
|
||||||
|
'continue_status_codes',
|
||||||
|
] as const
|
||||||
const hasFailoverRules = computed(() => {
|
const hasFailoverRules = computed(() => {
|
||||||
const rules = provider.value?.failover_rules
|
const rules = provider.value?.failover_rules
|
||||||
if (!rules) return false
|
if (!rules) return false
|
||||||
return (rules.success_failover_patterns?.length || 0) > 0
|
return FAILOVER_RULE_ARRAY_KEYS.some(key => (rules[key]?.length || 0) > 0)
|
||||||
|| (rules.error_stop_patterns?.length || 0) > 0
|
|| typeof rules.max_retries === 'number'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Provider 级别代理配置状态
|
// Provider 级别代理配置状态
|
||||||
|
|||||||
@@ -1401,9 +1401,8 @@ const currentAttemptFailureDiagnostic = computed<{
|
|||||||
const attempt = currentAttempt.value
|
const attempt = currentAttempt.value
|
||||||
if (!attempt) return null
|
if (!attempt) return null
|
||||||
const extra = extractObject(attempt.extra_data)
|
const extra = extractObject(attempt.extra_data)
|
||||||
const failureDiagnostic = extractObject(extra?.failure_diagnostic)
|
const failureDiagnostic = extractVisibleFailureDiagnostic(extra)
|
||||||
const safeToShow = failureDiagnostic?.safe_to_show !== false
|
const error = failureDiagnostic
|
||||||
const error = failureDiagnostic && safeToShow
|
|
||||||
? failureDiagnostic
|
? failureDiagnostic
|
||||||
: extractObject(extra?.request_body_build_error)
|
: extractObject(extra?.request_body_build_error)
|
||||||
const path = typeof error?.path === 'string' && error.path.trim()
|
const path = typeof error?.path === 'string' && error.path.trim()
|
||||||
@@ -1415,19 +1414,205 @@ const currentAttemptFailureDiagnostic = computed<{
|
|||||||
if (!path && !message) return null
|
if (!path && !message) return null
|
||||||
return {
|
return {
|
||||||
path: path || '$',
|
path: path || '$',
|
||||||
message: message || '请求体转换失败',
|
message: formatAttemptErrorMessage(message) || message || '请求体转换失败',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isGenericExecutionRuntimeStatusMessage = (message: string): boolean =>
|
||||||
|
/execution runtime (stream )?returned non-success status \d+/i.test(message)
|
||||||
|
|
||||||
|
const isLocalSyncFinalizeDiagnostic = (message: string): boolean =>
|
||||||
|
/local sync attempt failed before terminal finalization/i.test(message)
|
||||||
|
|| /unsupported provider stream (event|finish reason)/i.test(message)
|
||||||
|
|
||||||
|
const isActionableDiagnosticMessage = (message: string): boolean =>
|
||||||
|
isLocalSyncFinalizeDiagnostic(message) || isConversionDiagnosticMessage(message)
|
||||||
|
|
||||||
|
const extractVisibleFailureDiagnostic = (
|
||||||
|
extra: Record<string, unknown> | null | undefined,
|
||||||
|
): Record<string, unknown> | null => {
|
||||||
|
const failureDiagnostic = extractObject(extra?.failure_diagnostic)
|
||||||
|
if (!failureDiagnostic || failureDiagnostic.safe_to_show === false) return null
|
||||||
|
return failureDiagnostic
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractVisibleDiagnosticObjects = (
|
||||||
|
extra: Record<string, unknown> | null | undefined,
|
||||||
|
): Array<Record<string, unknown>> => [
|
||||||
|
extractVisibleFailureDiagnostic(extra),
|
||||||
|
extractObject(extra?.request_conversion_error),
|
||||||
|
extractObject(extra?.request_body_build_error),
|
||||||
|
].filter((value): value is Record<string, unknown> => Boolean(value))
|
||||||
|
|
||||||
|
const extractVisibleDiagnosticMessage = (
|
||||||
|
extra: Record<string, unknown> | null | undefined,
|
||||||
|
): string => {
|
||||||
|
for (const diagnostic of extractVisibleDiagnosticObjects(extra)) {
|
||||||
|
const message = readStringField(diagnostic, 'message')
|
||||||
|
if (message) return message
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const chooseAttemptRawErrorMessage = (
|
||||||
|
flowMessage: string,
|
||||||
|
fallbackMessage: string,
|
||||||
|
diagnosticMessage: string,
|
||||||
|
): string => {
|
||||||
|
const flow = flowMessage.trim()
|
||||||
|
const fallback = fallbackMessage.trim()
|
||||||
|
const diagnostic = diagnosticMessage.trim()
|
||||||
|
|
||||||
|
if (flow && fallback && !isActionableDiagnosticMessage(flow) && isActionableDiagnosticMessage(fallback)) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if (flow && diagnostic && isGenericExecutionRuntimeStatusMessage(flow) && isActionableDiagnosticMessage(diagnostic)) {
|
||||||
|
return diagnostic
|
||||||
|
}
|
||||||
|
if (fallback && diagnostic && isGenericExecutionRuntimeStatusMessage(fallback) && isActionableDiagnosticMessage(diagnostic)) {
|
||||||
|
return diagnostic
|
||||||
|
}
|
||||||
|
|
||||||
|
return flow || fallback || diagnostic
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodeRustDebugString = (value: string): string => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(`"${value}"`)
|
||||||
|
} catch {
|
||||||
|
return value.replace(/\\"/g, '"')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeDiagnosticFieldPath = (field: string): string => {
|
||||||
|
const trimmed = field.trim()
|
||||||
|
if (!trimmed || trimmed === '$') return '$'
|
||||||
|
if (trimmed.startsWith('$')) return trimmed
|
||||||
|
if (trimmed.startsWith('[')) return `$${trimmed}`
|
||||||
|
return `$.${trimmed}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatConversionPair = (source: string, target: string): string =>
|
||||||
|
`${formatApiFormat(source.trim())} → ${formatApiFormat(target.trim())}`
|
||||||
|
|
||||||
|
const extractFieldDetail = (message: string): string => {
|
||||||
|
const fieldMatch = message.match(/field\s+([^;=]+?)\s*=\s*(\"(?:\\.|[^"\\])*\"|[^;]+)/i)
|
||||||
|
const unsupportedFieldMatch = message.match(/field\s+([^;]+?)\s+is unsupported/i)
|
||||||
|
if (fieldMatch?.[1]) {
|
||||||
|
return `字段 ${normalizeDiagnosticFieldPath(fieldMatch[1])} = ${fieldMatch[2].trim()}`
|
||||||
|
}
|
||||||
|
if (unsupportedFieldMatch?.[1]) {
|
||||||
|
return `字段 ${normalizeDiagnosticFieldPath(unsupportedFieldMatch[1])} 不支持`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatUnsupportedStreamEventMessage = (message: string): string => {
|
||||||
|
const detail = extractFieldDetail(message)
|
||||||
|
const fieldDetail = detail ? `(${detail})` : ''
|
||||||
|
return `流式格式转换失败:上游返回了当前不支持的 stream event${fieldDetail},无法无损转换到客户端请求格式`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatUnsupportedFinishReasonMessage = (message: string): string => {
|
||||||
|
const fieldDetail = extractFieldDetail(message)
|
||||||
|
const legacyMatch = message.match(/unsupported provider stream finish reason\s+(.+?)\s+cannot be converted losslessly/i)
|
||||||
|
const detail = fieldDetail || (legacyMatch?.[1]
|
||||||
|
? `字段 $.finish_reason = ${legacyMatch[1].trim()}`
|
||||||
|
: '')
|
||||||
|
return `流式格式转换失败:上游返回了当前不支持的 finish reason${detail ? `(${detail})` : ''},无法无损转换到客户端请求格式`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatKnownConversionErrorMessage = (message: string): string => {
|
||||||
|
const lossy = message.match(/^lossy conversion blocked from\s+(\S+)\s+to\s+(\S+)\s+at\s+([^:]+):\s*(.+)$/i)
|
||||||
|
if (lossy) {
|
||||||
|
return `格式转换失败:${formatConversionPair(lossy[1], lossy[2])} 在字段 ${normalizeDiagnosticFieldPath(lossy[3])} 会丢失信息:${lossy[4].trim()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const unaudited = message.match(/^unaudited field\s+(.+?)\s+in\s+(.+?)\s+cannot be converted to\s+([^:]+):\s*(.+)$/i)
|
||||||
|
if (unaudited) {
|
||||||
|
return `格式转换失败:${formatConversionPair(unaudited[2], unaudited[3])} 的字段 ${normalizeDiagnosticFieldPath(unaudited[1])} 尚未审计,不能安全转换:${unaudited[4].trim()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsupportedField = message.match(/^unsupported field\s+(.+?)\s+in\s+([^:]+(?::[^:]+)?):\s*(.+)$/i)
|
||||||
|
if (unsupportedField) {
|
||||||
|
return `格式转换失败:${formatApiFormat(unsupportedField[2])} 不支持字段 ${normalizeDiagnosticFieldPath(unsupportedField[1])}:${unsupportedField[3].trim()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidEnum = message.match(/^invalid enum value\s+(.+?)\s+for\s+(.+)\.([^.\s]+)$/i)
|
||||||
|
if (invalidEnum) {
|
||||||
|
return `格式转换失败:${formatApiFormat(invalidEnum[2])} 字段 ${normalizeDiagnosticFieldPath(invalidEnum[3])} 的枚举值 ${invalidEnum[1].trim()} 无效`
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidTarget = message.match(/^invalid target field\s+(.+?)\s+for\s+(.+?):\s*(.+)$/i)
|
||||||
|
if (invalidTarget) {
|
||||||
|
return `格式转换失败:目标格式 ${formatApiFormat(invalidTarget[2])} 字段 ${normalizeDiagnosticFieldPath(invalidTarget[1])} 无效:${invalidTarget[3].trim()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsupportedFormat = message.match(/^unsupported AI format:\s*(.+)$/i)
|
||||||
|
if (unsupportedFormat) {
|
||||||
|
return `格式转换失败:不支持的 API 格式 ${unsupportedFormat[1].trim()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseEmit = message.match(/^failed to\s+(parse|emit)\s+(.+?)\s+(request|response)$/i)
|
||||||
|
if (parseEmit) {
|
||||||
|
const action = parseEmit[1].toLowerCase() === 'parse' ? '解析' : '生成'
|
||||||
|
const subject = parseEmit[3].toLowerCase() === 'request' ? '请求体' : '响应体'
|
||||||
|
return `格式转换失败:无法${action} ${formatApiFormat(parseEmit[2])} ${subject}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConversionDiagnosticMessage = (message: string): boolean => {
|
||||||
|
const normalized = message.trim()
|
||||||
|
if (!normalized) return false
|
||||||
|
return /conversion|converted|convertible|cannot be converted|lossy conversion|unsupported field|unaudited field|invalid enum value|invalid target field|unsupported ai format|failed to (parse|emit) .+ (request|response)|unsupported provider stream (event|finish reason)|转换|无损|字段 .*不支持/i
|
||||||
|
.test(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
const formatAttemptErrorMessage = (message: string, statusCode?: number): string => {
|
const formatAttemptErrorMessage = (message: string, statusCode?: number): string => {
|
||||||
const normalized = message.trim()
|
const normalized = message.trim()
|
||||||
if (!normalized) return ''
|
if (!normalized) return ''
|
||||||
if (/execution runtime (stream )?returned non-success status \d+/i.test(normalized)) {
|
const directInternal = normalized.match(/^Internal\("((?:\\.|[^"\\])*)"\)$/i)
|
||||||
|
if (directInternal?.[1]) {
|
||||||
|
return formatAttemptErrorMessage(decodeRustDebugString(directInternal[1]), statusCode)
|
||||||
|
}
|
||||||
|
const localSyncInternal = normalized.match(/local sync attempt failed before terminal finalization:\s*Internal\("((?:\\.|[^"\\])*)"\)/i)
|
||||||
|
if (localSyncInternal?.[1]) {
|
||||||
|
return formatAttemptErrorMessage(decodeRustDebugString(localSyncInternal[1]), statusCode)
|
||||||
|
}
|
||||||
|
if (/unsupported provider stream event cannot be converted losslessly/i.test(normalized)) {
|
||||||
|
return formatUnsupportedStreamEventMessage(normalized)
|
||||||
|
}
|
||||||
|
if (/unsupported provider stream finish reason/i.test(normalized)) {
|
||||||
|
return formatUnsupportedFinishReasonMessage(normalized)
|
||||||
|
}
|
||||||
|
const conversionMessage = formatKnownConversionErrorMessage(normalized)
|
||||||
|
if (conversionMessage) {
|
||||||
|
return conversionMessage
|
||||||
|
}
|
||||||
|
if (isGenericExecutionRuntimeStatusMessage(normalized)) {
|
||||||
return statusCode != null ? `上游返回非成功状态 ${statusCode}` : '上游返回非成功状态'
|
return statusCode != null ? `上游返回非成功状态 ${statusCode}` : '上游返回非成功状态'
|
||||||
}
|
}
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldShowAttemptMessageWithUpstreamResponse = (
|
||||||
|
rawMessage: string,
|
||||||
|
upstreamResponse: Record<string, unknown> | null,
|
||||||
|
): boolean => {
|
||||||
|
if (!upstreamResponse) return true
|
||||||
|
const normalized = rawMessage.trim()
|
||||||
|
if (!normalized) return false
|
||||||
|
if (isLocalSyncFinalizeDiagnostic(normalized)) return true
|
||||||
|
if (isConversionDiagnosticMessage(normalized)) return true
|
||||||
|
if (isGenericExecutionRuntimeStatusMessage(normalized)) return false
|
||||||
|
|
||||||
|
const hasBody = hasRenderableValue(upstreamResponse.body)
|
||||||
|
const bodyState = (readStringField(upstreamResponse, 'body_state') ?? '').toLowerCase()
|
||||||
|
return !hasBody && bodyState === 'disabled'
|
||||||
|
}
|
||||||
|
|
||||||
const currentAttemptRequestError = computed<{
|
const currentAttemptRequestError = computed<{
|
||||||
message: string
|
message: string
|
||||||
statusCode?: number
|
statusCode?: number
|
||||||
@@ -1453,17 +1638,177 @@ const currentAttemptRequestError = computed<{
|
|||||||
const fallbackType = typeof attempt.error_type === 'string' && attempt.error_type.trim()
|
const fallbackType = typeof attempt.error_type === 'string' && attempt.error_type.trim()
|
||||||
? attempt.error_type.trim()
|
? attempt.error_type.trim()
|
||||||
: ''
|
: ''
|
||||||
const message = formatAttemptErrorMessage(flowMessage || fallbackMessage, statusCode) || fallbackType
|
const diagnosticMessage = extractVisibleDiagnosticMessage(extra)
|
||||||
|
const rawMessage = chooseAttemptRawErrorMessage(flowMessage || '', fallbackMessage, diagnosticMessage)
|
||||||
|
const message = formatAttemptErrorMessage(rawMessage, statusCode) || fallbackType
|
||||||
const upstreamResponseDisplay = normalizeUpstreamResponseDisplay(extra?.upstream_response)
|
const upstreamResponseDisplay = normalizeUpstreamResponseDisplay(extra?.upstream_response)
|
||||||
if (!message && statusCode == null && !upstreamResponseDisplay) return null
|
const visibleDiagnosticObjects = extractVisibleDiagnosticObjects(extra)
|
||||||
|
const shouldAttachDiagnostic = Boolean(
|
||||||
|
visibleDiagnosticObjects.length
|
||||||
|
|| isLocalSyncFinalizeDiagnostic(rawMessage)
|
||||||
|
|| isConversionDiagnosticMessage(rawMessage),
|
||||||
|
)
|
||||||
|
const diagnostic = shouldAttachDiagnostic
|
||||||
|
? buildAttemptDiagnosticPayload(
|
||||||
|
attempt,
|
||||||
|
message || fallbackType || rawMessage || '未知失败',
|
||||||
|
statusCode,
|
||||||
|
upstreamResponseDisplay,
|
||||||
|
rawMessage,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
const upstreamResponseWithDiagnostic = diagnostic
|
||||||
|
? { ...(upstreamResponseDisplay ?? {}), diagnostic }
|
||||||
|
: upstreamResponseDisplay
|
||||||
|
if (!message && statusCode == null && !upstreamResponseWithDiagnostic) return null
|
||||||
|
const showMessage = shouldShowAttemptMessageWithUpstreamResponse(
|
||||||
|
rawMessage || fallbackType,
|
||||||
|
upstreamResponseDisplay,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: upstreamResponseDisplay ? '' : (message || '未知错误'),
|
message: showMessage ? (message || '未知错误') : '',
|
||||||
statusCode,
|
statusCode,
|
||||||
upstreamResponse: upstreamResponseDisplay,
|
upstreamResponse: upstreamResponseWithDiagnostic,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const diagnosticPathFromObject = (value: unknown): string => {
|
||||||
|
const object = extractObject(value)
|
||||||
|
if (!object) return ''
|
||||||
|
return readStringField(object, 'path')
|
||||||
|
|| readStringField(object, 'field_path')
|
||||||
|
|| readStringField(object, 'fieldPath')
|
||||||
|
|| readStringField(object, 'field')
|
||||||
|
|| ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const diagnosticFieldPathFromMessage = (message: string): string => {
|
||||||
|
const normalized = message.trim()
|
||||||
|
if (!normalized) return ''
|
||||||
|
const fieldMatch = normalized.match(/field\s+([^;=]+?)\s*(?:=|is unsupported|不支持)/i)
|
||||||
|
if (fieldMatch?.[1]) return normalizeDiagnosticFieldPath(fieldMatch[1])
|
||||||
|
const lossyMatch = normalized.match(/lossy conversion blocked from\s+\S+\s+to\s+\S+\s+at\s+([^:]+):/i)
|
||||||
|
if (lossyMatch?.[1]) return normalizeDiagnosticFieldPath(lossyMatch[1])
|
||||||
|
const invalidTargetMatch = normalized.match(/invalid target field\s+(.+?)\s+for\s+/i)
|
||||||
|
if (invalidTargetMatch?.[1]) return normalizeDiagnosticFieldPath(invalidTargetMatch[1])
|
||||||
|
const unsupportedFieldMatch = normalized.match(/unsupported field\s+(.+?)\s+in\s+/i)
|
||||||
|
if (unsupportedFieldMatch?.[1]) return normalizeDiagnosticFieldPath(unsupportedFieldMatch[1])
|
||||||
|
const invalidEnumMatch = normalized.match(/invalid enum value\s+.+?\s+for\s+.+\.([^.\s]+)$/i)
|
||||||
|
if (invalidEnumMatch?.[1]) return normalizeDiagnosticFieldPath(invalidEnumMatch[1])
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAttemptDiagnosticBreakpoint(attempt: CandidateRecord, rawMessageOverride = ''): string {
|
||||||
|
const extra = extractObject(attempt.extra_data)
|
||||||
|
const failureDiagnostic = extractVisibleFailureDiagnostic(extra)
|
||||||
|
const requestConversionError = extractObject(extra?.request_conversion_error)
|
||||||
|
const requestBodyBuildError = extractObject(extra?.request_body_build_error)
|
||||||
|
const errorFlow = extractObject(extra?.error_flow)
|
||||||
|
const rawMessage = [
|
||||||
|
rawMessageOverride,
|
||||||
|
readStringField(errorFlow ?? {}, 'message') ?? '',
|
||||||
|
readStringField(failureDiagnostic ?? {}, 'message') ?? '',
|
||||||
|
readStringField(requestConversionError ?? {}, 'message') ?? '',
|
||||||
|
readStringField(requestBodyBuildError ?? {}, 'message') ?? '',
|
||||||
|
typeof attempt.error_message === 'string' ? attempt.error_message : '',
|
||||||
|
].find(item => item.trim()) ?? ''
|
||||||
|
|
||||||
|
return diagnosticPathFromObject(failureDiagnostic)
|
||||||
|
|| diagnosticPathFromObject(requestConversionError)
|
||||||
|
|| diagnosticPathFromObject(requestBodyBuildError)
|
||||||
|
|| diagnosticFieldPathFromMessage(rawMessage)
|
||||||
|
|| '$'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAttemptDiagnosticPayload(
|
||||||
|
attempt: CandidateRecord,
|
||||||
|
summaryInput: string,
|
||||||
|
statusCode: number | undefined,
|
||||||
|
upstreamResponseDisplay: Record<string, unknown> | null,
|
||||||
|
rawMessageForBreakpoint = '',
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const extra = extractObject(attempt.extra_data)
|
||||||
|
const rawFailureDiagnostic = extractVisibleFailureDiagnostic(extra)
|
||||||
|
const rawRequestConversionError = extractObject(extra?.request_conversion_error)
|
||||||
|
const rawRequestBodyBuildError = extractObject(extra?.request_body_build_error)
|
||||||
|
const hasDiagnostic = Boolean(
|
||||||
|
summaryInput
|
||||||
|
|| rawFailureDiagnostic
|
||||||
|
|| rawRequestConversionError
|
||||||
|
|| rawRequestBodyBuildError
|
||||||
|
|| attempt.error_message
|
||||||
|
|| attempt.error_type
|
||||||
|
|| attempt.skip_reason
|
||||||
|
|| upstreamResponseDisplay,
|
||||||
|
)
|
||||||
|
if (!hasDiagnostic) return null
|
||||||
|
|
||||||
|
const summary = summaryInput
|
||||||
|
|| readStringField(rawFailureDiagnostic ?? {}, 'message')
|
||||||
|
|| readStringField(rawRequestConversionError ?? {}, 'message')
|
||||||
|
|| readStringField(rawRequestBodyBuildError ?? {}, 'message')
|
||||||
|
|| (typeof attempt.error_message === 'string' ? formatAttemptErrorMessage(attempt.error_message, attempt.status_code) : '')
|
||||||
|
|| attempt.error_type
|
||||||
|
|| currentAttemptSkipReasonDisplay.value
|
||||||
|
|| '未知失败'
|
||||||
|
const breakpoint = resolveAttemptDiagnosticBreakpoint(attempt, rawMessageForBreakpoint)
|
||||||
|
const providerFormat = readStringField(extra ?? {}, 'provider_api_format')
|
||||||
|
const clientFormat = readStringField(extra ?? {}, 'client_api_format')
|
||||||
|
|| (typeof props.requestApiFormat === 'string' ? props.requestApiFormat : '')
|
||||||
|
const conversionDisplay = clientFormat || providerFormat
|
||||||
|
? `${clientFormat ? formatApiFormat(clientFormat) : '未知请求格式'} → ${providerFormat ? formatApiFormat(providerFormat) : '未知上游格式'}`
|
||||||
|
: currentAttemptFormatDisplay.value
|
||||||
|
const analysisHint = (() => {
|
||||||
|
const raw = `${summary}\n${rawMessageForBreakpoint}\n${attempt.error_message ?? ''}`.toLowerCase()
|
||||||
|
if (raw.includes('unsupported provider stream event')) {
|
||||||
|
return '断点在上游流式事件解析/转换矩阵:先按 breakpoint 对应字段确认 event type,再决定是补 canonical mapping 还是加入 known noop。'
|
||||||
|
}
|
||||||
|
if (raw.includes('finish reason')) {
|
||||||
|
return '断点在 finish_reason 映射:确认该结束原因是否可等价映射;不能无损映射时保持失败闭合。'
|
||||||
|
}
|
||||||
|
if (raw.includes('lossy conversion') || raw.includes('无损') || raw.includes('丢失信息') || raw.includes('request_conversion')) {
|
||||||
|
return '断点在请求/响应格式转换器:检查 breakpoint 字段是否能被目标格式表达,不能表达就需要拒绝、降级或新增显式映射策略。'
|
||||||
|
}
|
||||||
|
return '先从 breakpoint 字段开始回放;若 breakpoint 为 $,优先查看 raw.failure_diagnostic / raw.error_message 和 upstream_response。'
|
||||||
|
})()
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
summary,
|
||||||
|
breakpoint,
|
||||||
|
analysis_hint: analysisHint,
|
||||||
|
request: {
|
||||||
|
request_id: trace.value?.request_id ?? attempt.request_id,
|
||||||
|
path: currentAttemptRequestPathDisplay.value || null,
|
||||||
|
format_conversion: conversionDisplay || null,
|
||||||
|
client_api_format: clientFormat || null,
|
||||||
|
provider_api_format: providerFormat || null,
|
||||||
|
needs_conversion: extra?.needs_conversion ?? null,
|
||||||
|
conversion_mode: readStringField(extra ?? {}, 'conversion_mode') ?? null,
|
||||||
|
},
|
||||||
|
node: {
|
||||||
|
candidate_id: attempt.id,
|
||||||
|
candidate_index: attempt.candidate_index,
|
||||||
|
retry_index: attempt.retry_index,
|
||||||
|
provider: attempt.provider_name || attempt.provider_id || null,
|
||||||
|
key: currentAttemptKeyDisplay.value || null,
|
||||||
|
status: attempt.status,
|
||||||
|
status_code: statusCode ?? attempt.status_code ?? null,
|
||||||
|
skip_reason: attempt.skip_reason ?? null,
|
||||||
|
error_type: attempt.error_type ?? null,
|
||||||
|
error_message: attempt.error_message ?? null,
|
||||||
|
},
|
||||||
|
raw: {
|
||||||
|
failure_diagnostic: rawFailureDiagnostic,
|
||||||
|
request_conversion_error: rawRequestConversionError,
|
||||||
|
request_body_build_error: rawRequestBodyBuildError,
|
||||||
|
error_flow: extractObject(extra?.error_flow),
|
||||||
|
upstream_response: upstreamResponseDisplay ?? normalizeUpstreamResponseDisplay(extra?.upstream_response),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
const currentAttemptExtraDataDisplay = computed<Record<string, unknown> | null>(() => {
|
const currentAttemptExtraDataDisplay = computed<Record<string, unknown> | null>(() => {
|
||||||
const extra = extractObject(currentAttempt.value?.extra_data)
|
const extra = extractObject(currentAttempt.value?.extra_data)
|
||||||
if (!extra) return null
|
if (!extra) return null
|
||||||
@@ -2765,6 +3110,7 @@ function getDisplayStatus(attempt: CandidateRecord | null | undefined): string {
|
|||||||
/* 跳过原因 */
|
/* 跳过原因 */
|
||||||
.skip-reason {
|
.skip-reason {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
background: hsl(var(--muted) / 0.5);
|
background: hsl(var(--muted) / 0.5);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -570,6 +570,176 @@ describe('HorizontalRequestTimeline', () => {
|
|||||||
expect(root.textContent).not.toContain('该错误被标记为敏感上游错误')
|
expect(root.textContent).not.toContain('该错误被标记为敏感上游错误')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps local sync diagnostics visible when upstream response body capture is disabled', async () => {
|
||||||
|
const trace = buildTrace([
|
||||||
|
buildCandidate({
|
||||||
|
id: 'cand-local-sync-diagnostic',
|
||||||
|
provider_id: 'provider-local-sync',
|
||||||
|
provider_name: 'Provider Local Sync',
|
||||||
|
key_id: 'key-local-sync',
|
||||||
|
key_name: 'Local Sync Key',
|
||||||
|
candidate_index: 0,
|
||||||
|
status: 'failed',
|
||||||
|
status_code: 500,
|
||||||
|
error_type: 'local_sync_attempt_aborted',
|
||||||
|
error_message: 'Local sync attempt failed before terminal finalization: Internal("Unsupported provider stream event cannot be converted losslessly: field $.type = \\"response.future.delta\\"; fields: payload, response, type")',
|
||||||
|
extra_data: {
|
||||||
|
upstream_response: {
|
||||||
|
status_code: 500,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body_state: 'disabled',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const root = mountTimeline(trace)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(root.textContent).toContain('错误信息')
|
||||||
|
expect(root.textContent).toContain('HTTP 500')
|
||||||
|
expect(root.textContent).toContain('流式格式转换失败')
|
||||||
|
expect(root.textContent).toContain('上游返回了当前不支持的 stream event')
|
||||||
|
expect(root.textContent).toContain('字段 $.type = "response.future.delta"')
|
||||||
|
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||||
|
expect(errorJsonText).toContain('"body_state":"disabled"')
|
||||||
|
expect(errorJsonText).toContain('"diagnostic"')
|
||||||
|
expect(errorJsonText).toContain('"breakpoint":"$.type"')
|
||||||
|
expect(errorJsonText).toContain('"analysis_hint"')
|
||||||
|
expect(errorJsonText).toContain('"raw"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats request conversion diagnostics with field paths on skipped trace nodes', async () => {
|
||||||
|
const trace = buildTrace([
|
||||||
|
buildCandidate({
|
||||||
|
id: 'cand-request-conversion',
|
||||||
|
provider_id: 'provider-request-conversion',
|
||||||
|
provider_name: 'Provider Request Conversion',
|
||||||
|
key_id: 'key-request-conversion',
|
||||||
|
key_name: 'Request Conversion Key',
|
||||||
|
candidate_index: 0,
|
||||||
|
status: 'skipped',
|
||||||
|
skip_reason: 'provider_request_body_build_failed',
|
||||||
|
extra_data: {
|
||||||
|
failure_diagnostic: {
|
||||||
|
kind: 'request_conversion',
|
||||||
|
path: '$.n',
|
||||||
|
message: 'lossy conversion blocked from openai:chat to openai:responses at n: multiple completions cannot be represented losslessly',
|
||||||
|
safe_to_show: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const root = mountTimeline(trace)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(root.textContent).toContain('跳过原因')
|
||||||
|
expect(root.textContent).toContain('上游请求体转换失败')
|
||||||
|
expect(root.textContent).toContain('$.n')
|
||||||
|
expect(root.textContent).toContain('格式转换失败')
|
||||||
|
expect(root.textContent).toContain('OpenAI Chat → OpenAI Responses')
|
||||||
|
expect(root.textContent).toContain('字段 $.n 会丢失信息')
|
||||||
|
expect(root.querySelector('.diagnostic-json-panel')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats unsupported stream finish reasons with the failing field', async () => {
|
||||||
|
const trace = buildTrace([
|
||||||
|
buildCandidate({
|
||||||
|
id: 'cand-finish-reason',
|
||||||
|
provider_id: 'provider-finish-reason',
|
||||||
|
provider_name: 'Provider Finish Reason',
|
||||||
|
key_id: 'key-finish-reason',
|
||||||
|
key_name: 'Finish Reason Key',
|
||||||
|
candidate_index: 0,
|
||||||
|
status: 'failed',
|
||||||
|
status_code: 500,
|
||||||
|
error_message: 'Internal("Unsupported provider stream finish reason cannot be converted losslessly: field $.finish_reason = \\"future_reason\\"")',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const root = mountTimeline(trace)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(root.textContent).toContain('流式格式转换失败')
|
||||||
|
expect(root.textContent).toContain('finish reason')
|
||||||
|
expect(root.textContent).toContain('字段 $.finish_reason = "future_reason"')
|
||||||
|
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||||
|
expect(errorJsonText).toContain('"diagnostic"')
|
||||||
|
expect(errorJsonText).toContain('"breakpoint":"$.finish_reason"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses conversion messages from error_flow as the diagnostic breakpoint source', async () => {
|
||||||
|
const trace = buildTrace([
|
||||||
|
buildCandidate({
|
||||||
|
id: 'cand-error-flow-conversion',
|
||||||
|
provider_id: 'provider-error-flow-conversion',
|
||||||
|
provider_name: 'Provider Error Flow Conversion',
|
||||||
|
key_id: 'key-error-flow-conversion',
|
||||||
|
key_name: 'Error Flow Conversion Key',
|
||||||
|
candidate_index: 0,
|
||||||
|
status: 'failed',
|
||||||
|
status_code: 500,
|
||||||
|
error_message: 'execution runtime stream returned non-success status 500',
|
||||||
|
extra_data: {
|
||||||
|
upstream_response: {
|
||||||
|
status_code: 500,
|
||||||
|
body_state: 'disabled',
|
||||||
|
},
|
||||||
|
error_flow: {
|
||||||
|
status_code: 500,
|
||||||
|
message: 'lossy conversion blocked from openai:chat to openai:responses at n: multiple completions cannot be represented losslessly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const root = mountTimeline(trace)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(root.textContent).toContain('格式转换失败')
|
||||||
|
expect(root.textContent).toContain('OpenAI Chat → OpenAI Responses')
|
||||||
|
expect(root.textContent).toContain('字段 $.n 会丢失信息')
|
||||||
|
expect(root.textContent).not.toContain('上游返回非成功状态 500')
|
||||||
|
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||||
|
expect(errorJsonText).toContain('"body_state":"disabled"')
|
||||||
|
expect(errorJsonText).toContain('"breakpoint":"$.n"')
|
||||||
|
expect(errorJsonText).toContain('断点在请求/响应格式转换器')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows failed diagnostic messages even when the only response panel data is diagnostic metadata', async () => {
|
||||||
|
const trace = buildTrace([
|
||||||
|
buildCandidate({
|
||||||
|
id: 'cand-diagnostic-only',
|
||||||
|
provider_id: 'provider-diagnostic-only',
|
||||||
|
provider_name: 'Provider Diagnostic Only',
|
||||||
|
key_id: 'key-diagnostic-only',
|
||||||
|
key_name: 'Diagnostic Only Key',
|
||||||
|
candidate_index: 0,
|
||||||
|
status: 'failed',
|
||||||
|
error_type: 'request_conversion_failed',
|
||||||
|
extra_data: {
|
||||||
|
failure_diagnostic: {
|
||||||
|
kind: 'request_conversion',
|
||||||
|
path: '$.temperature',
|
||||||
|
message: 'unsupported field temperature in openai:responses: temperature cannot be represented',
|
||||||
|
safe_to_show: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const root = mountTimeline(trace)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(root.textContent).toContain('错误信息')
|
||||||
|
expect(root.textContent).toContain('格式转换失败')
|
||||||
|
expect(root.textContent).toContain('OpenAI Responses 不支持字段 $.temperature')
|
||||||
|
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||||
|
expect(errorJsonText).toContain('"diagnostic"')
|
||||||
|
expect(errorJsonText).toContain('"breakpoint":"$.temperature"')
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the failure message when upstream response only records an empty body state', async () => {
|
it('keeps the failure message when upstream response only records an empty body state', async () => {
|
||||||
const trace = buildTrace([
|
const trace = buildTrace([
|
||||||
buildCandidate({
|
buildCandidate({
|
||||||
|
|||||||
Reference in New Issue
Block a user