feat(quota): add Crof and NeuralWatt quota providers (#2415)
This commit is contained in:
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<unknown>): void => {
|
||||
globalThis.fetch = (async () => resolver()) as typeof fetch;
|
||||
};
|
||||
|
||||
const stubFetchFailing = (json: () => Promise<unknown>, 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;
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>).key || (crofAuth as Record<string, unknown>).token)) {
|
||||
configured.add('crof');
|
||||
}
|
||||
|
||||
const neuralwattAuth = normalizeAuthEntry(getAuthEntry(auth, ['neuralwatt']));
|
||||
if (neuralwattAuth && ((neuralwattAuth as Record<string, unknown>).key || (neuralwattAuth as Record<string, unknown>).token)) {
|
||||
configured.add('neuralwatt');
|
||||
}
|
||||
|
||||
return Array.from(configured);
|
||||
};
|
||||
|
||||
@@ -1865,6 +1906,243 @@ const fetchWaferQuota = async (): Promise<ProviderResult> => {
|
||||
}
|
||||
};
|
||||
|
||||
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<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['neuralwatt'])) as Record<string, unknown> | 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<string, UsageWindow> = {};
|
||||
|
||||
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<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['crof'])) as Record<string, unknown> | 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<string, UsageWindow> = {
|
||||
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<ProviderResult> => {
|
||||
switch (providerId) {
|
||||
case 'claude':
|
||||
@@ -1906,6 +2184,10 @@ export const fetchQuotaForProvider = async (providerId: string): Promise<Provide
|
||||
}
|
||||
case 'cursor':
|
||||
return fetchCursorQuota();
|
||||
case 'crof':
|
||||
return fetchCrofQuota();
|
||||
case 'neuralwatt':
|
||||
return fetchNeuralwattQuota();
|
||||
default:
|
||||
return buildResult({
|
||||
providerId,
|
||||
|
||||
@@ -19,6 +19,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
|
||||
| `claude` | Claude | `providers/claude.js` | `anthropic`, `claude` |
|
||||
| `codex` | Codex | `providers/codex.js` | `openai`, `codex`, `chatgpt` |
|
||||
| `cursor` | Cursor | `providers/cursor.js` | Environment/token files, OpenChamber-managed credentials, or explicit one-time Cursor import |
|
||||
| `crof` | CrofAI | `providers/crof.js` | `crof` (API key under `key` or `token`) |
|
||||
| `google` | Google | `providers/google/index.js` | `google`, `google.oauth`, Antigravity accounts file |
|
||||
| `github-copilot` | GitHub Copilot | `providers/copilot.js` | `github-copilot`, `copilot` |
|
||||
| `github-copilot-addon` | GitHub Copilot Add-on | `providers/copilot.js` | `github-copilot`, `copilot` |
|
||||
@@ -32,6 +33,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
|
||||
| `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Manual cookie stored under `~/.config/openchamber/quota/` |
|
||||
| `wafer` | Wafer.ai | `providers/wafer.js` | `wafer`, `wafer-ai`, `wafer_ai`, `wafer.ai` |
|
||||
| `opencode-go` | OpenCode Go | `providers/opencode-go.js` | Manual workspace ID and auth cookie stored under `~/.config/openchamber/quota/` |
|
||||
| `neuralwatt` | NeuralWatt | `providers/neuralwatt.js` | `neuralwatt` (API key under `key` or `token`) |
|
||||
|
||||
## Internal-only provider module
|
||||
- `providers/openai.js` exists for logic parity/reuse but is intentionally not registered for dispatcher ID routing.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { readAuthFile } from '../../opencode/auth.js';
|
||||
import {
|
||||
getAuthEntry,
|
||||
normalizeAuthEntry,
|
||||
buildResult,
|
||||
toUsageWindow,
|
||||
toNumber,
|
||||
formatMoney
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const providerId = 'crof';
|
||||
export const providerName = 'CrofAI';
|
||||
const aliases = ['crof'];
|
||||
const CROF_USAGE_URL = 'https://crof.ai/usage_api/';
|
||||
|
||||
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(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')
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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')
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user