131 lines
4.8 KiB
TypeScript
131 lines
4.8 KiB
TypeScript
// tests/main/services/llm-gateway.test.ts
|
|
// LLM 백엔드 선택 정책(단일 정본)의 local / online / 불가 / 실패 매트릭스.
|
|
|
|
import { describe, it, expect, vi } from 'vitest'
|
|
import { ErrorCode } from '@d3ro/core/errors'
|
|
import {
|
|
createRoutedLlmGateway,
|
|
type LocalLlmAdapter,
|
|
type PremiumLlmAdapter,
|
|
} from '../../../src/main/services/llm/LlmGateway'
|
|
|
|
vi.mock('../../../src/main/services/LoggerService', () => ({
|
|
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
|
}))
|
|
|
|
async function* tokens(...values: string[]): AsyncGenerator<string, string> {
|
|
for (const v of values) yield v
|
|
return values.join('')
|
|
}
|
|
|
|
function premium(overrides: Partial<PremiumLlmAdapter> = {}): PremiumLlmAdapter {
|
|
return {
|
|
isAvailable: vi.fn(() => true),
|
|
processText: vi.fn(async () => 'premium'),
|
|
generate: vi.fn(async () => ({ text: 'premium' })),
|
|
chatStream: vi.fn(() => tokens('p')),
|
|
cancelGeneration: vi.fn(),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function local(overrides: Partial<LocalLlmAdapter> = {}): LocalLlmAdapter {
|
|
return {
|
|
isAvailable: vi.fn(() => true),
|
|
processText: vi.fn(async () => 'local'),
|
|
generate: vi.fn(async () => ({ text: 'local', model: 'gemma' })),
|
|
chatStream: vi.fn(() => tokens('l')),
|
|
cancelGeneration: vi.fn(),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function gateway(backend: string | undefined, p: PremiumLlmAdapter, l: LocalLlmAdapter) {
|
|
return createRoutedLlmGateway({
|
|
getBackend: () => backend,
|
|
loadPremium: async () => p,
|
|
getLocal: () => l,
|
|
})
|
|
}
|
|
|
|
describe('LlmGateway 라우팅', () => {
|
|
it('local 백엔드는 Premium 을 절대 건드리지 않는다', async () => {
|
|
const p = premium()
|
|
const l = local()
|
|
const gw = gateway('local', p, l)
|
|
|
|
expect(await gw.processText('t', 'refine')).toBe('local')
|
|
expect((await gw.generate('q', { systemPrompt: 'docs' })).text).toBe('local')
|
|
expect(p.processText).not.toHaveBeenCalled()
|
|
expect(p.generate).not.toHaveBeenCalled()
|
|
expect(p.isAvailable).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('online + 가용이면 Premium 을 쓴다', async () => {
|
|
const p = premium()
|
|
const l = local()
|
|
const gw = gateway('online', p, l)
|
|
expect(await gw.processText('t', 'custom', undefined, 'sys')).toBe('premium')
|
|
expect(p.processText).toHaveBeenCalledWith('t', 'custom', undefined, 'sys')
|
|
expect(l.processText).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('online + 불가면 로컬로 폴백하고 사유를 알린다', async () => {
|
|
const onFallback = vi.fn()
|
|
const gw = gateway('online', premium({ isAvailable: vi.fn(() => false) }), local())
|
|
expect(await gw.processText('t', 'refine', undefined, undefined, { onFallback })).toBe('local')
|
|
expect(onFallback).toHaveBeenCalledWith(expect.stringContaining('unavailable'))
|
|
})
|
|
|
|
it('online + Premium 호출 실패면 로컬로 폴백한다', async () => {
|
|
const onFallback = vi.fn()
|
|
const gw = gateway(
|
|
'online',
|
|
premium({ generate: vi.fn(async () => { throw new Error('quota') }) }),
|
|
local(),
|
|
)
|
|
expect((await gw.generate('q', undefined, { onFallback })).text).toBe('local')
|
|
expect(onFallback).toHaveBeenCalledWith(expect.stringContaining('quota'))
|
|
})
|
|
|
|
it('로컬 실패는 그대로 올린다 (성공으로 위장하지 않는다)', async () => {
|
|
const gw = gateway('local', premium(), local({ processText: vi.fn(async () => { throw new Error('ollama down') }) }))
|
|
await expect(gw.processText('t', 'refine')).rejects.toThrow('ollama down')
|
|
})
|
|
|
|
it('채팅 스트림: Premium 가용이면 Premium, 로컬은 옵션과 signal 을 받는다', async () => {
|
|
const p = premium()
|
|
const l = local()
|
|
const signal = new AbortController().signal
|
|
|
|
const online = await gateway('online', p, l).openChatStream([{ role: 'user', content: 'hi' }], { signal })
|
|
expect(online.backend).toBe('premium')
|
|
|
|
const offline = await gateway('local', p, l).openChatStream([{ role: 'user', content: 'hi' }], {
|
|
signal,
|
|
local: { maxTokens: 512, timeoutMs: 60000, keepAlive: '2m' },
|
|
})
|
|
expect(offline.backend).toBe('local')
|
|
expect(l.chatStream).toHaveBeenCalledWith(
|
|
[{ role: 'user', content: 'hi' }],
|
|
{ maxTokens: 512, timeoutMs: 60000, keepAlive: '2m', signal },
|
|
)
|
|
})
|
|
|
|
it('채팅 스트림: 로컬이 불가하면 LLMServerUnreachable', async () => {
|
|
const gw = gateway('local', premium(), local({ isAvailable: vi.fn(() => false) }))
|
|
await expect(
|
|
gw.openChatStream([], { signal: new AbortController().signal }),
|
|
).rejects.toMatchObject({ code: ErrorCode.LLMServerUnreachable })
|
|
})
|
|
|
|
it('cancel 은 로드된 두 어댑터를 모두 취소한다', async () => {
|
|
const p = premium()
|
|
const l = local()
|
|
const gw = gateway('online', p, l)
|
|
await gw.processText('t', 'refine')
|
|
gw.cancel()
|
|
expect(p.cancelGeneration).toHaveBeenCalled()
|
|
expect(l.cancelGeneration).toHaveBeenCalled()
|
|
})
|
|
})
|