Paris99 commited on
Commit
db8d354
·
verified ·
1 Parent(s): ef004b7

Add H2O-Lightning-4B MLX q8 build

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ tokenizer.json filter=lfs diff=lfs merge=lfs -text
chat_template.jinja ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {%- set image_count = namespace(value=0) %}
2
+ {%- set video_count = namespace(value=0) %}
3
+ {%- macro render_content(content, do_vision_count, is_system_content=false) %}
4
+ {%- if content is string %}
5
+ {{- content }}
6
+ {%- elif content is iterable and content is not mapping %}
7
+ {%- for item in content %}
8
+ {%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
9
+ {%- if is_system_content %}
10
+ {{- raise_exception('System message cannot contain images.') }}
11
+ {%- endif %}
12
+ {%- if do_vision_count %}
13
+ {%- set image_count.value = image_count.value + 1 %}
14
+ {%- endif %}
15
+ {%- if add_vision_id %}
16
+ {{- 'Picture ' ~ image_count.value ~ ': ' }}
17
+ {%- endif %}
18
+ {{- '<|vision_start|><|image_pad|><|vision_end|>' }}
19
+ {%- elif 'video' in item or item.type == 'video' %}
20
+ {%- if is_system_content %}
21
+ {{- raise_exception('System message cannot contain videos.') }}
22
+ {%- endif %}
23
+ {%- if do_vision_count %}
24
+ {%- set video_count.value = video_count.value + 1 %}
25
+ {%- endif %}
26
+ {%- if add_vision_id %}
27
+ {{- 'Video ' ~ video_count.value ~ ': ' }}
28
+ {%- endif %}
29
+ {{- '<|vision_start|><|video_pad|><|vision_end|>' }}
30
+ {%- elif 'text' in item %}
31
+ {{- item.text }}
32
+ {%- else %}
33
+ {{- raise_exception('Unexpected item type in content.') }}
34
+ {%- endif %}
35
+ {%- endfor %}
36
+ {%- elif content is none or content is undefined %}
37
+ {{- '' }}
38
+ {%- else %}
39
+ {{- raise_exception('Unexpected content type.') }}
40
+ {%- endif %}
41
+ {%- endmacro %}
42
+ {%- if not messages %}
43
+ {{- raise_exception('No messages provided.') }}
44
+ {%- endif %}
45
+ {%- if tools and tools is iterable and tools is not mapping %}
46
+ {{- '<|im_start|>system\n' }}
47
+ {{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
48
+ {%- for tool in tools %}
49
+ {{- "\n" }}
50
+ {{- tool | tojson }}
51
+ {%- endfor %}
52
+ {{- "\n</tools>" }}
53
+ {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
54
+ {%- if messages[0].role == 'system' %}
55
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
56
+ {%- if content %}
57
+ {{- '\n\n' + content }}
58
+ {%- endif %}
59
+ {%- endif %}
60
+ {{- '<|im_end|>\n' }}
61
+ {%- else %}
62
+ {%- if messages[0].role == 'system' %}
63
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
64
+ {{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
65
+ {%- endif %}
66
+ {%- endif %}
67
+ {%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
68
+ {%- for message in messages[::-1] %}
69
+ {%- set index = (messages|length - 1) - loop.index0 %}
70
+ {%- if ns.multi_step_tool and message.role == "user" %}
71
+ {%- set content = render_content(message.content, false)|trim %}
72
+ {%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
73
+ {%- set ns.multi_step_tool = false %}
74
+ {%- set ns.last_query_index = index %}
75
+ {%- endif %}
76
+ {%- endif %}
77
+ {%- endfor %}
78
+ {%- if ns.multi_step_tool %}
79
+ {{- raise_exception('No user query found in messages.') }}
80
+ {%- endif %}
81
+ {%- for message in messages %}
82
+ {%- set content = render_content(message.content, true)|trim %}
83
+ {%- if message.role == "system" %}
84
+ {%- if not loop.first %}
85
+ {{- raise_exception('System message must be at the beginning.') }}
86
+ {%- endif %}
87
+ {%- elif message.role == "user" %}
88
+ {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
89
+ {%- elif message.role == "assistant" %}
90
+ {%- set reasoning_content = '' %}
91
+ {%- if message.reasoning_content is string %}
92
+ {%- set reasoning_content = message.reasoning_content %}
93
+ {%- else %}
94
+ {%- if '</think>' in content %}
95
+ {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
96
+ {%- set content = content.split('</think>')[-1].lstrip('\n') %}
97
+ {%- endif %}
98
+ {%- endif %}
99
+ {%- set reasoning_content = reasoning_content|trim %}
100
+ {%- if loop.index0 > ns.last_query_index %}
101
+ {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
102
+ {%- else %}
103
+ {{- '<|im_start|>' + message.role + '\n' + content }}
104
+ {%- endif %}
105
+ {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
106
+ {%- for tool_call in message.tool_calls %}
107
+ {%- if tool_call.function is defined %}
108
+ {%- set tool_call = tool_call.function %}
109
+ {%- endif %}
110
+ {%- if loop.first %}
111
+ {%- if content|trim %}
112
+ {{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
113
+ {%- else %}
114
+ {{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
115
+ {%- endif %}
116
+ {%- else %}
117
+ {{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
118
+ {%- endif %}
119
+ {%- if tool_call.arguments is defined %}
120
+ {%- for args_name, args_value in tool_call.arguments|items %}
121
+ {{- '<parameter=' + args_name + '>\n' }}
122
+ {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
123
+ {{- args_value }}
124
+ {{- '\n</parameter>\n' }}
125
+ {%- endfor %}
126
+ {%- endif %}
127
+ {{- '</function>\n</tool_call>' }}
128
+ {%- endfor %}
129
+ {%- endif %}
130
+ {{- '<|im_end|>\n' }}
131
+ {%- elif message.role == "tool" %}
132
+ {%- if loop.previtem and loop.previtem.role != "tool" %}
133
+ {{- '<|im_start|>user' }}
134
+ {%- endif %}
135
+ {{- '\n<tool_response>\n' }}
136
+ {{- content }}
137
+ {{- '\n</tool_response>' }}
138
+ {%- if not loop.last and loop.nextitem.role != "tool" %}
139
+ {{- '<|im_end|>\n' }}
140
+ {%- elif loop.last %}
141
+ {{- '<|im_end|>\n' }}
142
+ {%- endif %}
143
+ {%- else %}
144
+ {{- raise_exception('Unexpected message role.') }}
145
+ {%- endif %}
146
+ {%- endfor %}
147
+ {%- if add_generation_prompt %}
148
+ {{- '<|im_start|>assistant\n' }}
149
+ {%- if enable_thinking is defined and enable_thinking is false %}
150
+ {{- '<think>\n\n</think>\n\n' }}
151
+ {%- else %}
152
+ {{- '<think>\n' }}
153
+ {%- endif %}
154
+ {%- endif %}
config.json ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "Qwen3_5ForConditionalGeneration"
4
+ ],
5
+ "eos_token_id": 248044,
6
+ "generation_config": {
7
+ "_from_model_config": true,
8
+ "eos_token_id": 248044,
9
+ "transformers_version": "5.15.0",
10
+ "use_cache": true
11
+ },
12
+ "head_dtype": "float32",
13
+ "image_token_id": 248056,
14
+ "model_type": "qwen3_5",
15
+ "quantization": {
16
+ "group_size": 64,
17
+ "bits": 8,
18
+ "mode": "affine"
19
+ },
20
+ "quantization_config": {
21
+ "group_size": 64,
22
+ "bits": 8,
23
+ "mode": "affine"
24
+ },
25
+ "text_config": {
26
+ "attention_bias": false,
27
+ "attention_dropout": 0.0,
28
+ "attn_output_gate": true,
29
+ "bos_token_id": null,
30
+ "dtype": "bfloat16",
31
+ "eos_token_id": 248044,
32
+ "full_attention_interval": 4,
33
+ "head_dim": 256,
34
+ "hidden_act": "silu",
35
+ "hidden_size": 2560,
36
+ "initializer_range": 0.02,
37
+ "intermediate_size": 9216,
38
+ "layer_types": [
39
+ "linear_attention",
40
+ "linear_attention",
41
+ "linear_attention",
42
+ "full_attention",
43
+ "linear_attention",
44
+ "linear_attention",
45
+ "linear_attention",
46
+ "full_attention",
47
+ "linear_attention",
48
+ "linear_attention",
49
+ "linear_attention",
50
+ "full_attention",
51
+ "linear_attention",
52
+ "linear_attention",
53
+ "linear_attention",
54
+ "full_attention",
55
+ "linear_attention",
56
+ "linear_attention",
57
+ "linear_attention",
58
+ "full_attention",
59
+ "linear_attention",
60
+ "linear_attention",
61
+ "linear_attention",
62
+ "full_attention",
63
+ "linear_attention",
64
+ "linear_attention",
65
+ "linear_attention",
66
+ "full_attention",
67
+ "linear_attention",
68
+ "linear_attention",
69
+ "linear_attention",
70
+ "full_attention"
71
+ ],
72
+ "linear_conv_kernel_dim": 4,
73
+ "linear_key_head_dim": 128,
74
+ "linear_num_key_heads": 16,
75
+ "linear_num_value_heads": 32,
76
+ "linear_value_head_dim": 128,
77
+ "mamba_ssm_dtype": "float32",
78
+ "max_position_embeddings": 262144,
79
+ "mlp_only_layers": [],
80
+ "model_type": "qwen3_5_text",
81
+ "mtp_num_hidden_layers": 1,
82
+ "mtp_use_dedicated_embeddings": false,
83
+ "num_attention_heads": 16,
84
+ "num_hidden_layers": 32,
85
+ "num_key_value_heads": 4,
86
+ "pad_token_id": null,
87
+ "partial_rotary_factor": 0.25,
88
+ "rms_norm_eps": 1e-06,
89
+ "rope_parameters": {
90
+ "mrope_interleaved": true,
91
+ "mrope_section": [
92
+ 11,
93
+ 11,
94
+ 10
95
+ ],
96
+ "partial_rotary_factor": 0.25,
97
+ "rope_theta": 10000000,
98
+ "rope_type": "default"
99
+ },
100
+ "tie_word_embeddings": true,
101
+ "transformers_version": "5.15.0",
102
+ "use_cache": true,
103
+ "vocab_size": 248320,
104
+ "head_dtype": "float32"
105
+ },
106
+ "tie_word_embeddings": true,
107
+ "transformers_version": "4.57.0.dev0",
108
+ "video_token_id": 248057,
109
+ "vision_config": {
110
+ "deepstack_visual_indexes": [],
111
+ "depth": 24,
112
+ "hidden_act": "gelu_pytorch_tanh",
113
+ "hidden_size": 1024,
114
+ "in_channels": 3,
115
+ "initializer_range": 0.02,
116
+ "intermediate_size": 4096,
117
+ "model_type": "qwen3_5",
118
+ "num_heads": 16,
119
+ "num_position_embeddings": 2304,
120
+ "out_hidden_size": 2560,
121
+ "patch_size": 16,
122
+ "spatial_merge_size": 2,
123
+ "temporal_patch_size": 2
124
+ },
125
+ "vision_end_token_id": 248054,
126
+ "vision_start_token_id": 248053
127
+ }
generation_config.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "_from_model_config": true,
3
+ "eos_token_id": 248044,
4
+ "transformers_version": "5.15.0",
5
+ "use_cache": true
6
+ }
h2o_lightning_shim.py ADDED
@@ -0,0 +1,911 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """H2O-Lightning-4B shim: typed decisions over a stock vLLM server, one output token per decision. Stdlib only.
3
+
4
+ WHAT IT DOES
5
+ POST /v1/systemone (the `typesafe` decision wire format; /api/alpha/decisions is an alias)
6
+ -> renders each question in the prompt the model was trained on (serve_config.json: the chat template with
7
+ thinking off, the system prompt, the "plain" layout, compact JSON for a structured state, one label per
8
+ option, the "Answer:" prefill)
9
+ -> sends ONE vLLM completion per question with max_tokens=1 and `logprob_token_ids` set to that question's
10
+ label tokens, so every option is read at the answer slot and none can fall out of a top-k
11
+ -> applies the temperature (one for every question type, or one per type: serve_config.json "temperature":
12
+ {"choice": T, "noul": T, "score": T}), then the yes/no commit floor
13
+ -> returns {"model", "answers", "probability_source", "effort_used", "usage"}
14
+
15
+ Several questions about one state are sent concurrently; vLLM's prefix cache shares the state between them, and
16
+ usage.input_tokens counts that shared head once plus each question's own tail.
17
+ It generates no text and calls nothing but the local vLLM server.
18
+
19
+ python3 h2o_lightning_shim.py --config serve_config.json --vllm http://127.0.0.1:8000 --port 8741
20
+
21
+ IMAGES (only when serve_config.json has an "image" block, and only for a request that carries an image)
22
+ Accepted forms: a `data:image/...;base64,` URI or bare base64 image bytes anywhere inside `state` (a string, an
23
+ object value, a list item); a chat-style image part inside `state` ({"type": "image_url", "image_url": {"url"}},
24
+ {"type": "image", "image": ...}, {"type": "input_image", "image_url": ...}, {"type": "base64", "media_type",
25
+ "data"}); the top-level fields `images`, `image`, `image_url`, `image_urls`, `image_data`, `image_base64` (data
26
+ URIs, bare base64, http(s) URLs, or the part objects above); and multipart/form-data with the JSON in a
27
+ `request` field and the images as file parts. A URL is an image only in those explicit places, never as a plain
28
+ string inside `state`. Each image lifted out of `state` leaves `[image N]` in the record (the top-level images are
29
+ numbered first). The question is then read through vLLM's chat completions with the images ahead of the same
30
+ user text, the assistant turn pre-filled with the same "Answer:" (continue_final_message) and the same
31
+ `logprob_token_ids` readout, so the prompt is the text prompt plus the image tokens. A request without an image
32
+ takes the text path above, unchanged.
33
+
34
+ STATUS CODES (an evaluation runner stops after three consecutive failures unless a failure is a 422): a request this
35
+ system cannot answer -- too many options, a malformed question, a prompt over vLLM's context -- is a 422, scored as
36
+ one wrong answer; an empty request is a 400; an unreachable or failing vLLM is a 502, and a vLLM that does not serve
37
+ the configured model is a 503, so a broken backend stops a run; vLLM's own 401/403/429 pass through.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import argparse
42
+ import base64
43
+ import binascii
44
+ import email.parser
45
+ import email.policy
46
+ import http.client
47
+ import socket
48
+ import json
49
+ import math
50
+ import os
51
+ import re
52
+ import sys
53
+ import threading
54
+ import time
55
+ import urllib.parse
56
+ from concurrent.futures import ThreadPoolExecutor
57
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
58
+
59
+ SHIM_VERSION = "h2o-lightning-shim"
60
+ PRIMITIVES = ("choice", "noul", "score")
61
+ EFFORT_TIERS = ("fast", "balanced", "thorough")
62
+ # vLLM 0.30 accepts at most 128 `logprob_token_ids` per request; a question with more options is read in chunks
63
+ LOGPROB_CHUNK = int(os.environ.get("SHIM_LOGPROB_CHUNK", "128"))
64
+ TIMEOUT = float(os.environ.get("SHIM_TIMEOUT", "180"))
65
+ # vLLM's wording when a prompt does not fit --max-model-len (it has changed between releases)
66
+ _CONTEXT_ERROR = re.compile(r"maximum context length|maximum model length|max_model_len|too long and exceeds|"
67
+ r"longer than the maximum", re.I)
68
+ # Qwen3.5's chat template for a system and a user message with the generation prompt and thinking off; the template
69
+ # trims both contents (Jinja's `trim` is Python's str.strip)
70
+ CHATML = {"system": "<|im_start|>system\n{content}<|im_end|>\n",
71
+ "user": "<|im_start|>user\n{content}<|im_end|>\n",
72
+ "generation": "<|im_start|>assistant\n<think>\n\n</think>\n\n"}
73
+ # what the template writes for one image part, before vLLM expands the pad into the image's tokens
74
+ VISION_SLOT = "<|vision_start|><|image_pad|><|vision_end|>"
75
+ MAX_IMAGE_BYTES = int(os.environ.get("SHIM_MAX_IMAGE_BYTES", str(20 * 1024 * 1024)))
76
+
77
+
78
+ class Unprocessable(ValueError):
79
+ """A request in the contract's shape that this system cannot answer: a 422, one wrong item, the run goes on."""
80
+
81
+
82
+ class BadRequest(ValueError):
83
+ """Not the contract's shape at all (no questions, an unknown effort tier): a 400."""
84
+
85
+
86
+ class UpstreamError(RuntimeError):
87
+ """vLLM failed or could not be reached: a 502 (or 503, or vLLM's own 401/403/429), so a run stops."""
88
+
89
+ def __init__(self, message, status=502):
90
+ super().__init__(message)
91
+ self.status = status
92
+
93
+
94
+ # ------------------------------------------------------------------------------------------------- the prompt
95
+ def render_state(state, compact):
96
+ """The record: a string verbatim, anything else as JSON (compact when the config says so)."""
97
+ if isinstance(state, str):
98
+ return state
99
+ if compact:
100
+ return json.dumps(state, ensure_ascii=False, separators=(",", ":"))
101
+ return json.dumps(state, indent=1)
102
+
103
+
104
+ def desc_text(v):
105
+ """An option description or score level: a string verbatim, anything else as JSON."""
106
+ return v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)
107
+
108
+
109
+ def render_instr(value):
110
+ """`instructions` may be a string, an object or an array."""
111
+ return value if isinstance(value, str) else json.dumps(value, indent=1)
112
+
113
+
114
+ def options_of(question, max_options=255):
115
+ """(option names, descriptions) for the three question types. A yes/no question is always [true, false]."""
116
+ typ = question.get("type", "choice")
117
+ criteria = question.get("criteria")
118
+ if typ not in PRIMITIVES:
119
+ raise Unprocessable(f"question type {typ!r} is not one of {list(PRIMITIVES)}")
120
+ if typ == "noul":
121
+ criteria = criteria or {}
122
+ if not isinstance(criteria, dict):
123
+ raise Unprocessable("noul criteria must be an object with true/false descriptions")
124
+ true_desc = criteria.get("true", criteria.get("yes"))
125
+ false_desc = criteria.get("false", criteria.get("no"))
126
+ return ["true", "false"], [
127
+ desc_text(true_desc) if true_desc not in (None, "") else "the statement holds",
128
+ desc_text(false_desc) if false_desc not in (None, "") else "it does not",
129
+ ]
130
+ if typ == "score":
131
+ if not isinstance(criteria, list) or len(criteria) < 1:
132
+ raise Unprocessable("score criteria must be an ordered array of levels")
133
+ return [str(i) for i in range(len(criteria))], [desc_text(value) for value in criteria]
134
+ if not isinstance(criteria, dict) or len(criteria) < 1:
135
+ raise Unprocessable("choice criteria must map option -> description")
136
+ if len(criteria) > max_options:
137
+ raise Unprocessable(f"choice supports at most {max_options} options")
138
+ names = list(criteria)
139
+ return names, [desc_text(criteria[name]) if criteria[name] is not None else name for name in names]
140
+
141
+
142
+ def option_lines(labels, names, descs):
143
+ """One line per option: `A) name: description`."""
144
+ if len(labels) < len(names):
145
+ raise Unprocessable(f"{len(names)} options but only {len(labels)} labels")
146
+ return [f"{l}) {n}: {d}" for l, n, d in zip(labels, names, descs)]
147
+
148
+
149
+ def user_turn(state_text, instructions, names, descs, labels, layout="plain"):
150
+ """The user message."""
151
+ body = "\n".join(option_lines(labels, names, descs))
152
+ if layout == "plain":
153
+ return f"record: {state_text}\nquestion: {instructions}\noptions:\n{body}"
154
+ if layout == "markdown":
155
+ return f"### RECORD\n{state_text}\n\n### QUESTION\n{instructions}\n\n### OPTIONS\n{body}\n"
156
+ raise ValueError(f"layout {layout!r} is not supported")
157
+
158
+
159
+ def render_chat(system, user, prefill, chat=True):
160
+ """The full prompt: the chat template for [system, user] with thinking off, then the answer prefill. Without the
161
+ template, the same parts joined by blank lines."""
162
+ if not chat:
163
+ return "\n\n".join(([] if system is None else [system]) + [user]) + f"\n{prefill}"
164
+ out = "" if system is None else CHATML["system"].replace("{content}", system.strip())
165
+ return out + CHATML["user"].replace("{content}", user.strip()) + CHATML["generation"] + prefill
166
+
167
+
168
+ def chat_messages(system, user, prefill, image_urls):
169
+ """The same prompt as render_chat, as chat messages for vLLM: the images ahead of the user text, and the assistant
170
+ turn pre-filled with the prefill (sent with continue_final_message). Qwen3.5's template renders a final assistant
171
+ turn after the last user turn as `<think>\\n\\n</think>\\n\\n` + its content, which is render_chat's generation
172
+ prompt + prefill, so with no image the two token streams are the same (checked against vLLM at first use)."""
173
+ msgs = [] if system is None else [{"role": "system", "content": system.strip()}]
174
+ parts = [{"type": "image_url", "image_url": {"url": u}} for u in image_urls]
175
+ msgs.append({"role": "user", "content": parts + [{"type": "text", "text": user.strip()}]})
176
+ msgs.append({"role": "assistant", "content": prefill})
177
+ return msgs
178
+
179
+
180
+ # ------------------------------------------------------------------------------------------------- images
181
+ IMAGE_KEYS = ("images", "image", "image_url", "image_urls", "image_data", "image_base64")
182
+ _DATA_URI = re.compile(r"data:(image/[A-Za-z0-9.+-]+);base64,(.*)", re.S | re.I)
183
+ _B64_CHARS = re.compile(r"[A-Za-z0-9+/=\s]+")
184
+ _B64_URLSAFE = re.compile(r"[A-Za-z0-9_\-=\s]+")
185
+ _PART_TYPES = ("image_url", "image", "input_image")
186
+ # how base64 of a PNG, JPEG, GIF and WebP begins: the cheap test before anything inside `state` is decoded
187
+ _B64_HEADS = ("iVBORw0KGgo", "/9j/", "R0lGOD", "UklGR")
188
+
189
+
190
+ def sniff_image(raw):
191
+ """The image type from its first bytes, or None."""
192
+ if raw.startswith(b"\x89PNG\r\n\x1a\n"):
193
+ return "image/png"
194
+ if raw.startswith(b"\xff\xd8\xff"):
195
+ return "image/jpeg"
196
+ if raw.startswith((b"GIF87a", b"GIF89a")):
197
+ return "image/gif"
198
+ if raw[:4] == b"RIFF" and raw[8:12] == b"WEBP":
199
+ return "image/webp"
200
+ if raw.startswith(b"BM"):
201
+ return "image/bmp"
202
+ if raw[:4] in (b"II*\x00", b"MM\x00*"):
203
+ return "image/tiff"
204
+ return None
205
+
206
+
207
+ def _b64_bytes(s):
208
+ """Decoded bytes of a (standard or URL-safe) base64 string, or None."""
209
+ t = re.sub(r"\s+", "", s)
210
+ if not t:
211
+ return None
212
+ try:
213
+ if _B64_CHARS.fullmatch(t):
214
+ return base64.b64decode(t + "=" * (-len(t) % 4), validate=True)
215
+ if _B64_URLSAFE.fullmatch(t):
216
+ return base64.urlsafe_b64decode(t + "=" * (-len(t) % 4))
217
+ except (binascii.Error, ValueError):
218
+ return None
219
+ return None
220
+
221
+
222
+ def _data_uri(mime, raw):
223
+ if len(raw) > MAX_IMAGE_BYTES:
224
+ raise Unprocessable(f"an image of {len(raw)} bytes is over the {MAX_IMAGE_BYTES}-byte limit")
225
+ return f"data:{mime};base64,{base64.b64encode(raw).decode('ascii')}"
226
+
227
+
228
+ def image_from_string(s, explicit):
229
+ """A string as an image URL for vLLM, or None when it is not an image.
230
+
231
+ A `data:image/...;base64,` URI is always an image (a malformed one is a 422). Bare base64 counts only when it
232
+ decodes to known image bytes; inside `state` it must also be one unbroken token of at least 64 characters that
233
+ decodes to a PNG, JPEG, GIF or WebP, so ordinary text never qualifies. An http(s) URL counts only when `explicit`
234
+ (a top-level image field or an image part): a record may hold ordinary links."""
235
+ s = s.strip()
236
+ m = _DATA_URI.match(s)
237
+ if m:
238
+ raw = _b64_bytes(m.group(2))
239
+ if not raw:
240
+ raise Unprocessable("an image data URI does not carry valid base64")
241
+ return _data_uri(sniff_image(raw) or m.group(1).lower(), raw)
242
+ if explicit:
243
+ if re.match(r"https?://", s, re.I):
244
+ return s
245
+ raw = _b64_bytes(s) if len(s) >= 16 else None
246
+ mime = sniff_image(raw) if raw else None
247
+ if mime:
248
+ return _data_uri(mime, raw)
249
+ raise Unprocessable("an image field holds neither a data URI, base64 image bytes nor an http(s) URL")
250
+ if len(s) < 64 or not _B64_CHARS.fullmatch(s) or any(c.isspace() for c in s) or not s.startswith(_B64_HEADS):
251
+ return None
252
+ raw = _b64_bytes(s)
253
+ mime = sniff_image(raw) if raw else None
254
+ return _data_uri(mime, raw) if mime in ("image/png", "image/jpeg", "image/gif", "image/webp") else None
255
+
256
+
257
+ def image_from_part(obj, in_state=False):
258
+ """A chat/API-style image object as an image URL, or None when `obj` is not one. Inside `state` (`in_state`) an
259
+ object shaped like a part whose value is not an image (say {"type": "image", "image": "a sunset"}) is record
260
+ data and stays; at the top level it is a 422."""
261
+ if not isinstance(obj, dict):
262
+ return None
263
+ typ = obj.get("type")
264
+ if typ == "base64" and isinstance(obj.get("data"), str): # {"type": "base64", "media_type", "data"}
265
+ v = f"data:{obj.get('media_type') or 'image/png'};base64,{obj['data']}"
266
+ elif typ in _PART_TYPES:
267
+ v = obj.get(typ) if typ != "input_image" else obj.get("image_url", obj.get("image"))
268
+ if isinstance(v, dict):
269
+ v = v.get("url", v.get("data"))
270
+ if not isinstance(v, str):
271
+ if in_state:
272
+ return None
273
+ raise Unprocessable(f"an image part of type {typ!r} has no url or data")
274
+ else:
275
+ return None
276
+ if in_state and not (_DATA_URI.match(v.strip()) or re.match(r"https?://", v.strip(), re.I)):
277
+ return image_from_string(v, False)
278
+ return image_from_string(v, True)
279
+
280
+
281
+ def explicit_image(v):
282
+ """One entry of a top-level image field: a string or an image object ({"url"}, {"image_url": {"url"}}, a part)."""
283
+ if isinstance(v, str):
284
+ return image_from_string(v, True)
285
+ if isinstance(v, dict):
286
+ got = image_from_part(v)
287
+ if got is not None:
288
+ return got
289
+ for k in ("url", "image_url", "data", "image"):
290
+ inner = v.get(k)
291
+ if isinstance(inner, dict):
292
+ inner = inner.get("url", inner.get("data"))
293
+ if isinstance(inner, str):
294
+ return image_from_string(inner, True)
295
+ raise Unprocessable("an image entry must be a string or an object with a url or base64 data")
296
+
297
+
298
+ def lift_images(state, found):
299
+ """`state` with every image inside it replaced by `[image N]` (N counts on from len(found)); the images are
300
+ appended to `found`. Anything that is not an image is returned as is (the same object when nothing changed)."""
301
+ if isinstance(state, str):
302
+ u = image_from_string(state, False)
303
+ if u is None:
304
+ return state
305
+ found.append(u)
306
+ return f"[image {len(found)}]"
307
+ if isinstance(state, dict):
308
+ u = image_from_part(state, in_state=True)
309
+ if u is not None:
310
+ found.append(u)
311
+ return f"[image {len(found)}]"
312
+ out, changed = {}, False
313
+ for k, v in state.items():
314
+ out[k] = lift_images(v, found)
315
+ changed = changed or out[k] is not v
316
+ return out if changed else state
317
+ if isinstance(state, list):
318
+ out = [lift_images(v, found) for v in state]
319
+ return out if any(a is not b for a, b in zip(out, state)) else state
320
+ return state
321
+
322
+
323
+ def extract_images(body):
324
+ """(state with `[image N]` placeholders, [image URLs]) for a request; (the same state, []) when it has none."""
325
+ found = []
326
+ for k in IMAGE_KEYS:
327
+ v = body.get(k)
328
+ if v is None:
329
+ continue
330
+ for item in (v if isinstance(v, list) else [v]):
331
+ found.append(explicit_image(item))
332
+ return lift_images(body["state"], found), found
333
+
334
+
335
+ def parse_multipart(content_type, raw):
336
+ """A multipart/form-data body as a request object: the JSON from the `request` (or `body`, `json`, `data`) field,
337
+ every file part as an image in the top-level `images` list, after any images the JSON already has."""
338
+ msg = email.parser.BytesParser(policy=email.policy.HTTP).parsebytes(
339
+ b"Content-Type: " + content_type.encode("latin-1") + b"\r\n\r\n" + raw)
340
+ if not msg.is_multipart():
341
+ raise Unprocessable("a multipart request without parts")
342
+ body, files = None, []
343
+ for part in msg.iter_parts():
344
+ name = part.get_param("name", header="content-disposition")
345
+ data = part.get_payload(decode=True) or b""
346
+ if part.get_filename() is not None or (part.get_content_maintype() == "image"):
347
+ mime = sniff_image(data) or (part.get_content_type() if part.get_content_maintype() == "image" else None)
348
+ if not mime:
349
+ raise Unprocessable(f"file part {name!r} is not a recognised image")
350
+ files.append(_data_uri(mime, data))
351
+ elif name in ("request", "body", "json", "data"):
352
+ try:
353
+ body = json.loads(data.decode("utf-8"))
354
+ except ValueError as e:
355
+ raise Unprocessable(f"multipart field {name!r} is not JSON: {e}") from e
356
+ if not isinstance(body, dict):
357
+ raise Unprocessable("a multipart request needs its JSON in a `request` field")
358
+ if files:
359
+ have = body.get("images")
360
+ body["images"] = (have if isinstance(have, list) else ([] if have is None else [have])) + files
361
+ return body
362
+
363
+
364
+ # ------------------------------------------------------------------------------------------------- the answer
365
+ def confidence(p):
366
+ """Normalized maximum: 1 for a point mass, 0 for a uniform distribution."""
367
+ n = len(p)
368
+ if n < 2:
369
+ return 1.0
370
+ return float((max(p) - 1.0 / n) / (1.0 - 1.0 / n))
371
+
372
+
373
+ def probabilities(logprobs, temperature):
374
+ """softmax(log-probabilities / T) over the options."""
375
+ if not logprobs or not all(isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
376
+ for v in logprobs):
377
+ raise UpstreamError("vLLM returned an invalid log-probability vector")
378
+ v = [x / max(float(temperature), 1e-3) for x in logprobs]
379
+ m = max(v)
380
+ e = [math.exp(x - m) for x in v]
381
+ s = sum(e)
382
+ if not math.isfinite(s) or s <= 0:
383
+ raise UpstreamError("vLLM returned log-probabilities that cannot be normalized")
384
+ return [x / s for x in e]
385
+
386
+
387
+ def commit_noul(p_yes, floor):
388
+ """A yes/no probability with max(P, 1-P) under the floor moves to the floor on its own side (a tie goes to yes).
389
+ The answer never changes; 0 turns the floor off."""
390
+ if not floor:
391
+ return p_yes
392
+ if max(p_yes, 1.0 - p_yes) >= floor:
393
+ return p_yes
394
+ return floor if p_yes >= 0.5 else 1.0 - floor
395
+
396
+
397
+ def noul_threshold(q):
398
+ """A yes/no question's optional decision threshold in [0, 1], else None."""
399
+ if q.get("type", "choice") != "noul":
400
+ return None
401
+ t = q.get("threshold", q.get("noul_threshold"))
402
+ return float(t) if isinstance(t, (int, float)) and 0.0 <= float(t) <= 1.0 else None
403
+
404
+
405
+ def format_answer(typ, names, descs, logprobs, temperature, noul_floor, threshold=None):
406
+ """One question's answer in the wire shape."""
407
+ p = probabilities(logprobs, temperature)
408
+ if typ == "noul":
409
+ py = commit_noul(float(p[0]), noul_floor)
410
+ answer = {"type": "noul", "noul": float(py)}
411
+ if threshold is not None:
412
+ answer["threshold"] = float(threshold)
413
+ answer["decision"] = bool(py >= float(threshold))
414
+ return answer
415
+ if typ == "score":
416
+ return {
417
+ "type": "score",
418
+ "score": float(sum(i * x for i, x in enumerate(p))),
419
+ "legend": {str(i): d for i, d in enumerate(descs)},
420
+ "probabilities": {str(i): float(x) for i, x in enumerate(p)},
421
+ "confidence": confidence(p),
422
+ }
423
+ k = max(range(len(p)), key=p.__getitem__) # the first maximum
424
+ return {
425
+ "type": "choice",
426
+ "choice": names[k],
427
+ "probabilities": {name: float(x) for name, x in zip(names, p)},
428
+ "confidence": confidence(p),
429
+ }
430
+
431
+
432
+ class Contract:
433
+ """The serving settings, from serve_config.json."""
434
+
435
+ def __init__(self, cfg, env=os.environ):
436
+ p = cfg["prompt"]
437
+ self.cfg = cfg
438
+ self.model = cfg["model"]
439
+ self.chat = bool(p.get("chat_template", True))
440
+ if p.get("enable_thinking", False):
441
+ raise ValueError("prompt.enable_thinking must be false: the answer is read after a closed think block")
442
+ self.system = p.get("system") or None
443
+ self.layout = p.get("layout", "plain")
444
+ self.state_compact = bool(p.get("state_compact", True))
445
+ self.prefill = p.get("prefill", "Answer:")
446
+ self.max_state_tokens = int(p.get("max_state_tokens", 32000))
447
+ self.default_instructions = p.get("default_instructions", "Answer the question below.")
448
+ if not isinstance(cfg.get("labels"), list):
449
+ raise ValueError("serve_config.json: `labels` must be the list of option labels, in order")
450
+ self.labels = [str(l) for l in cfg["labels"]][: int(cfg.get("max_options", len(cfg["labels"])))]
451
+ if len(set(self.labels)) != len(self.labels):
452
+ raise ValueError("serve_config.json: the labels are not distinct")
453
+ self.label_ids = None # each label's token id at the answer slot, read from vLLM's tokenizer
454
+ # SHIM_TEMPERATURE / SHIM_NOUL_FLOOR override the config; SHIM_NOUL_FLOOR=0 turns the floor off
455
+ t_env, f_env = env.get("SHIM_TEMPERATURE", "").strip(), env.get("SHIM_NOUL_FLOOR", "").strip()
456
+ # "temperature" is one number for every type, or {"choice": T, "noul": T, "score": T}; SHIM_TEMPERATURE sets
457
+ # every type to one value
458
+ t_cfg = cfg["temperature"]
459
+ if t_env:
460
+ self.temperature_by_type = {t: float(t_env) for t in PRIMITIVES}
461
+ elif isinstance(t_cfg, dict):
462
+ if set(t_cfg) != set(PRIMITIVES):
463
+ raise ValueError(f"temperature by type must give exactly {list(PRIMITIVES)}, got {sorted(t_cfg)}")
464
+ self.temperature_by_type = {t: float(t_cfg[t]) for t in PRIMITIVES}
465
+ else:
466
+ self.temperature_by_type = {t: float(t_cfg) for t in PRIMITIVES}
467
+ self.temperature = self.temperature_by_type["choice"] # the image path's default and the start-up line
468
+ self.temperature_source = "SHIM_TEMPERATURE" if t_env else "serve_config.json"
469
+ self.noul_floor = float(f_env) if f_env else float(cfg["noul_floor"])
470
+ self.noul_floor_source = "SHIM_NOUL_FLOOR" if f_env else "serve_config.json"
471
+ if not all(0.05 <= t <= 20.0 for t in self.temperature_by_type.values()):
472
+ raise ValueError(f"temperature {self.temperature_by_type} is outside [0.05, 20]")
473
+ if self.noul_floor and not 0.5 < self.noul_floor < 1.0:
474
+ raise ValueError(f"noul_floor {self.noul_floor}: must be in (0.5, 1), or 0 to turn it off")
475
+ # IMAGES: on only with an "image" block. SHIM_MAX_IMAGES / SHIM_MAX_PIXELS / SHIM_IMAGE_TEMPERATURE override it;
476
+ # max_pixels goes to vLLM's image processor with every image request (0: the server's own setting)
477
+ im = cfg.get("image")
478
+ self.images = isinstance(im, dict)
479
+ im = im if self.images else {}
480
+ mi, mp, it = (env.get(k, "").strip() for k in ("SHIM_MAX_IMAGES", "SHIM_MAX_PIXELS", "SHIM_IMAGE_TEMPERATURE"))
481
+ self.max_images = int(mi) if mi else int(im.get("max_images", 4))
482
+ self.max_pixels = int(mp) if mp else int(im.get("max_pixels", 0) or 0)
483
+ self.image_temperature = float(it) if it else float(im.get("temperature", self.temperature))
484
+ if self.images and not self.chat:
485
+ raise ValueError("images need prompt.chat_template: the image path renders through the chat template")
486
+ if self.images and not (1 <= self.max_images <= 16 and 0.05 <= self.image_temperature <= 20.0):
487
+ raise ValueError("image.max_images must be in [1, 16] and image.temperature in [0.05, 20]")
488
+
489
+ def question_parts(self, state_text, q, qid="q"):
490
+ """(user text, option names, descriptions, labels) for one question."""
491
+ names, descs = options_of(q, len(self.labels))
492
+ if len(names) > len(self.labels):
493
+ raise Unprocessable(f"question {qid}: {len(names)} options exceeds {len(self.labels)}")
494
+ labels = self.labels[:len(names)]
495
+ instr = render_instr(q.get("instructions", "")).lstrip() or self.default_instructions
496
+ return user_turn(state_text, instr, names, descs, labels, self.layout), names, descs, labels
497
+
498
+ def question_prompt(self, state_text, q, qid="q"):
499
+ """(prompt text, option names, descriptions, labels) for one question."""
500
+ user, names, descs, labels = self.question_parts(state_text, q, qid)
501
+ return render_chat(self.system, user, self.prefill, self.chat), names, descs, labels
502
+
503
+ def image_prompt(self, state_text, q, image_urls, qid="q"):
504
+ """(chat messages, the same prompt as text with one VISION_SLOT per image, names, descriptions, labels)."""
505
+ user, names, descs, labels = self.question_parts(state_text, q, qid)
506
+ text = render_chat(self.system, VISION_SLOT * len(image_urls) + user.strip(), self.prefill, self.chat)
507
+ return chat_messages(self.system, user, self.prefill, image_urls), text, names, descs, labels
508
+
509
+
510
+ # ------------------------------------------------------------------------------------------------- vLLM client
511
+ QUICKACK = getattr(socket, "TCP_QUICKACK", None) # Linux; reset by the kernel, so set per request
512
+
513
+
514
+ class VLLM:
515
+ """A keep-alive HTTP/1.1 connection per thread to one vLLM server."""
516
+
517
+ def __init__(self, base, model):
518
+ u = urllib.parse.urlsplit(base if "://" in base else "http://" + base)
519
+ self.base = f"{u.scheme}://{u.netloc}"
520
+ self.https = u.scheme == "https"
521
+ self.host = u.netloc
522
+ self.model = model
523
+ self.local = threading.local()
524
+
525
+ def _conn(self, fresh=False):
526
+ c = getattr(self.local, "conn", None)
527
+ if c is None or fresh:
528
+ if c is not None:
529
+ c.close()
530
+ cls = http.client.HTTPSConnection if self.https else http.client.HTTPConnection
531
+ c = self.local.conn = cls(self.host, timeout=TIMEOUT)
532
+ return c
533
+
534
+ def call(self, method, path, body=None):
535
+ """(status, parsed JSON or text). A connection the server closed is reopened once."""
536
+ data = None if body is None else json.dumps(body).encode()
537
+ headers = {"Content-Type": "application/json"} if data is not None else {}
538
+ for attempt in (0, 1):
539
+ try:
540
+ c = self._conn(fresh=attempt == 1)
541
+ c.request(method, path, body=data, headers=headers)
542
+ if QUICKACK and c.sock is not None: # ACK vLLM's reply at once: a kept-alive connection would
543
+ c.sock.setsockopt(socket.IPPROTO_TCP, QUICKACK, 1) # otherwise delay it (~40 ms with Nagle)
544
+ r = c.getresponse()
545
+ raw = r.read()
546
+ break
547
+ except (http.client.HTTPException, OSError) as e:
548
+ if attempt == 1:
549
+ raise UpstreamError(f"vLLM at {self.base} unreachable: {type(e).__name__}: {e}") from e
550
+ try:
551
+ return r.status, json.loads(raw)
552
+ except ValueError:
553
+ return r.status, raw.decode("utf-8", "replace")
554
+
555
+ def post(self, path, body, item_errors=(400,)):
556
+ status, out = self.call("POST", path, body)
557
+ if status == 200 and isinstance(out, dict):
558
+ return out
559
+ detail = json.dumps(out)[:500] if isinstance(out, dict) else str(out)[:500]
560
+ if status in (400, 413) and _CONTEXT_ERROR.search(detail):
561
+ raise Unprocessable(f"over the server's context limit: {detail}")
562
+ if status in item_errors:
563
+ raise Unprocessable(f"vLLM refused the request: {detail}")
564
+ raise UpstreamError(f"vLLM {path} HTTP {status}: {detail}", status if status in (401, 403, 429) else 502)
565
+
566
+ def tokenize(self, text):
567
+ return self.post("/tokenize", {"model": self.model, "prompt": text, "add_special_tokens": False})["tokens"]
568
+
569
+ def detokenize(self, ids):
570
+ return self.post("/detokenize", {"model": self.model, "tokens": ids})["prompt"]
571
+
572
+ def label_logprobs(self, prompt, ids):
573
+ """{token id: log-probability} at the answer slot, one completion per LOGPROB_CHUNK labels."""
574
+ out, usage = {}, None
575
+ for k in range(0, len(ids), LOGPROB_CHUNK):
576
+ chunk = ids[k:k + LOGPROB_CHUNK]
577
+ r = self.post("/v1/completions", {
578
+ "model": self.model, "prompt": prompt, "max_tokens": 1, "temperature": 0.0,
579
+ "logprobs": len(chunk), "logprob_token_ids": chunk, "return_tokens_as_token_ids": True,
580
+ "add_special_tokens": False})
581
+ try:
582
+ top = r["choices"][0]["logprobs"]["top_logprobs"][0]
583
+ got = {int(key.split(":", 1)[1]): float(v) for key, v in top.items()}
584
+ except (KeyError, IndexError, TypeError, ValueError) as e:
585
+ raise UpstreamError(f"vLLM returned no label log-probabilities at the answer slot ({e})") from e
586
+ missing = [i for i in chunk if i not in got]
587
+ if missing:
588
+ raise UpstreamError(f"vLLM omitted label tokens {missing[:5]} from logprob_token_ids")
589
+ out.update({i: got[i] for i in chunk})
590
+ usage = usage or r.get("usage") or {}
591
+ return out, usage
592
+
593
+ def tokenize_chat(self, messages):
594
+ """vLLM's token ids for chat messages rendered as the image path renders them (no image parts here)."""
595
+ return self.post("/tokenize", {"model": self.model, "messages": messages, "add_generation_prompt": False,
596
+ "continue_final_message": True, "add_special_tokens": False,
597
+ "chat_template_kwargs": {"enable_thinking": False}})["tokens"]
598
+
599
+ def label_logprobs_chat(self, messages, ids, max_pixels=0):
600
+ """label_logprobs for a chat with images: the final assistant turn (the prefill) is continued, so the answer
601
+ slot is the same position as in the text prompt. One chat completion per LOGPROB_CHUNK labels."""
602
+ out, usage = {}, None
603
+ for k in range(0, len(ids), LOGPROB_CHUNK):
604
+ chunk = ids[k:k + LOGPROB_CHUNK]
605
+ body = {"model": self.model, "messages": messages, "max_completion_tokens": 1, "temperature": 0.0,
606
+ "logprobs": True, "logprob_token_ids": chunk, "return_tokens_as_token_ids": True,
607
+ "add_generation_prompt": False, "continue_final_message": True, "add_special_tokens": False,
608
+ "chat_template_kwargs": {"enable_thinking": False}}
609
+ if max_pixels:
610
+ body["mm_processor_kwargs"] = {"max_pixels": int(max_pixels)}
611
+ # an image vLLM cannot load (a URL it cannot fetch, bytes it cannot decode) is a 422 from vLLM: one bad item
612
+ r = self.post("/v1/chat/completions", body, item_errors=(400, 422))
613
+ try:
614
+ top = r["choices"][0]["logprobs"]["content"][0]["top_logprobs"]
615
+ got = {int(e["token"].split(":", 1)[1]): float(e["logprob"]) for e in top}
616
+ except (KeyError, IndexError, TypeError, ValueError, AttributeError) as e:
617
+ raise UpstreamError(f"vLLM returned no label log-probabilities at the answer slot ({e})") from e
618
+ missing = [i for i in chunk if i not in got]
619
+ if missing:
620
+ raise UpstreamError(f"vLLM omitted label tokens {missing[:5]} from logprob_token_ids")
621
+ out.update({i: got[i] for i in chunk})
622
+ usage = usage or r.get("usage") or {}
623
+ return out, usage
624
+
625
+
626
+ def common_prefix_len(seqs):
627
+ """The length of the longest common prefix of several token id lists; 0 for fewer than two."""
628
+ if len(seqs) < 2:
629
+ return 0
630
+ n = min(len(x) for x in seqs)
631
+ for i in range(n):
632
+ if any(x[i] != seqs[0][i] for x in seqs[1:]):
633
+ return i
634
+ return n
635
+
636
+
637
+ # ------------------------------------------------------------------------------------------------------ server
638
+ class Shim:
639
+ def __init__(self, contract, vllm, min_context=4096):
640
+ self.c = contract
641
+ self.v = vllm
642
+ self.min_context = min_context
643
+ self.ready = None # the backend check, done once at first use (vLLM may start later)
644
+ self.ready_lock = threading.Lock()
645
+ self.max_model_len = None
646
+ self.chat_ok = None # the image path's template check, done once at the first image request
647
+ self.pool = ThreadPoolExecutor(max_workers=int(os.environ.get("SHIM_QUESTION_THREADS", "64")))
648
+
649
+ def check_backend(self):
650
+ """vLLM serves the configured model with a usable context, and every label is ONE token at the answer slot:
651
+ after a probe prompt with all the labels, appending " <label>" adds exactly one token, whose id is read."""
652
+ if self.ready:
653
+ return
654
+ with self.ready_lock:
655
+ if self.ready:
656
+ return
657
+ status, out = self.v.call("GET", "/v1/models")
658
+ models = (out.get("data") or []) if isinstance(out, dict) else []
659
+ entry = next((m for m in models if isinstance(m, dict) and m.get("id") == self.c.model), None)
660
+ if status != 200 or entry is None:
661
+ raise UpstreamError(f"vLLM at {self.v.base} does not serve {self.c.model!r} "
662
+ f"(start it with --served-model-name {self.c.model})", 503)
663
+ self.max_model_len = entry.get("max_model_len")
664
+ if isinstance(self.max_model_len, int) and self.max_model_len < self.min_context:
665
+ raise UpstreamError(f"vLLM's max_model_len {self.max_model_len} is below {self.min_context}", 503)
666
+ n = len(self.c.labels)
667
+ probe = {"type": "choice", "instructions": "Which?", "criteria": {f"o{i}": f"d{i}" for i in range(n)}}
668
+ text = self.c.question_prompt(render_state({"x": 1}, self.c.state_compact), probe)[0]
669
+ base = self.v.tokenize(text)
670
+ ids, bad = {}, []
671
+ for label in self.c.labels:
672
+ full = self.v.tokenize(text + " " + label)
673
+ if len(full) != len(base) + 1 or full[:len(base)] != base:
674
+ bad.append(label)
675
+ else:
676
+ ids[label] = full[-1]
677
+ if bad or len(set(ids.values())) != len(ids):
678
+ raise UpstreamError(f"these labels are not one distinct token at the answer slot under vLLM's "
679
+ f"tokenizer: {bad[:8]} (is this serve_config.json the one for this model?)", 503)
680
+ self.c.label_ids = ids
681
+ self.ready = True
682
+ sys.stderr.write(f"shim: vLLM serves {self.c.model} (max_model_len {self.max_model_len}); "
683
+ f"{n} labels verified\n")
684
+
685
+ def truncate_state(self, text):
686
+ """A record over max_state_tokens keeps its head and its tail around a "..." line (vLLM's tokenizer)."""
687
+ mx = self.c.max_state_tokens
688
+ if not mx or len(text.encode("utf-8", "ignore")) <= mx: # a byte-level BPE never has more tokens than bytes
689
+ return text
690
+ ids = self.v.tokenize(text)
691
+ if len(ids) <= mx:
692
+ return text
693
+ half = mx // 2
694
+ return self.v.detokenize(ids[:half]) + "\n...\n" + self.v.detokenize(ids[-half:])
695
+
696
+ def one(self, prompt, labels):
697
+ ids = [self.c.label_ids[l] for l in labels]
698
+ lp, usage = self.v.label_logprobs(prompt, ids)
699
+ return [lp[i] for i in ids], usage
700
+
701
+ def decide(self, body):
702
+ """The /v1/systemone handler. Raises BadRequest / Unprocessable / UpstreamError."""
703
+ t0 = time.time()
704
+ if not isinstance(body, dict) or "state" not in body or not isinstance(body.get("questions"), dict):
705
+ raise Unprocessable("expected a JSON object with `state` and a `questions` object")
706
+ questions = body["questions"]
707
+ if not questions:
708
+ raise BadRequest("no questions")
709
+ if not all(isinstance(q, dict) for q in questions.values()):
710
+ raise Unprocessable("every question must be an object")
711
+ effort = body.get("effort")
712
+ if effort is not None and not isinstance(effort, (str, dict)):
713
+ raise Unprocessable("effort must be a tier name or an object")
714
+ asked = effort if isinstance(effort, str) or effort is None else effort.get("tier", "fast")
715
+ if asked is not None and asked not in EFFORT_TIERS:
716
+ raise BadRequest(f"effort tier {asked!r} is not one of {list(EFFORT_TIERS)}")
717
+ self.check_backend()
718
+ if self.c.images: # an image deployment: a request that carries an image takes the image path
719
+ state, images = extract_images(body)
720
+ if images:
721
+ return self.decide_images(questions, state, images, asked, t0)
722
+ state_txt = self.truncate_state(render_state(body["state"], self.c.state_compact))
723
+ work = []
724
+ for qid, q in questions.items():
725
+ text, names, descs, labels = self.c.question_prompt(state_txt, q, qid)
726
+ work.append((qid, q.get("type", "choice"), names, descs, text, labels, noul_threshold(q)))
727
+ shared = 0
728
+ if len(work) == 1:
729
+ reads = [self.one(work[0][4], work[0][5])]
730
+ else: # concurrently: vLLM batches them and its prefix cache shares the state
731
+ futs = [self.pool.submit(self.one, w[4], w[5]) for w in work]
732
+ toks = [self.pool.submit(self.v.tokenize, w[4]) for w in work]
733
+ reads = [f.result() for f in futs]
734
+ # COUNT THE SHARED HEAD ONCE: the system prompt and the record are computed once by vLLM's prefix
735
+ # cache, so N whole prompts would bill the record N times
736
+ shared = common_prefix_len([f.result() for f in toks])
737
+ return self.respond(work, reads, shared, asked, t0, self.c.temperature_by_type)
738
+
739
+ def check_chat(self):
740
+ """Once, before the first image request: vLLM's chat template renders our messages (with no image) to exactly
741
+ the tokens of the text prompt, so the image path differs from the trained prompt only by the image tokens."""
742
+ if self.chat_ok:
743
+ return
744
+ with self.ready_lock:
745
+ if self.chat_ok:
746
+ return
747
+ probe = {"type": "choice", "instructions": "Which?", "criteria": {"a": "first", "b": "second"}}
748
+ user, _n, _d, _l = self.c.question_parts(render_state({"x": 1}, self.c.state_compact), probe)
749
+ want = self.v.tokenize(render_chat(self.c.system, user, self.c.prefill, self.c.chat))
750
+ got = self.v.tokenize_chat(chat_messages(self.c.system, user, self.c.prefill, []))
751
+ if got != want:
752
+ k = common_prefix_len([got, want]) if got and want else 0
753
+ raise UpstreamError(f"vLLM's chat template does not render the trained prompt (tokens differ from "
754
+ f"position {k} of {len(want)}); is this the model's chat_template.jinja?", 503)
755
+ self.chat_ok = True
756
+ sys.stderr.write("shim: image path verified (chat template renders the trained prompt)\n")
757
+
758
+ def one_chat(self, messages, labels):
759
+ ids = [self.c.label_ids[l] for l in labels]
760
+ lp, usage = self.v.label_logprobs_chat(messages, ids, self.c.max_pixels)
761
+ return [lp[i] for i in ids], usage
762
+
763
+ def decide_images(self, questions, state, images, asked, t0):
764
+ """The image path: the same record and question text, the images ahead of it, read through chat completions."""
765
+ if len(images) > self.c.max_images:
766
+ raise Unprocessable(f"{len(images)} images; this deployment reads at most {self.c.max_images} per request")
767
+ self.check_chat()
768
+ state_txt = self.truncate_state(render_state(state, self.c.state_compact))
769
+ work, msgs = [], []
770
+ for qid, q in questions.items():
771
+ m, text, names, descs, labels = self.c.image_prompt(state_txt, q, images, qid)
772
+ msgs.append(m)
773
+ work.append((qid, q.get("type", "choice"), names, descs, text, labels, noul_threshold(q)))
774
+ shared = 0
775
+ if len(work) == 1:
776
+ reads = [self.one_chat(msgs[0], work[0][5])]
777
+ else:
778
+ futs = [self.pool.submit(self.one_chat, m, w[5]) for m, w in zip(msgs, work)]
779
+ toks = [self.pool.submit(self.v.tokenize, w[4]) for w in work]
780
+ reads = [f.result() for f in futs]
781
+ toks = [f.result() for f in toks]
782
+ # the shared head holds the images: their tokens are what vLLM counted beyond the one-slot text prompt
783
+ extra = max(0, int(reads[0][1].get("prompt_tokens") or 0) - len(toks[0]))
784
+ shared = common_prefix_len(toks) + extra
785
+ return self.respond(work, reads, shared, asked, t0, self.c.image_temperature, len(images))
786
+
787
+ def respond(self, work, reads, shared, asked, t0, temperature, n_images=0):
788
+ """The response: each answer at `temperature` (one value, or {type: T}; then the yes/no floor), and usage with the
789
+ shared head once."""
790
+ answers, ntok = {}, 0
791
+ for (qid, typ, names, descs, _t, _l, thr), (lp, usage) in zip(work, reads):
792
+ t = temperature[typ] if isinstance(temperature, dict) else temperature
793
+ answers[qid] = format_answer(typ, names, descs, lp, t, self.c.noul_floor, thr)
794
+ ntok += int(usage.get("prompt_tokens") or 0)
795
+ ntok -= shared * (len(work) - 1) # = the shared head + each question's tail
796
+ used = {"tier": "fast", "passes_per_decision": 1}
797
+ if asked and asked != "fast":
798
+ used["requested"] = asked
799
+ used["note"] = (f"tier {asked!r} is accepted for API compatibility but not implemented; this response "
800
+ f"was produced at tier 'fast' with one forward pass per decision")
801
+ out = {"model": self.c.model, "answers": answers, "probability_source": "native", "effort_used": used,
802
+ "usage": {"input_tokens": ntok, "output_tokens": 0, "latency_ms": round((time.time() - t0) * 1000, 1)}}
803
+ if n_images:
804
+ out["usage"]["images"] = n_images
805
+ return out
806
+
807
+ def health(self):
808
+ c = self.c
809
+ return {"ok": bool(self.ready), "model": c.model, "shim": SHIM_VERSION, "backend": f"vllm {self.v.base}",
810
+ "layout": c.layout, "chat_template": c.chat, "state_compact": c.state_compact, "prefill": c.prefill,
811
+ "max_state_tokens": c.max_state_tokens,
812
+ "noul_floor": {"value": c.noul_floor, "source": c.noul_floor_source},
813
+ "temperature_by_type": dict(c.temperature_by_type),
814
+ "temperature_source": c.temperature_source, "max_options": len(c.labels),
815
+ "vllm_max_model_len": self.max_model_len,
816
+ **({"images": {"max_images": c.max_images, "max_pixels": c.max_pixels or None,
817
+ "temperature": c.image_temperature, "verified": bool(self.chat_ok)}}
818
+ if c.images else {})}
819
+
820
+
821
+ def make_handler(shim):
822
+ class Handler(BaseHTTPRequestHandler):
823
+ protocol_version = "HTTP/1.1"
824
+ # TCP_NODELAY on each client connection: the headers and the body go out as two writes, and on a kept-alive
825
+ # connection Nagle would hold the body until the client's (delayed, ~40 ms) ACK of the headers
826
+ disable_nagle_algorithm = True
827
+
828
+ def log_message(self, fmt, *args):
829
+ if os.environ.get("SHIM_ACCESS_LOG", "") == "1":
830
+ sys.stderr.write("shim %s\n" % (fmt % args))
831
+
832
+ def _send(self, code, obj):
833
+ payload = json.dumps(obj).encode()
834
+ self.send_response(code)
835
+ self.send_header("Content-Type", "application/json")
836
+ self.send_header("Content-Length", str(len(payload)))
837
+ self.end_headers()
838
+ self.wfile.write(payload)
839
+
840
+ def do_GET(self):
841
+ if self.path.startswith("/health"):
842
+ try:
843
+ shim.check_backend()
844
+ except (UpstreamError, Unprocessable) as e:
845
+ return self._send(503, {**shim.health(), "error": str(e)})
846
+ return self._send(200, shim.health())
847
+ if self.path.startswith("/v1/models"):
848
+ return self._send(200, {"object": "list", "data": [{"id": shim.c.model, "readout": "native label "
849
+ "log-probabilities over vLLM"}]})
850
+ self._send(404, {"error": "not found"})
851
+
852
+ def do_POST(self):
853
+ if not self.path.startswith(("/v1/systemone", "/api/alpha/decisions")):
854
+ return self._send(404, {"error": "not found"})
855
+ ctype = self.headers.get("Content-Type") or ""
856
+ try:
857
+ n = int(self.headers.get("Content-Length") or 0)
858
+ raw = self.rfile.read(n)
859
+ if shim.c.images and ctype.lower().startswith("multipart/form-data"):
860
+ body = parse_multipart(ctype, raw)
861
+ else:
862
+ body = json.loads(raw or b"null")
863
+ except Unprocessable as e:
864
+ return self._send(422, {"detail": str(e)})
865
+ except (ValueError, OSError) as e:
866
+ return self._send(422, {"detail": f"request body is not JSON: {e}"})
867
+ try:
868
+ return self._send(200, shim.decide(body))
869
+ except BadRequest as e:
870
+ return self._send(400, {"detail": str(e)})
871
+ except Unprocessable as e:
872
+ return self._send(422, {"detail": str(e)})
873
+ except UpstreamError as e:
874
+ return self._send(e.status, {"detail": str(e)})
875
+ except Exception as e: # one item's failure is a 422, never a 500
876
+ import traceback
877
+ traceback.print_exc()
878
+ return self._send(422, {"detail": f"{type(e).__name__}: {str(e)[:300]}"})
879
+
880
+ return Handler
881
+
882
+
883
+ class Server(ThreadingHTTPServer):
884
+ daemon_threads = True
885
+ request_queue_size = 512 # the standard library's listen backlog of 5 resets concurrent clients
886
+
887
+
888
+ def main(argv=None):
889
+ here = os.path.dirname(os.path.abspath(__file__))
890
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
891
+ ap.add_argument("--config", default=os.environ.get("SHIM_CONFIG", os.path.join(here, "serve_config.json")))
892
+ ap.add_argument("--vllm", default=os.environ.get("SHIM_VLLM", "http://127.0.0.1:8000"), help="vLLM base URL")
893
+ ap.add_argument("--model", default=os.environ.get("SHIM_MODEL"),
894
+ help="vLLM's --served-model-name (default: serve_config.json's `model`)")
895
+ ap.add_argument("--host", default=os.environ.get("SHIM_HOST", "127.0.0.1"))
896
+ ap.add_argument("--port", type=int, default=int(os.environ.get("SHIM_PORT", "8741")))
897
+ ap.add_argument("--min-context", type=int, default=int(os.environ.get("SHIM_MIN_CONTEXT", "4096")))
898
+ a = ap.parse_args(argv)
899
+ cfg = json.load(open(a.config))
900
+ if a.model:
901
+ cfg["model"] = a.model
902
+ contract = Contract(cfg)
903
+ shim = Shim(contract, VLLM(a.vllm, contract.model), a.min_context)
904
+ srv = Server((a.host, a.port), make_handler(shim))
905
+ print(f"shim on {a.host}:{a.port} -> {a.vllm} (model {contract.model}, T {contract.temperature_by_type}, "
906
+ f"yes/no floor {contract.noul_floor}, {len(contract.labels)} labels)", flush=True)
907
+ srv.serve_forever()
908
+
909
+
910
+ if __name__ == "__main__":
911
+ main()
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dc68c3d37b5f14a33df34776f3396ae86b49e1bf11ef60dee4a2d1781a6e689d
3
+ size 5136694595
model.safetensors.index.json ADDED
The diff for this file is too large to render. See raw diff
 
preprocessor_config.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "size": {
3
+ "longest_edge": 16777216,
4
+ "shortest_edge": 65536
5
+ },
6
+ "patch_size": 16,
7
+ "temporal_patch_size": 2,
8
+ "merge_size": 2,
9
+ "image_mean": [
10
+ 0.5,
11
+ 0.5,
12
+ 0.5
13
+ ],
14
+ "image_std": [
15
+ 0.5,
16
+ 0.5,
17
+ 0.5
18
+ ],
19
+ "processor_class": "Qwen3VLProcessor",
20
+ "image_processor_type": "Qwen2VLImageProcessorFast"
21
+ }
processor_config.json ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "image_processor": {
3
+ "do_convert_rgb": true,
4
+ "do_normalize": true,
5
+ "do_rescale": true,
6
+ "image_mean": [
7
+ 0.5,
8
+ 0.5,
9
+ 0.5
10
+ ],
11
+ "image_processor_type": "Qwen3VLImageProcessor",
12
+ "image_std": [
13
+ 0.5,
14
+ 0.5,
15
+ 0.5
16
+ ],
17
+ "max_pixels": 16777216,
18
+ "merge_size": 2,
19
+ "min_pixels": 65536,
20
+ "patch_size": 16,
21
+ "rescale_factor": 0.00392156862745098,
22
+ "temporal_patch_size": 2
23
+ },
24
+ "processor_class": "Qwen3VLProcessor",
25
+ "video_processor": {
26
+ "do_convert_rgb": true,
27
+ "do_normalize": true,
28
+ "do_rescale": true,
29
+ "fps": 2.0,
30
+ "image_mean": [
31
+ 0.5,
32
+ 0.5,
33
+ 0.5
34
+ ],
35
+ "image_std": [
36
+ 0.5,
37
+ 0.5,
38
+ 0.5
39
+ ],
40
+ "max_frames": 768,
41
+ "max_pixels": 25165824,
42
+ "merge_size": 2,
43
+ "min_frames": 4,
44
+ "min_pixels": 4096,
45
+ "patch_size": 16,
46
+ "rescale_factor": 0.00392156862745098,
47
+ "temporal_patch_size": 2,
48
+ "video_processor_type": "Qwen3VLVideoProcessor"
49
+ }
50
+ }
serve_config.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "h2oai/h2o-lightning-4b",
3
+ "base_model": "Qwen/Qwen3.5-4B",
4
+ "base_model_revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
5
+ "temperature": {"choice": 0.75, "noul": 0.8, "score": 0.65},
6
+ "noul_floor": 0.801,
7
+ "prompt": {
8
+ "chat_template": true,
9
+ "enable_thinking": false,
10
+ "system": "You are a decision engine. You read a record and answer one question about it by choosing exactly one option. Reply with a single letter.",
11
+ "layout": "plain",
12
+ "state_compact": true,
13
+ "prefill": "Answer:",
14
+ "max_state_tokens": 32000
15
+ },
16
+ "labels": ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "α", "β", "γ", "δ", "ε", "ζ", "η", "θ", "ι", "κ", "λ", "μ", "ν", "ξ", "ο", "π", "σ", "τ", "υ", "φ", "χ", "ψ", "ω", "in", "er", "on", "re", "at", "st", "en", "or", "th", "le", "it", "an", "ar", "al", "the", "ou", "is", "ing", "es", "ion", "ed", "ic", "et", "ro", "as", "el", "ct", "nd", "ent", "id", "am", "to", "of", "om", "im", "il", "and", "ur", "se", "ex", "ad", "ch", "ut", "if", "em", "ol", "ig", "iv", "ce", "od", "ate", "ag", "ay", "ot", "us", "un", "ul", "ue", "ow", "ew", "for", "ab", "ort", "um", "ame", "pe", "tr", "ck", "ist", "he", "lo", "be", "ers", "con", "ap", "ub", "ass", "int", "ly", "urn", "av", "ir", "nt", "end", "de", "ith", "out", "our", "lic", "res", "pt", "wh", "ver", "age", "ht", "ext", "ess", "os", "ect", "ke", "rom", "qu", "iz", "op", "up", "get", "ile", "ata", "ore", "ri", "pro", "ter", "ain", "com", "art", "ack", "est", "ine", "ill", "ind", "ere", "elf", "orm", "ult", "str", "you", "pl", "new", "ld", "oc", "ant", "ase", "ave", "ang", "are", "ial", "act", "ive", "ode", "ost", "not", "og", "ord", "all", "ff", "ont", "ime", "pr", "ies", "ure", "by", "ire", "sh", "ice", "ast", "ok", "cl", "ard", "was", "ip", "The", "ide", "ace", "ib", "ac", "rou", "we", "ak", "ve", "ath", "oid", "ust", "que", "ans", "pp", "one", "ail", "ach", "div", "ell"],
17
+ "max_options": 255,
18
+ "image": {
19
+ "max_images": 4,
20
+ "max_pixels": 1048576,
21
+ "temperature": 0.65
22
+ }
23
+ }
tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a5cd9732badce41de57e6efce8302930ded1c1188c5f81feb2bd6c24c4a1941f
3
+ size 19989339
tokenizer_config.json ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": false,
3
+ "audio_bos_token": "<|audio_start|>",
4
+ "audio_eos_token": "<|audio_end|>",
5
+ "audio_token": "<|audio_pad|>",
6
+ "backend": "tokenizers",
7
+ "bos_token": null,
8
+ "clean_up_tokenization_spaces": false,
9
+ "eos_token": "<|im_end|>",
10
+ "errors": "replace",
11
+ "extra_special_tokens": [
12
+ "<|im_start|>",
13
+ "<|im_end|>",
14
+ "<|object_ref_start|>",
15
+ "<|object_ref_end|>",
16
+ "<|box_start|>",
17
+ "<|box_end|>",
18
+ "<|quad_start|>",
19
+ "<|quad_end|>",
20
+ "<|vision_start|>",
21
+ "<|vision_end|>",
22
+ "<|vision_pad|>",
23
+ "<|image_pad|>",
24
+ "<|video_pad|>"
25
+ ],
26
+ "image_token": "<|image_pad|>",
27
+ "is_local": true,
28
+ "local_files_only": false,
29
+ "merges_file": null,
30
+ "model_max_length": 262144,
31
+ "model_specific_special_tokens": {
32
+ "audio_bos_token": "<|audio_start|>",
33
+ "audio_eos_token": "<|audio_end|>",
34
+ "audio_token": "<|audio_pad|>",
35
+ "image_token": "<|image_pad|>",
36
+ "video_token": "<|video_pad|>",
37
+ "vision_bos_token": "<|vision_start|>",
38
+ "vision_eos_token": "<|vision_end|>"
39
+ },
40
+ "pad_token": "<|endoftext|>",
41
+ "pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
42
+ "processor_class": "Qwen3VLProcessor",
43
+ "split_special_tokens": false,
44
+ "tokenizer_class": "Qwen3_5Tokenizer",
45
+ "unk_token": null,
46
+ "video_token": "<|video_pad|>",
47
+ "vision_bos_token": "<|vision_start|>",
48
+ "vision_eos_token": "<|vision_end|>"
49
+ }
video_preprocessor_config.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "size": {
3
+ "longest_edge": 25165824,
4
+ "shortest_edge": 4096
5
+ },
6
+ "patch_size": 16,
7
+ "temporal_patch_size": 2,
8
+ "merge_size": 2,
9
+ "image_mean": [
10
+ 0.5,
11
+ 0.5,
12
+ 0.5
13
+ ],
14
+ "image_std": [
15
+ 0.5,
16
+ 0.5,
17
+ 0.5
18
+ ],
19
+ "processor_class": "Qwen3VLProcessor",
20
+ "video_processor_type": "Qwen3VLVideoProcessor"
21
+ }
vocab.json ADDED
The diff for this file is too large to render. See raw diff