@openlines/opl-pi-sht 0.2.10 → 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 CHANGED
@@ -15,6 +15,8 @@
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.
18
20
  > ### v0.2.10 - Codex subscription usage in opl-footer
19
21
  > - `opl-footer` added ChatGPT Codex subs 5-hour/weekly quota segment with reset countdowns and stale-snapshot fallback.
20
22
  > ### v0.2.9 - pi dependencies patch, updated test scripts
@@ -24,7 +26,7 @@ A portable collection of various Pi coding agent extensions. Repository director
24
26
  >
25
27
  > See [CHANGELOG](CHANGELOG.md) for more details.
26
28
  >
27
- > `Current project state also marked 'SAFE' for pi v0.99.x release`
29
+ > `Current project state also marked 'SAFE' for pi v1.0.0 release`
28
30
  >
29
31
 
30
32
  ## Installation
@@ -139,7 +141,7 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
139
141
 
140
142
  ### See what the agent is doing
141
143
 
142
- - **`opl-footer`** surfaces model, cost, token and cache activity, git state, agent status, per-turn timing, and an optional exact remaining ChatGPT Codex 5-hour/weekly subscription-quota segment with reset countdowns and stale-snapshot fallback; `/configure-opl` edits and reorders its layout interactively.
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.
143
145
  - **`opl-input`** is a configurable editor that reflects the active mode's identity, so you always know which mode you are typing into.
144
146
  ![input-footer](images/ss-input-footer.png)
145
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, codex_usage, text:<literal>. codex_usage is hidden by default; add it to any row to show ChatGPT Codex subscription windows.",
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",
@@ -32,6 +32,7 @@ The third row is populated after the first completed turn. Its session and perfo
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
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
35
36
  - **Session statistics**: prompt, API-call, and model tool-call counts
36
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
37
38
  - **Thinking, mode, and status indicators**: thinking-level colors plus caveman, plan, chat, unified mode, and `Working`/`Waiting`/`Ready` status segments
@@ -93,6 +94,7 @@ See the tracked [`configs/opl-footer.json.sample`](../../configs/opl-footer.json
93
94
  | `perf_stats` | LLM/tool timing, TTFT, output rate, and cache-hit percentage | Hidden until the first prompt |
94
95
  | `status` | `Working`, `Waiting`, or `Ready` | `accent`, `warning`, and `success` theme colors respectively; optional and hidden by default |
95
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 |
96
98
  | `separator` | `\|` divider | Coloured via `separator` in `colors` |
97
99
  | `text:...` | Literal text, e.g. `text:⚡` | — |
98
100
 
@@ -102,6 +104,12 @@ Add `codex_usage` to any row through `/configure-opl` or `opl-footer.json`. Whil
102
104
 
103
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.
104
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
+
105
113
  ## Context Bar
106
114
 
107
115
  The `context_pct` segment's bar is fully configurable via `segmentOptions.contextBar`:
@@ -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", "codex_usage",
30
+ "session_stats", "perf_stats", "status", "codex_usage", "openrouter_usage",
31
31
  ];
32
32
 
