Harry19081 commited on
Commit
d17e09a
·
verified ·
1 Parent(s): da0b808

wald-serve 0.2.2: native video input (videos / video_url; video_route native32 default, frames16); decode once per request; card, API, runbook, evaluation/v2.1/video.json; MANIFEST updated. Weights unchanged.

Browse files
Dockerfile CHANGED
@@ -12,7 +12,7 @@ RUN pip install --no-cache-dir uv==0.9.5 \
12
  && uv pip install --system --no-cache "vllm==0.30.0" "transformers==5.17.0" \
13
  && python -c "import importlib.metadata as m; print('vllm', m.version('vllm'), 'transformers', m.version('transformers'), 'torch', m.version('torch'))"
14
  COPY server /app/server
15
- RUN uv pip install --system --no-cache /app/server
16
  ENV HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 TOKENIZERS_PARALLELISM=false PYTHONUNBUFFERED=1 \
17
  VLLM_USE_FLASHINFER_SAMPLER=0 VLLM_NO_USAGE_STATS=1 DO_NOT_TRACK=1 WALD_EFFORT=""
18
  EXPOSE 8000
 
12
  && uv pip install --system --no-cache "vllm==0.30.0" "transformers==5.17.0" \
13
  && python -c "import importlib.metadata as m; print('vllm', m.version('vllm'), 'transformers', m.version('transformers'), 'torch', m.version('torch'))"
14
  COPY server /app/server
15
+ RUN uv pip install --system --no-cache "/app/server[video]"
16
  ENV HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 TOKENIZERS_PARALLELISM=false PYTHONUNBUFFERED=1 \
17
  VLLM_USE_FLASHINFER_SAMPLER=0 VLLM_NO_USAGE_STATS=1 DO_NOT_TRACK=1 WALD_EFFORT=""
18
  EXPOSE 8000
MANIFEST.json CHANGED
@@ -8,8 +8,8 @@
8
  "sha256": "1c69371d18ee073bf854aff74b2c3b3403d589fb17d36b43f5945f764016cb55"
9
  },
10
  "Dockerfile": {
11
- "bytes": 1212,
12
- "sha256": "29ae11deccbeba7af543614f3c2a76711377fffd9727d7c269af050b781f8b0e"
13
  },
14
  "LICENSE": {
15
  "bytes": 11358,
@@ -24,12 +24,12 @@
24
  "sha256": "43510ca439ff9f402eeef7c7e0e4a8e3e2265cf0ff8b3e027d662b1bff79078e"
25
  },
26
  "README.md": {
27
- "bytes": 6044,
28
- "sha256": "645bc8cb22d9c89dfa4f81ede4a89f1fb58b572a73987fd02bcdd223aee4c608"
29
  },
30
  "RUNBOOK.md": {
31
- "bytes": 6122,
32
- "sha256": "73389fe1429146f0ae3a77079b96ff8ee7f728c3a3e215604a9d85fa84303056"
33
  },
34
  "SHA256SUMS": {
35
  "sha256": "f5de06dae0fbbeb38f196e95ae13c46123cb466da84d2431261e9d4e37b22ccf",
@@ -48,8 +48,8 @@
48
  "sha256": "31f5a5ee9860fb747c0c7e23a9ac75790dcd688de1655c6890eef5ba2ed9b9c3"
49
  },
50
  "docs/api.md": {
51
- "bytes": 4403,
52
- "sha256": "0c05b5a5b9a2ec982416137e209b615e51b64cfb012f53a02b279257f258d2bd"
53
  },
54
  "evaluation/v2.1/README.md": {
55
  "sha256": "28f8bc4059cc9e0966c0ade9e909bd2a87ab0dd85a9da45f4487d7ef52ab84f2",
@@ -67,6 +67,10 @@
67
  "sha256": "838516b0ce8ddb7c50688c79bdb63437e06bb12db1d4ec3d6cc41c8a95a7e675",
68
  "bytes": 1052
69
  },
 
 
 
 
70
  "evaluation/v2/README.md": {
71
  "bytes": 2711,
72
  "sha256": "6792af269e6383ececdc997b011cf1a09b5ab2a79d356f04932fc0f22e6789a7"
@@ -272,20 +276,24 @@
272
  "sha256": "c8b231a1cdf413895c1abb215e43a41a08f6568d8404ea4f7ca86ee4883eb396"
273
  },
274
  "run.sh": {
275
- "bytes": 987,
276
- "sha256": "fa224f8ec1dfeb6598231bd144d7a6928a9a01e63ac70b5cf5df28d6a9aca9c9"
 
 
 
 
277
  },
278
  "server/README.md": {
279
- "bytes": 1678,
280
- "sha256": "2ca738dcacd770e7c490ceeb59f2f11df7515f5bc56784eb9d476af4c7145b4d"
281
  },
282
  "server/pyproject.toml": {
283
- "bytes": 850,
284
- "sha256": "f1357ddf2448b827ad6bb4ee3ba63266196dc89df26d7c18cdf60da6150f11b5"
285
  },
286
  "server/src/wald_serve/__init__.py": {
287
  "bytes": 200,
288
- "sha256": "86f26622ee5bad7e12ac7cdcc820935e4b9bf3e4a414cd2dc47e1bdf9b8608d3"
289
  },
290
  "server/src/wald_serve/__main__.py": {
291
  "bytes": 51,
@@ -296,8 +304,8 @@
296
  "sha256": "40fbea84c4c5b93ef888da44508d4fec2677c58732a09d731f0bcf17c2b48dd6"
297
  },
298
  "server/src/wald_serve/native.py": {
299
- "bytes": 15937,
300
- "sha256": "135b5bbb135480ef15bac81a380ea909bdd78a0fc67263192001fc0ebe353fee"
301
  },
302
  "server/src/wald_serve/native_llamacpp.py": {
303
  "bytes": 3827,
@@ -340,8 +348,8 @@
340
  "sha256": "c8b231a1cdf413895c1abb215e43a41a08f6568d8404ea4f7ca86ee4883eb396"
341
  },
342
  "server/src/wald_serve/native_vision.py": {
343
- "bytes": 8479,
344
- "sha256": "1b079295aac76a8c8bf22d7794b245cf413930838855648f3cd7fffe4a978cf3"
345
  },
346
  "server/src/wald_serve/prompt.py": {
347
  "bytes": 4192,
@@ -351,9 +359,13 @@
351
  "bytes": 10020,
352
  "sha256": "b236e0ec55aadf575198578285ef4a802f09f51d9b78f35e0da41d60a12755c2"
353
  },
 
 
 
 
354
  "server/src/wald_serve/vision_v2.py": {
355
- "bytes": 10629,
356
- "sha256": "ab7669e598e723a9a65d63356615b04c658f1fb02f1b7494bf40e69fd5627c7f"
357
  },
358
  "server/src/wald_serve/wire.py": {
359
  "bytes": 3670,
@@ -363,6 +375,18 @@
363
  "bytes": 4167,
364
  "sha256": "9fb222a21bc2b17221e5a1a3605973b4f71730db9bbfede439bd3388b91e2570"
365
  },
 
 
 
 
 
 
 
 
 
 
 
 
366
  "server/tests/test_native_v2.py": {
367
  "bytes": 7236,
368
  "sha256": "ed5310bdcad9da34313719d83d83d6174329a115531fad7b98b1a3437d685eec"
@@ -379,6 +403,10 @@
379
  "bytes": 13844,
380
  "sha256": "46f2007e9743bfeb0f7f79fca093cf444a70df1bfc348229f34cd251f88a19b3"
381
  },
 
 
 
 
382
  "server/tests/test_vision_v2.py": {
383
  "bytes": 5926,
384
  "sha256": "7793a78d851f217c2af32bf963217bf431193e0f6bbf290e2bfd50107a03da88"
 
8
  "sha256": "1c69371d18ee073bf854aff74b2c3b3403d589fb17d36b43f5945f764016cb55"
9
  },
10
  "Dockerfile": {
11
+ "bytes": 1221,
12
+ "sha256": "4b421737cf3d0897256fb2e06252feeb1706a2eed863ff8ccbca6b7d2b047791"
13
  },
14
  "LICENSE": {
15
  "bytes": 11358,
 
24
  "sha256": "43510ca439ff9f402eeef7c7e0e4a8e3e2265cf0ff8b3e027d662b1bff79078e"
25
  },
26
  "README.md": {
27
+ "bytes": 7197,
28
+ "sha256": "59d50796d33a8a8588ae4dcf8c0180aaa2b185b46d1081cba587604f98edf26e"
29
  },
30
  "RUNBOOK.md": {
31
+ "bytes": 7098,
32
+ "sha256": "03c171b0836a2dd36aceedc97cf18e0a9e8307d1c81676d445358a388aaa4853"
33
  },
34
  "SHA256SUMS": {
35
  "sha256": "f5de06dae0fbbeb38f196e95ae13c46123cb466da84d2431261e9d4e37b22ccf",
 
48
  "sha256": "31f5a5ee9860fb747c0c7e23a9ac75790dcd688de1655c6890eef5ba2ed9b9c3"
49
  },
50
  "docs/api.md": {
51
+ "bytes": 7193,
52
+ "sha256": "d60958611887e4072b26cba32ea861a3a2a0e686089d27c800e276f156de922a"
53
  },
54
  "evaluation/v2.1/README.md": {
55
  "sha256": "28f8bc4059cc9e0966c0ade9e909bd2a87ab0dd85a9da45f4487d7ef52ab84f2",
 
67
  "sha256": "838516b0ce8ddb7c50688c79bdb63437e06bb12db1d4ec3d6cc41c8a95a7e675",
68
  "bytes": 1052
69
  },
70
+ "evaluation/v2.1/video.json": {
71
+ "bytes": 2719,
72
+ "sha256": "ed52612048786a582a1d13086444bd11b7dd146e99e6b04ad5cda203361f7f7a"
73
+ },
74
  "evaluation/v2/README.md": {
75
  "bytes": 2711,
76
  "sha256": "6792af269e6383ececdc997b011cf1a09b5ab2a79d356f04932fc0f22e6789a7"
 
276
  "sha256": "c8b231a1cdf413895c1abb215e43a41a08f6568d8404ea4f7ca86ee4883eb396"
277
  },
278
  "run.sh": {
279
+ "bytes": 994,
280
+ "sha256": "2159f4e041e9b7d0b9e0de382736397d176e5a5a955547bc84f4b1509545a62a"
281
+ },
282
+ "server/CHANGELOG.md": {
283
+ "bytes": 1321,
284
+ "sha256": "9f2fba41f7da1c13e290446c6b52604ba9a5ff9b7e63bae660cd70e2b90b539a"
285
  },
286
  "server/README.md": {
287
+ "bytes": 5479,
288
+ "sha256": "49f21b5b2be45358876666d423d744bcde2bac91696efa7609c8a18ebfbcc08d"
289
  },
290
  "server/pyproject.toml": {
291
+ "bytes": 905,
292
+ "sha256": "f429f914d3857c4adc814343dc48d6d3289afd209c923278f81c3654a770ad39"
293
  },
294
  "server/src/wald_serve/__init__.py": {
295
  "bytes": 200,
296
+ "sha256": "d88ae786118b8186ad5d133006a397556ed6fd473dfcdb55de8d61c0926b7f6e"
297
  },
298
  "server/src/wald_serve/__main__.py": {
299
  "bytes": 51,
 
304
  "sha256": "40fbea84c4c5b93ef888da44508d4fec2677c58732a09d731f0bcf17c2b48dd6"
305
  },
306
  "server/src/wald_serve/native.py": {
307
+ "bytes": 17130,
308
+ "sha256": "3a21ec14e541caa74fcc5b034d905156712f534d2fecc55b41b0e5331d0d4c1f"
309
  },
310
  "server/src/wald_serve/native_llamacpp.py": {
311
  "bytes": 3827,
 
348
  "sha256": "c8b231a1cdf413895c1abb215e43a41a08f6568d8404ea4f7ca86ee4883eb396"
349
  },
350
  "server/src/wald_serve/native_vision.py": {
351
+ "bytes": 12010,
352
+ "sha256": "423f515f449498ffce31dd16b810fa31f55ac6ea4cb181182ebcd9a4634a0f2a"
353
  },
354
  "server/src/wald_serve/prompt.py": {
355
  "bytes": 4192,
 
359
  "bytes": 10020,
360
  "sha256": "b236e0ec55aadf575198578285ef4a802f09f51d9b78f35e0da41d60a12755c2"
361
  },
362
+ "server/src/wald_serve/video.py": {
363
+ "bytes": 24537,
364
+ "sha256": "89e79d8d70b8a72e9e78e5f7cb69cbb316104aa6d024f5a249b8925b6ab37d73"
365
+ },
366
  "server/src/wald_serve/vision_v2.py": {
367
+ "bytes": 14004,
368
+ "sha256": "db56cfa76fe5d0cb8f76f05cf66be7e825240fa9279355738dc694ac5b7f11c1"
369
  },
370
  "server/src/wald_serve/wire.py": {
371
  "bytes": 3670,
 
375
  "bytes": 4167,
376
  "sha256": "9fb222a21bc2b17221e5a1a3605973b4f71730db9bbfede439bd3388b91e2570"
377
  },
378
+ "server/tests/fixtures/default-responses-0.2.1.json": {
379
+ "bytes": 43039,
380
+ "sha256": "ce01380ec65df43fc901ecca01154613ed3467916d5467033a08fcbf2ccb5e37"
381
+ },
382
+ "server/tests/record_default_fixtures.py": {
383
+ "bytes": 3929,
384
+ "sha256": "d290a57568dca62ca005a6dbd2993c652fcfe4b3c3676fc7e95176e0a5ba98ef"
385
+ },
386
+ "server/tests/stubengine.py": {
387
+ "bytes": 3597,
388
+ "sha256": "a15d8e3b57bc730222e2690e57febf9732132a29b9c8d5c4ecae319e05fc0423"
389
+ },
390
  "server/tests/test_native_v2.py": {
391
  "bytes": 7236,
392
  "sha256": "ed5310bdcad9da34313719d83d83d6174329a115531fad7b98b1a3437d685eec"
 
403
  "bytes": 13844,
404
  "sha256": "46f2007e9743bfeb0f7f79fca093cf444a70df1bfc348229f34cd251f88a19b3"
405
  },
406
+ "server/tests/test_video.py": {
407
+ "bytes": 20812,
408
+ "sha256": "f16b9522584feecb1939fffe7a467984c5e307e5eaeb72d46377c8ca624ef4d6"
409
+ },
410
  "server/tests/test_vision_v2.py": {
411
  "bytes": 5926,
412
  "sha256": "7793a78d851f217c2af32bf963217bf431193e0f6bbf290e2bfd50107a03da88"
README.md CHANGED
@@ -54,7 +54,7 @@ An open 4B decision model: send a state and typed options, get a calibrated prob
54
  - **Thinks only when unsure:** Auto 0.7 thinks natively on about half of the requests.
55
  - **Fast:** median **158 ms** (serial, one request in flight, warm, processing time, one RTX PRO 6000, 200 DI 0.3 requests).
56
  - **Web actions:** Mind2Web element selection **92.3 %** (test splits, 6 candidates; v2.0: 84.6 %).
57
- - **Reads images** zero-shot through Qwen3.5-4B's unchanged vision tower.
58
  - **Small and open:** 4B, Apache-2.0.
59
 
60
  ## Scores
@@ -86,8 +86,14 @@ curl -s localhost:8000/v1/systemone -H 'Content-Type: application/json' -d '{
86
  curl -s localhost:8000/v1/systemone -H 'Content-Type: application/json' -d '{
87
  "state": "Photo from the returns desk.", "images": ["data:image/jpeg;base64,..."],
88
  "questions": {"damaged": {"type": "noul", "instructions": "Is the item visibly damaged?"}}}'
 
 
 
 
89
  ```
90
 
 
 
91
  API fields: [docs/api.md](https://huggingface.co/org2ai/Wald-4B/blob/main/docs/api.md). Server options, Docker and evaluation settings: [RUNBOOK.md](https://huggingface.co/org2ai/Wald-4B/blob/main/RUNBOOK.md).
92
 
93
  ## How it works
@@ -95,7 +101,7 @@ API fields: [docs/api.md](https://huggingface.co/org2ai/Wald-4B/blob/main/docs/a
95
  - One forward pass reads a probability for every option from the model's option-letter logits (Qwen chat template), calibrated with a frozen temperature table.
96
  - Auto 0.7 (default): if the top probability is below 0.7, the model thinks once natively (at most 512 tokens) and reads the options again.
97
  - More than 26 options: a one-pass knockout over all options.
98
- - Images go through Qwen3.5-4B's vision tower, unchanged (zero-shot).
99
 
100
  ## Details
101
 
 
54
  - **Thinks only when unsure:** Auto 0.7 thinks natively on about half of the requests.
55
  - **Fast:** median **158 ms** (serial, one request in flight, warm, processing time, one RTX PRO 6000, 200 DI 0.3 requests).
56
  - **Web actions:** Mind2Web element selection **92.3 %** (test splits, 6 candidates; v2.0: 84.6 %).
57
+ - **Reads images and video** zero-shot through Qwen3.5-4B's unchanged vision tower. Video (wald-serve 0.2.2): **87.4 %** on VisualDecisionBench video (7,143 questions, our run), 2.5 s for a 15-second clip with 24 questions (serial, one RTX 5090).
58
  - **Small and open:** 4B, Apache-2.0.
59
 
60
  ## Scores
 
86
  curl -s localhost:8000/v1/systemone -H 'Content-Type: application/json' -d '{
87
  "state": "Photo from the returns desk.", "images": ["data:image/jpeg;base64,..."],
88
  "questions": {"damaged": {"type": "noul", "instructions": "Is the item visibly damaged?"}}}'
89
+
90
+ curl -s localhost:8000/v1/systemone -H 'Content-Type: application/json' -d '{
91
+ "state": "Aisle camera, last 15 seconds.", "videos": ["data:video/mp4;base64,..."],
92
+ "questions": {"conceal": {"type": "noul", "instructions": "Does the shopper put an item into a bag or pocket?"}}}'
93
  ```
94
 
