Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
6514fc7
feat(banked-reset): initial commit
huzky-v Jun 17, 2026
142514b
ui adjustments
huzky-v Jun 17, 2026
144855d
review
huzky-v Jun 18, 2026
75a8ae5
ui adjustments
huzky-v Jun 18, 2026
77560b6
feat(banked-reset): add api for query and redeem the credit (not in c…
huzky-v Jun 19, 2026
75002fd
fix api to get all redeemable credit
huzky-v Jun 19, 2026
10f685b
ty & ruff
huzky-v Jun 19, 2026
433b956
review
huzky-v Jun 19, 2026
7bf987c
fix(reset-credits): invalidate stale snapshots for ineligible accounts
huzky-v Jun 19, 2026
8973159
fix(reset-credits): critical consume hardening and zero-db migration …
ellentane Jun 21, 2026
cebee84
fix(test): mock timers to prevent input-otp leaks in auth-gate
ellentane Jun 21, 2026
753e45f
fix(web): add space between datetime and countdown in reset credit di…
ellentane Jun 21, 2026
6062e6c
chore(web): remove auto-generated package-lock.json
ellentane Jun 21, 2026
3c37785
fix(api): address codex review findings for rate limit reset credits
ellentane Jun 21, 2026
7746ffc
Align reset-credit eligibility and confirmation-dialog specs with cur…
huzky-v Jun 22, 2026
3219de4
Disable default retries for reset credit redemption
huzky-v Jun 22, 2026
c8c4c2d
fix(reset-credits): stop status writes on 401, suppress stale reauth …
huzky-v Jun 22, 2026
99b9eb5
Treat false force-refresh results as failures
huzky-v Jun 22, 2026
fd02a54
Fix reset-credit consume audit and refresh cache invalidation
huzky-v Jun 22, 2026
f84d3cd
Clear skipped account snapshots before they can be reused
huzky-v Jun 22, 2026
acfa251
Invalidate stale reset-credit cache on empty fresh fetch
huzky-v Jun 22, 2026
a3c79c9
ruff & ty
huzky-v Jun 22, 2026
4d5f43a
ruff
huzky-v Jun 22, 2026
ed5f5de
Refresh usage after v1 reset-credit redemption
huzky-v Jun 22, 2026
dd35115
Refresh account tokens before redeeming reset credits
huzky-v Jun 22, 2026
67b0a32
Handle refresh failures before redeeming reset credits
huzky-v Jun 22, 2026
2bc574a
Preserve successful reset-credit redemption response
huzky-v Jun 22, 2026
80be8a6
ruff
huzky-v Jun 22, 2026
b738dca
Gate dashboard redemption on cached snapshot
huzky-v Jun 22, 2026
5c4722b
ruff
huzky-v Jun 22, 2026
6832cdc
docs: add @ellentane as a contributor
huzky-v Jun 22, 2026
ffd5102
fix reset-credit eligibility and confirm dialog closing
huzky-v Jun 22, 2026
6fd64f4
Keep reset-credit GET cache-only
huzky-v Jun 22, 2026
6bf30df
Revert " Keep reset-credit GET cache-only"
huzky-v Jun 22, 2026
0c6eaa1
Serialize reset-credit redemption across replicas
huzky-v Jun 22, 2026
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
29 changes: 29 additions & 0 deletions app/core/clients/headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from __future__ import annotations

from app.core.utils.request_id import get_request_id


def build_chatgpt_auth_headers(
access_token: str,
account_id: str | None,
*,
extra: dict[str, str] | None = None,
) -> dict[str, str]:
"""Build the headers required to call ChatGPT ``backend-api`` endpoints.

Includes the OAuth bearer token and the ``chatgpt-account-id`` header. The
account-id header is omitted when the id is a synthetic ``email_``/``local_``
prefix, matching upstream behavior. An active request id (if any) is attached.
"""
headers: dict[str, str] = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
}
request_id = get_request_id()
if request_id:
headers["x-request-id"] = request_id
if account_id and not account_id.startswith(("email_", "local_")):
headers["chatgpt-account-id"] = account_id
if extra:
headers.update(extra)
return headers
284 changes: 284 additions & 0 deletions app/core/clients/rate_limit_reset_credits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
from __future__ import annotations

import asyncio
import logging
import uuid
from datetime import datetime

import aiohttp
from aiohttp_retry import ExponentialRetry, RetryClient
from pydantic import BaseModel, ConfigDict, Field, ValidationError

from app.core.clients.headers import build_chatgpt_auth_headers
from app.core.clients.http import lease_retry_client
from app.core.config.settings import get_settings
from app.core.types import JsonObject
from app.core.utils.request_id import get_request_id

RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
RETRY_START_TIMEOUT = 0.5
RETRY_MAX_TIMEOUT = 2.0

logger = logging.getLogger(__name__)


class ResetCreditFetchError(Exception):
def __init__(self, status_code: int, message: str, code: str | None = None) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
self.code = code


class ConsumeResetCreditError(Exception):
def __init__(self, status_code: int, message: str, code: str | None = None) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
self.code = code


