@contentgrowth/llm-service 0.8.0 → 0.8.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentgrowth/llm-service",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Unified LLM Service for Content Growth",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -6,4 +6,4 @@ export { OpenAIProvider } from './llm/providers/openai-provider.js';
6
6
  export { GeminiProvider } from './llm/providers/gemini-provider.js';
7
7
  export { extractJsonFromResponse, extractTextAndJson } from './llm/json-utils.js';
8
8
  export { FINISH_REASONS } from './llm/providers/base-provider.js';
9
-
9
+ export { handleApiError, sanitizeError } from './utils/error-handler.js';
@@ -22,9 +22,11 @@ export const MODEL_CONFIGS = {
22
22
  cost: 'gemini-3-flash-preview', // 'gemini-2.5-flash-lite',
23
23
  free: 'gemini-3-flash-preview', // 'gemini-2.0-flash-lite',
24
24
  video: 'veo',
25
+ image: 'gemini-3-pro-image-preview', // Default image generation model
25
26
  },
26
27
  };
27
28
 
29
+
28
30
  export class ConfigManager {
29
31
  static _provider = new DefaultConfigProvider();
30
32
 
@@ -124,6 +124,8 @@ export class DefaultConfigProvider extends BaseConfigProvider {
124
124
  fast: env.GEMINI_MODEL_FAST || providerDefaults.fast,
125
125
  cost: env.GEMINI_MODEL_COST || providerDefaults.cost,
126
126
  free: env.GEMINI_MODEL_FREE || providerDefaults.free,
127
+ image: env.GEMINI_IMAGE_MODEL || providerDefaults.image,
128
+ video: env.GEMINI_VIDEO_MODEL || providerDefaults.video,
127
129
  };
128
130
  }
129
131
 
@@ -86,12 +86,19 @@ export class BaseLLMProvider {
86
86
  }
87
87
 
88
88
  /**
89
- * Generate image (optional support)
89
+ * Generate image
90
+ * Subclasses should override this method.
91
+ * Model can be overridden via options.model, otherwise uses config.models.image
92
+ * @param {string} prompt - Text description of the image
93
+ * @param {string} systemPrompt - System instructions for generation
94
+ * @param {Object} options - Generation options (aspectRatio, images, model, etc.)
95
+ * @returns {Promise<{imageData: string, mimeType: string}>}
90
96
  */
