Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/kernelbot/api/api_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
SubmissionRequest,
prepare_submission,
)
from libkernelbot.utils import KernelBotError


async def _handle_discord_oauth(code: str, redirect_uri: str) -> tuple[str, str]:
Expand Down Expand Up @@ -147,6 +148,8 @@ async def _run_submission(
):
try:
req = prepare_submission(submission, backend)
except KernelBotError as e:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

raise HTTPException(status_code=e.http_code, detail=str(e)) from e
except Exception as e:
raise HTTPException(status_code=400, detail=str(e)) from e

Expand Down
68 changes: 49 additions & 19 deletions src/kernelbot/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

app = FastAPI()


def json_serializer(obj):
"""JSON serializer for objects not serializable by default json code"""
if isinstance(obj, (datetime.datetime, datetime.date, datetime.time)):
Expand Down Expand Up @@ -255,10 +256,16 @@ async def cli_auth(auth_provider: str, code: str, state: str, db_context=Depends
raise e
except Exception as e:
# Catch unexpected errors during OAuth handling
raise HTTPException(status_code=500, detail=f"Error during {auth_provider} OAuth flow: {e}") from e
raise HTTPException(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these actual lint changes or claude gone wild?

status_code=500,
detail=f"Error during {auth_provider} OAuth flow: {e}",
) from e

if not user_id or not user_name:
raise HTTPException(status_code=500,detail="Failed to retrieve user ID or username from provider.",)
raise HTTPException(
status_code=500,
detail="Failed to retrieve user ID or username from provider.",
)

try:
with db_context as db:
Expand All @@ -268,7 +275,10 @@ async def cli_auth(auth_provider: str, code: str, state: str, db_context=Depends
db.create_user_from_cli(user_id, user_name, cli_id, auth_provider)

except AttributeError as e:
raise HTTPException(status_code=500, detail=f"Database interface error during update: {e}") from e
raise HTTPException(
status_code=500,
detail=f"Database interface error during update: {e}",
) from e
except Exception as e:
raise HTTPException(status_code=400, detail=f"Database update failed: {e}") from e

Expand All @@ -280,6 +290,7 @@ async def cli_auth(auth_provider: str, code: str, state: str, db_context=Depends
"is_reset": is_reset,
}


async def _stream_submission_response(
submission_request: SubmissionRequest,
submission_mode_enum: SubmissionMode,
Expand All @@ -298,18 +309,22 @@ async def _stream_submission_response(

while not task.done():
elapsed_time = time.time() - start_time
yield f"event: status\ndata: {json.dumps({'status': 'processing',
'elapsed_time': round(elapsed_time, 2)},
default=json_serializer)}\n\n"
status_data = json.dumps(
{"status": "processing", "elapsed_time": round(elapsed_time, 2)},
default=json_serializer,
)
yield f"event: status\ndata: {status_data}\n\n"

try:
await asyncio.wait_for(asyncio.shield(task), timeout=15.0)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
yield f"event: error\ndata: {json.dumps(
{'status': 'error', 'detail': 'Submission cancelled'},
default=json_serializer)}\n\n"
error_data = json.dumps(
{"status": "error", "detail": "Submission cancelled"},
default=json_serializer,
)
yield f"event: error\ndata: {error_data}\n\n"
return

