@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 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.24.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.11",
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;
@@ -0,0 +1,3 @@
1
+ const docsparseRoute = require("./docsparseRoute");
2
+
3
+ module.exports = { docsparseRoute: docsparseRoute }
@@ -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 vllmConfig;
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 === 'vllm') {
38
+ if (multiServerProviders.includes(body.llm)) {
37
39
  try {
38
- vllmConfig = await aiManager.resolveLLMConfig(id_project, body.llm, body.model, body.vllmServer);
39
- key = vllmConfig.api_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 === 'vllm') {
114
+ if (multiServerProviders.includes(body.llm)) {
106
115
  json.llm_key = key || "";
107
- json.model = {
108
- name: body.model,
109
- url: vllmConfig.url,
110
- api_key: key || "",
111
- provider: 'vllm'
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
 
@@ -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, vllmServer = undefined) {
255
+ async resolveLLMConfig(id_project, provider = 'openai', model, llmServer = undefined) {
256
+ const selfHostedProviders = ['ollama', 'vllm', 'agentplatform'];
255
257
 
256
- if (provider === 'ollama' || provider === 'vllm') {
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 (provider === 'vllm' && Array.isArray(value.servers)) {
265
- if (!vllmServer) {
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
+ };