test(agent): cover LLM provider directory (#680)

This commit is contained in:
InfinityPacer
2026-08-16 16:54:01 +08:00
committed by GitHub
parent 0acb604466
commit 774ef77639
3 changed files with 597 additions and 93 deletions
+55
View File
@@ -0,0 +1,55 @@
import { manageLlmProvider, manageNotificationChannel, manageStorage } from '@/api/manage'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiPost: vi.fn(),
}))
vi.mock('@/api', () => ({
default: createDataApiMock({
post: (...args: unknown[]) => mocks.apiPost(...args),
}),
}))
describe('manage API adapters', () => {
beforeEach(() => {
mocks.apiPost.mockReset()
mocks.apiPost.mockResolvedValue({ ok: true })
})
it('透传通知渠道的目标、动作、参数和请求配置', async () => {
const config = { timeout: 1200 }
await expect(manageNotificationChannel('telegram', 'refresh', { force: true }, config)).resolves.toEqual({
ok: true,
})
expect(mocks.apiPost).toHaveBeenCalledWith(
'notification/manage',
{ target: 'telegram', action: 'refresh', params: { force: true } },
config,
)
})
it('为网盘存储动作补充空参数对象', async () => {
await manageStorage('alist', 'disconnect')
expect(mocks.apiPost).toHaveBeenCalledWith(
'storage/manage',
{ target: 'alist', action: 'disconnect', params: {} },
undefined,
)
})
it('透传 LLM Provider 管理动作并返回已解包业务数据', async () => {
mocks.apiPost.mockResolvedValueOnce([{ id: 'openai' }])
await expect(manageLlmProvider('', 'list_providers')).resolves.toEqual([{ id: 'openai' }])
expect(mocks.apiPost).toHaveBeenCalledWith(
'llm/manage',
{ target: '', action: 'list_providers', params: {} },
undefined,
)
})
})
@@ -1,23 +1,28 @@
import { useLlmProviderDirectory } from '@/composables/useLlmProviderDirectory'
import { mount } from '@vue/test-utils'
import { defineComponent, nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
useLlmProviderDirectory,
type LlmModel,
type LlmProvider,
type LlmProviderAuthSession,
} from '@/composables/useLlmProviderDirectory'
import { mount, type VueWrapper } from '@vue/test-utils'
import { defineComponent, h, nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiPost: vi.fn(),
manageLlmProvider: vi.fn(),
}))
vi.mock('@/api', () => ({
default: createDataApiMock({
post: (...args: unknown[]) => mocks.apiPost(...args),
}),
vi.mock('@/api/manage', () => ({
manageLlmProvider: (...args: unknown[]) => mocks.manageLlmProvider(...args),
}))
function createProvider(id: string, runtime: string) {
const wrappers: VueWrapper[] = []
function createProvider(id: string, overrides: Partial<LlmProvider> = {}): LlmProvider {
return {
id,
name: id,
runtime,
runtime: 'openai_compatible',
default_base_url: '',
base_url_presets: [],
base_url_editable: true,
@@ -28,112 +33,542 @@ function createProvider(id: string, runtime: string) {
supports_model_refresh: true,
oauth_methods: [],
auth_status: { connected: false },
...overrides,
}
}
function createModel(id: string, overrides: Partial<LlmModel> = {}): LlmModel {
return {
id,
name: id,
...overrides,
}
}
function mountDirectory(
initial: Partial<{
provider: string
apiKey: string
baseUrl: string
baseUrlPreset: string
useProxy: boolean
userAgent: string
apiProtocol: string
model: string
maxContextTokens: number
authConnected: boolean
}> = {},
) {
const provider = ref(initial.provider ?? '')
const apiKey = ref(initial.apiKey ?? '')
const baseUrl = ref(initial.baseUrl ?? '')
const baseUrlPreset = ref(initial.baseUrlPreset ?? '')
const useProxy = ref(initial.useProxy ?? false)
const userAgent = ref(initial.userAgent ?? '')
const apiProtocol = ref(initial.apiProtocol ?? 'auto')
const model = ref(initial.model ?? '')
const maxContextTokens = ref(initial.maxContextTokens ?? 0)
const authConnected = ref(initial.authConnected ?? false)
let directory!: ReturnType<typeof useLlmProviderDirectory>
const wrapper = mount(
defineComponent({
setup() {
directory = useLlmProviderDirectory({
provider,
apiKey,
baseUrl,
baseUrlPreset,
useProxy,
userAgent,
apiProtocol,
model,
maxContextTokens,
authConnected,
})
return () => h('div')
},
}),
)
wrappers.push(wrapper)
return {
wrapper,
directory,
provider,
apiKey,
baseUrl,
baseUrlPreset,
useProxy,
userAgent,
apiProtocol,
model,
maxContextTokens,
authConnected,
}
}
describe('useLlmProviderDirectory', () => {
beforeEach(() => {
mocks.apiPost.mockReset()
mocks.manageLlmProvider.mockReset()
})
it('只为 OpenAI 兼容 runtime 或声明 Responses 工具能力的模型显示 API 协议字段', async () => {
mocks.apiPost.mockResolvedValue({
success: true,
data: [
createProvider('openai', 'openai_compatible'),
createProvider('deepseek', 'deepseek'),
createProvider('google', 'google'),
],
afterEach(() => {
for (const wrapper of wrappers.splice(0)) {
if (wrapper.exists()) wrapper.unmount()
}
})
it('选择首个 Provider,并回填默认 URL、预设和认证状态', async () => {
const provider = createProvider('openai', {
default_base_url: 'https://api.example.com/v1',
base_url_presets: [{ id: 'official', label: '官方', value: 'https://api.example.com/v1' }],
auth_status: { connected: true, label: 'demo@example.com' },
})
mocks.manageLlmProvider.mockResolvedValue([provider])
const state = mountDirectory({ provider: 'missing' })
await expect(state.directory.loadProviders()).resolves.toEqual([provider])
expect(mocks.manageLlmProvider).toHaveBeenCalledWith('', 'list_providers')
expect(state.provider.value).toBe('openai')
expect(state.baseUrl.value).toBe('https://api.example.com/v1')
expect(state.baseUrlPreset.value).toBe('official')
expect(state.authConnected.value).toBe(true)
expect(state.directory.providerItems.value).toEqual([{ title: 'openai', value: 'openai' }])
expect(state.directory.loadingProviders.value).toBe(false)
})
it('默认保留已有 URL 和预设,并可显式恢复 Provider 默认值', async () => {
const provider = createProvider('openai', {
default_base_url: 'https://default.example.com/v1',
base_url_presets: [{ id: 'official', label: '官方', value: 'https://default.example.com/v1' }],
})
mocks.manageLlmProvider.mockResolvedValue([provider])
const state = mountDirectory({
provider: 'openai',
baseUrl: 'https://custom.example.com/v1',
baseUrlPreset: 'custom',
})
const Harness = defineComponent({
setup() {
const provider = ref('openai')
const model = ref('')
const directory = useLlmProviderDirectory({
provider,
apiKey: ref(''),
baseUrl: ref(''),
model,
})
await state.directory.loadProviders()
expect(state.baseUrl.value).toBe('https://custom.example.com/v1')
expect(state.baseUrlPreset.value).toBe('custom')
return {
loadProviders: directory.loadProviders,
selectProvider: (value: string) => {
provider.value = value
},
selectModel: (value: string) => {
model.value = value
},
loadModels: directory.loadModels,
showApiProtocolField: directory.showApiProtocolField,
supportsBuiltinWebSearch: directory.supportsBuiltinWebSearch,
}
await state.directory.loadProviders(false)
expect(state.baseUrl.value).toBe('https://default.example.com/v1')
expect(state.baseUrlPreset.value).toBe('official')
})
it('将非数组 Provider 响应规整为空列表,并在失败后恢复 loading', async () => {
const state = mountDirectory()
mocks.manageLlmProvider.mockResolvedValueOnce({ providers: [] })
await expect(state.directory.loadProviders()).resolves.toEqual([])
expect(state.directory.providers.value).toEqual([])
mocks.manageLlmProvider.mockRejectedValueOnce(new Error('provider unavailable'))
await expect(state.directory.loadProviders()).rejects.toThrow('provider unavailable')
expect(state.directory.loadingProviders.value).toBe(false)
})
it('切换 Provider 时重置从属凭据、模型和协议状态', () => {
const state = mountDirectory({
provider: 'oauth',
apiKey: 'secret',
baseUrl: 'https://custom.example.com',
baseUrlPreset: 'custom',
apiProtocol: 'responses',
model: 'old-model',
maxContextTokens: 256,
authConnected: false,
})
state.directory.providers.value = [
createProvider('oauth', {
default_base_url: 'https://oauth.example.com',
base_url_presets: [{ id: 'oauth-default', label: '默认', value: 'https://oauth.example.com' }],
auth_status: { connected: true },
}),
]
state.directory.models.value = [createModel('old-model')]
state.directory.handleProviderSelection()
expect(state.baseUrl.value).toBe('https://oauth.example.com')
expect(state.baseUrlPreset.value).toBe('oauth-default')
expect(state.apiKey.value).toBe('')
expect(state.apiProtocol.value).toBe('auto')
expect(state.model.value).toBe('')
expect(state.maxContextTokens.value).toBe(64)
expect(state.authConnected.value).toBe(true)
expect(state.directory.models.value).toEqual([])
})
it('同步 URL 预设值,并为界面生成带说明的选项', () => {
const state = mountDirectory({ provider: 'openai' })
state.directory.providers.value = [
createProvider('openai', {
base_url_presets: [{ id: 'proxy', label: '代理', value: 'https://proxy.example.com' }],
}),
]
expect(state.directory.baseUrlPresetItems.value).toEqual([
{
id: 'proxy',
title: 'https://proxy.example.com',
value: 'https://proxy.example.com',
subtitle: '代理',
},
template: '<div />',
])
state.directory.setBaseUrlPreset(' proxy ', 'https://proxy.example.com')
expect(state.baseUrlPreset.value).toBe('proxy')
expect(state.baseUrl.value).toBe('https://proxy.example.com')
state.directory.setBaseUrlPreset(undefined, '')
expect(state.baseUrlPreset.value).toBe('')
expect(state.baseUrl.value).toBe('')
})
it('按 Provider 能力、凭据和必填 URL 判断模型刷新资格', () => {
const state = mountDirectory({ provider: 'custom' })
state.directory.providers.value = [
createProvider('custom', {
requires_base_url: true,
oauth_methods: [{ id: 'device', type: 'device', label: '设备授权' }],
supports_api_key: false,
}),
]
expect(state.directory.showBaseUrlField.value).toBe(false)
expect(state.directory.showApiKeyField.value).toBe(false)
expect(state.directory.hasUsableCredential.value).toBe(false)
expect(state.directory.canRefreshModels.value).toBe(false)
state.apiKey.value = ' key '
expect(state.directory.canRefreshModels.value).toBe(false)
state.baseUrl.value = ' https://custom.example.com '
expect(state.directory.canRefreshModels.value).toBe(true)
state.apiKey.value = ''
state.directory.providers.value[0] = {
...state.directory.providers.value[0],
auth_status: { connected: true },
}
expect(state.directory.hasUsableCredential.value).toBe(true)
expect(state.directory.canRefreshModels.value).toBe(true)
state.directory.providers.value[0] = {
...state.directory.providers.value[0],
supports_model_refresh: false,
}
expect(state.directory.canRefreshModels.value).toBe(false)
})
it('没有选中 Provider 时不请求模型', async () => {
const state = mountDirectory()
await expect(state.directory.loadModels(true)).resolves.toEqual([])
expect(mocks.manageLlmProvider).not.toHaveBeenCalled()
expect(state.directory.loadingModels.value).toBe(false)
})
it('规范化模型刷新 payload,选择首个模型并同步认证与上下文元数据', async () => {
const state = mountDirectory({
provider: ' openai ',
apiKey: ' secret ',
baseUrl: ' https://api.example.com/v1 ',
baseUrlPreset: ' official ',
useProxy: false,
userAgent: ' MoviePilot-Test ',
maxContextTokens: 64,
})
state.directory.providers.value = [createProvider('openai')]
mocks.manageLlmProvider.mockResolvedValue({
auth_status: { connected: true, label: 'demo@example.com' },
models: [createModel('gpt-test', { context_tokens_k: 128 })],
})
const wrapper = mount(Harness)
await wrapper.vm.loadProviders()
await expect(state.directory.loadModels(true)).resolves.toEqual([
createModel('gpt-test', { context_tokens_k: 128 }),
])
expect(wrapper.vm.showApiProtocolField).toBe(true)
expect(mocks.manageLlmProvider).toHaveBeenCalledWith('openai', 'list_models', {
api_key: 'secret',
base_url: 'https://api.example.com/v1',
base_url_preset: 'official',
use_proxy: false,
user_agent: 'MoviePilot-Test',
force_refresh: true,
})
expect(state.model.value).toBe('gpt-test')
expect(state.maxContextTokens.value).toBe(128)
expect(state.authConnected.value).toBe(true)
expect(state.directory.selectedModel.value?.id).toBe('gpt-test')
expect(state.directory.loadingModels.value).toBe(false)
})
wrapper.vm.selectProvider('deepseek')
await nextTick()
it('保留仍存在的模型选择,并仅从匹配模型回填上下文', async () => {
const state = mountDirectory({ provider: 'openai', model: 'model-b', maxContextTokens: 32 })
state.directory.providers.value = [createProvider('openai')]
mocks.manageLlmProvider.mockResolvedValue({
models: [createModel('model-a', { context_tokens_k: 64 }), createModel('model-b', { context_tokens_k: 256 })],
})
expect(wrapper.vm.showApiProtocolField).toBe(false)
await state.directory.loadModels()
mocks.apiPost.mockResolvedValueOnce({
success: true,
data: {
models: [
expect(state.model.value).toBe('model-b')
expect(state.maxContextTokens.value).toBe(256)
expect(state.directory.applyModelMetadata('missing')).toBeNull()
})
it('将非数组模型响应规整为空列表,并在失败后恢复 loading', async () => {
const state = mountDirectory({ provider: 'openai' })
state.directory.providers.value = [createProvider('openai')]
mocks.manageLlmProvider.mockResolvedValueOnce({ models: { id: 'invalid' } })
await expect(state.directory.loadModels()).resolves.toEqual([])
expect(state.directory.models.value).toEqual([])
mocks.manageLlmProvider.mockRejectedValueOnce(new Error('model unavailable'))
await expect(state.directory.loadModels()).rejects.toThrow('model unavailable')
expect(state.directory.loadingModels.value).toBe(false)
})
it('只为 OpenAI 兼容 runtime 或要求 Responses 的工具模型显示协议字段', async () => {
const state = mountDirectory({ provider: 'openai' })
state.directory.providers.value = [
createProvider('openai'),
createProvider('deepseek', { runtime: 'deepseek' }),
createProvider('google', { runtime: 'google' }),
]
expect(state.directory.showApiProtocolField.value).toBe(true)
state.provider.value = 'deepseek'
state.directory.models.value = [
createModel('deepseek-v4-flash', {
server_tools: [
{
id: 'deepseek-v4-flash',
name: 'deepseek-v4-flash',
server_tools: [
{
id: 'web_search',
required_api_protocol: 'responses',
client_adapter: 'openai_responses',
},
],
id: 'web_search',
required_api_protocol: 'responses',
client_adapter: 'openai_responses',
},
],
},
}),
]
state.model.value = 'deepseek-v4-flash'
await nextTick()
expect(state.directory.supportsBuiltinWebSearch.value).toBe(true)
expect(state.directory.showApiProtocolField.value).toBe(true)
state.provider.value = 'google'
state.directory.models.value = [
createModel('gemini-test', {
server_tools: [{ id: 'web_search', required_api_protocol: 'native', client_adapter: 'google_native' }],
}),
]
state.model.value = 'gemini-test'
await nextTick()
expect(state.directory.supportsBuiltinWebSearch.value).toBe(true)
expect(state.directory.showApiProtocolField.value).toBe(false)
})
it('没有 Provider 时拒绝开始 OAuth 授权', async () => {
const state = mountDirectory()
await expect(state.directory.startAuth('device')).rejects.toThrow('LLM provider is required')
expect(mocks.manageLlmProvider).not.toHaveBeenCalled()
})
it('开始 OAuth 授权后打开页面、显示弹窗并安排首次轮询', async () => {
vi.useFakeTimers()
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const state = mountDirectory({ provider: 'openai' })
state.directory.providers.value = [createProvider('openai')]
const session: LlmProviderAuthSession = {
session_id: 'session-1',
provider_id: 'openai',
flow_type: 'device',
status: 'pending',
authorize_url: 'https://auth.example.com/device',
}
mocks.manageLlmProvider.mockResolvedValue(session)
await expect(state.directory.startAuth('device')).resolves.toEqual(session)
expect(mocks.manageLlmProvider).toHaveBeenCalledWith('openai', 'start_auth', { method: 'device' })
expect(open).toHaveBeenCalledWith(
'https://auth.example.com/device',
'_blank',
'noopener,noreferrer,width=960,height=780',
)
expect(state.directory.authDialogVisible.value).toBe(true)
expect(state.directory.authPopupBlocked.value).toBe(true)
expect(vi.getTimerCount()).toBe(1)
})
it('优先打开 authorize URL,并回退到 verification URL', () => {
const open = vi.spyOn(window, 'open').mockReturnValue({} as Window)
const state = mountDirectory({ provider: 'openai' })
state.directory.openAuthPage()
expect(open).not.toHaveBeenCalled()
state.directory.authSession.value = {
session_id: 'session-1',
provider_id: 'openai',
flow_type: 'device',
status: 'pending',
verification_url: 'https://auth.example.com/verify',
}
state.directory.openAuthPage()
expect(open).toHaveBeenCalledWith(
'https://auth.example.com/verify',
'_blank',
'noopener,noreferrer,width=960,height=780',
)
expect(state.directory.authPopupBlocked.value).toBe(false)
})
it('待授权时合并会话状态,并按服务端间隔安排下一次静默轮询', async () => {
vi.useFakeTimers()
const state = mountDirectory({ provider: 'openai' })
state.directory.authSession.value = {
session_id: 'session-1',
provider_id: 'openai',
flow_type: 'device',
status: 'pending',
interval_seconds: 5,
}
mocks.manageLlmProvider.mockResolvedValue({ status: 'pending', interval_seconds: 0.5, message: 'waiting' })
await expect(state.directory.pollAuthSession()).resolves.toMatchObject({
status: 'pending',
interval_seconds: 0.5,
message: 'waiting',
})
await wrapper.vm.loadModels()
wrapper.vm.selectModel('deepseek-v4-flash')
await nextTick()
expect(wrapper.vm.supportsBuiltinWebSearch).toBe(true)
expect(wrapper.vm.showApiProtocolField).toBe(true)
expect(mocks.manageLlmProvider).toHaveBeenCalledWith(
'openai',
'poll_auth',
{ session_id: 'session-1' },
{ feedback: 'silent' },
)
expect(state.directory.authPolling.value).toBe(false)
expect(vi.getTimerCount()).toBe(1)
wrapper.vm.selectProvider('google')
await nextTick()
mocks.apiPost.mockResolvedValueOnce({
success: true,
data: {
models: [
{
id: 'gemini-3.6-flash-preview',
name: 'gemini-3.6-flash-preview',
server_tools: [
{
id: 'web_search',
required_api_protocol: 'native',
client_adapter: 'google_native',
},
],
},
],
},
await vi.advanceTimersByTimeAsync(999)
expect(mocks.manageLlmProvider).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(mocks.manageLlmProvider).toHaveBeenNthCalledWith(
2,
'openai',
'poll_auth',
{ session_id: 'session-1' },
{ feedback: 'silent' },
)
})
it('授权成功后刷新 Provider 与模型,并将模型请求标记为强制刷新', async () => {
const state = mountDirectory({ provider: 'openai' })
state.directory.providers.value = [createProvider('openai')]
state.directory.authSession.value = {
session_id: 'session-1',
provider_id: 'openai',
flow_type: 'device',
status: 'pending',
}
mocks.manageLlmProvider.mockImplementation((_provider: string, action: string) => {
if (action === 'poll_auth') return Promise.resolve({ status: 'authorized' })
if (action === 'list_providers') {
return Promise.resolve([createProvider('openai', { auth_status: { connected: true } })])
}
if (action === 'list_models') return Promise.resolve({ models: [createModel('gpt-test')] })
return Promise.reject(new Error(`unexpected action: ${action}`))
})
await wrapper.vm.loadModels()
wrapper.vm.selectModel('gemini-3.6-flash-preview')
await nextTick()
expect(wrapper.vm.supportsBuiltinWebSearch).toBe(true)
expect(wrapper.vm.showApiProtocolField).toBe(false)
wrapper.unmount()
await expect(state.directory.pollAuthSession()).resolves.toMatchObject({ status: 'authorized' })
expect(mocks.manageLlmProvider).toHaveBeenNthCalledWith(
1,
'openai',
'poll_auth',
{ session_id: 'session-1' },
{ feedback: 'silent' },
)
expect(mocks.manageLlmProvider).toHaveBeenNthCalledWith(2, '', 'list_providers')
expect(mocks.manageLlmProvider).toHaveBeenNthCalledWith(3, 'openai', 'list_models', {
api_key: undefined,
base_url: undefined,
base_url_preset: undefined,
use_proxy: false,
user_agent: undefined,
force_refresh: true,
})
expect(state.authConnected.value).toBe(true)
expect(state.model.value).toBe('gpt-test')
expect(state.directory.authPolling.value).toBe(false)
})
it('轮询失败后恢复 polling 状态且不安排下一次请求', async () => {
vi.useFakeTimers()
const state = mountDirectory({ provider: 'openai' })
state.directory.authSession.value = {
session_id: 'session-1',
provider_id: 'openai',
flow_type: 'device',
status: 'pending',
}
mocks.manageLlmProvider.mockRejectedValue(new Error('poll failed'))
await expect(state.directory.pollAuthSession()).rejects.toThrow('poll failed')
expect(state.directory.authPolling.value).toBe(false)
expect(vi.getTimerCount()).toBe(0)
})
it('断开认证后刷新 Provider,并在没有 Provider 时直接返回', async () => {
const emptyState = mountDirectory()
await expect(emptyState.directory.disconnectAuth()).resolves.toBe(false)
const state = mountDirectory({ provider: 'openai' })
state.directory.providers.value = [createProvider('openai', { auth_status: { connected: true } })]
mocks.manageLlmProvider.mockImplementation((_provider: string, action: string) => {
if (action === 'disconnect') return Promise.resolve(undefined)
if (action === 'list_providers') return Promise.resolve([createProvider('openai')])
return Promise.reject(new Error(`unexpected action: ${action}`))
})
await expect(state.directory.disconnectAuth()).resolves.toBe(true)
expect(mocks.manageLlmProvider).toHaveBeenNthCalledWith(1, 'openai', 'disconnect')
expect(mocks.manageLlmProvider).toHaveBeenNthCalledWith(2, '', 'list_providers')
expect(state.authConnected.value).toBe(false)
})
it('关闭授权弹窗和组件卸载都会清理轮询 timer', async () => {
vi.useFakeTimers()
const state = mountDirectory({ provider: 'openai' })
state.directory.providers.value = [createProvider('openai')]
mocks.manageLlmProvider.mockResolvedValue({
session_id: 'session-1',
provider_id: 'openai',
flow_type: 'device',
status: 'pending',
})
await state.directory.startAuth('device')
expect(vi.getTimerCount()).toBe(1)
state.directory.closeAuthDialog()
expect(state.directory.authDialogVisible.value).toBe(false)
expect(vi.getTimerCount()).toBe(0)
await state.directory.startAuth('device')
expect(vi.getTimerCount()).toBe(1)
state.wrapper.unmount()
expect(vi.getTimerCount()).toBe(0)
})
})