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
2 changes: 2 additions & 0 deletions src/types/RenderContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface RenderUsageData {
weeklySonnetResetAt?: string;
weeklyOpusUsage?: number;
weeklyOpusResetAt?: string;
fableUsage?: number;
fableResetAt?: string;
extraUsageEnabled?: boolean;
extraUsageLimit?: number;
extraUsageUsed?: number;
Expand Down
159 changes: 157 additions & 2 deletions src/utils/__tests__/usage-fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,19 @@ describe('fetchUsageData error handling', () => {
resets_at: '2030-01-07T00:00:00.000Z'
}
});
const placeholderFableResponseBody = JSON.stringify({
five_hour: {
utilization: 42,
resets_at: '2030-01-01T00:00:00.000Z'
},
seven_day: {
utilization: 17,
resets_at: '2030-01-07T00:00:00.000Z'
},
limits: [
{ kind: 'weekly_scoped', percent: 0, resets_at: null, scope: { model: { display_name: 'Claude 3.5 Fable' } } }
]
});
const updatedSuccessResponseBody = JSON.stringify({
five_hour: {
utilization: 55,
Expand Down Expand Up @@ -699,6 +712,92 @@ describe('fetchUsageData error handling', () => {
}
});

it('treats a missing fable window as conclusive when core usage fields are present', () => {
const harness = createProbeHarness();

try {
const home = harness.createTokenHome('missing-fable-conclusive');
const requiredFields = ['fableUsage'];
const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'success',
nowMs,
pathDir: home.bin,
requiredFields,
responseBody: successResponseBody
});

expect(result.first).toEqual({
sessionUsage: 42,
sessionResetAt: '2030-01-01T00:00:00.000Z',
weeklyUsage: 17,
weeklyResetAt: '2030-01-07T00:00:00.000Z'
});
expect(result.second).toEqual(result.first);
expect(result.requestCount).toBe(1);

const cacheMtimeMs = fs.statSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.json')).mtimeMs;
const cachedResult = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'unexpected',
nowMs: cacheMtimeMs + 10000,
pathDir: home.bin,
requiredFields
});

expect(cachedResult.first).toEqual(result.first);
expect(cachedResult.second).toEqual(result.first);
expect(cachedResult.requestCount).toBe(0);
} finally {
harness.cleanup();
}
});

it('treats a placeholder fable window as conclusive when core usage fields are present', () => {
const harness = createProbeHarness();

try {
const home = harness.createTokenHome('placeholder-fable-conclusive');
const requiredFields = ['fableUsage'];
const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'success',
nowMs,
pathDir: home.bin,
requiredFields,
responseBody: placeholderFableResponseBody
});

expect(result.first).toEqual({
sessionUsage: 42,
sessionResetAt: '2030-01-01T00:00:00.000Z',
weeklyUsage: 17,
weeklyResetAt: '2030-01-07T00:00:00.000Z'
});
expect(result.second).toEqual(result.first);
expect(result.requestCount).toBe(1);

const cacheMtimeMs = fs.statSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.json')).mtimeMs;
const cachedResult = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'unexpected',
nowMs: cacheMtimeMs + 10000,
pathDir: home.bin,
requiredFields
});

expect(cachedResult.first).toEqual(result.first);
expect(cachedResult.second).toEqual(result.first);
expect(cachedResult.requestCount).toBe(0);
} finally {
harness.cleanup();
}
});

