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 +202 -0
- avrik-1.0.0/NOTICE +10 -0
- avrik-1.0.0/PKG-INFO +80 -0
- avrik-1.0.0/PYPI.md +60 -0
- avrik-1.0.0/pyproject.toml +40 -0
- avrik-1.0.0/pyproject.toml.orig +30 -0
- avrik-1.0.0/src/avrik_cli/__init__.py +1 -0
- avrik-1.0.0/src/avrik_cli/__main__.py +297 -0
- avrik-1.0.0/src/avrik_cli/py.typed +0 -0
- avrik-1.0.0/src/avrik_cli/release.py +310 -0
- avrik-1.0.0/src/avrik_cli/releases.py +50 -0
- avrik-1.0.0/src/avrik_cli/verify.py +241 -0
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
|