Skip to content

Commit 517c0ab

Browse files
committed
fix(admin): align upstream routing readiness with account lifecycle
Derive admin routing availability from identity lifecycle, assignment health, and quota readiness instead of treating quota readiness as the routing signal. Show lifecycle-specific recovery copy for refresh failures and keep cockpit routing metrics aligned with gateway eligibility.
1 parent b14b3dc commit 517c0ab

23 files changed

Lines changed: 1235 additions & 89 deletions

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ The card body is split into status, token burn, quota windows, and footer metric
7575
</tr>
7676
<tr>
7777
<td>Routing</td>
78-
<td>Compact route posture such as quota ready or quota exhausted. It summarizes why this account is likely eligible or blocked before request-specific checks.</td>
78+
<td>Compact lifecycle-aware runtime routing readiness summary. It separates identity lifecycle, Pool assignment, credential freshness, and quota evidence so operators can see why this account is likely eligible or blocked before request-specific checks.</td>
7979
</tr>
8080
<tr>
8181
<td>Pools</td>
@@ -116,6 +116,10 @@ Common upstream states are:
116116
<td>Refreshing</td>
117117
<td>A background refresh is currently updating account evidence or credentials.</td>
118118
</tr>
119+
<tr>
120+
<td>Refresh failed</td>
121+
<td>`refresh_failed` means credential or auth lifecycle refresh failed. Treat it as identity recovery work first; quota readiness may still be unknown or stale until credentials are relinked or refreshed successfully.</td>
122+
</tr>
119123
<tr>
120124
<td>Reauth required</td>
121125
<td>The account needs operator recovery before it can route work again. Relink with OpenAI OAuth, import replacement credentials, or reinvite the account when those actions are available.</td>
@@ -176,6 +180,13 @@ authorization must be enabled for Codex on the OpenAI account or workspace. If
176180
device authorization is unavailable or denied, use the browser workflow or ask
177181
the workspace admin to enable Codex device-code login.
178182

183+
Immediately after a successful link, the new account is not guaranteed to route
184+
traffic. Codex Pooler creates the initial Pool assignment and enqueues account
185+
reconciliation to prime quota evidence. That quota refresh can require a token
186+
refresh first. If token refresh fails, the account can move to `refresh_failed`
187+
before any user request is routed, and runtime routing excludes it until token
188+
refresh succeeds or the credentials are relinked.
189+
179190
Operators should never paste callback URLs, authorization codes, tokens, cookies,
180191
raw auth.json, OpenAI provider payloads, or local credential files into docs,
181192
tickets, logs, or evidence. The admin UI may show the one-time authorization URL
@@ -325,8 +336,8 @@ When an upstream is not routing as expected, check it in this order:
325336
1. confirm the upstream state is `active`
326337
2. confirm it is assigned to the Pool used by the client API key
327338
3. confirm the Pool itself is active
328-
4. inspect the routing footer for quota ready or quota exhausted posture
329-
5. inspect quota bars for exhausted account-level or model-specific windows
339+
4. inspect the routing footer for lifecycle-aware runtime routing readiness across identity state, Pool assignment, credential freshness, quota signals, and request posture
340+
5. inspect quota bars for exhausted account-level or model-specific windows; quota readiness is only one input to routing readiness
330341
6. confirm token freshness and quota freshness are recent enough to trust
331342
7. open the upstream cockpit when the card shows degraded, stale, or recovery states
332343
8. use Request logs filtered by Pool or upstream metadata to confirm whether traffic selected this account

lib/codex_pooler/admin/alert_incidents_read_model.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ defmodule CodexPooler.Admin.AlertIncidentsReadModel do
4040
]
4141

