d3evil4 commited on
Commit
7712dec
·
1 Parent(s): bd23640

feat: add OpenAI-compatible vision inference server with tracing

Browse files

- Add Express server exposing OpenAI-compatible chat completions API
- Integrate @huggingface/transformers for vision-language model inference
- Support multimodal user messages with data URL images
- Add Langfuse OpenTelemetry tracing for observability
- Include Swagger UI for API documentation
- Add package.json and package-lock.json with dependencies

Files changed (3) hide show
  1. package-lock.json +0 -0
  2. package.json +2 -1
  3. server.js +375 -7
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json CHANGED
@@ -12,6 +12,7 @@
12
  "@huggingface/transformers": "next",
13
  "express": "^4.21.0",
14
  "onnxruntime-node": "^1.21.0",
15
- "swagger-ui-express": "^5.0.0"
 
16
  }
17
  }
 
12
  "@huggingface/transformers": "next",
13
  "express": "^4.21.0",
14
  "onnxruntime-node": "^1.21.0",
15
+ "swagger-ui-express": "^5.0.0",
16
+ "sharp": "^0.34.5"
17
  }
18
  }
server.js CHANGED
@@ -3,8 +3,11 @@ import swaggerUi from "swagger-ui-express";
3
  import {
4
  AutoProcessor,
5
  Qwen3_5ForConditionalGeneration,
 
6
  } from "@huggingface/transformers";
7
  import crypto from "crypto";
 
 
8
  import { NodeSDK } from "@opentelemetry/sdk-node";
9
  import { LangfuseSpanProcessor } from "@langfuse/otel";
10
  import { startActiveObservation } from "@langfuse/tracing";
@@ -13,6 +16,10 @@ const app = express();
13
  const PORT = 7860;
14
  const MODEL_ID = "huggingworld/Qwen3.5-0.8B-ONNX";
15
  const API_KEY = process.env.API_KEY;
 
 
 
 
16
  const LANGFUSE_PUBLIC_KEY = process.env.LANGFUSE_PUBLIC_KEY;
17
  const LANGFUSE_SECRET_KEY = process.env.LANGFUSE_SECRET_KEY;
18
  const LANGFUSE_BASE_URL = process.env.LANGFUSE_BASE_URL || "https://cloud.langfuse.com";
@@ -105,6 +112,12 @@ async function withPromptTrace(req, prompt, maxTokens, handler) {
105
  );
106
  }
107
 
 
 
 
 
 
 
