pi-hypercharm-provider 1.3.26 → 1.3.28

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/AGENTS.md CHANGED
@@ -21,6 +21,9 @@ When a model needs overrides, new properties, or corrections, edit the appropria
21
21
  | `index.ts` | Provider extension code: model sync, streaming wrapper, footer-status wiring. |
22
22
  | `status.ts` | Footer-status presentation: config schema, hypercredit/rate-limit formatters, progressive-disclosure tiers, width-aware widget. Pure module — no pi imports; exercised by `tests/status.smoke.ts`. |
23
23
  | `scripts/update-models.js` | The sync script itself (edit only if changing how models are fetched/transformed). |
24
+ | `identity.ts` | Every identifier this extension registers into a shared pi surface (provider id, custom api name, status/widget keys, command, prism entry type, auth key + env var, config/cache file names). `tests/identity.test.ts` enforces namespacing, uniqueness, and disjointness from the official `@charmland/pi-hyper-provider` — never hardcode one of these elsewhere. |
25
+ | `notify.ts` | Deduplicated warning sink: fetch/parse failures go to the session UI once one is active, stderr before that, never thrown. |
26
+ | `prism.ts` | Hyper routing-header validation (sanitizer + persisted-entry re-validation). Pure module — no pi imports; exercised by `tests/prism.test.ts`. |
24
27
 
25
28
  ## Data Flow
26
29
 
@@ -53,6 +56,27 @@ Provider API ──fetch──► models.json ──apply──► patch.jso
53
56
 
54
57
  ## TL;DR
55
58
 
59
+ - **Never register into a shared pi surface with a hardcoded name** — add it to `identity.ts`; the co-installation tests enforce the namespace.
56
60
  - **Never edit `models.json`** — edit `patch.json` instead.
57
61
  - **Never edit the README model table** — run the update script instead.
58
62
  - `patch.json` and `custom-models.json` are the source files you should modify.
63
+
64
+ ## Tests
65
+
66
+ `npm run check` runs `tsc --noEmit`, the dependency-free status smoke test, and
67
+ the node:test suites (through `jiti/register` so the extension's extensionless
68
+ TS imports resolve). `npm test` runs only the test suites.
69
+
70
+ | File | Scope |
71
+ |------|-------|
72
+ | `tests/status.smoke.ts` | Footer widget layout, width math, tier building, config coercion |
73
+ | `tests/identity.test.ts` | Co-install invariant: namespaced + unique + disjoint from the official provider's reserved names |
74
+ | `tests/prism.test.ts` | Prism header/label sanitization and persisted-entry validation |
75
+ | `tests/notify.test.ts` | Warning dedupe, UI routing, stale-ctx stderr fallback |
76
+ | `tests/provider.integration.test.ts` | Real pi runtime (DefaultResourceLoader + createAgentSession + ExtensionRunner): embedded catalog, catalog hot-swap/cache/retention, deprecated grace window, warning surfacing, prism entry durability, and co-installation against `tests/fixtures/official-surface.ts` |
77
+
78
+ The integration suite stubs `globalThis.fetch` and emits the session lifecycle
79
+ events the pi CLI emits (`session_start`, `turn_start`, `after_provider_response`,
80
+ `message_end`, `turn_end`). It must be run with jiti; `--import jiti/register`
81
+ is already wired into `npm test`.
82
+
package/README.md CHANGED
@@ -38,7 +38,7 @@ _Hyperoptimized coding models — DeepSeek, GLM, Kimi, Qwen, MiniMax, Gemma, GPT
38
38
  | GLM-5 | Text | 203K | 20K | $0.86 | $2.75 |
39
39
  | GLM-5.1 | Text | 203K | 3K | $1.36 | $4.27 |
40
40
  | GLM-5.2 | Text | 1.0M | 33K | $1.52 | $4.79 |
41
- | gpt-oss-120b | Text | 128K | 13K | $0.18 | $0.68 |
41
+ | gpt-oss-120b | Text | 131K | 13K | $0.18 | $0.68 |
42
42
  | Inkling | Text + Image | 1.0M | 33K | $1.09 | $4.41 |
43
43
  | Kimi K2 Thinking | Text | 262K | 26K | $0.60 | $2.50 |
44
44
  | Kimi K2.5 | Text | 262K | 26K | $0.56 | $2.94 |
