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 typing import Any 6 7from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status 8from ctfy.sdk.base import BaseHttpClient 9from ctfy.server.models import ( 10 AdminChallengeLatencyBucket, 11 AdminChallengeQuestions, 12 AdminChallengeStatsRow, 13 AdminChallengeTimeseries, 14 AdminFeedbackRow, 15 AdminTaskInfo, 16 ChallengeBuildStateResponse, 17 ChallengePullStateResponse, 18 DeceptionStats, 19 LlmBudgetPage, 20 LlmBudgetResetInfo, 21 QuestionAttemptResetInfo, 22) 23 24 25class AdminChallengesResource: 26 """Operator challenge tooling: reload the catalog, pre-build on nodes, 27 inspect attempt/solve stats, audit feedback, reset attempt caps.""" 28 29 def __init__(self, http: BaseHttpClient) -> None: 30 self._http = http 31 32 def rescan(self) -> AdminTaskInfo: 33 """Submit a catalog-rescan background task (reload the platform's 34 spec cache + fan the rescan out to every online node). Returns the 35 task record; poll ``admin.tasks.get(id)`` (or watch the Tasks page) 36 for the per-node outcome in ``result``. Wraps 37 ``POST /admin/challenges/rescan``.""" 38 resp = self._http.request("POST", "/admin/challenges/rescan") 39 _raise_for_status(resp) 40 return AdminTaskInfo.model_validate(resp.json()) 41 42 def build(self, challenge_id: str) -> AdminTaskInfo: 43 """Submit a single-challenge pre-build background task. 44 45 Fans the build out to every online node and polls it to 46 completion; poll :meth:`build_state` (or the Tasks surface) to 47 watch ``building`` → ``built`` / ``failed``. Returns the task 48 record. Wraps ``POST /admin/challenges/{id}/build``.""" 49 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/build") 50 _raise_for_status(resp) 51 return AdminTaskInfo.model_validate(resp.json()) 52 53 def build_all(self, *, competition_id: str = "") -> AdminTaskInfo: 54 """Submit a bulk pre-build background task. With ``competition_id`` 55 the build is scoped to that competition's effective challenge set; 56 otherwise the whole catalog. The task kicks each online node off 57 and polls them to completion; the per-node outcome + final 58 per-challenge status land in ``result``. Returns the task record. 59 Wraps ``POST /admin/challenges/build-all``.""" 60 params: dict[str, Any] = {} 61 if competition_id: 62 params["competition_id"] = competition_id 63 resp = self._http.request("POST", "/admin/challenges/build-all", params=params) 64 _raise_for_status(resp) 65 return AdminTaskInfo.model_validate(resp.json()) 66 67 def questions(self, challenge_id: str) -> AdminChallengeQuestions: 68 """Every milestone a challenge declares, gated ones included. 69 70 The catalog projection omits questions behind ``requires`` so a 71 player cannot read a challenge's attack chain off its public page; 72 this is the organiser's way back in — "which milestones exist, and 73 why did this team not get credit for one". Wraps 74 ``GET /admin/challenges/{id}/questions``.""" 75 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/questions") 76 _raise_for_status(resp) 77 return AdminChallengeQuestions.model_validate(resp.json()) 78 79 def build_state(self) -> ChallengeBuildStateResponse: 80 """Aggregate per-challenge build state across every online node. 81 82 Returns one row per known challenge with an ``aggregated`` 83 worst-case status plus the per-node breakdown for the modal 84 drill-down. Wraps ``GET /admin/challenges/build-state``.""" 85 resp = self._http.request("GET", "/admin/challenges/build-state") 86 _raise_for_status(resp) 87 return ChallengeBuildStateResponse.model_validate(resp.json()) 88 89 def pull(self, challenge_id: str) -> AdminTaskInfo: 90 """Submit a single-challenge pre-pull background task (pull-side 91 twin of :meth:`build`: warms registry-only ``image:`` services). 92 Returns the task record. Wraps ``POST /admin/challenges/{id}/pull``.""" 93 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/pull") 94 _raise_for_status(resp) 95 return AdminTaskInfo.model_validate(resp.json()) 96 97 def pull_all(self, *, competition_id: str = "") -> AdminTaskInfo: 98 """Submit a bulk pre-pull background task (pull-side twin of 99 :meth:`build_all`). Returns the task record. Wraps 100 ``POST /admin/challenges/pull-all``.""" 101 params: dict[str, Any] = {} 102 if competition_id: 103 params["competition_id"] = competition_id 104 resp = self._http.request("POST", "/admin/challenges/pull-all", params=params) 105 _raise_for_status(resp) 106 return AdminTaskInfo.model_validate(resp.json()) 107 108 def pull_state(self) -> ChallengePullStateResponse: 109 """Aggregate per-challenge pull state across every online node. 110 111 Pull-side twin of :meth:`build_state`. Wraps 112 ``GET /admin/challenges/pull-state``.""" 113 resp = self._http.request("GET", "/admin/challenges/pull-state") 114 _raise_for_status(resp) 115 return ChallengePullStateResponse.model_validate(resp.json()) 116 117 def stats(self, offset: int = 0, limit: int = 50) -> list[AdminChallengeStatsRow]: 118 """Per-challenge attempt / solve / first-blood counts.""" 119 resp = self._http.request( 120 "GET", 121 "/admin/challenges/stats", 122 params={"offset": offset, "limit": limit}, 123 ) 124 _raise_for_status(resp) 125 return [AdminChallengeStatsRow.model_validate(r) for r in resp.json()["items"]] 126 127 def timeseries( 128 self, 129 challenge_id: str, 130 *, 131 window: int = 86400, 132 bucket: int = 3600, 133 ) -> AdminChallengeTimeseries: 134 """Per-challenge timeseries for the row-level drawer on 135 ``/admin/challenges``.""" 136 resp = self._http.request( 137 "GET", 138 f"/admin/challenges/{challenge_id}/timeseries", 139 params={"window": window, "bucket": bucket}, 140 ) 141 _raise_for_status(resp) 142 return AdminChallengeTimeseries.model_validate(resp.json()) 143 144 def latency_histogram(self) -> list[AdminChallengeLatencyBucket]: 145 """Instance-start latency distribution across challenges (the 146 admin challenges latency chart).""" 147 resp = self._http.request("GET", "/admin/challenges/latency-histogram") 148 _raise_for_status(resp) 149 return [AdminChallengeLatencyBucket.model_validate(b) for b in resp.json()] 150 151 def feedback( 152 self, challenge_id: str, *, competition_id: str = "" 153 ) -> PagedList[AdminFeedbackRow]: 154 """Admin audit list: every reaction row on a challenge with 155 denormalised user identity. Requires admin role.""" 156 params: dict[str, Any] = {} 157 if competition_id: 158 params["competition_id"] = competition_id 159 resp = self._http.request( 160 "GET", f"/admin/challenges/{challenge_id}/feedback", params=params 161 ) 162 _raise_for_status(resp) 163 return _extract_items(resp.json(), AdminFeedbackRow) 164 165 def reset_question_attempts( 166 self, 167 team_id: str, 168 challenge_id: str, 169 question_id: str, 170 *, 171 reason: str = "", 172 ) -> QuestionAttemptResetInfo: 173 """Reset the per-question wrong-attempt counter for one team. 174 175 Writes a fresh baseline timestamp; the cap counter ignores all 176 wrong submissions before the new baseline, so the team gets a 177 fresh batch of attempts on this question without any audit 178 rows being deleted. Returns the new baseline row. 179 Wraps ``POST /admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts``.""" 180 resp = self._http.request( 181 "POST", 182 f"/admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts", 183 json={"reason": reason}, 184 ) 185 _raise_for_status(resp) 186 return QuestionAttemptResetInfo.model_validate(resp.json()) 187 188 def list_llm_budgets(self, *, competition_id: str = "") -> LlmBudgetPage: 189 """Who has spent what against the challenge-LLM gateway. 190 191 The reader for :meth:`reset_llm_budget` — without it an organiser 192 can only hand back an allowance to a team somebody already named. 193 ⚠️ ``gateway_configured`` is on the envelope because an empty 194 list otherwise reads as "nobody spent anything" when it means 195 "this deployment enforces no budgets", and those call for 196 opposite actions. 197 Wraps ``GET /admin/llm-budgets``.""" 198 params = {"competition_id": competition_id} if competition_id else None 199 resp = self._http.request("GET", "/admin/llm-budgets", params=params) 200 _raise_for_status(resp) 201 return LlmBudgetPage.model_validate(resp.json()) 202 203 def deception_stats( 204 self, 205 *, 206 competition_id: str = "", 207 challenge_id: str = "", 208 ) -> DeceptionStats: 209 """How often an assessing agent obeyed a directive planted in a 210 challenge's own output. 211 212 ⚠️ ``resisted`` is an **upper bound**, and the response carries 213 that caveat on the wire rather than leaving it to documentation: 214 the probe runs inside a container the player is invited to 215 compromise, so a missing compliance record is not evidence of 216 restraint. ``comply_rate`` is ``None`` rather than ``0.0`` when 217 nothing has been contacted — 0/0 rendered as 0% would assert 218 that no agent was fooled. 219 220 Nothing here is scored; it reports, it does not rule. 221 Wraps ``GET /admin/deception/stats``.""" 222 params = { 223 k: v 224 for k, v in ( 225 ("competition_id", competition_id), 226 ("challenge_id", challenge_id), 227 ) 228 if v 229 } 230 resp = self._http.request("GET", "/admin/deception/stats", params=params or None) 231 _raise_for_status(resp) 232 return DeceptionStats.model_validate(resp.json()) 233 234 def reset_llm_budget( 235 self, 236 team_id: str, 237 competition_id: str, 238 challenge_id: str, 239 *, 240 reason: str = "", 241 ) -> LlmBudgetResetInfo: 242 """Give one team its LLM allowance back on one challenge. 243 244 Clears the gateway's live tally **and** the platform's durable 245 total — either alone un-does itself, so the route does both or 246 refuses. ⚠️ Always answers 200; read ``.outcome`` rather than the 247 status, because ``unreachable`` means nothing changed and is 248 worth retrying. 249 Wraps ``POST /admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset``.""" 250 resp = self._http.request( 251 "POST", 252 f"/admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset", 253 json={"reason": reason}, 254 ) 255 _raise_for_status(resp) 256 return LlmBudgetResetInfo.model_validate(resp.json())
26class AdminChallengesResource: 27 """Operator challenge tooling: reload the catalog, pre-build on nodes, 28 inspect attempt/solve stats, audit feedback, reset attempt caps.""" 29 30 def __init__(self, http: BaseHttpClient) -> None: 31 self._http = http 32 33 def rescan(self) -> AdminTaskInfo: 34 """Submit a catalog-rescan background task (reload the platform's 35 spec cache + fan the rescan out to every online node). Returns the 36 task record; poll ``admin.tasks.get(id)`` (or watch the Tasks page) 37 for the per-node outcome in ``result``. Wraps 38 ``POST /admin/challenges/rescan``.""" 39 resp = self._http.request("POST", "/admin/challenges/rescan") 40 _raise_for_status(resp) 41 return AdminTaskInfo.model_validate(resp.json()) 42 43 def build(self, challenge_id: str) -> AdminTaskInfo: 44 """Submit a single-challenge pre-build background task. 45 46 Fans the build out to every online node and polls it to 47 completion; poll :meth:`build_state` (or the Tasks surface) to 48 watch ``building`` → ``built`` / ``failed``. Returns the task 49 record. Wraps ``POST /admin/challenges/{id}/build``.""" 50 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/build") 51 _raise_for_status(resp) 52 return AdminTaskInfo.model_validate(resp.json()) 53 54 def build_all(self, *, competition_id: str = "") -> AdminTaskInfo: 55 """Submit a bulk pre-build background task. With ``competition_id`` 56 the build is scoped to that competition's effective challenge set; 57 otherwise the whole catalog. The task kicks each online node off 58 and polls them to completion; the per-node outcome + final 59 per-challenge status land in ``result``. Returns the task record. 60 Wraps ``POST /admin/challenges/build-all``.""" 61 params: dict[str, Any] = {} 62 if competition_id: 63 params["competition_id"] = competition_id 64 resp = self._http.request("POST", "/admin/challenges/build-all", params=params) 65 _raise_for_status(resp) 66 return AdminTaskInfo.model_validate(resp.json()) 67 68 def questions(self, challenge_id: str) -> AdminChallengeQuestions: 69 """Every milestone a challenge declares, gated ones included. 70 71 The catalog projection omits questions behind ``requires`` so a 72 player cannot read a challenge's attack chain off its public page; 73 this is the organiser's way back in — "which milestones exist, and 74 why did this team not get credit for one". Wraps 75 ``GET /admin/challenges/{id}/questions``.""" 76 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/questions") 77 _raise_for_status(resp) 78 return AdminChallengeQuestions.model_validate(resp.json()) 79 80 def build_state(self) -> ChallengeBuildStateResponse: 81 """Aggregate per-challenge build state across every online node. 82 83 Returns one row per known challenge with an ``aggregated`` 84 worst-case status plus the per-node breakdown for the modal 85 drill-down. Wraps ``GET /admin/challenges/build-state``.""" 86 resp = self._http.request("GET", "/admin/challenges/build-state") 87 _raise_for_status(resp) 88 return ChallengeBuildStateResponse.model_validate(resp.json()) 89 90 def pull(self, challenge_id: str) -> AdminTaskInfo: 91 """Submit a single-challenge pre-pull background task (pull-side 92 twin of :meth:`build`: warms registry-only ``image:`` services). 93 Returns the task record. Wraps ``POST /admin/challenges/{id}/pull``.""" 94 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/pull") 95 _raise_for_status(resp) 96 return AdminTaskInfo.model_validate(resp.json()) 97 98 def pull_all(self, *, competition_id: str = "") -> AdminTaskInfo: 99 """Submit a bulk pre-pull background task (pull-side twin of 100 :meth:`build_all`). Returns the task record. Wraps 101 ``POST /admin/challenges/pull-all``.""" 102 params: dict[str, Any] = {} 103 if competition_id: 104 params["competition_id"] = competition_id 105 resp = self._http.request("POST", "/admin/challenges/pull-all", params=params) 106 _raise_for_status(resp) 107 return AdminTaskInfo.model_validate(resp.json()) 108 109 def pull_state(self) -> ChallengePullStateResponse: 110 """Aggregate per-challenge pull state across every online node. 111 112 Pull-side twin of :meth:`build_state`. Wraps 113 ``GET /admin/challenges/pull-state``.""" 114 resp = self._http.request("GET", "/admin/challenges/pull-state") 115 _raise_for_status(resp) 116 return ChallengePullStateResponse.model_validate(resp.json()) 117 118 def stats(self, offset: int = 0, limit: int = 50) -> list[AdminChallengeStatsRow]: 119 """Per-challenge attempt / solve / first-blood counts.""" 120 resp = self._http.request( 121 "GET", 122 "/admin/challenges/stats", 123 params={"offset": offset, "limit": limit}, 124 ) 125 _raise_for_status(resp) 126 return [AdminChallengeStatsRow.model_validate(r) for r in resp.json()["items"]] 127 128 def timeseries( 129 self, 130 challenge_id: str, 131 *, 132 window: int = 86400, 133 bucket: int = 3600, 134 ) -> AdminChallengeTimeseries: 135 """Per-challenge timeseries for the row-level drawer on 136 ``/admin/challenges``.""" 137 resp = self._http.request( 138 "GET", 139 f"/admin/challenges/{challenge_id}/timeseries", 140 params={"window": window, "bucket": bucket}, 141 ) 142 _raise_for_status(resp) 143 return AdminChallengeTimeseries.model_validate(resp.json()) 144 145 def latency_histogram(self) -> list[AdminChallengeLatencyBucket]: 146 """Instance-start latency distribution across challenges (the 147 admin challenges latency chart).""" 148 resp = self._http.request("GET", "/admin/challenges/latency-histogram") 149 _raise_for_status(resp) 150 return [AdminChallengeLatencyBucket.model_validate(b) for b in resp.json()] 151 152 def feedback( 153 self, challenge_id: str, *, competition_id: str = "" 154 ) -> PagedList[AdminFeedbackRow]: 155 """Admin audit list: every reaction row on a challenge with 156 denormalised user identity. Requires admin role.""" 157 params: dict[str, Any] = {} 158 if competition_id: 159 params["competition_id"] = competition_id 160 resp = self._http.request( 161 "GET", f"/admin/challenges/{challenge_id}/feedback", params=params 162 ) 163 _raise_for_status(resp) 164 return _extract_items(resp.json(), AdminFeedbackRow) 165 166 def reset_question_attempts( 167 self, 168 team_id: str, 169 challenge_id: str, 170 question_id: str, 171 *, 172 reason: str = "", 173 ) -> QuestionAttemptResetInfo: 174 """Reset the per-question wrong-attempt counter for one team. 175 176 Writes a fresh baseline timestamp; the cap counter ignores all 177 wrong submissions before the new baseline, so the team gets a 178 fresh batch of attempts on this question without any audit 179 rows being deleted. Returns the new baseline row. 180 Wraps ``POST /admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts``.""" 181 resp = self._http.request( 182 "POST", 183 f"/admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts", 184 json={"reason": reason}, 185 ) 186 _raise_for_status(resp) 187 return QuestionAttemptResetInfo.model_validate(resp.json()) 188 189 def list_llm_budgets(self, *, competition_id: str = "") -> LlmBudgetPage: 190 """Who has spent what against the challenge-LLM gateway. 191 192 The reader for :meth:`reset_llm_budget` — without it an organiser 193 can only hand back an allowance to a team somebody already named. 194 ⚠️ ``gateway_configured`` is on the envelope because an empty 195 list otherwise reads as "nobody spent anything" when it means 196 "this deployment enforces no budgets", and those call for 197 opposite actions. 198 Wraps ``GET /admin/llm-budgets``.""" 199 params = {"competition_id": competition_id} if competition_id else None 200 resp = self._http.request("GET", "/admin/llm-budgets", params=params) 201 _raise_for_status(resp) 202 return LlmBudgetPage.model_validate(resp.json()) 203 204 def deception_stats( 205 self, 206 *, 207 competition_id: str = "", 208 challenge_id: str = "", 209 ) -> DeceptionStats: 210 """How often an assessing agent obeyed a directive planted in a 211 challenge's own output. 212 213 ⚠️ ``resisted`` is an **upper bound**, and the response carries 214 that caveat on the wire rather than leaving it to documentation: 215 the probe runs inside a container the player is invited to 216 compromise, so a missing compliance record is not evidence of 217 restraint. ``comply_rate`` is ``None`` rather than ``0.0`` when 218 nothing has been contacted — 0/0 rendered as 0% would assert 219 that no agent was fooled. 220 221 Nothing here is scored; it reports, it does not rule. 222 Wraps ``GET /admin/deception/stats``.""" 223 params = { 224 k: v 225 for k, v in ( 226 ("competition_id", competition_id), 227 ("challenge_id", challenge_id), 228 ) 229 if v 230 } 231 resp = self._http.request("GET", "/admin/deception/stats", params=params or None) 232 _raise_for_status(resp) 233 return DeceptionStats.model_validate(resp.json()) 234 235 def reset_llm_budget( 236 self, 237 team_id: str, 238 competition_id: str, 239 challenge_id: str, 240 *, 241 reason: str = "", 242 ) -> LlmBudgetResetInfo: 243 """Give one team its LLM allowance back on one challenge. 244 245 Clears the gateway's live tally **and** the platform's durable 246 total — either alone un-does itself, so the route does both or 247 refuses. ⚠️ Always answers 200; read ``.outcome`` rather than the 248 status, because ``unreachable`` means nothing changed and is 249 worth retrying. 250 Wraps ``POST /admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset``.""" 251 resp = self._http.request( 252 "POST", 253 f"/admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset", 254 json={"reason": reason}, 255 ) 256 _raise_for_status(resp) 257 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.
33 def rescan(self) -> AdminTaskInfo: 34 """Submit a catalog-rescan background task (reload the platform's 35 spec cache + fan the rescan out to every online node). Returns the 36 task record; poll ``admin.tasks.get(id)`` (or watch the Tasks page) 37 for the per-node outcome in ``result``. Wraps 38 ``POST /admin/challenges/rescan``.""" 39 resp = self._http.request("POST", "/admin/challenges/rescan") 40 _raise_for_status(resp) 41 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.
43 def build(self, challenge_id: str) -> AdminTaskInfo: 44 """Submit a single-challenge pre-build background task. 45 46 Fans the build out to every online node and polls it to 47 completion; poll :meth:`build_state` (or the Tasks surface) to 48 watch ``building`` → ``built`` / ``failed``. Returns the task 49 record. Wraps ``POST /admin/challenges/{id}/build``.""" 50 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/build") 51 _raise_for_status(resp) 52 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.
54 def build_all(self, *, competition_id: str = "") -> AdminTaskInfo: 55 """Submit a bulk pre-build background task. With ``competition_id`` 56 the build is scoped to that competition's effective challenge set; 57 otherwise the whole catalog. The task kicks each online node off 58 and polls them to completion; the per-node outcome + final 59 per-challenge status land in ``result``. Returns the task record. 60 Wraps ``POST /admin/challenges/build-all``.""" 61 params: dict[str, Any] = {} 62 if competition_id: 63 params["competition_id"] = competition_id 64 resp = self._http.request("POST", "/admin/challenges/build-all", params=params) 65 _raise_for_status(resp) 66 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.
68 def questions(self, challenge_id: str) -> AdminChallengeQuestions: 69 """Every milestone a challenge declares, gated ones included. 70 71 The catalog projection omits questions behind ``requires`` so a 72 player cannot read a challenge's attack chain off its public page; 73 this is the organiser's way back in — "which milestones exist, and 74 why did this team not get credit for one". Wraps 75 ``GET /admin/challenges/{id}/questions``.""" 76 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/questions") 77 _raise_for_status(resp) 78 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.
80 def build_state(self) -> ChallengeBuildStateResponse: 81 """Aggregate per-challenge build state across every online node. 82 83 Returns one row per known challenge with an ``aggregated`` 84 worst-case status plus the per-node breakdown for the modal 85 drill-down. Wraps ``GET /admin/challenges/build-state``.""" 86 resp = self._http.request("GET", "/admin/challenges/build-state") 87 _raise_for_status(resp) 88 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.
90 def pull(self, challenge_id: str) -> AdminTaskInfo: 91 """Submit a single-challenge pre-pull background task (pull-side 92 twin of :meth:`build`: warms registry-only ``image:`` services). 93 Returns the task record. Wraps ``POST /admin/challenges/{id}/pull``.""" 94 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/pull") 95 _raise_for_status(resp) 96 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.
98 def pull_all(self, *, competition_id: str = "") -> AdminTaskInfo: 99 """Submit a bulk pre-pull background task (pull-side twin of 100 :meth:`build_all`). Returns the task record. Wraps 101 ``POST /admin/challenges/pull-all``.""" 102 params: dict[str, Any] = {} 103 if competition_id: 104 params["competition_id"] = competition_id 105 resp = self._http.request("POST", "/admin/challenges/pull-all", params=params) 106 _raise_for_status(resp) 107 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.
109 def pull_state(self) -> ChallengePullStateResponse: 110 """Aggregate per-challenge pull state across every online node. 111 112 Pull-side twin of :meth:`build_state`. Wraps 113 ``GET /admin/challenges/pull-state``.""" 114 resp = self._http.request("GET", "/admin/challenges/pull-state") 115 _raise_for_status(resp) 116 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.
118 def stats(self, offset: int = 0, limit: int = 50) -> list[AdminChallengeStatsRow]: 119 """Per-challenge attempt / solve / first-blood counts.""" 120 resp = self._http.request( 121 "GET", 122 "/admin/challenges/stats", 123 params={"offset": offset, "limit": limit}, 124 ) 125 _raise_for_status(resp) 126 return [AdminChallengeStatsRow.model_validate(r) for r in resp.json()["items"]]
Per-challenge attempt / solve / first-blood counts.
128 def timeseries( 129 self, 130 challenge_id: str, 131 *, 132 window: int = 86400, 133 bucket: int = 3600, 134 ) -> AdminChallengeTimeseries: 135 """Per-challenge timeseries for the row-level drawer on 136 ``/admin/challenges``.""" 137 resp = self._http.request( 138 "GET", 139 f"/admin/challenges/{challenge_id}/timeseries", 140 params={"window": window, "bucket": bucket}, 141 ) 142 _raise_for_status(resp) 143 return AdminChallengeTimeseries.model_validate(resp.json())
Per-challenge timeseries for the row-level drawer on
/admin/challenges.
145 def latency_histogram(self) -> list[AdminChallengeLatencyBucket]: 146 """Instance-start latency distribution across challenges (the 147 admin challenges latency chart).""" 148 resp = self._http.request("GET", "/admin/challenges/latency-histogram") 149 _raise_for_status(resp) 150 return [AdminChallengeLatencyBucket.model_validate(b) for b in resp.json()]
Instance-start latency distribution across challenges (the admin challenges latency chart).
152 def feedback( 153 self, challenge_id: str, *, competition_id: str = "" 154 ) -> PagedList[AdminFeedbackRow]: 155 """Admin audit list: every reaction row on a challenge with 156 denormalised user identity. Requires admin role.""" 157 params: dict[str, Any] = {} 158 if competition_id: 159 params["competition_id"] = competition_id 160 resp = self._http.request( 161 "GET", f"/admin/challenges/{challenge_id}/feedback", params=params 162 ) 163 _raise_for_status(resp) 164 return _extract_items(resp.json(), AdminFeedbackRow)
Admin audit list: every reaction row on a challenge with denormalised user identity. Requires admin role.
166 def reset_question_attempts( 167 self, 168 team_id: str, 169 challenge_id: str, 170 question_id: str, 171 *, 172 reason: str = "", 173 ) -> QuestionAttemptResetInfo: 174 """Reset the per-question wrong-attempt counter for one team. 175 176 Writes a fresh baseline timestamp; the cap counter ignores all 177 wrong submissions before the new baseline, so the team gets a 178 fresh batch of attempts on this question without any audit 179 rows being deleted. Returns the new baseline row. 180 Wraps ``POST /admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts``.""" 181 resp = self._http.request( 182 "POST", 183 f"/admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts", 184 json={"reason": reason}, 185 ) 186 _raise_for_status(resp) 187 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.
189 def list_llm_budgets(self, *, competition_id: str = "") -> LlmBudgetPage: 190 """Who has spent what against the challenge-LLM gateway. 191 192 The reader for :meth:`reset_llm_budget` — without it an organiser 193 can only hand back an allowance to a team somebody already named. 194 ⚠️ ``gateway_configured`` is on the envelope because an empty 195 list otherwise reads as "nobody spent anything" when it means 196 "this deployment enforces no budgets", and those call for 197 opposite actions. 198 Wraps ``GET /admin/llm-budgets``.""" 199 params = {"competition_id": competition_id} if competition_id else None 200 resp = self._http.request("GET", "/admin/llm-budgets", params=params) 201 _raise_for_status(resp) 202 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.
204 def deception_stats( 205 self, 206 *, 207 competition_id: str = "", 208 challenge_id: str = "", 209 ) -> DeceptionStats: 210 """How often an assessing agent obeyed a directive planted in a 211 challenge's own output. 212 213 ⚠️ ``resisted`` is an **upper bound**, and the response carries 214 that caveat on the wire rather than leaving it to documentation: 215 the probe runs inside a container the player is invited to 216 compromise, so a missing compliance record is not evidence of 217 restraint. ``comply_rate`` is ``None`` rather than ``0.0`` when 218 nothing has been contacted — 0/0 rendered as 0% would assert 219 that no agent was fooled. 220 221 Nothing here is scored; it reports, it does not rule. 222 Wraps ``GET /admin/deception/stats``.""" 223 params = { 224 k: v 225 for k, v in ( 226 ("competition_id", competition_id), 227 ("challenge_id", challenge_id), 228 ) 229 if v 230 } 231 resp = self._http.request("GET", "/admin/deception/stats", params=params or None) 232 _raise_for_status(resp) 233 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.
235 def reset_llm_budget( 236 self, 237 team_id: str, 238 competition_id: str, 239 challenge_id: str, 240 *, 241 reason: str = "", 242 ) -> LlmBudgetResetInfo: 243 """Give one team its LLM allowance back on one challenge. 244 245 Clears the gateway's live tally **and** the platform's durable 246 total — either alone un-does itself, so the route does both or 247 refuses. ⚠️ Always answers 200; read ``.outcome`` rather than the 248 status, because ``unreachable`` means nothing changed and is 249 worth retrying. 250 Wraps ``POST /admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset``.""" 251 resp = self._http.request( 252 "POST", 253 f"/admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset", 254 json={"reason": reason}, 255 ) 256 _raise_for_status(resp) 257 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.