pi-umans-provider 1.0.34 → 1.0.35

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.35
4
+
5
+ - 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.
6
+ - 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.
7
+ - 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.
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.0.32
4
12
 
5
13
  - 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,17 +394,34 @@ 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 ────────────────────────────────────────────────────────
@@ -445,6 +485,59 @@ async function fetchUsage(
445
485
  }
446
486
  }
447
487
 
488
+ // ─── Shared usage snapshot (cross-process startup dedupe) ─────────────────────
489
+
490
+ const USAGE_CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-usage.json`);
491
+
492
+ /** Read the usage snapshot another process wrote — only if it was fetched with this key. */
493
+ function readUsageCache(apiKey: string): UmansUsage | null {
494
+ try {
495
+ const meta = JSON.parse(fs.readFileSync(syncMetaPath(USAGE_CACHE_PATH), "utf8"));
496
+ if (meta?.keyFingerprint !== keyFingerprint(apiKey)) return null;
497
+ const data = JSON.parse(fs.readFileSync(USAGE_CACHE_PATH, "utf8")) as UmansUsage;
498
+ return data?.plan?.slug ? data : null;
499
+ } catch {
500
+ return null;
501
+ }
502
+ }
503
+
504
+ function writeUsageCache(usage: UmansUsage): void {
505
+ try {
506
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
507
+ writeFileAtomic(USAGE_CACHE_PATH, JSON.stringify(usage) + "\n");
508
+ } catch {
509
+ // Non-fatal: other processes just fetch for themselves next time.
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Usage for session_start: one pi process per USAGE_SYNC_TTL_MS window calls
515
+ * /v1/usage (same lease/lock/backoff policy as the model sync, own cache
516
+ * file); the others render the snapshot it wrote. Never fetches without a key.
517
+ * `fetched` is false when this process skipped the network.
518
+ */
519
+ async function startupUsage(
520
+ apiKey: string | undefined,
521
+ signal: AbortSignal,
522
+ ): Promise<{ usage: UmansUsage | null; fetched: boolean }> {
523
+ if (!apiKey) return { usage: null, fetched: false };
524
+ const lease = acquireModelSync({
525
+ cachePath: USAGE_CACHE_PATH,
526
+ apiKey,
527
+ ttlMs: USAGE_SYNC_TTL_MS,
528
+ isValidCache: (data) => data != null,
529
+ });
530
+ if (!lease) return { usage: readUsageCache(apiKey), fetched: false };
531
+ let usage: UmansUsage | null = null;
532
+ try {
533
+ usage = await throttledFetchUsage(apiKey, { force: true, signal });
534
+ if (usage) writeUsageCache(usage);
535
+ return { usage, fetched: true };
536
+ } finally {
537
+ lease.release(usage ? "fetched" : signal.aborted ? "aborted" : "failed");
538
+ }
539
+ }
540
+
448
541
  function applyUsage(usage: UmansUsage, ctx: any): void {
449
542
  sessionPlan = usage.plan.display_name;
450
543
  sessionConcurrency = usage.limits?.concurrency?.limit ?? null;
@@ -573,6 +666,34 @@ export default function (pi: ExtensionAPI) {
573
666
  const staleBase = loadStaleModels(embeddedModels);
574
667
  const staleModels = buildModels(staleBase, customModels, patches);
575
668
 
669
+ function registerFresh(freshBase: JsonModel[]): void {
670
+ pi.registerProvider("umans", {
671
+ baseUrl: BASE_URL,
672
+ apiKey: "$UMANS_API_KEY",
673
+ api: "openai-completions",
674
+ models: buildModels(freshBase, customModels, patches),
675
+ });
676
+ }
677
+
678
+ // After /login: revalidate with the new key right away (forced past the TTL,
679
+ // still single-flight). Deferred so pi stores the credential first; never
680
+ // awaited, never throws into the login flow.
681
+ async function loginAndRevalidate(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
682
+ const credentials = await loginUmans(callbacks);
683
+ setTimeout(() => {
684
+ const key = credentials.access;
685
+ cachedApiKey = key;
686
+ if (!revalidateAbort || revalidateAbort.signal.aborted) revalidateAbort = new AbortController();
687
+ const signal = revalidateAbort.signal;
688
+ void revalidateModels(key, embeddedModels, signal, true)
689
+ .then((freshBase) => {
690
+ if (freshBase && !signal.aborted) registerFresh(freshBase);
691
+ })
692
+ .catch(() => { /* keep serving the current catalog */ });
693
+ }, 0);
694
+ return credentials;
695
+ }
696
+
576
697
  pi.registerProvider("umans", {
577
698
  baseUrl: BASE_URL,
578
699
  apiKey: "$UMANS_API_KEY",
@@ -580,7 +701,7 @@ export default function (pi: ExtensionAPI) {
580
701
  models: staleModels,
581
702
  oauth: {
582
703
  name: "Umans AI (API Key)",
583
- login: loginUmans,
704
+ login: loginAndRevalidate,
584
705
  refreshToken: refreshUmansToken,
585
706
  getApiKey: getApiKey,
586
707
  },
@@ -644,6 +765,8 @@ export default function (pi: ExtensionAPI) {
644
765
  return p;
645
766
  });
646
767
 
768
+ // Fire-and-forget: never await network here — startup must not block on the
769
+ // provider API. Nothing is awaited and every rejection is swallowed.
647
770
  pi.on("session_start", async (_event, ctx) => {
648
771
  revalidateAbort?.abort();
649
772
  revalidateAbort = new AbortController();
@@ -652,32 +775,37 @@ export default function (pi: ExtensionAPI) {
652
775
  usageAbort?.abort();
653
776
  usageAbort = new AbortController();
654
777
  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
- });
778
+ const keyResolved = resolveApiKey(ctx.modelRegistry);
779
+
780
+ void keyResolved
781
+ .then(() => revalidateModels(cachedApiKey, embeddedModels, signal))
782
+ .then((freshBase) => {
783
+ if (freshBase && !signal.aborted) registerFresh(freshBase);
784
+ })
785
+ .catch(() => { /* keep serving the stale catalog */ });
786
+
787
+ // Usage footer: interactive sessions only (no footer in print mode or
788
+ // headless child agents, so no /v1/usage call there either).
789
+ void keyResolved
790
+ .then(async () => {
791
+ if (!isUmansModel(ctx)) {
792
+ clearUsageStatus(ctx);
793
+ return;
664
794
  }
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
- });
795
+ if (!ctx.hasUI) return;
796
+ const { usage, fetched } = await startupUsage(cachedApiKey, signal);
797
+ if (signal.aborted) return;
798
+ if (usage) applyUsage(usage, ctx);
799
+ // Skipped the network and nothing usable on disk yet (another process
800
+ // is fetching right now): let the idle poll fill the footer in.
801
+ else if (!fetched && cachedApiKey) resetIdleTimer(ctx);
802
+ })
803
+ .catch(() => { /* the footer stays empty */ });
677
804
  });
678
805
 
679
806
  pi.on("model_select", (event, ctx) => {
680
807
  if (event.model?.provider === "umans") {
808
+ if (!ctx.hasUI) return; // no footer to render without a UI
681
809
  throttledFetchUsage(cachedApiKey, { force: true }).then((usage) => {
682
810
  if (usage) {
683
811
  applyUsage(usage, ctx);
@@ -695,7 +823,7 @@ export default function (pi: ExtensionAPI) {
695
823
  // registration lag, so fetching now would undercount us anyway. One span
696
824
  // per prompt (agent_start → agent_settled), so no flicker between tool turns.
697
825
  pi.on("agent_start", async (_event, ctx) => {
698
- if (!isUmansModel(ctx)) return;
826
+ if (!isUmansModel(ctx) || !ctx.hasUI) return;
699
827
  if (idleTimer) {
700
828
  clearTimeout(idleTimer);
701
829
  idleTimer = null;
@@ -707,7 +835,7 @@ export default function (pi: ExtensionAPI) {
707
835
  // Turn ended: drop our optimistic +1 and reconcile with the server after a
708
836
  // short settle so the server has dropped our session from its count.
709
837
  pi.on("agent_settled", async (_event, ctx) => {
710
- if (!isUmansModel(ctx)) return;
838
+ if (!isUmansModel(ctx) || !ctx.hasUI) return;
711
839
  activeStreams = Math.max(0, activeStreams - 1);
712
840
  updateUsageStatus(ctx);
713
841
  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.35",
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
+ }