Skip to content
Merged
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
9 changes: 2 additions & 7 deletions backend/druks/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,8 @@ async def _quota_retry_wait() -> float:
scrape = UsageScrape.latest_for(harness.name, connection.account_id)
if scrape:
now = datetime.now(UTC)
# The nearest window still ahead: the five-hour one usually
# turns first; an exhausted week is what the caller caps on.
reset = scrape.five_hour_resets_at
if reset and reset > now:
return (reset - now).total_seconds()
reset = scrape.week_resets_at
if reset and reset > now:
reset = scrape.soonest_reset_after(now)
if reset:
return (reset - now).total_seconds()
# No scrape yet, or nothing ahead of now: a blind half hour.
return _QUOTA_FALLBACK_WAIT_SECONDS
Expand Down
9 changes: 5 additions & 4 deletions backend/druks/harnesses/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import TYPE_CHECKING, ClassVar

import httpx
from pydantic import TypeAdapter

from druks.database import db_session
from druks.mcp import models as mcp_models
Expand All @@ -29,6 +30,7 @@
CompletedConnect,
HarnessRunResult,
OAuthToken,
ParsedMetric,
ParsedModels,
ParsedUsage,
RotationResult,
Expand Down Expand Up @@ -58,6 +60,8 @@

Token = OAuthToken | CodexToken

_WEEKLY_WINDOWS = TypeAdapter(tuple[ParsedMetric, ...])


class Harness(ABC):
name: str
Expand Down Expand Up @@ -497,10 +501,7 @@ async def poll_usage(cls, connection: HarnessConnection) -> dict[str, object]:
if parsed.five_hour:
snapshot.five_hour_percent_left = parsed.five_hour.percent_left
snapshot.five_hour_resets_at = parsed.five_hour.resets_at
if parsed.week:
snapshot.week_percent_left = parsed.week.percent_left
snapshot.week_resets_at = parsed.week.resets_at
snapshot.week_model = parsed.week.model
snapshot.weeks = _WEEKLY_WINDOWS.dump_python(parsed.weeks, mode="json")
snapshot.save()
return {
"harness": cls.name,
Expand Down
18 changes: 9 additions & 9 deletions backend/druks/harnesses/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,10 +324,10 @@ def _parse_usage(cls, raw: str) -> ParsedUsage:
if not isinstance(data, dict) or not any(k in data for k in ("five_hour", "seven_day")):
return ParsedUsage(ok=False, error="unexpected_payload", raw=raw)
try:
five_hour, week = _claude_windows(data)
five_hour, weeks = _claude_windows(data)
except (KeyError, TypeError, ValueError):
return ParsedUsage(ok=False, error="unexpected_payload", raw=raw)
return ParsedUsage(ok=True, five_hour=five_hour, week=week, raw=raw)
return ParsedUsage(ok=True, five_hour=five_hour, weeks=weeks, raw=raw)

@classmethod
def _parse_models(cls, raw: str) -> ParsedModels:
Expand Down Expand Up @@ -355,9 +355,8 @@ def _oauth_block(data: dict) -> dict:
return block if isinstance(block, dict) else data


def _claude_windows(data: dict) -> tuple[ParsedMetric | None, ParsedMetric | None]:
"""The five-hour and weekly quotas that bind. A plan can meter one model
tighter than the rest, and that limit stops work first."""
def _claude_windows(data: dict) -> tuple[ParsedMetric | None, tuple[ParsedMetric, ...]]:
"""The binding five-hour window and every weekly window in provider order."""
five_hour, weekly = [], []
for limit in data.get("limits") or []:
# A limit can be scoped to something other than a model, which leaves
Expand All @@ -372,10 +371,11 @@ def _claude_windows(data: dict) -> tuple[ParsedMetric | None, ParsedMetric | Non
weekly.append(window)
elif limit["group"] == "session":
five_hour.append(window)
return (
ParsedMetric.binding(five_hour) or _claude_metric(data.get("five_hour")),
ParsedMetric.binding(weekly) or _claude_metric(data.get("seven_day")),
)
if not weekly and (fallback_week := _claude_metric(data.get("seven_day"))):
weekly.append(fallback_week)
binding_five_hour = ParsedMetric.binding(five_hour) or _claude_metric(data.get("five_hour"))
weekly_windows = tuple(weekly)
return binding_five_hour, weekly_windows


def _claude_metric(block: object) -> ParsedMetric | None:
Expand Down
19 changes: 10 additions & 9 deletions backend/druks/harnesses/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,10 +430,10 @@ def _parse_usage(cls, raw: str) -> ParsedUsage:
return ParsedUsage(ok=False, error="unexpected_payload", raw=raw)
plan = data.get("plan_type") if isinstance(data.get("plan_type"), str) else None
try:
five_hour, week = _codex_windows(data)
five_hour, weeks = _codex_windows(data)
except (AttributeError, KeyError, TypeError, ValueError):
return ParsedUsage(ok=False, error="unexpected_payload", plan_tier=plan, raw=raw)
if not five_hour and not week:
if not five_hour and not weeks:
# Business/enterprise accounts with unlimited credits carry
# ``rate_limit: null`` — no windows is the expected shape, not
# a parse failure. Report permanently-full buckets.
Expand All @@ -444,7 +444,7 @@ def _parse_usage(cls, raw: str) -> ParsedUsage:
ok=True,
plan_tier=plan,
five_hour=full,
week=full,
weeks=(full,),
unlimited=True,
raw=raw,
)
Expand All @@ -453,7 +453,7 @@ def _parse_usage(cls, raw: str) -> ParsedUsage:
ok=True,
plan_tier=plan,
five_hour=five_hour,
week=week,
weeks=weeks,
raw=raw,
)

