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:07:24 +08:00
parent a9d10163af
commit 5d710d9d10
16 changed files with 1388 additions and 204 deletions
@@ -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'])
})
})
@@ -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()