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.
126 lines
3.6 KiB
JavaScript
126 lines
3.6 KiB
JavaScript
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')
|
|
});
|
|
}
|
|
};
|