@tiledesk/tiledesk-server 2.24.2 → 2.26.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 +8 -0
- package/config/kb/situatedContext.js +37 -2
- package/package.json +2 -2
- package/pubmodules/docsparse/docsparseRoute.js +94 -0
- package/pubmodules/docsparse/index.js +3 -0
- package/pubmodules/pubModulesManager.js +24 -0
- 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,14 @@
|
|
|
5
5
|
🚀 IN PRODUCTION 🚀
|
|
6
6
|
(https://www.npmjs.com/package/@tiledesk/tiledesk-server/v/2.3.77)
|
|
7
7
|
|
|
8
|
+
# 2.26.0
|
|
9
|
+
- Added pubmodules proxy route for docsparse
|
|
10
|
+
|
|
11
|
+
# 2.25.0
|
|
12
|
+
- Added support for OpenRouter
|
|
13
|
+
- Added support for Gemini Agent Platform
|
|
14
|
+
- Updated tybot-connector to 2.1.15
|
|
15
|
+
|
|
8
16
|
# 2.24.2
|
|
9
17
|
- Fixed bug recalling webhooks
|
|
10
18
|
|
|
@@ -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.26.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",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
var express = require('express');
|
|
2
|
+
var router = express.Router({ mergeParams: true });
|
|
3
|
+
var axios = require('axios');
|
|
4
|
+
var querystring = require('querystring');
|
|
5
|
+
var winston = require('../../config/winston');
|
|
6
|
+
|
|
7
|
+
// Headers that must not be forwarded between the two connections
|
|
8
|
+
const HOP_BY_HOP_HEADERS = [
|
|
9
|
+
'connection',
|
|
10
|
+
'keep-alive',
|
|
11
|
+
'proxy-authenticate',
|
|
12
|
+
'proxy-authorization',
|
|
13
|
+
'te',
|
|
14
|
+
'trailer',
|
|
15
|
+
'transfer-encoding',
|
|
16
|
+
'upgrade'
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function forwardableRequestHeaders(req) {
|
|
20
|
+
let headers = Object.assign({}, req.headers);
|
|
21
|
+
HOP_BY_HOP_HEADERS.forEach((header) => {
|
|
22
|
+
delete headers[header];
|
|
23
|
+
});
|
|
24
|
+
delete headers['host'];
|
|
25
|
+
delete headers['content-length'];
|
|
26
|
+
return headers;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requestBody(req, headers) {
|
|
30
|
+
// A request without content-length nor transfer-encoding has no body at all
|
|
31
|
+
if (req.headers['content-length'] === undefined && req.headers['transfer-encoding'] === undefined) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// body-parser sets _body when it already consumed the stream (json / urlencoded),
|
|
36
|
+
// in that case the parsed body must be serialized again
|
|
37
|
+
if (req._body) {
|
|
38
|
+
let contentType = req.headers['content-type'] || '';
|
|
39
|
+
if (contentType.indexOf('application/x-www-form-urlencoded') > -1) {
|
|
40
|
+
return querystring.stringify(req.body);
|
|
41
|
+
}
|
|
42
|
+
return req.body;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Everything else (multipart, binary, ...) is piped as it is
|
|
46
|
+
return req;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
router.use(function (req, res) {
|
|
50
|
+
|
|
51
|
+
let baseUrl = process.env.DOCSPARSE_BASEURL;
|
|
52
|
+
|
|
53
|
+
if (!baseUrl) {
|
|
54
|
+
winston.error("DOCSPARSE_BASEURL is not set. Unable to forward the request to docsparse");
|
|
55
|
+
return res.status(503).send({ success: false, msg: "Docsparse module is not configured. Set the DOCSPARSE_BASEURL environment variable" });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// req.url is the part of the url following the module route, query string included
|
|
59
|
+
let url = baseUrl.replace(/\/+$/, '') + req.url;
|
|
60
|
+
let headers = forwardableRequestHeaders(req);
|
|
61
|
+
|
|
62
|
+
winston.debug("[DOCSPARSE] " + req.method + " " + url);
|
|
63
|
+
|
|
64
|
+
axios({
|
|
65
|
+
url: url,
|
|
66
|
+
method: req.method,
|
|
67
|
+
headers: headers,
|
|
68
|
+
data: requestBody(req, headers),
|
|
69
|
+
responseType: 'stream',
|
|
70
|
+
decompress: false,
|
|
71
|
+
maxContentLength: Infinity,
|
|
72
|
+
maxBodyLength: Infinity,
|
|
73
|
+
validateStatus: () => true
|
|
74
|
+
}).then((response) => {
|
|
75
|
+
|
|
76
|
+
winston.debug("[DOCSPARSE] response status " + response.status + " for " + url);
|
|
77
|
+
|
|
78
|
+
Object.keys(response.headers).forEach((header) => {
|
|
79
|
+
if (HOP_BY_HOP_HEADERS.indexOf(header.toLowerCase()) === -1) {
|
|
80
|
+
res.setHeader(header, response.headers[header]);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
res.status(response.status);
|
|
85
|
+
response.data.pipe(res);
|
|
86
|
+
|
|
87
|
+
}).catch((err) => {
|
|
88
|
+
winston.error("[DOCSPARSE] Error forwarding the request to " + url + ": " + err.message);
|
|
89
|
+
res.status(502).send({ success: false, msg: "Error forwarding the request to docsparse", error: err.message });
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
module.exports = router;
|
|
@@ -76,6 +76,9 @@ class PubModulesManager {
|
|
|
76
76
|
this.dialogFlow = undefined;
|
|
77
77
|
|
|
78
78
|
this.analyticsPublisher = undefined;
|
|
79
|
+
|
|
80
|
+
this.docsparse = undefined;
|
|
81
|
+
this.docsparseRoute = undefined;
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
|
|
@@ -170,6 +173,12 @@ class PubModulesManager {
|
|
|
170
173
|
winston.info("ModulesManager trigger controller loaded");
|
|
171
174
|
}
|
|
172
175
|
|
|
176
|
+
if (this.docsparseRoute) {
|
|
177
|
+
// docsparse apis have no authentication on their own: the server authenticates the caller before forwarding
|
|
178
|
+
app.use('/:projectid/docsparse', [passport.authenticate(['basic', 'jwt'], { session: false }), validtoken, roleChecker.hasRoleOrTypes('admin', ['bot','subscription'])], this.docsparseRoute);
|
|
179
|
+
winston.info("ModulesManager docsparse controller loaded");
|
|
180
|
+
}
|
|
181
|
+
|
|
173
182
|
}
|
|
174
183
|
|
|
175
184
|
|
|
@@ -609,6 +618,21 @@ class PubModulesManager {
|
|
|
609
618
|
winston.info("PubModulesManager error initializing analyticsPublisher module", err);
|
|
610
619
|
}
|
|
611
620
|
}
|
|
621
|
+
|
|
622
|
+
try {
|
|
623
|
+
this.docsparse = require('./docsparse');
|
|
624
|
+
winston.debug("this.docsparse: " + this.docsparse);
|
|
625
|
+
|
|
626
|
+
this.docsparseRoute = this.docsparse.docsparseRoute;
|
|
627
|
+
|
|
628
|
+
winston.info("PubModulesManager initialized docsparse.");
|
|
629
|
+
} catch(err) {
|
|
630
|
+
if (err.code == 'MODULE_NOT_FOUND') {
|
|
631
|
+
winston.info("PubModulesManager init docsparse module not found");
|
|
632
|
+
} else {
|
|
633
|
+
winston.info("PubModulesManager error initializing docsparse module", err);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
612
636
|
}
|
|
613
637
|
|
|
614
638
|
start() {
|
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
|
+
};
|