diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index 7320ba06..5d1798c3 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -22,5 +22,6 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [ { id: 'wafer', name: 'Wafer.ai' }, { id: 'opencode-go', name: 'OpenCode Go' }, { id: 'crof', name: 'CrofAI' }, + { id: 'deepseek', name: 'DeepSeek' }, { id: 'neuralwatt', name: 'NeuralWatt' }, ]; diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index 6e5e11a4..365f588d 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -17,6 +17,7 @@ export type QuotaProviderId = | 'wafer' | 'opencode-go' | 'crof' + | 'deepseek' | 'neuralwatt'; export interface UsageWindow { diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index 9f7b466b..7d00df14 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -11,6 +11,7 @@ const AUTH = JSON.stringify({ crof: { key: 'test-token' }, neuralwatt: { key: 'test-token' }, 'zai-coding-plan': { key: 'test-token' }, + deepseek: { key: 'test-token' }, }); ((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true; ((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH; @@ -419,3 +420,72 @@ describe('NeuralWatt quota provider (VS Code parity)', () => { fsMock.readFileSync = ORIGINAL_FS.readFileSync; }); }); + +describe('DeepSeek quota provider (VS Code parity)', () => { + test('builds credits_balance window from documented USD payload (string balance)', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + is_available: true, + balance_infos: [ + { currency: 'USD', total_balance: '7.54', granted_balance: '0.00', topped_up_balance: '7.54' }, + ], + }))); + + const result = await fetchQuotaForProvider('deepseek'); + + assert.equal(result.ok, true); + assert.equal(result.providerId, 'deepseek'); + assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$7.54'); + assert.equal(result.usage!.windows.credits_balance!.usedPercent, null); + assert.equal(result.usage!.windows.credits_balance!.windowSeconds, null); + assert.equal(result.usage!.windows.credits_balance!.resetAt, null); + }); + + test('falls back to CNY entry with ¥ symbol when no USD entry is present', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + is_available: true, + balance_infos: [ + { currency: 'CNY', total_balance: '100.00', granted_balance: '0.00', topped_up_balance: '100.00' }, + ], + }))); + + const result = await fetchQuotaForProvider('deepseek'); + + assert.equal(result.ok, true); + assert.equal(result.usage!.windows.credits_balance!.valueLabel, '¥100.00'); + }); + + test('maps 401 to session-expired', async () => { + stubFetchFailing(async () => ({}), { ok: false, status: 401 }); + + const result = await fetchQuotaForProvider('deepseek'); + + assert.equal(result.ok, false); + assert.equal(result.error, 'Session expired — please re-authenticate with DeepSeek'); + }); + + test('returns no-quota-data on a 200 payload with no usable balance', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + is_available: true, + balance_infos: [{ currency: 'USD', total_balance: '', granted_balance: '0.00', topped_up_balance: '0.00' }], + }))); + + const result = await fetchQuotaForProvider('deepseek'); + + 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({ + is_available: true, + balance_infos: [{ currency: 'USD', total_balance: '0.00', granted_balance: '0.00', topped_up_balance: '0.00' }], + }))); + + const result = await fetchQuotaForProvider('deepseek'); + + assert.equal(result.ok, true); + assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$0.00'); + }); +}); diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index d3de2e8d..b275f593 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -124,6 +124,16 @@ type CrofPayload = { credits?: number | string; }; +type DeepseekPayload = { + is_available?: boolean; + balance_infos?: Array<{ + currency?: string; + total_balance?: number | string; + granted_balance?: number | string; + topped_up_balance?: number | string; + }>; +}; + type NeuralwattPayload = { balance?: { credits_remaining_usd?: number | string; @@ -492,6 +502,11 @@ export const listConfiguredQuotaProviders = () => { configured.add('neuralwatt'); } + const deepseekAuth = normalizeAuthEntry(getAuthEntry(auth, ['deepseek'])); + if (deepseekAuth && ((deepseekAuth as Record).key || (deepseekAuth as Record).token)) { + configured.add('deepseek'); + } + return Array.from(configured); }; @@ -2191,6 +2206,101 @@ const fetchCrofQuota = async (): Promise => { } }; +const DEEPSEEK_QUOTA_URL = 'https://api.deepseek.com/user/balance'; + +const fetchDeepseekQuota = async (): Promise => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['deepseek'])) as Record | null; + const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); + + if (!apiKey) { + return buildResult({ + providerId: 'deepseek', + providerName: 'DeepSeek', + ok: false, + configured: false, + error: 'Not configured', + }); + } + + const timeoutSignal = AbortSignal.timeout(15_000); + + try { + const response = await fetch(DEEPSEEK_QUOTA_URL, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Accept-Encoding': 'identity', + }, + signal: timeoutSignal, + }); + + if (!response.ok) { + return buildResult({ + providerId: 'deepseek', + providerName: 'DeepSeek', + ok: false, + configured: true, + error: response.status === 401 || response.status === 403 + ? 'Session expired — please re-authenticate with DeepSeek' + : `API error: ${response.status}`, + }); + } + + const payload = await response.json() as DeepseekPayload; + const balanceInfos = Array.isArray(payload?.balance_infos) ? payload.balance_infos : []; + const balanceInfo = balanceInfos.find((info) => info?.currency === 'USD') + ?? balanceInfos.find((info) => info?.currency === 'CNY') + ?? null; + const rawBalance = balanceInfo?.total_balance; + const totalBalance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== '')) + ? toNumber(rawBalance) + : null; + + if (totalBalance === null) { + return buildResult({ + providerId: 'deepseek', + providerName: 'DeepSeek', + ok: false, + configured: true, + error: 'No quota data in response', + }); + } + + const symbol = balanceInfo?.currency === 'CNY' ? '¥' : '$'; + const windows: Record = { + credits_balance: toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: `${symbol}${formatMoney(totalBalance)}`, + }), + }; + + return buildResult({ + providerId: 'deepseek', + providerName: 'DeepSeek', + 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: 'deepseek', + providerName: 'DeepSeek', + 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 => { switch (providerId) { case 'claude': @@ -2234,6 +2344,8 @@ export const fetchQuotaForProvider = 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(DEEPSEEK_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 DeepSeek' + : `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const balanceInfos = Array.isArray(payload?.balance_infos) ? payload.balance_infos : []; + const balanceInfo = balanceInfos.find((info) => info?.currency === 'USD') + ?? balanceInfos.find((info) => info?.currency === 'CNY') + ?? null; + const rawBalance = balanceInfo?.total_balance; + const totalBalance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== '')) + ? toNumber(rawBalance) + : null; + + if (totalBalance === null) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: 'No quota data in response' + }); + } + + const isCny = balanceInfo?.currency === 'CNY'; + const symbol = isCny ? '¥' : '$'; + const valueLabel = `${symbol}${formatMoney(totalBalance)}`; + + const windows = { + credits_balance: 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') + }); + } +}; diff --git a/packages/web/server/lib/quota/providers/deepseek.test.js b/packages/web/server/lib/quota/providers/deepseek.test.js new file mode 100644 index 00000000..d92728c5 --- /dev/null +++ b/packages/web/server/lib/quota/providers/deepseek.test.js @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../opencode/auth.js', () => ({ + readAuthFile: () => ({ deepseek: { key: 'test-token' } }), +})); + +import { fetchQuota } from './deepseek.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const mockResponse = (body, init = {}) => ({ + ok: true, + status: 200, + json: async () => body, + ...init, +}); + +// Documented payload shape from https://api.deepseek.com/user/balance +const DOCUMENTED_PAYLOAD = { + is_available: true, + balance_infos: [ + { + currency: 'USD', + total_balance: '7.54', + granted_balance: '0.00', + topped_up_balance: '7.54' + } + ] +}; + +describe('DeepSeek quota provider', () => { + it('builds credits_balance window from documented USD payload (string balance)', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(DOCUMENTED_PAYLOAD))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.providerId).toBe('deepseek'); + + const window = result.usage.windows.credits_balance; + expect(window).toBeDefined(); + expect(window.valueLabel).toBe('$7.54'); + expect(window.usedPercent).toBeNull(); + expect(window.windowSeconds).toBeNull(); + expect(window.resetAt).toBeNull(); + }); + + it('falls back to CNY entry when no USD entry is present', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ + is_available: true, + balance_infos: [ + { currency: 'CNY', total_balance: '100.00', granted_balance: '0.00', topped_up_balance: '100.00' } + ] + }))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.usage.windows.credits_balance.valueLabel).toBe('¥100.00'); + }); + + it('prefers the USD entry when both USD and CNY are present', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ + is_available: true, + balance_infos: [ + { currency: 'CNY', total_balance: '100.00', granted_balance: '0.00', topped_up_balance: '100.00' }, + { currency: 'USD', total_balance: '3.55', granted_balance: '0.00', topped_up_balance: '3.55' } + ] + }))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.usage.windows.credits_balance.valueLabel).toBe('$3.55'); + }); + + it('tolerates a numeric total_balance', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ + is_available: true, + balance_infos: [{ currency: 'USD', total_balance: 12.5, granted_balance: 0, topped_up_balance: 12.5 }] + }))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.usage.windows.credits_balance.valueLabel).toBe('$12.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.error).toBe('Session expired — please re-authenticate with DeepSeek'); + }); + + 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 DeepSeek'); + }); + + 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 balance', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ + is_available: true, + balance_infos: [{ currency: 'USD', total_balance: '', granted_balance: '0.00', topped_up_balance: '0.00' }] + }))); + + 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({ + is_available: true, + balance_infos: [{ currency: 'USD', total_balance: '0.00', granted_balance: '0.00', topped_up_balance: '0.00' }] + }))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.usage.windows.credits_balance.valueLabel).toBe('$0.00'); + }); +}); diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index 17bb6a9c..3d37e889 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -12,6 +12,7 @@ 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 deepseek from './deepseek.js'; import * as google from './google/index.js'; import * as kimi from './kimi.js'; import * as nanogpt from './nanogpt.js'; @@ -51,6 +52,12 @@ const registry = { isConfigured: cursor.isConfigured, fetchQuota: cursor.fetchQuota }, + deepseek: { + providerId: deepseek.providerId, + providerName: deepseek.providerName, + isConfigured: deepseek.isConfigured, + fetchQuota: deepseek.fetchQuota + }, google: { providerId: google.providerId, providerName: google.providerName, @@ -184,6 +191,7 @@ export const fetchOpenaiQuota = openai.fetchQuota; export const fetchGoogleQuota = google.fetchGoogleQuota; export const fetchCodexQuota = codex.fetchQuota; export const fetchCursorQuota = cursor.fetchQuota; +export const fetchDeepseekQuota = deepseek.fetchQuota; export const fetchCopilotQuota = copilot.fetchQuota; export const fetchCopilotAddonQuota = copilot.fetchQuotaAddon; export const fetchKimiQuota = kimi.fetchQuota;