GyrTech commited on
Commit
73bc533
·
verified ·
1 Parent(s): 30b6cf7

Add CPU-verified decision-only serving kit and container usage

Browse files
MANIFEST.json CHANGED
@@ -57,8 +57,8 @@
57
  },
58
  {
59
  "path": "README.md",
60
- "size_bytes": 16076,
61
- "sha256": "8e2af48820820a6435534cf6814f3eb32eca33b235550701b2729675faf8553f"
62
  },
63
  {
64
  "path": "config.json",
@@ -120,6 +120,51 @@
120
  "size_bytes": 33011,
121
  "sha256": "0ce91858995441a7b2c8fef65d9b0da1ba7135ced82c651a2890bf1802997c87"
122
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  {
124
  "path": "tokenizer.json",
125
  "size_bytes": 19989325,
 
57
  },
58
  {
59
  "path": "README.md",
60
+ "size_bytes": 16550,
61
+ "sha256": "d60c1d98cae65abb179ee03a25b5d45509d16b252ca672d2881de84873ea13a6"
62
  },
63
  {
64
  "path": "config.json",
 
120
  "size_bytes": 33011,
121
  "sha256": "0ce91858995441a7b2c8fef65d9b0da1ba7135ced82c651a2890bf1802997c87"
122
  },
123
+ {
124
+ "path": "serving/.dockerignore",
125
+ "size_bytes": 51,
126
+ "sha256": "c1e2d5a66114a731106f2ac7c17dcf5f3841ccfbd82f5833b106c5862f9ab48a"
127
+ },
128
+ {
129
+ "path": "serving/Dockerfile",
130
+ "size_bytes": 620,
131
+ "sha256": "4db38457f7a17f860590b22dc3a02796048e60b41455d270bcdd86eb8f52f77d"
132
+ },
133
+ {
134
+ "path": "serving/SERVING.md",
135
+ "size_bytes": 5246,
136
+ "sha256": "9be604a955acf5f63fee59d81a6fb3430cddae32ec34eac3a3b19b3be6a115c6"
137
+ },
138
+ {
139
+ "path": "serving/TESTS.json",
140
+ "size_bytes": 4347,
141
+ "sha256": "cb277820be7e8c3277b450e6f3bba3ed216873674d02d431ea89775a3ceadd5e"
142
+ },
143
+ {
144
+ "path": "serving/request.json",
145
+ "size_bytes": 416,
146
+ "sha256": "b09ffbcfbf0525c83de758e032113fcfd269d2be82a112de3e85ec8af6f64fbd"
147
+ },
148
+ {
149
+ "path": "serving/requirements.txt",
150
+ "size_bytes": 209,
151
+ "sha256": "f70fac27c587d235a62549688a8a61c089eca728afb190af6ad02cae1e7c6407"
152
+ },
153
+ {
154
+ "path": "serving/run.py",
155
+ "size_bytes": 732,
156
+ "sha256": "28060c2e5bd33afc9848236fcd6ade36e026809def9510ab1fce891eef29243b"
157
+ },
158
+ {
159
+ "path": "serving/serve.py",
160
+ "size_bytes": 3074,
161
+ "sha256": "635070fdbe2bb70ddfe3e75f4555c7760ad12e7cb3741660c7d9e714e27459f6"
162
+ },
163
+ {
164
+ "path": "serving/test_serving.py",
165
+ "size_bytes": 4852,
166
+ "sha256": "e79aa7ddf6da7890abed5e5a55c8e52169cec5769e156601364fe8b7b8974795"
167
+ },
168
  {
169
  "path": "tokenizer.json",
170
  "size_bytes": 19989325,
README.md CHANGED
@@ -127,6 +127,15 @@ four-decimal probability rounding, then residual calibration.
127
 
128
  ## Usage
129
 
 
 
 
 
 
 
 
 
 
130
  Use Python 3.12, PyTorch, Transformers with Qwen3.5 support, and safetensors.
131
  PEFT is not needed to load the merged release. Do not install Unsloth for this
132
  loader. The local verification environment has an incompatible optional
 
127
 
128
  ## Usage
129
 
130
+ ### Decision API And Container
131
+
132
+ See [serving/SERVING.md](serving/SERVING.md) for the decision-only FastAPI
133
+ server, CLI, CPU/GPU Docker commands, private HF startup download and tests.
134
+ The endpoint is `POST /v1/systemone`; health is `GET /health`.
135
+ Image: `registry.omaemirates.com/sultan/saif/saif-4b-serving:0.1.0`.
136
+ Weights are mounted or downloaded, never baked into the image. Only CPU-tested;
137
+ GPU execution is expected but unverified. No text generation or chat routes.
138
+
139
  Use Python 3.12, PyTorch, Transformers with Qwen3.5 support, and safetensors.
140
  PEFT is not needed to load the merged release. Do not install Unsloth for this
141
  loader. The local verification environment has an incompatible optional
serving/.dockerignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ **
2
+ !Dockerfile
3
+ !requirements.txt
4
+ !serve.py
5
+ !run.py
serving/Dockerfile ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM pytorch/pytorch:2.8.0-cuda12.8-cudnn9-runtime
2
+ WORKDIR /app
3
+ COPY requirements.txt .
4
+ RUN python -m pip uninstall -y torchao && python -m pip install --no-cache-dir -r requirements.txt
5
+ RUN useradd --create-home --uid 10001 saif
6
+ COPY serve.py run.py ./
7
+ USER saif
8
+ ENV SAIF_DEVICE=cpu SAIF_THREADS=4 PORT=8000 HF_HOME=/home/saif/.cache/huggingface PYTHONDONTWRITEBYTECODE=1
9
+ EXPOSE 8000
10
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 CMD python -c "import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','8000')+'/health',timeout=4)"
11
+ CMD ["python", "serve.py"]
serving/SERVING.md ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Serving Saif 4B Decisions
2
+
3
+ This is a decision-only service, not a chat server. `POST /v1/systemone`
4
+ accepts `model`, any JSON `state`, and 1-64 typed `questions`. It returns
5
+ `model: saif-4b`, `answers` keyed by question ID, and token `usage` with zero
6
+ output tokens. The optional input model label is accepted for DI client
7
+ compatibility; this service always runs Saif 4B. `GET /health` becomes available
8
+ after the model has loaded. Validation and complete-record context overflow
9
+ return HTTP 422. No text-generation routes are registered.
10
+
11
+ `noul` returns `noul` (probability of true) and a probability distribution;
12
+ `choice` returns `choice`, `confidence`, and `probabilities`; `score` returns
13
+ the expected zero-based `score`, `confidence`, `legend`, and `probabilities`.
14
+ The loader's additional `type` and `answer` fields are preserved. Question
15
+ instructions are optional. Choice criteria are a mapping of at least two
16
+ option IDs to descriptions; score criteria are a list of at least two level
17
+ descriptions. Noul criteria optionally override `true` and `false` descriptions.
18
+ Images, video, chat messages and generation parameters are rejected.
19
+
20
+ ## Python And CLI
21
+
22
+ Use a clean Python 3.12 environment and `pip install -r requirements.txt`.
23
+ The torch, transformers and safetensors versions match the CPU equivalence
24
+ environment. **Exclude torchao**: the equivalence environment's optional
25
+ torchao installation is incompatible and is not needed for bf16 inference.
26
+ PEFT and Unsloth are not required. The CUDA wheel also supports CPU execution.
27
+
28
+ ```bash
29
+ SAIF_MODEL_PATH=/path/to/saif-4b SAIF_DEVICE=cpu CUDA_VISIBLE_DEVICES= python run.py request.json
30
+ SAIF_MODEL_PATH=/path/to/saif-4b SAIF_DEVICE=cpu CUDA_VISIBLE_DEVICES= python serve.py
31
+ ```
32
+
33
+ ```python
34
+ import sys
35
+ import torch
36
+ sys.path.insert(0, '/path/to/saif-4b')
37
+ from joint_schema_model import load
38
+ torch.set_num_threads(4)
39
+ model = load('/path/to/saif-4b')
40
+ print(model.decide('The delivery was excellent.', {
41
+ 'satisfied': {'type': 'noul', 'instructions': 'Is the customer satisfied?'}
42
+ })['answers'])
43
+ ```
44
+
45
+ ## Docker
46
+
47
+ The image contains the server and dependencies, **not the weights**. Model
48
+ directories contain executable custom Python: mount only a trusted release.
49
+ The process defaults to UID 10001; mounted weights must be readable by that
50
+ user. For owner-only (0600) local weights, use the non-root owner UID/GID as
51
+ below rather than making private weights world-readable. Omit `--user` when
52
+ the default user already has access. Startup downloads use the default user.
53
+
54
+ ```bash
55
+ IMAGE=registry.omaemirates.com/sultan/saif/saif-4b-serving:0.1.0
56
+ # CPU
57
+ docker run --rm --user "$(id -u):$(id -g)" -p 8000:8000 -e CUDA_VISIBLE_DEVICES= -e SAIF_DEVICE=cpu \
58
+ -e SAIF_MODEL_PATH=/model -v /absolute/path/to/saif-4b:/model:ro "$IMAGE"
59
+ # GPU: requires NVIDIA Container Toolkit on the host
60
+ docker run --rm --user "$(id -u):$(id -g)" --gpus all -p 8000:8000 -e SAIF_DEVICE=cuda \
61
+ -e SAIF_MODEL_PATH=/model -v /absolute/path/to/saif-4b:/model:ro "$IMAGE"
62
+ # Private HF download at startup; HF_TOKEN must already be exported securely
63
+ docker run --rm -p 8000:8000 -e HF_TOKEN -e SAIF_HF_REPO=GyrTechnology/saif-4b \
64
+ -e SAIF_DEVICE=cpu "$IMAGE"
65
+ ```
66
+
67
+ `SAIF_MODEL_PATH` takes precedence over `SAIF_HF_REPO`. With only the latter
68
+ set, startup uses `huggingface_hub.snapshot_download` and `HF_TOKEN`.
69
+ `SAIF_DEVICE` is `cpu` (default) or `cuda`; `SAIF_THREADS` defaults to 4;
70
+ `PORT` defaults to 8000. The image healthcheck follows `PORT`. A download can
71
+ take longer than the initial healthcheck grace period; readiness follows load.
72
+ Run one worker per model replica. Inference requests are serialized to bound
73
+ concurrent memory use. Put authentication/TLS at a trusted reverse proxy;
74
+ the service itself does not authenticate requests.
75
+
76
+ ```bash
77
+ curl http://localhost:8000/v1/systemone -H 'Content-Type: application/json' \
78
+ -d '{"model":"saif-4b","state":"The delivery was excellent.","questions":{"satisfied":{"type":"noul","instructions":"Is the customer satisfied?"}}}'
79
+ ```
80
+
81
+ ## Compatibility And Verification
82
+
83
+ Compatible options: **this server**; **Triton's Python backend (advanced)**
84
+ using the custom loader and decision head (no Triton configuration is bundled).
85
+
86
+ **Not supported: Ollama, llama.cpp/GGUF, vLLM, SGLang, LM Studio, TGI.**
87
+ These are text-generation engines, and this model is decision-only with a
88
+ custom head. Clef's specialized SGLang recipe does not establish support for
89
+ this Saif release. There is no LM head or open-ended chat API here.
90
+
91
+ Only CPU-tested so far. GPU execution is expected to work but is **not
92
+ verified**. The local GPU is training and was not used; local Docker has only
93
+ runc, without an NVIDIA runtime. See `TESTS.json` for exact-match checks,
94
+ malformed-input checks and measured CPU request latency, not a GPU benchmark.
95
+
96
+ Protocol references checked: DI `decision_index/engines/http.py` and Clef's
97
+ published README revision `0b331204bb13fbd2ca93a64df1956f5b55478ce5`.
98
+
99
+ To reproduce: run `test_serving.py reference` with `SAIF_MODEL_PATH` set, start
100
+ the server, then run `test_serving.py local --url http://127.0.0.1:8000`.
101
+ Repeat against the mounted-weight container with mode `container`. All test
102
+ commands require `CUDA_VISIBLE_DEVICES= SAIF_DEVICE=cpu`.
serving/TESTS.json ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "reference": [
3
+ {
4
+ "model": "saif-4b",
5
+ "answers": {
6
+ "satisfied": {
7
+ "type": "noul",
8
+ "probabilities": {
9
+ "true": 0.9977252035331371,
10
+ "false": 0.0022747964668629324
11
+ },
12
+ "noul": 0.9977252035331371,
13
+ "answer": true
14
+ }
15
+ },
16
+ "usage": {
17
+ "input_tokens": 154,
18
+ "output_tokens": 0
19
+ }
20
+ },
21
+ {
22
+ "model": "saif-4b",
23
+ "answers": {
24
+ "intent": {
25
+ "type": "choice",
26
+ "probabilities": {
27
+ "complaint": 0.0014628375324945344,
28
+ "praise": 0.9985371624675055
29
+ },
30
+ "choice": "praise",
31
+ "answer": "praise",
32
+ "confidence": 0.9985371624675055
33
+ }
34
+ },
35
+ "usage": {
36
+ "input_tokens": 141,
37
+ "output_tokens": 0
38
+ }
39
+ },
40
+ {
41
+ "model": "saif-4b",
42
+ "answers": {
43
+ "sentiment": {
44
+ "type": "score",
45
+ "probabilities": {
46
+ "0": 0.0029920151680415817,
47
+ "1": 0.06401839839575167,
48
+ "2": 0.9329895864362067
49
+ },
50
+ "score": 1.929997571268165,
51
+ "answer": 2,
52
+ "confidence": 0.9329895864362067,
53
+ "legend": {
54
+ "0": "negative",
55
+ "1": "neutral",
56
+ "2": "positive"
57
+ }
58
+ }
59
+ },
60
+ "usage": {
61
+ "input_tokens": 152,
62
+ "output_tokens": 0
63
+ }
64
+ }
65
+ ],
66
+ "versions": {
67
+ "torch": "2.8.0+cu128",
68
+ "transformers": "5.17.0",
69
+ "safetensors": "0.8.0",
70
+ "fastapi": "0.142.2",
71
+ "uvicorn": "0.54.0",
72
+ "pydantic": "2.14.1",
73
+ "huggingface_hub": "1.33.0",
74
+ "accelerate": "1.14.0"
75
+ },
76
+ "gpu_tested": false,
77
+ "gpu_note": "CPU only; local Docker has runc only, no NVIDIA runtime.",
78
+ "routes": [
79
+ "/docs",
80
+ "/docs/oauth2-redirect",
81
+ "/health",
82
+ "/openapi.json",
83
+ "/redoc",
84
+ "/v1/systemone"
85
+ ],
86
+ "no_generation_routes": true,
87
+ "64_question_schema_accepted": true,
88
+ "local": {
89
+ "passed": true,
90
+ "requests": [
91
+ {
92
+ "type": "noul",
93
+ "exact_match": true,
94
+ "probabilities_sum_one": true,
95
+ "latency_seconds": 2.8982914260122925
96
+ },
97
+ {
98
+ "type": "choice",
99
+ "exact_match": true,
100
+ "probabilities_sum_one": true,
101
+ "latency_seconds": 2.7930272528901696
102
+ },
103
+ {
104
+ "type": "score",
105
+ "exact_match": true,
106
+ "probabilities_sum_one": true,
107
+ "latency_seconds": 1.9185104710049927
108
+ }
109
+ ],
110
+ "malformed_422_count": 9,
111
+ "generation_routes_404": true,
112
+ "python_version": "3.12.14"
113
+ },
114
+ "di_client_live_passed": true,
115
+ "64_question_live": {
116
+ "passed": true,
117
+ "latency_seconds": 91.86650505103171
118
+ },
119
+ "cli": {
120
+ "happy_path_passed": true,
121
+ "help_passed": true,
122
+ "missing_file_exit_code": 2
123
+ },
124
+ "lsp": "Unavailable: basedpyright not installed; installation previously declined",
125
+ "container": {
126
+ "passed": true,
127
+ "requests": [
128
+ {
129
+ "type": "noul",
130
+ "exact_match": true,
131
+ "probabilities_sum_one": true,
132
+ "latency_seconds": 3.9418553828727454
133
+ },
134
+ {
135
+ "type": "choice",
136
+ "exact_match": true,
137
+ "probabilities_sum_one": true,
138
+ "latency_seconds": 1.384989856975153
139
+ },
140
+ {
141
+ "type": "score",
142
+ "exact_match": true,
143
+ "probabilities_sum_one": true,
144
+ "latency_seconds": 1.695539910113439
145
+ }
146
+ ],
147
+ "malformed_422_count": 9,
148
+ "generation_routes_404": true,
149
+ "user": "1000:1000",
150
+ "health": "healthy",
151
+ "python_version": "3.11 (official PyTorch base)",
152
+ "versions_match_local": true,
153
+ "initial_startup": "Default UID could not read owner-only 0600 weights. Re-run with non-root weight-owner UID:GID succeeded; documented --user flag. Permissions unchanged."
154
+ },
155
+ "docker_build": {
156
+ "passed": true,
157
+ "image_id": "sha256:576663ba2b8710c2906afdc7a6466a8f9de8003791225f124ff6ab1fa0aa3d76",
158
+ "default_user": "saif",
159
+ "base": "pytorch/pytorch:2.8.0-cuda12.8-cudnn9-runtime",
160
+ "weights_baked_in": false
161
+ },
162
+ "observations": [
163
+ "Existing release tokenizer emits incorrect-regex warning; loader/tokenizer unchanged to preserve verified model outputs.",
164
+ "HF startup-download path implemented, not exercised with a full redundant weight download."
165
+ ]
166
+ }
serving/request.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"model":"saif-4b","state":{"message":"The delivery arrived on time and works well."},"questions":{"satisfied":{"type":"noul","instructions":"Is the customer satisfied?"},"intent":{"type":"choice","instructions":"Classify the message.","criteria":{"praise":"Positive feedback","complaint":"A problem"}},"sentiment":{"type":"score","instructions":"Rate the sentiment.","criteria":["negative","neutral","positive"]}}}
serving/requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cu128
2
+ torch==2.8.0+cu128
3
+ transformers==5.17.0
4
+ safetensors==0.8.0
5
+ fastapi==0.142.2
6
+ uvicorn==0.54.0
7
+ pydantic==2.14.1
8
+ huggingface_hub==1.33.0
9
+ accelerate==1.14.0
serving/run.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run a typed decision request from a JSON file."""
2
+ import argparse
3
+ import json
4
+
5
+ from pydantic import ValidationError
6
+
7
+ from serve import DecisionRequest, load_model
8
+
9
+
10
+ def main():
11
+ parser = argparse.ArgumentParser(description=__doc__)
12
+ parser.add_argument("request", help="System One request JSON file")
13
+ args = parser.parse_args()
14
+ try:
15
+ with open(args.request) as stream:
16
+ request = DecisionRequest.model_validate(json.load(stream))
17
+ except (OSError, ValueError, ValidationError) as error:
18
+ parser.error(str(error))
19
+ result = load_model().decide(request.state, request.question_dict())
20
+ print(json.dumps(result["answers"], ensure_ascii=False))
21
+
22
+
23
+ if __name__ == "__main__":
24
+ main()
serving/serve.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Decision-only System One / Jev HTTP service."""
2
+ import importlib.util
3
+ import os
4
+ import sys
5
+ from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
+ from threading import Lock
8
+ from typing import Annotated, Literal
9
+
10
+ from fastapi import FastAPI, HTTPException
11
+ from pydantic import BaseModel, ConfigDict, Field, JsonValue
12
+
13
+
14
+ class Question(BaseModel):
15
+ model_config = ConfigDict(extra="forbid", strict=True)
16
+ instructions: str | None = None
17
+
18
+
19
+ class Noul(Question):
20
+ type: Literal["noul"]
21
+ criteria: dict[Literal["true", "false"], str] | None = None
22
+
23
+
24
+ class Choice(Question):
25
+ type: Literal["choice"]
26
+ criteria: dict[str, str] = Field(min_length=2)
27
+
28
+
29
+ class Score(Question):
30
+ type: Literal["score"]
31
+ criteria: list[str] = Field(min_length=2)
32
+
33
+
34
+ class DecisionRequest(BaseModel):
35
+ model_config = ConfigDict(extra="forbid", strict=True)
36
+ model: str = "saif-4b"
37
+ state: JsonValue
38
+ questions: dict[str, Annotated[Noul | Choice | Score, Field(discriminator="type")]] = Field(
39
+ min_length=1, max_length=64
40
+ )
41
+
42
+ def question_dict(self):
43
+ return {key: question.model_dump(exclude_none=True) for key, question in self.questions.items()}
44
+
45
+
46
+ def load_model():
47
+ import torch
48
+
49
+ device = os.environ.get("SAIF_DEVICE", "cpu")
50
+ if device not in ("cpu", "cuda"):
51
+ raise ValueError("SAIF_DEVICE must be cpu or cuda")
52
+ threads = int(os.environ.get("SAIF_THREADS", "4"))
53
+ if threads < 1:
54
+ raise ValueError("SAIF_THREADS must be positive")
55
+ torch.set_num_threads(threads)
56
+ path = os.environ.get("SAIF_MODEL_PATH")
57
+ if not path and os.environ.get("SAIF_HF_REPO"):
58
+ from huggingface_hub import snapshot_download
59
+
60
+ path = snapshot_download(os.environ["SAIF_HF_REPO"], token=os.environ.get("HF_TOKEN"))
61
+ if not path:
62
+ raise ValueError("Set SAIF_MODEL_PATH or SAIF_HF_REPO")
63
+ path = Path(path).resolve()
64
+ spec = importlib.util.spec_from_file_location("saif_release_model", path / "joint_schema_model.py")
65
+ module = importlib.util.module_from_spec(spec)
66
+ sys.modules[spec.name] = module
67
+ spec.loader.exec_module(module)
68
+ return module.load(str(path)).to(device=device)
69
+
70
+
71
+ @asynccontextmanager
72
+ async def lifespan(application):
73
+ application.state.model = load_model()
74
+ application.state.lock = Lock()
75
+ yield
76
+ del application.state.model
77
+
78
+
79
+ app = FastAPI(title="Saif 4B Decisions", version="0.1.0", lifespan=lifespan)
80
+
81
+
82
+ @app.get("/health")
83
+ def health():
84
+ return {"status": "ok", "model": "saif-4b"}
85
+
86
+
87
+ @app.post("/v1/systemone")
88
+ def decide(request: DecisionRequest):
89
+ try:
90
+ with app.state.lock:
91
+ return app.state.model.decide(request.state, request.question_dict())
92
+ except ValueError as error:
93
+ detail = str(error)
94
+ if "Complete record exceeds" in detail:
95
+ detail = "maximum context length: " + detail
96
+ raise HTTPException(status_code=422, detail=detail) from error
97
+
98
+
99
+ if __name__ == "__main__":
100
+ import uvicorn
101
+
102
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8000")))
serving/test_serving.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CPU-only live HTTP checks; generate reference once, then check each server."""
2
+ import argparse
3
+ import importlib.metadata
4
+ import json
5
+ import math
6
+ import os
7
+ from pathlib import Path
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+
12
+ os.environ["CUDA_VISIBLE_DEVICES"] = ""
13
+ os.environ["SAIF_DEVICE"] = "cpu"
14
+
15
+ from serve import DecisionRequest, app, load_model
16
+
17
+ ROOT = Path(__file__).resolve().parent
18
+
19
+
20
+ def requests():
21
+ example = json.loads((ROOT / "request.json").read_text())
22
+ return [dict(example, questions={key: value}) for key, value in example["questions"].items()]
23
+
24
+
25
+ def post(url, payload):
26
+ request = urllib.request.Request(url + "/v1/systemone", data=json.dumps(payload).encode(),
27
+ headers={"Content-Type": "application/json"})
28
+ with urllib.request.urlopen(request, timeout=600) as response:
29
+ return json.load(response)
30
+
31
+
32
+ def main():
33
+ parser = argparse.ArgumentParser(description=__doc__)
34
+ parser.add_argument("mode", choices=["reference", "local", "container"])
35
+ parser.add_argument("--url", default="http://127.0.0.1:18000")
36
+ args = parser.parse_args()
37
+ target = ROOT / "TESTS.json"
38
+ report = json.loads(target.read_text()) if target.exists() else {}
39
+ if args.mode == "reference":
40
+ model = load_model()
41
+ outputs = [model.decide(item["state"], item["questions"]) for item in requests()]
42
+ report["reference"] = json.loads(json.dumps(outputs))
43
+ report["versions"] = {name: importlib.metadata.version(name) for name in
44
+ ["torch", "transformers", "safetensors", "fastapi", "uvicorn",
45
+ "pydantic", "huggingface_hub", "accelerate"]}
46
+ report["gpu_tested"] = False
47
+ report["gpu_note"] = "CPU only; local Docker has runc only, no NVIDIA runtime."
48
+ report["routes"] = sorted(route.path for route in app.routes)
49
+ assert set(report["routes"]) == {"/health", "/v1/systemone", "/docs", "/docs/oauth2-redirect", "/redoc", "/openapi.json"}
50
+ report["no_generation_routes"] = True
51
+ DecisionRequest.model_validate(dict(requests()[0], questions={str(index): {"type": "noul"} for index in range(64)}))
52
+ report["64_question_schema_accepted"] = True
53
+ else:
54
+ with urllib.request.urlopen(args.url + "/health") as response:
55
+ assert json.load(response) == {"status": "ok", "model": "saif-4b"}
56
+ results = []
57
+ for payload, expected in zip(requests(), report["reference"]):
58
+ start = time.perf_counter()
59
+ actual = post(args.url, payload)
60
+ elapsed = time.perf_counter() - start
61
+ assert actual == expected, (actual, expected)
62
+ for answer in actual["answers"].values():
63
+ probabilities = list(answer["probabilities"].values())
64
+ assert all(math.isfinite(value) and 0 <= value <= 1 for value in probabilities)
65
+ assert math.isclose(sum(probabilities), 1, abs_tol=1e-12)
66
+ results.append({"type": next(iter(payload["questions"].values()))["type"],
67
+ "exact_match": True, "probabilities_sum_one": True, "latency_seconds": elapsed})
68
+ malformed = [{}, {"state": "x", "questions": {}},
69
+ {"state": "x", "questions": {"q": {"type": "text"}}},
70
+ {"state": "x", "questions": {"q": {"type": "choice", "criteria": []}}},
71
+ {"state": "x", "questions": {"q": {"type": "score", "criteria": {"a": "x"}}}},
72
+ {"state": "x", "questions": {"q": {"type": "noul", "criteria": {"bad": "x"}}}},
73
+ dict(requests()[0], images=["unsupported"]),
74
+ dict(requests()[0], questions={str(index): {"type": "noul"} for index in range(65)}),
75
+ dict(requests()[0], state="word " * 17000)]
76
+ for payload in malformed:
77
+ try:
78
+ post(args.url, payload)
79
+ except urllib.error.HTTPError as error:
80
+ assert error.code == 422, error.code
81
+ else:
82
+ raise AssertionError("Malformed input was accepted")
83
+ for route in ("/v1/chat/completions", "/v1/completions", "/generate"):
84
+ try:
85
+ urllib.request.urlopen(urllib.request.Request(args.url + route, data=b"{}"))
86
+ except urllib.error.HTTPError as error:
87
+ assert error.code == 404
88
+ else:
89
+ raise AssertionError("Unexpected generation endpoint")
90
+ report[args.mode] = {"passed": True, "requests": results, "malformed_422_count": len(malformed),
91
+ "generation_routes_404": True}
92
+ target.write_text(json.dumps(report, indent=2) + "\n")
93
+ print(json.dumps(report.get(args.mode, {"reference": "passed"}), indent=2))
94
+
95
+
96
+ if __name__ == "__main__":
97
+ main()