@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 +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 +55 -3
- 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,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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
+
}
|