AffixIO commited on
Commit
551b309
·
verified ·
1 Parent(s): f381ef4

Publish affix-huggingface 0.3.0

Browse files
LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor has
163
+ been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
202
+
SECURITY.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security
2
+
3
+ ## The published trial key
4
+
5
+ `PUBLIC_TRIAL_API_KEY` ships inside the package. It is published, not secret. Anyone who installs the wheel can read it, so it is scoped, rate limited, and revocable, and it carries no customer data access.
6
+
7
+ No SDK can hide a credential from the machine it runs on. If the code can send a key to AffixIO, the user can read it. Treat any key embedded in a distributed package as public, and keep production keys in the runtime secret store.
8
+
9
+ ## Trial allowance
10
+
11
+ The 100 proof allowance is enforced on `api.affix-io.com` against a subject digest. The local file under `~/.cache/affix-huggingface/` holds install credentials only. Deleting it forces re-registration against the same server-side counter rather than granting new proofs.
12
+
13
+ Local counters cannot be made tamper proof. Anything written to a user's disk can be deleted, and a fresh container or virtual machine always looks new. Server-side counting is what makes the limit hold.
14
+
15
+ Creating a second identity still yields a second allowance. New subject registrations are throttled per origin. Where the limit must resist deliberate evasion, bind registration to a verified Hub account.
16
+
17
+ ## Credentials
18
+
19
+ Load `AFFIX_API_KEY` and `HF_TOKEN` from the runtime secret store. Do not put either value in source control, container images, notebooks, model cards, or Hugging Face Spaces variables marked public.
20
+
21
+ The SDK sends the AffixIO key only to the configured AffixIO base URL and the Hugging Face token only through `huggingface_hub`.
22
+
23
+ Changing `affix_base_url` transfers the AffixIO key to that host. Treat this setting as security-sensitive and keep the default in production unless you operate the destination.
24
+
25
+ ## Data boundary
26
+
27
+ The SDK does not include prompt or output content in AffixIO prove, gate, or attestation requests. Model identifiers, declared revision metadata, gate receipts, proof digests, model-binding digests, and the trial subject digest are sent to AffixIO. The Hugging Face account name and token are not.
28
+
29
+ Customer applications are responsible for calculating `AdmissionChecks` from trusted records. Supplying `True` values without evaluating those records weakens admission to a caller assertion.
30
+
31
+ ## Reporting
32
+
33
+ Report suspected vulnerabilities privately to security@affix-io.com. Include the affected version and a minimal reproduction. Do not include production API keys, Hugging Face tokens, prompts, or customer data.
34
+
dist/affix_huggingface-0.3.0-py3-none-any.whl ADDED
Binary file (23.9 kB). View file
 
