fix(quota): read OpenRouter usage from /api/v1/key (#3411)
The OpenRouter provider called GET /api/v1/credits, which OpenRouter
documents as requiring a management key. Called with a normal inference
key it returns HTTP 200 and {"total_credits":0,"total_usage":0} instead
of an error, so the provider rendered "$0.00 left - $0.00 spent" for a
funded key and the !response.ok guard could never catch it.
Read GET /api/v1/key instead, which is documented as callable with any
valid API key. A key with a spending limit reports its own usage against
that limit in the window named by limit_reset, and a key without one
reports usage_monthly. Window usage is limit - limit_remaining rather
than usage, because usage is all-time and limit_remaining tracks the
current reset window. limit_remaining is also server-computed and
already honors include_byok_in_limit.
Bring the provider up to the deepseek.js standard while here: a 15s
timeout, 401 and 403 mapped to a session-expired message, parse failures
mapped to an invalid-response message, an explicit no-quota-data result,
and the aliases export that quota/DOCUMENTATION.md requires. Add the
missing openrouter.test.js and keep packages/vscode in sync.
Refs #3060
This commit is contained in:
@@ -17,6 +17,7 @@ const AUTH = JSON.stringify({
|
|||||||
crof: { key: 'test-token' },
|
crof: { key: 'test-token' },
|
||||||
neuralwatt: { key: 'test-token' },
|
neuralwatt: { key: 'test-token' },
|
||||||
'opencode-go': { key: 'test-token' },
|
'opencode-go': { key: 'test-token' },
|
||||||
|
openrouter: { key: 'test-token' },
|
||||||
'zai-coding-plan': { key: 'test-token' },
|
'zai-coding-plan': { key: 'test-token' },
|
||||||
deepseek: { key: 'test-token' },
|
deepseek: { key: 'test-token' },
|
||||||
hyper: { key: 'test-token' },
|
hyper: { key: 'test-token' },
|
||||||
@@ -114,6 +115,180 @@ describe('OpenCode Go quota provider (VS Code parity)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('OpenRouter quota provider (VS Code parity)', () => {
|
||||||
|
const documentedPayload = {
|
||||||
|
data: {
|
||||||
|
label: 'test-key',
|
||||||
|
usage: 3.17561396,
|
||||||
|
usage_daily: 0.0000018,
|
||||||
|
usage_weekly: 0.0000018,
|
||||||
|
usage_monthly: 3.17561396,
|
||||||
|
limit: 30,
|
||||||
|
limit_remaining: 29.9999982,
|
||||||
|
limit_reset: 'daily',
|
||||||
|
is_free_tier: true,
|
||||||
|
is_management_key: false,
|
||||||
|
include_byok_in_limit: false,
|
||||||
|
byok_usage: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test('reads the documented key endpoint and emits the current reset window', async () => {
|
||||||
|
let requestedUrl = '';
|
||||||
|
let requestInit: RequestInit | undefined;
|
||||||
|
globalThis.fetch = (async (url: string, init?: RequestInit) => {
|
||||||
|
requestedUrl = url;
|
||||||
|
requestInit = init;
|
||||||
|
return mockResponse(documentedPayload);
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const result = await fetchQuotaForProvider('openrouter');
|
||||||
|
|
||||||
|
assert.equal(requestedUrl, 'https://openrouter.ai/api/v1/key');
|
||||||
|
assert.equal(requestedUrl.includes('/api/v1/credits'), false);
|
||||||
|
assert.equal(new Headers(requestInit?.headers).get('Authorization'), 'Bearer test-token');
|
||||||
|
assert.equal(new Headers(requestInit?.headers).get('Accept-Encoding'), 'identity');
|
||||||
|
assert.ok(requestInit?.signal instanceof AbortSignal);
|
||||||
|
assert.deepEqual(Object.keys(result.usage!.windows), ['daily']);
|
||||||
|
assert.equal(result.usage!.windows.daily!.windowSeconds, 86400);
|
||||||
|
assert.equal(result.usage!.windows.daily!.valueLabel, '$0.00 / $30.00');
|
||||||
|
assert.ok(typeof result.usage!.windows.daily!.resetAt === 'number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maps an unlimited null-limit key to a monthly spent window', async () => {
|
||||||
|
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||||
|
data: { limit: null, limit_remaining: null, limit_reset: null, usage_monthly: 12.5, is_management_key: false },
|
||||||
|
})));
|
||||||
|
|
||||||
|
const result = await fetchQuotaForProvider('openrouter');
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.deepEqual(Object.keys(result.usage!.windows), ['monthly']);
|
||||||
|
assert.equal(result.usage!.windows.monthly!.usedPercent, null);
|
||||||
|
assert.equal(result.usage!.windows.monthly!.windowSeconds, 30 * 86400);
|
||||||
|
assert.equal(result.usage!.windows.monthly!.valueLabel, '$12.50 spent');
|
||||||
|
assert.ok(typeof result.usage!.windows.monthly!.resetAt === 'number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maps a lifetime cap to a credits window without reset metadata', async () => {
|
||||||
|
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||||
|
data: { limit: 30, limit_remaining: 25, limit_reset: null, usage_monthly: 5 },
|
||||||
|
})));
|
||||||
|
|
||||||
|
const result = await fetchQuotaForProvider('openrouter');
|
||||||
|
const window = result.usage!.windows.credits;
|
||||||
|
|
||||||
|
assert.ok(window);
|
||||||
|
assert.equal(window!.windowSeconds, null);
|
||||||
|
assert.equal(window!.resetAt, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maps an unrecognized reset period to a credits window', async () => {
|
||||||
|
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||||
|
data: { limit: 30, limit_remaining: 25, limit_reset: 'yearly', usage_monthly: 5 },
|
||||||
|
})));
|
||||||
|
|
||||||
|
const result = await fetchQuotaForProvider('openrouter');
|
||||||
|
|
||||||
|
assert.ok(result.usage!.windows.credits);
|
||||||
|
assert.equal(result.usage!.windows.credits!.windowSeconds, null);
|
||||||
|
assert.equal(result.usage!.windows.credits!.resetAt, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps percent at 100 while leaving the money label unclamped', async () => {
|
||||||
|
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||||
|
data: { limit: 30, limit_remaining: -1, limit_reset: 'monthly', usage_monthly: 31 },
|
||||||
|
})));
|
||||||
|
|
||||||
|
const result = await fetchQuotaForProvider('openrouter');
|
||||||
|
const window = result.usage!.windows.monthly;
|
||||||
|
|
||||||
|
assert.equal(window!.usedPercent, 100);
|
||||||
|
assert.equal(window!.valueLabel, '$31.00 / $30.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses a weekly window and derives its reset on Monday UTC', async () => {
|
||||||
|
stubFetchReturning(() => Promise.resolve(mockResponse({
|
||||||
|
data: { limit: 30, limit_remaining: 25, limit_reset: 'weekly', usage_monthly: 5 },
|
||||||
|
})));
|
||||||
|
|
||||||
|
const result = await fetchQuotaForProvider('openrouter');
|
||||||
|
const window = result.usage!.windows.weekly;
|
||||||
|
|
||||||
|
assert.ok(window);
|
||||||
|
assert.equal(window!.windowSeconds, 604800);
|
||||||
|
assert.equal(new Date(window!.resetAt!).getUTCDay(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects management keys with an inference-key error', async () => {
|
||||||
|
stubFetchReturning(() => Promise.resolve(mockResponse({ data: { is_management_key: true } })));
|
||||||
|
|
||||||
|
const result = await fetchQuotaForProvider('openrouter');
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.configured, true);
|
||||||
|
assert.equal(result.usage, null);
|
||||||
|
assert.equal(result.error, 'Management key configured — quota needs an inference API key');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const status of [401, 403]) {
|
||||||
|
test(`maps HTTP ${status} to session expiry`, async () => {
|
||||||
|
stubFetchFailing(async () => ({}), { ok: false, status });
|
||||||
|
|
||||||
|
const result = await fetchQuotaForProvider('openrouter');
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.error, 'Session expired — please re-authenticate with OpenRouter');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('reports invalid JSON as a parse failure', async () => {
|
||||||
|
globalThis.fetch = (async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => { throw new SyntaxError('Unexpected token'); },
|
||||||
|
}) as unknown as Response) as typeof fetch;
|
||||||
|
|
||||||
|
const result = await fetchQuotaForProvider('openrouter');
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.error, 'Invalid response from provider');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes timeout failures', async () => {
|
||||||
|
stubFetchReturning(() => Promise.reject(new DOMException('Timed out', 'TimeoutError')));
|
||||||
|
|
||||||
|
const result = await fetchQuotaForProvider('openrouter');
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.error, 'Request timed out');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a response without usable quota data', async () => {
|
||||||
|
stubFetchReturning(() => Promise.resolve(mockResponse({ data: { limit: 30, limit_remaining: null } })));
|
||||||
|
|
||||||
|
const result = await fetchQuotaForProvider('openrouter');
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.configured, true);
|
||||||
|
assert.equal(result.usage, null);
|
||||||
|
assert.equal(result.error, 'No quota data in response');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const payload of [{ data: {} }, { data: null }]) {
|
||||||
|
test(`rejects ${JSON.stringify(payload)} without quota data`, async () => {
|
||||||
|
stubFetchReturning(() => Promise.resolve(mockResponse(payload)));
|
||||||
|
|
||||||
|
const result = await fetchQuotaForProvider('openrouter');
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.configured, true);
|
||||||
|
assert.equal(result.usage, null);
|
||||||
|
assert.equal(result.error, 'No quota data in response');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
describe('Crof quota provider (VS Code parity)', () => {
|
describe('Crof quota provider (VS Code parity)', () => {
|
||||||
test('reports credits balance as valueLabel with null percent', async () => {
|
test('reports credits balance as valueLabel with null percent', async () => {
|
||||||
|
|||||||
@@ -1933,6 +1933,28 @@ const fetchCursorQuota = async (): Promise<ProviderResult> => {
|
|||||||
} catch (error) { return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' }); }
|
} catch (error) { return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' }); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openRouterResetAt = (period: string | null, nowMs: number): number | null => {
|
||||||
|
const now = new Date(nowMs);
|
||||||
|
const year = now.getUTCFullYear();
|
||||||
|
const month = now.getUTCMonth();
|
||||||
|
const day = now.getUTCDate();
|
||||||
|
|
||||||
|
if (period === 'daily') return Date.UTC(year, month, day + 1);
|
||||||
|
if (period === 'weekly') {
|
||||||
|
const daysUntilMonday = ((8 - now.getUTCDay()) % 7) || 7;
|
||||||
|
return Date.UTC(year, month, day + daysUntilMonday);
|
||||||
|
}
|
||||||
|
if (period === 'monthly') return Date.UTC(year, month + 1, 1);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERIOD_SECONDS = { daily: 86400, weekly: 604800, monthly: 30 * 86400 };
|
||||||
|
type OpenRouterPeriod = keyof typeof PERIOD_SECONDS;
|
||||||
|
|
||||||
|
const isOpenRouterPeriod = (value: unknown): value is OpenRouterPeriod => (
|
||||||
|
typeof value === 'string' && Object.prototype.hasOwnProperty.call(PERIOD_SECONDS, value)
|
||||||
|
);
|
||||||
|
|
||||||
const fetchOpenRouterQuota = async (): Promise<ProviderResult> => {
|
const fetchOpenRouterQuota = async (): Promise<ProviderResult> => {
|
||||||
const auth = readAuthFile();
|
const auth = readAuthFile();
|
||||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])) as Record<string, unknown> | null;
|
const entry = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])) as Record<string, unknown> | null;
|
||||||
@@ -1948,13 +1970,16 @@ const fetchOpenRouterQuota = async (): Promise<ProviderResult> => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timeoutSignal = AbortSignal.timeout(15_000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://openrouter.ai/api/v1/credits', {
|
const response = await fetch('https://openrouter.ai/api/v1/key', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
'Content-Type': 'application/json',
|
'Accept-Encoding': 'identity',
|
||||||
},
|
},
|
||||||
|
signal: timeoutSignal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1963,20 +1988,86 @@ const fetchOpenRouterQuota = async (): Promise<ProviderResult> => {
|
|||||||
providerName: 'OpenRouter',
|
providerName: 'OpenRouter',
|
||||||
ok: false,
|
ok: false,
|
||||||
configured: true,
|
configured: true,
|
||||||
error: `API error: ${response.status}`,
|
error: response.status === 401 || response.status === 403
|
||||||
|
? 'Session expired — please re-authenticate with OpenRouter'
|
||||||
|
: `API error: ${response.status}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await response.json() as Record<string, unknown>;
|
const payload = await response.json() as unknown;
|
||||||
const credits = payload.data as Record<string, unknown> | undefined;
|
const dataContainer = asObject(payload);
|
||||||
const totalCredits = toNumber(credits?.total_credits);
|
const data = asObject(dataContainer?.data);
|
||||||
const totalUsage = toNumber(credits?.total_usage);
|
if (data === null) {
|
||||||
const remaining = totalCredits !== null && totalUsage !== null
|
return buildResult({
|
||||||
? Math.max(0, totalCredits - totalUsage)
|
providerId: 'openrouter',
|
||||||
: null;
|
providerName: 'OpenRouter',
|
||||||
let valueLabel: string | null = null;
|
ok: false,
|
||||||
if (remaining !== null && totalUsage !== null) {
|
configured: true,
|
||||||
valueLabel = `$${formatMoney(remaining)} left · $${formatMoney(totalUsage)} spent`;
|
error: 'No quota data in response',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.is_management_key === true) {
|
||||||
|
return buildResult({
|
||||||
|
providerId: 'openrouter',
|
||||||
|
providerName: 'OpenRouter',
|
||||||
|
ok: false,
|
||||||
|
configured: true,
|
||||||
|
error: 'Management key configured — quota needs an inference API key',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = toNumber(data.limit);
|
||||||
|
const limitRemaining = toNumber(data.limit_remaining);
|
||||||
|
if (limit !== null && limitRemaining === null) {
|
||||||
|
return buildResult({
|
||||||
|
providerId: 'openrouter',
|
||||||
|
providerName: 'OpenRouter',
|
||||||
|
ok: false,
|
||||||
|
configured: true,
|
||||||
|
error: 'No quota data in response',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageMonthly = toNumber(data.usage_monthly);
|
||||||
|
if (limit === null && usageMonthly === null) {
|
||||||
|
return buildResult({
|
||||||
|
providerId: 'openrouter',
|
||||||
|
providerName: 'OpenRouter',
|
||||||
|
ok: false,
|
||||||
|
configured: true,
|
||||||
|
error: 'No quota data in response',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowMs = Date.now();
|
||||||
|
let windowKey: string;
|
||||||
|
let windowSeconds: number | null;
|
||||||
|
let resetAt: number | null;
|
||||||
|
let usedPercent: number | null;
|
||||||
|
let valueLabel: string;
|
||||||
|
|
||||||
|
if (limit === null) {
|
||||||
|
windowKey = 'monthly';
|
||||||
|
windowSeconds = PERIOD_SECONDS.monthly;
|
||||||
|
resetAt = openRouterResetAt('monthly', nowMs);
|
||||||
|
usedPercent = null;
|
||||||
|
valueLabel = `$${formatMoney(usageMonthly)} spent`;
|
||||||
|
} else {
|
||||||
|
const used = Math.max(0, limit - (limitRemaining ?? 0));
|
||||||
|
const percent = limit > 0 ? (used / limit) * 100 : null;
|
||||||
|
usedPercent = percent === null ? null : Math.min(100, percent);
|
||||||
|
valueLabel = `$${formatMoney(used)} / $${formatMoney(limit)}`;
|
||||||
|
|
||||||
|
if (isOpenRouterPeriod(data.limit_reset)) {
|
||||||
|
windowKey = data.limit_reset;
|
||||||
|
windowSeconds = PERIOD_SECONDS[data.limit_reset];
|
||||||
|
resetAt = openRouterResetAt(data.limit_reset, nowMs);
|
||||||
|
} else {
|
||||||
|
windowKey = 'credits';
|
||||||
|
windowSeconds = null;
|
||||||
|
resetAt = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildResult({
|
return buildResult({
|
||||||
@@ -1986,22 +2077,28 @@ const fetchOpenRouterQuota = async (): Promise<ProviderResult> => {
|
|||||||
configured: true,
|
configured: true,
|
||||||
usage: {
|
usage: {
|
||||||
windows: {
|
windows: {
|
||||||
credits: toUsageWindow({
|
[windowKey]: toUsageWindow({
|
||||||
usedPercent: null,
|
usedPercent,
|
||||||
windowSeconds: null,
|
windowSeconds,
|
||||||
resetAt: null,
|
resetAt,
|
||||||
valueLabel,
|
valueLabel,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const isTimeout = error instanceof DOMException && (error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted));
|
||||||
|
const isParseError = error instanceof SyntaxError;
|
||||||
return buildResult({
|
return buildResult({
|
||||||
providerId: 'openrouter',
|
providerId: 'openrouter',
|
||||||
providerName: 'OpenRouter',
|
providerName: 'OpenRouter',
|
||||||
ok: false,
|
ok: false,
|
||||||
configured: true,
|
configured: true,
|
||||||
error: error instanceof Error ? error.message : 'Request failed',
|
error: isTimeout
|
||||||
|
? 'Request timed out'
|
||||||
|
: isParseError
|
||||||
|
? 'Invalid response from provider'
|
||||||
|
: error instanceof Error ? error.message : 'Request failed',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -128,6 +128,16 @@ snapshot carries `entitlement`, `remaining`, `unlimited`, and
|
|||||||
- When entitlement/remaining are unusable, fall back to `100 - percent_remaining`.
|
- When entitlement/remaining are unusable, fall back to `100 - percent_remaining`.
|
||||||
- Snapshots other than `premium_interactions` (legacy annual plans) yield zero windows.
|
- Snapshots other than `premium_interactions` (legacy annual plans) yield zero windows.
|
||||||
|
|
||||||
|
## OpenRouter key semantics
|
||||||
|
|
||||||
|
OpenRouter quota reads `GET https://openrouter.ai/api/v1/key`, which is documented as callable with any valid API key. `GET /api/v1/credits` is documented as "Management key required" and is not used. Calling `/credits` with a normal inference key has been observed to return HTTP 200 with `{total_credits:0, total_usage:0}` rather than an error; this behavior is not documented and is why the old implementation silently rendered "$0.00 left · $0.00 spent". A `/credits` fallback for unlimited keys would render the same zeros, so unlimited keys report `usage_monthly` instead.
|
||||||
|
|
||||||
|
The documented `limit`, `limit_remaining`, and `limit_reset` fields are present and null on unlimited keys; null means unlimited, never missing data. For a limited key, window usage is `limit - limit_remaining`, not `usage`: `usage` is all-time and measures a different axis from the current reset window. Pairing `usage` with the current limit produces a wrong number. `limit_remaining` is server-computed and already honors `include_byok_in_limit`, so `byok_*` fields are ignored.
|
||||||
|
|
||||||
|
Unlimited keys report `usage_monthly` in a `monthly` window with no percent. `limit_reset` is a period string (`daily`, `weekly`, `monthly`, or null), not a timestamp; `resetAt` is derived from the documented midnight-UTC boundaries, with weeks starting Monday. A set `limit` with a null `limit_reset` is a lifetime cap and maps to the `credits` window with no reset.
|
||||||
|
|
||||||
|
Keep `packages/web/server/lib/quota/providers/openrouter.js` and `packages/vscode/src/quotaProviders.ts` in sync, as with the Kimi and Copilot providers; the VS Code extension duplicates this parsing logic rather than importing the web provider.
|
||||||
|
|
||||||
## Notes for contributors
|
## Notes for contributors
|
||||||
- Keep provider IDs stable; clients use them directly.
|
- Keep provider IDs stable; clients use them directly.
|
||||||
- Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs.
|
- Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs.
|
||||||
|
|||||||
@@ -5,12 +5,30 @@ import {
|
|||||||
buildResult,
|
buildResult,
|
||||||
toUsageWindow,
|
toUsageWindow,
|
||||||
toNumber,
|
toNumber,
|
||||||
|
asObject,
|
||||||
formatMoney
|
formatMoney
|
||||||
} from '../utils/index.js';
|
} from '../utils/index.js';
|
||||||
|
|
||||||
export const providerId = 'openrouter';
|
export const providerId = 'openrouter';
|
||||||
export const providerName = 'OpenRouter';
|
export const providerName = 'OpenRouter';
|
||||||
const aliases = ['openrouter'];
|
export const aliases = ['openrouter'];
|
||||||
|
const OPENROUTER_QUOTA_URL = 'https://openrouter.ai/api/v1/key';
|
||||||
|
const PERIOD_SECONDS = { daily: 86400, weekly: 604800, monthly: 30 * 86400 };
|
||||||
|
|
||||||
|
export const resolveResetAt = (limitReset, nowMs) => {
|
||||||
|
const now = new Date(nowMs);
|
||||||
|
const y = now.getUTCFullYear();
|
||||||
|
const m = now.getUTCMonth();
|
||||||
|
const d = now.getUTCDate();
|
||||||
|
|
||||||
|
if (limitReset === 'daily') return Date.UTC(y, m, d + 1);
|
||||||
|
if (limitReset === 'weekly') {
|
||||||
|
const dow = now.getUTCDay();
|
||||||
|
return Date.UTC(y, m, d + ((8 - dow) % 7 || 7));
|
||||||
|
}
|
||||||
|
if (limitReset === 'monthly') return Date.UTC(y, m + 1, 1);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export const isConfigured = () => {
|
export const isConfigured = () => {
|
||||||
const auth = readAuthFile();
|
const auth = readAuthFile();
|
||||||
@@ -33,13 +51,16 @@ export const fetchQuota = async () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timeoutSignal = AbortSignal.timeout(15_000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://openrouter.ai/api/v1/credits', {
|
const response = await fetch(OPENROUTER_QUOTA_URL, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
'Content-Type': 'application/json'
|
'Accept-Encoding': 'identity'
|
||||||
}
|
},
|
||||||
|
signal: timeoutSignal
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -48,45 +69,119 @@ export const fetchQuota = async () => {
|
|||||||
providerName,
|
providerName,
|
||||||
ok: false,
|
ok: false,
|
||||||
configured: true,
|
configured: true,
|
||||||
error: `API error: ${response.status}`
|
error: response.status === 401 || response.status === 403
|
||||||
|
? 'Session expired — please re-authenticate with OpenRouter'
|
||||||
|
: `API error: ${response.status}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
const credits = payload?.data ?? {};
|
const data = asObject(payload?.data);
|
||||||
const totalCredits = toNumber(credits.total_credits);
|
|
||||||
const totalUsage = toNumber(credits.total_usage);
|
if (data === null) {
|
||||||
const remaining = totalCredits !== null && totalUsage !== null
|
return buildResult({
|
||||||
? Math.max(0, totalCredits - totalUsage)
|
providerId,
|
||||||
: null;
|
providerName,
|
||||||
let valueLabel = null;
|
ok: false,
|
||||||
if (remaining !== null && totalUsage !== null) {
|
configured: true,
|
||||||
valueLabel = `$${formatMoney(remaining)} left · $${formatMoney(totalUsage)} spent`;
|
error: 'No quota data in response'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const windows = {
|
if (data.is_management_key === true) {
|
||||||
credits: toUsageWindow({
|
return buildResult({
|
||||||
usedPercent: null,
|
providerId,
|
||||||
windowSeconds: null,
|
providerName,
|
||||||
resetAt: null,
|
ok: false,
|
||||||
valueLabel
|
configured: true,
|
||||||
})
|
error: 'Management key configured — quota needs an inference API key'
|
||||||
};
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = toNumber(data.limit);
|
||||||
|
const limitRemaining = toNumber(data.limit_remaining);
|
||||||
|
if (limit !== null && limitRemaining === null) {
|
||||||
|
return buildResult({
|
||||||
|
providerId,
|
||||||
|
providerName,
|
||||||
|
ok: false,
|
||||||
|
configured: true,
|
||||||
|
error: 'No quota data in response'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageMonthly = toNumber(data.usage_monthly);
|
||||||
|
if (limit === null && usageMonthly === null) {
|
||||||
|
return buildResult({
|
||||||
|
providerId,
|
||||||
|
providerName,
|
||||||
|
ok: false,
|
||||||
|
configured: true,
|
||||||
|
error: 'No quota data in response'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowMs = Date.now();
|
||||||
|
let windowKey;
|
||||||
|
let windowSeconds;
|
||||||
|
let resetAt;
|
||||||
|
let usedPercent;
|
||||||
|
let valueLabel;
|
||||||
|
|
||||||
|
if (limit === null) {
|
||||||
|
windowKey = 'monthly';
|
||||||
|
windowSeconds = PERIOD_SECONDS.monthly;
|
||||||
|
resetAt = resolveResetAt('monthly', nowMs);
|
||||||
|
usedPercent = null;
|
||||||
|
valueLabel = `$${formatMoney(usageMonthly)} spent`;
|
||||||
|
} else {
|
||||||
|
const used = Math.max(0, limit - limitRemaining);
|
||||||
|
const percent = limit > 0 ? (used / limit) * 100 : null;
|
||||||
|
usedPercent = percent === null ? null : Math.min(100, percent);
|
||||||
|
valueLabel = `$${formatMoney(used)} / $${formatMoney(limit)}`;
|
||||||
|
|
||||||
|
if (Object.hasOwn(PERIOD_SECONDS, data.limit_reset)) {
|
||||||
|
windowKey = data.limit_reset;
|
||||||
|
windowSeconds = PERIOD_SECONDS[data.limit_reset];
|
||||||
|
resetAt = resolveResetAt(data.limit_reset, nowMs);
|
||||||
|
} else {
|
||||||
|
windowKey = 'credits';
|
||||||
|
windowSeconds = null;
|
||||||
|
resetAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return buildResult({
|
return buildResult({
|
||||||
providerId,
|
providerId,
|
||||||
providerName,
|
providerName,
|
||||||
ok: true,
|
ok: true,
|
||||||
configured: true,
|
configured: true,
|
||||||
usage: { windows }
|
usage: {
|
||||||
|
windows: {
|
||||||
|
[windowKey]: toUsageWindow({
|
||||||
|
usedPercent,
|
||||||
|
windowSeconds,
|
||||||
|
resetAt,
|
||||||
|
valueLabel
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const isTimeout = error instanceof DOMException && (
|
||||||
|
error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted)
|
||||||
|
);
|
||||||
|
const isParseError = error instanceof SyntaxError;
|
||||||
return buildResult({
|
return buildResult({
|
||||||
providerId,
|
providerId,
|
||||||
providerName,
|
providerName,
|
||||||
ok: false,
|
ok: false,
|
||||||
configured: true,
|
configured: true,
|
||||||
error: error instanceof Error ? error.message : 'Request failed'
|
error: isTimeout
|
||||||
|
? 'Request timed out'
|
||||||
|
: isParseError
|
||||||
|
? 'Invalid response from provider'
|
||||||
|
: (error instanceof Error ? error.message : 'Request failed')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../opencode/auth.js', () => ({
|
||||||
|
readAuthFile: () => ({ openrouter: { key: 'test-token' } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { fetchQuota, resolveResetAt } from './openrouter.js';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockResponse = (body, init = {}) => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => body,
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Documented payload shape from https://openrouter.ai/docs/api_reference/limits
|
||||||
|
const DOCUMENTED_PAYLOAD = {
|
||||||
|
data: {
|
||||||
|
label: 'Default',
|
||||||
|
limit: 30,
|
||||||
|
limit_remaining: 25,
|
||||||
|
limit_reset: 'monthly',
|
||||||
|
include_byok_in_limit: false,
|
||||||
|
usage: 5,
|
||||||
|
usage_daily: 1,
|
||||||
|
usage_weekly: 3,
|
||||||
|
usage_monthly: 5,
|
||||||
|
byok_usage: 0,
|
||||||
|
byok_usage_daily: 0,
|
||||||
|
byok_usage_weekly: 0,
|
||||||
|
byok_usage_monthly: 0,
|
||||||
|
is_free_tier: false,
|
||||||
|
is_management_key: false,
|
||||||
|
is_provisioning_key: false,
|
||||||
|
creator_user_id: 'user-fixture',
|
||||||
|
expires_at: null,
|
||||||
|
rate_limit: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectMonthlyReset = (resetAt) => {
|
||||||
|
expect(resetAt).toEqual(expect.any(Number));
|
||||||
|
const resetDate = new Date(resetAt);
|
||||||
|
expect(resetDate.getUTCDate()).toBe(1);
|
||||||
|
expect(resetDate.getUTCHours()).toBe(0);
|
||||||
|
expect(resetAt - Date.now()).toBeGreaterThan(0);
|
||||||
|
expect(resetAt - Date.now()).toBeLessThanOrEqual(31 * 86400 * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('OpenRouter quota provider', () => {
|
||||||
|
it('builds a monthly window from the documented payload', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(DOCUMENTED_PAYLOAD)));
|
||||||
|
|
||||||
|
const result = await fetchQuota();
|
||||||
|
const window = result.usage.windows.monthly;
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.providerId).toBe('openrouter');
|
||||||
|
expect(window.usedPercent).toBeCloseTo(16.6667, 3);
|
||||||
|
expect(window.valueLabel).toBe('$5.00 / $30.00');
|
||||||
|
expect(window.windowSeconds).toBe(30 * 86400);
|
||||||
|
expectMonthlyReset(window.resetAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the daily window and preserves the observed funded-key values', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||||
|
data: {
|
||||||
|
usage: 3.17561396,
|
||||||
|
usage_daily: 0.0000018,
|
||||||
|
usage_weekly: 0.0000018,
|
||||||
|
usage_monthly: 3.17561396,
|
||||||
|
limit: 30,
|
||||||
|
limit_remaining: 29.9999982,
|
||||||
|
limit_reset: 'daily',
|
||||||
|
is_free_tier: true,
|
||||||
|
is_management_key: false,
|
||||||
|
include_byok_in_limit: false,
|
||||||
|
byok_usage: 0
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
|
||||||
|
const result = await fetchQuota();
|
||||||
|
const window = result.usage.windows.daily;
|
||||||
|
|
||||||
|
expect(window).toBeDefined();
|
||||||
|
expect(window.windowSeconds).toBe(86400);
|
||||||
|
expect(window.valueLabel).toBe('$0.00 / $30.00');
|
||||||
|
expect(window.usedPercent).toBeLessThan(0.001);
|
||||||
|
expect(window.resetAt % 86400000).toBe(0);
|
||||||
|
expect(window.resetAt - Date.now()).toBeGreaterThan(0);
|
||||||
|
expect(window.resetAt - Date.now()).toBeLessThanOrEqual(86400000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a monthly unlimited window from null limit fields', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||||
|
data: {
|
||||||
|
limit: null,
|
||||||
|
limit_remaining: null,
|
||||||
|
limit_reset: null,
|
||||||
|
usage_monthly: 3.17561396,
|
||||||
|
is_management_key: false
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
|
||||||
|
const result = await fetchQuota();
|
||||||
|
const window = result.usage.windows.monthly;
|
||||||
|
|
||||||
|
expect(window.usedPercent).toBeNull();
|
||||||
|
expect(window.valueLabel).toBe('$3.18 spent');
|
||||||
|
expect(window.windowSeconds).toBe(30 * 86400);
|
||||||
|
expectMonthlyReset(window.resetAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a credits window for a lifetime cap', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||||
|
data: { limit: 30, limit_remaining: 25, limit_reset: null, usage_monthly: 5 }
|
||||||
|
})));
|
||||||
|
|
||||||
|
const result = await fetchQuota();
|
||||||
|
const window = result.usage.windows.credits;
|
||||||
|
|
||||||
|
expect(window).toBeDefined();
|
||||||
|
expect(window.resetAt).toBeNull();
|
||||||
|
expect(window.windowSeconds).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a credits window for an unrecognized reset period', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||||
|
data: { limit: 30, limit_remaining: 25, limit_reset: 'yearly', usage_monthly: 5 }
|
||||||
|
})));
|
||||||
|
|
||||||
|
const result = await fetchQuota();
|
||||||
|
const window = result.usage.windows.credits;
|
||||||
|
|
||||||
|
expect(window).toBeDefined();
|
||||||
|
expect(window.resetAt).toBeNull();
|
||||||
|
expect(window.windowSeconds).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a weekly window from a weekly limit', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||||
|
data: { limit: 30, limit_remaining: 25, limit_reset: 'weekly', usage_monthly: 5 }
|
||||||
|
})));
|
||||||
|
|
||||||
|
const result = await fetchQuota();
|
||||||
|
const window = result.usage.windows.weekly;
|
||||||
|
|
||||||
|
expect(window).toBeDefined();
|
||||||
|
expect(window.windowSeconds).toBe(604800);
|
||||||
|
expect(new Date(window.resetAt).getUTCDay()).toBe(1);
|
||||||
|
expect(window.resetAt - Date.now()).toBeGreaterThan(0);
|
||||||
|
expect(window.resetAt - Date.now()).toBeLessThanOrEqual(7 * 86400 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores BYOK usage when calculating the quota label and percent', async () => {
|
||||||
|
const withByokPayload = {
|
||||||
|
...DOCUMENTED_PAYLOAD,
|
||||||
|
data: {
|
||||||
|
...DOCUMENTED_PAYLOAD.data,
|
||||||
|
include_byok_in_limit: true,
|
||||||
|
byok_usage_monthly: 100,
|
||||||
|
byok_usage: 100
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
.mockResolvedValueOnce(mockResponse(DOCUMENTED_PAYLOAD))
|
||||||
|
.mockResolvedValueOnce(mockResponse(withByokPayload));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const withoutByok = await fetchQuota();
|
||||||
|
const withByok = await fetchQuota();
|
||||||
|
|
||||||
|
expect(withByok.usage.windows.monthly.valueLabel)
|
||||||
|
.toBe(withoutByok.usage.windows.monthly.valueLabel);
|
||||||
|
expect(withByok.usage.windows.monthly.usedPercent)
|
||||||
|
.toBe(withoutByok.usage.windows.monthly.usedPercent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects management keys with a specific error', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||||
|
data: { is_management_key: true }
|
||||||
|
})));
|
||||||
|
|
||||||
|
const result = await fetchQuota();
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.configured).toBe(true);
|
||||||
|
expect(result.usage).toBeNull();
|
||||||
|
expect(result.error).toBe('Management key configured — quota needs an inference API key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([401, 403])('maps %s to a session-expired error', async (status) => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status }));
|
||||||
|
|
||||||
|
const result = await fetchQuota();
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toBe('Session expired — please re-authenticate with OpenRouter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports invalid-response on JSON parse failure', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => { throw new SyntaxError('Unexpected token'); },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await fetchQuota();
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toBe('Invalid response from provider');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a normalized timeout error', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('The operation timed out.', 'TimeoutError')));
|
||||||
|
|
||||||
|
const result = await fetchQuota();
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toBe('Request timed out');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no-quota-data when data is missing', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({})));
|
||||||
|
|
||||||
|
const result = await fetchQuota();
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.configured).toBe(true);
|
||||||
|
expect(result.error).toBe('No quota data in response');
|
||||||
|
expect(result.usage).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no-quota-data when data is empty', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ data: {} })));
|
||||||
|
|
||||||
|
const result = await fetchQuota();
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.configured).toBe(true);
|
||||||
|
expect(result.error).toBe('No quota data in response');
|
||||||
|
expect(result.usage).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, []])('returns no-quota-data when data is %s', async (data) => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ data })));
|
||||||
|
|
||||||
|
const result = await fetchQuota();
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.configured).toBe(true);
|
||||||
|
expect(result.usage).toBeNull();
|
||||||
|
expect(result.error).toBe('No quota data in response');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no-quota-data when a limit has no remaining value', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||||
|
data: { limit: 30, limit_remaining: null, usage_monthly: 5 }
|
||||||
|
})));
|
||||||
|
|
||||||
|
const result = await fetchQuota();
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toBe('No quota data in response');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a zero limit valid with a null percent', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||||
|
data: { limit: 0, limit_remaining: 0, limit_reset: 'monthly', usage_monthly: 0 }
|
||||||
|
})));
|
||||||
|
|
||||||
|
const result = await fetchQuota();
|
||||||
|
const window = result.usage.windows.monthly;
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(window.usedPercent).toBeNull();
|
||||||
|
expect(window.valueLabel).toBe('$0.00 / $0.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests the key endpoint and never the credits endpoint', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(mockResponse(DOCUMENTED_PAYLOAD));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await fetchQuota();
|
||||||
|
|
||||||
|
const requestedUrl = fetchMock.mock.calls[0][0];
|
||||||
|
expect(requestedUrl).toBe('https://openrouter.ai/api/v1/key');
|
||||||
|
expect(requestedUrl).not.toContain('/api/v1/credits');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves daily reset at the next UTC day across year boundaries', () => {
|
||||||
|
expect(resolveResetAt('daily', Date.UTC(2024, 11, 31, 23, 59))).toBe(Date.UTC(2025, 0, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves weekly reset from Sunday to the next Monday', () => {
|
||||||
|
expect(resolveResetAt('weekly', Date.UTC(2024, 0, 7, 12))).toBe(Date.UTC(2024, 0, 8));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves weekly reset from Monday to the following Monday', () => {
|
||||||
|
expect(resolveResetAt('weekly', Date.UTC(2024, 0, 8, 12))).toBe(Date.UTC(2024, 0, 15));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves weekly reset from Wednesday to the next Monday', () => {
|
||||||
|
expect(resolveResetAt('weekly', Date.UTC(2024, 0, 10, 12))).toBe(Date.UTC(2024, 0, 15));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves monthly reset from January 31 to February 1', () => {
|
||||||
|
expect(resolveResetAt('monthly', Date.UTC(2024, 0, 31, 12))).toBe(Date.UTC(2024, 1, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves monthly reset across December to January', () => {
|
||||||
|
expect(resolveResetAt('monthly', Date.UTC(2024, 11, 31, 23, 59))).toBe(Date.UTC(2025, 0, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps percent at 100 while leaving the money label unclamped', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||||
|
data: { limit: 30, limit_remaining: -1, limit_reset: 'monthly', usage_monthly: 31 }
|
||||||
|
})));
|
||||||
|
|
||||||
|
const result = await fetchQuota();
|
||||||
|
const window = result.usage.windows.monthly;
|
||||||
|
|
||||||
|
expect(window.usedPercent).toBe(100);
|
||||||
|
expect(window.valueLabel).toBe('$31.00 / $30.00');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user