Skip to content

Commit b19bf8f

Browse files
committed
fix: recover stale saved reset redemptions
1 parent 4fcd699 commit b19bf8f

11 files changed

Lines changed: 193 additions & 37 deletions

File tree

docs-site/src/content/docs/operators/upstreams.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,9 @@ Account cards show a compact saved-reset count badge only when sanitized usage o
9898

9999
The saved-reset dialog explains the banked reset count in plain operator terms and edits auto-redemption policy. If expiration metadata is available, it shows the next available saved reset expiration and the number of reported expiration dates. The dialog and cockpit both require an explicit confirmation before queueing a manual `Redeem one saved reset` action. Saving the policy never enqueues redemption work.
100100

101-
Manual redemption is enabled only when the upstream is not deleted or disabled, credentials are usable, at least one Pool assignment exists, a saved-reset count has been reported, the reported count is greater than zero, and no redemption is already in progress. Otherwise the action is disabled with the matching blocker, such as missing credentials, no Pool assignment, unreported count, no available resets, or an in-progress redemption.
101+
Manual redemption is enabled only when the upstream is not deleted or disabled, credentials are usable, at least one Pool assignment exists, a saved-reset count has been reported, the reported count is greater than zero, and no fresh redemption is already in progress. A stale in-progress attempt is not a blocker; confirming manual redemption lets the worker recover the stale attempt state before claiming a new redemption. Otherwise the action is disabled with the matching blocker, such as missing credentials, no Pool assignment, unreported count, no available resets, or a fresh in-progress redemption.
102102

103-
Auto redemption is a per-upstream opt-in policy available from the account-card `Saved resets` dialog and the upstream cockpit. New accounts default to auto redemption off. Operators choose whether automatic redemption waits until weekly quota is blocked or may start earlier when fresh weekly quota evidence shows every eligible candidate account is near the configured `Near-limit threshold`. A saved reset with a known expiration inside the next 24 hours may also redeem early when that same account already has weekly usage to recover and the natural weekly reset is not close. The `Natural reset buffer` prevents spending when the weekly quota will reset naturally soon, and `Keep credits` reserves banked resets from automatic use.
103+
Auto redemption is a per-upstream opt-in policy available from the account-card `Saved resets` dialog and the upstream cockpit. New accounts default to auto redemption off. Operators choose whether automatic redemption waits until weekly quota is blocked or may start earlier when fresh weekly quota evidence shows every eligible candidate account is near the configured `Near-limit threshold`. The blocked mode is still allowed to rescue a saved reset with a known expiration inside the next 24 hours when that same account already has weekly usage to recover and the natural weekly reset is not close. The `Natural reset buffer` prevents spending when the weekly quota will reset naturally soon, and `Keep credits` reserves banked resets from automatic use.
104104

105105
Saved reset observations and redemption attempts stay metadata-only on `upstream_identities.metadata` under `saved_resets` and `saved_reset_redemption`. The saved-reset observation may include sanitized aggregate expiration fields such as `available_expires_at`, `next_expires_at`, `expires_observed_at`, and `expires_refresh_attempted_at`; it must not include raw provider credit objects, identifiers, titles, descriptions, or redemption request ids. Policy fields live on the upstream identity as `saved_reset_auto_redeem_enabled`, `saved_reset_auto_redeem_trigger_mode`, `saved_reset_auto_redeem_quota_threshold_percent`, `saved_reset_auto_redeem_min_blocked_minutes`, and `saved_reset_auto_redeem_keep_credits`. Do not copy provider payloads, raw credit objects or identifiers, redemption request identifiers, token material, or account secrets into metadata, logs, docs, or tests.
106106

docs-site/src/content/docs/reference/routing-strategies.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,11 @@ Fallback doesn't bypass hard checks. It won't use a paused account, an account w
183183

184184
Saved reset redemption is quota recovery, not a routing strategy. It cannot make a paused, unassigned, unsupported, unhealthy, or session-conflicting upstream eligible.
185185

