diff --git a/.env.example b/.env.example index 1568aac..3b3e940 100644 --- a/.env.example +++ b/.env.example @@ -25,13 +25,29 @@ POSTGRES_PASSWORD= DATABASE_URL= # ----------------------------------------------------------------------------- -# 선택 — Gemini API 키 +# 선택 — AI 프로바이더 # ----------------------------------------------------------------------------- -# Gemini AI 요약 기능을 쓰려면 키가 필요합니다. -# 두 가지 방법 중 선택: -# (A) 웹 UI 방식 — 앱 기동 후 /settings에서 입력 (브라우저 LocalStorage 저장, 추천) -# (B) 환경변수 방식 — 아래 값 채우면 모든 사용자가 공통으로 사용 +# AI 요약 기능을 쓰려면 프로바이더 설정이 필요합니다. 두 가지 방법 중 선택: +# (A) 웹 UI 방식 — 앱 기동 후 /settings에서 선택·입력 (브라우저 LocalStorage, 추천) +# (B) 환경변수 방식 — 아래 값을 채우면 모든 사용자가 공통으로 사용 # +# 설정이 없어도 "단순 변환" 모드는 정상 동작합니다. + +# --- (기본) Google Gemini 직접 호출 --- # 무료 발급: https://aistudio.google.com/apikey -# 키 없이도 "단순 변환" 모드는 정상 동작합니다. GEMINI_API_KEY= + +# --- (선택) OpenAI 호환 엔드포인트 --- +# OrcaRouter · OpenAI · Ollama · LM Studio · vLLM 등 OpenAI Chat Completions +# 형식을 따르는 엔드포인트라면 무엇이든 연결됩니다. +# +# LLM_PROVIDER=openai-compatible 로 두었을 때만 아래 값들이 사용됩니다. +# OpenAI : LLM_BASE_URL=https://api.openai.com/v1 LLM_MODEL=gpt-4o-mini +# OrcaRouter : LLM_BASE_URL=https://api.orcarouter.ai/v1 LLM_MODEL=google/gemini-3.5-flash-lite +# Ollama : LLM_BASE_URL=http://localhost:11434/v1 LLM_MODEL=llama3.1 (키 불필요) +# +# ⚠️ 중계 서비스를 쓰면 회의 전문이 그 회사 서버를 거칩니다. SECURITY.md 참고. +# LLM_PROVIDER= +# LLM_BASE_URL= +# LLM_MODEL= +# LLM_API_KEY= diff --git a/README.md b/README.md index 3b43ffb..923fe8c 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ docker compose up -d - [⚡ 실시간 롤링 요약](#-실시간-롤링-요약) - [💾 회의록 저장/조회/검색](#-회의록-저장조회검색) - [✅ 액션 아이템 자동 추출](#-액션-아이템-자동-추출) - - [🔑 웹에서 API 키 설정](#-웹에서-api-키-설정) + - [🔑 AI 프로바이더 & API 키 설정](#-ai-프로바이더--api-키-설정) - [📋 Google Docs 호환 복사](#-google-docs-호환-복사) - [🚀 시작하기](#-시작하기) - [Docker Compose (권장)](#docker-compose-권장) @@ -101,7 +101,7 @@ docker compose up -d - 429 쿼터 초과 시 60초 자동 쿨다운, UI에 카운트다운 노출 - 일반 실패 시 지수 백오프 (15s → 30s → 최대 120s) -**비용 가드 (Gemini 2.5 Flash Lite 무료 등급 기준)** +**비용 가드 (Gemini Flash Lite 무료 등급 기준)** - 15 RPM / 1000 RPD / 250K TPM - 30초 폴링 + 증분 게이트 → 1시간 회의 ≤ 60회 호출, 발화량 적으면 훨씬 적음 - 개인 사용 시 일일 한도 도달 거의 불가 @@ -154,9 +154,22 @@ docker compose up -d --- -### 🔑 웹에서 API 키 설정 +### 🔑 AI 프로바이더 & API 키 설정 -`.env`를 만지지 않고 `/settings` 페이지에서 Gemini 키를 입력·저장·검증할 수 있습니다. +`.env`를 만지지 않고 `/settings` 페이지에서 프로바이더·모델·키를 선택하고 검증할 수 있습니다. + +**선택 가능한 프로바이더** + +| 프리셋 | 엔드포인트 | 비고 | +|--------|-----------|------| +| **Google Gemini** (기본) | Google 직접 호출 | 무료 티어가 있어 가장 간단 | +| **OpenAI** | `https://api.openai.com/v1` | Chat Completions | +| **OrcaRouter** | `https://api.orcarouter.ai/v1` | 하나의 키로 여러 제공사 모델 | +| **로컬 모델** | `http://localhost:11434/v1` | Ollama · LM Studio · vLLM — 회의 내용이 외부로 나가지 않음 | +| **직접 입력** | 사용자 지정 | OpenAI 호환이면 무엇이든 | + +Gemini 외 프리셋은 모두 동일한 **OpenAI Chat Completions** 어댑터를 사용합니다 +(`src/lib/providers/openai-compatible.ts`). base URL과 모델 ID만 바꾸면 새 서비스가 붙습니다. **저장 위치** - 브라우저 LocalStorage (서버 DB에 저장되지 않음) @@ -164,9 +177,9 @@ docker compose up -d **우선순위** ``` -1. 요청 body의 apiKey (브라우저 LocalStorage) +1. 요청 body의 provider/apiKey/baseUrl/model (브라우저 LocalStorage) ↓ 없으면 -2. process.env.GEMINI_API_KEY (서버 환경변수 fallback) +2. 서버 환경변수 (GEMINI_API_KEY 또는 LLM_PROVIDER / LLM_BASE_URL / LLM_MODEL / LLM_API_KEY) ↓ 없으면 3. summarize → 단순 변환 폴백 (warning 표시) summarize-live → 503 + 안내 @@ -175,12 +188,14 @@ docker compose up -d **기능** - 마스킹된 현재 키 표시 (`AIza••••XYZ12`) - 보이기/숨기기 토글 -- **🧪 테스트 호출** — 가벼운 Gemini 응답으로 즉시 키 검증 -- 현재 키 소스 배지 (`브라우저` / `환경변수` / `없음`) +- **🧪 테스트 호출** — 가벼운 응답으로 즉시 설정 검증 (사용된 모델명 표시) +- 현재 소스 배지 (`브라우저` / `환경변수` / `없음`) +- 기존에 Gemini 키만 저장해둔 사용자는 **재입력 없이 그대로 동작** (자동 승계) **보안** - HTTPS 권장 (LocalStorage는 동일 출처 정책 의존) - GET 응답에 절대 풀 키 노출 안 함 +- base URL은 `http`/`https`만 허용 — 자세한 내용은 [SECURITY.md](SECURITY.md) --- @@ -231,12 +246,13 @@ DATABASE_URL=postgresql://meetinguser:<위와_같은_비번>@localhost:5432/meet > ⚠️ `POSTGRES_PASSWORD`를 비워두면 docker compose가 명시적 에러로 실패합니다. 보안을 위한 의도된 동작. -> 💡 **Gemini API 키는 두 가지 방법 중 선택** +> 💡 **AI 프로바이더 설정은 두 가지 방법 중 선택** > -> - **(A) 웹 UI** — 앱 기동 후 `/settings`에서 입력 (브라우저 LocalStorage, 추천) -> - **(B) `.env` 환경변수** — `GEMINI_API_KEY=AIza...` 작성 +> - **(A) 웹 UI** — 앱 기동 후 `/settings`에서 프로바이더 선택 + 키 입력 (브라우저 LocalStorage, 추천) +> - **(B) `.env` 환경변수** — `GEMINI_API_KEY=AIza...` 또는 `LLM_PROVIDER` / `LLM_BASE_URL` / `LLM_MODEL` / `LLM_API_KEY` > -> [Google AI Studio](https://aistudio.google.com/apikey)에서 무료로 발급. 키 없이도 "단순 변환" 모드는 동작. +> 기본값인 Gemini 키는 [Google AI Studio](https://aistudio.google.com/apikey)에서 무료로 발급. +> 설정이 없어도 "단순 변환" 모드는 동작. #### 3. DB 마이그레이션 (최초 1회) @@ -304,11 +320,11 @@ npm run test:coverage # 커버리지 리포트 |------|------| | 프레임워크 | Next.js 16 (App Router) + React 19 + TypeScript | | STT (실시간) | Web Speech API — Chrome 내장, 무료 | -| AI 요약 | Gemini 2.5 Flash Lite (무료 등급 15 RPM / 1000 RPD) | +| AI 요약 | Gemini 3.5 Flash Lite (기본) · OpenAI 호환 엔드포인트 선택 가능 | | DB | PostgreSQL 16 + Prisma 7 (driver adapter `@prisma/adapter-pg`) | | Markdown | `marked` + `isomorphic-dompurify` | | 스타일 | Tailwind CSS v4 | -| 테스트 | Vitest 4 (jsdom, 102 tests) | +| 테스트 | Vitest 4 (jsdom, 141 tests) | | 배포 | Docker Compose + standalone Next.js 빌드 | --- @@ -339,14 +355,20 @@ src/ │ ├── page.tsx # 메인 (녹음/요약) │ └── layout.tsx ├── lib/ +│ ├── providers/ # AI 프로바이더 어댑터 +│ │ ├── index.ts # complete() 디스패치 +│ │ ├── types.ts # ProviderSettings / CompletionResult +│ │ ├── presets.ts # 설정 UI용 프리셋 목록 +│ │ ├── gemini.ts # Google Generative Language API +│ │ └── openai-compatible.ts # OpenAI Chat Completions 호환 │ ├── db.ts # Prisma 싱글톤 (pg adapter) │ ├── markdown.ts # marked + DOMPurify + Docs용 복사 │ ├── action-items.ts # - [ ] 휴리스틱 파서 -│ ├── api-keys.ts # 서버: 요청 키 → env fallback -│ ├── api-key-storage.ts # 클라: LocalStorage + 마스킹 +│ ├── api-keys.ts # 서버: 요청 설정 → env fallback +│ ├── api-key-storage.ts # 클라: LocalStorage + 마스킹 + 프로바이더 설정 │ ├── templates.ts # 템플릿 레지스트리 + 프롬프트 빌더 -│ ├── minutes-generator.ts # 최종 요약 생성 -│ ├── live-summary.ts # 롤링 요약 생성 +│ ├── minutes-generator.ts # 최종 요약 생성 (프로바이더 무관) +│ ├── live-summary.ts # 롤링 요약 생성 (프로바이더 무관) │ ├── transcript-formatter.ts │ ├── audio-validation.ts │ ├── upload-handler.ts @@ -409,7 +431,7 @@ prisma/ - 같은 머신을 다른 사람과 공유하지 않는 것을 가정 - 회의 transcript / 회의록은 평문으로 PostgreSQL에 저장됨 (디스크 암호화는 호스트 OS에 위임) -### Gemini API 키 +### API 키 - LocalStorage 저장 (브라우저 동일 출처 정책으로 보호) - 서버 DB에 저장되지 않음 - 요청 시점에만 body로 전송, 일회성 사용 @@ -417,10 +439,14 @@ prisma/ ### 외부 데이터 송출 - **Web Speech API** — Chrome이 마이크 오디오를 Google 서버로 전송하여 전사 (Chrome 자체 동작, 우리 서버 경유 X) -- **Gemini API** — 사용자가 명시적으로 활성화한 경우에만 transcript를 Google 서버로 전송 +- **AI 요약 API** — 사용자가 명시적으로 활성화한 경우에만 transcript를 선택한 프로바이더로 전송 + - Gemini / OpenAI 직접 호출 → 해당 회사 서버 1곳 + - OrcaRouter 등 중계 라우터 → **라우터 운영사 + 실제 모델 제공사** 양쪽 + - 로컬 모델 (Ollama / LM Studio / vLLM) → **외부 전송 없음** - **그 외** — 외부 호출 없음 (텔레메트리 / 분석 도구 미설치) -> ⚠️ **회사 회의 등 민감 정보가 외부 클라우드(Google)로 송출되는 점에 유의.** 사내 컴플라이언스 정책 확인 후 사용 권장. +> ⚠️ **회사 회의 등 민감 정보가 외부 클라우드로 송출되는 점에 유의.** 사내 컴플라이언스 정책 확인 후 사용 권장. +> 외부 전송이 곤란하다면 `/settings`에서 **로컬 모델**을 선택하세요. ### 권장 배포 방식 diff --git a/SECURITY.md b/SECURITY.md index 2f69e29..7ae724e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -48,9 +48,9 @@ | 데이터 | 저장 위치 | 외부 송출 | |---|---|---| -| 회의 transcript / 회의록 | PostgreSQL (`meetings.markdownMinutes`, `rawTranscript`) | Gemini AI 요약 활성화 시 transcript가 Google로 전송 | +| 회의 transcript / 회의록 | PostgreSQL (`meetings.markdownMinutes`, `rawTranscript`) | AI 요약 활성화 시 transcript가 **선택한 프로바이더**로 전송 | | 액션 아이템 | PostgreSQL (`action_items`) | 외부 송출 없음 | -| Gemini API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 Google에만 전송 | +| API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 선택한 프로바이더에만 전송 | | 음성 데이터 | 메모리 (실시간), `/app/uploads` (파일 업로드) | **Chrome Web Speech API → Google 서버** ⚠️ | ### ⚠️ 주의: Web Speech API의 음성 외부 전송 @@ -63,6 +63,30 @@ Chrome의 `SpeechRecognition` API는 **음성 데이터를 Google 서버로 전 이 경우 **파일 업로드 탭**도 같은 한계가 있으므로(현재 업로드 후 전사는 미구현, Phase 4에서 자체 STT 검토 예정), 이 도구의 사용을 보류하는 것을 권장합니다. +### ⚠️ 주의: AI 프로바이더 선택에 따른 전송 경로 + +`/settings`에서 고른 프로바이더에 따라 **회의 전문이 지나가는 회사가 달라집니다.** + +| 선택 | transcript를 보게 되는 주체 | +|---|---| +| Google Gemini (기본) | Google | +| OpenAI | OpenAI | +| OrcaRouter 등 중계 라우터 | **라우터 운영사 + 라우터가 고른 실제 모델 제공사** (2단계) | +| 로컬 모델 (Ollama / LM Studio / vLLM) | **없음** — 요청이 내 머신 밖으로 나가지 않음 | + +- 중계 라우터는 요청을 대신 전달하는 구조상 **평문 프롬프트를 볼 수 있는 주체가 한 곳 늘어납니다.** 로깅·보관 정책은 각 서비스 약관을 직접 확인하세요. +- 사내 컴플라이언스가 외부 전송을 제한한다면 **로컬 모델** 프리셋을 사용하세요. + +### base URL은 서버가 대신 호출합니다 (SSRF 주의) + +OpenAI 호환 프리셋의 base URL은 브라우저가 아니라 **Next.js Route Handler(서버)** 가 fetch 합니다. + +- `http` / `https` 스킴만 허용합니다 (`normalizeBaseUrl`). +- 그 외 호스트 제한은 없습니다. 이 앱은 `127.0.0.1` 단일 사용자 실행을 전제로 하므로 의도된 설계이지만, + **앱을 LAN이나 인터넷에 노출하면 요청자가 서버 내부망 주소를 base URL로 넣어 스캔할 수 있습니다.** +- 노출 배포가 필요하다면 리버스 프록시에서 egress를 제한하거나, `LLM_BASE_URL`을 환경변수로 고정하고 + 요청 body의 `baseUrl`을 무시하도록 수정하세요. + --- ## ✅ 구현된 보안 조치 @@ -91,9 +115,10 @@ Chrome의 `SpeechRecognition` API는 **음성 데이터를 Google 서버로 전 ### API 키 보호 - `/api/settings/status`: 키 존재 여부만 응답 (값 노출 X) -- `/api/settings/test`: 401/403/429 분류, detail 200자로 제한 -- `getStoredApiKey` GET 요청에 절대 포함 안 함 (body 전송만) +- `/api/settings/test`: 401/403/429 분류, 업스트림 응답 본문을 그대로 돌려주지 않음 +- 키는 GET 요청에 절대 포함 안 함 (body 전송만) - `maskApiKey()`: UI에 표시 시 `AIza••••XYZ12` 형태로 마스킹 +- Gemini는 `x-goog-api-key` 헤더, OpenAI 호환은 `Authorization: Bearer` 헤더 — **키를 URL에 넣지 않음** (로그/리퍼러 유출 방지) --- diff --git a/src/__tests__/api-key-storage.test.ts b/src/__tests__/api-key-storage.test.ts index 48fac54..a0f67fd 100644 --- a/src/__tests__/api-key-storage.test.ts +++ b/src/__tests__/api-key-storage.test.ts @@ -4,6 +4,10 @@ import { setStoredApiKey, clearStoredApiKey, maskApiKey, + getStoredProviderConfig, + setStoredProviderConfig, + clearStoredProviderConfig, + getProviderRequestPayload, } from '@/lib/api-key-storage' describe('LocalStorage api key helpers', () => { @@ -48,3 +52,99 @@ describe('maskApiKey', () => { expect(maskApiKey('123456789')).toBe('1234••••6789') }) }) + +describe('프로바이더 설정 저장', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('저장 후 동일한 설정을 돌려준다', () => { + setStoredProviderConfig({ + presetId: 'orcarouter', + apiKey: 'sk-test', + baseUrl: 'https://api.orcarouter.ai/v1', + model: 'google/gemini-2.5-flash-lite', + }) + + expect(getStoredProviderConfig()).toEqual({ + presetId: 'orcarouter', + apiKey: 'sk-test', + baseUrl: 'https://api.orcarouter.ai/v1', + model: 'google/gemini-2.5-flash-lite', + }) + }) + + it('미설정이거나 깨진 값이면 null', () => { + expect(getStoredProviderConfig()).toBeNull() + window.localStorage.setItem('meeting-minutes:llm-provider', '{not json') + expect(getStoredProviderConfig()).toBeNull() + }) + + it('clear 시 삭제된다', () => { + setStoredProviderConfig({ + presetId: 'openai', + apiKey: 'sk', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o-mini', + }) + clearStoredProviderConfig() + expect(getStoredProviderConfig()).toBeNull() + }) +}) + +describe('getProviderRequestPayload', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('프로바이더 설정이 없으면 기존 Gemini 키를 그대로 사용한다', () => { + setStoredApiKey('AIza-legacy') + + expect(getProviderRequestPayload()).toEqual({ + provider: 'gemini', + apiKey: 'AIza-legacy', + baseUrl: '', + model: '', + }) + }) + + it('아무것도 없으면 빈 gemini 설정을 돌려준다', () => { + expect(getProviderRequestPayload()).toEqual({ + provider: 'gemini', + apiKey: '', + baseUrl: '', + model: '', + }) + }) + + it('프리셋 id로부터 provider를 결정한다', () => { + setStoredProviderConfig({ + presetId: 'local', + apiKey: '', + baseUrl: 'http://localhost:11434/v1', + model: 'llama3.1', + }) + + expect(getProviderRequestPayload()).toEqual({ + provider: 'openai-compatible', + apiKey: '', + baseUrl: 'http://localhost:11434/v1', + model: 'llama3.1', + }) + }) + + it('gemini 프리셋에서 키를 비워두면 기존 키로 폴백한다', () => { + setStoredApiKey('AIza-legacy') + setStoredProviderConfig({ + presetId: 'gemini', + apiKey: '', + baseUrl: '', + model: 'gemini-2.5-pro', + }) + + const payload = getProviderRequestPayload() + expect(payload.provider).toBe('gemini') + expect(payload.apiKey).toBe('AIza-legacy') + expect(payload.model).toBe('gemini-2.5-pro') + }) +}) diff --git a/src/__tests__/api-keys.test.ts b/src/__tests__/api-keys.test.ts index f0e19fa..053cefc 100644 --- a/src/__tests__/api-keys.test.ts +++ b/src/__tests__/api-keys.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { resolveGeminiApiKey, isEnvKeyConfigured } from '@/lib/api-keys' +import { + resolveGeminiApiKey, + isEnvKeyConfigured, + isEnvProviderConfigured, + resolveProviderSettings, +} from '@/lib/api-keys' describe('resolveGeminiApiKey', () => { const originalEnv = process.env.GEMINI_API_KEY @@ -70,3 +75,139 @@ describe('isEnvKeyConfigured', () => { expect(isEnvKeyConfigured()).toBe(false) }) }) + +describe('resolveProviderSettings', () => { + const ENV_KEYS = [ + 'GEMINI_API_KEY', + 'LLM_PROVIDER', + 'LLM_BASE_URL', + 'LLM_API_KEY', + 'LLM_MODEL', + ] as const + const saved: Record = {} + + beforeEach(() => { + for (const key of ENV_KEYS) { + saved[key] = process.env[key] + delete process.env[key] + } + }) + + afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + }) + + it('provider를 지정하지 않으면 gemini로 간주한다', () => { + expect(resolveProviderSettings({ apiKey: 'req-key' })).toEqual({ + provider: 'gemini', + apiKey: 'req-key', + model: undefined, + }) + }) + + it('gemini인데 키가 아무데도 없으면 null (단순 변환 폴백)', () => { + expect(resolveProviderSettings({})).toBeNull() + expect(resolveProviderSettings(null)).toBeNull() + }) + + it('gemini 키는 요청 → GEMINI_API_KEY 순으로 해석한다', () => { + process.env.GEMINI_API_KEY = 'env-key' + expect(resolveProviderSettings({ apiKey: 'req-key' })?.apiKey).toBe('req-key') + expect(resolveProviderSettings({})?.apiKey).toBe('env-key') + }) + + it('openai-compatible은 baseUrl과 model이 모두 있어야 한다', () => { + const base = { provider: 'openai-compatible', apiKey: 'sk-x' } + expect(resolveProviderSettings(base)).toBeNull() + expect( + resolveProviderSettings({ ...base, baseUrl: 'https://a.example/v1' }), + ).toBeNull() + expect( + resolveProviderSettings({ ...base, model: 'gpt-4o-mini' }), + ).toBeNull() + expect( + resolveProviderSettings({ + ...base, + baseUrl: 'https://a.example/v1', + model: 'gpt-4o-mini', + }), + ).toEqual({ + provider: 'openai-compatible', + apiKey: 'sk-x', + baseUrl: 'https://a.example/v1', + model: 'gpt-4o-mini', + }) + }) + + it('openai-compatible은 키가 비어도 허용한다 (로컬 모델 서버)', () => { + const settings = resolveProviderSettings({ + provider: 'openai-compatible', + apiKey: '', + baseUrl: 'http://localhost:11434/v1', + model: 'llama3.1', + }) + expect(settings?.apiKey).toBe('') + }) + + it('LLM_* 환경변수만으로도 설정된다', () => { + process.env.LLM_PROVIDER = 'openai-compatible' + process.env.LLM_BASE_URL = 'https://api.orcarouter.ai/v1' + process.env.LLM_MODEL = 'google/gemini-2.5-flash-lite' + process.env.LLM_API_KEY = 'sk-env' + + expect(resolveProviderSettings({})).toEqual({ + provider: 'openai-compatible', + apiKey: 'sk-env', + baseUrl: 'https://api.orcarouter.ai/v1', + model: 'google/gemini-2.5-flash-lite', + }) + }) + + it('알 수 없는 provider 값은 무시하고 기본값으로 떨어진다', () => { + process.env.GEMINI_API_KEY = 'env-key' + expect(resolveProviderSettings({ provider: 'anthropic' })?.provider).toBe( + 'gemini', + ) + }) +}) + +describe('isEnvProviderConfigured', () => { + const ENV_KEYS = ['GEMINI_API_KEY', 'LLM_PROVIDER', 'LLM_BASE_URL', 'LLM_MODEL', 'LLM_API_KEY'] as const + const saved: Record = {} + + beforeEach(() => { + for (const key of ENV_KEYS) { + saved[key] = process.env[key] + delete process.env[key] + } + }) + + afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + }) + + it('아무 것도 없으면 false', () => { + expect(isEnvProviderConfigured()).toBe(false) + }) + + it('GEMINI_API_KEY만 있어도 true', () => { + process.env.GEMINI_API_KEY = 'k' + expect(isEnvProviderConfigured()).toBe(true) + }) + + it('openai-compatible은 base URL과 모델까지 있어야 true', () => { + process.env.LLM_PROVIDER = 'openai-compatible' + process.env.LLM_API_KEY = 'k' + expect(isEnvProviderConfigured()).toBe(false) + + process.env.LLM_BASE_URL = 'https://a.example/v1' + process.env.LLM_MODEL = 'm' + expect(isEnvProviderConfigured()).toBe(true) + }) +}) diff --git a/src/__tests__/live-summary.test.ts b/src/__tests__/live-summary.test.ts index 98de65f..ae2fa86 100644 --- a/src/__tests__/live-summary.test.ts +++ b/src/__tests__/live-summary.test.ts @@ -128,3 +128,30 @@ describe('generateLiveSummary', () => { expect(init.headers['x-goog-api-key']).toBe('secret-key') }) }) + +describe('generateLiveSummary — openai-compatible', () => { + it('OpenAI 호환 엔드포인트로 중간 요약을 만든다', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: '## 요약\n진행 중' } }], + }), + }) + + const result = await generateLiveSummary('회의 내용', { + provider: { + provider: 'openai-compatible', + apiKey: 'sk-test', + baseUrl: 'http://localhost:11434/v1', + model: 'llama3.1', + }, + fetchFn: mockFetch, + }) + + expect(result.success).toBe(true) + expect(mockFetch.mock.calls[0][0]).toBe( + 'http://localhost:11434/v1/chat/completions', + ) + }) +}) diff --git a/src/__tests__/minutes-generator.test.ts b/src/__tests__/minutes-generator.test.ts index 769932c..51a03ea 100644 --- a/src/__tests__/minutes-generator.test.ts +++ b/src/__tests__/minutes-generator.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest' import { generateSimpleMinutes, + generateAiMinutes, generateGeminiMinutes, type MinutesInput, } from '@/lib/minutes-generator' @@ -105,3 +106,49 @@ describe('generateGeminiMinutes', () => { } }) }) + +describe('generateAiMinutes — openai-compatible', () => { + const settings = { + provider: 'openai-compatible' as const, + apiKey: 'sk-test', + baseUrl: 'https://api.orcarouter.ai/v1', + model: 'google/gemini-2.5-flash-lite', + } + + it('OpenAI 호환 엔드포인트 응답으로 회의록을 만든다', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: '## 요약\n- 리팩토링 완료' } }], + }), + }) + + const result = await generateAiMinutes(sampleInput, { + provider: settings, + fetchFn: mockFetch, + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.markdown).toContain('# 주간 스프린트 회의') + expect(result.markdown).toContain('리팩토링 완료') + // 하단 문구는 실제 사용한 모델을 표기한다 + expect(result.markdown).toContain('google/gemini-2.5-flash-lite') + } + }) + + it('응답이 비어 있으면 실패로 처리해 단순 변환 폴백을 유도한다', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ choices: [{ message: { content: ' ' } }] }), + }) + + const result = await generateAiMinutes(sampleInput, { + provider: settings, + fetchFn: mockFetch, + }) + + expect(result.success).toBe(false) + }) +}) diff --git a/src/__tests__/providers.test.ts b/src/__tests__/providers.test.ts new file mode 100644 index 0000000..6fc95fa --- /dev/null +++ b/src/__tests__/providers.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi } from 'vitest' +import { + complete, + describeModel, + normalizeBaseUrl, + toProviderSettings, + DEFAULT_GEMINI_MODEL, + findPreset, + PROVIDER_PRESETS, +} from '@/lib/providers' + +function okResponse(content: string) { + return { + ok: true, + json: () => Promise.resolve({ choices: [{ message: { content } }] }), + } +} + +const OPENAI_SETTINGS = { + provider: 'openai-compatible' as const, + apiKey: 'sk-test', + baseUrl: 'https://api.orcarouter.ai/v1', + model: 'google/gemini-2.5-flash-lite', +} + +describe('normalizeBaseUrl', () => { + it('끝의 슬래시를 제거한다', () => { + expect(normalizeBaseUrl('https://api.example.com/v1/')).toBe( + 'https://api.example.com/v1', + ) + }) + + it('앞뒤 공백을 제거한다', () => { + expect(normalizeBaseUrl(' https://api.example.com/v1 ')).toBe( + 'https://api.example.com/v1', + ) + }) + + it('http/https 이외의 스킴은 거부한다', () => { + expect(normalizeBaseUrl('file:///etc/passwd')).toBeNull() + expect(normalizeBaseUrl('ftp://example.com')).toBeNull() + }) + + it('URL이 아니거나 비어 있으면 null', () => { + expect(normalizeBaseUrl('not-a-url')).toBeNull() + expect(normalizeBaseUrl(' ')).toBeNull() + }) +}) + +describe('complete — openai-compatible', () => { + it('/chat/completions로 OpenAI 형식 요청을 보낸다', async () => { + const mockFetch = vi.fn().mockResolvedValue(okResponse('## 요약')) + + const result = await complete('프롬프트', OPENAI_SETTINGS, { + fetchFn: mockFetch, + }) + + expect(result.success).toBe(true) + if (result.success) expect(result.text).toBe('## 요약') + + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe('https://api.orcarouter.ai/v1/chat/completions') + expect(init.headers.Authorization).toBe('Bearer sk-test') + + const body = JSON.parse(init.body) + expect(body.model).toBe('google/gemini-2.5-flash-lite') + expect(body.messages).toEqual([{ role: 'user', content: '프롬프트' }]) + }) + + it('키가 없으면 Authorization 헤더를 붙이지 않는다 (로컬 모델 서버)', async () => { + const mockFetch = vi.fn().mockResolvedValue(okResponse('요약')) + + await complete( + '프롬프트', + { ...OPENAI_SETTINGS, apiKey: '', baseUrl: 'http://localhost:11434/v1' }, + { fetchFn: mockFetch }, + ) + + const [, init] = mockFetch.mock.calls[0] + expect(init.headers.Authorization).toBeUndefined() + }) + + it('base URL이 없으면 호출하지 않고 에러를 반환한다', async () => { + const mockFetch = vi.fn() + + const result = await complete( + '프롬프트', + { ...OPENAI_SETTINGS, baseUrl: '' }, + { fetchFn: mockFetch }, + ) + + expect(result.success).toBe(false) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('모델이 없으면 호출하지 않고 에러를 반환한다', async () => { + const mockFetch = vi.fn() + + const result = await complete( + '프롬프트', + { ...OPENAI_SETTINGS, model: ' ' }, + { fetchFn: mockFetch }, + ) + + expect(result.success).toBe(false) + if (!result.success) expect(result.error).toContain('모델') + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('429는 rateLimited 플래그를 세운다', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 429 }) + + const result = await complete('프롬프트', OPENAI_SETTINGS, { + fetchFn: mockFetch, + }) + + expect(result.success).toBe(false) + if (!result.success) expect(result.rateLimited).toBe(true) + }) + + it('401은 인증 실패로 안내한다', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 401 }) + + const result = await complete('프롬프트', OPENAI_SETTINGS, { + fetchFn: mockFetch, + }) + + expect(result.success).toBe(false) + if (!result.success) expect(result.error).toContain('인증') + }) + + it('네트워크 예외를 결과 객체로 감싼다', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')) + + const result = await complete('프롬프트', OPENAI_SETTINGS, { + fetchFn: mockFetch, + }) + + expect(result.success).toBe(false) + if (!result.success) expect(result.error).toContain('ECONNREFUSED') + }) +}) + +describe('complete — gemini 라우팅', () => { + it('provider가 gemini면 Google 엔드포인트를 호출한다', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + candidates: [{ content: { parts: [{ text: '요약' }] } }], + }), + }) + + await complete( + '프롬프트', + { provider: 'gemini', apiKey: 'AIza-test' }, + { fetchFn: mockFetch }, + ) + + const [url, init] = mockFetch.mock.calls[0] + expect(url).toContain('generativelanguage.googleapis.com') + expect(url).toContain(DEFAULT_GEMINI_MODEL) + expect(init.headers['x-goog-api-key']).toBe('AIza-test') + }) + + it('모델을 지정하면 해당 모델 엔드포인트를 호출한다', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + candidates: [{ content: { parts: [{ text: '요약' }] } }], + }), + }) + + await complete( + '프롬프트', + { provider: 'gemini', apiKey: 'AIza-test', model: 'gemini-2.5-pro' }, + { fetchFn: mockFetch }, + ) + + expect(mockFetch.mock.calls[0][0]).toContain('gemini-2.5-pro') + }) +}) + +describe('toProviderSettings', () => { + it('provider가 없으면 기존 Gemini 호출부와 동일하게 동작한다', () => { + expect(toProviderSettings(undefined, 'legacy-key')).toEqual({ + provider: 'gemini', + apiKey: 'legacy-key', + }) + }) + + it('provider가 있으면 그대로 사용한다', () => { + expect(toProviderSettings(OPENAI_SETTINGS, 'ignored')).toBe(OPENAI_SETTINGS) + }) +}) + +describe('describeModel', () => { + it('gemini는 기본 모델명을 돌려준다', () => { + expect(describeModel({ provider: 'gemini', apiKey: 'k' })).toBe( + DEFAULT_GEMINI_MODEL, + ) + }) + + it('openai-compatible은 설정한 모델명을 돌려준다', () => { + expect(describeModel(OPENAI_SETTINGS)).toBe('google/gemini-2.5-flash-lite') + }) +}) + +describe('프리셋', () => { + it('알 수 없는 id는 기본 프리셋으로 폴백한다', () => { + expect(findPreset('없는-프리셋').id).toBe('gemini') + expect(findPreset(null).id).toBe('gemini') + }) + + it('openai-compatible 프리셋은 base URL 또는 직접 입력 안내를 갖는다', () => { + for (const preset of PROVIDER_PRESETS) { + if (preset.provider !== 'openai-compatible') continue + expect(preset.id === 'custom' || preset.baseUrl.length > 0).toBe(true) + } + }) +}) diff --git a/src/app/api/settings/status/route.ts b/src/app/api/settings/status/route.ts index 3656877..54f36eb 100644 --- a/src/app/api/settings/status/route.ts +++ b/src/app/api/settings/status/route.ts @@ -1,9 +1,10 @@ -import { isEnvKeyConfigured } from '@/lib/api-keys' +import { isEnvKeyConfigured, isEnvProviderConfigured } from '@/lib/api-keys' export const dynamic = 'force-dynamic' export async function GET() { return Response.json({ envConfigured: isEnvKeyConfigured(), + envProviderConfigured: isEnvProviderConfigured(), }) } diff --git a/src/app/api/settings/test/route.ts b/src/app/api/settings/test/route.ts index 23986c6..d8c659d 100644 --- a/src/app/api/settings/test/route.ts +++ b/src/app/api/settings/test/route.ts @@ -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 : '알 수 없는 오류' diff --git a/src/app/api/summarize-live/route.ts b/src/app/api/summarize-live/route.ts index 61e30d1..bf6fee4 100644 --- a/src/app/api/summarize-live/route.ts +++ b/src/app/api/summarize-live/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from 'next/server' import { generateLiveSummary } from '@/lib/live-summary' -import { resolveGeminiApiKey } from '@/lib/api-keys' +import { resolveProviderSettings } from '@/lib/api-keys' import type { SummaryDepth, TemplateId } from '@/lib/templates' const MAX_TRANSCRIPT_CHARS = 40_000 @@ -8,7 +8,7 @@ const MAX_TRANSCRIPT_CHARS = 40_000 export async function POST(request: NextRequest) { try { const body = await request.json() - const { transcript, template, depth, customPrompt, apiKey } = body + const { transcript, template, depth, customPrompt } = body if (!transcript || typeof transcript !== 'string') { return Response.json( @@ -17,12 +17,12 @@ export async function POST(request: NextRequest) { ) } - const resolvedKey = resolveGeminiApiKey(apiKey) - if (!resolvedKey) { + const settings = resolveProviderSettings(body) + if (!settings) { return Response.json( { error: - 'Gemini API 키가 설정되지 않았습니다. /settings에서 키를 입력해주세요.', + 'AI 프로바이더가 설정되지 않았습니다. /settings에서 키를 입력해주세요.', }, { status: 503 }, ) @@ -34,7 +34,7 @@ export async function POST(request: NextRequest) { : transcript const result = await generateLiveSummary(truncated, { - apiKey: resolvedKey, + provider: settings, template: (template as TemplateId | undefined) ?? 'meeting', depth: depth as SummaryDepth | undefined, customPrompt: diff --git a/src/app/api/summarize/route.ts b/src/app/api/summarize/route.ts index f1e5394..741997b 100644 --- a/src/app/api/summarize/route.ts +++ b/src/app/api/summarize/route.ts @@ -1,9 +1,9 @@ import { NextRequest } from 'next/server' import { - generateGeminiMinutes, + generateAiMinutes, generateSimpleMinutes, } from '@/lib/minutes-generator' -import { resolveGeminiApiKey } from '@/lib/api-keys' +import { resolveProviderSettings } from '@/lib/api-keys' import type { SummaryDepth, TemplateId } from '@/lib/templates' const MAX_TRANSCRIPT_CHARS = 100_000 @@ -11,16 +11,7 @@ const MAX_TRANSCRIPT_CHARS = 100_000 export async function POST(request: NextRequest) { try { const body = await request.json() - const { - title, - transcript, - mode, - date, - template, - depth, - customPrompt, - apiKey, - } = body + const { title, transcript, mode, date, template, depth, customPrompt } = body if (!transcript || typeof transcript !== 'string') { return Response.json( @@ -48,26 +39,27 @@ export async function POST(request: NextRequest) { typeof customPrompt === 'string' ? customPrompt : undefined, } - if (mode === 'gemini') { - const resolvedKey = resolveGeminiApiKey(apiKey) - if (!resolvedKey) { + // 'gemini'는 DB에 저장된 기존 값과의 호환을 위해 유지되는 AI 모드 식별자다. + if (mode === 'gemini' || mode === 'ai') { + const settings = resolveProviderSettings(body) + if (!settings) { const fallback = generateSimpleMinutes(input) return Response.json({ markdown: fallback, mode: 'simple', warning: - 'Gemini API 키가 설정되지 않아 단순 변환으로 대체되었습니다. /settings에서 키를 설정하세요.', + 'AI 프로바이더가 설정되지 않아 단순 변환으로 대체되었습니다. /settings에서 설정하세요.', }) } - const result = await generateGeminiMinutes(input, { apiKey: resolvedKey }) + const result = await generateAiMinutes(input, { provider: settings }) if (!result.success) { const fallback = generateSimpleMinutes(input) return Response.json({ markdown: fallback, mode: 'simple', - warning: `Gemini 요약에 실패하여 단순 변환으로 대체되었습니다: ${result.error}`, + warning: `AI 요약에 실패하여 단순 변환으로 대체되었습니다: ${result.error}`, }) } diff --git a/src/app/page.tsx b/src/app/page.tsx index bef33ef..4d9a937 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,7 +5,7 @@ import Link from 'next/link' import { AudioUploader } from '@/components/upload/AudioUploader' import { LiveRecorder } from '@/components/recorder/LiveRecorder' import { MinutesViewer } from '@/components/minutes/MinutesViewer' -import { getStoredApiKey } from '@/lib/api-key-storage' +import { getProviderRequestPayload } from '@/lib/api-key-storage' import { TEMPLATES, DEFAULT_TEMPLATE_ID, @@ -119,7 +119,7 @@ export default function HomePage() { template, depth, customPrompt: template === 'custom' ? customPrompt : undefined, - apiKey: getStoredApiKey(), + ...getProviderRequestPayload(), }), }) const data = await res.json() @@ -212,7 +212,7 @@ export default function HomePage() { : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200' }`} > - Gemini AI 요약 + AI 요약 diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index d5b8385..4234695 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -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(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(null) const [envConfigured, setEnvConfigured] = useState(null) const [testState, setTestState] = useState({ 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() { ⚙️ 설정