class ResetCreditItem(BaseModel):
model_config = ConfigDict(extra="ignore")

id: str
reset_type: str | None = None
status: str | None = None
granted_at: datetime | None = None
expires_at: datetime | None = None
title: str | None = None
description: str | None = None
redeem_started_at: datetime | None = None
redeemed_at: datetime | None = None


class ResetCreditsResponse(BaseModel):
model_config = ConfigDict(extra="ignore")

credits: list[ResetCreditItem]
available_count: int


class ConsumeResetCreditCredit(BaseModel):
model_config = ConfigDict(extra="ignore")

id: str | None = None
reset_type: str | None = None
status: str | None = None
redeemed_at: datetime | None = None


class ConsumeResetCreditResponse(BaseModel):
model_config = ConfigDict(extra="ignore")

code: str
credit: ConsumeResetCreditCredit
windows_reset: int


class RateLimitResetCreditsSnapshot(BaseModel):
"""In-memory snapshot of a single account's banked reset credits.

Carries the upstream ``available_count``, the soonest expiry among the
available credits, and the full credit list so dashboard endpoints can
render details and the consume path can re-select at click time.
"""

model_config = ConfigDict(extra="ignore")

available_count: int = 0
nearest_expires_at: datetime | None = None
credits: list[ResetCreditItem] = Field(default_factory=list)


async def fetch_reset_credits(
access_token: str,
account_id: str | None,
*,
base_url: str | None = None,
timeout_seconds: float | None = None,
max_retries: int | None = None,
client: RetryClient | None = None,
) -> ResetCreditsResponse:
settings = get_settings()
usage_base = base_url or settings.upstream_base_url
url = _reset_credits_url(usage_base)
timeout = aiohttp.ClientTimeout(total=timeout_seconds or settings.usage_fetch_timeout_seconds)
retries = max_retries if max_retries is not None else settings.usage_fetch_max_retries
headers = build_chatgpt_auth_headers(access_token, account_id)
retry_options = _retry_options(retries + 1)

try:
async with lease_retry_client(client) as retry_client:
async with retry_client.request(
"GET",
url,
headers=headers,
timeout=timeout,
retry_options=retry_options,
) as resp:
data = await _safe_json(resp)
if resp.status >= 400:
code = _extract_error_code(data)
message = _extract_error_message(data) or f"Reset credits fetch failed ({resp.status})"
logger.warning(
"Reset credits fetch failed request_id=%s status=%s code=%s message=%s",
get_request_id(),
resp.status,
code,
message,
)
raise ResetCreditFetchError(resp.status, message, code=code)
try:
return ResetCreditsResponse.model_validate(_success_payload(data))
except (ValueError, ValidationError) as exc:
logger.warning("Reset credits fetch invalid payload request_id=%s", get_request_id())
raise ResetCreditFetchError(502, "Invalid reset credits payload") from exc
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
logger.warning("Reset credits fetch error request_id=%s error=%s", get_request_id(), exc)
raise ResetCreditFetchError(0, f"Reset credits fetch failed: {exc}") from exc


async def consume_reset_credit(
access_token: str,
account_id: str | None,
credit_id: str,
*,
base_url: str | None = None,
timeout_seconds: float | None = None,
max_retries: int | None = None,
client: RetryClient | None = None,
) -> ConsumeResetCreditResponse:
settings = get_settings()
usage_base = base_url or settings.upstream_base_url
url = _consume_url(usage_base)
timeout = aiohttp.ClientTimeout(total=timeout_seconds or settings.usage_fetch_timeout_seconds)
retries = max_retries if max_retries is not None else settings.usage_fetch_max_retries
headers = build_chatgpt_auth_headers(
access_token,
account_id,
extra={"Content-Type": "application/json"},
)
redeem_request_id = str(uuid.uuid4())
body = {"credit_id": credit_id, "redeem_request_id": redeem_request_id}
retry_options = _retry_options(retries + 1)

try:
async with lease_retry_client(client) as retry_client:
async with retry_client.request(
Comment thread
huzky-v marked this conversation as resolved.
"POST",
url,
headers=headers,
json=body,
timeout=timeout,
retry_options=retry_options,
) as resp:
data = await _safe_json(resp)
if resp.status >= 400:
code = _extract_error_code(data)
message = _extract_error_message(data) or f"Reset credits consume failed ({resp.status})"
logger.warning(
"Reset credits consume failed request_id=%s status=%s code=%s message=%s",
get_request_id(),
resp.status,
code,
message,
)
raise ConsumeResetCreditError(resp.status, message, code=code)
try:
return ConsumeResetCreditResponse.model_validate(_success_payload(data))
except (ValueError, ValidationError) as exc:
logger.warning("Reset credits consume invalid payload request_id=%s", get_request_id())
raise ConsumeResetCreditError(502, "Invalid reset credits consume payload") from exc
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
logger.warning("Reset credits consume error request_id=%s error=%s", get_request_id(), exc)
raise ConsumeResetCreditError(0, f"Reset credits consume failed: {exc}") from exc