91
- async imageGeneration(prompt, modelName, systemPrompt, options) {
97
+ async imageGeneration(prompt, systemPrompt, options = {}) {
92
98
  throw new Error('Image generation not supported by this provider');
93
99
  }
94
100
 
101
+
95
102
  /**
96
103
  * Start video generation (returns operation name for polling)
97
104
  * @param {string} prompt
@@ -113,18 +113,32 @@ export class GeminiProvider extends BaseLLMProvider {
113
113
  break;
114
114
  case 'assistant':
115
115
  role = 'model';
116
+
117
+ // Find if this is the LAST assistant message in the conversation
118
+ // Only the last assistant message should carry the thought_signature to avoid token bloat
119
+ const isLastAssistantMessage = index === geminiMessages.map((m, i) => m.role === 'assistant' ? i : -1).filter(i => i >= 0).pop();
120
+
116
121
  if (msg.tool_calls) {
117
122
  parts = msg.tool_calls.map(tc => {
118
123
  const part = {
119
124
  functionCall: { name: tc.function.name, args: tc.function.arguments || tc.function.args }
120
125
  };
121
- if (tc.thought_signature) {
122
- part.thought_signature = tc.thought_signature;
126
+ // Only attach signature for the last assistant message
127
+ if (isLastAssistantMessage && tc.thought_signature) {
128
+ console.log(`[GeminiProvider] Sending thought_signature in tool_call (${tc.thought_signature.length} chars)`);
129
+ part.thoughtSignature = tc.thought_signature; // camelCase for SDK
123
130
  }
124
131
  return part;
125
132
  });
126
133
  } else {
127
- parts = [{ text: msg.content || '' }];
134
+ // Handle text content with optional thought signature
135
+ const part = { text: msg.content || '' };
136
+ // Only attach signature for the last assistant message
137
+ if (isLastAssistantMessage && msg.thought_signature) {
138
+ console.log(`[GeminiProvider] Sending thought_signature in text message (${msg.thought_signature.length} chars)`);
139
+ part.thoughtSignature = msg.thought_signature;
140
+ }
141
+ parts = [part];
128
142
  }
129
143
  break;
130
144
  case 'tool':
@@ -207,22 +221,35 @@ export class GeminiProvider extends BaseLLMProvider {
207
221
 
208
222
  const parts = candidate.content?.parts || [];
209
223
 
210
- // Extract text and function calls
224
+ // Extract text, function calls, and thought signatures
211
225
  let textContent = '';
212
226
  let toolCalls = null;
227
+ let responseThoughtSignature = null;
213
228
 
214
229
  for (const part of parts) {
215
230
  if (part.text) {
216
231
  textContent += part.text;
232
+ // Capture thought signature attached to text part if present
233
+ if (part.thought_signature || part.thoughtSignature) {
234
+ responseThoughtSignature = part.thought_signature || part.thoughtSignature;
235
+ }
217
236
  }
218
237
  if (part.functionCall) {
219
238
  if (!toolCalls) toolCalls = [];
220
239
  // Preserve thought_signature if present (Gemini 3 requirement)
221
- if (part.thought_signature) {
222
- part.functionCall.thought_signature = part.thought_signature;
240
+ // Check both snake_case (API) and camelCase (SDK convention)
241
+ const sig = part.thought_signature || part.thoughtSignature;
242
+ if (sig) {
243
+ part.functionCall.thought_signature = sig;
244
+ // Also capture as top-level if not already set (though tool calls might have their own)
245
+ if (!responseThoughtSignature) responseThoughtSignature = sig;
223
246
  }
224
247
  toolCalls.push(part.functionCall);
225
248
  }
249
+ // Fallback for standalone thought signature parts if they exist (hypothetical)
250
+ if (!part.text && !part.functionCall && (part.thought_signature || part.thoughtSignature)) {
251
+ responseThoughtSignature = part.thought_signature || part.thoughtSignature;
252
+ }
226
253
  }
227
254
 
228
255
  // Validate that we have EITHER content OR tool calls
@@ -238,6 +265,9 @@ export class GeminiProvider extends BaseLLMProvider {
238
265
  );
239
266
  }
240
267
 
268
+ // Detailed logging as requested
269
+ // console.log('[GeminiProvider] generateContent response candidate:', JSON.stringify(candidate, null, 2));
270
+
241
271
  // console.log('Gemini returns:', textContent);
242
272
  // Return with parsed JSON if applicable
243
273
  // Normalize the finish reason to standard value for consistent handling
@@ -245,6 +275,7 @@ export class GeminiProvider extends BaseLLMProvider {
245
275
 
246
276
  return {
247
277
  content: textContent,
278
+ thought_signature: responseThoughtSignature, // Return signature to caller
248
279
  tool_calls: toolCalls ? (Array.isArray(toolCalls) ? toolCalls : [toolCalls]).map(fc => ({
249
280
  type: 'function',
250
281
  function: fc,
@@ -390,7 +421,11 @@ export class GeminiProvider extends BaseLLMProvider {
390
421
  toolResults.forEach(result => messages.push({ role: 'tool', tool_call_id: result.tool_call_id, content: result.output }));
391
422
  }
392
423
 
393
- async imageGeneration(prompt, modelName, systemPrompt, options = {}) {
424
+ async imageGeneration(prompt, systemPrompt, options = {}) {
425
+ // Allow model override via options.model, otherwise use default from config
426
+ const modelName = options.model || this.models.image || 'gemini-3-pro-image-preview';
427
+ console.log(`[GeminiProvider] Generating image with model: ${modelName}`);
428
+
394
429
  const generationConfig = {
395
430
  responseModalities: ["IMAGE"],
396
431
  };
@@ -454,9 +489,29 @@ export class GeminiProvider extends BaseLLMProvider {
454
489
  throw new Error(`No image data in response. Finish Reason: ${candidate?.finishReason}`);
455
490
  }
456
491
 
492
+ // Check for thought signature in the image part or any other part
493
+ let thoughtSignature = null;
494
+ if (imagePart.thought_signature || imagePart.thoughtSignature) {
495
+ thoughtSignature = imagePart.thought_signature || imagePart.thoughtSignature;
496
+ } else {
497
+ // Check other parts for standalone thought signature
498
+ const signaturePart = response.candidates?.[0]?.content?.parts?.find(p => p.thought_signature || p.thoughtSignature);
499
+ if (signaturePart) {
500
+ thoughtSignature = signaturePart.thought_signature || signaturePart.thoughtSignature;
501
+ }
502
+ }
503
+
504
+ // Safety: If thought signature is abnormally large (>50KB), replace with bypass token
505
+ // to prevent massive context usage (User reported 1.5MB signatures in some cases).
506
+ if (thoughtSignature && thoughtSignature.length > 50000) {
507
+ console.warn(`[GeminiProvider] ⚠️ Thought signature is abnormally large (${thoughtSignature.length} chars). Replacing with bypass token to save context.`);
508
+ thoughtSignature = "skip_thought_signature_validator";
509
+ }
510
+
457
511
  return {
458
512
  imageData: imagePart.inlineData.data,
459
513
  mimeType: imagePart.inlineData.mimeType,
514
+ thought_signature: thoughtSignature
460
515
  };
461
516
  }
462
517
 
@@ -251,8 +251,12 @@ export class LLMService {
251
251
  /**
252
252
  * Generate an image
253
253
  * Falls back to system keys if tenant doesn't have image capability enabled
254
+ * @param {string} prompt - Text description of the image
255
+ * @param {string} tenantId - Tenant identifier
256
+ * @param {string} systemPrompt - System instructions for generation
257
+ * @param {Object} options - Generation options (aspectRatio, images, etc.)
254
258
  */
255
- async imageGeneration(prompt, tenantId, modelName, systemPrompt, options = {}) {
259
+ async imageGeneration(prompt, tenantId, systemPrompt, options = {}) {
256
260
  // Check if tenant has image capability enabled
257
261
  if (tenantId) {
258
262
  const config = await ConfigManager.getConfig(tenantId, this.env);
@@ -263,7 +267,7 @@ export class LLMService {
263
267
  }
264
268
 
265
269
  const provider = await this._getProvider(tenantId);
266
- return provider.imageGeneration(prompt, modelName, systemPrompt, options);
270
+ return provider.imageGeneration(prompt, systemPrompt, options);
267
271
  }
268
272
  }
269
273
 
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Error Handling Utility for LLM Service
3
+ * Provides centralized error parsing and user-friendly message generation.
4
+ * Returns plain objects - framework-specific response handling is done by consumers.
5
+ */
6
+
7
+ /**
8
+ * Parse an error and return a standardized error response object.
9
+ * Detects specific error types like service overload, rate limits, and input issues.
10
+ *
11
+ * @param {Error} error - The caught error
12
+ * @param {string} operation - The operation being performed (e.g., 'generate image', 'edit article')
13
+ * @param {string} context - Context for logging (e.g., 'image_generation', 'ai_edit')
14
+ * @returns {{ message: string, error: string, retryable: boolean, statusCode: number }}
15
+ */
16
+ export function handleApiError(error, operation = 'complete this operation', context = 'api') {
17
+ console.error(`[${context}] Error:`, error);
18
+
19
+ const errorMessage = error.message?.toLowerCase() || '';
20
+ const errorString = JSON.stringify(error).toLowerCase();
21
+
22
+ // Check for model overload (503)
23
+ if (errorMessage.includes('overloaded') || errorMessage.includes('503') ||
24
+ errorString.includes('unavailable') || errorString.includes('overloaded')) {
25
+ return {
26
+ message: 'AI service is busy. Please try again.',
27
+ error: 'service_overloaded',
28
+ retryable: true,
29
+ statusCode: 503
30
+ };
31
+ }
32
+
33
+ // Check for rate limiting (429)
34
+ if (errorMessage.includes('quota') || errorMessage.includes('429') ||
35
+ errorMessage.includes('too many requests') || errorMessage.includes('rate limit') ||
36
+ errorString.includes('resource_exhausted')) {
37
+ return {
38
+ message: 'Too many requests. Please try again later.',
39
+ error: 'rate_limited',
40
+ retryable: true,
41
+ statusCode: 429
42
+ };
43
+ }
44
+
45
+ // Check for context length / input too long
46
+ if (errorMessage.includes('context length') || errorMessage.includes('too long') ||
47
+ errorString.includes('invalid_argument')) {
48
+ return {
49
+ message: 'Content too big. Try making focused edits.',
50
+ error: 'input_too_long',
51
+ retryable: false,
52
+ statusCode: 422
53
+ };
54
+ }
55
+
56
+ // Check for user quota exceeded (from our own quota system)
57
+ if (errorMessage.includes('quotaerror') || errorMessage.includes('quota_exceeded')) {
58
+ return {
59
+ message: 'You have reached your usage limit for this month. Please upgrade your plan to continue.',
60
+ error: 'user_quota_exceeded',
61
+ retryable: false,
62
+ statusCode: 402
63
+ };
64
+ }
65
+
66
+ // Check for trial limitation errors
67
+ if (errorMessage.includes('trial')) {
68
+ return {
69
+ message: 'This feature is not available during the free trial. Please upgrade to use this feature.',
70
+ error: 'trial_limitation',
71
+ retryable: false,
72
+ statusCode: 402
73
+ };
74
+ }
75
+
76
+ // Check for authentication/configuration errors
77
+ if (errorMessage.includes('api key') || errorMessage.includes('authentication') || errorMessage.includes('unauthorized')) {
78
+ return {
79
+ message: 'Service is not available at this time. Please contact support.',
80
+ error: 'not_configured',
81
+ retryable: false,
82
+ statusCode: 503
83
+ };
84
+ }
85
+
86
+ // Check for invalid input errors
87
+ if (errorMessage.includes('invalid') || errorMessage.includes('bad request')) {
88
+ return {
89
+ message: 'Invalid request. Please check your input and try again.',
90
+ error: 'invalid_input',
91
+ retryable: false,
92
+ statusCode: 400
93
+ };
94
+ }
95
+
96
+ // Default error
97
+ return {
98
+ message: `An error occurred while trying to ${operation}. Please try again.`,
99
+ error: 'operation_failed',
100
+ retryable: true,
101
+ statusCode: 500
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Sanitize error messages to prevent leaking technical details.
107
+ * Returns an Error with a clean error code that can be handled by the API layer.
108
+ * Use this when you want to throw an error rather than return a JSON response.
109
+ *
110
+ * @param {Error} error - The original error
111
+ * @param {string} context - Context of where error occurred (e.g., 'image_generation', 'ai_edit')
112
+ * @returns {Error} - Sanitized error with clean message code
113
+ */
114
+ export function sanitizeError(error, context = 'general') {
115
+ const result = handleApiError(error, 'complete this operation', context);
116
+ return new Error(result.error);
117
+ }