Serve

Inference on your machines.

SMILE Serve is the inference runtime all you need. A commercial license covers production use without the AGPL network source offer.

Overview

SMILE Serve is a production-ready inference server built on Quarkus that brings together three complementary inference capabilities on the JVM. A React-based web UI is bundled and served from the same process.

CapabilityAPI prefixDescription
Classic ML/api/v1/smileSerialized SMILE models (.sml) — classifiers and regressors
ONNX Runtime/api/v1/onnxAny model in the ONNX open format (.onnx)
LLM Chat/api/v1/chatOpenAI-compatible chat completions
Models/api/v1/modelsOpenAI-compatible list and retrieve for every loaded model — chat, ONNX, and SMILE
Metrics/q/metricsPrometheus scrape for classic, ONNX, and LLM models

Quick Start with Docker

The fastest way to run SMILE Serve is via the pre-built Docker image. Mount a local directory containing your model files and map the port:

Bash
docker run -it \
  -v /path/to/model/folder:/model \
  -p 8888:8080 \
  ghcr.io/haifengl/smile-serve:latest

The service starts on port 8080 inside the container (mapped to 8888 on the host). Place your .sml and .onnx model files in /path/to/model/folder; they are discovered automatically at startup.

GPU Build & Run

Build the JVM+GPU image from source and run it with CUDA graph decode enabled. To enable Google login, add -Dsmile.auth.google.client-id=... -Dsmile.auth.google.client-secret=... -Dsmile.auth.google.redirect-uri=... to JAVA_OPTS_APPEND yourself (see Authentication); keep those values out of shell history / docker inspect by sourcing them from environment variables rather than inlining literals.

Bash
./gradlew :serve:build
sudo docker build -f serve/src/main/docker/Dockerfile.jvm-gpu -t quarkus/smile-serve-gpu .
sudo docker run -i --rm --gpus all -p 8888:8080 \
  -e JAVA_OPTS_APPEND="-Dsmile.chat.model=Qwen/Qwen3.8-27B" \
  -u root -v "/llm/model":/model quarkus/smile-serve-gpu

Debug variant (adds CUDA_LAUNCH_BLOCKING=1 for synchronous CUDA error reporting; slower, use only when diagnosing a CUDA graph issue):

Bash
sudo docker run -i --rm --gpus all -p 8888:8080 \
  -e CUDA_LAUNCH_BLOCKING=1 \
  -e JAVA_OPTS_APPEND="-Dsmile.chat.model=Qwen/Qwen3.8-27B" \
  -u root -v "/llm/model":/model quarkus/smile-serve-gpu

LLM Decode Benchmarks

Steady-state continuous-batching decode on NVIDIA A100 with FlashInfer, smile.chat.max-batch-size=48, max_tokens: 512, greedy sampling (temperature: 0), and a uniform decode cohort (smile.chat.admit-coalesce-ms=50 for parallel load tests). Step is engine decode-step throughput (batch / step_ms); aggregate is AggregateTokenThroughput over a ~3 s window (includes streaming / logging overhead).

Llama 3.1 8B Instruct

CheckpointWeight pathStepAggregateStep timeForward / sample
meta-llama/Llama-3.1-8B-InstructBF16 dense2087 tok/s~2036 tok/s23 ms22 / 1 ms
hugging-quants/Meta-Llama-3.1-8B-Instruct-AWQ-INT4AWQ INT4 (Marlin)2667 tok/s~2543 tok/s18 ms17 / 0 ms

Both runs used a full batch of 48 active requests at locked positions (e.g. positions=[417..417]). AWQ is ~28% faster at the same concurrency; lighter weights also leave more free KV slots for longer context.

Qwen3.8 27B

CheckpointSetupStepAggregateStep timeForward / sample
Qwen/Qwen3.8-27BBF16, TP=4906 tok/s~896 tok/s53 ms51 / 1 ms

Hybrid Qwen3.5 stack (Gated DeltaNet + full attention) at the same concurrency and locked positions (e.g. positions=[466..466]). Absolute throughput is lower than 8B Llama as expected for a ~27B hybrid model across four GPUs; per-request generation stays ~17 tok/s with the batch saturated. Qwen AWQ via compressed-tensors / Marlin is not supported yet (detector currently treats cards such as cyankiwi/Qwen3.8-27B-AWQ-INT4 as dense).

Qwen3.8 27B with MTP speculative decoding

Qwen3.5/3.8 checkpoints ship a native multi-token-prediction (MTP) head. With smile.chat.speculative=true the engine drafts speculative-tokens tokens with that head and verifies them in one target forward pass; accepted tokens are byte-for-byte what greedy decoding would have produced (the output is lossless, up to rare bf16 near-tie flips between a 3-token window and single-token decode). MTP is a latency feature for low concurrency: it speeds up a lone request about 2-3x, and it automatically stays out of the way once several requests are active.

Setup: BF16, TP=4 on A100 40 GB, greedy, speculative-tokens=2 (the default depth of 3 measures the same or slightly better), prompt "What is the history of China?", 512 new tokens, warm run, wall time including prefill.

Single requestThroughputVerify forward / roundDraft accept rate
Plain decode18.7 tok/sn/an/a
MTP + persistent draft context (+ checkpoint replay)23 tok/s91 ms~73 %
+ fused DeltaNet window + FlashInfer verify attention38 tok/s49 ms~75 %
+ verify CUDA graph (default)52.8 tok/s (2.8x plain)~32 ms~77 %

