ctfy.sdk.resources.eval

client.eval — the public leaderboard, plus your own agent shelf.

  1"""``client.eval`` — the public leaderboard, plus your own agent shelf."""
  2
  3from __future__ import annotations
  4
  5from collections.abc import Sequence
  6from typing import Any
  7
  8from ctfy.core.state.models import EvalCampaignState, HarnessState, TaskState
  9from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status
 10from ctfy.sdk.base import BaseHttpClient
 11from ctfy.server.models import (
 12    EvalAllowanceRow,
 13    EvalLeaderboard,
 14    HarnessTags,
 15    InstanceTrafficPage,
 16    LlmCallBody,
 17    LlmCallPage,
 18    LlmModelOption,
 19    LlmProviderChoice,
 20    MyEvalRunPage,
 21    MyEvalRunRow,
 22    MyLlmKey,
 23    MyLlmKeyProbe,
 24    RegistryPushTarget,
 25    RunWriteup,
 26)
 27
 28
 29class EvalResource:
 30    """The public model leaderboard (no auth), and the signed-in caller's
 31    own harness registry (``/eval/harnesses``)."""
 32
 33    def __init__(self, http: BaseHttpClient) -> None:
 34        self._http = http
 35
 36    def leaderboard(self, *, competition_id: str = "", tier: str = "official") -> EvalLeaderboard:
 37        """Ranked model standings + per-dimension (difficulty / bucket / tag)
 38        breakdowns. ``competition_id`` scopes to one competition; empty = global.
 39
 40        ``tier`` selects the credibility band: ``official`` (default,
 41        platform-run), ``self_reported`` (vendor-run, unverified), or ``all``
 42        for both. Sandbox runs are never exposed here."""
 43        resp = self._http.request(
 44            "GET", "/eval/leaderboard", params={"competition_id": competition_id, "tier": tier}
 45        )
 46        _raise_for_status(resp)
 47        return EvalLeaderboard.model_validate(resp.json())
 48
 49    # -- your own agent images -------------------------------------------
 50
 51    def harnesses(self) -> list[HarnessState]:
 52        """The harnesses *you* registered, newest first.
 53
 54        Scoped to the caller — the platform's own images and other
 55        users' are not listed here (``/admin/eval-harnesses`` is the
 56        operator's whole-registry view).
 57        """
 58        resp = self._http.request("GET", "/eval/harnesses")
 59        _raise_for_status(resp)
 60        return [HarnessState.model_validate(row) for row in resp.json()]
 61
 62    def register_harness(
 63        self,
 64        harness_id: str,
 65        image: str,
 66        *,
 67        display_name: str = "",
 68        version: str = "",
 69        notes: str = "",
 70    ) -> HarnessState:
 71        """Register one of your own agent images.
 72
 73        ⚠️ The returned row is ``review_status="pending"`` and is **not
 74        dispatchable** until an operator approves it — registering an
 75        image and being trusted to run one are deliberately two things.
 76        ``owner_user_id`` and ``review_status`` are stamped by the
 77        server; anything you pass for them is ignored.
 78        """
 79        resp = self._http.request(
 80            "POST",
 81            "/eval/harnesses",
 82            json={
 83                "id": harness_id,
 84                "image": image,
 85                "display_name": display_name,
 86                "version": version,
 87                "notes": notes,
 88            },
 89        )
 90        _raise_for_status(resp)
 91        return HarnessState.model_validate(resp.json())
 92
 93    def update_harness(
 94        self,
 95        harness_id: str,
 96        *,
 97        image: str | None = None,
 98        display_name: str | None = None,
 99        version: str | None = None,
100        notes: str | None = None,
101        source_url: str | None = None,
102        active: bool | None = None,
103    ) -> HarnessState:
104        """Edit one of your own. Anything left ``None`` is untouched.
105
106        ⚠️ **Changing ``image`` puts the row back to
107        ``review_status="pending"``** and clears the approved digest —
108        the verdict was about specific content, and a dispatch pulls the
109        digest rather than the tag. Carrying the approval across would
110        leave the row naming a new reference while the node kept running
111        the old bytes. Re-sending the *same* image is a no-op and costs
112        nothing.
113
114        Anything that is not yours answers 404 rather than 403, like
115        :meth:`delete_harness`.
116        """
117        # ⚠️ Absence is what "leave it alone" means on the wire, so an
118        # unset argument must not appear in the body at all — the server
119        # reads ``model_fields_set``, and a key sent as ``null`` is a key
120        # that was sent.
121        body: dict[str, object] = {
122            name: value
123            for name, value in (
124                ("image", image),
125                ("display_name", display_name),
126                ("version", version),
127                ("notes", notes),
128                ("source_url", source_url),
129                ("active", active),
130            )
131            if value is not None
132        }
133        resp = self._http.request("PATCH", f"/eval/harnesses/{harness_id}", json=body)
134        _raise_for_status(resp)
135        return HarnessState.model_validate(resp.json())
136
137    def delete_harness(self, harness_id: str) -> bool:
138        """Remove one of your own. Anything that is not yours — another
139        user's, or a platform image — answers 404 rather than 403, so
140        this cannot be used to probe the registry."""
141        resp = self._http.request("DELETE", f"/eval/harnesses/{harness_id}")
142        _raise_for_status(resp)
143        return bool(resp.json().get("deleted", False))
144
145    def llm_providers(self) -> list[LlmProviderChoice]:
146        """The upstreams this deployment will relay to.
147
148        ⚠️ **Ask rather than assume.** The gateway makes the request to
149        whatever address a key names — from inside its own network, and
150        it returns the answer — so the set is closed and an operator may
151        replace it. A caller carrying its own list would offer addresses
152        this deployment refuses.
153
154        Each entry carries its dialect: a provider *is* a dialect plus an
155        address, and DeepSeek, Moonshot and OpenAI all speak ``openai``.
156        """
157        resp = self._http.request("GET", "/eval/llm-providers")
158        _raise_for_status(resp)
159        return [LlmProviderChoice.model_validate(r) for r in resp.json()]
160
161    def my_llm_keys(self) -> list[MyLlmKey]:
162        """Your own upstream credentials — what they are, never what they say.
163
164        ⚠️ There is no ``api_key`` on the way back, by design. A key is
165        pasted once; every read after that lets you tell two apart, not
166        copy one.
167        """
168        resp = self._http.request("GET", "/eval/llm-keys")
169        _raise_for_status(resp)
170        return [MyLlmKey.model_validate(r) for r in resp.json()]
171
172    def put_my_llm_key(
173        self, flavor: str, api_key: str, *, label: str = "", base_url: str = ""
174    ) -> MyLlmKey:
175        """Store or rotate the credential this account spends for ``flavor``.
176
177        A repeat dialect replaces: rotation is a write, because
178        delete-then-add leaves a window in which a run has no key.
179        Answers 503 on a deployment that cannot seal secrets at rest —
180        the key is never stored in the clear.
181        """
182        resp = self._http.request(
183            "PUT",
184            f"/eval/llm-keys/{flavor}",
185            json={"api_key": api_key, "label": label, "base_url": base_url},
186        )
187        _raise_for_status(resp)
188        return MyLlmKey.model_validate(resp.json())
189
190    def delete_my_llm_key(self, flavor: str, *, base_url: str = "") -> bool:
191        """Remove it. 404 for a provider you hold no key for — the same
192        answer somebody else's key gets, so this cannot report who holds
193        what.
194
195        ⚠️ ``base_url`` is part of the address, not a filter. Naming only
196        the dialect is ambiguous the moment you hold two ``openai``
197        providers, and resolving that by picking one is how somebody
198        loses the key they meant to keep. ``""`` names that dialect's
199        official endpoint, which is a value rather than a wildcard.
200        """
201        resp = self._http.request(
202            "DELETE", f"/eval/llm-keys/{flavor}", params={"base_url": base_url}
203        )
204        _raise_for_status(resp)
205        return bool(resp.json().get("deleted", False))
206
207    def probe_my_llm_key(self, flavor: str, *, base_url: str = "") -> MyLlmKeyProbe:
208        """Spend one token on your own credential and report what happened.
209
210        Storing a key says the write landed; it says nothing about
211        whether the credential **works**. The only other way to find
212        that out is to enter an agent, let a run be dispatched, and read
213        the failure out of the artifact tarball — two hours, on a full
214        range, to learn that a key is invalid.
215
216        ⚠️ **A code, never a boolean.** A rejected key, an address
217        nothing answers at, an account out of credit and a deployment
218        with no model registered at this endpoint are four different
219        fields to go and fix.
220
221        ⚠️ **200 with the outcome in the body** for every one of them —
222        so this does not raise on a failed credential. A 404 (and only a
223        404) means you hold no key for that provider.
224
225        ⚠️ ``base_url`` is part of the address, exactly as it is for the
226        removal beside it: naming the dialect alone picks whichever row
227        matched first the moment you hold two ``openai`` providers.
228        """
229        resp = self._http.request(
230            "POST", f"/eval/llm-keys/{flavor}/probe", params={"base_url": base_url}
231        )
232        _raise_for_status(resp)
233        return MyLlmKeyProbe.model_validate(resp.json())
234
235    def my_allowances(self) -> list[EvalAllowanceRow]:
236        """What an operator has granted this account, and how much is left.
237
238        ⚠️ Read ``remaining_usd`` rather than subtracting: the server
239        clamps an overshoot at zero, and a client doing its own
240        arithmetic would eventually disagree with the refusal that
241        actually decides.
242        """
243        resp = self._http.request("GET", "/eval/allowances")
244        _raise_for_status(resp)
245        return [EvalAllowanceRow.model_validate(r) for r in resp.json()]
246
247    def models(self) -> list[LlmModelOption]:
248        """The models an entry may name.
249
250        ⚠️ Approved and active only, decided by the server. The registry
251        holds more than this — the operator's own notes and the
252        addressing that says where their credential is spent — and none
253        of it travels.
254        """
255        resp = self._http.request("GET", "/eval/models")
256        _raise_for_status(resp)
257        return [LlmModelOption.model_validate(r) for r in resp.json()]
258
259    def enter(
260        self,
261        *,
262        harness_id: str,
263        model_id: str,
264        competition_id: str,
265        num_samples: int = 1,
266    ) -> EvalCampaignState:
267        """Put one of your own agents into one competition.
268
269        ⚠️ **The result is `self_reported`, and the server decides that.**
270        The default public leaderboard shows operator-run results; this
271        is your own claim about your own agent, so read it back with
272        ``tier="self_reported"`` or ``"all"``. There is no parameter for
273        it here because there is none on the wire.
274
275        ⚠️ **409 carries the reason as its ``code``** — whether to wait
276        for review, add a key, or pick another event. A caller that
277        reports only "entry refused" throws away the only actionable
278        part of the answer.
279        """
280        resp = self._http.request(
281            "POST",
282            "/eval/entries",
283            json={
284                "harness_id": harness_id,
285                "model_id": model_id,
286                "competition_id": competition_id,
287                "num_samples": num_samples,
288            },
289        )
290        _raise_for_status(resp)
291        return EvalCampaignState.model_validate(resp.json())
292
293    def my_entries(
294        self, *, competition_id: str = "", offset: int = 0, limit: int = 50
295    ) -> PagedList[EvalCampaignState]:
296        """The campaigns this account entered, newest first — one page,
297        with the total (``limit`` at most 200).
298
299        Only entries: an operator campaign the same account queued is
300        not one of them, because the two are different claims about the
301        same board. ``competition_id`` narrows to entries into that one
302        competition, on the server.
303
304        ⚠️ This returned a bare list, the server's newest 200, with
305        nothing to say the list was cut; ``total`` says how many exist.
306        """
307        params: dict[str, Any] = {"offset": offset, "limit": limit}
308        if competition_id:
309            params["competition_id"] = competition_id
310        resp = self._http.request("GET", "/eval/entries", params=params)
311        _raise_for_status(resp)
312        return _extract_items(resp.json(), EvalCampaignState)
313
314    def runs(self, *, competition_id: str = "", offset: int = 0, limit: int = 50) -> MyEvalRunPage:
315        """Evaluation runs you may read — your own plus your team-mates'.
316
317        ⚠️ The author set is the same membership join the per-run routes
318        enforce, so a run this lists can always be opened and a run it
319        omits always 404s. Two readings of "the same team" would drift
320        in the open direction.
321
322        ⚠️ ``mine`` on each row says whether *you* may open the
323        conversations. It is composed server-side because it gates a
324        control, and two clients deriving it would disagree.
325        Wraps ``GET /eval/runs``."""
326        resp = self._http.request(
327            "GET",
328            "/eval/runs",
329            params={"competition_id": competition_id, "offset": offset, "limit": limit},
330        )
331        _raise_for_status(resp)
332        return MyEvalRunPage.model_validate(resp.json())
333
334    def run(self, run_id: str) -> MyEvalRunRow:
335        """One of your runs, projected as the shelf projects it.
336
337        ⚠️ Addressable because :meth:`runs` is paginated: one campaign
338        over a large bucket is hundreds of runs, so finding a row by
339        scanning the first page is a bug with a threshold.
340
341        Metadata scope — a team-mate reads the row, and its ``traffic``
342        field says ``not_yours`` rather than the route refusing.
343        Wraps ``GET /eval/runs/{run_id}``."""
344        resp = self._http.request("GET", f"/eval/runs/{run_id}")
345        _raise_for_status(resp)
346        return MyEvalRunRow.model_validate(resp.json())
347
348    def run_llm_calls(self, run_id: str, *, offset: int = 0, limit: int = 50) -> LlmCallPage:
349        """Every model request one of your runs made, newest first.
350
351        Metadata only — token split, latency, queue wait, the estimated
352        cost. **Scoped to the run's team**, so a captain can see who is
353        burning the allowance without reading anybody's prompts, and to
354        ``principal == "agent"``, so a challenge container's own
355        conversation is never on this route.
356        Wraps ``GET /eval/runs/{run_id}/llm-calls``."""
357        resp = self._http.request(
358            "GET",
359            f"/eval/runs/{run_id}/llm-calls",
360            params={"offset": offset, "limit": limit},
361        )
362        _raise_for_status(resp)
363        return LlmCallPage.model_validate(resp.json())
364
365    def run_llm_call(self, run_id: str, call_id: str) -> LlmCallBody:
366        """The conversation itself — **the run author's only**.
367
368        ⚠️ Narrower than :meth:`run_llm_calls` on purpose: a team-mate
369        who can see the row is answered ``403
370        transcript_body_owner_only`` here, since an agent's prompt is its
371        author's work and ``HarnessStanding`` ranks the two of them
372        against each other.
373
374        ⚠️ ``available=False`` is an ordinary answer, not an error —
375        retention reclaims bodies and leaves the row behind.
376        Wraps ``GET /eval/runs/{run_id}/llm-calls/{call_id}``."""
377        resp = self._http.request("GET", f"/eval/runs/{run_id}/llm-calls/{call_id}")
378        _raise_for_status(resp)
379        return LlmCallBody.model_validate(resp.json())
380
381    def run_artifact(self, run_id: str) -> bytes:
382        """The files one of your runs left behind, as ``.tar.gz`` bytes.
383
384        Its working directory and, when the harness is Claude Code, its
385        session — collected from the container before it was removed.
386        The writing half, where :meth:`run_llm_call` is the deciding one
387        and :meth:`run_traffic` the doing one.
388
389        ⚠️ **The run author's only**, the narrowest of the three: a
390        team-mate who can see the row is answered ``403
391        run_artifact_owner_only``. An archive carries whatever the agent
392        wrote down, which for a solved challenge includes the flag.
393
394        ⚠️ **Bytes, not a model, and no URL.** The read goes through the
395        platform rather than handing out a bucket address, so there is
396        nothing to return but the archive itself. ⚠️ A run that kept no
397        files answers ``404 run_artifact_not_found`` rather than empty
398        bytes — which of the several reasons it is (the agent wrote
399        nothing, collection failed, retention took them) is on the run
400        row's ``artifact`` field, so the caller never has to guess from
401        an absence.
402
403        Wraps ``GET /eval/runs/{run_id}/artifact``."""
404        resp = self._http.request("GET", f"/eval/runs/{run_id}/artifact")
405        _raise_for_status(resp)
406        return bytes(resp.content)
407
408    def run_traffic(self, run_id: str, *, cursor: str = "", limit: int = 0) -> InstanceTrafficPage:
409        """Every request one of your runs sent at the challenge.
410
411        The doing half, where :meth:`run_llm_call` is the deciding half.
412        **The run author's only** — a team-mate who can see the row is
413        answered ``403 run_traffic_owner_only``, the same split the
414        conversation body makes, because the payloads an agent wrote are
415        its author's work.
416
417        ⚠️ Carry ``cursor`` forward from the previous page and you get
418        only what arrived since; pass an empty one and you get the whole
419        capture again. Every failure — an unreachable node, a run whose
420        estate the reaper has taken — answers 200 with your own cursor
421        and no flows, so a blip never re-delivers the capture. Which of
422        those it was, ``MyEvalRunRow.traffic`` says.
423
424        ⚠️ Redacted: this instance's minted answers are scrubbed out.
425        Wraps ``GET /eval/runs/{run_id}/traffic``."""
426        resp = self._http.request(
427            "GET",
428            f"/eval/runs/{run_id}/traffic",
429            params={"cursor": cursor, "limit": limit},
430        )
431        _raise_for_status(resp)
432        return InstanceTrafficPage.model_validate(resp.json())
433
434    def run_writeup(self, run_id: str) -> RunWriteup:
435        """What one of your runs said it did, in its own words.
436
437        ``CTFY-0001`` §3 invites an optional ``writeup`` in the result
438        file. The saying half, where :meth:`run_llm_call` is the
439        deciding one, :meth:`run_traffic` the doing one and
440        :meth:`run_artifact` the writing one.
441
442        ⚠️ **Telemetry, never a grading path.** Nothing in the result
443        file is scored — the run's own ``POST /submissions`` is — so a
444        write-up cannot earn a point and is not read as an answer.
445
446        ⚠️ **The run author's only**: a team-mate who can see the row is
447        answered ``403 run_writeup_owner_only``, the same split the
448        other three bodies make. ⚠️ A run whose image wrote none answers
449        ``404 run_writeup_not_found`` rather than empty prose, and the
450        run row's ``writeup`` field already said which — so the caller
451        never has to reach here to find out.
452
453        Wraps ``GET /eval/runs/{run_id}/writeup``."""
454        resp = self._http.request("GET", f"/eval/runs/{run_id}/writeup")
455        _raise_for_status(resp)
456        return RunWriteup.model_validate(resp.json())
457
458    def set_run_wall(self, run_id: str, *, published: bool) -> MyEvalRunRow:
459        """Put this run's agent on the event's projector wall, or take it off.
460
461        ``GET /competitions/{id}/agent-actions`` draws one scrubbed line
462        per agent saying what it is doing. The scrub answers what the
463        bytes make unsafe; this answers the part it cannot — the
464        conversation belongs to whoever ran it, so the wall shows an
465        entrant only once their author has said yes.
466
467        ⚠️ **The switch is on the *entrant*, not on this run.** The
468        transcript index groups by team and an agent's team is one per
469        ``(owner, harness, model, competition)``, so every run of the
470        same agent at the same event shares one answer — which is what
471        "this agent, at this event" means. The row that comes back is
472        the run's, with ``wall_published`` re-read from the entrant.
473
474        ⚠️ **The author's only**: a team-mate who can see the row is
475        answered ``403 run_wall_not_yours``, the same split
476        :meth:`run_writeup` makes, and for the same reason — the line
477        is read out of a conversation they already meet a 403 on.
478
479        ⚠️ A run with no agent entrant answers ``409`` naming which
480        kind of nothing it is; ``MyEvalRunRow.wall`` carries the same
481        code, so a caller never has to press to find out.
482
483        Wraps ``POST /eval/runs/{run_id}/wall``."""
484        resp = self._http.request(
485            "POST",
486            f"/eval/runs/{run_id}/wall",
487            json={"published": published},
488        )
489        _raise_for_status(resp)
490        return MyEvalRunRow.model_validate(resp.json())
491
492    def set_run_transcript(self, run_id: str, *, published: bool) -> MyEvalRunRow:
493        """Publish this run's agent's **full conversation**, or take it back.
494
495        A **second, stronger** consent than :meth:`set_run_wall`. The wall
496        shows one scrubbed line of what the agent is doing; this opens the
497        whole conversation — every turn the agent wrote — to spectators at
498        ``GET /competitions/{id}/awd/transcript/{call_id}``. The projection
499        still emits only the agent's own scrubbed turns (never the system
500        prompt, never a tool result), but the decision to publish a
501        conversation at all is the author's alone and separate from the
502        wall's.
503
504        ⚠️ **The switch is on the *entrant***, exactly as the wall's —
505        one answer per ``(owner, harness, model, competition)``.
506
507        ⚠️ **Refused for an anonymous entrant** (``403
508        run_transcript_anonymous``): a conversation de-anonymises whoever
509        wrote it, so the withheld-identity promise wins over the consent.
510        The author's-only and no-agent refusals mirror :meth:`set_run_wall`.
511
512        Wraps ``POST /eval/runs/{run_id}/transcript``."""
513        resp = self._http.request(
514            "POST",
515            f"/eval/runs/{run_id}/transcript",
516            json={"published": published},
517        )
518        _raise_for_status(resp)
519        return MyEvalRunRow.model_validate(resp.json())
520
521    def registry_push_target(self) -> RegistryPushTarget:
522        """Where this account pushes an agent image, if anywhere.
523
524        ⚠️ Read ``enabled``, not a non-empty ``repository_prefix``. The
525        server composes the verdict from four facts — a host, a token
526        service, and both halves of the signing pair — and a deployment
527        with a hostname but no signing pair refuses every login.
528        """
529        resp = self._http.request("GET", "/eval/registry")
530        _raise_for_status(resp)
531        return RegistryPushTarget.model_validate(resp.json())
532
533    def harness_tags(self, harness_id: str) -> HarnessTags:
534        """Which versions the registry publishes for one of your agents.
535
536        ⚠️ Addressed by **harness id**, not by image: the platform looks
537        the reference up in your own registry row, which is what keeps
538        this from being an outbound proxy. Answers 404 for anything that
539        is not yours, and 502 when the registry itself would not answer
540        — never an empty list, which would read as "no versions".
541        """
542        resp = self._http.request("GET", f"/eval/harnesses/{harness_id}/tags")
543        _raise_for_status(resp)
544        return HarnessTags.model_validate(resp.json())
545
546    def check_harness(self, harness_id: str) -> HarnessState:
547        """Ask the platform to look at one of your agent images.
548
549        Returns the row with the verdict on it: ``conformance_status``
550        (``""`` never checked / ``passed`` / ``failed`` / ``error``),
551        the digest the verdict describes, and a finding per thing worth
552        saying.
553
554        ⚠️ **Evidence, never a gate.** Nothing here changes
555        ``review_status``, and a ``failed`` verdict does not stop an
556        operator approving the image — the check exists so somebody can
557        see *why* a dispatch would go wrong, before it does.
558
559        ⚠️ **``error`` is not ``failed``, and it arrives as a 200.** A
560        registry that would not answer is not a fact about the image;
561        the message is on ``conformance_error``.
562
563        ⚠️ Addressed by harness id, like :meth:`harness_tags` and for
564        the same reason: the image is read off your own registry row, so
565        this cannot be pointed at an arbitrary host.
566        """
567        resp = self._http.request("POST", f"/eval/harnesses/{harness_id}/conformance")
568        _raise_for_status(resp)
569        return HarnessState.model_validate(resp.json())
570
571    def probe_harness(self, harness_id: str) -> TaskState:
572        """Actually run one of your agent images once, to check it.
573
574        Where :meth:`check_harness` reads a manifest, this starts the
575        container on a worker under the same isolation a real dispatch
576        uses, hands it a briefing marked ``dry_run``, and asks the one
577        question a manifest cannot answer: **did it write a result file
578        at the address it was given.**
579
580        ⚠️ **Returns a task, not a verdict.** The image has to be pulled
581        before the container starts, so this is submitted and polled —
582        read the harness row, or the task, for the outcome.
583
584        ⚠️ **No credential travels with the probe** and no platform URL
585        is named. See §3.1 of ``docs/agent-protocol.md``.
586        """
587        resp = self._http.request("POST", f"/eval/harnesses/{harness_id}/probe")
588        _raise_for_status(resp)
589        return TaskState.model_validate(resp.json())
590
591
592class RegistryResource:
593    """The container registry's token endpoint.
594
595    ⚠️ **Not a method anybody calls from Python.** ``docker`` follows
596    the registry's ``WWW-Authenticate`` realm here on its own, with HTTP
597    Basic; this exists so the SDK covers the surface and so a test can
598    drive the exchange the way docker does. The credential goes in the
599    *password* half — that is where ``docker login -p`` puts it.
600    """
601
602    def __init__(self, http: BaseHttpClient) -> None:
603        self._http = http
604
605    def token(self, *, service: str, scope: str | Sequence[str] = "") -> dict[str, Any]:
606        # ⚠️ A sequence is sent as a repeated ``scope`` parameter, because
607        # that is what docker does for a cross-repository blob mount —
608        # ``push`` on the destination and ``pull`` on the source, in one
609        # exchange. This method's whole stated purpose is that a test can
610        # drive the exchange the way docker does, and it could not.
611        params: list[tuple[str, str]] = [("service", service)]
612        if isinstance(scope, str):
613            if scope:
614                params.append(("scope", scope))
615        else:
616            params.extend(("scope", s) for s in scope if s)
617        resp = self._http.request("GET", "/registry/token", params=params)
618        _raise_for_status(resp)
619        body: dict[str, Any] = resp.json()
620        return body
class EvalResource:
 30class EvalResource:
 31    """The public model leaderboard (no auth), and the signed-in caller's
 32    own harness registry (``/eval/harnesses``)."""
 33
 34    def __init__(self, http: BaseHttpClient) -> None:
 35        self._http = http
 36
 37    def leaderboard(self, *, competition_id: str = "", tier: str = "official") -> EvalLeaderboard:
 38        """Ranked model standings + per-dimension (difficulty / bucket / tag)
 39        breakdowns. ``competition_id`` scopes to one competition; empty = global.
 40
 41        ``tier`` selects the credibility band: ``official`` (default,
 42        platform-run), ``self_reported`` (vendor-run, unverified), or ``all``
 43        for both. Sandbox runs are never exposed here."""
 44        resp = self._http.request(
 45            "GET", "/eval/leaderboard", params={"competition_id": competition_id, "tier": tier}
 46        )
 47        _raise_for_status(resp)
 48        return EvalLeaderboard.model_validate(resp.json())
 49
 50    # -- your own agent images -------------------------------------------
 51
 52    def harnesses(self) -> list[HarnessState]:
 53        """The harnesses *you* registered, newest first.
 54
 55        Scoped to the caller — the platform's own images and other
 56        users' are not listed here (``/admin/eval-harnesses`` is the
 57        operator's whole-registry view).
 58        """
 59        resp = self._http.request("GET", "/eval/harnesses")
 60        _raise_for_status(resp)
 61        return [HarnessState.model_validate(row) for row in resp.json()]
 62
 63    def register_harness(
 64        self,
 65        harness_id: str,
 66        image: str,
 67        *,
 68        display_name: str = "",
 69        version: str = "",
 70        notes: str = "",
 71    ) -> HarnessState:
 72        """Register one of your own agent images.
 73
 74        ⚠️ The returned row is ``review_status="pending"`` and is **not
 75        dispatchable** until an operator approves it — registering an
 76        image and being trusted to run one are deliberately two things.
 77        ``owner_user_id`` and ``review_status`` are stamped by the
 78        server; anything you pass for them is ignored.
 79        """
 80        resp = self._http.request(
 81            "POST",
 82            "/eval/harnesses",
 83            json={
 84                "id": harness_id,
 85                "image": image,
 86                "display_name": display_name,
 87                "version": version,
 88                "notes": notes,
 89            },
 90        )
 91        _raise_for_status(resp)
 92        return HarnessState.model_validate(resp.json())
 93
 94    def update_harness(
 95        self,
 96        harness_id: str,
 97        *,
 98        image: str | None = None,
 99        display_name: str | None = None,
100        version: str | None = None,
101        notes: str | None = None,
102        source_url: str | None = None,
103        active: bool | None = None,
104    ) -> HarnessState:
105        """Edit one of your own. Anything left ``None`` is untouched.
106
107        ⚠️ **Changing ``image`` puts the row back to
108        ``review_status="pending"``** and clears the approved digest —
109        the verdict was about specific content, and a dispatch pulls the
110        digest rather than the tag. Carrying the approval across would
111        leave the row naming a new reference while the node kept running
112        the old bytes. Re-sending the *same* image is a no-op and costs
113        nothing.
114
115        Anything that is not yours answers 404 rather than 403, like
116        :meth:`delete_harness`.
117        """
118        # ⚠️ Absence is what "leave it alone" means on the wire, so an
119        # unset argument must not appear in the body at all — the server
120        # reads ``model_fields_set``, and a key sent as ``null`` is a key
121        # that was sent.
122        body: dict[str, object] = {
123            name: value
124            for name, value in (
125                ("image", image),
126                ("display_name", display_name),
127                ("version", version),
128                ("notes", notes),
129                ("source_url", source_url),
130                ("active", active),
131            )
132            if value is not None
133        }
134        resp = self._http.request("PATCH", f"/eval/harnesses/{harness_id}", json=body)
135        _raise_for_status(resp)
136        return HarnessState.model_validate(resp.json())
137
138    def delete_harness(self, harness_id: str) -> bool:
139        """Remove one of your own. Anything that is not yours — another
140        user's, or a platform image — answers 404 rather than 403, so
141        this cannot be used to probe the registry."""
142        resp = self._http.request("DELETE", f"/eval/harnesses/{harness_id}")
143        _raise_for_status(resp)
144        return bool(resp.json().get("deleted", False))
145
146    def llm_providers(self) -> list[LlmProviderChoice]:
147        """The upstreams this deployment will relay to.
148
149        ⚠️ **Ask rather than assume.** The gateway makes the request to
150        whatever address a key names — from inside its own network, and
151        it returns the answer — so the set is closed and an operator may
152        replace it. A caller carrying its own list would offer addresses
153        this deployment refuses.
154
155        Each entry carries its dialect: a provider *is* a dialect plus an
156        address, and DeepSeek, Moonshot and OpenAI all speak ``openai``.
157        """
158        resp = self._http.request("GET", "/eval/llm-providers")
159        _raise_for_status(resp)
160        return [LlmProviderChoice.model_validate(r) for r in resp.json()]
161
162    def my_llm_keys(self) -> list[MyLlmKey]:
163        """Your own upstream credentials — what they are, never what they say.
164
165        ⚠️ There is no ``api_key`` on the way back, by design. A key is
166        pasted once; every read after that lets you tell two apart, not
167        copy one.
168        """
169        resp = self._http.request("GET", "/eval/llm-keys")
170        _raise_for_status(resp)
171        return [MyLlmKey.model_validate(r) for r in resp.json()]
172
173    def put_my_llm_key(
174        self, flavor: str, api_key: str, *, label: str = "", base_url: str = ""
175    ) -> MyLlmKey:
176        """Store or rotate the credential this account spends for ``flavor``.
177
178        A repeat dialect replaces: rotation is a write, because
179        delete-then-add leaves a window in which a run has no key.
180        Answers 503 on a deployment that cannot seal secrets at rest —
181        the key is never stored in the clear.
182        """
183        resp = self._http.request(
184            "PUT",
185            f"/eval/llm-keys/{flavor}",
186            json={"api_key": api_key, "label": label, "base_url": base_url},
187        )
188        _raise_for_status(resp)
189        return MyLlmKey.model_validate(resp.json())
190
191    def delete_my_llm_key(self, flavor: str, *, base_url: str = "") -> bool:
192        """Remove it. 404 for a provider you hold no key for — the same
193        answer somebody else's key gets, so this cannot report who holds
194        what.
195
196        ⚠️ ``base_url`` is part of the address, not a filter. Naming only
197        the dialect is ambiguous the moment you hold two ``openai``
198        providers, and resolving that by picking one is how somebody
199        loses the key they meant to keep. ``""`` names that dialect's
200        official endpoint, which is a value rather than a wildcard.
201        """
202        resp = self._http.request(
203            "DELETE", f"/eval/llm-keys/{flavor}", params={"base_url": base_url}
204        )
205        _raise_for_status(resp)
206        return bool(resp.json().get("deleted", False))
207
208    def probe_my_llm_key(self, flavor: str, *, base_url: str = "") -> MyLlmKeyProbe:
209        """Spend one token on your own credential and report what happened.
210
211        Storing a key says the write landed; it says nothing about
212        whether the credential **works**. The only other way to find
213        that out is to enter an agent, let a run be dispatched, and read
214        the failure out of the artifact tarball — two hours, on a full
215        range, to learn that a key is invalid.
216
217        ⚠️ **A code, never a boolean.** A rejected key, an address
218        nothing answers at, an account out of credit and a deployment
219        with no model registered at this endpoint are four different
220        fields to go and fix.
221
222        ⚠️ **200 with the outcome in the body** for every one of them —
223        so this does not raise on a failed credential. A 404 (and only a
224        404) means you hold no key for that provider.
225
226        ⚠️ ``base_url`` is part of the address, exactly as it is for the
227        removal beside it: naming the dialect alone picks whichever row
228        matched first the moment you hold two ``openai`` providers.
229        """
230        resp = self._http.request(
231            "POST", f"/eval/llm-keys/{flavor}/probe", params={"base_url": base_url}
232        )
233        _raise_for_status(resp)
234        return MyLlmKeyProbe.model_validate(resp.json())
235
236    def my_allowances(self) -> list[EvalAllowanceRow]:
237        """What an operator has granted this account, and how much is left.
238
239        ⚠️ Read ``remaining_usd`` rather than subtracting: the server
240        clamps an overshoot at zero, and a client doing its own
241        arithmetic would eventually disagree with the refusal that
242        actually decides.
243        """
244        resp = self._http.request("GET", "/eval/allowances")
245        _raise_for_status(resp)
246        return [EvalAllowanceRow.model_validate(r) for r in resp.json()]
247
248    def models(self) -> list[LlmModelOption]:
249        """The models an entry may name.
250
251        ⚠️ Approved and active only, decided by the server. The registry
252        holds more than this — the operator's own notes and the
253        addressing that says where their credential is spent — and none
254        of it travels.
255        """
256        resp = self._http.request("GET", "/eval/models")
257        _raise_for_status(resp)
258        return [LlmModelOption.model_validate(r) for r in resp.json()]
259
260    def enter(
261        self,
262        *,
263        harness_id: str,
264        model_id: str,
265        competition_id: str,
266        num_samples: int = 1,
267    ) -> EvalCampaignState:
268        """Put one of your own agents into one competition.
269
270        ⚠️ **The result is `self_reported`, and the server decides that.**
271        The default public leaderboard shows operator-run results; this
272        is your own claim about your own agent, so read it back with
273        ``tier="self_reported"`` or ``"all"``. There is no parameter for
274        it here because there is none on the wire.
275
276        ⚠️ **409 carries the reason as its ``code``** — whether to wait
277        for review, add a key, or pick another event. A caller that
278        reports only "entry refused" throws away the only actionable
279        part of the answer.
280        """
281        resp = self._http.request(
282            "POST",
283            "/eval/entries",
284            json={
285                "harness_id": harness_id,
286                "model_id": model_id,
287                "competition_id": competition_id,
288                "num_samples": num_samples,
289            },
290        )
291        _raise_for_status(resp)
292        return EvalCampaignState.model_validate(resp.json())
293
294    def my_entries(
295        self, *, competition_id: str = "", offset: int = 0, limit: int = 50
296    ) -> PagedList[EvalCampaignState]:
297        """The campaigns this account entered, newest first — one page,
298        with the total (``limit`` at most 200).
299
300        Only entries: an operator campaign the same account queued is
301        not one of them, because the two are different claims about the
302        same board. ``competition_id`` narrows to entries into that one
303        competition, on the server.
304
305        ⚠️ This returned a bare list, the server's newest 200, with
306        nothing to say the list was cut; ``total`` says how many exist.
307        """
308        params: dict[str, Any] = {"offset": offset, "limit": limit}
309        if competition_id:
310            params["competition_id"] = competition_id
311        resp = self._http.request("GET", "/eval/entries", params=params)
312        _raise_for_status(resp)
313        return _extract_items(resp.json(), EvalCampaignState)
314
315    def runs(self, *, competition_id: str = "", offset: int = 0, limit: int = 50) -> MyEvalRunPage:
316        """Evaluation runs you may read — your own plus your team-mates'.
317
318        ⚠️ The author set is the same membership join the per-run routes
319        enforce, so a run this lists can always be opened and a run it
320        omits always 404s. Two readings of "the same team" would drift
321        in the open direction.
322
323        ⚠️ ``mine`` on each row says whether *you* may open the
324        conversations. It is composed server-side because it gates a
325        control, and two clients deriving it would disagree.
326        Wraps ``GET /eval/runs``."""
327        resp = self._http.request(
328            "GET",
329            "/eval/runs",
330            params={"competition_id": competition_id, "offset": offset, "limit": limit},
331        )
332        _raise_for_status(resp)
333        return MyEvalRunPage.model_validate(resp.json())
334
335    def run(self, run_id: str) -> MyEvalRunRow:
336        """One of your runs, projected as the shelf projects it.
337
338        ⚠️ Addressable because :meth:`runs` is paginated: one campaign
339        over a large bucket is hundreds of runs, so finding a row by
340        scanning the first page is a bug with a threshold.
341
342        Metadata scope — a team-mate reads the row, and its ``traffic``
343        field says ``not_yours`` rather than the route refusing.
344        Wraps ``GET /eval/runs/{run_id}``."""
345        resp = self._http.request("GET", f"/eval/runs/{run_id}")
346        _raise_for_status(resp)
347        return MyEvalRunRow.model_validate(resp.json())
348
349    def run_llm_calls(self, run_id: str, *, offset: int = 0, limit: int = 50) -> LlmCallPage:
350        """Every model request one of your runs made, newest first.
351
352        Metadata only — token split, latency, queue wait, the estimated
353        cost. **Scoped to the run's team**, so a captain can see who is
354        burning the allowance without reading anybody's prompts, and to
355        ``principal == "agent"``, so a challenge container's own
356        conversation is never on this route.
357        Wraps ``GET /eval/runs/{run_id}/llm-calls``."""
358        resp = self._http.request(
359            "GET",
360            f"/eval/runs/{run_id}/llm-calls",
361            params={"offset": offset, "limit": limit},
362        )
363        _raise_for_status(resp)
364        return LlmCallPage.model_validate(resp.json())
365
366    def run_llm_call(self, run_id: str, call_id: str) -> LlmCallBody:
367        """The conversation itself — **the run author's only**.
368
369        ⚠️ Narrower than :meth:`run_llm_calls` on purpose: a team-mate
370        who can see the row is answered ``403
371        transcript_body_owner_only`` here, since an agent's prompt is its
372        author's work and ``HarnessStanding`` ranks the two of them
373        against each other.
374
375        ⚠️ ``available=False`` is an ordinary answer, not an error —
376        retention reclaims bodies and leaves the row behind.
377        Wraps ``GET /eval/runs/{run_id}/llm-calls/{call_id}``."""
378        resp = self._http.request("GET", f"/eval/runs/{run_id}/llm-calls/{call_id}")
379        _raise_for_status(resp)
380        return LlmCallBody.model_validate(resp.json())
381
382    def run_artifact(self, run_id: str) -> bytes:
383        """The files one of your runs left behind, as ``.tar.gz`` bytes.
384
385        Its working directory and, when the harness is Claude Code, its
386        session — collected from the container before it was removed.
387        The writing half, where :meth:`run_llm_call` is the deciding one
388        and :meth:`run_traffic` the doing one.
389
390        ⚠️ **The run author's only**, the narrowest of the three: a
391        team-mate who can see the row is answered ``403
392        run_artifact_owner_only``. An archive carries whatever the agent
393        wrote down, which for a solved challenge includes the flag.
394
395        ⚠️ **Bytes, not a model, and no URL.** The read goes through the
396        platform rather than handing out a bucket address, so there is
397        nothing to return but the archive itself. ⚠️ A run that kept no
398        files answers ``404 run_artifact_not_found`` rather than empty
399        bytes — which of the several reasons it is (the agent wrote
400        nothing, collection failed, retention took them) is on the run
401        row's ``artifact`` field, so the caller never has to guess from
402        an absence.
403
404        Wraps ``GET /eval/runs/{run_id}/artifact``."""
405        resp = self._http.request("GET", f"/eval/runs/{run_id}/artifact")
406        _raise_for_status(resp)
407        return bytes(resp.content)
408
409    def run_traffic(self, run_id: str, *, cursor: str = "", limit: int = 0) -> InstanceTrafficPage:
410        """Every request one of your runs sent at the challenge.
411
412        The doing half, where :meth:`run_llm_call` is the deciding half.
413        **The run author's only** — a team-mate who can see the row is
414        answered ``403 run_traffic_owner_only``, the same split the
415        conversation body makes, because the payloads an agent wrote are
416        its author's work.
417
418        ⚠️ Carry ``cursor`` forward from the previous page and you get
419        only what arrived since; pass an empty one and you get the whole
420        capture again. Every failure — an unreachable node, a run whose
421        estate the reaper has taken — answers 200 with your own cursor
422        and no flows, so a blip never re-delivers the capture. Which of
423        those it was, ``MyEvalRunRow.traffic`` says.
424
425        ⚠️ Redacted: this instance's minted answers are scrubbed out.
426        Wraps ``GET /eval/runs/{run_id}/traffic``."""
427        resp = self._http.request(
428            "GET",
429            f"/eval/runs/{run_id}/traffic",
430            params={"cursor": cursor, "limit": limit},
431        )
432        _raise_for_status(resp)
433        return InstanceTrafficPage.model_validate(resp.json())
434
435    def run_writeup(self, run_id: str) -> RunWriteup:
436        """What one of your runs said it did, in its own words.
437
438        ``CTFY-0001`` §3 invites an optional ``writeup`` in the result
439        file. The saying half, where :meth:`run_llm_call` is the
440        deciding one, :meth:`run_traffic` the doing one and
441        :meth:`run_artifact` the writing one.
442
443        ⚠️ **Telemetry, never a grading path.** Nothing in the result
444        file is scored — the run's own ``POST /submissions`` is — so a
445        write-up cannot earn a point and is not read as an answer.
446
447        ⚠️ **The run author's only**: a team-mate who can see the row is
448        answered ``403 run_writeup_owner_only``, the same split the
449        other three bodies make. ⚠️ A run whose image wrote none answers
450        ``404 run_writeup_not_found`` rather than empty prose, and the
451        run row's ``writeup`` field already said which — so the caller
452        never has to reach here to find out.
453
454        Wraps ``GET /eval/runs/{run_id}/writeup``."""
455        resp = self._http.request("GET", f"/eval/runs/{run_id}/writeup")
456        _raise_for_status(resp)
457        return RunWriteup.model_validate(resp.json())
458
459    def set_run_wall(self, run_id: str, *, published: bool) -> MyEvalRunRow:
460        """Put this run's agent on the event's projector wall, or take it off.
461
462        ``GET /competitions/{id}/agent-actions`` draws one scrubbed line
463        per agent saying what it is doing. The scrub answers what the
464        bytes make unsafe; this answers the part it cannot — the
465        conversation belongs to whoever ran it, so the wall shows an
466        entrant only once their author has said yes.
467
468        ⚠️ **The switch is on the *entrant*, not on this run.** The
469        transcript index groups by team and an agent's team is one per
470        ``(owner, harness, model, competition)``, so every run of the
471        same agent at the same event shares one answer — which is what
472        "this agent, at this event" means. The row that comes back is
473        the run's, with ``wall_published`` re-read from the entrant.
474
475        ⚠️ **The author's only**: a team-mate who can see the row is
476        answered ``403 run_wall_not_yours``, the same split
477        :meth:`run_writeup` makes, and for the same reason — the line
478        is read out of a conversation they already meet a 403 on.
479
480        ⚠️ A run with no agent entrant answers ``409`` naming which
481        kind of nothing it is; ``MyEvalRunRow.wall`` carries the same
482        code, so a caller never has to press to find out.
483
484        Wraps ``POST /eval/runs/{run_id}/wall``."""
485        resp = self._http.request(
486            "POST",
487            f"/eval/runs/{run_id}/wall",
488            json={"published": published},
489        )
490        _raise_for_status(resp)
491        return MyEvalRunRow.model_validate(resp.json())
492
493    def set_run_transcript(self, run_id: str, *, published: bool) -> MyEvalRunRow:
494        """Publish this run's agent's **full conversation**, or take it back.
495
496        A **second, stronger** consent than :meth:`set_run_wall`. The wall
497        shows one scrubbed line of what the agent is doing; this opens the
498        whole conversation — every turn the agent wrote — to spectators at
499        ``GET /competitions/{id}/awd/transcript/{call_id}``. The projection
500        still emits only the agent's own scrubbed turns (never the system
501        prompt, never a tool result), but the decision to publish a
502        conversation at all is the author's alone and separate from the
503        wall's.
504
505        ⚠️ **The switch is on the *entrant***, exactly as the wall's —
506        one answer per ``(owner, harness, model, competition)``.
507
508        ⚠️ **Refused for an anonymous entrant** (``403
509        run_transcript_anonymous``): a conversation de-anonymises whoever
510        wrote it, so the withheld-identity promise wins over the consent.
511        The author's-only and no-agent refusals mirror :meth:`set_run_wall`.
512
513        Wraps ``POST /eval/runs/{run_id}/transcript``."""
514        resp = self._http.request(
515            "POST",
516            f"/eval/runs/{run_id}/transcript",
517            json={"published": published},
518        )
519        _raise_for_status(resp)
520        return MyEvalRunRow.model_validate(resp.json())
521
522    def registry_push_target(self) -> RegistryPushTarget:
523        """Where this account pushes an agent image, if anywhere.
524
525        ⚠️ Read ``enabled``, not a non-empty ``repository_prefix``. The
526        server composes the verdict from four facts — a host, a token
527        service, and both halves of the signing pair — and a deployment
528        with a hostname but no signing pair refuses every login.
529        """
530        resp = self._http.request("GET", "/eval/registry")
531        _raise_for_status(resp)
532        return RegistryPushTarget.model_validate(resp.json())
533
534    def harness_tags(self, harness_id: str) -> HarnessTags:
535        """Which versions the registry publishes for one of your agents.
536
537        ⚠️ Addressed by **harness id**, not by image: the platform looks
538        the reference up in your own registry row, which is what keeps
539        this from being an outbound proxy. Answers 404 for anything that
540        is not yours, and 502 when the registry itself would not answer
541        — never an empty list, which would read as "no versions".
542        """
543        resp = self._http.request("GET", f"/eval/harnesses/{harness_id}/tags")
544        _raise_for_status(resp)
545        return HarnessTags.model_validate(resp.json())
546
547    def check_harness(self, harness_id: str) -> HarnessState:
548        """Ask the platform to look at one of your agent images.
549
550        Returns the row with the verdict on it: ``conformance_status``
551        (``""`` never checked / ``passed`` / ``failed`` / ``error``),
552        the digest the verdict describes, and a finding per thing worth
553        saying.
554
555        ⚠️ **Evidence, never a gate.** Nothing here changes
556        ``review_status``, and a ``failed`` verdict does not stop an
557        operator approving the image — the check exists so somebody can
558        see *why* a dispatch would go wrong, before it does.
559
560        ⚠️ **``error`` is not ``failed``, and it arrives as a 200.** A
561        registry that would not answer is not a fact about the image;
562        the message is on ``conformance_error``.
563
564        ⚠️ Addressed by harness id, like :meth:`harness_tags` and for
565        the same reason: the image is read off your own registry row, so
566        this cannot be pointed at an arbitrary host.
567        """
568        resp = self._http.request("POST", f"/eval/harnesses/{harness_id}/conformance")
569        _raise_for_status(resp)
570        return HarnessState.model_validate(resp.json())
571
572    def probe_harness(self, harness_id: str) -> TaskState:
573        """Actually run one of your agent images once, to check it.
574
575        Where :meth:`check_harness` reads a manifest, this starts the
576        container on a worker under the same isolation a real dispatch
577        uses, hands it a briefing marked ``dry_run``, and asks the one
578        question a manifest cannot answer: **did it write a result file
579        at the address it was given.**
580
581        ⚠️ **Returns a task, not a verdict.** The image has to be pulled
582        before the container starts, so this is submitted and polled —
583        read the harness row, or the task, for the outcome.
584
585        ⚠️ **No credential travels with the probe** and no platform URL
586        is named. See §3.1 of ``docs/agent-protocol.md``.
587        """
588        resp = self._http.request("POST", f"/eval/harnesses/{harness_id}/probe")
589        _raise_for_status(resp)
590        return TaskState.model_validate(resp.json())

