@exulu/backend 3.0.0 → 3.2.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.
@@ -1,6 +1,5 @@
1
- import { generateText, Output } from "ai";
2
1
  import { z } from "zod";
3
- import { withRetry } from "@SRC/utils/with-retry";
2
+ import { microCall } from "./micro-call";
4
3
  import { fuzzyPrefilter } from "./prefilter";
5
4
  import { normalizeFileName } from "./text-utils";
6
5
  import type { RoutingRule } from "./config";
@@ -103,25 +102,17 @@ export async function runRoutingPhase(opts: {
103
102
  const [docPageRaw, explicitKBRaw] = await Promise.all([
104
103
  (async () => {
105
104
  try {
106
- return await withRetry(
107
- () =>
108
- generateText({
109
- model,
110
- temperature: 0,
111
- system: buildDocPagePrompt(knownIdentifiers),
112
- messages: [{ role: "user", content: question }],
113
- output: Output.object({
114
- schema: z.object({
115
- hasFilenameHint: z.boolean(),
116
- filenameHints: z.array(z.string()).optional(),
117
- hasPageHint: z.boolean(),
118
- pageNumber: z.number().int().nullable().optional(),
119
- }),
120
- }),
121
- maxOutputTokens: 300,
122
- }),
123
- 3,
124
- );
105
+ return await microCall({
106
+ model,
107
+ system: buildDocPagePrompt(knownIdentifiers),
108
+ messages: [{ role: "user", content: question }],
109
+ schema: z.object({
110
+ hasFilenameHint: z.boolean(),
111
+ filenameHints: z.array(z.string()).optional(),
112
+ hasPageHint: z.boolean(),
113
+ pageNumber: z.number().int().nullable().optional(),
114
+ }),
115
+ });
125
116
  } catch (err) {
126
117
  steps.push({ text: "Doc/page detection failed — skipping filename and page hints." });
127
118
  return {
@@ -136,24 +127,16 @@ export async function runRoutingPhase(opts: {
136
127
  })(),
137
128
  (async () => {
138
129
  try {
139
- return await withRetry(
140
- () =>
141
- generateText({
142
- model,
143
- temperature: 0,
144
- system: kbSystemPrompt,
145
- output: Output.object({
146
- schema: z.object({
147
- explicitlyRequestedKnowledgeBases: z.array(
148
- z.enum(enabledContexts.map((c) => c.id) as [string, ...string[]]),
149
- ),
150
- }),
151
- }),
152
- messages: [{ role: "user", content: question }],
153
- maxOutputTokens: 200,
154
- }),
155
- 3,
156
- );
130
+ return await microCall({
131
+ model,
132
+ system: kbSystemPrompt,
133
+ schema: z.object({
134
+ explicitlyRequestedKnowledgeBases: z.array(
135
+ z.enum(enabledContexts.map((c) => c.id) as [string, ...string[]]),
136
+ ),
137
+ }),
138
+ messages: [{ role: "user", content: question }],
139
+ });
157
140
  } catch (err) {
158
141
  return { output: { explicitlyRequestedKnowledgeBases: [] as string[] } };
159
142
  }
@@ -267,23 +250,15 @@ export async function runRoutingPhase(opts: {
267
250
  }
268
251
 
269
252
  try {
270
- const { output: classified } = await withRetry(
271
- () =>
272
- generateText({
273
- model,
274
- temperature: 0,
275
- system: classifyPrompt,
276
- messages: [{ role: "user", content: question }],
277
- output: Output.object({
278
- schema: z.object({
279
- ruleId: z.enum(ruleIds as [string, ...string[]]),
280
- reason: z.string(),
281
- }),
282
- }),
283
- maxOutputTokens: 200,
284
- }),
285
- 3,
286
- );
253
+ const { output: classified } = await microCall({
254
+ model,
255
+ system: classifyPrompt,
256
+ messages: [{ role: "user", content: question }],
257
+ schema: z.object({
258
+ ruleId: z.enum(ruleIds as [string, ...string[]]),
259
+ reason: z.string(),
260
+ }),
261
+ });
287
262
 
288
263
  const matchedRule = routingRules.find((r) => r.id === classified.ruleId);
289
264
  if (matchedRule) {
@@ -146,7 +146,6 @@ async function resolveVlmModel(
146
146
 
147
147
  const { languageModel } = await resolveModel({
148
148
  modelId,
149
- providers: [], // unused in LiteLLM mode; resolveModel ignores it there
150
149
  user: config?.attribution?.user,
151
150
  project: config?.attribution?.project,
152
151
  agent: config?.attribution?.agent,
@@ -5,6 +5,19 @@ import { BullMQOtel } from "bullmq-otel";
5
5
  import type { ExuluQueueConfig } from "@EXULU_TYPES/queue-config";
6
6
  import { checkLicense } from "@EE/entitlements";
7
7
 
8
+ /**
9
+ * Built-in/system queues that ExuluApp registers automatically. They back
10
+ * internal processing (eval runs, inbound email intake) and must never be
11
+ * offered to users as a routine's run queue — the `queues` GraphQL query
12
+ * excludes them (see resolveAvailableQueues). Underscore, not hyphen:
13
+ * registered queue names are interpolated verbatim into the GraphQL QueueEnum,
14
+ * where "-" is illegal.
15
+ */
16
+ export const global_queues = {
17
+ eval_runs: "eval_runs",
18
+ email_intake: "email_intake",
19
+ };
20
+
8
21
  // Used for workflows and embedders
9
22
  class ExuluQueues {
10
23
  queues: {
package/ee/schemas.ts CHANGED
@@ -419,6 +419,10 @@ export const workflowTemplatesSchema: ExuluTableDefinition = {
419
419
  type: "boolean",
420
420
  default: false,
421
421
  },
422
+ {
423
+ name: "queue",
424
+ type: "text"
425
+ }
422
426
  ],
423
427
  };
424
428
 
@@ -454,16 +458,13 @@ export const workflowTemplatesSchema: ExuluTableDefinition = {
454
458
  type: "boolean",
455
459
  default: false,
456
460
  },
457
- {
458
- // Generated server-side: {routine-slug}-{8 hex}@{inbound_domain}.
459
- // Real UNIQUE column (not JSON) because the webhook resolves
460
- // triggers by recipient address.
461
- name: "address",
462
- type: "text",
463
- required: true,
464
- unique: true,
465
- index: true,
466
- },
461
+ // Secret capability URL key: base64url randomBytes(32). Both routes
462
+ // and authorizes the webhook (POST /webhooks/routine/:secret).
463
+ { name: "secret", type: "text", required: true, unique: true, index: true },
464
+ // Optional per-trigger HMAC shared secret, AES-encrypted at rest.
465
+ { name: "signing_secret", type: "text" },
466
+ // Stamped on every verified webhook hit; per-trigger setup aid.
467
+ { name: "last_fired_at", type: "date" },
467
468
  {
468
469
  // allowed_senders / filters / filtered_run_retention /
469
470
  // rate_limit_per_hour / sender_rate_limit_per_hour (spec §3.1).
package/ee/workers.ts CHANGED
@@ -16,7 +16,7 @@ import type { BullMqJobData } from "@EE/queues/decorator.ts";
16
16
  import { maybePruneJobResults } from "@EE/queues/prune-job-results.ts";
17
17
  import { type Tracer } from "@opentelemetry/api";
18
18
  import { v4 as uuidv4 } from "uuid";
19
- import { type UIMessage } from "ai";
19
+ import { createIdGenerator, type UIMessage } from "ai";
20
20
  import CryptoJS from "crypto-js";
21
21
  import { STATISTICS_TYPE_ENUM, type STATISTICS_TYPE } from "@EXULU_TYPES/enums/statistics";
22
22
  import type { User } from "@EXULU_TYPES/models/user";
@@ -29,14 +29,15 @@ import type { STATISTICS_LABELS } from "@EXULU_TYPES/statistics.ts";
29
29
  import { sanitizeToolName } from "@SRC/utils/sanitize-tool-name.ts";
30
30
  import type { ExuluConfig } from "@SRC/exulu/app/index.ts";
31
31
  import { updateStatistic } from "@SRC/exulu/statistics";
32
- import type { ExuluProvider } from "@SRC/exulu/provider.ts";
33
- import { saveChat, getAgentMessages } from "@SRC/exulu/provider.ts";
32
+ import { saveChat, getAgentMessages, generateStream } from "@SRC/exulu/generate-stream";
34
33
  import { exuluApp } from "@SRC/exulu/app/singleton";
35
34
  import { handleEmailIntake } from "@SRC/exulu/email-inbound/intake";
36
35
  import { markStreamActive, clearStreamActive } from "@SRC/exulu/active-streams.ts";
37
36
  import { messageHasPendingApproval, substituteVariablesInMessage } from "@SRC/exulu/routines/flow-steps.ts";
38
37
  import { createRunSession } from "@SRC/exulu/routines/run-session.ts";
39
38
  import { casJobResultState, parseRunMetadata, upsertWorkflowRunStart } from "@SRC/exulu/routines/run-state.ts";
39
+ import { findLiteLLMModel } from "@SRC/exulu/litellm/catalog.ts";
40
+ import { computeRunCostUsd } from "@SRC/exulu/routines/run-cost.ts";
40
41
 
41
42
  /**
42
43
  * Session-backed runs persist messages at each step boundary, so retries must
@@ -132,7 +133,6 @@ const installGlobalErrorHandlers = () => {
132
133
  let isShuttingDown = false;
133
134
 
134
135
  export const createWorkers = async (
135
- providers: ExuluProvider[],
136
136
  queues: ExuluQueueConfig[],
137
137
  config: ExuluConfig,
138
138
  contexts: ExuluContext[],
@@ -525,11 +525,10 @@ export const createWorkers = async (
525
525
 
526
526
  const {
527
527
  agent,
528
- provider,
529
528
  user,
530
529
  workflow,
531
530
  messages: inputMessages,
532
- } = await validateWorkflowPayload(data, providers);
531
+ } = await validateWorkflowPayload(data);
533
532
 
534
533
  // Session-backed runs (spec §3.4): reuse the session provided by
535
534
  // the enqueuer (email intake / continuation / retry / previous
@@ -576,9 +575,7 @@ export const createWorkers = async (
576
575
  // + substituted text) — pass a fresh deep copy each attempt
577
576
  // so a retry/resume never reuses the mutated array.
578
577
  const messages = await processUiMessagesFlow({
579
- providers,
580
578
  agent,
581
- provider,
582
579
  inputMessages: structuredClone(inputMessages),
583
580
  contexts,
584
581
  user,
@@ -658,6 +655,22 @@ export const createWorkers = async (
658
655
  (priorTokens?.cachedInputTokens ?? 0) + metadata.tokens.cachedInputTokens,
659
656
  };
660
657
 
658
+ // Approximate per-run $ cost (spec 2026-07-29): recompute from the
659
+ // cumulative token totals × the run model's catalog list price on every
660
+ // persist, so it stays correct across pause/resume. Null when the model
661
+ // has no catalog price — the UI shows "—", not a fabricated $0.
662
+ const modelPrice = await findLiteLLMModel(agent.model ?? "");
663
+ (tokens as Record<string, number | null>).costUsd = computeRunCostUsd(
664
+ tokens.inputTokens,
665
+ tokens.outputTokens,
666
+ modelPrice
667
+ ? {
668
+ input_cost_per_million_tokens: modelPrice.input_cost_per_million_tokens,
669
+ output_cost_per_million_tokens: modelPrice.output_cost_per_million_tokens,
670
+ }
671
+ : null,
672
+ );
673
+
661
674
  if (result.pausedAtStepIndex !== undefined) {
662
675
  // Pause is success (spec §5.3): persist progress and flip to
663
676
  // waiting_approval synchronously BEFORE returning — the
@@ -741,17 +754,15 @@ export const createWorkers = async (
741
754
 
742
755
  const {
743
756
  agent,
744
- provider,
745
757
  user,
746
758
  evalRun,
747
759
  testCase,
748
760
  messages: inputMessages,
749
- } = await validateEvalPayload(data, providers);
761
+ } = await validateEvalPayload(data);
750
762
 
751
763
  const retries = 3;
752
764
  let attempts = 0;
753
765
 
754
- // todo allow setting queue on agent Provider and then create a job with type "agent"
755
766
  const promise = new Promise<{
756
767
  messages: UIMessage[];
757
768
  metadata: {
@@ -768,9 +779,7 @@ export const createWorkers = async (
768
779
  while (attempts < retries) {
769
780
  try {
770
781
  const messages = await processUiMessagesFlow({
771
- providers,
772
782
  agent,
773
- provider,
774
783
  inputMessages,
775
784
  contexts,
776
785
  user,
@@ -877,7 +886,6 @@ export const createWorkers = async (
877
886
  } else {
878
887
  result = await evalMethod.run(
879
888
  agent,
880
- provider,
881
889
  testCase,
882
890
  messages,
883
891
  evalFunction.config || {},
@@ -971,10 +979,9 @@ export const createWorkers = async (
971
979
  const {
972
980
  evalRun,
973
981
  agent,
974
- provider,
975
982
  testCase,
976
983
  messages: inputMessages,
977
- } = await validateEvalPayload(data, providers);
984
+ } = await validateEvalPayload(data);
978
985
 
979
986
  const evalFunctions: {
980
987
  id: string;
@@ -995,7 +1002,6 @@ export const createWorkers = async (
995
1002
 
996
1003
  result = await evalMethod.run(
997
1004
  agent,
998
- provider,
999
1005
  testCase,
1000
1006
  inputMessages,
1001
1007
  evalFunction.config || {},
@@ -1085,24 +1091,19 @@ export const createWorkers = async (
1085
1091
  }
1086
1092
 
1087
1093
  if (data.type === "email_intake") {
1088
- console.log("[EXULU] running an email intake job.", bullmqJob.name);
1089
-
1090
- if (!data.inputs?.s3Key) {
1091
- throw new Error(`No s3Key set for email intake job.`);
1094
+ console.log("[EXULU] running a routine webhook intake job.", bullmqJob.name);
1095
+ if (!data.inputs?.s3Key || !data.inputs?.triggerId) {
1096
+ throw new Error(`Missing s3Key/triggerId for email intake job.`);
1092
1097
  }
1093
-
1094
1098
  const result = await handleEmailIntake(
1095
1099
  {
1096
1100
  s3Key: data.inputs.s3Key,
1097
- recipient: data.inputs.recipient,
1101
+ triggerId: data.inputs.triggerId,
1102
+ format: data.inputs.format === "json" ? "json" : "eml",
1098
1103
  },
1099
- { config, providers },
1104
+ { config },
1100
1105
  );
1101
-
1102
- return {
1103
- result,
1104
- metadata: {},
1105
- };
1106
+ return { result, metadata: {} };
1106
1107
  }
1107
1108
 
1108
1109
  throw new Error(`Invalid job type: ${data.type} for job ${bullmqJob.name}.`);
@@ -1313,10 +1314,8 @@ export const createWorkers = async (
1313
1314
 
1314
1315
  export const validateWorkflowPayload = async (
1315
1316
  data: BullMqJobData,
1316
- providers: ExuluProvider[],
1317
1317
  ): Promise<{
1318
1318
  agent: ExuluAgent;
1319
- provider: ExuluProvider;
1320
1319
  user: User;
1321
1320
  workflow: ExuluWorkflow;
1322
1321
  variables: Record<string, any>;
@@ -1348,12 +1347,6 @@ export const validateWorkflowPayload = async (
1348
1347
  throw new Error(`Agent ${workflow.agent} not found in the database.`);
1349
1348
  }
1350
1349
 
1351
- const provider = providers.find((a) => a.id === agent.provider);
1352
-
1353
- if (!provider) {
1354
- throw new Error(`Provider ${agent.provider} not found in the database.`);
1355
- }
1356
-
1357
1350
  const user = await db.from("users").where({ id: data.user }).first();
1358
1351
 
1359
1352
  if (!user) {
@@ -1362,7 +1355,6 @@ export const validateWorkflowPayload = async (
1362
1355
 
1363
1356
  return {
1364
1357
  agent,
1365
- provider,
1366
1358
  user,
1367
1359
  workflow,
1368
1360
  variables: data.inputs,
@@ -1372,10 +1364,8 @@ export const validateWorkflowPayload = async (
1372
1364
 
1373
1365
  const validateEvalPayload = async (
1374
1366
  data: BullMqJobData,
1375
- providers: ExuluProvider[],
1376
1367
  ): Promise<{
1377
1368
  agent: ExuluAgent;
1378
- provider: ExuluProvider;
1379
1369
  user: User;
1380
1370
  testCase: TestCase;
1381
1371
  evalRun: EvalRun;
@@ -1419,12 +1409,6 @@ const validateEvalPayload = async (
1419
1409
  throw new Error(`Agent ${evalRun.agent_id} not found in the database.`);
1420
1410
  }
1421
1411
 
1422
- const provider = providers.find((a) => a.id === agent.provider);
1423
-
1424
- if (!provider) {
1425
- throw new Error(`Provider ${agent.provider} not found in the database.`);
1426
- }
1427
-
1428
1412
  const user = await db.from("users").where({ id: data.user }).first();
1429
1413
 
1430
1414
  if (!user) {
@@ -1439,7 +1423,6 @@ const validateEvalPayload = async (
1439
1423
 
1440
1424
  return {
1441
1425
  agent,
1442
- provider,
1443
1426
  user,
1444
1427
  testCase,
1445
1428
  evalRun,
@@ -1503,9 +1486,7 @@ const pollJobResult = async ({
1503
1486
  };
1504
1487
 
1505
1488
  export const processUiMessagesFlow = async ({
1506
- providers,
1507
1489
  agent,
1508
- provider,
1509
1490
  inputMessages,
1510
1491
  contexts,
1511
1492
  user,
@@ -1517,9 +1498,7 @@ export const processUiMessagesFlow = async ({
1517
1498
  resumeFromIndex,
1518
1499
  respectToolApprovals,
1519
1500
  }: {
1520
- providers: ExuluProvider[];
1521
1501
  agent: ExuluAgent;
1522
- provider: ExuluProvider;
1523
1502
  inputMessages: UIMessage[];
1524
1503
  contexts: ExuluContext[];
1525
1504
  user: User;
@@ -1579,7 +1558,6 @@ export const processUiMessagesFlow = async ({
1579
1558
  tools,
1580
1559
  contexts,
1581
1560
  disabledTools,
1582
- providers,
1583
1561
  user,
1584
1562
  );
1585
1563
 
@@ -1597,11 +1575,10 @@ export const processUiMessagesFlow = async ({
1597
1575
  const resolved = await resolveModel({
1598
1576
  modelId: agent.model,
1599
1577
  user,
1600
- providers,
1601
1578
  agent: agent,
1602
1579
  routine,
1603
1580
  });
1604
- const providerapikey = resolved.apiKey;
1581
+
1605
1582
  const resolvedLanguageModel = resolved.languageModel;
1606
1583
 
1607
1584
  // Remove placeholder agent response before sending
@@ -1698,7 +1675,7 @@ export const processUiMessagesFlow = async ({
1698
1675
  const startTime = Date.now();
1699
1676
 
1700
1677
  try {
1701
- const result = await provider.generateStream({
1678
+ const result = await generateStream({
1702
1679
  contexts,
1703
1680
  agent: agent,
1704
1681
  user,
@@ -1714,7 +1691,6 @@ export const processUiMessagesFlow = async ({
1714
1691
  currentTools: enabledTools,
1715
1692
  allExuluTools: tools,
1716
1693
  languageModel: resolvedLanguageModel,
1717
- providerapikey,
1718
1694
  toolConfigs: agent.tools,
1719
1695
  exuluConfig: config,
1720
1696
  });
@@ -1737,6 +1713,16 @@ export const processUiMessagesFlow = async ({
1737
1713
  originalMessages: result.originalMessages,
1738
1714
  sendReasoning: true,
1739
1715
  sendSources: true,
1716
+ // Give each assistant message a real unique id (matches the live
1717
+ // chat path in routes.ts). Without this the SDK assigns id "",
1718
+ // and saveChat's global message_id upsert collapses every empty-id
1719
+ // message onto one frozen-createdAt row — which sorts the tool
1720
+ // approval to the top of the transcript and leaves it out of the
1721
+ // last-message slot the approval handler acts on (buttons inert).
1722
+ generateMessageId: createIdGenerator({
1723
+ prefix: "msg_",
1724
+ size: 16,
1725
+ }),
1740
1726
  onError: (error) => {
1741
1727
  console.error("[EXULU] Ui message stream error.", error);
1742
1728
  reject(new Error(error instanceof Error ? error.message : String(error)));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@exulu/backend",
3
3
  "author": "Qventu Bv.",
4
- "version": "3.0.0",
4
+ "version": "3.2.0",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
7
7
  "publishConfig": {