ctfy.sdk.admin_resources.eval

client.admin.llm_models + client.admin.eval_runs — model evaluation (admin).

  1"""``client.admin.llm_models`` + ``client.admin.eval_runs`` — model evaluation (admin)."""
  2
  3from __future__ import annotations
  4
  5import builtins
  6from typing import Any
  7
  8from ctfy.core.enums import RegistryReclaimPolicy
  9from ctfy.core.state.models import (
 10    EvalCampaignState,
 11    EvalRunState,
 12    HarnessState,
 13    LlmModelState,
 14    VendorState,
 15    VendorTokenState,
 16)
 17from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status
 18from ctfy.sdk.base import BaseHttpClient
 19from ctfy.server.models import (
 20    AdminHarnessRow,
 21    AdminTaskInfo,
 22    EvalAllowanceRow,
 23    EvalArtifactReport,
 24    EvalRunCoverage,
 25    RegistryGcStatus,
 26    RegistryReclaimPlan,
 27    RegistryRepoList,
 28    RegistryTagList,
 29    RegistryUsageReport,
 30)
 31
 32
 33class AdminLlmModelsResource:
 34    """The registry of models under evaluation: register / list / delete.
 35
 36    No API key is ever stored — a registered model is addressing only
 37    (flavor + base_url + provider model id); credentials are supplied to the
 38    run at dispatch time via env.
 39    """
 40
 41    def __init__(self, http: BaseHttpClient) -> None:
 42        self._http = http
 43
 44    def list(
 45        self, *, active_only: bool = False, offset: int = 0, limit: int = 100
 46    ) -> PagedList[LlmModelState]:
 47        resp = self._http.request(
 48            "GET",
 49            "/admin/llm-models",
 50            params={"active_only": active_only, "offset": offset, "limit": limit},
 51        )
 52        _raise_for_status(resp)
 53        return _extract_items(resp.json(), LlmModelState)
 54
 55    def register(
 56        self,
 57        *,
 58        id: str,
 59        flavor: str,
 60        display_name: str = "",
 61        provider_model: str = "",
 62        base_url: str = "",
 63        vendor: str = "",
 64        notes: str = "",
 65        input_price_per_mtok: float = 0.0,
 66        output_price_per_mtok: float = 0.0,
 67    ) -> LlmModelState:
 68        """Register (or overwrite) a model. ``flavor`` is ``anthropic`` / ``openai``.
 69
 70        ``*_price_per_mtok`` is USD per 1M tokens (0 = unpriced) — drives the
 71        leaderboard's cost columns.
 72        """
 73        resp = self._http.request(
 74            "POST",
 75            "/admin/llm-models",
 76            json={
 77                "id": id,
 78                "flavor": flavor,
 79                "display_name": display_name,
 80                "provider_model": provider_model,
 81                "base_url": base_url,
 82                "vendor": vendor,
 83                "notes": notes,
 84                "input_price_per_mtok": input_price_per_mtok,
 85                "output_price_per_mtok": output_price_per_mtok,
 86            },
 87        )
 88        _raise_for_status(resp)
 89        return LlmModelState.model_validate(resp.json())
 90
 91    def delete(self, model_id: str) -> bool:
 92        resp = self._http.request("DELETE", f"/admin/llm-models/{model_id}")
 93        _raise_for_status(resp)
 94        return bool(resp.json().get("deleted"))
 95
 96    def mint_token(
 97        self,
 98        model_id: str,
 99        *,
100        competition_id: str,
101        label: str = "",
102        expires_in_days: int | None = None,
103    ) -> dict[str, Any]:
104        """Mint a play credential bound to this model's synthetic team.
105
106        Returns ``{token, model_id, competition_id, team_id,
107        user_id}``; the plaintext ``pf_`` bearer is shown **once** —
108        store it now, only its hash is persisted. Scoped to
109        ``competition_id`` and carrying only what a run needs
110        (``challenges: read`` + ``instances: write`` + ``submissions:
111        write``).
112
113        This is what an *external* harness — one the platform does not
114        dispatch itself — uses to keep each model on its own team.
115        Sharing a team makes ``challenge_fully_solved`` inherit between
116        models and makes them share one wrong-answer budget, silently.
117        """
118        resp = self._http.request(
119            "POST",
120            f"/admin/llm-models/{model_id}/tokens",
121            json={
122                "competition_id": competition_id,
123                "label": label,
124                "expires_in_days": expires_in_days,
125            },
126        )
127        _raise_for_status(resp)
128        data: dict[str, Any] = resp.json()
129        return data
130
131    def review(self, model_id: str, *, status: str, note: str = "") -> LlmModelState:
132        """Approve / reject a model. ``status`` is ``approved`` / ``rejected`` /
133        ``pending``; only ``approved`` models may back an official campaign."""
134        resp = self._http.request(
135            "POST",
136            f"/admin/llm-models/{model_id}/review",
137            json={"status": status, "note": note},
138        )
139        _raise_for_status(resp)
140        return LlmModelState.model_validate(resp.json())
141
142
143class AdminEvalVendorsResource:
144    """The registry of model vendors / orgs: register / list / delete.
145
146    A vendor's ``id`` is a slug matching the ``vendor`` label on each of its
147    registered models, so the leaderboard's vendor scorecard resolves display
148    metadata from one curated row.
149    """
150
151    def __init__(self, http: BaseHttpClient) -> None:
152        self._http = http
153
154    def list(
155        self, *, active_only: bool = False, offset: int = 0, limit: int = 100
156    ) -> PagedList[VendorState]:
157        resp = self._http.request(
158            "GET",
159            "/admin/eval-vendors",
160            params={"active_only": active_only, "offset": offset, "limit": limit},
161        )
162        _raise_for_status(resp)
163        return _extract_items(resp.json(), VendorState)
164
165    def register(
166        self,
167        *,
168        id: str,
169        display_name: str = "",
170        homepage: str = "",
171        contact_email: str = "",
172        active: bool = True,
173        notes: str = "",
174        max_concurrent_campaigns: int = 1,
175        daily_run_quota: int = 200,
176        monthly_cost_budget_usd: float = 0.0,
177    ) -> VendorState:
178        """Register (or overwrite) a vendor by slug ``id``.
179
180        The quota trio caps the vendor's self-service campaign usage
181        (docs/model-eval-vendor-tenancy.md §6.2); ``0`` on any axis means "no
182        limit" there. A re-register overwrites the whole row, so pass the
183        current quota values to keep them."""
184        resp = self._http.request(
185            "POST",
186            "/admin/eval-vendors",
187            json={
188                "id": id,
189                "display_name": display_name,
190                "homepage": homepage,
191                "contact_email": contact_email,
192                "active": active,
193                "notes": notes,
194                "max_concurrent_campaigns": max_concurrent_campaigns,
195                "daily_run_quota": daily_run_quota,
196                "monthly_cost_budget_usd": monthly_cost_budget_usd,
197            },
198        )
199        _raise_for_status(resp)
200        return VendorState.model_validate(resp.json())
201
202    def delete(self, vendor_id: str) -> bool:
203        resp = self._http.request("DELETE", f"/admin/eval-vendors/{vendor_id}")
204        _raise_for_status(resp)
205        return bool(resp.json().get("deleted"))
206
207    # -- vendor access tokens (tenancy) -------------------------------------
208
209    def mint_token(self, vendor_id: str, *, label: str = "", scope: str = "read") -> dict[str, Any]:
210        """Mint a ``pv_`` vendor bearer. The plaintext ``token`` in the reply
211        is shown **once** — store it now; only its hash is persisted. ``scope``
212        is ``read`` (default) or ``submit`` (self-service writes)."""
213        resp = self._http.request(
214            "POST",
215            f"/admin/eval-vendors/{vendor_id}/tokens",
216            json={"label": label, "scope": scope},
217        )
218        _raise_for_status(resp)
219        data: dict[str, Any] = resp.json()
220        return data
221
222    def list_tokens(self, vendor_id: str) -> builtins.list[VendorTokenState]:
223        """List a vendor's tokens (hashes blanked server-side)."""
224        resp = self._http.request("GET", f"/admin/eval-vendors/{vendor_id}/tokens")
225        _raise_for_status(resp)
226        return [VendorTokenState.model_validate(row) for row in resp.json()]
227
228    def revoke_token(self, vendor_id: str, token_id: str) -> bool:
229        resp = self._http.request("DELETE", f"/admin/eval-vendors/{vendor_id}/tokens/{token_id}")
230        _raise_for_status(resp)
231        return bool(resp.json().get("deleted"))
232
233
234class AdminEvalHarnessesResource:
235    """The registry of eval harnesses (agentic scaffolds): register / list / delete.
236
237    A harness's ``id`` is a slug (``ctfy`` / ``claude-code`` / …); ``image`` is
238    the container a node runs for it. Lets harness be a comparison variable
239    alongside the model.
240    """
241
242    def __init__(self, http: BaseHttpClient) -> None:
243        self._http = http
244
245    def list(
246        self, *, active_only: bool = False, offset: int = 0, limit: int = 100
247    ) -> PagedList[AdminHarnessRow]:
248        resp = self._http.request(
249            "GET",
250            "/admin/eval-harnesses",
251            params={"active_only": active_only, "offset": offset, "limit": limit},
252        )
253        _raise_for_status(resp)
254        return _extract_items(resp.json(), AdminHarnessRow)
255
256    def register(
257        self,
258        *,
259        id: str,
260        image: str = "",
261        display_name: str = "",
262        version: str = "",
263        notes: str = "",
264        active: bool = True,
265    ) -> HarnessState:
266        """Register (or overwrite) one of the *platform's* harnesses.
267
268        ⚠️ Refused with 409 on a row a user owns. The stamping here says
269        "the platform published this" — ``owner_user_id: ""`` is exactly
270        what the dispatch gate reads as needing no review — so aiming it
271        at somebody's registration would strip the owner and grant a
272        permanent exemption while reading like an approval. Use
273        :meth:`review` for a verdict.
274        """
275        resp = self._http.request(
276            "POST",
277            "/admin/eval-harnesses",
278            json={
279                "id": id,
280                "image": image,
281                "display_name": display_name,
282                "version": version,
283                "notes": notes,
284                "active": active,
285            },
286        )
287        _raise_for_status(resp)
288        return HarnessState.model_validate(resp.json())
289
290    def review(self, harness_id: str, status: str, *, note: str = "") -> HarnessState:
291        """Decide whether a user's agent image may run.
292
293        ``status`` is ``approved`` / ``rejected`` / ``pending``; a
294        verdict can be taken back. Never touches ``owner_user_id`` — the
295        field the gate turns on — so the decision stays about this
296        image rather than becoming a permanent exemption.
297        """
298        resp = self._http.request(
299            "POST",
300            f"/admin/eval-harnesses/{harness_id}/review",
301            json={"status": status, "note": note},
302        )
303        _raise_for_status(resp)
304        return HarnessState.model_validate(resp.json())
305
306    def check(self, harness_id: str) -> AdminHarnessRow:
307        """Look at the image now and record what was found.
308
309        ⚠️ **Evidence, never a gate.** It does not touch
310        ``review_status``, and a ``failed`` verdict does not stop a later
311        :meth:`review` approving the row — an operator may know something
312        the check does not.
313
314        ⚠️ **A registry that would not answer comes back 200 carrying
315        ``conformance_status == "error"``**, which is a recorded answer
316        about the registry rather than a claim about the image. Only a
317        row naming no image refuses (400 ``harness_has_no_image``):
318        "there is nothing to check" and "we could not look" send the
319        reader to different places.
320        """
321        resp = self._http.request("POST", f"/admin/eval-harnesses/{harness_id}/conformance")
322        _raise_for_status(resp)
323        return AdminHarnessRow.model_validate(resp.json())
324
325    def probe(self, harness_id: str) -> AdminTaskInfo:
326        """Run the image once, under the profile a dispatch uses.
327
328        ⚠️ **Answers a queued task, never a verdict.** The image is
329        pulled before the container starts, so the answer lands on the
330        harness row minutes later — poll :meth:`list`, not this return
331        value.
332        """
333        resp = self._http.request("POST", f"/admin/eval-harnesses/{harness_id}/probe")
334        _raise_for_status(resp)
335        return AdminTaskInfo.model_validate(resp.json())
336
337    def delete(self, harness_id: str) -> bool:
338        resp = self._http.request("DELETE", f"/admin/eval-harnesses/{harness_id}")
339        _raise_for_status(resp)
340        return bool(resp.json().get("deleted"))
341
342
343class AdminEvalAllowancesResource:
344    """Grants of the platform's own model key: list / grant / revoke.
345
346    ⚠️ This is the one custody mode that spends the deployment's money on
347    somebody else's behalf, so it is an explicit grant per
348    ``(account, competition)`` rather than a fall-through from "the user
349    has no key". Re-granting keeps what was already spent.
350    """
351
352    def __init__(self, http: BaseHttpClient) -> None:
353        self._http = http
354
355    def list(self, *, competition_id: str = "") -> builtins.list[EvalAllowanceRow]:
356        """Every grant, or one competition's. ``""`` means every one."""
357        resp = self._http.request(
358            "GET", "/admin/eval-allowances", params={"competition_id": competition_id}
359        )
360        _raise_for_status(resp)
361        return [EvalAllowanceRow.model_validate(row) for row in resp.json()]
362
363    def grant(
364        self, user_id: str, competition_id: str, cap_usd: float, *, note: str = ""
365    ) -> EvalAllowanceRow:
366        """Let this account spend the platform's key, up to ``cap_usd``.
367
368        A repeat ``(account, competition)`` re-caps and **keeps the
369        spend** — raising a cap does not refund what went before.
370        """
371        resp = self._http.request(
372            "POST",
373            "/admin/eval-allowances",
374            json={
375                "user_id": user_id,
376                "competition_id": competition_id,
377                "cap_usd": cap_usd,
378                "note": note,
379            },
380        )
381        _raise_for_status(resp)
382        return EvalAllowanceRow.model_validate(resp.json())
383
384    def revoke(self, user_id: str, competition_id: str) -> bool:
385        """Take it back. Runs already paid for stay paid for."""
386        resp = self._http.request("DELETE", f"/admin/eval-allowances/{user_id}/{competition_id}")
387        _raise_for_status(resp)
388        return bool(resp.json().get("deleted", False))
389
390
391class AdminEvalRunsResource:
392    """Submit + read model × challenge evaluation runs."""
393
394    def __init__(self, http: BaseHttpClient) -> None:
395        self._http = http
396
397    def artifacts(self) -> EvalArtifactReport:
398        """How much disk the collected agent archives are using.
399
400        ⚠️ **Read ``by_status`` beside the headline.** Zero stored bytes
401        is four different facts — nobody has run an agent, this
402        deployment has no bucket, collection is failing, or a retention
403        window reclaimed them — and they are identical as a single
404        number.
405        """
406        resp = self._http.request("GET", "/admin/eval-artifacts")
407        _raise_for_status(resp)
408        return EvalArtifactReport.model_validate(resp.json())
409
410    def list(
411        self,
412        *,
413        model_id: str = "",
414        competition_id: str = "",
415        status: str = "",
416        campaign_id: str = "",
417        offset: int = 0,
418        limit: int = 50,
419    ) -> PagedList[EvalRunState]:
420        resp = self._http.request(
421            "GET",
422            "/admin/eval-runs",
423            params={
424                "model_id": model_id,
425                "competition_id": competition_id,
426                "status": status,
427                "campaign_id": campaign_id,
428                "offset": offset,
429                "limit": limit,
430            },
431        )
432        _raise_for_status(resp)
433        return _extract_items(resp.json(), EvalRunState)
434
435    def get(self, eval_run_id: str) -> EvalRunState:
436        resp = self._http.request("GET", f"/admin/eval-runs/{eval_run_id}")
437        _raise_for_status(resp)
438        return EvalRunState.model_validate(resp.json())
439
440    def coverage(self, eval_run_id: str) -> EvalRunCoverage:
441        """How much of the range this run reached, question by question.
442
443        The other number beside the Reporting grade: that one says
444        whether what was written is true, this one says how much there
445        was to write about. Composed from the challenge's own spec, so it
446        moves when the range does.
447        """
448        resp = self._http.request("GET", f"/admin/eval-runs/{eval_run_id}/coverage")
449        _raise_for_status(resp)
450        return EvalRunCoverage.model_validate(resp.json())
451
452    def submit(
453        self,
454        *,
455        model_id: str,
456        challenge_id: str,
457        competition_id: str = "",
458        harness_id: str = "",
459        max_turns: int | None = None,
460        samples: int = 1,
461    ) -> AdminTaskInfo:
462        """Queue a ``model_eval_run`` task for one model × challenge. ``harness_id``
463        selects the agentic scaffold (``""`` = the built-in ctfy harness);
464        ``samples`` > 1 queues k runs (pass@k). Returns the first pending task;
465        poll :meth:`list` for the resulting run records."""
466        body: dict[str, Any] = {
467            "model_id": model_id,
468            "challenge_id": challenge_id,
469            "competition_id": competition_id,
470            "harness_id": harness_id,
471            "samples": samples,
472        }
473        if max_turns is not None:
474            body["max_turns"] = max_turns
475        resp = self._http.request("POST", "/admin/eval-runs", json=body)
476        _raise_for_status(resp)
477        return AdminTaskInfo.model_validate(resp.json())
478
479
480class AdminEvalCampaignsResource:
481    """Submit + read operator batch evaluations (a model over a standard set)."""
482
483    def __init__(self, http: BaseHttpClient) -> None:
484        self._http = http
485
486    def list(
487        self,
488        *,
489        model_id: str = "",
490        vendor_id: str = "",
491        status: str = "",
492        tier: str = "",
493        offset: int = 0,
494        limit: int = 50,
495    ) -> PagedList[EvalCampaignState]:
496        resp = self._http.request(
497            "GET",
498            "/admin/eval-campaigns",
499            params={
500                "model_id": model_id,
501                "vendor_id": vendor_id,
502                "status": status,
503                "tier": tier,
504                "offset": offset,
505                "limit": limit,
506            },
507        )
508        _raise_for_status(resp)
509        return _extract_items(resp.json(), EvalCampaignState)
510
511    def get(self, eval_campaign_id: str) -> EvalCampaignState:
512        resp = self._http.request("GET", f"/admin/eval-campaigns/{eval_campaign_id}")
513        _raise_for_status(resp)
514        return EvalCampaignState.model_validate(resp.json())
515
516    def submit(
517        self,
518        *,
519        model_id: str,
520        standard_set_id: str,
521        name: str = "",
522        harness_id: str = "",
523        num_samples: int = 1,
524        max_cost_usd: float = 0.0,
525        timeout_s: int = 0,
526        max_turns: int = 0,
527    ) -> AdminTaskInfo:
528        """Queue an ``eval_campaign_run`` fan-out over a standard set. The model
529        must be ``approved``; the standard set (a competition id) must carry
530        challenges. Returns the driving task; poll :meth:`get` for the rollup."""
531        resp = self._http.request(
532            "POST",
533            "/admin/eval-campaigns",
534            json={
535                "model_id": model_id,
536                "standard_set_id": standard_set_id,
537                "name": name,
538                "harness_id": harness_id,
539                "num_samples": num_samples,
540                "max_cost_usd": max_cost_usd,
541                "timeout_s": timeout_s,
542                "max_turns": max_turns,
543            },
544        )
545        _raise_for_status(resp)
546        return AdminTaskInfo.model_validate(resp.json())
547
548    def cancel(self, eval_campaign_id: str) -> EvalCampaignState:
549        """Cooperatively cancel a campaign via its driving task."""
550        resp = self._http.request("POST", f"/admin/eval-campaigns/{eval_campaign_id}/cancel")
551        _raise_for_status(resp)
552        return EvalCampaignState.model_validate(resp.json())
553
554
555class AdminRegistryResource:
556    """The registry this deployment runs: who holds what, and reclaiming it.
557
558    ⚠️ **There is deliberately no automatic GC**, so this is the whole
559    reclaim surface. ``approved_digest`` is what a dispatch *pulls*, so a
560    rule like "keep the newest K tags" would delete the image a campaign
561    that has not started yet will ask for — and the failure lands hours
562    later on somebody else's evaluation as an unexplained pull error.
563    """
564
565    def __init__(self, http: BaseHttpClient) -> None:
566        self._http = http
567
568    def usage(self) -> RegistryUsageReport:
569        """What each account holds, against the ceiling it is judged by.
570
571        ⚠️ ``over_quota`` on each row is the **server's** verdict, from
572        the same predicate that refuses a push. Do not recompute it from
573        ``bytes``/``tags``: a second opinion is the one that eventually
574        disagrees with the one that decides.
575        """
576        resp = self._http.request("GET", "/admin/registry/usage")
577        _raise_for_status(resp)
578        return RegistryUsageReport.model_validate(resp.json())
579
580    def delete_manifest(self, repository: str, digest: str) -> str:
581        """Remove one manifest, and every tag pointing at it.
582
583        ⚠️ **Refused (409 ``registry_digest_approved``) when a harness is
584        approved at that digest**, naming the harnesses — a dispatch
585        pulls by digest, so removing it breaks runs that have not
586        started yet. Un-approve or re-approve those agents first.
587        """
588        resp = self._http.request(
589            "DELETE",
590            "/admin/registry/manifests",
591            params={"repository": repository, "digest": digest},
592        )
593        _raise_for_status(resp)
594        return str(resp.json().get("digest", ""))
595
596    def repos(self) -> RegistryRepoList:
597        """Every repository, with its tag count and kind — the browse list."""
598        resp = self._http.request("GET", "/admin/registry/repos")
599        _raise_for_status(resp)
600        return RegistryRepoList.model_validate(resp.json())
601
602    def tags(self, repository: str, *, limit: int = 500) -> RegistryTagList:
603        """One repository's tags — size, push time, digest, references."""
604        resp = self._http.request(
605            "GET",
606            "/admin/registry/tags",
607            params={"repository": repository, "limit": limit},
608        )
609        _raise_for_status(resp)
610        return RegistryTagList.model_validate(resp.json())
611
612    def reclaim(
613        self, policy: RegistryReclaimPolicy | str, *, dry_run: bool = True
614    ) -> RegistryReclaimPlan:
615        """Preview (``dry_run=True``) or run a reference-checked reclaim.
616
617        ⚠️ Dry run is the default; only ``dry_run=False`` deletes, and it
618        never removes a manifest a live instance, published competition or
619        harness pin references.
620        """
621        resp = self._http.request(
622            "POST",
623            "/admin/registry/reclaim",
624            json={"policy": str(policy), "dry_run": dry_run},
625        )
626        _raise_for_status(resp)
627        return RegistryReclaimPlan.model_validate(resp.json())
628
629    def gc(self) -> RegistryGcStatus:
630        """Probe the registry and preview what garbage collection would free."""
631        resp = self._http.request("GET", "/admin/registry/gc")
632        _raise_for_status(resp)
633        return RegistryGcStatus.model_validate(resp.json())
class AdminLlmModelsResource:
 34class AdminLlmModelsResource:
 35    """The registry of models under evaluation: register / list / delete.
 36
 37    No API key is ever stored — a registered model is addressing only
 38    (flavor + base_url + provider model id); credentials are supplied to the
 39    run at dispatch time via env.
 40    """
 41
 42    def __init__(self, http: BaseHttpClient) -> None:
 43        self._http = http
 44
 45    def list(
 46        self, *, active_only: bool = False, offset: int = 0, limit: int = 100
 47    ) -> PagedList[LlmModelState]:
 48        resp = self._http.request(
 49            "GET",
 50            "/admin/llm-models",
 51            params={"active_only": active_only, "offset": offset, "limit": limit},
 52        )
 53        _raise_for_status(resp)
 54        return _extract_items(resp.json(), LlmModelState)
 55
 56    def register(
 57        self,
 58        *,
 59        id: str,
 60        flavor: str,
 61        display_name: str = "",
 62        provider_model: str = "",
 63        base_url: str = "",
 64        vendor: str = "",
 65        notes: str = "",
 66        input_price_per_mtok: float = 0.0,
 67        output_price_per_mtok: float = 0.0,
 68    ) -> LlmModelState:
 69        """Register (or overwrite) a model. ``flavor`` is ``anthropic`` / ``openai``.
 70
 71        ``*_price_per_mtok`` is USD per 1M tokens (0 = unpriced) — drives the
 72        leaderboard's cost columns.
 73        """
 74        resp = self._http.request(
 75            "POST",
 76            "/admin/llm-models",
 77            json={
 78                "id": id,
 79                "flavor": flavor,
 80                "display_name": display_name,
 81                "provider_model": provider_model,
 82                "base_url": base_url,
 83                "vendor": vendor,
 84                "notes": notes,
 85                "input_price_per_mtok": input_price_per_mtok,
 86                "output_price_per_mtok": output_price_per_mtok,
 87            },
 88        )
 89        _raise_for_status(resp)
 90        return LlmModelState.model_validate(resp.json())
 91
 92    def delete(self, model_id: str) -> bool:
 93        resp = self._http.request("DELETE", f"/admin/llm-models/{model_id}")
 94        _raise_for_status(resp)
 95        return bool(resp.json().get("deleted"))
 96
 97    def mint_token(
 98        self,
 99        model_id: str,
100        *,
101        competition_id: str,
102        label: str = "",
103        expires_in_days: int | None = None,
104    ) -> dict[str, Any]:
105        """Mint a play credential bound to this model's synthetic team.
106
107        Returns ``{token, model_id, competition_id, team_id,
108        user_id}``; the plaintext ``pf_`` bearer is shown **once** —
109        store it now, only its hash is persisted. Scoped to
110        ``competition_id`` and carrying only what a run needs
111        (``challenges: read`` + ``instances: write`` + ``submissions:
112        write``).
113
114        This is what an *external* harness — one the platform does not
115        dispatch itself — uses to keep each model on its own team.
116        Sharing a team makes ``challenge_fully_solved`` inherit between
117        models and makes them share one wrong-answer budget, silently.
118        """
119        resp = self._http.request(
120            "POST",
121            f"/admin/llm-models/{model_id}/tokens",
122            json={
123                "competition_id": competition_id,
124                "label": label,
125                "expires_in_days": expires_in_days,
126            },
127        )
128        _raise_for_status(resp)
129        data: dict[str, Any] = resp.json()
130        return data
131
132    def review(self, model_id: str, *, status: str, note: str = "") -> LlmModelState:
133        """Approve / reject a model. ``status`` is ``approved`` / ``rejected`` /
134        ``pending``; only ``approved`` models may back an official campaign."""
135        resp = self._http.request(
136            "POST",
137            f"/admin/llm-models/{model_id}/review",
138            json={"status": status, "note": note},
139        )
140        _raise_for_status(resp)
141        return LlmModelState.model_validate(resp.json())

