ironbcc commited on
Commit
085f380
·
verified ·
1 Parent(s): a27d200

add serve/system1_vllm_backend.py

Browse files
Files changed (1) hide show
  1. serve/system1_vllm_backend.py +181 -0
serve/system1_vllm_backend.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """System One server on top of a STOCK vLLM OpenAI server (no custom vLLM code).
2
+
3
+ Run: python system1_vllm_backend.py --vllm-url http://HOST:8000 --served-model s1 --port 8020
4
+
5
+ Readout (zero generated content): the prompt is rendered and tokenised locally (byte-identical to training), then ONE
6
+ `/v1/completions` request is sent with prompt=<token ids>, max_tokens=1, allowed_token_ids=<candidate ids>, logprobs=K.
7
+ With the server flag `--logprobs-mode processed_logprobs` the returned logprobs are taken AFTER the allowed-token mask, so
8
+ they are exactly log_softmax over the candidates of the next-token logits at the assistant prefix. The backend's scores
9
+ are those logprobs; the Service's softmax (calibrated_probabilities, T=1) is shift-invariant, so probabilities equal the
10
+ MLX backend's softmax over raw candidate logits. Default raw_logprobs mode would return the top-K of the FULL vocabulary
11
+ (candidates can be missing), so a missing candidate or a non-normalised row raises instead of returning wrong scores.
12
+ Sampling params are pinned (temperature 1, top_p 1, top_k -1) because vLLM otherwise applies the model's generation_config.
13
+
14
+ Fallback `--readout prompt_logprobs`: one request per candidate with prompt = prefix+[candidate id], prompt_logprobs=0; the
15
+ logprob of the last prompt token is log_softmax(full vocab)[candidate]. Exact too (constant shift), needs no server flag, but
16
+ K prefills (vLLM does not reuse the prefix cache for prompt_logprobs requests) and large payloads; use it as a GPU control."""
17
+ import argparse
18
+ import base64
19
+ import json
20
+ import mimetypes
21
+ import math
22
+ import os
23
+ import urllib.error
24
+ import urllib.request
25
+
26
+ from system1_client import MAX_CONTEXT_TOKENS, OverlengthError
27
+ from system1_server import make_server
28
+
29
+ MODEL_ID, MODEL_REVISION = "Qwen/Qwen3.5-9B", "c202236235762e1c871ad0ccb60c8ee5ba337b9a"
30
+
31
+
32
+ def candidate_ids(tokenizer, prompt, prefix, keys):
33
+ """Same rules as MlxBackend.score: each key is exactly one token appended at the assistant prefix; ids distinct."""
34
+ ids = {}
35
+ for key in keys:
36
+ extended = tokenizer.encode(prompt + key, add_special_tokens=False)
37
+ if extended[:-1] != prefix or tokenizer.decode(extended[-1:]) != key:
38
+ raise ValueError(f"candidate {key}: not one token at assistant prefix")
39
+ ids[key] = int(extended[-1])
40
+ if len(set(ids.values())) != len(keys):
41
+ raise ValueError("candidate token IDs collide")
42
+ return ids
43
+
44
+
45
+ def load_tokenizer(model=MODEL_ID, revision=MODEL_REVISION):
46
+ from huggingface_hub import snapshot_download
47
+ from transformers import AutoTokenizer
48
+ path = model if os.path.isdir(model) else snapshot_download(model, revision=revision, local_files_only=True)
49
+ return AutoTokenizer.from_pretrained(path, local_files_only=True, trust_remote_code=False)
50
+
51
+
52
+ class VllmBackend:
53
+ def __init__(self, vllm_url, served_model, tokenizer=None, readout="allowed_logprobs", timeout=60, api_key=None,
54
+ image_root=None):
55
+ if readout not in ("allowed_logprobs", "prompt_logprobs"):
56
+ raise ValueError(f"unknown readout {readout!r}")
57
+ self.url = vllm_url.rstrip("/") + "/v1/completions"
58
+ self.chat_url = vllm_url.rstrip("/") + "/v1/chat/completions"
59
+ self.image_root = os.path.realpath(image_root) if image_root else None # request-supplied file paths must live here
60
+ self.model, self.readout, self.timeout, self.api_key = served_model, readout, timeout, api_key
61
+ self.tokenizer = tokenizer or load_tokenizer()
62
+
63
+ def _post(self, body, url=None):
64
+ headers = {"Content-Type": "application/json"}
65
+ if self.api_key:
66
+ headers["Authorization"] = f"Bearer {self.api_key}"
67
+ req = urllib.request.Request(url or self.url, json.dumps(body).encode(), headers)
68
+ try:
69
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
70
+ return json.loads(r.read())["choices"][0]
71
+ except urllib.error.HTTPError as e:
72
+ raise RuntimeError(f"vLLM HTTP {e.code}: {e.read()[:500].decode(errors='replace')}")
73
+
74
+ @staticmethod
75
+ def _lp(entry):
76
+ return float(entry["logprob"] if isinstance(entry, dict) else entry)
77
+
78
+ def _data_url(self, ref, trusted):
79
+ if ref.startswith("data:image/"):
80
+ return ref
81
+ path = os.path.realpath(ref)
82
+ if not trusted and not (self.image_root and os.path.commonpath([path, self.image_root]) == self.image_root):
83
+ raise ValueError("image file paths in requests need --image-root; send a data URL instead")
84
+ with open(path, "rb") as f:
85
+ return f"data:{mimetypes.guess_type(path)[0] or 'image/jpeg'};base64," + base64.b64encode(f.read()).decode()
86
+
87
+ @staticmethod
88
+ def _request_image(messages):
89
+ """A System One request carries state.image={"type":"image_url","image_url":{"url":..}}. Returns (url, messages with
90
+ the image replaced by "attached"), or (None, messages) for every text-only request (those never get re-serialised)."""
91
+ content = messages[-1]["content"]
92
+ if '"image_url"' not in content:
93
+ return None, messages
94
+ payload = json.loads(content)
95
+ img = payload.get("state", {}).get("image") if isinstance(payload.get("state"), dict) else None
96
+ if not (isinstance(img, dict) and img.get("type") == "image_url" and isinstance(img.get("image_url", {}).get("url"), str)):
97
+ return None, messages
98
+ payload["state"] = {**payload["state"], "image": "attached"}
99
+ text = json.dumps(payload, ensure_ascii=True, separators=(",", ":")).replace("<", "\\u003c")
100
+ return img["image_url"]["url"], messages[:-1] + [{**messages[-1], "content": text}]
101
+
102
+ def score_image(self, messages, keys, image):
103
+ """Same readout as score() but via /v1/chat/completions with the image prepended to the user turn.
104
+ `image` is a trusted local path or a data URL. Candidate ids come from the text-only render (same assistant prefix)."""
105
+ return self._score_image(messages, keys, self._data_url(image, trusted=True))
106
+
107
+ def _score_image(self, messages, keys, data_url):
108
+ prompt = self.tokenizer.apply_chat_template(
109
+ messages, tokenize=False, add_generation_prompt=True, enable_thinking=False)
110
+ prefix = self.tokenizer.encode(prompt, add_special_tokens=False)
111
+ if len(prefix) + 1 > MAX_CONTEXT_TOKENS: # ponytail: text only; image tokens are bounded by the server's own limits
112
+ raise OverlengthError("prompt exceeds context budget")
113
+ ids = candidate_ids(self.tokenizer, prompt, prefix, keys)
114
+ chat = messages[:-1] + [{"role": "user", "content": [
115
+ {"type": "image_url", "image_url": {"url": data_url}}, {"type": "text", "text": messages[-1]["content"]}]}]
116
+ choice = self._post({"model": self.model, "messages": chat, "max_tokens": 1, "temperature": 1.0, "top_p": 1.0, "top_k": -1,
117
+ "chat_template_kwargs": {"enable_thinking": False}, "allowed_token_ids": list(ids.values()),
118
+ "logprobs": True, "top_logprobs": len(ids), "return_tokens_as_token_ids": True}, self.chat_url)
119
+ top = {e["token"]: e["logprob"] for e in choice["logprobs"]["content"][0]["top_logprobs"]}
120
+ missing = [k for k, t in ids.items() if f"token_id:{t}" not in top]
121
+ if missing:
122
+ raise RuntimeError(f"vLLM returned no logprob for candidates {missing}; start vllm serve with --logprobs-mode processed_logprobs")
123
+ out = {k: self._lp(top[f"token_id:{t}"]) for k, t in ids.items()}
124
+ if abs(sum(math.exp(v) for v in out.values()) - 1) > 1e-2:
125
+ raise RuntimeError("candidate logprobs are not normalised over the candidates; start vllm serve with --logprobs-mode processed_logprobs")
126
+ return out
127
+
128
+ def score(self, messages, keys, image=None):
129
+ if image is not None:
130
+ return self.score_image(messages, keys, image)
131
+ url, messages = self._request_image(messages)
132
+ if url is not None:
133
+ return self._score_image(messages, keys, self._data_url(url, trusted=False))
134
+ prompt = self.tokenizer.apply_chat_template(
135
+ messages, tokenize=False, add_generation_prompt=True, enable_thinking=False)
136
+ prefix = self.tokenizer.encode(prompt, add_special_tokens=False)
137
+ if len(prefix) + 1 > MAX_CONTEXT_TOKENS:
138
+ raise OverlengthError("prompt exceeds context budget")
139
+ ids = candidate_ids(self.tokenizer, prompt, prefix, keys)
140
+ pin = {"model": self.model, "max_tokens": 1, "temperature": 1.0, "top_p": 1.0, "top_k": -1}
141
+ if self.readout == "prompt_logprobs":
142
+ out = {}
143
+ for key, tid in ids.items():
144
+ last = self._post({**pin, "prompt": prefix + [tid], "prompt_logprobs": 0})["prompt_logprobs"][-1]
145
+ if str(tid) not in last:
146
+ raise RuntimeError(f"vLLM prompt_logprobs missing candidate {key} (token {tid})")
147
+ out[key] = self._lp(last[str(tid)])
148
+ return out
149
+ choice = self._post({**pin, "prompt": prefix, "allowed_token_ids": list(ids.values()), "logprobs": len(ids),
150
+ "return_tokens_as_token_ids": True})
151
+ top = choice["logprobs"]["top_logprobs"][0]
152
+ missing = [k for k, t in ids.items() if f"token_id:{t}" not in top]
153
+ if missing:
154
+ raise RuntimeError(f"vLLM returned no logprob for candidates {missing}; start vllm serve with --logprobs-mode processed_logprobs")
155
+ out = {k: self._lp(top[f"token_id:{t}"]) for k, t in ids.items()}
156
+ if abs(sum(math.exp(v) for v in out.values()) - 1) > 1e-2:
157
+ raise RuntimeError("candidate logprobs are not normalised over the candidates; start vllm serve with --logprobs-mode processed_logprobs")
158
+ return out
159
+
160
+
161
+ def main():
162
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
163
+ ap.add_argument("--vllm-url", required=True)
164
+ ap.add_argument("--served-model", required=True, help="vLLM model name or --lora-modules name (e.g. s1)")
165
+ ap.add_argument("--tokenizer", default=MODEL_ID, help="HF id (local cache only) or directory")
166
+ ap.add_argument("--tokenizer-revision", default=MODEL_REVISION)
167
+ ap.add_argument("--readout", choices=("allowed_logprobs", "prompt_logprobs"), default="allowed_logprobs")
168
+ ap.add_argument("--image-root", help="directory request image file paths may point into (default: data URLs only)")
169
+ ap.add_argument("--port", type=int, default=8020)
170
+ ap.add_argument("--host", default="127.0.0.1")
171
+ ap.add_argument("--model-name", default="system1-9b")
172
+ a = ap.parse_args()
173
+ backend = VllmBackend(a.vllm_url, a.served_model, load_tokenizer(a.tokenizer, a.tokenizer_revision), a.readout,
174
+ image_root=a.image_root)
175
+ server = make_server(backend, a.model_name, a.host, a.port)
176
+ print(f"serving {a.model_name} ({a.readout}) on http://{a.host}:{a.port} -> {a.vllm_url}", flush=True)
177
+ server.serve_forever()
178
+
179
+
180
+ if __name__ == "__main__":
181
+ main()