// 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 { for (const v of values) yield v return values.join('') } function premium(overrides: Partial = {}): 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 { 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() }) })