The registry of models under evaluation: register / list / delete.

No API key is ever stored — a registered model is addressing only (flavor + base_url + provider model id); credentials are supplied to the run at dispatch time via env.

AdminLlmModelsResource(http: ctfy.sdk.base.BaseHttpClient)
42    def __init__(self, http: BaseHttpClient) -> None:
43        self._http = http
def list( self, *, active_only: bool = False, offset: int = 0, limit: int = 100) -> ctfy.sdk._helpers.PagedList[ctfy.core.state.models.LlmModelState]:
45    def list(
46        self, *, active_only: bool = False, offset: int = 0, limit: int = 100
47    ) -> PagedList[LlmModelState]:
48        resp = self._http.request(
49            "GET",
50            "/admin/llm-models",
51            params={"active_only": active_only, "offset": offset, "limit": limit},
52        )
53        _raise_for_status(resp)
54        return _extract_items(resp.json(), LlmModelState)
def register( self, *, id: str, flavor: str, display_name: str = '', provider_model: str = '', base_url: str = '', vendor: str = '', notes: str = '', input_price_per_mtok: float = 0.0, output_price_per_mtok: float = 0.0) -> ctfy.core.state.models.LlmModelState:
56    def register(
57        self,
58        *,
59        id: str,
60        flavor: str,
61        display_name: str = "",
62        provider_model: str = "",
63        base_url: str = "",
64        vendor: str = "",
65        notes: str = "",
66        input_price_per_mtok: float = 0.0,
67        output_price_per_mtok: float = 0.0,
68    ) -> LlmModelState:
69        """Register (or overwrite) a model. ``flavor`` is ``anthropic`` / ``openai``.
70
71        ``*_price_per_mtok`` is USD per 1M tokens (0 = unpriced) — drives the
72        leaderboard's cost columns.
73        """
74        resp = self._http.request(
75            "POST",
76            "/admin/llm-models",
77            json={
78                "id": id,
79                "flavor": flavor,
80                "display_name": display_name,
81                "provider_model": provider_model,
82                "base_url": base_url,
83                "vendor": vendor,
84                "notes": notes,
85                "input_price_per_mtok": input_price_per_mtok,
86                "output_price_per_mtok": output_price_per_mtok,
87            },
88        )
89        _raise_for_status(resp)
90        return LlmModelState.model_validate(resp.json())