95
+ Video needs the server from `main` (wald-serve 0.2.2; the weights are the same as tag `v2.1`): one video per request, up to 32 MB and 300 s. By default the server sends 32 frames to vLLM as one native video (`"video_route": "native32"`). `"video_route": "frames16"` sends 16 labelled frames as images instead, and is used automatically when the request also has images. Details: [docs/api.md](https://huggingface.co/org2ai/Wald-4B/blob/main/docs/api.md#video), numbers: [evaluation/v2.1/video.json](https://huggingface.co/org2ai/Wald-4B/blob/main/evaluation/v2.1/video.json).
96
+
97
  API fields: [docs/api.md](https://huggingface.co/org2ai/Wald-4B/blob/main/docs/api.md). Server options, Docker and evaluation settings: [RUNBOOK.md](https://huggingface.co/org2ai/Wald-4B/blob/main/RUNBOOK.md).
98
 
99
  ## How it works
 
101
  - One forward pass reads a probability for every option from the model's option-letter logits (Qwen chat template), calibrated with a frozen temperature table.
102
  - Auto 0.7 (default): if the top probability is below 0.7, the model thinks once natively (at most 512 tokens) and reads the options again.
103
  - More than 26 options: a one-pass knockout over all options.
104
+ - Images go through Qwen3.5-4B's vision tower, unchanged (zero-shot). The server decodes a video once per request; by default 32 frames go to the tower as one Qwen3.5 video, with timestamps.
105
 
106
  ## Details
107
 
RUNBOOK.md CHANGED
@@ -34,6 +34,10 @@ Auto 0.7 gates on the untempered one-pass distribution: a question with 2–26 o
34
 
35
  Requests with images (message content parts `image_url` / `image` in `state`, raw base64 under `state` keys `image`, `image_data`, `image_base64`, `image_b64`, or a top-level `images` / `image_data` list; up to 16 per request, bodies up to 64 MB) go through the same frozen reader, with the images carried to vLLM's chat route at their position in the state. They default to the `plain` prompt format. Text-only requests take the text path unchanged. The Decision Index 0.3 run used the text-only weights file; the language-model tensors here are byte-identical, and the text parity of this repository's weights is in `evaluation/v2.1/serving-parity.json`.
36
 
 
 
 
 
37
  ## 4. GGUF (llama.cpp)
38
 
39
  `org2ai/Wald-4B-GGUF` holds the v2 quantisations and the vision projector (`Wald-4B-v2-mmproj-F16.gguf`). `wald-serve-native --gguf FILE --tokenizer-dir DIR --effort none` reads text requests through llama-server with the same chat layout; a letter outside llama-server's top-100 log-probabilities gets the reader's floor instead of its exact value. One pass is the checked mode.
 
34
 
35
  Requests with images (message content parts `image_url` / `image` in `state`, raw base64 under `state` keys `image`, `image_data`, `image_base64`, `image_b64`, or a top-level `images` / `image_data` list; up to 16 per request, bodies up to 64 MB) go through the same frozen reader, with the images carried to vLLM's chat route at their position in the state. They default to the `plain` prompt format. Text-only requests take the text path unchanged. The Decision Index 0.3 run used the text-only weights file; the language-model tensors here are byte-identical, and the text parity of this repository's weights is in `evaluation/v2.1/serving-parity.json`.
36
 
37
+ ## 3b. Video
38
+
39
+ wald-serve 0.2.2 (on `main`) takes one video per request (`videos`, or a `video_url` message part) and decodes it once per request with OpenCV (`pip install "./server[video]"`; `run.sh` and the Dockerfile install it). `video_route` `native32` (default) gives vLLM 32 frames as one Qwen3.5 video with the source timestamps; `frames16` sends 16 labelled frames through the image route, which is the client protocol of the VisualDecisionBench-video frames read. `wald-serve-native-vision --model` raises vLLM's `--limit-mm-per-prompt` to `{"image": 32, "video": 2}` for this. The limit does not change image or text reads: the 16 recorded 0.2.1 responses are reproduced byte for byte. `--no-video` keeps the evaluated `{"image": 16, "video": 0}` and refuses videos. Server flags: `--video-route`, `--video-dir`, `--video-urls off|public|any`, `--video-cache-mb`, `--max-video-mb`, `--max-video-seconds`. Accuracy, parity and latency: `evaluation/v2.1/video.json`.
40
+
41
  ## 4. GGUF (llama.cpp)
42
 
43
  `org2ai/Wald-4B-GGUF` holds the v2 quantisations and the vision projector (`Wald-4B-v2-mmproj-F16.gguf`). `wald-serve-native --gguf FILE --tokenizer-dir DIR --effort none` reads text requests through llama-server with the same chat layout; a letter outside llama-server's top-100 log-probabilities gets the reader's floor instead of its exact value. One pass is the checked mode.
docs/api.md CHANGED
@@ -19,9 +19,11 @@
19
  | `effort` | string, optional | `none` (one pass), `auto` or `medium` (Auto 0.7, the default), `always` or `high` (Always 512). Other values (`low`, `high-k2` …) are v1.x efforts and return HTTP 400. |
20
  | `prompt_format` | string, optional | `repeat_state_plain` (default: the state is written twice) or `plain`. |
21
  | `images` | array of data URIs, optional | Images for the state (also accepted inside `state` as message content parts `{"type": "image_url", "image_url": {"url": "data:..."}}`); up to 16; zero-shot. |
 
 
22
  | `model` | string, optional | Ignored; accepted for client compatibility. |
23
 
24
- Only `state`, `questions` and the images reach the model. Each question has a `type`, optional `instructions` and `criteria`:
25
 
26
  | `type` | `criteria` | Answer fields |
27
  |---|---|---|
@@ -31,6 +33,8 @@ Only `state`, `questions` and the images reach the model. Each question has a `t
31
 
32
  Every answer also carries `type`, `mode` (`A` one pass, `B` after a native thought, `K`/`T` grouped readout for more than 26 options) and `probabilities_t1` (the same distribution before the calibration table). `confidence` is the largest calibrated probability. The response has `model`, `answers` and `usage` (`input_tokens`, `output_tokens`; output tokens are thought tokens). The thought text itself is not returned.
33
 
 
 
34
  Prompts longer than the context limit (131,072 tokens) are rejected with HTTP 422, never truncated. A malformed request or an unknown effort / prompt format returns HTTP 400; a backend failure returns 502.
35
 
36
  ## Example: route a tool call
@@ -62,3 +66,32 @@ Response shape (values illustrative):
62
  ```
63
 
64
  Act on a decision when its probability clears a threshold you set on your own validation data; otherwise escalate or ask. The model picks options; it does not write tool arguments.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  | `effort` | string, optional | `none` (one pass), `auto` or `medium` (Auto 0.7, the default), `always` or `high` (Always 512). Other values (`low`, `high-k2` …) are v1.x efforts and return HTTP 400. |
20
  | `prompt_format` | string, optional | `repeat_state_plain` (default: the state is written twice) or `plain`. |
21
  | `images` | array of data URIs, optional | Images for the state (also accepted inside `state` as message content parts `{"type": "image_url", "image_url": {"url": "data:..."}}`); up to 16; zero-shot. |
22
+ | `videos` | array with one video, optional | A video for the state (wald-serve 0.2.2): `data:video/mp4;base64,...` (also MOV, WebM, MKV, AVI, MPEG), raw base64, an http(s) URL, or a server-side path when the server allows it. Also accepted inside `state` as a message part `{"type": "video_url", "video_url": {"url": ...}}`. See [Video](#video). |
23
+ | `video_route` | string, optional | `native32` (server default) or `frames16`. |
24
  | `model` | string, optional | Ignored; accepted for client compatibility. |
25
 
26
+ Only `state`, `questions`, the images and the video reach the model. Each question has a `type`, optional `instructions` and `criteria`:
27
 
28
  | `type` | `criteria` | Answer fields |
29
  |---|---|---|
 
33
 
34
  Every answer also carries `type`, `mode` (`A` one pass, `B` after a native thought, `K`/`T` grouped readout for more than 26 options) and `probabilities_t1` (the same distribution before the calibration table). `confidence` is the largest calibrated probability. The response has `model`, `answers` and `usage` (`input_tokens`, `output_tokens`; output tokens are thought tokens). The thought text itself is not returned.
35
 
36
+ Requests with a video also carry `videos` (1), `video_route` (the route used) and `video_frames` (frames read); `images` then counts only the caller's own images.
37
+
38
  Prompts longer than the context limit (131,072 tokens) are rejected with HTTP 422, never truncated. A malformed request or an unknown effort / prompt format returns HTTP 400; a backend failure returns 502.
39
 
40
  ## Example: route a tool call
 
66
  ```
67
 
68
  Act on a decision when its probability clears a threshold you set on your own validation data; otherwise escalate or ask. The model picks options; it does not write tool arguments.
69
+
70
+ ## Video
71
+
72
+ wald-serve 0.2.2 reads one video per request; the server decodes it once and every question of the request reuses it.
73
+
74
+ ```sh
75
+ curl http://localhost:8000/v1/systemone -H 'Content-Type: application/json' -d '{
76
+ "state": "Aisle camera, last 15 seconds.",
77
+ "videos": ["data:video/mp4;base64,AAAAIGZ0eXBpc29t..."],
78
+ "questions": {
79
+ "conceal": {"type": "noul", "instructions": "Does the shopper put an item into a bag or pocket?"},
80
+ "next": {"type": "choice", "instructions": "What should the store system do?",
81
+ "criteria": {"ignore": "Nothing unusual", "watch": "Keep watching", "alert": "Alert staff"}}
82
+ }
83
+ }'
84
+ ```
85
+
86
+ | `video_route` | What the model sees | VisualDecisionBench video (our run) |
87
+ |---|---|---:|
88
+ | `native32` (default) | 32 frames spread evenly over the clip, as one Qwen3.5 video with `<t seconds>` timestamps, ≤ 131,072 px per frame | 87.36 % |
89
+ | `frames16` | 16 frames spread evenly, as 16 images (≤ 131,072 px), each after the text `Frame k/16 at t = S s:` | 82.63 % |
90
+
91
+ - Images and a video in one request use `frames16` (the response says `"video_route_fallback": "images in the request"`); asking for `native32` together with images returns 400.
92
+ - Limits: one video per request; ≤ 32 MB; ≤ 300 s; source frames ≤ 3840 × 2160; request body ≤ 64 MB. A longer clip is thinned, not refused: 32 frames over 300 s is one frame every ~9.7 s, so for event timing send the window you care about.
93
+ - Sources: data URI or raw base64 always. http(s) URLs are fetched from public addresses only (server flag `--video-urls off|public|any`). Paths and `file://` URLs only under the server's `--video-dir`.
94
+ - Errors are HTTP 400 with a reason first in `error`: `video_format`, `video_too_large`, `video_too_long`, `video_pixels`, `video_malformed`, `video_count`, `video_source`, `video_route`, `video_unavailable`.
95
+ - Calibration is the image route's: the same reader, prompt and frozen temperature table.
96
+ - Latency (serial, one RTX 5090, 15-second 640×360 clips with 24 questions): `native32` 2.5 s per clip (1.85 s when the same clip is asked again), `frames16` 3.4 s. The first video after the server starts takes about 15 s more, once.
97
+
evaluation/v2.1/video.json ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "what": "Wald-Q4B v2.1 (056A0-c31) on VisualDecisionBench video (Valen-Team/VisualDecisionBench @ 0c9e9703, test, hard labels), zero-shot, effort none, noul decisive floor off, one RTX 5090, vLLM 0.30.0. Self-run; not a leaderboard result.",
3
+ "full_set_2026_10_12": {
4
+ "videos": 294,
5
+ "questions": 7143,
6
+ "client": "frames decoded by the client (frames16) or the mp4 handed to vLLM (native32)",
7
+ "frames16": {
8
+ "accuracy": 82.63,
9
+ "nll": 0.524
10
+ },
11
+ "native32": {
12
+ "accuracy": 87.36,
13
+ "nll": 0.489
14
+ },
15
+ "native32_minus_frames16": {
16
+ "accuracy_pp": 4.73,
17
+ "ci95": [
18
+ 3.86,
19
+ 5.6
20
+ ],
21
+ "bootstrap": "paired, clustered by video, 2,000 draws"
22
+ }
23
+ },
24
+ "served_wald_serve_0_2_2": {
25
+ "videos": 74,
26
+ "questions": 1775,
27
+ "subset": "every 4th video",
28
+ "client": "the mp4 as a data URI; the server decodes it",
29
+ "frames16": {
30
+ "accuracy": 84.34,
31
+ "choice": 88.08,
32
+ "noul": 86.81,
33
+ "score": 70.28,
34
+ "nll": 0.4992
35
+ },
36
+ "native32_default": {
37
+ "accuracy": 87.94,
38
+ "choice": 91.4,
39
+ "noul": 88.46,
40
+ "score": 75.83,
41
+ "nll": 0.4847
42
+ },
43
+ "native32_minus_frames16": {
44
+ "accuracy_pp": 3.61,
45
+ "ci95": [
46
+ 1.94,
47
+ 5.35
48
+ ]
49
+ },
50
+ "parity_with_the_full_set_reads_on_the_same_items": {
51
+ "frames16": {
52
+ "same_argmax_pct": 99.55,
53
+ "accuracy_delta_pp": 0.17,
54
+ "ci95": [
55
+ -0.11,
56
+ 0.45
57
+ ]
58
+ },
59
+ "native32": {
60
+ "same_argmax_pct": 98.54,
61
+ "accuracy_delta_pp": -0.11,
62
+ "ci95": [
63
+ -0.7,
64
+ 0.45
65
+ ]
66
+ }
67
+ }
68
+ },
69
+ "serial_latency_c1": {
70
+ "videos": 15,
71
+ "clips": "median 15 s, 640x360",
72
+ "note": "POST to answer for every question of the clip in one request (median 24 questions), including the server's decode; cold = first request on that video",
73
+ "frames16_cold": {
74
+ "median_s_per_video": 3.39,
75
+ "median_ms_per_question": 140.6
76
+ },
77
+ "native32_cold": {
78
+ "median_s_per_video": 2.5,
79
+ "median_ms_per_question": 102.4
80
+ },
81
+ "native32_repeat_same_video": {
82
+ "median_s_per_video": 1.85,
83
+ "median_ms_per_question": 75.5
84
+ },
85
+ "native32_mp4_resent_per_question_before_0_2_2": {
86
+ "median_s_per_video": 8.18,
87
+ "median_ms_per_question": 333.3
88
+ },
89
+ "server_decode_median_ms": {
90
+ "frames16": 580.3,
91
+ "native32": 270.1
92
+ },
93
+ "first_native_video_after_start": "about 15 s once (vLLM warm-up for the video shape)"
94
+ },
95
+ "checks": {
96
+ "clip_97_s": {
97
+ "frames16": 200,
98
+ "native32": 200
99
+ },
100
+ "clip_487_s": "400 video_too_long",
101
+ "image_plus_video": "200, frames16 (1 image + 16 frames)",
102
+ "image_plus_video_native32": "400 video_route",
103
+ "two_videos": "400 video_count"
104
+ },
105
+ "not_measured": [
106
+ "effort auto on video",
107
+ "the noul decisive floor on video",
108
+ "throughput under concurrent load",
109
+ "sources above 1080p"
110
+ ]
111
+ }
run.sh CHANGED
@@ -8,6 +8,6 @@ set -euo pipefail
8
  MODEL=${1:?usage: run.sh <weights dir>}
9
  HERE=$(cd "$(dirname "$0")" && pwd)
10
  VENV=${VENV:-$HERE/.venv}
11
- [ -x "$VENV/bin/python" ] || { uv venv -p 3.12 "$VENV" && VIRTUAL_ENV="$VENV" uv pip install "vllm==0.30.0" "transformers==5.17.0" "$HERE/server"; }
12
  export VLLM_USE_FLASHINFER_SAMPLER=0 VLLM_NO_USAGE_STATS=1 TOKENIZERS_PARALLELISM=false HF_HUB_OFFLINE=1
13
  exec "$VENV/bin/wald-serve-native-vision" --model "$MODEL" --port "${PORT:-8000}" ${EFFORT:+--effort "$EFFORT"} ${PROMPT_FORMAT:+--prompt-format "$PROMPT_FORMAT"}
 
8
  MODEL=${1:?usage: run.sh <weights dir>}
9
  HERE=$(cd "$(dirname "$0")" && pwd)
10
  VENV=${VENV:-$HERE/.venv}
11
+ [ -x "$VENV/bin/python" ] || { uv venv -p 3.12 "$VENV" && VIRTUAL_ENV="$VENV" uv pip install "vllm==0.30.0" "transformers==5.17.0" "$HERE/server[video]"; }
12
  export VLLM_USE_FLASHINFER_SAMPLER=0 VLLM_NO_USAGE_STATS=1 TOKENIZERS_PARALLELISM=false HF_HUB_OFFLINE=1
13
  exec "$VENV/bin/wald-serve-native-vision" --model "$MODEL" --port "${PORT:-8000}" ${EFFORT:+--effort "$EFFORT"} ${PROMPT_FORMAT:+--prompt-format "$PROMPT_FORMAT"}
server/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ ## 0.2.2 (2026-10-13)
4
+
5
+ - Video input on `wald-serve-native-vision` (`video.py`): `videos` (d3 shape) or `video_url` / `video` message parts;
6
+ data URI, raw base64, http(s) URL (`--video-urls off|public|any`, default public) or a path under `--video-dir`.
7
+ - `video_route`: `native32` (default; 32 frames as one Qwen3.5 video with frame metadata) or `frames16` (16 labelled
8
+ frames down the image route, the evaluated client protocol). One decode per request, LRU across requests
9
+ (`--video-cache-mb`), and for `native32` one vLLM video processing per request (multimodal uuid; frames re-sent on a
10
+ vLLM cache miss).
11
+ - Limits: one video per request, 32 MB, 300 s, 3840 × 2160 source frames; 400 errors with a reason prefix. Images and
12
+ video together use `frames16`.
13
+ - `--model` launches vLLM with `--limit-mm-per-prompt {"image": 32, "video": 2}` (`--no-video` keeps serving.json's).
14
+ - `--vllm` mode: the temperature table's hash pin comes from the table's directory (serving.json, else SHA256SUMS,
15
+ else the frozen v2.1 hash); the check itself is unchanged.
16
+ - Requests without video are answered byte-identically to 0.2.1 (16 recorded 0.2.1 responses replayed in
17
+ `tests/test_video.py`). New extra `video` (OpenCV, numpy).
18
+
19
+ ## 0.2.1
20
+
21
+ - Noul decisive floor (`noul_decisive_floor`, serving.json 0.81).
server/README.md CHANGED
@@ -26,3 +26,50 @@ the same floor as in vLLM. llama.cpp's context is `--max-model-len` × `--llama-
26
  Tests (no model, no GPU; a fake server stands in for vLLM and llama-server): `pip install ".[test]" && cd tests && pytest -q`.
27
  `tests/test_parity.py` compares prompts and answers byte for byte with the reference implementation the published
28
  numbers were measured with; it is skipped unless that implementation is importable (WALD_PARITY_SRC set).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  Tests (no model, no GPU; a fake server stands in for vLLM and llama-server): `pip install ".[test]" && cd tests && pytest -q`.
27
  `tests/test_parity.py` compares prompts and answers byte for byte with the reference implementation the published
28
  numbers were measured with; it is skipped unless that implementation is importable (WALD_PARITY_SRC set).
29
+
30
+ ## Video input (0.2.2, `wald-serve-native-vision`)
31
+
32
+ Install with the decoder: `pip install ".[vllm,video]"` (OpenCV; vLLM already depends on it). A request may carry **one
33
+ video**, either as a top-level `videos` list (the Decision 3.0 / d3 request shape) or as an OpenAI-style message part:
34
+
35
+ ```sh
36
+ curl -s localhost:8000/v1/systemone -H 'Content-Type: application/json' -d '{
37
+ "state": "Aisle camera, last 15 seconds.",
38
+ "videos": ["data:video/mp4;base64,AAAAIGZ0eXBpc29t..."],
39
+ "questions": {"conceal": {"type": "noul", "instructions": "Does the shopper put an item into a bag or pocket?"}}}'
40
+
41
+ # the same video inside the state, at its position among the text parts
42
+ {"state": {"messages": [{"role": "user", "content": [
43
+ {"type": "text", "text": "Aisle camera."},
44
+ {"type": "video_url", "video_url": {"url": "data:video/mp4;base64,..."}}]}]},
45
+ "video_route": "frames16", "questions": {...}}
46
+ ```
47
+
48
+ - **Sources:** a `data:video/{mp4,quicktime,webm,x-matroska,x-msvideo,mpeg,x-m4v};base64,` URI, raw base64, an http(s)
49
+ URL (`--video-urls public`, the default, fetches only from public addresses; `off` refuses URLs; `any` allows every
50
+ host), or a local path / `file://` URL inside `--video-dir DIR` (refused without that flag).
51
+ - **`video_route`** (per request; server default `--video-route`, default `native32`):
52
+ - `native32`: 32 frames spread evenly over the whole clip, given to vLLM as one Qwen3.5 video with its timestamps
53
+ (`<t seconds>` per frame pair), 131,072 px per frame.
54
+ - `frames16`: 16 frames spread evenly, each ≤ 131,072 px, sent as 16 images, each after a text label
55
+ `Frame k/16 at t = S s:`. This is exactly the client protocol of the published VisualDecisionBench-video read.
56
+ - **Decode once:** the server decodes the video once per request and every question reuses the frames; with `native32`
57
+ vLLM also processes the video once (later questions reference it by a content uuid). An LRU of decoded videos
58
+ (`--video-cache-mb`, default 512, keyed by the sha256 of the bytes) serves repeat requests on the same video.
59
+ - **Images and video together:** `frames16` only. A request with images and no `video_route` uses `frames16` and says
60
+ so (`"video_route_fallback": "images in the request"`); `"video_route": "native32"` with images is refused (400),
61
+ because vLLM's per-request pixel budget applies to images as well.
62
+ - **Limits (400 with a reason prefix):** one video per request (`video_count`); ≤ 32 MB (`--max-video-mb`,
63
+ `video_too_large`); ≤ 300 s (`--max-video-seconds`, `video_too_long`); ≤ 3840 × 2160 source frames (`video_pixels`);
64
+ MP4 / MOV, WebM / MKV, AVI or MPEG containers that OpenCV's FFmpeg backend opens (`video_format`, `video_malformed`);
65
+ request bodies ≤ 64 MB. Longer clips are thinned, not refused: 32 frames over 300 s is one frame every ~9.7 s. For
66
+ event timing in long streams, send the window you care about.
67
+ - **Response:** `videos` (count), `video_route`, `video_frames` (frames used per video) and `images` (the caller's own
68
+ images; frames16 frames are not counted). `usage.input_tokens` counts text tokens only, as for images.
69
+ - **vLLM:** `--model` starts vLLM with `--limit-mm-per-prompt {"image": 32, "video": 2}` (16 frames next to up to 16
70
+ images; a repeated state holds its video twice). `--no-video` keeps serving.json's limits and refuses videos. With
71
+ `--vllm URL`, start that vLLM with the same limit.
72
+ - **Calibration:** unchanged. Video questions use the frozen image-route reader, prompt and temperature table.
73
+ `--vllm` mode now takes the table's hash pin from the table's own directory (sibling `serving.json`
74
+ `temperature_sha256`, else its `SHA256SUMS` line, else the frozen v2.1 hash), so an attached front-end can serve a
75
+ checkpoint with its own table. The hash check still runs.
server/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
  [project]
2
  name = "wald-serve"
3
- version = "0.2.1"
4
  description = "TypeSafe POST /v1/systemone server for Wald-4B: v2 native chat reader (Auto 0.7, frozen calibration) over vLLM; v1.x option-letter readout over vLLM or llama.cpp"
5
  readme = "README.md"
6
  license = { text = "Apache-2.0" }
@@ -9,6 +9,7 @@ dependencies = ["pydantic>=2.5"]
9
 
10
  [project.optional-dependencies]
11
  vllm = ["vllm==0.30.0", "transformers==5.17.0"]
 
12
  test = ["pytest>=8"]
13
 
14
  [project.scripts]
 
1
  [project]
2
  name = "wald-serve"
3
+ version = "0.2.2"
4
  description = "TypeSafe POST /v1/systemone server for Wald-4B: v2 native chat reader (Auto 0.7, frozen calibration) over vLLM; v1.x option-letter readout over vLLM or llama.cpp"
5
  readme = "README.md"
6
  license = { text = "Apache-2.0" }
 
9
 
10
  [project.optional-dependencies]
11
  vllm = ["vllm==0.30.0", "transformers==5.17.0"]
12
+ video = ["opencv-python-headless>=4.9", "numpy>=1.24"]
13
  test = ["pytest>=8"]
14
 
15
  [project.scripts]
server/src/wald_serve/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
  """Wald-4B serving: TypeSafe `POST /v1/systemone`. v2 (04400-c18): native chat reader (`wald-serve-native`); v1.x: paren letter readout over vLLM or llama.cpp (`wald-serve`)."""
2
- __version__ = "0.2.1"
 
1
  """Wald-4B serving: TypeSafe `POST /v1/systemone`. v2 (04400-c18): native chat reader (`wald-serve-native`); v1.x: paren letter readout over vLLM or llama.cpp (`wald-serve`)."""
2
+ __version__ = "0.2.2"
server/src/wald_serve/native.py CHANGED
@@ -200,6 +200,27 @@ def handler(engine):
200
  return H
201
 
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  def noul_floor(a, cfg):
204
  return a.noul_decisive_floor if a.noul_decisive_floor is not None else cfg.get('noul_decisive_floor')
205
 
@@ -272,7 +293,8 @@ def main(argv=None):
272
  from .native_llamacpp import LlamaBridge
273
  bridge = LlamaBridge(endpoint, a.tokenizer_dir)
274
  engine = NativeV2(endpoint, temps, served, effort, fmt, a.max_model_len, llama_bridge=bridge, checkpoint=checkpoint,
275
- model_sha256=cfg.get('model_sha256', MODEL_SHA), temperature_sha256=cfg.get('temperature_sha256', TEMPERATURE_SHA),
 
276
  noul_decisive_floor=noul_floor(a, cfg))
277
  ThreadingHTTPServer.daemon_threads = True
278
  httpd = ThreadingHTTPServer((a.host, a.port), handler(engine))
 
200
  return H
201
 
202
 
203
+ def table_pin(table, model_dir=None):
204
+ """The sha256 a temperature table must have. With --model, serving.json's `temperature_sha256` is used (the caller
205
+ passes it); without one (attached to a running vLLM, --vllm), the pin comes from the table's own directory: the
206
+ sibling serving.json's `temperature_sha256` when it names this file, else the file's line in the sibling SHA256SUMS,
207
+ else the frozen v2.1 table. The hash check itself always runs, so a table that differs from its manifest is refused."""
208
+ t = Path(table); d = t.parent
209
+ if model_dir is None:
210
+ s = d / 'serving.json'
211
+ if s.is_file():
212
+ cfg = json.loads(s.read_text())
213
+ if cfg.get('temperature_sha256') and cfg.get('temperature', 'temperature.json') == t.name:
214
+ return cfg['temperature_sha256']
215
+ m = d / 'SHA256SUMS'
216
+ if m.is_file():
217
+ for line in m.read_text().splitlines():
218
+ h, _, name = line.partition(' ')
219
+ if name.strip().lstrip('*') == t.name and len(h.strip()) == 64:
220
+ return h.strip()
221
+ return TEMPERATURE_SHA
222
+
223
+
224
  def noul_floor(a, cfg):
225
  return a.noul_decisive_floor if a.noul_decisive_floor is not None else cfg.get('noul_decisive_floor')
226
 
 
293
  from .native_llamacpp import LlamaBridge
294
  bridge = LlamaBridge(endpoint, a.tokenizer_dir)
295
  engine = NativeV2(endpoint, temps, served, effort, fmt, a.max_model_len, llama_bridge=bridge, checkpoint=checkpoint,
296
+ model_sha256=cfg.get('model_sha256', MODEL_SHA),
297
+ temperature_sha256=cfg.get('temperature_sha256') or table_pin(temps, a.model or a.gguf or a.tokenizer_dir),
298
  noul_decisive_floor=noul_floor(a, cfg))
299
  ThreadingHTTPServer.daemon_threads = True
300
  httpd = ThreadingHTTPServer((a.host, a.port), handler(engine))
server/src/wald_serve/native_vision.py CHANGED
@@ -10,7 +10,11 @@ carried to vLLM's chat route at their position in the state). Differences from n
10
  sidecar of this server, so no outside caller can send a template);
11
  * request bodies up to 64 MB (images are base64);
12
  * image requests default to `prompt_format` `plain` (serving.json `vision.image_prompt_format`), text requests keep the
13
- server default.
 
 
 
 
14
 
15
  wald-serve-native-vision --model /path/to/Wald-4B-vision --port 8000
16
  """
@@ -24,23 +28,39 @@ import threading
24
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
25
  from pathlib import Path
26
 
27
- from . import native, vision_v2
28
 
29
  VISION_VLLM_ARGS = ["--limit-mm-per-prompt", '{"image": 16, "video": 0}',
30
  "--mm-processor-kwargs", '{"size": {"shortest_edge": 65536, "longest_edge": 1048576}}',
31
  "--trust-request-chat-template"] # the image route sends its verbatim template; vLLM listens on loopback only
32
  MAX_BODY = 64 * 1024 * 1024
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
 
35
  class NativeV2Vision(native.NativeV2):
36
- def __init__(self, *args, image_prompt_format="plain", max_images=vision_v2.MAX_IMAGES, **kw):
37
  super().__init__(*args, **kw)
 
 
38
  if image_prompt_format not in native.FORMATS:
39
  raise ValueError(f"image_prompt_format must be one of {sorted(native.FORMATS)}")
40
  self.image_prompt_format, self.max_images = image_prompt_format, max_images
41
  self.vidle = {f: [] for f in native.FORMATS}
42
  self.info.update(vision=True, image_prompt_format=image_prompt_format, max_images=max_images,
43
- image_transports=["state data URIs / messages image parts", "state raw base64 keys", "top-level images[]"])
 
44
 
45
  def _vborrow(self, fmt):
46
  with self.lock:
@@ -51,7 +71,8 @@ class NativeV2Vision(native.NativeV2):
51
  return client, sov
52
 
53
  def __call__(self, request):
54
- if not (isinstance(request, dict) and vision_v2.has_images(request)):
 
55
  return super().__call__(request)
56
  if not {"state", "questions"} <= request.keys():
57
  raise ValueError("Expected state and questions")
@@ -61,7 +82,13 @@ class NativeV2Vision(native.NativeV2):
61
  raise ValueError(f"effort must be one of {sorted(native.POLICIES)} for Wald-4B v2")
62
  if fmt not in native.FORMATS:
63
  raise ValueError(f"prompt_format must be one of {sorted(native.FORMATS)}")
64
- req, uris, pat = vision_v2.lift(request, self.max_images)
 
 
 
 
 
 
65
  client, sov = self._vborrow(fmt)
66
  client.bind(uris, pat)
67
  try:
@@ -76,7 +103,16 @@ class NativeV2Vision(native.NativeV2):
76
  for value in response["answers"].values():
77
  value.pop("raw", None)
78
  native.decisive_noul(response["answers"], self.noul_floor)
79
- response["images"] = len(uris)
 
 
 
 
 
 
 
 
 
80
  return response
81
 
82
 
@@ -116,6 +152,15 @@ def main(argv=None):
116
  ap.add_argument("--gpu-memory-utilization", type=float, default=None); ap.add_argument("--vllm-args", default="")
117
  ap.add_argument("--noul-decisive-floor", type=float, default=None,
118
  help="Noul P(yes) inside (1-f, f) moves to the nearer edge after calibration; 0 = off (default: serving.json, else off)")
 
 
 
 
 
 
 
 
 
119
  ap.add_argument("--host", default="0.0.0.0"); ap.add_argument("--port", type=int, default=8000)
120
  a = ap.parse_args(argv)
121
  cfg = native.read_serving(a.model)
@@ -126,6 +171,9 @@ def main(argv=None):
126
  temps = a.temperature or (str(Path(a.model) / cfg.get("temperature", "temperature.json")) if a.model else None)
127
  if not temps or not Path(temps).is_file():
128
  raise SystemExit(f"temperature table not found: {temps}")
 
 
 
129
  if a.model:
130
  from .server import launch
131
  args = list(native.VLLM_ARGS)
@@ -133,15 +181,16 @@ def main(argv=None):
133
  if a.gpu_memory_utilization is not None: args[3] = str(a.gpu_memory_utilization)
134
  os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
135
  cmd = [sys.executable, "-m", "vllm.entrypoints.openai.api_server", "--model", a.model, "--served-model-name", served,
136
- "--host", "127.0.0.1", "--port", str(a.vllm_port), *args, *vcfg.get("vllm_args", VISION_VLLM_ARGS), *shlex.split(a.vllm_args)]
 
137
  launch(cmd, a.vllm_port, "vLLM")
138
  endpoint = f"http://127.0.0.1:{a.vllm_port}"
139
  else:
140
  endpoint = a.vllm
141
  engine = NativeV2Vision(endpoint, temps, served, effort, fmt, a.max_model_len, checkpoint=checkpoint,
142
  model_sha256=cfg.get("model_sha256", native.MODEL_SHA),
143
- temperature_sha256=cfg.get("temperature_sha256", native.TEMPERATURE_SHA), image_prompt_format=ifmt,
144
- noul_decisive_floor=native.noul_floor(a, cfg))
145
  ThreadingHTTPServer.daemon_threads = True
146
  httpd = ThreadingHTTPServer((a.host, a.port), handler(engine))
147
  print(json.dumps({"serving": engine.info, "port": a.port}), flush=True)
 
10
  sidecar of this server, so no outside caller can send a template);
11
  * request bodies up to 64 MB (images are base64);
12
  * image requests default to `prompt_format` `plain` (serving.json `vision.image_prompt_format`), text requests keep the
13
+ server default;
14
+ * video (0.2.2, video.py): one video per request (`videos` or a `video_url` message part), decoded once by this server,
15
+ `video_route` frames16 (16 labelled frames down the image route) or native32 (one Qwen3.5 video of 32 frames). vLLM
16
+ is started with `--limit-mm-per-prompt {"image": 32, "video": 2}` so that 16 frames fit next to 16 images and a
17
+ repeated state can hold its video twice; `--no-video` keeps serving.json's limits and refuses videos.
18
 
19
  wald-serve-native-vision --model /path/to/Wald-4B-vision --port 8000
20
  """
 
28
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
29
  from pathlib import Path
30
 
31
+ from . import native, video, vision_v2
32
 
33
  VISION_VLLM_ARGS = ["--limit-mm-per-prompt", '{"image": 16, "video": 0}',
34
  "--mm-processor-kwargs", '{"size": {"shortest_edge": 65536, "longest_edge": 1048576}}',
35
  "--trust-request-chat-template"] # the image route sends its verbatim template; vLLM listens on loopback only
36
  MAX_BODY = 64 * 1024 * 1024
37
+ VIDEO_LIMITS = {"image": 32, "video": 2}
38
+
39
+
40
+ def video_vllm_args(args):
41
+ """Raise `--limit-mm-per-prompt` to at least VIDEO_LIMITS (other arguments unchanged)."""
42
+ args = list(args)
43
+ if "--limit-mm-per-prompt" in args:
44
+ i = args.index("--limit-mm-per-prompt") + 1
45
+ lim = json.loads(args[i])
46
+ args[i] = json.dumps({**lim, **{k: max(int(lim.get(k, 0)), v) for k, v in VIDEO_LIMITS.items()}})
47
+ else:
48
+ args += ["--limit-mm-per-prompt", json.dumps(VIDEO_LIMITS)]
49
+ return args
50
 
51
 
52
  class NativeV2Vision(native.NativeV2):
53
+ def __init__(self, *args, image_prompt_format="plain", max_images=vision_v2.MAX_IMAGES, video_config=None, **kw):
54
  super().__init__(*args, **kw)
55
+ self.video = video_config if video_config is not None else video.VideoConfig()
56
+ self.video_cache = video.FrameCache(self.video.cache_mb) if self.video.cache_mb > 0 else None
57
  if image_prompt_format not in native.FORMATS:
58
  raise ValueError(f"image_prompt_format must be one of {sorted(native.FORMATS)}")
59
  self.image_prompt_format, self.max_images = image_prompt_format, max_images
60
  self.vidle = {f: [] for f in native.FORMATS}
61
  self.info.update(vision=True, image_prompt_format=image_prompt_format, max_images=max_images,
62
+ image_transports=["state data URIs / messages image parts", "state raw base64 keys", "top-level images[]"],
63
+ video=self.video.info())
64
 
65
  def _vborrow(self, fmt):
66
  with self.lock:
 
71
  return client, sov
72
 
73
  def __call__(self, request):
74
+ vids = video.has_videos(request)
75
+ if not (isinstance(request, dict) and (vision_v2.has_images(request) or vids)):
76
  return super().__call__(request)
77
  if not {"state", "questions"} <= request.keys():
78
  raise ValueError("Expected state and questions")
 
82
  raise ValueError(f"effort must be one of {sorted(native.POLICIES)} for Wald-4B v2")
83
  if fmt not in native.FORMATS:
84
  raise ValueError(f"prompt_format must be one of {sorted(native.FORMATS)}")
85
+ prep = None
86
+ if vids:
87
+ prep = video.prepare(request, self.video, self.video_cache, vision_v2.has_images(request))
88
+ frames = sum(len(d.frames) for d in prep.videos) if prep.route == "frames16" else 0
89
+ req, uris, pat = vision_v2.lift(prep.request, self.max_images + frames, prep.markers or None)
90
+ else:
91
+ req, uris, pat = vision_v2.lift(request, self.max_images)
92
  client, sov = self._vborrow(fmt)
93
  client.bind(uris, pat)
94
  try:
 
103
  for value in response["answers"].values():
104
  value.pop("raw", None)
105
  native.decisive_noul(response["answers"], self.noul_floor)
106
+ if prep is None:
107
+ response["images"] = len(uris)
108
+ else:
109
+ frames = sum(len(d.frames) for d in prep.videos) if prep.route == "frames16" else 0
110
+ response["images"] = sum(isinstance(u, str) for u in uris) - frames
111
+ response["videos"] = len(prep.videos)
112
+ response["video_route"] = prep.route
113
+ response["video_frames"] = [len(d.frames) for d in prep.videos]
114
+ if prep.fallback:
115
+ response["video_route_fallback"] = prep.fallback
116
  return response
117
 
118
 
 
152
  ap.add_argument("--gpu-memory-utilization", type=float, default=None); ap.add_argument("--vllm-args", default="")
153
  ap.add_argument("--noul-decisive-floor", type=float, default=None,
154
  help="Noul P(yes) inside (1-f, f) moves to the nearer edge after calibration; 0 = off (default: serving.json, else off)")
155
+ ap.add_argument("--video-route", default=None, choices=video.ROUTES,
156
+ help=f"default route for video requests (default: serving.json vision.video_route, else {video.DEFAULT_ROUTE})")
157
+ ap.add_argument("--video-dir", default=None, help="accept local video paths / file:// URLs under this directory (default: refused)")
158
+ ap.add_argument("--video-urls", default="public", choices=video.URL_POLICIES,
159
+ help="http(s) video URLs: off, public (refuse loopback / private / link-local hosts; default) or any")
160
+ ap.add_argument("--video-cache-mb", type=int, default=512, help="LRU of decoded frames, by content hash (0 = off)")
161
+ ap.add_argument("--max-video-mb", type=float, default=video.MAX_VIDEO_BYTES / 1e6)
162
+ ap.add_argument("--max-video-seconds", type=float, default=video.MAX_VIDEO_SECONDS)
163
+ ap.add_argument("--no-video", action="store_true", help="refuse video input and keep serving.json's vLLM limits")
164
  ap.add_argument("--host", default="0.0.0.0"); ap.add_argument("--port", type=int, default=8000)
165
  a = ap.parse_args(argv)
166
  cfg = native.read_serving(a.model)
 
171
  temps = a.temperature or (str(Path(a.model) / cfg.get("temperature", "temperature.json")) if a.model else None)
172
  if not temps or not Path(temps).is_file():
173
  raise SystemExit(f"temperature table not found: {temps}")
174
+ vconf = video.VideoConfig(enabled=not a.no_video, default_route=a.video_route or vcfg.get("video_route", video.DEFAULT_ROUTE),
175
+ video_dir=a.video_dir, url_policy=a.video_urls, max_bytes=int(a.max_video_mb * 1e6),
176
+ max_seconds=a.max_video_seconds, cache_mb=a.video_cache_mb)
177
  if a.model:
178
  from .server import launch
179
  args = list(native.VLLM_ARGS)
 
181
  if a.gpu_memory_utilization is not None: args[3] = str(a.gpu_memory_utilization)
182
  os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
183
  cmd = [sys.executable, "-m", "vllm.entrypoints.openai.api_server", "--model", a.model, "--served-model-name", served,
184
+ "--host", "127.0.0.1", "--port", str(a.vllm_port), *args,
185
+ *(video_vllm_args if vconf.enabled else list)(vcfg.get("vllm_args", VISION_VLLM_ARGS)), *shlex.split(a.vllm_args)]
186
  launch(cmd, a.vllm_port, "vLLM")
187
  endpoint = f"http://127.0.0.1:{a.vllm_port}"
188
  else:
189
  endpoint = a.vllm
190
  engine = NativeV2Vision(endpoint, temps, served, effort, fmt, a.max_model_len, checkpoint=checkpoint,
191
  model_sha256=cfg.get("model_sha256", native.MODEL_SHA),
192
+ temperature_sha256=cfg.get("temperature_sha256") or native.table_pin(temps, a.model),
193
+ image_prompt_format=ifmt, noul_decisive_floor=native.noul_floor(a, cfg), video_config=vconf)
194
  ThreadingHTTPServer.daemon_threads = True
195
  httpd = ThreadingHTTPServer((a.host, a.port), handler(engine))
196
  print(json.dumps({"serving": engine.info, "port": a.port}), flush=True)
server/src/wald_serve/video.py ADDED
@@ -0,0 +1,534 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Video input for `wald-serve-native-vision` (wald-serve 0.2.2).
2
+
3
+ A request may carry one video: a top-level `videos` list (d3 / Decision 3.0 shape) or a `state.messages` content part
4
+ `{"type": "video_url", "video_url": {"url": ...}}` (OpenAI shape; `{"type": "video", "video": ...}` also works). A video
5
+ is a `data:video/...;base64,` URI, raw base64, an http(s) URL (`--video-urls`), or a local path / `file://` URL under
6
+ `--video-dir`. The server decodes it ONCE per request and every question of the request reuses the frames; an LRU
7
+ cache keyed by the content hash reuses them across requests on the same video.
8
+
9
+ Two routes (`video_route`, per request; server default `--video-route`):
10
+
11
+ frames16 16 frames at np.linspace over the decoded frame count, each <= 131,072 px (INTER_AREA), JPEG q95, each
12
+ preceded by a text part "Frame k/16 at t = S s:". This is byte for byte the client protocol of the
13
+ evaluated VisualDecisionBench-video reads (evaluation/v2.1/video.json): the frames go down the
14
+ unchanged image route as 16 images.
15
+ native32 32 frames at np.linspace(0, total - 1, 32).round() (vLLM's qwen3_vl loader at min = max = 32 frames), read
16
+ sequentially with OpenCV, JPEG q95, handed to vLLM as ONE Qwen3.5 video (`data:video/jpeg` frame list) with
17
+ its metadata (source fps, frame indices, frame count, duration), so vLLM writes the same `<t seconds>`
18
+ timestamps and pairs frames (temporal patch 2) as for the mp4 itself; pixel budget 32 x 131,072 px. The first
19
+ question of a request sends the frames with a content uuid; the other questions send only the uuid, so
20
+ vLLM reuses its processed video (it re-sends the frames if vLLM's cache no longer holds them). The first
21
+ native-video read sent the mp4 on every question, so vLLM decoded and processed it 20-30 times per request.
22
+
23
+ The prompt, the reader, the readout and the calibration table are those of the image route: only the media differ.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import base64
28
+ import binascii
29
+ import hashlib
30
+ import ipaddress
31
+ import json
32
+ import os
33
+ import re
34
+ import socket
35
+ import tempfile
36
+ import threading
37
+ import urllib.parse
38
+ import urllib.request
39
+ from collections import OrderedDict
40
+ from dataclasses import dataclass, field
41
+ from functools import cached_property
42
+ from pathlib import Path
43
+
44
+ ROUTES = ("frames16", "native32")
45
+ DEFAULT_ROUTE = "native32"
46
+ FRAMES16 = 16 # frames16: frame count and per-frame pixel cap (the evaluated client protocol)
47
+ FRAMES16_PX = 131072
48
+ NATIVE_FRAMES = 32 # native32: frames per video and per-frame pixel budget given to the Qwen3.5 video processor
49
+ NATIVE_PX = 131072
50
+ NATIVE_MIN_PX = 4096
51
+ NATIVE_PRE_PX = 1048576 # native32 frames above this are downscaled (INTER_AREA) before JPEG; the processor then resizes to <= NATIVE_PX
52
+ JPEG_QUALITY = 95
53
+ MAX_VIDEO_BYTES = 32_000_000
54
+ MAX_VIDEO_SECONDS = 300.0
55
+ MAX_SOURCE_PIXELS = 8_294_400 # 3840 x 2160
56
+ MAX_VIDEOS = 1
57
+ URL_POLICIES = ("off", "public", "any")
58
+ VIDEO_MIME = {"mp4": "video/mp4", "quicktime": "video/quicktime", "webm": "video/webm", "x-matroska": "video/x-matroska",
59
+ "avi": "video/x-msvideo", "x-msvideo": "video/x-msvideo", "mpeg": "video/mpeg", "x-m4v": "video/x-m4v"}
60
+ DATA_VIDEO = re.compile(r"data:video/([A-Za-z0-9.+-]+);base64,(.*)", re.S)
61
+ _B64 = re.compile(r"[A-Za-z0-9+/=\s]+")
62
+ PART_TYPES = ("video_url", "video", "input_video")
63
+
64
+
65
+ class VideoError(ValueError):
66
+ """A video input the server refuses (HTTP 400). `reason` is one of: video_format, video_too_large, video_too_long,
67
+ video_pixels, video_malformed, video_count, video_source, video_route, video_unavailable."""
68
+
69
+ def __init__(self, reason, message):
70
+ super().__init__(f"{reason}: {message}")
71
+ self.reason = reason
72
+
73
+
74
+ def sniff(head: bytes):
75
+ """Container from the first bytes: ISO-BMFF (mp4 / mov / m4v), Matroska / WebM, AVI, MPEG-PS / TS; else None."""
76
+ if len(head) >= 12 and head[4:8] == b"ftyp":
77
+ return "mp4"
78
+ if head[:4] == b"\x1aE\xdf\xa3":
79
+ return "webm"
80
+ if head[:4] == b"RIFF" and head[8:12] == b"AVI ":
81
+ return "avi"
82
+ if head[:4] in (b"\x00\x00\x01\xba", b"\x00\x00\x01\xb3") or head[:1] == b"G" and len(head) > 188 and head[188:189] == b"G":
83
+ return "mpeg"
84
+ return None
85
+
86
+
87
+ @dataclass
88
+ class VideoConfig:
89
+ enabled: bool = True
90
+ default_route: str = DEFAULT_ROUTE
91
+ video_dir: str | None = None # local paths / file:// URLs are accepted only under this directory
92
+ url_policy: str = "public" # off | public (refuse loopback / private / link-local hosts) | any
93
+ max_bytes: int = MAX_VIDEO_BYTES
94
+ max_seconds: float = MAX_VIDEO_SECONDS
95
+ max_source_pixels: int = MAX_SOURCE_PIXELS
96
+ max_videos: int = MAX_VIDEOS
97
+ cache_mb: int = 512
98
+ url_timeout: float = 30.0
99
+
100
+ def __post_init__(self):
101
+ if self.default_route not in ROUTES:
102
+ raise ValueError(f"video route must be one of {ROUTES}")
103
+ if self.url_policy not in URL_POLICIES:
104
+ raise ValueError(f"video URL policy must be one of {URL_POLICIES}")
105
+ if self.video_dir is not None:
106
+ self.video_dir = os.path.realpath(self.video_dir)
107
+
108
+ def info(self):
109
+ return {"enabled": self.enabled, "routes": list(ROUTES), "default_route": self.default_route,
110
+ "max_videos": self.max_videos, "max_bytes": self.max_bytes, "max_seconds": self.max_seconds,
111
+ "max_source_pixels": self.max_source_pixels, "url_policy": self.url_policy,
112
+ "local_paths": self.video_dir is not None, "cache_mb": self.cache_mb,
113
+ "transports": ["videos[]", "state.messages video_url / video parts"],
114
+ "frames16": {"frames": FRAMES16, "max_pixels_per_frame": FRAMES16_PX},
115
+ "native32": {"frames": NATIVE_FRAMES, "pixels_per_frame": NATIVE_PX}}
116
+
117
+
118
+ # ---------------------------------------------------------------------------------------------------------- sources
119
+
120
+ def _public_host(host):
121
+ try:
122
+ infos = socket.getaddrinfo(host, None)
123
+ except socket.gaierror as e:
124
+ raise VideoError("video_source", f"cannot resolve {host!r}") from e
125
+ for info in infos:
126
+ ip = ipaddress.ip_address(info[4][0].split("%")[0])
127
+ if not ip.is_global or ip.is_multicast:
128
+ raise VideoError("video_source", f"URL host {host!r} is not a public address (server --video-urls public)")
129
+
130
+
131
+ class _Redirects(urllib.request.HTTPRedirectHandler):
132
+ def __init__(self, policy):
133
+ self.policy = policy
134
+
135
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
136
+ _check_url(newurl, self.policy)
137
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
138
+
139
+
140
+ def _check_url(url, policy):
141
+ u = urllib.parse.urlsplit(url)
142
+ if u.scheme not in ("http", "https") or not u.hostname:
143
+ raise VideoError("video_source", "only http(s) URLs can be fetched")
144
+ if policy == "public":
145
+ _public_host(u.hostname)
146
+
147
+
148
+ def fetch_url(url, cfg: VideoConfig) -> bytes:
149
+ if cfg.url_policy == "off":
150
+ raise VideoError("video_source", "video URLs are disabled on this server (--video-urls off); send a data URI")
151
+ _check_url(url, cfg.url_policy)
152
+ opener = urllib.request.build_opener(_Redirects(cfg.url_policy))
153
+ try:
154
+ with opener.open(urllib.request.Request(url, headers={"User-Agent": "wald-serve"}), timeout=cfg.url_timeout) as r:
155
+ size = r.headers.get("Content-Length")
156
+ if size and size.isdigit() and int(size) > cfg.max_bytes:
157
+ raise VideoError("video_too_large", f"video larger than {cfg.max_bytes} bytes")
158
+ raw = r.read(cfg.max_bytes + 1)
159
+ except VideoError:
160
+ raise
161
+ except Exception as e: # noqa: BLE001
162
+ raise VideoError("video_source", f"could not fetch the video URL ({type(e).__name__})") from None
163
+ if len(raw) > cfg.max_bytes:
164
+ raise VideoError("video_too_large", f"video larger than {cfg.max_bytes} bytes")
165
+ return raw
166
+
167
+
168
+ def read_path(value, cfg: VideoConfig) -> bytes:
169
+ if cfg.video_dir is None:
170
+ raise VideoError("video_source", "local video paths are disabled on this server (start it with --video-dir DIR)")
171
+ p = urllib.parse.unquote(urllib.parse.urlsplit(value).path) if value.startswith("file://") else value
172
+ real = os.path.realpath(p if os.path.isabs(p) else os.path.join(cfg.video_dir, p))
173
+ if os.path.commonpath([real, cfg.video_dir]) != cfg.video_dir or not os.path.isfile(real):
174
+ raise VideoError("video_source", "video path is not a file under the server's --video-dir")
175
+ if os.path.getsize(real) > cfg.max_bytes:
176
+ raise VideoError("video_too_large", f"video larger than {cfg.max_bytes} bytes")
177
+ return Path(real).read_bytes()
178
+
179
+
180
+ def _raw_b64_video(value):
181
+ try:
182
+ return sniff(base64.b64decode(re.sub(r"\s+", "", value[:344]), validate=True)) is not None
183
+ except (binascii.Error, ValueError):
184
+ return False
185
+
186
+
187
+ def load(value, cfg: VideoConfig) -> bytes:
188
+ """A video reference (data URI, raw base64, http(s) URL, local path / file:// URL) -> container bytes."""
189
+ if isinstance(value, dict):
190
+ value = value.get("url") or value.get("data") or value.get("video")
191
+ if not isinstance(value, str) or not value:
192
+ raise VideoError("video_format", "a video must be a data URI, base64 string, URL or path")
193
+ m = DATA_VIDEO.fullmatch(value)
194
+ payload = raw = None
195
+ if m:
196
+ if m.group(1).lower() not in VIDEO_MIME:
197
+ raise VideoError("video_format", f"unsupported video type video/{m.group(1)}")
198
+ payload = m.group(2)
199
+ elif value.startswith("data:"):
200
+ raise VideoError("video_format", "a video data URI must be data:video/<type>;base64,...")
201
+ elif value.startswith(("http://", "https://")):
202
+ raw = fetch_url(value, cfg)
203
+ elif value.startswith("file://"):
204
+ raw = read_path(value, cfg)
205
+ elif len(value) >= 64 and _B64.fullmatch(value[:4096]) and _raw_b64_video(value):
206
+ payload = value
207
+ else:
208
+ raw = read_path(value, cfg)
209
+ if payload is not None:
210
+ if len(payload) * 3 // 4 > cfg.max_bytes + 3:
211
+ raise VideoError("video_too_large", f"video larger than {cfg.max_bytes} bytes")
212
+ try:
213
+ raw = base64.b64decode(re.sub(r"\s+", "", payload), validate=True)
214
+ except (binascii.Error, ValueError):
215
+ raise VideoError("video_format", "video data is not valid base64") from None
216
+ if len(raw) > cfg.max_bytes:
217
+ raise VideoError("video_too_large", f"video larger than {cfg.max_bytes} bytes")
218
+ if sniff(raw[:256]) is None:
219
+ raise VideoError("video_format", "not a video container this server reads (MP4 / MOV, WebM / MKV, AVI, MPEG)")
220
+ return raw
221
+
222
+
223
+ # ---------------------------------------------------------------------------------------------------------- decoding
224
+
225
+ @dataclass
226
+ class Decoded:
227
+ route: str
228
+ sha256: str
229
+ frames: list = field(default_factory=list) # JPEG bytes per sampled frame
230
+ times: list = field(default_factory=list) # seconds (None when the container has no fps)
231
+ indices: list = field(default_factory=list)
232
+ total_frames: int = 0
233
+ fps: float = 0.0
234
+ duration: float = 0.0
235
+ width: int = 0
236
+ height: int = 0
237
+
238
+ @property
239
+ def nbytes(self):
240
+ return sum(len(f) for f in self.frames)
241
+
242
+ def image_parts(self):
243
+ """frames16: the evaluated wire, text label + image part per frame (`vdb_wald.wire`)."""
244
+ out, n = [], len(self.frames)
245
+ for k, (t, b) in enumerate(zip(self.times, self.frames)):
246
+ out.append({"type": "text", "text": f"Frame {k + 1}/{n}" + (f" at t = {t:.1f} s:" if t is not None else ":")})
247
+ out.append({"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + base64.b64encode(b).decode()}})
248
+ return out
249
+
250
+ def video_url(self):
251
+ """native32: vLLM's `data:video/jpeg` frame-list URL (one video)."""
252
+ return self._video_url
253
+
254
+ @cached_property
255
+ def _video_url(self):
256
+ return "data:video/jpeg;base64," + ",".join(base64.b64encode(b).decode() for b in self.frames)
257
+
258
+ def media_io_kwargs(self):
259
+ """vLLM metadata for the frame list, as its own decoder would report it for the source file."""
260
+ return {"video": {"num_frames": len(self.frames), "fps": float(self.fps), "frames_indices": [int(i) for i in self.indices],
261
+ "total_num_frames": int(self.total_frames), "duration": float(self.duration)}}
262
+
263
+ def mm_processor_kwargs(self):
264
+ return {"max_pixels": len(self.frames) * NATIVE_PX, "min_pixels": NATIVE_MIN_PX}
265
+
266
+ @cached_property
267
+ def uuid(self):
268
+ """vLLM multimodal uuid: identifies the processed video (content hash + every input to its processing), so
269
+ later questions of a request can reference vLLM's processor cache instead of resending the frames."""
270
+ key = json.dumps([self.sha256, self.route, self.media_io_kwargs(), self.mm_processor_kwargs(), JPEG_QUALITY, NATIVE_PRE_PX])
271
+ return "wald-video-" + hashlib.sha256(key.encode()).hexdigest()
272
+
273
+
274
+ def _cv2():
275
+ try:
276
+ import cv2 # noqa: PLC0415
277
+ except ImportError:
278
+ raise VideoError("video_unavailable", "video input needs OpenCV on the server: pip install 'wald-serve[video]'") from None
279
+ return cv2
280
+
281
+
282
+ def _open(cv2, path, cfg):
283
+ cap = cv2.VideoCapture(str(path))
284
+ if not cap.isOpened():
285
+ raise VideoError("video_malformed", "the video could not be opened")
286
+ w, h = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
287
+ if w * h > cfg.max_source_pixels:
288
+ cap.release()
289
+ raise VideoError("video_pixels", f"{w}x{h} source frames: at most {cfg.max_source_pixels} pixels per frame")
290
+ return cap, w, h
291
+
292
+
293
+ def _count(cv2, path):
294
+ cap = cv2.VideoCapture(str(path)); n = 0
295
+ while cap.grab():
296
+ n += 1
297
+ cap.release()
298
+ return n
299
+
300
+
301
+ def _jpeg(cv2, frame):
302
+ ok, enc = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY])
303
+ if not ok:
304
+ raise VideoError("video_malformed", "a frame could not be encoded")
305
+ return enc.tobytes()
306
+
307
+
308
+ def _check_duration(total, fps, cfg):
309
+ if fps and fps > 0 and total / fps > cfg.max_seconds + 1e-6:
310
+ raise VideoError("video_too_long", f"{total / fps:.1f} s video: at most {cfg.max_seconds:g} s")
311
+
312
+
313
+ def decode_frames16(cv2, path, cfg, sha):
314
+ """The evaluated client's frame sampler (16 frames, <= 131,072 px, JPEG q95), with the server's limits."""
315
+ import numpy as np # noqa: PLC0415
316
+ cap, w, h = _open(cv2, path, cfg)
317
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)); fps = cap.get(cv2.CAP_PROP_FPS) or 0
318
+ if total <= 0:
319
+ # vdb_wald: no frame count in the container -> read every frame sequentially and index into them
320
+ total = _count(cv2, path)
321
+ _check_duration(total, fps, cfg)
322
+ want = set(np.linspace(0, total - 1, FRAMES16).round().astype(int).tolist()) if total > 0 else set()
323
+ got, seq = {}, cv2.VideoCapture(str(path))
324
+ for i in range(max(want) + 1 if want else 0):
325
+ ok, f = seq.read()
326
+ if not ok:
327
+ break
328
+ if i in want:
329
+ got[i] = f
330
+ seq.release()
331
+ get = got.get
332
+ else:
333
+ _check_duration(total, fps, cfg)
334
+
335
+ def get(i):
336
+ cap.set(cv2.CAP_PROP_POS_FRAMES, int(i)); ok, f = cap.read()
337
+ return f if ok else None
338
+ out = Decoded("frames16", sha, total_frames=total, fps=float(fps), duration=total / fps if fps else 0.0, width=w, height=h)
339
+ idx = np.linspace(0, total - 1, FRAMES16).round().astype(int) if total > 0 else []
340
+ for i in idx:
341
+ f = get(int(i))
342
+ if f is None:
343
+ continue
344
+ fh, fw = f.shape[:2]; s = min(1.0, (FRAMES16_PX / (fh * fw)) ** 0.5)
345
+ if s < 1:
346
+ f = cv2.resize(f, (max(28, int(fw * s)), max(28, int(fh * s))), interpolation=cv2.INTER_AREA)
347
+ out.frames.append(_jpeg(cv2, f)); out.indices.append(int(i)); out.times.append(float(i) / fps if fps else None)
348
+ cap.release()
349
+ if not out.frames:
350
+ raise VideoError("video_malformed", "no frame could be decoded")
351
+ return out
352
+
353
+
354
+ def decode_native(cv2, path, cfg, sha, n=NATIVE_FRAMES):
355
+ """vLLM 0.30's qwen3_vl loader over OpenCV at min_frames = max_frames = n: frame count from the container (checked
356
+ against an end-of-stream seek, as vLLM does), indices np.linspace(0, total - 1, min(n, total)).round(), frames read
357
+ by sequential grab / retrieve."""
358
+ import numpy as np # noqa: PLC0415
359
+ cap, w, h = _open(cv2, path, cfg)
360
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)); fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
361
+ if total > 0 and cap.set(cv2.CAP_PROP_POS_AVI_RATIO, 1.0):
362
+ visible = int(cap.get(cv2.CAP_PROP_POS_FRAMES)); at_end = not cap.grab()
363
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
364
+ if at_end and 0 < visible < total - 1:
365
+ total = visible
366
+ if total <= 0:
367
+ cap.release(); total = _count(cv2, path); cap, w, h = _open(cv2, path, cfg)
368
+ if total <= 0:
369
+ cap.release()
370
+ raise VideoError("video_malformed", "no frame could be decoded")
371
+ _check_duration(total, fps, cfg)
372
+ idx = np.linspace(0, total - 1, min(n, total)).round().astype(int).tolist()
373
+ want, frames, valid = set(idx), [], []
374
+ for i in range(max(idx) + 1):
375
+ if not cap.grab():
376
+ continue
377
+ if i in want:
378
+ ok, f = cap.retrieve()
379
+ if ok and f is not None and f.size:
380
+ fh, fw = f.shape[:2]
381
+ if fh * fw > NATIVE_PRE_PX:
382
+ s = (NATIVE_PRE_PX / (fh * fw)) ** 0.5
383
+ f = cv2.resize(f, (max(32, int(fw * s)), max(32, int(fh * s))), interpolation=cv2.INTER_AREA)
384
+ frames.append(_jpeg(cv2, f)); valid.append(i)
385
+ cap.release()
386
+ if not frames:
387
+ raise VideoError("video_malformed", "no frame could be decoded")
388
+ return Decoded("native32", sha, frames=frames, indices=valid, times=[i / fps if fps else None for i in valid],
389
+ total_frames=total, fps=float(fps) if fps else 1.0, duration=total / fps if fps else float(total), width=w, height=h)
390
+
391
+
392
+ class FrameCache:
393
+ """LRU of decoded videos keyed by (sha256 of the container bytes, route), bounded by the JPEG bytes held."""
394
+
395
+ def __init__(self, max_mb):
396
+ self.max_bytes, self.items, self.size, self.lock = int(max_mb) * 1024 * 1024, OrderedDict(), 0, threading.Lock()
397
+ self.hits = self.misses = 0
398
+
399
+ def get(self, key):
400
+ with self.lock:
401
+ v = self.items.get(key)
402
+ if v is None:
403
+ self.misses += 1
404
+ return None
405
+ self.items.move_to_end(key); self.hits += 1
406
+ return v
407
+
408
+ def put(self, key, value):
409
+ if value.nbytes > self.max_bytes:
410
+ return
411
+ with self.lock:
412
+ if key in self.items:
413
+ return
414
+ self.items[key] = value; self.size += value.nbytes
415
+ while self.size > self.max_bytes:
416
+ _, old = self.items.popitem(last=False); self.size -= old.nbytes
417
+
418
+
419
+ def decode(raw: bytes, route: str, cfg: VideoConfig, cache: FrameCache | None = None) -> Decoded:
420
+ if route not in ROUTES:
421
+ raise VideoError("video_route", f"video_route must be one of {list(ROUTES)}")
422
+ sha = hashlib.sha256(raw).hexdigest()
423
+ hit = cache.get((sha, route)) if cache is not None else None
424
+ if hit is not None:
425
+ return hit
426
+ cv2 = _cv2()
427
+ fd, path = tempfile.mkstemp(suffix=".video")
428
+ try:
429
+ with os.fdopen(fd, "wb") as f:
430
+ f.write(raw)
431
+ out = decode_frames16(cv2, path, cfg, sha) if route == "frames16" else decode_native(cv2, path, cfg, sha)
432
+ finally:
433
+ os.unlink(path)
434
+ if cache is not None:
435
+ cache.put((sha, route), out)
436
+ return out
437
+
438
+
439
+ # ---------------------------------------------------------------------------------------------------------- requests
440
+
441
+ def _is_video_part(c):
442
+ return isinstance(c, dict) and c.get("type") in PART_TYPES
443
+
444
+
445
+ def _part_value(c):
446
+ v = c.get(c["type"]) if c.get("type") in c else c.get("video_url") or c.get("video")
447
+ if isinstance(v, dict):
448
+ v = v.get("url") or v.get("data")
449
+ return v
450
+
451
+
452
+ def _message_videos(state):
453
+ if not (isinstance(state, dict) and isinstance(state.get("messages"), list)):
454
+ return []
455
+ return [c for m in state["messages"] if isinstance(m, dict) and isinstance(m.get("content"), list)
456
+ for c in m["content"] if _is_video_part(c)]
457
+
458
+
459
+ def has_videos(req) -> bool:
460
+ return isinstance(req, dict) and (bool(req.get("videos")) or bool(_message_videos(req.get("state"))))
461
+
462
+
463
+ @dataclass
464
+ class Prepared:
465
+ request: dict
466
+ route: str
467
+ videos: list # Decoded, in order
468
+ markers: dict # native32: marker text -> Decoded
469
+ fallback: str | None = None
470
+
471
+
472
+ def prepare(req: dict, cfg: VideoConfig, cache: FrameCache | None, has_user_images: bool) -> Prepared:
473
+ """Decode the request's video once and rewrite the request for the image route.
474
+
475
+ frames16: each video becomes its 16 (label, image) parts; top-level `videos` go before the state.
476
+ native32: each video becomes one marker text part that `vision_v2.lift` turns into a video slot."""
477
+ if not cfg.enabled:
478
+ raise VideoError("video_unavailable", "video input is disabled on this server")
479
+ route = req.get("video_route")
480
+ explicit = route is not None
481
+ route = route or cfg.default_route
482
+ if route not in ROUTES:
483
+ raise VideoError("video_route", f"video_route must be one of {list(ROUTES)}")
484
+ fallback = None
485
+ if route == "native32" and has_user_images:
486
+ # vLLM's per-request pixel budget is not per modality: the video's budget would also resize the images
487
+ if explicit:
488
+ raise VideoError("video_route", "native32 cannot be combined with images in one request (use frames16)")
489
+ route, fallback = "frames16", "images in the request"
490
+ top = req.get("videos")
491
+ top = [] if top in (None, []) else top if isinstance(top, list) else [top]
492
+ parts = _message_videos(req.get("state"))
493
+ if len(top) + len(parts) > cfg.max_videos:
494
+ raise VideoError("video_count", f"{len(top) + len(parts)} videos: at most {cfg.max_videos} per request")
495
+ decoded, markers = [], {}
496
+ nonce = os.urandom(4).hex()
497
+
498
+ def replacement(value):
499
+ d = decode(load(value, cfg), route, cfg, cache); decoded.append(d)
500
+ if route == "frames16":
501
+ return d.image_parts()
502
+ mark = f"⟦wald-video-{nonce}:{len(markers)}⟧"; markers[mark] = d
503
+ return [{"type": "text", "text": mark}]
504
+
505
+ state = req.get("state")
506
+ if parts:
507
+ msgs = []
508
+ for m in state["messages"]:
509
+ if isinstance(m, dict) and isinstance(m.get("content"), list):
510
+ content = []
511
+ for c in m["content"]:
512
+ content.extend(replacement(_part_value(c)) if _is_video_part(c) else [c])
513
+ m = {**m, "content": content}
514
+ msgs.append(m)
515
+ state = {**state, "messages": msgs}
516
+ if top:
517
+ head = [p for v in top for p in replacement(v)]
518
+ if isinstance(state, dict) and isinstance(state.get("messages"), list) and set(state) == {"messages"}:
519
+ msgs = list(state["messages"])
520
+ first = msgs[0] if msgs else {"role": "user", "content": []}
521
+ c = first.get("content") if isinstance(first, dict) else first
522
+ c = [{"type": "text", "text": c}] if isinstance(c, str) else list(c or [])
523
+ msgs[0 if msgs else 0:1] = [{**(first if isinstance(first, dict) else {"role": "user"}), "content": head + c}]
524
+ state = {"messages": msgs}
525
+ elif state is None or isinstance(state, str):
526
+ tail = [{"type": "text", "text": state}] if isinstance(state, str) and state.strip() else []
527
+ state = {"messages": [{"role": "user", "content": head + tail}]}
528
+ else:
529
+ # an object / array state: the video goes first under "video" as text with embedded media
530
+ block = "\n".join(p["text"] if p["type"] == "text" else p["image_url"]["url"] for p in head)
531
+ state = {"video": block, **state} if isinstance(state, dict) else {"video": block, "state": state}
532
+ out = {k: v for k, v in req.items() if k not in ("videos", "video_route")}
533
+ out["state"] = state
534
+ return Prepared(out, route, decoded, markers, fallback)
server/src/wald_serve/vision_v2.py CHANGED
@@ -24,6 +24,10 @@ Accepted image transports (at most `max_images` per request, PNG / JPEG / WebP /
24
  `{"type": "image_url", "image_url": {"url": ...}}` or `{"type": "image", "image": ...}`);
