IMGVLM / server.js
d3evil4's picture
feat: add OpenAI-compatible vision inference server with tracing
7712dec
Raw
History Blame Contribute Delete
20.1 kB
import express from "express";
import swaggerUi from "swagger-ui-express";
import {
AutoProcessor,
Qwen3_5ForConditionalGeneration,
RawImage,
} from "@huggingface/transformers";
import crypto from "crypto";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { LangfuseSpanProcessor } from "@langfuse/otel";
import { startActiveObservation } from "@langfuse/tracing";
const app = express();
const PORT = 7860;
const MODEL_ID = "huggingworld/Qwen3.5-0.8B-ONNX";
const API_KEY = process.env.API_KEY;
const NVIDIA_CHAT_URL =
process.env.NVIDIA_CHAT_URL || "https://integrate.api.nvidia.com/v1/chat/completions";
const NVIDIA_API_KEY = process.env.NVIDIA_API_KEY;
const NVIDIA_FETCH_TIMEOUT_MS = parseInt(process.env.NVIDIA_FETCH_TIMEOUT_MS || "300000", 10);
const LANGFUSE_PUBLIC_KEY = process.env.LANGFUSE_PUBLIC_KEY;
const LANGFUSE_SECRET_KEY = process.env.LANGFUSE_SECRET_KEY;
const LANGFUSE_BASE_URL = process.env.LANGFUSE_BASE_URL || "https://cloud.langfuse.com";
const LANGFUSE_ENV = process.env.LANGFUSE_ENV || process.env.NODE_ENV || "development";
const LANGFUSE_ENABLED = Boolean(LANGFUSE_PUBLIC_KEY && LANGFUSE_SECRET_KEY);
let model = null;
let processor = null;
let inferenceQueue = Promise.resolve();
let telemetrySdk = null;
function log(level, event, meta = {}) {
const payload = {
ts: new Date().toISOString(),
level,
event,
...meta,
};
const line = JSON.stringify(payload);
if (level === "error") {
console.error(line);
} else {
console.log(line);
}
}
async function loadModel() {
const start = Date.now();
log("info", "model_load_started", { model_id: MODEL_ID });
processor = await AutoProcessor.from_pretrained(MODEL_ID);
model = await Qwen3_5ForConditionalGeneration.from_pretrained(MODEL_ID, {
dtype: {
embed_tokens: "q4",
vision_encoder: "fp16",
decoder_model_merged: "q4",
},
});
log("info", "model_load_completed", {
model_id: MODEL_ID,
duration_ms: Date.now() - start,
});
}
async function setupTracing() {
if (!LANGFUSE_ENABLED) {
log("info", "langfuse_disabled", {
reason: "missing_langfuse_keys",
});
return;
}
const spanProcessor = new LangfuseSpanProcessor({
publicKey: LANGFUSE_PUBLIC_KEY,
secretKey: LANGFUSE_SECRET_KEY,
baseUrl: LANGFUSE_BASE_URL,
environment: LANGFUSE_ENV,
});
telemetrySdk = new NodeSDK({
spanProcessors: [spanProcessor],
});
await telemetrySdk.start();
log("info", "langfuse_enabled", {
base_url: LANGFUSE_BASE_URL,
environment: LANGFUSE_ENV,
});
}
async function withPromptTrace(req, prompt, maxTokens, handler) {
if (!LANGFUSE_ENABLED) {
return handler();
}
return startActiveObservation(
"http.prompt",
async (span) => {
span.update({
input: { prompt, max_tokens: maxTokens },
metadata: {
request_id: req.requestId,
method: req.method,
path: req.originalUrl,
},
});
return handler(span);
},
{ endOnExit: true },
);
}
function queueInference(runner) {
const task = inferenceQueue.then(() => runner());
inferenceQueue = task.catch(() => {});
return task;
}
async function runTextInference(prompt, maxTokens) {
const conversation = [
{
role: "user",
content: [{ type: "text", text: prompt }],
},
];
const text = processor.apply_chat_template(conversation, {
add_generation_prompt: true,
chat_template_kwargs: { enable_thinking: false },
});
const inputs = await processor(text);
const output = await model.generate({
...inputs,
max_new_tokens: maxTokens,
do_sample: false,
});
const promptLength = inputs.input_ids.dims.at(-1);
const decoded = processor.batch_decode(
output.slice(null, [promptLength, null]),
{ skip_special_tokens: true },
);
return decoded[0];
}
function queueTextInference(prompt, maxTokens) {
return queueInference(() => runTextInference(prompt, maxTokens));
}
function decodeDataUrl(url) {
const trimmed = url.trim();
const m = /^data:([^;,]+)?;base64,(.+)$/i.exec(trimmed);
if (!m) return null;
const mime = (m[1] || "application/octet-stream").split(";")[0].trim();
try {
return { mime, buffer: Buffer.from(m[2], "base64") };
} catch {
return null;
}
}
async function rawImageFromDataUrlOnly(url) {
const data = decodeDataUrl(url);
if (!data) {
return null;
}
return RawImage.fromBlob(new Blob([data.buffer], { type: data.mime }));
}
async function runVisionInference(userText, rawImages, maxTokens) {
const content = [];
for (let i = 0; i < rawImages.length; i++) {
content.push({ type: "image" });
}
const textLine = (userText ?? "").trim() || "What is in this image?";
content.push({ type: "text", text: textLine });
const conversation = [
{
role: "user",
content,
},
];
const text = processor.apply_chat_template(conversation, {
add_generation_prompt: true,
chat_template_kwargs: { enable_thinking: false },
});
const visionArg = rawImages.length === 1 ? rawImages[0] : rawImages;
const inputs = await processor(text, visionArg);
const output = await model.generate({
...inputs,
max_new_tokens: maxTokens,
do_sample: false,
});
const promptLength = inputs.input_ids.dims.at(-1);
const decoded = processor.batch_decode(output.slice(null, [promptLength, null]), {
skip_special_tokens: true,
});
return decoded[0];
}
function getLastUserMessage(messages) {
if (!Array.isArray(messages)) return null;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]?.role === "user") return messages[i];
}
return null;
}
function flattenUserMultimodalContent(userMessage) {
if (!userMessage) return { segments: [] };
const c = userMessage.content;
/** @type {{ type: string, text?: string, url?: string }[]} */
const segments = [];
if (typeof c === "string") {
segments.push({ type: "text", text: c });
return { segments };
}
if (!Array.isArray(c)) return { segments };
for (const part of c) {
if (!part || typeof part !== "object") continue;
if (part.type === "text" && typeof part.text === "string") {
segments.push({ type: "text", text: part.text });
} else if (part.type === "image_url") {
const imgUrl = part.image_url?.url;
if (typeof imgUrl === "string" && imgUrl.length > 0) {
segments.push({ type: "image_url", url: imgUrl });
}
}
}
return { segments };
}
/** @returns {Promise<boolean>} true if the response was fully sent (do not send fallback) */
async function proxyNvidiaChatCompletions(req, body, streamRequested, res) {
if (!NVIDIA_API_KEY) {
log("info", "nvidia_skipped", { request_id: req.requestId, reason: "no_nvidia_api_key" });
return false;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), NVIDIA_FETCH_TIMEOUT_MS);
try {
const upstream = await fetch(NVIDIA_CHAT_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${NVIDIA_API_KEY}`,
Accept: streamRequested ? "text/event-stream" : "application/json",
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!upstream.ok) {
let preview = "";
try {
preview = (await upstream.text()).slice(0, 800);
} catch {
/* ignore */
}
log("warn", "nvidia_upstream_rejected", {
request_id: req.requestId,
status: upstream.status,
body_preview: preview,
});
return false;
}
const ct = upstream.headers.get("content-type") || "";
if (upstream.body != null && streamRequested && ct.includes("text/event-stream")) {
res.status(200);
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
try {
await pipeline(
Readable.fromWeb(/** @type {any} */ (upstream.body)),
res,
);
} catch (pipeErr) {
log("error", "nvidia_stream_pipe_failed", {
request_id: req.requestId,
error: pipeErr.message,
});
if (!res.headersSent) {
return false;
}
if (!res.writableEnded) {
try {
res.destroy();
} catch {
/* ignore */
}
}
}
return true;
}
const buf = await upstream.text();
if (streamRequested && ct.includes("text/event-stream")) {
res.status(200);
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.send(buf);
return true;
}
res.status(200).type(ct || "application/json").send(buf);
return true;
} catch (err) {
log("warn", "nvidia_fetch_failed", {
request_id: req.requestId,
error: err.message,
name: err.name,
});
return false;
} finally {
clearTimeout(timeoutId);
}
}
function openAiCompletionJson(assistantText) {
const id = `chatcmpl-${crypto.randomUUID()}`;
return {
id,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: MODEL_ID,
choices: [
{
index: 0,
message: { role: "assistant", content: assistantText },
finish_reason: "stop",
},
],
};
}
function writeFallbackSseChunks(res, assistantText) {
const id = `chatcmpl-${crypto.randomUUID()}`;
const base = () => ({
id,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: MODEL_ID,
});
res.write(`data: ${JSON.stringify({
...base(),
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
})}\n\n`);
res.write(`data: ${JSON.stringify({
...base(),
choices: [{ index: 0, delta: { content: assistantText }, finish_reason: null }],
})}\n\n`);
res.write(`data: ${JSON.stringify({
...base(),
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
})}\n\n`);
res.write("data: [DONE]\n\n");
}
async function fallbackLocalChatCompletion(req, res, body, streamRequested) {
if (!model || !processor) {
log("error", "fallback_model_unavailable", { request_id: req.requestId });
return res.status(503).json({
error: { message: "Local ONNX model is not loaded." },
source: "local_onnx_unavailable",
});
}
const rawMax = typeof body.max_tokens === "number" ? body.max_tokens : 512;
const maxTokens =
typeof rawMax === "number" && rawMax > 0 ? Math.min(Math.floor(rawMax), 16384) : 512;
const lastUser = getLastUserMessage(body.messages);
const { segments } = flattenUserMultimodalContent(lastUser);
const textParts = segments
.filter((s) => s.type === "text")
.map((s) => s.text ?? "")
.join("\n");
const imageUrls = segments.filter((s) => s.type === "image_url");
let assistantText;
try {
if (imageUrls.length > 0) {
const unsupported = imageUrls.find((s) => !decodeDataUrl(s.url));
if (unsupported) {
return res.status(400).json({
error: {
message:
"Local ONNX fallback only accepts embedded images as data:image/...;base64,... URIs.",
},
source: "local_onnx_fallback",
});
}
const rawImages = await Promise.all(imageUrls.map((s) => rawImageFromDataUrlOnly(s.url)));
assistantText = await queueInference(() =>
runVisionInference(textParts || "What is in this image?", rawImages, maxTokens),
);
} else if ((textParts ?? "").trim().length > 0) {
assistantText = await queueInference(() => runTextInference(textParts.trim(), maxTokens));
} else {
return res.status(400).json({
error: {
message:
"Local fallback needs text or image parts on the last user message.",
},
source: "local_onnx_fallback",
});
}
log("info", "local_fallback_completed", {
request_id: req.requestId,
chars: assistantText?.length ?? 0,
had_images: imageUrls.length > 0,
});
if (streamRequested) {
res.status(200);
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
writeFallbackSseChunks(res, assistantText);
return res.end();
}
return res.json(openAiCompletionJson(assistantText));
} catch (err) {
log("error", "local_fallback_failed", {
request_id: req.requestId,
error: err.message,
stack: err.stack,
});
return res.status(500).json({
error: { message: err.message || "Local ONNX inference failed." },
source: "local_onnx_fallback",
});
}
}
const swaggerDoc = {
openapi: "3.0.0",
info: {
title: "Qwen3.5-0.8B Text API (ONNX)",
version: "1.1.0",
description:
"/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.",
},
components: {
securitySchemes: {
ApiKeyAuth: {
type: "apiKey",
in: "header",
name: "X-API-Key",
description: "Set API_KEY env var; send as X-API-Key or Authorization: Bearer <key>",
},
},
},
paths: {
"/": {
get: {
summary: "Root",
responses: { 200: { description: "API status" } },
},
},
"/health": {
get: {
summary: "Health check",
responses: { 200: { description: "Model load status" } },
},
},
"/prompt": {
post: {
summary: "Text prompt inference (no image)",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["prompt"],
properties: {
prompt: { type: "string", description: "Text prompt to send to the model" },
max_tokens: { type: "integer", default: 256 },
},
},
},
},
},
responses: {
200: { description: "Inference result" },
400: { description: "Invalid input" },
401: { description: "Invalid or missing API key" },
503: { description: "Model not loaded" },
},
security: [{ ApiKeyAuth: [] }],
},
},
"/v1/chat/completions": {
post: {
summary: "OpenAI-compatible chat completions; NVIDIA proxy with local ONNX fallback",
responses: {
200: { description: "Completion JSON or streamed events" },
400: { description: "Invalid input" },
401: { description: "Invalid or missing API key" },
503: { description: "Model not loaded during fallback" },
},
security: [{ ApiKeyAuth: [] }],
},
},
},
};
function requireApiKey(req, res, next) {
if (!API_KEY) return next();
const bearer = req.headers.authorization?.startsWith("Bearer ")
? req.headers.authorization.slice(7)
: null;
const key = bearer ?? req.headers["x-api-key"] ?? null;
if (key !== API_KEY) {
log("warn", "api_key_rejected", { request_id: req.requestId, path: req.path });
return res.status(401).json({ detail: "Invalid or missing API key." });
}
next();
}
app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerDoc));
app.use((req, res, next) => {
const requestId = crypto.randomUUID();
const start = Date.now();
req.requestId = requestId;
log("info", "request_started", {
request_id: requestId,
method: req.method,
path: req.originalUrl,
ip: req.ip,
});
res.on("finish", () => {
log("info", "request_finished", {
request_id: requestId,
method: req.method,
path: req.originalUrl,
status_code: res.statusCode,
duration_ms: Date.now() - start,
});
});
next();
});
const chatJsonParser = express.json({ limit: "50mb" });
app.get("/", (req, res) => {
log("info", "root_status", { request_id: req.requestId });
res.json({
status: "ok",
model: MODEL_ID,
nvidia_chat_completions_configured: Boolean(NVIDIA_API_KEY),
});
});
app.get("/health", (req, res) => {
log("info", "health_checked", {
request_id: req.requestId,
model_loaded: model !== null && processor !== null,
});
res.json({
status: "healthy",
model_loaded: model !== null,
nvidia_chat_completions_configured: Boolean(NVIDIA_API_KEY),
});
});
app.post(
"/v1/chat/completions",
requireApiKey,
chatJsonParser,
async (req, res) => {
const body = req.body;
if (!body?.messages || !Array.isArray(body.messages)) {
return res.status(400).json({
error: { message: "messages (array) is required." },
});
}
const streamRequested = Boolean(body.stream);
log("info", "chat_completions_request", {
request_id: req.requestId,
stream: streamRequested,
model: body.model,
nvidia_configured: Boolean(NVIDIA_API_KEY),
});
const proxied = await proxyNvidiaChatCompletions(req, body, streamRequested, res);
if (proxied || res.headersSent) return;
log("info", "chat_completions_fallback_local", { request_id: req.requestId });
return fallbackLocalChatCompletion(req, res, body, streamRequested);
},
);
app.post("/prompt", requireApiKey, express.json(), async (req, res) => {
const prompt = req.body.prompt;
const maxTokens = parseInt(req.body.max_tokens) || 256;
log("info", "prompt_request_received", {
request_id: req.requestId,
prompt_chars: prompt?.length ?? 0,
max_tokens: maxTokens,
});
if (!model || !processor) {
log("error", "prompt_model_unavailable", { request_id: req.requestId });
return res.status(503).json({ detail: "Model not loaded yet." });
}
if (!prompt) {
log("error", "prompt_validation_failed", {
request_id: req.requestId,
reason: "missing_prompt",
});
return res.status(400).json({ detail: "No prompt provided." });
}
try {
await withPromptTrace(req, prompt, maxTokens, async (span) => {
const generation = span?.startObservation(
"qwen_text_generation",
{
model: MODEL_ID,
input: prompt,
modelParameters: { max_new_tokens: maxTokens, do_sample: 0 },
},
{ asType: "generation" },
);
const start = Date.now();
const response = await queueTextInference(prompt, maxTokens);
const duration = Date.now() - start;
generation?.update({ output: response }).end();
span?.update({
output: { response_chars: response?.length ?? 0 },
metadata: { duration_ms: duration },
});
log("info", "prompt_completed", {
request_id: req.requestId,
duration_ms: duration,
response_chars: response?.length ?? 0,
});
res.json({ response });
});
} catch (err) {
log("error", "prompt_failed", {
request_id: req.requestId,
error: err.message,
stack: err.stack,
});
res.status(500).json({ detail: "Inference failed.", error: err.message });
}
});
Promise.all([setupTracing(), loadModel()]).then(() => {
app.listen(PORT, "0.0.0.0", async () => {
log("info", "server_started", {
host: "0.0.0.0",
port: PORT,
model_id: MODEL_ID,
langfuse_enabled: LANGFUSE_ENABLED,
nvidia_chat_url: NVIDIA_CHAT_URL,
nvidia_configured: Boolean(NVIDIA_API_KEY),
});
const shutdown = async (signal) => {
log("info", "shutdown_started", { signal });
if (telemetrySdk) {
await telemetrySdk.shutdown();
}
process.exit(0);
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
});
});