@openlines/opl-pi-sht 0.2.9 → 0.2.11
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 +7 -3
- package/configs/opl-footer.json.sample +1 -1
- package/extensions/opl-footer/README.md +16 -0
- package/extensions/opl-footer/codex-usage.ts +122 -0
- package/extensions/opl-footer/config.ts +1 -1
- package/extensions/opl-footer/index.ts +122 -4
- package/extensions/opl-footer/openrouter-usage.ts +77 -0
- package/extensions/opl-footer/segments/codex-usage.ts +45 -0
- package/extensions/opl-footer/segments/index.ts +4 -0
- package/extensions/opl-footer/segments/openrouter-usage.ts +21 -0
- package/extensions/opl-footer/types.ts +7 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
A portable collection of various Pi coding agent extensions. Repository directories and config files use `opl-`; established Pi-facing commands and tool names stay compatible.
|
|
16
16
|
|
|
17
17
|
>
|
|
18
|
+
> ### v0.2.11 - OpenRouter key-limit usage in opl-footer
|
|
19
|
+
> - `opl-footer` added an OpenRouter API key's used amount, cap, and percentage.
|
|
20
|
+
> ### v0.2.10 - Codex subscription usage in opl-footer
|
|
21
|
+
> - `opl-footer` added ChatGPT Codex subs 5-hour/weekly quota segment with reset countdowns and stale-snapshot fallback.
|
|
18
22
|
> ### v0.2.9 - pi dependencies patch, updated test scripts
|
|
19
23
|
> - `devDependencies` updated to `>=0.87.0`, conforming to latest pi release `v0.99.x`; TUI and tests updates
|
|
20
24
|
> ### v0.2.8 - SSRF and tool-safety hardening
|
|
@@ -22,7 +26,7 @@ A portable collection of various Pi coding agent extensions. Repository director
|
|
|
22
26
|
>
|
|
23
27
|
> See [CHANGELOG](CHANGELOG.md) for more details.
|
|
24
28
|
>
|
|
25
|
-
> `Current project state also marked 'SAFE' for pi
|
|
29
|
+
> `Current project state also marked 'SAFE' for pi v1.0.0 release`
|
|
26
30
|
>
|
|
27
31
|
|
|
28
32
|
## Installation
|
|
@@ -92,7 +96,7 @@ Copy mode overwrites matching destinations. Link mode skips existing destination
|
|
|
92
96
|
| [`opl-questionnaire`](extensions/opl-questionnaire/README.md) | Interactive structured-choice tool. | `questionnaire`; no config. |
|
|
93
97
|
| [`opl-input`](extensions/opl-input/README.md) | Configurable replacement editor - enhanced [pikit chat-input](https://github.com/adrianapan/pikit) (because pet is life, and configurable).  | No commands/tools;`opl-input.json`. |
|
|
94
98
|
| [`opl-modes`](extensions/opl-modes/README.md) | Mode, plan, tool-safety, lazy-tool-loading, and active-appearance manager - highly-modified, configrable and enhanced mode-switcher. | `/mode`, `/chat`, `/plan`, `/execute`, `plan_complete`, `load_tools`; `opl-modes.json`. |
|
|
95
|
-
| [`opl-footer`](extensions/opl-footer/README.md) | Configurable multi-row status footer - highly-specialized, and enhanced [pikit footer](https://github.com/adrianapan/pikit).
|
|
99
|
+
| [`opl-footer`](extensions/opl-footer/README.md) | Configurable multi-row status footer - highly-specialized, and enhanced [pikit footer](https://github.com/adrianapan/pikit). | `/configure-opl`; `opl-footer.json`. |
|
|
96
100
|
|
|
97
101
|
## What you'll gain
|
|
98
102
|
|
|
@@ -137,7 +141,7 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
|
|
|
137
141
|
|
|
138
142
|
### See what the agent is doing
|
|
139
143
|
|
|
140
|
-
- **`opl-footer`** surfaces model, cost, token and cache activity, git state, agent status,
|
|
144
|
+
- **`opl-footer`** surfaces model, cost, token and cache activity, git state, agent status, per-turn timing, an optional exact remaining ChatGPT Codex subscription-quota segment, and optional OpenRouter API-key cap usage. The OpenRouter segment resolves Pi's active key locally and calls only OpenRouter's key endpoint; keys and response bodies are neither persisted nor logged. `/configure-opl` edits and reorders its layout interactively.
|
|
141
145
|
- **`opl-input`** is a configurable editor that reflects the active mode's identity, so you always know which mode you are typing into.
|
|
142
146
|

|
|
143
147
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "Available segment IDs: pi, model, path, git, thinking, caveman, plan_mode, chat_mode, mode_switcher, separator, token_in, token_out, token_total, cache_read, cache_write, cost, context_pct, context_total, session_stats, perf_stats, status, text:<literal
|
|
2
|
+
"_comment": "Available segment IDs: pi, model, path, git, thinking, caveman, plan_mode, chat_mode, mode_switcher, separator, token_in, token_out, token_total, cache_read, cache_write, cost, context_pct, context_total, session_stats, perf_stats, status, codex_usage, openrouter_usage, text:<literal>. codex_usage and openrouter_usage are hidden by default; add codex_usage to show ChatGPT Codex subscription windows, or openrouter_usage to show the active OpenRouter API-key limit usage.",
|
|
3
3
|
"row1LeftSegments": [
|
|
4
4
|
"pi",
|
|
5
5
|
"separator",
|
|
@@ -31,6 +31,8 @@ The third row is populated after the first completed turn. Its session and perfo
|
|
|
31
31
|
- **Context bar**: configurable gradient bar with percentage and context-window size
|
|
32
32
|
- **Git integration**: branch plus staged, unstaged, and untracked counts, with invalidation after relevant file and Git commands
|
|
33
33
|
- **Token and cost tracking**: total, cache, input/output, and accumulated cost segments
|
|
34
|
+
- **Codex subscription quota**: optional exact 5-hour and weekly ChatGPT Codex usage percentages with reset countdowns and stale-snapshot fallback
|
|
35
|
+
- **OpenRouter key-limit usage**: optional per-key paid-usage amount, limit, and percentage with stale-snapshot fallback
|
|
34
36
|
- **Session statistics**: prompt, API-call, and model tool-call counts
|
|
35
37
|
- **Performance statistics**: cumulative LLM/tool duration, most recent user-prompt-to-completion turnaround time, average time to first token, output rate, and cache-hit percentage
|
|
36
38
|
- **Thinking, mode, and status indicators**: thinking-level colors plus caveman, plan, chat, unified mode, and `Working`/`Waiting`/`Ready` status segments
|
|
@@ -91,9 +93,23 @@ See the tracked [`configs/opl-footer.json.sample`](../../configs/opl-footer.json
|
|
|
91
93
|
| `session_stats` | Prompt, API-call, and tool-call counts | Hidden until the first prompt |
|
|
92
94
|
| `perf_stats` | LLM/tool timing, TTFT, output rate, and cache-hit percentage | Hidden until the first prompt |
|
|
93
95
|
| `status` | `Working`, `Waiting`, or `Ready` | `accent`, `warning`, and `success` theme colors respectively; optional and hidden by default |
|
|
96
|
+
| `codex_usage` | `5h 76% ↻2h18m · W 37% ↻3d7h` | Exact remaining ChatGPT Codex subscription quota; OAuth-only, optional and hidden by default; a retained snapshot is marked `(stale)` after refresh failure |
|
|
97
|
+
| `openrouter_usage` | `$3.4500 / $25.0000 (13.8%)` | OpenRouter API-key limit usage; only on the selected `openrouter` provider, optional and hidden by default; a retained snapshot is marked `(stale)` after refresh failure |
|
|
94
98
|
| `separator` | `\|` divider | Coloured via `separator` in `colors` |
|
|
95
99
|
| `text:...` | Literal text, e.g. `text:⚡` | — |
|
|
96
100
|
|
|
101
|
+
## Codex Subscription Usage
|
|
102
|
+
|
|
103
|
+
Add `codex_usage` to any row through `/configure-opl` or `opl-footer.json`. While the active model is the OAuth-authenticated `openai-codex` provider, the footer asks Pi's model registry to resolve the same short-lived OAuth access token that Pi uses for the selected model, then sends `GET https://chatgpt.com/backend-api/wham/usage` directly from the local Pi process. The request carries `Authorization: Bearer <Pi OAuth token>`, `Accept: application/json`, a fixed `User-Agent: opl-footer-codex-usage`, and the token's `chatgpt_account_id` JWT claim as `chatgpt-account-id` when present. It runs after session start, model selection, and settled agent turns, with a 30-second refresh floor and a 15-second request timeout. It classifies the returned windows by duration rather than response order. This is ChatGPT subscription quota, not OpenAI Platform API-key usage or billing.
|
|
104
|
+
|
|
105
|
+
No OAuth token, account ID, or response body is written to disk, logged, added to the Pi session, or sent anywhere other than `chatgpt.com` for that request. **The footer retains only the parsed 5-hour/weekly percentage and reset time in process memory for the active session.** The endpoint is an internal ChatGPT backend API and can change without notice. A failed refresh keeps the most recent exact snapshot and marks it `(stale)`; if no request has ever succeeded, the segment stays hidden. Disabling the segment prevents future requests and discards any result from a request already in flight.
|
|
106
|
+
|
|
107
|
+
## OpenRouter Usage
|
|
108
|
+
|
|
109
|
+
Add `openrouter_usage` to any row through `/configure-opl` or `opl-footer.json`. While the selected model provider is `openrouter`, the footer resolves the same API key Pi uses for inference and sends `GET https://openrouter.ai/api/v1/key` from the local Pi process. It renders the configured per-key limit as `$<limit - limit_remaining> / $<limit> (<percent>%)`; an absent, malformed, or unlimited key cap leaves the segment hidden. The numerator deliberately uses OpenRouter's authoritative `limit_remaining`, rather than `usage` or `byok_usage`, because those ledgers may not both count against the key limit.
|
|
110
|
+
|
|
111
|
+
It runs after session start, model selection, and settled agent turns, with a 30-second refresh floor and a 15-second request timeout. No API key or response body is written to disk, logged, added to the Pi session, or sent anywhere other than `openrouter.ai`. A failed refresh keeps the most recent exact snapshot and marks it `(stale)`; if no request has ever succeeded, the segment stays hidden. Disabling the segment prevents future requests and discards any result from a request already in flight.
|
|
112
|
+
|
|
97
113
|
## Context Bar
|
|
98
114
|
|
|
99
115
|
The `context_pct` segment's bar is fully configurable via `segmentOptions.contextBar`:
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
4
|
+
const FIVE_HOURS_SECONDS = 5 * 60 * 60;
|
|
5
|
+
const WEEK_SECONDS = 7 * 24 * 60 * 60;
|
|
6
|
+
const WINDOW_TOLERANCE_SECONDS = 120;
|
|
7
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
8
|
+
const OPENAI_AUTH_CLAIM = "https://api.openai.com/auth";
|
|
9
|
+
|
|
10
|
+
export interface CodexUsageWindow {
|
|
11
|
+
usedPercent: number;
|
|
12
|
+
windowSeconds: number;
|
|
13
|
+
resetAt?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CodexUsageSnapshot {
|
|
17
|
+
fiveHour?: CodexUsageWindow;
|
|
18
|
+
weekly?: CodexUsageWindow;
|
|
19
|
+
fetchedAt: number;
|
|
20
|
+
stale: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
24
|
+
|
|
25
|
+
type UsageRecord = Record<string, unknown>;
|
|
26
|
+
|
|
27
|
+
function asRecord(value: unknown): UsageRecord | null {
|
|
28
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
29
|
+
? value as UsageRecord
|
|
30
|
+
: null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeWindow(value: unknown): CodexUsageWindow | null {
|
|
34
|
+
const record = asRecord(value);
|
|
35
|
+
if (!record) return null;
|
|
36
|
+
|
|
37
|
+
const usedPercent = record.used_percent;
|
|
38
|
+
const windowSeconds = record.limit_window_seconds;
|
|
39
|
+
const resetAt = record.reset_at;
|
|
40
|
+
if (
|
|
41
|
+
typeof usedPercent !== "number" || !Number.isFinite(usedPercent) ||
|
|
42
|
+
typeof windowSeconds !== "number" || !Number.isFinite(windowSeconds) || windowSeconds <= 0
|
|
43
|
+
) return null;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
usedPercent,
|
|
47
|
+
windowSeconds,
|
|
48
|
+
...(typeof resetAt === "number" && Number.isFinite(resetAt) ? { resetAt } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function matchesWindow(window: CodexUsageWindow, seconds: number): boolean {
|
|
53
|
+
return Math.abs(window.windowSeconds - seconds) <= WINDOW_TOLERANCE_SECONDS;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseCodexUsage(data: unknown, fetchedAt = Date.now()): CodexUsageSnapshot | null {
|
|
57
|
+
const root = asRecord(data);
|
|
58
|
+
const rateLimit = asRecord(root?.rate_limit);
|
|
59
|
+
const windows = [
|
|
60
|
+
normalizeWindow(rateLimit?.primary_window),
|
|
61
|
+
normalizeWindow(rateLimit?.secondary_window),
|
|
62
|
+
].filter((window): window is CodexUsageWindow => window !== null);
|
|
63
|
+
|
|
64
|
+
const fiveHour = windows.find((window) => matchesWindow(window, FIVE_HOURS_SECONDS));
|
|
65
|
+
const weekly = windows.find((window) => matchesWindow(window, WEEK_SECONDS));
|
|
66
|
+
if (!fiveHour && !weekly) return null;
|
|
67
|
+
|
|
68
|
+
return { fiveHour, weekly, fetchedAt, stale: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function accountIdFromToken(token: string): string | undefined {
|
|
72
|
+
try {
|
|
73
|
+
const payload = token.split(".")[1];
|
|
74
|
+
if (!payload) return undefined;
|
|
75
|
+
const claims = asRecord(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
|
|
76
|
+
const auth = asRecord(claims?.[OPENAI_AUTH_CLAIM]);
|
|
77
|
+
return typeof auth?.chatgpt_account_id === "string" ? auth.chatgpt_account_id : undefined;
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function fetchCodexUsage(
|
|
84
|
+
ctx: ExtensionContext,
|
|
85
|
+
fetchFn: FetchLike = globalThis.fetch,
|
|
86
|
+
fetchedAt = Date.now(),
|
|
87
|
+
): Promise<CodexUsageSnapshot> {
|
|
88
|
+
const model = ctx.model;
|
|
89
|
+
if (!model || model.provider !== "openai-codex" || !ctx.modelRegistry.isUsingOAuth(model)) {
|
|
90
|
+
throw new Error("Codex usage requires an openai-codex OAuth subscription model");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
94
|
+
if (!auth.ok || !auth.apiKey) throw new Error("OpenAI Codex OAuth authentication is unavailable");
|
|
95
|
+
|
|
96
|
+
const accountId = accountIdFromToken(auth.apiKey);
|
|
97
|
+
const response = await fetchFn(USAGE_URL, {
|
|
98
|
+
headers: {
|
|
99
|
+
Authorization: `Bearer ${auth.apiKey}`,
|
|
100
|
+
Accept: "application/json",
|
|
101
|
+
"User-Agent": "opl-footer-codex-usage",
|
|
102
|
+
...(accountId ? { "chatgpt-account-id": accountId } : {}),
|
|
103
|
+
},
|
|
104
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
105
|
+
});
|
|
106
|
+
if (!response.ok) throw new Error(`Codex usage request failed (${response.status})`);
|
|
107
|
+
|
|
108
|
+
const snapshot = parseCodexUsage(await response.json(), fetchedAt);
|
|
109
|
+
if (!snapshot) throw new Error("Codex usage response contained no supported windows");
|
|
110
|
+
return snapshot;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function refreshCodexUsageSnapshot(
|
|
114
|
+
previous: CodexUsageSnapshot | null,
|
|
115
|
+
load: () => Promise<CodexUsageSnapshot>,
|
|
116
|
+
): Promise<CodexUsageSnapshot | null> {
|
|
117
|
+
try {
|
|
118
|
+
return await load();
|
|
119
|
+
} catch {
|
|
120
|
+
return previous ? { ...previous, stale: true } : null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -27,7 +27,7 @@ export const CONFIGURABLE_SEGMENTS: StatusLineSegmentId[] = [
|
|
|
27
27
|
"pi", "model", "path", "git", "thinking", "caveman", "plan_mode",
|
|
28
28
|
"chat_mode", "mode_switcher", "token_in", "token_out", "token_total",
|
|
29
29
|
"cache_read", "cache_write", "cost", "context_pct", "context_total",
|
|
30
|
-
"session_stats", "perf_stats", "status",
|
|
30
|
+
"session_stats", "perf_stats", "status", "codex_usage", "openrouter_usage",
|
|
31
31
|
];
|
|
32
32
|
|
|
33
33
|
const DEFAULT_LAYOUTS: Record<FooterLayoutKey, StatusLineSegmentId[]> = {
|
|
@@ -12,6 +12,8 @@ import { getEffectiveConfig } from "./config.js";
|
|
|
12
12
|
import { getIcons } from "./icons.js";
|
|
13
13
|
import { getDefaultColors, fg } from "./theme.js";
|
|
14
14
|
import { showFooterConfigurator } from "./configure.js";
|
|
15
|
+
import { fetchCodexUsage, refreshCodexUsageSnapshot, type CodexUsageSnapshot } from "./codex-usage.js";
|
|
16
|
+
import { fetchOpenRouterUsage, refreshOpenRouterUsageSnapshot, type OpenRouterUsageSnapshot } from "./openrouter-usage.js";
|
|
15
17
|
|
|
16
18
|
const GIT_BRANCH_PATTERNS: RegExp[] = [
|
|
17
19
|
// init/clone included: creating a repo mid-session must clear the not-a-repo back-off.
|
|
@@ -19,8 +21,10 @@ const GIT_BRANCH_PATTERNS: RegExp[] = [
|
|
|
19
21
|
/\bgit\s+stash\s+(pop|apply)/,
|
|
20
22
|
];
|
|
21
23
|
|
|
22
|
-
/** Row keys
|
|
23
|
-
const
|
|
24
|
+
/** Row keys used to detect whether an optional segment is enabled. */
|
|
25
|
+
const CODEX_USAGE_REFRESH_MS = 30_000;
|
|
26
|
+
|
|
27
|
+
const LAYOUT_ROWS = [
|
|
24
28
|
"row1LeftSegments",
|
|
25
29
|
"row1RightSegments",
|
|
26
30
|
"row2LeftSegments",
|
|
@@ -116,6 +120,97 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
116
120
|
let lastBranchLength = 0;
|
|
117
121
|
let cachedUsageStats: UsageStats | null = null;
|
|
118
122
|
let tuiRef: TUI | null = null;
|
|
123
|
+
let codexUsage: CodexUsageSnapshot | null = null;
|
|
124
|
+
let codexUsageLastAttempt = 0;
|
|
125
|
+
let codexUsageGeneration = 0;
|
|
126
|
+
let codexUsageInFlight: Promise<void> | null = null;
|
|
127
|
+
let openRouterUsage: OpenRouterUsageSnapshot | null = null;
|
|
128
|
+
let openRouterUsageLastAttempt = 0;
|
|
129
|
+
let openRouterUsageGeneration = 0;
|
|
130
|
+
let openRouterUsageInFlight: Promise<void> | null = null;
|
|
131
|
+
|
|
132
|
+
const refreshOpenRouterUsage = (ctx: ExtensionContext, force = false): Promise<void> => {
|
|
133
|
+
const isOpenRouter = ctx.model?.provider === "openrouter";
|
|
134
|
+
if (!isOpenRouter) {
|
|
135
|
+
openRouterUsageGeneration++;
|
|
136
|
+
openRouterUsageInFlight = null;
|
|
137
|
+
openRouterUsage = null;
|
|
138
|
+
tuiRef?.requestRender();
|
|
139
|
+
return Promise.resolve();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const config = getEffectiveConfig();
|
|
143
|
+
const enabled = LAYOUT_ROWS.some((row) => config[row]?.includes("openrouter_usage"));
|
|
144
|
+
if (!enabled) {
|
|
145
|
+
openRouterUsageGeneration++;
|
|
146
|
+
openRouterUsageInFlight = null;
|
|
147
|
+
openRouterUsage = null;
|
|
148
|
+
openRouterUsageLastAttempt = 0;
|
|
149
|
+
tuiRef?.requestRender();
|
|
150
|
+
return Promise.resolve();
|
|
151
|
+
}
|
|
152
|
+
if (openRouterUsageInFlight) return openRouterUsageInFlight;
|
|
153
|
+
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
if (!force && now - openRouterUsageLastAttempt < CODEX_USAGE_REFRESH_MS) return Promise.resolve();
|
|
156
|
+
openRouterUsageLastAttempt = now;
|
|
157
|
+
const generation = ++openRouterUsageGeneration;
|
|
158
|
+
|
|
159
|
+
let request: Promise<void>;
|
|
160
|
+
request = refreshOpenRouterUsageSnapshot(openRouterUsage, () => fetchOpenRouterUsage(ctx))
|
|
161
|
+
.then((snapshot) => {
|
|
162
|
+
if (generation !== openRouterUsageGeneration) return;
|
|
163
|
+
openRouterUsage = snapshot;
|
|
164
|
+
tuiRef?.requestRender();
|
|
165
|
+
})
|
|
166
|
+
.finally(() => {
|
|
167
|
+
if (openRouterUsageInFlight === request) openRouterUsageInFlight = null;
|
|
168
|
+
});
|
|
169
|
+
openRouterUsageInFlight = request;
|
|
170
|
+
return request;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const refreshCodexUsage = (ctx: ExtensionContext, force = false): Promise<void> => {
|
|
174
|
+
const model = ctx.model;
|
|
175
|
+
const isCodexSubscription = model?.provider === "openai-codex" && ctx.modelRegistry.isUsingOAuth(model);
|
|
176
|
+
if (!isCodexSubscription) {
|
|
177
|
+
codexUsageGeneration++;
|
|
178
|
+
codexUsageInFlight = null;
|
|
179
|
+
codexUsage = null;
|
|
180
|
+
tuiRef?.requestRender();
|
|
181
|
+
return Promise.resolve();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const config = getEffectiveConfig();
|
|
185
|
+
const enabled = LAYOUT_ROWS.some((row) => config[row]?.includes("codex_usage"));
|
|
186
|
+
if (!enabled) {
|
|
187
|
+
codexUsageGeneration++;
|
|
188
|
+
codexUsageInFlight = null;
|
|
189
|
+
codexUsage = null;
|
|
190
|
+
codexUsageLastAttempt = 0;
|
|
191
|
+
tuiRef?.requestRender();
|
|
192
|
+
return Promise.resolve();
|
|
193
|
+
}
|
|
194
|
+
if (codexUsageInFlight) return codexUsageInFlight;
|
|
195
|
+
|
|
196
|
+
const now = Date.now();
|
|
197
|
+
if (!force && now - codexUsageLastAttempt < CODEX_USAGE_REFRESH_MS) return Promise.resolve();
|
|
198
|
+
codexUsageLastAttempt = now;
|
|
199
|
+
const generation = ++codexUsageGeneration;
|
|
200
|
+
|
|
201
|
+
let request: Promise<void>;
|
|
202
|
+
request = refreshCodexUsageSnapshot(codexUsage, () => fetchCodexUsage(ctx))
|
|
203
|
+
.then((snapshot) => {
|
|
204
|
+
if (generation !== codexUsageGeneration) return;
|
|
205
|
+
codexUsage = snapshot;
|
|
206
|
+
tuiRef?.requestRender();
|
|
207
|
+
})
|
|
208
|
+
.finally(() => {
|
|
209
|
+
if (codexUsageInFlight === request) codexUsageInFlight = null;
|
|
210
|
+
});
|
|
211
|
+
codexUsageInFlight = request;
|
|
212
|
+
return request;
|
|
213
|
+
};
|
|
119
214
|
|
|
120
215
|
// Session stats accumulators (timing only; counts are reconstructed from the branch)
|
|
121
216
|
let llmMs = 0;
|
|
@@ -132,7 +227,11 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
132
227
|
pi.registerCommand("configure-opl", {
|
|
133
228
|
description: "Interactively configure the OPL footer layout",
|
|
134
229
|
handler: async (_args, ctx) => {
|
|
135
|
-
await showFooterConfigurator(ctx, () =>
|
|
230
|
+
await showFooterConfigurator(ctx, () => {
|
|
231
|
+
tuiRef?.requestRender();
|
|
232
|
+
void refreshCodexUsage(ctx, true);
|
|
233
|
+
void refreshOpenRouterUsage(ctx, true);
|
|
234
|
+
});
|
|
136
235
|
},
|
|
137
236
|
});
|
|
138
237
|
|
|
@@ -142,6 +241,14 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
142
241
|
currentCtx = ctx;
|
|
143
242
|
lastBranchLength = 0;
|
|
144
243
|
cachedUsageStats = null;
|
|
244
|
+
codexUsage = null;
|
|
245
|
+
codexUsageLastAttempt = 0;
|
|
246
|
+
codexUsageGeneration++;
|
|
247
|
+
codexUsageInFlight = null;
|
|
248
|
+
openRouterUsage = null;
|
|
249
|
+
openRouterUsageLastAttempt = 0;
|
|
250
|
+
openRouterUsageGeneration++;
|
|
251
|
+
openRouterUsageInFlight = null;
|
|
145
252
|
llmMs = 0;
|
|
146
253
|
toolMs = 0;
|
|
147
254
|
ttftSamples = [];
|
|
@@ -155,9 +262,16 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
155
262
|
|
|
156
263
|
if (ctx.hasUI) {
|
|
157
264
|
setupFooter(ctx);
|
|
265
|
+
void refreshCodexUsage(ctx, true);
|
|
266
|
+
void refreshOpenRouterUsage(ctx, true);
|
|
158
267
|
}
|
|
159
268
|
});
|
|
160
269
|
|
|
270
|
+
pi.on("model_select", async (_event: unknown, ctx: ExtensionContext) => {
|
|
271
|
+
void refreshCodexUsage(ctx, true);
|
|
272
|
+
void refreshOpenRouterUsage(ctx, true);
|
|
273
|
+
});
|
|
274
|
+
|
|
161
275
|
// Track user-prompt-to-completion turnaround. agent_start may fire multiple
|
|
162
276
|
// times per user turn (retries/compaction); only the first since the last
|
|
163
277
|
// settle marks when the user's prompt began processing. agent_settled is the
|
|
@@ -175,6 +289,8 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
175
289
|
}
|
|
176
290
|
statusTracker.agentSettled();
|
|
177
291
|
tuiRef?.requestRender();
|
|
292
|
+
void refreshCodexUsage(_ctx);
|
|
293
|
+
void refreshOpenRouterUsage(_ctx);
|
|
178
294
|
});
|
|
179
295
|
|
|
180
296
|
pi.on("turn_start", async (_event: unknown, _ctx: ExtensionContext) => {
|
|
@@ -292,7 +408,7 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
292
408
|
|
|
293
409
|
// Get git status (cached). Skip the probes entirely when no visible row renders the
|
|
294
410
|
// git segment — otherwise an unused cell keeps spawning git once per second.
|
|
295
|
-
const usesGit =
|
|
411
|
+
const usesGit = LAYOUT_ROWS.some((row) => effectiveConfig[row]?.includes("git"));
|
|
296
412
|
const gitBranch = usesGit ? footerDataRef?.getGitBranch() ?? null : null;
|
|
297
413
|
const gitStatus = usesGit
|
|
298
414
|
? getGitStatus(gitBranch)
|
|
@@ -339,6 +455,8 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
339
455
|
icons: getIcons(effectiveConfig.icons),
|
|
340
456
|
sessionStats: { prompts: branchPrompts, apiCalls: branchApiCalls, toolCalls: branchToolCalls, llmMs, toolMs, ttftSamples, lastTurnaroundMs },
|
|
341
457
|
agentStatus: statusTracker.status(),
|
|
458
|
+
codexUsage,
|
|
459
|
+
openRouterUsage,
|
|
342
460
|
};
|
|
343
461
|
}
|
|
344
462
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const USAGE_URL = "https://openrouter.ai/api/v1/key";
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
5
|
+
|
|
6
|
+
export interface OpenRouterUsageSnapshot {
|
|
7
|
+
used: number;
|
|
8
|
+
limit: number;
|
|
9
|
+
fetchedAt: number;
|
|
10
|
+
stale: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
14
|
+
type UsageRecord = Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
function asRecord(value: unknown): UsageRecord | null {
|
|
17
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
18
|
+
? value as UsageRecord
|
|
19
|
+
: null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseOpenRouterUsage(data: unknown, fetchedAt = Date.now()): OpenRouterUsageSnapshot | null {
|
|
23
|
+
const root = asRecord(data);
|
|
24
|
+
const key = asRecord(root?.data);
|
|
25
|
+
const limit = key?.limit;
|
|
26
|
+
const remaining = key?.limit_remaining;
|
|
27
|
+
if (
|
|
28
|
+
typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0 ||
|
|
29
|
+
typeof remaining !== "number" || !Number.isFinite(remaining)
|
|
30
|
+
) return null;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
used: Math.max(0, Math.round((limit - remaining) * 1_000_000_000) / 1_000_000_000),
|
|
34
|
+
limit,
|
|
35
|
+
fetchedAt,
|
|
36
|
+
stale: false,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function refreshOpenRouterUsageSnapshot(
|
|
41
|
+
previous: OpenRouterUsageSnapshot | null,
|
|
42
|
+
load: () => Promise<OpenRouterUsageSnapshot>,
|
|
43
|
+
): Promise<OpenRouterUsageSnapshot | null> {
|
|
44
|
+
try {
|
|
45
|
+
return await load();
|
|
46
|
+
} catch {
|
|
47
|
+
return previous ? { ...previous, stale: true } : null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function fetchOpenRouterUsage(
|
|
52
|
+
ctx: ExtensionContext,
|
|
53
|
+
fetchFn: FetchLike = globalThis.fetch,
|
|
54
|
+
fetchedAt = Date.now(),
|
|
55
|
+
): Promise<OpenRouterUsageSnapshot> {
|
|
56
|
+
const model = ctx.model;
|
|
57
|
+
if (!model || model.provider !== "openrouter") {
|
|
58
|
+
throw new Error("OpenRouter usage requires an openrouter model");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
62
|
+
if (!auth.ok || !auth.apiKey) throw new Error("OpenRouter authentication is unavailable");
|
|
63
|
+
|
|
64
|
+
const response = await fetchFn(USAGE_URL, {
|
|
65
|
+
headers: {
|
|
66
|
+
Authorization: `Bearer ${auth.apiKey}`,
|
|
67
|
+
Accept: "application/json",
|
|
68
|
+
"User-Agent": "opl-footer-openrouter-usage",
|
|
69
|
+
},
|
|
70
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
71
|
+
});
|
|
72
|
+
if (!response.ok) throw new Error(`OpenRouter usage request failed (${response.status})`);
|
|
73
|
+
|
|
74
|
+
const snapshot = parseOpenRouterUsage(await response.json(), fetchedAt);
|
|
75
|
+
if (!snapshot) throw new Error("OpenRouter usage response contained no usable key limit");
|
|
76
|
+
return snapshot;
|
|
77
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { RenderedSegment, SegmentContext } from "../types.js";
|
|
2
|
+
import { applyColor } from "../theme.js";
|
|
3
|
+
|
|
4
|
+
function formatPercent(value: number): string {
|
|
5
|
+
const clamped = Math.max(0, Math.min(100, value));
|
|
6
|
+
return Number.isInteger(clamped) ? String(clamped) : clamped.toFixed(1);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function formatReset(resetAt: number | undefined, nowMs: number): string {
|
|
10
|
+
if (resetAt === undefined) return "";
|
|
11
|
+
const resetMs = resetAt > 10_000_000_000 ? resetAt : resetAt * 1000;
|
|
12
|
+
const remainingMinutes = Math.max(0, Math.floor((resetMs - nowMs) / 60_000));
|
|
13
|
+
if (remainingMinutes === 0) return "now";
|
|
14
|
+
|
|
15
|
+
const days = Math.floor(remainingMinutes / (24 * 60));
|
|
16
|
+
const hours = Math.floor((remainingMinutes % (24 * 60)) / 60);
|
|
17
|
+
if (days > 0) return `${days}d${hours}h`;
|
|
18
|
+
|
|
19
|
+
const minutes = remainingMinutes % 60;
|
|
20
|
+
if (hours > 0) return `${hours}h${minutes}m`;
|
|
21
|
+
return `${minutes}m`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const codexUsageSegment = {
|
|
25
|
+
id: "codex_usage" as const,
|
|
26
|
+
render(ctx: SegmentContext): RenderedSegment {
|
|
27
|
+
const snapshot = ctx.codexUsage;
|
|
28
|
+
if (!snapshot) return { content: "", visible: false };
|
|
29
|
+
|
|
30
|
+
const nowMs = ctx.now ?? Date.now();
|
|
31
|
+
const parts: string[] = [];
|
|
32
|
+
if (snapshot.fiveHour) {
|
|
33
|
+
const reset = formatReset(snapshot.fiveHour.resetAt, nowMs);
|
|
34
|
+
parts.push(`5h ${formatPercent(100 - snapshot.fiveHour.usedPercent)}% ${reset ? `↻${reset}` : ""}`);
|
|
35
|
+
}
|
|
36
|
+
if (snapshot.weekly) {
|
|
37
|
+
const reset = formatReset(snapshot.weekly.resetAt, nowMs);
|
|
38
|
+
parts.push(`W ${formatPercent(100 - snapshot.weekly.usedPercent)}% ${reset ? `↻${reset}` : ""}`);
|
|
39
|
+
}
|
|
40
|
+
if (parts.length === 0) return { content: "", visible: false };
|
|
41
|
+
|
|
42
|
+
const stale = snapshot.stale ? applyColor(ctx.theme, "dim", " (stale)") : "";
|
|
43
|
+
return { content: parts.join(applyColor(ctx.theme, "dim", " · ")) + stale, visible: true };
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -14,6 +14,8 @@ import { chatModeSegment } from "./chat-mode.js";
|
|
|
14
14
|
import { modeSwitcherSegment } from "./mode-switcher.js";
|
|
15
15
|
import { sessionStatsSegment, perfStatsSegment } from "./session-stats.js";
|
|
16
16
|
import { statusSegment } from "./status.js";
|
|
17
|
+
import { codexUsageSegment } from "./codex-usage.js";
|
|
18
|
+
import { openRouterUsageSegment } from "./openrouter-usage.js";
|
|
17
19
|
|
|
18
20
|
const SEGMENTS = {
|
|
19
21
|
pi: piSegment,
|
|
@@ -37,6 +39,8 @@ const SEGMENTS = {
|
|
|
37
39
|
session_stats: sessionStatsSegment,
|
|
38
40
|
perf_stats: perfStatsSegment,
|
|
39
41
|
status: statusSegment,
|
|
42
|
+
codex_usage: codexUsageSegment,
|
|
43
|
+
openrouter_usage: openRouterUsageSegment,
|
|
40
44
|
};
|
|
41
45
|
|
|
42
46
|
export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RenderedSegment, SegmentContext } from "../types.js";
|
|
2
|
+
import { applyColor } from "../theme.js";
|
|
3
|
+
|
|
4
|
+
function formatUsd(value: number): string {
|
|
5
|
+
return `$${value.toFixed(4)}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const openRouterUsageSegment = {
|
|
9
|
+
id: "openrouter_usage" as const,
|
|
10
|
+
render(ctx: SegmentContext): RenderedSegment {
|
|
11
|
+
const snapshot = ctx.openRouterUsage;
|
|
12
|
+
if (!snapshot) return { content: "", visible: false };
|
|
13
|
+
|
|
14
|
+
const percent = (snapshot.used / snapshot.limit) * 100;
|
|
15
|
+
const stale = snapshot.stale ? applyColor(ctx.theme, "dim", " (stale)") : "";
|
|
16
|
+
return {
|
|
17
|
+
content: `${formatUsd(snapshot.used)} / ${formatUsd(snapshot.limit)} (${percent.toFixed(1)}%)${stale}`,
|
|
18
|
+
visible: true,
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { IconSet } from "./icons.js";
|
|
3
|
+
import type { CodexUsageSnapshot } from "./codex-usage.js";
|
|
4
|
+
import type { OpenRouterUsageSnapshot } from "./openrouter-usage.js";
|
|
3
5
|
|
|
4
6
|
// Theme color - either a pi theme color name or a custom hex color
|
|
5
7
|
export type ColorValue = ThemeColor | `#${string}`;
|
|
@@ -54,6 +56,8 @@ export type StatusLineSegmentId =
|
|
|
54
56
|
| "session_stats"
|
|
55
57
|
| "perf_stats"
|
|
56
58
|
| "status"
|
|
59
|
+
| "codex_usage"
|
|
60
|
+
| "openrouter_usage"
|
|
57
61
|
| "separator"
|
|
58
62
|
| `text:${string}`;
|
|
59
63
|
|
|
@@ -131,6 +135,9 @@ export interface SegmentContext {
|
|
|
131
135
|
icons: IconSet;
|
|
132
136
|
sessionStats: SessionStats;
|
|
133
137
|
agentStatus: AgentStatus;
|
|
138
|
+
codexUsage: CodexUsageSnapshot | null;
|
|
139
|
+
openRouterUsage: OpenRouterUsageSnapshot | null;
|
|
140
|
+
now?: number;
|
|
134
141
|
}
|
|
135
142
|
|
|
136
143
|
// Minimal event shapes used by the opl-footer handlers
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlines/opl-pi-sht",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "opl-pi-sht (shouldHaveThat) Pi coding-agent extension kit: token-efficient context, tool and session safety, fingerprinted /init, model benchmarking, web access, and browser automation.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"scripts": {
|
|
43
43
|
"test": "node tests/test-summary.mjs",
|
|
44
44
|
"test:opl-browser": "bun test tests/opl-browser-helpers.test.mjs && OPL_EXTENSION=opl-browser bun test tests/extension-smoke.test.mjs",
|
|
45
|
-
"test:opl-footer": "bun test tests/opl-footer-helpers.test.mjs && OPL_EXTENSION=opl-footer bun test tests/extension-smoke.test.mjs",
|
|
45
|
+
"test:opl-footer": "bun test tests/opl-footer-helpers.test.mjs tests/opl-footer-lifecycle.test.mjs && OPL_EXTENSION=opl-footer bun test tests/extension-smoke.test.mjs",
|
|
46
46
|
"test:opl-guardian": "bun test tests/opl-guardian.test.mjs && OPL_EXTENSION=opl-guardian bun test tests/extension-smoke.test.mjs",
|
|
47
47
|
"test:opl-init": "bun test tests/opl-init-crawl.test.mjs tests/opl-init-fingerprint.test.mjs tests/opl-init-packet.test.mjs && OPL_EXTENSION=opl-init bun test tests/extension-smoke.test.mjs",
|
|
48
48
|
"test:opl-input": "bun test tests/opl-input-style.test.mjs && OPL_EXTENSION=opl-input bun test tests/extension-smoke.test.mjs",
|