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:
Maxime Leduc
2026-09-07 20:25:11 +03:00
committed by GitHub
parent ab7ee73992
commit 0e836e7b75
5 changed files with 749 additions and 42 deletions
+175
View File
@@ -17,6 +17,7 @@ const AUTH = JSON.stringify({
crof: { key: 'test-token' },
neuralwatt: { key: 'test-token' },
'opencode-go': { key: 'test-token' },
openrouter: { key: 'test-token' },
'zai-coding-plan': { key: 'test-token' },
deepseek: { 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)', () => {
test('reports credits balance as valueLabel with null percent', async () => {
+115 -18
View File
@@ -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' }); }
};
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 auth = readAuthFile();
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 {
const response = await fetch('https://openrouter.ai/api/v1/credits', {
const response = await fetch('https://openrouter.ai/api/v1/key', {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Accept-Encoding': 'identity',
},
signal: timeoutSignal,
});
if (!response.ok) {
@@ -1963,20 +1988,86 @@ const fetchOpenRouterQuota = async (): Promise<ProviderResult> => {
providerName: 'OpenRouter',
ok: false,
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 credits = payload.data as Record<string, unknown> | undefined;
const totalCredits = toNumber(credits?.total_credits);
const totalUsage = toNumber(credits?.total_usage);
const remaining = totalCredits !== null && totalUsage !== null
? Math.max(0, totalCredits - totalUsage)
: null;
let valueLabel: string | null = null;
if (remaining !== null && totalUsage !== null) {
valueLabel = `$${formatMoney(remaining)} left · $${formatMoney(totalUsage)} spent`;
const payload = await response.json() as unknown;
const dataContainer = asObject(payload);
const data = asObject(dataContainer?.data);
if (data === null) {
return buildResult({
providerId: 'openrouter',
providerName: 'OpenRouter',
ok: false,
configured: true,
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({
@@ -1986,22 +2077,28 @@ const fetchOpenRouterQuota = async (): Promise<ProviderResult> => {
configured: true,
usage: {
windows: {
credits: toUsageWindow({
usedPercent: null,
windowSeconds: null,
resetAt: null,
[windowKey]: toUsageWindow({
usedPercent,
windowSeconds,
resetAt,
valueLabel,
}),
},
},
});
} catch (error) {
const isTimeout = error instanceof DOMException && (error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted));
const isParseError = error instanceof SyntaxError;
return buildResult({
providerId: 'openrouter',
providerName: 'OpenRouter',
ok: false,
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',
});
}
};