Files
meeting-minutes/src/__tests__/providers.test.ts
T
352ac2ffd1 feat: OpenAI 호환 프로바이더 지원 (OrcaRouter · OpenAI · 로컬 모델) (#12)
* feat: OpenAI 호환 프로바이더 지원 (OrcaRouter · OpenAI · 로컬 모델)

Gemini 직접 호출만 가능하던 구조를 프로바이더 레이어로 분리.
base URL과 모델 ID만 지정하면 OpenAI Chat Completions 형식을 따르는
엔드포인트는 모두 연결된다 (OrcaRouter, OpenAI, Ollama, LM Studio, vLLM).

- src/lib/providers/ 신설 (gemini / openai-compatible 어댑터 + 프리셋)
- /settings에 프로바이더 선택 UI 추가, 기존 Gemini 키는 자동 승계
- LLM_PROVIDER / LLM_BASE_URL / LLM_MODEL / LLM_API_KEY 환경변수 지원
- base URL은 http/https만 허용 (서버가 대신 fetch하므로 SSRF 방어)
- SECURITY.md에 프로바이더별 전송 경로와 SSRF 주의사항 문서화
- 테스트 102 → 141

DB에 저장되는 summaryMode 값('gemini')은 기존 레코드 호환을 위해 유지하고
UI 라벨만 "AI 요약"으로 변경했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: 기본 Gemini 모델을 3.5 Flash Lite로 갱신

gemini-2.5-flash-lite → gemini-3.5-flash-lite. 같은 저비용·고속 티어의
최신 세대이며, 무료 등급 중심의 사용 프로필을 그대로 유지한다.

- providers/gemini.ts: DEFAULT_GEMINI_MODEL
- presets.ts: OrcaRouter 기본 모델과 모델 힌트 문구
- .env.example, README 참조 갱신

참고: 화자 분리를 지원하는 gemini-3.5-transcribe는 오디오 입력 전용
음성인식 모델이라 이 자리(텍스트 → 회의록 요약)에 넣을 수 없다.
오디오 캡처가 들어오는 시점에 STT 경로로 별도 추가해야 한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: csbae <csbae@RP-002.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:14:30 +09:00

223 lines
6.6 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest'
import {
complete,
describeModel,
normalizeBaseUrl,
toProviderSettings,
DEFAULT_GEMINI_MODEL,
findPreset,
PROVIDER_PRESETS,
} from '@/lib/providers'
function okResponse(content: string) {
return {
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content } }] }),
}
}
const OPENAI_SETTINGS = {
provider: 'openai-compatible' as const,
apiKey: 'sk-test',
baseUrl: 'https://api.orcarouter.ai/v1',
model: 'google/gemini-2.5-flash-lite',
}
describe('normalizeBaseUrl', () => {
it('끝의 슬래시를 제거한다', () => {
expect(normalizeBaseUrl('https://api.example.com/v1/')).toBe(
'https://api.example.com/v1',
)
})
it('앞뒤 공백을 제거한다', () => {
expect(normalizeBaseUrl(' https://api.example.com/v1 ')).toBe(
'https://api.example.com/v1',
)
})
it('http/https 이외의 스킴은 거부한다', () => {
expect(normalizeBaseUrl('file:///etc/passwd')).toBeNull()
expect(normalizeBaseUrl('ftp://example.com')).toBeNull()
})
it('URL이 아니거나 비어 있으면 null', () => {
expect(normalizeBaseUrl('not-a-url')).toBeNull()
expect(normalizeBaseUrl(' ')).toBeNull()
})
})
describe('complete — openai-compatible', () => {
it('/chat/completions로 OpenAI 형식 요청을 보낸다', async () => {
const mockFetch = vi.fn().mockResolvedValue(okResponse('## 요약'))
const result = await complete('프롬프트', OPENAI_SETTINGS, {
fetchFn: mockFetch,
})
expect(result.success).toBe(true)
if (result.success) expect(result.text).toBe('## 요약')
const [url, init] = mockFetch.mock.calls[0]
expect(url).toBe('https://api.orcarouter.ai/v1/chat/completions')
expect(init.headers.Authorization).toBe('Bearer sk-test')
const body = JSON.parse(init.body)
expect(body.model).toBe('google/gemini-2.5-flash-lite')
expect(body.messages).toEqual([{ role: 'user', content: '프롬프트' }])
})
it('키가 없으면 Authorization 헤더를 붙이지 않는다 (로컬 모델 서버)', async () => {
const mockFetch = vi.fn().mockResolvedValue(okResponse('요약'))
await complete(
'프롬프트',
{ ...OPENAI_SETTINGS, apiKey: '', baseUrl: 'http://localhost:11434/v1' },
{ fetchFn: mockFetch },
)
const [, init] = mockFetch.mock.calls[0]
expect(init.headers.Authorization).toBeUndefined()
})
it('base URL이 없으면 호출하지 않고 에러를 반환한다', async () => {
const mockFetch = vi.fn()
const result = await complete(
'프롬프트',
{ ...OPENAI_SETTINGS, baseUrl: '' },
{ fetchFn: mockFetch },
)
expect(result.success).toBe(false)
expect(mockFetch).not.toHaveBeenCalled()
})
it('모델이 없으면 호출하지 않고 에러를 반환한다', async () => {
const mockFetch = vi.fn()
const result = await complete(
'프롬프트',
{ ...OPENAI_SETTINGS, model: ' ' },
{ fetchFn: mockFetch },
)
expect(result.success).toBe(false)
if (!result.success) expect(result.error).toContain('모델')
expect(mockFetch).not.toHaveBeenCalled()
})
it('429는 rateLimited 플래그를 세운다', async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 429 })
const result = await complete('프롬프트', OPENAI_SETTINGS, {
fetchFn: mockFetch,
})
expect(result.success).toBe(false)
if (!result.success) expect(result.rateLimited).toBe(true)
})
it('401은 인증 실패로 안내한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 401 })
const result = await complete('프롬프트', OPENAI_SETTINGS, {
fetchFn: mockFetch,
})
expect(result.success).toBe(false)
if (!result.success) expect(result.error).toContain('인증')
})
it('네트워크 예외를 결과 객체로 감싼다', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))
const result = await complete('프롬프트', OPENAI_SETTINGS, {
fetchFn: mockFetch,
})
expect(result.success).toBe(false)
if (!result.success) expect(result.error).toContain('ECONNREFUSED')
})
})
describe('complete — gemini 라우팅', () => {
it('provider가 gemini면 Google 엔드포인트를 호출한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
candidates: [{ content: { parts: [{ text: '요약' }] } }],
}),
})
await complete(
'프롬프트',
{ provider: 'gemini', apiKey: 'AIza-test' },
{ fetchFn: mockFetch },
)
const [url, init] = mockFetch.mock.calls[0]
expect(url).toContain('generativelanguage.googleapis.com')
expect(url).toContain(DEFAULT_GEMINI_MODEL)
expect(init.headers['x-goog-api-key']).toBe('AIza-test')
})
it('모델을 지정하면 해당 모델 엔드포인트를 호출한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
candidates: [{ content: { parts: [{ text: '요약' }] } }],
}),
})
await complete(
'프롬프트',
{ provider: 'gemini', apiKey: 'AIza-test', model: 'gemini-2.5-pro' },
{ fetchFn: mockFetch },
)
expect(mockFetch.mock.calls[0][0]).toContain('gemini-2.5-pro')
})
})
describe('toProviderSettings', () => {
it('provider가 없으면 기존 Gemini 호출부와 동일하게 동작한다', () => {
expect(toProviderSettings(undefined, 'legacy-key')).toEqual({
provider: 'gemini',
apiKey: 'legacy-key',
})
})
it('provider가 있으면 그대로 사용한다', () => {
expect(toProviderSettings(OPENAI_SETTINGS, 'ignored')).toBe(OPENAI_SETTINGS)
})
})
describe('describeModel', () => {
it('gemini는 기본 모델명을 돌려준다', () => {
expect(describeModel({ provider: 'gemini', apiKey: 'k' })).toBe(
DEFAULT_GEMINI_MODEL,
)
})
it('openai-compatible은 설정한 모델명을 돌려준다', () => {
expect(describeModel(OPENAI_SETTINGS)).toBe('google/gemini-2.5-flash-lite')
})
})
describe('프리셋', () => {
it('알 수 없는 id는 기본 프리셋으로 폴백한다', () => {
expect(findPreset('없는-프리셋').id).toBe('gemini')
expect(findPreset(null).id).toBe('gemini')
})
it('openai-compatible 프리셋은 base URL 또는 직접 입력 안내를 갖는다', () => {
for (const preset of PROVIDER_PRESETS) {
if (preset.provider !== 'openai-compatible') continue
expect(preset.id === 'custom' || preset.baseUrl.length > 0).toBe(true)
}
})
})