pi-hypercharm-provider 1.3.27 → 1.3.29
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/README.md +7 -5
- package/identity.ts +49 -0
- package/index.ts +144 -39
- 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 +485 -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/README.md
CHANGED
|
@@ -138,11 +138,13 @@ DeepSeek V4 models use the `deepseek` thinking format — the same native format
|
|
|
138
138
|
|
|
139
139
|
## Footer Status
|
|
140
140
|
|
|
141
|
-
A Neuralwatt-style status line sits below the editor. It appears
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
141
|
+
A Neuralwatt-style status line sits below the editor. It appears as soon as a
|
|
142
|
+
HyperCharm model is selected — the account side (team, balance, rate limits)
|
|
143
|
+
renders when the session-start or model-select credits fetch lands, and the
|
|
144
|
+
session side joins it after the first HyperCharm turn completes. Selecting
|
|
145
|
+
another provider hides it (`hideOnOtherProvider` defaults to `true`), the
|
|
146
|
+
balance refreshes when the agent run fully settles, and sessions that never
|
|
147
|
+
use HyperCharm make no status-related API calls:
|
|
146
148
|
|
|
147
149
|
```
|
|
148
150
|
⚡ 1.24 hc · 7 req Xu's Team ◆ 249 hc · 996/1k/h · 29d
|
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
|
@@ -33,12 +33,15 @@
|
|
|
33
33
|
* The right side compresses across progressive tiers as the terminal
|
|
34
34
|
* narrows. The balance flips to a ⚠ warning at/below lowBalanceHc.
|
|
35
35
|
*
|
|
36
|
-
* Lifecycle (mirrors pi-neuralwatt-provider):
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* the
|
|
41
|
-
*
|
|
36
|
+
* Lifecycle (mirrors pi-neuralwatt-provider): selecting a HyperCharm model
|
|
37
|
+
* shows the line — the account side renders as soon as the credits/team
|
|
38
|
+
* prefetch lands, and the session side (spend/requests) joins it on the
|
|
39
|
+
* first completed turn. hideOnOtherProvider (default true) clears
|
|
40
|
+
* everything the moment the active model belongs to another provider.
|
|
41
|
+
* Credits/team are prefetched on session start or model select when a
|
|
42
|
+
* HyperCharm model is active, so the first turn ends with data already
|
|
43
|
+
* cached. The balance is polled again on pi's agent_settled event (fires
|
|
44
|
+
* only once no automatic retry,
|
|
42
45
|
* compaction, or queued continuation can follow) — and nowhere else, so
|
|
43
46
|
* sessions without HyperCharm turns make zero status-related API calls.
|
|
44
47
|
* Between polls the balance moves optimistically: each turn's
|
|
@@ -103,7 +106,23 @@
|
|
|
103
106
|
|
|
104
107
|
import { clampThinkingLevel, streamOpenAICompletions } from "@earendil-works/pi-ai/compat";
|
|
105
108
|
import type { AssistantMessageEventStream, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
|
|
109
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
106
110
|
import { USER_AGENT, loginHypercharm, refreshHypercharmToken } from "./oauth";
|
|
111
|
+
import {
|
|
112
|
+
API_KEY_PLACEHOLDER,
|
|
113
|
+
API_NAME,
|
|
114
|
+
CACHE_FILE_NAME,
|
|
115
|
+
CONFIG_FILE_NAME,
|
|
116
|
+
PRISM_ENTRY_TYPE,
|
|
117
|
+
PROVIDER_DISPLAY_NAME,
|
|
118
|
+
PROVIDER_ID,
|
|
119
|
+
STATUS_COMMAND,
|
|
120
|
+
STATUS_KEY_ACCOUNT,
|
|
121
|
+
STATUS_KEY_SESSION,
|
|
122
|
+
WIDGET_KEY,
|
|
123
|
+
} from "./identity";
|
|
124
|
+
import { createNotifier } from "./notify";
|
|
125
|
+
import { prismRouteFromHeaders, prismRouteLabel, readPrismRoute, type PrismRoute } from "./prism";
|
|
107
126
|
import { getAgentDir, type ExtensionAPI, type ExtensionContext, type ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
108
127
|
import modelsData from "./models.json" with { type: "json" };
|
|
109
128
|
import customModelsData from "./custom-models.json" with { type: "json" };
|
|
@@ -132,6 +151,18 @@ import path from "path";
|
|
|
132
151
|
|
|
133
152
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
134
153
|
|
|
154
|
+
// Warning sink for fetch/parse failures: deduplicated, routed to the session UI
|
|
155
|
+
// once one is active, stderr before that. Warnings must never throw.
|
|
156
|
+
const notifier = createNotifier();
|
|
157
|
+
|
|
158
|
+
function describeError(err: unknown): string {
|
|
159
|
+
return err instanceof Error ? err.message : String(err);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function warnAccountFetch(label: string, reason: string): void {
|
|
163
|
+
notifier.warn(`Unable to refresh HyperCharm ${label}: ${reason}.`);
|
|
164
|
+
}
|
|
165
|
+
|
|
135
166
|
interface JsonModel {
|
|
136
167
|
id: string;
|
|
137
168
|
name: string;
|
|
@@ -248,11 +279,10 @@ function buildModels(base: JsonModel[], custom: JsonModel[], patch: PatchData):
|
|
|
248
279
|
|
|
249
280
|
// ─── Stale-While-Revalidate Model Sync ────────────────────────────────────────
|
|
250
281
|
|
|
251
|
-
const PROVIDER_ID = "hypercharm";
|
|
252
282
|
const BASE_URL = "https://hyper.charm.land/v1";
|
|
253
283
|
const MODELS_URL = `${BASE_URL}/provider`;
|
|
254
284
|
const CACHE_DIR = path.join(getAgentDir(), "cache");
|
|
255
|
-
const CACHE_PATH = path.join(CACHE_DIR,
|
|
285
|
+
const CACHE_PATH = path.join(CACHE_DIR, CACHE_FILE_NAME);
|
|
256
286
|
const LIVE_FETCH_TIMEOUT_MS = 8000;
|
|
257
287
|
|
|
258
288
|
const PI_THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
@@ -322,12 +352,22 @@ async function fetchLiveModels(apiKey: string, signal?: AbortSignal): Promise<Js
|
|
|
322
352
|
headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": USER_AGENT },
|
|
323
353
|
signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
|
|
324
354
|
});
|
|
325
|
-
if (!response.ok)
|
|
355
|
+
if (!response.ok) {
|
|
356
|
+
notifier.warn(`HyperCharm model catalog refresh failed: HTTP ${response.status} — serving cached/embedded models.`);
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
326
359
|
const data = await response.json();
|
|
327
360
|
const apiModels = Array.isArray(data) ? data : (data.models || data.data || []);
|
|
328
|
-
if (!Array.isArray(apiModels) || apiModels.length === 0)
|
|
361
|
+
if (!Array.isArray(apiModels) || apiModels.length === 0) {
|
|
362
|
+
notifier.warn("HyperCharm model catalog refresh returned no usable models — serving cached/embedded models.");
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
329
365
|
return apiModels.map(transformApiModel).filter((m): m is JsonModel => m !== null);
|
|
330
|
-
} catch {
|
|
366
|
+
} catch (err) {
|
|
367
|
+
// An aborted signal means the session was replaced, not that Hyper failed.
|
|
368
|
+
if (!signal?.aborted) {
|
|
369
|
+
notifier.warn(`HyperCharm model catalog refresh failed: ${describeError(err)} — serving cached/embedded models.`);
|
|
370
|
+
}
|
|
331
371
|
return null;
|
|
332
372
|
}
|
|
333
373
|
}
|
|
@@ -336,7 +376,10 @@ function loadCachedModels(): JsonModel[] | null {
|
|
|
336
376
|
try {
|
|
337
377
|
const data = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
|
|
338
378
|
return Array.isArray(data) ? data : null;
|
|
339
|
-
} catch {
|
|
379
|
+
} catch (err) {
|
|
380
|
+
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
|
381
|
+
notifier.warn(`Ignoring unreadable HyperCharm model cache at ${CACHE_PATH}: ${describeError(err)}.`);
|
|
382
|
+
}
|
|
340
383
|
return null;
|
|
341
384
|
}
|
|
342
385
|
}
|
|
@@ -345,8 +388,9 @@ function cacheModels(models: JsonModel[]): void {
|
|
|
345
388
|
try {
|
|
346
389
|
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
347
390
|
fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
|
|
348
|
-
} catch {
|
|
349
|
-
//
|
|
391
|
+
} catch (err) {
|
|
392
|
+
// Non-fatal: the freshly fetched catalog still serves this session.
|
|
393
|
+
notifier.warn(`Could not write the HyperCharm model cache to ${CACHE_PATH}: ${describeError(err)}.`);
|
|
350
394
|
}
|
|
351
395
|
}
|
|
352
396
|
|
|
@@ -451,8 +495,11 @@ function loadStatusConfig(): StatusConfig {
|
|
|
451
495
|
try {
|
|
452
496
|
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
453
497
|
statusConfig = coerceStatusConfig(raw);
|
|
454
|
-
} catch {
|
|
455
|
-
//
|
|
498
|
+
} catch (err) {
|
|
499
|
+
// A missing file is normal; anything else is worth surfacing once.
|
|
500
|
+
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
|
501
|
+
notifier.warn(`Ignoring unreadable HyperCharm status config at ${CONFIG_PATH}: ${describeError(err)} — using defaults.`);
|
|
502
|
+
}
|
|
456
503
|
}
|
|
457
504
|
return statusConfig;
|
|
458
505
|
}
|
|
@@ -473,8 +520,9 @@ function writeStatusConfig(): void {
|
|
|
473
520
|
raw.glyphs = statusConfig.glyphs;
|
|
474
521
|
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
475
522
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(raw, null, 2) + "\n");
|
|
476
|
-
} catch {
|
|
477
|
-
//
|
|
523
|
+
} catch (err) {
|
|
524
|
+
// Non-fatal: the in-memory config still applies to this session.
|
|
525
|
+
notifier.warn(`Could not save the HyperCharm status config to ${CONFIG_PATH}: ${describeError(err)}.`);
|
|
478
526
|
}
|
|
479
527
|
}
|
|
480
528
|
|
|
@@ -650,7 +698,7 @@ let lastCreditsFetchAt = 0;
|
|
|
650
698
|
let creditsInFlight: Promise<void> | null = null;
|
|
651
699
|
let metaFetched = false;
|
|
652
700
|
|
|
653
|
-
async function fetchJsonGet(url: string, apiKey: string, signal
|
|
701
|
+
async function fetchJsonGet(url: string, apiKey: string, signal: AbortSignal | undefined, label: string): Promise<any | null> {
|
|
654
702
|
try {
|
|
655
703
|
const response = await fetch(url, {
|
|
656
704
|
headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": USER_AGENT },
|
|
@@ -658,9 +706,14 @@ async function fetchJsonGet(url: string, apiKey: string, signal?: AbortSignal):
|
|
|
658
706
|
? AbortSignal.any([AbortSignal.timeout(ACCOUNT_FETCH_TIMEOUT_MS), signal])
|
|
659
707
|
: AbortSignal.timeout(ACCOUNT_FETCH_TIMEOUT_MS),
|
|
660
708
|
});
|
|
661
|
-
if (!response.ok)
|
|
709
|
+
if (!response.ok) {
|
|
710
|
+
warnAccountFetch(label, `HTTP ${response.status}`);
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
662
713
|
return await response.json();
|
|
663
|
-
} catch {
|
|
714
|
+
} catch (err) {
|
|
715
|
+
// An abort means the session was replaced or shut down, not a failure.
|
|
716
|
+
if (!signal?.aborted) warnAccountFetch(label, describeError(err));
|
|
664
717
|
return null;
|
|
665
718
|
}
|
|
666
719
|
}
|
|
@@ -673,7 +726,7 @@ function refreshCredits(apiKey: string | undefined, signal: AbortSignal | undefi
|
|
|
673
726
|
if (creditsInFlight) return creditsInFlight;
|
|
674
727
|
creditsInFlight = (async () => {
|
|
675
728
|
try {
|
|
676
|
-
const data = await fetchJsonGet(`${BASE_URL}/credits`, apiKey, signal);
|
|
729
|
+
const data = await fetchJsonGet(`${BASE_URL}/credits`, apiKey, signal, "Hypercredit balance");
|
|
677
730
|
if (data === null) return;
|
|
678
731
|
// /credits can report hypercredits ("balance") or USD ("balance_usd",
|
|
679
732
|
// USD-billed accounts). Handle both at the observed 20 hc = $1 rate so a
|
|
@@ -693,8 +746,8 @@ function refreshCredits(apiKey: string | undefined, signal: AbortSignal | undefi
|
|
|
693
746
|
async function refreshAccountMeta(apiKey: string | undefined, signal?: AbortSignal): Promise<void> {
|
|
694
747
|
if (!apiKey || metaFetched) return;
|
|
695
748
|
const [teams, devices] = await Promise.all([
|
|
696
|
-
fetchJsonGet(`${BASE_URL}/teams`, apiKey, signal),
|
|
697
|
-
fetchJsonGet(`${BASE_URL}/devices`, apiKey, signal),
|
|
749
|
+
fetchJsonGet(`${BASE_URL}/teams`, apiKey, signal, "team metadata"),
|
|
750
|
+
fetchJsonGet(`${BASE_URL}/devices`, apiKey, signal, "device sessions"),
|
|
698
751
|
]);
|
|
699
752
|
if (signal?.aborted) return;
|
|
700
753
|
|
|
@@ -719,10 +772,6 @@ async function refreshAccountMeta(apiKey: string | undefined, signal?: AbortSign
|
|
|
719
772
|
|
|
720
773
|
// ─── Status Rendering ─────────────────────────────────────────────────────────
|
|
721
774
|
|
|
722
|
-
const WIDGET_KEY = "hypercharm";
|
|
723
|
-
const STATUS_KEY_SESSION = "hypercharm-session";
|
|
724
|
-
const STATUS_KEY_ACCOUNT = "hypercharm-account";
|
|
725
|
-
|
|
726
775
|
function currentProviderId(ctx: ExtensionContext): string | undefined {
|
|
727
776
|
// ctx.model is a getter that can throw on stale contexts
|
|
728
777
|
try {
|
|
@@ -781,10 +830,13 @@ function renderStatus(ctx: ExtensionContext): void {
|
|
|
781
830
|
widgetGlyphClampNotified = true;
|
|
782
831
|
ctx.ui.notify("HyperCharm: widget glyphs stay ASCII on this terminal — unicode glyphs overflow legacy mintty/Cygwin cell widths. Statusbar is unaffected.", "info");
|
|
783
832
|
}
|
|
784
|
-
// Show
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
|
|
833
|
+
// Show while HyperCharm is the selected provider — the account side renders
|
|
834
|
+
// as soon as the session_start/model_select credits fetch lands, with no
|
|
835
|
+
// need to wait for a turn — and once this session recorded HyperCharm
|
|
836
|
+
// activity, which is what keeps the line alive after a switch when
|
|
837
|
+
// hideOnOtherProvider is false. The default true clears it on the switch.
|
|
838
|
+
const visible = hasActivity || provider === PROVIDER_ID;
|
|
839
|
+
const accountVisible = statusConfig.account !== "off" && accountHasData(account) && visible;
|
|
788
840
|
const lowBalance =
|
|
789
841
|
statusConfig.lowBalanceHc !== null && account.balance !== null && account.balance <= statusConfig.lowBalanceHc;
|
|
790
842
|
const sessionLine = statusConfig.session !== "off" ? buildSessionLine(sessionStats, glyphs) : undefined;
|
|
@@ -1055,15 +1107,15 @@ let currentModels: JsonModel[] = [];
|
|
|
1055
1107
|
function makeProviderConfig(models: JsonModel[] = currentModels) {
|
|
1056
1108
|
return {
|
|
1057
1109
|
baseUrl: BASE_URL,
|
|
1058
|
-
apiKey:
|
|
1110
|
+
apiKey: API_KEY_PLACEHOLDER,
|
|
1059
1111
|
// Custom API name so our streamSimple registers as its own handler and
|
|
1060
1112
|
// never shadows pi's built-in openai-completions pipeline for other
|
|
1061
1113
|
// providers. streamHypercharm delegates to pi-ai's OpenAI-compat streamer.
|
|
1062
|
-
api:
|
|
1114
|
+
api: API_NAME,
|
|
1063
1115
|
models,
|
|
1064
1116
|
streamSimple: streamHypercharm,
|
|
1065
1117
|
oauth: {
|
|
1066
|
-
name:
|
|
1118
|
+
name: PROVIDER_DISPLAY_NAME,
|
|
1067
1119
|
login: (callbacks) => loginHypercharm(callbacks),
|
|
1068
1120
|
refreshToken: (credentials, signal) => refreshHypercharmToken(credentials, signal),
|
|
1069
1121
|
getApiKey: (credentials) => String(credentials.access ?? ""),
|
|
@@ -1076,13 +1128,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
1076
1128
|
const customModels = customModelsData as JsonModel[];
|
|
1077
1129
|
const patches = patchData as PatchData;
|
|
1078
1130
|
|
|
1131
|
+
// Prism routing state: collected per assistant request, committed at turn_end.
|
|
1132
|
+
let collectingPrismRoute = false;
|
|
1133
|
+
let prismRoute: PrismRoute | undefined;
|
|
1134
|
+
|
|
1079
1135
|
const staleBase = loadStaleModels(embeddedModels);
|
|
1080
1136
|
const staleModels = buildModels(staleBase, customModels, patches);
|
|
1081
1137
|
currentModels = staleModels;
|
|
1082
1138
|
|
|
1083
1139
|
pi.registerProvider(PROVIDER_ID, makeProviderConfig(staleModels));
|
|
1084
1140
|
|
|
1085
|
-
pi.registerCommand(
|
|
1141
|
+
pi.registerCommand(STATUS_COMMAND, {
|
|
1086
1142
|
description: "Configure the HyperCharm footer status (session spend, balance, rate limits)",
|
|
1087
1143
|
handler: async (args, ctx) => {
|
|
1088
1144
|
await handleStatusCommand(args, ctx);
|
|
@@ -1090,6 +1146,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1090
1146
|
});
|
|
1091
1147
|
|
|
1092
1148
|
pi.on("session_start", async (_event, ctx) => {
|
|
1149
|
+
notifier.activate(ctx);
|
|
1093
1150
|
const epoch = ++statusEpoch;
|
|
1094
1151
|
revalidateAbort?.abort();
|
|
1095
1152
|
revalidateAbort = new AbortController();
|
|
@@ -1100,12 +1157,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
1100
1157
|
|
|
1101
1158
|
loadStatusConfig();
|
|
1102
1159
|
resetStatusState();
|
|
1103
|
-
updateStatus(ctx); // clears any carryover;
|
|
1160
|
+
updateStatus(ctx); // clears any carryover; the account side lands with the credits fetch
|
|
1104
1161
|
// Re-register so our identity (custom api + streamSimple) always wins
|
|
1105
1162
|
// over anything that touched provider registration during load.
|
|
1106
1163
|
pi.registerProvider(PROVIDER_ID, makeProviderConfig());
|
|
1107
1164
|
|
|
1108
|
-
|
|
1165
|
+
// A failure here used to vanish: no key resolved meant no refresh and no
|
|
1166
|
+
// diagnostics. Surface it, then continue — without a key we serve the
|
|
1167
|
+
// embedded/cached catalog.
|
|
1168
|
+
resolveApiKey(ctx.modelRegistry)
|
|
1169
|
+
.catch((err) => {
|
|
1170
|
+
notifier.warn(`Unable to resolve HyperCharm credentials: ${describeError(err)} — serving cached/embedded models.`);
|
|
1171
|
+
})
|
|
1172
|
+
.then(() => {
|
|
1109
1173
|
// A session replacement while the key resolved invalidated the
|
|
1110
1174
|
// captured ctx (fast-resume, /new, /fork); nothing below may touch it.
|
|
1111
1175
|
if (epoch !== statusEpoch) return;
|
|
@@ -1129,8 +1193,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1129
1193
|
updateStatus(ctx);
|
|
1130
1194
|
const model: any = (event as any).model;
|
|
1131
1195
|
if (model?.provider === PROVIDER_ID && cachedApiKey) {
|
|
1196
|
+
// Both refreshes repaint when they land: selection alone must fill in
|
|
1197
|
+
// the account side (balance now, team/auth atoms a moment later)
|
|
1198
|
+
// instead of leaving a bare gem until the next turn.
|
|
1132
1199
|
updateStatusAfter(refreshCredits(cachedApiKey, statusAbort?.signal ?? undefined, false), ctx);
|
|
1133
|
-
|
|
1200
|
+
updateStatusAfter(refreshAccountMeta(cachedApiKey, statusAbort?.signal ?? undefined), ctx);
|
|
1134
1201
|
}
|
|
1135
1202
|
});
|
|
1136
1203
|
|
|
@@ -1165,4 +1232,42 @@ export default function (pi: ExtensionAPI) {
|
|
|
1165
1232
|
ctx.ui.setStatus(STATUS_KEY_ACCOUNT, undefined);
|
|
1166
1233
|
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
1167
1234
|
});
|
|
1235
|
+
|
|
1236
|
+
// Prism routing: Hyper's edge reports which upstream model actually served the
|
|
1237
|
+
// assistant request via response headers. Collection is scoped to that request
|
|
1238
|
+
// so auxiliary calls between turns cannot leak a route into the transcript,
|
|
1239
|
+
// and the route lands at turn_end as a durable session entry — never a
|
|
1240
|
+
// notification — so it survives reopening the session.
|
|
1241
|
+
pi.registerEntryRenderer(PRISM_ENTRY_TYPE, (entry, _options, theme) => {
|
|
1242
|
+
const route = readPrismRoute(entry.data);
|
|
1243
|
+
const label = route ? prismRouteLabel(route) : undefined;
|
|
1244
|
+
if (label === undefined) return undefined;
|
|
1245
|
+
return new Text(`${theme.fg("muted", "Prism")} ${theme.fg("dim", "→")} ${theme.fg("muted", label)}`, 0, 0);
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
pi.on("turn_start", () => {
|
|
1249
|
+
collectingPrismRoute = true;
|
|
1250
|
+
prismRoute = undefined;
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
pi.on("after_provider_response", (event) => {
|
|
1254
|
+
if (!collectingPrismRoute) return;
|
|
1255
|
+
prismRoute = prismRouteFromHeaders(event.headers);
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
pi.on("message_end", (event) => {
|
|
1259
|
+
if (event.message.role === "assistant") collectingPrismRoute = false;
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
pi.on("turn_end", (event) => {
|
|
1263
|
+
const route = prismRoute;
|
|
1264
|
+
prismRoute = undefined;
|
|
1265
|
+
collectingPrismRoute = false;
|
|
1266
|
+
if (route === undefined) return;
|
|
1267
|
+
if (event.message.role !== "assistant") return;
|
|
1268
|
+
if (event.message.provider !== PROVIDER_ID) return;
|
|
1269
|
+
if (event.message.stopReason === "error" || event.message.stopReason === "aborted") return;
|
|
1270
|
+
pi.appendEntry(PRISM_ENTRY_TYPE, route);
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1168
1273
|
}
|
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.29",
|
|
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
|
+
}
|