mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
Merge remote-tracking branch 'origin/pr-476'
This commit is contained in:
@@ -469,6 +469,67 @@ fn admin_usage_simplify_all_candidates_skipped_client_error_message(
|
|||||||
Some(format!("没有可用提供商支持本次{request_mode}请求"))
|
Some(format!("没有可用提供商支持本次{request_mode}请求"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn admin_usage_local_runtime_miss_reason_label(reason: &str) -> &'static str {
|
||||||
|
match reason {
|
||||||
|
"all_candidates_skipped" => "所有候选均被跳过",
|
||||||
|
"candidate_list_empty" => "没有可调度候选",
|
||||||
|
"local_runtime_unavailable" => "本地执行运行时不可用",
|
||||||
|
"provider_transport_unavailable" => "提供商传输不可用",
|
||||||
|
_ => "本地调度未命中",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_usage_extract_local_runtime_miss_reason_summary(message: &str) -> Option<String> {
|
||||||
|
let without_reason_code = message
|
||||||
|
.split_once("(原因代码:")
|
||||||
|
.map(|(prefix, _)| prefix)
|
||||||
|
.unwrap_or(message)
|
||||||
|
.trim();
|
||||||
|
let summary = without_reason_code
|
||||||
|
.rsplit_once(':')
|
||||||
|
.or_else(|| without_reason_code.rsplit_once(':'))
|
||||||
|
.map(|(_, suffix)| suffix.trim())?;
|
||||||
|
(!summary.is_empty()).then(|| summary.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_usage_scheduling_failure_json(
|
||||||
|
item: &StoredRequestUsageAudit,
|
||||||
|
client_error: &Value,
|
||||||
|
) -> Value {
|
||||||
|
if item.routing_execution_path() != Some("local_execution_runtime_miss") {
|
||||||
|
return Value::Null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let reason = item
|
||||||
|
.routing_local_execution_runtime_miss_reason()
|
||||||
|
.unwrap_or("local_execution_runtime_miss");
|
||||||
|
let message = admin_usage_error_domain_message(client_error)
|
||||||
|
.or_else(|| admin_usage_client_error_fallback_message(item))
|
||||||
|
.or_else(|| item.error_message.as_deref().map(str::to_string));
|
||||||
|
let raw_message = item
|
||||||
|
.error_message
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let reason_summary =
|
||||||
|
raw_message.and_then(admin_usage_extract_local_runtime_miss_reason_summary);
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"source": "local_execution_runtime_miss",
|
||||||
|
"reason": reason,
|
||||||
|
"reason_label": admin_usage_local_runtime_miss_reason_label(reason),
|
||||||
|
"title": format!("本地调度失败:{}", admin_usage_local_runtime_miss_reason_label(reason)),
|
||||||
|
"message": message,
|
||||||
|
"reason_summary": reason_summary,
|
||||||
|
"status_code": item.status_code,
|
||||||
|
"no_upstream_attempt": item.candidate_id.is_none()
|
||||||
|
&& item.provider_api_key_id.is_none()
|
||||||
|
&& item.provider_request_headers.is_none()
|
||||||
|
&& item.provider_request_body.is_none()
|
||||||
|
&& item.provider_request_body_ref.is_none(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn admin_usage_extract_local_execution_request_mode(message: &str) -> Option<&str> {
|
fn admin_usage_extract_local_execution_request_mode(message: &str) -> Option<&str> {
|
||||||
let rest = message.get(message.find("本次")? + "本次".len()..)?;
|
let rest = message.get(message.find("本次")? + "本次".len()..)?;
|
||||||
let mode = rest.get(..rest.find("请求")?)?.trim();
|
let mode = rest.get(..rest.find("请求")?)?.trim();
|
||||||
@@ -2199,6 +2260,8 @@ pub fn build_admin_usage_detail_payload(
|
|||||||
payload["upstream_error"] = error_domains["upstream_error"].clone();
|
payload["upstream_error"] = error_domains["upstream_error"].clone();
|
||||||
payload["client_error"] = error_domains["client_error"].clone();
|
payload["client_error"] = error_domains["client_error"].clone();
|
||||||
payload["failure_summary"] = error_domains["failure_summary"].clone();
|
payload["failure_summary"] = error_domains["failure_summary"].clone();
|
||||||
|
payload["scheduling_failure"] =
|
||||||
|
admin_usage_scheduling_failure_json(item, &error_domains["client_error"]);
|
||||||
payload["error_flow"] = error_flow;
|
payload["error_flow"] = error_flow;
|
||||||
payload["has_request_body"] = json!(admin_usage_has_body_value(
|
payload["has_request_body"] = json!(admin_usage_has_body_value(
|
||||||
item,
|
item,
|
||||||
@@ -2886,6 +2949,11 @@ mod tests {
|
|||||||
fn detail_payload_simplifies_local_client_error_when_client_body_is_unloaded() {
|
fn detail_payload_simplifies_local_client_error_when_client_body_is_unloaded() {
|
||||||
let message = "没有可用提供商支持模型 gpt-5.4 的流式请求。请检查模型映射、端点启用状态和 API Key 权限(原因代码: candidate_list_empty)";
|
let message = "没有可用提供商支持模型 gpt-5.4 的流式请求。请检查模型映射、端点启用状态和 API Key 权限(原因代码: candidate_list_empty)";
|
||||||
let item = StoredRequestUsageAudit {
|
let item = StoredRequestUsageAudit {
|
||||||
|
provider_api_key_id: None,
|
||||||
|
provider_request_headers: None,
|
||||||
|
provider_request_body: None,
|
||||||
|
provider_request_body_ref: None,
|
||||||
|
candidate_id: None,
|
||||||
execution_path: Some("local_execution_runtime_miss".to_string()),
|
execution_path: Some("local_execution_runtime_miss".to_string()),
|
||||||
local_execution_runtime_miss_reason: Some("candidate_list_empty".to_string()),
|
local_execution_runtime_miss_reason: Some("candidate_list_empty".to_string()),
|
||||||
error_category: Some("http_error".to_string()),
|
error_category: Some("http_error".to_string()),
|
||||||
@@ -2918,12 +2986,31 @@ mod tests {
|
|||||||
payload["failure_summary"]["message"],
|
payload["failure_summary"]["message"],
|
||||||
"没有可用提供商支持模型 gpt-5.4 的流式请求"
|
"没有可用提供商支持模型 gpt-5.4 的流式请求"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["scheduling_failure"]["title"],
|
||||||
|
"本地调度失败:没有可调度候选"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["scheduling_failure"]["reason"],
|
||||||
|
"candidate_list_empty"
|
||||||
|
);
|
||||||
|
assert!(payload["scheduling_failure"]["reason_summary"].is_null());
|
||||||
|
assert_eq!(
|
||||||
|
payload["scheduling_failure"]["message"],
|
||||||
|
"没有可用提供商支持模型 gpt-5.4 的流式请求"
|
||||||
|
);
|
||||||
|
assert_eq!(payload["scheduling_failure"]["no_upstream_attempt"], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detail_payload_simplifies_all_candidates_skipped_when_client_body_is_unloaded() {
|
fn detail_payload_simplifies_all_candidates_skipped_when_client_body_is_unloaded() {
|
||||||
let message = "找到 1 个支持模型 gpt-5.4 的候选提供商,但本次流式请求全部不可用:provider_quota_blocked 2 次(原因代码: all_candidates_skipped)";
|
let message = "找到 1 个支持模型 gpt-5.4 的候选提供商,但本次流式请求全部不可用:provider_quota_blocked 2 次(原因代码: all_candidates_skipped)";
|
||||||
let item = StoredRequestUsageAudit {
|
let item = StoredRequestUsageAudit {
|
||||||
|
provider_api_key_id: None,
|
||||||
|
provider_request_headers: None,
|
||||||
|
provider_request_body: None,
|
||||||
|
provider_request_body_ref: None,
|
||||||
|
candidate_id: None,
|
||||||
execution_path: Some("local_execution_runtime_miss".to_string()),
|
execution_path: Some("local_execution_runtime_miss".to_string()),
|
||||||
local_execution_runtime_miss_reason: Some("all_candidates_skipped".to_string()),
|
local_execution_runtime_miss_reason: Some("all_candidates_skipped".to_string()),
|
||||||
error_category: Some("http_error".to_string()),
|
error_category: Some("http_error".to_string()),
|
||||||
@@ -2956,6 +3043,23 @@ mod tests {
|
|||||||
payload["failure_summary"]["message"],
|
payload["failure_summary"]["message"],
|
||||||
"没有可用提供商支持模型 gpt-5.4 的流式请求"
|
"没有可用提供商支持模型 gpt-5.4 的流式请求"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["scheduling_failure"]["title"],
|
||||||
|
"本地调度失败:所有候选均被跳过"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["scheduling_failure"]["reason"],
|
||||||
|
"all_candidates_skipped"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["scheduling_failure"]["reason_summary"],
|
||||||
|
"provider_quota_blocked 2 次"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["scheduling_failure"]["message"],
|
||||||
|
"没有可用提供商支持模型 gpt-5.4 的流式请求"
|
||||||
|
);
|
||||||
|
assert_eq!(payload["scheduling_failure"]["no_upstream_attempt"], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -142,6 +142,17 @@ export interface RequestErrorFlow {
|
|||||||
summary_source?: string | null
|
summary_source?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RequestSchedulingFailure {
|
||||||
|
source?: string | null
|
||||||
|
reason?: string | null
|
||||||
|
reason_label?: string | null
|
||||||
|
title?: string | null
|
||||||
|
message?: string | null
|
||||||
|
reason_summary?: string | null
|
||||||
|
status_code?: number | null
|
||||||
|
no_upstream_attempt?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface RequestDetail {
|
export interface RequestDetail {
|
||||||
id: string // UUID
|
id: string // UUID
|
||||||
request_id: string
|
request_id: string
|
||||||
@@ -206,6 +217,7 @@ export interface RequestDetail {
|
|||||||
failure_summary?: RequestErrorDomain | null
|
failure_summary?: RequestErrorDomain | null
|
||||||
errors?: RequestErrorDomains | null
|
errors?: RequestErrorDomains | null
|
||||||
error_flow?: RequestErrorFlow | null
|
error_flow?: RequestErrorFlow | null
|
||||||
|
scheduling_failure?: RequestSchedulingFailure | null
|
||||||
response_time_ms: number
|
response_time_ms: number
|
||||||
first_byte_time_ms?: number | null
|
first_byte_time_ms?: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -366,7 +378,7 @@ export interface TimeRangeParams {
|
|||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
// 获取仪表盘统计数据
|
// 获取仪表盘统计数据
|
||||||
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
|
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
|
||||||
const cacheKey = buildCacheKey('dashboard:stats', params)
|
const cacheKey = buildCacheKey('dashboard:stats', params as Record<string, unknown> | undefined)
|
||||||
return cachedRequest(
|
return cachedRequest(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
async () => {
|
async () => {
|
||||||
@@ -427,7 +439,7 @@ export const dashboardApi = {
|
|||||||
|
|
||||||
// 获取每日统计数据
|
// 获取每日统计数据
|
||||||
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
|
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
|
||||||
const cacheKey = buildCacheKey('dashboard:daily-stats', params)
|
const cacheKey = buildCacheKey('dashboard:daily-stats', params as Record<string, unknown> | undefined)
|
||||||
return cachedRequest(
|
return cachedRequest(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -159,6 +159,47 @@
|
|||||||
v-else-if="detail"
|
v-else-if="detail"
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
|
<!-- 执行失败原因:优先展示本地调度/运行时失败摘要 -->
|
||||||
|
<Card
|
||||||
|
v-if="failureNotice"
|
||||||
|
class="border-red-200 bg-red-50/80 shadow-sm dark:border-red-900/60 dark:bg-red-950/30"
|
||||||
|
>
|
||||||
|
<div class="p-3 sm:p-4 flex gap-3">
|
||||||
|
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-300">
|
||||||
|
<AlertTriangle class="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1 space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h4 class="text-sm font-semibold text-red-950 dark:text-red-100">
|
||||||
|
{{ failureNotice.title }}
|
||||||
|
</h4>
|
||||||
|
<Badge
|
||||||
|
v-if="failureNotice.isSchedulingFailure"
|
||||||
|
variant="outline"
|
||||||
|
class="border-red-300 bg-white/60 text-[10px] text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200"
|
||||||
|
>
|
||||||
|
调度阶段
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm leading-6 text-red-900 dark:text-red-100">
|
||||||
|
{{ failureNotice.message }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="failureNotice.meta.length > 0"
|
||||||
|
class="flex flex-wrap gap-1.5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="item in failureNotice.meta"
|
||||||
|
:key="item"
|
||||||
|
class="rounded-full border border-red-200 bg-white/70 px-2 py-0.5 text-[11px] font-mono text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-200"
|
||||||
|
>
|
||||||
|
{{ item }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<!-- 费用与性能概览 -->
|
<!-- 费用与性能概览 -->
|
||||||
<Card>
|
<Card>
|
||||||
<div class="p-3 sm:p-4">
|
<div class="p-3 sm:p-4">
|
||||||
@@ -701,7 +742,7 @@ import Separator from '@/components/ui/separator.vue'
|
|||||||
import Skeleton from '@/components/ui/skeleton.vue'
|
import Skeleton from '@/components/ui/skeleton.vue'
|
||||||
import Tabs from '@/components/ui/tabs.vue'
|
import Tabs from '@/components/ui/tabs.vue'
|
||||||
import TabsContent from '@/components/ui/tabs-content.vue'
|
import TabsContent from '@/components/ui/tabs-content.vue'
|
||||||
import { Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
|
import { AlertTriangle, Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
|
||||||
import { dashboardApi, type RequestDetail, type RequestErrorDomain } from '@/api/dashboard'
|
import { dashboardApi, type RequestDetail, type RequestErrorDomain } from '@/api/dashboard'
|
||||||
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
|
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
|
||||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
@@ -721,6 +762,7 @@ import {
|
|||||||
resolveDisplayRequestStatus,
|
resolveDisplayRequestStatus,
|
||||||
resolveUsageStreamLabelSegments,
|
resolveUsageStreamLabelSegments,
|
||||||
} from '../utils/status'
|
} from '../utils/status'
|
||||||
|
import { resolveRequestFailureNotice } from '../utils/errorNotice'
|
||||||
|
|
||||||
// 子组件
|
// 子组件
|
||||||
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
|
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
|
||||||
@@ -1023,6 +1065,8 @@ const metadataPanelData = computed<Record<string, unknown> | null>(() => {
|
|||||||
return Object.keys(merged).length > 0 ? merged : null
|
return Object.keys(merged).length > 0 ? merged : null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const failureNotice = computed(() => resolveRequestFailureNotice(detail.value))
|
||||||
|
|
||||||
const settlementInfo = computed<JsonRecord | null>(() =>
|
const settlementInfo = computed<JsonRecord | null>(() =>
|
||||||
asRecord(detail.value?.settlement ?? null),
|
asRecord(detail.value?.settlement ?? null),
|
||||||
)
|
)
|
||||||
@@ -1826,6 +1870,7 @@ async function ensureBodyContentLoaded() {
|
|||||||
failure_summary: response.failure_summary,
|
failure_summary: response.failure_summary,
|
||||||
errors: response.errors,
|
errors: response.errors,
|
||||||
error_flow: response.error_flow,
|
error_flow: response.error_flow,
|
||||||
|
scheduling_failure: response.scheduling_failure,
|
||||||
}
|
}
|
||||||
bodiesLoadedForRequestId.value = cacheKey
|
bodiesLoadedForRequestId.value = cacheKey
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1872,12 +1917,13 @@ async function loadDetail(id: string, silent = false) {
|
|||||||
provider_request_body: sameRequest ? previousDetail?.provider_request_body : undefined,
|
provider_request_body: sameRequest ? previousDetail?.provider_request_body : undefined,
|
||||||
response_body: sameRequest ? previousDetail?.response_body : undefined,
|
response_body: sameRequest ? previousDetail?.response_body : undefined,
|
||||||
client_response_body: sameRequest ? previousDetail?.client_response_body : undefined,
|
client_response_body: sameRequest ? previousDetail?.client_response_body : undefined,
|
||||||
request_error: sameRequest ? (previousDetail?.request_error ?? response.request_error) : response.request_error,
|
request_error: response.request_error,
|
||||||
upstream_error: sameRequest ? (previousDetail?.upstream_error ?? response.upstream_error) : response.upstream_error,
|
upstream_error: response.upstream_error,
|
||||||
client_error: sameRequest ? (previousDetail?.client_error ?? response.client_error) : response.client_error,
|
client_error: response.client_error,
|
||||||
failure_summary: sameRequest ? (previousDetail?.failure_summary ?? response.failure_summary) : response.failure_summary,
|
failure_summary: response.failure_summary,
|
||||||
errors: sameRequest ? (previousDetail?.errors ?? response.errors) : response.errors,
|
errors: response.errors,
|
||||||
error_flow: sameRequest ? (previousDetail?.error_flow ?? response.error_flow) : response.error_flow,
|
error_flow: response.error_flow,
|
||||||
|
scheduling_failure: response.scheduling_failure,
|
||||||
}
|
}
|
||||||
bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null
|
bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null
|
||||||
|
|
||||||
|
|||||||
108
frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts
Normal file
108
frontend/src/features/usage/utils/__tests__/errorNotice.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { RequestDetail } from '@/api/dashboard'
|
||||||
|
import { resolveRequestFailureNotice } from '../errorNotice'
|
||||||
|
|
||||||
|
function buildRequestDetail(overrides: Partial<RequestDetail> = {}): RequestDetail {
|
||||||
|
return {
|
||||||
|
id: 'usage-1',
|
||||||
|
request_id: 'req-1',
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
},
|
||||||
|
api_key: {
|
||||||
|
id: 'key-1',
|
||||||
|
name: 'primary',
|
||||||
|
display: 'primary',
|
||||||
|
},
|
||||||
|
provider: 'OpenAI',
|
||||||
|
model: 'gpt-5',
|
||||||
|
tokens: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
request_type: 'chat',
|
||||||
|
is_stream: true,
|
||||||
|
status_code: 503,
|
||||||
|
status: 'failed',
|
||||||
|
response_time_ms: 0,
|
||||||
|
created_at: '2026-05-14T10:33:21Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('request failure notice', () => {
|
||||||
|
it('prioritizes local scheduling failure details over generic 503 status', () => {
|
||||||
|
const notice = resolveRequestFailureNotice(buildRequestDetail({
|
||||||
|
error_message: 'generic 503',
|
||||||
|
failure_summary: {
|
||||||
|
status_code: 503,
|
||||||
|
message: '没有可用提供商支持模型 gpt-5 的流式请求',
|
||||||
|
},
|
||||||
|
scheduling_failure: {
|
||||||
|
source: 'local_execution_runtime_miss',
|
||||||
|
reason: 'all_candidates_skipped',
|
||||||
|
reason_label: '所有候选均被跳过',
|
||||||
|
title: '本地调度失败:所有候选均被跳过',
|
||||||
|
message: '没有可用提供商支持模型 gpt-5 的流式请求',
|
||||||
|
reason_summary: 'pool_account_exhausted 2 次',
|
||||||
|
status_code: 503,
|
||||||
|
no_upstream_attempt: true,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(notice).toEqual({
|
||||||
|
title: '本地调度失败:所有候选均被跳过',
|
||||||
|
message: '没有可用提供商支持模型 gpt-5 的流式请求',
|
||||||
|
isSchedulingFailure: true,
|
||||||
|
meta: [
|
||||||
|
'pool_account_exhausted 2 次',
|
||||||
|
'所有候选均被跳过',
|
||||||
|
'all_candidates_skipped',
|
||||||
|
'HTTP 503',
|
||||||
|
'未进入上游执行',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the failure summary for upstream failures', () => {
|
||||||
|
const notice = resolveRequestFailureNotice(buildRequestDetail({
|
||||||
|
failure_summary: {
|
||||||
|
source: 'upstream_response',
|
||||||
|
status_code: 429,
|
||||||
|
type: 'insufficient_quota',
|
||||||
|
message: 'quota exceeded',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(notice).toEqual({
|
||||||
|
title: '执行失败原因',
|
||||||
|
message: 'quota exceeded',
|
||||||
|
isSchedulingFailure: false,
|
||||||
|
meta: ['HTTP 429', 'insufficient_quota', 'upstream_response'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show a stale notice when the refreshed detail has no error fields', () => {
|
||||||
|
const notice = resolveRequestFailureNotice(buildRequestDetail({
|
||||||
|
status_code: 200,
|
||||||
|
status: 'completed',
|
||||||
|
error_message: undefined,
|
||||||
|
scheduling_failure: null,
|
||||||
|
failure_summary: null,
|
||||||
|
client_error: null,
|
||||||
|
upstream_error: null,
|
||||||
|
request_error: null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(notice).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
82
frontend/src/features/usage/utils/errorNotice.ts
Normal file
82
frontend/src/features/usage/utils/errorNotice.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { RequestDetail, RequestErrorDomain, RequestSchedulingFailure } from '@/api/dashboard'
|
||||||
|
|
||||||
|
export interface RequestFailureNotice {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
meta: string[]
|
||||||
|
isSchedulingFailure: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonEmptyString(value: string | null | undefined): string | null {
|
||||||
|
const trimmed = value?.trim()
|
||||||
|
return trimmed ? trimmed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): RequestErrorDomain | null {
|
||||||
|
if (!nonEmptyString(domain?.message)) return null
|
||||||
|
return domain ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHttpStatus(statusCode: number | null | undefined): string | null {
|
||||||
|
return typeof statusCode === 'number' ? `HTTP ${statusCode}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueMeta(values: Array<string | null | undefined>): string[] {
|
||||||
|
return Array.from(new Set(values.map(value => value?.trim()).filter((value): value is string => Boolean(value))))
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulingFailureMessage(
|
||||||
|
failure: RequestSchedulingFailure,
|
||||||
|
fallbackDomain: RequestErrorDomain | null,
|
||||||
|
fallbackErrorMessage: string | null,
|
||||||
|
): string | null {
|
||||||
|
return nonEmptyString(failure.message)
|
||||||
|
?? nonEmptyString(fallbackDomain?.message)
|
||||||
|
?? fallbackErrorMessage
|
||||||
|
?? nonEmptyString(failure.reason_label)
|
||||||
|
?? nonEmptyString(failure.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRequestFailureNotice(detail: RequestDetail | null | undefined): RequestFailureNotice | null {
|
||||||
|
if (!detail) return null
|
||||||
|
|
||||||
|
const fallbackDomain = normalizeErrorDomain(detail.failure_summary)
|
||||||
|
?? normalizeErrorDomain(detail.client_error)
|
||||||
|
?? normalizeErrorDomain(detail.upstream_error)
|
||||||
|
?? normalizeErrorDomain(detail.request_error)
|
||||||
|
const fallbackErrorMessage = nonEmptyString(detail.error_message ?? null)
|
||||||
|
const schedulingFailure = detail.scheduling_failure ?? null
|
||||||
|
|
||||||
|
if (schedulingFailure) {
|
||||||
|
const message = schedulingFailureMessage(schedulingFailure, fallbackDomain, fallbackErrorMessage)
|
||||||
|
if (message) {
|
||||||
|
return {
|
||||||
|
title: nonEmptyString(schedulingFailure.title) ?? '本地调度失败',
|
||||||
|
message,
|
||||||
|
isSchedulingFailure: true,
|
||||||
|
meta: uniqueMeta([
|
||||||
|
nonEmptyString(schedulingFailure.reason_summary),
|
||||||
|
nonEmptyString(schedulingFailure.reason_label),
|
||||||
|
nonEmptyString(schedulingFailure.reason),
|
||||||
|
formatHttpStatus(schedulingFailure.status_code ?? detail.status_code),
|
||||||
|
schedulingFailure.no_upstream_attempt ? '未进入上游执行' : null,
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = fallbackDomain
|
||||||
|
const message = nonEmptyString(domain?.message) ?? fallbackErrorMessage
|
||||||
|
if (!message) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '执行失败原因',
|
||||||
|
message,
|
||||||
|
isSchedulingFailure: false,
|
||||||
|
meta: uniqueMeta([
|
||||||
|
formatHttpStatus(domain?.status_code ?? detail.status_code),
|
||||||
|
nonEmptyString(domain?.type),
|
||||||
|
nonEmptyString(domain?.source),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user