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
@@ -23,6 +23,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
| Provider ID | Display name | Module | Auth aliases/keys |
| --- | --- | --- | --- |
| `claude` | Claude | `providers/claude/` | Claude Code Keychain entry, Claude Code credentials file, OpenCode `auth.json` (`anthropic`, `claude`), `CLAUDE_CODE_OAUTH_TOKEN` |
| `cline-pass` | ClinePass | `providers/cline-pass.js` | `cline-pass` (API key under `key` or `token`) |
| `codex` | Codex | `providers/codex.js` | `openai`, `codex`, `chatgpt` |
| `command-code` | Command Code | `providers/command-code.js` | `command-code` OAuth/API credential in OpenCode `auth.json`, or `COMMAND_CODE_API_KEY` |
| `cursor` | Cursor | `providers/cursor.js` | Environment/token files, OpenChamber-managed credentials, or explicit one-time Cursor import |
@@ -0,0 +1,121 @@
import { readAuthFile } from '../../opencode/auth.js';
import {
getAuthEntry,
normalizeAuthEntry,
asObject,
buildResult,
toUsageWindow,
toNumber,
toTimestamp
} from '../utils/index.js';
export const providerId = 'cline-pass';
export const providerName = 'ClinePass';
const aliases = ['cline-pass'];
const CLINE_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 WINDOW_KINDS = {
five_hour: { key: '5h', windowSeconds: 5 * 60 * 60 },
weekly: { key: 'weekly', windowSeconds: 7 * 24 * 60 * 60 },
monthly: { key: 'monthly', windowSeconds: null }
};
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(CLINE_USAGE_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
? 'Session expired — please re-authenticate with ClinePass'
: `API error: ${response.status}`
});
}
const payload = await response.json();
const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : [];
const windows = {};
for (const item of limits) {
const limit = asObject(item);
if (!limit) continue;
const kind = WINDOW_KINDS[limit.type];
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,
providerName,
ok: false,
configured: true,
error: 'No quota data in response'
});
}
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,127 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../opencode/auth.js', () => ({
readAuthFile: () => ({ 'cline-pass': { key: 'test-token' } }),
}));
import { fetchQuota } from './cline-pass.js';
afterEach(() => {
vi.unstubAllGlobals();
});
const mockResponse = (body, init = {}) => ({
ok: true,
status: 200,
json: async () => body,
...init,
});
// 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,
};
describe('ClinePass quota provider', () => {
it('maps documented limit kinds to 5h/weekly/monthly windows', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(documentedPayload)));
const result = await fetchQuota();
expect(result.ok).toBe(true);
expect(result.providerId).toBe('cline-pass');
expect(Object.keys(result.usage.windows)).toEqual(['5h', 'weekly', 'monthly']);
expect(result.usage.windows['5h'].usedPercent).toBe(43);
expect(result.usage.windows['5h'].remainingPercent).toBe(57);
expect(result.usage.windows['5h'].windowSeconds).toBe(18_000);
expect(result.usage.windows['5h'].resetAt).toBe(Date.parse('2026-09-08T17:00:44.598174595Z'));
expect(result.usage.windows.weekly.usedPercent).toBe(17);
expect(result.usage.windows.weekly.windowSeconds).toBe(604_800);
expect(result.usage.windows.monthly.usedPercent).toBe(8);
expect(result.usage.windows.monthly.windowSeconds).toBeNull();
});
it('ignores unknown limit types', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
mockResponse({ data: { limits: [{ type: 'quarterly', percentUsed: 5, resetsAt: '2026-10-01T00:00:00Z' }] } }),
));
const result = await fetchQuota();
expect(result.ok).toBe(false);
expect(result.usage).toBeNull();
expect(result.error).toBe('No quota data in response');
});
it('parses numeric-string percentUsed', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
mockResponse({ data: { limits: [{ type: 'weekly', percentUsed: '51' }] } }),
));
const result = await fetchQuota();
expect(result.ok).toBe(true);
expect(result.usage.windows.weekly.usedPercent).toBe(51);
});
for (const payload of [{ data: { limits: [] } }, { data: {} }, {}]) {
it(`rejects ${JSON.stringify(payload)} without quota data`, async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(payload)));
const result = await fetchQuota();
expect(result.ok).toBe(false);
expect(result.configured).toBe(true);
expect(result.usage).toBeNull();
expect(result.error).toBe('No quota data in response');
});
}
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.configured).toBe(true);
expect(result.error).toBe('Session expired — please re-authenticate with ClinePass');
});
it('surfaces non-401 API errors with status', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 503,
json: async () => ({}),
}));
const result = await fetchQuota();
expect(result.ok).toBe(false);
expect(result.error).toBe('API error: 503');
});
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');
});
});
@@ -8,6 +8,7 @@
import { buildResult } from '../utils/index.js';
import * as claude from './claude/index.js';
import * as clinePass from './cline-pass.js';
import * as codex from './codex.js';
import * as copilot from './copilot.js';
import * as crof from './crof.js';
@@ -37,6 +38,12 @@ const registry = {
isConfigured: claude.isConfigured,
fetchQuota: claude.fetchQuota
},
'cline-pass': {
providerId: clinePass.providerId,
providerName: clinePass.providerName,
isConfigured: clinePass.isConfigured,
fetchQuota: clinePass.fetchQuota
},
codex: {
providerId: codex.providerId,
providerName: codex.providerName,