mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user