diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index 784d5bc5..09429a1b 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -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 () => { diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 962c7103..7deb6129 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -1933,6 +1933,28 @@ const fetchCursorQuota = async (): Promise => { } 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 => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])) as Record | null; @@ -1948,13 +1970,16 @@ const fetchOpenRouterQuota = async (): Promise => { }); } + 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 => { 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; - const credits = payload.data as Record | 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 => { 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', }); } }; diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index fdfe118f..0a8d10e9 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -128,6 +128,16 @@ snapshot carries `entitlement`, `remaining`, `unlimited`, and - When entitlement/remaining are unusable, fall back to `100 - percent_remaining`. - 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 - Keep provider IDs stable; clients use them directly. - Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs. diff --git a/packages/web/server/lib/quota/providers/openrouter.js b/packages/web/server/lib/quota/providers/openrouter.js index f82ed45a..499b0a01 100644 --- a/packages/web/server/lib/quota/providers/openrouter.js +++ b/packages/web/server/lib/quota/providers/openrouter.js @@ -5,12 +5,30 @@ import { buildResult, toUsageWindow, toNumber, + asObject, formatMoney } from '../utils/index.js'; export const providerId = '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 = () => { const auth = readAuthFile(); @@ -33,13 +51,16 @@ export const fetchQuota = async () => { }); } + const timeoutSignal = AbortSignal.timeout(15_000); + try { - const response = await fetch('https://openrouter.ai/api/v1/credits', { + const response = await fetch(OPENROUTER_QUOTA_URL, { method: 'GET', headers: { Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - } + 'Accept-Encoding': 'identity' + }, + signal: timeoutSignal }); if (!response.ok) { @@ -48,45 +69,119 @@ export const fetchQuota = async () => { providerName, 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(); - const credits = payload?.data ?? {}; - 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 = null; - if (remaining !== null && totalUsage !== null) { - valueLabel = `$${formatMoney(remaining)} left · $${formatMoney(totalUsage)} spent`; + const data = asObject(payload?.data); + + if (data === null) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: 'No quota data in response' + }); } - const windows = { - credits: toUsageWindow({ - usedPercent: null, - windowSeconds: null, - resetAt: null, - valueLabel - }) - }; + if (data.is_management_key === true) { + return buildResult({ + providerId, + providerName, + 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, + 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({ providerId, providerName, ok: true, configured: true, - usage: { windows } + usage: { + windows: { + [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, providerName, 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') }); } }; diff --git a/packages/web/server/lib/quota/providers/openrouter.test.js b/packages/web/server/lib/quota/providers/openrouter.test.js new file mode 100644 index 00000000..42474654 --- /dev/null +++ b/packages/web/server/lib/quota/providers/openrouter.test.js @@ -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'); + }); +});