@contentgrowth/llm-service 1.3.8 → 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,6 +747,14 @@ 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
760
  if (config.apiKey) {
@@ -775,8 +788,65 @@ var GoogleProvider = class extends BaseLLMProvider {
775
788
  * the client was constructed.
776
789
  */
777
790
  async _generateContent(requestOptions) {
791
+ if (this.useDirectAPI && this.accessToken) {
792
+ return this._directVertexAPICall(requestOptions);
793
+ }
778
794
  return this.client.models.generateContent(requestOptions);
779
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
+ }
780
850
  async chat(userMessage, systemPrompt = "", options = {}) {
781
851
  const messages = [{ role: "user", content: userMessage }];
782
852
  const tier = options.tier || "default";
@@ -1123,15 +1193,22 @@ ${msg.content}`;
1123
1193
  }
1124
1194
  async imageGeneration(prompt, systemPrompt, options = {}) {
1125
1195
  var _a, _b, _c, _d, _e, _f, _g, _h, _i;
1126
- const modelName = normalizeModelId(options.model) || this.models.image || "gemini-3-pro-image";
1127
- 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()})`);
1128
1204
  const hasReferenceImages = options.images && options.images.length > 0;
1129
1205
  const generationConfig = {
1130
1206
  responseModalities: hasReferenceImages ? ["TEXT", "IMAGE"] : ["IMAGE"]
1131
1207
  };
1132
- if (options.aspectRatio) {
1208
+ if (options.aspectRatio || options.thinkingLevel) {
1133
1209
  generationConfig.imageConfig = {
1134
- aspectRatio: options.aspectRatio
1210
+ aspectRatio: options.aspectRatio || "1:1",
1211
+ ...options.thinkingLevel ? { thinkingLevel: options.thinkingLevel } : {}
1135
1212
  };
1136
1213
  }
1137
1214
  const parts = [{ text: prompt }];
@@ -2024,53 +2101,173 @@ function sanitizeError(error, context = "general") {
2024
2101
  var import_openai2 = __toESM(require("openai"), 1);
2025
2102
 
2026
2103
  // src/utils/google-auth.js
2027
- var jose = __toESM(require("jose"), 1);
2028
- 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 {
2029
2121
  /**
2030
- * @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)
2031
2126
  */
2032
- constructor(credentials) {
2033
- if (!credentials || !credentials.private_key || !credentials.client_email) {
2034
- 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;
2035
2146
  }
2036
- this.credentials = credentials;
2037
- this.tokenUrl = "https://oauth2.googleapis.com/token";
2038
- 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;
2157
+ }
2158
+ /**
2159
+ * Check if cached token is still valid
2160
+ *
2161
+ * @returns {boolean} True if token is valid
2162
+ */
2163
+ isValid() {
2164
+ const now = Math.floor(Date.now() / 1e3);
2165
+ return this._tokenCache && now < this._expiresAt - 300;
2039
2166
  }
2040
2167
  /**
2041
- * Generate a signed JWT for the token exchange
2168
+ * Get time until token expires (in seconds)
2169
+ *
2170
+ * @returns {number} Seconds until expiration (0 if no token or expired)
2042
2171
  */
2043
- async createSignedJWT() {
2044
- const alg = "RS256";
2045
- const privateKey = await jose.importPKCS8(this.credentials.private_key, alg);
2046
- const jwt = await new jose.SignJWT({
2047
- scope: this.scopes.join(" ")
2048
- }).setProtectedHeader({ alg }).setIssuedAt().setIssuer(this.credentials.client_email).setAudience(this.tokenUrl).setExpirationTime("1h").sign(privateKey);
2049
- return jwt;
2172
+ getTimeToExpiry() {
2173
+ if (!this._tokenCache) return 0;
2174
+ const now = Math.floor(Date.now() / 1e3);
2175
+ return Math.max(0, this._expiresAt - now);
2050
2176
  }
2051
2177
  /**
2052
- * Get an access token from Google OAuth2
2053
- * @returns {Promise<string>} Access Token
2178
+ * Generate OAuth2 token via JWT flow
2179
+ *
2180
+ * @private
2181
+ * @returns {Promise<string>} Access token
2054
2182
  */
2055
- async getAccessToken() {
2056
- const jwt = await this.createSignedJWT();
2057
- const params = new URLSearchParams();
2058
- params.append("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer");
2059
- params.append("assertion", jwt);
2060
- const response = await fetch(this.tokenUrl, {
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", {
2061
2198
  method: "POST",
2062
2199
  headers: {
2063
2200
  "Content-Type": "application/x-www-form-urlencoded"
2064
2201
  },
2065
- body: params
2202
+ body: new URLSearchParams({
2203
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
2204
+ assertion: jwt
2205
+ })
2066
2206
  });
2067
2207
  if (!response.ok) {
2068
- const errorText = await response.text();
2069
- 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}`);
2070
2210
  }
2071
2211
  const data = await response.json();
2212
+ if (!data.access_token) {
2213
+ throw new Error("No access_token in OAuth2 response");
2214
+ }
2072
2215
  return data.access_token;
2073
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
+ }
2074
2271
  };
2075
2272
 
2076
2273
  // src/transcription-service.js
@@ -2090,7 +2287,7 @@ var TranscriptionService = class {
2090
2287
  if (this.provider === "openai") {
2091
2288
  this.client = new import_openai2.default({ apiKey: this.apiKey });
2092
2289
  } else if (this.provider === "google") {
2093
- this.googleAuth = new GoogleAuth(config.googleCredentials);
2290
+ this.googleTokenManager = new GoogleTokenManager(config.googleCredentials);
2094
2291
  this.projectId = config.googleCredentials.project_id;
2095
2292
  }
2096
2293
  }
@@ -2171,7 +2368,7 @@ var TranscriptionService = class {
2171
2368
  }
2172
2369
  async _transcribeGoogle(audioFile, model, language) {
2173
2370
  try {
2174
- const accessToken = await this.googleAuth.getAccessToken();
2371
+ const accessToken = await this.googleTokenManager.getToken();
2175
2372
  const arrayBuffer = await audioFile.arrayBuffer();
2176
2373
  const audioBytes = Buffer.from(arrayBuffer).toString("base64");
2177
2374
  let location = "global";