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.eval_atomic import CancelMode 9from ctfy.core.state.models import ( 10 EvalCampaignState, 11 EvalFundingSource, 12 HarnessState, 13 HarnessVersionState, 14) 15from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status 16from ctfy.sdk.base import BaseHttpClient 17from ctfy.server.models import ( 18 AgentLogPage, 19 EvalAllowanceRow, 20 EvalBatchCancelResult, 21 EvalBatchLimits, 22 EvalBatchModel, 23 EvalBatchRequest, 24 EvalBatchState, 25 EvalCampaignAtomicSummary, 26 EvalCancelRequest, 27 EvalLeaderboard, 28 EvalQueueChange, 29 EvalRunEventList, 30 HarnessTags, 31 InstanceTrafficPage, 32 LlmAccountChoice, 33 LlmBalanceRefresh, 34 LlmCallBody, 35 LlmCallPage, 36 LlmConnectionRef, 37 LlmLivePage, 38 LlmModelOption, 39 LlmProviderChoice, 40 LlmSpendCapUpdate, 41 LlmUsagePage, 42 MyEvalRunPage, 43 MyEvalRunRow, 44 MyHarness, 45 MyLlmConnection, 46 MyLlmConnectionCreate, 47 MyLlmKey, 48 MyLlmKeyProbe, 49 RegistryPushTarget, 50 RunWriteup, 51) 52 53 54class EvalResource: 55 """The public model leaderboard (no auth), and the signed-in caller's 56 own harness registry (``/eval/harnesses``).""" 57 58 def __init__(self, http: BaseHttpClient) -> None: 59 self._http = http 60 61 def leaderboard( 62 self, 63 competition_id: str, 64 *, 65 tier: str = "official", 66 corpus_sha: str = "", 67 q: str = "", 68 vendor: str = "", 69 harness_id: str | None = None, 70 offset: int = 0, 71 limit: int | None = None, 72 ) -> EvalLeaderboard: 73 """Ranked model standings + per-dimension (difficulty / bucket / tag) 74 breakdowns for **one** competition or suite. There is no platform-wide 75 board (#2341): runs graded under different sets do not rank together. 76 77 ``tier`` selects the credibility band: ``official`` (default, 78 platform-run), ``self_reported`` (vendor-run, unverified), or ``all`` 79 for both. Sandbox runs are never exposed here. ``corpus_sha`` counts 80 only runs graded against that version (a suite's pin); empty is every 81 version. 82 83 #2733: ``harness_id`` scopes the aggregate to one harness (``""`` = 84 the built-in one; ``None`` = every harness) and ranks inside it. 85 ``q`` (model search), ``vendor`` (slug), ``offset`` and ``limit`` 86 pick rows out of the scope without re-ranking; ``result.scope`` 87 reports the revision, manifest, freshness, filters and totals.""" 88 if not competition_id: 89 raise ValueError("competition_id is required: there is no platform-wide board") 90 params: dict[str, str | int] = { 91 "competition_id": competition_id, 92 "tier": tier, 93 "corpus_sha": corpus_sha, 94 "q": q, 95 "vendor": vendor, 96 "offset": offset, 97 } 98 # ⚠️ ``""`` is a real harness (the built-in one), so only ``None`` 99 # leaves the parameter out. 100 if harness_id is not None: 101 params["harness_id"] = harness_id 102 if limit is not None: 103 params["limit"] = limit 104 resp = self._http.request("GET", "/eval/leaderboard", params=params) 105 _raise_for_status(resp) 106 return EvalLeaderboard.model_validate(resp.json()) 107 108 # -- your own agent images ------------------------------------------- 109 110 def harnesses(self) -> list[HarnessState]: 111 """The harnesses *you* registered, newest first. 112 113 Scoped to the caller — the platform's own images and other 114 users' are not listed here (``/admin/eval-harnesses`` is the 115 operator's whole-registry view). 116 """ 117 resp = self._http.request("GET", "/eval/harnesses") 118 _raise_for_status(resp) 119 return [HarnessState.model_validate(row) for row in resp.json()] 120 121 def harness_versions(self, harness_id: str) -> list[HarnessVersionState]: 122 """Your agent's saved immutable releases, newest first.""" 123 resp = self._http.request("GET", f"/eval/harnesses/{harness_id}/versions") 124 _raise_for_status(resp) 125 return [HarnessVersionState.model_validate(row) for row in resp.json()] 126 127 def create_harness_version(self, harness_id: str) -> HarnessVersionState: 128 """Save the currently reviewed image; the first release becomes default.""" 129 resp = self._http.request("POST", f"/eval/harnesses/{harness_id}/versions") 130 _raise_for_status(resp) 131 return HarnessVersionState.model_validate(resp.json()) 132 133 def register_harness( 134 self, 135 harness_id: str, 136 image: str, 137 *, 138 display_name: str = "", 139 version: str = "", 140 notes: str = "", 141 dialect: str = "", 142 ) -> HarnessState: 143 """Register one of your own agent images. 144 145 ⚠️ The returned row is ``review_status="pending"`` and is **not 146 dispatchable** until an operator approves it — registering an 147 image and being trusted to run one are deliberately two things. 148 ``owner_user_id`` and ``review_status`` are stamped by the 149 server; anything you pass for them is ignored. 150 """ 151 resp = self._http.request( 152 "POST", 153 "/eval/harnesses", 154 json={ 155 "id": harness_id, 156 "image": image, 157 "display_name": display_name, 158 "version": version, 159 "notes": notes, 160 # #3473: the wire surface the agent calls; "" is not declared. 161 "dialect": dialect, 162 }, 163 ) 164 _raise_for_status(resp) 165 return HarnessState.model_validate(resp.json()) 166 167 def update_harness( 168 self, 169 harness_id: str, 170 *, 171 image: str | None = None, 172 display_name: str | None = None, 173 version: str | None = None, 174 notes: str | None = None, 175 source_url: str | None = None, 176 active: bool | None = None, 177 default_version_id: str | None = None, 178 dialect: str | None = None, 179 ) -> HarnessState: 180 """Edit one of your own. Anything left ``None`` is untouched. 181 182 ⚠️ **Changing ``image`` puts the row back to 183 ``review_status="pending"``** and clears the approved digest — 184 the verdict was about specific content, and a dispatch pulls the 185 digest rather than the tag. Carrying the approval across would 186 leave the row naming a new reference while the node kept running 187 the old bytes. Re-sending the *same* image is a no-op and costs 188 nothing. 189 190 Anything that is not yours answers 404 rather than 403, like 191 :meth:`delete_harness`. 192 """ 193 # ⚠️ Absence is what "leave it alone" means on the wire, so an 194 # unset argument must not appear in the body at all — the server 195 # reads ``model_fields_set``, and a key sent as ``null`` is a key 196 # that was sent. 197 body: dict[str, object] = { 198 name: value 199 for name, value in ( 200 ("image", image), 201 ("display_name", display_name), 202 ("version", version), 203 ("notes", notes), 204 ("source_url", source_url), 205 ("active", active), 206 ("default_version_id", default_version_id), 207 ("dialect", dialect), 208 ) 209 if value is not None 210 } 211 resp = self._http.request("PATCH", f"/eval/harnesses/{harness_id}", json=body) 212 _raise_for_status(resp) 213 return HarnessState.model_validate(resp.json()) 214 215 def delete_harness(self, harness_id: str) -> bool: 216 """Remove one of your own. Anything that is not yours — another 217 user's, or a platform image — answers 404 rather than 403, so 218 this cannot be used to probe the registry.""" 219 resp = self._http.request("DELETE", f"/eval/harnesses/{harness_id}") 220 _raise_for_status(resp) 221 return bool(resp.json().get("deleted", False)) 222 223 def llm_providers(self) -> list[LlmProviderChoice]: 224 """The upstreams this deployment will relay to. 225 226 ⚠️ **Ask rather than assume.** The gateway makes the request to 227 whatever address a key names — from inside its own network, and 228 it returns the answer — so the set is closed and an operator may 229 replace it. A caller carrying its own list would offer addresses 230 this deployment refuses. 231 232 Each entry carries its dialect: a provider *is* a dialect plus an 233 address, and DeepSeek, Moonshot and OpenAI all speak ``openai``. 234 """ 235 resp = self._http.request("GET", "/eval/llm-providers") 236 _raise_for_status(resp) 237 return [LlmProviderChoice.model_validate(r) for r in resp.json()] 238 239 def my_llm_keys(self) -> list[MyLlmKey]: 240 """Your own upstream credentials — what they are, never what they say. 241 242 ⚠️ There is no ``api_key`` on the way back, by design. A key is 243 pasted once; every read after that lets you tell two apart, not 244 copy one. 245 """ 246 resp = self._http.request("GET", "/eval/llm-keys") 247 _raise_for_status(resp) 248 return [MyLlmKey.model_validate(r) for r in resp.json()] 249 250 def put_my_llm_key( 251 self, flavor: str, api_key: str, *, label: str = "", base_url: str = "" 252 ) -> MyLlmKey: 253 """Store or rotate the credential this account spends for ``flavor``. 254 255 A repeat dialect replaces: rotation is a write, because 256 delete-then-add leaves a window in which a run has no key. 257 Answers 503 on a deployment that cannot seal secrets at rest — 258 the key is never stored in the clear. 259 """ 260 resp = self._http.request( 261 "PUT", 262 f"/eval/llm-keys/{flavor}", 263 json={"api_key": api_key, "label": label, "base_url": base_url}, 264 ) 265 _raise_for_status(resp) 266 return MyLlmKey.model_validate(resp.json()) 267 268 def delete_my_llm_key(self, flavor: str, *, base_url: str = "") -> bool: 269 """Remove it. 404 for a provider you hold no key for — the same 270 answer somebody else's key gets, so this cannot report who holds 271 what. 272 273 ⚠️ ``base_url`` is part of the address, not a filter. Naming only 274 the dialect is ambiguous the moment you hold two ``openai`` 275 providers, and resolving that by picking one is how somebody 276 loses the key they meant to keep. ``""`` names that dialect's 277 official endpoint, which is a value rather than a wildcard. 278 """ 279 resp = self._http.request( 280 "DELETE", f"/eval/llm-keys/{flavor}", params={"base_url": base_url} 281 ) 282 _raise_for_status(resp) 283 return bool(resp.json().get("deleted", False)) 284 285 def probe_my_llm_key(self, flavor: str, *, base_url: str = "") -> MyLlmKeyProbe: 286 """Spend one token on your own credential and report what happened. 287 288 Storing a key says the write landed; it says nothing about 289 whether the credential **works**. The only other way to find 290 that out is to enter an agent, let a run be dispatched, and read 291 the failure out of the artifact tarball — two hours, on a full 292 range, to learn that a key is invalid. 293 294 ⚠️ **A code, never a boolean.** A rejected key, an address 295 nothing answers at, an account out of credit and a deployment 296 with no model registered at this endpoint are four different 297 fields to go and fix. 298 299 ⚠️ **200 with the outcome in the body** for every one of them — 300 so this does not raise on a failed credential. A 404 (and only a 301 404) means you hold no key for that provider. 302 303 ⚠️ ``base_url`` is part of the address, exactly as it is for the 304 removal beside it: naming the dialect alone picks whichever row 305 matched first the moment you hold two ``openai`` providers. 306 """ 307 resp = self._http.request( 308 "POST", f"/eval/llm-keys/{flavor}/probe", params={"base_url": base_url} 309 ) 310 _raise_for_status(resp) 311 return MyLlmKeyProbe.model_validate(resp.json()) 312 313 # -- #3487: model connections — one vendor account, one key ------------ 314 315 def llm_accounts(self) -> list[LlmAccountChoice]: 316 """The vendor accounts this deployment relays to, one row per account. 317 318 Never per dialect: the gateway routes each request to the account's 319 endpoint in the dialect the agent sent, so there is nothing to pick. 320 """ 321 resp = self._http.request("GET", "/eval/llm-accounts") 322 _raise_for_status(resp) 323 return [LlmAccountChoice.model_validate(r) for r in resp.json()] 324 325 def my_llm_connections(self) -> list[MyLlmConnection]: 326 """Your connections, with each surface's last check and the model list.""" 327 resp = self._http.request("GET", "/eval/llm-connections") 328 _raise_for_status(resp) 329 return [MyLlmConnection.model_validate(r) for r in resp.json()] 330 331 def connect_llm(self, account: str, api_key: str, *, label: str = "") -> MyLlmConnection: 332 """Save (or rotate) the key for one vendor account, then check it. 333 334 The check probes every surface of the account once with the key — a 335 tiny call each, on your own bill — and reads the vendor's model list. 336 A key that fails is still saved; the answer says which surface refused. 337 """ 338 body = MyLlmConnectionCreate(account=account, api_key=api_key, label=label) 339 resp = self._http.request("POST", "/eval/llm-connections", json=body.model_dump()) 340 _raise_for_status(resp) 341 return MyLlmConnection.model_validate(resp.json()) 342 343 def recheck_llm_connection(self, connection_id: str) -> MyLlmConnection: 344 """Probe every surface again and re-read the model list (200 either way).""" 345 body = LlmConnectionRef(id=connection_id).model_dump() 346 resp = self._http.request("POST", "/eval/llm-connections/check", json=body) 347 _raise_for_status(resp) 348 return MyLlmConnection.model_validate(resp.json()) 349 350 def refresh_llm_connection_models(self, connection_id: str) -> MyLlmConnection: 351 """Re-read only the vendor's model list with this key (free, no probe).""" 352 body = LlmConnectionRef(id=connection_id).model_dump() 353 resp = self._http.request("POST", "/eval/llm-connections/models", json=body) 354 _raise_for_status(resp) 355 return MyLlmConnection.model_validate(resp.json()) 356 357 def llm_connections_usage(self) -> LlmUsagePage: 358 """Your connections' last-30-day tokens, runs and estimated cost.""" 359 resp = self._http.request("GET", "/eval/llm-connections/usage") 360 _raise_for_status(resp) 361 return LlmUsagePage.model_validate(resp.json()) 362 363 def read_llm_connection_balance( 364 self, connection_id: str, *, force: bool = False 365 ) -> MyLlmConnection: 366 """Read the account balance with this key (``force`` skips the 10-min reuse).""" 367 body = LlmBalanceRefresh(id=connection_id, force=force).model_dump() 368 resp = self._http.request("POST", "/eval/llm-connections/balance", json=body) 369 _raise_for_status(resp) 370 return MyLlmConnection.model_validate(resp.json()) 371 372 def set_llm_connection_cap( 373 self, connection_id: str, *, usd: float = 0.0, tokens: int = 0 374 ) -> MyLlmConnection: 375 """Set one connection's monthly cap; zeros clear it.""" 376 body = LlmSpendCapUpdate(id=connection_id, usd=usd, tokens=tokens).model_dump() 377 resp = self._http.request("PUT", "/eval/llm-connections/cap", json=body) 378 _raise_for_status(resp) 379 return MyLlmConnection.model_validate(resp.json()) 380 381 def delete_llm_connection(self, connection_id: str) -> bool: 382 """Remove one connection. 404 for one that is not yours.""" 383 resp = self._http.request( 384 "DELETE", "/eval/llm-connections", params={"connection_id": connection_id} 385 ) 386 _raise_for_status(resp) 387 return bool(resp.json().get("deleted", False)) 388 389 def my_allowances(self) -> list[EvalAllowanceRow]: 390 """What an operator has granted this account, and how much is left. 391 392 ⚠️ Read ``remaining_usd`` rather than subtracting: the server 393 clamps an overshoot at zero, and a client doing its own 394 arithmetic would eventually disagree with the refusal that 395 actually decides. 396 """ 397 resp = self._http.request("GET", "/eval/allowances") 398 _raise_for_status(resp) 399 return [EvalAllowanceRow.model_validate(r) for r in resp.json()] 400 401 def models(self) -> list[LlmModelOption]: 402 """The models an entry may name. 403 404 ⚠️ Approved and active only, decided by the server. The registry 405 holds more than this — the operator's own notes and the 406 addressing that says where their credential is spent — and none 407 of it travels. 408 """ 409 resp = self._http.request("GET", "/eval/models") 410 _raise_for_status(resp) 411 return [LlmModelOption.model_validate(r) for r in resp.json()] 412 413 def enter( 414 self, 415 *, 416 harness_id: str, 417 model_id: str = "", 418 competition_id: str, 419 num_samples: int = 1, 420 harness_version_id: str = "", 421 funding_source: EvalFundingSource = "auto", 422 llm_connection_id: str = "", 423 scheduling_cost_limit_usd: float = 0.0, 424 timeout_s: int = 0, 425 max_turns: int = 0, 426 challenge_ids: Sequence[str] = (), 427 provider_model: str = "", 428 ) -> EvalCampaignState: 429 """Put one of your own agents into one competition. 430 431 Name a registered ``model_id`` (``models()``), **or** — #3471 — type 432 the provider's own model id as ``provider_model`` with 433 ``funding_source="byok"`` and the ``llm_connection_id`` of the saved 434 connection whose provider serves it. No operator row is needed; the 435 platform spends one small call on your key to check the pair first 436 and refuses with a ``byok_model_check_*`` code when it fails. 437 438 ⚠️ **The result is `self_reported`, and the server decides that.** 439 The default public leaderboard shows operator-run results; this 440 is your own claim about your own agent, so read it back with 441 ``tier="self_reported"`` or ``"all"``. There is no parameter for 442 it here because there is none on the wire. 443 444 ⚠️ **409 carries the reason as its ``code``** — whether to wait 445 for review, add a key, or pick another event. A caller that 446 reports only "entry refused" throws away the only actionable 447 part of the answer. 448 449 ⚠️ **``funding_source`` defaults to ``"auto"`` here only for the 450 callers that predate it.** Pass ``"platform"`` or ``"byok"`` (with 451 ``llm_connection_id``) to say who pays: an explicit choice never 452 falls back to the other, which is what ``ctfy eval run`` does. 453 ``scheduling_cost_limit_usd`` stops *new* dispatches once recorded 454 spend reaches it; it is a scheduling threshold, not a hard cap. 455 ``0`` for ``timeout_s`` / ``max_turns`` inherits the platform's. 456 457 ``challenge_ids`` makes it a **trial run** (#2551): only those 458 challenges of the target, ranked on no board. Empty is the whole set. 459 """ 460 body: dict[str, Any] = { 461 "harness_id": harness_id, 462 "harness_version_id": harness_version_id, 463 "model_id": model_id, 464 "competition_id": competition_id, 465 "num_samples": num_samples, 466 } 467 # ⚠️ Only what was asked for: the bodies older callers send stay 468 # byte-for-byte what they were, so `auto` is never spelled out. 469 if funding_source != "auto": 470 body["funding_source"] = funding_source 471 if llm_connection_id: 472 body["llm_connection_id"] = llm_connection_id 473 if scheduling_cost_limit_usd: 474 body["scheduling_cost_limit_usd"] = scheduling_cost_limit_usd 475 if timeout_s: 476 body["timeout_s"] = timeout_s 477 if max_turns: 478 body["max_turns"] = max_turns 479 if challenge_ids: 480 body["challenge_ids"] = list(challenge_ids) 481 if provider_model: 482 body["provider_model"] = provider_model 483 resp = self._http.request("POST", "/eval/entries", json=body) 484 _raise_for_status(resp) 485 return EvalCampaignState.model_validate(resp.json()) 486 487 def my_entries( 488 self, *, competition_id: str = "", offset: int = 0, limit: int = 50 489 ) -> PagedList[EvalCampaignState]: 490 """The campaigns this account entered, newest first — one page, 491 with the total (``limit`` at most 200). 492 493 Only entries: an operator campaign the same account queued is 494 not one of them, because the two are different claims about the 495 same board. ``competition_id`` narrows to entries into that one 496 competition, on the server. 497 498 ⚠️ This returned a bare list, the server's newest 200, with 499 nothing to say the list was cut; ``total`` says how many exist. 500 """ 501 params: dict[str, Any] = {"offset": offset, "limit": limit} 502 if competition_id: 503 params["competition_id"] = competition_id 504 resp = self._http.request("GET", "/eval/entries", params=params) 505 _raise_for_status(resp) 506 return _extract_items(resp.json(), EvalCampaignState) 507 508 def runs(self, *, competition_id: str = "", offset: int = 0, limit: int = 50) -> MyEvalRunPage: 509 """Evaluation runs you may read — your own plus your team-mates'. 510 511 ⚠️ The author set is the same membership join the per-run routes 512 enforce, so a run this lists can always be opened and a run it 513 omits always 404s. Two readings of "the same team" would drift 514 in the open direction. 515 516 ⚠️ ``mine`` on each row says whether *you* may open the 517 conversations. It is composed server-side because it gates a 518 control, and two clients deriving it would disagree. 519 Wraps ``GET /eval/runs``.""" 520 resp = self._http.request( 521 "GET", 522 "/eval/runs", 523 params={"competition_id": competition_id, "offset": offset, "limit": limit}, 524 ) 525 _raise_for_status(resp) 526 return MyEvalRunPage.model_validate(resp.json()) 527 528 def run(self, run_id: str) -> MyEvalRunRow: 529 """One of your runs, projected as the shelf projects it. 530 531 ⚠️ Addressable because :meth:`runs` is paginated: one campaign 532 over a large bucket is hundreds of runs, so finding a row by 533 scanning the first page is a bug with a threshold. 534 535 Metadata scope — a team-mate reads the row, and its ``traffic`` 536 field says ``not_yours`` rather than the route refusing. 537 Wraps ``GET /eval/runs/{run_id}``.""" 538 resp = self._http.request("GET", f"/eval/runs/{run_id}") 539 _raise_for_status(resp) 540 return MyEvalRunRow.model_validate(resp.json()) 541 542 def campaign_runs(self, campaign_id: str) -> MyEvalRunPage: 543 """Every atomic task of one campaign, in dispatch order (#2585). 544 545 ⚠️ Whole, never a page: the server answers a ``campaign_id`` listing 546 in full, so counting stages or numbering the queue from it is sound. 547 Wraps ``GET /eval/runs?campaign_id=``.""" 548 resp = self._http.request("GET", "/eval/runs", params={"campaign_id": campaign_id}) 549 _raise_for_status(resp) 550 return MyEvalRunPage.model_validate(resp.json()) 551 552 def run_events(self, run_id: str) -> EvalRunEventList: 553 """One atomic task's event log, in time order (#2585). 554 Wraps ``GET /eval/runs/{run_id}/events``.""" 555 resp = self._http.request("GET", f"/eval/runs/{run_id}/events") 556 _raise_for_status(resp) 557 return EvalRunEventList.model_validate(resp.json()) 558 559 def cancel_run( 560 self, run_id: str, *, mode: CancelMode = "drain", requeue: bool = False 561 ) -> EvalQueueChange: 562 """Cancel one atomic task (#2585): queued → cancelled now; started → 563 stopped (``mode="now"``) or left to finish (``"drain"``). A task 564 being graded answers ``409 eval_run_grading``. 565 Wraps ``POST /eval/runs/{run_id}/cancel``.""" 566 resp = self._http.request( 567 "POST", 568 f"/eval/runs/{run_id}/cancel", 569 json=EvalCancelRequest(mode=mode, requeue=requeue).model_dump(), 570 ) 571 _raise_for_status(resp) 572 return EvalQueueChange.model_validate(resp.json()) 573 574 def campaign_summary(self, campaign_id: str) -> EvalCampaignAtomicSummary: 575 """A campaign's tasks counted per stage, and whether it is 576 ``incomplete`` (off the official board) (#2585). 577 Wraps ``GET /eval/campaigns/{campaign_id}/summary``.""" 578 resp = self._http.request("GET", f"/eval/campaigns/{campaign_id}/summary") 579 _raise_for_status(resp) 580 return EvalCampaignAtomicSummary.model_validate(resp.json()) 581 582 def cancel_campaign(self, campaign_id: str, *, mode: CancelMode = "drain") -> EvalQueueChange: 583 """Cancel a campaign's queued tasks now, its started ones per 584 ``mode`` (#2585). Wraps ``POST /eval/campaigns/{campaign_id}/cancel``.""" 585 resp = self._http.request( 586 "POST", 587 f"/eval/campaigns/{campaign_id}/cancel", 588 json=EvalCancelRequest(mode=mode).model_dump(), 589 ) 590 _raise_for_status(resp) 591 return EvalQueueChange.model_validate(resp.json()) 592 593 def batch_limits(self) -> EvalBatchLimits: 594 """The most cells one batch may hold, and the ranges your runs may 595 hold at once across every campaign of yours (#2677). 596 Wraps ``GET /eval/batches/limits``.""" 597 resp = self._http.request("GET", "/eval/batches/limits") 598 _raise_for_status(resp) 599 return EvalBatchLimits.model_validate(resp.json()) 600 601 def enter_batch( 602 self, 603 *, 604 competition_id: str, 605 harness_ids: Sequence[str], 606 models: Sequence[EvalBatchModel], 607 num_samples: int = 1, 608 scheduling_cost_limit_usd: float = 0.0, 609 timeout_s: int = 0, 610 max_turns: int = 0, 611 challenge_ids: Sequence[str] = (), 612 ) -> EvalBatchState: 613 """Enter each of your agents with each model on one target: one 614 campaign per cell, grouped under a batch (#2677). 615 616 Every cell is checked as :meth:`enter` checks one entry before any is 617 created, so a refusal carries that cell's own ``code`` and nothing 618 is created. More cells than the deployment allows is ``409 619 eval_batch_too_large``. ``scheduling_cost_limit_usd`` is per cell. 620 Wraps ``POST /eval/batches``.""" 621 body = EvalBatchRequest( 622 competition_id=competition_id, 623 harness_ids=list(harness_ids), 624 models=list(models), 625 num_samples=num_samples, 626 scheduling_cost_limit_usd=scheduling_cost_limit_usd, 627 timeout_s=timeout_s, 628 max_turns=max_turns, 629 challenge_ids=list(challenge_ids), 630 ) 631 resp = self._http.request("POST", "/eval/batches", json=body.model_dump()) 632 _raise_for_status(resp) 633 return EvalBatchState.model_validate(resp.json()) 634 635 def batch(self, batch_id: str) -> EvalBatchState: 636 """One of your batches: its cells' campaigns (#2677). 637 Wraps ``GET /eval/batches/{batch_id}``.""" 638 resp = self._http.request("GET", f"/eval/batches/{batch_id}") 639 _raise_for_status(resp) 640 return EvalBatchState.model_validate(resp.json()) 641 642 def cancel_batch(self, batch_id: str, *, mode: CancelMode = "drain") -> EvalBatchCancelResult: 643 """Cancel every cell of a batch still running, each as 644 :meth:`cancel_campaign` would (#2677). 645 Wraps ``POST /eval/batches/{batch_id}/cancel``.""" 646 resp = self._http.request( 647 "POST", 648 f"/eval/batches/{batch_id}/cancel", 649 json=EvalCancelRequest(mode=mode).model_dump(), 650 ) 651 _raise_for_status(resp) 652 return EvalBatchCancelResult.model_validate(resp.json()) 653 654 def rerun_unfinished(self, campaign_id: str) -> EvalQueueChange: 655 """Queue one fresh task per cancelled task nothing has filled (#2585). 656 Wraps ``POST /eval/campaigns/{campaign_id}/rerun-unfinished``.""" 657 resp = self._http.request("POST", f"/eval/campaigns/{campaign_id}/rerun-unfinished") 658 _raise_for_status(resp) 659 return EvalQueueChange.model_validate(resp.json()) 660 661 def run_llm_calls(self, run_id: str, *, offset: int = 0, limit: int = 50) -> LlmCallPage: 662 """Every model request one of your runs made, newest first. 663 664 Metadata only — token split, latency, queue wait, the estimated 665 cost. **Scoped to the run's team**, so a captain can see who is 666 burning the allowance without reading anybody's prompts, and to 667 ``principal == "agent"``, so a challenge container's own 668 conversation is never on this route. 669 Wraps ``GET /eval/runs/{run_id}/llm-calls``.""" 670 resp = self._http.request( 671 "GET", 672 f"/eval/runs/{run_id}/llm-calls", 673 params={"offset": offset, "limit": limit}, 674 ) 675 _raise_for_status(resp) 676 return LlmCallPage.model_validate(resp.json()) 677 678 def run_agent_log(self, run_id: str, *, after: int = 0) -> AgentLogPage: 679 """One of your runs' own stdout and stderr, live, after a cursor (#2401). 680 681 **The run author's only** (``403 agent_log_owner_only`` for a 682 team-mate). Pass the last ``seq`` you hold as *after*; a jump in 683 ``seq`` is lines that are no longer held. Scrubbed on the platform 684 before it was held. 685 Wraps ``GET /eval/runs/{run_id}/agent-log``.""" 686 resp = self._http.request("GET", f"/eval/runs/{run_id}/agent-log", params={"after": after}) 687 _raise_for_status(resp) 688 return AgentLogPage.model_validate(resp.json()) 689 690 def run_llm_live(self, run_id: str, *, after: int = 0) -> LlmLivePage: 691 """What one of your runs' model is sending back, live, after a cursor 692 (#2401 slice 2). 693 694 The body's readers only — the author, or a platform admin 695 (``403 llm_live_owner_only`` for a team-mate). Pass the last ``seq`` 696 you hold as *after*; a jump in ``seq`` is deltas no longer held. 697 Wraps ``GET /eval/runs/{run_id}/llm-live``.""" 698 resp = self._http.request("GET", f"/eval/runs/{run_id}/llm-live", params={"after": after}) 699 _raise_for_status(resp) 700 return LlmLivePage.model_validate(resp.json()) 701 702 def run_llm_call(self, run_id: str, call_id: str) -> LlmCallBody: 703 """The conversation itself — **the run author's only**. 704 705 ⚠️ Narrower than :meth:`run_llm_calls` on purpose: a team-mate 706 who can see the row is answered ``403 707 transcript_body_owner_only`` here, since an agent's prompt is its 708 author's work and ``HarnessStanding`` ranks the two of them 709 against each other. 710 711 ⚠️ ``available=False`` is an ordinary answer, not an error — 712 retention reclaims bodies and leaves the row behind. 713 Wraps ``GET /eval/runs/{run_id}/llm-calls/{call_id}``.""" 714 resp = self._http.request("GET", f"/eval/runs/{run_id}/llm-calls/{call_id}") 715 _raise_for_status(resp) 716 return LlmCallBody.model_validate(resp.json()) 717 718 def run_artifact(self, run_id: str) -> bytes: 719 """The files one of your runs left behind, as ``.tar.gz`` bytes. 720 721 Its working directory and, when the harness is Claude Code, its 722 session — collected from the container before it was removed. 723 The writing half, where :meth:`run_llm_call` is the deciding one 724 and :meth:`run_traffic` the doing one. 725 726 ⚠️ **The run author's only**, the narrowest of the three: a 727 team-mate who can see the row is answered ``403 728 run_artifact_owner_only``. An archive carries whatever the agent 729 wrote down, which for a solved challenge includes the flag. 730 731 ⚠️ **Bytes, not a model, and no URL.** The read goes through the 732 platform rather than handing out a bucket address, so there is 733 nothing to return but the archive itself. ⚠️ A run that kept no 734 files answers ``404 run_artifact_not_found`` rather than empty 735 bytes — which of the several reasons it is (the agent wrote 736 nothing, collection failed, retention took them) is on the run 737 row's ``artifact`` field, so the caller never has to guess from 738 an absence. 739 740 Wraps ``GET /eval/runs/{run_id}/artifact``.""" 741 resp = self._http.request("GET", f"/eval/runs/{run_id}/artifact") 742 _raise_for_status(resp) 743 return bytes(resp.content) 744 745 def run_traffic(self, run_id: str, *, cursor: str = "", limit: int = 0) -> InstanceTrafficPage: 746 """Every request one of your runs sent at the challenge. 747 748 The doing half, where :meth:`run_llm_call` is the deciding half. 749 **The run author's only** — a team-mate who can see the row is 750 answered ``403 run_traffic_owner_only``, the same split the 751 conversation body makes, because the payloads an agent wrote are 752 its author's work. 753 754 ⚠️ Carry ``cursor`` forward from the previous page and you get 755 only what arrived since; pass an empty one and you get the whole 756 capture again. Every failure — an unreachable node, a run whose 757 estate the reaper has taken — answers 200 with your own cursor 758 and no flows, so a blip never re-delivers the capture. Which of 759 those it was, ``MyEvalRunRow.traffic`` says. 760 761 ⚠️ Redacted: this instance's minted answers are scrubbed out. 762 Wraps ``GET /eval/runs/{run_id}/traffic``.""" 763 resp = self._http.request( 764 "GET", 765 f"/eval/runs/{run_id}/traffic", 766 params={"cursor": cursor, "limit": limit}, 767 ) 768 _raise_for_status(resp) 769 return InstanceTrafficPage.model_validate(resp.json()) 770 771 def run_writeup(self, run_id: str) -> RunWriteup: 772 """What one of your runs said it did, in its own words. 773 774 ``CTFY-0001`` §3 invites an optional ``writeup`` in the result 775 file. The saying half, where :meth:`run_llm_call` is the 776 deciding one, :meth:`run_traffic` the doing one and 777 :meth:`run_artifact` the writing one. 778 779 ⚠️ **Telemetry, never a grading path.** Nothing in the result 780 file is scored — the run's own ``POST /submissions`` is — so a 781 write-up cannot earn a point and is not read as an answer. 782 783 ⚠️ **The run author's only**: a team-mate who can see the row is 784 answered ``403 run_writeup_owner_only``, the same split the 785 other three bodies make. ⚠️ A run whose image wrote none answers 786 ``404 run_writeup_not_found`` rather than empty prose, and the 787 run row's ``writeup`` field already said which — so the caller 788 never has to reach here to find out. 789 790 Wraps ``GET /eval/runs/{run_id}/writeup``.""" 791 resp = self._http.request("GET", f"/eval/runs/{run_id}/writeup") 792 _raise_for_status(resp) 793 return RunWriteup.model_validate(resp.json()) 794 795 def set_run_wall(self, run_id: str, *, published: bool) -> MyEvalRunRow: 796 """Put this run's agent on the event's projector wall, or take it off. 797 798 ``GET /competitions/{id}/agent-actions`` draws one scrubbed line 799 per agent saying what it is doing. The scrub answers what the 800 bytes make unsafe; this answers the part it cannot — the 801 conversation belongs to whoever ran it, so the wall shows an 802 entrant only once their author has said yes. 803 804 ⚠️ **The switch is on the *entrant*, not on this run.** The 805 transcript index groups by team and an agent's team is one per 806 ``(owner, harness, model, competition)``, so every run of the 807 same agent at the same event shares one answer — which is what 808 "this agent, at this event" means. The row that comes back is 809 the run's, with ``wall_published`` re-read from the entrant. 810 811 ⚠️ **The author's only**: a team-mate who can see the row is 812 answered ``403 run_wall_not_yours``, the same split 813 :meth:`run_writeup` makes, and for the same reason — the line 814 is read out of a conversation they already meet a 403 on. 815 816 ⚠️ A run with no agent entrant answers ``409`` naming which 817 kind of nothing it is; ``MyEvalRunRow.wall`` carries the same 818 code, so a caller never has to press to find out. 819 820 Wraps ``POST /eval/runs/{run_id}/wall``.""" 821 resp = self._http.request( 822 "POST", 823 f"/eval/runs/{run_id}/wall", 824 json={"published": published}, 825 ) 826 _raise_for_status(resp) 827 return MyEvalRunRow.model_validate(resp.json()) 828 829 def set_run_transcript(self, run_id: str, *, published: bool) -> MyEvalRunRow: 830 """Publish this run's agent's **full conversation**, or take it back. 831 832 A **second, stronger** consent than :meth:`set_run_wall`. The wall 833 shows one scrubbed line of what the agent is doing; this opens the 834 whole conversation — every turn the agent wrote — to spectators at 835 ``GET /competitions/{id}/awd/transcript/{call_id}``. The projection 836 still emits only the agent's own scrubbed turns (never the system 837 prompt, never a tool result), but the decision to publish a 838 conversation at all is the author's alone and separate from the 839 wall's. 840 841 ⚠️ **The switch is on the *entrant***, exactly as the wall's — 842 one answer per ``(owner, harness, model, competition)``. 843 844 ⚠️ **Refused for an anonymous entrant** (``403 845 run_transcript_anonymous``): a conversation de-anonymises whoever 846 wrote it, so the withheld-identity promise wins over the consent. 847 The author's-only and no-agent refusals mirror :meth:`set_run_wall`. 848 849 Wraps ``POST /eval/runs/{run_id}/transcript``.""" 850 resp = self._http.request( 851 "POST", 852 f"/eval/runs/{run_id}/transcript", 853 json={"published": published}, 854 ) 855 _raise_for_status(resp) 856 return MyEvalRunRow.model_validate(resp.json()) 857 858 def registry_push_target(self) -> RegistryPushTarget: 859 """Where this account pushes an agent image, if anywhere. 860 861 ⚠️ Read ``enabled``, not a non-empty ``repository_prefix``. The 862 server composes the verdict from four facts — a host, a token 863 service, and both halves of the signing pair — and a deployment 864 with a hostname but no signing pair refuses every login. 865 """ 866 resp = self._http.request("GET", "/eval/registry") 867 _raise_for_status(resp) 868 return RegistryPushTarget.model_validate(resp.json()) 869 870 def harness_tags(self, harness_id: str) -> HarnessTags: 871 """Which versions the registry publishes for one of your agents. 872 873 ⚠️ Addressed by **harness id**, not by image: the platform looks 874 the reference up in your own registry row, which is what keeps 875 this from being an outbound proxy. Answers 404 for anything that 876 is not yours, and 502 when the registry itself would not answer 877 — never an empty list, which would read as "no versions". 878 """ 879 resp = self._http.request("GET", f"/eval/harnesses/{harness_id}/tags") 880 _raise_for_status(resp) 881 return HarnessTags.model_validate(resp.json()) 882 883 def check_harness(self, harness_id: str) -> HarnessState: 884 """Ask the platform to look at one of your agent images. 885 886 Returns the row with the verdict on it: ``conformance_status`` 887 (``""`` never checked / ``passed`` / ``failed`` / ``error``), 888 the digest the verdict describes, and a finding per thing worth 889 saying. 890 891 ⚠️ **Evidence, never a gate.** Nothing here changes 892 ``review_status``, and a ``failed`` verdict does not stop an 893 operator approving the image — the check exists so somebody can 894 see *why* a dispatch would go wrong, before it does. 895 896 ⚠️ **``error`` is not ``failed``, and it arrives as a 200.** A 897 registry that would not answer is not a fact about the image; 898 the message is on ``conformance_error``. 899 900 ⚠️ Addressed by harness id, like :meth:`harness_tags` and for 901 the same reason: the image is read off your own registry row, so 902 this cannot be pointed at an arbitrary host. 903 """ 904 resp = self._http.request("POST", f"/eval/harnesses/{harness_id}/conformance") 905 _raise_for_status(resp) 906 return HarnessState.model_validate(resp.json()) 907 908 def probe_harness(self, harness_id: str) -> MyHarness: 909 """Put one of your agent images in line to be run once, to check it. 910 911 Where :meth:`check_harness` reads a manifest, this runs the 912 container on a worker that may hold a user's code, under the 913 isolation a real dispatch uses, with a briefing marked ``dry_run``, 914 and asks the one question a manifest cannot answer: **did it write 915 a result file at the address it was given.** 916 917 ⚠️ **Returns the row in line, not a verdict** (#2608): 918 ``probe_status`` is ``queued`` (with ``probe_reason`` and 919 ``probe_position``) or ``running``; poll :meth:`harnesses` for 920 ``passed`` / ``failed`` (with a reason) / ``error``. 921 922 ⚠️ **No credential travels with the probe** but the row's own 923 scoped pull ticket, or the owner's stored registry login for the 924 image's own host (#3475), and no platform URL is named. See §3.1 of 925 ``docs/standards/CTFY-0001.md``. 926 """ 927 resp = self._http.request("POST", f"/eval/harnesses/{harness_id}/probe") 928 _raise_for_status(resp) 929 return MyHarness.model_validate(resp.json()) 930 931 def cancel_probe(self, harness_id: str) -> MyHarness: 932 """Take a queued dry run out of line (409 once it has started).""" 933 resp = self._http.request("DELETE", f"/eval/harnesses/{harness_id}/probe") 934 _raise_for_status(resp) 935 return MyHarness.model_validate(resp.json()) 936 937 def set_registry_credential(self, harness_id: str, *, username: str, secret: str) -> MyHarness: 938 """Store the login your agent's image is pulled with from a private registry (#3475). 939 940 The registry is the one the agent's image names; a host the platform 941 does not pull from is refused (400 ``harness_registry_not_allowed``). 942 The answer shows ``registry_credential`` (who and when), never the 943 token. Run the check and dry run again afterwards. 944 """ 945 resp = self._http.request( 946 "PUT", 947 f"/eval/harnesses/{harness_id}/registry-credential", 948 json={"username": username, "secret": secret}, 949 ) 950 _raise_for_status(resp) 951 return MyHarness.model_validate(resp.json()) 952 953 def clear_registry_credential(self, harness_id: str) -> MyHarness: 954 """Remove your agent's registry login; its image is then pulled anonymously.""" 955 resp = self._http.request("DELETE", f"/eval/harnesses/{harness_id}/registry-credential") 956 _raise_for_status(resp) 957 return MyHarness.model_validate(resp.json()) 958 959 960class RegistryResource: 961 """The container registry's token endpoint. 962 963 ⚠️ **Not a method anybody calls from Python.** ``docker`` follows 964 the registry's ``WWW-Authenticate`` realm here on its own, with HTTP 965 Basic; this exists so the SDK covers the surface and so a test can 966 drive the exchange the way docker does. The credential goes in the 967 *password* half — that is where ``docker login -p`` puts it. 968 """ 969 970 def __init__(self, http: BaseHttpClient) -> None: 971 self._http = http 972 973 def token(self, *, service: str, scope: str | Sequence[str] = "") -> dict[str, Any]: 974 # ⚠️ A sequence is sent as a repeated ``scope`` parameter, because 975 # that is what docker does for a cross-repository blob mount — 976 # ``push`` on the destination and ``pull`` on the source, in one 977 # exchange. This method's whole stated purpose is that a test can 978 # drive the exchange the way docker does, and it could not. 979 params: list[tuple[str, str]] = [("service", service)] 980 if isinstance(scope, str): 981 if scope: 982 params.append(("scope", scope)) 983 else: 984 params.extend(("scope", s) for s in scope if s) 985 resp = self._http.request("GET", "/registry/token", params=params) 986 _raise_for_status(resp) 987 body: dict[str, Any] = resp.json() 988 return body
55class EvalResource: 56 """The public model leaderboard (no auth), and the signed-in caller's 57 own harness registry (``/eval/harnesses``).""" 58 59 def __init__(self, http: BaseHttpClient) -> None: 60 self._http = http 61 62 def leaderboard( 63 self, 64 competition_id: str, 65 *, 66 tier: str = "official", 67 corpus_sha: str = "", 68 q: str = "", 69 vendor: str = "", 70 harness_id: str | None = None, 71 offset: int = 0, 72 limit: int | None = None, 73 ) -> EvalLeaderboard: 74 """Ranked model standings + per-dimension (difficulty / bucket / tag) 75 breakdowns for **one** competition or suite. There is no platform-wide 76 board (#2341): runs graded under different sets do not rank together. 77 78 ``tier`` selects the credibility band: ``official`` (default, 79 platform-run), ``self_reported`` (vendor-run, unverified), or ``all`` 80 for both. Sandbox runs are never exposed here. ``corpus_sha`` counts 81 only runs graded against that version (a suite's pin); empty is every 82 version. 83 84 #2733: ``harness_id`` scopes the aggregate to one harness (``""`` = 85 the built-in one; ``None`` = every harness) and ranks inside it. 86 ``q`` (model search), ``vendor`` (slug), ``offset`` and ``limit`` 87 pick rows out of the scope without re-ranking; ``result.scope`` 88 reports the revision, manifest, freshness, filters and totals.""" 89 if not competition_id: 90 raise ValueError("competition_id is required: there is no platform-wide board") 91 params: dict[str, str | int] = { 92 "competition_id": competition_id, 93 "tier": tier, 94 "corpus_sha": corpus_sha, 95 "q": q, 96 "vendor": vendor, 97 "offset": offset, 98 } 99 # ⚠️ ``""`` is a real harness (the built-in one), so only ``None`` 100 # leaves the parameter out. 101 if harness_id is not None: 102 params["harness_id"] = harness_id 103 if limit is not None: 104 params["limit"] = limit 105 resp = self._http.request("GET", "/eval/leaderboard", params=params) 106 _raise_for_status(resp) 107 return EvalLeaderboard.model_validate(resp.json()) 108 109 # -- your own agent images ------------------------------------------- 110 111 def harnesses(self) -> list[HarnessState]: 112 """The harnesses *you* registered, newest first. 113 114 Scoped to the caller — the platform's own images and other 115 users' are not listed here (``/admin/eval-harnesses`` is the 116 operator's whole-registry view). 117 """ 118 resp = self._http.request("GET", "/eval/harnesses") 119 _raise_for_status(resp) 120 return [HarnessState.model_validate(row) for row in resp.json()] 121 122 def harness_versions(self, harness_id: str) -> list[HarnessVersionState]: 123 """Your agent's saved immutable releases, newest first.""" 124 resp = self._http.request("GET", f"/eval/harnesses/{harness_id}/versions") 125 _raise_for_status(resp) 126 return [HarnessVersionState.model_validate(row) for row in resp.json()] 127 128 def create_harness_version(self, harness_id: str) -> HarnessVersionState: 129 """Save the currently reviewed image; the first release becomes default.""" 130 resp = self._http.request("POST", f"/eval/harnesses/{harness_id}/versions") 131 _raise_for_status(resp) 132 return HarnessVersionState.model_validate(resp.json()) 133 134 def register_harness( 135 self, 136 harness_id: str, 137 image: str, 138 *, 139 display_name: str = "", 140 version: str = "", 141 notes: str = "", 142 dialect: str = "", 143 ) -> HarnessState: 144 """Register one of your own agent images. 145 146 ⚠️ The returned row is ``review_status="pending"`` and is **not 147 dispatchable** until an operator approves it — registering an 148 image and being trusted to run one are deliberately two things. 149 ``owner_user_id`` and ``review_status`` are stamped by the 150 server; anything you pass for them is ignored. 151 """ 152 resp = self._http.request( 153 "POST", 154 "/eval/harnesses", 155 json={ 156 "id": harness_id, 157 "image": image, 158 "display_name": display_name, 159 "version": version, 160 "notes": notes, 161 # #3473: the wire surface the agent calls; "" is not declared. 162 "dialect": dialect, 163 }, 164 ) 165 _raise_for_status(resp) 166 return HarnessState.model_validate(resp.json()) 167 168 def update_harness( 169 self, 170 harness_id: str, 171 *, 172 image: str | None = None, 173 display_name: str | None = None, 174 version: str | None = None, 175 notes: str | None = None, 176 source_url: str | None = None, 177 active: bool | None = None, 178 default_version_id: str | None = None, 179 dialect: str | None = None, 180 ) -> HarnessState: 181 """Edit one of your own. Anything left ``None`` is untouched. 182 183 ⚠️ **Changing ``image`` puts the row back to 184 ``review_status="pending"``** and clears the approved digest — 185 the verdict was about specific content, and a dispatch pulls the 186 digest rather than the tag. Carrying the approval across would 187 leave the row naming a new reference while the node kept running 188 the old bytes. Re-sending the *same* image is a no-op and costs 189 nothing. 190 191 Anything that is not yours answers 404 rather than 403, like 192 :meth:`delete_harness`. 193 """ 194 # ⚠️ Absence is what "leave it alone" means on the wire, so an 195 # unset argument must not appear in the body at all — the server 196 # reads ``model_fields_set``, and a key sent as ``null`` is a key 197 # that was sent. 198 body: dict[str, object] = { 199 name: value 200 for name, value in ( 201 ("image", image), 202 ("display_name", display_name), 203 ("version", version), 204 ("notes", notes), 205 ("source_url", source_url), 206 ("active", active), 207 ("default_version_id", default_version_id), 208 ("dialect", dialect), 209 ) 210 if value is not None 211 } 212 resp = self._http.request("PATCH", f"/eval/harnesses/{harness_id}", json=body) 213 _raise_for_status(resp) 214 return HarnessState.model_validate(resp.json()) 215 216 def delete_harness(self, harness_id: str) -> bool: 217 """Remove one of your own. Anything that is not yours — another 218 user's, or a platform image — answers 404 rather than 403, so 219 this cannot be used to probe the registry.""" 220 resp = self._http.request("DELETE", f"/eval/harnesses/{harness_id}") 221 _raise_for_status(resp) 222 return bool(resp.json().get("deleted", False)) 223 224 def llm_providers(self) -> list[LlmProviderChoice]: 225 """The upstreams this deployment will relay to. 226 227 ⚠️ **Ask rather than assume.** The gateway makes the request to 228 whatever address a key names — from inside its own network, and 229 it returns the answer — so the set is closed and an operator may 230 replace it. A caller carrying its own list would offer addresses 231 this deployment refuses. 232 233 Each entry carries its dialect: a provider *is* a dialect plus an 234 address, and DeepSeek, Moonshot and OpenAI all speak ``openai``. 235 """ 236 resp = self._http.request("GET", "/eval/llm-providers") 237 _raise_for_status(resp) 238 return [LlmProviderChoice.model_validate(r) for r in resp.json()] 239 240 def my_llm_keys(self) -> list[MyLlmKey]: 241 """Your own upstream credentials — what they are, never what they say. 242 243 ⚠️ There is no ``api_key`` on the way back, by design. A key is 244 pasted once; every read after that lets you tell two apart, not 245 copy one. 246 """ 247 resp = self._http.request("GET", "/eval/llm-keys") 248 _raise_for_status(resp) 249 return [MyLlmKey.model_validate(r) for r in resp.json()] 250 251 def put_my_llm_key( 252 self, flavor: str, api_key: str, *, label: str = "", base_url: str = "" 253 ) -> MyLlmKey: 254 """Store or rotate the credential this account spends for ``flavor``. 255 256 A repeat dialect replaces: rotation is a write, because 257 delete-then-add leaves a window in which a run has no key. 258 Answers 503 on a deployment that cannot seal secrets at rest — 259 the key is never stored in the clear. 260 """ 261 resp = self._http.request( 262 "PUT", 263 f"/eval/llm-keys/{flavor}", 264 json={"api_key": api_key, "label": label, "base_url": base_url}, 265 ) 266 _raise_for_status(resp) 267 return MyLlmKey.model_validate(resp.json()) 268 269 def delete_my_llm_key(self, flavor: str, *, base_url: str = "") -> bool: 270 """Remove it. 404 for a provider you hold no key for — the same 271 answer somebody else's key gets, so this cannot report who holds 272 what. 273 274 ⚠️ ``base_url`` is part of the address, not a filter. Naming only 275 the dialect is ambiguous the moment you hold two ``openai`` 276 providers, and resolving that by picking one is how somebody 277 loses the key they meant to keep. ``""`` names that dialect's 278 official endpoint, which is a value rather than a wildcard. 279 """ 280 resp = self._http.request( 281 "DELETE", f"/eval/llm-keys/{flavor}", params={"base_url": base_url} 282 ) 283 _raise_for_status(resp) 284 return bool(resp.json().get("deleted", False)) 285 286 def probe_my_llm_key(self, flavor: str, *, base_url: str = "") -> MyLlmKeyProbe: 287 """Spend one token on your own credential and report what happened. 288 289 Storing a key says the write landed; it says nothing about 290 whether the credential **works**. The only other way to find 291 that out is to enter an agent, let a run be dispatched, and read 292 the failure out of the artifact tarball — two hours, on a full 293 range, to learn that a key is invalid. 294 295 ⚠️ **A code, never a boolean.** A rejected key, an address 296 nothing answers at, an account out of credit and a deployment 297 with no model registered at this endpoint are four different 298 fields to go and fix. 299 300 ⚠️ **200 with the outcome in the body** for every one of them — 301 so this does not raise on a failed credential. A 404 (and only a 302 404) means you hold no key for that provider. 303 304 ⚠️ ``base_url`` is part of the address, exactly as it is for the 305 removal beside it: naming the dialect alone picks whichever row 306 matched first the moment you hold two ``openai`` providers. 307 """ 308 resp = self._http.request( 309 "POST", f"/eval/llm-keys/{flavor}/probe", params={"base_url": base_url} 310 ) 311 _raise_for_status(resp) 312 return MyLlmKeyProbe.model_validate(resp.json()) 313 314 # -- #3487: model connections — one vendor account, one key ------------ 315 316 def llm_accounts(self) -> list[LlmAccountChoice]: 317 """The vendor accounts this deployment relays to, one row per account. 318 319 Never per dialect: the gateway routes each request to the account's 320 endpoint in the dialect the agent sent, so there is nothing to pick. 321 """ 322 resp = self._http.request("GET", "/eval/llm-accounts") 323 _raise_for_status(resp) 324 return [LlmAccountChoice.model_validate(r) for r in resp.json()] 325 326 def my_llm_connections(self) -> list[MyLlmConnection]: 327 """Your connections, with each surface's last check and the model list.""" 328 resp = self._http.request("GET", "/eval/llm-connections") 329 _raise_for_status(resp) 330 return [MyLlmConnection.model_validate(r) for r in resp.json()] 331 332 def connect_llm(self, account: str, api_key: str, *, label: str = "") -> MyLlmConnection: 333 """Save (or rotate) the key for one vendor account, then check it. 334 335 The check probes every surface of the account once with the key — a 336 tiny call each, on your own bill — and reads the vendor's model list. 337 A key that fails is still saved; the answer says which surface refused. 338 """ 339 body = MyLlmConnectionCreate(account=account, api_key=api_key, label=label) 340 resp = self._http.request("POST", "/eval/llm-connections", json=body.model_dump()) 341 _raise_for_status(resp) 342 return MyLlmConnection.model_validate(resp.json()) 343 344 def recheck_llm_connection(self, connection_id: str) -> MyLlmConnection: 345 """Probe every surface again and re-read the model list (200 either way).""" 346 body = LlmConnectionRef(id=connection_id).model_dump() 347 resp = self._http.request("POST", "/eval/llm-connections/check", json=body) 348 _raise_for_status(resp) 349 return MyLlmConnection.model_validate(resp.json()) 350 351 def refresh_llm_connection_models(self, connection_id: str) -> MyLlmConnection: 352 """Re-read only the vendor's model list with this key (free, no probe).""" 353 body = LlmConnectionRef(id=connection_id).model_dump() 354 resp = self._http.request("POST", "/eval/llm-connections/models", json=body) 355 _raise_for_status(resp) 356 return MyLlmConnection.model_validate(resp.json()) 357 358 def llm_connections_usage(self) -> LlmUsagePage: 359 """Your connections' last-30-day tokens, runs and estimated cost.""" 360 resp = self._http.request("GET", "/eval/llm-connections/usage") 361 _raise_for_status(resp) 362 return LlmUsagePage.model_validate(resp.json()) 363 364 def read_llm_connection_balance( 365 self, connection_id: str, *, force: bool = False 366 ) -> MyLlmConnection: 367 """Read the account balance with this key (``force`` skips the 10-min reuse).""" 368 body = LlmBalanceRefresh(id=connection_id, force=force).model_dump() 369 resp = self._http.request("POST", "/eval/llm-connections/balance", json=body) 370 _raise_for_status(resp) 371 return MyLlmConnection.model_validate(resp.json()) 372 373 def set_llm_connection_cap( 374 self, connection_id: str, *, usd: float = 0.0, tokens: int = 0 375 ) -> MyLlmConnection: 376 """Set one connection's monthly cap; zeros clear it.""" 377 body = LlmSpendCapUpdate(id=connection_id, usd=usd, tokens=tokens).model_dump() 378 resp = self._http.request("PUT", "/eval/llm-connections/cap", json=body) 379 _raise_for_status(resp) 380 return MyLlmConnection.model_validate(resp.json()) 381 382 def delete_llm_connection(self, connection_id: str) -> bool: 383 """Remove one connection. 404 for one that is not yours.""" 384 resp = self._http.request( 385 "DELETE", "/eval/llm-connections", params={"connection_id": connection_id} 386 ) 387 _raise_for_status(resp) 388 return bool(resp.json().get("deleted", False)) 389 390 def my_allowances(self) -> list[EvalAllowanceRow]: 391 """What an operator has granted this account, and how much is left. 392 393 ⚠️ Read ``remaining_usd`` rather than subtracting: the server 394 clamps an overshoot at zero, and a client doing its own 395 arithmetic would eventually disagree with the refusal that 396 actually decides. 397 """ 398 resp = self._http.request("GET", "/eval/allowances") 399 _raise_for_status(resp) 400 return [EvalAllowanceRow.model_validate(r) for r in resp.json()] 401 402 def models(self) -> list[LlmModelOption]: 403 """The models an entry may name. 404 405 ⚠️ Approved and active only, decided by the server. The registry 406 holds more than this — the operator's own notes and the 407 addressing that says where their credential is spent — and none 408 of it travels. 409 """ 410 resp = self._http.request("GET", "/eval/models") 411 _raise_for_status(resp) 412 return [LlmModelOption.model_validate(r) for r in resp.json()] 413 414 def enter( 415 self, 416 *, 417 harness_id: str, 418 model_id: str = "", 419 competition_id: str, 420 num_samples: int = 1, 421 harness_version_id: str = "", 422 funding_source: EvalFundingSource = "auto", 423 llm_connection_id: str = "", 424 scheduling_cost_limit_usd: float = 0.0, 425 timeout_s: int = 0, 426 max_turns: int = 0, 427 challenge_ids: Sequence[str] = (), 428 provider_model: str = "", 429 ) -> EvalCampaignState: 430 """Put one of your own agents into one competition. 431 432 Name a registered ``model_id`` (``models()``), **or** — #3471 — type 433 the provider's own model id as ``provider_model`` with 434 ``funding_source="byok"`` and the ``llm_connection_id`` of the saved 435 connection whose provider serves it. No operator row is needed; the 436 platform spends one small call on your key to check the pair first 437 and refuses with a ``byok_model_check_*`` code when it fails. 438 439 ⚠️ **The result is `self_reported`, and the server decides that.** 440 The default public leaderboard shows operator-run results; this 441 is your own claim about your own agent, so read it back with 442 ``tier="self_reported"`` or ``"all"``. There is no parameter for 443 it here because there is none on the wire. 444 445 ⚠️ **409 carries the reason as its ``code``** — whether to wait 446 for review, add a key, or pick another event. A caller that 447 reports only "entry refused" throws away the only actionable 448 part of the answer. 449 450 ⚠️ **``funding_source`` defaults to ``"auto"`` here only for the 451 callers that predate it.** Pass ``"platform"`` or ``"byok"`` (with 452 ``llm_connection_id``) to say who pays: an explicit choice never 453 falls back to the other, which is what ``ctfy eval run`` does. 454 ``scheduling_cost_limit_usd`` stops *new* dispatches once recorded 455 spend reaches it; it is a scheduling threshold, not a hard cap. 456 ``0`` for ``timeout_s`` / ``max_turns`` inherits the platform's. 457 458 ``challenge_ids`` makes it a **trial run** (#2551): only those 459 challenges of the target, ranked on no board. Empty is the whole set. 460 """ 461 body: dict[str, Any] = { 462 "harness_id": harness_id, 463 "harness_version_id": harness_version_id, 464 "model_id": model_id, 465 "competition_id": competition_id, 466 "num_samples": num_samples, 467 } 468 # ⚠️ Only what was asked for: the bodies older callers send stay 469 # byte-for-byte what they were, so `auto` is never spelled out. 470 if funding_source != "auto": 471 body["funding_source"] = funding_source 472 if llm_connection_id: 473 body["llm_connection_id"] = llm_connection_id 474 if scheduling_cost_limit_usd: 475 body["scheduling_cost_limit_usd"] = scheduling_cost_limit_usd 476 if timeout_s: 477 body["timeout_s"] = timeout_s 478 if max_turns: 479 body["max_turns"] = max_turns 480 if challenge_ids: 481 body["challenge_ids"] = list(challenge_ids) 482 if provider_model: 483 body["provider_model"] = provider_model 484 resp = self._http.request("POST", "/eval/entries", json=body) 485 _raise_for_status(resp) 486 return EvalCampaignState.model_validate(resp.json()) 487 488 def my_entries( 489 self, *, competition_id: str = "", offset: int = 0, limit: int = 50 490 ) -> PagedList[EvalCampaignState]: 491 """The campaigns this account entered, newest first — one page, 492 with the total (``limit`` at most 200). 493 494 Only entries: an operator campaign the same account queued is 495 not one of them, because the two are different claims about the 496 same board. ``competition_id`` narrows to entries into that one 497 competition, on the server. 498 499 ⚠️ This returned a bare list, the server's newest 200, with 500 nothing to say the list was cut; ``total`` says how many exist. 501 """ 502 params: dict[str, Any] = {"offset": offset, "limit": limit} 503 if competition_id: 504 params["competition_id"] = competition_id 505 resp = self._http.request("GET", "/eval/entries", params=params) 506 _raise_for_status(resp) 507 return _extract_items(resp.json(), EvalCampaignState) 508 509 def runs(self, *, competition_id: str = "", offset: int = 0, limit: int = 50) -> MyEvalRunPage: 510 """Evaluation runs you may read — your own plus your team-mates'. 511 512 ⚠️ The author set is the same membership join the per-run routes 513 enforce, so a run this lists can always be opened and a run it 514 omits always 404s. Two readings of "the same team" would drift 515 in the open direction. 516 517 ⚠️ ``mine`` on each row says whether *you* may open the 518 conversations. It is composed server-side because it gates a 519 control, and two clients deriving it would disagree. 520 Wraps ``GET /eval/runs``.""" 521 resp = self._http.request( 522 "GET", 523 "/eval/runs", 524 params={"competition_id": competition_id, "offset": offset, "limit": limit}, 525 ) 526 _raise_for_status(resp) 527 return MyEvalRunPage.model_validate(resp.json()) 528 529 def run(self, run_id: str) -> MyEvalRunRow: 530 """One of your runs, projected as the shelf projects it. 531 532 ⚠️ Addressable because :meth:`runs` is paginated: one campaign 533 over a large bucket is hundreds of runs, so finding a row by 534 scanning the first page is a bug with a threshold. 535 536 Metadata scope — a team-mate reads the row, and its ``traffic`` 537 field says ``not_yours`` rather than the route refusing. 538 Wraps ``GET /eval/runs/{run_id}``.""" 539 resp = self._http.request("GET", f"/eval/runs/{run_id}") 540 _raise_for_status(resp) 541 return MyEvalRunRow.model_validate(resp.json()) 542 543 def campaign_runs(self, campaign_id: str) -> MyEvalRunPage: 544 """Every atomic task of one campaign, in dispatch order (#2585). 545 546 ⚠️ Whole, never a page: the server answers a ``campaign_id`` listing 547 in full, so counting stages or numbering the queue from it is sound. 548 Wraps ``GET /eval/runs?campaign_id=``.""" 549 resp = self._http.request("GET", "/eval/runs", params={"campaign_id": campaign_id}) 550 _raise_for_status(resp) 551 return MyEvalRunPage.model_validate(resp.json()) 552 553 def run_events(self, run_id: str) -> EvalRunEventList: 554 """One atomic task's event log, in time order (#2585). 555 Wraps ``GET /eval/runs/{run_id}/events``.""" 556 resp = self._http.request("GET", f"/eval/runs/{run_id}/events") 557 _raise_for_status(resp) 558 return EvalRunEventList.model_validate(resp.json()) 559 560 def cancel_run( 561 self, run_id: str, *, mode: CancelMode = "drain", requeue: bool = False 562 ) -> EvalQueueChange: 563 """Cancel one atomic task (#2585): queued → cancelled now; started → 564 stopped (``mode="now"``) or left to finish (``"drain"``). A task 565 being graded answers ``409 eval_run_grading``. 566 Wraps ``POST /eval/runs/{run_id}/cancel``.""" 567 resp = self._http.request( 568 "POST", 569 f"/eval/runs/{run_id}/cancel", 570 json=EvalCancelRequest(mode=mode, requeue=requeue).model_dump(), 571 ) 572 _raise_for_status(resp) 573 return EvalQueueChange.model_validate(resp.json()) 574 575 def campaign_summary(self, campaign_id: str) -> EvalCampaignAtomicSummary: 576 """A campaign's tasks counted per stage, and whether it is 577 ``incomplete`` (off the official board) (#2585). 578 Wraps ``GET /eval/campaigns/{campaign_id}/summary``.""" 579 resp = self._http.request("GET", f"/eval/campaigns/{campaign_id}/summary") 580 _raise_for_status(resp) 581 return EvalCampaignAtomicSummary.model_validate(resp.json()) 582 583 def cancel_campaign(self, campaign_id: str, *, mode: CancelMode = "drain") -> EvalQueueChange: 584 """Cancel a campaign's queued tasks now, its started ones per 585 ``mode`` (#2585). Wraps ``POST /eval/campaigns/{campaign_id}/cancel``.""" 586 resp = self._http.request( 587 "POST", 588 f"/eval/campaigns/{campaign_id}/cancel", 589 json=EvalCancelRequest(mode=mode).model_dump(), 590 ) 591 _raise_for_status(resp) 592 return EvalQueueChange.model_validate(resp.json()) 593 594 def batch_limits(self) -> EvalBatchLimits: 595 """The most cells one batch may hold, and the ranges your runs may 596 hold at once across every campaign of yours (#2677). 597 Wraps ``GET /eval/batches/limits``.""" 598 resp = self._http.request("GET", "/eval/batches/limits") 599 _raise_for_status(resp) 600 return EvalBatchLimits.model_validate(resp.json()) 601 602 def enter_batch( 603 self, 604 *, 605 competition_id: str, 606 harness_ids: Sequence[str], 607 models: Sequence[EvalBatchModel], 608 num_samples: int = 1, 609 scheduling_cost_limit_usd: float = 0.0, 610 timeout_s: int = 0, 611 max_turns: int = 0, 612 challenge_ids: Sequence[str] = (), 613 ) -> EvalBatchState: 614 """Enter each of your agents with each model on one target: one 615 campaign per cell, grouped under a batch (#2677). 616 617 Every cell is checked as :meth:`enter` checks one entry before any is 618 created, so a refusal carries that cell's own ``code`` and nothing 619 is created. More cells than the deployment allows is ``409 620 eval_batch_too_large``. ``scheduling_cost_limit_usd`` is per cell. 621 Wraps ``POST /eval/batches``.""" 622 body = EvalBatchRequest( 623 competition_id=competition_id, 624 harness_ids=list(harness_ids), 625 models=list(models), 626 num_samples=num_samples, 627 scheduling_cost_limit_usd=scheduling_cost_limit_usd, 628 timeout_s=timeout_s, 629 max_turns=max_turns, 630 challenge_ids=list(challenge_ids), 631 ) 632 resp = self._http.request("POST", "/eval/batches", json=body.model_dump()) 633 _raise_for_status(resp) 634 return EvalBatchState.model_validate(resp.json()) 635 636 def batch(self, batch_id: str) -> EvalBatchState: 637 """One of your batches: its cells' campaigns (#2677). 638 Wraps ``GET /eval/batches/{batch_id}``.""" 639 resp = self._http.request("GET", f"/eval/batches/{batch_id}") 640 _raise_for_status(resp) 641 return EvalBatchState.model_validate(resp.json()) 642 643 def cancel_batch(self, batch_id: str, *, mode: CancelMode = "drain") -> EvalBatchCancelResult: 644 """Cancel every cell of a batch still running, each as 645 :meth:`cancel_campaign` would (#2677). 646 Wraps ``POST /eval/batches/{batch_id}/cancel``.""" 647 resp = self._http.request( 648 "POST", 649 f"/eval/batches/{batch_id}/cancel", 650 json=EvalCancelRequest(mode=mode).model_dump(), 651 ) 652 _raise_for_status(resp) 653 return EvalBatchCancelResult.model_validate(resp.json()) 654 655 def rerun_unfinished(self, campaign_id: str) -> EvalQueueChange: 656 """Queue one fresh task per cancelled task nothing has filled (#2585). 657 Wraps ``POST /eval/campaigns/{campaign_id}/rerun-unfinished``.""" 658 resp = self._http.request("POST", f"/eval/campaigns/{campaign_id}/rerun-unfinished") 659 _raise_for_status(resp) 660 return EvalQueueChange.model_validate(resp.json()) 661 662 def run_llm_calls(self, run_id: str, *, offset: int = 0, limit: int = 50) -> LlmCallPage: 663 """Every model request one of your runs made, newest first. 664 665 Metadata only — token split, latency, queue wait, the estimated 666 cost. **Scoped to the run's team**, so a captain can see who is 667 burning the allowance without reading anybody's prompts, and to 668 ``principal == "agent"``, so a challenge container's own 669 conversation is never on this route. 670 Wraps ``GET /eval/runs/{run_id}/llm-calls``.""" 671 resp = self._http.request( 672 "GET", 673 f"/eval/runs/{run_id}/llm-calls", 674 params={"offset": offset, "limit": limit}, 675 ) 676 _raise_for_status(resp) 677 return LlmCallPage.model_validate(resp.json()) 678 679 def run_agent_log(self, run_id: str, *, after: int = 0) -> AgentLogPage: 680 """One of your runs' own stdout and stderr, live, after a cursor (#2401). 681 682 **The run author's only** (``403 agent_log_owner_only`` for a 683 team-mate). Pass the last ``seq`` you hold as *after*; a jump in 684 ``seq`` is lines that are no longer held. Scrubbed on the platform 685 before it was held. 686 Wraps ``GET /eval/runs/{run_id}/agent-log``.""" 687 resp = self._http.request("GET", f"/eval/runs/{run_id}/agent-log", params={"after": after}) 688 _raise_for_status(resp) 689 return AgentLogPage.model_validate(resp.json()) 690 691 def run_llm_live(self, run_id: str, *, after: int = 0) -> LlmLivePage: 692 """What one of your runs' model is sending back, live, after a cursor 693 (#2401 slice 2). 694 695 The body's readers only — the author, or a platform admin 696 (``403 llm_live_owner_only`` for a team-mate). Pass the last ``seq`` 697 you hold as *after*; a jump in ``seq`` is deltas no longer held. 698 Wraps ``GET /eval/runs/{run_id}/llm-live``.""" 699 resp = self._http.request("GET", f"/eval/runs/{run_id}/llm-live", params={"after": after}) 700 _raise_for_status(resp) 701 return LlmLivePage.model_validate(resp.json()) 702 703 def run_llm_call(self, run_id: str, call_id: str) -> LlmCallBody: 704 """The conversation itself — **the run author's only**. 705 706 ⚠️ Narrower than :meth:`run_llm_calls` on purpose: a team-mate 707 who can see the row is answered ``403 708 transcript_body_owner_only`` here, since an agent's prompt is its 709 author's work and ``HarnessStanding`` ranks the two of them 710 against each other. 711 712 ⚠️ ``available=False`` is an ordinary answer, not an error — 713 retention reclaims bodies and leaves the row behind. 714 Wraps ``GET /eval/runs/{run_id}/llm-calls/{call_id}``.""" 715 resp = self._http.request("GET", f"/eval/runs/{run_id}/llm-calls/{call_id}") 716 _raise_for_status(resp) 717 return LlmCallBody.model_validate(resp.json()) 718 719 def run_artifact(self, run_id: str) -> bytes: 720 """The files one of your runs left behind, as ``.tar.gz`` bytes. 721 722 Its working directory and, when the harness is Claude Code, its 723 session — collected from the container before it was removed. 724 The writing half, where :meth:`run_llm_call` is the deciding one 725 and :meth:`run_traffic` the doing one. 726 727 ⚠️ **The run author's only**, the narrowest of the three: a 728 team-mate who can see the row is answered ``403 729 run_artifact_owner_only``. An archive carries whatever the agent 730 wrote down, which for a solved challenge includes the flag. 731 732 ⚠️ **Bytes, not a model, and no URL.** The read goes through the 733 platform rather than handing out a bucket address, so there is 734 nothing to return but the archive itself. ⚠️ A run that kept no 735 files answers ``404 run_artifact_not_found`` rather than empty 736 bytes — which of the several reasons it is (the agent wrote 737 nothing, collection failed, retention took them) is on the run 738 row's ``artifact`` field, so the caller never has to guess from 739 an absence. 740 741 Wraps ``GET /eval/runs/{run_id}/artifact``.""" 742 resp = self._http.request("GET", f"/eval/runs/{run_id}/artifact") 743 _raise_for_status(resp) 744 return bytes(resp.content) 745 746 def run_traffic(self, run_id: str, *, cursor: str = "", limit: int = 0) -> InstanceTrafficPage: 747 """Every request one of your runs sent at the challenge. 748 749 The doing half, where :meth:`run_llm_call` is the deciding half. 750 **The run author's only** — a team-mate who can see the row is 751 answered ``403 run_traffic_owner_only``, the same split the 752 conversation body makes, because the payloads an agent wrote are 753 its author's work. 754 755 ⚠️ Carry ``cursor`` forward from the previous page and you get 756 only what arrived since; pass an empty one and you get the whole 757 capture again. Every failure — an unreachable node, a run whose 758 estate the reaper has taken — answers 200 with your own cursor 759 and no flows, so a blip never re-delivers the capture. Which of 760 those it was, ``MyEvalRunRow.traffic`` says. 761 762 ⚠️ Redacted: this instance's minted answers are scrubbed out. 763 Wraps ``GET /eval/runs/{run_id}/traffic``.""" 764 resp = self._http.request( 765 "GET", 766 f"/eval/runs/{run_id}/traffic", 767 params={"cursor": cursor, "limit": limit}, 768 ) 769 _raise_for_status(resp) 770 return InstanceTrafficPage.model_validate(resp.json()) 771 772 def run_writeup(self, run_id: str) -> RunWriteup: 773 """What one of your runs said it did, in its own words. 774 775 ``CTFY-0001`` §3 invites an optional ``writeup`` in the result 776 file. The saying half, where :meth:`run_llm_call` is the 777 deciding one, :meth:`run_traffic` the doing one and 778 :meth:`run_artifact` the writing one. 779 780 ⚠️ **Telemetry, never a grading path.** Nothing in the result 781 file is scored — the run's own ``POST /submissions`` is — so a 782 write-up cannot earn a point and is not read as an answer. 783 784 ⚠️ **The run author's only**: a team-mate who can see the row is 785 answered ``403 run_writeup_owner_only``, the same split the 786 other three bodies make. ⚠️ A run whose image wrote none answers 787 ``404 run_writeup_not_found`` rather than empty prose, and the 788 run row's ``writeup`` field already said which — so the caller 789 never has to reach here to find out. 790 791 Wraps ``GET /eval/runs/{run_id}/writeup``.""" 792 resp = self._http.request("GET", f"/eval/runs/{run_id}/writeup") 793 _raise_for_status(resp) 794 return RunWriteup.model_validate(resp.json()) 795 796 def set_run_wall(self, run_id: str, *, published: bool) -> MyEvalRunRow: 797 """Put this run's agent on the event's projector wall, or take it off. 798 799 ``GET /competitions/{id}/agent-actions`` draws one scrubbed line 800 per agent saying what it is doing. The scrub answers what the 801 bytes make unsafe; this answers the part it cannot — the 802 conversation belongs to whoever ran it, so the wall shows an 803 entrant only once their author has said yes. 804 805 ⚠️ **The switch is on the *entrant*, not on this run.** The 806 transcript index groups by team and an agent's team is one per 807 ``(owner, harness, model, competition)``, so every run of the 808 same agent at the same event shares one answer — which is what 809 "this agent, at this event" means. The row that comes back is 810 the run's, with ``wall_published`` re-read from the entrant. 811 812 ⚠️ **The author's only**: a team-mate who can see the row is 813 answered ``403 run_wall_not_yours``, the same split 814 :meth:`run_writeup` makes, and for the same reason — the line 815 is read out of a conversation they already meet a 403 on. 816 817 ⚠️ A run with no agent entrant answers ``409`` naming which 818 kind of nothing it is; ``MyEvalRunRow.wall`` carries the same 819 code, so a caller never has to press to find out. 820 821 Wraps ``POST /eval/runs/{run_id}/wall``.""" 822 resp = self._http.request( 823 "POST", 824 f"/eval/runs/{run_id}/wall", 825 json={"published": published}, 826 ) 827 _raise_for_status(resp) 828 return MyEvalRunRow.model_validate(resp.json()) 829 830 def set_run_transcript(self, run_id: str, *, published: bool) -> MyEvalRunRow: 831 """Publish this run's agent's **full conversation**, or take it back. 832 833 A **second, stronger** consent than :meth:`set_run_wall`. The wall 834 shows one scrubbed line of what the agent is doing; this opens the 835 whole conversation — every turn the agent wrote — to spectators at 836 ``GET /competitions/{id}/awd/transcript/{call_id}``. The projection 837 still emits only the agent's own scrubbed turns (never the system 838 prompt, never a tool result), but the decision to publish a 839 conversation at all is the author's alone and separate from the 840 wall's. 841 842 ⚠️ **The switch is on the *entrant***, exactly as the wall's — 843 one answer per ``(owner, harness, model, competition)``. 844 845 ⚠️ **Refused for an anonymous entrant** (``403 846 run_transcript_anonymous``): a conversation de-anonymises whoever 847 wrote it, so the withheld-identity promise wins over the consent. 848 The author's-only and no-agent refusals mirror :meth:`set_run_wall`. 849 850 Wraps ``POST /eval/runs/{run_id}/transcript``.""" 851 resp = self._http.request( 852 "POST", 853 f"/eval/runs/{run_id}/transcript", 854 json={"published": published}, 855 ) 856 _raise_for_status(resp) 857 return MyEvalRunRow.model_validate(resp.json()) 858 859 def registry_push_target(self) -> RegistryPushTarget: 860 """Where this account pushes an agent image, if anywhere. 861 862 ⚠️ Read ``enabled``, not a non-empty ``repository_prefix``. The 863 server composes the verdict from four facts — a host, a token 864 service, and both halves of the signing pair — and a deployment 865 with a hostname but no signing pair refuses every login. 866 """ 867 resp = self._http.request("GET", "/eval/registry") 868 _raise_for_status(resp) 869 return RegistryPushTarget.model_validate(resp.json()) 870 871 def harness_tags(self, harness_id: str) -> HarnessTags: 872 """Which versions the registry publishes for one of your agents. 873 874 ⚠️ Addressed by **harness id**, not by image: the platform looks 875 the reference up in your own registry row, which is what keeps 876 this from being an outbound proxy. Answers 404 for anything that 877 is not yours, and 502 when the registry itself would not answer 878 — never an empty list, which would read as "no versions". 879 """ 880 resp = self._http.request("GET", f"/eval/harnesses/{harness_id}/tags") 881 _raise_for_status(resp) 882 return HarnessTags.model_validate(resp.json()) 883 884 def check_harness(self, harness_id: str) -> HarnessState: 885 """Ask the platform to look at one of your agent images. 886 887 Returns the row with the verdict on it: ``conformance_status`` 888 (``""`` never checked / ``passed`` / ``failed`` / ``error``), 889 the digest the verdict describes, and a finding per thing worth 890 saying. 891 892 ⚠️ **Evidence, never a gate.** Nothing here changes 893 ``review_status``, and a ``failed`` verdict does not stop an 894 operator approving the image — the check exists so somebody can 895 see *why* a dispatch would go wrong, before it does. 896 897 ⚠️ **``error`` is not ``failed``, and it arrives as a 200.** A 898 registry that would not answer is not a fact about the image; 899 the message is on ``conformance_error``. 900 901 ⚠️ Addressed by harness id, like :meth:`harness_tags` and for 902 the same reason: the image is read off your own registry row, so 903 this cannot be pointed at an arbitrary host. 904 """ 905 resp = self._http.request("POST", f"/eval/harnesses/{harness_id}/conformance") 906 _raise_for_status(resp) 907 return HarnessState.model_validate(resp.json()) 908 909 def probe_harness(self, harness_id: str) -> MyHarness: 910 """Put one of your agent images in line to be run once, to check it. 911 912 Where :meth:`check_harness` reads a manifest, this runs the 913 container on a worker that may hold a user's code, under the 914 isolation a real dispatch uses, with a briefing marked ``dry_run``, 915 and asks the one question a manifest cannot answer: **did it write 916 a result file at the address it was given.** 917 918 ⚠️ **Returns the row in line, not a verdict** (#2608): 919 ``probe_status`` is ``queued`` (with ``probe_reason`` and 920 ``probe_position``) or ``running``; poll :meth:`harnesses` for 921 ``passed`` / ``failed`` (with a reason) / ``error``. 922 923 ⚠️ **No credential travels with the probe** but the row's own 924 scoped pull ticket, or the owner's stored registry login for the 925 image's own host (#3475), and no platform URL is named. See §3.1 of 926 ``docs/standards/CTFY-0001.md``. 927 """ 928 resp = self._http.request("POST", f"/eval/harnesses/{harness_id}/probe") 929 _raise_for_status(resp) 930 return MyHarness.model_validate(resp.json()) 931 932 def cancel_probe(self, harness_id: str) -> MyHarness: 933 """Take a queued dry run out of line (409 once it has started).""" 934 resp = self._http.request("DELETE", f"/eval/harnesses/{harness_id}/probe") 935 _raise_for_status(resp) 936 return MyHarness.model_validate(resp.json()) 937 938 def set_registry_credential(self, harness_id: str, *, username: str, secret: str) -> MyHarness: 939 """Store the login your agent's image is pulled with from a private registry (#3475). 940 941 The registry is the one the agent's image names; a host the platform 942 does not pull from is refused (400 ``harness_registry_not_allowed``). 943 The answer shows ``registry_credential`` (who and when), never the 944 token. Run the check and dry run again afterwards. 945 """ 946 resp = self._http.request( 947 "PUT", 948 f"/eval/harnesses/{harness_id}/registry-credential", 949 json={"username": username, "secret": secret}, 950 ) 951 _raise_for_status(resp) 952 return MyHarness.model_validate(resp.json()) 953 954 def clear_registry_credential(self, harness_id: str) -> MyHarness: 955 """Remove your agent's registry login; its image is then pulled anonymously.""" 956 resp = self._http.request("DELETE", f"/eval/harnesses/{harness_id}/registry-credential") 957 _raise_for_status(resp) 958 return MyHarness.model_validate(resp.json())
The public model leaderboard (no auth), and the signed-in caller's
own harness registry (/eval/harnesses).
62 def leaderboard( 63 self, 64 competition_id: str, 65 *, 66 tier: str = "official", 67 corpus_sha: str = "", 68 q: str = "", 69 vendor: str = "", 70 harness_id: str | None = None, 71 offset: int = 0, 72 limit: int | None = None, 73 ) -> EvalLeaderboard: 74 """Ranked model standings + per-dimension (difficulty / bucket / tag) 75 breakdowns for **one** competition or suite. There is no platform-wide 76 board (#2341): runs graded under different sets do not rank together. 77 78 ``tier`` selects the credibility band: ``official`` (default, 79 platform-run), ``self_reported`` (vendor-run, unverified), or ``all`` 80 for both. Sandbox runs are never exposed here. ``corpus_sha`` counts 81 only runs graded against that version (a suite's pin); empty is every 82 version. 83 84 #2733: ``harness_id`` scopes the aggregate to one harness (``""`` = 85 the built-in one; ``None`` = every harness) and ranks inside it. 86 ``q`` (model search), ``vendor`` (slug), ``offset`` and ``limit`` 87 pick rows out of the scope without re-ranking; ``result.scope`` 88 reports the revision, manifest, freshness, filters and totals.""" 89 if not competition_id: 90 raise ValueError("competition_id is required: there is no platform-wide board") 91 params: dict[str, str | int] = { 92 "competition_id": competition_id, 93 "tier": tier, 94 "corpus_sha": corpus_sha, 95 "q": q, 96 "vendor": vendor, 97 "offset": offset, 98 } 99 # ⚠️ ``""`` is a real harness (the built-in one), so only ``None`` 100 # leaves the parameter out. 101 if harness_id is not None: 102 params["harness_id"] = harness_id 103 if limit is not None: 104 params["limit"] = limit 105 resp = self._http.request("GET", "/eval/leaderboard", params=params) 106 _raise_for_status(resp) 107 return EvalLeaderboard.model_validate(resp.json())
Ranked model standings + per-dimension (difficulty / bucket / tag) breakdowns for one competition or suite. There is no platform-wide board (#2341): runs graded under different sets do not rank together.
tier selects the credibility band: official (default,
platform-run), self_reported (vendor-run, unverified), or all
for both. Sandbox runs are never exposed here. corpus_sha counts
only runs graded against that version (a suite's pin); empty is every
version.
2733: harness_id scopes the aggregate to one harness ("" =
the built-in one; None = every harness) and ranks inside it.
q (model search), vendor (slug), offset and limit
pick rows out of the scope without re-ranking; result.scope
reports the revision, manifest, freshness, filters and totals.
111 def harnesses(self) -> list[HarnessState]: 112 """The harnesses *you* registered, newest first. 113 114 Scoped to the caller — the platform's own images and other 115 users' are not listed here (``/admin/eval-harnesses`` is the 116 operator's whole-registry view). 117 """ 118 resp = self._http.request("GET", "/eval/harnesses") 119 _raise_for_status(resp) 120 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).
122 def harness_versions(self, harness_id: str) -> list[HarnessVersionState]: 123 """Your agent's saved immutable releases, newest first.""" 124 resp = self._http.request("GET", f"/eval/harnesses/{harness_id}/versions") 125 _raise_for_status(resp) 126 return [HarnessVersionState.model_validate(row) for row in resp.json()]
Your agent's saved immutable releases, newest first.
128 def create_harness_version(self, harness_id: str) -> HarnessVersionState: 129 """Save the currently reviewed image; the first release becomes default.""" 130 resp = self._http.request("POST", f"/eval/harnesses/{harness_id}/versions") 131 _raise_for_status(resp) 132 return HarnessVersionState.model_validate(resp.json())
Save the currently reviewed image; the first release becomes default.
134 def register_harness( 135 self, 136 harness_id: str, 137 image: str, 138 *, 139 display_name: str = "", 140 version: str = "", 141 notes: str = "", 142 dialect: str = "", 143 ) -> HarnessState: 144 """Register one of your own agent images. 145 146 ⚠️ The returned row is ``review_status="pending"`` and is **not 147 dispatchable** until an operator approves it — registering an 148 image and being trusted to run one are deliberately two things. 149 ``owner_user_id`` and ``review_status`` are stamped by the 150 server; anything you pass for them is ignored. 151 """ 152 resp = self._http.request( 153 "POST", 154 "/eval/harnesses", 155 json={ 156 "id": harness_id, 157 "image": image, 158 "display_name": display_name, 159 "version": version, 160 "notes": notes, 161 # #3473: the wire surface the agent calls; "" is not declared. 162 "dialect": dialect, 163 }, 164 ) 165 _raise_for_status(resp) 166 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.
168 def update_harness( 169 self, 170 harness_id: str, 171 *, 172 image: str | None = None, 173 display_name: str | None = None, 174 version: str | None = None, 175 notes: str | None = None, 176 source_url: str | None = None, 177 active: bool | None = None, 178 default_version_id: str | None = None, 179 dialect: str | None = None, 180 ) -> HarnessState: 181 """Edit one of your own. Anything left ``None`` is untouched. 182 183 ⚠️ **Changing ``image`` puts the row back to 184 ``review_status="pending"``** and clears the approved digest — 185 the verdict was about specific content, and a dispatch pulls the 186 digest rather than the tag. Carrying the approval across would 187 leave the row naming a new reference while the node kept running 188 the old bytes. Re-sending the *same* image is a no-op and costs 189 nothing. 190 191 Anything that is not yours answers 404 rather than 403, like 192 :meth:`delete_harness`. 193 """ 194 # ⚠️ Absence is what "leave it alone" means on the wire, so an 195 # unset argument must not appear in the body at all — the server 196 # reads ``model_fields_set``, and a key sent as ``null`` is a key 197 # that was sent. 198 body: dict[str, object] = { 199 name: value 200 for name, value in ( 201 ("image", image), 202 ("display_name", display_name), 203 ("version", version), 204 ("notes", notes), 205 ("source_url", source_url), 206 ("active", active), 207 ("default_version_id", default_version_id), 208 ("dialect", dialect), 209 ) 210 if value is not None 211 } 212 resp = self._http.request("PATCH", f"/eval/harnesses/{harness_id}", json=body) 213 _raise_for_status(resp) 214 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().
216 def delete_harness(self, harness_id: str) -> bool: 217 """Remove one of your own. Anything that is not yours — another 218 user's, or a platform image — answers 404 rather than 403, so 219 this cannot be used to probe the registry.""" 220 resp = self._http.request("DELETE", f"/eval/harnesses/{harness_id}") 221 _raise_for_status(resp) 222 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.
224 def llm_providers(self) -> list[LlmProviderChoice]: 225 """The upstreams this deployment will relay to. 226 227 ⚠️ **Ask rather than assume.** The gateway makes the request to 228 whatever address a key names — from inside its own network, and 229 it returns the answer — so the set is closed and an operator may 230 replace it. A caller carrying its own list would offer addresses 231 this deployment refuses. 232 233 Each entry carries its dialect: a provider *is* a dialect plus an 234 address, and DeepSeek, Moonshot and OpenAI all speak ``openai``. 235 """ 236 resp = self._http.request("GET", "/eval/llm-providers") 237 _raise_for_status(resp) 238 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.
240 def my_llm_keys(self) -> list[MyLlmKey]: 241 """Your own upstream credentials — what they are, never what they say. 242 243 ⚠️ There is no ``api_key`` on the way back, by design. A key is 244 pasted once; every read after that lets you tell two apart, not 245 copy one. 246 """ 247 resp = self._http.request("GET", "/eval/llm-keys") 248 _raise_for_status(resp) 249 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.
251 def put_my_llm_key( 252 self, flavor: str, api_key: str, *, label: str = "", base_url: str = "" 253 ) -> MyLlmKey: 254 """Store or rotate the credential this account spends for ``flavor``. 255 256 A repeat dialect replaces: rotation is a write, because 257 delete-then-add leaves a window in which a run has no key. 258 Answers 503 on a deployment that cannot seal secrets at rest — 259 the key is never stored in the clear. 260 """ 261 resp = self._http.request( 262 "PUT", 263 f"/eval/llm-keys/{flavor}", 264 json={"api_key": api_key, "label": label, "base_url": base_url}, 265 ) 266 _raise_for_status(resp) 267 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.
269 def delete_my_llm_key(self, flavor: str, *, base_url: str = "") -> bool: 270 """Remove it. 404 for a provider you hold no key for — the same 271 answer somebody else's key gets, so this cannot report who holds 272 what. 273 274 ⚠️ ``base_url`` is part of the address, not a filter. Naming only 275 the dialect is ambiguous the moment you hold two ``openai`` 276 providers, and resolving that by picking one is how somebody 277 loses the key they meant to keep. ``""`` names that dialect's 278 official endpoint, which is a value rather than a wildcard. 279 """ 280 resp = self._http.request( 281 "DELETE", f"/eval/llm-keys/{flavor}", params={"base_url": base_url} 282 ) 283 _raise_for_status(resp) 284 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.
286 def probe_my_llm_key(self, flavor: str, *, base_url: str = "") -> MyLlmKeyProbe: 287 """Spend one token on your own credential and report what happened. 288 289 Storing a key says the write landed; it says nothing about 290 whether the credential **works**. The only other way to find 291 that out is to enter an agent, let a run be dispatched, and read 292 the failure out of the artifact tarball — two hours, on a full 293 range, to learn that a key is invalid. 294 295 ⚠️ **A code, never a boolean.** A rejected key, an address 296 nothing answers at, an account out of credit and a deployment 297 with no model registered at this endpoint are four different 298 fields to go and fix. 299 300 ⚠️ **200 with the outcome in the body** for every one of them — 301 so this does not raise on a failed credential. A 404 (and only a 302 404) means you hold no key for that provider. 303 304 ⚠️ ``base_url`` is part of the address, exactly as it is for the 305 removal beside it: naming the dialect alone picks whichever row 306 matched first the moment you hold two ``openai`` providers. 307 """ 308 resp = self._http.request( 309 "POST", f"/eval/llm-keys/{flavor}/probe", params={"base_url": base_url} 310 ) 311 _raise_for_status(resp) 312 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.
316 def llm_accounts(self) -> list[LlmAccountChoice]: 317 """The vendor accounts this deployment relays to, one row per account. 318 319 Never per dialect: the gateway routes each request to the account's 320 endpoint in the dialect the agent sent, so there is nothing to pick. 321 """ 322 resp = self._http.request("GET", "/eval/llm-accounts") 323 _raise_for_status(resp) 324 return [LlmAccountChoice.model_validate(r) for r in resp.json()]
The vendor accounts this deployment relays to, one row per account.
Never per dialect: the gateway routes each request to the account's endpoint in the dialect the agent sent, so there is nothing to pick.
326 def my_llm_connections(self) -> list[MyLlmConnection]: 327 """Your connections, with each surface's last check and the model list.""" 328 resp = self._http.request("GET", "/eval/llm-connections") 329 _raise_for_status(resp) 330 return [MyLlmConnection.model_validate(r) for r in resp.json()]
Your connections, with each surface's last check and the model list.
332 def connect_llm(self, account: str, api_key: str, *, label: str = "") -> MyLlmConnection: 333 """Save (or rotate) the key for one vendor account, then check it. 334 335 The check probes every surface of the account once with the key — a 336 tiny call each, on your own bill — and reads the vendor's model list. 337 A key that fails is still saved; the answer says which surface refused. 338 """ 339 body = MyLlmConnectionCreate(account=account, api_key=api_key, label=label) 340 resp = self._http.request("POST", "/eval/llm-connections", json=body.model_dump()) 341 _raise_for_status(resp) 342 return MyLlmConnection.model_validate(resp.json())
Save (or rotate) the key for one vendor account, then check it.
The check probes every surface of the account once with the key — a tiny call each, on your own bill — and reads the vendor's model list. A key that fails is still saved; the answer says which surface refused.
344 def recheck_llm_connection(self, connection_id: str) -> MyLlmConnection: 345 """Probe every surface again and re-read the model list (200 either way).""" 346 body = LlmConnectionRef(id=connection_id).model_dump() 347 resp = self._http.request("POST", "/eval/llm-connections/check", json=body) 348 _raise_for_status(resp) 349 return MyLlmConnection.model_validate(resp.json())
Probe every surface again and re-read the model list (200 either way).
351 def refresh_llm_connection_models(self, connection_id: str) -> MyLlmConnection: 352 """Re-read only the vendor's model list with this key (free, no probe).""" 353 body = LlmConnectionRef(id=connection_id).model_dump() 354 resp = self._http.request("POST", "/eval/llm-connections/models", json=body) 355 _raise_for_status(resp) 356 return MyLlmConnection.model_validate(resp.json())
Re-read only the vendor's model list with this key (free, no probe).
358 def llm_connections_usage(self) -> LlmUsagePage: 359 """Your connections' last-30-day tokens, runs and estimated cost.""" 360 resp = self._http.request("GET", "/eval/llm-connections/usage") 361 _raise_for_status(resp) 362 return LlmUsagePage.model_validate(resp.json())
Your connections' last-30-day tokens, runs and estimated cost.
364 def read_llm_connection_balance( 365 self, connection_id: str, *, force: bool = False 366 ) -> MyLlmConnection: 367 """Read the account balance with this key (``force`` skips the 10-min reuse).""" 368 body = LlmBalanceRefresh(id=connection_id, force=force).model_dump() 369 resp = self._http.request("POST", "/eval/llm-connections/balance", json=body) 370 _raise_for_status(resp) 371 return MyLlmConnection.model_validate(resp.json())
Read the account balance with this key (force skips the 10-min reuse).
373 def set_llm_connection_cap( 374 self, connection_id: str, *, usd: float = 0.0, tokens: int = 0 375 ) -> MyLlmConnection: 376 """Set one connection's monthly cap; zeros clear it.""" 377 body = LlmSpendCapUpdate(id=connection_id, usd=usd, tokens=tokens).model_dump() 378 resp = self._http.request("PUT", "/eval/llm-connections/cap", json=body) 379 _raise_for_status(resp) 380 return MyLlmConnection.model_validate(resp.json())
Set one connection's monthly cap; zeros clear it.
382 def delete_llm_connection(self, connection_id: str) -> bool: 383 """Remove one connection. 404 for one that is not yours.""" 384 resp = self._http.request( 385 "DELETE", "/eval/llm-connections", params={"connection_id": connection_id} 386 ) 387 _raise_for_status(resp) 388 return bool(resp.json().get("deleted", False))
Remove one connection. 404 for one that is not yours.
390 def my_allowances(self) -> list[EvalAllowanceRow]: 391 """What an operator has granted this account, and how much is left. 392 393 ⚠️ Read ``remaining_usd`` rather than subtracting: the server 394 clamps an overshoot at zero, and a client doing its own 395 arithmetic would eventually disagree with the refusal that 396 actually decides. 397 """ 398 resp = self._http.request("GET", "/eval/allowances") 399 _raise_for_status(resp) 400 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.
402 def models(self) -> list[LlmModelOption]: 403 """The models an entry may name. 404 405 ⚠️ Approved and active only, decided by the server. The registry 406 holds more than this — the operator's own notes and the 407 addressing that says where their credential is spent — and none 408 of it travels. 409 """ 410 resp = self._http.request("GET", "/eval/models") 411 _raise_for_status(resp) 412 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.
414 def enter( 415 self, 416 *, 417 harness_id: str, 418 model_id: str = "", 419 competition_id: str, 420 num_samples: int = 1, 421 harness_version_id: str = "", 422 funding_source: EvalFundingSource = "auto", 423 llm_connection_id: str = "", 424 scheduling_cost_limit_usd: float = 0.0, 425 timeout_s: int = 0, 426 max_turns: int = 0, 427 challenge_ids: Sequence[str] = (), 428 provider_model: str = "", 429 ) -> EvalCampaignState: 430 """Put one of your own agents into one competition. 431 432 Name a registered ``model_id`` (``models()``), **or** — #3471 — type 433 the provider's own model id as ``provider_model`` with 434 ``funding_source="byok"`` and the ``llm_connection_id`` of the saved 435 connection whose provider serves it. No operator row is needed; the 436 platform spends one small call on your key to check the pair first 437 and refuses with a ``byok_model_check_*`` code when it fails. 438 439 ⚠️ **The result is `self_reported`, and the server decides that.** 440 The default public leaderboard shows operator-run results; this 441 is your own claim about your own agent, so read it back with 442 ``tier="self_reported"`` or ``"all"``. There is no parameter for 443 it here because there is none on the wire. 444 445 ⚠️ **409 carries the reason as its ``code``** — whether to wait 446 for review, add a key, or pick another event. A caller that 447 reports only "entry refused" throws away the only actionable 448 part of the answer. 449 450 ⚠️ **``funding_source`` defaults to ``"auto"`` here only for the 451 callers that predate it.** Pass ``"platform"`` or ``"byok"`` (with 452 ``llm_connection_id``) to say who pays: an explicit choice never 453 falls back to the other, which is what ``ctfy eval run`` does. 454 ``scheduling_cost_limit_usd`` stops *new* dispatches once recorded 455 spend reaches it; it is a scheduling threshold, not a hard cap. 456 ``0`` for ``timeout_s`` / ``max_turns`` inherits the platform's. 457 458 ``challenge_ids`` makes it a **trial run** (#2551): only those 459 challenges of the target, ranked on no board. Empty is the whole set. 460 """ 461 body: dict[str, Any] = { 462 "harness_id": harness_id, 463 "harness_version_id": harness_version_id, 464 "model_id": model_id, 465 "competition_id": competition_id, 466 "num_samples": num_samples, 467 } 468 # ⚠️ Only what was asked for: the bodies older callers send stay 469 # byte-for-byte what they were, so `auto` is never spelled out. 470 if funding_source != "auto": 471 body["funding_source"] = funding_source 472 if llm_connection_id: 473 body["llm_connection_id"] = llm_connection_id 474 if scheduling_cost_limit_usd: 475 body["scheduling_cost_limit_usd"] = scheduling_cost_limit_usd 476 if timeout_s: 477 body["timeout_s"] = timeout_s 478 if max_turns: 479 body["max_turns"] = max_turns 480 if challenge_ids: 481 body["challenge_ids"] = list(challenge_ids) 482 if provider_model: 483 body["provider_model"] = provider_model 484 resp = self._http.request("POST", "/eval/entries", json=body) 485 _raise_for_status(resp) 486 return EvalCampaignState.model_validate(resp.json())
Put one of your own agents into one competition.
Name a registered model_id (models()), or — #3471 — type
the provider's own model id as provider_model with
funding_source="byok" and the llm_connection_id of the saved
connection whose provider serves it. No operator row is needed; the
platform spends one small call on your key to check the pair first
and refuses with a byok_model_check_* code when it fails.
⚠️ 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.
⚠️ funding_source defaults to "auto" here only for the
callers that predate it. Pass "platform" or "byok" (with
llm_connection_id) to say who pays: an explicit choice never
falls back to the other, which is what ctfy eval run does.
scheduling_cost_limit_usd stops new dispatches once recorded
spend reaches it; it is a scheduling threshold, not a hard cap.
0 for timeout_s / max_turns inherits the platform's.
challenge_ids makes it a trial run (#2551): only those
challenges of the target, ranked on no board. Empty is the whole set.
488 def my_entries( 489 self, *, competition_id: str = "", offset: int = 0, limit: int = 50 490 ) -> PagedList[EvalCampaignState]: 491 """The campaigns this account entered, newest first — one page, 492 with the total (``limit`` at most 200). 493 494 Only entries: an operator campaign the same account queued is 495 not one of them, because the two are different claims about the 496 same board. ``competition_id`` narrows to entries into that one 497 competition, on the server. 498 499 ⚠️ This returned a bare list, the server's newest 200, with 500 nothing to say the list was cut; ``total`` says how many exist. 501 """ 502 params: dict[str, Any] = {"offset": offset, "limit": limit} 503 if competition_id: 504 params["competition_id"] = competition_id 505 resp = self._http.request("GET", "/eval/entries", params=params) 506 _raise_for_status(resp) 507 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.
509 def runs(self, *, competition_id: str = "", offset: int = 0, limit: int = 50) -> MyEvalRunPage: 510 """Evaluation runs you may read — your own plus your team-mates'. 511 512 ⚠️ The author set is the same membership join the per-run routes 513 enforce, so a run this lists can always be opened and a run it 514 omits always 404s. Two readings of "the same team" would drift 515 in the open direction. 516 517 ⚠️ ``mine`` on each row says whether *you* may open the 518 conversations. It is composed server-side because it gates a 519 control, and two clients deriving it would disagree. 520 Wraps ``GET /eval/runs``.""" 521 resp = self._http.request( 522 "GET", 523 "/eval/runs", 524 params={"competition_id": competition_id, "offset": offset, "limit": limit}, 525 ) 526 _raise_for_status(resp) 527 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.
529 def run(self, run_id: str) -> MyEvalRunRow: 530 """One of your runs, projected as the shelf projects it. 531 532 ⚠️ Addressable because :meth:`runs` is paginated: one campaign 533 over a large bucket is hundreds of runs, so finding a row by 534 scanning the first page is a bug with a threshold. 535 536 Metadata scope — a team-mate reads the row, and its ``traffic`` 537 field says ``not_yours`` rather than the route refusing. 538 Wraps ``GET /eval/runs/{run_id}``.""" 539 resp = self._http.request("GET", f"/eval/runs/{run_id}") 540 _raise_for_status(resp) 541 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}.
543 def campaign_runs(self, campaign_id: str) -> MyEvalRunPage: 544 """Every atomic task of one campaign, in dispatch order (#2585). 545 546 ⚠️ Whole, never a page: the server answers a ``campaign_id`` listing 547 in full, so counting stages or numbering the queue from it is sound. 548 Wraps ``GET /eval/runs?campaign_id=``.""" 549 resp = self._http.request("GET", "/eval/runs", params={"campaign_id": campaign_id}) 550 _raise_for_status(resp) 551 return MyEvalRunPage.model_validate(resp.json())
Every atomic task of one campaign, in dispatch order (#2585).
⚠️ Whole, never a page: the server answers a campaign_id listing
in full, so counting stages or numbering the queue from it is sound.
Wraps GET /eval/runs?campaign_id=.
553 def run_events(self, run_id: str) -> EvalRunEventList: 554 """One atomic task's event log, in time order (#2585). 555 Wraps ``GET /eval/runs/{run_id}/events``.""" 556 resp = self._http.request("GET", f"/eval/runs/{run_id}/events") 557 _raise_for_status(resp) 558 return EvalRunEventList.model_validate(resp.json())
One atomic task's event log, in time order (#2585).
Wraps GET /eval/runs/{run_id}/events.
560 def cancel_run( 561 self, run_id: str, *, mode: CancelMode = "drain", requeue: bool = False 562 ) -> EvalQueueChange: 563 """Cancel one atomic task (#2585): queued → cancelled now; started → 564 stopped (``mode="now"``) or left to finish (``"drain"``). A task 565 being graded answers ``409 eval_run_grading``. 566 Wraps ``POST /eval/runs/{run_id}/cancel``.""" 567 resp = self._http.request( 568 "POST", 569 f"/eval/runs/{run_id}/cancel", 570 json=EvalCancelRequest(mode=mode, requeue=requeue).model_dump(), 571 ) 572 _raise_for_status(resp) 573 return EvalQueueChange.model_validate(resp.json())
Cancel one atomic task (#2585): queued → cancelled now; started →
stopped (mode="now") or left to finish ("drain"). A task
being graded answers 409 eval_run_grading.
Wraps POST /eval/runs/{run_id}/cancel.
575 def campaign_summary(self, campaign_id: str) -> EvalCampaignAtomicSummary: 576 """A campaign's tasks counted per stage, and whether it is 577 ``incomplete`` (off the official board) (#2585). 578 Wraps ``GET /eval/campaigns/{campaign_id}/summary``.""" 579 resp = self._http.request("GET", f"/eval/campaigns/{campaign_id}/summary") 580 _raise_for_status(resp) 581 return EvalCampaignAtomicSummary.model_validate(resp.json())
A campaign's tasks counted per stage, and whether it is
incomplete (off the official board) (#2585).
Wraps GET /eval/campaigns/{campaign_id}/summary.
583 def cancel_campaign(self, campaign_id: str, *, mode: CancelMode = "drain") -> EvalQueueChange: 584 """Cancel a campaign's queued tasks now, its started ones per 585 ``mode`` (#2585). Wraps ``POST /eval/campaigns/{campaign_id}/cancel``.""" 586 resp = self._http.request( 587 "POST", 588 f"/eval/campaigns/{campaign_id}/cancel", 589 json=EvalCancelRequest(mode=mode).model_dump(), 590 ) 591 _raise_for_status(resp) 592 return EvalQueueChange.model_validate(resp.json())
Cancel a campaign's queued tasks now, its started ones per
mode (#2585). Wraps POST /eval/campaigns/{campaign_id}/cancel.
594 def batch_limits(self) -> EvalBatchLimits: 595 """The most cells one batch may hold, and the ranges your runs may 596 hold at once across every campaign of yours (#2677). 597 Wraps ``GET /eval/batches/limits``.""" 598 resp = self._http.request("GET", "/eval/batches/limits") 599 _raise_for_status(resp) 600 return EvalBatchLimits.model_validate(resp.json())
The most cells one batch may hold, and the ranges your runs may
hold at once across every campaign of yours (#2677).
Wraps GET /eval/batches/limits.
602 def enter_batch( 603 self, 604 *, 605 competition_id: str, 606 harness_ids: Sequence[str], 607 models: Sequence[EvalBatchModel], 608 num_samples: int = 1, 609 scheduling_cost_limit_usd: float = 0.0, 610 timeout_s: int = 0, 611 max_turns: int = 0, 612 challenge_ids: Sequence[str] = (), 613 ) -> EvalBatchState: 614 """Enter each of your agents with each model on one target: one 615 campaign per cell, grouped under a batch (#2677). 616 617 Every cell is checked as :meth:`enter` checks one entry before any is 618 created, so a refusal carries that cell's own ``code`` and nothing 619 is created. More cells than the deployment allows is ``409 620 eval_batch_too_large``. ``scheduling_cost_limit_usd`` is per cell. 621 Wraps ``POST /eval/batches``.""" 622 body = EvalBatchRequest( 623 competition_id=competition_id, 624 harness_ids=list(harness_ids), 625 models=list(models), 626 num_samples=num_samples, 627 scheduling_cost_limit_usd=scheduling_cost_limit_usd, 628 timeout_s=timeout_s, 629 max_turns=max_turns, 630 challenge_ids=list(challenge_ids), 631 ) 632 resp = self._http.request("POST", "/eval/batches", json=body.model_dump()) 633 _raise_for_status(resp) 634 return EvalBatchState.model_validate(resp.json())
Enter each of your agents with each model on one target: one campaign per cell, grouped under a batch (#2677).
Every cell is checked as enter() checks one entry before any is
created, so a refusal carries that cell's own code and nothing
is created. More cells than the deployment allows is 409
eval_batch_too_large. scheduling_cost_limit_usd is per cell.
Wraps POST /eval/batches.
636 def batch(self, batch_id: str) -> EvalBatchState: 637 """One of your batches: its cells' campaigns (#2677). 638 Wraps ``GET /eval/batches/{batch_id}``.""" 639 resp = self._http.request("GET", f"/eval/batches/{batch_id}") 640 _raise_for_status(resp) 641 return EvalBatchState.model_validate(resp.json())
One of your batches: its cells' campaigns (#2677).
Wraps GET /eval/batches/{batch_id}.
643 def cancel_batch(self, batch_id: str, *, mode: CancelMode = "drain") -> EvalBatchCancelResult: 644 """Cancel every cell of a batch still running, each as 645 :meth:`cancel_campaign` would (#2677). 646 Wraps ``POST /eval/batches/{batch_id}/cancel``.""" 647 resp = self._http.request( 648 "POST", 649 f"/eval/batches/{batch_id}/cancel", 650 json=EvalCancelRequest(mode=mode).model_dump(), 651 ) 652 _raise_for_status(resp) 653 return EvalBatchCancelResult.model_validate(resp.json())
Cancel every cell of a batch still running, each as
cancel_campaign() would (#2677).
Wraps POST /eval/batches/{batch_id}/cancel.
655 def rerun_unfinished(self, campaign_id: str) -> EvalQueueChange: 656 """Queue one fresh task per cancelled task nothing has filled (#2585). 657 Wraps ``POST /eval/campaigns/{campaign_id}/rerun-unfinished``.""" 658 resp = self._http.request("POST", f"/eval/campaigns/{campaign_id}/rerun-unfinished") 659 _raise_for_status(resp) 660 return EvalQueueChange.model_validate(resp.json())
Queue one fresh task per cancelled task nothing has filled (#2585).
Wraps POST /eval/campaigns/{campaign_id}/rerun-unfinished.
662 def run_llm_calls(self, run_id: str, *, offset: int = 0, limit: int = 50) -> LlmCallPage: 663 """Every model request one of your runs made, newest first. 664 665 Metadata only — token split, latency, queue wait, the estimated 666 cost. **Scoped to the run's team**, so a captain can see who is 667 burning the allowance without reading anybody's prompts, and to 668 ``principal == "agent"``, so a challenge container's own 669 conversation is never on this route. 670 Wraps ``GET /eval/runs/{run_id}/llm-calls``.""" 671 resp = self._http.request( 672 "GET", 673 f"/eval/runs/{run_id}/llm-calls", 674 params={"offset": offset, "limit": limit}, 675 ) 676 _raise_for_status(resp) 677 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.
679 def run_agent_log(self, run_id: str, *, after: int = 0) -> AgentLogPage: 680 """One of your runs' own stdout and stderr, live, after a cursor (#2401). 681 682 **The run author's only** (``403 agent_log_owner_only`` for a 683 team-mate). Pass the last ``seq`` you hold as *after*; a jump in 684 ``seq`` is lines that are no longer held. Scrubbed on the platform 685 before it was held. 686 Wraps ``GET /eval/runs/{run_id}/agent-log``.""" 687 resp = self._http.request("GET", f"/eval/runs/{run_id}/agent-log", params={"after": after}) 688 _raise_for_status(resp) 689 return AgentLogPage.model_validate(resp.json())
One of your runs' own stdout and stderr, live, after a cursor (#2401).
The run author's only (403 agent_log_owner_only for a
team-mate). Pass the last seq you hold as after; a jump in
seq is lines that are no longer held. Scrubbed on the platform
before it was held.
Wraps GET /eval/runs/{run_id}/agent-log.
691 def run_llm_live(self, run_id: str, *, after: int = 0) -> LlmLivePage: 692 """What one of your runs' model is sending back, live, after a cursor 693 (#2401 slice 2). 694 695 The body's readers only — the author, or a platform admin 696 (``403 llm_live_owner_only`` for a team-mate). Pass the last ``seq`` 697 you hold as *after*; a jump in ``seq`` is deltas no longer held. 698 Wraps ``GET /eval/runs/{run_id}/llm-live``.""" 699 resp = self._http.request("GET", f"/eval/runs/{run_id}/llm-live", params={"after": after}) 700 _raise_for_status(resp) 701 return LlmLivePage.model_validate(resp.json())
What one of your runs' model is sending back, live, after a cursor (#2401 slice 2).
The body's readers only — the author, or a platform admin
(403 llm_live_owner_only for a team-mate). Pass the last seq
you hold as after; a jump in seq is deltas no longer held.
Wraps GET /eval/runs/{run_id}/llm-live.
703 def run_llm_call(self, run_id: str, call_id: str) -> LlmCallBody: 704 """The conversation itself — **the run author's only**. 705 706 ⚠️ Narrower than :meth:`run_llm_calls` on purpose: a team-mate 707 who can see the row is answered ``403 708 transcript_body_owner_only`` here, since an agent's prompt is its 709 author's work and ``HarnessStanding`` ranks the two of them 710 against each other. 711 712 ⚠️ ``available=False`` is an ordinary answer, not an error — 713 retention reclaims bodies and leaves the row behind. 714 Wraps ``GET /eval/runs/{run_id}/llm-calls/{call_id}``.""" 715 resp = self._http.request("GET", f"/eval/runs/{run_id}/llm-calls/{call_id}") 716 _raise_for_status(resp) 717 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}.
719 def run_artifact(self, run_id: str) -> bytes: 720 """The files one of your runs left behind, as ``.tar.gz`` bytes. 721 722 Its working directory and, when the harness is Claude Code, its 723 session — collected from the container before it was removed. 724 The writing half, where :meth:`run_llm_call` is the deciding one 725 and :meth:`run_traffic` the doing one. 726 727 ⚠️ **The run author's only**, the narrowest of the three: a 728 team-mate who can see the row is answered ``403 729 run_artifact_owner_only``. An archive carries whatever the agent 730 wrote down, which for a solved challenge includes the flag. 731 732 ⚠️ **Bytes, not a model, and no URL.** The read goes through the 733 platform rather than handing out a bucket address, so there is 734 nothing to return but the archive itself. ⚠️ A run that kept no 735 files answers ``404 run_artifact_not_found`` rather than empty 736 bytes — which of the several reasons it is (the agent wrote 737 nothing, collection failed, retention took them) is on the run 738 row's ``artifact`` field, so the caller never has to guess from 739 an absence. 740 741 Wraps ``GET /eval/runs/{run_id}/artifact``.""" 742 resp = self._http.request("GET", f"/eval/runs/{run_id}/artifact") 743 _raise_for_status(resp) 744 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.
746 def run_traffic(self, run_id: str, *, cursor: str = "", limit: int = 0) -> InstanceTrafficPage: 747 """Every request one of your runs sent at the challenge. 748 749 The doing half, where :meth:`run_llm_call` is the deciding half. 750 **The run author's only** — a team-mate who can see the row is 751 answered ``403 run_traffic_owner_only``, the same split the 752 conversation body makes, because the payloads an agent wrote are 753 its author's work. 754 755 ⚠️ Carry ``cursor`` forward from the previous page and you get 756 only what arrived since; pass an empty one and you get the whole 757 capture again. Every failure — an unreachable node, a run whose 758 estate the reaper has taken — answers 200 with your own cursor 759 and no flows, so a blip never re-delivers the capture. Which of 760 those it was, ``MyEvalRunRow.traffic`` says. 761 762 ⚠️ Redacted: this instance's minted answers are scrubbed out. 763 Wraps ``GET /eval/runs/{run_id}/traffic``.""" 764 resp = self._http.request( 765 "GET", 766 f"/eval/runs/{run_id}/traffic", 767 params={"cursor": cursor, "limit": limit}, 768 ) 769 _raise_for_status(resp) 770 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.
772 def run_writeup(self, run_id: str) -> RunWriteup: 773 """What one of your runs said it did, in its own words. 774 775 ``CTFY-0001`` §3 invites an optional ``writeup`` in the result 776 file. The saying half, where :meth:`run_llm_call` is the 777 deciding one, :meth:`run_traffic` the doing one and 778 :meth:`run_artifact` the writing one. 779 780 ⚠️ **Telemetry, never a grading path.** Nothing in the result 781 file is scored — the run's own ``POST /submissions`` is — so a 782 write-up cannot earn a point and is not read as an answer. 783 784 ⚠️ **The run author's only**: a team-mate who can see the row is 785 answered ``403 run_writeup_owner_only``, the same split the 786 other three bodies make. ⚠️ A run whose image wrote none answers 787 ``404 run_writeup_not_found`` rather than empty prose, and the 788 run row's ``writeup`` field already said which — so the caller 789 never has to reach here to find out. 790 791 Wraps ``GET /eval/runs/{run_id}/writeup``.""" 792 resp = self._http.request("GET", f"/eval/runs/{run_id}/writeup") 793 _raise_for_status(resp) 794 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.
796 def set_run_wall(self, run_id: str, *, published: bool) -> MyEvalRunRow: 797 """Put this run's agent on the event's projector wall, or take it off. 798 799 ``GET /competitions/{id}/agent-actions`` draws one scrubbed line 800 per agent saying what it is doing. The scrub answers what the 801 bytes make unsafe; this answers the part it cannot — the 802 conversation belongs to whoever ran it, so the wall shows an 803 entrant only once their author has said yes. 804 805 ⚠️ **The switch is on the *entrant*, not on this run.** The 806 transcript index groups by team and an agent's team is one per 807 ``(owner, harness, model, competition)``, so every run of the 808 same agent at the same event shares one answer — which is what 809 "this agent, at this event" means. The row that comes back is 810 the run's, with ``wall_published`` re-read from the entrant. 811 812 ⚠️ **The author's only**: a team-mate who can see the row is 813 answered ``403 run_wall_not_yours``, the same split 814 :meth:`run_writeup` makes, and for the same reason — the line 815 is read out of a conversation they already meet a 403 on. 816 817 ⚠️ A run with no agent entrant answers ``409`` naming which 818 kind of nothing it is; ``MyEvalRunRow.wall`` carries the same 819 code, so a caller never has to press to find out. 820 821 Wraps ``POST /eval/runs/{run_id}/wall``.""" 822 resp = self._http.request( 823 "POST", 824 f"/eval/runs/{run_id}/wall", 825 json={"published": published}, 826 ) 827 _raise_for_status(resp) 828 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.
830 def set_run_transcript(self, run_id: str, *, published: bool) -> MyEvalRunRow: 831 """Publish this run's agent's **full conversation**, or take it back. 832 833 A **second, stronger** consent than :meth:`set_run_wall`. The wall 834 shows one scrubbed line of what the agent is doing; this opens the 835 whole conversation — every turn the agent wrote — to spectators at 836 ``GET /competitions/{id}/awd/transcript/{call_id}``. The projection 837 still emits only the agent's own scrubbed turns (never the system 838 prompt, never a tool result), but the decision to publish a 839 conversation at all is the author's alone and separate from the 840 wall's. 841 842 ⚠️ **The switch is on the *entrant***, exactly as the wall's — 843 one answer per ``(owner, harness, model, competition)``. 844 845 ⚠️ **Refused for an anonymous entrant** (``403 846 run_transcript_anonymous``): a conversation de-anonymises whoever 847 wrote it, so the withheld-identity promise wins over the consent. 848 The author's-only and no-agent refusals mirror :meth:`set_run_wall`. 849 850 Wraps ``POST /eval/runs/{run_id}/transcript``.""" 851 resp = self._http.request( 852 "POST", 853 f"/eval/runs/{run_id}/transcript", 854 json={"published": published}, 855 ) 856 _raise_for_status(resp) 857 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.
859 def registry_push_target(self) -> RegistryPushTarget: 860 """Where this account pushes an agent image, if anywhere. 861 862 ⚠️ Read ``enabled``, not a non-empty ``repository_prefix``. The 863 server composes the verdict from four facts — a host, a token 864 service, and both halves of the signing pair — and a deployment 865 with a hostname but no signing pair refuses every login. 866 """ 867 resp = self._http.request("GET", "/eval/registry") 868 _raise_for_status(resp) 869 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.
884 def check_harness(self, harness_id: str) -> HarnessState: 885 """Ask the platform to look at one of your agent images. 886 887 Returns the row with the verdict on it: ``conformance_status`` 888 (``""`` never checked / ``passed`` / ``failed`` / ``error``), 889 the digest the verdict describes, and a finding per thing worth 890 saying. 891 892 ⚠️ **Evidence, never a gate.** Nothing here changes 893 ``review_status``, and a ``failed`` verdict does not stop an 894 operator approving the image — the check exists so somebody can 895 see *why* a dispatch would go wrong, before it does. 896 897 ⚠️ **``error`` is not ``failed``, and it arrives as a 200.** A 898 registry that would not answer is not a fact about the image; 899 the message is on ``conformance_error``. 900 901 ⚠️ Addressed by harness id, like :meth:`harness_tags` and for 902 the same reason: the image is read off your own registry row, so 903 this cannot be pointed at an arbitrary host. 904 """ 905 resp = self._http.request("POST", f"/eval/harnesses/{harness_id}/conformance") 906 _raise_for_status(resp) 907 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.
909 def probe_harness(self, harness_id: str) -> MyHarness: 910 """Put one of your agent images in line to be run once, to check it. 911 912 Where :meth:`check_harness` reads a manifest, this runs the 913 container on a worker that may hold a user's code, under the 914 isolation a real dispatch uses, with a briefing marked ``dry_run``, 915 and asks the one question a manifest cannot answer: **did it write 916 a result file at the address it was given.** 917 918 ⚠️ **Returns the row in line, not a verdict** (#2608): 919 ``probe_status`` is ``queued`` (with ``probe_reason`` and 920 ``probe_position``) or ``running``; poll :meth:`harnesses` for 921 ``passed`` / ``failed`` (with a reason) / ``error``. 922 923 ⚠️ **No credential travels with the probe** but the row's own 924 scoped pull ticket, or the owner's stored registry login for the 925 image's own host (#3475), and no platform URL is named. See §3.1 of 926 ``docs/standards/CTFY-0001.md``. 927 """ 928 resp = self._http.request("POST", f"/eval/harnesses/{harness_id}/probe") 929 _raise_for_status(resp) 930 return MyHarness.model_validate(resp.json())
Put one of your agent images in line to be run once, to check it.
Where check_harness() reads a manifest, this runs the
container on a worker that may hold a user's code, under the
isolation a real dispatch uses, with 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 the row in line, not a verdict (#2608):
probe_status is queued (with probe_reason and
probe_position) or running; poll harnesses() for
passed / failed (with a reason) / error.
⚠️ No credential travels with the probe but the row's own
scoped pull ticket, or the owner's stored registry login for the
image's own host (#3475), and no platform URL is named. See §3.1 of
docs/standards/CTFY-0001.md.
932 def cancel_probe(self, harness_id: str) -> MyHarness: 933 """Take a queued dry run out of line (409 once it has started).""" 934 resp = self._http.request("DELETE", f"/eval/harnesses/{harness_id}/probe") 935 _raise_for_status(resp) 936 return MyHarness.model_validate(resp.json())
Take a queued dry run out of line (409 once it has started).
938 def set_registry_credential(self, harness_id: str, *, username: str, secret: str) -> MyHarness: 939 """Store the login your agent's image is pulled with from a private registry (#3475). 940 941 The registry is the one the agent's image names; a host the platform 942 does not pull from is refused (400 ``harness_registry_not_allowed``). 943 The answer shows ``registry_credential`` (who and when), never the 944 token. Run the check and dry run again afterwards. 945 """ 946 resp = self._http.request( 947 "PUT", 948 f"/eval/harnesses/{harness_id}/registry-credential", 949 json={"username": username, "secret": secret}, 950 ) 951 _raise_for_status(resp) 952 return MyHarness.model_validate(resp.json())
Store the login your agent's image is pulled with from a private registry (#3475).
The registry is the one the agent's image names; a host the platform
does not pull from is refused (400 harness_registry_not_allowed).
The answer shows registry_credential (who and when), never the
token. Run the check and dry run again afterwards.
954 def clear_registry_credential(self, harness_id: str) -> MyHarness: 955 """Remove your agent's registry login; its image is then pulled anonymously.""" 956 resp = self._http.request("DELETE", f"/eval/harnesses/{harness_id}/registry-credential") 957 _raise_for_status(resp) 958 return MyHarness.model_validate(resp.json())
Remove your agent's registry login; its image is then pulled anonymously.
961class RegistryResource: 962 """The container registry's token endpoint. 963 964 ⚠️ **Not a method anybody calls from Python.** ``docker`` follows 965 the registry's ``WWW-Authenticate`` realm here on its own, with HTTP 966 Basic; this exists so the SDK covers the surface and so a test can 967 drive the exchange the way docker does. The credential goes in the 968 *password* half — that is where ``docker login -p`` puts it. 969 """ 970 971 def __init__(self, http: BaseHttpClient) -> None: 972 self._http = http 973 974 def token(self, *, service: str, scope: str | Sequence[str] = "") -> dict[str, Any]: 975 # ⚠️ A sequence is sent as a repeated ``scope`` parameter, because 976 # that is what docker does for a cross-repository blob mount — 977 # ``push`` on the destination and ``pull`` on the source, in one 978 # exchange. This method's whole stated purpose is that a test can 979 # drive the exchange the way docker does, and it could not. 980 params: list[tuple[str, str]] = [("service", service)] 981 if isinstance(scope, str): 982 if scope: 983 params.append(("scope", scope)) 984 else: 985 params.extend(("scope", s) for s in scope if s) 986 resp = self._http.request("GET", "/registry/token", params=params) 987 _raise_for_status(resp) 988 body: dict[str, Any] = resp.json() 989 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.
974 def token(self, *, service: str, scope: str | Sequence[str] = "") -> dict[str, Any]: 975 # ⚠️ A sequence is sent as a repeated ``scope`` parameter, because 976 # that is what docker does for a cross-repository blob mount — 977 # ``push`` on the destination and ``pull`` on the source, in one 978 # exchange. This method's whole stated purpose is that a test can 979 # drive the exchange the way docker does, and it could not. 980 params: list[tuple[str, str]] = [("service", service)] 981 if isinstance(scope, str): 982 if scope: 983 params.append(("scope", scope)) 984 else: 985 params.extend(("scope", s) for s in scope if s) 986 resp = self._http.request("GET", "/registry/token", params=params) 987 _raise_for_status(resp) 988 body: dict[str, Any] = resp.json() 989 return body