pi-hypercharm-provider 1.1.4 → 1.2.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/index.ts CHANGED
@@ -4,13 +4,11 @@
4
4
  * Registers HyperCharm (hyper.charm.land) as a custom provider using the
5
5
  * openai-completions API. Base URL: https://hyper.charm.land/v1
6
6
  *
7
- * HyperCharm provides hyperoptimized coding models via an OpenAI-compatible API.
8
- * The /v1/models endpoint returns structured metadata including reasoning flags,
9
- * pricing, context windows, and max output tokens.
10
- *
11
- * Note: The API's `supports_reasoning` flag is unreliable for some models (e.g.,
12
- * it reports true for Llama 3.3 70B which doesn't support extended thinking).
13
- * The models.json embeds curated reasoning flags; patch.json corrects compat.
7
+ * Model metadata comes from Charm's typed official-catalog endpoint,
8
+ * /v1/provider, matching @charmland/pi-hyper-provider. It provides canonical
9
+ * names, pricing, context and output limits, reasoning levels, and attachment
10
+ * support. patch.json remains available for verified endpoint regressions, but
11
+ * currently contains no overrides.
14
12
  *
15
13
  * Model resolution strategy: Stale-While-Revalidate
16
14
  * 1. Serve stale immediately: disk cache → embedded models.json (zero-latency)
@@ -19,6 +17,57 @@
19
17
  *
20
18
  * Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
21
19
  *
20
+ * Footer Status Widget:
21
+ * A below-editor line shows HyperCharm session + account state:
22
+ *
23
+ * ⚡ 1.24 hc · 7 req Xu's Team ◆ 249 hc · 996/1k/h · 10k/10k/d · ⟳ 29d
24
+ * └─ session activity ──┘ └─────────────── account / quota ─────────────────┘
25
+ *
26
+ * The left side reports what this session has spent/sent (from Hyper's
27
+ * usage.cost extension on each chat completion — pi requests
28
+ * stream_options.include_usage, so the final SSE chunk carries cost and we
29
+ * read it off a teed response stream, no polling). The right side reports
30
+ * the team (public /v1/teams — works for API-key auth), the canonical
31
+ * Hypercredit balance (/v1/credits), per-hour/day rate limits captured from
32
+ * response headers, and OAuth device-session days remaining (/v1/devices).
33
+ * The right side compresses across progressive tiers as the terminal
34
+ * narrows. The balance flips to a ⚠ warning at/below lowBalanceHc.
35
+ *
36
+ * Lifecycle (mirrors pi-neuralwatt-provider): nothing renders before this
37
+ * session's first HyperCharm turn completes, so fresh sessions and other
38
+ * providers' sessions see no half-empty line. Credits/team are prefetched
39
+ * on session start or model select when a HyperCharm model is active, so
40
+ * the first turn ends with data already cached. The balance is polled
41
+ * again on pi's agent_settled event (fires only once no automatic retry,
42
+ * compaction, or queued continuation can follow) — and nowhere else, so
43
+ * sessions without HyperCharm turns make zero status-related API calls.
44
+ *
45
+ * Unit note (observed): 20 hypercredits = $1. usage.cost.hypercredits is in
46
+ * the same display unit /v1/credits reports; usage.cost.usd ÷ 20 matches.
47
+ * usage.remaining.hypercredits is USD-denominated despite the name — we
48
+ * therefore display only the polled /v1/credits balance.
49
+ *
50
+ * Display Configuration:
51
+ * Create ~/.pi/agent/extensions/hypercharm.json:
52
+ * {
53
+ * "session": "widget", // "widget" | "statusbar" | "off"
54
+ * "account": "widget", // "widget" | "statusbar" | "off"
55
+ * "hideOnOtherProvider": true, // hide when a non-HyperCharm model is active
56
+ * "lowBalanceHc": 25 // warn threshold, null/false disables
57
+ * }
58
+ *
59
+ * - "widget" (default): rendered in the below-editor status line
60
+ * - "statusbar": rendered in the built-in pi status bar
61
+ * - "off": hidden entirely (account=off also skips/quota fetches)
62
+ *
63
+ * Manage interactively with /hypercharm-status, or non-interactively:
64
+ * /hypercharm-status session widget|statusbar|off
65
+ * /hypercharm-status account widget|statusbar|off
66
+ * /hypercharm-status hide true|false
67
+ * /hypercharm-status lowBalance <hc>|off
68
+ * /hypercharm-status refresh (re-fetch balance/team now)
69
+ * /hypercharm-status reset
70
+ *
22
71
  * Usage:
23
72
  * # Option 1: Store in auth.json (recommended)
24
73
  * # Add to ~/.pi/agent/auth.json:
@@ -35,54 +84,70 @@
35
84
  * @see https://hyper.charm.land
36
85
  */
37
86
 
38
- import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-works/pi-coding-agent";
87
+ import { clampThinkingLevel, streamOpenAICompletions } from "@earendil-works/pi-ai/compat";
88
+ import type { AssistantMessageEventStream, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
89
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext, type ModelRegistry } from "@earendil-works/pi-coding-agent";
39
90
  import modelsData from "./models.json" with { type: "json" };
40
91
  import customModelsData from "./custom-models.json" with { type: "json" };
41
92
  import patchData from "./patch.json" with { type: "json" };
42
93
  import deprecatedData from "./deprecated-models.json" with { type: "json" };
94
+ import {
95
+ buildAccountTiers,
96
+ buildSessionLine,
97
+ coerceStatusConfig,
98
+ DEFAULT_STATUS_CONFIG,
99
+ EMPTY_ACCOUNT,
100
+ EMPTY_SESSION_STATS,
101
+ StatusLineWidget,
102
+ accountHasData,
103
+ type AccountState,
104
+ type SessionStats,
105
+ type StatusConfig,
106
+ } from "./status";
43
107
  import fs from "fs";
108
+ import { hostname } from "os";
44
109
  import path from "path";
45
110
 
46
111
  // ─── Types ────────────────────────────────────────────────────────────────────
47
112
 
48
113
  interface JsonModel {
49
- id: string;
50
- name: string;
51
- reasoning: boolean;
52
- input: ("text" | "image")[];
53
- cost: {
54
- input: number;
55
- output: number;
56
- cacheRead: number;
57
- cacheWrite: number;
58
- };
59
- contextWindow: number;
60
- maxTokens: number;
61
- thinkingLevelMap?: Record<string, string | null>;
62
- compat?: {
63
- supportsDeveloperRole?: boolean;
64
- supportsStore?: boolean;
65
- maxTokensField?: "max_completion_tokens" | "max_tokens";
66
- thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template" | "deepseek";
67
- supportsReasoningEffort?: boolean;
68
- requiresReasoningContentOnAssistantMessages?: boolean;
69
- };
114
+ id: string;
115
+ name: string;
116
+ reasoning: boolean;
117
+ input: ("text" | "image")[];
118
+ cost: {
119
+ input: number;
120
+ output: number;
121
+ cacheRead: number;
122
+ cacheWrite: number;
123
+ };
124
+ contextWindow: number;
125
+ maxTokens: number;
126
+ thinkingLevelMap?: Record<string, string | null>;
127
+ compat?: {
128
+ supportsDeveloperRole?: boolean;
129
+ supportsStore?: boolean;
130
+ maxTokensField?: "max_completion_tokens" | "max_tokens";
131
+ thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template" | "deepseek";
132
+ supportsReasoningEffort?: boolean;
133
+ requiresReasoningContentOnAssistantMessages?: boolean;
134
+ };
70
135
  }
