Skip to content

Commit 762e8c2

Browse files
committed
fix(codex-usage): select usage bucket by model
1 parent 02c6daf commit 762e8c2

3 files changed

Lines changed: 129 additions & 18 deletions

File tree

MEMORY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- In Pi extensions, do not call action methods such as `getThinkingLevel()` during the factory load; defer them to `session_start` or later handlers.
1313
- Extension statusline entries should be activity-based: only show an extension in status when it is actively running, retrying, or needs attention; avoid permanent “configured/ready/on” statuses.
1414
- Codex usage can be queried without Codex CLI by sending Pi's `openai-codex` bearer token to `https://chatgpt.com/backend-api/wham/usage`; response uses Codex `RateLimitStatusPayload` snake_case fields.
15+
- `pi-codex-usage` statusline must select a rate-limit bucket by current model id/name; `gpt-5.3-codex-spark` can use its own returned bucket instead of primary `codex`.
1516

1617
## TASTE
1718

extensions/pi-codex-usage/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,12 @@ When the selected Pi model provider is `openai-codex`, `pi-codex-usage` refreshe
6767

6868
```text
6969
codex 59% 5h 61% wk
70+
codex spark 100% 5h 100% wk
7071
```
7172

72-
The statusline value uses the cached usage snapshot and refreshes every five minutes while the current model remains `openai-codex`. Switching away from an OpenAI Codex model clears the item.
73+
The statusline value uses the cached usage snapshot and refreshes every five minutes while the current model remains `openai-codex`.
74+
When the selected model has its own returned usage bucket, such as `gpt-5.3-codex-spark`, the statusline switches to that bucket instead of the default `codex` bucket.
75+
Switching away from an OpenAI Codex model clears the item.
7376

7477
Use `/codex-status --no-statusline` for a one-off notification without updating the statusline, or `/codex-status --clear-statusline` to clear the item manually.
7578

extensions/pi-codex-usage/src/codex-usage.ts

Lines changed: 124 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const RESET_FOREGROUND = "\x1b[39m";
2020

2121
type UsageSource = "pi-auth" | "codex-app-server";
2222
type PiModel = NonNullable<ExtensionContext["model"]>;
23+
export type CodexUsageModel = Pick<PiModel, "id" | "name" | "provider">;
2324

