The Decision Model
Some DocsGPT features need a quick, bounded judgement rather than generated text: is this message a prompt injection, does this chunk answer the question, did this conversation end with the user’s goal met. They ask the decision model, arc53/arc-decide-0.4b-v0.2, a small classifier that reads a state (a message, a chunk, a conversation) and answers typed questions with calibrated probabilities. It is not a chat model and never writes text, and it is unrelated to the decision journal of guardrails.
It runs on CPU with ONNX Runtime, which DocsGPT already ships for embeddings, so it adds no dependency; it can also run on a Mac’s GPU or, as arc53/arc-decide-0.6b-v0.1, on an NVIDIA GPU with vLLM. It is MIT-licensed, built on Qwen3 (Apache-2.0).
Everything ships off
Every feature that uses the decision model is off by default, and nothing is downloaded or loaded while they all are. Each feature has a mode setting:
| Mode | What the feature does |
|---|---|
off (default) | Nothing; DocsGPT behaves as it does without the model. |
monitor | Asks the model and records the answer without acting on it. |
on | Asks the model and acts on the answer. |
auto | Runs only if its predicted time on this machine fits its budget; see Measured auto. |
The first feature you set to anything but off downloads the model (about 0.5 GB) into DECISION_CACHE_DIR, then loads it in the background. Until it is ready, features carry on without it. The features and their settings are in Turning features on.
Two settings apply to the model as a whole:
DECISION_MODE=none # turn it off everywhere, whatever the feature settings say
DECISION_PRELOAD=true # download, load and time it when the API starts, even with every feature offWhere it runs:
| Setup | Where the model runs |
|---|---|
Every install by default: Docker (docsgpt up, every Compose file), Kubernetes, docsgpt up --native, pip, a checkout | Inside the API process; Celery workers call it there and never load it. |
| Several API replicas or workers, or the model’s memory kept apart from the API’s | The decider, a container of its own that the API and the worker call. It is off until you turn it on. |
| A Mac, fast | A native decide-server on the Mac’s GPU: see On a Mac’s GPU. |
| An NVIDIA GPU | vLLM: see On an NVIDIA GPU. |
If a call cannot be answered (the model is off, loading, busy or unreachable), the feature falls back to what it does without it, so a missing model never fails a request.
Celery workers
A Celery worker never loads the model: it sends its calls to the API process’s model. It reaches the API where it already hands over finished indexes, WORKER_API_URL or else API_URL (http://backend:7091 in Compose, http://docsgpt-api-service in Kubernetes), at /api/internal/decisions/v1/decide (and /v1/decide/batch, /v1/models, /v1/load), with INTERNAL_KEY. The worker keeps only the 11 MB tokenizer, and its first call starts the API’s load as the API’s own first call would. A worker without INTERNAL_KEY gets no answers. With DECISION_MODE=none or api, the API answers these routes with 503 and loads nothing; in API mode (the decider included) the worker calls the decision server itself.
Turning features on
Each feature has its own switch, so you can turn them on one at a time:
| To turn on | How | Default |
|---|---|---|
| The injection check on user input, for every agent (flags only) | GUARDRAILS_INJECTION_INPUT_MODE=monitor in .env; on also blocks on agents that enforce their guardrails, and auto flags only while the check fits its 0.5 s budget on this machine | off |
| The injection check on one agent | an injection_model control at the agent’s input stage | none |
Both are described in Agent Guardrails.
Offline and air-gapped installs
The files are fetched from Hugging Face at a pinned revision, as real files under DECISION_CACHE_DIR (models/decision in the data home). To fetch them ahead of time:
docsgpt decisions prefetchIn a Docker stack, run it in the backend, which keeps the files in the decision_models volume it shares with the worker and the decider: docker compose exec backend python -m docsgpt decisions prefetch from the stack’s folder. In Kubernetes the API keeps the model in an emptyDir and downloads it again when its pod is recreated; for a cluster without internet access, mount a copy and set DECISION_MODEL_PATH, or use the decider, whose volume claim keeps it. For a machine with no internet access, run it on a connected machine and copy the directory, or point DECISION_MODEL_PATH at a copy of the model: nothing is downloaded then. --runtime torch fetches the weights a Mac’s GPU runs instead. HF_HUB_OFFLINE=1 is honoured. A missing file or a failed download leaves the model unavailable, logs the fix, and is retried after DECISION_LOAD_RETRY_SECONDS. See Air-Gapped Deployment.
Running in the API process or the stack’s decider, the model sends no text anywhere: the text goes no further than the stack’s own network, and the only outbound call is that one-time download. API mode sends text to the server you point it at.
The decider
By default the model runs inside the API process. The decider runs it in a container of its own instead: the same arc53/docsgpt image running docsgpt decide-server, which the API and the worker call. Every Compose file and the Kubernetes manifests include it, turned off. Turn it on when:
- You run several API replicas or many workers. Each API process loads its own copy of the model (up to 1 GB of memory each), and every worker’s decisions run in an API process, beside its requests; one decider serves them all from one copy.
- You want the model’s memory kept apart from the API’s. The API process then stays at its usual size, and a decider that runs out of memory or is killed takes no requests down with it.
- The API process recycles. The image runs
gunicorn -w 1 --max-requests 5000, so gunicorn replaces the API process after about 5,000 requests. The model inside it loads again each time, and decisions fail open for those seconds. The decider is not recycled.
To turn it on:
-
Compose. Add three lines to
.env, then run yourdocker compose ... up -dagain:COMPOSE_PROFILES=decider DECISION_BASE_URL=http://decider:7095 DECISION_KEY=<a long random string, e.g. from openssl rand -hex 32>The first starts the container, the second points the backend and the worker at it, and the third is the key they send it; the decider refuses to start without one and logs why. A stack that already uses the
httpsprofile lists both:COMPOSE_PROFILES=https,decider. To turn it off, remove the first two lines, runup -dagain and remove the container with the same--env-fileand-foptions you start the stack with, for exampledocker compose --env-file .env -f deployment/docker-compose-hub.yaml --profile decider rm --stop --force deciderfrom a checkout. -
docsgpt up --deciderwrites the first two lines to the stack’s.env, and a randomDECISION_KEYwhen it has none; every laterdocsgpt upkeeps them.docsgpt up --no-deciderremoves the first two and the container, and keeps the key. -
Kubernetes. Uncomment
components: - optional-deciderindeployment/k8s/kustomization.yamland apply it again withkubectl apply -k deployment/k8s/. The component adds thedocsgpt-deciderDeployment, its Service and thedocsgpt-decision-modelsvolume claim, and setsDECISION_BASE_URLon the API and the worker; see Kubernetes.
The decider idles at about 100 MB and loads nothing until a feature calls. The API and the worker call it at DECISION_BASE_URL (http://decider:7095 in Compose, http://docsgpt-decider-service:7095 in Kubernetes) with DECISION_KEY: in Compose the one .env sets, in Kubernetes INTERNAL_KEY, which the component sets for all three and which stays inside the cluster. The first feature turned on downloads the model into the decider’s volume (decision_models in Compose, the docsgpt-decision-models claim in Kubernetes), where it survives upgrades; the API and the worker then keep only the 11 MB tokenizer.
- Memory and CPU. Compose limits it to 2 GB (
DECISION_MEMORYin.env), Kubernetes to 2Gi and 4 CPUs; the model peaks under 1 GB. Compose sets no CPU limit: interactive calls are short, and background work takes at mostDECISION_BACKGROUND_SHAREof the cores. - Health. The API never waits for it. A decider that is down, restarting or unreachable makes features carry on without the model (see the failure handling under API mode).
docsgpt upand Kubernetes restart it; the checkout Compose files restart none of their services, so there it stays down until the nextdocker compose up. Its ownGET /healthanswers while the process runs, loaded or not. - Routes.
POST /v1/decide,POST /v1/decide/batch,GET /v1/models(the model, its revision and calibration, its runtime, whether it is loaded and how fast it measured),POST /v1/loadandGET /health. A request’sX-Decision-Priority(hotorbackground) picks its lane, and states not started withinX-Decision-Deadline-Mscome back as{"error": "timeout"}. Its logs hold counts and timings, never text.
docsgpt decide-server runs the same server anywhere:
docsgpt decide-server # 127.0.0.1:7095, ONNX on the CPU
docsgpt decide-server --host 0.0.0.0 # every interface: refuses to start without DECISION_KEYA backend run on the host against deployment/docker-compose-dev.yaml can use one too: start it with --profile decider, and set DECISION_KEY and DECISION_BASE_URL=http://127.0.0.1:7095 in .env.
On a Mac’s GPU
Docker on a Mac runs Linux with no access to the GPU, so the decider there runs on the CPU, where an injection check takes about 2.2 s on an M1, too slow for most features. Natively, PyTorch on the Mac’s GPU takes about 0.14 s, with about 1.2 GB of GPU memory. Run the server on the Mac itself:
uv tool install "docsgpt[decisions-torch]" # adds PyTorch and transformers; no image ever has them
docsgpt decisions prefetch --runtime torch # the weights, 1.07 GiB
docsgpt env set DECISION_BASE_URL=http://host.docker.internal:7095 DECISION_KEY=$(openssl rand -hex 32)
docsgpt up # recreate the containers with the new address and key
docsgpt decide-server --device mps # 127.0.0.1:7095, in the foregroundThe containers reach the server through host.docker.internal. Installed this way, docsgpt uses the same data home as docsgpt up (~/.docsgpt/server), so the server reads the stack’s .env: its feature settings and the DECISION_KEY the containers send. For a stack in another folder, set DOCSGPT_HOME to it for these commands. For a native install (docsgpt up --native), set DECISION_BASE_URL=http://127.0.0.1:7095 instead. If the stack ran the decider, docsgpt up --no-decider removes it and keeps this address. Starting the server with the Mac (a launchd unit) is not set up for you yet: keep it running in a terminal, or start it again after a restart.
PyTorch never loads in the API or the worker: it and faiss, the default vector store, each bring an OpenMP runtime, and macOS aborts a process that loads both. DECISION_DEVICE=mps is read by decide-server only. docsgpt doctor says where the decision model runs and, on a Mac with a feature on and the model in Docker, suggests this setup.
On an NVIDIA GPU
deployment/optional/docker-compose.optional.decide-vllm.yaml adds stock vLLM (vllm/vllm-openai, pinned) serving arc53/arc-decide-0.6b-v0.1 at its pinned revision, and points the backend and the worker at it (DECISION_PROTOCOL=vllm):
docker compose --env-file .env -f deployment/docker-compose-hub.yaml \
-f deployment/optional/docker-compose.optional.decide-vllm.yaml up -dIt needs an amd64 host with the NVIDIA Container Toolkit. vLLM takes DECISION_VLLM_GPU_SHARE of the card’s memory (0.2 by default, about 4.8 GB of 24 GB), which leaves room for an LLM such as Ollama on the same card; if vLLM fails to start on a smaller card, raise it. Until vLLM answers, features carry on without the model. vLLM checks the same DECISION_KEY and logs no prompts; set it in .env, since without it every docker compose command that names the overlay stops with an error rather than start vLLM with no key. With docsgpt up, which runs its own Compose file only, run vLLM beside the stack as in API mode and set DECISION_BASE_URL and DECISION_PROTOCOL=vllm in its .env.
API mode
Instead of loading the model in the API process, DocsGPT can call a decision server. Set its address in .env; with DECISION_BASE_URL set, DECISION_MODE defaults to api, and both the API and the Celery worker call the server:
DECISION_BASE_URL=http://127.0.0.1:7095 # the server's root, without /v1
DECISION_KEY= # bearer key the server expects; unset sends noneTwo kinds of server work:
- A
/v1/decideserver (the defaultDECISION_PROTOCOL=decide):POST /v1/decide,POST /v1/decide/batchandGET /v1/models, asarcd serve, the model’s research server, answers them. The server renders, cuts and calibrates; DocsGPT checks every answer against the question it asked. A server that does not report which model it runs is taken to runarc53/arc-decide-0.6b-v0.1; setDECISION_MODELif it runs another. - Stock vLLM (
DECISION_PROTOCOL=vllm) on an NVIDIA GPU, servingarc53/arc-decide-0.6b-v0.1at its pinned revision. DocsGPT builds the prompt, cuts the state and applies the model’s calibration itself, and reads one label token per question:
vllm serve arc53/arc-decide-0.6b-v0.1 --revision a03cd6bc45fa7b75e05489454e2084bd3516db17 \
--logprobs-mode processed_logprobs --max-logprobs 16 --enable-prefix-caching --max-model-len 4096 \
--gpu-memory-utilization 0.2DECISION_BASE_URL=http://127.0.0.1:8000
DECISION_PROTOCOL=vllmThe two logprobs flags are required: without them the answers come back empty and every call fails open. --gpu-memory-utilization 0.2 (about 4.8 GB of a 24 GB card) leaves room for an LLM on the same card. To let interactive decisions overtake background ones, start vLLM with --scheduling-policy priority and set DECISION_VLLM_PRIORITY=true.
In API mode DocsGPT downloads only the model’s tokenizer (11 MB, at the same pinned revision) into DECISION_CACHE_DIR, so it counts tokens and reports truncation exactly as the server cuts a state. On an air-gapped host, docsgpt decisions prefetch fetches it with the rest of the model.
The server receives the text of every message, chunk or conversation a feature asks about. Point DECISION_BASE_URL only at a server you trust with that data, as you would an LLM provider: it acts as a data processor for your users’ content. DocsGPT sends DECISION_KEY as a bearer key when it is set, and no key otherwise; it never sends INTERNAL_KEY, which guards its own internal API, to the decision server.
The Compose files pass DECISION_KEY from .env like any other setting and never set it from INTERNAL_KEY, so a server outside the stack gets no key until you set one. In Kubernetes, only the in-cluster optional-decider component sets DECISION_KEY from INTERNAL_KEY, on the API, the worker and the decider; DocsGPT itself never falls back to it.
When the server fails, features carry on without it:
- Each request’s timeout is the time left before the feature’s deadline. A connection error or a 502, 503 or 504 is retried once (
DECISION_API_RETRIES), and only while 30% of that time remains; timeouts and other errors are not retried. - After 3 consecutive failures (
DECISION_BREAKER_FAILURES), calls are skipped for 30 seconds (DECISION_BREAKER_COOLDOWN); then one is let through, and its success resumes them. - A server that refuses the key (HTTP 401 or 403) is logged once, and calls stop until a later check of the server succeeds. Set
DECISION_KEYto the key the server expects. - A server that answers one bad item with an error for the whole batch, as
arcd servedoes, has that batch sent again one item at a time.
States are sent in batches of DECISION_BATCH_SIZE (32). Up to DECISION_API_CONCURRENCY (4) requests are in flight for decisions someone waits on, and DECISION_BACKGROUND_CONCURRENCY (1) for background work, which never takes an interactive slot. DocsGPT reads the server’s GET /v1/models before the first call and every DECISION_PROBE_SECONDS (30).
For Measured auto, a server that reports its own timing on /v1/models is predicted from it, plus the round trip of each request. Any other server, arcd serve and vLLM included, is timed by sending the same few constant calls through it, when it is first used and again whenever it comes back after failing.
Memory and speed on CPU
Measured on a 4 vCPU x86 server:
| Memory, loaded and idle | ~0.55 GB |
| Memory, steady state | under 1 GB |
| An injection check on a short message | ~0.15 s |
| An analytics turn (a 2,048-token conversation, 8 questions) | ~4 s |
Before it loads, DocsGPT checks that the process has room: below DECISION_MIN_FREE_MB (1,200 MB by default) the load is refused and features carry on without the model. The model uses the cores the process may use (its CPU affinity, container quota and physical cores); set DECISION_THREADS to fix the count. One pass runs at a time: work someone waits on goes first, and background work (analytics, scans) keeps the CPU busy for at most DECISION_BACKGROUND_SHARE (a quarter) of the time.
Measured auto
When the model loads, DocsGPT times a few fixed passes on this machine (a second or two on 4 vCPU) and predicts from them how long each feature’s calls take. A feature set to auto runs while its prediction fits its budget, and once on, turns off only above 1.25 times the budget, so it does not flap. Explicit off, monitor and on always win.
The predictions stay current. Real calls are compared with them, and when they drift outside 0.67 to 1.5 times the prediction the timing runs again (at most every 10 minutes). The CPU budget is re-read every minute, and a change rebuilds the model with the new thread count. If the timing leaves every auto feature off and no feature is set to on or monitor, the model is unloaded again and only its measurements are kept, so a machine too slow for any feature does not hold the memory for nothing.
What is recorded
Nothing a feature sends the model is stored. Every call leaves numbers only:
- On the answer. The assistant message’s metadata holds
decisions: per call, the feature, its status, how long it waited, whether it acted on the answer (appliedisfalseinmonitormode) and the numbers it decided on, such as the injection check’s probability and thresholds. A message keeps at most 64 entries and counts the rest indropped. A turn that pauses for a tool approval keeps its entries, and the resumed turn adds its own in the nextround. - In the trace. Each call is a
decisionstep in the request’s execution trace, with its states, answers, tokens and timings, and in the exported OpenTelemetry spans and metrics. - As daily counts. The
decision_usagetable counts calls, states, answers and time per day, feature, mode, model version and status. Each process writes its counts once a minute, and a daily task keepsDECISION_USAGE_RETENTION_DAYS(90). They are the denominators for amonitorrollout: how many messages the injection check read, next to the guardrail events it raised.
{"v": 1,
"model": {"id": "arc53/arc-decide-0.4b-v0.2", "version": "arc53/arc-decide-0.4b-v0.2/p78cfb276f93a/cc3bc72ff6907",
"runtime": "onnx-int8", "mode": "local"},
"entries": [{"feature": "injection_input", "round": 0, "status": "ok", "applied": false, "ms": 212.4,
"outcome": {"p": 0.03, "flag_threshold": 0.5, "threshold": 0.9, "windows": [1, 1]}}],
"dropped": 0}Decision calls never count toward users’ token limits: the model runs on your hardware and has no per-token price.
Checking it
docsgpt decisions status prints the settings, each feature’s mode, the pinned revision and whether the files are downloaded, without loading anything. In API mode it asks the decision server once, so the model, load status and speed it shows are the server’s ("server": "no answer" when it cannot reach it). docsgpt doctor adds a decisions line: where the model runs, what a decision server reports (its runtime, model version and load status), whether the files are here and the memory a load would have, and each feature’s mode, with the auto verdict when the server reports its timing.
A running server reports what its API process sees at GET /api/admin/decisions (admins only):
{
"success": true,
"available": true,
"mode": "local",
"status": "ready",
"reason": null,
"model": "arc53/arc-decide-0.4b-v0.2",
"version": "arc53/arc-decide-0.4b-v0.2/p78cfb276f93a/cc3bc72ff6907",
"runtime": "onnx-int8",
"speed": {"threads": 4, "measured_at": "2026-10-15T09:12:03+00:00", "drift": 1.02,
"guard_s": 0.152, "relevance_s": 1.41, "analytics_s": 4.1},
"features": {
"injection_input": {"mode": "auto", "effective": "monitor", "predicted_s": 0.38, "budget_s": 0.5, "reason": "fits"}
}
}statusisoff(DECISION_MODE=none),idle(not loaded),loading,readyorunavailable, and in API modedegradedwhile calls to a failing server are skipped.reasonsays why:memory,download,offline,wrong_modelorload_failedwhen unavailable, andnothing_fitswhen the model was unloaded because no feature set toautofits; in API mode alsocircuit_open,auth(the server refused the key) andno_decider(DECISION_MODE=apiwithoutDECISION_BASE_URL, or a worker withoutINTERNAL_KEY).speedis this machine’s timing: the predicted seconds of an injection check, of one relevance check on a whole 1,220-token chunk and of an analytics turn. In API mode they include the transport, andspeedaddsround_trip_msandmeasured_by(serverwhen the server reported its own timing,clientwhen DocsGPT timed it).runtimeisonnx-int8here; in API mode it isvllm, the runtime a server reports (onnx-int8for the decider,torch-mps-fp16on a Mac’s GPU,torch-cuda-bf16on an NVIDIA one), orremote. A worker calling the API process reportsapi-process:onnx-int8(api-processbefore its first call).featureshas, per feature, its configuredmode, what it does now aseffective(off,monitororon),predicted_s,budget_sandreason.
The public /api/config carries only decision_available.