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..4065d351 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' }, @@ -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; diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 90951d93..66571625 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -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).key || (neuralwattAuth as Record).token)) { configured.add('neuralwatt'); @@ -2745,6 +2765,111 @@ 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 }], +]); + +const fetchClinePassQuota = async (): Promise => { + 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 = {}; + 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 => { @@ -3068,6 +3193,8 @@ const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise { + 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') + }); + } +}; 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..08c2397c --- /dev/null +++ b/packages/web/server/lib/quota/providers/cline-pass.test.js @@ -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'); + }); +}); 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,