mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat(test,quota,failover): 模型并发测试、统一配额读取器与故障转移取消支持
- 新增 QuotaReader 抽象层,统一 Codex/Kiro/Antigravity 配额解析逻辑,
替换 pool/routes.py 中分散的配额构建函数
- 模型测试支持并发执行多候选,前端新增 useModelTest composable 统一
ModelsTab 和 ModelMappingTab 的测试逻辑
- ModelTestDialog 增加结果概览摘要、超长结果折叠、端点列和新状态支持,
删除已合并的 TestResultDialog
- FailoverEngine 新增客户端断开检测,支持取消剩余候选并标记记录
- 刷新配额改为分批执行,直连测试候选按可用性排序
- 修复 error 判断从 "error" in dict 改为 dict.get("error") 避免误判
This commit is contained in:
183
frontend/src/composables/useModelTest.ts
Normal file
183
frontend/src/composables/useModelTest.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { useToast } from './useToast'
|
||||
import {
|
||||
testModelFailover,
|
||||
type TestModelFailoverResponse,
|
||||
} from '@/api/endpoints/providers'
|
||||
import { requestTraceApi, type RequestTrace } from '@/api/requestTrace'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
|
||||
export interface StartTestParams {
|
||||
mode: 'global' | 'direct'
|
||||
modelName: string
|
||||
displayLabel: string
|
||||
apiFormat?: string
|
||||
endpointId?: string
|
||||
message?: string
|
||||
concurrency?: number
|
||||
onSuccess?: (result: TestModelFailoverResponse) => void
|
||||
/** Return `true` to indicate the failure has been handled; otherwise the composable sets `testResult`. */
|
||||
onFailure?: (result: TestModelFailoverResponse) => boolean | void
|
||||
/** Return `true` to indicate the error has been handled; otherwise a toast is shown and state is reset. */
|
||||
onError?: (err: unknown) => boolean | void
|
||||
}
|
||||
|
||||
export interface UseModelTestOptions {
|
||||
providerId: () => string
|
||||
pollInterval?: number
|
||||
}
|
||||
|
||||
export function useModelTest(options: UseModelTestOptions) {
|
||||
const { providerId, pollInterval = 800 } = options
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
|
||||
const testing = ref(false)
|
||||
const testMode = ref<'global' | 'direct'>('global')
|
||||
const testResult = ref<TestModelFailoverResponse | null>(null)
|
||||
const testTrace = ref<RequestTrace | null>(null)
|
||||
const requestId = ref<string | null>(null)
|
||||
const dialogOpen = ref(false)
|
||||
|
||||
let tracePollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let tracePollToken = 0
|
||||
let activeAbortController: AbortController | null = null
|
||||
|
||||
function buildTestRequestId(): string {
|
||||
const randomUUID = globalThis.crypto?.randomUUID?.bind(globalThis.crypto)
|
||||
if (randomUUID) {
|
||||
return `provider-test-${randomUUID().replace(/-/g, '').slice(0, 20)}`
|
||||
}
|
||||
return `provider-test-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
async function pollTestTrace(reqId: string, token: number) {
|
||||
try {
|
||||
const trace = await requestTraceApi.getRequestTrace(reqId, { attemptedOnly: false })
|
||||
if (tracePollToken !== token || requestId.value !== reqId) return
|
||||
testTrace.value = trace
|
||||
} catch (err: unknown) {
|
||||
if (isAxiosError(err) && err.response?.status === 404) return
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling(opts: { clearState?: boolean } = {}) {
|
||||
tracePollToken += 1
|
||||
if (tracePollTimer) {
|
||||
clearInterval(tracePollTimer)
|
||||
tracePollTimer = null
|
||||
}
|
||||
if (opts.clearState !== false) {
|
||||
requestId.value = null
|
||||
testTrace.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(reqId: string) {
|
||||
stopPolling()
|
||||
requestId.value = reqId
|
||||
testTrace.value = null
|
||||
const token = ++tracePollToken
|
||||
void pollTestTrace(reqId, token)
|
||||
tracePollTimer = setInterval(() => {
|
||||
void pollTestTrace(reqId, token)
|
||||
}, pollInterval)
|
||||
}
|
||||
|
||||
function abortActiveRequest() {
|
||||
if (!activeAbortController) return
|
||||
activeAbortController.abort()
|
||||
activeAbortController = null
|
||||
}
|
||||
|
||||
function isRequestCancelled(err: unknown): boolean {
|
||||
if (isAxiosError(err)) {
|
||||
return err.code === 'ERR_CANCELED'
|
||||
}
|
||||
return err instanceof DOMException && err.name === 'AbortError'
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
abortActiveRequest()
|
||||
stopPolling()
|
||||
dialogOpen.value = false
|
||||
testResult.value = null
|
||||
}
|
||||
|
||||
async function startTest(params: StartTestParams) {
|
||||
abortActiveRequest()
|
||||
testing.value = true
|
||||
testMode.value = params.mode
|
||||
dialogOpen.value = true
|
||||
testResult.value = null
|
||||
|
||||
const abortController = new AbortController()
|
||||
activeAbortController = abortController
|
||||
const reqId = buildTestRequestId()
|
||||
startPolling(reqId)
|
||||
|
||||
try {
|
||||
const result = await testModelFailover({
|
||||
provider_id: providerId(),
|
||||
mode: params.mode,
|
||||
model_name: params.modelName,
|
||||
api_format: params.apiFormat,
|
||||
endpoint_id: params.endpointId,
|
||||
message: params.message ?? 'hello',
|
||||
request_id: reqId,
|
||||
concurrency: params.concurrency,
|
||||
}, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
const successAttempt = result.attempts.find(a => a.status === 'success')
|
||||
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
|
||||
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== params.modelName
|
||||
? ` -> ${successAttempt.effective_model}`
|
||||
: ''
|
||||
params.onSuccess?.(result)
|
||||
showSuccess(`${params.displayLabel}${mapped} 测试成功${latency}`)
|
||||
resetState()
|
||||
return
|
||||
}
|
||||
|
||||
stopPolling({ clearState: false })
|
||||
const handled = params.onFailure?.(result)
|
||||
if (!handled) {
|
||||
testResult.value = result
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (isRequestCancelled(err)) {
|
||||
return
|
||||
}
|
||||
stopPolling()
|
||||
const handled = params.onError?.(err)
|
||||
if (!handled) {
|
||||
showError(`模型测试失败: ${parseApiError(err, '测试请求失败')}`)
|
||||
resetState()
|
||||
}
|
||||
} finally {
|
||||
if (activeAbortController === abortController) {
|
||||
activeAbortController = null
|
||||
}
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
return {
|
||||
testing,
|
||||
testMode,
|
||||
testResult,
|
||||
testTrace,
|
||||
requestId,
|
||||
dialogOpen,
|
||||
startTest,
|
||||
resetState,
|
||||
stopPolling,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user