154 lines
6.3 KiB
TypeScript
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()
|
|
})
|
|
})
|