@contentgrowth/llm-service 0.8.1 → 0.8.3

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.1",
3
+ "version": "0.8.3",
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,34 @@ 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
+ // Text messages: only the last one should carry thought_signature (cumulative state)
119
+ // Tool calls: ALL must carry their signatures (model requirement for function calls)
120
+ const isLastAssistantMessage = index === geminiMessages.map((m, i) => m.role === 'assistant' ? i : -1).filter(i => i >= 0).pop();
121
+
116
122
  if (msg.tool_calls) {
117
123
  parts = msg.tool_calls.map(tc => {
118
124
  const part = {
119
125
  functionCall: { name: tc.function.name, args: tc.function.arguments || tc.function.args }
120
126
  };
127
+ // IMPORTANT: Always attach signatures for ALL tool calls in history
128
+ // The model requires thought_signature on every functionCall part
121
129
  if (tc.thought_signature) {
130
+ console.log(`[GeminiProvider] Sending thought_signature in tool_call (${tc.thought_signature.length} chars)`);
122
131
  part.thoughtSignature = tc.thought_signature; // camelCase for SDK
123
132
  }
124
133
  return part;
125
134
  });
126
135
  } else {
127
- parts = [{ text: msg.content || '' }];
136
+ // Handle text content with optional thought signature
137
+ const part = { text: msg.content || '' };
138
+ // Only attach signature for the last assistant message (text messages only)
139
+ if (isLastAssistantMessage && msg.thought_signature) {
140
+ console.log(`[GeminiProvider] Sending thought_signature in text message (${msg.thought_signature.length} chars)`);
141
+ part.thoughtSignature = msg.thought_signature;
142
+ }
143
+ parts = [part];
128
144
  }
129
145
  break;
130
146
  case 'tool':
@@ -207,13 +223,18 @@ export class GeminiProvider extends BaseLLMProvider {
207
223
 
208
224
  const parts = candidate.content?.parts || [];
209
225
 
210
- // Extract text and function calls
226
+ // Extract text, function calls, and thought signatures
211
227
  let textContent = '';
212
228
  let toolCalls = null;
229
+ let responseThoughtSignature = null;
213
230
 
214
231
  for (const part of parts) {
215
232
  if (part.text) {
216
233
  textContent += part.text;
234
+ // Capture thought signature attached to text part if present
235
+ if (part.thought_signature || part.thoughtSignature) {
236
+ responseThoughtSignature = part.thought_signature || part.thoughtSignature;
237
+ }
217
238
  }
218
239
  if (part.functionCall) {
219
240
  if (!toolCalls) toolCalls = [];
@@ -222,9 +243,15 @@ export class GeminiProvider extends BaseLLMProvider {
222
243
  const sig = part.thought_signature || part.thoughtSignature;
223
244
  if (sig) {
224
245
  part.functionCall.thought_signature = sig;
246
+ // Also capture as top-level if not already set (though tool calls might have their own)
247
+ if (!responseThoughtSignature) responseThoughtSignature = sig;
225
248
  }
226
249
  toolCalls.push(part.functionCall);
227
250
  }
251
+ // Fallback for standalone thought signature parts if they exist (hypothetical)
252
+ if (!part.text && !part.functionCall && (part.thought_signature || part.thoughtSignature)) {
253
+ responseThoughtSignature = part.thought_signature || part.thoughtSignature;
254
+ }
228
255
  }
229
256
 
230
257
  // Validate that we have EITHER content OR tool calls
@@ -250,6 +277,7 @@ export class GeminiProvider extends BaseLLMProvider {
250
277
 
251
278
  return {
252
279
  content: textContent,
280
+ thought_signature: responseThoughtSignature, // Return signature to caller
253
281
  tool_calls: toolCalls ? (Array.isArray(toolCalls) ? toolCalls : [toolCalls]).map(fc => ({
254
282
  type: 'function',
255
283
  function: fc,
@@ -395,7 +423,11 @@ export class GeminiProvider extends BaseLLMProvider {
395
423
  toolResults.forEach(result => messages.push({ role: 'tool', tool_call_id: result.tool_call_id, content: result.output }));
396
424
  }
397
425
 
398
- async imageGeneration(prompt, modelName, systemPrompt, options = {}) {
426
+ async imageGeneration(prompt, systemPrompt, options = {}) {
427
+ // Allow model override via options.model, otherwise use default from config
428
+ const modelName = options.model || this.models.image || 'gemini-3-pro-image-preview';
429
+ console.log(`[GeminiProvider] Generating image with model: ${modelName}`);
430
+
399
431
  const generationConfig = {
400
432
  responseModalities: ["IMAGE"],
401
433
  };
@@ -459,9 +491,29 @@ export class GeminiProvider extends BaseLLMProvider {
459
491
  throw new Error(`No image data in response. Finish Reason: ${candidate?.finishReason}`);
460
492
  }
461
493
 
494
+ // Check for thought signature in the image part or any other part
495
+ let thoughtSignature = null;
496
+ if (imagePart.thought_signature || imagePart.thoughtSignature) {
497
+ thoughtSignature = imagePart.thought_signature || imagePart.thoughtSignature;
498
+ } else {
499
+ // Check other parts for standalone thought signature
500
+ const signaturePart = response.candidates?.[0]?.content?.parts?.find(p => p.thought_signature || p.thoughtSignature);
501
+ if (signaturePart) {
502
+ thoughtSignature = signaturePart.thought_signature || signaturePart.thoughtSignature;
503
+ }
504
+ }
505
+
506
+ // Safety: If thought signature is abnormally large (>50KB), replace with bypass token
507
+ // to prevent massive context usage (User reported 1.5MB signatures in some cases).
508
+ if (thoughtSignature && thoughtSignature.length > 50000) {
509
+ console.warn(`[GeminiProvider] ⚠️ Thought signature is abnormally large (${thoughtSignature.length} chars). Replacing with bypass token to save context.`);
510
+ thoughtSignature = "skip_thought_signature_validator";
511
+ }
512
+
462
513
  return {
463
514
  imageData: imagePart.inlineData.data,
464
515
  mimeType: imagePart.inlineData.mimeType,
516
+ thought_signature: thoughtSignature
465
517
  };
466
518
  }
467
519
 
@@ -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
+ }