mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: 优化用量状态同步机制和 OpenAI CLI 流式转换
- 增加状态回退保护,防止异步响应覆盖已知状态 - 添加轮询并发保护 (pollInFlight),避免重复请求 - 支持 cancelled 状态筛选和显示 - 前端 mergeRecordStatus 保护活跃记录状态 - 后端 streaming 状态同步更新改为使用当前 DB 会话 - OpenAI CLI 流式转换增加工具调用事件支持 - 轮询接口新增 target_model 字段返回 - 修复迁移脚本 inspector 缓存问题,改用 information_schema
This commit is contained in:
@@ -213,6 +213,7 @@ export const usageApi = {
|
||||
api_format?: string | null
|
||||
endpoint_api_format?: string | null
|
||||
has_format_conversion?: boolean | null
|
||||
target_model?: string | null
|
||||
}>
|
||||
}> {
|
||||
const params = ids?.length ? { ids: ids.join(',') } : {}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="px-3 py-2 border-b">
|
||||
<Card class="overflow-hidden flex flex-col">
|
||||
<div class="px-3 py-2 border-b flex-shrink-0">
|
||||
<h3 class="text-sm font-medium">
|
||||
按API格式分析
|
||||
</h3>
|
||||
</div>
|
||||
<Table class="text-sm">
|
||||
<div class="overflow-auto max-h-[320px]">
|
||||
<Table class="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">
|
||||
@@ -64,6 +65,7 @@
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="px-3 py-2 border-b">
|
||||
<Card class="overflow-hidden flex flex-col">
|
||||
<div class="px-3 py-2 border-b flex-shrink-0">
|
||||
<h3 class="text-sm font-medium">
|
||||
按模型分析
|
||||
</h3>
|
||||
</div>
|
||||
<Table class="text-sm">
|
||||
<div class="overflow-auto max-h-[320px]">
|
||||
<Table class="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">
|
||||
@@ -64,6 +65,7 @@
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="px-3 py-2 border-b">
|
||||
<Card class="overflow-hidden flex flex-col">
|
||||
<div class="px-3 py-2 border-b flex-shrink-0">
|
||||
<h3 class="text-sm font-medium">
|
||||
按提供商分析
|
||||
</h3>
|
||||
</div>
|
||||
<Table class="text-sm">
|
||||
<div class="overflow-auto max-h-[320px]">
|
||||
<Table class="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="h-8 px-2">
|
||||
@@ -70,6 +71,7 @@
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -134,18 +134,15 @@
|
||||
<SelectItem value="standard">
|
||||
标准
|
||||
</SelectItem>
|
||||
<SelectItem value="pending">
|
||||
等待中
|
||||
</SelectItem>
|
||||
<SelectItem value="streaming">
|
||||
传输中
|
||||
</SelectItem>
|
||||
<SelectItem value="completed">
|
||||
完成
|
||||
<SelectItem value="active">
|
||||
活跃
|
||||
</SelectItem>
|
||||
<SelectItem value="failed">
|
||||
失败
|
||||
</SelectItem>
|
||||
<SelectItem value="cancelled">
|
||||
已取消
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -415,6 +412,13 @@
|
||||
>
|
||||
失败
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="record.status === 'cancelled'"
|
||||
variant="outline"
|
||||
class="whitespace-nowrap border-amber-500/50 text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
已取消
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="record.is_stream"
|
||||
variant="secondary"
|
||||
@@ -732,7 +736,8 @@ function getApiFormatTooltip(record: UsageRecord): string {
|
||||
|
||||
// 如果发生了格式转换或同族格式差异,显示详细信息
|
||||
if (shouldShowFormatConversion(record)) {
|
||||
const endpointDisplayFormat = formatApiFormat(record.endpoint_api_format!)
|
||||
const endpointApiFormat = record.endpoint_api_format ?? record.api_format
|
||||
const endpointDisplayFormat = formatApiFormat(endpointApiFormat)
|
||||
const conversionType = record.has_format_conversion ? '格式转换' : '格式兼容(无需转换)'
|
||||
return `用户请求格式: ${displayFormat}\n端点原生格式: ${endpointDisplayFormat}\n${conversionType}`
|
||||
}
|
||||
|
||||
@@ -164,7 +164,9 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
}))
|
||||
|
||||
// 用户页面:记录直接从 userData 获取(数量较少)
|
||||
currentRecords.value = (userData.records || []) as UsageRecord[]
|
||||
// 使用 mergeRecordStatus 保护已有的活跃状态,避免轮询更新被覆盖
|
||||
const nextRecords = (userData.records || []) as UsageRecord[]
|
||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||
totalRecords.value = currentRecords.value.length
|
||||
|
||||
// 从记录中提取筛选选项和 API 格式统计
|
||||
@@ -272,12 +274,14 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
}
|
||||
|
||||
const response = await usageApi.getAllUsageRecords(params)
|
||||
currentRecords.value = (response.records || []) as UsageRecord[]
|
||||
const nextRecords = (response.records || []) as UsageRecord[]
|
||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||
totalRecords.value = response.total || 0
|
||||
} else {
|
||||
// 用户页面:使用用户 API
|
||||
const userData = await meApi.getUsage(params)
|
||||
currentRecords.value = (userData.records || []) as UsageRecord[]
|
||||
const nextRecords = (userData.records || []) as UsageRecord[]
|
||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||
totalRecords.value = userData.pagination?.total || currentRecords.value.length
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -289,6 +293,79 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function mergeRecordStatus(
|
||||
current: UsageRecord[],
|
||||
next: UsageRecord[]
|
||||
): UsageRecord[] {
|
||||
if (!current.length) return next
|
||||
const statusPriority: Record<string, number> = {
|
||||
pending: 0,
|
||||
streaming: 1,
|
||||
completed: 2,
|
||||
failed: 2,
|
||||
cancelled: 2
|
||||
}
|
||||
const currentById = new Map<string, UsageRecord>(
|
||||
current.map(record => [record.id, record])
|
||||
)
|
||||
return next.map(record => {
|
||||
const existing = currentById.get(record.id)
|
||||
if (!existing) return record
|
||||
|
||||
// 确定是否需要保护 status(避免刷新把已知状态覆盖为 undefined 或回退)
|
||||
const hasExistingStatus = typeof existing.status === 'string' && existing.status.length > 0
|
||||
const hasNextStatus = typeof record.status === 'string' && record.status.length > 0
|
||||
const currentRank = hasExistingStatus ? (statusPriority[existing.status] ?? -1) : -1
|
||||
const nextRank = hasNextStatus ? (statusPriority[record.status] ?? -1) : -1
|
||||
const statusProgressed = hasNextStatus && (
|
||||
!hasExistingStatus ||
|
||||
nextRank > currentRank ||
|
||||
(nextRank === currentRank && existing.status === record.status)
|
||||
)
|
||||
const mergedStatus = statusProgressed ? record.status : existing.status
|
||||
const protectStatus = mergedStatus !== record.status
|
||||
|
||||
// 确定是否需要保护 provider(避免 pending/unknown 覆盖已有的正确值)
|
||||
const isPendingProvider = !record.provider || record.provider === 'pending' || record.provider === 'unknown'
|
||||
const hasValidExistingProvider = existing.provider && existing.provider !== 'pending' && existing.provider !== 'unknown'
|
||||
const protectProvider = isPendingProvider && hasValidExistingProvider
|
||||
|
||||
// 如果需要保护状态,说明本地数据比后端更新,应该保留本地的所有实时更新字段
|
||||
if (protectStatus) {
|
||||
return {
|
||||
...record,
|
||||
// 保留本地的状态和所有通过轮询更新的字段
|
||||
status: mergedStatus,
|
||||
provider: protectProvider ? existing.provider : (record.provider || existing.provider),
|
||||
input_tokens: existing.input_tokens || record.input_tokens,
|
||||
output_tokens: existing.output_tokens || record.output_tokens,
|
||||
cache_creation_input_tokens: existing.cache_creation_input_tokens ?? record.cache_creation_input_tokens,
|
||||
cache_read_input_tokens: existing.cache_read_input_tokens ?? record.cache_read_input_tokens,
|
||||
cost: existing.cost || record.cost,
|
||||
actual_cost: existing.actual_cost ?? record.actual_cost,
|
||||
response_time_ms: existing.response_time_ms ?? record.response_time_ms,
|
||||
first_byte_time_ms: existing.first_byte_time_ms ?? record.first_byte_time_ms,
|
||||
api_format: existing.api_format || record.api_format,
|
||||
endpoint_api_format: existing.endpoint_api_format || record.endpoint_api_format,
|
||||
has_format_conversion: existing.has_format_conversion ?? record.has_format_conversion,
|
||||
api_key_name: existing.api_key_name || record.api_key_name,
|
||||
rate_multiplier: existing.rate_multiplier ?? record.rate_multiplier,
|
||||
target_model: existing.target_model || record.target_model
|
||||
}
|
||||
}
|
||||
|
||||
// 只需要保护 provider
|
||||
if (protectProvider) {
|
||||
return {
|
||||
...record,
|
||||
provider: existing.provider
|
||||
}
|
||||
}
|
||||
|
||||
return record
|
||||
})
|
||||
}
|
||||
|
||||
// 刷新所有数据
|
||||
async function refreshData(dateRange?: DateRangeParams) {
|
||||
await loadStats(dateRange)
|
||||
|
||||
@@ -70,10 +70,18 @@ export function useUsageFilters(options: UseUsageFiltersOptions) {
|
||||
records = records.filter(record =>
|
||||
!record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
|
||||
)
|
||||
} else if (filterStatus.value === 'error') {
|
||||
} else if (filterStatus.value === 'active') {
|
||||
records = records.filter(record =>
|
||||
record.error_message || (record.status_code && record.status_code >= 400)
|
||||
record.status === 'pending' || record.status === 'streaming'
|
||||
)
|
||||
} else if (filterStatus.value === 'failed') {
|
||||
records = records.filter(record =>
|
||||
record.status === 'failed' ||
|
||||
(record.status_code && record.status_code >= 400) ||
|
||||
record.error_message
|
||||
)
|
||||
} else if (filterStatus.value === 'cancelled') {
|
||||
records = records.filter(record => record.status === 'cancelled')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
readonly displayName = 'OpenAI'
|
||||
|
||||
/**
|
||||
* 检测是否为 OpenAI 格式
|
||||
* 检测是否为 OpenAI 格式(包括 Chat Completions 和 CLI/Responses API)
|
||||
*/
|
||||
detect(requestBody: any, responseBody: any, hint?: string): number {
|
||||
// 1. 后端提示优先
|
||||
@@ -52,7 +52,12 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
if (model.includes('gpt') || model.includes('o1') || model.includes('o3')) return 95
|
||||
|
||||
// 3. 检查请求体结构
|
||||
if (!requestBody?.messages || !Array.isArray(requestBody.messages)) {
|
||||
// OpenAI CLI (Responses API) 使用 input 字段
|
||||
const isCliFormat = requestBody?.input !== undefined || requestBody?.instructions !== undefined
|
||||
// OpenAI Chat Completions 使用 messages 数组
|
||||
const isChatFormat = requestBody?.messages && Array.isArray(requestBody.messages)
|
||||
|
||||
if (!isCliFormat && !isChatFormat) {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -62,7 +67,11 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
: responseBody
|
||||
|
||||
if (respBody) {
|
||||
// OpenAI 响应特征: choices 数组
|
||||
// OpenAI CLI 响应特征: type 字段为 response.* 格式
|
||||
if (this.isCliResponseEvent(respBody)) {
|
||||
return 95
|
||||
}
|
||||
// OpenAI Chat Completions 响应特征: choices 数组
|
||||
if (respBody.choices || respBody.object?.includes('chat.completion')) {
|
||||
return 90
|
||||
}
|
||||
@@ -73,6 +82,10 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
}
|
||||
|
||||
// 5. 检查 OpenAI 特有的请求结构
|
||||
if (isCliFormat) {
|
||||
return 80
|
||||
}
|
||||
|
||||
// OpenAI 的 system 是在 messages 数组中作为 role: system
|
||||
const hasSystemInMessages = requestBody.messages?.some(
|
||||
(m: any) => m.role === 'system'
|
||||
@@ -85,13 +98,36 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析请求体
|
||||
* 检查是否为 OpenAI CLI (Responses API) 的响应事件
|
||||
*/
|
||||
private isCliResponseEvent(chunk: any): boolean {
|
||||
const type = chunk?.type
|
||||
if (typeof type !== 'string') return false
|
||||
return type.startsWith('response.') || chunk?.object === 'response'
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析请求体(支持 Chat Completions 和 CLI/Responses API 格式)
|
||||
*/
|
||||
parseRequest(requestBody: any): ParsedConversation {
|
||||
if (!requestBody) {
|
||||
return createEmptyConversation('openai', '无请求体')
|
||||
}
|
||||
|
||||
// 检测是否为 CLI 格式
|
||||
const isCliFormat = requestBody.input !== undefined || requestBody.instructions !== undefined
|
||||
|
||||
if (isCliFormat) {
|
||||
return this.parseCliRequest(requestBody)
|
||||
}
|
||||
|
||||
return this.parseChatRequest(requestBody)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 OpenAI Chat Completions 请求
|
||||
*/
|
||||
private parseChatRequest(requestBody: any): ParsedConversation {
|
||||
try {
|
||||
const result: ParsedConversation = {
|
||||
messages: [],
|
||||
@@ -127,13 +163,128 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析响应体
|
||||
* 解析 OpenAI CLI (Responses API) 请求
|
||||
*
|
||||
* CLI 格式特点:
|
||||
* - 使用 input 字段(可以是字符串、消息数组或对象)
|
||||
* - 使用 instructions 字段作为系统指令
|
||||
*/
|
||||
private parseCliRequest(requestBody: any): ParsedConversation {
|
||||
try {
|
||||
const result: ParsedConversation = {
|
||||
messages: [],
|
||||
isStream: requestBody.stream === true,
|
||||
apiFormat: 'openai',
|
||||
model: requestBody.model,
|
||||
}
|
||||
|
||||
// 处理 instructions(系统指令)
|
||||
if (requestBody.instructions) {
|
||||
result.system = requestBody.instructions
|
||||
}
|
||||
|
||||
// 处理 input
|
||||
const input = requestBody.input
|
||||
|
||||
if (typeof input === 'string') {
|
||||
// 简单字符串输入
|
||||
result.messages.push(createMessage('user', [createTextBlock(input)]))
|
||||
} else if (Array.isArray(input)) {
|
||||
// 消息数组
|
||||
for (const item of input) {
|
||||
const parsedMsg = this.parseCliInputItem(item)
|
||||
if (parsedMsg) {
|
||||
result.messages.push(parsedMsg)
|
||||
}
|
||||
}
|
||||
} else if (input?.messages && Array.isArray(input.messages)) {
|
||||
// 包装在对象中的消息数组
|
||||
for (const item of input.messages) {
|
||||
const parsedMsg = this.parseCliInputItem(item)
|
||||
if (parsedMsg) {
|
||||
result.messages.push(parsedMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (e) {
|
||||
return createEmptyConversation('openai', `CLI 格式解析失败: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 CLI 格式的单个输入项
|
||||
*/
|
||||
private parseCliInputItem(item: any): ParsedMessage | null {
|
||||
if (!item) return null
|
||||
|
||||
const itemType = item.type
|
||||
|
||||
// 标准消息(有 role 字段)
|
||||
if (itemType === 'message' || item.role) {
|
||||
const role = this.mapRole(item.role)
|
||||
const contentBlocks: ContentBlock[] = []
|
||||
|
||||
const content = item.content
|
||||
if (typeof content === 'string') {
|
||||
contentBlocks.push(createTextBlock(content))
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
if (part.type === 'input_text' || part.type === 'output_text' || part.type === 'text') {
|
||||
contentBlocks.push(createTextBlock(part.text || ''))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentBlocks.length === 0) return null
|
||||
return createMessage(role, contentBlocks)
|
||||
}
|
||||
|
||||
// function_call -> 工具调用
|
||||
if (itemType === 'function_call') {
|
||||
const toolId = item.call_id || item.id || ''
|
||||
const toolName = item.name || ''
|
||||
const args = item.arguments || '{}'
|
||||
return createMessage('assistant', [createToolUseBlock(toolId, toolName, args)])
|
||||
}
|
||||
|
||||
// function_call_output -> 工具结果
|
||||
if (itemType === 'function_call_output') {
|
||||
const toolUseId = item.call_id || item.id || ''
|
||||
const output = typeof item.output === 'string'
|
||||
? item.output
|
||||
: JSON.stringify(item.output, null, 2)
|
||||
return createMessage('tool', [createToolResultBlock(toolUseId, output)])
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析响应体(支持 Chat Completions 和 CLI/Responses API 格式)
|
||||
*/
|
||||
parseResponse(responseBody: any): ParsedConversation {
|
||||
if (!responseBody) {
|
||||
return createEmptyConversation('openai', '无响应体')
|
||||
}
|
||||
|
||||
// 检测是否为 CLI 格式
|
||||
const isCliFormat = this.isCliResponseEvent(responseBody) ||
|
||||
responseBody.object === 'response' ||
|
||||
responseBody.output !== undefined
|
||||
|
||||
if (isCliFormat) {
|
||||
return this.parseCliResponse(responseBody)
|
||||
}
|
||||
|
||||
return this.parseChatResponse(responseBody)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 OpenAI Chat Completions 响应
|
||||
*/
|
||||
private parseChatResponse(responseBody: any): ParsedConversation {
|
||||
try {
|
||||
const result: ParsedConversation = {
|
||||
messages: [],
|
||||
@@ -175,13 +326,70 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析流式响应
|
||||
* 解析 OpenAI CLI (Responses API) 响应
|
||||
*
|
||||
* CLI 响应格式: { output: [{ type: "message", content: [...] }] }
|
||||
*/
|
||||
private parseCliResponse(responseBody: any): ParsedConversation {
|
||||
try {
|
||||
const result: ParsedConversation = {
|
||||
messages: [],
|
||||
isStream: false,
|
||||
apiFormat: 'openai',
|
||||
model: responseBody.model,
|
||||
}
|
||||
|
||||
const output = responseBody.output
|
||||
if (!Array.isArray(output)) {
|
||||
return result
|
||||
}
|
||||
|
||||
for (const item of output) {
|
||||
if (item?.type === 'message') {
|
||||
const contentBlocks: ContentBlock[] = []
|
||||
|
||||
if (Array.isArray(item.content)) {
|
||||
for (const content of item.content) {
|
||||
if (content?.type === 'output_text' && content?.text) {
|
||||
contentBlocks.push(createTextBlock(content.text))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentBlocks.length > 0) {
|
||||
result.messages.push(createMessage('assistant', contentBlocks))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (e) {
|
||||
return createEmptyConversation('openai', `CLI 格式解析失败: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析流式响应(支持 Chat Completions 和 CLI/Responses API 格式)
|
||||
*/
|
||||
parseStreamResponse(chunks: any[]): ParsedConversation {
|
||||
if (!chunks || chunks.length === 0) {
|
||||
return createEmptyConversation('openai', '无响应数据')
|
||||
}
|
||||
|
||||
// 检测是否为 CLI 格式
|
||||
const isCliFormat = chunks.some(chunk => this.isCliResponseEvent(chunk))
|
||||
|
||||
if (isCliFormat) {
|
||||
return this.parseCliStreamResponse(chunks)
|
||||
}
|
||||
|
||||
return this.parseChatStreamResponse(chunks)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 OpenAI Chat Completions 流式响应
|
||||
*/
|
||||
private parseChatStreamResponse(chunks: any[]): ParsedConversation {
|
||||
try {
|
||||
const result: ParsedConversation = {
|
||||
messages: [],
|
||||
@@ -252,6 +460,126 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 OpenAI CLI (Responses API) 流式响应
|
||||
*
|
||||
* 支持的事件类型:
|
||||
* - response.created: 响应创建
|
||||
* - response.output_text.delta: 文本增量
|
||||
* - response.completed: 响应完成(包含完整响应和 usage)
|
||||
* - response.function_call_arguments.delta: 函数调用参数增量
|
||||
*/
|
||||
private parseCliStreamResponse(chunks: any[]): ParsedConversation {
|
||||
try {
|
||||
const result: ParsedConversation = {
|
||||
messages: [],
|
||||
isStream: true,
|
||||
apiFormat: 'openai',
|
||||
}
|
||||
|
||||
const textParts: string[] = []
|
||||
const toolCalls = new Map<string, { name: string; id: string; args: string[] }>()
|
||||
let currentToolId = ''
|
||||
let currentToolName = ''
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const eventType = chunk.type
|
||||
|
||||
// 从 response.created 或 response.completed 提取模型名
|
||||
if (!result.model) {
|
||||
const response = chunk.response
|
||||
if (response?.model) {
|
||||
result.model = response.model
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文本增量: response.output_text.delta
|
||||
if (eventType === 'response.output_text.delta') {
|
||||
const delta = chunk.delta
|
||||
if (typeof delta === 'string') {
|
||||
textParts.push(delta)
|
||||
} else if (delta?.text) {
|
||||
textParts.push(delta.text)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理函数调用输出项添加: response.output_item.added
|
||||
if (eventType === 'response.output_item.added') {
|
||||
const item = chunk.item
|
||||
if (item?.type === 'function_call') {
|
||||
currentToolId = item.call_id || item.id || ''
|
||||
currentToolName = item.name || ''
|
||||
if (currentToolId && !toolCalls.has(currentToolId)) {
|
||||
toolCalls.set(currentToolId, {
|
||||
name: currentToolName,
|
||||
id: currentToolId,
|
||||
args: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理函数调用参数增量: response.function_call_arguments.delta
|
||||
if (eventType === 'response.function_call_arguments.delta') {
|
||||
const delta = chunk.delta
|
||||
if (delta && currentToolId && toolCalls.has(currentToolId)) {
|
||||
toolCalls.get(currentToolId)!.args.push(delta)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理完成事件: response.completed
|
||||
// 如果之前没有收集到文本,从完成事件中提取
|
||||
if (eventType === 'response.completed') {
|
||||
const response = chunk.response
|
||||
if (response?.model && !result.model) {
|
||||
result.model = response.model
|
||||
}
|
||||
|
||||
// 从 output 中提取文本(备用方案)
|
||||
if (textParts.length === 0 && response?.output) {
|
||||
for (const item of response.output) {
|
||||
if (item?.type === 'message' && item?.content) {
|
||||
for (const content of item.content) {
|
||||
if (content?.type === 'output_text' && content?.text) {
|
||||
textParts.push(content.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const contentBlocks: ContentBlock[] = []
|
||||
|
||||
// 文本内容
|
||||
if (textParts.length > 0) {
|
||||
contentBlocks.push(createTextBlock(textParts.join('')))
|
||||
}
|
||||
|
||||
// 工具调用
|
||||
for (const [, call] of toolCalls) {
|
||||
contentBlocks.push(createToolUseBlock(
|
||||
call.id,
|
||||
call.name,
|
||||
call.args.join('')
|
||||
))
|
||||
}
|
||||
|
||||
if (contentBlocks.length > 0) {
|
||||
result.messages.push(createMessage('assistant', contentBlocks))
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (e) {
|
||||
return createEmptyConversation('openai', `CLI 格式解析失败: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单条消息
|
||||
*/
|
||||
@@ -328,13 +656,27 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 渲染请求体
|
||||
* 渲染请求体(支持 Chat Completions 和 CLI/Responses API 格式)
|
||||
*/
|
||||
renderRequest(requestBody: any): RenderResult {
|
||||
if (!requestBody) {
|
||||
return createEmptyRenderResult('无请求体')
|
||||
}
|
||||
|
||||
// 检测是否为 CLI 格式
|
||||
const isCliFormat = requestBody.input !== undefined || requestBody.instructions !== undefined
|
||||
|
||||
if (isCliFormat) {
|
||||
return this.renderCliRequest(requestBody)
|
||||
}
|
||||
|
||||
return this.renderChatRequest(requestBody)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 OpenAI Chat Completions 请求
|
||||
*/
|
||||
private renderChatRequest(requestBody: any): RenderResult {
|
||||
try {
|
||||
const blocks: RenderBlock[] = []
|
||||
const isStream = requestBody.stream === true
|
||||
@@ -366,7 +708,104 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染响应体
|
||||
* 渲染 OpenAI CLI (Responses API) 请求
|
||||
*/
|
||||
private renderCliRequest(requestBody: any): RenderResult {
|
||||
try {
|
||||
const blocks: RenderBlock[] = []
|
||||
const isStream = requestBody.stream === true
|
||||
|
||||
// 渲染 instructions(系统指令)
|
||||
if (requestBody.instructions) {
|
||||
blocks.push(createMessageBlock('system', [
|
||||
createTextRenderBlock(requestBody.instructions),
|
||||
], { roleLabel: 'Instructions' }))
|
||||
}
|
||||
|
||||
// 渲染 input
|
||||
const input = requestBody.input
|
||||
|
||||
if (typeof input === 'string') {
|
||||
// 简单字符串输入
|
||||
blocks.push(createMessageBlock('user', [
|
||||
createTextRenderBlock(input),
|
||||
], { roleLabel: 'User' }))
|
||||
} else if (Array.isArray(input)) {
|
||||
// 消息数组
|
||||
for (const item of input) {
|
||||
const msgBlock = this.renderCliInputItem(item)
|
||||
if (msgBlock) {
|
||||
blocks.push(msgBlock)
|
||||
}
|
||||
}
|
||||
} else if (input?.messages && Array.isArray(input.messages)) {
|
||||
// 包装在对象中的消息数组
|
||||
for (const item of input.messages) {
|
||||
const msgBlock = this.renderCliInputItem(item)
|
||||
if (msgBlock) {
|
||||
blocks.push(msgBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { blocks, isStream }
|
||||
} catch (e) {
|
||||
return createEmptyRenderResult(`CLI 格式渲染失败: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 CLI 格式的单个输入项
|
||||
*/
|
||||
private renderCliInputItem(item: any): RenderBlock | null {
|
||||
if (!item) return null
|
||||
|
||||
const itemType = item.type
|
||||
|
||||
// 标准消息
|
||||
if (itemType === 'message' || item.role) {
|
||||
const role = this.mapRole(item.role)
|
||||
const contentBlocks: RenderBlock[] = []
|
||||
|
||||
const content = item.content
|
||||
if (typeof content === 'string') {
|
||||
contentBlocks.push(createTextRenderBlock(content))
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
if (part.type === 'input_text' || part.type === 'output_text' || part.type === 'text') {
|
||||
contentBlocks.push(createTextRenderBlock(part.text || ''))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentBlocks.length === 0) return null
|
||||
return createMessageBlock(role, contentBlocks, { roleLabel: this.getRoleLabel(role) })
|
||||
}
|
||||
|
||||
// function_call -> 工具调用
|
||||
if (itemType === 'function_call') {
|
||||
const toolName = item.name || '工具调用'
|
||||
const args = this.formatJson(item.arguments)
|
||||
return createMessageBlock('assistant', [
|
||||
createToolUseRenderBlock(toolName, args, item.call_id || item.id),
|
||||
], { roleLabel: 'Assistant', badges: [createBadgeBlock('工具调用', 'outline')] })
|
||||
}
|
||||
|
||||
// function_call_output -> 工具结果
|
||||
if (itemType === 'function_call_output') {
|
||||
const output = typeof item.output === 'string'
|
||||
? item.output
|
||||
: JSON.stringify(item.output, null, 2)
|
||||
return createMessageBlock('tool', [
|
||||
createToolResultRenderBlock(output),
|
||||
], { roleLabel: 'Tool', badges: [createBadgeBlock('工具结果', 'outline')] })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染响应体(支持 Chat Completions 和 CLI/Responses API 格式)
|
||||
*/
|
||||
renderResponse(responseBody: any): RenderResult {
|
||||
if (!responseBody) {
|
||||
@@ -378,6 +817,22 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
return this.renderStreamResponse(responseBody.chunks || [])
|
||||
}
|
||||
|
||||
// 检测是否为 CLI 格式
|
||||
const isCliFormat = this.isCliResponseEvent(responseBody) ||
|
||||
responseBody.object === 'response' ||
|
||||
responseBody.output !== undefined
|
||||
|
||||
if (isCliFormat) {
|
||||
return this.renderCliResponse(responseBody)
|
||||
}
|
||||
|
||||
return this.renderChatResponse(responseBody)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 OpenAI Chat Completions 响应
|
||||
*/
|
||||
private renderChatResponse(responseBody: any): RenderResult {
|
||||
try {
|
||||
const blocks: RenderBlock[] = []
|
||||
|
||||
@@ -418,6 +873,44 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 OpenAI CLI (Responses API) 响应
|
||||
*/
|
||||
private renderCliResponse(responseBody: any): RenderResult {
|
||||
try {
|
||||
const blocks: RenderBlock[] = []
|
||||
|
||||
const output = responseBody.output
|
||||
if (!Array.isArray(output)) {
|
||||
return { blocks, isStream: false }
|
||||
}
|
||||
|
||||
for (const item of output) {
|
||||
if (item?.type === 'message') {
|
||||
const contentBlocks: RenderBlock[] = []
|
||||
|
||||
if (Array.isArray(item.content)) {
|
||||
for (const content of item.content) {
|
||||
if (content?.type === 'output_text' && content?.text) {
|
||||
contentBlocks.push(createTextRenderBlock(content.text))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentBlocks.length > 0) {
|
||||
blocks.push(createMessageBlock('assistant', contentBlocks, {
|
||||
roleLabel: 'Assistant',
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { blocks, isStream: false }
|
||||
} catch (e) {
|
||||
return createEmptyRenderResult(`CLI 格式渲染失败: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染流式响应
|
||||
*/
|
||||
|
||||
@@ -109,8 +109,8 @@ export interface DateRangeParams {
|
||||
// 时间段选项
|
||||
export type PeriodValue = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last90days'
|
||||
|
||||
// 筛选状态(包含新的请求状态值)
|
||||
export type FilterStatusValue = '__all__' | 'stream' | 'standard' | 'error' | 'active' | 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||
// 筛选状态(简化为常用维度)
|
||||
export type FilterStatusValue = '__all__' | 'stream' | 'standard' | 'active' | 'failed' | 'cancelled'
|
||||
|
||||
// 默认统计状态
|
||||
export function createDefaultStats(): UsageStatsState {
|
||||
|
||||
@@ -211,20 +211,10 @@ const filteredRecords = computed(() => {
|
||||
records = records.filter(record =>
|
||||
!record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
|
||||
)
|
||||
} else if (filterStatus.value === 'error') {
|
||||
records = records.filter(record =>
|
||||
record.error_message || (record.status_code && record.status_code >= 400)
|
||||
)
|
||||
} else if (filterStatus.value === 'active') {
|
||||
records = records.filter(record =>
|
||||
record.status === 'pending' || record.status === 'streaming'
|
||||
)
|
||||
} else if (filterStatus.value === 'pending') {
|
||||
records = records.filter(record => record.status === 'pending')
|
||||
} else if (filterStatus.value === 'streaming') {
|
||||
records = records.filter(record => record.status === 'streaming')
|
||||
} else if (filterStatus.value === 'completed') {
|
||||
records = records.filter(record => record.status === 'completed')
|
||||
} else if (filterStatus.value === 'failed') {
|
||||
// 失败请求需要同时考虑新旧两种判断方式:
|
||||
// 1. 新方式:status = "failed"
|
||||
@@ -234,6 +224,8 @@ const filteredRecords = computed(() => {
|
||||
(record.status_code && record.status_code >= 400) ||
|
||||
record.error_message
|
||||
)
|
||||
} else if (filterStatus.value === 'cancelled') {
|
||||
records = records.filter(record => record.status === 'cancelled')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,8 +252,12 @@ const GLOBAL_AUTO_REFRESH_INTERVAL = 5000 // 5秒刷新一次(全局自动刷
|
||||
const globalAutoRefresh = ref(false) // 全局自动刷新开关
|
||||
|
||||
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
|
||||
|
||||
let pollInFlight = false
|
||||
async function pollActiveRequests() {
|
||||
if (!hasActiveRequests.value) return
|
||||
if (pollInFlight) return
|
||||
pollInFlight = true
|
||||
|
||||
try {
|
||||
// 根据页面类型选择不同的 API
|
||||
@@ -280,34 +276,53 @@ async function pollActiveRequests() {
|
||||
continue
|
||||
}
|
||||
|
||||
// 状态变化:completed/failed 需要刷新获取完整数据
|
||||
if (record.status !== update.status) {
|
||||
// 状态只允许单向推进,避免异步响应回退(pending -> streaming -> completed/failed/cancelled)
|
||||
const statusPriority: Record<string, number> = {
|
||||
pending: 0,
|
||||
streaming: 1,
|
||||
completed: 2,
|
||||
failed: 2,
|
||||
cancelled: 2
|
||||
}
|
||||
const currentRank = record.status ? (statusPriority[record.status] ?? 0) : 0
|
||||
const newRank = update.status ? (statusPriority[update.status] ?? 0) : 0
|
||||
const shouldApply = newRank >= currentRank
|
||||
|
||||
if (shouldApply && record.status !== update.status) {
|
||||
record.status = update.status
|
||||
}
|
||||
if (update.status === 'completed' || update.status === 'failed') {
|
||||
if (shouldApply && (update.status === 'completed' || update.status === 'failed')) {
|
||||
shouldRefresh = true
|
||||
}
|
||||
|
||||
// 进行中状态也需要持续更新(provider/key/TTFB 可能在 streaming 后才落库)
|
||||
record.input_tokens = update.input_tokens
|
||||
record.output_tokens = update.output_tokens
|
||||
record.cache_creation_input_tokens = update.cache_creation_input_tokens ?? undefined
|
||||
record.cache_read_input_tokens = update.cache_read_input_tokens ?? undefined
|
||||
record.cost = update.cost
|
||||
record.actual_cost = update.actual_cost ?? undefined
|
||||
record.rate_multiplier = update.rate_multiplier ?? undefined
|
||||
record.response_time_ms = update.response_time_ms ?? undefined
|
||||
record.first_byte_time_ms = update.first_byte_time_ms ?? undefined
|
||||
// API 格式/格式转换:streaming 时已可确定,轮询时同步更新
|
||||
if (update.api_format !== undefined) record.api_format = update.api_format
|
||||
if (update.endpoint_api_format !== undefined) record.endpoint_api_format = update.endpoint_api_format
|
||||
if (update.has_format_conversion !== undefined) record.has_format_conversion = update.has_format_conversion
|
||||
// 管理员接口返回额外字段
|
||||
if ('provider' in update && typeof update.provider === 'string') {
|
||||
record.provider = update.provider
|
||||
}
|
||||
if ('api_key_name' in update) {
|
||||
record.api_key_name = typeof update.api_key_name === 'string' ? update.api_key_name : undefined
|
||||
if (shouldApply) {
|
||||
// 进行中状态也需要持续更新(provider/key/TTFB 可能在 streaming 后才落库)
|
||||
record.input_tokens = update.input_tokens
|
||||
record.output_tokens = update.output_tokens
|
||||
record.cache_creation_input_tokens = update.cache_creation_input_tokens ?? undefined
|
||||
record.cache_read_input_tokens = update.cache_read_input_tokens ?? undefined
|
||||
record.cost = update.cost
|
||||
record.actual_cost = update.actual_cost ?? undefined
|
||||
record.rate_multiplier = update.rate_multiplier ?? undefined
|
||||
record.response_time_ms = update.response_time_ms ?? undefined
|
||||
record.first_byte_time_ms = update.first_byte_time_ms ?? undefined
|
||||
// API 格式/格式转换:streaming 时已可确定,轮询时同步更新
|
||||
if (update.api_format != null) record.api_format = update.api_format
|
||||
if (update.endpoint_api_format != null) record.endpoint_api_format = update.endpoint_api_format
|
||||
if (update.has_format_conversion != null) record.has_format_conversion = update.has_format_conversion
|
||||
// 模型映射:streaming 时已可确定
|
||||
if ('target_model' in update && (typeof update.target_model === 'string' || update.target_model === null)) {
|
||||
record.target_model = update.target_model
|
||||
}
|
||||
// 管理员接口返回额外字段
|
||||
// 只有当返回的 provider 不是 pending/unknown 时才更新,避免覆盖已有的正确值
|
||||
if ('provider' in update && typeof update.provider === 'string' &&
|
||||
update.provider !== 'pending' && update.provider !== 'unknown') {
|
||||
record.provider = update.provider
|
||||
}
|
||||
if ('api_key_name' in update) {
|
||||
record.api_key_name = typeof update.api_key_name === 'string' ? update.api_key_name : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +331,8 @@ async function pollActiveRequests() {
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('轮询活跃请求状态失败:', error)
|
||||
} finally {
|
||||
pollInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user