The public model leaderboard (no auth), and the signed-in caller's own harness registry (/eval/harnesses).

EvalResource(http: ctfy.sdk.base.BaseHttpClient)
34    def __init__(self, http: BaseHttpClient) -> None:
35        self._http = http
def leaderboard( self, *, competition_id: str = '', tier: str = 'official') -> ctfy.server.models.EvalLeaderboard:
37    def leaderboard(self, *, competition_id: str = "", tier: str = "official") -> EvalLeaderboard:
38        """Ranked model standings + per-dimension (difficulty / bucket / tag)
39        breakdowns. ``competition_id`` scopes to one competition; empty = global.
40
41        ``tier`` selects the credibility band: ``official`` (default,
42        platform-run), ``self_reported`` (vendor-run, unverified), or ``all``
43        for both. Sandbox runs are never exposed here."""
44        resp = self._http.request(
45            "GET", "/eval/leaderboard", params={"competition_id": competition_id, "tier": tier}
46        )
47        _raise_for_status(resp)
48        return EvalLeaderboard.model_validate(resp.json())

Ranked model standings + per-dimension (difficulty / bucket / tag) breakdowns. competition_id scopes to one competition; empty = global.

tier selects the credibility band: official (default, platform-run), self_reported (vendor-run, unverified), or all for both. Sandbox runs are never exposed here.