71
136
 
72
137
  interface PatchEntry {
73
- name?: string;
74
- reasoning?: boolean;
75
- input?: ("text" | "image")[];
76
- cost?: {
77
- input?: number;
78
- output?: number;
79
- cacheRead?: number;
80
- cacheWrite?: number;
81
- };
82
- contextWindow?: number;
83
- maxTokens?: number;
84
- thinkingLevelMap?: Record<string, string | null>;
85
- compat?: Record<string, unknown>;
138
+ name?: string;
139
+ reasoning?: boolean;
140
+ input?: ("text" | "image")[];
141
+ cost?: {
142
+ input?: number;
143
+ output?: number;
144
+ cacheRead?: number;
145
+ cacheWrite?: number;
146
+ };
147
+ contextWindow?: number;
148
+ maxTokens?: number;
149
+ thinkingLevelMap?: Record<string, string | null>;
150
+ compat?: Record<string, unknown>;
86
151
  }
87
152
 
88
153
  type PatchData = Record<string, PatchEntry>;
@@ -90,216 +155,207 @@ type PatchData = Record<string, PatchEntry>;
90
155
  // ─── Patch Application ────────────────────────────────────────────────────────
91
156
 
92
157
  function applyPatch(model: JsonModel, patch: PatchEntry): JsonModel {
93
- const result = { ...model };
94
-
95
- if (patch.name !== undefined) result.name = patch.name;
96
- if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
97
- if (patch.input !== undefined) result.input = patch.input;
98
- if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
99
- if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
100
- if (patch.thinkingLevelMap !== undefined) result.thinkingLevelMap = { ...patch.thinkingLevelMap };
101
-
102
- if (patch.cost) {
103
- result.cost = {
104
- input: patch.cost.input ?? result.cost.input,
105
- output: patch.cost.output ?? result.cost.output,
106
- cacheRead: patch.cost.cacheRead ?? result.cost.cacheRead,
107
- cacheWrite: patch.cost.cacheWrite ?? result.cost.cacheWrite,
108
- };
109
- }
110
- if (patch.compat) {
111
- result.compat = { ...(result.compat || {}), ...patch.compat };
112
- }
113
-
114
- if (!result.reasoning && result.compat?.thinkingFormat) {
115
- delete result.compat.thinkingFormat;
116
- }
117
- if (result.compat && Object.keys(result.compat).length === 0) {
118
- delete result.compat;
119
- }
120
-
121
- return result;
158
+ const result = { ...model };
159
+
160
+ if (patch.name !== undefined) result.name = patch.name;
161
+ if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
162
+ if (patch.input !== undefined) result.input = patch.input;
163
+ if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
164
+ if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
165
+ if (patch.thinkingLevelMap !== undefined) result.thinkingLevelMap = { ...patch.thinkingLevelMap };
166
+
167
+ if (patch.cost) {
168
+ result.cost = {
169
+ input: patch.cost.input ?? result.cost.input,
170
+ output: patch.cost.output ?? result.cost.output,
171
+ cacheRead: patch.cost.cacheRead ?? result.cost.cacheRead,
172
+ cacheWrite: patch.cost.cacheWrite ?? result.cost.cacheWrite,
173
+ };
174
+ }
175
+ if (patch.compat) {
176
+ result.compat = { ...(result.compat || {}), ...patch.compat };
177
+ }
178
+
179
+ if (!result.reasoning && result.compat?.thinkingFormat) {
180
+ delete result.compat.thinkingFormat;
181
+ }
182
+ if (!result.reasoning && result.thinkingLevelMap) {
183
+ delete result.thinkingLevelMap;
184
+ }
185
+ if (result.compat && Object.keys(result.compat).length === 0) {
186
+ delete result.compat;
187
+ }
188
+
189
+ return result;
122
190
  }
123
191
 
124
192
  /** Full pipeline: base models → patch → custom → result */
125
193
  function buildModels(base: JsonModel[], custom: JsonModel[], patch: PatchData): JsonModel[] {
126
- const modelMap = new Map<string, JsonModel>();
127
-
128
- // Seed with the base list plus grace-period deprecated models so patch.json
129
- // entries apply to deprecated models exactly as while the model was live
130
- // (withDeprecated keeps live data on id conflicts).
131
- for (const model of withDeprecated(base)) {
132
- modelMap.set(model.id, model);
133
- }
134
-
135
- for (const [id, patchEntry] of Object.entries(patch)) {
136
- const existing = modelMap.get(id);
137
- if (existing) {
138
- modelMap.set(id, applyPatch(existing, patchEntry));
139
- }
140
- }
141
-
142
- for (const model of custom) {
143
- const existing = modelMap.get(model.id);
144
- const patchEntry = patch[model.id];
145
- if (existing && patchEntry) {
146
- modelMap.set(model.id, applyPatch(model, patchEntry));
147
- } else if (existing) {
148
- modelMap.set(model.id, model);
149
- } else if (patchEntry) {
150
- modelMap.set(model.id, applyPatch(model, patchEntry));
151
- } else {
152
- modelMap.set(model.id, model);
153
- }
154
- }
155
-
156
- const result = Array.from(modelMap.values());
157
-
158
- // Ensure DeepSeek reasoning models have required compat settings.
159
- // Live-fetched models from the SWR pipeline may not have these set.
160
- for (const model of result) {
161
- if (!model.reasoning) continue;
162
- if (isDeepSeekModel(model.id)) {
163
- if (!model.compat) {
164
- model.compat = {
165
- thinkingFormat: "deepseek",
166
- maxTokensField: "max_tokens",
167
- supportsDeveloperRole: true,
168
- supportsStore: false,
169
- supportsReasoningEffort: true,
170
- requiresReasoningContentOnAssistantMessages: true,
171
- };
172
- } else {
173
- if (model.compat.thinkingFormat === undefined) {
174
- model.compat.thinkingFormat = "deepseek";
175
- }
176
- if (model.compat.supportsReasoningEffort === undefined) {
177
- model.compat.supportsReasoningEffort = true;
178
- }
179
- if ((model.compat as any).requiresReasoningContentOnAssistantMessages === undefined) {
180
- (model.compat as any).requiresReasoningContentOnAssistantMessages = true;
181
- }
182
- }
183
- if (!model.thinkingLevelMap) {
184
- model.thinkingLevelMap = {
185
- minimal: null, low: null, medium: null, high: "high", max: "max",
186
- };
187
- }
188
- }
189
- }
190
-
191
- return result;
192
- }
193
-
194
- function isDeepSeekModel(id: string): boolean {
195
- return /^deepseek-v/.test(id);
194
+ const modelMap = new Map<string, JsonModel>();
195
+
196
+ // Seed with the base list plus grace-period deprecated models so patch.json
197
+ // entries apply to deprecated models exactly as while the model was live
198
+ // (withDeprecated keeps live data on id conflicts).
199
+ for (const model of withDeprecated(base)) {
200
+ modelMap.set(model.id, model);
201
+ }
202
+
203
+ for (const [id, patchEntry] of Object.entries(patch)) {
204
+ const existing = modelMap.get(id);
205
+ if (existing) {
206
+ modelMap.set(id, applyPatch(existing, patchEntry));
207
+ }
208
+ }
209
+
210
+ for (const model of custom) {
211
+ const existing = modelMap.get(model.id);
212
+ const patchEntry = patch[model.id];
213
+ if (existing && patchEntry) {
214
+ modelMap.set(model.id, applyPatch(model, patchEntry));
215
+ } else if (existing) {
216
+ modelMap.set(model.id, model);
217
+ } else if (patchEntry) {
218
+ modelMap.set(model.id, applyPatch(model, patchEntry));
219
+ } else {
220
+ modelMap.set(model.id, model);
221
+ }
222
+ }
223
+
224
+ return Array.from(modelMap.values());
196
225
  }
