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
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { findPreset, type ProviderId } from './providers'
|
||||
|
||||
const STORAGE_KEY = 'meeting-minutes:gemini-api-key'
|
||||
|
||||
export function getStoredApiKey(): string | null {
|
||||
@@ -34,3 +36,86 @@ export function maskApiKey(key: string): string {
|
||||
if (key.length <= 8) return '••••'
|
||||
return `${key.slice(0, 4)}••••${key.slice(-4)}`
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 프로바이더 설정 (Gemini / OpenAI 호환 엔드포인트)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const PROVIDER_STORAGE_KEY = 'meeting-minutes:llm-provider'
|
||||
|
||||
export interface StoredProviderConfig {
|
||||
presetId: string
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export interface ProviderRequestPayload {
|
||||
provider: ProviderId
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export function getStoredProviderConfig(): StoredProviderConfig | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PROVIDER_STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as Partial<StoredProviderConfig>
|
||||
if (typeof parsed?.presetId !== 'string') return null
|
||||
return {
|
||||
presetId: parsed.presetId,
|
||||
apiKey: typeof parsed.apiKey === 'string' ? parsed.apiKey : '',
|
||||
baseUrl: typeof parsed.baseUrl === 'string' ? parsed.baseUrl : '',
|
||||
model: typeof parsed.model === 'string' ? parsed.model : '',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredProviderConfig(config: StoredProviderConfig): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(PROVIDER_STORAGE_KEY, JSON.stringify(config))
|
||||
} catch {
|
||||
// ignore storage errors (private mode, quota)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredProviderConfig(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.removeItem(PROVIDER_STORAGE_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 요청 body에 실을 프로바이더 정보.
|
||||
*
|
||||
* 프로바이더 설정이 없으면 기존 Gemini 키만 쓰던 사용자를 그대로 이어받는다
|
||||
* (별도 마이그레이션 없이 동작).
|
||||
*/
|
||||
export function getProviderRequestPayload(): ProviderRequestPayload {
|
||||
const stored = getStoredProviderConfig()
|
||||
|
||||
if (!stored) {
|
||||
return {
|
||||
provider: 'gemini',
|
||||
apiKey: getStoredApiKey() ?? '',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
}
|
||||
}
|
||||
|
||||
const preset = findPreset(stored.presetId)
|
||||
return {
|
||||
provider: preset.provider,
|
||||
apiKey: stored.apiKey || (preset.provider === 'gemini' ? getStoredApiKey() ?? '' : ''),
|
||||
baseUrl: stored.baseUrl,
|
||||
model: stored.model,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isProviderId, type ProviderId, type ProviderSettings } from './providers'
|
||||
|
||||
/**
|
||||
* Gemini API 키 해석 우선순위:
|
||||
* 1) 요청 body로 전달된 키 (브라우저 LocalStorage에서 옴)
|
||||
@@ -19,3 +21,70 @@ export function resolveGeminiApiKey(
|
||||
export function isEnvKeyConfigured(): boolean {
|
||||
return (process.env.GEMINI_API_KEY ?? '').trim().length > 0
|
||||
}
|
||||
|
||||
/** LLM_* 환경변수로 프로바이더가 지정되어 있는지 */
|
||||
export function isEnvProviderConfigured(): boolean {
|
||||
const provider = env('LLM_PROVIDER')
|
||||
if (provider === 'openai-compatible') {
|
||||
return env('LLM_BASE_URL').length > 0 && env('LLM_MODEL').length > 0
|
||||
}
|
||||
return isEnvKeyConfigured() || env('LLM_API_KEY').length > 0
|
||||
}
|
||||
|
||||
export interface ProviderRequestBody {
|
||||
provider?: unknown
|
||||
apiKey?: unknown
|
||||
baseUrl?: unknown
|
||||
model?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 body + 환경변수로부터 프로바이더 설정을 만든다.
|
||||
* 필드별 우선순위는 기존 키 해석과 동일하게 "요청 → 환경변수" 순이다.
|
||||
*
|
||||
* 사용 가능한 설정이 없으면 null을 돌려주고, 호출부는 단순 변환으로 폴백한다.
|
||||
*/
|
||||
export function resolveProviderSettings(
|
||||
body: ProviderRequestBody | null | undefined,
|
||||
): ProviderSettings | null {
|
||||
const provider = resolveProvider(body?.provider)
|
||||
|
||||
if (provider === 'gemini') {
|
||||
const apiKey = resolveGeminiApiKey(str(body?.apiKey) || env('LLM_API_KEY'))
|
||||
if (!apiKey) return null
|
||||
return {
|
||||
provider,
|
||||
apiKey,
|
||||
model: str(body?.model) || env('LLM_MODEL') || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = str(body?.baseUrl) || env('LLM_BASE_URL')
|
||||
const model = str(body?.model) || env('LLM_MODEL')
|
||||
if (baseUrl.length === 0 || model.length === 0) return null
|
||||
|
||||
// 로컬 모델 서버는 키가 없어도 되므로 빈 문자열을 허용한다.
|
||||
return {
|
||||
provider,
|
||||
apiKey: str(body?.apiKey) || env('LLM_API_KEY'),
|
||||
baseUrl,
|
||||
model,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProvider(requested: unknown): ProviderId {
|
||||
if (isProviderId(requested)) return requested
|
||||
|
||||
const fromEnv = env('LLM_PROVIDER')
|
||||
if (isProviderId(fromEnv)) return fromEnv
|
||||
|
||||
return 'gemini'
|
||||
}
|
||||
|
||||
function str(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function env(name: string): string {
|
||||
return (process.env[name] ?? '').trim()
|
||||
}
|
||||
+18
-45
@@ -1,3 +1,4 @@
|
||||
import { complete, toProviderSettings, type ProviderSettings } from './providers'
|
||||
import {
|
||||
buildPrompt,
|
||||
resolveDepth,
|
||||
@@ -10,7 +11,9 @@ type LiveSummaryResult =
|
||||
| { success: false; error: string; rateLimited?: boolean }
|
||||
|
||||
interface LiveSummaryOptions {
|
||||
apiKey: string
|
||||
/** 프로바이더 설정. 생략하면 apiKey로 Gemini를 호출한다. */
|
||||
provider?: ProviderSettings
|
||||
apiKey?: string
|
||||
template?: TemplateId
|
||||
depth?: SummaryDepth
|
||||
customPrompt?: string
|
||||
@@ -21,11 +24,8 @@ export async function generateLiveSummary(
|
||||
transcript: string,
|
||||
options: LiveSummaryOptions,
|
||||
): Promise<LiveSummaryResult> {
|
||||
const { apiKey, fetchFn = fetch } = options
|
||||
|
||||
if (!apiKey || apiKey.trim().length === 0) {
|
||||
return { success: false, error: 'Gemini API 키가 필요합니다.' }
|
||||
}
|
||||
const { fetchFn } = options
|
||||
const settings = toProviderSettings(options.provider, options.apiKey)
|
||||
|
||||
const trimmed = transcript.trim()
|
||||
if (trimmed.length === 0) {
|
||||
@@ -42,46 +42,19 @@ export async function generateLiveSummary(
|
||||
customPrompt: options.customPrompt,
|
||||
})
|
||||
|
||||
const url =
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
|
||||
const result = await complete(prompt, settings, { fetchFn })
|
||||
|
||||
try {
|
||||
const response = await fetchFn(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: prompt }] }],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Gemini 요청 한도 초과. 잠시 후 자동 재시도됩니다.',
|
||||
rateLimited: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `Gemini API 호출 실패: ${response.status}`,
|
||||
}
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
...(result.rateLimited ? { rateLimited: true } : {}),
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const markdown: string =
|
||||
data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
|
||||
|
||||
if (markdown.trim().length === 0) {
|
||||
return { success: false, error: '요약 결과가 비어 있습니다.' }
|
||||
}
|
||||
|
||||
return { success: true, markdown }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
return { success: false, error: `실시간 요약 중 오류: ${message}` }
|
||||
}
|
||||
|
||||
if (result.text.trim().length === 0) {
|
||||
return { success: false, error: '요약 결과가 비어 있습니다.' }
|
||||
}
|
||||
|
||||
return { success: true, markdown: result.text }
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
complete,
|
||||
describeModel,
|
||||
toProviderSettings,
|
||||
type ProviderSettings,
|
||||
} from './providers'
|
||||
import {
|
||||
buildPrompt,
|
||||
resolveDepth,
|
||||
@@ -14,12 +20,14 @@ export interface MinutesInput {
|
||||
customPrompt?: string
|
||||
}
|
||||
|
||||
type GeminiResult =
|
||||
type AiMinutesResult =
|
||||
| { success: true; markdown: string }
|
||||
| { success: false; error: string }
|
||||
| { success: false; error: string; rateLimited?: boolean }
|
||||
|
||||
interface GeminiOptions {
|
||||
apiKey: string
|
||||
interface AiMinutesOptions {
|
||||
/** 프로바이더 설정. 생략하면 apiKey로 Gemini를 호출한다. */
|
||||
provider?: ProviderSettings
|
||||
apiKey?: string
|
||||
fetchFn?: typeof fetch
|
||||
}
|
||||
|
||||
@@ -49,15 +57,12 @@ ${content}
|
||||
`
|
||||
}
|
||||
|
||||
export async function generateGeminiMinutes(
|
||||
export async function generateAiMinutes(
|
||||
input: MinutesInput,
|
||||
options: GeminiOptions,
|
||||
): Promise<GeminiResult> {
|
||||
const { apiKey, fetchFn = fetch } = options
|
||||
|
||||
if (!apiKey || apiKey.trim().length === 0) {
|
||||
return { success: false, error: 'Gemini API 키가 필요합니다.' }
|
||||
}
|
||||
options: AiMinutesOptions,
|
||||
): Promise<AiMinutesResult> {
|
||||
const { fetchFn } = options
|
||||
const settings = toProviderSettings(options.provider, options.apiKey)
|
||||
|
||||
const templateId = input.template ?? 'meeting'
|
||||
const depth = resolveDepth(templateId, input.depth)
|
||||
@@ -69,34 +74,23 @@ export async function generateGeminiMinutes(
|
||||
customPrompt: input.customPrompt,
|
||||
})
|
||||
|
||||
const url =
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
|
||||
const result = await complete(prompt, settings, { fetchFn })
|
||||
|
||||
try {
|
||||
const response = await fetchFn(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: prompt }] }],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Gemini API 호출 실패: ${response.status} ${response.statusText}`,
|
||||
}
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
...(result.rateLimited ? { rateLimited: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const generatedText =
|
||||
data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
|
||||
const generatedText = result.text.trim()
|
||||
if (generatedText.length === 0) {
|
||||
return { success: false, error: '요약 결과가 비어 있습니다.' }
|
||||
}
|
||||
|
||||
const dateStr = formatDate(input.date)
|
||||
const markdown = `# ${input.title}
|
||||
const dateStr = formatDate(input.date)
|
||||
const markdown = `# ${input.title}
|
||||
|
||||
**날짜**: ${dateStr}
|
||||
|
||||
@@ -106,15 +100,13 @@ ${generatedText}
|
||||
|
||||
---
|
||||
|
||||
*이 회의록은 Gemini AI를 활용하여 자동 생성되었습니다.*
|
||||
*이 회의록은 AI(${describeModel(settings)})를 활용하여 자동 생성되었습니다.*
|
||||
`
|
||||
|
||||
return { success: true, markdown }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
return {
|
||||
success: false,
|
||||
error: `Gemini API 호출 중 오류 발생: ${message}`,
|
||||
}
|
||||
}
|
||||
return { success: true, markdown }
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated `generateAiMinutes`를 사용하세요. Gemini 전용 호출부 하위 호환용입니다.
|
||||
*/
|
||||
export const generateGeminiMinutes = generateAiMinutes
|
||||
@@ -0,0 +1,76 @@
|
||||
import type {
|
||||
CompletionOptions,
|
||||
CompletionResult,
|
||||
ProviderSettings,
|
||||
} from './types'
|
||||
|
||||
export const DEFAULT_GEMINI_MODEL = 'gemini-3.5-flash-lite'
|
||||
|
||||
const API_ROOT = 'https://generativelanguage.googleapis.com/v1beta/models'
|
||||
|
||||
export function resolveGeminiModel(model?: string): string {
|
||||
const trimmed = model?.trim() ?? ''
|
||||
return trimmed.length > 0 ? trimmed : DEFAULT_GEMINI_MODEL
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Generative Language API 직접 호출.
|
||||
* 키는 URL이 아닌 `x-goog-api-key` 헤더로 보낸다 (로그/리퍼러 유출 방지).
|
||||
*/
|
||||
export async function completeWithGemini(
|
||||
prompt: string,
|
||||
settings: ProviderSettings,
|
||||
options: CompletionOptions = {},
|
||||
): Promise<CompletionResult> {
|
||||
const { fetchFn = fetch } = options
|
||||
const apiKey = settings.apiKey.trim()
|
||||
|
||||
if (apiKey.length === 0) {
|
||||
return { success: false, error: 'Gemini API 키가 필요합니다.' }
|
||||
}
|
||||
|
||||
const model = resolveGeminiModel(settings.model)
|
||||
const url = `${API_ROOT}/${encodeURIComponent(model)}:generateContent`
|
||||
|
||||
try {
|
||||
const response = await fetchFn(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: prompt }] }],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Gemini API 요청 한도 초과. 잠시 후 자동 재시도됩니다.',
|
||||
rateLimited: true,
|
||||
}
|
||||
}
|
||||
return { success: false, error: describeHttpError(response.status) }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const text: string = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
|
||||
|
||||
return { success: true, text }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
return { success: false, error: `Gemini API 호출 중 오류: ${message}` }
|
||||
}
|
||||
}
|
||||
|
||||
function describeHttpError(status: number): string {
|
||||
if (status === 400) {
|
||||
return 'Gemini API 오류: 잘못된 요청 또는 키 형식입니다 (400).'
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
return `Gemini API 인증 실패 (${status}). 키를 확인해주세요.`
|
||||
}
|
||||
return `Gemini API 호출 실패: ${status}`
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { completeWithGemini, resolveGeminiModel } from './gemini'
|
||||
import { completeWithOpenAICompatible } from './openai-compatible'
|
||||
import type {
|
||||
CompletionOptions,
|
||||
CompletionResult,
|
||||
ProviderSettings,
|
||||
} from './types'
|
||||
|
||||
export * from './types'
|
||||
export * from './presets'
|
||||
export { DEFAULT_GEMINI_MODEL, resolveGeminiModel } from './gemini'
|
||||
export { normalizeBaseUrl } from './openai-compatible'
|
||||
|
||||
/**
|
||||
* 설정된 프로바이더로 프롬프트 1회 호출.
|
||||
* 실패는 예외 대신 `{ success: false }`로 돌려주므로 호출부에서 폴백하기 쉽다.
|
||||
*/
|
||||
export async function complete(
|
||||
prompt: string,
|
||||
settings: ProviderSettings,
|
||||
options: CompletionOptions = {},
|
||||
): Promise<CompletionResult> {
|
||||
if (settings.provider === 'openai-compatible') {
|
||||
return completeWithOpenAICompatible(prompt, settings, options)
|
||||
}
|
||||
return completeWithGemini(prompt, settings, options)
|
||||
}
|
||||
|
||||
/** 회의록 하단 문구 등에 쓸 모델 표기. */
|
||||
export function describeModel(settings: ProviderSettings): string {
|
||||
if (settings.provider === 'openai-compatible') {
|
||||
return settings.model?.trim() || '알 수 없는 모델'
|
||||
}
|
||||
return resolveGeminiModel(settings.model)
|
||||
}
|
||||
|
||||
/** provider 설정이 없으면 기존 Gemini 전용 호출부와 동일하게 동작시킨다. */
|
||||
export function toProviderSettings(
|
||||
settings: ProviderSettings | undefined,
|
||||
apiKey: string | undefined,
|
||||
): ProviderSettings {
|
||||
return settings ?? { provider: 'gemini', apiKey: apiKey ?? '' }
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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}`
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { DEFAULT_GEMINI_MODEL } from './gemini'
|
||||
import type { ProviderId } from './types'
|
||||
|
||||
export interface ProviderPreset {
|
||||
id: string
|
||||
label: string
|
||||
provider: ProviderId
|
||||
/** openai-compatible 프리셋의 기본 base URL. 사용자가 수정할 수 있다. */
|
||||
baseUrl: string
|
||||
defaultModel: string
|
||||
description: string
|
||||
apiKeyLabel: string
|
||||
apiKeyPlaceholder: string
|
||||
/** 키 없이도 동작하는 엔드포인트(로컬 모델 서버)인지 여부 */
|
||||
apiKeyOptional?: boolean
|
||||
docsUrl?: string
|
||||
docsLabel?: string
|
||||
modelHint?: string
|
||||
}
|
||||
|
||||
export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
{
|
||||
id: 'gemini',
|
||||
label: 'Google Gemini',
|
||||
provider: 'gemini',
|
||||
baseUrl: '',
|
||||
defaultModel: DEFAULT_GEMINI_MODEL,
|
||||
description: 'Google에 직접 호출합니다. 무료 티어가 있어 가장 간단합니다.',
|
||||
apiKeyLabel: 'Gemini API 키',
|
||||
apiKeyPlaceholder: 'AIzaSy...',
|
||||
docsUrl: 'https://aistudio.google.com/apikey',
|
||||
docsLabel: 'Google AI Studio',
|
||||
modelHint:
|
||||
'예: gemini-3.5-flash-lite(저렴·빠름), gemini-3.6-flash, gemini-2.5-pro',
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
label: 'OpenAI',
|
||||
provider: 'openai-compatible',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
description: 'OpenAI Chat Completions API를 사용합니다.',
|
||||
apiKeyLabel: 'OpenAI API 키',
|
||||
apiKeyPlaceholder: 'sk-...',
|
||||
docsUrl: 'https://platform.openai.com/api-keys',
|
||||
docsLabel: 'OpenAI 대시보드',
|
||||
modelHint: '예: gpt-4o-mini, gpt-4o',
|
||||
},
|
||||
{
|
||||
id: 'orcarouter',
|
||||
label: 'OrcaRouter',
|
||||
provider: 'openai-compatible',
|
||||
baseUrl: 'https://api.orcarouter.ai/v1',
|
||||
defaultModel: 'google/gemini-3.5-flash-lite',
|
||||
description:
|
||||
'하나의 키로 여러 제공사 모델을 사용합니다. 별도 가입과 크레딧 충전(또는 BYOK 등록)이 필요합니다.',
|
||||
apiKeyLabel: 'OrcaRouter API 키',
|
||||
apiKeyPlaceholder: 'sk-...',
|
||||
docsUrl: 'https://www.orcarouter.ai/',
|
||||
docsLabel: 'OrcaRouter',
|
||||
modelHint:
|
||||
'예: google/gemini-3.5-flash-lite, openai/gpt-4o-mini, orcarouter/auto',
|
||||
},
|
||||
{
|
||||
id: 'local',
|
||||
label: '로컬 모델',
|
||||
provider: 'openai-compatible',
|
||||
baseUrl: 'http://localhost:11434/v1',
|
||||
defaultModel: 'llama3.1',
|
||||
description:
|
||||
'Ollama · LM Studio · vLLM 등 내 PC에서 도는 모델. 회의 내용이 외부로 나가지 않습니다.',
|
||||
apiKeyLabel: 'API 키 (보통 불필요)',
|
||||
apiKeyPlaceholder: '비워두세요',
|
||||
apiKeyOptional: true,
|
||||
modelHint: 'Ollama 기본 포트는 11434, LM Studio는 1234입니다.',
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
label: '직접 입력',
|
||||
provider: 'openai-compatible',
|
||||
baseUrl: '',
|
||||
defaultModel: '',
|
||||
description: 'OpenAI 호환 엔드포인트라면 무엇이든 연결할 수 있습니다.',
|
||||
apiKeyLabel: 'API 키',
|
||||
apiKeyPlaceholder: 'sk-...',
|
||||
apiKeyOptional: true,
|
||||
modelHint: '엔드포인트가 제공하는 모델 ID를 그대로 입력하세요.',
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_PRESET_ID = 'gemini'
|
||||
|
||||
export function findPreset(presetId: string | null | undefined): ProviderPreset {
|
||||
return (
|
||||
PROVIDER_PRESETS.find((preset) => preset.id === presetId) ??
|
||||
PROVIDER_PRESETS[0]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* LLM 프로바이더 공통 타입.
|
||||
*
|
||||
* - `gemini`: Google Generative Language API를 직접 호출 (기본값, 기존 동작)
|
||||
* - `openai-compatible`: OpenAI Chat Completions 형식을 따르는 모든 엔드포인트
|
||||
* (OrcaRouter, OpenAI, Ollama, LM Studio, vLLM 등)
|
||||
*/
|
||||
export type ProviderId = 'gemini' | 'openai-compatible'
|
||||
|
||||
export const PROVIDER_IDS: readonly ProviderId[] = ['gemini', 'openai-compatible']
|
||||
|
||||
export function isProviderId(value: unknown): value is ProviderId {
|
||||
return typeof value === 'string' && (PROVIDER_IDS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
export interface ProviderSettings {
|
||||
provider: ProviderId
|
||||
/** 로컬 모델 서버처럼 인증이 필요 없는 엔드포인트에서는 빈 문자열일 수 있다. */
|
||||
apiKey: string
|
||||
/** openai-compatible 전용. 예: https://api.orcarouter.ai/v1 */
|
||||
baseUrl?: string
|
||||
/** 비워두면 프로바이더별 기본 모델을 사용한다. */
|
||||
model?: string
|
||||
}
|
||||
|
||||
export type CompletionResult =
|
||||
| { success: true; text: string }
|
||||
| { success: false; error: string; rateLimited?: boolean }
|
||||
|
||||
export interface CompletionOptions {
|
||||
fetchFn?: typeof fetch
|
||||
}
|
||||
Reference in new issue
Block a user