pi-umans-provider 1.0.35 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
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
+
3
7
  ## 1.0.35
4
8
 
5
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.
package/index.ts CHANGED
@@ -428,6 +428,9 @@ async function revalidateModels(
428
428
 
429
429
  let cachedApiKey: string | undefined;
430
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;
431
434
  // Aborted on session replacement/shutdown so in-flight usage fetches from
432
435
  // timers don't resolve against a stale ctx (the timer handle itself can't be
433
436
  // clearTimeout'd once the async callback has already fired).
@@ -675,6 +678,14 @@ export default function (pi: ExtensionAPI) {
675
678
  });
676
679
  }
677
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
+
678
689
  // After /login: revalidate with the new key right away (forced past the TTL,
679
690
  // still single-flight). Deferred so pi stores the credential first; never
680
691
  // awaited, never throws into the login flow.
@@ -684,8 +695,11 @@ export default function (pi: ExtensionAPI) {
684
695
  const key = credentials.access;
685
696
  cachedApiKey = key;
686
697
  if (!revalidateAbort || revalidateAbort.signal.aborted) revalidateAbort = new AbortController();
687
- const signal = revalidateAbort.signal;
688
- void revalidateModels(key, embeddedModels, signal, true)
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)))
689
703
  .then((freshBase) => {
690
704
  if (freshBase && !signal.aborted) registerFresh(freshBase);
691
705
  })
@@ -777,10 +791,11 @@ export default function (pi: ExtensionAPI) {
777
791
  const signal = revalidateAbort.signal;
778
792
  const keyResolved = resolveApiKey(ctx.modelRegistry);
779
793
 
780
- void keyResolved
781
- .then(() => revalidateModels(cachedApiKey, embeddedModels, signal))
794
+ const syncSignal = supersedeModelSync(signal);
795
+ modelSync = keyResolved
796
+ .then(() => revalidateModels(cachedApiKey, embeddedModels, syncSignal))
782
797
  .then((freshBase) => {
783
- if (freshBase && !signal.aborted) registerFresh(freshBase);
798
+ if (freshBase && !syncSignal.aborted) registerFresh(freshBase);
784
799
  })
785
800
  .catch(() => { /* keep serving the stale catalog */ });
786
801
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-umans-provider",
3
- "version": "1.0.35",
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",
@@ -65,7 +65,7 @@ function writeCache(ageMs: number, models: unknown[] = embeddedModels, fingerpri
65
65
  }
66
66
  }
67
67
 
68
- function stubFetch(impl: (url?: unknown) => Promise<Response> = async (url) => (isUsageUrl(url) ? usageResponse() : liveModelsResponse())) {
68
+ function stubFetch(impl: (url?: unknown, init?: RequestInit) => Promise<Response> = async (url) => (isUsageUrl(url) ? usageResponse() : liveModelsResponse())) {
69
69
  const fetch = vi.fn(impl);
70
70
  vi.stubGlobal("fetch", fetch);
71
71
  return fetch;
@@ -299,6 +299,32 @@ describe("umans: /login and usage polling", () => {
299
299
  expect(JSON.parse(fs.readFileSync(syncMetaPath(cachePath), "utf8")).keyFingerprint).toBe(keyFingerprint("sk-after-login"));
300
300
  });
301
301
 
302
+ it("fetches the new key's catalog when /login races the session revalidation holding the lock", async () => {
303
+ setupHome();
304
+ writeCache(2 * HOUR);
305
+ const pending: Array<() => void> = [];
306
+ const fetch = stubFetch((url, init?: RequestInit) => new Promise<Response>((resolve, reject) => {
307
+ const signal = init?.signal;
308
+ signal?.addEventListener("abort", () => reject(signal.reason));
309
+ pending.push(() => resolve(isUsageUrl(url) ? usageResponse() : liveModelsResponse()));
310
+ }));
311
+ const ext = await loadExtension();
312
+ await ext.startSession(interactive);
313
+ await vi.waitFor(() => expect(modelCalls(fetch)).toBe(1)); // session sync in flight, lock held
314
+ await vi.waitFor(() => expect(usageCalls(fetch)).toBe(1));
315
+ const login = (ext.registrations[0] as any).oauth.login;
316
+ await login({ onPrompt: async () => "sk-new-login-key" });
317
+ await vi.waitFor(() => expect(modelCalls(fetch)).toBe(2));
318
+ for (const release of pending) release();
319
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
320
+ // Superseding the catalog sync leaves the session's usage footer fetch alone.
321
+ await vi.waitFor(() => expect(ext.setStatus).toHaveBeenCalledWith("umans-usage", expect.stringContaining("Code Max")));
322
+ const modelRequests = fetch.mock.calls.filter(([url]) => !isUsageUrl(url)) as any[][];
323
+ expect(modelRequests[1][1].headers.Authorization).toBe("Bearer sk-new-login-key");
324
+ expect(JSON.parse(fs.readFileSync(syncMetaPath(cachePath), "utf8")).keyFingerprint).toBe(keyFingerprint("sk-new-login-key"));
325
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(false);
326
+ });
327
+
302
328
  it("does not poll usage without a UI (print mode, child agents)", async () => {
303
329
  setupHome();
304
330
  writeCache(10 * 60 * 1000);