Before the draft head received its prefix context (it only saw the current round's own draft tokens), MTP was ~15 % slower than plain decode with a ~50 % accept rate (27.5 vs 32.5 tok/s in an earlier measurement on a quieter system; absolute numbers differ between sessions, compare within a table).

A round (draft + one verify + bookkeeping) takes ~48 ms and commits 2.5 tokens on average (mean accepted draft depth 1.5 of 2). Throughput gains hold at long context (6.6k-token prompt, 256 new tokens: 16.7 s with the verify graph vs 26.5 s plain).

Concurrency. Speculating requests are drafted and verified together as one batched round, each with its own persistent draft context, so the gain holds well beyond a single request. Aggregate throughput (tok/s, 512 new tokens, identical prompts, decode CUDA graph on, max-batch-size=48, admit-coalesce-ms=50, BF16 TP=4 on A100 40 GB, default mem-fraction-static=0.85):

Active requestsPlain decodespeculative=trueSpeedup
125532.1x
248621.3x
4941211.3x
8187216-2361.2x
16335408-4411.2-1.3x
324846941.4x
48688-7028111.15x

Per-request speed at 16 requests is ~26-28 tok/s versus ~21 for plain decode. A batched round of 16 requests takes ~80 ms (draft ~8 ms, verify forward ~52 ms, bookkeeping ~14-19 ms) and commits ~2.5 tokens per request. Outputs match plain decode for the same prompt up to bf16 near-tie flips: plain decode itself is not batch-invariant, and a plain batch of 8 diverges from the same prompts run alone at the same places a speculative batch does.

Memory. Each speculating request needs DeltaNet checkpoint buffers: about 0.04 GB per GPU per draft slot for a 27B hybrid model, i.e. ~0.11 GB at the default depth 3 (~0.075 GB at depth 2), reserved at load for min(max-batch-size, speculative-max-concurrency) rows, before the KV pool is sized, so the KV budget already excludes it (48 rows at depth 3 = ~5.3 GB per GPU on a 27B hybrid model; it shrinks the KV pool, e.g. from ~734k to ~455k token slots on 40 GB GPUs at mem-fraction-static=0.85). Only the slots a partial accept can restore from are stored (positions 1..depth; the pre-window copy and the final position are not needed). A cohort larger than the reservation (only possible after a depth change) is still checked at run time and, if it does not fit, the engine logs one warning and serves it with plain batched decode instead of failing requests. To keep more KV, lower smile.chat.speculative-max-concurrency (smaller reservation) or set smile.chat.speculative=false.

Speculation is on by default for Qwen3.5/3.8 checkpoints with MTP weights; budget the memory above on small GPUs or very large batches, or set smile.chat.speculative=false.

Controls (all default to the values that were benchmarked):

SettingDefaultMeaning
smile.chat.speculativetrueMTP speculation for Qwen3.5/3.8 checkpoints with MTP weights (ignored for other models). Set false to disable.
smile.chat.speculative-tokens0 (model default; 3 for Qwen3.8-27B)Draft depth. Measured aggregate tok/s at 1 / 8 / 16 requests: depth 1: 47 / 203 / 387; depth 2: 53 / 216-236 / 408-441; depth 3 (default): 55 / 232 / 446; depth 4: 54 / 241 / 439. Depths 2-4 are within noise; depth 1 is slower.
smile.chat.speculative-max-concurrency0 (= max-batch-size)Requests that may speculate at once. A request keeps its speculation slot for its whole life; requests beyond the limit use batched plain decode. Speculating only part of a large cohort is slower than all or none, so leave unlimited unless you need to bound checkpoint memory.
SMILE_VERIFY_CUDA_GRAPHonCapture the verify forward as one CUDA graph (one capture per session). 0 disables.
SMILE_MTP_HISTORYonDraft head keeps a persistent per-request KV of the whole prefix (prompt and accepted tokens). 0 restores the context-free legacy head (about half the accept rate).
SMILE_MTP_VERIFY_CHECKPOINT_REPLAYonOn a partial accept, restore the DeltaNet state saved during the verify pass instead of running a second forward. 0 disables.
SMILE_VERIFY_CUDA_GRAPH_RAGGEDonAlso graph cohorts of 2+ requests at different positions. 0 limits the graph to a lone request (or equal positions).
SMILE_VERIFY_CUDA_GRAPH_MAX_BATCH16Largest cohort that uses a verify graph.
SMILE_VERIFY_FLASHINFER_EAGERonUse FlashInfer's paged prefill kernel for verify windows when the graph is not replaying. 0 disables.

Notes and limits:

Building and Running

All commands use the Gradle wrapper from the project root.

Dev Mode

Live-reload development mode — changes to Java sources are reflected without restarting. The Quarkus Dev UI is available at http://localhost:8888/q/dev/.

Bash
./gradlew :serve:quarkusDev \
  --jvm-args="--add-opens java.base/java.lang=ALL-UNNAMED"

The --add-opens flags are required by ONNX Runtime's Foreign Function Interface. The dev-mode HTTP port defaults to 8888 (configured via %dev.quarkus.http.port).

Packaging as a JAR

Bash
./gradlew :serve:build

This produces a Quarkus layered application in build/quarkus-app/. The entry point is build/quarkus-app/quarkus-run.jar; the dependencies live in build/quarkus-app/lib/ and must be distributed together.

Run it with:

Bash
java \
  --add-opens java.base/java.lang=ALL-UNNAMED \
  --add-opens java.base/java.nio=ALL-UNNAMED \
  --enable-native-access ALL-UNNAMED \
  -jar build/quarkus-app/quarkus-run.jar

To run on a custom port:

Bash
java \
  --add-opens java.base/java.lang=ALL-UNNAMED \
  --add-opens java.base/java.nio=ALL-UNNAMED \
  --enable-native-access ALL-UNNAMED \
  -Dquarkus.http.port=3801 \
  -jar build/quarkus-app/quarkus-run.jar

Uber-JAR

A single self-contained JAR (slower to start, simpler to deploy):

Bash
./gradlew :serve:build -Dquarkus.package.jar.type=uber-jar
java \
  --add-opens java.base/java.lang=ALL-UNNAMED \
  --add-opens java.base/java.nio=ALL-UNNAMED \
  --enable-native-access ALL-UNNAMED \
  -jar build/smile-serve-runner.jar

Native Executable

Compile to a native binary with GraalVM (sub-millisecond startup, lower memory):

Bash
./gradlew :serve:build -Dquarkus.native.enabled=true
./build/smile-serve-*-runner

Without a local GraalVM installation, use a Docker-based build:

Bash
./gradlew :serve:build \
  -Dquarkus.native.enabled=true \
  -Dquarkus.native.container-build=true

See the Quarkus native build guide for details.

Configuration Reference

Configuration is managed in src/main/resources/application.properties. Quarkus profile prefixes (%dev., %test.) override the base values in the corresponding profiles.

PropertyDefaultDescription
quarkus.http.port8080HTTP listen port (%dev default: 8888)
quarkus.rest.path/api/v1Global REST path prefix
smile.serve.model../modelPath to a .sml file or directory of .sml files
smile.onnx.model../modelPath to a .onnx file or directory of .onnx files
smile.chat.model../model/Llama3.1-8B-InstructLocal HF-layout checkpoint directory, or Hugging Face repo id (owner/name). Tokenizer is resolved next to the checkpoint (original/tokenizer.model, tokenizer.model, or HF tokenizer.json for AWQ/GPTQ cards)
smile.chat.max-seq-len0 (auto)Max context (prompt+output), like vLLM --max-model-len / SGLang --context-length. <=0 uses max_position_embeddings from the model config; set explicitly (e.g. 8192) to cap large-window models such as Qwen3.5
smile.chat.max-batch-size1Max in-flight chat generations (InferenceEngine Fluid Injection cap). Values >1 enable continuous batching. A waiting request is admitted only when free KV can reserve its full prompt + max_tokens window (capped by max-seq-len); otherwise it stays queued.
smile.chat.max-decode-batch0 (same as max-batch-size)Cap on requests per GPU decodeStep; 0 means use max-batch-size
smile.chat.prefill-token-budget2048Max prompt tokens prefilled per scheduler tick (chunked prefill so long prompts do not stall decode)
smile.chat.admission-timeout-ms120000Max ms a job may wait for KV admission before failing (0 = wait until Instant Eviction frees capacity)
smile.chat.admit-coalesce-ms0When idle, delay first admission up to this many ms so a parallel burst can fill the queue; stops early once queued >= max-batch-size. 0 disables (default). Use e.g. 50 for curl --parallel load tests
smile.chat.mem-fraction-static0.85SGLang --mem-fraction-static: fraction y of total GPU memory for the static region (weights + DeltaNet + KV). Leaves ~(1−y)×total free for activations (plus a small soft margin). Idle use near y×total is expected. Short-prompt OOM usually means activation peak/leak during forward — try 0.75 and/or a lower max-seq-len on 40GB TP=2 Qwen desktops. Pool is static (no per-request growth); when free KV is exhausted, generation stops early with partial output (finish_reason=length)
smile.chat.model-loader-threads0 (auto)Concurrent safetensors shard readers. Each worker loads one shard to CPU then fans out to TP ranks. Peak host RAM ≈ threads × shard size. 0 = min(8, CPUs), capped by number of shard files. Use 1–2 on RAM-tight desktops
smile.chat.devices0CUDA device index, or comma-separated TP list (0,7). %dev default: 7
smile.chat.tensor-parallel-size1TP world size; with a single devices entry expands to consecutive GPUs
smile.chat.pipeline-parallel-size1Must stay 1 until multi-node PP
smile.chat.kv-cache.dtype(unset)KV-cache element dtype (bfloat16, float16, float32, fp8_e4m3, fp8_e5m2, …). When unset, uses torch_dtype from the model config.json. Set fp8_e4m3 for ~2× KV capacity (longer context); attention dequants to BF16/FP16. Independent of weight quant.
smile.chat.quantizationautoWeight GEMM: auto / dense / fp8 / nvfp4 / marlin. auto selects FP8/NVFP4 on Hopper/Blackwell for native checkpoints, Marlin on Ampere/Ada for GPTQ/AWQ. GPTQ/AWQ on Hopper+ fails fast (no silent Marlin). Qwen hybrid: native FP8 (e.g. Qwen3.8-FP8) on H100+ (sm_90+) uses block-128 weight_scale_inv via LibTorch _scaled_mm_v2 (Fp8BlockLinear); rebuild libsmile_torch after pulling. Vision tower and lm_head stay BF16. A100 still fails fast on native FP8 Qwen. GPTQ/AWQ/Marlin for Qwen remain unsupported. Marlin AWQ/GPTQ install batch-reads each safetensors shard once and packs linears in parallel (direct int4→Marlin, no FP16 round-trip); expect on the order of tens of seconds for 8B AWQ on A100 rather than multi-minute phase-1 loads. GPTQ act-order (g_idx) is rejected.
smile.chat.kv-cache.page-size16Tokens per radix / KV pool page (prefix match and insert are page-aligned)
smile.chat.kv-cache.prefix-reusetrueMatch/insert prompts in the radix KV tree (SGLang-style). Hybrid Qwen also needs hybrid-prefix-replay
smile.chat.kv-cache.hybrid-prefix-replaytrueOn a hybrid Qwen prefix hit, replay the matched prefix to restore DeltaNet state while sharing KV pages. Set false to force-disable hybrid prefix reuse
smile.chat.oga.enabledtrueWhen Torch path is not selected, try ORT GenAI for GenAI-ready models (incl. nested HF packages / Olive cache)
smile.chat.oga.precision(auto)Cache-key / offline Olive optimize --precision; empty/auto uses cascade default (int4 — optimize does not accept fp8)
smile.chat.oga.cache-dir{SMILE_CACHE}/oliveOlive output cache root (never writes into HF_HOME hub trees); serve opens hits at startup
smile.chat.oga.olive-commandoliveOlive CLI executable for offline conversion only (not run at startup)
smile.chat.oga.device / provider(cascade)Optional Olive overrides for offline convert / cache key; default from GenAI.resolveOliveTarget()
quarkus.datasource.db-kindpostgresqlDatabase backend for chat history
quarkus.datasource.jdbc.urljdbc:postgresql://localhost:5432/smileJDBC connection URL
quarkus.hibernate-orm.activefalseEnable ORM (set true when database is available)

Override at runtime with -D system properties, for example:

Bash
java ... -Dsmile.serve.model=/data/models/rf_classifier.sml -jar quarkus-run.jar

Classic ML Inference API

Model Format

Classic ML models are serialized Java objects saved in .sml files by the SMILE smile.model.Model framework. They carry:

At startup, InferenceService scans the path specified by the property smile.serve.model. If the path is a regular .sml file only that model is loaded; if it is a directory every .sml file in the directory is loaded.

Get Model Metadata

Returns the algorithm name, input schema, and tags for a model. Use GET /api/v1/models to discover loaded model IDs.

GET /api/v1/smile/{id}

Example:

Bash
curl http://localhost:8080/api/v1/smile/iris_random_forest-1
{
  "id": "iris_random_forest-1",
  "algorithm": "random-forest",
  "schema": {
    "petallength": { "type": "float", "nullable": false },
    "petalwidth":  { "type": "float", "nullable": false },
    "sepallength": { "type": "float", "nullable": false },
    "sepalwidth":  { "type": "float", "nullable": false }
  },
  "tags": {
    "smile.random_forest.trees": "200"
  }
}

The schema object lists every input feature in alphabetical order — this is the column order used by the CSV streaming endpoint.

Single Inference (JSON)

POST /api/v1/smile/{id}
Content-Type: application/json

The request body is a flat JSON object whose keys are the feature names defined in the model schema. All non-nullable fields are required.

Classification example (iris):

Bash
curl -X POST http://localhost:8080/api/v1/smile/iris_random_forest-1 \
  -H "Content-Type: application/json" \
  -d '{
    "sepallength": 5.1,
    "sepalwidth":  3.5,
    "petallength": 1.4,
    "petalwidth":  0.2
  }'
{
  "prediction": 0,
  "probabilities": [0.960, 0.021, 0.019]
}

