@contentgrowth/llm-service 0.3.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 ADDED
@@ -0,0 +1,114 @@
1
+ # @contentgrowth/llm-service
2
+
3
+ Unified LLM Service for Content Growth applications. This package provides a standardized interface for interacting with various LLM providers (OpenAI, Gemini) and supports "Bring Your Own Key" (BYOK) functionality via pluggable configuration.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @contentgrowth/llm-service
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Basic Usage
14
+
15
+ The service requires an environment object (usually from Cloudflare Workers) to access bindings.
16
+
17
+ ```javascript
18
+ import { LLMService } from '@contentgrowth/llm-service';
19
+
20
+ // In your Worker
21
+ export default {
22
+ async fetch(request, env, ctx) {
23
+ const llmService = new LLMService(env);
24
+
25
+ // Chat
26
+ const response = await llmService.chat('Hello, how are you?', 'tenant-id');
27
+ console.log(response.text);
28
+
29
+ // Chat Completion (with system prompt)
30
+ const result = await llmService.chatCompletion(
31
+ [{ role: 'user', content: 'Write a poem' }],
32
+ 'tenant-id',
33
+ 'You are a poetic assistant'
34
+ );
35
+ console.log(result.content);
36
+ }
37
+ }
38
+ ```
39
+
40
+ ### Configuration & BYOK
41
+
42
+ The service uses a `ConfigManager` to determine which LLM provider and API key to use for a given tenant.
43
+
44
+ #### Default Behavior (Cloudflare KV + Durable Objects)
45
+ By default, the service expects the `env` object passed to the constructor to contain:
46
+ - `TENANT_LLM_CONFIG`: A KV Namespace binding.
47
+ - `TENANT_DO`: A Durable Object Namespace binding.
48
+
49
+ It uses these to fetch tenant-specific configurations.
50
+
51
+ #### Custom Configuration (Pluggable Providers)
52
+ If your project stores tenant keys differently (e.g., in a SQL database, environment variables, or a different service), you can implement a custom `ConfigProvider`.
53
+
54
+ ```javascript
55
+ import { LLMService, ConfigManager, BaseConfigProvider } from '@contentgrowth/llm-service';
56
+
57
+ // 1. Define your custom provider
58
+ class MyDatabaseConfigProvider extends BaseConfigProvider {
59
+ async getConfig(tenantId, env) {
60
+ // Fetch config from your database or other source
61
+ // You can use 'env' here if you need access to bindings
62
+ const apiKey = await getApiKeyFromDB(tenantId);
63
+
64
+ return {
65
+ provider: 'openai', // or 'gemini'
66
+ apiKey: apiKey,
67
+ models: {
68
+ default: 'gpt-4o',
69
+ // ... optional overrides
70
+ },
71
+ // Optional capabilities
72
+ capabilities: { chat: true, image: true }
73
+ };
74
+ }
75
+ }
76
+
77
+ // 2. Register the provider at application startup
78
+ ConfigManager.setConfigProvider(new MyDatabaseConfigProvider());
79
+
80
+ // 3. Use LLMService as normal - it will now use your provider
81
+ const service = new LLMService(env);
82
+ ```
83
+
84
+ ## Publishing
85
+
86
+ To publish this package to NPM:
87
+
88
+ 1. **Update Version**:
89
+ Update the `version` in `package.json`.
90
+
91
+ 2. **Login to NPM**:
92
+ ```bash
93
+ npm login
94
+ ```
95
+
96
+ 3. **Publish**:
97
+ ```bash
98
+ # For public access
99
+ npm publish --access public
100
+ ```
101
+
102
+ ## Development
103
+
104
+ ### Directory Structure
105
+ - `src/llm-service.js`: Main service class.
106
+ - `src/llm/config-manager.js`: Configuration resolution logic.
107
+ - `src/llm/config-provider.js`: Abstract provider interfaces.
108
+ - `src/llm/providers/`: Individual LLM provider implementations.
109
+
110
+ ### Testing
111
+ Run the local test script to verify imports and configuration:
112
+ ```bash
113
+ node test-custom-config.js
114
+ ```
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@contentgrowth/llm-service",
3
+ "version": "0.3.0",
4
+ "description": "Unified LLM Service for Content Growth",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "echo \"Error: no test specified\" && exit 1",
9
+ "test:live": "node test-live.js"
10
+ },
11
+ "author": "Content Growth",
12
+ "license": "MIT",
13
+ "dependencies": {
14
+ "@google/generative-ai": "^0.24.1",
15
+ "openai": "^6.9.1"
16
+ },
17
+ "devDependencies": {
18
+ "dotenv": "^17.2.3"
19
+ },
20
+ "files": [
21
+ "src"
22
+ ]
23
+ }
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { LLMService, LLMServiceException } from './llm-service.js';
2
+ export { ConfigManager } from './llm/config-manager.js';
3
+ export { BaseConfigProvider, DefaultConfigProvider } from './llm/config-provider.js';
4
+ export { MODEL_CONFIGS } from './llm/config-manager.js';
5
+ export { OpenAIProvider } from './llm/providers/openai-provider.js';
6
+ export { GeminiProvider } from './llm/providers/gemini-provider.js';
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Manages LLM configuration resolution.
3
+ * Prioritizes Tenant KV config over System Environment config.
4
+ */
5
+
6
+
7
+
8
+ import { DefaultConfigProvider } from './config-provider.js';
9
+
10
+ export const MODEL_CONFIGS = {
11
+ openai: {
12
+ default: 'gpt-4o',
13
+ edge: 'gpt-4o',
14
+ fast: 'gpt-4o-mini',
15
+ cost: 'gpt-4o-mini',
16
+ free: 'gpt-4o-mini',
17
+ },
18
+ gemini: {
19
+ default: 'gemini-2.5-flash',
20
+ edge: 'gemini-2.5-pro',
21
+ fast: 'gemini-2.5-flash-lite',
22
+ cost: 'gemini-2.5-flash-lite',
23
+ free: 'gemini-2.0-flash-lite',
24
+ },
25
+ };
26
+
27
+ export class ConfigManager {
28
+ static _provider = new DefaultConfigProvider();
29
+
30
+ /**
31
+ * Set a custom configuration provider.
32
+ * @param {BaseConfigProvider} provider
33
+ */
34
+ static setConfigProvider(provider) {
35
+ this._provider = provider;
36
+ }
37
+
38
+ static async getConfig(tenantId, env) {
39
+ return this._provider.getConfig(tenantId, env);
40
+ }
41
+ }
42
+
@@ -0,0 +1,138 @@
1
+ import { MODEL_CONFIGS } from './config-manager.js';
2
+
3
+ /**
4
+ * Abstract base class for Config Providers.
5
+ */
6
+ export class BaseConfigProvider {
7
+ /**
8
+ * Retrieve configuration for a specific tenant.
9
+ * @param {string} tenantId
10
+ * @param {Object} env
11
+ * @returns {Promise<Object>} Configuration object
12
+ */
13
+ async getConfig(tenantId, env) {
14
+ throw new Error('Method not implemented');
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Default implementation using Cloudflare KV and Durable Objects.
20
+ */
21
+ export class DefaultConfigProvider extends BaseConfigProvider {
22
+ async getConfig(tenantId, env) {
23
+ if (!tenantId) {
24
+ return this._getSystemConfig(env);
25
+ }
26
+
27
+ // Layer 1: Try KV cache first (1hr TTL)
28
+ const cacheKey = `tenant:${tenantId}:llm_config`;
29
+ const cached = await env.TENANT_LLM_CONFIG?.get(cacheKey);
30
+
31
+ if (cached !== null && cached !== undefined) {
32
+ // Negative cache: empty object means "no BYOK"
33
+ if (cached === '{}') {
34
+ // console.log(`[ConfigManager] KV negative cache hit for tenant ${tenantId}`);
35
+ return this._getSystemConfig(env);
36
+ }
37
+
38
+ const config = JSON.parse(cached);
39
+ // console.log(`[ConfigManager] KV cache hit for tenant ${tenantId}`);
40
+ return this._buildTenantConfig(config, env);
41
+ }
42
+
43
+ // Layer 2: KV miss - load from TenantDO (which loads from D1 if needed)
44
+ // console.log(`[ConfigManager] KV miss, loading from TenantDO for tenant ${tenantId}`);
45
+ const tenantConfig = await this._loadFromTenantDO(tenantId, env);
46
+
47
+ if (tenantConfig && tenantConfig.enabled && tenantConfig.api_key) {
48
+ // Cache the config with 1hr TTL
49
+ if (env.TENANT_LLM_CONFIG) {
50
+ await env.TENANT_LLM_CONFIG.put(
51
+ cacheKey,
52
+ JSON.stringify(tenantConfig),
53
+ { expirationTtl: 3600 } // 1 hour
54
+ );
55
+ }
56
+ // console.log(`[ConfigManager] Using BYOK for tenant ${tenantId} (${tenantConfig.provider})`);
57
+ return this._buildTenantConfig(tenantConfig, env);
58
+ } else {
59
+ // Negative cache: user doesn't have BYOK
60
+ if (env.TENANT_LLM_CONFIG) {
61
+ await env.TENANT_LLM_CONFIG.put(
62
+ cacheKey,
63
+ '{}',
64
+ { expirationTtl: 3600 } // 1 hour
65
+ );
66
+ }
67
+ // console.log(`[ConfigManager] No BYOK for tenant ${tenantId}, using system config`);
68
+ return this._getSystemConfig(env);
69
+ }
70
+ }
71
+
72
+ async _loadFromTenantDO(tenantId, env) {
73
+ try {
74
+ if (!env.TENANT_DO) return null;
75
+
76
+ const doId = env.TENANT_DO.idFromName(tenantId);
77
+ const stub = env.TENANT_DO.get(doId);
78
+ const response = await stub.fetch(new Request(`http://do/llm-config/${tenantId}`));
79
+
80
+ if (response.ok) {
81
+ const config = await response.json();
82
+ return config; // May be null if no config
83
+ }
84
+ return null;
85
+ } catch (error) {
86
+ console.error(`[ConfigManager] Failed to load from TenantDO for tenant ${tenantId}:`, error);
87
+ return null;
88
+ }
89
+ }
90
+
91
+ _buildTenantConfig(tenantConfig, env) {
92
+ return {
93
+ provider: tenantConfig.provider,
94
+ apiKey: tenantConfig.api_key,
95
+ models: MODEL_CONFIGS[tenantConfig.provider],
96
+ temperature: parseFloat(env.DEFAULT_TEMPERATURE || '0.7'),
97
+ maxTokens: parseInt(env.DEFAULT_MAX_TOKENS || '4096'),
98
+ capabilities: tenantConfig.capabilities || { chat: true, image: false, video: false },
99
+ isTenantOwned: true
100
+ };
101
+ }
102
+
103
+ _getSystemConfig(env) {
104
+ const provider = env.LLM_PROVIDER?.toLowerCase() || 'openai';
105
+ const providerDefaults = MODEL_CONFIGS[provider] || MODEL_CONFIGS['openai'];
106
+
107
+ let apiKey;
108
+ let models = { ...providerDefaults };
109
+
110
+ if (provider === 'openai') {
111
+ apiKey = env.OPENAI_API_KEY;
112
+ models = {
113
+ default: env.OPENAI_MODEL || providerDefaults.default,
114
+ edge: env.OPENAI_MODEL_EDGE || providerDefaults.edge,
115
+ fast: env.OPENAI_MODEL_FAST || providerDefaults.fast,
116
+ cost: env.OPENAI_MODEL_COST || providerDefaults.cost,
117
+ free: env.OPENAI_MODEL_FREE || providerDefaults.free,
118
+ };
119
+ } else if (provider === 'gemini') {
120
+ apiKey = env.GEMINI_API_KEY;
121
+ models = {
122
+ default: env.GEMINI_MODEL || providerDefaults.default,
123
+ edge: env.GEMINI_MODEL_EDGE || providerDefaults.edge,
124
+ fast: env.GEMINI_MODEL_FAST || providerDefaults.fast,
125
+ cost: env.GEMINI_MODEL_COST || providerDefaults.cost,
126
+ free: env.GEMINI_MODEL_FREE || providerDefaults.free,
127
+ };
128
+ }
129
+
130
+ return {
131
+ provider,
132
+ apiKey,
133
+ models,
134
+ temperature: parseFloat(env.DEFAULT_TEMPERATURE || '0.7'),
135
+ maxTokens: parseInt(env.DEFAULT_MAX_TOKENS || '4096'),
136
+ };
137
+ }
138
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Abstract base class for LLM Providers.
3
+ * Defines the standard interface that all providers must implement.
4
+ */
5
+ export class BaseLLMProvider {
6
+ constructor(config) {
7
+ this.config = config;
8
+ }
9
+
10
+ /**
11
+ * Simple chat interface for single-turn conversations
12
+ * @param {string} userMessage
13
+ * @param {string} systemPrompt
14
+ * @param {Object} options
15
+ */
16
+ async chat(userMessage, systemPrompt, options) {
17
+ throw new Error('Method not implemented');
18
+ }
19
+
20
+ /**
21
+ * Advanced chat completion with tool support
22
+ * @param {Array} messages
23
+ * @param {string} systemPrompt
24
+ * @param {Array} tools
25
+ */
26
+ async chatCompletion(messages, systemPrompt, tools) {
27
+ throw new Error('Method not implemented');
28
+ }
29
+
30
+ /**
31
+ * Execute tools requested by the LLM
32
+ * @param {Array} toolCalls
33
+ * @param {Array} messages
34
+ * @param {string} tenantId
35
+ * @param {Object} toolImplementations
36
+ * @param {Object} env
37
+ */
38
+ async executeTools(toolCalls, messages, tenantId, toolImplementations, env) {
39
+ throw new Error('Method not implemented');
40
+ }
41
+
42
+ /**
43
+ * Generate image (optional support)
44
+ */
45
+ async imageGeneration(prompt, modelName, systemPrompt, options) {
46
+ throw new Error('Image generation not supported by this provider');
47
+ }
48
+ }
@@ -0,0 +1,195 @@
1
+ import { GoogleGenerativeAI } from '@google/generative-ai';
2
+ import { BaseLLMProvider } from './base-provider.js';
3
+ import { LLMServiceException } from '../../llm-service.js';
4
+
5
+ export class GeminiProvider extends BaseLLMProvider {
6
+ constructor(config) {
7
+ super(config);
8
+ this.client = new GoogleGenerativeAI(config.apiKey);
9
+ this.models = config.models;
10
+ this.defaultModel = config.models.default;
11
+ }
12
+
13
+ async chat(userMessage, systemPrompt = '', options = {}) {
14
+ const messages = [{ role: 'user', content: userMessage }];
15
+ const tier = options.tier || 'default';
16
+ const effectiveModel = this._getModelForTier(tier);
17
+ const effectiveMaxTokens = options.maxTokens || this.config.maxTokens;
18
+ const effectiveTemperature = options.temperature !== undefined ? options.temperature : this.config.temperature;
19
+
20
+ const response = await this._chatCompletionWithModel(
21
+ messages,
22
+ systemPrompt,
23
+ null,
24
+ effectiveModel,
25
+ effectiveMaxTokens,
26
+ effectiveTemperature
27
+ );
28
+ return { text: response.content };
29
+ }
30
+
31
+ async chatCompletion(messages, systemPrompt, tools = null) {
32
+ return this._chatCompletionWithModel(
33
+ messages,
34
+ systemPrompt,
35
+ tools,
36
+ this.defaultModel,
37
+ this.config.maxTokens,
38
+ this.config.temperature
39
+ );
40
+ }
41
+
42
+ async _chatCompletionWithModel(messages, systemPrompt, tools, modelName, maxTokens, temperature) {
43
+ const modelConfig = {
44
+ model: modelName,
45
+ systemInstruction: systemPrompt,
46
+ tools: tools ? [{ functionDeclarations: tools.map(t => t.function) }] : undefined,
47
+ };
48
+
49
+ const model = this.client.getGenerativeModel(modelConfig);
50
+
51
+ // Pre-process messages to handle the 'system' role for Gemini
52
+ const geminiMessages = [];
53
+ let systemContentBuffer = [];
54
+
55
+ for (const msg of messages) {
56
+ if (msg.role === 'system') {
57
+ systemContentBuffer.push(msg.content);
58
+ } else {
59
+ if (msg.role === 'user' && systemContentBuffer.length > 0) {
60
+ const fullContent = `${systemContentBuffer.join('\n')}\n\n${msg.content}`;
61
+ geminiMessages.push({ ...msg, content: fullContent });
62
+ systemContentBuffer = [];
63
+ } else {
64
+ geminiMessages.push(msg);
65
+ }
66
+ }
67
+ }
68
+
69
+ const history = geminiMessages.map((msg, index) => {
70
+ let role = '';
71
+ let parts;
72
+
73
+ switch (msg.role) {
74
+ case 'user':
75
+ role = 'user';
76
+ parts = [{ text: msg.content }];
77
+ break;
78
+ case 'assistant':
79
+ role = 'model';
80
+ if (msg.tool_calls) {
81
+ parts = msg.tool_calls.map(tc => ({
82
+ functionCall: { name: tc.function.name, args: tc.function.arguments }
83
+ }));
84
+ } else {
85
+ parts = [{ text: msg.content || '' }];
86
+ }
87
+ break;
88
+ case 'tool':
89
+ role = 'function';
90
+ const preceding_message = messages[index - 1];
91
+ const tool_call = preceding_message?.tool_calls?.find(tc => tc.id === msg.tool_call_id);
92
+ parts = [{
93
+ functionResponse: {
94
+ name: tool_call?.function?.name || 'unknown_tool',
95
+ response: { content: msg.content },
96
+ }
97
+ }];
98
+ break;
99
+ default:
100
+ return null;
101
+ }
102
+ return { role, parts };
103
+ }).filter(Boolean);
104
+
105
+ while (history.length > 0 && history[0].role !== 'user') {
106
+ history.shift();
107
+ }
108
+
109
+ if (history.length === 0) {
110
+ throw new LLMServiceException('Cannot process a conversation with no user messages.', 400);
111
+ }
112
+
113
+ const lastMessage = history.pop();
114
+ const chat = model.startChat({ history });
115
+
116
+ const result = await chat.sendMessage(lastMessage.parts);
117
+ const response = result.response;
118
+ const toolCalls = response.functionCalls();
119
+
120
+ return {
121
+ content: response.text(),
122
+ tool_calls: toolCalls ? toolCalls.map(fc => ({ type: 'function', function: fc })) : null,
123
+ };
124
+ }
125
+
126
+ async executeTools(tool_calls, messages, tenantId, toolImplementations, env) {
127
+ const toolResults = await Promise.all(
128
+ tool_calls.map(async (toolCall, index) => {
129
+ const toolName = toolCall.function.name;
130
+ const tool = toolImplementations[toolName];
131
+ const tool_call_id = `gemini-tool-call-${index}`;
132
+ toolCall.id = tool_call_id;
133
+
134
+ console.log(`[Tool Call] ${toolName} with arguments:`, toolCall.function.args);
135
+
136
+ if (!tool) {
137
+ console.error(`[Tool Error] Tool '${toolName}' not found`);
138
+ return { tool_call_id, output: JSON.stringify({ error: `Tool '${toolName}' not found.` }) };
139
+ }
140
+ try {
141
+ const output = await tool(toolCall.function.args, { env, tenantId });
142
+ console.log(`[Tool Result] ${toolName} returned:`, output.substring(0, 200) + (output.length > 200 ? '...' : ''));
143
+ return { tool_call_id, output };
144
+ } catch (error) {
145
+ console.error(`[Tool Error] ${toolName} failed:`, error.message);
146
+ return { tool_call_id, output: JSON.stringify({ error: `Error executing tool '${toolName}': ${error.message}` }) };
147
+ }
148
+ })
149
+ );
150
+ toolResults.forEach(result => messages.push({ role: 'tool', tool_call_id: result.tool_call_id, content: result.output }));
151
+ }
152
+
153
+ async imageGeneration(prompt, modelName, systemPrompt, options = {}) {
154
+ const model = this.client.getGenerativeModel({
155
+ model: modelName,
156
+ systemInstruction: systemPrompt,
157
+ });
158
+
159
+ const generationConfig = {
160
+ responseModalities: ["IMAGE"],
161
+ };
162
+
163
+ if (options.aspectRatio) {
164
+ generationConfig.imageConfig = {
165
+ aspectRatio: options.aspectRatio
166
+ };
167
+ }
168
+
169
+ const result = await model.generateContent({
170
+ contents: [{
171
+ role: "user",
172
+ parts: [{ text: prompt }]
173
+ }],
174
+ generationConfig
175
+ });
176
+
177
+ const response = result.response;
178
+ const imagePart = response.candidates?.[0]?.content?.parts?.find(
179
+ part => part.inlineData && part.inlineData.mimeType?.startsWith('image/')
180
+ );
181
+
182
+ if (!imagePart || !imagePart.inlineData) {
183
+ throw new Error('No image data in response');
184
+ }
185
+
186
+ return {
187
+ imageData: imagePart.inlineData.data,
188
+ mimeType: imagePart.inlineData.mimeType,
189
+ };
190
+ }
191
+
192
+ _getModelForTier(tier) {
193
+ return this.models[tier] || this.models.default;
194
+ }
195
+ }
@@ -0,0 +1,88 @@
1
+ import OpenAI from 'openai';
2
+ import { BaseLLMProvider } from './base-provider.js';
3
+
4
+ export class OpenAIProvider extends BaseLLMProvider {
5
+ constructor(config) {
6
+ super(config);
7
+ this.client = new OpenAI({ apiKey: config.apiKey });
8
+ this.models = config.models;
9
+ this.defaultModel = config.models.default;
10
+ }
11
+
12
+ async chat(userMessage, systemPrompt = '', options = {}) {
13
+ const messages = [{ role: 'user', content: userMessage }];
14
+ const tier = options.tier || 'default';
15
+ const effectiveModel = this._getModelForTier(tier);
16
+ const effectiveMaxTokens = options.maxTokens || this.config.maxTokens;
17
+ const effectiveTemperature = options.temperature !== undefined ? options.temperature : this.config.temperature;
18
+
19
+ const response = await this._chatCompletionWithModel(
20
+ messages,
21
+ systemPrompt,
22
+ null,
23
+ effectiveModel,
24
+ effectiveMaxTokens,
25
+ effectiveTemperature
26
+ );
27
+ return { text: response.content };
28
+ }
29
+
30
+ async chatCompletion(messages, systemPrompt, tools = null) {
31
+ return this._chatCompletionWithModel(
32
+ messages,
33
+ systemPrompt,
34
+ tools,
35
+ this.defaultModel,
36
+ this.config.maxTokens,
37
+ this.config.temperature
38
+ );
39
+ }
40
+
41
+ async _chatCompletionWithModel(messages, systemPrompt, tools, modelName, maxTokens, temperature) {
42
+ const requestPayload = {
43
+ model: modelName,
44
+ temperature: temperature,
45
+ max_tokens: maxTokens,
46
+ messages: [{ role: 'system', content: systemPrompt }, ...messages],
47
+ tools: tools,
48
+ tool_choice: tools ? 'auto' : undefined,
49
+ };
50
+
51
+ const response = await this.client.chat.completions.create(requestPayload);
52
+ const message = response.choices[0].message;
53
+
54
+ return {
55
+ content: message.content,
56
+ tool_calls: message.tool_calls,
57
+ };
58
+ }
59
+
60
+ async executeTools(tool_calls, messages, tenantId, toolImplementations, env) {
61
+ const toolResults = await Promise.all(
62
+ tool_calls.map(async (toolCall) => {
63
+ const toolName = toolCall.function.name;
64
+ const tool = toolImplementations[toolName];
65
+
66
+ console.log(`[Tool Call] ${toolName} with arguments:`, toolCall.function.arguments);
67
+
68
+ if (!tool) {
69
+ console.error(`[Tool Error] Tool '${toolName}' not found`);
70
+ return { tool_call_id: toolCall.id, output: JSON.stringify({ error: `Tool '${toolName}' not found.` }) };
71
+ }
72
+ try {
73
+ const output = await tool(toolCall.function.arguments, { env, tenantId });
74
+ console.log(`[Tool Result] ${toolName} returned:`, output.substring(0, 200) + (output.length > 200 ? '...' : ''));
75
+ return { tool_call_id: toolCall.id, output };
76
+ } catch (error) {
77
+ console.error(`[Tool Error] ${toolName} failed:`, error.message);
78
+ return { tool_call_id: toolCall.id, output: JSON.stringify({ error: `Error executing tool '${toolName}': ${error.message}` }) };
79
+ }
80
+ })
81
+ );
82
+ toolResults.forEach(result => messages.push({ role: 'tool', tool_call_id: result.tool_call_id, content: result.output }));
83
+ }
84
+
85
+ _getModelForTier(tier) {
86
+ return this.models[tier] || this.models.default;
87
+ }
88
+ }
@@ -0,0 +1,139 @@
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
+ async chatCompletion(messages, tenantId, systemPrompt, tools = null) {
65
+ const provider = await this._getProvider(tenantId);
66
+
67
+ if (!systemPrompt?.trim()) {
68
+ throw new LLMServiceException('No prompt set for bot', 503);
69
+ }
70
+
71
+ return provider.chatCompletion(messages, systemPrompt, tools);
72
+ }
73
+
74
+ /**
75
+ * Wrap of chatCompletion to handle toolcalls from LLM.
76
+ */
77
+ async chatWithTools(messages, tenantId, systemPrompt, tools = []) {
78
+ const provider = await this._getProvider(tenantId);
79
+
80
+ let currentMessages = [...messages];
81
+
82
+ // Initial call
83
+ const initialResponse = await provider.chatCompletion(
84
+ currentMessages,
85
+ systemPrompt,
86
+ tools
87
+ );
88
+
89
+ let { content, tool_calls } = initialResponse;
90
+
91
+ // Tool execution loop
92
+ while (tool_calls) {
93
+ console.log('[Tool Call] Assistant wants to use tools:', tool_calls);
94
+ currentMessages.push({ role: 'assistant', content: content || '', tool_calls });
95
+
96
+ // Execute tools using the provider's helper (which formats results for that provider)
97
+ await provider.executeTools(tool_calls, currentMessages, tenantId, this.toolImplementations, this.env);
98
+
99
+ // Next call
100
+ const nextResponse = await provider.chatCompletion(
101
+ currentMessages,
102
+ systemPrompt,
103
+ tools
104
+ );
105
+
106
+ content = nextResponse.content;
107
+ tool_calls = nextResponse.tool_calls;
108
+ }
109
+
110
+ return { content };
111
+ }
112
+
113
+ /**
114
+ * Generate an image
115
+ * Falls back to system keys if tenant doesn't have image capability enabled
116
+ */
117
+ async imageGeneration(prompt, tenantId, modelName, systemPrompt, options = {}) {
118
+ // Check if tenant has image capability enabled
119
+ if (tenantId) {
120
+ const config = await ConfigManager.getConfig(tenantId, this.env);
121
+ if (config.isTenantOwned && !config.capabilities?.image) {
122
+ console.log(`[LLMService] Tenant ${tenantId} BYOK doesn't have image capability. Using system keys.`);
123
+ tenantId = null; // Fall back to system
124
+ }
125
+ }
126
+
127
+ const provider = await this._getProvider(tenantId);
128
+ return provider.imageGeneration(prompt, modelName, systemPrompt, options);
129
+ }
130
+ }
131
+
132
+ export class LLMServiceException extends Error {
133
+ constructor(message, statusCode = 500, details = null) {
134
+ super(message);
135
+ this.name = 'LLMServiceException';
136
+ this.statusCode = statusCode;
137
+ this.details = details || {};
138
+ }
139
+ }