dondo-donuts 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.
@@ -0,0 +1,84 @@
1
+ import { rm } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { ANTIGRAVITY_ACCOUNT, ANTIGRAVITY_KEYCHAIN, ANTIGRAVITY_SERVICE } from '../config.ts';
5
+ import { run } from '../shell.ts';
6
+ import type { Snapshot } from '../types.ts';
7
+
8
+ export const parsePassword = (stderr: string) => {
9
+ const match = stderr.match(/password: "((?:\\"|[^"])*)"/);
10
+ if (!match) {
11
+ throw new Error('Could not read password from security output');
12
+ }
13
+ return match[1]?.replace(/\\"/g, '"') ?? '';
14
+ };
15
+
16
+ const keychainArgs = () => {
17
+ return ANTIGRAVITY_KEYCHAIN ? [ANTIGRAVITY_KEYCHAIN] : [];
18
+ };
19
+
20
+ const deleteLivePassword = async () => {
21
+ await run('security', [
22
+ 'delete-generic-password',
23
+ '-s',
24
+ ANTIGRAVITY_SERVICE,
25
+ '-a',
26
+ ANTIGRAVITY_ACCOUNT,
27
+ ...keychainArgs(),
28
+ ]).catch(() => {});
29
+ };
30
+
31
+ export const readCurrentSnapshot = async (): Promise<Snapshot> => {
32
+ const { stderr } = await run('security', [
33
+ 'find-generic-password',
34
+ '-s',
35
+ ANTIGRAVITY_SERVICE,
36
+ '-a',
37
+ ANTIGRAVITY_ACCOUNT,
38
+ '-g',
39
+ ...keychainArgs(),
40
+ ]);
41
+ const now = new Date().toISOString();
42
+ return {
43
+ account: ANTIGRAVITY_ACCOUNT,
44
+ createdAt: now,
45
+ kind: 'Generic Password',
46
+ label: stderr.match(/"labl"<blob>="([^"]*)"/)?.[1] ?? ANTIGRAVITY_SERVICE,
47
+ password: parsePassword(stderr),
48
+ service: ANTIGRAVITY_SERVICE,
49
+ updatedAt: now,
50
+ };
51
+ };
52
+
53
+ export const restoreSnapshot = async (snap: Snapshot) => {
54
+ await deleteLivePassword();
55
+ await run('security', [
56
+ 'add-generic-password',
57
+ '-s',
58
+ snap.service,
59
+ '-a',
60
+ snap.account,
61
+ '-l',
62
+ snap.label,
63
+ '-D',
64
+ snap.kind,
65
+ '-w',
66
+ snap.password,
67
+ '-U',
68
+ ...keychainArgs(),
69
+ ]);
70
+ };
71
+
72
+ export const clearLiveAuth = async () => {
73
+ await deleteLivePassword();
74
+ const home = homedir();
75
+ await Promise.all(
76
+ [
77
+ join(home, '.antigravity-agent', 'cloud_accounts.db'),
78
+ join(home, '.gemini', 'antigravity'),
79
+ join(home, '.gemini', 'antigravity-ide'),
80
+ join(home, '.gemini', 'antigravity-backup'),
81
+ join(home, 'Library', 'Application Support', 'Antigravity'),
82
+ ].map((path) => rm(path, { force: true, recursive: true })),
83
+ );
84
+ };
@@ -0,0 +1,111 @@
1
+ import { ANTIGRAVITY_ACCOUNT, ANTIGRAVITY_SERVICE, VAULT_PATH } from '../config.ts';
2
+ import { assertAccountKey, cleanLimitError, publicError } from '../errors.ts';
3
+ import { readVault, updateVault } from '../storage/vault.ts';
4
+ import type { AppVault, LimitResult, Snapshot } from '../types.ts';
5
+ import { decodeToken, fetchLimits } from './google.ts';
6
+ import { clearLiveAuth, readCurrentSnapshot, restoreSnapshot } from './keychain.ts';
7
+
8
+ const isSameSnapshot = (a: Snapshot | null, b: Snapshot) => {
9
+ if (a?.service !== b.service || a.account !== b.account) {
10
+ return false;
11
+ }
12
+ const aRefresh = decodeToken(a.password)?.token?.refresh_token;
13
+ const bRefresh = decodeToken(b.password)?.token?.refresh_token;
14
+ if (aRefresh || bRefresh) {
15
+ return aRefresh === bRefresh;
16
+ }
17
+ return a.password === b.password;
18
+ };
19
+
20
+ const hasNoUsageLeft = (quota: LimitResult | null) => {
21
+ const limits = quota?.ok === true ? Object.values(quota.models) : [];
22
+ return limits.length > 0 && limits.every((model) => model.percentage <= 0);
23
+ };
24
+
25
+ const sortEntries = <T extends { active: boolean; key: string; quota: LimitResult | null }>(entries: T[]) => {
26
+ return [...entries].sort((a, b) => {
27
+ if (a.active !== b.active) {
28
+ return a.active ? -1 : 1;
29
+ }
30
+ if (hasNoUsageLeft(a.quota) !== hasNoUsageLeft(b.quota)) {
31
+ return hasNoUsageLeft(a.quota) ? 1 : -1;
32
+ }
33
+ return a.key.localeCompare(b.key);
34
+ });
35
+ };
36
+
37
+ const updateMissingOrStaleLimits = async (vault: AppVault, force: boolean, targetKey?: string) => {
38
+ let changed = false;
39
+ if (targetKey && !vault.antigravity.data[targetKey]) {
40
+ throw publicError(404, `No snapshot named ${targetKey}`);
41
+ }
42
+
43
+ for (const [key, snap] of Object.entries(vault.antigravity.data)) {
44
+ if ((targetKey && key !== targetKey) || (!force && vault.antigravity.limits[key])) {
45
+ continue;
46
+ }
47
+ const result = await fetchLimits(snap).catch((error) => ({
48
+ password: undefined,
49
+ quota: cleanLimitError(error),
50
+ }));
51
+ if (result.password) {
52
+ vault.antigravity.data[key] = { ...snap, password: result.password, updatedAt: new Date().toISOString() };
53
+ }
54
+ vault.antigravity.limits[key] = { fetchedAt: new Date().toISOString(), quota: result.quota };
55
+ changed = true;
56
+ }
57
+
58
+ return changed;
59
+ };
60
+
61
+ export const saveAntigravity = async (key: string) => {
62
+ const safeKey = assertAccountKey(key);
63
+ const snapshot = await readCurrentSnapshot();
64
+ await updateVault(async (vault) => {
65
+ vault.antigravity.data[safeKey] = snapshot;
66
+ delete vault.antigravity.limits[safeKey];
67
+ return { result: undefined };
68
+ });
69
+ };
70
+
71
+ export const loadAntigravity = async (key: string) => {
72
+ const safeKey = assertAccountKey(key);
73
+ const snap = (await readVault()).antigravity.data[safeKey];
74
+ if (!snap) {
75
+ throw publicError(404, `No snapshot named ${safeKey}`);
76
+ }
77
+ await restoreSnapshot(snap);
78
+ };
79
+
80
+ export const clearAntigravity = async () => {
81
+ await clearLiveAuth();
82
+ };
83
+
84
+ export const antigravityState = async (options: { refreshLimitKey?: string; refreshLimits?: boolean } = {}) => {
85
+ const refreshLimitKey = options.refreshLimitKey ? assertAccountKey(options.refreshLimitKey) : undefined;
86
+ const vault = await updateVault(async (current) => {
87
+ const changed = await updateMissingOrStaleLimits(current, options.refreshLimits === true, refreshLimitKey);
88
+ return { result: current, write: changed };
89
+ });
90
+ const live = await readCurrentSnapshot().catch(() => null);
91
+
92
+ return {
93
+ account: ANTIGRAVITY_ACCOUNT,
94
+ entries: sortEntries(
95
+ Object.entries(vault.antigravity.data).map(([key, snap]: [string, Snapshot]) => {
96
+ const cached = vault.antigravity.limits[key];
97
+ return {
98
+ account: snap.account,
99
+ active: isSameSnapshot(live, snap),
100
+ key,
101
+ limitUpdatedAt: cached?.fetchedAt ?? '',
102
+ quota: cached?.quota ?? null,
103
+ service: snap.service,
104
+ updatedAt: snap.updatedAt,
105
+ };
106
+ }),
107
+ ),
108
+ service: ANTIGRAVITY_SERVICE,
109
+ vaultPath: VAULT_PATH,
110
+ };
111
+ };
@@ -0,0 +1,136 @@
1
+ import { CODEX_AUTH_PATH, VAULT_PATH } from '../config.ts';
2
+ import { assertAccountKey, cleanLimitError, publicError } from '../errors.ts';
3
+ import { writePrivateFile } from '../storage/file.ts';
4
+ import { readVault, updateVault } from '../storage/vault.ts';
5
+ import type { AppVault, CodexSnapshot, LimitResult } from '../types.ts';
6
+ import { fetchCodexLimits } from './usage.ts';
7
+
8
+ const liveAuth = async () => {
9
+ const file = Bun.file(CODEX_AUTH_PATH);
10
+ return (await file.exists()) ? await file.text() : '';
11
+ };
12
+
13
+ const parseAuth = (auth: string) => {
14
+ try {
15
+ return JSON.parse(auth) as {
16
+ OPENAI_API_KEY?: string | null;
17
+ tokens?: { account_id?: string; refresh_token?: string };
18
+ };
19
+ } catch {
20
+ return {};
21
+ }
22
+ };
23
+
24
+ const isSameAuth = (a: ReturnType<typeof parseAuth>, b: ReturnType<typeof parseAuth>) => {
25
+ if (a.tokens?.account_id || b.tokens?.account_id) {
26
+ return a.tokens?.account_id === b.tokens?.account_id;
27
+ }
28
+ return !!a.OPENAI_API_KEY && a.OPENAI_API_KEY === b.OPENAI_API_KEY;
29
+ };
30
+
31
+ const hasNoUsageLeft = (quota: LimitResult | null) => {
32
+ const limits = quota?.ok === true ? Object.entries(quota.models).filter(([key]) => key !== 'codex-credits') : [];
33
+ return limits.length > 0 && limits.every(([, model]) => model.percentage <= 0);
34
+ };
35
+
36
+ const sortEntries = <T extends { active: boolean; key: string; quota: LimitResult | null }>(entries: T[]) => {
37
+ return [...entries].sort((a, b) => {
38
+ if (a.active !== b.active) {
39
+ return a.active ? -1 : 1;
40
+ }
41
+ if (hasNoUsageLeft(a.quota) !== hasNoUsageLeft(b.quota)) {
42
+ return hasNoUsageLeft(a.quota) ? 1 : -1;
43
+ }
44
+ return a.key.localeCompare(b.key);
45
+ });
46
+ };
47
+
48
+ const updateMissingOrStaleLimits = async (vault: AppVault, force: boolean, targetKey?: string) => {
49
+ let changed = false;
50
+ if (targetKey && !vault.codex.data[targetKey]) {
51
+ throw publicError(404, `No Codex auth named ${targetKey}`);
52
+ }
53
+
54
+ for (const [key, snap] of Object.entries(vault.codex.data)) {
55
+ if ((targetKey && key !== targetKey) || (!force && vault.codex.limits[key])) {
56
+ continue;
57
+ }
58
+ const result = await fetchCodexLimits(snap.auth).catch((error) => ({
59
+ auth: undefined,
60
+ quota: cleanLimitError(error),
61
+ }));
62
+ if (result.auth) {
63
+ vault.codex.data[key] = { ...snap, auth: result.auth, updatedAt: new Date().toISOString() };
64
+ }
65
+ vault.codex.limits[key] = { fetchedAt: new Date().toISOString(), quota: result.quota };
66
+ changed = true;
67
+ }
68
+
69
+ return changed;
70
+ };
71
+
72
+ export const saveCodex = async (key: string) => {
73
+ const safeKey = assertAccountKey(key);
74
+ const authFile = Bun.file(CODEX_AUTH_PATH);
75
+ if (!(await authFile.exists())) {
76
+ throw publicError(404, `${CODEX_AUTH_PATH} does not exist`);
77
+ }
78
+ const auth = await authFile.text();
79
+ if (!auth.trim()) {
80
+ throw publicError(400, `${CODEX_AUTH_PATH} is empty`);
81
+ }
82
+ try {
83
+ JSON.parse(auth);
84
+ } catch {
85
+ throw publicError(400, `${CODEX_AUTH_PATH} is not valid JSON`);
86
+ }
87
+
88
+ await updateVault(async (vault) => {
89
+ const existing = vault.codex.data[safeKey];
90
+ const now = new Date().toISOString();
91
+ vault.codex.data[safeKey] = {
92
+ auth,
93
+ createdAt: existing?.createdAt ?? now,
94
+ updatedAt: now,
95
+ };
96
+ delete vault.codex.limits[safeKey];
97
+ return { result: undefined };
98
+ });
99
+ };
100
+
101
+ export const loadCodex = async (key: string) => {
102
+ const safeKey = assertAccountKey(key);
103
+ const snap = (await readVault()).codex.data[safeKey];
104
+ if (!snap) {
105
+ throw publicError(404, `No Codex auth named ${safeKey}`);
106
+ }
107
+
108
+ await writePrivateFile(CODEX_AUTH_PATH, snap.auth);
109
+ };
110
+
111
+ export const codexState = async (options: { refreshLimitKey?: string; refreshLimits?: boolean } = {}) => {
112
+ const refreshLimitKey = options.refreshLimitKey ? assertAccountKey(options.refreshLimitKey) : undefined;
113
+ const vault = await updateVault(async (current) => {
114
+ const changed = await updateMissingOrStaleLimits(current, options.refreshLimits === true, refreshLimitKey);
115
+ return { result: current, write: changed };
116
+ });
117
+ const activeAuth = await liveAuth().catch(() => '');
118
+ const active = parseAuth(activeAuth);
119
+
120
+ return {
121
+ authPath: CODEX_AUTH_PATH,
122
+ entries: sortEntries(
123
+ Object.entries(vault.codex.data).map(([key, snap]: [string, CodexSnapshot]) => {
124
+ const cached = vault.codex.limits[key];
125
+ return {
126
+ active: isSameAuth(active, parseAuth(snap.auth)),
127
+ key,
128
+ limitUpdatedAt: cached?.fetchedAt ?? '',
129
+ quota: cached?.quota ?? null,
130
+ updatedAt: snap.updatedAt,
131
+ };
132
+ }),
133
+ ),
134
+ vaultPath: VAULT_PATH,
135
+ };
136
+ };
@@ -0,0 +1,96 @@
1
+ import { afterEach, expect, it } from 'bun:test';
2
+ import { fetchCodexLimits, parseJwtPayload, tokenExpiredOrNearExpiry, usageToLimitResult } from './usage.ts';
3
+
4
+ const jwt = (payload: object) =>
5
+ ['header', Buffer.from(JSON.stringify(payload)).toString('base64url'), 'signature'].join('.');
6
+
7
+ const originalFetch = globalThis.fetch;
8
+
9
+ afterEach(() => {
10
+ globalThis.fetch = originalFetch;
11
+ });
12
+
13
+ it('should map Codex usage windows into limit cards without null entries', () => {
14
+ const result = usageToLimitResult({
15
+ credits: { balance: '0', has_credits: false, unlimited: false },
16
+ plan_type: 'plus',
17
+ rate_limit: {
18
+ primary_window: { limit_window_seconds: 18_000, reset_at: 1_800_000_000, used_percent: 10.2 },
19
+ secondary_window: null,
20
+ },
21
+ });
22
+
23
+ expect(result.ok).toBe(true);
24
+ if (!result.ok) {
25
+ return;
26
+ }
27
+ expect(result.tier).toBe('plus');
28
+ expect(Object.keys(result.models)).toEqual(['codex-primary', 'codex-credits']);
29
+ expect(result.models['codex-primary']).toEqual({
30
+ displayName: '5h Limit (5h)',
31
+ percentage: 90,
32
+ resetTime: '2027-01-15T08:00:00.000Z',
33
+ });
34
+ expect(result.models['codex-credits']?.percentage).toBe(0);
35
+ });
36
+
37
+ it('should parse JWT payloads and detect near-expired tokens', () => {
38
+ expect(parseJwtPayload(jwt({ exp: 123, sub: 'user' }))).toEqual({ exp: 123, sub: 'user' });
39
+ expect(tokenExpiredOrNearExpiry(jwt({ exp: Math.floor(Date.now() / 1000) + 30 }))).toBe(true);
40
+ expect(tokenExpiredOrNearExpiry(jwt({ exp: Math.floor(Date.now() / 1000) + 300 }))).toBe(false);
41
+ expect(tokenExpiredOrNearExpiry('')).toBe(true);
42
+ });
43
+
44
+ it('should not render a zero-minute usage window suffix', () => {
45
+ const result = usageToLimitResult({
46
+ rate_limit: {
47
+ primary_window: { limit_window_seconds: 0, reset_at: null, used_percent: 20 },
48
+ },
49
+ });
50
+
51
+ expect(result.ok).toBe(true);
52
+ if (!result.ok) {
53
+ return;
54
+ }
55
+ expect(result.models['codex-primary']?.displayName).toBe('Primary Limit');
56
+ });
57
+
58
+ it('should force refresh Codex tokens after a 401 usage response', async () => {
59
+ const calls: string[] = [];
60
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
61
+ const target = String(url);
62
+ calls.push(target);
63
+ if (target.includes('/oauth/token')) {
64
+ return Response.json({
65
+ access_token: jwt({ exp: Math.floor(Date.now() / 1000) + 3600 }),
66
+ refresh_token: 'next-refresh',
67
+ });
68
+ }
69
+ if (calls.filter((call) => call.includes('/wham/usage')).length === 1) {
70
+ return new Response('', { status: 401 });
71
+ }
72
+ expect((init?.headers as Record<string, string>).Authorization).toContain('Bearer ');
73
+ return Response.json({
74
+ plan_type: 'plus',
75
+ rate_limit: {
76
+ primary_window: { limit_window_seconds: 18_000, reset_at: 1_800_000_000, used_percent: 25 },
77
+ },
78
+ });
79
+ }) as typeof fetch;
80
+
81
+ const result = await fetchCodexLimits(
82
+ JSON.stringify({
83
+ auth_mode: 'chatgpt',
84
+ tokens: {
85
+ access_token: jwt({ exp: Math.floor(Date.now() / 1000) + 3600 }),
86
+ account_id: 'account',
87
+ refresh_token: 'refresh',
88
+ },
89
+ }),
90
+ );
91
+
92
+ expect(result.auth).toContain('next-refresh');
93
+ expect(result.quota.ok).toBe(true);
94
+ expect(calls.filter((call) => call.includes('/wham/usage'))).toHaveLength(2);
95
+ expect(calls.filter((call) => call.includes('/oauth/token'))).toHaveLength(1);
96
+ });
@@ -0,0 +1,219 @@
1
+ import { CODEX_CLIENT_ID, CODEX_TOKEN_URL, CODEX_USAGE_URL, CODEX_USER_AGENT } from '../config.ts';
2
+ import { publicError } from '../errors.ts';
3
+ import type { LimitResult } from '../types.ts';
4
+
5
+ type CodexAuth = {
6
+ OPENAI_API_KEY?: string | null;
7
+ auth_mode?: string;
8
+ tokens?: {
9
+ access_token?: string;
10
+ account_id?: string;
11
+ id_token?: string;
12
+ refresh_token?: string;
13
+ };
14
+ last_refresh?: string;
15
+ };
16
+
17
+ type CodexUsagePayload = {
18
+ plan_type?: string;
19
+ rate_limit?: {
20
+ primary_window?: CodexWindow | null;
21
+ secondary_window?: CodexWindow | null;
22
+ } | null;
23
+ credits?: {
24
+ balance?: string | null;
25
+ has_credits?: boolean;
26
+ unlimited?: boolean;
27
+ } | null;
28
+ };
29
+
30
+ type CodexWindow = {
31
+ limit_window_seconds?: number | null;
32
+ reset_at?: number | null;
33
+ used_percent?: number;
34
+ };
35
+
36
+ type CodexLimitFetch = {
37
+ auth?: string;
38
+ quota: LimitResult;
39
+ };
40
+
41
+ const REQUEST_TIMEOUT_MS = 15_000;
42
+ const EXPIRY_GRACE_SECONDS = 60;
43
+
44
+ const parseAuth = (auth: string): CodexAuth => {
45
+ try {
46
+ return JSON.parse(auth) as CodexAuth;
47
+ } catch {
48
+ throw publicError(400, 'Saved Codex auth JSON is malformed');
49
+ }
50
+ };
51
+
52
+ export const parseJwtPayload = (token: string): Record<string, unknown> | null => {
53
+ const part = token.split('.')[1];
54
+ if (!part) {
55
+ return null;
56
+ }
57
+ try {
58
+ return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as Record<string, unknown>;
59
+ } catch {
60
+ return null;
61
+ }
62
+ };
63
+
64
+ export const tokenExpiredOrNearExpiry = (token: string) => {
65
+ const exp = parseJwtPayload(token)?.exp;
66
+ return typeof exp === 'number' ? exp <= Math.floor(Date.now() / 1000) + EXPIRY_GRACE_SECONDS : true;
67
+ };
68
+
69
+ const refreshTokens = async (refreshToken: string) => {
70
+ const res = await fetch(CODEX_TOKEN_URL, {
71
+ body: new URLSearchParams({
72
+ client_id: CODEX_CLIENT_ID,
73
+ grant_type: 'refresh_token',
74
+ refresh_token: refreshToken,
75
+ }),
76
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
77
+ method: 'POST',
78
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
79
+ });
80
+ if (!res.ok) {
81
+ throw new Error(`Token refresh failed: HTTP ${res.status}`);
82
+ }
83
+ return (await res.json()) as { access_token: string; id_token?: string; refresh_token?: string };
84
+ };
85
+
86
+ const ensureFreshAuth = async (auth: CodexAuth, force = false) => {
87
+ const tokens = auth.tokens;
88
+ const accessToken = tokens?.access_token;
89
+ const refreshToken = tokens?.refresh_token;
90
+ if (!refreshToken || (!force && accessToken && !tokenExpiredOrNearExpiry(accessToken))) {
91
+ return auth;
92
+ }
93
+
94
+ const refreshed = await refreshTokens(refreshToken);
95
+ return {
96
+ ...auth,
97
+ last_refresh: new Date().toISOString(),
98
+ tokens: {
99
+ ...tokens,
100
+ access_token: refreshed.access_token,
101
+ id_token: refreshed.id_token ?? tokens.id_token,
102
+ refresh_token: refreshed.refresh_token ?? refreshToken,
103
+ },
104
+ };
105
+ };
106
+
107
+ const codexHeaders = (accessToken: string, accountId?: string) => ({
108
+ ...(accountId ? { 'chatgpt-account-id': accountId } : {}),
109
+ Authorization: `Bearer ${accessToken}`,
110
+ 'User-Agent': CODEX_USER_AGENT,
111
+ });
112
+
113
+ const resetIso = (resetAt?: number | null) => (resetAt ? new Date(resetAt * 1000).toISOString() : '');
114
+
115
+ const windowSuffix = (minutes: number) => {
116
+ if (minutes <= 0) {
117
+ return '';
118
+ }
119
+ if (minutes >= 10_080) {
120
+ return 'weekly';
121
+ }
122
+ if (minutes >= 60) {
123
+ return `${Math.round(minutes / 60)}h`;
124
+ }
125
+ return `${minutes}m`;
126
+ };
127
+
128
+ const windowLabel = (fallback: string, suffix: string) => {
129
+ if (suffix === 'weekly') {
130
+ return 'Weekly Limit';
131
+ }
132
+ if (suffix === '5h') {
133
+ return '5h Limit';
134
+ }
135
+ return fallback;
136
+ };
137
+
138
+ const windowLimit = (fallbackLabel: string, window?: CodexWindow | null) => {
139
+ if (!window || typeof window.used_percent !== 'number') {
140
+ return null;
141
+ }
142
+ const minutes = window.limit_window_seconds ? Math.ceil(window.limit_window_seconds / 60) : 0;
143
+ const suffix = windowSuffix(minutes);
144
+ return {
145
+ displayName: `${windowLabel(fallbackLabel, suffix)}${suffix ? ` (${suffix})` : ''}`,
146
+ percentage: Math.max(0, Math.min(100, Math.round(100 - window.used_percent))),
147
+ resetTime: resetIso(window.reset_at),
148
+ };
149
+ };
150
+
151
+ export const usageToLimitResult = (payload: CodexUsagePayload): LimitResult => {
152
+ const entries: [string, NonNullable<ReturnType<typeof windowLimit>>][] = [];
153
+ const primary = windowLimit('Primary Limit', payload.rate_limit?.primary_window);
154
+ const secondary = windowLimit('Secondary Limit', payload.rate_limit?.secondary_window);
155
+
156
+ if (primary) {
157
+ entries.push(['codex-primary', primary]);
158
+ }
159
+ if (secondary) {
160
+ entries.push(['codex-secondary', secondary]);
161
+ }
162
+ if (payload.credits?.balance) {
163
+ entries.push([
164
+ 'codex-credits',
165
+ {
166
+ displayName: `Credits ${payload.credits.balance}`,
167
+ percentage: payload.credits.unlimited ? 100 : payload.credits.has_credits ? 100 : 0,
168
+ resetTime: '',
169
+ },
170
+ ]);
171
+ }
172
+
173
+ return {
174
+ expires: '',
175
+ models: Object.fromEntries(entries),
176
+ ok: true,
177
+ tier: payload.plan_type ?? '',
178
+ };
179
+ };
180
+
181
+ const requestUsage = async (auth: CodexAuth) => {
182
+ const accessToken = auth.tokens?.access_token;
183
+ if (!accessToken) {
184
+ return { error: 'No Codex access token in snapshot', ok: false as const };
185
+ }
186
+
187
+ const res = await fetch(CODEX_USAGE_URL, {
188
+ headers: codexHeaders(accessToken, auth.tokens?.account_id),
189
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
190
+ });
191
+ if (!res.ok) {
192
+ throw new Error(`HTTP ${res.status}`);
193
+ }
194
+ return usageToLimitResult((await res.json()) as CodexUsagePayload);
195
+ };
196
+
197
+ export const fetchCodexLimits = async (authText: string): Promise<CodexLimitFetch> => {
198
+ const auth = parseAuth(authText);
199
+ if (auth.auth_mode === 'apikey' || auth.auth_mode === 'api_key' || auth.OPENAI_API_KEY) {
200
+ return { quota: { error: 'Codex usage is only available for ChatGPT login accounts', ok: false } };
201
+ }
202
+
203
+ const freshAuth = await ensureFreshAuth(auth);
204
+ try {
205
+ return {
206
+ auth: freshAuth === auth ? undefined : JSON.stringify(freshAuth, null, 2),
207
+ quota: await requestUsage(freshAuth),
208
+ };
209
+ } catch (error) {
210
+ if (!String(error).includes('HTTP 401') || !freshAuth.tokens?.refresh_token) {
211
+ throw error;
212
+ }
213
+ const refreshedAuth = await ensureFreshAuth(freshAuth, true);
214
+ return {
215
+ auth: JSON.stringify(refreshedAuth, null, 2),
216
+ quota: await requestUsage(refreshedAuth),
217
+ };
218
+ }
219
+ };