108
  async function runTextInference(prompt, maxTokens) {
109
  const conversation = [
110
  {
@@ -134,17 +147,320 @@ async function runTextInference(prompt, maxTokens) {
134
  }
135
 
136
  function queueTextInference(prompt, maxTokens) {
137
- const task = inferenceQueue.then(() => runTextInference(prompt, maxTokens));
138
- inferenceQueue = task.catch(() => {});
139
- return task;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  }
141
 
142
  const swaggerDoc = {
143
  openapi: "3.0.0",
144
  info: {
145
  title: "Qwen3.5-0.8B Text API (ONNX)",
146
- version: "1.0.0",
147
- description: "Text inference API using Qwen3.5-0.8B ONNX with transformers.js",
 
148
  },
149
  components: {
150
  securitySchemes: {
@@ -196,6 +512,18 @@ const swaggerDoc = {
196
  security: [{ ApiKeyAuth: [] }],
197
  },
198
  },
 
 
 
 
 
 
 
 
 
 
 
 
199
  },
200
  };
201
 
@@ -236,9 +564,15 @@ app.use((req, res, next) => {
236
  next();
237
  });
238
 
 
 
239
  app.get("/", (req, res) => {
240
  log("info", "root_status", { request_id: req.requestId });
241
- res.json({ status: "ok", model: MODEL_ID });
 
 
 
 
242
  });
243
 
244
  app.get("/health", (req, res) => {
@@ -246,9 +580,41 @@ app.get("/health", (req, res) => {
246
  request_id: req.requestId,
247
  model_loaded: model !== null && processor !== null,
248
  });
249
- res.json({ status: "healthy", model_loaded: model !== null });
 
 
 
 
250
  });
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  app.post("/prompt", requireApiKey, express.json(), async (req, res) => {
253
  const prompt = req.body.prompt;
254
  const maxTokens = parseInt(req.body.max_tokens) || 256;
@@ -317,6 +683,8 @@ Promise.all([setupTracing(), loadModel()]).then(() => {
317
  port: PORT,
318
  model_id: MODEL_ID,
319
  langfuse_enabled: LANGFUSE_ENABLED,
 
 
320
  });
321
 
322
  const shutdown = async (signal) => {
 
3
  import {
4
  AutoProcessor,
5
  Qwen3_5ForConditionalGeneration,
6
+ RawImage,
7
  } from "@huggingface/transformers";
8
  import crypto from "crypto";
9
+ import { Readable } from "node:stream";
10
+ import { pipeline } from "node:stream/promises";
11
  import { NodeSDK } from "@opentelemetry/sdk-node";
12
  import { LangfuseSpanProcessor } from "@langfuse/otel";
13
  import { startActiveObservation } from "@langfuse/tracing";
 
16
  const PORT = 7860;
17
  const MODEL_ID = "huggingworld/Qwen3.5-0.8B-ONNX";
18
  const API_KEY = process.env.API_KEY;
19
+ const NVIDIA_CHAT_URL =
20
+ process.env.NVIDIA_CHAT_URL || "https://integrate.api.nvidia.com/v1/chat/completions";
21
+ const NVIDIA_API_KEY = process.env.NVIDIA_API_KEY;
22
+ const NVIDIA_FETCH_TIMEOUT_MS = parseInt(process.env.NVIDIA_FETCH_TIMEOUT_MS || "300000", 10);
23
  const LANGFUSE_PUBLIC_KEY = process.env.LANGFUSE_PUBLIC_KEY;
24
  const LANGFUSE_SECRET_KEY = process.env.LANGFUSE_SECRET_KEY;
25
  const LANGFUSE_BASE_URL = process.env.LANGFUSE_BASE_URL || "https://cloud.langfuse.com";
 
112
  );
113
  }
114
 
115
+ function queueInference(runner) {
116
+ const task = inferenceQueue.then(() => runner());
117
+ inferenceQueue = task.catch(() => {});
118
+ return task;
119
+ }
120
+
121
  async function runTextInference(prompt, maxTokens) {
122
  const conversation = [
123
  {
 
147
  }
148
 
149
  function queueTextInference(prompt, maxTokens) {
150
+ return queueInference(() => runTextInference(prompt, maxTokens));
151
+ }
152
+
153
+ function decodeDataUrl(url) {
154
+ const trimmed = url.trim();
155
+ const m = /^data:([^;,]+)?;base64,(.+)$/i.exec(trimmed);
156
+ if (!m) return null;
157
+ const mime = (m[1] || "application/octet-stream").split(";")[0].trim();
158
+ try {
159
+ return { mime, buffer: Buffer.from(m[2], "base64") };
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ async function rawImageFromDataUrlOnly(url) {
166
+ const data = decodeDataUrl(url);
167
+ if (!data) {
168
+ return null;
169
+ }
170
+ return RawImage.fromBlob(new Blob([data.buffer], { type: data.mime }));
171
+ }
172
+
173
+ async function runVisionInference(userText, rawImages, maxTokens) {
174
+ const content = [];
175
+ for (let i = 0; i < rawImages.length; i++) {
176
+ content.push({ type: "image" });
177
+ }
178
+
179
+ const textLine = (userText ?? "").trim() || "What is in this image?";
180
+ content.push({ type: "text", text: textLine });
181
+
182
+ const conversation = [
183
+ {
184
+ role: "user",
185
+ content,
186
+ },
187
+ ];
188
+
189
+ const text = processor.apply_chat_template(conversation, {
190
+ add_generation_prompt: true,
191
+ chat_template_kwargs: { enable_thinking: false },
192
+ });
193
+
194
+ const visionArg = rawImages.length === 1 ? rawImages[0] : rawImages;
195
+ const inputs = await processor(text, visionArg);
196
+ const output = await model.generate({
197
+ ...inputs,
198
+ max_new_tokens: maxTokens,
199
+ do_sample: false,
200
+ });
201
+
202
+ const promptLength = inputs.input_ids.dims.at(-1);
203
+ const decoded = processor.batch_decode(output.slice(null, [promptLength, null]), {
204
+ skip_special_tokens: true,
205
+ });
206
+ return decoded[0];
207
+ }
208
+
209
+ function getLastUserMessage(messages) {
210
+ if (!Array.isArray(messages)) return null;
211
+ for (let i = messages.length - 1; i >= 0; i--) {
212
+ if (messages[i]?.role === "user") return messages[i];
213
+ }
214
+ return null;
215
+ }
216
+
217
+ function flattenUserMultimodalContent(userMessage) {
218
+ if (!userMessage) return { segments: [] };
219
+ const c = userMessage.content;
220
+ /** @type {{ type: string, text?: string, url?: string }[]} */
221
+ const segments = [];
222
+
223
+ if (typeof c === "string") {
224
+ segments.push({ type: "text", text: c });
225
+ return { segments };
226
+ }
227
+
228
+ if (!Array.isArray(c)) return { segments };
229
+
230
+ for (const part of c) {
231
+ if (!part || typeof part !== "object") continue;
232
+ if (part.type === "text" && typeof part.text === "string") {
233
+ segments.push({ type: "text", text: part.text });
234
+ } else if (part.type === "image_url") {
235
+ const imgUrl = part.image_url?.url;
236
+ if (typeof imgUrl === "string" && imgUrl.length > 0) {
237
+ segments.push({ type: "image_url", url: imgUrl });
238
+ }
239
+ }
240
+ }
241
+
242
+ return { segments };
243
+ }
244
+
245
+ /** @returns {Promise<boolean>} true if the response was fully sent (do not send fallback) */
246
+ async function proxyNvidiaChatCompletions(req, body, streamRequested, res) {
247
+ if (!NVIDIA_API_KEY) {
248
+ log("info", "nvidia_skipped", { request_id: req.requestId, reason: "no_nvidia_api_key" });
249
+ return false;
250
+ }
251
+
252
+ const controller = new AbortController();
253
+ const timeoutId = setTimeout(() => controller.abort(), NVIDIA_FETCH_TIMEOUT_MS);
254
+ try {
255
+ const upstream = await fetch(NVIDIA_CHAT_URL, {
256
+ method: "POST",
257
+ headers: {
258
+ "Content-Type": "application/json",
259
+ Authorization: `Bearer ${NVIDIA_API_KEY}`,
260
+ Accept: streamRequested ? "text/event-stream" : "application/json",
261
+ },
262
+ body: JSON.stringify(body),
263
+ signal: controller.signal,
264
+ });
265
+
266
+ if (!upstream.ok) {
267
+ let preview = "";
268
+ try {
269
+ preview = (await upstream.text()).slice(0, 800);
270
+ } catch {
271
+ /* ignore */
272
+ }
273
+ log("warn", "nvidia_upstream_rejected", {
274
+ request_id: req.requestId,
275
+ status: upstream.status,
276
+ body_preview: preview,
277
+ });
278
+ return false;
279
+ }
280
+
281
+ const ct = upstream.headers.get("content-type") || "";
282
+
283
+ if (upstream.body != null && streamRequested && ct.includes("text/event-stream")) {
284
+ res.status(200);
285
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
286
+ res.setHeader("Cache-Control", "no-cache");
287
+ res.setHeader("Connection", "keep-alive");
288
+ try {
289
+ await pipeline(
290
+ Readable.fromWeb(/** @type {any} */ (upstream.body)),
291
+ res,
292
+ );
293
+ } catch (pipeErr) {
294
+ log("error", "nvidia_stream_pipe_failed", {
295
+ request_id: req.requestId,
296
+ error: pipeErr.message,
297
+ });
298
+ if (!res.headersSent) {
299
+ return false;
300
+ }
301
+ if (!res.writableEnded) {
302
+ try {
303
+ res.destroy();
304
+ } catch {
305
+ /* ignore */
306
+ }
307
+ }
308
+ }
309
+ return true;
310
+ }
311
+
312
+ const buf = await upstream.text();
313
+ if (streamRequested && ct.includes("text/event-stream")) {
314
+ res.status(200);
315
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
316
+ res.send(buf);
317
+ return true;
318
+ }
319
+ res.status(200).type(ct || "application/json").send(buf);
320
+ return true;
321
+ } catch (err) {
322
+ log("warn", "nvidia_fetch_failed", {
323
+ request_id: req.requestId,
324
+ error: err.message,
325
+ name: err.name,
326
+ });
327
+ return false;
328
+ } finally {
329
+ clearTimeout(timeoutId);
330
+ }
331
+ }
332
+
333
+ function openAiCompletionJson(assistantText) {
334
+ const id = `chatcmpl-${crypto.randomUUID()}`;
335
+ return {
336
+ id,
337
+ object: "chat.completion",
338
+ created: Math.floor(Date.now() / 1000),
339
+ model: MODEL_ID,
340
+ choices: [
341
+ {
342
+ index: 0,
343
+ message: { role: "assistant", content: assistantText },
344
+ finish_reason: "stop",
345
+ },
346
+ ],
347
+ };
348
+ }
349
+
350
+ function writeFallbackSseChunks(res, assistantText) {
351
+ const id = `chatcmpl-${crypto.randomUUID()}`;
352
+ const base = () => ({
353
+ id,
354
+ object: "chat.completion.chunk",
355
+ created: Math.floor(Date.now() / 1000),
356
+ model: MODEL_ID,
357
+ });
358
+
359
+ res.write(`data: ${JSON.stringify({
360
+ ...base(),
361
+ choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
362
+ })}\n\n`);
363
+
364
+ res.write(`data: ${JSON.stringify({
365
+ ...base(),
366
+ choices: [{ index: 0, delta: { content: assistantText }, finish_reason: null }],
367
+ })}\n\n`);
368
+
369
+ res.write(`data: ${JSON.stringify({
370
+ ...base(),
371
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
372
+ })}\n\n`);
373
+
374
+ res.write("data: [DONE]\n\n");
375
+ }
376
+
377
+ async function fallbackLocalChatCompletion(req, res, body, streamRequested) {
378
+ if (!model || !processor) {
379
+ log("error", "fallback_model_unavailable", { request_id: req.requestId });
380
+ return res.status(503).json({
381
+ error: { message: "Local ONNX model is not loaded." },
382
+ source: "local_onnx_unavailable",
383
+ });
384
+ }
385
+
386
+ const rawMax = typeof body.max_tokens === "number" ? body.max_tokens : 512;
387
+ const maxTokens =
388
+ typeof rawMax === "number" && rawMax > 0 ? Math.min(Math.floor(rawMax), 16384) : 512;
389
+
390
+ const lastUser = getLastUserMessage(body.messages);
391
+ const { segments } = flattenUserMultimodalContent(lastUser);
392
+
393
+ const textParts = segments
394
+ .filter((s) => s.type === "text")
395
+ .map((s) => s.text ?? "")
396
+ .join("\n");
397
+ const imageUrls = segments.filter((s) => s.type === "image_url");
398
+
399
+ let assistantText;
400
+ try {
401
+ if (imageUrls.length > 0) {
402
+ const unsupported = imageUrls.find((s) => !decodeDataUrl(s.url));
403
+ if (unsupported) {
404
+ return res.status(400).json({
405
+ error: {
406
+ message:
407
+ "Local ONNX fallback only accepts embedded images as data:image/...;base64,... URIs.",
408
+ },
409
+ source: "local_onnx_fallback",
410
+ });
411
+ }
412
+ const rawImages = await Promise.all(imageUrls.map((s) => rawImageFromDataUrlOnly(s.url)));
413
+ assistantText = await queueInference(() =>
414
+ runVisionInference(textParts || "What is in this image?", rawImages, maxTokens),
415
+ );
416
+ } else if ((textParts ?? "").trim().length > 0) {
417
+ assistantText = await queueInference(() => runTextInference(textParts.trim(), maxTokens));
418
+ } else {
419
+ return res.status(400).json({
420
+ error: {
421
+ message:
422
+ "Local fallback needs text or image parts on the last user message.",
423
+ },
424
+ source: "local_onnx_fallback",
425
+ });
426
+ }
427
+
428
+ log("info", "local_fallback_completed", {
429
+ request_id: req.requestId,
430
+ chars: assistantText?.length ?? 0,
431
+ had_images: imageUrls.length > 0,
432
+ });
433
+
434
+ if (streamRequested) {
435
+ res.status(200);
436
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
437
+ res.setHeader("Cache-Control", "no-cache");
438
+ res.setHeader("Connection", "keep-alive");
439
+ writeFallbackSseChunks(res, assistantText);
440
+ return res.end();
441
+ }
442
+
443
+ return res.json(openAiCompletionJson(assistantText));
444
+ } catch (err) {
445
+ log("error", "local_fallback_failed", {
446
+ request_id: req.requestId,
447
+ error: err.message,
448
+ stack: err.stack,
449
+ });
450
+ return res.status(500).json({
451
+ error: { message: err.message || "Local ONNX inference failed." },
452
+ source: "local_onnx_fallback",
453
+ });
454
+ }
455
  }
456
 
457
  const swaggerDoc = {
458
  openapi: "3.0.0",
459
  info: {
460
  title: "Qwen3.5-0.8B Text API (ONNX)",
461
+ version: "1.1.0",
462
+ description:
463
+ "/prompt serves local text. POST /v1/chat/completions tries NVIDIA integrate.api (NVIDIA_API_KEY) then falls back to local ONNX (vision + text) on upstream errors or timeouts.",
464
  },
465
  components: {
466
  securitySchemes: {
 
512
  security: [{ ApiKeyAuth: [] }],
513
  },
514
  },
515
+ "/v1/chat/completions": {
516
+ post: {
517
+ summary: "OpenAI-compatible chat completions; NVIDIA proxy with local ONNX fallback",
518
+ responses: {
519
+ 200: { description: "Completion JSON or streamed events" },
520
+ 400: { description: "Invalid input" },
521
+ 401: { description: "Invalid or missing API key" },
522
+ 503: { description: "Model not loaded during fallback" },
523
+ },
524
+ security: [{ ApiKeyAuth: [] }],
525
+ },
526
+ },
527
  },
528
  };
529
 
 
564
  next();
565
  });
566
 
567
+ const chatJsonParser = express.json({ limit: "50mb" });
568
+
569
  app.get("/", (req, res) => {
570
  log("info", "root_status", { request_id: req.requestId });
571
+ res.json({
572
+ status: "ok",
573
+ model: MODEL_ID,
574
+ nvidia_chat_completions_configured: Boolean(NVIDIA_API_KEY),
575
+ });
576
  });
577
 
578
  app.get("/health", (req, res) => {
 
580
  request_id: req.requestId,
581
  model_loaded: model !== null && processor !== null,
582
  });
583
+ res.json({
584
+ status: "healthy",
585
+ model_loaded: model !== null,
586
+ nvidia_chat_completions_configured: Boolean(NVIDIA_API_KEY),
587
+ });
588
  });
589
 
590
+ app.post(
591
+ "/v1/chat/completions",
592
+ requireApiKey,
593
+ chatJsonParser,
594
+ async (req, res) => {
595
+ const body = req.body;
596
+ if (!body?.messages || !Array.isArray(body.messages)) {
597
+ return res.status(400).json({
598
+ error: { message: "messages (array) is required." },
599
+ });
600
+ }
601
+
602
+ const streamRequested = Boolean(body.stream);
603
+ log("info", "chat_completions_request", {
604
+ request_id: req.requestId,
605
+ stream: streamRequested,
606
+ model: body.model,
607
+ nvidia_configured: Boolean(NVIDIA_API_KEY),
608
+ });
609
+
610
+ const proxied = await proxyNvidiaChatCompletions(req, body, streamRequested, res);
611
+ if (proxied || res.headersSent) return;
612
+
613
+ log("info", "chat_completions_fallback_local", { request_id: req.requestId });
614
+ return fallbackLocalChatCompletion(req, res, body, streamRequested);
615
+ },
616
+ );
617
+
618
  app.post("/prompt", requireApiKey, express.json(), async (req, res) => {
619
  const prompt = req.body.prompt;
620
  const maxTokens = parseInt(req.body.max_tokens) || 256;
 
683
  port: PORT,
684
  model_id: MODEL_ID,
685
  langfuse_enabled: LANGFUSE_ENABLED,
686
+ nvidia_chat_url: NVIDIA_CHAT_URL,
687
+ nvidia_configured: Boolean(NVIDIA_API_KEY),
688
  });
689
 
690
  const shutdown = async (signal) => {