@tiledesk/tiledesk-server 2.24.2 → 2.25.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/CHANGELOG.md +5 -0
- package/config/kb/situatedContext.js +37 -2
- package/package.json +2 -2
- package/routes/kb.js +5 -1
- package/routes/llm.js +27 -11
- package/services/aiManager.js +57 -19
- package/test/aiManager.test.js +189 -0
- package/test/kbRoute.js +181 -0
- package/test/openrouterUtils.test.js +139 -0
- package/utils/openrouterUtils.js +89 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
🚀 IN PRODUCTION 🚀
|
|
6
6
|
(https://www.npmjs.com/package/@tiledesk/tiledesk-server/v/2.3.77)
|
|
7
7
|
|
|
8
|
+
# 2.25.0
|
|
9
|
+
- Added support for OpenRouter
|
|
10
|
+
- Added support for Gemini Agent Platform
|
|
11
|
+
- Updated tybot-connector to 2.1.15
|
|
12
|
+
|
|
8
13
|
# 2.24.2
|
|
9
14
|
- Fixed bug recalling webhooks
|
|
10
15
|
|
|
@@ -1,6 +1,41 @@
|
|
|
1
|
+
const winston = require('../winston');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Provider routing for contextual retrieval, used only when the provider is
|
|
5
|
+
* openrouter. Unlike the chat path, the provider here is operator-wide rather
|
|
6
|
+
* than per project, so the routing comes from the environment too:
|
|
7
|
+
*
|
|
8
|
+
* SITUATED_CONTEXT_PROVIDER=openrouter
|
|
9
|
+
* SITUATED_CONTEXT_PROVIDER_ROUTING={"order":["azure","openai"],"sort":"price"}
|
|
10
|
+
*
|
|
11
|
+
* Invalid JSON is ignored rather than fatal: a malformed variable must not stop
|
|
12
|
+
* indexing, it just falls back to OpenRouter's own routing.
|
|
13
|
+
*/
|
|
14
|
+
function parseProviderRouting() {
|
|
15
|
+
const raw = process.env.SITUATED_CONTEXT_PROVIDER_ROUTING;
|
|
16
|
+
if (!raw) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(raw);
|
|
22
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
23
|
+
winston.warn("SITUATED_CONTEXT_PROVIDER_ROUTING is not a JSON object, ignoring it");
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return parsed;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
winston.warn("SITUATED_CONTEXT_PROVIDER_ROUTING is not valid JSON, ignoring it: " + err.message);
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const provider_routing = parseProviderRouting();
|
|
34
|
+
|
|
1
35
|
module.exports = {
|
|
2
36
|
enable: process.env.SITUATED_CONTEXT_ENABLE === "true",
|
|
3
37
|
provider: process.env.SITUATED_CONTEXT_PROVIDER || "openai",
|
|
4
38
|
model: process.env.SITUATED_CONTEXT_MODEL || "gpt-5.4-nano",
|
|
5
|
-
api_key: ""
|
|
6
|
-
}
|
|
39
|
+
api_key: "",
|
|
40
|
+
...(provider_routing && { provider_routing })
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiledesk/tiledesk-server",
|
|
3
3
|
"description": "The Tiledesk server module",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.25.0",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"start": "node ./bin/www",
|
|
7
7
|
"pretest": "mongodb-runner start",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"@tiledesk/tiledesk-rasa-connector": "^1.0.10",
|
|
50
50
|
"@tiledesk/tiledesk-sms-connector": "^0.1.13",
|
|
51
51
|
"@tiledesk/tiledesk-telegram-connector": "^0.1.14",
|
|
52
|
-
"@tiledesk/tiledesk-tybot-connector": "^2.1.
|
|
52
|
+
"@tiledesk/tiledesk-tybot-connector": "^2.1.15",
|
|
53
53
|
"@tiledesk/tiledesk-voice-twilio-connector": "^0.3.2",
|
|
54
54
|
"@tiledesk/tiledesk-vxml-connector": "^0.1.91",
|
|
55
55
|
"@tiledesk/tiledesk-whatsapp-connector": "1.0.26",
|
package/routes/kb.js
CHANGED
|
@@ -388,7 +388,7 @@ router.post('/qa', async (req, res) => {
|
|
|
388
388
|
|
|
389
389
|
let model;
|
|
390
390
|
try {
|
|
391
|
-
model = await aiManager.resolveLLMConfig(id_project, data.llm, data.model, data.vllmServer);
|
|
391
|
+
model = await aiManager.resolveLLMConfig(id_project, data.llm, data.model, data.llmServer || data.vllmServer);
|
|
392
392
|
} catch (err) {
|
|
393
393
|
let errorCode = err?.code ?? 500;
|
|
394
394
|
return res.status(errorCode).send({ success: false, error: err.error });
|
|
@@ -424,6 +424,10 @@ router.post('/qa', async (req, res) => {
|
|
|
424
424
|
data.engine = namespace.engine || default_engine;
|
|
425
425
|
data.embedding = aiManager.normalizeEmbedding(namespace.embedding);
|
|
426
426
|
|
|
427
|
+
if (data.llm === 'agentplatform') {
|
|
428
|
+
data.llm = "google";
|
|
429
|
+
}
|
|
430
|
+
|
|
427
431
|
if (namespace.hybrid === true) {
|
|
428
432
|
data.search_type = 'hybrid';
|
|
429
433
|
|
package/routes/llm.js
CHANGED
|
@@ -27,16 +27,25 @@ router.post('/preview', async (req, res) => {
|
|
|
27
27
|
let key;
|
|
28
28
|
let publicKey = false;
|
|
29
29
|
let integration;
|
|
30
|
-
let
|
|
30
|
+
let llmConfig;
|
|
31
|
+
let openrouterConfig;
|
|
32
|
+
const multiServerProviders = ['vllm', 'agentplatform'];
|
|
31
33
|
|
|
32
34
|
if (!body.llm) {
|
|
33
35
|
return res.status(400).send({ success: false, error: "Missing required parameter 'llm'" });
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
if (body.llm
|
|
38
|
+
if (multiServerProviders.includes(body.llm)) {
|
|
37
39
|
try {
|
|
38
|
-
|
|
39
|
-
key =
|
|
40
|
+
llmConfig = await aiManager.resolveLLMConfig(id_project, body.llm, body.model, body.llmServer || body.vllmServer);
|
|
41
|
+
key = llmConfig.api_key;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return res.status(err.code ?? 500).send({ success: false, error: err.error });
|
|
44
|
+
}
|
|
45
|
+
} else if (body.llm === 'openrouter') {
|
|
46
|
+
try {
|
|
47
|
+
openrouterConfig = await aiManager.resolveLLMConfig(id_project, body.llm, body.model);
|
|
48
|
+
key = openrouterConfig.api_key;
|
|
40
49
|
} catch (err) {
|
|
41
50
|
return res.status(err.code ?? 500).send({ success: false, error: err.error });
|
|
42
51
|
}
|
|
@@ -102,14 +111,21 @@ router.post('/preview', async (req, res) => {
|
|
|
102
111
|
json.stream = false;
|
|
103
112
|
}
|
|
104
113
|
|
|
105
|
-
if (body.llm
|
|
114
|
+
if (multiServerProviders.includes(body.llm)) {
|
|
106
115
|
json.llm_key = key || "";
|
|
107
|
-
json.model =
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
116
|
+
json.model = llmConfig;
|
|
117
|
+
json.stream = false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (body.llm === 'agentplatform') {
|
|
121
|
+
json.llm = "google";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (body.llm === 'openrouter') {
|
|
125
|
+
// The object form carries provider_routing, which decides the upstream
|
|
126
|
+
// provider; sending the bare model name would silently drop it.
|
|
127
|
+
json.llm_key = key || "";
|
|
128
|
+
json.model = openrouterConfig;
|
|
113
129
|
json.stream = false;
|
|
114
130
|
}
|
|
115
131
|
|
package/services/aiManager.js
CHANGED
|
@@ -20,6 +20,7 @@ const default_engine = require('../config/kb/engine');
|
|
|
20
20
|
const default_engine_hybrid = require('../config/kb/engine.hybrid');
|
|
21
21
|
const default_embedding = require('../config/kb/embedding');
|
|
22
22
|
const integrationService = require('./integrationService');
|
|
23
|
+
const { buildOpenRouterModel } = require('../utils/openrouterUtils');
|
|
23
24
|
const situatedContext = require('../config/kb/situatedContext');
|
|
24
25
|
const { MODELS_MULTIPLIER } = require('../utils/aiUtils');
|
|
25
26
|
|
|
@@ -251,33 +252,29 @@ class AiManager {
|
|
|
251
252
|
})
|
|
252
253
|
}
|
|
253
254
|
|
|
254
|
-
async resolveLLMConfig(id_project, provider = 'openai', model,
|
|
255
|
+
async resolveLLMConfig(id_project, provider = 'openai', model, llmServer = undefined) {
|
|
256
|
+
const selfHostedProviders = ['ollama', 'vllm', 'agentplatform'];
|
|
255
257
|
|
|
256
|
-
if (provider === '
|
|
258
|
+
if (provider === 'openrouter') {
|
|
259
|
+
const integration = await integrationService.getIntegration(id_project, provider);
|
|
260
|
+
if (!integration?.value?.apikey) {
|
|
261
|
+
throw { code: 422, error: "The key provided for openrouter is not valid or undefined" };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return buildOpenRouterModel(integration.value, model);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (selfHostedProviders.includes(provider)) {
|
|
257
268
|
const integration = await integrationService.getIntegration(id_project, provider);
|
|
258
269
|
if (!integration?.value) {
|
|
259
270
|
throw { code: 422, error: `${provider} integration not found` };
|
|
260
271
|
}
|
|
261
272
|
|
|
262
273
|
const value = integration.value;
|
|
274
|
+
const usesServerList = provider === 'agentplatform' || (provider === 'vllm' && Array.isArray(value.servers));
|
|
263
275
|
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
throw { code: 422, error: "vllmServer attribute is undefined" };
|
|
267
|
-
}
|
|
268
|
-
const server = value.servers.find(s => s.name === vllmServer);
|
|
269
|
-
if (!server) {
|
|
270
|
-
throw { code: 422, error: `vllm server '${vllmServer}' not found` };
|
|
271
|
-
}
|
|
272
|
-
if (!server.url) {
|
|
273
|
-
throw { code: 422, error: "Server url for vllm is empty or invalid" };
|
|
274
|
-
}
|
|
275
|
-
return {
|
|
276
|
-
provider,
|
|
277
|
-
name: model,
|
|
278
|
-
url: server.url,
|
|
279
|
-
api_key: server.apikey || ""
|
|
280
|
-
};
|
|
276
|
+
if (usesServerList) {
|
|
277
|
+
return this._resolveMultiServerConfig(provider, model, value, llmServer);
|
|
281
278
|
}
|
|
282
279
|
|
|
283
280
|
if (!value.url) {
|
|
@@ -301,6 +298,47 @@ class AiManager {
|
|
|
301
298
|
};
|
|
302
299
|
}
|
|
303
300
|
|
|
301
|
+
_resolveMultiServerConfig(provider, model, value, llmServer) {
|
|
302
|
+
if (!llmServer) {
|
|
303
|
+
throw { code: 422, error: "llmServer attribute is undefined" };
|
|
304
|
+
}
|
|
305
|
+
if (!Array.isArray(value.servers)) {
|
|
306
|
+
throw { code: 422, error: `${provider} servers list is missing or invalid` };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const server = value.servers.find(s => s.name === llmServer);
|
|
310
|
+
if (!server) {
|
|
311
|
+
throw { code: 422, error: `${provider} server '${llmServer}' not found` };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (provider === 'agentplatform') {
|
|
315
|
+
if (!server.project) {
|
|
316
|
+
throw { code: 422, error: "Project for agentplatform is empty or invalid" };
|
|
317
|
+
}
|
|
318
|
+
if (!server.location) {
|
|
319
|
+
throw { code: 422, error: "Location for agentplatform is empty or invalid" };
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
provider: 'google',
|
|
323
|
+
name: model,
|
|
324
|
+
api_key: server.apikey || "",
|
|
325
|
+
project: server.project,
|
|
326
|
+
location: server.location
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!server.url) {
|
|
331
|
+
throw { code: 422, error: `Server url for ${provider} is empty or invalid` };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
provider,
|
|
336
|
+
name: model,
|
|
337
|
+
url: server.url,
|
|
338
|
+
api_key: server.apikey || ""
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
304
342
|
async checkQuotaAvailability(quoteManager, project, ncontents) {
|
|
305
343
|
|
|
306
344
|
return new Promise( async (resolve, reject) => {
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
process.env.NODE_ENV = 'test';
|
|
2
|
+
process.env.LOG_LEVEL = 'critical';
|
|
3
|
+
|
|
4
|
+
const { expect } = require('chai');
|
|
5
|
+
const aiManager = require('../services/aiManager');
|
|
6
|
+
const integrationService = require('../services/integrationService');
|
|
7
|
+
|
|
8
|
+
describe('AiManager.resolveLLMConfig - agentplatform', function () {
|
|
9
|
+
|
|
10
|
+
const projectId = 'agentplatform-test-project';
|
|
11
|
+
const originalGetIntegration = integrationService.getIntegration.bind(integrationService);
|
|
12
|
+
|
|
13
|
+
const validServer = {
|
|
14
|
+
name: 'prod-vertex',
|
|
15
|
+
apikey: 'AQ.Ab8R-testkey',
|
|
16
|
+
project: 'poc-tiledesk-496310',
|
|
17
|
+
location: 'europe-west8'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
afterEach(function () {
|
|
21
|
+
integrationService.getIntegration = originalGetIntegration;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function stubIntegration(value) {
|
|
25
|
+
integrationService.getIntegration = async function () {
|
|
26
|
+
if (value === null) {
|
|
27
|
+
throw { code: 404, error: `Integration agentplatform not found for project ${projectId}` };
|
|
28
|
+
}
|
|
29
|
+
return { id_project: projectId, name: 'agentplatform', value };
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
it('returns google provider config from the named server', async function () {
|
|
34
|
+
stubIntegration({ servers: [validServer] });
|
|
35
|
+
|
|
36
|
+
const llmConfig = await aiManager.resolveLLMConfig(
|
|
37
|
+
projectId,
|
|
38
|
+
'agentplatform',
|
|
39
|
+
'google/gemini-2.5-flash-lite',
|
|
40
|
+
'prod-vertex'
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
expect(llmConfig).to.deep.equal({
|
|
44
|
+
provider: 'google',
|
|
45
|
+
name: 'google/gemini-2.5-flash-lite',
|
|
46
|
+
api_key: 'AQ.Ab8R-testkey',
|
|
47
|
+
project: 'poc-tiledesk-496310',
|
|
48
|
+
location: 'europe-west8'
|
|
49
|
+
});
|
|
50
|
+
expect(llmConfig).to.not.have.property('url');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('maps agentplatform provider to google', async function () {
|
|
54
|
+
stubIntegration({ servers: [validServer] });
|
|
55
|
+
|
|
56
|
+
const llmConfig = await aiManager.resolveLLMConfig(
|
|
57
|
+
projectId,
|
|
58
|
+
'agentplatform',
|
|
59
|
+
'google/gemini-2.5-flash-lite',
|
|
60
|
+
'prod-vertex'
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(llmConfig.provider).to.equal('google');
|
|
64
|
+
expect(llmConfig.provider).to.not.equal('agentplatform');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('uses an empty api_key when the server has no apikey', async function () {
|
|
68
|
+
stubIntegration({
|
|
69
|
+
servers: [{
|
|
70
|
+
name: 'no-apikey',
|
|
71
|
+
project: 'poc-tiledesk-496310',
|
|
72
|
+
location: 'europe-west8'
|
|
73
|
+
}]
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const llmConfig = await aiManager.resolveLLMConfig(
|
|
77
|
+
projectId,
|
|
78
|
+
'agentplatform',
|
|
79
|
+
'google/gemini-2.5-flash-lite',
|
|
80
|
+
'no-apikey'
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(llmConfig.api_key).to.equal('');
|
|
84
|
+
expect(llmConfig.project).to.equal('poc-tiledesk-496310');
|
|
85
|
+
expect(llmConfig.location).to.equal('europe-west8');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('throws 422 when llmServer is missing', async function () {
|
|
89
|
+
stubIntegration({ servers: [validServer] });
|
|
90
|
+
|
|
91
|
+
let thrown;
|
|
92
|
+
try {
|
|
93
|
+
await aiManager.resolveLLMConfig(projectId, 'agentplatform', 'google/gemini-2.5-flash-lite');
|
|
94
|
+
} catch (err) {
|
|
95
|
+
thrown = err;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
expect(thrown).to.exist;
|
|
99
|
+
expect(thrown.code).to.equal(422);
|
|
100
|
+
expect(thrown.error).to.equal('llmServer attribute is undefined');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('throws 422 when the named server is not found', async function () {
|
|
104
|
+
stubIntegration({ servers: [validServer] });
|
|
105
|
+
|
|
106
|
+
let thrown;
|
|
107
|
+
try {
|
|
108
|
+
await aiManager.resolveLLMConfig(projectId, 'agentplatform', 'google/gemini-2.5-flash-lite', 'unknown-server');
|
|
109
|
+
} catch (err) {
|
|
110
|
+
thrown = err;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
expect(thrown).to.exist;
|
|
114
|
+
expect(thrown.code).to.equal(422);
|
|
115
|
+
expect(thrown.error).to.equal("agentplatform server 'unknown-server' not found");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('throws 422 when the server project is missing', async function () {
|
|
119
|
+
stubIntegration({
|
|
120
|
+
servers: [{
|
|
121
|
+
name: 'no-project',
|
|
122
|
+
apikey: 'AQ.Ab8R-noproject',
|
|
123
|
+
location: 'europe-west8'
|
|
124
|
+
}]
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
let thrown;
|
|
128
|
+
try {
|
|
129
|
+
await aiManager.resolveLLMConfig(projectId, 'agentplatform', 'google/gemini-2.5-flash-lite', 'no-project');
|
|
130
|
+
} catch (err) {
|
|
131
|
+
thrown = err;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
expect(thrown).to.exist;
|
|
135
|
+
expect(thrown.code).to.equal(422);
|
|
136
|
+
expect(thrown.error).to.equal('Project for agentplatform is empty or invalid');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('throws 422 when the server location is missing', async function () {
|
|
140
|
+
stubIntegration({
|
|
141
|
+
servers: [{
|
|
142
|
+
name: 'no-location',
|
|
143
|
+
apikey: 'AQ.Ab8R-nolocation',
|
|
144
|
+
project: 'poc-tiledesk-496310'
|
|
145
|
+
}]
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
let thrown;
|
|
149
|
+
try {
|
|
150
|
+
await aiManager.resolveLLMConfig(projectId, 'agentplatform', 'google/gemini-2.5-flash-lite', 'no-location');
|
|
151
|
+
} catch (err) {
|
|
152
|
+
thrown = err;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
expect(thrown).to.exist;
|
|
156
|
+
expect(thrown.code).to.equal(422);
|
|
157
|
+
expect(thrown.error).to.equal('Location for agentplatform is empty or invalid');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('throws 422 when the servers list is missing', async function () {
|
|
161
|
+
stubIntegration({});
|
|
162
|
+
|
|
163
|
+
let thrown;
|
|
164
|
+
try {
|
|
165
|
+
await aiManager.resolveLLMConfig(projectId, 'agentplatform', 'google/gemini-2.5-flash-lite', 'prod-vertex');
|
|
166
|
+
} catch (err) {
|
|
167
|
+
thrown = err;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
expect(thrown).to.exist;
|
|
171
|
+
expect(thrown.code).to.equal(422);
|
|
172
|
+
expect(thrown.error).to.equal('agentplatform servers list is missing or invalid');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('throws when the agentplatform integration does not exist', async function () {
|
|
176
|
+
stubIntegration(null);
|
|
177
|
+
|
|
178
|
+
let thrown;
|
|
179
|
+
try {
|
|
180
|
+
await aiManager.resolveLLMConfig(projectId, 'agentplatform', 'google/gemini-2.5-flash-lite', 'prod-vertex');
|
|
181
|
+
} catch (err) {
|
|
182
|
+
thrown = err;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
expect(thrown).to.exist;
|
|
186
|
+
expect(thrown.code).to.equal(404);
|
|
187
|
+
expect(thrown.error).to.include('agentplatform');
|
|
188
|
+
});
|
|
189
|
+
});
|
package/test/kbRoute.js
CHANGED
|
@@ -31,6 +31,7 @@ const nock = require('nock');
|
|
|
31
31
|
const faq = require('../models/faq');
|
|
32
32
|
const { Namespace } = require('../models/kb_setting');
|
|
33
33
|
const default_engine = require('../config/kb/engine');
|
|
34
|
+
const Integration = require('../models/integrations');
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
// chai.config.includeStack = true;
|
|
@@ -1800,6 +1801,186 @@ describe('KbRoute', () => {
|
|
|
1800
1801
|
})
|
|
1801
1802
|
}).timeout(10000)
|
|
1802
1803
|
|
|
1804
|
+
it('askkb-agentplatform-success', (done) => {
|
|
1805
|
+
|
|
1806
|
+
var email = "test-signup-" + Date.now() + "@email.com";
|
|
1807
|
+
var pwd = "pwd";
|
|
1808
|
+
|
|
1809
|
+
userService.signup(email, pwd, "Test Firstname", "Test Lastname").then((savedUser) => {
|
|
1810
|
+
projectService.create("test-kb-qa-agentplatform", savedUser._id).then((savedProject) => {
|
|
1811
|
+
|
|
1812
|
+
Integration.create({
|
|
1813
|
+
id_project: savedProject._id.toString(),
|
|
1814
|
+
name: 'agentplatform',
|
|
1815
|
+
value: {
|
|
1816
|
+
servers: [{
|
|
1817
|
+
name: 'prod-vertex',
|
|
1818
|
+
apikey: 'AQ.Ab8R-testkey',
|
|
1819
|
+
project: 'poc-tiledesk-496310',
|
|
1820
|
+
location: 'europe-west8'
|
|
1821
|
+
}]
|
|
1822
|
+
}
|
|
1823
|
+
}).then(() => {
|
|
1824
|
+
|
|
1825
|
+
chai.request(server)
|
|
1826
|
+
.get('/' + savedProject._id + '/kb/namespace/all')
|
|
1827
|
+
.auth(email, pwd)
|
|
1828
|
+
.end((err, res) => {
|
|
1829
|
+
|
|
1830
|
+
if (err) { console.error("err: ", err); }
|
|
1831
|
+
if (log) { console.log("get all namespaces res.body: ", res.body); }
|
|
1832
|
+
|
|
1833
|
+
res.should.have.status(200);
|
|
1834
|
+
|
|
1835
|
+
chai.request(server)
|
|
1836
|
+
.post('/' + savedProject._id + "/kb/qa")
|
|
1837
|
+
.auth(email, pwd)
|
|
1838
|
+
.send({
|
|
1839
|
+
llm: "agentplatform",
|
|
1840
|
+
model: "google/gemini-2.5-flash-lite",
|
|
1841
|
+
llmServer: "prod-vertex",
|
|
1842
|
+
namespace: savedProject._id,
|
|
1843
|
+
question: "sample question",
|
|
1844
|
+
advancedPrompt: true
|
|
1845
|
+
})
|
|
1846
|
+
.end((err, res) => {
|
|
1847
|
+
|
|
1848
|
+
if (err) { console.error("err: ", err) };
|
|
1849
|
+
if (log) { console.log("res.body: ", res.body) };
|
|
1850
|
+
|
|
1851
|
+
res.should.have.status(200);
|
|
1852
|
+
expect(res.body.success).to.equal(true);
|
|
1853
|
+
expect(res.body.message).to.equal("Question skipped in test environment");
|
|
1854
|
+
res.body.data.model.should.be.a('object');
|
|
1855
|
+
expect(res.body.data.model.provider).to.equal("google");
|
|
1856
|
+
expect(res.body.data.model.name).to.equal("google/gemini-2.5-flash-lite");
|
|
1857
|
+
expect(res.body.data.model.api_key).to.equal("AQ.Ab8R-testkey");
|
|
1858
|
+
expect(res.body.data.model.project).to.equal("poc-tiledesk-496310");
|
|
1859
|
+
expect(res.body.data.model.location).to.equal("europe-west8");
|
|
1860
|
+
should.not.exist(res.body.data.model.url);
|
|
1861
|
+
|
|
1862
|
+
done();
|
|
1863
|
+
})
|
|
1864
|
+
})
|
|
1865
|
+
})
|
|
1866
|
+
})
|
|
1867
|
+
})
|
|
1868
|
+
}).timeout(10000)
|
|
1869
|
+
|
|
1870
|
+
it('askkb-agentplatform-vllmServer-fallback', (done) => {
|
|
1871
|
+
|
|
1872
|
+
var email = "test-signup-" + Date.now() + "@email.com";
|
|
1873
|
+
var pwd = "pwd";
|
|
1874
|
+
|
|
1875
|
+
userService.signup(email, pwd, "Test Firstname", "Test Lastname").then((savedUser) => {
|
|
1876
|
+
projectService.create("test-kb-qa-agentplatform", savedUser._id).then((savedProject) => {
|
|
1877
|
+
|
|
1878
|
+
Integration.create({
|
|
1879
|
+
id_project: savedProject._id.toString(),
|
|
1880
|
+
name: 'agentplatform',
|
|
1881
|
+
value: {
|
|
1882
|
+
servers: [{
|
|
1883
|
+
name: 'prod-vertex',
|
|
1884
|
+
apikey: 'AQ.Ab8R-testkey',
|
|
1885
|
+
project: 'poc-tiledesk-496310',
|
|
1886
|
+
location: 'europe-west8'
|
|
1887
|
+
}]
|
|
1888
|
+
}
|
|
1889
|
+
}).then(() => {
|
|
1890
|
+
|
|
1891
|
+
chai.request(server)
|
|
1892
|
+
.get('/' + savedProject._id + '/kb/namespace/all')
|
|
1893
|
+
.auth(email, pwd)
|
|
1894
|
+
.end((err, res) => {
|
|
1895
|
+
|
|
1896
|
+
if (err) { console.error("err: ", err); }
|
|
1897
|
+
|
|
1898
|
+
res.should.have.status(200);
|
|
1899
|
+
|
|
1900
|
+
chai.request(server)
|
|
1901
|
+
.post('/' + savedProject._id + "/kb/qa")
|
|
1902
|
+
.auth(email, pwd)
|
|
1903
|
+
.send({
|
|
1904
|
+
llm: "agentplatform",
|
|
1905
|
+
model: "google/gemini-2.5-flash-lite",
|
|
1906
|
+
vllmServer: "prod-vertex",
|
|
1907
|
+
namespace: savedProject._id,
|
|
1908
|
+
question: "sample question",
|
|
1909
|
+
advancedPrompt: true
|
|
1910
|
+
})
|
|
1911
|
+
.end((err, res) => {
|
|
1912
|
+
|
|
1913
|
+
if (err) { console.error("err: ", err) };
|
|
1914
|
+
|
|
1915
|
+
res.should.have.status(200);
|
|
1916
|
+
expect(res.body.data.model.provider).to.equal("google");
|
|
1917
|
+
expect(res.body.data.model.project).to.equal("poc-tiledesk-496310");
|
|
1918
|
+
expect(res.body.data.model.location).to.equal("europe-west8");
|
|
1919
|
+
|
|
1920
|
+
done();
|
|
1921
|
+
})
|
|
1922
|
+
})
|
|
1923
|
+
})
|
|
1924
|
+
})
|
|
1925
|
+
})
|
|
1926
|
+
}).timeout(10000)
|
|
1927
|
+
|
|
1928
|
+
it('askkb-agentplatform-missing-llmServer', (done) => {
|
|
1929
|
+
|
|
1930
|
+
var email = "test-signup-" + Date.now() + "@email.com";
|
|
1931
|
+
var pwd = "pwd";
|
|
1932
|
+
|
|
1933
|
+
userService.signup(email, pwd, "Test Firstname", "Test Lastname").then((savedUser) => {
|
|
1934
|
+
projectService.create("test-kb-qa-agentplatform", savedUser._id).then((savedProject) => {
|
|
1935
|
+
|
|
1936
|
+
Integration.create({
|
|
1937
|
+
id_project: savedProject._id.toString(),
|
|
1938
|
+
name: 'agentplatform',
|
|
1939
|
+
value: {
|
|
1940
|
+
servers: [{
|
|
1941
|
+
name: 'prod-vertex',
|
|
1942
|
+
apikey: 'AQ.Ab8R-testkey',
|
|
1943
|
+
project: 'poc-tiledesk-496310',
|
|
1944
|
+
location: 'europe-west8'
|
|
1945
|
+
}]
|
|
1946
|
+
}
|
|
1947
|
+
}).then(() => {
|
|
1948
|
+
|
|
1949
|
+
chai.request(server)
|
|
1950
|
+
.get('/' + savedProject._id + '/kb/namespace/all')
|
|
1951
|
+
.auth(email, pwd)
|
|
1952
|
+
.end((err, res) => {
|
|
1953
|
+
|
|
1954
|
+
if (err) { console.error("err: ", err); }
|
|
1955
|
+
|
|
1956
|
+
res.should.have.status(200);
|
|
1957
|
+
|
|
1958
|
+
chai.request(server)
|
|
1959
|
+
.post('/' + savedProject._id + "/kb/qa")
|
|
1960
|
+
.auth(email, pwd)
|
|
1961
|
+
.send({
|
|
1962
|
+
llm: "agentplatform",
|
|
1963
|
+
model: "google/gemini-2.5-flash-lite",
|
|
1964
|
+
namespace: savedProject._id,
|
|
1965
|
+
question: "sample question",
|
|
1966
|
+
advancedPrompt: true
|
|
1967
|
+
})
|
|
1968
|
+
.end((err, res) => {
|
|
1969
|
+
|
|
1970
|
+
if (err) { console.error("err: ", err) };
|
|
1971
|
+
|
|
1972
|
+
res.should.have.status(422);
|
|
1973
|
+
expect(res.body.success).to.equal(false);
|
|
1974
|
+
expect(res.body.error).to.equal("llmServer attribute is undefined");
|
|
1975
|
+
|
|
1976
|
+
done();
|
|
1977
|
+
})
|
|
1978
|
+
})
|
|
1979
|
+
})
|
|
1980
|
+
})
|
|
1981
|
+
})
|
|
1982
|
+
}).timeout(10000)
|
|
1983
|
+
|
|
1803
1984
|
it('webhook', (done) => {
|
|
1804
1985
|
|
|
1805
1986
|
var email = "test-signup-" + Date.now() + "@email.com";
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const { expect } = require('chai');
|
|
2
|
+
const {
|
|
3
|
+
OPENROUTER_BASE_URL,
|
|
4
|
+
findModelConfig,
|
|
5
|
+
buildProviderRouting,
|
|
6
|
+
buildOpenRouterModel
|
|
7
|
+
} = require('../utils/openrouterUtils');
|
|
8
|
+
const { mergeIntegrationValue, maskIntegrationValue } = require('../utils/maskIntegrationSecrets');
|
|
9
|
+
|
|
10
|
+
const value = {
|
|
11
|
+
apikey: 'sk-or-secret-key',
|
|
12
|
+
models: [
|
|
13
|
+
{
|
|
14
|
+
id: 'openai/gpt-4o',
|
|
15
|
+
name: 'GPT-4o',
|
|
16
|
+
providers: ['azure', 'openai'],
|
|
17
|
+
allow_fallbacks: false,
|
|
18
|
+
sort: 'price'
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 'meta-llama/llama-3.3-70b',
|
|
22
|
+
name: 'Llama 3.3 70B',
|
|
23
|
+
providers: [],
|
|
24
|
+
allow_fallbacks: true,
|
|
25
|
+
sort: null
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('openrouterUtils', () => {
|
|
31
|
+
|
|
32
|
+
describe('findModelConfig', () => {
|
|
33
|
+
|
|
34
|
+
it('finds the entry for a model id', () => {
|
|
35
|
+
expect(findModelConfig(value, 'openai/gpt-4o').name).to.equal('GPT-4o');
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns undefined for an unknown model', () => {
|
|
39
|
+
expect(findModelConfig(value, 'anthropic/claude-opus-5')).to.be.undefined;
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('returns undefined when the integration has no models', () => {
|
|
43
|
+
expect(findModelConfig({ apikey: 'sk-or-x' }, 'openai/gpt-4o')).to.be.undefined;
|
|
44
|
+
expect(findModelConfig(undefined, 'openai/gpt-4o')).to.be.undefined;
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns undefined for an empty model id', () => {
|
|
48
|
+
expect(findModelConfig(value, '')).to.be.undefined;
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('buildProviderRouting', () => {
|
|
53
|
+
|
|
54
|
+
it('carries the ordered providers, the restriction and the sort', () => {
|
|
55
|
+
expect(buildProviderRouting(value, 'openai/gpt-4o')).to.deep.equal({
|
|
56
|
+
order: ['azure', 'openai'],
|
|
57
|
+
allow_fallbacks: false,
|
|
58
|
+
sort: 'price'
|
|
59
|
+
});
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns undefined when a configured model has nothing to route', () => {
|
|
63
|
+
// allow_fallbacks true is OpenRouter's own default, so it says nothing.
|
|
64
|
+
expect(buildProviderRouting(value, 'meta-llama/llama-3.3-70b')).to.be.undefined;
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns undefined for a model that was never configured', () => {
|
|
68
|
+
expect(buildProviderRouting(value, 'anthropic/claude-opus-5')).to.be.undefined;
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('drops blank provider slugs', () => {
|
|
72
|
+
const v = { models: [{ id: 'm', providers: [' ', 'fireworks', ''] }] };
|
|
73
|
+
expect(buildProviderRouting(v, 'm')).to.deep.equal({ order: ['fireworks'] });
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('drops an unrecognised sort', () => {
|
|
77
|
+
const v = { models: [{ id: 'm', providers: ['a'], sort: 'cheapest' }] };
|
|
78
|
+
expect(buildProviderRouting(v, 'm')).to.deep.equal({ order: ['a'] });
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('normalises the sort case', () => {
|
|
82
|
+
const v = { models: [{ id: 'm', sort: 'Latency' }] };
|
|
83
|
+
expect(buildProviderRouting(v, 'm')).to.deep.equal({ sort: 'latency' });
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('buildOpenRouterModel', () => {
|
|
88
|
+
|
|
89
|
+
it('builds the model object the LLM microservice expects', () => {
|
|
90
|
+
expect(buildOpenRouterModel(value, 'openai/gpt-4o')).to.deep.equal({
|
|
91
|
+
provider: 'openrouter',
|
|
92
|
+
name: 'openai/gpt-4o',
|
|
93
|
+
url: OPENROUTER_BASE_URL,
|
|
94
|
+
api_key: 'sk-or-secret-key',
|
|
95
|
+
provider_routing: {
|
|
96
|
+
order: ['azure', 'openai'],
|
|
97
|
+
allow_fallbacks: false,
|
|
98
|
+
sort: 'price'
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('omits provider_routing entirely for an unrouted model', () => {
|
|
104
|
+
const model = buildOpenRouterModel(value, 'anthropic/claude-opus-5');
|
|
105
|
+
expect(model).to.not.have.property('provider_routing');
|
|
106
|
+
expect(model.name).to.equal('anthropic/claude-opus-5');
|
|
107
|
+
expect(model.url).to.equal(OPENROUTER_BASE_URL);
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('integration secret handling', () => {
|
|
112
|
+
|
|
113
|
+
it('masks the key without touching the model list', () => {
|
|
114
|
+
const masked = maskIntegrationValue(value);
|
|
115
|
+
const stars = '*'.repeat(value.apikey.length - 7);
|
|
116
|
+
|
|
117
|
+
expect(masked.apikey).to.equal('sk-' + stars + '-key');
|
|
118
|
+
expect(masked.models).to.deep.equal(value.models);
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('replaces the model list wholesale while keeping the stored key', () => {
|
|
122
|
+
const incoming = {
|
|
123
|
+
apikey: 'sk-***********-key', // masked: the dashboard echoes it back
|
|
124
|
+
models: [{ id: 'openai/gpt-4o', name: 'GPT-4o', providers: ['openai'] }]
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const merged = mergeIntegrationValue(incoming, value);
|
|
128
|
+
|
|
129
|
+
expect(merged.apikey).to.equal('sk-or-secret-key');
|
|
130
|
+
expect(merged.models).to.have.lengthOf(1);
|
|
131
|
+
expect(merged.models[0].providers).to.deep.equal(['openai']);
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('lets the last model be removed', () => {
|
|
135
|
+
const merged = mergeIntegrationValue({ apikey: 'sk-***********-key', models: [] }, value);
|
|
136
|
+
expect(merged.models).to.deep.equal([]);
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter provider routing.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard stores, per model, which upstream providers may serve it and
|
|
5
|
+
* in what order:
|
|
6
|
+
*
|
|
7
|
+
* integration.value = {
|
|
8
|
+
* apikey: "sk-or-...",
|
|
9
|
+
* models: [
|
|
10
|
+
* { id: "openai/gpt-4o", name: "GPT-4o",
|
|
11
|
+
* providers: ["azure", "openai"], allow_fallbacks: true, sort: "price" }
|
|
12
|
+
* ]
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* This turns one of those entries into the routing object the LLM microservice
|
|
16
|
+
* forwards to OpenRouter as the request's "provider" block.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
20
|
+
|
|
21
|
+
const SORT_VALUES = ['price', 'throughput', 'latency'];
|
|
22
|
+
|
|
23
|
+
/** The stored configuration for one model id, or undefined when it has none. */
|
|
24
|
+
function findModelConfig(value, model) {
|
|
25
|
+
const models = value && Array.isArray(value.models) ? value.models : [];
|
|
26
|
+
const wanted = String(model || '').trim();
|
|
27
|
+
if (!wanted) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return models.find((entry) => entry && String(entry.id || '').trim() === wanted);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the routing block for a model.
|
|
35
|
+
*
|
|
36
|
+
* Returns undefined when the model carries nothing worth routing, so an
|
|
37
|
+
* unconfigured model behaves exactly like any other OpenAI-compatible
|
|
38
|
+
* provider instead of being pinned to an empty provider list.
|
|
39
|
+
*/
|
|
40
|
+
function buildProviderRouting(value, model) {
|
|
41
|
+
const config = findModelConfig(value, model);
|
|
42
|
+
if (!config) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const routing = {};
|
|
47
|
+
|
|
48
|
+
const order = (Array.isArray(config.providers) ? config.providers : [])
|
|
49
|
+
.map((provider) => String(provider || '').trim())
|
|
50
|
+
.filter((provider) => !!provider);
|
|
51
|
+
if (order.length > 0) {
|
|
52
|
+
routing.order = order;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// true is OpenRouter's own default, so only the restrictive choice is worth sending.
|
|
56
|
+
if (config.allow_fallbacks === false) {
|
|
57
|
+
routing.allow_fallbacks = false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sort = String(config.sort || '').trim().toLowerCase();
|
|
61
|
+
if (SORT_VALUES.includes(sort)) {
|
|
62
|
+
routing.sort = sort;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return Object.keys(routing).length > 0 ? routing : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The model object the LLM microservice expects for an OpenRouter call.
|
|
70
|
+
* `provider_routing` is omitted entirely when there is nothing to route.
|
|
71
|
+
*/
|
|
72
|
+
function buildOpenRouterModel(value, model) {
|
|
73
|
+
const routing = buildProviderRouting(value, model);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
provider: 'openrouter',
|
|
77
|
+
name: model,
|
|
78
|
+
url: OPENROUTER_BASE_URL,
|
|
79
|
+
api_key: (value && value.apikey) || '',
|
|
80
|
+
...(routing && { provider_routing: routing })
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
OPENROUTER_BASE_URL,
|
|
86
|
+
findModelConfig,
|
|
87
|
+
buildProviderRouting,
|
|
88
|
+
buildOpenRouterModel
|
|
89
|
+
};
|