feat: add DeepSeek quota provider

This commit is contained in:
Howon Lee
2026-08-03 20:46:32 +09:00
parent 0d6ecbfc12
commit 2dd3bbfe8e
9 changed files with 457 additions and 0 deletions
@@ -20,6 +20,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
| `codex` | Codex | `providers/codex.js` | `openai`, `codex`, `chatgpt` |
| `cursor` | Cursor | `providers/cursor.js` | Environment/token files, OpenChamber-managed credentials, or explicit one-time Cursor import |
| `crof` | CrofAI | `providers/crof.js` | `crof` (API key under `key` or `token`) |
| `deepseek` | DeepSeek | `providers/deepseek.js` | `deepseek` (API key under `key` or `token`) |
| `google` | Google | `providers/google/index.js` | `google`, `google.oauth`, Antigravity accounts file |
| `github-copilot` | GitHub Copilot | `providers/copilot.js` | `github-copilot`, `copilot` |
| `github-copilot-addon` | GitHub Copilot Add-on | `providers/copilot.js` | `github-copilot`, `copilot` |
+1
View File
@@ -13,6 +13,7 @@ export {
fetchGoogleQuota,
fetchCodexQuota,
fetchCursorQuota,
fetchDeepseekQuota,
fetchCopilotQuota,
fetchCopilotAddonQuota,
fetchKimiQuota,
@@ -0,0 +1,116 @@
import { readAuthFile } from '../../opencode/auth.js';
import {
getAuthEntry,
normalizeAuthEntry,
buildResult,
toUsageWindow,
toNumber,
formatMoney
} from '../utils/index.js';
export const providerId = 'deepseek';
export const providerName = 'DeepSeek';
const aliases = ['deepseek'];
const DEEPSEEK_QUOTA_URL = 'https://api.deepseek.com/user/balance';
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(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')
});
}
};
@@ -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');
});
});
@@ -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;