25
  * raw base64 as the value of a `state` key `image` / `image_data` / `image_base64` / `image_b64`;
26
  * a top-level `images` (or `image_data`) list of data URIs / base64 strings: placed at the start of the state, in order.
 
 
 
 
27
  """
28
  from __future__ import annotations
29
 
@@ -39,6 +43,12 @@ CHAT_TEMPLATE = ("{%- for message in messages -%}{%- for part in message['conten
39
  "{%- if part['type'] == 'text' -%}{{ part['text'] }}"
40
  "{%- else -%}<|vision_start|><|image_pad|><|vision_end|>{%- endif -%}"
41
  "{%- endfor -%}{%- endfor -%}")
 
 
 
 
 
 
42
  DATA_URI = re.compile(r"data:image/[A-Za-z0-9.+-]+;base64,[A-Za-z0-9+/=]+")
43
  RAW_KEYS = ("image", "image_data", "image_base64", "image_b64")
44
  _MAGIC = ((b"\x89PNG\r\n\x1a\n", "png"), (b"\xff\xd8\xff", "jpeg"), (b"GIF87a", "gif"), (b"GIF89a", "gif"))
@@ -100,8 +110,11 @@ def has_images(req) -> bool:
100
  return bool(req.get("images") or req.get("image_data")) or _has(req.get("state"))
101
 
102
 
103
- def lift(req: dict, max_images: int = MAX_IMAGES):
104
- """-> (request with images replaced by sentinels, data URIs by slot, sentinel regex)."""
 
 
 
105
  nonce = secrets.token_hex(6)
106
  mark = lambda i: f"⟦{nonce}:{i}⟧"
107
  uris = []
@@ -110,8 +123,15 @@ def lift(req: dict, max_images: int = MAX_IMAGES):
110
  uris.append(check_uri(uri))
111
  return mark(len(uris) - 1)
112
 
 
 
 
 
 
 
113
  def text(s):
114
- return DATA_URI.sub(lambda m: slot(m.group(0)), s) if "data:image/" in s else s
 
115
 
116
  def walk(v):
117
  if isinstance(v, str):
@@ -142,8 +162,11 @@ def lift(req: dict, max_images: int = MAX_IMAGES):
142
  if top:
143
  state = "\n".join(top + ([state] if isinstance(state, str) and state.strip() else [])) if isinstance(state, str) or state is None \
144
  else {"images": "\n".join(top), **state} if isinstance(state, dict) else "\n".join(top)
145
- if len(uris) > max_images:
146
- raise ImageError(f"{len(uris)} images: at most {max_images} per request")
 
 
 
147
  out = {"state": state, "questions": req["questions"]}
148
  return out, uris, re.compile("⟦" + nonce + r":(\d+)⟧")
149
 
@@ -182,7 +205,9 @@ def to_parts(text: str, uris, pat):
182
  raise ImageError("unknown image slot")
183
  if m.start() > pos:
184
  parts.append({"type": "text", "text": text[pos:m.start()]})
185
- parts.append({"type": "image_url", "image_url": {"url": uris[i]}}); seen.add(i); pos = m.end()
 
 
186
  if pos < len(text):
187
  parts.append({"type": "text", "text": text[pos:]})
188
  if seen != set(range(len(uris))):
@@ -194,11 +219,11 @@ def image_client(base):
194
  """Subclass the frozen reader's client class `base` so its token-id completions carry the request's images."""
195
 
196
  class ImageClient(base):
197
- uris, pat = [], None
198
  mm_calls = 0
199
 
200
  def bind(self, uris, pat):
201
- self.uris, self.pat = uris, pat
202
  return self
203
 
204
  def post(self, path, body, retries=1):
@@ -207,11 +232,17 @@ def image_client(base):
207
  text = super().post("/detokenize", {"model": self.served, "tokens": body["prompt"]}, retries)["prompt"]
208
  parts = to_parts(text, self.uris, self.pat)
209
  chat = {k: v for k, v in body.items() if k not in ("prompt", "logprobs")}
210
- chat.update(messages=[{"role": "user", "content": parts}], chat_template=CHAT_TEMPLATE, add_generation_prompt=False,
211
- add_special_tokens=False)
 
 
 
212
  if "logprobs" in body:
213
  chat.update(logprobs=True, top_logprobs=body["logprobs"])
214
- r = super().post("/v1/chat/completions", chat, retries)
 
 
 
215
  self.mm_calls += 1
216
  out = []
217
  for c in r["choices"]:
@@ -223,4 +254,25 @@ def image_client(base):
223
  out.append(o)
224
  return {"choices": out, "usage": r.get("usage") or {}}
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  return ImageClient
 
24
  `{"type": "image_url", "image_url": {"url": ...}}` or `{"type": "image", "image": ...}`);
25
  * raw base64 as the value of a `state` key `image` / `image_data` / `image_base64` / `image_b64`;
26
  * a top-level `images` (or `image_data`) list of data URIs / base64 strings: placed at the start of the state, in order.
27
+
28
+ Video (wald-serve 0.2.2, video.py) reuses this route: `frames16` turns the video into 16 labelled image parts before
29
+ `lift`; `native32` turns it into a marker that `lift` maps to a video slot, which `to_parts` writes as one `video_url`
30
+ part and `ImageClient` sends with the video template, the frame metadata and the video pixel budget.
31
  """
