Skip to content
4 min read · 876 words

Transformers.js

This is the only battery in the section that runs the same code in Node and the browser. ONNX Runtime auto-selects its backend — onnxruntime-node (native, CPU, no GPU required) in Node, onnxruntime-web (WASM + WebGPU) in the browser — and the battery does not gate on navigator.gpu. isAvailable() returns true whenever the @huggingface/transformers peer imports, full stop. That env-neutrality is the headline: the agent you test in Node CI is the agent that runs in the user's tab.

ts
import { TransformersJsAdapter } from '@nhtio/adk/batteries/llm/transformers_js'

const executor = new TransformersJsAdapter({
  model: 'onnx-community/gemma-4-E2B-it-ONNX',
  dtype: 'q4',
})

It is text-out, like LiteRT-LM: the model emits a string, and tool calls + reasoning are parsed back out by the shared chat_common layer (toolCallParser / reasoningParser, both default 'auto' — try-all, first confident match wins). Streaming v1 reports prose deltas live, then stops streaming prose the moment a tool-call or reasoning start-marker appears; the authoritative clean message is persisted after generation completes (so the persisted message is always correct — only live prose preceding a tool call is truncated).

Tested models

The broadest table in this section, because env-neutral Node execution makes it cheap to run real weights in CI. Every row is a real matrix entry; every quirk traces to a real generation, not a guess.

ModelParserNotes (the real quirk)
Gemma 4 E2B (onnx-community/gemma-4-E2B-it-ONNX, q4)gemmaText + image + audio + the combined image-and-audio turn. Emits decoder-stripped call:NAME{k:v} at runtime, not the template's <|tool_call>.
Qwen3 0.6B (q4f16)hermesReasons before the call; at a tight budget the <think> eats the tokens and the call truncates mid-JSON — needs ~320-token budget so the hermes JSON completes.
DeepSeek-R1-Distill-Qwen 1.5B (q4)think-tagReasoning-only; q4f16 crashes this graph (ONNX Cast-node error) → use q4. May spend its whole budget reasoning without concluding.
Llama 3.2 1B (q4f16)autollama3_jsonEmits bare {"name","parameters"} JSON (not pythonic) — the auto driver claims it.
SmolLM2 360M (q4)Text/streaming baseline; no tool/reasoning assumptions.
Phi-4-mini (q4)phiThe q4 quant declines tool calls — text baseline only here; the phi parser (functools[...]) is proven via the capture tier.
Granite 4.0 350MhermesEmits Hermes-style <tool_call>{json}</tool_call>no Granite-specific parser needed.
Qwen2.5-Coder 0.5B (q4)autollama3_jsonEmits fenced ```json {name,arguments} ``` (not <tool_call>); the llama3_json parser un-fences and claims it. q4f16 crashes → q4.
Nemotron 3 Nano 4Bqwen3_coderMamba-hybrid LOAD-PROBE; emits qwen3_coder XML + a stray </think> (orphan-recovered).
Qwen2.5-VL 3B (image, q4f16)PROBE: q4f16 VL preprocessing throws a Tensor size != data-length error upstream in transformers.js — a run failure here is DATA, not our bug.

Embeddings (separate transformers_js_embed battery, same runtime): all-MiniLM-L6-v2, BGE small en v1.5, Snowflake Arctic embed S (asymmetric query/document prefixes), and EmbeddingGemma 300M — all verified unit-norm + deterministic.

Multimodal input

"Text-out" is about output structure, not input. transformers.js genuinely perceives image and audio: a multimodal model's processor is called positionally as _call(text, images, audio), and the model consumes them. Gemma-4-E2B transcribes speech, names an image's dominant colour, and does both in a single turn.

One env-neutrality detail worth knowing: transformers.js's own read_audio decodes via the Web Audio API (AudioContext), which does not exist in Node. So for the common case — uncompressed PCM WAV at 16 kHz, exactly what speech models want — the battery decodes the RIFF itself with a DataView, dependency-free, in Node and the browser alike, before it ever imports the heavy peer. Compressed containers (mp3/flac/ogg) fall back to read_audio (browser-only, by that path's nature).

Media output

Media output is wired but opt-in. By default the battery is text-out (the tested chat checkpoints emit text), so it attaches nothing — byte-for-byte unchanged. Supply an extractMediaOutputs hook and a wrapped media-emitting model's generated audio/image is surfaced as an assistant Message.attachments entry:

ts
const executor = new TransformersJsAdapter({
  model: 'onnx-community/Llama-3.2-1B-Instruct-q4f16',
  // Turn the model's text answer into spoken audio and attach it to the turn.
  extractMediaOutputs: async (rawResult) => {
    const wavBytes = await synthesizeSpeechFromResult(rawResult) // your TTS
    return [{ kind: 'audio', mimeType: 'audio/wav', bytes: wavBytes }]
  },
})

The adapter persists each output via ctx.storeMediaBytes, builds a first-party Media.toolGenerated(...), and attaches it to the assistant message (a media-only turn — empty text + attachment — is legitimate). The hook receives the raw generation result; what it does with it is yours. This is the same seam LiteRT-LM exposes. (A dedicated text-to-audio/image generation battery — emitting media as the model's own task — is a separate, future thing; this hook is for an LLM turn that produces media alongside or instead of text.)

Call dispose() in long-lived browser sessions

reset() only nulls the cached pipeline reference — it does NOT free the underlying ONNX Runtime sessions or the WebGPU/wasm device memory they hold. Loading many models back-to-back in one browser session (a model picker, a test matrix) without disposing accumulates those sessions until the heap is exhausted, surfacing as Can't create a session … memory copy. await adapter.dispose() releases the model's ONNX sessions; the next dispatch re-resolves a fresh pipeline. This is exactly the bug the build showcase recounts hitting in the WebGPU matrix.

Errors

Typed E_TRANSFORMERS_JS_* family — context overflow, stream error, invalid tool-call args, tool-parse failed — plus E_INVALID_TRANSFORMERS_JS_OPTIONS and E_UNSUPPORTED_MEDIA_MODALITY. Full option + exception detail in Assembly → LLM batteries.