Register (or overwrite) a model. flavor is anthropic / openai.

*_price_per_mtok is USD per 1M tokens (0 = unpriced) — drives the leaderboard's cost columns.

def delete(self, model_id: str) -> bool:
92    def delete(self, model_id: str) -> bool:
93        resp = self._http.request("DELETE", f"/admin/llm-models/{model_id}")
94        _raise_for_status(resp)
95        return bool(resp.json().get("deleted"))
def mint_token( self, model_id: str, *, competition_id: str, label: str = '', expires_in_days: int | None = None) -> dict[str, typing.Any]:
 97    def mint_token(
 98        self,
 99        model_id: str,
100        *,
101        competition_id: str,
102        label: str = "",
103        expires_in_days: int | None = None,
104    ) -> dict[str, Any]:
105        """Mint a play credential bound to this model's synthetic team.
106
107        Returns ``{token, model_id, competition_id, team_id,
108        user_id}``; the plaintext ``pf_`` bearer is shown **once** —
109        store it now, only its hash is persisted. Scoped to
110        ``competition_id`` and carrying only what a run needs
111        (``challenges: read`` + ``instances: write`` + ``submissions:
112        write``).
113
114        This is what an *external* harness — one the platform does not
115        dispatch itself — uses to keep each model on its own team.
116        Sharing a team makes ``challenge_fully_solved`` inherit between
117        models and makes them share one wrong-answer budget, silently.
118        """
119        resp = self._http.request(
120            "POST",
121            f"/admin/llm-models/{model_id}/tokens",
122            json={
123                "competition_id": competition_id,
124                "label": label,
125                "expires_in_days": expires_in_days,
126            },
127        )
128        _raise_for_status(resp)
129        data: dict[str, Any] = resp.json()
130        return data

