@contentgrowth/llm-service 0.8.3 → 0.8.5

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.
@@ -1,203 +0,0 @@
1
- import OpenAI from 'openai';
2
- import { BaseLLMProvider } from './base-provider.js';
3
- import { extractJsonFromResponse } from '../json-utils.js';
4
- import { LLMServiceException } from '../../llm-service.js';
5
-
6
- export class OpenAIProvider extends BaseLLMProvider {
7
- constructor(config) {
8
- super(config);
9
- this.client = new OpenAI({ apiKey: config.apiKey });
10
- this.models = config.models;
11
- this.defaultModel = config.models.default;
12
- }
13
-
14
- async chat(userMessage, systemPrompt = '', options = {}) {
15
- const messages = [{ role: 'user', content: userMessage }];
16
- const tier = options.tier || 'default';
17
- const effectiveModel = this._getModelForTier(tier);
18
- const effectiveMaxTokens = options.maxTokens || this.config.maxTokens;
19
- const effectiveTemperature = options.temperature !== undefined ? options.temperature : this.config.temperature;
20
-
21
- const response = await this._chatCompletionWithModel(
22
- messages,
23
- systemPrompt,
24
- null,
25
- effectiveModel,
26
- effectiveMaxTokens,
27
- effectiveTemperature
28
- );
29
- return { text: response.content };
30
- }
31
-
32
- async chatCompletion(messages, systemPrompt, tools = null, options = {}) {
33
- return this._chatCompletionWithModel(
34
- messages,
35
- systemPrompt,
36
- tools,
37
- this.defaultModel,
38
- this.config.maxTokens,
39
- this.config.temperature,
40
- options
41
- );
42
- }
43
-
44
- async _chatCompletionWithModel(messages, systemPrompt, tools, modelName, maxTokens, temperature, options = {}) {
45
- const requestPayload = {
46
- model: modelName,
47
- temperature: options.temperature ?? temperature,
48
- max_tokens: options.maxTokens ?? maxTokens,
49
- messages: [{ role: 'system', content: systemPrompt }, ...messages],
50
- tools: tools,
51
- tool_choice: tools ? 'auto' : undefined,
52
- };
53
-
54
- // Add JSON mode support if requested
55
- if (options.responseFormat) {
56
- requestPayload.response_format = this._buildResponseFormat(options);
57
- }
58
-
59
- let response;
60
- try {
61
- response = await this.client.chat.completions.create(requestPayload);
62
- } catch (error) {
63
- console.error(`[OpenAIProvider] chat completion failed (API Key: ${this._getMaskedApiKey()}):`, error);
64
- throw error;
65
- }
66
- const message = response.choices[0].message;
67
-
68
- // Validate that we have EITHER content OR tool calls
69
- if (!message.content && (!message.tool_calls || message.tool_calls.length === 0)) {
70
- console.error('[OpenAIProvider] Model returned empty response (no text, no tool calls)');
71
- throw new LLMServiceException(
72
- 'Model returned empty response. This usually means the prompt or schema is confusing the model.',
73
- 500
74
- );
75
- }
76
-
77
- // Normalize the finish reason to standard value for consistent handling
78
- const rawFinishReason = response.choices[0].finish_reason;
79
- const normalizedFinishReason = this.normalizeFinishReason(rawFinishReason);
80
-
81
- // Return with parsed JSON if applicable
82
- return {
83
- content: message.content,
84
- tool_calls: message.tool_calls,
85
- finishReason: normalizedFinishReason, // Standardized: 'completed', 'truncated', etc.
86
- _rawFinishReason: rawFinishReason, // Keep original for debugging
87
- // Add metadata about response format
88
- _responseFormat: options.responseFormat,
89
- // Auto-parse JSON if requested
90
- ...(options.responseFormat && this._shouldAutoParse(options) ? {
91
- parsedContent: this._safeJsonParse(message.content)
92
- } : {})
93
- };
94
- }
95
-
96
- _buildResponseFormat(options) {
97
- if (!options.responseFormat) {
98
- return undefined;
99
- }
100
-
101
- // Handle responseFormat as either string or object { type, schema }
102
- const formatType = typeof options.responseFormat === 'string'
103
- ? options.responseFormat
104
- : options.responseFormat.type;
105
-
106
- const schema = typeof options.responseFormat === 'object'
107
- ? options.responseFormat.schema
108
- : options.responseSchema || null;
109
-
110
- switch (formatType) {
111
- case 'json':
112
- // If schema is provided, use strict mode; otherwise use legacy json_object
113
- if (schema) {
114
- console.log('[OpenAIProvider] Using Strict JSON mode with schema');
115
- return {
116
- type: 'json_schema',
117
- json_schema: {
118
- name: options.schemaName || 'response_schema',
119
- strict: options.strictSchema ?? true,
120
- schema: schema
121
- }
122
- };
123
- } else {
124
- console.warn('[OpenAIProvider] Using legacy json_object mode without schema - may produce markdown wrappers');
125
- return { type: 'json_object' };
126
- }
127
-
128
- case 'json_schema':
129
- if (!schema) {
130
- throw new Error('responseSchema required when using json_schema format');
131
- }
132
- console.log('[OpenAIProvider] Using Strict JSON mode with schema');
133
- return {
134
- type: 'json_schema',
135
- json_schema: {
136
- name: options.schemaName || 'response_schema',
137
- strict: options.strictSchema ?? true,
138
- schema: schema
139
- }
140
- };
141
-
142
- default:
143
- return undefined;
144
- }
145
- }
146
-
147
- _shouldAutoParse(options) {
148
- return options.autoParse !== false; // Default true
149
- }
150
-
151
- _safeJsonParse(content) {
152
- if (!content) return null;
153
-
154
- // Use the robust JSON extractor that handles:
155
- // - Markdown code blocks (```json ... ```)
156
- // - Plain JSON objects
157
- // - Over-escaped content (\\\\n instead of \\n)
158
- // - Brace extraction as fallback
159
- const parsed = extractJsonFromResponse(content);
160
-
161
- if (parsed) {
162
- console.log('[OpenAIProvider] Successfully parsed JSON from response');
163
- } else {
164
- console.error('[OpenAIProvider] Failed to extract valid JSON from response');
165
- console.error('[OpenAIProvider] Content preview:', content.substring(0, 200));
166
- }
167
-
168
- return parsed;
169
- }
170
-
171
- async executeTools(tool_calls, messages, tenantId, toolImplementations, env) {
172
- const toolResults = await Promise.all(
173
- tool_calls.map(async (toolCall) => {
174
- const toolName = toolCall.function.name;
175
- const tool = toolImplementations[toolName];
176
-
177
- console.log(`[Tool Call] ${toolName} with arguments:`, toolCall.function.arguments);
178
-
179
- if (!tool) {
180
- console.error(`[Tool Error] Tool '${toolName}' not found`);
181
- return { tool_call_id: toolCall.id, output: JSON.stringify({ error: `Tool '${toolName}' not found.` }) };
182
- }
183
- try {
184
- const output = await tool(toolCall.function.arguments, { env, tenantId });
185
- console.log(`[Tool Result] ${toolName} returned:`, output.substring(0, 200) + (output.length > 200 ? '...' : ''));
186
- return { tool_call_id: toolCall.id, output };
187
- } catch (error) {
188
- console.error(`[Tool Error] ${toolName} failed:`, error.message);
189
- return { tool_call_id: toolCall.id, output: JSON.stringify({ error: `Error executing tool '${toolName}': ${error.message}` }) };
190
- }
191
- })
192
- );
193
- toolResults.forEach(result => messages.push({ role: 'tool', tool_call_id: result.tool_call_id, content: result.output }));
194
- }
195
-
196
- _getModelForTier(tier) {
197
- return this.models[tier] || this.models.default;
198
- }
199
-
200
- async videoGeneration(prompt, images, modelName, systemPrompt, options = {}) {
201
- throw new Error('Video generation is not supported by OpenAI provider yet.');
202
- }
203
- }
@@ -1,281 +0,0 @@
1
- import { ConfigManager } from './llm/config-manager.js';
2
- import { OpenAIProvider } from './llm/providers/openai-provider.js';
3
- import { GeminiProvider } from './llm/providers/gemini-provider.js';
4
-
5
- /**
6
- * LLM Service Module
7
- *
8
- * This module provides a unified interface for interacting with different LLM providers.
9
- * It now supports BYOK (Bring Your Own Key) via Tenant Configuration.
10
- */
11
- export class LLMService {
12
- constructor(env, toolImplementations = {}) {
13
- this.env = env;
14
- this.toolImplementations = toolImplementations;
15
- // Cache for provider instances to avoid recreation within the same request if possible
16
- this.providerCache = new Map();
17
- }
18
-
19
- async _getProvider(tenantId) {
20
- // Check cache first
21
- const cacheKey = tenantId || 'system';
22
- if (this.providerCache.has(cacheKey)) {
23
- return this.providerCache.get(cacheKey);
24
- }
25
-
26
- const config = await ConfigManager.getConfig(tenantId, this.env);
27
-
28
- if (!config.apiKey) {
29
- throw new LLMServiceException(`LLM service is not configured for ${config.provider}. Missing API Key.`, 500);
30
- }
31
-
32
- let provider;
33
- if (config.provider === 'openai') {
34
- provider = new OpenAIProvider(config);
35
- } else if (config.provider === 'gemini') {
36
- provider = new GeminiProvider(config);
37
- } else {
38
- throw new LLMServiceException(`Unsupported LLM provider: ${config.provider}`, 500);
39
- }
40
-
41
- this.providerCache.set(cacheKey, provider);
42
- return provider;
43
- }
44
-
45
- /**
46
- * Check if LLM service is configured for a tenant (or system default)
47
- */
48
- async isConfigured(tenantId) {
49
- const config = await ConfigManager.getConfig(tenantId, this.env);
50
- return !!config.apiKey;
51
- }
52
-
53
- /**
54
- * Simple chat interface for single-turn conversations
55
- */
56
- async chat(userMessage, tenantId, systemPrompt = '', options = {}) {
57
- const provider = await this._getProvider(tenantId);
58
- return provider.chat(userMessage, systemPrompt, options);
59
- }
60
-
61
- /**
62
- * Interact with LLM for generation
63
- *
64
- * Intelligent signature detection supports:
65
- * - chatCompletion(messages, tenantId, systemPrompt)
66
- * - chatCompletion(messages, tenantId, systemPrompt, tools)
67
- * - chatCompletion(messages, tenantId, systemPrompt, options)
68
- * - chatCompletion(messages, tenantId, systemPrompt, tools, options)
69
- *
70
- * @param {Array} messages - Conversation messages
71
- * @param {string} tenantId - Tenant identifier
72
- * @param {string} systemPrompt - System instructions
73
- * @param {Array|Object} toolsOrOptions - Tools array or options object
74
- * @param {Object} optionsParam - Options object (if tools provided)
75
- * @returns {Object} Response with content, tool_calls, and optionally parsedContent
76
- */
77
- async chatCompletion(messages, tenantId, systemPrompt, toolsOrOptions = null, optionsParam = {}) {
78
- const provider = await this._getProvider(tenantId);
79
-
80
- if (!systemPrompt?.trim()) {
81
- throw new LLMServiceException('No prompt set for bot', 503);
82
- }
83
-
84
- // Intelligent parameter detection
85
- const { tools, options } = this._parseToolsAndOptions(toolsOrOptions, optionsParam);
86
-
87
- return provider.chatCompletion(messages, systemPrompt, tools, options);
88
- }
89
-
90
- /**
91
- * Helper to intelligently detect tools vs options parameters
92
- * @private
93
- */
94
- _parseToolsAndOptions(param1, param2) {
95
- // If param1 is null/undefined, use defaults
96
- if (param1 === null || param1 === undefined) {
97
- return { tools: null, options: param2 || {} };
98
- }
99
-
100
- // If param1 is an array, it's tools
101
- if (Array.isArray(param1)) {
102
- return { tools: param1, options: param2 || {} };
103
- }
104
-
105
- // If param1 is an object, check if it looks like options or tools
106
- if (typeof param1 === 'object') {
107
- // Check for known options keys
108
- const optionsKeys = ['responseFormat', 'responseSchema', 'temperature', 'maxTokens', 'tier', 'autoParse', 'strictSchema', 'schemaName'];
109
- const hasOptionsKeys = optionsKeys.some(key => key in param1);
110
-
111
- if (hasOptionsKeys) {
112
- // param1 is options
113
- return { tools: null, options: param1 };
114
- }
115
-
116
- // Could be tools in object format (legacy support)
117
- // Treat as tools if it doesn't have options keys
118
- return { tools: param1, options: param2 || {} };
119
- }
120
-
121
- // Default: treat as tools
122
- return { tools: param1, options: param2 || {} };
123
- }
124
-
125
- /**
126
- * Convenience method for JSON-only responses
127
- * Automatically enables JSON mode and returns parsed object
128
- *
129
- * @param {Array} messages - Conversation messages
130
- * @param {string} tenantId - Tenant identifier
131
- * @param {string} systemPrompt - System instructions
132
- * @param {Object} schema - Optional JSON schema for validation
133
- * @param {Array} tools - Optional tools array
134
- * @returns {Object} Parsed JSON object
135
- * @throws {LLMServiceException} If JSON parsing fails
136
- */
137
- async chatCompletionJson(messages, tenantId, systemPrompt, schema = null, tools = null) {
138
- const options = {
139
- responseFormat: schema ? { type: 'json_schema', schema } : 'json',
140
- autoParse: true
141
- };
142
-
143
- const response = await this.chatCompletion(messages, tenantId, systemPrompt, tools, options);
144
-
145
- // Return parsed content directly or throw if parsing failed
146
- if (response.parsedContent !== null && response.parsedContent !== undefined) {
147
- return response.parsedContent;
148
- }
149
-
150
- // Fallback: try to parse the raw content
151
- try {
152
- return JSON.parse(response.content);
153
- } catch (e) {
154
- throw new LLMServiceException(
155
- 'LLM returned invalid JSON despite JSON mode being enabled',
156
- 500,
157
- { rawContent: response.content, parseError: e.message }
158
- );
159
- }
160
- }
161
-
162
- async chatWithTools(messages, tenantId, systemPrompt, tools = [], options = {}) {
163
- const provider = await this._getProvider(tenantId);
164
-
165
- let currentMessages = [...messages];
166
- const MAX_ITERATIONS = 10; // Prevent infinite loops
167
- let iteration = 0;
168
-
169
- // Initial call
170
- const initialResponse = await provider.chatCompletion(
171
- currentMessages,
172
- systemPrompt,
173
- tools,
174
- options
175
- );
176
-
177
- let { content, tool_calls, parsedContent, finishReason, _rawFinishReason } = initialResponse;
178
-
179
- // Tool execution loop with safety limit
180
- while (tool_calls && iteration < MAX_ITERATIONS) {
181
- iteration++;
182
- console.log(`[Tool Call] Iteration ${iteration}/${MAX_ITERATIONS} with finish reason ${finishReason}: Assistant wants to use tools:`, tool_calls);
183
- currentMessages.push({ role: 'assistant', content: content || '', tool_calls });
184
-
185
- // Execute tools using the provider's helper (which formats results for that provider)
186
- await provider.executeTools(tool_calls, currentMessages, tenantId, this.toolImplementations, this.env);
187
-
188
- // Next call
189
- const nextResponse = await provider.chatCompletion(
190
- currentMessages,
191
- systemPrompt,
192
- tools,
193
- options
194
- );
195
-
196
- content = nextResponse.content;
197
- tool_calls = nextResponse.tool_calls;
198
- parsedContent = nextResponse.parsedContent; // Preserve parsedContent from final response
199
- finishReason = nextResponse.finishReason; // Preserve finishReason from final response
200
- _rawFinishReason = nextResponse._rawFinishReason; // Preserve raw finish reason for debugging
201
- }
202
-
203
- if (iteration >= MAX_ITERATIONS) {
204
- console.warn(`[Tool Call] Reached maximum iterations (${MAX_ITERATIONS}). Forcing completion.`);
205
- }
206
-
207
- // Return both content and parsedContent (if available)
208
- return { content, parsedContent, toolCalls: tool_calls, finishReason, _rawFinishReason };
209
- }
210
-
211
- /**
212
- * Generate a video (async wrapper with polling - backward compatibility)
213
- */
214
- async videoGeneration(prompt, images, tenantId, modelName, systemPrompt, options = {}) {
215
- const { operationName } = await this.startVideoGeneration(prompt, images, tenantId, modelName, systemPrompt, options);
216
-
217
- let status = await this.getVideoGenerationStatus(operationName, tenantId);
218
-
219
- while (!status.done) {
220
- console.log(`Waiting for video generation... Progress: ${status.progress}%`);
221
- await new Promise(resolve => setTimeout(resolve, 10000)); // Wait 10 seconds
222
- status = await this.getVideoGenerationStatus(operationName, tenantId);
223
- }
224
-
225
- if (status.error) {
226
- throw new Error(`Video generation failed: ${status.error.message || JSON.stringify(status.error)}`);
227
- }
228
-
229
- return {
230
- content: status.content || "Video generation completed.",
231
- videoUri: status.videoUri
232
- };
233
- }
234
-
235
- /**
236
- * Start video generation (returns operation name for polling)
237
- */
238
- async startVideoGeneration(prompt, images, tenantId, modelName, systemPrompt, options = {}) {
239
- const provider = await this._getProvider(tenantId);
240
- return provider.startVideoGeneration(prompt, images, modelName, systemPrompt, options);
241
- }
242
-
243
- /**
244
- * Get video generation status
245
- */
246
- async getVideoGenerationStatus(operationName, tenantId) {
247
- const provider = await this._getProvider(tenantId);
248
- return provider.getVideoGenerationStatus(operationName);
249
- }
250
-
251
- /**
252
- * Generate an image
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.)
258
- */
259
- async imageGeneration(prompt, tenantId, systemPrompt, options = {}) {
260
- // Check if tenant has image capability enabled
261
- if (tenantId) {
262
- const config = await ConfigManager.getConfig(tenantId, this.env);
263
- if (config.isTenantOwned && !config.capabilities?.image) {
264
- console.log(`[LLMService] Tenant ${tenantId} BYOK doesn't have image capability. Using system keys.`);
265
- tenantId = null; // Fall back to system
266
- }
267
- }
268
-
269
- const provider = await this._getProvider(tenantId);
270
- return provider.imageGeneration(prompt, systemPrompt, options);
271
- }
272
- }
273
-
274
- export class LLMServiceException extends Error {
275
- constructor(message, statusCode = 500, details = null) {
276
- super(message);
277
- this.name = 'LLMServiceException';
278
- this.statusCode = statusCode;
279
- this.details = details || {};
280
- }
281
- }
@@ -1,117 +0,0 @@
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
- }