Experimental High-Level Python API and Server#
The experimental Python API wraps export, engine build, engine loading, generation, streaming, and OpenAI-compatible serving.
Status: Experimental. API may change between releases.
Prerequisites#
Complete the Installation Guide with the C++ runtime, Python bindings, and server dependencies enabled before proceeding. The examples below assume experimental.server and tensorrt_edgellm are importable from the active Python environment.
If the active environment was installed with base export dependencies only, install the server dependencies before building Python bindings or launching the server:
cd /path/to/TensorRT-Edge-LLM
python -m pip install -e ".[server]"
Python API#
From a HuggingFace checkpoint:
from experimental.server import LLM, SamplingParams
llm = LLM(model="Qwen/Qwen3-1.7B")
outputs = llm.generate(
["What is the capital of France?"],
SamplingParams(temperature=0.7, max_tokens=128),
)
print(outputs[0].text)
From existing ONNX or engine directories:
from experimental.server import LLM
llm = LLM(onnx_dir="/path/to/llm_onnx")
llm = LLM(engine_dir="/path/to/llm_engine")
Streaming:
from experimental.server import LLM, SamplingParams
llm = LLM(engine_dir="/path/to/llm_engine")
for delta in llm.generate_stream(
[{"role": "user", "content": "Tell me a story."}],
SamplingParams(max_tokens=256),
):
print(delta.text, end="", flush=True)
OpenAI-Compatible Server#
python -m experimental.server \
--model Qwen/Qwen3-1.7B \
--port 8000
Serve an existing engine without exporting or building:
python -m experimental.server \
--model /path/to/llm_engine \
--port 8000
For a multimodal model, point the server at the encoders explicitly:
--multimodal-engine-dir(alias--visual-engine-dir): prebuilt visual and/or audio encoder engines for a prebuilt--modelengine dir.--visual-onnx-dir/--audio-onnx-dir: prebuilt encoder ONNX dirs when--modelis a prebuilt ONNX dir.
Query:
curl -sN http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "Hello!"}], "max_tokens": 128}'
Streaming query:
curl -sN http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "Hello!"}], "max_tokens": 128, "stream": true}'
Legacy raw-prompt completion (no chat template applied):
curl -sN http://localhost:8000/v1/completions \
-H "Content-Type: application/json" \
-d '{"prompt": "Once upon a time", "max_tokens": 128}'
Tool-aware query:
curl -sN http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [{"role": "user", "content": "What is the weather in Paris?"}],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}
}
}],
"tool_choice": "auto",
"max_tokens": 128
}'
To continue an agentic loop, include the previous assistant tool_calls and
the matching tool response messages in the next request.
Tool response follow-up:
{
"messages": [
{"role": "user", "content": "What is the weather in Paris?"},
{
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Paris\"}"
}
}]
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": "{\"temperature\":22,\"unit\":\"celsius\"}"
}
],
"tools": [{
"type": "function",
"function": {"name": "get_weather", "parameters": {"type": "object"}}
}]
}
Common Inputs#
LLM requires exactly one source:
Source |
Meaning |
|---|---|
|
HuggingFace model ID or local checkpoint; export, build, then load |
|
Existing ONNX directory; build then load |
|
Existing engine directory; load only |
Encoders are passed alongside: visual_onnx_dir / audio_onnx_dir with
onnx_dir, and multimodal_engine_dir (alias visual_engine_dir) with
engine_dir. Models that support audio: Qwen3-Omni, Qwen3-ASR, Nemotron-Omni.
Audio Input#
The server accepts three OpenAI-compatible content forms inside user messages for models that support audio (Qwen3-Omni, Qwen3-ASR, Nemotron-Omni):
{"type": "input_audio", "input_audio": {"data": "<base64>", "format": "wav"}}
{"type": "audio_url", "audio_url": {"url": "file:///abs/path | data:audio/...;base64,..."}}
{"type": "audio", "audio": "<local path>"}
http(s):// URLs are rejected by design — inline the bytes as base64 via
input_audio, or pass a local path with the server started as
--allowed-local-media-path <dir> (local paths and file:// are refused over
HTTP otherwise, and are confined to that directory when it is set). Supported
containers: .wav, .mp3, .flac. The server decodes the container
in-process via vendored miniaudio and the audio runner extracts the
mel-spectrogram in C++ (no HF transformers feature extractor or Python
preprocessing step is required). The model-appropriate feature extractor
is selected automatically from the engine’s audio/config.json::model_type:
whisper for Qwen3-Omni / Qwen3-ASR, parakeet for Nemotron-Omni.
Example (base64-inline):
B64=$(base64 -w0 sample.wav)
curl -X POST http://127.0.0.1:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d "{\"model\":\"local\",\"messages\":[{\"role\":\"user\",\"content\":[ \
{\"type\":\"input_audio\",\"input_audio\":{\"data\":\"$B64\",\"format\":\"wav\"}}, \
{\"type\":\"text\",\"text\":\"Transcribe.\"}]}],\"max_tokens\":128}"
Audio Output (Qwen3-Omni)#
For Qwen3-Omni models the server can stream synthesized speech alongside the text response, following the OpenAI chat-completions audio schema. It requires the Omni audio-output engines (Talker, CodePredictor, Code2Wav) placed as siblings of the Thinker engine directory — they are auto-detected at startup:
{engine_root}/
thinker/ # engine_dir passed to LLM (llm.engine)
talker/ # llm.engine
code_predictor/ # llm.engine
code2wav/ # code2wav.engine
The dirs can also be passed explicitly via LLM(talker_engine_dir=..., code_predictor_engine_dir=..., code2wav_engine_dir=...).
Build requirement: the Python bindings must be built with the CuTe DSL GEMM enabled, or the Talker MLP fails and no audio is produced. Generate the AOT artifact first, then build with
ENABLE_CUTE_DSL=gemm:python kernelSrcs/build_cutedsl.py --kernels gemm --gpu_arch <sm> TRT_PACKAGE_DIR=... ENABLE_CUTE_DSL=gemm \ python experimental/server/setup_pybind.py build_ext --inplace
Request audio by adding modalities (and optionally an audio object):
curl -N http://127.0.0.1:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "local", "stream": true,
"modalities": ["text", "audio"],
"audio": {"format": "pcm16", "voice": ""},
"messages": [{"role": "user", "content": "Introduce yourself."}]}'
Streaming responses interleave text and audio deltas in one SSE stream. Audio arrives as base64 int16 mono PCM at 24 kHz:
{"choices": [{"delta": {"content": "Hello"}, "index": 0}]}
{"choices": [{"delta": {"audio": {"id": "audio-...", "data": "<base64 pcm16>",
"format": "pcm16", "sample_rate": 24000}}, "index": 0}]}
Generation is truly streaming: the Thinker and Talker run interleaved, so the
first audio chunk is emitted after roughly talker_prefill_threshold text
tokens rather than after the full text completes. With stream: false the
server aggregates the chunks and returns one message.audio.data blob plus
message.audio.transcript.
Talker knobs live inside the audio object (they are namespaced there to
avoid colliding with text sampling fields):
Field |
Default |
Description |
|---|---|---|
|
|
Speaker name; empty selects the model default |
|
|
Output encoding; only |
|
|
Vocode every N codec frames (1 frame ≈ 80 ms of audio). Smaller values lower chunk latency at the cost of more Code2Wav invocations |
|
|
Thinker tokens accumulated before Talker prefill starts |
|
|
Talker sampling temperature (must be > 0; greedy Talker sampling never emits EOS) |
|
|
Talker top-K |
|
|
Talker top-P |
|
|
Talker codec repetition penalty |
|
|
Maximum codec frames per response |
tools and logprobs are rejected (400) in combination with audio output.
Text-to-Speech (/v1/audio/speech)#
Any server with the audio-output engines loaded also exposes an OpenAI-style
TTS endpoint. Unlike chat with modalities: ["audio"], the input text goes
straight to the Talker — no Thinker generation pass:
curl -X POST http://127.0.0.1:8000/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"model": "local", "input": "今天天气真不错。", "voice": ""}' \
--output speech.pcm
response_format: "pcm" (default) streams raw int16 mono 24 kHz PCM as
chunks are vocoded (headers carry X-Sample-Rate / X-Channels /
X-Sample-Format); "wav" aggregates and returns a complete WAV file. The
Talker knobs from the table above sit at the top level of the body
(talker_temperature, codec_chunk_frames, …). input is capped at 4096
characters.
For Qwen3-TTS-style engine sets (Talker + CodePredictor + Code2Wav, no text model), serve TTS-only — chat endpoints then return 400:
from experimental.server import TTS
tts = TTS(talker_engine_dir="/engines/qwen3-tts/talker")
tts.serve(port=8000)
code_predictor_engine_dir / code2wav_engine_dir default to the talker
directory’s siblings; tokenizer_dir defaults to the talker directory itself
(Qwen3-TTS exports carry the tokenizer and text_embedding.safetensors
there). On a Qwen3-Omni server, pass the thinker engine dir as
tokenizer_dir — the text embedding lives there.
Transcription endpoint (/v1/audio/transcriptions)#
For ASR models the OpenAI transcription route accepts a multipart audio
upload and returns {"text": ...}. Clip duration is bounded by the engine
audio profile (~30 s, matching vLLM); errors are staged 400 (bad request)
/ 413 (too long) / 503 (busy) / 500.
curl -X POST http://127.0.0.1:8000/v1/audio/transcriptions \
-F model=local -F file=@sample.wav
Video Input#
Video content accepts a source URL or pre-extracted frames:
{"type": "video_url", "video_url": {"url": "file:///abs/path | data:video/...;base64,..."}}
{"type": "video", "video": "<local path>"}
{"type": "video", "frames": ["<frame path>", "..."], "fps": 1.0}
Sampling is controlled per request with fps, nframes, min_frames and
max_frames. Local paths follow the same --allowed-local-media-path rule as
audio; http(s):// is rejected.
Nemotron-Omni video has extra constraints: a request may carry at most one
video and no images alongside it, and it always runs as a batch of one (video
requests are never micro-batched). do_resize: false is rejected because the
runner always resizes frames to the target patch grid. Frames are resized with
UINT8 bicubic interpolation, a close but not bit-exact match to the HF FP32
antialiased resize.
Build requirement: the Nemotron-Omni patch embedder runs a CuTe DSL FP16 GEMM in the runtime for both image and video, so serving any Nemotron-Omni visual input needs the same CuTe DSL GEMM build as the Talker MLP above — a default build compiles but the visual runner fails to load without it.
Sampling Parameters#
Parameter |
Default |
Description |
|---|---|---|
|
|
Sampling temperature |
|
|
Nucleus sampling threshold |
|
|
Top-K sampling |
|
|
Sparse OpenAI-compatible map from token ID to bias value; incompatible with active speculative decoding |
|
|
Maximum generated tokens |
|
|
Enables Qwen-style thinking output |
|
|
Disables EAGLE for one request |
Requests with a non-empty logit_bias map are rejected while speculative decoding is active. Set
disable_spec_decode: true to explicitly use vanilla decoding for that request batch.
Server-Side Batching#
Non-streaming HTTP requests can be micro-batched before entering the runtime:
python -m experimental.server \
--model Qwen/Qwen3-1.7B \
--max-batch-size 16 \
--enable-batching \
--max-queue-batch-size 16 \
--batch-timeout-ms 10
Batching is off by default. When enabled, the server groups compatible
non-streaming requests for up to batch-timeout-ms milliseconds, then submits
one runtime batch. Requests are compatible when their runtime generation
settings match, including temperature, top_p, top_k, max_tokens,
enable_thinking, and chat-template settings. Streaming requests bypass the
batcher.
Request Admission and Backpressure#
The server bounds the number of concurrently admitted requests (queued plus
running) with --request-queue-size (default 32):
python -m experimental.server \
--model Qwen/Qwen3-1.7B \
--request-queue-size 32
When the queue is full, the server sheds load with a retryable backpressure
status rather than blocking — 503 on the OpenAI endpoints and
529 overloaded_error on the Anthropic endpoint, both with Retry-After: 1.
Non-streaming requests are admitted into the batcher; streaming requests take a
single runtime slot. Admission never blocks a server worker thread, so a burst
fails fast instead of starving in-flight streaming responses.
When batching is enabled, keep --request-queue-size below the server thread
pool (default 40); a very large queue lets admitted requests occupy every
worker and stall streaming responses.
Tool Calls#
The OpenAI-compatible server accepts tools, tool_choice,
assistant.tool_calls, and tool messages. Tool-aware requests are formatted
with the model’s Hugging Face chat template before they are sent to the runtime.
tool_choice supports auto, none, required, and forced function choices.
Malformed tools, unknown forced tools, and dangling tool_call_id values return
a 400 response.
When the model returns a supported tool-call format, non-streaming responses
include message.tool_calls and finish_reason: "tool_calls". Streaming
responses include delta.tool_calls chunks.
Anthropic Messages API#
The server also exposes a native Anthropic Messages API, so agents that speak
the Anthropic protocol (for example, Claude Code) can target the server
directly by pointing ANTHROPIC_BASE_URL at it, with no translation proxy. It
is a thin adapter over the same runtime pipeline as /v1/chat/completions.
curl -sN http://localhost:8000/v1/messages \
-H "Content-Type: application/json" \
-d '{
"model": "local",
"max_tokens": 128,
"messages": [{"role": "user", "content": "Hello!"}]
}'
system prompts, text/tool_use/tool_result content blocks, tools,
tool_choice (auto, any, tool, none), and stop_sequences are
translated to their OpenAI-pipeline equivalents. Responses carry Anthropic
content blocks, stop_reason, and usage. Set "stream": true for the Anthropic
SSE event sequence (message_start, content_block_start/_delta/_stop,
message_delta, message_stop).
POST /v1/messages/count_tokens returns an input-token estimate for the given
messages and tools without running generation. It is implemented because
some clients probe it for context management.
Connecting Claude Code#
Point Claude Code at the server with ANTHROPIC_BASE_URL — set it to the host
only, as Claude Code appends /v1/messages itself. The server does not check
credentials, but the client still requires a non-empty token, so set a dummy
key. Claude Code also requests distinct model tiers internally, so map each
tier to the served model (its id is shown by GET /v1/models) to avoid
unknown-model errors:
ANTHROPIC_BASE_URL=http://localhost:8000 \
ANTHROPIC_API_KEY=dummy \
ANTHROPIC_AUTH_TOKEN=dummy \
ANTHROPIC_DEFAULT_OPUS_MODEL=local \
ANTHROPIC_DEFAULT_SONNET_MODEL=local \
ANTHROPIC_DEFAULT_HAIKU_MODEL=local \
claude
The same values can be placed in the env block of ~/.claude/settings.json.
Any other Anthropic-Messages-compatible client connects the same way: set its
Anthropic base URL (or provider configuration) to the server’s address.
The adapter targets text agentic workloads. Image and document blocks are
dropped, server-executed tools (for example, web_search) are not offered to
the model, and a matched custom stop sequence is reported as end_turn. On
overload the endpoint returns 529 (see
Request Admission and Backpressure).
Connecting OpenClaw#
OpenClaw speaks the OpenAI protocol, so it connects to /v1/chat/completions
rather than the Anthropic API. Register the server as a provider in
~/.openclaw/openclaw.json and route an agent to it:
{
"models": {
"mode": "merge",
"providers": {
"edgellm": {
"baseUrl": "http://127.0.0.1:8000/v1",
"apiKey": "dummy",
"api": "openai-completions",
"models": [
{
"id": "qwen3-8b-edgellm",
"name": "Qwen3-8B (Edge-LLM)",
"contextWindow": 32768,
"maxTokens": 4096,
"input": ["text"]
}
]
}
}
},
"agents": {"defaults": {"model": {"primary": "edgellm/qwen3-8b-edgellm"}}}
}
The baseUrl includes the /v1 suffix, and apiKey may be any non-empty
string. Serve an engine whose --max-input-len covers the agent’s system
prompt and tool schemas — OpenClaw’s context runs to tens of thousands of
tokens, so build a large-context engine rather than a small batching engine.
EAGLE#
from experimental.server import LLM, SamplingParams
llm = LLM(
engine_dir="/path/to/base/engine",
eagle_engine_dir="/path/to/eagle/engines",
draft_top_k=10,
draft_step=6,
verify_tree_size=60,
)
outputs = llm.generate(
["Explain quantum computing."],
SamplingParams(max_tokens=256),
)
Endpoints#
Method |
Path |
Description |
|---|---|---|
|
|
Health check |
|
|
List models |
|
|
Chat completions with optional SSE streaming |
|
|
Anthropic Messages API with optional SSE streaming |
|
|
Anthropic input-token estimate |
|
|
Text-to-speech (streamed PCM or WAV) |
|
|
Speaker names accepted as |
|
|
Legacy raw-prompt completion (no chat template); streaming or non-stream |
|
|
ASR transcription (audio upload); staged errors |
Notes#
Standard chat templates are applied in the C++ runtime. Tool-aware requests are formatted in Python with the model’s Hugging Face chat template.
Thinking output is returned in
reasoning; final answer text is returned incontent.Supported finish reasons are
stop,length,cancelled, anderror./v1/chat/completionsacceptsmax_completion_tokensas an alias formax_tokens; the modern field wins when both are present. The requested length is capped at131072(a larger value returns a 400).On a streaming request, set
stream_options.include_usage: trueto receive a final chunk carryingusage(emptychoices) just before[DONE].usage.prompt_tokensis the runtime’s templated, media-expanded prompt length when available; otherwise it falls back to the Hugging Face tokenizer estimate, which counts each media placeholder once.