Skip to content

Commit 1e2d522

Browse files
committed
Add right summaries and normalize Synthetic dates
Accept optional provider "right" usage summaries in formatting output and use them as left-adjacent text in tiny and classic layouts (format.ts, toast-format-grouped.ts). Add normalization for Synthetic subscription.renewsAt to parse/validate timestamps and ignore malformed values (synthetic.ts). Update tests to cover rendering of right-side summaries, Synthetic date normalization, numeric-field validation, and various Anthropic fallback/error cases; also ensure quota-status shows Synthetic diagnostics without performing a live fetch. README updated to document Synthetic API expectations.
1 parent 72e38f9 commit 1e2d522

8 files changed

Lines changed: 337 additions & 16 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,8 @@ For security, provider secrets are read from `SYNTHETIC_API_KEY`, your user/glob
538538

539539
- The plugin calls `GET https://api.synthetic.new/v2/quotas`.
540540
- It reads `subscription.limit`, `subscription.requests`, and `subscription.renewsAt`.
541-
- `/quota`, toasts, and the sidebar show one Synthetic row with remaining percent plus a compact `used/limit` summary.
541+
- Synthetic currently expects numeric JSON values for `subscription.limit` and `subscription.requests`; malformed or stringified values are treated as API-shape errors. Invalid `subscription.renewsAt` values are ignored.
542+
- `/quota`, toasts, and the sidebar show one Synthetic row with remaining percent plus the same compact `used/limit` summary used by other percent-based providers when that row data is available.
542543
- `/quota_status` shows a `synthetic` section with API-key diagnostics only; it does not do a live Synthetic fetch there.
543544
- Allowed env templates are limited to `{env:SYNTHETIC_API_KEY}`.
544545

