mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 05:00:19 +08:00
Merge remote-tracking branch 'origin/pr/555'
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user