pi-clinepass 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/package.json +52 -0
- package/src/auth.ts +363 -0
- package/src/catalog.ts +117 -0
- package/src/errors.ts +112 -0
- package/src/headers.ts +80 -0
- package/src/index.ts +109 -0
- package/src/settings.ts +49 -0
- package/src/usage.ts +743 -0
- package/src/workos.ts +231 -0
package/src/usage.ts
ADDED
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClinePass real-time billing: server-truth usage, plan limits, and the
|
|
3
|
+
* status meter.
|
|
4
|
+
*
|
|
5
|
+
* Turn cost and session total come from Cline's `/usages` API (the same
|
|
6
|
+
* billing the subscription meter uses), not from local price estimation.
|
|
7
|
+
* The meter updates pi's footer status bar — no chat-stream noise.
|
|
8
|
+
*
|
|
9
|
+
* Tracking runs on a background queue: `message_end` sits on pi's critical
|
|
10
|
+
* path (message finalization + agent loop), so polling the billing API must
|
|
11
|
+
* never block it. Records are matched to the completed message by freshness
|
|
12
|
+
* and model id, with a session-start baseline so stale records from earlier
|
|
13
|
+
* sessions are never adopted as this session's cost.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_API_BASE,
|
|
18
|
+
WORKOS_REFRESH_MARGIN_MS,
|
|
19
|
+
refreshWorkosToken,
|
|
20
|
+
type ClineAuthCredentials,
|
|
21
|
+
} from "./workos.js";
|
|
22
|
+
import { isFreeModel, MODELS } from "./catalog.js";
|
|
23
|
+
import {
|
|
24
|
+
persistOAuthCredential,
|
|
25
|
+
resolvePiStoredCredential,
|
|
26
|
+
type AuthOptions,
|
|
27
|
+
} from "./auth.js";
|
|
28
|
+
|
|
29
|
+
export const PROVIDER_NAME = "clinepass";
|
|
30
|
+
|
|
31
|
+
export interface UsageRecord {
|
|
32
|
+
id: string;
|
|
33
|
+
model: string;
|
|
34
|
+
promptTokens: number;
|
|
35
|
+
cachedTokens: number;
|
|
36
|
+
completionTokens: number;
|
|
37
|
+
totalTokens: number;
|
|
38
|
+
/** USD (costUsd is micro-units from the API: /1e8). */
|
|
39
|
+
costUsd: number;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PlanLimits {
|
|
44
|
+
planName?: string;
|
|
45
|
+
isActive?: boolean;
|
|
46
|
+
currentPeriodEnd?: string;
|
|
47
|
+
fiveHour: { usedPercent: number; limitUsd: number | undefined; resetsAt?: string };
|
|
48
|
+
sevenDay: { usedPercent: number; limitUsd: number | undefined; resetsAt?: string };
|
|
49
|
+
thirtyDay: { usedPercent: number; limitUsd: number | undefined; resetsAt?: string };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface UsageOptions extends AuthOptions {
|
|
53
|
+
fetch?: typeof globalThis.fetch;
|
|
54
|
+
apiBase?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const USAGES_PER_PAGE = 50;
|
|
58
|
+
/** Records scanned per poll when matching a turn to its usage record. */
|
|
59
|
+
const TRACKING_SCAN_LIMIT = 5;
|
|
60
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
61
|
+
/** A record created this long before the turn started is not this turn's. */
|
|
62
|
+
const STALE_RECORD_SKEW_MS = 2 * 60 * 1000;
|
|
63
|
+
/** Poll attempts for the server to flush the turn's usage record. */
|
|
64
|
+
const TRACKING_POLL_ATTEMPTS = 3;
|
|
65
|
+
const TRACKING_POLL_INTERVAL_MS = 400;
|
|
66
|
+
/** Minimum remaining TTL for the cached token to be reused. */
|
|
67
|
+
const TOKEN_CACHE_MIN_TTL_MS = 30_000;
|
|
68
|
+
|
|
69
|
+
// ─── ANSI color support ────────────────────────────────────────────────────
|
|
70
|
+
// pi's TUI renders ANSI in the footer status bar and status messages.
|
|
71
|
+
// Respect NO_COLOR and allow opt-out via CLINEPASS_COLOR=0.
|
|
72
|
+
const USE_COLOR = !process.env.NO_COLOR && process.env.CLINEPASS_COLOR !== "0";
|
|
73
|
+
const c = {
|
|
74
|
+
red: (s: string) => (USE_COLOR ? `\x1b[31m${s}\x1b[0m` : s),
|
|
75
|
+
green: (s: string) => (USE_COLOR ? `\x1b[32m${s}\x1b[0m` : s),
|
|
76
|
+
yellow: (s: string) => (USE_COLOR ? `\x1b[33m${s}\x1b[0m` : s),
|
|
77
|
+
bold: (s: string) => (USE_COLOR ? `\x1b[1m${s}\x1b[0m` : s),
|
|
78
|
+
dim: (s: string) => (USE_COLOR ? `\x1b[2m${s}\x1b[0m` : s),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Warning sign in text presentation (U+26A0 + VS15) — not the emoji variant. */
|
|
82
|
+
const WARN = "\u26A0\uFE0E";
|
|
83
|
+
|
|
84
|
+
/** Reset all ANSI attributes — clears pi's outer dim wrapper around report text. */
|
|
85
|
+
const CLEAR = USE_COLOR ? "\x1b[0m" : "";
|
|
86
|
+
|
|
87
|
+
/** Color by usage level: <50 green, 50-79 yellow, ≥80 red. */
|
|
88
|
+
function usageColor(p: number): (s: string) => string {
|
|
89
|
+
return p >= 80 ? c.red : p >= 50 ? c.yellow : c.green;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
93
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function stringValue(value: unknown): string | undefined {
|
|
97
|
+
return typeof value === "string" ? value : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function numberValue(value: unknown): number | undefined {
|
|
101
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The usage record's model id, in catalog form. Real data: `aiModelName`
|
|
107
|
+
* for paid ClinePass models already carries the catalog id
|
|
108
|
+
* ("cline-pass/mimo-v2.5-pro"); `metadata.raw_model` is an upstream vendor
|
|
109
|
+
* path ("xiaomi/mimo-v2.5-pro") and must NOT take precedence. Only the
|
|
110
|
+
* free deepseek route leaves `aiModelName` bare, and free models are
|
|
111
|
+
* skipped before matching anyway.
|
|
112
|
+
*/
|
|
113
|
+
function normalizeRecordModel(item: Record<string, unknown>): string {
|
|
114
|
+
const name = stringValue(item.aiModelName);
|
|
115
|
+
if (name) return name;
|
|
116
|
+
return stringValue(isRecord(item.metadata) ? item.metadata.raw_model : undefined) ?? "";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Token resolution (single source, shared with auth.ts) ────────────────
|
|
120
|
+
|
|
121
|
+
interface ResolvedToken {
|
|
122
|
+
token: string;
|
|
123
|
+
/** When the token should be re-resolved (ms epoch). */
|
|
124
|
+
expiresAt: number;
|
|
125
|
+
/**
|
|
126
|
+
* Identity of the stored credential this token was resolved from
|
|
127
|
+
* (`pi-store:<accessToken>`). `/logout` deletes the stored credential
|
|
128
|
+
* without any extension event, so every call re-reads the store (a cheap
|
|
129
|
+
* sync read) and drops the cache when the identity changes — the meter
|
|
130
|
+
* must never keep using a logged-out or switched credential.
|
|
131
|
+
*/
|
|
132
|
+
identity: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let tokenCache: ResolvedToken | undefined;
|
|
136
|
+
let userIdCache: { token: string; userId: string } | undefined;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve a usable bearer token for Cline API requests.
|
|
140
|
+
*
|
|
141
|
+
* Single source of truth: pi's stored credential for the `clinepass`
|
|
142
|
+
* provider (`~/.pi/agent/auth.json` → `clinepass`) — the identity every
|
|
143
|
+
* login method converges to (paste / device flow / reuse), so the meter
|
|
144
|
+
* always measures the account chat actually uses. The token is cached to avoid
|
|
145
|
+
* a network refresh on every call, but the store is re-read each time so
|
|
146
|
+
* `/logout` (or switching accounts) takes effect immediately.
|
|
147
|
+
*/
|
|
148
|
+
export async function getActiveToken(options: UsageOptions = {}): Promise<string | undefined> {
|
|
149
|
+
const credential = resolvePiStoredCredential(options);
|
|
150
|
+
if (!credential) {
|
|
151
|
+
tokenCache = undefined;
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
const identity = `pi-store:${credential.accessToken}`;
|
|
155
|
+
// Same credential still stored and cached token has lifetime: reuse it
|
|
156
|
+
// (skips the potentially-networking refresh below).
|
|
157
|
+
if (tokenCache?.identity === identity && tokenCache.expiresAt > Date.now() + TOKEN_CACHE_MIN_TTL_MS) {
|
|
158
|
+
return tokenCache.token;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const resolved = await freshenCredential(credential, options);
|
|
162
|
+
tokenCache = { ...resolved, identity };
|
|
163
|
+
return resolved.token;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Turn the stored credential into a request token, refreshing near expiry. */
|
|
167
|
+
async function freshenCredential(
|
|
168
|
+
clineAuth: ClineAuthCredentials,
|
|
169
|
+
options: UsageOptions,
|
|
170
|
+
): Promise<Omit<ResolvedToken, "identity">> {
|
|
171
|
+
if (clineAuth.expiresAt > Date.now() + WORKOS_REFRESH_MARGIN_MS) {
|
|
172
|
+
return { token: clineAuth.accessToken, expiresAt: clineAuth.expiresAt - WORKOS_REFRESH_MARGIN_MS };
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const refreshed = await refreshWorkosToken(
|
|
176
|
+
{ access: clineAuth.accessToken, refresh: clineAuth.refreshToken, expires: clineAuth.expiresAt },
|
|
177
|
+
{ fetch: options.fetch, apiBase: options.apiBase ?? DEFAULT_API_BASE },
|
|
178
|
+
);
|
|
179
|
+
// Persist rotated refresh tokens back to pi's auth.json — if the server
|
|
180
|
+
// rotates single-use refresh tokens, discarding the new one would leave
|
|
181
|
+
// pi's stored credential dead and force a re-login.
|
|
182
|
+
if (refreshed.refresh !== clineAuth.refreshToken) {
|
|
183
|
+
await persistOAuthCredential(refreshed, options).catch(() => {});
|
|
184
|
+
}
|
|
185
|
+
return { token: refreshed.access, expiresAt: refreshed.expires };
|
|
186
|
+
} catch {
|
|
187
|
+
// Keep the stale token for a short window; pi's own refresh on the next
|
|
188
|
+
// chat request usually repairs the stored credential.
|
|
189
|
+
return { token: clineAuth.accessToken, expiresAt: Date.now() + TOKEN_CACHE_MIN_TTL_MS };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── API fetchers ─────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
async function getJson(path: string, options: UsageOptions): Promise<Record<string, unknown> | undefined> {
|
|
196
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
197
|
+
const apiBase = options.apiBase ?? DEFAULT_API_BASE;
|
|
198
|
+
const token = await getActiveToken(options);
|
|
199
|
+
if (!token) return undefined;
|
|
200
|
+
const response = await fetchFn(`${apiBase}${path}`, {
|
|
201
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
202
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
203
|
+
});
|
|
204
|
+
if (response.status === 401) {
|
|
205
|
+
// Cached token rejected — drop caches so the next call re-resolves.
|
|
206
|
+
tokenCache = undefined;
|
|
207
|
+
userIdCache = undefined;
|
|
208
|
+
}
|
|
209
|
+
if (!response.ok) return undefined;
|
|
210
|
+
const body: unknown = await response.json().catch(() => undefined);
|
|
211
|
+
return isRecord(body) ? body : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function getUserId(options: UsageOptions): Promise<string | undefined> {
|
|
215
|
+
const token = await getActiveToken(options);
|
|
216
|
+
if (!token) return undefined;
|
|
217
|
+
if (userIdCache?.token === token) return userIdCache.userId;
|
|
218
|
+
const json = await getJson("/api/v1/users/me", options);
|
|
219
|
+
const id = stringValue(isRecord(json?.data) ? json.data.id : json?.id);
|
|
220
|
+
if (id) userIdCache = { token, userId: id };
|
|
221
|
+
return id;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Fetch recent usage records (newest first) from Cline's billing API.
|
|
226
|
+
* `costUsd` arrives as micro-units; divide by 1e8 to get USD.
|
|
227
|
+
*/
|
|
228
|
+
export async function fetchUsageRecords(
|
|
229
|
+
options: UsageOptions = {},
|
|
230
|
+
limit = USAGES_PER_PAGE,
|
|
231
|
+
): Promise<UsageRecord[] | undefined> {
|
|
232
|
+
const userId = await getUserId(options);
|
|
233
|
+
if (!userId) return undefined;
|
|
234
|
+
const json = await getJson(`/api/v1/users/${encodeURIComponent(userId)}/usages?limit=${limit}`, options);
|
|
235
|
+
const items = isRecord(json?.data) && Array.isArray(json.data.items) ? json.data.items : undefined;
|
|
236
|
+
if (!items) return undefined;
|
|
237
|
+
return items
|
|
238
|
+
.filter(isRecord)
|
|
239
|
+
.map((item) => {
|
|
240
|
+
const promptTokens = numberValue(item.promptTokens) ?? 0;
|
|
241
|
+
const cachedTokens = numberValue(item.cachedTokens) ?? 0;
|
|
242
|
+
const completionTokens = numberValue(item.completionTokens) ?? 0;
|
|
243
|
+
const rawCostUsd = numberValue(item.costUsd) ?? 0;
|
|
244
|
+
return {
|
|
245
|
+
id: stringValue(item.id) ?? "",
|
|
246
|
+
// The API's `aiModelName` is bare (e.g. "deepseek-v4-flash"); the
|
|
247
|
+
// catalog/message ids carry the vendor path ("deepseek/deepseek-v4-flash")
|
|
248
|
+
// so normalize with `metadata.rawModel` when present.
|
|
249
|
+
model: normalizeRecordModel(item),
|
|
250
|
+
promptTokens,
|
|
251
|
+
cachedTokens,
|
|
252
|
+
completionTokens,
|
|
253
|
+
totalTokens: promptTokens + completionTokens,
|
|
254
|
+
costUsd: rawCostUsd / 100_000_000,
|
|
255
|
+
createdAt: stringValue(item.createdAt) ?? new Date().toISOString(),
|
|
256
|
+
};
|
|
257
|
+
})
|
|
258
|
+
.filter((r) => r.id);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Fetch rolling plan limits: percent used (5h/weekly/monthly) plus the real
|
|
263
|
+
* cap thresholds from the plan entitlements (micro-units → USD). Limit
|
|
264
|
+
* amounts are `undefined` when the plan exposes no cap entitlement — no
|
|
265
|
+
* invented fallback numbers.
|
|
266
|
+
*/
|
|
267
|
+
export async function fetchPlanLimits(options: UsageOptions = {}): Promise<PlanLimits | undefined> {
|
|
268
|
+
const [limitsJson, planJson] = await Promise.all([
|
|
269
|
+
getJson("/api/v1/users/me/plan/usage-limits", options),
|
|
270
|
+
getJson("/api/v1/users/me/plan", options),
|
|
271
|
+
]);
|
|
272
|
+
if (!limitsJson) return undefined;
|
|
273
|
+
|
|
274
|
+
const limits = Array.isArray(isRecord(limitsJson.data) ? limitsJson.data.limits : undefined)
|
|
275
|
+
? (limitsJson.data as { limits: unknown[] }).limits
|
|
276
|
+
: [];
|
|
277
|
+
const byType = new Map<string, { percentUsed?: number; resetsAt?: string }>();
|
|
278
|
+
for (const item of limits.filter(isRecord)) {
|
|
279
|
+
const type = stringValue(item.type);
|
|
280
|
+
if (!type) continue;
|
|
281
|
+
byType.set(type, {
|
|
282
|
+
percentUsed: numberValue(item.percentUsed),
|
|
283
|
+
resetsAt: stringValue(item.resetsAt),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const plan = isRecord(planJson?.data) ? planJson.data : planJson;
|
|
288
|
+
const planDetails = isRecord(plan?.plan) ? plan.plan : undefined;
|
|
289
|
+
const entitlements = isRecord(planDetails?.entitlements) ? planDetails.entitlements : undefined;
|
|
290
|
+
const clinePass = isRecord(entitlements?.cline_pass) ? entitlements.cline_pass : undefined;
|
|
291
|
+
const cap = isRecord(clinePass?.inferenceCapThreshold) ? clinePass.inferenceCapThreshold : undefined;
|
|
292
|
+
const microToUsd = (v: unknown): number | undefined => {
|
|
293
|
+
const n = numberValue(v);
|
|
294
|
+
return n === undefined ? undefined : n / 100_000_000;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const fiveHour = byType.get("five_hour");
|
|
298
|
+
const weekly = byType.get("weekly");
|
|
299
|
+
const monthly = byType.get("monthly");
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
planName: stringValue(planDetails?.displayName) ?? "Cline Pass",
|
|
303
|
+
isActive: isRecord(plan?.plan) ? (plan.plan.isActive as boolean | undefined) : undefined,
|
|
304
|
+
currentPeriodEnd: stringValue(plan?.currentPeriodEnd),
|
|
305
|
+
fiveHour: {
|
|
306
|
+
usedPercent: fiveHour?.percentUsed ?? 0,
|
|
307
|
+
limitUsd: microToUsd(cap?.last5HoursUsageCostUSDPerUser),
|
|
308
|
+
resetsAt: fiveHour?.resetsAt,
|
|
309
|
+
},
|
|
310
|
+
sevenDay: {
|
|
311
|
+
usedPercent: weekly?.percentUsed ?? 0,
|
|
312
|
+
limitUsd: microToUsd(cap?.last7daysUsageCostUSDPerUser),
|
|
313
|
+
resetsAt: weekly?.resetsAt,
|
|
314
|
+
},
|
|
315
|
+
thirtyDay: {
|
|
316
|
+
usedPercent: monthly?.percentUsed ?? 0,
|
|
317
|
+
limitUsd: microToUsd(cap?.last30daysUsageCostUSDPerUser),
|
|
318
|
+
resetsAt: monthly?.resetsAt,
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ─── Meter ────────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
export interface MeterContext {
|
|
326
|
+
hasUI?: boolean;
|
|
327
|
+
ui?: {
|
|
328
|
+
setStatus?: (key: string, text: string | undefined) => void;
|
|
329
|
+
notify?: (msg: string, type: "info" | "warning" | "error") => void;
|
|
330
|
+
};
|
|
331
|
+
model?: { provider?: string; id?: string };
|
|
332
|
+
sessionManager?: {
|
|
333
|
+
getEntries?: () => unknown[];
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function isClinePassContext(ctx: MeterContext): boolean {
|
|
338
|
+
const provider = ctx.model?.provider;
|
|
339
|
+
return provider === PROVIDER_NAME || provider === "cline-pass";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Colored progress bar + percentage segment for the status line. */
|
|
343
|
+
function formatLimitSegment(usedPercent: number, resetsAt?: string): string {
|
|
344
|
+
const color = usageColor(usedPercent);
|
|
345
|
+
const bar = color(renderBar(usedPercent));
|
|
346
|
+
const pct = color(`${usedPercent}%`);
|
|
347
|
+
const reset = resetsAt ? ` (resets ${formatTime(resetsAt)})` : "";
|
|
348
|
+
return `${bar} ${pct}${reset}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Wrap a status-line payload in box-drawing side rails so the meter stands
|
|
353
|
+
* out from pi's other footer statuses.
|
|
354
|
+
*/
|
|
355
|
+
function meterBox(payload: string): string {
|
|
356
|
+
return `│ ${payload} │`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Format a USD price keeping only meaningful decimals: strip trailing zeros
|
|
361
|
+
* while keeping at least 2 decimals for values ≥ 1 and exact zeros.
|
|
362
|
+
* (1.40, 30.00, 0.26, 0.317, 0.0028)
|
|
363
|
+
*/
|
|
364
|
+
function formatUsd(value: number): string {
|
|
365
|
+
if (value === 0) return "$0.00";
|
|
366
|
+
const stripped = value.toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
|
|
367
|
+
const decimals = stripped.includes(".") ? stripped.length - stripped.indexOf(".") - 1 : 0;
|
|
368
|
+
return `$${decimals >= 2 ? stripped : value.toFixed(2)}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function renderBar(percent: number, length = 10): string {
|
|
372
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
373
|
+
const filled = Math.round((clamped / 100) * length);
|
|
374
|
+
return `[${"█".repeat(filled)}${"░".repeat(length - filled)}]`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function formatTime(iso: string): string {
|
|
378
|
+
const d = new Date(iso);
|
|
379
|
+
return Number.isNaN(d.getTime()) ? "" : d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Sum the per-session cost entries appended by writeCostEntry.
|
|
384
|
+
*
|
|
385
|
+
* Aggregates over ALL session entries — including turns later abandoned
|
|
386
|
+
* by rewind/fork — matching pi's own billing philosophy ("reflect what
|
|
387
|
+
* was actually billed"): those API calls were really made, so their
|
|
388
|
+
* spend counts even if the conversation moved on. Cost entries are kept
|
|
389
|
+
* across compaction boundaries — compaction is context management, not
|
|
390
|
+
* cost accounting.
|
|
391
|
+
*/
|
|
392
|
+
export function sumSessionEntries(
|
|
393
|
+
sessionManager: { getEntries?: () => unknown[] } | undefined,
|
|
394
|
+
): number {
|
|
395
|
+
let total = 0;
|
|
396
|
+
for (const entry of sessionManager?.getEntries?.() ?? []) {
|
|
397
|
+
if (!isRecord(entry)) continue;
|
|
398
|
+
if (entry.type === "custom" && entry.customType === "clinepass-cost") {
|
|
399
|
+
total += numberValue(isRecord(entry.data) ? entry.data.costUsd : undefined) ?? 0;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return total;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function clearMeter(ctx: MeterContext): void {
|
|
406
|
+
try {
|
|
407
|
+
ctx.ui?.setStatus?.("clinepass-cost", undefined);
|
|
408
|
+
} catch {
|
|
409
|
+
// Meter updates are best-effort; a stale ctx (session replaced/reloaded)
|
|
410
|
+
// must never crash the extension.
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* message_end entry point: queue billing tracking in the background.
|
|
416
|
+
* `message_end` handlers are awaited inline by pi (they gate message
|
|
417
|
+
* finalization and the agent loop), so the usage-record poll must run
|
|
418
|
+
* detached. Tasks are serialized on an internal chain to preserve
|
|
419
|
+
* per-message order of session cost entries.
|
|
420
|
+
* Returns the queued task (resolves when tracking for this message is done).
|
|
421
|
+
*/
|
|
422
|
+
export function handleUsageTracking(
|
|
423
|
+
event: { message?: unknown },
|
|
424
|
+
ctx: MeterContext,
|
|
425
|
+
writeEntry: (usage: UsageRecord) => void,
|
|
426
|
+
options: UsageOptions = {},
|
|
427
|
+
): Promise<void> {
|
|
428
|
+
// Extract everything from the event synchronously — the event object may
|
|
429
|
+
// be reused after this handler returns.
|
|
430
|
+
const msg = isRecord(event.message) ? event.message : undefined;
|
|
431
|
+
if (!msg || msg.role !== "assistant") return Promise.resolve();
|
|
432
|
+
|
|
433
|
+
const provider = typeof msg.provider === "string" ? msg.provider : ctx.model?.provider;
|
|
434
|
+
if (provider !== PROVIDER_NAME && provider !== "cline-pass") return Promise.resolve();
|
|
435
|
+
|
|
436
|
+
const rawModelId = typeof msg.model === "string" ? msg.model : ctx.model?.id ?? "";
|
|
437
|
+
const modelId = rawModelId.toLowerCase();
|
|
438
|
+
if (isFreeModel(modelId)) {
|
|
439
|
+
clearMeter(ctx);
|
|
440
|
+
return Promise.resolve();
|
|
441
|
+
}
|
|
442
|
+
// Logged out (or never logged in): no account to bill against, so no
|
|
443
|
+
// tracking. The credential check re-runs inside the tracking queue too,
|
|
444
|
+
// so a logout mid-turn stops the poll as well.
|
|
445
|
+
if (!resolvePiStoredCredential()) {
|
|
446
|
+
tokenCache = undefined;
|
|
447
|
+
clearMeter(ctx);
|
|
448
|
+
return Promise.resolve();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const info = { modelId, turnStartMs: Date.now() };
|
|
452
|
+
const task = trackingQueue.then(() => trackUsage(info, ctx, writeEntry, options));
|
|
453
|
+
trackingQueue = task.catch(() => {});
|
|
454
|
+
return task;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let trackingQueue: Promise<void> = Promise.resolve();
|
|
458
|
+
/** Newest usage-record id seen/adopted; records at or before it are old news. */
|
|
459
|
+
let lastSeenUsageId: string | undefined;
|
|
460
|
+
|
|
461
|
+
async function trackUsage(
|
|
462
|
+
info: { modelId: string; turnStartMs: number },
|
|
463
|
+
ctx: MeterContext,
|
|
464
|
+
writeEntry: (usage: UsageRecord) => void,
|
|
465
|
+
options: UsageOptions,
|
|
466
|
+
): Promise<void> {
|
|
467
|
+
try {
|
|
468
|
+
// The server flushes the usage record shortly after the turn completes;
|
|
469
|
+
// poll briefly, then fall back to a late background check so the session
|
|
470
|
+
// total never under-counts. Re-check the stored credential on every poll
|
|
471
|
+
// attempt and before each late check — a /logout mid-turn must stop all
|
|
472
|
+
// billing traffic immediately, and the meter's token cache is dropped so
|
|
473
|
+
// no stale token is reused afterwards.
|
|
474
|
+
let usage: UsageRecord | undefined;
|
|
475
|
+
for (let attempt = 0; attempt < TRACKING_POLL_ATTEMPTS && !usage; attempt++) {
|
|
476
|
+
await sleep(TRACKING_POLL_INTERVAL_MS);
|
|
477
|
+
if (!resolvePiStoredCredential(options)) {
|
|
478
|
+
tokenCache = undefined;
|
|
479
|
+
userIdCache = undefined;
|
|
480
|
+
clearMeter(ctx);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const records = await fetchUsageRecords(options, TRACKING_SCAN_LIMIT);
|
|
484
|
+
if (!records || records.length === 0) continue;
|
|
485
|
+
usage = adoptUsageRecord(records, info.turnStartMs, info.modelId);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (!usage) {
|
|
489
|
+
scheduleLateRecord(info, ctx, writeEntry, options);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
writeEntry(usage);
|
|
493
|
+
await updateMeter(usage, ctx, options);
|
|
494
|
+
} catch {
|
|
495
|
+
// Billing tracking is best-effort; never let it crash the turn.
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function recordCreatedMs(record: UsageRecord): number {
|
|
500
|
+
const ms = Date.parse(record.createdAt);
|
|
501
|
+
return Number.isNaN(ms) ? Date.now() : ms;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function isStaleRecord(record: UsageRecord, turnStartMs: number): boolean {
|
|
505
|
+
return recordCreatedMs(record) < turnStartMs - STALE_RECORD_SKEW_MS;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Whether a usage record's model matches the message/catalog model id.
|
|
510
|
+
* Real data: `aiModelName` matches the catalog id exactly (after
|
|
511
|
+
* normalizeRecordModel). Only the free deepseek route differs, and it is
|
|
512
|
+
* skipped before matching.
|
|
513
|
+
*/
|
|
514
|
+
function modelsMatch(recordModel: string, modelId: string): boolean {
|
|
515
|
+
return recordModel.toLowerCase() === modelId.toLowerCase();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Pick this turn's usage record from the newest-first `records` list.
|
|
520
|
+
* A record is adopted only when it is unseen, fresh (created within the
|
|
521
|
+
* staleness window around this turn), and billed for the same model —
|
|
522
|
+
* so stale records from earlier sessions and foreign-model records on the
|
|
523
|
+
* same account are never misattributed. Mutates the module baseline.
|
|
524
|
+
*/
|
|
525
|
+
export function adoptUsageRecord(
|
|
526
|
+
records: UsageRecord[],
|
|
527
|
+
turnStartMs: number,
|
|
528
|
+
modelId?: string,
|
|
529
|
+
): UsageRecord | undefined {
|
|
530
|
+
for (const record of records) {
|
|
531
|
+
if (record.id === lastSeenUsageId) return undefined; // reached seen territory
|
|
532
|
+
if (isStaleRecord(record, turnStartMs)) continue;
|
|
533
|
+
if (modelId && record.model && !modelsMatch(record.model, modelId)) continue;
|
|
534
|
+
lastSeenUsageId = record.id;
|
|
535
|
+
return record;
|
|
536
|
+
}
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Seed the tracking baseline with the newest existing usage record so the
|
|
542
|
+
* first tracked turn of a session cannot adopt a record from before the
|
|
543
|
+
* session (which would show a phantom cost). Best-effort, idempotent.
|
|
544
|
+
*/
|
|
545
|
+
let seedingBaseline = false;
|
|
546
|
+
async function seedUsageBaseline(options: UsageOptions): Promise<void> {
|
|
547
|
+
if (lastSeenUsageId !== undefined || seedingBaseline) return;
|
|
548
|
+
seedingBaseline = true;
|
|
549
|
+
try {
|
|
550
|
+
const records = await fetchUsageRecords(options, 1);
|
|
551
|
+
if (records && records.length > 0 && lastSeenUsageId === undefined) {
|
|
552
|
+
lastSeenUsageId = records[0].id;
|
|
553
|
+
}
|
|
554
|
+
} catch {
|
|
555
|
+
// best-effort
|
|
556
|
+
} finally {
|
|
557
|
+
seedingBaseline = false;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function scheduleLateRecord(
|
|
562
|
+
info: { modelId: string; turnStartMs: number },
|
|
563
|
+
ctx: MeterContext,
|
|
564
|
+
writeEntry: (usage: UsageRecord) => void,
|
|
565
|
+
options: UsageOptions,
|
|
566
|
+
): void {
|
|
567
|
+
const run = async (delayMs: number): Promise<void> => {
|
|
568
|
+
await sleep(delayMs);
|
|
569
|
+
// Late checks re-verify the stored credential: a /logout between the
|
|
570
|
+
// turn and the late poll must stop all billing traffic.
|
|
571
|
+
if (!resolvePiStoredCredential(options)) {
|
|
572
|
+
tokenCache = undefined;
|
|
573
|
+
userIdCache = undefined;
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const records = await fetchUsageRecords(options, TRACKING_SCAN_LIMIT);
|
|
577
|
+
if (!records || records.length === 0) return;
|
|
578
|
+
const late = adoptUsageRecord(records, info.turnStartMs, info.modelId);
|
|
579
|
+
if (!late) return;
|
|
580
|
+
writeEntry(late);
|
|
581
|
+
await updateMeter(late, ctx, options);
|
|
582
|
+
};
|
|
583
|
+
void run(3000).catch(() => {});
|
|
584
|
+
void run(8000).catch(() => {});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function updateMeter(usage: UsageRecord, ctx: MeterContext, options: UsageOptions): Promise<void> {
|
|
588
|
+
const sessionTotal = sumSessionEntries(ctx.sessionManager);
|
|
589
|
+
if (usage.costUsd === 0 && sessionTotal === 0) {
|
|
590
|
+
clearMeter(ctx);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const limits = await fetchPlanLimits(options);
|
|
595
|
+
const limitSeg = limits
|
|
596
|
+
? ` | 5h: ${formatLimitSegment(limits.fiveHour.usedPercent, limits.fiveHour.resetsAt)}`
|
|
597
|
+
: "";
|
|
598
|
+
const text = meterBox(
|
|
599
|
+
`Turn: $${usage.costUsd.toFixed(5)} · ${c.dim(`Session: $${sessionTotal.toFixed(5)}`)}${limitSeg}`,
|
|
600
|
+
);
|
|
601
|
+
try {
|
|
602
|
+
ctx.ui?.setStatus?.("clinepass-cost", text);
|
|
603
|
+
} catch {
|
|
604
|
+
// best-effort meter update; stale ctx must not crash
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* session_start / model_select handler: show a placeholder meter (and plan
|
|
610
|
+
* limits) before the first turn completes; clear it for free models or when
|
|
611
|
+
* the session is not on ClinePass. Also seeds the usage-record baseline so
|
|
612
|
+
* the first tracked turn cannot adopt a pre-session record.
|
|
613
|
+
*/
|
|
614
|
+
export async function handleInitialMeter(ctx: MeterContext, options: UsageOptions = {}): Promise<void> {
|
|
615
|
+
try {
|
|
616
|
+
if (!isClinePassContext(ctx)) {
|
|
617
|
+
clearMeter(ctx);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
// The meter tracks the logged-in account only; before /login there is
|
|
621
|
+
// nothing to show.
|
|
622
|
+
if (!resolvePiStoredCredential(options)) {
|
|
623
|
+
clearMeter(ctx);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
void seedUsageBaseline(options);
|
|
627
|
+
const currentModelId = ctx.model?.id?.toLowerCase() ?? "";
|
|
628
|
+
if (isFreeModel(currentModelId)) {
|
|
629
|
+
clearMeter(ctx);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const limits = await fetchPlanLimits(options);
|
|
633
|
+
const sessionTotal = sumSessionEntries(ctx.sessionManager);
|
|
634
|
+
const limitSeg = limits
|
|
635
|
+
? ` | 5h: ${formatLimitSegment(limits.fiveHour.usedPercent, limits.fiveHour.resetsAt)}`
|
|
636
|
+
: "";
|
|
637
|
+
const sessionDisplay = sessionTotal > 0
|
|
638
|
+
? ` · ${c.dim(`Session: $${sessionTotal.toFixed(5)}`)}`
|
|
639
|
+
: "";
|
|
640
|
+
ctx.ui?.setStatus?.("clinepass-cost", meterBox(`Turn: $0.00000${sessionDisplay}${limitSeg}`));
|
|
641
|
+
} catch {
|
|
642
|
+
// best-effort meter update; stale ctx must not crash
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function sleep(ms: number): Promise<void> {
|
|
647
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** @internal Reset module-level caches and tracking state (tests only). */
|
|
651
|
+
export function resetUsageTrackingForTest(): void {
|
|
652
|
+
tokenCache = undefined;
|
|
653
|
+
userIdCache = undefined;
|
|
654
|
+
lastSeenUsageId = undefined;
|
|
655
|
+
seedingBaseline = false;
|
|
656
|
+
trackingQueue = Promise.resolve();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ─── /clinepass report ────────────────────────────────────────────────────
|
|
660
|
+
|
|
661
|
+
/** Clean display name for the report: strip "(ClinePass)" / "(Cline Free)". */
|
|
662
|
+
function reportName(m: (typeof MODELS)[number]): string {
|
|
663
|
+
return m.name
|
|
664
|
+
.replace(" (ClinePass)", "")
|
|
665
|
+
.replace(" (Cline Free)", " (Free)");
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function formatResetDate(iso?: string): string {
|
|
669
|
+
if (!iso) return "";
|
|
670
|
+
const d = new Date(iso);
|
|
671
|
+
return Number.isNaN(d.getTime()) ? "" : ` (resets ${formatTime(iso)})`;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function formatResetDateWithDate(iso?: string): string {
|
|
675
|
+
if (!iso) return "";
|
|
676
|
+
const d = new Date(iso);
|
|
677
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
678
|
+
const time = formatTime(iso);
|
|
679
|
+
if (!time) return "";
|
|
680
|
+
const datePart = d.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
|
|
681
|
+
return ` (resets ${datePart}, ${time})`;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function formatExpiryDate(iso?: string): string {
|
|
685
|
+
if (!iso) return "";
|
|
686
|
+
const d = new Date(iso);
|
|
687
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
688
|
+
const diffDays = Math.max(0, Math.round((d.getTime() - Date.now()) / (24 * 3600 * 1000)));
|
|
689
|
+
const dateStr = d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
|
690
|
+
const timeStr = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
691
|
+
const isExpiringSoon = diffDays <= 3;
|
|
692
|
+
const warnTag = isExpiringSoon ? c.yellow(` - ${WARN} Expiring soon!`) : "";
|
|
693
|
+
return ` • Active until ${dateStr} ${timeStr} (${diffDays}d remaining)${warnTag}`;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function formatLimitUsd(value: number | undefined): string {
|
|
697
|
+
return value === undefined ? "n/a" : `$${value.toFixed(2)}`;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Generate the formatted /clinepass report: price table + plan limits. */
|
|
701
|
+
export async function getCapReport(options: UsageOptions = {}): Promise<string> {
|
|
702
|
+
const limits = await fetchPlanLimits(options);
|
|
703
|
+
if (!limits) {
|
|
704
|
+
return `${WARN} [ClinePass] Unable to retrieve plan limits. Please ensure you are logged in via \`pi /login\`.`;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const priceRows = MODELS.map((m) => {
|
|
708
|
+
const cost = m.cost;
|
|
709
|
+
return `${reportName(m).padEnd(34)}${formatUsd(cost.input).padStart(9)}${formatUsd(cost.output).padStart(9)}${formatUsd(cost.cacheRead).padStart(9)}`;
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
const reset5h = formatResetDate(limits.fiveHour.resetsAt);
|
|
713
|
+
const reset7d = formatResetDateWithDate(limits.sevenDay.resetsAt);
|
|
714
|
+
const reset30d = formatResetDateWithDate(limits.thirtyDay.resetsAt);
|
|
715
|
+
const expiryInfo = formatExpiryDate(limits.currentPeriodEnd);
|
|
716
|
+
|
|
717
|
+
const col5h = usageColor(limits.fiveHour.usedPercent);
|
|
718
|
+
const col7d = usageColor(limits.sevenDay.usedPercent);
|
|
719
|
+
const col30d = usageColor(limits.thirtyDay.usedPercent);
|
|
720
|
+
|
|
721
|
+
const bar5h = col5h(renderBar(limits.fiveHour.usedPercent, 10));
|
|
722
|
+
const bar7d = col7d(renderBar(limits.sevenDay.usedPercent, 10));
|
|
723
|
+
const bar30d = col30d(renderBar(limits.thirtyDay.usedPercent, 10));
|
|
724
|
+
|
|
725
|
+
const pct5h = col5h(`${limits.fiveHour.usedPercent}%`.padStart(4, " "));
|
|
726
|
+
const pct7d = col7d(`${limits.sevenDay.usedPercent}%`.padStart(4, " "));
|
|
727
|
+
const pct30d = col30d(`${limits.thirtyDay.usedPercent}%`.padStart(4, " "));
|
|
728
|
+
|
|
729
|
+
return [
|
|
730
|
+
CLEAR + c.bold("ClinePass Model Rates ($ / 1M tokens)"),
|
|
731
|
+
"",
|
|
732
|
+
c.bold("MODEL".padEnd(34) + "INPUT".padStart(9) + "OUTPUT".padStart(9) + "CACHE-R".padStart(9)),
|
|
733
|
+
"-".repeat(61),
|
|
734
|
+
...priceRows,
|
|
735
|
+
"-".repeat(61),
|
|
736
|
+
"",
|
|
737
|
+
`${c.bold(limits.planName ?? "Cline Pass")}${expiryInfo}`,
|
|
738
|
+
"-".repeat(71),
|
|
739
|
+
`5-Hour Limit (${formatLimitUsd(limits.fiveHour.limitUsd)}) : ${bar5h} ${pct5h}${reset5h}`,
|
|
740
|
+
`Weekly Limit (${formatLimitUsd(limits.sevenDay.limitUsd)}) : ${bar7d} ${pct7d}${reset7d}`,
|
|
741
|
+
`Monthly Limit (${formatLimitUsd(limits.thirtyDay.limitUsd)}) : ${bar30d} ${pct30d}${reset30d}`,
|
|
742
|
+
].join("\n");
|
|
743
|
+
}
|