@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 +1 -1
- package/src/index.js +1 -1
- package/src/llm/config-manager.js +2 -0
- package/src/llm/config-provider.js +2 -0
- package/src/llm/providers/base-provider.js +9 -2
- package/src/llm/providers/gemini-provider.js +62 -7
- package/src/llm-service.js +6 -2
- package/src/utils/error-handler.js +117 -0
package/package.json
CHANGED
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
|
|
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,
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
222
|
-
|
|
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,
|
|
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
|
|
package/src/llm-service.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
+
}
|