@genee/omp-opsx-addon 0.11.0 → 0.12.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/lib/cursor-auth.ts +34 -18
- package/lib/cursor-limit-status.ts +250 -0
- package/lib/model-selector.ts +5 -1
- package/lib/usage-poller.ts +10 -1
- package/lib/usage-render.ts +29 -0
- package/lib/usage-resolver.ts +22 -1
- package/package.json +1 -1
package/lib/cursor-auth.ts
CHANGED
|
@@ -84,30 +84,46 @@ export function isJwtExpired(token: string, nowMs: number = Date.now()): boolean
|
|
|
84
84
|
return exp * 1000 <= nowMs;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function queryTokensFromDb(db: Database): CursorTokenStore | null {
|
|
88
|
+
const row = (key: string): string | undefined => {
|
|
89
|
+
const r = db.query('SELECT value FROM ItemTable WHERE key = ?').get(key) as
|
|
90
|
+
| { value?: unknown }
|
|
91
|
+
| null
|
|
92
|
+
| undefined;
|
|
93
|
+
if (!r || r.value == null) return undefined;
|
|
94
|
+
const v = r.value;
|
|
95
|
+
return typeof v === 'string' ? v : Buffer.isBuffer(v) ? v.toString('utf8') : String(v);
|
|
96
|
+
};
|
|
97
|
+
const accessToken = row('cursorAuth/accessToken')?.trim() || undefined;
|
|
98
|
+
const refreshToken = row('cursorAuth/refreshToken')?.trim() || undefined;
|
|
99
|
+
const membershipType = row('cursorAuth/stripeMembershipType')?.trim() || undefined;
|
|
100
|
+
if (!accessToken && !refreshToken) return null;
|
|
101
|
+
return { accessToken, refreshToken, membershipType };
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
function defaultReadTokensFromDb(dbPath: string): CursorTokenStore | null {
|
|
88
|
-
|
|
89
|
-
|
|
105
|
+
// Read-only first. Bun's bundled SQLite defers the open to the first
|
|
106
|
+
// prepare, where a live Cursor DB (active WAL/locks on APFS) can raise
|
|
107
|
+
// SQLITE_CANTOPEN even though the file is readable — plain python3 and a
|
|
108
|
+
// default read-write handle both work. So a failed readonly pass falls
|
|
109
|
+
// back to the default open; the SHARED lock is only held for the three
|
|
110
|
+
// key reads before we close, same footprint as any external reader.
|
|
111
|
+
for (const opts of [{ readonly: true } as const, undefined]) {
|
|
112
|
+
let db: Database;
|
|
90
113
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
};
|
|
100
|
-
const accessToken = row('cursorAuth/accessToken')?.trim() || undefined;
|
|
101
|
-
const refreshToken = row('cursorAuth/refreshToken')?.trim() || undefined;
|
|
102
|
-
const membershipType = row('cursorAuth/stripeMembershipType')?.trim() || undefined;
|
|
103
|
-
if (!accessToken && !refreshToken) return null;
|
|
104
|
-
return { accessToken, refreshToken, membershipType };
|
|
114
|
+
db = new Database(dbPath, opts);
|
|
115
|
+
} catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return queryTokensFromDb(db);
|
|
120
|
+
} catch {
|
|
121
|
+
// Try the next open mode.
|
|
105
122
|
} finally {
|
|
106
123
|
db.close();
|
|
107
124
|
}
|
|
108
|
-
} catch {
|
|
109
|
-
return null;
|
|
110
125
|
}
|
|
126
|
+
return null;
|
|
111
127
|
}
|
|
112
128
|
|
|
113
129
|
function readStore(env: NodeJS.ProcessEnv = process.env): CursorTokenStore {
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor usage-limit / slow-pool status via Connect RPC
|
|
3
|
+
* (`GetUsageLimitStatusAndActiveGrants`), plus note encode/decode for
|
|
4
|
+
* attaching the parsed status to a `UsageReport` without extending pi-ai types.
|
|
5
|
+
*
|
|
6
|
+
* Fail-soft everywhere: field drift / network errors / missing token → null /
|
|
7
|
+
* undefined, never throw. Module-level TTL cache absorbs intermittent RPC
|
|
8
|
+
* failures so the widget does not flap exhausted↔warning.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { UsageReport } from '@oh-my-pi/pi-ai';
|
|
12
|
+
import { resolveCursorAccessToken } from './cursor-auth.js';
|
|
13
|
+
|
|
14
|
+
export const CURSOR_LIMIT_STATUS_URL =
|
|
15
|
+
'https://api2.cursor.sh/aiserver.v1.DashboardService/GetUsageLimitStatusAndActiveGrants';
|
|
16
|
+
|
|
17
|
+
export const LIMIT_STATUS_TTL_MS = 5 * 60_000;
|
|
18
|
+
export const LIMIT_STATUS_TIMEOUT_MS = 5000;
|
|
19
|
+
|
|
20
|
+
export const CURSOR_LIMIT_STATUS_NOTE_PREFIX = 'cursorLimitStatus=';
|
|
21
|
+
|
|
22
|
+
export interface CursorLimitStatus {
|
|
23
|
+
isInSlowPool: boolean;
|
|
24
|
+
stage?: string;
|
|
25
|
+
slownessMs?: number;
|
|
26
|
+
grantsRemainingCents: number;
|
|
27
|
+
grantsExpiresAtMs?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FetchCursorLimitStatusOpts {
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
fetchImpl?: typeof fetch;
|
|
33
|
+
resolveCursorToken?: () => Promise<{ accessToken: string } | null>;
|
|
34
|
+
nowMs?: () => number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type CacheEntry = { status: CursorLimitStatus; atMs: number };
|
|
38
|
+
|
|
39
|
+
let cache: CacheEntry | null = null;
|
|
40
|
+
|
|
41
|
+
/** Module-level seam defaults (opts override these). Cleared by `_resetLimitStatusCacheForTest`. */
|
|
42
|
+
let seamFetchImpl: typeof fetch | undefined;
|
|
43
|
+
let seamResolveCursorToken: (() => Promise<{ accessToken: string } | null>) | undefined;
|
|
44
|
+
let seamNowMs: (() => number) | undefined;
|
|
45
|
+
|
|
46
|
+
export function _setLimitStatusSeamsForTest(seams: {
|
|
47
|
+
fetchImpl?: typeof fetch;
|
|
48
|
+
resolveCursorToken?: () => Promise<{ accessToken: string } | null>;
|
|
49
|
+
nowMs?: () => number;
|
|
50
|
+
} | null): void {
|
|
51
|
+
if (!seams) {
|
|
52
|
+
seamFetchImpl = undefined;
|
|
53
|
+
seamResolveCursorToken = undefined;
|
|
54
|
+
seamNowMs = undefined;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if ('fetchImpl' in seams) seamFetchImpl = seams.fetchImpl;
|
|
58
|
+
if ('resolveCursorToken' in seams) seamResolveCursorToken = seams.resolveCursorToken;
|
|
59
|
+
if ('nowMs' in seams) seamNowMs = seams.nowMs;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function _resetLimitStatusCacheForTest(): void {
|
|
63
|
+
cache = null;
|
|
64
|
+
seamFetchImpl = undefined;
|
|
65
|
+
seamResolveCursorToken = undefined;
|
|
66
|
+
seamNowMs = undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Defensive parse of the Connect JSON body. Returns null on any structural
|
|
71
|
+
* drift (missing policy status, non-boolean isInSlowPool, non-object body).
|
|
72
|
+
* Invalid grant entries are skipped; cents are summed from valid ones.
|
|
73
|
+
*/
|
|
74
|
+
export function parseCursorLimitStatusResponse(data: unknown): CursorLimitStatus | null {
|
|
75
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) return null;
|
|
76
|
+
const root = data as Record<string, unknown>;
|
|
77
|
+
const policy = root.usageLimitPolicyStatus;
|
|
78
|
+
if (policy === null || typeof policy !== 'object' || Array.isArray(policy)) return null;
|
|
79
|
+
const pol = policy as Record<string, unknown>;
|
|
80
|
+
if (typeof pol.isInSlowPool !== 'boolean') return null;
|
|
81
|
+
|
|
82
|
+
const stage = typeof pol.stage === 'string' ? pol.stage : undefined;
|
|
83
|
+
const slownessMs =
|
|
84
|
+
typeof pol.slownessMs === 'number' && Number.isFinite(pol.slownessMs) ? pol.slownessMs : undefined;
|
|
85
|
+
|
|
86
|
+
let grantsRemainingCents = 0;
|
|
87
|
+
let grantsExpiresAtMs: number | undefined;
|
|
88
|
+
const grants = root.activeGrants;
|
|
89
|
+
if (Array.isArray(grants)) {
|
|
90
|
+
for (const entry of grants) {
|
|
91
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
92
|
+
const g = entry as Record<string, unknown>;
|
|
93
|
+
const raw = g.remainingCents;
|
|
94
|
+
if (typeof raw !== 'string') continue;
|
|
95
|
+
// Integer cents string only — reject floats / signs / whitespace-padded junk.
|
|
96
|
+
if (!/^\d+$/.test(raw)) continue;
|
|
97
|
+
const cents = Number(raw);
|
|
98
|
+
if (!Number.isSafeInteger(cents) || cents < 0) continue;
|
|
99
|
+
grantsRemainingCents += cents;
|
|
100
|
+
if (cents > 0) {
|
|
101
|
+
const expRaw = g.expiresAtMs;
|
|
102
|
+
const exp =
|
|
103
|
+
typeof expRaw === 'string' && /^\d+$/.test(expRaw)
|
|
104
|
+
? Number(expRaw)
|
|
105
|
+
: typeof expRaw === 'number'
|
|
106
|
+
? expRaw
|
|
107
|
+
: NaN;
|
|
108
|
+
if (Number.isFinite(exp) && Number.isSafeInteger(exp)) {
|
|
109
|
+
grantsExpiresAtMs =
|
|
110
|
+
grantsExpiresAtMs === undefined ? exp : Math.max(grantsExpiresAtMs, exp);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} else if (grants !== undefined) {
|
|
115
|
+
// Present but not an array → drift; treat whole parse as unknown.
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const out: CursorLimitStatus = {
|
|
120
|
+
isInSlowPool: pol.isInSlowPool,
|
|
121
|
+
grantsRemainingCents,
|
|
122
|
+
};
|
|
123
|
+
if (stage !== undefined) out.stage = stage;
|
|
124
|
+
if (slownessMs !== undefined) out.slownessMs = slownessMs;
|
|
125
|
+
if (grantsExpiresAtMs !== undefined) out.grantsExpiresAtMs = grantsExpiresAtMs;
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Encode status into the structured note (omit absent optional fields). */
|
|
130
|
+
export function encodeCursorLimitStatusNote(status: CursorLimitStatus): string {
|
|
131
|
+
const parts: string[] = [`isInSlowPool:${status.isInSlowPool ? 1 : 0}`];
|
|
132
|
+
if (status.stage !== undefined) parts.push(`stage:${status.stage}`);
|
|
133
|
+
if (status.slownessMs !== undefined) parts.push(`slownessMs:${status.slownessMs}`);
|
|
134
|
+
parts.push(`grantsCents:${status.grantsRemainingCents}`);
|
|
135
|
+
if (status.grantsExpiresAtMs !== undefined) parts.push(`grantsExpiryMs:${status.grantsExpiresAtMs}`);
|
|
136
|
+
return CURSOR_LIMIT_STATUS_NOTE_PREFIX + parts.join(';');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Tolerant note parse: unknown fields ignored, truncated/malformed note →
|
|
141
|
+
* undefined (never throws). Requires at least a recognizable isInSlowPool bit.
|
|
142
|
+
*/
|
|
143
|
+
export function parseCursorLimitStatusNote(notes?: string[]): CursorLimitStatus | undefined {
|
|
144
|
+
if (!notes) return undefined;
|
|
145
|
+
for (const n of notes) {
|
|
146
|
+
if (!n.startsWith(CURSOR_LIMIT_STATUS_NOTE_PREFIX)) continue;
|
|
147
|
+
const body = n.slice(CURSOR_LIMIT_STATUS_NOTE_PREFIX.length);
|
|
148
|
+
if (!body) return undefined;
|
|
149
|
+
const fields = new Map<string, string>();
|
|
150
|
+
for (const part of body.split(';')) {
|
|
151
|
+
if (!part) continue;
|
|
152
|
+
const colon = part.indexOf(':');
|
|
153
|
+
if (colon <= 0) continue;
|
|
154
|
+
fields.set(part.slice(0, colon), part.slice(colon + 1));
|
|
155
|
+
}
|
|
156
|
+
const slowRaw = fields.get('isInSlowPool');
|
|
157
|
+
if (slowRaw !== '0' && slowRaw !== '1') return undefined;
|
|
158
|
+
const grantsRaw = fields.get('grantsCents');
|
|
159
|
+
let grantsRemainingCents = 0;
|
|
160
|
+
if (grantsRaw !== undefined) {
|
|
161
|
+
if (!/^\d+$/.test(grantsRaw)) return undefined;
|
|
162
|
+
const g = Number(grantsRaw);
|
|
163
|
+
if (!Number.isSafeInteger(g) || g < 0) return undefined;
|
|
164
|
+
grantsRemainingCents = g;
|
|
165
|
+
}
|
|
166
|
+
const out: CursorLimitStatus = {
|
|
167
|
+
isInSlowPool: slowRaw === '1',
|
|
168
|
+
grantsRemainingCents,
|
|
169
|
+
};
|
|
170
|
+
const stage = fields.get('stage');
|
|
171
|
+
if (stage !== undefined && stage.length > 0) out.stage = stage;
|
|
172
|
+
const slowMs = fields.get('slownessMs');
|
|
173
|
+
if (slowMs !== undefined && /^-?\d+(\.\d+)?$/.test(slowMs)) {
|
|
174
|
+
const v = Number(slowMs);
|
|
175
|
+
if (Number.isFinite(v)) out.slownessMs = v;
|
|
176
|
+
}
|
|
177
|
+
const exp = fields.get('grantsExpiryMs');
|
|
178
|
+
if (exp !== undefined && /^\d+$/.test(exp)) {
|
|
179
|
+
const v = Number(exp);
|
|
180
|
+
if (Number.isSafeInteger(v)) out.grantsExpiresAtMs = v;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Return a new report whose notes carry the encoded status. Replaces any
|
|
189
|
+
* existing `cursorLimitStatus=` note; preserves all other notes. Idempotent.
|
|
190
|
+
*/
|
|
191
|
+
export function enhanceCursorReport(report: UsageReport, status: CursorLimitStatus): UsageReport {
|
|
192
|
+
const note = encodeCursorLimitStatusNote(status);
|
|
193
|
+
const prev = report.notes ?? [];
|
|
194
|
+
const kept = prev.filter((n) => !n.startsWith(CURSOR_LIMIT_STATUS_NOTE_PREFIX));
|
|
195
|
+
return { ...report, notes: [...kept, note] };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Fetch + parse Cursor limit status. Fail-soft: network / non-OK / no token /
|
|
200
|
+
* parse failure → null (or cached value within TTL). Success overwrites cache.
|
|
201
|
+
*/
|
|
202
|
+
export async function fetchCursorLimitStatus(
|
|
203
|
+
opts?: FetchCursorLimitStatusOpts,
|
|
204
|
+
): Promise<CursorLimitStatus | null> {
|
|
205
|
+
const now = (opts?.nowMs ?? seamNowMs ?? Date.now)();
|
|
206
|
+
const fetchFn = opts?.fetchImpl ?? seamFetchImpl ?? fetch;
|
|
207
|
+
const resolveToken =
|
|
208
|
+
opts?.resolveCursorToken ??
|
|
209
|
+
seamResolveCursorToken ??
|
|
210
|
+
(async () => {
|
|
211
|
+
const resolved = await resolveCursorAccessToken();
|
|
212
|
+
return resolved?.accessToken ? { accessToken: resolved.accessToken } : null;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const auth = await resolveToken();
|
|
217
|
+
if (!auth?.accessToken) {
|
|
218
|
+
return softFail(now);
|
|
219
|
+
}
|
|
220
|
+
const res = await fetchFn(CURSOR_LIMIT_STATUS_URL, {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
headers: {
|
|
223
|
+
'content-type': 'application/json',
|
|
224
|
+
authorization: `Bearer ${auth.accessToken}`,
|
|
225
|
+
'Connect-Protocol-Version': '1',
|
|
226
|
+
'x-cursor-client-version': '0.1.0',
|
|
227
|
+
},
|
|
228
|
+
body: JSON.stringify({}),
|
|
229
|
+
signal: AbortSignal.timeout(opts?.timeoutMs ?? LIMIT_STATUS_TIMEOUT_MS),
|
|
230
|
+
});
|
|
231
|
+
if (!res.ok) return softFail(now);
|
|
232
|
+
let data: unknown;
|
|
233
|
+
try {
|
|
234
|
+
data = await res.json();
|
|
235
|
+
} catch {
|
|
236
|
+
return softFail(now);
|
|
237
|
+
}
|
|
238
|
+
const parsed = parseCursorLimitStatusResponse(data);
|
|
239
|
+
if (!parsed) return softFail(now);
|
|
240
|
+
cache = { status: parsed, atMs: now };
|
|
241
|
+
return parsed;
|
|
242
|
+
} catch {
|
|
243
|
+
return softFail(now);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function softFail(now: number): CursorLimitStatus | null {
|
|
248
|
+
if (cache && now - cache.atMs < LIMIT_STATUS_TTL_MS) return cache.status;
|
|
249
|
+
return null;
|
|
250
|
+
}
|
package/lib/model-selector.ts
CHANGED
|
@@ -130,7 +130,11 @@ export function candidateQuota(
|
|
|
130
130
|
const win = health.windows.find((w) => w.id === bucket) ?? health.windows.find((w) => w.id === 'other');
|
|
131
131
|
const ondemand = health.windows.find((w) => w.id === 'ondemand');
|
|
132
132
|
if (!win) {
|
|
133
|
-
|
|
133
|
+
// D6: provider-level exhaustion was relaxed for slow-pool cursor; the
|
|
134
|
+
// !win fallback must NOT get more permissive than pre-change behavior.
|
|
135
|
+
const cls = health.cursorLimitStatus;
|
|
136
|
+
const slowPoolAlive = !!cls && (cls.isInSlowPool || cls.grantsRemainingCents > 0);
|
|
137
|
+
return { exhausted: health.exhausted || slowPoolAlive, remainingFraction: health.worstRemainingFraction };
|
|
134
138
|
}
|
|
135
139
|
if (win.exhausted) {
|
|
136
140
|
if (ondemand && !ondemand.exhausted) {
|
package/lib/usage-poller.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { homedir } from 'os';
|
|
|
56
56
|
import type { UsageReport } from '@oh-my-pi/pi-ai';
|
|
57
57
|
import type { AuthStorageLike, ProviderHealth, DirectFetcher, ApiKeyResolver } from './usage-resolver.js';
|
|
58
58
|
import { resolveProviderReports, buildHealthMap, canonicalizeProvider, listDetectableProviders } from './usage-resolver.js';
|
|
59
|
+
import { fetchCursorLimitStatus, enhanceCursorReport } from './cursor-limit-status.js';
|
|
59
60
|
import { RedisRelay } from './usage-redis-probe.js';
|
|
60
61
|
import type { RedisTransport } from './usage-redis-client.js';
|
|
61
62
|
import type { UsageBucket } from './usage-estimator.js';
|
|
@@ -979,8 +980,16 @@ async function fetchProvider(
|
|
|
979
980
|
lastFetchedByProvider.set(canonical, fetchedAt);
|
|
980
981
|
// Replace (or insert) just this provider's report; keep all others.
|
|
981
982
|
const byId = new Map(cachedReports.map((r) => [r.provider, r]));
|
|
982
|
-
|
|
983
|
+
let newReport = reports.find((r) => r.provider === canonical);
|
|
983
984
|
if (newReport) {
|
|
985
|
+
// Cursor slow-pool/grants enhancement (D1): rides the existing fetch
|
|
986
|
+
// cadence; failure is fail-soft inside fetchCursorLimitStatus and must
|
|
987
|
+
// NOT re-mark dirty (the outer catch only guards the report fetch).
|
|
988
|
+
// On null the report lands as-is — current behavior (exhausted).
|
|
989
|
+
if (canonical === 'cursor') {
|
|
990
|
+
const limitStatus = await fetchCursorLimitStatus();
|
|
991
|
+
if (limitStatus) newReport = enhanceCursorReport(newReport, limitStatus);
|
|
992
|
+
}
|
|
984
993
|
byId.set(canonical, newReport);
|
|
985
994
|
// Fresh report landed → the provider's health is CONFIRMED.
|
|
986
995
|
unconfirmedProviders.delete(canonical);
|
package/lib/usage-render.ts
CHANGED
|
@@ -42,6 +42,8 @@ export interface Painter {
|
|
|
42
42
|
pct(p: number): string;
|
|
43
43
|
/** Exhausted-window marker text, error-colored. */
|
|
44
44
|
exhausted(text: string): string;
|
|
45
|
+
/** Warning / soft-degraded marker (e.g. Cursor ⚠降速), warning-colored. */
|
|
46
|
+
warning(text: string): string;
|
|
45
47
|
/** Inter-column separator, already dyed incl. flanking spaces (3 cells). */
|
|
46
48
|
sep: string;
|
|
47
49
|
/** Provider header. `brandAnsi` is a raw brand-color SGR (or "" when none). */
|
|
@@ -101,6 +103,7 @@ export const ansiPainter: Painter = {
|
|
|
101
103
|
return c(col === 'error' ? X : col === 'warning' ? Y : G, pctValue(p));
|
|
102
104
|
},
|
|
103
105
|
exhausted: (text) => c(X, text),
|
|
106
|
+
warning: (text) => c(Y, text),
|
|
104
107
|
sep: ` ${D}│${R} `,
|
|
105
108
|
header: (label, brandAnsi) => c(brandAnsi ? BOLD + brandAnsi : BOLD, label),
|
|
106
109
|
balance: (text) => c(WHITE, text),
|
|
@@ -113,6 +116,7 @@ export function themePainter(theme: PainterTheme): Painter {
|
|
|
113
116
|
return {
|
|
114
117
|
pct: (p) => theme.fg(pctColor(p), pctValue(p)),
|
|
115
118
|
exhausted: (text) => theme.fg('error', text),
|
|
119
|
+
warning: (text) => theme.fg('warning', text),
|
|
116
120
|
sep: ` ${theme.fg('dim', '│')} `,
|
|
117
121
|
header: (label, brandAnsi) => (brandAnsi ? c(brandAnsi, label) : theme.fg('accent', label)),
|
|
118
122
|
balance: (text) => theme.fg('text', text),
|
|
@@ -135,6 +139,7 @@ const PROVIDER: Record<string, { label: string; color: string }> = {
|
|
|
135
139
|
'kimi-code': { label: 'Kimi', color: CYAN },
|
|
136
140
|
'minimax-code-cn': { label: 'MiniMax', color: X },
|
|
137
141
|
'ark-coding-plan': { label: '火山方舟', color: CYAN },
|
|
142
|
+
'alibaba-token-plan': { label: '千问云', color: PURPLE },
|
|
138
143
|
deepseek: { label: 'DeepSeek', color: BLUE },
|
|
139
144
|
cursor: { label: 'Cursor', color: CYAN },
|
|
140
145
|
'opencode-go': { label: 'OpenCode', color: G },
|
|
@@ -349,6 +354,30 @@ export const buildColumn = (r: UsageReport, painter: Painter = ansiPainter, load
|
|
|
349
354
|
// column visible rather than dropping a logged-in provider.
|
|
350
355
|
return isCursor ? [header, painter.balance('—')] : undefined;
|
|
351
356
|
}
|
|
357
|
+
// Cursor slow-pool / grants markers (provider not exhausted — chips path).
|
|
358
|
+
// Status lives on its own line: bucket chips first, then ⚠降速 / Grants /
|
|
359
|
+
// reset countdown. No cursorLimitStatus note → single chips line, exactly
|
|
360
|
+
// as before the change.
|
|
361
|
+
if (isCursor && health.cursorLimitStatus) {
|
|
362
|
+
const cls = health.cursorLimitStatus;
|
|
363
|
+
const status: string[] = [];
|
|
364
|
+
if (cls.isInSlowPool) status.push(painter.warning('⚠降速'));
|
|
365
|
+
if (cls.grantsRemainingCents > 0) {
|
|
366
|
+
status.push(painter.balance(`Grants $${(cls.grantsRemainingCents / 100).toFixed(2)}`));
|
|
367
|
+
}
|
|
368
|
+
// Included buckets reset with the billing period — surface the soonest
|
|
369
|
+
// reset so a slow-pooled user can see when quota revives. Reuses the
|
|
370
|
+
// existing exhausted-path formatter; hidden when no resetsAt.
|
|
371
|
+
let soonest: number | undefined;
|
|
372
|
+
for (const l of r.limits) {
|
|
373
|
+
const at = l.window?.resetsAt;
|
|
374
|
+
if (typeof at !== 'number') continue;
|
|
375
|
+
soonest = soonest === undefined ? at : Math.min(soonest, at);
|
|
376
|
+
}
|
|
377
|
+
if (soonest !== undefined) status.push(`${resetIn(soonest)}重置`);
|
|
378
|
+
if (status.length === 0) return [header, parts.join(' · ')];
|
|
379
|
+
return [header, parts.join(' · '), status.join(' · ')];
|
|
380
|
+
}
|
|
352
381
|
return [header, parts.join(' · ')];
|
|
353
382
|
};
|
|
354
383
|
|
package/lib/usage-resolver.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { UsageReport, UsageStatus } from '@oh-my-pi/pi-ai';
|
|
16
|
+
import { parseCursorLimitStatusNote, type CursorLimitStatus } from './cursor-limit-status.js';
|
|
16
17
|
|
|
17
18
|
export type ApiKeyResolver = (provider: string) => Promise<string | undefined>;
|
|
18
19
|
|
|
@@ -100,6 +101,8 @@ export interface ProviderHealth {
|
|
|
100
101
|
exhaustedWindows: string[];
|
|
101
102
|
fetchedAt: number;
|
|
102
103
|
notes?: string[];
|
|
104
|
+
/** Cursor slow-pool / grants echo (from `cursorLimitStatus=` note); render + selector consume this. */
|
|
105
|
+
cursorLimitStatus?: CursorLimitStatus;
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
const HEALTHY_REMAINING_THRESHOLD = 0.1;
|
|
@@ -262,6 +265,8 @@ export function summarizeReport(report: UsageReport): ProviderHealth {
|
|
|
262
265
|
|
|
263
266
|
let exhausted = false;
|
|
264
267
|
let worstRemaining: number | undefined;
|
|
268
|
+
const limitStatus =
|
|
269
|
+
report.provider === 'cursor' ? parseCursorLimitStatusNote(report.notes) : undefined;
|
|
265
270
|
|
|
266
271
|
if (policy === 'bucket') {
|
|
267
272
|
const byId = new Map(windows.map((w) => [w.id, w]));
|
|
@@ -277,6 +282,17 @@ export function summarizeReport(report: UsageReport): ProviderHealth {
|
|
|
277
282
|
if (w.remaining === undefined) continue;
|
|
278
283
|
if (worstRemaining === undefined || w.remaining > worstRemaining) worstRemaining = w.remaining;
|
|
279
284
|
}
|
|
285
|
+
// D3: slow-pool / grants keep provider usable (warning) instead of exhausted.
|
|
286
|
+
if (
|
|
287
|
+
exhausted &&
|
|
288
|
+
limitStatus &&
|
|
289
|
+
(limitStatus.isInSlowPool || limitStatus.grantsRemainingCents > 0)
|
|
290
|
+
) {
|
|
291
|
+
exhausted = false;
|
|
292
|
+
status = 'warning';
|
|
293
|
+
} else if (!exhausted && limitStatus?.isInSlowPool && status === 'ok') {
|
|
294
|
+
status = 'warning';
|
|
295
|
+
}
|
|
280
296
|
} else if (policy === 'balance') {
|
|
281
297
|
const win = windows[0];
|
|
282
298
|
exhausted = windows.some((w) => w.exhausted) || (win?.remaining !== undefined && win.remaining <= 0);
|
|
@@ -315,6 +331,7 @@ export function summarizeReport(report: UsageReport): ProviderHealth {
|
|
|
315
331
|
exhaustedWindows,
|
|
316
332
|
fetchedAt: report.fetchedAt,
|
|
317
333
|
notes: report.notes,
|
|
334
|
+
cursorLimitStatus: limitStatus,
|
|
318
335
|
};
|
|
319
336
|
}
|
|
320
337
|
|
|
@@ -327,7 +344,11 @@ export function healthCacheKey(health: Map<string, ProviderHealth> | null | unde
|
|
|
327
344
|
const h = health.get(p)!;
|
|
328
345
|
const wins = h.windows.map((w) => `${w.id}:${w.exhausted ? 1 : 0}:${w.remaining ?? ''}`).join(',');
|
|
329
346
|
const ab = (h.autoBucketModels ?? []).join('+');
|
|
330
|
-
|
|
347
|
+
// Cursor only: boolean bits (not raw cents) so grants churn does not invalidate selection cache.
|
|
348
|
+
const cls = h.cursorLimitStatus
|
|
349
|
+
? `;sp=${h.cursorLimitStatus.isInSlowPool ? 1 : 0};g=${h.cursorLimitStatus.grantsRemainingCents > 0 ? 1 : 0}`
|
|
350
|
+
: '';
|
|
351
|
+
return `${p}=${h.exhausted ? 1 : 0};${wins};${ab}${cls}`;
|
|
331
352
|
})
|
|
332
353
|
.join('|');
|
|
333
354
|
}
|
package/package.json
CHANGED