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/.pi/fabric/mesh/state.json +106 -0
- package/AGENTS.md +2 -1
- package/README.md +81 -50
- package/deprecated-models.json +34 -1
- package/index.ts +951 -291
- package/models.json +436 -125
- package/package.json +9 -2
- package/patch.json +1 -155
- package/pnpm-workspace.yaml +15 -0
- package/scripts/update-models.js +181 -101
- package/status.ts +305 -0
- package/tests/status.smoke.ts +146 -0
- package/tsconfig.json +15 -0
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* pricing, context
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}/
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
336
|
-
|
|
391
|
+
const cached = loadCachedModels();
|
|
392
|
+
if (!cached || cached.length === 0) return embeddedModels;
|
|
337
393
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
}
|