pi-parasail-provider 1.0.33 → 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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.35
4
+
5
+ - Quiet startup sync: background revalidation (`/v1/models` plus the public pricing endpoint) 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.
6
+ - Without an API key the extension no longer calls the public pricing endpoint either: no key, no request.
7
+ - The model cache is only written when the authenticated model list succeeds; a failed refresh no longer overwrites the cache with the bundled models.
8
+ - Never store the API key: key changes are detected via a salted sha256 fingerprint in `cache/parasail-models.sync.json`. The cache file format is unchanged.
9
+ - Write the model cache atomically and swallow API key resolution failures in `session_start`.
10
+ - Add vitest coverage for the sync policy and the session_start integration.
11
+
3
12
  ## 1.0.27
4
13
 
5
14
  - Validate provider registration and session lifecycle against Pi 1.0.0; update the pinned development SDK.
package/README.md CHANGED
@@ -20,6 +20,7 @@ _DeepSeek, Qwen, GLM, Kimi, Llama, Gemma, Mistral — serverless with live prici
20
20
  - **Vision Support** — Qwen3-VL, Gemma 4, Qwen2.5-VL accept image + text input
21
21
  - **Unified API** — OpenAI-compatible completions endpoint
22
22
  - **Long Context** — DeepSeek V4 (1M), Llama 4 Maverick (1M), MiniMax M2.5 (1M)