186-
Auto redemption is default-disabled per upstream account because saved resets are limited provider capacity. When an operator enables it, the gateway may attempt one redemption only after required quota filtering reaches `quota_exhausted` and every candidate is blocked by weekly quota exhaustion. The selected candidate must have a reported saved-reset count greater than its `Keep credits` policy, no redemption already in progress, and a natural reset far enough away to satisfy `Min blocked minutes`.
186+
Auto redemption is default-disabled per upstream account because saved resets are limited provider capacity. In blocked mode, the gateway may attempt one redemption only after required quota filtering reaches `quota_exhausted` and every candidate is blocked by weekly quota exhaustion. The selected candidate must have a reported saved-reset count greater than its `Keep credits` policy, no fresh or stale redemption already claimed, and a natural reset far enough away to satisfy `Min blocked minutes`.
187187

188-
If auto redemption applies a saved reset, Codex Pooler refreshes quota evidence and filters candidates again before strategy ordering. If no saved reset can be applied, or the redemption result does not change quota evidence, the original quota exhaustion stands. Manual `Redeem saved reset` from the upstream cockpit uses the same metadata-only redemption boundary but is an operator action, not an in-flight routing fallback.
188+
Early redemption can also run before hard exhaustion when the operator selects near-limit mode and every eligible candidate account has fresh weekly quota evidence at or above the configured threshold. Separately, a known saved reset expiring inside the next 24 hours may be rescued early when that same account already has weekly usage to recover and the natural weekly reset is not close.
189+
190+
If auto redemption applies a saved reset, Codex Pooler refreshes quota evidence and filters candidates again before strategy ordering. If no saved reset can be applied, or the redemption result does not change quota evidence, the original quota decision stands. Manual `Redeem saved reset` from the upstream cockpit uses the same metadata-only redemption boundary but is an operator action, not an in-flight routing fallback.
189191

190192
Saved-reset observations and redemption attempt state are stored on `upstream_identities.metadata`; routing and redemption metadata stores bounded status, result codes, timestamps, HTTP status, and before/after counts only. It does not store raw provider payloads, credit identifiers, redemption request identifiers, tokens, prompts, request bodies, or response bodies.
191193

lib/codex_pooler/gateway/routing/saved_reset_auto_redeem.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,8 @@ defmodule CodexPooler.Gateway.Routing.SavedResetAutoRedeem do
261261
snapshot = SavedResets.snapshot(identity)
262262

263263
policy.enabled? and snapshot.available_count != nil and
264-
snapshot.available_count > policy.keep_credits and not snapshot.in_progress?
264+
snapshot.available_count > policy.keep_credits and not snapshot.in_progress? and
265+
not snapshot.redemption_stale?
265266
end
266267

