Merge PR #672: 支持账号批量配置与可用模型管理

This commit is contained in:
elky
2026-07-18 22:06:32 +08:00
37 changed files with 2612 additions and 377 deletions
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const adminApiMocks = vi.hoisted(() => ({
queryProviderModels: vi.fn(),
queryProviderModelsForKeys: vi.fn(),
}))
vi.mock('@/api/admin', () => ({ adminApi: adminApiMocks }))
import { useUpstreamModelsCache } from '../useUpstreamModelsCache'
function response(modelId: string) {
return {
success: true,
data: { models: [{ id: modelId }] },
provider: { id: 'provider-1', name: 'Provider', display_name: 'Provider' },
}
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => { resolve = done })
return { promise, resolve }
}
describe('useUpstreamModelsCache', () => {
beforeEach(() => {
adminApiMocks.queryProviderModels.mockReset()
adminApiMocks.queryProviderModelsForKeys.mockReset()
})
it('deduplicates equivalent multi-key model requests', async () => {
const request = deferred<ReturnType<typeof response>>()
adminApiMocks.queryProviderModelsForKeys.mockReturnValue(request.promise)
const { fetchModelsForKeys } = useUpstreamModelsCache()
const first = fetchModelsForKeys('provider-1', ['key-b', 'key-a', 'key-a'])
const second = fetchModelsForKeys('provider-1', ['key-a', 'key-b'])
expect(adminApiMocks.queryProviderModelsForKeys).toHaveBeenCalledTimes(1)
expect(adminApiMocks.queryProviderModelsForKeys).toHaveBeenCalledWith(
'provider-1',
['key-a', 'key-b'],
false,
)
request.resolve(response('gpt-5.6-sol'))
await expect(first).resolves.toMatchObject({ models: [{ id: 'gpt-5.6-sol' }] })
await expect(second).resolves.toMatchObject({ models: [{ id: 'gpt-5.6-sol' }] })
})
it('keeps the loading state owned by the latest forced request', async () => {
const firstRequest = deferred<ReturnType<typeof response>>()
const forcedRequest = deferred<ReturnType<typeof response>>()
adminApiMocks.queryProviderModels
.mockReturnValueOnce(firstRequest.promise)
.mockReturnValueOnce(forcedRequest.promise)
const { fetchModels, isLoading } = useUpstreamModelsCache()
const first = fetchModels('provider-1', 'key-a')
const forced = fetchModels('provider-1', 'key-a', true)
expect(isLoading('provider-1', 'key-a')).toBe(true)
firstRequest.resolve(response('gpt-5.6-sol'))
await first
expect(isLoading('provider-1', 'key-a')).toBe(true)
forcedRequest.resolve(response('gpt-5.6-luna'))
await forced
expect(isLoading('provider-1', 'key-a')).toBe(false)
})
})
@@ -5,7 +5,7 @@
*/
import { ref } from 'vue'
import { isAxiosError } from 'axios'
import { adminApi } from '@/api/admin'
import { adminApi, type ProviderModelsQueryResponse } from '@/api/admin'
import { parseUpstreamModelError } from '@/utils/errorParser'
import type { UpstreamModel } from '@/api/endpoints/types'
@@ -15,6 +15,8 @@ type FetchResult = { models: UpstreamModel[]; error?: string; warning?: string;
// 进行中的请求(用于去重并发请求)
const pendingRequests = new Map<string, Promise<FetchResult>>()
const activeRequestIds = new Map<string, number>()
let nextRequestId = 0
// 请求状态
const loadingMap = ref<Map<string, boolean>>(new Map())
@@ -26,6 +28,57 @@ function getRequestKey(providerId: string, apiKeyId?: string): string {
return apiKeyId ? `${providerId}:${apiKeyId}` : providerId
}
function getBatchRequestKey(providerId: string, apiKeyIds: string[]): string {
return `${providerId}:batch:${JSON.stringify([...new Set(apiKeyIds)].sort())}`
}
function providerModelsFetchResult(response: ProviderModelsQueryResponse): FetchResult {
if (response.success && response.data?.models) {
const partialWarning = response.data.warning ?? response.data.error
return {
models: response.data.models,
warning: partialWarning ? parseUpstreamModelError(partialWarning) : undefined,
fromCache: response.data.from_cache,
}
}
const rawError = response.data?.error || response.data?.warning || '获取上游模型失败'
return { models: [], error: parseUpstreamModelError(rawError) }
}
function fetchProviderModels(
requestKey: string,
forceRefresh: boolean,
request: () => Promise<ProviderModelsQueryResponse>,
): Promise<FetchResult> {
if (!forceRefresh && pendingRequests.has(requestKey)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return pendingRequests.get(requestKey)!
}
const requestId = ++nextRequestId
activeRequestIds.set(requestKey, requestId)
loadingMap.value.set(requestKey, true)
const requestPromise = (async (): Promise<FetchResult> => {
try {
return providerModelsFetchResult(await request())
} catch (err: unknown) {
const rawError = isAxiosError(err)
? (err.response?.data?.detail ?? err.message)
: (err instanceof Error ? err.message : String(err))
return { models: [], error: parseUpstreamModelError(rawError || '获取上游模型失败') }
} finally {
if (activeRequestIds.get(requestKey) === requestId) {
loadingMap.value.set(requestKey, false)
pendingRequests.delete(requestKey)
activeRequestIds.delete(requestKey)
}
}
})()
pendingRequests.set(requestKey, requestPromise)
return requestPromise
}
export function useUpstreamModelsCache() {
/**
* 获取上游模型列表
@@ -40,41 +93,32 @@ export function useUpstreamModelsCache() {
forceRefresh = false
): Promise<FetchResult> {
const requestKey = getRequestKey(providerId, apiKeyId)
return fetchProviderModels(
requestKey,
forceRefresh,
() => adminApi.queryProviderModels(providerId, apiKeyId, forceRefresh),
)
}
// 强制刷新时不复用进行中的请求
if (!forceRefresh && pendingRequests.has(requestKey)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return pendingRequests.get(requestKey)!
async function fetchModelsForKeys(
providerId: string,
apiKeyIds: string[],
forceRefresh = false
): Promise<FetchResult> {
const normalizedKeyIds = [...new Set(apiKeyIds.map(id => id.trim()).filter(Boolean))].sort()
if (normalizedKeyIds.length === 0) {
return { models: [], error: '请先选择账号' }
}
// 创建新请求
const requestPromise = (async (): Promise<FetchResult> => {
try {
loadingMap.value.set(requestKey, true)
const response = await adminApi.queryProviderModels(providerId, apiKeyId, forceRefresh)
if (response.success && response.data?.models) {
const partialWarning = response.data.warning ?? response.data.error
return {
models: response.data.models,
warning: partialWarning ? parseUpstreamModelError(partialWarning) : undefined,
fromCache: response.data.from_cache
}
} else {
const rawError = response.data?.error || response.data?.warning || '获取上游模型失败'
return { models: [], error: parseUpstreamModelError(rawError) }
}
} catch (err: unknown) {
const rawError = isAxiosError(err) ? (err.response?.data?.detail ?? err.message) : (err instanceof Error ? err.message : String(err))
return { models: [], error: parseUpstreamModelError(rawError || '获取上游模型失败') }
} finally {
loadingMap.value.set(requestKey, false)
pendingRequests.delete(requestKey)
}
})()
pendingRequests.set(requestKey, requestPromise)
return requestPromise
const requestKey = getBatchRequestKey(providerId, normalizedKeyIds)
return fetchProviderModels(
requestKey,
forceRefresh,
() => adminApi.queryProviderModelsForKeys(
providerId,
normalizedKeyIds,
forceRefresh,
),
)
}
/**
@@ -87,6 +131,7 @@ export function useUpstreamModelsCache() {
return {
fetchModels,
fetchModelsForKeys,
isLoading,
loadingMap
}