ClinePass limits, plan and period end in the Cline provider; icons for Cline, Command Code and API relays; card header: plan on the right, status under the name
This commit is contained in:
1 parent
900b3fd302
commit
dec7f5ddaa
8 files changed
+212
-40
No files matched your search
@@ -37,12 +37,25 @@ const SOURCES = {
|
||||
runway: 'lobehub:runway',
|
||||
poe: 'lobehub:poe',
|
||||
deepseek: 'lobehub:deepseek',
|
||||
'command-goat': 'lobehub:commandcode',
|
||||
cline: 'lobehub:cline',
|
||||
'opencode-go': 'lobehub:opencode',
|
||||
opencode: 'lobehub:opencode',
|
||||
claude: 'lobehub:claude',
|
||||
cursor: 'lobehub:cursor',
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks drawn for this app, for entries that are not a brand. `relay` stands for any API relay
|
||||
* (New API, Sub2API, One API...): two opposing arrows, i.e. requests forwarded both ways.
|
||||
*/
|
||||
const LOCAL = {
|
||||
relay: {
|
||||
viewBox: '0 0 24 24',
|
||||
paths: [{ d: 'M4 7h12V4l5 4-5 4V9H4zM20 15H8v-3l-5 4 5 4v-3h12z' }],
|
||||
},
|
||||
};
|
||||
|
||||
const urlFor = (ref) => {
|
||||
const [kind, slug] = ref.split(':');
|
||||
return kind === 'lobehub'
|
||||
@@ -89,6 +102,11 @@ for (const [id, ref] of Object.entries(SOURCES)) {
|
||||
console.log(`ok ${id.padEnd(14)} viewBox=${viewBox} paths=${paths.length}`);
|
||||
}
|
||||
|
||||
for (const [id, mark] of Object.entries(LOCAL)) {
|
||||
entries.push({ id, ...mark });
|
||||
console.log(`ok ${id.padEnd(14)} (drawn locally)`);
|
||||
}
|
||||
|
||||
const data = entries
|
||||
.map(
|
||||
({ id, viewBox, paths }) =>
|
||||
@@ -104,8 +122,9 @@ const file = `/**
|
||||
* Official provider marks, stored as structured path data so the component can render them with
|
||||
* <Svg>/<Path> and tint them with the theme text colour (see ui/provider-icon.tsx).
|
||||
*
|
||||
* Sources: @lobehub/icons-static-svg (AI brand set) and simple-icons (GitHub).
|
||||
* Brands remain the property of their owners; used here only to identify the provider.
|
||||
* Sources: @lobehub/icons-static-svg (AI brand set) and simple-icons (GitHub); the API relay mark
|
||||
* is drawn for this app. Brands remain the property of their owners; used here only to identify
|
||||
* the provider.
|
||||
*
|
||||
* Regenerate with scripts/fetch-provider-icons.mjs when a brand updates its mark.
|
||||
*/
|
||||
|
||||
+2
-2
@@ -316,8 +316,8 @@ export const zh: Record<string, string> = {
|
||||
'Cookie sign-in removed; existing accounts are read-only.':
|
||||
'已移除 Cookie 登录,已有账号仅可查看。',
|
||||
'No usage connection yet.': '暂不支持自动采集。',
|
||||
'Shows your Cline credits. Create an API key at app.cline.bot under Settings > API Keys.':
|
||||
'显示 Cline 额度余额。请在 app.cline.bot 的 Settings > API Keys 中创建 API Key。',
|
||||
'Shows your Cline credits and, with ClinePass, the 5-hour, weekly and monthly limits. Create an API key at app.cline.bot under Settings > API Keys.':
|
||||
'显示 Cline 额度余额;订阅了 ClinePass 时还会显示 5 小时、每周和每月用量。请在 app.cline.bot 的 Settings > API Keys 中创建 API Key。',
|
||||
// subscriptions, forecasts, reset notices, widget
|
||||
'{name}: plan ends or renews {date}': '{name}:套餐将于 {date} 到期或续费',
|
||||
'Within a day. Check your subscription if you do not plan to keep it.':
|
||||
|
||||
@@ -40,7 +40,45 @@ describe('Cline credits', () => {
|
||||
const s = await clinePlugin().fetchUsage('a', key, ctxWith(f.fetch));
|
||||
expect(s.plan).toBe('Team');
|
||||
expect(s.meters[0].kind).toEqual({ type: 'balance', value: 12, unit: 'USD' });
|
||||
expect(f.calls.map((c) => c.url)).toEqual([ME, orgBalance('o1')]);
|
||||
expect(f.calls.map((c) => c.url)).toContain(orgBalance('o1'));
|
||||
expect(f.calls.map((c) => c.url)).not.toContain(userBalance('u1'));
|
||||
});
|
||||
|
||||
it('adds the ClinePass 5-hour, weekly and monthly windows, the plan and its period end', async () => {
|
||||
const f = fakeFetch({
|
||||
[ME]: { json: { success: true, data: { id: 'u1', organizations: [] } } },
|
||||
[userBalance('u1')]: { json: { success: true, data: { balance: 0 } } },
|
||||
[`${CLINE_API}/api/v1/users/me/plan/usage-limits`]: {
|
||||
json: {
|
||||
success: true,
|
||||
data: {
|
||||
limits: [
|
||||
{ type: 'monthly', percentUsed: 12.5, resetsAt: '2026-11-01T00:00:00Z' },
|
||||
{ type: 'five_hour', percentUsed: 140, resetsAt: '2026-10-06T15:00:00Z' },
|
||||
{ type: 'weekly', percentUsed: 40, resetsAt: null },
|
||||
{ type: 'mystery', percentUsed: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
[`${CLINE_API}/api/v1/users/me/plan`]: {
|
||||
json: {
|
||||
success: true,
|
||||
data: { plan: { displayName: 'ClinePass' }, currentPeriodEnd: '2026-11-01T00:00:00Z' },
|
||||
},
|
||||
},
|
||||
});
|
||||
const s = await clinePlugin().fetchUsage('a', key, ctxWith(f.fetch));
|
||||
expect(s.plan).toBe('ClinePass');
|
||||
expect(s.renewsAt).toBe('2026-11-01T00:00:00.000Z');
|
||||
expect(s.meters.map((m) => [m.id, m.kind])).toEqual([
|
||||
['session', { type: 'percent', used: 100 }],
|
||||
['weekly', { type: 'percent', used: 40 }],
|
||||
['monthly', { type: 'percent', used: 12.5 }],
|
||||
['credits', { type: 'balance', value: 0, unit: 'USD' }],
|
||||
]);
|
||||
expect(s.meters[0].resetsAt).toBe('2026-10-06T15:00:00.000Z');
|
||||
expect(s.meters[1].resetsAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports a rejected key as an expired login and an unknown shape as unsupported', async () => {
|
||||
|
||||
@@ -10,7 +10,8 @@ describe('provider search', () => {
|
||||
});
|
||||
|
||||
it('matches names and ids, ignoring case, spaces and punctuation', () => {
|
||||
expect(names('cline')).toEqual(['Cline']);
|
||||
expect(names('cline')).toEqual(['Cline / ClinePass']);
|
||||
expect(names('clinepass')).toEqual(['Cline / ClinePass']);
|
||||
expect(names('OPEN router')).toEqual(['OpenRouter']);
|
||||
expect(names('chatgpt/codex')).toEqual(['ChatGPT / Codex']);
|
||||
expect(names('command-goat')).toEqual(['Command Code']);
|
||||
|
||||
@@ -3,20 +3,32 @@ import { requestJson } from '@/core/http';
|
||||
import type { Credential, FetchContext, Meter, ProviderPlugin, UsageSnapshot } from '@/core/types';
|
||||
|
||||
/**
|
||||
* Cline account credits (prepaid, pay-as-you-go).
|
||||
* Cline: pay-as-you-go credits and the ClinePass subscription, both on the same account and key.
|
||||
*
|
||||
* Verified against the official docs (docs.cline.bot, Enterprise API reference) and the open-source
|
||||
* Cline SDK/CLI (sdk/packages/core/src/account/cline-account-service.ts, apps/cli/src/utils/output.ts):
|
||||
* Verified against the official docs (docs.cline.bot: Enterprise API reference, ClinePass) and the
|
||||
* open-source Cline SDK and extension (sdk/packages/core/src/account/cline-account-service.ts,
|
||||
* apps/vscode/webview-ui/src/utils/format.ts):
|
||||
* - auth: `Authorization: Bearer <API key>` from app.cline.bot > Settings > API Keys
|
||||
* - `GET /api/v1/users/me` -> `{ id, organizations: [{ organizationId, active, ... }] }`
|
||||
* - `GET /api/v1/users/{id}/balance` and `GET /api/v1/organizations/{orgId}/balance` -> `{ balance }`
|
||||
* - responses may be wrapped as `{ success, data }`; balances are millionths of a dollar
|
||||
* - `GET /api/v1/users/{id}/balance` or `/organizations/{orgId}/balance` -> `{ balance }`
|
||||
* in microcredits: the extension shows `balance / 100 / 10000`, i.e. millionths of a dollar
|
||||
* - `GET /api/v1/users/me/plan` -> `{ plan: { displayName, name }, currentPeriodEnd, cancelAt }`
|
||||
* - `GET /api/v1/users/me/plan/usage-limits` -> `{ limits: [{ type: five_hour | weekly | monthly,
|
||||
* percentUsed, resetsAt }] }` (ClinePass only; other accounts get an error)
|
||||
* - responses may be wrapped as `{ success, data }`
|
||||
* When an organization is active, its balance is the one Cline itself shows and spends from.
|
||||
*/
|
||||
export const CLINE_API = 'https://api.cline.bot';
|
||||
|
||||
const MICRO_DOLLARS = 1_000_000;
|
||||
|
||||
/** ClinePass windows, mapped onto the meter ids the rest of the app knows (forecasts, widget). */
|
||||
const PASS_WINDOWS: Record<string, { id: string; label: string }> = {
|
||||
five_hour: { id: 'session', label: '5-hour window' },
|
||||
weekly: { id: 'weekly', label: 'Weekly' },
|
||||
monthly: { id: 'monthly', label: 'Monthly' },
|
||||
};
|
||||
|
||||
/** Unwraps `{ success, data }` envelopes; plain payloads pass through. */
|
||||
export function unwrapCline(json: unknown): Record<string, unknown> | undefined {
|
||||
if (!json || typeof json !== 'object') return undefined;
|
||||
@@ -36,6 +48,56 @@ async function getJson(url: string, token: string, ctx: FetchContext) {
|
||||
return unwrapCline(res.json);
|
||||
}
|
||||
|
||||
/** Optional parts (ClinePass, plan) must not fail the account; a rejected key still does. */
|
||||
async function optional<T>(p: Promise<T>): Promise<T | undefined> {
|
||||
try {
|
||||
return await p;
|
||||
} catch (e) {
|
||||
if (e instanceof AuthExpiredError) throw e;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function passMeters(limits: unknown): Meter[] {
|
||||
if (!limits || typeof limits !== 'object') return [];
|
||||
const list = (limits as { limits?: unknown }).limits;
|
||||
if (!Array.isArray(list)) return [];
|
||||
const out: Meter[] = [];
|
||||
for (const raw of list) {
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
const l = raw as { type?: unknown; percentUsed?: unknown; resetsAt?: unknown };
|
||||
const w = typeof l.type === 'string' ? PASS_WINDOWS[l.type] : undefined;
|
||||
if (!w || typeof l.percentUsed !== 'number' || !Number.isFinite(l.percentUsed)) continue;
|
||||
const reset = typeof l.resetsAt === 'string' ? Date.parse(l.resetsAt) : NaN;
|
||||
out.push({
|
||||
id: w.id,
|
||||
label: w.label,
|
||||
kind: { type: 'percent', used: Math.min(100, Math.max(0, l.percentUsed)) },
|
||||
scope: { type: 'overall' },
|
||||
resetsAt: Number.isFinite(reset) ? new Date(reset).toISOString() : undefined,
|
||||
});
|
||||
}
|
||||
// always 5-hour, weekly, monthly
|
||||
const order = ['session', 'weekly', 'monthly'];
|
||||
return out.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
|
||||
}
|
||||
|
||||
/** Plan name and the date it renews (or ends, when cancelled). */
|
||||
export function planInfo(plan: Record<string, unknown> | undefined): {
|
||||
plan?: string;
|
||||
renewsAt?: string;
|
||||
} {
|
||||
if (!plan) return {};
|
||||
const p =
|
||||
plan.plan && typeof plan.plan === 'object' ? (plan.plan as Record<string, unknown>) : {};
|
||||
const name = [p.displayName, p.name].find((v) => typeof v === 'string' && v.trim()) as
|
||||
string | undefined;
|
||||
const end = [plan.cancelAt, plan.currentPeriodEnd].find(
|
||||
(v) => typeof v === 'string' && Number.isFinite(Date.parse(v)),
|
||||
) as string | undefined;
|
||||
return { plan: name, renewsAt: end ? new Date(Date.parse(end)).toISOString() : undefined };
|
||||
}
|
||||
|
||||
export function clinePlugin(): ProviderPlugin {
|
||||
return {
|
||||
id: 'cline',
|
||||
@@ -62,24 +124,33 @@ export function clinePlugin(): ProviderPlugin {
|
||||
(o as { active?: unknown }).active === true &&
|
||||
typeof (o as { organizationId?: unknown }).organizationId === 'string',
|
||||
);
|
||||
const url = active
|
||||
const balanceUrl = active
|
||||
? `${CLINE_API}/api/v1/organizations/${encodeURIComponent(active.organizationId)}/balance`
|
||||
: `${CLINE_API}/api/v1/users/${encodeURIComponent(userId)}/balance`;
|
||||
const balance = (await getJson(url, cred.key, ctx))?.balance;
|
||||
if (typeof balance !== 'number' || !Number.isFinite(balance)) {
|
||||
return { ...base, meters: [], status: { type: 'unsupported' } };
|
||||
|
||||
const [balanceRes, limits, plan] = await Promise.all([
|
||||
optional(getJson(balanceUrl, cred.key, ctx)),
|
||||
optional(getJson(`${CLINE_API}/api/v1/users/me/plan/usage-limits`, cred.key, ctx)),
|
||||
optional(getJson(`${CLINE_API}/api/v1/users/me/plan`, cred.key, ctx)),
|
||||
]);
|
||||
|
||||
const meters = passMeters(limits);
|
||||
const balance = balanceRes?.balance;
|
||||
if (typeof balance === 'number' && Number.isFinite(balance)) {
|
||||
meters.push({
|
||||
id: 'credits',
|
||||
label: 'Credits balance',
|
||||
kind: { type: 'balance', value: balance / MICRO_DOLLARS, unit: 'USD' },
|
||||
scope: { type: 'overall' },
|
||||
});
|
||||
}
|
||||
const meter: Meter = {
|
||||
id: 'credits',
|
||||
label: 'Credits balance',
|
||||
kind: { type: 'balance', value: balance / MICRO_DOLLARS, unit: 'USD' },
|
||||
scope: { type: 'overall' },
|
||||
};
|
||||
const info = planInfo(plan);
|
||||
return {
|
||||
...base,
|
||||
plan: active?.name ? String(active.name) : undefined,
|
||||
meters: [meter],
|
||||
status: { type: 'ok' },
|
||||
plan: info.plan ?? (active?.name ? String(active.name) : undefined),
|
||||
renewsAt: info.renewsAt,
|
||||
meters,
|
||||
status: meters.length > 0 ? { type: 'ok' } : { type: 'unsupported' },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -119,11 +119,11 @@ export const providers: ProviderMeta[] = [
|
||||
},
|
||||
{
|
||||
id: 'cline',
|
||||
name: 'Cline',
|
||||
name: 'Cline / ClinePass',
|
||||
automation: 'plugin',
|
||||
auth: ['apiKey'],
|
||||
plugin: clinePlugin(),
|
||||
note: 'Shows your Cline credits. Create an API key at app.cline.bot under Settings > API Keys.',
|
||||
note: 'Shows your Cline credits and, with ClinePass, the 5-hour, weekly and monthly limits. Create an API key at app.cline.bot under Settings > API Keys.',
|
||||
},
|
||||
{
|
||||
id: 'opencode-go',
|
||||
|
||||
+33
-12
@@ -10,7 +10,7 @@ import { renewalInfo } from '@/ui/meter-text';
|
||||
import { MeterRow } from '@/ui/meter-row';
|
||||
import { PressableScale } from '@/ui/motion';
|
||||
import { ProviderIcon } from '@/ui/provider-icon';
|
||||
import { StatusBadge } from '@/ui/status-badge';
|
||||
import { StatusBadge, statusLabel } from '@/ui/status-badge';
|
||||
|
||||
export { forecastText, meterTitle, renewalInfo, resetText } from '@/ui/meter-text';
|
||||
|
||||
@@ -48,16 +48,27 @@ export function AccountCard({
|
||||
scaleTo={0.985}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
{/* one line: logo, name, and the plan on the right, all centred on the same axis */}
|
||||
<View style={styles.header}>
|
||||
<ProviderIcon providerId={view.account.providerId} label={name} size={24} />
|
||||
{/* empty lines are left out so a lone title centres on the icon */}
|
||||
<View style={styles.titles}>
|
||||
<ThemedText type="smallBold">{name}</ThemedText>
|
||||
{subtitle ? (
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{subtitle}
|
||||
</ThemedText>
|
||||
) : null}
|
||||
<ThemedText type="smallBold" numberOfLines={1} style={styles.name}>
|
||||
{name}
|
||||
</ThemedText>
|
||||
{subtitle ? (
|
||||
<ThemedText
|
||||
type="small"
|
||||
themeColor="textSecondary"
|
||||
numberOfLines={1}
|
||||
style={styles.subtitle}
|
||||
>
|
||||
{subtitle}
|
||||
</ThemedText>
|
||||
) : null}
|
||||
</View>
|
||||
{/* status and plan end sit under the name, indented past the logo */}
|
||||
{renewal || statusLabel(status, now, t) ? (
|
||||
<View style={styles.meta}>
|
||||
<StatusBadge status={status} now={now} />
|
||||
{renewal ? (
|
||||
<ThemedText
|
||||
type="small"
|
||||
@@ -68,8 +79,7 @@ export function AccountCard({
|
||||
</ThemedText>
|
||||
) : null}
|
||||
</View>
|
||||
<StatusBadge status={status} now={now} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{meters.length > 0 ? (
|
||||
<View style={styles.meters}>
|
||||
@@ -105,6 +115,17 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
gap: Spacing.two,
|
||||
},
|
||||
titles: { gap: Spacing.half, flexShrink: 1, flexGrow: 1, justifyContent: 'center' },
|
||||
name: { flexShrink: 1, flexGrow: 1 },
|
||||
// the plan yields space to the name first, but never collapses to nothing
|
||||
subtitle: { flexShrink: 2, textAlign: 'right', minWidth: 48 },
|
||||
// 24pt logo + 8pt gap: aligned with the name above
|
||||
meta: {
|
||||
marginTop: -Spacing.two,
|
||||
paddingLeft: 24 + Spacing.two,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.two,
|
||||
},
|
||||
meters: { gap: Spacing.two },
|
||||
});
|
||||
@@ -2,8 +2,9 @@
|
||||
* Official provider marks, stored as structured path data so the component can render them with
|
||||
* <Svg>/<Path> and tint them with the theme text colour (see ui/provider-icon.tsx).
|
||||
*
|
||||
* Sources: @lobehub/icons-static-svg (AI brand set) and simple-icons (GitHub).
|
||||
* Brands remain the property of their owners; used here only to identify the provider.
|
||||
* Sources: @lobehub/icons-static-svg (AI brand set) and simple-icons (GitHub); the API relay mark
|
||||
* is drawn for this app. Brands remain the property of their owners; used here only to identify
|
||||
* the provider.
|
||||
*
|
||||
* Regenerate with scripts/fetch-provider-icons.mjs when a brand updates its mark.
|
||||
*/
|
||||
@@ -108,6 +109,23 @@ export const providerIcons: Record<string, ProviderMark> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
'command-goat': {
|
||||
viewBox: '0 0 24 24',
|
||||
paths: [
|
||||
{
|
||||
d: 'M19.334 0a4.67 4.67 0 0 0-4.666 4.668v2H9.334v-2A4.67 4.67 0 0 0 4.668 0 4.67 4.67 0 0 0 0 4.668a4.67 4.67 0 0 0 4.668 4.665h2v5.334h-2A4.67 4.67 0 0 0 0 19.334 4.67 4.67 0 0 0 4.668 24a4.67 4.67 0 0 0 4.666-4.666v-2h5.334v2A4.67 4.67 0 0 0 19.334 24 4.67 4.67 0 0 0 24 19.334a4.67 4.67 0 0 0-4.666-4.667h-2V9.333h2A4.67 4.67 0 0 0 24 4.668 4.673 4.673 0 0 0 19.334 0m-2 6.666v-2c0-1.107.893-2 2-2s2 .893 2 2-.893 2-2 2zm-12.668 0c-1.107 0-2-.893-2-2s.893-2 2-2 2 .893 2 2v2zm4.666 8V9.334h5.334v5.334zm10.002 6.667c-1.107 0-2-.893-2-2v-2h2c1.107 0 2 .892 2 2 0 1.107-.893 2-2 2m-14.668 0c-1.107 0-2-.893-2-2 0-1.108.893-2 2-2h2v2c0 1.107-.893 2-2 2',
|
||||
},
|
||||
],
|
||||
},
|
||||
cline: {
|
||||
viewBox: '0 0 24 24',
|
||||
paths: [
|
||||
{
|
||||
d: 'M17.035 3.991c2.75 0 4.98 2.24 4.98 5.003v1.667l1.45 2.896a1.01 1.01 0 01-.002.909l-1.448 2.864v1.668c0 2.762-2.23 5.002-4.98 5.002H7.074c-2.751 0-4.98-2.24-4.98-5.002V17.33l-1.48-2.855a1.01 1.01 0 01-.003-.927l1.482-2.887V8.994c0-2.763 2.23-5.003 4.98-5.003h9.962zM8.265 9.6a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 004.547 0v-4.042A2.274 2.274 0 008.265 9.6zm7.326 0a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 104.548 0v-4.042A2.274 2.274 0 0015.59 9.6z',
|
||||
},
|
||||
{ d: 'M12.054 5.558a2.779 2.779 0 100-5.558 2.779 2.779 0 000 5.558z' },
|
||||
],
|
||||
},
|
||||
'opencode-go': {
|
||||
viewBox: '0 0 24 24',
|
||||
paths: [{ d: 'M16 6H8v12h8V6zm4 16H4V2h16v20z' }],
|
||||
@@ -132,4 +150,8 @@ export const providerIcons: Record<string, ProviderMark> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
relay: {
|
||||
viewBox: '0 0 24 24',
|
||||
paths: [{ d: 'M4 7h12V4l5 4-5 4V9H4zM20 15H8v-3l-5 4 5 4v-3h12z' }],
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user