package/identity.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Single source of truth for every identifier this extension registers into
3
+ * shared Pi surfaces.
4
+ *
5
+ * Co-installation with the official `@charmland/pi-hyper-provider` is a hard
6
+ * requirement, so no identifier we register may be a bare shared name. Every
7
+ * key derives from PROVIDER_ID: provider id, custom api name, status and widget
8
+ * keys, command name, prism entry type, auth key and env var, and the on-disk
9
+ * config/cache file names. tests/identity.test.ts and
10
+ * tests/provider.integration.test.ts enforce the invariant (namespacing,
11
+ * uniqueness, and disjointness from the official provider's reserved names).
12
+ */
13
+ export const PROVIDER_ID = "hypercharm";
14
+ export const PROVIDER_DISPLAY_NAME = "HyperCharm";
15
+
16
+ /** Custom api name: keeps our streamSimple handler distinct from pi's built-in openai-completions pipeline. */
17
+ export const API_NAME = PROVIDER_ID;
18
+
19
+ export const API_KEY_ENV = "HYPERCHARM_API_KEY";
20
+ export const API_KEY_PLACEHOLDER = `$${API_KEY_ENV}`;
21
+
22
+ export const STATUS_COMMAND = `${PROVIDER_ID}-status`;
23
+ export const STATUS_KEY_SESSION = `${PROVIDER_ID}-session`;
24
+ export const STATUS_KEY_ACCOUNT = `${PROVIDER_ID}-account`;
25
+ export const WIDGET_KEY = PROVIDER_ID;
26
+ export const PRISM_ENTRY_TYPE = `${PROVIDER_ID}-prism-route`;
27
+
28
+ export const CONFIG_FILE_NAME = `${PROVIDER_ID}.json`;
29
+ export const CACHE_FILE_NAME = `${PROVIDER_ID}-models.json`;
30
+
31
+ /**
32
+ * Every distinct namespaced identifier this extension registers or writes.
33
+ * API_NAME and WIDGET_KEY alias PROVIDER_ID by design — pi keys the custom api
34
+ * handler and the below-editor widget by provider id — so the set is deduped;
35
+ * what matters is that no *value* is shared with another extension.
36
+ */
37
+ export const IDENTIFIERS: readonly string[] = Object.freeze([
38
+ ...new Set([
39
+ PROVIDER_ID,
40
+ API_KEY_ENV,
41
+ API_KEY_PLACEHOLDER,
42
+ STATUS_COMMAND,
43
+ STATUS_KEY_SESSION,
44
+ STATUS_KEY_ACCOUNT,
45
+ PRISM_ENTRY_TYPE,
46
+ CONFIG_FILE_NAME,
47
+ CACHE_FILE_NAME,
48
+ ]),
49
+ ]);
package/index.ts CHANGED
@@ -103,7 +103,23 @@
103
103
 
104
104
  import { clampThinkingLevel, streamOpenAICompletions } from "@earendil-works/pi-ai/compat";
105
105
  import type { AssistantMessageEventStream, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
106
+ import { Text } from "@earendil-works/pi-tui";
106
107
  import { USER_AGENT, loginHypercharm, refreshHypercharmToken } from "./oauth";
108
+ import {
109
+ API_KEY_PLACEHOLDER,
110
+ API_NAME,
111
+ CACHE_FILE_NAME,
112
+ CONFIG_FILE_NAME,
113
+ PRISM_ENTRY_TYPE,
114
+ PROVIDER_DISPLAY_NAME,
115
+ PROVIDER_ID,
116
+ STATUS_COMMAND,
117
+ STATUS_KEY_ACCOUNT,
118
+ STATUS_KEY_SESSION,
119
+ WIDGET_KEY,
120
+ } from "./identity";
121
+ import { createNotifier } from "./notify";
122
+ import { prismRouteFromHeaders, prismRouteLabel, readPrismRoute, type PrismRoute } from "./prism";
107
123
  import { getAgentDir, type ExtensionAPI, type ExtensionContext, type ModelRegistry } from "@earendil-works/pi-coding-agent";
108
124
  import modelsData from "./models.json" with { type: "json" };
109
125
  import customModelsData from "./custom-models.json" with { type: "json" };
@@ -132,6 +148,18 @@ import path from "path";
132
148
 
133
149
  // ─── Types ────────────────────────────────────────────────────────────────────
134
150
 
151
+ // Warning sink for fetch/parse failures: deduplicated, routed to the session UI
152
+ // once one is active, stderr before that. Warnings must never throw.
153
+ const notifier = createNotifier();
154
+
155
+ function describeError(err: unknown): string {
156
+ return err instanceof Error ? err.message : String(err);
157
+ }
158
+
159
+ function warnAccountFetch(label: string, reason: string): void {
160
+ notifier.warn(`Unable to refresh HyperCharm ${label}: ${reason}.`);
161
+ }
162
+
135
163
  interface JsonModel {
136
164
  id: string;
137
165
  name: string;
@@ -248,11 +276,10 @@ function buildModels(base: JsonModel[], custom: JsonModel[], patch: PatchData):
248
276
 
249
277
  // ─── Stale-While-Revalidate Model Sync ────────────────────────────────────────
250
278
 
251
- const PROVIDER_ID = "hypercharm";
252
279
  const BASE_URL = "https://hyper.charm.land/v1";
253
280
  const MODELS_URL = `${BASE_URL}/provider`;
254
281
  const CACHE_DIR = path.join(getAgentDir(), "cache");
255
- const CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-models.json`);
282
+ const CACHE_PATH = path.join(CACHE_DIR, CACHE_FILE_NAME);
256
283
  const LIVE_FETCH_TIMEOUT_MS = 8000;
257
284
 
258
285
  const PI_THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
@@ -322,12 +349,22 @@ async function fetchLiveModels(apiKey: string, signal?: AbortSignal): Promise<Js
322
349
  headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": USER_AGENT },
323
350
  signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
324
351
  });
325
- if (!response.ok) return null;
352
+ if (!response.ok) {
353
+ notifier.warn(`HyperCharm model catalog refresh failed: HTTP ${response.status} — serving cached/embedded models.`);
354
+ return null;
355
+ }
326
356
  const data = await response.json();
327
357
  const apiModels = Array.isArray(data) ? data : (data.models || data.data || []);
328
- if (!Array.isArray(apiModels) || apiModels.length === 0) return null;
358
+ if (!Array.isArray(apiModels) || apiModels.length === 0) {
359
+ notifier.warn("HyperCharm model catalog refresh returned no usable models — serving cached/embedded models.");
360
+ return null;
361
+ }
329
362
  return apiModels.map(transformApiModel).filter((m): m is JsonModel => m !== null);
330
- } catch {
363
+ } catch (err) {
364
+ // An aborted signal means the session was replaced, not that Hyper failed.
365
+ if (!signal?.aborted) {
366
+ notifier.warn(`HyperCharm model catalog refresh failed: ${describeError(err)} — serving cached/embedded models.`);
367
+ }
331
368
  return null;
332
369
  }
333
370
  }
@@ -336,7 +373,10 @@ function loadCachedModels(): JsonModel[] | null {
336
373
  try {
337
374
  const data = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
338
375
  return Array.isArray(data) ? data : null;
339
- } catch {
376
+ } catch (err) {
377
+ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
378
+ notifier.warn(`Ignoring unreadable HyperCharm model cache at ${CACHE_PATH}: ${describeError(err)}.`);
379
+ }
340
380
  return null;
341
381
  }
342
382
  }
@@ -345,8 +385,9 @@ function cacheModels(models: JsonModel[]): void {
345
385
  try {
346
386
  fs.mkdirSync(CACHE_DIR, { recursive: true });
347
387
  fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
348
- } catch {
349
- // Cache write failure is non-fatal
388
+ } catch (err) {
389
+ // Non-fatal: the freshly fetched catalog still serves this session.
390
+ notifier.warn(`Could not write the HyperCharm model cache to ${CACHE_PATH}: ${describeError(err)}.`);
350
391
  }
351
392
  }
352
393
 
@@ -451,8 +492,11 @@ function loadStatusConfig(): StatusConfig {
451
492
  try {
452
493
  const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
453
494
  statusConfig = coerceStatusConfig(raw);
454
- } catch {
455
- // Missing or unreadable file → defaults
495
+ } catch (err) {
496
+ // A missing file is normal; anything else is worth surfacing once.
497
+ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
498
+ notifier.warn(`Ignoring unreadable HyperCharm status config at ${CONFIG_PATH}: ${describeError(err)} — using defaults.`);
499
+ }
456
500
  }
457
501
  return statusConfig;
458
502
  }
@@ -473,8 +517,9 @@ function writeStatusConfig(): void {
473
517
  raw.glyphs = statusConfig.glyphs;
474
518
  fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
475
519
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(raw, null, 2) + "\n");
476
- } catch {
477
- // Config write failure is non-fatal — the in-memory config still applies
520
+ } catch (err) {
521
+ // Non-fatal: the in-memory config still applies to this session.
522
+ notifier.warn(`Could not save the HyperCharm status config to ${CONFIG_PATH}: ${describeError(err)}.`);
478
523
  }
479
524
  }
480
525
 
@@ -650,7 +695,7 @@ let lastCreditsFetchAt = 0;
650
695
  let creditsInFlight: Promise<void> | null = null;
651
696
  let metaFetched = false;
652
697
 
653
- async function fetchJsonGet(url: string, apiKey: string, signal?: AbortSignal): Promise<any | null> {
698
+ async function fetchJsonGet(url: string, apiKey: string, signal: AbortSignal | undefined, label: string): Promise<any | null> {
654
699
  try {
655
700
  const response = await fetch(url, {
656
701
  headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": USER_AGENT },
@@ -658,9 +703,14 @@ async function fetchJsonGet(url: string, apiKey: string, signal?: AbortSignal):
658
703
  ? AbortSignal.any([AbortSignal.timeout(ACCOUNT_FETCH_TIMEOUT_MS), signal])
659
704
  : AbortSignal.timeout(ACCOUNT_FETCH_TIMEOUT_MS),
660
705
  });
661
- if (!response.ok) return null;
706
+ if (!response.ok) {
707
+ warnAccountFetch(label, `HTTP ${response.status}`);
708
+ return null;
709
+ }
662
710
  return await response.json();
663
- } catch {
711
+ } catch (err) {
712
+ // An abort means the session was replaced or shut down, not a failure.
713
+ if (!signal?.aborted) warnAccountFetch(label, describeError(err));
664
714
  return null;
665
715
  }
666
716
  }
@@ -673,7 +723,7 @@ function refreshCredits(apiKey: string | undefined, signal: AbortSignal | undefi
673
723
  if (creditsInFlight) return creditsInFlight;
674
724
  creditsInFlight = (async () => {
675
725
  try {
676
- const data = await fetchJsonGet(`${BASE_URL}/credits`, apiKey, signal);
726
+ const data = await fetchJsonGet(`${BASE_URL}/credits`, apiKey, signal, "Hypercredit balance");
677
727
  if (data === null) return;
678
728
  // /credits can report hypercredits ("balance") or USD ("balance_usd",
679
729
  // USD-billed accounts). Handle both at the observed 20 hc = $1 rate so a
@@ -693,8 +743,8 @@ function refreshCredits(apiKey: string | undefined, signal: AbortSignal | undefi
693
743
  async function refreshAccountMeta(apiKey: string | undefined, signal?: AbortSignal): Promise<void> {
694
744
  if (!apiKey || metaFetched) return;
695
745
  const [teams, devices] = await Promise.all([
696
- fetchJsonGet(`${BASE_URL}/teams`, apiKey, signal),
697
- fetchJsonGet(`${BASE_URL}/devices`, apiKey, signal),
746
+ fetchJsonGet(`${BASE_URL}/teams`, apiKey, signal, "team metadata"),
747
+ fetchJsonGet(`${BASE_URL}/devices`, apiKey, signal, "device sessions"),
698
748
  ]);
699
749
  if (signal?.aborted) return;
700
750
 
@@ -719,10 +769,6 @@ async function refreshAccountMeta(apiKey: string | undefined, signal?: AbortSign
719
769
 
720
770
  // ─── Status Rendering ─────────────────────────────────────────────────────────
721
771
 
722
- const WIDGET_KEY = "hypercharm";
723
- const STATUS_KEY_SESSION = "hypercharm-session";
724
- const STATUS_KEY_ACCOUNT = "hypercharm-account";
725
-
726
772
  function currentProviderId(ctx: ExtensionContext): string | undefined {
727
773
  // ctx.model is a getter that can throw on stale contexts
728
774
  try {
@@ -1055,15 +1101,15 @@ let currentModels: JsonModel[] = [];
1055
1101
  function makeProviderConfig(models: JsonModel[] = currentModels) {
1056
1102
  return {
1057
1103
  baseUrl: BASE_URL,
1058
- apiKey: "$HYPERCHARM_API_KEY",
1104
+ apiKey: API_KEY_PLACEHOLDER,
1059
1105
  // Custom API name so our streamSimple registers as its own handler and
1060
1106
  // never shadows pi's built-in openai-completions pipeline for other
1061
1107
  // providers. streamHypercharm delegates to pi-ai's OpenAI-compat streamer.
1062
- api: "hypercharm",
1108
+ api: API_NAME,
1063
1109
  models,
1064
1110
  streamSimple: streamHypercharm,
1065
1111
  oauth: {
1066
- name: "HyperCharm",
1112
+ name: PROVIDER_DISPLAY_NAME,
1067
1113
  login: (callbacks) => loginHypercharm(callbacks),
1068
1114
  refreshToken: (credentials, signal) => refreshHypercharmToken(credentials, signal),
1069
1115
  getApiKey: (credentials) => String(credentials.access ?? ""),
@@ -1076,13 +1122,17 @@ export default function (pi: ExtensionAPI) {
1076
1122
  const customModels = customModelsData as JsonModel[];
1077
1123
  const patches = patchData as PatchData;
1078
1124
 
1125
+ // Prism routing state: collected per assistant request, committed at turn_end.
1126
+ let collectingPrismRoute = false;
1127
+ let prismRoute: PrismRoute | undefined;
1128
+
1079
1129
  const staleBase = loadStaleModels(embeddedModels);
1080
1130
  const staleModels = buildModels(staleBase, customModels, patches);
1081
1131
  currentModels = staleModels;
1082
1132
 
1083
1133
  pi.registerProvider(PROVIDER_ID, makeProviderConfig(staleModels));
1084
1134
 
1085
- pi.registerCommand("hypercharm-status", {
1135
+ pi.registerCommand(STATUS_COMMAND, {
1086
1136
  description: "Configure the HyperCharm footer status (session spend, balance, rate limits)",
1087
1137
  handler: async (args, ctx) => {
1088
1138
  await handleStatusCommand(args, ctx);
@@ -1090,6 +1140,7 @@ export default function (pi: ExtensionAPI) {
1090
1140
  });
1091
1141
 
1092
1142
  pi.on("session_start", async (_event, ctx) => {
1143
+ notifier.activate(ctx);
1093
1144
  const epoch = ++statusEpoch;
1094
1145
  revalidateAbort?.abort();
1095
1146
  revalidateAbort = new AbortController();
@@ -1105,7 +1156,14 @@ export default function (pi: ExtensionAPI) {
1105
1156
  // over anything that touched provider registration during load.
1106
1157
  pi.registerProvider(PROVIDER_ID, makeProviderConfig());
1107
1158
 
1108
- resolveApiKey(ctx.modelRegistry).then(() => {
1159
+ // A failure here used to vanish: no key resolved meant no refresh and no
1160
+ // diagnostics. Surface it, then continue — without a key we serve the
1161
+ // embedded/cached catalog.
1162
+ resolveApiKey(ctx.modelRegistry)
1163
+ .catch((err) => {
1164
+ notifier.warn(`Unable to resolve HyperCharm credentials: ${describeError(err)} — serving cached/embedded models.`);
1165
+ })
1166
+ .then(() => {
1109
1167
  // A session replacement while the key resolved invalidated the
1110
1168
  // captured ctx (fast-resume, /new, /fork); nothing below may touch it.
1111
1169
  if (epoch !== statusEpoch) return;
@@ -1165,4 +1223,42 @@ export default function (pi: ExtensionAPI) {
1165
1223
  ctx.ui.setStatus(STATUS_KEY_ACCOUNT, undefined);
1166
1224
  ctx.ui.setWidget(WIDGET_KEY, undefined);
1167
1225
  });
1226
+
1227
+ // Prism routing: Hyper's edge reports which upstream model actually served the
1228
+ // assistant request via response headers. Collection is scoped to that request
1229
+ // so auxiliary calls between turns cannot leak a route into the transcript,
1230
+ // and the route lands at turn_end as a durable session entry — never a
1231
+ // notification — so it survives reopening the session.
1232
+ pi.registerEntryRenderer(PRISM_ENTRY_TYPE, (entry, _options, theme) => {
1233
+ const route = readPrismRoute(entry.data);
1234
+ const label = route ? prismRouteLabel(route) : undefined;
1235
+ if (label === undefined) return undefined;
1236
+ return new Text(`${theme.fg("muted", "Prism")} ${theme.fg("dim", "→")} ${theme.fg("muted", label)}`, 0, 0);
1237
+ });
1238
+
1239
+ pi.on("turn_start", () => {
1240
+ collectingPrismRoute = true;
1241
+ prismRoute = undefined;
1242
+ });
1243
+
1244
+ pi.on("after_provider_response", (event) => {
1245
+ if (!collectingPrismRoute) return;
1246
+ prismRoute = prismRouteFromHeaders(event.headers);
1247
+ });
1248
+
1249
+ pi.on("message_end", (event) => {
1250
+ if (event.message.role === "assistant") collectingPrismRoute = false;
1251
+ });
1252
+
1253
+ pi.on("turn_end", (event) => {
1254
+ const route = prismRoute;
1255
+ prismRoute = undefined;
1256
+ collectingPrismRoute = false;
1257
+ if (route === undefined) return;
1258
+ if (event.message.role !== "assistant") return;
1259
+ if (event.message.provider !== PROVIDER_ID) return;
1260
+ if (event.message.stopReason === "error" || event.message.stopReason === "aborted") return;
1261
+ pi.appendEntry(PRISM_ENTRY_TYPE, route);
1262
+ });
1263
+
1168
1264
  }
package/models.json CHANGED
@@ -285,13 +285,13 @@
285
285
  "name": "gpt-oss-120b",
286
286
  "reasoning": true,
287
287
  "thinkingLevelMap": {
288
- "off": "none",
289
- "minimal": "minimal",
288
+ "off": null,
289
+ "minimal": null,
290
290
  "low": "low",
291
291
  "medium": "medium",
292
292
  "high": "high",
293
- "xhigh": "xhigh",
294
- "max": "max"
293
+ "xhigh": null,
294
+ "max": null
295
295
  },
296
296
  "input": [
297
297
  "text"
@@ -302,7 +302,7 @@
302
302
  "cacheRead": 0.089,
303
303
  "cacheWrite": 0
304
304
  },
305
- "contextWindow": 128072,
305
+ "contextWindow": 131072,
306
306
  "maxTokens": 13107,
307
307
  "compat": {
308
308
  "supportsStore": false,
package/notify.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type WarningSink = (message: string) => void;
4
+
5
+ export interface Notifier {
6
+ /** Emit a deduplicated warning through the currently available output. */
7
+ warn: WarningSink;
8
+ /** Route future warnings through this session's UI (no-op without a UI). */
9
+ activate(ctx: ExtensionContext): void;
10
+ }
11
+
12
+ /**
13
+ * Fetch and parse failures must never be silent, and must never spam: warnings
14
+ * are deduplicated by message for the life of the process. Until a UI session
15
+ * activates the notifier they go to stderr (visible in print/json modes); after
16
+ * activation they go through ctx.ui.notify. A stale ctx — a refresh landing
17
+ * after its session was replaced — falls back to stderr instead of throwing.
18
+ */
19
+ export function createNotifier(): Notifier {
20
+ const seenWarnings = new Set<string>();
21
+ const toStderr: WarningSink = (message) => {
22
+ process.stderr.write(`HyperCharm warning: ${message}\n`);
23
+ };
24
+ let emit: WarningSink = toStderr;
25
+
26
+ return {
27
+ warn(message) {
28
+ if (seenWarnings.has(message)) return;
29
+ seenWarnings.add(message);
30
+ emit(message);
31
+ },
32
+
33
+ activate(ctx) {
34
+ let hasUI = false;
35
+ try {
36
+ hasUI = ctx.hasUI;
37
+ } catch {
38
+ return;
39
+ }
40
+ if (!hasUI) return;
41
+ emit = (message) => {
42
+ try {
43
+ ctx.ui.notify(message, "warning");
44
+ } catch {
45
+ toStderr(message);
46
+ }
47
+ };
48
+ },
49
+ };
50
+ }
package/package.json CHANGED
@@ -1,9 +1,17 @@
1
1
  {
2
2
  "name": "pi-hypercharm-provider",
3
- "version": "1.3.26",
3
+ "version": "1.3.28",
4
4
  "description": "HyperCharm provider extension for pi - Access DeepSeek, GLM, Kimi, Qwen, MiniMax, Gemma, and GPT-OSS models through the Charm Hyper API",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
+ "scripts": {
8
+ "clean": "echo 'nothing to clean'",
9
+ "build": "echo 'nothing to build'",
10
+ "check": "tsc --noEmit && npm run smoke && npm test",
11
+ "smoke": "node tests/status.smoke.ts",
12
+ "test": "node --import jiti/register --test tests/identity.test.ts tests/prism.test.ts tests/notify.test.ts tests/provider.integration.test.ts",
13
+ "update-models": "node scripts/update-models.js"
14
+ },
7
15
  "keywords": [
8
16
  "pi",
9
17
  "extension",
@@ -21,10 +29,15 @@
21
29
  "gemma",
22
30
  "gpt-oss"
23
31
  ],
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-tui": "*"
34
+ },
24
35
  "devDependencies": {
25
36
  "@earendil-works/pi-ai": "0.85.1",
26
37
  "@earendil-works/pi-coding-agent": "0.85.1",
38
+ "@earendil-works/pi-tui": "0.85.1",
27
39
  "@types/node": "^24.10.1",
40
+ "jiti": "2.7.0",
28
41
  "typescript": "6.0.3"
29
42
  },
30
43
  "author": "",
@@ -33,12 +46,5 @@
33
46
  "extensions": [
34
47
  "./index.ts"
35
48
  ]
36
- },
37
- "scripts": {
38
- "clean": "echo 'nothing to clean'",
39
- "build": "echo 'nothing to build'",
40
- "check": "tsc --noEmit && node tests/status.smoke.ts",
41
- "smoke": "node tests/status.smoke.ts",
42
- "update-models": "node scripts/update-models.js"
43
49
  }
44
- }
50
+ }
package/prism.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { PRISM_ENTRY_TYPE } from "./identity";
2
+
3
+ export { PRISM_ENTRY_TYPE };
4
+
5
+ /** Longest routing label we accept (mirrors the official provider's limit). */
6
+ const MAX_LABEL_LENGTH = 200;
7
+ /** Control, format, line-separator, and paragraph-separator characters. */
8
+ const UNSAFE_LABEL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
9
+
10
+ export interface PrismRoute {
11
+ modelName?: string;
12
+ modelId?: string;
13
+ }
14
+
15
+ /** Trimmed, length- and character-checked routing label; undefined when unusable. */
16
+ export function prismLabel(value: unknown): string | undefined {
17
+ if (typeof value !== "string") return undefined;
18
+ const label = value.trim();
19
+ if (label === "" || label.length > MAX_LABEL_LENGTH || UNSAFE_LABEL.test(label)) return undefined;
20
+ return label;
21
+ }
22
+
23
+ /** Route captured from a provider response; undefined when neither header is usable. */
24
+ export function prismRouteFromHeaders(headers: Record<string, string> | undefined): PrismRoute | undefined {
25
+ if (!headers) return undefined;
26
+ const modelName = prismLabel(headers["x-prism-model-name"]);
27
+ const modelId = prismLabel(headers["x-prism-model-id"]);
28
+ return modelName !== undefined || modelId !== undefined ? { modelName, modelId } : undefined;
29
+ }
30
+
31
+ /** Re-validate persisted entry data: session files are user-editable input. */
32
+ export function readPrismRoute(data: unknown): PrismRoute | undefined {
33
+ if (typeof data !== "object" || data === null) return undefined;
34
+ const record = data as Record<string, unknown>;
35
+ const modelName = prismLabel(record.modelName);
36
+ const modelId = prismLabel(record.modelId);
37
+ return modelName !== undefined || modelId !== undefined ? { modelName, modelId } : undefined;
38
+ }
39
+
40
+ /** What to show for a route: the human name when present, else the model id. */
41
+ export function prismRouteLabel(route: PrismRoute): string | undefined {
42
+ return route.modelName ?? route.modelId;
43
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Stand-in for the official @charmland/pi-hyper-provider@0.4.0 registration
3
+ * surface, used by tests/provider.integration.test.ts to prove both extensions
4
+ * can be installed at once.
5
+ *
6
+ * It deliberately registers under the OFFICIAL identifiers — provider id
7
+ * "hyper", status key "hyper", /hyper-status command, hyper-prism-route entry
8
+ * renderer, HYPER_API_KEY — and publishes a model with an id we also serve
9
+ * ("glm-5.3"), so the test asserts provider-scoped model resolution instead of
10
+ * accidental global-key behaviour.
11
+ */
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+
14
+ export default function officialSurfaceFixture(pi: ExtensionAPI) {
15
+ pi.registerProvider("hyper", {
16
+ baseUrl: "https://hyper.charm.land/v1",
17
+ apiKey: "$HYPER_API_KEY",
18
+ api: "openai-completions",
19
+ models: [
20
+ {
21
+ id: "glm-5.3",
22
+ name: "Official fixture GLM 5.3",
23
+ reasoning: false,
24
+ input: ["text"],
25
+ cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
26
+ contextWindow: 4096,
27
+ maxTokens: 512,
28
+ },
29
+ ],
30
+ });
31
+
32
+ pi.registerEntryRenderer("hyper-prism-route", () => undefined);
33
+
34
+ pi.registerCommand("hyper-status", {
35
+ description: "Configure the Charm Hyper footer status",
36
+ handler: async () => {},
37
+ });
38
+
39
+ pi.on("turn_end", (_event, ctx) => {
40
+ ctx.ui.setStatus("hyper", "Hyper fixture status");
41
+ });
42
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * First-principles co-installation guard: every identifier this extension
3
+ * registers into a shared Pi surface must be namespaced under PROVIDER_ID and
4
+ * disjoint from the official provider's reserved names.
5
+ *
6
+ * OFFICIAL_RESERVED mirrors @charmland/pi-hyper-provider@0.4.0's registration
7
+ * surface (src/hyper.ts, src/index.ts, src/settings.ts, src/credits.ts). Keep
8
+ * it in sync when the official package adds or renames a shared identifier: a
9
+ * failure here means the two extensions could collide on that surface.
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { test } from "node:test";
13
+ import {
14
+ API_KEY_ENV,
15
+ API_NAME,
16
+ IDENTIFIERS,
17
+ PRISM_ENTRY_TYPE,
18
+ PROVIDER_ID,
19
+ STATUS_COMMAND,
20
+ STATUS_KEY_ACCOUNT,
21
+ STATUS_KEY_SESSION,
22
+ WIDGET_KEY,
23
+ } from "../identity.ts";
24
+
25
+ const OFFICIAL_RESERVED = [
26
+ "hyper", // provider id and status key (PROVIDER_NAME)
27
+ "Charm Hyper", // provider + oauth display name
28
+ "hyper-status", // /hyper-status command
29
+ "hyper-prism-route", // prism entry customType
30
+ "hyper-provider", // ~/.pi/agent/hyper-provider settings dir
31
+ "HYPER_API_KEY", // envApiKeyAuth env var (auth key)
32
+ "openai-completions", // api name (openAICompletionsApi)
33
+ "@charmland/pi-hyper-provider", // npm package name
34
+ ];
35
+
36
+ test("every registered identifier is namespaced under the provider id", () => {
37
+ for (const identifier of IDENTIFIERS) {
38
+ assert.equal(typeof identifier, "string");
39
+ assert.ok(identifier.length > 0);
40
+ // Normalize the two conventions we use: env vars are upper-cased
41
+ // (HYPERCHARM_API_KEY) and pi's env-var placeholders carry a "$" sigil.
42
+ const normalized = identifier.replace(/^\$/, "").toLowerCase();
43
+ assert.ok(normalized.startsWith(PROVIDER_ID), identifier + " must start with " + PROVIDER_ID);
44
+ }
45
+ });
46
+
47
+ test("identifiers are unique", () => {
48
+ assert.equal(new Set(IDENTIFIERS).size, IDENTIFIERS.length);
49
+ });
50
+
51
+ test("no identifier collides with the official provider's reserved names", () => {
52
+ for (const identifier of IDENTIFIERS) {
53
+ assert.ok(!OFFICIAL_RESERVED.includes(identifier), identifier + " collides with the official provider");
54
+ }
55
+ });
56
+
57
+ test("the specific shared surfaces differ from the official registration", () => {
58
+ assert.equal(PROVIDER_ID, "hypercharm");
59
+ assert.notEqual(PROVIDER_ID, "hyper");
60
+ assert.notEqual(API_NAME, "openai-completions");
61
+ assert.notEqual(API_KEY_ENV, "HYPER_API_KEY");
62
+ assert.notEqual(STATUS_COMMAND, "hyper-status");
63
+ assert.notEqual(PRISM_ENTRY_TYPE, "hyper-prism-route");
64
+ assert.notEqual(STATUS_KEY_SESSION, "hyper");
65
+ assert.notEqual(STATUS_KEY_ACCOUNT, "hyper");
66
+ assert.notEqual(WIDGET_KEY, "hyper");
67
+ });
68
+
69
+ test("status and widget keys stay disjoint so pi cannot cross-clear them", () => {
70
+ assert.ok(WIDGET_KEY.startsWith(PROVIDER_ID));
71
+ assert.ok(STATUS_KEY_SESSION.startsWith(PROVIDER_ID + "-"));
72
+ assert.ok(STATUS_KEY_ACCOUNT.startsWith(PROVIDER_ID + "-"));
73
+ assert.notEqual(STATUS_KEY_SESSION, STATUS_KEY_ACCOUNT);
74
+ assert.notEqual(STATUS_KEY_SESSION, WIDGET_KEY);
75
+ assert.notEqual(STATUS_KEY_ACCOUNT, WIDGET_KEY);
76
+ });