pi-zro-provider 1.3.24 → 1.3.27

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.
@@ -0,0 +1,60 @@
1
+ name: Update models
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "7 0 * * *" # 07:07 Asia/Ho_Chi_Minh
6
+ workflow_dispatch:
7
+
8
+ # Publishing uses npm Trusted Publishing (OIDC) bound to this workflow file, so
9
+ # no npm token is stored. A version pushed while publishing was unavailable is
10
+ # published by the next run that finds it missing from npm.
11
+ permissions:
12
+ contents: write
13
+ id-token: write
14
+
15
+ concurrency:
16
+ group: update-models
17
+ cancel-in-progress: false
18
+
19
+ jobs:
20
+ update:
21
+ runs-on: ubuntu-latest
22
+ timeout-minutes: 15
23
+ steps:
24
+ - uses: actions/checkout@v5
25
+
26
+ - uses: actions/setup-node@v5
27
+ with:
28
+ node-version: 24
29
+
30
+ - name: Fetch the model catalog
31
+ env:
32
+ ZRO_API_KEY: ${{ secrets.ZRO_API_KEY }}
33
+ run: node scripts/update-models.js
34
+
35
+ - name: Commit, bump and push
36
+ run: |
37
+ if git diff --quiet; then
38
+ echo "No model changes."
39
+ exit 0
40
+ fi
41
+ git config user.name "github-actions[bot]"
42
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
43
+ git add -A
44
+ git commit -m "feat: update zro models from API"
45
+ npm version patch --no-git-tag-version
46
+ version=$(node -p "require('./package.json').version")
47
+ git commit -am "chore(release): v$version"
48
+ git push origin HEAD:main
49
+
50
+ - name: Publish to npm if this version is missing
51
+ run: |
52
+ name=$(node -p "require('./package.json').name")
53
+ version=$(node -p "require('./package.json').version")
54
+ if npm view "$name@$version" version >/dev/null 2>&1; then
55
+ echo "$name@$version is already on npm."
56
+ exit 0
57
+ fi
58
+ # Trusted publishing needs npm 11.5.1 or newer.
59
+ npm install --global npm@latest
60
+ npm publish --access public --provenance
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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.27
4
+
5
+ - Pin Pi SDK development dependencies to 1.2.0 while retaining wildcard host peers; verify real manifest loading, lifecycle and native/bundled Pi hosts offline.
6
+
7
+ ## 1.3.25
8
+
9
+ - 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.
10
+ - 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.
11
+ - 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.
12
+ - Write the model cache atomically and swallow API key resolution failures in `session_start`.
13
+ - Add vitest coverage for the sync policy and the session_start integration.
14
+
3
15
  ## 1.3.23
4
16
 
5
17
  - 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:
@@ -1 +1,33 @@
1
- {}
1
+ {
2
+ "kimi-k3": {
3
+ "id": "kimi-k3",
4
+ "name": "Kimi K3",
5
+ "reasoning": true,
6
+ "thinkingLevelMap": {
7
+ "off": null,
8
+ "minimal": null,
9
+ "low": "low",
10
+ "medium": null,
11
+ "high": "high",
12
+ "xhigh": null,
13
+ "max": "max"
14
+ },
15
+ "input": [
16
+ "text"
17
+ ],
18
+ "cost": {
19
+ "input": 0,
20
+ "output": 0,
21
+ "cacheRead": 0,
22
+ "cacheWrite": 0
23
+ },
24
+ "contextWindow": 1048576,
25
+ "maxTokens": 131072,
26
+ "compat": {
27
+ "supportsDeveloperRole": false,
28
+ "supportsReasoningEffort": true,
29
+ "maxTokensField": "max_tokens"
30
+ },
31
+ "deprecatedAt": "2026-10-15T04:24:09.470Z"
32
+ }
33
+ }
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. It is polled again on pi's
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
- fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
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
- async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal): Promise<JsonModel[] | null> {
422
- if (!apiKey) return null;
423
- const liveModels = await fetchLiveModels(apiKey, signal);
424
- if (!liveModels || liveModels.length === 0) return null;
425
- const merged = mergeWithEmbedded(liveModels, embeddedModels);
426
- cacheModels(merged);
427
- return merged;
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
- const billing = data?.billing;
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
- resolveApiKey(ctx.modelRegistry).then(() => {
1101
- // A session replacement while the key resolved invalidated the
1102
- // captured ctx (fast-resume, /new, /fork); nothing below may touch it.
1103
- if (epoch !== statusEpoch) return;
1104
- // Prefetch account status only when a Zro model is active
1105
- // (pi-neuralwatt also prefetches so the first turn ends with data, but
1106
- // gating here avoids API calls in sessions that never use the provider).
1107
- if (currentProviderId(ctx) === PROVIDER_ID) {
1108
- updateStatusAfter(refreshAccountStatus(cachedApiKey, statusSignal, true), ctx);
1109
- }
1110
- revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
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
- if (model?.provider === PROVIDER_ID && cachedApiKey) {
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/models.json CHANGED
@@ -143,35 +143,5 @@
143
143
  "supportsReasoningEffort": true,
144
144
  "maxTokensField": "max_tokens"
145
145
  }
146
- },
147
- {
148
- "id": "kimi-k3",
149
- "name": "Kimi K3",
150
- "reasoning": true,
151
- "thinkingLevelMap": {
152
- "off": null,
153
- "minimal": null,
154
- "low": "low",
155
- "medium": null,
156
- "high": "high",
157
- "xhigh": null,
158
- "max": "max"
159
- },
160
- "input": [
161
- "text"
162
- ],
163
- "cost": {
164
- "input": 0,
165
- "output": 0,
166
- "cacheRead": 0,
167
- "cacheWrite": 0
168
- },
169
- "contextWindow": 1048576,
170
- "maxTokens": 131072,
171
- "compat": {
172
- "supportsDeveloperRole": false,
173
- "supportsReasoningEffort": true,
174
- "maxTokensField": "max_tokens"
175
- }
176
146
  }
177
147
  ]
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "pi-zro-provider",
3
- "version": "1.3.24",
3
+ "version": "1.3.27",
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"
@@ -37,13 +38,18 @@
37
38
  "brace-expansion": "5.0.12"
38
39
  },
39
40
  "devDependencies": {
40
- "@earendil-works/pi-ai": "1.1.0",
41
- "@earendil-works/pi-coding-agent": "1.1.0",
41
+ "@earendil-works/pi-ai": "1.2.0",
42
+ "@earendil-works/pi-coding-agent": "1.2.0",
42
43
  "@types/node": "^24.10.1",
43
- "typescript": "6.0.3"
44
+ "typescript": "6.0.3",
45
+ "vitest": "4.1.7"
44
46
  },
45
47
  "peerDependencies": {
46
48
  "@earendil-works/pi-ai": "*",
47
49
  "@earendil-works/pi-coding-agent": "*"
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/monotykamary/pi-zro-provider.git"
48
54
  }
49
55
  }
@@ -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.1.0', 'test the actual pinned Pi host, not a stale override');
16
+ assert.equal(VERSION, '1.2.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.1.0 transcript', timestamp: 0 },
61
+ { role: 'system', content: 'Offline Pi 1.2.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.1.0 transcript')));
80
+ assert.ok(requestBody.messages.some(message => typeof message.content === 'string' && message.content.includes('Offline Pi 1.2.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.1.0 startup and shutdown must be clean');
86
+ assert.deepEqual(errors, [], 'Pi 1.2.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();