mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
* 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>
113 lines
3.5 KiB
TypeScript
113 lines
3.5 KiB
TypeScript
import type {
|
|
CompletionOptions,
|
|
CompletionResult,
|
|
ProviderSettings,
|
|
} from './types'
|
|
|
|
/**
|
|
* base URL 검증.
|
|
*
|
|
* 이 값은 사용자가 설정 화면에서 입력하고 서버(Route Handler)가 그대로 fetch 하므로,
|
|
* http/https 이외의 스킴은 거부한다. 앱을 localhost 밖으로 노출한다면
|
|
* SECURITY.md의 "외부 엔드포인트" 항목을 먼저 확인할 것.
|
|
*/
|
|
export function normalizeBaseUrl(baseUrl: string): string | null {
|
|
const trimmed = baseUrl.trim().replace(/\/+$/, '')
|
|
if (trimmed.length === 0) return null
|
|
|
|
let parsed: URL
|
|
try {
|
|
parsed = new URL(trimmed)
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
|
|
|
|
return trimmed
|
|
}
|
|
|
|
/**
|
|
* OpenAI Chat Completions 호환 엔드포인트 호출.
|
|
* OrcaRouter · OpenAI · Ollama · LM Studio · vLLM 등이 모두 이 형식을 따른다.
|
|
*/
|
|
export async function completeWithOpenAICompatible(
|
|
prompt: string,
|
|
settings: ProviderSettings,
|
|
options: CompletionOptions = {},
|
|
): Promise<CompletionResult> {
|
|
const { fetchFn = fetch } = options
|
|
|
|
const baseUrl = normalizeBaseUrl(settings.baseUrl ?? '')
|
|
if (!baseUrl) {
|
|
return {
|
|
success: false,
|
|
error: 'API 주소(base URL)가 올바르지 않습니다. http:// 또는 https:// 로 시작해야 합니다.',
|
|
}
|
|
}
|
|
|
|
const model = settings.model?.trim() ?? ''
|
|
if (model.length === 0) {
|
|
return { success: false, error: '모델 이름이 필요합니다.' }
|
|
}
|
|
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
}
|
|
|
|
// 로컬 모델 서버(Ollama/LM Studio)는 키 없이 동작하므로 키가 없어도 호출한다.
|
|
//
|
|
// NOTE: 일부 라우터는 "이 요청이 어느 앱에서 왔는지" 식별하는 헤더를 받는다
|
|
// (OpenRouter의 HTTP-Referer / X-Title 등). 특정 서비스와 제휴해 트래픽을
|
|
// 귀속시키려면 그 서비스가 공식 문서로 밝힌 헤더를 여기에 추가하면 된다.
|
|
// 문서화되지 않은 헤더를 추측해서 보내지는 않는다.
|
|
const apiKey = settings.apiKey.trim()
|
|
if (apiKey.length > 0) {
|
|
headers.Authorization = `Bearer ${apiKey}`
|
|
}
|
|
|
|
try {
|
|
const response = await fetchFn(`${baseUrl}/chat/completions`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({
|
|
model,
|
|
messages: [{ role: 'user', content: prompt }],
|
|
stream: false,
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 429) {
|
|
return {
|
|
success: false,
|
|
error: 'API 요청 한도 초과. 잠시 후 자동 재시도됩니다.',
|
|
rateLimited: true,
|
|
}
|
|
}
|
|
return { success: false, error: describeHttpError(response.status) }
|
|
}
|
|
|
|
const data = await response.json()
|
|
const text: string = data?.choices?.[0]?.message?.content ?? ''
|
|
|
|
return { success: true, text }
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
|
return { success: false, error: `API 호출 중 오류: ${message}` }
|
|
}
|
|
}
|
|
|
|
function describeHttpError(status: number): string {
|
|
if (status === 401 || status === 403) {
|
|
return `인증에 실패했습니다 (${status}). API 키를 확인해주세요.`
|
|
}
|
|
if (status === 404) {
|
|
return `엔드포인트를 찾을 수 없습니다 (404). base URL과 모델 이름을 확인해주세요.`
|
|
}
|
|
if (status === 402) {
|
|
return '크레딧이 부족합니다 (402). 프로바이더 잔액을 확인해주세요.'
|
|
}
|
|
return `API 호출 실패: ${status}`
|
|
}
|