From 663085606158845934a73552a9782b2063ee62f3 Mon Sep 17 00:00:00 2001 From: elky Date: Wed, 9 Sep 2026 10:38:25 +0800 Subject: [PATCH] fix: harden routing failover, model testing, and wallet queries --- .../src/execution_runtime/fallback.rs | 2 +- .../src/execution_runtime/stream/execution.rs | 272 +++++++-- .../src/executor/candidate_loop.rs | 37 ++ .../src/orchestration/classifier.rs | 2 +- .../adapters/postgres/src/wallet.rs | 546 ++++++++++++++++++ docs/operations/routing-failover.md | 6 +- .../ModelMappingTab.contract.spec.ts | 231 +++++++- .../provider-tabs/ModelMappingTab.vue | 72 ++- .../provider-tabs/ModelTestDialog.vue | 2 +- .../RoutingFailoverPolicyEditor.spec.ts | 128 +++- .../RoutingFailoverPolicyEditor.vue | 102 +++- .../features/routing/utils/routingFailover.ts | 2 +- frontend/src/views/admin/RoutingProfiles.vue | 37 +- .../RoutingProfiles.failover.spec.ts | 175 ++++++ 14 files changed, 1530 insertions(+), 84 deletions(-) create mode 100644 frontend/src/views/admin/__tests__/RoutingProfiles.failover.spec.ts diff --git a/apps/aether-gateway/src/execution_runtime/fallback.rs b/apps/aether-gateway/src/execution_runtime/fallback.rs index 7eb990a77..86d178abf 100644 --- a/apps/aether-gateway/src/execution_runtime/fallback.rs +++ b/apps/aether-gateway/src/execution_runtime/fallback.rs @@ -14,7 +14,7 @@ fn sync_plan_kind_disables_local_candidate_failover(plan_kind: &str) -> bool { ) } -fn openai_image_success_disables_local_success_failover( +pub(super) fn openai_image_success_disables_local_success_failover( plan: &ExecutionPlan, status_code: u16, ) -> bool { diff --git a/apps/aether-gateway/src/execution_runtime/stream/execution.rs b/apps/aether-gateway/src/execution_runtime/stream/execution.rs index 31e5b22b3..fc9eca590 100644 --- a/apps/aether-gateway/src/execution_runtime/stream/execution.rs +++ b/apps/aether-gateway/src/execution_runtime/stream/execution.rs @@ -4759,6 +4759,31 @@ fn parse_prefetched_sync_json_body(body: &[u8]) -> Option { serde_json::from_slice::(stripped).ok() } +fn success_failover_matchable_body<'body>( + headers: &BTreeMap, + body: &'body [u8], +) -> Option<&'body [u8]> { + if body.is_empty() { + return None; + } + if response_headers_indicate_sse(headers) { + let mut complete_end = 0; + while let Some((record_end, separator_len)) = + find_sse_record_boundary(&body[complete_end..]) + { + complete_end += record_end + separator_len; + } + return (complete_end > 0).then_some(&body[..complete_end]); + } + let stripped = strip_utf8_bom_and_ws(body); + if stripped.starts_with(b"{") || stripped.starts_with(b"[") { + if serde_json::from_slice::(stripped).is_err_and(|error| error.is_eof()) { + return None; + } + } + Some(body) +} + fn resolve_provider_stream_error_status_code( provider_api_format: &str, upstream_status_code: u16, @@ -5881,7 +5906,11 @@ async fn execute_stream_from_frame_stream_with_retry_scope( } let mut buffered_frames = VecDeque::new(); let mut stream_terminal_summary: Option = None; - if status_code == 200 && should_probe_success_failover_before_stream(&headers) { + let direct_stream_finalize_kind = resolve_core_stream_direct_finalize_report_kind(plan_kind); + if status_code == 200 + && direct_stream_finalize_kind.is_none() + && should_probe_success_failover_before_stream(&headers) + { let success_probe_text = probe_local_stream_success_failover_text(&mut buffered_frames, &mut lines).await?; if should_retry_next_local_candidate_stream( @@ -6298,7 +6327,6 @@ async fn execute_stream_from_frame_stream_with_retry_scope( )?)); } - let direct_stream_finalize_kind = resolve_core_stream_direct_finalize_report_kind(plan_kind); let normalized_stream_report_context = normalize_provider_private_report_context(report_context.as_ref()); let upstream_headers = headers.clone(); @@ -6358,6 +6386,12 @@ async fn execute_stream_from_frame_stream_with_retry_scope( .iter() .map(|rule| (&rule.pattern, &rule.status_codes)), ) + .filter(|_| { + !crate::execution_runtime::fallback::openai_image_success_disables_local_success_failover( + &plan, + status_code, + ) + }) .filter(|(_, status_codes)| status_codes.is_empty() || status_codes.contains(&200)) .filter_map(|(pattern, _)| regex::Regex::new(pattern.trim()).ok()) .collect::>(); @@ -6661,28 +6695,6 @@ async fn execute_stream_from_frame_stream_with_retry_scope( &mut prefetched_inspection_body_truncated, ); - if !prefetch_success_patterns.is_empty() - && crate::orchestration::attempt_identity_from_report_context( - report_context.as_ref(), - ) - .is_some() - { - let response_text = String::from_utf8_lossy(&prefetched_inspection_body); - if prefetch_success_patterns.iter().any(|pattern| pattern.is_match(&response_text)) - && crate::orchestration::classify_local_failover( - &prefetch_failover_policy, - crate::orchestration::LocalFailoverInput::new(status_code, Some(&response_text)), - ) == crate::orchestration::LocalFailoverClassification::RetrySuccessPattern - { - record_prefetch_success_failover(state, &plan, report_context.as_ref(), stream_elapsed_ms_since(stream_started_at)).await; - if let Some(retry_scope) = retry_scope_out.as_deref_mut() { - *retry_scope = AiAttemptRetryScope::Candidate; - } - warn!(event_name = "local_stream_candidate_retry_scheduled", log_type = "event", trace_id, request_id, status_code, "gateway retrying after a precommit success pattern match"); - return Ok(None); - } - } - let semantic_commit_ready = match stream_commit_gate.observe_provider_bytes(&chunk) { StreamPrecommitObservation::Pending => false, @@ -6792,6 +6804,33 @@ async fn execute_stream_from_frame_stream_with_retry_scope( StreamPrefetchInspection::NonError => {} } + if !prefetch_success_patterns.is_empty() + && crate::orchestration::attempt_identity_from_report_context( + report_context.as_ref(), + ) + .is_some() + { + if let Some(matchable_body) = success_failover_matchable_body( + &upstream_headers, + &prefetched_inspection_body, + ) { + let response_text = String::from_utf8_lossy(matchable_body); + if prefetch_success_patterns.iter().any(|pattern| pattern.is_match(&response_text)) + && crate::orchestration::classify_local_failover( + &prefetch_failover_policy, + crate::orchestration::LocalFailoverInput::new(status_code, Some(&response_text)), + ) == crate::orchestration::LocalFailoverClassification::RetrySuccessPattern + { + record_prefetch_success_failover(state, &plan, report_context.as_ref(), stream_elapsed_ms_since(stream_started_at)).await; + if let Some(retry_scope) = retry_scope_out.as_deref_mut() { + *retry_scope = AiAttemptRetryScope::Candidate; + } + warn!(event_name = "local_stream_candidate_retry_scheduled", log_type = "event", trace_id, request_id, status_code, "gateway retrying after a precommit success pattern match"); + return Ok(None); + } + } + } + if !response_headers_indicate_sse(&upstream_headers) && (200..300).contains(&status_code) { @@ -9035,11 +9074,30 @@ mod tests { provider_config: Option, stall: bool, content_type: &str, + ) -> Option> { + execute_stream_precommit_for_format( + chunks, + routing_policy, + provider_config, + stall, + content_type, + "openai:responses", + ) + .await + } + + async fn execute_stream_precommit_for_format( + chunks: Vec<&str>, + routing_policy: Value, + provider_config: Option, + stall: bool, + content_type: &str, + api_format: &str, ) -> Option> { let request_id = format!("generic-precommit-{}", uuid::Uuid::new_v4()); let mut plan = native_anthropic_stream_plan(&request_id); - plan.provider_api_format = "openai:responses".to_string(); - plan.client_api_format = "openai:responses".to_string(); + plan.provider_api_format = api_format.to_string(); + plan.client_api_format = api_format.to_string(); plan.timeouts = Some(ExecutionTimeouts { first_byte_ms: Some(20), ..Default::default() @@ -9077,17 +9135,22 @@ mod tests { } .boxed(); let mut scope = AiAttemptRetryScope::Provider; + let plan_kind = if api_format == "openai:image" { + "openai_image_stream" + } else { + "openai_responses_stream" + }; execute_stream_from_frame_stream_with_retry_scope( &state, plan, "trace-generic-precommit", &test_decision(), - "openai_responses_stream", - Some("openai_responses_stream_success".to_string()), + plan_kind, + Some(format!("{plan_kind}_success")), Some(json!({ "request_id": request_id, "candidate_id": format!("candidate-{request_id}"), "candidate_index": 0, "retry_index": 0, - "provider_api_format": "openai:responses", "client_api_format": "openai:responses", + "provider_api_format": api_format, "client_api_format": api_format, "routing_execution_policy": routing_policy, })), crate::clock::current_unix_ms(), @@ -9107,13 +9170,18 @@ mod tests { #[tokio::test] async fn generic_stream_success_regex_matches_fragmented_plain_body() { - assert!(execute_generic_stream_precommit( + for chunks in [ vec!["upstream CAPACITY ", "exhausted"], - json!({"failover_rules": {"success_failover_patterns": [{"pattern": "(?i)capacity.*exhausted"}]}}), - None, - false, - "text/plain", - ).await.is_none()); + vec!["[upstream] CAPACITY ", "exhausted"], + ] { + assert!(execute_generic_stream_precommit( + chunks, + json!({"failover_rules": {"success_failover_patterns": [{"pattern": "(?i)capacity.*exhausted"}]}}), + None, + false, + "text/plain", + ).await.is_none()); + } } #[tokio::test] @@ -9152,6 +9220,128 @@ mod tests { to_bytes(response.into_body(), usize::MAX).await.unwrap(); } + #[tokio::test] + async fn generic_image_success_is_not_replayed_by_global_or_provider_success_regex() { + let rule = json!({ "success_failover_patterns": [{ "pattern": "b64_json" }] }); + for (routing_policy, provider_config) in [ + (json!({ "failover_rules": rule }), None), + (json!({}), Some(json!({ "failover_rules": rule }))), + ] { + let response = execute_stream_precommit_for_format( + vec![r#"{"created":1,"data":[{"b64_json":"aGVsbG8="}]}"#], + routing_policy, + provider_config, + false, + "application/json", + "openai:image", + ) + .await + .expect("successful image responses must retain their no-replay protection"); + assert_eq!(response.status(), axum::http::StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert!(String::from_utf8_lossy(&body).contains("aGVsbG8=")); + } + } + + #[tokio::test] + async fn generic_complete_setup_events_and_json_bodies_still_match_success_regex() { + for (content_type, chunks) in [ + ( + "text/event-stream", + vec![ + "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"metadata\":{\"warning\":\"capacity", + " exhausted\"}}}\n\n", + ], + ), + ( + "application/json", + vec!["{\"warning\":\"capacity", " exhausted\"}"], + ), + ] { + assert!(execute_generic_stream_precommit( + chunks, + json!({ "failover_rules": { + "success_failover_patterns": [{ "pattern": "capacity.*exhausted" }], + } }), + None, + false, + content_type, + ) + .await + .is_none()); + } + } + + #[tokio::test] + async fn generic_fragmented_errors_apply_stop_rules_before_success_regex() { + for (content_type, chunks) in [ + ( + "text/event-stream", + vec![ + "event: response.created\ndata: {\"type\":\"response.created\"}\n\n", + "event: response.failed\ndata: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"capacity", + " exhausted\"}}}\n\n", + ], + ), + ( + "application/json", + vec![ + "{\"error\":{\"type\":\"server_error\",\"message\":\"capacity", + " exhausted\"}}", + ], + ), + ( + "application/json", + vec![r#"{"error":{"type":"server_error","message":"capacity exhausted"}}"#], + ), + ] { + let response = execute_generic_stream_precommit( + chunks, + json!({ "failover_rules": { + "success_failover_patterns": [{ "pattern": "capacity" }], + "error_stop_patterns": [{ "status_codes": [500], "pattern": "capacity" }], + } }), + None, + false, + content_type, + ) + .await + .unwrap_or_else(|| panic!("partial errors must be parsed before applying success regex rules ({content_type})")); + assert!(response.status().is_server_error()); + to_bytes(response.into_body(), usize::MAX).await.unwrap(); + } + } + + #[tokio::test] + async fn generic_sse_global_error_stop_precedes_global_or_provider_success_regex() { + let success_rules = json!({ "success_failover_patterns": [{ "pattern": "capacity" }] }); + let stop_rule = json!([{ "status_codes": [500], "pattern": "capacity" }]); + for (routing_policy, provider_config) in [ + ( + json!({ "failover_rules": { + "success_failover_patterns": success_rules["success_failover_patterns"], + "error_stop_patterns": stop_rule, + } }), + None, + ), + ( + json!({ "failover_rules": { "error_stop_patterns": stop_rule } }), + Some(json!({ "failover_rules": success_rules })), + ), + ] { + let response = execute_generic_sse_precommit( + vec!["event: response.failed\ndata: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"capacity exhausted\"}}}\n\n"], + routing_policy, + provider_config, + false, + ) + .await + .expect("a matching global stop rule must win over a 200 success regex"); + assert!(response.status().is_server_error()); + to_bytes(response.into_body(), usize::MAX).await.unwrap(); + } + } + #[tokio::test] async fn generic_sse_success_regex_applies_to_global_and_provider_rules() { let rule = json!({ "success_failover_patterns": [{ "pattern": "(?i)CAPACITY" }] }); @@ -9177,6 +9367,18 @@ mod tests { assert!(String::from_utf8_lossy(&body).contains("hello")); } + #[tokio::test] + async fn generic_sse_setup_timeout_ignores_removed_global_transport_stop_flag() { + let response = execute_generic_sse_precommit( + vec!["event: response.created\ndata: {\"type\":\"response.created\"}\n\n"], + json!({ "failover_rules": { "stop_on_transport_errors": true } }), + None, + true, + ) + .await; + assert!(response.is_none()); + } + #[tokio::test] async fn generic_sse_setup_timeout_always_retries() { let response = execute_generic_sse_precommit( diff --git a/apps/aether-gateway/src/executor/candidate_loop.rs b/apps/aether-gateway/src/executor/candidate_loop.rs index d0743ec87..74afa0f41 100644 --- a/apps/aether-gateway/src/executor/candidate_loop.rs +++ b/apps/aether-gateway/src/executor/candidate_loop.rs @@ -2651,6 +2651,43 @@ mod tests { ); } + #[tokio::test] + async fn cloned_tracker_preserves_global_budget_across_candidate_loops() { + let state = AppState::new().unwrap(); + let tracker = ProviderTransferTracker::default(); + let mut attempts = transfer_test_attempts(); + for attempt in &mut attempts { + attempt.report_context["routing_execution_policy"] = json!({ "max_transfer_count": 1 }); + } + let remaining = attempts.split_off(3); + let first_port = TransferTestPort::with_tracker(&state, tracker.clone()); + let first_outcome = run_ai_attempt_loop(&first_port, attempts).await.unwrap(); + assert!(matches!(first_outcome, AiAttemptLoopOutcome::Exhausted(_))); + assert_eq!(tracker.state.lock().await.global.transfer_count, 1); + + let second_port = TransferTestPort::with_tracker(&state, tracker.clone()); + let mut source = TransferTestAttemptSource { + attempts: remaining.into(), + skipped_providers: Vec::new(), + }; + let second_outcome = run_dynamic_attempt_loop( + &second_port, + &mut source, + "global-budget-across-loops", + "test", + Duration::from_secs(1), + ) + .await + .unwrap(); + assert!(matches!( + second_outcome, + LocalExecutionRequestOutcome::NoPath + )); + assert!(second_port.executed.lock().unwrap().is_empty()); + assert_eq!(source.skipped_providers, ["provider-a", "provider-b"]); + assert!(tracker.state.lock().await.global.exhausted); + } + #[tokio::test] async fn cloned_tracker_preserves_transfer_budget_across_candidate_loops() { let state = AppState::new().expect("state should build"); diff --git a/apps/aether-gateway/src/orchestration/classifier.rs b/apps/aether-gateway/src/orchestration/classifier.rs index dd13dab2a..347ff40a3 100644 --- a/apps/aether-gateway/src/orchestration/classifier.rs +++ b/apps/aether-gateway/src/orchestration/classifier.rs @@ -605,7 +605,7 @@ mod tests { } #[test] - fn routing_transport_stop_cannot_be_overridden_by_provider() { + fn provider_transport_stop_rule_is_respected() { let policy = super::LocalFailoverPolicy { stop_on_transport_errors: true, ..Default::default() diff --git a/crates/aether-data/adapters/postgres/src/wallet.rs b/crates/aether-data/adapters/postgres/src/wallet.rs index c6eff8f33..8e4cf3b4b 100644 --- a/crates/aether-data/adapters/postgres/src/wallet.rs +++ b/crates/aether-data/adapters/postgres/src/wallet.rs @@ -5869,6 +5869,8 @@ RETURNING CAST(refunded_amount_usd AS DOUBLE PRECISION) AS refunded_amount_usd, CAST(refundable_amount_usd AS DOUBLE PRECISION) AS refundable_amount_usd, payment_method, + payment_provider, + order_kind, gateway_order_id, gateway_response, status, @@ -5914,6 +5916,8 @@ SELECT CAST(refunded_amount_usd AS DOUBLE PRECISION) AS refunded_amount_usd, CAST(refundable_amount_usd AS DOUBLE PRECISION) AS refundable_amount_usd, payment_method, + payment_provider, + order_kind, gateway_order_id, gateway_response, status, @@ -5968,6 +5972,8 @@ RETURNING CAST(refunded_amount_usd AS DOUBLE PRECISION) AS refunded_amount_usd, CAST(refundable_amount_usd AS DOUBLE PRECISION) AS refundable_amount_usd, payment_method, + payment_provider, + order_kind, gateway_order_id, gateway_response, status, @@ -6234,6 +6240,8 @@ RETURNING CAST(refunded_amount_usd AS DOUBLE PRECISION) AS refunded_amount_usd, CAST(refundable_amount_usd AS DOUBLE PRECISION) AS refundable_amount_usd, payment_method, + payment_provider, + order_kind, gateway_order_id, gateway_response, status, @@ -6433,6 +6441,8 @@ RETURNING CAST(refunded_amount_usd AS DOUBLE PRECISION) AS refunded_amount_usd, CAST(refundable_amount_usd AS DOUBLE PRECISION) AS refundable_amount_usd, payment_method, + payment_provider, + order_kind, gateway_order_id, gateway_response, status, @@ -7273,6 +7283,8 @@ RETURNING CAST(refunded_amount_usd AS DOUBLE PRECISION) AS refunded_amount_usd, CAST(refundable_amount_usd AS DOUBLE PRECISION) AS refundable_amount_usd, payment_method, + payment_provider, + order_kind, gateway_order_id, gateway_response, status, @@ -8465,9 +8477,543 @@ VALUES ($1, $2, 'gift', 'gift_initial', $3, 0, $3, 0, 0, 0, $3, 'system_task', $ #[cfg(test)] mod tests { + use aether_data_contracts::repository::wallet::{ + CreateManualWalletRechargeInput, CreditAdminPaymentOrderInput, RedeemWalletCodeInput, + RedeemWalletCodeOutcome, WalletLookupKey, WalletMutationOutcome, WalletReadRepository, + WalletWriteRepository, + }; + use sqlx::Row; + use super::SqlxWalletRepository; use crate::{PostgresPoolConfig, PostgresPoolFactory}; + #[test] + fn payment_order_sql_projections_cover_mapper_columns() { + let source = include_str!("wallet.rs") + .split("#[cfg(test)]") + .next() + .expect("wallet implementation should exist"); + let mapper = source + .split("fn map_admin_payment_order_row(") + .nth(1) + .expect("payment order mapper should exist") + .split("\nfn ") + .next() + .expect("payment order mapper body should exist"); + let required_columns = mapper + .split("row_get(row, \"") + .skip(1) + .map(|read| read.split('"').next().expect("column name should exist")) + .collect::>(); + assert!(required_columns.contains(&"payment_provider")); + assert!(required_columns.contains(&"order_kind")); + + let mut projections_checked = 0; + for fragment in source.split("r#\"").skip(1) { + let Some((sql, _)) = fragment.split_once("\"#") else { + continue; + }; + if !sql.contains("payment_orders") + || !sql.contains("AS created_at_unix_ms") + || !sql.contains("pay_currency") + { + continue; + } + let projection = match sql.rsplit_once("RETURNING") { + Some((_, projection)) => projection.to_string(), + None => sql + .lines() + .take_while(|line| !line.trim_start().starts_with("FROM payment_orders")) + .collect::>() + .join("\n"), + }; + let tokens = projection + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .collect::>(); + for column in &required_columns { + assert!( + tokens.contains(column), + "payment order projection omits {column}: {sql}" + ); + } + projections_checked += 1; + } + assert!( + projections_checked > 0, + "payment order projections should exist" + ); + } + + async fn isolated_wallet_test_pool() -> sqlx::PgPool { + let database_url = std::env::var("AETHER_TEST_DATABASE_URL") + .expect("AETHER_TEST_DATABASE_URL must point at the test database"); + let options = database_url + .parse::() + .expect("test database URL should parse") + .options([("search_path", "pg_temp")]); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .idle_timeout(None) + .max_lifetime(None) + .connect_with(options) + .await + .expect("test database should connect"); + for table in [ + "wallets", + "payment_orders", + "wallet_transactions", + "user_plan_entitlements", + "redeem_code_batches", + "redeem_codes", + ] { + sqlx::query(&format!( + "CREATE TEMP TABLE {table} (LIKE public.{table} INCLUDING ALL)" + )) + .execute(&pool) + .await + .expect("isolated wallet table should be created"); + } + pool + } + + async fn seed_wallet(pool: &sqlx::PgPool) -> (String, String) { + let wallet_id = uuid::Uuid::new_v4().to_string(); + let user_id = uuid::Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO wallets (id, user_id, balance, gift_balance, total_recharged, created_at, updated_at) VALUES ($1, $2, 10, 3, 20, NOW(), NOW())", + ) + .bind(&wallet_id) + .bind(&user_id) + .execute(pool) + .await + .expect("test wallet should be created"); + (wallet_id, user_id) + } + + async fn seed_pending_order( + pool: &sqlx::PgPool, + wallet_id: &str, + user_id: &str, + order_kind: &str, + ) -> String { + let order_id = uuid::Uuid::new_v4().to_string(); + let plan_snapshot = (order_kind == "plan_purchase").then(|| { + serde_json::json!({ + "id": "test-plan", + "duration_days": 30, + "purchase_limit_scope": "unlimited", + "entitlements": [{ + "type": "wallet_credit", + "amount_usd": 4.0, + "balance_bucket": "gift", + }], + }) + }); + sqlx::query( + "INSERT INTO payment_orders (id, order_no, wallet_id, user_id, amount_usd, pay_amount, pay_currency, payment_method, payment_provider, order_kind, product_id, product_snapshot, status, created_at, expires_at) VALUES ($1, $2, $3, $4, 5, 5, 'USD', 'stripe', 'stripe', $5, $6, $7, 'pending', NOW(), NOW() + INTERVAL '1 hour')", + ) + .bind(&order_id) + .bind(format!("order-{order_id}")) + .bind(wallet_id) + .bind(user_id) + .bind(order_kind) + .bind(plan_snapshot.as_ref().map(|_| "test-plan")) + .bind(plan_snapshot) + .execute(pool) + .await + .expect("pending payment order should be created"); + order_id + } + + #[tokio::test] + #[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL bootstrap schema"] + async fn live_manual_recharge_commits_wallet_order_and_transaction() { + let pool = isolated_wallet_test_pool().await; + let (wallet_id, user_id) = seed_wallet(&pool).await; + let repository = SqlxWalletRepository::new(pool.clone()); + let input = CreateManualWalletRechargeInput { + wallet_id: wallet_id.clone(), + amount_usd: 5.0, + payment_method: "admin_manual".to_string(), + operator_id: Some(uuid::Uuid::new_v4().to_string()), + description: Some("manual recharge regression".to_string()), + order_no: format!("manual-{}", uuid::Uuid::new_v4()), + }; + let (wallet, order) = repository + .create_manual_wallet_recharge(input.clone()) + .await + .expect("manual recharge should commit") + .expect("test wallet should exist"); + + assert_eq!(wallet.id, wallet_id); + assert_eq!(wallet.user_id.as_deref(), Some(user_id.as_str())); + assert_eq!(wallet.balance, 15.0); + assert_eq!(wallet.gift_balance, 3.0); + assert_eq!(wallet.total_recharged, 25.0); + assert_eq!(order.order_no, input.order_no); + assert_eq!(order.wallet_id, wallet_id); + assert_eq!(order.user_id.as_deref(), Some(user_id.as_str())); + assert_eq!(order.amount_usd, 5.0); + assert_eq!(order.refunded_amount_usd, 0.0); + assert_eq!(order.refundable_amount_usd, 5.0); + assert_eq!(order.payment_method, "admin_manual"); + assert_eq!(order.payment_provider, None); + assert_eq!(order.order_kind, "wallet_recharge"); + assert_eq!(order.status, "credited"); + assert!(order.paid_at_unix_secs.is_some()); + assert!(order.credited_at_unix_secs.is_some()); + assert_eq!( + order.gateway_response, + Some(serde_json::json!({ + "source": "manual", + "operator_id": input.operator_id, + "description": input.description, + })) + ); + + assert!(repository + .create_manual_wallet_recharge(input.clone()) + .await + .is_err()); + for amount_usd in [0.0, -1.0, f64::NAN, f64::INFINITY] { + assert!(repository + .create_manual_wallet_recharge(CreateManualWalletRechargeInput { + amount_usd, + ..input.clone() + }) + .await + .is_err()); + } + assert!(repository + .create_manual_wallet_recharge(CreateManualWalletRechargeInput { + wallet_id: uuid::Uuid::new_v4().to_string(), + ..input.clone() + }) + .await + .expect("missing wallet should not fail") + .is_none()); + + let persisted_wallet = repository + .find(WalletLookupKey::WalletId(&wallet_id)) + .await + .expect("wallet should be readable after commit") + .expect("wallet should persist"); + assert_eq!(persisted_wallet, wallet); + let persisted_order = repository + .find_admin_payment_order(&order.id) + .await + .expect("payment order should be readable after commit") + .expect("payment order should persist"); + assert_eq!(persisted_order, order); + + let transaction = sqlx::query( + "SELECT category, reason_code, CAST(amount AS DOUBLE PRECISION) AS amount, CAST(balance_before AS DOUBLE PRECISION) AS balance_before, CAST(balance_after AS DOUBLE PRECISION) AS balance_after, CAST(recharge_balance_before AS DOUBLE PRECISION) AS recharge_balance_before, CAST(recharge_balance_after AS DOUBLE PRECISION) AS recharge_balance_after, CAST(gift_balance_before AS DOUBLE PRECISION) AS gift_balance_before, CAST(gift_balance_after AS DOUBLE PRECISION) AS gift_balance_after, link_type, link_id, operator_id, description FROM wallet_transactions WHERE wallet_id = $1", + ) + .bind(&wallet_id) + .fetch_one(&pool) + .await + .expect("recharge transaction should persist"); + assert_eq!(transaction.get::("category"), "recharge"); + assert_eq!( + transaction.get::("reason_code"), + "topup_admin_manual" + ); + assert_eq!(transaction.get::("amount"), 5.0); + assert_eq!(transaction.get::("balance_before"), 13.0); + assert_eq!(transaction.get::("balance_after"), 18.0); + assert_eq!(transaction.get::("recharge_balance_before"), 10.0); + assert_eq!(transaction.get::("recharge_balance_after"), 15.0); + assert_eq!(transaction.get::("gift_balance_before"), 3.0); + assert_eq!(transaction.get::("gift_balance_after"), 3.0); + assert_eq!(transaction.get::("link_type"), "payment_order"); + assert_eq!(transaction.get::("link_id"), order.id); + assert_eq!( + transaction.get::, _>("operator_id"), + input.operator_id + ); + assert_eq!( + transaction.get::, _>("description"), + input.description + ); + for table in ["wallets", "payment_orders", "wallet_transactions"] { + let count: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}")) + .fetch_one(&pool) + .await + .expect("wallet record count should be readable"); + assert_eq!(count, 1, "rejected recharges must not add {table} rows"); + } + pool.close().await; + } + + #[tokio::test] + #[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL bootstrap schema"] + async fn live_admin_order_state_changes_preserve_metadata() { + let pool = isolated_wallet_test_pool().await; + let (wallet_id, user_id) = seed_wallet(&pool).await; + let repository = SqlxWalletRepository::new(pool.clone()); + for target_status in ["expired", "failed"] { + let order_id = seed_pending_order(&pool, &wallet_id, &user_id, "wallet_recharge").await; + let order = if target_status == "expired" { + let outcome = repository + .expire_admin_payment_order(&order_id) + .await + .expect("order expiry should commit"); + let WalletMutationOutcome::Applied((order, changed)) = outcome else { + panic!("pending order should expire"); + }; + assert!(changed); + assert!(matches!( + repository.expire_admin_payment_order(&order_id).await, + Ok(WalletMutationOutcome::Applied((_, false))) + )); + order + } else { + let outcome = repository + .fail_admin_payment_order(&order_id) + .await + .expect("order failure should commit"); + let WalletMutationOutcome::Applied(order) = outcome else { + panic!("pending order should be marked failed"); + }; + order + }; + assert_eq!(order.status, target_status); + assert_eq!(order.payment_provider.as_deref(), Some("stripe")); + assert_eq!(order.order_kind, "wallet_recharge"); + assert_eq!( + repository + .find_admin_payment_order(&order_id) + .await + .unwrap(), + Some(order) + ); + } + let wallet = repository + .find(WalletLookupKey::WalletId(&wallet_id)) + .await + .unwrap() + .unwrap(); + assert_eq!( + (wallet.balance, wallet.gift_balance, wallet.total_recharged), + (10.0, 3.0, 20.0) + ); + let transaction_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM wallet_transactions") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(transaction_count, 0); + pool.close().await; + } + + async fn assert_admin_payment_order_credit(order_kind: &str) { + let pool = isolated_wallet_test_pool().await; + let (wallet_id, user_id) = seed_wallet(&pool).await; + let order_id = seed_pending_order(&pool, &wallet_id, &user_id, order_kind).await; + let repository = SqlxWalletRepository::new(pool.clone()); + let input = CreditAdminPaymentOrderInput { + order_id: order_id.clone(), + gateway_order_id: None, + pay_amount: None, + pay_currency: None, + exchange_rate: None, + gateway_response_patch: None, + operator_id: Some(uuid::Uuid::new_v4().to_string()), + }; + let outcome = repository + .credit_admin_payment_order(input.clone()) + .await + .expect("admin credit should commit"); + let WalletMutationOutcome::Applied((order, changed)) = outcome else { + panic!("pending payment order should be credited"); + }; + assert!(changed); + assert_eq!(order.status, "credited"); + assert_eq!(order.payment_provider.as_deref(), Some("stripe")); + assert_eq!(order.order_kind, order_kind); + assert!(order.paid_at_unix_secs.is_some()); + assert!(order.credited_at_unix_secs.is_some()); + assert_eq!( + repository + .find_admin_payment_order(&order_id) + .await + .unwrap(), + Some(order.clone()) + ); + assert!(matches!( + repository.credit_admin_payment_order(input).await, + Ok(WalletMutationOutcome::Applied((_, false))) + )); + assert!(matches!( + repository.expire_admin_payment_order(&order_id).await, + Ok(WalletMutationOutcome::Invalid(_)) + )); + assert!(matches!( + repository.fail_admin_payment_order(&order_id).await, + Ok(WalletMutationOutcome::Invalid(_)) + )); + + let wallet = repository + .find(WalletLookupKey::WalletId(&wallet_id)) + .await + .unwrap() + .unwrap(); + let expected_balances = if order_kind == "plan_purchase" { + assert_eq!(order.refundable_amount_usd, 0.0); + let fulfillment: String = + sqlx::query_scalar("SELECT fulfillment_status FROM payment_orders WHERE id = $1") + .bind(&order_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(fulfillment, "fulfilled"); + (10.0, 7.0, 20.0) + } else { + assert_eq!(order.refundable_amount_usd, 5.0); + (15.0, 3.0, 25.0) + }; + assert_eq!( + (wallet.balance, wallet.gift_balance, wallet.total_recharged), + expected_balances + ); + let transaction_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM wallet_transactions WHERE wallet_id = $1") + .bind(&wallet_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(transaction_count, 1); + let entitlement_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM user_plan_entitlements WHERE payment_order_id = $1", + ) + .bind(&order_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(entitlement_count, i64::from(order_kind == "plan_purchase")); + pool.close().await; + } + + #[tokio::test] + #[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL bootstrap schema"] + async fn live_admin_wallet_order_credit_commits_once() { + assert_admin_payment_order_credit("wallet_recharge").await; + } + + #[tokio::test] + #[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL bootstrap schema"] + async fn live_admin_plan_order_credit_commits_once() { + assert_admin_payment_order_credit("plan_purchase").await; + } + + #[tokio::test] + #[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL bootstrap schema"] + async fn live_redeem_code_commits_order_and_wallet_once_for_each_bucket() { + let pool = isolated_wallet_test_pool().await; + let repository = SqlxWalletRepository::new(pool.clone()); + for balance_bucket in ["recharge", "gift"] { + let (wallet_id, user_id) = seed_wallet(&pool).await; + let batch_id = uuid::Uuid::new_v4().to_string(); + let code_id = uuid::Uuid::new_v4().to_string(); + let code = super::generate_redeem_code_normalized(); + sqlx::query( + "INSERT INTO redeem_code_batches (id, name, amount_usd, balance_bucket, total_count, created_at, updated_at) VALUES ($1, 'regression batch', 5, $2, 1, NOW(), NOW())", + ) + .bind(&batch_id) + .bind(balance_bucket) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO redeem_codes (id, batch_id, code_hash, code_prefix, code_suffix, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, NOW(), NOW())", + ) + .bind(&code_id) + .bind(&batch_id) + .bind(super::hash_redeem_code(&code)) + .bind(super::redeem_code_prefix(&code)) + .bind(super::redeem_code_suffix(&code)) + .execute(&pool) + .await + .unwrap(); + let input = RedeemWalletCodeInput { + code: super::format_redeem_code(&code), + user_id, + order_no: format!("redeem-{}", uuid::Uuid::new_v4()), + }; + let outcome = repository + .redeem_wallet_code(input.clone()) + .await + .expect("redeem code recharge should commit"); + let RedeemWalletCodeOutcome::Redeemed { + wallet, + order, + amount_usd, + .. + } = outcome + else { + panic!("active code should be redeemed"); + }; + assert_eq!(amount_usd, 5.0); + assert_eq!(order.status, "credited"); + assert_eq!(order.order_kind, "wallet_recharge"); + assert_eq!(order.payment_provider, None); + let expected_balances = if balance_bucket == "recharge" { + assert_eq!(order.payment_method, "card_code"); + assert_eq!(order.refundable_amount_usd, 5.0); + (15.0, 3.0, 25.0) + } else { + assert_eq!(order.payment_method, "gift_code"); + assert_eq!(order.refundable_amount_usd, 0.0); + (10.0, 8.0, 25.0) + }; + assert_eq!( + (wallet.balance, wallet.gift_balance, wallet.total_recharged), + expected_balances + ); + assert!(matches!( + repository.redeem_wallet_code(input).await, + Ok(RedeemWalletCodeOutcome::CodeRedeemed) + )); + assert_eq!( + repository + .find(WalletLookupKey::WalletId(&wallet_id)) + .await + .unwrap(), + Some(wallet) + ); + assert_eq!( + repository + .find_admin_payment_order(&order.id) + .await + .unwrap(), + Some(order.clone()) + ); + let redeemed = sqlx::query( + "SELECT status, redeemed_wallet_id, redeemed_payment_order_id FROM redeem_codes WHERE id = $1", + ) + .bind(&code_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(redeemed.get::("status"), "redeemed"); + assert_eq!(redeemed.get::("redeemed_wallet_id"), wallet_id); + assert_eq!( + redeemed.get::("redeemed_payment_order_id"), + order.id + ); + for table in ["payment_orders", "wallet_transactions"] { + let count: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {table} WHERE wallet_id = $1" + )) + .bind(&wallet_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1, "redeeming twice must not duplicate {table}"); + } + } + pool.close().await; + } + #[tokio::test] async fn repository_constructs_from_lazy_pool() { let factory = PostgresPoolFactory::new(PostgresPoolConfig { diff --git a/docs/operations/routing-failover.md b/docs/operations/routing-failover.md index a16e89bea..b021a7fbc 100644 --- a/docs/operations/routing-failover.md +++ b/docs/operations/routing-failover.md @@ -35,13 +35,15 @@ 先匹配调度策略的全局显式规则;未匹配时继续使用提供商的规则及协议默认行为。本地执行函数真正返回 `Err` 时仍然终止,不做兜底重放。 - **成功转移规则**:仅当 HTTP 200 响应匹配配置的正则时继续转移,不是对所有 200 进行重试。非流式请求匹配响应体;流式请求只匹配尚未交付业务输出的有界预读取内容。 -- **错误提前终止**:适用于 400–599 错误。状态码与正则都填写时要求同时满足;只填状态码表示该状态一律终止;只填正则表示在所有错误状态上匹配。流内错误使用解析后的错误状态,而不是外层 200。 +- **结构化错误优先**:标准流式请求统一在预读取阶段先解析完整 SSE 事件或 JSON 中的错误,再判断成功正则;不在半截错误载荷上提前触发成功转移,避免旧的 JSON 提前探测绕过错误终止规则。普通文本响应仍支持跨分片匹配。 +- **图片成功保护**:`openai:image` 的成功响应保留不重放行为,不因全局或提供商的成功正则再次生成图片;正常错误响应仍按错误规则处理。 +- **错误终止规则**:适用于 400–599 错误。状态码与正则都填写时要求同时满足;只填状态码表示该状态一律终止;只填正则表示在所有错误状态上匹配。流内错误使用解析后的错误状态,而不是外层 200。 - **网络错误**:没有上游 HTTP 状态的连接、TLS、DNS、提交前超时等错误统一继续转移;提供商级别若单独配置了停止规则,仍按提供商规则处理。 - 正则使用 Rust `regex` 语法,支持 `(?i)` 等内联标志。服务端拒绝无效正则、无意义的空规则以及错误状态范围。每组最多 64 条,每条表达式最多 4096 字节。 ## 流式 200 的恢复窗口 -上游 HTTP 200 响应头不再默认关闭标准文本 SSE 的恢复窗口。执行器先缓冲协议开场事件,例如 Responses 的 `response.created`、Chat 的 role-only 增量、Anthropic 的空 `message_start` / 文本块起始事件。图片专用流保留原来的响应头提交行为,除非显式配置了需要预读取的规则。 +上游 HTTP 200 响应头不再默认关闭标准文本 SSE 的恢复窗口。执行器先缓冲协议开场事件,例如 Responses 的 `response.created`、Chat 的 role-only 增量、Anthropic 的空 `message_start` / 文本块起始事件。`openai:image` 图片专用流保留原来的响应头提交行为,成功正则不打开重放窗口。 首个业务内容之前的结构化错误、过早 EOF、首字节超时以及 200 正则命中会进入统一故障转移判断。真实文本、思考、工具调用或正常结束事件确定后,缓冲内容按原顺序交付;之后发生的错误保持终止,不重新执行原请求。 diff --git a/frontend/src/features/providers/components/__tests__/ModelMappingTab.contract.spec.ts b/frontend/src/features/providers/components/__tests__/ModelMappingTab.contract.spec.ts index 1593cfc83..eb783d6ce 100644 --- a/frontend/src/features/providers/components/__tests__/ModelMappingTab.contract.spec.ts +++ b/frontend/src/features/providers/components/__tests__/ModelMappingTab.contract.spec.ts @@ -1,10 +1,32 @@ -import { describe, expect, it } from 'vitest' -import { createSSRApp, h } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createApp, createSSRApp, h, nextTick, type App } from 'vue' import { renderToString } from '@vue/server-renderer' import type { ProviderWithEndpointsSummary } from '@/api/endpoints' +import type { EndpointAPIKey } from '@/api/endpoints/keys' import ModelMappingTab from '../provider-tabs/ModelMappingTab.vue' +const keyMocks = vi.hoisted(() => ({ getProviderKeys: vi.fn() })) +vi.mock('@/api/endpoints/keys', () => keyMocks) + +const testMocks = vi.hoisted(() => ({ + testModel: vi.fn(), + getRequestTrace: vi.fn(), + showError: vi.fn(), + showSuccess: vi.fn(), +})) + +vi.mock('@/api/endpoints/providers', async importOriginal => ({ + ...await importOriginal(), + testModel: testMocks.testModel, +})) +vi.mock('@/api/requestTrace', () => ({ + requestTraceApi: { getRequestTrace: testMocks.getRequestTrace }, +})) +vi.mock('@/composables/useToast', () => ({ + useToast: () => ({ error: testMocks.showError, success: testMocks.showSuccess }), +})) + const provider: ProviderWithEndpointsSummary = { id: 'provider-demo', name: 'Demo Provider', @@ -29,7 +51,212 @@ const provider: ProviderWithEndpointsSummary = { updated_at: '2026-01-01T00:00:00Z', } +type MappingTabProps = InstanceType['$props'] +type MappingTestState = { + runMappingTest: (key: string, model: string) => void + handleSelectTestEndpoint: (id: string) => void + selectedTestKeyIds: string[] + testKeyOptions: Array<{ value: string; label: string }> + loadingTestKeys: boolean + handleTestDialogClose: () => void + handleStartMappingTest: () => Promise +} + +const endpoints = [ + { id: 'chat', api_format: 'openai:chat', base_url: 'https://example.com', is_active: true, active_keys: 1 }, + { id: 'claude', api_format: 'claude:chat', base_url: 'https://example.com', is_active: true, active_keys: 1 }, +] as MappingTabProps['endpoints'] + +function createTestKey(overrides: Partial): EndpointAPIKey { + return { + id: 'test-key', + provider_id: provider.id, + name: 'Test Key', + api_formats: [], + api_key_masked: '', + auth_type: 'api_key', + internal_priority: 0, + cache_ttl_minutes: 0, + max_probe_interval_minutes: 1, + health_score: 1, + consecutive_failures: 0, + request_count: 0, + success_count: 0, + error_count: 0, + success_rate: 0, + avg_response_time_ms: 0, + is_active: true, + created_at: provider.created_at, + updated_at: provider.updated_at, + ...overrides, + } +} + +const testKeys = [ + createTestKey({ id: 'chat-key', name: 'Chat Key', api_key_masked: 'sk-****chat', api_formats: ['openai:chat'], internal_priority: 0 }), + createTestKey({ id: 'claude-key', name: 'Claude Key', api_formats: ['claude:chat'], internal_priority: 1 }), + createTestKey({ id: 'disabled-key', is_active: false, internal_priority: 2 }), +] +const mounted: Array<{ app: App; root: HTMLElement }> = [] + +function mountMappingTab(overrides: Partial = {}) { + const root = document.createElement('div') + document.body.appendChild(root) + const app = createApp(ModelMappingTab, { provider, endpoints, models: [], ...overrides }) + const instance = app.mount(root) + const state = (instance.$ as unknown as { setupState: MappingTestState }).setupState + mounted.push({ app, root }) + return state +} + +function buttonWithText(text: string): HTMLButtonElement { + const button = [...document.querySelectorAll('button')] + .find(element => element.textContent?.trim() === text) + if (!button) throw new Error(`Missing button: ${text}`) + return button +} + +async function openMappingTest(state: MappingTestState) { + state.runMappingTest('mapping', 'test-model') + await Promise.resolve() + await nextTick() +} + +beforeEach(() => { + vi.resetAllMocks() + keyMocks.getProviderKeys.mockResolvedValue(testKeys) + testMocks.testModel.mockResolvedValue({ success: true, model: 'test-model' }) + testMocks.getRequestTrace.mockResolvedValue(null) +}) + +afterEach(() => { + for (const { app, root } of mounted.splice(0)) { + app.unmount() + root.remove() + } +}) + describe('ModelMappingTab response contracts', () => { + it('loads test keys and removes incompatible selections when switching endpoints', async () => { + const state = mountMappingTab() + await openMappingTest(state) + + expect(keyMocks.getProviderKeys).toHaveBeenCalledWith(provider.id) + expect(state.testKeyOptions).toEqual([ + { value: 'chat-key', label: 'Chat Key · sk-****chat · api_key' }, + ]) + expect(document.body.textContent).toContain('测试 Key') + buttonWithText('默认调度(不指定 Key)').click() + await nextTick() + const option = [...document.querySelectorAll('input[type="checkbox"]')] + .find(element => element.parentElement?.textContent?.includes('Chat Key')) + if (!option) throw new Error('Missing Chat Key option') + option.click() + await nextTick() + expect(state.selectedTestKeyIds).toEqual(['chat-key']) + + state.handleSelectTestEndpoint('claude') + expect(state.selectedTestKeyIds).toEqual([]) + expect(state.testKeyOptions.map(option => option.value)).toEqual(['claude-key']) + state.selectedTestKeyIds = ['claude-key'] + state.handleTestDialogClose() + expect(state.selectedTestKeyIds).toEqual([]) + }) + + it.each([ + { selectedKeyIds: ['chat-key'] }, + { selectedKeyIds: ['chat-key', 'shared-key'] }, + ])('passes the selected keys to the test request: $selectedKeyIds', async ({ selectedKeyIds }) => { + keyMocks.getProviderKeys.mockResolvedValue([ + ...testKeys, + createTestKey({ id: 'shared-key', name: 'Shared Key', internal_priority: 3 }), + ]) + const state = mountMappingTab() + await openMappingTest(state) + state.selectedTestKeyIds = [...selectedKeyIds, selectedKeyIds[0], 'disabled-key', 'claude-key'] + + await state.handleStartMappingTest() + + expect(testMocks.testModel).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ + provider_id: provider.id, + mode: 'direct', + model_name: 'test-model', + endpoint_id: 'chat', + api_format: 'openai:chat', + api_key_ids: selectedKeyIds, + }), expect.objectContaining({ signal: expect.any(AbortSignal) })) + }) + + it('keeps default scheduling when no key is selected', async () => { + const state = mountMappingTab() + await openMappingTest(state) + + await state.handleStartMappingTest() + + expect(testMocks.testModel).toHaveBeenCalledOnce() + expect(testMocks.testModel.mock.calls[0][0]).not.toHaveProperty('api_key_ids') + }) + + it('waits for keys to load before allowing a test', async () => { + let resolveKeys!: (keys: EndpointAPIKey[]) => void + keyMocks.getProviderKeys.mockReturnValue(new Promise(resolve => { + resolveKeys = resolve + })) + const state = mountMappingTab() + await openMappingTest(state) + + expect(buttonWithText('正在加载 Key').disabled).toBe(true) + expect(buttonWithText('开始测试').disabled).toBe(true) + await state.handleStartMappingTest() + expect(testMocks.testModel).not.toHaveBeenCalled() + + resolveKeys(testKeys) + await Promise.resolve() + await nextTick() + expect(buttonWithText('开始测试').disabled).toBe(false) + }) + + it('keeps the selector visible when no compatible keys are available', async () => { + keyMocks.getProviderKeys.mockResolvedValue([testKeys[1], testKeys[2]]) + const state = mountMappingTab() + await openMappingTest(state) + + expect(document.body.textContent).toContain('测试 Key') + buttonWithText('默认调度(不指定 Key)').click() + await nextTick() + expect(document.body.textContent).toContain('暂无可选 Key') + }) + + it('keeps provided keys usable after a loading failure', async () => { + keyMocks.getProviderKeys.mockRejectedValue(new Error('Key service unavailable')) + const state = mountMappingTab({ providerKeys: testKeys }) + await openMappingTest(state) + + expect(testMocks.showError).toHaveBeenCalledOnce() + expect(state.loadingTestKeys).toBe(false) + expect(state.testKeyOptions.map(option => option.value)).toEqual(['chat-key']) + expect(buttonWithText('开始测试').disabled).toBe(false) + }) + + it('ignores key responses from a closed dialog', async () => { + let resolveKeys!: (keys: EndpointAPIKey[]) => void + keyMocks.getProviderKeys.mockReturnValueOnce(new Promise(resolve => { + resolveKeys = resolve + })) + const state = mountMappingTab() + await openMappingTest(state) + state.handleTestDialogClose() + expect(state.loadingTestKeys).toBe(false) + + keyMocks.getProviderKeys.mockResolvedValue([]) + await openMappingTest(state) + resolveKeys(testKeys) + await Promise.resolve() + await nextTick() + + expect(state.testKeyOptions).toEqual([]) + }) + it('keeps the module visible when a legacy or malformed preview reaches the component', async () => { const props: InstanceType['$props'] = { provider, diff --git a/frontend/src/features/providers/components/provider-tabs/ModelMappingTab.vue b/frontend/src/features/providers/components/provider-tabs/ModelMappingTab.vue index c79e884e0..bb6a6305e 100644 --- a/frontend/src/features/providers/components/provider-tabs/ModelMappingTab.vue +++ b/frontend/src/features/providers/components/provider-tabs/ModelMappingTab.vue @@ -345,18 +345,22 @@ :request-body-draft="testRequestBodyDraft" :request-body-reset-value="testRequestBodyResetValue" :request-body-error="testRequestBodyError" - :start-disabled="!selectedTestEndpoint || !!testRequestHeadersError || !!testRequestBodyError" + :key-options="testKeyOptions" + :selected-key-ids="selectedTestKeyIds" + :key-options-loading="loadingTestKeys" + :start-disabled="loadingTestKeys || !selectedTestEndpoint || !!testRequestHeadersError || !!testRequestBodyError" @close="handleTestDialogClose" @back="handleTestDialogBack" @select-endpoint="handleSelectTestEndpoint" @start="handleStartMappingTest" @update:request-headers-draft="testRequestHeadersDraft = $event" @update:request-body-draft="testRequestBodyDraft = $event" + @update:selected-key-ids="selectedTestKeyIds = $event" />