| """Trial allowance behaviour, including reinstall and exhaustion.""" |
|
|
| |
|
|
| from __future__ import annotations |
|
|
| from pathlib import Path |
| from typing import Any |
|
|
| import httpx |
| import pytest |
| from test_client import FakeHfClient |
|
|
| from affix_huggingface import ( |
| AdmissionChecks, |
| AdmissionProof, |
| AffixHuggingFace, |
| ModelRef, |
| TrialExhausted, |
| ) |
| from affix_huggingface.errors import ConfigurationError, RegistrationThrottled |
| from affix_huggingface.transport import AffixTransport |
| from affix_huggingface.trial import ( |
| TrialCredentials, |
| load_credentials, |
| resolve_subject, |
| save_credentials, |
| subject_digest, |
| ) |
|
|
| PASSING_CHECKS = AdmissionChecks( |
| entitled=True, |
| data_route_allowed=True, |
| controls_satisfied=True, |
| ) |
|
|
|
|
| class FakeQuotaServer: |
| """Minimal stand-in for the AffixIO trial endpoints.""" |
|
|
| def __init__(self, *, limit: int = 100) -> None: |
| self.limit = limit |
| self.subject_used: dict[str, int] = {} |
| self.installs: dict[str, str] = {} |
| self.install_counter = 0 |
| self.registrations = 0 |
|
|
| def handler(self, request: httpx.Request) -> httpx.Response: |
| path = request.url.path |
| if path == "/v1/trial/register": |
| return self._register(request) |
| install_id = request.headers.get("X-Affix-Install-Id") |
| subject = self.installs.get(install_id or "") |
| if subject is None: |
| return httpx.Response(403, json={"error": "install_rejected"}) |
| used = self.subject_used.get(subject, 0) |
| remaining = max(0, self.limit - used) |
| headers = { |
| "X-Affix-Trial-Limit": str(self.limit), |
| "X-Affix-Trial-Used": str(used), |
| "X-Affix-Trial-Remaining": str(remaining), |
| } |
| if path.endswith("/prove"): |
| if remaining <= 0: |
| return httpx.Response( |
| 403, |
| json={ |
| "error": "trial_exhausted", |
| "reason_code": "TRIAL_EXHAUSTED", |
| "message": "Trial allowance used up", |
| "limit": self.limit, |
| "used": used, |
| }, |
| headers=headers, |
| ) |
| self.subject_used[subject] = used + 1 |
| return httpx.Response(200, json={"proof": "proof_hex"}, headers=headers) |
| if path == "/v1/gate/verify": |
| return httpx.Response( |
| 200, |
| json={"allow": True, "reason_code": "ADMITTED", "mode": "consume"}, |
| headers=headers, |
| ) |
| if path == "/api/attest": |
| return httpx.Response(200, json={"attestation": {"algorithm": "ML-DSA-65"}}) |
| if path == "/v1/trial/quota": |
| return httpx.Response( |
| 200, |
| json={"limit": self.limit, "used": used, "remaining": remaining}, |
| headers=headers, |
| ) |
| return httpx.Response(200, json={"ok": True}, headers=headers) |
|
|
| def _register(self, request: httpx.Request) -> httpx.Response: |
| import json as jsonlib |
|
|
| body = jsonlib.loads(request.content.decode("utf-8")) |
| subject = str(body["subject"]) |
| self.registrations += 1 |
| self.install_counter += 1 |
| install_id = f"ins_{self.install_counter}" |
| self.installs[install_id] = subject |
| used = self.subject_used.setdefault(subject, 0) |
| return httpx.Response( |
| 201, |
| json={ |
| "install_id": install_id, |
| "install_secret": "secret-value", |
| "subject_id": subject, |
| "product": "huggingface", |
| "quota": { |
| "limit": self.limit, |
| "used": used, |
| "remaining": max(0, self.limit - used), |
| }, |
| }, |
| ) |
|
|
|
|
| @pytest.fixture() |
| def cache_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: |
| path = tmp_path / "install.json" |
| monkeypatch.setenv("AFFIX_INSTALL_FILE", str(path)) |
| monkeypatch.setenv("AFFIX_TRIAL_SUBJECT", "tester@example.com") |
| monkeypatch.setenv("HF_TOKEN", "hf_test_token") |
| return path |
|
|
|
|
| def build_client(server: FakeQuotaServer) -> tuple[AffixHuggingFace, FakeHfClient]: |
| http = httpx.Client(transport=httpx.MockTransport(server.handler)) |
| transport = AffixTransport("aio_public_trial", client=http) |
| hf = FakeHfClient() |
| client = AffixHuggingFace( |
| affix_transport=transport, |
| hf_client=hf, |
| trial=True, |
| evidence_mode="off", |
| ) |
| return client, hf |
|
|
|
|
| def test_first_use_registers_and_caches(cache_file: Path) -> None: |
| server = FakeQuotaServer() |
| client, _ = build_client(server) |
|
|
| client.text_generation("prompt", model=ModelRef("org/model"), checks=PASSING_CHECKS) |
|
|
| assert server.registrations == 1 |
| assert cache_file.exists() |
| cached = load_credentials(cache_file) |
| assert cached is not None |
| assert cached.install_id == "ins_1" |
|
|
|
|
| def test_cached_install_is_reused(cache_file: Path) -> None: |
| server = FakeQuotaServer() |
| client, _ = build_client(server) |
|
|
| client.text_generation("one", model=ModelRef("org/model"), checks=PASSING_CHECKS) |
| client.text_generation("two", model=ModelRef("org/model"), checks=PASSING_CHECKS) |
|
|
| assert server.registrations == 1 |
| assert server.subject_used[subject_digest("tester@example.com")] == 2 |
|
|
|
|
| def test_deleting_the_cache_does_not_restore_proofs(cache_file: Path) -> None: |
| server = FakeQuotaServer(limit=3) |
| client, _ = build_client(server) |
| for _ in range(3): |
| client.text_generation("prompt", model=ModelRef("org/model"), checks=PASSING_CHECKS) |
|
|
| |
| cache_file.unlink() |
| assert load_credentials(cache_file) is None |
|
|
| fresh_client, fresh_hf = build_client(server) |
| with pytest.raises(TrialExhausted) as exc: |
| fresh_client.text_generation("prompt", model=ModelRef("org/model"), checks=PASSING_CHECKS) |
|
|
| assert server.registrations == 2 |
| assert exc.value.limit == 3 |
| assert fresh_hf.calls == [] |
|
|
|
|
| def test_exhaustion_blocks_hugging_face(cache_file: Path) -> None: |
| server = FakeQuotaServer(limit=2) |
| client, hf = build_client(server) |
| client.text_generation("one", model=ModelRef("org/model"), checks=PASSING_CHECKS) |
| client.text_generation("two", model=ModelRef("org/model"), checks=PASSING_CHECKS) |
| assert len(hf.calls) == 2 |
|
|
| with pytest.raises(TrialExhausted): |
| client.text_generation("three", model=ModelRef("org/model"), checks=PASSING_CHECKS) |
| assert len(hf.calls) == 2 |
|
|
|
|
| def test_pre_issued_proof_still_reports_quota(cache_file: Path) -> None: |
| server = FakeQuotaServer() |
| client, _ = build_client(server) |
| client.chat_completion( |
| [{"role": "user", "content": "hello"}], |
| model=ModelRef("org/model"), |
| proof=AdmissionProof("proof_hex"), |
| ) |
| quota = client.quota |
| assert quota is not None |
| assert quota.limit == 100 |
| assert quota.remaining == 100 |
|
|
|
|
| def test_refresh_quota_reads_server_state(cache_file: Path) -> None: |
| server = FakeQuotaServer(limit=10) |
| client, _ = build_client(server) |
| client.text_generation("one", model=ModelRef("org/model"), checks=PASSING_CHECKS) |
|
|
| quota = client.refresh_quota() |
| assert quota.used == 1 |
| assert quota.remaining == 9 |
|
|
|
|
| def test_install_headers_are_sent(cache_file: Path) -> None: |
| seen: list[httpx.Request] = [] |
| server = FakeQuotaServer() |
|
|
| def handler(request: httpx.Request) -> httpx.Response: |
| seen.append(request) |
| return server.handler(request) |
|
|
| http = httpx.Client(transport=httpx.MockTransport(handler)) |
| transport = AffixTransport("aio_public_trial", client=http) |
| client = AffixHuggingFace( |
| affix_transport=transport, |
| hf_client=FakeHfClient(), |
| trial=True, |
| evidence_mode="off", |
| ) |
| client.text_generation("prompt", model=ModelRef("org/model"), checks=PASSING_CHECKS) |
|
|
| prove = next(r for r in seen if r.url.path.endswith("/prove")) |
| assert prove.headers["X-Affix-Install-Id"] == "ins_1" |
| assert prove.headers["X-Affix-Install-Secret"] == "secret-value" |
|
|
|
|
| def test_registration_throttle_surfaces_clearly(cache_file: Path) -> None: |
| def handler(request: httpx.Request) -> httpx.Response: |
| return httpx.Response( |
| 429, |
| json={ |
| "error": "registration_throttled", |
| "message": "Too many new trial subjects from this origin today.", |
| }, |
| ) |
|
|
| http = httpx.Client(transport=httpx.MockTransport(handler)) |
| transport = AffixTransport("aio_public_trial", client=http, max_retries=0) |
| client = AffixHuggingFace( |
| affix_transport=transport, |
| hf_client=FakeHfClient(), |
| trial=True, |
| evidence_mode="off", |
| ) |
| with pytest.raises(RegistrationThrottled): |
| client.text_generation("prompt", model=ModelRef("org/model"), checks=PASSING_CHECKS) |
|
|
|
|
| def test_subject_resolution_is_stable_and_opaque() -> None: |
| first = resolve_subject(subject="Tester@Example.com") |
| second = resolve_subject(subject=" tester@example.com ") |
| assert first == second |
| assert "tester" not in first |
| assert len(first) == 64 |
|
|
|
|
| def test_subject_resolution_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: |
| monkeypatch.delenv("AFFIX_TRIAL_SUBJECT", raising=False) |
| monkeypatch.delenv("HF_TOKEN", raising=False) |
| with pytest.raises(ConfigurationError): |
| resolve_subject() |
|
|
|
|
| def test_credentials_round_trip(tmp_path: Path) -> None: |
| path = tmp_path / "install.json" |
| credentials = TrialCredentials( |
| install_id="ins_x", |
| install_secret="s", |
| subject_id="d" * 64, |
| ) |
| save_credentials(credentials, path) |
| assert load_credentials(path) == credentials |
| assert path.stat().st_mode & 0o777 == 0o600 |
|
|
|
|
| def test_own_api_key_disables_trial(monkeypatch: pytest.MonkeyPatch) -> None: |
| monkeypatch.setenv("HF_TOKEN", "hf_test_token") |
| server = FakeQuotaServer() |
| http = httpx.Client(transport=httpx.MockTransport(server.handler)) |
| transport = AffixTransport("aio_customer_key", client=http) |
| client = AffixHuggingFace( |
| affix_api_key="aio_customer_key", |
| affix_transport=transport, |
| hf_client=FakeHfClient(), |
| evidence_mode="off", |
| ) |
| assert client._trial is False |
|
|
|
|
| def test_public_trial_key_used_when_none_supplied( |
| monkeypatch: pytest.MonkeyPatch, |
| ) -> None: |
| monkeypatch.delenv("AFFIX_API_KEY", raising=False) |
| monkeypatch.setenv("HF_TOKEN", "hf_test_token") |
| captured: dict[str, Any] = {} |
|
|
| def handler(request: httpx.Request) -> httpx.Response: |
| captured["auth"] = request.headers.get("X-API-Key") |
| return httpx.Response(200, json={"ok": True}) |
|
|
| http = httpx.Client(transport=httpx.MockTransport(handler)) |
| client = AffixHuggingFace(hf_client=FakeHfClient(), trial=False) |
| client._affix.close() |
| client._affix = AffixTransport(client._affix.api_key, client=http) |
| client.health() |
| assert captured["auth"].startswith("aio_") |
|
|