feat: 请求记录 cURL 导出与回放功能

- 后端新增 /{usage_id}/curl 接口,重建完整 cURL 命令(含明文 API Key)
- 后端新增 /{usage_id}/replay 接口,支持向原始或指定提供商回放请求
- 回放支持 OAuth/Vertex AI/API Key 多种认证方式和跨格式请求体转换
- 前端新增 ReplayDialog 组件,支持选择目标提供商/Key 并查看响应
- RequestDetailDrawer 添加回放按钮和 cURL 复制按钮
- 调整 Tab 内容区布局,表头栏与内容区融为一体
This commit is contained in:
fawney19
2026-02-10 03:07:49 +08:00
parent 9f5dd6f658
commit f9adf5938e
4 changed files with 1160 additions and 89 deletions

View File

@@ -205,6 +205,30 @@ export interface RequestDetail {
video_billing?: VideoBilling | null video_billing?: VideoBilling | null
} }
export interface CurlData {
url: string
method: string
headers: Record<string, string>
body: Record<string, any>
curl: string
}
export interface ReplayRequest {
provider_id?: string
endpoint_id?: string
api_key_id?: string
body_override?: Record<string, any>
}
export interface ReplayResponse {
url: string
provider: string
status_code: number
response_headers: Record<string, string>
response_body: Record<string, any>
response_time_ms: number
}
export interface ModelBreakdown { export interface ModelBreakdown {
model: string model: string
requests: number requests: number
@@ -294,5 +318,20 @@ export const dashboardApi = {
params params
}) })
return response.data return response.data
},
// 获取 cURL 命令数据(含明文 API Key
async getCurlData(requestId: string): Promise<CurlData> {
const response = await apiClient.get<CurlData>(`/api/admin/usage/${requestId}/curl`)
return response.data
},
// 回放请求到提供商
async replayRequest(requestId: string, params?: ReplayRequest): Promise<ReplayResponse> {
const response = await apiClient.post<ReplayResponse>(
`/api/admin/usage/${requestId}/replay`,
params || {}
)
return response.data
} }
} }

View File

