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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ slm = slmkit.cli:app
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
@@ -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())
@@ -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()
@@ -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)."""