Expand Down Expand Up @@ -708,10 +708,11 @@ def _codex_credentials(
)


def _codex_windows(usage: dict) -> tuple[ParsedMetric | None, ParsedMetric | None]:
"""The five-hour and weekly quotas that bind. A window's declared length
names it, not the slot it arrives in, and a separately metered model
exhausts before the account-wide quota does."""
def _codex_windows(usage: dict) -> tuple[ParsedMetric | None, tuple[ParsedMetric, ...]]:
"""The binding five-hour window and every weekly window in provider order.

A window's declared length names it, not the slot it arrives in.
"""
rate_limits = [(None, usage["rate_limit"] or {})]
for metered in usage.get("additional_rate_limits") or []:
rate_limits.append((metered["limit_name"], metered["rate_limit"] or {}))
Expand All @@ -731,4 +732,4 @@ def _codex_windows(usage: dict) -> tuple[ParsedMetric | None, ParsedMetric | Non
weekly.append(window)
else:
five_hour.append(window)
return ParsedMetric.binding(five_hour), ParsedMetric.binding(weekly)
return ParsedMetric.binding(five_hour), tuple(weekly)
2 changes: 1 addition & 1 deletion backend/druks/harnesses/datastructures.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ class ParsedUsage:
error: str | None = None
plan_tier: str | None = None
five_hour: ParsedMetric | None = None
week: ParsedMetric | None = None
weeks: tuple[ParsedMetric, ...] = ()
# Unmetered plan (e.g. Codex business with unlimited credits). The
# windows above are synthesized permanently-full buckets; consumers
# should render "unmetered" rather than a quota that never moves.
Expand Down
9 changes: 5 additions & 4 deletions backend/druks/mcp/gateway/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,18 +136,19 @@ def _harness_usage(name: str, account_id: str, *, now: datetime) -> schemas.Agen
if point.five_hour_percent_left is not None and point.scraped_at >= five_hour_cutoff
]
week = [
UsageHistoryPoint(t=point.scraped_at, pct=point.week_percent_left)
UsageHistoryPoint(t=point.scraped_at, pct=binding["percent_left"])
for point in history
if point.week_percent_left is not None
if (binding := point.binding_week())
]
binding_week = row.binding_week() or {}
return schemas.AgentHarnessUsage(
name=name,
is_connected=is_connected,
plan_tier=row.plan_tier,
five_hour_percent_left=row.five_hour_percent_left,
five_hour_resets_at=row.five_hour_resets_at,
week_percent_left=row.week_percent_left,
week_resets_at=row.week_resets_at,
week_percent_left=binding_week.get("percent_left"),
week_resets_at=binding_week.get("resets_at"),
is_unlimited=row.unlimited,
scraped_at=row.scraped_at,
five_hour_history=downsample(five_hour, cap=_HISTORY_POINTS),
Expand Down
28 changes: 22 additions & 6 deletions backend/druks/usage/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from datetime import datetime, timedelta
from typing import Any

from sqlalchemy import ForeignKey, Index, delete, select
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column

from druks.db import Base, db_session
Expand Down Expand Up @@ -33,12 +35,8 @@ class UsageScrape(Base):
# doesn't have a 5h concept yet so it stays null for the codex row.
five_hour_percent_left: Mapped[int | None]
five_hour_resets_at: Mapped[datetime | None]
# Weekly window — both CLIs expose this.
week_percent_left: Mapped[int | None]
week_resets_at: Mapped[datetime | None]
# Set when the weekly window meters one model separately from the
# rest, naming it. None covers every model.
week_model: Mapped[str | None]
# Weekly windows in provider order, including separately metered models.
weeks: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, default=list)
# Unmetered plan (Codex business/enterprise with unlimited credits).
# The window percentages above are synthesized permanently-full
# buckets when this is set — the UI renders "unmetered" instead of
Expand Down Expand Up @@ -69,6 +67,24 @@ def history_for(cls, harness: str, account_id: str, *, since: datetime) -> list[
)
return list(db_session().execute(stmt).scalars())

