/**
* Gemini 서비스
*
* PDF, 이미지, 텍스트 분석을 위한 통합 Gemini 클라이언트
* - API: 직접 API 호출 (낮은 쿼터)
* - CLI: Gemini CLI 사용 (높은 쿼터, Google One 지원)
*/
import { GoogleGenAI } from '@google/genai';
import axios from 'axios';
import * as fs from 'fs';
import * as path from 'path';
import { isCliAvailable, runGeminiCli } from './gemini-cli.js';
import { logRequest, logResponse } from './logger.js';
// ============================================================
// Configuration
// ============================================================
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
const MAX_FILE_SIZE_MB = 20;
const DOWNLOAD_TIMEOUT_MS = 60000;
const MODELS = {
flash: 'gemini-3-flash-preview',
pro: 'gemini-3-pro-preview',
// Legacy models
'flash-2.5': 'gemini-2.5-flash',
'pro-2.5': 'gemini-2.5-pro',
'flash-lite': 'gemini-2.5-flash-lite',
} as const;
export type GeminiModel = keyof typeof MODELS;
export type GeminiProvider = 'api' | 'cli';
let geminiClient: GoogleGenAI | null = null;
// Default provider: CLI if available, otherwise API
const DEFAULT_PROVIDER: GeminiProvider = isCliAvailable() ? 'cli' : 'api';
// ============================================================
// Client Management
// ============================================================
export function isGeminiAvailable(): boolean {
return !!GEMINI_API_KEY || isCliAvailable();
}
export function getDefaultProvider(): GeminiProvider {
return DEFAULT_PROVIDER;
}
function getClient(): GoogleGenAI {
if (!geminiClient) {
if (!GEMINI_API_KEY) {
throw new Error('GEMINI_API_KEY 환경변수가 설정되지 않았습니다.');
}
geminiClient = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
}
return geminiClient;
}
// ============================================================
// File Loading Utilities
// ============================================================
interface FileData {
data: string; // Base64 encoded
mimeType: string;
sizeBytes: number;
}
function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const mimeTypes: Record<string, string> = {
'.pdf': 'application/pdf',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
};
return mimeTypes[ext] || 'application/octet-stream';
}
function isUrl(source: string): boolean {
return source.startsWith('http://') || source.startsWith('https://');
}
function expandPath(filePath: string): string {
if (filePath.startsWith('~')) {
return filePath.replace('~', process.env.HOME || '');
}
return path.resolve(filePath);
}
async function fetchFromUrl(url: string, expectedMimePrefix?: string): Promise<FileData> {
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: DOWNLOAD_TIMEOUT_MS,
maxContentLength: MAX_FILE_SIZE_MB * 1024 * 1024,
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
Accept: '*/*',
},
});
const buffer = Buffer.from(response.data);
const contentType = response.headers['content-type'] || 'application/octet-stream';
// Determine MIME type from Content-Type header or URL
let mimeType = contentType.split(';')[0].trim();
if (mimeType === 'application/octet-stream') {
mimeType = getMimeType(url);
}
return {
data: buffer.toString('base64'),
mimeType,
sizeBytes: buffer.length,
};
}
async function readFromFile(filePath: string): Promise<FileData> {
const absolutePath = expandPath(filePath);
if (!fs.existsSync(absolutePath)) {
throw new Error(`파일을 찾을 수 없습니다: ${absolutePath}`);
}
const stats = fs.statSync(absolutePath);
if (stats.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
throw new Error(
`파일이 너무 큽니다: ${(stats.size / 1024 / 1024).toFixed(1)}MB (최대 ${MAX_FILE_SIZE_MB}MB)`
);
}
const buffer = fs.readFileSync(absolutePath);
return {
data: buffer.toString('base64'),
mimeType: getMimeType(absolutePath),
sizeBytes: buffer.length,
};
}
export async function loadFile(source: string): Promise<FileData> {
return isUrl(source) ? fetchFromUrl(source) : readFromFile(source);
}
// ============================================================
// Token Usage
// ============================================================
export interface TokenUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
// ============================================================
// Text Generation
// ============================================================
export interface GenerateTextOptions {
prompt: string;
context?: string;
model?: GeminiModel;
maxTokens?: number;
provider?: GeminiProvider;
}
export interface GenerateTextResult {
success: boolean;
text?: string;
model: string;
provider: GeminiProvider;
tokenUsage?: TokenUsage;
error?: string;
}
export async function generateText(options: GenerateTextOptions): Promise<GenerateTextResult> {
const { prompt, context, model = 'flash', maxTokens = 8192, provider = DEFAULT_PROVIDER } = options;
const modelId = MODELS[model];
const fullPrompt = context ? `${prompt}\n\n컨텍스트:\n${context}` : prompt;
const startTime = Date.now();
// Log request
logRequest({
tool: 'generate_text',
provider,
model: modelId,
prompt,
context,
});
// Use CLI if specified
if (provider === 'cli') {
const result = await runGeminiCli(fullPrompt, { model: modelId });
const response: GenerateTextResult = {
success: result.success,
text: result.text,
model: result.model || modelId,
provider: 'cli',
tokenUsage: result.tokenUsage,
error: result.error,
};
// Log response
logResponse({
tool: 'generate_text',
provider: 'cli',
model: result.model || modelId,
success: result.success,
response: result.text,
responseLength: result.text?.length,
tokenUsage: result.tokenUsage,
durationMs: Date.now() - startTime,
error: result.error,
});
return response;
}
// Use API
try {
const client = getClient();
const response = await client.models.generateContent({
model: modelId,
contents: [{ role: 'user', parts: [{ text: fullPrompt }] }],
config: { maxOutputTokens: maxTokens },
});
const usage = response.usageMetadata;
const tokenUsage = usage
? {
promptTokens: usage.promptTokenCount || 0,
completionTokens: usage.candidatesTokenCount || 0,
totalTokens: usage.totalTokenCount || 0,
}
: undefined;
// Log response
logResponse({
tool: 'generate_text',
provider: 'api',
model: modelId,
success: true,
response: response.text,
responseLength: response.text?.length,
tokenUsage,
durationMs: Date.now() - startTime,
});
return {
success: true,
text: response.text,
model: modelId,
provider: 'api',
tokenUsage,
};
} catch (error: any) {
const errorMsg = formatError(error);
// Log error
logResponse({
tool: 'generate_text',
provider: 'api',
model: modelId,
success: false,
durationMs: Date.now() - startTime,
error: errorMsg,
});
return {
success: false,
model: modelId,
provider: 'api',
error: errorMsg,
};
}
}
// ============================================================
// File Analysis (PDF/Image)
// ============================================================
export interface AnalyzeFileOptions {
source: string;
prompt: string;
model?: GeminiModel;
provider?: GeminiProvider;
}
export interface AnalyzeFileResult {
success: boolean;
analysis?: string;
model: string;
provider: GeminiProvider;
source: string;
sourceType: 'url' | 'file';
mimeType: string;
fileSizeBytes: number;
tokenUsage?: TokenUsage;
error?: string;
}
export async function analyzeFile(options: AnalyzeFileOptions): Promise<AnalyzeFileResult> {
const { source, prompt, model = 'flash', provider = DEFAULT_PROVIDER } = options;
const modelId = MODELS[model];
const sourceType = isUrl(source) ? 'url' : 'file';
const startTime = Date.now();
const tool = sourceType === 'file' && getMimeType(source).startsWith('image/') ? 'analyze_image' : 'analyze_pdf';
// Log request
logRequest({
tool,
provider,
model: modelId,
prompt,
source,
});
// CLI doesn't support inline file data well for binary files
// Always use API for file analysis
const useApi = provider === 'api' || sourceType === 'url' || !isCliAvailable();
if (!useApi && sourceType === 'file') {
// Use CLI for local file analysis with text prompt
const result = await runGeminiCli(`${prompt}\n\n파일 경로: ${expandPath(source)}`, { model: modelId });
let fileSizeBytes = 0;
let mimeType = 'unknown';
try {
const absolutePath = expandPath(source);
if (fs.existsSync(absolutePath)) {
fileSizeBytes = fs.statSync(absolutePath).size;
mimeType = getMimeType(absolutePath);
}
} catch {}
// Log response
logResponse({
tool,
provider: 'cli',
model: result.model || modelId,
success: result.success,
response: result.text,
responseLength: result.text?.length,
tokenUsage: result.tokenUsage,
durationMs: Date.now() - startTime,
error: result.error,
});
return {
success: result.success,
analysis: result.text,
model: result.model || modelId,
provider: 'cli',
source,
sourceType,
mimeType,
fileSizeBytes,
tokenUsage: result.tokenUsage,
error: result.error,
};
}
// Use API for URL sources or when API is specified
try {
const fileData = await loadFile(source);
const client = getClient();
const response = await client.models.generateContent({
model: modelId,
contents: [
{
role: 'user',
parts: [
{ text: prompt },
{
inlineData: {
mimeType: fileData.mimeType,
data: fileData.data,
},
},
],
},
],
});
const usage = response.usageMetadata;
const tokenUsage = usage
? {
promptTokens: usage.promptTokenCount || 0,
completionTokens: usage.candidatesTokenCount || 0,
totalTokens: usage.totalTokenCount || 0,
}
: undefined;
// Log response
logResponse({
tool,
provider: 'api',
model: modelId,
success: true,
response: response.text,
responseLength: response.text?.length,
tokenUsage,
durationMs: Date.now() - startTime,
});
return {
success: true,
analysis: response.text,
model: modelId,
provider: 'api',
source,
sourceType,
mimeType: fileData.mimeType,
fileSizeBytes: fileData.sizeBytes,
tokenUsage,
};
} catch (error: any) {
const errorMsg = formatError(error);
// Log error
logResponse({
tool,
provider: 'api',
model: modelId,
success: false,
durationMs: Date.now() - startTime,
error: errorMsg,
});
return {
success: false,
model: modelId,
provider: 'api',
source,
sourceType,
mimeType: 'unknown',
fileSizeBytes: 0,
error: errorMsg,
};
}
}
// ============================================================
// Code Analysis
// ============================================================
export type CodeTask = 'review' | 'explain' | 'improve';
export interface AnalyzeCodeOptions {
code: string;
language?: string;
task?: CodeTask;
prompt?: string;
model?: GeminiModel;
provider?: GeminiProvider;
}
export interface AnalyzeCodeResult {
success: boolean;
analysis?: string;
model: string;
provider: GeminiProvider;
task: CodeTask;
tokenUsage?: TokenUsage;
error?: string;
}
const CODE_TASK_PROMPTS: Record<CodeTask, string> = {
review: `다음 코드를 리뷰해주세요. 버그, 보안 취약점, 성능 문제, 코드 품질 이슈를 찾아주세요.`,
explain: `다음 코드가 어떻게 동작하는지 상세히 설명해주세요.`,
improve: `다음 코드를 개선할 방법을 제안해주세요. 더 좋은 패턴, 리팩토링, 최적화 방안을 알려주세요.`,
};
export async function analyzeCode(options: AnalyzeCodeOptions): Promise<AnalyzeCodeResult> {
const { code, language, task = 'review', prompt, model = 'flash', provider = DEFAULT_PROVIDER } = options;
const modelId = MODELS[model];
const startTime = Date.now();
let fullPrompt = prompt || CODE_TASK_PROMPTS[task];
if (language) {
fullPrompt += `\n\n언어: ${language}`;
}
fullPrompt += `\n\n\`\`\`${language || ''}\n${code}\n\`\`\``;
// Log request
logRequest({
tool: 'analyze_code',
provider,
model: modelId,
prompt: fullPrompt,
code,
});
// Use CLI if specified
if (provider === 'cli') {
const result = await runGeminiCli(fullPrompt, { model: modelId });
// Log response
logResponse({
tool: 'analyze_code',
provider: 'cli',
model: result.model || modelId,
success: result.success,
response: result.text,
responseLength: result.text?.length,
tokenUsage: result.tokenUsage,
durationMs: Date.now() - startTime,
error: result.error,
});
return {
success: result.success,
analysis: result.text,
model: result.model || modelId,
provider: 'cli',
task,
tokenUsage: result.tokenUsage,
error: result.error,
};
}
// Use API
try {
const client = getClient();
const response = await client.models.generateContent({
model: modelId,
contents: [{ role: 'user', parts: [{ text: fullPrompt }] }],
});
const usage = response.usageMetadata;
const tokenUsage = usage
? {
promptTokens: usage.promptTokenCount || 0,
completionTokens: usage.candidatesTokenCount || 0,
totalTokens: usage.totalTokenCount || 0,
}
: undefined;
// Log response
logResponse({
tool: 'analyze_code',
provider: 'api',
model: modelId,
success: true,
response: response.text,
responseLength: response.text?.length,
tokenUsage,
durationMs: Date.now() - startTime,
});
return {
success: true,
analysis: response.text,
model: modelId,
provider: 'api',
task,
tokenUsage,
};
} catch (error: any) {
const errorMsg = formatError(error);
// Log error
logResponse({
tool: 'analyze_code',
provider: 'api',
model: modelId,
success: false,
durationMs: Date.now() - startTime,
error: errorMsg,
});
return {
success: false,
model: modelId,
provider: 'api',
task,
error: errorMsg,
};
}
}
// ============================================================
// Error Handling
// ============================================================
function formatError(error: any): string {
const message = error.message || String(error);
if (message.includes('RESOURCE_EXHAUSTED')) {
return 'API 요청 한도 초과. 잠시 후 다시 시도하세요.';
}
if (message.includes('INVALID_ARGUMENT')) {
return '잘못된 파일 형식이거나 손상된 파일입니다.';
}
if (message.includes('PERMISSION_DENIED')) {
return 'API 키가 유효하지 않거나 권한이 없습니다.';
}
if (error.code === 'ENOENT') {
return `파일을 찾을 수 없습니다.`;
}
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
return '서버에 연결할 수 없습니다.';
}
if (error.response?.status === 404) {
return '파일을 찾을 수 없습니다 (404).';
}
if (error.response?.status === 403) {
return '접근이 거부되었습니다 (403).';
}
return message;
}