diff --git a/app/Http/Controllers/System/AiConfigController.php b/app/Http/Controllers/System/AiConfigController.php index cc02fdd9..b3b174c3 100644 --- a/app/Http/Controllers/System/AiConfigController.php +++ b/app/Http/Controllers/System/AiConfigController.php @@ -204,19 +204,35 @@ public function test(Request $request): JsonResponse { $validated = $request->validate([ 'provider' => 'required|string|in:gemini,claude,openai', - 'api_key' => 'required|string', + 'api_key' => 'nullable|string', 'model' => 'required|string', 'base_url' => 'nullable|string', + 'auth_type' => 'nullable|string|in:api_key,vertex_ai', + 'project_id' => 'nullable|string', + 'region' => 'nullable|string', + 'service_account_path' => 'nullable|string', ]); try { $provider = $validated['provider']; - $apiKey = $validated['api_key']; $model = $validated['model']; - $baseUrl = $validated['base_url'] ?? AiConfig::DEFAULT_BASE_URLS[$provider]; + $authType = $validated['auth_type'] ?? 'api_key'; if ($provider === 'gemini') { - $result = $this->testGemini($baseUrl, $model, $apiKey); + if ($authType === 'vertex_ai') { + // Vertex AI (서비스 계정) 방식 + $result = $this->testGeminiVertexAi( + $model, + $validated['project_id'] ?? '', + $validated['region'] ?? 'us-central1', + $validated['service_account_path'] ?? '' + ); + } else { + // API 키 방식 + $apiKey = $validated['api_key'] ?? ''; + $baseUrl = $validated['base_url'] ?? AiConfig::DEFAULT_BASE_URLS[$provider]; + $result = $this->testGemini($baseUrl, $model, $apiKey); + } } else { return response()->json([ 'ok' => false, @@ -234,7 +250,7 @@ public function test(Request $request): JsonResponse } /** - * Gemini API 테스트 + * Gemini API 테스트 (API 키 방식) */ private function testGemini(string $baseUrl, string $model, string $apiKey): array { @@ -267,6 +283,115 @@ private function testGemini(string $baseUrl, string $model, string $apiKey): arr ]; } + /** + * Gemini API 테스트 (Vertex AI 방식) + */ + private function testGeminiVertexAi(string $model, string $projectId, string $region, string $serviceAccountPath): array + { + // 필수 파라미터 검증 + if (empty($projectId)) { + return ['ok' => false, 'error' => '프로젝트 ID가 필요합니다.']; + } + + if (empty($serviceAccountPath)) { + return ['ok' => false, 'error' => '서비스 계정 파일 경로가 필요합니다.']; + } + + if (!file_exists($serviceAccountPath)) { + return ['ok' => false, 'error' => "서비스 계정 파일을 찾을 수 없습니다: {$serviceAccountPath}"]; + } + + // 서비스 계정 JSON 로드 + $serviceAccount = json_decode(file_get_contents($serviceAccountPath), true); + if (!$serviceAccount || empty($serviceAccount['client_email']) || empty($serviceAccount['private_key'])) { + return ['ok' => false, 'error' => '서비스 계정 파일 형식이 올바르지 않습니다.']; + } + + // OAuth 토큰 획득 + $accessToken = $this->getVertexAiAccessToken($serviceAccount); + if (!$accessToken) { + return ['ok' => false, 'error' => 'OAuth 토큰 획득 실패. 서비스 계정 권한을 확인하세요.']; + } + + // Vertex AI 엔드포인트 URL 구성 + $url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent"; + + $response = \Illuminate\Support\Facades\Http::timeout(30) + ->withHeaders([ + 'Authorization' => 'Bearer ' . $accessToken, + 'Content-Type' => 'application/json', + ]) + ->post($url, [ + 'contents' => [ + [ + 'role' => 'user', + 'parts' => [ + ['text' => '안녕하세요. 테스트입니다. "OK"라고만 응답해주세요.'], + ], + ], + ], + 'generationConfig' => [ + 'temperature' => 0, + 'maxOutputTokens' => 10, + ], + ]); + + if ($response->successful()) { + return [ + 'ok' => true, + 'message' => 'Vertex AI 연결 테스트 성공', + ]; + } + + // 상세 오류 메시지 추출 + $errorBody = $response->json(); + $errorMsg = $errorBody['error']['message'] ?? ('HTTP ' . $response->status()); + + return [ + 'ok' => false, + 'error' => "Vertex AI 오류: {$errorMsg}", + ]; + } + + /** + * Vertex AI OAuth 토큰 획득 + */ + private function getVertexAiAccessToken(array $serviceAccount): ?string + { + $now = time(); + $jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $jwtClaim = $this->base64UrlEncode(json_encode([ + 'iss' => $serviceAccount['client_email'], + 'scope' => 'https://www.googleapis.com/auth/cloud-platform', + 'aud' => 'https://oauth2.googleapis.com/token', + 'exp' => $now + 3600, + 'iat' => $now, + ])); + + $privateKey = openssl_pkey_get_private($serviceAccount['private_key']); + if (!$privateKey) { + return null; + } + + openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); + if (PHP_VERSION_ID < 80000) { + openssl_free_key($privateKey); + } + + $jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature); + + $response = \Illuminate\Support\Facades\Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ]); + + if ($response->successful()) { + return $response->json('access_token'); + } + + return null; + } + /** * GCS 연결 테스트 */ diff --git a/resources/views/system/ai-config/index.blade.php b/resources/views/system/ai-config/index.blade.php index 033d9ef7..4f0032ff 100644 --- a/resources/views/system/ai-config/index.blade.php +++ b/resources/views/system/ai-config/index.blade.php @@ -602,16 +602,38 @@ function toggleAuthTypeUI(provider, authType) { // 연결 테스트 (모달에서) window.testConnectionFromModal = async function() { + const provider = document.getElementById('config-provider').value; + const authType = document.getElementById('config-auth-type').value; + const data = { - provider: document.getElementById('config-provider').value, - api_key: document.getElementById('config-api-key').value, + provider: provider, model: document.getElementById('config-model').value, - base_url: document.getElementById('config-base-url').value || null + auth_type: authType }; - if (!data.api_key) { - showToast('API 키를 입력해주세요.', 'error'); - return; + // Vertex AI 방식인 경우 + if (provider === 'gemini' && authType === 'vertex_ai') { + data.project_id = document.getElementById('config-project-id').value; + data.region = document.getElementById('config-region').value; + data.service_account_path = document.getElementById('config-service-account-path').value; + + if (!data.project_id) { + showToast('프로젝트 ID를 입력해주세요.', 'error'); + return; + } + if (!data.service_account_path) { + showToast('서비스 계정 파일 경로를 입력해주세요.', 'error'); + return; + } + } else { + // API 키 방식인 경우 + data.api_key = document.getElementById('config-api-key').value; + data.base_url = document.getElementById('config-base-url').value || null; + + if (!data.api_key) { + showToast('API 키를 입력해주세요.', 'error'); + return; + } } showToast('연결 테스트 중...', 'info'); @@ -629,7 +651,7 @@ function toggleAuthTypeUI(provider, authType) { const result = await response.json(); if (result.ok) { - showToast('연결 테스트 성공!', 'success'); + showToast(result.message || '연결 테스트 성공!', 'success'); } else { showToast(result.error || '연결 테스트 실패', 'error'); }