Mint a play credential bound to this model's synthetic team.

Returns {token, model_id, competition_id, team_id, user_id}; the plaintext pf_ bearer is shown once — store it now, only its hash is persisted. Scoped to competition_id and carrying only what a run needs (challenges: read + instances: write + submissions: write).

This is what an external harness — one the platform does not dispatch itself — uses to keep each model on its own team. Sharing a team makes challenge_fully_solved inherit between models and makes them share one wrong-answer budget, silently.

def review( self, model_id: str, *, status: str, note: str = '') -> ctfy.core.state.models.LlmModelState:
132    def review(self, model_id: str, *, status: str, note: str = "") -> LlmModelState:
133        """Approve / reject a model. ``status`` is ``approved`` / ``rejected`` /
134        ``pending``; only ``approved`` models may back an official campaign."""
135        resp = self._http.request(
136            "POST",
137            f"/admin/llm-models/{model_id}/review",
138            json={"status": status, "note": note},
139        )
140        _raise_for_status(resp)
141        return LlmModelState.model_validate(resp.json())

Approve / reject a model. status is approved / rejected / pending; only approved models may back an official campaign.

class AdminEvalVendorsResource:
144class AdminEvalVendorsResource:
145    """The registry of model vendors / orgs: register / list / delete.
146
147    A vendor's ``id`` is a slug matching the ``vendor`` label on each of its
148    registered models, so the leaderboard's vendor scorecard resolves display
149    metadata from one curated row.
150    """
151
152    def __init__(self, http: BaseHttpClient) -> None:
153        self._http = http
154
155    def list(
156        self, *, active_only: bool = False, offset: int = 0, limit: int = 100
157    ) -> PagedList[VendorState]:
158        resp = self._http.request(
159            "GET",
160            "/admin/eval-vendors",
161            params={"active_only": active_only, "offset": offset, "limit": limit},
162        )
163        _raise_for_status(resp)
164        return _extract_items(resp.json(), VendorState)
165
166    def register(
167        self,
168        *,
169        id: str,
170        display_name: str = "",
171        homepage: str = "",
172        contact_email: str = "",
173        active: bool = True,
174        notes: str = "",
175        max_concurrent_campaigns: int = 1,
176        daily_run_quota: int = 200,
177        monthly_cost_budget_usd: float = 0.0,
178    ) -> VendorState:
179        """Register (or overwrite) a vendor by slug ``id``.
180
181        The quota trio caps the vendor's self-service campaign usage
182        (docs/model-eval-vendor-tenancy.md §6.2); ``0`` on any axis means "no
183        limit" there. A re-register overwrites the whole row, so pass the
184        current quota values to keep them."""
185        resp = self._http.request(
186            "POST",
187            "/admin/eval-vendors",
188            json={
189                "id": id,
190                "display_name": display_name,
191                "homepage": homepage,
192                "contact_email": contact_email,
193                "active": active,
194                "notes": notes,
195                "max_concurrent_campaigns": max_concurrent_campaigns,
196                "daily_run_quota": daily_run_quota,
197                "monthly_cost_budget_usd": monthly_cost_budget_usd,
198            },
199        )
200        _raise_for_status(resp)
201        return VendorState.model_validate(resp.json())
202
203    def delete(self, vendor_id: str) -> bool:
204        resp = self._http.request("DELETE", f"/admin/eval-vendors/{vendor_id}")
205        _raise_for_status(resp)
206        return bool(resp.json().get("deleted"))
207
208    # -- vendor access tokens (tenancy) -------------------------------------
209
210    def mint_token(self, vendor_id: str, *, label: str = "", scope: str = "read") -> dict[str, Any]:
211        """Mint a ``pv_`` vendor bearer. The plaintext ``token`` in the reply
212        is shown **once** — store it now; only its hash is persisted. ``scope``
213        is ``read`` (default) or ``submit`` (self-service writes)."""
214        resp = self._http.request(
215            "POST",
216            f"/admin/eval-vendors/{vendor_id}/tokens",
217            json={"label": label, "scope": scope},
218        )
219        _raise_for_status(resp)
220        data: dict[str, Any] = resp.json()
221        return data
222
223    def list_tokens(self, vendor_id: str) -> builtins.list[VendorTokenState]:
224        """List a vendor's tokens (hashes blanked server-side)."""
225        resp = self._http.request("GET", f"/admin/eval-vendors/{vendor_id}/tokens")
226        _raise_for_status(resp)
227        return [VendorTokenState.model_validate(row) for row in resp.json()]
228
229    def revoke_token(self, vendor_id: str, token_id: str) -> bool:
230        resp = self._http.request("DELETE", f"/admin/eval-vendors/{vendor_id}/tokens/{token_id}")
231        _raise_for_status(resp)
232        return bool(resp.json().get("deleted"))

The registry of model vendors / orgs: register / list / delete.

A vendor's id is a slug matching the vendor label on each of its registered models, so the leaderboard's vendor scorecard resolves display metadata from one curated row.

AdminEvalVendorsResource(http: ctfy.sdk.base.BaseHttpClient)
152    def __init__(self, http: BaseHttpClient) -> None:
153        self._http = http
def list( self, *, active_only: bool = False, offset: int = 0, limit: int = 100) -> ctfy.sdk._helpers.PagedList[ctfy.core.state.models.VendorState]:
155    def list(
156        self, *, active_only: bool = False, offset: int = 0, limit: int = 100
157    ) -> PagedList[VendorState]:
158        resp = self._http.request(
159            "GET",
160            "/admin/eval-vendors",
161            params={"active_only": active_only, "offset": offset, "limit": limit},
162        )
163        _raise_for_status(resp)
164        return _extract_items(resp.json(), VendorState)
def register( self, *, id: str, display_name: str = '', homepage: str = '', contact_email: str = '', active: bool = True, notes: str = '', max_concurrent_campaigns: int = 1, daily_run_quota: int = 200, monthly_cost_budget_usd: float = 0.0) -> ctfy.core.state.models.VendorState:
166    def register(
167        self,
168        *,
169        id: str,
170        display_name: str = "",
171        homepage: str = "",
172        contact_email: str = "",
173        active: bool = True,
174        notes: str = "",
175        max_concurrent_campaigns: int = 1,
176        daily_run_quota: int = 200,
177        monthly_cost_budget_usd: float = 0.0,
178    ) -> VendorState:
179        """Register (or overwrite) a vendor by slug ``id``.
180
181        The quota trio caps the vendor's self-service campaign usage
182        (docs/model-eval-vendor-tenancy.md §6.2); ``0`` on any axis means "no
183        limit" there. A re-register overwrites the whole row, so pass the
184        current quota values to keep them."""
185        resp = self._http.request(
186            "POST",
187            "/admin/eval-vendors",
188            json={
189                "id": id,
190                "display_name": display_name,
191                "homepage": homepage,
192                "contact_email": contact_email,
193                "active": active,
194                "notes": notes,
195                "max_concurrent_campaigns": max_concurrent_campaigns,
196                "daily_run_quota": daily_run_quota,
197                "monthly_cost_budget_usd": monthly_cost_budget_usd,
198            },
199        )
200        _raise_for_status(resp)
201        return VendorState.model_validate(resp.json())

