From 5e7c1477857f3db81e13a35916348689512adce2 Mon Sep 17 00:00:00 2001 From: Howon Lee Date: Sun, 6 Sep 2026 05:16:13 +0900 Subject: [PATCH] feat: add Charm Hyper quota provider (#3368) --- packages/ui/src/lib/quota/providers/index.ts | 1 + packages/ui/src/types/quota.ts | 1 + packages/vscode/src/quotaProviders.test.ts | 85 ++++++++++++ packages/vscode/src/quotaProviders.ts | 111 +++++++++++++++ .../web/server/lib/quota/DOCUMENTATION.md | 1 + packages/web/server/lib/quota/index.js | 1 + .../web/server/lib/quota/providers/hyper.js | 118 ++++++++++++++++ .../server/lib/quota/providers/hyper.test.js | 128 ++++++++++++++++++ .../web/server/lib/quota/providers/index.js | 8 ++ 9 files changed, 454 insertions(+) create mode 100644 packages/web/server/lib/quota/providers/hyper.js create mode 100644 packages/web/server/lib/quota/providers/hyper.test.js diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index 749f5516..5616135d 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -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' }, ]; diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index 1524ca51..aa55d52e 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -19,6 +19,7 @@ export type QuotaProviderId = | 'crof' | 'deepseek' | 'exe-dev' + | 'hyper' | 'neuralwatt' | 'xai'; diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index 62a8f014..ec9e13c2 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -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; + }); +}); diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 74be878f..b1518f2d 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -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).key || (hyperAuth as Record).token)) { + configured.add('hyper'); + } + let xaiAuth: XaiAuthEntry | null = null; try { xaiAuth = resolveXaiAuth(); @@ -2786,6 +2795,106 @@ const fetchDeepseekQuota = async (): Promise => { } }; +const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits'; +const HYPER_CREDIT_TO_USD = 0.05; + +const fetchHyperQuota = async (): Promise => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['hyper'])) as Record | 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 = { + 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 => { try { const entry = resolveXaiAuth(); @@ -2907,6 +3016,8 @@ const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + return Boolean(entry?.key || entry?.token); +}; + +export const fetchQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const apiKey = entry?.key ?? entry?.token; + + if (!apiKey) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: false, + error: 'Not configured' + }); + } + + const timeoutSignal = AbortSignal.timeout(15_000); + + try { + const response = await fetch(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') + }); + } +}; \ No newline at end of file diff --git a/packages/web/server/lib/quota/providers/hyper.test.js b/packages/web/server/lib/quota/providers/hyper.test.js new file mode 100644 index 00000000..9fd869f0 --- /dev/null +++ b/packages/web/server/lib/quota/providers/hyper.test.js @@ -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'); + }); +}); \ No newline at end of file diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index b6d7be3d..c9ef0050 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -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;