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

+198 -78
View File
@@ -4,71 +4,124 @@ import Link from 'next/link'
import { useEffect, useState } from 'react'
import {
clearStoredApiKey,
clearStoredProviderConfig,
getStoredApiKey,
getStoredProviderConfig,
maskApiKey,
setStoredApiKey,
setStoredProviderConfig,
} from '@/lib/api-key-storage'
import {
DEFAULT_PRESET_ID,
PROVIDER_PRESETS,
findPreset,
} from '@/lib/providers'
type TestState =
| { status: 'idle' }
| { status: 'testing' }
| { status: 'success'; message: string; reply?: string }
| { status: 'success'; message: string; model?: string; reply?: string }
| { status: 'failed'; message: string }
export default function SettingsPage() {
const [storedKey, setStoredKey] = useState<string | null>(null)
const [draftKey, setDraftKey] = useState('')
const [presetId, setPresetId] = useState(DEFAULT_PRESET_ID)
const [apiKey, setApiKey] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [model, setModel] = useState('')
const [showKey, setShowKey] = useState(false)
const [savedKey, setSavedKey] = useState<string | null>(null)
const [envConfigured, setEnvConfigured] = useState<boolean | null>(null)
const [testState, setTestState] = useState<TestState>({ status: 'idle' })
const [savedToast, setSavedToast] = useState(false)
const preset = findPreset(presetId)
const isOpenAiCompatible = preset.provider === 'openai-compatible'
useEffect(() => {
setStoredKey(getStoredApiKey())
const stored = getStoredProviderConfig()
if (stored) {
const storedPreset = findPreset(stored.presetId)
setPresetId(storedPreset.id)
setApiKey(stored.apiKey)
setBaseUrl(stored.baseUrl)
setModel(stored.model)
setSavedKey(stored.apiKey || null)
} else {
// 프로바이더 설정 이전에 저장해둔 Gemini 키를 그대로 이어받는다.
const legacyKey = getStoredApiKey()
setApiKey(legacyKey ?? '')
setModel(findPreset(DEFAULT_PRESET_ID).defaultModel)
setSavedKey(legacyKey)
}
fetch('/api/settings/status')
.then((r) => r.json())
.then((d) => setEnvConfigured(!!d.envConfigured))
.then((d) => setEnvConfigured(!!d.envConfigured || !!d.envProviderConfigured))
.catch(() => setEnvConfigured(false))
}, [])
function handlePresetChange(nextId: string) {
const next = findPreset(nextId)
if (next.id === presetId) return
setPresetId(next.id)
setBaseUrl(next.baseUrl)
setModel(next.defaultModel)
// 프로바이더가 바뀌면 이전 키는 무의미할 뿐 아니라, 그대로 두면
// 다른 회사 엔드포인트로 전송될 수 있으므로 비운다.
setApiKey('')
setTestState({ status: 'idle' })
}
function handleSave() {
const trimmed = draftKey.trim()
if (!trimmed) return
setStoredApiKey(trimmed)
setStoredKey(trimmed)
setDraftKey('')
setStoredProviderConfig({
presetId,
apiKey: apiKey.trim(),
baseUrl: baseUrl.trim(),
model: model.trim(),
})
setSavedKey(apiKey.trim() || null)
setSavedToast(true)
setTimeout(() => setSavedToast(false), 2000)
}
function handleClear() {
if (!confirm('브라우저에 저장된 키를 삭제할까요? 환경변수가 설정되어 있다면 그것을 사용합니다.')) {
if (
!confirm(
'브라우저에 저장된 프로바이더 설정과 키를 삭제할까요? 환경변수가 설정되어 있다면 그것을 사용합니다.',
)
) {
return
}
clearStoredProviderConfig()
clearStoredApiKey()
setStoredKey(null)
const fallback = findPreset(DEFAULT_PRESET_ID)
setPresetId(fallback.id)
setApiKey('')
setBaseUrl(fallback.baseUrl)
setModel(fallback.defaultModel)
setSavedKey(null)
setTestState({ status: 'idle' })
}
async function handleTest() {
const keyToTest = draftKey.trim() || storedKey
if (!keyToTest) {
setTestState({ status: 'failed', message: '테스트할 키가 없습니다.' })
return
}
setTestState({ status: 'testing' })
try {
const res = await fetch('/api/settings/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: keyToTest }),
body: JSON.stringify({
provider: preset.provider,
apiKey: apiKey.trim(),
baseUrl: baseUrl.trim(),
model: model.trim(),
}),
})
const data = await res.json()
if (data.ok) {
setTestState({
status: 'success',
message: data.message,
model: data.model,
reply: data.reply,
})
} else {
@@ -82,13 +135,18 @@ export default function SettingsPage() {
}
}
const activeSource = storedKey
const canTest =
testState.status !== 'testing' &&
(preset.apiKeyOptional || apiKey.trim().length > 0) &&
(!isOpenAiCompatible || (baseUrl.trim().length > 0 && model.trim().length > 0))
const activeSource = savedKey
? '브라우저 (LocalStorage)'
: envConfigured
? '환경변수 (서버)'
: '없음'
const activeBadgeStyle = storedKey
const activeBadgeStyle = savedKey
? 'bg-purple-100 text-purple-700'
: envConfigured
? 'bg-green-100 text-green-700'
@@ -117,15 +175,15 @@ export default function SettingsPage() {
⚙️ 설정
</h1>
<p className="mt-2 text-sm text-neutral-500">
Gemini API 키를 브라우저에 저장합니다. 키는 서버에 저장되지 않으며,
요청 시점에만 전송되어 사용됩니다.
AI 요약에 사용할 프로바이더와 키를 브라우저에 저장합니다. 키는 서버 DB에
저장되지 않으며, 요청 시점에만 전송되어 사용됩니다.
</p>
</header>
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-neutral-800">
현재 키 소스
현재 설정
</h2>
<span
className={`text-xs px-2.5 py-1 rounded-full font-medium ${activeBadgeStyle}`}
@@ -135,16 +193,28 @@ export default function SettingsPage() {
</div>
<div className="space-y-1 text-sm text-neutral-600">
<p>
<span className="inline-block w-32 text-neutral-500">프로바이더:</span>{' '}
<span className="font-medium text-neutral-800">{preset.label}</span>
</p>
<p>
<span className="inline-block w-32 text-neutral-500">모델:</span>{' '}
{model.trim() ? (
<span className="font-mono text-xs">{model.trim()}</span>
) : (
<span className="text-neutral-400">기본값</span>
)}
</p>
<p>
<span className="inline-block w-32 text-neutral-500">브라우저 키:</span>{' '}
{storedKey ? (
<span className="font-mono">{maskApiKey(storedKey)}</span>
{savedKey ? (
<span className="font-mono">{maskApiKey(savedKey)}</span>
) : (
<span className="text-neutral-400">미설정</span>
)}
</p>
<p>
<span className="inline-block w-32 text-neutral-500">환경변수 키:</span>{' '}
<span className="inline-block w-32 text-neutral-500">환경변수:</span>{' '}
{envConfigured === null ? (
<span className="text-neutral-400">확인 중...</span>
) : envConfigured ? (
@@ -156,73 +226,121 @@ export default function SettingsPage() {
</div>
<p className="mt-3 text-xs text-neutral-500">
💡 우선순위: 브라우저 키 → 환경변수 → 단순 변환 폴백
💡 우선순위: 브라우저 설정 → 환경변수 → 단순 변환 폴백
</p>
</section>
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-6">
<h2 className="mb-4 text-base font-semibold text-neutral-800">
Gemini API 키
프로바이더
</h2>
<label className="block mb-2 text-sm text-neutral-600">
새 키 입력
</label>
<div className="flex gap-2">
<input
type={showKey ? 'text' : 'password'}
value={draftKey}
onChange={(e) => setDraftKey(e.target.value)}
placeholder="AIzaSy..."
className="flex-1 rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all"
/>
<button
onClick={() => setShowKey((v) => !v)}
className="rounded-xl border border-neutral-300 px-3 text-sm hover:bg-neutral-50 transition-colors"
title={showKey ? '숨기기' : '보이기'}
>
{showKey ? '🙈' : '👁️'}
</button>
<div className="flex flex-wrap gap-2">
{PROVIDER_PRESETS.map((item) => (
<button
key={item.id}
onClick={() => handlePresetChange(item.id)}
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-all ${
presetId === item.id
? 'border-purple-400 bg-purple-50 text-purple-700'
: 'border-neutral-200 bg-white text-neutral-600 hover:border-neutral-300 hover:bg-neutral-50'
}`}
>
{item.label}
</button>
))}
</div>
<p className="mt-2 text-xs text-neutral-500">
<a
href="https://aistudio.google.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-purple-600"
>
Google AI Studio
</a>
에서 무료로 발급할 수 있습니다.
</p>
<p className="mt-3 text-xs text-neutral-500">{preset.description}</p>
<div className="mt-4 flex flex-wrap gap-2">
{isOpenAiCompatible && (
<div className="mt-5">
<label className="mb-2 block text-sm text-neutral-600">
API 주소 (base URL)
</label>
<input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.example.com/v1"
className="w-full rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all"
/>
<p className="mt-1 text-xs text-neutral-500">
OpenAI 호환 엔드포인트의 <code>/chat/completions</code> 앞부분까지
입력하세요.
</p>
</div>
)}
<div className="mt-5">
<label className="mb-2 block text-sm text-neutral-600">모델</label>
<input
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder={preset.defaultModel || 'model-id'}
className="w-full rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all"
/>
{preset.modelHint && (
<p className="mt-1 text-xs text-neutral-500">{preset.modelHint}</p>
)}
</div>
<div className="mt-5">
<label className="mb-2 block text-sm text-neutral-600">
{preset.apiKeyLabel}
</label>
<div className="flex gap-2">
<input
type={showKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={preset.apiKeyPlaceholder}
className="flex-1 rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all"
/>
<button
onClick={() => setShowKey((v) => !v)}
className="rounded-xl border border-neutral-300 px-3 text-sm hover:bg-neutral-50 transition-colors"
title={showKey ? '숨기기' : '보이기'}
>
{showKey ? '🙈' : '👁️'}
</button>
</div>
{preset.docsUrl && (
<p className="mt-2 text-xs text-neutral-500">
<a
href={preset.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-purple-600"
>
{preset.docsLabel ?? preset.docsUrl}
</a>
에서 발급할 수 있습니다.
</p>
)}
</div>
<div className="mt-5 flex flex-wrap gap-2">
<button
onClick={handleSave}
disabled={!draftKey.trim()}
className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
💾 저장
</button>
<button
onClick={handleTest}
disabled={
testState.status === 'testing' ||
(!draftKey.trim() && !storedKey)
}
disabled={!canTest}
className="rounded-lg border border-purple-300 bg-purple-50 px-4 py-2 text-sm font-medium text-purple-700 hover:bg-purple-100 disabled:opacity-50 transition-colors"
>
{testState.status === 'testing' ? '테스트 중...' : '🧪 테스트 호출'}
</button>
{storedKey && (
<button
onClick={handleClear}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 transition-colors"
>
🗑️ 삭제
</button>
)}
<button
onClick={handleClear}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 transition-colors"
>
🗑️ 삭제
</button>
</div>
{savedToast && (
@@ -234,11 +352,10 @@ export default function SettingsPage() {
{testState.status === 'success' && (
<div className="mt-4 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
<strong>✓ {testState.message}</strong>
{testState.reply && (
<p className="mt-1 font-mono text-xs text-green-600">
Gemini 응답: {testState.reply}
</p>
)}
<p className="mt-1 font-mono text-xs text-green-600">
{testState.model ? `${testState.model} → ` : ''}
{testState.reply}
</p>
</div>
)}
@@ -265,6 +382,9 @@ export default function SettingsPage() {
<li className="text-amber-700">
⚠️ <strong>회의 내용에 민감 정보가 포함된 경우</strong>, 실시간 녹음 기능은 음성을 Google 서버로 전송합니다 (Chrome Web Speech API 동작). 사내 컴플라이언스 정책 확인 후 사용해주세요.
</li>
<li className="text-amber-700">
⚠️ <strong>중계 서비스를 고르면 회의 전문이 그 회사 서버를 거칩니다.</strong> OpenAI·Gemini 직접 호출은 해당 회사만, OrcaRouter 같은 라우터는 라우터 운영사 + 실제 모델 제공사 양쪽을 거칩니다. 외부 전송이 곤란하면 <strong>로컬 모델</strong>을 선택하세요.
</li>
</ul>
</section>
</div>