def harnesses(self) -> list[ctfy.core.state.models.HarnessState]:
52    def harnesses(self) -> list[HarnessState]:
53        """The harnesses *you* registered, newest first.
54
55        Scoped to the caller — the platform's own images and other
56        users' are not listed here (``/admin/eval-harnesses`` is the
57        operator's whole-registry view).
58        """
59        resp = self._http.request("GET", "/eval/harnesses")
60        _raise_for_status(resp)
61        return [HarnessState.model_validate(row) for row in resp.json()]

The harnesses you registered, newest first.

Scoped to the caller — the platform's own images and other users' are not listed here (/admin/eval-harnesses is the operator's whole-registry view).

def register_harness( self, harness_id: str, image: str, *, display_name: str = '', version: str = '', notes: str = '') -> ctfy.core.state.models.HarnessState:
63    def register_harness(
64        self,
65        harness_id: str,
66        image: str,
67        *,
68        display_name: str = "",
69        version: str = "",
70        notes: str = "",
71    ) -> HarnessState:
72        """Register one of your own agent images.
73
74        ⚠️ The returned row is ``review_status="pending"`` and is **not
75        dispatchable** until an operator approves it — registering an
76        image and being trusted to run one are deliberately two things.
77        ``owner_user_id`` and ``review_status`` are stamped by the
78        server; anything you pass for them is ignored.
79        """
80        resp = self._http.request(
81            "POST",
82            "/eval/harnesses",
83            json={
84                "id": harness_id,
85                "image": image,
86                "display_name": display_name,
87                "version": version,
88                "notes": notes,
89            },
90        )
91        _raise_for_status(resp)
92        return HarnessState.model_validate(resp.json())

Register one of your own agent images.

⚠️ The returned row is review_status="pending" and is not dispatchable until an operator approves it — registering an image and being trusted to run one are deliberately two things. owner_user_id and review_status are stamped by the server; anything you pass for them is ignored.

def update_harness( self, harness_id: str, *, image: str | None = None, display_name: str | None = None, version: str | None = None, notes: str | None = None, source_url: str | None = None, active: bool | None = None) -> ctfy.core.state.models.HarnessState:
 94    def update_harness(
 95        self,
 96        harness_id: str,
 97        *,
 98        image: str | None = None,
 99        display_name: str | None = None,
100        version: str | None = None,
101        notes: str | None = None,
102        source_url: str | None = None,
103        active: bool | None = None,
104    ) -> HarnessState:
105        """Edit one of your own. Anything left ``None`` is untouched.
106
107        ⚠️ **Changing ``image`` puts the row back to
108        ``review_status="pending"``** and clears the approved digest —
109        the verdict was about specific content, and a dispatch pulls the
110        digest rather than the tag. Carrying the approval across would
111        leave the row naming a new reference while the node kept running
112        the old bytes. Re-sending the *same* image is a no-op and costs
113        nothing.
114
115        Anything that is not yours answers 404 rather than 403, like
116        :meth:`delete_harness`.
117        """
118        # ⚠️ Absence is what "leave it alone" means on the wire, so an
119        # unset argument must not appear in the body at all — the server
120        # reads ``model_fields_set``, and a key sent as ``null`` is a key
121        # that was sent.
122        body: dict[str, object] = {
123            name: value
124            for name, value in (
125                ("image", image),
126                ("display_name", display_name),
127                ("version", version),
128                ("notes", notes),
129                ("source_url", source_url),
130                ("active", active),
131            )
132            if value is not None
133        }
134        resp = self._http.request("PATCH", f"/eval/harnesses/{harness_id}", json=body)
135        _raise_for_status(resp)
136        return HarnessState.model_validate(resp.json())

Edit one of your own. Anything left None is untouched.

⚠️ Changing image puts the row back to review_status="pending" and clears the approved digest — the verdict was about specific content, and a dispatch pulls the digest rather than the tag. Carrying the approval across would leave the row naming a new reference while the node kept running the old bytes. Re-sending the same image is a no-op and costs nothing.

Anything that is not yours answers 404 rather than 403, like delete_harness().

def delete_harness(self, harness_id: str) -> bool:
138    def delete_harness(self, harness_id: str) -> bool:
139        """Remove one of your own. Anything that is not yours — another
140        user's, or a platform image — answers 404 rather than 403, so
141        this cannot be used to probe the registry."""
142        resp = self._http.request("DELETE", f"/eval/harnesses/{harness_id}")
143        _raise_for_status(resp)
144        return bool(resp.json().get("deleted", False))

Remove one of your own. Anything that is not yours — another user's, or a platform image — answers 404 rather than 403, so this cannot be used to probe the registry.

def llm_providers(self) -> list[ctfy.server.models.LlmProviderChoice]:
146    def llm_providers(self) -> list[LlmProviderChoice]:
147        """The upstreams this deployment will relay to.
148
149        ⚠️ **Ask rather than assume.** The gateway makes the request to
150        whatever address a key names — from inside its own network, and
151        it returns the answer — so the set is closed and an operator may
152        replace it. A caller carrying its own list would offer addresses
153        this deployment refuses.
154
155        Each entry carries its dialect: a provider *is* a dialect plus an
156        address, and DeepSeek, Moonshot and OpenAI all speak ``openai``.
157        """
158        resp = self._http.request("GET", "/eval/llm-providers")
159        _raise_for_status(resp)
160        return [LlmProviderChoice.model_validate(r) for r in resp.json()]

The upstreams this deployment will relay to.

⚠️ Ask rather than assume. The gateway makes the request to whatever address a key names — from inside its own network, and it returns the answer — so the set is closed and an operator may replace it. A caller carrying its own list would offer addresses this deployment refuses.

Each entry carries its dialect: a provider is a dialect plus an address, and DeepSeek, Moonshot and OpenAI all speak openai.

def my_llm_keys(self) -> list[ctfy.server.models.MyLlmKey]:
162    def my_llm_keys(self) -> list[MyLlmKey]:
163        """Your own upstream credentials — what they are, never what they say.
164
165        ⚠️ There is no ``api_key`` on the way back, by design. A key is
166        pasted once; every read after that lets you tell two apart, not
167        copy one.
168        """
169        resp = self._http.request("GET", "/eval/llm-keys")
170        _raise_for_status(resp)
171        return [MyLlmKey.model_validate(r) for r in resp.json()]

Your own upstream credentials — what they are, never what they say.

⚠️ There is no api_key on the way back, by design. A key is pasted once; every read after that lets you tell two apart, not copy one.

def put_my_llm_key( self, flavor: str, api_key: str, *, label: str = '', base_url: str = '') -> ctfy.server.models.MyLlmKey:
173    def put_my_llm_key(
174        self, flavor: str, api_key: str, *, label: str = "", base_url: str = ""
175    ) -> MyLlmKey:
176        """Store or rotate the credential this account spends for ``flavor``.
177
178        A repeat dialect replaces: rotation is a write, because
179        delete-then-add leaves a window in which a run has no key.
180        Answers 503 on a deployment that cannot seal secrets at rest —
181        the key is never stored in the clear.
182        """
183        resp = self._http.request(
184            "PUT",
185            f"/eval/llm-keys/{flavor}",
186            json={"api_key": api_key, "label": label, "base_url": base_url},
187        )
188        _raise_for_status(resp)
189        return MyLlmKey.model_validate(resp.json())

Store or rotate the credential this account spends for flavor.

A repeat dialect replaces: rotation is a write, because delete-then-add leaves a window in which a run has no key. Answers 503 on a deployment that cannot seal secrets at rest — the key is never stored in the clear.

def delete_my_llm_key(self, flavor: str, *, base_url: str = '') -> bool:
191    def delete_my_llm_key(self, flavor: str, *, base_url: str = "") -> bool:
192        """Remove it. 404 for a provider you hold no key for — the same
193        answer somebody else's key gets, so this cannot report who holds
194        what.
195
196        ⚠️ ``base_url`` is part of the address, not a filter. Naming only
197        the dialect is ambiguous the moment you hold two ``openai``
198        providers, and resolving that by picking one is how somebody
199        loses the key they meant to keep. ``""`` names that dialect's
200        official endpoint, which is a value rather than a wildcard.
201        """
202        resp = self._http.request(
203            "DELETE", f"/eval/llm-keys/{flavor}", params={"base_url": base_url}
204        )
205        _raise_for_status(resp)
206        return bool(resp.json().get("deleted", False))

Remove it. 404 for a provider you hold no key for — the same answer somebody else's key gets, so this cannot report who holds what.

⚠️ base_url is part of the address, not a filter. Naming only the dialect is ambiguous the moment you hold two openai providers, and resolving that by picking one is how somebody loses the key they meant to keep. "" names that dialect's official endpoint, which is a value rather than a wildcard.

def probe_my_llm_key( self, flavor: str, *, base_url: str = '') -> ctfy.server.models.MyLlmKeyProbe:
208    def probe_my_llm_key(self, flavor: str, *, base_url: str = "") -> MyLlmKeyProbe:
209        """Spend one token on your own credential and report what happened.
210
211        Storing a key says the write landed; it says nothing about
212        whether the credential **works**. The only other way to find
213        that out is to enter an agent, let a run be dispatched, and read
214        the failure out of the artifact tarball — two hours, on a full
215        range, to learn that a key is invalid.
216
217        ⚠️ **A code, never a boolean.** A rejected key, an address
218        nothing answers at, an account out of credit and a deployment
219        with no model registered at this endpoint are four different
220        fields to go and fix.
221
222        ⚠️ **200 with the outcome in the body** for every one of them —
223        so this does not raise on a failed credential. A 404 (and only a
224        404) means you hold no key for that provider.
225
226        ⚠️ ``base_url`` is part of the address, exactly as it is for the
227        removal beside it: naming the dialect alone picks whichever row
228        matched first the moment you hold two ``openai`` providers.
229        """
230        resp = self._http.request(
231            "POST", f"/eval/llm-keys/{flavor}/probe", params={"base_url": base_url}
232        )
233        _raise_for_status(resp)
234        return MyLlmKeyProbe.model_validate(resp.json())

Spend one token on your own credential and report what happened.

Storing a key says the write landed; it says nothing about whether the credential works. The only other way to find that out is to enter an agent, let a run be dispatched, and read the failure out of the artifact tarball — two hours, on a full range, to learn that a key is invalid.

⚠️ A code, never a boolean. A rejected key, an address nothing answers at, an account out of credit and a deployment with no model registered at this endpoint are four different fields to go and fix.

⚠️ 200 with the outcome in the body for every one of them — so this does not raise on a failed credential. A 404 (and only a 404) means you hold no key for that provider.

⚠️ base_url is part of the address, exactly as it is for the removal beside it: naming the dialect alone picks whichever row matched first the moment you hold two openai providers.

def my_allowances(self) -> list[ctfy.server.models.EvalAllowanceRow]:
236    def my_allowances(self) -> list[EvalAllowanceRow]:
237        """What an operator has granted this account, and how much is left.
238
239        ⚠️ Read ``remaining_usd`` rather than subtracting: the server
240        clamps an overshoot at zero, and a client doing its own
241        arithmetic would eventually disagree with the refusal that
242        actually decides.
243        """
244        resp = self._http.request("GET", "/eval/allowances")
245        _raise_for_status(resp)
246        return [EvalAllowanceRow.model_validate(r) for r in resp.json()]

What an operator has granted this account, and how much is left.

⚠️ Read remaining_usd rather than subtracting: the server clamps an overshoot at zero, and a client doing its own arithmetic would eventually disagree with the refusal that actually decides.

def models(self) -> list[ctfy.server.models.LlmModelOption]:
248    def models(self) -> list[LlmModelOption]:
249        """The models an entry may name.
250
251        ⚠️ Approved and active only, decided by the server. The registry
252        holds more than this — the operator's own notes and the
253        addressing that says where their credential is spent — and none
254        of it travels.
255        """
256        resp = self._http.request("GET", "/eval/models")
257        _raise_for_status(resp)
258        return [LlmModelOption.model_validate(r) for r in resp.json()]

The models an entry may name.

⚠️ Approved and active only, decided by the server. The registry holds more than this — the operator's own notes and the addressing that says where their credential is spent — and none of it travels.

def enter( self, *, harness_id: str, model_id: str, competition_id: str, num_samples: int = 1) -> ctfy.core.state.models.EvalCampaignState:
260    def enter(
261        self,
262        *,
263        harness_id: str,
264        model_id: str,
265        competition_id: str,
266        num_samples: int = 1,
267    ) -> EvalCampaignState:
268        """Put one of your own agents into one competition.
269
270        ⚠️ **The result is `self_reported`, and the server decides that.**
271        The default public leaderboard shows operator-run results; this
272        is your own claim about your own agent, so read it back with
273        ``tier="self_reported"`` or ``"all"``. There is no parameter for
274        it here because there is none on the wire.
275
276        ⚠️ **409 carries the reason as its ``code``** — whether to wait
277        for review, add a key, or pick another event. A caller that
278        reports only "entry refused" throws away the only actionable
279        part of the answer.
280        """
281        resp = self._http.request(
282            "POST",
283            "/eval/entries",
284            json={
285                "harness_id": harness_id,
286                "model_id": model_id,
287                "competition_id": competition_id,
288                "num_samples": num_samples,
289            },
290        )
291        _raise_for_status(resp)
292        return EvalCampaignState.model_validate(resp.json())

Put one of your own agents into one competition.

⚠️ The result is self_reported, and the server decides that. The default public leaderboard shows operator-run results; this is your own claim about your own agent, so read it back with tier="self_reported" or "all". There is no parameter for it here because there is none on the wire.

⚠️ 409 carries the reason as its code — whether to wait for review, add a key, or pick another event. A caller that reports only "entry refused" throws away the only actionable part of the answer.

def my_entries( self, *, competition_id: str = '', offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.core.state.models.EvalCampaignState]:
294    def my_entries(
295        self, *, competition_id: str = "", offset: int = 0, limit: int = 50
296    ) -> PagedList[EvalCampaignState]:
297        """The campaigns this account entered, newest first — one page,
298        with the total (``limit`` at most 200).
299
300        Only entries: an operator campaign the same account queued is
301        not one of them, because the two are different claims about the
302        same board. ``competition_id`` narrows to entries into that one
303        competition, on the server.
304
305        ⚠️ This returned a bare list, the server's newest 200, with
306        nothing to say the list was cut; ``total`` says how many exist.
307        """
308        params: dict[str, Any] = {"offset": offset, "limit": limit}
309        if competition_id:
310            params["competition_id"] = competition_id
311        resp = self._http.request("GET", "/eval/entries", params=params)
312        _raise_for_status(resp)
313        return _extract_items(resp.json(), EvalCampaignState)

The campaigns this account entered, newest first — one page, with the total (limit at most 200).

Only entries: an operator campaign the same account queued is not one of them, because the two are different claims about the same board. competition_id narrows to entries into that one competition, on the server.

⚠️ This returned a bare list, the server's newest 200, with nothing to say the list was cut; total says how many exist.

def runs( self, *, competition_id: str = '', offset: int = 0, limit: int = 50) -> ctfy.server.models.MyEvalRunPage:
315    def runs(self, *, competition_id: str = "", offset: int = 0, limit: int = 50) -> MyEvalRunPage:
316        """Evaluation runs you may read — your own plus your team-mates'.
317
318        ⚠️ The author set is the same membership join the per-run routes
319        enforce, so a run this lists can always be opened and a run it
320        omits always 404s. Two readings of "the same team" would drift
321        in the open direction.
322
323        ⚠️ ``mine`` on each row says whether *you* may open the
324        conversations. It is composed server-side because it gates a
325        control, and two clients deriving it would disagree.
326        Wraps ``GET /eval/runs``."""
327        resp = self._http.request(
328            "GET",
329            "/eval/runs",
330            params={"competition_id": competition_id, "offset": offset, "limit": limit},
331        )
332        _raise_for_status(resp)
333        return MyEvalRunPage.model_validate(resp.json())

Evaluation runs you may read — your own plus your team-mates'.

⚠️ The author set is the same membership join the per-run routes enforce, so a run this lists can always be opened and a run it omits always 404s. Two readings of "the same team" would drift in the open direction.

⚠️ mine on each row says whether you may open the conversations. It is composed server-side because it gates a control, and two clients deriving it would disagree. Wraps GET /eval/runs.

def run(self, run_id: str) -> ctfy.server.models.MyEvalRunRow:
335    def run(self, run_id: str) -> MyEvalRunRow:
336        """One of your runs, projected as the shelf projects it.
337
338        ⚠️ Addressable because :meth:`runs` is paginated: one campaign
339        over a large bucket is hundreds of runs, so finding a row by
340        scanning the first page is a bug with a threshold.
341
342        Metadata scope — a team-mate reads the row, and its ``traffic``
343        field says ``not_yours`` rather than the route refusing.
344        Wraps ``GET /eval/runs/{run_id}``."""
345        resp = self._http.request("GET", f"/eval/runs/{run_id}")
346        _raise_for_status(resp)
347        return MyEvalRunRow.model_validate(resp.json())

One of your runs, projected as the shelf projects it.

⚠️ Addressable because runs() is paginated: one campaign over a large bucket is hundreds of runs, so finding a row by scanning the first page is a bug with a threshold.

Metadata scope — a team-mate reads the row, and its traffic field says not_yours rather than the route refusing. Wraps GET /eval/runs/{run_id}.

def run_llm_calls( self, run_id: str, *, offset: int = 0, limit: int = 50) -> ctfy.server.models.LlmCallPage:
349    def run_llm_calls(self, run_id: str, *, offset: int = 0, limit: int = 50) -> LlmCallPage:
350        """Every model request one of your runs made, newest first.
351
352        Metadata only — token split, latency, queue wait, the estimated
353        cost. **Scoped to the run's team**, so a captain can see who is
354        burning the allowance without reading anybody's prompts, and to
355        ``principal == "agent"``, so a challenge container's own
356        conversation is never on this route.
357        Wraps ``GET /eval/runs/{run_id}/llm-calls``."""
358        resp = self._http.request(
359            "GET",
360            f"/eval/runs/{run_id}/llm-calls",
361            params={"offset": offset, "limit": limit},
362        )
363        _raise_for_status(resp)
364        return LlmCallPage.model_validate(resp.json())

Every model request one of your runs made, newest first.

Metadata only — token split, latency, queue wait, the estimated cost. Scoped to the run's team, so a captain can see who is burning the allowance without reading anybody's prompts, and to principal == "agent", so a challenge container's own conversation is never on this route. Wraps GET /eval/runs/{run_id}/llm-calls.

def run_llm_call(self, run_id: str, call_id: str) -> ctfy.server.models.LlmCallBody:
366    def run_llm_call(self, run_id: str, call_id: str) -> LlmCallBody:
367        """The conversation itself — **the run author's only**.
368
369        ⚠️ Narrower than :meth:`run_llm_calls` on purpose: a team-mate
370        who can see the row is answered ``403
371        transcript_body_owner_only`` here, since an agent's prompt is its
372        author's work and ``HarnessStanding`` ranks the two of them
373        against each other.
374
375        ⚠️ ``available=False`` is an ordinary answer, not an error —
376        retention reclaims bodies and leaves the row behind.
377        Wraps ``GET /eval/runs/{run_id}/llm-calls/{call_id}``."""
378        resp = self._http.request("GET", f"/eval/runs/{run_id}/llm-calls/{call_id}")
379        _raise_for_status(resp)
380        return LlmCallBody.model_validate(resp.json())

The conversation itself — the run author's only.

⚠️ Narrower than run_llm_calls() on purpose: a team-mate who can see the row is answered 403 transcript_body_owner_only here, since an agent's prompt is its author's work and HarnessStanding ranks the two of them against each other.

⚠️ available=False is an ordinary answer, not an error — retention reclaims bodies and leaves the row behind. Wraps GET /eval/runs/{run_id}/llm-calls/{call_id}.

def run_artifact(self, run_id: str) -> bytes:
382    def run_artifact(self, run_id: str) -> bytes:
383        """The files one of your runs left behind, as ``.tar.gz`` bytes.
384
385        Its working directory and, when the harness is Claude Code, its
386        session — collected from the container before it was removed.
387        The writing half, where :meth:`run_llm_call` is the deciding one
388        and :meth:`run_traffic` the doing one.
389
390        ⚠️ **The run author's only**, the narrowest of the three: a
391        team-mate who can see the row is answered ``403
392        run_artifact_owner_only``. An archive carries whatever the agent
393        wrote down, which for a solved challenge includes the flag.
394
395        ⚠️ **Bytes, not a model, and no URL.** The read goes through the
396        platform rather than handing out a bucket address, so there is
397        nothing to return but the archive itself. ⚠️ A run that kept no
398        files answers ``404 run_artifact_not_found`` rather than empty
399        bytes — which of the several reasons it is (the agent wrote
400        nothing, collection failed, retention took them) is on the run
401        row's ``artifact`` field, so the caller never has to guess from
402        an absence.
403
404        Wraps ``GET /eval/runs/{run_id}/artifact``."""
405        resp = self._http.request("GET", f"/eval/runs/{run_id}/artifact")
406        _raise_for_status(resp)
407        return bytes(resp.content)

The files one of your runs left behind, as .tar.gz bytes.

Its working directory and, when the harness is Claude Code, its session — collected from the container before it was removed. The writing half, where run_llm_call() is the deciding one and run_traffic() the doing one.

⚠️ The run author's only, the narrowest of the three: a team-mate who can see the row is answered 403 run_artifact_owner_only. An archive carries whatever the agent wrote down, which for a solved challenge includes the flag.

⚠️ Bytes, not a model, and no URL. The read goes through the platform rather than handing out a bucket address, so there is nothing to return but the archive itself. ⚠️ A run that kept no files answers 404 run_artifact_not_found rather than empty bytes — which of the several reasons it is (the agent wrote nothing, collection failed, retention took them) is on the run row's artifact field, so the caller never has to guess from an absence.

Wraps GET /eval/runs/{run_id}/artifact.

def run_traffic( self, run_id: str, *, cursor: str = '', limit: int = 0) -> ctfy.server.models.InstanceTrafficPage:
409    def run_traffic(self, run_id: str, *, cursor: str = "", limit: int = 0) -> InstanceTrafficPage:
410        """Every request one of your runs sent at the challenge.
411
412        The doing half, where :meth:`run_llm_call` is the deciding half.
413        **The run author's only** — a team-mate who can see the row is
414        answered ``403 run_traffic_owner_only``, the same split the
415        conversation body makes, because the payloads an agent wrote are
416        its author's work.
417
418        ⚠️ Carry ``cursor`` forward from the previous page and you get
419        only what arrived since; pass an empty one and you get the whole
420        capture again. Every failure — an unreachable node, a run whose
421        estate the reaper has taken — answers 200 with your own cursor
422        and no flows, so a blip never re-delivers the capture. Which of
423        those it was, ``MyEvalRunRow.traffic`` says.
424
425        ⚠️ Redacted: this instance's minted answers are scrubbed out.
426        Wraps ``GET /eval/runs/{run_id}/traffic``."""
427        resp = self._http.request(
428            "GET",
429            f"/eval/runs/{run_id}/traffic",
430            params={"cursor": cursor, "limit": limit},
431        )
432        _raise_for_status(resp)
433        return InstanceTrafficPage.model_validate(resp.json())

Every request one of your runs sent at the challenge.

The doing half, where run_llm_call() is the deciding half. The run author's only — a team-mate who can see the row is answered 403 run_traffic_owner_only, the same split the conversation body makes, because the payloads an agent wrote are its author's work.

⚠️ Carry cursor forward from the previous page and you get only what arrived since; pass an empty one and you get the whole capture again. Every failure — an unreachable node, a run whose estate the reaper has taken — answers 200 with your own cursor and no flows, so a blip never re-delivers the capture. Which of those it was, MyEvalRunRow.traffic says.

⚠️ Redacted: this instance's minted answers are scrubbed out. Wraps GET /eval/runs/{run_id}/traffic.

def run_writeup(self, run_id: str) -> ctfy.server.models.RunWriteup:
435    def run_writeup(self, run_id: str) -> RunWriteup:
436        """What one of your runs said it did, in its own words.
437
438        ``CTFY-0001`` §3 invites an optional ``writeup`` in the result
439        file. The saying half, where :meth:`run_llm_call` is the
440        deciding one, :meth:`run_traffic` the doing one and
441        :meth:`run_artifact` the writing one.
442
443        ⚠️ **Telemetry, never a grading path.** Nothing in the result
444        file is scored — the run's own ``POST /submissions`` is — so a
445        write-up cannot earn a point and is not read as an answer.
446
447        ⚠️ **The run author's only**: a team-mate who can see the row is
448        answered ``403 run_writeup_owner_only``, the same split the
449        other three bodies make. ⚠️ A run whose image wrote none answers
450        ``404 run_writeup_not_found`` rather than empty prose, and the
451        run row's ``writeup`` field already said which — so the caller
452        never has to reach here to find out.
453
454        Wraps ``GET /eval/runs/{run_id}/writeup``."""
455        resp = self._http.request("GET", f"/eval/runs/{run_id}/writeup")
456        _raise_for_status(resp)
457        return RunWriteup.model_validate(resp.json())

What one of your runs said it did, in its own words.

CTFY-0001 §3 invites an optional writeup in the result file. The saying half, where run_llm_call() is the deciding one, run_traffic() the doing one and run_artifact() the writing one.

⚠️ Telemetry, never a grading path. Nothing in the result file is scored — the run's own POST /submissions is — so a write-up cannot earn a point and is not read as an answer.

⚠️ The run author's only: a team-mate who can see the row is answered 403 run_writeup_owner_only, the same split the other three bodies make. ⚠️ A run whose image wrote none answers 404 run_writeup_not_found rather than empty prose, and the run row's writeup field already said which — so the caller never has to reach here to find out.

Wraps GET /eval/runs/{run_id}/writeup.

def set_run_wall( self, run_id: str, *, published: bool) -> ctfy.server.models.MyEvalRunRow:
459    def set_run_wall(self, run_id: str, *, published: bool) -> MyEvalRunRow:
460        """Put this run's agent on the event's projector wall, or take it off.
461
462        ``GET /competitions/{id}/agent-actions`` draws one scrubbed line
463        per agent saying what it is doing. The scrub answers what the
464        bytes make unsafe; this answers the part it cannot — the
465        conversation belongs to whoever ran it, so the wall shows an
466        entrant only once their author has said yes.
467
468        ⚠️ **The switch is on the *entrant*, not on this run.** The
469        transcript index groups by team and an agent's team is one per
470        ``(owner, harness, model, competition)``, so every run of the
471        same agent at the same event shares one answer — which is what
472        "this agent, at this event" means. The row that comes back is
473        the run's, with ``wall_published`` re-read from the entrant.
474
475        ⚠️ **The author's only**: a team-mate who can see the row is
476        answered ``403 run_wall_not_yours``, the same split
477        :meth:`run_writeup` makes, and for the same reason — the line
478        is read out of a conversation they already meet a 403 on.
479
480        ⚠️ A run with no agent entrant answers ``409`` naming which
481        kind of nothing it is; ``MyEvalRunRow.wall`` carries the same
482        code, so a caller never has to press to find out.
483
484        Wraps ``POST /eval/runs/{run_id}/wall``."""
485        resp = self._http.request(
486            "POST",
487            f"/eval/runs/{run_id}/wall",
488            json={"published": published},
489        )
490        _raise_for_status(resp)
491        return MyEvalRunRow.model_validate(resp.json())

Put this run's agent on the event's projector wall, or take it off.

GET /competitions/{id}/agent-actions draws one scrubbed line per agent saying what it is doing. The scrub answers what the bytes make unsafe; this answers the part it cannot — the conversation belongs to whoever ran it, so the wall shows an entrant only once their author has said yes.

⚠️ The switch is on the entrant, not on this run. The transcript index groups by team and an agent's team is one per (owner, harness, model, competition), so every run of the same agent at the same event shares one answer — which is what "this agent, at this event" means. The row that comes back is the run's, with wall_published re-read from the entrant.

⚠️ The author's only: a team-mate who can see the row is answered 403 run_wall_not_yours, the same split run_writeup() makes, and for the same reason — the line is read out of a conversation they already meet a 403 on.

⚠️ A run with no agent entrant answers 409 naming which kind of nothing it is; MyEvalRunRow.wall carries the same code, so a caller never has to press to find out.

Wraps POST /eval/runs/{run_id}/wall.

def set_run_transcript( self, run_id: str, *, published: bool) -> ctfy.server.models.MyEvalRunRow:
493    def set_run_transcript(self, run_id: str, *, published: bool) -> MyEvalRunRow:
494        """Publish this run's agent's **full conversation**, or take it back.
495
496        A **second, stronger** consent than :meth:`set_run_wall`. The wall
497        shows one scrubbed line of what the agent is doing; this opens the
498        whole conversation — every turn the agent wrote — to spectators at
499        ``GET /competitions/{id}/awd/transcript/{call_id}``. The projection
500        still emits only the agent's own scrubbed turns (never the system
501        prompt, never a tool result), but the decision to publish a
502        conversation at all is the author's alone and separate from the
503        wall's.
504
505        ⚠️ **The switch is on the *entrant***, exactly as the wall's —
506        one answer per ``(owner, harness, model, competition)``.
507
508        ⚠️ **Refused for an anonymous entrant** (``403
509        run_transcript_anonymous``): a conversation de-anonymises whoever
510        wrote it, so the withheld-identity promise wins over the consent.
511        The author's-only and no-agent refusals mirror :meth:`set_run_wall`.
512
513        Wraps ``POST /eval/runs/{run_id}/transcript``."""
514        resp = self._http.request(
515            "POST",
516            f"/eval/runs/{run_id}/transcript",
517            json={"published": published},
518        )
519        _raise_for_status(resp)
520        return MyEvalRunRow.model_validate(resp.json())

Publish this run's agent's full conversation, or take it back.

A second, stronger consent than set_run_wall(). The wall shows one scrubbed line of what the agent is doing; this opens the whole conversation — every turn the agent wrote — to spectators at GET /competitions/{id}/awd/transcript/{call_id}. The projection still emits only the agent's own scrubbed turns (never the system prompt, never a tool result), but the decision to publish a conversation at all is the author's alone and separate from the wall's.

⚠️ The switch is on the entrant, exactly as the wall's — one answer per (owner, harness, model, competition).

⚠️ Refused for an anonymous entrant (403 run_transcript_anonymous): a conversation de-anonymises whoever wrote it, so the withheld-identity promise wins over the consent. The author's-only and no-agent refusals mirror set_run_wall().

Wraps POST /eval/runs/{run_id}/transcript.

def registry_push_target(self) -> ctfy.server.models.RegistryPushTarget:
522    def registry_push_target(self) -> RegistryPushTarget:
523        """Where this account pushes an agent image, if anywhere.
524
525        ⚠️ Read ``enabled``, not a non-empty ``repository_prefix``. The
526        server composes the verdict from four facts — a host, a token
527        service, and both halves of the signing pair — and a deployment
528        with a hostname but no signing pair refuses every login.
529        """
530        resp = self._http.request("GET", "/eval/registry")
531        _raise_for_status(resp)
532        return RegistryPushTarget.model_validate(resp.json())

Where this account pushes an agent image, if anywhere.

⚠️ Read enabled, not a non-empty repository_prefix. The server composes the verdict from four facts — a host, a token service, and both halves of the signing pair — and a deployment with a hostname but no signing pair refuses every login.

def harness_tags(self, harness_id: str) -> ctfy.server.models.HarnessTags:
534    def harness_tags(self, harness_id: str) -> HarnessTags:
535        """Which versions the registry publishes for one of your agents.
536
537        ⚠️ Addressed by **harness id**, not by image: the platform looks
538        the reference up in your own registry row, which is what keeps
539        this from being an outbound proxy. Answers 404 for anything that
540        is not yours, and 502 when the registry itself would not answer
541        — never an empty list, which would read as "no versions".
542        """
543        resp = self._http.request("GET", f"/eval/harnesses/{harness_id}/tags")
544        _raise_for_status(resp)
545        return HarnessTags.model_validate(resp.json())

Which versions the registry publishes for one of your agents.

⚠️ Addressed by harness id, not by image: the platform looks the reference up in your own registry row, which is what keeps this from being an outbound proxy. Answers 404 for anything that is not yours, and 502 when the registry itself would not answer — never an empty list, which would read as "no versions".

def check_harness(self, harness_id: str) -> ctfy.core.state.models.HarnessState:
547    def check_harness(self, harness_id: str) -> HarnessState:
548        """Ask the platform to look at one of your agent images.
549
550        Returns the row with the verdict on it: ``conformance_status``
551        (``""`` never checked / ``passed`` / ``failed`` / ``error``),
552        the digest the verdict describes, and a finding per thing worth
553        saying.
554
555        ⚠️ **Evidence, never a gate.** Nothing here changes
556        ``review_status``, and a ``failed`` verdict does not stop an
557        operator approving the image — the check exists so somebody can
558        see *why* a dispatch would go wrong, before it does.
559
560        ⚠️ **``error`` is not ``failed``, and it arrives as a 200.** A
561        registry that would not answer is not a fact about the image;
562        the message is on ``conformance_error``.
563
564        ⚠️ Addressed by harness id, like :meth:`harness_tags` and for
565        the same reason: the image is read off your own registry row, so
566        this cannot be pointed at an arbitrary host.
567        """
568        resp = self._http.request("POST", f"/eval/harnesses/{harness_id}/conformance")
569        _raise_for_status(resp)
570        return HarnessState.model_validate(resp.json())

Ask the platform to look at one of your agent images.

Returns the row with the verdict on it: conformance_status ("" never checked / passed / failed / error), the digest the verdict describes, and a finding per thing worth saying.

⚠️ Evidence, never a gate. Nothing here changes review_status, and a failed verdict does not stop an operator approving the image — the check exists so somebody can see why a dispatch would go wrong, before it does.

⚠️ error is not failed, and it arrives as a 200. A registry that would not answer is not a fact about the image; the message is on conformance_error.

⚠️ Addressed by harness id, like harness_tags() and for the same reason: the image is read off your own registry row, so this cannot be pointed at an arbitrary host.

def probe_harness(self, harness_id: str) -> ctfy.core.state.models.TaskState:
572    def probe_harness(self, harness_id: str) -> TaskState:
573        """Actually run one of your agent images once, to check it.
574
575        Where :meth:`check_harness` reads a manifest, this starts the
576        container on a worker under the same isolation a real dispatch
577        uses, hands it a briefing marked ``dry_run``, and asks the one
578        question a manifest cannot answer: **did it write a result file
579        at the address it was given.**
580
581        ⚠️ **Returns a task, not a verdict.** The image has to be pulled
582        before the container starts, so this is submitted and polled —
583        read the harness row, or the task, for the outcome.
584
585        ⚠️ **No credential travels with the probe** and no platform URL
586        is named. See §3.1 of ``docs/agent-protocol.md``.
587        """
588        resp = self._http.request("POST", f"/eval/harnesses/{harness_id}/probe")
589        _raise_for_status(resp)
590        return TaskState.model_validate(resp.json())

Actually run one of your agent images once, to check it.

Where check_harness() reads a manifest, this starts the container on a worker under the same isolation a real dispatch uses, hands it a briefing marked dry_run, and asks the one question a manifest cannot answer: did it write a result file at the address it was given.

⚠️ Returns a task, not a verdict. The image has to be pulled before the container starts, so this is submitted and polled — read the harness row, or the task, for the outcome.

⚠️ No credential travels with the probe and no platform URL is named. See §3.1 of docs/agent-protocol.md.

class RegistryResource:
593class RegistryResource:
594    """The container registry's token endpoint.
595
596    ⚠️ **Not a method anybody calls from Python.** ``docker`` follows
597    the registry's ``WWW-Authenticate`` realm here on its own, with HTTP
598    Basic; this exists so the SDK covers the surface and so a test can
599    drive the exchange the way docker does. The credential goes in the
600    *password* half — that is where ``docker login -p`` puts it.
601    """
602
603    def __init__(self, http: BaseHttpClient) -> None:
604        self._http = http
605
606    def token(self, *, service: str, scope: str | Sequence[str] = "") -> dict[str, Any]:
607        # ⚠️ A sequence is sent as a repeated ``scope`` parameter, because
608        # that is what docker does for a cross-repository blob mount —
609        # ``push`` on the destination and ``pull`` on the source, in one
610        # exchange. This method's whole stated purpose is that a test can
611        # drive the exchange the way docker does, and it could not.
612        params: list[tuple[str, str]] = [("service", service)]
613        if isinstance(scope, str):
614            if scope:
615                params.append(("scope", scope))
616        else:
617            params.extend(("scope", s) for s in scope if s)
618        resp = self._http.request("GET", "/registry/token", params=params)
619        _raise_for_status(resp)
620        body: dict[str, Any] = resp.json()
621        return body

The container registry's token endpoint.

⚠️ Not a method anybody calls from Python. docker follows the registry's WWW-Authenticate realm here on its own, with HTTP Basic; this exists so the SDK covers the surface and so a test can drive the exchange the way docker does. The credential goes in the password half — that is where docker login -p puts it.

RegistryResource(http: ctfy.sdk.base.BaseHttpClient)
603    def __init__(self, http: BaseHttpClient) -> None:
604        self._http = http
def token( self, *, service: str, scope: str | Sequence[str] = '') -> dict[str, typing.Any]:
606    def token(self, *, service: str, scope: str | Sequence[str] = "") -> dict[str, Any]:
607        # ⚠️ A sequence is sent as a repeated ``scope`` parameter, because
608        # that is what docker does for a cross-repository blob mount —
609        # ``push`` on the destination and ``pull`` on the source, in one
610        # exchange. This method's whole stated purpose is that a test can
611        # drive the exchange the way docker does, and it could not.
612        params: list[tuple[str, str]] = [("service", service)]
613        if isinstance(scope, str):
614            if scope:
615                params.append(("scope", scope))
616        else:
617            params.extend(("scope", s) for s in scope if s)
618        resp = self._http.request("GET", "/registry/token", params=params)
619        _raise_for_status(resp)
620        body: dict[str, Any] = resp.json()
621        return body