feat: add Charm Hyper quota provider (#3368)
This commit is contained in:
@@ -24,6 +24,7 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
|
||||
{ id: 'crof', name: 'CrofAI' },
|
||||
{ id: 'deepseek', name: 'DeepSeek' },
|
||||
{ id: 'exe-dev', name: 'exe.dev' },
|
||||
{ id: 'hyper', name: 'Charm Hyper' },
|
||||
{ id: 'neuralwatt', name: 'NeuralWatt' },
|
||||
{ id: 'xai', name: 'xAI' },
|
||||
];
|
||||
|
||||
@@ -19,6 +19,7 @@ export type QuotaProviderId =
|
||||
| 'crof'
|
||||
| 'deepseek'
|
||||
| 'exe-dev'
|
||||
| 'hyper'
|
||||
| 'neuralwatt'
|
||||
| 'xai';
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const AUTH = JSON.stringify({
|
||||
'opencode-go': { key: 'test-token' },
|
||||
'zai-coding-plan': { key: 'test-token' },
|
||||
deepseek: { key: 'test-token' },
|
||||
hyper: { key: 'test-token' },
|
||||
'github-copilot': { access: 'test-token' },
|
||||
anthropic: { access: 'test-token', refresh: 'test-refresh' },
|
||||
});
|
||||
@@ -714,3 +715,87 @@ describe('DeepSeek quota provider (VS Code parity)', () => {
|
||||
fsMock.readFileSync = ORIGINAL_FS.readFileSync;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Charm Hyper quota provider (VS Code parity)', () => {
|
||||
beforeEach(() => {
|
||||
const fsMock = fs as unknown as { existsSync: () => boolean; readFileSync: () => string };
|
||||
fsMock.existsSync = () => true;
|
||||
fsMock.readFileSync = () => AUTH;
|
||||
});
|
||||
|
||||
test('builds credits and credits_balance windows from documented payload (numeric balance)', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({ balance: 100 })));
|
||||
|
||||
const result = await fetchQuotaForProvider('hyper');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.providerId, 'hyper');
|
||||
|
||||
const balanceWindow = result.usage!.windows.credits_balance!;
|
||||
assert.equal(balanceWindow.valueLabel, '$5.00');
|
||||
assert.equal(balanceWindow.usedPercent, null);
|
||||
assert.equal(balanceWindow.windowSeconds, null);
|
||||
assert.equal(balanceWindow.resetAt, null);
|
||||
|
||||
const creditsWindow = result.usage!.windows.credits!;
|
||||
assert.equal(creditsWindow.valueLabel, '100 credits');
|
||||
assert.equal(creditsWindow.usedPercent, null);
|
||||
assert.equal(creditsWindow.windowSeconds, null);
|
||||
assert.equal(creditsWindow.resetAt, null);
|
||||
});
|
||||
|
||||
test('tolerates a string balance', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({ balance: '50' })));
|
||||
|
||||
const result = await fetchQuotaForProvider('hyper');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.usage!.windows.credits!.valueLabel, '50 credits');
|
||||
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$2.50');
|
||||
});
|
||||
|
||||
test('maps 401 to session-expired', async () => {
|
||||
stubFetchFailing(async () => ({}), { ok: false, status: 401 });
|
||||
|
||||
const result = await fetchQuotaForProvider('hyper');
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'Session expired — please re-authenticate with Charm Hyper');
|
||||
});
|
||||
|
||||
test('reports a normalized timeout error', async () => {
|
||||
stubFetchReturning(() => Promise.reject(new DOMException('The operation timed out.', 'TimeoutError')));
|
||||
|
||||
const result = await fetchQuotaForProvider('hyper');
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, 'Request timed out');
|
||||
});
|
||||
|
||||
test('returns no-quota-data on a 200 payload with no balance', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({})));
|
||||
|
||||
const result = await fetchQuotaForProvider('hyper');
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.configured, true);
|
||||
assert.equal(result.error, 'No quota data in response');
|
||||
assert.equal(result.usage, null);
|
||||
});
|
||||
|
||||
test('keeps a literal zero balance as a valid valueLabel', async () => {
|
||||
stubFetchReturning(() => Promise.resolve(mockResponse({ balance: 0 })));
|
||||
|
||||
const result = await fetchQuotaForProvider('hyper');
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.usage!.windows.credits!.valueLabel, '0 credits');
|
||||
assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$0.00');
|
||||
});
|
||||
|
||||
test('teardown: restore fs', () => {
|
||||
const fsMock = fs as unknown as { existsSync: unknown; readFileSync: unknown };
|
||||
fsMock.existsSync = ORIGINAL_FS.existsSync;
|
||||
fsMock.readFileSync = ORIGINAL_FS.readFileSync;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,6 +155,10 @@ type DeepseekPayload = {
|
||||
}>;
|
||||
};
|
||||
|
||||
type HyperPayload = {
|
||||
balance?: number | string;
|
||||
};
|
||||
|
||||
type NeuralwattPayload = {
|
||||
balance?: {
|
||||
credits_remaining_usd?: number | string;
|
||||
@@ -853,6 +857,11 @@ export const listConfiguredQuotaProviders = () => {
|
||||
configured.add('deepseek');
|
||||
}
|
||||
|
||||
const hyperAuth = normalizeAuthEntry(getAuthEntry(auth, ['hyper']));
|
||||
if (hyperAuth && ((hyperAuth as Record<string, unknown>).key || (hyperAuth as Record<string, unknown>).token)) {
|
||||
configured.add('hyper');
|
||||
}
|
||||
|
||||
let xaiAuth: XaiAuthEntry | null = null;
|
||||
try {
|
||||
xaiAuth = resolveXaiAuth();
|
||||
@@ -2786,6 +2795,106 @@ const fetchDeepseekQuota = async (): Promise<ProviderResult> => {
|
||||
}
|
||||
};
|
||||
|
||||
const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits';
|
||||
const HYPER_CREDIT_TO_USD = 0.05;
|
||||
|
||||
const fetchHyperQuota = async (): Promise<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['hyper'])) as Record<string, unknown> | null;
|
||||
const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined);
|
||||
|
||||
if (!apiKey) {
|
||||
return buildResult({
|
||||
providerId: 'hyper',
|
||||
providerName: 'Charm Hyper',
|
||||
ok: false,
|
||||
configured: false,
|
||||
error: 'Not configured',
|
||||
});
|
||||
}
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(15_000);
|
||||
|
||||
try {
|
||||
const response = await fetch(HYPER_QUOTA_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Accept-Encoding': 'identity',
|
||||
},
|
||||
signal: timeoutSignal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return buildResult({
|
||||
providerId: 'hyper',
|
||||
providerName: 'Charm Hyper',
|
||||
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 = await response.json() as HyperPayload;
|
||||
const rawBalance = payload?.balance;
|
||||
const balance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== ''))
|
||||
? toNumber(rawBalance)
|
||||
: null;
|
||||
|
||||
if (balance === null) {
|
||||
return buildResult({
|
||||
providerId: 'hyper',
|
||||
providerName: 'Charm Hyper',
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'No quota data in response',
|
||||
});
|
||||
}
|
||||
|
||||
const creditsLabel = Number.isInteger(balance) ? String(balance) : formatMoney(balance);
|
||||
const windows: Record<string, UsageWindow> = {
|
||||
credits_balance: toUsageWindow({
|
||||
usedPercent: null,
|
||||
windowSeconds: null,
|
||||
resetAt: null,
|
||||
valueLabel: `$${formatMoney(balance * HYPER_CREDIT_TO_USD)}`,
|
||||
}),
|
||||
credits: toUsageWindow({
|
||||
usedPercent: null,
|
||||
windowSeconds: null,
|
||||
resetAt: null,
|
||||
valueLabel: `${creditsLabel} credits`,
|
||||
}),
|
||||
};
|
||||
|
||||
return buildResult({
|
||||
providerId: 'hyper',
|
||||
providerName: 'Charm Hyper',
|
||||
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: 'hyper',
|
||||
providerName: 'Charm Hyper',
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: isTimeout
|
||||
? 'Request timed out'
|
||||
: isParseError
|
||||
? 'Invalid response from provider'
|
||||
: (error instanceof Error ? error.message : 'Request failed'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fetchXaiQuota = async (): Promise<ProviderResult> => {
|
||||
try {
|
||||
const entry = resolveXaiAuth();
|
||||
@@ -2907,6 +3016,8 @@ const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise<Pro
|
||||
return fetchCrofQuota();
|
||||
case 'deepseek':
|
||||
return fetchDeepseekQuota();
|
||||
case 'hyper':
|
||||
return fetchHyperQuota();
|
||||
case 'neuralwatt':
|
||||
return fetchNeuralwattQuota();
|
||||
case 'xai':
|
||||
|
||||
@@ -25,6 +25,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
|
||||
| `deepseek` | DeepSeek | `providers/deepseek.js` | `deepseek` (API key under `key` or `token`) |
|
||||
| `exe-dev` | exe.dev | `providers/exe-dev.js` | Usage API token stored under `~/.config/openchamber/quota/` |
|
||||
| `google` | Google | `providers/google/index.js` | `google`, `google.oauth`, Antigravity accounts file |
|
||||
| `hyper` | Charm Hyper | `providers/hyper.js` | `hyper` (API key under `key` or `token`) |
|
||||
| `github-copilot` | GitHub Copilot | `providers/copilot.js` | `github-copilot`, `copilot` |
|
||||
| `github-copilot-addon` | GitHub Copilot Add-on | `providers/copilot.js` | `github-copilot`, `copilot` |
|
||||
| `kimi-for-coding` | Kimi for Coding | `providers/kimi.js` | `kimi-for-coding`, `kimi` |
|
||||
|
||||
@@ -14,6 +14,7 @@ export {
|
||||
fetchCodexQuota,
|
||||
fetchCursorQuota,
|
||||
fetchDeepseekQuota,
|
||||
fetchHyperQuota,
|
||||
fetchCopilotQuota,
|
||||
fetchCopilotAddonQuota,
|
||||
fetchKimiQuota,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { readAuthFile } from '../../opencode/auth.js';
|
||||
import {
|
||||
getAuthEntry,
|
||||
normalizeAuthEntry,
|
||||
buildResult,
|
||||
toUsageWindow,
|
||||
toNumber,
|
||||
formatMoney
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const providerId = 'hyper';
|
||||
export const providerName = 'Charm Hyper';
|
||||
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 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(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 = await response.json();
|
||||
const rawBalance = payload?.balance;
|
||||
const balance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== ''))
|
||||
? toNumber(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} credits`
|
||||
})
|
||||
};
|
||||
|
||||
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')
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../opencode/auth.js', () => ({
|
||||
readAuthFile: () => ({ 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.
|
||||
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();
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('tolerates a string balance', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: '50' })));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.usage.windows.credits.valueLabel).toBe('50 credits');
|
||||
expect(result.usage.windows.credits_balance.valueLabel).toBe('$2.50');
|
||||
});
|
||||
|
||||
it('formats a fractional balance in both windows', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: 25.5 })));
|
||||
|
||||
const result = await fetchQuota();
|
||||
|
||||
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();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe('Session expired — please re-authenticate with Charm Hyper');
|
||||
});
|
||||
|
||||
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);
|
||||
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();
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import * as cursor from './cursor.js';
|
||||
import * as deepseek from './deepseek.js';
|
||||
import * as exeDev from './exe-dev.js';
|
||||
import * as google from './google/index.js';
|
||||
import * as hyper from './hyper.js';
|
||||
import * as kimi from './kimi.js';
|
||||
import * as nanogpt from './nanogpt.js';
|
||||
import * as openai from './openai.js';
|
||||
@@ -72,6 +73,12 @@ const registry = {
|
||||
isConfigured: google.isConfigured,
|
||||
fetchQuota: google.fetchGoogleQuota
|
||||
},
|
||||
hyper: {
|
||||
providerId: hyper.providerId,
|
||||
providerName: hyper.providerName,
|
||||
isConfigured: hyper.isConfigured,
|
||||
fetchQuota: hyper.fetchQuota
|
||||
},
|
||||
'zai-coding-plan': {
|
||||
providerId: zai.providerId,
|
||||
providerName: zai.providerName,
|
||||
@@ -220,6 +227,7 @@ export const fetchGoogleQuota = google.fetchGoogleQuota;
|
||||
export const fetchCodexQuota = codex.fetchQuota;
|
||||
export const fetchCursorQuota = cursor.fetchQuota;
|
||||
export const fetchDeepseekQuota = deepseek.fetchQuota;
|
||||
export const fetchHyperQuota = hyper.fetchQuota;
|
||||
export const fetchCopilotQuota = copilot.fetchQuota;
|
||||
export const fetchCopilotAddonQuota = copilot.fetchQuotaAddon;
|
||||
export const fetchKimiQuota = kimi.fetchQuota;
|
||||
|
||||
Reference in New Issue
Block a user