Skip to content

Commit 29908c2

Browse files
committed
fix: usage API parsing for nullable cohort response buckets
1 parent b01336b commit 29908c2

3 files changed

Lines changed: 116 additions & 20 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ccstatusline",
3-
"version": "2.2.16",
3+
"version": "2.2.17",
44
"description": "A customizable status line formatter for Claude Code CLI",
55
"module": "src/ccstatusline.ts",
66
"type": "module",

src/utils/__tests__/usage-fetch.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,32 @@ describe('fetchUsageData error handling', () => {
270270
seven_day_sonnet: null,
271271
seven_day_opus: null
272272
});
273+
const cohortResponseBody = JSON.stringify({
274+
five_hour: {
275+
utilization: 52,
276+
resets_at: '2030-01-01T00:00:00.000Z'
277+
},
278+
seven_day: null,
279+
seven_day_oauth_apps: null,
280+
seven_day_sonnet: null,
281+
seven_day_opus: null,
282+
seven_day_cowork: null,
283+
seven_day_omelette: {
284+
utilization: 0,
285+
resets_at: null
286+
},
287+
tangelo: null,
288+
iguana_necktie: null,
289+
omelette_promotional: null,
290+
extra_usage: {
291+
is_enabled: false,
292+
monthly_limit: null,
293+
used_credits: null,
294+
utilization: null,
295+
currency: null,
296+
disabled_reason: null
297+
}
298+
});
273299
const rateLimitedResponseBody = JSON.stringify({
274300
error: {
275301
message: 'Rate limited. Please try again later.',
@@ -477,6 +503,73 @@ describe('fetchUsageData error handling', () => {
477503
}
478504
});
479505

506+
it('parses null aggregate buckets and cohort fields from the usage API', () => {
507+
const harness = createProbeHarness();
508+
509+
try {
510+
const home = harness.createTokenHome('cohort-fields');
511+
const result = harness.runProbe({
512+
claudeConfigDir: home.claudeConfig,
513+
home: home.home,
514+
mode: 'success',
515+
nowMs,
516+
pathDir: home.bin,
517+
requiredFields: ['weeklyUsage', 'weeklySonnetUsage', 'weeklyOpusUsage', 'extraUsageEnabled'],
518+
responseBody: cohortResponseBody
519+
});
520+
521+
expect(result.first).toEqual({
522+
sessionUsage: 52,
523+
sessionResetAt: '2030-01-01T00:00:00.000Z',
524+
weeklyUsage: 0,
525+
weeklySonnetUsage: 0,
526+
weeklyOpusUsage: 0,
527+
extraUsageEnabled: false
528+
});
529+
expect(result.second).toEqual(result.first);
530+
expect(result.requestCount).toBe(1);
531+
} finally {
532+
harness.cleanup();
533+
}
534+
});
535+
536+
it('keeps parse-error locks distinct from timeout locks', () => {
537+
const harness = createProbeHarness();
538+
539+
try {
540+
const home = harness.createTokenHome('parse-error-lock');
541+
const parseErrorResult = harness.runProbe({
542+
claudeConfigDir: home.claudeConfig,
543+
home: home.home,
544+
mode: 'success',
545+
nowMs,
546+
pathDir: home.bin,
547+
responseBody: '{'
548+
});
549+
550+
expect(parseErrorResult.first).toEqual({ error: 'parse-error' });
551+
expect(parseErrorResult.second).toEqual({ error: 'parse-error' });
552+
expect(parseLockContents(parseErrorResult.lockContents)).toEqual({
553+
blockedUntil: Math.floor(nowMs / 1000) + 30,
554+
error: 'parse-error'
555+
});
556+
557+
const activeLockResult = harness.runProbe({
558+
claudeConfigDir: home.claudeConfig,
559+
home: home.home,
560+
mode: 'unexpected',
561+
nowMs,
562+
pathDir: home.bin
563+
});
564+
565+
expect(activeLockResult.first).toEqual({ error: 'parse-error' });
566+
expect(activeLockResult.second).toEqual({ error: 'parse-error' });
567+
expect(activeLockResult.requestCount).toBe(0);
568+
} finally {
569+
harness.cleanup();
570+
}
571+
});
572+
480573
it('bypasses fresh aggregate-only cache when requested per-model fields are missing', () => {
481574
const harness = createProbeHarness();
482575

src/utils/usage-fetch.ts

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type UsageDataField = Exclude<keyof UsageData, 'error'>;
2828
export interface FetchUsageDataOptions { requiredFields?: readonly UsageDataField[] }
2929

3030
const UsageCredentialsSchema = z.object({ claudeAiOauth: z.object({ accessToken: z.string().nullable().optional() }).optional() });
31-
const UsageLockErrorSchema = z.enum(['timeout', 'rate-limited']);
31+
const UsageLockErrorSchema = z.enum(['timeout', 'rate-limited', 'parse-error']);
3232
const UsageLockSchema = z.object({
3333
blockedUntil: z.number(),
3434
error: UsageLockErrorSchema.optional()
@@ -50,30 +50,30 @@ const CachedUsageDataSchema = z.object({
5050
error: z.string().nullable().optional()
5151
});
5252

53-
const PerModelWeeklyBucketSchema = z.object({
53+
const UsageApiBucketSchema = z.looseObject({
5454
utilization: z.number().nullable().optional(),
5555
resets_at: z.string().nullable().optional()
5656
}).nullable().optional();
5757

58-
const UsageApiResponseSchema = z.object({
59-
five_hour: z.object({
60-
utilization: z.number().nullable().optional(),
61-
resets_at: z.string().nullable().optional()
62-
}).optional(),
63-
seven_day: z.object({
64-
utilization: z.number().nullable().optional(),
65-
resets_at: z.string().nullable().optional()
66-
}).optional(),
67-
seven_day_sonnet: PerModelWeeklyBucketSchema,
68-
seven_day_opus: PerModelWeeklyBucketSchema,
69-
extra_usage: z.object({
58+
type UsageApiBucket = z.infer<typeof UsageApiBucketSchema>;
59+
60+
const UsageApiResponseSchema = z.looseObject({
61+
five_hour: UsageApiBucketSchema,
62+
seven_day: UsageApiBucketSchema,
63+
seven_day_sonnet: UsageApiBucketSchema,
64+
seven_day_opus: UsageApiBucketSchema,
65+
extra_usage: z.looseObject({
7066
is_enabled: z.boolean().nullable().optional(),
7167
monthly_limit: z.number().nullable().optional(),
7268
used_credits: z.number().nullable().optional(),
7369
utilization: z.number().nullable().optional()
74-
}).optional()
70+
}).nullable().optional()
7571
});
7672

73+
function getUsageApiBucketUtilization(bucket: UsageApiBucket): number | undefined {
74+
return bucket === null ? 0 : bucket?.utilization ?? undefined;
75+
}
76+
7777
function parseJsonWithSchema<T>(rawJson: string, schema: z.ZodType<T>): T | null {
7878
try {
7979
const parsed = schema.safeParse(JSON.parse(rawJson));
@@ -120,13 +120,13 @@ function parseUsageApiResponse(rawJson: string): UsageData | null {
120120
}
121121

122122
return {
123-
sessionUsage: parsed.five_hour?.utilization ?? undefined,
123+
sessionUsage: getUsageApiBucketUtilization(parsed.five_hour),
124124
sessionResetAt: parsed.five_hour?.resets_at ?? undefined,
125-
weeklyUsage: parsed.seven_day?.utilization ?? undefined,
125+
weeklyUsage: getUsageApiBucketUtilization(parsed.seven_day),
126126
weeklyResetAt: parsed.seven_day?.resets_at ?? undefined,
127-
weeklySonnetUsage: parsed.seven_day_sonnet === null ? 0 : parsed.seven_day_sonnet?.utilization ?? undefined,
127+
weeklySonnetUsage: getUsageApiBucketUtilization(parsed.seven_day_sonnet),
128128
weeklySonnetResetAt: parsed.seven_day_sonnet?.resets_at ?? undefined,
129-
weeklyOpusUsage: parsed.seven_day_opus === null ? 0 : parsed.seven_day_opus?.utilization ?? undefined,
129+
weeklyOpusUsage: getUsageApiBucketUtilization(parsed.seven_day_opus),
130130
weeklyOpusResetAt: parsed.seven_day_opus?.resets_at ?? undefined,
131131
extraUsageEnabled: parsed.extra_usage?.is_enabled ?? undefined,
132132
extraUsageLimit: parsed.extra_usage?.monthly_limit ?? undefined,
@@ -569,11 +569,13 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi
569569

570570
const usageData = parseUsageApiResponse(response.body);
571571
if (!usageData) {
572+
writeUsageLock(now + LOCK_MAX_AGE, 'parse-error');
572573
return getStaleUsageOrError('parse-error', now, LOCK_MAX_AGE, requiredFields);
573574
}
574575

575576
// Validate we got actual data
576577
if (usageData.sessionUsage === undefined && usageData.weeklyUsage === undefined) {
578+
writeUsageLock(now + LOCK_MAX_AGE, 'parse-error');
577579
return getStaleUsageOrError('parse-error', now, LOCK_MAX_AGE, requiredFields);
578580
}
579581

@@ -587,6 +589,7 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi
587589

588590
return cacheUsageData(usageData, now);
589591
} catch {
592+
writeUsageLock(now + LOCK_MAX_AGE, 'parse-error');
590593
return getStaleUsageOrError('parse-error', now, LOCK_MAX_AGE, requiredFields);
591594
}
592595
}

0 commit comments

Comments
 (0)