inferkit 0.1.0__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.
- inferkit-0.1.0.dist-info/METADATA +191 -0
- inferkit-0.1.0.dist-info/RECORD +21 -0
- inferkit-0.1.0.dist-info/WHEEL +4 -0
- inferkit-0.1.0.dist-info/entry_points.txt +2 -0
- slmkit/__init__.py +11 -0
- slmkit/backends/__init__.py +26 -0
- slmkit/backends/llamacpp.py +59 -0
- slmkit/backends/mlx.py +63 -0
- slmkit/backends/onnx.py +1 -0
- slmkit/cli.py +104 -0
- slmkit/compiler.py +172 -0
- slmkit/formats/__init__.py +1 -0
- slmkit/formats/artifact.py +136 -0
- slmkit/formats/manifest.py +74 -0
- slmkit/formats/schema.py +31 -0
- slmkit/model.py +88 -0
- slmkit/tasks/__init__.py +26 -0
- slmkit/tasks/classification.py +41 -0
- slmkit/tasks/embedding.py +1 -0
- slmkit/tasks/extraction.py +1 -0
- slmkit/tasks/generation.py +1 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inferkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Embeddable inference engine for small language models — the SQLite for AI
|
|
5
|
+
Author-email: Milad Olad <milad.olad@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: ai,edge,embedding,inference,llm,local,offline,slm
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: huggingface-hub>=0.25
|
|
19
|
+
Requires-Dist: llama-cpp-python>=0.3
|
|
20
|
+
Requires-Dist: tokenizers>=0.21
|
|
21
|
+
Requires-Dist: typer>=0.15
|
|
22
|
+
Provides-Extra: mlx
|
|
23
|
+
Requires-Dist: mlx-lm>=0.31; extra == 'mlx'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# slmkit
|
|
27
|
+
|
|
28
|
+
Package small language models into self-contained, embeddable `.slm` artifacts.
|
|
29
|
+
|
|
30
|
+
**SQLite for AI** — one file, zero config, zero internet, 5ms inference.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import slmkit
|
|
34
|
+
|
|
35
|
+
classifier = slmkit.load("sentiment.slm")
|
|
36
|
+
result = classifier("The aurora was breathtaking")
|
|
37
|
+
# → {"label": "positive", "confidence": 1.0}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install slmkit
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Python 3.11+. The `llama-cpp-python` dependency compiles C++ on first install, which may take a few minutes.
|
|
47
|
+
|
|
48
|
+
## Quickstart
|
|
49
|
+
|
|
50
|
+
### 1. Compile a model
|
|
51
|
+
|
|
52
|
+
Package a pre-quantized GGUF model from HuggingFace into a `.slm` artifact:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
slm compile \
|
|
56
|
+
--source Qwen/Qwen2.5-0.5B-Instruct-GGUF \
|
|
57
|
+
--task classification \
|
|
58
|
+
--labels "positive,negative,neutral" \
|
|
59
|
+
--quantize q4_k_m \
|
|
60
|
+
--output sentiment.slm
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
This downloads the model (~490 MB), bakes in a task-specific prompt, and packages everything into a single file.
|
|
64
|
+
|
|
65
|
+
You can also pass a local `.gguf` file as `--source` instead of a HuggingFace repo.
|
|
66
|
+
|
|
67
|
+
### 2. Load and run
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import slmkit
|
|
71
|
+
|
|
72
|
+
with slmkit.load("sentiment.slm") as model:
|
|
73
|
+
result = model("The midnight sun over Tromsø was unforgettable")
|
|
74
|
+
print(result)
|
|
75
|
+
# {"label": "positive", "confidence": 1.0}
|
|
76
|
+
|
|
77
|
+
results = model.batch([
|
|
78
|
+
"The recipe was a disaster",
|
|
79
|
+
"I finally fixed the timing belt myself",
|
|
80
|
+
])
|
|
81
|
+
# [{"label": "negative", ...}, {"label": "positive", ...}]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 3. Test and inspect
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# Run inference from the CLI
|
|
88
|
+
slm test sentiment.slm --input "The coral reef was thriving"
|
|
89
|
+
|
|
90
|
+
# View artifact metadata
|
|
91
|
+
slm info sentiment.slm
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Validation
|
|
95
|
+
|
|
96
|
+
Bake test cases into the artifact at compile time:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
slm compile \
|
|
100
|
+
--source Qwen/Qwen2.5-0.5B-Instruct-GGUF \
|
|
101
|
+
--task classification \
|
|
102
|
+
--labels "positive,negative,neutral" \
|
|
103
|
+
--quantize q4_k_m \
|
|
104
|
+
--validation tests.json \
|
|
105
|
+
--output sentiment.slm
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Where `tests.json` contains:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
[
|
|
112
|
+
{"input": "The view from the summit was breathtaking", "expected_label": "positive"},
|
|
113
|
+
{"input": "The bridge collapsed during rush hour", "expected_label": "negative"}
|
|
114
|
+
]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Then run the validation suite:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
slm test sentiment.slm
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## The `.slm` format
|
|
124
|
+
|
|
125
|
+
A `.slm` file is a ZIP archive with a fixed structure:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
sentiment.slm (ZIP)
|
|
129
|
+
├── model.gguf # Quantized model weights
|
|
130
|
+
├── manifest.json # Task type, backend, labels, source info
|
|
131
|
+
├── prompt_template.txt # Baked-in system prompt
|
|
132
|
+
├── tokenizer.json # Tokenizer config (optional)
|
|
133
|
+
└── validation.json # Test cases (optional)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
One file. Everything included. Ship it, load it, call it.
|
|
137
|
+
|
|
138
|
+
## Current scope
|
|
139
|
+
|
|
140
|
+
This is an early release focused on proving the core workflow:
|
|
141
|
+
|
|
142
|
+
- **Backends**: llama-cpp-python (default), MLX (Apple Silicon, `pip install slmkit[mlx]`)
|
|
143
|
+
- **Task**: classification
|
|
144
|
+
- **Models**: pre-quantized GGUF or MLX-format models from HuggingFace
|
|
145
|
+
|
|
146
|
+
### MLX on Apple Silicon
|
|
147
|
+
|
|
148
|
+
For Metal-accelerated inference on M-series Macs:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
pip install slmkit[mlx]
|
|
152
|
+
|
|
153
|
+
slm compile \
|
|
154
|
+
--source mlx-community/Qwen2.5-0.5B-Instruct-4bit \
|
|
155
|
+
--task classification \
|
|
156
|
+
--labels "positive,negative,neutral" \
|
|
157
|
+
--backend mlx \
|
|
158
|
+
--output sentiment-mlx.slm
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The backend is auto-detected from the model source, or set explicitly with `--backend`.
|
|
162
|
+
|
|
163
|
+
## Examples
|
|
164
|
+
|
|
165
|
+
Runnable examples in [`examples/`](examples/):
|
|
166
|
+
|
|
167
|
+
### Classification
|
|
168
|
+
|
|
169
|
+
| Example | Model | Size | Description |
|
|
170
|
+
|---------|-------|------|-------------|
|
|
171
|
+
| [sentiment.py](examples/classification/sentiment.py) | Qwen2.5-0.5B | ~490 MB | Restaurant review sentiment |
|
|
172
|
+
| [moderation.py](examples/classification/moderation.py) | Qwen2.5-0.5B | ~490 MB | Content moderation with validation |
|
|
173
|
+
| [spam_detection.py](examples/classification/spam_detection.py) | SmolLM2-360M | ~300 MB | Binary spam vs ham |
|
|
174
|
+
| [urgency_triage.py](examples/classification/urgency_triage.py) | Llama-3.2-1B | ~700 MB | Priority routing |
|
|
175
|
+
| [language_detection.py](examples/classification/language_detection.py) | Qwen2.5-1.5B | ~1 GB | Six-language detection |
|
|
176
|
+
| [topic_categorization.py](examples/classification/topic_categorization.py) | Gemma-2-2B | ~1.5 GB | Article topic sorting |
|
|
177
|
+
| [tone_detection.py](examples/classification/tone_detection.py) | Phi-3.5-Mini | ~2.2 GB | Tone/intent classification |
|
|
178
|
+
|
|
179
|
+
### MLX (Apple Silicon)
|
|
180
|
+
|
|
181
|
+
| Example | Model | Size | Description |
|
|
182
|
+
|---------|-------|------|-------------|
|
|
183
|
+
| [sentiment.py](examples/mlx/sentiment.py) | Qwen2.5-0.5B-4bit | ~243 MB | Metal-accelerated sentiment |
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
uv run python examples/classification/sentiment.py
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
slmkit/__init__.py,sha256=hPIeouWwN_JjFKvXuvyeA1KosmWJ5vk-A3xJYouZAU0,293
|
|
2
|
+
slmkit/cli.py,sha256=DktdvPeFmIk5BSYYyRo6kufaN_nDPbbV9W-s8a2jC-k,3547
|
|
3
|
+
slmkit/compiler.py,sha256=tlvywoNOa5iFucq2wAArkWhNDAyifpPnXC_Tz03Rk5s,5379
|
|
4
|
+
slmkit/model.py,sha256=OkqwWwnuXLbOHC8SVVN3DbEJFEPtzgA-jBssStA0zXA,2882
|
|
5
|
+
slmkit/backends/__init__.py,sha256=JuHaE88uKrWlDYR8pba5FAQ_PnwxwBlx-aQYmIOVyww,797
|
|
6
|
+
slmkit/backends/llamacpp.py,sha256=s5iJxSm3exCRlKMK2AN7beLY6FndP4yQ6fIPibp6Rk4,1619
|
|
7
|
+
slmkit/backends/mlx.py,sha256=04M2zOthQTkD4vNlcC6zf0cCcc-NqK0RGQ2HR1-J4ok,1813
|
|
8
|
+
slmkit/backends/onnx.py,sha256=A6G6gZkoxozvGMI619mlgHD0puyan66ITCu9CgdV3IA,47
|
|
9
|
+
slmkit/formats/__init__.py,sha256=G-z8Pyey-ooaRD5dt0Z_hgjraijLDHxUIWlMnXN8-XA,47
|
|
10
|
+
slmkit/formats/artifact.py,sha256=H8hhruMTAXAl2aoqhZRZF-j73HsuJiandn3RTEI0jqQ,4745
|
|
11
|
+
slmkit/formats/manifest.py,sha256=yYNO3URS0DCgN4PBEOGRI_pP1YGNjZdklxFGpYvJzEs,2272
|
|
12
|
+
slmkit/formats/schema.py,sha256=pGLK3-9zxsTF4ajr4FX_NJ7mvnfgaJ-jGEpv5zKj-UQ,1067
|
|
13
|
+
slmkit/tasks/__init__.py,sha256=58vTSICyGHa7cZ380CZQI7nx8cDlHv05egnmBg2OSdA,981
|
|
14
|
+
slmkit/tasks/classification.py,sha256=W7mFYt7E8YQMWgZEjkbE_dM-KmFgS67wt8OkIgvy5As,1330
|
|
15
|
+
slmkit/tasks/embedding.py,sha256=5mFqr_bTTCMFSPRByUR64XXN_Vg4WHVUpeqyursjRbI,38
|
|
16
|
+
slmkit/tasks/extraction.py,sha256=EmraQZEPE6NXp8gy3wxz1z-J7gCN1mYgWKEzEd7CWR0,43
|
|
17
|
+
slmkit/tasks/generation.py,sha256=1WzRitNl_be-86PErP6AMeUBd3swZkOD0XlK8LWLr0c,47
|
|
18
|
+
inferkit-0.1.0.dist-info/METADATA,sha256=Dc0VadH38f3Clh889ceGyDMtxKYPzQ2ZCOGwmoNtb78,5566
|
|
19
|
+
inferkit-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
20
|
+
inferkit-0.1.0.dist-info/entry_points.txt,sha256=oE0uOL2wSFKert5p7dq3UuhSVTrUdQDzwwh9FzDu73k,39
|
|
21
|
+
inferkit-0.1.0.dist-info/RECORD,,
|
slmkit/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""slmkit — Package small language models into self-contained, embeddable artifacts."""
|
|
2
|
+
|
|
3
|
+
from slmkit.model import SlmModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def load(path: str) -> SlmModel:
|
|
7
|
+
"""Load a .slm artifact and return a callable model."""
|
|
8
|
+
return SlmModel.from_artifact(path)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = ["SlmModel", "load"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Backend auto-detection and dispatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from slmkit.formats.manifest import BackendType
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from slmkit.backends.llamacpp import LlamaCppBackend
|
|
12
|
+
from slmkit.backends.mlx import MlxBackend
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_backend(backend: BackendType, model_path: Path) -> LlamaCppBackend | MlxBackend:
|
|
16
|
+
"""Instantiate the requested inference backend."""
|
|
17
|
+
if backend == BackendType.LLAMACPP:
|
|
18
|
+
from slmkit.backends.llamacpp import LlamaCppBackend
|
|
19
|
+
|
|
20
|
+
return LlamaCppBackend(model_path)
|
|
21
|
+
if backend == BackendType.MLX:
|
|
22
|
+
from slmkit.backends.mlx import MlxBackend
|
|
23
|
+
|
|
24
|
+
return MlxBackend(model_path)
|
|
25
|
+
msg = f"Unsupported backend: {backend}"
|
|
26
|
+
raise ValueError(msg)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""llama-cpp-python inference backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LlamaCppBackend:
|
|
10
|
+
"""Inference backend using llama-cpp-python."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
model_path: Path,
|
|
15
|
+
*,
|
|
16
|
+
n_ctx: int = 512,
|
|
17
|
+
n_gpu_layers: int = -1,
|
|
18
|
+
) -> None:
|
|
19
|
+
try:
|
|
20
|
+
from llama_cpp import Llama
|
|
21
|
+
except ImportError:
|
|
22
|
+
msg = (
|
|
23
|
+
"llama-cpp-python is not installed or failed to load. "
|
|
24
|
+
"Install it with: pip install llama-cpp-python"
|
|
25
|
+
)
|
|
26
|
+
raise ImportError(msg) from None
|
|
27
|
+
|
|
28
|
+
if not model_path.exists():
|
|
29
|
+
msg = f"Model file not found: {model_path}"
|
|
30
|
+
raise FileNotFoundError(msg)
|
|
31
|
+
|
|
32
|
+
self._model = Llama(
|
|
33
|
+
model_path=str(model_path),
|
|
34
|
+
n_ctx=n_ctx,
|
|
35
|
+
n_gpu_layers=n_gpu_layers,
|
|
36
|
+
verbose=False,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def chat(
|
|
40
|
+
self,
|
|
41
|
+
system_prompt: str,
|
|
42
|
+
user_message: str,
|
|
43
|
+
*,
|
|
44
|
+
max_tokens: int = 32,
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Run chat completion and return the assistant response text."""
|
|
47
|
+
response: dict[str, Any] = self._model.create_chat_completion(
|
|
48
|
+
messages=[
|
|
49
|
+
{"role": "system", "content": system_prompt},
|
|
50
|
+
{"role": "user", "content": user_message},
|
|
51
|
+
],
|
|
52
|
+
max_tokens=max_tokens,
|
|
53
|
+
temperature=0.0,
|
|
54
|
+
)
|
|
55
|
+
return response["choices"][0]["message"]["content"]
|
|
56
|
+
|
|
57
|
+
def close(self) -> None:
|
|
58
|
+
if hasattr(self, "_model"):
|
|
59
|
+
del self._model
|
slmkit/backends/mlx.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""MLX inference backend for Apple Silicon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MlxBackend:
|
|
9
|
+
"""Inference backend using mlx-lm on Apple Silicon."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, model_path: Path) -> None:
|
|
12
|
+
try:
|
|
13
|
+
from mlx_lm import load
|
|
14
|
+
except ImportError:
|
|
15
|
+
msg = (
|
|
16
|
+
"mlx-lm is not installed. "
|
|
17
|
+
"Install it with: pip install mlx-lm (requires Apple Silicon Mac)"
|
|
18
|
+
)
|
|
19
|
+
raise ImportError(msg) from None
|
|
20
|
+
|
|
21
|
+
if not model_path.exists():
|
|
22
|
+
msg = f"Model path not found: {model_path}"
|
|
23
|
+
raise FileNotFoundError(msg)
|
|
24
|
+
|
|
25
|
+
self._model, self._tokenizer = load(str(model_path))
|
|
26
|
+
|
|
27
|
+
def chat(
|
|
28
|
+
self,
|
|
29
|
+
system_prompt: str,
|
|
30
|
+
user_message: str,
|
|
31
|
+
*,
|
|
32
|
+
max_tokens: int = 32,
|
|
33
|
+
) -> str:
|
|
34
|
+
"""Run chat completion and return the assistant response text."""
|
|
35
|
+
import mlx.core as mx
|
|
36
|
+
from mlx_lm import generate
|
|
37
|
+
|
|
38
|
+
messages = [
|
|
39
|
+
{"role": "system", "content": system_prompt},
|
|
40
|
+
{"role": "user", "content": user_message},
|
|
41
|
+
]
|
|
42
|
+
prompt = self._tokenizer.apply_chat_template(
|
|
43
|
+
messages, add_generation_prompt=True, tokenize=False
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Greedy (temperature=0) sampling via argmax
|
|
47
|
+
def _greedy_sampler(logits: mx.array) -> mx.array:
|
|
48
|
+
return mx.argmax(logits, axis=-1)
|
|
49
|
+
|
|
50
|
+
return generate(
|
|
51
|
+
self._model,
|
|
52
|
+
self._tokenizer,
|
|
53
|
+
prompt=prompt,
|
|
54
|
+
max_tokens=max_tokens,
|
|
55
|
+
sampler=_greedy_sampler,
|
|
56
|
+
verbose=False,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def close(self) -> None:
|
|
60
|
+
if hasattr(self, "_model"):
|
|
61
|
+
del self._model
|
|
62
|
+
if hasattr(self, "_tokenizer"):
|
|
63
|
+
del self._tokenizer
|
slmkit/backends/onnx.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ONNX Runtime inference backend (future)."""
|
slmkit/cli.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""CLI entrypoint for the `slm` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="slmkit — package and run small language models")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command()
|
|
14
|
+
def compile(
|
|
15
|
+
source: Annotated[str, typer.Option(help="HuggingFace repo ID or local path")],
|
|
16
|
+
task: Annotated[str, typer.Option(help="Task type (e.g., classification)")],
|
|
17
|
+
output: Annotated[str, typer.Option(help="Output .slm file path")],
|
|
18
|
+
labels: Annotated[
|
|
19
|
+
str | None, typer.Option(help="Comma-separated labels for classification")
|
|
20
|
+
] = None,
|
|
21
|
+
quantize: Annotated[str, typer.Option(help="Quantization level (e.g., q4_k_m)")] = "q4_k_m",
|
|
22
|
+
validation: Annotated[
|
|
23
|
+
str | None,
|
|
24
|
+
typer.Option(help="Path to validation JSON file (list of {input, expected_label})"),
|
|
25
|
+
] = None,
|
|
26
|
+
backend: Annotated[
|
|
27
|
+
str | None,
|
|
28
|
+
typer.Option(help="Backend: llamacpp or mlx (auto-detected if omitted)"),
|
|
29
|
+
] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Compile a HuggingFace model into a .slm artifact."""
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from slmkit.compiler import compile_model
|
|
35
|
+
|
|
36
|
+
label_list = [label.strip() for label in labels.split(",")] if labels else None
|
|
37
|
+
|
|
38
|
+
validation_data = None
|
|
39
|
+
if validation:
|
|
40
|
+
validation_data = json.loads(Path(validation).read_text())
|
|
41
|
+
|
|
42
|
+
result = compile_model(
|
|
43
|
+
source,
|
|
44
|
+
task=task,
|
|
45
|
+
output=output,
|
|
46
|
+
labels=label_list,
|
|
47
|
+
quantization=quantize,
|
|
48
|
+
validation=validation_data,
|
|
49
|
+
backend=backend,
|
|
50
|
+
)
|
|
51
|
+
size_mb = result.stat().st_size / 1024 / 1024
|
|
52
|
+
typer.echo(f"Compiled: {result} ({size_mb:.1f} MB)")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command()
|
|
56
|
+
def test(
|
|
57
|
+
artifact: Annotated[str, typer.Argument(help="Path to .slm artifact")],
|
|
58
|
+
input_text: Annotated[str | None, typer.Option("--input", help="Test input text")] = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Run a quick sanity check on a .slm artifact."""
|
|
61
|
+
import slmkit
|
|
62
|
+
|
|
63
|
+
with slmkit.load(artifact) as model:
|
|
64
|
+
model_info = model.info()
|
|
65
|
+
typer.echo(f"Task: {model_info['task']}")
|
|
66
|
+
typer.echo(f"Backend: {model_info['backend']}")
|
|
67
|
+
typer.echo(f"Source: {model_info['source']}")
|
|
68
|
+
|
|
69
|
+
if input_text:
|
|
70
|
+
result = model(input_text)
|
|
71
|
+
typer.echo(f"Result: {json.dumps(result)}")
|
|
72
|
+
|
|
73
|
+
# Run baked-in validation cases
|
|
74
|
+
validation = model.validation_cases()
|
|
75
|
+
if validation:
|
|
76
|
+
typer.echo(f"\nRunning {len(validation)} validation case(s)...")
|
|
77
|
+
passed = 0
|
|
78
|
+
for i, case in enumerate(validation, 1):
|
|
79
|
+
result = model(case["input"])
|
|
80
|
+
expected = case.get("expected_label")
|
|
81
|
+
actual = result["label"]
|
|
82
|
+
ok = actual == expected if expected else True
|
|
83
|
+
status = "PASS" if ok else "FAIL"
|
|
84
|
+
typer.echo(f' [{status}] {i}. "{case["input"][:50]}" → {actual}')
|
|
85
|
+
if ok:
|
|
86
|
+
passed += 1
|
|
87
|
+
typer.echo(f"\n{passed}/{len(validation)} passed")
|
|
88
|
+
if passed < len(validation):
|
|
89
|
+
raise typer.Exit(1)
|
|
90
|
+
elif not input_text:
|
|
91
|
+
typer.echo("Model loaded successfully. Use --input to run inference.")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command()
|
|
95
|
+
def info(
|
|
96
|
+
artifact: Annotated[str, typer.Argument(help="Path to .slm artifact")],
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Show metadata from a .slm artifact."""
|
|
99
|
+
from pathlib import Path
|
|
100
|
+
|
|
101
|
+
from slmkit.formats.artifact import read_manifest
|
|
102
|
+
|
|
103
|
+
manifest = read_manifest(Path(artifact))
|
|
104
|
+
typer.echo(json.dumps(manifest.to_dict(), indent=2))
|
slmkit/compiler.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Compile pipeline: HuggingFace model → .slm artifact."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from huggingface_hub import hf_hub_download, list_repo_files, snapshot_download
|
|
9
|
+
|
|
10
|
+
from slmkit.formats.artifact import write_artifact
|
|
11
|
+
from slmkit.formats.manifest import BackendType, Manifest, ModelFormat, TaskType
|
|
12
|
+
from slmkit.formats.schema import validate_manifest
|
|
13
|
+
from slmkit.tasks import build_system_prompt
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _find_gguf_file(repo_id: str, quantization: str) -> str:
|
|
19
|
+
"""Find a GGUF file in a HuggingFace repo matching the requested quantization."""
|
|
20
|
+
files = list(list_repo_files(repo_id))
|
|
21
|
+
gguf_files = [f for f in files if f.endswith(".gguf")]
|
|
22
|
+
|
|
23
|
+
if not gguf_files:
|
|
24
|
+
msg = (
|
|
25
|
+
f"No GGUF files found in '{repo_id}'. "
|
|
26
|
+
f"Try a repo with pre-quantized GGUF files (e.g., '{repo_id}-GGUF')."
|
|
27
|
+
)
|
|
28
|
+
raise FileNotFoundError(msg)
|
|
29
|
+
|
|
30
|
+
q_lower = quantization.lower().replace("-", "_")
|
|
31
|
+
matching = [f for f in gguf_files if q_lower in f.lower()]
|
|
32
|
+
|
|
33
|
+
if not matching:
|
|
34
|
+
available = ", ".join(gguf_files)
|
|
35
|
+
msg = (
|
|
36
|
+
f"No GGUF file matching quantization '{quantization}' in '{repo_id}'. "
|
|
37
|
+
f"Available: {available}"
|
|
38
|
+
)
|
|
39
|
+
raise FileNotFoundError(msg)
|
|
40
|
+
|
|
41
|
+
return matching[0]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_gguf_model(source: str, quantization: str) -> Path:
|
|
45
|
+
"""Resolve the model source to a local GGUF file path."""
|
|
46
|
+
source_path = Path(source)
|
|
47
|
+
if source_path.is_file() and source_path.suffix == ".gguf":
|
|
48
|
+
return source_path
|
|
49
|
+
|
|
50
|
+
# Treat as HuggingFace repo ID
|
|
51
|
+
gguf_filename = _find_gguf_file(source, quantization)
|
|
52
|
+
logger.info("Downloading %s from %s", gguf_filename, source)
|
|
53
|
+
local_path = hf_hub_download(repo_id=source, filename=gguf_filename)
|
|
54
|
+
return Path(local_path)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_mlx_model(source: str) -> Path:
|
|
58
|
+
"""Resolve the model source to a local MLX model directory."""
|
|
59
|
+
source_path = Path(source)
|
|
60
|
+
if source_path.is_dir():
|
|
61
|
+
return source_path
|
|
62
|
+
|
|
63
|
+
# Download entire MLX repo snapshot
|
|
64
|
+
logger.info("Downloading MLX model snapshot from %s", source)
|
|
65
|
+
local_dir = snapshot_download(repo_id=source)
|
|
66
|
+
return Path(local_dir)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_tokenizer(source: str) -> Path | None:
|
|
70
|
+
"""Try to download tokenizer.json from the source repo."""
|
|
71
|
+
source_path = Path(source)
|
|
72
|
+
if source_path.is_file():
|
|
73
|
+
tokenizer = source_path.parent / "tokenizer.json"
|
|
74
|
+
return tokenizer if tokenizer.exists() else None
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
local_path = hf_hub_download(repo_id=source, filename="tokenizer.json")
|
|
78
|
+
return Path(local_path)
|
|
79
|
+
except Exception:
|
|
80
|
+
logger.debug("No tokenizer.json found in %s", source)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _detect_backend(source: str, backend: str | None) -> BackendType:
|
|
85
|
+
"""Detect or validate the backend to use."""
|
|
86
|
+
if backend:
|
|
87
|
+
return BackendType(backend)
|
|
88
|
+
|
|
89
|
+
# Auto-detect from source
|
|
90
|
+
source_path = Path(source)
|
|
91
|
+
if source_path.is_file() and source_path.suffix == ".gguf":
|
|
92
|
+
return BackendType.LLAMACPP
|
|
93
|
+
if source_path.is_dir():
|
|
94
|
+
# Check for safetensors files → MLX
|
|
95
|
+
if any(source_path.glob("*.safetensors")):
|
|
96
|
+
return BackendType.MLX
|
|
97
|
+
return BackendType.LLAMACPP
|
|
98
|
+
|
|
99
|
+
# HuggingFace repo — check for MLX markers
|
|
100
|
+
try:
|
|
101
|
+
files = list(list_repo_files(source))
|
|
102
|
+
if any(f.endswith(".safetensors") for f in files) and not any(
|
|
103
|
+
f.endswith(".gguf") for f in files
|
|
104
|
+
):
|
|
105
|
+
return BackendType.MLX
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
return BackendType.LLAMACPP
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def compile_model(
|
|
113
|
+
source: str,
|
|
114
|
+
*,
|
|
115
|
+
task: str,
|
|
116
|
+
output: str,
|
|
117
|
+
labels: list[str] | None = None,
|
|
118
|
+
quantization: str = "q4_k_m",
|
|
119
|
+
validation: list[dict[str, object]] | None = None,
|
|
120
|
+
backend: str | None = None,
|
|
121
|
+
) -> Path:
|
|
122
|
+
"""Compile a model source into a .slm artifact."""
|
|
123
|
+
task_type = TaskType(task)
|
|
124
|
+
output_path = Path(output)
|
|
125
|
+
|
|
126
|
+
# Build task config
|
|
127
|
+
task_config: dict[str, object] = {}
|
|
128
|
+
if task_type == TaskType.CLASSIFICATION:
|
|
129
|
+
if not labels:
|
|
130
|
+
msg = "Classification task requires --labels"
|
|
131
|
+
raise ValueError(msg)
|
|
132
|
+
task_config["labels"] = labels
|
|
133
|
+
|
|
134
|
+
# Detect backend
|
|
135
|
+
backend_type = _detect_backend(source, backend)
|
|
136
|
+
|
|
137
|
+
# Resolve model
|
|
138
|
+
if backend_type == BackendType.MLX:
|
|
139
|
+
model_path = _resolve_mlx_model(source)
|
|
140
|
+
model_format = ModelFormat.SAFETENSORS
|
|
141
|
+
# MLX repos include their own tokenizer; skip separate fetch
|
|
142
|
+
tokenizer_path = None
|
|
143
|
+
else:
|
|
144
|
+
model_path = _resolve_gguf_model(source, quantization)
|
|
145
|
+
model_format = ModelFormat.GGUF
|
|
146
|
+
tokenizer_path = _resolve_tokenizer(source)
|
|
147
|
+
|
|
148
|
+
# Build prompt template
|
|
149
|
+
prompt_template = build_system_prompt(task_type, task_config)
|
|
150
|
+
|
|
151
|
+
# Create manifest
|
|
152
|
+
manifest = Manifest(
|
|
153
|
+
task=task_type,
|
|
154
|
+
backend=backend_type,
|
|
155
|
+
source=source,
|
|
156
|
+
quantization=quantization,
|
|
157
|
+
model_format=model_format,
|
|
158
|
+
task_config=task_config,
|
|
159
|
+
)
|
|
160
|
+
validate_manifest(manifest)
|
|
161
|
+
|
|
162
|
+
# Package artifact
|
|
163
|
+
write_artifact(
|
|
164
|
+
output_path,
|
|
165
|
+
model_path=model_path,
|
|
166
|
+
manifest=manifest,
|
|
167
|
+
prompt_template=prompt_template,
|
|
168
|
+
tokenizer_path=tokenizer_path,
|
|
169
|
+
validation=validation,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return output_path
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
""".slm artifact format — ZIP read/write."""
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
""".slm artifact file I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import tempfile
|
|
7
|
+
import zipfile
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from slmkit.formats.manifest import Manifest
|
|
12
|
+
|
|
13
|
+
MANIFEST_NAME = "manifest.json"
|
|
14
|
+
MODEL_NAME = "model.gguf"
|
|
15
|
+
MODEL_DIR = "model/"
|
|
16
|
+
PROMPT_TEMPLATE_NAME = "prompt_template.txt"
|
|
17
|
+
TOKENIZER_NAME = "tokenizer.json"
|
|
18
|
+
VALIDATION_NAME = "validation.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class ArtifactContent:
|
|
23
|
+
"""Extracted contents of a .slm artifact."""
|
|
24
|
+
|
|
25
|
+
root: Path
|
|
26
|
+
manifest: Manifest
|
|
27
|
+
model_path: Path
|
|
28
|
+
prompt_template: str
|
|
29
|
+
tokenizer_path: Path | None
|
|
30
|
+
validation: list[dict[str, object]] | None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _validate_zip_entries(zf: zipfile.ZipFile) -> None:
|
|
34
|
+
"""Guard against zip-slip path traversal."""
|
|
35
|
+
for info in zf.infolist():
|
|
36
|
+
if info.filename.startswith("/") or ".." in info.filename:
|
|
37
|
+
msg = f"Unsafe zip entry: {info.filename}"
|
|
38
|
+
raise ValueError(msg)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def write_artifact(
|
|
42
|
+
output: Path,
|
|
43
|
+
*,
|
|
44
|
+
model_path: Path,
|
|
45
|
+
manifest: Manifest,
|
|
46
|
+
prompt_template: str,
|
|
47
|
+
tokenizer_path: Path | None = None,
|
|
48
|
+
validation: list[dict[str, object]] | None = None,
|
|
49
|
+
) -> Path:
|
|
50
|
+
"""Package model files into a .slm artifact."""
|
|
51
|
+
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
52
|
+
zf.writestr(MANIFEST_NAME, manifest.to_json())
|
|
53
|
+
if model_path.is_dir():
|
|
54
|
+
# MLX-style model directory: bundle all files under model/
|
|
55
|
+
for file in sorted(model_path.rglob("*")):
|
|
56
|
+
if file.is_file():
|
|
57
|
+
arcname = MODEL_DIR + str(file.relative_to(model_path))
|
|
58
|
+
zf.write(file, arcname)
|
|
59
|
+
else:
|
|
60
|
+
zf.write(model_path, MODEL_NAME)
|
|
61
|
+
zf.writestr(PROMPT_TEMPLATE_NAME, prompt_template)
|
|
62
|
+
if tokenizer_path and tokenizer_path.exists():
|
|
63
|
+
zf.write(tokenizer_path, TOKENIZER_NAME)
|
|
64
|
+
if validation:
|
|
65
|
+
zf.writestr(VALIDATION_NAME, json.dumps(validation, indent=2))
|
|
66
|
+
return output
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def read_manifest(path: Path) -> Manifest:
|
|
70
|
+
"""Read only the manifest from a .slm artifact without extracting the model."""
|
|
71
|
+
if not path.exists():
|
|
72
|
+
msg = f"Artifact not found: {path}"
|
|
73
|
+
raise FileNotFoundError(msg)
|
|
74
|
+
if not zipfile.is_zipfile(path):
|
|
75
|
+
msg = f"Not a valid .slm artifact (corrupt or wrong file): {path}"
|
|
76
|
+
raise ValueError(msg)
|
|
77
|
+
with zipfile.ZipFile(path, "r") as zf:
|
|
78
|
+
_validate_zip_entries(zf)
|
|
79
|
+
if MANIFEST_NAME not in zf.namelist():
|
|
80
|
+
msg = f"Artifact missing {MANIFEST_NAME}: {path}"
|
|
81
|
+
raise ValueError(msg)
|
|
82
|
+
manifest_data = zf.read(MANIFEST_NAME).decode()
|
|
83
|
+
return Manifest.from_json(manifest_data)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_artifact(path: Path, extract_dir: Path | None = None) -> ArtifactContent:
|
|
87
|
+
"""Extract a .slm artifact and return its contents."""
|
|
88
|
+
if not path.exists():
|
|
89
|
+
msg = f"Artifact not found: {path}"
|
|
90
|
+
raise FileNotFoundError(msg)
|
|
91
|
+
if not zipfile.is_zipfile(path):
|
|
92
|
+
msg = f"Not a valid .slm artifact (corrupt or wrong file): {path}"
|
|
93
|
+
raise ValueError(msg)
|
|
94
|
+
|
|
95
|
+
if extract_dir is None:
|
|
96
|
+
extract_dir = Path(tempfile.mkdtemp(prefix="slmkit_"))
|
|
97
|
+
|
|
98
|
+
with zipfile.ZipFile(path, "r") as zf:
|
|
99
|
+
_validate_zip_entries(zf)
|
|
100
|
+
names = zf.namelist()
|
|
101
|
+
has_gguf = MODEL_NAME in names
|
|
102
|
+
has_model_dir = any(n.startswith(MODEL_DIR) for n in names)
|
|
103
|
+
if not has_gguf and not has_model_dir:
|
|
104
|
+
msg = f"Artifact missing model data (expected '{MODEL_NAME}' or '{MODEL_DIR}'): {path}"
|
|
105
|
+
raise ValueError(msg)
|
|
106
|
+
for required in (MANIFEST_NAME, PROMPT_TEMPLATE_NAME):
|
|
107
|
+
if required not in names:
|
|
108
|
+
msg = f"Artifact missing required file '{required}': {path}"
|
|
109
|
+
raise ValueError(msg)
|
|
110
|
+
zf.extractall(extract_dir)
|
|
111
|
+
|
|
112
|
+
manifest = Manifest.from_path(extract_dir / MANIFEST_NAME)
|
|
113
|
+
prompt_template = (extract_dir / PROMPT_TEMPLATE_NAME).read_text()
|
|
114
|
+
|
|
115
|
+
# Determine model path: single GGUF file or model directory
|
|
116
|
+
model_dir = extract_dir / MODEL_DIR.rstrip("/")
|
|
117
|
+
gguf_path = extract_dir / MODEL_NAME
|
|
118
|
+
model_path_resolved = model_dir if model_dir.is_dir() else gguf_path
|
|
119
|
+
|
|
120
|
+
tokenizer_path = extract_dir / TOKENIZER_NAME
|
|
121
|
+
if not tokenizer_path.exists():
|
|
122
|
+
tokenizer_path = None
|
|
123
|
+
|
|
124
|
+
validation = None
|
|
125
|
+
validation_path = extract_dir / VALIDATION_NAME
|
|
126
|
+
if validation_path.exists():
|
|
127
|
+
validation = json.loads(validation_path.read_text())
|
|
128
|
+
|
|
129
|
+
return ArtifactContent(
|
|
130
|
+
root=extract_dir,
|
|
131
|
+
manifest=manifest,
|
|
132
|
+
model_path=model_path_resolved,
|
|
133
|
+
prompt_template=prompt_template,
|
|
134
|
+
tokenizer_path=tokenizer_path,
|
|
135
|
+
validation=validation,
|
|
136
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""manifest.json parsing and creation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from enum import StrEnum, auto
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TaskType(StrEnum):
|
|
13
|
+
CLASSIFICATION = auto()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BackendType(StrEnum):
|
|
17
|
+
LLAMACPP = auto()
|
|
18
|
+
MLX = auto()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ModelFormat(StrEnum):
|
|
22
|
+
GGUF = auto()
|
|
23
|
+
SAFETENSORS = auto()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class Manifest:
|
|
28
|
+
task: TaskType
|
|
29
|
+
backend: BackendType
|
|
30
|
+
source: str
|
|
31
|
+
quantization: str
|
|
32
|
+
model_format: ModelFormat = ModelFormat.GGUF
|
|
33
|
+
task_config: dict[str, object] = field(default_factory=dict)
|
|
34
|
+
version: str = "1"
|
|
35
|
+
created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, object]:
|
|
38
|
+
return {
|
|
39
|
+
"version": self.version,
|
|
40
|
+
"task": self.task.value,
|
|
41
|
+
"backend": self.backend.value,
|
|
42
|
+
"model": {
|
|
43
|
+
"source": self.source,
|
|
44
|
+
"quantization": self.quantization,
|
|
45
|
+
"format": self.model_format.value,
|
|
46
|
+
},
|
|
47
|
+
"task_config": self.task_config,
|
|
48
|
+
"created_at": self.created_at,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def to_json(self) -> str:
|
|
52
|
+
return json.dumps(self.to_dict(), indent=2)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_dict(cls, data: dict[str, object]) -> Manifest:
|
|
56
|
+
model_data: dict[str, object] = data.get("model", {}) # type: ignore[assignment]
|
|
57
|
+
return cls(
|
|
58
|
+
version=str(data.get("version", "1")),
|
|
59
|
+
task=TaskType(data["task"]), # type: ignore[arg-type]
|
|
60
|
+
backend=BackendType(data["backend"]), # type: ignore[arg-type]
|
|
61
|
+
source=str(model_data["source"]),
|
|
62
|
+
quantization=str(model_data.get("quantization", "unknown")),
|
|
63
|
+
model_format=ModelFormat(model_data.get("format", "gguf")), # type: ignore[arg-type]
|
|
64
|
+
task_config=data.get("task_config", {}), # type: ignore[arg-type]
|
|
65
|
+
created_at=str(data.get("created_at", "")),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_json(cls, text: str) -> Manifest:
|
|
70
|
+
return cls.from_dict(json.loads(text))
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_path(cls, path: Path) -> Manifest:
|
|
74
|
+
return cls.from_json(path.read_text())
|
slmkit/formats/schema.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Input/output schema validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from slmkit.formats.manifest import Manifest, TaskType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SchemaError(ValueError):
|
|
9
|
+
"""Raised when a manifest or task config fails validation."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_manifest(manifest: Manifest) -> None:
|
|
13
|
+
"""Validate that a manifest has all required fields for its task type."""
|
|
14
|
+
if manifest.task == TaskType.CLASSIFICATION:
|
|
15
|
+
_validate_classification(manifest)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _validate_classification(manifest: Manifest) -> None:
|
|
19
|
+
labels = manifest.task_config.get("labels")
|
|
20
|
+
if not labels:
|
|
21
|
+
raise SchemaError("Classification task requires 'labels' in task_config")
|
|
22
|
+
if not isinstance(labels, list) or len(labels) < 2:
|
|
23
|
+
raise SchemaError("Classification task requires at least 2 labels")
|
|
24
|
+
if len(labels) != len(set(labels)):
|
|
25
|
+
raise SchemaError("Classification labels must be unique")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_input(text: str) -> None:
|
|
29
|
+
"""Validate model input."""
|
|
30
|
+
if not text or not text.strip():
|
|
31
|
+
raise SchemaError("Input text must not be empty")
|
slmkit/model.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Model loading and inference wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from slmkit.backends import load_backend
|
|
12
|
+
from slmkit.formats.artifact import ArtifactContent, read_artifact
|
|
13
|
+
from slmkit.formats.schema import validate_input, validate_manifest
|
|
14
|
+
from slmkit.tasks import parse_output
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from slmkit.backends.llamacpp import LlamaCppBackend
|
|
18
|
+
from slmkit.backends.mlx import MlxBackend
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SlmModel:
|
|
22
|
+
"""A loaded .slm model — callable for single-shot inference."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
artifact: ArtifactContent,
|
|
27
|
+
*,
|
|
28
|
+
_backend: LlamaCppBackend | MlxBackend,
|
|
29
|
+
_temp_dir: Path | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._artifact = artifact
|
|
32
|
+
self._backend = _backend
|
|
33
|
+
self._temp_dir = _temp_dir
|
|
34
|
+
self._system_prompt = artifact.prompt_template
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_artifact(cls, path: str | Path) -> SlmModel:
|
|
38
|
+
"""Load a .slm artifact and return a ready-to-use model."""
|
|
39
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="slmkit_"))
|
|
40
|
+
artifact = read_artifact(Path(path), extract_dir=temp_dir)
|
|
41
|
+
validate_manifest(artifact.manifest)
|
|
42
|
+
backend = load_backend(artifact.manifest.backend, artifact.model_path)
|
|
43
|
+
return cls(artifact, _backend=backend, _temp_dir=temp_dir)
|
|
44
|
+
|
|
45
|
+
def __call__(self, text: str) -> dict[str, Any]:
|
|
46
|
+
"""Classify a single input text."""
|
|
47
|
+
validate_input(text)
|
|
48
|
+
raw = self._backend.chat(self._system_prompt, text)
|
|
49
|
+
result = parse_output(
|
|
50
|
+
self._artifact.manifest.task,
|
|
51
|
+
raw,
|
|
52
|
+
self._artifact.manifest.task_config,
|
|
53
|
+
)
|
|
54
|
+
return asdict(result)
|
|
55
|
+
|
|
56
|
+
def batch(self, texts: list[str]) -> list[dict[str, Any]]:
|
|
57
|
+
"""Run inference on a list of inputs."""
|
|
58
|
+
return [self(text) for text in texts]
|
|
59
|
+
|
|
60
|
+
def info(self) -> dict[str, Any]:
|
|
61
|
+
"""Return artifact metadata."""
|
|
62
|
+
m = self._artifact.manifest
|
|
63
|
+
return {
|
|
64
|
+
"task": m.task.value,
|
|
65
|
+
"backend": m.backend.value,
|
|
66
|
+
"source": m.source,
|
|
67
|
+
"quantization": m.quantization,
|
|
68
|
+
"task_config": m.task_config,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
def validation_cases(self) -> list[dict[str, object]] | None:
|
|
72
|
+
"""Return baked-in validation test cases, if any."""
|
|
73
|
+
return self._artifact.validation
|
|
74
|
+
|
|
75
|
+
def close(self) -> None:
|
|
76
|
+
"""Release backend resources and clean up extracted files."""
|
|
77
|
+
self._backend.close()
|
|
78
|
+
if self._temp_dir and self._temp_dir.exists():
|
|
79
|
+
shutil.rmtree(self._temp_dir, ignore_errors=True)
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> SlmModel:
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(self, *_: object) -> None:
|
|
85
|
+
self.close()
|
|
86
|
+
|
|
87
|
+
def __del__(self) -> None:
|
|
88
|
+
self.close()
|
slmkit/tasks/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Task type registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from slmkit.formats.manifest import TaskType
|
|
6
|
+
from slmkit.tasks import classification
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_system_prompt(task: TaskType, task_config: dict[str, object]) -> str:
|
|
10
|
+
"""Build the system prompt for a given task type and config."""
|
|
11
|
+
if task == TaskType.CLASSIFICATION:
|
|
12
|
+
labels: list[str] = task_config.get("labels", []) # type: ignore[assignment]
|
|
13
|
+
return classification.build_system_prompt(labels)
|
|
14
|
+
msg = f"Unsupported task type: {task}"
|
|
15
|
+
raise ValueError(msg)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_output(
|
|
19
|
+
task: TaskType, raw: str, task_config: dict[str, object]
|
|
20
|
+
) -> classification.ClassificationResult:
|
|
21
|
+
"""Parse raw model output for a given task type."""
|
|
22
|
+
if task == TaskType.CLASSIFICATION:
|
|
23
|
+
labels: list[str] = task_config.get("labels", []) # type: ignore[assignment]
|
|
24
|
+
return classification.parse_output(raw, labels)
|
|
25
|
+
msg = f"Unsupported task type: {task}"
|
|
26
|
+
raise ValueError(msg)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Classification task — prompt template + output parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
_SYSTEM_PROMPT_TEMPLATE = (
|
|
8
|
+
"You are a text classifier. Classify the input text into exactly one of "
|
|
9
|
+
"these categories: {labels}.\n\n"
|
|
10
|
+
"Respond with ONLY the category label, nothing else."
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True, frozen=True)
|
|
15
|
+
class ClassificationResult:
|
|
16
|
+
label: str
|
|
17
|
+
confidence: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_system_prompt(labels: list[str]) -> str:
|
|
21
|
+
"""Build the system prompt for classification with the given labels."""
|
|
22
|
+
return _SYSTEM_PROMPT_TEMPLATE.format(labels=", ".join(labels))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_output(raw: str, labels: list[str]) -> ClassificationResult:
|
|
26
|
+
"""Parse raw model output into a classification result."""
|
|
27
|
+
cleaned = raw.strip()
|
|
28
|
+
cleaned_lower = cleaned.lower()
|
|
29
|
+
|
|
30
|
+
# Exact match (case-insensitive)
|
|
31
|
+
for label in labels:
|
|
32
|
+
if cleaned_lower == label.lower():
|
|
33
|
+
return ClassificationResult(label=label, confidence=1.0)
|
|
34
|
+
|
|
35
|
+
# Substring match — model may have added extra text
|
|
36
|
+
for label in labels:
|
|
37
|
+
if label.lower() in cleaned_lower:
|
|
38
|
+
return ClassificationResult(label=label, confidence=0.8)
|
|
39
|
+
|
|
40
|
+
# No match — return raw output with zero confidence
|
|
41
|
+
return ClassificationResult(label=cleaned, confidence=0.0)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Vector embedding task (future)."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Structured extraction task (future)."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Free-form text generation task (future)."""
|