Explanations (TreeSHAP)

Explanations can be requested by passing "enableExplanations": true in the JSON body, or appending ?explain=true to the URL.

Bash
curl -X POST "http://localhost:8080/api/v1/smile/iris_random_forest-1?explain=true" \
  -H "Content-Type: application/json" \
  -d '{
    "sepallength": 5.1,
    "sepalwidth":  3.5,
    "petallength": 1.4,
    "petalwidth":  0.2
  }'
{
  "prediction": 0,
  "probabilities": [0.960, 0.021, 0.019],
  "explanations": {
    "shap": [
      [0.012, -0.045, 0.123, -0.002],
      [-0.034, 0.056, -0.210, 0.015],
      [0.022, -0.011, 0.087, -0.013]
    ]
  }
}

Error responses:

HTTPCause
400 Bad RequestMissing required field, or malformed JSON
404 Not FoundUnknown model ID

Streaming Inference (CSV / JSON-lines)

Process many samples in a single request. The server returns results as a Server-Sent Events stream emitting structured JSON objects — one data: line per input sample. Append ?explain=true to include explanations in each streaming event.

POST /api/v1/smile/{id}/stream
Content-Type: text/plain          ← CSV mode
Content-Type: application/json   ← JSON-lines mode

CSV mode (text/plain)

