pi-umans-provider 1.0.34 → 1.0.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -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. |
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
  | `scripts/update-models.js` | The sync script itself (edit only if changing how models are fetched/transformed). |
23
24
 
24
25
  ## Data Flow
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.36
4
+
5
+ - `/login` while the session-start catalog revalidation is still in flight now fetches the new key's catalog right away: the forced refresh aborts the old-key revalidation and waits for it to release the sync lock instead of skipping as "locked" until the next session start.
6
+
7
+ ## 1.0.35
8
+
9
+ - Quiet startup sync: background `/v1/models/info` 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, 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. `/login` forces a revalidation with the new key.
10
+ - Never store the API key: key changes are detected via a salted sha256 fingerprint in `cache/umans-models.sync.json`. The cache file format is unchanged.
11
+ - Usage footer: `/v1/usage` is no longer called without a UI (print mode, headless child agents). The session-start usage fetch is shared across pi processes for 60 seconds via `cache/umans-usage.json`; turn-end and idle polling are unchanged.
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.0.32
4
16
 
5
17
  - Validate provider registration and session lifecycle against Pi 1.0.0; update the pinned development SDK.
package/README.md CHANGED
@@ -22,6 +22,7 @@ _A [pi](https://github.com/earendil-works/pi-coding-agent) provider extension wi
22
22
  - **Streaming** - Real-time token streaming
23
23
  - **Subscription-based** - All models included in your plan, no per-token cost
24
24
  - **Usage status bar** — Displays your plan, concurrent sessions, and remaining requests in the pi footer
25
+ - **Quiet live model sync** — Models refresh from the UMANS API in the background, at most hourly and by one pi process at a time ([details](#model-sync))
25
26
 
26
27
  ## Available Models
27
28
 
@@ -105,6 +106,7 @@ Get your API key from [code.umans.ai](https://code.umans.ai).
105
106
  | Variable | Required | Description |
106
107
  |----------|----------|-------------|
107
108
  | `UMANS_API_KEY` | No | Your UMANS API key (fallback if not in auth.json) |
109
+ | `PI_PROVIDER_SYNC_TTL_MS` | No | Model sync freshness window in ms (default `3600000`; `0` = revalidate every session start) |
108
110
 
109
111
  ## Configuration
110
112
 
@@ -158,6 +160,23 @@ Code Max (Founding Seat) | ⟠ 1/4
158
160
 
159
161
  The concurrent-session count is tracked locally: it's optimistically incremented the moment an agent run starts (no API call needed — the server has a brief registration lag anyway), reconciled with UMANS's `/v1/usage` endpoint after each agent run ends, and lightly polled while idle so the baseline stays fresh. The plan name and request budget are fetched on session start and model selection.
160
162
 
163
+ The footer (and every `/v1/usage` call) is interactive-only: print mode (`pi -p`) and headless child agents never poll. On session start, one pi process per 60 seconds calls `/v1/usage` and writes the result to `~/.pi/agent/cache/umans-usage.json` (with the same lock and key fingerprint as the model sync); other sessions started within that window render it from disk.
164
+
165
+ ## Model sync
166
+
167
+ Startup never waits on the network. pi starts with the cached catalog (`~/.pi/agent/cache/umans-models.json`,
168
+ falling back to the bundled `models.json`) and revalidates against `/v1/models/info` in the background — only when:
169
+
170
+ - a UMANS API key is configured (no key, no request),
171
+ - the cache is older than 1 hour, missing, or was fetched with a different API key, and
172
+ - no other pi process is already refreshing it (an `umans-models.lock` file next to the cache).
173
+
174
+ So `/new`, `/reload`, `pi -p` and fan-out child agents share one cache instead of each calling the API; a
175
+ session that skips the fetch still picks up a cache another process refreshed. Logging in with `/login` revalidates
176
+ right away. A failed refresh is retried after 5 minutes at the earliest. `PI_PROVIDER_SYNC_TTL_MS` changes the
177
+ window (`0` revalidates on every session start). Only a hash fingerprint of the key is stored
178
+ (`umans-models.sync.json`), never the key itself.
179
+
161
180
  ## API Compatibility
162
181
 
163
182
  The UMANS API follows OpenAI conventions with these differences (handled via `patch.json` and `before_provider_request` hook):
package/index.ts CHANGED
@@ -19,6 +19,10 @@
19
19
  * Model resolution strategy: Stale-While-Revalidate
20
20
  * 1. Serve stale immediately: disk cache → embedded models.json (zero-latency)
21
21
  * 2. Revalidate in background: live API /v1/models/info → merge with embedded → cache → hot-swap
22
+ * Quiet startup sync (sync-policy.ts): only with an API key, only when the
23
+ * disk cache is older than the TTL (1h, PI_PROVIDER_SYNC_TTL_MS) or the key
24
+ * changed, and only one pi process at a time (lock file next to the cache).
25
+ * /login forces a revalidation with the new key.
22
26
  * 3. patch.json + custom-models.json applied on top of whichever source won
23
27
  *
24
28
  * Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
@@ -45,6 +49,14 @@ import patchData from "./patch.json" with { type: "json" };
45
49
  import deprecatedData from "./deprecated-models.json" with { type: "json" };
46
50
  import fs from "fs";
47
51
  import path from "path";
52
+ import {
53
+ acquireModelSync,
54
+ cacheChangedSinceLoad,
55
+ keyFingerprint,
56
+ markCacheLoaded,
57
+ syncMetaPath,
58
+ writeFileAtomic,
59
+ } from "./sync-policy";
48
60
 
49
61
  // ─── Usage/Plan Types ────────────────────────────────────────────────────────
50
62
 
@@ -53,6 +65,15 @@ const USAGE_FETCH_TIMEOUT_MS = 5000;
53
65
  const USAGE_THROTTLE_MS = 30_000;
54
66
  const END_SETTLE_MS = 2000;
55
67
  const IDLE_POLL_MS = 45_000;
68
+ /**
69
+ * Startup usage dedupe window. Every interactive session_start used to call
70
+ * /v1/usage; now one process per window fetches and writes the result to
71
+ * `cache/umans-usage.json`, and sessions started inside the window (/new,
72
+ * /reload, a second terminal) render it from disk. 60s: the plan name and
73
+ * request budget barely move, and the concurrent-session baseline is refreshed
74
+ * by the idle poll (IDLE_POLL_MS) and after every agent run anyway.
75
+ */
76
+ const USAGE_SYNC_TTL_MS = 60_000;
56
77
 
57
78
  let sessionPlan: string | null = null;
58
79
  let sessionConcurrency: number | null = null;
@@ -287,7 +308,8 @@ function loadCachedModels(): JsonModel[] | null {
287
308
  function cacheModels(models: JsonModel[]): void {
288
309
  try {
289
310
  fs.mkdirSync(CACHE_DIR, { recursive: true });
290
- fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
311
+ // Atomic (tmp + rename): other pi processes may be reading the cache right now.
312
+ writeFileAtomic(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
291
313
  } catch {
292
314
  // Cache write failure is non-fatal
293
315
  }
@@ -359,6 +381,7 @@ function withDeprecated(models: JsonModel[]): JsonModel[] {
359
381
  }
360
382
 
361
383
  function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
384
+ markCacheLoaded(CACHE_PATH); // stat before read: lets session_start spot newer caches
362
385
  const cached = loadCachedModels();
363
386
  if (!cached || cached.length === 0) return embeddedModels;
364
387
 
@@ -371,23 +394,43 @@ function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
371
394
  return cached;
372
395
  }
373
396
 
397
+ /**
398
+ * Background revalidation, gated by the quiet startup sync policy
399
+ * (sync-policy.ts): no key → no fetch; fresh cache (TTL) → no fetch; another
400
+ * process already fetching → no fetch. When skipped, a cache that another
401
+ * process refreshed since we loaded it is adopted from disk instead.
402
+ * `force` bypasses the TTL (e.g. after /login); it is still single-flight.
403
+ *
404
+ * The Umans "OAuth" credential is the API key itself (access === refresh, never
405
+ * rotated), so it doubles as the stable identity for the key fingerprint.
406
+ */
374
407
  async function revalidateModels(
375
408
  apiKey: string | undefined,
376
409
  embeddedModels: JsonModel[],
377
410
  signal?: AbortSignal,
411
+ force = false,
378
412
  ): Promise<JsonModel[] | null> {
379
- if (!apiKey) return null;
380
- const liveModels = await fetchLiveModels(apiKey, signal);
381
- if (!liveModels || liveModels.length === 0) return null;
382
- const merged = mergeWithEmbedded(liveModels, embeddedModels);
383
- cacheModels(merged);
384
- return merged;
413
+ const lease = acquireModelSync({ cachePath: CACHE_PATH, apiKey, force });
414
+ if (!lease) return cacheChangedSinceLoad(CACHE_PATH) ? loadStaleModels(embeddedModels) : null;
415
+ let merged: JsonModel[] | null = null;
416
+ try {
417
+ const liveModels = apiKey ? await fetchLiveModels(apiKey, signal) : null;
418
+ if (!liveModels || liveModels.length === 0) return null;
419
+ merged = mergeWithEmbedded(liveModels, embeddedModels);
420
+ cacheModels(merged);
421
+ return merged;
422
+ } finally {
423
+ lease.release(merged ? "fetched" : signal?.aborted ? "aborted" : "failed");
424
+ }
385
425
  }
386
426
 
387
427
  // ─── API Key Resolution ────────────────────────────────────────────────────────
388
428
 
389
429
  let cachedApiKey: string | undefined;
390
430
  let revalidateAbort: AbortController | null = null;
431
+ // The in-flight catalog revalidation (session start or /login); it never rejects.
432
+ let modelSync: Promise<void> = Promise.resolve();
433
+ let modelSyncAbort: AbortController | null = null;
391
434
  // Aborted on session replacement/shutdown so in-flight usage fetches from
392
435
  // timers don't resolve against a stale ctx (the timer handle itself can't be
393
436
  // clearTimeout'd once the async callback has already fired).
@@ -445,6 +488,59 @@ async function fetchUsage(
445
488
  }
446
489
  }
447
490
 
491
+ // ─── Shared usage snapshot (cross-process startup dedupe) ─────────────────────
492
+
493
+ const USAGE_CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-usage.json`);
494
+
495
+ /** Read the usage snapshot another process wrote — only if it was fetched with this key. */
496
+ function readUsageCache(apiKey: string): UmansUsage | null {
497
+ try {
498
+ const meta = JSON.parse(fs.readFileSync(syncMetaPath(USAGE_CACHE_PATH), "utf8"));
499
+ if (meta?.keyFingerprint !== keyFingerprint(apiKey)) return null;
500
+ const data = JSON.parse(fs.readFileSync(USAGE_CACHE_PATH, "utf8")) as UmansUsage;
501
+ return data?.plan?.slug ? data : null;
502
+ } catch {
503
+ return null;
504
+ }
505
+ }
506
+
507
+ function writeUsageCache(usage: UmansUsage): void {
508
+ try {
509
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
510
+ writeFileAtomic(USAGE_CACHE_PATH, JSON.stringify(usage) + "\n");
511
+ } catch {
512
+ // Non-fatal: other processes just fetch for themselves next time.
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Usage for session_start: one pi process per USAGE_SYNC_TTL_MS window calls
518
+ * /v1/usage (same lease/lock/backoff policy as the model sync, own cache
519
+ * file); the others render the snapshot it wrote. Never fetches without a key.
520
+ * `fetched` is false when this process skipped the network.
521
+ */
522
+ async function startupUsage(
523
+ apiKey: string | undefined,
524
+ signal: AbortSignal,
525
+ ): Promise<{ usage: UmansUsage | null; fetched: boolean }> {
526
+ if (!apiKey) return { usage: null, fetched: false };
527
+ const lease = acquireModelSync({
528
+ cachePath: USAGE_CACHE_PATH,
529
+ apiKey,
530
+ ttlMs: USAGE_SYNC_TTL_MS,
531
+ isValidCache: (data) => data != null,
532
+ });
533
+ if (!lease) return { usage: readUsageCache(apiKey), fetched: false };
534
+ let usage: UmansUsage | null = null;
535
+ try {
536
+ usage = await throttledFetchUsage(apiKey, { force: true, signal });
537
+ if (usage) writeUsageCache(usage);
538
+ return { usage, fetched: true };
539
+ } finally {
540
+ lease.release(usage ? "fetched" : signal.aborted ? "aborted" : "failed");
541
+ }
542
+ }
543
+
448
544
  function applyUsage(usage: UmansUsage, ctx: any): void {
449
545
  sessionPlan = usage.plan.display_name;
450
546
  sessionConcurrency = usage.limits?.concurrency?.limit ?? null;
@@ -573,6 +669,45 @@ export default function (pi: ExtensionAPI) {
573
669
  const staleBase = loadStaleModels(embeddedModels);
574
670
  const staleModels = buildModels(staleBase, customModels, patches);
575
671
 
672
+ function registerFresh(freshBase: JsonModel[]): void {
673
+ pi.registerProvider("umans", {
674
+ baseUrl: BASE_URL,
675
+ apiKey: "$UMANS_API_KEY",
676
+ api: "openai-completions",
677
+ models: buildModels(freshBase, customModels, patches),
678
+ });
679
+ }
680
+
681
+ // The catalog sync's own abort scope inside the session's: a /login cancels
682
+ // an in-flight (old-key) sync without cancelling the session's usage fetch.
683
+ function supersedeModelSync(session: AbortSignal): AbortSignal {
684
+ modelSyncAbort?.abort();
685
+ modelSyncAbort = new AbortController();
686
+ return AbortSignal.any([session, modelSyncAbort.signal]);
687
+ }
688
+
689
+ // After /login: revalidate with the new key right away (forced past the TTL,
690
+ // still single-flight). Deferred so pi stores the credential first; never
691
+ // awaited, never throws into the login flow.
692
+ async function loginAndRevalidate(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
693
+ const credentials = await loginUmans(callbacks);
694
+ setTimeout(() => {
695
+ const key = credentials.access;
696
+ cachedApiKey = key;
697
+ if (!revalidateAbort || revalidateAbort.signal.aborted) revalidateAbort = new AbortController();
698
+ // The new key supersedes an in-flight revalidation, which must release its
699
+ // sync lock first, or this forced refresh would skip as "locked".
700
+ const signal = supersedeModelSync(revalidateAbort.signal);
701
+ modelSync = modelSync
702
+ .then(() => (signal.aborted ? null : revalidateModels(key, embeddedModels, signal, true)))
703
+ .then((freshBase) => {
704
+ if (freshBase && !signal.aborted) registerFresh(freshBase);
705
+ })
706
+ .catch(() => { /* keep serving the current catalog */ });
707
+ }, 0);
708
+ return credentials;
709
+ }
710
+
576
711
  pi.registerProvider("umans", {
577
712
  baseUrl: BASE_URL,
578
713
  apiKey: "$UMANS_API_KEY",
@@ -580,7 +715,7 @@ export default function (pi: ExtensionAPI) {
580
715
  models: staleModels,
581
716
  oauth: {
582
717
  name: "Umans AI (API Key)",
583
- login: loginUmans,
718
+ login: loginAndRevalidate,
584
719
  refreshToken: refreshUmansToken,
585
720
  getApiKey: getApiKey,
586
721
  },
@@ -644,6 +779,8 @@ export default function (pi: ExtensionAPI) {
644
779
  return p;
645
780
  });
646
781
 
782
+ // Fire-and-forget: never await network here — startup must not block on the
783
+ // provider API. Nothing is awaited and every rejection is swallowed.
647
784
  pi.on("session_start", async (_event, ctx) => {
648
785
  revalidateAbort?.abort();
649
786
  revalidateAbort = new AbortController();
@@ -652,32 +789,38 @@ export default function (pi: ExtensionAPI) {
652
789
  usageAbort?.abort();
653
790
  usageAbort = new AbortController();
654
791
  const signal = revalidateAbort.signal;
655
- resolveApiKey(ctx.modelRegistry).then(async () => {
656
- revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
657
- if (freshBase && !signal.aborted) {
658
- pi.registerProvider("umans", {
659
- baseUrl: BASE_URL,
660
- apiKey: "$UMANS_API_KEY",
661
- api: "openai-completions",
662
- models: buildModels(freshBase, customModels, patches),
663
- });
792
+ const keyResolved = resolveApiKey(ctx.modelRegistry);
793
+
794
+ const syncSignal = supersedeModelSync(signal);
795
+ modelSync = keyResolved
796
+ .then(() => revalidateModels(cachedApiKey, embeddedModels, syncSignal))
797
+ .then((freshBase) => {
798
+ if (freshBase && !syncSignal.aborted) registerFresh(freshBase);
799
+ })
800
+ .catch(() => { /* keep serving the stale catalog */ });
801
+
802
+ // Usage footer: interactive sessions only (no footer in print mode or
803
+ // headless child agents, so no /v1/usage call there either).
804
+ void keyResolved
805
+ .then(async () => {
806
+ if (!isUmansModel(ctx)) {
807
+ clearUsageStatus(ctx);
808
+ return;
664
809
  }
665
- });
666
-
667
- if (!isUmansModel(ctx)) {
668
- clearUsageStatus(ctx);
669
- return;
670
- }
671
-
672
- const usage = await throttledFetchUsage(cachedApiKey, { force: true, signal });
673
- if (usage && !signal.aborted) {
674
- applyUsage(usage, ctx);
675
- }
676
- });
810
+ if (!ctx.hasUI) return;
811
+ const { usage, fetched } = await startupUsage(cachedApiKey, signal);
812
+ if (signal.aborted) return;
813
+ if (usage) applyUsage(usage, ctx);
814
+ // Skipped the network and nothing usable on disk yet (another process
815
+ // is fetching right now): let the idle poll fill the footer in.
816
+ else if (!fetched && cachedApiKey) resetIdleTimer(ctx);
817
+ })
818
+ .catch(() => { /* the footer stays empty */ });
677
819
  });
678
820
 
679
821
  pi.on("model_select", (event, ctx) => {
680
822
  if (event.model?.provider === "umans") {
823
+ if (!ctx.hasUI) return; // no footer to render without a UI
681
824
  throttledFetchUsage(cachedApiKey, { force: true }).then((usage) => {
682
825
  if (usage) {
683
826
  applyUsage(usage, ctx);
@@ -695,7 +838,7 @@ export default function (pi: ExtensionAPI) {
695
838
  // registration lag, so fetching now would undercount us anyway. One span
696
839
  // per prompt (agent_start → agent_settled), so no flicker between tool turns.
697
840
  pi.on("agent_start", async (_event, ctx) => {
698
- if (!isUmansModel(ctx)) return;
841
+ if (!isUmansModel(ctx) || !ctx.hasUI) return;
699
842
  if (idleTimer) {
700
843
  clearTimeout(idleTimer);
701
844
  idleTimer = null;
@@ -707,7 +850,7 @@ export default function (pi: ExtensionAPI) {
707
850
  // Turn ended: drop our optimistic +1 and reconcile with the server after a
708
851
  // short settle so the server has dropped our session from its count.
709
852
  pi.on("agent_settled", async (_event, ctx) => {
710
- if (!isUmansModel(ctx)) return;
853
+ if (!isUmansModel(ctx) || !ctx.hasUI) return;
711
854
  activeStreams = Math.max(0, activeStreams - 1);
712
855
  updateUsageStatus(ctx);
713
856
  scheduleEndFetch(ctx);
package/package.json CHANGED
@@ -1,9 +1,18 @@
1
1
  {
2
2
  "name": "pi-umans-provider",
3
- "version": "1.0.34",
3
+ "version": "1.0.36",
4
4
  "description": "UMANS provider extension for pi - Access Kimi K2.6, GLM 5.1, Qwen 3.6, and coding-optimized models through the UMANS 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 && vitest run && node scripts/test-pi-compat.mjs",
11
+ "test": "vitest run",
12
+ "update-models": "node scripts/update-models.js",
13
+ "test:pi": "node scripts/test-pi-compat.mjs",
14
+ "typecheck": "tsc --noEmit"
15
+ },
7
16
  "keywords": [
8
17
  "pi",
9
18
  "extension",
@@ -31,18 +40,11 @@
31
40
  "@earendil-works/pi-ai": "1.1.0",
32
41
  "@earendil-works/pi-coding-agent": "1.1.0",
33
42
  "@types/node": "^25.9.2",
34
- "typescript": "^6.0.3"
43
+ "typescript": "^6.0.3",
44
+ "vitest": "4.1.7"
35
45
  },
36
46
  "peerDependencies": {
37
47
  "@earendil-works/pi-ai": "*",
38
48
  "@earendil-works/pi-coding-agent": "*"
39
- },
40
- "scripts": {
41
- "clean": "echo 'nothing to clean'",
42
- "build": "echo 'nothing to build'",
43
- "check": "tsc --noEmit && node scripts/test-pi-compat.mjs",
44
- "update-models": "node scripts/update-models.js",
45
- "test:pi": "node scripts/test-pi-compat.mjs",
46
- "typecheck": "tsc --noEmit"
47
49
  }
48
- }
50
+ }