diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 2bf2edec5f..820f7cfb4a 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -554,9 +554,7 @@ async function getGlmUsage(apiKey: string, providerSpecificData?: Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function firstNonEmptyString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== "string") { + continue; + } + + const trimmed = value.trim(); + if (trimmed) { + return trimmed; + } + } + + return undefined; +} + +function extractPlanFromPayload(payload: unknown): string | undefined { + const data = toRecord(payload); + const billing = toRecord(data.billing); + + return firstNonEmptyString(data.account_tier, data.plan, data.subscription_type, billing.plan); +} + +function extractClaudePlan(tokens: unknown, extra: unknown): string | undefined { + const extraData = toRecord(extra); + + return firstNonEmptyString( + extractPlanFromPayload(tokens), + extractPlanFromPayload(extraData.userInfo), + extractPlanFromPayload(extra) + ); +} + export const claude = { config: CLAUDE_CONFIG, flowType: "authorization_code_pkce", @@ -48,10 +86,15 @@ export const claude = { return await response.json(); }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - }), + mapTokens: (tokens, extra) => { + const plan = extractClaudePlan(tokens, extra); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + providerSpecificData: plan ? { plan } : undefined, + }; + }, }; diff --git a/tests/unit/claude-oauth-provider.test.ts b/tests/unit/claude-oauth-provider.test.ts index c451391a24..963a48b562 100644 --- a/tests/unit/claude-oauth-provider.test.ts +++ b/tests/unit/claude-oauth-provider.test.ts @@ -75,3 +75,33 @@ test("Claude OAuth provider always uses the configured redirectUri during token assert.equal(captured.body.state, "state-from-fragment"); assert.equal(captured.body.code_verifier, "verifier-123"); }); + +test("Claude OAuth token mapper persists the first non-empty token plan field", () => { + const cases = [ + [{ account_tier: " Pro ", plan: "Max" }, "Pro"], + [{ account_tier: "", plan: "Max" }, "Max"], + [{ plan: "", subscription_type: "Team" }, "Team"], + [{ subscription_type: "", billing: { plan: "Enterprise" } }, "Enterprise"], + ]; + + for (const [tokens, expected] of cases) { + const mapped = claude.mapTokens({ access_token: "token-1", ...tokens }); + + assert.equal(mapped.providerSpecificData.plan, expected); + } +}); + +test("Claude OAuth token mapper reads plan fields from userinfo extras after token fields", () => { + const mapped = claude.mapTokens( + { access_token: "token-1" }, + { userInfo: { account_tier: "", subscription_type: "Max" } } + ); + + assert.equal(mapped.providerSpecificData.plan, "Max"); +}); + +test("Claude OAuth token mapper leaves providerSpecificData.plan undefined without plan fields", () => { + const mapped = claude.mapTokens({ access_token: "token-1", scope: "user:profile" }); + + assert.equal(mapped.providerSpecificData, undefined); +}); diff --git a/tests/unit/provider-limits-ui.test.ts b/tests/unit/provider-limits-ui.test.ts index ae40f8b868..2c78c2969c 100644 --- a/tests/unit/provider-limits-ui.test.ts +++ b/tests/unit/provider-limits-ui.test.ts @@ -30,6 +30,17 @@ test("Codex workspacePlanType is used when live plan is missing or unknown", () assert.equal(tier.variant, "success"); }); +test("Claude providerSpecificData plan is used when live plan is missing", () => { + const resolvedPlan = providerLimitUtils.resolvePlanValue(null, { + plan: "Pro", + }); + + assert.equal(resolvedPlan, "Pro"); + const tier = providerLimitUtils.normalizePlanTier(resolvedPlan); + assert.equal(tier.key, "pro"); + assert.equal(tier.variant, "success"); +}); + test("remaining percentage helpers reflect remaining quota and stale resets refill to 100", () => { assert.equal(providerLimitUtils.calculatePercentage(0, 100), 100); assert.equal(providerLimitUtils.calculatePercentage(17, 100), 83);