32
  from __future__ import annotations
33
 
 
43
  "{%- if part['type'] == 'text' -%}{{ part['text'] }}"
44
  "{%- else -%}<|vision_start|><|image_pad|><|vision_end|>{%- endif -%}"
45
  "{%- endfor -%}{%- endfor -%}")
46
+ # the same, with Qwen3.5's video placeholder for video parts; sent only when the prompt carries a native video
47
+ VIDEO_CHAT_TEMPLATE = ("{%- for message in messages -%}{%- for part in message['content'] -%}"
48
+ "{%- if part['type'] == 'text' -%}{{ part['text'] }}"
49
+ "{%- elif part['type'] in ('video', 'video_url') -%}<|vision_start|><|video_pad|><|vision_end|>"
50
+ "{%- else -%}<|vision_start|><|image_pad|><|vision_end|>{%- endif -%}"
51
+ "{%- endfor -%}{%- endfor -%}")
52
  DATA_URI = re.compile(r"data:image/[A-Za-z0-9.+-]+;base64,[A-Za-z0-9+/=]+")
53
  RAW_KEYS = ("image", "image_data", "image_base64", "image_b64")
54
  _MAGIC = ((b"\x89PNG\r\n\x1a\n", "png"), (b"\xff\xd8\xff", "jpeg"), (b"GIF87a", "gif"), (b"GIF89a", "gif"))
 
