@exulu/backend 3.4.0 → 3.5.1

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.
@@ -3,7 +3,7 @@ import {
3
3
  __resetLiteLLMCatalogCacheForTesting,
4
4
  fetchLiteLLMCatalog,
5
5
  findLiteLLMModel
6
- } from "./chunk-NUVMV5FC.js";
6
+ } from "./chunk-4PDWNVNT.js";
7
7
  export {
8
8
  __resetLiteLLMCatalogCacheForTesting,
9
9
  fetchLiteLLMCatalog,
@@ -17,9 +17,14 @@ function litellmBase() {
17
17
  }
18
18
  return { url: `http://${host}:${port}`, masterKey };
19
19
  }
20
+ var _clientMode = false;
21
+ var isLiteLLMClientMode = () => _clientMode;
22
+ var setLiteLLMClientMode = (value) => {
23
+ _clientMode = value;
24
+ };
20
25
  function resolveLiteLLMTarget() {
21
26
  const rawBase = process.env.LITELLM_BASE_URL;
22
- if (rawBase && rawBase.trim().length > 0) {
27
+ if (isLiteLLMClientMode() && rawBase && rawBase.trim().length > 0) {
23
28
  const apiKey = process.env.EXULU_API_KEY;
24
29
  if (!apiKey) {
25
30
  throw new Error("EXULU_API_KEY is required when LITELLM_BASE_URL is set (remote LiteLLM client mode).");
@@ -46,7 +51,7 @@ var _cache;
46
51
  var __resetLiteLLMCatalogCacheForTesting = () => {
47
52
  _cache = void 0;
48
53
  };
49
- var fetchLiteLLMCatalog = async () => {
54
+ var fetchFullCatalog = async () => {
50
55
  if (process.env.EXULU_USE_LITELLM !== "true") return [];
51
56
  if (_cache && _cache.expiresAt > Date.now()) {
52
57
  return _cache.items;
@@ -105,21 +110,27 @@ var fetchLiteLLMCatalog = async () => {
105
110
  }
106
111
  const uniqueItems = Array.from(map.values());
107
112
  _cache = { expiresAt: Date.now() + CACHE_TTL_MS, items: uniqueItems };
108
- return uniqueItems.filter((m) => m.type !== "speech_to_text" && m.type !== "text_to_speech");
113
+ return uniqueItems;
109
114
  } catch (err) {
110
115
  console.error("[EXULU] litellmCatalog: failed to fetch /model/info:", err);
111
116
  return [];
112
117
  }
113
118
  };
119
+ var fetchLiteLLMCatalog = async () => {
120
+ const items = await fetchFullCatalog();
121
+ return items.filter((m) => m.type !== "speech_to_text" && m.type !== "text_to_speech");
122
+ };
114
123
  var findLiteLLMModel = async (modelName) => {
115
124
  if (!modelName) return void 0;
116
- const items = await fetchLiteLLMCatalog();
125
+ const items = await fetchFullCatalog();
117
126
  return items.find((m) => m.model_name === modelName);
118
127
  };
119
128
 
120
129
  export {
121
130
  LiteLLMAdminError,
122
131
  litellmBase,
132
+ isLiteLLMClientMode,
133
+ setLiteLLMClientMode,
123
134
  resolveLiteLLMTarget,
124
135
  __resetLiteLLMCatalogCacheForTesting,
125
136
  fetchLiteLLMCatalog,
@@ -2,9 +2,11 @@ import "dotenv/config";
2
2
  import {
3
3
  LiteLLMAdminError,
4
4
  findLiteLLMModel,
5
+ isLiteLLMClientMode,
5
6
  litellmBase,
6
- resolveLiteLLMTarget
7
- } from "./chunk-NUVMV5FC.js";
7
+ resolveLiteLLMTarget,
8
+ setLiteLLMClientMode
9
+ } from "./chunk-4PDWNVNT.js";
8
10
 
9
11
  // src/templates/tools/convert-exulu-tools-to-ai-sdk-tools.ts
10
12
  import { S3Client as S3Client3, PutObjectCommand as PutObjectCommand3, S3ServiceException } from "@aws-sdk/client-s3";
@@ -169,13 +171,12 @@ var supervise = async (cfg) => {
169
171
  }
170
172
  };
171
173
  var _packageRoot;
172
- var _clientMode = false;
173
174
  var setLiteLLMPackageRoot = (root) => {
174
175
  _packageRoot = root;
175
176
  };
176
177
  var enableLiteLLMClientMode = () => {
177
178
  if (internal.readyPromise) return;
178
- _clientMode = true;
179
+ setLiteLLMClientMode(true);
179
180
  };
180
181
  var startLiteLLMSupervisor = async (options = {}) => {
181
182
  if (!isLiteLLMEnabled()) return;
@@ -225,7 +226,7 @@ var startLiteLLMSupervisor = async (options = {}) => {
225
226
  };
226
227
  var waitForLiteLLMReady = async () => {
227
228
  if (!isLiteLLMEnabled()) return;
228
- if (_clientMode) {
229
+ if (isLiteLLMClientMode()) {
229
230
  if (internal.state === "ready") return;
230
231
  const { baseUrl, authHeaders, remote } = resolveLiteLLMTarget();
231
232
  const url = remote ? `${baseUrl}/v1/models` : `${baseUrl}/health/liveliness`;
@@ -1712,7 +1713,7 @@ var ExuluTool = class _ExuluTool {
1712
1713
  if (!agent) {
1713
1714
  throw new Error("Agent not found.");
1714
1715
  }
1715
- const { convertExuluToolsToAiSdkTools: convertExuluToolsToAiSdkTools2 } = await import("./convert-exulu-tools-to-ai-sdk-tools-AB4F55SD.js");
1716
+ const { convertExuluToolsToAiSdkTools: convertExuluToolsToAiSdkTools2 } = await import("./convert-exulu-tools-to-ai-sdk-tools-TZ2UKWR4.js");
1716
1717
  const tools = await convertExuluToolsToAiSdkTools2(
1717
1718
  [this],
1718
1719
  [],
@@ -2,8 +2,8 @@ import "dotenv/config";
2
2
  import {
3
3
  convertExuluToolsToAiSdkTools,
4
4
  hydrateVariables
5
- } from "./chunk-UIIUBZCO.js";
6
- import "./chunk-NUVMV5FC.js";
5
+ } from "./chunk-7AZH4ETH.js";
6
+ import "./chunk-4PDWNVNT.js";
7
7
  export {
8
8
  convertExuluToolsToAiSdkTools,
9
9
  hydrateVariables
package/dist/index.cjs CHANGED
@@ -1245,7 +1245,7 @@ function litellmBase() {
1245
1245
  }
1246
1246
  function resolveLiteLLMTarget() {
1247
1247
  const rawBase = process.env.LITELLM_BASE_URL;
1248
- if (rawBase && rawBase.trim().length > 0) {
1248
+ if (isLiteLLMClientMode() && rawBase && rawBase.trim().length > 0) {
1249
1249
  const apiKey = process.env.EXULU_API_KEY;
1250
1250
  if (!apiKey) {
1251
1251
  throw new Error("EXULU_API_KEY is required when LITELLM_BASE_URL is set (remote LiteLLM client mode).");
@@ -1265,7 +1265,7 @@ function resolveLiteLLMTarget() {
1265
1265
  remote: false
1266
1266
  };
1267
1267
  }
1268
- var LiteLLMAdminError;
1268
+ var LiteLLMAdminError, _clientMode, isLiteLLMClientMode, setLiteLLMClientMode;
1269
1269
  var init_env = __esm({
1270
1270
  "src/exulu/litellm/env.ts"() {
1271
1271
  "use strict";
@@ -1277,11 +1277,16 @@ var init_env = __esm({
1277
1277
  this.name = "LiteLLMAdminError";
1278
1278
  }
1279
1279
  };
1280
+ _clientMode = false;
1281
+ isLiteLLMClientMode = () => _clientMode;
1282
+ setLiteLLMClientMode = (value) => {
1283
+ _clientMode = value;
1284
+ };
1280
1285
  }
1281
1286
  });
1282
1287
 
1283
1288
  // src/exulu/litellm/supervisor.ts
1284
- var import_node_child_process, import_node_fs3, import_node_path2, LITELLM_UI_PATH, MAX_CRASHES, INITIAL_BACKOFF_MS, MAX_BACKOFF_MS, READY_TIMEOUT_MS, WAIT_TIMEOUT_MS, READY_POLL_INTERVAL_MS, SHUTDOWN_GRACE_MS, internal, isLiteLLMEnabled, resolveConfig, log2, pollHealth, spawnLiteLLM, supervise, _packageRoot, _clientMode, setLiteLLMPackageRoot, enableLiteLLMClientMode, startLiteLLMSupervisor, waitForLiteLLMReady, stopLiteLLM, shutdownHandlersRegistered, registerShutdownHandlers, getSupervisorState;
1289
+ var import_node_child_process, import_node_fs3, import_node_path2, LITELLM_UI_PATH, MAX_CRASHES, INITIAL_BACKOFF_MS, MAX_BACKOFF_MS, READY_TIMEOUT_MS, WAIT_TIMEOUT_MS, READY_POLL_INTERVAL_MS, SHUTDOWN_GRACE_MS, internal, isLiteLLMEnabled, resolveConfig, log2, pollHealth, spawnLiteLLM, supervise, _packageRoot, setLiteLLMPackageRoot, enableLiteLLMClientMode, startLiteLLMSupervisor, waitForLiteLLMReady, stopLiteLLM, shutdownHandlersRegistered, registerShutdownHandlers, getSupervisorState;
1285
1290
  var init_supervisor = __esm({
1286
1291
  "src/exulu/litellm/supervisor.ts"() {
1287
1292
  "use strict";
@@ -1416,13 +1421,12 @@ var init_supervisor = __esm({
1416
1421
  internal.backoffMs = Math.min(internal.backoffMs * 2, MAX_BACKOFF_MS);
1417
1422
  }
1418
1423
  };
1419
- _clientMode = false;
1420
1424
  setLiteLLMPackageRoot = (root) => {
1421
1425
  _packageRoot = root;
1422
1426
  };
1423
1427
  enableLiteLLMClientMode = () => {
1424
1428
  if (internal.readyPromise) return;
1425
- _clientMode = true;
1429
+ setLiteLLMClientMode(true);
1426
1430
  };
1427
1431
  startLiteLLMSupervisor = async (options = {}) => {
1428
1432
  if (!isLiteLLMEnabled()) return;
@@ -1472,7 +1476,7 @@ var init_supervisor = __esm({
1472
1476
  };
1473
1477
  waitForLiteLLMReady = async () => {
1474
1478
  if (!isLiteLLMEnabled()) return;
1475
- if (_clientMode) {
1479
+ if (isLiteLLMClientMode()) {
1476
1480
  if (internal.state === "ready") return;
1477
1481
  const { baseUrl, authHeaders, remote } = resolveLiteLLMTarget();
1478
1482
  const url = remote ? `${baseUrl}/v1/models` : `${baseUrl}/health/liveliness`;
@@ -4979,7 +4983,7 @@ __export(catalog_exports, {
4979
4983
  fetchLiteLLMCatalog: () => fetchLiteLLMCatalog,
4980
4984
  findLiteLLMModel: () => findLiteLLMModel
4981
4985
  });
4982
- var CACHE_TTL_MS, _cache, __resetLiteLLMCatalogCacheForTesting, fetchLiteLLMCatalog, findLiteLLMModel;
4986
+ var CACHE_TTL_MS, _cache, __resetLiteLLMCatalogCacheForTesting, fetchFullCatalog, fetchLiteLLMCatalog, findLiteLLMModel;
4983
4987
  var init_catalog = __esm({
4984
4988
  "src/exulu/litellm/catalog.ts"() {
4985
4989
  "use strict";
@@ -4989,7 +4993,7 @@ var init_catalog = __esm({
4989
4993
  __resetLiteLLMCatalogCacheForTesting = () => {
4990
4994
  _cache = void 0;
4991
4995
  };
4992
- fetchLiteLLMCatalog = async () => {
4996
+ fetchFullCatalog = async () => {
4993
4997
  if (process.env.EXULU_USE_LITELLM !== "true") return [];
4994
4998
  if (_cache && _cache.expiresAt > Date.now()) {
4995
4999
  return _cache.items;
@@ -5048,15 +5052,19 @@ var init_catalog = __esm({
5048
5052
  }
5049
5053
  const uniqueItems = Array.from(map.values());
5050
5054
  _cache = { expiresAt: Date.now() + CACHE_TTL_MS, items: uniqueItems };
5051
- return uniqueItems.filter((m) => m.type !== "speech_to_text" && m.type !== "text_to_speech");
5055
+ return uniqueItems;
5052
5056
  } catch (err) {
5053
5057
  console.error("[EXULU] litellmCatalog: failed to fetch /model/info:", err);
5054
5058
  return [];
5055
5059
  }
5056
5060
  };
5061
+ fetchLiteLLMCatalog = async () => {
5062
+ const items = await fetchFullCatalog();
5063
+ return items.filter((m) => m.type !== "speech_to_text" && m.type !== "text_to_speech");
5064
+ };
5057
5065
  findLiteLLMModel = async (modelName) => {
5058
5066
  if (!modelName) return void 0;
5059
- const items = await fetchLiteLLMCatalog();
5067
+ const items = await fetchFullCatalog();
5060
5068
  return items.find((m) => m.model_name === modelName);
5061
5069
  };
5062
5070
  }
@@ -23875,6 +23883,18 @@ ${summary}` }],
23875
23883
  // src/exulu/transcribe.ts
23876
23884
  init_cjs_shims();
23877
23885
  init_env();
23886
+ init_catalog();
23887
+ var TRANSCRIBE_SYSTEM_PROMPT = "You are a speech-to-text transcription engine. Detect the language actually spoken and transcribe it word-for-word in that same language. Never translate. Output only the transcript text \u2014 no quotes, labels, or commentary. If there is no intelligible speech, output nothing.";
23888
+ function isGeminiChatTranscriptionModel(entry) {
23889
+ const upstream = entry?.upstream_model ?? "";
23890
+ return /^vertex_ai\//i.test(upstream) && /gemini/i.test(upstream);
23891
+ }
23892
+ function cleanTranscript(raw) {
23893
+ let text = (raw ?? "").trim();
23894
+ const wrapped = text.match(/^(["'`])([\s\S]*)\1$/);
23895
+ if (wrapped) text = (wrapped[2] ?? "").trim();
23896
+ return text;
23897
+ }
23878
23898
  var TranscriptionError = class extends Error {
23879
23899
  constructor(upstreamStatus, message) {
23880
23900
  super(message);
@@ -23883,9 +23903,16 @@ var TranscriptionError = class extends Error {
23883
23903
  }
23884
23904
  };
23885
23905
  async function transcribeAudio(args) {
23886
- const { baseUrl, authHeaders } = resolveLiteLLMTarget();
23906
+ const target = resolveLiteLLMTarget();
23887
23907
  const model = process.env.TRANSCRIPTION_MODEL;
23888
23908
  if (!model) throw new Error("TRANSCRIPTION_MODEL is not set");
23909
+ const entry = await findLiteLLMModel(model).catch(() => void 0);
23910
+ if (isGeminiChatTranscriptionModel(entry)) {
23911
+ return transcribeViaChat(args, target, model);
23912
+ }
23913
+ return transcribeViaAudioEndpoint(args, target, model);
23914
+ }
23915
+ async function transcribeViaAudioEndpoint(args, target, model) {
23889
23916
  const form = new FormData();
23890
23917
  form.append(
23891
23918
  "file",
@@ -23894,9 +23921,9 @@ async function transcribeAudio(args) {
23894
23921
  );
23895
23922
  form.append("model", model);
23896
23923
  if (args.language) form.append("language", args.language);
23897
- const res = await fetch(`${baseUrl}/v1/audio/transcriptions`, {
23924
+ const res = await fetch(`${target.baseUrl}/v1/audio/transcriptions`, {
23898
23925
  method: "POST",
23899
- headers: { ...authHeaders },
23926
+ headers: { ...target.authHeaders },
23900
23927
  body: form
23901
23928
  });
23902
23929
  if (!res.ok) {
@@ -23909,6 +23936,42 @@ async function transcribeAudio(args) {
23909
23936
  const json = await res.json();
23910
23937
  return { text: typeof json.text === "string" ? json.text : "" };
23911
23938
  }
23939
+ async function transcribeViaChat(args, target, model) {
23940
+ const subtype = args.file.mimetype.replace(/^audio\//, "").split(";")[0];
23941
+ const format = (subtype && subtype.length > 0 ? subtype : "wav").toLowerCase();
23942
+ const body = {
23943
+ model,
23944
+ temperature: 0,
23945
+ reasoning_effort: "disable",
23946
+ messages: [
23947
+ { role: "system", content: TRANSCRIBE_SYSTEM_PROMPT },
23948
+ {
23949
+ role: "user",
23950
+ content: [
23951
+ { type: "text", text: "Transcribe this audio." },
23952
+ {
23953
+ type: "input_audio",
23954
+ input_audio: { data: args.file.buffer.toString("base64"), format }
23955
+ }
23956
+ ]
23957
+ }
23958
+ ]
23959
+ };
23960
+ const res = await fetch(`${target.baseUrl}/v1/chat/completions`, {
23961
+ method: "POST",
23962
+ headers: { ...target.authHeaders, "Content-Type": "application/json" },
23963
+ body: JSON.stringify(body)
23964
+ });
23965
+ if (!res.ok) {
23966
+ const errBody = await res.text().catch(() => "");
23967
+ throw new TranscriptionError(
23968
+ res.status,
23969
+ `LiteLLM transcription failed (status ${res.status}): ${errBody}`.trim()
23970
+ );
23971
+ }
23972
+ const json = await res.json();
23973
+ return { text: cleanTranscript(json.choices?.[0]?.message?.content) };
23974
+ }
23912
23975
 
23913
23976
  // src/exulu/speech.ts
23914
23977
  init_cjs_shims();
@@ -33335,7 +33398,16 @@ ${setupResult.output || ""}`);
33335
33398
  args: [paths.source, chunksDir, "--chunk-size", String(maxPagesPerChunk), "--max-size-mb", "25"],
33336
33399
  timeout: 5 * 60 * 1e3
33337
33400
  });
33338
- const pdfChunks = JSON.parse(splitResult.stdout);
33401
+ let pdfChunks;
33402
+ try {
33403
+ pdfChunks = JSON.parse(splitResult.stdout);
33404
+ } catch (err) {
33405
+ throw new Error(
33406
+ `[EXULU] split_pdf.py returned invalid JSON on stdout: ${err.message}
33407
+ stdout: ${splitResult.stdout.slice(0, 500)}
33408
+ stderr: ${splitResult.stderr.slice(-1e3)}`
33409
+ );
33410
+ }
33339
33411
  console.log(`[EXULU] PDF split into ${pdfChunks.length} chunk(s) for OCR (max ${maxPagesPerChunk} pages each)`);
33340
33412
  const chunkLimit = (0, import_p_limit.default)(3);
33341
33413
  const chunkResults = await Promise.all(
package/dist/index.js CHANGED
@@ -87,12 +87,12 @@ import {
87
87
  verifyCredentialNonce,
88
88
  waitForLiteLLMReady,
89
89
  withRetry
90
- } from "./chunk-UIIUBZCO.js";
90
+ } from "./chunk-7AZH4ETH.js";
91
91
  import {
92
92
  LiteLLMAdminError,
93
93
  findLiteLLMModel,
94
94
  resolveLiteLLMTarget
95
- } from "./chunk-NUVMV5FC.js";
95
+ } from "./chunk-4PDWNVNT.js";
96
96
 
97
97
  // src/index.ts
98
98
  import "dotenv/config";
@@ -12867,7 +12867,7 @@ type LiteLLMModel {
12867
12867
  }
12868
12868
  `;
12869
12869
  resolvers.Query["litellmCatalog"] = async () => {
12870
- const { fetchLiteLLMCatalog } = await import("./catalog-PZTI2MJZ.js");
12870
+ const { fetchLiteLLMCatalog } = await import("./catalog-3QLU2G7R.js");
12871
12871
  return fetchLiteLLMCatalog();
12872
12872
  };
12873
12873
  resolvers.Query["workflowSchedule"] = async (_, args, context, info) => {
@@ -14821,6 +14821,17 @@ ${summary}` }],
14821
14821
  };
14822
14822
 
14823
14823
  // src/exulu/transcribe.ts
14824
+ var TRANSCRIBE_SYSTEM_PROMPT = "You are a speech-to-text transcription engine. Detect the language actually spoken and transcribe it word-for-word in that same language. Never translate. Output only the transcript text \u2014 no quotes, labels, or commentary. If there is no intelligible speech, output nothing.";
14825
+ function isGeminiChatTranscriptionModel(entry) {
14826
+ const upstream = entry?.upstream_model ?? "";
14827
+ return /^vertex_ai\//i.test(upstream) && /gemini/i.test(upstream);
14828
+ }
14829
+ function cleanTranscript(raw) {
14830
+ let text = (raw ?? "").trim();
14831
+ const wrapped = text.match(/^(["'`])([\s\S]*)\1$/);
14832
+ if (wrapped) text = (wrapped[2] ?? "").trim();
14833
+ return text;
14834
+ }
14824
14835
  var TranscriptionError = class extends Error {
14825
14836
  constructor(upstreamStatus, message) {
14826
14837
  super(message);
@@ -14829,9 +14840,16 @@ var TranscriptionError = class extends Error {
14829
14840
  }
14830
14841
  };
14831
14842
  async function transcribeAudio(args) {
14832
- const { baseUrl, authHeaders } = resolveLiteLLMTarget();
14843
+ const target = resolveLiteLLMTarget();
14833
14844
  const model = process.env.TRANSCRIPTION_MODEL;
14834
14845
  if (!model) throw new Error("TRANSCRIPTION_MODEL is not set");
14846
+ const entry = await findLiteLLMModel(model).catch(() => void 0);
14847
+ if (isGeminiChatTranscriptionModel(entry)) {
14848
+ return transcribeViaChat(args, target, model);
14849
+ }
14850
+ return transcribeViaAudioEndpoint(args, target, model);
14851
+ }
14852
+ async function transcribeViaAudioEndpoint(args, target, model) {
14835
14853
  const form = new FormData();
14836
14854
  form.append(
14837
14855
  "file",
@@ -14840,9 +14858,9 @@ async function transcribeAudio(args) {
14840
14858
  );
14841
14859
  form.append("model", model);
14842
14860
  if (args.language) form.append("language", args.language);
14843
- const res = await fetch(`${baseUrl}/v1/audio/transcriptions`, {
14861
+ const res = await fetch(`${target.baseUrl}/v1/audio/transcriptions`, {
14844
14862
  method: "POST",
14845
- headers: { ...authHeaders },
14863
+ headers: { ...target.authHeaders },
14846
14864
  body: form
14847
14865
  });
14848
14866
  if (!res.ok) {
@@ -14855,6 +14873,42 @@ async function transcribeAudio(args) {
14855
14873
  const json = await res.json();
14856
14874
  return { text: typeof json.text === "string" ? json.text : "" };
14857
14875
  }
14876
+ async function transcribeViaChat(args, target, model) {
14877
+ const subtype = args.file.mimetype.replace(/^audio\//, "").split(";")[0];
14878
+ const format = (subtype && subtype.length > 0 ? subtype : "wav").toLowerCase();
14879
+ const body = {
14880
+ model,
14881
+ temperature: 0,
14882
+ reasoning_effort: "disable",
14883
+ messages: [
14884
+ { role: "system", content: TRANSCRIBE_SYSTEM_PROMPT },
14885
+ {
14886
+ role: "user",
14887
+ content: [
14888
+ { type: "text", text: "Transcribe this audio." },
14889
+ {
14890
+ type: "input_audio",
14891
+ input_audio: { data: args.file.buffer.toString("base64"), format }
14892
+ }
14893
+ ]
14894
+ }
14895
+ ]
14896
+ };
14897
+ const res = await fetch(`${target.baseUrl}/v1/chat/completions`, {
14898
+ method: "POST",
14899
+ headers: { ...target.authHeaders, "Content-Type": "application/json" },
14900
+ body: JSON.stringify(body)
14901
+ });
14902
+ if (!res.ok) {
14903
+ const errBody = await res.text().catch(() => "");
14904
+ throw new TranscriptionError(
14905
+ res.status,
14906
+ `LiteLLM transcription failed (status ${res.status}): ${errBody}`.trim()
14907
+ );
14908
+ }
14909
+ const json = await res.json();
14910
+ return { text: cleanTranscript(json.choices?.[0]?.message?.content) };
14911
+ }
14858
14912
 
14859
14913
  // src/exulu/speech.ts
14860
14914
  var SpeechError = class extends Error {
@@ -23938,7 +23992,16 @@ ${setupResult.output || ""}`);
23938
23992
  args: [paths.source, chunksDir, "--chunk-size", String(maxPagesPerChunk), "--max-size-mb", "25"],
23939
23993
  timeout: 5 * 60 * 1e3
23940
23994
  });
23941
- const pdfChunks = JSON.parse(splitResult.stdout);
23995
+ let pdfChunks;
23996
+ try {
23997
+ pdfChunks = JSON.parse(splitResult.stdout);
23998
+ } catch (err) {
23999
+ throw new Error(
24000
+ `[EXULU] split_pdf.py returned invalid JSON on stdout: ${err.message}
24001
+ stdout: ${splitResult.stdout.slice(0, 500)}
24002
+ stderr: ${splitResult.stderr.slice(-1e3)}`
24003
+ );
24004
+ }
23942
24005
  console.log(`[EXULU] PDF split into ${pdfChunks.length} chunk(s) for OCR (max ${maxPagesPerChunk} pages each)`);
23943
24006
  const chunkLimit = pLimit(3);
23944
24007
  const chunkResults = await Promise.all(
@@ -837,8 +837,20 @@ async function processPdf(
837
837
  timeout: 5 * 60 * 1000,
838
838
  });
839
839
 
840
- const pdfChunks: Array<{ path: string; start_page: number; end_page: number }> =
841
- JSON.parse(splitResult.stdout);
840
+ // split_pdf.py contracts to put nothing but the JSON payload on stdout.
841
+ // If something slips in anyway, the bare SyntaxError only quotes the first
842
+ // few characters ("Unexpected token 'w'") and names neither the script nor
843
+ // the offending output — so re-throw with the actual streams attached.
844
+ let pdfChunks: Array<{ path: string; start_page: number; end_page: number }>;
845
+ try {
846
+ pdfChunks = JSON.parse(splitResult.stdout);
847
+ } catch (err) {
848
+ throw new Error(
849
+ `[EXULU] split_pdf.py returned invalid JSON on stdout: ${(err as Error).message}\n` +
850
+ `stdout: ${splitResult.stdout.slice(0, 500)}\n` +
851
+ `stderr: ${splitResult.stderr.slice(-1000)}`
852
+ );
853
+ }
842
854
 
843
855
  console.log(`[EXULU] PDF split into ${pdfChunks.length} chunk(s) for OCR (max ${maxPagesPerChunk} pages each)`);
844
856
 
@@ -20,7 +20,24 @@ import os
20
20
  import json
21
21
  import argparse
22
22
 
23
- import fitz # PyMuPDF installed as a docling transitive dependency
23
+ # stdout is this script's result channel and the caller does JSON.parse() on it,
24
+ # so nothing else may write there. Our own prints all pass file=sys.stderr, but
25
+ # dependencies do not honour that: PyMuPDF sends its messages to sys.stdout by
26
+ # default, and importing the legacy `fitz` alias emits
27
+ # "warning: The `fitz` API is deprecated ..." — which lands ahead of the payload
28
+ # and fails the caller with "Unexpected token 'w'". PyMuPDF arrives unpinned as
29
+ # a docling transitive dependency, so a routine rebuild is enough to introduce a
30
+ # banner like that. Point sys.stdout at stderr before importing anything and
31
+ # keep a private handle for the result, so any library that prints — now or
32
+ # after a future dependency bump — is shunted to the log channel instead of
33
+ # corrupting the payload.
34
+ _stdout = sys.stdout
35
+ sys.stdout = sys.stderr
36
+
37
+ try:
38
+ import pymupdf as fitz # PyMuPDF >= 1.24.3, where the module was renamed
39
+ except ImportError: # older releases only ship the legacy `fitz` module
40
+ import fitz
24
41
 
25
42
 
26
43
  def _write_chunk(
@@ -145,7 +162,7 @@ if __name__ == "__main__":
145
162
 
146
163
  try:
147
164
  chunks = split_pdf(args.input_pdf, args.output_dir, args.chunk_size, max_size_bytes)
148
- print(json.dumps(chunks))
165
+ print(json.dumps(chunks), file=_stdout)
149
166
  except Exception as e:
150
167
  print(f"[split_pdf] ERROR: {e}", file=sys.stderr)
151
168
  sys.exit(1)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@exulu/backend",
3
3
  "author": "Qventu Bv.",
4
- "version": "3.4.0",
4
+ "version": "3.5.1",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
7
7
  "publishConfig": {