def binding_week(self) -> dict[str, Any] | None:
"""The window closest to exhaustion — whichever stops work first."""
reported_windows = [week for week in self.weeks if week["percent_left"] is not None]
if reported_windows:
return min(reported_windows, key=lambda week: week["percent_left"])

def soonest_reset_after(self, now: datetime) -> datetime | None:
resets = []
if self.five_hour_resets_at and self.five_hour_resets_at > now:
resets.append(self.five_hour_resets_at)
for week in self.weeks:
if week["resets_at"]:
reset = datetime.fromisoformat(week["resets_at"])
if reset > now:
resets.append(reset)
if resets:
return min(resets)

def save(self) -> None:
if not self.scraped_at:
self.scraped_at = Base.utc_now()
Expand Down
39 changes: 23 additions & 16 deletions backend/druks/usage/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
UsageMetricSummary,
UsageResponse,
UsageTodayResponse,
UsageWindowHistory,
)
from druks.usage.trends import FIVE_HOUR_RANGE, WEEK_RANGE, downsample
from druks.user_settings.models import HarnessSettings, UserSettings
Expand Down Expand Up @@ -146,15 +147,23 @@ def _harness_history(name: str, account_id: str, *, now: datetime) -> UsageHarne
for row in rows
if row.five_hour_percent_left is not None and row.scraped_at >= five_hour_cutoff
]
week = [
UsageHistoryPoint(t=row.scraped_at, pct=row.week_percent_left)
for row in rows
if row.week_percent_left is not None
]
weekly_points: dict[str | None, list[UsageHistoryPoint]] = {}
for row in rows:
for week in row.weeks:
if week["percent_left"] is not None:
weekly_points.setdefault(week["model"], []).append(
UsageHistoryPoint(t=row.scraped_at, pct=week["percent_left"])
)
return UsageHarnessHistory(
name=name,
five_hour=downsample(five_hour, cap=_MAX_SPARK_POINTS),
week=downsample(week, cap=_MAX_SPARK_POINTS),
weeks=[
UsageWindowHistory(
model=model,
points=downsample(points, cap=_MAX_SPARK_POINTS),
)
for model, points in weekly_points.items()
],
)


Expand All @@ -168,13 +177,19 @@ def _summarize(
if not row:
return UsageHarnessSummary(name=name, available=False, connected=connected)
age = _age_seconds(row.scraped_at, now=now)
five_hour = None
if row.five_hour_percent_left is not None or row.five_hour_resets_at:
five_hour = UsageMetricSummary(
percent_left=row.five_hour_percent_left,
resets_at=row.five_hour_resets_at,
)
return UsageHarnessSummary(
name=name,
available=row.parse_ok,
connected=connected,
plan_tier=row.plan_tier,
five_hour=_metric(row.five_hour_percent_left, row.five_hour_resets_at),
week=_metric(row.week_percent_left, row.week_resets_at, row.week_model),
five_hour=five_hour,
weeks=[UsageMetricSummary.model_validate(week) for week in row.weeks],
unlimited=row.unlimited,
scraped_at=row.scraped_at,
age_seconds=age,
Expand All @@ -184,14 +199,6 @@ def _summarize(
)


def _metric(
percent_left: int | None, resets_at: datetime | None, model: str | None = None
) -> UsageMetricSummary | None:
if percent_left is None and resets_at is None:
return
return UsageMetricSummary(percent_left=percent_left, resets_at=resets_at, model=model)


def _age_seconds(scraped_at: datetime | None, *, now: datetime) -> int | None:
if not scraped_at:
return
Expand Down
15 changes: 10 additions & 5 deletions backend/druks/usage/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class UsageHarnessSummary(BaseResponse):
connected: bool
plan_tier: str | None = None
five_hour: UsageMetricSummary | None = None
week: UsageMetricSummary | None = None
weeks: list[UsageMetricSummary] = Field(default_factory=list)
# Unmetered plan (Codex business/enterprise). The window buckets are
# synthesized permanently-full — the UI shows "unmetered" plus
# actual consumption from druks' own run records instead of a
Expand Down Expand Up @@ -59,14 +59,19 @@ class UsageHistoryPoint(BaseResponse):
pct: int


class UsageWindowHistory(BaseResponse):
model: str | None = None
points: list[UsageHistoryPoint] = Field(default_factory=list)


class UsageHarnessHistory(BaseResponse):
name: str
# Percent-left samples, oldest first. ``five_hour`` covers the last
# ~6h (one full 5h window plus headroom); ``week`` covers the last
# 7 days, downsampled. Either list is empty when the harness never
# reported that window.
# ~6h (one full 5h window plus headroom); ``weeks`` covers the last
# 7 days as one downsampled series per weekly window. Either list is
# empty when the harness never reported that window.
five_hour: list[UsageHistoryPoint] = Field(default_factory=list)
week: list[UsageHistoryPoint] = Field(default_factory=list)
weeks: list[UsageWindowHistory] = Field(default_factory=list)


class UsageHistoryResponse(BaseResponse):
Expand Down
Loading