import { ref, onBeforeUnmount } from 'vue' import { isAxiosError } from 'axios' import { useToast } from './useToast' import { testModel, testModelFailover, type TestCandidateSummary, type TestAttemptDetail, type TestModelResponse, type TestModelFailoverResponse, } from '@/api/endpoints/providers' import { requestTraceApi, type RequestTrace } from '@/api/requestTrace' import { parseApiError } from '@/utils/errorParser' export interface StartTestParams { mode: 'global' | 'direct' | 'pool' modelName: string displayLabel: string apiFormat?: string endpointId?: string endpointBaseUrl?: string message?: string apiKeyIds?: string[] applyModelMapping?: boolean mappedModelName?: string requestHeaders?: Record requestBody?: Record 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 LOCAL_FAILOVER_UNCONFIGURED_MESSAGE = 'Rust local provider-query failover simulation is not configured' const testing = ref(false) const testMode = ref<'global' | 'direct' | 'pool'>('global') const testResult = ref(null) const testTrace = ref(null) const requestId = ref(null) const dialogOpen = ref(false) let tracePollTimer: ReturnType | 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)}` } function resultHasTraceContext(result: TestModelFailoverResponse): boolean { if (result.success) return true if (Array.isArray(result.attempts) && result.attempts.length > 0) return true if (typeof result.total_attempts === 'number' && result.total_attempts > 0) return true if (typeof result.total_candidates === 'number' && result.total_candidates > 0) return true return false } function normalizeDirectTestResult( params: StartTestParams, result: TestModelResponse, ): TestModelFailoverResponse { const responsePayload = result.data?.response const failureMessage = typeof result.error === 'string' && result.error.trim() ? result.error.trim() : ( typeof responsePayload?.error === 'string' ? responsePayload.error : responsePayload?.error?.message ) || null const syntheticAttempt: TestAttemptDetail = { candidate_index: 0, endpoint_api_format: params.apiFormat || '-', endpoint_base_url: params.endpointBaseUrl || '', key_name: null, key_id: '', auth_type: '', effective_model: result.model || params.modelName, status: result.success ? 'success' : 'failed', skip_reason: null, error_message: result.success ? null : failureMessage, status_code: responsePayload?.status_code ?? null, latency_ms: null, request_url: null, request_headers: (params.requestHeaders as Record | undefined) ?? null, request_body: params.requestBody ?? null, response_headers: null, response_body: (responsePayload as Record | undefined) ?? (result.data as Record | undefined) ?? null, } const attempts = Array.isArray(result.attempts) && result.attempts.length > 0 ? result.attempts : [syntheticAttempt] const totalCandidates = typeof result.total_candidates === 'number' ? result.total_candidates : attempts.length const totalAttempts = typeof result.total_attempts === 'number' ? result.total_attempts : attempts.filter(attempt => !['skipped', 'available', 'unused'].includes(attempt.status)).length const syntheticSummary: TestCandidateSummary = result.candidate_summary ?? { total_candidates: totalCandidates, attempted: totalAttempts, success: result.success ? 1 : 0, failed: result.success ? 0 : 1, skipped: 0, unused: result.success ? Math.max(0, totalCandidates - totalAttempts) : 0, pending: 0, available: 0, completed: result.success ? totalCandidates : totalAttempts, stop_reason: result.success ? 'first_success' : 'exhausted', winning_candidate_index: result.success ? 0 : null, winning_key_name: null, winning_key_id: '', winning_auth_type: '', winning_effective_model: result.success ? (result.model || params.modelName) : null, winning_endpoint_api_format: params.apiFormat || null, winning_endpoint_base_url: params.endpointBaseUrl || null, winning_latency_ms: null, winning_status_code: responsePayload?.status_code ?? null, } return { success: result.success, model: result.model || params.modelName, provider: result.provider || { id: providerId(), name: providerId() }, attempts, total_candidates: totalCandidates, total_attempts: totalAttempts, candidate_summary: syntheticSummary, data: (result.data as Record | undefined) ?? null, error: failureMessage, } } async function runDirectTest( params: StartTestParams, reqId: string, signal?: AbortSignal, ): Promise { const message = normalizedMessage(params.message) const apiKeyIds = normalizedApiKeyIds(params.apiKeyIds) return normalizeDirectTestResult(params, await testModel({ provider_id: providerId(), model_name: params.modelName, mode: params.mode, api_format: params.apiFormat, endpoint_id: params.endpointId, ...(apiKeyIds ? { api_key_ids: apiKeyIds } : {}), ...(message ? { message } : {}), ...(typeof params.applyModelMapping === 'boolean' ? { apply_model_mapping: params.applyModelMapping } : {}), ...(params.mappedModelName ? { mapped_model_name: params.mappedModelName } : {}), ...(params.requestHeaders ? { request_headers: params.requestHeaders } : {}), ...(params.requestBody ? { request_body: params.requestBody } : {}), request_id: reqId, }, { signal, })) } function normalizedMessage(message?: string): string | undefined { return typeof message === 'string' && message.trim() ? message.trim() : undefined } function normalizedApiKeyIds(apiKeyIds?: string[]): string[] | undefined { const ids = Array.isArray(apiKeyIds) ? apiKeyIds.map(item => item.trim()).filter(Boolean) : [] return ids.length > 0 ? [...new Set(ids)] : undefined } 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 } } async function refreshTraceSnapshot(reqId: string) { try { const trace = await requestTraceApi.getRequestTrace(reqId, { attemptedOnly: false }) if (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 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 message = normalizedMessage(params.message) const apiKeyIds = normalizedApiKeyIds(params.apiKeyIds) let result = params.mode === 'direct' ? await runDirectTest(params, reqId, abortController.signal) : await testModelFailover({ provider_id: providerId(), mode: params.mode, model_name: params.modelName, failover_models: [params.modelName], ...(apiKeyIds ? { api_key_ids: apiKeyIds } : {}), api_format: params.apiFormat, endpoint_id: params.endpointId, ...(message ? { message } : {}), ...(typeof params.applyModelMapping === 'boolean' ? { apply_model_mapping: params.applyModelMapping } : {}), ...(params.mappedModelName ? { mapped_model_name: params.mappedModelName } : {}), ...(params.requestHeaders ? { request_headers: params.requestHeaders } : {}), ...(params.requestBody ? { request_body: params.requestBody } : {}), request_id: reqId, }, { signal: abortController.signal, }) if ( params.mode === 'global' && !result.success && result.error === LOCAL_FAILOVER_UNCONFIGURED_MESSAGE ) { result = await runDirectTest(params, reqId, abortController.signal) } const keepTraceContext = resultHasTraceContext(result) if (result.success) { if (keepTraceContext) { await refreshTraceSnapshot(reqId) stopPolling({ clearState: false }) } else { stopPolling() } testResult.value = result 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}`) return } if (keepTraceContext) { await refreshTraceSnapshot(reqId) stopPolling({ clearState: false }) } else { stopPolling() } 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, } }