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