mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
475
frontend/src/features/usage/components/ReplayDialog.vue
Normal file
475
frontend/src/features/usage/components/ReplayDialog.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 URL,build_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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ==================== 缓存亲和性分析 ====================
|
# ==================== 缓存亲和性分析 ====================
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user