4242
@type incident_filters :: %{
43+
optional(:channel_id) => Ecto.UUID.t() | nil,
4344
optional(:pool_id) => Ecto.UUID.t() | nil,
45+
optional(:rule_id) => Ecto.UUID.t() | nil,
46+
optional(:severity) => String.t() | nil,
4447
optional(:state) => String.t() | nil
4548
}
4649
@type linked_channel :: %{

lib/codex_pooler/admin/alert_notifications_read_model.ex

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,6 @@ defmodule CodexPooler.Admin.AlertNotificationsReadModel do
128128
optional(Ecto.UUID.t()) => [impacted_pool()]
129129
}
130130
defp impacted_pools_by_incident([], _pool_ids), do: %{}
131-
defp impacted_pools_by_incident(_rows, []), do: %{}
132131

133132
defp impacted_pools_by_incident(rows, pool_ids) do
134133
incident_ids = rows |> Enum.map(fn {incident, _receipt} -> incident.id end) |> Enum.uniq()

lib/codex_pooler/admin/upstream_cockpit_read_model.ex

Lines changed: 121 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ defmodule CodexPooler.Admin.UpstreamCockpitReadModel do
88
alias CodexPooler.Accounting.{Attempt, Request}
99
alias CodexPooler.Accounts.Scope
1010
alias CodexPooler.Admin.UpstreamQuotaReadiness
11-
alias CodexPooler.Quotas.{Evidence, WindowClassifier}
11+
alias CodexPooler.Admin.UpstreamRoutingReadiness
1212
alias CodexPooler.Pools
13+
alias CodexPooler.Quotas.{Evidence, WindowClassifier}
1314
alias CodexPooler.Repo
14-
alias CodexPooler.Upstreams.Schemas.UpstreamIdentity
1515
alias CodexPooler.Upstreams.Quota
1616
alias CodexPooler.Upstreams.Quota.Charts.Measurements
1717
alias CodexPooler.Upstreams.Quota.Windows, as: QuotaWindows
18+
alias CodexPooler.Upstreams.Schemas.UpstreamIdentity
1819