src/lib/format.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,16 @@ export function formatQuotaRows(params: {
6262

6363
const lines: string[] = [];
6464

65-
const addPercentEntry = (name: string, resetIso: string | undefined, remaining: number) => {
65+
const addPercentEntry = (
66+
name: string,
67+
resetIso: string | undefined,
68+
remaining: number,
69+
rightSummary?: string,
70+
) => {
6671
const displayedPercent = resolveDisplayedPercent(remaining, params.percentDisplayMode);
6772
const percentLabel = formatDisplayedPercentLabel(remaining, params.percentDisplayMode);
73+
const summary = rightSummary?.trim() || "";
74+
const leftText = summary ? `${name} ${summary}` : name;
6875

6976
// Show reset countdown whenever quota is not fully available.
7077
// (i.e., any usage at all, or depleted)
@@ -74,7 +81,7 @@ export function formatQuotaRows(params: {
7481
// In tiny mode: single line with name + time + percent
7582
const tinyNameCol = maxWidth - separator.length - timeCol - separator.length - percentCol;
7683
const line = [
77-
padRight(name, tinyNameCol),
84+
padRight(leftText, tinyNameCol),
7885
padLeft(timeStr, timeCol),
7986
padLeft(percentLabel, percentCol),
8087
].join(separator);
@@ -86,7 +93,7 @@ export function formatQuotaRows(params: {
8693
// Time is right-aligned to end of bar
8794
const timeWidth = Math.max(timeStr.length, timeCol);
8895
const nameWidth = Math.max(1, barWidth - separator.length - timeWidth);
89-
const timeLine = padRight(name, nameWidth) + separator + padLeft(timeStr, timeWidth);
96+
const timeLine = padRight(leftText, nameWidth) + separator + padLeft(timeStr, timeWidth);
9097
lines.push(timeLine.slice(0, barWidth));
9198

9299
// Line 2: bar + percent (percent extends beyond bar width)
@@ -134,7 +141,7 @@ export function formatQuotaRows(params: {
134141
if (isValueEntry(entry)) {
135142
addValueEntry(entry.name, entry.resetTimeIso, entry.value);
136143
} else {
137-
addPercentEntry(entry.name, entry.resetTimeIso, entry.percentRemaining);
144+
addPercentEntry(entry.name, entry.resetTimeIso, entry.percentRemaining, entry.right);
138145
}
139146
}
140147

src/lib/synthetic.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,24 @@ function invalidSyntheticResponse(message: string): QuotaError {
4343
};
4444
}
4545

46+
function normalizeResetTimeIso(value: unknown): string | undefined {
47+
if (typeof value !== "string") {
48+
return undefined;
49+
}
50+
51+
const trimmed = value.trim();
52+
if (!trimmed) {
53+
return undefined;
54+
}
55+
56+
const parsed = Date.parse(trimmed);
57+
if (!Number.isFinite(parsed)) {
58+
return undefined;
59+
}
60+
61+
return new Date(parsed).toISOString();
62+
}
63+
4664
export async function querySyntheticQuota(): Promise<SyntheticResult> {
4765
const resolved = await resolveSyntheticApiKey();
4866
if (!resolved) return null;
@@ -80,10 +98,7 @@ export async function querySyntheticQuota(): Promise<SyntheticResult> {
8098
return invalidSyntheticResponse("Synthetic API response missing subscription.requests");
8199
}
82100

83-
const renewsAt =
84-
typeof subscription.renewsAt === "string" && subscription.renewsAt.trim().length > 0
85-
? subscription.renewsAt.trim()
86-
: undefined;
101+
const renewsAt = normalizeResetTimeIso(subscription.renewsAt);
87102

88103
const percentRemaining = clampPercent(((limit - requests) / limit) * 100);
89104

src/lib/toast-format-grouped.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,9 @@ export function formatQuotaRowsGrouped(params: {
120120
if (isTiny) {
121121
// Tiny: "label time XX%" (ignore bar)
122122
const tinyNameCol = maxWidth - separator.length - timeCol - separator.length - percentCol;
123+
const leftText = right ? `${label} ${right}` : label;
123124
const line = [
124-
padRight(label, tinyNameCol),
125+
padRight(leftText, tinyNameCol),
125126
padLeft(timeStr, timeCol),
126127
padLeft(percentLabel, percentCol),
127128
].join(separator);

tests/format.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,32 @@ describe("formatQuotaRows", () => {
6767
expect((barLine.match(//g) ?? [])).toHaveLength(2);
6868
});
6969

70+
it("renders percent-row usage summaries in classic output when providers supply them", () => {
71+
const out = formatQuotaRows({
72+
version: "1.0.0",
73+
layout: { maxWidth: 50, narrowAt: 42, tinyAt: 32 },
74+
entries: [
75+
{
76+
name: "Synthetic",
77+
right: "0/135",
78+
percentRemaining: 100,
79+
},
80+
{
81+
name: "Qwen RPM",
82+
right: "5/60",
83+
percentRemaining: 92,
84+
resetTimeIso: "2099-01-01T00:00:00.000Z",
85+
},
86+
],
87+
});
88+
89+
expect(out).toContain("Synthetic");
90+
expect(out).toContain("0/135");
91+
expect(out).toContain("Qwen RPM");
92+
expect(out).toContain("5/60");
93+
expect(out).toContain("92% left");
94+
});
95+
7096
it("shows reset countdown when quota is partially used", () => {
7197
const out = formatQuotaRows({
7298
version: "1.0.0",
@@ -176,6 +202,26 @@ describe("formatQuotaRows", () => {
176202
expect((barLine?.match(//g) ?? [])).toHaveLength(2);
177203
});
178204

205+
it("renders grouped percent-row usage summaries when providers supply them", () => {
206+
const out = formatQuotaRows({
207+
version: "1.0.0",
208+
style: "grouped",
209+
layout: { maxWidth: 50, narrowAt: 42, tinyAt: 32 },
210+
entries: [
211+
{
212+
name: "Synthetic",
213+
group: "Synthetic",
214+
label: "Quota:",
215+
right: "0/135",
216+
percentRemaining: 100,
217+
},
218+
],
219+
});
220+
221+
expect(out).toContain("Quota: 0/135");
222+
expect(out).toContain("100% left");
223+
});
224+
179225
it("locks rendered grouped toast ordering for Qwen and OpenAI provider groups", () => {
180226
const out = formatQuotaRows({
181227
version: "1.0.0",

tests/lib.anthropic.test.ts

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,19 @@ function mockJsonResponse(body: unknown, status = 200): Response {
6868
} as unknown as Response;
6969
}
7070

71+
function mockInvalidJsonResponse(status = 200): Response {
72+
return {
73+
ok: status >= 200 && status < 300,
74+
status,
75+
json: vi.fn().mockRejectedValue(new Error("invalid json")),
76+
text: vi.fn().mockResolvedValue("{"),
77+
} as unknown as Response;
78+
}
79+
7180
afterEach(() => {
72-
vi.clearAllMocks();
81+
execFileMock.mockReset();
82+
readFileMock.mockReset();
83+
fetchWithTimeoutMock.mockReset();
7384
clearAnthropicDiagnosticsCacheForTests();
7485
});
7586

@@ -366,6 +377,129 @@ describe("Claude CLI diagnostics", () => {
366377
expect(readFileMock).toHaveBeenCalledTimes(1);
367378
});
368379

380+
it("returns no quota when the Claude OAuth fallback access token is malformed", async () => {
381+
mockExecSequence([
382+
{ stdout: "claude 1.2.3\n" },
383+
{
384+
stdout: JSON.stringify({
385+
authenticated: true,
386+
}),
387+
},
388+
]);
389+
readFileMock.mockResolvedValue(
390+
JSON.stringify({
391+
claudeAiOauth: {
392+
accessToken: 123,
393+
},
394+
}),
395+
);
396+
397+
const diagnostics = await getAnthropicDiagnostics();
398+
expect(diagnostics.quotaSupported).toBe(false);
399+
expect(diagnostics.message).toContain("Claude OAuth access token missing");
400+
401+
const quota = await queryAnthropicQuota();
402+
expect(quota?.success).toBe(false);
403+
if (quota && !quota.success) {
404+
expect(quota.error).toContain("Claude OAuth access token missing");
405+
}
406+
expect(fetchWithTimeoutMock).not.toHaveBeenCalled();
407+
});
408+
409+
it("returns no quota when the Claude OAuth fallback API returns a non-2xx response", async () => {
410+
mockExecSequence([
411+
{ stdout: "claude 1.2.3\n" },
412+
{
413+
stdout: JSON.stringify({
414+
authenticated: true,
415+
}),
416+
},
417+
]);
418+
readFileMock.mockResolvedValue(
419+
JSON.stringify({
420+
claudeAiOauth: {
421+
accessToken: "oauth-access-token",
422+
},
423+
}),
424+
);
425+
fetchWithTimeoutMock.mockResolvedValue({
426+
ok: false,
427+
status: 429,
428+
json: vi.fn(),
429+
text: vi.fn().mockResolvedValue("rate\u001b[31m limited"),
430+
} as unknown as Response);
431+
432+
const diagnostics = await getAnthropicDiagnostics();
433+
expect(diagnostics.quotaSupported).toBe(false);
434+
expect(diagnostics.message).toContain("Anthropic API error 429: rate limited");
435+
expect(diagnostics.message).not.toContain("\u001b");
436+
437+
const quota = await queryAnthropicQuota();
438+
expect(quota?.success).toBe(false);
439+
if (quota && !quota.success) {
440+
expect(quota.error).toContain("Anthropic API error 429: rate limited");
441+
expect(quota.error).not.toContain("\u001b");
442+
}
443+
});
444+
445+
it("returns no quota when the Claude OAuth fallback API returns invalid JSON", async () => {
446+
mockExecSequence([
447+
{ stdout: "claude 1.2.3\n" },
448+
{
449+
stdout: JSON.stringify({
450+
authenticated: true,
451+
}),
452+
},
453+
]);
454+
readFileMock.mockResolvedValue(
455+
JSON.stringify({
456+
claudeAiOauth: {
457+
accessToken: "oauth-access-token",
458+
},
459+
}),
460+
);
461+
fetchWithTimeoutMock.mockResolvedValue(mockInvalidJsonResponse());
462+
463+
const diagnostics = await getAnthropicDiagnostics();
464+
expect(diagnostics.quotaSupported).toBe(false);
465+
expect(diagnostics.message).toContain("Failed to parse Anthropic quota response");
466+
467+
const quota = await queryAnthropicQuota();
468+
expect(quota?.success).toBe(false);
469+
if (quota && !quota.success) {
470+
expect(quota.error).toContain("Failed to parse Anthropic quota response");
471+
}
472+
});
473+
474+
it("returns no quota when the Claude OAuth fallback API returns an unexpected JSON shape", async () => {
475+
mockExecSequence([
476+
{ stdout: "claude 1.2.3\n" },
477+
{
478+
stdout: JSON.stringify({
479+
authenticated: true,
480+
}),
481+
},
482+
]);
483+
readFileMock.mockResolvedValue(
484+
JSON.stringify({
485+
claudeAiOauth: {
486+
accessToken: "oauth-access-token",
487+
},
488+
}),
489+
);
490+
fetchWithTimeoutMock.mockResolvedValue(mockJsonResponse({ ok: true }));
491+
492+
const diagnostics = await getAnthropicDiagnostics();
493+
expect(diagnostics.quotaSupported).toBe(false);
494+
expect(diagnostics.message).toContain("Unexpected Anthropic quota response shape");
495+
496+
const quota = await queryAnthropicQuota();
497+
expect(quota?.success).toBe(false);
498+
if (quota && !quota.success) {
499+
expect(quota.error).toContain("Unexpected Anthropic quota response shape");
500+
}
501+
});
502+
369503
it("falls back to plain auth status when --json is unsupported", async () => {
370504
mockExecSequence([
371505
{ stdout: "Claude CLI version 1.2.3\n" },
@@ -377,6 +511,11 @@ describe("Claude CLI diagnostics", () => {
377511
stdout: "Authenticated",
378512
},
379513
]);
514+
readFileMock.mockRejectedValue(
515+
Object.assign(new Error("missing credentials"), {
516+
code: "ENOENT",
517+
}),
518+
);
380519

381520
const diagnostics = await getAnthropicDiagnostics();
382521

tests/lib.quota-status.test.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@ const nanoGptMocks = vi.hoisted(() => ({
108108
queryNanoGptQuota: vi.fn(async () => null),
109109
}));
110110

111+
const syntheticMocks = vi.hoisted(() => ({
112+
getSyntheticKeyDiagnostics: vi.fn(async () => ({
113+
configured: false,
114+
source: null,
115+
checkedPaths: [],
116+
})),
117+
querySyntheticQuota: vi.fn(async () => null),
118+
}));
119+
111120
const openCodeGoMocks = vi.hoisted(() => ({
112121
getOpenCodeGoConfigDiagnostics: vi.fn(async () => ({
113122
state: "none" as const,
@@ -182,11 +191,8 @@ vi.mock("../src/lib/anthropic.js", () => ({
182191
}));
183192

184193
vi.mock("../src/lib/synthetic.js", () => ({
185-
getSyntheticKeyDiagnostics: vi.fn(async () => ({
186-
configured: false,
187-
source: null,
188-
checkedPaths: [],
189-
})),
194+
getSyntheticKeyDiagnostics: syntheticMocks.getSyntheticKeyDiagnostics,
195+
querySyntheticQuota: syntheticMocks.querySyntheticQuota,
190196
}));
191197

192198
vi.mock("../src/lib/chutes.js", () => ({
@@ -675,6 +681,37 @@ describe("buildQuotaStatusReport", () => {
675681
expect(report).toContain("- seven_day_remaining: 85% reset_at=2026-04-01T00:00:00.000Z");
676682
});
677683

684+
it("renders Synthetic diagnostics without performing a live Synthetic fetch", async () => {
685+
syntheticMocks.getSyntheticKeyDiagnostics.mockResolvedValueOnce({
686+
configured: true,
687+
source: "env:SYNTHETIC_API_KEY",
688+
checkedPaths: ["env:SYNTHETIC_API_KEY"],
689+
});
690+
691+
const { buildQuotaStatusReport } = await import("../src/lib/quota-status.js");
692+
const report = await buildQuotaStatusReport({
693+
configSource: "test",
694+
configPaths: [],
695+
enabledProviders: ["synthetic"],
696+
alibabaCodingPlanTier: "lite",
697+
cursorPlan: "none",
698+
pricingSnapshotSource: "auto",
699+
onlyCurrentModel: false,
700+
providerAvailability: [
701+
{
702+
id: "synthetic",
703+
enabled: true,
704+
available: true,
705+
},
706+
],
707+
generatedAtMs: Date.UTC(2026, 2, 12, 12, 45, 0),
708+
});
709+
710+
expect(report).toContain("synthetic:");
711+
expect(report).toContain("- synthetic api key: configured=true source=env:SYNTHETIC_API_KEY");
712+
expect(syntheticMocks.querySyntheticQuota).not.toHaveBeenCalled();
713+
});
714+
678715
it("reports NanoGPT live subscription and balance diagnostics when configured", async () => {
679716
nanoGptMocks.getNanoGptKeyDiagnostics.mockResolvedValueOnce({
680717
configured: true,

0 commit comments

Comments
 (0)