usage-panel-opencode 0.0.0-stage → 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/README.md +70 -2
- package/package.json +56 -4
- package/src/config.ts +62 -0
- package/src/core/account.ts +74 -0
- package/src/core/backoff.ts +32 -0
- package/src/core/claims.ts +49 -0
- package/src/core/redact.ts +151 -0
- package/src/core/snapshot.ts +276 -0
- package/src/core/state.ts +42 -0
- package/src/core/usage-client.ts +168 -0
- package/src/core/usage-headers.ts +32 -0
- package/src/core/windows.ts +102 -0
- package/src/index.ts +761 -0
- package/src/rpc.ts +313 -0
- package/src/tui.tsx +158 -0
- package/src/ui/detail.tsx +253 -0
- package/src/ui/format.ts +13 -0
- package/src/ui/sidebar.tsx +228 -0
- package/src/ui/theme.ts +20 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SubscriptionAccount,
|
|
3
|
+
UsageSnapshot,
|
|
4
|
+
UsageWindow,
|
|
5
|
+
WindowAvailability,
|
|
6
|
+
WindowKind,
|
|
7
|
+
} from '../rpc';
|
|
8
|
+
import { maskAccountId, type RedactionOptions, redactText, sanitizeText } from './redact';
|
|
9
|
+
import { normalizeUsageWindow } from './windows';
|
|
10
|
+
|
|
11
|
+
export interface HeaderUsage {
|
|
12
|
+
primaryUsedPercent?: number;
|
|
13
|
+
secondaryUsedPercent?: number;
|
|
14
|
+
planType?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface NormalizeSnapshotInput {
|
|
18
|
+
account: SubscriptionAccount;
|
|
19
|
+
service?: unknown;
|
|
20
|
+
headers?: HeaderUsage | null;
|
|
21
|
+
fetchedAt?: number | null;
|
|
22
|
+
serviceFetchedAt?: number | null;
|
|
23
|
+
detail?: unknown;
|
|
24
|
+
sensitiveValues?: RedactionOptions['secrets'];
|
|
25
|
+
sensitiveAccountIds?: RedactionOptions['accountIds'];
|
|
26
|
+
now?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const WINDOW_ORDER: Record<WindowKind, number> = { five_hour: 0, weekly: 1, other: 2 };
|
|
30
|
+
const KNOWN_WINDOWS = ['five_hour', 'weekly'] as const;
|
|
31
|
+
|
|
32
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function safeAccount(
|
|
37
|
+
account: SubscriptionAccount,
|
|
38
|
+
redaction: Pick<RedactionOptions, 'secrets' | 'accountIds'>,
|
|
39
|
+
): SubscriptionAccount {
|
|
40
|
+
const rawAccountId = account.accountId;
|
|
41
|
+
const accountId =
|
|
42
|
+
typeof rawAccountId === 'string' && /^…[A-Za-z0-9_-]{4}$/.test(rawAccountId)
|
|
43
|
+
? rawAccountId
|
|
44
|
+
: maskAccountId(rawAccountId);
|
|
45
|
+
const label =
|
|
46
|
+
typeof account.label === 'string'
|
|
47
|
+
? sanitizeText(redactText(account.label, redaction), 100)
|
|
48
|
+
: account.label;
|
|
49
|
+
const planHint =
|
|
50
|
+
typeof account.planHint === 'string'
|
|
51
|
+
? redactText(account.planHint, { ...redaction, maxLength: 80 })
|
|
52
|
+
: account.planHint;
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
credentialKind: account.credentialKind,
|
|
56
|
+
...(label !== undefined ? { label } : {}),
|
|
57
|
+
...(accountId ? { accountId } : {}),
|
|
58
|
+
...(planHint !== undefined ? { planHint } : {}),
|
|
59
|
+
...(account.organization !== undefined ? { organization: account.organization } : {}),
|
|
60
|
+
...(account.expiresAt !== undefined ? { expiresAt: account.expiresAt } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function planInfo(
|
|
65
|
+
typeValue: unknown,
|
|
66
|
+
redaction: Pick<RedactionOptions, 'secrets' | 'accountIds'>,
|
|
67
|
+
): { type: string; label: string } | null {
|
|
68
|
+
if (typeof typeValue !== 'string') return null;
|
|
69
|
+
const type = redactText(typeValue, { ...redaction, maxLength: 80 });
|
|
70
|
+
if (!type) return null;
|
|
71
|
+
const normalized = type.toLowerCase();
|
|
72
|
+
const labels: Record<string, string> = {
|
|
73
|
+
plus: 'Plus',
|
|
74
|
+
pro: 'Pro',
|
|
75
|
+
prolite: 'Pro',
|
|
76
|
+
team: 'Business',
|
|
77
|
+
business: 'Business',
|
|
78
|
+
enterprise: 'Enterprise',
|
|
79
|
+
edu: 'Edu',
|
|
80
|
+
};
|
|
81
|
+
return { type, label: labels[normalized] ?? type };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function percentage(value: unknown): number | null {
|
|
85
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100
|
|
86
|
+
? value
|
|
87
|
+
: null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function availabilityOf(window: UsageWindow): WindowAvailability {
|
|
91
|
+
return window.availability ?? (window.applicable ? 'applicable' : 'not_applicable');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function unknownWindow(kind: 'five_hour' | 'weekly', source: 'service' | 'headers'): UsageWindow {
|
|
95
|
+
return {
|
|
96
|
+
kind,
|
|
97
|
+
label: kind === 'five_hour' ? '5 h' : 'Semanal',
|
|
98
|
+
applicable: false,
|
|
99
|
+
availability: 'unknown',
|
|
100
|
+
usedPercent: null,
|
|
101
|
+
remainingPercent: null,
|
|
102
|
+
resetsAt: null,
|
|
103
|
+
source,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function notApplicableWindow(kind: 'five_hour' | 'weekly'): UsageWindow {
|
|
108
|
+
return {
|
|
109
|
+
kind,
|
|
110
|
+
label: kind === 'five_hour' ? '5 h' : 'Semanal',
|
|
111
|
+
applicable: false,
|
|
112
|
+
availability: 'not_applicable',
|
|
113
|
+
usedPercent: null,
|
|
114
|
+
remainingPercent: null,
|
|
115
|
+
resetsAt: null,
|
|
116
|
+
source: 'service',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function headerWindow(kind: 'five_hour' | 'weekly', usedPercent: number | undefined): UsageWindow {
|
|
121
|
+
const used = percentage(usedPercent);
|
|
122
|
+
return {
|
|
123
|
+
kind,
|
|
124
|
+
label: kind === 'five_hour' ? '5 h' : 'Semanal',
|
|
125
|
+
applicable: used !== null,
|
|
126
|
+
availability: used === null ? 'unknown' : 'applicable',
|
|
127
|
+
usedPercent: used,
|
|
128
|
+
remainingPercent: used === null ? null : Math.round(100 - used),
|
|
129
|
+
resetsAt: null,
|
|
130
|
+
source: 'headers',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function mergeServiceDuplicate(previous: UsageWindow, candidate: UsageWindow): UsageWindow {
|
|
135
|
+
const preferred =
|
|
136
|
+
previous.usedPercent === null && candidate.usedPercent !== null ? candidate : previous;
|
|
137
|
+
return {
|
|
138
|
+
...preferred,
|
|
139
|
+
resetsAt: previous.resetsAt ?? candidate.resetsAt,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function mergeHeaderValue(existing: UsageWindow, header: UsageWindow): UsageWindow {
|
|
144
|
+
const existingAvailability = availabilityOf(existing);
|
|
145
|
+
const headerAvailability = availabilityOf(header);
|
|
146
|
+
|
|
147
|
+
// A complete service response is authoritative about a window that does not apply.
|
|
148
|
+
if (existingAvailability === 'not_applicable') return existing;
|
|
149
|
+
if (headerAvailability !== 'applicable') return existing;
|
|
150
|
+
|
|
151
|
+
// Header observations fill missing evidence but never replace a valid service value or reset.
|
|
152
|
+
if (existingAvailability === 'unknown') return header;
|
|
153
|
+
if (existing.usedPercent === null) {
|
|
154
|
+
return {
|
|
155
|
+
...existing,
|
|
156
|
+
applicable: true,
|
|
157
|
+
availability: 'applicable',
|
|
158
|
+
usedPercent: header.usedPercent,
|
|
159
|
+
remainingPercent: header.remainingPercent,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return existing;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Converts raw service data and/or allowlisted response headers to the stable public snapshot. */
|
|
166
|
+
export function normalizeSnapshot(input: NormalizeSnapshotInput): UsageSnapshot {
|
|
167
|
+
const service = isRecord(input.service) ? input.service : null;
|
|
168
|
+
const rateLimit = service && isRecord(service.rate_limit) ? service.rate_limit : null;
|
|
169
|
+
const redaction: Pick<RedactionOptions, 'secrets' | 'accountIds'> = {
|
|
170
|
+
secrets: input.sensitiveValues,
|
|
171
|
+
accountIds: input.sensitiveAccountIds,
|
|
172
|
+
};
|
|
173
|
+
const now = typeof input.now === 'number' && Number.isFinite(input.now) ? input.now : Date.now();
|
|
174
|
+
const serviceFetchedAt =
|
|
175
|
+
typeof input.serviceFetchedAt === 'number' &&
|
|
176
|
+
Number.isFinite(input.serviceFetchedAt) &&
|
|
177
|
+
input.serviceFetchedAt >= 0
|
|
178
|
+
? input.serviceFetchedAt
|
|
179
|
+
: typeof input.fetchedAt === 'number' &&
|
|
180
|
+
Number.isFinite(input.fetchedAt) &&
|
|
181
|
+
input.fetchedAt >= 0
|
|
182
|
+
? input.fetchedAt
|
|
183
|
+
: now;
|
|
184
|
+
const windows = new Map<WindowKind, UsageWindow>();
|
|
185
|
+
const serviceAvailable = Boolean(rateLimit || planInfo(service?.plan_type, redaction));
|
|
186
|
+
|
|
187
|
+
if (rateLimit) {
|
|
188
|
+
let hasUnclassifiedWindow = false;
|
|
189
|
+
for (const key of ['primary_window', 'secondary_window'] as const) {
|
|
190
|
+
const entry = rateLimit[key];
|
|
191
|
+
if (entry === null) continue;
|
|
192
|
+
if (entry === undefined) {
|
|
193
|
+
if (Object.hasOwn(rateLimit, key)) hasUnclassifiedWindow = true;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const normalized = normalizeUsageWindow(entry, 'service', { now: serviceFetchedAt });
|
|
197
|
+
hasUnclassifiedWindow ||= normalized.kind === 'other';
|
|
198
|
+
// A malformed/unknown window has no trustworthy identity; known windows remain unknown
|
|
199
|
+
// below instead of publishing a fabricated "other" quota or treating it as not applicable.
|
|
200
|
+
if (normalized.kind === 'other' && availabilityOf(normalized) === 'unknown') continue;
|
|
201
|
+
const existing = windows.get(normalized.kind);
|
|
202
|
+
windows.set(
|
|
203
|
+
normalized.kind,
|
|
204
|
+
existing ? mergeServiceDuplicate(existing, normalized) : normalized,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Missing fields in a partial payload are unknown. Only a complete pair of fields
|
|
209
|
+
// provides evidence that an omitted five-hour/weekly window is not applicable.
|
|
210
|
+
const complete =
|
|
211
|
+
Object.hasOwn(rateLimit, 'primary_window') && Object.hasOwn(rateLimit, 'secondary_window');
|
|
212
|
+
for (const kind of KNOWN_WINDOWS) {
|
|
213
|
+
if (windows.has(kind)) continue;
|
|
214
|
+
windows.set(
|
|
215
|
+
kind,
|
|
216
|
+
complete && !hasUnclassifiedWindow
|
|
217
|
+
? notApplicableWindow(kind)
|
|
218
|
+
: unknownWindow(kind, 'service'),
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
} else if (serviceAvailable) {
|
|
222
|
+
for (const kind of KNOWN_WINDOWS) windows.set(kind, unknownWindow(kind, 'service'));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const headers = input.headers;
|
|
226
|
+
let headersUsed = false;
|
|
227
|
+
if (headers) {
|
|
228
|
+
for (const [kind, value] of [
|
|
229
|
+
['five_hour', headers.primaryUsedPercent],
|
|
230
|
+
['weekly', headers.secondaryUsedPercent],
|
|
231
|
+
] as const) {
|
|
232
|
+
const candidate = headerWindow(kind, value);
|
|
233
|
+
const existing = windows.get(kind);
|
|
234
|
+
if (!existing) {
|
|
235
|
+
windows.set(kind, candidate);
|
|
236
|
+
headersUsed ||= availabilityOf(candidate) === 'applicable';
|
|
237
|
+
} else {
|
|
238
|
+
const merged = mergeHeaderValue(existing, candidate);
|
|
239
|
+
windows.set(kind, merged);
|
|
240
|
+
headersUsed ||= merged !== existing;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const servicePlan = planInfo(service?.plan_type, redaction);
|
|
246
|
+
const headerPlan = planInfo(headers?.planType, redaction);
|
|
247
|
+
const plan = servicePlan ?? headerPlan;
|
|
248
|
+
if (!servicePlan && headerPlan) headersUsed = true;
|
|
249
|
+
|
|
250
|
+
const orderedWindows = [...windows.values()].sort(
|
|
251
|
+
(left, right) => WINDOW_ORDER[left.kind] - WINDOW_ORDER[right.kind],
|
|
252
|
+
);
|
|
253
|
+
const originKind =
|
|
254
|
+
serviceAvailable && headersUsed
|
|
255
|
+
? 'mixed'
|
|
256
|
+
: serviceAvailable
|
|
257
|
+
? 'service'
|
|
258
|
+
: headersUsed
|
|
259
|
+
? 'headers'
|
|
260
|
+
: 'none';
|
|
261
|
+
const fetchedAt =
|
|
262
|
+
typeof input.fetchedAt === 'number' && Number.isFinite(input.fetchedAt) && input.fetchedAt >= 0
|
|
263
|
+
? input.fetchedAt
|
|
264
|
+
: null;
|
|
265
|
+
const detail =
|
|
266
|
+
typeof input.detail === 'string'
|
|
267
|
+
? redactText(input.detail, { ...redaction, maxLength: 160 }) || null
|
|
268
|
+
: null;
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
account: safeAccount(input.account, redaction),
|
|
272
|
+
plan,
|
|
273
|
+
windows: orderedWindows.slice(0, 8),
|
|
274
|
+
origin: { kind: originKind, fetchedAt, detail },
|
|
275
|
+
};
|
|
276
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { PanelError, PanelState, UsageSnapshot } from '../rpc';
|
|
2
|
+
|
|
3
|
+
export interface PanelRuntimeState {
|
|
4
|
+
state: PanelState;
|
|
5
|
+
snapshot: UsageSnapshot | null;
|
|
6
|
+
error: PanelError | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type PanelStateEvent =
|
|
10
|
+
| { type: 'success'; snapshot: UsageSnapshot }
|
|
11
|
+
| { type: 'failure'; error: PanelError }
|
|
12
|
+
| { type: 'no_subscription' }
|
|
13
|
+
| { type: 'needs_auth'; error?: PanelError }
|
|
14
|
+
| { type: 'account_changed' }
|
|
15
|
+
| { type: 'stop' };
|
|
16
|
+
|
|
17
|
+
export function initialPanelState(): PanelRuntimeState {
|
|
18
|
+
return { state: 'loading', snapshot: null, error: null };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function transitionPanelState(
|
|
22
|
+
current: PanelRuntimeState,
|
|
23
|
+
event: PanelStateEvent,
|
|
24
|
+
): PanelRuntimeState {
|
|
25
|
+
switch (event.type) {
|
|
26
|
+
case 'success':
|
|
27
|
+
return { state: 'ready', snapshot: event.snapshot, error: null };
|
|
28
|
+
case 'failure':
|
|
29
|
+
return current.snapshot
|
|
30
|
+
? { state: 'stale', snapshot: current.snapshot, error: event.error }
|
|
31
|
+
: { state: 'error', snapshot: null, error: event.error };
|
|
32
|
+
case 'no_subscription':
|
|
33
|
+
return { state: 'no_subscription', snapshot: null, error: null };
|
|
34
|
+
case 'needs_auth':
|
|
35
|
+
return current.snapshot
|
|
36
|
+
? { state: 'stale', snapshot: current.snapshot, error: event.error ?? null }
|
|
37
|
+
: { state: 'needs_auth', snapshot: null, error: event.error ?? null };
|
|
38
|
+
case 'account_changed':
|
|
39
|
+
case 'stop':
|
|
40
|
+
return initialPanelState();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { redactError } from './redact';
|
|
2
|
+
|
|
3
|
+
export type UsageClientErrorKind =
|
|
4
|
+
| 'network'
|
|
5
|
+
| 'rate_limited'
|
|
6
|
+
| 'unauthorized'
|
|
7
|
+
| 'forbidden'
|
|
8
|
+
| 'malformed'
|
|
9
|
+
| 'unknown';
|
|
10
|
+
|
|
11
|
+
export interface UsageClientError {
|
|
12
|
+
kind: UsageClientErrorKind;
|
|
13
|
+
message: string;
|
|
14
|
+
retryAfterMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UsagePayload {
|
|
18
|
+
plan_type?: string;
|
|
19
|
+
rate_limit?: {
|
|
20
|
+
primary_window?: Record<string, unknown> | null;
|
|
21
|
+
secondary_window?: Record<string, unknown> | null;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type UsageFetchResult =
|
|
26
|
+
| { ok: true; data: UsagePayload; fetchedAt: number }
|
|
27
|
+
| { ok: false; error: UsageClientError };
|
|
28
|
+
|
|
29
|
+
export interface FetchUsageOptions {
|
|
30
|
+
accessToken: string;
|
|
31
|
+
accountId?: string | null;
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
34
|
+
now?: () => number;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const USAGE_ENDPOINT = 'https://chatgpt.com/backend-api/wham/usage';
|
|
39
|
+
const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9_-]{4,64}$/;
|
|
40
|
+
const USER_AGENT = 'usage-panel-opencode/0.1.0';
|
|
41
|
+
|
|
42
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pickWindow(value: unknown): Record<string, unknown> | null {
|
|
47
|
+
if (value === null) return null;
|
|
48
|
+
// Preserve explicit null as a known non-applicable value. Invalid/non-object payloads become
|
|
49
|
+
// empty objects so the normalizer can mark their availability unknown instead.
|
|
50
|
+
if (!isRecord(value)) return {};
|
|
51
|
+
const output: Record<string, unknown> = {};
|
|
52
|
+
for (const key of ['used_percent', 'limit_window_seconds', 'reset_at', 'reset_after_seconds']) {
|
|
53
|
+
const field = value[key];
|
|
54
|
+
if (typeof field === 'number' && Number.isFinite(field)) output[key] = field;
|
|
55
|
+
}
|
|
56
|
+
return output;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function pickPayload(value: unknown): UsagePayload | null {
|
|
60
|
+
if (!isRecord(value)) return null;
|
|
61
|
+
if (typeof value.plan_type !== 'string' && !isRecord(value.rate_limit)) return null;
|
|
62
|
+
const payload: UsagePayload = {};
|
|
63
|
+
if (typeof value.plan_type === 'string') payload.plan_type = value.plan_type;
|
|
64
|
+
if (isRecord(value.rate_limit)) {
|
|
65
|
+
payload.rate_limit = {
|
|
66
|
+
...(Object.hasOwn(value.rate_limit, 'primary_window')
|
|
67
|
+
? { primary_window: pickWindow(value.rate_limit.primary_window) }
|
|
68
|
+
: {}),
|
|
69
|
+
...(Object.hasOwn(value.rate_limit, 'secondary_window')
|
|
70
|
+
? { secondary_window: pickWindow(value.rate_limit.secondary_window) }
|
|
71
|
+
: {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return payload;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function retryAfterMs(value: string | null, now: number): number | undefined {
|
|
78
|
+
if (!value) return undefined;
|
|
79
|
+
const seconds = Number(value.trim());
|
|
80
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
81
|
+
const timestamp = Date.parse(value);
|
|
82
|
+
return Number.isFinite(timestamp) ? Math.max(0, timestamp - now) : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function safeFailure(kind: UsageClientErrorKind, retryAfter?: number): UsageFetchResult {
|
|
86
|
+
const messages: Record<UsageClientErrorKind, string> = {
|
|
87
|
+
network: 'No se pudo consultar el consumo de la suscripción.',
|
|
88
|
+
rate_limited: 'OpenAI limitó temporalmente las consultas de consumo.',
|
|
89
|
+
unauthorized: 'La conexión de OpenAI necesita volver a autenticarse.',
|
|
90
|
+
forbidden: 'OpenAI no permitió consultar el consumo de esta cuenta.',
|
|
91
|
+
malformed: 'OpenAI devolvió datos de consumo con un formato no reconocido.',
|
|
92
|
+
unknown: 'No se pudo obtener el consumo de la suscripción.',
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: {
|
|
97
|
+
kind,
|
|
98
|
+
message: redactError(messages[kind]),
|
|
99
|
+
...(retryAfter !== undefined ? { retryAfterMs: retryAfter } : {}),
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Makes the sole permitted subscription request; response bodies and transport errors never escape raw. */
|
|
105
|
+
export async function fetchUsage(options: FetchUsageOptions): Promise<UsageFetchResult> {
|
|
106
|
+
if (!options.accessToken) return safeFailure('unauthorized');
|
|
107
|
+
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
const timeoutMs =
|
|
110
|
+
typeof options.timeoutMs === 'number' &&
|
|
111
|
+
Number.isFinite(options.timeoutMs) &&
|
|
112
|
+
options.timeoutMs > 0
|
|
113
|
+
? options.timeoutMs
|
|
114
|
+
: 10_000;
|
|
115
|
+
const now = options.now ?? Date.now;
|
|
116
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
117
|
+
const abortFromCaller = () => controller.abort(options.signal?.reason);
|
|
118
|
+
if (options.signal?.aborted) abortFromCaller();
|
|
119
|
+
else options.signal?.addEventListener('abort', abortFromCaller, { once: true });
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const headers = new Headers({
|
|
123
|
+
accept: 'application/json',
|
|
124
|
+
authorization: `Bearer ${options.accessToken}`,
|
|
125
|
+
'user-agent': USER_AGENT,
|
|
126
|
+
});
|
|
127
|
+
if (typeof options.accountId === 'string' && ACCOUNT_ID_PATTERN.test(options.accountId)) {
|
|
128
|
+
headers.set('chatgpt-account-id', options.accountId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const response = await (options.fetchImpl ?? fetch)(USAGE_ENDPOINT, {
|
|
132
|
+
method: 'GET',
|
|
133
|
+
// Never forward the bearer token or account id to a redirect destination.
|
|
134
|
+
redirect: 'error',
|
|
135
|
+
headers,
|
|
136
|
+
signal: controller.signal,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
const nowMs = now();
|
|
141
|
+
if (response.status === 401) return safeFailure('unauthorized');
|
|
142
|
+
if (response.status === 403) return safeFailure('forbidden');
|
|
143
|
+
if (response.status === 429) {
|
|
144
|
+
return safeFailure(
|
|
145
|
+
'rate_limited',
|
|
146
|
+
retryAfterMs(response.headers.get('retry-after'), nowMs),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (response.status >= 500) return safeFailure('network');
|
|
150
|
+
return safeFailure('unknown');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let body: unknown;
|
|
154
|
+
try {
|
|
155
|
+
body = await response.json();
|
|
156
|
+
} catch {
|
|
157
|
+
return safeFailure('malformed');
|
|
158
|
+
}
|
|
159
|
+
const data = pickPayload(body);
|
|
160
|
+
if (!data) return safeFailure('malformed');
|
|
161
|
+
return { ok: true, data, fetchedAt: now() };
|
|
162
|
+
} catch {
|
|
163
|
+
return safeFailure('network');
|
|
164
|
+
} finally {
|
|
165
|
+
clearTimeout(timeout);
|
|
166
|
+
options.signal?.removeEventListener('abort', abortFromCaller);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { sanitizeText } from './redact';
|
|
2
|
+
import type { HeaderUsage } from './snapshot';
|
|
3
|
+
|
|
4
|
+
type HeaderSource = Headers | Record<string, string | undefined> | null | undefined;
|
|
5
|
+
|
|
6
|
+
function getHeader(source: HeaderSource, name: string): string | null {
|
|
7
|
+
if (!source) return null;
|
|
8
|
+
if (source instanceof Headers) return source.get(name);
|
|
9
|
+
const match = Object.entries(source).find(([key]) => key.toLowerCase() === name);
|
|
10
|
+
return match?.[1] ?? null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readPercentage(value: string | null): number | undefined {
|
|
14
|
+
if (value === null || value.trim() === '') return undefined;
|
|
15
|
+
const parsed = Number(value.trim());
|
|
16
|
+
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 100 ? parsed : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Reads only the three documented x-codex-* response headers; request authorization is never inspected. */
|
|
20
|
+
export function readUsageHeaders(source: HeaderSource): HeaderUsage | null {
|
|
21
|
+
const primaryUsedPercent = readPercentage(getHeader(source, 'x-codex-primary-used-percent'));
|
|
22
|
+
const secondaryUsedPercent = readPercentage(getHeader(source, 'x-codex-secondary-used-percent'));
|
|
23
|
+
const rawPlan = getHeader(source, 'x-codex-plan-type');
|
|
24
|
+
const planType = rawPlan ? sanitizeText(rawPlan, 80) : '';
|
|
25
|
+
const usage: HeaderUsage = {
|
|
26
|
+
...(primaryUsedPercent !== undefined ? { primaryUsedPercent } : {}),
|
|
27
|
+
...(secondaryUsedPercent !== undefined ? { secondaryUsedPercent } : {}),
|
|
28
|
+
...(planType ? { planType } : {}),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return Object.keys(usage).length > 0 ? usage : null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { UsageWindow, WindowKind, WindowSource } from '../rpc';
|
|
2
|
+
|
|
3
|
+
export interface WindowNormalizationOptions {
|
|
4
|
+
now?: number;
|
|
5
|
+
kindHint?: WindowKind;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function numberField(record: Record<string, unknown>, key: string): number | null {
|
|
13
|
+
const value = record[key];
|
|
14
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function classifyWindow(durationSeconds: number): WindowKind {
|
|
18
|
+
if (durationSeconds === 18_000) return 'five_hour';
|
|
19
|
+
if (durationSeconds === 604_800) return 'weekly';
|
|
20
|
+
return 'other';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function durationLabel(durationSeconds: number): string {
|
|
24
|
+
if (durationSeconds % 86_400 === 0) return `${durationSeconds / 86_400} d`;
|
|
25
|
+
if (durationSeconds % 3_600 === 0) return `${durationSeconds / 3_600} h`;
|
|
26
|
+
if (durationSeconds % 60 === 0) return `${durationSeconds / 60} min`;
|
|
27
|
+
return `${durationSeconds} s`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function labelFor(kind: WindowKind, durationSeconds: number | null): string {
|
|
31
|
+
if (kind === 'five_hour') return '5 h';
|
|
32
|
+
if (kind === 'weekly') return 'Semanal';
|
|
33
|
+
return durationSeconds && durationSeconds > 0 ? durationLabel(durationSeconds) : 'Otra ventana';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resetTime(record: Record<string, unknown>, now: number): number | null {
|
|
37
|
+
const resetAt = numberField(record, 'reset_at');
|
|
38
|
+
if (resetAt !== null && resetAt >= 0) return resetAt < 100_000_000_000 ? resetAt * 1000 : resetAt;
|
|
39
|
+
|
|
40
|
+
const resetAfter = numberField(record, 'reset_after_seconds');
|
|
41
|
+
if (resetAfter !== null && resetAfter >= 0) return now + resetAfter * 1000;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function notApplicable(kind: WindowKind): UsageWindow {
|
|
46
|
+
return {
|
|
47
|
+
kind,
|
|
48
|
+
label: labelFor(kind, null),
|
|
49
|
+
applicable: false,
|
|
50
|
+
availability: 'not_applicable',
|
|
51
|
+
usedPercent: null,
|
|
52
|
+
remainingPercent: null,
|
|
53
|
+
resetsAt: null,
|
|
54
|
+
source: 'service',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function unknownWindow(source: WindowSource): UsageWindow {
|
|
59
|
+
return {
|
|
60
|
+
kind: 'other',
|
|
61
|
+
label: 'Otra ventana',
|
|
62
|
+
applicable: false,
|
|
63
|
+
availability: 'unknown',
|
|
64
|
+
usedPercent: null,
|
|
65
|
+
remainingPercent: null,
|
|
66
|
+
resetsAt: null,
|
|
67
|
+
source,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Normalizes one service window. An absent/null window is only classified when a kind hint is explicit. */
|
|
72
|
+
export function normalizeUsageWindow(
|
|
73
|
+
rawValue: unknown,
|
|
74
|
+
source: WindowSource,
|
|
75
|
+
options: WindowNormalizationOptions = {},
|
|
76
|
+
): UsageWindow {
|
|
77
|
+
if (rawValue === null) return notApplicable(options.kindHint ?? 'other');
|
|
78
|
+
if (rawValue === undefined) return unknownWindow(source);
|
|
79
|
+
|
|
80
|
+
if (!isRecord(rawValue)) return unknownWindow(source);
|
|
81
|
+
const raw = rawValue;
|
|
82
|
+
const duration = numberField(raw, 'limit_window_seconds');
|
|
83
|
+
if (duration === null) return unknownWindow(source);
|
|
84
|
+
if (duration !== null && duration <= 0) return notApplicable(options.kindHint ?? 'other');
|
|
85
|
+
|
|
86
|
+
const kind = duration !== null && duration > 0 ? classifyWindow(duration) : 'other';
|
|
87
|
+
const used = numberField(raw, 'used_percent');
|
|
88
|
+
const validUsed = used !== null && used >= 0 && used <= 100 ? used : null;
|
|
89
|
+
const now =
|
|
90
|
+
typeof options.now === 'number' && Number.isFinite(options.now) ? options.now : Date.now();
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
kind,
|
|
94
|
+
label: labelFor(kind, duration),
|
|
95
|
+
applicable: true,
|
|
96
|
+
availability: 'applicable',
|
|
97
|
+
usedPercent: validUsed,
|
|
98
|
+
remainingPercent: validUsed === null ? null : Math.round(100 - validUsed),
|
|
99
|
+
resetsAt: resetTime(raw, now),
|
|
100
|
+
source,
|
|
101
|
+
};
|
|
102
|
+
}
|