@contentgrowth/llm-service 1.3.7 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,7 +124,7 @@ VERTEX_LOCATION=us-central1
124
124
  VERTEX_MODEL=gemini-3.5-flash
125
125
  VERTEX_MODEL_EDGE=gemini-3.5-pro
126
126
  VERTEX_MODEL_FAST=gemini-3.5-flash
127
- VERTEX_IMAGE_MODEL=gemini-3.5-pro-image-preview
127
+ VERTEX_IMAGE_MODEL=gemini-nano-banana-2.1
128
128
  VERTEX_VIDEO_MODEL=veo
129
129
  ```
130
130
 
@@ -409,6 +409,71 @@ await chatCompletion(messages, tenant, prompt, tools, { responseFormat: 'json' }
409
409
 
410
410
  ## Testing
411
411
 
412
+ ### Google Cloud Authentication Utilities
413
+
414
+ The package exports utilities for working with Google Cloud service accounts in edge runtime environments (Cloudflare Workers, Vercel Edge, etc.) where traditional ADC (Application Default Credentials) is not available.
415
+
416
+ ```javascript
417
+ import {
418
+ parseGoogleCredentials,
419
+ GoogleTokenManager,
420
+ getGoogleOAuth2Token
421
+ } from '@contentgrowth/llm-service/utils/google';
422
+
423
+ // Parse service account from various formats
424
+ const sa = parseGoogleCredentials(jsonString); // or base64 or object
425
+
426
+ // Create token manager for automatic refresh
427
+ const tokenManager = new GoogleTokenManager(sa);
428
+
429
+ // Get token (cached or fresh)
430
+ const token = await tokenManager.getToken();
431
+
432
+ // Check if token is still valid
433
+ if (tokenManager.isValid()) {
434
+ console.log(`Token valid for ${tokenManager.getTimeToExpiry()} seconds`);
435
+ }
436
+
437
+ // Force refresh
438
+ tokenManager.invalidate();
439
+
440
+ // Or get token directly (no caching)
441
+ const freshToken = await getGoogleOAuth2Token(sa);
442
+ ```
443
+
444
+ #### Using with llm-service
445
+
446
+ ```javascript
447
+ import { LLMService } from '@contentgrowth/llm-service';
448
+ import { parseGoogleCredentials, GoogleTokenManager } from '@contentgrowth/llm-service/utils/google';
449
+
450
+ // Backend manages token lifecycle
451
+ const sa = parseGoogleCredentials(serviceAccountJson);
452
+ const tokenManager = new GoogleTokenManager(sa);
453
+ const token = await tokenManager.getToken();
454
+
455
+ // Pass pre-generated token to llm-service
456
+ const llmService = new LLMService(env, {
457
+ provider: 'vertex',
458
+ accessToken: token, // Pre-generated token
459
+ project: sa.project_id,
460
+ location: 'us-central1',
461
+ models: {
462
+ default: 'gemini-3.8-flash',
463
+ image: 'gemini-nano-banana-2.1'
464
+ }
465
+ });
466
+
467
+ // Use normally - llm-service will use the token
468
+ const response = await llmService.chat('Hello!', 'tenant-id');
469
+ ```
470
+
471
+ **Benefits of this approach:**
472
+ - Backend controls token lifecycle and caching
473
+ - Service account credentials stay in backend
474
+ - llm-service receives only the token
475
+ - Works in edge runtime environments
476
+
412
477
  ### Running JSON Mode Tests
413
478
 
414
479
  1. **Create `.env` file** (copy from `.env.example`):
package/dist/index.cjs CHANGED
@@ -250,7 +250,7 @@ var MODEL_CONFIGS = {
250
250
  live: "gemini-3.8-live",
251
251
  liveThinking: "gemini-3.8-live-extended-thinking",
252
252
  video: "veo",
253
- image: "gemini-3-pro-image"
253
+ image: "gemini-nano-banana-2.1"
254
254
  },
255
255
  // Vertex AI / Gemini Enterprise Agent Platform
256
256
  vertex: {
@@ -262,7 +262,7 @@ var MODEL_CONFIGS = {
262
262
  live: "gemini-3.8-live",
263
263
  liveThinking: "gemini-3.8-live-extended-thinking",
264
264
  video: "veo",
265
- image: "gemini-3.1-flash-image"
265
+ image: "gemini-nano-banana-2.1"
266
266
  },
267
267
  // Anthropic IDs are "claude-<family>-<major>-<minor>", e.g. claude-sonnet-5-5 (not claude-5.5-sonnet).
268
268
  anthropic: {
@@ -277,11 +277,16 @@ var LEGACY_MODEL_ALIASES = {
277
277
  // OpenAI: ChatGPT product names, not API model IDs
278
278
  "gpt-5.5-instant": "gpt-5.6-luna",
279
279
  "gpt-5.5-thinking": "gpt-5.6-sol",
280
- // Gemini: never existed
280
+ // Gemini: never existed or were preview names
281
281
  "gemini-3.5-pro": "gemini-3.1-pro-preview",
282
282
  "gemini-3.7-pro": "gemini-3.1-pro-preview",
283
283
  "gemini-3.5-pro-image-preview": "gemini-3-pro-image",
284
284
  "gemini-3.7-pro-image-preview": "gemini-3-pro-image",
285
+ // Old Gemini image models (pre-Nano Banana branding)
286
+ "gemini-3-pro-image": "gemini-nano-banana-2.1",
287
+ "gemini-3.1-flash-image": "gemini-nano-banana-2.1",
288
+ "gemini-3.1-flash-lite-image": "gemini-nano-banana-2.1",
289
+ "gemini-2.5-flash-image": "gemini-nano-banana-2.1",
285
290
  // Anthropic: wrong segment order
286
291
  "claude-4.5-haiku": "claude-haiku-4-5",
287
292
  "claude-4.6-sonnet": "claude-sonnet-4-6",
@@ -742,17 +747,24 @@ var GoogleProvider = class extends BaseLLMProvider {
742
747
  this.models = normalizeModelMap(config.models);
743
748
  this.defaultModel = this.models.default;
744
749
  this._pendingOperations = /* @__PURE__ */ new Map();
750
+ if (config.accessToken) {
751
+ this.accessToken = config.accessToken;
752
+ this.project = config.project;
753
+ this.location = config.location || "global";
754
+ this.useDirectAPI = true;
755
+ console.log(`[GoogleProvider] Using pre-generated access token for project: ${this.project}`);
756
+ return;
757
+ }
745
758
  const httpOptions = config.baseURL ? { baseUrl: config.baseURL } : void 0;
746
759
  if (config.provider === "vertex") {
747
- const vertexLocation = config.location || "global";
748
760
  if (config.apiKey) {
749
761
  this.client = new import_genai.GoogleGenAI({
750
762
  vertexai: true,
751
763
  apiKey: config.apiKey,
752
- location: vertexLocation,
753
764
  ...httpOptions ? { httpOptions } : {}
754
765
  });
755
766
  } else {
767
+ const vertexLocation = config.location || "global";
756
768
  if (!config.project) {
757
769
  console.warn("[GoogleProvider] Vertex AI: no project ID and no API key. Calls will likely fail.");
758
770
  }
@@ -776,8 +788,65 @@ var GoogleProvider = class extends BaseLLMProvider {
776
788
  * the client was constructed.
777
789
  */
778
790
  async _generateContent(requestOptions) {
791
+ if (this.useDirectAPI && this.accessToken) {
792
+ return this._directVertexAPICall(requestOptions);
793
+ }
779
794
  return this.client.models.generateContent(requestOptions);
780
795
  }
796
+ /**
797
+ * Direct Vertex AI API call using pre-generated access token
798
+ * @private
799
+ */
800
+ async _directVertexAPICall(requestOptions) {
801
+ var _a, _b, _c;
802
+ const modelPath = `projects/${this.project}/locations/${this.location}/publishers/google/models/${requestOptions.model}`;
803
+ const endpoint = `https://${this.location}-aiplatform.googleapis.com/v1/${modelPath}:generateContent`;
804
+ const body = {
805
+ contents: requestOptions.contents,
806
+ generationConfig: requestOptions.config
807
+ };
808
+ if ((_a = requestOptions.config) == null ? void 0 : _a.systemInstruction) {
809
+ body.systemInstruction = requestOptions.config.systemInstruction;
810
+ }
811
+ if ((_b = requestOptions.config) == null ? void 0 : _b.tools) {
812
+ body.tools = requestOptions.config.tools;
813
+ }
814
+ const response = await fetch(endpoint, {
815
+ method: "POST",
816
+ headers: {
817
+ "Authorization": `Bearer ${this.accessToken}`,
818
+ "Content-Type": "application/json"
819
+ },
820
+ body: JSON.stringify(body)
821
+ });
822
+ if (!response.ok) {
823
+ const errorText = await response.text();
824
+ let errorData;
825
+ try {
826
+ errorData = JSON.parse(errorText);
827
+ } catch (e) {
828
+ errorData = { message: errorText };
829
+ }
830
+ const errorMessage = ((_c = errorData.error) == null ? void 0 : _c.message) || errorData.message || `Vertex API error: ${response.status}`;
831
+ throw new LLMServiceException(errorMessage, response.status);
832
+ }
833
+ return response.json();
834
+ }
835
+ /**
836
+ * Determine authentication mode from config for logging/debugging
837
+ * @private
838
+ * @returns {string} Auth mode description
839
+ */
840
+ _getAuthMode() {
841
+ if (this.config.provider === "vertex") {
842
+ if (this.config.apiKey) {
843
+ return "Vertex Express (API Key)";
844
+ } else {
845
+ return "Vertex ADC (Service Account)";
846
+ }
847
+ }
848
+ return "Gemini AI Studio";
849
+ }
781
850
  async chat(userMessage, systemPrompt = "", options = {}) {
782
851
  const messages = [{ role: "user", content: userMessage }];
783
852
  const tier = options.tier || "default";
@@ -1124,15 +1193,22 @@ ${msg.content}`;
1124
1193
  }
1125
1194
  async imageGeneration(prompt, systemPrompt, options = {}) {
1126
1195
  var _a, _b, _c, _d, _e, _f, _g, _h, _i;
1127
- const modelName = normalizeModelId(options.model) || this.models.image || "gemini-3-pro-image";
1128
- console.log(`[${this.constructor.name}] Generating image with model: ${modelName}`);
1196
+ const modelName = normalizeModelId(options.model) || this.models.image || "gemini-nano-banana-2.1";
1197
+ if (modelName.startsWith("imagen-") || modelName.startsWith("imagegeneration@")) {
1198
+ throw new LLMServiceException(
1199
+ `Imagen models were shut down August 17, 2026. Requested: "${modelName}". Use Gemini image models instead (e.g., "gemini-nano-banana-2.1").`,
1200
+ 400
1201
+ );
1202
+ }
1203
+ console.log(`[${this.constructor.name}] Generating image with model: ${modelName} (${this._getAuthMode()})`);
1129
1204
  const hasReferenceImages = options.images && options.images.length > 0;
1130
1205
  const generationConfig = {
1131
1206
  responseModalities: hasReferenceImages ? ["TEXT", "IMAGE"] : ["IMAGE"]
1132
1207
  };
1133
- if (options.aspectRatio) {
1208
+ if (options.aspectRatio || options.thinkingLevel) {
1134
1209
  generationConfig.imageConfig = {
1135
- aspectRatio: options.aspectRatio
1210
+ aspectRatio: options.aspectRatio || "1:1",
1211
+ ...options.thinkingLevel ? { thinkingLevel: options.thinkingLevel } : {}
1136
1212
  };
1137
1213
  }
1138
1214
  const parts = [{ text: prompt }];
@@ -2025,53 +2101,173 @@ function sanitizeError(error, context = "general") {
2025
2101
  var import_openai2 = __toESM(require("openai"), 1);
2026
2102
 
2027
2103
  // src/utils/google-auth.js
2028
- var jose = __toESM(require("jose"), 1);
2029
- var GoogleAuth = class {
2104
+ var import_node_buffer = require("buffer");
2105
+ function validateServiceAccount(sa) {
2106
+ if (!sa || typeof sa !== "object") {
2107
+ throw new Error("Service account must be an object");
2108
+ }
2109
+ if (!sa.client_email) {
2110
+ throw new Error("Service account missing client_email");
2111
+ }
2112
+ if (!sa.private_key) {
2113
+ throw new Error("Service account missing private_key");
2114
+ }
2115
+ if (!sa.project_id) {
2116
+ console.warn("[GoogleAuth] Service account missing project_id - may need to specify project explicitly");
2117
+ }
2118
+ return true;
2119
+ }
2120
+ var GoogleTokenManager = class {
2030
2121
  /**
2031
- * @param {Object} credentials - Parsed Service Account JSON
2122
+ * @param {object} serviceAccount - Parsed service account with client_email and private_key
2123
+ * @param {object} options - Optional configuration
2124
+ * @param {string} options.scope - OAuth2 scope (default: cloud-platform)
2125
+ * @param {number} options.tokenLifetime - Token lifetime in seconds (default: 3600)
2032
2126
  */
2033
- constructor(credentials) {
2034
- if (!credentials || !credentials.private_key || !credentials.client_email) {
2035
- throw new Error("Invalid Google credentials. Missing private_key or client_email.");
2127
+ constructor(serviceAccount, options = {}) {
2128
+ validateServiceAccount(serviceAccount);
2129
+ this.clientEmail = serviceAccount.client_email;
2130
+ this.privateKey = serviceAccount.private_key;
2131
+ this.projectId = serviceAccount.project_id;
2132
+ this.scope = options.scope || "https://www.googleapis.com/auth/cloud-platform";
2133
+ this.tokenLifetime = options.tokenLifetime || 3600;
2134
+ this._tokenCache = null;
2135
+ this._expiresAt = 0;
2136
+ }
2137
+ /**
2138
+ * Get a valid access token (cached or fresh)
2139
+ *
2140
+ * @returns {Promise<string>} OAuth2 access token
2141
+ */
2142
+ async getToken() {
2143
+ const now = Math.floor(Date.now() / 1e3);
2144
+ if (this._tokenCache && now < this._expiresAt - 300) {
2145
+ return this._tokenCache;
2036
2146
  }
2037
- this.credentials = credentials;
2038
- this.tokenUrl = "https://oauth2.googleapis.com/token";
2039
- this.scopes = ["https://www.googleapis.com/auth/cloud-platform"];
2147
+ this._tokenCache = await this._generateToken();
2148
+ this._expiresAt = now + this.tokenLifetime;
2149
+ return this._tokenCache;
2150
+ }
2151
+ /**
2152
+ * Clear cached token (force refresh on next getToken call)
2153
+ */
2154
+ invalidate() {
2155
+ this._tokenCache = null;
2156
+ this._expiresAt = 0;
2040
2157
  }
2041
2158
  /**
2042
- * Generate a signed JWT for the token exchange
2159
+ * Check if cached token is still valid
2160
+ *
2161
+ * @returns {boolean} True if token is valid
2043
2162
  */
2044
- async createSignedJWT() {
2045
- const alg = "RS256";
2046
- const privateKey = await jose.importPKCS8(this.credentials.private_key, alg);
2047
- const jwt = await new jose.SignJWT({
2048
- scope: this.scopes.join(" ")
2049
- }).setProtectedHeader({ alg }).setIssuedAt().setIssuer(this.credentials.client_email).setAudience(this.tokenUrl).setExpirationTime("1h").sign(privateKey);
2050
- return jwt;
2163
+ isValid() {
2164
+ const now = Math.floor(Date.now() / 1e3);
2165
+ return this._tokenCache && now < this._expiresAt - 300;
2051
2166
  }
2052
2167
  /**
2053
- * Get an access token from Google OAuth2
2054
- * @returns {Promise<string>} Access Token
2168
+ * Get time until token expires (in seconds)
2169
+ *
2170
+ * @returns {number} Seconds until expiration (0 if no token or expired)
2055
2171
  */
2056
- async getAccessToken() {
2057
- const jwt = await this.createSignedJWT();
2058
- const params = new URLSearchParams();
2059
- params.append("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer");
2060
- params.append("assertion", jwt);
2061
- const response = await fetch(this.tokenUrl, {
2172
+ getTimeToExpiry() {
2173
+ if (!this._tokenCache) return 0;
2174
+ const now = Math.floor(Date.now() / 1e3);
2175
+ return Math.max(0, this._expiresAt - now);
2176
+ }
2177
+ /**
2178
+ * Generate OAuth2 token via JWT flow
2179
+ *
2180
+ * @private
2181
+ * @returns {Promise<string>} Access token
2182
+ */
2183
+ async _generateToken() {
2184
+ const now = Math.floor(Date.now() / 1e3);
2185
+ const header = {
2186
+ alg: "RS256",
2187
+ typ: "JWT"
2188
+ };
2189
+ const payload = {
2190
+ iss: this.clientEmail,
2191
+ scope: this.scope,
2192
+ aud: "https://oauth2.googleapis.com/token",
2193
+ exp: now + this.tokenLifetime,
2194
+ iat: now
2195
+ };
2196
+ const jwt = await this._signJWT(header, payload);
2197
+ const response = await fetch("https://oauth2.googleapis.com/token", {
2062
2198
  method: "POST",
2063
2199
  headers: {
2064
2200
  "Content-Type": "application/x-www-form-urlencoded"
2065
2201
  },
2066
- body: params
2202
+ body: new URLSearchParams({
2203
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
2204
+ assertion: jwt
2205
+ })
2067
2206
  });
2068
2207
  if (!response.ok) {
2069
- const errorText = await response.text();
2070
- throw new Error(`Failed to get Google Access Token: ${response.status} ${errorText}`);
2208
+ const error = await response.text();
2209
+ throw new Error(`Failed to get OAuth2 token: ${response.status} - ${error}`);
2071
2210
  }
2072
2211
  const data = await response.json();
2212
+ if (!data.access_token) {
2213
+ throw new Error("No access_token in OAuth2 response");
2214
+ }
2073
2215
  return data.access_token;
2074
2216
  }
2217
+ /**
2218
+ * Sign JWT using service account private key
2219
+ *
2220
+ * @private
2221
+ * @param {object} header - JWT header
2222
+ * @param {object} payload - JWT payload
2223
+ * @returns {Promise<string>} Signed JWT
2224
+ */
2225
+ async _signJWT(header, payload) {
2226
+ const encoder = new TextEncoder();
2227
+ const headerB64 = this._base64UrlEncode(JSON.stringify(header));
2228
+ const payloadB64 = this._base64UrlEncode(JSON.stringify(payload));
2229
+ const unsignedToken = `${headerB64}.${payloadB64}`;
2230
+ const privateKey = await this._importPrivateKey(this.privateKey);
2231
+ const signature = await crypto.subtle.sign(
2232
+ { name: "RSASSA-PKCS1-v1_5" },
2233
+ privateKey,
2234
+ encoder.encode(unsignedToken)
2235
+ );
2236
+ const signatureB64 = this._base64UrlEncode(signature);
2237
+ return `${unsignedToken}.${signatureB64}`;
2238
+ }
2239
+ /**
2240
+ * Import PEM private key for signing
2241
+ *
2242
+ * @private
2243
+ * @param {string} pemKey - PEM formatted private key
2244
+ * @returns {Promise<CryptoKey>} Imported key
2245
+ */
2246
+ async _importPrivateKey(pemKey) {
2247
+ const pemContents = pemKey.replace(/-----BEGIN PRIVATE KEY-----/, "").replace(/-----END PRIVATE KEY-----/, "").replace(/\\n/g, "").replace(/\n/g, "").trim();
2248
+ const binaryKey = import_node_buffer.Buffer.from(pemContents, "base64");
2249
+ return crypto.subtle.importKey(
2250
+ "pkcs8",
2251
+ binaryKey,
2252
+ {
2253
+ name: "RSASSA-PKCS1-v1_5",
2254
+ hash: "SHA-256"
2255
+ },
2256
+ false,
2257
+ ["sign"]
2258
+ );
2259
+ }
2260
+ /**
2261
+ * Base64 URL encode (without padding)
2262
+ *
2263
+ * @private
2264
+ * @param {string|ArrayBuffer} input - Data to encode
2265
+ * @returns {string} Base64 URL encoded string
2266
+ */
2267
+ _base64UrlEncode(input) {
2268
+ const buffer = typeof input === "string" ? import_node_buffer.Buffer.from(input, "utf-8") : import_node_buffer.Buffer.from(input);
2269
+ return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
2270
+ }
2075
2271
  };
2076
2272
 
2077
2273
  // src/transcription-service.js
@@ -2091,7 +2287,7 @@ var TranscriptionService = class {
2091
2287
  if (this.provider === "openai") {
2092
2288
  this.client = new import_openai2.default({ apiKey: this.apiKey });
2093
2289
  } else if (this.provider === "google") {
2094
- this.googleAuth = new GoogleAuth(config.googleCredentials);
2290
+ this.googleTokenManager = new GoogleTokenManager(config.googleCredentials);
2095
2291
  this.projectId = config.googleCredentials.project_id;
2096
2292
  }
2097
2293
  }
@@ -2172,7 +2368,7 @@ var TranscriptionService = class {
2172
2368
  }
2173
2369
  async _transcribeGoogle(audioFile, model, language) {
2174
2370
  try {
2175
- const accessToken = await this.googleAuth.getAccessToken();
2371
+ const accessToken = await this.googleTokenManager.getToken();
2176
2372
  const arrayBuffer = await audioFile.arrayBuffer();
2177
2373
  const audioBytes = Buffer.from(arrayBuffer).toString("base64");
2178
2374
  let location = "global";