mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
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>
This commit is contained in:
25 files changed
+1439
-269
No files matched your search
@@ -0,0 +1,222 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user