Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ The **GenAIApp** library is a Google Apps Script library designed for creating,
- [Example 6: Extend a Chat with an MCP Connector](#example-6--extend-a-chat-with-an-mcp-connector)
- [Example 7: Connect to a Custom MCP Server with setServerUrl()](#example-7--connect-to-a-custom-mcp-server-with-setserverurl)
- [Example 8: Continue a Conversation with previous_response_id](#example-8--continue-a-conversation-with-previous_response_id)
- [Testing authentication modes](#testing-authentication-modes)
- [Contributing](#contributing)
- [License](#license)
- [Reference](#reference)
Expand All @@ -59,7 +60,7 @@ The **GenAIApp** library is a Google Apps Script library designed for creating,
The setup for **GenAIApp** varies depending on which models you plan to use:
1. If you want to use **OpenAI models**: You'll need an **OpenAI API key**
2. If you want to use **Google Gemini models**: you’ll need a **Google Cloud Platform (GCP) project** with **Vertex AI** enabled for access to Gemini models.
Ensure to link your Google Apps Script project to a GCP project with Vertex AI enabled, and to include the following scopes in your manifest file:
Ensure to link your Google Apps Script project to a GCP project with Vertex AI enabled. GenAIApp prefers the Google Apps Script Vertex AI Advanced Service when it is enabled in the Apps Script project, and automatically falls back to the direct `UrlFetchApp` Vertex AI call if the Advanced Service is unavailable or fails. Include the following scopes in your manifest file:
```js
"oauthScopes": [
"https://www.googleapis.com/auth/cloud-platform",
Expand Down Expand Up @@ -461,6 +462,31 @@ const secondAnswer = secondChat.run({ model: "gpt-5.4" });
Logger.log(secondAnswer);
```

## Testing authentication modes

The library includes an Apps Script test entrypoint named `testConfiguredAuthenticationModes()` for explicitly validating the two Gemini authentication paths supported by GenAIApp:

1. **API key authentication** through `GenAIApp.setGeminiAPIKey(...)`.
2. **Vertex AI authentication** through `GenAIApp.setGeminiAuth(projectId, region)`.

Each mode can be enabled independently with boolean switches so you can run only API-key tests, only Vertex AI tests, both modes, or neither mode while a path is temporarily blocked.

| Switch | Default | Behavior |
| --- | --- | --- |
| `ENABLE_API_KEY_AUTH_TESTS` | `true` | Runs the Gemini API-key smoke test when `true`; logs a skip when `false`. |
| `ENABLE_VERTEX_AI_AUTH_TESTS` | `false` | Runs the Gemini Vertex AI smoke test when `true`; logs a skip when `false`. |

### Local Apps Script configuration

Set these values as Apps Script **Script Properties** or define equivalent constants in your local test project. Do not print credential values in logs.

| Name | Required when | Description |
| --- | --- | --- |
| `GEMINI_API_KEY` | `ENABLE_API_KEY_AUTH_TESTS=true` | Gemini API key used by the public Generative Language API test. |
| `VERTEX_AI_GCP_PROJECT_ID` | `ENABLE_VERTEX_AI_AUTH_TESTS=true` | GCP project linked to the Apps Script project and enabled for Vertex AI. |
| `VERTEX_AI_GCP_REGION` | Optional for Vertex AI | Vertex AI region such as `us-central1`; leave blank to use the global endpoint. |

Run `testConfiguredAuthenticationModes()` from the Apps Script editor after setting the switches and credentials. Disabled modes log a clear skip message. Enabled modes fail before making an API call when required credentials/configuration are missing. The test code clears the opposite Gemini authentication setting before each smoke test so API-key coverage cannot accidentally pass through Vertex AI, and Vertex AI coverage cannot accidentally pass through the API-key path.

## Contributing

Expand Down
234 changes: 232 additions & 2 deletions src/code.gs
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,9 @@ const GenAIApp = (function () {
if (containerId) {
this._codeInterpreterContainerId = containerId;
}

return this;
};

/** OPTIONAL
*
* Enable or disable server-side tool invocations for Gemini (Tool Combination).
Expand Down Expand Up @@ -540,7 +542,12 @@ const GenAIApp = (function () {
}
}
}
responseMessage = _callGenAIApi(endpointUrl, payload);
if (model.includes("gemini") && !geminiKey && !payload?.tool_config?.includeServerSideToolInvocations) { // VertexAI does not support server-side tool invocation metadata in the response at the moment
responseMessage = _callVertexAi(endpointUrl, payload);
}
else {
responseMessage = _callGenAIApi(endpointUrl, payload);
}
if (responseMessage?.usage) {
this._lastUsage = responseMessage.usage;
if (this._inputTokenWarningThreshold !== null
Expand Down Expand Up @@ -1549,6 +1556,218 @@ const GenAIApp = (function () {
}
}

/**
* Calls Vertex AI for Gemini requests, preferring the Google Apps Script
* Vertex AI Advanced Service and falling back to the existing UrlFetchApp path.
*
* @private
* @param {string} endpoint - The Vertex AI REST endpoint used by the fallback path.
* @param {Object} payload - The Gemini generateContent payload.
* @returns {object} - The normalized response message from the Gemini API.
*/
function _callVertexAi(endpoint, payload) {
try {
return _callVertexAiWithAdvancedService(endpoint, payload);
}
catch (err) {
_logVertexAiAdvancedServiceFallback(err);
return _callVertexAiWithUrlFetchFallback(endpoint, payload);
}
}

/**
* Calls Vertex AI through the Apps Script Advanced Service.
*
* @private
* @param {string} endpoint - The Vertex AI REST endpoint, used to derive the model resource.
* @param {Object} payload - The Gemini generateContent payload.
* @returns {object} - The normalized response message from the Gemini API.
* @throws {Error} If the Advanced Service is unavailable, unsupported, misconfigured, or returns an API error.
*/
function _callVertexAiWithAdvancedService(endpoint, payload) {
const service = _getVertexAiAdvancedService();
const generateContentInfo = _getVertexAiGenerateContentMethod(service);
const modelResource = _getVertexAiModelResource(endpoint, payload);
const advancedServicePayload = _buildVertexAiAdvancedServicePayload(payload);
const response = generateContentInfo.method(advancedServicePayload, modelResource);

return _normalizeVertexAiResponse(response, payload);
}

/**
* Calls Vertex AI through the existing UrlFetchApp implementation.
*
* @private
* @param {string} endpoint - The Vertex AI REST endpoint.
* @param {Object} payload - The Gemini generateContent payload.
* @returns {object} - The normalized response message from the Gemini API.
*/
function _callVertexAiWithUrlFetchFallback(endpoint, payload) {
return _callGenAIApi(endpoint, payload);
}

/**
* Finds the Apps Script Vertex AI Advanced Service object if it is enabled.
*
* @private
* @returns {Object} - The Vertex AI Advanced Service object.
* @throws {Error} If the service is not available.
*/
function _getVertexAiAdvancedService() {
if (typeof VertexAI !== 'undefined') {
return VertexAI;
}
if (typeof vertexai !== 'undefined') {
return vertexai;
}

for (const globalSymbol in globalThis) {
try {
const candidateService = globalThis[globalSymbol];
if (_getVertexAiGenerateContentMethod(candidateService)) {
return candidateService;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
catch (err) {
// Ignore globals that cannot be inspected and keep looking for the Advanced Service.
}
}

throw new Error('Vertex AI Advanced Service is not enabled or not available in this Apps Script project.');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Resolves the generated Apps Script method for projects.locations.publishers.models.generateContent.
* Apps Script Advanced Service names can differ by release/casing, so support the known variants.
*
* @private
* @param {Object} service - The Vertex AI Advanced Service object.
* @returns {{method: Function, path: string}} - The generateContent method and its collection path.
* @throws {Error} If the method is not exposed by the enabled Advanced Service.
*/
function _getVertexAiGenerateContentMethod(service) {
const collectionPaths = [
['projects', 'locations', 'publishers', 'models'],
['Projects', 'Locations', 'Publishers', 'Models'],
['endpoints'],
['Endpoints']
];

for (let i = 0; i < collectionPaths.length; i++) {
let collection = service;

for (let j = 0; j < collectionPaths[i].length && collection; j++) {
collection = collection[collectionPaths[i][j]];
}

if (collection && typeof collection.generateContent === 'function') {
return {
method: collection.generateContent.bind(collection),
path: collectionPaths[i].join('.')
};
}
}

throw new Error('Vertex AI Advanced Service does not expose a compatible generateContent method.');
}

/**
* Derives the model resource name expected by the Vertex AI Advanced Service.
*
* @private
* @param {string} endpoint - The Vertex AI REST endpoint.
* @param {Object} payload - The Gemini generateContent payload.
* @returns {string} - The fully qualified Vertex AI model resource name.
* @throws {Error} If the project, location, or model cannot be resolved.
*/
function _getVertexAiModelResource(endpoint, payload) {
const endpointMatch = endpoint.match(/\/v1\/(projects\/[^:]+):generateContent/);
if (endpointMatch && endpointMatch[1]) {
return endpointMatch[1];
}

if (!gcpProjectId) {
throw new Error('Missing GCP project ID for Vertex AI Advanced Service call.');
}
if (!payload?.model) {
throw new Error('Missing Gemini model for Vertex AI Advanced Service call.');
}

const location = (!region || payload.model.includes('gemini-3')) ? 'global' : region;
if (!location) {
throw new Error('Missing Vertex AI location for Advanced Service call.');
}

return `projects/${gcpProjectId}/locations/${location}/publishers/google/models/${payload.model}`;
}

/**
* Builds a request body compatible with Vertex AI generateContent.
*
* @private
* @param {Object} payload - The existing Gemini request payload.
* @returns {Object} - A request body for Vertex AI Advanced Service.
*/
function _buildVertexAiAdvancedServicePayload(payload) {
const advancedServicePayload = JSON.parse(JSON.stringify(payload || {}));
delete advancedServicePayload.model;
if (advancedServicePayload.tool_config) {
delete advancedServicePayload.tool_config.includeServerSideToolInvocations;
}
return advancedServicePayload;
Comment thread
aubrypaul marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Normalizes a Vertex AI Advanced Service GenerateContentResponse to match
* the existing UrlFetchApp response format returned by _callGenAIApi().
*
* @private
* @param {Object} response - The Advanced Service response.
* @param {Object} payload - The original request payload.
* @returns {object} - The first candidate content object.
* @throws {Error} If the response is invalid or contains an API error.
*/
function _normalizeVertexAiResponse(response, payload) {
if (!response) {
throw new Error('Vertex AI Advanced Service returned an empty response.');
}
if (response.error) {
throw new Error(`Vertex AI Advanced Service returned an API error: ${JSON.stringify(response.error)}`);
}

const firstCandidate = response.candidates?.[0];
const responseMessage = firstCandidate?.content || null;
const finish_reason = firstCandidate?.finishReason || null;

if (!responseMessage) {
throw new Error('Vertex AI Advanced Service returned no candidate content.');
}
if (finish_reason == "length" || finish_reason == "incomplete" || finish_reason == "MAX_TOKENS") {
console.warn(`[GenAIApp] - ${payload.model} response could not be completed because of an insufficient amount of tokens. To resolve this issue, you can increase the amount of tokens like this : chat.run({max_tokens: XXXX}).`);
}

if (verbose) {
Logger.log({
message: `[GenAIApp] - Got response from ${payload.model}`,
responseMessage: responseMessage
});
}
return responseMessage;
}

/**
* Logs the internal reason for falling back from the Vertex AI Advanced Service.
*
* @private
* @param {Error} err - The Advanced Service failure.
*/
function _logVertexAiAdvancedServiceFallback(err) {
if (verbose) {
const message = err?.message || err;
console.warn(`[GenAIApp] - Vertex AI Advanced Service call failed; falling back to UrlFetchApp. Reason: ${message}`);
}
}

/**
* Makes an API call to the specified GenAI endpoint (either OpenAI or Google) with a payload
* and handles authentication, retries on rate limits and server errors, and response parsing.
Expand Down Expand Up @@ -2624,6 +2843,17 @@ const GenAIApp = (function () {
*/
setPrivateInstanceBaseUrl: function (baseUrl) {
privateInstanceBaseUrl = baseUrl;
},

/**
* Resets Gemini authentication state to default values.
* Clears API key, GCP project ID, and region settings.
* Useful for test isolation to prevent auth state from leaking between tests.
*/
resetGeminiAuthState: function () {
geminiKey = "";
gcpProjectId = "";
region = "";
}
}
})();
2 changes: 1 addition & 1 deletion src/testFunctions.gs
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,4 @@ function testCodeInterpreterPDF(driveFileId) {
// Weather function implementation
function getWeather(cityName) {
return `The weather in ${cityName} is 19°C today.`;
}
}