feat(rust): 支持 image 同步转流式 SSE、free_team_first 调度预设及账户错误自动重试

- openai:image 同步响应桥接为流式 SSE(image_generation.completed / image_edit.completed 事件)
- image 请求解析与前置校验移除模型白名单,支持任意自定义模型名
- 调度器新增 free_team_first 预设(mode: both / free_only / team_only)
- pool config 解析重构:新增 POOL_ALLOWED_SCHEDULING_PRESETS 白名单,规范化 mode 字段
- 错误分类器新增账户/账单错误模式集,升级为 RetryUpstreamFailure 而非 StopSemanticClientError
- 修复 stream execution 中上游 headers 与输出 headers 混用导致 content-type 判断错误的问题
- codex image 工具始终写入 action 字段(generate/edit),仅 generate 操作填充默认 size/quality
- 前端:pool 节点组只展示实际执行过的候选节点,全部 skipped 时折叠为最后一个节点
- Redis 测试就绪检测从 TCP 连接改为 PING/PONG 协议验证
This commit is contained in:
fawney19
2026-04-24 10:05:55 +08:00
parent a9d10163af
commit 5d710d9d10
16 changed files with 1388 additions and 204 deletions

View File

@@ -496,6 +496,7 @@ import { parseApiError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { resolveTimelineFinalStatus } from '../utils/status'
import {
buildPoolGroupVisibleAttempts,
buildPoolParticipatedCandidates,
extractPoolGroupId,
makeAttemptKey,
@@ -508,7 +509,7 @@ interface NodeGroup {
providerName: string
primary: CandidateRecord
primaryStatus: string
allAttempts: CandidateRecord[] // 所有尝试(包括首次和重试
allAttempts: CandidateRecord[] // 当前展示的尝试(含主节点
retryCount: number
totalLatency: number // 所有尝试的总延迟
startIndex: number
@@ -867,15 +868,19 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
})
if (attempts.length === 0) continue
const poolPrimaryStatus = attempts.reduce((best, current) => {
const visibleAttempts = buildPoolGroupVisibleAttempts(attempts)
if (visibleAttempts.length === 0) continue
const poolPrimaryStatus = visibleAttempts.reduce((best, current) => {
const bestPriority = STATUS_PRIORITY[best] ?? 0
const currentStatus = getDisplayStatus(current)
const currentPriority = STATUS_PRIORITY[currentStatus] ?? 0
return currentPriority > bestPriority ? currentStatus : best
}, getDisplayStatus(attempts[0]))
}, getDisplayStatus(visibleAttempts[0]))
const successAttempt = attempts.find((item) => item.status === 'success')
const poolPrimary = successAttempt || attempts[attempts.length - 1] || attempts[0]
const successAttempt = visibleAttempts.find((item) => item.status === 'success')
const poolPrimary =
successAttempt || visibleAttempts[visibleAttempts.length - 1] || visibleAttempts[0]
const startIndex = Math.min(...attempts.map(item => item.candidate_index))
const endIndex = Math.max(...attempts.map(item => item.candidate_index))
@@ -884,12 +889,12 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
providerName: getProviderDisplayName(poolPrimary, { allowAuthTypeFallback: false }),
primary: poolPrimary,
primaryStatus: poolPrimaryStatus,
allAttempts: attempts,
retryCount: Math.max(0, attempts.length - 1),
totalLatency: attempts.reduce((sum, item) => sum + (item.latency_ms || 0), 0),
allAttempts: visibleAttempts,
retryCount: Math.max(0, visibleAttempts.length - 1),
totalLatency: visibleAttempts.reduce((sum, item) => sum + (item.latency_ms || 0), 0),
startIndex,
endIndex,
hasConversion: attempts.some((item) => item.extra_data?.needs_conversion === true),
hasConversion: visibleAttempts.some((item) => item.extra_data?.needs_conversion === true),
providerApiFormat: null,
isPoolGroup: true,
})

View File

@@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest'
import type { CandidateRecord } from '@/api/requestTrace'
import {
buildPoolAttemptCandidatesFromAudit,
buildPoolGroupVisibleAttempts,
buildPoolParticipatedCandidates,
isAttemptedCandidate,
} from '@/features/usage/utils/poolTrace'
function buildCandidate(
@@ -170,4 +172,61 @@ describe('poolTrace', () => {
expect(attempts.map(item => item.key_id)).toEqual(['key-success', 'key-skipped'])
expect(attempts[1].extra_data?.pool_group_id).toBe('provider-1')
})
it('treats only real execution statuses as attempted', () => {
expect(isAttemptedCandidate(buildCandidate({ status: 'success' }))).toBe(true)
expect(isAttemptedCandidate(buildCandidate({ status: 'failed' }))).toBe(true)
expect(isAttemptedCandidate(buildCandidate({ status: 'cancelled' }))).toBe(true)
expect(isAttemptedCandidate(buildCandidate({ status: 'streaming' }))).toBe(true)
expect(isAttemptedCandidate(buildCandidate({ status: 'stream_interrupted' }))).toBe(true)
expect(isAttemptedCandidate(buildCandidate({ status: 'pending' }))).toBe(false)
expect(isAttemptedCandidate(buildCandidate({
status: 'pending',
started_at: '2026-04-24T12:00:00.000Z',
}))).toBe(true)
expect(isAttemptedCandidate(buildCandidate({ status: 'skipped' }))).toBe(false)
expect(isAttemptedCandidate(buildCandidate({ status: 'available' }))).toBe(false)
expect(isAttemptedCandidate(buildCandidate({ status: 'unused' }))).toBe(false)
})
it('shows only attempted pool children when attempted nodes exist', () => {
const attempts = buildPoolGroupVisibleAttempts([
buildCandidate({
id: 'cand-skipped',
candidate_index: 0,
status: 'skipped',
}),
buildCandidate({
id: 'cand-failed',
candidate_index: 1,
status: 'failed',
started_at: '2026-04-24T12:00:00.000Z',
}),
buildCandidate({
id: 'cand-success',
candidate_index: 2,
status: 'success',
started_at: '2026-04-24T12:00:01.000Z',
}),
])
expect(attempts.map(item => item.id)).toEqual(['cand-failed', 'cand-success'])
})
it('collapses all-skipped pool nodes to a single provider node', () => {
const attempts = buildPoolGroupVisibleAttempts([
buildCandidate({
id: 'cand-skipped-1',
candidate_index: 0,
status: 'skipped',
}),
buildCandidate({
id: 'cand-skipped-2',
candidate_index: 1,
status: 'skipped',
}),
])
expect(attempts.map(item => item.id)).toEqual(['cand-skipped-2'])
})
})

View File

@@ -45,6 +45,39 @@ export const isPoolParticipatedCandidate = (candidate: CandidateRecord): boolean
return true
}
export const isAttemptedCandidate = (
candidate: Pick<CandidateRecord, 'status' | 'started_at'>,
): boolean => {
switch (candidate.status) {
case 'streaming':
case 'success':
case 'failed':
case 'cancelled':
case 'stream_interrupted':
return true
case 'pending':
return Boolean(candidate.started_at)
case 'available':
case 'unused':
case 'skipped':
default:
return false
}
}
export function buildPoolGroupVisibleAttempts(
attempts: CandidateRecord[],
): CandidateRecord[] {
if (attempts.length === 0) return []
const attempted = attempts.filter(isAttemptedCandidate)
if (attempted.length > 0) {
return attempted
}
return [attempts[attempts.length - 1]]
}
export const parseTimelineStatus = (value: unknown): CandidateRecord['status'] | null => {
if (typeof value !== 'string') return null
const normalized = value.trim().toLowerCase()