// tests/main/services/llm-limits-routing-redteam-r2-1.test.ts // - Premium 단일 턴(generate/processText)은 프록시 한도를 넘으면 보내지 않고 LLMPromptTooLong // - 단일 턴도 호출 단위 signal 로 취소된다(전역 cancelGeneration 없이) // - 게이트웨이: 취소는 로컬로 폴백하지 않는다, 채팅 스트림에 signal 을 넘긴다 import { beforeEach, describe, expect, it, vi } from 'vitest' import { ErrorCode } from '@d3ro/core/errors' import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat' 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() }), })) const cloud = vi.hoisted(() => ({ isEnabled: (): boolean => true, isAuthenticated: (): boolean => true, invokeFunctionStream: vi.fn(), invokeFunction: vi.fn(), })) vi.mock('../../../src/main/services/CloudSyncService', () => ({ getCloudSyncService: () => cloud, })) import { getPremiumLLMService, resetPremiumLLMServiceForTests } from '../../../src/main/services/PremiumLLMService' const okResponse = { data: { id: 'm', model: 'claude', role: 'assistant', content: [{ type: 'text', text: '결과' }], stop_reason: 'end_turn', usage: { input_tokens: 1, output_tokens: 1 }, }, error: null, } describe('PremiumLLMService 단일 턴 한도', () => { beforeEach(() => { resetPremiumLLMServiceForTests() cloud.invokeFunction.mockReset() cloud.invokeFunction.mockResolvedValue(okResponse) }) it('8,000자를 넘는 generate 입력은 보내지 않고 LLMPromptTooLong 이다', async () => { const text = 'x'.repeat(LLM_PROXY_CHAT_LIMITS.maxMessageChars + 1) await expect(getPremiumLLMService().generate(text)).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong }) expect(cloud.invokeFunction).not.toHaveBeenCalled() }) it('system 이 한도를 넘어도 보내지 않는다', async () => { await expect( getPremiumLLMService().generate('q', { systemPrompt: 's'.repeat(LLM_PROXY_CHAT_LIMITS.maxSystemChars + 1) }), ).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong }) await expect( getPremiumLLMService().processText('y'.repeat(LLM_PROXY_CHAT_LIMITS.maxMessageChars + 1), 'refine'), ).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong }) expect(cloud.invokeFunction).not.toHaveBeenCalled() }) it('경계값(정확히 8,000자, 앞뒤 공백 제외)은 보낸다', async () => { const text = ` ${'x'.repeat(LLM_PROXY_CHAT_LIMITS.maxMessageChars)} ` await expect(getPremiumLLMService().generate(text)).resolves.toEqual({ text: '결과' }) expect(cloud.invokeFunction).toHaveBeenCalledTimes(1) }) it('단일 턴도 호출 단위 signal 로 취소된다(cancelGeneration 없이)', async () => { cloud.invokeFunction.mockImplementation( (_n: string, _b: unknown, options?: { signal?: AbortSignal }) => new Promise((resolve) => { options?.signal?.addEventListener('abort', () => resolve({ data: null, error: { message: 'FunctionsFetchError: aborted' } }), { once: true }) }), ) const controller = new AbortController() const pending = getPremiumLLMService().generate('q', { signal: controller.signal }) controller.abort() await expect(pending).rejects.toMatchObject({ code: ErrorCode.LLMProcessingCancelled }) }) }) 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' })), chatStream: vi.fn(() => tokens('l')), cancelGeneration: vi.fn(), ...overrides, } } describe('LlmGateway 호출 단위 취소', () => { it('Premium 호출이 취소되면 로컬로 폴백하지 않고 취소를 전파한다', async () => { const controller = new AbortController() const l = local() const p = premium({ generate: vi.fn(async () => { controller.abort() throw Object.assign(new Error('cancelled'), { code: ErrorCode.LLMProcessingCancelled }) }), }) const gw = createRoutedLlmGateway({ getBackend: () => 'online', loadPremium: async () => p, getLocal: () => l }) await expect(gw.generate('q', undefined, { signal: controller.signal })).rejects.toBeTruthy() expect(l.generate).not.toHaveBeenCalled() }) it('signal 을 Premium 단일 턴·채팅 스트림에 넘기고, 다른 호출은 건드리지 않는다', async () => { const p = premium() const gw = createRoutedLlmGateway({ getBackend: () => 'online', loadPremium: async () => p, getLocal: () => local() }) const a = new AbortController() const b = new AbortController() await gw.processText('t', 'refine', undefined, undefined, { signal: a.signal }) await gw.openChatStream([{ role: 'user', content: 'hi' }], { signal: b.signal }) a.abort() expect(p.processText).toHaveBeenCalledWith('t', 'refine', undefined, undefined, { signal: a.signal }) expect(p.chatStream).toHaveBeenCalledWith([{ role: 'user', content: 'hi' }], { signal: b.signal }) expect(b.signal.aborted).toBe(false) expect(p.cancelGeneration).not.toHaveBeenCalled() }) it('이미 취소된 signal 이면 어떤 백엔드도 부르지 않는다', async () => { const controller = new AbortController() controller.abort() const l = local() const gw = createRoutedLlmGateway({ getBackend: () => 'local', loadPremium: async () => premium(), getLocal: () => l }) await expect(gw.processText('t', 'refine', undefined, undefined, { signal: controller.signal })).rejects.toMatchObject({ code: ErrorCode.LLMProcessingCancelled, }) expect(l.processText).not.toHaveBeenCalled() }) })