diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bfa7587..02fd7a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ All notable changes to this project will be documented in this file. - Mobile/iOS: push notifications now use Apple’s production service by default (thanks to @natheihei). - Mobile/iOS: notifications now work for development builds installed from Xcode — the app detects its Apple push environment and the server delivers each device to the matching endpoint, so dev (sandbox) and TestFlight/App Store (production) installs both receive pushes. - Desktop: the window-controls position setting now lives under Appearance (thanks to @makeittech). +- Usage: added Crof and NeuralWatt quota tracking with subscription kWh, independent key-allowance windows, and credits-balance fallback across the web server and VS Code extension. +- Usage: Crof and NeuralWatt provider follow-up — fixed NeuralWatt allowance usage math (effectiveLimit now correctly accounts for spent credits), switched NeuralWatt windows to stable map keys with reused i18n labels, dropped dead `fetchCrofQuota`/`fetchNeuralwattQuota` re-exports, removed the speculative wall-clock `computeAllowanceResetAt` fallback (now trusts the API-provided `reset_at` or returns null, matching sibling providers), aligned display name to CrofAI, and ported payload-parsing tests to the VS Code runtime. ## [1.16.3] - 2026-07-22 diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index 827915af..7320ba06 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -21,4 +21,6 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [ { id: 'ollama-cloud', name: 'Ollama Cloud' }, { id: 'wafer', name: 'Wafer.ai' }, { id: 'opencode-go', name: 'OpenCode Go' }, + { id: 'crof', name: 'CrofAI' }, + { id: 'neuralwatt', name: 'NeuralWatt' }, ]; diff --git a/packages/ui/src/lib/quota/utils.ts b/packages/ui/src/lib/quota/utils.ts index 912c917e..5308bd74 100644 --- a/packages/ui/src/lib/quota/utils.ts +++ b/packages/ui/src/lib/quota/utils.ts @@ -83,6 +83,7 @@ export const formatWindowLabel = (label: string): string => { if (label === 'credits') return t('quota.window.credits'); if (label === 'credits_balance') return t('quota.window.creditsBalance'); if (label === 'billing_cycle') return t('quota.window.billingCycle'); + if (label === 'plan_limit') return t('quota.window.planLimit'); if (label === 'auto') return t('quota.window.auto'); if (label === 'api') return t('quota.window.api'); if (label === 'plan_limit') return t('quota.window.planLimit'); diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index dcd5459c..6e5e11a4 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -15,7 +15,9 @@ export type QuotaProviderId = | 'minimax-cn-coding-plan' | 'ollama-cloud' | 'wafer' - | 'opencode-go'; + | 'opencode-go' + | 'crof' + | 'neuralwatt'; export interface UsageWindow { usedPercent: number | null; diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index c86b927a..d4b16499 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -4,6 +4,8 @@ - Chat: jumping to messages in long conversations now lands on the intended message when earlier rows have not been rendered yet. - Settings: added an option to hide starter suggestions on the new-session screen. - Shortcuts: fixed a regression where double-Escape could be primed when the current session was not active. +- Usage: added Crof and NeuralWatt quota tracking, including the missing Crof switch arm that previously fell back to "Unsupported provider". +- Usage: Crof and NeuralWatt follow-up — fixed NeuralWatt allowance usage math (effectiveLimit now correctly accounts for spent credits), switched NeuralWatt windows to stable map keys with reused i18n labels, removed the speculative wall-clock `computeAllowanceResetAt` fallback (now trusts the API-provided `reset_at` or returns null, matching sibling providers), aligned display name to CrofAI, and added payload-parsing tests for the VS Code runtime. ## [1.16.3] - 2026-07-22 diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts new file mode 100644 index 00000000..8245c713 --- /dev/null +++ b/packages/vscode/src/quotaProviders.test.ts @@ -0,0 +1,367 @@ +import { afterEach, beforeEach, describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +// readAuthFile reads ~/.local/share/opencode/auth.json via fs.readFileSync. +// Stub fs to serve a known auth entry so the providers treat themselves as +// configured and proceed straight to fetch. +const ORIGINAL_FS = { ...fs }; +const AUTH = JSON.stringify({ + crof: { key: 'test-token' }, + neuralwatt: { key: 'test-token' }, +}); +((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true; +((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH; + +import { fetchQuotaForProvider } from './quotaProviders'; + +type MockResponseInit = { ok?: boolean; status?: number }; + +const mockResponse = (body: unknown, init: MockResponseInit = {}): Response => ({ + ok: 'ok' in init ? init.ok! : true, + status: init.status ?? 200, + json: async () => body, +} as unknown as Response); + +// Documented NeuralWatt payload from https://portal.neuralwatt.com/docs/api/quota. +// plan="standard", kwh_included=20.0, kwh_used=13.9023. +const DOCUMENTED_SUBSCRIPTION_PAYLOAD = { + snapshot_at: '2026-04-16T18:30:00Z', + balance: { credits_remaining_usd: 32.6774, total_credits_usd: 52.34, credits_used_usd: 19.6626, accounting_method: 'energy' }, + usage: { + lifetime: { cost_usd: 243.9145, requests: 37801, tokens: 1235477176, energy_kwh: 15.6009 }, + current_month: { cost_usd: 160.1463, requests: 23902, tokens: 1116658995, energy_kwh: 9.7278 }, + }, + limits: { overage_limit_usd: null, rate_limit_tier: 'standard' }, + subscription: { + plan: 'standard', + status: 'active', + billing_interval: 'year', + current_period_start: '2026-04-11T05:05:25Z', + current_period_end: '2027-04-11T05:05:25Z', + auto_renew: true, + kwh_included: 20.0, + kwh_used: 13.9023, + kwh_remaining: 6.0977, + in_overage: false, + }, + key: { name: 'my-production-key', allowance: null }, +} as const; + +let ORIGINAL_FETCH: typeof globalThis.fetch; + +beforeEach(() => { + ORIGINAL_FETCH = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = ORIGINAL_FETCH; +}); + +const stubFetchReturning = (resolver: () => Promise): void => { + globalThis.fetch = (async () => resolver()) as typeof fetch; +}; + +const stubFetchFailing = (json: () => Promise, init: MockResponseInit): void => { + globalThis.fetch = (async () => ({ json, ...init }) as unknown as Response) as typeof fetch; +}; + +describe('Crof quota provider (VS Code parity)', () => { + test('reports credits balance as valueLabel with null percent', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ usable_requests: 450, credits: 12.3456 }))); + + const result = await fetchQuotaForProvider('crof'); + + assert.equal(result.ok, true); + assert.equal(result.providerId, 'crof'); + assert.equal(result.usage!.windows.credits!.usedPercent, null); + assert.equal(result.usage!.windows.credits!.valueLabel, '$12.35'); + }); + + test('tolerates missing credits field', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ usable_requests: 0 }))); + + const result = await fetchQuotaForProvider('crof'); + + assert.equal(result.ok, true); + assert.equal(result.usage!.windows.credits!.valueLabel, undefined); + assert.equal(result.usage!.windows.credits!.usedPercent, null); + }); + + test('maps 401 to session-expired with CrofAI branding', async () => { + stubFetchFailing(async () => ({}), { ok: false, status: 401 }); + + const result = await fetchQuotaForProvider('crof'); + + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.error, 'Session expired — please re-authenticate with CrofAI'); + }); + + test('reports invalid-response on JSON 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('crof'); + + assert.equal(result.ok, false); + assert.equal(result.error, 'Invalid response from provider'); + }); +}); + +describe('NeuralWatt quota provider (VS Code parity)', () => { + test('builds subscription window keyed by plan name (windowSeconds null)', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse(DOCUMENTED_SUBSCRIPTION_PAYLOAD))); + + const result = await fetchQuotaForProvider('neuralwatt'); + + assert.equal(result.ok, true); + assert.equal(result.providerId, 'neuralwatt'); + + // Subscription window is keyed by the plan name; windowSeconds is null + // because the API exposes no kWh window start to derive duration from. + const window = result.usage!.windows.standard; + assert.ok(window, 'subscription window should be defined'); + assert.ok(Math.abs((window.usedPercent as number) - (13.9023 / 20.0) * 100) < 1e-2); + assert.equal(window.windowSeconds, null); + assert.equal(window.resetAt, Date.parse('2027-04-11T05:05:25Z')); + + // allowance is null → credits_balance also surfaced + assert.ok(result.usage!.windows.credits_balance, 'credits_balance should be defined'); + assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$32.68'); + }); + + test('falls back to plan_limit title when plan is missing', async () => { + const payload = { + ...DOCUMENTED_SUBSCRIPTION_PAYLOAD, + subscription: { ...DOCUMENTED_SUBSCRIPTION_PAYLOAD.subscription, plan: null }, + }; + stubFetchReturning(() => Promise.resolve(mockResponse(payload))); + + const result = await fetchQuotaForProvider('neuralwatt'); + + assert.ok(result.usage!.windows.plan_limit); + assert.ok(Math.abs((result.usage!.windows.plan_limit!.usedPercent as number) - (13.9023 / 20.0) * 100) < 1e-2); + }); + + test('marks in-overage subscription as 100%, still shows credits', async () => { + const payload = { + ...DOCUMENTED_SUBSCRIPTION_PAYLOAD, + subscription: { ...DOCUMENTED_SUBSCRIPTION_PAYLOAD.subscription, in_overage: true, kwh_used: 25.0 }, + }; + stubFetchReturning(() => Promise.resolve(mockResponse(payload))); + + const result = await fetchQuotaForProvider('neuralwatt'); + + const window = result.usage!.windows.standard; + assert.ok(window); + assert.equal(window!.usedPercent, 100); + assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$32.68'); + }); + + test('surfaces subscription and allowance windows (allowance keyed by period, key name in valueLabel)', async () => { + const payload = { + ...DOCUMENTED_SUBSCRIPTION_PAYLOAD, + balance: { credits_remaining_usd: 200 }, + key: { + name: 'Prod', + allowance: { limit_usd: 100, period: 'monthly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' }, + }, + }; + stubFetchReturning(() => Promise.resolve(mockResponse(payload))); + + const result = await fetchQuotaForProvider('neuralwatt'); + + const subWindow = result.usage!.windows.standard; + assert.ok(subWindow); + assert.ok(Math.abs((subWindow!.usedPercent as number) - (13.9023 / 20.0) * 100) < 1e-2); + + // Allowance window is keyed by the localized period label ("monthly"); + // key name flows through valueLabel for identification. + const allowWindow = result.usage!.windows.monthly; + assert.ok(allowWindow); + assert.equal(allowWindow!.usedPercent, 25); + assert.equal(allowWindow!.valueLabel, 'Prod'); + assert.equal(allowWindow!.resetAt, Date.parse('2026-08-01T00:00:00Z')); + + assert.equal(result.usage!.windows.credits_balance, undefined); + }); + + test('uses allowance effective limit = min(limit, credits_remaining + spent)', async () => { + const payload = { + balance: { credits_remaining_usd: 30 }, + subscription: null, + key: { + name: 'prod-key', + allowance: { limit_usd: 100, period: 'monthly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' }, + }, + }; + stubFetchReturning(() => Promise.resolve(mockResponse(payload))); + + const result = await fetchQuotaForProvider('neuralwatt'); + + const window = result.usage!.windows.monthly; + assert.ok(window); + // effectiveLimit = min(100, 30+25) = 55; usedPercent = 25/55 * 100 ≈ 45.4545 + assert.ok(Math.abs((window!.usedPercent as number) - (25 / 55) * 100) < 1e-2); + assert.equal(window!.windowSeconds, 30 * 86400); + assert.equal(window!.resetAt, Date.parse('2026-08-01T00:00:00Z')); + assert.equal(window!.valueLabel, 'prod-key'); + assert.equal(result.usage!.windows.credits_balance, undefined); + }); + + test('binds allowance ceiling to limit when limit < credits_remaining + spent', async () => { + const payload = { + balance: { credits_remaining_usd: 200 }, + subscription: null, + key: { + name: 'prod-key', + allowance: { limit_usd: 100, period: 'monthly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' }, + }, + }; + stubFetchReturning(() => Promise.resolve(mockResponse(payload))); + + const result = await fetchQuotaForProvider('neuralwatt'); + + const window = result.usage!.windows.monthly; + assert.ok(window); + assert.equal(window!.usedPercent, 25); + }); + + test('uses weekly as the allowance key when period is weekly', async () => { + const payload = { + balance: { credits_remaining_usd: 200 }, + subscription: null, + key: { + name: 'Prod', + allowance: { limit_usd: 100, period: 'weekly', spent_usd: 20, blocked: false, reset_at: '2026-07-04T00:00:00Z' }, + }, + }; + stubFetchReturning(() => Promise.resolve(mockResponse(payload))); + + const result = await fetchQuotaForProvider('neuralwatt'); + + const window = result.usage!.windows.weekly; + assert.ok(window); + assert.equal(window!.windowSeconds, 604800); + assert.equal(window!.resetAt, Date.parse('2026-07-04T00:00:00Z')); + assert.equal(window!.valueLabel, 'Prod'); + }); + + test('uses daily as the allowance key when period is daily', async () => { + const payload = { + balance: { credits_remaining_usd: 200 }, + subscription: null, + key: { + name: 'Prod', + allowance: { limit_usd: 10, period: 'daily', spent_usd: 2, blocked: false, reset_at: '2026-07-04T00:00:00Z' }, + }, + }; + stubFetchReturning(() => Promise.resolve(mockResponse(payload))); + + const result = await fetchQuotaForProvider('neuralwatt'); + + const window = result.usage!.windows.daily; + assert.ok(window); + assert.equal(window!.windowSeconds, 86400); + assert.equal(window!.resetAt, Date.parse('2026-07-04T00:00:00Z')); + }); + + test('falls back to billing_cycle when allowance period is missing or unknown', async () => { + const payload = { + balance: { credits_remaining_usd: 200 }, + subscription: null, + key: { + name: 'Prod', + allowance: { limit_usd: 100, period: 'fortnightly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' }, + }, + }; + stubFetchReturning(() => Promise.resolve(mockResponse(payload))); + + const result = await fetchQuotaForProvider('neuralwatt'); + + const window = result.usage!.windows.billing_cycle; + assert.ok(window); + assert.equal(window!.usedPercent, 25); + }); + + test('marks blocked allowance as 100% with valueLabel set', async () => { + const payload = { + balance: { credits_remaining_usd: 30 }, + subscription: null, + key: { + name: 'sample', + allowance: { limit_usd: 50, period: 'monthly', spent_usd: 10, blocked: true, reset_at: '2026-08-01T00:00:00Z' }, + }, + }; + stubFetchReturning(() => Promise.resolve(mockResponse(payload))); + + const result = await fetchQuotaForProvider('neuralwatt'); + + const window = result.usage!.windows.monthly; + assert.ok(window); + assert.equal(window!.usedPercent, 100); + assert.equal(window!.valueLabel, 'sample'); + }); + + test('falls back to credits_balance when neither subscription nor allowance exists', async () => { + const payload = { + balance: { credits_remaining_usd: 32.6774 }, + subscription: null, + key: { name: 'sample', allowance: null }, + }; + stubFetchReturning(() => Promise.resolve(mockResponse(payload))); + + const result = await fetchQuotaForProvider('neuralwatt'); + + assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$32.68'); + assert.equal(result.usage!.windows.credits_balance!.usedPercent, null); + }); + + test('maps 401 to session-expired', async () => { + stubFetchFailing(async () => ({}), { ok: false, status: 401 }); + + const result = await fetchQuotaForProvider('neuralwatt'); + + assert.equal(result.ok, false); + assert.equal(result.error, 'Session expired — please re-authenticate with NeuralWatt'); + }); + + test('reports invalid-response on JSON 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('neuralwatt'); + + assert.equal(result.ok, false); + assert.equal(result.error, 'Invalid response from provider'); + }); + + test('returns no-quota-data on a 200 payload with no usable windows', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + balance: { credits_remaining_usd: null }, + subscription: null, + key: { name: 'sample', allowance: null }, + }))); + + const result = await fetchQuotaForProvider('neuralwatt'); + + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.error, 'No quota data in response'); + assert.equal(result.usage, null); + }); + + // Restore fs so other test files (which use the real auth file) are unaffected. + test('teardown: restore fs', () => { + const fsMock = fs as unknown as { existsSync: unknown; readFileSync: unknown }; + fsMock.existsSync = ORIGINAL_FS.existsSync; + fsMock.readFileSync = ORIGINAL_FS.readFileSync; + }); +}); diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 5dde3b1c..5f0662b4 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -112,6 +112,37 @@ type WaferPayload = { plan_tier?: string; }; +type CrofPayload = { + usable_requests?: number | null; + credits?: number | string; +}; + +type NeuralwattPayload = { + balance?: { + credits_remaining_usd?: number | string; + }; + subscription?: { + plan?: string; + billing_interval?: string; + current_period_start?: string; + current_period_end?: string; + kwh_included?: number | string; + kwh_used?: number | string; + in_overage?: boolean; + kwh_reset_date?: string; + } | null; + key?: { + name?: string; + allowance?: { + limit_usd?: number | string; + period?: string; + spent_usd?: number | string; + blocked?: boolean; + reset_at?: string; + } | null; + }; +}; + export type ProviderResult = { providerId: string; providerName: string; @@ -441,6 +472,16 @@ export const listConfiguredQuotaProviders = () => { configured.add('wafer'); } + const crofAuth = normalizeAuthEntry(getAuthEntry(auth, ['crof'])); + if (crofAuth && ((crofAuth as Record).key || (crofAuth as Record).token)) { + configured.add('crof'); + } + + const neuralwattAuth = normalizeAuthEntry(getAuthEntry(auth, ['neuralwatt'])); + if (neuralwattAuth && ((neuralwattAuth as Record).key || (neuralwattAuth as Record).token)) { + configured.add('neuralwatt'); + } + return Array.from(configured); }; @@ -1865,6 +1906,243 @@ const fetchWaferQuota = async (): Promise => { } }; +const NEURALWATT_QUOTA_URL = 'https://api.neuralwatt.com/v1/quota'; + +// 30d month / 365d year are fixed approximations; real calendars vary but the +// window is for the UI's progress bar label, not billing decisions. +// Accepts both subscription (month/year) and allowance (monthly/weekly/daily) shapes. +const neuralwattWindowSeconds = (period: string | null | undefined): number | null => { + if (period === 'daily') return 86400; + if (period === 'weekly') return 604800; + if (period === 'monthly' || period === 'month') return 30 * 86400; + if (period === 'yearly' || period === 'year') return 365 * 86400; + return null; +}; + +const fetchNeuralwattQuota = async (): Promise => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['neuralwatt'])) as Record | null; + const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); + + if (!apiKey) { + return buildResult({ + providerId: 'neuralwatt', + providerName: 'NeuralWatt', + ok: false, + configured: false, + error: 'Not configured', + }); + } + + const timeoutSignal = AbortSignal.timeout(15_000); + + try { + const response = await fetch(NEURALWATT_QUOTA_URL, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Accept-Encoding': 'identity', + }, + signal: timeoutSignal, + }); + + if (!response.ok) { + return buildResult({ + providerId: 'neuralwatt', + providerName: 'NeuralWatt', + ok: false, + configured: true, + error: response.status === 401 + ? 'Session expired — please re-authenticate with NeuralWatt' + : `API error: ${response.status}`, + }); + } + + const payload = await response.json() as NeuralwattPayload; + const subscription = payload?.subscription ?? null; + const inOverage = Boolean(subscription?.in_overage); + const allowance = payload?.key?.allowance ?? null; + const keyName = payload?.key?.name ?? null; + const creditsRemaining = toNumber(payload?.balance?.credits_remaining_usd); + + const windows: Record = {}; + + if (subscription) { + const kwhIncluded = toNumber(subscription.kwh_included); + const kwhUsed = toNumber(subscription.kwh_used); + const plan = typeof subscription.plan === 'string' && subscription.plan.trim() + ? subscription.plan.trim() + : null; + // Subscription window title is the plan name; subscription limits reset + // monthly even on annual billing plans, but the API exposes no kWh window + // start to derive windowSeconds — pass null rather than fabricating a guess. + const subKey = plan ?? 'plan_limit'; + const usedPercent = inOverage + ? 100 + : (kwhIncluded !== null && kwhIncluded > 0 && kwhUsed !== null + ? Math.max(0, Math.min(100, (kwhUsed / kwhIncluded) * 100)) + : null); + const subResetAt = toTimestamp(subscription.kwh_reset_date) ?? toTimestamp(subscription.current_period_end); + windows[subKey] = toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt: subResetAt, + }); + } + + if (allowance) { + const spent = toNumber(allowance.spent_usd); + const limit = toNumber(allowance.limit_usd); + // Credits wallet is reduced by each period's spend before the allowance cap + // bites, so the real ceiling is min(limit, creditsRemaining + spent). + const effectiveSpent = spent ?? 0; + const effectiveLimit = limit !== null && creditsRemaining !== null + ? Math.min(limit, creditsRemaining + effectiveSpent) + : (limit ?? creditsRemaining); + const period = typeof allowance.period === 'string' && allowance.period.trim() + ? allowance.period.trim() + : null; + const blocked = Boolean(allowance.blocked); + const usedPercent = blocked + ? 100 + : (spent !== null && effectiveLimit !== null && effectiveLimit > 0 + ? Math.max(0, Math.min(100, (spent / effectiveLimit) * 100)) + : null); + // Window title is the localized period label (daily/weekly/monthly); key + // name is attached via valueLabel for identification (wafer precedent). + const periodKey = (period === 'daily' || period === 'weekly' || period === 'monthly' || period === 'month') + ? (period === 'month' ? 'monthly' : period) + : 'billing_cycle'; + const labelName = typeof keyName === 'string' && keyName.trim() ? keyName.trim() : null; + const resetAt = toTimestamp(allowance.reset_at); + const windowSeconds = period ? neuralwattWindowSeconds(period) : null; + windows[periodKey] = toUsageWindow({ + usedPercent, + windowSeconds, + resetAt, + ...(labelName ? { valueLabel: labelName } : {}), + }); + } else if (creditsRemaining !== null) { + windows.credits_balance = toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: `$${formatMoney(creditsRemaining)}`, + }); + } + + if (Object.keys(windows).length === 0) { + return buildResult({ + providerId: 'neuralwatt', + providerName: 'NeuralWatt', + ok: false, + configured: true, + error: 'No quota data in response', + }); + } + + return buildResult({ + providerId: 'neuralwatt', + providerName: 'NeuralWatt', + ok: true, + configured: true, + usage: { windows }, + }); + } catch (error) { + const isTimeout = error instanceof DOMException && error.name === 'AbortError' && timeoutSignal.aborted; + const isParseError = error instanceof SyntaxError; + return buildResult({ + providerId: 'neuralwatt', + providerName: 'NeuralWatt', + ok: false, + configured: true, + error: isTimeout + ? 'Request timed out' + : isParseError + ? 'Invalid response from provider' + : (error instanceof Error ? error.message : 'Request failed'), + }); + } +}; + +const CROF_USAGE_URL = 'https://crof.ai/usage_api/'; + +const fetchCrofQuota = async (): Promise => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['crof'])) as Record | null; + const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); + + if (!apiKey) { + return buildResult({ + providerId: 'crof', + providerName: 'CrofAI', + ok: false, + configured: false, + error: 'Not configured', + }); + } + + const timeoutSignal = AbortSignal.timeout(15_000); + + try { + const response = await fetch(CROF_USAGE_URL, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Accept-Encoding': 'identity', + }, + signal: timeoutSignal, + }); + + if (!response.ok) { + return buildResult({ + providerId: 'crof', + providerName: 'CrofAI', + ok: false, + configured: true, + error: response.status === 401 + ? 'Session expired — please re-authenticate with CrofAI' + : `API error: ${response.status}`, + }); + } + + const payload = await response.json() as CrofPayload; + const credits = toNumber(payload?.credits); + const valueLabel = credits !== null ? `$${formatMoney(credits)}` : null; + + const windows: Record = { + credits: toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel, + }), + }; + + return buildResult({ + providerId: 'crof', + providerName: 'CrofAI', + ok: true, + configured: true, + usage: { windows }, + }); + } catch (error) { + const isTimeout = error instanceof DOMException && error.name === 'AbortError' && timeoutSignal.aborted; + const isParseError = error instanceof SyntaxError; + return buildResult({ + providerId: 'crof', + providerName: 'CrofAI', + ok: false, + configured: true, + error: isTimeout + ? 'Request timed out' + : isParseError + ? 'Invalid response from provider' + : (error instanceof Error ? error.message : 'Request failed'), + }); + } +}; + export const fetchQuotaForProvider = async (providerId: string): Promise => { switch (providerId) { case 'claude': @@ -1906,6 +2184,10 @@ export const fetchQuotaForProvider = async (providerId: string): Promise { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + return Boolean(entry?.key || entry?.token); +}; + +export const fetchQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const apiKey = entry?.key ?? entry?.token; + + if (!apiKey) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: false, + error: 'Not configured' + }); + } + + const timeoutSignal = AbortSignal.timeout(15_000); + + try { + const response = await fetch(CROF_USAGE_URL, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Accept-Encoding': 'identity' + }, + signal: timeoutSignal + }); + + if (!response.ok) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: response.status === 401 + ? 'Session expired — please re-authenticate with CrofAI' + : `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const credits = toNumber(payload?.credits); + const valueLabel = credits !== null ? `$${formatMoney(credits)}` : null; + + const windows = { + credits: toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel + }) + }; + + return buildResult({ + providerId, + providerName, + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + const isTimeout = error instanceof DOMException && error.name === 'AbortError' && timeoutSignal.aborted; + const isParseError = error instanceof SyntaxError; + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + 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/crof.test.js b/packages/web/server/lib/quota/providers/crof.test.js new file mode 100644 index 00000000..4c04a1b8 --- /dev/null +++ b/packages/web/server/lib/quota/providers/crof.test.js @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../opencode/auth.js', () => ({ + readAuthFile: () => ({ crof: { key: 'test-token' } }), +})); + +import { fetchQuota } from './crof.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const mockResponse = (body, init = {}) => ({ + ok: true, + status: 200, + json: async () => body, + ...init, +}); + +describe('Crof quota provider', () => { + it('reports credits balance as valueLabel with null percent', async () => { + // Documented /usage_api/ response from https://crof.ai/docs.md + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + mockResponse({ usable_requests: 450, credits: 12.3456 }), + )); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.providerId).toBe('crof'); + expect(result.usage.windows.credits.usedPercent).toBeNull(); + expect(result.usage.windows.credits.valueLabel).toBe('$12.35'); + expect(result.usage.windows.credits.windowSeconds).toBeNull(); + expect(result.usage.windows.credits.resetAt).toBeNull(); + }); + + it('tolerates missing credits field', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + mockResponse({ usable_requests: 0 }), + )); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.usage.windows.credits.valueLabel).toBeUndefined(); + expect(result.usage.windows.credits.usedPercent).toBeNull(); + }); + + it('parses numeric-string credits', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + mockResponse({ credits: '99.5' }), + )); + + const result = await fetchQuota(); + + expect(result.usage.windows.credits.valueLabel).toBe('$99.50'); + }); + + it('maps 401 to session-expired error', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({}), + })); + + const result = await fetchQuota(); + + expect(result.ok).toBe(false); + expect(result.configured).toBe(true); + expect(result.error).toBe('Session expired — please re-authenticate with CrofAI'); + }); + + it('surfaces non-401 API errors with status', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 503, + json: async () => ({}), + })); + + const result = await fetchQuota(); + + expect(result.ok).toBe(false); + expect(result.error).toBe('API error: 503'); + }); + + 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'); + }); +}); diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index d572f215..17bb6a9c 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -10,6 +10,7 @@ import { buildResult } from '../utils/index.js'; import * as claude from './claude.js'; import * as codex from './codex.js'; import * as copilot from './copilot.js'; +import * as crof from './crof.js'; import * as cursor from './cursor.js'; import * as google from './google/index.js'; import * as kimi from './kimi.js'; @@ -20,6 +21,7 @@ import * as zai from './zai.js'; import * as zhipuaiCodingPlan from './zhipuai-coding-plan.js'; import * as minimaxCodingPlan from './minimax-coding-plan.js'; import * as minimaxCnCodingPlan from './minimax-cn-coding-plan.js'; +import * as neuralwatt from './neuralwatt.js'; import * as ollamaCloud from './ollama-cloud.js'; import * as wafer from './wafer.js'; import * as opencodeGo from './opencode-go.js'; @@ -37,6 +39,12 @@ const registry = { isConfigured: codex.isConfigured, fetchQuota: codex.fetchQuota }, + crof: { + providerId: crof.providerId, + providerName: crof.providerName, + isConfigured: crof.isConfigured, + fetchQuota: crof.fetchQuota + }, cursor: { providerId: cursor.providerId, providerName: cursor.providerName, @@ -120,6 +128,12 @@ const registry = { providerName: opencodeGo.providerName, isConfigured: opencodeGo.isConfigured, fetchQuota: opencodeGo.fetchQuota + }, + neuralwatt: { + providerId: neuralwatt.providerId, + providerName: neuralwatt.providerName, + isConfigured: neuralwatt.isConfigured, + fetchQuota: neuralwatt.fetchQuota } }; diff --git a/packages/web/server/lib/quota/providers/neuralwatt.js b/packages/web/server/lib/quota/providers/neuralwatt.js new file mode 100644 index 00000000..7fa9e001 --- /dev/null +++ b/packages/web/server/lib/quota/providers/neuralwatt.js @@ -0,0 +1,174 @@ +import { readAuthFile } from '../../opencode/auth.js'; +import { + getAuthEntry, + normalizeAuthEntry, + buildResult, + toUsageWindow, + toNumber, + toTimestamp, + formatMoney, + asNonEmptyString +} from '../utils/index.js'; + +export const providerId = 'neuralwatt'; +export const providerName = 'NeuralWatt'; +const aliases = ['neuralwatt']; +const NEURALWATT_QUOTA_URL = 'https://api.neuralwatt.com/v1/quota'; + +// 30d month / 365d year are fixed approximations; real calendars vary but the +// window is for the UI's progress bar label, not billing decisions. +const periodToWindowSeconds = (period) => { + if (period === 'daily') return 86400; + if (period === 'weekly') return 604800; + if (period === 'monthly' || period === 'month') return 30 * 86400; + if (period === 'yearly' || period === 'year') return 365 * 86400; + return null; +}; + +export const isConfigured = () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + return Boolean(entry?.key || entry?.token); +}; + +export const fetchQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const apiKey = entry?.key ?? entry?.token; + + if (!apiKey) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: false, + error: 'Not configured' + }); + } + + const timeoutSignal = AbortSignal.timeout(15_000); + + try { + const response = await fetch(NEURALWATT_QUOTA_URL, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Accept-Encoding': 'identity' + }, + signal: timeoutSignal + }); + + if (!response.ok) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: response.status === 401 + ? 'Session expired — please re-authenticate with NeuralWatt' + : `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const subscription = payload?.subscription ?? null; + const inOverage = Boolean(subscription?.in_overage); + const allowance = payload?.key?.allowance ?? null; + const keyName = payload?.key?.name ?? null; + const creditsRemaining = toNumber(payload?.balance?.credits_remaining_usd); + + const windows = {}; + + if (subscription) { + const kwhIncluded = toNumber(subscription.kwh_included); + const kwhUsed = toNumber(subscription.kwh_used); + const plan = asNonEmptyString(subscription.plan); + // Subscription window title is the plan name; subscription limits reset + // monthly even on annual billing plans, but the API exposes no kWh window + // start to derive windowSeconds — pass null rather than fabricating a guess. + const subKey = plan ?? 'plan_limit'; + const usedPercent = inOverage + ? 100 + : (kwhIncluded !== null && kwhIncluded > 0 && kwhUsed !== null + ? Math.max(0, Math.min(100, (kwhUsed / kwhIncluded) * 100)) + : null); + const subResetAt = toTimestamp(subscription.kwh_reset_date) ?? toTimestamp(subscription.current_period_end); + windows[subKey] = toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt: subResetAt + }); + } + + if (allowance) { + const spent = toNumber(allowance.spent_usd); + const limit = toNumber(allowance.limit_usd); + // Credits wallet is reduced by each period's spend before the allowance cap + // bites, so the real ceiling is min(limit, creditsRemaining + spent). + const effectiveSpent = spent ?? 0; + const effectiveLimit = limit !== null && creditsRemaining !== null + ? Math.min(limit, creditsRemaining + effectiveSpent) + : (limit ?? creditsRemaining); + const period = asNonEmptyString(allowance.period); + const blocked = Boolean(allowance.blocked); + const usedPercent = blocked + ? 100 + : (spent !== null && effectiveLimit !== null && effectiveLimit > 0 + ? Math.max(0, Math.min(100, (spent / effectiveLimit) * 100)) + : null); + // Window title is the localized period label (daily/weekly/monthly); key + // name is attached via valueLabel for identification (wafer precedent). + const periodKey = (period === 'daily' || period === 'weekly' || period === 'monthly' || period === 'month') + ? (period === 'month' ? 'monthly' : period) + : 'billing_cycle'; + const labelName = asNonEmptyString(keyName); + const resetAt = toTimestamp(allowance.reset_at); + const windowSeconds = period ? periodToWindowSeconds(period) : null; + windows[periodKey] = toUsageWindow({ + usedPercent, + windowSeconds, + resetAt, + ...(labelName ? { valueLabel: labelName } : {}) + }); + } else if (creditsRemaining !== null) { + windows.credits_balance = toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: `$${formatMoney(creditsRemaining)}` + }); + } + + if (Object.keys(windows).length === 0) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: 'No quota data in response' + }); + } + + return buildResult({ + providerId, + providerName, + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + const isTimeout = error instanceof DOMException && error.name === 'AbortError' && timeoutSignal.aborted; + const isParseError = error instanceof SyntaxError; + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + 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/neuralwatt.test.js b/packages/web/server/lib/quota/providers/neuralwatt.test.js new file mode 100644 index 00000000..8a08d2f1 --- /dev/null +++ b/packages/web/server/lib/quota/providers/neuralwatt.test.js @@ -0,0 +1,287 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../opencode/auth.js', () => ({ + readAuthFile: () => ({ neuralwatt: { key: 'test-token' } }), +})); + +import { fetchQuota } from './neuralwatt.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const mockResponse = (body, init = {}) => ({ + ok: true, + status: 200, + json: async () => body, + ...init, +}); + +// Documented payload shape from https://portal.neuralwatt.com/docs/api/quota +// Subscription has kwh_included=20.0, kwh_used=13.9023, plan="standard". +const DOCUMENTED_SUBSCRIPTION_PAYLOAD = { + snapshot_at: '2026-04-16T18:30:00Z', + balance: { credits_remaining_usd: 32.6774, total_credits_usd: 52.34, credits_used_usd: 19.6626, accounting_method: 'energy' }, + usage: { lifetime: { cost_usd: 243.9145, requests: 37801, tokens: 1235477176, energy_kwh: 15.6009 }, current_month: { cost_usd: 160.1463, requests: 23902, tokens: 1116658995, energy_kwh: 9.7278 } }, + limits: { overage_limit_usd: null, rate_limit_tier: 'standard' }, + subscription: { + plan: 'standard', + status: 'active', + billing_interval: 'year', + current_period_start: '2026-04-11T05:05:25Z', + current_period_end: '2027-04-11T05:05:25Z', + auto_renew: true, + kwh_included: 20.0, + kwh_used: 13.9023, + kwh_remaining: 6.0977, + in_overage: false, + }, + key: { name: 'my-production-key', allowance: null }, +}; + +describe('NeuralWatt quota provider', () => { + it('builds subscription window from documented payload (keyed by plan name)', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(DOCUMENTED_SUBSCRIPTION_PAYLOAD))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.providerId).toBe('neuralwatt'); + + // Subscription window is keyed by the plan name; windowSeconds is null + // because the API exposes no kWh window start to derive duration from. + const window = result.usage.windows.standard; + expect(window).toBeDefined(); + expect(window.usedPercent).toBeCloseTo((13.9023 / 20.0) * 100, 4); + expect(window.windowSeconds).toBeNull(); + expect(window.resetAt).toBe(Date.parse('2027-04-11T05:05:25Z')); + + // allowance is null, so the credits_balance window is *also* surfaced. + expect(result.usage.windows.credits_balance).toBeDefined(); + expect(result.usage.windows.credits_balance.valueLabel).toBe('$32.68'); + }); + + it('falls back to plan_limit title when plan is missing', async () => { + const payload = { + ...DOCUMENTED_SUBSCRIPTION_PAYLOAD, + subscription: { ...DOCUMENTED_SUBSCRIPTION_PAYLOAD.subscription, plan: null }, + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(payload))); + + const result = await fetchQuota(); + + expect(result.usage.windows.plan_limit).toBeDefined(); + expect(result.usage.windows.plan_limit.usedPercent).toBeCloseTo((13.9023 / 20.0) * 100, 4); + }); + + it('marks in-overage subscription as 100%, still shows credits', async () => { + const payload = { + ...DOCUMENTED_SUBSCRIPTION_PAYLOAD, + subscription: { ...DOCUMENTED_SUBSCRIPTION_PAYLOAD.subscription, in_overage: true, kwh_used: 25.0 }, + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(payload))); + + const result = await fetchQuota(); + + const window = result.usage.windows.standard; + expect(window).toBeDefined(); + expect(window.usedPercent).toBe(100); + expect(result.usage.windows.credits_balance.valueLabel).toBe('$32.68'); + }); + + it('surfaces subscription and allowance windows (allowance keyed by period, key name in valueLabel)', async () => { + const payload = { + ...DOCUMENTED_SUBSCRIPTION_PAYLOAD, + balance: { credits_remaining_usd: 200 }, + key: { + name: 'Prod', + allowance: { limit_usd: 100, period: 'monthly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' }, + }, + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(payload))); + + const result = await fetchQuota(); + + const subWindow = result.usage.windows.standard; + expect(subWindow).toBeDefined(); + expect(subWindow.usedPercent).toBeCloseTo((13.9023 / 20.0) * 100, 4); + + // Allowance window is keyed by the localized period label ("monthly"); + // key name flows through valueLabel for identification. + const allowWindow = result.usage.windows.monthly; + expect(allowWindow).toBeDefined(); + expect(allowWindow.usedPercent).toBe(25); + expect(allowWindow.valueLabel).toBe('Prod'); + expect(allowWindow.resetAt).toBe(Date.parse('2026-08-01T00:00:00Z')); + + // credits_balance suppressed because allowance is present + expect(result.usage.windows.credits_balance).toBeUndefined(); + }); + + it('uses allowance effective limit = min(limit, credits_remaining + spent)', async () => { + const payload = { + balance: { credits_remaining_usd: 30 }, + subscription: null, + key: { + name: 'prod-key', + allowance: { limit_usd: 100, period: 'monthly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' }, + }, + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(payload))); + + const result = await fetchQuota(); + + const window = result.usage.windows.monthly; + expect(window).toBeDefined(); + // effectiveLimit = min(100, 30+25) = 55; usedPercent = 25/55 * 100 ≈ 45.4545 + expect(window.usedPercent).toBeCloseTo((25 / 55) * 100, 4); + expect(window.windowSeconds).toBe(30 * 86400); + expect(window.resetAt).toBe(Date.parse('2026-08-01T00:00:00Z')); + expect(window.valueLabel).toBe('prod-key'); + expect(result.usage.windows.credits_balance).toBeUndefined(); + }); + + it('binds allowance ceiling to limit when limit < credits_remaining + spent', async () => { + const payload = { + balance: { credits_remaining_usd: 200 }, + subscription: null, + key: { + name: 'prod-key', + allowance: { limit_usd: 100, period: 'monthly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' }, + }, + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(payload))); + + const result = await fetchQuota(); + + const window = result.usage.windows.monthly; + expect(window).toBeDefined(); + expect(window.usedPercent).toBe(25); + }); + + it('uses weekly as the allowance key when period is weekly', async () => { + const payload = { + balance: { credits_remaining_usd: 200 }, + subscription: null, + key: { + name: 'Prod', + allowance: { limit_usd: 100, period: 'weekly', spent_usd: 20, blocked: false, reset_at: '2026-07-04T00:00:00Z' }, + }, + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(payload))); + + const result = await fetchQuota(); + + const window = result.usage.windows.weekly; + expect(window).toBeDefined(); + expect(window.windowSeconds).toBe(604800); + expect(window.resetAt).toBe(Date.parse('2026-07-04T00:00:00Z')); + expect(window.valueLabel).toBe('Prod'); + }); + + it('uses daily as the allowance key when period is daily', async () => { + const payload = { + balance: { credits_remaining_usd: 200 }, + subscription: null, + key: { + name: 'Prod', + allowance: { limit_usd: 10, period: 'daily', spent_usd: 2, blocked: false, reset_at: '2026-07-04T00:00:00Z' }, + }, + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(payload))); + + const result = await fetchQuota(); + + const window = result.usage.windows.daily; + expect(window).toBeDefined(); + expect(window.windowSeconds).toBe(86400); + expect(window.resetAt).toBe(Date.parse('2026-07-04T00:00:00Z')); + }); + + it('falls back to billing_cycle when allowance period is missing or unknown', async () => { + const payload = { + balance: { credits_remaining_usd: 200 }, + subscription: null, + key: { + name: 'Prod', + allowance: { limit_usd: 100, period: 'fortnightly', spent_usd: 25, blocked: false, reset_at: '2026-08-01T00:00:00Z' }, + }, + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(payload))); + + const result = await fetchQuota(); + + const window = result.usage.windows.billing_cycle; + expect(window).toBeDefined(); + expect(window.usedPercent).toBe(25); + }); + + it('marks blocked allowance as 100% with valueLabel set', async () => { + const payload = { + balance: { credits_remaining_usd: 30 }, + subscription: null, + key: { + name: 'sample', + allowance: { limit_usd: 50, period: 'monthly', spent_usd: 10, blocked: true, reset_at: '2026-08-01T00:00:00Z' }, + }, + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(payload))); + + const result = await fetchQuota(); + + const window = result.usage.windows.monthly; + expect(window.usedPercent).toBe(100); + expect(window.valueLabel).toBe('sample'); + }); + + it('falls back to credits_balance when neither subscription nor allowance exists', async () => { + const payload = { + balance: { credits_remaining_usd: 32.6774 }, + subscription: null, + key: { name: 'sample', allowance: null }, + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(payload))); + + const result = await fetchQuota(); + + expect(result.usage.windows.credits_balance.valueLabel).toBe('$32.68'); + expect(result.usage.windows.credits_balance.usedPercent).toBeNull(); + }); + + it('maps 401 to session-expired error', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })); + + const result = await fetchQuota(); + + expect(result.ok).toBe(false); + expect(result.error).toBe('Session expired — please re-authenticate with NeuralWatt'); + }); + + 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('returns no-quota-data on a 200 payload with no usable windows', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ + balance: { credits_remaining_usd: null }, + subscription: null, + key: { name: 'sample', allowance: null }, + }))); + + 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(); + }); +});