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:
fawney19
2026-04-23 21:53:50 +08:00
parent 40282c3447
commit 342d4a268c
40 changed files with 3638 additions and 97 deletions
@@ -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', () => {
@@ -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
}
+82 -8
View File
@@ -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(