feat(usage): integrate ClinePass quota provider (#3431)
Add ClinePass across server and VS Code quota paths. Reject malformed windows, preserve valid sibling limits, align credential fallback, and report timeout failures accurately. Validated 30 server quota tests, 129 VS Code quota tests and VS Code type-check. Oxlint findings are confined to pre-existing VS Code code.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="24" height="24">
|
||||
<title>Cline</title>
|
||||
<path d="m39.06 22.594-2.403-4.826V14.99c0-4.606-3.697-8.336-8.257-8.336h-4.107c.297-.61.46-1.297.46-2.021 0-2.56-2.06-4.632-4.605-4.632s-4.606 2.072-4.606 4.632c0 .724.163 1.41.46 2.02h-4.107c-4.56 0-8.256 3.731-8.256 8.337v2.78l-2.454 4.81a1.7 1.7 0 0 0 0 1.545l2.454 4.758v2.78c0 4.605 3.697 8.336 8.256 8.336H28.4c4.56 0 8.257-3.73 8.257-8.337v-2.779l2.399-4.774a1.7 1.7 0 0 0 .004-1.516m-21.424 3.932c0 2.093-1.688 3.79-3.769 3.79-2.08 0-3.768-1.697-3.768-3.79V19.79c0-2.093 1.688-3.79 3.768-3.79s3.769 1.697 3.769 3.79zm12.142 0c0 2.093-1.688 3.79-3.769 3.79-2.08 0-3.768-1.697-3.768-3.79V19.79c0-2.093 1.688-3.79 3.768-3.79s3.769 1.697 3.769 3.79z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 773 B |
@@ -20,6 +20,7 @@ const LOGO_ALIAS = new Map<string, string>([
|
||||
['codex', 'openai'],
|
||||
['chatgpt', 'openai'],
|
||||
['claude', 'anthropic'],
|
||||
['cline-pass', 'cline'],
|
||||
['gemini', 'google'],
|
||||
['evroc-ai', 'evroc'],
|
||||
['evrocai', 'evroc'],
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface QuotaProviderMeta {
|
||||
|
||||
export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
|
||||
{ id: 'claude', name: 'Claude' },
|
||||
{ id: 'cline-pass', name: 'ClinePass' },
|
||||
{ id: 'codex', name: 'Codex' },
|
||||
{ id: 'cursor', name: 'Cursor' },
|
||||
{ id: 'github-copilot', name: 'GitHub Copilot' },
|
||||
|
||||
@@ -3,6 +3,7 @@ export type QuotaProviderId =
|
||||
| 'codex'
|
||||
| 'cursor'
|
||||
| 'claude'
|
||||
| 'cline-pass'
|
||||
| 'github-copilot'
|
||||
| 'github-copilot-addon'
|
||||
| 'google'
|
||||
|
||||
@@ -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' },
|
||||
@@ -27,7 +28,7 @@ const AUTH = JSON.stringify({
|
||||
((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true;
|
||||
((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH;
|
||||
|
||||
import { fetchHyperQuota, fetchOllamaCloudQuota, fetchQuotaForProvider } from './quotaProviders';
|
||||
import { fetchClinePassQuota, fetchHyperQuota, fetchOllamaCloudQuota, fetchQuotaForProvider } from './quotaProviders';
|
||||
import { validateCredential } from './quotaCredentials';
|
||||
|
||||
type MockResponseInit = { ok?: boolean; status?: number };
|
||||
@@ -336,6 +337,121 @@ 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');
|
||||
});
|
||||
|
||||
const readAuth = () => ({ 'cline-pass': { key: 'test-token' } });
|
||||
|
||||
for (const limit of [
|
||||
{ type: 'constructor', percentUsed: 5 }, { type: 'toString', percentUsed: 5 },
|
||||
{ type: '__proto__', percentUsed: 5 }, { type: 'weekly', percentUsed: '' },
|
||||
{ type: 'weekly', percentUsed: ' ' }, { type: 'weekly', percentUsed: true },
|
||||
{ type: 'weekly', percentUsed: null }, null,
|
||||
]) {
|
||||
test(`skips malformed windows independently: ${JSON.stringify(limit)}`, async () => {
|
||||
const invalid = await fetchClinePassQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [limit] } }) });
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.equal(invalid.usage, null);
|
||||
const mixed = await fetchClinePassQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [limit, { type: 'monthly', percentUsed: 8 }] } }) });
|
||||
assert.equal(mixed.ok, true);
|
||||
assert.ok(mixed.usage);
|
||||
assert.deepEqual(Object.keys(mixed.usage.windows), ['monthly']);
|
||||
});
|
||||
}
|
||||
|
||||
for (const percentUsed of [0, '0', '51']) {
|
||||
test(`accepts percentage ${JSON.stringify(percentUsed)}`, async () => {
|
||||
const result = await fetchClinePassQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [{ type: 'weekly', percentUsed }] } }) });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.usage?.windows.weekly?.usedPercent, Number(percentUsed));
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of [{ key: '' }, { key: ' ' }, { key: 42 }, {}]) {
|
||||
test(`falls back to a usable token: ${JSON.stringify(entry)}`, async () => {
|
||||
const result = await fetchClinePassQuota({
|
||||
readAuth: () => ({ 'cline-pass': { ...entry, token: 'test-token' } }),
|
||||
fetchImpl: async (_url, options) => {
|
||||
assert.equal(new Headers(options.headers).get('Authorization'), 'Bearer test-token');
|
||||
return Response.json(documentedPayload);
|
||||
},
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
}
|
||||
|
||||
test('does not request usage without usable credentials', async () => {
|
||||
const result = await fetchClinePassQuota({ readAuth: () => ({ 'cline-pass': { key: 42 } }), fetchImpl: async () => { throw new Error('Unexpected fetch'); } });
|
||||
assert.equal(result.configured, false);
|
||||
assert.equal(result.error, 'Not configured');
|
||||
});
|
||||
|
||||
test('recognizes the timeout exception from AbortSignal.timeout', async () => {
|
||||
const result = await fetchClinePassQuota({ readAuth, fetchImpl: async () => { throw new DOMException('Timed out', 'TimeoutError'); } });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.configured, true);
|
||||
assert.equal(result.usage, null);
|
||||
assert.equal(result.error, 'Request timed out');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Codex quota provider (VS Code parity)', () => {
|
||||
test('coalesces concurrent refreshes for the same provider', async () => {
|
||||
let resolveResponse: ((response: Response) => void) | undefined;
|
||||
|
||||
@@ -146,6 +146,11 @@ type CrofPayload = {
|
||||
credits?: number | string;
|
||||
};
|
||||
|
||||
type ClineWindowKind = {
|
||||
key: string;
|
||||
windowSeconds: number | null;
|
||||
};
|
||||
|
||||
type DeepseekPayload = {
|
||||
is_available?: boolean;
|
||||
balance_infos?: Array<{
|
||||
@@ -844,6 +849,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 +2755,118 @@ 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 }],
|
||||
]);
|
||||
|
||||
type ClineQuotaDependencies = {
|
||||
readAuth?: () => AuthFile;
|
||||
fetchImpl?: (url: string, options: RequestInit) => Promise<Response>;
|
||||
};
|
||||
|
||||
export const fetchClinePassQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch }: ClineQuotaDependencies = {}): Promise<ProviderResult> => {
|
||||
const auth = readAuth();
|
||||
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 fetchImpl(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}`,
|
||||
});
|
||||
}
|
||||
|
||||
const payload = asObject(await response.json());
|
||||
const data = asObject(payload?.data);
|
||||
const limits = Array.isArray(data?.limits) ? 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(asNonEmptyString(limit.percentUsed)
|
||||
?? (Number.isFinite(limit.percentUsed) ? limit.percentUsed : null));
|
||||
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 === 'TimeoutError' || (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 +3190,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':
|
||||
|
||||
@@ -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 |
|
||||
@@ -96,6 +97,16 @@ In 2025/2026 MiniMax rebranded "Coding Plan" to "Token Plan" alongside the M3 mo
|
||||
- **model_remains array**: Now contains entries for multiple model categories (chat, speech, video, image). The provider selects the chat-model entry by matching `MiniMax-M*`, then `general`/`chat`/`text` by name, then any entry with a remaining percent.
|
||||
- **Window status**: The `current_interval_status` and `current_weekly_status` fields indicate whether a window is active. Status `3` means the window is not applicable for the current plan tier (e.g. legacy plans without weekly limits). The provider omits inactive windows.
|
||||
|
||||
## ClinePass quota semantics
|
||||
|
||||
ClinePass reads `data.limits` from its usage-limits endpoint. Web/Electron and
|
||||
VS Code accept only known window types with finite numeric or non-empty numeric
|
||||
string percentages. Invalid windows are skipped independently; no usable windows
|
||||
is a failed refresh, not zero usage. Both implementations choose a non-empty
|
||||
`key`, then `token`, and expose auth/fetch dependencies for focused tests.
|
||||
Saved UI provider-visibility lists remain authoritative; installations without a
|
||||
saved list include ClinePass through the provider registry.
|
||||
|
||||
## Charm Hyper balance semantics
|
||||
|
||||
`GET https://hyper.charm.land/v1/credits` returns a team's current Hypercredit balance, not a percentage or reset timestamp. The [Hyper FAQ](https://hyper.charm.land/faq) defines one Hypercredit as $0.05. Both runtimes expose `credits_balance` in dollars and `credits` as a numeric label under the UI's localized window title. Keep English unit text out of that numeric label.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { readAuthFile } from '../../opencode/auth.js';
|
||||
import {
|
||||
getAuthEntry,
|
||||
normalizeAuthEntry,
|
||||
asObject,
|
||||
asNonEmptyString,
|
||||
buildResult,
|
||||
toUsageWindow,
|
||||
toNumber,
|
||||
toTimestamp
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const providerId = 'cline-pass';
|
||||
export const providerName = 'ClinePass';
|
||||
export 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 = new Map([
|
||||
['five_hour', { key: '5h', windowSeconds: 5 * 60 * 60 }],
|
||||
['weekly', { key: 'weekly', windowSeconds: 7 * 24 * 60 * 60 }],
|
||||
['monthly', { key: 'monthly', windowSeconds: null }]
|
||||
]);
|
||||
|
||||
const getApiKey = (auth) => {
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
return asNonEmptyString(entry?.key) ?? asNonEmptyString(entry?.token);
|
||||
};
|
||||
|
||||
export const isConfigured = (auth = readAuthFile()) => Boolean(getApiKey(auth));
|
||||
|
||||
export const fetchQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch } = {}) => {
|
||||
const apiKey = getApiKey(readAuth());
|
||||
|
||||
if (!apiKey) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: false,
|
||||
error: 'Not configured'
|
||||
});
|
||||
}
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(15_000);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(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 = asObject(await response.json());
|
||||
const data = asObject(payload?.data);
|
||||
const limits = Array.isArray(data?.limits) ? data.limits : [];
|
||||
|
||||
const windows = {};
|
||||
for (const item of limits) {
|
||||
const limit = asObject(item);
|
||||
if (!limit) continue;
|
||||
const kind = WINDOW_KINDS.get(asNonEmptyString(limit.type));
|
||||
if (!kind) continue;
|
||||
const usedPercent = toNumber(asNonEmptyString(limit.percentUsed)
|
||||
?? (Number.isFinite(limit.percentUsed) ? limit.percentUsed : null));
|
||||
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 === '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')
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fetchQuota, isConfigured } from './cline-pass.js';
|
||||
|
||||
const readAuth = () => ({ 'cline-pass': { key: 'test-token' } });
|
||||
|
||||
// Response shape verified by the contributor against the live ClinePass API.
|
||||
const documentedPayload = {
|
||||
data: { limits: [
|
||||
{ type: 'five_hour', percentUsed: 43, resetsAt: '2026-09-08T17:00:44.598174595Z' },
|
||||
{ type: 'weekly', percentUsed: 17 },
|
||||
{ type: 'monthly', percentUsed: 8 },
|
||||
] },
|
||||
success: true,
|
||||
};
|
||||
|
||||
describe('ClinePass quota provider', () => {
|
||||
it('maps the documented windows and sends credentials only in headers', async () => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async (url, options) => {
|
||||
expect(url).toBe('https://api.cline.bot/api/v1/users/me/plan/usage-limits');
|
||||
expect(new Headers(options.headers).get('Authorization')).toBe('Bearer test-token');
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal);
|
||||
return Response.json(documentedPayload);
|
||||
} });
|
||||
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();
|
||||
expect(JSON.stringify(result)).not.toContain('test-token');
|
||||
});
|
||||
|
||||
it.each([0, '0', '51'])('accepts finite percentage %s', async (percentUsed) => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [{ type: 'weekly', percentUsed }] } }) });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.usage.windows.weekly.usedPercent).toBe(Number(percentUsed));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ type: 'constructor', percentUsed: 5 }, { type: 'toString', percentUsed: 5 },
|
||||
{ type: '__proto__', percentUsed: 5 }, { type: 'quarterly', percentUsed: 5 },
|
||||
{ type: 'weekly', percentUsed: '' }, { type: 'weekly', percentUsed: ' ' },
|
||||
{ type: 'weekly', percentUsed: null }, { type: 'weekly', percentUsed: true },
|
||||
{ type: 'weekly', percentUsed: 'NaN' }, null,
|
||||
])('ignores malformed windows without discarding valid siblings: %j', async (limit) => {
|
||||
const invalid = await fetchQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [limit] } }) });
|
||||
expect(invalid.ok).toBe(false);
|
||||
expect(invalid.configured).toBe(true);
|
||||
expect(invalid.usage).toBeNull();
|
||||
const mixed = await fetchQuota({ readAuth, fetchImpl: async () => Response.json({ data: { limits: [limit, { type: 'monthly', percentUsed: 8 }] } }) });
|
||||
expect(mixed.ok).toBe(true);
|
||||
expect(Object.keys(mixed.usage.windows)).toEqual(['monthly']);
|
||||
});
|
||||
|
||||
it.each([null, [], {}, { data: null }, { data: { limits: [] } }])('rejects empty or malformed payload %j', async (payload) => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => Response.json(payload) });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.usage).toBeNull();
|
||||
expect(result.error).toBe('No quota data in response');
|
||||
});
|
||||
|
||||
it.each([{ key: '' }, { key: ' ' }, { key: 42 }, {}])('uses a usable token when the key is malformed: %j', async (entry) => {
|
||||
const auth = { 'cline-pass': { ...entry, token: 'test-token' } };
|
||||
expect(isConfigured(auth)).toBe(true);
|
||||
const result = await fetchQuota({ readAuth: () => auth, fetchImpl: async (_url, options) => {
|
||||
expect(new Headers(options.headers).get('Authorization')).toBe('Bearer test-token');
|
||||
return Response.json(documentedPayload);
|
||||
} });
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it.each([{}, { 'cline-pass': { key: '' } }, { 'cline-pass': { key: 42 } }])('does not fetch without usable credentials: %j', async (auth) => {
|
||||
expect(isConfigured(auth)).toBe(false);
|
||||
const result = await fetchQuota({ readAuth: () => auth, fetchImpl: async () => { throw new Error('Unexpected fetch'); } });
|
||||
expect(result.configured).toBe(false);
|
||||
expect(result.error).toBe('Not configured');
|
||||
});
|
||||
|
||||
it.each([[401, 'Session expired — please re-authenticate with ClinePass'], [503, 'API error: 503']])('reports HTTP %s', async (status, error) => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response(null, { status }) });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.usage).toBeNull();
|
||||
expect(result.error).toBe(error);
|
||||
});
|
||||
|
||||
it('reports invalid JSON', async () => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response('{') });
|
||||
expect(result.error).toBe('Invalid response from provider');
|
||||
});
|
||||
|
||||
it('recognizes the timeout exception from AbortSignal.timeout', async () => {
|
||||
const result = await fetchQuota({ readAuth, fetchImpl: async () => { throw new DOMException('Timed out', 'TimeoutError'); } });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.configured).toBe(true);
|
||||
expect(result.usage).toBeNull();
|
||||
expect(result.error).toBe('Request timed out');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user