feat(usage): add ClinePass quota provider

This commit is contained in:
Aleksei Ilin
2026-09-08 17:02:56 +02:00
parent 9ef235a6b4
commit c9bcae5b04
10 changed files with 452 additions and 0 deletions
@@ -15,6 +15,7 @@ const ORIGINAL_FS = { ...fs };
const AUTH = JSON.stringify({
openai: { access: 'test-token' },
crof: { key: 'test-token' },
'cline-pass': { key: 'test-token' },
neuralwatt: { key: 'test-token' },
'opencode-go': { key: 'test-token' },
openrouter: { key: 'test-token' },
@@ -336,6 +337,67 @@ describe('Crof quota provider (VS Code parity)', () => {
});
});
describe('ClinePass quota provider (VS Code parity)', () => {
// Live-verified response shape of
// GET https://api.cline.bot/api/v1/users/me/plan/usage-limits
const documentedPayload = {
data: {
limits: [
{ type: 'five_hour', percentUsed: 43, resetsAt: '2026-09-08T17:00:44.598174595Z' },
{ type: 'weekly', percentUsed: 17, resetsAt: '2026-09-13T17:00:44.598174595Z' },
{ type: 'monthly', percentUsed: 8, resetsAt: '2026-10-01T00:00:00Z' },
],
},
success: true,
};
test('maps documented limit kinds to 5h/weekly/monthly windows', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse(documentedPayload)));
const result = await fetchQuotaForProvider('cline-pass');
assert.equal(result.ok, true);
assert.equal(result.providerId, 'cline-pass');
assert.equal(result.usage!.windows['5h']!.usedPercent, 43);
assert.equal(result.usage!.windows['5h']!.windowSeconds, 18_000);
assert.equal(result.usage!.windows['5h']!.resetAt, Date.parse('2026-09-08T17:00:44.598174595Z'));
assert.equal(result.usage!.windows.weekly!.usedPercent, 17);
assert.equal(result.usage!.windows.weekly!.windowSeconds, 604_800);
assert.equal(result.usage!.windows.monthly!.usedPercent, 8);
assert.equal(result.usage!.windows.monthly!.windowSeconds, null);
});
test('ignores unknown limit types and rejects responses without quota data', async () => {
stubFetchReturning(() => Promise.resolve(mockResponse({ data: { limits: [{ type: 'quarterly', percentUsed: 5 }] } })));
const result = await fetchQuotaForProvider('cline-pass');
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.usage, null);
assert.equal(result.error, 'No quota data in response');
});
test('maps 401 to session-expired with ClinePass branding', async () => {
stubFetchFailing(async () => ({}), { ok: false, status: 401 });
const result = await fetchQuotaForProvider('cline-pass');
assert.equal(result.ok, false);
assert.equal(result.configured, true);
assert.equal(result.error, 'Session expired — please re-authenticate with ClinePass');
});
test('reports invalid-response on JSON parse failure', async () => {
stubFetchFailing(async () => { throw new SyntaxError('Unexpected token'); }, { ok: true, status: 200 });
const result = await fetchQuotaForProvider('cline-pass');
assert.equal(result.ok, false);
assert.equal(result.error, 'Invalid response from provider');
});
});
describe('Codex quota provider (VS Code parity)', () => {
test('coalesces concurrent refreshes for the same provider', async () => {
let resolveResponse: ((response: Response) => void) | undefined;
+127
View File
@@ -146,6 +146,21 @@ type CrofPayload = {
credits?: number | string;
};
type ClinePayload = {
data?: {
limits?: Array<{
type?: string;
percentUsed?: number | string;
resetsAt?: string;
}>;
};
};
type ClineWindowKind = {
key: string;
windowSeconds: number | null;
};
type DeepseekPayload = {
is_available?: boolean;
balance_infos?: Array<{
@@ -844,6 +859,11 @@ export const listConfiguredQuotaProviders = () => {
configured.add('crof');
}
const clineAuth = normalizeAuthEntry(getAuthEntry(auth, ['cline-pass']));
if (clineAuth && (asNonEmptyString(clineAuth.key) || asNonEmptyString(clineAuth.token))) {
configured.add('cline-pass');
}
const neuralwattAuth = normalizeAuthEntry(getAuthEntry(auth, ['neuralwatt']));
if (neuralwattAuth && ((neuralwattAuth as Record<string, unknown>).key || (neuralwattAuth as Record<string, unknown>).token)) {
configured.add('neuralwatt');
@@ -2745,6 +2765,111 @@ const fetchCrofQuota = async (): Promise<ProviderResult> => {
}
};
const CLINE_PASS_USAGE_URL = 'https://api.cline.bot/api/v1/users/me/plan/usage-limits';
// Cline reports a rolling five-hour window, a rolling weekly window, and a
// calendar-month limit. Each window carries its duration so consumers can rank
// limits by how soon they run out; the calendar month has no fixed duration.
const CLINE_WINDOW_KINDS = new Map<string, ClineWindowKind>([
['five_hour', { key: '5h', windowSeconds: 5 * 60 * 60 }],
['weekly', { key: 'weekly', windowSeconds: 7 * 24 * 60 * 60 }],
['monthly', { key: 'monthly', windowSeconds: null }],
]);
const fetchClinePassQuota = async (): Promise<ProviderResult> => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, ['cline-pass']));
const apiKey = asNonEmptyString(entry?.key) ?? asNonEmptyString(entry?.token);
if (!apiKey) {
return buildResult({
providerId: 'cline-pass',
providerName: 'ClinePass',
ok: false,
configured: false,
error: 'Not configured',
});
}
const timeoutSignal = AbortSignal.timeout(15_000);
try {
const response = await fetch(CLINE_PASS_USAGE_URL, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Accept-Encoding': 'identity',
},
signal: timeoutSignal,
});
if (!response.ok) {
return buildResult({
providerId: 'cline-pass',
providerName: 'ClinePass',
ok: false,
configured: true,
error: response.status === 401
? 'Session expired — please re-authenticate with ClinePass'
: `API error: ${response.status}`,
});
}
// SAFETY: the Cline usage endpoint returns this documented JSON shape;
// every field is optional and validated before use below.
const payload = await response.json() as ClinePayload;
const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : [];
const windows: Record<string, UsageWindow> = {};
for (const item of limits) {
const limit = asObject(item);
if (!limit) continue;
const limitType = asNonEmptyString(limit.type);
const kind = limitType === null ? undefined : CLINE_WINDOW_KINDS.get(limitType);
if (!kind) continue;
const usedPercent = toNumber(limit.percentUsed);
if (usedPercent === null) continue;
windows[kind.key] = toUsageWindow({
usedPercent,
windowSeconds: kind.windowSeconds,
resetAt: toTimestamp(limit.resetsAt),
});
}
if (Object.keys(windows).length === 0) {
return buildResult({
providerId: 'cline-pass',
providerName: 'ClinePass',
ok: false,
configured: true,
error: 'No quota data in response',
});
}
return buildResult({
providerId: 'cline-pass',
providerName: 'ClinePass',
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: 'cline-pass',
providerName: 'ClinePass',
ok: false,
configured: true,
error: isTimeout
? 'Request timed out'
: isParseError
? 'Invalid response from provider'
: (error instanceof Error ? error.message : 'Request failed'),
});
}
};
const DEEPSEEK_QUOTA_URL = 'https://api.deepseek.com/user/balance';
const fetchDeepseekQuota = async (): Promise<ProviderResult> => {
@@ -3068,6 +3193,8 @@ const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise<Pro
return fetchCursorQuota();
case 'crof':
return fetchCrofQuota();
case 'cline-pass':
return fetchClinePassQuota();
case 'deepseek':
return fetchDeepseekQuota();
case 'hyper':