Implement Vertex AI authentication modes and configuration in UI - Updated index.html to include options for Vertex AI authentication modes (Express and Full). - Enhanced openai.js to manage Vertex AI settings, including project ID, region, and service account JSON. - Added validation and handling for service account JSON in the backend. - Modified API request handling in google.js to support both authentication modes for Vertex AI. - Updated secrets.js to include a key for storing Vertex AI service account JSON. - Improved error handling and user feedback for authentication issues.
| @@ -2779,7 +2779,7 @@ | ||
| 2779 | 2779 | <option value="deepseek">DeepSeek</option> |
| 2780 | 2780 | <option value="groq">Groq</option> |
| 2781 | 2781 | <option value="makersuite">Google AI Studio</option> |
| 2782 | 2782 | <option value="vertexai">Google Vertex AI (Express mode)</option> |
| 2783 | 2783 | <option value="mistralai">MistralAI</option> |
| 2784 | 2784 | <option value="nanogpt">NanoGPT</option> |
| 2785 | 2785 | <option value="openrouter">OpenRouter</option> |
| @@ -3225,12 +3225,25 @@ | ||
| 3225 | 3225 | </div> |
| 3226 | 3226 | </form> |
| 3227 | 3227 | <div id="vertexai_form" data-source="vertexai"> |
| 3228 | + <h4 data-i18n="Google Vertex AI Configuration">Google Vertex AI Configuration</h4> | |
| 3229 | + | |
| 3230 | + <!-- Authentication Mode Selection --> | |
| 3231 | + <div class="flex-container"> | |
| 3232 | + <label for="vertexai_auth_mode" data-i18n="Authentication Mode">Authentication Mode:</label> | |
| 3233 | + <select id="vertexai_auth_mode" class="text_pole"> | |
| 3234 | + <option value="express" data-i18n="Express Mode (API Key)">Express Mode (API Key)</option> | |
| 3235 | + <option value="full" data-i18n="Full Version (Service Account)">Full Version (Service Account)</option> | |
| 3236 | + </select> | |
| 3237 | + </div> | |
| 3238 | + | |
| 3239 | + <!-- Express Mode Configuration --> | |
| 3240 | + <div id="vertexai_express_config" class="vertexai-auth-section"> | |
| 3228 | 3241 | <h4> |
| 3229 | 3242 | <span data-i18n="Google Vertex AI API Key"> |
| 3230 | 3243 | Google Vertex AI API Key |
| 3231 | 3244 | </span> |
| 3232 | 3245 | <a href="https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview" data-i18n="(Express mode keys only)" target="_blank" rel="noopener noreferrer"> |
| 3233 | 3246 | (Express mode keys only) |
| 3234 | 3247 | </a> |
| 3235 | 3248 | </h4> |
| 3236 | 3249 | <div class="flex-container"> |
| @@ -3240,18 +3253,118 @@ | ||
| 3240 | 3253 | <div data-for="api_key_vertexai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page."> |
| 3241 | 3254 | For privacy reasons, your API key will be hidden after you reload the page. |
| 3242 | 3255 | </div> |
| 3256 | + </div> | |
| 3257 | + | |
| 3258 | + <!-- Full Version Configuration --> | |
| 3259 | + <div id="vertexai_full_config" class="vertexai-auth-section" style="display: none;"> | |
| 3260 | + <h4> | |
| 3261 | + <span data-i18n="Service Account Configuration"> | |
| 3262 | + Service Account Configuration | |
| 3263 | + </span> | |
| 3264 | + <a href="https://cloud.google.com/vertex-ai/docs/authentication" target="_blank" rel="noopener noreferrer"> | |
| 3265 | + <i class="fa-solid fa-circle-question"></i> | |
| 3266 | + </a> | |
| 3267 | + </h4> | |
| 3268 | + | |
| 3269 | + <!-- Project ID --> | |
| 3270 | + <div class="flex-container"> | |
| 3271 | + <label for="vertexai_project_id" data-i18n="Project ID">Project ID:</label> | |
| 3272 | + <input id="vertexai_project_id" name="vertexai_project_id" class="text_pole flex1" value="" type="text" autocomplete="off" placeholder="your-gcp-project-id"> | |
| 3273 | + </div> | |
| 3274 | + | |
| 3275 | + <!-- Region --> | |
| 3276 | + <div class="flex-container"> | |
| 3277 | + <label for="vertexai_region" data-i18n="Region">Region:</label> | |
| 3278 | + <input id="vertexai_region" name="vertexai_region" class="text_pole flex1" value="us-central1" type="text" autocomplete="off" placeholder="e.g., us-central1, europe-west1, asia-northeast1"> | |
| 3279 | + </div> | |
| 3280 | + | |
| 3281 | + <!-- Service Account JSON Content --> | |
| 3282 | + <div class="flex-container flexFlowColumn"> | |
| 3283 | + <label for="vertexai_service_account_json" data-i18n="Service Account JSON Content">Service Account JSON Content:</label> | |
| 3284 | + <textarea id="vertexai_service_account_json" class="text_pole textarea_compact" rows="8" placeholder='Paste your Service Account JSON content here, e.g.: | |
| 3285 | +{ | |
| 3286 | + "type": "service_account", | |
| 3287 | + "project_id": "your-project-id", | |
| 3288 | + "private_key_id": "...", | |
| 3289 | + "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", | |
| 3290 | + "client_email": "...", | |
| 3291 | + "client_id": "...", | |
| 3292 | + "auth_uri": "https://accounts.google.com/o/oauth2/auth", | |
| 3293 | + "token_uri": "https://oauth2.googleapis.com/token" | |
| 3294 | +}'></textarea> | |
| 3295 | + <div class="neutral_warning" data-i18n="Paste the complete JSON content of your Google Cloud Service Account key file. The content will be stored securely."> | |
| 3296 | + Paste the complete JSON content of your Google Cloud Service Account key file. The content will be stored securely. | |
| 3297 | + </div> | |
| 3298 | + <div class="flex-container"> | |
| 3299 | + <button type="button" id="vertexai_validate_service_account" class="menu_button" data-i18n="Validate JSON">Validate JSON</button> | |
| 3300 | + <button type="button" id="vertexai_clear_service_account" class="menu_button" data-i18n="Clear">Clear</button> | |
| 3301 | + </div> | |
| 3302 | + <div id="vertexai_service_account_status" class="info-block" style="display: none;"> | |
| 3303 | + <span id="vertexai_service_account_info"></span> | |
| 3304 | + </div> | |
| 3305 | + </div> | |
| 3306 | + </div> | |
| 3307 | + | |
| 3308 | + <!-- Model Selection --> | |
| 3243 | 3309 | <div> |
| 3244 | 3310 | <h4 data-i18n="Google Model">Google Model</h4> |
| 3245 | 3311 | <select id="model_vertexai_select"> |
| 3246 | - <optgroup label="Gemini 2.5"> | |
| 3312 | + <!-- Express Mode Models --> | |
| 3313 | + <optgroup id="vertexai_express_models" label="Express Mode Models"> | |
| 3247 | 3314 | <option value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option> |
| 3248 | 3315 | <option value="gemini-2.5-pro-preview-03-25">gemini-2.5-pro-preview-03-25</option> |
| 3249 | 3316 | <option value="gemini-2.5-flash-preview-05-20">gemini-2.5-flash-preview-05-20</option> |
| 3250 | 3317 | <option value="gemini-2.5-flash-preview-04-17">gemini-2.5-flash-preview-04-17</option> |
| 3318 | + <option value="gemini-2.0-flash-001">gemini-2.0-flash-001</option> | |
| 3319 | + <option value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option> | |
| 3251 | 3320 | </optgroup> |
| 3252 | - <optgroup label="Gemini 2.0"> | |
| 3321 | + | |
| 3322 | + <!-- Full Version Models --> | |
| 3323 | + <optgroup id="vertexai_full_gemini_25" label="Gemini 2.5" style="display: none;"> | |
| 3324 | + <option value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option> | |
| 3325 | + <option value="gemini-2.5-pro-preview-03-25">gemini-2.5-pro-preview-03-25</option> | |
| 3326 | + <option value="gemini-2.5-pro-exp-03-25">gemini-2.5-pro-exp-03-25</option> | |
| 3327 | + <option value="gemini-2.5-flash-preview-05-20">gemini-2.5-flash-preview-05-20</option> | |
| 3328 | + <option value="gemini-2.5-flash-preview-04-17">gemini-2.5-flash-preview-04-17</option> | |
| 3329 | + </optgroup> | |
| 3330 | + <optgroup id="vertexai_full_gemini_20" label="Gemini 2.0" style="display: none;"> | |
| 3331 | + <option value="gemini-2.0-pro-exp-02-05">gemini-2.0-pro-exp-02-05 → 2.5-pro-exp-03-25</option> | |
| 3332 | + <option value="gemini-2.0-pro-exp">gemini-2.0-pro-exp → 2.5-pro-exp-03-25</option> | |
| 3333 | + <option value="gemini-exp-1206">gemini-exp-1206 → 2.5-pro-exp-03-25</option> | |
| 3253 | 3334 | <option value="gemini-2.0-flash-001">gemini-2.0-flash-001</option> |
| 3335 | + <option value="gemini-2.0-flash-exp-image-generation">gemini-2.0-flash-exp-image-generation</option> | |
| 3336 | + <option value="gemini-2.0-flash-exp">gemini-2.0-flash-exp</option> | |
| 3337 | + <option value="gemini-2.0-flash">gemini-2.0-flash</option> | |
| 3338 | + <option value="gemini-2.0-flash-thinking-exp-01-21">gemini-2.0-flash-thinking-exp-01-21 → 2.5-flash-preview-04-17</option> | |
| 3339 | + <option value="gemini-2.0-flash-thinking-exp-1219">gemini-2.0-flash-thinking-exp-1219 → 2.5-flash-preview-04-17</option> | |
| 3340 | + <option value="gemini-2.0-flash-thinking-exp">gemini-2.0-flash-thinking-exp → 2.5-flash-preview-04-17</option> | |
| 3254 | 3341 | <option value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option> |
| 3342 | + <option value="gemini-2.0-flash-lite-preview-02-05">gemini-2.0-flash-lite-preview-02-05</option> | |
| 3343 | + <option value="gemini-2.0-flash-lite-preview">gemini-2.0-flash-lite-preview</option> | |
| 3344 | + </optgroup> | |
| 3345 | + <optgroup id="vertexai_full_gemini_15" label="Gemini 1.5" style="display: none;"> | |
| 3346 | + <option value="gemini-1.5-pro-latest">gemini-1.5-pro-latest</option> | |
| 3347 | + <option value="gemini-1.5-pro-002">gemini-1.5-pro-002</option> | |
| 3348 | + <option value="gemini-1.5-pro-001">gemini-1.5-pro-001</option> | |
| 3349 | + <option value="gemini-1.5-pro">gemini-1.5-pro</option> | |
| 3350 | + <option value="gemini-1.5-flash-latest">gemini-1.5-flash-latest</option> | |
| 3351 | + <option value="gemini-1.5-flash-002">gemini-1.5-flash-002</option> | |
| 3352 | + <option value="gemini-1.5-flash-001">gemini-1.5-flash-001</option> | |
| 3353 | + <option value="gemini-1.5-flash">gemini-1.5-flash</option> | |
| 3354 | + <option value="gemini-1.5-flash-8b-001">gemini-1.5-flash-8b-001</option> | |
| 3355 | + <option value="gemini-1.5-flash-8b-exp-0924">gemini-1.5-flash-8b-exp-0924</option> | |
| 3356 | + <option value="gemini-1.5-flash-8b-exp-0827">gemini-1.5-flash-8b-exp-0827</option> | |
| 3357 | + <option value="gemini-1.5-flash-8b">gemini-1.5-flash-8b</option> | |
| 3358 | + </optgroup> | |
| 3359 | + <optgroup id="vertexai_full_gemma" label="Gemma" style="display: none;"> | |
| 3360 | + <option value="gemma-3-27b-it">gemma-3-27b-it</option> | |
| 3361 | + <option value="gemma-3-12b-it">gemma-3-12b-it</option> | |
| 3362 | + <option value="gemma-3-4b-it">gemma-3-4b-it</option> | |
| 3363 | + <option value="gemma-3-1b-it">gemma-3-1b-it</option> | |
| 3364 | + </optgroup> | |
| 3365 | + <optgroup id="vertexai_full_learnlm" label="LearnLM" style="display: none;"> | |
| 3366 | + <option value="learnlm-2.0-flash-experimental">learnlm-2.0-flash-experimental</option> | |
| 3367 | + <option value="learnlm-1.5-pro-experimental">learnlm-1.5-pro-experimental</option> | |
| 3255 | 3368 | </optgroup> |
| 3256 | 3369 | </select> |
| 3257 | 3370 | </div> |
| @@ -56,6 +56,13 @@ export async function getMultimodalCaption(base64Img, prompt) { | ||
| 56 | 56 | model: extension_settings.caption.multimodal_model || 'gpt-4-turbo', |
| 57 | 57 | }; |
| 58 | 58 | |
| 59 | + // Add Vertex AI specific parameters if using Vertex AI | |
| 60 | + if (extension_settings.caption.multimodal_api === 'vertexai') { | |
| 61 | + requestBody.vertexai_auth_mode = oai_settings.vertexai_auth_mode; | |
| 62 | + requestBody.vertexai_project_id = oai_settings.vertexai_project_id; | |
| 63 | + requestBody.vertexai_region = oai_settings.vertexai_region; | |
| 64 | + } | |
| 65 | + | |
| 59 | 66 | if (isOllama) { |
| 60 | 67 | if (extension_settings.caption.multimodal_model === 'ollama_current') { |
| 61 | 68 | requestBody.model = textgenerationwebui_settings.ollama_model; |
| @@ -164,8 +171,27 @@ function throwIfInvalidModel(useReverseProxy) { | ||
| 164 | 171 | throw new Error('Google AI Studio API key is not set.'); |
| 165 | 172 | } |
| 166 | 173 | |
| 167 | 174 | if (multimodalApi === 'vertexai' && !secret_state[SECRET_KEYS.VERTEXAI] && !useReverseProxy) { |
| 168 | - throw new Error('Google Vertex AI API key is not set.'); | |
| 175 | + // Check based on authentication mode | |
| 176 | + const authMode = oai_settings.vertexai_auth_mode || 'express'; | |
| 177 | + | |
| 178 | + if (authMode === 'express') { | |
| 179 | + // Express mode requires API key | |
| 180 | + if (!secret_state[SECRET_KEYS.VERTEXAI]) { | |
| 181 | + throw new Error('Google Vertex AI API key is not set for Express mode.'); | |
| 182 | + } | |
| 183 | + } else if (authMode === 'full') { | |
| 184 | + // Full mode requires Service Account JSON and project settings | |
| 185 | + if (!secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]) { | |
| 186 | + throw new Error('Service Account JSON is required for Vertex AI Full mode. Please validate and save your Service Account JSON.'); | |
| 187 | + } | |
| 188 | + if (!oai_settings.vertexai_project_id) { | |
| 189 | + throw new Error('Project ID is required for Vertex AI Full mode.'); | |
| 190 | + } | |
| 191 | + if (!oai_settings.vertexai_region) { | |
| 192 | + throw new Error('Region is required for Vertex AI Full mode.'); | |
| 193 | + } | |
| 194 | + } | |
| 169 | 195 | } |
| 170 | 196 | |
| 171 | 197 | if (multimodalApi === 'mistral' && !secret_state[SECRET_KEYS.MISTRALAI] && !useReverseProxy) { |
| @@ -306,6 +306,10 @@ export const settingsToUpdate = { | ||
| 306 | 306 | assistant_impersonation: ['#claude_assistant_impersonation', 'assistant_impersonation', false, false], |
| 307 | 307 | claude_use_sysprompt: ['#claude_use_sysprompt', 'claude_use_sysprompt', true, false], |
| 308 | 308 | use_makersuite_sysprompt: ['#use_makersuite_sysprompt', 'use_makersuite_sysprompt', true, false], |
| 309 | + vertexai_auth_mode: ['#vertexai_auth_mode', 'vertexai_auth_mode', false, true], | |
| 310 | + vertexai_project_id: ['#vertexai_project_id', 'vertexai_project_id', false, true], | |
| 311 | + vertexai_region: ['#vertexai_region', 'vertexai_region', false, true], | |
| 312 | + vertexai_service_account_json: ['#vertexai_service_account_json', 'vertexai_service_account_json', false, true], | |
| 309 | 313 | use_alt_scale: ['#use_alt_scale', 'use_alt_scale', true, true], |
| 310 | 314 | squash_system_messages: ['#squash_system_messages', 'squash_system_messages', true, false], |
| 311 | 315 | image_inlining: ['#openai_image_inlining', 'image_inlining', true, false], |
| @@ -387,6 +391,10 @@ const default_settings = { | ||
| 387 | 391 | assistant_impersonation: '', |
| 388 | 392 | claude_use_sysprompt: false, |
| 389 | 393 | use_makersuite_sysprompt: true, |
| 394 | + vertexai_auth_mode: 'express', | |
| 395 | + vertexai_project_id: '', | |
| 396 | + vertexai_region: 'us-central1', | |
| 397 | + vertexai_service_account_json: '', | |
| 390 | 398 | use_alt_scale: false, |
| 391 | 399 | squash_system_messages: false, |
| 392 | 400 | image_inlining: false, |
| @@ -471,6 +479,10 @@ const oai_settings = { | ||
| 471 | 479 | assistant_impersonation: '', |
| 472 | 480 | claude_use_sysprompt: false, |
| 473 | 481 | use_makersuite_sysprompt: true, |
| 482 | + vertexai_auth_mode: 'express', | |
| 483 | + vertexai_project_id: '', | |
| 484 | + vertexai_region: 'us-central1', | |
| 485 | + vertexai_service_account_json: '', | |
| 474 | 486 | use_alt_scale: false, |
| 475 | 487 | squash_system_messages: false, |
| 476 | 488 | image_inlining: false, |
| @@ -2188,6 +2200,11 @@ async function sendOpenAIRequest(type, messages, signal) { | ||
| 2188 | 2200 | generate_data['top_k'] = Number(oai_settings.top_k_openai); |
| 2189 | 2201 | generate_data['stop'] = getCustomStoppingStrings(stopStringsLimit).slice(0, stopStringsLimit).filter(x => x.length >= 1 && x.length <= 16); |
| 2190 | 2202 | generate_data['use_makersuite_sysprompt'] = oai_settings.use_makersuite_sysprompt; |
| 2203 | + if (isVertexAI) { | |
| 2204 | + generate_data['vertexai_auth_mode'] = oai_settings.vertexai_auth_mode; | |
| 2205 | + generate_data['vertexai_project_id'] = oai_settings.vertexai_project_id; | |
| 2206 | + generate_data['vertexai_region'] = oai_settings.vertexai_region; | |
| 2207 | + } | |
| 2191 | 2208 | } |
| 2192 | 2209 | |
| 2193 | 2210 | if (isMistral) { |
| @@ -3427,6 +3444,9 @@ function loadOpenAISettings(data, settings) { | ||
| 3427 | 3444 | if (settings.openai_model !== undefined) oai_settings.openai_model = settings.openai_model; |
| 3428 | 3445 | if (settings.claude_use_sysprompt !== undefined) oai_settings.claude_use_sysprompt = !!settings.claude_use_sysprompt; |
| 3429 | 3446 | if (settings.use_makersuite_sysprompt !== undefined) oai_settings.use_makersuite_sysprompt = !!settings.use_makersuite_sysprompt; |
| 3447 | + if (settings.vertexai_auth_mode !== undefined) oai_settings.vertexai_auth_mode = settings.vertexai_auth_mode; | |
| 3448 | + if (settings.vertexai_project_id !== undefined) oai_settings.vertexai_project_id = settings.vertexai_project_id; | |
| 3449 | + if (settings.vertexai_region !== undefined) oai_settings.vertexai_region = settings.vertexai_region; | |
| 3430 | 3450 | if (settings.use_alt_scale !== undefined) { oai_settings.use_alt_scale = !!settings.use_alt_scale; updateScaleForm(); } |
| 3431 | 3451 | $('#stream_toggle').prop('checked', oai_settings.stream_openai); |
| 3432 | 3452 | $('#api_url_scale').val(oai_settings.api_url_scale); |
| @@ -3482,6 +3502,12 @@ function loadOpenAISettings(data, settings) { | ||
| 3482 | 3502 | $('#openai_external_category').toggle(oai_settings.show_external_models); |
| 3483 | 3503 | $('#claude_use_sysprompt').prop('checked', oai_settings.claude_use_sysprompt); |
| 3484 | 3504 | $('#use_makersuite_sysprompt').prop('checked', oai_settings.use_makersuite_sysprompt); |
| 3505 | + $('#vertexai_auth_mode').val(oai_settings.vertexai_auth_mode); | |
| 3506 | + $('#vertexai_project_id').val(oai_settings.vertexai_project_id); | |
| 3507 | + $('#vertexai_region').val(oai_settings.vertexai_region); | |
| 3508 | + // Don't display Service Account JSON in textarea - it's stored in backend secrets | |
| 3509 | + $('#vertexai_service_account_json').val(''); | |
| 3510 | + updateVertexAIServiceAccountStatus(); | |
| 3485 | 3511 | $('#scale-alt').prop('checked', oai_settings.use_alt_scale); |
| 3486 | 3512 | $('#openrouter_use_fallback').prop('checked', oai_settings.openrouter_use_fallback); |
| 3487 | 3513 | $('#openrouter_group_models').prop('checked', oai_settings.openrouter_group_models); |
| @@ -3804,6 +3830,10 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) { | ||
| 3804 | 3830 | assistant_impersonation: settings.assistant_impersonation, |
| 3805 | 3831 | claude_use_sysprompt: settings.claude_use_sysprompt, |
| 3806 | 3832 | use_makersuite_sysprompt: settings.use_makersuite_sysprompt, |
| 3833 | + vertexai_auth_mode: settings.vertexai_auth_mode, | |
| 3834 | + vertexai_project_id: settings.vertexai_project_id, | |
| 3835 | + vertexai_region: settings.vertexai_region, | |
| 3836 | + vertexai_service_account_json: settings.vertexai_service_account_json, | |
| 3807 | 3837 | use_alt_scale: settings.use_alt_scale, |
| 3808 | 3838 | squash_system_messages: settings.squash_system_messages, |
| 3809 | 3839 | image_inlining: settings.image_inlining, |
| @@ -4979,6 +5009,8 @@ async function onConnectButtonClick(e) { | ||
| 4979 | 5009 | } |
| 4980 | 5010 | |
| 4981 | 5011 | if (oai_settings.chat_completion_source == chat_completion_sources.VERTEXAI) { |
| 5012 | + if (oai_settings.vertexai_auth_mode === 'express') { | |
| 5013 | + // Express mode - use API key | |
| 4982 | 5014 | const api_key_vertexai = String($('#api_key_vertexai').val()).trim(); |
| 4983 | 5015 | |
| 4984 | 5016 | if (api_key_vertexai.length) { |
| @@ -4986,9 +5018,22 @@ async function onConnectButtonClick(e) { | ||
| 4986 | 5018 | } |
| 4987 | 5019 | |
| 4988 | 5020 | if (!secret_state[SECRET_KEYS.VERTEXAI] && !oai_settings.reverse_proxy) { |
| 4989 | 5021 | console.log('No secret key saved for Vertex AI Express mode'); |
| 4990 | 5022 | return; |
| 4991 | 5023 | } |
| 5024 | + } else { | |
| 5025 | + // Full version - use service account | |
| 5026 | + if (!oai_settings.vertexai_project_id.trim()) { | |
| 5027 | + toastr.error('Project ID is required for Vertex AI full version'); | |
| 5028 | + return; | |
| 5029 | + } | |
| 5030 | + | |
| 5031 | + // Check if service account JSON is saved in backend | |
| 5032 | + if (!secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]) { | |
| 5033 | + toastr.error('Service Account JSON is required for Vertex AI full version. Please validate and save your Service Account JSON.'); | |
| 5034 | + return; | |
| 5035 | + } | |
| 5036 | + } | |
| 4992 | 5037 | } |
| 4993 | 5038 | |
| 4994 | 5039 | if (oai_settings.chat_completion_source == chat_completion_sources.CLAUDE) { |
| @@ -5170,6 +5215,8 @@ function toggleChatCompletionForms() { | ||
| 5170 | 5215 | } |
| 5171 | 5216 | else if (oai_settings.chat_completion_source == chat_completion_sources.VERTEXAI) { |
| 5172 | 5217 | $('#model_vertexai_select').trigger('change'); |
| 5218 | + // Update UI based on authentication mode | |
| 5219 | + onVertexAIAuthModeChange.call($('#vertexai_auth_mode')[0]); | |
| 5173 | 5220 | } |
| 5174 | 5221 | else if (oai_settings.chat_completion_source == chat_completion_sources.OPENROUTER) { |
| 5175 | 5222 | $('#model_openrouter_select').trigger('change'); |
| @@ -5480,6 +5527,160 @@ function runProxyCallback(_, value) { | ||
| 5480 | 5527 | return foundName; |
| 5481 | 5528 | } |
| 5482 | 5529 | |
| 5530 | +/** | |
| 5531 | + * Handle Vertex AI authentication mode change | |
| 5532 | + */ | |
| 5533 | +function onVertexAIAuthModeChange() { | |
| 5534 | + const authMode = String($(this).val()); | |
| 5535 | + oai_settings.vertexai_auth_mode = authMode; | |
| 5536 | + | |
| 5537 | + // Show/hide appropriate configuration sections | |
| 5538 | + if (authMode === 'express') { | |
| 5539 | + $('#vertexai_express_config').show(); | |
| 5540 | + $('#vertexai_full_config').hide(); | |
| 5541 | + $('#vertexai_express_models').show(); | |
| 5542 | + // Hide all full version model groups | |
| 5543 | + $('#vertexai_full_gemini_25').hide(); | |
| 5544 | + $('#vertexai_full_gemini_20').hide(); | |
| 5545 | + $('#vertexai_full_gemini_15').hide(); | |
| 5546 | + $('#vertexai_full_gemma').hide(); | |
| 5547 | + $('#vertexai_full_learnlm').hide(); | |
| 5548 | + } else { | |
| 5549 | + $('#vertexai_express_config').hide(); | |
| 5550 | + $('#vertexai_full_config').show(); | |
| 5551 | + $('#vertexai_express_models').hide(); | |
| 5552 | + // Show all full version model groups | |
| 5553 | + $('#vertexai_full_gemini_25').show(); | |
| 5554 | + $('#vertexai_full_gemini_20').show(); | |
| 5555 | + $('#vertexai_full_gemini_15').show(); | |
| 5556 | + $('#vertexai_full_gemma').show(); | |
| 5557 | + $('#vertexai_full_learnlm').show(); | |
| 5558 | + } | |
| 5559 | + | |
| 5560 | + saveSettingsDebounced(); | |
| 5561 | +} | |
| 5562 | + | |
| 5563 | +/** | |
| 5564 | + * Validate Vertex AI service account JSON | |
| 5565 | + */ | |
| 5566 | +async function onVertexAIValidateServiceAccount() { | |
| 5567 | + const jsonContent = String($('#vertexai_service_account_json').val()).trim(); | |
| 5568 | + | |
| 5569 | + if (!jsonContent) { | |
| 5570 | + toastr.error('Please enter Service Account JSON content'); | |
| 5571 | + return; | |
| 5572 | + } | |
| 5573 | + | |
| 5574 | + try { | |
| 5575 | + const serviceAccount = JSON.parse(jsonContent); | |
| 5576 | + const requiredFields = ['type', 'project_id', 'private_key', 'client_email', 'client_id']; | |
| 5577 | + const missingFields = requiredFields.filter(field => !serviceAccount[field]); | |
| 5578 | + | |
| 5579 | + if (missingFields.length > 0) { | |
| 5580 | + toastr.error(`Missing required fields: ${missingFields.join(', ')}`); | |
| 5581 | + updateVertexAIServiceAccountStatus(false, `Missing fields: ${missingFields.join(', ')}`); | |
| 5582 | + return; | |
| 5583 | + } | |
| 5584 | + | |
| 5585 | + if (serviceAccount.type !== 'service_account') { | |
| 5586 | + toastr.error('Invalid service account type. Expected "service_account"'); | |
| 5587 | + updateVertexAIServiceAccountStatus(false, 'Invalid service account type'); | |
| 5588 | + return; | |
| 5589 | + } | |
| 5590 | + | |
| 5591 | + // Save to backend secret storage | |
| 5592 | + await writeSecret(SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, jsonContent); | |
| 5593 | + | |
| 5594 | + // Clear the textarea and update settings | |
| 5595 | + $('#vertexai_service_account_json').val(''); | |
| 5596 | + oai_settings.vertexai_service_account_json = ''; | |
| 5597 | + | |
| 5598 | + // Show success status | |
| 5599 | + updateVertexAIServiceAccountStatus(true, `Project: ${serviceAccount.project_id}, Email: ${serviceAccount.client_email}`); | |
| 5600 | + | |
| 5601 | + toastr.success('Service Account JSON is valid and saved securely'); | |
| 5602 | + saveSettingsDebounced(); | |
| 5603 | + } catch (error) { | |
| 5604 | + console.error('JSON validation error:', error); | |
| 5605 | + toastr.error('Invalid JSON format'); | |
| 5606 | + updateVertexAIServiceAccountStatus(false, 'Invalid JSON format'); | |
| 5607 | + } | |
| 5608 | +} | |
| 5609 | + | |
| 5610 | +/** | |
| 5611 | + * Clear Vertex AI service account JSON | |
| 5612 | + */ | |
| 5613 | +async function onVertexAIClearServiceAccount() { | |
| 5614 | + $('#vertexai_service_account_json').val(''); | |
| 5615 | + oai_settings.vertexai_service_account_json = ''; | |
| 5616 | + | |
| 5617 | + // Clear from backend secret storage | |
| 5618 | + await writeSecret(SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, ''); | |
| 5619 | + | |
| 5620 | + updateVertexAIServiceAccountStatus(false); | |
| 5621 | + toastr.info('Service Account JSON cleared'); | |
| 5622 | + saveSettingsDebounced(); | |
| 5623 | +} | |
| 5624 | + | |
| 5625 | +/** | |
| 5626 | + * Handle Vertex AI service account JSON input change | |
| 5627 | + */ | |
| 5628 | +function onVertexAIServiceAccountJsonChange() { | |
| 5629 | + const jsonContent = String($(this).val()).trim(); | |
| 5630 | + // Don't save to settings automatically - only save when validated | |
| 5631 | + // oai_settings.vertexai_service_account_json = jsonContent; | |
| 5632 | + | |
| 5633 | + if (jsonContent) { | |
| 5634 | + // Auto-validate when content is pasted | |
| 5635 | + try { | |
| 5636 | + const serviceAccount = JSON.parse(jsonContent); | |
| 5637 | + const requiredFields = ['type', 'project_id', 'private_key', 'client_email']; | |
| 5638 | + const hasAllFields = requiredFields.every(field => serviceAccount[field]); | |
| 5639 | + | |
| 5640 | + if (hasAllFields && serviceAccount.type === 'service_account') { | |
| 5641 | + updateVertexAIServiceAccountStatus(false, 'JSON appears valid - click "Validate JSON" to save'); | |
| 5642 | + } else { | |
| 5643 | + updateVertexAIServiceAccountStatus(false, 'Incomplete or invalid JSON'); | |
| 5644 | + } | |
| 5645 | + } catch (error) { | |
| 5646 | + updateVertexAIServiceAccountStatus(false, 'Invalid JSON format'); | |
| 5647 | + } | |
| 5648 | + } else { | |
| 5649 | + updateVertexAIServiceAccountStatus(false); | |
| 5650 | + } | |
| 5651 | + | |
| 5652 | + // Don't save settings automatically | |
| 5653 | + // saveSettingsDebounced(); | |
| 5654 | +} | |
| 5655 | + | |
| 5656 | +/** | |
| 5657 | + * Update the Vertex AI service account status display | |
| 5658 | + * @param {boolean} isValid - Whether the service account is valid | |
| 5659 | + * @param {string} message - Status message to display | |
| 5660 | + */ | |
| 5661 | +function updateVertexAIServiceAccountStatus(isValid = false, message = '') { | |
| 5662 | + const statusDiv = $('#vertexai_service_account_status'); | |
| 5663 | + const infoSpan = $('#vertexai_service_account_info'); | |
| 5664 | + | |
| 5665 | + // If no explicit message provided, check if we have a saved service account | |
| 5666 | + if (!message && secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]) { | |
| 5667 | + isValid = true; | |
| 5668 | + message = 'Service Account JSON is saved and ready to use'; | |
| 5669 | + } | |
| 5670 | + | |
| 5671 | + if (isValid && message) { | |
| 5672 | + infoSpan.html(`<i class="fa-solid fa-check-circle" style="color: green;"></i> ${message}`); | |
| 5673 | + statusDiv.show(); | |
| 5674 | + } else if (!isValid && message) { | |
| 5675 | + infoSpan.html(`<i class="fa-solid fa-exclamation-triangle" style="color: orange;"></i> ${message}`); | |
| 5676 | + statusDiv.show(); | |
| 5677 | + } else { | |
| 5678 | + statusDiv.hide(); | |
| 5679 | + } | |
| 5680 | +} | |
| 5681 | + | |
| 5682 | + | |
| 5683 | + | |
| 5483 | 5684 | export function initOpenAI() { |
| 5484 | 5685 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 5485 | 5686 | name: 'proxy', |
| @@ -5943,6 +6144,18 @@ export function initOpenAI() { | ||
| 5943 | 6144 | $('#model_scale_select').on('change', onModelChange); |
| 5944 | 6145 | $('#model_google_select').on('change', onModelChange); |
| 5945 | 6146 | $('#model_vertexai_select').on('change', onModelChange); |
| 6147 | + $('#vertexai_auth_mode').on('change', onVertexAIAuthModeChange); | |
| 6148 | + $('#vertexai_project_id').on('input', function () { | |
| 6149 | + oai_settings.vertexai_project_id = String($(this).val()); | |
| 6150 | + saveSettingsDebounced(); | |
| 6151 | + }); | |
| 6152 | + $('#vertexai_region').on('input', function () { | |
| 6153 | + oai_settings.vertexai_region = String($(this).val()); | |
| 6154 | + saveSettingsDebounced(); | |
| 6155 | + }); | |
| 6156 | + $('#vertexai_service_account_json').on('input', onVertexAIServiceAccountJsonChange); | |
| 6157 | + $('#vertexai_validate_service_account').on('click', onVertexAIValidateServiceAccount); | |
| 6158 | + $('#vertexai_clear_service_account').on('click', onVertexAIClearServiceAccount); | |
| 5946 | 6159 | $('#model_openrouter_select').on('change', onModelChange); |
| 5947 | 6160 | $('#openrouter_group_models').on('change', onOpenrouterModelSortChange); |
| 5948 | 6161 | $('#openrouter_sort_models').on('change', onOpenrouterModelSortChange); |
| @@ -44,6 +44,7 @@ export const SECRET_KEYS = { | ||
| 44 | 44 | SERPER: 'api_key_serper', |
| 45 | 45 | FALAI: 'api_key_falai', |
| 46 | 46 | XAI: 'api_key_xai', |
| 47 | + VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json', | |
| 47 | 48 | }; |
| 48 | 49 | |
| 49 | 50 | const INPUT_MAP = { |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import process from 'node:process'; |
| 2 | 2 | import util from 'node:util'; |
| 3 | +import crypto from 'node:crypto'; | |
| 3 | 4 | import express from 'express'; |
| 4 | 5 | import fetch from 'node-fetch'; |
| 5 | 6 | |
| @@ -61,6 +62,124 @@ const API_XAI = 'https://api.x.ai/v1'; | ||
| 61 | 62 | const API_POLLINATIONS = 'https://text.pollinations.ai/openai'; |
| 62 | 63 | |
| 63 | 64 | /** |
| 65 | + * Generates a JWT token for Google Cloud authentication using service account credentials. | |
| 66 | + * @param {object} serviceAccount Service account JSON object | |
| 67 | + * @returns {Promise<string>} JWT token | |
| 68 | + */ | |
| 69 | +async function generateJWTToken(serviceAccount) { | |
| 70 | + const now = Math.floor(Date.now() / 1000); | |
| 71 | + const expiry = now + 3600; // 1 hour | |
| 72 | + | |
| 73 | + const header = { | |
| 74 | + alg: 'RS256', | |
| 75 | + typ: 'JWT' | |
| 76 | + }; | |
| 77 | + | |
| 78 | + const payload = { | |
| 79 | + iss: serviceAccount.client_email, | |
| 80 | + scope: 'https://www.googleapis.com/auth/cloud-platform', | |
| 81 | + aud: 'https://oauth2.googleapis.com/token', | |
| 82 | + iat: now, | |
| 83 | + exp: expiry | |
| 84 | + }; | |
| 85 | + | |
| 86 | + const headerBase64 = Buffer.from(JSON.stringify(header)).toString('base64url'); | |
| 87 | + const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); | |
| 88 | + const signatureInput = `${headerBase64}.${payloadBase64}`; | |
| 89 | + | |
| 90 | + // Create signature using private key | |
| 91 | + const sign = crypto.createSign('RSA-SHA256'); | |
| 92 | + sign.update(signatureInput); | |
| 93 | + const signature = sign.sign(serviceAccount.private_key, 'base64url'); | |
| 94 | + | |
| 95 | + return `${signatureInput}.${signature}`; | |
| 96 | +} | |
| 97 | + | |
| 98 | +/** | |
| 99 | + * Gets an access token from Google OAuth2 using JWT assertion. | |
| 100 | + * @param {string} jwtToken JWT token | |
| 101 | + * @returns {Promise<string>} Access token | |
| 102 | + */ | |
| 103 | +async function getAccessToken(jwtToken) { | |
| 104 | + const response = await fetch('https://oauth2.googleapis.com/token', { | |
| 105 | + method: 'POST', | |
| 106 | + headers: { | |
| 107 | + 'Content-Type': 'application/x-www-form-urlencoded' | |
| 108 | + }, | |
| 109 | + body: new URLSearchParams({ | |
| 110 | + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', | |
| 111 | + assertion: jwtToken | |
| 112 | + }) | |
| 113 | + }); | |
| 114 | + | |
| 115 | + if (!response.ok) { | |
| 116 | + throw new Error(`Failed to get access token: ${response.status} ${response.statusText}`); | |
| 117 | + } | |
| 118 | + | |
| 119 | + const data = await response.json(); | |
| 120 | + return data.access_token; | |
| 121 | +} | |
| 122 | + | |
| 123 | +/** | |
| 124 | + * Gets authentication for Vertex AI - either API key or service account token. | |
| 125 | + * @param {object} request Express request | |
| 126 | + * @returns {Promise<{authHeader: string, authType: string}>} Authentication header and type | |
| 127 | + */ | |
| 128 | +async function getVertexAIAuth(request) { | |
| 129 | + // Get the authentication mode from frontend | |
| 130 | + const authMode = request.body.vertexai_auth_mode || 'express'; | |
| 131 | + | |
| 132 | + // Check if using reverse proxy | |
| 133 | + if (request.body.reverse_proxy) { | |
| 134 | + return { | |
| 135 | + authHeader: `Bearer ${request.body.proxy_password}`, | |
| 136 | + authType: 'proxy' | |
| 137 | + }; | |
| 138 | + } | |
| 139 | + | |
| 140 | + if (authMode === 'express') { | |
| 141 | + // Express mode: use API key | |
| 142 | + const apiKey = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI); | |
| 143 | + if (apiKey) { | |
| 144 | + return { | |
| 145 | + authHeader: `Bearer ${apiKey}`, | |
| 146 | + authType: 'express' | |
| 147 | + }; | |
| 148 | + } | |
| 149 | + throw new Error('API key is required for Vertex AI Express mode'); | |
| 150 | + } else if (authMode === 'full') { | |
| 151 | + // Full mode: use service account JSON | |
| 152 | + // First try to read from backend secret storage | |
| 153 | + let serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT); | |
| 154 | + | |
| 155 | + // If not found in secrets, fall back to request body (for backward compatibility) | |
| 156 | + if (!serviceAccountJson) { | |
| 157 | + serviceAccountJson = request.body.vertexai_service_account_json; | |
| 158 | + } | |
| 159 | + | |
| 160 | + if (serviceAccountJson) { | |
| 161 | + try { | |
| 162 | + const serviceAccount = JSON.parse(serviceAccountJson); | |
| 163 | + | |
| 164 | + const jwtToken = await generateJWTToken(serviceAccount); | |
| 165 | + const accessToken = await getAccessToken(jwtToken); | |
| 166 | + | |
| 167 | + return { | |
| 168 | + authHeader: `Bearer ${accessToken}`, | |
| 169 | + authType: 'full' | |
| 170 | + }; | |
| 171 | + } catch (error) { | |
| 172 | + console.error('Failed to authenticate with service account:', error); | |
| 173 | + throw new Error(`Service account authentication failed: ${error.message}`); | |
| 174 | + } | |
| 175 | + } | |
| 176 | + throw new Error('Service Account JSON is required for Vertex AI Full mode'); | |
| 177 | + } | |
| 178 | + | |
| 179 | + throw new Error(`Unsupported Vertex AI authentication mode: ${authMode}`); | |
| 180 | +} | |
| 181 | + | |
| 182 | +/** | |
| 64 | 183 | * Applies a post-processing step to the generated messages. |
| 65 | 184 | * @param {object[]} messages Messages to post-process |
| 66 | 185 | * @param {string} type Prompt conversion type |
| @@ -348,13 +467,20 @@ async function sendMakerSuiteRequest(request, response) { | ||
| 348 | 467 | let apiUrl; |
| 349 | 468 | let apiKey; |
| 350 | 469 | |
| 470 | + let authHeader; | |
| 471 | + let authType; | |
| 472 | + | |
| 351 | 473 | if (useVertexAi) { |
| 352 | 474 | apiUrl = new URL(request.body.reverse_proxy || API_VERTEX_AI); |
| 353 | - apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.VERTEXAI); | |
| 354 | 475 | |
| 355 | - if (!request.body.reverse_proxy && !apiKey) { | |
| 476 | + try { | |
| 356 | - console.warn(`${apiName} API key is missing.`); | |
| 477 | + const auth = await getVertexAIAuth(request); | |
| 357 | - return response.status(400).send({ error: true }); | |
| 478 | + authHeader = auth.authHeader; | |
| 479 | + authType = auth.authType; | |
| 480 | + console.debug(`Using Vertex AI authentication type: ${authType}`); | |
| 481 | + } catch (error) { | |
| 482 | + console.warn(`${apiName} authentication failed: ${error.message}`); | |
| 483 | + return response.status(400).send({ error: true, message: error.message }); | |
| 358 | 484 | } |
| 359 | 485 | } else { |
| 360 | 486 | apiUrl = new URL(request.body.reverse_proxy || API_MAKERSUITE); |
| @@ -364,6 +490,9 @@ async function sendMakerSuiteRequest(request, response) { | ||
| 364 | 490 | console.warn(`${apiName} API key is missing.`); |
| 365 | 491 | return response.status(400).send({ error: true }); |
| 366 | 492 | } |
| 493 | + | |
| 494 | + authHeader = `Bearer ${apiKey}`; | |
| 495 | + authType = 'api_key'; | |
| 367 | 496 | } |
| 368 | 497 | |
| 369 | 498 | const model = String(request.body.model); |
| @@ -499,17 +628,39 @@ async function sendMakerSuiteRequest(request, response) { | ||
| 499 | 628 | const responseType = (stream ? 'streamGenerateContent' : 'generateContent'); |
| 500 | 629 | |
| 501 | 630 | let url; |
| 631 | + let headers = { | |
| 632 | + 'Content-Type': 'application/json', | |
| 633 | + }; | |
| 634 | + | |
| 502 | 635 | if (useVertexAi) { |
| 503 | - url = `${apiUrl.toString().replace(/\/$/, '')}/v1/publishers/google/models/${model}:${responseType}?key=${apiKey}${stream ? '&alt=sse' : ''}`; | |
| 636 | + if (authType === 'express') { | |
| 637 | + // For Express mode (API key authentication), use the key parameter | |
| 638 | + const keyParam = authHeader.replace('Bearer ', ''); | |
| 639 | + url = `${apiUrl.toString().replace(/\/$/, '')}/v1/publishers/google/models/${model}:${responseType}?key=${keyParam}${stream ? '&alt=sse' : ''}`; | |
| 640 | + } else if (authType === 'full') { | |
| 641 | + // For Full mode (service account authentication), use project-specific URL | |
| 642 | + const projectId = request.body.vertexai_project_id || 'your-project-id'; | |
| 643 | + const region = request.body.vertexai_region || 'us-central1'; | |
| 644 | + // Handle global region differently - no region prefix in hostname | |
| 645 | + if (region === 'global') { | |
| 646 | + url = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${responseType}${stream ? '?alt=sse' : ''}`; | |
| 647 | + } else { | |
| 648 | + url = `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${responseType}${stream ? '?alt=sse' : ''}`; | |
| 649 | + } | |
| 650 | + headers['Authorization'] = authHeader; | |
| 651 | + } else { | |
| 652 | + // For proxy mode, use the original URL with Authorization header | |
| 653 | + url = `${apiUrl.toString().replace(/\/$/, '')}/v1/publishers/google/models/${model}:${responseType}${stream ? '?alt=sse' : ''}`; | |
| 654 | + headers['Authorization'] = authHeader; | |
| 655 | + } | |
| 504 | 656 | } else { |
| 505 | 657 | url = `${apiUrl.toString().replace(/\/$/, '')}/${apiVersion}/models/${model}:${responseType}?key=${apiKey}${stream ? '&alt=sse' : ''}`; |
| 506 | 658 | } |
| 659 | + | |
| 507 | 660 | const generateResponse = await fetch(url, { |
| 508 | 661 | body: JSON.stringify(body), |
| 509 | 662 | method: 'POST', |
| 510 | 663 | headers: {headers, |
| 511 | - 'Content-Type': 'application/json', | |
| 512 | - }, | |
| 513 | 664 | signal: controller.signal, |
| 514 | 665 | }); |
| 515 | 666 | |
| @@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer'; | ||
| 2 | 2 | import fetch from 'node-fetch'; |
| 3 | 3 | import express from 'express'; |
| 4 | 4 | import { speak, languages } from 'google-translate-api-x'; |
| 5 | +import crypto from 'node:crypto'; | |
| 5 | 6 | |
| 6 | 7 | import { readSecret, SECRET_KEYS } from './secrets.js'; |
| 7 | 8 | import { GEMINI_SAFETY } from '../constants.js'; |
| @@ -9,6 +10,108 @@ import { GEMINI_SAFETY } from '../constants.js'; | ||
| 9 | 10 | const API_MAKERSUITE = 'https://generativelanguage.googleapis.com'; |
| 10 | 11 | const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com'; |
| 11 | 12 | |
| 13 | +// Vertex AI authentication helper functions | |
| 14 | +async function getVertexAIAuth(request) { | |
| 15 | + const authMode = request.body.vertexai_auth_mode || 'express'; | |
| 16 | + | |
| 17 | + if (request.body.reverse_proxy) { | |
| 18 | + return { | |
| 19 | + authHeader: `Bearer ${request.body.proxy_password}`, | |
| 20 | + authType: 'proxy' | |
| 21 | + }; | |
| 22 | + } | |
| 23 | + | |
| 24 | + if (authMode === 'express') { | |
| 25 | + const apiKey = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI); | |
| 26 | + if (apiKey) { | |
| 27 | + return { | |
| 28 | + authHeader: `Bearer ${apiKey}`, | |
| 29 | + authType: 'express' | |
| 30 | + }; | |
| 31 | + } | |
| 32 | + throw new Error('API key is required for Vertex AI Express mode'); | |
| 33 | + } else if (authMode === 'full') { | |
| 34 | + // Try to get service account JSON from backend storage first | |
| 35 | + let serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT); | |
| 36 | + | |
| 37 | + // If not found in backend storage, try from request body (for backward compatibility) | |
| 38 | + if (!serviceAccountJson) { | |
| 39 | + serviceAccountJson = request.body.vertexai_service_account_json; | |
| 40 | + } | |
| 41 | + | |
| 42 | + if (serviceAccountJson) { | |
| 43 | + try { | |
| 44 | + const serviceAccount = JSON.parse(serviceAccountJson); | |
| 45 | + const jwtToken = await generateJWTToken(serviceAccount); | |
| 46 | + const accessToken = await getAccessToken(jwtToken); | |
| 47 | + return { | |
| 48 | + authHeader: `Bearer ${accessToken}`, | |
| 49 | + authType: 'full' | |
| 50 | + }; | |
| 51 | + } catch (error) { | |
| 52 | + console.error('Failed to authenticate with service account:', error); | |
| 53 | + throw new Error(`Service account authentication failed: ${error.message}`); | |
| 54 | + } | |
| 55 | + } | |
| 56 | + throw new Error('Service Account JSON is required for Vertex AI Full mode'); | |
| 57 | + } | |
| 58 | + | |
| 59 | + throw new Error(`Unsupported Vertex AI authentication mode: ${authMode}`); | |
| 60 | +} | |
| 61 | + | |
| 62 | +/** | |
| 63 | + * Generates a JWT token for Google Cloud authentication using service account credentials. | |
| 64 | + * @param {object} serviceAccount Service account JSON object | |
| 65 | + * @returns {Promise<string>} JWT token | |
| 66 | + */ | |
| 67 | +async function generateJWTToken(serviceAccount) { | |
| 68 | + const now = Math.floor(Date.now() / 1000); | |
| 69 | + const expiry = now + 3600; // 1 hour | |
| 70 | + | |
| 71 | + const header = { | |
| 72 | + alg: 'RS256', | |
| 73 | + typ: 'JWT' | |
| 74 | + }; | |
| 75 | + | |
| 76 | + const payload = { | |
| 77 | + iss: serviceAccount.client_email, | |
| 78 | + scope: 'https://www.googleapis.com/auth/cloud-platform', | |
| 79 | + aud: 'https://oauth2.googleapis.com/token', | |
| 80 | + iat: now, | |
| 81 | + exp: expiry | |
| 82 | + }; | |
| 83 | + | |
| 84 | + const headerBase64 = Buffer.from(JSON.stringify(header)).toString('base64url'); | |
| 85 | + const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); | |
| 86 | + const signatureInput = `${headerBase64}.${payloadBase64}`; | |
| 87 | + | |
| 88 | + // Create signature using private key | |
| 89 | + const sign = crypto.createSign('RSA-SHA256'); | |
| 90 | + sign.update(signatureInput); | |
| 91 | + const signature = sign.sign(serviceAccount.private_key, 'base64url'); | |
| 92 | + | |
| 93 | + return `${signatureInput}.${signature}`; | |
| 94 | +} | |
| 95 | + | |
| 96 | +async function getAccessToken(jwtToken) { | |
| 97 | + const response = await fetch('https://oauth2.googleapis.com/token', { | |
| 98 | + method: 'POST', | |
| 99 | + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| 100 | + body: new URLSearchParams({ | |
| 101 | + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', | |
| 102 | + assertion: jwtToken | |
| 103 | + }) | |
| 104 | + }); | |
| 105 | + | |
| 106 | + if (!response.ok) { | |
| 107 | + const error = await response.text(); | |
| 108 | + throw new Error(`Failed to get access token: ${error}`); | |
| 109 | + } | |
| 110 | + | |
| 111 | + const data = await response.json(); | |
| 112 | + return data.access_token; | |
| 113 | +} | |
| 114 | + | |
| 12 | 115 | export const router = express.Router(); |
| 13 | 116 | |
| 14 | 117 | router.post('/caption-image', async (request, response) => { |
| @@ -17,20 +120,43 @@ router.post('/caption-image', async (request, response) => { | ||
| 17 | 120 | const base64Data = request.body.image.split(',')[1]; |
| 18 | 121 | const useVertexAi = request.body.api === 'vertexai'; |
| 19 | 122 | const apiName = useVertexAi ? 'Google Vertex AI' : 'Google AI Studio'; |
| 20 | - let apiKey; | |
| 21 | - let apiUrl; | |
| 22 | - if (useVertexAi) { | |
| 23 | - apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.VERTEXAI); | |
| 24 | - apiUrl = new URL(request.body.reverse_proxy || API_VERTEX_AI); | |
| 25 | - } else { | |
| 26 | - apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE); | |
| 27 | - apiUrl = new URL(request.body.reverse_proxy || API_MAKERSUITE); | |
| 28 | - } | |
| 29 | 123 | const model = request.body.model || 'gemini-2.0-flash'; |
| 124 | + | |
| 30 | 125 | let url; |
| 126 | + let headers = { | |
| 127 | + 'Content-Type': 'application/json', | |
| 128 | + }; | |
| 129 | + | |
| 31 | 130 | if (useVertexAi) { |
| 32 | - url = `${apiUrl.origin}/v1/publishers/google/models/${model}:generateContent?key=${apiKey}`; | |
| 131 | + // Get authentication for Vertex AI | |
| 132 | + const { authHeader, authType } = await getVertexAIAuth(request); | |
| 133 | + | |
| 134 | + if (authType === 'express') { | |
| 135 | + // Express mode: use API key parameter | |
| 136 | + const keyParam = authHeader.replace('Bearer ', ''); | |
| 137 | + const apiUrl = new URL(request.body.reverse_proxy || API_VERTEX_AI); | |
| 138 | + url = `${apiUrl.origin}/v1/publishers/google/models/${model}:generateContent?key=${keyParam}`; | |
| 139 | + } else if (authType === 'full') { | |
| 140 | + // Full mode: use project-specific URL with Authorization header | |
| 141 | + const projectId = request.body.vertexai_project_id || 'your-project-id'; | |
| 142 | + const region = request.body.vertexai_region || 'us-central1'; | |
| 143 | + // Handle global region differently - no region prefix in hostname | |
| 144 | + if (region === 'global') { | |
| 145 | + url = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:generateContent`; | |
| 33 | 146 | } else { |
| 147 | + url = `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:generateContent`; | |
| 148 | + } | |
| 149 | + headers['Authorization'] = authHeader; | |
| 150 | + } else { | |
| 151 | + // Proxy mode: use Authorization header | |
| 152 | + const apiUrl = new URL(request.body.reverse_proxy || API_VERTEX_AI); | |
| 153 | + url = `${apiUrl.origin}/v1/publishers/google/models/${model}:generateContent`; | |
| 154 | + headers['Authorization'] = authHeader; | |
| 155 | + } | |
| 156 | + } else { | |
| 157 | + // Google AI Studio | |
| 158 | + const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE); | |
| 159 | + const apiUrl = new URL(request.body.reverse_proxy || API_MAKERSUITE); | |
| 34 | 160 | url = `${apiUrl.origin}/v1beta/models/${model}:generateContent?key=${apiKey}`; |
| 35 | 161 | } |
| 36 | 162 | const body = { |
| @@ -53,9 +179,7 @@ router.post('/caption-image', async (request, response) => { | ||
| 53 | 179 | const result = await fetch(url, { |
| 54 | 180 | body: JSON.stringify(body), |
| 55 | 181 | method: 'POST', |
| 56 | 182 | headers: {headers, |
| 57 | - 'Content-Type': 'application/json', | |
| 58 | - }, | |
| 59 | 183 | }); |
| 60 | 184 | |
| 61 | 185 | if (!result.ok) { |
| @@ -54,6 +54,7 @@ export const SECRET_KEYS = { | ||
| 54 | 54 | DEEPSEEK: 'api_key_deepseek', |
| 55 | 55 | SERPER: 'api_key_serper', |
| 56 | 56 | XAI: 'api_key_xai', |
| 57 | + VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json', | |
| 57 | 58 | }; |
| 58 | 59 | |
| 59 | 60 | // These are the keys that are safe to expose, even if allowKeysExposure is false |