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:
authored and GitHub committed 2026-09-07 18:14:30 +09:00
1 parent f4ad512056
commit 352ac2ffd1
25 files changed
+1439 -269

No files matched your search

+100
View File
@@ -4,6 +4,10 @@ import {
setStoredApiKey,
clearStoredApiKey,
maskApiKey,
getStoredProviderConfig,
setStoredProviderConfig,
clearStoredProviderConfig,
getProviderRequestPayload,
} from '@/lib/api-key-storage'
describe('LocalStorage api key helpers', () => {
@@ -48,3 +52,99 @@ describe('maskApiKey', () => {
expect(maskApiKey('123456789')).toBe('1234••••6789')
})
})
describe('프로바이더 설정 저장', () => {
beforeEach(() => {
window.localStorage.clear()
})
it('저장 후 동일한 설정을 돌려준다', () => {
setStoredProviderConfig({
presetId: 'orcarouter',
apiKey: 'sk-test',
baseUrl: 'https://api.orcarouter.ai/v1',
model: 'google/gemini-2.5-flash-lite',
})
expect(getStoredProviderConfig()).toEqual({
presetId: 'orcarouter',
apiKey: 'sk-test',
baseUrl: 'https://api.orcarouter.ai/v1',
model: 'google/gemini-2.5-flash-lite',
})
})
it('미설정이거나 깨진 값이면 null', () => {
expect(getStoredProviderConfig()).toBeNull()
window.localStorage.setItem('meeting-minutes:llm-provider', '{not json')
expect(getStoredProviderConfig()).toBeNull()
})
it('clear 시 삭제된다', () => {
setStoredProviderConfig({
presetId: 'openai',
apiKey: 'sk',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
})
clearStoredProviderConfig()
expect(getStoredProviderConfig()).toBeNull()
})
})
describe('getProviderRequestPayload', () => {
beforeEach(() => {
window.localStorage.clear()
})
it('프로바이더 설정이 없으면 기존 Gemini 키를 그대로 사용한다', () => {
setStoredApiKey('AIza-legacy')
expect(getProviderRequestPayload()).toEqual({
provider: 'gemini',
apiKey: 'AIza-legacy',
baseUrl: '',
model: '',
})
})
it('아무것도 없으면 빈 gemini 설정을 돌려준다', () => {
expect(getProviderRequestPayload()).toEqual({
provider: 'gemini',
apiKey: '',
baseUrl: '',
model: '',
})
})
it('프리셋 id로부터 provider를 결정한다', () => {
setStoredProviderConfig({
presetId: 'local',
apiKey: '',
baseUrl: 'http://localhost:11434/v1',
model: 'llama3.1',
})
expect(getProviderRequestPayload()).toEqual({
provider: 'openai-compatible',
apiKey: '',
baseUrl: 'http://localhost:11434/v1',
model: 'llama3.1',
})
})
it('gemini 프리셋에서 키를 비워두면 기존 키로 폴백한다', () => {
setStoredApiKey('AIza-legacy')
setStoredProviderConfig({
presetId: 'gemini',
apiKey: '',
baseUrl: '',
model: 'gemini-2.5-pro',
})
const payload = getProviderRequestPayload()
expect(payload.provider).toBe('gemini')
expect(payload.apiKey).toBe('AIza-legacy')
expect(payload.model).toBe('gemini-2.5-pro')
})
})
+142 -1
View File
@@ -1,5 +1,10 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { resolveGeminiApiKey, isEnvKeyConfigured } from '@/lib/api-keys'
import {
resolveGeminiApiKey,
isEnvKeyConfigured,
isEnvProviderConfigured,
resolveProviderSettings,
} from '@/lib/api-keys'
describe('resolveGeminiApiKey', () => {
const originalEnv = process.env.GEMINI_API_KEY
@@ -70,3 +75,139 @@ describe('isEnvKeyConfigured', () => {
expect(isEnvKeyConfigured()).toBe(false)
})
})
describe('resolveProviderSettings', () => {
const ENV_KEYS = [
'GEMINI_API_KEY',
'LLM_PROVIDER',
'LLM_BASE_URL',
'LLM_API_KEY',
'LLM_MODEL',
] as const
const saved: Record<string, string | undefined> = {}
beforeEach(() => {
for (const key of ENV_KEYS) {
saved[key] = process.env[key]
delete process.env[key]
}
})
afterEach(() => {
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key]
else process.env[key] = saved[key]
}
})
it('provider를 지정하지 않으면 gemini로 간주한다', () => {
expect(resolveProviderSettings({ apiKey: 'req-key' })).toEqual({
provider: 'gemini',
apiKey: 'req-key',
model: undefined,
})
})
it('gemini인데 키가 아무데도 없으면 null (단순 변환 폴백)', () => {
expect(resolveProviderSettings({})).toBeNull()
expect(resolveProviderSettings(null)).toBeNull()
})
it('gemini 키는 요청 → GEMINI_API_KEY 순으로 해석한다', () => {
process.env.GEMINI_API_KEY = 'env-key'
expect(resolveProviderSettings({ apiKey: 'req-key' })?.apiKey).toBe('req-key')
expect(resolveProviderSettings({})?.apiKey).toBe('env-key')
})
it('openai-compatible은 baseUrl과 model이 모두 있어야 한다', () => {
const base = { provider: 'openai-compatible', apiKey: 'sk-x' }
expect(resolveProviderSettings(base)).toBeNull()
expect(
resolveProviderSettings({ ...base, baseUrl: 'https://a.example/v1' }),
).toBeNull()
expect(
resolveProviderSettings({ ...base, model: 'gpt-4o-mini' }),
).toBeNull()
expect(
resolveProviderSettings({
...base,
baseUrl: 'https://a.example/v1',
model: 'gpt-4o-mini',
}),
).toEqual({
provider: 'openai-compatible',
apiKey: 'sk-x',
baseUrl: 'https://a.example/v1',
model: 'gpt-4o-mini',
})
})
it('openai-compatible은 키가 비어도 허용한다 (로컬 모델 서버)', () => {
const settings = resolveProviderSettings({
provider: 'openai-compatible',
apiKey: '',
baseUrl: 'http://localhost:11434/v1',
model: 'llama3.1',
})
expect(settings?.apiKey).toBe('')
})
it('LLM_* 환경변수만으로도 설정된다', () => {
process.env.LLM_PROVIDER = 'openai-compatible'
process.env.LLM_BASE_URL = 'https://api.orcarouter.ai/v1'
process.env.LLM_MODEL = 'google/gemini-2.5-flash-lite'
process.env.LLM_API_KEY = 'sk-env'
expect(resolveProviderSettings({})).toEqual({
provider: 'openai-compatible',
apiKey: 'sk-env',
baseUrl: 'https://api.orcarouter.ai/v1',
model: 'google/gemini-2.5-flash-lite',
})
})
it('알 수 없는 provider 값은 무시하고 기본값으로 떨어진다', () => {
process.env.GEMINI_API_KEY = 'env-key'
expect(resolveProviderSettings({ provider: 'anthropic' })?.provider).toBe(
'gemini',
)
})
})
describe('isEnvProviderConfigured', () => {
const ENV_KEYS = ['GEMINI_API_KEY', 'LLM_PROVIDER', 'LLM_BASE_URL', 'LLM_MODEL', 'LLM_API_KEY'] as const
const saved: Record<string, string | undefined> = {}
beforeEach(() => {
for (const key of ENV_KEYS) {
saved[key] = process.env[key]
delete process.env[key]
}
})
afterEach(() => {
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key]
else process.env[key] = saved[key]
}
})
it('아무 것도 없으면 false', () => {
expect(isEnvProviderConfigured()).toBe(false)
})
it('GEMINI_API_KEY만 있어도 true', () => {
process.env.GEMINI_API_KEY = 'k'
expect(isEnvProviderConfigured()).toBe(true)
})
it('openai-compatible은 base URL과 모델까지 있어야 true', () => {
process.env.LLM_PROVIDER = 'openai-compatible'
process.env.LLM_API_KEY = 'k'
expect(isEnvProviderConfigured()).toBe(false)
process.env.LLM_BASE_URL = 'https://a.example/v1'
process.env.LLM_MODEL = 'm'
expect(isEnvProviderConfigured()).toBe(true)
})
})
+27
View File
@@ -128,3 +128,30 @@ describe('generateLiveSummary', () => {
expect(init.headers['x-goog-api-key']).toBe('secret-key')
})
})
describe('generateLiveSummary — openai-compatible', () => {
it('OpenAI 호환 엔드포인트로 중간 요약을 만든다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: '## 요약\n진행 중' } }],
}),
})
const result = await generateLiveSummary('회의 내용', {
provider: {
provider: 'openai-compatible',
apiKey: 'sk-test',
baseUrl: 'http://localhost:11434/v1',
model: 'llama3.1',
},
fetchFn: mockFetch,
})
expect(result.success).toBe(true)
expect(mockFetch.mock.calls[0][0]).toBe(
'http://localhost:11434/v1/chat/completions',
)
})
})
+47
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest'
import {
generateSimpleMinutes,
generateAiMinutes,
generateGeminiMinutes,
type MinutesInput,
} from '@/lib/minutes-generator'
@@ -105,3 +106,49 @@ describe('generateGeminiMinutes', () => {
}
})
})
describe('generateAiMinutes — openai-compatible', () => {
const settings = {
provider: 'openai-compatible' as const,
apiKey: 'sk-test',
baseUrl: 'https://api.orcarouter.ai/v1',
model: 'google/gemini-2.5-flash-lite',
}
it('OpenAI 호환 엔드포인트 응답으로 회의록을 만든다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: '## 요약\n- 리팩토링 완료' } }],
}),
})
const result = await generateAiMinutes(sampleInput, {
provider: settings,
fetchFn: mockFetch,
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.markdown).toContain('# 주간 스프린트 회의')
expect(result.markdown).toContain('리팩토링 완료')
// 하단 문구는 실제 사용한 모델을 표기한다
expect(result.markdown).toContain('google/gemini-2.5-flash-lite')
}
})
it('응답이 비어 있으면 실패로 처리해 단순 변환 폴백을 유도한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: ' ' } }] }),
})
const result = await generateAiMinutes(sampleInput, {
provider: settings,
fetchFn: mockFetch,
})
expect(result.success).toBe(false)
})
})
+222
View File
@@ -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)
}
})
})