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

+85
View File
@@ -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,
}
}
+69
View File
@@ -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
View File
@@ -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 }
}
+37 -45
View File
@@ -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
+76
View File
@@ -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}`
}
+43
View File
@@ -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 ?? '' }
}
+112
View File
@@ -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}`
}
+98
View File
@@ -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]
)
}
+32
View File
@@ -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
}