it('clears the in-flight lock after a successful fetch', () => {
const harness = createProbeHarness();

Expand Down Expand Up @@ -1484,15 +1583,71 @@ describe('parseUsageApiResponse limits[] fallback (#503)', () => {
expect(parseUsageApiResponse(placeholderLimitResponseBody)).toEqual({});
});

it('ignores unknown limits[] kinds and weekly_scoped entries without error', () => {
it('ignores unknown limits[] kinds but consumes fable weekly_scoped entries', () => {
expect(parseUsageApiResponse(unknownKindsAndScopedResponseBody)).toEqual({
sessionUsage: 10,
sessionResetAt: '2030-05-01T00:00:00.000Z',
weeklyUsage: 20,
weeklyResetAt: '2030-05-07T00:00:00.000Z'
weeklyResetAt: '2030-05-07T00:00:00.000Z',
fableUsage: 71,
fableResetAt: '2030-05-07T00:00:00.000Z'
});
});

it('derives fable usage/reset from a weekly_scoped entry with Fable case variants', () => {
expect(parseUsageApiResponse(JSON.stringify({
limits: [
{
kind: 'weekly_scoped',
percent: 22,
resets_at: '2030-05-08T00:00:00.000Z',
scope: { model: { display_name: 'claude 3.5 fAbLe' } }
}
]
}))).toEqual({
fableUsage: 22,
fableResetAt: '2030-05-08T00:00:00.000Z'
});
});

it('ignores weekly_scoped entry with NON-fable display_name', () => {
expect(parseUsageApiResponse(JSON.stringify({
limits: [
{
kind: 'weekly_scoped',
percent: 99,
resets_at: '2030-05-08T00:00:00.000Z',
scope: { model: { display_name: 'Sonnet' } }
}
]
}))).toEqual({});
});

it('absent when fable placeholder (percent 0, no resets_at)', () => {
expect(parseUsageApiResponse(JSON.stringify({
limits: [
{
kind: 'weekly_scoped',
percent: 0,
resets_at: null,
scope: { model: { display_name: 'Fable' } }
}
]
}))).toEqual({});
});

it('ignores weekly_scoped when no scope -> no crash', () => {
expect(parseUsageApiResponse(JSON.stringify({
limits: [
{
kind: 'weekly_scoped',
percent: 10,
resets_at: '2030-05-08T00:00:00.000Z'
}
]
}))).toEqual({});
});

it('behaves identically to before when limits[] is absent', () => {
expect(parseUsageApiResponse(noLimitsResponseBody)).toEqual({
sessionUsage: 42,
Expand Down
43 changes: 43 additions & 0 deletions src/utils/__tests__/usage-prefetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,32 @@ describe('usage prefetch', () => {
expect(mockFetchUsageData.mock.calls.length).toBe(1);
});

it('triggers API fetch for fable-weekly-usage even when stdin rate_limits is fully populated', async () => {
mockFetchUsageData.mockResolvedValue({
sessionUsage: 42,
sessionResetAt: '2026-03-20T12:00:00.000Z',
weeklyUsage: 15,
weeklyResetAt: '2026-03-27T12:00:00.000Z',
fableUsage: 5,
fableResetAt: '2026-03-27T12:00:00.000Z'
});

const lines = makeLines(
[{ id: '1', type: 'fable-weekly-usage' }]
);

const usageData = await prefetchUsageDataIfNeeded(lines, {
rate_limits: {
five_hour: { used_percentage: 42, resets_at: 1774020000 },
seven_day: { used_percentage: 15, resets_at: 1774540000 }
}
});

expect(usageData?.fableUsage).toBe(5);
expect(mockFetchUsageData.mock.calls.length).toBe(1);
expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['fableUsage'] }]);
});

it.each([
{
apiUsageData: { weeklySonnetUsage: 8 },
Expand Down Expand Up @@ -379,6 +405,23 @@ describe('usage prefetch', () => {
expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['weeklySonnetUsage'] }]);
});

it('uses aggregate weekly reset as the cursor fallback without fetching for fable reset data', async () => {
mockFetchUsageData.mockResolvedValue({ fableUsage: 8 });

const lines = makeLines(
[{ id: '1', type: 'fable-weekly-usage', metadata: { cursor: 'true' } }]
);

const usageData = await prefetchUsageDataIfNeeded(lines, { rate_limits: { seven_day: { resets_at: 1774540000 } } });

expect(usageData).toEqual({
weeklyResetAt: epochToIso(1774540000),
fableUsage: 8
});
expect(mockFetchUsageData.mock.calls.length).toBe(1);
expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['fableUsage'] }]);
});

it('fetches extra usage fields while preserving statusline usage data', async () => {
mockFetchUsageData.mockResolvedValue({
extraUsageEnabled: true,
Expand Down
27 changes: 24 additions & 3 deletions src/utils/usage-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ const EXTRA_USAGE_DETAIL_FIELDS = new Set<UsageDataField>([
'extraUsageUtilization'
]);

const FABLE_USAGE_FIELDS = new Set<UsageDataField>([
'fableUsage',
'fableResetAt'
]);

// Maps each window reset field to the utilization field parsed from the same
// API bucket. A null bucket (Enterprise accounts have no rate-limit windows,
// #343) parses to utilization 0 with no resets_at, so once the utilization is
Expand All @@ -42,7 +47,8 @@ const WINDOW_RESET_FIELD_SENTINELS: Partial<Record<UsageDataField, UsageDataFiel
sessionResetAt: 'sessionUsage',
weeklyResetAt: 'weeklyUsage',
weeklySonnetResetAt: 'weeklySonnetUsage',
weeklyOpusResetAt: 'weeklyOpusUsage'
weeklyOpusResetAt: 'weeklyOpusUsage',
fableResetAt: 'fableUsage'
};

const UsageCredentialsSchema = z.object({ claudeAiOauth: z.object({ accessToken: z.string().nullable().optional() }).optional() });
Expand All @@ -61,6 +67,8 @@ const CachedUsageDataSchema = z.object({
weeklySonnetResetAt: z.string().nullable().optional(),
weeklyOpusUsage: z.number().nullable().optional(),
weeklyOpusResetAt: z.string().nullable().optional(),
fableUsage: z.number().nullable().optional(),
fableResetAt: z.string().nullable().optional(),
extraUsageEnabled: z.boolean().nullable().optional(),
extraUsageLimit: z.number().nullable().optional(),
extraUsageUsed: z.number().nullable().optional(),
Expand All @@ -85,7 +93,8 @@ type UsageApiBucket = z.infer<typeof UsageApiBucketSchema>;
const UsageApiLimitSchema = z.looseObject({
kind: z.string().nullable().optional(),
percent: z.number().nullable().optional(),
resets_at: z.string().nullable().optional()
resets_at: z.string().nullable().optional(),
scope: z.looseObject({ model: z.looseObject({ display_name: z.string().nullable().optional() }).nullable().optional() }).nullable().catch(null).optional()
});

type UsageApiLimit = z.infer<typeof UsageApiLimitSchema>;
Expand Down Expand Up @@ -159,6 +168,8 @@ function parseCachedUsageData(rawJson: string): UsageData | null {
weeklySonnetResetAt: parsed.weeklySonnetResetAt ?? undefined,
weeklyOpusUsage: parsed.weeklyOpusUsage ?? undefined,
weeklyOpusResetAt: parsed.weeklyOpusResetAt ?? undefined,
fableUsage: parsed.fableUsage ?? undefined,
fableResetAt: parsed.fableResetAt ?? undefined,
extraUsageEnabled: parsed.extraUsageEnabled ?? undefined,
extraUsageLimit: parsed.extraUsageLimit ?? undefined,
extraUsageUsed: parsed.extraUsageUsed ?? undefined,
Expand Down Expand Up @@ -201,6 +212,7 @@ export function parseUsageApiResponse(rawJson: string): UsageData | null {
// the percentage and resets_at can each come from a different source (#503).
const sessionLimit = findUsageApiLimit(parsed.limits, 'session');
const weeklyLimit = findUsageApiLimit(parsed.limits, 'weekly_all');
const fableLimit = parsed.limits?.find(limit => limit.kind === 'weekly_scoped' && (limit.scope?.model?.display_name ?? '').toLowerCase().includes('fable'));

return {
sessionUsage: getUsageApiBucketUtilization(parsed.five_hour) ?? getUsageApiLimitPercent(sessionLimit),
Expand All @@ -211,6 +223,8 @@ export function parseUsageApiResponse(rawJson: string): UsageData | null {
weeklySonnetResetAt: parsed.seven_day_sonnet?.resets_at ?? undefined,
weeklyOpusUsage: getUsageApiBucketUtilization(parsed.seven_day_opus),
weeklyOpusResetAt: parsed.seven_day_opus?.resets_at ?? undefined,
fableUsage: getUsageApiLimitPercent(fableLimit),
fableResetAt: getUsageApiLimitResetAt(fableLimit),
extraUsageEnabled: parsed.extra_usage?.is_enabled ?? undefined,
extraUsageLimit: parsed.extra_usage?.monthly_limit ?? undefined,
extraUsageUsed: parsed.extra_usage?.used_credits ?? undefined,
Expand Down Expand Up @@ -267,7 +281,14 @@ function hasRequiredUsageField(data: UsageData, field: UsageDataField): boolean
// Once the API has reported the extra usage state, missing detail fields are
// conclusive: accounts without a configured monthly limit never report
// monthly_limit/utilization, so refetching cannot produce them (#413).
return data.extraUsageEnabled !== undefined && EXTRA_USAGE_DETAIL_FIELDS.has(field);
if (data.extraUsageEnabled !== undefined && EXTRA_USAGE_DETAIL_FIELDS.has(field)) {
return true;
}

// Once the API has reported the core usage state, a missing fable window is
// conclusive: legacy accounts and non-Fable plans never report a fable
// limit, so refetching cannot produce it.
return (data.sessionUsage !== undefined || data.weeklyUsage !== undefined) && FABLE_USAGE_FIELDS.has(field);
}

function hasRequiredUsageFields(data: UsageData, requiredFields: readonly UsageDataField[] = []): boolean {
Expand Down
9 changes: 8 additions & 1 deletion src/utils/usage-prefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const USAGE_WIDGET_TYPES = new Set<string>([
'weekly-usage',
'weekly-sonnet-usage',
'weekly-opus-usage',
'fable-weekly-usage',
'block-timer',
'reset-timer',
'weekly-reset-timer',
Expand All @@ -28,6 +29,8 @@ const USAGE_DATA_FIELDS: UsageDataField[] = [
'weeklySonnetResetAt',
'weeklyOpusUsage',
'weeklyOpusResetAt',
'fableUsage',
'fableResetAt',
'extraUsageEnabled',
'extraUsageLimit',
'extraUsageUsed',
Expand All @@ -48,6 +51,7 @@ const USAGE_WIDGET_REQUIREMENTS: Record<string, UsageFieldRequirement[]> = {
'weekly-usage': [{ field: 'weeklyUsage' }],
'weekly-sonnet-usage': [{ field: 'weeklySonnetUsage' }],
'weekly-opus-usage': [{ field: 'weeklyOpusUsage' }],
'fable-weekly-usage': [{ field: 'fableUsage' }],
'block-timer': [{ field: 'sessionResetAt', suppressFetchError: true }],
'reset-timer': [{ field: 'sessionResetAt', suppressFetchError: true }],
'weekly-reset-timer': [{ field: 'weeklyResetAt', suppressFetchError: true }],
Expand All @@ -70,7 +74,8 @@ const USAGE_CURSOR_REQUIREMENTS: Record<string, UsageFieldRequirement> = {
'session-usage': { field: 'sessionResetAt' },
'weekly-usage': { field: 'weeklyResetAt' },
'weekly-sonnet-usage': { field: 'weeklySonnetResetAt', alternatives: ['weeklyResetAt'] },
'weekly-opus-usage': { field: 'weeklyOpusResetAt', alternatives: ['weeklyResetAt'] }
'weekly-opus-usage': { field: 'weeklyOpusResetAt', alternatives: ['weeklyResetAt'] },
'fable-weekly-usage': { field: 'fableResetAt', alternatives: ['weeklyResetAt'] }
};

export function hasUsageDependentWidgets(lines: WidgetItem[][]): boolean {
Expand Down Expand Up @@ -146,6 +151,8 @@ function pickDefinedUsageFields(data: UsageData | null | undefined): Partial<Usa
...(data?.weeklySonnetResetAt !== undefined ? { weeklySonnetResetAt: data.weeklySonnetResetAt } : {}),
...(data?.weeklyOpusUsage !== undefined ? { weeklyOpusUsage: data.weeklyOpusUsage } : {}),
...(data?.weeklyOpusResetAt !== undefined ? { weeklyOpusResetAt: data.weeklyOpusResetAt } : {}),
...(data?.fableUsage !== undefined ? { fableUsage: data.fableUsage } : {}),
...(data?.fableResetAt !== undefined ? { fableResetAt: data.fableResetAt } : {}),
...(data?.extraUsageEnabled !== undefined ? { extraUsageEnabled: data.extraUsageEnabled } : {}),
...(data?.extraUsageLimit !== undefined ? { extraUsageLimit: data.extraUsageLimit } : {}),
...(data?.extraUsageUsed !== undefined ? { extraUsageUsed: data.extraUsageUsed } : {}),
Expand Down
2 changes: 2 additions & 0 deletions src/utils/usage-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface UsageData {
weeklySonnetResetAt?: string; // seven_day_sonnet.resets_at
weeklyOpusUsage?: number; // seven_day_opus.utilization (percentage)
weeklyOpusResetAt?: string; // seven_day_opus.resets_at
fableUsage?: number;
fableResetAt?: string;
extraUsageEnabled?: boolean;
extraUsageLimit?: number; // in cents (divide by 100 for dollars)
extraUsageUsed?: number; // in cents (divide by 100 for dollars)
Expand Down
4 changes: 4 additions & 0 deletions src/utils/usage-windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ export function resolveWeeklyOpusUsageWindow(usageData: UsageData, nowMs = Date.
return getWeeklyUsageWindowFromResetAt(usageData.weeklyOpusResetAt ?? usageData.weeklyResetAt, nowMs);
}

export function resolveFableUsageWindow(usageData: UsageData, nowMs = Date.now()): UsageWindowMetrics | null {
return getWeeklyUsageWindowFromResetAt(usageData.fableResetAt ?? usageData.weeklyResetAt, nowMs);
}

export function formatUsageDuration(durationMs: number, compact = false, useDays = true): string {
const clampedMs = Math.max(0, durationMs);
const totalHours = Math.floor(clampedMs / (1000 * 60 * 60));
Expand Down
Loading