@contentgrowth/llm-service 0.3.0 → 0.5.0

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/README.md CHANGED
@@ -81,6 +81,126 @@ ConfigManager.setConfigProvider(new MyDatabaseConfigProvider());
81
81
  const service = new LLMService(env);
82
82
  ```
83
83
 
84
+ ### JSON Mode & Structured Outputs
85
+
86
+ The service supports native JSON mode for OpenAI and Gemini, guaranteeing valid JSON responses without escaping issues.
87
+
88
+ #### Basic JSON Mode
89
+
90
+ ```javascript
91
+ const response = await llmService.chatCompletion(
92
+ messages,
93
+ tenantId,
94
+ 'You are a helpful assistant. Always respond in JSON.',
95
+ { responseFormat: 'json' } // ← Enable JSON mode
96
+ );
97
+
98
+ // Response includes auto-parsed JSON
99
+ console.log(response.parsedContent); // Already parsed object
100
+ console.log(response.content); // Raw JSON string
101
+ ```
102
+
103
+ #### JSON Mode with Schema Validation (Structured Outputs)
104
+
105
+ Define a schema to guarantee the response structure:
106
+
107
+ ```javascript
108
+ const schema = {
109
+ type: 'object',
110
+ properties: {
111
+ answer: { type: 'string' },
112
+ confidence: { type: 'number' },
113
+ sources: {
114
+ type: 'array',
115
+ items: { type: 'string' },
116
+ nullable: true
117
+ }
118
+ },
119
+ required: ['answer', 'confidence']
120
+ };
121
+
122
+ const response = await llmService.chatCompletion(
123
+ messages,
124
+ tenantId,
125
+ systemPrompt,
126
+ {
127
+ responseFormat: 'json_schema',
128
+ responseSchema: schema,
129
+ schemaName: 'question_answer'
130
+ }
131
+ );
132
+
133
+ // Guaranteed to match schema
134
+ const { answer, confidence, sources } = response.parsedContent;
135
+ ```
136
+
137
+ #### Convenience Method
138
+
139
+ For JSON-only responses, use `chatCompletionJson()` to get parsed objects directly:
140
+
141
+ ```javascript
142
+ // Returns parsed object directly (not response wrapper)
143
+ const data = await llmService.chatCompletionJson(
144
+ messages,
145
+ tenantId,
146
+ systemPrompt,
147
+ schema // optional
148
+ );
149
+
150
+ console.log(data.answer); // Direct access to fields
151
+ console.log(data.confidence); // No .parsedContent needed
152
+ ```
153
+
154
+ #### Flexible Call Signatures
155
+
156
+ The `chatCompletion()` method intelligently detects whether you're passing tools, options, or both:
157
+
158
+ ```javascript
159
+ // All these work!
160
+ await chatCompletion(messages, tenant, prompt);
161
+ await chatCompletion(messages, tenant, prompt, tools);
162
+ await chatCompletion(messages, tenant, prompt, { responseFormat: 'json' });
163
+ await chatCompletion(messages, tenant, prompt, tools, { responseFormat: 'json' });
164
+ ```
165
+
166
+ #### Supported Options
167
+
168
+ - `responseFormat`: `'text'` (default), `'json'`, or `'json_schema'`
169
+ - `responseSchema`: JSON schema object (required for `json_schema` mode)
170
+ - `schemaName`: Name for the schema (optional, for `json_schema` mode)
171
+ - `strictSchema`: Enforce strict validation (default: `true`)
172
+ - `autoParse`: Auto-parse JSON responses (default: `true`)
173
+ - `temperature`: Override temperature
174
+ - `maxTokens`: Override max tokens
175
+ - `tier`: Model tier (`'default'`, `'fast'`, `'smart'`)
176
+
177
+
178
+ ## Testing
179
+
180
+ ### Running JSON Mode Tests
181
+
182
+ 1. **Create `.env` file** (copy from `.env.example`):
183
+ ```bash
184
+ cp .env.example .env
185
+ ```
186
+
187
+ 2. **Add your API keys** to `.env`:
188
+ ```ini
189
+ LLM_PROVIDER=openai # or gemini
190
+ OPENAI_API_KEY=sk-your-key-here
191
+ GEMINI_API_KEY=your-gemini-key-here
192
+ ```
193
+
194
+ 3. **Run tests**:
195
+ ```bash
196
+ npm run test:json # Run comprehensive test suite
197
+ npm run examples:json # Run interactive examples
198
+ ```
199
+
200
+ See [TESTING.md](./TESTING.md) for detailed testing documentation.
201
+
202
+
203
+
84
204
  ## Publishing
85
205
 
86
206
  To publish this package to NPM:
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@contentgrowth/llm-service",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Unified LLM Service for Content Growth",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
7
7
  "scripts": {
8
8
  "test": "echo \"Error: no test specified\" && exit 1",
9
- "test:live": "node test-live.js"
9
+ "test:live": "node test-live.js",
10
+ "test:json": "node test-json-mode.js",
11
+ "examples:json": "node examples-json-mode.js",
12
+ "prepublishOnly": "echo '\n📦 Package contents:\n' && npm pack --dry-run && echo '\n⚠️ Review the files above before publishing!\n'"
10
13
  },
11
14
  "author": "Content Growth",
12
15
  "license": "MIT",
@@ -21,6 +21,7 @@ export const MODEL_CONFIGS = {
21
21
  fast: 'gemini-2.5-flash-lite',
22
22
  cost: 'gemini-2.5-flash-lite',
23
23
  free: 'gemini-2.0-flash-lite',
24
+ video: 'veo',
24
25
  },
25
26
  };
26
27
 
@@ -22,8 +22,9 @@ export class BaseLLMProvider {
22
22
  * @param {Array} messages
23
23
  * @param {string} systemPrompt
24
24
  * @param {Array} tools
25
+ * @param {Object} options - Generation options (responseFormat, temperature, etc.)
25
26
  */
26
- async chatCompletion(messages, systemPrompt, tools) {
27
+ async chatCompletion(messages, systemPrompt, tools, options = {}) {
27
28
  throw new Error('Method not implemented');
28
29
  }
29
30
 
@@ -28,24 +28,36 @@ export class GeminiProvider extends BaseLLMProvider {
28
28
  return { text: response.content };
29
29
  }
30
30
 
31
- async chatCompletion(messages, systemPrompt, tools = null) {
31
+ async chatCompletion(messages, systemPrompt, tools = null, options = {}) {
32
32
  return this._chatCompletionWithModel(
33
33
  messages,
34
34
  systemPrompt,
35
35
  tools,
36
36
  this.defaultModel,
37
37
  this.config.maxTokens,
38
- this.config.temperature
38
+ this.config.temperature,
39
+ options
39
40
  );
40
41
  }
41
42
 
42
- async _chatCompletionWithModel(messages, systemPrompt, tools, modelName, maxTokens, temperature) {
43
+ async _chatCompletionWithModel(messages, systemPrompt, tools, modelName, maxTokens, temperature, options = {}) {
43
44
  const modelConfig = {
44
45
  model: modelName,
45
46
  systemInstruction: systemPrompt,
46
47
  tools: tools ? [{ functionDeclarations: tools.map(t => t.function) }] : undefined,
47
48
  };
48
49
 
50
+ // Add JSON mode support for Gemini
51
+ if (options.responseFormat) {
52
+ modelConfig.generationConfig = this._buildGenerationConfig(options, maxTokens, temperature);
53
+ } else if (options.temperature !== undefined || options.maxTokens !== undefined) {
54
+ // Apply temperature/maxTokens overrides even without JSON mode
55
+ modelConfig.generationConfig = {
56
+ temperature: options.temperature ?? temperature,
57
+ maxOutputTokens: options.maxTokens ?? maxTokens,
58
+ };
59
+ }
60
+
49
61
  const model = this.client.getGenerativeModel(modelConfig);
50
62
 
51
63
  // Pre-process messages to handle the 'system' role for Gemini
@@ -117,12 +129,108 @@ export class GeminiProvider extends BaseLLMProvider {
117
129
  const response = result.response;
118
130
  const toolCalls = response.functionCalls();
119
131
 
132
+ // Return with parsed JSON if applicable
120
133
  return {
121
134
  content: response.text(),
122
135
  tool_calls: toolCalls ? toolCalls.map(fc => ({ type: 'function', function: fc })) : null,
136
+ _responseFormat: options.responseFormat,
137
+ ...(options.responseFormat && this._shouldAutoParse(options) ? {
138
+ parsedContent: this._safeJsonParse(response.text())
139
+ } : {})
123
140
  };
124
141
  }
125
142
 
143
+ _buildGenerationConfig(options, maxTokens, temperature) {
144
+ const config = {
145
+ temperature: options.temperature ?? temperature,
146
+ maxOutputTokens: options.maxTokens ?? maxTokens,
147
+ };
148
+
149
+ switch (options.responseFormat) {
150
+ case 'json':
151
+ case 'json_schema':
152
+ config.responseMimeType = 'application/json';
153
+
154
+ if (options.responseSchema) {
155
+ config.responseSchema = this._convertToGeminiSchema(options.responseSchema);
156
+ }
157
+ break;
158
+ }
159
+
160
+ return config;
161
+ }
162
+
163
+ _convertToGeminiSchema(jsonSchema) {
164
+ // SchemaType constants for Gemini schema conversion
165
+ const SchemaType = {
166
+ STRING: 'STRING',
167
+ NUMBER: 'NUMBER',
168
+ INTEGER: 'INTEGER',
169
+ BOOLEAN: 'BOOLEAN',
170
+ ARRAY: 'ARRAY',
171
+ OBJECT: 'OBJECT'
172
+ };
173
+
174
+ const convertType = (type) => {
175
+ switch (type) {
176
+ case 'string': return SchemaType.STRING;
177
+ case 'number': return SchemaType.NUMBER;
178
+ case 'integer': return SchemaType.INTEGER;
179
+ case 'boolean': return SchemaType.BOOLEAN;
180
+ case 'array': return SchemaType.ARRAY;
181
+ case 'object': return SchemaType.OBJECT;
182
+ default: return SchemaType.STRING;
183
+ }
184
+ };
185
+
186
+ const convert = (schema) => {
187
+ const result = {
188
+ type: convertType(schema.type),
189
+ };
190
+
191
+ if (schema.properties) {
192
+ result.properties = {};
193
+ for (const [key, value] of Object.entries(schema.properties)) {
194
+ result.properties[key] = convert(value);
195
+ }
196
+ }
197
+
198
+ if (schema.items) {
199
+ result.items = convert(schema.items);
200
+ }
201
+
202
+ if (schema.required) {
203
+ result.required = schema.required;
204
+ }
205
+
206
+ if (schema.nullable) {
207
+ result.nullable = schema.nullable;
208
+ }
209
+
210
+ if (schema.description) {
211
+ result.description = schema.description;
212
+ }
213
+
214
+ return result;
215
+ };
216
+
217
+ return convert(jsonSchema);
218
+ }
219
+
220
+ _shouldAutoParse(options) {
221
+ return options.autoParse !== false; // Default true
222
+ }
223
+
224
+ _safeJsonParse(content) {
225
+ if (!content) return null;
226
+ try {
227
+ return JSON.parse(content);
228
+ } catch (e) {
229
+ console.warn('[GeminiProvider] Failed to auto-parse JSON response:', e.message);
230
+ return null;
231
+ }
232
+ }
233
+
126
234
  async executeTools(tool_calls, messages, tenantId, toolImplementations, env) {
127
235
  const toolResults = await Promise.all(
128
236
  tool_calls.map(async (toolCall, index) => {
@@ -192,4 +300,48 @@ export class GeminiProvider extends BaseLLMProvider {
192
300
  _getModelForTier(tier) {
193
301
  return this.models[tier] || this.models.default;
194
302
  }
303
+
304
+ async videoGeneration(prompt, images, modelName, systemPrompt, options = {}) {
305
+ const model = this.client.getGenerativeModel({
306
+ model: modelName,
307
+ systemInstruction: systemPrompt,
308
+ });
309
+
310
+ // Prepare image parts
311
+ const imageParts = images.map(img => ({
312
+ inlineData: {
313
+ data: img.data, // Base64 string
314
+ mimeType: img.mimeType
315
+ }
316
+ }));
317
+
318
+ const result = await model.generateContent({
319
+ contents: [{
320
+ role: "user",
321
+ parts: [
322
+ { text: prompt },
323
+ ...imageParts
324
+ ]
325
+ }]
326
+ });
327
+
328
+ const response = result.response;
329
+
330
+ // Check for video attachment/URI in the response
331
+ // This structure depends on the specific API response for Veo
332
+ // Assuming it might return a file URI or a specific part type
333
+
334
+ // Fallback: Return text if no specific video part is found,
335
+ // but try to find a URI in the text if possible.
336
+ const text = response.text();
337
+
338
+ // TODO: Update this once Veo API response structure is fully documented/available
339
+ // For now, we return the text which might contain the URI or status.
340
+
341
+ return {
342
+ content: text,
343
+ // potential video URI extraction
344
+ videoUri: text.match(/https?:\/\/[^\s]+/) ? text.match(/https?:\/\/[^\s]+/)[0] : null
345
+ };
346
+ }
195
347
  }
@@ -27,36 +27,86 @@ export class OpenAIProvider extends BaseLLMProvider {
27
27
  return { text: response.content };
28
28
  }
29
29
 
30
- async chatCompletion(messages, systemPrompt, tools = null) {
30
+ async chatCompletion(messages, systemPrompt, tools = null, options = {}) {
31
31
  return this._chatCompletionWithModel(
32
32
  messages,
33
33
  systemPrompt,
34
34
  tools,
35
35
  this.defaultModel,
36
36
  this.config.maxTokens,
37
- this.config.temperature
37
+ this.config.temperature,
38
+ options
38
39
  );
39
40
  }
40
41
 
41
- async _chatCompletionWithModel(messages, systemPrompt, tools, modelName, maxTokens, temperature) {
42
+ async _chatCompletionWithModel(messages, systemPrompt, tools, modelName, maxTokens, temperature, options = {}) {
42
43
  const requestPayload = {
43
44
  model: modelName,
44
- temperature: temperature,
45
- max_tokens: maxTokens,
45
+ temperature: options.temperature ?? temperature,
46
+ max_tokens: options.maxTokens ?? maxTokens,
46
47
  messages: [{ role: 'system', content: systemPrompt }, ...messages],
47
48
  tools: tools,
48
49
  tool_choice: tools ? 'auto' : undefined,
49
50
  };
50
51
 
52
+ // Add JSON mode support if requested
53
+ if (options.responseFormat) {
54
+ requestPayload.response_format = this._buildResponseFormat(options);
55
+ }
56
+
51
57
  const response = await this.client.chat.completions.create(requestPayload);
52
58
  const message = response.choices[0].message;
53
59
 
60
+ // Return with parsed JSON if applicable
54
61
  return {
55
62
  content: message.content,
56
63
  tool_calls: message.tool_calls,
64
+ // Add metadata about response format
65
+ _responseFormat: options.responseFormat,
66
+ // Auto-parse JSON if requested
67
+ ...(options.responseFormat && this._shouldAutoParse(options) ? {
68
+ parsedContent: this._safeJsonParse(message.content)
69
+ } : {})
57
70
  };
58
71
  }
59
72
 
73
+ _buildResponseFormat(options) {
74
+ switch (options.responseFormat) {
75
+ case 'json':
76
+ return { type: 'json_object' };
77
+
78
+ case 'json_schema':
79
+ if (!options.responseSchema) {
80
+ throw new Error('responseSchema required when using json_schema format');
81
+ }
82
+ return {
83
+ type: 'json_schema',
84
+ json_schema: {
85
+ name: options.schemaName || 'response_schema',
86
+ strict: options.strictSchema ?? true,
87
+ schema: options.responseSchema
88
+ }
89
+ };
90
+
91
+ default:
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ _shouldAutoParse(options) {
97
+ return options.autoParse !== false; // Default true
98
+ }
99
+
100
+ _safeJsonParse(content) {
101
+ if (!content) return null;
102
+ try {
103
+ return JSON.parse(content);
104
+ } catch (e) {
105
+ console.warn('[OpenAIProvider] Failed to auto-parse JSON response:', e.message);
106
+ return null;
107
+ }
108
+ }
109
+
60
110
  async executeTools(tool_calls, messages, tenantId, toolImplementations, env) {
61
111
  const toolResults = await Promise.all(
62
112
  tool_calls.map(async (toolCall) => {
@@ -85,4 +135,8 @@ export class OpenAIProvider extends BaseLLMProvider {
85
135
  _getModelForTier(tier) {
86
136
  return this.models[tier] || this.models.default;
87
137
  }
138
+
139
+ async videoGeneration(prompt, images, modelName, systemPrompt, options = {}) {
140
+ throw new Error('Video generation is not supported by OpenAI provider yet.');
141
+ }
88
142
  }
@@ -60,33 +60,129 @@ export class LLMService {
60
60
 
61
61
  /**
62
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
63
76
  */
64
- async chatCompletion(messages, tenantId, systemPrompt, tools = null) {
77
+ async chatCompletion(messages, tenantId, systemPrompt, toolsOrOptions = null, optionsParam = {}) {
65
78
  const provider = await this._getProvider(tenantId);
66
79
 
67
80
  if (!systemPrompt?.trim()) {
68
81
  throw new LLMServiceException('No prompt set for bot', 503);
69
82
  }
70
83
 
71
- return provider.chatCompletion(messages, systemPrompt, tools);
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 ? 'json_schema' : 'json',
140
+ responseSchema: schema,
141
+ autoParse: true
142
+ };
143
+
144
+ const response = await this.chatCompletion(messages, tenantId, systemPrompt, tools, options);
145
+
146
+ // Return parsed content directly or throw if parsing failed
147
+ if (response.parsedContent !== null && response.parsedContent !== undefined) {
148
+ return response.parsedContent;
149
+ }
150
+
151
+ // Fallback: try to parse the raw content
152
+ try {
153
+ return JSON.parse(response.content);
154
+ } catch (e) {
155
+ throw new LLMServiceException(
156
+ 'LLM returned invalid JSON despite JSON mode being enabled',
157
+ 500,
158
+ { rawContent: response.content, parseError: e.message }
159
+ );
160
+ }
72
161
  }
73
162
 
74
163
  /**
75
164
  * Wrap of chatCompletion to handle toolcalls from LLM.
165
+ * @param {Array} messages - Conversation messages
166
+ * @param {string} tenantId - Tenant identifier
167
+ * @param {string} systemPrompt - System instructions
168
+ * @param {Array} tools - Tools array
169
+ * @param {Object} options - Options object (for responseFormat, etc.)
170
+ * @returns {Object} Response with content, tool_calls, and optionally parsedContent
76
171
  */
77
- async chatWithTools(messages, tenantId, systemPrompt, tools = []) {
172
+ async chatWithTools(messages, tenantId, systemPrompt, tools = [], options = {}) {
78
173
  const provider = await this._getProvider(tenantId);
79
174
 
80
175
  let currentMessages = [...messages];
81
176
 
82
- // Initial call
177
+ // Initial call - pass options to enable JSON mode, etc.
83
178
  const initialResponse = await provider.chatCompletion(
84
179
  currentMessages,
85
180
  systemPrompt,
86
- tools
181
+ tools,
182
+ options
87
183
  );
88
184
 
89
- let { content, tool_calls } = initialResponse;
185
+ let { content, tool_calls, parsedContent } = initialResponse;
90
186
 
91
187
  // Tool execution loop
92
188
  while (tool_calls) {
@@ -96,18 +192,29 @@ export class LLMService {
96
192
  // Execute tools using the provider's helper (which formats results for that provider)
97
193
  await provider.executeTools(tool_calls, currentMessages, tenantId, this.toolImplementations, this.env);
98
194
 
99
- // Next call
195
+ // Next call - also pass options
100
196
  const nextResponse = await provider.chatCompletion(
101
197
  currentMessages,
102
198
  systemPrompt,
103
- tools
199
+ tools,
200
+ options
104
201
  );
105
202
 
106
203
  content = nextResponse.content;
107
204
  tool_calls = nextResponse.tool_calls;
205
+ parsedContent = nextResponse.parsedContent; // Preserve parsedContent from final response
108
206
  }
109
207
 
110
- return { content };
208
+ // Return both content and parsedContent (if available)
209
+ return { content, parsedContent, toolCalls: tool_calls };
210
+ }
211
+
212
+ /**
213
+ * Generate a video
214
+ */
215
+ async videoGeneration(prompt, images, tenantId, modelName, systemPrompt, options = {}) {
216
+ const provider = await this._getProvider(tenantId);
217
+ return provider.videoGeneration(prompt, images, modelName, systemPrompt, options);
111
218
  }
112
219
 
113
220
  /**