d3ro-voice/apps/desktop/tests/main/services/llm-limits-routing-redteam-r2-1.test.ts

154 lines
6.3 KiB
TypeScript

// 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<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' })),
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()
})
})