pi-zro-provider 1.3.23 → 1.3.25
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 +1 -0
- package/CHANGELOG.md +8 -0
- package/README.md +24 -1
- package/index.ts +156 -39
- package/package.json +10 -5
- package/scripts/test-pi-compat.mjs +4 -4
- package/sync-policy.ts +309 -0
- package/tests/quiet-startup-sync.test.ts +383 -0
- package/tests/sync-policy.test.ts +259 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +10 -0
package/AGENTS.md
CHANGED
|
@@ -19,6 +19,7 @@ When a model needs overrides, new properties, or corrections, edit the appropria
|
|
|
19
19
|
| `patch.json` | Per-model overrides keyed by model ID. Add reasoning flags, compat settings, pricing corrections, thinking level maps, etc. Applied on top of `models.json` at runtime and for README generation. |
|
|
20
20
|
| `custom-models.json` | Models that don't exist in the provider API (hidden models, router endpoints, cross-provider aliases). Merged after patch. Format: array of full model objects (same schema as `models.json` entries). |
|
|
21
21
|
| `index.ts` | Provider extension code: model sync, streaming wrapper, footer-status wiring. |
|
|
22
|
+
| `sync-policy.ts` | Quiet startup sync policy (TTL, key fingerprint, cross-process lock). Provider-agnostic and shared verbatim across pi-*-provider repos — keep it free of provider specifics. |
|
|
22
23
|
| `status.ts` | Footer-status presentation: config schema, USD/token formatters, progressive-disclosure tiers, width-aware widget. Pure module — no pi imports; exercised by `tests/status.smoke.ts`. |
|
|
23
24
|
| `scripts/update-models.js` | The sync script itself (edit only if changing how models are fetched/transformed). |
|
|
24
25
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.25
|
|
4
|
+
|
|
5
|
+
- Quiet startup sync: background `/api/cli/models` revalidation runs only with an API key, only when the disk cache is older than 1 hour (`PI_PROVIDER_SYNC_TTL_MS`, `0` = always), missing, or fetched with a different key (including a new `zro login`), and only in one pi process at a time (lock file next to the cache). Failed refreshes back off for 5 minutes; sessions that skip adopt a cache refreshed by another process.
|
|
6
|
+
- Never store the API key: key changes are detected via a salted sha256 fingerprint in `cache/zro-models.sync.json`. The cache file format is unchanged.
|
|
7
|
+
- Footer account prefetch at session start: skipped without a UI (`pi -p`, child agents) or with `account: "off"`, and shared across pi processes through `cache/zro-account.json` (60 s window, same lock/fingerprint rules). Post-run and model-switch balance polling also skip without a UI; `/zro-status refresh` still fetches.
|
|
8
|
+
- Write the model cache atomically and swallow API key resolution failures in `session_start`.
|
|
9
|
+
- Add vitest coverage for the sync policy and the session_start integration.
|
|
10
|
+
|
|
3
11
|
## 1.3.23
|
|
4
12
|
|
|
5
13
|
- Validate provider registration and session lifecycle against Pi 1.0.0; update the pinned development SDK.
|
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ _GLM-5.2, Kimi K3, and DeepSeek V4 / V4.1 Flash — run through the Zro inferenc
|
|
|
19
19
|
- **Native reasoning effort** — every model ships an endpoint-verified `piLevel` → level-id map (`glm-5.2`, `glm-5.3-flash`, `deepseek-v4-flash-0731`: full `off`–`max` ladder; `deepseek-v4.1-flash`: `low`/`high`/`max` — catalog has no `none`, and `xhigh`/`max` both send `max`; `kimi-k3`: `low`/`high`/`max` — the proxy rejects `medium` there), so `/thinking low` etc. maps exactly to what the Zro proxy accepts. Quirks: `glm-5.3-flash` silently disables thinking at `low`, so `minimal`/`low` map to `minimal`; `glm-5.3` mirrors flash's full ladder — Z.ai officially removed `none` for the 5.3 family, but the proxy accepts it and streams cleanly (occasionally a short chain-of-thought preamble surfaces in `content` before the answer), and `xhigh`/`max` both send `max`
|
|
20
20
|
- **OpenAI-compatible API** at `https://zro.moonmath.ai/v1`
|
|
21
21
|
- **Official catalog sync** from Zro's `/api/cli/models` endpoint — same one `zro models` uses
|
|
22
|
+
- **Quiet live model sync** — the catalog refreshes in the background, at most hourly and by one pi process at a time ([details](#model-sync))
|
|
22
23
|
- **Zro login reuse** — if you've run `zro login`, the extension picks up `~/.config/zro/credentials.json` automatically (no duplicated keys)
|
|
23
24
|
- **Footer status widget** — session spend/requests and account plan / available spend / usage packs / 30-day activity at a glance
|
|
24
25
|
|
|
@@ -118,7 +119,10 @@ A Neuralwatt-style status line sits below the editor. It appears after the
|
|
|
118
119
|
session's first Zro turn completes (never before — no half-empty line on
|
|
119
120
|
fresh sessions or other providers), refreshes its balance when the agent run
|
|
120
121
|
fully settles, and makes no status-related API calls in sessions that never
|
|
121
|
-
use Zro
|
|
122
|
+
use Zro. The account prefetch at session start runs only in interactive
|
|
123
|
+
sessions (not `pi -p` or child agents) and is shared across pi processes: one
|
|
124
|
+
fetches `/api/cli/status` and writes `~/.pi/agent/cache/zro-account.json`, and
|
|
125
|
+
sessions starting within the next 60 seconds render from that file instead:
|
|
122
126
|
|
|
123
127
|
```
|
|
124
128
|
⚡ $0.42 · 7 req Pro ◆ $12.34 avail · $2.50 pack · 1.2k req/30d
|
|
@@ -191,6 +195,7 @@ Get your API key from [zro.moonmath.ai](https://zro.moonmath.ai).
|
|
|
191
195
|
| `ZRO_API_KEY` | No | Your Zro API key (env var fallback) |
|
|
192
196
|
| `ZRO_ENDPOINT_ROOT` | No | Override the endpoint root (default `https://zro.moonmath.ai`) |
|
|
193
197
|
| `ZRO_AUTH_URL` | No | Override the authentication/API host (used by `zro login`; the extension resolves catalog/status from it too) |
|
|
198
|
+
| `PI_PROVIDER_SYNC_TTL_MS` | No | Model sync freshness window in ms (default `3600000`; `0` = revalidate every session start) |
|
|
194
199
|
|
|
195
200
|
## Configuration
|
|
196
201
|
|
|
@@ -226,6 +231,24 @@ currently empty.
|
|
|
226
231
|
corrections. It is currently empty because every live field comes from Zro's
|
|
227
232
|
canonical model catalog.
|
|
228
233
|
|
|
234
|
+
## Model sync
|
|
235
|
+
|
|
236
|
+
Startup never waits on the network. pi starts with the cached catalog (`~/.pi/agent/cache/zro-models.json`,
|
|
237
|
+
falling back to the bundled `models.json`) and revalidates against `/api/cli/models` in the background — only when:
|
|
238
|
+
|
|
239
|
+
- a Zro API key is configured (auth.json, `ZRO_API_KEY`, or a `zro login`; no key, no request),
|
|
240
|
+
- the cache is older than 1 hour, missing, or was fetched with a different API key (so a new `zro login`
|
|
241
|
+
refreshes it right away), and
|
|
242
|
+
- no other pi process is already refreshing it (a `zro-models.lock` file next to the cache).
|
|
243
|
+
|
|
244
|
+
So `/new`, `/reload`, `pi -p` and fan-out child agents share one cache instead of each calling the API; a
|
|
245
|
+
session that skips the fetch still picks up a cache another process refreshed. A failed refresh is retried after
|
|
246
|
+
5 minutes at the earliest. `PI_PROVIDER_SYNC_TTL_MS` changes the window (`0` revalidates on every session start).
|
|
247
|
+
Only a hash fingerprint of the key is stored (`zro-models.sync.json`), never the key itself.
|
|
248
|
+
|
|
249
|
+
The footer's startup account prefetch follows the same rules with its own 60-second window
|
|
250
|
+
(`zro-account.json`, see [Footer Status](#footer-status)); balance polls after each agent run are unchanged.
|
|
251
|
+
|
|
229
252
|
## Updating Models
|
|
230
253
|
|
|
231
254
|
Run the update script to fetch the latest models from the Zro catalog:
|
package/index.ts
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* Model resolution strategy: Stale-While-Revalidate
|
|
14
14
|
* 1. Serve stale immediately: disk cache → embedded models.json (zero-latency)
|
|
15
15
|
* 2. Revalidate in background: live API /api/cli/models → merge with embedded → cache → hot-swap
|
|
16
|
+
* Quiet startup sync (sync-policy.ts): only with an API key, only when the
|
|
17
|
+
* disk cache is older than the TTL (1h, PI_PROVIDER_SYNC_TTL_MS) or the key
|
|
18
|
+
* changed (incl. a new `zro login`), and only one pi process at a time
|
|
19
|
+
* (lock file next to the cache).
|
|
16
20
|
* 3. patch.json + custom-models.json applied on top of whichever source won
|
|
17
21
|
*
|
|
18
22
|
* Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
|
|
@@ -39,7 +43,10 @@
|
|
|
39
43
|
* session's first Zro turn completes, so fresh sessions and other
|
|
40
44
|
* providers' sessions see no half-empty line. Account status is prefetched
|
|
41
45
|
* on session start or model select when a Zro model is active, so the
|
|
42
|
-
* first turn ends with data already cached.
|
|
46
|
+
* first turn ends with data already cached. That startup prefetch runs
|
|
47
|
+
* only in interactive sessions (ctx.hasUI) with a key and account != off,
|
|
48
|
+
* and is shared across pi processes: one process fetches (lock next to
|
|
49
|
+
* cache/zro-account.json), the others reuse that file for 60s. It is polled again on pi's
|
|
43
50
|
* agent_settled event (fires only once no automatic retry, compaction, or
|
|
44
51
|
* queued continuation can follow) — and nowhere else, so sessions without
|
|
45
52
|
* Zro turns make zero status-related API calls. Between polls the balance
|
|
@@ -124,6 +131,7 @@ import {
|
|
|
124
131
|
import fs from "fs";
|
|
125
132
|
import { homedir } from "os";
|
|
126
133
|
import path from "path";
|
|
134
|
+
import { acquireModelSync, cacheChangedSinceLoad, keyFingerprint, markCacheLoaded, syncMetaPath, writeFileAtomic } from "./sync-policy";
|
|
127
135
|
|
|
128
136
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
129
137
|
|
|
@@ -339,7 +347,8 @@ function loadCachedModels(): JsonModel[] | null {
|
|
|
339
347
|
function cacheModels(models: JsonModel[]): void {
|
|
340
348
|
try {
|
|
341
349
|
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
342
|
-
|
|
350
|
+
// Atomic (tmp + rename): other pi processes may be reading the cache right now.
|
|
351
|
+
writeFileAtomic(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
|
|
343
352
|
} catch {
|
|
344
353
|
// Cache write failure is non-fatal
|
|
345
354
|
}
|
|
@@ -405,6 +414,7 @@ function withDeprecated(models: JsonModel[]): JsonModel[] {
|
|
|
405
414
|
}
|
|
406
415
|
|
|
407
416
|
function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
|
|
417
|
+
markCacheLoaded(CACHE_PATH); // stat before read: lets session_start spot newer caches
|
|
408
418
|
const cached = loadCachedModels();
|
|
409
419
|
if (!cached || cached.length === 0) return embeddedModels;
|
|
410
420
|
|
|
@@ -418,13 +428,28 @@ function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
|
|
|
418
428
|
return cached;
|
|
419
429
|
}
|
|
420
430
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
431
|
+
/**
|
|
432
|
+
* Background revalidation, gated by the quiet startup sync policy
|
|
433
|
+
* (sync-policy.ts): no key → no fetch; fresh cache (TTL) → no fetch; another
|
|
434
|
+
* process already fetching → no fetch. When skipped, a cache that another
|
|
435
|
+
* process refreshed since we loaded it is adopted from disk instead.
|
|
436
|
+
* `apiKey` is the key actually in use (auth.json → ZRO_API_KEY → zro CLI
|
|
437
|
+
* credentials), so a new `zro login` changes its fingerprint and forces a
|
|
438
|
+
* refresh. `force` bypasses the TTL; it is still single-flight.
|
|
439
|
+
*/
|
|
440
|
+
async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal, force = false): Promise<JsonModel[] | null> {
|
|
441
|
+
const lease = acquireModelSync({ cachePath: CACHE_PATH, apiKey, force });
|
|
442
|
+
if (!lease) return cacheChangedSinceLoad(CACHE_PATH) ? loadStaleModels(embeddedModels) : null;
|
|
443
|
+
let merged: JsonModel[] | null = null;
|
|
444
|
+
try {
|
|
445
|
+
const liveModels = apiKey ? await fetchLiveModels(apiKey, signal) : null;
|
|
446
|
+
if (!liveModels || liveModels.length === 0) return null;
|
|
447
|
+
merged = mergeWithEmbedded(liveModels, embeddedModels);
|
|
448
|
+
cacheModels(merged);
|
|
449
|
+
return merged;
|
|
450
|
+
} finally {
|
|
451
|
+
lease.release(merged ? "fetched" : signal?.aborted ? "aborted" : "failed");
|
|
452
|
+
}
|
|
428
453
|
}
|
|
429
454
|
|
|
430
455
|
// ─── API Key Resolution (via ModelRegistry) ────────────────────────────────────
|
|
@@ -698,19 +723,7 @@ function refreshAccountStatus(apiKey: string | undefined, signal: AbortSignal |
|
|
|
698
723
|
try {
|
|
699
724
|
const data = await fetchJsonGet(STATUS_URL, apiKey, signal);
|
|
700
725
|
if (data === null) return;
|
|
701
|
-
|
|
702
|
-
if (billing && typeof billing === "object") {
|
|
703
|
-
if (typeof billing.totalRemaining === "number") account.availableUsd = billing.totalRemaining;
|
|
704
|
-
if (typeof billing.usagePacks?.remaining === "number") account.usagePackUsd = billing.usagePacks.remaining;
|
|
705
|
-
const planName = billing.plan?.name;
|
|
706
|
-
if (typeof planName === "string" && planName.trim()) account.planName = planName.trim();
|
|
707
|
-
}
|
|
708
|
-
const activity = data?.activity30d;
|
|
709
|
-
if (activity && typeof activity === "object") {
|
|
710
|
-
if (typeof activity.requests === "number") account.activity30dRequests = activity.requests;
|
|
711
|
-
if (typeof activity.totalTokens === "number") account.activity30dTokens = activity.totalTokens;
|
|
712
|
-
}
|
|
713
|
-
if (typeof data?.key?.alias === "string" && data.key.alias.trim()) account.keyAlias = data.key.alias.trim();
|
|
726
|
+
Object.assign(account, parseAccountSnapshot(data));
|
|
714
727
|
metaFetched = true;
|
|
715
728
|
} finally {
|
|
716
729
|
statusInFlight = null;
|
|
@@ -719,6 +732,103 @@ function refreshAccountStatus(apiKey: string | undefined, signal: AbortSignal |
|
|
|
719
732
|
return statusInFlight;
|
|
720
733
|
}
|
|
721
734
|
|
|
735
|
+
/** The account fields /api/cli/status reports; only fields present in the response are set. */
|
|
736
|
+
type AccountSnapshot = Partial<Omit<AccountState, "rate">>;
|
|
737
|
+
|
|
738
|
+
function parseAccountSnapshot(data: any): AccountSnapshot {
|
|
739
|
+
const snapshot: AccountSnapshot = {};
|
|
740
|
+
const billing = data?.billing;
|
|
741
|
+
if (billing && typeof billing === "object") {
|
|
742
|
+
if (typeof billing.totalRemaining === "number") snapshot.availableUsd = billing.totalRemaining;
|
|
743
|
+
if (typeof billing.usagePacks?.remaining === "number") snapshot.usagePackUsd = billing.usagePacks.remaining;
|
|
744
|
+
const planName = billing.plan?.name;
|
|
745
|
+
if (typeof planName === "string" && planName.trim()) snapshot.planName = planName.trim();
|
|
746
|
+
}
|
|
747
|
+
const activity = data?.activity30d;
|
|
748
|
+
if (activity && typeof activity === "object") {
|
|
749
|
+
if (typeof activity.requests === "number") snapshot.activity30dRequests = activity.requests;
|
|
750
|
+
if (typeof activity.totalTokens === "number") snapshot.activity30dTokens = activity.totalTokens;
|
|
751
|
+
}
|
|
752
|
+
if (typeof data?.key?.alias === "string" && data.key.alias.trim()) snapshot.keyAlias = data.key.alias.trim();
|
|
753
|
+
return snapshot;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ─── Startup Account Prefetch (shared across pi processes) ────────────────────
|
|
757
|
+
// /new, /reload and every other pi process start would each hit
|
|
758
|
+
// /api/cli/status. The startup prefetch instead reuses the quiet-sync lease
|
|
759
|
+
// with its own cache file: one process fetches and writes the parsed snapshot
|
|
760
|
+
// to cache/zro-account.json; others render from that file while it is younger
|
|
761
|
+
// than ACCOUNT_SYNC_TTL_MS. 60s keeps the balance current (spend moves it, and
|
|
762
|
+
// the agent_settled poll reconciles after each run anyway) while collapsing
|
|
763
|
+
// bursts of session starts into one request. A snapshot is only adopted when
|
|
764
|
+
// it was fetched with the same key, and never when older than
|
|
765
|
+
// ACCOUNT_ADOPT_MAX_AGE_MS (e.g. while another process holds the lock) — the
|
|
766
|
+
// first turn's retry fetches instead.
|
|
767
|
+
const ACCOUNT_CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-account.json`);
|
|
768
|
+
const ACCOUNT_SYNC_TTL_MS = 60_000;
|
|
769
|
+
const ACCOUNT_ADOPT_MAX_AGE_MS = 10 * 60_000;
|
|
770
|
+
|
|
771
|
+
function adoptAccountCache(apiKey: string): void {
|
|
772
|
+
try {
|
|
773
|
+
const meta = JSON.parse(fs.readFileSync(syncMetaPath(ACCOUNT_CACHE_PATH), "utf8"));
|
|
774
|
+
if (meta?.keyFingerprint !== keyFingerprint(apiKey)) return;
|
|
775
|
+
if (Date.now() - fs.statSync(ACCOUNT_CACHE_PATH).mtimeMs > ACCOUNT_ADOPT_MAX_AGE_MS) return;
|
|
776
|
+
const snapshot = JSON.parse(fs.readFileSync(ACCOUNT_CACHE_PATH, "utf8"));
|
|
777
|
+
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return;
|
|
778
|
+
Object.assign(account, parseCachedSnapshot(snapshot));
|
|
779
|
+
if (accountHasData(account)) metaFetched = true;
|
|
780
|
+
} catch {
|
|
781
|
+
// Missing/corrupt cache: the first turn's retry fetches instead.
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** Re-validate a snapshot read from disk (written by any extension version/process). */
|
|
786
|
+
function parseCachedSnapshot(raw: Record<string, unknown>): AccountSnapshot {
|
|
787
|
+
const snapshot: AccountSnapshot = {};
|
|
788
|
+
for (const field of ["availableUsd", "usagePackUsd", "activity30dRequests", "activity30dTokens"] as const) {
|
|
789
|
+
if (typeof raw[field] === "number" && Number.isFinite(raw[field])) snapshot[field] = raw[field] as number;
|
|
790
|
+
}
|
|
791
|
+
for (const field of ["planName", "keyAlias"] as const) {
|
|
792
|
+
if (typeof raw[field] === "string" && (raw[field] as string).trim()) snapshot[field] = raw[field] as string;
|
|
793
|
+
}
|
|
794
|
+
return snapshot;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Session-start prefetch: deduped across processes (see above). Never throws. */
|
|
798
|
+
async function prefetchAccountStatus(apiKey: string | undefined, signal: AbortSignal): Promise<void> {
|
|
799
|
+
if (!apiKey) return;
|
|
800
|
+
const lease = acquireModelSync({
|
|
801
|
+
cachePath: ACCOUNT_CACHE_PATH,
|
|
802
|
+
apiKey,
|
|
803
|
+
ttlMs: ACCOUNT_SYNC_TTL_MS,
|
|
804
|
+
isValidCache: (d) => d != null,
|
|
805
|
+
});
|
|
806
|
+
if (!lease) {
|
|
807
|
+
adoptAccountCache(apiKey);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
let written = false;
|
|
811
|
+
try {
|
|
812
|
+
lastStatusFetchAt = Date.now();
|
|
813
|
+
const data = await fetchJsonGet(STATUS_URL, apiKey, signal);
|
|
814
|
+
if (data === null || signal.aborted) return;
|
|
815
|
+
const snapshot = parseAccountSnapshot(data);
|
|
816
|
+
Object.assign(account, snapshot);
|
|
817
|
+
metaFetched = true;
|
|
818
|
+
try {
|
|
819
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
820
|
+
writeFileAtomic(ACCOUNT_CACHE_PATH, JSON.stringify(snapshot) + "\n");
|
|
821
|
+
written = true;
|
|
822
|
+
} catch {
|
|
823
|
+
// Non-fatal: other processes fetch for themselves.
|
|
824
|
+
}
|
|
825
|
+
} catch {
|
|
826
|
+
// Never surface prefetch failures.
|
|
827
|
+
} finally {
|
|
828
|
+
lease.release(written ? "fetched" : signal.aborted ? "aborted" : "failed");
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
722
832
|
// ─── Status Rendering ─────────────────────────────────────────────────────────
|
|
723
833
|
|
|
724
834
|
const WIDGET_KEY = "zro";
|
|
@@ -855,7 +965,7 @@ function commitPending(ctx: ExtensionContext): void {
|
|
|
855
965
|
if (pendingSawOutOfCredits) {
|
|
856
966
|
pendingSawOutOfCredits = false;
|
|
857
967
|
// Re-fetch now so the balance reflects exhaustion immediately
|
|
858
|
-
updateStatusAfter(refreshAccountStatus(cachedApiKey, statusAbort?.signal ?? undefined, true), ctx);
|
|
968
|
+
if (ctx.hasUI) updateStatusAfter(refreshAccountStatus(cachedApiKey, statusAbort?.signal ?? undefined, true), ctx);
|
|
859
969
|
if (!outOfCreditsNotified && ctx.hasUI) {
|
|
860
970
|
outOfCreditsNotified = true;
|
|
861
971
|
ctx.ui.notify("Zro has run out of available spend — top up at zro.moonmath.ai", "error");
|
|
@@ -1097,29 +1207,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
1097
1207
|
// over anything that touched provider registration during load.
|
|
1098
1208
|
pi.registerProvider(PROVIDER_ID, makeProviderConfig());
|
|
1099
1209
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1210
|
+
// Fire-and-forget: never await network here — startup must not block on
|
|
1211
|
+
// the provider API. Nothing is awaited and every rejection is swallowed.
|
|
1212
|
+
void resolveApiKey(ctx.modelRegistry)
|
|
1213
|
+
.then(() => {
|
|
1214
|
+
// A session replacement while the key resolved invalidated the
|
|
1215
|
+
// captured ctx (fast-resume, /new, /fork); nothing below may touch it.
|
|
1216
|
+
if (epoch !== statusEpoch) return null;
|
|
1217
|
+
// Prefetch account status only in interactive sessions (print mode
|
|
1218
|
+
// and child agents render no footer) when a Zro model is active
|
|
1219
|
+
// (pi-neuralwatt also prefetches so the first turn ends with data, but
|
|
1220
|
+
// gating here avoids API calls in sessions that never use the provider).
|
|
1221
|
+
if (ctx.hasUI && statusConfig.account !== "off" && currentProviderId(ctx) === PROVIDER_ID) {
|
|
1222
|
+
updateStatusAfter(prefetchAccountStatus(cachedApiKey, statusSignal), ctx);
|
|
1223
|
+
}
|
|
1224
|
+
return revalidateModels(cachedApiKey, embeddedModels, signal);
|
|
1225
|
+
})
|
|
1226
|
+
.then((freshBase) => {
|
|
1111
1227
|
if (freshBase && epoch === statusEpoch && !signal.aborted) {
|
|
1112
1228
|
currentModels = buildModels(freshBase, customModels, patches);
|
|
1113
1229
|
pi.registerProvider(PROVIDER_ID, makeProviderConfig());
|
|
1114
1230
|
}
|
|
1115
|
-
})
|
|
1116
|
-
|
|
1231
|
+
})
|
|
1232
|
+
.catch(() => { /* keep serving the stale catalog */ });
|
|
1117
1233
|
});
|
|
1118
1234
|
|
|
1119
1235
|
pi.on("model_select", (event, ctx) => {
|
|
1120
1236
|
updateStatus(ctx);
|
|
1121
1237
|
const model: any = (event as any).model;
|
|
1122
|
-
|
|
1238
|
+
// Headless runs (print mode, child agents) render no account status.
|
|
1239
|
+
if (ctx.hasUI && model?.provider === PROVIDER_ID && cachedApiKey) {
|
|
1123
1240
|
updateStatusAfter(refreshAccountStatus(cachedApiKey, statusAbort?.signal ?? undefined, false), ctx);
|
|
1124
1241
|
}
|
|
1125
1242
|
});
|
|
@@ -1130,7 +1247,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1130
1247
|
commitPending(ctx);
|
|
1131
1248
|
// If the session_start/model_select status fetch raced or failed, retry
|
|
1132
1249
|
// once we have real activity so the very first turn shows the balance.
|
|
1133
|
-
if (sessionStats.requests > 0 && account.availableUsd === null && !metaFetched) {
|
|
1250
|
+
if (ctx.hasUI && sessionStats.requests > 0 && account.availableUsd === null && !metaFetched) {
|
|
1134
1251
|
await refreshAccountStatus(cachedApiKey, statusAbort?.signal ?? undefined, false);
|
|
1135
1252
|
}
|
|
1136
1253
|
updateStatus(ctx);
|
|
@@ -1141,7 +1258,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1141
1258
|
// /api/cli/status is both fresh and not redundant. Gated on session activity
|
|
1142
1259
|
// so sessions without Zro turns make zero API calls here.
|
|
1143
1260
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
1144
|
-
if (sessionStats.requests > 0 || sessionStats.spend > 0) {
|
|
1261
|
+
if (ctx.hasUI && (sessionStats.requests > 0 || sessionStats.spend > 0)) {
|
|
1145
1262
|
await refreshAccountStatus(cachedApiKey, statusAbort?.signal ?? undefined, false);
|
|
1146
1263
|
updateStatus(ctx);
|
|
1147
1264
|
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-zro-provider",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.25",
|
|
4
4
|
"description": "Zro provider extension for pi - GLM-5.2, GLM-5.3 Flash, Kimi K3, DeepSeek V4 Flash & DeepSeek V4.1 Flash through the Zro inference endpoint",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"clean": "echo 'nothing to clean'",
|
|
9
9
|
"build": "echo 'nothing to build'",
|
|
10
|
-
"check": "tsc --noEmit && node tests/status.smoke.ts && node scripts/test-pi-compat.mjs",
|
|
10
|
+
"check": "tsc --noEmit && vitest run && node tests/status.smoke.ts && node scripts/test-pi-compat.mjs",
|
|
11
|
+
"test": "vitest run",
|
|
11
12
|
"smoke": "node tests/status.smoke.ts",
|
|
12
13
|
"update-models": "node scripts/update-models.js",
|
|
13
14
|
"test:pi": "node scripts/test-pi-compat.mjs"
|
|
@@ -33,11 +34,15 @@
|
|
|
33
34
|
"./index.ts"
|
|
34
35
|
]
|
|
35
36
|
},
|
|
37
|
+
"overrides": {
|
|
38
|
+
"brace-expansion": "5.0.12"
|
|
39
|
+
},
|
|
36
40
|
"devDependencies": {
|
|
37
|
-
"@earendil-works/pi-ai": "1.
|
|
38
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
41
|
+
"@earendil-works/pi-ai": "1.1.0",
|
|
42
|
+
"@earendil-works/pi-coding-agent": "1.1.0",
|
|
39
43
|
"@types/node": "^24.10.1",
|
|
40
|
-
"typescript": "6.0.3"
|
|
44
|
+
"typescript": "6.0.3",
|
|
45
|
+
"vitest": "4.1.7"
|
|
41
46
|
},
|
|
42
47
|
"peerDependencies": {
|
|
43
48
|
"@earendil-works/pi-ai": "*",
|
|
@@ -13,7 +13,7 @@ globalThis.fetch = async () => new Response('', { status: 503 });
|
|
|
13
13
|
let session;
|
|
14
14
|
try {
|
|
15
15
|
const { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, VERSION } = await import('@earendil-works/pi-coding-agent');
|
|
16
|
-
assert.equal(VERSION, '1.
|
|
16
|
+
assert.equal(VERSION, '1.1.0', 'test the actual pinned Pi host, not a stale override');
|
|
17
17
|
const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
18
18
|
for (const name of ['@earendil-works/pi-ai', '@earendil-works/pi-agent-core', '@earendil-works/pi-coding-agent', '@earendil-works/pi-tui', 'typebox']) {
|
|
19
19
|
assert.equal(manifest.dependencies?.[name], undefined, `${name}: host packages must not be runtime dependencies`);
|
|
@@ -58,7 +58,7 @@ try {
|
|
|
58
58
|
{ id: label, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5, total_cost: 0.001 } },
|
|
59
59
|
];
|
|
60
60
|
const result = await modelRuntime.streamSimple(model, { messages: [
|
|
61
|
-
{ role: 'system', content: 'Offline Pi 1.0 transcript', timestamp: 0 },
|
|
61
|
+
{ role: 'system', content: 'Offline Pi 1.1.0 transcript', timestamp: 0 },
|
|
62
62
|
{ role: 'user', content: label, timestamp: 1 },
|
|
63
63
|
] }, {
|
|
64
64
|
maxTokens: 32,
|
|
@@ -77,13 +77,13 @@ try {
|
|
|
77
77
|
assert.equal(result.usage.totalTokens, 5);
|
|
78
78
|
assert.equal(requestBody.user, label, 'request-scoped instrumentation must not cross concurrent streams');
|
|
79
79
|
assert.equal(requestBody.max_tokens ?? requestBody.max_completion_tokens, 32);
|
|
80
|
-
assert.ok(requestBody.messages.some(message => typeof message.content === 'string' && message.content.includes('Offline Pi 1.0 transcript')));
|
|
80
|
+
assert.ok(requestBody.messages.some(message => typeof message.content === 'string' && message.content.includes('Offline Pi 1.1.0 transcript')));
|
|
81
81
|
assert.deepEqual(hooks.slice(0, 2), ['payload', 'response']);
|
|
82
82
|
assert.equal(hooks.filter(hook => hook === 'stream').length, 2);
|
|
83
83
|
}));
|
|
84
84
|
assert.equal(globalThis.fetch, offlineFetch, 'Zro must never monkey-patch global fetch');
|
|
85
85
|
await session.extensionRunner.emit({ type: 'session_shutdown' });
|
|
86
|
-
assert.deepEqual(errors, [], 'Pi 1.0 startup and shutdown must be clean');
|
|
86
|
+
assert.deepEqual(errors, [], 'Pi 1.1.0 startup and shutdown must be clean');
|
|
87
87
|
console.log(`${manifest.name}: Pi ${VERSION} manifest, ${models.length} models, startup/shutdown, concurrent streams and instrumentation OK`);
|
|
88
88
|
} finally {
|
|
89
89
|
session?.dispose();
|