Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions src/lib/oauth/providers/claude.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
import { CLAUDE_CONFIG } from "../constants/oauth";

function toRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}

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",
Expand Down Expand Up @@ -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,
};
},
};
30 changes: 30 additions & 0 deletions tests/unit/claude-oauth-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
11 changes: 11 additions & 0 deletions tests/unit/provider-limits-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading