pi-hypercharm-provider 1.1.5 → 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/.pi/fabric/mesh/state.json +106 -0
- package/AGENTS.md +2 -1
- package/README.md +52 -7
- package/deprecated-models.json +34 -1
- package/index.ts +936 -265
- package/models.json +49 -48
- package/package.json +9 -2
- package/pnpm-workspace.yaml +15 -0
- package/scripts/update-models.js +108 -2
- package/status.ts +305 -0
- package/tests/status.smoke.ts +146 -0
- package/tsconfig.json +15 -0
package/index.ts
CHANGED
|
@@ -17,6 +17,57 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
|
|
19
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
|
+
*
|
|
20
71
|
* Usage:
|
|
21
72
|
* # Option 1: Store in auth.json (recommended)
|
|
22
73
|
* # Add to ~/.pi/agent/auth.json:
|
|
@@ -33,54 +84,70 @@
|
|
|
33
84
|
* @see https://hyper.charm.land
|
|
34
85
|
*/
|
|
35
86
|
|
|
36
|
-
import {
|
|
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";
|
|
37
90
|
import modelsData from "./models.json" with { type: "json" };
|
|
38
91
|
import customModelsData from "./custom-models.json" with { type: "json" };
|
|
39
92
|
import patchData from "./patch.json" with { type: "json" };
|
|
40
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";
|
|
41
107
|
import fs from "fs";
|
|
108
|
+
import { hostname } from "os";
|
|
42
109
|
import path from "path";
|
|
43
110
|
|
|
44
111
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
45
112
|
|
|
46
113
|
interface JsonModel {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
};
|
|
68
135
|
}
|
|
69
136
|
|
|
70
137
|
interface PatchEntry {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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>;
|
|
84
151
|
}
|
|
85
152
|
|
|
86
153
|
type PatchData = Record<string, PatchEntry>;
|
|
@@ -88,73 +155,73 @@ type PatchData = Record<string, PatchEntry>;
|
|
|
88
155
|
// ─── Patch Application ────────────────────────────────────────────────────────
|
|
89
156
|
|
|
90
157
|
function applyPatch(model: JsonModel, patch: PatchEntry): JsonModel {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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;
|
|
123
190
|
}
|
|
124
191
|
|
|
125
192
|
/** Full pipeline: base models → patch → custom → result */
|
|
126
193
|
function buildModels(base: JsonModel[], custom: JsonModel[], patch: PatchData): JsonModel[] {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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());
|
|
158
225
|
}
|
|
159
226
|
|
|
160
227
|
// ─── Stale-While-Revalidate Model Sync ────────────────────────────────────────
|
|
@@ -169,126 +236,126 @@ const LIVE_FETCH_TIMEOUT_MS = 8000;
|
|
|
169
236
|
const PI_THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
170
237
|
|
|
171
238
|
const ON_OFF_THINKING_LEVEL_MAP: Record<string, string | null> = {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
239
|
+
off: "off",
|
|
240
|
+
minimal: null,
|
|
241
|
+
low: null,
|
|
242
|
+
medium: null,
|
|
243
|
+
high: null,
|
|
244
|
+
xhigh: null,
|
|
245
|
+
max: "max",
|
|
179
246
|
};
|
|
180
247
|
|
|
181
248
|
function buildThinkingLevelMap(levels: string[]): Record<string, string | null> | undefined {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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;
|
|
191
258
|
}
|
|
192
259
|
|
|
193
260
|
/** Transform a model from Charm's official typed Hyper /v1/provider catalog. */
|
|
194
261
|
function transformApiModel(apiModel: any): JsonModel | null {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
+
};
|
|
228
295
|
}
|
|
229
296
|
|
|
230
297
|
async function fetchLiveModels(apiKey: string, signal?: AbortSignal): Promise<JsonModel[] | null> {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
}
|
|
244
311
|
}
|
|
245
312
|
|
|
246
313
|
function loadCachedModels(): JsonModel[] | null {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
}
|
|
253
320
|
}
|
|
254
321
|
|
|
255
322
|
function cacheModels(models: JsonModel[]): void {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
+
}
|
|
262
329
|
}
|
|
263
330
|
|
|
264
331
|
function mergeWithEmbedded(liveModels: JsonModel[], embeddedModels: JsonModel[]): JsonModel[] {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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;
|
|
292
359
|
}
|
|
293
360
|
|
|
294
361
|
// Grace period for delisted models. When the provider API stops listing a
|
|
@@ -300,47 +367,47 @@ const DEPRECATED_MODEL_TTL_MS = 14 * 24 * 60 * 60 * 1000;
|
|
|
300
367
|
|
|
301
368
|
// Grace-period deprecated models with deprecation metadata stripped.
|
|
302
369
|
function activeDeprecatedModels(): JsonModel[] {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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;
|
|
314
381
|
}
|
|
315
382
|
|
|
316
383
|
// Append grace-period deprecated models the list does not already have (live data wins).
|
|
317
384
|
function withDeprecated(models: JsonModel[]): JsonModel[] {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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;
|
|
321
388
|
}
|
|
322
389
|
|
|
323
390
|
function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
|
|
324
|
-
|
|
325
|
-
|
|
391
|
+
const cached = loadCachedModels();
|
|
392
|
+
if (!cached || cached.length === 0) return embeddedModels;
|
|
326
393
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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;
|
|
335
402
|
}
|
|
336
403
|
|
|
337
404
|
async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal): Promise<JsonModel[] | null> {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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;
|
|
344
411
|
}
|
|
345
412
|
|
|
346
413
|
// ─── API Key Resolution (via ModelRegistry) ────────────────────────────────────
|
|
@@ -349,45 +416,649 @@ let cachedApiKey: string | undefined;
|
|
|
349
416
|
let revalidateAbort: AbortController | null = null;
|
|
350
417
|
|
|
351
418
|
async function resolveApiKey(modelRegistry: ModelRegistry): Promise<void> {
|
|
352
|
-
|
|
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
|
+
}
|
|
353
953
|
}
|
|
354
954
|
|
|
355
955
|
// ─── Extension Entry Point ────────────────────────────────────────────────────
|
|
356
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
|
+
|
|
357
975
|
export default function (pi: ExtensionAPI) {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
+
});
|
|
393
1064
|
}
|