23
+ - **Quiet live model sync** — Models and pricing refresh from the Parasail API in the background, at most hourly and by one pi process at a time ([details](#model-sync))
23
24
 
24
25
  ## Installation
25
26
 
@@ -83,13 +84,13 @@ Get your API key at [parasail.io](https://parasail.io/).
83
84
  | Glm 53 | 1M | ❌ | Text | 16K | $1.40 | $4.40 | $0.26 |
84
85
  | Glm 53 Flash | 1M | ❌ | Text | 16K | $0.15 | $0.50 | $0.03 |
85
86
  | Glm 53 Flash Fast | 1M | ❌ | Text | 16K | $0.19 | $0.63 | $0.04 |
86
- | Glm 53 Flash Vercel | 1M | ❌ | Text | 16K | — | — | — |
87
87
  | Googlegemma 3 4b It | 131K | ❌ | Text | 16K | $0.05 | — | — |
88
88
  | Gpt Oss 120b Fast | 131K | ✅ | Text | 16K | $0.15 | $0.60 | — |
89
89
  | GPT-OSS 120B | 131K | ✅ | Text | 16K | $0.10 | $0.75 | $0.06 |
90
90
  | GPT-OSS 20B | 131K | ✅ | Text | 16K | $0.03 | $0.15 | $0.02 |
91
91
  | Kimi K2.6 | 262K | ✅ | Text + Image | 16K | $0.75 | $3.50 | $0.16 |
92
92
  | Kimi K3 | 1M | ✅ | Text + Image | 16K | $2.60 | $13.00 | $0.26 |
93
+ | Kimi K3 Fast | 1M | ✅ | Text + Image | 16K | $3.00 | $15.00 | $0.30 |
93
94
  | Llama 3.3 70B | 131K | ❌ | Text | 16K | $0.22 | $0.50 | $0.11 |
94
95
  | Llama 32 3b Instruct | 131K | ❌ | Text | 16K | $0.05 | $0.33 | — |
95
96
  | Llama 4 Maverick 17B-128E | 524K | ❌ | Text + Image | 16K | $0.35 | $1.00 | $0.17 |
@@ -155,6 +156,7 @@ Get your API key at [parasail.io](https://parasail.io/).
155
156
  | Variable | Required | Description |
156
157
  |----------|----------|-------------|
157
158
  | `PARASAIL_API_KEY` | No | Your Parasail API key (fallback if not in auth.json) |
159
+ | `PI_PROVIDER_SYNC_TTL_MS` | No | Model sync freshness window in ms (default `3600000`; `0` = revalidate every session start) |
158
160
 
159
161
  ## Configuration
160
162
 
@@ -184,6 +186,22 @@ The `patch.json` file contains overrides that are applied on top of `models.json
184
186
  - Adding compat settings that the API doesn't provide
185
187
  - Overriding pricing/context window when specs change
186
188
 
189
+ ## Model sync
190
+
191
+ Startup never waits on the network. pi starts with the cached catalog (`~/.pi/agent/cache/parasail-models.json`,
192
+ falling back to the bundled `models.json`) and revalidates against `/v1/models` and the public pricing endpoint in the
193
+ background — only when:
194
+
195
+ - a Parasail API key is configured (no key, no request — not even to the public pricing endpoint),
196
+ - the cache is older than 1 hour, missing, or was fetched with a different API key, and
197
+ - no other pi process is already refreshing it (a `parasail-models.lock` file next to the cache).
198
+
199
+ So `/new`, `/reload`, `pi -p` and fan-out child agents share one cache instead of each calling the API; a
200
+ session that skips the fetch still picks up a cache another process refreshed. The cache is only rewritten when the
201
+ model list succeeds (pricing is a best-effort enrichment). A failed refresh is retried after 5 minutes at the earliest.
202
+ `PI_PROVIDER_SYNC_TTL_MS` changes the window (`0` revalidates on every session start). Only a hash fingerprint of the
203
+ key is stored (`parasail-models.sync.json`), never the key itself.
204
+
187
205
  ## Updating Models
188
206
 
189
207
  Run the update script to fetch the latest models from Parasail's API:
@@ -64,5 +64,27 @@
64
64
  "supportsStore": false
65
65
  },
66
66
  "deprecatedAt": "2026-10-08T00:00:21.187Z"
67
+ },
68
+ "parasail-glm-53-flash-vercel": {
69
+ "id": "parasail-glm-53-flash-vercel",
70
+ "name": "Glm 53 Flash Vercel",
71
+ "reasoning": false,
72
+ "input": [
73
+ "text"
74
+ ],
75
+ "cost": {
76
+ "input": 0,
77
+ "output": 0,
78
+ "cacheRead": 0,
79
+ "cacheWrite": 0
80
+ },
81
+ "contextWindow": 1048576,
82
+ "maxTokens": 16384,
83
+ "compat": {
84
+ "maxTokensField": "max_completion_tokens",
85
+ "supportsDeveloperRole": false,
86
+ "supportsStore": false
87
+ },
88
+ "deprecatedAt": "2026-10-09T00:00:22.639Z"
67
89
  }
68
90
  }
package/index.ts CHANGED
@@ -13,6 +13,10 @@
13
13
  * 2. Revalidate in background:
14
14
  * a. Live API /v1/models → merge with embedded → enrich with pricing
15
15
  * b. Public pricing endpoint → apply input/output/cache costs + context windows
16
+ * Quiet startup sync (sync-policy.ts): only with an API key (the public
17
+ * pricing endpoint is not called without one either), only when the disk
18
+ * cache is older than the TTL (1h, PI_PROVIDER_SYNC_TTL_MS) or the key
19
+ * changed, and only one pi process at a time (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
@@ -38,6 +42,7 @@ import patchData from "./patch.json" with { type: "json" };
38
42
  import deprecatedData from "./deprecated-models.json" with { type: "json" };
39
43
  import fs from "fs";
40
44
  import path from "path";
45
+ import { acquireModelSync, cacheChangedSinceLoad, markCacheLoaded, writeFileAtomic } from "./sync-policy";
41
46
 
42
47
  // ─── Types ────────────────────────────────────────────────────────────────────
43
48
 
@@ -288,7 +293,8 @@ function loadCachedModels(): JsonModel[] | null {
288
293
  function cacheModels(models: JsonModel[]): void {
289
294
  try {
290
295
  fs.mkdirSync(CACHE_DIR, { recursive: true });
291
- fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
296
+ // Atomic (tmp + rename): other pi processes may be reading the cache right now.
297
+ writeFileAtomic(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
292
298
  } catch {
293
299
  // Cache write failure is non-fatal
294
300
  }
@@ -361,6 +367,7 @@ function withDeprecated(models: JsonModel[]): JsonModel[] {
361
367
  }
362
368
 
363
369
  function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
370
+ markCacheLoaded(CACHE_PATH); // stat before read: lets session_start spot newer caches
364
371
  const cached = loadCachedModels();
365
372
  if (!cached || cached.length === 0) return embeddedModels;
366
373
 
@@ -374,23 +381,38 @@ function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
374
381
  return cached;
375
382
  }
376
383
 
377
- async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal): Promise<JsonModel[] | null> {
378
- // Fetch both models list and pricing in parallel
379
- const [liveModels, pricing] = await Promise.all([
380
- apiKey ? fetchLiveModels(apiKey, signal) : Promise.resolve(null),
381
- fetchPricingData(signal),
382
- ]);
383
-
384
- // Use embedded as base if live fetch failed
385
- const base = liveModels && liveModels.length > 0
386
- ? mergeWithEmbedded(liveModels, embeddedModels)
387
- : embeddedModels;
388
-
389
- // Always apply live pricing if available (public endpoint, no auth needed)
390
- const enriched = pricing ? applyPricing(base, pricing) : base;
391
-
392
- cacheModels(enriched);
393
- return enriched;
384
+ /**
385
+ * Background revalidation, gated by the quiet startup sync policy
386
+ * (sync-policy.ts): no key → no fetch at all (not even the public pricing
387
+ * endpoint); fresh cache (TTL) → no fetch; another process already fetching →
388
+ * no fetch. When skipped, a cache that another process refreshed since we
389
+ * loaded it is adopted from disk instead. `force` bypasses the TTL; it is
390
+ * still single-flight.
391
+ *
392
+ * One lease covers both requests. They run in parallel, each bounded by
393
+ * LIVE_FETCH_TIMEOUT_MS, so the lease is held well under the lock stale time.
394
+ * The authenticated /v1/models list is authoritative: the cache is written
395
+ * (and the sync recorded as "fetched") only when it succeeds. Pricing is a
396
+ * best-effort enrichment on top of it.
397
+ */
398
+ async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal, force = false): Promise<JsonModel[] | null> {
399
+ const lease = acquireModelSync({ cachePath: CACHE_PATH, apiKey, force });
400
+ if (!lease) return cacheChangedSinceLoad(CACHE_PATH) ? loadStaleModels(embeddedModels) : null;
401
+ let enriched: JsonModel[] | null = null;
402
+ try {
403
+ // Fetch both models list and pricing in parallel
404
+ const [liveModels, pricing] = await Promise.all([
405
+ apiKey ? fetchLiveModels(apiKey, signal) : Promise.resolve(null),
406
+ fetchPricingData(signal),
407
+ ]);
408
+ if (!liveModels || liveModels.length === 0) return null;
409
+ const base = mergeWithEmbedded(liveModels, embeddedModels);
410
+ enriched = pricing ? applyPricing(base, pricing) : base;
411
+ cacheModels(enriched);
412
+ return enriched;
413
+ } finally {
414
+ lease.release(enriched ? "fetched" : signal?.aborted ? "aborted" : "failed");
415
+ }
394
416
  }
395
417
 
396
418
  // ─── API Key Resolution (via ModelRegistry) ────────────────────────────────────
@@ -420,14 +442,17 @@ export default function (pi: ExtensionAPI) {
420
442
  models: staleModels,
421
443
  });
422
444
 
423
- // Revalidate in background: fetch live models + pricing → merge → cache → hot-swap
424
- // Pricing endpoint is public (no auth needed), models endpoint requires API key
445
+ // Revalidate in background: fetch live models + pricing → merge → cache → hot-swap.
446
+ // Both requests are gated on an API key (quiet startup sync). Fire-and-forget:
447
+ // never await network here — startup must not block on the provider API.
448
+ // Nothing is awaited and every rejection is swallowed.
425
449
  pi.on("session_start", async (_event, ctx) => {
426
450
  revalidateAbort?.abort();
427
451
  revalidateAbort = new AbortController();
428
452
  const signal = revalidateAbort.signal;
429
- resolveApiKey(ctx.modelRegistry).then(() => {
430
- revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
453
+ void resolveApiKey(ctx.modelRegistry)
454
+ .then(() => revalidateModels(cachedApiKey, embeddedModels, signal))
455
+ .then((freshBase) => {
431
456
  if (freshBase && !signal.aborted) {
432
457
  pi.registerProvider("parasail", {
433
458
  baseUrl: BASE_URL,
@@ -436,8 +461,8 @@ export default function (pi: ExtensionAPI) {
436
461
  models: buildModels(freshBase, customModels, patches),
437
462
  });
438
463
  }
439
- });
440
- });
464
+ })
465
+ .catch(() => { /* keep serving the stale catalog */ });
441
466
  });
442
467
 
443
468
  pi.on("session_shutdown", () => {
package/models.json CHANGED
@@ -371,27 +371,6 @@
371
371
  "supportsStore": false
372
372
  }
373
373
  },
374
- {
375
- "id": "parasail-glm-53-flash-vercel",
376
- "name": "Glm 53 Flash Vercel",
377
- "reasoning": false,
378
- "input": [
379
- "text"
380
- ],
381
- "cost": {
382
- "input": 0,
383
- "output": 0,
384
- "cacheRead": 0,
385
- "cacheWrite": 0
386
- },
387
- "contextWindow": 1048576,
388
- "maxTokens": 16384,
389
- "compat": {
390
- "maxTokensField": "max_completion_tokens",
391
- "supportsDeveloperRole": false,
392
- "supportsStore": false
393
- }
394
- },
395
374
  {
396
375
  "id": "parasail-kimi-k3",
397
376
  "name": "Kimi K3",
@@ -455,6 +434,27 @@
455
434
  "supportsStore": false
456
435
  }
457
436
  },
437
+ {
438
+ "id": "parasail-kimi-k3-fast",
439
+ "name": "Kimi K3 Fast",
440
+ "reasoning": false,
441
+ "input": [
442
+ "text"
443
+ ],
444
+ "cost": {
445
+ "input": 3,
446
+ "output": 15,
447
+ "cacheRead": 0.3,
448
+ "cacheWrite": 0
449
+ },
450
+ "contextWindow": 1000000,
451
+ "maxTokens": 16384,
452
+ "compat": {
453
+ "maxTokensField": "max_completion_tokens",
454
+ "supportsDeveloperRole": false,
455
+ "supportsStore": false
456
+ }
457
+ },
458
458
  {
459
459
  "id": "parasail-llama-4-maverick-instruct-fp8",
460
460
  "name": "Llama 4 Maverick 17B-128E",
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "pi-parasail-provider",
3
- "version": "1.0.33",
3
+ "version": "1.0.35",
4
4
  "description": "Parasail provider extension for pi - Access top open-source models (DeepSeek, Qwen, GLM, Kimi, Llama, Gemma, Mistral) through the Parasail API",
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 scripts/test-pi-compat.mjs",
10
+ "check": "tsc --noEmit && vitest run && node scripts/test-pi-compat.mjs",
11
+ "test": "vitest run",
11
12
  "update-models": "node scripts/update-models.js",
12
13
  "test:pi": "node scripts/test-pi-compat.mjs",
13
14
  "typecheck": "tsc --noEmit"
@@ -40,7 +41,8 @@
40
41
  "devDependencies": {
41
42
  "@earendil-works/pi-coding-agent": "1.1.0",
42
43
  "typescript": "6.0.3",
43
- "@types/node": "^25.9.1"
44
+ "@types/node": "^25.9.1",
45
+ "vitest": "4.1.7"
44
46
  },
45
47
  "peerDependencies": {
46
48
  "@earendil-works/pi-coding-agent": "*"
package/sync-policy.ts ADDED
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Quiet startup sync — shared policy for background model-catalog revalidation.
3
+ *
4
+ * Every pi process (interactive start, /new, /reload, `pi -p`, and every child
5
+ * agent a fan-out tool spawns) fires `session_start`. Revalidating the live
6
+ * `/models` catalog on each of those spams the provider API, so this module
7
+ * decides — without any network I/O and without ever throwing — whether *this*
8
+ * process should fetch at all:
9
+ *
10
+ * 1. No credentials → no fetch (serve disk cache → embedded models.json).
11
+ * 2. Freshness window → skip while the disk cache is younger than the TTL.
12
+ * TTL source: the cache file's mtime. The cache is only (re)written after a
13
+ * successful live fetch, so mtime is "last fetched at" — and caches written
14
+ * by older extension versions carry it too, with no format change.
15
+ * Default 1 hour; override with PI_PROVIDER_SYNC_TTL_MS (0 = always).
16
+ * 3. Forced refresh ignores the TTL: cache missing/corrupt, API key changed
17
+ * since the cache was fetched (compared via a salted sha256 fingerprint —
18
+ * never the key itself), or the caller passes `force` (e.g. after /login).
19
+ * 4. Failure backoff → after a failed fetch with the same key, non-forced
20
+ * attempts wait min(5 min, TTL) so a bad key or outage is not retried by
21
+ * every process that starts.
22
+ * 5. Cross-process single-flight → an atomic lock file (`open(…, "wx")`) next
23
+ * to the cache; whoever creates it fetches, everyone else skips and keeps
24
+ * serving the cache. Locks older than the stale timeout (crashed holder)
25
+ * are reclaimed. Any filesystem error fails open to "skip the fetch".
26
+ *
27
+ * Files (for a cache at `<agentDir>/cache/<provider>-models.json`):
28
+ * <provider>-models.json the cache itself — unchanged plain JSON array
29
+ * <provider>-models.sync.json sidecar: { version, keyFingerprint, fetchedAt, failedAt?, failedKeyFingerprint? }
30
+ * <provider>-models.lock present only while one process is fetching
31
+ *
32
+ * Older extension versions ignore the sidecar/lock and read the cache as
33
+ * before; a cache without a sidecar (written by an older version) is treated
34
+ * as "fetched with an unknown key" and revalidated once (still single-flight).
35
+ *
36
+ * This file is provider-agnostic: it is copied verbatim into every
37
+ * pi-*-provider repo. Keep it free of pi imports and provider specifics.
38
+ *
39
+ * Typical use inside a provider's revalidateModels():
40
+ *
41
+ * const lease = acquireModelSync({ cachePath: CACHE_PATH, apiKey, force });
42
+ * if (!lease) return cacheChangedSinceLoad(CACHE_PATH) ? loadStaleModels(embedded) : null;
43
+ * let merged = null;
44
+ * try { …fetch, merge, cacheModels(merged)…; return merged; }
45
+ * finally { lease.release(merged ? "fetched" : signal?.aborted ? "aborted" : "failed"); }
46
+ */
47
+
48
+ import crypto from "crypto";
49
+ import fs from "fs";
50
+ import path from "path";
51
+
52
+ /** Env override for the freshness window, shared by all pi-*-provider extensions. */
53
+ export const SYNC_TTL_ENV = "PI_PROVIDER_SYNC_TTL_MS";
54
+ export const DEFAULT_SYNC_TTL_MS = 60 * 60 * 1000;
55
+ /** A lock older than this is assumed abandoned (crashed holder). Must exceed the fetch timeout. */
56
+ export const DEFAULT_LOCK_STALE_MS = 60 * 1000;
57
+ /** Pause after a failed fetch (same key) before non-forced retries; capped at the TTL. */
58
+ export const FAILURE_BACKOFF_MS = 5 * 60 * 1000;
59
+
60
+ /** Why this process was granted the fetch. */
61
+ export type SyncReason = "missing" | "key-changed" | "forced" | "stale";
62
+ /** Why this process must not fetch (serve cache/embedded instead). */
63
+ export type SkipReason = "no-key" | "fresh" | "backoff" | "locked" | "unwritable";
64
+ /** How a granted fetch ended; "aborted" (session replaced/shut down) records no failure. */
65
+ export type SyncOutcome = "fetched" | "failed" | "aborted";
66
+
67
+ export type SyncPlan = { fetch: true; reason: SyncReason } | { fetch: false; reason: SkipReason };
68
+
69
+ export interface ModelSyncOptions {
70
+ /** Absolute path of the provider's disk cache, e.g. `<agentDir>/cache/<provider>-models.json`. */
71
+ cachePath: string;
72
+ /** Resolved credential. Falsy → never fetch. */
73
+ apiKey: string | undefined | null;
74
+ /** Revalidate regardless of TTL/backoff (e.g. right after /login). Still single-flight. */
75
+ force?: boolean;
76
+ /** Freshness window; defaults to $PI_PROVIDER_SYNC_TTL_MS, else 1 hour. */
77
+ ttlMs?: number;
78
+ lockStaleMs?: number;
79
+ /** Cache validity check; defaults to "non-empty JSON array" (the models cache format). */
80
+ isValidCache?: (data: unknown) => boolean;
81
+ /** Clock override for tests. */
82
+ now?: number;
83
+ /** Optional diagnostics hook: called with the reason whenever the fetch is skipped. */
84
+ onSkip?: (reason: SkipReason) => void;
85
+ }
86
+
87
+ export interface ModelSyncLease {
88
+ readonly reason: SyncReason;
89
+ /** Always call exactly once (use try/finally). Records the outcome and drops the lock. */
90
+ release(outcome: SyncOutcome): void;
91
+ }
92
+
93
+ interface SyncMeta {
94
+ version: 1;
95
+ keyFingerprint?: string;
96
+ fetchedAt?: number;
97
+ failedAt?: number;
98
+ failedKeyFingerprint?: string;
99
+ }
100
+
101
+ // ─── Small helpers ────────────────────────────────────────────────────────────
102
+
103
+ /** Salted, truncated sha256 of the key: detects key changes, cannot be reversed into the key. */
104
+ export function keyFingerprint(apiKey: string): string {
105
+ return crypto.createHash("sha256").update(`pi-provider-sync\0${apiKey}`).digest("hex").slice(0, 16);
106
+ }
107
+
108
+ export function resolveSyncTtlMs(ttlMs?: number): number {
109
+ if (typeof ttlMs === "number" && Number.isFinite(ttlMs) && ttlMs >= 0) return ttlMs;
110
+ const raw = process.env[SYNC_TTL_ENV];
111
+ const fromEnv = raw === undefined || raw.trim() === "" ? NaN : Number(raw);
112
+ return Number.isFinite(fromEnv) && fromEnv >= 0 ? fromEnv : DEFAULT_SYNC_TTL_MS;
113
+ }
114
+
115
+ /** `foo-models.json` → `foo-models` (sidecar/lock base name). */
116
+ function sidecarBase(cachePath: string): string {
117
+ return cachePath.replace(/\.json$/i, "");
118
+ }
119
+ export const syncMetaPath = (cachePath: string): string => `${sidecarBase(cachePath)}.sync.json`;
120
+ export const syncLockPath = (cachePath: string): string => `${sidecarBase(cachePath)}.lock`;
121
+
122
+ function mtimeMs(file: string): number | null {
123
+ try {
124
+ return fs.statSync(file).mtimeMs;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ function readJson(file: string): unknown {
131
+ try {
132
+ return JSON.parse(fs.readFileSync(file, "utf8"));
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ function readMeta(cachePath: string): SyncMeta | null {
139
+ const data = readJson(syncMetaPath(cachePath));
140
+ return data && typeof data === "object" && !Array.isArray(data) ? (data as SyncMeta) : null;
141
+ }
142
+
143
+ /**
144
+ * Write via temp file + rename so concurrent readers in other processes never
145
+ * observe a half-written file (a torn cache would otherwise read as "corrupt"
146
+ * and force a refetch). Throws on failure — callers decide whether that is fatal.
147
+ */
148
+ export function writeFileAtomic(file: string, contents: string): void {
149
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
150
+ try {
151
+ fs.writeFileSync(tmp, contents);
152
+ fs.renameSync(tmp, file);
153
+ } catch (error) {
154
+ try { fs.unlinkSync(tmp); } catch { /* already gone */ }
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ function writeMeta(cachePath: string, meta: SyncMeta): void {
160
+ try {
161
+ writeFileAtomic(syncMetaPath(cachePath), JSON.stringify(meta) + "\n");
162
+ } catch {
163
+ // Non-fatal: worst case the next process revalidates once more.
164
+ }
165
+ }
166
+
167
+ // ─── In-process view of the shared cache ──────────────────────────────────────
168
+
169
+ // mtime of the cache as this process last loaded/wrote it, per cache path.
170
+ const loadedCacheMtimes = new Map<string, number>();
171
+
172
+ /** Call right before reading the disk cache at startup (stat-before-read keeps races harmless). */
173
+ export function markCacheLoaded(cachePath: string): void {
174
+ loadedCacheMtimes.set(cachePath, mtimeMs(cachePath) ?? 0);
175
+ }
176
+
177
+ /**
178
+ * True when another process rewrote the shared cache since this process last
179
+ * loaded it, so a long-lived session can adopt the newer catalog from disk
180
+ * instead of fetching it again.
181
+ */
182
+ export function cacheChangedSinceLoad(cachePath: string): boolean {
183
+ const current = mtimeMs(cachePath);
184
+ return current !== null && current !== loadedCacheMtimes.get(cachePath);
185
+ }
186
+
187
+ // ─── Policy ───────────────────────────────────────────────────────────────────
188
+
189
+ const isNonEmptyArray = (data: unknown): boolean => Array.isArray(data) && data.length > 0;
190
+
191
+ /** Decide whether a fetch is warranted (no lock taken, no I/O beyond local stat/read). */
192
+ export function planModelSync(options: ModelSyncOptions): SyncPlan {
193
+ const { cachePath, apiKey, force = false } = options;
194
+ if (!apiKey) return { fetch: false, reason: "no-key" };
195
+
196
+ const now = options.now ?? Date.now();
197
+ const ttlMs = resolveSyncTtlMs(options.ttlMs);
198
+ const fingerprint = keyFingerprint(apiKey);
199
+ const meta = readMeta(cachePath);
200
+ const cacheMtime = mtimeMs(cachePath);
201
+ const valid = cacheMtime !== null && (options.isValidCache ?? isNonEmptyArray)(readJson(cachePath));
202
+
203
+ let reason: SyncReason;
204
+ if (!valid) reason = "missing";
205
+ // A cache without a sidecar (older extension version) counts as an unknown key.
206
+ else if (meta?.keyFingerprint !== fingerprint) reason = "key-changed";
207
+ else if (force) reason = "forced";
208
+ else if (now - cacheMtime < ttlMs) return { fetch: false, reason: "fresh" };
209
+ else reason = "stale";
210
+
211
+ if (!force && meta?.failedKeyFingerprint === fingerprint && typeof meta.failedAt === "number"
212
+ && now - meta.failedAt < Math.min(FAILURE_BACKOFF_MS, ttlMs)) {
213
+ return { fetch: false, reason: "backoff" };
214
+ }
215
+ return { fetch: true, reason };
216
+ }
217
+
218
+ /** Atomically create the lock file; reclaim it once if its holder looks dead. */
219
+ function tryLock(lockPath: string, token: string, lockStaleMs: number, now: number): "locked" | "held" | "unwritable" {
220
+ try {
221
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
222
+ } catch {
223
+ return "unwritable";
224
+ }
225
+ for (let attempt = 0; attempt < 2; attempt++) {
226
+ let fd: number;
227
+ try {
228
+ fd = fs.openSync(lockPath, "wx");
229
+ } catch (error) {
230
+ // EEXIST: someone holds the lock. Anything else (EACCES, ENOTDIR, EROFS…):
231
+ // the cache dir is unusable — fail open to "skip the fetch".
232
+ if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") return "unwritable";
233
+ if (attempt > 0) return "held";
234
+ const lockMtime = mtimeMs(lockPath);
235
+ if (lockMtime === null) continue; // released between open and stat: retry once
236
+ if (now - lockMtime <= lockStaleMs) return "held"; // a live process is fetching
237
+ // Stale: the holder crashed or hung. Reclaim and retry once. (Two processes
238
+ // reclaiming the same stale lock at the same instant may both fetch — a rare,
239
+ // harmless duplicate.)
240
+ try { fs.unlinkSync(lockPath); } catch { /* someone else reclaimed it first */ }
241
+ continue;
242
+ }
243
+ // We own the lock. Record a token so release() only deletes our own lock.
244
+ let wrote = false;
245
+ try {
246
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, token, at: now }));
247
+ wrote = true;
248
+ } catch {
249
+ // e.g. ENOSPC — handled below
250
+ } finally {
251
+ try { fs.closeSync(fd); } catch { /* ignore */ }
252
+ }
253
+ if (wrote) return "locked";
254
+ try { fs.unlinkSync(lockPath); } catch { /* ignore */ }
255
+ return "unwritable";
256
+ }
257
+ return "held";
258
+ }
259
+
260
+ /**
261
+ * Decide and, when this process should fetch, take the cross-process lock.
262
+ * Returns null when the caller must skip the network (serve cache/embedded).
263
+ * Never throws.
264
+ */
265
+ export function acquireModelSync(options: ModelSyncOptions): ModelSyncLease | null {
266
+ try {
267
+ const plan = planModelSync(options);
268
+ if (!plan.fetch) return skip(options, plan.reason);
269
+
270
+ const { cachePath } = options;
271
+ const now = options.now ?? Date.now();
272
+ const lockPath = syncLockPath(cachePath);
273
+ const token = crypto.randomBytes(8).toString("hex");
274
+ const lock = tryLock(lockPath, token, options.lockStaleMs ?? DEFAULT_LOCK_STALE_MS, now);
275
+ if (lock !== "locked") return skip(options, lock === "held" ? "locked" : "unwritable");
276
+
277
+ const fingerprint = keyFingerprint(options.apiKey as string);
278
+ let released = false;
279
+ return {
280
+ reason: plan.reason,
281
+ release(outcome: SyncOutcome): void {
282
+ if (released) return;
283
+ released = true;
284
+ try {
285
+ const at = Date.now();
286
+ if (outcome === "fetched") {
287
+ writeMeta(cachePath, { version: 1, keyFingerprint: fingerprint, fetchedAt: at });
288
+ markCacheLoaded(cachePath); // our own write is not "another process's" update
289
+ } else if (outcome === "failed") {
290
+ const previous = readMeta(cachePath);
291
+ writeMeta(cachePath, { ...previous, version: 1, failedAt: at, failedKeyFingerprint: fingerprint });
292
+ }
293
+ // Only remove the lock if it is still ours (it may have been reclaimed as stale).
294
+ const held = readJson(lockPath) as { token?: string } | undefined;
295
+ if (held?.token === token) fs.unlinkSync(lockPath);
296
+ } catch {
297
+ // Never throw from cleanup; a leftover lock expires after lockStaleMs.
298
+ }
299
+ },
300
+ };
301
+ } catch {
302
+ return skip(options, "unwritable");
303
+ }
304
+ }
305
+
306
+ function skip(options: ModelSyncOptions, reason: SkipReason): null {
307
+ try { options.onSkip?.(reason); } catch { /* diagnostics must never break startup */ }
308
+ return null;
309
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Quiet startup sync, end to end through the extension's real session_start
3
+ * handler: the live /models catalog is fetched only with a key, only when the
4
+ * disk cache is stale (or the key changed), only by one process at a time, and
5
+ * never on the session_start critical path.
6
+ *
7
+ * Porting: only the "Provider specifics" block below differs between
8
+ * pi-*-provider repos.
9
+ */
10
+
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { keyFingerprint, syncLockPath, syncMetaPath } from "../sync-policy";
16
+ import embeddedModels from "../models.json" with { type: "json" };
17
+
18
+ // ─── Provider specifics ───────────────────────────────────────────────────────
19
+ const PROVIDER_ID = "parasail";
20
+ // transformApiModel only keeps parasail- prefixed ids.
21
+ const LIVE_ONLY_ID = "parasail-live-only-test-model";
22
+ /** A minimal successful /models response containing LIVE_ONLY_ID. */
23
+ const liveModelsResponse = () => Response.json({ data: [{ id: LIVE_ONLY_ID }] });
24
+ // Parasail also enriches the catalog from a public pricing endpoint. It sits
25
+ // behind the same lease, so `fetch` below counts /v1/models calls and
26
+ // `pricingFetch` counts pricing calls.
27
+ const PRICING_URL = "https://www.saas.parasail.io/api/v1/prices/serverlessEndpoints";
28
+ const pricingResponse = () => Response.json([
29
+ { externalAlias: LIVE_ONLY_ID, contextLength: 65536, inputCost: 1.25, outputCost: 2.5, cachedCost: 0.5, tags: [] },
30
+ ]);
31
+ let pricingFetch: ReturnType<typeof vi.fn>;
32
+ // ──────────────────────────────────────────────────────────────────────────────
33
+
34
+ const HOUR = 60 * 60 * 1000;
35
+ const KEY = "sk-quiet-sync-test";
36
+
37
+ let home: string;
38
+ let cachePath: string;
39
+
40
+ afterEach(() => {
41
+ vi.unstubAllEnvs();
42
+ vi.unstubAllGlobals();
43
+ vi.resetModules();
44
+ if (home) fs.rmSync(home, { recursive: true, force: true });
45
+ });
46
+
47
+ function setupHome(): void {
48
+ home = fs.mkdtempSync(path.join(os.tmpdir(), `pi-${PROVIDER_ID}-sync-`));
49
+ cachePath = path.join(home, "cache", `${PROVIDER_ID}-models.json`);
50
+ vi.stubEnv("PI_CODING_AGENT_DIR", home);
51
+ vi.stubEnv("PI_PROVIDER_SYNC_TTL_MS", "");
52
+ }
53
+
54
+ function writeCache(ageMs: number, models: unknown[] = embeddedModels, fingerprintKey: string | null = KEY): void {
55
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
56
+ fs.writeFileSync(cachePath, JSON.stringify(models));
57
+ const t = new Date(Date.now() - ageMs);
58
+ fs.utimesSync(cachePath, t, t);
59
+ if (fingerprintKey !== null) {
60
+ fs.writeFileSync(syncMetaPath(cachePath), JSON.stringify({ version: 1, keyFingerprint: keyFingerprint(fingerprintKey) }));
61
+ }
62
+ }
63
+
64
+ function stubFetch(impl: () => Promise<Response> = async () => liveModelsResponse()) {
65
+ const fetch = vi.fn(impl);
66
+ pricingFetch = vi.fn(async () => pricingResponse());
67
+ vi.stubGlobal("fetch", (input: unknown) => String(input) === PRICING_URL ? pricingFetch() : fetch());
68
+ return fetch;
69
+ }
70
+
71
+ /** Load the extension fresh (module-level cache paths read PI_CODING_AGENT_DIR). */
72
+ async function loadExtension() {
73
+ vi.resetModules();
74
+ const mod = await import("../index");
75
+ const handlers = new Map<string, (...args: any[]) => any>();
76
+ const registrations: Array<{ models: Array<{ id: string }> }> = [];
77
+ mod.default({
78
+ on: (event: string, handler: (...args: any[]) => any) => handlers.set(event, handler),
79
+ registerProvider: (_id: string, config: any) => registrations.push(config),
80
+ registerCommand: () => {},
81
+ } as any);
82
+ const getApiKeyForProvider = vi.fn(async (): Promise<string | undefined> => KEY);
83
+ const startSession = () => handlers.get("session_start")!({ type: "session_start" }, { modelRegistry: { getApiKeyForProvider } });
84
+ return { handlers, registrations, getApiKeyForProvider, startSession };
85
+ }
86
+
87
+ /** Let the fire-and-forget revalidation chain run to completion. */
88
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 50));
89
+ const hasLiveModel = (config: { models: Array<{ id: string }> } | undefined) => !!config?.models.some((m) => m.id === LIVE_ONLY_ID);
90
+
91
+ describe("quiet startup sync", () => {
92
+ it("skips the live fetch while the disk cache is fresher than the TTL", async () => {
93
+ setupHome();
94
+ writeCache(10 * 60 * 1000);
95
+ const fetch = stubFetch();
96
+ const ext = await loadExtension();
97
+ await ext.startSession();
98
+ await settle();
99
+ expect(ext.getApiKeyForProvider).toHaveBeenCalled();
100
+ expect(fetch).not.toHaveBeenCalled();
101
+ expect(ext.registrations).toHaveLength(1);
102
+ });
103
+
104
+ it("revalidates a stale cache once, hot-swaps the catalog and rewrites the cache", async () => {
105
+ setupHome();
106
+ writeCache(2 * HOUR);
107
+ const fetch = stubFetch();
108
+ const ext = await loadExtension();
109
+ await ext.startSession();
110
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
111
+ expect(fetch).toHaveBeenCalledOnce();
112
+ const cached = JSON.parse(fs.readFileSync(cachePath, "utf8"));
113
+ // Cache stays a plain JSON array so older extension versions still read it.
114
+ expect(Array.isArray(cached)).toBe(true);
115
+ expect(cached.some((m: { id: string }) => m.id === LIVE_ONLY_ID)).toBe(true);
116
+ // Pricing enrichment rides on the same sync.
117
+ expect(pricingFetch).toHaveBeenCalledOnce();
118
+ expect(cached.find((m: { id: string }) => m.id === LIVE_ONLY_ID).cost.input).toBe(1.25);
119
+ expect(Date.now() - fs.statSync(cachePath).mtimeMs).toBeLessThan(60_000);
120
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(false);
121
+ const meta = fs.readFileSync(syncMetaPath(cachePath), "utf8");
122
+ expect(meta).not.toContain(KEY);
123
+ expect(JSON.parse(meta).keyFingerprint).toBe(keyFingerprint(KEY));
124
+
125
+ // A second session in the same process (/new) is now inside the TTL.
126
+ await ext.startSession();
127
+ await settle();
128
+ expect(fetch).toHaveBeenCalledOnce();
129
+ });
130
+
131
+ it("never touches the network without an API key", async () => {
132
+ setupHome();
133
+ const fetch = stubFetch();
134
+ const ext = await loadExtension();
135
+ ext.getApiKeyForProvider.mockResolvedValue(undefined);
136
+ await ext.startSession();
137
+ await settle();
138
+ expect(fetch).not.toHaveBeenCalled();
139
+ expect(pricingFetch).not.toHaveBeenCalled(); // public endpoint, but still gated on the key
140
+ expect(fs.existsSync(cachePath)).toBe(false);
141
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(false);
142
+ expect(ext.registrations).toHaveLength(1);
143
+ });
144
+
145
+ it("skips when another process holds the revalidation lock", async () => {
146
+ setupHome();
147
+ writeCache(2 * HOUR);
148
+ fs.writeFileSync(syncLockPath(cachePath), JSON.stringify({ pid: 999999, token: "other-process" }));
149
+ const fetch = stubFetch();
150
+ const ext = await loadExtension();
151
+ await ext.startSession();
152
+ await settle();
153
+ expect(fetch).not.toHaveBeenCalled();
154
+ expect(pricingFetch).not.toHaveBeenCalled();
155
+ expect(JSON.parse(fs.readFileSync(syncLockPath(cachePath), "utf8")).token).toBe("other-process");
156
+ });
157
+
158
+ it("reclaims a stale lock left by a crashed process", async () => {
159
+ setupHome();
160
+ writeCache(2 * HOUR);
161
+ const lockPath = syncLockPath(cachePath);
162
+ fs.writeFileSync(lockPath, JSON.stringify({ pid: 999999, token: "crashed" }));
163
+ const old = new Date(Date.now() - 10 * 60 * 1000);
164
+ fs.utimesSync(lockPath, old, old);
165
+ const fetch = stubFetch();
166
+ const ext = await loadExtension();
167
+ await ext.startSession();
168
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
169
+ expect(fetch).toHaveBeenCalledOnce();
170
+ expect(fs.existsSync(lockPath)).toBe(false);
171
+ });
172
+
173
+ it("forces a revalidation when the API key changed, even inside the TTL", async () => {
174
+ setupHome();
175
+ writeCache(60 * 1000, embeddedModels, "previous-key");
176
+ const fetch = stubFetch();
177
+ const ext = await loadExtension();
178
+ await ext.startSession();
179
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
180
+ expect(fetch).toHaveBeenCalledOnce();
181
+ });
182
+
183
+ it("revalidates once when the cache predates the sync sidecar (older extension version)", async () => {
184
+ setupHome();
185
+ writeCache(60 * 1000, embeddedModels, null);
186
+ const fetch = stubFetch();
187
+ const ext = await loadExtension();
188
+ expect(ext.registrations[0].models.length).toBeGreaterThan(0); // old cache is still served
189
+ await ext.startSession();
190
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
191
+ expect(fetch).toHaveBeenCalledOnce();
192
+ });
193
+
194
+ it("does not throw or fetch when the cache dir is unwritable", async () => {
195
+ setupHome();
196
+ fs.writeFileSync(path.join(home, "cache"), "a file where the cache dir should be");
197
+ const fetch = stubFetch();
198
+ const ext = await loadExtension();
199
+ await expect(ext.startSession()).resolves.toBeUndefined();
200
+ await settle();
201
+ expect(fetch).not.toHaveBeenCalled();
202
+ expect(ext.registrations).toHaveLength(1);
203
+ });
204
+
205
+ it("does not await the network in session_start", async () => {
206
+ setupHome();
207
+ writeCache(2 * HOUR);
208
+ let release!: () => void;
209
+ const fetch = stubFetch(() => new Promise<Response>((resolve) => { release = () => resolve(liveModelsResponse()); }));
210
+ const ext = await loadExtension();
211
+ const outcome = await Promise.race([
212
+ Promise.resolve(ext.startSession()).then(() => "returned"),
213
+ new Promise((resolve) => setTimeout(() => resolve("blocked"), 1000)),
214
+ ]);
215
+ expect(outcome).toBe("returned");
216
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
217
+ release();
218
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
219
+ });
220
+
221
+ it("swallows API key resolution failures (no unhandled rejection)", async () => {
222
+ setupHome();
223
+ const fetch = stubFetch();
224
+ const ext = await loadExtension();
225
+ const unhandled = vi.fn();
226
+ process.on("unhandledRejection", unhandled);
227
+ try {
228
+ ext.getApiKeyForProvider.mockRejectedValue(new Error("keychain locked"));
229
+ await ext.startSession();
230
+ await settle();
231
+ expect(unhandled).not.toHaveBeenCalled();
232
+ expect(fetch).not.toHaveBeenCalled();
233
+ } finally {
234
+ process.off("unhandledRejection", unhandled);
235
+ }
236
+ });
237
+
238
+ it("backs off after a failed fetch instead of retrying in every new process", async () => {
239
+ setupHome();
240
+ writeCache(2 * HOUR);
241
+ const fetch = stubFetch(async () => new Response("unauthorized", { status: 401 }));
242
+ let ext = await loadExtension();
243
+ await ext.startSession();
244
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
245
+ await settle();
246
+ ext = await loadExtension(); // next pi process
247
+ await ext.startSession();
248
+ await settle();
249
+ expect(fetch).toHaveBeenCalledOnce();
250
+ });
251
+
252
+ it("adopts a cache refreshed by another process without fetching", async () => {
253
+ setupHome();
254
+ writeCache(30 * 60 * 1000);
255
+ const fetch = stubFetch();
256
+ const ext = await loadExtension();
257
+ // Another pi process revalidated after this one started.
258
+ const refreshed = [...embeddedModels, { ...embeddedModels[0], id: LIVE_ONLY_ID }];
259
+ fs.writeFileSync(cachePath, JSON.stringify(refreshed));
260
+ await ext.startSession();
261
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
262
+ expect(fetch).not.toHaveBeenCalled();
263
+ });
264
+
265
+ it("PI_PROVIDER_SYNC_TTL_MS=0 revalidates on every session start", async () => {
266
+ setupHome();
267
+ vi.stubEnv("PI_PROVIDER_SYNC_TTL_MS", "0");
268
+ writeCache(0);
269
+ const fetch = stubFetch();
270
+ const ext = await loadExtension();
271
+ await ext.startSession();
272
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
273
+ });
274
+
275
+ it("does not write the cache when the model list fails, even if pricing succeeds", async () => {
276
+ setupHome();
277
+ writeCache(2 * HOUR);
278
+ const before = fs.readFileSync(cachePath, "utf8");
279
+ const mtime = fs.statSync(cachePath).mtimeMs;
280
+ const fetch = stubFetch(async () => new Response("unavailable", { status: 503 }));
281
+ const ext = await loadExtension();
282
+ await ext.startSession();
283
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
284
+ await settle();
285
+ expect(pricingFetch).toHaveBeenCalledOnce();
286
+ expect(fs.readFileSync(cachePath, "utf8")).toBe(before);
287
+ expect(fs.statSync(cachePath).mtimeMs).toBe(mtime);
288
+ expect(ext.registrations).toHaveLength(1);
289
+ expect(JSON.parse(fs.readFileSync(syncMetaPath(cachePath), "utf8")).failedKeyFingerprint).toBe(keyFingerprint(KEY));
290
+ });
291
+
292
+ it("still caches the live model list when the pricing endpoint fails", async () => {
293
+ setupHome();
294
+ writeCache(2 * HOUR);
295
+ const fetch = stubFetch();
296
+ pricingFetch.mockImplementation(async () => new Response("down", { status: 500 }));
297
+ const ext = await loadExtension();
298
+ await ext.startSession();
299
+ await vi.waitFor(() => expect(hasLiveModel(ext.registrations.at(-1))).toBe(true));
300
+ expect(fetch).toHaveBeenCalledOnce();
301
+ const cached = JSON.parse(fs.readFileSync(cachePath, "utf8"));
302
+ expect(cached.some((m: { id: string }) => m.id === LIVE_ONLY_ID)).toBe(true);
303
+ });
304
+ });
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Unit tests for sync-policy.ts — the provider-agnostic quiet startup sync
3
+ * policy (TTL, key fingerprint, failure backoff, cross-process lock).
4
+ *
5
+ * Provider-agnostic: copied verbatim into every pi-*-provider repo alongside
6
+ * sync-policy.ts.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import { execFile } from "node:child_process";
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import {
16
+ DEFAULT_SYNC_TTL_MS,
17
+ SYNC_TTL_ENV,
18
+ acquireModelSync,
19
+ cacheChangedSinceLoad,
20
+ keyFingerprint,
21
+ markCacheLoaded,
22
+ planModelSync,
23
+ resolveSyncTtlMs,
24
+ syncLockPath,
25
+ syncMetaPath,
26
+ writeFileAtomic,
27
+ } from "../sync-policy";
28
+
29
+ const HOUR = 60 * 60 * 1000;
30
+ const KEY = "sk-test-secret-key";
31
+
32
+ let dir: string;
33
+ let cachePath: string;
34
+
35
+ beforeEach(() => {
36
+ vi.stubEnv(SYNC_TTL_ENV, "");
37
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-sync-policy-"));
38
+ cachePath = path.join(dir, "cache", "example-models.json");
39
+ });
40
+
41
+ afterEach(() => {
42
+ vi.unstubAllEnvs();
43
+ fs.rmSync(dir, { recursive: true, force: true });
44
+ });
45
+
46
+ function writeCache(ageMs: number, models: unknown = [{ id: "m" }]): void {
47
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
48
+ fs.writeFileSync(cachePath, JSON.stringify(models));
49
+ const t = new Date(Date.now() - ageMs);
50
+ fs.utimesSync(cachePath, t, t);
51
+ }
52
+
53
+ function writeMeta(meta: Record<string, unknown>): void {
54
+ fs.writeFileSync(syncMetaPath(cachePath), JSON.stringify({ version: 1, ...meta }));
55
+ }
56
+
57
+ function writeLock(ageMs: number, token = "someone-else"): void {
58
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
59
+ fs.writeFileSync(syncLockPath(cachePath), JSON.stringify({ pid: 999999, token }));
60
+ const t = new Date(Date.now() - ageMs);
61
+ fs.utimesSync(syncLockPath(cachePath), t, t);
62
+ }
63
+
64
+ describe("keyFingerprint", () => {
65
+ it("is a stable 16-hex digest that never contains the key", () => {
66
+ const fp = keyFingerprint(KEY);
67
+ expect(fp).toMatch(/^[0-9a-f]{16}$/);
68
+ expect(keyFingerprint(KEY)).toBe(fp);
69
+ expect(keyFingerprint(`${KEY}x`)).not.toBe(fp);
70
+ expect(fp).not.toContain(KEY);
71
+ });
72
+ });
73
+
74
+ describe("resolveSyncTtlMs", () => {
75
+ it("defaults to 1 hour", () => {
76
+ expect(resolveSyncTtlMs()).toBe(DEFAULT_SYNC_TTL_MS);
77
+ expect(DEFAULT_SYNC_TTL_MS).toBe(HOUR);
78
+ });
79
+
80
+ it(`honors ${SYNC_TTL_ENV} (0 = always revalidate) and ignores garbage`, () => {
81
+ vi.stubEnv(SYNC_TTL_ENV, "0");
82
+ expect(resolveSyncTtlMs()).toBe(0);
83
+ vi.stubEnv(SYNC_TTL_ENV, "120000");
84
+ expect(resolveSyncTtlMs()).toBe(120000);
85
+ vi.stubEnv(SYNC_TTL_ENV, "-5");
86
+ expect(resolveSyncTtlMs()).toBe(DEFAULT_SYNC_TTL_MS);
87
+ vi.stubEnv(SYNC_TTL_ENV, "soon");
88
+ expect(resolveSyncTtlMs()).toBe(DEFAULT_SYNC_TTL_MS);
89
+ });
90
+
91
+ it("prefers an explicit ttlMs over the env", () => {
92
+ vi.stubEnv(SYNC_TTL_ENV, "0");
93
+ expect(resolveSyncTtlMs(5000)).toBe(5000);
94
+ });
95
+ });
96
+
97
+ describe("planModelSync", () => {
98
+ it("never fetches without a key", () => {
99
+ expect(planModelSync({ cachePath, apiKey: undefined })).toEqual({ fetch: false, reason: "no-key" });
100
+ expect(planModelSync({ cachePath, apiKey: "" })).toEqual({ fetch: false, reason: "no-key" });
101
+ });
102
+
103
+ it("fetches when the cache is missing or corrupt", () => {
104
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "missing" });
105
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
106
+ fs.writeFileSync(cachePath, "{not json");
107
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "missing" });
108
+ writeCache(0, []);
109
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "missing" });
110
+ });
111
+
112
+ it("skips inside the TTL and fetches once it expires", () => {
113
+ writeCache(10 * 60 * 1000);
114
+ writeMeta({ keyFingerprint: keyFingerprint(KEY) });
115
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: false, reason: "fresh" });
116
+ writeCache(2 * HOUR);
117
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "stale" });
118
+ vi.stubEnv(SYNC_TTL_ENV, "0");
119
+ writeCache(0);
120
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "stale" });
121
+ });
122
+
123
+ it("forces a fetch when the key changed or the cache predates the sidecar", () => {
124
+ writeCache(0);
125
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "key-changed" });
126
+ writeMeta({ keyFingerprint: keyFingerprint("old-key") });
127
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "key-changed" });
128
+ });
129
+
130
+ it("forces a fetch on demand (e.g. after /login) regardless of TTL", () => {
131
+ writeCache(0);
132
+ writeMeta({ keyFingerprint: keyFingerprint(KEY) });
133
+ expect(planModelSync({ cachePath, apiKey: KEY, force: true })).toEqual({ fetch: true, reason: "forced" });
134
+ });
135
+
136
+ it("backs off after a recent failure with the same key, unless forced", () => {
137
+ writeCache(2 * HOUR);
138
+ writeMeta({ keyFingerprint: keyFingerprint(KEY), failedAt: Date.now() - 1000, failedKeyFingerprint: keyFingerprint(KEY) });
139
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: false, reason: "backoff" });
140
+ expect(planModelSync({ cachePath, apiKey: KEY, force: true }).fetch).toBe(true);
141
+ // A different key is not held back by another key's failure.
142
+ expect(planModelSync({ cachePath, apiKey: "new-key" })).toEqual({ fetch: true, reason: "key-changed" });
143
+ // Backoff expires.
144
+ writeMeta({ keyFingerprint: keyFingerprint(KEY), failedAt: Date.now() - HOUR, failedKeyFingerprint: keyFingerprint(KEY) });
145
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: true, reason: "stale" });
146
+ });
147
+ });
148
+
149
+ describe("acquireModelSync", () => {
150
+ it("is single-flight: a second caller skips while the lock is held", () => {
151
+ const skipped: string[] = [];
152
+ const first = acquireModelSync({ cachePath, apiKey: KEY });
153
+ expect(first?.reason).toBe("missing");
154
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(true);
155
+ expect(acquireModelSync({ cachePath, apiKey: KEY, onSkip: (r) => skipped.push(r) })).toBeNull();
156
+ expect(skipped).toEqual(["locked"]);
157
+ first!.release("aborted");
158
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(false);
159
+ expect(acquireModelSync({ cachePath, apiKey: KEY })).not.toBeNull();
160
+ });
161
+
162
+ it("skips while another process holds a live lock", () => {
163
+ writeLock(1000);
164
+ const skipped: string[] = [];
165
+ expect(acquireModelSync({ cachePath, apiKey: KEY, onSkip: (r) => skipped.push(r) })).toBeNull();
166
+ expect(skipped).toEqual(["locked"]);
167
+ });
168
+
169
+ it("reclaims a stale lock left by a crashed process", () => {
170
+ writeLock(5 * 60 * 1000);
171
+ const lease = acquireModelSync({ cachePath, apiKey: KEY });
172
+ expect(lease).not.toBeNull();
173
+ lease!.release("aborted");
174
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(false);
175
+ });
176
+
177
+ it("never removes a lock it no longer owns", () => {
178
+ const lease = acquireModelSync({ cachePath, apiKey: KEY, lockStaleMs: 0 })!;
179
+ writeLock(0, "reclaimed-by-another-process");
180
+ lease.release("aborted");
181
+ expect(JSON.parse(fs.readFileSync(syncLockPath(cachePath), "utf8")).token).toBe("reclaimed-by-another-process");
182
+ });
183
+
184
+ it("records the key fingerprint on success (never the key) and the failure on error", () => {
185
+ let lease = acquireModelSync({ cachePath, apiKey: KEY })!;
186
+ writeCache(0);
187
+ lease.release("fetched");
188
+ const raw = fs.readFileSync(syncMetaPath(cachePath), "utf8");
189
+ expect(raw).not.toContain(KEY);
190
+ expect(JSON.parse(raw)).toMatchObject({ version: 1, keyFingerprint: keyFingerprint(KEY) });
191
+ expect(planModelSync({ cachePath, apiKey: KEY })).toEqual({ fetch: false, reason: "fresh" });
192
+
193
+ lease = acquireModelSync({ cachePath, apiKey: KEY, force: true })!;
194
+ lease.release("failed");
195
+ expect(JSON.parse(fs.readFileSync(syncMetaPath(cachePath), "utf8"))).toMatchObject({
196
+ keyFingerprint: keyFingerprint(KEY),
197
+ failedKeyFingerprint: keyFingerprint(KEY),
198
+ });
199
+ });
200
+
201
+ it("does not record a failure for an aborted fetch", () => {
202
+ acquireModelSync({ cachePath, apiKey: KEY })!.release("aborted");
203
+ expect(fs.existsSync(syncMetaPath(cachePath))).toBe(false);
204
+ });
205
+
206
+ it("fails open (null, no throw) when the cache dir is unwritable", () => {
207
+ // A regular file where the cache directory should be: mkdir/open fail even as root.
208
+ fs.writeFileSync(path.join(dir, "cache"), "not a directory");
209
+ const skipped: string[] = [];
210
+ expect(() => acquireModelSync({ cachePath, apiKey: KEY, onSkip: (r) => skipped.push(r) })).not.toThrow();
211
+ expect(acquireModelSync({ cachePath, apiKey: KEY })).toBeNull();
212
+ expect(skipped).toEqual(["unwritable"]);
213
+ });
214
+
215
+ // Real processes: children load sync-policy.ts via Node's built-in type stripping (Node >= 22.18).
216
+ it.skipIf(!(process.features as { typescript?: unknown }).typescript)("lets exactly one of many concurrent processes fetch", async () => {
217
+ writeCache(2 * HOUR);
218
+ writeMeta({ keyFingerprint: keyFingerprint(KEY) });
219
+ const modulePath = fileURLToPath(new URL("../sync-policy.ts", import.meta.url));
220
+ // Each child: try to acquire; the winner "fetches" for 300ms, writes the
221
+ // cache and releases. Late starters must then see a fresh cache.
222
+ const child = `
223
+ const { acquireModelSync, writeFileAtomic } = await import(${JSON.stringify(modulePath)});
224
+ const [cachePath, key] = process.argv.slice(-2);
225
+ const lease = acquireModelSync({ cachePath, apiKey: key });
226
+ if (lease) {
227
+ await new Promise((r) => setTimeout(r, 300));
228
+ writeFileAtomic(cachePath, JSON.stringify([{ id: "fresh" }]));
229
+ lease.release("fetched");
230
+ }
231
+ process.stdout.write(lease ? "fetch" : "skip");
232
+ `;
233
+ const run = () => new Promise<string>((resolve, reject) => {
234
+ execFile(process.execPath, ["--input-type=module", "-e", child, cachePath, KEY], (err, stdout) => err ? reject(err) : resolve(stdout));
235
+ });
236
+ const results = await Promise.all(Array.from({ length: 8 }, run));
237
+ expect(results.filter((r) => r === "fetch")).toHaveLength(1);
238
+ expect(results.filter((r) => r === "skip")).toHaveLength(7);
239
+ expect(fs.existsSync(syncLockPath(cachePath))).toBe(false);
240
+ }, 20000);
241
+ });
242
+
243
+ describe("cache helpers", () => {
244
+ it("writeFileAtomic replaces the file without leaving temp files", () => {
245
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
246
+ writeFileAtomic(cachePath, "[1]");
247
+ writeFileAtomic(cachePath, "[2]");
248
+ expect(fs.readFileSync(cachePath, "utf8")).toBe("[2]");
249
+ expect(fs.readdirSync(path.dirname(cachePath))).toEqual([path.basename(cachePath)]);
250
+ });
251
+
252
+ it("cacheChangedSinceLoad spots a cache rewritten by another process", () => {
253
+ writeCache(HOUR);
254
+ markCacheLoaded(cachePath);
255
+ expect(cacheChangedSinceLoad(cachePath)).toBe(false);
256
+ writeCache(0);
257
+ expect(cacheChangedSinceLoad(cachePath)).toBe(true);
258
+ });
259
+ });
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["tests/**/*.test.ts"],
7
+ clearMocks: true,
8
+ restoreMocks: true,
9
+ },
10
+ });