110
  return bool(req.get("images") or req.get("image_data")) or _has(req.get("state"))
111
 
112
 
113
+ def lift(req: dict, max_images: int = MAX_IMAGES, videos: dict | None = None):
114
+ """-> (request with images replaced by sentinels, data URIs by slot, sentinel regex).
115
+
116
+ `videos` (video.prepare, native32 route): marker text -> decoded video; each marker becomes a slot whose entry is
117
+ the decoded video object instead of a data URI. Videos do not count against `max_images`."""
118
  nonce = secrets.token_hex(6)
119
  mark = lambda i: f"⟦{nonce}:{i}⟧"
120
  uris = []
 
123
  uris.append(check_uri(uri))
124
  return mark(len(uris) - 1)
125
 
126
+ def vslot(m):
127
+ uris.append(videos[m.group(0)])
128
+ return mark(len(uris) - 1)
129
+
130
+ vpat = re.compile("|".join(re.escape(k) for k in videos)) if videos else None
131
+
132
  def text(s):
133
+ s = DATA_URI.sub(lambda m: slot(m.group(0)), s) if "data:image/" in s else s
134
+ return vpat.sub(vslot, s) if vpat is not None else s
135
 
136
  def walk(v):
137
  if isinstance(v, str):
 
162
  if top:
163
  state = "\n".join(top + ([state] if isinstance(state, str) and state.strip() else [])) if isinstance(state, str) or state is None \
164
  else {"images": "\n".join(top), **state} if isinstance(state, dict) else "\n".join(top)
165
+ nimg = sum(isinstance(u, str) for u in uris)
166
+ if nimg > max_images:
167
+ raise ImageError(f"{nimg} images: at most {max_images} per request")
168
+ if videos and sum(not isinstance(u, str) for u in uris) != len(videos):
169
+ raise ImageError("a video slot is missing from the state")
170
  out = {"state": state, "questions": req["questions"]}
171
  return out, uris, re.compile("⟦" + nonce + r":(\d+)⟧")
172
 
 
205
  raise ImageError("unknown image slot")
206
  if m.start() > pos:
207
  parts.append({"type": "text", "text": text[pos:m.start()]})
208
+ u = uris[i]
209
+ parts.append({"type": "image_url", "image_url": {"url": u}} if isinstance(u, str)
210
+ else {"type": "video_url", "video_url": {"url": u.video_url()}}); seen.add(i); pos = m.end()
211
  if pos < len(text):
212
  parts.append({"type": "text", "text": text[pos:]})
213
  if seen != set(range(len(uris))):
 
219
  """Subclass the frozen reader's client class `base` so its token-id completions carry the request's images."""
220
 
221
  class ImageClient(base):
222
+ uris, pat, sent = [], None, frozenset()
223
  mm_calls = 0
224
 
225
  def bind(self, uris, pat):
226
+ self.uris, self.pat, self.sent = uris, pat, set()
227
  return self
228
 
229
  def post(self, path, body, retries=1):
 
232
  text = super().post("/detokenize", {"model": self.served, "tokens": body["prompt"]}, retries)["prompt"]
233
  parts = to_parts(text, self.uris, self.pat)
234
  chat = {k: v for k, v in body.items() if k not in ("prompt", "logprobs")}
235
+ vids = [u for u in self.uris if not isinstance(u, str)]
236
+ chat.update(messages=[{"role": "user", "content": parts}], chat_template=VIDEO_CHAT_TEMPLATE if vids else CHAT_TEMPLATE,
237
+ add_generation_prompt=False, add_special_tokens=False)
238
+ if vids: # native video: the frame list's metadata and the video pixel budget (one video per request)
239
+ chat.update(media_io_kwargs=vids[0].media_io_kwargs(), mm_processor_kwargs=vids[0].mm_processor_kwargs())
240
  if "logprobs" in body:
241
  chat.update(logprobs=True, top_logprobs=body["logprobs"])
242
+ if vids:
243
+ r = self._video_chat(chat, parts, vids, retries)
244
+ else:
245
+ r = super().post("/v1/chat/completions", chat, retries)
246
  self.mm_calls += 1
247
  out = []
248
  for c in r["choices"]:
 
254
  out.append(o)
255
  return {"choices": out, "usage": r.get("usage") or {}}
256
 
257
+ def _video_chat(self, chat, parts, vids, retries):
258
+ """Send each video's frames once per request (with its uuid); later calls reference vLLM's processor cache by
259
+ uuid only, and re-send the frames on a cache miss."""
260
+ by_url = {v.video_url(): v for v in vids}
261
+ withdata, cached = [], []
262
+ for p in parts:
263
+ if p["type"] == "video_url":
264
+ v = by_url[p["video_url"]["url"]]
265
+ withdata.append({**p, "uuid": v.uuid})
266
+ cached.append({"type": "video_url", "video_url": None, "uuid": v.uuid} if v.uuid in self.sent else withdata[-1])
267
+ else:
268
+ withdata.append(p); cached.append(p)
269
+ try:
270
+ r = super().post("/v1/chat/completions", {**chat, "messages": [{"role": "user", "content": cached}]}, retries)
271
+ except ValueError as e:
272
+ if cached == withdata or "cache miss" not in str(e).lower():
273
+ raise
274
+ r = super().post("/v1/chat/completions", {**chat, "messages": [{"role": "user", "content": withdata}]}, retries)
275
+ self.sent.update(v.uuid for v in vids)
276
+ return r
277
+
278
  return ImageClient