Each non-blank line is a comma-separated row of feature values in the same column order as the model schema (alphabetical by field name, as shown by GET /api/v1/smile/{id}).

Bash
cat iris.csv | curl -X POST \
  -H "Content-Type: text/plain" \
  --data-binary @- \
  http://localhost:8080/api/v1/smile/iris_random_forest-1/stream

Where iris.csv might contain:

5.1,3.5,1.4,0.2
6.7,3.0,5.2,2.3
5.8,2.7,4.1,1.0

The response stream (SSE format emitting JSON payloads):

data: {"prediction":0,"probabilities":[0.960,0.021,0.019]}

data: {"prediction":2,"probabilities":[0.012,0.051,0.937]}

data: {"prediction":1,"probabilities":[0.031,0.752,0.217]}

JSON-lines mode (application/json)

Each non-blank line must be a complete JSON object (one per line). This is more verbose but supports named fields in any order. Each sample can also independently include "enableExplanations": true.

Bash
cat iris.jsonl | curl -X POST \
  -H "Content-Type: application/json" \
  --data-binary @- \
  http://localhost:8080/api/v1/smile/iris_random_forest-1/stream

Where iris.jsonl contains:

{"sepallength":5.1,"sepalwidth":3.5,"petallength":1.4,"petalwidth":0.2}
{"sepallength":6.7,"sepalwidth":3.0,"petallength":5.2,"petalwidth":2.3,"enableExplanations":true}

Model IDs