267268
defp redeemable_weekly_window?(

lib/codex_pooler/upstreams/saved_resets.ex

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ defmodule CodexPooler.Upstreams.SavedResets do
1919
@default_quota_threshold_percent 95
2020
@expiration_refresh_ttl_seconds 6 * 60 * 60
2121
@expiring_soon_seconds 24 * 60 * 60
22+
@redemption_projection_receive_timeout_ms 15_000
23+
@redemption_projection_stale_grace_ms 60_000
2224

2325
@type count_parse_result :: {:reported, non_neg_integer()} | :unreported
2426
@type snapshot_projection :: %{
@@ -37,6 +39,7 @@ defmodule CodexPooler.Upstreams.SavedResets do
3739
required(:expires_refresh_attempted_at) => String.t() | nil,
3840
required(:expires_reported?) => boolean(),
3941
required(:in_progress?) => boolean(),
42+
required(:redemption_stale?) => boolean(),
4043
required(:last_redemption) => map() | nil
4144
}
4245
@type auto_policy_projection :: %{
@@ -128,11 +131,16 @@ defmodule CodexPooler.Upstreams.SavedResets do
128131
end
129132

130133
@spec snapshot(UpstreamIdentity.t() | map() | nil) :: snapshot_projection()
131-
def snapshot(%UpstreamIdentity{} = identity), do: snapshot(identity.metadata)
134+
def snapshot(identity_or_metadata), do: snapshot(identity_or_metadata, now())
132135

133-
def snapshot(%{} = metadata) do
136+
@spec snapshot(UpstreamIdentity.t() | map() | nil, DateTime.t()) :: snapshot_projection()
137+
def snapshot(%UpstreamIdentity{} = identity, %DateTime{} = timestamp),
138+
do: snapshot(identity.metadata, timestamp)
139+
140+
def snapshot(%{} = metadata, %DateTime{} = timestamp) do
134141
snapshot = Map.get(metadata, "saved_resets", metadata)
135142
redemption = Map.get(metadata, "saved_reset_redemption")
143+
redemption_state = redemption_state(redemption, timestamp)
136144
status = snapshot_status(snapshot)
137145
available_count = snapshot_available_count(snapshot, status)
138146
available_expires_at = snapshot_available_expires_at(snapshot)
@@ -157,13 +165,14 @@ defmodule CodexPooler.Upstreams.SavedResets do
157165
expires_observed_at: expires_observed_at,
158166
expires_refresh_attempted_at: expires_refresh_attempted_at,
159167
expires_reported?: next_expires_at != nil,
160-
in_progress?: redemption_in_progress?(redemption),
168+
in_progress?: redemption_state == :in_progress,
169+
redemption_stale?: redemption_state == :stale,
161170
last_redemption: redemption_or_nil(redemption)
162171
}
163172
end
164173

165-
def snapshot(_identity_or_metadata) do
166-
snapshot(%{"saved_resets" => %{}})
174+
def snapshot(_identity_or_metadata, %DateTime{} = timestamp) do
175+
snapshot(%{"saved_resets" => %{}}, timestamp)
167176
end
168177

169178
@spec reset_credit_list_refresh_due?(
@@ -514,8 +523,32 @@ defmodule CodexPooler.Upstreams.SavedResets do
514523
defp label(@unavailable, _count), do: "Saved resets unavailable"
515524
defp label(_status, _count), do: "Saved resets not reported"
516525

517-
defp redemption_in_progress?(%{"status" => "redeeming"}), do: true
518-
defp redemption_in_progress?(_redemption), do: false
526+
@spec redemption_state(map() | term(), DateTime.t()) :: :in_progress | :stale | :complete
527+
defp redemption_state(
528+
%{"status" => "redeeming", "started_at" => started_at},
529+
%DateTime{} = timestamp
530+
)
531+
when is_binary(started_at) do
532+
case DateTime.from_iso8601(started_at) do
533+
{:ok, started_at, _offset} ->
534+
if fresh_redemption?(started_at, timestamp), do: :in_progress, else: :stale
535+
536+
_invalid ->
537+
:stale
538+
end
539+
end
540+
541+
defp redemption_state(%{"status" => "redeeming"}, _timestamp), do: :stale
542+
defp redemption_state(_redemption, _timestamp), do: :complete
543+
544+
@spec fresh_redemption?(DateTime.t(), DateTime.t()) :: boolean()
545+
defp fresh_redemption?(%DateTime{} = started_at, %DateTime{} = timestamp) do
546+
DateTime.diff(timestamp, started_at, :millisecond) <
547+
@redemption_projection_receive_timeout_ms + @redemption_projection_stale_grace_ms
548+
end
549+
550+
@spec now() :: DateTime.t()
551+
defp now, do: DateTime.utc_now() |> DateTime.truncate(:microsecond)
519552

520553
defp redemption_or_nil(%{} = redemption), do: redemption
521554
defp redemption_or_nil(_redemption), do: nil

lib/codex_pooler_web/live/admin/components/pages/upstreams/cockpit/sections.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ defmodule CodexPoolerWeb.Admin.UpstreamCockpitComponents.Sections do
311311
<div class="grid max-w-3xl gap-1">
312312
<p class="text-sm font-semibold text-base-content">Auto redeem policy</p>
313313
<p class="text-xs leading-5 text-base-content/60">
314-
Automatic redemption can wait until weekly quota is blocked, start earlier near the quota limit when every eligible account is under pressure, or spend a soon-expiring reset when this account already has weekly usage. The reset buffer prevents spending when the weekly reset is close.
314+
Automatic redemption can wait until weekly quota is blocked, start earlier near the quota limit when every eligible account is under pressure, or rescue a soon-expiring reset when this account already has weekly usage. The reset buffer prevents spending when the weekly reset is close.
315315
</p>
316316
</div>
317317
<label
@@ -341,7 +341,7 @@ defmodule CodexPoolerWeb.Admin.UpstreamCockpitComponents.Sections do
341341
label="Auto trigger"
342342
class="select select-bordered w-full"
343343
options={[
344-
{"Blocked", "blocked"},
344+
{"Blocked or expiring", "blocked"},
345345
{"Near limit", "threshold"}
346346
]}
347347
/>

lib/codex_pooler_web/live/admin/components/pages/upstreams/cockpit/summary.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ defmodule CodexPoolerWeb.Admin.UpstreamCockpitComponents.Summary do
342342
do: "Auto redeem on · near #{threshold}% · keep #{keep_credits}"
343343

344344
defp saved_reset_policy_description(%{enabled?: true, keep_credits: keep_credits}),
345-
do: "Auto redeem on · blocked · keep #{keep_credits}"
345+
do: "Auto redeem on · blocked/expiring · keep #{keep_credits}"
346346

347347
defp saved_reset_policy_description(_policy), do: "Auto redeem off"
348348

lib/codex_pooler_web/live/admin/components/pages/upstreams/page_components.ex

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -605,9 +605,6 @@ defmodule CodexPoolerWeb.Admin.UpstreamPageComponents do
605605
name="saved_reset_policy[auto_redeem_enabled]"
606606
label="Auto redeem saved resets"
607607
/>
608-
<p class="text-xs leading-5 text-base-content/65">
609-
Let Codex Pooler spend a banked reset automatically when the Pool is at risk and this account has more resets than the reserve below.
610-
</p>
611608
</div>
612609
613610
<div class="grid gap-4 md:grid-cols-[minmax(0,1.1fr)_minmax(9rem,0.9fr)]">
@@ -619,12 +616,12 @@ defmodule CodexPoolerWeb.Admin.UpstreamPageComponents do
619616
name="saved_reset_policy[trigger_mode]"
620617
label="When automatic redemption can start"
621618
options={[
622-
{"Only after work is blocked", "blocked"},
619+
{"After block or near expiry", "blocked"},
623620
{"Before work stops near the quota limit", "threshold"}
624621
]}
625622
/>
626623
<p class="text-xs leading-5 text-base-content/65">
627-
Early mode waits until every eligible account in the Pool is also near the configured weekly quota limit. Expiring saved resets may also redeem early when this account already has weekly usage.
624+
Blocked mode waits for weekly quota exhaustion, except a known reset expiring within 24 hours may be rescued early after this account has weekly usage. Near-limit mode waits until every eligible account in the Pool is also near the configured weekly quota limit.
628625
</p>
629626
</div>
630627
@@ -640,7 +637,7 @@ defmodule CodexPoolerWeb.Admin.UpstreamPageComponents do
640637
step="1"
641638
/>
642639
<p class="text-xs leading-5 text-base-content/65">
643-
Used only by early mode. 95 means redeem when fresh weekly quota evidence shows at least 95% used.
640+
Used only by near-limit mode. 95 means redeem when every eligible account has fresh weekly quota evidence at or above 95% used.
644641
</p>
645642
</div>
646643
</div>
@@ -655,7 +652,7 @@ defmodule CodexPoolerWeb.Admin.UpstreamPageComponents do
655652
min="0"
656653
/>
657654
<p class="text-xs leading-5 text-base-content/65">
658-
Do not spend a saved reset when the weekly quota will reset naturally within this many minutes. Use 0 only when operators accept aggressive fail-open routing.
655+
Do not spend a saved reset when the weekly quota will reset naturally within this many minutes. Set 0 to allow automatic redemption even when the natural reset is close.
659656
</p>
660657
</div>
661658
@@ -669,7 +666,7 @@ defmodule CodexPoolerWeb.Admin.UpstreamPageComponents do
669666
min="0"
670667
/>
671668
<p class="text-xs leading-5 text-base-content/65">
672-
Automatic redemption stops when the available reset count is at or below this reserve. Manual redemption below remains an explicit operator action.
669+
Automatic redemption stops when the available reset count is at or below this reserve.
673670
</p>
674671
</div>
675672
</.form>

test/codex_pooler/gateway/routing/route_filtering_test.exs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,43 @@ defmodule CodexPooler.Gateway.Routing.RouteFilteringTest do
9696
assert [] = FakeUpstream.requests(upstream)
9797
end
9898

99+
test "auto redemption ignores stale in-progress redemption until manual recovery" do
100+
{:ok, upstream} =
101+
FakeUpstream.start_link(
102+
{:path_json,
103+
%{
104+
"/api/codex/rate-limit-reset-credits/consume" => {200, %{"code" => "reset"}},
105+
"/api/codex/usage" => {200, usage_payload(0)}
106+
}}
107+
)
108+
109+
started_at = DateTime.utc_now() |> DateTime.add(-5, :minute) |> DateTime.to_iso8601()
110+
%{pool: pool, api_key: api_key} = active_api_key_fixture()
111+
112+
%{identity: identity, assignment: assignment} =
113+
active_upstream_assignment_fixture(pool, %{
114+
metadata:
115+
upstream
116+
|> saved_reset_metadata(1)
117+
|> Map.put("saved_reset_redemption", %{
118+
"status" => "redeeming",
119+
"attempt_id" => Ecto.UUID.generate(),
120+
"generation" => 1,
121+
"trigger_kind" => "gateway_auto",
122+
"started_at" => started_at,
123+
"finished_at" => nil,
124+
"result" => nil
125+
})
126+
})
127+
128+
identity = enable_saved_reset_auto_redeem!(identity)
129+
upsert_weekly_exhausted_quota!(identity)
130+
filter_input = filter_input(pool, api_key, assignment, identity, "auto-stale-redemption")
131+
132+
assert {:error, %{code: "quota_exhausted"}} = RouteFiltering.filter_candidates(filter_input)
133+
assert [] = FakeUpstream.requests(upstream)
134+
end
135+
99136
test "auto redeems saved reset and refilters when weekly account quota is exhausted" do
100137
{:ok, upstream} =
101138
FakeUpstream.start_link(

test/codex_pooler/upstreams/saved_reset_redemption_test.exs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,47 @@ defmodule CodexPooler.Upstreams.SavedResetRedemptionTest do
132132
assert {:error, :redemption_in_progress} = SavedResetRedemption.redeem(assignment)
133133
assert [] = FakeUpstream.requests(fake)
134134
end
135+
136+
test "stale admin in-progress redemption is recovered by manual attempt" do
137+
{:ok, fake} =
138+
FakeUpstream.start_link(
139+
{:path_json,
140+
%{
141+
"/api/codex/rate-limit-reset-credits/consume" => {200, %{"code" => "reset"}},
142+
"/api/codex/usage" => {200, usage_payload(0)}
143+
}}
144+
)
145+
146+
stale_started_at =
147+
DateTime.utc_now()
148+
|> DateTime.add(-5, :minute)
149+
|> DateTime.truncate(:microsecond)
150+
151+
%{identity: identity, assignment: assignment} =
152+
assignment_with_fake(fake, "/api/codex/usage", "codex_api",
153+
redemption: %{
154+
"status" => "redeeming",
155+
"attempt_id" => Ecto.UUID.generate(),
156+
"generation" => 1,
157+
"trigger_kind" => "admin_manual",
158+
"started_at" => DateTime.to_iso8601(stale_started_at),
159+
"finished_at" => nil,
160+
"result" => nil
161+
}
162+
)
163+
164+
assert {:ok, %{status: :succeeded, applied?: true, code: "reset"}} =
165+
SavedResetRedemption.redeem(assignment)
166+
167+
assert [consume_request, usage_request] = FakeUpstream.requests(fake)
168+
assert consume_request.path == "/api/codex/rate-limit-reset-credits/consume"
169+
assert usage_request.path == "/api/codex/usage"
170+
171+
persisted = Repo.reload!(identity)
172+
assert get_in(persisted.metadata, ["saved_reset_redemption", "status"]) == "succeeded"
173+
assert get_in(persisted.metadata, ["saved_reset_redemption", "generation"]) == 3
174+
assert get_in(persisted.metadata, ["saved_reset_redemption", "result", "code"]) == "reset"
175+
end
135176
end
136177

137178
defp assignment_with_fake(fake, usage_path, path_style, opts \\ []) do

0 commit comments

Comments
 (0)