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.

5656c7950dcda9e5fa9c01d7fd544799f7880bc4

InterestingDarkness <computeridiot999@gmail.com>

7 files changed, +658 -29Showing whitespace changes
public/index.html+118 -5
@@ -2779,7 +2779,7 @@
27792779 <option value="deepseek">DeepSeek</option>
27802780 <option value="groq">Groq</option>
27812781 <option value="makersuite">Google AI Studio</option>
27822782 <option value="vertexai">Google Vertex AI (Express mode)</option>
27832783 <option value="mistralai">MistralAI</option>
27842784 <option value="nanogpt">NanoGPT</option>
27852785 <option value="openrouter">OpenRouter</option>
@@ -3225,12 +3225,25 @@
32253225 </div>
32263226 </form>
32273227 <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">
32283241 <h4>
32293242 <span data-i18n="Google Vertex AI API Key">
32303243 Google Vertex AI API Key
32313244 </span>
32323245 <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">
32333246 (Express mode keys only)
32343247 </a>
32353248 </h4>
32363249 <div class="flex-container">
@@ -3240,18 +3253,118 @@
32403253 <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.">
32413254 For privacy reasons, your API key will be hidden after you reload the page.
32423255 </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 -->
32433309 <div>
32443310 <h4 data-i18n="Google Model">Google Model</h4>
32453311 <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">
32473314 <option value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option>
32483315 <option value="gemini-2.5-pro-preview-03-25">gemini-2.5-pro-preview-03-25</option>
32493316 <option value="gemini-2.5-flash-preview-05-20">gemini-2.5-flash-preview-05-20</option>
32503317 <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>
32513320 </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>
32533334 <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>
32543341 <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>
32553368 </optgroup>
32563369 </select>
32573370 </div>
public/scripts/extensions/shared.js+28 -2
@@ -56,6 +56,13 @@ export async function getMultimodalCaption(base64Img, prompt) {
5656 model: extension_settings.caption.multimodal_model || 'gpt-4-turbo',
5757 };
5858
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+
5966 if (isOllama) {
6067 if (extension_settings.caption.multimodal_model === 'ollama_current') {
6168 requestBody.model = textgenerationwebui_settings.ollama_model;
@@ -164,8 +171,27 @@ function throwIfInvalidModel(useReverseProxy) {
164171 throw new Error('Google AI Studio API key is not set.');
165172 }
166173
167174 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+ }
169195 }
170196
171197 if (multimodalApi === 'mistral' && !secret_state[SECRET_KEYS.MISTRALAI] && !useReverseProxy) {
public/scripts/openai.js+214 -1
@@ -306,6 +306,10 @@ export const settingsToUpdate = {
306306 assistant_impersonation: ['#claude_assistant_impersonation', 'assistant_impersonation', false, false],
307307 claude_use_sysprompt: ['#claude_use_sysprompt', 'claude_use_sysprompt', true, false],
308308 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],
309313 use_alt_scale: ['#use_alt_scale', 'use_alt_scale', true, true],
310314 squash_system_messages: ['#squash_system_messages', 'squash_system_messages', true, false],
311315 image_inlining: ['#openai_image_inlining', 'image_inlining', true, false],
@@ -387,6 +391,10 @@ const default_settings = {
387391 assistant_impersonation: '',
388392 claude_use_sysprompt: false,
389393 use_makersuite_sysprompt: true,
394+ vertexai_auth_mode: 'express',
395+ vertexai_project_id: '',
396+ vertexai_region: 'us-central1',
397+ vertexai_service_account_json: '',
390398 use_alt_scale: false,
391399 squash_system_messages: false,
392400 image_inlining: false,
@@ -471,6 +479,10 @@ const oai_settings = {
471479 assistant_impersonation: '',
472480 claude_use_sysprompt: false,
473481 use_makersuite_sysprompt: true,
482+ vertexai_auth_mode: 'express',
483+ vertexai_project_id: '',
484+ vertexai_region: 'us-central1',
485+ vertexai_service_account_json: '',
474486 use_alt_scale: false,
475487 squash_system_messages: false,
476488 image_inlining: false,
@@ -2188,6 +2200,11 @@ async function sendOpenAIRequest(type, messages, signal) {
21882200 generate_data['top_k'] = Number(oai_settings.top_k_openai);
21892201 generate_data['stop'] = getCustomStoppingStrings(stopStringsLimit).slice(0, stopStringsLimit).filter(x => x.length >= 1 && x.length <= 16);
21902202 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+ }
21912208 }
21922209
21932210 if (isMistral) {
@@ -3427,6 +3444,9 @@ function loadOpenAISettings(data, settings) {
34273444 if (settings.openai_model !== undefined) oai_settings.openai_model = settings.openai_model;
34283445 if (settings.claude_use_sysprompt !== undefined) oai_settings.claude_use_sysprompt = !!settings.claude_use_sysprompt;
34293446 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;
34303450 if (settings.use_alt_scale !== undefined) { oai_settings.use_alt_scale = !!settings.use_alt_scale; updateScaleForm(); }
34313451 $('#stream_toggle').prop('checked', oai_settings.stream_openai);
34323452 $('#api_url_scale').val(oai_settings.api_url_scale);
@@ -3482,6 +3502,12 @@ function loadOpenAISettings(data, settings) {
34823502 $('#openai_external_category').toggle(oai_settings.show_external_models);
34833503 $('#claude_use_sysprompt').prop('checked', oai_settings.claude_use_sysprompt);
34843504 $('#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();
34853511 $('#scale-alt').prop('checked', oai_settings.use_alt_scale);
34863512 $('#openrouter_use_fallback').prop('checked', oai_settings.openrouter_use_fallback);
34873513 $('#openrouter_group_models').prop('checked', oai_settings.openrouter_group_models);
@@ -3804,6 +3830,10 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) {
38043830 assistant_impersonation: settings.assistant_impersonation,
38053831 claude_use_sysprompt: settings.claude_use_sysprompt,
38063832 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,
38073837 use_alt_scale: settings.use_alt_scale,
38083838 squash_system_messages: settings.squash_system_messages,
38093839 image_inlining: settings.image_inlining,
@@ -4979,6 +5009,8 @@ async function onConnectButtonClick(e) {
49795009 }
49805010
49815011 if (oai_settings.chat_completion_source == chat_completion_sources.VERTEXAI) {
5012+ if (oai_settings.vertexai_auth_mode === 'express') {
5013+ // Express mode - use API key
49825014 const api_key_vertexai = String($('#api_key_vertexai').val()).trim();
49835015
49845016 if (api_key_vertexai.length) {
@@ -4986,9 +5018,22 @@ async function onConnectButtonClick(e) {
49865018 }
49875019
49885020 if (!secret_state[SECRET_KEYS.VERTEXAI] && !oai_settings.reverse_proxy) {
49895021 console.log('No secret key saved for Vertex AI Express mode');
49905022 return;
49915023 }
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+ }
49925037 }
49935038
49945039 if (oai_settings.chat_completion_source == chat_completion_sources.CLAUDE) {
@@ -5170,6 +5215,8 @@ function toggleChatCompletionForms() {
51705215 }
51715216 else if (oai_settings.chat_completion_source == chat_completion_sources.VERTEXAI) {
51725217 $('#model_vertexai_select').trigger('change');
5218+ // Update UI based on authentication mode
5219+ onVertexAIAuthModeChange.call($('#vertexai_auth_mode')[0]);
51735220 }
51745221 else if (oai_settings.chat_completion_source == chat_completion_sources.OPENROUTER) {
51755222 $('#model_openrouter_select').trigger('change');
@@ -5480,6 +5527,160 @@ function runProxyCallback(_, value) {
54805527 return foundName;
54815528}
54825529
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+
54835684export function initOpenAI() {
54845685 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
54855686 name: 'proxy',
@@ -5943,6 +6144,18 @@ export function initOpenAI() {
59436144 $('#model_scale_select').on('change', onModelChange);
59446145 $('#model_google_select').on('change', onModelChange);
59456146 $('#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);
59466159 $('#model_openrouter_select').on('change', onModelChange);
59476160 $('#openrouter_group_models').on('change', onOpenrouterModelSortChange);
59486161 $('#openrouter_sort_models').on('change', onOpenrouterModelSortChange);
public/scripts/secrets.js+1 -0
@@ -44,6 +44,7 @@ export const SECRET_KEYS = {
4444 SERPER: 'api_key_serper',
4545 FALAI: 'api_key_falai',
4646 XAI: 'api_key_xai',
47+ VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
4748};
4849
4950const INPUT_MAP = {
src/endpoints/backends/chat-completions.js+159 -8
@@ -1,5 +1,6 @@
11import process from 'node:process';
22import util from 'node:util';
3+import crypto from 'node:crypto';
34import express from 'express';
45import fetch from 'node-fetch';
56
@@ -61,6 +62,124 @@ const API_XAI = 'https://api.x.ai/v1';
6162const API_POLLINATIONS = 'https://text.pollinations.ai/openai';
6263
6364/**
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+/**
64183 * Applies a post-processing step to the generated messages.
65184 * @param {object[]} messages Messages to post-process
66185 * @param {string} type Prompt conversion type
@@ -348,13 +467,20 @@ async function sendMakerSuiteRequest(request, response) {
348467 let apiUrl;
349468 let apiKey;
350469
470+ let authHeader;
471+ let authType;
472+
351473 if (useVertexAi) {
352474 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);
354475
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 });
358484 }
359485 } else {
360486 apiUrl = new URL(request.body.reverse_proxy || API_MAKERSUITE);
@@ -364,6 +490,9 @@ async function sendMakerSuiteRequest(request, response) {
364490 console.warn(`${apiName} API key is missing.`);
365491 return response.status(400).send({ error: true });
366492 }
493+
494+ authHeader = `Bearer ${apiKey}`;
495+ authType = 'api_key';
367496 }
368497
369498 const model = String(request.body.model);
@@ -499,17 +628,39 @@ async function sendMakerSuiteRequest(request, response) {
499628 const responseType = (stream ? 'streamGenerateContent' : 'generateContent');
500629
501630 let url;
631+ let headers = {
632+ 'Content-Type': 'application/json',
633+ };
634+
502635 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+ }
504656 } else {
505657 url = `${apiUrl.toString().replace(/\/$/, '')}/${apiVersion}/models/${model}:${responseType}?key=${apiKey}${stream ? '&alt=sse' : ''}`;
506658 }
659+
507660 const generateResponse = await fetch(url, {
508661 body: JSON.stringify(body),
509662 method: 'POST',
510663 headers: {headers,
511- 'Content-Type': 'application/json',
512- },
513664 signal: controller.signal,
514665 });
515666
src/endpoints/google.js+137 -13
@@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer';
22import fetch from 'node-fetch';
33import express from 'express';
44import { speak, languages } from 'google-translate-api-x';
5+import crypto from 'node:crypto';
56
67import { readSecret, SECRET_KEYS } from './secrets.js';
78import { GEMINI_SAFETY } from '../constants.js';
@@ -9,6 +10,108 @@ import { GEMINI_SAFETY } from '../constants.js';
910const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';
1011const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com';
1112
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+
12115export const router = express.Router();
13116
14117router.post('/caption-image', async (request, response) => {
@@ -17,20 +120,43 @@ router.post('/caption-image', async (request, response) => {
17120 const base64Data = request.body.image.split(',')[1];
18121 const useVertexAi = request.body.api === 'vertexai';
19122 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- }
29123 const model = request.body.model || 'gemini-2.0-flash';
124+
30125 let url;
126+ let headers = {
127+ 'Content-Type': 'application/json',
128+ };
129+
31130 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`;
33146 } 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);
34160 url = `${apiUrl.origin}/v1beta/models/${model}:generateContent?key=${apiKey}`;
35161 }
36162 const body = {
@@ -53,9 +179,7 @@ router.post('/caption-image', async (request, response) => {
53179 const result = await fetch(url, {
54180 body: JSON.stringify(body),
55181 method: 'POST',
56182 headers: {headers,
57- 'Content-Type': 'application/json',
58- },
59183 });
60184
61185 if (!result.ok) {
src/endpoints/secrets.js+1 -0
@@ -54,6 +54,7 @@ export const SECRET_KEYS = {
5454 DEEPSEEK: 'api_key_deepseek',
5555 SERPER: 'api_key_serper',
5656 XAI: 'api_key_xai',
57+ VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
5758};
5859
5960// These are the keys that are safe to expose, even if allowKeysExposure is false