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.
@@ -0,0 +1,331 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+
6
+ import {
7
+ type LoadedUsageConfig,
8
+ type UsageConfigPatch,
9
+ loadUsageConfig,
10
+ patchUsageConfig,
11
+ } from "./config";
12
+ import { resolveCodexOAuthCredentials, type CodexCredentialResolution } from "./auth";
13
+ import { createUsageCommandFacade } from "./usage-command-facade";
14
+ import {
15
+ createUsageRefreshCoordinator,
16
+ type UsageRefreshCoordinator,
17
+ } from "./usage-refresh-coordinator";
18
+ import {
19
+ fetchCodexUsage,
20
+ type UsageClientPort,
21
+ } from "./usage-client";
22
+ import { type UsageStateStore, createUsageStateStore } from "./usage-state";
23
+ import { formatDiagnosticsReport } from "./diagnostics-reporter";
24
+ import { openInteractiveSettingsMenu } from "./interactive-settings-menu";
25
+
26
+ type UsageSettingsCommandContext = Pick<
27
+ ExtensionCommandContext,
28
+ "ui" | "model" | "modelRegistry" | "signal" | "hasUI"
29
+ >;
30
+
31
+ type ConfigPatchWriter = (configPath: string, patch: UsageConfigPatch) => void;
32
+ type ConfigChangedCallback = (ctx: UsageSettingsCommandContext) => void | Promise<void>;
33
+ type StatusLineReapplyCallback = (ctx: UsageSettingsCommandContext) => void | Promise<void>;
34
+
35
+ type UsageSettingsCommandDependencies = {
36
+ loadConfig?: () => LoadedUsageConfig;
37
+ resolveCredentials?: (
38
+ ctx: UsageSettingsCommandContext,
39
+ ) => Promise<CodexCredentialResolution>;
40
+ usageClient?: UsageClientPort;
41
+ usageState?: UsageStateStore;
42
+ usageRefreshCoordinator?: UsageRefreshCoordinator;
43
+ patchConfig?: ConfigPatchWriter;
44
+ onConfigChanged?: ConfigChangedCallback;
45
+ reapplyStatusLine?: StatusLineReapplyCallback;
46
+ };
47
+
48
+ const defaultUsageClient: UsageClientPort = {
49
+ fetchUsage: fetchCodexUsage,
50
+ };
51
+
52
+ export type { UsageSettingsCommandDependencies };
53
+
54
+ export function registerOpenAIUsageSettingsCommand(
55
+ pi: ExtensionAPI,
56
+ dependencies: UsageSettingsCommandDependencies = {},
57
+ ): void {
58
+ const loadConfig = dependencies.loadConfig ?? loadUsageConfig;
59
+ const resolveCredentials = dependencies.resolveCredentials ?? defaultResolveCredentials;
60
+ const usageClient = dependencies.usageClient ?? defaultUsageClient;
61
+ const usageState = dependencies.usageState ?? createUsageStateStore();
62
+ const usageRefreshCoordinator =
63
+ dependencies.usageRefreshCoordinator ??
64
+ createUsageRefreshCoordinator({ usageClient, usageState });
65
+ const patchConfig = dependencies.patchConfig ?? patchUsageConfig;
66
+ const onConfigChanged = dependencies.onConfigChanged ?? noopConfigChanged;
67
+ const reapplyStatusLine = dependencies.reapplyStatusLine ?? noopStatusLineReapply;
68
+ const usageCommandFacade = createUsageCommandFacade({
69
+ loadConfig,
70
+ resolveCredentials: (ctx) => resolveCredentials(ctx as UsageSettingsCommandContext),
71
+ usageRefreshCoordinator,
72
+ });
73
+
74
+ pi.registerCommand("openai-usage-settings", {
75
+ description: "OpenAI usage settings and utility subcommands",
76
+ getArgumentCompletions(argumentPrefix: string) {
77
+ return suggestSettingsSubcommands(argumentPrefix);
78
+ },
79
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
80
+ const trimmed = args.trim();
81
+ const commandContext = asUsageSettingsContext(ctx);
82
+
83
+ const lowered = trimmed.toLowerCase();
84
+ if (trimmed.length === 0) {
85
+ await handleShowSettings({
86
+ commandContext,
87
+ loadConfig,
88
+ patchConfig,
89
+ onConfigChanged,
90
+ reapplyStatusLine,
91
+ });
92
+ return;
93
+ }
94
+
95
+ if (isSubcommandInvocation(lowered, "show")) {
96
+ notify(ctx, removedShowPathText());
97
+ return;
98
+ }
99
+
100
+ if (isSubcommandInvocation(lowered, "debug")) {
101
+ notify(ctx, removedDebugPathText());
102
+ return;
103
+ }
104
+
105
+ if (lowered === "usage") {
106
+ await usageCommandFacade.showUsage(commandContext);
107
+ return;
108
+ }
109
+
110
+ if (lowered === "refresh") {
111
+ await usageCommandFacade.showUsage(commandContext, { forceRefresh: true });
112
+ return;
113
+ }
114
+
115
+ if (lowered === "help") {
116
+ notify(ctx, usageSettingsHelpText());
117
+ return;
118
+ }
119
+
120
+ if (lowered === "diagnostics") {
121
+ await handleDiagnostics({ commandContext, loadConfig, resolveCredentials, usageState });
122
+ return;
123
+ }
124
+
125
+ if (isRemovedSetInvocation(lowered)) {
126
+ notify(ctx, removedSetPathText());
127
+ return;
128
+ }
129
+
130
+ notify(ctx, "Unknown /openai-usage-settings subcommand. Use /openai-usage-settings help");
131
+ },
132
+ });
133
+ }
134
+
135
+ async function handleShowSettings(options: {
136
+ commandContext: UsageSettingsCommandContext;
137
+ loadConfig: () => LoadedUsageConfig;
138
+ patchConfig: ConfigPatchWriter;
139
+ onConfigChanged: ConfigChangedCallback;
140
+ reapplyStatusLine: StatusLineReapplyCallback;
141
+ }): Promise<void> {
142
+ const { commandContext, loadConfig, patchConfig, onConfigChanged, reapplyStatusLine } = options;
143
+ if (!commandContext.hasUI || typeof commandContext.ui.custom !== "function") {
144
+ notify(commandContext, usageSettingsNoUiFallbackText());
145
+ return;
146
+ }
147
+
148
+ const loaded = loadConfig();
149
+ await openInteractiveSettingsMenu(commandContext, loaded.effective, {
150
+ onPatch: (patch) =>
151
+ handleInteractiveSettingsPatch({
152
+ commandContext,
153
+ configPath: loaded.configPath,
154
+ patch,
155
+ patchConfig,
156
+ onConfigChanged,
157
+ reapplyStatusLine,
158
+ }),
159
+ });
160
+ }
161
+
162
+ function handleInteractiveSettingsPatch(options: {
163
+ commandContext: UsageSettingsCommandContext;
164
+ configPath: string;
165
+ patch: UsageConfigPatch;
166
+ patchConfig: ConfigPatchWriter;
167
+ onConfigChanged: ConfigChangedCallback;
168
+ reapplyStatusLine: StatusLineReapplyCallback;
169
+ }): void {
170
+ const { commandContext, configPath, patch, patchConfig, onConfigChanged, reapplyStatusLine } =
171
+ options;
172
+
173
+ try {
174
+ patchConfig(configPath, patch);
175
+ } catch {
176
+ return;
177
+ }
178
+
179
+ applySuccessfulMenuWriteSideEffects({
180
+ commandContext,
181
+ onConfigChanged,
182
+ reapplyStatusLine,
183
+ });
184
+ }
185
+
186
+ function applySuccessfulMenuWriteSideEffects(options: {
187
+ commandContext: UsageSettingsCommandContext;
188
+ onConfigChanged: ConfigChangedCallback;
189
+ reapplyStatusLine: StatusLineReapplyCallback;
190
+ }): void {
191
+ const { commandContext, onConfigChanged, reapplyStatusLine } = options;
192
+
193
+ try {
194
+ const configChanged = onConfigChanged(commandContext);
195
+ if (isPromiseLike(configChanged)) {
196
+ void Promise.resolve(configChanged)
197
+ .then(() => reapplyStatusLine(commandContext))
198
+ .catch(() => undefined);
199
+ return;
200
+ }
201
+
202
+ const reapplied = reapplyStatusLine(commandContext);
203
+ if (isPromiseLike(reapplied)) {
204
+ void Promise.resolve(reapplied).catch(() => undefined);
205
+ }
206
+ } catch {
207
+ return;
208
+ }
209
+ }
210
+
211
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
212
+ return (
213
+ (typeof value === "object" || typeof value === "function") &&
214
+ value !== null &&
215
+ typeof (value as { then?: unknown }).then === "function"
216
+ );
217
+ }
218
+
219
+ async function handleDiagnostics(options: {
220
+ commandContext: UsageSettingsCommandContext;
221
+ loadConfig: () => LoadedUsageConfig;
222
+ resolveCredentials: (ctx: UsageSettingsCommandContext) => Promise<CodexCredentialResolution>;
223
+ usageState: UsageStateStore;
224
+ }): Promise<void> {
225
+ const { commandContext, loadConfig, resolveCredentials, usageState } = options;
226
+ const loaded = loadConfig();
227
+ const credentials = await resolveCredentials(commandContext);
228
+ notify(
229
+ commandContext,
230
+ formatDiagnosticsReport({
231
+ loaded,
232
+ credentials,
233
+ usageState,
234
+ runtime: commandContext,
235
+ }),
236
+ );
237
+ }
238
+
239
+ function isSubcommandInvocation(loweredArgs: string, subcommand: string): boolean {
240
+ return loweredArgs === subcommand || loweredArgs.startsWith(`${subcommand} `);
241
+ }
242
+
243
+ function isRemovedSetInvocation(loweredArgs: string): boolean {
244
+ return isSubcommandInvocation(loweredArgs, "set");
245
+ }
246
+
247
+ function removedShowPathText(): string {
248
+ return [
249
+ "The /openai-usage-settings show path was removed.",
250
+ "Common settings are interactive through no-args /openai-usage-settings when UI is available.",
251
+ "Use /openai-usage-settings help for supported utility subcommands.",
252
+ ].join("\n");
253
+ }
254
+
255
+ function removedDebugPathText(): string {
256
+ return [
257
+ "The /openai-usage-settings debug path is not a diagnostics alias.",
258
+ "Use /openai-usage-settings diagnostics for diagnostics.",
259
+ "Use /openai-usage-settings help for supported utility subcommands.",
260
+ ].join("\n");
261
+ }
262
+
263
+ function removedSetPathText(): string {
264
+ return [
265
+ "Slash-command setting writes were removed.",
266
+ "Common settings are interactive now through no-args /openai-usage-settings when UI is available.",
267
+ "Advanced settings can be edited in the JSON config file.",
268
+ "Use /openai-usage-settings help for supported utility subcommands.",
269
+ ].join("\n");
270
+ }
271
+
272
+ function usageSettingsNoUiFallbackText(): string {
273
+ return [
274
+ "Interactive settings require UI.",
275
+ "This non-UI fallback is read-only and does not change configuration.",
276
+ "Supported utility subcommands:",
277
+ " /openai-usage-settings usage",
278
+ " /openai-usage-settings refresh",
279
+ " /openai-usage-settings diagnostics",
280
+ " /openai-usage-settings help",
281
+ ].join("\n");
282
+ }
283
+
284
+ function usageSettingsHelpText(): string {
285
+ return [
286
+ "/openai-usage-settings is the only OpenAI usage/settings command.",
287
+ "Common settings are interactive through no-args /openai-usage-settings when UI is available.",
288
+ "Advanced settings are JSON-file-only in the usage config file.",
289
+ "",
290
+ "Supported utility subcommands:",
291
+ " usage Show cached usage and refresh when stale or missing",
292
+ " refresh Force a usage refresh",
293
+ " diagnostics Show runtime, auth, config, and raw-config diagnostics",
294
+ " help Show this help",
295
+ ].join("\n");
296
+ }
297
+
298
+ const SUPPORTED_UTILITY_SUBCOMMANDS = ["usage", "refresh", "diagnostics", "help"] as const;
299
+
300
+ function suggestSettingsSubcommands(argumentPrefix: string) {
301
+ const prefix = argumentPrefix.trim().toLowerCase();
302
+ const matches = SUPPORTED_UTILITY_SUBCOMMANDS
303
+ .filter((command) => command.startsWith(prefix))
304
+ .map((command) => ({
305
+ value: command,
306
+ label: command,
307
+ }));
308
+ return matches.length > 0 ? matches : null;
309
+ }
310
+
311
+ function asUsageSettingsContext(ctx: ExtensionCommandContext): UsageSettingsCommandContext {
312
+ return ctx as UsageSettingsCommandContext;
313
+ }
314
+
315
+ function notify(ctx: UsageSettingsCommandContext, message: string): void {
316
+ ctx.ui.notify(message, "info");
317
+ }
318
+
319
+ function noopConfigChanged(_ctx: UsageSettingsCommandContext): void {
320
+ return undefined;
321
+ }
322
+
323
+ function noopStatusLineReapply(_ctx: UsageSettingsCommandContext): void {
324
+ return undefined;
325
+ }
326
+
327
+ function defaultResolveCredentials(
328
+ ctx: UsageSettingsCommandContext,
329
+ ): Promise<CodexCredentialResolution> {
330
+ return resolveCodexOAuthCredentials({ modelRegistry: ctx.modelRegistry });
331
+ }
@@ -0,0 +1,136 @@
1
+ export type UsageWindow = {
2
+ used_percent?: unknown;
3
+ reset_after_seconds?: unknown;
4
+ reset_at?: unknown;
5
+ };
6
+
7
+ export type RateLimitBucket = {
8
+ allowed?: unknown;
9
+ limit_reached?: unknown;
10
+ primary_window?: unknown;
11
+ secondary_window?: unknown;
12
+ };
13
+
14
+ export type CodexUsageResponse = {
15
+ rate_limit?: unknown;
16
+ additional_rate_limits?: unknown;
17
+ };
18
+
19
+ export type UsageSnapshot = {
20
+ fiveHourLeftPercent: number | null;
21
+ sevenDayLeftPercent: number | null;
22
+ fiveHourResetInSeconds: number | null;
23
+ sevenDayResetInSeconds: number | null;
24
+ isLimited: boolean;
25
+ };
26
+
27
+ export type ParseUsageSnapshotOptions = {
28
+ modelId?: string;
29
+ nowMs?: number;
30
+ };
31
+
32
+ const SPARK_MODEL_ID = "gpt-5.3-codex-spark";
33
+ const SPARK_LIMIT_NAME = "GPT-5.3-Codex-Spark";
34
+ const MILLISECONDS_EPOCH_THRESHOLD = 100_000_000_000;
35
+
36
+ export function parseUsageSnapshot(
37
+ rawResponse: unknown,
38
+ options: ParseUsageSnapshotOptions = {},
39
+ ): UsageSnapshot {
40
+ const response = asRecord(rawResponse);
41
+ const rootBucket = normalizeRateLimitBucket(response?.rate_limit);
42
+ const bucket =
43
+ options.modelId === SPARK_MODEL_ID
44
+ ? findSparkRateLimitBucket(response) ?? rootBucket
45
+ : rootBucket;
46
+
47
+ const primaryWindow = asRecord(bucket?.primary_window);
48
+ const secondaryWindow = asRecord(bucket?.secondary_window);
49
+
50
+ return {
51
+ fiveHourLeftPercent: usedToRemainingPercent(primaryWindow?.used_percent),
52
+ sevenDayLeftPercent: usedToRemainingPercent(secondaryWindow?.used_percent),
53
+ fiveHourResetInSeconds: resetInSeconds(primaryWindow, options.nowMs),
54
+ sevenDayResetInSeconds: resetInSeconds(secondaryWindow, options.nowMs),
55
+ isLimited: bucket?.allowed === false || bucket?.limit_reached === true,
56
+ };
57
+ }
58
+
59
+ function normalizeRateLimitBucket(value: unknown): RateLimitBucket | undefined {
60
+ const record = asRecord(value);
61
+ if (record === undefined) return undefined;
62
+
63
+ if (
64
+ !("primary_window" in record) &&
65
+ !("secondary_window" in record) &&
66
+ !("allowed" in record) &&
67
+ !("limit_reached" in record)
68
+ ) {
69
+ return undefined;
70
+ }
71
+
72
+ return record;
73
+ }
74
+
75
+ function findSparkRateLimitBucket(
76
+ response: Record<string, unknown> | undefined,
77
+ ): RateLimitBucket | undefined {
78
+ if (response === undefined) return undefined;
79
+
80
+ const additionalRateLimits = response.additional_rate_limits;
81
+ if (Array.isArray(additionalRateLimits)) {
82
+ for (const entry of additionalRateLimits) {
83
+ const bucket = extractSparkBucketFromEntry(entry);
84
+ if (bucket !== undefined) return bucket;
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ const additionalRateLimitMap = asRecord(additionalRateLimits);
90
+ if (additionalRateLimitMap === undefined) return undefined;
91
+
92
+ for (const entry of Object.values(additionalRateLimitMap)) {
93
+ const bucket = extractSparkBucketFromEntry(entry);
94
+ if (bucket !== undefined) return bucket;
95
+ }
96
+ return undefined;
97
+ }
98
+
99
+ function extractSparkBucketFromEntry(entry: unknown): RateLimitBucket | undefined {
100
+ const record = asRecord(entry);
101
+ if (record?.limit_name !== SPARK_LIMIT_NAME) return undefined;
102
+ return normalizeRateLimitBucket(record.rate_limit);
103
+ }
104
+
105
+ function usedToRemainingPercent(value: unknown): number | null {
106
+ if (!isFiniteNumber(value)) return null;
107
+ return clampPercent(100 - value);
108
+ }
109
+
110
+ function clampPercent(value: number): number {
111
+ return Math.min(100, Math.max(0, value));
112
+ }
113
+
114
+ function resetInSeconds(
115
+ window: Record<string, unknown> | undefined,
116
+ nowMs = Date.now(),
117
+ ): number | null {
118
+ const resetAfterSeconds = window?.reset_after_seconds;
119
+ if (isFiniteNumber(resetAfterSeconds)) return Math.max(0, resetAfterSeconds);
120
+
121
+ const resetAt = window?.reset_at;
122
+ if (!isFiniteNumber(resetAt)) return null;
123
+
124
+ const resetAtSeconds =
125
+ resetAt > MILLISECONDS_EPOCH_THRESHOLD ? resetAt / 1000 : resetAt;
126
+ return Math.max(0, resetAtSeconds - nowMs / 1000);
127
+ }
128
+
129
+ function isFiniteNumber(value: unknown): value is number {
130
+ return typeof value === "number" && Number.isFinite(value);
131
+ }
132
+
133
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
134
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
135
+ return value as Record<string, unknown>;
136
+ }
@@ -0,0 +1,66 @@
1
+ import type { UsageFetchError } from "./usage-client";
2
+ import { parseUsageSnapshot, type UsageSnapshot } from "./usage-snapshot";
3
+
4
+ export type UsageStateStore = {
5
+ getSnapshot(modelId?: string): UsageSnapshot | undefined;
6
+ storeSnapshot(snapshot: UsageSnapshot, fetchedAt?: Date): void;
7
+ storeRawUsageResponse(rawResponse: unknown, fetchedAt?: Date): void;
8
+ getLastAttemptAt(): Date | undefined;
9
+ getLastSuccessAt(): Date | undefined;
10
+ getLastError(): UsageFetchError | undefined;
11
+ getCurrentError(): UsageFetchError | undefined;
12
+ recordFetchAttempt(at?: Date): void;
13
+ recordFetchError(error: UsageFetchError, attemptedAt?: Date): void;
14
+ };
15
+
16
+ export function createUsageStateStore(): UsageStateStore {
17
+ let snapshot: UsageSnapshot | undefined;
18
+ let rawUsageResponse: unknown | undefined;
19
+ let lastAttemptAt: Date | undefined;
20
+ let lastSuccessAt: Date | undefined;
21
+ let lastError: UsageFetchError | undefined;
22
+ let currentError: UsageFetchError | undefined;
23
+
24
+ return {
25
+ getSnapshot(modelId) {
26
+ if (rawUsageResponse !== undefined) {
27
+ return parseUsageSnapshot(rawUsageResponse, { modelId });
28
+ }
29
+ return snapshot;
30
+ },
31
+ storeSnapshot(nextSnapshot, fetchedAt = new Date()) {
32
+ snapshot = nextSnapshot;
33
+ rawUsageResponse = undefined;
34
+ lastAttemptAt = fetchedAt;
35
+ lastSuccessAt = fetchedAt;
36
+ currentError = undefined;
37
+ },
38
+ storeRawUsageResponse(nextRawUsageResponse, fetchedAt = new Date()) {
39
+ rawUsageResponse = nextRawUsageResponse;
40
+ snapshot = parseUsageSnapshot(nextRawUsageResponse);
41
+ lastAttemptAt = fetchedAt;
42
+ lastSuccessAt = fetchedAt;
43
+ currentError = undefined;
44
+ },
45
+ getLastAttemptAt() {
46
+ return lastAttemptAt;
47
+ },
48
+ getLastSuccessAt() {
49
+ return lastSuccessAt;
50
+ },
51
+ getLastError() {
52
+ return lastError;
53
+ },
54
+ getCurrentError() {
55
+ return currentError;
56
+ },
57
+ recordFetchAttempt(at = new Date()) {
58
+ lastAttemptAt = at;
59
+ },
60
+ recordFetchError(error, attemptedAt = new Date()) {
61
+ lastAttemptAt = attemptedAt;
62
+ currentError = error;
63
+ lastError = error;
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,39 @@
1
+ export type UsageModel = {
2
+ provider: string;
3
+ id?: string;
4
+ };
5
+
6
+ export type UsageModelRegistry = {
7
+ isUsingOAuth(model: UsageModel): boolean;
8
+ };
9
+
10
+ export type UsageVisibilityInput = {
11
+ showAlways: boolean;
12
+ model: UsageModel | undefined;
13
+ modelRegistry: UsageModelRegistry | undefined;
14
+ };
15
+
16
+ export type UsageVisibilityDecision =
17
+ | { visible: true; reason: "show_always" | "eligible_model" }
18
+ | { visible: false; reason: "ineligible_model" };
19
+
20
+ const ELIGIBLE_USAGE_PROVIDERS = new Set(["openai", "openai-codex"]);
21
+
22
+ export function decideUsageVisibility(input: UsageVisibilityInput): UsageVisibilityDecision {
23
+ if (input.showAlways) return { visible: true, reason: "show_always" };
24
+
25
+ if (isUsageEligibleModel(input.model, input.modelRegistry)) {
26
+ return { visible: true, reason: "eligible_model" };
27
+ }
28
+
29
+ return { visible: false, reason: "ineligible_model" };
30
+ }
31
+
32
+ export function isUsageEligibleModel(
33
+ model: UsageModel | undefined,
34
+ modelRegistry: UsageModelRegistry | undefined,
35
+ ): boolean {
36
+ if (model === undefined) return false;
37
+ if (!ELIGIBLE_USAGE_PROVIDERS.has(model.provider)) return false;
38
+ return modelRegistry?.isUsingOAuth(model) === true;
39
+ }