Register (or overwrite) a vendor by slug id.

The quota trio caps the vendor's self-service campaign usage (docs/model-eval-vendor-tenancy.md §6.2); 0 on any axis means "no limit" there. A re-register overwrites the whole row, so pass the current quota values to keep them.

def delete(self, vendor_id: str) -> bool:
203    def delete(self, vendor_id: str) -> bool:
204        resp = self._http.request("DELETE", f"/admin/eval-vendors/{vendor_id}")
205        _raise_for_status(resp)
206        return bool(resp.json().get("deleted"))
def mint_token( self, vendor_id: str, *, label: str = '', scope: str = 'read') -> dict[str, typing.Any]:
210    def mint_token(self, vendor_id: str, *, label: str = "", scope: str = "read") -> dict[str, Any]:
211        """Mint a ``pv_`` vendor bearer. The plaintext ``token`` in the reply
212        is shown **once** — store it now; only its hash is persisted. ``scope``
213        is ``read`` (default) or ``submit`` (self-service writes)."""
214        resp = self._http.request(
215            "POST",
216            f"/admin/eval-vendors/{vendor_id}/tokens",
217            json={"label": label, "scope": scope},
218        )
219        _raise_for_status(resp)
220        data: dict[str, Any] = resp.json()
221        return data

Mint a pv_ vendor bearer. The plaintext token in the reply is shown once — store it now; only its hash is persisted. scope is read (default) or submit (self-service writes).

def list_tokens(self, vendor_id: str) -> list[ctfy.core.state.models.VendorTokenState]:
223    def list_tokens(self, vendor_id: str) -> builtins.list[VendorTokenState]:
224        """List a vendor's tokens (hashes blanked server-side)."""
225        resp = self._http.request("GET", f"/admin/eval-vendors/{vendor_id}/tokens")
226        _raise_for_status(resp)
227        return [VendorTokenState.model_validate(row) for row in resp.json()]

List a vendor's tokens (hashes blanked server-side).

def revoke_token(self, vendor_id: str, token_id: str) -> bool:
229    def revoke_token(self, vendor_id: str, token_id: str) -> bool:
230        resp = self._http.request("DELETE", f"/admin/eval-vendors/{vendor_id}/tokens/{token_id}")
231        _raise_for_status(resp)
232        return bool(resp.json().get("deleted"))
class AdminEvalHarnessesResource:
235class AdminEvalHarnessesResource:
236    """The registry of eval harnesses (agentic scaffolds): register / list / delete.
237
238    A harness's ``id`` is a slug (``ctfy`` / ``claude-code`` / …); ``image`` is
239    the container a node runs for it. Lets harness be a comparison variable
240    alongside the model.
241    """
242
243    def __init__(self, http: BaseHttpClient) -> None:
244        self._http = http
245
246    def list(
247        self, *, active_only: bool = False, offset: int = 0, limit: int = 100
248    ) -> PagedList[AdminHarnessRow]:
249        resp = self._http.request(
250            "GET",
251            "/admin/eval-harnesses",
252            params={"active_only": active_only, "offset": offset, "limit": limit},
253        )
254        _raise_for_status(resp)
255        return _extract_items(resp.json(), AdminHarnessRow)
256
257    def register(
258        self,
259        *,
260        id: str,
261        image: str = "",
262        display_name: str = "",
263        version: str = "",
264        notes: str = "",
265        active: bool = True,
266    ) -> HarnessState:
267        """Register (or overwrite) one of the *platform's* harnesses.
268
269        ⚠️ Refused with 409 on a row a user owns. The stamping here says
270        "the platform published this" — ``owner_user_id: ""`` is exactly
271        what the dispatch gate reads as needing no review — so aiming it
272        at somebody's registration would strip the owner and grant a
273        permanent exemption while reading like an approval. Use
274        :meth:`review` for a verdict.
275        """
276        resp = self._http.request(
277            "POST",
278            "/admin/eval-harnesses",
279            json={
280                "id": id,
281                "image": image,
282                "display_name": display_name,
283                "version": version,
284                "notes": notes,
285                "active": active,
286            },
287        )
288        _raise_for_status(resp)
289        return HarnessState.model_validate(resp.json())
290
291    def review(self, harness_id: str, status: str, *, note: str = "") -> HarnessState:
292        """Decide whether a user's agent image may run.
293
294        ``status`` is ``approved`` / ``rejected`` / ``pending``; a
295        verdict can be taken back. Never touches ``owner_user_id`` — the
296        field the gate turns on — so the decision stays about this
297        image rather than becoming a permanent exemption.
298        """
299        resp = self._http.request(
300            "POST",
301            f"/admin/eval-harnesses/{harness_id}/review",
302            json={"status": status, "note": note},
303        )
304        _raise_for_status(resp)
305        return HarnessState.model_validate(resp.json())
306
307    def check(self, harness_id: str) -> AdminHarnessRow:
308        """Look at the image now and record what was found.
309
310        ⚠️ **Evidence, never a gate.** It does not touch
311        ``review_status``, and a ``failed`` verdict does not stop a later
312        :meth:`review` approving the row — an operator may know something
313        the check does not.
314
315        ⚠️ **A registry that would not answer comes back 200 carrying
316        ``conformance_status == "error"``**, which is a recorded answer
317        about the registry rather than a claim about the image. Only a
318        row naming no image refuses (400 ``harness_has_no_image``):
319        "there is nothing to check" and "we could not look" send the
320        reader to different places.
321        """
322        resp = self._http.request("POST", f"/admin/eval-harnesses/{harness_id}/conformance")
323        _raise_for_status(resp)
324        return AdminHarnessRow.model_validate(resp.json())
325
326    def probe(self, harness_id: str) -> AdminTaskInfo:
327        """Run the image once, under the profile a dispatch uses.
328
329        ⚠️ **Answers a queued task, never a verdict.** The image is
330        pulled before the container starts, so the answer lands on the
331        harness row minutes later — poll :meth:`list`, not this return
332        value.
333        """
334        resp = self._http.request("POST", f"/admin/eval-harnesses/{harness_id}/probe")
335        _raise_for_status(resp)
336        return AdminTaskInfo.model_validate(resp.json())
337
338    def delete(self, harness_id: str) -> bool:
339        resp = self._http.request("DELETE", f"/admin/eval-harnesses/{harness_id}")
340        _raise_for_status(resp)
341        return bool(resp.json().get("deleted"))

The registry of eval harnesses (agentic scaffolds): register / list / delete.

A harness's id is a slug (ctfy / claude-code / …); image is the container a node runs for it. Lets harness be a comparison variable alongside the model.

AdminEvalHarnessesResource(http: ctfy.sdk.base.BaseHttpClient)
243    def __init__(self, http: BaseHttpClient) -> None:
244        self._http = http
def list( self, *, active_only: bool = False, offset: int = 0, limit: int = 100) -> ctfy.sdk._helpers.PagedList[ctfy.server.models.AdminHarnessRow]:
246    def list(
247        self, *, active_only: bool = False, offset: int = 0, limit: int = 100
248    ) -> PagedList[AdminHarnessRow]:
249        resp = self._http.request(
250            "GET",
251            "/admin/eval-harnesses",
252            params={"active_only": active_only, "offset": offset, "limit": limit},
253        )
254        _raise_for_status(resp)
255        return _extract_items(resp.json(), AdminHarnessRow)
def register( self, *, id: str, image: str = '', display_name: str = '', version: str = '', notes: str = '', active: bool = True) -> ctfy.core.state.models.HarnessState:
257    def register(
258        self,
259        *,
260        id: str,
261        image: str = "",
262        display_name: str = "",
263        version: str = "",
264        notes: str = "",
265        active: bool = True,
266    ) -> HarnessState:
267        """Register (or overwrite) one of the *platform's* harnesses.
268
269        ⚠️ Refused with 409 on a row a user owns. The stamping here says
270        "the platform published this" — ``owner_user_id: ""`` is exactly
271        what the dispatch gate reads as needing no review — so aiming it
272        at somebody's registration would strip the owner and grant a
273        permanent exemption while reading like an approval. Use
274        :meth:`review` for a verdict.
275        """
276        resp = self._http.request(
277            "POST",
278            "/admin/eval-harnesses",
279            json={
280                "id": id,
281                "image": image,
282                "display_name": display_name,
283                "version": version,
284                "notes": notes,
285                "active": active,
286            },
287        )
288        _raise_for_status(resp)
289        return HarnessState.model_validate(resp.json())

Register (or overwrite) one of the platform's harnesses.

⚠️ Refused with 409 on a row a user owns. The stamping here says "the platform published this" — owner_user_id: "" is exactly what the dispatch gate reads as needing no review — so aiming it at somebody's registration would strip the owner and grant a permanent exemption while reading like an approval. Use review() for a verdict.

