feat: 优化用量状态同步机制和 OpenAI CLI 流式转换

- 增加状态回退保护,防止异步响应覆盖已知状态
- 添加轮询并发保护 (pollInFlight),避免重复请求
- 支持 cancelled 状态筛选和显示
- 前端 mergeRecordStatus 保护活跃记录状态
- 后端 streaming 状态同步更新改为使用当前 DB 会话
- OpenAI CLI 流式转换增加工具调用事件支持
- 轮询接口新增 target_model 字段返回
- 修复迁移脚本 inspector 缓存问题,改用 information_schema
This commit is contained in:
fawney19
2026-02-04 03:17:55 +08:00
parent f3e2f84b38
commit 7f09f191a1
17 changed files with 957 additions and 84 deletions

View File

@@ -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:

View File

@@ -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(',') } : {}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}`
} }

View File

@@ -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)

View File

@@ -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')
} }
} }

View File

@@ -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}`)
}
}
/** /**
* 渲染流式响应 * 渲染流式响应
*/ */

View File

@@ -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 {

View File

@@ -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
} }
} }

View File

@@ -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":

View File

@@ -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(

View File

@@ -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:
"""根据上下文获取状态字符串""" """根据上下文获取状态字符串"""

View File

@@ -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

View File

@@ -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

View File

@@ -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": {