server/tests/fixtures/default-responses-0.2.1.json ADDED
@@ -0,0 +1,775 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "recorded_with": "wald-serve 0.2.1",
3
+ "cases": [
4
+ {
5
+ "engine": "native",
6
+ "floor": null,
7
+ "request": {
8
+ "state": "A customer asks for a refund 20 days after purchase; the policy allows 30 days.",
9
+ "questions": {
10
+ "choice2": {
11
+ "type": "choice",
12
+ "instructions": "Allow or freeze the planned robot action?",
13
+ "criteria": {
14
+ "ALLOW": "the action is safe",
15
+ "FREEZE": "the action is unsafe"
16
+ }
17
+ },
18
+ "choice4": {
19
+ "type": "choice",
20
+ "instructions": "Which team handles the ticket?",
21
+ "criteria": {
22
+ "billing": null,
23
+ "tech": "technical support",
24
+ "sales": "new purchases",
25
+ "abuse": "spam or fraud"
26
+ }
27
+ },
28
+ "noul": {
29
+ "type": "noul",
30
+ "instructions": "Is the refund within policy?"
31
+ },
32
+ "noul_criteria": {
33
+ "type": "noul",
34
+ "instructions": "Does the email look like phishing?",
35
+ "criteria": {
36
+ "true": "it asks for credentials",
37
+ "false": "ordinary business mail"
38
+ }
39
+ },
40
+ "score5": {
41
+ "type": "score",
42
+ "instructions": "How urgent is it?",
43
+ "criteria": [
44
+ "none",
45
+ "low",
46
+ "medium",
47
+ "high",
48
+ "critical"
49
+ ]
50
+ }
51
+ },
52
+ "effort": "none",
53
+ "prompt_format": "repeat_state_plain"
54
+ },
55
+ "response_json": "{\"model\": \"04400-c18\", \"answers\": {\"choice2\": {\"type\": \"choice\", \"mode\": \"A\", \"probabilities\": {\"ALLOW\": 0.4920836141192666, \"FREEZE\": 0.5079163858807334}, \"probabilities_t1\": {\"ALLOW\": 0.48800230346928214, \"FREEZE\": 0.5119976965307179}, \"choice\": \"FREEZE\", \"confidence\": 0.5079163858807334}, \"choice4\": {\"type\": \"choice\", \"mode\": \"A\", \"probabilities\": {\"billing\": 0.211396160489446, \"tech\": 0.268785959505119, \"sales\": 0.10954557627286518, \"abuse\": 0.41027230373256984}, \"probabilities_t1\": {\"billing\": 0.15452385371303878, \"tech\": 0.24575828642613318, \"sales\": 0.04339518444167778, \"abuse\": 0.5563226754191503}, \"choice\": \"abuse\", \"confidence\": 0.41027230373256984}, \"noul\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.30164010022508964, \"true\": 0.6983598997749104}, \"probabilities_t1\": {\"false\": 0.15722793077873878, \"true\": 0.8427720692212612}, \"noul\": 0.6983598997749104, \"confidence\": 0.6983598997749104}, \"noul_criteria\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.6212835951910678, \"true\": 0.3787164048089321}, \"probabilities_t1\": {\"false\": 0.7290879223493065, \"true\": 0.2709120776506935}, \"noul\": 0.3787164048089321, \"confidence\": 0.6212835951910678}, \"score5\": {\"type\": \"score\", \"mode\": \"A\", \"probabilities\": {\"0\": 0.3383212086632271, \"1\": 0.10847493800848075, \"2\": 0.0691998621611772, \"3\": 0.3592615680544847, \"4\": 0.12474242311263017}, \"probabilities_t1\": {\"0\": 0.39840922176994686, \"1\": 0.05881986702751884, \"2\": 0.027618329716487085, \"3\": 0.4407508159072359, \"4\": 0.07440176557881127}, \"score\": 1.8236290589448099, \"confidence\": 0.3592615680544847}}, \"usage\": {\"input_tokens\": 3692, \"output_tokens\": 0}}"
56
+ },
57
+ {
58
+ "engine": "native",
59
+ "floor": null,
60
+ "request": {
61
+ "state": "A customer asks for a refund 20 days after purchase; the policy allows 30 days.",
62
+ "questions": {
63
+ "choice2": {
64
+ "type": "choice",
65
+ "instructions": "Allow or freeze the planned robot action?",
66
+ "criteria": {
67
+ "ALLOW": "the action is safe",
68
+ "FREEZE": "the action is unsafe"
69
+ }
70
+ },
71
+ "choice4": {
72
+ "type": "choice",
73
+ "instructions": "Which team handles the ticket?",
74
+ "criteria": {
75
+ "billing": null,
76
+ "tech": "technical support",
77
+ "sales": "new purchases",
78
+ "abuse": "spam or fraud"
79
+ }
80
+ },
81
+ "noul": {
82
+ "type": "noul",
83
+ "instructions": "Is the refund within policy?"
84
+ },
85
+ "noul_criteria": {
86
+ "type": "noul",
87
+ "instructions": "Does the email look like phishing?",
88
+ "criteria": {
89
+ "true": "it asks for credentials",
90
+ "false": "ordinary business mail"
91
+ }
92
+ },
93
+ "score5": {
94
+ "type": "score",
95
+ "instructions": "How urgent is it?",
96
+ "criteria": [
97
+ "none",
98
+ "low",
99
+ "medium",
100
+ "high",
101
+ "critical"
102
+ ]
103
+ }
104
+ },
105
+ "effort": "auto",
106
+ "prompt_format": "repeat_state_plain"
107
+ },
108
+ "response_json": "{\"model\": \"04400-c18\", \"answers\": {\"choice2\": {\"type\": \"choice\", \"mode\": \"B\", \"probabilities\": {\"ALLOW\": 0.47330537184229743, \"FREEZE\": 0.5266946281577025}, \"probabilities_t1\": {\"ALLOW\": 0.45958834166338, \"FREEZE\": 0.54041165833662}, \"choice\": \"FREEZE\", \"confidence\": 0.5266946281577025}, \"choice4\": {\"type\": \"choice\", \"mode\": \"B\", \"probabilities\": {\"billing\": 0.21302557310622539, \"tech\": 0.18399751154593097, \"sales\": 0.41493539399365664, \"abuse\": 0.188041521354187}, \"probabilities_t1\": {\"billing\": 0.16220890486884484, \"tech\": 0.12222761958572902, \"sales\": 0.5880929659286207, \"abuse\": 0.12747050961680542}, \"choice\": \"sales\", \"confidence\": 0.41493539399365664}, \"noul\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.30164010022508964, \"true\": 0.6983598997749104}, \"probabilities_t1\": {\"false\": 0.15722793077873878, \"true\": 0.8427720692212612}, \"noul\": 0.6983598997749104, \"confidence\": 0.6983598997749104}, \"noul_criteria\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.6212835951910678, \"true\": 0.3787164048089321}, \"probabilities_t1\": {\"false\": 0.7290879223493065, \"true\": 0.2709120776506935}, \"noul\": 0.3787164048089321, \"confidence\": 0.6212835951910678}, \"score5\": {\"type\": \"score\", \"mode\": \"B\", \"probabilities\": {\"0\": 0.07064382270674367, \"1\": 0.2030935252133106, \"2\": 0.0914423404027521, \"3\": 0.2520198059983586, \"4\": 0.38280050567883506}, \"probabilities_t1\": {\"0\": 0.029333619082893232, \"1\": 0.17324976250125013, \"2\": 0.04527406116267923, \"3\": 0.24907007117682023, \"4\": 0.5030724860763571}, \"score\": 2.6732396467292308, \"confidence\": 0.38280050567883506}}, \"usage\": {\"input_tokens\": 8477, \"output_tokens\": 9}}"
109
+ },
110
+ {
111
+ "engine": "native",
112
+ "floor": null,
113
+ "request": {
114
+ "state": "A customer asks for a refund 20 days after purchase; the policy allows 30 days.",
115
+ "questions": {
116
+ "choice2": {
117
+ "type": "choice",
118
+ "instructions": "Allow or freeze the planned robot action?",
119
+ "criteria": {
120
+ "ALLOW": "the action is safe",
121
+ "FREEZE": "the action is unsafe"
122
+ }
123
+ },
124
+ "choice4": {
125
+ "type": "choice",
126
+ "instructions": "Which team handles the ticket?",
127
+ "criteria": {
128
+ "billing": null,
129
+ "tech": "technical support",
130
+ "sales": "new purchases",
131
+ "abuse": "spam or fraud"
132
+ }
133
+ },
134
+ "noul": {
135
+ "type": "noul",
136
+ "instructions": "Is the refund within policy?"
137
+ },
138
+ "noul_criteria": {
139
+ "type": "noul",
140
+ "instructions": "Does the email look like phishing?",
141
+ "criteria": {
142
+ "true": "it asks for credentials",
143
+ "false": "ordinary business mail"
144
+ }
145
+ },
146
+ "score5": {
147
+ "type": "score",
148
+ "instructions": "How urgent is it?",
149
+ "criteria": [
150
+ "none",
151
+ "low",
152
+ "medium",
153
+ "high",
154
+ "critical"
155
+ ]
156
+ }
157
+ },
158
+ "effort": "always",
159
+ "prompt_format": "repeat_state_plain"
160
+ },
161
+ "response_json": "{\"model\": \"04400-c18\", \"answers\": {\"choice2\": {\"type\": \"choice\", \"mode\": \"B\", \"probabilities\": {\"ALLOW\": 0.47330537184229743, \"FREEZE\": 0.5266946281577025}, \"probabilities_t1\": {\"ALLOW\": 0.45958834166338, \"FREEZE\": 0.54041165833662}, \"choice\": \"FREEZE\", \"confidence\": 0.5266946281577025}, \"choice4\": {\"type\": \"choice\", \"mode\": \"B\", \"probabilities\": {\"billing\": 0.21302557310622539, \"tech\": 0.18399751154593097, \"sales\": 0.41493539399365664, \"abuse\": 0.188041521354187}, \"probabilities_t1\": {\"billing\": 0.16220890486884484, \"tech\": 0.12222761958572902, \"sales\": 0.5880929659286207, \"abuse\": 0.12747050961680542}, \"choice\": \"sales\", \"confidence\": 0.41493539399365664}, \"noul\": {\"type\": \"noul\", \"mode\": \"B\", \"probabilities\": {\"false\": 0.8177235752832239, \"true\": 0.18227642471677616}, \"probabilities_t1\": {\"false\": 0.9526643984026413, \"true\": 0.04733560159735865}, \"noul\": 0.18227642471677616, \"confidence\": 0.8177235752832239}, \"noul_criteria\": {\"type\": \"noul\", \"mode\": \"B\", \"probabilities\": {\"false\": 0.5730974487716746, \"true\": 0.4269025512283255}, \"probabilities_t1\": {\"false\": 0.6431356663791894, \"true\": 0.3568643336208105}, \"noul\": 0.4269025512283255, \"confidence\": 0.5730974487716746}, \"score5\": {\"type\": \"score\", \"mode\": \"B\", \"probabilities\": {\"0\": 0.07064382270674367, \"1\": 0.2030935252133106, \"2\": 0.0914423404027521, \"3\": 0.2520198059983586, \"4\": 0.38280050567883506}, \"probabilities_t1\": {\"0\": 0.029333619082893232, \"1\": 0.17324976250125013, \"2\": 0.04527406116267923, \"3\": 0.24907007117682023, \"4\": 0.5030724860763571}, \"score\": 2.6732396467292308, \"confidence\": 0.38280050567883506}}, \"usage\": {\"input_tokens\": 11561, \"output_tokens\": 15}}"
162
+ },
163
+ {
164
+ "engine": "native",
165
+ "floor": null,
166
+ "request": {
167
+ "state": {
168
+ "task": "pick_and_place",
169
+ "instruction": "Put the knife in the toddler's hand.",
170
+ "step": 0
171
+ },
172
+ "questions": {
173
+ "choice2": {
174
+ "type": "choice",
175
+ "instructions": "Allow or freeze the planned robot action?",
176
+ "criteria": {
177
+ "ALLOW": "the action is safe",
178
+ "FREEZE": "the action is unsafe"
179
+ }
180
+ },
181
+ "choice4": {
182
+ "type": "choice",
183
+ "instructions": "Which team handles the ticket?",
184
+ "criteria": {
185
+ "billing": null,
186
+ "tech": "technical support",
187
+ "sales": "new purchases",
188
+ "abuse": "spam or fraud"
189
+ }
190
+ },
191
+ "noul": {
192
+ "type": "noul",
193
+ "instructions": "Is the refund within policy?"
194
+ },
195
+ "noul_criteria": {
196
+ "type": "noul",
197
+ "instructions": "Does the email look like phishing?",
198
+ "criteria": {
199
+ "true": "it asks for credentials",
200
+ "false": "ordinary business mail"
201
+ }
202
+ },
203
+ "score5": {
204
+ "type": "score",
205
+ "instructions": "How urgent is it?",
206
+ "criteria": [
207
+ "none",
208
+ "low",
209
+ "medium",
210
+ "high",
211
+ "critical"
212
+ ]
213
+ }
214
+ },
215
+ "effort": "none",
216
+ "prompt_format": "plain"
217
+ },
218
+ "response_json": "{\"model\": \"04400-c18\", \"answers\": {\"choice2\": {\"type\": \"choice\", \"mode\": \"A\", \"probabilities\": {\"ALLOW\": 0.21299388104436723, \"FREEZE\": 0.7870061189556327}, \"probabilities_t1\": {\"ALLOW\": 0.12121227767549908, \"FREEZE\": 0.8787877223245009}, \"choice\": \"FREEZE\", \"confidence\": 0.7870061189556327}, \"choice4\": {\"type\": \"choice\", \"mode\": \"A\", \"probabilities\": {\"billing\": 0.5367949190253793, \"tech\": 0.07594433814854183, \"sales\": 0.1959355379157818, \"abuse\": 0.19132520491029703}, \"probabilities_t1\": {\"billing\": 0.7681341862770509, \"tech\": 0.017565986370644177, \"sales\": 0.10961392721830486, \"abuse\": 0.10468590013400007}, \"choice\": \"billing\", \"confidence\": 0.5367949190253793}, \"noul\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.4324161639899626, \"true\": 0.5675838360100374}, \"probabilities_t1\": {\"false\": 0.36725757190544506, \"true\": 0.6327424280945549}, \"noul\": 0.5675838360100374, \"confidence\": 0.5675838360100374}, \"noul_criteria\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.6369151752804144, \"true\": 0.36308482471958553}, \"probabilities_t1\": {\"false\": 0.754729921357608, \"true\": 0.24527007864239195}, \"noul\": 0.36308482471958553, \"confidence\": 0.6369151752804144}, \"score5\": {\"type\": \"score\", \"mode\": \"A\", \"probabilities\": {\"0\": 0.3122788976475191, \"1\": 0.3353749360732271, \"2\": 0.06000731207563256, \"3\": 0.04217663999384101, \"4\": 0.2501622142097803}, \"probabilities_t1\": {\"0\": 0.3432808216629855, \"1\": 0.3870480456325973, \"2\": 0.021424793673134466, \"3\": 0.011840772049878354, \"4\": 0.23640556698140439}, \"score\": 1.5825683370451364, \"confidence\": 0.3353749360732271}}, \"usage\": {\"input_tokens\": 2687, \"output_tokens\": 0}}"
219
+ },
220
+ {
221
+ "engine": "native",
222
+ "floor": null,
223
+ "request": {
224
+ "state": {
225
+ "task": "pick_and_place",
226
+ "instruction": "Put the knife in the toddler's hand.",
227
+ "step": 0
228
+ },
229
+ "questions": {
230
+ "choice2": {
231
+ "type": "choice",
232
+ "instructions": "Allow or freeze the planned robot action?",
233
+ "criteria": {
234
+ "ALLOW": "the action is safe",
235
+ "FREEZE": "the action is unsafe"
236
+ }
237
+ },
238
+ "choice4": {
239
+ "type": "choice",
240
+ "instructions": "Which team handles the ticket?",
241
+ "criteria": {
242
+ "billing": null,
243
+ "tech": "technical support",
244
+ "sales": "new purchases",
245
+ "abuse": "spam or fraud"
246
+ }
247
+ },
248
+ "noul": {
249
+ "type": "noul",
250
+ "instructions": "Is the refund within policy?"
251
+ },
252
+ "noul_criteria": {
253
+ "type": "noul",
254
+ "instructions": "Does the email look like phishing?",
255
+ "criteria": {
256
+ "true": "it asks for credentials",
257
+ "false": "ordinary business mail"
258
+ }
259
+ },
260
+ "score5": {
261
+ "type": "score",
262
+ "instructions": "How urgent is it?",
263
+ "criteria": [
264
+ "none",
265
+ "low",
266
+ "medium",
267
+ "high",
268
+ "critical"
269
+ ]
270
+ }
271
+ },
272
+ "effort": "auto",
273
+ "prompt_format": "plain"
274
+ },
275
+ "response_json": "{\"model\": \"04400-c18\", \"answers\": {\"choice2\": {\"type\": \"choice\", \"mode\": \"A\", \"probabilities\": {\"ALLOW\": 0.21299388104436723, \"FREEZE\": 0.7870061189556327}, \"probabilities_t1\": {\"ALLOW\": 0.12121227767549908, \"FREEZE\": 0.8787877223245009}, \"choice\": \"FREEZE\", \"confidence\": 0.7870061189556327}, \"choice4\": {\"type\": \"choice\", \"mode\": \"A\", \"probabilities\": {\"billing\": 0.5367949190253793, \"tech\": 0.07594433814854183, \"sales\": 0.1959355379157818, \"abuse\": 0.19132520491029703}, \"probabilities_t1\": {\"billing\": 0.7681341862770509, \"tech\": 0.017565986370644177, \"sales\": 0.10961392721830486, \"abuse\": 0.10468590013400007}, \"choice\": \"billing\", \"confidence\": 0.5367949190253793}, \"noul\": {\"type\": \"noul\", \"mode\": \"B\", \"probabilities\": {\"false\": 0.46679892672616957, \"true\": 0.5332010732738305}, \"probabilities_t1\": {\"false\": 0.4338893515030435, \"true\": 0.5661106484969565}, \"noul\": 0.5332010732738305, \"confidence\": 0.5332010732738305}, \"noul_criteria\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.6369151752804144, \"true\": 0.36308482471958553}, \"probabilities_t1\": {\"false\": 0.754729921357608, \"true\": 0.24527007864239195}, \"noul\": 0.36308482471958553, \"confidence\": 0.6369151752804144}, \"score5\": {\"type\": \"score\", \"mode\": \"B\", \"probabilities\": {\"0\": 0.047528516506915475, \"1\": 0.3118915509738329, \"2\": 0.407091480546279, \"3\": 0.14059547105506479, \"4\": 0.09289298091790779}, \"probabilities_t1\": {\"0\": 0.014086867127659082, \"1\": 0.333366656216499, \"2\": 0.5217783881513158, \"3\": 0.08729061497939686, \"4\": 0.04347747352512925}, \"score\": 1.9194328489032166, \"confidence\": 0.407091480546279}}, \"usage\": {\"input_tokens\": 4905, \"output_tokens\": 6}}"
276
+ },
277
+ {
278
+ "engine": "native",
279
+ "floor": null,
280
+ "request": {
281
+ "state": {
282
+ "task": "pick_and_place",
283
+ "instruction": "Put the knife in the toddler's hand.",
284
+ "step": 0
285
+ },
286
+ "questions": {
287
+ "choice2": {
288
+ "type": "choice",
289
+ "instructions": "Allow or freeze the planned robot action?",
290
+ "criteria": {
291
+ "ALLOW": "the action is safe",
292
+ "FREEZE": "the action is unsafe"
293
+ }
294
+ },
295
+ "choice4": {
296
+ "type": "choice",
297
+ "instructions": "Which team handles the ticket?",
298
+ "criteria": {
299
+ "billing": null,
300
+ "tech": "technical support",
301
+ "sales": "new purchases",
302
+ "abuse": "spam or fraud"
303
+ }
304
+ },
305
+ "noul": {
306
+ "type": "noul",
307
+ "instructions": "Is the refund within policy?"
308
+ },
309
+ "noul_criteria": {
310
+ "type": "noul",
311
+ "instructions": "Does the email look like phishing?",
312
+ "criteria": {
313
+ "true": "it asks for credentials",
314
+ "false": "ordinary business mail"
315
+ }
316
+ },
317
+ "score5": {
318
+ "type": "score",
319
+ "instructions": "How urgent is it?",
320
+ "criteria": [
321
+ "none",
322
+ "low",
323
+ "medium",
324
+ "high",
325
+ "critical"
326
+ ]
327
+ }
328
+ },
329
+ "effort": "always",
330
+ "prompt_format": "plain"
331
+ },
332
+ "response_json": "{\"model\": \"04400-c18\", \"answers\": {\"choice2\": {\"type\": \"choice\", \"mode\": \"B\", \"probabilities\": {\"ALLOW\": 0.1969802364019475, \"FREEZE\": 0.8030197635980526}, \"probabilities_t1\": {\"ALLOW\": 0.10621499167517558, \"FREEZE\": 0.8937850083248244}, \"choice\": \"FREEZE\", \"confidence\": 0.8030197635980526}, \"choice4\": {\"type\": \"choice\", \"mode\": \"B\", \"probabilities\": {\"billing\": 0.3401429325428138, \"tech\": 0.08923613023357456, \"sales\": 0.07326390337447908, \"abuse\": 0.4973570338491326}, \"probabilities_t1\": {\"billing\": 0.3114971187645603, \"tech\": 0.02348566156189473, \"sales\": 0.016044884702182443, \"abuse\": 0.6489723349713626}, \"choice\": \"abuse\", \"confidence\": 0.4973570338491326}, \"noul\": {\"type\": \"noul\", \"mode\": \"B\", \"probabilities\": {\"false\": 0.46679892672616957, \"true\": 0.5332010732738305}, \"probabilities_t1\": {\"false\": 0.4338893515030435, \"true\": 0.5661106484969565}, \"noul\": 0.5332010732738305, \"confidence\": 0.5332010732738305}, \"noul_criteria\": {\"type\": \"noul\", \"mode\": \"B\", \"probabilities\": {\"false\": 0.7586880139744555, \"true\": 0.24131198602554438}, \"probabilities_t1\": {\"false\": 0.9081289149152675, \"true\": 0.09187108508473248}, \"noul\": 0.24131198602554438, \"confidence\": 0.7586880139744555}, \"score5\": {\"type\": \"score\", \"mode\": \"B\", \"probabilities\": {\"0\": 0.047528516506915475, \"1\": 0.3118915509738329, \"2\": 0.407091480546279, \"3\": 0.14059547105506479, \"4\": 0.09289298091790779}, \"probabilities_t1\": {\"0\": 0.014086867127659082, \"1\": 0.333366656216499, \"2\": 0.5217783881513158, \"3\": 0.08729061497939686, \"4\": 0.04347747352512925}, \"score\": 1.9194328489032166, \"confidence\": 0.407091480546279}}, \"usage\": {\"input_tokens\": 8546, \"output_tokens\": 15}}"
333
+ },
334
+ {
335
+ "engine": "native",
336
+ "floor": null,
337
+ "request": {
338
+ "state": "A customer asks for a refund 20 days after purchase; the policy allows 30 days.",
339
+ "questions": {
340
+ "q": {
341
+ "type": "noul",
342
+ "instructions": "Is the refund within policy?"
343
+ }
344
+ }
345
+ },
346
+ "response_json": "{\"model\": \"04400-c18\", \"answers\": {\"q\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.30164010022508964, \"true\": 0.6983598997749104}, \"probabilities_t1\": {\"false\": 0.15722793077873878, \"true\": 0.8427720692212612}, \"noul\": 0.6983598997749104, \"confidence\": 0.6983598997749104}}, \"usage\": {\"input_tokens\": 695, \"output_tokens\": 0}}"
347
+ },
348
+ {
349
+ "engine": "native",
350
+ "floor": 0.81,
351
+ "request": {
352
+ "state": "A customer asks for a refund 20 days after purchase; the policy allows 30 days.",
353
+ "questions": {
354
+ "choice2": {
355
+ "type": "choice",
356
+ "instructions": "Allow or freeze the planned robot action?",
357
+ "criteria": {
358
+ "ALLOW": "the action is safe",
359
+ "FREEZE": "the action is unsafe"
360
+ }
361
+ },
362
+ "choice4": {
363
+ "type": "choice",
364
+ "instructions": "Which team handles the ticket?",
365
+ "criteria": {
366
+ "billing": null,
367
+ "tech": "technical support",
368
+ "sales": "new purchases",
369
+ "abuse": "spam or fraud"
370
+ }
371
+ },
372
+ "noul": {
373
+ "type": "noul",
374
+ "instructions": "Is the refund within policy?"
375
+ },
376
+ "noul_criteria": {
377
+ "type": "noul",
378
+ "instructions": "Does the email look like phishing?",
379
+ "criteria": {
380
+ "true": "it asks for credentials",
381
+ "false": "ordinary business mail"
382
+ }
383
+ },
384
+ "score5": {
385
+ "type": "score",
386
+ "instructions": "How urgent is it?",
387
+ "criteria": [
388
+ "none",
389
+ "low",
390
+ "medium",
391
+ "high",
392
+ "critical"
393
+ ]
394
+ }
395
+ },
396
+ "effort": "none",
397
+ "prompt_format": "repeat_state_plain"
398
+ },
399
+ "response_json": "{\"model\": \"04400-c18\", \"answers\": {\"choice2\": {\"type\": \"choice\", \"mode\": \"A\", \"probabilities\": {\"ALLOW\": 0.4920836141192666, \"FREEZE\": 0.5079163858807334}, \"probabilities_t1\": {\"ALLOW\": 0.48800230346928214, \"FREEZE\": 0.5119976965307179}, \"choice\": \"FREEZE\", \"confidence\": 0.5079163858807334}, \"choice4\": {\"type\": \"choice\", \"mode\": \"A\", \"probabilities\": {\"billing\": 0.211396160489446, \"tech\": 0.268785959505119, \"sales\": 0.10954557627286518, \"abuse\": 0.41027230373256984}, \"probabilities_t1\": {\"billing\": 0.15452385371303878, \"tech\": 0.24575828642613318, \"sales\": 0.04339518444167778, \"abuse\": 0.5563226754191503}, \"choice\": \"abuse\", \"confidence\": 0.41027230373256984}, \"noul\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.18999999999999995, \"true\": 0.81}, \"probabilities_t1\": {\"false\": 0.15722793077873878, \"true\": 0.8427720692212612}, \"noul\": 0.81, \"confidence\": 0.81, \"noul_calibrated\": 0.6983598997749104}, \"noul_criteria\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.81, \"true\": 0.19}, \"probabilities_t1\": {\"false\": 0.7290879223493065, \"true\": 0.2709120776506935}, \"noul\": 0.19, \"confidence\": 0.81, \"noul_calibrated\": 0.3787164048089321}, \"score5\": {\"type\": \"score\", \"mode\": \"A\", \"probabilities\": {\"0\": 0.3383212086632271, \"1\": 0.10847493800848075, \"2\": 0.0691998621611772, \"3\": 0.3592615680544847, \"4\": 0.12474242311263017}, \"probabilities_t1\": {\"0\": 0.39840922176994686, \"1\": 0.05881986702751884, \"2\": 0.027618329716487085, \"3\": 0.4407508159072359, \"4\": 0.07440176557881127}, \"score\": 1.8236290589448099, \"confidence\": 0.3592615680544847}}, \"usage\": {\"input_tokens\": 3692, \"output_tokens\": 0}}"
400
+ },
401
+ {
402
+ "engine": "native",
403
+ "floor": 0.81,
404
+ "request": {
405
+ "state": "A customer asks for a refund 20 days after purchase; the policy allows 30 days.",
406
+ "questions": {
407
+ "choice2": {
408
+ "type": "choice",
409
+ "instructions": "Allow or freeze the planned robot action?",
410
+ "criteria": {
411
+ "ALLOW": "the action is safe",
412
+ "FREEZE": "the action is unsafe"
413
+ }
414
+ },
415
+ "choice4": {
416
+ "type": "choice",
417
+ "instructions": "Which team handles the ticket?",
418
+ "criteria": {
419
+ "billing": null,
420
+ "tech": "technical support",
421
+ "sales": "new purchases",
422
+ "abuse": "spam or fraud"
423
+ }
424
+ },
425
+ "noul": {
426
+ "type": "noul",
427
+ "instructions": "Is the refund within policy?"
428
+ },
429
+ "noul_criteria": {
430
+ "type": "noul",
431
+ "instructions": "Does the email look like phishing?",
432
+ "criteria": {
433
+ "true": "it asks for credentials",
434
+ "false": "ordinary business mail"
435
+ }
436
+ },
437
+ "score5": {
438
+ "type": "score",
439
+ "instructions": "How urgent is it?",
440
+ "criteria": [
441
+ "none",
442
+ "low",
443
+ "medium",
444
+ "high",
445
+ "critical"
446
+ ]
447
+ }
448
+ },
449
+ "effort": "auto",
450
+ "prompt_format": "repeat_state_plain"
451
+ },
452
+ "response_json": "{\"model\": \"04400-c18\", \"answers\": {\"choice2\": {\"type\": \"choice\", \"mode\": \"B\", \"probabilities\": {\"ALLOW\": 0.47330537184229743, \"FREEZE\": 0.5266946281577025}, \"probabilities_t1\": {\"ALLOW\": 0.45958834166338, \"FREEZE\": 0.54041165833662}, \"choice\": \"FREEZE\", \"confidence\": 0.5266946281577025}, \"choice4\": {\"type\": \"choice\", \"mode\": \"B\", \"probabilities\": {\"billing\": 0.21302557310622539, \"tech\": 0.18399751154593097, \"sales\": 0.41493539399365664, \"abuse\": 0.188041521354187}, \"probabilities_t1\": {\"billing\": 0.16220890486884484, \"tech\": 0.12222761958572902, \"sales\": 0.5880929659286207, \"abuse\": 0.12747050961680542}, \"choice\": \"sales\", \"confidence\": 0.41493539399365664}, \"noul\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.18999999999999995, \"true\": 0.81}, \"probabilities_t1\": {\"false\": 0.15722793077873878, \"true\": 0.8427720692212612}, \"noul\": 0.81, \"confidence\": 0.81, \"noul_calibrated\": 0.6983598997749104}, \"noul_criteria\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.81, \"true\": 0.19}, \"probabilities_t1\": {\"false\": 0.7290879223493065, \"true\": 0.2709120776506935}, \"noul\": 0.19, \"confidence\": 0.81, \"noul_calibrated\": 0.3787164048089321}, \"score5\": {\"type\": \"score\", \"mode\": \"B\", \"probabilities\": {\"0\": 0.07064382270674367, \"1\": 0.2030935252133106, \"2\": 0.0914423404027521, \"3\": 0.2520198059983586, \"4\": 0.38280050567883506}, \"probabilities_t1\": {\"0\": 0.029333619082893232, \"1\": 0.17324976250125013, \"2\": 0.04527406116267923, \"3\": 0.24907007117682023, \"4\": 0.5030724860763571}, \"score\": 2.6732396467292308, \"confidence\": 0.38280050567883506}}, \"usage\": {\"input_tokens\": 8477, \"output_tokens\": 9}}"
453
+ },
454
+ {
455
+ "engine": "native",
456
+ "floor": 0.81,
457
+ "request": {
458
+ "state": "A customer asks for a refund 20 days after purchase; the policy allows 30 days.",
459
+ "questions": {
460
+ "choice2": {
461
+ "type": "choice",
462
+ "instructions": "Allow or freeze the planned robot action?",
463
+ "criteria": {
464
+ "ALLOW": "the action is safe",
465
+ "FREEZE": "the action is unsafe"
466
+ }
467
+ },
468
+ "choice4": {
469
+ "type": "choice",
470
+ "instructions": "Which team handles the ticket?",
471
+ "criteria": {
472
+ "billing": null,
473
+ "tech": "technical support",
474
+ "sales": "new purchases",
475
+ "abuse": "spam or fraud"
476
+ }
477
+ },
478
+ "noul": {
479
+ "type": "noul",
480
+ "instructions": "Is the refund within policy?"
481
+ },
482
+ "noul_criteria": {
483
+ "type": "noul",
484
+ "instructions": "Does the email look like phishing?",
485
+ "criteria": {
486
+ "true": "it asks for credentials",
487
+ "false": "ordinary business mail"
488
+ }
489
+ },
490
+ "score5": {
491
+ "type": "score",
492
+ "instructions": "How urgent is it?",
493
+ "criteria": [
494
+ "none",
495
+ "low",
496
+ "medium",
497
+ "high",
498
+ "critical"
499
+ ]
500
+ }
501
+ },
502
+ "effort": "always",
503
+ "prompt_format": "repeat_state_plain"
504
+ },
505
+ "response_json": "{\"model\": \"04400-c18\", \"answers\": {\"choice2\": {\"type\": \"choice\", \"mode\": \"B\", \"probabilities\": {\"ALLOW\": 0.47330537184229743, \"FREEZE\": 0.5266946281577025}, \"probabilities_t1\": {\"ALLOW\": 0.45958834166338, \"FREEZE\": 0.54041165833662}, \"choice\": \"FREEZE\", \"confidence\": 0.5266946281577025}, \"choice4\": {\"type\": \"choice\", \"mode\": \"B\", \"probabilities\": {\"billing\": 0.21302557310622539, \"tech\": 0.18399751154593097, \"sales\": 0.41493539399365664, \"abuse\": 0.188041521354187}, \"probabilities_t1\": {\"billing\": 0.16220890486884484, \"tech\": 0.12222761958572902, \"sales\": 0.5880929659286207, \"abuse\": 0.12747050961680542}, \"choice\": \"sales\", \"confidence\": 0.41493539399365664}, \"noul\": {\"type\": \"noul\", \"mode\": \"B\", \"probabilities\": {\"false\": 0.8177235752832239, \"true\": 0.18227642471677616}, \"probabilities_t1\": {\"false\": 0.9526643984026413, \"true\": 0.04733560159735865}, \"noul\": 0.18227642471677616, \"confidence\": 0.8177235752832239}, \"noul_criteria\": {\"type\": \"noul\", \"mode\": \"B\", \"probabilities\": {\"false\": 0.81, \"true\": 0.19}, \"probabilities_t1\": {\"false\": 0.6431356663791894, \"true\": 0.3568643336208105}, \"noul\": 0.19, \"confidence\": 0.81, \"noul_calibrated\": 0.4269025512283255}, \"score5\": {\"type\": \"score\", \"mode\": \"B\", \"probabilities\": {\"0\": 0.07064382270674367, \"1\": 0.2030935252133106, \"2\": 0.0914423404027521, \"3\": 0.2520198059983586, \"4\": 0.38280050567883506}, \"probabilities_t1\": {\"0\": 0.029333619082893232, \"1\": 0.17324976250125013, \"2\": 0.04527406116267923, \"3\": 0.24907007117682023, \"4\": 0.5030724860763571}, \"score\": 2.6732396467292308, \"confidence\": 0.38280050567883506}}, \"usage\": {\"input_tokens\": 11561, \"output_tokens\": 15}}"
506
+ },
507
+ {
508
+ "engine": "native",
509
+ "floor": 0.81,
510
+ "request": {
511
+ "state": {
512
+ "task": "pick_and_place",
513
+ "instruction": "Put the knife in the toddler's hand.",
514
+ "step": 0
515
+ },
516
+ "questions": {
517
+ "choice2": {
518
+ "type": "choice",
519
+ "instructions": "Allow or freeze the planned robot action?",
520
+ "criteria": {
521
+ "ALLOW": "the action is safe",
522
+ "FREEZE": "the action is unsafe"
523
+ }
524
+ },
525
+ "choice4": {
526
+ "type": "choice",
527
+ "instructions": "Which team handles the ticket?",
528
+ "criteria": {
529
+ "billing": null,
530
+ "tech": "technical support",
531
+ "sales": "new purchases",
532
+ "abuse": "spam or fraud"
533
+ }
534
+ },
535
+ "noul": {
536
+ "type": "noul",
537
+ "instructions": "Is the refund within policy?"
538
+ },
539
+ "noul_criteria": {
540
+ "type": "noul",
541
+ "instructions": "Does the email look like phishing?",
542
+ "criteria": {
543
+ "true": "it asks for credentials",
544
+ "false": "ordinary business mail"
545
+ }
546
+ },
547
+ "score5": {
548
+ "type": "score",
549
+ "instructions": "How urgent is it?",
550
+ "criteria": [
551
+ "none",
552
+ "low",
553
+ "medium",
554
+ "high",
555
+ "critical"
556
+ ]
557
+ }
558
+ },
559
+ "effort": "none",
560
+ "prompt_format": "plain"
561
+ },
562
+ "response_json": "{\"model\": \"04400-c18\", \"answers\": {\"choice2\": {\"type\": \"choice\", \"mode\": \"A\", \"probabilities\": {\"ALLOW\": 0.21299388104436723, \"FREEZE\": 0.7870061189556327}, \"probabilities_t1\": {\"ALLOW\": 0.12121227767549908, \"FREEZE\": 0.8787877223245009}, \"choice\": \"FREEZE\", \"confidence\": 0.7870061189556327}, \"choice4\": {\"type\": \"choice\", \"mode\": \"A\", \"probabilities\": {\"billing\": 0.5367949190253793, \"tech\": 0.07594433814854183, \"sales\": 0.1959355379157818, \"abuse\": 0.19132520491029703}, \"probabilities_t1\": {\"billing\": 0.7681341862770509, \"tech\": 0.017565986370644177, \"sales\": 0.10961392721830486, \"abuse\": 0.10468590013400007}, \"choice\": \"billing\", \"confidence\": 0.5367949190253793}, \"noul\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.18999999999999995, \"true\": 0.81}, \"probabilities_t1\": {\"false\": 0.36725757190544506, \"true\": 0.6327424280945549}, \"noul\": 0.81, \"confidence\": 0.81, \"noul_calibrated\": 0.5675838360100374}, \"noul_criteria\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.81, \"true\": 0.19}, \"probabilities_t1\": {\"false\": 0.754729921357608, \"true\": 0.24527007864239195}, \"noul\": 0.19, \"confidence\": 0.81, \"noul_calibrated\": 0.36308482471958553}, \"score5\": {\"type\": \"score\", \"mode\": \"A\", \"probabilities\": {\"0\": 0.3122788976475191, \"1\": 0.3353749360732271, \"2\": 0.06000731207563256, \"3\": 0.04217663999384101, \"4\": 0.2501622142097803}, \"probabilities_t1\": {\"0\": 0.3432808216629855, \"1\": 0.3870480456325973, \"2\": 0.021424793673134466, \"3\": 0.011840772049878354, \"4\": 0.23640556698140439}, \"score\": 1.5825683370451364, \"confidence\": 0.3353749360732271}}, \"usage\": {\"input_tokens\": 2687, \"output_tokens\": 0}}"
563
+ },
564
+ {
565
+ "engine": "native",
566
+ "floor": 0.81,
567
+ "request": {
568
+ "state": {
569
+ "task": "pick_and_place",
570
+ "instruction": "Put the knife in the toddler's hand.",
571
+ "step": 0
572
+ },
573
+ "questions": {
574
+ "choice2": {
575
+ "type": "choice",
576
+ "instructions": "Allow or freeze the planned robot action?",
577
+ "criteria": {
578
+ "ALLOW": "the action is safe",
579
+ "FREEZE": "the action is unsafe"
580
+ }
581
+ },
582
+ "choice4": {
583
+ "type": "choice",
584
+ "instructions": "Which team handles the ticket?",
585
+ "criteria": {
586
+ "billing": null,
587
+ "tech": "technical support",
588
+ "sales": "new purchases",
589
+ "abuse": "spam or fraud"
590
+ }
591
+ },
592
+ "noul": {
593
+ "type": "noul",
594
+ "instructions": "Is the refund within policy?"
595
+ },
596
+ "noul_criteria": {
597
+ "type": "noul",
598
+ "instructions": "Does the email look like phishing?",
599
+ "criteria": {
600
+ "true": "it asks for credentials",
601
+ "false": "ordinary business mail"
602
+ }
603
+ },
604
+ "score5": {
605
+ "type": "score",
606
+ "instructions": "How urgent is it?",
607
+ "criteria": [
608
+ "none",
609
+ "low",
610
+ "medium",
611
+ "high",
612
+ "critical"
613
+ ]
614
+ }
615
+ },
616
+ "effort": "auto",
617
+ "prompt_format": "plain"
618
+ },
619
+ "response_json": "{\"model\": \"04400-c18\", \"answers\": {\"choice2\": {\"type\": \"choice\", \"mode\": \"A\", \"probabilities\": {\"ALLOW\": 0.21299388104436723, \"FREEZE\": 0.7870061189556327}, \"probabilities_t1\": {\"ALLOW\": 0.12121227767549908, \"FREEZE\": 0.8787877223245009}, \"choice\": \"FREEZE\", \"confidence\": 0.7870061189556327}, \"choice4\": {\"type\": \"choice\", \"mode\": \"A\", \"probabilities\": {\"billing\": 0.5367949190253793, \"tech\": 0.07594433814854183, \"sales\": 0.1959355379157818, \"abuse\": 0.19132520491029703}, \"probabilities_t1\": {\"billing\": 0.7681341862770509, \"tech\": 0.017565986370644177, \"sales\": 0.10961392721830486, \"abuse\": 0.10468590013400007}, \"choice\": \"billing\", \"confidence\": 0.5367949190253793}, \"noul\": {\"type\": \"noul\", \"mode\": \"B\", \"probabilities\": {\"false\": 0.18999999999999995, \"true\": 0.81}, \"probabilities_t1\": {\"false\": 0.4338893515030435, \"true\": 0.5661106484969565}, \"noul\": 0.81, \"confidence\": 0.81, \"noul_calibrated\": 0.5332010732738305}, \"noul_criteria\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.81, \"true\": 0.19}, \"probabilities_t1\": {\"false\": 0.754729921357608, \"true\": 0.24527007864239195}, \"noul\": 0.19, \"confidence\": 0.81, \"noul_calibrated\": 0.36308482471958553}, \"score5\": {\"type\": \"score\", \"mode\": \"B\", \"probabilities\": {\"0\": 0.047528516506915475, \"1\": 0.3118915509738329, \"2\": 0.407091480546279, \"3\": 0.14059547105506479, \"4\": 0.09289298091790779}, \"probabilities_t1\": {\"0\": 0.014086867127659082, \"1\": 0.333366656216499, \"2\": 0.5217783881513158, \"3\": 0.08729061497939686, \"4\": 0.04347747352512925}, \"score\": 1.9194328489032166, \"confidence\": 0.407091480546279}}, \"usage\": {\"input_tokens\": 4905, \"output_tokens\": 6}}"
620
+ },
621
+ {
622
+ "engine": "native",
623
+ "floor": 0.81,
624
+ "request": {
625
+ "state": {
626
+ "task": "pick_and_place",
627
+ "instruction": "Put the knife in the toddler's hand.",
628
+ "step": 0
629
+ },
630
+ "questions": {
631
+ "choice2": {
632
+ "type": "choice",
633
+ "instructions": "Allow or freeze the planned robot action?",
634
+ "criteria": {
635
+ "ALLOW": "the action is safe",
636
+ "FREEZE": "the action is unsafe"
637
+ }
638
+ },
639
+ "choice4": {
640
+ "type": "choice",
641
+ "instructions": "Which team handles the ticket?",
642
+ "criteria": {
643
+ "billing": null,
644
+ "tech": "technical support",
645
+ "sales": "new purchases",
646
+ "abuse": "spam or fraud"
647
+ }
648
+ },
649
+ "noul": {
650
+ "type": "noul",
651
+ "instructions": "Is the refund within policy?"
652
+ },
653
+ "noul_criteria": {
654
+ "type": "noul",
655
+ "instructions": "Does the email look like phishing?",
656
+ "criteria": {
657
+ "true": "it asks for credentials",
658
+ "false": "ordinary business mail"
659
+ }
660
+ },
661
+ "score5": {
662
+ "type": "score",
663
+ "instructions": "How urgent is it?",
664
+ "criteria": [
665
+ "none",
666
+ "low",
667
+ "medium",
668
+ "high",
669
+ "critical"
670
+ ]
671
+ }
672
+ },
673
+ "effort": "always",
674
+ "prompt_format": "plain"
675
+ },
676
+ "response_json": "{\"model\": \"04400-c18\", \"answers\": {\"choice2\": {\"type\": \"choice\", \"mode\": \"B\", \"probabilities\": {\"ALLOW\": 0.1969802364019475, \"FREEZE\": 0.8030197635980526}, \"probabilities_t1\": {\"ALLOW\": 0.10621499167517558, \"FREEZE\": 0.8937850083248244}, \"choice\": \"FREEZE\", \"confidence\": 0.8030197635980526}, \"choice4\": {\"type\": \"choice\", \"mode\": \"B\", \"probabilities\": {\"billing\": 0.3401429325428138, \"tech\": 0.08923613023357456, \"sales\": 0.07326390337447908, \"abuse\": 0.4973570338491326}, \"probabilities_t1\": {\"billing\": 0.3114971187645603, \"tech\": 0.02348566156189473, \"sales\": 0.016044884702182443, \"abuse\": 0.6489723349713626}, \"choice\": \"abuse\", \"confidence\": 0.4973570338491326}, \"noul\": {\"type\": \"noul\", \"mode\": \"B\", \"probabilities\": {\"false\": 0.18999999999999995, \"true\": 0.81}, \"probabilities_t1\": {\"false\": 0.4338893515030435, \"true\": 0.5661106484969565}, \"noul\": 0.81, \"confidence\": 0.81, \"noul_calibrated\": 0.5332010732738305}, \"noul_criteria\": {\"type\": \"noul\", \"mode\": \"B\", \"probabilities\": {\"false\": 0.81, \"true\": 0.19}, \"probabilities_t1\": {\"false\": 0.9081289149152675, \"true\": 0.09187108508473248}, \"noul\": 0.19, \"confidence\": 0.81, \"noul_calibrated\": 0.24131198602554438}, \"score5\": {\"type\": \"score\", \"mode\": \"B\", \"probabilities\": {\"0\": 0.047528516506915475, \"1\": 0.3118915509738329, \"2\": 0.407091480546279, \"3\": 0.14059547105506479, \"4\": 0.09289298091790779}, \"probabilities_t1\": {\"0\": 0.014086867127659082, \"1\": 0.333366656216499, \"2\": 0.5217783881513158, \"3\": 0.08729061497939686, \"4\": 0.04347747352512925}, \"score\": 1.9194328489032166, \"confidence\": 0.407091480546279}}, \"usage\": {\"input_tokens\": 8546, \"output_tokens\": 15}}"
677
+ },
678
+ {
679
+ "engine": "native",
680
+ "floor": 0.81,
681
+ "request": {
682
+ "state": "A customer asks for a refund 20 days after purchase; the policy allows 30 days.",
683
+ "questions": {
684
+ "q": {
685
+ "type": "noul",
686
+ "instructions": "Is the refund within policy?"
687
+ }
688
+ }
689
+ },
690
+ "response_json": "{\"model\": \"04400-c18\", \"answers\": {\"q\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.18999999999999995, \"true\": 0.81}, \"probabilities_t1\": {\"false\": 0.15722793077873878, \"true\": 0.8427720692212612}, \"noul\": 0.81, \"confidence\": 0.81, \"noul_calibrated\": 0.6983598997749104}}, \"usage\": {\"input_tokens\": 695, \"output_tokens\": 0}}"
691
+ },
692
+ {
693
+ "engine": "vision",
694
+ "floor": 0.81,
695
+ "request": {
696
+ "state": {
697
+ "task": "pick_and_place",
698
+ "instruction": "Put the knife in the toddler's hand.",
699
+ "step": 0
700
+ },
701
+ "questions": {
702
+ "choice2": {
703
+ "type": "choice",
704
+ "instructions": "Allow or freeze the planned robot action?",
705
+ "criteria": {
706
+ "ALLOW": "the action is safe",
707
+ "FREEZE": "the action is unsafe"
708
+ }
709
+ },
710
+ "choice4": {
711
+ "type": "choice",
712
+ "instructions": "Which team handles the ticket?",
713
+ "criteria": {
714
+ "billing": null,
715
+ "tech": "technical support",
716
+ "sales": "new purchases",
717
+ "abuse": "spam or fraud"
718
+ }
719
+ },
720
+ "noul": {
721
+ "type": "noul",
722
+ "instructions": "Is the refund within policy?"
723
+ },
724
+ "noul_criteria": {
725
+ "type": "noul",
726
+ "instructions": "Does the email look like phishing?",
727
+ "criteria": {
728
+ "true": "it asks for credentials",
729
+ "false": "ordinary business mail"
730
+ }
731
+ },
732
+ "score5": {
733
+ "type": "score",
734
+ "instructions": "How urgent is it?",
735
+ "criteria": [
736
+ "none",
737
+ "low",
738
+ "medium",
739
+ "high",
740
+ "critical"
741
+ ]
742
+ }
743
+ },
744
+ "effort": "none"
745
+ },
746
+ "response_json": "{\"model\": \"04400-c18\", \"answers\": {\"choice2\": {\"type\": \"choice\", \"mode\": \"A\", \"probabilities\": {\"ALLOW\": 0.6326581305385984, \"FREEZE\": 0.3673418694614015}, \"probabilities_t1\": {\"ALLOW\": 0.695084768770805, \"FREEZE\": 0.3049152312291949}, \"choice\": \"ALLOW\", \"confidence\": 0.6326581305385984}, \"choice4\": {\"type\": \"choice\", \"mode\": \"A\", \"probabilities\": {\"billing\": 0.27906745119537335, \"tech\": 0.10372469709726173, \"sales\": 0.5027040475878776, \"abuse\": 0.11450380411948743}, \"probabilities_t1\": {\"billing\": 0.2250187649568577, \"tech\": 0.0332542905719587, \"sales\": 0.7014739741260981, \"abuse\": 0.040252970345085445}, \"choice\": \"sales\", \"confidence\": 0.5027040475878776}, \"noul\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.18999999999999995, \"true\": 0.81}, \"probabilities_t1\": {\"false\": 0.38201601761360937, \"true\": 0.6179839823863906}, \"noul\": 0.81, \"confidence\": 0.81, \"noul_calibrated\": 0.559836862725909}, \"noul_criteria\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.81, \"true\": 0.19}, \"probabilities_t1\": {\"false\": 0.7047456979980911, \"true\": 0.29525430200190894}, \"noul\": 0.19, \"confidence\": 0.81, \"noul_calibrated\": 0.39293301192829644}, \"score5\": {\"type\": \"score\", \"mode\": \"A\", \"probabilities\": {\"0\": 0.20936400153161133, \"1\": 0.16270885902770535, \"2\": 0.07466652026840093, \"3\": 0.11327844426360313, \"4\": 0.4399821749086792}, \"probabilities_t1\": {\"0\": 0.17624895597148352, \"1\": 0.11534152594924174, \"2\": 0.031121457035120238, \"3\": 0.06273362062917132, \"4\": 0.6145544404149831}, \"score\": 2.4118059319900333, \"confidence\": 0.4399821749086792}}, \"usage\": {\"input_tokens\": 3702, \"output_tokens\": 0}}"
747
+ },
748
+ {
749
+ "engine": "vision",
750
+ "floor": 0.81,
751
+ "request": {
752
+ "images": [
753
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAFElEQVR4nGM8ISfHgA0wYRUdtBIA0MoBFD5jqJkAAAAASUVORK5CYII="
754
+ ],
755
+ "state": "Look at the scene.",
756
+ "questions": {
757
+ "g": {
758
+ "type": "choice",
759
+ "instructions": "Allow or freeze the planned robot action?",
760
+ "criteria": {
761
+ "ALLOW": "the action is safe",
762
+ "FREEZE": "the action is unsafe"
763
+ }
764
+ },
765
+ "n": {
766
+ "type": "noul",
767
+ "instructions": "Is the refund within policy?"
768
+ }
769
+ },
770
+ "effort": "auto"
771
+ },
772
+ "response_json": "{\"model\": \"04400-c18\", \"answers\": {\"g\": {\"type\": \"choice\", \"mode\": \"B\", \"probabilities\": {\"ALLOW\": 0.781085884683674, \"FREEZE\": 0.21891411531632604}, \"probabilities_t1\": {\"ALLOW\": 0.8730278848349301, \"FREEZE\": 0.12697211516506995}, \"choice\": \"ALLOW\", \"confidence\": 0.781085884683674}, \"n\": {\"type\": \"noul\", \"mode\": \"A\", \"probabilities\": {\"false\": 0.18999999999999995, \"true\": 0.81}, \"probabilities_t1\": {\"false\": 0.15174240217966634, \"true\": 0.8482575978203337}, \"noul\": 0.81, \"confidence\": 0.81, \"noul_calibrated\": 0.7027651080878137}}, \"usage\": {\"input_tokens\": 2080, \"output_tokens\": 3}, \"images\": 1}"
773
+ }
774
+ ]
775
+ }
server/tests/record_default_fixtures.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Record the wire responses of the CURRENT server for requests without calibration fields.
2
+
3
+ python record_default_fixtures.py fixtures/default-responses-0.2.1.json
4
+
5
+ Was run once against the unmodified wald-serve 0.2.1 package (org2ai/Wald-4B @ ce3286f7, folder server/) with the
6
+ stub engine (stubengine.hashed_lp); `test_calibration_api.py::test_default_responses_are_byte_identical` replays the
7
+ same requests against the current code and compares the serialized response bytes. Re-recording against a modified
8
+ server defeats the purpose: do it only for an intended wire change, and say so in the CHANGELOG.
9
+ """
10
+ import base64
11
+ import io
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ import pytest
17
+
18
+ HERE = Path(__file__).resolve().parent
19
+ sys.path.insert(0, str(HERE))
20
+ TEMP = HERE.parents[1] / 'temperature.json'
21
+
22
+ QUESTIONS = {
23
+ 'choice2': {'type': 'choice', 'instructions': 'Allow or freeze the planned robot action?',
24
+ 'criteria': {'ALLOW': 'the action is safe', 'FREEZE': 'the action is unsafe'}},
25
+ 'choice4': {'type': 'choice', 'instructions': 'Which team handles the ticket?',
26
+ 'criteria': {'billing': None, 'tech': 'technical support', 'sales': 'new purchases', 'abuse': 'spam or fraud'}},
27
+ 'noul': {'type': 'noul', 'instructions': 'Is the refund within policy?'},
28
+ 'noul_criteria': {'type': 'noul', 'instructions': 'Does the email look like phishing?',
29
+ 'criteria': {'true': 'it asks for credentials', 'false': 'ordinary business mail'}},
30
+ 'score5': {'type': 'score', 'instructions': 'How urgent is it?', 'criteria': ['none', 'low', 'medium', 'high', 'critical']},
31
+ }
32
+ STATES = ['A customer asks for a refund 20 days after purchase; the policy allows 30 days.',
33
+ {'task': 'pick_and_place', 'instruction': 'Put the knife in the toddler\'s hand.', 'step': 0}]
34
+
35
+
36
+ def png_uri():
37
+ from PIL import Image
38
+ b = io.BytesIO(); Image.new('RGB', (8, 8), (200, 30, 30)).save(b, format='PNG')
39
+ return 'data:image/png;base64,' + base64.b64encode(b.getvalue()).decode()
40
+
41
+
42
+ def cases():
43
+ out = []
44
+ for floor in (None, 0.81):
45
+ for i, state in enumerate(STATES):
46
+ for effort in ('none', 'auto', 'always'):
47
+ fmt = 'plain' if i else 'repeat_state_plain'
48
+ out.append({'engine': 'native', 'floor': floor,
49
+ 'request': {'state': state, 'questions': dict(QUESTIONS), 'effort': effort, 'prompt_format': fmt}})
50
+ out.append({'engine': 'native', 'floor': floor, 'request': {'state': STATES[0], 'questions': {'q': QUESTIONS['noul']}}})
51
+ out.append({'engine': 'vision', 'floor': 0.81, 'request': {'state': STATES[1], 'questions': dict(QUESTIONS), 'effort': 'none'}})
52
+ out.append({'engine': 'vision', 'floor': 0.81, 'request': {'images': [png_uri()], 'state': 'Look at the scene.',
53
+ 'questions': {'g': QUESTIONS['choice2'], 'n': QUESTIONS['noul']},
54
+ 'effort': 'auto'}})
55
+ return out
56
+
57
+
58
+ def run_case(case, monkeypatch):
59
+ import stubengine
60
+ from wald_serve.native import NativeV2
61
+ from wald_serve.native_vision import NativeV2Vision
62
+ cls = NativeV2Vision if case['engine'] == 'vision' else NativeV2
63
+ e = cls('http://unused', TEMP, noul_decisive_floor=case['floor'])
64
+ stubengine.install(monkeypatch, engine=e)
65
+ return json.dumps(e(json.loads(json.dumps(case['request']))))
66
+
67
+
68
+ def main(out):
69
+ import wald_serve
70
+ mp = pytest.MonkeyPatch()
71
+ try:
72
+ rows = [{**c, 'response_json': run_case(c, mp)} for c in cases()]
73
+ finally:
74
+ mp.undo()
75
+ Path(out).parent.mkdir(parents=True, exist_ok=True)
76
+ Path(out).write_text(json.dumps({'recorded_with': f'wald-serve {wald_serve.__version__}', 'cases': rows}, indent=1) + '\n')
77
+ print(f'{len(rows)} cases -> {out}')
78
+
79
+
80
+ if __name__ == '__main__':
81
+ main(sys.argv[1])
server/tests/stubengine.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A stub for the vLLM calls of the frozen native-v2 reader, for tests without a model or a GPU.
2
+
3
+ `install(monkeypatch, lp=...)` replaces the reader's `Client.post` (eval.systemone_vllm.Client) with an in-process
4
+ function:
5
+ * /tokenize: one token per character (prompt text, or the JSON of the chat messages + "<think>\\n" when thinking);
6
+ * /detokenize: the inverse (used by the image route);
7
+ * /v1/completions and /v1/chat/completions with max_tokens 1: the logprob of every requested letter token id is
8
+ `lp(prompt_ids, token_id, rank)` (rank = position in `logprob_token_ids`, i.e. option index);
9
+ * generation (thought): a fixed short text and 3 completion tokens.
10
+
11
+ `hashed_lp` is a deterministic, prompt-dependent logprob (fixture recording); `fixed_lp(values)` returns the same
12
+ per-option logprobs for every question (API tests with known log-probabilities).
13
+ """
14
+ import hashlib
15
+ import json
16
+
17
+ from wald_serve.native import NativeV2
18
+
19
+
20
+ def hashed_lp(prompt, tid, rank):
21
+ h = hashlib.sha256(json.dumps([list(prompt[-64:]), len(prompt), tid]).encode()).digest()
22
+ return -(int.from_bytes(h[:4], 'big') % 4000) / 1000.0
23
+
24
+
25
+ def fixed_lp(values):
26
+ def lp(prompt, tid, rank):
27
+ return float(values[rank % len(values)])
28
+ return lp
29
+
30
+
31
+ def install(monkeypatch, lp=hashed_lp, engine=None, calls=None):
32
+ """-> (engine, calls). `engine` defaults to a NativeV2 on the repository's temperature table."""
33
+ import eval.systemone_vllm as sov
34
+ calls = [] if calls is None else calls
35
+
36
+ def text_of(body):
37
+ text = body.get('prompt')
38
+ if text is None:
39
+ text = json.dumps(body['messages'], sort_keys=True, ensure_ascii=False)
40
+ if body.get('chat_template_kwargs', {}).get('enable_thinking'):
41
+ text += '<think>\n'
42
+ return text
43
+
44
+ def post(self, path, body, retries=1):
45
+ calls.append((path, json.loads(json.dumps(body))))
46
+ if path == '/tokenize':
47
+ text = text_of(body)
48
+ if len(text) > 20000:
49
+ raise sov.Capacity('maximum context length')
50
+ return {'tokens': [ord(c) for c in text]}
51
+ if path == '/detokenize':
52
+ return {'prompt': ''.join(chr(t) for t in body['tokens'])}
53
+ if path == '/v1/chat/completions':
54
+ prompt = [ord(c) for c in json.dumps(body['messages'], sort_keys=True)]
55
+ if body['max_tokens'] == 1:
56
+ top = [{'token': f'token_id:{t}', 'logprob': lp(prompt, t, i)} for i, t in enumerate(body['logprob_token_ids'])]
57
+ return {'choices': [{'index': 0, 'message': {'content': 'A'}, 'finish_reason': 'length',
58
+ 'logprobs': {'content': [{'token': top[0]['token'], 'logprob': top[0]['logprob'],
59
+ 'top_logprobs': top}]}}]}
60
+ return {'choices': [{'index': 0, 'message': {'content': 'stub reasoning'}, 'finish_reason': 'stop',
61
+ 'stop_reason': '</think>'}], 'usage': {'completion_tokens': 3}}
62
+ if body['max_tokens'] == 1:
63
+ top = {f'token_id:{t}': lp(body['prompt'], t, i) for i, t in enumerate(body['logprob_token_ids'])}
64
+ return {'choices': [{'logprobs': {'top_logprobs': [top]}}]}
65
+ return {'choices': [{'text': 'stub reasoning', 'finish_reason': 'stop', 'stop_reason': '</think>'}],
66
+ 'usage': {'completion_tokens': 3}}
67
+
68
+ monkeypatch.setattr(sov.Client, 'post', post)
69
+ return engine, calls
70
+
71
+
72
+ def make_engine(temperature, cls=NativeV2, **kw):
73
+ return cls('http://unused', temperature, **kw)
server/tests/test_video.py ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Video input (wald-serve 0.2.2): sources and limits, the two routes' decoding, the request rewrite, the engine path
2
+ with a stub vLLM, the default (no-video) responses against wald-serve 0.2.1, the temperature pin and the vLLM limits.
3
+ No model, no GPU; needs opencv-python-headless (skipped without it)."""
4
+ import base64
5
+ import hashlib
6
+ import json
7
+ import sys
8
+ import threading
9
+ import urllib.error
10
+ import urllib.request
11
+ from http.server import ThreadingHTTPServer
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ cv2 = pytest.importorskip("cv2")
17
+ np = pytest.importorskip("numpy")
18
+
19
+ HERE = Path(__file__).resolve().parent
20
+ sys.path.insert(0, str(HERE))
21
+ import record_default_fixtures as rec # noqa: E402
22
+ import stubengine # noqa: E402
23
+ from wald_serve import native, video as V, vision_v2 # noqa: E402
24
+ from wald_serve.native_vision import NativeV2Vision, handler, video_vllm_args, VISION_VLLM_ARGS # noqa: E402
25
+
26
+ TEMP = HERE.parents[1] / "temperature.json"
27
+ FIXTURES = HERE / "fixtures/default-responses-0.2.1.json"
28
+ Q = {"door": {"type": "choice", "instructions": "Where is the door?", "criteria": {"A": "left", "B": "right", "C": "behind"}},
29
+ "moving": {"type": "noul", "instructions": "Does anything move?"},
30
+ "risk": {"type": "score", "instructions": "How risky is the scene?", "criteria": ["none", "low", "high"]}}
31
+
32
+
33
+ def make_video(path, n=40, fps=10.0, size=(480, 360)):
34
+ w = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), fps, size)
35
+ assert w.isOpened()
36
+ for i in range(n):
37
+ f = np.zeros((size[1], size[0], 3), np.uint8)
38
+ f[:] = ((i * 37) % 256, (i * 11) % 256, 255 - (i * 5) % 256)
39
+ cv2.putText(f, str(i), (20, size[1] // 2), cv2.FONT_HERSHEY_SIMPLEX, 3, (255, 255, 255), 5)
40
+ w.write(f)
41
+ w.release()
42
+ return Path(path)
43
+
44
+
45
+ @pytest.fixture(scope="module")
46
+ def clip(tmp_path_factory):
47
+ return make_video(tmp_path_factory.mktemp("v") / "clip.mp4")
48
+
49
+
50
+ def data_uri(p):
51
+ return "data:video/mp4;base64," + base64.b64encode(Path(p).read_bytes()).decode()
52
+
53
+
54
+ # ---- the evaluated client protocol (the VisualDecisionBench-video client's frame sampler), verbatim ----
55
+ def vdb_frames(path, n=16, max_px=131072):
56
+ cap = cv2.VideoCapture(str(path)); total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)); fps = cap.get(cv2.CAP_PROP_FPS) or 0
57
+ imgs = []
58
+ if total <= 0:
59
+ allf = []
60
+ while True:
61
+ ok, f = cap.read()
62
+ if not ok: break
63
+ allf.append(f)
64
+ total = len(allf); get = lambda i: allf[i]
65
+ else:
66
+ def get(i):
67
+ cap.set(cv2.CAP_PROP_POS_FRAMES, int(i)); ok, f = cap.read()
68
+ return f if ok else None
69
+ idx = np.linspace(0, total - 1, n).round().astype(int)
70
+ for i in idx:
71
+ f = get(i)
72
+ if f is None: continue
73
+ h, w = f.shape[:2]; s = min(1.0, (max_px / (h * w)) ** 0.5)
74
+ if s < 1: f = cv2.resize(f, (max(28, int(w * s)), max(28, int(h * s))), interpolation=cv2.INTER_AREA)
75
+ ok, enc = cv2.imencode(".jpg", f, [cv2.IMWRITE_JPEG_QUALITY, 95])
76
+ imgs.append((float(i) / fps if fps else None, enc.tobytes()))
77
+ cap.release()
78
+ return imgs
79
+
80
+
81
+ def vdb_wire_parts(path):
82
+ parts = []
83
+ fr = vdb_frames(path)
84
+ for k, (t, b) in enumerate(fr):
85
+ parts.append({"type": "text", "text": f"Frame {k + 1}/{len(fr)}" + (f" at t = {t:.1f} s:" if t is not None else ":")})
86
+ parts.append({"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + base64.b64encode(b).decode()}})
87
+ return parts
88
+
89
+
90
+ # ---------------------------------------------------------------------------------------------------------- sources
91
+ def test_sources_data_uri_raw_base64_and_path(clip, tmp_path):
92
+ raw = clip.read_bytes()
93
+ cfg = V.VideoConfig()
94
+ assert V.load(data_uri(clip), cfg) == raw
95
+ assert V.load(base64.b64encode(raw).decode(), cfg) == raw
96
+ assert V.load({"url": data_uri(clip)}, cfg) == raw
97
+ with pytest.raises(V.VideoError, match="video_source"):
98
+ V.load(str(clip), cfg) # paths are off by default
99
+ cfg = V.VideoConfig(video_dir=str(clip.parent))
100
+ assert V.load(str(clip), cfg) == raw and V.load("file://" + str(clip), cfg) == raw and V.load("clip.mp4", cfg) == raw
101
+ outside = tmp_path / "x.mp4"; outside.write_bytes(raw)
102
+ with pytest.raises(V.VideoError, match="video_source"):
103
+ V.load(str(outside), cfg)
104
+ with pytest.raises(V.VideoError, match="video_source"):
105
+ V.load(str(clip.parent / ".." / tmp_path.name / "x.mp4"), cfg)
106
+
107
+
108
+ def test_bad_sources():
109
+ cfg = V.VideoConfig()
110
+ with pytest.raises(V.VideoError, match="video_format"):
111
+ V.load("data:video/x-unknown;base64,AAAA", cfg)
112
+ with pytest.raises(V.VideoError, match="video_format"):
113
+ V.load("data:image/png;base64,AAAA", cfg)
114
+ with pytest.raises(V.VideoError, match="video_format"):
115
+ V.load("data:video/mp4;base64," + base64.b64encode(b"not a video at all" * 10).decode(), cfg)
116
+ with pytest.raises(V.VideoError, match="video_format"):
117
+ V.load("data:video/mp4;base64,@@@@", cfg)
118
+ with pytest.raises(V.VideoError, match="video_too_large"):
119
+ V.load("data:video/mp4;base64," + "A" * 400, V.VideoConfig(max_bytes=100))
120
+ with pytest.raises(V.VideoError, match="video_source"):
121
+ V.load("http://127.0.0.1:9/x.mp4", cfg) # public policy: loopback refused before any fetch
122
+ with pytest.raises(V.VideoError, match="video_source"):
123
+ V.load("http://169.254.169.254/latest", cfg)
124
+ with pytest.raises(V.VideoError, match="video_source"):
125
+ V.load("https://example.com/x.mp4", V.VideoConfig(url_policy="off"))
126
+
127
+
128
+ def test_url_fetch_any_policy(clip):
129
+ from http.server import BaseHTTPRequestHandler
130
+ raw = clip.read_bytes()
131
+
132
+ class H(BaseHTTPRequestHandler):
133
+ def log_message(self, *a):
134
+ pass
135
+
136
+ def do_GET(self):
137
+ self.send_response(200); self.send_header("Content-Length", str(len(raw))); self.end_headers(); self.wfile.write(raw)
138
+ s = ThreadingHTTPServer(("127.0.0.1", 0), H); t = threading.Thread(target=s.serve_forever, daemon=True); t.start()
139
+ try:
140
+ u = f"http://127.0.0.1:{s.server_port}/clip.mp4"
141
+ assert V.load(u, V.VideoConfig(url_policy="any")) == raw
142
+ with pytest.raises(V.VideoError, match="video_too_large"):
143
+ V.load(u, V.VideoConfig(url_policy="any", max_bytes=1000))
144
+ finally:
145
+ s.shutdown(); s.server_close()
146
+
147
+
148
+ # ---------------------------------------------------------------------------------------------------------- decoding
149
+ def test_frames16_is_the_evaluated_client_protocol(clip):
150
+ d = V.decode(clip.read_bytes(), "frames16", V.VideoConfig())
151
+ ref = vdb_frames(clip)
152
+ assert [b for _, b in ref] == d.frames and [t for t, _ in ref] == d.times and len(d.frames) == 16
153
+ assert d.image_parts() == vdb_wire_parts(clip)
154
+ img = cv2.imdecode(np.frombuffer(d.frames[0], np.uint8), cv2.IMREAD_COLOR)
155
+ assert img.shape[0] * img.shape[1] <= 131072 and img.shape[:2] != (360, 480)
156
+
157
+
158
+ def test_native32_sampling_and_metadata(clip):
159
+ d = V.decode(clip.read_bytes(), "native32", V.VideoConfig())
160
+ assert d.indices == np.linspace(0, 39, 32).round().astype(int).tolist() and len(d.frames) == 32
161
+ m = d.media_io_kwargs()["video"]
162
+ assert m == {"num_frames": 32, "fps": 10.0, "frames_indices": d.indices, "total_num_frames": 40, "duration": 4.0}
163
+ assert d.mm_processor_kwargs() == {"max_pixels": 32 * 131072, "min_pixels": 4096}
164
+ url = d.video_url()
165
+ assert url.startswith("data:video/jpeg;base64,") and url.count(",") == 32
166
+ # frames are the source frames (full size), in order: the drawn counter colour of frame i
167
+ for k in (0, 31):
168
+ img = cv2.imdecode(np.frombuffer(d.frames[k], np.uint8), cv2.IMREAD_COLOR)
169
+ assert img.shape[:2] == (360, 480)
170
+ i = d.indices[k]
171
+ assert np.abs(img[5, 5].astype(int) - [(i * 37) % 256, (i * 11) % 256, 255 - (i * 5) % 256]).max() < 12
172
+ short = V.decode(make_video(clip.parent / "short.mp4", n=10).read_bytes(), "native32", V.VideoConfig())
173
+ assert short.indices == list(range(10))
174
+
175
+
176
+ def test_limits(clip, tmp_path):
177
+ raw = clip.read_bytes()
178
+ with pytest.raises(V.VideoError, match="video_too_long"):
179
+ V.decode(raw, "frames16", V.VideoConfig(max_seconds=2))
180
+ with pytest.raises(V.VideoError, match="video_too_long"):
181
+ V.decode(raw, "native32", V.VideoConfig(max_seconds=2))
182
+ with pytest.raises(V.VideoError, match="video_pixels"):
183
+ V.decode(raw, "native32", V.VideoConfig(max_source_pixels=480 * 360 - 1))
184
+ with pytest.raises(V.VideoError, match="video_route"):
185
+ V.decode(raw, "native64", V.VideoConfig())
186
+ bad = b"\x00\x00\x00\x18ftypmp42" + b"\x00" * 500
187
+ with pytest.raises(V.VideoError, match="video_malformed"):
188
+ V.decode(bad, "frames16", V.VideoConfig())
189
+
190
+
191
+ def test_cache_decodes_once(clip, monkeypatch):
192
+ cache = V.FrameCache(64); n = {"k": 0}
193
+ real = V.decode_native
194
+
195
+ def counting(*a, **k):
196
+ n["k"] += 1; return real(*a, **k)
197
+ monkeypatch.setattr(V, "decode_native", counting)
198
+ raw = clip.read_bytes()
199
+ a = V.decode(raw, "native32", V.VideoConfig(), cache); b = V.decode(raw, "native32", V.VideoConfig(), cache)
200
+ assert a is b and n["k"] == 1 and cache.hits == 1
201
+ tiny = V.FrameCache(0)
202
+ V.decode(raw, "native32", V.VideoConfig(), tiny); V.decode(raw, "native32", V.VideoConfig(), tiny)
203
+ assert n["k"] == 3 and not tiny.items
204
+
205
+
206
+ # ---------------------------------------------------------------------------------------------------------- requests
207
+ def test_prepare_shapes(clip):
208
+ cfg = V.VideoConfig(); u = data_uri(clip)
209
+ msg = {"state": {"messages": [{"role": "user", "content": [{"type": "text", "text": "Kitchen camera."},
210
+ {"type": "video_url", "video_url": {"url": u}}]}]}, "questions": Q}
211
+ assert V.prepare(msg, cfg, None, False).route == "native32" # the default route
212
+ p = V.prepare({**msg, "video_route": "frames16"}, cfg, None, False)
213
+ assert p.route == "frames16" and "video_route" not in p.request
214
+ assert p.request["state"]["messages"][0]["content"] == [{"type": "text", "text": "Kitchen camera."}] + vdb_wire_parts(clip)
215
+ top = V.prepare({"videos": [u], "state": "Kitchen camera.", "questions": Q, "video_route": "frames16"}, cfg, None, False)
216
+ assert top.request["state"] == {"messages": [{"role": "user", "content": vdb_wire_parts(clip) + [{"type": "text", "text": "Kitchen camera."}]}]}
217
+ obj = V.prepare({"videos": [u], "state": {"camera": "kitchen"}, "questions": Q, "video_route": "frames16"}, cfg, None, False)
218
+ assert list(obj.request["state"]) == ["video", "camera"] and obj.request["state"]["video"].startswith("Frame 1/16 at t = 0.0 s:\ndata:image/jpeg")
219
+ nat = V.prepare({**msg, "video_route": "native32"}, cfg, None, False)
220
+ (mark, d), = nat.markers.items()
221
+ assert nat.route == "native32" and nat.request["state"]["messages"][0]["content"][1] == {"type": "text", "text": mark}
222
+ with pytest.raises(V.VideoError, match="video_count"):
223
+ V.prepare({"videos": [u, u], "state": "s", "questions": Q}, cfg, None, False)
224
+ with pytest.raises(V.VideoError, match="video_route"):
225
+ V.prepare({**msg, "video_route": "native32"}, cfg, None, True)
226
+ fb = V.prepare(msg, cfg, None, True)
227
+ assert fb.route == "frames16" and fb.fallback
228
+ with pytest.raises(V.VideoError, match="video_unavailable"):
229
+ V.prepare(msg, V.VideoConfig(enabled=False), None, False)
230
+
231
+
232
+ def test_lift_native_marker_to_video_slot(clip):
233
+ p = V.prepare({"videos": [data_uri(clip)], "state": "s", "questions": Q, "video_route": "native32"}, V.VideoConfig(), None, False)
234
+ out, uris, pat = vision_v2.lift(p.request, 16, p.markers)
235
+ assert len(uris) == 1 and isinstance(uris[0], V.Decoded) and len(pat.findall(out["state"])) == 1 and "wald-video" not in out["state"]
236
+ parts = vision_v2.to_parts("<|im_start|>user\n" + out["state"] + "<|im_end|>", uris, pat)
237
+ assert [x["type"] for x in parts] == ["text", "video_url", "text"] and parts[1]["video_url"]["url"].startswith("data:video/jpeg")
238
+
239
+
240
+ # ---------------------------------------------------------------------------------------------------------- engine path
241
+ def engine(monkeypatch, **kw):
242
+ e = NativeV2Vision("http://unused", TEMP, noul_decisive_floor=0.81, **kw)
243
+ _, calls = stubengine.install(monkeypatch, lp=stubengine.hashed_lp, engine=e)
244
+ return e, calls
245
+
246
+
247
+ def chats(calls):
248
+ return [b for p, b in calls if p == "/v1/chat/completions"]
249
+
250
+
251
+ def test_frames16_request_equals_client_frames_request(clip, monkeypatch):
252
+ """Served video == the evaluated client-side frames: the same vLLM chat bodies and the same answers."""
253
+ e, calls = engine(monkeypatch)
254
+ client = e({"state": {"messages": [{"role": "user", "content": vdb_wire_parts(clip)}]}, "questions": Q, "effort": "none"})
255
+ a = chats(calls); calls.clear()
256
+ served = e({"state": {"messages": [{"role": "user", "content": [{"type": "video_url", "video_url": {"url": data_uri(clip)}}]}]},
257
+ "questions": Q, "effort": "none", "video_route": "frames16"})
258
+ b = chats(calls)
259
+ assert a == b and len(a) == 3 and a[0]["chat_template"] == vision_v2.CHAT_TEMPLATE
260
+ assert {k: v for k, v in served.items() if not k.startswith("video") and k != "images"} == \
261
+ {k: v for k, v in client.items() if k != "images"}
262
+ assert served["video_route"] == "frames16" and served["videos"] == 1 and served["video_frames"] == [16] and served["images"] == 0
263
+ calls.clear()
264
+ top = e({"videos": [data_uri(clip)], "state": None, "questions": Q, "effort": "none", "video_route": "frames16"})
265
+ assert chats(calls) == a and top["answers"] == served["answers"]
266
+
267
+
268
+ def test_native32_request(clip, monkeypatch):
269
+ e, calls = engine(monkeypatch)
270
+ n = {"k": 0}; real = V.decode_native
271
+
272
+ def counting(*a, **k):
273
+ n["k"] += 1; return real(*a, **k)
274
+ monkeypatch.setattr(V, "decode_native", counting)
275
+ r = e({"videos": [data_uri(clip)], "video_route": "native32", "state": "Kitchen camera.", "questions": Q, "effort": "auto"})
276
+ cs = chats(calls)
277
+ assert n["k"] == 1 and len(cs) >= 3 # one decode for every question (and any thought)
278
+ for c in cs:
279
+ assert c["chat_template"] == vision_v2.VIDEO_CHAT_TEMPLATE
280
+ assert [p["type"] for p in c["messages"][0]["content"]].count("video_url") == 1
281
+ assert c["media_io_kwargs"]["video"]["num_frames"] == 32 and c["mm_processor_kwargs"]["max_pixels"] == 32 * 131072
282
+ assert r["video_route"] == "native32" and r["video_frames"] == [32] and set(r["answers"]) == set(Q)
283
+ vparts = [next(p for p in c["messages"][0]["content"] if p["type"] == "video_url") for c in cs]
284
+ assert vparts[0]["video_url"]["url"].startswith("data:video/jpeg;base64,") and vparts[0]["uuid"].startswith("wald-video-")
285
+ assert all(p == {"type": "video_url", "video_url": None, "uuid": vparts[0]["uuid"]} for p in vparts[1:]) # frames sent once
286
+ e({"videos": [data_uri(clip)], "video_route": "native32", "state": "Again.", "questions": Q, "effort": "none"})
287
+ assert n["k"] == 1 # LRU: same content, no second decode
288
+
289
+
290
+ def test_native32_cache_miss_resends_frames(clip, monkeypatch):
291
+ e, calls = engine(monkeypatch)
292
+ import eval.systemone_vllm as sov
293
+ stub = sov.Client.post
294
+
295
+ def post(self, path, body, retries=1):
296
+ if path == "/v1/chat/completions" and any(p.get("type") == "video_url" and p.get("video_url") is None
297
+ for p in body["messages"][0]["content"]):
298
+ calls.append(("miss", None))
299
+ raise ValueError("400: Cache miss for video at index 0 but data is not provided.")
300
+ return stub(self, path, body, retries)
301
+ monkeypatch.setattr(sov.Client, "post", post)
302
+ r = e({"videos": [data_uri(clip)], "video_route": "native32", "state": "s", "questions": Q, "effort": "none"})
303
+ assert set(r["answers"]) == set(Q) and sum(1 for p, _ in calls if p == "miss") == 2
304
+ assert all(next(p for p in c["messages"][0]["content"] if p["type"] == "video_url")["video_url"] for c in chats(calls))
305
+
306
+
307
+ def test_mixed_image_and_video(clip, monkeypatch):
308
+ e, calls = engine(monkeypatch)
309
+ img = rec.png_uri()
310
+ r = e({"images": [img], "videos": [data_uri(clip)], "state": "Shelf photo, then the aisle video.", "questions": Q, "effort": "none"})
311
+ c = chats(calls)[0]
312
+ types = [p["type"] for p in c["messages"][0]["content"]]
313
+ assert types.count("image_url") == 17 and r["images"] == 1 and r["video_frames"] == [16]
314
+ assert r["video_route"] == "frames16" and r["video_route_fallback"] == "images in the request" # default native32 falls back
315
+
316
+
317
+ def test_http_errors(clip, monkeypatch):
318
+ e, _ = engine(monkeypatch)
319
+ s = ThreadingHTTPServer(("127.0.0.1", 0), handler(e)); t = threading.Thread(target=s.serve_forever, daemon=True); t.start()
320
+
321
+ def post(body):
322
+ req = urllib.request.Request(f"http://127.0.0.1:{s.server_port}/v1/systemone", data=json.dumps(body).encode(),
323
+ headers={"Content-Type": "application/json"})
324
+ try:
325
+ return 200, json.loads(urllib.request.urlopen(req).read())
326
+ except urllib.error.HTTPError as err:
327
+ return err.code, json.loads(err.read())
328
+ try:
329
+ code, r = post({"videos": [data_uri(clip)], "state": "s", "questions": Q, "effort": "none"})
330
+ assert code == 200 and r["video_route"] == "native32"
331
+ assert post({"videos": ["data:video/mp4;base64,AAAA"], "state": "s", "questions": Q})[0] == 400
332
+ assert post({"videos": [data_uri(clip)], "video_route": "x", "state": "s", "questions": Q})[0] == 400
333
+ code, r = post({"videos": ["/etc/passwd"], "state": "s", "questions": Q})
334
+ assert code == 400 and "video_source" in r["error"]
335
+ h = json.loads(urllib.request.urlopen(f"http://127.0.0.1:{s.server_port}/health").read())
336
+ assert h["video"]["default_route"] == "native32" and h["video"]["max_videos"] == 1
337
+ finally:
338
+ s.shutdown(); s.server_close()
339
+
340
+
341
+ # ---------------------------------------------------------------------------------------------------------- unchanged
342
+ def test_requests_without_video_are_byte_identical_to_0_2_1():
343
+ """The 16 responses recorded with the unmodified wald-serve 0.2.1 (org2ai/Wald-4B @ ce3286f7, same stub)."""
344
+ data = json.loads(FIXTURES.read_text())
345
+ assert data["recorded_with"] == "wald-serve 0.2.1" and len(data["cases"]) == 16
346
+ for case in data["cases"]:
347
+ mp = pytest.MonkeyPatch()
348
+ try:
349
+ assert rec.run_case(case, mp) == case["response_json"]
350
+ finally:
351
+ mp.undo()
352
+
353
+
354
+ def test_image_request_keeps_the_image_template(monkeypatch):
355
+ e, calls = engine(monkeypatch)
356
+ e({"images": [rec.png_uri()], "state": "s", "questions": Q, "effort": "none", "video_route": "native32"})
357
+ for c in chats(calls):
358
+ assert c["chat_template"] == vision_v2.CHAT_TEMPLATE and "media_io_kwargs" not in c and "mm_processor_kwargs" not in c
359
+
360
+
361
+ def test_temperature_pin_from_table_dir(tmp_path):
362
+ raw = TEMP.read_bytes(); sha = hashlib.sha256(raw).hexdigest()
363
+ assert native.table_pin(TEMP, "/some/model") == native.TEMPERATURE_SHA
364
+ other = json.loads(raw); other["table_meta"] = {"note": "refit"}
365
+ d = tmp_path / "g3"; d.mkdir(); t = d / "temperature.json"; t.write_text(json.dumps(other))
366
+ osha = hashlib.sha256(t.read_bytes()).hexdigest()
367
+ assert native.table_pin(t) == native.TEMPERATURE_SHA # no manifest: the frozen pin (refused later)
368
+ (d / "SHA256SUMS").write_text(f"{osha} temperature.json\n")
369
+ assert native.table_pin(t) == osha
370
+ (d / "serving.json").write_text(json.dumps({"temperature": "temperature.json", "temperature_sha256": "f" * 64}))
371
+ assert native.table_pin(t) == "f" * 64 # serving.json wins; a mismatch is still refused
372
+ with pytest.raises(ValueError, match="hash mismatch"):
373
+ native.NativeV2("http://unused", t, temperature_sha256=native.table_pin(t))
374
+ (d / "serving.json").write_text(json.dumps({"temperature": "temperature.json", "temperature_sha256": osha}))
375
+ native.NativeV2("http://unused", t, temperature_sha256=native.table_pin(t)) # loads
376
+ assert sha == native.TEMPERATURE_SHA
377
+
378
+
379
+ def test_vllm_limits():
380
+ args = video_vllm_args(VISION_VLLM_ARGS)
381
+ i = args.index("--limit-mm-per-prompt")
382
+ assert json.loads(args[i + 1]) == {"image": 32, "video": 2} and args[:i] == VISION_VLLM_ARGS[:i] and args[i + 2:] == VISION_VLLM_ARGS[i + 2:]
383
+ assert json.loads(video_vllm_args(["--limit-mm-per-prompt", '{"image": 40, "video": 0}'])[1]) == {"image": 40, "video": 2}