def review( self, harness_id: str, status: str, *, note: str = '') -> ctfy.core.state.models.HarnessState:
291    def review(self, harness_id: str, status: str, *, note: str = "") -> HarnessState:
292        """Decide whether a user's agent image may run.
293
294        ``status`` is ``approved`` / ``rejected`` / ``pending``; a
295        verdict can be taken back. Never touches ``owner_user_id`` — the
296        field the gate turns on — so the decision stays about this
297        image rather than becoming a permanent exemption.
298        """
299        resp = self._http.request(
300            "POST",
301            f"/admin/eval-harnesses/{harness_id}/review",
302            json={"status": status, "note": note},
303        )
304        _raise_for_status(resp)
305        return HarnessState.model_validate(resp.json())

Decide whether a user's agent image may run.

status is approved / rejected / pending; a verdict can be taken back. Never touches owner_user_id — the field the gate turns on — so the decision stays about this image rather than becoming a permanent exemption.

def check(self, harness_id: str) -> ctfy.server.models.AdminHarnessRow:
307    def check(self, harness_id: str) -> AdminHarnessRow:
308        """Look at the image now and record what was found.
309
310        ⚠️ **Evidence, never a gate.** It does not touch
311        ``review_status``, and a ``failed`` verdict does not stop a later
312        :meth:`review` approving the row — an operator may know something
313        the check does not.
314
315        ⚠️ **A registry that would not answer comes back 200 carrying
316        ``conformance_status == "error"``**, which is a recorded answer
317        about the registry rather than a claim about the image. Only a
318        row naming no image refuses (400 ``harness_has_no_image``):
319        "there is nothing to check" and "we could not look" send the
320        reader to different places.
321        """
322        resp = self._http.request("POST", f"/admin/eval-harnesses/{harness_id}/conformance")
323        _raise_for_status(resp)
324        return AdminHarnessRow.model_validate(resp.json())

Look at the image now and record what was found.

⚠️ Evidence, never a gate. It does not touch review_status, and a failed verdict does not stop a later review() approving the row — an operator may know something the check does not.

⚠️ A registry that would not answer comes back 200 carrying conformance_status == "error", which is a recorded answer about the registry rather than a claim about the image. Only a row naming no image refuses (400 harness_has_no_image): "there is nothing to check" and "we could not look" send the reader to different places.

def probe(self, harness_id: str) -> ctfy.server.models.AdminTaskInfo:
326    def probe(self, harness_id: str) -> AdminTaskInfo:
327        """Run the image once, under the profile a dispatch uses.
328
329        ⚠️ **Answers a queued task, never a verdict.** The image is
330        pulled before the container starts, so the answer lands on the
331        harness row minutes later — poll :meth:`list`, not this return
332        value.
333        """
334        resp = self._http.request("POST", f"/admin/eval-harnesses/{harness_id}/probe")
335        _raise_for_status(resp)
336        return AdminTaskInfo.model_validate(resp.json())

Run the image once, under the profile a dispatch uses.

⚠️ Answers a queued task, never a verdict. The image is pulled before the container starts, so the answer lands on the harness row minutes later — poll list(), not this return value.

def delete(self, harness_id: str) -> bool:
338    def delete(self, harness_id: str) -> bool:
339        resp = self._http.request("DELETE", f"/admin/eval-harnesses/{harness_id}")
340        _raise_for_status(resp)
341        return bool(resp.json().get("deleted"))
class AdminEvalAllowancesResource:
344class AdminEvalAllowancesResource:
345    """Grants of the platform's own model key: list / grant / revoke.
346
347    ⚠️ This is the one custody mode that spends the deployment's money on
348    somebody else's behalf, so it is an explicit grant per
349    ``(account, competition)`` rather than a fall-through from "the user
350    has no key". Re-granting keeps what was already spent.
351    """
352
353    def __init__(self, http: BaseHttpClient) -> None:
354        self._http = http
355
356    def list(self, *, competition_id: str = "") -> builtins.list[EvalAllowanceRow]:
357        """Every grant, or one competition's. ``""`` means every one."""
358        resp = self._http.request(
359            "GET", "/admin/eval-allowances", params={"competition_id": competition_id}
360        )
361        _raise_for_status(resp)
362        return [EvalAllowanceRow.model_validate(row) for row in resp.json()]
363
364    def grant(
365        self, user_id: str, competition_id: str, cap_usd: float, *, note: str = ""
366    ) -> EvalAllowanceRow:
367        """Let this account spend the platform's key, up to ``cap_usd``.
368
369        A repeat ``(account, competition)`` re-caps and **keeps the
370        spend** — raising a cap does not refund what went before.
371        """
372        resp = self._http.request(
373            "POST",
374            "/admin/eval-allowances",
375            json={
376                "user_id": user_id,
377                "competition_id": competition_id,
378                "cap_usd": cap_usd,
379                "note": note,
380            },
381        )
382        _raise_for_status(resp)
383        return EvalAllowanceRow.model_validate(resp.json())
384
385    def revoke(self, user_id: str, competition_id: str) -> bool:
386        """Take it back. Runs already paid for stay paid for."""
387        resp = self._http.request("DELETE", f"/admin/eval-allowances/{user_id}/{competition_id}")
388        _raise_for_status(resp)
389        return bool(resp.json().get("deleted", False))

Grants of the platform's own model key: list / grant / revoke.

⚠️ This is the one custody mode that spends the deployment's money on somebody else's behalf, so it is an explicit grant per (account, competition) rather than a fall-through from "the user has no key". Re-granting keeps what was already spent.

AdminEvalAllowancesResource(http: ctfy.sdk.base.BaseHttpClient)
353    def __init__(self, http: BaseHttpClient) -> None:
354        self._http = http
def list( self, *, competition_id: str = '') -> list[ctfy.server.models.EvalAllowanceRow]:
356    def list(self, *, competition_id: str = "") -> builtins.list[EvalAllowanceRow]:
357        """Every grant, or one competition's. ``""`` means every one."""
358        resp = self._http.request(
359            "GET", "/admin/eval-allowances", params={"competition_id": competition_id}
360        )
361        _raise_for_status(resp)
362        return [EvalAllowanceRow.model_validate(row) for row in resp.json()]

Every grant, or one competition's. "" means every one.

def grant( self, user_id: str, competition_id: str, cap_usd: float, *, note: str = '') -> ctfy.server.models.EvalAllowanceRow:
364    def grant(
365        self, user_id: str, competition_id: str, cap_usd: float, *, note: str = ""
366    ) -> EvalAllowanceRow:
367        """Let this account spend the platform's key, up to ``cap_usd``.
368
369        A repeat ``(account, competition)`` re-caps and **keeps the
370        spend** — raising a cap does not refund what went before.
371        """
372        resp = self._http.request(
373            "POST",
374            "/admin/eval-allowances",
375            json={
376                "user_id": user_id,
377                "competition_id": competition_id,
378                "cap_usd": cap_usd,
379                "note": note,
380            },
381        )
382        _raise_for_status(resp)
383        return EvalAllowanceRow.model_validate(resp.json())

Let this account spend the platform's key, up to cap_usd.

A repeat (account, competition) re-caps and keeps the spend — raising a cap does not refund what went before.

def revoke(self, user_id: str, competition_id: str) -> bool:
385    def revoke(self, user_id: str, competition_id: str) -> bool:
386        """Take it back. Runs already paid for stay paid for."""
387        resp = self._http.request("DELETE", f"/admin/eval-allowances/{user_id}/{competition_id}")
388        _raise_for_status(resp)
389        return bool(resp.json().get("deleted", False))

Take it back. Runs already paid for stay paid for.

class AdminEvalRunsResource:
392class AdminEvalRunsResource:
393    """Submit + read model × challenge evaluation runs."""
394
395    def __init__(self, http: BaseHttpClient) -> None:
396        self._http = http
397
398    def artifacts(self) -> EvalArtifactReport:
399        """How much disk the collected agent archives are using.
400
401        ⚠️ **Read ``by_status`` beside the headline.** Zero stored bytes
402        is four different facts — nobody has run an agent, this
403        deployment has no bucket, collection is failing, or a retention
404        window reclaimed them — and they are identical as a single
405        number.
406        """
407        resp = self._http.request("GET", "/admin/eval-artifacts")
408        _raise_for_status(resp)
409        return EvalArtifactReport.model_validate(resp.json())
410
411    def list(
412        self,
413        *,
414        model_id: str = "",
415        competition_id: str = "",
416        status: str = "",
417        campaign_id: str = "",
418        offset: int = 0,
419        limit: int = 50,
420    ) -> PagedList[EvalRunState]:
421        resp = self._http.request(
422            "GET",
423            "/admin/eval-runs",
424            params={
425                "model_id": model_id,
426                "competition_id": competition_id,
427                "status": status,
428                "campaign_id": campaign_id,
429                "offset": offset,
430                "limit": limit,
431            },
432        )
433        _raise_for_status(resp)
434        return _extract_items(resp.json(), EvalRunState)
435
436    def get(self, eval_run_id: str) -> EvalRunState:
437        resp = self._http.request("GET", f"/admin/eval-runs/{eval_run_id}")
438        _raise_for_status(resp)
439        return EvalRunState.model_validate(resp.json())
440
441    def coverage(self, eval_run_id: str) -> EvalRunCoverage:
442        """How much of the range this run reached, question by question.
443
444        The other number beside the Reporting grade: that one says
445        whether what was written is true, this one says how much there
446        was to write about. Composed from the challenge's own spec, so it
447        moves when the range does.
448        """
449        resp = self._http.request("GET", f"/admin/eval-runs/{eval_run_id}/coverage")
450        _raise_for_status(resp)
451        return EvalRunCoverage.model_validate(resp.json())
452
453    def submit(
454        self,
455        *,
456        model_id: str,
457        challenge_id: str,
458        competition_id: str = "",
459        harness_id: str = "",
460        max_turns: int | None = None,
461        samples: int = 1,
462    ) -> AdminTaskInfo:
463        """Queue a ``model_eval_run`` task for one model × challenge. ``harness_id``
464        selects the agentic scaffold (``""`` = the built-in ctfy harness);
465        ``samples`` > 1 queues k runs (pass@k). Returns the first pending task;
466        poll :meth:`list` for the resulting run records."""
467        body: dict[str, Any] = {
468            "model_id": model_id,
469            "challenge_id": challenge_id,
470            "competition_id": competition_id,
471            "harness_id": harness_id,
472            "samples": samples,
473        }
474        if max_turns is not None:
475            body["max_turns"] = max_turns
476        resp = self._http.request("POST", "/admin/eval-runs", json=body)
477        _raise_for_status(resp)
478        return AdminTaskInfo.model_validate(resp.json())

