pi-hypercharm-provider 1.3.27 → 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 +24 -0
- package/identity.ts +49 -0
- package/index.ts +123 -27
- package/notify.ts +50 -0
- package/package.json +15 -9
- package/prism.ts +43 -0
- package/tests/fixtures/official-surface.ts +42 -0
- package/tests/identity.test.ts +76 -0
- package/tests/notify.test.ts +111 -0
- package/tests/prism.test.ts +67 -0
- package/tests/provider.integration.test.ts +416 -0
- package/bun.lock +0 -342
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/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,
|
|
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)
|
|
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)
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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)
|
|
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:
|
|
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:
|
|
1108
|
+
api: API_NAME,
|
|
1063
1109
|
models,
|
|
1064
1110
|
streamSimple: streamHypercharm,
|
|
1065
1111
|
oauth: {
|
|
1066
|
-
name:
|
|
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(
|
|
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
|
-
|
|
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/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.
|
|
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
|
+
});
|