2425
type QueryUsageOptions = {
2526
clearStatusline: boolean;
@@ -176,17 +177,21 @@ export default function codexUsage(pi: ExtensionAPI) {
176177
const setUsageStatusline = (
177178
ctx: ExtensionContext,
178179
report: CodexUsageReport,
179-
options: { autoRefresh: boolean },
180+
options: { autoRefresh: boolean; model: CodexUsageModel | undefined },
180181
) => {
181182
if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
182183
statuslineClearTimer = undefined;
183-
ctx.ui.setStatus(STATUS_KEY, formatCodexUsageStatusline(report));
184+
ctx.ui.setStatus(STATUS_KEY, formatCodexUsageStatusline(report, options.model));
184185
if (options.autoRefresh) scheduleStatuslineRefresh(ctx);
185186
else scheduleTemporaryStatuslineClear(ctx);
186187
};
187188

188-
const refreshCurrentCodexUsageStatusline = async (ctx: ExtensionContext, force: boolean) => {
189-
if (!isOpenAICodexModel(ctx.model)) {
189+
const refreshCurrentCodexUsageStatusline = async (
190+
ctx: ExtensionContext,
191+
force: boolean,
192+
model = ctx.model,
193+
) => {
194+
if (!isOpenAICodexModel(model)) {
190195
clearUsageStatusline(ctx);
191196
return;
192197
}
@@ -195,7 +200,7 @@ export default function codexUsage(pi: ExtensionAPI) {
195200
statuslineRequestId = requestId;
196201
const cached = cache && Date.now() - cache.createdAt < CACHE_TTL_MS ? cache : undefined;
197202
if (cached && !force) {
198-
setUsageStatusline(ctx, cached.report, { autoRefresh: true });
203+
setUsageStatusline(ctx, cached.report, { autoRefresh: true, model });
199204
return;
200205
}
201206

@@ -214,7 +219,7 @@ export default function codexUsage(pi: ExtensionAPI) {
214219
}
215220

216221
cache = { createdAt: Date.now(), report: result.report };
217-
setUsageStatusline(ctx, result.report, { autoRefresh: true });
222+
setUsageStatusline(ctx, result.report, { autoRefresh: true, model });
218223
};
219224

220225
pi.registerCommand(COMMAND_NAME, {
@@ -235,7 +240,10 @@ export default function codexUsage(pi: ExtensionAPI) {
235240
const cached = cache && Date.now() - cache.createdAt < CACHE_TTL_MS ? cache : undefined;
236241
if (cached && !options.value.refresh) {
237242
if (options.value.statusline) {
238-
setUsageStatusline(ctx, cached.report, { autoRefresh: isOpenAICodexModel(ctx.model) });
243+
setUsageStatusline(ctx, cached.report, {
244+
autoRefresh: isOpenAICodexModel(ctx.model),
245+
model: ctx.model,
246+
});
239247
}
240248
showReport(ctx, cached.report, true);
241249
return;
@@ -252,7 +260,10 @@ export default function codexUsage(pi: ExtensionAPI) {
252260

253261
cache = { createdAt: Date.now(), report: result.report };
254262
if (options.value.statusline) {
255-
setUsageStatusline(ctx, result.report, { autoRefresh: isOpenAICodexModel(ctx.model) });
263+
setUsageStatusline(ctx, result.report, {
264+
autoRefresh: isOpenAICodexModel(ctx.model),
265+
model: ctx.model,
266+
});
256267
keepStatusline = true;
257268
}
258269
showReport(ctx, result.report, false);
@@ -273,8 +284,11 @@ export default function codexUsage(pi: ExtensionAPI) {
273284
});
274285

275286
pi.on("model_select", (event, ctx) => {
276-
if (isOpenAICodexModel(event.model)) void refreshCurrentCodexUsageStatusline(ctx, false);
277-
else clearUsageStatusline(ctx);
287+
if (isOpenAICodexModel(event.model)) {
288+
void refreshCurrentCodexUsageStatusline(ctx, false, event.model);
289+
} else {
290+
clearUsageStatusline(ctx);
291+
}
278292
});
279293

280294
pi.on("session_shutdown", (_event, ctx) => clearUsageStatusline(ctx));
@@ -324,7 +338,7 @@ function parseArgs(
324338
return { ok: true, value: { clearStatusline, refresh, statusline, timeoutMs } };
325339
}
326340

327-
function isOpenAICodexModel(model: ExtensionContext["model"]): boolean {
341+
function isOpenAICodexModel(model: Pick<PiModel, "provider"> | undefined): boolean {
328342
return model?.provider === CODEX_PROVIDER_ID;
329343
}
330344

@@ -806,19 +820,109 @@ export function formatCodexUsageReport(report: CodexUsageReport, _cacheAgeMs?: n
806820
return lines.join("\n");
807821
}
808822

809-
export function formatCodexUsageStatusline(report: CodexUsageReport): string {
810-
const snapshot =
811-
report.snapshots.find((item) => item.limitId.toLowerCase() === "codex") ??
812-
report.snapshots[0];
823+
export function formatCodexUsageStatusline(
824+
report: CodexUsageReport,
825+
model?: CodexUsageModel,
826+
): string {
827+
const snapshot = selectSnapshotForModel(report, model);
813828
if (!snapshot) return "codex usage unavailable";
814829

815-
const parts = ["codex"];
830+
const parts = [formatStatuslinePrefix(snapshot)];
816831
if (snapshot.primary) parts.push(`${formatRemainingPercent(snapshot.primary)} 5h`);
817832
if (snapshot.secondary) parts.push(`${formatRemainingPercent(snapshot.secondary)} wk`);
818833
if (parts.length === 1 && snapshot.credits) parts.push(formatCredits(snapshot.credits));
819834
return parts.join(" ");
820835
}
821836

837+
function selectSnapshotForModel(
838+
report: CodexUsageReport,
839+
model: CodexUsageModel | undefined,
840+
): NormalizedRateLimitSnapshot | undefined {
841+
const codexSnapshot = report.snapshots.find(isPrimaryCodexSnapshot);
842+
if (!model || !isOpenAICodexModel(model)) return codexSnapshot ?? report.snapshots[0];
843+
844+
const modelKeys = normalizedModelUsageKeys(model);
845+
const exactMatch = report.snapshots.find((snapshot) =>
846+
normalizedSnapshotUsageKeys(snapshot).some((key) => modelKeys.has(key)),
847+
);
848+
if (exactMatch) return exactMatch;
849+
850+
const variants = codexModelVariantKeys(modelKeys);
851+
for (const variant of variants) {
852+
const matches = report.snapshots.filter(
853+
(snapshot) =>
854+
!isPrimaryCodexSnapshot(snapshot) &&
855+
normalizedSnapshotUsageKeys(snapshot).some((key) => normalizedKeyHasToken(key, variant)),
856+
);
857+
if (matches.length === 1) return matches[0];
858+
}
859+
860+
return codexSnapshot ?? report.snapshots[0];
861+
}
862+
863+
function normalizedModelUsageKeys(model: CodexUsageModel): Set<string> {
864+
const keys = new Set<string>();
865+
addNormalizedUsageKey(keys, model.id);
866+
addNormalizedUsageKey(keys, model.name);
867+
868+
for (const key of [...keys]) {
869+
const codexIndex = key.indexOf("codex");
870+
if (codexIndex >= 0) keys.add(key.slice(codexIndex));
871+
}
872+
873+
return keys;
874+
}
875+
876+
function normalizedSnapshotUsageKeys(snapshot: NormalizedRateLimitSnapshot): string[] {
877+
return [normalizedUsageKey(snapshot.limitId), normalizedUsageKey(snapshot.limitName)].filter(
878+
(key): key is string => key !== undefined,
879+
);
880+
}
881+
882+
function addNormalizedUsageKey(keys: Set<string>, value: string | undefined): void {
883+
const key = normalizedUsageKey(value);
884+
if (key) keys.add(key);
885+
}
886+
887+
function normalizedUsageKey(value: string | undefined): string | undefined {
888+
const key = value
889+
?.toLowerCase()
890+
.replace(/[^a-z0-9]+/g, "-")
891+
.replace(/^-+|-+$/g, "");
892+
return key || undefined;
893+
}
894+
895+
function codexModelVariantKeys(modelKeys: Set<string>): string[] {
896+
const variants = new Set<string>();
897+
for (const key of modelKeys) {
898+
const match = key.match(/(?:^|-)codex-(.+)$/);
899+
if (match?.[1]) variants.add(match[1]);
900+
}
901+
return [...variants];
902+
}
903+
904+
function normalizedKeyHasToken(key: string, token: string): boolean {
905+
return (
906+
key === token ||
907+
key.startsWith(`${token}-`) ||
908+
key.endsWith(`-${token}`) ||
909+
key.includes(`-${token}-`)
910+
);
911+
}
912+
913+
function formatStatuslinePrefix(snapshot: NormalizedRateLimitSnapshot): string {
914+
if (isPrimaryCodexSnapshot(snapshot)) return "codex";
915+
const label = snapshot.limitName ?? snapshot.limitId;
916+
return `codex ${compactLimitLabel(label)}`;
917+
}
918+
919+
function compactLimitLabel(label: string): string {
920+
const normalized = label.replace(/[_-]+/g, " ").trim();
921+
const codexVariant = normalized.match(/\bcodex\s+(.+)$/i)?.[1]?.trim();
922+
const compact = codexVariant || normalized;
923+
return compact.toLowerCase().replace(/\s+/g, " ");
924+
}
925+
822926
function formatRemainingPercent(window: NormalizedRateLimitWindow): string {
823927
return `${(100 - clampPercent(window.usedPercent)).toFixed(0)}%`;
824928
}
@@ -840,7 +944,10 @@ function brightenInfoNotification(text: string): string {
840944
}
841945

842946
function isPrimaryCodexSnapshot(snapshot: NormalizedRateLimitSnapshot): boolean {
843-
return snapshot.limitId.toLowerCase() === "codex";
947+
return (
948+
normalizedUsageKey(snapshot.limitId) === "codex" ||
949+
normalizedUsageKey(snapshot.limitName) === "codex"
950+
);
844951
}
845952

846953
function formatWindowLine(label: string, window: NormalizedRateLimitWindow): string {

0 commit comments

Comments
 (0)