Submit + read model × challenge evaluation runs.

AdminEvalRunsResource(http: ctfy.sdk.base.BaseHttpClient)
395    def __init__(self, http: BaseHttpClient) -> None:
396        self._http = http
def artifacts(self) -> ctfy.server.models.EvalArtifactReport:
398    def artifacts(self) -> EvalArtifactReport:
399        """How much disk the collected agent archives are using.
400
401        ⚠️ **Read ``by_status`` beside the headline.** Zero stored bytes
402        is four different facts — nobody has run an agent, this
403        deployment has no bucket, collection is failing, or a retention
404        window reclaimed them — and they are identical as a single
405        number.
406        """
407        resp = self._http.request("GET", "/admin/eval-artifacts")
408        _raise_for_status(resp)
409        return EvalArtifactReport.model_validate(resp.json())

How much disk the collected agent archives are using.

⚠️ Read by_status beside the headline. Zero stored bytes is four different facts — nobody has run an agent, this deployment has no bucket, collection is failing, or a retention window reclaimed them — and they are identical as a single number.

def list( self, *, model_id: str = '', competition_id: str = '', status: str = '', campaign_id: str = '', offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.core.state.models.EvalRunState]:
411    def list(
412        self,
413        *,
414        model_id: str = "",
415        competition_id: str = "",
416        status: str = "",
417        campaign_id: str = "",
418        offset: int = 0,
419        limit: int = 50,
420    ) -> PagedList[EvalRunState]:
421        resp = self._http.request(
422            "GET",
423            "/admin/eval-runs",
424            params={
425                "model_id": model_id,
426                "competition_id": competition_id,
427                "status": status,
428                "campaign_id": campaign_id,
429                "offset": offset,
430                "limit": limit,
431            },
432        )
433        _raise_for_status(resp)
434        return _extract_items(resp.json(), EvalRunState)
def get(self, eval_run_id: str) -> ctfy.core.state.models.EvalRunState:
436    def get(self, eval_run_id: str) -> EvalRunState:
437        resp = self._http.request("GET", f"/admin/eval-runs/{eval_run_id}")
438        _raise_for_status(resp)
439        return EvalRunState.model_validate(resp.json())
def coverage(self, eval_run_id: str) -> ctfy.server.models.EvalRunCoverage:
441    def coverage(self, eval_run_id: str) -> EvalRunCoverage:
442        """How much of the range this run reached, question by question.
443
444        The other number beside the Reporting grade: that one says
445        whether what was written is true, this one says how much there
446        was to write about. Composed from the challenge's own spec, so it
447        moves when the range does.
448        """
449        resp = self._http.request("GET", f"/admin/eval-runs/{eval_run_id}/coverage")
450        _raise_for_status(resp)
451        return EvalRunCoverage.model_validate(resp.json())

How much of the range this run reached, question by question.

The other number beside the Reporting grade: that one says whether what was written is true, this one says how much there was to write about. Composed from the challenge's own spec, so it moves when the range does.

def submit( self, *, model_id: str, challenge_id: str, competition_id: str = '', harness_id: str = '', max_turns: int | None = None, samples: int = 1) -> ctfy.server.models.AdminTaskInfo:
453    def submit(
454        self,
455        *,
456        model_id: str,
457        challenge_id: str,
458        competition_id: str = "",
459        harness_id: str = "",
460        max_turns: int | None = None,
461        samples: int = 1,
462    ) -> AdminTaskInfo:
463        """Queue a ``model_eval_run`` task for one model × challenge. ``harness_id``
464        selects the agentic scaffold (``""`` = the built-in ctfy harness);
465        ``samples`` > 1 queues k runs (pass@k). Returns the first pending task;
466        poll :meth:`list` for the resulting run records."""
467        body: dict[str, Any] = {
468            "model_id": model_id,
469            "challenge_id": challenge_id,
470            "competition_id": competition_id,
471            "harness_id": harness_id,
472            "samples": samples,
473        }
474        if max_turns is not None:
475            body["max_turns"] = max_turns
476        resp = self._http.request("POST", "/admin/eval-runs", json=body)
477        _raise_for_status(resp)
478        return AdminTaskInfo.model_validate(resp.json())

Queue a model_eval_run task for one model × challenge. harness_id selects the agentic scaffold ("" = the built-in ctfy harness); samples > 1 queues k runs (pass@k). Returns the first pending task; poll list() for the resulting run records.

class AdminEvalCampaignsResource:
481class AdminEvalCampaignsResource:
482    """Submit + read operator batch evaluations (a model over a standard set)."""
483
484    def __init__(self, http: BaseHttpClient) -> None:
485        self._http = http
486
487    def list(
488        self,
489        *,
490        model_id: str = "",
491        vendor_id: str = "",
492        status: str = "",
493        tier: str = "",
494        offset: int = 0,
495        limit: int = 50,
496    ) -> PagedList[EvalCampaignState]:
497        resp = self._http.request(
498            "GET",
499            "/admin/eval-campaigns",
500            params={
501                "model_id": model_id,
502                "vendor_id": vendor_id,
503                "status": status,
504                "tier": tier,
505                "offset": offset,
506                "limit": limit,
507            },
508        )
509        _raise_for_status(resp)
510        return _extract_items(resp.json(), EvalCampaignState)
511
512    def get(self, eval_campaign_id: str) -> EvalCampaignState:
513        resp = self._http.request("GET", f"/admin/eval-campaigns/{eval_campaign_id}")
514        _raise_for_status(resp)
515        return EvalCampaignState.model_validate(resp.json())
516
517    def submit(
518        self,
519        *,
520        model_id: str,
521        standard_set_id: str,
522        name: str = "",
523        harness_id: str = "",
524        num_samples: int = 1,
525        max_cost_usd: float = 0.0,
526        timeout_s: int = 0,
527        max_turns: int = 0,
528    ) -> AdminTaskInfo:
529        """Queue an ``eval_campaign_run`` fan-out over a standard set. The model
530        must be ``approved``; the standard set (a competition id) must carry
531        challenges. Returns the driving task; poll :meth:`get` for the rollup."""
532        resp = self._http.request(
533            "POST",
534            "/admin/eval-campaigns",
535            json={
536                "model_id": model_id,
537                "standard_set_id": standard_set_id,
538                "name": name,
539                "harness_id": harness_id,
540                "num_samples": num_samples,
541                "max_cost_usd": max_cost_usd,
542                "timeout_s": timeout_s,
543                "max_turns": max_turns,
544            },
545        )
546        _raise_for_status(resp)
547        return AdminTaskInfo.model_validate(resp.json())
548
549    def cancel(self, eval_campaign_id: str) -> EvalCampaignState:
550        """Cooperatively cancel a campaign via its driving task."""
551        resp = self._http.request("POST", f"/admin/eval-campaigns/{eval_campaign_id}/cancel")
552        _raise_for_status(resp)
553        return EvalCampaignState.model_validate(resp.json())

Submit + read operator batch evaluations (a model over a standard set).

AdminEvalCampaignsResource(http: ctfy.sdk.base.BaseHttpClient)
484    def __init__(self, http: BaseHttpClient) -> None:
485        self._http = http
def list( self, *, model_id: str = '', vendor_id: str = '', status: str = '', tier: str = '', offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.core.state.models.EvalCampaignState]:
487    def list(
488        self,
489        *,
490        model_id: str = "",
491        vendor_id: str = "",
492        status: str = "",
493        tier: str = "",
494        offset: int = 0,
495        limit: int = 50,
496    ) -> PagedList[EvalCampaignState]:
497        resp = self._http.request(
498            "GET",
499            "/admin/eval-campaigns",
500            params={
501                "model_id": model_id,
502                "vendor_id": vendor_id,
503                "status": status,
504                "tier": tier,
505                "offset": offset,
506                "limit": limit,
507            },
508        )
509        _raise_for_status(resp)
510        return _extract_items(resp.json(), EvalCampaignState)
def get(self, eval_campaign_id: str) -> ctfy.core.state.models.EvalCampaignState:
512    def get(self, eval_campaign_id: str) -> EvalCampaignState:
513        resp = self._http.request("GET", f"/admin/eval-campaigns/{eval_campaign_id}")
514        _raise_for_status(resp)
515        return EvalCampaignState.model_validate(resp.json())
def submit( self, *, model_id: str, standard_set_id: str, name: str = '', harness_id: str = '', num_samples: int = 1, max_cost_usd: float = 0.0, timeout_s: int = 0, max_turns: int = 0) -> ctfy.server.models.AdminTaskInfo:
517    def submit(
518        self,
519        *,
520        model_id: str,
521        standard_set_id: str,
522        name: str = "",
523        harness_id: str = "",
524        num_samples: int = 1,
525        max_cost_usd: float = 0.0,
526        timeout_s: int = 0,
527        max_turns: int = 0,
528    ) -> AdminTaskInfo:
529        """Queue an ``eval_campaign_run`` fan-out over a standard set. The model
530        must be ``approved``; the standard set (a competition id) must carry
531        challenges. Returns the driving task; poll :meth:`get` for the rollup."""
532        resp = self._http.request(
533            "POST",
534            "/admin/eval-campaigns",
535            json={
536                "model_id": model_id,
537                "standard_set_id": standard_set_id,
538                "name": name,
539                "harness_id": harness_id,
540                "num_samples": num_samples,
541                "max_cost_usd": max_cost_usd,
542                "timeout_s": timeout_s,
543                "max_turns": max_turns,
544            },
545        )
546        _raise_for_status(resp)
547        return AdminTaskInfo.model_validate(resp.json())

