pi-runinfra-provider 1.2.18 → 1.2.23
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/README.md +18 -5
- package/deprecated-models.json +1 -68
- package/index.ts +34 -12
- package/models.json +6 -6
- package/package.json +6 -3
- package/sync-policy.ts +309 -0
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ _A [pi](https://github.com/earendil-works/pi-coding-agent) provider extension fo
|
|
|
21
21
|
- **Reasoning Models** — Extended thinking via `reasoning_content` field
|
|
22
22
|
- **Reasoning Effort** — Control reasoning depth on DeepSeek V4 (including `max`) and other reasoning models
|
|
23
23
|
- **Request Tracing** — Automatic `X-Client-Request-Id` UUID header on every request for server-side observability
|
|
24
|
+
- **Quiet live model sync** — Models refresh from the RunInfra API in the background, at most hourly and by one pi process at a time ([details](#model-sync))
|
|
24
25
|
|
|
25
26
|
## Installation
|
|
26
27
|
|
|
@@ -69,13 +70,10 @@ pi
|
|
|
69
70
|
|
|
70
71
|
| Model | Context | Vision | Reasoning | Input $/M | Cache Read $/M | Output $/M |
|
|
71
72
|
|-------|---------|--------|-----------|-----------|-----------------|------------|
|
|
72
|
-
| Deepseek V4 1 Flash | 1.0M | ❌ | ❌ | $0.
|
|
73
|
-
|
|
|
74
|
-
| DeepSeek V4 Pro | 1.0M | ❌ | ✅ | $0.60 | — | $1.90 |
|
|
75
|
-
| GLM 5.3 Flash | 1.0M | ✅ | ✅ | $0.08 | $0.02 | $0.33 |
|
|
73
|
+
| Deepseek V4 1 Flash | 1.0M | ❌ | ❌ | $0.14 | $0.03 | $0.58 |
|
|
74
|
+
| GLM 5.3 Flash | 1.0M | ✅ | ✅ | $0.11 | $0.03 | $0.45 |
|
|
76
75
|
| Nemotron 3.5 Lightning 30B | 262K | ❌ | ✅ | $0.05 | $0.01 | $0.15 |
|
|
77
76
|
| Ornith 1.5 35B | 262K | ✅ | ✅ | $0.10 | $0.01 | $0.40 |
|
|
78
|
-
| Qwen3 8 Flash Next | 1.0M | ❌ | ❌ | $0.12 | — | $0.40 |
|
|
79
77
|
| Qwen3.8 27B | 262K | ❌ | ✅ | $0.10 | $0.01 | $0.40 |
|
|
80
78
|
|
|
81
79
|
*Costs are per million tokens. Prices subject to change — check RunInfra for current pricing.*
|
|
@@ -111,6 +109,7 @@ The RunInfra gateway key can be configured in multiple ways (resolved in this or
|
|
|
111
109
|
| Variable | Required | Description |
|
|
112
110
|
|----------|----------|-------------|
|
|
113
111
|
| `RUNINFRA_GATEWAY_KEY` | No | Your RunInfra gateway key (fallback if not in auth.json) |
|
|
112
|
+
| `PI_PROVIDER_SYNC_TTL_MS` | No | Model sync freshness window in ms (default `3600000`; `0` = revalidate every session start) |
|
|
114
113
|
|
|
115
114
|
## Configuration
|
|
116
115
|
|
|
@@ -153,6 +152,20 @@ The `custom-models.json` file contains full model definitions for models that ne
|
|
|
153
152
|
|
|
154
153
|
Merge order: `[live|cache|embedded] → patch.json → custom-models.json`
|
|
155
154
|
|
|
155
|
+
## Model sync
|
|
156
|
+
|
|
157
|
+
Startup never waits on the network. pi starts with the cached catalog (`~/.pi/agent/cache/runinfra-models.json`,
|
|
158
|
+
falling back to the bundled `models.json`) and revalidates against `/v1/models` in the background — only when:
|
|
159
|
+
|
|
160
|
+
- a RunInfra API key is configured (no key, no request),
|
|
161
|
+
- the cache is older than 1 hour, missing, or was fetched with a different API key, and
|
|
162
|
+
- no other pi process is already refreshing it (a `runinfra-models.lock` file next to the cache).
|
|
163
|
+
|
|
164
|
+
So `/new`, `/reload`, `pi -p` and fan-out child agents share one cache instead of each calling the API; a
|
|
165
|
+
session that skips the fetch still picks up a cache another process refreshed. A failed refresh is retried after
|
|
166
|
+
5 minutes at the earliest. `PI_PROVIDER_SYNC_TTL_MS` changes the window (`0` revalidates on every session start).
|
|
167
|
+
Only a hash fingerprint of the key is stored (`runinfra-models.sync.json`), never the key itself.
|
|
168
|
+
|
|
156
169
|
## Updating Models
|
|
157
170
|
|
|
158
171
|
Run the update script to fetch the latest models from RunInfra's API:
|
package/deprecated-models.json
CHANGED
|
@@ -1,68 +1 @@
|
|
|
1
|
-
{
|
|
2
|
-
"deepseek-v4-pro": {
|
|
3
|
-
"id": "deepseek-v4-pro",
|
|
4
|
-
"name": "DeepSeek V4 Pro",
|
|
5
|
-
"reasoning": true,
|
|
6
|
-
"input": [
|
|
7
|
-
"text"
|
|
8
|
-
],
|
|
9
|
-
"cost": {
|
|
10
|
-
"input": 0.6,
|
|
11
|
-
"output": 1.9,
|
|
12
|
-
"cacheRead": 0,
|
|
13
|
-
"cacheWrite": 0
|
|
14
|
-
},
|
|
15
|
-
"contextWindow": 1048576,
|
|
16
|
-
"maxTokens": 1048576,
|
|
17
|
-
"compat": {
|
|
18
|
-
"supportsDeveloperRole": false,
|
|
19
|
-
"supportsStore": false,
|
|
20
|
-
"maxTokensField": "max_tokens"
|
|
21
|
-
},
|
|
22
|
-
"deprecatedAt": "2026-09-29T00:00:21.988Z"
|
|
23
|
-
},
|
|
24
|
-
"qwen3-8-flash-next": {
|
|
25
|
-
"id": "qwen3-8-flash-next",
|
|
26
|
-
"name": "Qwen3 8 Flash Next",
|
|
27
|
-
"reasoning": false,
|
|
28
|
-
"input": [
|
|
29
|
-
"text"
|
|
30
|
-
],
|
|
31
|
-
"cost": {
|
|
32
|
-
"input": 0.12,
|
|
33
|
-
"output": 0.4,
|
|
34
|
-
"cacheRead": 0,
|
|
35
|
-
"cacheWrite": 0
|
|
36
|
-
},
|
|
37
|
-
"contextWindow": 1048576,
|
|
38
|
-
"maxTokens": 1048576,
|
|
39
|
-
"compat": {
|
|
40
|
-
"supportsDeveloperRole": false,
|
|
41
|
-
"supportsStore": false,
|
|
42
|
-
"maxTokensField": "max_tokens"
|
|
43
|
-
},
|
|
44
|
-
"deprecatedAt": "2026-09-29T00:00:21.988Z"
|
|
45
|
-
},
|
|
46
|
-
"deepseek-v4-flash": {
|
|
47
|
-
"id": "deepseek-v4-flash",
|
|
48
|
-
"name": "DeepSeek V4 Flash",
|
|
49
|
-
"reasoning": true,
|
|
50
|
-
"input": [
|
|
51
|
-
"text"
|
|
52
|
-
],
|
|
53
|
-
"cost": {
|
|
54
|
-
"input": 0.13,
|
|
55
|
-
"output": 0.27,
|
|
56
|
-
"cacheRead": 0,
|
|
57
|
-
"cacheWrite": 0
|
|
58
|
-
},
|
|
59
|
-
"contextWindow": 1048576,
|
|
60
|
-
"maxTokens": 1048576,
|
|
61
|
-
"compat": {
|
|
62
|
-
"supportsDeveloperRole": false,
|
|
63
|
-
"supportsStore": false,
|
|
64
|
-
"maxTokensField": "max_tokens"
|
|
65
|
-
},
|
|
66
|
-
"deprecatedAt": "2026-09-30T00:00:19.940Z"
|
|
67
|
-
}
|
|
68
|
-
}
|
|
1
|
+
{}
|
package/index.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* Model resolution strategy: Stale-While-Revalidate
|
|
8
8
|
* 1. Serve stale immediately: disk cache → embedded models.json (zero-latency)
|
|
9
9
|
* 2. Revalidate in background: live API /models → merge with embedded → cache → hot-swap
|
|
10
|
+
* Quiet startup sync (sync-policy.ts): only with an API key, only when the
|
|
11
|
+
* disk cache is older than the TTL (1h, PI_PROVIDER_SYNC_TTL_MS) or the key
|
|
12
|
+
* changed, and only one pi process at a time (lock file next to the cache).
|
|
10
13
|
* 3. patch.json + custom-models.json applied on top of whichever source won
|
|
11
14
|
*
|
|
12
15
|
* Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
|
|
@@ -33,6 +36,7 @@ import patchData from "./patch.json" with { type: "json" };
|
|
|
33
36
|
import deprecatedData from "./deprecated-models.json" with { type: "json" };
|
|
34
37
|
import fs from "fs";
|
|
35
38
|
import path from "path";
|
|
39
|
+
import { acquireModelSync, cacheChangedSinceLoad, markCacheLoaded, writeFileAtomic } from "./sync-policy";
|
|
36
40
|
|
|
37
41
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
38
42
|
|
|
@@ -226,7 +230,8 @@ function loadCachedModels(): JsonModel[] | null {
|
|
|
226
230
|
function cacheModels(models: JsonModel[]): void {
|
|
227
231
|
try {
|
|
228
232
|
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
229
|
-
|
|
233
|
+
// Atomic (tmp + rename): other pi processes may be reading the cache right now.
|
|
234
|
+
writeFileAtomic(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
|
|
230
235
|
} catch {
|
|
231
236
|
// Cache write failure is non-fatal
|
|
232
237
|
}
|
|
@@ -299,6 +304,7 @@ function withDeprecated(models: JsonModel[]): JsonModel[] {
|
|
|
299
304
|
}
|
|
300
305
|
|
|
301
306
|
function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
|
|
307
|
+
markCacheLoaded(CACHE_PATH); // stat before read: lets session_start spot newer caches
|
|
302
308
|
const cached = loadCachedModels();
|
|
303
309
|
if (!cached || cached.length === 0) return embeddedModels;
|
|
304
310
|
|
|
@@ -312,13 +318,26 @@ function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
|
|
|
312
318
|
return cached;
|
|
313
319
|
}
|
|
314
320
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Background revalidation, gated by the quiet startup sync policy
|
|
323
|
+
* (sync-policy.ts): no key → no fetch; fresh cache (TTL) → no fetch; another
|
|
324
|
+
* process already fetching → no fetch. When skipped, a cache that another
|
|
325
|
+
* process refreshed since we loaded it is adopted from disk instead.
|
|
326
|
+
* `force` bypasses the TTL (e.g. after /login); it is still single-flight.
|
|
327
|
+
*/
|
|
328
|
+
async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal, force = false): Promise<JsonModel[] | null> {
|
|
329
|
+
const lease = acquireModelSync({ cachePath: CACHE_PATH, apiKey, force });
|
|
330
|
+
if (!lease) return cacheChangedSinceLoad(CACHE_PATH) ? loadStaleModels(embeddedModels) : null;
|
|
331
|
+
let merged: JsonModel[] | null = null;
|
|
332
|
+
try {
|
|
333
|
+
const liveModels = apiKey ? await fetchLiveModels(apiKey, signal) : null;
|
|
334
|
+
if (!liveModels || liveModels.length === 0) return null;
|
|
335
|
+
merged = mergeWithEmbedded(liveModels, embeddedModels);
|
|
336
|
+
cacheModels(merged);
|
|
337
|
+
return merged;
|
|
338
|
+
} finally {
|
|
339
|
+
lease.release(merged ? "fetched" : signal?.aborted ? "aborted" : "failed");
|
|
340
|
+
}
|
|
322
341
|
}
|
|
323
342
|
|
|
324
343
|
// ─── API Key Resolution (via ModelRegistry) ────────────────────────────────────
|
|
@@ -362,12 +381,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
362
381
|
return payload;
|
|
363
382
|
});
|
|
364
383
|
|
|
384
|
+
// Fire-and-forget: never await network here — startup must not block on the
|
|
385
|
+
// provider API. Nothing is awaited and every rejection is swallowed.
|
|
365
386
|
pi.on("session_start", async (_event, ctx) => {
|
|
366
387
|
revalidateAbort?.abort();
|
|
367
388
|
revalidateAbort = new AbortController();
|
|
368
389
|
const signal = revalidateAbort.signal;
|
|
369
|
-
resolveApiKey(ctx.modelRegistry)
|
|
370
|
-
revalidateModels(cachedApiKey, embeddedModels, signal)
|
|
390
|
+
void resolveApiKey(ctx.modelRegistry)
|
|
391
|
+
.then(() => revalidateModels(cachedApiKey, embeddedModels, signal))
|
|
392
|
+
.then((freshBase) => {
|
|
371
393
|
if (freshBase && !signal.aborted) {
|
|
372
394
|
pi.registerProvider(PROVIDER_ID, {
|
|
373
395
|
baseUrl: BASE_URL,
|
|
@@ -376,8 +398,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
376
398
|
models: buildModels(freshBase, customModels, patches),
|
|
377
399
|
});
|
|
378
400
|
}
|
|
379
|
-
})
|
|
380
|
-
|
|
401
|
+
})
|
|
402
|
+
.catch(() => { /* keep serving the stale catalog */ });
|
|
381
403
|
});
|
|
382
404
|
|
|
383
405
|
pi.on("session_shutdown", () => {
|
package/models.json
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
"text"
|
|
8
8
|
],
|
|
9
9
|
"cost": {
|
|
10
|
-
"input": 0.
|
|
11
|
-
"output": 0.
|
|
12
|
-
"cacheRead": 0.
|
|
10
|
+
"input": 0.14,
|
|
11
|
+
"output": 0.58,
|
|
12
|
+
"cacheRead": 0.03,
|
|
13
13
|
"cacheWrite": 0
|
|
14
14
|
},
|
|
15
15
|
"contextWindow": 1048576,
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
"text"
|
|
29
29
|
],
|
|
30
30
|
"cost": {
|
|
31
|
-
"input": 0.
|
|
32
|
-
"output": 0.
|
|
33
|
-
"cacheRead": 0.
|
|
31
|
+
"input": 0.11,
|
|
32
|
+
"output": 0.45,
|
|
33
|
+
"cacheRead": 0.03,
|
|
34
34
|
"cacheWrite": 0
|
|
35
35
|
},
|
|
36
36
|
"contextWindow": 1048576,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-runinfra-provider",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.23",
|
|
4
4
|
"description": "RunInfra provider extension for pi - Access DeepSeek V4, Nemotron 3.5 Lightning, and Qwen3.8 models through the RunInfra gateway",
|
|
5
5
|
"author": "monotykamary",
|
|
6
6
|
"homepage": "https://github.com/monotykamary/pi-runinfra-provider#readme",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"clean": "echo 'nothing to clean'",
|
|
18
18
|
"build": "echo 'nothing to build'",
|
|
19
|
-
"check": "tsc --noEmit && node scripts/test-pi-compat.mjs",
|
|
19
|
+
"check": "tsc --noEmit && vitest run && node scripts/test-pi-compat.mjs",
|
|
20
|
+
"test": "vitest run",
|
|
20
21
|
"update-models": "node scripts/update-models.js",
|
|
21
22
|
"test:pi": "node scripts/test-pi-compat.mjs",
|
|
22
23
|
"typecheck": "tsc --noEmit"
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
"license": "MIT",
|
|
36
37
|
"files": [
|
|
37
38
|
"index.ts",
|
|
39
|
+
"sync-policy.ts",
|
|
38
40
|
"models.json",
|
|
39
41
|
"deprecated-models.json",
|
|
40
42
|
"custom-models.json",
|
|
@@ -49,7 +51,8 @@
|
|
|
49
51
|
"devDependencies": {
|
|
50
52
|
"@types/node": "^22.20.0",
|
|
51
53
|
"typescript": "6.0.3",
|
|
52
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
54
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
55
|
+
"vitest": "4.1.7"
|
|
53
56
|
},
|
|
54
57
|
"pi": {
|
|
55
58
|
"extensions": [
|
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
|
+
}
|