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
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, it, mock } from 'bun:test';
import type { QuotaCheckerConfig } from '../../../../types/quota';
import { SyntheticQuotaChecker } from '../synthetic-checker';
import { QuotaCheckerFactory } from '../../quota-checker-factory';

const makeConfig = (options: Record<string, unknown> = {}): QuotaCheckerConfig => ({
id: 'synthetic-test',
provider: 'synthetic',
type: 'synthetic',
enabled: true,
intervalMinutes: 30,
options: {
apiKey: 'synthetic-api-key',
...options,
},
});

describe('SyntheticQuotaChecker', () => {
const setFetchMock = (impl: (...args: any[]) => Promise<Response>): void => {
global.fetch = mock(impl) as unknown as typeof fetch;
};

beforeEach(() => {
mock.restore();
});

it('is registered under synthetic', () => {
expect(QuotaCheckerFactory.isRegistered('synthetic')).toBe(true);
});

it('maps rollingFiveHourLimit to rolling_five_hour window', async () => {
setFetchMock(async () =>
new Response(
JSON.stringify({
rollingFiveHourLimit: { remaining: 30, max: 100, nextTickAt: '2026-04-10T12:00:00Z' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
);

const result = await new SyntheticQuotaChecker(makeConfig()).checkQuota();

expect(result.success).toBe(true);
const w = result.windows?.find((w) => w.windowType === 'rolling_five_hour');
expect(w).toBeDefined();
expect(w?.remaining).toBe(30);
expect(w?.limit).toBe(100);
expect(w?.used).toBe(70);
expect(w?.unit).toBe('requests');
expect(w?.description).toBe('Rolling 5-hour limit');
});

it('maps weeklyTokenLimit dollar strings to rolling_weekly window', async () => {
setFetchMock(async () =>
new Response(
JSON.stringify({
weeklyTokenLimit: {
maxCredits: '$50.00',
remainingCredits: '$20.00',
nextRegenAt: '2026-04-17T00:00:00Z',
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
);

const result = await new SyntheticQuotaChecker(makeConfig()).checkQuota();

expect(result.success).toBe(true);
const w = result.windows?.find((w) => w.windowType === 'rolling_weekly');
expect(w).toBeDefined();
expect(w?.limit).toBeCloseTo(50);
expect(w?.remaining).toBeCloseTo(20);
expect(w?.used).toBeCloseTo(30);
expect(w?.unit).toBe('dollars');
expect(w?.description).toBe('Weekly token credits');
});

it('handles weeklyTokenLimit with dollar-sign-less credit strings', async () => {
setFetchMock(async () =>
new Response(
JSON.stringify({
weeklyTokenLimit: { maxCredits: '100', remainingCredits: '40' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
);

const result = await new SyntheticQuotaChecker(makeConfig()).checkQuota();
const w = result.windows?.find((w) => w.windowType === 'rolling_weekly');
expect(w?.limit).toBeCloseTo(100);
expect(w?.remaining).toBeCloseTo(40);
expect(w?.used).toBeCloseTo(60);
});

it('omits rolling_weekly window when credit strings are unparseable', async () => {
setFetchMock(async () =>
new Response(
JSON.stringify({
weeklyTokenLimit: { maxCredits: 'N/A', remainingCredits: 'N/A' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
);

const result = await new SyntheticQuotaChecker(makeConfig()).checkQuota();
// Window is created but limit/used are undefined — treated as no useful data
const w = result.windows?.find((w) => w.windowType === 'rolling_weekly');
expect(w?.limit).toBeUndefined();
expect(w?.used).toBeUndefined();
});

it('maps search hourly window when present', async () => {
setFetchMock(async () =>
new Response(
JSON.stringify({
search: {
hourly: { limit: 50, requests: 10, remaining: 40, renewsAt: '2026-04-10T13:00:00Z' },
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
);

const result = await new SyntheticQuotaChecker(makeConfig()).checkQuota();
const w = result.windows?.find((w) => w.windowType === 'search');
expect(w).toBeDefined();
expect(w?.limit).toBe(50);
expect(w?.remaining).toBe(40);
expect(w?.unit).toBe('requests');
});

it('returns success with empty windows when response has no known fields', async () => {
setFetchMock(async () =>
new Response(JSON.stringify({}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);

const result = await new SyntheticQuotaChecker(makeConfig()).checkQuota();
expect(result.success).toBe(true);
expect(result.windows).toHaveLength(0);
});

it('returns error for non-200 response', async () => {
setFetchMock(
async () => new Response('unauthorized', { status: 401, statusText: 'Unauthorized' })
);

const result = await new SyntheticQuotaChecker(makeConfig()).checkQuota();
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 401: Unauthorized');
});

it('sends Authorization header with api key', async () => {
let capturedAuth: string | undefined;

setFetchMock(async (_input, init) => {
capturedAuth = new Headers(init?.headers).get('Authorization') ?? undefined;
return new Response(JSON.stringify({}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
});

await new SyntheticQuotaChecker(makeConfig()).checkQuota();
expect(capturedAuth).toBe('Bearer synthetic-api-key');
});
});
55 changes: 40 additions & 15 deletions packages/backend/src/services/quota/checkers/synthetic-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ interface SyntheticQuotaResponse {
remaining?: number;
renewsAt?: string;
};
weeklyTokenLimit?: {
nextRegenAt?: string;
percentRemaining?: number;
maxCredits?: string;
remainingCredits?: string;
nextRegenCredits?: string;
};
rollingFiveHourLimit?: {
nextTickAt?: string;
tickPercent?: number;
remaining?: number;
max?: number;
limited?: boolean;
};
}

export class SyntheticQuotaChecker extends QuotaChecker {
Expand Down Expand Up @@ -52,16 +66,17 @@ export class SyntheticQuotaChecker extends QuotaChecker {
const data: SyntheticQuotaResponse = await response.json();
const windows: QuotaWindow[] = [];

if (data.subscription) {
if (data.rollingFiveHourLimit) {
const { remaining, max, nextTickAt } = data.rollingFiveHourLimit;
windows.push(
this.createWindow(
'five_hour',
data.subscription.limit,
data.subscription.requests,
data.subscription.remaining,
'rolling_five_hour',
max,
max !== undefined && remaining !== undefined ? max - remaining : undefined,
remaining,
'requests',
data.subscription.renewsAt ? new Date(data.subscription.renewsAt) : undefined,
'5-hour request quota'
nextTickAt ? new Date(nextTickAt) : undefined,
'Rolling 5-hour limit'
)
);
}
Expand All @@ -80,16 +95,26 @@ export class SyntheticQuotaChecker extends QuotaChecker {
);
}

if (data.freeToolCalls) {
if (data.weeklyTokenLimit) {
const { maxCredits, remainingCredits, nextRegenAt } = data.weeklyTokenLimit;
const parseCredits = (val?: string) => {
if (!val) return undefined;
const num = parseFloat(val.replace('$', ''));
return isNaN(num) ? undefined : num;
};
const parsedMax = parseCredits(maxCredits);
const parsedRemaining = parseCredits(remainingCredits);
windows.push(
this.createWindow(
'toolcalls',
data.freeToolCalls.limit,
data.freeToolCalls.requests,
data.freeToolCalls.remaining,
'requests',
data.freeToolCalls.renewsAt ? new Date(data.freeToolCalls.renewsAt) : undefined,
'Free tool calls (5-hour)'
'rolling_weekly',
parsedMax,
parsedMax !== undefined && parsedRemaining !== undefined
? parsedMax - parsedRemaining
: undefined,
parsedRemaining,
'dollars',
nextRegenAt ? new Date(nextRegenAt) : undefined,
'Weekly token credits'
)
);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/types/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ export type QuotaWindowType =
| 'subscription'
| 'hourly'
| 'five_hour'
| 'rolling_five_hour'
| 'toolcalls'
| 'search'
| 'daily'
| 'weekly'
| 'rolling_weekly'
| 'monthly'
| 'custom';

Expand Down
4 changes: 3 additions & 1 deletion packages/frontend/src/components/quota/CompactQuotasCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ interface CompactQuotasCardProps {
// Window type priority for display order (lower = shown first)
export const WINDOW_PRIORITY: Record<string, number> = {
five_hour: 1,
rolling_five_hour: 1,
daily: 2,
toolcalls: 3,
search: 4,
weekly: 5,
rolling_weekly: 5,
monthly: 6,
};

Expand Down Expand Up @@ -162,7 +164,7 @@ export const getTrackedWindowsForChecker = (category: string, windows: any[]): s

switch (category) {
case 'synthetic':
return ['five_hour', 'toolcalls'].filter((t) => availableTypes.has(t));
return ['rolling_weekly', 'rolling_five_hour'].filter((t) => availableTypes.has(t));
case 'claude':
case 'codex':
return ['five_hour', 'weekly'].filter((t) => availableTypes.has(t));
Expand Down
36 changes: 29 additions & 7 deletions packages/frontend/src/components/quota/QuotaHistoryModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,39 @@ export const QuotaHistoryModal: React.FC<QuotaHistoryModalProps> = ({
// Colors for different window types
const WINDOW_COLORS: Record<string, string> = {
five_hour: '#3b82f6', // blue
rolling_five_hour: '#60a5fa', // light blue
toolcalls: '#06b6d4', // cyan
search: '#8b5cf6', // violet
daily: '#10b981', // emerald
weekly: '#a855f7', // purple
rolling_weekly: '#c084fc', // light purple
monthly: '#f59e0b', // amber
subscription: '#ec4899', // pink
custom: '#6b7280', // gray
};

// Synthetic-specific display names for window types
const SYNTHETIC_DISPLAY_NAMES: Record<string, string> = {
five_hour: 'Five Hour (old)',
rolling_five_hour: 'Five Hour',
toolcalls: 'Tool Calls (old)',
rolling_weekly: 'Weekly',
};

// Check if this is a Synthetic checker
const isSynthetic = quota
? (quota.checkerType || quota.checkerId).toLowerCase().includes('synthetic')
: false;

// Format window type for display
const formatWindowType = (windowType: string): string => {
const defaultName = windowType.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());

if (!isSynthetic) return defaultName;

return SYNTHETIC_DISPLAY_NAMES[windowType] || defaultName;
};

// Process history data for the chart - group by window type
const { chartData, windowTypes } = useMemo(() => {
if (!history.length) return { chartData: [], windowTypes: [] as string[] };
Expand Down Expand Up @@ -186,10 +210,12 @@ export const QuotaHistoryModal: React.FC<QuotaHistoryModalProps> = ({
// Sort window types by priority
const priorityOrder = [
'five_hour',
'rolling_five_hour',
'toolcalls',
'search',
'daily',
'weekly',
'rolling_weekly',
'monthly',
'subscription',
'custom',
Expand Down Expand Up @@ -461,9 +487,7 @@ export const QuotaHistoryModal: React.FC<QuotaHistoryModalProps> = ({
.filter((p) => p.value !== null && p.value !== undefined)
.map((p) => {
const windowType = p.dataKey;
const displayName = windowType
.replace(/_/g, ' ')
.replace(/\b\w/g, (l) => l.toUpperCase());
const displayName = formatWindowType(windowType);
return (
<div key={windowType} className="flex items-center gap-2">
<div
Expand Down Expand Up @@ -523,9 +547,7 @@ export const QuotaHistoryModal: React.FC<QuotaHistoryModalProps> = ({
strokeWidth={2}
fillOpacity={1}
fill={`url(#color${windowType})`}
name={windowType
.replace(/_/g, ' ')
.replace(/\b\w/g, (l) => l.toUpperCase())}
name={formatWindowType(windowType)}
connectNulls={false}
/>
))}
Expand All @@ -540,7 +562,7 @@ export const QuotaHistoryModal: React.FC<QuotaHistoryModalProps> = ({
style={{ backgroundColor: WINDOW_COLORS[windowType] || '#6b7280' }}
/>
<span className="text-xs text-text-secondary">
{windowType.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
{formatWindowType(windowType)}
</span>
</div>
))}
Expand Down
Loading