avrik 1.0.0__tar.gz

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.
avrik-1.0.0/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
avrik-1.0.0/NOTICE ADDED
@@ -0,0 +1,10 @@
1
+ avrik
2
+ Copyright 2026 Avrik AI (https://www.avrik.ai)
3
+
4
+ Licensed under the Apache License, Version 2.0 (see LICENSE). Distributed on an "AS IS"
5
+ BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND.
6
+
7
+ Avrik models are built on Gemma 4 by Google, licensed under the Apache License,
8
+ Version 2.0. Acceptable use policy: https://huggingface.co/avrik-ai/Avrik-Pro-12B/blob/main/USE_POLICY.md
9
+
10
+ Contact: avriktech.ai@gmail.com
avrik-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: avrik
3
+ Version: 1.0.0
4
+ Summary: Avrik: run the Avrik decision models locally (pull, ask, serve).
5
+ Author: Avrik AI
6
+ Author-email: Avrik AI <avriktech.ai@gmail.com>
7
+ License-Expression: Apache-2.0
8
+ License-File: LICENSE
9
+ License-File: NOTICE
10
+ Requires-Dist: avrik-server[api,readout]>=1.0.0,<2
11
+ Requires-Dist: huggingface-hub>=0.30
12
+ Requires-Dist: avrik-server[cpu]>=1.0.0,<2 ; extra == 'cpu'
13
+ Maintainer: Avrik AI
14
+ Maintainer-email: Avrik AI <avriktech.ai@gmail.com>
15
+ Requires-Python: >=3.12
16
+ Project-URL: Homepage, https://www.avrik.ai
17
+ Project-URL: Models, https://huggingface.co/avrik-ai
18
+ Provides-Extra: cpu
19
+ Description-Content-Type: text/markdown
20
+
21
+ # avrik
22
+
23
+ Run [Avrik](https://huggingface.co/avrik-ai) decision models on your own machine:
24
+ `avrik pull`, `avrik ask`, `avrik serve`, `avrik info`.
25
+
26
+ An Avrik model reads a state and typed questions (yes/no, choice, score, multi-label) and
27
+ returns a calibrated probability distribution over each question's options, in one
28
+ forward pass with no generated text. Every answer carries the hashes that identify the
29
+ exact model, calibration and prompt behind it.
30
+
31
+ ## Install
32
+
33
+ On a CPU:
34
+
35
+ ```bash
36
+ pip install llama-cpp-python==0.3.35 --no-deps --only-binary=:all: \
37
+ --index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
38
+ pip install "avrik[cpu]"
39
+ ```
40
+
41
+ The first line installs llama.cpp's prebuilt CPU wheel from its own index; without it, pip
42
+ compiles llama.cpp, which needs a C++ compiler. On a GPU, `pip install avrik` and run vLLM
43
+ on loopback (each model card gives the command), then pass `--vllm-url`.
44
+
45
+ ## Use
46
+
47
+ ```bash
48
+ avrik pull Avrik-Lite-4B # downloads avrik-ai/Avrik-Lite-4B-GGUF and verifies it
49
+ avrik ask Avrik-Lite-4B --state "Customer bought headphones 12 days ago, unopened, \
50
+ receipt attached. Policy: unopened items within 30 days get a full refund." \
51
+ --noul "Should the refund be approved?" \
52
+ --choice "Which action?=approve,deny,escalate" [--json]
53
+ avrik serve Avrik-Lite-4B --port 8080 # /v1/systemone on 127.0.0.1 only
54
+ avrik info Avrik-Lite-4B # provenance: hashes, recipe, calibration
55
+ ```
56
+
57
+ | Model | Default build | Hardware |
58
+ | --- | --- | --- |
59
+ | `Avrik-Lite-4B` | GGUF Q4_K_M (`--format bf16` for GPU) | CPU |
60
+ | `Avrik-Pro-12B` | GGUF Q8_0 (`--format bf16` for GPU) | CPU or 16 GB card; bf16 on one GPU |
61
+ | `Avrik-Max-31B` | FP8 via vLLM (weights downloaded from Google's repo, verified by hash) | one 80 GB GPU |
62
+
63
+ Names match in any case. `--dir` uses a local release directory instead of the cache
64
+ (`$AVRIK_HOME`, default `~/.cache/avrik`).
65
+
66
+ ## Integrity and privacy
67
+
68
+ - `pull` downloads a release at a commit pinned inside this package and refuses it unless
69
+ its `MANIFEST.sha256` has the pinned digest; then every file is checked.
70
+ - `ask`, `serve` and `info` re-check every file before anything loads. Code files,
71
+ `auto_map`, symlinks and unlisted files are refused (exit 3).
72
+ - Only `pull` uses the network. Inference sends no telemetry and stores nothing.
73
+
74
+ Call the API from Python with [`avrik-sdk`](https://pypi.org/project/avrik-sdk/).
75
+
76
+ Built on Gemma 4 by Google (Apache-2.0). Licence: Apache-2.0. Security reports:
77
+ avriktech.ai@gmail.com.
78
+
79
+ Website: [www.avrik.ai](https://www.avrik.ai) · Models: [huggingface.co/avrik-ai](https://huggingface.co/avrik-ai) ·
80
+ [Acceptable use policy](https://huggingface.co/avrik-ai/Avrik-Pro-12B/blob/main/USE_POLICY.md)
avrik-1.0.0/PYPI.md ADDED
@@ -0,0 +1,60 @@
1
+ # avrik
2
+
3
+ Run [Avrik](https://huggingface.co/avrik-ai) decision models on your own machine:
4
+ `avrik pull`, `avrik ask`, `avrik serve`, `avrik info`.
5
+
6
+ An Avrik model reads a state and typed questions (yes/no, choice, score, multi-label) and
7
+ returns a calibrated probability distribution over each question's options, in one
8
+ forward pass with no generated text. Every answer carries the hashes that identify the
9
+ exact model, calibration and prompt behind it.
10
+
11
+ ## Install
12
+
13
+ On a CPU:
14
+
15
+ ```bash
16
+ pip install llama-cpp-python==0.3.35 --no-deps --only-binary=:all: \
17
+ --index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
18
+ pip install "avrik[cpu]"
19
+ ```
20
+
21
+ The first line installs llama.cpp's prebuilt CPU wheel from its own index; without it, pip
22
+ compiles llama.cpp, which needs a C++ compiler. On a GPU, `pip install avrik` and run vLLM
23
+ on loopback (each model card gives the command), then pass `--vllm-url`.
24
+
25
+ ## Use
26
+
27
+ ```bash
28
+ avrik pull Avrik-Lite-4B # downloads avrik-ai/Avrik-Lite-4B-GGUF and verifies it
29
+ avrik ask Avrik-Lite-4B --state "Customer bought headphones 12 days ago, unopened, \
30
+ receipt attached. Policy: unopened items within 30 days get a full refund." \
31
+ --noul "Should the refund be approved?" \
32
+ --choice "Which action?=approve,deny,escalate" [--json]
33
+ avrik serve Avrik-Lite-4B --port 8080 # /v1/systemone on 127.0.0.1 only
34
+ avrik info Avrik-Lite-4B # provenance: hashes, recipe, calibration
35
+ ```
36
+
37
+ | Model | Default build | Hardware |
38
+ | --- | --- | --- |
39
+ | `Avrik-Lite-4B` | GGUF Q4_K_M (`--format bf16` for GPU) | CPU |
40
+ | `Avrik-Pro-12B` | GGUF Q8_0 (`--format bf16` for GPU) | CPU or 16 GB card; bf16 on one GPU |
41
+ | `Avrik-Max-31B` | FP8 via vLLM (weights downloaded from Google's repo, verified by hash) | one 80 GB GPU |
42
+
43
+ Names match in any case. `--dir` uses a local release directory instead of the cache
44
+ (`$AVRIK_HOME`, default `~/.cache/avrik`).
45
+
46
+ ## Integrity and privacy
47
+
48
+ - `pull` downloads a release at a commit pinned inside this package and refuses it unless
49
+ its `MANIFEST.sha256` has the pinned digest; then every file is checked.
50
+ - `ask`, `serve` and `info` re-check every file before anything loads. Code files,
51
+ `auto_map`, symlinks and unlisted files are refused (exit 3).
52
+ - Only `pull` uses the network. Inference sends no telemetry and stores nothing.
53
+
54
+ Call the API from Python with [`avrik-sdk`](https://pypi.org/project/avrik-sdk/).
55
+
56
+ Built on Gemma 4 by Google (Apache-2.0). Licence: Apache-2.0. Security reports:
57
+ avriktech.ai@gmail.com.
58
+
59
+ Website: [www.avrik.ai](https://www.avrik.ai) · Models: [huggingface.co/avrik-ai](https://huggingface.co/avrik-ai) ·
60
+ [Acceptable use policy](https://huggingface.co/avrik-ai/Avrik-Pro-12B/blob/main/USE_POLICY.md)
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "avrik"
3
+ version = "1.0.0"
4
+ description = "Avrik: run the Avrik decision models locally (pull, ask, serve)."
5
+ readme = "PYPI.md"
6
+ requires-python = ">=3.12"
7
+ license = "Apache-2.0"
8
+ license-files = [
9
+ "LICENSE",
10
+ "NOTICE",
11
+ ]
12
+ dependencies = [
13
+ "avrik-server[api,readout]>=1.0.0,<2",
14
+ "huggingface_hub>=0.30",
15
+ ]
16
+
17
+ [[project.authors]]
18
+ name = "Avrik AI"
19
+ email = "avriktech.ai@gmail.com"
20
+
21
+ [[project.maintainers]]
22
+ name = "Avrik AI"
23
+ email = "avriktech.ai@gmail.com"
24
+
25
+ [project.urls]
26
+ Homepage = "https://www.avrik.ai"
27
+ Models = "https://huggingface.co/avrik-ai"
28
+
29
+ [project.optional-dependencies]
30
+ cpu = ["avrik-server[cpu]>=1.0.0,<2"]
31
+
32
+ [project.scripts]
33
+ avrik = "avrik_cli.__main__:main"
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.12.1,<0.13.0"]
37
+ build-backend = "uv_build"
38
+
39
+ [tool.uv.build-backend]
40
+ module-name = "avrik_cli"
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "avrik"
3
+ authors = [{ name = "Avrik AI", email = "avriktech.ai@gmail.com" }]
4
+ version = "1.0.0"
5
+ description = "Avrik: run the Avrik decision models locally (pull, ask, serve)."
6
+ readme = "PYPI.md"
7
+ requires-python = ">=3.12"
8
+ license = "Apache-2.0"
9
+ license-files = ["LICENSE", "NOTICE"]
10
+ maintainers = [{ name = "Avrik AI", email = "avriktech.ai@gmail.com" }]
11
+ # Published on PyPI with avrik-server (spec 004, research R14). Must not depend on
12
+ # avrik-eval, avrik-data or avrik-train.
13
+ dependencies = ["avrik-server[api,readout]>=1.0.0,<2", "huggingface_hub>=0.30"]
14
+
15
+ [project.urls]
16
+ Homepage = "https://www.avrik.ai"
17
+ Models = "https://huggingface.co/avrik-ai"
18
+
19
+ [project.optional-dependencies]
20
+ cpu = ["avrik-server[cpu]>=1.0.0,<2"]
21
+
22
+ [project.scripts]
23
+ avrik = "avrik_cli.__main__:main"
24
+
25
+ [build-system]
26
+ requires = ["uv_build>=0.12.1,<0.13.0"]
27
+ build-backend = "uv_build"
28
+
29
+ [tool.uv.build-backend]
30
+ module-name = "avrik_cli"
@@ -0,0 +1 @@
1
+ """avrik: run the avrik-s1 decision models locally (spec 004, contracts/avrik-cli.md)."""
@@ -0,0 +1,297 @@
1
+ """``avrik``: pull, ask, serve, info (spec 004 contracts/avrik-cli.md).
2
+
3
+ - ``avrik pull MODEL [--format gguf|bf16|fp8]``: downloads ``avrik-ai/MODEL`` (``-GGUF``
4
+ for the GGUF build) and verifies ``MANIFEST.sha256``. For a weights-free build (Max,
5
+ ``fp8`` by default) it then downloads the pinned upstream revision into ``upstream/``
6
+ and checks every file against the shipped lock. This is the only command that uses the
7
+ network.
8
+ - ``avrik ask MODEL --state … --noul Q --choice "Q=a,b,c" --score "Q=low,high"``:
9
+ answers in-process with the server's engine. It loads only verified files, offline.
10
+ - ``avrik serve MODEL``: the ``/v1/systemone`` app on loopback.
11
+ - ``avrik info MODEL``: recipe, hashes and calibration from ``provenance.json``.
12
+
13
+ A release directory holds:
14
+ - the weights (``*.gguf``, or bf16 safetensors), or for Max ``upstream.lock.json`` and,
15
+ once pulled, the verified upstream files in ``upstream/``;
16
+ - the tokenizer and chat template;
17
+ - ``.avrik-pin.json``;
18
+ - ``readout.json``;
19
+ - ``calibration/*.json``;
20
+ - ``provenance.json``;
21
+ - ``MANIFEST.sha256``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import asyncio
28
+ import json
29
+ import os
30
+ import sys
31
+ from collections.abc import Sequence
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ from avrik_cli.verify import UPSTREAM_DIR, ManifestError, verify_release
36
+
37
+ ORG = "avrik-ai"
38
+ FORMATS = ("gguf", "bf16", "fp8")
39
+ DEFAULT_FORMAT = {"Avrik-Max-31B": "fp8"} # weights-free: served by vLLM in FP8
40
+ MODELS = ("Avrik-Lite-4B", "Avrik-Pro-12B", "Avrik-Max-31B")
41
+
42
+
43
+ def canonical(model: str) -> str:
44
+ """A published model's name as the registry spells it, matched in any case."""
45
+ return next((m for m in MODELS if m.lower() == model.lower()), model)
46
+
47
+
48
+ def cache_dir() -> Path:
49
+ """Where pulled releases live (``$AVRIK_HOME`` or ``~/.cache/avrik``)."""
50
+ return Path(os.environ.get("AVRIK_HOME", Path.home() / ".cache" / "avrik"))
51
+
52
+
53
+ def release_dir(model: str, fmt: str) -> Path:
54
+ """A model's local release directory for a format."""
55
+ return cache_dir() / f"{model}-{fmt}"
56
+
57
+
58
+ def pull(
59
+ model: str,
60
+ fmt: str,
61
+ revision: str | None,
62
+ dest: Path | None = None,
63
+ manifest_sha256: str | None = None,
64
+ ) -> Path:
65
+ """Download a release at a trusted commit, verify it, and move it into place.
66
+
67
+ The commit and manifest digest come from :mod:`avrik_cli.releases`, or from
68
+ ``revision`` with ``manifest_sha256`` for a newer release. The download goes into a
69
+ fresh temporary directory, is verified there, and is renamed into ``dest``, which
70
+ must not exist yet or be empty (security review T066, S1 and S2).
71
+
72
+ Raises:
73
+ ManifestError: When the release is unknown, the digest differs, a check fails, or
74
+ ``dest`` is not empty.
75
+ """
76
+ import shutil
77
+ import tempfile
78
+
79
+ from huggingface_hub import snapshot_download
80
+
81
+ from avrik_cli.releases import RELEASES
82
+ from avrik_cli.verify import MANIFEST, read_manifest, sha256
83
+
84
+ if bool(revision) != bool(manifest_sha256):
85
+ raise ManifestError("--revision and --manifest-sha256 go together")
86
+ known = RELEASES.get((model, fmt))
87
+ repo = f"{ORG}/{model}" + ("-GGUF" if fmt == "gguf" else "")
88
+ if revision and manifest_sha256:
89
+ commit, digest = revision, manifest_sha256
90
+ elif known is not None:
91
+ repo, commit, digest = known.repo, known.commit, known.manifest_sha256
92
+ else:
93
+ raise ManifestError(
94
+ f"{model} ({fmt}) is not a release this avrik knows; pass --revision and "
95
+ "--manifest-sha256 from the model card"
96
+ )
97
+ dest = dest or release_dir(model, fmt)
98
+ if dest.exists() and not dest.is_dir():
99
+ raise ManifestError(f"{dest} is not a directory")
100
+ if dest.exists() and any(dest.iterdir()):
101
+ raise ManifestError(f"{dest} is not empty; remove it or pick another --dir")
102
+ dest.parent.mkdir(parents=True, exist_ok=True)
103
+ work = Path(tempfile.mkdtemp(prefix=".avrik-pull-", dir=dest.parent))
104
+ try:
105
+ token = os.environ.get("HF_TOKEN") or False # environment only, never a stored token
106
+ # The manifest first, checked against the trusted digest; then exactly the files it
107
+ # lists. The Hub adds files of its own (``.gitattributes``) that a release never lists.
108
+ snapshot_download(repo_id=repo, revision=commit, local_dir=work, token=token,
109
+ allow_patterns=[MANIFEST]) # fmt: skip
110
+ if sha256(work / MANIFEST) != digest:
111
+ raise ManifestError(f"{repo}@{commit}: MANIFEST.sha256 is not the published one")
112
+ snapshot_download(repo_id=repo, revision=commit, local_dir=work, token=token,
113
+ allow_patterns=sorted(read_manifest(work))) # fmt: skip
114
+ n = verify_release(work)
115
+ _check_provenance(work)
116
+ upstream = _upstream_block(work)
117
+ if upstream is not None:
118
+ if (work / UPSTREAM_DIR).exists():
119
+ raise ManifestError(f"{repo}@{commit} ships upstream/; only pull may write it")
120
+ snapshot_download(repo_id=upstream["repo"], revision=upstream["revision"],
121
+ local_dir=work / UPSTREAM_DIR, token=token) # fmt: skip
122
+ n = verify_release(work, upstream_required=True)
123
+ if dest.exists():
124
+ dest.rmdir()
125
+ work.rename(dest)
126
+ except BaseException:
127
+ shutil.rmtree(work, ignore_errors=True)
128
+ raise
129
+ print(f"pulled {repo}@{commit} -> {dest} ({n} files verified)")
130
+ return dest
131
+
132
+
133
+ def _upstream_block(root: Path) -> dict[str, str] | None:
134
+ """A verified weights-free release's upstream repo and revision, or None.
135
+
136
+ :func:`verify_release` has already checked the block against the lock and the pin
137
+ (an ``org/name`` repo at a full 40-hex commit).
138
+ """
139
+ from avrik_cli.verify import readout
140
+
141
+ block = readout(root).get("upstream")
142
+ if not isinstance(block, dict):
143
+ return None
144
+ return {"repo": str(block["repo"]), "revision": str(block["revision"])}
145
+
146
+
147
+ def _check_provenance(root: Path) -> None:
148
+ pin = json.loads((root / ".avrik-pin.json").read_text(encoding="utf-8"))
149
+ prov = json.loads((root / "provenance.json").read_text(encoding="utf-8"))
150
+ if prov.get("model_hash") != f"{pin.get('repo')}@{pin.get('revision')}":
151
+ raise ManifestError("provenance.json model_hash does not match .avrik-pin.json")
152
+
153
+
154
+ PRECISIONS = ("bf16", "q8_0", "q4_k_m", "fp8")
155
+ MAX_CONTEXT = 131072
156
+
157
+
158
+ def _config(root: Path, *, vllm_url: str | None, port: int) -> dict[str, Any]:
159
+ """The server config for a verified release, from the names in ``readout.json``.
160
+
161
+ Values from the repo are checked, never expanded (security review T066, B1 and S3).
162
+ """
163
+ from avrik_cli.verify import readout
164
+
165
+ info = readout(root)
166
+ precision = str(info.get("precision", "bf16"))
167
+ if precision not in PRECISIONS:
168
+ raise ManifestError(f"readout.json precision {precision!r} is not one of {PRECISIONS}")
169
+ context = info.get("max_model_len", 8192)
170
+ if not isinstance(context, int) or not 512 <= context <= MAX_CONTEXT:
171
+ raise ManifestError(f"readout.json max_model_len must be 512–{MAX_CONTEXT}")
172
+ weights = [str(w) for w in info["weights"]]
173
+ if vllm_url:
174
+ backend: dict[str, Any] = {"kind": "vllm-gpu", "url": vllm_url, "served_model": "avrik"}
175
+ else:
176
+ ggufs = [w for w in weights if w.endswith(".gguf")]
177
+ if len(ggufs) != 1:
178
+ raise ManifestError("readout.json names no single GGUF; use --vllm-url for bf16")
179
+ backend = {"kind": "llamacpp-cpu", "gguf": str(root / ggufs[0])}
180
+ backend |= {"precision": precision, "max_model_len": context}
181
+ name = str(info.get("model", ""))
182
+ if not name or any(c in name for c in "$/\\ \n"):
183
+ raise ManifestError("readout.json model name is missing or has unsafe characters")
184
+ return {
185
+ "model": {"name": name, "dir": str(root)},
186
+ "backend": backend,
187
+ "profile": "avrik",
188
+ "calibration": str(root / str(info["calibration"])),
189
+ "serving": {"host": "127.0.0.1", "port": port, "max_in_flight": 1, "max_queued": 16},
190
+ }
191
+
192
+
193
+ def _engine(root: Path, vllm_url: str | None, port: int = 8080) -> Any:
194
+ os.environ["HF_HUB_OFFLINE"] = "1"
195
+ os.environ["TRANSFORMERS_OFFLINE"] = "1"
196
+ from avrik_server.api.__main__ import build_backend
197
+ from avrik_server.calibration.file import load
198
+ from avrik_server.config import parse_config
199
+ from avrik_server.engine.decide import Engine
200
+ from avrik_server.readout.smoke import _hf_tokenizer
201
+
202
+ config = parse_config(_config(root, vllm_url=vllm_url, port=port))
203
+ return Engine(config, _hf_tokenizer(root), build_backend(config), load(config.calibration))
204
+
205
+
206
+ def _questions(args: argparse.Namespace) -> dict[str, Any]:
207
+ qs: dict[str, Any] = {}
208
+ for text in args.noul or []:
209
+ qs[f"q{len(qs) + 1}"] = {"type": "noul", "instructions": text}
210
+ for spec in args.choice or []:
211
+ question, _, opts = spec.rpartition("=")
212
+ criteria = {o.strip(): o.strip() for o in opts.split(",")}
213
+ qs[f"q{len(qs) + 1}"] = {"type": "choice", "instructions": question, "criteria": criteria}
214
+ for spec in args.score or []:
215
+ question, _, levels = spec.rpartition("=")
216
+ qs[f"q{len(qs) + 1}"] = {"type": "score", "instructions": question,
217
+ "criteria": [lv.strip() for lv in levels.split(",")]} # fmt: skip
218
+ if not qs:
219
+ raise SystemExit("ask needs at least one --noul, --choice or --score")
220
+ return qs
221
+
222
+
223
+ def _print_answers(questions: dict[str, Any], out: dict[str, Any]) -> None:
224
+ for name, q in questions.items():
225
+ a = out["answers"][name]
226
+ if a["type"] == "noul":
227
+ line = f"P(yes) = {a['noul']:.3f}"
228
+ elif a["type"] == "choice":
229
+ probs = ", ".join(f"{k} {v:.3f}" for k, v in a["probabilities"].items())
230
+ line = f"{a['choice']} ({probs})"
231
+ else:
232
+ line = f"score {a['score']:.2f} of 0..{len(a['legend']) - 1}"
233
+ print(f"{q['instructions']}\n -> {line}{' [abstain]' if a.get('abstain') else ''}")
234
+
235
+
236
+ def main(argv: Sequence[str] | None = None) -> int:
237
+ """Entry point: 0 ok, 3 verification failure."""
238
+ parser = argparse.ArgumentParser(prog="avrik")
239
+ sub = parser.add_subparsers(dest="cmd", required=True)
240
+ for name in ("pull", "ask", "serve", "info"):
241
+ p = sub.add_parser(name)
242
+ p.add_argument("model")
243
+ p.add_argument("--format", choices=FORMATS,
244
+ help="build (default: fp8 for Avrik-Max-31B, else gguf)") # fmt: skip
245
+ p.add_argument("--dir", type=Path, help="a release directory (default: the pull cache)")
246
+ if name == "pull":
247
+ p.add_argument("--revision", help="a commit, with --manifest-sha256")
248
+ p.add_argument("--manifest-sha256", help="the card's MANIFEST.sha256 digest")
249
+ if name in ("ask", "serve"):
250
+ p.add_argument("--vllm-url", help="a vLLM server on loopback (bf16 and fp8 builds)")
251
+ if name == "ask":
252
+ p.add_argument("--state", required=True, help="text, or @file")
253
+ p.add_argument("--noul", action="append")
254
+ p.add_argument("--choice", action="append", help='"question=a,b,c"')
255
+ p.add_argument("--score", action="append", help='"question=low,mid,high"')
256
+ p.add_argument("--json", action="store_true")
257
+ if name == "serve":
258
+ p.add_argument("--port", type=int, default=8080)
259
+ args = parser.parse_args(argv)
260
+ args.model = canonical(args.model)
261
+ args.format = args.format or DEFAULT_FORMAT.get(args.model, "gguf")
262
+ try:
263
+ if args.cmd == "pull":
264
+ pull(args.model, args.format, args.revision, args.dir, args.manifest_sha256)
265
+ return 0
266
+ root = args.dir or release_dir(args.model, args.format)
267
+ # ask and serve need a weights-free build's upstream/ present and verified (M1).
268
+ verify_release(root, upstream_required=args.cmd in ("ask", "serve"))
269
+ if args.cmd == "info":
270
+ print(json.dumps(json.loads((root / "provenance.json").read_text()), indent=1))
271
+ return 0
272
+ if args.cmd == "ask":
273
+ state = args.state
274
+ if state.startswith("@"):
275
+ state = Path(state[1:]).read_text(encoding="utf-8")
276
+ questions = _questions(args)
277
+ engine = _engine(root, args.vllm_url)
278
+ out = asyncio.run(engine.decide({"state": state, "questions": questions}))
279
+ if args.json:
280
+ print(json.dumps(out, indent=1))
281
+ else:
282
+ _print_answers(questions, out)
283
+ return 0
284
+ import uvicorn
285
+
286
+ from avrik_server.api.app import create_app
287
+
288
+ engine = _engine(root, args.vllm_url, args.port)
289
+ uvicorn.run(create_app(engine), host="127.0.0.1", port=args.port, access_log=False)
290
+ return 0
291
+ except ManifestError as error:
292
+ print(f"refused: {error}", file=sys.stderr)
293
+ return 3
294
+
295
+
296
+ if __name__ == "__main__":
297
+ sys.exit(main())
File without changes
@@ -0,0 +1,310 @@
1
+ """Release bundles for Hugging Face (spec 004 contracts/release-bundle.md; FR-017).
2
+
3
+ ``stage`` assembles one build's release directory:
4
+
5
+ | Contents | Source |
6
+ | --- | --- |
7
+ | Weights | GGUF or bf16 safetensors |
8
+ | Tokenizer and chat template | Copied from the base |
9
+ | ``.avrik-pin.json`` | The build's identity (below) |
10
+ | ``readout.json`` | Written here |
11
+ | ``calibration/`` | The fitted calibration file |
12
+ | ``provenance.json`` | Written here |
13
+ | ``README.md`` | The card |
14
+ | ``LICENSE``, ``NOTICE``, ``USE_POLICY.md`` | ``--extra``: licence and terms files |
15
+ | ``results/`` | Copied in |
16
+ | ``MANIFEST.sha256`` | Every file above, hashed |
17
+
18
+ **Build identity.** The pin is ``avrik-ai/<repo>@<first 16 hex of the weights' sha256>``.
19
+ The calibration must be fitted against this exact ``model_hash``, and the server refuses
20
+ a mismatch.
21
+
22
+ **Weights-free builds** (``Avrik-Max-31B``; research R17). ``--upstream-lock`` replaces
23
+ ``--weights``: the bundle ships the lock as ``upstream.lock.json`` and no weights, and the
24
+ pin is the upstream ``repo@revision`` the lock names. ``avrik pull`` fetches that revision
25
+ and checks it against the lock.
26
+
27
+ ``render_card`` fills ``docs/model-cards/TEMPLATE.md`` from a JSON of values and refuses
28
+ any placeholder left unfilled.
29
+
30
+ ``upload`` creates the repo **private** and uploads the folder. ``--public`` is a
31
+ separate, approved step (Day 7).
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import hashlib
38
+ import json
39
+ import os
40
+ import re
41
+ import shutil
42
+ import sys
43
+ from collections.abc import Sequence
44
+ from datetime import UTC, datetime
45
+ from pathlib import Path
46
+ from typing import Any
47
+
48
+ from avrik_cli.verify import MANIFEST, UPSTREAM_LOCK, ManifestError, sha256
49
+
50
+ ORG = "avrik-ai"
51
+ TOKENIZER_FILES = ("tokenizer.json", "tokenizer_config.json", "chat_template.jinja",
52
+ "special_tokens_map.json", "generation_config.json", "config.json",
53
+ "processor_config.json", "preprocessor_config.json") # fmt: skip
54
+
55
+
56
+ def repo_name(model: str, fmt: str) -> str:
57
+ """HF repo for a build: the bf16 repo has no suffix, the GGUF repo ``-GGUF``."""
58
+ return f"{model}-GGUF" if fmt.startswith("gguf") else model
59
+
60
+
61
+ def weights_digest(files: Sequence[Path]) -> str:
62
+ """One sha256 over the weight files' own sha256s, in name order."""
63
+ h = hashlib.sha256()
64
+ for path in sorted(files, key=lambda p: p.name):
65
+ h.update(f"{path.name}:{sha256(path)}\n".encode())
66
+ return h.hexdigest()
67
+
68
+
69
+ def pin(model: str, fmt: str, weights: Sequence[Path]) -> dict[str, str]:
70
+ """The build identity written as ``.avrik-pin.json``."""
71
+ return {"repo": f"{ORG}/{repo_name(model, fmt)}", "revision": weights_digest(weights)[:16]}
72
+
73
+
74
+ def read_lock(path: Path) -> dict[str, Any]:
75
+ """An upstream lock (``infra/weights/model_lock.py``): repo, revision, files.
76
+
77
+ Raises:
78
+ ValueError: When a field is missing or no file is listed.
79
+ """
80
+ lock: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
81
+ if not lock.get("repo") or not lock.get("revision") or not lock.get("files"):
82
+ raise ValueError(f"{path} is not a lock with repo, revision and files")
83
+ return lock
84
+
85
+
86
+ def upstream_pin(lock: dict[str, Any]) -> dict[str, str]:
87
+ """A weights-free build's identity: the upstream repo at its full revision."""
88
+ return {"repo": str(lock["repo"]), "revision": str(lock["revision"])}
89
+
90
+
91
+ def stage(
92
+ *,
93
+ model: str,
94
+ fmt: str,
95
+ weights: Sequence[Path],
96
+ base_dir: Path,
97
+ calibration: Path | None,
98
+ out: Path,
99
+ provenance: dict[str, Any],
100
+ card: Path | None = None,
101
+ results: Sequence[Path] = (),
102
+ readout: dict[str, Any] | None = None,
103
+ upstream_lock: Path | None = None,
104
+ extra: Sequence[Path] = (),
105
+ ) -> Path:
106
+ """Assemble a release directory and its manifest; return ``out``.
107
+
108
+ ``out`` must not exist or must be empty, so nothing left over gets listed and shipped
109
+ (security review T066, S6). Weights are hardlinked when ``out`` is on the same
110
+ filesystem, and copied otherwise. ``readout.json`` names the weights and the
111
+ calibration; ``avrik`` loads exactly those. ``extra`` files (licence and terms) are
112
+ copied to the root by name. With ``upstream_lock`` the bundle has no
113
+ weights: it ships the lock and names it in ``readout.json``'s ``upstream`` block.
114
+
115
+ Raises:
116
+ ValueError: When ``out`` is not empty, no calibration is given, or the bundle has
117
+ both or neither of weights and an upstream lock.
118
+ """
119
+ if out.exists() and any(out.iterdir()):
120
+ raise ValueError(f"{out} is not empty; stage into a fresh directory")
121
+ if calibration is None:
122
+ raise ValueError("a release needs its own calibration file")
123
+ if bool(weights) == (upstream_lock is not None):
124
+ raise ValueError("a release ships weights or an upstream lock, not both or neither")
125
+ lock = read_lock(upstream_lock) if upstream_lock is not None else None
126
+ out.mkdir(parents=True, exist_ok=True)
127
+ for path in weights:
128
+ try:
129
+ os.link(path, out / path.name)
130
+ except OSError:
131
+ shutil.copy2(path, out / path.name)
132
+ for name in TOKENIZER_FILES:
133
+ if (base_dir / name).exists():
134
+ shutil.copy2(base_dir / name, out / name)
135
+ identity = upstream_pin(lock) if lock is not None else pin(model, fmt, weights)
136
+ upstream: dict[str, Any] = {}
137
+ if lock is not None and upstream_lock is not None:
138
+ shutil.copy2(upstream_lock, out / UPSTREAM_LOCK)
139
+ upstream = {"upstream": {**identity, "lock": UPSTREAM_LOCK}}
140
+ adapter = provenance.get("adapter_hash")
141
+ record = {**identity, **({"adapter": adapter} if adapter and adapter != "none" else {})}
142
+ (out / ".avrik-pin.json").write_text(json.dumps(record, indent=1) + "\n")
143
+ (out / "calibration").mkdir(exist_ok=True)
144
+ shutil.copy2(calibration, out / "calibration" / calibration.name)
145
+ (out / "readout.json").write_text(json.dumps({
146
+ "model": model, "format": fmt, "profile": "avrik", "mass_bound": 0.98,
147
+ "weights": [p.name for p in weights], "calibration": f"calibration/{calibration.name}",
148
+ "allowed_set": "every tokenizer form of each option code; codes past 26 use two "
149
+ "letters, skipping codes the tokenizer splits; abstain last",
150
+ **(readout or {}), **upstream, # the upstream block cannot be overridden
151
+ }, indent=1) + "\n") # fmt: skip
152
+ (out / "provenance.json").write_text(json.dumps({
153
+ "model": model, "format": fmt, "model_hash": f"{identity['repo']}@{identity['revision']}",
154
+ "created_at": datetime.now(UTC).isoformat(timespec="seconds"), **provenance,
155
+ }, indent=1) + "\n") # fmt: skip
156
+ if card is not None:
157
+ shutil.copy2(card, out / "README.md")
158
+ for path in extra:
159
+ shutil.copy2(path, out / path.name)
160
+ if results:
161
+ (out / "results").mkdir(exist_ok=True)
162
+ for path in results:
163
+ shutil.copy2(path, out / "results" / path.name)
164
+ files = sorted(p for p in out.rglob("*") if p.is_file() and p.name != MANIFEST
165
+ and ".cache" not in p.parts) # fmt: skip
166
+ (out / MANIFEST).write_text("".join(f"{sha256(p)} {p.relative_to(out).as_posix()}\n"
167
+ for p in files)) # fmt: skip
168
+ return out
169
+
170
+
171
+ PLACEHOLDER = re.compile(r"\{\{([a-z0-9_]+)\}\}")
172
+
173
+
174
+ class CardError(ValueError):
175
+ """A card template has placeholders with no value."""
176
+
177
+
178
+ def render_card(template: Path, values: dict[str, str], out: Path) -> Path:
179
+ """Fill ``{{key}}`` placeholders in ``template``; refuse any left unfilled.
180
+
181
+ Raises:
182
+ CardError: Naming the missing keys.
183
+ """
184
+ text = template.read_text(encoding="utf-8")
185
+ missing = sorted({k for k in PLACEHOLDER.findall(text) if k not in values})
186
+ if missing:
187
+ raise CardError(f"card values missing: {', '.join(missing)}")
188
+ rendered = PLACEHOLDER.sub(lambda m: str(values[m.group(1)]), text)
189
+ out.write_text(rendered, encoding="utf-8")
190
+ return out
191
+
192
+
193
+ def upload(folder: Path, repo: str, *, public: bool = False) -> str:
194
+ """Verify ``folder``, then upload its manifest's files to ``avrik-ai/<repo>``.
195
+
196
+ The repo's visibility must match the request: an existing public repo is refused
197
+ unless ``public``, and ``public`` on an existing private repo is refused too. Switching
198
+ visibility is a separate, approved step (security review T066, S5). The token comes
199
+ from ``HF_TOKEN`` only.
200
+
201
+ Raises:
202
+ ValueError: On a visibility mismatch or a failed verification.
203
+ """
204
+ from huggingface_hub import HfApi
205
+
206
+ from avrik_cli.verify import read_manifest, verify_release
207
+
208
+ verify_release(folder)
209
+ api = HfApi(token=os.environ.get("HF_TOKEN") or False)
210
+ repo_id = f"{ORG}/{repo}"
211
+ api.create_repo(repo_id, repo_type="model", private=not public, exist_ok=True)
212
+ private = bool(api.model_info(repo_id).private)
213
+ if private == public:
214
+ state = "private" if private else "public"
215
+ raise ValueError(f"{repo_id} is {state}; upload refused (asked public={public})")
216
+ files = [*read_manifest(folder), MANIFEST]
217
+ api.upload_folder(folder_path=str(folder), repo_id=repo_id, repo_type="model",
218
+ allow_patterns=files) # fmt: skip
219
+ return repo_id
220
+
221
+
222
+ def main(argv: Sequence[str] | None = None) -> int:
223
+ """``python -m avrik_cli.release pin|stage|card|upload ...``; 3 on a refusal."""
224
+ parser = argparse.ArgumentParser(prog="python -m avrik_cli.release")
225
+ sub = parser.add_subparsers(dest="cmd", required=True)
226
+ up = sub.add_parser("upload")
227
+ up.add_argument("--dir", type=Path, required=True)
228
+ up.add_argument("--repo", required=True)
229
+ up.add_argument("--public", action="store_true")
230
+ pn = sub.add_parser("pin", help="print a build's identity (fit its calibration to this)")
231
+ pn.add_argument("--model", required=True)
232
+ pn.add_argument("--format", required=True)
233
+ pn.add_argument("--weights", type=Path, nargs="+", required=True)
234
+ pn.add_argument("--base-dir", type=Path, help="also print the template sha256")
235
+ st = sub.add_parser("stage")
236
+ st.add_argument("--model", required=True)
237
+ st.add_argument("--format", required=True)
238
+ st.add_argument("--weights", type=Path, nargs="*", default=[])
239
+ st.add_argument("--upstream-lock", type=Path, help="a weights-free build's upstream lock")
240
+ st.add_argument("--base-dir", type=Path, required=True)
241
+ st.add_argument("--calibration", type=Path, required=True)
242
+ st.add_argument("--provenance", type=Path, required=True, help="JSON merged into provenance")
243
+ st.add_argument("--readout", type=Path, help="JSON merged into readout.json")
244
+ st.add_argument("--card", type=Path)
245
+ st.add_argument("--results", type=Path, nargs="*", default=[])
246
+ st.add_argument("--extra", type=Path, nargs="*", default=[], help="licence and terms files")
247
+ st.add_argument("--out", type=Path, required=True)
248
+ card = sub.add_parser("card")
249
+ card.add_argument("--template", type=Path, default=Path("docs/model-cards/TEMPLATE.md"))
250
+ card.add_argument("--values", type=Path, required=True, help="JSON {key: text}")
251
+ card.add_argument("--out", type=Path, required=True)
252
+ args = parser.parse_args(argv)
253
+ if args.cmd == "pin":
254
+ identity = pin(args.model, args.format, args.weights)
255
+ record: dict[str, str] = {
256
+ **identity,
257
+ "model_hash": f"{identity['repo']}@{identity['revision']}",
258
+ }
259
+ if args.base_dir:
260
+ from avrik_server.readout.prompt import template_sha256
261
+ from avrik_server.readout.smoke import _hf_tokenizer
262
+
263
+ record["template_sha256"] = template_sha256(
264
+ _hf_tokenizer(args.base_dir), "avrik", identity["revision"]
265
+ )
266
+ print(json.dumps(record, indent=1))
267
+ return 0
268
+ if args.cmd == "stage":
269
+ cal = json.loads(args.calibration.read_text(encoding="utf-8"))
270
+ try:
271
+ identity = (upstream_pin(read_lock(args.upstream_lock)) if args.upstream_lock
272
+ else pin(args.model, args.format, args.weights)) # fmt: skip
273
+ except ValueError as error:
274
+ print(f"refused: {error}", file=sys.stderr)
275
+ return 3
276
+ build = f"{identity['repo']}@{identity['revision']}"
277
+ if cal.get("model_hash") != build:
278
+ bound = cal.get("model_hash")
279
+ print(f"refused: {args.calibration} is bound to {bound}, not {build}", file=sys.stderr)
280
+ return 3
281
+ readout = json.loads(args.readout.read_text()) if args.readout else None
282
+ try:
283
+ out = stage(model=args.model, fmt=args.format, weights=args.weights,
284
+ base_dir=args.base_dir, calibration=args.calibration, out=args.out,
285
+ provenance=json.loads(args.provenance.read_text()), card=args.card,
286
+ results=args.results, readout=readout,
287
+ upstream_lock=args.upstream_lock, extra=args.extra) # fmt: skip
288
+ except ValueError as error:
289
+ print(f"refused: {error}", file=sys.stderr)
290
+ return 3
291
+ print(out)
292
+ return 0
293
+ if args.cmd == "card":
294
+ values = json.loads(args.values.read_text(encoding="utf-8"))
295
+ try:
296
+ print(render_card(args.template, values, args.out))
297
+ except CardError as error:
298
+ print(f"refused: {error}", file=sys.stderr)
299
+ return 3
300
+ return 0
301
+ try:
302
+ print(upload(args.dir, args.repo, public=args.public))
303
+ except (ValueError, ManifestError) as error:
304
+ print(f"refused: {error}", file=sys.stderr)
305
+ return 3
306
+ return 0
307
+
308
+
309
+ if __name__ == "__main__":
310
+ sys.exit(main())
@@ -0,0 +1,50 @@
1
+ """Published releases this ``avrik`` version trusts (security review T066, S1).
2
+
3
+ ``avrik pull`` downloads a release at the commit listed here and refuses it unless its
4
+ ``MANIFEST.sha256`` has the listed sha256. A downloaded manifest can't vouch for itself,
5
+ so the trusted digest ships inside the package. Day 7 (T061/T062) fills this in when the
6
+ repos are uploaded. ``pull --revision`` with ``--manifest-sha256`` covers releases newer
7
+ than this package.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import NamedTuple
13
+
14
+
15
+ class Release(NamedTuple):
16
+ """One published Hugging Face repo at one commit."""
17
+
18
+ repo: str
19
+ commit: str
20
+ manifest_sha256: str
21
+
22
+
23
+ # key: (model, format), e.g. ("Avrik-Lite-4B", "gguf")
24
+ RELEASES: dict[tuple[str, str], Release] = {
25
+ ("Avrik-Lite-4B", "gguf"): Release(
26
+ "avrik-ai/Avrik-Lite-4B-GGUF",
27
+ "b52cdce83c998c49a66dd44b9c1cd05f7fc18d59",
28
+ "d008a6ccee3a4c1d1bd64369cec10f53179d35203e4c917b423032864adbab76",
29
+ ),
30
+ ("Avrik-Lite-4B", "bf16"): Release(
31
+ "avrik-ai/Avrik-Lite-4B",
32
+ "05ca5ed2faa1b853c3a80201bbebb30ad9d25714",
33
+ "f87feb81b75670d7550e8ad501e79560619d7bb97ffdde9e7d5a710e0bf5dc3f",
34
+ ),
35
+ ("Avrik-Pro-12B", "gguf"): Release(
36
+ "avrik-ai/Avrik-Pro-12B-GGUF",
37
+ "3361b4db25bf0e30b2db10404df748f059ee3ac7",
38
+ "afe2735afb68472bc0b2f630db827e20c8a449487922526143b91e48f7770721",
39
+ ),
40
+ ("Avrik-Pro-12B", "bf16"): Release(
41
+ "avrik-ai/Avrik-Pro-12B",
42
+ "1c52c6e0cd210cff279e4389f8d8c2d9de2b970e",
43
+ "04c221547f84cdd1401f5d61d2e6c6e76a90de19a4ab8c5fade31dfd9d7d95f4",
44
+ ),
45
+ ("Avrik-Max-31B", "fp8"): Release(
46
+ "avrik-ai/Avrik-Max-31B",
47
+ "ee7cb20d17e5512d61eb4f4ca1d5485d1bd3b082",
48
+ "f9e34c712fbdd18769589a989c1964a29e10ec2af419693e33c49e91e4134993",
49
+ ),
50
+ }
@@ -0,0 +1,241 @@
1
+ """Release checks before any file is loaded (spec 004 FR-018, SC-008; security review T066).
2
+
3
+ A release directory carries ``MANIFEST.sha256``, with lines ``<sha256> <relative path>``
4
+ in sha256sum format. :func:`verify_release` refuses the release unless all of these hold:
5
+
6
+ - **Hashes:** every listed file exists inside the directory and matches its sha256.
7
+ - **Nothing unlisted:** every file in the directory is listed. The only exception is
8
+ ``.cache/huggingface/``, the Hub's download bookkeeping, which is never loaded.
9
+ - **No symlinks:** none anywhere, files or directories, because a link could point
10
+ outside the release.
11
+ - **No code (B2):** no ``*.py``, ``*.pyc``, ``*.so``, ``*.dylib`` or ``*.dll`` file, and no
12
+ ``auto_map`` key in any JSON file, so Transformers never offers to run shipped code.
13
+ - **Required files:** ``readout.json``, ``provenance.json``, ``.avrik-pin.json`` and
14
+ ``tokenizer.json``, plus the weights and the calibration that ``readout.json`` names
15
+ (``weights``, ``calibration``). Loaders use those names and never pick a file by
16
+ pattern.
17
+ - **Weights-free builds** (``Avrik-Max-31B``; spec 004 research R17): ``readout.json``
18
+ names no weights and carries ``upstream: {repo, revision, lock}``. The lock
19
+ (``upstream.lock.json``, listed in the manifest) holds the sha256 of every file of the
20
+ pinned upstream revision, and the files themselves live in ``upstream/``. Every file
21
+ there must be exactly the lock's, under the same checks; ``upstream_required`` refuses
22
+ a release whose ``upstream/`` is missing (after ``avrik pull``).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import json
29
+ import os
30
+ import re
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ MANIFEST = "MANIFEST.sha256"
35
+ IGNORED = (".cache", "huggingface") # huggingface_hub's bookkeeping; never loaded
36
+ CODE_SUFFIXES = (".py", ".pyc", ".so", ".dylib", ".dll")
37
+ REQUIRED = ("readout.json", "provenance.json", ".avrik-pin.json", "tokenizer.json")
38
+ UPSTREAM_LOCK = "upstream.lock.json"
39
+ UPSTREAM_DIR = "upstream"
40
+
41
+
42
+ class ManifestError(RuntimeError):
43
+ """A release fails a check; nothing in it may be loaded."""
44
+
45
+
46
+ def sha256(path: Path) -> str:
47
+ """Hex sha256 of a file, streamed."""
48
+ h = hashlib.sha256()
49
+ with path.open("rb") as f:
50
+ for chunk in iter(lambda: f.read(1 << 20), b""):
51
+ h.update(chunk)
52
+ return h.hexdigest()
53
+
54
+
55
+ def read_manifest(root: Path) -> dict[str, str]:
56
+ """``{relative path: sha256}`` from ``root/MANIFEST.sha256``.
57
+
58
+ Raises:
59
+ ManifestError: When the manifest is missing or malformed.
60
+ """
61
+ path = root / MANIFEST
62
+ if path.is_symlink() or not path.is_file():
63
+ raise ManifestError(f"{root} has no {MANIFEST}")
64
+ out: dict[str, str] = {}
65
+ for line in path.read_text(encoding="utf-8").splitlines():
66
+ if not line.strip():
67
+ continue
68
+ digest, sep, rel = line.partition(" ")
69
+ if not sep or len(digest) != 64:
70
+ raise ManifestError(f"malformed manifest line: {line[:80]!r}")
71
+ out[rel.strip()] = digest
72
+ return out
73
+
74
+
75
+ def _ignored(rel: Path) -> bool:
76
+ """Under ``.cache/huggingface/``; anything else under ``.cache`` is checked."""
77
+ return rel.parts[: len(IGNORED)] == IGNORED
78
+
79
+
80
+ def files_in(root: Path) -> list[Path]:
81
+ """Every file under ``root``, relative, outside the ignored path.
82
+
83
+ Raises:
84
+ ManifestError: On any symlink.
85
+ """
86
+ found: list[Path] = []
87
+ for directory, dirnames, filenames in os.walk(root, followlinks=False):
88
+ here = Path(directory)
89
+ for name in [*dirnames, *filenames]:
90
+ rel = (here / name).relative_to(root)
91
+ if not _ignored(rel) and (here / name).is_symlink():
92
+ raise ManifestError(f"{rel.as_posix()} is a symlink; a release may not hold links")
93
+ dirnames[:] = [d for d in dirnames if not _ignored((here / d).relative_to(root))]
94
+ found += [(here / f).relative_to(root) for f in filenames]
95
+ return sorted(f for f in found if not _ignored(f))
96
+
97
+
98
+ def readout(root: Path) -> dict[str, Any]:
99
+ """The release's ``readout.json``; call :func:`verify_release` first."""
100
+ data: dict[str, Any] = json.loads((root / "readout.json").read_text(encoding="utf-8"))
101
+ return data
102
+
103
+
104
+ def verify_release(root: Path, *, upstream_required: bool = False) -> int:
105
+ """Run every check in the module docstring; return how many files were hashed.
106
+
107
+ Raises:
108
+ ManifestError: Naming the first problem found.
109
+ """
110
+ root = root.resolve()
111
+ entries = read_manifest(root)
112
+ present = files_in(root) # symlinks refused before anything is opened
113
+ for rel, digest in entries.items():
114
+ path = (root / rel).resolve()
115
+ if not path.is_relative_to(root):
116
+ raise ManifestError(f"manifest path escapes the release: {rel}")
117
+ if not path.is_file():
118
+ raise ManifestError(f"file {rel} is missing from the release")
119
+ if sha256(path) != digest:
120
+ raise ManifestError(f"file {rel} does not match the release manifest")
121
+ for name in REQUIRED:
122
+ if name not in entries:
123
+ raise ManifestError(f"the release has no {name}")
124
+ info = readout(root)
125
+ upstream = _upstream(root, info, entries)
126
+ listed = set(entries) | {MANIFEST}
127
+ pulled: list[Path] = []
128
+ for found in present:
129
+ if upstream is not None and found.parts[0] == UPSTREAM_DIR:
130
+ pulled.append(found)
131
+ continue
132
+ name = found.as_posix()
133
+ if name not in listed:
134
+ raise ManifestError(f"file {name} is not in the release manifest")
135
+ _no_code(root, found)
136
+ weights, calibration = info.get("weights"), info.get("calibration")
137
+ if not isinstance(weights, list) or not isinstance(calibration, str):
138
+ raise ManifestError("readout.json must name its weights and its calibration")
139
+ if bool(weights) == (upstream is not None):
140
+ raise ManifestError("readout.json must name weights, or an upstream lock, not both")
141
+ for name in [*weights, calibration]:
142
+ if name not in entries:
143
+ raise ManifestError(f"readout.json names {name}, which is not in the manifest")
144
+ hashed = len(entries)
145
+ if upstream is not None and (pulled or upstream_required):
146
+ hashed += _verify_upstream(root, upstream, pulled)
147
+ return hashed
148
+
149
+
150
+ def _no_code(root: Path, found: Path) -> None:
151
+ name = found.as_posix()
152
+ if found.suffix in CODE_SUFFIXES:
153
+ raise ManifestError(f"file {name} is code; a release may not ship code")
154
+ if found.suffix == ".json" and _has_auto_map(root / found):
155
+ raise ManifestError(f"file {name} declares auto_map (remote code)")
156
+
157
+
158
+ REPO = re.compile(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+")
159
+ COMMIT = re.compile(r"[0-9a-f]{40}")
160
+ HEX64 = re.compile(r"[0-9a-f]{64}")
161
+ # Root files the readout loads that also come from upstream; README.md is our card.
162
+ FROM_UPSTREAM = ("tokenizer.json", "tokenizer_config.json", "chat_template.jinja",
163
+ "special_tokens_map.json", "generation_config.json", "config.json",
164
+ "processor_config.json", "preprocessor_config.json") # fmt: skip
165
+
166
+
167
+ def _upstream(root: Path, info: dict[str, Any], entries: dict[str, str]) -> dict[str, str] | None:
168
+ """The verified lock's files for a weights-free build, or None for one with weights.
169
+
170
+ The block, the lock and ``.avrik-pin.json`` must name the same ``org/name`` repo and
171
+ full 40-hex commit, and every tokenizer, template or config file at the root must
172
+ have the lock's sha256, so the readout and vLLM read the same files.
173
+ """
174
+ block = info.get("upstream")
175
+ if block is None:
176
+ return None
177
+ if not isinstance(block, dict) or block.get("lock") != UPSTREAM_LOCK:
178
+ raise ManifestError(f"readout.json upstream must name its lock, {UPSTREAM_LOCK}")
179
+ if UPSTREAM_LOCK not in entries:
180
+ raise ManifestError(f"{UPSTREAM_LOCK} is not in the release manifest")
181
+ repo, revision = block.get("repo"), block.get("revision")
182
+ if not isinstance(repo, str) or not REPO.fullmatch(repo):
183
+ raise ManifestError(f"readout.json upstream repo {repo!r} is not org/name")
184
+ if not isinstance(revision, str) or not COMMIT.fullmatch(revision):
185
+ raise ManifestError(f"upstream {repo}@{revision} is not pinned to a full commit")
186
+ try:
187
+ lock = json.loads((root / UPSTREAM_LOCK).read_text(encoding="utf-8"))
188
+ pin = json.loads((root / ".avrik-pin.json").read_text(encoding="utf-8"))
189
+ except (ValueError, UnicodeDecodeError) as error:
190
+ raise ManifestError(f"{UPSTREAM_LOCK} or .avrik-pin.json is not valid JSON") from error
191
+ if not isinstance(lock, dict) or not isinstance(pin, dict):
192
+ raise ManifestError(f"{UPSTREAM_LOCK} and .avrik-pin.json must be JSON objects")
193
+ if (lock.get("repo"), lock.get("revision")) != (repo, revision):
194
+ raise ManifestError("readout.json upstream does not match its lock")
195
+ if (pin.get("repo"), pin.get("revision")) != (repo, revision):
196
+ raise ManifestError(".avrik-pin.json does not name the upstream lock's revision")
197
+ files = lock.get("files")
198
+ if not isinstance(files, dict) or not files:
199
+ raise ManifestError(f"{UPSTREAM_LOCK} lists no files")
200
+ for name, digest in files.items():
201
+ if not isinstance(name, str) or not isinstance(digest, str) or not HEX64.fullmatch(digest):
202
+ raise ManifestError(f"{UPSTREAM_LOCK} has a malformed entry for {name!r}")
203
+ if name in FROM_UPSTREAM and name in entries and entries[name] != digest:
204
+ raise ManifestError(f"file {name} differs from the upstream lock's {name}")
205
+ return dict(files)
206
+
207
+
208
+ def _verify_upstream(root: Path, lock: dict[str, str], pulled: list[Path]) -> int:
209
+ """``upstream/`` holds exactly the lock's files, each matching; return the count."""
210
+ here = root / UPSTREAM_DIR
211
+ found = {p.relative_to(UPSTREAM_DIR).as_posix(): p for p in pulled
212
+ if not _ignored(p.relative_to(UPSTREAM_DIR))} # fmt: skip
213
+ for name in sorted(set(found) - set(lock)):
214
+ raise ManifestError(f"file {UPSTREAM_DIR}/{name} is not in the upstream lock")
215
+ for name, digest in sorted(lock.items()):
216
+ path = (here / name).resolve()
217
+ if not path.is_relative_to(here.resolve()):
218
+ raise ManifestError(f"upstream lock path escapes the release: {name}")
219
+ if name not in found or not path.is_file():
220
+ raise ManifestError(f"upstream file {name} is missing; run avrik pull")
221
+ if sha256(path) != digest:
222
+ raise ManifestError(f"upstream file {name} does not match the upstream lock")
223
+ _no_code(root, found[name])
224
+ return len(lock)
225
+
226
+
227
+ def _has_auto_map(path: Path) -> bool:
228
+ try:
229
+ data = json.loads(path.read_text(encoding="utf-8"))
230
+ except (ValueError, UnicodeDecodeError) as error:
231
+ raise ManifestError(f"{path.name} is not valid JSON") from error
232
+ stack: list[Any] = [data]
233
+ while stack:
234
+ item = stack.pop()
235
+ if isinstance(item, dict):
236
+ if "auto_map" in item:
237
+ return True
238
+ stack.extend(item.values())
239
+ elif isinstance(item, list):
240
+ stack.extend(item)
241
+ return False