A model's ID is constructed as <name>-<version> from the model's embedded metadata tags (smile.model.Model.ID and smile.model.Model.VERSION). If those tags are absent, the file name stem is used as the name and "1" as the version. For example, a file named iris_random_forest.sml with no ID tag gets the ID iris_random_forest-1.

ONNX Inference API

The ONNX endpoint exposes any model in the ONNX open format through SMILE's native ONNX Runtime binding (smile.onnx). This covers models exported from PyTorch, TensorFlow, scikit-learn (via sklearn-onnx), and many other frameworks. Classical ONNX inference is documented on ONNX.

Model Format

At startup, OnnxService scans the folder specified by the property smile.onnx.model. Every .onnx file found is loaded into an InferenceSession. The model ID is the file name without the .onnx extension (e.g., resnet50.onnx → ID resnet50).

Native library: the ONNX Runtime shared library (libonnxruntime.so / libonnxruntime.dylib / onnxruntime.dll) must be on the OS library search path, and the JVM must include --enable-native-access=ALL-UNNAMED. If the library is missing, startup continues without ONNX models (endpoints return 404) rather than aborting the whole serve process. Download pre-built binaries from the ORT releases page.

Get ONNX Model Info

Returns graph metadata and the typed, shaped input/output node descriptors. Use GET /api/v1/models to discover loaded model IDs.

GET /api/v1/onnx/{id}
Bash
curl http://localhost:8080/api/v1/onnx/resnet50
{
  "id": "resnet50",
  "graphName": "ResNet50",
  "description": "Image classification model",
  "version": 1,
  "inputs": [
    {
      "name": "input",
      "onnxType": "TENSOR",
      "elementType": "FLOAT",
      "shape": [1, 3, 224, 224]
    }
  ],
  "outputs": [
    {
      "name": "output",
      "onnxType": "TENSOR",
      "elementType": "FLOAT",
      "shape": [1, 1000]
    }
  ],
  "customMeta": {}
}

A shape value of -1 means that dimension is dynamic (determined at inference time from the input data).

Single Inference (JSON)

POST /api/v1/onnx/{id}
Content-Type: application/json

The request body is a JSON object mapping each input name to a flat JSON array of numbers or strings. The server constructs the required ORT tensor from the declared element type and shape.

Example — image classification (resnet50, 1×3×224×224 = 150528 floats):

Bash
curl -X POST http://localhost:8080/api/v1/onnx/resnet50 \
  -H "Content-Type: application/json" \
  -d '{"input": [0.485, 0.456, 0.406, ...]}'

The Infer UI image path resizes to the model's H×W, scales to [0, 1], then applies ImageNet channel normalization (x - mean) / std with mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] (NCHW or NHWC as declared). When posting tensors yourself, use the same layout and normalization the model was trained with.

Response — a JSON object mapping each output name to a flat array of raw logits (not probabilities) or strings. ImageNet classifiers from the ONNX Model Zoo typically omit a Softmax node; apply MathEx.softmax before interpreting scores as class probabilities (same as InferenceSessionTest in core).

{
  "output": [0.001, 0.002, 0.872, 0.003, ...]
}

Multi-input model example:

Bash
curl -X POST http://localhost:8080/api/v1/onnx/bert_classifier \
  -H "Content-Type: application/json" \
  -d '{
    "input_ids":      [101, 2054, 2003, 1996, 3007, 1997, 2605, 1029, 102],
    "attention_mask": [1,   1,    1,    1,    1,    1,    1,    1,    1  ],
    "token_type_ids": [0,   0,    0,    0,    0,    0,    0,    0,    0  ]
  }'

String input model example (e.g. scikit-learn pipeline with categorical string features or text inputs):

Bash
curl -X POST http://localhost:8080/api/v1/onnx/pipeline_classifier \
  -H "Content-Type: application/json" \
  -d '{
    "category": ["electronics", "apparel"],
    "rating":   [4.5, 3.0]
  }'

Supported input element types:

ONNX typeJSON valuesORT type
FLOATnumbersfloat[]
DOUBLEnumbersdouble[]
INT32integersint[]
INT64integerslong[]
INT8 / UINT8 / BOOLintegers (0/1 for bool)byte[]
STRINGstringsString[]

Error responses:

HTTPCause
400 Bad RequestMissing input, wrong element count, type mismatch
404 Not FoundUnknown model ID

Streaming Inference

Identical in structure to the classic ML streaming endpoint but returns JSON objects:

POST /api/v1/onnx/{id}/stream
Content-Type: text/plain          ← CSV floats for single-input models
Content-Type: application/json   ← JSON-lines for multi-input models

CSV (single-input models only):

Bash
cat features.csv | curl -X POST \
  -H "Content-Type: text/plain" \
  --data-binary @- \
  http://localhost:8080/api/v1/onnx/my_classifier/stream

Each response line is a compact JSON object:

data: {"output":[0.02,0.95,0.03]}

data: {"output":[0.88,0.07,0.05]}

JSON-lines (any number of inputs):

Bash
cat samples.jsonl | curl -X POST \
  -H "Content-Type: application/json" \
  --data-binary @- \
  http://localhost:8080/api/v1/onnx/bert_classifier/stream

Tensor Types and Shape Resolution

The server automatically resolves the ORT tensor shape from the model's declared input shape and the actual array length:

LLM Chat API

SMILE Serve includes a Java implementation of Large Language Models for on-premise LLM inference. The chat API is designed to be compatible with the OpenAI Chat Completions interface.

The LLM is optional: if the path specified by the property smile.chat.model does not exist on the file system (and cannot be resolved via Hugging Face / OGA), ChatService starts in an unavailable state and every request to the chat endpoints returns HTTP 503 Service Unavailable.