1920
@request_failed_statuses ~w(failed rejected interrupted cancelled)
2021
@request_terminal_statuses ["succeeded" | @request_failed_statuses]
@@ -28,7 +29,8 @@ defmodule CodexPooler.Admin.UpstreamCockpitReadModel do
2829
required(:assignment_label) => String.t(),
2930
required(:status) => String.t(),
3031
required(:health_status) => String.t(),
31-
required(:eligibility_status) => String.t()
32+
required(:eligibility_status) => String.t(),
33+
optional(:identity_status) => String.t()
3234
}
3335
@type quota_health_item :: %{
3436
required(:assignment_id) => Ecto.UUID.t(),
@@ -39,6 +41,11 @@ defmodule CodexPooler.Admin.UpstreamCockpitReadModel do
3941
required(:state) => String.t(),
4042
required(:state_label) => String.t(),
4143
required(:routing_usable?) => boolean(),
44+
required(:routing_readiness_state) => String.t(),
45+
required(:routing_readiness_label) => String.t(),
46+
required(:routing_readiness_reason) => String.t(),
47+
required(:routing_readiness_reason_code) => String.t(),
48+
required(:routing_readiness_recovery_action) => String.t() | nil,
4249
required(:window_kind) => String.t() | nil,
4350
required(:window_minutes) => pos_integer() | nil,
4451
required(:remaining_percent_value) => float() | nil,
@@ -106,6 +113,11 @@ defmodule CodexPooler.Admin.UpstreamCockpitReadModel do
106113
required(:assignment_state) => String.t(),
107114
required(:assignment_state_label) => String.t(),
108115
required(:routing_usable?) => boolean(),
116+
required(:routing_readiness_state) => String.t(),
117+
required(:routing_readiness_label) => String.t(),
118+
required(:routing_readiness_reason) => String.t(),
119+
required(:routing_readiness_reason_code) => String.t(),
120+
required(:routing_readiness_recovery_action) => String.t() | nil,
109121
required(:successful_request_count_7d) => non_neg_integer(),
110122
required(:share_percent_value) => float(),
111123
required(:bar_value) => float()
@@ -179,12 +191,12 @@ defmodule CodexPooler.Admin.UpstreamCockpitReadModel do
179191
|> QuotaWindows.list_quota_windows()
180192
end
181193

182-
quota_health_from_windows(visible_assignments, windows, now())
194+
quota_health_from_windows(identity_or_id, visible_assignments, windows, now())
183195
end
184196

185197
@spec quota_health_without_quota_data([assignment_summary()]) :: quota_health()
186198
def quota_health_without_quota_data(assignments) when is_list(assignments) do
187-
quota_health_from_windows(assignments, [], now())
199+
quota_health_from_windows(nil, assignments, [], now())
188200
end
189201

190202
@spec pool_contribution(Scope.t(), identity_ref(), [assignment_summary()]) ::
@@ -201,12 +213,26 @@ defmodule CodexPooler.Admin.UpstreamCockpitReadModel do
201213
|> identity_id()
202214
|> pool_contribution_rows(pool_ids, start_7d, as_of)
203215

204-
pool_contribution_from_rows(visible_assignments, rows)
216+
quota_readiness =
217+
if visible_assignments == [] do
218+
UpstreamQuotaReadiness.from_windows([], as_of)
219+
else
220+
identity_or_id
221+
|> identity_id()
222+
|> quota_readiness_for_identity(as_of)
223+
end
224+
225+
pool_contribution_from_rows(identity_or_id, visible_assignments, rows, quota_readiness)
205226
end
206227

207228
@spec pool_contribution_without_request_data([assignment_summary()]) :: pool_contribution()
208229
def pool_contribution_without_request_data(assignments) when is_list(assignments) do
209-
pool_contribution_from_rows(assignments, [])
230+
pool_contribution_from_rows(
231+
nil,
232+
assignments,
233+
[],
234+
UpstreamQuotaReadiness.from_windows([], now())
235+
)
210236
end
211237

212238
@spec recent_request_event_rows(Scope.t(), identity_ref(), pos_integer()) :: [
@@ -220,12 +246,12 @@ defmodule CodexPooler.Admin.UpstreamCockpitReadModel do
220246

221247
def recent_request_event_rows(_scope, _identity_or_id, _limit), do: []
222248

223-
defp quota_health_from_windows(assignments, windows, as_of) do
249+
defp quota_health_from_windows(identity_or_status, assignments, windows, as_of) do
224250
readiness = UpstreamQuotaReadiness.from_windows(windows, as_of)
225251

226252
items =
227253
assignments
228-
|> Enum.map(&quota_health_item(&1, readiness, as_of))
254+
|> Enum.map(&quota_health_item(&1, readiness, identity_or_status, as_of))
229255
|> Enum.sort_by(&{&1.pool_label, &1.assignment_label, &1.assignment_id})
230256

231257
kpis = quota_health_kpis(items)
@@ -243,7 +269,8 @@ defmodule CodexPooler.Admin.UpstreamCockpitReadModel do
243269
}
244270
end
245271

246-
defp quota_health_item(assignment, readiness, as_of) do
272+
defp quota_health_item(assignment, readiness, identity_or_status, as_of) do
273+
routing_readiness = routing_readiness(identity_or_status, assignment, readiness)
247274
primary_5h = classified_window(readiness.primary_window, :primary_5h)
248275
primary_30d = readiness.primary_30d_window
249276
weekly = readiness.weekly_window
@@ -258,12 +285,13 @@ defmodule CodexPooler.Admin.UpstreamCockpitReadModel do
258285
|> Map.put(:assignment_id, assignment.id)
259286
|> Map.put(:state, state)
260287
|> Map.put(:state_label, quota_state_label(state))
261-
|> Map.put(:routing_usable?, readiness.routing_ready_now?)
288+
|> Map.put(:routing_usable?, routing_readiness.routing_ready_now?)
289+
|> Map.merge(routing_readiness_contract(routing_readiness))
262290
|> Map.put(:window_kind, display_window && display_window.window_kind)
263291
|> Map.put(:window_minutes, display_window && display_window.window_minutes)
264292
|> Map.put(:reset_at, display_window && display_window.reset_at)
265293
|> Map.put(:freshness_state, quota_freshness_state(display_window, as_of))
266-
|> Map.put(:reason_codes, readiness.reason_codes)
294+
|> Map.put(:reason_codes, quota_reason_codes(readiness.reason_codes, routing_readiness))
267295
|> Map.put(:remaining, measurements.remaining)
268296
|> Map.put(:capacity, measurements.capacity)
269297
|> Map.put(:used, measurements.used)
@@ -305,7 +333,8 @@ defmodule CodexPooler.Admin.UpstreamCockpitReadModel do
305333
end
306334

307335
defp quota_health_degraded?(kpis) do
308-
kpis.stale_or_missing_count > 0 or kpis.exhausted_count > 0 or kpis.blocked_count > 0
336+
kpis.stale_or_missing_count > 0 or kpis.exhausted_count > 0 or kpis.blocked_count > 0 or
337+
kpis.routing_usable_count < kpis.assignment_count
309338
end
310339

311340
defp quota_health_state(%{assignment_count: 0}), do: "empty"
@@ -370,13 +399,21 @@ defmodule CodexPooler.Admin.UpstreamCockpitReadModel do
370399
defp decimal_to_float(%Decimal{} = value), do: Decimal.to_float(value)
371400
defp decimal_to_float(nil), do: nil
372401

373-
defp pool_contribution_from_rows(assignments, rows) do
402+
defp pool_contribution_from_rows(identity_or_status, assignments, rows, quota_readiness) do
374403
successful_requests_7d = length(rows)
375404
request_counts_by_pool_id = Enum.frequencies_by(rows, & &1.pool_id)
376405

377406
items =
378407
assignments
379-
|> Enum.map(&pool_contribution_item(&1, request_counts_by_pool_id, successful_requests_7d))
408+
|> Enum.map(
409+
&pool_contribution_item(
410+
&1,
411+
request_counts_by_pool_id,
412+
successful_requests_7d,
413+
identity_or_status,
414+
quota_readiness
415+
)
416+
)
380417
|> Enum.sort_by(&{&1.pool_label, &1.assignment_label, &1.assignment_id})
381418

382419
kpis = pool_contribution_kpis(items, successful_requests_7d)
@@ -415,10 +452,17 @@ defmodule CodexPooler.Admin.UpstreamCockpitReadModel do
415452
|> Repo.all()
416453
end
417454

418-
defp pool_contribution_item(assignment, request_counts_by_pool_id, successful_requests_7d) do
455+
defp pool_contribution_item(
456+
assignment,
457+
request_counts_by_pool_id,
458+
successful_requests_7d,
459+
identity_or_status,
460+
quota_readiness
461+
) do
419462
successful_request_count_7d = Map.get(request_counts_by_pool_id, assignment.pool_id, 0)
420463
share_percent_value = percentage(successful_request_count_7d, successful_requests_7d)
421-
assignment_state = pool_contribution_assignment_state(assignment)
464+
routing_readiness = routing_readiness(identity_or_status, assignment, quota_readiness)
465+
assignment_state = pool_contribution_assignment_state(routing_readiness)
422466

423467
assignment
424468
|> Map.take([
@@ -434,9 +478,10 @@ defmodule CodexPooler.Admin.UpstreamCockpitReadModel do
434478
|> Map.put(:assignment_state, assignment_state)
435479
|> Map.put(
436480
:assignment_state_label,
437-
pool_contribution_assignment_state_label(assignment_state)
481+
pool_contribution_assignment_state_label(assignment_state, routing_readiness)
438482
)
439-
|> Map.put(:routing_usable?, pool_contribution_routing_usable?(assignment))
483+
|> Map.put(:routing_usable?, routing_readiness.routing_ready_now?)
484+
|> Map.merge(routing_readiness_contract(routing_readiness))
440485
|> Map.put(:successful_request_count_7d, successful_request_count_7d)
441486
|> Map.put(:share_percent_value, share_percent_value)
442487
|> Map.put(:bar_value, share_percent_value)
@@ -459,16 +504,66 @@ defmodule CodexPooler.Admin.UpstreamCockpitReadModel do
459504

460505
defp pool_contribution_state(_items, _kpis), do: "contributing"
461506

462-
defp pool_contribution_assignment_state(assignment) do
463-
if pool_contribution_routing_usable?(assignment), do: "active", else: "disabled"
507+
defp pool_contribution_assignment_state(%{routing_ready_now?: true}), do: "active"
508+
defp pool_contribution_assignment_state(_routing_readiness), do: "disabled"
509+
510+
defp pool_contribution_assignment_state_label("active", _routing_readiness),
511+
do: "Active assignment"
512+
513+
defp pool_contribution_assignment_state_label("disabled", %{state: "assignment_unavailable"}),
514+
do: "Disabled or unusable assignment"
515+
516+
defp pool_contribution_assignment_state_label("disabled", %{label: label})
517+
when is_binary(label),
518+
do: label
519+
520+
defp pool_contribution_assignment_state_label("disabled", _routing_readiness),
521+
do: "Disabled or unusable assignment"
522+
523+
defp quota_readiness_for_identity(identity_id, as_of) when is_binary(identity_id) do
524+
identity_id
525+
|> QuotaWindows.list_quota_windows()
526+
|> UpstreamQuotaReadiness.from_windows(as_of)
527+
end
528+
529+
defp quota_readiness_for_identity(_identity_id, as_of),
530+
do: UpstreamQuotaReadiness.from_windows([], as_of)
531+
532+
defp routing_readiness(identity_or_status, assignment, quota_readiness) do
533+
identity_or_status
534+
|> routing_identity_status(assignment)
535+
|> UpstreamRoutingReadiness.from_inputs(assignment, quota_readiness)
536+
end
537+
538+
defp routing_identity_status(identity_or_status, assignment) do
539+
assignment_identity_status(assignment) || identity_or_status
540+
end
541+
542+
defp assignment_identity_status(%{identity_status: status}) when is_binary(status), do: status
543+
544+
defp assignment_identity_status(%{"identity_status" => status}) when is_binary(status),
545+
do: status
546+
547+
defp assignment_identity_status(_assignment), do: nil
548+
549+
defp routing_readiness_contract(routing_readiness) do
550+
%{
551+
routing_readiness_state: routing_readiness.state,
552+
routing_readiness_label: routing_readiness.label,
553+
routing_readiness_reason: routing_readiness.reason,
554+
routing_readiness_reason_code: routing_readiness.reason_code,
555+
routing_readiness_recovery_action: routing_readiness.recovery_action
556+
}
464557
end
465558

466-
defp pool_contribution_assignment_state_label("active"), do: "Active assignment"
467-
defp pool_contribution_assignment_state_label("disabled"), do: "Disabled or unusable assignment"
559+
defp quota_reason_codes(quota_reason_codes, %{routing_ready_now?: true}), do: quota_reason_codes
560+
defp quota_reason_codes(quota_reason_codes, %{state: "quota_blocked"}), do: quota_reason_codes
468561

469-
defp pool_contribution_routing_usable?(assignment) do
470-
assignment.status == "active" and assignment.health_status == "active" and
471-
assignment.eligibility_status == "eligible"
562+
defp quota_reason_codes(quota_reason_codes, %{reason_code: reason_code})
563+
when is_binary(reason_code) do
564+
[reason_code | quota_reason_codes]
565+
|> Enum.reject(&is_nil/1)
566+
|> Enum.uniq()
472567
end
473568

474569
defp request_health_rows(identity_id, %Scope{} = scope, start_7d, as_of)

0 commit comments

Comments
 (0)