pi-openai-usage 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 +196 -0
- package/index.ts +32 -0
- package/package.json +43 -0
- package/src/auth.ts +303 -0
- package/src/color.ts +391 -0
- package/src/config.ts +767 -0
- package/src/diagnostics-reporter.ts +202 -0
- package/src/display-width.ts +83 -0
- package/src/format.ts +356 -0
- package/src/interactive-settings-menu.ts +363 -0
- package/src/progress-bar.ts +163 -0
- package/src/status-controller.ts +280 -0
- package/src/usage-client.ts +144 -0
- package/src/usage-command-facade.ts +103 -0
- package/src/usage-refresh-coordinator.ts +193 -0
- package/src/usage-settings.ts +331 -0
- package/src/usage-snapshot.ts +136 -0
- package/src/usage-state.ts +66 -0
- package/src/visibility.ts +39 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
resolveCodexOAuthCredentials,
|
|
5
|
+
type CodexCredentialResolution,
|
|
6
|
+
type CodexModelRegistry,
|
|
7
|
+
} from "./auth";
|
|
8
|
+
import { type LoadedUsageConfig, type UsageConfig, loadUsageConfig } from "./config";
|
|
9
|
+
import type { UsageColorTheme } from "./color";
|
|
10
|
+
import {
|
|
11
|
+
appendUsageRefreshFailureMarker,
|
|
12
|
+
formatUsageAuthFailedStatusLine,
|
|
13
|
+
formatUsageLoginRequiredStatusLine,
|
|
14
|
+
formatUsageRefreshFailedStatusLine,
|
|
15
|
+
formatUsageStatusLine,
|
|
16
|
+
} from "./format";
|
|
17
|
+
import { fetchCodexUsage, type UsageClientPort, type UsageFetchError } from "./usage-client";
|
|
18
|
+
import { createUsageRefreshCoordinator, type UsageRefreshCoordinator } from "./usage-refresh-coordinator";
|
|
19
|
+
import { createUsageStateStore, type UsageStateStore } from "./usage-state";
|
|
20
|
+
import { decideUsageVisibility, type UsageModel, type UsageModelRegistry } from "./visibility";
|
|
21
|
+
|
|
22
|
+
export const USAGE_STATUS_KEY = "openai-usage";
|
|
23
|
+
|
|
24
|
+
export type StatusLineContext = {
|
|
25
|
+
model?: UsageModel;
|
|
26
|
+
modelRegistry?: UsageModelRegistry & CodexModelRegistry;
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
ui: {
|
|
29
|
+
setStatus(key: string, text: string | undefined): void;
|
|
30
|
+
theme?: UsageColorTheme;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type TimerHandle = unknown;
|
|
35
|
+
|
|
36
|
+
export type TimerApi = {
|
|
37
|
+
setInterval(handler: () => void, delayMs: number): TimerHandle;
|
|
38
|
+
clearInterval(handle: TimerHandle): void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type UsageStatusControllerDependencies = {
|
|
42
|
+
loadConfig?: () => LoadedUsageConfig;
|
|
43
|
+
timerApi?: TimerApi;
|
|
44
|
+
resolveCredentials?: (
|
|
45
|
+
ctx: StatusLineContext,
|
|
46
|
+
) => CodexCredentialResolution | Promise<CodexCredentialResolution>;
|
|
47
|
+
usageClient?: UsageClientPort;
|
|
48
|
+
usageState?: UsageStateStore;
|
|
49
|
+
usageRefreshCoordinator?: UsageRefreshCoordinator;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type UsageStatusController = {
|
|
53
|
+
reapply(ctx: StatusLineContext): Promise<void>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const defaultTimerApi: TimerApi = {
|
|
57
|
+
setInterval(handler, delayMs) {
|
|
58
|
+
const handle = setInterval(handler, delayMs);
|
|
59
|
+
handle.unref?.();
|
|
60
|
+
return handle;
|
|
61
|
+
},
|
|
62
|
+
clearInterval(handle) {
|
|
63
|
+
clearInterval(handle as ReturnType<typeof setInterval>);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const defaultUsageClient: UsageClientPort = {
|
|
68
|
+
fetchUsage: fetchCodexUsage,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Registers lifecycle ownership for the Usage Status Extension's status-line entry.
|
|
73
|
+
*/
|
|
74
|
+
export function registerUsageStatusController(
|
|
75
|
+
pi: ExtensionAPI,
|
|
76
|
+
dependencies: UsageStatusControllerDependencies = {},
|
|
77
|
+
): UsageStatusController {
|
|
78
|
+
const loadConfig = dependencies.loadConfig ?? loadUsageConfig;
|
|
79
|
+
const timerApi = dependencies.timerApi ?? defaultTimerApi;
|
|
80
|
+
const resolveCredentials = dependencies.resolveCredentials ?? defaultResolveCredentials;
|
|
81
|
+
const usageClient = dependencies.usageClient ?? defaultUsageClient;
|
|
82
|
+
const usageState = dependencies.usageState ?? createUsageStateStore();
|
|
83
|
+
const refreshCoordinator =
|
|
84
|
+
dependencies.usageRefreshCoordinator ??
|
|
85
|
+
createUsageRefreshCoordinator({ usageClient, usageState });
|
|
86
|
+
let refreshTimer: TimerHandle | undefined;
|
|
87
|
+
let refreshTimerDelayMs: number | undefined;
|
|
88
|
+
let activeApplyId = 0;
|
|
89
|
+
|
|
90
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
91
|
+
await applyCurrentConfiguration(ctx);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
pi.on("model_select", async (_event, ctx) => {
|
|
95
|
+
await applyCurrentConfiguration(ctx);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
99
|
+
await applyCurrentConfiguration(ctx);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
103
|
+
stopRefreshTimer();
|
|
104
|
+
refreshCoordinator.abortInFlightRefresh();
|
|
105
|
+
activeApplyId += 1;
|
|
106
|
+
clearUsageStatus(ctx);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
async function applyCurrentConfiguration(ctx: StatusLineContext): Promise<void> {
|
|
110
|
+
const applyId = ++activeApplyId;
|
|
111
|
+
|
|
112
|
+
const { effective: initialConfig } = loadConfig();
|
|
113
|
+
|
|
114
|
+
if (!isCurrentApply(applyId)) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (!initialConfig.enabled) {
|
|
118
|
+
stopRefreshTimer();
|
|
119
|
+
clearUsageStatus(ctx);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const initialVisibility = decideUsageVisibility({
|
|
124
|
+
showAlways: initialConfig.display.showAlways,
|
|
125
|
+
model: ctx.model,
|
|
126
|
+
modelRegistry: ctx.modelRegistry,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (!initialVisibility.visible) {
|
|
130
|
+
stopRefreshTimer();
|
|
131
|
+
clearUsageStatus(ctx);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const credentials = await resolveCredentials(ctx);
|
|
136
|
+
if (!isCurrentApply(applyId)) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (!credentials.ok) {
|
|
140
|
+
stopRefreshTimer();
|
|
141
|
+
publishLoginRequiredStatus(ctx);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const hadCachedSnapshot = usageState.getSnapshot(ctx.model?.id) !== undefined;
|
|
146
|
+
if (hadCachedSnapshot && isCurrentApply(applyId)) {
|
|
147
|
+
publishUsageStatus(ctx, initialConfig, usageState);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const refreshResult = await refreshCoordinator.refresh({
|
|
151
|
+
credentials: credentials.credentials,
|
|
152
|
+
signal: ctx.signal,
|
|
153
|
+
modelId: ctx.model?.id,
|
|
154
|
+
staleAfterMs: initialConfig.refreshIntervalMs,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!isCurrentApply(applyId)) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const latestConfig = loadConfig().effective;
|
|
162
|
+
|
|
163
|
+
const latestVisibility = decideUsageVisibility({
|
|
164
|
+
showAlways: latestConfig.display.showAlways,
|
|
165
|
+
model: ctx.model,
|
|
166
|
+
modelRegistry: ctx.modelRegistry,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
if (!latestConfig.enabled || !latestVisibility.visible) {
|
|
170
|
+
stopRefreshTimer();
|
|
171
|
+
clearUsageStatus(ctx);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (refreshResult.status === "failed") {
|
|
176
|
+
publishRefreshFailureStatus(ctx, latestConfig, refreshResult.error, hadCachedSnapshot);
|
|
177
|
+
} else if (refreshResult.status !== "skipped" || hadCachedSnapshot) {
|
|
178
|
+
publishUsageStatus(ctx, latestConfig, usageState);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
ensureRefreshTimer(ctx, latestConfig.refreshIntervalMs);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function publishRefreshFailureStatus(
|
|
185
|
+
ctx: StatusLineContext,
|
|
186
|
+
config: UsageConfig,
|
|
187
|
+
error: UsageFetchError,
|
|
188
|
+
hadCachedSnapshot: boolean,
|
|
189
|
+
): void {
|
|
190
|
+
if (error.kind === "auth") {
|
|
191
|
+
publishUsageAuthFailedStatus(ctx);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (hadCachedSnapshot) {
|
|
196
|
+
const failedCachedText = formatUsageStatusLine({
|
|
197
|
+
snapshot: usageState.getSnapshot(ctx.model?.id),
|
|
198
|
+
config,
|
|
199
|
+
theme: ctx.ui.theme,
|
|
200
|
+
});
|
|
201
|
+
if (failedCachedText !== undefined) {
|
|
202
|
+
publishRefreshFailedStatus(ctx, failedCachedText);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
publishUsageRefreshFailedStatus(ctx);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isCurrentApply(applyId: number): boolean {
|
|
211
|
+
return applyId === activeApplyId;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function ensureRefreshTimer(ctx: StatusLineContext, delayMs: number): void {
|
|
215
|
+
if (refreshTimer !== undefined && refreshTimerDelayMs === delayMs) return;
|
|
216
|
+
|
|
217
|
+
stopRefreshTimer();
|
|
218
|
+
refreshTimer = timerApi.setInterval(() => {
|
|
219
|
+
void applyCurrentConfiguration(ctx);
|
|
220
|
+
}, delayMs);
|
|
221
|
+
refreshTimerDelayMs = delayMs;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function stopRefreshTimer(): void {
|
|
225
|
+
if (refreshTimer === undefined) return;
|
|
226
|
+
|
|
227
|
+
timerApi.clearInterval(refreshTimer);
|
|
228
|
+
refreshTimer = undefined;
|
|
229
|
+
refreshTimerDelayMs = undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
reapply: applyCurrentConfiguration,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function defaultResolveCredentials(
|
|
238
|
+
ctx: StatusLineContext,
|
|
239
|
+
): Promise<CodexCredentialResolution> {
|
|
240
|
+
return resolveCodexOAuthCredentials({ modelRegistry: ctx.modelRegistry });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function publishUsageStatus(
|
|
244
|
+
ctx: StatusLineContext,
|
|
245
|
+
config: UsageConfig,
|
|
246
|
+
usageState: UsageStateStore,
|
|
247
|
+
): void {
|
|
248
|
+
const statusText = formatUsageStatusLine({
|
|
249
|
+
snapshot: usageState.getSnapshot(ctx.model?.id),
|
|
250
|
+
config,
|
|
251
|
+
theme: ctx.ui.theme,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
if (statusText === undefined || statusText.length === 0) {
|
|
255
|
+
clearUsageStatus(ctx);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
ctx.ui.setStatus(USAGE_STATUS_KEY, statusText);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function publishLoginRequiredStatus(ctx: StatusLineContext): void {
|
|
263
|
+
ctx.ui.setStatus(USAGE_STATUS_KEY, formatUsageLoginRequiredStatusLine(ctx.ui.theme));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function publishUsageAuthFailedStatus(ctx: StatusLineContext): void {
|
|
267
|
+
ctx.ui.setStatus(USAGE_STATUS_KEY, formatUsageAuthFailedStatusLine(ctx.ui.theme));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function publishRefreshFailedStatus(ctx: StatusLineContext, statusText: string): void {
|
|
271
|
+
ctx.ui.setStatus(USAGE_STATUS_KEY, appendUsageRefreshFailureMarker(statusText, ctx.ui.theme));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function publishUsageRefreshFailedStatus(ctx: StatusLineContext): void {
|
|
275
|
+
ctx.ui.setStatus(USAGE_STATUS_KEY, formatUsageRefreshFailedStatusLine(ctx.ui.theme));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function clearUsageStatus(ctx: StatusLineContext): void {
|
|
279
|
+
ctx.ui.setStatus(USAGE_STATUS_KEY, undefined);
|
|
280
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
|
|
2
|
+
export const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 10_000;
|
|
3
|
+
|
|
4
|
+
export type UsageRequestCredentials = {
|
|
5
|
+
accessToken: string;
|
|
6
|
+
accountId: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type UsageFetchErrorKind = "auth" | "http" | "network" | "timeout" | "aborted" | "parse";
|
|
10
|
+
|
|
11
|
+
export type UsageFetchError = {
|
|
12
|
+
kind: UsageFetchErrorKind;
|
|
13
|
+
message: string;
|
|
14
|
+
status?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type UsageFetchResult =
|
|
18
|
+
| { ok: true; raw: unknown; status: number }
|
|
19
|
+
| { ok: false; error: UsageFetchError };
|
|
20
|
+
|
|
21
|
+
export type FetchCodexUsageOptions = {
|
|
22
|
+
fetchImpl?: typeof fetch;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type UsageClientPort = {
|
|
28
|
+
fetchUsage(
|
|
29
|
+
credentials: UsageRequestCredentials,
|
|
30
|
+
options?: Pick<FetchCodexUsageOptions, "signal">,
|
|
31
|
+
): Promise<UsageFetchResult>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const TIMEOUT_ABORT_MARKER = Symbol("usage request timeout");
|
|
35
|
+
const EXTERNAL_ABORT_MARKER = Symbol("usage request aborted");
|
|
36
|
+
|
|
37
|
+
export async function fetchCodexUsage(
|
|
38
|
+
credentials: UsageRequestCredentials,
|
|
39
|
+
options: FetchCodexUsageOptions = {},
|
|
40
|
+
): Promise<UsageFetchResult> {
|
|
41
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
42
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_USAGE_REQUEST_TIMEOUT_MS;
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
let abortReason: typeof TIMEOUT_ABORT_MARKER | typeof EXTERNAL_ABORT_MARKER | undefined;
|
|
45
|
+
|
|
46
|
+
const timeoutHandle = setTimeout(() => {
|
|
47
|
+
abortReason ??= TIMEOUT_ABORT_MARKER;
|
|
48
|
+
controller.abort(TIMEOUT_ABORT_MARKER);
|
|
49
|
+
}, timeoutMs);
|
|
50
|
+
timeoutHandle.unref?.();
|
|
51
|
+
|
|
52
|
+
const removeExternalAbortListener = pipeExternalAbort(options.signal, controller, () => {
|
|
53
|
+
abortReason ??= EXTERNAL_ABORT_MARKER;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const response = await fetchImpl(USAGE_ENDPOINT, {
|
|
58
|
+
headers: {
|
|
59
|
+
accept: "*/*",
|
|
60
|
+
authorization: `Bearer ${credentials.accessToken}`,
|
|
61
|
+
"chatgpt-account-id": credentials.accountId,
|
|
62
|
+
},
|
|
63
|
+
signal: controller.signal,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
error: classifyHttpError(response.status),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
raw: await response.json(),
|
|
77
|
+
status: response.status,
|
|
78
|
+
};
|
|
79
|
+
} catch {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: { kind: "parse", message: "Codex usage response was not valid JSON" },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return { ok: false, error: classifyThrownError(error, abortReason) };
|
|
87
|
+
} finally {
|
|
88
|
+
clearTimeout(timeoutHandle);
|
|
89
|
+
removeExternalAbortListener();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function classifyHttpError(status: number): UsageFetchError {
|
|
94
|
+
if (status === 401 || status === 403) {
|
|
95
|
+
return { kind: "auth", status, message: `Codex usage authentication failed (${status})` };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { kind: "http", status, message: `Codex usage request failed (${status})` };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function classifyThrownError(
|
|
102
|
+
error: unknown,
|
|
103
|
+
abortReason: typeof TIMEOUT_ABORT_MARKER | typeof EXTERNAL_ABORT_MARKER | undefined,
|
|
104
|
+
): UsageFetchError {
|
|
105
|
+
if (abortReason === TIMEOUT_ABORT_MARKER) {
|
|
106
|
+
return { kind: "timeout", message: "Codex usage request timed out" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (abortReason === EXTERNAL_ABORT_MARKER || isAbortError(error)) {
|
|
110
|
+
return { kind: "aborted", message: "Codex usage request was aborted" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { kind: "network", message: messageFromUnknownError(error) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function pipeExternalAbort(
|
|
117
|
+
externalSignal: AbortSignal | undefined,
|
|
118
|
+
controller: AbortController,
|
|
119
|
+
onAbort: () => void,
|
|
120
|
+
): () => void {
|
|
121
|
+
if (externalSignal === undefined) return () => undefined;
|
|
122
|
+
|
|
123
|
+
const abort = () => {
|
|
124
|
+
onAbort();
|
|
125
|
+
controller.abort(EXTERNAL_ABORT_MARKER);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
if (externalSignal.aborted) {
|
|
129
|
+
abort();
|
|
130
|
+
return () => undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
externalSignal.addEventListener("abort", abort, { once: true });
|
|
134
|
+
return () => externalSignal.removeEventListener("abort", abort);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isAbortError(error: unknown): boolean {
|
|
138
|
+
return error instanceof DOMException && error.name === "AbortError";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function messageFromUnknownError(error: unknown): string {
|
|
142
|
+
if (error instanceof Error && error.message.trim().length > 0) return error.message;
|
|
143
|
+
return "Codex usage request failed";
|
|
144
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { resolveCodexOAuthCredentials, type CodexCredentialResolution } from "./auth";
|
|
4
|
+
import { type LoadedUsageConfig, loadUsageConfig, type UsageConfig } from "./config";
|
|
5
|
+
import {
|
|
6
|
+
appendUsageRefreshFailureMarker,
|
|
7
|
+
formatUsageAuthFailedStatusLine,
|
|
8
|
+
formatUsageLoginRequiredStatusLine,
|
|
9
|
+
formatUsageRefreshFailedStatusLine,
|
|
10
|
+
formatUsageStatusLine,
|
|
11
|
+
} from "./format";
|
|
12
|
+
import type { UsageRefreshCoordinator, UsageRefreshResult } from "./usage-refresh-coordinator";
|
|
13
|
+
|
|
14
|
+
export type UsageCommandFacadeContext = Pick<
|
|
15
|
+
ExtensionCommandContext,
|
|
16
|
+
"ui" | "model" | "modelRegistry" | "signal"
|
|
17
|
+
>;
|
|
18
|
+
|
|
19
|
+
export type UsageCommandFacadeDependencies = {
|
|
20
|
+
loadConfig?: () => LoadedUsageConfig;
|
|
21
|
+
resolveCredentials?: (
|
|
22
|
+
ctx: UsageCommandFacadeContext,
|
|
23
|
+
) => Promise<CodexCredentialResolution>;
|
|
24
|
+
usageRefreshCoordinator: UsageRefreshCoordinator;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ShowUsageOptions = {
|
|
28
|
+
forceRefresh?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type UsageCommandFacade = {
|
|
32
|
+
showUsage(ctx: UsageCommandFacadeContext, options?: ShowUsageOptions): Promise<void>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function createUsageCommandFacade(
|
|
36
|
+
dependencies: UsageCommandFacadeDependencies,
|
|
37
|
+
): UsageCommandFacade {
|
|
38
|
+
const loadConfig = dependencies.loadConfig ?? loadUsageConfig;
|
|
39
|
+
const resolveCredentials = dependencies.resolveCredentials ?? defaultResolveCredentials;
|
|
40
|
+
const usageRefreshCoordinator = dependencies.usageRefreshCoordinator;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
async showUsage(ctx, options = {}) {
|
|
44
|
+
const initialConfig = loadConfig().effective;
|
|
45
|
+
const credentials = await resolveCredentials(ctx);
|
|
46
|
+
if (!credentials.ok) {
|
|
47
|
+
notify(ctx, formatUsageLoginRequiredStatusLine());
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const refreshResult = await usageRefreshCoordinator.refresh({
|
|
52
|
+
credentials: credentials.credentials,
|
|
53
|
+
signal: ctx.signal,
|
|
54
|
+
modelId: ctx.model?.id,
|
|
55
|
+
staleAfterMs: initialConfig.refreshIntervalMs,
|
|
56
|
+
force: options.forceRefresh,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const latestConfig = loadConfig().effective;
|
|
60
|
+
const statusText = resolveUsageStatusText(latestConfig, refreshResult, ctx.ui.theme);
|
|
61
|
+
notify(ctx, statusText ?? formatUsageRefreshFailedStatusLine());
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveUsageStatusText(
|
|
67
|
+
config: UsageConfig,
|
|
68
|
+
refreshResult: UsageRefreshResult,
|
|
69
|
+
theme?: UsageCommandFacadeContext["ui"]["theme"],
|
|
70
|
+
): string | undefined {
|
|
71
|
+
if (refreshResult.status === "failed") {
|
|
72
|
+
if (refreshResult.error.kind === "auth") {
|
|
73
|
+
return formatUsageAuthFailedStatusLine();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const cachedText = formatUsageStatusLine({
|
|
77
|
+
snapshot: refreshResult.snapshot,
|
|
78
|
+
config,
|
|
79
|
+
theme,
|
|
80
|
+
});
|
|
81
|
+
if (cachedText !== undefined) {
|
|
82
|
+
return appendUsageRefreshFailureMarker(cachedText, theme);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return formatUsageRefreshFailedStatusLine();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return formatUsageStatusLine({
|
|
89
|
+
snapshot: refreshResult.snapshot,
|
|
90
|
+
config,
|
|
91
|
+
theme,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function notify(ctx: UsageCommandFacadeContext, message: string): void {
|
|
96
|
+
ctx.ui.notify(message, "info");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function defaultResolveCredentials(
|
|
100
|
+
ctx: UsageCommandFacadeContext,
|
|
101
|
+
): Promise<CodexCredentialResolution> {
|
|
102
|
+
return resolveCodexOAuthCredentials({ modelRegistry: ctx.modelRegistry });
|
|
103
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { UsageClientPort, UsageFetchError, UsageRequestCredentials } from "./usage-client";
|
|
2
|
+
import { createUsageStateStore, type UsageStateStore } from "./usage-state";
|
|
3
|
+
import { parseUsageSnapshot, type UsageSnapshot } from "./usage-snapshot";
|
|
4
|
+
|
|
5
|
+
export type UsageRefreshOptions = {
|
|
6
|
+
credentials: UsageRequestCredentials;
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
modelId?: string;
|
|
9
|
+
staleAfterMs?: number;
|
|
10
|
+
force?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type UsageRefreshResult =
|
|
14
|
+
| {
|
|
15
|
+
status: "refreshed";
|
|
16
|
+
joined: boolean;
|
|
17
|
+
snapshot: UsageSnapshot;
|
|
18
|
+
fetchedAt: Date;
|
|
19
|
+
}
|
|
20
|
+
| {
|
|
21
|
+
status: "skipped";
|
|
22
|
+
reason: "fresh";
|
|
23
|
+
joined: false;
|
|
24
|
+
snapshot: UsageSnapshot | undefined;
|
|
25
|
+
}
|
|
26
|
+
| {
|
|
27
|
+
status: "failed";
|
|
28
|
+
joined: boolean;
|
|
29
|
+
error: UsageFetchError;
|
|
30
|
+
snapshot: UsageSnapshot | undefined;
|
|
31
|
+
attemptedAt: Date;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type UsageRefreshCoordinator = {
|
|
35
|
+
refresh(options: UsageRefreshOptions): Promise<UsageRefreshResult>;
|
|
36
|
+
isRefreshing(): boolean;
|
|
37
|
+
abortInFlightRefresh(): void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type UsageRefreshCoordinatorDependencies = {
|
|
41
|
+
usageClient: UsageClientPort;
|
|
42
|
+
usageState?: UsageStateStore;
|
|
43
|
+
now?: () => Date;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type InFlightRefreshResult =
|
|
47
|
+
| {
|
|
48
|
+
status: "refreshed";
|
|
49
|
+
raw: unknown;
|
|
50
|
+
fetchedAt: Date;
|
|
51
|
+
}
|
|
52
|
+
| {
|
|
53
|
+
status: "failed";
|
|
54
|
+
error: UsageFetchError;
|
|
55
|
+
attemptedAt: Date;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function createUsageRefreshCoordinator(
|
|
59
|
+
dependencies: UsageRefreshCoordinatorDependencies,
|
|
60
|
+
): UsageRefreshCoordinator {
|
|
61
|
+
const usageState = dependencies.usageState ?? createUsageStateStore();
|
|
62
|
+
const now = dependencies.now ?? (() => new Date());
|
|
63
|
+
let inFlightRefresh: Promise<InFlightRefreshResult> | undefined;
|
|
64
|
+
let inFlightAbortController: AbortController | undefined;
|
|
65
|
+
|
|
66
|
+
async function refresh(options: UsageRefreshOptions): Promise<UsageRefreshResult> {
|
|
67
|
+
if (inFlightRefresh !== undefined) {
|
|
68
|
+
return materializeRefreshResult(usageState, await inFlightRefresh, true, options.modelId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!options.force && !isCachedSnapshotStale(usageState, options.staleAfterMs, now())) {
|
|
72
|
+
return {
|
|
73
|
+
status: "skipped",
|
|
74
|
+
reason: "fresh",
|
|
75
|
+
joined: false,
|
|
76
|
+
snapshot: usageState.getSnapshot(options.modelId),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ownerAbort = new AbortController();
|
|
81
|
+
inFlightAbortController = ownerAbort;
|
|
82
|
+
const cleanupAbortForwarder = pipeExternalAbortSignal(options.signal, ownerAbort);
|
|
83
|
+
const ownerRefresh = runRefresh(options, ownerAbort.signal);
|
|
84
|
+
inFlightRefresh = ownerRefresh;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
return materializeRefreshResult(usageState, await ownerRefresh, false, options.modelId);
|
|
88
|
+
} finally {
|
|
89
|
+
inFlightAbortController = undefined;
|
|
90
|
+
cleanupAbortForwarder();
|
|
91
|
+
if (inFlightRefresh === ownerRefresh) {
|
|
92
|
+
inFlightRefresh = undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function runRefresh(
|
|
98
|
+
options: UsageRefreshOptions,
|
|
99
|
+
signal: AbortSignal,
|
|
100
|
+
): Promise<InFlightRefreshResult> {
|
|
101
|
+
const attemptedAt = now();
|
|
102
|
+
usageState.recordFetchAttempt(attemptedAt);
|
|
103
|
+
|
|
104
|
+
const result = await dependencies.usageClient.fetchUsage(options.credentials, { signal });
|
|
105
|
+
|
|
106
|
+
if (!result.ok) {
|
|
107
|
+
usageState.recordFetchError(result.error, attemptedAt);
|
|
108
|
+
return {
|
|
109
|
+
status: "failed",
|
|
110
|
+
error: result.error,
|
|
111
|
+
attemptedAt,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const fetchedAt = now();
|
|
116
|
+
usageState.storeRawUsageResponse(result.raw, fetchedAt);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
status: "refreshed",
|
|
120
|
+
raw: result.raw,
|
|
121
|
+
fetchedAt,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
refresh,
|
|
127
|
+
isRefreshing() {
|
|
128
|
+
return inFlightRefresh !== undefined;
|
|
129
|
+
},
|
|
130
|
+
abortInFlightRefresh() {
|
|
131
|
+
inFlightAbortController?.abort();
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isCachedSnapshotStale(
|
|
137
|
+
usageState: UsageStateStore,
|
|
138
|
+
staleAfterMs: number | undefined,
|
|
139
|
+
now: Date,
|
|
140
|
+
): boolean {
|
|
141
|
+
if (usageState.getSnapshot() === undefined) return true;
|
|
142
|
+
|
|
143
|
+
const lastSuccessAt = usageState.getLastSuccessAt();
|
|
144
|
+
if (lastSuccessAt === undefined) return true;
|
|
145
|
+
if (staleAfterMs === undefined) return true;
|
|
146
|
+
|
|
147
|
+
return now.getTime() - lastSuccessAt.getTime() >= staleAfterMs;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function materializeRefreshResult(
|
|
151
|
+
usageState: UsageStateStore,
|
|
152
|
+
result: InFlightRefreshResult,
|
|
153
|
+
joined: boolean,
|
|
154
|
+
modelId: string | undefined,
|
|
155
|
+
): UsageRefreshResult {
|
|
156
|
+
if (result.status === "failed") {
|
|
157
|
+
return {
|
|
158
|
+
status: "failed",
|
|
159
|
+
joined,
|
|
160
|
+
error: result.error,
|
|
161
|
+
snapshot: usageState.getSnapshot(modelId),
|
|
162
|
+
attemptedAt: result.attemptedAt,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
status: "refreshed",
|
|
168
|
+
joined,
|
|
169
|
+
snapshot: parseUsageSnapshot(result.raw, { modelId }),
|
|
170
|
+
fetchedAt: result.fetchedAt,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function pipeExternalAbortSignal(
|
|
175
|
+
signal: AbortSignal | undefined,
|
|
176
|
+
internalController: AbortController,
|
|
177
|
+
): () => void {
|
|
178
|
+
if (signal === undefined) {
|
|
179
|
+
return () => undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const abort = (): void => {
|
|
183
|
+
internalController.abort(signal.reason);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if (signal.aborted) {
|
|
187
|
+
abort();
|
|
188
|
+
return () => undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
192
|
+
return () => signal.removeEventListener("abort", abort);
|
|
193
|
+
}
|