vLLM: text watermark in the sampling loop
Read this if you serve a model with vLLM (or run one with transformers) and need the text it produces to carry a watermark you can detect later. You will install the Laidline logits processor into vLLM, front it with
laidline serve, and score text with/detect. Skip if your model sits behind a vendor API you do not run: the watermark has to act while the model samples, so there is nothing to mark afterwards. Time: an hour, most of it the vLLM install. Needs: Python 3.12, an NVIDIA GPU, a vLLM nightly build,laidline[text]andlaidline-vllmin vLLM's environment.
In short
- Text can only be marked while the model samples;
laidline-vllmdoes that inside vLLM with a key only you hold. - SynthID is used for sampled traffic and KGW for greedy traffic; the sidecar asks for it per request.
- It needs vLLM's Model Runner V2 interface, which only nightly builds have; the Tested table names the build.
- Each marked completion gets a text evidence entry (token counts, SHA-256 of the completion, never the text).
/detectscores text with the same key; paraphrase, translation and heavy editing remove the signal.
Text is the one modality Laidline cannot mark after the fact: the watermark has to bias token choices while the model
samples. laidline-vllm is a vLLM logits processor that does this with a key only your deployment holds, SynthID-style
for sampled traffic and KGW for greedy traffic. The Laidline sidecar in front of vLLM asks for the watermark on each
request and writes the evidence entry; /detect scores text later.
client --> laidline serve (proxy) --vllm_xargs.laidline--> vllm serve + laidline-vllm (marks while sampling)
| <-- completion + x-laidline-watermarked / x-laidline-scheme --|
+--> text evidence entry (token counts, SHA-256 of the completion; never the text)
Proven live on an RTX 5080 (16 GB) against a real vLLM server: 9 of 9 checks, five against vLLM directly and four through the proxy (see "What it proved").
Which vLLM
The plugin uses vLLM's Model Runner V2 (MRV2) logits-processor interface,
vllm.v1.worker.gpu.sample.logits_processor. No release has it as of the tested date below: not 0.19.1, and not 0.30.0. It is on vLLM's main branch and in the nightlies.
| Component | Tested | Tested on |
|---|---|---|
| vLLM | 0.30.1rc1.dev86+g5b3f280a6 (official nightly wheel, commit 5b3f280a6df68c4f8264014380249ba530173ed6) |
2026-09-24 |
| torch | 2.13.0+cu130 (CUDA 13.0) |
2026-09-24 |
| Python | 3.12 | 2026-09-24 |
| GPU | NVIDIA GeForce RTX 5080 (sm_120) | 2026-09-24 |
| Repeat campaign: 20 of 20 completions marked, 30 of 30 detected | vLLM nightly, same GPU | 2026-10-19 |
To reproduce the tested build, install that commit from vLLM's per-commit wheel index
(pip install "vllm==0.30.1rc1.dev86+g5b3f280a6" --pre --extra-index-url https://wheels.vllm.ai/5b3f280a6df68c4f8264014380249ba530173ed6),
or take the latest nightly from https://wheels.vllm.ai/nightly. Nightly wheels are CUDA 13 builds, so torch must be
the cu130 build of the version vLLM resolved: add --extra-index-url https://download.pytorch.org/whl/cu130 for
torch, torchvision and torchaudio only. Pin VLLM_USE_V2_MODEL_RUNNER=1:
MRV2 is the default on these builds, but vLLM falls back to the V1 runner, which cannot load this processor, when a
model or flag needs a feature MRV2 lacks. The pin makes that fail loudly.
Install
Into vLLM's own environment (not the sidecar's):
# with vLLM's Python pip install "laidline[text]" laidline-vllm
torch and transformers already come with vLLM and satisfy the text extra; the extra also brings the C2PA library
that import laidline loads.
- Entry point.
laidline-vllmregistersvllm.logits_processors: laidline(laidline_vllm.logits_processor:LaidlineTextWatermark). vLLM loads it for every server in that environment; no flag is needed. Only requests that opt in are marked. - Config.
LAIDLINE_CONFIGnames a Laidline config; the processor readswatermark.textfrom it (the whole file must validate). Use the same file, or at least the samewatermark.text.key_seedandkey_id, as the sidecar and the detector. Without a config file, or withwatermark.text.enabled: false, the processor logs a warning and marks nothing; an invalid file stops the server. - Ack middleware.
--middleware laidline_vllm.middleware.ack_headeraddsx-laidline-watermarked: 1andx-laidline-scheme: <watermark.text.scheme>to every response. Use the dotted path: vLLM splits it on the last., somodule:functionfails at startup.
LAIDLINE_CONFIG=/etc/laidline/config.yaml VLLM_USE_V2_MODEL_RUNNER=1 VLLM_USE_FLASHINFER_SAMPLER=0 \ vllm serve Qwen/Qwen2.5-0.5B-Instruct --host 127.0.0.1 --middleware laidline_vllm.middleware.ack_header
VLLM_USE_FLASHINFER_SAMPLER=0 is needed unless the CUDA toolkit (nvcc) is installed: the FlashInfer sampler compiles
at warmup and the engine dies about two minutes in. vllm serve listens on all interfaces by
default, so --host 127.0.0.1 keeps the raw vLLM endpoint off the network beside the proxy (laidline serve binds
127.0.0.1 by default).
The server log confirms the processor:
LaidlineTextWatermark active: scheme=synthid key_id=k1 fingerprint=bd58623f ngram_len=5 depth=30. The fingerprint
identifies your key without revealing it; the value shown is the one for the public development seed, and your own
seed gives another.
The ack header means "the middleware is installed and its config has watermark.text.enabled: true"; it reports the
config's scheme on every response, also on a KGW request and on a SynthID request that was declined as greedy, and
cannot see whether the engine loaded the logits processor. Without a usable config, or with text disabled, it reports
x-laidline-scheme: unknown, which the proxy treats as no ack; the proxy applies its own greedy check (temperature
below 1e-5, vLLM's threshold).
Sidecar. The sidecar lives in its own environment: pip install "laidline[image,server,text]", then
laidline init ..., then add upstream: {base_url: http://127.0.0.1:8000} to laidline.yaml (init writes none).
Also plan for:
- Disk. vLLM's environment is about 11 GB.
- Image weights on the sidecar. Its start-up self-test marks an image, so even a text-only sidecar downloads about
1.5 GB of image weights on its first start; fetch them ahead with
laidline models fetch.
Asking for the watermark: vllm_xargs.laidline
A request opts in with vllm_xargs.laidline, an object with optional key_id and scheme (synthid or kgw).
Over HTTP the object must be JSON-encoded, because vLLM's OpenAI server accepts only strings, numbers and lists
as vllm_xargs values and answers 400 to a nested object:
{"model": "Qwen/Qwen2.5-0.5B-Instruct", "messages": [{"role": "user", "content": "Write a story."}], "max_tokens": 400, "temperature": 1.0, "vllm_xargs": {"laidline": "{\"key_id\": \"k1\", \"scheme\": \"synthid\"}"}}
The Python LLM API also takes a plain dict in SamplingParams.extra_args["laidline"]. An unknown scheme, unknown
keys or a value that is not a JSON object is rejected with 400 at admission. A per-request key_id is honoured (the
processor keeps cores for up to 16 key ids per server).
Behind the proxy, clients send nothing extra: with watermark.text.enabled: true the proxy injects
{"key_id": "<watermark.text.key_id>", "scheme": "<watermark.text.scheme>"} JSON-encoded, replaces any client
key_id, keeps a client scheme, and for streams sets stream_options.include_usage so it can count tokens. See
OpenAI proxy.
The detector scores only watermark.text.scheme: KGW completions detect only with a config whose scheme is kgw (keep
a second config for them).
Greedy decoding, speculative decoding, memory
- SynthID does nothing at temperature 0 (it cannot move the argmax), so the processor declines SynthID for greedy
requests (temperature below 1e-5) and the proxy records them
unmarkedwith reasongreedy_decoding. Usescheme: kgwfor greedy traffic: KGW biases a keyed green list and changes greedy output. - Speculative decoding is not supported. Draft rows are left unmarked.
- Memory. SynthID's per-step work is
[rows, vocab, depth]wide. It runs 16 rows at a time; at a 152k vocabulary and depth 30 that is about 140 MB per row. Measured: 16 concurrent marked requests raised GPU memory from 11,397 MiB to a peak of 13,629 MiB (+2.2 GiB), and torch's caching allocator keeps it. Leave that headroom outside--gpu-memory-utilization. - Device independence. The SynthID sampling table and the KGW green lists are drawn on CPU and moved to the GPU,
so text marked on any GPU verifies on the CPU detector. KGW is keyed on
len(tokenizer)(151,665 for Qwen2.5), not the padded logits width.
Startup time and the development seed
On the tested machine, startup took 109 s cold and 28 s warm with the model already downloaded on a local disk; expect
several minutes when the model or the environment sits on a slow or network-mounted drive. The server is ready when
GET /health answers 200 and the log shows the LaidlineTextWatermark active line above.
Use your own watermark.text.key_seed for real traffic. A public development seed is fine for trying the plugin and
never for traffic you want to attribute: generate yours with
python -c "import secrets; print(secrets.token_hex(16))" and keep it secret.
What it proved
Run on 2026-09-24 with a fixed seed per prompt and 400 tokens per completion (an earlier development key_seed):
| Case | Detector | z | p |
|---|---|---|---|
| SynthID, temperature 1, marked | SynthID | 41.7 | 0 (underflow) |
same prompt and seed, no vllm_xargs |
SynthID | -0.73 | 0.77 |
| SynthID asked at temperature 0 (declined) | SynthID | 1.84 | 0.033 |
| KGW, temperature 0, marked | KGW | 15.1 | 1.5e-51 |
same prompt, no vllm_xargs |
KGW | 0.61 | 0.27 |
The two KGW rows (z and p) were measured before the detector began scoring each distinct n-gram once, so they count every repeat of an n-gram.
A repeat campaign on 2026-10-19 on the same GPU with a vLLM nightly marked 20 of 20 completions through the sidecar and detected 30 of 30 marked texts.
Plus an unknown scheme rejected with 400, and through the proxy: a non-stream completion injected, marked
(status: ok, upstream_ack: true) and scored p < 1e-3; a stream relayed with its usage chunk and the same token
count in evidence; a greedy request recorded unmarked / greedy_decoding.
transformers
The same watermark without vLLM, for models you run with transformers (laidline-transformers):
pip install laidline-transformers "laidline[text]"
Plain pip install gives whatever torch your index serves. For a GPU, add your driver's CUDA index for torch, for
example --extra-index-url https://download.pytorch.org/whl/cu130, and move the model to the device
(AutoModelForCausalLM.from_pretrained(model_id, dtype=torch.bfloat16).to("cuda")). generate_marked builds the
watermark on model.device, so a CUDA model works.
from transformers import AutoModelForCausalLM, AutoTokenizer from laidline import MarkContext, Marker from laidline_transformers import generate_marked model_id = "Qwen/Qwen2.5-0.5B-Instruct" tokenizer = AutoTokenizer.from_pretrained(model_id) model = AutoModelForCausalLM.from_pretrained(model_id) # CPU is fine for a 0.5B model, just slow marker = Marker.from_config("laidline.yaml") # watermark.text.enabled: true # in-process detect accepts any tokenizer id while detect_models is empty; once the list is set, model_id must be in it text, record = generate_marked( model, tokenizer, "Write a long story about a lighthouse keeper.", marker, MarkContext(), max_new_tokens=400, temperature=1.0, ) print(record.status, record.reason, record.tokens) # ok None 400 print(marker.detect(text, model=model_id).verdict) # marked_by_this_system
generate_marked samples by default (do_sample=True) and writes the same text evidence entry as the proxy.
Run on 2026-10-21 with the install line above in a fresh environment: Qwen2.5-0.5B-Instruct on an RTX 5080 (bf16) marked 3 of 3 completions of 340 to 400 tokens, and all three were detected at grade B (p < 1e-127). Two unmarked controls with the same sampling were not detected (p 0.04 and 0.13). Peak GPU memory was 1.2 GB, and generation ran at 8 to 17 tokens/s on a shared GPU, which is not a throughput claim.
Detection
curl -X POST localhost:8080/detect -H 'content-type: application/json' \ -d '{"text": "<the completion>", "model": "Qwen/Qwen2.5-0.5B-Instruct"}'
model is the tokenizer id. Over HTTP it must be listed exactly in watermark.text.detect_models; an empty list
disables text detection over HTTP (text_detect_models_empty), an unlisted id is model_not_allowed, so anonymous
callers cannot trigger Hub downloads or probe local paths. Limits: text longer than max_detect_chars (100,000) is a
413; a model over 256 characters is a 422; only the first max_detect_tokens (8,192) tokens are scored, and for
KGW at most kgw.max_detect_tokens (1,024), with a caveat saying so. In-process, marker.detect(text, model=...)
is looser: any id while detect_models is empty, and always a local tokenizer directory.
The detector is training-free: the mean g-value (SynthID, centred on the key's own sampling-table mean rather than
0.5) or the green-token share (KGW) as a one-sided z-test. KGW scores each distinct (context, token) n-gram once, so
repeated n-grams (the loops greedy decoding falls into) add no evidence. Counting every repeat would let
unmarked greedy loops reach z 6.3. KGW's p-value is the exact binomial upper tail of the green count. Present means
p < detect_p_value (1e-3). Grades: B over at least min_tokens (200) tokens (for KGW, 200 tokens covered by distinct
n-grams, so a repeating text needs more) and with p < grade_b_p_value (1e-5, about 1.9 times the tokens of the 1e-3
gate for the same per-token signal); D below either; never A (Grading). A short or heavily edited marked
text that was B at 1e-3 can be D now; the text robustness bench has not re-measured that cost. Paraphrase, translation and heavy editing remove the signal.
The first text detection in a process imports transformers and loads the tokenizer, which took about 54 s on a slow
network-mounted filesystem and 2.4 s on a local disk with the tokenizer cached (later calls 10 to 22 ms). The server does not warm
this up itself: send one /detect text request after start (or before marking the instance ready) so the first real
caller does not wait.
vLLM native watermarking
vLLM 0.30.0 ships its own generation watermark: --watermark-config '{"key": ..., "algorithm": "gumbel"}'
(Gumbel-max sampling with a Philox PRF; also dual_key_gumbel, which supports speculative decoding). It is configured
per server, in the engine, with no plugin. Laidline does not use it; use the laidline-vllm processor on a nightly.
Limits
- The plugin needs a vLLM build with the Model Runner V2 interface; no release has it yet (see "Which vLLM").
- SynthID does nothing at temperature 0 and speculative decoding is unmarked ("Greedy decoding, speculative decoding, memory").
- A text detection of grade B is the ceiling: grade A is never given to text (Grading), and grade B is a statistical claim (p < 1e-5 per text), not the registry-confirmed grade B of an image or video.
- Paraphrase, translation and heavy editing remove the signal; figures: Robustness.
Troubleshooting
| Message | Cause | Fix |
|---|---|---|
ModuleNotFoundError: vllm.v1.worker.gpu.sample.logits_processor |
a vLLM release without Model Runner V2 | install the nightly named in "Which vLLM" |
vllm_xargs answered 400 |
the laidline value was a nested object |
JSON-encode it as a string ("Asking for the watermark") |
x-laidline-scheme: unknown |
the middleware has no usable config or text is disabled | set LAIDLINE_CONFIG and watermark.text.enabled: true |
| engine dies about two minutes after start | the FlashInfer sampler compiles at warmup and needs nvcc |
set VLLM_USE_FLASHINFER_SAMPLER=0 |
text_detect_models_empty / model_not_allowed |
/detect accepts only models listed in watermark.text.detect_models |
list the tokenizer id exactly |
unmarked with reason greedy_decoding |
SynthID was asked at temperature 0 | send scheme: kgw or a temperature above 0 |
Reference
- Integrations: what each integration was proven against, and how.
- OpenAI proxy: how the sidecar injects the request and counts tokens.
- Config: the
watermark.textsection. - Evidence: the text evidence entry.
- Grading: how a detected text is graded.
- Install: extras and the CPU and CUDA torch indexes.