mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat(stream-bridge): 支持上游 sync 响应转 SSE 流式输出,记录 client/upstream 流模式差异
- 新增 sync_to_stream 桥接模块,将非 SSE 上游响应转换为 SSE 格式回传给流式客户端 - stream_pump 检测非 SSE 响应头后缓冲整包并通过桥接逻辑重写为 SSE 帧 - proxy handler 同步支持 sync→stream 聚合与转换(覆盖 openai/claude/gemini 四种格式) - sync_products 补全 openai:cli 的完整流式事件聚合(text delta、reasoning、tool call 等) - usage runtime 写入 client_requested_stream / upstream_is_stream 到 request_metadata - SQL 查询层将两个布尔字段从 request_metadata jsonb 中提取并回传给前端 - 前端 status.ts 新增 resolveUsageStreamLabelSegments,优先读取 client_requested_stream - RequestDetailDrawer 在流式转换场景下显示"客户端→上游"两段 Badge
This commit is contained in:
@@ -54,11 +54,24 @@
|
||||
{{ detail.status_code }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="detail"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
v-if="detail && resolveUsageStreamLabelSegments(detail).hasConversion"
|
||||
:variant="resolveUsageStreamLabelSegments(detail).client === '流式' ? 'secondary' : 'outline'"
|
||||
:class="resolveUsageStreamLabelSegments(detail).client === '流式'
|
||||
? 'text-xs inline-flex items-center gap-1'
|
||||
: 'text-xs inline-flex items-center gap-1 border-border/60 text-muted-foreground'"
|
||||
>
|
||||
{{ detail ? formatUsageStreamLabel(detail) : '标准' }}
|
||||
<span>{{ resolveUsageStreamLabelSegments(detail).client }}</span>
|
||||
<span class="opacity-60">→</span>
|
||||
<span>{{ resolveUsageStreamLabelSegments(detail).upstream }}</span>
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="detail"
|
||||
:variant="isUsageUpstreamStream(detail) ? 'secondary' : 'outline'"
|
||||
:class="isUsageUpstreamStream(detail)
|
||||
? 'text-xs'
|
||||
: 'text-xs border-border/60 text-muted-foreground'"
|
||||
>
|
||||
{{ formatUsageStreamLabel(detail) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
@@ -695,7 +708,11 @@ import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { formatShortRequestId } from '@/utils/format'
|
||||
import { log } from '@/utils/logger'
|
||||
import { getEffectiveInputTokens } from '../token-normalization'
|
||||
import { formatUsageStreamLabel } from '../utils/status'
|
||||
import {
|
||||
formatUsageStreamLabel,
|
||||
isUsageUpstreamStream,
|
||||
resolveUsageStreamLabelSegments,
|
||||
} from '../utils/status'
|
||||
|
||||
// 子组件
|
||||
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
|
||||
|
||||
@@ -232,6 +232,17 @@
|
||||
>
|
||||
取消
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="getStreamModeSegments(record).hasConversion"
|
||||
:variant="getStreamModeSegments(record).client === '流式' ? 'secondary' : 'outline'"
|
||||
:class="getStreamModeSegments(record).client === '流式'
|
||||
? 'whitespace-nowrap text-[10px] px-1.5 h-4 leading-4 inline-flex items-center gap-0.5'
|
||||
: 'whitespace-nowrap border-border/60 text-muted-foreground text-[10px] px-1.5 h-4 leading-4 inline-flex items-center gap-0.5'"
|
||||
>
|
||||
<span>{{ getStreamModeSegments(record).client }}</span>
|
||||
<span class="opacity-60">→</span>
|
||||
<span>{{ getStreamModeSegments(record).upstream }}</span>
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else
|
||||
:variant="getUpstreamStream(record) ? 'secondary' : 'outline'"
|
||||
@@ -529,6 +540,17 @@
|
||||
>
|
||||
已取消
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="getStreamModeSegments(record).hasConversion"
|
||||
:variant="getStreamModeSegments(record).client === '流式' ? 'secondary' : 'outline'"
|
||||
:class="getStreamModeSegments(record).client === '流式'
|
||||
? 'whitespace-nowrap inline-flex items-center gap-1'
|
||||
: 'whitespace-nowrap border-border/60 text-muted-foreground inline-flex items-center gap-1'"
|
||||
>
|
||||
<span>{{ getStreamModeSegments(record).client }}</span>
|
||||
<span class="opacity-60">→</span>
|
||||
<span>{{ getStreamModeSegments(record).upstream }}</span>
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else
|
||||
:variant="getUpstreamStream(record) ? 'secondary' : 'outline'"
|
||||
@@ -672,7 +694,8 @@ import {
|
||||
formatUsageStreamLabel,
|
||||
isUsageRecordFailed,
|
||||
isUsageUpstreamStream,
|
||||
resolveDisplayRequestStatus
|
||||
resolveDisplayRequestStatus,
|
||||
resolveUsageStreamLabelSegments
|
||||
} from '../utils/status'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
@@ -763,6 +786,10 @@ function getStreamModeLabel(record: UsageRecord): string {
|
||||
return formatUsageStreamLabel(record)
|
||||
}
|
||||
|
||||
function getStreamModeSegments(record: UsageRecord) {
|
||||
return resolveUsageStreamLabelSegments(record)
|
||||
}
|
||||
|
||||
function getUpstreamStream(record: UsageRecord): boolean {
|
||||
return isUsageUpstreamStream(record)
|
||||
}
|
||||
|
||||
@@ -384,6 +384,42 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
|
||||
// 如果需要保护状态,说明本地数据比后端更新,应该保留本地的所有实时更新字段
|
||||
if (protectStatus) {
|
||||
const recordUpstreamIsStream = typeof record.upstream_is_stream === 'boolean'
|
||||
? record.upstream_is_stream
|
||||
: typeof record.is_stream === 'boolean'
|
||||
? record.is_stream
|
||||
: undefined
|
||||
const existingUpstreamIsStream = typeof existing.upstream_is_stream === 'boolean'
|
||||
? existing.upstream_is_stream
|
||||
: typeof existing.is_stream === 'boolean'
|
||||
? existing.is_stream
|
||||
: undefined
|
||||
const upstreamIsStream = recordUpstreamIsStream ?? existingUpstreamIsStream ?? false
|
||||
|
||||
const recordClientRequestedStream = typeof record.client_requested_stream === 'boolean'
|
||||
? record.client_requested_stream
|
||||
: typeof record.client_is_stream === 'boolean'
|
||||
? record.client_is_stream
|
||||
: undefined
|
||||
const existingClientRequestedStream = typeof existing.client_requested_stream === 'boolean'
|
||||
? existing.client_requested_stream
|
||||
: typeof existing.client_is_stream === 'boolean'
|
||||
? existing.client_is_stream
|
||||
: undefined
|
||||
const clientRequestedStream = recordClientRequestedStream ?? existingClientRequestedStream
|
||||
|
||||
const recordClientIsStream = typeof record.client_is_stream === 'boolean'
|
||||
? record.client_is_stream
|
||||
: typeof record.client_requested_stream === 'boolean'
|
||||
? record.client_requested_stream
|
||||
: undefined
|
||||
const existingClientIsStream = typeof existing.client_is_stream === 'boolean'
|
||||
? existing.client_is_stream
|
||||
: typeof existing.client_requested_stream === 'boolean'
|
||||
? existing.client_requested_stream
|
||||
: undefined
|
||||
const clientIsStream = recordClientIsStream ?? existingClientIsStream ?? clientRequestedStream
|
||||
|
||||
return {
|
||||
...record,
|
||||
// 保留本地的状态和所有通过轮询更新的字段
|
||||
@@ -404,6 +440,10 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
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,
|
||||
is_stream: upstreamIsStream,
|
||||
upstream_is_stream: upstreamIsStream,
|
||||
client_requested_stream: clientRequestedStream,
|
||||
client_is_stream: clientIsStream,
|
||||
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,
|
||||
|
||||
@@ -70,4 +70,43 @@ describe('Conversation stream compatibility', () => {
|
||||
content: 'Hello from CLI stream',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders legacy OpenAI CLI outtext delta alias from stored usage records', () => {
|
||||
const requestBody = {
|
||||
model: 'gpt-5.4',
|
||||
stream: true,
|
||||
input: 'Hello',
|
||||
}
|
||||
const rawSse = [
|
||||
'event: response.created',
|
||||
'data: {"type":"response.created","response":{"id":"resp_legacy_123","object":"response","model":"gpt-5.4","status":"in_progress"}}',
|
||||
'',
|
||||
'event: response.outtext.delta',
|
||||
'data: {"type":"response.outtext.delta","delta":"Hello from legacy alias"}',
|
||||
'',
|
||||
'event: response.completed',
|
||||
'data: {"type":"response.completed","response":{"id":"resp_legacy_123","object":"response","model":"gpt-5.4","status":"completed","output":[]}}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const rendered = renderResponse(rawSse, requestBody, 'openai:cli')
|
||||
expect(rendered.error).toBeUndefined()
|
||||
expect(rendered.isStream).toBe(true)
|
||||
expect(rendered.blocks[0]).toMatchObject({
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
})
|
||||
|
||||
const firstBlock = rendered.blocks[0]
|
||||
if (!firstBlock || firstBlock.type !== 'message') {
|
||||
throw new Error('expected first render block to be message')
|
||||
}
|
||||
|
||||
expect(firstBlock.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
content: 'Hello from legacy alias',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -525,7 +525,8 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
}
|
||||
|
||||
// 处理文本增量: response.output_text.delta
|
||||
if (eventType === 'response.output_text.delta') {
|
||||
// 兼容旧别名 response.outtext.delta
|
||||
if (eventType === 'response.output_text.delta' || eventType === 'response.outtext.delta') {
|
||||
const delta = chunk.delta
|
||||
if (typeof delta === 'string') {
|
||||
textParts.push(delta)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { UsageRecord } from '../../types'
|
||||
import { syncUsageRecordStreamResolution } from '../recordSync'
|
||||
|
||||
function buildUsageRecord(overrides: Partial<UsageRecord> = {}): UsageRecord {
|
||||
return {
|
||||
id: 'usage-1',
|
||||
model: 'gpt-5.4',
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
total_tokens: 15,
|
||||
cost: 0.001,
|
||||
is_stream: true,
|
||||
upstream_is_stream: true,
|
||||
client_requested_stream: true,
|
||||
client_is_stream: true,
|
||||
created_at: '2026-04-23T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('syncUsageRecordStreamResolution', () => {
|
||||
it('updates the matching row with resolved client and upstream stream modes', () => {
|
||||
const records = [
|
||||
buildUsageRecord(),
|
||||
buildUsageRecord({ id: 'usage-2', model: 'gpt-4.1' }),
|
||||
]
|
||||
|
||||
const nextRecords = syncUsageRecordStreamResolution(records, {
|
||||
id: 'usage-1',
|
||||
is_stream: true,
|
||||
upstream_is_stream: true,
|
||||
client_requested_stream: false,
|
||||
client_is_stream: false,
|
||||
})
|
||||
|
||||
expect(nextRecords[0]).toMatchObject({
|
||||
id: 'usage-1',
|
||||
is_stream: true,
|
||||
upstream_is_stream: true,
|
||||
client_requested_stream: false,
|
||||
client_is_stream: false,
|
||||
})
|
||||
expect(nextRecords[1]).toBe(records[1])
|
||||
})
|
||||
|
||||
it('returns the original array when no matching row exists', () => {
|
||||
const records = [buildUsageRecord()]
|
||||
|
||||
const nextRecords = syncUsageRecordStreamResolution(records, {
|
||||
id: 'missing',
|
||||
is_stream: false,
|
||||
upstream_is_stream: false,
|
||||
client_requested_stream: false,
|
||||
client_is_stream: false,
|
||||
})
|
||||
|
||||
expect(nextRecords).toBe(records)
|
||||
})
|
||||
})
|
||||
@@ -103,16 +103,54 @@ describe('usage status helpers', () => {
|
||||
expect(formatUsageStreamLabel(buildUsageRecord({
|
||||
is_stream: true,
|
||||
upstream_is_stream: true,
|
||||
client_requested_stream: true,
|
||||
client_requested_stream: false,
|
||||
client_is_stream: false,
|
||||
}))).toBe('标准 -> 流式')
|
||||
}))).toBe('标准->流式')
|
||||
})
|
||||
|
||||
it('falls back to legacy stream fields when symmetric aliases are absent', () => {
|
||||
expect(formatUsageStreamLabel(buildUsageRecord({
|
||||
is_stream: true,
|
||||
client_requested_stream: false,
|
||||
}))).toBe('标准 -> 流式')
|
||||
}))).toBe('标准->流式')
|
||||
})
|
||||
|
||||
it('defaults OpenAI and Claude requests to non-stream when client flags are absent', () => {
|
||||
expect(formatUsageStreamLabel(buildUsageRecord({
|
||||
api_format: 'openai:cli',
|
||||
is_stream: true,
|
||||
upstream_is_stream: true,
|
||||
client_requested_stream: undefined,
|
||||
client_is_stream: undefined,
|
||||
}))).toBe('标准->流式')
|
||||
|
||||
expect(formatUsageStreamLabel(buildUsageRecord({
|
||||
api_format: 'claude:chat',
|
||||
is_stream: false,
|
||||
upstream_is_stream: false,
|
||||
client_requested_stream: undefined,
|
||||
client_is_stream: undefined,
|
||||
}))).toBe('标准')
|
||||
})
|
||||
|
||||
it('keeps upstream fallback for formats without a default non-stream convention', () => {
|
||||
expect(formatUsageStreamLabel(buildUsageRecord({
|
||||
api_format: 'gemini:cli',
|
||||
is_stream: true,
|
||||
upstream_is_stream: true,
|
||||
client_requested_stream: undefined,
|
||||
client_is_stream: undefined,
|
||||
}))).toBe('流式')
|
||||
})
|
||||
|
||||
it('prefers client_requested_stream over stale client_is_stream when they disagree', () => {
|
||||
expect(formatUsageStreamLabel(buildUsageRecord({
|
||||
api_format: 'openai:cli',
|
||||
is_stream: true,
|
||||
upstream_is_stream: true,
|
||||
client_requested_stream: false,
|
||||
client_is_stream: true,
|
||||
}))).toBe('标准->流式')
|
||||
})
|
||||
|
||||
it('uses status code only as a last fallback for timeline status', () => {
|
||||
|
||||
36
frontend/src/features/usage/utils/recordSync.ts
Normal file
36
frontend/src/features/usage/utils/recordSync.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { UsageRecord } from '../types'
|
||||
|
||||
export type UsageRecordStreamResolution = Pick<
|
||||
UsageRecord,
|
||||
'id' | 'is_stream' | 'upstream_is_stream' | 'client_requested_stream' | 'client_is_stream'
|
||||
>
|
||||
|
||||
export function syncUsageRecordStreamResolution(
|
||||
records: UsageRecord[],
|
||||
resolved: UsageRecordStreamResolution
|
||||
): UsageRecord[] {
|
||||
let changed = false
|
||||
|
||||
const nextRecords = records.map((record) => {
|
||||
if (record.id !== resolved.id) {
|
||||
return record
|
||||
}
|
||||
|
||||
changed = true
|
||||
return {
|
||||
...record,
|
||||
is_stream: resolved.is_stream,
|
||||
upstream_is_stream: typeof resolved.upstream_is_stream === 'boolean'
|
||||
? resolved.upstream_is_stream
|
||||
: resolved.is_stream,
|
||||
client_requested_stream: typeof resolved.client_requested_stream === 'boolean'
|
||||
? resolved.client_requested_stream
|
||||
: resolved.client_is_stream,
|
||||
client_is_stream: typeof resolved.client_is_stream === 'boolean'
|
||||
? resolved.client_is_stream
|
||||
: resolved.client_requested_stream,
|
||||
}
|
||||
})
|
||||
|
||||
return changed ? nextRecords : records
|
||||
}
|
||||
@@ -20,18 +20,29 @@ export function hasUsageFallback(
|
||||
export function resolveUsageStreamModes(
|
||||
record: Pick<
|
||||
UsageRecord,
|
||||
'is_stream' | 'upstream_is_stream' | 'client_requested_stream' | 'client_is_stream'
|
||||
| 'is_stream'
|
||||
| 'upstream_is_stream'
|
||||
| 'client_requested_stream'
|
||||
| 'client_is_stream'
|
||||
| 'api_format'
|
||||
| 'endpoint_api_format'
|
||||
>
|
||||
): { clientRequestedStream: boolean, upstreamStream: boolean } {
|
||||
const upstreamStream = typeof record.upstream_is_stream === 'boolean'
|
||||
? record.upstream_is_stream
|
||||
: record.is_stream
|
||||
|
||||
const streamFormat = normalizeUsageStreamApiFormat(
|
||||
record.api_format ?? record.endpoint_api_format
|
||||
)
|
||||
|
||||
return {
|
||||
clientRequestedStream: typeof record.client_is_stream === 'boolean'
|
||||
? record.client_is_stream
|
||||
: typeof record.client_requested_stream === 'boolean'
|
||||
? record.client_requested_stream
|
||||
clientRequestedStream: typeof record.client_requested_stream === 'boolean'
|
||||
? record.client_requested_stream
|
||||
: typeof record.client_is_stream === 'boolean'
|
||||
? record.client_is_stream
|
||||
: usageApiFormatDefaultsToNonStream(streamFormat)
|
||||
? false
|
||||
: upstreamStream,
|
||||
upstreamStream
|
||||
}
|
||||
@@ -40,7 +51,12 @@ export function resolveUsageStreamModes(
|
||||
export function isUsageUpstreamStream(
|
||||
record: Pick<
|
||||
UsageRecord,
|
||||
'is_stream' | 'upstream_is_stream' | 'client_requested_stream' | 'client_is_stream'
|
||||
| 'is_stream'
|
||||
| 'upstream_is_stream'
|
||||
| 'client_requested_stream'
|
||||
| 'client_is_stream'
|
||||
| 'api_format'
|
||||
| 'endpoint_api_format'
|
||||
>
|
||||
): boolean {
|
||||
return resolveUsageStreamModes(record).upstreamStream
|
||||
@@ -49,7 +65,12 @@ export function isUsageUpstreamStream(
|
||||
export function formatUsageStreamLabel(
|
||||
record: Pick<
|
||||
UsageRecord,
|
||||
'is_stream' | 'upstream_is_stream' | 'client_requested_stream' | 'client_is_stream'
|
||||
| 'is_stream'
|
||||
| 'upstream_is_stream'
|
||||
| 'client_requested_stream'
|
||||
| 'client_is_stream'
|
||||
| 'api_format'
|
||||
| 'endpoint_api_format'
|
||||
>
|
||||
): string {
|
||||
const { clientRequestedStream, upstreamStream } = resolveUsageStreamModes(record)
|
||||
@@ -60,7 +81,60 @@ export function formatUsageStreamLabel(
|
||||
return clientLabel
|
||||
}
|
||||
|
||||
return `${clientLabel} -> ${upstreamLabel}`
|
||||
return `${clientLabel}->${upstreamLabel}`
|
||||
}
|
||||
|
||||
export interface UsageStreamLabelSegments {
|
||||
client: '流式' | '标准'
|
||||
upstream: '流式' | '标准'
|
||||
hasConversion: boolean
|
||||
}
|
||||
|
||||
export function resolveUsageStreamLabelSegments(
|
||||
record: Pick<
|
||||
UsageRecord,
|
||||
| 'is_stream'
|
||||
| 'upstream_is_stream'
|
||||
| 'client_requested_stream'
|
||||
| 'client_is_stream'
|
||||
| 'api_format'
|
||||
| 'endpoint_api_format'
|
||||
>
|
||||
): UsageStreamLabelSegments {
|
||||
const { clientRequestedStream, upstreamStream } = resolveUsageStreamModes(record)
|
||||
return {
|
||||
client: clientRequestedStream ? '流式' : '标准',
|
||||
upstream: upstreamStream ? '流式' : '标准',
|
||||
hasConversion: clientRequestedStream !== upstreamStream,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUsageStreamApiFormat(value: string | null | undefined): string {
|
||||
const normalized = value?.trim().toLowerCase().replaceAll('_', ':') ?? ''
|
||||
switch (normalized) {
|
||||
case 'openai':
|
||||
return 'openai:chat'
|
||||
case 'claude':
|
||||
return 'claude:chat'
|
||||
case 'gemini':
|
||||
return 'gemini:chat'
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
function usageApiFormatDefaultsToNonStream(apiFormat: string): boolean {
|
||||
switch (apiFormat) {
|
||||
case 'openai:chat':
|
||||
case 'openai:cli':
|
||||
case 'openai:compact':
|
||||
case 'openai:image':
|
||||
case 'claude:chat':
|
||||
case 'claude:cli':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function hasTerminalSuccessStatusCode(
|
||||
|
||||
@@ -449,12 +449,14 @@ async function pollActiveRequests() {
|
||||
record.is_stream = update.upstream_is_stream
|
||||
} else if (typeof update.is_stream === 'boolean') {
|
||||
record.is_stream = update.is_stream
|
||||
record.upstream_is_stream = update.is_stream
|
||||
}
|
||||
if (typeof update.client_is_stream === 'boolean') {
|
||||
record.client_is_stream = update.client_is_stream
|
||||
record.client_requested_stream = update.client_is_stream
|
||||
} else if (typeof update.client_requested_stream === 'boolean') {
|
||||
record.client_requested_stream = update.client_requested_stream
|
||||
record.client_is_stream = update.client_requested_stream
|
||||
}
|
||||
// API 格式/格式转换:streaming 时已可确定,轮询时同步更新
|
||||
if (update.api_format != null) record.api_format = update.api_format
|
||||
|
||||
Reference in New Issue
Block a user