Queue an eval_campaign_run fan-out over a standard set. The model must be approved; the standard set (a competition id) must carry challenges. Returns the driving task; poll get() for the rollup.

def cancel(self, eval_campaign_id: str) -> ctfy.core.state.models.EvalCampaignState:
549    def cancel(self, eval_campaign_id: str) -> EvalCampaignState:
550        """Cooperatively cancel a campaign via its driving task."""
551        resp = self._http.request("POST", f"/admin/eval-campaigns/{eval_campaign_id}/cancel")
552        _raise_for_status(resp)
553        return EvalCampaignState.model_validate(resp.json())

Cooperatively cancel a campaign via its driving task.

class AdminRegistryResource:
556class AdminRegistryResource:
557    """The registry this deployment runs: who holds what, and reclaiming it.
558
559    ⚠️ **There is deliberately no automatic GC**, so this is the whole
560    reclaim surface. ``approved_digest`` is what a dispatch *pulls*, so a
561    rule like "keep the newest K tags" would delete the image a campaign
562    that has not started yet will ask for — and the failure lands hours
563    later on somebody else's evaluation as an unexplained pull error.
564    """
565
566    def __init__(self, http: BaseHttpClient) -> None:
567        self._http = http
568
569    def usage(self) -> RegistryUsageReport:
570        """What each account holds, against the ceiling it is judged by.
571
572        ⚠️ ``over_quota`` on each row is the **server's** verdict, from
573        the same predicate that refuses a push. Do not recompute it from
574        ``bytes``/``tags``: a second opinion is the one that eventually
575        disagrees with the one that decides.
576        """
577        resp = self._http.request("GET", "/admin/registry/usage")
578        _raise_for_status(resp)
579        return RegistryUsageReport.model_validate(resp.json())
580
581    def delete_manifest(self, repository: str, digest: str) -> str:
582        """Remove one manifest, and every tag pointing at it.
583
584        ⚠️ **Refused (409 ``registry_digest_approved``) when a harness is
585        approved at that digest**, naming the harnesses — a dispatch
586        pulls by digest, so removing it breaks runs that have not
587        started yet. Un-approve or re-approve those agents first.
588        """
589        resp = self._http.request(
590            "DELETE",
591            "/admin/registry/manifests",
592            params={"repository": repository, "digest": digest},
593        )
594        _raise_for_status(resp)
595        return str(resp.json().get("digest", ""))
596
597    def repos(self) -> RegistryRepoList:
598        """Every repository, with its tag count and kind — the browse list."""
599        resp = self._http.request("GET", "/admin/registry/repos")
600        _raise_for_status(resp)
601        return RegistryRepoList.model_validate(resp.json())
602
603    def tags(self, repository: str, *, limit: int = 500) -> RegistryTagList:
604        """One repository's tags — size, push time, digest, references."""
605        resp = self._http.request(
606            "GET",
607            "/admin/registry/tags",
608            params={"repository": repository, "limit": limit},
609        )
610        _raise_for_status(resp)
611        return RegistryTagList.model_validate(resp.json())
612
613    def reclaim(
614        self, policy: RegistryReclaimPolicy | str, *, dry_run: bool = True
615    ) -> RegistryReclaimPlan:
616        """Preview (``dry_run=True``) or run a reference-checked reclaim.
617
618        ⚠️ Dry run is the default; only ``dry_run=False`` deletes, and it
619        never removes a manifest a live instance, published competition or
620        harness pin references.
621        """
622        resp = self._http.request(
623            "POST",
624            "/admin/registry/reclaim",
625            json={"policy": str(policy), "dry_run": dry_run},
626        )
627        _raise_for_status(resp)
628        return RegistryReclaimPlan.model_validate(resp.json())
629
630    def gc(self) -> RegistryGcStatus:
631        """Probe the registry and preview what garbage collection would free."""
632        resp = self._http.request("GET", "/admin/registry/gc")
633        _raise_for_status(resp)
634        return RegistryGcStatus.model_validate(resp.json())

The registry this deployment runs: who holds what, and reclaiming it.

⚠️ There is deliberately no automatic GC, so this is the whole reclaim surface. approved_digest is what a dispatch pulls, so a rule like "keep the newest K tags" would delete the image a campaign that has not started yet will ask for — and the failure lands hours later on somebody else's evaluation as an unexplained pull error.

AdminRegistryResource(http: ctfy.sdk.base.BaseHttpClient)
566    def __init__(self, http: BaseHttpClient) -> None:
567        self._http = http
def usage(self) -> ctfy.server.models.RegistryUsageReport:
569    def usage(self) -> RegistryUsageReport:
570        """What each account holds, against the ceiling it is judged by.
571
572        ⚠️ ``over_quota`` on each row is the **server's** verdict, from
573        the same predicate that refuses a push. Do not recompute it from
574        ``bytes``/``tags``: a second opinion is the one that eventually
575        disagrees with the one that decides.
576        """
577        resp = self._http.request("GET", "/admin/registry/usage")
578        _raise_for_status(resp)
579        return RegistryUsageReport.model_validate(resp.json())

What each account holds, against the ceiling it is judged by.

⚠️ over_quota on each row is the server's verdict, from the same predicate that refuses a push. Do not recompute it from bytes/tags: a second opinion is the one that eventually disagrees with the one that decides.

def delete_manifest(self, repository: str, digest: str) -> str:
581    def delete_manifest(self, repository: str, digest: str) -> str:
582        """Remove one manifest, and every tag pointing at it.
583
584        ⚠️ **Refused (409 ``registry_digest_approved``) when a harness is
585        approved at that digest**, naming the harnesses — a dispatch
586        pulls by digest, so removing it breaks runs that have not
587        started yet. Un-approve or re-approve those agents first.
588        """
589        resp = self._http.request(
590            "DELETE",
591            "/admin/registry/manifests",
592            params={"repository": repository, "digest": digest},
593        )
594        _raise_for_status(resp)
595        return str(resp.json().get("digest", ""))

Remove one manifest, and every tag pointing at it.

⚠️ Refused (409 registry_digest_approved) when a harness is approved at that digest, naming the harnesses — a dispatch pulls by digest, so removing it breaks runs that have not started yet. Un-approve or re-approve those agents first.

def repos(self) -> ctfy.server.models.RegistryRepoList:
597    def repos(self) -> RegistryRepoList:
598        """Every repository, with its tag count and kind — the browse list."""
599        resp = self._http.request("GET", "/admin/registry/repos")
600        _raise_for_status(resp)
601        return RegistryRepoList.model_validate(resp.json())

Every repository, with its tag count and kind — the browse list.

def tags( self, repository: str, *, limit: int = 500) -> ctfy.server.models.RegistryTagList:
603    def tags(self, repository: str, *, limit: int = 500) -> RegistryTagList:
604        """One repository's tags — size, push time, digest, references."""
605        resp = self._http.request(
606            "GET",
607            "/admin/registry/tags",
608            params={"repository": repository, "limit": limit},
609        )
610        _raise_for_status(resp)
611        return RegistryTagList.model_validate(resp.json())

One repository's tags — size, push time, digest, references.

def reclaim( self, policy: ctfy.core.enums.RegistryReclaimPolicy | str, *, dry_run: bool = True) -> ctfy.server.models.RegistryReclaimPlan:
613    def reclaim(
614        self, policy: RegistryReclaimPolicy | str, *, dry_run: bool = True
615    ) -> RegistryReclaimPlan:
616        """Preview (``dry_run=True``) or run a reference-checked reclaim.
617
618        ⚠️ Dry run is the default; only ``dry_run=False`` deletes, and it
619        never removes a manifest a live instance, published competition or
620        harness pin references.
621        """
622        resp = self._http.request(
623            "POST",
624            "/admin/registry/reclaim",
625            json={"policy": str(policy), "dry_run": dry_run},
626        )
627        _raise_for_status(resp)
628        return RegistryReclaimPlan.model_validate(resp.json())

Preview (dry_run=True) or run a reference-checked reclaim.

⚠️ Dry run is the default; only dry_run=False deletes, and it never removes a manifest a live instance, published competition or harness pin references.

def gc(self) -> ctfy.server.models.RegistryGcStatus:
630    def gc(self) -> RegistryGcStatus:
631        """Probe the registry and preview what garbage collection would free."""
632        resp = self._http.request("GET", "/admin/registry/gc")
633        _raise_for_status(resp)
634        return RegistryGcStatus.model_validate(resp.json())

Probe the registry and preview what garbage collection would free.