feat(agents): ChatGPT payer at Start, statuses and "Using ChatGPT plan"
Merged
CH
5d ago+16700+16700+01234567890123456789012345678901234567890123456789-115-115-012345678901234567890123456789
P25 of the native-harness rollout (task "Bringing Codex or Claude Code Max plans into Luke"). It extends P14 (#4334) to GPT models, and builds on P24 (#4330), P22 (#4302) and P18/P19.
What changed
- Payer at Start for GPT models.
agentRuntimeChoicenow honours the pick on the ChatGPT branch too. On a GPT non-fast model with a usablechatgpt_oauthrow:chatgpt_plangoes to Codex.api_key/lukego to eve, recorded asapi_keywhen the account saved an OpenAI key andlukeotherwise.- With no pick, the row's
for_agentsdecides. - A pick of the other provider's plan is no pick of this one's.
start_agenttakeschatgpt_plan.- The draft tab's "Who pays" picker offers "ChatGPT plan" / "API key" / "Luke" for a GPT non-fast model. It is not offered when the sign-in is signed out, ineligible or unreadable.
- Settings › Coding agents › Codex: a "Use my ChatGPT plan for coding agents" switch under a connected sign-in, using P14's PATCH.
- Row statuses, written from the projection in the same transaction.
PROVIDER_KEY_STATUSgainslimit_reachedandineligible.account_provider_keygains a nullablelimit_resets_at(additive migration). The service answers it aslimitResetsAt.codex-momentsemits a provider-neutralNATIVE_MOMENT.PLAN, following P22's words:verifiedon acompletedturn.ineligibleon*user_not_eligible.invalidonunauthorized/*invalid_user.limit_reachedonusageLimitExceeded/*usage_limit*.
- It also reads
account/rateLimits/updated, so a reached limit carries the reset time Codex heard. - The Fold turns the moment into
NATIVE_WRITE.PLAN_STATUS.runner-events-appwrites it with the newsetPlanStatusto the row of the agent's recorded payer (PLAN_ROWmapsclaude_plantoo, for P15), insidewithNativeProjection's transaction. - An unreadable row is never overwritten.
verifiednever liftsinvalid, because a renewal's refusal outranks a turn that ran. An already-verified row isn't rewritten on every turn.
- Refusals in words.
refreshChatGptAccessanswersineligible, andlimit_reachedwhile a named reset is still ahead.- Codex's launch maps these to
PLAN_REFUSAL.INELIGIBLE/LIMIT_REACHED, and they surface as the newagent-plan-ineligible/agent-plan-limit-reachedrefusals, with words for the agent tools and the panel. - The agent waits and never switches payer.
- Label. Under a Codex agent's composer: "Using ChatGPT plan · Manage usage". Manage usage opens https://chatgpt.com/settings/usage, following OpenAI's UI guidelines.
- For the starter, whose plan it is, a plan that can't serve shows its status instead: "Usage limit reached · Resets …", signed out, not eligible, or unreadable.
- The status is re-read when the agent's status changes.
- The composer takes a generic
payerslot, so P15's Claude label can use it.
Judgment calls
- A limit with no reset named doesn't refuse the launch. Otherwise nothing would ever lift it short of reconnecting; the next turn fails again and re-marks it.
- A reached limit offers the payer at Start (it resets); an ineligible account doesn't.
- Teammates see the plain label: the plan's status is the starter's alone.
- Coordinated with P15 in the task chat.
LIMIT_REACHED, the reset column and the PLAN moment are shared, so whichever of us lands second does the small merge.
Screenshots
Synthetic data, rendered from the panel in a throwaway harness. The broken OpenAI mark is the harness's missing asset only.
Settings › Codex switch · Start picker on a GPT model:

Composer label, verified · limit reached (the starter's view):

Tests
runtime-sessions.test.ts:- The ChatGPT pick, the default on and off, the override recording
api_key(OpenAI key) orlukeon eve, and the Claude plan picked for a GPT model. - The refusal mapping now covers limit reached and ineligible.
- The ChatGPT pick, the default on and off, the override recording
runner-events-app.test.ts, with statuses driven from recorded notifications:- P16's recorded launch gives
verified. - A
usageLimitExceededturn afteraccount/rateLimits/updatedgiveslimit_reachedwith that reset. unauthorized/subscription_sharing_invalid_usergivesinvalid, and a later successful turn leavesinvalid.user_not_eligiblegivesineligible.- An unrelated failure, or an agent a key pays for, changes nothing.
- P16's recorded launch gives
codex-sessions.test.ts:- A reached limit refuses the launch by name until its reset, then launches.
- Ineligible refuses by name.
- No runner starts while refused.
coder-agent-tools.test.ts:start_agenttakeschatgpt_plan, and an unknown payer is refused.payer-model.test.ts,draft-agent-tab.test.ts,chatgpt-plan-section.test.tsandplan-label-model.test.tscover the panel side.
Checks
tscfor features, service, panel and database.- Biome and oxlint on the change, and the discarded-effect check.
- Vitest over the touched suites (106 files, 828 tests, plus the native-events, codex-sessions and panel files above).
pnpm check:affected's parallel typecheck was OOM-killed (137) in this 8 GB sandbox, as it was for P14. CI runs the full check.
The developer waived Preview runs for this rollout, so this merges on green. The plan's live ChatGPT-plan task on Preview is the developer's, later.
Made with Luke