Skip to content

feat(profiles): fill in a missing account plan at the next token refresh - #804

Draft
Nowaker wants to merge 2 commits into
rynfar:mainfrom
Nowaker:feat/backfill-plan-fields
Draft

feat(profiles): fill in a missing account plan at the next token refresh#804
Nowaker wants to merge 2 commits into
rynfar:mainfrom
Nowaker:feat/backfill-plan-fields

Conversation

@Nowaker

@Nowaker Nowaker commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

DRAFT - please do not review or merge yet

Stacks on #795. The first commit here is that PR's; the second is this one. Read/merge #795 first.

The plan fields are written once, at login, and never again.

A credential file created before Meridian persisted them — or by a claude login that recorded subscriptionType without rateLimitTier — stays plan-blind for the life of the profile, because nothing else in the lifecycle ever asks. Measured on a ten-account host: eight of ten profiles carried no plan at all, and re-running an interactive login on each one just to learn the plan is not a reasonable ask.

Why the refresh path

GET /api/oauth/profile needs a valid access token. A token refresh is the one other moment in a profile's life that holds one — every other consumer reads credentials rather than minting them. So it is the only place the gap can be closed without an interactive re-login.

What it costs

One extra GET per profile, not per refresh. The lookup is gated on either field being absent, so the next refresh reads the value this one wrote and skips it. A steady-state fleet does zero extra requests.

Three further properties, all deliberate:

  • One store write. The fetched fields are merged into the same claudeAiOauth object before store.write(), so a backfilling refresh still writes once.
  • Disk always wins. The fetched fields are spread under the stored ones, so a backfill can never overwrite what a login recorded — it can only fill a hole.
  • Best-effort. fetchOAuthPlanFields already swallows its own failures and returns {}, so a profile endpoint that is down, slow or 401ing leaves the refresh itself completely untouched. A refresh never fails because of this.

token_refresh.success now logs which fields were backfilled, so a run that filled a gap is distinguishable from one that had nothing to do.

The module move

The plan lookup moves from src/proxy/profileCli.ts to a new leaf module src/proxy/oauthPlan.ts, unchanged apart from the new planFieldsMissing predicate.

profileCli already imports tokenRefresh for the credential store, so the refresh path importing back would close a cycle. Both callers — login and refresh — now depend on the leaf instead. The existing plan-fields tests were repointed at the new module; no assertion changed.

Verification

npm run typecheck clean. npm test green: 2568 pass, 0 fail, including 10 new tests for the backfill — that it fires when either field is missing, that it does not fire when both are present, that a fetched value never overrides a stored one, that a failing lookup leaves the refresh succeeding, and that the whole thing is still a single store write.

This PR is AI generated, but under direct supervision and on request of Nowaker.

`meridian profile add --headless` wrote four fields into
.credentials.json - accessToken, refreshToken, expiresAt, scopes - and
dropped the account's plan on the floor, even though OAuthCredentials
has declared subscriptionType and rateLimitTier all along. Measured on a
ten-account host: a credential file written by `claude login` is 471 B
and carries subscriptionType; one written by Meridian's own headless
login is 405 B and does not.

Everything downstream reads the plan back out of that file via
`claude auth status`, so a headless-created profile stays plan-unknown
forever: `meridian profile list` and /profiles/list report no
subscription type, /health omits it, and the `max`-only branch in
/v1/models - the one that advertises the larger context window - silently
never fires.

The token endpoint does not carry the plan. Claude Code reads only
access_token, refresh_token, expires_in, scope, account.{uuid,
email_address} and organization.uuid off a token response; it derives
subscriptionType and rateLimitTier from a separate authenticated
GET https://api.anthropic.com/api/oauth/profile, mapping the wire
organization_type (claude_max) onto the stored value (max). The headless
login now makes the same call, using the user:profile scope it already
requests.

The lookup is best-effort and never fails the login: on any error the
plan stays unknown and the profile is still created. Fields are spread in
only when known - `subscriptionType: undefined` would write a null-ish
key into a file the real CLI also parses.

This is forward-looking only. A profile created by an earlier version
cannot be repaired by a token refresh: the value is written at login and
nowhere else, and Anthropic's usage endpoint does not carry it. Operators
must re-run `meridian profile login <name> --headless` on affected
profiles.

doRefresh's spread over the existing credential is what keeps the value
alive across each ~8h refresh; a test pins that behaviour so it is not
later rewritten as an explicit field list, which would re-open the same
hole one refresh after every login.
The plan fields are written once, at login, and never again. A
credential file created before Meridian persisted them - or by a
`claude login` that recorded `subscriptionType` without
`rateLimitTier` - stays plan-blind for the life of the profile,
because nothing else in the lifecycle ever asks. Measured on a
ten-account host: eight profiles carried no plan at all.

A token refresh is the one other moment that holds a valid access
token, which is what `GET /api/oauth/profile` requires. So it is the
only place the gap can be closed without making the operator re-run
an interactive login on every profile.

doRefresh now asks for the plan when either field is absent, merges
the answer under the existing credential fields, and writes once. The
gate is what keeps the cost at one extra GET per profile rather than
one per ~8h refresh: the next refresh reads the value this one wrote
and skips. Spreading the fetched fields UNDER the stored ones means a
value already on disk always wins, so a backfill can never overwrite
what a login recorded. The fetch is best-effort and already swallows
its own failures, so a profile endpoint that is down leaves the
refresh itself untouched.

`token_refresh.success` now logs which fields were backfilled, so a
run that filled a gap is distinguishable from one that had nothing to
do.

The plan lookup moves from profileCli.ts to a new leaf module,
src/proxy/oauthPlan.ts, unchanged apart from the new
`planFieldsMissing` predicate. profileCli already imports tokenRefresh
for the credential store, so the refresh path importing back would
close a cycle. Both callers - login and refresh - now depend on the
leaf.

AI-Tool: opencode
AI-Model: anthropic/claude-opus-5
AI-Platform: linux
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant