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

+11 -38
View File
@@ -1,5 +1,6 @@
import { NextRequest } from 'next/server'
import { resolveGeminiApiKey } from '@/lib/api-keys'
import { resolveProviderSettings } from '@/lib/api-keys'
import { complete, describeModel } from '@/lib/providers'
export const dynamic = 'force-dynamic'
@@ -8,60 +9,32 @@ const TEST_PROMPT = '"OK"라고만 한 단어로 답하세요.'
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}))
const apiKey = resolveGeminiApiKey(body.apiKey)
const settings = resolveProviderSettings(body)
if (!apiKey) {
if (!settings) {
return Response.json(
{
ok: false,
error: '키가 비어 있습니다.',
error: '설정이 비어 있습니다. 키(또는 base URL과 모델)를 확인해주세요.',
},
{ status: 400 },
)
}
const url =
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
const result = await complete(TEST_PROMPT, settings)
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify({
contents: [{ parts: [{ text: TEST_PROMPT }] }],
}),
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
const message =
response.status === 400
? '잘못된 API 키 형식입니다.'
: response.status === 403
? '권한이 거부되었습니다. 키를 확인해주세요.'
: response.status === 429
? '요청 한도를 초과했지만 키 자체는 유효해 보입니다.'
: `Gemini API 오류: ${response.status}`
if (!result.success) {
return Response.json(
{
ok: false,
error: message,
detail: errorText.slice(0, 200),
},
{ ok: false, error: result.error },
{ status: 200 },
)
}
const data = await response.json()
const reply: string =
data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? ''
return Response.json({
ok: true,
message: '키가 정상적으로 동작합니다.',
reply: reply.slice(0, 50),
message: '설정이 정상적으로 동작합니다.',
model: describeModel(settings),
reply: result.text.trim().slice(0, 50),
})
} catch (err) {
const message = err instanceof Error ? err.message : '알 수 없는 오류'