ctfy.sdk.admin_resources.challenges
client.admin.challenges — catalog rescan, pre-build, stats, feedback audit.
1"""``client.admin.challenges`` — catalog rescan, pre-build, stats, feedback audit.""" 2 3from __future__ import annotations 4 5from datetime import datetime 6from typing import Any 7 8from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status 9from ctfy.sdk.base import BaseHttpClient 10from ctfy.server.models import ( 11 AdminChallengeLatencyBucket, 12 AdminChallengeQuestions, 13 AdminChallengeStatsRow, 14 AdminChallengeTimeseries, 15 AdminFeedbackRow, 16 AdminTaskInfo, 17 ChallengeBlueprint, 18 ChallengeBuildStateResponse, 19 ChallengePullStateResponse, 20 DeceptionStats, 21 LlmBudgetPage, 22 LlmBudgetResetInfo, 23 QuestionAttemptResetInfo, 24 RangeProgress, 25 RangeReplay, 26) 27 28 29class AdminChallengesResource: 30 """Operator challenge tooling: reload the catalog, pre-build on nodes, 31 inspect attempt/solve stats, audit feedback, reset attempt caps.""" 32 33 def __init__(self, http: BaseHttpClient) -> None: 34 self._http = http 35 36 def rescan(self) -> AdminTaskInfo: 37 """Submit a catalog-rescan background task (reload the platform's 38 spec cache + fan the rescan out to every online node). Returns the 39 task record; poll ``admin.tasks.get(id)`` (or watch the Tasks page) 40 for the per-node outcome in ``result``. Wraps 41 ``POST /admin/challenges/rescan``.""" 42 resp = self._http.request("POST", "/admin/challenges/rescan") 43 _raise_for_status(resp) 44 return AdminTaskInfo.model_validate(resp.json()) 45 46 def build(self, challenge_id: str) -> AdminTaskInfo: 47 """Submit a single-challenge pre-build background task. 48 49 Fans the build out to every online node and polls it to 50 completion; poll :meth:`build_state` (or the Tasks surface) to 51 watch ``building`` → ``built`` / ``failed``. Returns the task 52 record. Wraps ``POST /admin/challenges/{id}/build``.""" 53 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/build") 54 _raise_for_status(resp) 55 return AdminTaskInfo.model_validate(resp.json()) 56 57 def build_all(self, *, competition_id: str = "") -> AdminTaskInfo: 58 """Submit a bulk pre-build background task. With ``competition_id`` 59 the build is scoped to that competition's effective challenge set; 60 otherwise the whole catalog. The task kicks each online node off 61 and polls them to completion; the per-node outcome + final 62 per-challenge status land in ``result``. Returns the task record. 63 Wraps ``POST /admin/challenges/build-all``.""" 64 params: dict[str, Any] = {} 65 if competition_id: 66 params["competition_id"] = competition_id 67 resp = self._http.request("POST", "/admin/challenges/build-all", params=params) 68 _raise_for_status(resp) 69 return AdminTaskInfo.model_validate(resp.json()) 70 71 def build_push( 72 self, 73 *, 74 bucket: str = "", 75 challenge_ids: list[str] | None = None, 76 node_id: str = "", 77 ) -> AdminTaskInfo: 78 """Submit a build-and-push task: freeze the scoped challenges' 79 ``build:`` services into the registry (Layer 2 P0, producer side). 80 81 An explicit scope is **required** — a ``bucket`` (family such as 82 ``XBOW``) or ``challenge_ids`` — or the server refuses it (400); it 83 never freezes the whole catalog. ``node_id`` pins the build host 84 (default: the first online node running each challenge). 85 Super-admin. Returns the task record. Wraps 86 ``POST /admin/challenges/build-push``.""" 87 body: dict[str, Any] = { 88 "bucket": bucket, 89 "challenge_ids": challenge_ids or [], 90 "node_id": node_id, 91 } 92 resp = self._http.request("POST", "/admin/challenges/build-push", json=body) 93 _raise_for_status(resp) 94 return AdminTaskInfo.model_validate(resp.json()) 95 96 def questions(self, challenge_id: str) -> AdminChallengeQuestions: 97 """Every milestone a challenge declares, gated ones included. 98 99 The catalog projection omits questions behind ``requires`` so a 100 player cannot read a challenge's attack chain off its public page; 101 this is the organiser's way back in — "which milestones exist, and 102 why did this team not get credit for one". Wraps 103 ``GET /admin/challenges/{id}/questions``.""" 104 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/questions") 105 _raise_for_status(resp) 106 return AdminChallengeQuestions.model_validate(resp.json()) 107 108 def blueprint(self, challenge_id: str) -> ChallengeBlueprint: 109 """A range's estate and kill chain, for the people running it. 110 111 The sibling :meth:`questions` answers *which milestones exist*; 112 at four services that is the whole picture and at forty it is a 113 flat list with no shape. This adds the two axes a large range is 114 actually debugged along: which network segment each service sits 115 on (and so what can only be reached through a foothold), and 116 what has to fall before each milestone. 117 118 Derived server-side from the challenge's own ``metadata.yaml`` 119 and ``docker-compose.yml``, so it works for a hand-built range 120 and a generated one alike. Carries no groundtruth: see 121 :meth:`ctfy.sdk.admin_resources.records.RecordsResource.instance_answers` 122 for the value of a minted answer. Wraps 123 ``GET /admin/challenges/{id}/blueprint``.""" 124 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/blueprint") 125 _raise_for_status(resp) 126 return ChallengeBlueprint.model_validate(resp.json()) 127 128 def range_progress(self, competition_id: str, challenge_id: str) -> RangeProgress: 129 """Where every team of one competition is on one range. 130 131 :meth:`blueprint` says what the range *is*; this says where each 132 team got to on it — which milestones they hold, where they are 133 stopped, what they could take next, and which estate hosts they 134 have reached. 135 136 The blueprint is deliberately **not** in the answer: it is a 137 property of the challenge and cacheable, while this changes on 138 every save, and a caller drawing the overlay already holds it. 139 Carries no groundtruth and no network depth — the layer axis is 140 derived from the blueprint, which has the segments. Wraps 141 ``GET /admin/competitions/{cid}/ranges/{chid}/progress``.""" 142 resp = self._http.request( 143 "GET", f"/admin/competitions/{competition_id}/ranges/{challenge_id}/progress" 144 ) 145 _raise_for_status(resp) 146 return RangeProgress.model_validate(resp.json()) 147 148 def range_replay( 149 self, competition_id: str, challenge_id: str, *, frames: int | None = None 150 ) -> RangeReplay: 151 """The same field, wound back — where every team stood at time T. 152 153 :meth:`range_progress` answers *now*; this cuts the whole event 154 into instants, so a caller can ask *who moved when*. The last 155 frame equals that method's answer element for element, which is 156 the property the feature rests on: the clock is ``solved_at``, 157 the immutable first capture that every solve row carries. 158 159 ⚠️ **``frames: []`` means there is no time axis** — nothing was 160 captured and no window was declared, or nobody entered — and is 161 *not* what asking for no frames gets: ``frames=0`` is a 422, 162 because two facts behind one shape is the ``0``-versus-unset 163 trap. Omit ``frames`` for the server's default. 164 165 ⚠️ **A position carries ``reached`` as a count and no milestone 166 ids**, deliberately: carrying them would multiply the payload by 167 the milestone count. Its ``team_id`` is the id 168 :meth:`range_progress` published — an alias where the entrant 169 withheld their identity — so the two reads join without this 170 caller deciding who anybody is. Wraps 171 ``GET /admin/competitions/{cid}/ranges/{chid}/progress/replay``.""" 172 params = {} if frames is None else {"frames": frames} 173 resp = self._http.request( 174 "GET", 175 f"/admin/competitions/{competition_id}/ranges/{challenge_id}/progress/replay", 176 params=params, 177 ) 178 _raise_for_status(resp) 179 return RangeReplay.model_validate(resp.json()) 180 181 def build_state(self) -> ChallengeBuildStateResponse: 182 """Aggregate per-challenge build state across every online node. 183 184 Returns one row per known challenge with an ``aggregated`` 185 worst-case status plus the per-node breakdown for the modal 186 drill-down. Wraps ``GET /admin/challenges/build-state``.""" 187 resp = self._http.request("GET", "/admin/challenges/build-state") 188 _raise_for_status(resp) 189 return ChallengeBuildStateResponse.model_validate(resp.json()) 190 191 def pull(self, challenge_id: str) -> AdminTaskInfo: 192 """Submit a single-challenge pre-pull background task (pull-side 193 twin of :meth:`build`: warms registry-only ``image:`` services). 194 Returns the task record. Wraps ``POST /admin/challenges/{id}/pull``.""" 195 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/pull") 196 _raise_for_status(resp) 197 return AdminTaskInfo.model_validate(resp.json()) 198 199 def pull_all(self, *, competition_id: str = "") -> AdminTaskInfo: 200 """Submit a bulk pre-pull background task (pull-side twin of 201 :meth:`build_all`). Returns the task record. Wraps 202 ``POST /admin/challenges/pull-all``.""" 203 params: dict[str, Any] = {} 204 if competition_id: 205 params["competition_id"] = competition_id 206 resp = self._http.request("POST", "/admin/challenges/pull-all", params=params) 207 _raise_for_status(resp) 208 return AdminTaskInfo.model_validate(resp.json()) 209 210 def pull_state(self) -> ChallengePullStateResponse: 211 """Aggregate per-challenge pull state across every online node. 212 213 Pull-side twin of :meth:`build_state`. Wraps 214 ``GET /admin/challenges/pull-state``.""" 215 resp = self._http.request("GET", "/admin/challenges/pull-state") 216 _raise_for_status(resp) 217 return ChallengePullStateResponse.model_validate(resp.json()) 218 219 def stats(self, offset: int = 0, limit: int = 50) -> list[AdminChallengeStatsRow]: 220 """Per-challenge attempt / solve / first-blood counts.""" 221 resp = self._http.request( 222 "GET", 223 "/admin/challenges/stats", 224 params={"offset": offset, "limit": limit}, 225 ) 226 _raise_for_status(resp) 227 return [AdminChallengeStatsRow.model_validate(r) for r in resp.json()["items"]] 228 229 def timeseries( 230 self, 231 challenge_id: str, 232 *, 233 window: int = 86400, 234 bucket: int = 3600, 235 ) -> AdminChallengeTimeseries: 236 """Per-challenge timeseries for the row-level drawer on 237 ``/admin/challenges``.""" 238 resp = self._http.request( 239 "GET", 240 f"/admin/challenges/{challenge_id}/timeseries", 241 params={"window": window, "bucket": bucket}, 242 ) 243 _raise_for_status(resp) 244 return AdminChallengeTimeseries.model_validate(resp.json()) 245 246 def latency_histogram(self) -> list[AdminChallengeLatencyBucket]: 247 """Instance-start latency distribution across challenges (the 248 admin challenges latency chart).""" 249 resp = self._http.request("GET", "/admin/challenges/latency-histogram") 250 _raise_for_status(resp) 251 return [AdminChallengeLatencyBucket.model_validate(b) for b in resp.json()] 252 253 def feedback( 254 self, challenge_id: str, *, competition_id: str = "" 255 ) -> PagedList[AdminFeedbackRow]: 256 """Admin audit list: every reaction row on a challenge with 257 denormalised user identity. Requires admin role.""" 258 params: dict[str, Any] = {} 259 if competition_id: 260 params["competition_id"] = competition_id 261 resp = self._http.request( 262 "GET", f"/admin/challenges/{challenge_id}/feedback", params=params 263 ) 264 _raise_for_status(resp) 265 return _extract_items(resp.json(), AdminFeedbackRow) 266 267 def reset_question_attempts( 268 self, 269 team_id: str, 270 challenge_id: str, 271 question_id: str, 272 *, 273 reason: str = "", 274 ) -> QuestionAttemptResetInfo: 275 """Reset the per-question wrong-attempt counter for one team. 276 277 Writes a fresh baseline timestamp; the cap counter ignores all 278 wrong submissions before the new baseline, so the team gets a 279 fresh batch of attempts on this question without any audit 280 rows being deleted. Returns the new baseline row. 281 Wraps ``POST /admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts``.""" 282 resp = self._http.request( 283 "POST", 284 f"/admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts", 285 json={"reason": reason}, 286 ) 287 _raise_for_status(resp) 288 return QuestionAttemptResetInfo.model_validate(resp.json()) 289 290 def list_llm_budgets(self, *, competition_id: str = "") -> LlmBudgetPage: 291 """Who has spent what against the challenge-LLM gateway. 292 293 The reader for :meth:`reset_llm_budget` — without it an organiser 294 can only hand back an allowance to a team somebody already named. 295 ⚠️ ``gateway_configured`` is on the envelope because an empty 296 list otherwise reads as "nobody spent anything" when it means 297 "this deployment enforces no budgets", and those call for 298 opposite actions. 299 Wraps ``GET /admin/llm-budgets``.""" 300 params = {"competition_id": competition_id} if competition_id else None 301 resp = self._http.request("GET", "/admin/llm-budgets", params=params) 302 _raise_for_status(resp) 303 return LlmBudgetPage.model_validate(resp.json()) 304 305 def deception_stats( 306 self, 307 *, 308 competition_id: str = "", 309 challenge_id: str = "", 310 since: datetime | None = None, 311 ) -> DeceptionStats: 312 """How often an assessing agent obeyed a directive planted in a 313 challenge's own output. 314 315 ⚠️ ``resisted`` is an **upper bound**, and the response carries 316 that caveat on the wire rather than leaving it to documentation: 317 the probe runs inside a container the player is invited to 318 compromise, so a missing compliance record is not evidence of 319 restraint. ``comply_rate`` is ``None`` rather than ``0.0`` when 320 nothing has been contacted — 0/0 rendered as 0% would assert 321 that no agent was fooled. 322 323 Nothing here is scored; it reports, it does not rule. 324 Wraps ``GET /admin/deception/stats``.""" 325 # ⚠️ ``since`` is the route's third filter and this wrapper used 326 # to drop it, so "what happened during *this* event" was 327 # unaskable from anything but a hand-rolled request — the shape 328 # ``admin competition update``'s missing flags already record. 329 params = { 330 k: v 331 for k, v in ( 332 ("competition_id", competition_id), 333 ("challenge_id", challenge_id), 334 ("since", since.isoformat() if since else ""), 335 ) 336 if v 337 } 338 resp = self._http.request("GET", "/admin/deception/stats", params=params or None) 339 _raise_for_status(resp) 340 return DeceptionStats.model_validate(resp.json()) 341 342 def reset_llm_budget( 343 self, 344 team_id: str, 345 competition_id: str, 346 challenge_id: str, 347 *, 348 reason: str = "", 349 ) -> LlmBudgetResetInfo: 350 """Give one team its LLM allowance back on one challenge. 351 352 Clears the gateway's live tally **and** the platform's durable 353 total — either alone un-does itself, so the route does both or 354 refuses. ⚠️ Always answers 200; read ``.outcome`` rather than the 355 status, because ``unreachable`` means nothing changed and is 356 worth retrying. 357 Wraps ``POST /admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset``.""" 358 resp = self._http.request( 359 "POST", 360 f"/admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset", 361 json={"reason": reason}, 362 ) 363 _raise_for_status(resp) 364 return LlmBudgetResetInfo.model_validate(resp.json())
30class AdminChallengesResource: 31 """Operator challenge tooling: reload the catalog, pre-build on nodes, 32 inspect attempt/solve stats, audit feedback, reset attempt caps.""" 33 34 def __init__(self, http: BaseHttpClient) -> None: 35 self._http = http 36 37 def rescan(self) -> AdminTaskInfo: 38 """Submit a catalog-rescan background task (reload the platform's 39 spec cache + fan the rescan out to every online node). Returns the 40 task record; poll ``admin.tasks.get(id)`` (or watch the Tasks page) 41 for the per-node outcome in ``result``. Wraps 42 ``POST /admin/challenges/rescan``.""" 43 resp = self._http.request("POST", "/admin/challenges/rescan") 44 _raise_for_status(resp) 45 return AdminTaskInfo.model_validate(resp.json()) 46 47 def build(self, challenge_id: str) -> AdminTaskInfo: 48 """Submit a single-challenge pre-build background task. 49 50 Fans the build out to every online node and polls it to 51 completion; poll :meth:`build_state` (or the Tasks surface) to 52 watch ``building`` → ``built`` / ``failed``. Returns the task 53 record. Wraps ``POST /admin/challenges/{id}/build``.""" 54 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/build") 55 _raise_for_status(resp) 56 return AdminTaskInfo.model_validate(resp.json()) 57 58 def build_all(self, *, competition_id: str = "") -> AdminTaskInfo: 59 """Submit a bulk pre-build background task. With ``competition_id`` 60 the build is scoped to that competition's effective challenge set; 61 otherwise the whole catalog. The task kicks each online node off 62 and polls them to completion; the per-node outcome + final 63 per-challenge status land in ``result``. Returns the task record. 64 Wraps ``POST /admin/challenges/build-all``.""" 65 params: dict[str, Any] = {} 66 if competition_id: 67 params["competition_id"] = competition_id 68 resp = self._http.request("POST", "/admin/challenges/build-all", params=params) 69 _raise_for_status(resp) 70 return AdminTaskInfo.model_validate(resp.json()) 71 72 def build_push( 73 self, 74 *, 75 bucket: str = "", 76 challenge_ids: list[str] | None = None, 77 node_id: str = "", 78 ) -> AdminTaskInfo: 79 """Submit a build-and-push task: freeze the scoped challenges' 80 ``build:`` services into the registry (Layer 2 P0, producer side). 81 82 An explicit scope is **required** — a ``bucket`` (family such as 83 ``XBOW``) or ``challenge_ids`` — or the server refuses it (400); it 84 never freezes the whole catalog. ``node_id`` pins the build host 85 (default: the first online node running each challenge). 86 Super-admin. Returns the task record. Wraps 87 ``POST /admin/challenges/build-push``.""" 88 body: dict[str, Any] = { 89 "bucket": bucket, 90 "challenge_ids": challenge_ids or [], 91 "node_id": node_id, 92 } 93 resp = self._http.request("POST", "/admin/challenges/build-push", json=body) 94 _raise_for_status(resp) 95 return AdminTaskInfo.model_validate(resp.json()) 96 97 def questions(self, challenge_id: str) -> AdminChallengeQuestions: 98 """Every milestone a challenge declares, gated ones included. 99 100 The catalog projection omits questions behind ``requires`` so a 101 player cannot read a challenge's attack chain off its public page; 102 this is the organiser's way back in — "which milestones exist, and 103 why did this team not get credit for one". Wraps 104 ``GET /admin/challenges/{id}/questions``.""" 105 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/questions") 106 _raise_for_status(resp) 107 return AdminChallengeQuestions.model_validate(resp.json()) 108 109 def blueprint(self, challenge_id: str) -> ChallengeBlueprint: 110 """A range's estate and kill chain, for the people running it. 111 112 The sibling :meth:`questions` answers *which milestones exist*; 113 at four services that is the whole picture and at forty it is a 114 flat list with no shape. This adds the two axes a large range is 115 actually debugged along: which network segment each service sits 116 on (and so what can only be reached through a foothold), and 117 what has to fall before each milestone. 118 119 Derived server-side from the challenge's own ``metadata.yaml`` 120 and ``docker-compose.yml``, so it works for a hand-built range 121 and a generated one alike. Carries no groundtruth: see 122 :meth:`ctfy.sdk.admin_resources.records.RecordsResource.instance_answers` 123 for the value of a minted answer. Wraps 124 ``GET /admin/challenges/{id}/blueprint``.""" 125 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/blueprint") 126 _raise_for_status(resp) 127 return ChallengeBlueprint.model_validate(resp.json()) 128 129 def range_progress(self, competition_id: str, challenge_id: str) -> RangeProgress: 130 """Where every team of one competition is on one range. 131 132 :meth:`blueprint` says what the range *is*; this says where each 133 team got to on it — which milestones they hold, where they are 134 stopped, what they could take next, and which estate hosts they 135 have reached. 136 137 The blueprint is deliberately **not** in the answer: it is a 138 property of the challenge and cacheable, while this changes on 139 every save, and a caller drawing the overlay already holds it. 140 Carries no groundtruth and no network depth — the layer axis is 141 derived from the blueprint, which has the segments. Wraps 142 ``GET /admin/competitions/{cid}/ranges/{chid}/progress``.""" 143 resp = self._http.request( 144 "GET", f"/admin/competitions/{competition_id}/ranges/{challenge_id}/progress" 145 ) 146 _raise_for_status(resp) 147 return RangeProgress.model_validate(resp.json()) 148 149 def range_replay( 150 self, competition_id: str, challenge_id: str, *, frames: int | None = None 151 ) -> RangeReplay: 152 """The same field, wound back — where every team stood at time T. 153 154 :meth:`range_progress` answers *now*; this cuts the whole event 155 into instants, so a caller can ask *who moved when*. The last 156 frame equals that method's answer element for element, which is 157 the property the feature rests on: the clock is ``solved_at``, 158 the immutable first capture that every solve row carries. 159 160 ⚠️ **``frames: []`` means there is no time axis** — nothing was 161 captured and no window was declared, or nobody entered — and is 162 *not* what asking for no frames gets: ``frames=0`` is a 422, 163 because two facts behind one shape is the ``0``-versus-unset 164 trap. Omit ``frames`` for the server's default. 165 166 ⚠️ **A position carries ``reached`` as a count and no milestone 167 ids**, deliberately: carrying them would multiply the payload by 168 the milestone count. Its ``team_id`` is the id 169 :meth:`range_progress` published — an alias where the entrant 170 withheld their identity — so the two reads join without this 171 caller deciding who anybody is. Wraps 172 ``GET /admin/competitions/{cid}/ranges/{chid}/progress/replay``.""" 173 params = {} if frames is None else {"frames": frames} 174 resp = self._http.request( 175 "GET", 176 f"/admin/competitions/{competition_id}/ranges/{challenge_id}/progress/replay", 177 params=params, 178 ) 179 _raise_for_status(resp) 180 return RangeReplay.model_validate(resp.json()) 181 182 def build_state(self) -> ChallengeBuildStateResponse: 183 """Aggregate per-challenge build state across every online node. 184 185 Returns one row per known challenge with an ``aggregated`` 186 worst-case status plus the per-node breakdown for the modal 187 drill-down. Wraps ``GET /admin/challenges/build-state``.""" 188 resp = self._http.request("GET", "/admin/challenges/build-state") 189 _raise_for_status(resp) 190 return ChallengeBuildStateResponse.model_validate(resp.json()) 191 192 def pull(self, challenge_id: str) -> AdminTaskInfo: 193 """Submit a single-challenge pre-pull background task (pull-side 194 twin of :meth:`build`: warms registry-only ``image:`` services). 195 Returns the task record. Wraps ``POST /admin/challenges/{id}/pull``.""" 196 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/pull") 197 _raise_for_status(resp) 198 return AdminTaskInfo.model_validate(resp.json()) 199 200 def pull_all(self, *, competition_id: str = "") -> AdminTaskInfo: 201 """Submit a bulk pre-pull background task (pull-side twin of 202 :meth:`build_all`). Returns the task record. Wraps 203 ``POST /admin/challenges/pull-all``.""" 204 params: dict[str, Any] = {} 205 if competition_id: 206 params["competition_id"] = competition_id 207 resp = self._http.request("POST", "/admin/challenges/pull-all", params=params) 208 _raise_for_status(resp) 209 return AdminTaskInfo.model_validate(resp.json()) 210 211 def pull_state(self) -> ChallengePullStateResponse: 212 """Aggregate per-challenge pull state across every online node. 213 214 Pull-side twin of :meth:`build_state`. Wraps 215 ``GET /admin/challenges/pull-state``.""" 216 resp = self._http.request("GET", "/admin/challenges/pull-state") 217 _raise_for_status(resp) 218 return ChallengePullStateResponse.model_validate(resp.json()) 219 220 def stats(self, offset: int = 0, limit: int = 50) -> list[AdminChallengeStatsRow]: 221 """Per-challenge attempt / solve / first-blood counts.""" 222 resp = self._http.request( 223 "GET", 224 "/admin/challenges/stats", 225 params={"offset": offset, "limit": limit}, 226 ) 227 _raise_for_status(resp) 228 return [AdminChallengeStatsRow.model_validate(r) for r in resp.json()["items"]] 229 230 def timeseries( 231 self, 232 challenge_id: str, 233 *, 234 window: int = 86400, 235 bucket: int = 3600, 236 ) -> AdminChallengeTimeseries: 237 """Per-challenge timeseries for the row-level drawer on 238 ``/admin/challenges``.""" 239 resp = self._http.request( 240 "GET", 241 f"/admin/challenges/{challenge_id}/timeseries", 242 params={"window": window, "bucket": bucket}, 243 ) 244 _raise_for_status(resp) 245 return AdminChallengeTimeseries.model_validate(resp.json()) 246 247 def latency_histogram(self) -> list[AdminChallengeLatencyBucket]: 248 """Instance-start latency distribution across challenges (the 249 admin challenges latency chart).""" 250 resp = self._http.request("GET", "/admin/challenges/latency-histogram") 251 _raise_for_status(resp) 252 return [AdminChallengeLatencyBucket.model_validate(b) for b in resp.json()] 253 254 def feedback( 255 self, challenge_id: str, *, competition_id: str = "" 256 ) -> PagedList[AdminFeedbackRow]: 257 """Admin audit list: every reaction row on a challenge with 258 denormalised user identity. Requires admin role.""" 259 params: dict[str, Any] = {} 260 if competition_id: 261 params["competition_id"] = competition_id 262 resp = self._http.request( 263 "GET", f"/admin/challenges/{challenge_id}/feedback", params=params 264 ) 265 _raise_for_status(resp) 266 return _extract_items(resp.json(), AdminFeedbackRow) 267 268 def reset_question_attempts( 269 self, 270 team_id: str, 271 challenge_id: str, 272 question_id: str, 273 *, 274 reason: str = "", 275 ) -> QuestionAttemptResetInfo: 276 """Reset the per-question wrong-attempt counter for one team. 277 278 Writes a fresh baseline timestamp; the cap counter ignores all 279 wrong submissions before the new baseline, so the team gets a 280 fresh batch of attempts on this question without any audit 281 rows being deleted. Returns the new baseline row. 282 Wraps ``POST /admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts``.""" 283 resp = self._http.request( 284 "POST", 285 f"/admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts", 286 json={"reason": reason}, 287 ) 288 _raise_for_status(resp) 289 return QuestionAttemptResetInfo.model_validate(resp.json()) 290 291 def list_llm_budgets(self, *, competition_id: str = "") -> LlmBudgetPage: 292 """Who has spent what against the challenge-LLM gateway. 293 294 The reader for :meth:`reset_llm_budget` — without it an organiser 295 can only hand back an allowance to a team somebody already named. 296 ⚠️ ``gateway_configured`` is on the envelope because an empty 297 list otherwise reads as "nobody spent anything" when it means 298 "this deployment enforces no budgets", and those call for 299 opposite actions. 300 Wraps ``GET /admin/llm-budgets``.""" 301 params = {"competition_id": competition_id} if competition_id else None 302 resp = self._http.request("GET", "/admin/llm-budgets", params=params) 303 _raise_for_status(resp) 304 return LlmBudgetPage.model_validate(resp.json()) 305 306 def deception_stats( 307 self, 308 *, 309 competition_id: str = "", 310 challenge_id: str = "", 311 since: datetime | None = None, 312 ) -> DeceptionStats: 313 """How often an assessing agent obeyed a directive planted in a 314 challenge's own output. 315 316 ⚠️ ``resisted`` is an **upper bound**, and the response carries 317 that caveat on the wire rather than leaving it to documentation: 318 the probe runs inside a container the player is invited to 319 compromise, so a missing compliance record is not evidence of 320 restraint. ``comply_rate`` is ``None`` rather than ``0.0`` when 321 nothing has been contacted — 0/0 rendered as 0% would assert 322 that no agent was fooled. 323 324 Nothing here is scored; it reports, it does not rule. 325 Wraps ``GET /admin/deception/stats``.""" 326 # ⚠️ ``since`` is the route's third filter and this wrapper used 327 # to drop it, so "what happened during *this* event" was 328 # unaskable from anything but a hand-rolled request — the shape 329 # ``admin competition update``'s missing flags already record. 330 params = { 331 k: v 332 for k, v in ( 333 ("competition_id", competition_id), 334 ("challenge_id", challenge_id), 335 ("since", since.isoformat() if since else ""), 336 ) 337 if v 338 } 339 resp = self._http.request("GET", "/admin/deception/stats", params=params or None) 340 _raise_for_status(resp) 341 return DeceptionStats.model_validate(resp.json()) 342 343 def reset_llm_budget( 344 self, 345 team_id: str, 346 competition_id: str, 347 challenge_id: str, 348 *, 349 reason: str = "", 350 ) -> LlmBudgetResetInfo: 351 """Give one team its LLM allowance back on one challenge. 352 353 Clears the gateway's live tally **and** the platform's durable 354 total — either alone un-does itself, so the route does both or 355 refuses. ⚠️ Always answers 200; read ``.outcome`` rather than the 356 status, because ``unreachable`` means nothing changed and is 357 worth retrying. 358 Wraps ``POST /admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset``.""" 359 resp = self._http.request( 360 "POST", 361 f"/admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset", 362 json={"reason": reason}, 363 ) 364 _raise_for_status(resp) 365 return LlmBudgetResetInfo.model_validate(resp.json())
Operator challenge tooling: reload the catalog, pre-build on nodes, inspect attempt/solve stats, audit feedback, reset attempt caps.
37 def rescan(self) -> AdminTaskInfo: 38 """Submit a catalog-rescan background task (reload the platform's 39 spec cache + fan the rescan out to every online node). Returns the 40 task record; poll ``admin.tasks.get(id)`` (or watch the Tasks page) 41 for the per-node outcome in ``result``. Wraps 42 ``POST /admin/challenges/rescan``.""" 43 resp = self._http.request("POST", "/admin/challenges/rescan") 44 _raise_for_status(resp) 45 return AdminTaskInfo.model_validate(resp.json())
Submit a catalog-rescan background task (reload the platform's
spec cache + fan the rescan out to every online node). Returns the
task record; poll admin.tasks.get(id) (or watch the Tasks page)
for the per-node outcome in result. Wraps
POST /admin/challenges/rescan.
47 def build(self, challenge_id: str) -> AdminTaskInfo: 48 """Submit a single-challenge pre-build background task. 49 50 Fans the build out to every online node and polls it to 51 completion; poll :meth:`build_state` (or the Tasks surface) to 52 watch ``building`` → ``built`` / ``failed``. Returns the task 53 record. Wraps ``POST /admin/challenges/{id}/build``.""" 54 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/build") 55 _raise_for_status(resp) 56 return AdminTaskInfo.model_validate(resp.json())
Submit a single-challenge pre-build background task.
Fans the build out to every online node and polls it to
completion; poll build_state() (or the Tasks surface) to
watch building → built / failed. Returns the task
record. Wraps POST /admin/challenges/{id}/build.
58 def build_all(self, *, competition_id: str = "") -> AdminTaskInfo: 59 """Submit a bulk pre-build background task. With ``competition_id`` 60 the build is scoped to that competition's effective challenge set; 61 otherwise the whole catalog. The task kicks each online node off 62 and polls them to completion; the per-node outcome + final 63 per-challenge status land in ``result``. Returns the task record. 64 Wraps ``POST /admin/challenges/build-all``.""" 65 params: dict[str, Any] = {} 66 if competition_id: 67 params["competition_id"] = competition_id 68 resp = self._http.request("POST", "/admin/challenges/build-all", params=params) 69 _raise_for_status(resp) 70 return AdminTaskInfo.model_validate(resp.json())
Submit a bulk pre-build background task. With competition_id
the build is scoped to that competition's effective challenge set;
otherwise the whole catalog. The task kicks each online node off
and polls them to completion; the per-node outcome + final
per-challenge status land in result. Returns the task record.
Wraps POST /admin/challenges/build-all.
72 def build_push( 73 self, 74 *, 75 bucket: str = "", 76 challenge_ids: list[str] | None = None, 77 node_id: str = "", 78 ) -> AdminTaskInfo: 79 """Submit a build-and-push task: freeze the scoped challenges' 80 ``build:`` services into the registry (Layer 2 P0, producer side). 81 82 An explicit scope is **required** — a ``bucket`` (family such as 83 ``XBOW``) or ``challenge_ids`` — or the server refuses it (400); it 84 never freezes the whole catalog. ``node_id`` pins the build host 85 (default: the first online node running each challenge). 86 Super-admin. Returns the task record. Wraps 87 ``POST /admin/challenges/build-push``.""" 88 body: dict[str, Any] = { 89 "bucket": bucket, 90 "challenge_ids": challenge_ids or [], 91 "node_id": node_id, 92 } 93 resp = self._http.request("POST", "/admin/challenges/build-push", json=body) 94 _raise_for_status(resp) 95 return AdminTaskInfo.model_validate(resp.json())
Submit a build-and-push task: freeze the scoped challenges'
build: services into the registry (Layer 2 P0, producer side).
An explicit scope is required — a bucket (family such as
XBOW) or challenge_ids — or the server refuses it (400); it
never freezes the whole catalog. node_id pins the build host
(default: the first online node running each challenge).
Super-admin. Returns the task record. Wraps
POST /admin/challenges/build-push.
97 def questions(self, challenge_id: str) -> AdminChallengeQuestions: 98 """Every milestone a challenge declares, gated ones included. 99 100 The catalog projection omits questions behind ``requires`` so a 101 player cannot read a challenge's attack chain off its public page; 102 this is the organiser's way back in — "which milestones exist, and 103 why did this team not get credit for one". Wraps 104 ``GET /admin/challenges/{id}/questions``.""" 105 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/questions") 106 _raise_for_status(resp) 107 return AdminChallengeQuestions.model_validate(resp.json())
Every milestone a challenge declares, gated ones included.
The catalog projection omits questions behind requires so a
player cannot read a challenge's attack chain off its public page;
this is the organiser's way back in — "which milestones exist, and
why did this team not get credit for one". Wraps
GET /admin/challenges/{id}/questions.
109 def blueprint(self, challenge_id: str) -> ChallengeBlueprint: 110 """A range's estate and kill chain, for the people running it. 111 112 The sibling :meth:`questions` answers *which milestones exist*; 113 at four services that is the whole picture and at forty it is a 114 flat list with no shape. This adds the two axes a large range is 115 actually debugged along: which network segment each service sits 116 on (and so what can only be reached through a foothold), and 117 what has to fall before each milestone. 118 119 Derived server-side from the challenge's own ``metadata.yaml`` 120 and ``docker-compose.yml``, so it works for a hand-built range 121 and a generated one alike. Carries no groundtruth: see 122 :meth:`ctfy.sdk.admin_resources.records.RecordsResource.instance_answers` 123 for the value of a minted answer. Wraps 124 ``GET /admin/challenges/{id}/blueprint``.""" 125 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/blueprint") 126 _raise_for_status(resp) 127 return ChallengeBlueprint.model_validate(resp.json())
A range's estate and kill chain, for the people running it.
The sibling questions() answers which milestones exist;
at four services that is the whole picture and at forty it is a
flat list with no shape. This adds the two axes a large range is
actually debugged along: which network segment each service sits
on (and so what can only be reached through a foothold), and
what has to fall before each milestone.
Derived server-side from the challenge's own metadata.yaml
and docker-compose.yml, so it works for a hand-built range
and a generated one alike. Carries no groundtruth: see
ctfy.sdk.admin_resources.records.RecordsResource.instance_answers()
for the value of a minted answer. Wraps
GET /admin/challenges/{id}/blueprint.
129 def range_progress(self, competition_id: str, challenge_id: str) -> RangeProgress: 130 """Where every team of one competition is on one range. 131 132 :meth:`blueprint` says what the range *is*; this says where each 133 team got to on it — which milestones they hold, where they are 134 stopped, what they could take next, and which estate hosts they 135 have reached. 136 137 The blueprint is deliberately **not** in the answer: it is a 138 property of the challenge and cacheable, while this changes on 139 every save, and a caller drawing the overlay already holds it. 140 Carries no groundtruth and no network depth — the layer axis is 141 derived from the blueprint, which has the segments. Wraps 142 ``GET /admin/competitions/{cid}/ranges/{chid}/progress``.""" 143 resp = self._http.request( 144 "GET", f"/admin/competitions/{competition_id}/ranges/{challenge_id}/progress" 145 ) 146 _raise_for_status(resp) 147 return RangeProgress.model_validate(resp.json())
Where every team of one competition is on one range.
blueprint() says what the range is; this says where each
team got to on it — which milestones they hold, where they are
stopped, what they could take next, and which estate hosts they
have reached.
The blueprint is deliberately not in the answer: it is a
property of the challenge and cacheable, while this changes on
every save, and a caller drawing the overlay already holds it.
Carries no groundtruth and no network depth — the layer axis is
derived from the blueprint, which has the segments. Wraps
GET /admin/competitions/{cid}/ranges/{chid}/progress.
149 def range_replay( 150 self, competition_id: str, challenge_id: str, *, frames: int | None = None 151 ) -> RangeReplay: 152 """The same field, wound back — where every team stood at time T. 153 154 :meth:`range_progress` answers *now*; this cuts the whole event 155 into instants, so a caller can ask *who moved when*. The last 156 frame equals that method's answer element for element, which is 157 the property the feature rests on: the clock is ``solved_at``, 158 the immutable first capture that every solve row carries. 159 160 ⚠️ **``frames: []`` means there is no time axis** — nothing was 161 captured and no window was declared, or nobody entered — and is 162 *not* what asking for no frames gets: ``frames=0`` is a 422, 163 because two facts behind one shape is the ``0``-versus-unset 164 trap. Omit ``frames`` for the server's default. 165 166 ⚠️ **A position carries ``reached`` as a count and no milestone 167 ids**, deliberately: carrying them would multiply the payload by 168 the milestone count. Its ``team_id`` is the id 169 :meth:`range_progress` published — an alias where the entrant 170 withheld their identity — so the two reads join without this 171 caller deciding who anybody is. Wraps 172 ``GET /admin/competitions/{cid}/ranges/{chid}/progress/replay``.""" 173 params = {} if frames is None else {"frames": frames} 174 resp = self._http.request( 175 "GET", 176 f"/admin/competitions/{competition_id}/ranges/{challenge_id}/progress/replay", 177 params=params, 178 ) 179 _raise_for_status(resp) 180 return RangeReplay.model_validate(resp.json())
The same field, wound back — where every team stood at time T.
range_progress() answers now; this cuts the whole event
into instants, so a caller can ask who moved when. The last
frame equals that method's answer element for element, which is
the property the feature rests on: the clock is solved_at,
the immutable first capture that every solve row carries.
⚠️ frames: [] means there is no time axis — nothing was
captured and no window was declared, or nobody entered — and is
not what asking for no frames gets: frames=0 is a 422,
because two facts behind one shape is the 0-versus-unset
trap. Omit frames for the server's default.
⚠️ A position carries reached as a count and no milestone
ids, deliberately: carrying them would multiply the payload by
the milestone count. Its team_id is the id
range_progress() published — an alias where the entrant
withheld their identity — so the two reads join without this
caller deciding who anybody is. Wraps
GET /admin/competitions/{cid}/ranges/{chid}/progress/replay.
182 def build_state(self) -> ChallengeBuildStateResponse: 183 """Aggregate per-challenge build state across every online node. 184 185 Returns one row per known challenge with an ``aggregated`` 186 worst-case status plus the per-node breakdown for the modal 187 drill-down. Wraps ``GET /admin/challenges/build-state``.""" 188 resp = self._http.request("GET", "/admin/challenges/build-state") 189 _raise_for_status(resp) 190 return ChallengeBuildStateResponse.model_validate(resp.json())
Aggregate per-challenge build state across every online node.
Returns one row per known challenge with an aggregated
worst-case status plus the per-node breakdown for the modal
drill-down. Wraps GET /admin/challenges/build-state.
192 def pull(self, challenge_id: str) -> AdminTaskInfo: 193 """Submit a single-challenge pre-pull background task (pull-side 194 twin of :meth:`build`: warms registry-only ``image:`` services). 195 Returns the task record. Wraps ``POST /admin/challenges/{id}/pull``.""" 196 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/pull") 197 _raise_for_status(resp) 198 return AdminTaskInfo.model_validate(resp.json())
Submit a single-challenge pre-pull background task (pull-side
twin of build(): warms registry-only image: services).
Returns the task record. Wraps POST /admin/challenges/{id}/pull.
200 def pull_all(self, *, competition_id: str = "") -> AdminTaskInfo: 201 """Submit a bulk pre-pull background task (pull-side twin of 202 :meth:`build_all`). Returns the task record. Wraps 203 ``POST /admin/challenges/pull-all``.""" 204 params: dict[str, Any] = {} 205 if competition_id: 206 params["competition_id"] = competition_id 207 resp = self._http.request("POST", "/admin/challenges/pull-all", params=params) 208 _raise_for_status(resp) 209 return AdminTaskInfo.model_validate(resp.json())
Submit a bulk pre-pull background task (pull-side twin of
build_all()). Returns the task record. Wraps
POST /admin/challenges/pull-all.
211 def pull_state(self) -> ChallengePullStateResponse: 212 """Aggregate per-challenge pull state across every online node. 213 214 Pull-side twin of :meth:`build_state`. Wraps 215 ``GET /admin/challenges/pull-state``.""" 216 resp = self._http.request("GET", "/admin/challenges/pull-state") 217 _raise_for_status(resp) 218 return ChallengePullStateResponse.model_validate(resp.json())
Aggregate per-challenge pull state across every online node.
Pull-side twin of build_state(). Wraps
GET /admin/challenges/pull-state.
220 def stats(self, offset: int = 0, limit: int = 50) -> list[AdminChallengeStatsRow]: 221 """Per-challenge attempt / solve / first-blood counts.""" 222 resp = self._http.request( 223 "GET", 224 "/admin/challenges/stats", 225 params={"offset": offset, "limit": limit}, 226 ) 227 _raise_for_status(resp) 228 return [AdminChallengeStatsRow.model_validate(r) for r in resp.json()["items"]]
Per-challenge attempt / solve / first-blood counts.
230 def timeseries( 231 self, 232 challenge_id: str, 233 *, 234 window: int = 86400, 235 bucket: int = 3600, 236 ) -> AdminChallengeTimeseries: 237 """Per-challenge timeseries for the row-level drawer on 238 ``/admin/challenges``.""" 239 resp = self._http.request( 240 "GET", 241 f"/admin/challenges/{challenge_id}/timeseries", 242 params={"window": window, "bucket": bucket}, 243 ) 244 _raise_for_status(resp) 245 return AdminChallengeTimeseries.model_validate(resp.json())
Per-challenge timeseries for the row-level drawer on
/admin/challenges.
247 def latency_histogram(self) -> list[AdminChallengeLatencyBucket]: 248 """Instance-start latency distribution across challenges (the 249 admin challenges latency chart).""" 250 resp = self._http.request("GET", "/admin/challenges/latency-histogram") 251 _raise_for_status(resp) 252 return [AdminChallengeLatencyBucket.model_validate(b) for b in resp.json()]
Instance-start latency distribution across challenges (the admin challenges latency chart).
254 def feedback( 255 self, challenge_id: str, *, competition_id: str = "" 256 ) -> PagedList[AdminFeedbackRow]: 257 """Admin audit list: every reaction row on a challenge with 258 denormalised user identity. Requires admin role.""" 259 params: dict[str, Any] = {} 260 if competition_id: 261 params["competition_id"] = competition_id 262 resp = self._http.request( 263 "GET", f"/admin/challenges/{challenge_id}/feedback", params=params 264 ) 265 _raise_for_status(resp) 266 return _extract_items(resp.json(), AdminFeedbackRow)
Admin audit list: every reaction row on a challenge with denormalised user identity. Requires admin role.
268 def reset_question_attempts( 269 self, 270 team_id: str, 271 challenge_id: str, 272 question_id: str, 273 *, 274 reason: str = "", 275 ) -> QuestionAttemptResetInfo: 276 """Reset the per-question wrong-attempt counter for one team. 277 278 Writes a fresh baseline timestamp; the cap counter ignores all 279 wrong submissions before the new baseline, so the team gets a 280 fresh batch of attempts on this question without any audit 281 rows being deleted. Returns the new baseline row. 282 Wraps ``POST /admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts``.""" 283 resp = self._http.request( 284 "POST", 285 f"/admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts", 286 json={"reason": reason}, 287 ) 288 _raise_for_status(resp) 289 return QuestionAttemptResetInfo.model_validate(resp.json())
Reset the per-question wrong-attempt counter for one team.
Writes a fresh baseline timestamp; the cap counter ignores all
wrong submissions before the new baseline, so the team gets a
fresh batch of attempts on this question without any audit
rows being deleted. Returns the new baseline row.
Wraps POST /admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts.
291 def list_llm_budgets(self, *, competition_id: str = "") -> LlmBudgetPage: 292 """Who has spent what against the challenge-LLM gateway. 293 294 The reader for :meth:`reset_llm_budget` — without it an organiser 295 can only hand back an allowance to a team somebody already named. 296 ⚠️ ``gateway_configured`` is on the envelope because an empty 297 list otherwise reads as "nobody spent anything" when it means 298 "this deployment enforces no budgets", and those call for 299 opposite actions. 300 Wraps ``GET /admin/llm-budgets``.""" 301 params = {"competition_id": competition_id} if competition_id else None 302 resp = self._http.request("GET", "/admin/llm-budgets", params=params) 303 _raise_for_status(resp) 304 return LlmBudgetPage.model_validate(resp.json())
Who has spent what against the challenge-LLM gateway.
The reader for reset_llm_budget() — without it an organiser
can only hand back an allowance to a team somebody already named.
⚠️ gateway_configured is on the envelope because an empty
list otherwise reads as "nobody spent anything" when it means
"this deployment enforces no budgets", and those call for
opposite actions.
Wraps GET /admin/llm-budgets.
306 def deception_stats( 307 self, 308 *, 309 competition_id: str = "", 310 challenge_id: str = "", 311 since: datetime | None = None, 312 ) -> DeceptionStats: 313 """How often an assessing agent obeyed a directive planted in a 314 challenge's own output. 315 316 ⚠️ ``resisted`` is an **upper bound**, and the response carries 317 that caveat on the wire rather than leaving it to documentation: 318 the probe runs inside a container the player is invited to 319 compromise, so a missing compliance record is not evidence of 320 restraint. ``comply_rate`` is ``None`` rather than ``0.0`` when 321 nothing has been contacted — 0/0 rendered as 0% would assert 322 that no agent was fooled. 323 324 Nothing here is scored; it reports, it does not rule. 325 Wraps ``GET /admin/deception/stats``.""" 326 # ⚠️ ``since`` is the route's third filter and this wrapper used 327 # to drop it, so "what happened during *this* event" was 328 # unaskable from anything but a hand-rolled request — the shape 329 # ``admin competition update``'s missing flags already record. 330 params = { 331 k: v 332 for k, v in ( 333 ("competition_id", competition_id), 334 ("challenge_id", challenge_id), 335 ("since", since.isoformat() if since else ""), 336 ) 337 if v 338 } 339 resp = self._http.request("GET", "/admin/deception/stats", params=params or None) 340 _raise_for_status(resp) 341 return DeceptionStats.model_validate(resp.json())
How often an assessing agent obeyed a directive planted in a challenge's own output.
⚠️ resisted is an upper bound, and the response carries
that caveat on the wire rather than leaving it to documentation:
the probe runs inside a container the player is invited to
compromise, so a missing compliance record is not evidence of
restraint. comply_rate is None rather than 0.0 when
nothing has been contacted — 0/0 rendered as 0% would assert
that no agent was fooled.
Nothing here is scored; it reports, it does not rule.
Wraps GET /admin/deception/stats.
343 def reset_llm_budget( 344 self, 345 team_id: str, 346 competition_id: str, 347 challenge_id: str, 348 *, 349 reason: str = "", 350 ) -> LlmBudgetResetInfo: 351 """Give one team its LLM allowance back on one challenge. 352 353 Clears the gateway's live tally **and** the platform's durable 354 total — either alone un-does itself, so the route does both or 355 refuses. ⚠️ Always answers 200; read ``.outcome`` rather than the 356 status, because ``unreachable`` means nothing changed and is 357 worth retrying. 358 Wraps ``POST /admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset``.""" 359 resp = self._http.request( 360 "POST", 361 f"/admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset", 362 json={"reason": reason}, 363 ) 364 _raise_for_status(resp) 365 return LlmBudgetResetInfo.model_validate(resp.json())
Give one team its LLM allowance back on one challenge.
Clears the gateway's live tally and the platform's durable
total — either alone un-does itself, so the route does both or
refuses. ⚠️ Always answers 200; read .outcome rather than the
status, because unreachable means nothing changed and is
worth retrying.
Wraps POST /admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset.