@imenam/unified-llm-client 2.1.2 → 2.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,11 @@
1
1
  import { UnifiedLog } from "../../common/types";
2
2
  import { LogAdapter, LogData } from "../../core/LLMAdapter";
3
3
  export declare class QwenLogAdapter implements LogAdapter {
4
+ /**
5
+ * Remplace les données base64 des images et de l'audio par un marqueur avant
6
+ * journalisation. Un message dont `content` est une chaîne est renvoyé tel
7
+ * quel. Les messages reçus ne sont pas modifiés.
8
+ */
4
9
  removeImages(messages: any[]): any[];
5
10
  toUnifiedLog(data: LogData): UnifiedLog;
6
11
  }
@@ -1 +1 @@
1
- {"version":3,"file":"QwenLogAdapter.d.ts","sourceRoot":"","sources":["../../../src/adapters/qwen/QwenLogAdapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAY,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAE5D,qBAAa,cAAe,YAAW,UAAU;IAC/C,YAAY,CAAC,QAAQ,EAAE,GAAG,EAAE,GAAG,GAAG,EAAE;IAapC,YAAY,CAAC,IAAI,EAAE,OAAO,GAAG,UAAU;CAgCxC"}
1
+ {"version":3,"file":"QwenLogAdapter.d.ts","sourceRoot":"","sources":["../../../src/adapters/qwen/QwenLogAdapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAY,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAE5D,qBAAa,cAAe,YAAW,UAAU;IAC/C;;;;OAIG;IACH,YAAY,CAAC,QAAQ,EAAE,GAAG,EAAE,GAAG,GAAG,EAAE;IAyBpC,YAAY,CAAC,IAAI,EAAE,OAAO,GAAG,UAAU;CAgCxC"}
@@ -2,17 +2,26 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.QwenLogAdapter = void 0;
4
4
  class QwenLogAdapter {
5
+ /**
6
+ * Remplace les données base64 des images et de l'audio par un marqueur avant
7
+ * journalisation. Un message dont `content` est une chaîne est renvoyé tel
8
+ * quel. Les messages reçus ne sont pas modifiés.
9
+ */
5
10
  removeImages(messages) {
6
- const formattedMessages = messages.map((message) => {
7
- message.content = message.content.map((content) => {
8
- if (content.type === 'image_url') {
9
- content.image_url.url = content.image_url.url.split(',')[0] + "removed";
10
- }
11
- return content;
12
- });
13
- return message;
11
+ return messages.map((message) => {
12
+ if (!Array.isArray(message === null || message === void 0 ? void 0 : message.content))
13
+ return message;
14
+ return Object.assign(Object.assign({}, message), { content: message.content.map((content) => {
15
+ var _a, _b;
16
+ if ((content === null || content === void 0 ? void 0 : content.type) === 'image_url' && typeof ((_a = content.image_url) === null || _a === void 0 ? void 0 : _a.url) === 'string') {
17
+ return Object.assign(Object.assign({}, content), { image_url: Object.assign(Object.assign({}, content.image_url), { url: content.image_url.url.split(',')[0] + "removed" }) });
18
+ }
19
+ if ((content === null || content === void 0 ? void 0 : content.type) === 'input_audio' && typeof ((_b = content.input_audio) === null || _b === void 0 ? void 0 : _b.data) === 'string') {
20
+ return Object.assign(Object.assign({}, content), { input_audio: Object.assign(Object.assign({}, content.input_audio), { data: "removed" }) });
21
+ }
22
+ return content;
23
+ }) });
14
24
  });
15
- return formattedMessages;
16
25
  }
17
26
  toUnifiedLog(data) {
18
27
  var _a, _b, _c, _d, _e;
@@ -1 +1 @@
1
- {"version":3,"file":"QwenPromptAdapter.d.ts","sourceRoot":"","sources":["../../../src/adapters/qwen/QwenPromptAdapter.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,oBAAoB,EAAE,aAAa,EAAE,iBAAiB,EAAE,kBAAkB,EAAqB,MAAM,uBAAuB,CAAC;AACtI,OAAO,EAAE,WAAW,EAAY,cAAc,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAIzF,qBAAa,iBAAkB,YAAW,aAAa;IAIzC,OAAO,CAAC,QAAQ,CAAC,IAAI;IAHjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAa;gBAEA,IAAI,EAAE,oBAAoB;IAMhD,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,GAAG;IA8EzC,cAAc,CAClB,QAAQ,EAAE,GAAG,EACb,KAAK,GAAE,cAAc,EAAO,EAC5B,UAAU,GAAE,MAAM,GAAG,MAAM,GAAG,MAAe,EAC7C,YAAY,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,EAChC,KAAK,CAAC,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,kBAAkB,GAC1B,OAAO,CAAC,YAAY,CAAC;IAiClB,kBAAkB,CAAC,CAAC,EACxB,QAAQ,EACR,KAAK,EACL,UAAU,EACV,KAAK,EACL,OAAO,EACP,eAAe,EACf,MAAM,GACP,EAAE,iBAAiB,GAAG,OAAO,CAAC,YAAY,CAAC;YA6K9B,OAAO;CAgBtB"}
1
+ {"version":3,"file":"QwenPromptAdapter.d.ts","sourceRoot":"","sources":["../../../src/adapters/qwen/QwenPromptAdapter.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,oBAAoB,EAAE,aAAa,EAAE,iBAAiB,EAAE,kBAAkB,EAAqB,MAAM,uBAAuB,CAAC;AACtI,OAAO,EAAE,WAAW,EAAY,cAAc,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAoFzF,qBAAa,iBAAkB,YAAW,aAAa;IAIzC,OAAO,CAAC,QAAQ,CAAC,IAAI;IAHjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAa;gBAEA,IAAI,EAAE,oBAAoB;IAMhD,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,GAAG;IAiGzC,cAAc,CAClB,QAAQ,EAAE,GAAG,EACb,KAAK,GAAE,cAAc,EAAO,EAC5B,UAAU,GAAE,MAAM,GAAG,MAAM,GAAG,MAAe,EAC7C,YAAY,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,EAChC,KAAK,CAAC,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,kBAAkB,GAC1B,OAAO,CAAC,YAAY,CAAC;IAmClB,kBAAkB,CAAC,CAAC,EACxB,QAAQ,EACR,KAAK,EACL,UAAU,EACV,KAAK,EACL,OAAO,EACP,eAAe,EACf,MAAM,GACP,EAAE,iBAAiB,GAAG,OAAO,CAAC,YAAY,CAAC;YA4K9B,OAAO;CAgBtB"}
@@ -49,6 +49,75 @@ exports.QwenPromptAdapter = void 0;
49
49
  const node_fetch_1 = __importDefault(require("node-fetch"));
50
50
  const fs = __importStar(require("fs"));
51
51
  const path = __importStar(require("path"));
52
+ const IMAGE_MIME_TYPES = {
53
+ ".jpg": "image/jpeg",
54
+ ".jpeg": "image/jpeg",
55
+ ".png": "image/png",
56
+ ".gif": "image/gif",
57
+ ".webp": "image/webp",
58
+ ".pdf": "application/pdf",
59
+ };
60
+ /**
61
+ * Extensions audio acceptées par les modèles Qwen-Omni, associées à la valeur
62
+ * attendue dans `input_audio.format`.
63
+ */
64
+ const AUDIO_FORMATS = {
65
+ ".mp3": "mp3",
66
+ ".wav": "wav",
67
+ ".aac": "aac",
68
+ ".amr": "amr",
69
+ ".3gp": "3gp",
70
+ ".3gpp": "3gpp",
71
+ };
72
+ /**
73
+ * Extensions audio refusées par Qwen : un fichier de ce type lève une erreur
74
+ * au lieu d'être envoyé comme une image.
75
+ */
76
+ const UNSUPPORTED_AUDIO_EXTENSIONS = [".m4a", ".ogg", ".oga", ".opus", ".flac", ".wma", ".webm"];
77
+ /** Taille maximale d'un audio envoyé en base64, mesurée après encodage. */
78
+ const MAX_INLINE_AUDIO_BYTES = 10 * 1024 * 1024;
79
+ function base64Length(byteLength) {
80
+ return Math.ceil(byteLength / 3) * 4;
81
+ }
82
+ /**
83
+ * Regroupe par `index` les fragments de tool calls reçus en streaming : le
84
+ * premier fragment porte l'identifiant et le nom, les suivants complètent
85
+ * `arguments`.
86
+ */
87
+ function accumulateToolCalls(deltaToolCalls, parts) {
88
+ deltaToolCalls.forEach((toolCall, position) => {
89
+ var _a, _b, _c;
90
+ const index = typeof toolCall.index === "number" ? toolCall.index : position;
91
+ const current = (_a = parts.get(index)) !== null && _a !== void 0 ? _a : { name: "", arguments: "" };
92
+ if (toolCall.id)
93
+ current.id = toolCall.id;
94
+ if ((_b = toolCall.function) === null || _b === void 0 ? void 0 : _b.name)
95
+ current.name = toolCall.function.name;
96
+ if (typeof ((_c = toolCall.function) === null || _c === void 0 ? void 0 : _c.arguments) === "string") {
97
+ current.arguments += toolCall.function.arguments;
98
+ }
99
+ parts.set(index, current);
100
+ });
101
+ }
102
+ function finalizeToolCalls(parts) {
103
+ return [...parts.entries()]
104
+ .sort(([a], [b]) => a - b)
105
+ .map(([, part]) => {
106
+ let parsed = {};
107
+ try {
108
+ parsed = JSON.parse(part.arguments || "{}");
109
+ }
110
+ catch (_a) {
111
+ // Arguments non parsables : le texte brut est renvoyé sous `_raw`.
112
+ parsed = { _raw: part.arguments };
113
+ }
114
+ return { id: part.id, name: part.name, arguments: parsed };
115
+ });
116
+ }
117
+ /** Indique si le modèle est un modèle Omni, qui requiert le paramètre `modalities`. */
118
+ function isOmniModel(model) {
119
+ return !!model && /omni/i.test(model);
120
+ }
52
121
  class QwenPromptAdapter {
53
122
  constructor(opts) {
54
123
  var _a, _b;
@@ -59,7 +128,7 @@ class QwenPromptAdapter {
59
128
  this.endpoint += "/chat/completions";
60
129
  }
61
130
  formatMessage(message) {
62
- var _a;
131
+ var _a, _b;
63
132
  // Assistant message with tool calls → OpenAI-compatible format
64
133
  if (message.role === "assistant" && message.tool_calls && message.tool_calls.length > 0) {
65
134
  return {
@@ -78,55 +147,57 @@ class QwenPromptAdapter {
78
147
  }),
79
148
  };
80
149
  }
81
- // Si le message contient des attachedFile, les convertir au format Qwen VL
150
+ // Si le message contient des attachedFile, les convertir au format Qwen VL / Omni
82
151
  if (message.attachedFile) {
83
- const content = [];
84
- // Ajouter le texte si présent
85
- if (message.content && typeof message.content === 'string') {
86
- content.push({
87
- type: "text",
88
- text: message.content
89
- });
90
- }
91
- // Ajouter les fichiers attachés
152
+ const textPart = message.content && typeof message.content === 'string'
153
+ ? { type: "text", text: message.content }
154
+ : null;
92
155
  const files = Array.isArray(message.attachedFile) ? message.attachedFile : [message.attachedFile];
156
+ const fileParts = [];
93
157
  for (const file of files) {
94
- if (typeof file === 'string') {
95
- try {
96
- // Lire le fichier et le convertir en base64
97
- const filePath = path.resolve(file);
98
- const fileBuffer = fs.readFileSync(filePath);
99
- const base64Data = fileBuffer.toString('base64');
100
- // Déterminer le type MIME
101
- const ext = path.extname(filePath).toLowerCase();
102
- let mimeType = 'application/octet-stream';
103
- if (ext === '.jpg' || ext === '.jpeg') {
104
- mimeType = 'image/jpeg';
105
- }
106
- else if (ext === '.png') {
107
- mimeType = 'image/png';
108
- }
109
- else if (ext === '.gif') {
110
- mimeType = 'image/gif';
111
- }
112
- else if (ext === '.webp') {
113
- mimeType = 'image/webp';
114
- }
115
- else if (ext === '.pdf') {
116
- mimeType = 'application/pdf';
117
- }
118
- content.push({
119
- type: "image_url",
120
- image_url: {
121
- url: `data:${mimeType};base64,${base64Data}`
122
- }
123
- });
124
- }
125
- catch (error) {
126
- console.warn(`Failed to read attached file ${file}:`, error);
158
+ if (typeof file !== 'string')
159
+ continue;
160
+ const filePath = path.resolve(file);
161
+ const ext = path.extname(filePath).toLowerCase();
162
+ const audioFormat = AUDIO_FORMATS[ext];
163
+ // Les erreurs de lecture et de taille d'un audio sont propagées.
164
+ if (UNSUPPORTED_AUDIO_EXTENSIONS.includes(ext)) {
165
+ throw new Error(`Audio format ${ext} is not supported by Qwen. Transcode ${file} to one of: ${Object.keys(AUDIO_FORMATS).join(", ")}.`);
166
+ }
167
+ if (audioFormat) {
168
+ const encodedSize = base64Length(fs.statSync(filePath).size);
169
+ if (encodedSize > MAX_INLINE_AUDIO_BYTES) {
170
+ throw new Error(`Audio file ${file} is ${(encodedSize / 1024 / 1024).toFixed(1)} MB once Base64-encoded, above the ${MAX_INLINE_AUDIO_BYTES / 1024 / 1024} MB limit of the Qwen API. Split it or lower the bitrate.`);
127
171
  }
172
+ fileParts.push({
173
+ type: "input_audio",
174
+ input_audio: {
175
+ // DashScope attend le préfixe `data:;base64,`.
176
+ data: `data:;base64,${fs.readFileSync(filePath).toString('base64')}`,
177
+ format: audioFormat,
178
+ },
179
+ });
180
+ continue;
181
+ }
182
+ try {
183
+ const base64Data = fs.readFileSync(filePath).toString('base64');
184
+ const mimeType = (_b = IMAGE_MIME_TYPES[ext]) !== null && _b !== void 0 ? _b : 'application/octet-stream';
185
+ fileParts.push({
186
+ type: "image_url",
187
+ image_url: {
188
+ url: `data:${mimeType};base64,${base64Data}`
189
+ }
190
+ });
191
+ }
192
+ catch (error) {
193
+ console.warn(`Failed to read attached file ${file}:`, error);
128
194
  }
129
195
  }
196
+ // L'audio précède le texte ; les images le suivent.
197
+ const hasAudio = fileParts.some(part => part.type === "input_audio");
198
+ const content = hasAudio
199
+ ? [...fileParts, ...(textPart ? [textPart] : [])]
200
+ : [...(textPart ? [textPart] : []), ...fileParts];
130
201
  return {
131
202
  role: message.role,
132
203
  content: content
@@ -138,11 +209,8 @@ class QwenPromptAdapter {
138
209
  sendToProvider(messages_1) {
139
210
  return __awaiter(this, arguments, void 0, function* (messages, tools = [], toolChoice = "auto", attachedFile, model, config) {
140
211
  var _a;
141
- const body = {
142
- model: model || this.opts.model,
143
- messages,
144
- stream: false,
145
- };
212
+ const resolvedModel = model || this.opts.model;
213
+ const body = Object.assign({ model: resolvedModel, messages, stream: false }, (isOmniModel(resolvedModel) ? { modalities: ["text"] } : {}));
146
214
  if (tools.length) {
147
215
  body.tools = tools.map((fn) => ({ type: "function", function: fn }));
148
216
  body.tool_choice =
@@ -171,11 +239,10 @@ class QwenPromptAdapter {
171
239
  }
172
240
  streamFromProvider(_a) {
173
241
  return __awaiter(this, arguments, void 0, function* ({ messages, tools, toolChoice, model, onChunk, onFinalResponse, config, }) {
174
- const body = {
175
- model: model || this.opts.model,
176
- messages,
177
- stream: true,
178
- };
242
+ const resolvedModel = model || this.opts.model;
243
+ const body = Object.assign({ model: resolvedModel, messages, stream: true,
244
+ // Demande le décompte des tokens dans le dernier chunk du stream.
245
+ stream_options: { include_usage: true } }, (isOmniModel(resolvedModel) ? { modalities: ["text"] } : {}));
179
246
  if (tools === null || tools === void 0 ? void 0 : tools.length) {
180
247
  body.tools = tools.map((fn) => ({ type: "function", function: fn }));
181
248
  body.tool_choice =
@@ -231,10 +298,14 @@ class QwenPromptAdapter {
231
298
  signal.addEventListener("abort", abortHandler, { once: true });
232
299
  }
233
300
  let aggregatedContent = "";
234
- const aggregatedToolCalls = [];
301
+ const toolCallParts = new Map();
235
302
  let buffer = "";
303
+ // Usage, motif d'arrêt et dernier chunk, restitués dans la réponse finale.
304
+ let streamUsage = null;
305
+ let finishReason = null;
306
+ let lastChunk = null;
236
307
  response.body.on('data', (chunk) => {
237
- var _a, _b;
308
+ var _a, _b, _c, _d;
238
309
  if (signal === null || signal === void 0 ? void 0 : signal.aborted)
239
310
  return;
240
311
  // Convertir le buffer en string
@@ -251,20 +322,17 @@ class QwenPromptAdapter {
251
322
  try {
252
323
  const parsed = JSON.parse(data);
253
324
  const delta = (_b = (_a = parsed.choices) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.delta;
325
+ lastChunk = parsed;
326
+ if (parsed.usage)
327
+ streamUsage = parsed.usage;
328
+ if ((_d = (_c = parsed.choices) === null || _c === void 0 ? void 0 : _c[0]) === null || _d === void 0 ? void 0 : _d.finish_reason)
329
+ finishReason = parsed.choices[0].finish_reason;
254
330
  if (delta === null || delta === void 0 ? void 0 : delta.content) {
255
331
  aggregatedContent += delta.content;
256
332
  onChunk === null || onChunk === void 0 ? void 0 : onChunk({ contentChunk: delta.content, rawChunk: parsed });
257
333
  }
258
334
  if (delta === null || delta === void 0 ? void 0 : delta.tool_calls) {
259
- for (const toolCall of delta.tool_calls) {
260
- if (toolCall.function) {
261
- aggregatedToolCalls.push({
262
- id: toolCall.id,
263
- name: toolCall.function.name,
264
- arguments: JSON.parse(toolCall.function.arguments || "{}"),
265
- });
266
- }
267
- }
335
+ accumulateToolCalls(delta.tool_calls, toolCallParts);
268
336
  }
269
337
  }
270
338
  catch (e) {
@@ -293,15 +361,7 @@ class QwenPromptAdapter {
293
361
  onChunk === null || onChunk === void 0 ? void 0 : onChunk({ contentChunk: delta.content, rawChunk: parsed });
294
362
  }
295
363
  if (delta === null || delta === void 0 ? void 0 : delta.tool_calls) {
296
- for (const toolCall of delta.tool_calls) {
297
- if (toolCall.function) {
298
- aggregatedToolCalls.push({
299
- id: toolCall.id,
300
- name: toolCall.function.name,
301
- arguments: JSON.parse(toolCall.function.arguments || "{}"),
302
- });
303
- }
304
- }
364
+ accumulateToolCalls(delta.tool_calls, toolCallParts);
305
365
  }
306
366
  }
307
367
  catch (e) {
@@ -311,11 +371,8 @@ class QwenPromptAdapter {
311
371
  }
312
372
  }
313
373
  }
314
- const finalResponse = {
315
- content: aggregatedContent,
316
- toolCalls: aggregatedToolCalls.length > 0 ? aggregatedToolCalls : undefined,
317
- raw: null,
318
- };
374
+ const aggregatedToolCalls = finalizeToolCalls(toolCallParts);
375
+ const finalResponse = Object.assign({ content: aggregatedContent, toolCalls: aggregatedToolCalls.length > 0 ? aggregatedToolCalls : undefined, raw: lastChunk ? Object.assign(Object.assign({}, lastChunk), { usage: streamUsage }) : null }, (finishReason ? { finishReason } : {}));
319
376
  onFinalResponse === null || onFinalResponse === void 0 ? void 0 : onFinalResponse(finalResponse);
320
377
  finish(() => resolve(finalResponse));
321
378
  });
@@ -7,6 +7,7 @@ export declare const MODELS: {
7
7
  QWEN_VL_3: Model;
8
8
  QWEN_VL_FLASH: Model;
9
9
  QWEN_VL_3_FLASH: Model;
10
+ QWEN_OMNI_FLASH: Model;
10
11
  GEMINI_3_PRO: Model;
11
12
  GEMINI_3_1_PRO: Model;
12
13
  GEMINI_3_FLASH: Model;
@@ -1 +1 @@
1
- {"version":3,"file":"Models.d.ts","sourceRoot":"","sources":["../../src/core/Models.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAExC,eAAO,MAAM,MAAM;sBAWZ,KAAK;oBAYL,KAAK;iBAYL,KAAK;WAWL,KAAK;eAWL,KAAK;mBAWL,KAAK;qBAWL,KAAK;kBAWL,KAAK;oBAWL,KAAK;oBAaL,KAAK;sBAWL,KAAK;uBAKL,KAAK;qBAKL,KAAK;CACX,CAAC"}
1
+ {"version":3,"file":"Models.d.ts","sourceRoot":"","sources":["../../src/core/Models.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAExC,eAAO,MAAM,MAAM;sBAWZ,KAAK;oBAYL,KAAK;iBAYL,KAAK;WAWL,KAAK;eAWL,KAAK;mBAWL,KAAK;qBAWL,KAAK;qBAaL,KAAK;kBAWL,KAAK;oBAWL,KAAK;oBAaL,KAAK;sBAWL,KAAK;uBAKL,KAAK;qBAKL,KAAK;CACX,CAAC"}
@@ -75,6 +75,18 @@ exports.MODELS = {
75
75
  },
76
76
  currency: "USD",
77
77
  },
78
+ // Omnimodal (audio/vidéo/image/texte), tarifs du déploiement international (Singapour).
79
+ QWEN_OMNI_FLASH: {
80
+ provider: "qwen",
81
+ modelName: "qwen3.8-omni-flash",
82
+ costsPer1MTokens: {
83
+ prompt: 0.15,
84
+ prompt_cache: 0.016,
85
+ prompt_audio: 0.15,
86
+ output: 0.47,
87
+ },
88
+ currency: "USD",
89
+ },
78
90
  GEMINI_3_PRO: {
79
91
  provider: "gemini",
80
92
  modelName: "gemini-3-pro-preview",
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=qwen-omni-audio-example.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"qwen-omni-audio-example.d.ts","sourceRoot":"","sources":["../../src/examples/qwen-omni-audio-example.ts"],"names":[],"mappings":""}
@@ -0,0 +1,324 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
36
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
37
+ return new (P || (P = Promise))(function (resolve, reject) {
38
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
39
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
40
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
41
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
42
+ });
43
+ };
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ const UnifiedLLMClient_1 = require("../core/UnifiedLLMClient");
46
+ const Models_1 = require("../core/Models");
47
+ const child_process_1 = require("child_process");
48
+ const fs = __importStar(require("fs"));
49
+ const path = __importStar(require("path"));
50
+ /**
51
+ * Test d'intégration Qwen-Omni : transcription audio horodatée.
52
+ *
53
+ * Vérifie que la sortie est un JSON exploitable, aux timecodes croissants et
54
+ * compris dans la durée du fichier.
55
+ *
56
+ * ALIBABA_CLOUD_API_KEY=... npm run example:qwen-omni -- ./chemin/audio.mp3 [--tools]
57
+ * npm run example:qwen-omni -- ./reponse.txt [duree_en_secondes]
58
+ */
59
+ const TRANSCRIPTION_PROMPT = `Transcris cet extrait audio intégralement, en français.
60
+ Réponds UNIQUEMENT avec un tableau JSON, sans texte autour et sans bloc de code.
61
+ Chaque élément représente une phrase et suit ce format exact :
62
+ {"timecode": "MM:SS", "speaker": "nom ou Inconnu", "text": "la phrase transcrite"}
63
+ Le timecode est l'instant de DÉBUT de la phrase, relatif au début de l'extrait.`;
64
+ /** Prompt du mode function calling : le format est défini par le schéma de l'outil. */
65
+ const TRANSCRIPTION_PROMPT_TOOLS = `Transcris cet extrait audio intégralement, en français,
66
+ puis transmets le résultat via l'outil submit_transcription.`;
67
+ const TRANSCRIPTION_TOOL = {
68
+ name: "submit_transcription",
69
+ description: "Enregistre la transcription horodatée d'un extrait audio.",
70
+ parameters: {
71
+ type: "object",
72
+ properties: {
73
+ segments: {
74
+ type: "array",
75
+ description: "Une entrée par phrase, dans l'ordre chronologique.",
76
+ items: {
77
+ type: "object",
78
+ properties: {
79
+ timecode: { type: "string", description: "Début de la phrase, au format MM:SS." },
80
+ speaker: { type: "string", description: "Nom du locuteur, ou 'Inconnu'." },
81
+ text: { type: "string", description: "La phrase transcrite." },
82
+ },
83
+ required: ["timecode", "speaker", "text"],
84
+ },
85
+ },
86
+ },
87
+ required: ["segments"],
88
+ },
89
+ };
90
+ /** Durée réelle du fichier, via ffprobe. Renvoie null si ffprobe est absent. */
91
+ function probeDurationSeconds(filePath) {
92
+ try {
93
+ const out = (0, child_process_1.execFileSync)("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { encoding: "utf-8" });
94
+ const seconds = parseFloat(out.trim());
95
+ return Number.isFinite(seconds) ? seconds : null;
96
+ }
97
+ catch (_a) {
98
+ return null;
99
+ }
100
+ }
101
+ /** "MM:SS" ou "HH:MM:SS" → secondes. Renvoie NaN si le format est invalide. */
102
+ function timecodeToSeconds(timecode) {
103
+ if (typeof timecode !== "string")
104
+ return NaN;
105
+ const parts = timecode.trim().split(":").map(Number);
106
+ if (parts.some(n => !Number.isFinite(n)))
107
+ return NaN;
108
+ if (parts.length === 2)
109
+ return parts[0] * 60 + parts[1];
110
+ if (parts.length === 3)
111
+ return parts[0] * 3600 + parts[1] * 60 + parts[2];
112
+ return NaN;
113
+ }
114
+ /**
115
+ * Extrait les segments horodatés d'une réponse, qu'elle soit formée d'objets
116
+ * concaténés, d'un tableau ou d'un tableau enveloppé dans un objet,
117
+ * éventuellement dans un bloc de code.
118
+ */
119
+ function extractJsonObjects(raw) {
120
+ const cleaned = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/```$/, "");
121
+ const objects = [];
122
+ let depth = 0;
123
+ let start = -1;
124
+ let inString = false;
125
+ let escaped = false;
126
+ for (let i = 0; i < cleaned.length; i++) {
127
+ const char = cleaned[i];
128
+ if (inString) {
129
+ if (escaped)
130
+ escaped = false;
131
+ else if (char === "\\")
132
+ escaped = true;
133
+ else if (char === '"')
134
+ inString = false;
135
+ continue;
136
+ }
137
+ if (char === '"')
138
+ inString = true;
139
+ else if (char === "{") {
140
+ if (depth === 0)
141
+ start = i;
142
+ depth++;
143
+ }
144
+ else if (char === "}") {
145
+ depth--;
146
+ if (depth === 0 && start !== -1) {
147
+ try {
148
+ objects.push(JSON.parse(cleaned.slice(start, i + 1)));
149
+ }
150
+ catch (_a) {
151
+ // Objet non parsable : ignoré.
152
+ }
153
+ start = -1;
154
+ }
155
+ }
156
+ }
157
+ if (!objects.length)
158
+ throw new Error("Aucun objet JSON trouvé dans la réponse.");
159
+ // Collecte tout objet portant un `timecode`, quelle que soit sa profondeur.
160
+ const segments = [];
161
+ const visit = (node) => {
162
+ if (Array.isArray(node))
163
+ return node.forEach(visit);
164
+ if (!node || typeof node !== "object")
165
+ return;
166
+ if ("timecode" in node)
167
+ return void segments.push(node);
168
+ Object.values(node).forEach(visit);
169
+ };
170
+ objects.forEach(visit);
171
+ if (!segments.length) {
172
+ throw new Error("Objets JSON trouvés, mais aucun ne porte de champ `timecode`.");
173
+ }
174
+ return segments;
175
+ }
176
+ function runTimecodeChecks(entries, durationSeconds) {
177
+ const checks = [];
178
+ checks.push({
179
+ label: "Transcription non vide",
180
+ ok: entries.length > 0,
181
+ detail: `${entries.length} segment(s)`,
182
+ });
183
+ const malformed = entries.filter(e => !e || typeof e.text !== "string" || !e.text.trim() || !Number.isFinite(timecodeToSeconds(e.timecode)));
184
+ checks.push({
185
+ label: "Tous les segments ont un timecode et un texte valides",
186
+ ok: malformed.length === 0,
187
+ detail: malformed.length ? `${malformed.length} segment(s) invalide(s)` : "aucun défaut",
188
+ });
189
+ const seconds = entries.map(e => timecodeToSeconds(e === null || e === void 0 ? void 0 : e.timecode)).filter(Number.isFinite);
190
+ const regressions = [];
191
+ for (let i = 1; i < seconds.length; i++) {
192
+ if (seconds[i] < seconds[i - 1]) {
193
+ regressions.push(`#${i}: ${entries[i].timecode} après ${entries[i - 1].timecode}`);
194
+ }
195
+ }
196
+ checks.push({
197
+ label: "Timecodes strictement croissants",
198
+ ok: regressions.length === 0,
199
+ detail: regressions.length ? regressions.slice(0, 5).join(", ") : "ordre respecté",
200
+ });
201
+ const last = seconds.length ? seconds[seconds.length - 1] : 0;
202
+ if (durationSeconds !== null) {
203
+ checks.push({
204
+ label: "Dernier timecode dans la durée du fichier",
205
+ ok: last <= durationSeconds + 1,
206
+ detail: `${last.toFixed(0)}s vs ${durationSeconds.toFixed(0)}s de piste`,
207
+ });
208
+ const coverage = durationSeconds > 0 ? last / durationSeconds : 0;
209
+ checks.push({
210
+ label: "Couverture de la piste ≥ 80 %",
211
+ ok: coverage >= 0.8,
212
+ detail: `${(coverage * 100).toFixed(0)} % couverts`,
213
+ });
214
+ }
215
+ else {
216
+ console.log("ℹ️ ffprobe introuvable : contrôles de durée ignorés.");
217
+ }
218
+ return checks;
219
+ }
220
+ function reportCost(raw) {
221
+ var _a, _b;
222
+ const usage = raw === null || raw === void 0 ? void 0 : raw.usage;
223
+ if (!usage)
224
+ return;
225
+ const { prompt, output } = Models_1.MODELS.QWEN_OMNI_FLASH.costsPer1MTokens;
226
+ const inputTokens = (_a = usage.prompt_tokens) !== null && _a !== void 0 ? _a : 0;
227
+ const outputTokens = (_b = usage.completion_tokens) !== null && _b !== void 0 ? _b : 0;
228
+ const cost = (inputTokens * prompt + outputTokens * output) / 1000000;
229
+ console.log(`\n💰 ${inputTokens} tokens entrée / ${outputTokens} sortie → ${cost.toFixed(5)} USD`);
230
+ }
231
+ /** Rejoue les contrôles sur une réponse déjà obtenue, sans rappeler l'API. */
232
+ function replaySavedResponse(rawPath, durationSeconds) {
233
+ const content = fs.readFileSync(rawPath, "utf-8");
234
+ console.log(`♻️ Rejeu hors ligne de ${path.basename(rawPath)} (${content.length} caractères)\n`);
235
+ const entries = extractJsonObjects(content);
236
+ const checks = runTimecodeChecks(entries, durationSeconds);
237
+ checks.forEach(c => console.log(` ${c.ok ? "✅" : "❌"} ${c.label} (${c.detail})`));
238
+ if (checks.some(c => !c.ok))
239
+ process.exit(1);
240
+ }
241
+ function main() {
242
+ return __awaiter(this, void 0, void 0, function* () {
243
+ var _a, _b, _c, _d, _e, _f;
244
+ const audioPath = process.argv[2];
245
+ if (!audioPath) {
246
+ console.error("Usage: npm run example:qwen-omni -- <chemin/audio.mp3>");
247
+ console.error(" npm run example:qwen-omni -- <reponse.txt> [duree_en_secondes]");
248
+ process.exit(1);
249
+ }
250
+ // Un fichier .txt est une réponse sauvegardée : les contrôles sont rejoués sans appel à l'API.
251
+ if (audioPath.toLowerCase().endsWith(".txt")) {
252
+ const duration = process.argv[3] ? Number(process.argv[3]) : null;
253
+ replaySavedResponse(audioPath, Number.isFinite(duration) ? duration : null);
254
+ return;
255
+ }
256
+ if (!fs.existsSync(audioPath)) {
257
+ console.error(`Fichier introuvable : ${audioPath}`);
258
+ process.exit(1);
259
+ }
260
+ const sizeMb = fs.statSync(audioPath).size / 1024 / 1024;
261
+ const duration = probeDurationSeconds(audioPath);
262
+ console.log(`🎧 ${path.basename(audioPath)} — ${sizeMb.toFixed(1)} Mo` +
263
+ (duration !== null ? `, ${duration.toFixed(0)}s` : ""));
264
+ console.log(`🤖 ${Models_1.MODELS.QWEN_OMNI_FLASH.modelName}\n`);
265
+ const client = new UnifiedLLMClient_1.UnifiedLLMClient(Models_1.MODELS.QWEN_OMNI_FLASH);
266
+ const startedAt = Date.now();
267
+ const useTools = process.argv.includes("--tools");
268
+ console.log(useTools ? "🔧 Mode function calling\n" : "💬 Mode prompt JSON\n");
269
+ const result = yield client.chat(Object.assign(Object.assign({ message: [
270
+ {
271
+ role: "user",
272
+ content: useTools ? TRANSCRIPTION_PROMPT_TOOLS : TRANSCRIPTION_PROMPT,
273
+ attachedFile: audioPath,
274
+ },
275
+ ], stream: true }, (useTools ? { tools: [TRANSCRIPTION_TOOL], toolChoice: TRANSCRIPTION_TOOL.name } : {})), { onChunk: chunk => {
276
+ if (chunk.contentChunk)
277
+ process.stdout.write(".");
278
+ } }));
279
+ const latencyMs = Date.now() - startedAt;
280
+ console.log(`\n\n⏱️ Réponse en ${(latencyMs / 1000).toFixed(1)}s`);
281
+ // En function calling, la transcription arrive dans les arguments de l'outil,
282
+ // pas dans le contenu du message.
283
+ const toolArguments = (_b = (_a = result.toolCalls) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.arguments;
284
+ const content = useTools && toolArguments
285
+ ? JSON.stringify(toolArguments, null, 2)
286
+ : typeof result.content === "string"
287
+ ? result.content
288
+ : JSON.stringify(result.content);
289
+ if (useTools) {
290
+ console.log(`🔧 tool_calls: ${(_d = (_c = result.toolCalls) === null || _c === void 0 ? void 0 : _c.length) !== null && _d !== void 0 ? _d : 0}` +
291
+ (((_e = result.toolCalls) === null || _e === void 0 ? void 0 : _e[0]) ? ` → ${result.toolCalls[0].name}` : ""));
292
+ }
293
+ // La réponse brute est sauvegardée à côté du fichier audio.
294
+ const rawPath = path.join(path.dirname(audioPath), `${path.basename(audioPath)}.qwen-raw.txt`);
295
+ fs.writeFileSync(rawPath, content, "utf-8");
296
+ console.log(`📄 Réponse brute : ${rawPath}`);
297
+ console.log(`🏁 finish_reason: ${(_f = result.finishReason) !== null && _f !== void 0 ? _f : "?"} | ${content.length} caractères reçus`);
298
+ reportCost(result.raw);
299
+ let entries;
300
+ try {
301
+ entries = extractJsonObjects(content);
302
+ }
303
+ catch (error) {
304
+ console.error(`\n❌ Sortie non structurée : ${error instanceof Error ? error.message : error}`);
305
+ process.exit(1);
306
+ }
307
+ console.log("\n📋 Premiers segments :");
308
+ entries.slice(0, 5).forEach(e => { var _a; return console.log(` [${e.timecode}] ${(_a = e.speaker) !== null && _a !== void 0 ? _a : "?"} — ${e.text}`); });
309
+ const checks = runTimecodeChecks(entries, duration);
310
+ console.log("\n🔍 Contrôles :");
311
+ checks.forEach(c => console.log(` ${c.ok ? "✅" : "❌"} ${c.label} (${c.detail})`));
312
+ reportCost(result.raw);
313
+ const failed = checks.filter(c => !c.ok);
314
+ if (failed.length) {
315
+ console.error(`\n❌ ${failed.length} contrôle(s) en échec.`);
316
+ process.exit(1);
317
+ }
318
+ console.log("\n✅ Tous les contrôles passent.");
319
+ });
320
+ }
321
+ main().catch(error => {
322
+ console.error("\n💥 Échec :", error instanceof Error ? error.message : error);
323
+ process.exit(1);
324
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/unified-llm-client",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "A unified interface for multiple LLM providers (OpenAI, Gemini, Anthropic, Qwen) with tool-calling, streaming, and audio support",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -19,6 +19,7 @@
19
19
  "example": "node dist/examples/example.js",
20
20
  "example:audio": "node dist/examples/audio-example.js",
21
21
  "example:qwen": "node dist/examples/qwen-vl-example.js",
22
+ "example:qwen-omni": "node dist/examples/qwen-omni-audio-example.js",
22
23
  "example:anthropic": "node dist/examples/anthropic-example.js",
23
24
  "example:abort": "node dist/examples/chat-abort-example.js",
24
25
  "example:proxy": "node dist/examples/proxy-example.js",