result, reports = await task
Expand Down Expand Up @@ -343,6 +358,7 @@ async def _stream_submission_response(
except asyncio.CancelledError:
pass


@app.post("/{leaderboard_name}/{gpu_type}/{submission_mode}")
async def run_submission( # noqa: C901
leaderboard_name: str,
Expand Down Expand Up @@ -381,13 +397,13 @@ async def run_submission( # noqa: C901
)
return StreamingResponse(generator, media_type="text/event-stream")


async def enqueue_background_job(
req: ProcessedSubmissionRequest,
mode: SubmissionMode,
backend: KernelBackend,
manager: BackgroundSubmissionManager,
):

# pre-create the submission for api returns
with backend.db as db:
sub_id = db.create_submission(
Expand All @@ -401,7 +417,8 @@ async def enqueue_background_job(
job_id = db.upsert_submission_job_status(sub_id, "initial", None)
# put submission request in queue
await manager.enqueue(req, mode, sub_id)
return sub_id,job_id
return sub_id, job_id


@app.post("/submission/{leaderboard_name}/{gpu_type}/{submission_mode}")
async def run_submission_async(
Expand All @@ -425,37 +442,49 @@ async def run_submission_async(
Raises:
HTTPException: If the kernelbot is not initialized, or header/input is invalid.
Returns:
JSONResponse: A JSON response containing job_id and and submission_id for the client to poll for status.
JSONResponse: A JSON response containing job_id and submission_id.
The client can poll for status using these ids.
"""
try:

await simple_rate_limit()
logger.info(f"Received submission request for {leaderboard_name} {gpu_type} {submission_mode}")

logger.info(
"Received submission request for %s %s %s",
leaderboard_name,
gpu_type,
submission_mode,
)

# throw error if submission request is invalid
try:
submission_request, submission_mode_enum = await to_submit_info(
user_info, submission_mode, file, leaderboard_name, gpu_type, db_context
user_info, submission_mode, file, leaderboard_name, gpu_type, db_context
)

req = prepare_submission(submission_request, backend_instance)

except KernelBotError as e:
raise HTTPException(status_code=e.http_code, detail=str(e)) from e
except Exception as e:
raise HTTPException(status_code=400, detail=f"failed to prepare submission request: {str(e)}") from e
raise HTTPException(
status_code=400,
detail=f"failed to prepare submission request: {str(e)}",
) from e
Comment on lines +465 to +471
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is duplicate error handling for KernelBotError. The inner try-except block (lines 458-471) catches KernelBotError and converts it to HTTPException. However, the outer exception handling also catches KernelBotError (visible in the larger context). Since the inner handler already raises HTTPException for KernelBotError, any outer KernelBotError handler would never be reached and represents dead code.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah agree with AI here


# prepare submission request before the submission is started
if not req.gpus or len(req.gpus) != 1:
raise HTTPException(status_code=400, detail="Invalid GPU type")

# put submission request to background manager to run in background
sub_id,job_status_id = await enqueue_background_job(
sub_id, job_status_id = await enqueue_background_job(
req, submission_mode_enum, backend_instance, background_submission_manager
)

return JSONResponse(
status_code=202,
content={"details":{"id": sub_id, "job_status_id": job_status_id}, "status": "accepted"},
content={
"details": {"id": sub_id, "job_status_id": job_status_id},
"status": "accepted",
},
)
# Preserve FastAPI HTTPException as-is
except HTTPException:
Expand All @@ -470,6 +499,7 @@ async def run_submission_async(
logger.error(f"Unexpected error in api submissoin: {e}")
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in log message: 'submissoin' should be 'submission'.

Suggested change
logger.error(f"Unexpected error in api submissoin: {e}")
logger.error(f"Unexpected error in api submission: {e}")

Copilot uses AI. Check for mistakes.
raise HTTPException(status_code=500, detail="Internal server error") from e


@app.get("/leaderboards")
async def get_leaderboards(db_context=Depends(get_db)):
"""An endpoint that returns all leaderboards.
Expand Down
157 changes: 157 additions & 0 deletions src/kernelbot/cogs/admin_cog.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ def __init__(self, bot: "ClusterBot"):
name="set-forum-ids", description="Sets forum IDs"
)(self.set_forum_ids)

self.set_submission_rate_limit = bot.admin_group.command(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my thinking is we would instead say something like "you can't submit anything new, until your last request goes through or some timeout expires" - please reach out to mods if you'd like to be overriden

name="set-submission-rate-limit",
description="Set default or per-user submission rate limit (submissions/minute).",
)(self.set_submission_rate_limit)

self.get_submission_rate_limit = bot.admin_group.command(
name="get-submission-rate-limit",
description="Get default or per-user submission rate limit (submissions/minute).",
)(self.get_submission_rate_limit)

self._scheduled_cleanup_temp_users.start()

# --------------------------------------------------------------------------
Expand Down Expand Up @@ -512,6 +522,153 @@ async def start(self, interaction: discord.Interaction):
interaction, "Bot will accept submissions again!", ephemeral=True
)

def _parse_user_id_arg(self, user_id: str) -> str:
"""Accepts a raw id or a discord mention and returns the id string."""
s = (user_id or "").strip()
if s.startswith("<@") and s.endswith(">"):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you have some example tests for this function?

s = s[2:-1].strip()
if s.startswith("!"):
s = s[1:].strip()
return s

def _format_rate(self, rate: float | None) -> str:
if rate is None:
return "unlimited"
r = float(rate)
if r == 0:
return "blocked"
return f"{r:g}/min"

@app_commands.describe(
rate_per_minute="Rate in submissions/minute. Use 'none' for unlimited; 'default' clears a user override.", # noqa: E501
user_id="Optional user id or mention. If omitted, sets the default.",
)
@with_error_handling
async def set_submission_rate_limit(
self,
interaction: discord.Interaction,
rate_per_minute: str,
user_id: Optional[str] = None,
):
is_admin = await self.admin_check(interaction)
if not is_admin:
await send_discord_message(
interaction,
"You need to be Admin to use this command.",
ephemeral=True,
)
return

rate_s = (rate_per_minute or "").strip().lower()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this code is too defensive, it's mainly us mods making the change right? in which case we don't need to be this paranaoid

Also now as a mod i'm not sure what value should i set closer to 1 or 0.5, it's partly why I favor unlimited or at most one submission just cause it's easier for me to intuit what to put in

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this code is too defensive, it's mainly us mods making the change right? in which case we don't need to be this paranaoid

Also now as a mod i'm not sure what value should i set closer to 1 or 0.5, it's partly why I favor unlimited or at most one submission just cause it's easier for me to intuit what to put in

if rate_s in {"none", "unlimited", "off"}:
parsed_rate: float | None = None
clear_override = False
elif rate_s == "default":
parsed_rate = None
clear_override = True
else:
try:
parsed_rate = float(rate_s)
except ValueError:
await send_discord_message(
interaction,
"Invalid rate. Use a number like `1` or `0.5`, or `none`.",
ephemeral=True,
)
return
if parsed_rate < 0:
await send_discord_message(
interaction,
"Invalid rate. Must be >= 0 (or `none`).",
ephemeral=True,
)
return
clear_override = False

with self.bot.leaderboard_db as db:
if user_id is None or user_id.strip() == "":
if clear_override:
await send_discord_message(
interaction,
"For default limit, use a number or `none` (not `default`).",
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parsing logic for rate_per_minute has overlapping semantics that could confuse users. Both 'none' and 'default' set parsed_rate to None, but they have different meanings (unlimited vs. clear override). When a user sets the default rate to 'none' (unlimited), and then later tries to set it to 'default', they get an error message. However, the parameter description doesn't clearly explain that 'default' is only valid for per-user overrides, not for the default rate itself. Consider renaming the special value to something more explicit like 'remove' or 'clear' to make it obvious it's for removing user overrides.

Suggested change
"For default limit, use a number or `none` (not `default`).",
"For the default submission limit, use a number or `none` (unlimited). "
"The special value `default` is only valid when setting a per-user limit, "
"where it clears that user's override so the default applies.",

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with AI

ephemeral=True,
)
return
db.set_default_submission_rate_limit(parsed_rate)
await send_discord_message(
interaction,
f"Default submission rate limit set to **{self._format_rate(parsed_rate)}**.",
ephemeral=True,
)
return

uid = self._parse_user_id_arg(user_id)
if uid == "":
await send_discord_message(
interaction,
"Invalid user id.",
ephemeral=True,
)
return

if clear_override:
db.clear_user_submission_rate_limit(uid)
await send_discord_message(
interaction,
f"Cleared per-user submission rate limit override for `{uid}` (default now applies).", # noqa: E501
ephemeral=True,
)
return

db.set_user_submission_rate_limit(uid, parsed_rate)
await send_discord_message(
interaction,
f"Submission rate limit for `{uid}` set to **{self._format_rate(parsed_rate)}**.",
ephemeral=True,
)

@app_commands.describe(
user_id="Optional user id or mention. If omitted, shows the default.",
)
@with_error_handling
async def get_submission_rate_limit(
self,
interaction: discord.Interaction,
user_id: Optional[str] = None,
):
is_admin = await self.admin_check(interaction)
if not is_admin:
await send_discord_message(
interaction,
"You need to be Admin to use this command.",
ephemeral=True,
)
return

with self.bot.leaderboard_db as db:
if user_id is None or user_id.strip() == "":
default_rate, capacity = db.get_default_submission_rate_limit()
msg = (
f"Default submission rate limit: **{self._format_rate(default_rate)}**\n"
f"Bucket capacity: **{capacity:g}**"
)
await send_discord_message(interaction, msg, ephemeral=True)
return

uid = self._parse_user_id_arg(user_id)
effective, has_override, user_rate, default_rate, capacity = (
db.get_submission_rate_limits(uid)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just make sure,
it seems there is no creation for user limit if users only has web access
db.set_user_submission_rate_limit seems only happens for discrod.

I think for web/cli, you also need

  1. check if it's admin
  2. if it's not and the user's limit not in db, create it
  3. if exist, check remaining

meanwhile, i will update rate limit on FE side to be 10/minute too

)
override_text = "default" if not has_override else self._format_rate(user_rate)
msg = (
f"User `{uid}` submission rate limit:\n"
f"- Effective: **{self._format_rate(effective)}**\n"
f"- User override: **{override_text}**\n"
f"- Default: **{self._format_rate(default_rate)}**\n"
f"- Bucket capacity: **{capacity:g}**"
)
await send_discord_message(interaction, msg, ephemeral=True)

@app_commands.describe(
problem_set="Which problem set to load.",
repository_name="Name of the repository to load problems from (in format: user/repo)",
Expand Down
Loading
Loading