Publish affix-huggingface 0.3.0
Browse files- LICENSE +202 -0
- SECURITY.md +34 -0
- dist/affix_huggingface-0.3.0-py3-none-any.whl +0 -0
- dist/affix_huggingface-0.3.0.tar.gz +3 -0
- examples/chat_completion.py +33 -0
- hatch_build.py +62 -0
- pyproject.toml +73 -0
- src/affix_huggingface/__init__.py +58 -0
- src/affix_huggingface/client.py +544 -0
- src/affix_huggingface/errors.py +98 -0
- src/affix_huggingface/integrity.py +125 -0
- src/affix_huggingface/models.py +185 -0
- src/affix_huggingface/py.typed +1 -0
- src/affix_huggingface/transport.py +286 -0
- src/affix_huggingface/trial.py +177 -0
- tests/test_client.py +211 -0
- tests/test_integrity.py +82 -0
- tests/test_transport.py +99 -0
- tests/test_trial.py +323 -0
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_")
|