Merge remote-tracking branch 'origin/pr/555'

This commit is contained in:
fawney19
2026-05-25 21:14:45 +08:00
14 changed files with 526 additions and 77 deletions
@@ -1,7 +1,9 @@
use super::super::super::errors::build_internal_control_error_response;
use super::super::super::provisioning::provider_oauth_token_payload_expires_at_unix_secs;
use super::super::super::quota::codex::refresh_codex_provider_quota_locally;
use super::super::super::runtime::resolve_provider_oauth_runtime_endpoints;
use super::super::super::runtime::{
resolve_provider_oauth_runtime_endpoints,
spawn_provider_oauth_account_state_refresh_after_update,
};
use super::super::super::state::{
admin_provider_oauth_template, enrich_admin_provider_oauth_auth_config,
is_fixed_provider_type_for_provider_oauth, json_non_empty_string,
@@ -219,50 +221,20 @@ pub(super) async fn handle_admin_provider_oauth_complete_key(
));
}
let mut account_state_recheck_attempted = false;
let mut account_state_recheck_error = None::<String>;
if provider_type == "codex" {
if let Some(endpoint) = runtime_endpoint {
let refreshed_key = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
.into_iter()
.next()
.unwrap_or_else(|| key.clone());
if let Some(result) = refresh_codex_provider_quota_locally(
state,
&provider,
&endpoint,
vec![refreshed_key],
request_proxy.clone(),
)
.await?
{
account_state_recheck_attempted = true;
let success = result
.get("success")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
if success == 0 {
account_state_recheck_error = result
.get("results")
.and_then(serde_json::Value::as_array)
.and_then(|results| results.first())
.and_then(|value| value.get("message"))
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned);
}
}
}
}
spawn_provider_oauth_account_state_refresh_after_update(
state.cloned_app(),
provider.clone(),
key_id.clone(),
request_proxy.clone(),
);
Ok(Json(json!({
"provider_type": provider_type,
"expires_at": expires_at,
"has_refresh_token": refresh_token.is_some(),
"email": auth_config.get("email").cloned().unwrap_or(serde_json::Value::Null),
"account_state_recheck_attempted": account_state_recheck_attempted,
"account_state_recheck_error": account_state_recheck_error,
"account_state_recheck_attempted": false,
"account_state_recheck_error": serde_json::Value::Null,
}))
.into_response())
}
@@ -2874,15 +2874,10 @@ async fn gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_p
assert_eq!(payload["has_refresh_token"], true);
assert_eq!(payload["expires_at"], 4_102_444_800u64);
assert_eq!(payload["email"], "alice@example.com");
assert_eq!(payload["account_state_recheck_attempted"], true);
let account_state_recheck_error = payload["account_state_recheck_error"]
.as_str()
.expect("account_state_recheck_error should be string when recheck is attempted");
assert!(
account_state_recheck_error == "wham/usage API 返回状态码 401"
|| account_state_recheck_error == "wham/usage API 返回状态码 403"
|| account_state_recheck_error.starts_with("wham/usage 请求执行失败:"),
"unexpected account_state_recheck_error: {account_state_recheck_error}"
assert_eq!(payload["account_state_recheck_attempted"], false);
assert_eq!(
payload["account_state_recheck_error"],
serde_json::Value::Null
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
+40 -4
View File
@@ -407,7 +407,10 @@ fn is_openai_responses_family_format_alias(value: &str) -> bool {
fn stream_report_captured_terminal_state(
payload: &GatewayStreamReportRequest,
) -> Option<StreamCapturedTerminalState> {
let provider_state = stream_report_provider_capture_requires_terminal_event(payload)
let provider_requires_terminal =
stream_report_provider_capture_requires_terminal_event(payload);
let client_requires_terminal = stream_report_client_capture_requires_terminal_event(payload);
let provider_state = provider_requires_terminal
.then(|| {
stream_capture_terminal_state_from_base64(
payload.provider_body_base64.as_deref(),
@@ -415,7 +418,7 @@ fn stream_report_captured_terminal_state(
)
})
.flatten();
let client_state = stream_report_client_capture_requires_terminal_event(payload)
let client_state = client_requires_terminal
.then(|| {
stream_capture_terminal_state_from_base64(
payload.client_body_base64.as_deref(),
@@ -423,7 +426,37 @@ fn stream_report_captured_terminal_state(
)
})
.flatten();
combine_stream_terminal_states(provider_state, client_state)
combine_stream_terminal_states(provider_state, client_state).or_else(|| {
stream_report_required_captures_are_empty(
payload,
provider_requires_terminal,
client_requires_terminal,
)
.then_some(StreamCapturedTerminalState::Missing)
})
}
fn stream_report_required_captures_are_empty(
payload: &GatewayStreamReportRequest,
provider_requires_terminal: bool,
client_requires_terminal: bool,
) -> bool {
let mut has_required_capture = false;
let mut all_required_captures_empty = true;
if provider_requires_terminal {
has_required_capture = true;
all_required_captures_empty &= payload.provider_body_base64.is_none()
&& payload.provider_body_state == Some(UsageBodyCaptureState::None);
}
if client_requires_terminal {
has_required_capture = true;
all_required_captures_empty &= payload.client_body_base64.is_none()
&& payload.client_body_state == Some(UsageBodyCaptureState::None);
}
has_required_capture && all_required_captures_empty
}
fn stream_report_provider_capture_requires_terminal_event(
@@ -524,7 +557,10 @@ fn filter_incomplete_capture_terminal_state(
fn stream_body_capture_can_prove_missing_terminal(
body_state: Option<UsageBodyCaptureState>,
) -> bool {
matches!(body_state, None | Some(UsageBodyCaptureState::Inline))
matches!(
body_state,
None | Some(UsageBodyCaptureState::Inline) | Some(UsageBodyCaptureState::None)
)
}
pub fn stream_capture_terminal_state(value: &Value) -> Option<StreamCapturedTerminalState> {
+134 -4
View File
@@ -943,9 +943,20 @@ pub fn build_stream_terminal_usage_seed(
context_seed.client_contract.as_str(),
context_seed.provider_contract.as_str(),
);
let observed_stream_finish = observed_stream_finish.or_else(|| {
captured_terminal_state.map(|state| state != StreamCapturedTerminalState::Missing)
});
let empty_required_capture_missing_terminal = stream_empty_required_captures_missing_terminal(
report_kind.as_str(),
context_seed.client_contract.as_str(),
context_seed.provider_contract.as_str(),
provider_response_full.as_ref(),
provider_response_body_state,
client_response.as_ref(),
client_response_body_state,
);
let observed_stream_finish = observed_stream_finish
.or_else(|| {
captured_terminal_state.map(|state| state != StreamCapturedTerminalState::Missing)
})
.or_else(|| empty_required_capture_missing_terminal.then_some(false));
let missing_observed_finish = matches!(observed_stream_finish, Some(false))
&& (requires_observed_terminal_event
|| !standardized_usage
@@ -1137,7 +1148,10 @@ fn captured_stream_terminal_state_from_body(
fn stream_body_capture_can_prove_missing_terminal(
body_state: Option<UsageBodyCaptureState>,
) -> bool {
matches!(body_state, None | Some(UsageBodyCaptureState::Inline))
matches!(
body_state,
None | Some(UsageBodyCaptureState::Inline) | Some(UsageBodyCaptureState::None)
)
}
fn combine_stream_capture_terminal_states(
@@ -1161,6 +1175,48 @@ fn combine_stream_capture_terminal_states(
}
}
#[allow(clippy::too_many_arguments)]
fn stream_empty_required_captures_missing_terminal(
report_kind: &str,
client_contract: &str,
provider_contract: &str,
provider_response: Option<&Value>,
provider_response_body_state: Option<UsageBodyCaptureState>,
client_response: Option<&Value>,
client_response_body_state: Option<UsageBodyCaptureState>,
) -> bool {
let report_kind_requires_terminal_event =
stream_report_kind_requires_observed_terminal_event(report_kind);
let provider_contract_requires_terminal_event =
is_openai_responses_family_format_alias(provider_contract);
let client_contract_requires_terminal_event =
is_openai_responses_family_format_alias(client_contract);
let fallback_requires_terminal_event = report_kind_requires_terminal_event
&& !provider_contract_requires_terminal_event
&& !client_contract_requires_terminal_event;
let provider_requires_terminal =
provider_contract_requires_terminal_event || fallback_requires_terminal_event;
let client_requires_terminal =
client_contract_requires_terminal_event || fallback_requires_terminal_event;
let mut has_required_capture = false;
let mut all_required_captures_empty = true;
if provider_requires_terminal {
has_required_capture = true;
all_required_captures_empty &= provider_response.is_none()
&& provider_response_body_state == Some(UsageBodyCaptureState::None);
}
if client_requires_terminal {
has_required_capture = true;
all_required_captures_empty &= client_response.is_none()
&& client_response_body_state == Some(UsageBodyCaptureState::None);
}
has_required_capture && all_required_captures_empty
}
fn stream_report_kind_requires_observed_terminal_event(report_kind: &str) -> bool {
let report_kind = report_kind.trim().to_ascii_lowercase();
report_kind.starts_with("openai_responses_")
@@ -4073,6 +4129,80 @@ mod tests {
assert_eq!(event.data.output_tokens, None);
}
#[test]
fn stream_terminal_usage_marks_empty_openai_responses_capture_as_missing_terminal() {
let plan = ExecutionPlan {
request_id: "req-stream-empty-capture-1".to_string(),
candidate_id: Some("cand-stream-empty-capture-1".to_string()),
provider_name: Some("OpenAI".to_string()),
provider_id: "provider-1".to_string(),
endpoint_id: "endpoint-1".to_string(),
key_id: "key-1".to_string(),
method: "POST".to_string(),
url: "https://example.com/v1/responses".to_string(),
headers: BTreeMap::new(),
content_type: None,
content_encoding: None,
body: RequestBody {
json_body: None,
body_bytes_b64: None,
body_ref: None,
},
stream: true,
client_api_format: "openai:responses".to_string(),
provider_api_format: "openai:responses".to_string(),
model_name: Some("gpt-5.5".to_string()),
proxy: None,
transport_profile: None,
timeouts: None,
};
let payload = GatewayStreamReportRequest {
trace_id: "trace-stream-empty-capture-1".to_string(),
report_kind: "openai_responses_stream_success".to_string(),
report_context: Some(json!({
"client_api_format": "openai:responses",
"provider_api_format": "openai:responses"
})),
status_code: 200,
headers: BTreeMap::new(),
provider_body_base64: None,
provider_body_state: Some(UsageBodyCaptureState::None),
client_body_base64: None,
client_body_state: Some(UsageBodyCaptureState::None),
terminal_summary: None,
telemetry: None,
};
let event =
build_stream_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload)
.expect("usage event should build");
assert_eq!(event.event_type, UsageEventType::Failed);
assert_eq!(event.data.status_code, Some(200));
assert_eq!(
event.data.error_category.as_deref(),
Some("stream_missing_terminal_event")
);
assert_eq!(
event.data.error_message.as_deref(),
Some("execution runtime stream ended before provider terminal event")
);
assert_eq!(
event
.data
.client_response_body
.as_ref()
.and_then(|body| body.get("error"))
.and_then(|error| error.get("type"))
.and_then(Value::as_str),
Some("stream_missing_terminal_event")
);
assert_eq!(
event.data.client_response_body_state,
Some(UsageBodyCaptureState::Inline)
);
}
#[test]
fn stream_terminal_usage_marks_missing_captured_openai_responses_terminal_as_failed() {
let plan = ExecutionPlan {
@@ -19,6 +19,25 @@
class="pl-8 h-9"
/>
</div>
<Button
v-if="autoMatchKey"
variant="outline"
size="sm"
class="h-9 shrink-0"
:disabled="loadingGlobalModels || fetchingAutoMatchedModels"
:title="`按 ${autoMatchKeyLabel} 的上游模型自动勾选同名模型`"
@click="applyAutoMatchFromKey(true)"
>
<Loader2
v-if="fetchingAutoMatchedModels"
class="w-3.5 h-3.5 mr-1.5 animate-spin"
/>
<ListChecks
v-else
class="w-3.5 h-3.5 mr-1.5"
/>
匹配勾选
</Button>
</div>
<!-- 模型列表 -->
@@ -126,13 +145,14 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Layers, Loader2, Search, Check } from 'lucide-vue-next'
import { Layers, Loader2, Search, Check, ListChecks } from 'lucide-vue-next'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { parseApiError } from '@/utils/errorParser'
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
import {
getGlobalModels,
type GlobalModelResponse
@@ -144,10 +164,17 @@ import {
type Model
} from '@/api/endpoints'
interface AutoMatchKey {
id: string
name?: string | null
api_key_masked?: string | null
}
const props = defineProps<{
open: boolean
providerId: string
providerName?: string
autoMatchKey?: AutoMatchKey | null
}>()
const emit = defineEmits<{
@@ -155,12 +182,15 @@ const emit = defineEmits<{
'changed': []
}>()
const { error: showError, success } = useToast()
const { error: showError, success, warning: showWarning } = useToast()
const { confirmWarning } = useConfirm()
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
// 状态
const loadingGlobalModels = ref(false)
const saving = ref(false)
const fetchingAutoMatchedModels = ref(false)
const autoMatchedKeyId = ref<string | null>(null)
// 数据
const allGlobalModels = ref<GlobalModelResponse[]>([])
@@ -175,6 +205,13 @@ const initialGlobalModelIds = ref<Set<string>>(new Set())
// 搜索状态
const searchQuery = ref('')
const autoMatchKey = computed(() => props.autoMatchKey ?? null)
const autoMatchKeyLabel = computed(() => {
const key = autoMatchKey.value
if (!key) return ''
return key.name || key.api_key_masked || key.id.slice(0, 8)
})
// 已关联的全局模型 ID 集合(从已有数据计算)
const existingGlobalModelIds = computed(() => {
return new Set(
@@ -264,6 +301,73 @@ function toggleAllGlobalModels() {
selectedGlobalModelIds.value = new Set(selectedGlobalModelIds.value)
}
function normalizeModelName(name: string | null | undefined): string {
return (name || '').trim()
}
async function applyAutoMatchFromKey(forceRefresh = false) {
const key = autoMatchKey.value
if (!props.providerId || !key || fetchingAutoMatchedModels.value) return
if (!forceRefresh && autoMatchedKeyId.value === key.id) return
fetchingAutoMatchedModels.value = true
try {
const result = await fetchCachedModels(props.providerId, key.id, forceRefresh)
if (!props.open || autoMatchKey.value?.id !== key.id) return
if (result.error && result.models.length > 0) {
showWarning(`部分格式获取失败: ${result.error}`)
}
if (result.models.length === 0) {
if (result.error) {
showError(result.error, '获取上游模型失败')
} else {
showWarning('此 Key 未返回可用模型')
}
autoMatchedKeyId.value = key.id
return
}
const upstreamModelIds = new Set(
result.models
.map(model => normalizeModelName(model.id))
.filter(Boolean)
)
const matchedGlobalModelIds = allGlobalModels.value
.filter(model => upstreamModelIds.has(normalizeModelName(model.name)))
.map(model => model.id)
autoMatchedKeyId.value = key.id
if (matchedGlobalModelIds.length === 0) {
showWarning('未找到与此 Key 上游模型 ID 同名的全局模型')
return
}
const nextSelected = new Set(selectedGlobalModelIds.value)
let newlySelectedCount = 0
for (const id of matchedGlobalModelIds) {
if (!nextSelected.has(id)) {
newlySelectedCount++
}
nextSelected.add(id)
}
selectedGlobalModelIds.value = nextSelected
searchQuery.value = ''
if (newlySelectedCount > 0) {
success(`已按 ${autoMatchKeyLabel.value} 勾选 ${matchedGlobalModelIds.length} 个同名模型`)
} else {
success(`${matchedGlobalModelIds.length} 个同名模型已在选中列表中`)
}
} catch (err: unknown) {
showError(parseApiError(err, '自动匹配模型失败'), '错误')
} finally {
fetchingAutoMatchedModels.value = false
}
}
// 处理关闭
async function handleClose() {
if (hasChanges.value) {
@@ -350,11 +454,17 @@ function syncGlobalModelSelection() {
// 监听打开状态
watch(() => props.open, async (isOpen) => {
if (isOpen && props.providerId) {
autoMatchedKeyId.value = null
await loadData()
if (autoMatchKey.value) {
await applyAutoMatchFromKey(false)
}
} else {
searchQuery.value = ''
selectedGlobalModelIds.value = new Set()
initialGlobalModelIds.value = new Set()
fetchingAutoMatchedModels.value = false
autoMatchedKeyId.value = null
}
})
@@ -467,6 +467,15 @@
>
<Shield class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="按此 Key 自动勾选同名模型"
@click="handleAutoMatchKeyModels(key)"
>
<ListChecks class="w-3.5 h-3.5" />
</Button>
<!-- 代理节点配置 -->
<Popover
:open="proxyPopoverOpenKeyId === key.id"
@@ -1323,7 +1332,8 @@
:open="batchAssignDialogOpen"
:provider-id="provider.id"
:provider-name="provider.name"
@update:open="batchAssignDialogOpen = $event"
:auto-match-key="batchAssignAutoMatchKey"
@update:open="handleBatchAssignDialogOpenUpdate"
@changed="handleBatchAssignChanged"
/>
@@ -1368,6 +1378,7 @@ import {
ShieldX,
Globe,
GitBranch,
ListChecks,
} from 'lucide-vue-next'
import { parseApiError } from '@/utils/errorParser'
import { useEscapeKey } from '@/composables/useEscapeKey'
@@ -1464,6 +1475,12 @@ interface ProviderEndpointWithKeys extends ProviderEndpoint {
rpm_limit?: number
}
interface BatchAssignAutoMatchKey {
id: string
name?: string | null
api_key_masked?: string | null
}
interface Props {
providerId: string | null
open: boolean
@@ -1530,6 +1547,7 @@ const revealedKeys = ref<Map<string, string>>(new Map())
const modelFormDialogOpen = ref(false)
const editingModel = ref<Model | null>(null)
const batchAssignDialogOpen = ref(false)
const batchAssignAutoMatchKey = ref<BatchAssignAutoMatchKey | null>(null)
const modelMappingTabRef = ref<InstanceType<typeof ModelMappingTab> | null>(null)
// 密钥列表拖拽排序状态
@@ -1738,6 +1756,7 @@ watch(
oauthKeyEditDialogOpen.value = false
deleteKeyConfirmOpen.value = false
batchAssignDialogOpen.value = false
batchAssignAutoMatchKey.value = null
antigravityQuotaDialogOpen.value = false
antigravityQuotaDialogKey.value = null
@@ -3088,9 +3107,26 @@ function handleEditModel(model: Model) {
// 处理打开批量关联对话框
function handleBatchAssign() {
batchAssignAutoMatchKey.value = null
batchAssignDialogOpen.value = true
}
function handleAutoMatchKeyModels(key: EndpointAPIKey) {
batchAssignAutoMatchKey.value = {
id: key.id,
name: key.name,
api_key_masked: key.api_key_masked,
}
batchAssignDialogOpen.value = true
}
function handleBatchAssignDialogOpenUpdate(value: boolean) {
batchAssignDialogOpen.value = value
if (!value) {
batchAssignAutoMatchKey.value = null
}
}
// 处理批量关联完成
async function handleBatchAssignChanged() {
await Promise.all([loadEndpoints(), loadMappingPreview()])
@@ -1220,13 +1220,16 @@ const normalizeUpstreamResponseDisplay = (value: unknown): Record<string, unknow
const body = raw.body
const bodyRef = readStringField(raw, 'body_ref') ?? readStringField(raw, 'bodyRef')
const bodyState = readStringField(raw, 'body_state') ?? readStringField(raw, 'bodyState')
const meaningfulBodyState = bodyState && bodyState.toLowerCase() !== 'none'
? bodyState
: ''
if (
statusCode == null &&
!hasRenderableValue(headers) &&
!hasRenderableValue(body) &&
!bodyRef &&
!bodyState
!meaningfulBodyState
) {
return null
}
@@ -1236,7 +1239,7 @@ const normalizeUpstreamResponseDisplay = (value: unknown): Record<string, unknow
if (hasRenderableValue(headers)) data.headers = headers
if (hasRenderableValue(body)) data.body = body
if (bodyRef) data.body_ref = bodyRef
if (bodyState) data.body_state = bodyState
if (meaningfulBodyState) data.body_state = meaningfulBodyState
return data
}
@@ -828,6 +828,16 @@ import {
type RenderBlock,
} from '../conversation'
type RequestStateStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
const REQUEST_STATE_STATUSES = new Set<RequestStateStatus>([
'pending',
'streaming',
'completed',
'failed',
'cancelled',
])
const props = defineProps<{
isOpen: boolean
requestId: string | null
@@ -838,7 +848,7 @@ const emit = defineEmits<{
requestState: [state: {
id: string
requestId?: string | null
status?: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
status?: RequestStateStatus
statusCode?: number | null
responseTimeMs?: number | null
imageProgress?: ImageProgress | null
@@ -922,7 +932,7 @@ function formatErrorDomainMeta(domain: NormalizedErrorDomain): string {
function mapTraceFinalStatusToRequestStatus(
status?: RequestTrace['final_status'] | null
): 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled' | undefined {
): RequestStateStatus | undefined {
switch (status) {
case 'success':
return 'completed'
@@ -939,6 +949,49 @@ function mapTraceFinalStatusToRequestStatus(
}
}
function normalizeRequestStateStatus(status: unknown): RequestStateStatus | undefined {
const normalized = typeof status === 'string' ? status.trim().toLowerCase() : ''
return REQUEST_STATE_STATUSES.has(normalized as RequestStateStatus)
? normalized as RequestStateStatus
: undefined
}
function hasRequestFailureSignal(statusCode?: number | null, errorMessage?: string | null): boolean {
return (typeof statusCode === 'number' && statusCode >= 400) ||
(typeof errorMessage === 'string' && errorMessage.trim().length > 0)
}
function resolveRequestStateStatus(
status: unknown,
statusCode?: number | null,
errorMessage?: string | null
): RequestStateStatus | undefined {
const normalized = normalizeRequestStateStatus(status)
if ((normalized == null || normalized === 'pending' || normalized === 'streaming') &&
hasRequestFailureSignal(statusCode, errorMessage)) {
return 'failed'
}
return normalized
}
function resolveRequestStateStatusFromDetail(nextDetail: Pick<RequestDetail, 'status' | 'status_code' | 'error_message'>): RequestStateStatus | undefined {
return resolveRequestStateStatus(nextDetail.status, nextDetail.status_code, nextDetail.error_message)
}
function emitDetailRequestState(nextDetail: RequestDetail) {
const id = props.requestId
if (!id) return
emit('requestState', {
id,
requestId: nextDetail.request_id || nextDetail.id || null,
status: resolveRequestStateStatusFromDetail(nextDetail),
statusCode: nextDetail.status_code ?? undefined,
responseTimeMs: nextDetail.response_time_ms ?? undefined,
errorMessage: nextDetail.error_message ?? undefined,
})
}
function handleTraceState(state: {
loaded: boolean
hasTrace: boolean
@@ -953,7 +1006,11 @@ function handleTraceState(state: {
const id = props.requestId
if (!id) return
const status = mapTraceFinalStatusToRequestStatus(state.finalStatus)
const status = resolveRequestStateStatus(
mapTraceFinalStatusToRequestStatus(state.finalStatus),
state.statusCode,
state.errorMessage
)
const imageFailed = state.imageProgress?.phase === 'failed'
if (!status && !state.imageProgress && state.statusCode == null && state.latencyMs == null) return
@@ -2094,8 +2151,9 @@ async function loadDetail(id: string, silent = false) {
const prevKey = previousDetail?.request_id || previousDetail?.id
const currKey = response.request_id || response.id
const sameRequest = !!prevKey && prevKey === currKey
detail.value = {
const nextDetail: RequestDetail = {
...response,
status: resolveRequestStateStatusFromDetail(response) ?? response.status,
request_body: sameRequest ? previousDetail?.request_body : undefined,
provider_request_body: sameRequest ? previousDetail?.provider_request_body : undefined,
response_body: sameRequest ? previousDetail?.response_body : undefined,
@@ -2108,7 +2166,9 @@ async function loadDetail(id: string, silent = false) {
error_flow: response.error_flow,
scheduling_failure: response.scheduling_failure,
}
detail.value = nextDetail
bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null
emitDetailRequestState(nextDetail)
// 首次加载时优先停留在轻量 tab,避免默认触发大 body 加载
if (!silent) {
@@ -654,7 +654,14 @@
<TableCell v-if="isColumnVisible('status')" class="text-center py-4 w-[10%]">
<!-- 优先显示请求状态 -->
<Badge
v-if="getDisplayStatus(record) === 'pending'"
v-if="isUsageRecordFailed(record)"
variant="destructive"
class="whitespace-nowrap"
>
失败
</Badge>
<Badge
v-else-if="getDisplayStatus(record) === 'pending'"
variant="outline"
class="whitespace-nowrap animate-pulse border-muted-foreground/30 text-muted-foreground"
>
@@ -667,13 +674,6 @@
>
传输中
</Badge>
<Badge
v-else-if="isUsageRecordFailed(record)"
variant="destructive"
class="whitespace-nowrap"
>
失败
</Badge>
<Badge
v-else-if="record.status === 'cancelled'"
variant="outline"
@@ -481,4 +481,33 @@ describe('HorizontalRequestTimeline', () => {
expect(root.textContent).not.toContain('不再重试')
expect(root.textContent).not.toContain('该错误被标记为敏感上游错误')
})
it('keeps the failure message when upstream response only records an empty body state', async () => {
const trace = buildTrace([
buildCandidate({
id: 'cand-empty-body-state',
provider_id: 'provider-empty-body-state',
provider_name: 'Provider Empty Body State',
key_id: 'key-empty-body-state',
key_name: 'Empty Body State Key',
candidate_index: 0,
status: 'failed',
error_type: 'stream_missing_terminal_event',
error_message: 'execution runtime stream ended before provider terminal event',
extra_data: {
upstream_response: {
body_state: 'none',
},
},
}),
])
const root = mountTimeline(trace)
await nextTick()
expect(root.textContent).toContain('错误信息')
expect(root.textContent).toContain('execution runtime stream ended before provider terminal event')
expect(root.querySelector('.error-block .error-json')).toBeNull()
expect(root.textContent).not.toContain('"body_state":"none"')
})
})
@@ -257,6 +257,19 @@ describe('UsageRecordsTable', () => {
expect(root.textContent).not.toContain('等待中')
})
it('shows failed instead of waiting when an active row has an HTTP error code', () => {
const root = mountUsageRecordsTable([buildRecord({
status: 'pending',
status_code: 524,
error_message: 'error code: 524',
response_time_ms: null,
first_byte_time_ms: null,
})])
expect(root.textContent).toContain('失败')
expect(root.textContent).not.toContain('等待中')
})
it('renders output TPS in the non-admin usage table', () => {
const root = mountUsageRecordsTable([buildRecord()], { isAdmin: false })
@@ -102,6 +102,47 @@ describe('useUsageData', () => {
expect(totalRecords.value).toBe(1)
})
it('keeps locally resolved failure fields when a stale active record refreshes', async () => {
const isAdminPage = ref(true)
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'failed',
status_code: 524,
error_message: 'error code: 524',
response_time_ms: 125_000,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'pending',
status_code: undefined,
error_message: undefined,
response_time_ms: null,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value[0]).toMatchObject({
status: 'failed',
status_code: 524,
error_message: 'error code: 524',
response_time_ms: 125_000,
})
})
it('continues loading admin breakdowns when the summary request fails', async () => {
const isAdminPage = ref(true)
const {
@@ -509,6 +509,9 @@ export function useUsageData(options: UseUsageDataOptions) {
actual_cost: existing.actual_cost ?? record.actual_cost,
response_time_ms: mergePositiveDurationMs(existing.response_time_ms, record.response_time_ms),
first_byte_time_ms: mergePositiveDurationMs(existing.first_byte_time_ms, record.first_byte_time_ms),
status_code: existing.status_code ?? record.status_code,
error_message: existing.error_message ?? record.error_message,
image_progress: existing.image_progress ?? record.image_progress,
is_stream: upstreamIsStream,
upstream_is_stream: upstreamIsStream,
client_requested_stream: clientRequestedStream,
+24 -3
View File
@@ -156,6 +156,7 @@ import {
hasUsageFallback,
isUsageRecordFailed,
isUsageUpstreamStream,
normalizeRequestStatus,
resolveDisplayRequestStatus,
} from '@/features/usage/utils/status'
import type { DateRangeParams, FilterStatusValue, RequestStatus } from '@/features/usage/types'
@@ -974,6 +975,8 @@ function handleDetailRequestState(update: {
const record = currentRecords.value.find(record => record.id === update.id)
if (!record) return
const nextStatus = resolveDetailUpdateStatus(update)
const statusPriority: Record<RequestStatus, number> = {
pending: 0,
streaming: 1,
@@ -981,11 +984,11 @@ function handleDetailRequestState(update: {
failed: 2,
cancelled: 2,
}
if (update.status) {
if (nextStatus) {
const currentRank = record.status ? statusPriority[record.status] : 0
const nextRank = statusPriority[update.status]
const nextRank = statusPriority[nextStatus]
if (nextRank >= currentRank) {
record.status = update.status
record.status = nextStatus
}
}
if ('statusCode' in update) {
@@ -1005,6 +1008,24 @@ function handleDetailRequestState(update: {
}
}
function resolveDetailUpdateStatus(update: {
status?: RequestStatus
statusCode?: number | null
imageProgress?: ImageProgress | null
errorMessage?: string | null
}): RequestStatus | undefined {
const status = normalizeRequestStatus(update.status)
const hasFailureSignal =
(typeof update.statusCode === 'number' && update.statusCode >= 400) ||
(typeof update.errorMessage === 'string' && update.errorMessage.trim().length > 0) ||
update.imageProgress?.phase === 'failed'
if ((status == null || status === 'pending' || status === 'streaming') && hasFailureSignal) {
return 'failed'
}
return status
}
function prefetchRequestDetail(id: string) {
if (!isAdminPage.value) return
void dashboardApi.prefetchRequestDetail(id).catch(error => {