Routing specific models to a different LLM endpoint
By default every LLM call Poindexter makes goes to one endpoint — theapi_base in the LiteLLM provider config (your Ollama instance). The
model_api_base_overrides map lets you route specific models somewhere
else, per model, without touching code:
api_base. An empty
or absent map is a no-op — if you run everything on one GPU against one
Ollama, you can ignore this page entirely; nothing about Poindexter
requires a second endpoint.
When you’d use it
- Pin an eviction-prone model to a second GPU. With multiple hot models sharing one Ollama, a big writer reload can evict a QA-rail model mid-pipeline; the cold reload then times out and the rail skips. Running a second Ollama instance pinned to another GPU (see recipe below) and routing just that model to it removes the eviction path. This is how the reference deployment runs its vision-QA model.
- Borrow VRAM from another machine. Point a model at any Ollama you
can reach — an old gaming PC on the LAN, a box over Tailscale:
{"ollama/qwen3-vl:30b": "http://192.168.1.50:11434"}. - Offload a small model to a CPU-only instance so it never competes for GPU memory at all.
Behavior details
-
Keys match the resolved model name. Bare names get the default
prefix first (
qwen3-vl:30b→ollama/qwen3-vl:30b), so key your map with the prefix and both spellings match. -
The value may be a dict or a JSON string (app_settings TEXT rows
arrive as strings). Invalid JSON or a wrong type is logged and
ignored — dispatch falls back to the default
api_base, never fails. -
The paid-endpoint policy applies to the effective endpoint. An
override pointing at a cloud host (e.g.
https://api.openai.com/v1) is refused unless the paid gate is open — an override can’t smuggle a paid endpoint past the local-only default. Open the gate by settingplugin.llm_provider.litellm.allow_paid_base_urltotrue(withpoindexter settings set); the dispatcher folds that flat row into the provider config, so you don’t hand-edit the nested JSON blob. - Both streaming and non-streaming calls honor the map. All LLM traffic flows through the LiteLLM provider, so the map covers every pipeline stage, QA rail, and job.
-
A routed model skips the shared GPU lock. Poindexter serializes local
LLM dispatches behind one GPU lock (
gpu_serialize_llm_dispatch) so a model load can’t collide with an image/video render. A model routed to a different endpoint is exempt: it lives on its own server (and, per the recipe below, its own card), the eviction path only ever targets the defaultapi_base, and renders are pinned topipeline_gpu_index— so the lock protects a routed model from nothing while forcing it to queue behind the primary instance untilgpu_lock_acquire_timeout_secondsfires. Because the vision rail fails soft on timeout, that showed up asqa.visionsilently “passing open” rather than as an error. Setgpu_pinned_endpoint_skips_lock=falseif your second instance shares one physical GPU with the first — two servers on one card genuinely do contend. -
A routed model runs at one context size. An instance that holds one
model (
OLLAMA_MAX_LOADED_MODELS=1,KEEP_ALIVE=-1) holds it at onenum_ctx. Ollama reloads a resident model whenever a request asks for a different one, and for a ~20 GB vision judge that is a 10-40 s cold load in the middle of a QA rail. So every dispatch routed through this map runs atpinned_llm_endpoint_num_ctx(default 16384). That overrides the caller’snum_ctxand every per-phase*_num_ctxkey, includingqa_ragas_judge_num_ctxandqa_deepeval_judge_num_ctx, which only size a judge that is not pinned. The first time an override changes a size, a warning names the phase.WarmPinnedLlmEndpointsJobwarms at the same size, and the brain’s Ollama RAM-recycle re-pins at it. Anything outside Poindexter that sends nonum_ctxgets the instance’sOLLAMA_CONTEXT_LENGTH, so set that to the same value in the wrapper (the recipe below does). If the warm job finds the model resident at any other size it raisespinned_endpoint_context_mismatchand leaves the model loaded, because re-warming would fight the other caller. Set the key to0to go back to per-phase sizes. Before this rule the reference judge logged 48 loads in one day (2026-09-24), alternating 16384 / 32768 / 8192. -
A routed model’s cold load skips the render-GPU reclaim, but only when its
card is declared. Before a local model of at least
coldload_reclaim_min_gb(default 8) cold-loads, the cold-load guard clears idle media sidecars (ComfyUI, image-gen, wan, chatterbox, stable-audio, RIFE) off the render GPU, so the load cannot run out of memory beside them. A model whose instance sits on another card gains nothing from that, so the guard stands down for it. Being routed is not enough, though: the placement has to be declared. Three settings together make that declaration:ollama_vision_base_urlmust name the instance, which gives it theqa_judgerole.gpu_lock_per_device_enabledmust betrue.gpu_lock_scopesmust putqa_judgeon cards that share nothing withrenderorpipeline_gpu_index.
[COLDLOAD_GUARD]line names both cards and ends inskipping it. Before 2026-09-25 it had no such rule, and every cold load of the reference judge ran the ladder in the middle of renders. -
Several images in one request go one per message, over
/api/chat. Ollama 0.32.1 serving qwen3-vl through llama-server drops every second image of a request whose images share one message (the 1st, 3rd, 5th arrive; the 2nd, 4th never do) with HTTP 200 and no warning.LiteLLMProviderworks around it (route_multi_image_for_ollama): a request with more than one image to an Ollama model gets one image per message, and anollama/model reaches LiteLLM asollama_chat/. The route changes and the endpoint does not: the pin above is still looked up under the name the caller used, so the judge stays on its own instance. Do not switch a model’s prefix toollama_chat/in a*_modelsetting to get this behavior. The map is keyed on the exact resolved name, soollama_chat/qwen3-vl:...would miss the pin and load the judge onto the default endpoint’s GPU. Requests with one image or none, and every non-Ollama model, are untouched. How many images a call may carry is a context budget: each 1280×1024 image costs the judge about 1,282 of itspinned_llm_endpoint_num_ctxtokens, and a request past the context is rejected with HTTP 400exceed_context_size_error(never truncated). The measurements are in preview-links.md.
Recipe: a second Ollama pinned to a specific GPU
Ollama has no per-model GPU affinity — one instance schedules across every GPU it can see. Hard placement means a second instance that can only see the target GPU. On Linux the reference implementation isscripts/linux/ollama-vision.sh, run as the ollama-vision.service
systemd unit. On Windows it was scripts/ollama-vision-gpu1.ps1,
registered as a scheduled task by scripts/background-services.ps1:
:11435.
WarmPinnedLlmEndpointsJob loads it within five minutes. To warm it by hand,
send options.num_ctx equal to pinned_llm_endpoint_num_ctx, or the first
real call reloads it at that size. With OLLAMA_KEEP_ALIVE=-1 it stays
resident; the primary instance cannot evict it because it’s a separate server
process.
Gotchas (each of these cost us a debugging round)
OLLAMA_VULKAN=falseis load-bearing. Ollama 0.31+ enables a Vulkan backend by default whose GPU enumeration ignoresCUDA_VISIBLE_DEVICES. Without this, the scheduler still sees your other GPU asVulkan0and will happily place the model there — usually on whichever card has more free VRAM, which is exactly the card you were trying to avoid.- Pin by GPU UUID, not bare index. The UUID form
(
GPU-xxxxxxxx-...) confines reliably and survives enumeration-order changes across driver updates. - Scheduled tasks / services don’t inherit your user environment.
If your models directory or tuning flags live in user-level env vars
(
OLLAMA_MODELS,OLLAMA_FLASH_ATTENTION, …), the wrapper script must read them from the registry explicitly or the instance falls back to defaults and reports every model as missing. - Loopback bind is enough for Docker. Docker Desktop proxies
host.docker.internalto the host loopback, so127.0.0.1:11435is reachable from the worker container with no firewall exposure. - Verify placement with the serve log, not
nvidia-smideltas. The instance’sinference compute/selecting single GPUlog lines are ground truth for which device a model landed on; WDDM memory readouts lag and mislead, especially with other GPU consumers active. - A logon-only trigger doesn’t come back from a clean exit.
background-services.ps1registers this (and every background service) with both anAtLogOntrigger and a repeating 5-min trigger (poindexter#860) specifically because Windows’ ownRestartCount/RestartIntervalonly fires on a nonzero exit code — ifollama.exe serveexits 0 (observed during a host crash/reboot sequence), the task just goes quiet and, on a box that stays logged in for days, doesn’t come back until the next interactive logon. That left the vision instance dead for 24h+ and starved theqa.visionQA rail (advisory-only, so it didn’t block publishing, but it also wasn’t running — see GlitchTip issue #877). If you ever see connection refusals to:11435with sub-10ms latency (an instant refusal, not a slow/contended timeout), checktasklist | grep ollamafor a missing second process before assuming GPU contention. - Every context-size change is a full reload. A pinned judge that
answers slowly with a tiny decode time is usually reloading, not slow:
look for a huge
cost_logs.duration_msbeside a smalldecode_duration_ms. The serve log records every load with its size. Count them by size (Linux, systemd unit):journalctl -u ollama-vision.service --since today | grep -o 'starting llama-server.* -c [0-9]*' | grep -o ' -c [0-9]*$' | sort | uniq -c. A healthy instance shows one size, loaded once per restart or RAM recycle. Two sizes mean a caller is bypassing the dispatcher. Map the loads to callers with the[GIN]lines that complete right after eachstarting llama-serverline: their client IP names the container, and::1is a process on the host.
Verifying a routed model end-to-end
curl <override-base>/api/versionfrom inside the worker container (docker exec poindexter-worker curl http://host.docker.internal:11435/api/version).- Dispatch the routed model through the pipeline (or wait for its next
natural call) and confirm the default instance never loads it
(
ollama psagainst the default base stays clean). - If you pinned it to a GPU, watch that GPU’s utilization spike during the call.
- Confirm it stays at one context:
curl <override-base>/api/psshowscontext_lengthequal topinned_llm_endpoint_num_ctx, and the serve log’s load count (gotcha 7) does not grow between recycles. - If you declared its card (see the cold-load bullet above), unload it with
curl <override-base>/api/generate -d '{"model":"<name>","keep_alive":0}'and dispatch it again. The calling process’s log (worker or prefect-worker) should show the guard’sskipping itline with no[GPU] … unloadedlines after it, and there should be no newservice_restart_requestsrows. The media sidecars’ own logs should show noPOST /unloadin that window. A big cold model on the default instance still runs the ladder, and its[COLDLOAD_GUARD]line says why (loads on GPU 0, overlapping the render GPU 0).