fix: distinguish Ultra from Max in auth status (#1574)

## Summary

- Read the optional `planTier` field from the existing `claude auth
status --json` probe. Display Max subscriptions with `max_20x` as
**Claude Ultra**, while `max_5x` remains **Claude Max**.
- Fall back to the existing subscription label when the tier is missing,
unknown, or malformed. Preserve auth-source precedence and raw plan
metadata.
- Keep the CLI-derived label when a session SDK account read reports the
same identity and plan without a tier. A fresh CLI probe can still
replace the label, including when its tier is absent.
- Cover ACP v1 and v2 notifications and responses, and document the
display behavior.

The SDK AccountInfo omits the plan tier, but the bundled CLI already
emits it in auth-status JSON. This uses that CLI-only field as
best-effort display metadata, without additional probes, direct
credential-file reads, or entitlement checks.

## Validation

- `npm run build`
- `npm run check`
- `npm run test:run` — 4,002 passed, 33 skipped
- Targeted v1 auth-status and v2 protocol tests — 341 passed
- Local bundled CLI smoke check: `subscriptionType: "max"`, `planTier:
"max_20x"` maps to `Claude Ultra`
- Live model integration tests were not run.
This commit is contained in:
Ben Brandt
2026-11-10 14:25:20 +01:00
committed by GitHub
parent 3d6eb7b5b6
commit 7d95d30ec3
5 changed files with 189 additions and 22 deletions

View File

@@ -240,6 +240,8 @@ Matching is case-insensitive and accepts multiple whitespace characters between
Other active helpers use `label: "Anthropic API key"` and `detail: "API key helper"`; unknown key-source identifiers are not exposed as display text. CLI and SDK reads share this presentation, retain stale-probe protection, and suppress unchanged notifications.
For Max subscriptions, the existing `claude auth status --json` probe's optional `planTier` distinguishes `max_20x` (displayed as `Claude Ultra`) from `max_5x` (`Claude Max`). Missing, unrecognized, or malformed tiers fall back to the subscription's usual label. Other subscriptions and auth sources keep their existing classification. The raw `account.plan` and ACP `_meta.claudeCode.plan` remain unchanged. SDK account reads lack the tier, so they preserve the known label for the same account and plan; a fresh CLI read replaces it, including when the tier is absent. This is display metadata, not an entitlement check, and requires no additional probe or direct credential-file reads.
## Zed conventions
The adapter keeps these Zed and upstream conventions unchanged for every client.

View File

@@ -188,8 +188,8 @@ export class AuthStatusReporter {
/**
* Report the identity behind a session's `AccountInfo`.
*
* `AccountInfo` is richer than the CLI probe (it is what the live query
* actually authenticates with). The v1 core calls it before the
* `AccountInfo` describes what the live query authenticates with, but lacks
* the CLI's plan tier. The v1 core calls it before the
* `--hide-claude-auth` guard may refuse the session or the turn: that flag
* blocks the *use* of a subscription, it does not make the state secret, and
* a refusal is when the client most needs to know the account it was
@@ -225,7 +225,7 @@ export class AuthStatusReporter {
}
const fromSession = fromAccountInfo(account, apiKeyHelper);
if (fromSession) {
this.set(fromSession);
this.set(mergeAuthStatus(this.current, fromSession, "session"));
} else if (!this.loggedUninformativeAccount) {
this.loggedUninformativeAccount = true;
this.options.logger.log(
@@ -337,7 +337,7 @@ export class AuthStatusReporter {
// The CLI probe can be poorer than the session `AccountInfo` for the very
// same login (no organization, say). Keep those extra fields rather than
// regressing the payload; a different identity replaces it wholesale.
const merged = mergeAuthStatus(this.current, status);
const merged = mergeAuthStatus(this.current, status, "cli");
this.set(merged);
this.options.onProbe?.(status.kind);
return merged;

View File

@@ -144,6 +144,8 @@ export type CliAuthStatus = {
orgId?: string;
orgName?: string;
subscriptionType?: string;
/** CLI-only tier, e.g. "max_5x" or "max_20x"; not in SDK AccountInfo. */
planTier?: string;
};
const NOT_LOGGED_IN: AuthStatus = {
@@ -270,6 +272,7 @@ export function fromCliStatus(stdout: string, apiKeyHelper?: string): AuthStatus
return mapAuthFields({
apiProvider: status.apiProvider,
subscriptionType: status.subscriptionType,
planTier: status.planTier,
apiKeySource: status.apiKeySource,
email: status.email,
organization: status.orgName,
@@ -290,6 +293,7 @@ export function fromCliStatus(stdout: string, apiKeyHelper?: string): AuthStatus
function mapAuthFields(fields: {
apiProvider?: string;
subscriptionType?: string;
planTier?: string;
apiKeySource?: string;
email?: string;
organization?: string;
@@ -329,7 +333,7 @@ function mapAuthFields(fields: {
if (organization) account.organization = organization;
return {
kind: "account",
label: planLabel(subscriptionType),
label: planLabel(subscriptionType, fields.planTier),
// No detail: the client falls back to `account.email` for line 2.
account,
};
@@ -337,12 +341,14 @@ function mapAuthFields(fields: {
return notLoggedInAuthStatus();
}
/** Names the plan with exactly one "Claude". A newer CLI already reports
* "Claude Max", an older one reports "max", and both must read "Claude Max".
* Each word is title-cased; a word that already carries capitals is kept. */
function planLabel(plan: string): string {
/** Names the plan with exactly one "Claude". Without a tier, "Claude Max"
* from a newer CLI and "max" from an older one both read "Claude Max".
* Each word is title-cased; a word that already carries capitals is kept.
* The CLI's max_20x tier identifies Ultra; other tiers keep the plan label. */
function planLabel(plan: string, planTier?: string): string {
const titled = plan.replace(/\S+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1));
return /^claude(\s|$)/i.test(plan) ? titled : `Claude ${titled}`;
const label = /^claude(\s|$)/i.test(plan) ? titled : `Claude ${titled}`;
return label.toLowerCase() === "claude max" && planTier === "max_20x" ? "Claude Ultra" : label;
}
/**
@@ -370,8 +376,14 @@ export function sameIdentity(a: AuthStatus, b: AuthStatus): boolean {
* organization the CLI probe omits. So when the read describes the same
* identity, its values win field by field but the fields it lacks are kept;
* when it describes a different identity, it replaces the old one wholesale.
* Session reads lack the CLI's plan tier, so keep the label for the same plan.
* A CLI read always replaces the label, including when its tier is absent.
*/
export function mergeAuthStatus(previous: AuthStatus | undefined, next: AuthStatus): AuthStatus {
export function mergeAuthStatus(
previous: AuthStatus | undefined,
next: AuthStatus,
source: "cli" | "session",
): AuthStatus {
if (!previous || !sameIdentity(previous, next)) {
return next;
}
@@ -382,6 +394,14 @@ export function mergeAuthStatus(previous: AuthStatus | undefined, next: AuthStat
if (previous.account || next.account) {
merged.account = { ...previous.account, ...next.account };
}
if (
source === "session" &&
previous.account?.plan &&
next.account?.plan &&
planLabel(previous.account.plan).toLowerCase() === planLabel(next.account.plan).toLowerCase()
) {
merged.label = previous.label;
}
if (merged.vendor === undefined && previous.vendor !== undefined) {
merged.vendor = previous.vendor;
}

View File

@@ -363,6 +363,48 @@ describe("auth status mappers", () => {
).toEqual({ kind: "account", label: "Claude subscription", detail: "OAuth token" });
});
it.each([
["max", "max_20x", "Claude Ultra"],
["Claude Max", "max_20x", "Claude Ultra"],
["max", "max_5x", "Claude Max"],
["max", "max_other", "Claude Max"],
["max", undefined, "Claude Max"],
["max", "future_tier", "Claude Max"],
["max", null, "Claude Max"],
["max", 20, "Claude Max"],
["max", { tier: "max_20x" }, "Claude Max"],
["max", ["max_20x"], "Claude Max"],
["pro", "max_20x", "Claude Pro"],
["Future Plan", "future_tier", "Claude Future Plan"],
])("labels CLI plan %j with tier %j as %j", (subscriptionType, planTier, label) => {
expect(
fromCliStatus(
JSON.stringify({
...JSON.parse(CLI_SUBSCRIPTION),
subscriptionType,
planTier,
}),
),
).toEqual({
kind: "account",
label,
account: { plan: subscriptionType, email: "user@example.com", organization: "ACME" },
});
});
it.each([
[{ apiKeySource: "env" }, { kind: "api_key", label: "Anthropic API key", detail: "env" }],
[{ apiProvider: "bedrock" }, { kind: "external", label: "AWS Bedrock" }],
[{ apiProvider: "gateway" }, { kind: "gateway", label: "Custom model gateway" }],
[{ loggedIn: false }, { kind: "none", label: "Not logged in" }],
])("does not let a subscription tier override auth fields %j", (fields, expected) => {
expect(
fromCliStatus(
JSON.stringify({ ...JSON.parse(CLI_SUBSCRIPTION), planTier: "max_20x", ...fields }),
),
).toEqual(expected);
});
it("lets a third-party backend outrank the logged-out flag", () => {
// `loggedIn` tracks the claude.ai login only; on Bedrock the credentials
// are external and the agent is authenticated all the same.
@@ -384,19 +426,27 @@ describe("auth status mappers", () => {
};
// Same login, read from a source without the organization: it survives.
expect(
mergeAuthStatus(stored, {
kind: "account",
label: "Claude Max",
account: { plan: "max", email: "user@example.com" },
}),
mergeAuthStatus(
stored,
{
kind: "account",
label: "Claude Max",
account: { plan: "max", email: "user@example.com" },
},
"cli",
),
).toEqual(stored);
// A different login replaces everything, organization included.
expect(
mergeAuthStatus(stored, {
kind: "account",
label: "Claude Pro",
account: { plan: "pro", email: "other@example.com" },
}),
mergeAuthStatus(
stored,
{
kind: "account",
label: "Claude Pro",
account: { plan: "pro", email: "other@example.com" },
},
"cli",
),
).toEqual({
kind: "account",
label: "Claude Pro",
@@ -404,7 +454,11 @@ describe("auth status mappers", () => {
});
// A different kind never merges, even when it looks adjacent.
expect(
mergeAuthStatus(stored, { kind: "api_key", label: "Anthropic API key", detail: "env" }),
mergeAuthStatus(
stored,
{ kind: "api_key", label: "Anthropic API key", detail: "env" },
"cli",
),
).toEqual({ kind: "api_key", label: "Anthropic API key", detail: "env" });
// Key sources identify api_key payloads.
expect(
@@ -414,6 +468,29 @@ describe("auth status mappers", () => {
),
).toBe(false);
});
it.each<["cli" | "session", string, string, string | undefined, string]>([
["session", "max", "user@example.com", undefined, "Claude Ultra"],
["session", "Claude Max", "user@example.com", undefined, "Claude Ultra"],
["session", "pro", "user@example.com", undefined, "Claude Pro"],
["session", "max", "other@example.com", undefined, "Claude Max"],
["cli", "max", "user@example.com", "max_5x", "Claude Max"],
["cli", "max", "user@example.com", undefined, "Claude Max"],
["cli", "max", "user@example.com", "future_tier", "Claude Max"],
])(
"merges a %s read of %s for %s with tier %s as %s",
(source, subscriptionType, email, planTier, label) => {
const previous = fromCliStatus(
JSON.stringify({ ...JSON.parse(CLI_SUBSCRIPTION), planTier: "max_20x" }),
);
const fields = { apiProvider: "firstParty" as const, subscriptionType, email };
const next =
source === "session"
? fromAccountInfo(fields)
: fromCliStatus(JSON.stringify({ ...fields, loggedIn: true, planTier }));
expect(mergeAuthStatus(previous, next!, source).label).toBe(label);
},
);
});
describe("auth status over ACP", () => {
@@ -859,6 +936,32 @@ describe("auth status over ACP", () => {
});
});
it.each(["max_5x", undefined, "future_tier"])(
"keeps Ultra across session reads and refreshes the label for CLI tier %j",
async (planTier) => {
statusStdout = JSON.stringify({ ...JSON.parse(CLI_SUBSCRIPTION), planTier: "max_20x" });
await initialize();
const sessionId = await scriptedSession({
apiProvider: "firstParty",
subscriptionType: "Claude Max",
email: "user@example.com",
});
await promptOnce(sessionId);
expect(updates().every((call) => call[1].authStatus.label === "Claude Ultra")).toBe(true);
expect(await agent.authStatusResponse()).toMatchObject({
accounts: [{ label: "Claude Ultra", _meta: { claudeCode: { plan: "max" } } }],
});
await settle();
statusStdout = JSON.stringify({ ...JSON.parse(CLI_SUBSCRIPTION), planTier });
await promptOnce(sessionId);
expect(lastPush()?.label).toBe("Claude Max");
expect(authStatusUpdate.mock.calls.at(-1)?.[0]).toMatchObject({
accounts: [{ label: "Claude Max" }],
});
},
);
it("lets a probe of a different identity replace the stored one wholesale", async () => {
const sessionId = await scriptedSession({
apiProvider: "firstParty",

View File

@@ -9379,6 +9379,48 @@ describe("ACP v2 auth", () => {
expect(acpAuthUpdates).toEqual([status]);
});
it.each(["max_5x", undefined, "future_tier"])(
"keeps Ultra across session reads and refreshes the label for CLI tier %j",
async (nextTier) => {
let planTier: string | undefined = "max_20x";
execFileSpy.mockImplementation((...invocation: unknown[]) => {
const callback = invocation[invocation.length - 1] as (...a: unknown[]) => void;
callback(null, {
stdout: JSON.stringify({ ...JSON.parse(CLI_SUBSCRIPTION), planTier }),
stderr: "",
});
});
const account = {
apiProvider: "firstParty",
subscriptionType: "Claude Max",
email: "user@example.com",
};
const initializationResult = sdk.initializationResult;
sdk.initializationResult = async () => ({ ...(await initializationResult()), account });
sdk.accountInfo = async () => account;
const { app, authUpdate, acpAuthUpdates } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
expect((await authUpdate(1)).authStatus.label).toBe("Claude Ultra");
await agent.request(v2.methods.agent.session.new, { cwd });
expect(await agent.request(v2.methods.agent.auth.status, {})).toMatchObject({
accounts: [{ label: "Claude Ultra" }],
});
expect(
acpAuthUpdates.every((update) => update.accounts?.[0].label === "Claude Ultra"),
).toBe(true);
planTier = nextTier;
await agent.request(v2.methods.agent.auth.status, {});
await vi.waitFor(() =>
expect(acpAuthUpdates.at(-1)).toMatchObject({
accounts: [{ label: "Claude Max", _meta: { claudeCode: { plan: "max" } } }],
}),
);
});
},
);
/**
* In a remote environment with --hide-claude-auth, initialize offers the
* console login as a terminal method and hides the TUI subscription login.