Backend selection: with CUDA and a builtin Llama/Qwen checkpoint, serve uses the Torch path + continuous batching. Otherwise it falls back to ONNX Runtime GenAI (GenAiChatModel) when the model is already GenAI-ready (genai_config.json at the repo root or under provider folders such as cuda/cuda-int4-…/, or a prior Olive cache under {SMILE_CACHE}/olive). Serve does not run Olive at startup — convert offline with smile.chat.Olive (see smile.chat.oga.*). Tools input / structured tool_calls output work on the serial GenAI path; multimodal GenAI materializes media to temp files. See ONNX GenAI.

There are therefore two ways to run a chat model — the native (LibTorch) path for the built-in Llama-3.1 and Qwen3.5/3.8 implementations, and the ONNX Runtime GenAI path for every other family packaged as a GenAI-ready directory.

Supported Models

The native path covers Llama-3.1 and Qwen3.5/3.8 checkpoints. Every other family is served through ONNX Runtime GenAI and needs only a GenAI-ready package — published on the Hugging Face Hub, converted offline with Olive (smile.chat.Olive, see ONNX GenAI), or built with the ONNX Runtime GenAI model builder. Pass any repo id below (or a local directory) to smile.chat.model:

Bash
./gradlew :serve:quarkusDev "-Dsmile.chat.model=microsoft/Phi-4-mini-instruct-onnx"

Language models (decoder-only). The runtime dispatches on the architecture recorded in genai_config.json; the checkpoint listed for each family is a known-good, GenAI-ready Hub repository.

Vision-language models. GenAiChatModel accepts local image/audio paths through MultiModalProcessor; serve materializes data-URL / HTTP media to temp files first.

FamilyGenAI architectureExample GenAI-ready checkpoint
Phi-3 Vision / Phi-4 multimodalphi3vmicrosoft/Phi-3.5-vision-instruct-onnx, microsoft/Phi-4-multimodal-instruct-onnx
Qwen-VL / Qwen2.5-VL / Qwen3-VLqwen2_vlbuild with the model builder from the Qwen VL checkpoints

Audio models. Whisper speech recognition is supported by the runtime; select it outside the chat API (the chat endpoint is text-only).

FamilyGenAI architectureExample GenAI-ready checkpoint
Whisperwhispertonythethompson/Whisper-Tiny-GenAI-ONNX, tonythethompson/whisper-large-v3-genai

The architecture list mirrors the upstream ONNX Runtime GenAI supported models and model-builder support matrix; the checkpoint ids are Hub repositories that ship genai_config.json. File layouts and provider folders vary per repo — GenAiModelPaths auto-detects the nested package (CUDA / DirectML / CPU) that matches your execution provider.

List models

GET /api/v1/models

OpenAI-compatible catalog of all loaded models — chat LLMs, ONNX graphs, and SMILE .sml models. Inference still uses type-specific paths: /api/v1/chat/completions, /api/v1/onnx/{id}, /api/v1/smile/{id}.

Bash
curl http://localhost:8080/api/v1/models
{
  "object": "list",
  "data": [
    {
      "id": "meta-llama/Llama-3.1-8B-Instruct",
      "object": "model",
      "created": 1741900000,
      "owned_by": "meta-llama",
      "shutdown_date": null,
      "kind": "LLM"
    },
    {
      "id": "iris_random_forest-1",
      "object": "model",
      "created": 1710000000,
      "owned_by": "Unknown",
      "shutdown_date": null,
      "kind": "random-forest"
    },
    {
      "id": "resnet50",
      "object": "model",
      "created": 1710000000,
      "owned_by": "Unknown",
      "shutdown_date": null,
      "kind": "ONNX"
    }
  ]
}

kind values:

owned_by rules:

Retrieve model

GET /api/v1/models/{id}

OpenAI-compatible retrieve model. Returns the same base ModelObject fields as list entries, plus an optional type-specific detail block. Does not run inference — use /chat/completions, /onnx/{id}, or /smile/{id} for that. Ids may contain slashes (e.g. Hugging Face repo ids).

kindExtra fieldContents
SMILE algorithmsmileformula, schema, tags, train / validation / test metrics (finite values only)
ONNXonnxproducer, domain, graph info, I/O shapes, custom metadata from the .onnx file
LLMllmfamily, source (local/huggingface), architecture from config.json / params.json

List responses omit smile / onnx / llm so the catalog stays lean.

Bash
curl http://localhost:8080/api/v1/models/iris_random_forest-1
{
  "id": "iris_random_forest-1",
  "object": "model",
  "created": 1710000000,
  "owned_by": "Unknown",
  "shutdown_date": null,
  "kind": "random-forest",
  "smile": {
    "formula": "class ~ .",
    "schema": {
      "petallength": { "type": "float", "nullable": false }
    },
    "tags": {},
    "train": {
      "accuracy": 0.97,
      "size": 150
    },
    "validation": null,
    "test": null
  }
}

Chat Completions

POST /api/v1/chat/completions
Content-Type: application/json

Tokens are streamed back as Server-Sent Events when stream is true, or returned as a single OpenAI chat.completion JSON object when stream is false or omitted (OpenAI default). The conversation (user message + assistant reply) is automatically persisted to the configured database after generation finishes.

Serve runs an InferenceEngine continuous-batching loop (Fluid Injection admission, chunked prefill, batched decode, Instant Eviction on abort). Offline library callers may still use LanguageModel.generate; HTTP chat always goes through the engine. Client disconnect on an SSE stream aborts the GenerationHandle, which Instant-Evicts that request's KV pages without stopping peer in-flight generations.

Request body fields (snake_case):