33
33
  const DEFAULT_LAYOUTS: Record<FooterLayoutKey, StatusLineSegmentId[]> = {
@@ -13,6 +13,7 @@ import { getIcons } from "./icons.js";
13
13
  import { getDefaultColors, fg } from "./theme.js";
14
14
  import { showFooterConfigurator } from "./configure.js";
15
15
  import { fetchCodexUsage, refreshCodexUsageSnapshot, type CodexUsageSnapshot } from "./codex-usage.js";
16
+ import { fetchOpenRouterUsage, refreshOpenRouterUsageSnapshot, type OpenRouterUsageSnapshot } from "./openrouter-usage.js";
16
17
 
17
18
  const GIT_BRANCH_PATTERNS: RegExp[] = [
18
19
  // init/clone included: creating a repo mid-session must clear the not-a-repo back-off.
@@ -123,6 +124,51 @@ export default function footer(pi: ExtensionAPI) {
123
124
  let codexUsageLastAttempt = 0;
124
125
  let codexUsageGeneration = 0;
125
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
+ };
126
172
 
127
173
  const refreshCodexUsage = (ctx: ExtensionContext, force = false): Promise<void> => {
128
174
  const model = ctx.model;
@@ -184,6 +230,7 @@ export default function footer(pi: ExtensionAPI) {
184
230
  await showFooterConfigurator(ctx, () => {
185
231
  tuiRef?.requestRender();
186
232
  void refreshCodexUsage(ctx, true);
233
+ void refreshOpenRouterUsage(ctx, true);
187
234
  });
188
235
  },
189
236
  });
@@ -198,6 +245,10 @@ export default function footer(pi: ExtensionAPI) {
198
245
  codexUsageLastAttempt = 0;
199
246
  codexUsageGeneration++;
200
247
  codexUsageInFlight = null;
248
+ openRouterUsage = null;
249
+ openRouterUsageLastAttempt = 0;
250
+ openRouterUsageGeneration++;
251
+ openRouterUsageInFlight = null;
201
252
  llmMs = 0;
202
253
  toolMs = 0;
203
254
  ttftSamples = [];
@@ -212,11 +263,13 @@ export default function footer(pi: ExtensionAPI) {
212
263
  if (ctx.hasUI) {
213
264
  setupFooter(ctx);
214
265
  void refreshCodexUsage(ctx, true);
266
+ void refreshOpenRouterUsage(ctx, true);
215
267
  }
216
268
  });
217
269
 
218
270
  pi.on("model_select", async (_event: unknown, ctx: ExtensionContext) => {
219
271
  void refreshCodexUsage(ctx, true);
272
+ void refreshOpenRouterUsage(ctx, true);
220
273
  });
221
274
 
222
275
  // Track user-prompt-to-completion turnaround. agent_start may fire multiple
@@ -237,6 +290,7 @@ export default function footer(pi: ExtensionAPI) {
237
290
  statusTracker.agentSettled();
238
291
  tuiRef?.requestRender();
239
292
  void refreshCodexUsage(_ctx);
293
+ void refreshOpenRouterUsage(_ctx);
240
294
  });
241
295
 
242
296
  pi.on("turn_start", async (_event: unknown, _ctx: ExtensionContext) => {
@@ -402,6 +456,7 @@ export default function footer(pi: ExtensionAPI) {
402
456
  sessionStats: { prompts: branchPrompts, apiCalls: branchApiCalls, toolCalls: branchToolCalls, llmMs, toolMs, ttftSamples, lastTurnaroundMs },
403
457
  agentStatus: statusTracker.status(),
404
458
  codexUsage,
459
+ openRouterUsage,
405
460
  };
406
461
  }
407
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
+ }
@@ -15,6 +15,7 @@ import { modeSwitcherSegment } from "./mode-switcher.js";
15
15
  import { sessionStatsSegment, perfStatsSegment } from "./session-stats.js";
16
16
  import { statusSegment } from "./status.js";
17
17
  import { codexUsageSegment } from "./codex-usage.js";
18
+ import { openRouterUsageSegment } from "./openrouter-usage.js";
18
19
 
19
20
  const SEGMENTS = {
20
21
  pi: piSegment,
@@ -39,6 +40,7 @@ const SEGMENTS = {
39
40
  perf_stats: perfStatsSegment,
40
41
  status: statusSegment,
41
42
  codex_usage: codexUsageSegment,
43
+ openrouter_usage: openRouterUsageSegment,
42
44
  };
43
45
 
44
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,6 +1,7 @@
1
1
  import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import type { IconSet } from "./icons.js";
3
3
  import type { CodexUsageSnapshot } from "./codex-usage.js";
4
+ import type { OpenRouterUsageSnapshot } from "./openrouter-usage.js";
4
5
 
5
6
  // Theme color - either a pi theme color name or a custom hex color
6
7
  export type ColorValue = ThemeColor | `#${string}`;
@@ -56,6 +57,7 @@ export type StatusLineSegmentId =
56
57
  | "perf_stats"
57
58
  | "status"
58
59
  | "codex_usage"
60
+ | "openrouter_usage"
59
61
  | "separator"
60
62
  | `text:${string}`;
61
63
 
@@ -134,6 +136,7 @@ export interface SegmentContext {
134
136
  sessionStats: SessionStats;
135
137
  agentStatus: AgentStatus;
136
138
  codexUsage: CodexUsageSnapshot | null;
139
+ openRouterUsage: OpenRouterUsageSnapshot | null;
137
140
  now?: number;
138
141
  }
139
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlines/opl-pi-sht",
3
- "version": "0.2.10",
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",