dist/affix_huggingface-0.3.0.tar.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1b9116ecfde753be31ca9a4e5cbefc041c889177cc8324179b5dd7490eac348f
3
+ size 26006
examples/chat_completion.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run an AffixIO-authorised Hugging Face chat completion."""
2
+
3
+ # Credit: @paparichens
4
+
5
+ from affix_huggingface import AdmissionChecks, AffixHuggingFace, ModelRef
6
+
7
+
8
+ def main() -> None:
9
+ model = ModelRef(
10
+ model_id="meta-llama/Llama-3.1-8B-Instruct",
11
+ provider="hf-inference",
12
+ )
13
+ checks = AdmissionChecks(
14
+ entitled=True,
15
+ data_route_allowed=True,
16
+ controls_satisfied=True,
17
+ )
18
+
19
+ with AffixHuggingFace() as client:
20
+ result = client.chat_completion(
21
+ [{"role": "user", "content": "Summarise the supplied operational note."}],
22
+ model=model,
23
+ checks=checks,
24
+ max_tokens=150,
25
+ )
26
+
27
+ print(result.output)
28
+ print(f"AffixIO receipt: {result.receipt.gate.receipt_id}")
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()
33
+
hatch_build.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build hook that ships an integrity manifest inside the wheel.
2
+
3
+ The manifest hashes every packaged file. At runtime the package recomputes those
4
+ hashes and refuses to run if any differ. The manifest is written to a temporary
5
+ path and force-included, so the source tree stays clean and editable installs
6
+ remain unenforced.
7
+ """
8
+
9
+ # Credit: @paparichens
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import tempfile
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
20
+
21
+ PACKAGE = "affix_huggingface"
22
+ MANIFEST_NAME = "_integrity_manifest.json"
23
+ ALGORITHM = "sha256"
24
+ _SKIP_SUFFIXES = (".pyc", ".pyo")
25
+ _SKIP_DIRS = {"__pycache__"}
26
+
27
+
28
+ def _hash_file(path: Path) -> str:
29
+ digest = hashlib.new(ALGORITHM)
30
+ with path.open("rb") as handle:
31
+ for chunk in iter(lambda: handle.read(65536), b""):
32
+ digest.update(chunk)
33
+ return digest.hexdigest()
34
+
35
+
36
+ class CustomBuildHook(BuildHookInterface):
37
+ PLUGIN_NAME = "custom"
38
+
39
+ def initialize(self, version: str, build_data: dict[str, Any]) -> None:
40
+ if self.target_name != "wheel":
41
+ return
42
+
43
+ root = Path(self.root) / "src" / PACKAGE
44
+ files: dict[str, str] = {}
45
+ for path in sorted(root.rglob("*")):
46
+ if not path.is_file():
47
+ continue
48
+ rel = path.relative_to(root)
49
+ if any(part in _SKIP_DIRS for part in rel.parts):
50
+ continue
51
+ if path.suffix in _SKIP_SUFFIXES:
52
+ continue
53
+ if path.name == MANIFEST_NAME:
54
+ continue
55
+ files[rel.as_posix()] = _hash_file(path)
56
+
57
+ manifest = {"version": 1, "algorithm": ALGORITHM, "files": files}
58
+ tmp = Path(tempfile.mkdtemp(prefix="affix-manifest-")) / MANIFEST_NAME
59
+ tmp.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
60
+
61
+ force_include = build_data.setdefault("force_include", {})
62
+ force_include[str(tmp)] = f"{PACKAGE}/{MANIFEST_NAME}"
pyproject.toml ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "affix-huggingface"
7
+ version = "0.3.0"
8
+ description = "AffixIO admission and evidence controls for Hugging Face inference."
9
+ readme = "README.md"
10
+ license = { text = "Apache-2.0" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "AffixIO", email = "hello@affix-io.com" }]
13
+ keywords = [
14
+ "affixio",
15
+ "huggingface",
16
+ "inference",
17
+ "admission-control",
18
+ "verification",
19
+ "ml-dsa",
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
23
+ "Intended Audience :: Developers",
24
+ "License :: OSI Approved :: Apache Software License",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.10",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
30
+ "Typing :: Typed",
31
+ ]
32
+ dependencies = [
33
+ "httpx>=0.27,<1",
34
+ "huggingface-hub>=1.0,<2",
35
+ ]
36
+
37
+ [project.optional-dependencies]
38
+ dev = [
39
+ "build",
40
+ "mypy",
41
+ "pytest",
42
+ "pytest-cov",
43
+ "ruff",
44
+ ]
45
+
46
+ [project.urls]
47
+ Homepage = "https://www.affix-io.com/"
48
+ API = "https://api.affix-io.com/"
49
+ Credentials = "https://hub.affix-io.com/credentials/"
50
+ HuggingFace = "https://huggingface.co/"
51
+
52
+ [tool.hatch.build.targets.wheel]
53
+ packages = ["src/affix_huggingface"]
54
+
55
+ [tool.hatch.build.hooks.custom]
56
+ path = "hatch_build.py"
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
60
+ addopts = "-q"
61
+
62
+ [tool.ruff]
63
+ line-length = 100
64
+ target-version = "py310"
65
+
66
+ [tool.ruff.lint]
67
+ select = ["E", "F", "I", "UP", "B", "SIM"]
68
+
69
+ [tool.mypy]
70
+ python_version = "3.10"
71
+ strict = true
72
+ packages = ["affix_huggingface"]
73
+
src/affix_huggingface/__init__.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AffixIO admission and evidence controls for Hugging Face inference."""
2
+
3
+ # Credit: @paparichens
4
+
5
+ from .integrity import IntegrityError # noqa: I001
6
+ from .integrity import enforce as _enforce_integrity
7
+
8
+ _enforce_integrity()
9
+
10
+ from .client import AffixHuggingFace, AuthorisedStream # noqa: E402
11
+ from .errors import ( # noqa: E402
12
+ AdmissionDenied,
13
+ AffixAPIError,
14
+ AffixHuggingFaceError,
15
+ ConfigurationError,
16
+ EvidenceError,
17
+ RegistrationThrottled,
18
+ StreamNotComplete,
19
+ TrialExhausted,
20
+ )
21
+ from .models import ( # noqa: E402
22
+ AdmissionChecks,
23
+ AdmissionProof,
24
+ AffixReceipt,
25
+ AuthorisedResult,
26
+ CompletionEvidence,
27
+ GateReceipt,
28
+ ModelRef,
29
+ )
30
+ from .trial import TrialCredentials, TrialQuota, credentials_path # noqa: E402
31
+
32
+ __version__ = "0.3.0"
33
+
34
+ __all__ = [
35
+ "AdmissionChecks",
36
+ "AdmissionDenied",
37
+ "AdmissionProof",
38
+ "AffixAPIError",
39
+ "AffixHuggingFace",
40
+ "AffixHuggingFaceError",
41
+ "AffixReceipt",
42
+ "AuthorisedResult",
43
+ "AuthorisedStream",
44
+ "CompletionEvidence",
45
+ "ConfigurationError",
46
+ "EvidenceError",
47
+ "GateReceipt",
48
+ "IntegrityError",
49
+ "ModelRef",
50
+ "RegistrationThrottled",
51
+ "StreamNotComplete",
52
+ "TrialCredentials",
53
+ "TrialExhausted",
54
+ "TrialQuota",
55
+ "__version__",
56
+ "credentials_path",
57
+ ]
58
+
src/affix_huggingface/client.py ADDED
@@ -0,0 +1,544 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face inference guarded by AffixIO internet admission."""
2
+
3
+ # Credit: @paparichens
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import uuid
9
+ from collections.abc import Callable, Iterable, Iterator, Mapping
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Generic, Protocol, TypeVar, cast
12
+
13
+ from huggingface_hub import InferenceClient
14
+
15
+ from .errors import AdmissionDenied, ConfigurationError, EvidenceError, StreamNotComplete
16
+ from .integrity import ensure_intact
17
+ from .models import (
18
+ AdmissionChecks,
19
+ AdmissionProof,
20
+ AffixReceipt,
21
+ AuthorisedResult,
22
+ CompletionEvidence,
23
+ EvidenceMode,
24
+ GateMode,
25
+ GateReceipt,
26
+ ModelRef,
27
+ )
28
+ from .transport import AffixTransport
29
+ from .trial import (
30
+ PUBLIC_TRIAL_API_KEY,
31
+ TRIAL_PRODUCT,
32
+ TrialQuota,
33
+ load_credentials,
34
+ resolve_subject,
35
+ save_credentials,
36
+ )
37
+
38
+ T = TypeVar("T")
39
+
40
+
41
+ class InferenceClientProtocol(Protocol):
42
+ """Subset of Hugging Face's client used by this SDK."""
43
+
44
+ def chat_completion(
45
+ self,
46
+ messages: list[dict[str, Any]],
47
+ *,
48
+ model: str,
49
+ **kwargs: Any,
50
+ ) -> Any: ...
51
+
52
+ def text_generation(self, prompt: str, *, model: str, **kwargs: Any) -> Any: ...
53
+
54
+ def feature_extraction(self, text: str | list[str], *, model: str, **kwargs: Any) -> Any: ...
55
+
56
+
57
+ class AuthorisedStream(Generic[T], Iterator[T]):
58
+ """Iterator that issues final evidence after the HF stream completes."""
59
+
60
+ def __init__(
61
+ self,
62
+ source: Iterable[T],
63
+ *,
64
+ admission_receipt: AffixReceipt,
65
+ finalise: Callable[[], AffixReceipt],
66
+ ) -> None:
67
+ self._source = iter(source)
68
+ self._finalise = finalise
69
+ self.admission_receipt = admission_receipt
70
+ self._final_receipt: AffixReceipt | None = None
71
+ self._complete = False
72
+
73
+ def __iter__(self) -> AuthorisedStream[T]:
74
+ return self
75
+
76
+ def __next__(self) -> T:
77
+ try:
78
+ return next(self._source)
79
+ except StopIteration:
80
+ self._complete_stream()
81
+ raise
82
+
83
+ @property
84
+ def complete(self) -> bool:
85
+ return self._complete
86
+
87
+ @property
88
+ def final_receipt(self) -> AffixReceipt:
89
+ if self._final_receipt is None:
90
+ raise StreamNotComplete("Consume the stream before requesting its final receipt")
91
+ return self._final_receipt
92
+
93
+ def close(self) -> None:
94
+ close = getattr(self._source, "close", None)
95
+ if callable(close):
96
+ close()
97
+
98
+ def _complete_stream(self) -> None:
99
+ if self._complete:
100
+ return
101
+ self._final_receipt = self._finalise()
102
+ self._complete = True
103
+
104
+
105
+ class AffixHuggingFace:
106
+ """
107
+ Cloud-to-cloud AffixIO admission around Hugging Face inference.
108
+
109
+ Prompt and output content are supplied only to the Hugging Face client.
110
+ AffixIO receives proof material and metadata about the model binding.
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ *,
116
+ affix_api_key: str | None = None,
117
+ hf_token: str | None = None,
118
+ affix_base_url: str = "https://api.affix-io.com",
119
+ hf_provider: str | None = None,
120
+ timeout: float = 30.0,
121
+ max_retries: int = 2,
122
+ evidence_mode: EvidenceMode = "required",
123
+ subject: str | None = None,
124
+ trial: bool | None = None,
125
+ affix_transport: AffixTransport | None = None,
126
+ hf_client: InferenceClientProtocol | None = None,
127
+ ) -> None:
128
+ supplied_key = affix_api_key or os.getenv("AFFIX_API_KEY")
129
+ token = hf_token or os.getenv("HF_TOKEN")
130
+ ensure_intact()
131
+ api_key = supplied_key or PUBLIC_TRIAL_API_KEY
132
+ if not token and hf_client is None:
133
+ raise ConfigurationError("HF_TOKEN is required for Hugging Face inference")
134
+ if evidence_mode not in {"required", "best_effort", "off"}:
135
+ raise ValueError("evidence_mode must be required, best_effort, or off")
136
+
137
+ if trial is None:
138
+ trial = affix_transport is None and supplied_key is None
139
+
140
+ self.evidence_mode = evidence_mode
141
+ self._trial = trial
142
+ self._subject = subject
143
+ self._hf_token = token
144
+ self._registered = False
145
+ self._owns_affix = affix_transport is None
146
+ self._owns_hf = hf_client is None
147
+ self._affix = affix_transport or AffixTransport(
148
+ api_key or "",
149
+ base_url=affix_base_url,
150
+ timeout=timeout,
151
+ max_retries=max_retries,
152
+ )
153
+ self._hf = hf_client or cast(
154
+ InferenceClientProtocol,
155
+ InferenceClient(
156
+ token=token,
157
+ provider=hf_provider, # type: ignore[arg-type]
158
+ timeout=timeout,
159
+ ),
160
+ )
161
+
162
+ def close(self) -> None:
163
+ if self._owns_affix:
164
+ self._affix.close()
165
+ if self._owns_hf:
166
+ close = getattr(self._hf, "close", None)
167
+ if callable(close):
168
+ close()
169
+
170
+ def __enter__(self) -> AffixHuggingFace:
171
+ return self
172
+
173
+ def __exit__(self, *_: object) -> None:
174
+ self.close()
175
+
176
+ def health(self) -> Mapping[str, Any]:
177
+ data, _ = self._affix.health()
178
+ return data
179
+
180
+ @property
181
+ def quota(self) -> TrialQuota | None:
182
+ """Allowance reported by the last AffixIO response, if any."""
183
+ return self._affix.last_quota
184
+
185
+ def refresh_quota(self) -> TrialQuota:
186
+ """Ask AffixIO for the current allowance for this subject."""
187
+ self._ensure_registered()
188
+ return self._affix.quota()
189
+
190
+ def _ensure_registered(self) -> None:
191
+ """
192
+ Attach install credentials before the first metered call.
193
+
194
+ Cached credentials are a convenience. The allowance itself is counted
195
+ per subject on api.affix-io.com, so clearing the cache re-registers
196
+ against the same counter rather than granting new proofs.
197
+ """
198
+ if not self._trial or self._registered:
199
+ return
200
+
201
+ cached = load_credentials()
202
+ if cached is not None:
203
+ self._affix.credentials = cached
204
+ self._registered = True
205
+ return
206
+
207
+ subject_id = resolve_subject(subject=self._subject, hf_token=self._hf_token)
208
+ credentials, _ = self._affix.register_install(
209
+ subject_id=subject_id,
210
+ product=TRIAL_PRODUCT,
211
+ )
212
+ save_credentials(credentials)
213
+ self._affix.credentials = credentials
214
+ self._registered = True
215
+
216
+ def prove_admission(
217
+ self,
218
+ checks: AdmissionChecks,
219
+ *,
220
+ circuit_id: str = "yesno",
221
+ idempotency_key: str | None = None,
222
+ ) -> AdmissionProof:
223
+ self._ensure_registered()
224
+ data, _ = self._affix.prove(
225
+ circuit_id=circuit_id,
226
+ fields=checks.prove_fields(),
227
+ idempotency_key=idempotency_key or self._key("prove"),
228
+ )
229
+ proof = data.get("proof")
230
+ if not isinstance(proof, str) or not proof:
231
+ raise ConfigurationError("AffixIO prove response did not contain proof material")
232
+ return AdmissionProof(proof=proof, circuit_id=circuit_id, policy_id="gate.agent")
233
+
234
+ def inspect(
235
+ self,
236
+ model: ModelRef,
237
+ *,
238
+ proof: AdmissionProof,
239
+ idempotency_key: str | None = None,
240
+ ) -> GateReceipt:
241
+ """Verify without spending. This method never calls Hugging Face."""
242
+ return self._gate(
243
+ model,
244
+ proof=proof,
245
+ mode="check",
246
+ idempotency_key=idempotency_key or self._key("inspect"),
247
+ )
248
+
249
+ def authorise(
250
+ self,
251
+ model: ModelRef,
252
+ *,
253
+ checks: AdmissionChecks | None = None,
254
+ proof: AdmissionProof | None = None,
255
+ idempotency_key: str | None = None,
256
+ valid_from: int | None = None,
257
+ valid_until: int | None = None,
258
+ region_hash: str | None = None,
259
+ region_expected: str | None = None,
260
+ ) -> GateReceipt:
261
+ """Consume one valid proof before an inference call."""
262
+ root_key = idempotency_key or uuid.uuid4().hex
263
+ if proof is None:
264
+ if checks is None:
265
+ raise ConfigurationError("Provide checks or a pre-issued admission proof")
266
+ proof = self.prove_admission(
267
+ checks,
268
+ idempotency_key=f"{root_key}:prove",
269
+ )
270
+ receipt = self._gate(
271
+ model,
272
+ proof=proof,
273
+ mode="consume",
274
+ idempotency_key=f"{root_key}:gate",
275
+ valid_from=valid_from,
276
+ valid_until=valid_until,
277
+ region_hash=region_hash,
278
+ region_expected=region_expected,
279
+ )
280
+ if not receipt.allow:
281
+ raise AdmissionDenied(
282
+ "AffixIO denied the Hugging Face inference request",
283
+ reason_code=receipt.reason_code,
284
+ receipt_id=receipt.receipt_id,
285
+ request_id=receipt.request_id,
286
+ )
287
+ return receipt
288
+
289
+ def chat_completion(
290
+ self,
291
+ messages: list[dict[str, Any]],
292
+ *,
293
+ model: ModelRef,
294
+ checks: AdmissionChecks | None = None,
295
+ proof: AdmissionProof | None = None,
296
+ idempotency_key: str | None = None,
297
+ **kwargs: Any,
298
+ ) -> AuthorisedResult:
299
+ if kwargs.get("stream"):
300
+ raise ValueError("Use stream_chat_completion for streaming responses")
301
+ return self._execute(
302
+ "chat_completion",
303
+ model,
304
+ checks=checks,
305
+ proof=proof,
306
+ idempotency_key=idempotency_key,
307
+ invoke=lambda: self._hf.chat_completion(
308
+ messages,
309
+ model=model.inference_target,
310
+ **kwargs,
311
+ ),
312
+ )
313
+
314
+ def text_generation(
315
+ self,
316
+ prompt: str,
317
+ *,
318
+ model: ModelRef,
319
+ checks: AdmissionChecks | None = None,
320
+ proof: AdmissionProof | None = None,
321
+ idempotency_key: str | None = None,
322
+ **kwargs: Any,
323
+ ) -> AuthorisedResult:
324
+ if kwargs.get("stream"):
325
+ raise ValueError("Use stream_text_generation for streaming responses")
326
+ return self._execute(
327
+ "text_generation",
328
+ model,
329
+ checks=checks,
330
+ proof=proof,
331
+ idempotency_key=idempotency_key,
332
+ invoke=lambda: self._hf.text_generation(
333
+ prompt,
334
+ model=model.inference_target,
335
+ **kwargs,
336
+ ),
337
+ )
338
+
339
+ def feature_extraction(
340
+ self,
341
+ text: str | list[str],
342
+ *,
343
+ model: ModelRef,
344
+ checks: AdmissionChecks | None = None,
345
+ proof: AdmissionProof | None = None,
346
+ idempotency_key: str | None = None,
347
+ **kwargs: Any,
348
+ ) -> AuthorisedResult:
349
+ return self._execute(
350
+ "feature_extraction",
351
+ model,
352
+ checks=checks,
353
+ proof=proof,
354
+ idempotency_key=idempotency_key,
355
+ invoke=lambda: self._hf.feature_extraction(
356
+ text,
357
+ model=model.inference_target,
358
+ **kwargs,
359
+ ),
360
+ )
361
+
362
+ def stream_chat_completion(
363
+ self,
364
+ messages: list[dict[str, Any]],
365
+ *,
366
+ model: ModelRef,
367
+ checks: AdmissionChecks | None = None,
368
+ proof: AdmissionProof | None = None,
369
+ idempotency_key: str | None = None,
370
+ **kwargs: Any,
371
+ ) -> AuthorisedStream[Any]:
372
+ kwargs.pop("stream", None)
373
+ gate = self.authorise(
374
+ model,
375
+ checks=checks,
376
+ proof=proof,
377
+ idempotency_key=idempotency_key,
378
+ )
379
+ source = self._hf.chat_completion(
380
+ messages,
381
+ model=model.inference_target,
382
+ stream=True,
383
+ **kwargs,
384
+ )
385
+ return self._stream("chat_completion", model, gate, source)
386
+
387
+ def stream_text_generation(
388
+ self,
389
+ prompt: str,
390
+ *,
391
+ model: ModelRef,
392
+ checks: AdmissionChecks | None = None,
393
+ proof: AdmissionProof | None = None,
394
+ idempotency_key: str | None = None,
395
+ **kwargs: Any,
396
+ ) -> AuthorisedStream[Any]:
397
+ kwargs.pop("stream", None)
398
+ gate = self.authorise(
399
+ model,
400
+ checks=checks,
401
+ proof=proof,
402
+ idempotency_key=idempotency_key,
403
+ )
404
+ source = self._hf.text_generation(
405
+ prompt,
406
+ model=model.inference_target,
407
+ stream=True,
408
+ **kwargs,
409
+ )
410
+ return self._stream("text_generation", model, gate, source)
411
+
412
+ def _execute(
413
+ self,
414
+ operation: str,
415
+ model: ModelRef,
416
+ *,
417
+ checks: AdmissionChecks | None,
418
+ proof: AdmissionProof | None,
419
+ idempotency_key: str | None,
420
+ invoke: Callable[[], Any],
421
+ ) -> AuthorisedResult:
422
+ gate = self.authorise(
423
+ model,
424
+ checks=checks,
425
+ proof=proof,
426
+ idempotency_key=idempotency_key,
427
+ )
428
+ output = invoke()
429
+ receipt = self._finalise(operation, model, gate, output=output)
430
+ return AuthorisedResult(output=output, receipt=receipt)
431
+
432
+ def _stream(
433
+ self,
434
+ operation: str,
435
+ model: ModelRef,
436
+ gate: GateReceipt,
437
+ source: Any,
438
+ ) -> AuthorisedStream[Any]:
439
+ admission = AffixReceipt(model=model, operation=operation, gate=gate)
440
+ return AuthorisedStream(
441
+ source,
442
+ admission_receipt=admission,
443
+ finalise=lambda: self._finalise(operation, model, gate),
444
+ )
445
+
446
+ def _gate(
447
+ self,
448
+ model: ModelRef,
449
+ *,
450
+ proof: AdmissionProof,
451
+ mode: GateMode,
452
+ idempotency_key: str,
453
+ valid_from: int | None = None,
454
+ valid_until: int | None = None,
455
+ region_hash: str | None = None,
456
+ region_expected: str | None = None,
457
+ ) -> GateReceipt:
458
+ self._ensure_registered()
459
+ data, request_id = self._affix.gate(
460
+ proof=proof.proof,
461
+ circuit_id=proof.circuit_id,
462
+ policy_id=proof.policy_id,
463
+ mode=mode,
464
+ gate_id=model.binding_digest,
465
+ idempotency_key=idempotency_key,
466
+ valid_from=valid_from,
467
+ valid_until=valid_until,
468
+ region_hash=region_hash,
469
+ region_expected=region_expected,
470
+ )
471
+ return GateReceipt.from_response(data, request_id=request_id)
472
+
473
+ def _finalise(
474
+ self,
475
+ operation: str,
476
+ model: ModelRef,
477
+ gate: GateReceipt,
478
+ *,
479
+ output: Any = None,
480
+ ) -> AffixReceipt:
481
+ if self.evidence_mode == "off":
482
+ return AffixReceipt(model=model, operation=operation, gate=gate)
483
+
484
+ payload: dict[str, Any] = {
485
+ "schema": "affix-huggingface-receipt-v1",
486
+ "decision": "yes",
487
+ "operation": operation,
488
+ "model_id": model.model_id,
489
+ "model_revision": model.revision,
490
+ "hosting": model.hosting,
491
+ "provider": model.provider,
492
+ "model_binding_sha256": model.binding_digest,
493
+ "gate_receipt_id": gate.receipt_id,
494
+ "gate_proof_digest": gate.proof_digest,
495
+ "hf_response_id": self._response_id(output),
496
+ "completed_at": datetime.now(timezone.utc).isoformat(),
497
+ "prompt_included": False,
498
+ "output_included": False,
499
+ }
500
+ payload = {key: value for key, value in payload.items() if value is not None}
501
+ try:
502
+ data, request_id = self._affix.attest(
503
+ payload,
504
+ idempotency_key=self._key("completion"),
505
+ )
506
+ attestation = data.get("attestation")
507
+ if not isinstance(attestation, Mapping):
508
+ raise ConfigurationError("AffixIO attest response did not contain an attestation")
509
+ completion = CompletionEvidence(
510
+ payload=payload,
511
+ attestation=dict(attestation),
512
+ request_id=request_id,
513
+ )
514
+ return AffixReceipt(
515
+ model=model,
516
+ operation=operation,
517
+ gate=gate,
518
+ completion=completion,
519
+ )
520
+ except Exception as exc:
521
+ if self.evidence_mode == "required":
522
+ raise EvidenceError(
523
+ "Hugging Face inference completed, but AffixIO evidence failed",
524
+ output=output,
525
+ cause=exc,
526
+ ) from exc
527
+ return AffixReceipt(
528
+ model=model,
529
+ operation=operation,
530
+ gate=gate,
531
+ evidence_error=str(exc),
532
+ )
533
+
534
+ @staticmethod
535
+ def _response_id(output: Any) -> str | None:
536
+ value = getattr(output, "id", None)
537
+ if value is None and isinstance(output, Mapping):
538
+ value = output.get("id")
539
+ return str(value) if value is not None else None
540
+
541
+ @staticmethod
542
+ def _key(operation: str) -> str:
543
+ return f"hf-sdk:{operation}:{uuid.uuid4().hex}"
544
+
src/affix_huggingface/errors.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Exceptions raised by the AffixIO Hugging Face SDK."""
2
+
3
+ # Credit: @paparichens
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+
10
+ class AffixHuggingFaceError(Exception):
11
+ """Base SDK error."""
12
+
13
+
14
+ class ConfigurationError(AffixHuggingFaceError):
15
+ """Required credentials or configuration are missing."""
16
+
17
+
18
+ class AffixAPIError(AffixHuggingFaceError):
19
+ """The AffixIO API returned an error response."""
20
+
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ *,
25
+ status_code: int,
26
+ code: str | None = None,
27
+ request_id: str | None = None,
28
+ body: dict[str, Any] | None = None,
29
+ ) -> None:
30
+ super().__init__(message)
31
+ self.status_code = status_code
32
+ self.code = code
33
+ self.request_id = request_id
34
+ self.body = body or {}
35
+
36
+
37
+ class AdmissionDenied(AffixHuggingFaceError):
38
+ """AffixIO refused the inference request before Hugging Face was called."""
39
+
40
+ def __init__(
41
+ self,
42
+ message: str,
43
+ *,
44
+ reason_code: str,
45
+ receipt_id: str | None = None,
46
+ request_id: str | None = None,
47
+ ) -> None:
48
+ super().__init__(message)
49
+ self.reason_code = reason_code
50
+ self.receipt_id = receipt_id
51
+ self.request_id = request_id
52
+
53
+
54
+ UPGRADE_STEPS = (
55
+ "Your free allowance is used up. To continue with your own key:\n"
56
+ " 1. Register at https://hub.affix-io.com/ and request API access.\n"
57
+ " 2. Access is granted after review, not instantly.\n"
58
+ " 3. Once approved, create a key at https://hub.affix-io.com/credentials/\n"
59
+ " 4. Set AFFIX_API_KEY=aio_... and run again. Your key has no trial cap."
60
+ )
61
+
62
+
63
+ class TrialExhausted(AffixHuggingFaceError):
64
+ """The server-side proof allowance for this subject has been used up."""
65
+
66
+ def __init__(
67
+ self,
68
+ message: str,
69
+ *,
70
+ limit: int,
71
+ used: int,
72
+ request_id: str | None = None,
73
+ ) -> None:
74
+ super().__init__(f"{message}\n\n{UPGRADE_STEPS}")
75
+ self.limit = limit
76
+ self.used = used
77
+ self.remaining = 0
78
+ self.request_id = request_id
79
+ self.upgrade_url = "https://hub.affix-io.com/"
80
+ self.upgrade_steps = UPGRADE_STEPS
81
+
82
+
83
+ class RegistrationThrottled(AffixHuggingFaceError):
84
+ """AffixIO refused to register another new subject from this origin today."""
85
+
86
+
87
+ class EvidenceError(AffixHuggingFaceError):
88
+ """Inference completed, but completion evidence could not be issued."""
89
+
90
+ def __init__(self, message: str, *, output: Any, cause: Exception) -> None:
91
+ super().__init__(message)
92
+ self.output = output
93
+ self.__cause__ = cause
94
+
95
+
96
+ class StreamNotComplete(AffixHuggingFaceError):
97
+ """A final stream receipt was requested before the stream finished."""
98
+
src/affix_huggingface/integrity.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fail-closed integrity guard.
2
+
3
+ A signed file manifest ships inside the built wheel. On import the package
4
+ recomputes the hash of every shipped file and compares it to the manifest. Any
5
+ edit, overwrite, added file, or removed file trips the guard: the package marks
6
+ itself bricked and refuses to run.
7
+
8
+ Honest limit: this is tamper-evident, not tamper-proof. A user who edits the
9
+ guard itself, or deletes the manifest, disables the check, because the code runs
10
+ on their machine. No client-side mechanism can prevent that. What this does
11
+ guarantee is that any accidental or casual modification of a shipped file stops
12
+ the product working rather than running altered code silently.
13
+
14
+ The manifest is generated at build time (see hatch_build.py) and is absent from
15
+ the source tree, so an editable development checkout is not enforced.
16
+ """
17
+
18
+ # Credit: @paparichens
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import json
24
+ from collections.abc import Iterator
25
+ from pathlib import Path
26
+
27
+ MANIFEST_NAME = "_integrity_manifest.json"
28
+ MANIFEST_ALGORITHM = "sha256"
29
+ _SKIP_SUFFIXES = (".pyc", ".pyo")
30
+ _SKIP_DIRS = frozenset({"__pycache__"})
31
+
32
+ _bricked_reason: str | None = None
33
+
34
+
35
+ class IntegrityError(RuntimeError):
36
+ """Raised when a shipped file has been altered, added, or removed."""
37
+
38
+
39
+ def _iter_files(root: Path) -> Iterator[Path]:
40
+ for path in sorted(root.rglob("*")):
41
+ if not path.is_file():
42
+ continue
43
+ if any(part in _SKIP_DIRS for part in path.relative_to(root).parts):
44
+ continue
45
+ if path.suffix in _SKIP_SUFFIXES:
46
+ continue
47
+ if path.name == MANIFEST_NAME:
48
+ continue
49
+ yield path
50
+
51
+
52
+ def _hash_file(path: Path) -> str:
53
+ digest = hashlib.new(MANIFEST_ALGORITHM)
54
+ with path.open("rb") as handle:
55
+ for chunk in iter(lambda: handle.read(65536), b""):
56
+ digest.update(chunk)
57
+ return digest.hexdigest()
58
+
59
+
60
+ def build_manifest(root: Path) -> dict[str, object]:
61
+ """Compute the manifest for a package directory."""
62
+ files = {
63
+ str(path.relative_to(root).as_posix()): _hash_file(path)
64
+ for path in _iter_files(root)
65
+ }
66
+ return {"version": 1, "algorithm": MANIFEST_ALGORITHM, "files": files}
67
+
68
+
69
+ def verify_tree(root: Path) -> None:
70
+ """
71
+ Verify a package directory against its manifest.
72
+
73
+ No manifest present means an unbuilt or editable tree, which is not
74
+ enforced. A present manifest is enforced strictly.
75
+ """
76
+ manifest_path = root / MANIFEST_NAME
77
+ if not manifest_path.exists():
78
+ return
79
+
80
+ try:
81
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
82
+ expected = dict(manifest["files"])
83
+ except (OSError, ValueError, KeyError, TypeError) as exc:
84
+ raise IntegrityError("Integrity manifest is unreadable or malformed") from exc
85
+
86
+ actual = {
87
+ str(path.relative_to(root).as_posix()): _hash_file(path)
88
+ for path in _iter_files(root)
89
+ }
90
+
91
+ added = sorted(set(actual) - set(expected))
92
+ removed = sorted(set(expected) - set(actual))
93
+ changed = sorted(
94
+ name for name in expected.keys() & actual.keys() if expected[name] != actual[name]
95
+ )
96
+
97
+ if added or removed or changed:
98
+ parts = []
99
+ if changed:
100
+ parts.append(f"modified: {', '.join(changed)}")
101
+ if removed:
102
+ parts.append(f"missing: {', '.join(removed)}")
103
+ if added:
104
+ parts.append(f"unexpected: {', '.join(added)}")
105
+ raise IntegrityError(
106
+ "Package integrity check failed (" + "; ".join(parts) + "). "
107
+ "A shipped file was altered. Reinstall a clean copy: "
108
+ "pip install --force-reinstall affix-huggingface"
109
+ )
110
+
111
+
112
+ def enforce() -> None:
113
+ """Run the guard for this installed package and remember the outcome."""
114
+ global _bricked_reason
115
+ try:
116
+ verify_tree(Path(__file__).resolve().parent)
117
+ except IntegrityError as exc:
118
+ _bricked_reason = str(exc)
119
+ raise
120
+
121
+
122
+ def ensure_intact() -> None:
123
+ """Refuse to proceed once the guard has tripped."""
124
+ if _bricked_reason is not None:
125
+ raise IntegrityError(_bricked_reason)
src/affix_huggingface/models.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public value objects for AffixIO-controlled Hugging Face inference."""
2
+
3
+ # Credit: @paparichens
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import json
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Literal
12
+
13
+ HostingClass = Literal["hf_inference", "inference_endpoint", "self_hosted_tgi"]
14
+ GateMode = Literal["check", "consume"]
15
+ EvidenceMode = Literal["required", "best_effort", "off"]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ModelRef:
20
+ """
21
+ A Hugging Face model or remote Inference Endpoint.
22
+
23
+ ``revision`` is declared audit metadata. Hugging Face's public inference
24
+ call does not accept a revision pin, so use a pinned dedicated endpoint
25
+ where exact deployed weights are required.
26
+ """
27
+
28
+ model_id: str
29
+ revision: str | None = None
30
+ endpoint_url: str | None = None
31
+ provider: str | None = None
32
+ hosting: HostingClass = "hf_inference"
33
+
34
+ def __post_init__(self) -> None:
35
+ if not self.model_id.strip():
36
+ raise ValueError("model_id must not be empty")
37
+ if self.endpoint_url and not self.endpoint_url.startswith("https://"):
38
+ raise ValueError("endpoint_url must use HTTPS")
39
+
40
+ @property
41
+ def inference_target(self) -> str:
42
+ return self.endpoint_url or self.model_id
43
+
44
+ def binding(self) -> dict[str, str]:
45
+ values = {
46
+ "model_id": self.model_id,
47
+ "revision": self.revision,
48
+ "endpoint_url": self.endpoint_url,
49
+ "provider": self.provider,
50
+ "hosting": self.hosting,
51
+ }
52
+ return {key: value for key, value in values.items() if value is not None}
53
+
54
+ @property
55
+ def binding_digest(self) -> str:
56
+ canonical = json.dumps(
57
+ self.binding(),
58
+ sort_keys=True,
59
+ separators=(",", ":"),
60
+ ensure_ascii=True,
61
+ ).encode("utf-8")
62
+ return hashlib.sha256(canonical).hexdigest()
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class AdmissionChecks:
67
+ """
68
+ Customer-evaluated predicates submitted for remote AffixIO proving.
69
+
70
+ These values contain no prompt, output, or subject identifier. Applications
71
+ remain responsible for evaluating their own entitlement and policy records.
72
+ """
73
+
74
+ entitled: bool
75
+ data_route_allowed: bool
76
+ controls_satisfied: bool
77
+
78
+ @property
79
+ def allow_hint(self) -> bool:
80
+ return self.entitled and self.data_route_allowed and self.controls_satisfied
81
+
82
+ def prove_fields(self) -> dict[str, bool]:
83
+ return {
84
+ "condition_greater1": self.entitled,
85
+ "condition_greater2": self.data_route_allowed,
86
+ "condition_greater3": self.controls_satisfied,
87
+ }
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class AdmissionProof:
92
+ """Proof material accepted by the AffixIO gate."""
93
+
94
+ proof: str
95
+ circuit_id: str = "yesno"
96
+ policy_id: str = "gate.agent"
97
+
98
+ def __post_init__(self) -> None:
99
+ if not self.proof.strip():
100
+ raise ValueError("proof must not be empty")
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class GateReceipt:
105
+ """AffixIO admission response retained alongside an HF result."""
106
+
107
+ allow: bool
108
+ reason_code: str
109
+ receipt_id: str | None
110
+ request_id: str | None
111
+ proof_digest: str | None
112
+ proof_ref: str | None
113
+ policy_id: str | None
114
+ policy_version: str | None
115
+ mode: str | None
116
+ spent: bool
117
+ merkle_root: str | None
118
+ merkle_leaf_hash: str | None
119
+ attestation: Mapping[str, Any] | None
120
+ raw: Mapping[str, Any] = field(repr=False)
121
+
122
+ @classmethod
123
+ def from_response(
124
+ cls,
125
+ data: Mapping[str, Any],
126
+ *,
127
+ request_id: str | None,
128
+ ) -> GateReceipt:
129
+ return cls(
130
+ allow=bool(data.get("allow", False)),
131
+ reason_code=str(data.get("reason_code") or data.get("error") or "UNKNOWN"),
132
+ receipt_id=_optional_str(data.get("receipt_id")),
133
+ request_id=request_id,
134
+ proof_digest=_optional_str(data.get("proof_digest")),
135
+ proof_ref=_optional_str(data.get("proof_ref")),
136
+ policy_id=_optional_str(data.get("policy_id")),
137
+ policy_version=_optional_str(data.get("policy_version")),
138
+ mode=_optional_str(data.get("mode")),
139
+ spent=bool(data.get("spent", False)),
140
+ merkle_root=_optional_str(data.get("merkle_root")),
141
+ merkle_leaf_hash=_optional_str(data.get("merkle_leaf_hash")),
142
+ attestation=_mapping_or_none(data.get("attestation")),
143
+ raw=dict(data),
144
+ )
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class CompletionEvidence:
149
+ """Post-inference AffixIO attestation over metadata only."""
150
+
151
+ payload: Mapping[str, Any]
152
+ attestation: Mapping[str, Any]
153
+ request_id: str | None
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class AffixReceipt:
158
+ """Admission and optional post-inference evidence."""
159
+
160
+ model: ModelRef
161
+ operation: str
162
+ gate: GateReceipt
163
+ completion: CompletionEvidence | None = None
164
+ evidence_error: str | None = None
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class AuthorisedResult:
169
+ """Hugging Face output paired with its AffixIO receipt."""
170
+
171
+ output: Any
172
+ receipt: AffixReceipt
173
+
174
+
175
+ def _optional_str(value: Any) -> str | None:
176
+ if value is None:
177
+ return None
178
+ return str(value)
179
+
180
+
181
+ def _mapping_or_none(value: Any) -> Mapping[str, Any] | None:
182
+ if isinstance(value, Mapping):
183
+ return dict(value)
184
+ return None
185
+
src/affix_huggingface/py.typed ADDED
@@ -0,0 +1 @@
 
 
1
+
src/affix_huggingface/transport.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Authenticated HTTPS transport for the AffixIO production API."""
2
+
3
+ # Credit: @paparichens
4
+
5
+ from __future__ import annotations
6
+
7
+ import time
8
+ from collections.abc import Mapping
9
+ from typing import Any
10
+ from urllib.parse import quote
11
+
12
+ import httpx
13
+
14
+ from .errors import AffixAPIError, RegistrationThrottled, TrialExhausted
15
+ from .trial import TrialCredentials, TrialQuota
16
+
17
+ RETRYABLE_STATUS = frozenset({429, 502, 503, 504})
18
+ USER_AGENT = "affix-huggingface/0.3.0"
19
+
20
+
21
+ class AffixTransport:
22
+ """Small production transport with bounded, idempotent retries."""
23
+
24
+ def __init__(
25
+ self,
26
+ api_key: str,
27
+ *,
28
+ base_url: str = "https://api.affix-io.com",
29
+ timeout: float = 30.0,
30
+ max_retries: int = 2,
31
+ client: httpx.Client | None = None,
32
+ credentials: TrialCredentials | None = None,
33
+ ) -> None:
34
+ self.api_key = api_key
35
+ self.base_url = base_url.rstrip("/")
36
+ self.timeout = timeout
37
+ self.max_retries = max(0, max_retries)
38
+ self.credentials = credentials
39
+ self.last_quota: TrialQuota | None = None
40
+ self._owns_client = client is None
41
+ self._client = client or httpx.Client(
42
+ base_url=self.base_url,
43
+ timeout=timeout,
44
+ headers={
45
+ "Authorization": f"Bearer {api_key}",
46
+ "X-API-Key": api_key,
47
+ "Accept": "application/json",
48
+ "User-Agent": USER_AGENT,
49
+ },
50
+ )
51
+
52
+ def close(self) -> None:
53
+ if self._owns_client:
54
+ self._client.close()
55
+
56
+ def __enter__(self) -> AffixTransport:
57
+ return self
58
+
59
+ def __exit__(self, *_: object) -> None:
60
+ self.close()
61
+
62
+ def health(self) -> tuple[dict[str, Any], str | None]:
63
+ return self._request("GET", "/api/health")
64
+
65
+ def register_install(
66
+ self,
67
+ *,
68
+ subject_id: str,
69
+ product: str,
70
+ label: str | None = None,
71
+ ) -> tuple[TrialCredentials, TrialQuota | None]:
72
+ """Claim or rejoin the server-side allowance for a subject."""
73
+ body: dict[str, Any] = {"subject": subject_id, "product": product}
74
+ if label:
75
+ body["label"] = label
76
+ data, _ = self._request("POST", "/v1/trial/register", json=body)
77
+ credentials = TrialCredentials(
78
+ install_id=str(data["install_id"]),
79
+ install_secret=str(data["install_secret"]),
80
+ subject_id=str(data.get("subject_id", subject_id)),
81
+ product=str(data.get("product", product)),
82
+ )
83
+ raw_quota = data.get("quota")
84
+ quota = (
85
+ TrialQuota(
86
+ limit=int(raw_quota["limit"]),
87
+ used=int(raw_quota["used"]),
88
+ remaining=int(raw_quota["remaining"]),
89
+ )
90
+ if isinstance(raw_quota, Mapping)
91
+ else None
92
+ )
93
+ return credentials, quota
94
+
95
+ def quota(self) -> TrialQuota:
96
+ """Current allowance for the authenticated install."""
97
+ data, _ = self._request("GET", "/v1/trial/quota")
98
+ return TrialQuota(
99
+ limit=int(data["limit"]),
100
+ used=int(data["used"]),
101
+ remaining=int(data["remaining"]),
102
+ )
103
+
104
+ def prove(
105
+ self,
106
+ *,
107
+ circuit_id: str,
108
+ fields: Mapping[str, str | int | bool],
109
+ idempotency_key: str,
110
+ sector: str = "huggingface_inference",
111
+ ) -> tuple[dict[str, Any], str | None]:
112
+ body = {
113
+ "fields": dict(fields),
114
+ "requestAttestation": True,
115
+ "sector": sector,
116
+ }
117
+ return self._request(
118
+ "POST",
119
+ f"/v1/circuits/{quote(circuit_id, safe='')}/prove",
120
+ json=body,
121
+ idempotency_key=idempotency_key,
122
+ )
123
+
124
+ def gate(
125
+ self,
126
+ *,
127
+ proof: str,
128
+ circuit_id: str,
129
+ policy_id: str,
130
+ mode: str,
131
+ gate_id: str,
132
+ idempotency_key: str,
133
+ valid_from: int | None = None,
134
+ valid_until: int | None = None,
135
+ region_hash: str | None = None,
136
+ region_expected: str | None = None,
137
+ ) -> tuple[dict[str, Any], str | None]:
138
+ body: dict[str, Any] = {
139
+ "proof": proof,
140
+ "circuit_id": circuit_id,
141
+ "policy_id": policy_id,
142
+ "mode": mode,
143
+ "gate_id": gate_id,
144
+ "entry_point": "hf-sdk",
145
+ "requestAttestation": True,
146
+ "sector": "huggingface_inference",
147
+ }
148
+ optional = {
149
+ "valid_from": valid_from,
150
+ "valid_until": valid_until,
151
+ "region_hash": region_hash,
152
+ "region_expected": region_expected,
153
+ }
154
+ body.update({key: value for key, value in optional.items() if value is not None})
155
+ return self._request(
156
+ "POST",
157
+ "/v1/gate/verify",
158
+ json=body,
159
+ idempotency_key=idempotency_key,
160
+ accept_gate_denial=True,
161
+ )
162
+
163
+ def attest(
164
+ self,
165
+ payload: Mapping[str, Any],
166
+ *,
167
+ idempotency_key: str,
168
+ ) -> tuple[dict[str, Any], str | None]:
169
+ return self._request(
170
+ "POST",
171
+ "/api/attest",
172
+ json={"payload": dict(payload)},
173
+ idempotency_key=idempotency_key,
174
+ )
175
+
176
+ def _request(
177
+ self,
178
+ method: str,
179
+ path: str,
180
+ *,
181
+ json: Mapping[str, Any] | None = None,
182
+ idempotency_key: str | None = None,
183
+ accept_gate_denial: bool = False,
184
+ ) -> tuple[dict[str, Any], str | None]:
185
+ headers = {
186
+ "Authorization": f"Bearer {self.api_key}",
187
+ "X-API-Key": self.api_key,
188
+ "Accept": "application/json",
189
+ "User-Agent": USER_AGENT,
190
+ }
191
+ if idempotency_key:
192
+ headers["Idempotency-Key"] = idempotency_key
193
+ if self.credentials is not None:
194
+ headers.update(self.credentials.headers())
195
+ last_transport_error: httpx.TransportError | None = None
196
+
197
+ for attempt in range(self.max_retries + 1):
198
+ try:
199
+ response = self._client.request(
200
+ method,
201
+ self._url(path),
202
+ json=json,
203
+ headers=headers,
204
+ timeout=self.timeout,
205
+ )
206
+ except httpx.TransportError as exc:
207
+ last_transport_error = exc
208
+ if attempt >= self.max_retries:
209
+ raise AffixAPIError(
210
+ f"AffixIO transport failed: {exc}",
211
+ status_code=0,
212
+ code="transport_error",
213
+ ) from exc
214
+ self._sleep(attempt, None)
215
+ continue
216
+
217
+ data = self._json_object(response)
218
+ request_id = response.headers.get("X-Request-Id")
219
+ quota = TrialQuota.from_headers(response.headers)
220
+ if quota is not None:
221
+ self.last_quota = quota
222
+ if response.status_code < 400:
223
+ return data, request_id
224
+ self._raise_for_trial(data, request_id)
225
+ if accept_gate_denial and data.get("allow") is False:
226
+ return data, request_id
227
+ if response.status_code in RETRYABLE_STATUS and attempt < self.max_retries:
228
+ self._sleep(attempt, response.headers.get("Retry-After"))
229
+ continue
230
+ raise AffixAPIError(
231
+ str(data.get("message") or data.get("error") or f"HTTP {response.status_code}"),
232
+ status_code=response.status_code,
233
+ code=str(data.get("error") or f"http_{response.status_code}"),
234
+ request_id=request_id,
235
+ body=data,
236
+ )
237
+
238
+ raise AffixAPIError(
239
+ f"AffixIO transport failed: {last_transport_error}",
240
+ status_code=0,
241
+ code="transport_error",
242
+ )
243
+
244
+ @staticmethod
245
+ def _raise_for_trial(data: Mapping[str, Any], request_id: str | None) -> None:
246
+ error = str(data.get("error") or "")
247
+ if error == "trial_exhausted":
248
+ limit = int(data.get("limit") or 0)
249
+ used = int(data.get("used") or limit)
250
+ raise TrialExhausted(
251
+ str(data.get("message") or "Trial allowance used up"),
252
+ limit=limit,
253
+ used=used,
254
+ request_id=request_id,
255
+ )
256
+ if error == "registration_throttled":
257
+ raise RegistrationThrottled(
258
+ str(data.get("message") or "Too many new trial subjects from this origin")
259
+ )
260
+
261
+ def _url(self, path: str) -> str:
262
+ if self._owns_client:
263
+ return path
264
+ return f"{self.base_url}{path}"
265
+
266
+ @staticmethod
267
+ def _json_object(response: httpx.Response) -> dict[str, Any]:
268
+ try:
269
+ data = response.json()
270
+ except ValueError:
271
+ return {"error": "invalid_json", "message": response.text[:500]}
272
+ if isinstance(data, dict):
273
+ return data
274
+ return {"data": data}
275
+
276
+ @staticmethod
277
+ def _sleep(attempt: int, retry_after: str | None) -> None:
278
+ if retry_after:
279
+ try:
280
+ delay = min(max(float(retry_after), 0.0), 5.0)
281
+ except ValueError:
282
+ delay = 0.25 * (2**attempt)
283
+ else:
284
+ delay = 0.25 * (2**attempt)
285
+ time.sleep(delay)
286
+
src/affix_huggingface/trial.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trial install registration and the local credential cache.
2
+
3
+ The proof allowance is counted on api.affix-io.com against a subject digest.
4
+ Files written here are a cache. Deleting them forces re-registration against
5
+ the same server-side counter and does not restore spent proofs.
6
+ """
7
+
8
+ # Credit: @paparichens
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from .errors import ConfigurationError
20
+
21
+ TRIAL_PRODUCT = "huggingface"
22
+
23
+ # Published trial key. It ships inside the package, so treat it as public:
24
+ # it is scoped, rate limited, and revocable. Supply AFFIX_API_KEY for
25
+ # production use.
26
+ PUBLIC_TRIAL_API_KEY = "aio_50754e6f66c7a573a46a16a52e1b1edffba48c395b708316"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class TrialCredentials:
31
+ """Install credentials issued by AffixIO and cached locally."""
32
+
33
+ install_id: str
34
+ install_secret: str
35
+ subject_id: str
36
+ product: str = TRIAL_PRODUCT
37
+
38
+ def headers(self) -> dict[str, str]:
39
+ return {
40
+ "X-Affix-Install-Id": self.install_id,
41
+ "X-Affix-Install-Secret": self.install_secret,
42
+ }
43
+
44
+ def to_dict(self) -> dict[str, str]:
45
+ return {
46
+ "install_id": self.install_id,
47
+ "install_secret": self.install_secret,
48
+ "subject_id": self.subject_id,
49
+ "product": self.product,
50
+ }
51
+
52
+ @classmethod
53
+ def from_dict(cls, data: dict[str, Any]) -> TrialCredentials | None:
54
+ try:
55
+ return cls(
56
+ install_id=str(data["install_id"]),
57
+ install_secret=str(data["install_secret"]),
58
+ subject_id=str(data["subject_id"]),
59
+ product=str(data.get("product", TRIAL_PRODUCT)),
60
+ )
61
+ except (KeyError, TypeError):
62
+ return None
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class TrialQuota:
67
+ """Allowance reported by AffixIO."""
68
+
69
+ limit: int
70
+ used: int
71
+ remaining: int
72
+
73
+ @property
74
+ def exhausted(self) -> bool:
75
+ return self.remaining <= 0
76
+
77
+ @classmethod
78
+ def from_headers(cls, headers: Any) -> TrialQuota | None:
79
+ try:
80
+ limit = headers.get("X-Affix-Trial-Limit")
81
+ used = headers.get("X-Affix-Trial-Used")
82
+ remaining = headers.get("X-Affix-Trial-Remaining")
83
+ except AttributeError:
84
+ return None
85
+ if limit is None or used is None or remaining is None:
86
+ return None
87
+ try:
88
+ return cls(limit=int(limit), used=int(used), remaining=int(remaining))
89
+ except (TypeError, ValueError):
90
+ return None
91
+
92
+
93
+ def credentials_path() -> Path:
94
+ """Cache location for install credentials."""
95
+ override = os.getenv("AFFIX_INSTALL_FILE")
96
+ if override:
97
+ return Path(override).expanduser()
98
+ base = os.getenv("XDG_CACHE_HOME")
99
+ root = Path(base).expanduser() if base else Path.home() / ".cache"
100
+ return root / "affix-huggingface" / "install.json"
101
+
102
+
103
+ def load_credentials(path: Path | None = None) -> TrialCredentials | None:
104
+ target = path or credentials_path()
105
+ try:
106
+ raw = json.loads(target.read_text(encoding="utf-8"))
107
+ except (OSError, ValueError):
108
+ return None
109
+ if not isinstance(raw, dict):
110
+ return None
111
+ return TrialCredentials.from_dict(raw)
112
+
113
+
114
+ def save_credentials(credentials: TrialCredentials, path: Path | None = None) -> None:
115
+ target = path or credentials_path()
116
+ try:
117
+ target.parent.mkdir(parents=True, exist_ok=True)
118
+ target.write_text(
119
+ json.dumps(credentials.to_dict(), indent=2) + "\n",
120
+ encoding="utf-8",
121
+ )
122
+ target.chmod(0o600)
123
+ except OSError:
124
+ # A read-only cache directory costs a registration round trip per run,
125
+ # it does not grant extra proofs.
126
+ return
127
+
128
+
129
+ def subject_digest(raw: str) -> str:
130
+ """Hash a subject locally so the identity itself never leaves the host."""
131
+ value = raw.strip().lower()
132
+ if not value:
133
+ raise ConfigurationError("Trial subject must not be empty")
134
+ return hashlib.sha256(f"affix-hf-subject:{value}".encode()).hexdigest()
135
+
136
+
137
+ def resolve_subject(
138
+ *,
139
+ subject: str | None = None,
140
+ hf_token: str | None = None,
141
+ ) -> str:
142
+ """
143
+ Determine the identity the allowance is counted against.
144
+
145
+ Order of preference: explicit subject, AFFIX_TRIAL_SUBJECT, then the
146
+ Hugging Face account behind the token. Resolution fails closed, because a
147
+ random fallback would hand out a fresh allowance on every install.
148
+ """
149
+ explicit = subject or os.getenv("AFFIX_TRIAL_SUBJECT")
150
+ if explicit:
151
+ return subject_digest(explicit)
152
+
153
+ token = hf_token or os.getenv("HF_TOKEN")
154
+ if not token:
155
+ raise ConfigurationError(
156
+ "Cannot determine a trial subject. Set HF_TOKEN so the Hugging Face "
157
+ "account can be resolved, or pass subject= explicitly."
158
+ )
159
+
160
+ try:
161
+ from huggingface_hub import whoami
162
+
163
+ info = whoami(token=token)
164
+ except Exception as exc:
165
+ raise ConfigurationError(
166
+ "Could not resolve the Hugging Face account for the trial allowance. "
167
+ "Pass subject= explicitly if this host cannot reach huggingface.co."
168
+ ) from exc
169
+
170
+ name = None
171
+ if isinstance(info, dict):
172
+ name = info.get("id") or info.get("name")
173
+ else:
174
+ name = getattr(info, "id", None) or getattr(info, "name", None)
175
+ if not name:
176
+ raise ConfigurationError("Hugging Face account lookup returned no identity")
177
+ return subject_digest(f"hf:{name}")
tests/test_client.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Network-free contract tests for the AffixIO Hugging Face SDK."""
2
+
3
+ # Credit: @paparichens
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ import pytest
10
+
11
+ from affix_huggingface import (
12
+ AdmissionChecks,
13
+ AdmissionDenied,
14
+ AdmissionProof,
15
+ AffixHuggingFace,
16
+ ModelRef,
17
+ StreamNotComplete,
18
+ )
19
+
20
+
21
+ class FakeAffixTransport:
22
+ def __init__(self, *, allow: bool = True) -> None:
23
+ self.allow = allow
24
+ self.prove_calls: list[dict[str, Any]] = []
25
+ self.gate_calls: list[dict[str, Any]] = []
26
+ self.attest_calls: list[dict[str, Any]] = []
27
+
28
+ def close(self) -> None:
29
+ return None
30
+
31
+ def health(self) -> tuple[dict[str, Any], str | None]:
32
+ return {"ok": True}, "req_health"
33
+
34
+ def prove(self, **kwargs: Any) -> tuple[dict[str, Any], str | None]:
35
+ self.prove_calls.append(kwargs)
36
+ return {"proof": "proof_hex", "valid": True}, "req_prove"
37
+
38
+ def gate(self, **kwargs: Any) -> tuple[dict[str, Any], str | None]:
39
+ self.gate_calls.append(kwargs)
40
+ return (
41
+ {
42
+ "allow": self.allow,
43
+ "reason_code": "ADMITTED" if self.allow else "INVALID_PROOF",
44
+ "receipt_id": "rcpt_1",
45
+ "proof_digest": "abc123",
46
+ "proof_ref": "1:abc123",
47
+ "policy_id": "gate.agent",
48
+ "policy_version": "2026.08",
49
+ "mode": kwargs["mode"],
50
+ "spent": self.allow and kwargs["mode"] == "consume",
51
+ "merkle_root": "root_1",
52
+ "merkle_leaf_hash": "leaf_1",
53
+ "attestation": {"algorithm": "ML-DSA-65"},
54
+ },
55
+ "req_gate",
56
+ )
57
+
58
+ def attest(self, payload: dict[str, Any], **kwargs: Any) -> tuple[dict[str, Any], str | None]:
59
+ self.attest_calls.append({"payload": payload, **kwargs})
60
+ return {"attestation": {"algorithm": "ML-DSA-65", "signature": "sig"}}, "req_attest"
61
+
62
+
63
+ class FakeHfClient:
64
+ def __init__(self) -> None:
65
+ self.calls: list[dict[str, Any]] = []
66
+
67
+ def chat_completion(
68
+ self,
69
+ messages: list[dict[str, Any]],
70
+ *,
71
+ model: str,
72
+ **kwargs: Any,
73
+ ) -> dict[str, Any] | list[str]:
74
+ self.calls.append(
75
+ {"method": "chat_completion", "messages": messages, "model": model, **kwargs}
76
+ )
77
+ if kwargs.get("stream"):
78
+ return ["token-1", "token-2"]
79
+ return {"id": "hf_response_1", "choices": []}
80
+
81
+ def text_generation(self, prompt: str, *, model: str, **kwargs: Any) -> str | list[str]:
82
+ self.calls.append(
83
+ {"method": "text_generation", "prompt": prompt, "model": model, **kwargs}
84
+ )
85
+ if kwargs.get("stream"):
86
+ return ["first", "second"]
87
+ return "generated"
88
+
89
+ def feature_extraction(
90
+ self,
91
+ text: str | list[str],
92
+ *,
93
+ model: str,
94
+ **kwargs: Any,
95
+ ) -> list[list[float]]:
96
+ self.calls.append(
97
+ {"method": "feature_extraction", "text": text, "model": model, **kwargs}
98
+ )
99
+ return [[0.1, 0.2]]
100
+
101
+
102
+ def make_client(
103
+ *,
104
+ affix: FakeAffixTransport | None = None,
105
+ hf: FakeHfClient | None = None,
106
+ evidence_mode: str = "required",
107
+ ) -> tuple[AffixHuggingFace, FakeAffixTransport, FakeHfClient]:
108
+ affix = affix or FakeAffixTransport()
109
+ hf = hf or FakeHfClient()
110
+ client = AffixHuggingFace(
111
+ affix_transport=affix, # type: ignore[arg-type]
112
+ hf_client=hf,
113
+ evidence_mode=evidence_mode, # type: ignore[arg-type]
114
+ )
115
+ return client, affix, hf
116
+
117
+
118
+ def test_model_binding_is_deterministic_and_excludes_content() -> None:
119
+ model = ModelRef(
120
+ model_id="org/model",
121
+ revision="abc",
122
+ provider="hf-inference",
123
+ )
124
+ assert len(model.binding_digest) == 64
125
+ assert model.binding_digest == ModelRef(
126
+ model_id="org/model",
127
+ revision="abc",
128
+ provider="hf-inference",
129
+ ).binding_digest
130
+ assert "prompt" not in model.binding()
131
+
132
+
133
+ def test_denial_happens_before_hugging_face_call() -> None:
134
+ client, _, hf = make_client(affix=FakeAffixTransport(allow=False))
135
+ with pytest.raises(AdmissionDenied) as exc:
136
+ client.chat_completion(
137
+ [{"role": "user", "content": "private prompt"}],
138
+ model=ModelRef("org/model"),
139
+ proof=AdmissionProof("bad-proof"),
140
+ )
141
+ assert exc.value.reason_code == "INVALID_PROOF"
142
+ assert hf.calls == []
143
+
144
+
145
+ def test_authorised_chat_returns_output_and_evidence() -> None:
146
+ client, affix, hf = make_client()
147
+ result = client.chat_completion(
148
+ [{"role": "user", "content": "private prompt"}],
149
+ model=ModelRef("org/model", revision="commit-sha"),
150
+ proof=AdmissionProof("proof_hex"),
151
+ max_tokens=20,
152
+ )
153
+
154
+ assert result.output["id"] == "hf_response_1"
155
+ assert result.receipt.gate.allow is True
156
+ assert result.receipt.completion is not None
157
+ assert hf.calls[0]["messages"][0]["content"] == "private prompt"
158
+ assert "private prompt" not in repr(affix.gate_calls)
159
+ assert "private prompt" not in repr(affix.attest_calls)
160
+ assert affix.attest_calls[0]["payload"]["prompt_included"] is False
161
+ assert affix.attest_calls[0]["payload"]["output_included"] is False
162
+
163
+
164
+ def test_checks_use_remote_prove_then_consume() -> None:
165
+ client, affix, _ = make_client(evidence_mode="off")
166
+ client.text_generation(
167
+ "private input",
168
+ model=ModelRef("org/model"),
169
+ checks=AdmissionChecks(
170
+ entitled=True,
171
+ data_route_allowed=True,
172
+ controls_satisfied=True,
173
+ ),
174
+ )
175
+ assert affix.prove_calls[0]["fields"] == {
176
+ "condition_greater1": True,
177
+ "condition_greater2": True,
178
+ "condition_greater3": True,
179
+ }
180
+ assert affix.gate_calls[0]["mode"] == "consume"
181
+ assert "private input" not in repr(affix.prove_calls)
182
+ assert "private input" not in repr(affix.gate_calls)
183
+
184
+
185
+ def test_stream_attests_only_after_consumption() -> None:
186
+ client, affix, _ = make_client()
187
+ stream = client.stream_text_generation(
188
+ "private input",
189
+ model=ModelRef("org/model"),
190
+ proof=AdmissionProof("proof_hex"),
191
+ )
192
+ with pytest.raises(StreamNotComplete):
193
+ _ = stream.final_receipt
194
+ assert affix.attest_calls == []
195
+ assert list(stream) == ["first", "second"]
196
+ assert stream.complete is True
197
+ assert stream.final_receipt.completion is not None
198
+ assert len(affix.attest_calls) == 1
199
+
200
+
201
+ def test_inspect_does_not_call_hugging_face() -> None:
202
+ client, affix, hf = make_client(evidence_mode="off")
203
+ receipt = client.inspect(
204
+ ModelRef("org/model"),
205
+ proof=AdmissionProof("proof_hex"),
206
+ )
207
+ assert receipt.allow is True
208
+ assert receipt.mode == "check"
209
+ assert affix.gate_calls[0]["mode"] == "check"
210
+ assert hf.calls == []
211
+
tests/test_integrity.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integrity guard: matching manifest passes, any change bricks."""
2
+
3
+ # Credit: @paparichens
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from affix_huggingface.integrity import (
13
+ MANIFEST_NAME,
14
+ IntegrityError,
15
+ build_manifest,
16
+ verify_tree,
17
+ )
18
+
19
+
20
+ def _package(root: Path) -> Path:
21
+ pkg = root / "pkg"
22
+ pkg.mkdir()
23
+ (pkg / "__init__.py").write_text("VALUE = 1\n", encoding="utf-8")
24
+ (pkg / "core.py").write_text("def run():\n return True\n", encoding="utf-8")
25
+ (pkg / "py.typed").write_text("\n", encoding="utf-8")
26
+ return pkg
27
+
28
+
29
+ def _write_manifest(pkg: Path) -> None:
30
+ manifest = build_manifest(pkg)
31
+ (pkg / MANIFEST_NAME).write_text(json.dumps(manifest), encoding="utf-8")
32
+
33
+
34
+ def test_unbuilt_tree_without_manifest_is_not_enforced(tmp_path: Path) -> None:
35
+ pkg = _package(tmp_path)
36
+ verify_tree(pkg) # no manifest, no raise
37
+
38
+
39
+ def test_matching_manifest_passes(tmp_path: Path) -> None:
40
+ pkg = _package(tmp_path)
41
+ _write_manifest(pkg)
42
+ verify_tree(pkg)
43
+
44
+
45
+ def test_editing_a_file_bricks(tmp_path: Path) -> None:
46
+ pkg = _package(tmp_path)
47
+ _write_manifest(pkg)
48
+ (pkg / "core.py").write_text("def run():\n return False\n", encoding="utf-8")
49
+ with pytest.raises(IntegrityError, match="modified"):
50
+ verify_tree(pkg)
51
+
52
+
53
+ def test_adding_a_file_bricks(tmp_path: Path) -> None:
54
+ pkg = _package(tmp_path)
55
+ _write_manifest(pkg)
56
+ (pkg / "extra.py").write_text("x = 1\n", encoding="utf-8")
57
+ with pytest.raises(IntegrityError, match="unexpected"):
58
+ verify_tree(pkg)
59
+
60
+
61
+ def test_removing_a_file_bricks(tmp_path: Path) -> None:
62
+ pkg = _package(tmp_path)
63
+ _write_manifest(pkg)
64
+ (pkg / "core.py").unlink()
65
+ with pytest.raises(IntegrityError, match="missing"):
66
+ verify_tree(pkg)
67
+
68
+
69
+ def test_corrupt_manifest_bricks(tmp_path: Path) -> None:
70
+ pkg = _package(tmp_path)
71
+ (pkg / MANIFEST_NAME).write_text("{ not json", encoding="utf-8")
72
+ with pytest.raises(IntegrityError, match="malformed"):
73
+ verify_tree(pkg)
74
+
75
+
76
+ def test_manifest_excludes_itself_and_pyc(tmp_path: Path) -> None:
77
+ pkg = _package(tmp_path)
78
+ _write_manifest(pkg)
79
+ cache = pkg / "__pycache__"
80
+ cache.mkdir()
81
+ (cache / "core.cpython-312.pyc").write_bytes(b"\x00\x01")
82
+ verify_tree(pkg) # bytecode and manifest are ignored
tests/test_transport.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Transport contracts for the AffixIO production API client."""
2
+
3
+ # Credit: @paparichens
4
+
5
+ from __future__ import annotations
6
+
7
+ import httpx
8
+ import pytest
9
+
10
+ from affix_huggingface.errors import AffixAPIError
11
+ from affix_huggingface.transport import AffixTransport
12
+
13
+
14
+ def test_prove_sends_auth_idempotency_and_encoded_circuit() -> None:
15
+ captured: list[httpx.Request] = []
16
+
17
+ def handler(request: httpx.Request) -> httpx.Response:
18
+ captured.append(request)
19
+ return httpx.Response(
20
+ 200,
21
+ json={"proof": "proof_hex"},
22
+ headers={"X-Request-Id": "req_1"},
23
+ )
24
+
25
+ http = httpx.Client(transport=httpx.MockTransport(handler))
26
+ transport = AffixTransport("aio_test", client=http)
27
+ data, request_id = transport.prove(
28
+ circuit_id="yes/no",
29
+ fields={"condition_greater1": True},
30
+ idempotency_key="idem_1",
31
+ )
32
+
33
+ assert data["proof"] == "proof_hex"
34
+ assert request_id == "req_1"
35
+ assert captured[0].url.raw_path == b"/v1/circuits/yes%2Fno/prove"
36
+ assert captured[0].headers["Authorization"] == "Bearer aio_test"
37
+ assert captured[0].headers["X-API-Key"] == "aio_test"
38
+ assert captured[0].headers["Idempotency-Key"] == "idem_1"
39
+
40
+
41
+ def test_gate_returns_structured_denial_from_403() -> None:
42
+ def handler(_: httpx.Request) -> httpx.Response:
43
+ return httpx.Response(
44
+ 403,
45
+ json={
46
+ "allow": False,
47
+ "reason_code": "REGION_MISMATCH",
48
+ "receipt_id": "rcpt_denied",
49
+ },
50
+ )
51
+
52
+ http = httpx.Client(transport=httpx.MockTransport(handler))
53
+ transport = AffixTransport("aio_test", client=http)
54
+ data, _ = transport.gate(
55
+ proof="proof_hex",
56
+ circuit_id="yesno",
57
+ policy_id="gate.agent",
58
+ mode="consume",
59
+ gate_id="a" * 64,
60
+ idempotency_key="idem_gate",
61
+ )
62
+ assert data["allow"] is False
63
+ assert data["reason_code"] == "REGION_MISMATCH"
64
+
65
+
66
+ def test_retryable_response_is_retried(monkeypatch: pytest.MonkeyPatch) -> None:
67
+ attempts = 0
68
+
69
+ def handler(_: httpx.Request) -> httpx.Response:
70
+ nonlocal attempts
71
+ attempts += 1
72
+ if attempts == 1:
73
+ return httpx.Response(503, json={"error": "unavailable"})
74
+ return httpx.Response(200, json={"ok": True})
75
+
76
+ monkeypatch.setattr(AffixTransport, "_sleep", staticmethod(lambda *_: None))
77
+ http = httpx.Client(transport=httpx.MockTransport(handler))
78
+ transport = AffixTransport("aio_test", client=http, max_retries=1)
79
+ data, _ = transport.health()
80
+ assert data == {"ok": True}
81
+ assert attempts == 2
82
+
83
+
84
+ def test_non_retryable_error_preserves_api_fields() -> None:
85
+ def handler(_: httpx.Request) -> httpx.Response:
86
+ return httpx.Response(
87
+ 401,
88
+ json={"error": "unauthorised", "message": "API key rejected"},
89
+ headers={"X-Request-Id": "req_auth"},
90
+ )
91
+
92
+ http = httpx.Client(transport=httpx.MockTransport(handler))
93
+ transport = AffixTransport("bad_key", client=http)
94
+ with pytest.raises(AffixAPIError) as exc:
95
+ transport.health()
96
+ assert exc.value.status_code == 401
97
+ assert exc.value.code == "unauthorised"
98
+ assert exc.value.request_id == "req_auth"
99
+
tests/test_trial.py ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trial allowance behaviour, including reinstall and exhaustion."""
2
+
3
+ # Credit: @paparichens
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import httpx
11
+ import pytest
12
+ from test_client import FakeHfClient
13
+
14
+ from affix_huggingface import (
15
+ AdmissionChecks,
16
+ AdmissionProof,
17
+ AffixHuggingFace,
18
+ ModelRef,
19
+ TrialExhausted,
20
+ )
21
+ from affix_huggingface.errors import ConfigurationError, RegistrationThrottled
22
+ from affix_huggingface.transport import AffixTransport
23
+ from affix_huggingface.trial import (
24
+ TrialCredentials,
25
+ load_credentials,
26
+ resolve_subject,
27
+ save_credentials,
28
+ subject_digest,
29
+ )
30
+
31
+ PASSING_CHECKS = AdmissionChecks(
32
+ entitled=True,
33
+ data_route_allowed=True,
34
+ controls_satisfied=True,
35
+ )
36
+
37
+
38
+ class FakeQuotaServer:
39
+ """Minimal stand-in for the AffixIO trial endpoints."""
40
+
41
+ def __init__(self, *, limit: int = 100) -> None:
42
+ self.limit = limit
43
+ self.subject_used: dict[str, int] = {}
44
+ self.installs: dict[str, str] = {}
45
+ self.install_counter = 0
46
+ self.registrations = 0
47
+
48
+ def handler(self, request: httpx.Request) -> httpx.Response:
49
+ path = request.url.path
50
+ if path == "/v1/trial/register":
51
+ return self._register(request)
52
+ install_id = request.headers.get("X-Affix-Install-Id")
53
+ subject = self.installs.get(install_id or "")
54
+ if subject is None:
55
+ return httpx.Response(403, json={"error": "install_rejected"})
56
+ used = self.subject_used.get(subject, 0)
57
+ remaining = max(0, self.limit - used)
58
+ headers = {
59
+ "X-Affix-Trial-Limit": str(self.limit),
60
+ "X-Affix-Trial-Used": str(used),
61
+ "X-Affix-Trial-Remaining": str(remaining),
62
+ }
63
+ if path.endswith("/prove"):
64
+ if remaining <= 0:
65
+ return httpx.Response(
66
+ 403,
67
+ json={
68
+ "error": "trial_exhausted",
69
+ "reason_code": "TRIAL_EXHAUSTED",
70
+ "message": "Trial allowance used up",
71
+ "limit": self.limit,
72
+ "used": used,
73
+ },
74
+ headers=headers,
75
+ )
76
+ self.subject_used[subject] = used + 1
77
+ return httpx.Response(200, json={"proof": "proof_hex"}, headers=headers)
78
+ if path == "/v1/gate/verify":
79
+ return httpx.Response(
80
+ 200,
81
+ json={"allow": True, "reason_code": "ADMITTED", "mode": "consume"},
82
+ headers=headers,
83
+ )
84
+ if path == "/api/attest":
85
+ return httpx.Response(200, json={"attestation": {"algorithm": "ML-DSA-65"}})
86
+ if path == "/v1/trial/quota":
87
+ return httpx.Response(
88
+ 200,
89
+ json={"limit": self.limit, "used": used, "remaining": remaining},
90
+ headers=headers,
91
+ )
92
+ return httpx.Response(200, json={"ok": True}, headers=headers)
93
+
94
+ def _register(self, request: httpx.Request) -> httpx.Response:
95
+ import json as jsonlib
96
+
97
+ body = jsonlib.loads(request.content.decode("utf-8"))
98
+ subject = str(body["subject"])
99
+ self.registrations += 1
100
+ self.install_counter += 1
101
+ install_id = f"ins_{self.install_counter}"
102
+ self.installs[install_id] = subject
103
+ used = self.subject_used.setdefault(subject, 0)
104
+ return httpx.Response(
105
+ 201,
106
+ json={
107
+ "install_id": install_id,
108
+ "install_secret": "secret-value",
109
+ "subject_id": subject,
110
+ "product": "huggingface",
111
+ "quota": {
112
+ "limit": self.limit,
113
+ "used": used,
114
+ "remaining": max(0, self.limit - used),
115
+ },
116
+ },
117
+ )
118
+
119
+
120
+ @pytest.fixture()
121
+ def cache_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
122
+ path = tmp_path / "install.json"
123
+ monkeypatch.setenv("AFFIX_INSTALL_FILE", str(path))
124
+ monkeypatch.setenv("AFFIX_TRIAL_SUBJECT", "tester@example.com")
125
+ monkeypatch.setenv("HF_TOKEN", "hf_test_token")
126
+ return path
127
+
128
+
129
+ def build_client(server: FakeQuotaServer) -> tuple[AffixHuggingFace, FakeHfClient]:
130
+ http = httpx.Client(transport=httpx.MockTransport(server.handler))
131
+ transport = AffixTransport("aio_public_trial", client=http)
132
+ hf = FakeHfClient()
133
+ client = AffixHuggingFace(
134
+ affix_transport=transport,
135
+ hf_client=hf,
136
+ trial=True,
137
+ evidence_mode="off",
138
+ )
139
+ return client, hf
140
+
141
+
142
+ def test_first_use_registers_and_caches(cache_file: Path) -> None:
143
+ server = FakeQuotaServer()
144
+ client, _ = build_client(server)
145
+
146
+ client.text_generation("prompt", model=ModelRef("org/model"), checks=PASSING_CHECKS)
147
+
148
+ assert server.registrations == 1
149
+ assert cache_file.exists()
150
+ cached = load_credentials(cache_file)
151
+ assert cached is not None
152
+ assert cached.install_id == "ins_1"
153
+
154
+
155
+ def test_cached_install_is_reused(cache_file: Path) -> None:
156
+ server = FakeQuotaServer()
157
+ client, _ = build_client(server)
158
+
159
+ client.text_generation("one", model=ModelRef("org/model"), checks=PASSING_CHECKS)
160
+ client.text_generation("two", model=ModelRef("org/model"), checks=PASSING_CHECKS)
161
+
162
+ assert server.registrations == 1
163
+ assert server.subject_used[subject_digest("tester@example.com")] == 2
164
+
165
+
166
+ def test_deleting_the_cache_does_not_restore_proofs(cache_file: Path) -> None:
167
+ server = FakeQuotaServer(limit=3)
168
+ client, _ = build_client(server)
169
+ for _ in range(3):
170
+ client.text_generation("prompt", model=ModelRef("org/model"), checks=PASSING_CHECKS)
171
+
172
+ # Simulate uninstall: local credentials are gone.
173
+ cache_file.unlink()
174
+ assert load_credentials(cache_file) is None
175
+
176
+ fresh_client, fresh_hf = build_client(server)
177
+ with pytest.raises(TrialExhausted) as exc:
178
+ fresh_client.text_generation("prompt", model=ModelRef("org/model"), checks=PASSING_CHECKS)
179
+
180
+ assert server.registrations == 2
181
+ assert exc.value.limit == 3
182
+ assert fresh_hf.calls == []
183
+
184
+
185
+ def test_exhaustion_blocks_hugging_face(cache_file: Path) -> None:
186
+ server = FakeQuotaServer(limit=2)
187
+ client, hf = build_client(server)
188
+ client.text_generation("one", model=ModelRef("org/model"), checks=PASSING_CHECKS)
189
+ client.text_generation("two", model=ModelRef("org/model"), checks=PASSING_CHECKS)
190
+ assert len(hf.calls) == 2
191
+
192
+ with pytest.raises(TrialExhausted):
193
+ client.text_generation("three", model=ModelRef("org/model"), checks=PASSING_CHECKS)
194
+ assert len(hf.calls) == 2
195
+
196
+
197
+ def test_pre_issued_proof_still_reports_quota(cache_file: Path) -> None:
198
+ server = FakeQuotaServer()
199
+ client, _ = build_client(server)
200
+ client.chat_completion(
201
+ [{"role": "user", "content": "hello"}],
202
+ model=ModelRef("org/model"),
203
+ proof=AdmissionProof("proof_hex"),
204
+ )
205
+ quota = client.quota
206
+ assert quota is not None
207
+ assert quota.limit == 100
208
+ assert quota.remaining == 100
209
+
210
+
211
+ def test_refresh_quota_reads_server_state(cache_file: Path) -> None:
212
+ server = FakeQuotaServer(limit=10)
213
+ client, _ = build_client(server)
214
+ client.text_generation("one", model=ModelRef("org/model"), checks=PASSING_CHECKS)
215
+
216
+ quota = client.refresh_quota()
217
+ assert quota.used == 1
218
+ assert quota.remaining == 9
219
+
220
+
221
+ def test_install_headers_are_sent(cache_file: Path) -> None:
222
+ seen: list[httpx.Request] = []
223
+ server = FakeQuotaServer()
224
+
225
+ def handler(request: httpx.Request) -> httpx.Response:
226
+ seen.append(request)
227
+ return server.handler(request)
228
+
229
+ http = httpx.Client(transport=httpx.MockTransport(handler))
230
+ transport = AffixTransport("aio_public_trial", client=http)
231
+ client = AffixHuggingFace(
232
+ affix_transport=transport,
233
+ hf_client=FakeHfClient(),
234
+ trial=True,
235
+ evidence_mode="off",
236
+ )
237
+ client.text_generation("prompt", model=ModelRef("org/model"), checks=PASSING_CHECKS)
238
+
239
+ prove = next(r for r in seen if r.url.path.endswith("/prove"))
240
+ assert prove.headers["X-Affix-Install-Id"] == "ins_1"
241
+ assert prove.headers["X-Affix-Install-Secret"] == "secret-value"
242
+
243
+
244
+ def test_registration_throttle_surfaces_clearly(cache_file: Path) -> None:
245
+ def handler(request: httpx.Request) -> httpx.Response:
246
+ return httpx.Response(
247
+ 429,
248
+ json={
249
+ "error": "registration_throttled",
250
+ "message": "Too many new trial subjects from this origin today.",
251
+ },
252
+ )
253
+
254
+ http = httpx.Client(transport=httpx.MockTransport(handler))
255
+ transport = AffixTransport("aio_public_trial", client=http, max_retries=0)
256
+ client = AffixHuggingFace(
257
+ affix_transport=transport,
258
+ hf_client=FakeHfClient(),
259
+ trial=True,
260
+ evidence_mode="off",
261
+ )
262
+ with pytest.raises(RegistrationThrottled):
263
+ client.text_generation("prompt", model=ModelRef("org/model"), checks=PASSING_CHECKS)
264
+
265
+
266
+ def test_subject_resolution_is_stable_and_opaque() -> None:
267
+ first = resolve_subject(subject="Tester@Example.com")
268
+ second = resolve_subject(subject=" tester@example.com ")
269
+ assert first == second
270
+ assert "tester" not in first
271
+ assert len(first) == 64
272
+
273
+
274
+ def test_subject_resolution_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
275
+ monkeypatch.delenv("AFFIX_TRIAL_SUBJECT", raising=False)
276
+ monkeypatch.delenv("HF_TOKEN", raising=False)
277
+ with pytest.raises(ConfigurationError):
278
+ resolve_subject()
279
+
280
+
281
+ def test_credentials_round_trip(tmp_path: Path) -> None:
282
+ path = tmp_path / "install.json"
283
+ credentials = TrialCredentials(
284
+ install_id="ins_x",
285
+ install_secret="s",
286
+ subject_id="d" * 64,
287
+ )
288
+ save_credentials(credentials, path)
289
+ assert load_credentials(path) == credentials
290
+ assert path.stat().st_mode & 0o777 == 0o600
291
+
292
+
293
+ def test_own_api_key_disables_trial(monkeypatch: pytest.MonkeyPatch) -> None:
294
+ monkeypatch.setenv("HF_TOKEN", "hf_test_token")
295
+ server = FakeQuotaServer()
296
+ http = httpx.Client(transport=httpx.MockTransport(server.handler))
297
+ transport = AffixTransport("aio_customer_key", client=http)
298
+ client = AffixHuggingFace(
299
+ affix_api_key="aio_customer_key",
300
+ affix_transport=transport,
301
+ hf_client=FakeHfClient(),
302
+ evidence_mode="off",
303
+ )
304
+ assert client._trial is False
305
+
306
+
307
+ def test_public_trial_key_used_when_none_supplied(
308
+ monkeypatch: pytest.MonkeyPatch,
309
+ ) -> None:
310
+ monkeypatch.delenv("AFFIX_API_KEY", raising=False)
311
+ monkeypatch.setenv("HF_TOKEN", "hf_test_token")
312
+ captured: dict[str, Any] = {}
313
+
314
+ def handler(request: httpx.Request) -> httpx.Response:
315
+ captured["auth"] = request.headers.get("X-API-Key")
316
+ return httpx.Response(200, json={"ok": True})
317
+
318
+ http = httpx.Client(transport=httpx.MockTransport(handler))
319
+ client = AffixHuggingFace(hf_client=FakeHfClient(), trial=False)
320
+ client._affix.close()
321
+ client._affix = AffixTransport(client._affix.api_key, client=http)
322
+ client.health()
323
+ assert captured["auth"].startswith("aio_")