pi-coralbricks-provider 1.0.31 → 1.0.32
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 +28 -1
- package/guard.ts +155 -0
- package/index.ts +84 -74
- package/package.json +3 -1
- package/settings.ts +17 -5
- package/sync-policy.ts +309 -0
package/README.md
CHANGED
|
@@ -27,7 +27,9 @@ Run `bun run test:pi` for offline manifest, catalog, lifecycle and streaming che
|
|
|
27
27
|
- **OpenAI-compatible API** — standard `/v1/chat/completions`, streaming, and tool calling
|
|
28
28
|
- **Per-family thinking levels** — zai-style `thinking` control for GLM (including a *real* off switch), `reasoning_effort` for DeepSeek V4.1 Flash
|
|
29
29
|
- **Accurate cost tracking** — input, cache-write and output rates mirror Coral's [published pricing](https://www.coralbricks.ai/pricing), and cached reads are **$0 on every model**
|
|
30
|
-
- **Self-healing model sync** — stale-while-revalidate from the authenticated `/v1/models
|
|
30
|
+
- **Self-healing model sync** — stale-while-revalidate from the authenticated `/v1/models`, hot-swapped at session start and after `/login`. Without a key it makes no network request and serves the cached or embedded catalog
|
|
31
|
+
- **Quiet startup sync** — startup never waits on the network, and the catalog is refreshed at most hourly, by one pi process at a time, so `/new`, `pi -p` and fan-out child agents share one cache (see [Model Sync](#model-sync))
|
|
32
|
+
- **Safe to install twice** — when a standalone install and a copy bundled in another Pi package (such as [pi-fabric](https://github.com/fabric-runtime/pi-fabric)) load together, only the newest copy registers the provider (see [Installed more than once](#installed-more-than-once))
|
|
31
33
|
- **synbad-validated** — [synbad](https://github.com/synthetic-lab/synbad) tool-calling and reasoning-parsing evals pass 13/13 in every run on GLM 5.3 in both unary and streaming modes
|
|
32
34
|
|
|
33
35
|
## Installation
|
|
@@ -81,6 +83,12 @@ pi
|
|
|
81
83
|
pi -e /path/to/pi-coralbricks-provider
|
|
82
84
|
```
|
|
83
85
|
|
|
86
|
+
### Installed more than once
|
|
87
|
+
|
|
88
|
+
Another Pi package can bundle this extension; [pi-fabric](https://github.com/fabric-runtime/pi-fabric) does. You can still install it yourself, for example to get a newer version first. When two or more copies load into one Pi session, the newest version registers `coralbricks`, syncs models and serves `/coralbricks-settings`. The other copies register nothing and make no requests, so `/model` lists each model once and `/v1/models` is fetched once. Copies of the same version keep the one that loaded first.
|
|
89
|
+
|
|
90
|
+
The copies find each other through a process-global registry (`Symbol.for("pi-coralbricks-provider.registry.v1")`), scoped to the Pi extension runtime through the event bus Pi shares among that runtime's extensions. The newest copy wins whichever order Pi loads them in. If it loads second, it takes over before the session starts, and Pi serves its registration because a provider registered later under the same id replaces the earlier one. Ownership never moves back, and it ends with the session, so `/reload`, `/new`, `/resume` and `/fork` start from a clean slate. Versions before 1.0.32 do not take part and always register.
|
|
91
|
+
|
|
84
92
|
## Available Models
|
|
85
93
|
|
|
86
94
|
| Model | Context | Vision | Reasoning | Input $/M | Cache Read $/M | Cache Write $/M | Output $/M |
|
|
@@ -186,6 +194,13 @@ With [localterm](https://www.npmjs.com/package/localterm), store it once and it'
|
|
|
186
194
|
localterm secret set coralbricks_api_key
|
|
187
195
|
```
|
|
188
196
|
|
|
197
|
+
### Environment Variables
|
|
198
|
+
|
|
199
|
+
| Variable | Required | Description |
|
|
200
|
+
|----------|----------|-------------|
|
|
201
|
+
| `CORALBRICKS_API_KEY` | No | Your Coral API key (fallback if not in `auth.json` or `--api-key`) |
|
|
202
|
+
| `PI_PROVIDER_SYNC_TTL_MS` | No | Model sync freshness window in ms (default `3600000`; `0` = revalidate every session start) |
|
|
203
|
+
|
|
189
204
|
> Coral Inference is currently in a design-partner program — mint keys at [coralbricks.ai/api-keys](https://www.coralbricks.ai/api-keys). Newly-minted keys may take ~30 seconds to be honored, and `403 access_denied` means the account isn't on the allowlist yet.
|
|
190
205
|
|
|
191
206
|
## Thinking Levels
|
|
@@ -242,6 +257,18 @@ CORALBRICKS_API_KEY=cb_... node ../synbad/dist/source/index.js eval \
|
|
|
242
257
|
--model glm-5.3-fp4 --count 1 --reasoning-effort high
|
|
243
258
|
```
|
|
244
259
|
|
|
260
|
+
## Model Sync
|
|
261
|
+
|
|
262
|
+
At startup the extension serves the last catalog it cached (`~/.pi/agent/cache/coralbricks-models.json`), or the catalog embedded in the package. Once Pi resolves a key (`/login coralbricks`, `auth.json`, `--api-key` or `CORALBRICKS_API_KEY`), it refreshes from the authenticated `/v1/models` at session start and after `/login`, caches the result and swaps it in. With no key it makes no network request at all.
|
|
263
|
+
|
|
264
|
+
Startup never waits on the network: the session-start refresh runs in the background, and only when:
|
|
265
|
+
|
|
266
|
+
- a Coral API key is configured (no key, no request),
|
|
267
|
+
- the cache is older than 1 hour, missing or unreadable, was fetched with a different API key, or was written by a version before this sync policy, and
|
|
268
|
+
- no other pi process is already refreshing it (a `coralbricks-models.lock` file next to the cache).
|
|
269
|
+
|
|
270
|
+
So `/new`, `/reload`, `pi -p` and fan-out child agents share one cache instead of each calling the API; a session that skips the fetch still picks up a cache another process refreshed, and every cache write is atomic. `/login coralbricks` and a changed key always refresh, whatever the cache's age. A failed refresh is retried after 5 minutes at the earliest. `PI_PROVIDER_SYNC_TTL_MS` changes the window (`0` revalidates on every session start). Only a hash fingerprint of the key is stored (`coralbricks-models.sync.json`), never the key itself. When several copies of the extension load (see [Installed more than once](#installed-more-than-once)), only the owning copy touches the lock or the network.
|
|
271
|
+
|
|
245
272
|
## Updating Models
|
|
246
273
|
|
|
247
274
|
Run the update script to fetch the latest models from Coral's API:
|
package/guard.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One provider per Pi runtime, however many copies of this package load.
|
|
3
|
+
*
|
|
4
|
+
* A standalone install (`pi install npm:pi-coralbricks-provider`) and a copy
|
|
5
|
+
* bundled inside another Pi package (pi-fabric) load from separate module
|
|
6
|
+
* roots, so module state is not shared between them. Without coordination both
|
|
7
|
+
* would register `coralbricks`, both would sync models at session start, and
|
|
8
|
+
* both would add `/coralbricks-settings`.
|
|
9
|
+
*
|
|
10
|
+
* Copies coordinate through a process-global registry under
|
|
11
|
+
* `Symbol.for("pi-coralbricks-provider.registry.v1")`. It is scoped per Pi
|
|
12
|
+
* extension runtime: copies discover a shared scope token over the extension
|
|
13
|
+
* event bus Pi gives every extension of one runtime (`pi.events`). Pi drops a
|
|
14
|
+
* runtime's bus subscriptions when it invalidates that runtime (`/reload`,
|
|
15
|
+
* `/new`, `/resume`, `/fork`), so the next generation starts from an empty
|
|
16
|
+
* scope and never inherits a stale owner. Separate runtimes in one process
|
|
17
|
+
* (another SDK session, an isolated loader) get separate scopes.
|
|
18
|
+
*
|
|
19
|
+
* Within a scope the highest version owns the provider; equal versions keep the
|
|
20
|
+
* copy that loaded first. Ownership is decided while extensions load, before
|
|
21
|
+
* Pi binds a session:
|
|
22
|
+
*
|
|
23
|
+
* - The first copy owns the scope and registers the provider.
|
|
24
|
+
* - A later copy that is not newer yields: it registers nothing and never
|
|
25
|
+
* syncs models.
|
|
26
|
+
* - A later, newer copy takes over: the incumbent resigns (its hooks go inert)
|
|
27
|
+
* and the newer copy registers the provider. Pi applies registrations in
|
|
28
|
+
* load order and a native provider with an existing id replaces the previous
|
|
29
|
+
* one, so the newer registration is the one Pi serves.
|
|
30
|
+
*
|
|
31
|
+
* Ownership only ever moves to a strictly newer version, and only at load
|
|
32
|
+
* time, so it cannot flap, and only the owner fetches `/v1/models`. The first
|
|
33
|
+
* copy registers `/coralbricks-settings` once and serves it through whichever
|
|
34
|
+
* copy owns the scope. Ownership ends with the session (`session_shutdown`).
|
|
35
|
+
* Copies older than this protocol do not take part and keep registering.
|
|
36
|
+
*/
|
|
37
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
38
|
+
|
|
39
|
+
const REGISTRY_KEY = Symbol.for("pi-coralbricks-provider.registry.v1");
|
|
40
|
+
const SCOPE_CHANNEL = "pi-coralbricks-provider:scope.v1";
|
|
41
|
+
|
|
42
|
+
/** What one loaded copy offers the registry. Stable across versions (protocol v1). */
|
|
43
|
+
export interface CoralCopy {
|
|
44
|
+
readonly version: string;
|
|
45
|
+
/** A newer copy took over this runtime: stop syncing and serving. */
|
|
46
|
+
resign(): void;
|
|
47
|
+
/** `/coralbricks-settings`, as this copy implements it. */
|
|
48
|
+
settings(args: string, ctx: unknown): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Scope {
|
|
52
|
+
owner?: CoralCopy;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type Registry = WeakMap<object, Scope>;
|
|
56
|
+
|
|
57
|
+
export interface RuntimeClaim {
|
|
58
|
+
/** True while this copy owns the provider in its runtime. */
|
|
59
|
+
owns(): boolean;
|
|
60
|
+
/** True for the first copy in the runtime, which registers the shared command. */
|
|
61
|
+
readonly first: boolean;
|
|
62
|
+
/** The copy that owns the runtime now, if any. */
|
|
63
|
+
owner(): CoralCopy | undefined;
|
|
64
|
+
/** End this copy's ownership (session shutdown). */
|
|
65
|
+
release(): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function registry(): Registry {
|
|
69
|
+
const global = globalThis as typeof globalThis & { [REGISTRY_KEY]?: Registry };
|
|
70
|
+
global[REGISTRY_KEY] ??= new WeakMap();
|
|
71
|
+
return global[REGISTRY_KEY];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseVersion(version: string): { core: number[]; pre: string | undefined } | undefined {
|
|
75
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version.trim());
|
|
76
|
+
if (!match) return undefined;
|
|
77
|
+
return { core: [Number(match[1]), Number(match[2]), Number(match[3])], pre: match[4] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function comparePrerelease(a: string, b: string): number {
|
|
81
|
+
const left = a.split(".");
|
|
82
|
+
const right = b.split(".");
|
|
83
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
84
|
+
if (left[i] === undefined) return -1;
|
|
85
|
+
if (right[i] === undefined) return 1;
|
|
86
|
+
const numericLeft = /^\d+$/.test(left[i]);
|
|
87
|
+
const numericRight = /^\d+$/.test(right[i]);
|
|
88
|
+
if (numericLeft && numericRight) {
|
|
89
|
+
const diff = Number(left[i]) - Number(right[i]);
|
|
90
|
+
if (diff !== 0) return Math.sign(diff);
|
|
91
|
+
} else if (numericLeft !== numericRight) {
|
|
92
|
+
return numericLeft ? -1 : 1;
|
|
93
|
+
} else if (left[i] !== right[i]) {
|
|
94
|
+
return left[i] < right[i] ? -1 : 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Semver precedence; a malformed version sorts below every valid one. */
|
|
101
|
+
export function compareVersions(a: string, b: string): number {
|
|
102
|
+
const left = parseVersion(a);
|
|
103
|
+
const right = parseVersion(b);
|
|
104
|
+
if (!left || !right) return left ? 1 : right ? -1 : 0;
|
|
105
|
+
for (let i = 0; i < 3; i++) {
|
|
106
|
+
const diff = left.core[i] - right.core[i];
|
|
107
|
+
if (diff !== 0) return Math.sign(diff);
|
|
108
|
+
}
|
|
109
|
+
if (left.pre === undefined || right.pre === undefined) {
|
|
110
|
+
return left.pre === right.pre ? 0 : left.pre === undefined ? 1 : -1;
|
|
111
|
+
}
|
|
112
|
+
return comparePrerelease(left.pre, right.pre);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The scope token every copy in this extension runtime shares. */
|
|
116
|
+
function joinScope(pi: ExtensionAPI): { token: object; first: boolean } {
|
|
117
|
+
// A host without an extension event bus gives every copy its own scope.
|
|
118
|
+
if (typeof pi.events?.emit !== "function" || typeof pi.events.on !== "function") return { token: {}, first: true };
|
|
119
|
+
let token: object | undefined;
|
|
120
|
+
// Copies already loaded in this runtime answer synchronously on the bus.
|
|
121
|
+
pi.events.emit(SCOPE_CHANNEL, (found: unknown) => {
|
|
122
|
+
if (!token && typeof found === "object" && found !== null) token = found;
|
|
123
|
+
});
|
|
124
|
+
const first = token === undefined;
|
|
125
|
+
const scope = token ?? {};
|
|
126
|
+
pi.events.on(SCOPE_CHANNEL, (reply: unknown) => {
|
|
127
|
+
if (typeof reply === "function") reply(scope);
|
|
128
|
+
});
|
|
129
|
+
return { token: scope, first };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Decide, while this copy loads, whether it owns the `coralbricks` provider in its runtime. */
|
|
133
|
+
export function claimRuntime(pi: ExtensionAPI, copy: CoralCopy): RuntimeClaim {
|
|
134
|
+
const { token, first } = joinScope(pi);
|
|
135
|
+
const scopes = registry();
|
|
136
|
+
let scope = scopes.get(token);
|
|
137
|
+
if (!scope) {
|
|
138
|
+
scope = {};
|
|
139
|
+
scopes.set(token, scope);
|
|
140
|
+
}
|
|
141
|
+
const incumbent = scope.owner;
|
|
142
|
+
if (!incumbent || compareVersions(copy.version, incumbent.version) > 0) {
|
|
143
|
+
scope.owner = copy;
|
|
144
|
+
incumbent?.resign();
|
|
145
|
+
}
|
|
146
|
+
const current = scope;
|
|
147
|
+
return {
|
|
148
|
+
first,
|
|
149
|
+
owns: () => current.owner === copy,
|
|
150
|
+
owner: () => current.owner,
|
|
151
|
+
release: () => {
|
|
152
|
+
if (current.owner === copy) current.owner = undefined;
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
package/index.ts
CHANGED
|
@@ -7,12 +7,20 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Model resolution strategy: Stale-While-Revalidate
|
|
9
9
|
* 1. Serve stale immediately: disk cache → embedded models.json (zero-latency)
|
|
10
|
-
* 2. Revalidate in background: authenticated /v1/models
|
|
11
|
-
* set + per-million USD pricing)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* 2. Revalidate in background once a key resolves: authenticated /v1/models
|
|
11
|
+
* (authoritative enabled set + per-million USD pricing)
|
|
12
|
+
* → merge with embedded → cache → hot-swap. Without a key nothing is
|
|
13
|
+
* fetched; the stale catalog serves until /login or a key appears.
|
|
14
|
+
* Quiet startup sync (sync-policy.ts): only when the disk cache is older
|
|
15
|
+
* than the TTL (1h, PI_PROVIDER_SYNC_TTL_MS), missing, or fetched with a
|
|
16
|
+
* different key, or Pi forces it (after /login), and only one pi process
|
|
17
|
+
* at a time (lock file next to the cache).
|
|
14
18
|
* 3. patch.json + custom-models.json applied on top of whichever source won
|
|
15
19
|
*
|
|
20
|
+
* When several copies of this package load into one Pi runtime (a standalone
|
|
21
|
+
* install plus a copy bundled in another package), only the newest registers
|
|
22
|
+
* the provider and syncs models (guard.ts).
|
|
23
|
+
*
|
|
16
24
|
* Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
|
|
17
25
|
*
|
|
18
26
|
* Usage:
|
|
@@ -34,13 +42,16 @@ import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-wo
|
|
|
34
42
|
import { lazyStream, type Api, type Credential, type Model, type RefreshModelsContext } from "@earendil-works/pi-ai";
|
|
35
43
|
import type { AssistantMessageEventStream, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
|
|
36
44
|
import { clampThinkingLevel, streamOpenAICompletions, streamOpenAIResponses } from "@earendil-works/pi-ai/compat";
|
|
37
|
-
import { loadConfig,
|
|
45
|
+
import { createSettingsCommand, loadConfig, SETTINGS_COMMAND } from "./settings";
|
|
46
|
+
import { claimRuntime, type CoralCopy } from "./guard";
|
|
38
47
|
import { park, planPark, withParkFallback, type ParkPlan } from "./park";
|
|
39
48
|
import { coralApiKeyAuth, CORALBRICKS_ENV_VAR } from "./auth";
|
|
49
|
+
import { acquireModelSync, cacheChangedSinceLoad, markCacheLoaded, writeFileAtomic } from "./sync-policy";
|
|
40
50
|
import modelsData from "./models.json" with { type: "json" };
|
|
41
51
|
import customModelsData from "./custom-models.json" with { type: "json" };
|
|
42
52
|
import patchData from "./patch.json" with { type: "json" };
|
|
43
53
|
import deprecatedData from "./deprecated-models.json" with { type: "json" };
|
|
54
|
+
import packageData from "./package.json" with { type: "json" };
|
|
44
55
|
import fs from "fs";
|
|
45
56
|
import path from "path";
|
|
46
57
|
|
|
@@ -187,7 +198,7 @@ const PROVIDER_ID = "coralbricks";
|
|
|
187
198
|
const PROVIDER_NAME = "CoralBricks";
|
|
188
199
|
const BASE_URL = "https://inference.coralbricks.ai/v1";
|
|
189
200
|
const MODELS_URL = `${BASE_URL}/models`;
|
|
190
|
-
const
|
|
201
|
+
const PACKAGE_VERSION: string = packageData.version;
|
|
191
202
|
const CACHE_DIR = path.join(getAgentDir(), "cache");
|
|
192
203
|
const CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-models.json`);
|
|
193
204
|
const LIVE_FETCH_TIMEOUT_MS = 8000;
|
|
@@ -274,35 +285,6 @@ function transformApiModel(apiModel: any): JsonModel | null {
|
|
|
274
285
|
return model;
|
|
275
286
|
}
|
|
276
287
|
|
|
277
|
-
/**
|
|
278
|
-
* Transform a row from the public model catalog
|
|
279
|
-
* (https://www.coralbricks.ai/api/public/models) — the no-auth mirror of the
|
|
280
|
-
* gateway control plane. Fields: slug, name, contextWindow ("1M"),
|
|
281
|
-
* inputPerM / outputPerM, and since 2026-10-08 cacheWritePerM (the cache write on
|
|
282
|
-
* top of input). Coral's own prices are authoritative here; the parity/field vendor
|
|
283
|
-
* comparison fields are ignored. A row without cacheWritePerM leaves cacheWrite 0 so
|
|
284
|
-
* mergeWithEmbedded keeps the embedded rate.
|
|
285
|
-
*/
|
|
286
|
-
function transformCatalogModel(entry: any): JsonModel | null {
|
|
287
|
-
if (!entry?.slug) return null;
|
|
288
|
-
const model: JsonModel = {
|
|
289
|
-
id: entry.slug,
|
|
290
|
-
name: typeof entry.name === "string" && entry.name ? entry.name : displayName(entry.slug),
|
|
291
|
-
reasoning: false,
|
|
292
|
-
input: ["text"],
|
|
293
|
-
cost: {
|
|
294
|
-
input: toNumber(entry.inputPerM),
|
|
295
|
-
output: toNumber(entry.outputPerM),
|
|
296
|
-
cacheRead: 0,
|
|
297
|
-
cacheWrite: writtenTokenCost(toNumber(entry.inputPerM), entry.cacheWritePerM),
|
|
298
|
-
},
|
|
299
|
-
contextWindow: parseContextWindow(entry.contextWindow),
|
|
300
|
-
maxTokens: DEFAULT_MAX_TOKENS[entry.slug] ?? FALLBACK_MAX_TOKENS,
|
|
301
|
-
};
|
|
302
|
-
model.compat = baseCompat();
|
|
303
|
-
return model;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
288
|
async function fetchV1Models(apiKey: string, signal?: AbortSignal): Promise<JsonModel[] | null> {
|
|
307
289
|
try {
|
|
308
290
|
const response = await fetch(MODELS_URL, {
|
|
@@ -319,32 +301,14 @@ async function fetchV1Models(apiKey: string, signal?: AbortSignal): Promise<Json
|
|
|
319
301
|
}
|
|
320
302
|
}
|
|
321
303
|
|
|
322
|
-
async function fetchPublicCatalog(signal?: AbortSignal): Promise<JsonModel[] | null> {
|
|
323
|
-
try {
|
|
324
|
-
const response = await fetch(PUBLIC_CATALOG_URL, {
|
|
325
|
-
signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
|
|
326
|
-
});
|
|
327
|
-
if (!response.ok) return null;
|
|
328
|
-
const data = await response.json();
|
|
329
|
-
const entries = Array.isArray(data) ? data : (data.models || []);
|
|
330
|
-
if (!Array.isArray(entries) || entries.length === 0) return null;
|
|
331
|
-
return entries.map(transformCatalogModel).filter((m): m is JsonModel => m !== null);
|
|
332
|
-
} catch {
|
|
333
|
-
return null;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
304
|
/**
|
|
338
|
-
* Live model fetch
|
|
339
|
-
*
|
|
340
|
-
*
|
|
305
|
+
* Live model fetch from the authenticated /v1/models, the exact per-key enabled
|
|
306
|
+
* set. Without a key there is no request: the disk cache, then the embedded
|
|
307
|
+
* models.json, serve until a key resolves (/login, auth.json, the environment).
|
|
341
308
|
*/
|
|
342
309
|
async function fetchLiveModels(apiKey: string | undefined, signal?: AbortSignal): Promise<JsonModel[] | null> {
|
|
343
|
-
if (apiKey)
|
|
344
|
-
|
|
345
|
-
if (fromApi && fromApi.length > 0) return fromApi;
|
|
346
|
-
}
|
|
347
|
-
return fetchPublicCatalog(signal);
|
|
310
|
+
if (!apiKey) return null;
|
|
311
|
+
return fetchV1Models(apiKey, signal);
|
|
348
312
|
}
|
|
349
313
|
|
|
350
314
|
function loadCachedModels(): JsonModel[] | null {
|
|
@@ -359,7 +323,8 @@ function loadCachedModels(): JsonModel[] | null {
|
|
|
359
323
|
function cacheModels(models: JsonModel[]): void {
|
|
360
324
|
try {
|
|
361
325
|
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
362
|
-
|
|
326
|
+
// Atomic (tmp + rename): other pi processes may be reading the cache right now.
|
|
327
|
+
writeFileAtomic(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
|
|
363
328
|
} catch {
|
|
364
329
|
// Cache write failure is non-fatal
|
|
365
330
|
}
|
|
@@ -376,8 +341,8 @@ function mergeWithEmbedded(liveModels: JsonModel[], embeddedModels: JsonModel[])
|
|
|
376
341
|
// Self-heal: live API pricing is authoritative field-by-field. Prefer the
|
|
377
342
|
// live cost when the API reports it (non-zero); fall back to embedded when
|
|
378
343
|
// the API is silent (0). Coral reports cached_input_per_m as 0 because
|
|
379
|
-
// cached reads are free, which matches the curated cacheRead: 0.
|
|
380
|
-
//
|
|
344
|
+
// cached reads are free, which matches the curated cacheRead: 0. A row
|
|
345
|
+
// without a cache-write rate reports 0, which keeps the embedded one.
|
|
381
346
|
// Curation (reasoning/input/compat/name/thinkingLevelMap/maxTokens) still wins via ...embedded.
|
|
382
347
|
result.push({
|
|
383
348
|
...liveModel,
|
|
@@ -433,6 +398,7 @@ function withDeprecated(models: JsonModel[]): JsonModel[] {
|
|
|
433
398
|
}
|
|
434
399
|
|
|
435
400
|
function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
|
|
401
|
+
markCacheLoaded(CACHE_PATH); // stat before read: lets a skipped sync spot newer caches
|
|
436
402
|
const cached = loadCachedModels();
|
|
437
403
|
if (!cached || cached.length === 0) return embeddedModels;
|
|
438
404
|
|
|
@@ -446,12 +412,26 @@ function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
|
|
|
446
412
|
return cached;
|
|
447
413
|
}
|
|
448
414
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
415
|
+
/**
|
|
416
|
+
* Background revalidation, gated by the quiet startup sync policy
|
|
417
|
+
* (sync-policy.ts): no key → no fetch; fresh cache (TTL) → no fetch; another
|
|
418
|
+
* process already fetching → no fetch. When skipped, a cache that another
|
|
419
|
+
* process refreshed since we loaded it is adopted from disk instead.
|
|
420
|
+
* `force` bypasses the TTL (Pi sets it after /login); it is still single-flight.
|
|
421
|
+
*/
|
|
422
|
+
async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal, force = false): Promise<JsonModel[] | null> {
|
|
423
|
+
const lease = acquireModelSync({ cachePath: CACHE_PATH, apiKey, force });
|
|
424
|
+
if (!lease) return cacheChangedSinceLoad(CACHE_PATH) ? loadStaleModels(embeddedModels) : null;
|
|
425
|
+
let merged: JsonModel[] | null = null;
|
|
426
|
+
try {
|
|
427
|
+
const liveModels = await fetchLiveModels(apiKey, signal);
|
|
428
|
+
if (!liveModels || liveModels.length === 0) return null;
|
|
429
|
+
merged = mergeWithEmbedded(liveModels, embeddedModels);
|
|
430
|
+
cacheModels(merged);
|
|
431
|
+
return merged;
|
|
432
|
+
} finally {
|
|
433
|
+
lease.release(merged ? "fetched" : signal?.aborted ? "aborted" : "failed");
|
|
434
|
+
}
|
|
455
435
|
}
|
|
456
436
|
|
|
457
437
|
/** pi-ai's clampOpenAIPromptCacheKey, inlined: Coral accepts the same field. */
|
|
@@ -539,7 +519,6 @@ export function streamCoral(
|
|
|
539
519
|
// API Key Resolution (via ModelRegistry)
|
|
540
520
|
|
|
541
521
|
let cachedApiKey: string | undefined;
|
|
542
|
-
let revalidateAbort: AbortController | null = null;
|
|
543
522
|
|
|
544
523
|
async function resolveApiKey(modelRegistry: ModelRegistry): Promise<void> {
|
|
545
524
|
cachedApiKey = (await modelRegistry.getApiKeyForProvider(PROVIDER_ID)) ?? undefined;
|
|
@@ -569,6 +548,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
569
548
|
const customModels = customModelsData as JsonModel[];
|
|
570
549
|
const patches = patchData as PatchData;
|
|
571
550
|
|
|
551
|
+
let revalidateAbort: AbortController | null = null;
|
|
552
|
+
let settingsCommand: ReturnType<typeof createSettingsCommand> | undefined;
|
|
553
|
+
const copy: CoralCopy = {
|
|
554
|
+
version: PACKAGE_VERSION,
|
|
555
|
+
// A newer copy took this runtime over while extensions loaded; Pi serves its
|
|
556
|
+
// registration instead of ours, and our hooks below go inert.
|
|
557
|
+
resign: () => revalidateAbort?.abort(),
|
|
558
|
+
settings: async (args, ctx) => settingsCommand?.handler(args, ctx as any),
|
|
559
|
+
};
|
|
560
|
+
const claim = claimRuntime(pi, copy);
|
|
561
|
+
// A newer copy, or an equal one that loaded first, already serves this runtime.
|
|
562
|
+
if (!claim.owns()) return;
|
|
563
|
+
|
|
572
564
|
let config = loadConfig();
|
|
573
565
|
let currentModels = buildModels(loadStaleModels(embeddedModels), customModels, patches);
|
|
574
566
|
const selectedApi = () => config.api === "responses" ? "openai-responses" : "openai-completions";
|
|
@@ -580,17 +572,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
580
572
|
// so session-start revalidation stays the one routine fetch.
|
|
581
573
|
let revalidatedKey: string | undefined | null = null;
|
|
582
574
|
let keySync: Promise<void> = Promise.resolve();
|
|
575
|
+
// The in-flight session-start revalidation; it never rejects.
|
|
576
|
+
let sessionSync: Promise<void> = Promise.resolve();
|
|
583
577
|
let modelRegistry: ModelRegistry | undefined;
|
|
584
578
|
|
|
585
579
|
async function refreshModels(context: RefreshModelsContext): Promise<void> {
|
|
586
|
-
if (!context.allowNetwork) return;
|
|
580
|
+
if (!context.allowNetwork || !claim.owns()) return;
|
|
587
581
|
await keySync;
|
|
588
582
|
const apiKey = await refreshApiKey(modelRegistry, context.credential);
|
|
589
583
|
if (context.signal.aborted || (!context.force && apiKey === revalidatedKey)) return;
|
|
590
584
|
revalidatedKey = apiKey;
|
|
591
585
|
cachedApiKey = apiKey;
|
|
592
586
|
revalidateAbort?.abort(); // a newer credential supersedes an in-flight session revalidation
|
|
593
|
-
|
|
587
|
+
await sessionSync; // ...and must release its sync lock first, or this refresh would skip as "locked"
|
|
588
|
+
if (context.signal.aborted) return;
|
|
589
|
+
// Through the quiet-sync lease: a forced refresh (after /login) or a new key
|
|
590
|
+
// skips the TTL; otherwise a fresh disk cache from another process serves.
|
|
591
|
+
const freshBase = claim.owns() ? await revalidateModels(apiKey, embeddedModels, context.signal, context.force) : null;
|
|
594
592
|
if (!freshBase || context.signal.aborted) return;
|
|
595
593
|
const next = buildModels(freshBase, customModels, patches);
|
|
596
594
|
await context.publish({
|
|
@@ -602,6 +600,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
602
600
|
}
|
|
603
601
|
|
|
604
602
|
function registerProvider() {
|
|
603
|
+
if (!claim.owns()) return;
|
|
605
604
|
providerModels = toProviderModels(currentModels, selectedApi());
|
|
606
605
|
// An already-selected model may still carry the previous API. Snapshot
|
|
607
606
|
// the current selection per request, without mutating that model/history.
|
|
@@ -621,20 +620,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
621
620
|
});
|
|
622
621
|
}
|
|
623
622
|
registerProvider();
|
|
624
|
-
|
|
623
|
+
settingsCommand = createSettingsCommand(() => config, (next) => {
|
|
625
624
|
config = next;
|
|
626
625
|
registerProvider();
|
|
627
626
|
});
|
|
627
|
+
// One command per runtime, served by whichever copy owns it when invoked.
|
|
628
|
+
if (claim.first) {
|
|
629
|
+
pi.registerCommand(SETTINGS_COMMAND, {
|
|
630
|
+
description: settingsCommand.description,
|
|
631
|
+
handler: (args, ctx) => (claim.owner() ?? copy).settings(args, ctx),
|
|
632
|
+
});
|
|
633
|
+
}
|
|
628
634
|
|
|
629
635
|
pi.on("session_start", async (_event, ctx) => {
|
|
636
|
+
if (!claim.owns()) return;
|
|
630
637
|
modelRegistry = ctx.modelRegistry;
|
|
631
638
|
revalidateAbort?.abort();
|
|
632
639
|
revalidateAbort = new AbortController();
|
|
633
640
|
const signal = revalidateAbort.signal;
|
|
634
641
|
const resolved = resolveApiKey(ctx.modelRegistry);
|
|
635
642
|
keySync = resolved.then(() => { revalidatedKey = cachedApiKey; }, () => {});
|
|
636
|
-
|
|
637
|
-
|
|
643
|
+
// Fire-and-forget: startup never waits on the network. Only the owning copy
|
|
644
|
+
// reaches the sync lease (and so the lock file and /v1/models).
|
|
645
|
+
sessionSync = resolved
|
|
646
|
+
.then(() => (claim.owns() && !signal.aborted ? revalidateModels(cachedApiKey, embeddedModels, signal) : null))
|
|
638
647
|
.then((freshBase) => {
|
|
639
648
|
if (freshBase && !signal.aborted) {
|
|
640
649
|
currentModels = buildModels(freshBase, customModels, patches);
|
|
@@ -646,7 +655,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
646
655
|
|
|
647
656
|
pi.on("session_shutdown", () => {
|
|
648
657
|
revalidateAbort?.abort();
|
|
658
|
+
claim.release();
|
|
649
659
|
});
|
|
650
660
|
}
|
|
651
661
|
|
|
652
|
-
export { applyPatch, buildModels, mergeWithEmbedded, transformApiModel,
|
|
662
|
+
export { applyPatch, buildModels, mergeWithEmbedded, transformApiModel, activeDeprecatedModels, withDeprecated, parseContextWindow, PROVIDER_ID, BASE_URL };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-coralbricks-provider",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.32",
|
|
4
4
|
"description": "CoralBricks provider extension for pi - Access GLM 5.3 and DeepSeek V4.1 Flash (FP4 variants included) through the Coral Inference API",
|
|
5
5
|
"author": "monotykamary",
|
|
6
6
|
"homepage": "https://github.com/monotykamary/pi-coralbricks-provider#readme",
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
"settings.ts",
|
|
41
41
|
"auth.ts",
|
|
42
42
|
"park.ts",
|
|
43
|
+
"guard.ts",
|
|
44
|
+
"sync-policy.ts",
|
|
43
45
|
"models.json",
|
|
44
46
|
"deprecated-models.json",
|
|
45
47
|
"custom-models.json",
|
package/settings.ts
CHANGED
|
@@ -50,12 +50,16 @@ export function saveApi(api: CoralApi): void {
|
|
|
50
50
|
|
|
51
51
|
const PARK_DESCRIPTION = "Responses only. Stores each response on Coral (store:true) and sends only new items with previous_response_id. Falls back to a full replay when the chain does not match.";
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
type SettingsCommand = Parameters<ExtensionAPI["registerCommand"]>[1];
|
|
54
|
+
|
|
55
|
+
export const SETTINGS_COMMAND = "coralbricks-settings";
|
|
56
|
+
|
|
57
|
+
/** `/coralbricks-settings` for one loaded copy; index.ts registers it once per runtime. */
|
|
58
|
+
export function createSettingsCommand(
|
|
55
59
|
getConfig: () => CoralConfig,
|
|
56
60
|
apply: (config: CoralConfig) => void,
|
|
57
|
-
):
|
|
58
|
-
|
|
61
|
+
): SettingsCommand {
|
|
62
|
+
return {
|
|
59
63
|
description: "Configure CoralBricks API (Chat Completions or opt-in Responses) and Responses parking",
|
|
60
64
|
async handler(_args, ctx) {
|
|
61
65
|
if (!ctx.hasUI) {
|
|
@@ -127,5 +131,13 @@ export function registerSettingsCommand(
|
|
|
127
131
|
};
|
|
128
132
|
});
|
|
129
133
|
},
|
|
130
|
-
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function registerSettingsCommand(
|
|
138
|
+
pi: ExtensionAPI,
|
|
139
|
+
getConfig: () => CoralConfig,
|
|
140
|
+
apply: (config: CoralConfig) => void,
|
|
141
|
+
): void {
|
|
142
|
+
pi.registerCommand(SETTINGS_COMMAND, createSettingsCommand(getConfig, apply));
|
|
131
143
|
}
|
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
|
+
}
|