def build_snapshot(response: ResetCreditsResponse) -> RateLimitResetCreditsSnapshot:
"""Project an upstream list response into the cached snapshot shape."""
nearest = _nearest_available_expires_at(response.credits)
return RateLimitResetCreditsSnapshot(
available_count=response.available_count,
nearest_expires_at=nearest,
credits=list(response.credits),
)


def _nearest_available_expires_at(credits: list[ResetCreditItem]) -> datetime | None:
candidates = [
credit.expires_at for credit in credits if credit.status == "available" and credit.expires_at is not None
]
return min(candidates) if candidates else None


def _reset_credits_url(base_url: str) -> str:
normalized = base_url.rstrip("/")
if "/backend-api" not in normalized:
normalized = f"{normalized}/backend-api"
return f"{normalized}/wham/rate-limit-reset-credits"


def _consume_url(base_url: str) -> str:
return f"{_reset_credits_url(base_url)}/consume"


async def _safe_json(resp: aiohttp.ClientResponse) -> JsonObject:
try:
data = await resp.json(content_type=None)
except Exception:
text = await resp.text()
return {"error": {"message": text.strip()}}
return data if isinstance(data, dict) else {"error": {"message": str(data)}}


def _success_payload(payload: JsonObject) -> JsonObject:
if "error" in payload:
raise ValueError("success response carried error payload")
return payload


class _ErrorEnvelope(BaseModel):
model_config = ConfigDict(extra="ignore")

error: dict[str, str | None] | str | None = None
error_description: str | None = None
message: str | None = None


def _extract_error_message(payload: JsonObject) -> str | None:
envelope = _ErrorEnvelope.model_validate(payload)
error = envelope.error
if isinstance(error, dict):
message = error.get("message")
if isinstance(message, str) and message:
return message
description = error.get("error_description")
if isinstance(description, str) and description:
return description
if isinstance(error, str) and error:
return envelope.error_description or error
return envelope.message


def _extract_error_code(payload: JsonObject) -> str | None:
envelope = _ErrorEnvelope.model_validate(payload)
error = envelope.error
if isinstance(error, dict):
code = error.get("code")
if isinstance(code, str):
normalized = code.strip().lower()
return normalized or None
return None


def _retry_options(attempts: int) -> ExponentialRetry:
return ExponentialRetry(
attempts=attempts,
start_timeout=RETRY_START_TIMEOUT,
max_timeout=RETRY_MAX_TIMEOUT,
factor=2.0,
statuses=RETRYABLE_STATUS,
exceptions={aiohttp.ClientError, asyncio.TimeoutError},
retry_all_server_errors=False,
)
9 changes: 2 additions & 7 deletions app/core/clients/usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
create_codex_session,
require_route_or_direct_egress_opt_in,
)
from app.core.clients.headers import build_chatgpt_auth_headers
from app.core.clients.http import lease_retry_client
from app.core.config.settings import get_settings
from app.core.types import JsonObject
Expand Down Expand Up @@ -220,13 +221,7 @@ def _usage_url(base_url: str) -> str:


def _usage_headers(access_token: str, account_id: str | None) -> dict[str, str]:
headers = {"Authorization": f"Bearer {access_token}", "Accept": "application/json"}
request_id = get_request_id()
if request_id:
headers["x-request-id"] = request_id
if account_id and not account_id.startswith(("email_", "local_")):
headers["chatgpt-account-id"] = account_id
return headers
return build_chatgpt_auth_headers(access_token, account_id)


async def _safe_json(resp: aiohttp.ClientResponse) -> JsonObject:
Expand Down
1 change: 1 addition & 0 deletions app/core/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ class Settings(BaseSettings):
usage_fetch_max_retries: int = 2
usage_refresh_enabled: bool = True
usage_refresh_interval_seconds: int = Field(default=60, gt=0)
rate_limit_reset_credits_refresh_interval_seconds: int = Field(default=60, gt=0)
openai_cache_affinity_max_age_seconds: int = Field(default=1800, gt=0)
warmup_model: str = "gpt-5.4-mini"
openai_prompt_cache_key_derivation_enabled: bool = True
Expand Down
5 changes: 5 additions & 0 deletions app/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,8 @@ class DashboardRateLimitError(AppError):
def __init__(self, message: str, *, retry_after: int, code: str | None = None) -> None:
self.retry_after = retry_after
super().__init__(message, code=code)


class DashboardServiceUnavailableError(AppError):
status_code = 503
code = "service_unavailable"
2 changes: 2 additions & 0 deletions app/core/handlers/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
DashboardNotFoundError,
DashboardPermissionError,
DashboardRateLimitError,
DashboardServiceUnavailableError,
DashboardValidationError,
ProxyAuthError,
ProxyModelNotAllowed,
Expand All @@ -45,6 +46,7 @@
DashboardBadRequestError,
DashboardValidationError,
DashboardRateLimitError,
DashboardServiceUnavailableError,
)


Expand Down
Loading