mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 优化用量状态同步机制和 OpenAI CLI 流式转换
- 增加状态回退保护,防止异步响应覆盖已知状态 - 添加轮询并发保护 (pollInFlight),避免重复请求 - 支持 cancelled 状态筛选和显示 - 前端 mergeRecordStatus 保护活跃记录状态 - 后端 streaming 状态同步更新改为使用当前 DB 会话 - OpenAI CLI 流式转换增加工具调用事件支持 - 轮询接口新增 target_model 字段返回 - 修复迁移脚本 inspector 缓存问题,改用 information_schema
This commit is contained in:
@@ -37,9 +37,17 @@ def _index_exists(table_name: str, index_name: str) -> bool:
|
|||||||
|
|
||||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
bind = op.get_bind()
|
bind = op.get_bind()
|
||||||
inspector = inspect(bind)
|
# Use information_schema for more reliable detection (inspector can have caching issues)
|
||||||
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
result = bind.execute(
|
||||||
return column_name in columns
|
sa.text(
|
||||||
|
"SELECT EXISTS ("
|
||||||
|
"SELECT 1 FROM information_schema.columns "
|
||||||
|
"WHERE table_name = :table AND column_name = :column"
|
||||||
|
")"
|
||||||
|
),
|
||||||
|
{"table": table_name, "column": column_name},
|
||||||
|
)
|
||||||
|
return bool(result.scalar())
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ export const usageApi = {
|
|||||||
api_format?: string | null
|
api_format?: string | null
|
||||||
endpoint_api_format?: string | null
|
endpoint_api_format?: string | null
|
||||||
has_format_conversion?: boolean | null
|
has_format_conversion?: boolean | null
|
||||||
|
target_model?: string | null
|
||||||
}>
|
}>
|
||||||
}> {
|
}> {
|
||||||
const params = ids?.length ? { ids: ids.join(',') } : {}
|
const params = ids?.length ? { ids: ids.join(',') } : {}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<Card class="overflow-hidden">
|
<Card class="overflow-hidden flex flex-col">
|
||||||
<div class="px-3 py-2 border-b">
|
<div class="px-3 py-2 border-b flex-shrink-0">
|
||||||
<h3 class="text-sm font-medium">
|
<h3 class="text-sm font-medium">
|
||||||
按API格式分析
|
按API格式分析
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<Table class="text-sm">
|
<div class="overflow-auto max-h-[320px]">
|
||||||
|
<Table class="text-sm">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead class="h-8 px-2">
|
<TableHead class="h-8 px-2">
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<Card class="overflow-hidden">
|
<Card class="overflow-hidden flex flex-col">
|
||||||
<div class="px-3 py-2 border-b">
|
<div class="px-3 py-2 border-b flex-shrink-0">
|
||||||
<h3 class="text-sm font-medium">
|
<h3 class="text-sm font-medium">
|
||||||
按模型分析
|
按模型分析
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<Table class="text-sm">
|
<div class="overflow-auto max-h-[320px]">
|
||||||
|
<Table class="text-sm">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead class="h-8 px-2">
|
<TableHead class="h-8 px-2">
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<Card class="overflow-hidden">
|
<Card class="overflow-hidden flex flex-col">
|
||||||
<div class="px-3 py-2 border-b">
|
<div class="px-3 py-2 border-b flex-shrink-0">
|
||||||
<h3 class="text-sm font-medium">
|
<h3 class="text-sm font-medium">
|
||||||
按提供商分析
|
按提供商分析
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<Table class="text-sm">
|
<div class="overflow-auto max-h-[320px]">
|
||||||
|
<Table class="text-sm">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead class="h-8 px-2">
|
<TableHead class="h-8 px-2">
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -134,18 +134,15 @@
|
|||||||
<SelectItem value="standard">
|
<SelectItem value="standard">
|
||||||
标准
|
标准
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="pending">
|
<SelectItem value="active">
|
||||||
等待中
|
活跃
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="streaming">
|
|
||||||
传输中
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="completed">
|
|
||||||
完成
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="failed">
|
<SelectItem value="failed">
|
||||||
失败
|
失败
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="cancelled">
|
||||||
|
已取消
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -415,6 +412,13 @@
|
|||||||
>
|
>
|
||||||
失败
|
失败
|
||||||
</Badge>
|
</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
|
<Badge
|
||||||
v-else-if="record.is_stream"
|
v-else-if="record.is_stream"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -732,7 +736,8 @@ function getApiFormatTooltip(record: UsageRecord): string {
|
|||||||
|
|
||||||
// 如果发生了格式转换或同族格式差异,显示详细信息
|
// 如果发生了格式转换或同族格式差异,显示详细信息
|
||||||
if (shouldShowFormatConversion(record)) {
|
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 ? '格式转换' : '格式兼容(无需转换)'
|
const conversionType = record.has_format_conversion ? '格式转换' : '格式兼容(无需转换)'
|
||||||
return `用户请求格式: ${displayFormat}\n端点原生格式: ${endpointDisplayFormat}\n${conversionType}`
|
return `用户请求格式: ${displayFormat}\n端点原生格式: ${endpointDisplayFormat}\n${conversionType}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,7 +164,9 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// 用户页面:记录直接从 userData 获取(数量较少)
|
// 用户页面:记录直接从 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
|
totalRecords.value = currentRecords.value.length
|
||||||
|
|
||||||
// 从记录中提取筛选选项和 API 格式统计
|
// 从记录中提取筛选选项和 API 格式统计
|
||||||
@@ -272,12 +274,14 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await usageApi.getAllUsageRecords(params)
|
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
|
totalRecords.value = response.total || 0
|
||||||
} else {
|
} else {
|
||||||
// 用户页面:使用用户 API
|
// 用户页面:使用用户 API
|
||||||
const userData = await meApi.getUsage(params)
|
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
|
totalRecords.value = userData.pagination?.total || currentRecords.value.length
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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) {
|
async function refreshData(dateRange?: DateRangeParams) {
|
||||||
await loadStats(dateRange)
|
await loadStats(dateRange)
|
||||||
|
|||||||
@@ -70,10 +70,18 @@ export function useUsageFilters(options: UseUsageFiltersOptions) {
|
|||||||
records = records.filter(record =>
|
records = records.filter(record =>
|
||||||
!record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
|
!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 =>
|
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'
|
readonly displayName = 'OpenAI'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检测是否为 OpenAI 格式
|
* 检测是否为 OpenAI 格式(包括 Chat Completions 和 CLI/Responses API)
|
||||||
*/
|
*/
|
||||||
detect(requestBody: any, responseBody: any, hint?: string): number {
|
detect(requestBody: any, responseBody: any, hint?: string): number {
|
||||||
// 1. 后端提示优先
|
// 1. 后端提示优先
|
||||||
@@ -52,7 +52,12 @@ export class OpenAIParser implements ApiFormatParser {
|
|||||||
if (model.includes('gpt') || model.includes('o1') || model.includes('o3')) return 95
|
if (model.includes('gpt') || model.includes('o1') || model.includes('o3')) return 95
|
||||||
|
|
||||||
// 3. 检查请求体结构
|
// 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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +67,11 @@ export class OpenAIParser implements ApiFormatParser {
|
|||||||
: responseBody
|
: responseBody
|
||||||
|
|
||||||
if (respBody) {
|
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')) {
|
if (respBody.choices || respBody.object?.includes('chat.completion')) {
|
||||||
return 90
|
return 90
|
||||||
}
|
}
|
||||||
@@ -73,6 +82,10 @@ export class OpenAIParser implements ApiFormatParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. 检查 OpenAI 特有的请求结构
|
// 5. 检查 OpenAI 特有的请求结构
|
||||||
|
if (isCliFormat) {
|
||||||
|
return 80
|
||||||
|
}
|
||||||
|
|
||||||
// OpenAI 的 system 是在 messages 数组中作为 role: system
|
// OpenAI 的 system 是在 messages 数组中作为 role: system
|
||||||
const hasSystemInMessages = requestBody.messages?.some(
|
const hasSystemInMessages = requestBody.messages?.some(
|
||||||
(m: any) => m.role === 'system'
|
(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 {
|
parseRequest(requestBody: any): ParsedConversation {
|
||||||
if (!requestBody) {
|
if (!requestBody) {
|
||||||
return createEmptyConversation('openai', '无请求体')
|
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 {
|
try {
|
||||||
const result: ParsedConversation = {
|
const result: ParsedConversation = {
|
||||||
messages: [],
|
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 {
|
parseResponse(responseBody: any): ParsedConversation {
|
||||||
if (!responseBody) {
|
if (!responseBody) {
|
||||||
return createEmptyConversation('openai', '无响应体')
|
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 {
|
try {
|
||||||
const result: ParsedConversation = {
|
const result: ParsedConversation = {
|
||||||
messages: [],
|
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 {
|
parseStreamResponse(chunks: any[]): ParsedConversation {
|
||||||
if (!chunks || chunks.length === 0) {
|
if (!chunks || chunks.length === 0) {
|
||||||
return createEmptyConversation('openai', '无响应数据')
|
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 {
|
try {
|
||||||
const result: ParsedConversation = {
|
const result: ParsedConversation = {
|
||||||
messages: [],
|
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 {
|
renderRequest(requestBody: any): RenderResult {
|
||||||
if (!requestBody) {
|
if (!requestBody) {
|
||||||
return createEmptyRenderResult('无请求体')
|
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 {
|
try {
|
||||||
const blocks: RenderBlock[] = []
|
const blocks: RenderBlock[] = []
|
||||||
const isStream = requestBody.stream === true
|
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 {
|
renderResponse(responseBody: any): RenderResult {
|
||||||
if (!responseBody) {
|
if (!responseBody) {
|
||||||
@@ -378,6 +817,22 @@ export class OpenAIParser implements ApiFormatParser {
|
|||||||
return this.renderStreamResponse(responseBody.chunks || [])
|
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 {
|
try {
|
||||||
const blocks: RenderBlock[] = []
|
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 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 {
|
export function createDefaultStats(): UsageStatsState {
|
||||||
|
|||||||
@@ -211,20 +211,10 @@ const filteredRecords = computed(() => {
|
|||||||
records = records.filter(record =>
|
records = records.filter(record =>
|
||||||
!record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
|
!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') {
|
} else if (filterStatus.value === 'active') {
|
||||||
records = records.filter(record =>
|
records = records.filter(record =>
|
||||||
record.status === 'pending' || record.status === 'streaming'
|
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') {
|
} else if (filterStatus.value === 'failed') {
|
||||||
// 失败请求需要同时考虑新旧两种判断方式:
|
// 失败请求需要同时考虑新旧两种判断方式:
|
||||||
// 1. 新方式:status = "failed"
|
// 1. 新方式:status = "failed"
|
||||||
@@ -234,6 +224,8 @@ const filteredRecords = computed(() => {
|
|||||||
(record.status_code && record.status_code >= 400) ||
|
(record.status_code && record.status_code >= 400) ||
|
||||||
record.error_message
|
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) // 全局自动刷新开关
|
const globalAutoRefresh = ref(false) // 全局自动刷新开关
|
||||||
|
|
||||||
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
|
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
|
||||||
|
|
||||||
|
let pollInFlight = false
|
||||||
async function pollActiveRequests() {
|
async function pollActiveRequests() {
|
||||||
if (!hasActiveRequests.value) return
|
if (!hasActiveRequests.value) return
|
||||||
|
if (pollInFlight) return
|
||||||
|
pollInFlight = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 根据页面类型选择不同的 API
|
// 根据页面类型选择不同的 API
|
||||||
@@ -280,34 +276,53 @@ async function pollActiveRequests() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 状态变化:completed/failed 需要刷新获取完整数据
|
// 状态只允许单向推进,避免异步响应回退(pending -> streaming -> completed/failed/cancelled)
|
||||||
if (record.status !== update.status) {
|
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
|
record.status = update.status
|
||||||
}
|
}
|
||||||
if (update.status === 'completed' || update.status === 'failed') {
|
if (shouldApply && (update.status === 'completed' || update.status === 'failed')) {
|
||||||
shouldRefresh = true
|
shouldRefresh = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 进行中状态也需要持续更新(provider/key/TTFB 可能在 streaming 后才落库)
|
if (shouldApply) {
|
||||||
record.input_tokens = update.input_tokens
|
// 进行中状态也需要持续更新(provider/key/TTFB 可能在 streaming 后才落库)
|
||||||
record.output_tokens = update.output_tokens
|
record.input_tokens = update.input_tokens
|
||||||
record.cache_creation_input_tokens = update.cache_creation_input_tokens ?? undefined
|
record.output_tokens = update.output_tokens
|
||||||
record.cache_read_input_tokens = update.cache_read_input_tokens ?? undefined
|
record.cache_creation_input_tokens = update.cache_creation_input_tokens ?? undefined
|
||||||
record.cost = update.cost
|
record.cache_read_input_tokens = update.cache_read_input_tokens ?? undefined
|
||||||
record.actual_cost = update.actual_cost ?? undefined
|
record.cost = update.cost
|
||||||
record.rate_multiplier = update.rate_multiplier ?? undefined
|
record.actual_cost = update.actual_cost ?? undefined
|
||||||
record.response_time_ms = update.response_time_ms ?? undefined
|
record.rate_multiplier = update.rate_multiplier ?? undefined
|
||||||
record.first_byte_time_ms = update.first_byte_time_ms ?? undefined
|
record.response_time_ms = update.response_time_ms ?? undefined
|
||||||
// API 格式/格式转换:streaming 时已可确定,轮询时同步更新
|
record.first_byte_time_ms = update.first_byte_time_ms ?? undefined
|
||||||
if (update.api_format !== undefined) record.api_format = update.api_format
|
// API 格式/格式转换:streaming 时已可确定,轮询时同步更新
|
||||||
if (update.endpoint_api_format !== undefined) record.endpoint_api_format = update.endpoint_api_format
|
if (update.api_format != null) record.api_format = update.api_format
|
||||||
if (update.has_format_conversion !== undefined) record.has_format_conversion = update.has_format_conversion
|
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
|
||||||
if ('provider' in update && typeof update.provider === 'string') {
|
// 模型映射:streaming 时已可确定
|
||||||
record.provider = update.provider
|
if ('target_model' in update && (typeof update.target_model === 'string' || update.target_model === null)) {
|
||||||
}
|
record.target_model = update.target_model
|
||||||
if ('api_key_name' in update) {
|
}
|
||||||
record.api_key_name = typeof update.api_key_name === 'string' ? update.api_key_name : undefined
|
// 管理员接口返回额外字段
|
||||||
|
// 只有当返回的 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) {
|
} catch (error) {
|
||||||
log.error('轮询活跃请求状态失败:', error)
|
log.error('轮询活跃请求状态失败:', error)
|
||||||
|
} finally {
|
||||||
|
pollInFlight = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -857,7 +857,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
query = query.filter(Usage.is_stream == False) # noqa: E712
|
query = query.filter(Usage.is_stream == False) # noqa: E712
|
||||||
elif self.status == "error":
|
elif self.status == "error":
|
||||||
query = query.filter((Usage.status_code >= 400) | (Usage.error_message.isnot(None)))
|
query = query.filter((Usage.status_code >= 400) | (Usage.error_message.isnot(None)))
|
||||||
elif self.status in ("pending", "streaming", "completed"):
|
elif self.status in ("pending", "streaming", "completed", "cancelled"):
|
||||||
# 新的状态筛选:直接按 status 字段过滤
|
# 新的状态筛选:直接按 status 字段过滤
|
||||||
query = query.filter(Usage.status == self.status)
|
query = query.filter(Usage.status == self.status)
|
||||||
elif self.status == "failed":
|
elif self.status == "failed":
|
||||||
|
|||||||
@@ -2676,7 +2676,28 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
ctx.record_first_byte_time(self.start_time)
|
ctx.record_first_byte_time(self.start_time)
|
||||||
state["first_yield"] = False
|
state["first_yield"] = False
|
||||||
if not state["streaming_updated"]:
|
if not state["streaming_updated"]:
|
||||||
self._update_usage_to_streaming_with_ctx(ctx)
|
# 优先使用当前请求的 DB 会话同步更新,避免状态延迟或丢失
|
||||||
|
try:
|
||||||
|
from src.services.usage import UsageService
|
||||||
|
|
||||||
|
UsageService.update_usage_status(
|
||||||
|
db=self.db,
|
||||||
|
request_id=self.request_id,
|
||||||
|
status="streaming",
|
||||||
|
provider=ctx.provider_name,
|
||||||
|
target_model=ctx.mapped_model,
|
||||||
|
provider_id=ctx.provider_id,
|
||||||
|
provider_endpoint_id=ctx.endpoint_id,
|
||||||
|
provider_api_key_id=ctx.key_id,
|
||||||
|
first_byte_time_ms=ctx.first_byte_time_ms,
|
||||||
|
api_format=ctx.api_format,
|
||||||
|
endpoint_api_format=ctx.provider_api_format or None,
|
||||||
|
has_format_conversion=ctx.needs_conversion,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[{self.request_id}] 同步更新 streaming 状态失败: {e}")
|
||||||
|
# 回退到后台任务更新
|
||||||
|
self._update_usage_to_streaming_with_ctx(ctx)
|
||||||
state["streaming_updated"] = True
|
state["streaming_updated"] = True
|
||||||
|
|
||||||
def _convert_sse_line(
|
def _convert_sse_line(
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ class StreamTelemetryRecorder:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await self._dispatch_record(
|
await self._dispatch_record(
|
||||||
|
bg_db,
|
||||||
writer,
|
writer,
|
||||||
ctx,
|
ctx,
|
||||||
original_headers,
|
original_headers,
|
||||||
@@ -137,6 +138,7 @@ class StreamTelemetryRecorder:
|
|||||||
if response_body is None and should_log_body:
|
if response_body is None and should_log_body:
|
||||||
response_body = ctx.build_response_body(response_time_ms)
|
response_body = ctx.build_response_body(response_time_ms)
|
||||||
await self._dispatch_record(
|
await self._dispatch_record(
|
||||||
|
bg_db,
|
||||||
db_writer,
|
db_writer,
|
||||||
ctx,
|
ctx,
|
||||||
original_headers,
|
original_headers,
|
||||||
@@ -438,6 +440,7 @@ class StreamTelemetryRecorder:
|
|||||||
|
|
||||||
async def _dispatch_record(
|
async def _dispatch_record(
|
||||||
self,
|
self,
|
||||||
|
db: Session,
|
||||||
writer: TelemetryWriter,
|
writer: TelemetryWriter,
|
||||||
ctx: StreamContext,
|
ctx: StreamContext,
|
||||||
original_headers: dict[str, str],
|
original_headers: dict[str, str],
|
||||||
@@ -455,6 +458,14 @@ class StreamTelemetryRecorder:
|
|||||||
response_body,
|
response_body,
|
||||||
response_time_ms,
|
response_time_ms,
|
||||||
)
|
)
|
||||||
|
# Queue writer 异步落库可能造成 UI 延迟,先直接更新 Usage 状态
|
||||||
|
if isinstance(writer, QueueTelemetryWriter):
|
||||||
|
await self._update_usage_status_directly(
|
||||||
|
db=db,
|
||||||
|
status=self._get_status_from_ctx(ctx),
|
||||||
|
response_time_ms=response_time_ms,
|
||||||
|
status_code=ctx.status_code,
|
||||||
|
)
|
||||||
elif ctx.is_client_disconnected():
|
elif ctx.is_client_disconnected():
|
||||||
await self._record_cancelled(
|
await self._record_cancelled(
|
||||||
writer,
|
writer,
|
||||||
@@ -464,6 +475,14 @@ class StreamTelemetryRecorder:
|
|||||||
response_body,
|
response_body,
|
||||||
response_time_ms,
|
response_time_ms,
|
||||||
)
|
)
|
||||||
|
# Queue writer 异步落库可能造成 UI 延迟,先直接更新 Usage 状态
|
||||||
|
if isinstance(writer, QueueTelemetryWriter):
|
||||||
|
await self._update_usage_status_directly(
|
||||||
|
db=db,
|
||||||
|
status="cancelled",
|
||||||
|
response_time_ms=response_time_ms,
|
||||||
|
status_code=ctx.status_code,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
await self._record_failure(
|
await self._record_failure(
|
||||||
writer,
|
writer,
|
||||||
@@ -473,6 +492,15 @@ class StreamTelemetryRecorder:
|
|||||||
response_body,
|
response_body,
|
||||||
response_time_ms,
|
response_time_ms,
|
||||||
)
|
)
|
||||||
|
# Queue writer 异步落库可能造成 UI 延迟,先直接更新 Usage 状态
|
||||||
|
if isinstance(writer, QueueTelemetryWriter):
|
||||||
|
await self._update_usage_status_directly(
|
||||||
|
db=db,
|
||||||
|
status=self._get_status_from_ctx(ctx),
|
||||||
|
response_time_ms=response_time_ms,
|
||||||
|
status_code=ctx.status_code,
|
||||||
|
error_message=ctx.error_message or f"HTTP {ctx.status_code}",
|
||||||
|
)
|
||||||
|
|
||||||
def _get_status_from_ctx(self, ctx: StreamContext) -> str:
|
def _get_status_from_ctx(self, ctx: StreamContext) -> str:
|
||||||
"""根据上下文获取状态字符串"""
|
"""根据上下文获取状态字符串"""
|
||||||
|
|||||||
@@ -411,25 +411,144 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
if not state.model:
|
if not state.model:
|
||||||
state.model = event.model or ""
|
state.model = event.model or ""
|
||||||
ss.setdefault("collected_text", "")
|
ss.setdefault("collected_text", "")
|
||||||
|
ss.setdefault("tool_calls", {})
|
||||||
|
ss.setdefault("tool_blocks", {})
|
||||||
|
ss.setdefault("tool_output_index", {})
|
||||||
|
ss.setdefault("output_order", [])
|
||||||
|
ss.setdefault("message_output_index", None)
|
||||||
|
ss.setdefault("message_output_started", False)
|
||||||
|
ss.setdefault("text_started", False)
|
||||||
|
ss.setdefault("next_output_index", 0)
|
||||||
|
ss.setdefault("sent_in_progress", False)
|
||||||
|
response_obj = {
|
||||||
|
"id": state.message_id,
|
||||||
|
"object": "response",
|
||||||
|
"created": int(time.time()),
|
||||||
|
"model": state.model,
|
||||||
|
"status": "in_progress",
|
||||||
|
"output": [],
|
||||||
|
}
|
||||||
out.append(
|
out.append(
|
||||||
event_block(
|
event_block(
|
||||||
{
|
{
|
||||||
"type": "response.created",
|
"type": "response.created",
|
||||||
"response": {
|
"response": response_obj,
|
||||||
"id": state.message_id,
|
|
||||||
"object": "response",
|
|
||||||
"created": int(time.time()),
|
|
||||||
"model": state.model,
|
|
||||||
"status": "in_progress",
|
|
||||||
"output": [],
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# OpenAI Responses API 常见的 in_progress 事件(可选,最佳努力)
|
||||||
|
if not ss.get("sent_in_progress"):
|
||||||
|
ss["sent_in_progress"] = True
|
||||||
|
out.append(event_block({"type": "response.in_progress", "response": response_obj}))
|
||||||
|
return out
|
||||||
|
|
||||||
|
if isinstance(event, ContentBlockStartEvent):
|
||||||
|
# 工具调用块:输出 function_call 添加事件
|
||||||
|
if event.block_type == ContentType.TOOL_USE:
|
||||||
|
tool_id = event.tool_id or ""
|
||||||
|
tool_name = event.tool_name or ""
|
||||||
|
output_index = int(ss.get("next_output_index") or 0)
|
||||||
|
ss["next_output_index"] = output_index + 1
|
||||||
|
if tool_id:
|
||||||
|
tool_calls = ss.setdefault("tool_calls", {})
|
||||||
|
tool_calls.setdefault(tool_id, {"name": tool_name, "args": ""})
|
||||||
|
output_order = ss.setdefault("output_order", [])
|
||||||
|
output_order.append(
|
||||||
|
{"kind": "tool", "id": tool_id, "output_index": output_index}
|
||||||
|
)
|
||||||
|
ss.setdefault("tool_blocks", {})[event.block_index] = tool_id
|
||||||
|
ss.setdefault("tool_output_index", {})[tool_id] = output_index
|
||||||
|
out.append(
|
||||||
|
event_block(
|
||||||
|
{
|
||||||
|
"type": "response.output_item.added",
|
||||||
|
"output_index": output_index,
|
||||||
|
"item": {
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": tool_id,
|
||||||
|
"id": tool_id or f"call_{output_index}",
|
||||||
|
"name": tool_name,
|
||||||
|
"status": "in_progress",
|
||||||
|
"arguments": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
if isinstance(event, ToolCallDeltaEvent):
|
||||||
|
tool_id = event.tool_id or ss.get("tool_blocks", {}).get(event.block_index, "")
|
||||||
|
if tool_id:
|
||||||
|
tool_calls = ss.setdefault("tool_calls", {})
|
||||||
|
entry = tool_calls.setdefault(tool_id, {"name": "", "args": ""})
|
||||||
|
entry["args"] = str(entry.get("args") or "") + (event.input_delta or "")
|
||||||
|
output_index = ss.get("tool_output_index", {}).get(tool_id, event.block_index)
|
||||||
|
out.append(
|
||||||
|
event_block(
|
||||||
|
{
|
||||||
|
"type": "response.function_call_arguments.delta",
|
||||||
|
"delta": event.input_delta,
|
||||||
|
"item_id": tool_id,
|
||||||
|
"output_index": output_index,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
if isinstance(event, ContentBlockStopEvent):
|
||||||
|
tool_blocks = ss.get("tool_blocks", {})
|
||||||
|
tool_id = (
|
||||||
|
tool_blocks.pop(event.block_index, None) if isinstance(tool_blocks, dict) else None
|
||||||
|
)
|
||||||
|
if tool_id:
|
||||||
|
tool_calls = ss.get("tool_calls", {})
|
||||||
|
entry = tool_calls.get(tool_id, {})
|
||||||
|
output_index = ss.get("tool_output_index", {}).get(tool_id, event.block_index)
|
||||||
|
out.append(
|
||||||
|
event_block(
|
||||||
|
{
|
||||||
|
"type": "response.output_item.done",
|
||||||
|
"output_index": output_index,
|
||||||
|
"item": {
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": tool_id,
|
||||||
|
"id": tool_id,
|
||||||
|
"name": entry.get("name") or "",
|
||||||
|
"arguments": entry.get("args") or "",
|
||||||
|
"status": "completed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
if isinstance(event, ContentDeltaEvent):
|
if isinstance(event, ContentDeltaEvent):
|
||||||
if event.text_delta:
|
if event.text_delta:
|
||||||
|
if not ss.get("message_output_started"):
|
||||||
|
output_index = int(ss.get("next_output_index") or 0)
|
||||||
|
ss["next_output_index"] = output_index + 1
|
||||||
|
ss["message_output_index"] = output_index
|
||||||
|
ss["message_output_started"] = True
|
||||||
|
message_id = f"msg_{state.message_id or 'stream'}"
|
||||||
|
ss.setdefault("output_order", []).append(
|
||||||
|
{"kind": "message", "id": message_id, "output_index": output_index}
|
||||||
|
)
|
||||||
|
out.append(
|
||||||
|
event_block(
|
||||||
|
{
|
||||||
|
"type": "response.output_item.added",
|
||||||
|
"output_index": output_index,
|
||||||
|
"item": {
|
||||||
|
"type": "message",
|
||||||
|
"id": message_id,
|
||||||
|
"role": "assistant",
|
||||||
|
"status": "in_progress",
|
||||||
|
"content": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ss["text_started"] = True
|
||||||
ss["collected_text"] = str(ss.get("collected_text") or "") + event.text_delta
|
ss["collected_text"] = str(ss.get("collected_text") or "") + event.text_delta
|
||||||
out.append(
|
out.append(
|
||||||
event_block(
|
event_block(
|
||||||
@@ -443,6 +562,34 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
|
|
||||||
if isinstance(event, MessageStopEvent):
|
if isinstance(event, MessageStopEvent):
|
||||||
final_text = str(ss.get("collected_text") or "")
|
final_text = str(ss.get("collected_text") or "")
|
||||||
|
message_id = f"msg_{state.message_id or 'stream'}"
|
||||||
|
message_item = {
|
||||||
|
"type": "message",
|
||||||
|
"id": message_id,
|
||||||
|
"role": "assistant",
|
||||||
|
"status": "completed",
|
||||||
|
"content": ([{"type": "output_text", "text": final_text}] if final_text else []),
|
||||||
|
}
|
||||||
|
if ss.get("text_started"):
|
||||||
|
out.append(
|
||||||
|
event_block(
|
||||||
|
{
|
||||||
|
"type": "response.output_text.done",
|
||||||
|
"text": final_text,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if ss.get("message_output_started"):
|
||||||
|
output_index = ss.get("message_output_index") or 0
|
||||||
|
out.append(
|
||||||
|
event_block(
|
||||||
|
{
|
||||||
|
"type": "response.output_item.done",
|
||||||
|
"output_index": output_index,
|
||||||
|
"item": message_item,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
response_obj = self.response_from_internal(
|
response_obj = self.response_from_internal(
|
||||||
InternalResponse(
|
InternalResponse(
|
||||||
id=state.message_id or "resp",
|
id=state.message_id or "resp",
|
||||||
@@ -452,6 +599,53 @@ class OpenAICliNormalizer(FormatNormalizer):
|
|||||||
usage=event.usage or UsageInfo(),
|
usage=event.usage or UsageInfo(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# 将工具调用添加到 output(最佳努力)
|
||||||
|
tool_calls = ss.get("tool_calls", {})
|
||||||
|
output_order = ss.get("output_order", [])
|
||||||
|
output_items: list[dict[str, Any]] = []
|
||||||
|
used_tool_ids: set[str] = set()
|
||||||
|
if isinstance(output_order, list) and output_order:
|
||||||
|
for entry in output_order:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
if entry.get("kind") == "message":
|
||||||
|
if message_item.get("content"):
|
||||||
|
output_items.append(message_item)
|
||||||
|
elif entry.get("kind") == "tool":
|
||||||
|
tool_id = entry.get("id")
|
||||||
|
if not isinstance(tool_id, str) or not tool_id:
|
||||||
|
continue
|
||||||
|
used_tool_ids.add(tool_id)
|
||||||
|
tool_entry = (
|
||||||
|
tool_calls.get(tool_id) if isinstance(tool_calls, dict) else None
|
||||||
|
)
|
||||||
|
if isinstance(tool_entry, dict):
|
||||||
|
output_items.append(
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": tool_id,
|
||||||
|
"id": tool_id,
|
||||||
|
"name": tool_entry.get("name") or "",
|
||||||
|
"arguments": tool_entry.get("args") or "",
|
||||||
|
"status": "completed",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if isinstance(tool_calls, dict):
|
||||||
|
for tool_id, tool_entry in tool_calls.items():
|
||||||
|
if tool_id in used_tool_ids or not isinstance(tool_entry, dict):
|
||||||
|
continue
|
||||||
|
output_items.append(
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": tool_id,
|
||||||
|
"id": tool_id,
|
||||||
|
"name": tool_entry.get("name") or "",
|
||||||
|
"arguments": tool_entry.get("args") or "",
|
||||||
|
"status": "completed",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if output_items:
|
||||||
|
response_obj["output"] = output_items
|
||||||
out.append(event_block({"type": "response.completed", "response": response_obj}))
|
out.append(event_block({"type": "response.completed", "response": response_obj}))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -2848,6 +2848,14 @@ class UsageService:
|
|||||||
logger.warning(f"未找到 request_id={request_id} 的使用记录,无法更新状态")
|
logger.warning(f"未找到 request_id={request_id} 的使用记录,无法更新状态")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# 避免状态回退:streaming 只能从 pending/streaming 进入
|
||||||
|
if status == "streaming" and usage.status not in ("pending", "streaming"):
|
||||||
|
logger.debug(
|
||||||
|
f"跳过 streaming 状态更新(避免回退): request_id={request_id}, "
|
||||||
|
f"{usage.status} -> {status}"
|
||||||
|
)
|
||||||
|
return usage
|
||||||
|
|
||||||
old_status = usage.status
|
old_status = usage.status
|
||||||
usage.status = status
|
usage.status = status
|
||||||
if error_message:
|
if error_message:
|
||||||
@@ -3077,6 +3085,8 @@ class UsageService:
|
|||||||
Usage.api_format,
|
Usage.api_format,
|
||||||
Usage.endpoint_api_format,
|
Usage.endpoint_api_format,
|
||||||
Usage.has_format_conversion,
|
Usage.has_format_conversion,
|
||||||
|
# 模型映射(streaming 时已可确定)
|
||||||
|
Usage.target_model,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 管理员轮询:可附带 provider 与上游 key 名称(注意:不要在普通用户接口暴露上游 key 信息)
|
# 管理员轮询:可附带 provider 与上游 key 名称(注意:不要在普通用户接口暴露上游 key 信息)
|
||||||
@@ -3234,6 +3244,9 @@ class UsageService:
|
|||||||
item["endpoint_api_format"] = endpoint_api_format
|
item["endpoint_api_format"] = endpoint_api_format
|
||||||
if has_format_conversion is not None:
|
if has_format_conversion is not None:
|
||||||
item["has_format_conversion"] = bool(has_format_conversion)
|
item["has_format_conversion"] = bool(has_format_conversion)
|
||||||
|
# 模型映射(streaming 时已可确定)
|
||||||
|
if r.target_model:
|
||||||
|
item["target_model"] = r.target_model
|
||||||
if include_admin_fields:
|
if include_admin_fields:
|
||||||
item["provider"] = r.provider_name
|
item["provider"] = r.provider_name
|
||||||
item["api_key_name"] = r.api_key_name
|
item["api_key_name"] = r.api_key_name
|
||||||
|
|||||||
@@ -96,9 +96,11 @@ def test_stream_openai_to_openai_cli_delta() -> None:
|
|||||||
|
|
||||||
out_events = reg.convert_stream_chunk(chunk, "openai:chat", "openai:cli", state=state)
|
out_events = reg.convert_stream_chunk(chunk, "openai:chat", "openai:cli", state=state)
|
||||||
assert isinstance(out_events, list) and out_events
|
assert isinstance(out_events, list) and out_events
|
||||||
assert out_events[0].get("type") == "response.created"
|
created = [e for e in out_events if e.get("type") == "response.created"]
|
||||||
assert out_events[1].get("type") == "response.output_text.delta"
|
assert created
|
||||||
assert out_events[1].get("delta") == "hi"
|
deltas = [e for e in out_events if e.get("type") == "response.output_text.delta"]
|
||||||
|
assert deltas
|
||||||
|
assert deltas[0].get("delta") == "hi"
|
||||||
|
|
||||||
|
|
||||||
def test_stream_openai_cli_to_openai_delta() -> None:
|
def test_stream_openai_cli_to_openai_delta() -> None:
|
||||||
@@ -376,7 +378,7 @@ def test_real_claude_cli_stream_response_conversion() -> None:
|
|||||||
state = StreamState()
|
state = StreamState()
|
||||||
|
|
||||||
# 真实的 Claude CLI 流式响应事件序列
|
# 真实的 Claude CLI 流式响应事件序列
|
||||||
chunks = [
|
chunks: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"type": "message_start",
|
"type": "message_start",
|
||||||
"message": {
|
"message": {
|
||||||
@@ -533,7 +535,7 @@ def test_real_claude_cli_stream_to_openai_cli() -> None:
|
|||||||
state = StreamState()
|
state = StreamState()
|
||||||
|
|
||||||
# 简化的真实事件序列
|
# 简化的真实事件序列
|
||||||
chunks = [
|
chunks: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"type": "message_start",
|
"type": "message_start",
|
||||||
"message": {
|
"message": {
|
||||||
|
|||||||
Reference in New Issue
Block a user