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
4 changes: 4 additions & 0 deletions app/core/usage/refresh_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ async def _refresh_as_leader(self) -> float:
selected_account, cycle_complete = self._select_next_account(accounts)
if selected_account is not None:
selected_account_ids = [selected_account.id]
previous_plan_types = {
selected_account.id: normalize_account_plan_type(selected_account.plan_type)
}
before_primary = await usage_repo.latest_by_account(
window="primary",
account_ids=selected_account_ids,
Expand Down Expand Up @@ -283,6 +286,7 @@ async def _refresh_as_leader(self) -> float:
monthly_entries=warmup_after_monthly,
secondary_entries=after_secondary,
),
previous_plan_types=previous_plan_types,
refresh_started_at=refresh_started_at,
usage_refresh_interval_seconds=self.interval_seconds,
)
Expand Down
39 changes: 38 additions & 1 deletion app/modules/limit_warmup/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from app.core.openai.models import OpenAIError, ResponseUsage
from app.core.openai.parsing import parse_sse_event
from app.core.openai.requests import ResponsesRequest
from app.core.plan_types import account_plan_matches_allowed
from app.core.plan_types import account_plan_matches_allowed, normalize_account_plan_type
from app.core.upstream_proxy import ResolvedUpstreamRoute, UpstreamProxyRouteError, resolve_upstream_route
from app.core.usage.pricing import get_pricing_for_model
from app.core.utils.time import naive_utc_to_epoch, utcnow
Expand Down Expand Up @@ -342,6 +342,7 @@ async def run_after_usage_refresh(
before_secondary: dict[str, UsageHistory],
after_primary: dict[str, UsageHistory],
after_secondary: dict[str, UsageHistory],
previous_plan_types: dict[str, str | None] | None = None,
refresh_started_at: datetime | None = None,
usage_refresh_interval_seconds: int = _STAGGER_SLOT_GRACE_SECONDS,
) -> None:
Expand Down Expand Up @@ -388,6 +389,14 @@ async def run_after_usage_refresh(
after_secondary=after_secondary,
min_available_percent=settings.limit_warmup_min_available_percent,
)
if candidate is None and window == "secondary":
candidate = _build_paid_to_free_transition_candidate(
account=account,
previous_plan_type=(previous_plan_types or {}).get(account.id),
after_secondary=after_secondary,
refresh_started_at=refresh_started_at,
min_available_percent=settings.limit_warmup_min_available_percent,
)
if (
candidate is None
and _account_is_safe_candidate(account)
Expand Down Expand Up @@ -745,6 +754,34 @@ def usage_reset_confirmed(*, before: UsageHistory | None, after: UsageHistory |
return True


def _build_paid_to_free_transition_candidate(
*,
account: Account,
previous_plan_type: str | None,
after_secondary: dict[str, UsageHistory],
refresh_started_at: datetime | None,
min_available_percent: float,
) -> _WarmupCandidate | None:
normalized_previous_plan = normalize_account_plan_type(previous_plan_type)
if normalized_previous_plan is None or normalized_previous_plan == "free":
return None
if normalize_account_plan_type(account.plan_type) != "free":
return None
if refresh_started_at is None:
return None
after = after_secondary.get(account.id)
if after is None or after.window != "monthly" or after.reset_at is None:
return None
if after.recorded_at < refresh_started_at:
return None
if after.used_percent >= 100.0:
return None
available_percent = 100.0 - after.used_percent
if min_available_percent < 100.0 and available_percent < min_available_percent:
return None
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return _WarmupCandidate(reset_at=after.reset_at, window="monthly")


def _build_staggered_idle_candidate(
*,
account: Account,
Expand Down
76 changes: 76 additions & 0 deletions openspec/changes/warm-free-plan-transition/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
## Context

See `proposal.md` for motivation. The usage updater mutates and synchronizes the
selected account only after its existing paid-to-Free confirmation policy is
satisfied. The warm-up service currently sees only the post-refresh account and
requires matching canonical before/after windows, so it cannot distinguish a
confirmed plan transition from an account that was already Free.

The existing `usage_reset_confirmed` guard protects ordinary reset detection
from cross-window comparisons and timestamp drift. The transition path must not
weaken that guard.

## Goals / Non-Goals

**Goals:**

- Carry enough refresh-scoped evidence to identify a confirmed paid-to-Free
transition without introducing new persistent state.
- Require the monthly candidate to have been written by the same refresh and
to pass the existing availability, account, and global opt-in gates.
- Reuse the existing monthly warm-up identity and atomic claim.

**Non-Goals:**

- Changing paid-to-Free confirmation or ordinary same-window reset detection.
- Adding settings, schema, migrations, retry queues, or periodic backfill.
- Sending warm-up traffic to inactive or non-opted-in accounts.

## Decisions

### Snapshot the selected account plan before refresh

The scheduler will preserve the selected account's normalized pre-refresh plan
and pass it to warm-up evaluation after reloading the account. A transition is
eligible only when the snapshot is a recognized paid plan and the persisted
post-refresh plan is `free`.

Alternative considered: infer a transition from `secondary` to `monthly` usage
rows. That would incorrectly classify already-Free accounts whose first monthly
sample arrives after stale secondary history.

### Require a monthly sample written during the same refresh

The fallback candidate will accept only the selected long-window row when its
canonical window is `monthly`, it has a reset deadline, and its `recorded_at` is
at or after the refresh start. It will apply the existing minimum-availability
gate before returning a candidate.

Alternative considered: use the latest persisted monthly row regardless of
age. That could warm stale quota after an unrelated plan metadata update.

### Keep the transition as a fallback to normal reset detection

The service will first evaluate the existing same-window reset candidate. Only
when that returns no candidate for the configured long window will it evaluate
the paid-to-Free transition. The resulting candidate uses `window="monthly"`
and the monthly `reset_at`, so the existing atomic attempt claim provides
deduplication.

Alternative considered: alter `usage_reset_confirmed` to allow cross-window
transitions. That would weaken a safety guard used by status recovery and
ordinary warm-up paths.

## Risks / Trade-offs

- [A process exits after persisting plan and usage but before warm-up] → The
transition can be missed, matching the current event-triggered reset path;
avoid new persistence until stronger delivery semantics are required.
- [A future updater mutates plan before confirmation] → Keep regression coverage
at scheduler/service boundaries and rely on the updater's existing durable
two-observation confirmation contract.

## Migration Plan

No data migration is required. Deploy the code normally; rollback restores the
previous behavior without changing stored warm-up attempts or usage history.
35 changes: 35 additions & 0 deletions openspec/changes/warm-free-plan-transition/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## Why

A confirmed paid-to-Free plan change can replace the account's prior paid quota
window with a newly available monthly window. The existing same-window safety
guard correctly rejects arbitrary cross-window comparisons, but it also skips
the opted-in warm-up for this confirmed plan transition.

## What Changes

- Preserve the selected account's plan type across one background refresh.
- Treat a confirmed paid-to-Free transition that writes a fresh available
monthly sample as a long-window warm-up candidate.
- Keep ordinary reset detection restricted to matching canonical windows and
keep single, unconfirmed Free observations ineligible.
- Add regressions for the consumer-visible warm-up attempt and the safety
boundaries around unchanged plans and stale monthly history.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `usage-refresh-policy`: Allow an opted-in long-window warm-up after a
confirmed paid-to-Free transition opens a fresh monthly quota window.

## Impact

- Affected code: `app/core/usage/refresh_scheduler.py` and
`app/modules/limit_warmup/service.py`.
- Affected tests: focused scheduler and limit warm-up tests.
- No API, schema, migration, setting, dependency, dashboard, or deployment
change.
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
## ADDED Requirements

### Requirement: Confirmed paid-to-Free transitions warm the new monthly window

When background usage refresh confirms that an opted-in active account changed
from a recognized paid plan to `free`, and that confirming refresh writes a
fresh monthly usage sample with a reset deadline and enough available quota for
the configured warm-up threshold, the system SHALL attempt one long-window
warm-up for that monthly quota window. Eligibility MUST NOT depend on the usage
percentage reported before the plan change.

The plan-transition exception SHALL apply only to an actual paid-to-Free change
confirmed by the refresh that wrote the monthly sample. It MUST NOT apply to a
single unconfirmed Free observation, an account that was already Free, or a
monthly sample left over from an earlier refresh. Ordinary same-window reset
detection MUST remain unchanged. The durable warm-up identity SHALL remain the
account, canonical `monthly` window, and monthly reset deadline. The confirming
monthly sample MUST report `used_percent < 100`; the configured minimum-
available threshold MAY impose a stricter lower usage limit.

#### Scenario: Confirmed paid-to-Free transition warms fresh monthly quota

- **GIVEN** an active opted-in account whose stored plan is a recognized paid plan
- **WHEN** background usage refresh confirms its transition to `free`
- **AND** that confirming refresh writes a monthly sample with a reset deadline and enough available quota
- **THEN** the system attempts one warm-up identified by the account, `monthly` window, and monthly reset deadline

#### Scenario: Previous usage percentage does not gate plan-transition warm-up

- **GIVEN** an active opted-in paid account whose previous selected quota sample was not exhausted
- **WHEN** background usage refresh confirms its transition to `free` and writes an eligible fresh monthly sample
- **THEN** the system attempts the monthly warm-up regardless of the previous usage percentage

#### Scenario: One unconfirmed Free observation does not warm

- **GIVEN** an active opted-in account whose stored plan is a recognized paid plan
- **WHEN** one background usage refresh reports `free` without satisfying downgrade confirmation
- **THEN** no plan-transition warm-up is attempted

#### Scenario: Already-Free account does not use the plan-transition exception

- **GIVEN** an active opted-in account whose stored plan was already `free`
- **WHEN** background usage refresh writes its first monthly sample without confirming a plan change
- **THEN** no plan-transition warm-up is attempted

#### Scenario: Stale monthly history does not warm after a plan change

- **GIVEN** an active opted-in account whose transition from a paid plan to `free` is confirmed
- **WHEN** the latest monthly sample predates the confirming refresh
- **THEN** no plan-transition warm-up is attempted

#### Scenario: Existing durable identity deduplicates the transition warm-up

- **GIVEN** a warm-up attempt already exists for an account, `monthly` window, and monthly reset deadline
- **WHEN** the same confirmed paid-to-Free transition is evaluated again
- **THEN** no second warm-up request is sent for that durable identity
19 changes: 19 additions & 0 deletions openspec/changes/warm-free-plan-transition/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
## 1. Refresh-scoped transition evidence

- [x] 1.1 Snapshot the selected account's plan before background usage refresh.
- [x] 1.2 Pass the pre-refresh plan map and refresh timestamp into long-window warm-up evaluation.

## 2. Monthly transition candidate

- [x] 2.1 Add a paid-to-Free fallback candidate that requires a fresh available monthly sample.
- [x] 2.2 Preserve ordinary same-window reset detection and the existing durable monthly claim.

## 3. Regression coverage

- [x] 3.1 Prove a confirmed paid-to-Free scheduler refresh sends one monthly warm-up regardless of prior usage.
- [x] 3.2 Cover unconfirmed or unchanged Free plans, stale monthly history, availability gating, and deduplication.

## 4. Validation

- [x] 4.1 Run focused scheduler and limit warm-up tests.
- [x] 4.2 Run Ruff format/check, Ty, strict OpenSpec validation, and diff hygiene checks.
86 changes: 86 additions & 0 deletions tests/integration/test_usage_refresh_scheduler_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ async def run_after_usage_refresh(self, **kwargs: object) -> None:
selected.id,
unrelated.id,
}
assert warmup_calls[0]["previous_plan_types"] == {selected.id: "plus"}
for snapshot_name in ("before_primary", "before_secondary", "after_primary", "after_secondary"):
assert set(cast("dict[str, UsageHistory]", warmup_calls[0][snapshot_name])) <= {selected.id}

Expand Down Expand Up @@ -298,6 +299,91 @@ async def send(
assert (attempt.window, attempt.reset_at, attempt.status) == ("monthly", after_reset_at, "succeeded")


@pytest.mark.asyncio
async def test_scheduler_warms_confirmed_paid_to_free_plan_transition(
db_setup,
monkeypatch: pytest.MonkeyPatch,
) -> None:
del db_setup
account = _account("acc_paid_to_free", status=AccountStatus.ACTIVE)
prior_reset_at = int(time.time()) + 7 * 24 * 60 * 60
monthly_reset_at = int(time.time()) + 30 * 24 * 60 * 60

async with SessionLocal() as session:
await AccountsRepository(session).upsert(account)
await UsageRepository(session).add_entry(
account.id,
100.0,
window="secondary",
recorded_at=utcnow(),
reset_at=prior_reset_at,
window_minutes=10_080,
)
await SettingsRepository(session).update(
limit_warmup_enabled=True,
limit_warmup_windows="secondary",
limit_warmup_model="gpt-5.1-codex-mini",
)

class _Leader:
async def run_if_leader(self, fn: Callable[[], Awaitable[object]]) -> object:
return await fn()

class _Updater:
async def refresh_accounts(
self,
accounts: list[Account],
latest_usage: dict[str, UsageHistory],
) -> bool:
assert [candidate.id for candidate in accounts] == [account.id]
assert accounts[0].plan_type == "plus"
accounts[0].plan_type = "free"
async with SessionLocal() as session:
persisted = await AccountsRepository(session).get_by_id(account.id)
assert persisted is not None
persisted.plan_type = "free"
await session.commit()
await UsageRepository(session).add_entry(
account.id,
0.0,
window="monthly",
recorded_at=utcnow(),
reset_at=monthly_reset_at,
window_minutes=43_200,
)
return True

class _Sender:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []

async def send(
self,
target: Account,
*,
model: str,
prompt: str,
) -> LimitWarmupSendResult:
self.calls.append((target.id, model))
return LimitWarmupSendResult(request_id="warmup-plan-transition", success=True, latency_ms=12)

sender = _Sender()
monkeypatch.setattr(refresh_scheduler_module, "_get_leader_election", lambda: _Leader())
monkeypatch.setattr(refresh_scheduler_module, "build_background_usage_updater", lambda: _Updater())
monkeypatch.setattr(refresh_scheduler_module, "StreamingLimitWarmupSender", lambda *_args, **_kwargs: sender)

scheduler = refresh_scheduler_module.UsageRefreshScheduler(interval_seconds=60, enabled=True)

assert await scheduler._refresh_once() == 60.0
assert sender.calls == [(account.id, "gpt-5.1-codex-mini")]
async with SessionLocal() as session:
persisted_account = await AccountsRepository(session).get_by_id(account.id)
attempt = (await LimitWarmupRepository(session).latest_by_account([account.id]))[account.id]
assert persisted_account is not None
assert persisted_account.plan_type == "free"
assert (attempt.window, attempt.reset_at, attempt.status) == ("monthly", monthly_reset_at, "succeeded")


@pytest.mark.asyncio
@pytest.mark.parametrize("existing_attempt", [False, True], ids=["warmup-new", "warmup-deduped"])
@pytest.mark.parametrize(
Expand Down
Loading
Loading