diff --git a/packages/ui/src/assets/provider-logos/cline.svg b/packages/ui/src/assets/provider-logos/cline.svg new file mode 100644 index 00000000..ebda2239 --- /dev/null +++ b/packages/ui/src/assets/provider-logos/cline.svg @@ -0,0 +1,4 @@ + + Cline + + \ No newline at end of file diff --git a/packages/ui/src/hooks/useProviderLogo.ts b/packages/ui/src/hooks/useProviderLogo.ts index 80ded66d..c2342a3f 100644 --- a/packages/ui/src/hooks/useProviderLogo.ts +++ b/packages/ui/src/hooks/useProviderLogo.ts @@ -20,6 +20,7 @@ const LOGO_ALIAS = new Map([ ['codex', 'openai'], ['chatgpt', 'openai'], ['claude', 'anthropic'], + ['cline-pass', 'cline'], ['gemini', 'google'], ['evroc-ai', 'evroc'], ['evrocai', 'evroc'], diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index 5616135d..c626256b 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -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' }, diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index aa55d52e..c05a6dd9 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -3,6 +3,7 @@ export type QuotaProviderId = | 'codex' | 'cursor' | 'claude' + | 'cline-pass' | 'github-copilot' | 'github-copilot-addon' | 'google' diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index 0ec87390..90dc7af6 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -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; diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 90951d93..045c9b9e 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -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).key || (neuralwattAuth as Record).token)) { configured.add('neuralwatt'); @@ -2745,6 +2755,118 @@ const fetchCrofQuota = async (): Promise => { } }; +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([ + ['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; +}; + +export const fetchClinePassQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch }: ClineQuotaDependencies = {}): Promise => { + 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 = {}; + 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 => { @@ -3068,6 +3190,8 @@ const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise { + 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') + }); + } +}; diff --git a/packages/web/server/lib/quota/providers/cline-pass.test.js b/packages/web/server/lib/quota/providers/cline-pass.test.js new file mode 100644 index 00000000..d9551219 --- /dev/null +++ b/packages/web/server/lib/quota/providers/cline-pass.test.js @@ -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'); + }); +}); diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index c9ef0050..1a330043 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -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,