Text Classification
Transformers
Safetensors
saif_decision
feature-extraction
typed-decisions
decision-model
system-one
jev-compatible
tool-use
routing
classification
calibration
custom-code
custom_code
Instructions to use GyrTechnology/saif-4b with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use GyrTechnology/saif-4b with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-classification", model="GyrTechnology/saif-4b", trust_remote_code=True)# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("GyrTechnology/saif-4b", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Add CPU-verified decision-only serving kit and container usage
Browse files- MANIFEST.json +47 -2
- README.md +9 -0
- serving/.dockerignore +5 -0
- serving/Dockerfile +11 -0
- serving/SERVING.md +102 -0
- serving/TESTS.json +166 -0
- serving/request.json +1 -0
- serving/requirements.txt +9 -0
- serving/run.py +24 -0
- serving/serve.py +102 -0
- serving/test_serving.py +97 -0
MANIFEST.json
CHANGED
|
@@ -57,8 +57,8 @@
|
|
| 57 |
},
|
| 58 |
{
|
| 59 |
"path": "README.md",
|
| 60 |
-
"size_bytes":
|
| 61 |
-
"sha256": "
|
| 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()
|