Local models — vision and inference
You can run Prax fully off-OpenAI by pointing both the vision tool ( ) and the chat-completion path at any OpenAI-compatible local server: 's , vLLM, Ollama ( ), or LM Studio. One endpoint can serve both — it's the same HTTP API.
f62d7985
View source ↗
You can run Prax fully off-OpenAI by pointing both the vision tool
(analyze_image) and the chat-completion path at any OpenAI-compatible
local server: llama.cpp’s llama-server, vLLM, Ollama (/v1), or LM
Studio. One endpoint can serve both — it’s the same HTTP API.
How it works
| Component | What flips when you point it at a local server |
|---|---|
analyze_image (prax/agent/vision_tools.py) |
When VISION_BASE_URL is set, the OpenAI client uses that base URL and images get inlined as data:image/...;base64,... URIs. Most local servers run without outbound network access and can’t reach the Discord/Twilio CDN themselves. |
Chat / orchestrator / spokes (prax/agent/llm_factory.py) |
The existing vllm provider points ChatOpenAI at VLLM_BASE_URL. Despite the name, it works against any OpenAI-compatible server — llama.cpp, Ollama, vLLM all match. |
VISION_API_KEY is optional. Most local servers ignore the key; Prax
passes a placeholder when none is configured.
models_used in every trace node now records the provider and model that
served each LLM and vision call. When a call goes to a local endpoint, the
provider field includes the URL — e.g. openai@http://127.0.0.1:8083/v1 —
so reading a trace tells you immediately which calls ran on-prem and which
went to a hosted API.
llama.cpp llama-server
llama.cpp supports vision via the multimodal projector (mmproj). You
need two GGUFs: the language model and the matching mmproj-*.gguf.
Build a CUDA llama-server (skip if your binary already supports CUDA)
The default Unsloth llama-server binary is CPU-only. To use the GPU you
need a CUDA build. On Ada / Ampere with nvcc already installed:
cd ~/.unsloth/llama.cpp # or wherever your llama.cpp source lives
cmake -B build-cuda \
-DGGML_CUDA=ON \
-DCMAKE_CUDA_ARCHITECTURES=89 \ # 89 = Ada (RTX 4060/4080/2000-Ada). Use 86 for Ampere, 90 for Hopper.
-DCMAKE_BUILD_TYPE=Release \
-DLLAMA_CURL=OFF
cmake --build build-cuda --config Release -j16 --target llama-server
Resulting binary: build-cuda/bin/llama-server. Build takes 3–5 min on a
modern desktop CPU. Verify CUDA is in:
build-cuda/bin/llama-server --version
# => ggml_cuda_init: found 1 CUDA devices ...
A CPU-only binary will print warning: no usable GPU found on startup and
ignore -ngl.
Pick a model that fits your VRAM
Sized for a 16 GB GPU (e.g. RTX 2000 / 4060 / 4080 Ada). Numbers are rough on-disk sizes — the active VRAM footprint is usually smaller for MoE models.
| Model | VRAM target | On disk | Vision-capable? | Notes |
|---|---|---|---|---|
| Gemma 3 4B | ~6 GB | ~3 GB | yes | Fastest path; pairs with mmproj-model-f16.gguf from ggml-org/gemma-3-4b-it-GGUF. |
| Qwen2.5-VL 7B-Instruct | ~10 GB | ~5 GB | yes | Strongest small VL model. Reliable Q4_K_M GGUFs from unsloth / bartowski. |
| Qwen3.6-35B-A3B (MoE) | ~9 GB w/ CPU offload | ~22 GB | yes | Sparse MoE: only ~3 B params active per token. -ncmoe offloads routed-expert layers to CPU. Strong at chat + tool calls; OCR at Q4 is rough. |
Get GGUFs from Hugging Face. Always pull the matching mmproj-*.gguf
from the same repo — vision projectors are not interchangeable.
mkdir -p /data/models/qwen35-vision && cd /data/models/qwen35-vision
hf download unsloth/Qwen3.6-35B-A3B-GGUF \
Qwen3.6-35B-A3B-UD-Q4_K_M.gguf \
mmproj-F16.gguf \
--local-dir .
Start one server that serves both vision and chat
The same llama-server instance handles both modes — --mmproj enables
multimodal, and /v1/chat/completions already supports tool calls when
--jinja is on. Two clients pointing at the same port is fine.
Qwen3.6-35B-A3B on 16 GB VRAM (verified on RTX 2000 Ada):
./build-cuda/bin/llama-server \
-m /data/models/qwen35-vision/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf \
--mmproj /data/models/qwen35-vision/mmproj-F16.gguf \
--alias qwen35-local \
--host 127.0.0.1 --port 8083 \
-ngl 70 -ncmoe 30 -fa on \
--cache-type-k q8_0 --cache-type-v q8_0 \
-c 32768 -t 12 --jinja --metrics
What each flag does:
-ngl 70— offload 70 layers to GPU (the model has ~40 — anything ≥ all layers means “everything that fits”).-ncmoe 30— keep the routed-expert tensors of 30 layers on CPU. Tune this: raise on CUDA OOM, lower for more throughput if you have headroom. At-ncmoe 30on a 16 GB Ada we measured ~9 GB VRAM used, with ~6 GB free.-fa on— flash attention.--cache-type-k q8_0 --cache-type-v q8_0— quantize the KV cache, halves the cache cost. Worth it for any context ≥ 16k; quality hit is small.-c 32768— context length. Upstream cards say 131072 (128 k) is supported, but most real workloads fit in 16–32 k and shorter contexts start fast. Bump only when you genuinely send long transcripts.-t 12— CPU threads. Set to physical core count.--jinja— enables the model’s chat template (required for tool calls).--metrics— Prometheus stats at/metrics(optional).--host 127.0.0.1— bind localhost only. llama-server has no auth; do not expose it on the LAN unless you’ve put auth in front.
Smaller alternative (Qwen2.5-VL-7B) — fully GPU-resident, faster:
./build-cuda/bin/llama-server \
-m /data/models/qwen2.5-vl-7b/Qwen2.5-VL-7B-Instruct-Q4_K_M.gguf \
--mmproj /data/models/qwen2.5-vl-7b/mmproj-Qwen2.5-VL-7B-Instruct-f16.gguf \
--alias qwen-vl \
--host 127.0.0.1 --port 8083 \
-ngl 99 -fa on -c 32768 --jinja
Point Prax at it
In .env:
# Vision (analyze_image — Discord/SMS/TeamWork attachments)
VISION_PROVIDER=openai
VISION_MODEL=qwen35-local # match the --alias
VISION_BASE_URL=http://127.0.0.1:8083/v1
# VISION_API_KEY= # leave empty — llama-server ignores it
# Chat / orchestrator / spokes (replaces OpenAI for the agent loop)
LLM_PROVIDER=vllm # any OpenAI-compatible endpoint, despite the name
VLLM_BASE_URL=http://127.0.0.1:8083/v1
BASE_MODEL=qwen35-local # used by the default tier
LOW_MODEL=qwen35-local
MEDIUM_MODEL=qwen35-local
HIGH_MODEL=qwen35-local
Restart Prax. Every trace node will now show provider=openai@http://127.0.0.1:8083/v1
and model=qwen35-local in models_used. Vision attachments and chat
turns hit the same local server.
What we measured
On RTX 2000 Ada (16 GB) running Qwen3.6-35B-A3B-UD-Q4_K_M:
| Workload | Latency | VRAM used |
|---|---|---|
| Cold model load | ~3 s after first token, ~30 s total | 9 GB |
| Vision (one image, ~100 vision tokens, ~60 reply tokens) | ~3.4 s | — |
| Chat with one tool call (~200 prompt tokens, ~200 reply tokens) | ~8.4 s | — |
| Steady-state generation | ~30 tok/s | — |
Qwen3.6-35B-A3B is excellent at chat + tool calling at this quantization
but OCR fidelity at Q4 is mediocre (we measured HILO for HELLO). For
OCR-heavy use cases, run a dedicated VL model (Qwen2.5-VL-7B at Q5 or
higher) on a second alias and point only VISION_MODEL at it.
OCR fidelity comparison (Q4_K_M, same hardware)
Same OCR probe (single line of bold black text on a white background) against two locally-served models on the RTX 2000 Ada:
| Model | Size | Probe text | Output | Verdict |
|---|---|---|---|---|
| Gemma 3 4B | ~3 GB | ANALYZE_IMAGE WORKS |
ANALYZE_IMAGE WORK |
18/19 chars — dropped trailing S |
| Qwen3.6-35B-A3B | ~22 GB | QWEN VISION OK |
EVERYBODY |
unrelated — failed |
| Qwen3.6-35B-A3B | ~22 GB | HELLO |
HILO |
3/5 chars |
Gemma 3 4B beat Qwen3.6-35B-A3B at OCR despite being ~7× smaller. Why:
Gemma 3 was designed multimodal from scratch, while Qwen3.6-35B-A3B is a
chat-focused MoE (3 B activated params per token) with vision bolted on.
For text inside images, prefer a model whose card lists vision as a
primary capability (Gemma 3, Qwen2.5-VL, Pixtral, MiniCPM-V) over a
larger general-purpose model with image-text-to-text tagged on.
Hybrid routing — local for cheap, OpenAI for hard
You don’t have to commit to fully local. prax/plugins/configs/llm_routing.yaml
lets every component pick its own provider / model / tier /
temperature independently. A common cost-saving pattern:
- The orchestrator runs on every turn and on every scheduled briefing — put it on the local model.
- Sub-agents that need synthesis quality (note writing, content review, research) keep their hosted-API tier.
- Cheap classifiers (
note_quality_reviewer) go local.
Keep the global env vars on OpenAI as the default, then carve out the
specific components you want local in llm_routing.yaml:
# .env stays on hosted defaults
# LLM_PROVIDER=openai
# BASE_MODEL=gpt-5.4-nano
# (no VLLM_BASE_URL — it's only consulted by components that ask for vllm)
# llm_routing.yaml — per-component overrides
components:
orchestrator: # cheap, hot path → local
provider: vllm
model: qwen35-local
temperature: 0.7
note_quality_reviewer: # cheap binary classifier → local
provider: vllm
model: qwen35-local
temperature: 0.2
subagent_knowledge: # note synthesis → keep hosted
tier: high
temperature: 0.5
subagent_research: # research → keep hosted
tier: high
temperature: 0.3
subagent_professor: # consensus across providers → keep hosted
tier: pro
temperature: 0.3
For this to work, VLLM_BASE_URL (the global pointer at your local server)
still needs to be set in .env even though LLM_PROVIDER stays as
openai — the per-component provider: vllm overrides reach for that URL
when they fire:
LLM_PROVIDER=openai
VLLM_BASE_URL=http://127.0.0.1:8083/v1
For vision, the same logic applies — keep VISION_PROVIDER=openai (real
OpenAI) and unset VISION_BASE_URL, OR point vision at a local OCR-strong
model on a different alias / port:
VISION_PROVIDER=openai
VISION_MODEL=gemma3-vision
VISION_BASE_URL=http://127.0.0.1:8084/v1 # second llama-server, OCR-tuned
models_used in the trace makes the routing visible per call — you can
audit a real session and check that the expensive components went where
you intended.
vLLM
vLLM is OpenAI-compatible out of the box. One server, one config:
vllm serve Qwen/Qwen2.5-VL-7B-Instruct \
--host 127.0.0.1 --port 8000 \
--max-model-len 32768
VISION_BASE_URL=http://127.0.0.1:8000/v1
VISION_MODEL=Qwen/Qwen2.5-VL-7B-Instruct
LLM_PROVIDER=vllm
VLLM_BASE_URL=http://127.0.0.1:8000/v1
BASE_MODEL=Qwen/Qwen2.5-VL-7B-Instruct
Co-hosting two models on one GPU
To serve a heavy model and a fast model from one box (the local analog of
Prax’s low/medium/high tiers), run two vLLM backends behind one OpenAI-compatible
proxy (e.g. LiteLLM) and point VLLM_BASE_URL at the proxy, routing components via
llm_routing.yaml. Two gotchas from
../research/two-qwen3-on-one-spark.md:
gpu_memory_utilizationis a fraction of TOTAL VRAM, not free VRAM. Size the models so their fractions sum to < ~0.95 (leave ~5 GiB CUDA overhead).- Load the big model first, then
nvidia-smi --query-gpu=memory.used --format=csv, then size the small one against (free − ~5 GiB) — measured residency ≠ targets. Use FP8 to fit large models.
Add --enable-auto-tool-choice --tool-call-parser hermes so the local model can
call tools (Prax needs this).
Ollama
ollama serve
ollama pull qwen2.5vl:7b
VISION_BASE_URL=http://127.0.0.1:11434/v1
VISION_MODEL=qwen2.5vl:7b
LLM_PROVIDER=vllm
VLLM_BASE_URL=http://127.0.0.1:11434/v1
BASE_MODEL=qwen2.5vl:7b
Cloud GPU (no local GPU? launch one on demand)
A cloud GPU running vLLM is just a remote VLLM_BASE_URL — once it’s up,
nothing on the inference path changes. The full guide — top providers, how to
launch / serve / power off, and a least-privilege “on/off only” capability
(so Prax can never do more than flip the GPU) — is in
cloud-gpu.md. The plug-and-play flow: GPU present locally → use
it; absent → power a scoped cloud box on, point VLLM_BASE_URL at it, power it off
when done. No model is hard-wired; Prax serves what it needs on demand and saves
recurring recipes as workspace plugins.
Fine-tuning path
The same GPU unlocks fine-tuning: Prax has a harvest → LoRA-train → verify →
promote loop (finetune_service.run_self_improvement_cycle, vLLM adapter hot-swap
via /v1/load_lora_adapter, scripts/finetune_train.py — Unsloth QLoRA in a
separate CUDA venv), gated on FINETUNE_ENABLED. A QLoRA of an 8B fits ~24 GB; a
real run on an A100-80GB is 30–80 GPU-hours ($40–105). See
cloud-gpu.md for how to rent that A100 and turn it off and
../research/diy-document-extraction-model.md
for an end-to-end build that exercises this path.
Verifying it works
The fastest end-to-end check, after restart:
- Send a small image to the Discord bot or SMS line. In the resulting
trace, look for one
analyze_imagetool call withmodels_usedpointing at your local URL.run_python/delegate_desktopshould not appear. - Send a plain text question. Every span’s
models_usedshould show the local provider/model — nothing pointing atapi.openai.com.
If analyze_image doesn’t register, double-check build_vision_tools()’s
gating: it requires either VISION_BASE_URL, VISION_API_KEY, or
OPENAI_KEY to be non-empty.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Connection refused on every call |
llama-server isn’t running, or the port in *_BASE_URL is wrong. |
warning: no usable GPU found at server startup |
CPU-only llama-server binary. Build the CUDA target as shown above. |
| 500 with “tokenizer doesn’t support multimodal” | Started without --mmproj, or the projector doesn’t match the base model. |
| Garbled / unrelated OCR output but shapes/colors are correct | Q4 OCR quality is rough on a 35B-A3B. Try Q5/Q6 or a 7 B VL model. |
model … not found from the OpenAI client |
VISION_MODEL / BASE_MODEL doesn’t match the --alias (or Ollama tag, or vLLM model id). |
| OOM at startup | Raise -ncmoe. If still OOM, drop quant or model size. |
| Image fetch errors in the Prax log | The remote URL has expired (Discord CDN URLs expire after ~24 h). Ask the user to re-upload. |