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.
119 lines
3.1 KiB
JavaScript
119 lines
3.1 KiB
JavaScript
import { readAuthFile } from '../../opencode/auth.js';
|
|
import {
|
|
getAuthEntry,
|
|
normalizeAuthEntry,
|
|
buildResult,
|
|
toUsageWindow,
|
|
toNumber,
|
|
formatMoney,
|
|
asObject,
|
|
asNonEmptyString
|
|
} from '../utils/index.js';
|
|
|
|
export const providerId = 'hyper';
|
|
export const providerName = 'Charm Hyper';
|
|
export const aliases = ['hyper'];
|
|
const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits';
|
|
const CREDIT_TO_USD = 0.05;
|
|
|
|
const getApiKey = (auth) => {
|
|
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
|
return asNonEmptyString(entry?.key) ?? asNonEmptyString(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({
|
|
providerId,
|
|
providerName,
|
|
ok: false,
|
|
configured: false,
|
|
error: 'Not configured'
|
|
});
|
|
}
|
|
|
|
const timeoutSignal = AbortSignal.timeout(15_000);
|
|
|
|
try {
|
|
const response = await fetchImpl(HYPER_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 || response.status === 403
|
|
? 'Session expired — please re-authenticate with Charm Hyper'
|
|
: `API error: ${response.status}`
|
|
});
|
|
}
|
|
|
|
const payload = asObject(await response.json());
|
|
const rawBalance = payload?.balance;
|
|
const balance = toNumber(asNonEmptyString(rawBalance)
|
|
?? (Number.isFinite(rawBalance) ? rawBalance : null));
|
|
|
|
if (balance === null) {
|
|
return buildResult({
|
|
providerId,
|
|
providerName,
|
|
ok: false,
|
|
configured: true,
|
|
error: 'No quota data in response'
|
|
});
|
|
}
|
|
|
|
const creditsLabel = Number.isInteger(balance) ? String(balance) : formatMoney(balance);
|
|
const windows = {
|
|
credits_balance: toUsageWindow({
|
|
usedPercent: null,
|
|
windowSeconds: null,
|
|
resetAt: null,
|
|
valueLabel: `$${formatMoney(balance * CREDIT_TO_USD)}`
|
|
}),
|
|
credits: toUsageWindow({
|
|
usedPercent: null,
|
|
windowSeconds: null,
|
|
resetAt: null,
|
|
valueLabel: creditsLabel
|
|
})
|
|
};
|
|
|
|
return buildResult({
|
|
providerId,
|
|
providerName,
|
|
ok: true,
|
|
configured: true,
|
|
usage: { windows }
|
|
});
|
|
} catch (error) {
|
|
const isTimeout = error instanceof DOMException && (
|
|
error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted)
|
|
);
|
|
const isParseError = error instanceof SyntaxError;
|
|
return buildResult({
|
|
providerId,
|
|
providerName,
|
|
ok: false,
|
|
configured: true,
|
|
error: isTimeout
|
|
? 'Request timed out'
|
|
: isParseError
|
|
? 'Invalid response from provider'
|
|
: (error instanceof Error ? error.message : 'Request failed')
|
|
});
|
|
}
|
|
};
|