@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 +114 -0
- package/package.json +23 -0
- package/src/index.js +6 -0
- package/src/llm/config-manager.js +42 -0
- package/src/llm/config-provider.js +138 -0
- package/src/llm/providers/base-provider.js +48 -0
- package/src/llm/providers/gemini-provider.js +195 -0
- package/src/llm/providers/openai-provider.js +88 -0
- package/src/llm-service.js +139 -0
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
|
+
}
|