Text Generation
Transformers
Safetensors
GGUF
English
qwen3_5
image-text-to-text
decision-model
typed-decisions
calibration
calibrated-probabilities
classification
tool-selection
agent-routing
decision-index
jevbench
jev-compatible
systemone
wald
wald-q4b
qwen3.5
4b
vllm
reasoning
llama.cpp
conversational
Eval Results (legacy)
Instructions to use org2ai/Wald-4B with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use org2ai/Wald-4B with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-generation", model="org2ai/Wald-4B") messages = [ { "role": "user", "content": [ {"type": "image", "url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/p-blog/candy.JPG"}, {"type": "text", "text": "What animal is on the candy?"} ] }, ] pipe(text=messages)# pip install -U transformers accelerate # Load model directly from transformers import AutoProcessor, AutoModelForMultimodalLM processor = AutoProcessor.from_pretrained("org2ai/Wald-4B") model = AutoModelForMultimodalLM.from_pretrained("org2ai/Wald-4B", device_map="auto") messages = [ { "role": "user", "content": [ {"type": "image", "url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/p-blog/candy.JPG"}, {"type": "text", "text": "What animal is on the candy?"} ] }, ] inputs = processor.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", ).to(model.device) outputs = model.generate(**inputs, max_new_tokens=256) print(processor.decode(outputs[0][inputs["input_ids"].shape[-1]:])) - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use org2ai/Wald-4B with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "org2ai/Wald-4B" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "org2ai/Wald-4B", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker
docker model run hf.co/org2ai/Wald-4B
- SGLang
How to use org2ai/Wald-4B with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "org2ai/Wald-4B" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "org2ai/Wald-4B", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "org2ai/Wald-4B" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "org2ai/Wald-4B", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }' - Docker Model Runner
How to use org2ai/Wald-4B with Docker Model Runner:
docker model run hf.co/org2ai/Wald-4B
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 +1 -1
- MANIFEST.json +49 -21
- README.md +8 -2
- RUNBOOK.md +4 -0
- docs/api.md +34 -1
- evaluation/v2.1/video.json +111 -0
- run.sh +1 -1
- server/CHANGELOG.md +21 -0
- server/README.md +47 -0
- server/pyproject.toml +2 -1
- server/src/wald_serve/__init__.py +1 -1
- server/src/wald_serve/native.py +23 -1
- server/src/wald_serve/native_vision.py +59 -10
- server/src/wald_serve/video.py +534 -0
- server/src/wald_serve/vision_v2.py +63 -11
- server/tests/fixtures/default-responses-0.2.1.json +775 -0
- server/tests/record_default_fixtures.py +81 -0
- server/tests/stubengine.py +73 -0
- server/tests/test_video.py +383 -0
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":
|
| 12 |
-
"sha256": "
|
| 13 |
},
|
| 14 |
"LICENSE": {
|
| 15 |
"bytes": 11358,
|
|
@@ -24,12 +24,12 @@
|
|
| 24 |
"sha256": "43510ca439ff9f402eeef7c7e0e4a8e3e2265cf0ff8b3e027d662b1bff79078e"
|
| 25 |
},
|
| 26 |
"README.md": {
|
| 27 |
-
"bytes":
|
| 28 |
-
"sha256": "
|
| 29 |
},
|
| 30 |
"RUNBOOK.md": {
|
| 31 |
-
"bytes":
|
| 32 |
-
"sha256": "
|
| 33 |
},
|
| 34 |
"SHA256SUMS": {
|
| 35 |
"sha256": "f5de06dae0fbbeb38f196e95ae13c46123cb466da84d2431261e9d4e37b22ccf",
|
|
@@ -48,8 +48,8 @@
|
|
| 48 |
"sha256": "31f5a5ee9860fb747c0c7e23a9ac75790dcd688de1655c6890eef5ba2ed9b9c3"
|
| 49 |
},
|
| 50 |
"docs/api.md": {
|
| 51 |
-
"bytes":
|
| 52 |
-
"sha256": "
|
| 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":
|
| 276 |
-
"sha256": "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
},
|
| 278 |
"server/README.md": {
|
| 279 |
-
"bytes":
|
| 280 |
-
"sha256": "
|
| 281 |
},
|
| 282 |
"server/pyproject.toml": {
|
| 283 |
-
"bytes":
|
| 284 |
-
"sha256": "
|
| 285 |
},
|
| 286 |
"server/src/wald_serve/__init__.py": {
|
| 287 |
"bytes": 200,
|
| 288 |
-
"sha256": "
|
| 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":
|
| 300 |
-
"sha256": "
|
| 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":
|
| 344 |
-
"sha256": "
|
| 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":
|
| 356 |
-
"sha256": "
|
| 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
|
| 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.
|
| 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 |
"""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),
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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"
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
| 212 |
if "logprobs" in body:
|
| 213 |
chat.update(logprobs=True, top_logprobs=body["logprobs"])
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
| 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}
|