@@ -0,0 +1,475 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="isOpen"
class="fixed inset-0 z-[60] flex items-center justify-center"
@click.self="handleClose"
>
<div
class="absolute inset-0 bg-black/30 backdrop-blur-sm"
@click="handleClose"
/>
<Card class="relative w-full max-w-6xl max-h-[85vh] min-h-[60vh] mx-4 shadow-2xl flex flex-col">
<!-- 头部标题 + 提供商/Key 选择 + 发送 -->
<div class="px-4 py-2.5 border-b flex items-center gap-3 shrink-0 flex-wrap">
<h3 class="text-sm font-semibold shrink-0">
请求回放
</h3>
<Separator
orientation="vertical"
class="h-4"
/>
<!-- 提供商选择 -->
<div class="flex items-center gap-1.5 min-w-0">
<label class="text-xs text-muted-foreground shrink-0">提供商</label>
<select
v-model="selectedProviderId"
class="h-7 rounded-md border border-input bg-background px-2 text-xs min-w-[140px]"
:disabled="replaying"
@change="onProviderChange"
>
<option value="">
原始 ({{ detail?.provider || '-' }})
</option>
<option
v-for="p in providers"
:key="p.id"
:value="p.id"
>
{{ p.name }}
</option>
</select>
</div>
<!-- Key 选择 -->
<div class="flex items-center gap-1.5 min-w-0">
<label class="text-xs text-muted-foreground shrink-0">Key</label>
<select
v-model="selectedKeyId"
class="h-7 rounded-md border border-input bg-background px-2 text-xs min-w-[140px]"
:disabled="replaying || loadingKeys"
>
<option value="">
{{ loadingKeys ? '加载中...' : selectedProviderId ? '自动选择' : '原始 Key' }}
</option>
<option
v-for="k in keys"
:key="k.id"
:value="k.id"
>
{{ k.name }} ({{ k.api_key_masked }})
</option>
</select>
</div>
<!-- 右侧发送 + 关闭 -->
<div class="flex items-center gap-1 ml-auto shrink-0">
<Button
size="sm"
:disabled="replaying"
class="gap-1.5 h-7 text-xs"
@click="doReplay"
>
<Loader2
v-if="replaying"
class="w-3.5 h-3.5 animate-spin"
/>
<Play
v-else
class="w-3.5 h-3.5"
/>
{{ replaying ? '请求中...' : '发送' }}
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="handleClose"
>
<X class="w-4 h-4" />
</Button>
</div>
</div>
<!-- 双栏内容区 -->
<div class="flex-1 min-h-0 flex">
<!-- ===== 左栏请求 ===== -->
<div class="w-1/2 flex flex-col min-h-0 border-r">
<!-- 左栏头 -->
<div class="px-4 py-1.5 border-b bg-muted/30 flex items-center justify-between shrink-0">
<div class="flex items-center gap-2 min-w-0">
<span class="text-xs font-medium text-muted-foreground">请求</span>
<span
v-if="detail?.model"
class="text-[11px] text-muted-foreground/60 font-mono truncate"
>{{ detail.model }}</span>
</div>
<button
class="p-1 rounded transition-colors text-muted-foreground hover:bg-muted shrink-0"
:title="requestCopied ? '已复制' : '复制请求体'"
@click="copyRequestBody"
>
<Check
v-if="requestCopied"
class="w-3 h-3 text-green-500"
/>
<Copy
v-else
class="w-3 h-3"
/>
</button>
</div>
<!-- 左栏内容 -->
<div class="flex-1 overflow-y-auto scrollbar-stable">
<!-- 请求头可折叠 -->
<div class="border-b">
<button
class="w-full px-4 py-1.5 flex items-center gap-1.5 text-xs text-muted-foreground hover:bg-muted/30 transition-colors"
@click="showRequestHeaders = !showRequestHeaders"
>
<ChevronRight
class="w-3 h-3 transition-transform shrink-0"
:class="{ 'rotate-90': showRequestHeaders }"
/>
<span class="font-medium">Headers</span>
<span class="text-muted-foreground/60 ml-0.5">({{ requestHeaderCount }})</span>
</button>
<div
v-if="showRequestHeaders && hasRequestHeaders"
class="px-4 pb-2.5"
>
<div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-[11px] font-mono">
<template
v-for="(value, key) in displayRequestHeaders"
:key="key"
>
<span class="text-muted-foreground/70 text-right select-none">{{ key }}</span>
<span class="break-all">{{ value }}</span>
</template>
</div>
</div>
</div>
<!-- 请求体 -->
<div class="border-b">
<div class="px-4 py-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<span class="w-3 h-3 shrink-0" />
<span class="font-medium">Body</span>
</div>
</div>
<div class="px-4 py-3">
<pre
v-if="formattedRequestBody"
class="text-xs font-mono whitespace-pre-wrap break-all leading-relaxed"
>{{ formattedRequestBody }}</pre>
<div
v-else
class="text-xs text-muted-foreground/50 italic"
>
无请求体
</div>
</div>
</div>
</div>
<!-- ===== 右栏响应 ===== -->
<div class="w-1/2 flex flex-col min-h-0">
<!-- 右栏头 -->
<div class="px-4 py-1.5 border-b bg-muted/30 flex items-center justify-between shrink-0">
<div class="flex items-center gap-2 min-w-0">
<span class="text-xs font-medium text-muted-foreground">响应</span>
<template v-if="replayResult">
<Badge
:variant="replayResult.status_code < 400 ? 'success' : 'destructive'"
class="text-[10px] px-1.5 py-0 h-4"
>
{{ replayResult.status_code }}
</Badge>
<span class="text-[11px] text-muted-foreground/60">{{ replayResult.response_time_ms }}ms</span>
<span class="text-[11px] text-muted-foreground/60 font-mono truncate">{{ replayResult.provider }}</span>
</template>
</div>
<button
v-if="replayResult"
class="p-1 rounded transition-colors text-muted-foreground hover:bg-muted shrink-0"
:title="responseCopied ? '已复制' : '复制响应体'"
@click="copyResponseBody"
>
<Check
v-if="responseCopied"
class="w-3 h-3 text-green-500"
/>
<Copy
v-else
class="w-3 h-3"
/>
</button>
</div>
<!-- 右栏内容 -->
<div class="flex-1 overflow-y-auto scrollbar-stable">
<!-- 空状态 -->
<div
v-if="!replayResult && !replayError && !replaying"
class="flex flex-col items-center justify-center h-full text-muted-foreground/40 gap-2"
>
<Play class="w-8 h-8" />
<span class="text-xs">点击发送查看响应</span>
</div>
<!-- Loading -->
<div
v-else-if="replaying && !replayResult"
class="flex items-center justify-center h-full"
>
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
</div>
<!-- 错误 -->
<div
v-else-if="replayError"
class="px-4 py-4"
>
<div class="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3">
<p class="text-sm text-red-600 dark:text-red-400">
{{ replayError }}
</p>
</div>
</div>
<!-- 响应结果 -->
<template v-if="replayResult">
<!-- 响应头可折叠 -->
<div class="border-b">
<button
class="w-full px-4 py-1.5 flex items-center gap-1.5 text-xs text-muted-foreground hover:bg-muted/30 transition-colors"
@click="showResponseHeaders = !showResponseHeaders"
>
<ChevronRight
class="w-3 h-3 transition-transform shrink-0"
:class="{ 'rotate-90': showResponseHeaders }"
/>
<span class="font-medium">Headers</span>
<span class="text-muted-foreground/60 ml-0.5">({{ responseHeaderCount }})</span>
</button>
<div
v-if="showResponseHeaders"
class="px-4 pb-2.5"
>
<div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-[11px] font-mono">
<template
v-for="(value, key) in replayResult.response_headers"
:key="key"
>
<span class="text-muted-foreground/70 text-right select-none">{{ key }}</span>
<span class="break-all">{{ value }}</span>
</template>
</div>
</div>
</div>
<!-- 响应体 -->
<div class="border-b">
<div class="px-4 py-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<span class="w-3 h-3 shrink-0" />
<span class="font-medium">Body</span>
<span
v-if="replayResult.url"
class="text-muted-foreground/40 font-mono text-[10px] truncate ml-1"
>{{ replayResult.url }}</span>
</div>
</div>
<div class="px-4 py-3">
<pre class="text-xs font-mono whitespace-pre-wrap break-all leading-relaxed">{{ formattedResponseBody }}</pre>
</div>
</template>
</div>
</div>
</div>
</Card>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { dashboardApi, type ReplayResponse, type RequestDetail } from '@/api/dashboard'
import { getProvidersSummary } from '@/api/endpoints/providers'
import { getProviderKeys } from '@/api/endpoints/keys'
import type { EndpointAPIKey } from '@/api/endpoints/types'
import { useClipboard } from '@/composables/useClipboard'
import { useEscapeKey } from '@/composables/useEscapeKey'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue'
import Separator from '@/components/ui/separator.vue'
import { X, Play, Loader2, ChevronRight, Copy, Check } from 'lucide-vue-next'
import { log } from '@/utils/logger'
interface ProviderOption {
id: string
name: string
}
const props = defineProps<{
isOpen: boolean
requestId: string | null
detail: RequestDetail | null
}>()
const emit = defineEmits<{
close: []
}>()
const selectedProviderId = ref('')
const selectedKeyId = ref('')
const providers = ref<ProviderOption[]>([])
const keys = ref<EndpointAPIKey[]>([])
const loadingKeys = ref(false)
const replaying = ref(false)
const replayResult = ref<ReplayResponse | null>(null)
const replayError = ref<string | null>(null)
const showRequestHeaders = ref(false)
const showResponseHeaders = ref(false)
const requestCopied = ref(false)
const responseCopied = ref(false)
const { copyToClipboard } = useClipboard()
// ---- 请求侧数据 ----
const displayRequestHeaders = computed(() => {
if (!props.detail) return {}
// 优先显示发送给提供商的请求头,否则显示客户端请求头
return props.detail.provider_request_headers || props.detail.request_headers || {}
})
const hasRequestHeaders = computed(() => {
return Object.keys(displayRequestHeaders.value).length > 0
})
const requestHeaderCount = computed(() => {
return Object.keys(displayRequestHeaders.value).length
})
const formattedRequestBody = computed(() => {
if (!props.detail?.request_body) return ''
try {
return JSON.stringify(props.detail.request_body, null, 2)
} catch {
return String(props.detail.request_body)
}
})
// ---- 响应侧数据 ----
const formattedResponseBody = computed(() => {
if (!replayResult.value?.response_body) return ''
try {
return JSON.stringify(replayResult.value.response_body, null, 2)
} catch {
return String(replayResult.value.response_body)
}
})
const responseHeaderCount = computed(() => {
if (!replayResult.value?.response_headers) return 0
return Object.keys(replayResult.value.response_headers).length
})
// ---- 生命周期 ----
watch(() => props.isOpen, async (isOpen) => {
if (isOpen) {
replayResult.value = null
replayError.value = null
selectedProviderId.value = ''
selectedKeyId.value = ''
keys.value = []
showRequestHeaders.value = false
showResponseHeaders.value = false
try {
const summary = await getProvidersSummary()
providers.value = summary
.filter(p => p.is_active && p.active_endpoints > 0)
.map(p => ({ id: p.id, name: p.name }))
} catch (e) {
log.error('Failed to load providers:', e)
}
}
})
async function onProviderChange() {
selectedKeyId.value = ''
keys.value = []
if (!selectedProviderId.value) return
loadingKeys.value = true
try {
const allKeys = await getProviderKeys(selectedProviderId.value)
keys.value = allKeys.filter(k => k.health_score > 0 || allKeys.length <= 3)
if (keys.value.length === 0) keys.value = allKeys
} catch (e) {
log.error('Failed to load keys:', e)
} finally {
loadingKeys.value = false
}
}
async function doReplay() {
if (!props.requestId || replaying.value) return
replaying.value = true
replayError.value = null
replayResult.value = null
try {
const params: Record<string, string> = {}
if (selectedProviderId.value) params.provider_id = selectedProviderId.value
if (selectedKeyId.value) params.api_key_id = selectedKeyId.value
replayResult.value = await dashboardApi.replayRequest(
props.requestId,
Object.keys(params).length > 0 ? params : undefined,
)
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } }; message?: string }
replayError.value = err?.response?.data?.detail || err?.message || '请求失败'
log.error('Replay failed:', e)
} finally {
replaying.value = false
}
}
function copyRequestBody() {
if (!formattedRequestBody.value) return
copyToClipboard(formattedRequestBody.value, false)
requestCopied.value = true
setTimeout(() => { requestCopied.value = false }, 2000)
}
function copyResponseBody() {
if (!replayResult.value) return
copyToClipboard(formattedResponseBody.value, false)
responseCopied.value = true
setTimeout(() => { responseCopied.value = false }, 2000)
}
function handleClose() {
emit('close')
}
useEscapeKey(() => {
if (props.isOpen) handleClose()
}, { disableOnInput: true, once: false })
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -62,6 +62,16 @@
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="回放请求"
:disabled="loading"
@click="openReplayDialog"
>
<Play class="w-4 h-4" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -437,10 +447,6 @@
> >
<Server class="w-4 h-4" /> <Server class="w-4 h-4" />
</button> </button>
<Separator
orientation="vertical"
class="h-4 mx-1"
/>
</template> </template>
<!-- 请求体/响应体专用JSON/对话 视图切换单按钮 --> <!-- 请求体/响应体专用JSON/对话 视图切换单按钮 -->
@@ -463,16 +469,38 @@
class="w-4 h-4" class="w-4 h-4"
/> />
</button> </button>
<Separator
orientation="vertical"
class="h-4 mx-1"
/>
</template> </template>
<!-- cURL 复制仅在请求头/请求体 Tab -->
<template v-if="['request-headers', 'request-body'].includes(activeTab)">
<button
:title="curlCopied ? '已复制 cURL' : '复制 cURL'"
class="p-1.5 rounded transition-colors text-muted-foreground hover:bg-muted"
:disabled="curlCopying"
@click="copyCurlCommand"
>
<Check
v-if="curlCopied"
class="w-4 h-4 text-green-500"
/>
<Terminal
v-else
class="w-4 h-4"
:class="{ 'animate-pulse': curlCopying }"
/>
</button>
</template>
</div>
</div>
<!-- Tab 内容统一容器表头栏 + 内容区融为一体 -->
<div class="content-block rounded-md border overflow-hidden">
<!-- 表头栏操作按钮 -->
<div class="flex items-center justify-end gap-0.5 px-3 py-1 border-b bg-muted/40">
<!-- 展开/收缩 --> <!-- 展开/收缩 -->
<button <button
:title="currentExpandDepth === 0 ? '展开全部' : '收缩全部'" :title="currentExpandDepth === 0 ? '展开全部' : '收缩全部'"
class="p-1.5 rounded transition-colors" class="p-1 rounded transition-colors"
:class="viewMode === 'compare' || (supportsConversationView && contentViewMode === 'conversation') :class="viewMode === 'compare' || (supportsConversationView && contentViewMode === 'conversation')
? 'text-muted-foreground/40 cursor-not-allowed' ? 'text-muted-foreground/40 cursor-not-allowed'
: 'text-muted-foreground hover:bg-muted'" : 'text-muted-foreground hover:bg-muted'"
@@ -481,17 +509,17 @@
> >
<Maximize2 <Maximize2
v-if="currentExpandDepth === 0" v-if="currentExpandDepth === 0"
class="w-4 h-4" class="w-3.5 h-3.5"
/> />
<Minimize2 <Minimize2
v-else v-else
class="w-4 h-4" class="w-3.5 h-3.5"
/> />
</button> </button>
<!-- 复制 --> <!-- 复制 -->
<button <button
:title="copiedStates[activeTab] ? '已复制' : '复制'" :title="copiedStates[activeTab] ? '已复制' : '复制'"
class="p-1.5 rounded transition-colors" class="p-1 rounded transition-colors"
:class="viewMode === 'compare' :class="viewMode === 'compare'
? 'text-muted-foreground/40 cursor-not-allowed' ? 'text-muted-foreground/40 cursor-not-allowed'
: 'text-muted-foreground hover:bg-muted'" : 'text-muted-foreground hover:bg-muted'"
@@ -500,87 +528,86 @@
> >
<Check <Check
v-if="copiedStates[activeTab]" v-if="copiedStates[activeTab]"
class="w-4 h-4 text-green-500" class="w-3.5 h-3.5 text-green-500"
/> />
<Copy <Copy
v-else v-else
class="w-4 h-4" class="w-3.5 h-3.5"
/> />
</button> </button>
</div> </div>
<TabsContent value="request-headers">
<RequestHeadersContent
:detail="detail"
:view-mode="viewMode"
:data-source="dataSource"
:current-header-data="currentHeaderData"
:current-expand-depth="currentExpandDepth"
:has-provider-headers="hasProviderHeaders"
:client-headers-with-diff="clientHeadersWithDiff"
:provider-headers-with-diff="providerHeadersWithDiff"
:header-stats="headerStats"
:is-dark="isDark"
/>
</TabsContent>
<TabsContent value="request-body">
<!-- 对话视图 -->
<ConversationView
v-if="contentViewMode === 'conversation'"
:render-result="requestRenderResult"
empty-message="无请求体信息"
/>
<!-- JSON 视图 -->
<JsonContent
v-else
:data="detail.request_body"
:view-mode="viewMode"
:expand-depth="currentExpandDepth"
:is-dark="isDark"
empty-message="无请求体信息"
/>
</TabsContent>
<TabsContent value="response-headers">
<JsonContent
:data="actualResponseHeaders"
:view-mode="viewMode"
:expand-depth="currentExpandDepth"
:is-dark="isDark"
empty-message="无响应头信息"
/>
</TabsContent>
<TabsContent value="response-body">
<!-- 对话视图 -->
<ConversationView
v-if="contentViewMode === 'conversation'"
:render-result="responseRenderResult"
empty-message="无响应体信息"
/>
<!-- JSON 视图 -->
<JsonContent
v-else
:data="detail.response_body"
:view-mode="viewMode"
:expand-depth="currentExpandDepth"
:is-dark="isDark"
empty-message="无响应体信息"
/>
</TabsContent>
<TabsContent value="metadata">
<JsonContent
:data="detail.metadata"
:view-mode="viewMode"
:expand-depth="currentExpandDepth"
:is-dark="isDark"
empty-message="无元数据信息"
/>
</TabsContent>
</div> </div>
<!-- Tab 内容 -->
<TabsContent value="request-headers">
<RequestHeadersContent
:detail="detail"
:view-mode="viewMode"
:data-source="dataSource"
:current-header-data="currentHeaderData"
:current-expand-depth="currentExpandDepth"
:has-provider-headers="hasProviderHeaders"
:client-headers-with-diff="clientHeadersWithDiff"
:provider-headers-with-diff="providerHeadersWithDiff"
:header-stats="headerStats"
:is-dark="isDark"
/>
</TabsContent>
<TabsContent value="request-body">
<!-- 对话视图 -->
<ConversationView
v-if="contentViewMode === 'conversation'"
:render-result="requestRenderResult"
empty-message="无请求体信息"
/>
<!-- JSON 视图 -->
<JsonContent
v-else
:data="detail.request_body"
:view-mode="viewMode"
:expand-depth="currentExpandDepth"
:is-dark="isDark"
empty-message="无请求体信息"
/>
</TabsContent>
<TabsContent value="response-headers">
<JsonContent
:data="actualResponseHeaders"
:view-mode="viewMode"
:expand-depth="currentExpandDepth"
:is-dark="isDark"
empty-message="无响应头信息"
/>
</TabsContent>
<TabsContent value="response-body">
<!-- 对话视图 -->
<ConversationView
v-if="contentViewMode === 'conversation'"
:render-result="responseRenderResult"
empty-message="无响应体信息"
/>
<!-- JSON 视图 -->
<JsonContent
v-else
:data="detail.response_body"
:view-mode="viewMode"
:expand-depth="currentExpandDepth"
:is-dark="isDark"
empty-message="无响应体信息"
/>
</TabsContent>
<TabsContent value="metadata">
<JsonContent
:data="detail.metadata"
:view-mode="viewMode"
:expand-depth="currentExpandDepth"
:is-dark="isDark"
empty-message="无元数据信息"
/>
</TabsContent>
</Tabs> </Tabs>
</div> </div>
</Card> </Card>
@@ -590,6 +617,14 @@
</div> </div>
</Transition> </Transition>
</Teleport> </Teleport>
<!-- 请求回放对话框 -->
<ReplayDialog
:is-open="replayDialogOpen"
:request-id="requestId"
:detail="detail"
@close="replayDialogOpen = false"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -603,7 +638,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 { Copy, Check, Maximize2, Minimize2, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2 } from 'lucide-vue-next' import { Copy, Check, Maximize2, Minimize2, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
import { dashboardApi, type RequestDetail } from '@/api/dashboard' import { dashboardApi, type RequestDetail } from '@/api/dashboard'
import { API_FORMAT_LABELS } from '@/api/endpoints/types' import { API_FORMAT_LABELS } from '@/api/endpoints/types'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
@@ -613,6 +648,7 @@ import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.v
import JsonContent from './RequestDetailDrawer/JsonContent.vue' import JsonContent from './RequestDetailDrawer/JsonContent.vue'
import ConversationView from './RequestDetailDrawer/ConversationView.vue' import ConversationView from './RequestDetailDrawer/ConversationView.vue'
import HorizontalRequestTimeline from './HorizontalRequestTimeline.vue' import HorizontalRequestTimeline from './HorizontalRequestTimeline.vue'
import ReplayDialog from './ReplayDialog.vue'
// 对话解析器 // 对话解析器
import { import {
@@ -651,6 +687,9 @@ const historicalPricing = ref<{
} | null>(null) } | null>(null)
const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null) const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
const autoRefreshing = ref(false) const autoRefreshing = ref(false)
const curlCopying = ref(false)
const curlCopied = ref(false)
const replayDialogOpen = ref(false)
// 监听标签页切换 // 监听标签页切换
watch(activeTab, (newTab) => { watch(activeTab, (newTab) => {
@@ -1209,6 +1248,29 @@ function collapseAll() {
currentExpandDepth.value = 0 currentExpandDepth.value = 0
} }
// 复制 cURL 命令
async function copyCurlCommand() {
if (!props.requestId || curlCopying.value) return
curlCopying.value = true
try {
const data = await dashboardApi.getCurlData(props.requestId)
if (data.curl) {
copyToClipboard(data.curl, false)
curlCopied.value = true
setTimeout(() => { curlCopied.value = false }, 2000)
}
} catch (err) {
log.error('Failed to generate cURL command:', err)
} finally {
curlCopying.value = false
}
}
// 打开请求回放对话框
function openReplayDialog() {
replayDialogOpen.value = true
}
// 请求头合并对比逻辑 // 请求头合并对比逻辑
interface HeaderEntry { interface HeaderEntry {
key: string key: string
@@ -1348,6 +1410,13 @@ useEscapeKey(() => {
.drawer-leave-from .relative { .drawer-leave-from .relative {
transform: translateX(0); transform: translateX(0);
} }
/* 内容区融合:子组件的 Card 不再需要自己的边框和圆角,与表头栏融为一体 */
.content-block :deep(.rounded-2xl) {
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
}
</style> </style>
<style> <style>

View File

@@ -16,6 +16,7 @@ from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline from src.api.base.pipeline import ApiRequestPipeline
from src.config.constants import CacheTTL from src.config.constants import CacheTTL
from src.config.settings import config from src.config.settings import config
from src.core.logger import logger
from src.database import get_db from src.database import get_db
from src.models.database import ( from src.models.database import (
ApiKey, ApiKey,
@@ -274,8 +275,75 @@ async def get_active_requests(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{usage_id}/curl")
async def get_usage_curl_data(
usage_id: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
获取使用记录的 cURL 命令数据
返回重建 cURL 命令所需的 URL、请求头含明文 API Key和请求体。
**路径参数**:
- `usage_id`: 使用记录 ID
**返回字段**:
- `url`: 提供商请求 URL
- `method`: HTTP 方法
- `headers`: 提供商请求头(含明文 API Key
- `body`: 请求体
- `curl`: 生成的 cURL 命令字符串
"""
adapter = AdminUsageCurlAdapter(usage_id=usage_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/{usage_id}/replay")
async def replay_usage_request(
usage_id: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
回放使用记录请求
将原始请求重新发送到原始或指定的提供商,并返回响应结果。
**路径参数**:
- `usage_id`: 使用记录 ID
**请求体**:
- `provider_id`: 可选,目标提供商 ID不指定则使用原始提供商
- `endpoint_id`: 可选,目标端点 ID不指定则使用原始端点
- `body_override`: 可选,覆盖原始请求体
**返回字段**:
- `url`: 请求 URL
- `status_code`: HTTP 状态码
- `response_headers`: 响应头
- `response_body`: 响应体
- `response_time_ms`: 响应时间(毫秒)
"""
# 从 JSON body 中解析参数
try:
json_body = await request.json()
except Exception:
json_body = {}
adapter = AdminUsageReplayAdapter(
usage_id=usage_id,
target_provider_id=json_body.get("provider_id"),
target_endpoint_id=json_body.get("endpoint_id"),
target_api_key_id=json_body.get("api_key_id"),
body_override=json_body.get("body_override"),
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# NOTE: This route must be defined AFTER all other routes to avoid matching # NOTE: This route must be defined AFTER all other routes to avoid matching
# routes like /stats, /records, /active, etc. # routes like /stats, /records, /active, /curl, /replay, etc.
@router.get("/{usage_id}") @router.get("/{usage_id}")
async def get_usage_detail( async def get_usage_detail(
usage_id: str, usage_id: str,
@@ -1341,6 +1409,426 @@ class AdminUsageDetailAdapter(AdminApiAdapter):
return result return result
# ==================== cURL 导出 & 请求回放 ====================
def _find_usage_record(db: Session, usage_id: str) -> Usage:
"""按 id 或 request_id 查找 Usage 记录,找不到则抛 404。"""
record = db.query(Usage).filter(Usage.id == usage_id).first()
if not record:
record = db.query(Usage).filter(Usage.request_id == usage_id).first()
if not record:
raise HTTPException(status_code=404, detail="Usage record not found")
return record
def _build_provider_url_safe(
endpoint: ProviderEndpoint,
model_name: str | None,
is_stream: bool,
provider_key: ProviderAPIKey | None,
) -> str:
"""构建 Provider URLbuild_provider_url 失败时回退到 base_url + custom_path。"""
from src.services.provider.transport import build_provider_url
try:
return build_provider_url(
endpoint,
path_params={"model": model_name} if model_name else None,
is_stream=is_stream,
key=provider_key,
)
except Exception:
base = (endpoint.base_url or "").rstrip("/")
return f"{base}{endpoint.custom_path}" if endpoint.custom_path else base
def _build_fresh_headers(
auth_headers: dict[str, str],
endpoint: ProviderEndpoint,
) -> dict[str, str]:
"""从零构建请求头Content-Type + 认证头 + endpoint header_rules 额外头。"""
from src.core.api_format.headers import get_extra_headers_from_endpoint
headers: dict[str, str] = {"Content-Type": "application/json"}
headers.update(auth_headers)
extra = get_extra_headers_from_endpoint(endpoint)
if extra:
headers.update(extra)
return headers
async def _resolve_provider_auth(
provider_key: ProviderAPIKey,
endpoint: ProviderEndpoint,
db: Session,
) -> dict[str, str]:
"""解析 Provider Key 的认证信息,返回可直接用于请求的认证头字典。
支持: api_key / oauth / vertex_ai 三种 auth_type。
"""
from src.core.api_format.metadata import get_auth_config_for_endpoint
from src.core.crypto import crypto_service
auth_type = str(getattr(provider_key, "auth_type", "api_key") or "api_key").lower()
auth_headers: dict[str, str] = {}
if auth_type == "oauth":
from src.services.provider.oauth_token import resolve_oauth_access_token
# 获取 Provider 对象以读取 proxy 和 provider_type
provider_obj = db.query(Provider).filter(Provider.id == provider_key.provider_id).first()
provider_type = (
str(getattr(provider_obj, "provider_type", "") or "").lower() if provider_obj else ""
)
# Antigravity 使用 gemini:chat 端点格式
ep_format = str(getattr(endpoint, "api_format", "") or "")
if provider_type == "antigravity" and not ep_format:
ep_format = "gemini:chat"
resolved = await resolve_oauth_access_token(
key_id=str(provider_key.id),
encrypted_api_key=str(provider_key.api_key or ""),
encrypted_auth_config=(
str(provider_key.auth_config)
if getattr(provider_key, "auth_config", None) is not None
else None
),
provider_proxy_config=getattr(provider_obj, "proxy", None) if provider_obj else None,
endpoint_api_format=ep_format,
)
access_token = resolved.access_token or ""
auth_headers["Authorization"] = f"Bearer {access_token}"
# Codex 等需要 account_id
if resolved.decrypted_auth_config:
account_id = resolved.decrypted_auth_config.get("account_id")
if account_id:
auth_headers["chatgpt-account-id"] = str(account_id)
elif auth_type == "vertex_ai":
from src.api.handlers.base.request_builder import get_provider_auth
auth_info = await get_provider_auth(endpoint, provider_key)
if auth_info:
auth_headers[auth_info.auth_header] = auth_info.auth_value
else:
# 回退
decrypted_key = crypto_service.decrypt(provider_key.api_key)
auth_headers["Authorization"] = f"Bearer {decrypted_key}"
else:
# 标准 API Key
decrypted_key = crypto_service.decrypt(provider_key.api_key)
# 根据 endpoint signature 确定认证头名称和类型
api_family = str(getattr(endpoint, "api_family", "") or "").lower()
api_kind = str(getattr(endpoint, "endpoint_kind", "") or "").lower()
if api_family and api_kind:
endpoint_sig = f"{api_family}:{api_kind}"
else:
endpoint_sig = str(getattr(endpoint, "api_format", "") or "") or "openai:chat"
auth_header, auth_type_cfg = get_auth_config_for_endpoint(endpoint_sig)
auth_value = f"Bearer {decrypted_key}" if auth_type_cfg == "bearer" else decrypted_key
auth_headers[auth_header] = auth_value
return auth_headers
@dataclass
class AdminUsageCurlAdapter(AdminApiAdapter):
"""Generate cURL command data from a usage record."""
usage_id: str
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
import json as _json
import shlex
db = context.db
usage_record = _find_usage_record(db, self.usage_id)
# 获取端点和密钥
endpoint = None
if usage_record.provider_endpoint_id:
endpoint = (
db.query(ProviderEndpoint)
.filter(ProviderEndpoint.id == usage_record.provider_endpoint_id)
.first()
)
provider_key = None
if usage_record.provider_api_key_id:
provider_key = (
db.query(ProviderAPIKey)
.filter(ProviderAPIKey.id == usage_record.provider_api_key_id)
.first()
)
# 重建请求 URL
url: str | None = None
if endpoint:
model_name = usage_record.target_model or usage_record.model
url = _build_provider_url_safe(
endpoint, model_name, usage_record.is_stream or False, provider_key
)
# 解析认证信息并构建请求头
stored_headers = usage_record.provider_request_headers or {}
headers: dict[str, str] = {}
if provider_key and endpoint:
try:
auth_headers = await _resolve_provider_auth(provider_key, endpoint, db)
if stored_headers:
# 有存储的请求头:替换被脱敏的认证头为真实值
headers = dict(stored_headers)
auth_lower_keys = {k.lower() for k in auth_headers}
for key_name in list(headers.keys()):
if key_name.lower() in auth_lower_keys:
del headers[key_name]
headers.update(auth_headers)
else:
headers = _build_fresh_headers(auth_headers, endpoint)
except Exception:
headers = dict(stored_headers)
else:
headers = dict(stored_headers)
# 确保始终有 Content-Type
if not any(k.lower() == "content-type" for k in headers):
headers["Content-Type"] = "application/json"
# 获取请求体
body = usage_record.get_request_body()
# 生成 cURL 命令
curl_parts = ["curl"]
if url:
curl_parts.append(shlex.quote(url))
curl_parts.append("-X POST")
for h_key, h_value in headers.items():
curl_parts.append(f"-H {shlex.quote(f'{h_key}: {h_value}')}")
if body:
body_str = _json.dumps(body, ensure_ascii=False)
curl_parts.append(f"-d {shlex.quote(body_str)}")
curl_command = " \\\n ".join(curl_parts)
context.add_audit_metadata(
action="usage_curl",
usage_id=self.usage_id,
)
return {
"url": url,
"method": "POST",
"headers": headers,
"body": body,
"curl": curl_command,
}
@dataclass
class AdminUsageReplayAdapter(AdminApiAdapter):
"""Replay a usage record request to the same or a different provider."""
usage_id: str
target_provider_id: str | None = None
target_endpoint_id: str | None = None
target_api_key_id: str | None = None
body_override: dict | None = None
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
import time
import httpx
db = context.db
usage_record = _find_usage_record(db, self.usage_id)
# 确定目标端点和密钥
target_pid = self.target_provider_id
if self.target_endpoint_id:
endpoint = (
db.query(ProviderEndpoint)
.filter(ProviderEndpoint.id == self.target_endpoint_id)
.first()
)
if not endpoint:
raise HTTPException(status_code=404, detail="Target endpoint not found")
target_pid = str(endpoint.provider_id)
elif target_pid:
target_provider = db.query(Provider).filter(Provider.id == target_pid).first()
if not target_provider:
raise HTTPException(status_code=404, detail="Target provider not found")
endpoint = (
db.query(ProviderEndpoint)
.filter(
ProviderEndpoint.provider_id == target_pid,
ProviderEndpoint.is_active == True, # noqa: E712
)
.first()
)
if not endpoint:
raise HTTPException(
status_code=404, detail="No active endpoint found for target provider"
)
else:
endpoint = None
if usage_record.provider_endpoint_id:
endpoint = (
db.query(ProviderEndpoint)
.filter(ProviderEndpoint.id == usage_record.provider_endpoint_id)
.first()
)
if not endpoint:
raise HTTPException(
status_code=404,
detail="Original endpoint not found, specify target_endpoint_id",
)
# 确定 API Key
provider_key = None
if self.target_api_key_id:
provider_key = (
db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.target_api_key_id).first()
)
elif target_pid:
provider_key = (
db.query(ProviderAPIKey)
.filter(
ProviderAPIKey.provider_id == target_pid,
ProviderAPIKey.is_active == True, # noqa: E712
)
.first()
)
else:
if usage_record.provider_api_key_id:
provider_key = (
db.query(ProviderAPIKey)
.filter(ProviderAPIKey.id == usage_record.provider_api_key_id)
.first()
)
if not provider_key:
raise HTTPException(status_code=404, detail="No API key available for replay")
# 根据 auth_type 正确解析认证(支持 OAuth / Vertex AI / API Key
try:
auth_headers = await _resolve_provider_auth(provider_key, endpoint, db)
except Exception as e:
logger.error("[replay] Failed to resolve auth for key {}: {}", provider_key.id, e)
raise HTTPException(status_code=500, detail="Failed to resolve provider authentication")
# 构建 URL
model_name = usage_record.target_model or usage_record.model
url = _build_provider_url_safe(endpoint, model_name, False, provider_key)
# 构建请求头Content-Type + 认证头 + 端点额外头)
headers = _build_fresh_headers(auth_headers, endpoint)
# 使用覆盖体或原始请求体
body = self.body_override or usage_record.get_request_body() or {}
# 格式转换:如果存储的请求体格式与目标端点格式不同,需要转换
if isinstance(body, dict):
# 确定存储体的格式(发送给原 Provider 的格式)
stored_format = (
(usage_record.endpoint_api_format or usage_record.api_format or "").strip().lower()
)
target_format = str(getattr(endpoint, "api_format", "") or "").strip().lower()
if stored_format and target_format and stored_format != target_format:
try:
from src.core.api_format.conversion import format_conversion_registry
body = format_conversion_registry.convert_request(
body,
source_format=stored_format,
target_format=target_format,
)
except Exception as conv_err:
logger.warning(
"[replay] Format conversion {} -> {} failed: {}",
stored_format,
target_format,
conv_err,
)
# 转换失败仍发送原始体,让用户看到上游的实际报错
# 强制非流式以获取完整响应
# Gemini 格式通过 URL 控制流式streamGenerateContent vs generateContent
# 不支持 body 中的 stream 字段,设置会导致 400 错误
if isinstance(body, dict):
target_family = str(getattr(endpoint, "api_family", "") or "").lower()
if target_family == "gemini":
body.pop("stream", None)
else:
body["stream"] = False
# 获取提供商名称
provider_name = usage_record.provider_name
if self.target_provider_id:
target_p = db.query(Provider).filter(Provider.id == self.target_provider_id).first()
if target_p:
provider_name = target_p.name
elif endpoint and endpoint.provider_id:
p = db.query(Provider).filter(Provider.id == endpoint.provider_id).first()
if p:
provider_name = p.name
# 发送请求
try:
from src.utils.ssl_utils import get_ssl_context
start_time = time.monotonic()
async with httpx.AsyncClient(
timeout=60.0,
verify=get_ssl_context(),
) as client:
response = await client.post(
url,
headers=headers,
json=body,
)
elapsed_ms = int((time.monotonic() - start_time) * 1000)
# 解析响应体
try:
response_body = response.json()
except Exception:
response_body = {"raw": response.text[:10000]}
response_headers = dict(response.headers)
except httpx.TimeoutException:
raise HTTPException(status_code=504, detail="Request to provider timed out")
except Exception as e:
logger.error("[replay] Failed to connect to provider at {}: {}", url, e)
raise HTTPException(status_code=502, detail="Failed to connect to provider")
context.add_audit_metadata(
action="usage_replay",
usage_id=self.usage_id,
target_provider=provider_name,
target_url=url,
)
return {
"url": url,
"provider": provider_name,
"status_code": response.status_code,
"response_headers": response_headers,
"response_body": response_body,
"response_time_ms": elapsed_ms,
}
# ==================== 缓存亲和性分析 ==================== # ==================== 缓存亲和性分析 ====================