197
226
 
198
227
  // ─── Stale-While-Revalidate Model Sync ────────────────────────────────────────
199
228
 
200
229
  const PROVIDER_ID = "hypercharm";
201
230
  const BASE_URL = "https://hyper.charm.land/v1";
202
- const MODELS_URL = `${BASE_URL}/models`;
231
+ const MODELS_URL = `${BASE_URL}/provider`;
203
232
  const CACHE_DIR = path.join(getAgentDir(), "cache");
204
233
  const CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-models.json`);
205
234
  const LIVE_FETCH_TIMEOUT_MS = 8000;
206
235
 
207
- /** Transform a model from the HyperCharm /v1/models API to JsonModel format. */
236
+ const PI_THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
237
+
238
+ const ON_OFF_THINKING_LEVEL_MAP: Record<string, string | null> = {
239
+ off: "off",
240
+ minimal: null,
241
+ low: null,
242
+ medium: null,
243
+ high: null,
244
+ xhigh: null,
245
+ max: "max",
246
+ };
247
+
248
+ function buildThinkingLevelMap(levels: string[]): Record<string, string | null> | undefined {
249
+ if (levels.length === 0) return undefined;
250
+ const available = new Set(levels);
251
+ const result: Record<string, string | null> = {
252
+ off: available.has("off") ? "off" : available.has("none") ? "none" : null,
253
+ };
254
+ for (const level of PI_THINKING_LEVELS) {
255
+ result[level] = available.has(level) ? level : null;
256
+ }
257
+ return result;
258
+ }
259
+
260
+ /** Transform a model from Charm's official typed Hyper /v1/provider catalog. */
208
261
  function transformApiModel(apiModel: any): JsonModel | null {
209
- if (!apiModel.id) return null;
210
-
211
- const cost = apiModel.cost?.usd || {};
212
- const toPerM = (v: any) => {
213
- const n = typeof v === "string" ? parseFloat(v) : (v || 0);
214
- // API returns $/M directly; round to 6 decimals to preserve sub-cent cache prices.
215
- return Math.round(n * 1e6) / 1e6;
216
- };
217
-
218
- return {
219
- id: apiModel.id,
220
- name: apiModel.display_name || apiModel.id,
221
- reasoning: false, // API supports_reasoning is unreliable; patch.json corrects
222
- input: ["text"],
223
- cost: {
224
- input: toPerM(cost["1m_in"]),
225
- output: toPerM(cost["1m_out"]),
226
- cacheRead: toPerM(cost["1m_in_cache"]),
227
- cacheWrite: toPerM(cost["1m_out_cache"]),
228
- },
229
- contextWindow: apiModel.context_window || 0,
230
- maxTokens: apiModel.max_output_tokens || 0,
231
- };
262
+ if (typeof apiModel.id !== "string" || apiModel.id.length === 0) return null;
263
+
264
+ const reasoningLevels = Array.isArray(apiModel.reasoning_levels)
265
+ ? apiModel.reasoning_levels.filter((level: any) => typeof level === "string")
266
+ : [];
267
+ const supportsReasoningEffort = reasoningLevels.length > 0;
268
+ const thinkingLevelMap = supportsReasoningEffort
269
+ ? buildThinkingLevelMap(reasoningLevels)
270
+ : apiModel.can_reason === true
271
+ ? ON_OFF_THINKING_LEVEL_MAP
272
+ : undefined;
273
+
274
+ return {
275
+ id: apiModel.id,
276
+ name: apiModel.name || apiModel.id,
277
+ reasoning: apiModel.can_reason === true,
278
+ thinkingLevelMap,
279
+ input: apiModel.supports_attachments === true ? ["text", "image"] : ["text"],
280
+ cost: {
281
+ input: apiModel.cost_per_1m_in || 0,
282
+ output: apiModel.cost_per_1m_out || 0,
283
+ cacheRead: apiModel.cost_per_1m_in_cached || 0,
284
+ cacheWrite: 0,
285
+ },
286
+ contextWindow: apiModel.context_window || 0,
287
+ maxTokens: apiModel.default_max_tokens || apiModel.context_window || 0,
288
+ compat: {
289
+ supportsStore: false,
290
+ supportsReasoningEffort,
291
+ thinkingFormat: "deepseek",
292
+ maxTokensField: "max_tokens",
293
+ },
294
+ };
232
295
  }
233
296
 
234
297
  async function fetchLiveModels(apiKey: string, signal?: AbortSignal): Promise<JsonModel[] | null> {
235
- try {
236
- const response = await fetch(MODELS_URL, {
237
- headers: { Authorization: `Bearer ${apiKey}` },
238
- signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
239
- });
240
- if (!response.ok) return null;
241
- const data = await response.json();
242
- const apiModels = Array.isArray(data) ? data : (data.data || []);
243
- if (!Array.isArray(apiModels) || apiModels.length === 0) return null;
244
- return apiModels.map(transformApiModel).filter((m): m is JsonModel => m !== null);
245
- } catch {
246
- return null;
247
- }
298
+ try {
299
+ const response = await fetch(MODELS_URL, {
300
+ headers: { Authorization: `Bearer ${apiKey}` },
301
+ signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
302
+ });
303
+ if (!response.ok) return null;
304
+ const data = await response.json();
305
+ const apiModels = Array.isArray(data) ? data : (data.models || data.data || []);
306
+ if (!Array.isArray(apiModels) || apiModels.length === 0) return null;
307
+ return apiModels.map(transformApiModel).filter((m): m is JsonModel => m !== null);
308
+ } catch {
309
+ return null;
310
+ }
248
311
  }
249
312
 
250
313
  function loadCachedModels(): JsonModel[] | null {
251
- try {
252
- const data = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
253
- return Array.isArray(data) ? data : null;
254
- } catch {
255
- return null;
256
- }
314
+ try {
315
+ const data = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
316
+ return Array.isArray(data) ? data : null;
317
+ } catch {
318
+ return null;
319
+ }
257
320
  }
258
321
 
259
322
  function cacheModels(models: JsonModel[]): void {
260
- try {
261
- fs.mkdirSync(CACHE_DIR, { recursive: true });
262
- fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
263
- } catch {
264
- // Cache write failure is non-fatal
265
- }
323
+ try {
324
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
325
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
326
+ } catch {
327
+ // Cache write failure is non-fatal
328
+ }
266
329
  }
267
330
 
268
331
  function mergeWithEmbedded(liveModels: JsonModel[], embeddedModels: JsonModel[]): JsonModel[] {
269
- const embeddedMap = new Map(embeddedModels.map(m => [m.id, m]));
270
- const seen = new Set<string>();
271
- const result: JsonModel[] = [];
272
- for (const liveModel of liveModels) {
273
- const embedded = embeddedMap.get(liveModel.id);
274
- seen.add(liveModel.id);
275
- if (embedded) {
276
- // Self-heal: live API pricing is authoritative field-by-field. Prefer the
277
- // live cost when the API reports it (non-zero); fall back to embedded when
278
- // the API is silent (0) so curated cacheRead/cacheWrite isn't clobbered and
279
- // providers whose /models endpoint exposes no pricing keep their curated
280
- // cost. Curation (reasoning/input/compat/name) still wins via ...embedded.
281
- result.push({
282
- ...liveModel,
283
- ...embedded,
284
- cost: {
285
- input: liveModel.cost.input || embedded.cost.input,
286
- output: liveModel.cost.output || embedded.cost.output,
287
- cacheRead: liveModel.cost.cacheRead || embedded.cost.cacheRead,
288
- cacheWrite: liveModel.cost.cacheWrite || embedded.cost.cacheWrite,
289
- },
290
- contextWindow: liveModel.contextWindow || embedded.contextWindow,
291
- });
292
- } else {
293
- result.push(liveModel);
294
- }
295
- }
296
- // Append any embedded models that the live API didn't return
297
- for (const em of embeddedModels) {
298
- if (!seen.has(em.id)) {
299
- result.push(em);
300
- }
301
- }
302
- return result;
332
+ const embeddedMap = new Map(embeddedModels.map(m => [m.id, m]));
333
+ const seen = new Set<string>();
334
+ const result: JsonModel[] = [];
335
+ for (const liveModel of liveModels) {
336
+ const embedded = embeddedMap.get(liveModel.id);
337
+ seen.add(liveModel.id);
338
+ if (embedded) {
339
+ // The official /v1/provider catalog is authoritative for pricing, including
340
+ // legitimately zero-priced preview models. Curation (reasoning/input/compat/name)
341
+ // still wins via ...embedded.
342
+ result.push({
343
+ ...liveModel,
344
+ ...embedded,
345
+ cost: liveModel.cost,
346
+ contextWindow: liveModel.contextWindow || embedded.contextWindow,
347
+ });
348
+ } else {
349
+ result.push(liveModel);
350
+ }
351
+ }
352
+ // Append any embedded models that the live API didn't return
353
+ for (const em of embeddedModels) {
354
+ if (!seen.has(em.id)) {
355
+ result.push(em);
356
+ }
357
+ }
358
+ return result;
303
359
  }
304
360
 
305
361
  // Grace period for delisted models. When the provider API stops listing a
@@ -311,47 +367,47 @@ const DEPRECATED_MODEL_TTL_MS = 14 * 24 * 60 * 60 * 1000;
311
367
 
312
368
  // Grace-period deprecated models with deprecation metadata stripped.
313
369
  function activeDeprecatedModels(): JsonModel[] {
314
- const now = Date.now();
315
- const result: JsonModel[] = [];
316
- for (const entry of Object.values(deprecatedData as Record<string, JsonModel & { deprecatedAt?: string }>)) {
317
- if (!entry?.id) continue;
318
- const removedAt = Date.parse(entry.deprecatedAt ?? "");
319
- if (Number.isNaN(removedAt) || now - removedAt > DEPRECATED_MODEL_TTL_MS) continue;
320
- const model = { ...entry } as JsonModel & { deprecatedAt?: string };
321
- delete model.deprecatedAt;
322
- result.push(model);
323
- }
324
- return result;
370
+ const now = Date.now();
371
+ const result: JsonModel[] = [];
372
+ for (const entry of Object.values(deprecatedData as Record<string, JsonModel & { deprecatedAt?: string }>)) {
373
+ if (!entry?.id) continue;
374
+ const removedAt = Date.parse(entry.deprecatedAt ?? "");
375
+ if (Number.isNaN(removedAt) || now - removedAt > DEPRECATED_MODEL_TTL_MS) continue;
376
+ const model = { ...entry } as JsonModel & { deprecatedAt?: string };
377
+ delete model.deprecatedAt;
378
+ result.push(model);
379
+ }
380
+ return result;
325
381
  }
326
382
 
327
383
  // Append grace-period deprecated models the list does not already have (live data wins).
328
384
  function withDeprecated(models: JsonModel[]): JsonModel[] {
329
- const seen = new Set(models.map((m) => m.id));
330
- const extras = activeDeprecatedModels().filter((m) => !seen.has(m.id));
331
- return extras.length > 0 ? [...models, ...extras] : models;
385
+ const seen = new Set(models.map((m) => m.id));
386
+ const extras = activeDeprecatedModels().filter((m) => !seen.has(m.id));
387
+ return extras.length > 0 ? [...models, ...extras] : models;
332
388
  }
333
389
 
334
390
  function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
335
- const cached = loadCachedModels();
336
- if (!cached || cached.length === 0) return embeddedModels;
391
+ const cached = loadCachedModels();
392
+ if (!cached || cached.length === 0) return embeddedModels;
337
393
 
338
- // Merge embedded models that are missing from cache (newly added models)
339
- const cachedMap = new Map(cached.map(m => [m.id, m]));
340
- for (const em of embeddedModels) {
341
- if (!cachedMap.has(em.id)) {
342
- cached.push(em);
343
- }
344
- }
345
- return cached;
394
+ // Merge embedded models that are missing from cache (newly added models)
395
+ const cachedMap = new Map(cached.map(m => [m.id, m]));
396
+ for (const em of embeddedModels) {
397
+ if (!cachedMap.has(em.id)) {
398
+ cached.push(em);
399
+ }
400
+ }
401
+ return cached;
346
402
  }
347
403
 
348
404
  async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal): Promise<JsonModel[] | null> {
349
- if (!apiKey) return null;
350
- const liveModels = await fetchLiveModels(apiKey, signal);
351
- if (!liveModels || liveModels.length === 0) return null;
352
- const merged = mergeWithEmbedded(liveModels, embeddedModels);
353
- cacheModels(merged);
354
- return merged;
405
+ if (!apiKey) return null;
406
+ const liveModels = await fetchLiveModels(apiKey, signal);
407
+ if (!liveModels || liveModels.length === 0) return null;
408
+ const merged = mergeWithEmbedded(liveModels, embeddedModels);
409
+ cacheModels(merged);
410
+ return merged;
355
411
  }
356
412
 
357
413
  // ─── API Key Resolution (via ModelRegistry) ────────────────────────────────────
@@ -360,45 +416,649 @@ let cachedApiKey: string | undefined;
360
416
  let revalidateAbort: AbortController | null = null;
361
417
 
362
418
  async function resolveApiKey(modelRegistry: ModelRegistry): Promise<void> {
363
- cachedApiKey = await modelRegistry.getApiKeyForProvider("hypercharm") ?? undefined;
419
+ cachedApiKey = await modelRegistry.getApiKeyForProvider(PROVIDER_ID) ?? undefined;
420
+ }
421
+
422
+ // ─── Status Display Configuration ──────────────────────────────────────────────
423
+
424
+ const CONFIG_PATH = path.join(getAgentDir(), "extensions", "hypercharm.json");
425
+
426
+ let statusConfig: StatusConfig = { ...DEFAULT_STATUS_CONFIG };
427
+
428
+ function loadStatusConfig(): StatusConfig {
429
+ try {
430
+ const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
431
+ statusConfig = coerceStatusConfig(raw);
432
+ } catch {
433
+ // Missing or unreadable file → defaults
434
+ }
435
+ return statusConfig;
436
+ }
437
+
438
+ function writeStatusConfig(): void {
439
+ try {
440
+ let raw: Record<string, unknown> = {};
441
+ try {
442
+ const existing = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
443
+ if (existing && typeof existing === "object" && !Array.isArray(existing)) raw = existing;
444
+ } catch {
445
+ // No existing file — start fresh
446
+ }
447
+ raw.session = statusConfig.session;
448
+ raw.account = statusConfig.account;
449
+ raw.hideOnOtherProvider = statusConfig.hideOnOtherProvider;
450
+ raw.lowBalanceHc = statusConfig.lowBalanceHc;
451
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
452
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(raw, null, 2) + "\n");
453
+ } catch {
454
+ // Config write failure is non-fatal — the in-memory config still applies
455
+ }
456
+ }
457
+
458
+ loadStatusConfig();
459
+
460
+ // ─── Response Metadata Capture ────────────────────────────────────────────────
461
+ // The custom streamSimple below wraps fetch per request (never globalThis —
462
+ // concurrent main/helper requests would clobber a global patch). For every
463
+ // /chat/completions response we capture x-ratelimit-* headers and tee the
464
+ // body: one copy goes to pi's OpenAI streaming layer, the other is scanned
465
+ // for the final usage chunk that Hyper extends with hypercredit cost data.
466
+
467
+ const sessionStats: SessionStats = { ...EMPTY_SESSION_STATS };
468
+ const account: AccountState = { ...EMPTY_ACCOUNT };
469
+
470
+ // Per-turn pending state — teed streams settle asynchronously, so capture
471
+ // lands in pending* and is committed at turn_end.
472
+ let pendingRequests = 0;
473
+ let pendingSpendHc = 0;
474
+ let pendingSawUsage = false;
475
+ let pendingSawOutOfCredits = false;
476
+ let outOfCreditsNotified = false;
477
+
478
+ const teeReaders = new Set<Promise<void>>();
479
+
480
+ function trackTeeReader(promise: Promise<void>): void {
481
+ teeReaders.add(promise);
482
+ const release = () => { teeReaders.delete(promise); };
483
+ promise.then(release, release);
484
+ }
485
+
486
+ function settleTeeReaders(): Promise<void> {
487
+ if (teeReaders.size === 0) return Promise.resolve();
488
+ const pending = Array.from(teeReaders);
489
+ return Promise.allSettled(pending).then(() => undefined);
490
+ }
491
+
492
+ function captureRateLimitHeaders(headers: Headers): void {
493
+ const limitHour = Number(headers.get("x-ratelimit-limit-hour"));
494
+ const limitDay = Number(headers.get("x-ratelimit-limit-day"));
495
+ const remainingHour = Number(headers.get("x-ratelimit-remaining-hour"));
496
+ const remainingDay = Number(headers.get("x-ratelimit-remaining-day"));
497
+ if (![limitHour, limitDay, remainingHour, remainingDay].every((v) => Number.isFinite(v))) return;
498
+ account.rate = { limitHour, limitDay, remainingHour, remainingDay, capturedAt: Date.now() };
499
+ }
500
+
501
+ /** Extract spend data from a parsed completion chunk/body's usage object. */
502
+ function captureUsage(obj: any): void {
503
+ const usage = obj?.usage;
504
+ if (typeof usage !== "object" || usage === null) return;
505
+ const hc = usage.cost?.hypercredits;
506
+ if (typeof hc === "number" && Number.isFinite(hc)) {
507
+ pendingSpendHc += hc;
508
+ }
509
+ pendingSawUsage = true;
510
+ }
511
+
512
+ /** Scan a teed response for the final usage chunk (SSE) or JSON body usage. */
513
+ async function readUsageFromTee(body: ReadableStream<Uint8Array>): Promise<void> {
514
+ const reader = body.getReader();
515
+ const decoder = new TextDecoder();
516
+ let buffer = "";
517
+
518
+ const processLine = (line: string): void => {
519
+ const trimmed = line.trim();
520
+ if (!trimmed.startsWith("data: ")) return;
521
+ const payload = trimmed.slice(6);
522
+ if (payload === "[DONE]") return;
523
+ try {
524
+ captureUsage(JSON.parse(payload));
525
+ } catch {
526
+ // Not JSON or no usage — benign
527
+ }
528
+ };
529
+
530
+ try {
531
+ while (true) {
532
+ const { done, value } = await reader.read();
533
+ if (done) break;
534
+ buffer += decoder.decode(value, { stream: true });
535
+ const lines = buffer.split("\n");
536
+ buffer = lines.pop() || "";
537
+ for (const line of lines) processLine(line);
538
+ }
539
+ } catch {
540
+ // Tee stream may error if the main stream is aborted — that's fine
541
+ }
542
+
543
+ const trailing = (buffer + decoder.decode(new Uint8Array(0), { stream: false })).trim();
544
+ if (trailing) {
545
+ if (trailing.startsWith("data: ")) {
546
+ processLine(trailing);
547
+ } else if (trailing.startsWith("{")) {
548
+ try {
549
+ captureUsage(JSON.parse(trailing));
550
+ } catch {
551
+ // Partial non-SSE body — ignore
552
+ }
553
+ }
554
+ }
555
+
556
+ try {
557
+ reader.releaseLock();
558
+ } catch {
559
+ // Ignore
560
+ }
561
+ }
562
+
563
+ // ─── Custom Streaming Provider ────────────────────────────────────────────────
564
+
565
+ function streamHypercharm(
566
+ model: any,
567
+ context: any,
568
+ options?: SimpleStreamOptions,
569
+ ): AssistantMessageEventStream {
570
+ const apiKey = (options as any)?.apiKey || cachedApiKey || "";
571
+ if (!apiKey) {
572
+ throw new Error(
573
+ `No API key for HyperCharm. Add it to ~/.pi/agent/auth.json, ` +
574
+ `set HYPERCHARM_API_KEY env var, or use --api-key.`,
575
+ );
576
+ }
577
+
578
+ const hyperModel = { ...model, api: "openai-completions", baseUrl: model.baseUrl || BASE_URL };
579
+
580
+ // pi hands the user's thinking selection as options.reasoning (a raw
581
+ // ThinkingLevel); streamOpenAICompletions only reads reasoningEffort.
582
+ // Replicate pi-ai's clamp+convert so levels reach the request body.
583
+ const clampedReasoning = options?.reasoning ? clampThinkingLevel(hyperModel, options.reasoning) : undefined;
584
+ const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
585
+ const { reasoning: _reasoning, ...streamOptions } = (options ?? {}) as any;
586
+
587
+ // Per-request fetch wrapper: owns its interceptor, safe under concurrency.
588
+ const upstreamFetch = (streamOptions as any).fetch ?? globalThis.fetch;
589
+ const metaFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
590
+ const response = await upstreamFetch(input as any, init);
591
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
592
+ if (!url.includes("/chat/completions")) return response;
593
+
594
+ pendingRequests += 1;
595
+ captureRateLimitHeaders(response.headers);
596
+ if (response.status === 402) pendingSawOutOfCredits = true;
597
+ if (!response.ok || !response.body) return response;
598
+
599
+ const [bodyForSdk, bodyForMeta] = response.body.tee();
600
+ trackTeeReader(readUsageFromTee(bodyForMeta));
601
+ return new Response(bodyForSdk, {
602
+ headers: response.headers,
603
+ status: response.status,
604
+ statusText: response.statusText,
605
+ });
606
+ };
607
+
608
+ return streamOpenAICompletions(hyperModel, context, {
609
+ ...streamOptions,
610
+ fetch: metaFetch,
611
+ reasoningEffort,
612
+ apiKey,
613
+ } as any);
614
+ }
615
+
616
+ // ─── Account Metadata Fetching ────────────────────────────────────────────────
617
+
618
+ const CREDITS_MIN_INTERVAL_MS = 15_000;
619
+ const ACCOUNT_FETCH_TIMEOUT_MS = 8_000;
620
+
621
+ let statusAbort: AbortController | null = null;
622
+ let lastCreditsFetchAt = 0;
623
+ let creditsInFlight: Promise<void> | null = null;
624
+ let metaFetched = false;
625
+
626
+ async function fetchJsonGet(url: string, apiKey: string, signal?: AbortSignal): Promise<any | null> {
627
+ try {
628
+ const response = await fetch(url, {
629
+ headers: { Authorization: `Bearer ${apiKey}` },
630
+ signal: signal
631
+ ? AbortSignal.any([AbortSignal.timeout(ACCOUNT_FETCH_TIMEOUT_MS), signal])
632
+ : AbortSignal.timeout(ACCOUNT_FETCH_TIMEOUT_MS),
633
+ });
634
+ if (!response.ok) return null;
635
+ return await response.json();
636
+ } catch {
637
+ return null;
638
+ }
639
+ }
640
+
641
+ /** Canonical Hypercredit balance from /v1/credits. Throttled unless forced. */
642
+ function refreshCredits(apiKey: string | undefined, signal: AbortSignal | undefined, force: boolean): Promise<void> {
643
+ if (!apiKey) return Promise.resolve();
644
+ if (!force && Date.now() - lastCreditsFetchAt < CREDITS_MIN_INTERVAL_MS) return Promise.resolve();
645
+ lastCreditsFetchAt = Date.now();
646
+ if (creditsInFlight) return creditsInFlight;
647
+ creditsInFlight = (async () => {
648
+ try {
649
+ const data = await fetchJsonGet(`${BASE_URL}/credits`, apiKey, signal);
650
+ if (data === null) return;
651
+ const balance = data?.balance;
652
+ if (typeof balance === "number" && Number.isFinite(balance)) {
653
+ account.balance = balance;
654
+ }
655
+ } finally {
656
+ creditsInFlight = null;
657
+ }
658
+ })();
659
+ return creditsInFlight;
660
+ }
661
+
662
+ /** Team name (/v1/teams) + OAuth device-session expiry (/v1/devices). */
663
+ async function refreshAccountMeta(apiKey: string | undefined, signal?: AbortSignal): Promise<void> {
664
+ if (!apiKey || metaFetched) return;
665
+ const [teams, devices] = await Promise.all([
666
+ fetchJsonGet(`${BASE_URL}/teams`, apiKey, signal),
667
+ fetchJsonGet(`${BASE_URL}/devices`, apiKey, signal),
668
+ ]);
669
+ if (signal?.aborted) return;
670
+
671
+ const teamName = teams?.items?.[0]?.name;
672
+ if (typeof teamName === "string" && teamName.trim()) {
673
+ account.teamName = teamName.trim();
674
+ }
675
+
676
+ // Devices: the OAuth device flow registers this machine as
677
+ // `Pi (<hostname>)`. Match by name; skip silently for API-key auth
678
+ // (the endpoint returns OAuth sessions and may be empty).
679
+ if (Array.isArray(devices?.items)) {
680
+ const own = devices.items.find((d: any) => typeof d?.name === "string" && d.name === `Pi (${hostname()})`);
681
+ const expMs = own ? Date.parse(own.expires_at ?? "") : NaN;
682
+ if (!Number.isNaN(expMs)) {
683
+ account.authDaysLeft = Math.max(0, Math.ceil((expMs - Date.now()) / 86_400_000));
684
+ }
685
+ }
686
+
687
+ if (account.teamName !== null || account.authDaysLeft !== null) metaFetched = true;
688
+ }
689
+
690
+ // ─── Status Rendering ─────────────────────────────────────────────────────────
691
+
692
+ const WIDGET_KEY = "hypercharm";
693
+ const STATUS_KEY_SESSION = "hypercharm-session";
694
+ const STATUS_KEY_ACCOUNT = "hypercharm-account";
695
+
696
+ function currentProviderId(ctx: ExtensionContext): string | undefined {
697
+ // ctx.model is a getter that can throw on stale contexts
698
+ try {
699
+ return (ctx.model as any)?.provider as string | undefined;
700
+ } catch {
701
+ return undefined;
702
+ }
703
+ }
704
+
705
+ function updateStatus(ctx: ExtensionContext): void {
706
+ const provider = currentProviderId(ctx);
707
+ const hiddenByOtherProvider =
708
+ statusConfig.hideOnOtherProvider && provider !== undefined && provider !== PROVIDER_ID;
709
+
710
+ const clearAll = () => {
711
+ ctx.ui.setStatus(STATUS_KEY_SESSION, undefined);
712
+ ctx.ui.setStatus(STATUS_KEY_ACCOUNT, undefined);
713
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
714
+ };
715
+
716
+ if (hiddenByOtherProvider) {
717
+ clearAll();
718
+ return;
719
+ }
720
+
721
+ const hasActivity = sessionStats.requests > 0 || sessionStats.spendHc > 0;
722
+ const sessionLine = statusConfig.session !== "off" ? buildSessionLine(sessionStats) : undefined;
723
+ // Show only after HyperCharm activity this session (like pi-neuralwatt):
724
+ // no empty-gap line on fresh sessions, no stale account glare on other
725
+ // providers' sessions.
726
+ const accountVisible = statusConfig.account !== "off" && accountHasData(account) && hasActivity;
727
+ const lowBalance =
728
+ statusConfig.lowBalanceHc !== null && account.balance !== null && account.balance <= statusConfig.lowBalanceHc;
729
+ const accTiers = accountVisible ? buildAccountTiers(account, lowBalance) : [];
730
+
731
+ // Status bar (built-in footer slots)
732
+ const sBar = statusConfig.session === "statusbar" ? sessionLine : undefined;
733
+ const aBar = statusConfig.account === "statusbar" && accountVisible ? accTiers[0] : undefined;
734
+ if (sBar && aBar) {
735
+ // Combined to avoid eating two footer slots
736
+ ctx.ui.setStatus(STATUS_KEY_SESSION, ctx.ui.theme.fg(lowBalance ? "warning" : "dim", `${sBar} · ${aBar}`));
737
+ ctx.ui.setStatus(STATUS_KEY_ACCOUNT, undefined);
738
+ } else {
739
+ ctx.ui.setStatus(STATUS_KEY_SESSION, sBar ? ctx.ui.theme.fg("dim", sBar) : undefined);
740
+ ctx.ui.setStatus(STATUS_KEY_ACCOUNT, aBar ? ctx.ui.theme.fg(lowBalance ? "warning" : "dim", aBar) : undefined);
741
+ }
742
+
743
+ // Below-editor widget (two-zone, width-aware)
744
+ const leftW = statusConfig.session === "widget" ? sessionLine : undefined;
745
+ const rightW = statusConfig.account === "widget" && accountVisible ? accTiers : undefined;
746
+ if (leftW !== undefined || (rightW !== undefined && rightW.length > 0)) {
747
+ ctx.ui.setWidget(
748
+ WIDGET_KEY,
749
+ (_tui: any, theme: any) => new StatusLineWidget(theme, leftW ?? "", rightW ?? [], lowBalance),
750
+ { placement: "belowEditor" },
751
+ );
752
+ } else {
753
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
754
+ }
755
+ }
756
+
757
+ function resetStatusState(): void {
758
+ sessionStats.requests = 0;
759
+ sessionStats.spendHc = 0;
760
+ Object.assign(account, EMPTY_ACCOUNT);
761
+ pendingRequests = 0;
762
+ pendingSpendHc = 0;
763
+ pendingSawUsage = false;
764
+ pendingSawOutOfCredits = false;
765
+ outOfCreditsNotified = false;
766
+ lastCreditsFetchAt = 0;
767
+ metaFetched = false;
768
+ }
769
+
770
+ /** Commit per-turn pending capture into session state (after tees settle). */
771
+ function commitPending(ctx: ExtensionContext): void {
772
+ if (!pendingSawUsage && pendingRequests === 0) return;
773
+ sessionStats.requests += pendingRequests;
774
+ sessionStats.spendHc += pendingSpendHc;
775
+ pendingRequests = 0;
776
+ pendingSpendHc = 0;
777
+ pendingSawUsage = false;
778
+
779
+ if (pendingSawOutOfCredits) {
780
+ pendingSawOutOfCredits = false;
781
+ // Re-fetch now so the balance reflects exhaustion immediately
782
+ void refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, true).then(() => updateStatus(ctx));
783
+ if (!outOfCreditsNotified && ctx.hasUI) {
784
+ outOfCreditsNotified = true;
785
+ ctx.ui.notify("HyperCharm is out of Hypercredits — recharge at hyper.charm.land", "error");
786
+ }
787
+ }
788
+ }
789
+
790
+ // ─── Status Command ────────────────────────────────────────────────────────────
791
+
792
+ function statusSummary(): string {
793
+ const lb = statusConfig.lowBalanceHc === null ? "off" : `${statusConfig.lowBalanceHc}`;
794
+ return `session=${statusConfig.session}, account=${statusConfig.account}, hideOnOtherProvider=${statusConfig.hideOnOtherProvider}, lowBalanceHc=${lb}`;
795
+ }
796
+
797
+ const STATUS_USAGE =
798
+ "Usage: /hypercharm-status [session|account widget|statusbar|off · hide true|false · lowBalance <hc>|off · refresh · reset]";
799
+
800
+ async function handleStatusCommand(args: string, ctx: ExtensionContext): Promise<void> {
801
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
802
+
803
+ if (tokens.length === 0) {
804
+ if (!ctx.hasUI) {
805
+ ctx.ui.notify(statusSummary(), "info");
806
+ return;
807
+ }
808
+ await configureStatusInteractive(ctx);
809
+ return;
810
+ }
811
+
812
+ const [rawKey, rawValue] = tokens;
813
+ const key = rawKey.toLowerCase();
814
+ const value = rawValue?.toLowerCase();
815
+
816
+ if (key === "refresh") {
817
+ metaFetched = false;
818
+ await Promise.all([
819
+ refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, true),
820
+ refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined),
821
+ ]);
822
+ updateStatus(ctx);
823
+ const bal = account.balance !== null ? `${account.balance} hc` : "unknown";
824
+ ctx.ui.notify(`HyperCharm balance: ${bal}. ${statusSummary()}`, "info");
825
+ return;
826
+ }
827
+
828
+ if (key === "reset" && tokens.length === 1) {
829
+ statusConfig = { ...DEFAULT_STATUS_CONFIG };
830
+ writeStatusConfig();
831
+ updateStatus(ctx);
832
+ ctx.ui.notify(`HyperCharm status reset. ${statusSummary()}`, "info");
833
+ return;
834
+ }
835
+
836
+ if ((key === "session" || key === "account") && tokens.length === 2) {
837
+ if (value !== "widget" && value !== "statusbar" && value !== "off") {
838
+ ctx.ui.notify(STATUS_USAGE, "error");
839
+ return;
840
+ }
841
+ statusConfig[key] = value;
842
+ writeStatusConfig();
843
+ if (value !== "off" && key === "account") {
844
+ // Turning account on: make sure we have data to show
845
+ void refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, true).then(() => updateStatus(ctx));
846
+ void refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined);
847
+ }
848
+ updateStatus(ctx);
849
+ ctx.ui.notify(`HyperCharm ${key} line: ${value}. ${statusSummary()}`, "info");
850
+ return;
851
+ }
852
+
853
+ if ((key === "hide" || key === "hideonotherprovider") && tokens.length === 2) {
854
+ if (value !== "true" && value !== "false") {
855
+ ctx.ui.notify(STATUS_USAGE, "error");
856
+ return;
857
+ }
858
+ statusConfig.hideOnOtherProvider = value === "true";
859
+ writeStatusConfig();
860
+ updateStatus(ctx);
861
+ ctx.ui.notify(`HyperCharm status. ${statusSummary()}`, "info");
862
+ return;
863
+ }
864
+
865
+ if (key === "lowbalance" && tokens.length === 2) {
866
+ if (value === "off") {
867
+ statusConfig.lowBalanceHc = null;
868
+ } else {
869
+ const n = Number(value);
870
+ if (!Number.isFinite(n) || n <= 0) {
871
+ ctx.ui.notify(STATUS_USAGE, "error");
872
+ return;
873
+ }
874
+ statusConfig.lowBalanceHc = n;
875
+ }
876
+ writeStatusConfig();
877
+ updateStatus(ctx);
878
+ ctx.ui.notify(`HyperCharm status. ${statusSummary()}`, "info");
879
+ return;
880
+ }
881
+
882
+ ctx.ui.notify(STATUS_USAGE, "error");
883
+ }
884
+
885
+ async function configureStatusInteractive(ctx: ExtensionContext): Promise<void> {
886
+ const modes = ["widget", "statusbar", "off"] as const;
887
+ const nextMode = (m: string) => modes[(modes.indexOf(m as any) + 1) % modes.length];
888
+
889
+ for (;;) {
890
+ const lb = statusConfig.lowBalanceHc === null ? "off" : `${statusConfig.lowBalanceHc} hc`;
891
+ const sessionOpt = `Session line (spend/requests): ${statusConfig.session}`;
892
+ const accountOpt = `Account line (team/balance/rate limits): ${statusConfig.account}`;
893
+ const hideOpt = `Hide on other providers: ${statusConfig.hideOnOtherProvider ? "on" : "off"}`;
894
+ const lbOpt = `Low-balance warning: ${lb}`;
895
+ const refreshOpt = "Refresh balance now";
896
+ const doneOpt = "Done";
897
+
898
+ const choice = await ctx.ui.select("HyperCharm footer status", [
899
+ sessionOpt,
900
+ accountOpt,
901
+ hideOpt,
902
+ lbOpt,
903
+ refreshOpt,
904
+ doneOpt,
905
+ ]);
906
+
907
+ if (choice === undefined || choice === doneOpt) {
908
+ updateStatus(ctx);
909
+ return;
910
+ }
911
+ if (choice === sessionOpt) {
912
+ statusConfig.session = nextMode(statusConfig.session);
913
+ writeStatusConfig();
914
+ continue;
915
+ }
916
+ if (choice === accountOpt) {
917
+ statusConfig.account = nextMode(statusConfig.account);
918
+ writeStatusConfig();
919
+ if (statusConfig.account !== "off") {
920
+ void refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, true).then(() => updateStatus(ctx));
921
+ void refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined);
922
+ }
923
+ continue;
924
+ }
925
+ if (choice === hideOpt) {
926
+ statusConfig.hideOnOtherProvider = !statusConfig.hideOnOtherProvider;
927
+ writeStatusConfig();
928
+ updateStatus(ctx);
929
+ continue;
930
+ }
931
+ if (choice === lbOpt) {
932
+ const presets = ["off", "10", "25", "50", "100", "200", "500"];
933
+ const current = statusConfig.lowBalanceHc === null ? "off" : String(statusConfig.lowBalanceHc);
934
+ const ordered = presets.includes(current) ? presets : [current, ...presets];
935
+ const pick = await ctx.ui.select("Warn at/below balance (hc)", ordered);
936
+ if (pick !== undefined) {
937
+ statusConfig.lowBalanceHc = pick === "off" ? null : Number(pick);
938
+ writeStatusConfig();
939
+ updateStatus(ctx);
940
+ }
941
+ continue;
942
+ }
943
+ if (choice === refreshOpt) {
944
+ metaFetched = false;
945
+ await Promise.all([
946
+ refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, true),
947
+ refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined),
948
+ ]);
949
+ updateStatus(ctx);
950
+ continue;
951
+ }
952
+ }
364
953
  }
365
954
 
366
955
  // ─── Extension Entry Point ────────────────────────────────────────────────────
367
956
 
957
+ // The currently-registered model list — starts stale, hot-swapped when the
958
+ // live catalog lands. Provider identity funnels through makeProviderConfig so
959
+ // the stream handler and models never desync.
960
+ let currentModels: JsonModel[] = [];
961
+
962
+ function makeProviderConfig(models: JsonModel[] = currentModels) {
963
+ return {
964
+ baseUrl: BASE_URL,
965
+ apiKey: "$HYPERCHARM_API_KEY",
966
+ // Custom API name so our streamSimple registers as its own handler and
967
+ // never shadows pi's built-in openai-completions pipeline for other
968
+ // providers. streamHypercharm delegates to pi-ai's OpenAI-compat streamer.
969
+ api: "hypercharm",
970
+ models,
971
+ streamSimple: streamHypercharm,
972
+ };
973
+ }
974
+
368
975
  export default function (pi: ExtensionAPI) {
369
- const embeddedModels = modelsData as JsonModel[];
370
- const customModels = customModelsData as JsonModel[];
371
- const patches = patchData as PatchData;
372
-
373
- const staleBase = loadStaleModels(embeddedModels);
374
- const staleModels = buildModels(staleBase, customModels, patches);
375
-
376
- pi.registerProvider("hypercharm", {
377
- baseUrl: BASE_URL,
378
- apiKey: "$HYPERCHARM_API_KEY",
379
- api: "openai-completions",
380
- models: staleModels,
381
- });
382
-
383
- pi.on("session_start", async (_event, ctx) => {
384
- revalidateAbort?.abort();
385
- revalidateAbort = new AbortController();
386
- const signal = revalidateAbort.signal;
387
- resolveApiKey(ctx.modelRegistry).then(() => {
388
- revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
389
- if (freshBase && !signal.aborted) {
390
- pi.registerProvider("hypercharm", {
391
- baseUrl: BASE_URL,
392
- apiKey: "$HYPERCHARM_API_KEY",
393
- api: "openai-completions",
394
- models: buildModels(freshBase, customModels, patches),
395
- });
396
- }
397
- });
398
- });
399
- });
400
-
401
- pi.on("session_shutdown", () => {
402
- revalidateAbort?.abort();
403
- });
976
+ const embeddedModels = modelsData as JsonModel[];
977
+ const customModels = customModelsData as JsonModel[];
978
+ const patches = patchData as PatchData;
979
+
980
+ const staleBase = loadStaleModels(embeddedModels);
981
+ const staleModels = buildModels(staleBase, customModels, patches);
982
+ currentModels = staleModels;
983
+
984
+ pi.registerProvider(PROVIDER_ID, makeProviderConfig(staleModels));
985
+
986
+ pi.registerCommand("hypercharm-status", {
987
+ description: "Configure the HyperCharm footer status (session spend, balance, rate limits)",
988
+ handler: async (args, ctx) => {
989
+ await handleStatusCommand(args, ctx);
990
+ },
991
+ });
992
+
993
+ pi.on("session_start", async (_event, ctx) => {
994
+ revalidateAbort?.abort();
995
+ revalidateAbort = new AbortController();
996
+ const signal = revalidateAbort.signal;
997
+ statusAbort?.abort();
998
+ statusAbort = new AbortController();
999
+
1000
+ loadStatusConfig();
1001
+ resetStatusState();
1002
+ updateStatus(ctx); // clears any carryover; activity-gated, renders nothing yet
1003
+ // Re-register so our identity (custom api + streamSimple) always wins
1004
+ // over anything that touched provider registration during load.
1005
+ pi.registerProvider(PROVIDER_ID, makeProviderConfig());
1006
+
1007
+ resolveApiKey(ctx.modelRegistry).then(() => {
1008
+ // Prefetch credits/team metadata only when a HyperCharm model is active
1009
+ // (pi-neuralwatt also prefetches so the first turn ends with data, but
1010
+ // gating here avoids API calls in sessions that never use the provider).
1011
+ if (currentProviderId(ctx) === PROVIDER_ID) {
1012
+ void refreshCredits(cachedApiKey, statusAbort!.signal, true).then(() => updateStatus(ctx));
1013
+ void refreshAccountMeta(cachedApiKey, statusAbort!.signal).then(() => updateStatus(ctx));
1014
+ }
1015
+ revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
1016
+ if (freshBase && !signal.aborted) {
1017
+ currentModels = buildModels(freshBase, customModels, patches);
1018
+ pi.registerProvider(PROVIDER_ID, makeProviderConfig());
1019
+ }
1020
+ });
1021
+ });
1022
+ });
1023
+
1024
+ pi.on("model_select", (event, ctx) => {
1025
+ updateStatus(ctx);
1026
+ const model: any = (event as any).model;
1027
+ if (model?.provider === PROVIDER_ID && cachedApiKey) {
1028
+ void refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, false).then(() => updateStatus(ctx));
1029
+ void refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined);
1030
+ }
1031
+ });
1032
+
1033
+ pi.on("turn_end", async (_event, ctx) => {
1034
+ // Ensure every concurrent response tee has landed before committing.
1035
+ await settleTeeReaders();
1036
+ commitPending(ctx);
1037
+ // If the session_start/model_select credits fetch raced or failed, retry
1038
+ // once we have real activity so the very first turn shows the balance.
1039
+ if (sessionStats.requests > 0 && account.balance === null) {
1040
+ await refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, false);
1041
+ }
1042
+ updateStatus(ctx);
1043
+ });
1044
+
1045
+ // agent_settled (not agent_end): fires only when no automatic retry,
1046
+ // compaction, or queued continuation can follow — the one moment polling
1047
+ // /v1/credits is both fresh and not redundant. Gated on session activity
1048
+ // so sessions without HyperCharm turns make zero API calls here.
1049
+ pi.on("agent_settled", async (_event, ctx) => {
1050
+ if (sessionStats.requests > 0 || sessionStats.spendHc > 0) {
1051
+ await refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, false);
1052
+ if (!metaFetched) await refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined);
1053
+ updateStatus(ctx);
1054
+ }
1055
+ });
1056
+
1057
+ pi.on("session_shutdown", (_event, ctx) => {
1058
+ revalidateAbort?.abort();
1059
+ statusAbort?.abort();
1060
+ ctx.ui.setStatus(STATUS_KEY_SESSION, undefined);
1061
+ ctx.ui.setStatus(STATUS_KEY_ACCOUNT, undefined);
1062
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
1063
+ });
404
1064
  }