ironbcc commited on
Commit
a27d200
·
verified ·
1 Parent(s): 4c93e07

add serve/system1_server.py

Browse files
Files changed (1) hide show
  1. serve/system1_server.py +198 -0
serve/system1_server.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local HTTP server speaking TypeSafe's System One contract (POST /v1/systemone) on top of a System1 backend.
2
+ Run: python system1_server.py --model <path> [--adapter <safetensors|dir>] [--port 8020] [--model-name system1-9b]
3
+ Probabilities come from the same path as scripts/evaluate_system1.py predict (render_messages -> backend.score ->
4
+ calibrated_probabilities at T=1). Confidence formulas mirror the Kev reference (Apache-2.0, kev/api.py: choice_confidence,
5
+ score_confidence, round_prob), themselves mirroring TypeSafe system-one-adapter 0.2.1 confidence_metrics."""
6
+ import argparse
7
+ import json
8
+ import threading
9
+ import time
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+
12
+ from kev_convert import kev_record_to_rows
13
+ from system1_client import (
14
+ WIDE_CHOICE_KEYS, MAX_PAYLOAD_CHARS, OverlengthError, calibrated_probabilities, render_messages, request_from_row)
15
+
16
+ MAX_BODY_BYTES = 4 * MAX_PAYLOAD_CHARS # generous: JSON escaping and multi-question requests; rows are re-bounded per question
17
+
18
+
19
+ class ContractError(ValueError):
20
+ pass
21
+
22
+
23
+ def round_prob(x):
24
+ return round(float(x), 4)
25
+
26
+
27
+ def _normalize(p):
28
+ t = sum(p)
29
+ return [1 / len(p)] * len(p) if t == 0 else [x / t for x in p]
30
+
31
+
32
+ def choice_confidence(p):
33
+ K = len(p)
34
+ return 1.0 if K == 1 else (max(_normalize(p)) - 1 / K) / (1 - 1 / K)
35
+
36
+
37
+ def score_confidence(p):
38
+ L = len(p)
39
+ if L == 1:
40
+ return 1.0
41
+ p = _normalize(p)
42
+ mode = max(range(L), key=p.__getitem__)
43
+ D = sum(abs(i - (L - 1) / 2) for i in range(L)) / L
44
+ return max(0.0, 1.0 - sum(pi * abs(i - mode) for i, pi in enumerate(p)) / D)
45
+
46
+
47
+ def request_to_rows(req):
48
+ """Validate a System One request -> ({qid: row}, {qid: candidate names}). Rows come from kev_convert with a dummy label
49
+ (never rendered), so option text equals what the evaluator and Jev conversion produce."""
50
+ if not isinstance(req, dict):
51
+ raise ContractError("request: expected object")
52
+ if "state" not in req or not isinstance(req["state"], (str, dict, list)):
53
+ raise ContractError("state: required, string, object or array")
54
+ qs = req.get("questions")
55
+ if not isinstance(qs, dict) or not qs:
56
+ raise ContractError("questions: expected nonempty object")
57
+ dummy, rows, names = {}, {}, {}
58
+ for qid, q in qs.items():
59
+ if not isinstance(q, dict):
60
+ raise ContractError(f"{qid}: expected object")
61
+ kind, crit = q.get("type"), q.get("criteria")
62
+ if kind == "choice":
63
+ if not isinstance(crit, (dict, list)) or not 2 <= len(crit) <= len(WIDE_CHOICE_KEYS):
64
+ raise ContractError(f"{qid}: choice requires 2-{len(WIDE_CHOICE_KEYS)} options per choice")
65
+ label = next(iter(crit))
66
+ elif kind == "score":
67
+ if not isinstance(crit, list):
68
+ raise ContractError(f"{qid}: score criteria must be an ordered list")
69
+ label = 0
70
+ elif kind == "noul":
71
+ if crit is not None and not isinstance(crit, dict):
72
+ raise ContractError(f"{qid}: noul criteria must be an object")
73
+ label = True
74
+ else:
75
+ raise ContractError(f"{qid}: unknown question type {kind!r}")
76
+ dummy[qid] = {"type": kind, "instructions": q.get("instructions"), "criteria": crit, "label": label}
77
+ try:
78
+ for row in kev_record_to_rows({"state": req["state"], "questions": dummy}, "serve", "none", "serve", keys=WIDE_CHOICE_KEYS):
79
+ qid = row["meta"]["kev_question_id"]
80
+ rows[qid] = row
81
+ names[qid] = list(row["meta"].get("candidate_ids", {}).values())
82
+ except (ValueError, KeyError, TypeError) as e:
83
+ raise ContractError(str(e))
84
+ return rows, names
85
+
86
+
87
+ def answer_for(row, names, probs):
88
+ kind = row["type"]
89
+ if kind == "noul":
90
+ return {"type": "noul", "noul": round_prob(probs["Y"])}
91
+ if kind == "choice":
92
+ p = [probs[k] for k in row["options"]]
93
+ return {"type": "choice", "choice": names[max(range(len(p)), key=p.__getitem__)], "confidence": round_prob(choice_confidence(p)),
94
+ "probabilities": {n: round_prob(v) for n, v in zip(names, p)}}
95
+ p = [probs[k] for k in row["options"]]
96
+ return {"type": "score", "score": round_prob(sum(i * v for i, v in enumerate(p))),
97
+ "probabilities": {str(i): round_prob(v) for i, v in enumerate(p)}, "confidence": round_prob(score_confidence(p))}
98
+
99
+
100
+ class Service:
101
+ def __init__(self, backend, model_name):
102
+ self.backend, self.model_name, self.lock = backend, model_name, threading.Lock() # ponytail: one global lock, GPU is serial anyway
103
+
104
+ def _tokens(self, text_or_messages):
105
+ tok = getattr(self.backend, "tokenizer", None)
106
+ if tok is None:
107
+ return 0
108
+ if isinstance(text_or_messages, str):
109
+ return len(tok.encode(text_or_messages, add_special_tokens=False))
110
+ prompt = tok.apply_chat_template(text_or_messages, tokenize=False, add_generation_prompt=True, enable_thinking=False)
111
+ return len(tok.encode(prompt, add_special_tokens=False))
112
+
113
+ def systemone(self, req):
114
+ start = time.perf_counter()
115
+ rows, names = request_to_rows(req)
116
+ answers, in_tokens = {}, 0
117
+ for qid, row in rows.items():
118
+ try:
119
+ messages = render_messages(request_from_row(row))
120
+ except OverlengthError as e:
121
+ raise ContractError(f"{qid}: payload too large, over the maximum context length: {e}")
122
+ except ValueError as e:
123
+ raise ContractError(f"{qid}: {e}")
124
+ with self.lock:
125
+ try:
126
+ scores = self.backend.score(messages, list(row["options"]))
127
+ except OverlengthError as e:
128
+ raise ContractError(f"{qid}: context too large, over the maximum context length: {e}")
129
+ in_tokens += self._tokens(messages)
130
+ if not isinstance(scores, dict) or set(scores) != set(row["options"]):
131
+ raise RuntimeError(f"{qid}: backend returned incomplete scores")
132
+ answers[qid] = answer_for(row, names[qid], calibrated_probabilities(scores, 1.0))
133
+ return {"model": self.model_name, "answers": answers,
134
+ "usage": {"input_tokens": in_tokens, "output_tokens": self._tokens(json.dumps(answers))},
135
+ "latency_ms": round((time.perf_counter() - start) * 1000, 1)}
136
+
137
+
138
+ def make_server(backend, model_name="system1", host="127.0.0.1", port=8020):
139
+ svc = Service(backend, model_name)
140
+
141
+ class Handler(BaseHTTPRequestHandler):
142
+ def _send(self, code, body):
143
+ data = json.dumps(body).encode()
144
+ self.send_response(code)
145
+ self.send_header("Content-Type", "application/json")
146
+ self.send_header("Content-Length", str(len(data)))
147
+ self.end_headers()
148
+ self.wfile.write(data)
149
+
150
+ def do_GET(self):
151
+ if self.path == "/health":
152
+ self._send(200, {"status": "ok"})
153
+ elif self.path == "/v1/models":
154
+ self._send(200, {"object": "list", "data": [{"id": svc.model_name, "object": "model", "owned_by": "local"}]})
155
+ else:
156
+ self._send(404, {"error": "not found"})
157
+
158
+ def do_POST(self):
159
+ if self.path != "/v1/systemone":
160
+ return self._send(404, {"error": "not found"})
161
+ n = int(self.headers.get("Content-Length") or 0)
162
+ if n > MAX_BODY_BYTES:
163
+ return self._send(422, {"error": f"payload too large: {n} bytes"})
164
+ try:
165
+ req = json.loads(self.rfile.read(n))
166
+ except (ValueError, UnicodeDecodeError) as e:
167
+ return self._send(400, {"error": f"malformed JSON: {e}"})
168
+ try:
169
+ self._send(200, svc.systemone(req))
170
+ except ContractError as e:
171
+ self._send(422, {"error": str(e)})
172
+ except Exception as e:
173
+ self._send(500, {"error": f"{type(e).__name__}: {e}"})
174
+
175
+ def log_message(self, *a):
176
+ pass
177
+
178
+ return ThreadingHTTPServer((host, port), Handler)
179
+
180
+
181
+ def main():
182
+ ap = argparse.ArgumentParser(description=__doc__)
183
+ ap.add_argument("--model", required=True)
184
+ ap.add_argument("--adapter")
185
+ ap.add_argument("--port", type=int, default=8020)
186
+ ap.add_argument("--host", default="127.0.0.1")
187
+ ap.add_argument("--model-name", default="system1-9b")
188
+ ap.add_argument("--backend", choices=("mlx", "torch"), default="mlx", help="torch: CUDA via transformers")
189
+ a = ap.parse_args()
190
+ import system1_client
191
+ backend = system1_client.TorchBackend if a.backend == "torch" else system1_client.MlxBackend
192
+ server = make_server(backend(a.model, adapter_path=a.adapter), a.model_name, a.host, a.port)
193
+ print(f"serving {a.model_name} on http://{a.host}:{a.port}", flush=True)
194
+ server.serve_forever()
195
+
196
+
197
+ if __name__ == "__main__":
198
+ main()