- Gemini API 키를 브라우저에 저장합니다. 키는 서버에 저장되지 않으며, - 요청 시점에만 전송되어 사용됩니다. + AI 요약에 사용할 프로바이더와 키를 브라우저에 저장합니다. 키는 서버 DB에 + 저장되지 않으며, 요청 시점에만 전송되어 사용됩니다.

- 현재 키 소스 + 현재 설정

+

+ 프로바이더:{' '} + {preset.label} +

+

+ 모델:{' '} + {model.trim() ? ( + {model.trim()} + ) : ( + 기본값 + )} +

브라우저 키:{' '} - {storedKey ? ( - {maskApiKey(storedKey)} + {savedKey ? ( + {maskApiKey(savedKey)} ) : ( 미설정 )}

- 환경변수 키:{' '} + 환경변수:{' '} {envConfigured === null ? ( 확인 중... ) : envConfigured ? ( @@ -156,73 +226,121 @@ export default function SettingsPage() {

- 💡 우선순위: 브라우저 키 → 환경변수 → 단순 변환 폴백 + 💡 우선순위: 브라우저 설정 → 환경변수 → 단순 변환 폴백

- Gemini API 키 + 프로바이더

- -
- 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" - /> - +
+ {PROVIDER_PRESETS.map((item) => ( + + ))}
-

- - Google AI Studio - - 에서 무료로 발급할 수 있습니다. -

+

{preset.description}

-
+ {isOpenAiCompatible && ( +
+ + 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" + /> +

+ OpenAI 호환 엔드포인트의 /chat/completions 앞부분까지 + 입력하세요. +

+
+ )} + +
+ + 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 && ( +

{preset.modelHint}

+ )} +
+ +
+ +
+ 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" + /> + +
+ {preset.docsUrl && ( +

+ + {preset.docsLabel ?? preset.docsUrl} + + 에서 발급할 수 있습니다. +

+ )} +
+ +
- {storedKey && ( - - )} +
{savedToast && ( @@ -234,11 +352,10 @@ export default function SettingsPage() { {testState.status === 'success' && (
✓ {testState.message} - {testState.reply && ( -

- Gemini 응답: {testState.reply} -

- )} +

+ {testState.model ? `${testState.model} → ` : ''} + {testState.reply} +

)} @@ -265,6 +382,9 @@ export default function SettingsPage() {
  • ⚠️ 회의 내용에 민감 정보가 포함된 경우, 실시간 녹음 기능은 음성을 Google 서버로 전송합니다 (Chrome Web Speech API 동작). 사내 컴플라이언스 정책 확인 후 사용해주세요.
  • +
  • + ⚠️ 중계 서비스를 고르면 회의 전문이 그 회사 서버를 거칩니다. OpenAI·Gemini 직접 호출은 해당 회사만, OrcaRouter 같은 라우터는 라우터 운영사 + 실제 모델 제공사 양쪽을 거칩니다. 외부 전송이 곤란하면 로컬 모델을 선택하세요. +
  • diff --git a/src/components/minutes/MinutesViewer.tsx b/src/components/minutes/MinutesViewer.tsx index 5236b35..8efacdf 100644 --- a/src/components/minutes/MinutesViewer.tsx +++ b/src/components/minutes/MinutesViewer.tsx @@ -126,7 +126,7 @@ export function MinutesViewer({ } } - const modeLabel = mode === 'gemini' ? 'Gemini AI 요약' : '단순 변환' + const modeLabel = mode === 'gemini' ? 'AI 요약' : '단순 변환' return (
    diff --git a/src/hooks/useLiveSummary.ts b/src/hooks/useLiveSummary.ts index b160886..33f10e6 100644 --- a/src/hooks/useLiveSummary.ts +++ b/src/hooks/useLiveSummary.ts @@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { TranscriptChunk } from '@/lib/transcript-formatter' import { formatTranscriptChunks } from '@/lib/transcript-formatter' import type { SummaryDepth, TemplateId } from '@/lib/templates' -import { getStoredApiKey } from '@/lib/api-key-storage' +import { getProviderRequestPayload } from '@/lib/api-key-storage' interface UseLiveSummaryOptions { enabled: boolean @@ -119,7 +119,7 @@ export function useLiveSummary( template: configRef.current.template, depth: configRef.current.depth, customPrompt: configRef.current.customPrompt, - apiKey: getStoredApiKey(), + ...getProviderRequestPayload(), }), signal: controller.signal, }) diff --git a/src/lib/api-key-storage.ts b/src/lib/api-key-storage.ts index 24f64ae..6b9b18c 100644 --- a/src/lib/api-key-storage.ts +++ b/src/lib/api-key-storage.ts @@ -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 + 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, + } +} diff --git a/src/lib/api-keys.ts b/src/lib/api-keys.ts index 73b752b..7b38bb1 100644 --- a/src/lib/api-keys.ts +++ b/src/lib/api-keys.ts @@ -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() +} diff --git a/src/lib/live-summary.ts b/src/lib/live-summary.ts index 37e50b0..253eb5f 100644 --- a/src/lib/live-summary.ts +++ b/src/lib/live-summary.ts @@ -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 { - 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 } } diff --git a/src/lib/minutes-generator.ts b/src/lib/minutes-generator.ts index 23855a5..148cf81 100644 --- a/src/lib/minutes-generator.ts +++ b/src/lib/minutes-generator.ts @@ -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 { - const { apiKey, fetchFn = fetch } = options - - if (!apiKey || apiKey.trim().length === 0) { - return { success: false, error: 'Gemini API 키가 필요합니다.' } - } + options: AiMinutesOptions, +): Promise { + 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 diff --git a/src/lib/providers/gemini.ts b/src/lib/providers/gemini.ts new file mode 100644 index 0000000..216c05c --- /dev/null +++ b/src/lib/providers/gemini.ts @@ -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 { + 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}` +} diff --git a/src/lib/providers/index.ts b/src/lib/providers/index.ts new file mode 100644 index 0000000..926483c --- /dev/null +++ b/src/lib/providers/index.ts @@ -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 { + 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 ?? '' } +} diff --git a/src/lib/providers/openai-compatible.ts b/src/lib/providers/openai-compatible.ts new file mode 100644 index 0000000..af2f984 --- /dev/null +++ b/src/lib/providers/openai-compatible.ts @@ -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 { + 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 = { + '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}` +} diff --git a/src/lib/providers/presets.ts b/src/lib/providers/presets.ts new file mode 100644 index 0000000..19fa789 --- /dev/null +++ b/src/lib/providers/presets.ts @@ -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] + ) +} diff --git a/src/lib/providers/types.ts b/src/lib/providers/types.ts new file mode 100644 index 0000000..a661ab3 --- /dev/null +++ b/src/lib/providers/types.ts @@ -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 +}