fix(quota): validate Hyper credentials and clean up balance labels
Reject invalid credentials while preserving valid token fallback, parse balances with existing boundary helpers, and keep credit values free of untranslated unit text. Inject auth and HTTP dependencies for focused tests in both runtimes. Validated web quota and registry tests (35 passed), VS Code quota tests (70 passed), both package type checks and lint, extension build, and changed-line anti-slop checks. Reviewed dead-code output. Live Hyper validation was not run because no API key is available.
This commit is contained in:
@@ -91,6 +91,12 @@ In 2025/2026 MiniMax rebranded "Coding Plan" to "Token Plan" alongside the M3 mo
|
||||
- **model_remains array**: Now contains entries for multiple model categories (chat, speech, video, image). The provider selects the chat-model entry by matching `MiniMax-M*`, then `general`/`chat`/`text` by name, then any entry with a remaining percent.
|
||||
- **Window status**: The `current_interval_status` and `current_weekly_status` fields indicate whether a window is active. Status `3` means the window is not applicable for the current plan tier (e.g. legacy plans without weekly limits). The provider omits inactive windows.
|
||||
|
||||
## Charm Hyper balance semantics
|
||||
|
||||
`GET https://hyper.charm.land/v1/credits` returns a team's current Hypercredit balance, not a percentage or reset timestamp. The [Hyper FAQ](https://hyper.charm.land/faq) defines one Hypercredit as $0.05. Both runtimes expose `credits_balance` in dollars and `credits` as a numeric label under the UI's localized window title. Keep English unit text out of that numeric label.
|
||||
|
||||
Web and VS Code accept finite numeric balances and non-empty numeric strings. Missing, blank, or malformed balances remain explicit failures; zero is valid. Credential lookup uses a non-empty string `key`, then `token`, so malformed or blank keys cannot mark the provider configured or hide a valid fallback token. Hyper fetchers accept `readAuth` and `fetchImpl` dependencies for tests without replacing filesystem or auth modules.
|
||||
|
||||
## Kimi for Coding field semantics
|
||||
|
||||
`GET https://api.kimi.com/coding/v1/usages` is inconsistent about which field carries consumption:
|
||||
|
||||
@@ -5,25 +5,26 @@ import {
|
||||
buildResult,
|
||||
toUsageWindow,
|
||||
toNumber,
|
||||
formatMoney
|
||||
formatMoney,
|
||||
asObject,
|
||||
asNonEmptyString
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const providerId = 'hyper';
|
||||
export const providerName = 'Charm Hyper';
|
||||
const aliases = ['hyper'];
|
||||
export const aliases = ['hyper'];
|
||||
const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits';
|
||||
const CREDIT_TO_USD = 0.05;
|
||||
|
||||
export const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
const getApiKey = (auth) => {
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
return Boolean(entry?.key || entry?.token);
|
||||
return asNonEmptyString(entry?.key) ?? asNonEmptyString(entry?.token);
|
||||
};
|
||||
|
||||
export const fetchQuota = async () => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
const apiKey = entry?.key ?? entry?.token;
|
||||
export const isConfigured = (auth = readAuthFile()) => Boolean(getApiKey(auth));
|
||||
|
||||
export const fetchQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch } = {}) => {
|
||||
const apiKey = getApiKey(readAuth());
|
||||
|
||||
if (!apiKey) {
|
||||
return buildResult({
|
||||
@@ -38,7 +39,7 @@ export const fetchQuota = async () => {
|
||||
const timeoutSignal = AbortSignal.timeout(15_000);
|
||||
|
||||
try {
|
||||
const response = await fetch(HYPER_QUOTA_URL, {
|
||||
const response = await fetchImpl(HYPER_QUOTA_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
@@ -59,11 +60,10 @@ export const fetchQuota = async () => {
|
||||
});
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const payload = asObject(await response.json());
|
||||
const rawBalance = payload?.balance;
|
||||
const balance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== ''))
|
||||
? toNumber(rawBalance)
|
||||
: null;
|
||||
const balance = toNumber(asNonEmptyString(rawBalance)
|
||||
?? (Number.isFinite(rawBalance) ? rawBalance : null));
|
||||
|
||||
if (balance === null) {
|
||||
return buildResult({
|
||||
@@ -87,7 +87,7 @@ export const fetchQuota = async () => {
|
||||
usedPercent: null,
|
||||
windowSeconds: null,
|
||||
resetAt: null,
|
||||
valueLabel: `${creditsLabel} credits`
|
||||
valueLabel: creditsLabel
|
||||
})
|
||||
};
|
||||
|
||||
@@ -115,4 +115,4 @@ export const fetchQuota = async () => {
|
||||
: (error instanceof Error ? error.message : 'Request failed')
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,128 +1,126 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fetchQuota, isConfigured } from './hyper.js';
|
||||
|
||||
vi.mock('../../opencode/auth.js', () => ({
|
||||
readAuthFile: () => ({ hyper: { key: 'test-token' } }),
|
||||
}));
|
||||
const readAuth = () => ({ hyper: { key: 'test-token' } });
|
||||
|
||||
import { fetchQuota } from './hyper.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const mockResponse = (body, init = {}) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
...init,
|
||||
});
|
||||
|
||||
// Documented payload shape from https://hyper.charm.land/docs/api/credits.html
|
||||
// The balance is denominated in Hypercredits; 1 credit = $0.05.
|
||||
// https://hyper.charm.land/docs/api/credits.html documents the balance payload.
|
||||
// https://hyper.charm.land/faq defines one Hypercredit as $0.05.
|
||||
describe('Charm Hyper quota provider', () => {
|
||||
it('builds credits and credits_balance windows from documented payload (numeric balance)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: 100 })));
|
||||
|
||||
const result = await fetchQuota();
|
||||
it.each([
|
||||
[100, '100', '$5.00'],
|
||||
['50', '50', '$2.50'],
|
||||
[25.5, '25.50', '$1.28'],
|
||||
[0, '0', '$0.00'],
|
||||
['0', '0', '$0.00'],
|
||||
])('formats balance %s without an untranslated unit', async (balance, credits, dollars) => {
|
||||
const result = await fetchQuota({
|
||||
readAuth,
|
||||
fetchImpl: async () => Response.json({ balance }),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.providerId).toBe('hyper');
|
||||
|
||||
const balanceWindow = result.usage.windows.credits_balance;
|
||||
expect(balanceWindow).toBeDefined();
|
||||
expect(balanceWindow.valueLabel).toBe('$5.00');
|
||||
expect(balanceWindow.usedPercent).toBeNull();
|
||||
expect(balanceWindow.windowSeconds).toBeNull();
|
||||
expect(balanceWindow.resetAt).toBeNull();
|
||||
|
||||
const creditsWindow = result.usage.windows.credits;
|
||||
expect(creditsWindow).toBeDefined();
|
||||
expect(creditsWindow.valueLabel).toBe('100 credits');
|
||||
expect(creditsWindow.usedPercent).toBeNull();
|
||||
expect(creditsWindow.windowSeconds).toBeNull();
|
||||
expect(creditsWindow.resetAt).toBeNull();
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.usage.windows.credits.valueLabel).toBe(credits);
|
||||
expect(result.usage.windows.credits_balance.valueLabel).toBe(dollars);
|
||||
for (const window of Object.values(result.usage.windows)) {
|
||||
expect(window.usedPercent).toBeNull();
|
||||
expect(window.remainingPercent).toBeNull();
|
||||
expect(window.windowSeconds).toBeNull();
|
||||
expect(window.resetAt).toBeNull();
|
||||
expect(window.resetAfterSeconds).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('tolerates a string balance', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: '50' })));
|
||||
it.each([
|
||||
{}, null, [], { balance: '' }, { balance: ' \t ' }, { balance: 'NaN' },
|
||||
{ balance: 'Infinity' }, { balance: null }, { balance: true }, { balance: [] },
|
||||
{ balance: {} },
|
||||
])('rejects invalid payload %j instead of showing zero', async (payload) => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => Response.json(payload) });
|
||||
|
||||
const result = await fetchQuota();
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.error).toBe('No quota data in response');
|
||||
expect(result.usage).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ hyper: { key: 'test-token' } },
|
||||
{ hyper: { token: 'test-token' } },
|
||||
{ hyper: 'test-token' },
|
||||
{ hyper: { key: ' ', token: 'test-token' } },
|
||||
{ hyper: { key: 42, token: 'test-token' } },
|
||||
])('uses a validated credential for the documented request', async (auth) => {
|
||||
expect(isConfigured(auth)).toBe(true);
|
||||
let requests = 0;
|
||||
const result = await fetchQuota({
|
||||
readAuth: () => auth,
|
||||
fetchImpl: async (url, options) => {
|
||||
requests += 1;
|
||||
expect(url).toBe('https://hyper.charm.land/v1/credits');
|
||||
expect(options.method).toBe('GET');
|
||||
expect(new Headers(options.headers).get('Authorization')).toBe('Bearer test-token');
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal);
|
||||
return Response.json({ balance: 100 });
|
||||
},
|
||||
});
|
||||
|
||||
expect(requests).toBe(1);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.usage.windows.credits.valueLabel).toBe('50 credits');
|
||||
expect(result.usage.windows.credits_balance.valueLabel).toBe('$2.50');
|
||||
expect(JSON.stringify(result)).not.toContain('test-token');
|
||||
});
|
||||
|
||||
it('formats a fractional balance in both windows', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: 25.5 })));
|
||||
it.each([{}, { hyper: { key: '' } }, { hyper: { key: ' ' } }, { hyper: { key: 42 } }])(
|
||||
'does not request usage without a valid credential',
|
||||
async (auth) => {
|
||||
expect(isConfigured(auth)).toBe(false);
|
||||
let requests = 0;
|
||||
const result = await fetchQuota({
|
||||
readAuth: () => auth,
|
||||
fetchImpl: async () => {
|
||||
requests += 1;
|
||||
return Response.json({ balance: 100 });
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fetchQuota();
|
||||
expect(requests).toBe(0);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(false);
|
||||
expect(result.error).toBe('Not configured');
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.usage.windows.credits.valueLabel).toBe('25.50 credits');
|
||||
expect(result.usage.windows.credits_balance.valueLabel).toBe('$1.28');
|
||||
});
|
||||
|
||||
it('maps 401 to session-expired error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) }));
|
||||
|
||||
const result = await fetchQuota();
|
||||
it.each([
|
||||
[401, 'Session expired — please re-authenticate with Charm Hyper'],
|
||||
[403, 'Session expired — please re-authenticate with Charm Hyper'],
|
||||
[429, 'API error: 429'],
|
||||
[500, 'API error: 500'],
|
||||
])('reports HTTP %s as a failure', async (status, error) => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response(null, { status }) });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe('Session expired — please re-authenticate with Charm Hyper');
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.error).toBe(error);
|
||||
expect(result.usage).toBeNull();
|
||||
});
|
||||
|
||||
it('maps 403 to session-expired error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403, json: async () => ({}) }));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe('Session expired — please re-authenticate with Charm Hyper');
|
||||
});
|
||||
|
||||
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);
|
||||
it('reports invalid JSON as a parse failure', async () => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response('{') });
|
||||
expect(result.error).toBe('Invalid response from provider');
|
||||
});
|
||||
|
||||
it('returns no-quota-data on a 200 payload with no balance', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({})));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.error).toBe('No quota data in response');
|
||||
expect(result.usage).toBeNull();
|
||||
});
|
||||
|
||||
it('returns no-quota-data on an empty-string balance', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: '' })));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
it.each([
|
||||
[new DOMException('Timed out', 'TimeoutError'), 'Request timed out'],
|
||||
[new Error('Network unavailable'), 'Network unavailable'],
|
||||
])('reports request failure', async (failure, message) => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => { throw failure; } });
|
||||
expect(result.error).toBe(message);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.error).toBe('No quota data in response');
|
||||
expect(result.usage).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a literal zero balance as a valid valueLabel', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: 0 })));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.usage.windows.credits.valueLabel).toBe('0 credits');
|
||||
expect(result.usage.windows.credits_balance.valueLabel).toBe('$0.00');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user