FieldTypeDefaultDescription
modelstringloaded modelMust match the loaded model id when set (HF repo id or local directory name); omit/empty to use the loaded model
messagesMessage[]requiredOrdered dialog turns
conversationstringnullExisting conversation id (conv_<n>) to append to
max_tokensintremaining contextMax new tokens (legacy OpenAI name). Default: max-model-len − prompt_len
max_completion_tokensint—Alias for max_tokens; takes precedence when set
temperaturedouble0.6Sampling temperature (higher = more random)
top_pdouble0.9Nucleus-sampling threshold
logprobsbooleanfalseInclude log-probabilities
seedlong0Random seed (0 = non-deterministic)
streambooleanfalsetrue → SSE chunks; false/omitted → single chat.completion JSON
toolsarray—OpenAI tool definitions (type: function) for Qwen3 / Qwen3.5 / Qwen3.8
tool_choicestring|objectautoauto / none / required / {"type":"function","function":{"name":"…"}}
parallel_tool_callsbooleantrueAllow multiple tool calls in one assistant turn

Each Message has a role (system, user, assistant, or tool) and content. Assistant turns may include tool_calls; tool-result turns use role: "tool" and tool_call_id. Legacy "ipython" is accepted as an alias for "tool".

Tool calling (Qwen3 / Qwen3.5 / Qwen3.8)

OpenAI-compatible tool calling is supported for latest Qwen checkpoints (XML <tool_call> format). Clients run tools locally and continue the dialog:

Bash
curl -X POST http://localhost:8080/api/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [{"role": "user", "content": "What is the weather in SF?"}],
    "tools": [{
      "type": "function",
      "function": {
        "name": "get_current_temperature",
        "description": "Get temperature for a location",
        "parameters": {
          "type": "object",
          "properties": {"location": {"type": "string"}},
          "required": ["location"]
        }
      }
    }],
    "tool_choice": "auto"
  }'

A successful tool-call response uses finish_reason: "tool_calls" and message.tool_calls[]. Append tool results as {"role":"tool","tool_call_id":"…","content":"…"} and call again for the final answer. Streaming (v1) buffers the completion and replays delta.tool_calls at the end (not token-true XML streaming).

Smoke test with the OpenAI Python SDK:

Bash
python serve/scripts/test_tool_calling.py --base-url http://localhost:8888/api/v1

Streaming example (stream: true):

Bash
curl -X POST http://localhost:8080/api/v1/chat/completions \
  -H "Content-Type: application/json" \
  -N \
  -d '{
    "stream": true,
    "messages": [
      {"role": "system",  "content": "You are a helpful assistant."},
      {"role": "user",    "content": "What is the capital of France?"}
    ],
    "max_tokens": 256,
    "temperature": 0.7
  }'

The response is an SSE stream of OpenAI-shaped chat.completion.chunk JSON events, terminated by data: [DONE].

Non-streaming example (stream omitted or false):

Bash
curl -X POST http://localhost:8080/api/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      {"role": "user", "content": "What is the capital of France?"}
    ],
    "max_completion_tokens": 256
  }'
{
  "id": "chatcmpl-...",
  "object": "chat.completion",
  "created": 1741900000,
  "model": "meta-llama/Llama-3.1-8B-Instruct",
  "choices": [
    {
      "index": 0,
      "message": { "role": "assistant", "content": "Paris." },
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 20,
    "completion_tokens": 3,
    "total_tokens": 23
  }
}

Example — continue a previous conversation:

Bash
curl -X POST http://localhost:8080/api/v1/chat/completions \
  -H "Content-Type: application/json" \
  -N \
  -d '{
    "conversation": 42,
    "messages": [
      {"role": "user", "content": "What about Germany?"}
    ]
  }'

Conversation History API

Chat history is stored in a relational database (PostgreSQL in production, H2 in dev mode). The API base path is /api/v1/conversations.

Conversations are scoped to authenticated users. Guests (no session) may still create conversations and chat, but those rows are tied to client IP and do not appear in the sidebar list until the user signs in (recent guest threads from the same IP within 24 hours are merged on login).

On localhost or 127.0.0.1 (any port), the server auto-logs you in as the local me account so dev workflows work without OAuth.

Authentication

The authentication endpoints manage user identity specifically for conversation history and personal settings. Note that this is a session-cookie identity mechanism intended for personal use and UI convenience, not an enterprise authentication or authorization system: enterprise access control belongs to the API gateway layer, and ML/ONNX inference endpoints are intentionally unauthenticated at the engine level.

EndpointDescription
GET /api/v1/auth/meCurrent user (logged_in, user profile) or guest
GET /api/v1/auth/login/googleRedirect to Google OAuth (requires config below)
GET /api/v1/auth/callback/googleOAuth callback (sets session cookie)
POST /api/v1/auth/logoutClear session
GET /api/v1/users/meProfile (authenticated)
PATCH /api/v1/users/meUpdate display name, avatar URL, personal instructions

Configure Google login in application.properties (or env):

Properties
smile.auth.google.client-id=${GOOGLE_CLIENT_ID}
smile.auth.google.client-secret=${GOOGLE_CLIENT_SECRET}
# Optional; default is {origin}/api/v1/auth/callback/google
# smile.auth.google.redirect-uri=https://your-host/api/v1/auth/callback/google
smile.auth.session-secret=${SMILE_SESSION_SECRET}

Register the redirect URI in Google Cloud Console. Personal instructions from the user profile are injected as the chat system prompt when logged in.

Create, retrieve, update, and delete follow the OpenAI Conversations shapes. Conversation ids are strings of the form conv_<n>. List, PATCH (title/pin), and GET .../items are SMILE extensions.

