feat(profiles): fill in a missing account plan at the next token refresh - #804
Draft
Nowaker wants to merge 2 commits into
Draft
feat(profiles): fill in a missing account plan at the next token refresh#804Nowaker wants to merge 2 commits into
Nowaker wants to merge 2 commits into
Conversation
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DRAFT - please do not review or merge yet
The plan fields are written once, at login, and never again.
A credential file created before Meridian persisted them — or by a
claude loginthat recordedsubscriptionTypewithoutrateLimitTier— 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/profileneeds 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:
claudeAiOauthobject beforestore.write(), so a backfilling refresh still writes once.fetchOAuthPlanFieldsalready 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.successnow 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.tsto a new leaf modulesrc/proxy/oauthPlan.ts, unchanged apart from the newplanFieldsMissingpredicate.profileClialready importstokenRefreshfor 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 typecheckclean.npm testgreen: 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.