decisionnode-edge 0.9.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,337 @@
1
+ """The request of the hosted `/v1/decide` endpoint, read into the questions the model answers.
2
+
3
+ {"state": ..., "questions": {"<id>": {"type": "choice" | "score" | "truth" | "number", ...}}, "images": [...]}
4
+
5
+ What a request becomes (the same rules as the hosted API):
6
+
7
+ state a string is used as it is; an object or array becomes compact JSON with its key order
8
+ kept; a missing state, null and "" are the empty state.
9
+ instructions a string, or the compact JSON of an object or array; missing or null is the empty text.
10
+ choice criteria {option: description}, 1 to 255 options. A description is a string, the compact
11
+ JSON of an object or array, or "" for null.
12
+ score criteria [level, ...], 1 to 10 levels. One level is answered without the model.
13
+ truth optional criteria {"true": ..., "false": ...}; needs instructions or criteria.
14
+ number min, max and step (default 0, 255, 1): the answer is a value of that grid. Up to 256 values
15
+ are one internal choice over the values; up to 4,096 are answered as pages of 256 (one choice
16
+ over the pages, one per page) and composed. A grid of one value is answered without the model.
17
+ point, box instructions (required), image (the id of a picture; optional with one picture), units ("pixels"
18
+ or "normalized"), max (2 to 16: a list of places): answered in a few readings (places.py). Their
19
+ faults come first, every one in one 422 (RequestError.errors).
20
+ images [{"id", "data" (base64) and "media_type"}] or, on Edge, [{"id", "path"}] for a local file.
21
+ Every question sees every picture, in request order.
22
+
23
+ Edge has no videos, boosters, uploads or image URLs: a request that names them is refused.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import itertools
29
+ import json
30
+ import math
31
+ from dataclasses import dataclass, field
32
+ from decimal import Decimal, localcontext
33
+ from typing import Any
34
+
35
+ from . import places
36
+
37
+ MAX_OPTIONS = 255
38
+ MAX_LEVELS = 10
39
+ MAX_IMAGES = 16
40
+ MAX_IMAGE_ID = 64
41
+ IMAGE_MEDIA_TYPES = ("image/jpeg", "image/png", "image/webp")
42
+ ONE_ROW_VALUES = 256 # the answer codes: a grid up to this size is one internal choice
43
+ MAX_GRID_VALUES = 4096
44
+ MAX_DECIMALS = 6
45
+ NUMBER_DEFAULTS = {"min": 0, "max": 255, "step": 1}
46
+ NOT_ON_EDGE = ("videos", "boosters")
47
+ MAX_ERRORS = 1000 # the entries a 422 lists at most
48
+
49
+
50
+ class RequestError(ValueError):
51
+ """A request the model cannot answer. `status` is the HTTP status the hosted API gives the same fault, `error_type`
52
+ the kind of fault its error body names (the local server's, server.py), `errors` a 422's field errors when it lists
53
+ them ({"type", "loc", "msg", "input"}, as the hosted API's body), `detail` the error body's detail when it is not the
54
+ server's own shape (the hosted API's plain-text details on /v1/systemone)."""
55
+
56
+ def __init__(self, message: str, status: int = 400, error_type: str = "api_usage_error", errors: list[dict] | None = None, detail: Any = None):
57
+ super().__init__(message)
58
+ self.status = status
59
+ self.error_type = error_type
60
+ self.errors = errors
61
+ self.detail = detail
62
+
63
+
64
+ def text_of(value: Any) -> str:
65
+ """A state, instruction, description or level as the prompt carries it."""
66
+ if value is None:
67
+ return ""
68
+ if isinstance(value, str):
69
+ return value
70
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
71
+
72
+
73
+ def _content(value: Any, where: str, nullable: bool = True) -> None:
74
+ if not (isinstance(value, (str, dict, list)) or (value is None and nullable)):
75
+ raise RequestError(f"{where} must be a string, an object or an array", 422)
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Number grids
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ def _decimal(value: Any) -> Decimal:
84
+ return Decimal(repr(value)) if isinstance(value, float) else Decimal(int(value))
85
+
86
+
87
+ def _places(value: Decimal) -> int:
88
+ """Digits after the point once trailing zeros are dropped: 0.5 has 1, 7 has 0."""
89
+ text = format(value.normalize(), "f")
90
+ return len(text.split(".")[1]) if "." in text else 0
91
+
92
+
93
+ def _value_text(scaled: int, d: int) -> str:
94
+ """The grid value scaled / 10^d in its shortest plain decimal form."""
95
+ digits = str(abs(scaled)).rjust(d + 1, "0")
96
+ whole, frac = (digits[:-d], digits[-d:].rstrip("0")) if d else (digits, "")
97
+ text = whole + ("." + frac if frac else "")
98
+ return "-" + text if scaled < 0 else text
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class Grid:
103
+ """Value i is (m + i * s) / 10^d, for i in 0 .. n - 1."""
104
+
105
+ d: int
106
+ m: int
107
+ s: int
108
+ n: int
109
+
110
+ def text(self, i: int) -> str:
111
+ return _value_text(self.m + i * self.s, self.d)
112
+
113
+ def texts(self, start: int = 0, end: int | None = None) -> list[str]:
114
+ return [self.text(i) for i in range(start, self.n if end is None else end)]
115
+
116
+ @property
117
+ def pages(self) -> int:
118
+ return 1 if self.n <= ONE_ROW_VALUES else -(-self.n // ONE_ROW_VALUES)
119
+
120
+ def page_bounds(self) -> list[tuple[int, int]]:
121
+ """(first, end) of each page; the first n mod pages pages hold one value more."""
122
+ q, r = divmod(self.n, self.pages)
123
+ out, start = [], 0
124
+ for j in range(self.pages):
125
+ size = q + 1 if j < r else q
126
+ out.append((start, start + size))
127
+ start += size
128
+ return out
129
+
130
+
131
+ def number_grid(question: dict, qid: str) -> Grid:
132
+ values = {}
133
+ for name in ("min", "max", "step"):
134
+ value = question.get(name)
135
+ if value is None:
136
+ value = NUMBER_DEFAULTS[name]
137
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or (isinstance(value, float) and not math.isfinite(value)):
138
+ raise RequestError(f"question {qid!r}: {name} must be a finite number", 422)
139
+ values[name] = _decimal(value)
140
+ if abs(values[name]) > 2**53:
141
+ raise RequestError(f"question {qid!r}: {name} must be within plus or minus 2^53", 422)
142
+ if name != "max" and _places(values[name]) > MAX_DECIMALS:
143
+ raise RequestError(f"question {qid!r}: {name} has more than {MAX_DECIMALS} decimals", 422)
144
+ if values["step"] <= 0:
145
+ raise RequestError(f"question {qid!r}: step must be above 0", 422)
146
+ if values["min"] > values["max"]:
147
+ raise RequestError(f"question {qid!r}: min is above max", 422)
148
+ d = max(_places(values["min"]), _places(values["step"]))
149
+ with localcontext() as ctx:
150
+ ctx.prec = 200
151
+ if d:
152
+ bound = Decimal(10) ** (15 - d) - Decimal(10) ** -d
153
+ if abs(values["min"]) > bound or abs(values["max"]) > bound:
154
+ raise RequestError(f"question {qid!r}: on a grid with {d} decimals, min and max must stay within plus or minus {bound}", 422)
155
+ scale = Decimal(10) ** d
156
+ m, s = int(values["min"] * scale), int(values["step"] * scale)
157
+ top = int((values["max"] * scale).to_integral_value(rounding="ROUND_FLOOR"))
158
+ n = (top - m) // s + 1
159
+ if n > MAX_GRID_VALUES:
160
+ raise RequestError(f"question {qid!r}: the grid holds {n} values, the limit is {MAX_GRID_VALUES}", 422)
161
+ return Grid(d=d, m=m, s=s, n=n)
162
+
163
+
164
+ def number_rows(instructions: str, grid: Grid) -> list[dict]:
165
+ """The internal choice questions of a number question: one over the values, or one over the pages and one per page."""
166
+ if grid.pages == 1:
167
+ keys = grid.texts()
168
+ return [{"type": "choice", "instructions": instructions, "keys": keys, "descs": [""] * len(keys)}]
169
+ bounds = grid.page_bounds()
170
+ rows = [{"type": "choice", "instructions": instructions, "keys": [f"{grid.text(a)} to {grid.text(b - 1)}" for a, b in bounds], "descs": [""] * len(bounds)}]
171
+ for a, b in bounds:
172
+ keys = grid.texts(a, b)
173
+ rows.append({"type": "choice", "instructions": instructions, "keys": keys, "descs": [""] * len(keys)})
174
+ return rows
175
+
176
+
177
+ def compose(grid: Grid, answered: list[list[float]]) -> list[float]:
178
+ """The probability of every grid value: p(page) * p(value | page) on a paged grid."""
179
+ if grid.pages == 1:
180
+ return list(answered[0])
181
+ out: list[float] = []
182
+ for j, page in enumerate(answered[1:]):
183
+ out.extend(answered[0][j] * p for p in page)
184
+ return out
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # The request
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ @dataclass
193
+ class Question:
194
+ id: str
195
+ type: str # choice, score, truth or number (as answered)
196
+ rows: list[dict] # what the model runs: one prompt per row (none for a question answered without the model)
197
+ levels: list | None = None # score: the levels as sent, for `legend`
198
+ keys: list[str] | None = None # choice: the options in request order
199
+ grid: Grid | None = None # number
200
+ place: places.PlaceSpec | None = None # point and box (answered by places.Plan, not by rows of its own)
201
+
202
+
203
+ @dataclass
204
+ class Request:
205
+ state: str
206
+ questions: list[Question]
207
+ images: list[dict] = field(default_factory=list)
208
+
209
+
210
+ def parse(body: Any) -> Request:
211
+ """Validate a `/v1/decide` request body (a dict) and read it into questions. Raises RequestError."""
212
+ if not isinstance(body, dict):
213
+ raise RequestError("the request must be a JSON object", 422)
214
+ for name in NOT_ON_EDGE:
215
+ if body.get(name):
216
+ raise RequestError(f"`{name}` is not available on DecisionNode Edge")
217
+ specs = _place_specs(body)
218
+ state = body.get("state")
219
+ _content(state, "state")
220
+ questions = body.get("questions")
221
+ if not isinstance(questions, dict) or not questions:
222
+ raise RequestError("questions must be an object with at least one question", 422)
223
+ state_text = text_of(state)
224
+ out = [Question(qid, specs[qid].shape, [], place=specs[qid]) if qid in specs else _question(qid, q) for qid, q in questions.items()]
225
+ return Request(state=state_text, questions=out, images=_images(body.get("images")))
226
+
227
+
228
+ def _place_specs(body: dict) -> dict[str, places.PlaceSpec]:
229
+ """The point and box questions by id, read before the rest of the request: their 422 (every faulty field), then
230
+ the 400 of one without instructions."""
231
+ questions = body.get("questions")
232
+ if not isinstance(questions, dict):
233
+ return {}
234
+ found = [(qid, q) for qid, q in questions.items() if places.is_place_question(q)]
235
+ if not found:
236
+ return {}
237
+ images = body.get("images")
238
+ image_ids = [image.get("id") for image in images if isinstance(image, dict) and isinstance(image.get("id"), str)] if isinstance(images, list) else []
239
+ # pictures the image check refuses (not a list, an id that is not a string, an id used twice) are its fault to name
240
+ faulty = images is not None and (not isinstance(images, list) or len(image_ids) != len(images) or len(set(image_ids)) != len(image_ids))
241
+ entries = (entry for qid, q in found for entry in places.question_errors(q, ("body", "questions", qid), None if faulty else image_ids))
242
+ errors = list(itertools.islice(entries, MAX_ERRORS))
243
+ if errors:
244
+ raise RequestError(places.reason(errors), 422, errors=errors)
245
+ missing = places.missing_instructions(found)
246
+ if missing:
247
+ raise RequestError(missing)
248
+ out = {}
249
+ for qid, q in found:
250
+ image = q.get("image") if q.get("image") is not None else (image_ids[0] if image_ids else "")
251
+ number = image_ids.index(image) + 1 if image in image_ids else 0
252
+ out[qid] = places.PlaceSpec(str(qid), q["type"], text_of(q.get("instructions")), image, number, q.get("units") or "pixels", q.get("max"))
253
+ return out
254
+
255
+
256
+ def _question(qid: str, q: Any) -> Question:
257
+ if not isinstance(q, dict) or "type" not in q:
258
+ raise RequestError(f"question {qid!r} must be an object with a type", 422)
259
+ kind = q["type"]
260
+ if kind == "noul": # the older name of the yes-or-no type, answered as truth
261
+ kind = "truth"
262
+ if kind not in ("choice", "score", "truth", "number"):
263
+ raise RequestError(f"question {qid!r}: unknown question type {kind!r}")
264
+ instructions, criteria = q.get("instructions"), q.get("criteria")
265
+ _content(instructions, f"question {qid!r}: instructions")
266
+ text = text_of(instructions)
267
+
268
+ if kind == "number":
269
+ if instructions is None or instructions == "":
270
+ raise RequestError(f"Number question must have instructions: {qid}")
271
+ grid = number_grid(q, qid)
272
+ rows = [] if grid.n == 1 else number_rows(text, grid)
273
+ return Question(qid, "number", rows, grid=grid)
274
+
275
+ if kind == "choice":
276
+ if not isinstance(criteria, dict):
277
+ raise RequestError(f"question {qid!r}: a choice needs criteria, an object of options", 422)
278
+ if not criteria:
279
+ raise RequestError(f"Choice question must have at least one choice: {qid}")
280
+ if len(criteria) > MAX_OPTIONS:
281
+ raise RequestError(f"Too many choices: question {qid!r} has {len(criteria)} options, the limit is {MAX_OPTIONS} options per choice.")
282
+ for key, desc in criteria.items():
283
+ _content(desc, f"question {qid!r}: the description of {key!r}")
284
+ keys = list(criteria)
285
+ row = {"type": "choice", "instructions": text, "keys": keys, "descs": [text_of(d) for d in criteria.values()]}
286
+ return Question(qid, "choice", [] if len(keys) == 1 else [row], keys=keys)
287
+
288
+ if kind == "score":
289
+ if not isinstance(criteria, list) or not criteria:
290
+ raise RequestError(f"question {qid!r}: a score needs criteria, a list of 1 to {MAX_LEVELS} levels", 422)
291
+ if len(criteria) > MAX_LEVELS:
292
+ raise RequestError(f"Too many score levels. Must have at most {MAX_LEVELS} levels.")
293
+ for i, level in enumerate(criteria):
294
+ _content(level, f"question {qid!r}: level {i}", nullable=False)
295
+ row = {"type": "score", "instructions": text, "levels": [text_of(level) for level in criteria]}
296
+ return Question(qid, "score", [] if len(criteria) == 1 else [row], levels=list(criteria))
297
+
298
+ # truth
299
+ if criteria is not None and not isinstance(criteria, dict):
300
+ raise RequestError(f"question {qid!r}: truth criteria must be an object with `true` and `false`", 422)
301
+ if instructions is None and criteria is None:
302
+ raise RequestError(f"Truth question must have criteria or instructions: {qid}")
303
+ row: dict = {"type": "truth", "instructions": text}
304
+ if criteria:
305
+ for side in ("true", "false"):
306
+ _content(criteria.get(side), f"question {qid!r}: criteria.{side}")
307
+ sides = {side: text_of(criteria.get(side)) for side in ("true", "false") if text_of(criteria.get(side))}
308
+ if sides:
309
+ row["criteria"] = sides
310
+ return Question(qid, "truth", [row])
311
+
312
+
313
+ def _images(images: Any) -> list[dict]:
314
+ if images is None:
315
+ return []
316
+ if not isinstance(images, list):
317
+ raise RequestError("images must be a list", 422)
318
+ if len(images) > MAX_IMAGES:
319
+ raise RequestError(f"Too many images: {len(images)}, the limit is {MAX_IMAGES}.")
320
+ seen = set()
321
+ for i, image in enumerate(images):
322
+ if not isinstance(image, dict) or not isinstance(image.get("id"), str) or not image["id"]:
323
+ raise RequestError(f"Image {i} needs an id.")
324
+ image_id = image["id"]
325
+ if len(image_id) > MAX_IMAGE_ID:
326
+ raise RequestError(f"Image {i}: its id is longer than {MAX_IMAGE_ID} characters.")
327
+ if image_id in seen:
328
+ raise RequestError(f"Image id {image_id!r} is used twice.")
329
+ seen.add(image_id)
330
+ if image.get("url") is not None or image.get("upload_id") is not None:
331
+ raise RequestError(f"Image {image_id!r}: DecisionNode Edge reads pictures from `data` or a local `path`, not from a URL or an upload.")
332
+ forms = [name for name in ("data", "path") if image.get(name) is not None]
333
+ if len(forms) != 1:
334
+ raise RequestError(f"Image {image_id!r} needs exactly one of data and path.")
335
+ if forms == ["data"] and image.get("media_type") not in IMAGE_MEDIA_TYPES:
336
+ raise RequestError(f"Image {image_id!r}: media_type must be one of {', '.join(IMAGE_MEDIA_TYPES)}.")
337
+ return images
@@ -0,0 +1,282 @@
1
+ """A local HTTP server for a DecisionNode Edge model, on the standard library only.
2
+
3
+ python -m decisionnode_edge serve path/to/model [--host 127.0.0.1] [--port 8000] [--api-key FILE] [--backend ...]
4
+
5
+ POST /v1/systemone the /v1/systemone format used by public decision-model leaderboards:
6
+ {"model", "state", "questions", "assets"}; noul, choice, score and box questions; a noul is
7
+ answered {"type": "noul", "noul": p}, a choice and a score as on /v1/decide but with lists
8
+ that sum to exactly 1.00, a box as {"type": "box", "box": {"top", "bottom", "left", "right"},
9
+ "confidence"} (systemone.py)
10
+ POST /v1/decide the DecisionNode API's call: the runtime's own request and answers (EdgeModel.decide)
11
+ GET /v1/models {"models": [{"name", "description", "release_date"}]}
12
+ GET /healthz {"status": "ok", "model", "backend"} (no key needed)
13
+
14
+ Pictures: `images` as the runtime takes them, base64 `data` only (a local `path` is refused over HTTP: a caller must
15
+ never make the server read its files), and on /v1/systemone the format's own: the `assets` map, {"<ref>": {"type":
16
+ "image", "format": "png" | "jpg" | "jpeg", "data_base64": ...}}, whose references in the text become `Picture <n>`,
17
+ `image_url` content parts and `images` as data URLs (systemone.translate).
18
+
19
+ Point and box questions are taken on /v1/decide. On /v1/systemone a box is taken (in the format's shape, its
20
+ `confidence` the model folder's box calibration where there is one, box_calibration.json, else `present`), and point
21
+ and number questions are refused.
22
+
23
+ Errors carry a `detail` body: {"detail": {"error_type", "message"}} (a 422 as a list of field errors). A
24
+ request over the model's length limit says "maximum context length", a choice over 255 options "options per choice",
25
+ and on /v1/systemone a score over 10 levels "a score takes 2 to 10 levels": the words leaderboard harnesses look for to
26
+ count a request as beyond the model rather than as a failure. On /v1/systemone these three come in the hosted route's
27
+ bodies (systemone.mark_capacity).
28
+
29
+ The model answers one request at a time (a lock); the server listens on the loopback address unless told otherwise.
30
+ `model` is required on /v1/systemone, as on the hosted API, and any name is accepted: the answer names the model that
31
+ answered.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import hmac
38
+ import json
39
+ import logging
40
+ import os
41
+ import sys
42
+ import threading
43
+ import time
44
+ import traceback
45
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
46
+ from typing import Any
47
+
48
+ from . import request as requests, systemone
49
+ from .request import RequestError
50
+
51
+ MAX_BODY_BYTES = 64 << 20 # 16 pictures as base64 with room to spare
52
+ SYSTEMONE_TYPES = ("noul", "choice", "score", "box")
53
+ AUTH_MESSAGE = "Cannot authenticate with the server. Please check your API key and try again."
54
+ ROUTES = {"/v1/systemone": "POST", "/v1/decide": "POST", "/v1/models": "GET", "/healthz": "GET"}
55
+
56
+ log = logging.getLogger("decisionnode_edge.server")
57
+
58
+
59
+ class HTTPError(Exception):
60
+ def __init__(self, status: int, error_type: str, message: str, headers: dict | None = None):
61
+ super().__init__(message)
62
+ self.status, self.error_type, self.message, self.headers = status, error_type, message, headers or {}
63
+
64
+
65
+ def error_body(status: int, error_type: str, message: str, errors: list[dict] | None = None) -> dict:
66
+ if status == 422:
67
+ return {"detail": errors or [{"type": "value_error", "loc": ["body"], "msg": message, "input": None}]}
68
+ return {"detail": {"error_type": error_type, "message": message}}
69
+
70
+
71
+ def _images_over_http(images: Any) -> list:
72
+ if images is None:
73
+ return []
74
+ if not isinstance(images, list):
75
+ raise RequestError("images must be a list", 422)
76
+ for image in images:
77
+ if isinstance(image, dict) and image.get("path") is not None:
78
+ raise RequestError("Over HTTP a picture is sent as base64 `data` (or an asset), never as a local `path`.")
79
+ return list(images)
80
+
81
+
82
+ def box_calibration(model: Any, path: str | None = None, sha256: str | None = None) -> systemone.BoxCalibration | None:
83
+ """The box calibration /v1/systemone reads: `path` (or "none" for none), else the model folder's
84
+ box_calibration.json when there is one. `sha256` (else `box_calibration_sha256` in decisionnode_edge.json), when
85
+ set, must be the file's. A file that does not check is a BoxCalibrationError: the server does not start on it."""
86
+ if path == "none":
87
+ return None
88
+ sha256 = sha256 if sha256 is not None else model.config.get("box_calibration_sha256") or ""
89
+ if path is None:
90
+ path = os.path.join(model.path, systemone.BOX_CALIBRATION_FILE)
91
+ if not os.path.isfile(path):
92
+ if sha256:
93
+ raise systemone.BoxCalibrationError(f"{path}: the model names a box calibration that is not there")
94
+ return None
95
+ return systemone.BoxCalibration.load(path, sha256)
96
+
97
+
98
+ class Service:
99
+ """The routes, without the socket: handle(method, path, headers, body) gives (status, JSON body, extra headers).
100
+ `calibration`: the box calibration of /v1/systemone (default: box_calibration(model))."""
101
+
102
+ def __init__(self, model: Any, api_key: str | None = None, description: str | None = None, release_date: str | None = None, calibration: Any = "model"):
103
+ self.model = model
104
+ self.api_key = api_key
105
+ self.name = model.config["name"]
106
+ self.description = description or model.config.get("description") or f"{self.name}: typed, calibrated decisions, run locally"
107
+ self.release_date = release_date or model.config.get("release_date")
108
+ self.calibration = box_calibration(model) if calibration == "model" else calibration
109
+ self.lock = threading.Lock()
110
+
111
+ def handle(self, method: str, path: str, headers: dict[str, str], body: bytes) -> tuple[int, dict, dict]:
112
+ try:
113
+ return 200, self._route(method, path.split("?", 1)[0], headers, body), {}
114
+ except HTTPError as error:
115
+ return error.status, error_body(error.status, error.error_type, error.message), error.headers
116
+ except RequestError as error:
117
+ if path.split("?", 1)[0] == "/v1/systemone":
118
+ error = systemone.mark_capacity(error)
119
+ if error.detail is not None:
120
+ return error.status, {"detail": error.detail}, {}
121
+ return error.status, error_body(error.status, error.error_type, str(error), error.errors), {}
122
+ except Exception: # a fault of the model or the backend, never the caller's
123
+ log.error("the model could not answer:\n%s", traceback.format_exc())
124
+ return 500, error_body(500, "internal_error", "The model could not answer this request."), {}
125
+
126
+ def _route(self, method: str, path: str, headers: dict[str, str], body: bytes) -> dict:
127
+ if path not in ROUTES:
128
+ raise HTTPError(404, "not_found_error", "Not found.")
129
+ if method != ROUTES[path]:
130
+ raise HTTPError(405, "api_usage_error", "Method not allowed.", {"Allow": ROUTES[path]})
131
+ if path == "/healthz":
132
+ return {"status": "ok", "model": self.name, "backend": self.model.backend}
133
+ self._authenticate(headers)
134
+ if path == "/v1/models":
135
+ return {"models": [{"name": self.name, "description": self.description, "release_date": self.release_date}]}
136
+ try:
137
+ request = json.loads(body)
138
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
139
+ raise RequestError(f"the body is not JSON: {error}", 422) from None
140
+ if path == "/v1/decide":
141
+ if isinstance(request, dict) and request.get("images") is not None:
142
+ request = {**request, "images": _images_over_http(request["images"])}
143
+ with self.lock:
144
+ return self.model.decide(request)
145
+ return self._systemone(request)
146
+
147
+ def _authenticate(self, headers: dict[str, str]) -> None:
148
+ if self.api_key is None:
149
+ return
150
+ given = headers.get("authorization", "")
151
+ scheme, _, token = given.partition(" ")
152
+ if scheme.lower() != "bearer" or not hmac.compare_digest(token.strip().encode(), self.api_key.encode()):
153
+ raise HTTPError(401, "authentication_error", AUTH_MESSAGE, {"WWW-Authenticate": "Bearer"})
154
+
155
+ def _systemone(self, request: Any) -> dict:
156
+ if not isinstance(request, dict):
157
+ raise RequestError("the request must be a JSON object", 422)
158
+ if not isinstance(request.get("model"), str):
159
+ raise RequestError("model: Field required, a string", 422)
160
+ questions = request.get("questions")
161
+ if isinstance(questions, dict):
162
+ for qid, question in questions.items():
163
+ kind = question.get("type") if isinstance(question, dict) else None
164
+ if kind in ("number", "point"):
165
+ raise RequestError(f"question {qid!r}: the {kind} type is not available on /v1/systemone; send it to /v1/decide")
166
+ if isinstance(question, dict) and "type" in question and kind not in SYSTEMONE_TYPES:
167
+ raise RequestError(f"question {qid!r}: unknown question type {kind!r}")
168
+ _images_over_http(request.get("images"))
169
+ body = {name: request[name] for name in ("state", "questions", "images", "assets") if name in request}
170
+ body, done = systemone.translate(body, max_images=requests.MAX_IMAGES)
171
+ parsed = requests.parse(body)
172
+ systemone.normalised_boxes(q.place for q in parsed.questions if q.place is not None) # the format's edges are fractions
173
+ with self.lock:
174
+ response = self.model._decide(parsed, exact_sum=True)
175
+ answers = {}
176
+ for qid, answer in response["answers"].items(): # a noul is answered under its own name, as the format has it
177
+ if answer["type"] == "truth":
178
+ answer = {"type": "noul", "noul": answer["truth"]}
179
+ elif answer["type"] == "score" and qid in done.legends and len(done.legends[qid]) == len(answer["legend"]):
180
+ answer = {**answer, "legend": {str(i): level for i, level in enumerate(done.legends[qid])}} # the levels as sent
181
+ answers[qid] = answer
182
+ return systemone.box_answers({"model": response["model"], "answers": answers, "usage": response["usage"]}, self.calibration)
183
+
184
+
185
+ class Handler(BaseHTTPRequestHandler):
186
+ protocol_version = "HTTP/1.1" # keep-alive: every response carries Content-Length
187
+ disable_nagle_algorithm = True # headers and body are two writes: without it a kept-alive client waits for a delayed ACK
188
+ server_version = "decisionnode-edge"
189
+ service: Service = None # set by make_server
190
+
191
+ def _answer(self, method: str) -> None:
192
+ started = time.perf_counter()
193
+ body = b""
194
+ if method == "POST":
195
+ length = self.headers.get("Content-Length")
196
+ if length is None or not length.isdigit():
197
+ return self._send(411, error_body(411, "api_usage_error", "Content-Length is required."), {"Connection": "close"}, started)
198
+ if int(length) > MAX_BODY_BYTES:
199
+ return self._send(413, error_body(413, "api_usage_error", "Request body too large."), {"Connection": "close"}, started)
200
+ body = self.rfile.read(int(length))
201
+ headers = {k.lower(): v for k, v in self.headers.items()}
202
+ status, payload, extra = self.service.handle(method, self.path, headers, body)
203
+ self._send(status, payload, extra, started)
204
+
205
+ def _send(self, status: int, payload: dict, extra: dict, started: float) -> None:
206
+ data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
207
+ self.send_response(status)
208
+ self.send_header("Content-Type", "application/json")
209
+ self.send_header("Content-Length", str(len(data)))
210
+ self.send_header("X-Latency-Ms", f"{(time.perf_counter() - started) * 1000:.2f}")
211
+ for name, value in extra.items():
212
+ self.send_header(name, value)
213
+ self.end_headers()
214
+ self.wfile.write(data)
215
+ if extra.get("Connection") == "close":
216
+ self.close_connection = True
217
+
218
+ def do_GET(self) -> None: # noqa: N802 (the standard library's names)
219
+ self._answer("GET")
220
+
221
+ def do_POST(self) -> None: # noqa: N802
222
+ self._answer("POST")
223
+
224
+ def do_PUT(self) -> None: # noqa: N802
225
+ self._answer("PUT")
226
+
227
+ def do_DELETE(self) -> None: # noqa: N802
228
+ self._answer("DELETE")
229
+
230
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002
231
+ log.info("%s %s", self.address_string(), format % args)
232
+
233
+
234
+ def make_server(model: Any, host: str = "127.0.0.1", port: int = 8000, api_key: str | None = None, **service: Any) -> ThreadingHTTPServer:
235
+ handler = type("EdgeHandler", (Handler,), {"service": Service(model, api_key=api_key, **service)})
236
+ server = ThreadingHTTPServer((host, port), handler)
237
+ server.daemon_threads = True
238
+ return server
239
+
240
+
241
+ def main(argv: list[str] | None = None) -> int:
242
+ from . import EdgeModel
243
+
244
+ parser = argparse.ArgumentParser(prog="python -m decisionnode_edge serve", description="Serve a DecisionNode Edge model over HTTP: /v1/systemone, /v1/decide, /v1/models.")
245
+ parser.add_argument("model", help="the model directory")
246
+ parser.add_argument("--host", default="127.0.0.1", help="the address to listen on (default 127.0.0.1, this machine only)")
247
+ parser.add_argument("--port", type=int, default=8000)
248
+ parser.add_argument("--api-key", default=None, help="a file holding the key callers must send as `Authorization: Bearer <key>`")
249
+ parser.add_argument("--device", default=None)
250
+ parser.add_argument("--dtype", default=None)
251
+ parser.add_argument("--backend", default=None, help="auto, torch, vllm, openvino, mlx or llamacpp (default: auto)")
252
+ parser.add_argument("--artifacts", default=None, help="the directory python -m decisionnode_edge.build wrote")
253
+ parser.add_argument("--description", default=None, help="the description GET /v1/models gives")
254
+ parser.add_argument("--release-date", default=None, help="the release date GET /v1/models gives (YYYY-MM-DD)")
255
+ parser.add_argument("--box-calibration", default=None, help="the box calibration of /v1/systemone (default: the model folder's box_calibration.json when there is one; none for none)")
256
+ parser.add_argument("--box-calibration-sha256", default=None, help="the sha256 the box calibration file must have")
257
+ args = parser.parse_args(argv)
258
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
259
+ api_key = None
260
+ if args.api_key:
261
+ with open(args.api_key, encoding="utf-8") as f:
262
+ api_key = f.read().strip()
263
+ if not api_key:
264
+ raise SystemExit(f"{args.api_key} is empty")
265
+ model = EdgeModel.load(args.model, device=args.device, dtype=args.dtype, backend=args.backend, artifacts=args.artifacts)
266
+ calibration = box_calibration(model, args.box_calibration, args.box_calibration_sha256)
267
+ model.decide({"state": "The color is red.", "questions": {"warmup": {"type": "choice", "instructions": "Which color is named?", "criteria": {"red": "red", "blue": "blue"}}}})
268
+ server = make_server(model, args.host, args.port, api_key, description=args.description, release_date=args.release_date, calibration=calibration)
269
+ log.info("serving %s (%s backend) on http://%s:%d%s", model.config["name"], model.backend, args.host, server.server_address[1], ", key required" if api_key else "")
270
+ if calibration is not None:
271
+ log.info("/v1/systemone box confidence: the box calibration %s (sha256 %s)", calibration.model or "of this model", calibration.sha256)
272
+ try:
273
+ server.serve_forever()
274
+ except KeyboardInterrupt:
275
+ pass
276
+ finally:
277
+ server.server_close()
278
+ return 0
279
+
280
+
281
+ if __name__ == "__main__":
282
+ sys.exit(main())