List conversations (SMILE extension, requires login)

GET /api/v1/conversations?pageIndex=0&pageSize=25&q=search&pinned=true

Returns the signed-in user's conversations (title, pinned, updated_at). Search matches title and message content.

Bash
curl "http://localhost:8080/api/v1/conversations?pageSize=10"

Create a conversation

POST /api/v1/conversations
Content-Type: application/json

Optional body fields: metadata (≤16 string pairs) and items (≤20 message items with role + text content).

Bash
curl http://localhost:8080/api/v1/conversations \
  -H "Content-Type: application/json" \
  -d '{"metadata":{"topic":"demo"},"items":[{"type":"message","role":"user","content":"Hello!"}]}'
{
  "id": "conv_1",
  "object": "conversation",
  "created_at": 1741900000,
  "metadata": {"topic": "demo"}
}

Retrieve a conversation

GET /api/v1/conversations/{conversation_id}

Update a conversation

POST /api/v1/conversations/{conversation_id}
Content-Type: application/json
Bash
curl http://localhost:8080/api/v1/conversations/conv_1 \
  -H "Content-Type: application/json" \
  -d '{"metadata":{"topic":"project-x"}}'

Patch sidebar fields (SMILE extension, requires ownership)

PATCH /api/v1/conversations/{conversation_id}
Content-Type: application/json

{"title": "My chat", "pinned": true}

Delete a conversation

DELETE /api/v1/conversations/{conversation_id}
{
  "id": "conv_1",
  "object": "conversation.deleted",
  "deleted": true
}

Get conversation messages (SMILE extension)

GET /api/v1/conversations/{conversation_id}/items?pageIndex=0&pageSize=25

Returns the individual message turns (role + content + createdAt) in chronological order.

Bash
curl http://localhost:8080/api/v1/conversations/conv_42/items
[
  { "id": 1, "conversationId": 42, "role": "user",      "content": "What is the capital of France?", "createdAt": "2026-04-15T10:00:00Z" },
  { "id": 2, "conversationId": 42, "role": "assistant", "content": "The capital of France is Paris.", "createdAt": "2026-04-15T10:00:02Z" }
]

Web UI

A React-based web interface is bundled via Quarkus Quinoa. It is served from the root URL and provides:

In dev mode the React development server runs on port 5173 and requests are proxied to the Quarkus backend. The production build (dist/) is served statically by the Quarkus process.

Database

Chat conversation history requires a relational database.

ProfileBackendURL
ProductionPostgreSQLjdbc:postgresql://localhost:5432/smile
DevSQLitejdbc:sqlite:./smile_serve.db
TestH2 (in-memory)jdbc:h2:mem:test;DB_CLOSE_DELAY=-1

To enable the database in production set:

Properties
quarkus.hibernate-orm.active=true
quarkus.datasource.username=<user>
quarkus.datasource.password=<password>

Hibernate ORM uses drop-and-create by default. Change the strategy in production to update or validate:

Properties
quarkus.hibernate-orm.schema-management.strategy=update

The database is not required for the ML or ONNX inference endpoints — only for chat conversation persistence.

Testing

Bash
./gradlew :serve:test

The test profile (%test.*) configures the service with:

The test class InferenceResourceTest covers:

TestEndpointScenario
testGetModelMetadataGET /smile/{id}Returns algorithm, schema, and nullability
testGetUnknownModelReturns404GET /smile/{id}404 for unknown ID
testPredictJsonReturnsPredictionAndProbabilitiesPOST /smile/{id}Correct label + probabilities
testPredictJsonWithZeroFeaturesReturnsValidPredictionPOST /smile/{id}Edge case: all-zero features
testPredictJsonMissingFieldReturns400POST /smile/{id}400 for missing field
testPredictUnknownModelReturns404POST /smile/{id}404 for unknown model
testStreamCsvReturnsPredictionsPOST /smile/{id}/stream3 CSV rows → 3 SSE data lines
testStreamJsonLinesReturnsPredictionsPOST /smile/{id}/stream2 JSON-lines → 2 SSE data lines
testStreamCsvTooFewColumnsEmitsNoPredictionsPOST /smile/{id}/streamBad CSV closes stream
testStreamUnknownModelReturns404POST /smile/{id}/stream404 before stream starts

API Quick Reference

Classic ML — /api/v1/smile

MethodPathDescription
GET/smile/{id}Get model metadata and schema
POST/smile/{id}Single JSON inference
POST/smile/{id}/streamStreaming CSV or JSON-lines inference

ONNX — /api/v1/onnx

MethodPathDescription
GET/onnx/{id}Get graph info, input/output shapes
POST/onnx/{id}Single JSON inference
POST/onnx/{id}/streamStreaming CSV or JSON-lines inference

Chat — /api/v1/models, /api/v1/chat, /api/v1/conversations

MethodPathDescription
GET/modelsList all loaded models — chat, ONNX, SMILE (OpenAI-compatible)
GET/models/{id}Retrieve a model by id (OpenAI-compatible)
POST/chat/completionsChat completion — SSE when stream: true, JSON when stream: false
GET/conversationsList conversations (paginated; SMILE extension)
GET/conversations/{conversation_id}Retrieve conversation (OpenAI-compatible)
POST/conversationsCreate conversation (OpenAI-compatible)
POST/conversations/{conversation_id}Update conversation metadata (OpenAI-compatible)
DELETE/conversations/{conversation_id}Delete conversation (OpenAI-compatible)
GET/conversations/{conversation_id}/itemsList message turns (paginated; SMILE extension)

See Also

For the absolute latest details, see the SMILE Serve user guide in the repository.

←   ONNX GenAI Linear Algebra  →