pi-coralbricks-provider 1.0.30 → 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 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` (or the unauthenticated [public catalog](https://www.coralbricks.ai/api/public/models) before auth), hot-swapped at session start
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
@@ -49,7 +51,7 @@ pi install npm:pi-coralbricks-provider
49
51
  Then set your API key and run pi:
50
52
 
51
53
  ```bash
52
- # Recommended: add to auth.json
54
+ # Recommended: run /login coralbricks inside pi (saves it to auth.json)
53
55
  # See Authentication section below
54
56
 
55
57
  # Or set as environment variable
@@ -69,7 +71,7 @@ pi
69
71
 
70
72
  2. Set your Coral API key:
71
73
  ```bash
72
- # Recommended: add to auth.json
74
+ # Recommended: run /login coralbricks inside pi (saves it to auth.json)
73
75
  # See Authentication section below
74
76
 
75
77
  # Or set as environment variable
@@ -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 |
@@ -172,12 +180,12 @@ saving. If editing the file manually, run `/reload` to apply it.
172
180
 
173
181
  The Coral API key can be configured in multiple ways (resolved in this order):
174
182
 
175
- 1. **`auth.json`** (recommended) — Add to `~/.pi/agent/auth.json`:
183
+ 1. **Runtime override** — Use the `--api-key` CLI flag
184
+ 2. **`auth.json`** (recommended) — Run `/login coralbricks` inside pi and paste your key; pi saves it to `~/.pi/agent/auth.json` and refreshes the model list from the authenticated `/v1/models`. Or add it by hand:
176
185
  ```json
177
186
  { "coralbricks": { "type": "api_key", "key": "cb_your-key" } }
178
187
  ```
179
- The `key` field supports literal values, env var names, and shell commands (prefix with `!`).
180
- 2. **Runtime override** — Use the `--api-key` CLI flag
188
+ The `key` field supports literal values, env var names, and shell commands (prefix with `!`). `/logout` (pick CoralBricks) removes the stored key; `CORALBRICKS_API_KEY` is left alone.
181
189
  3. **Environment variable** — Set `CORALBRICKS_API_KEY`
182
190
 
183
191
  With [localterm](https://www.npmjs.com/package/localterm), store it once and it's exposed everywhere:
@@ -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/auth.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { envApiKeyAuth, type ApiKeyAuth } from "@earendil-works/pi-ai";
2
+
3
+ export const CORALBRICKS_ENV_VAR = "CORALBRICKS_API_KEY";
4
+ export const CORALBRICKS_KEYS_URL = "https://www.coralbricks.ai/api-keys";
5
+
6
+ /**
7
+ * API-key auth for `/login coralbricks`, the pattern Pi's built-in OpenRouter and
8
+ * Vercel AI Gateway providers use (pi-ai `envApiKeyAuth`): a stored auth.json
9
+ * `coralbricks` api_key wins, then CORALBRICKS_API_KEY. Login stores
10
+ * `{ "type": "api_key", "key": ... }` under `coralbricks`, the entry this
11
+ * extension has always read, and `/logout` removes it.
12
+ */
13
+ export function coralApiKeyAuth(): ApiKeyAuth {
14
+ const base = envApiKeyAuth("CoralBricks API key", [CORALBRICKS_ENV_VAR]);
15
+ return {
16
+ ...base,
17
+ login: async (interaction) => {
18
+ interaction.signal.throwIfAborted();
19
+ interaction.notify({
20
+ type: "info",
21
+ message: "Create a Coral Inference API key at https://www.coralbricks.ai (API keys page). New keys can take ~30 seconds to be honored.",
22
+ links: [{ url: CORALBRICKS_KEYS_URL, label: "coralbricks.ai/api-keys" }],
23
+ });
24
+ const key = (await interaction.prompt({ type: "secret", message: "Enter CoralBricks API key", placeholder: "cb_..." })).trim();
25
+ interaction.signal.throwIfAborted();
26
+ if (!key) throw new Error("No CoralBricks API key entered");
27
+ return { type: "api_key", key };
28
+ },
29
+ };
30
+ }
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,17 +7,25 @@
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 (authoritative enabled
11
- * set + per-million USD pricing) with the unauthenticated public catalog
12
- * (https://www.coralbricks.ai/api/public/models) as fallback
13
- * → merge with embedded → cache → hot-swap
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:
19
- * # Option 1: Store in auth.json (recommended)
20
- * # Add to ~/.pi/agent/auth.json:
27
+ * # Option 1: /login coralbricks inside pi (stores the key in auth.json), or
28
+ * # add to ~/.pi/agent/auth.json yourself:
21
29
  * # "coralbricks": { "type": "api_key", "key": "cb_your-key" }
22
30
  *
23
31
  * # Option 2: Set as environment variable
@@ -31,14 +39,19 @@
31
39
  */
32
40
 
33
41
  import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-works/pi-coding-agent";
42
+ import { lazyStream, type Api, type Credential, type Model, type RefreshModelsContext } from "@earendil-works/pi-ai";
34
43
  import type { AssistantMessageEventStream, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
35
44
  import { clampThinkingLevel, streamOpenAICompletions, streamOpenAIResponses } from "@earendil-works/pi-ai/compat";
36
- import { loadConfig, registerSettingsCommand } from "./settings";
45
+ import { createSettingsCommand, loadConfig, SETTINGS_COMMAND } from "./settings";
46
+ import { claimRuntime, type CoralCopy } from "./guard";
37
47
  import { park, planPark, withParkFallback, type ParkPlan } from "./park";
48
+ import { coralApiKeyAuth, CORALBRICKS_ENV_VAR } from "./auth";
49
+ import { acquireModelSync, cacheChangedSinceLoad, markCacheLoaded, writeFileAtomic } from "./sync-policy";
38
50
  import modelsData from "./models.json" with { type: "json" };
39
51
  import customModelsData from "./custom-models.json" with { type: "json" };
40
52
  import patchData from "./patch.json" with { type: "json" };
41
53
  import deprecatedData from "./deprecated-models.json" with { type: "json" };
54
+ import packageData from "./package.json" with { type: "json" };
42
55
  import fs from "fs";
43
56
  import path from "path";
44
57
 
@@ -182,9 +195,10 @@ function buildModels(base: JsonModel[], custom: JsonModel[], patch: PatchData):
182
195
  // Stale-While-Revalidate Model Sync
183
196
 
184
197
  const PROVIDER_ID = "coralbricks";
198
+ const PROVIDER_NAME = "CoralBricks";
185
199
  const BASE_URL = "https://inference.coralbricks.ai/v1";
186
200
  const MODELS_URL = `${BASE_URL}/models`;
187
- const PUBLIC_CATALOG_URL = "https://www.coralbricks.ai/api/public/models";
201
+ const PACKAGE_VERSION: string = packageData.version;
188
202
  const CACHE_DIR = path.join(getAgentDir(), "cache");
189
203
  const CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-models.json`);
190
204
  const LIVE_FETCH_TIMEOUT_MS = 8000;
@@ -271,35 +285,6 @@ function transformApiModel(apiModel: any): JsonModel | null {
271
285
  return model;
272
286
  }
273
287
 
274
- /**
275
- * Transform a row from the public model catalog
276
- * (https://www.coralbricks.ai/api/public/models) — the no-auth mirror of the
277
- * gateway control plane. Fields: slug, name, contextWindow ("1M"),
278
- * inputPerM / outputPerM, and since 2026-10-08 cacheWritePerM (the cache write on
279
- * top of input). Coral's own prices are authoritative here; the parity/field vendor
280
- * comparison fields are ignored. A row without cacheWritePerM leaves cacheWrite 0 so
281
- * mergeWithEmbedded keeps the embedded rate.
282
- */
283
- function transformCatalogModel(entry: any): JsonModel | null {
284
- if (!entry?.slug) return null;
285
- const model: JsonModel = {
286
- id: entry.slug,
287
- name: typeof entry.name === "string" && entry.name ? entry.name : displayName(entry.slug),
288
- reasoning: false,
289
- input: ["text"],
290
- cost: {
291
- input: toNumber(entry.inputPerM),
292
- output: toNumber(entry.outputPerM),
293
- cacheRead: 0,
294
- cacheWrite: writtenTokenCost(toNumber(entry.inputPerM), entry.cacheWritePerM),
295
- },
296
- contextWindow: parseContextWindow(entry.contextWindow),
297
- maxTokens: DEFAULT_MAX_TOKENS[entry.slug] ?? FALLBACK_MAX_TOKENS,
298
- };
299
- model.compat = baseCompat();
300
- return model;
301
- }
302
-
303
288
  async function fetchV1Models(apiKey: string, signal?: AbortSignal): Promise<JsonModel[] | null> {
304
289
  try {
305
290
  const response = await fetch(MODELS_URL, {
@@ -316,32 +301,14 @@ async function fetchV1Models(apiKey: string, signal?: AbortSignal): Promise<Json
316
301
  }
317
302
  }
318
303
 
319
- async function fetchPublicCatalog(signal?: AbortSignal): Promise<JsonModel[] | null> {
320
- try {
321
- const response = await fetch(PUBLIC_CATALOG_URL, {
322
- signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
323
- });
324
- if (!response.ok) return null;
325
- const data = await response.json();
326
- const entries = Array.isArray(data) ? data : (data.models || []);
327
- if (!Array.isArray(entries) || entries.length === 0) return null;
328
- return entries.map(transformCatalogModel).filter((m): m is JsonModel => m !== null);
329
- } catch {
330
- return null;
331
- }
332
- }
333
-
334
304
  /**
335
- * Live model fetch. The authenticated /v1/models is authoritative (it is the
336
- * exact per-key enabled set); the public catalog is the unauthenticated
337
- * fallback so model discovery works before a key is configured.
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).
338
308
  */
339
309
  async function fetchLiveModels(apiKey: string | undefined, signal?: AbortSignal): Promise<JsonModel[] | null> {
340
- if (apiKey) {
341
- const fromApi = await fetchV1Models(apiKey, signal);
342
- if (fromApi && fromApi.length > 0) return fromApi;
343
- }
344
- return fetchPublicCatalog(signal);
310
+ if (!apiKey) return null;
311
+ return fetchV1Models(apiKey, signal);
345
312
  }
346
313
 
347
314
  function loadCachedModels(): JsonModel[] | null {
@@ -356,7 +323,8 @@ function loadCachedModels(): JsonModel[] | null {
356
323
  function cacheModels(models: JsonModel[]): void {
357
324
  try {
358
325
  fs.mkdirSync(CACHE_DIR, { recursive: true });
359
- fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
326
+ // Atomic (tmp + rename): other pi processes may be reading the cache right now.
327
+ writeFileAtomic(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
360
328
  } catch {
361
329
  // Cache write failure is non-fatal
362
330
  }
@@ -373,8 +341,8 @@ function mergeWithEmbedded(liveModels: JsonModel[], embeddedModels: JsonModel[])
373
341
  // Self-heal: live API pricing is authoritative field-by-field. Prefer the
374
342
  // live cost when the API reports it (non-zero); fall back to embedded when
375
343
  // the API is silent (0). Coral reports cached_input_per_m as 0 because
376
- // cached reads are free, which matches the curated cacheRead: 0. The
377
- // public catalog has no cache-write rate, so its 0 keeps the embedded one.
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.
378
346
  // Curation (reasoning/input/compat/name/thinkingLevelMap/maxTokens) still wins via ...embedded.
379
347
  result.push({
380
348
  ...liveModel,
@@ -430,6 +398,7 @@ function withDeprecated(models: JsonModel[]): JsonModel[] {
430
398
  }
431
399
 
432
400
  function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
401
+ markCacheLoaded(CACHE_PATH); // stat before read: lets a skipped sync spot newer caches
433
402
  const cached = loadCachedModels();
434
403
  if (!cached || cached.length === 0) return embeddedModels;
435
404
 
@@ -443,12 +412,26 @@ function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
443
412
  return cached;
444
413
  }
445
414
 
446
- async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal): Promise<JsonModel[] | null> {
447
- const liveModels = await fetchLiveModels(apiKey, signal);
448
- if (!liveModels || liveModels.length === 0) return null;
449
- const merged = mergeWithEmbedded(liveModels, embeddedModels);
450
- cacheModels(merged);
451
- return merged;
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
+ }
452
435
  }
453
436
 
454
437
  /** pi-ai's clampOpenAIPromptCacheKey, inlined: Coral accepts the same field. */
@@ -467,8 +450,8 @@ export function streamCoral(
467
450
  const apiKey = options?.apiKey || cachedApiKey || "";
468
451
  if (!apiKey) {
469
452
  throw new Error(
470
- `No API key for CoralBricks. Add it to ~/.pi/agent/auth.json ` +
471
- `("coralbricks"), set CORALBRICKS_API_KEY, or use --api-key.`,
453
+ `No API key for CoralBricks. Run /login coralbricks, add it to ` +
454
+ `~/.pi/agent/auth.json ("coralbricks"), set CORALBRICKS_API_KEY, or use --api-key.`,
472
455
  );
473
456
  }
474
457
 
@@ -536,12 +519,28 @@ export function streamCoral(
536
519
  // API Key Resolution (via ModelRegistry)
537
520
 
538
521
  let cachedApiKey: string | undefined;
539
- let revalidateAbort: AbortController | null = null;
540
522
 
541
523
  async function resolveApiKey(modelRegistry: ModelRegistry): Promise<void> {
542
524
  cachedApiKey = (await modelRegistry.getApiKeyForProvider(PROVIDER_ID)) ?? undefined;
543
525
  }
544
526
 
527
+ /**
528
+ * The chat models Pi composes from a `pi.registerProvider(name, config)` catalog:
529
+ * each row bound to this provider's id, endpoint and selected API. The native
530
+ * registration below serves exactly these, so ids, pricing, thinking maps and
531
+ * compat flags reach Pi unchanged.
532
+ */
533
+ function toProviderModels(models: JsonModel[], api: Api): Model<Api>[] {
534
+ return models.map((model) => ({ ...model, api, provider: PROVIDER_ID, baseUrl: BASE_URL, headers: undefined }) as unknown as Model<Api>);
535
+ }
536
+
537
+ /** The key a refresh should fetch with: Pi's own resolution when a session exposed it. */
538
+ async function refreshApiKey(modelRegistry: ModelRegistry | undefined, credential: Credential | undefined): Promise<string | undefined> {
539
+ if (modelRegistry) return (await modelRegistry.getApiKeyForProvider(PROVIDER_ID)) ?? undefined;
540
+ if (credential?.type === "api_key" && credential.key) return credential.key;
541
+ return process.env[CORALBRICKS_ENV_VAR] || undefined;
542
+ }
543
+
545
544
  // Extension Entry Point
546
545
 
547
546
  export default function (pi: ExtensionAPI) {
@@ -549,32 +548,102 @@ export default function (pi: ExtensionAPI) {
549
548
  const customModels = customModelsData as JsonModel[];
550
549
  const patches = patchData as PatchData;
551
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
+
552
564
  let config = loadConfig();
553
565
  let currentModels = buildModels(loadStaleModels(embeddedModels), customModels, patches);
554
566
  const selectedApi = () => config.api === "responses" ? "openai-responses" : "openai-completions";
567
+ let providerModels = toProviderModels(currentModels, selectedApi());
568
+
569
+ // The key the catalog was last revalidated with (null: not yet this process).
570
+ // Pi refreshes catalogs on startup, on /model and after /login; only a changed
571
+ // key (a fresh /login, --api-key) or a forced refresh re-fetches /v1/models,
572
+ // so session-start revalidation stays the one routine fetch.
573
+ let revalidatedKey: string | undefined | null = null;
574
+ let keySync: Promise<void> = Promise.resolve();
575
+ // The in-flight session-start revalidation; it never rejects.
576
+ let sessionSync: Promise<void> = Promise.resolve();
577
+ let modelRegistry: ModelRegistry | undefined;
578
+
579
+ async function refreshModels(context: RefreshModelsContext): Promise<void> {
580
+ if (!context.allowNetwork || !claim.owns()) return;
581
+ await keySync;
582
+ const apiKey = await refreshApiKey(modelRegistry, context.credential);
583
+ if (context.signal.aborted || (!context.force && apiKey === revalidatedKey)) return;
584
+ revalidatedKey = apiKey;
585
+ cachedApiKey = apiKey;
586
+ revalidateAbort?.abort(); // a newer credential supersedes an in-flight session revalidation
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;
592
+ if (!freshBase || context.signal.aborted) return;
593
+ const next = buildModels(freshBase, customModels, patches);
594
+ await context.publish({
595
+ update: () => {
596
+ currentModels = next;
597
+ providerModels = toProviderModels(next, selectedApi());
598
+ },
599
+ });
600
+ }
601
+
555
602
  function registerProvider() {
556
- pi.registerProvider(PROVIDER_ID, {
603
+ if (!claim.owns()) return;
604
+ providerModels = toProviderModels(currentModels, selectedApi());
605
+ // An already-selected model may still carry the previous API. Snapshot
606
+ // the current selection per request, without mutating that model/history.
607
+ const stream = (model: Model<any>, context: any, options?: any) =>
608
+ lazyStream(model, async () => streamCoral({ ...model, api: selectedApi() }, context, options, { park: config.park }));
609
+ // Native provider (pi-ai `Provider`): the same catalog and stream as the
610
+ // config form, plus API-key auth with a `/login coralbricks` flow.
611
+ pi.registerProvider({
612
+ id: PROVIDER_ID,
613
+ name: PROVIDER_NAME,
557
614
  baseUrl: BASE_URL,
558
- apiKey: "$CORALBRICKS_API_KEY",
559
- api: selectedApi(),
560
- models: currentModels,
561
- // An already-selected model may still carry the previous API. Snapshot
562
- // the current selection per request, without mutating that model/history.
563
- streamSimple: (model, context, options) => streamCoral({ ...model, api: selectedApi() }, context, options, { park: config.park }),
615
+ auth: { apiKey: coralApiKeyAuth() },
616
+ getModels: () => providerModels,
617
+ refreshModels,
618
+ stream,
619
+ streamSimple: stream,
564
620
  });
565
621
  }
566
622
  registerProvider();
567
- registerSettingsCommand(pi, () => config, (next) => {
623
+ settingsCommand = createSettingsCommand(() => config, (next) => {
568
624
  config = next;
569
625
  registerProvider();
570
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
+ }
571
634
 
572
635
  pi.on("session_start", async (_event, ctx) => {
636
+ if (!claim.owns()) return;
637
+ modelRegistry = ctx.modelRegistry;
573
638
  revalidateAbort?.abort();
574
639
  revalidateAbort = new AbortController();
575
640
  const signal = revalidateAbort.signal;
576
- void resolveApiKey(ctx.modelRegistry)
577
- .then(() => revalidateModels(cachedApiKey, embeddedModels, signal))
641
+ const resolved = resolveApiKey(ctx.modelRegistry);
642
+ keySync = resolved.then(() => { revalidatedKey = cachedApiKey; }, () => {});
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))
578
647
  .then((freshBase) => {
579
648
  if (freshBase && !signal.aborted) {
580
649
  currentModels = buildModels(freshBase, customModels, patches);
@@ -586,7 +655,8 @@ export default function (pi: ExtensionAPI) {
586
655
 
587
656
  pi.on("session_shutdown", () => {
588
657
  revalidateAbort?.abort();
658
+ claim.release();
589
659
  });
590
660
  }
591
661
 
592
- export { applyPatch, buildModels, mergeWithEmbedded, transformApiModel, transformCatalogModel, activeDeprecatedModels, withDeprecated, parseContextWindow, PROVIDER_ID, BASE_URL };
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.30",
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",
@@ -38,7 +38,10 @@
38
38
  "files": [
39
39
  "index.ts",
40
40
  "settings.ts",
41
+ "auth.ts",
41
42
  "park.ts",
43
+ "guard.ts",
44
+ "sync-policy.ts",
42
45
  "models.json",
43
46
  "deprecated-models.json",
44
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
- export function registerSettingsCommand(
54
- pi: ExtensionAPI,
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
- ): void {
58
- pi.registerCommand("coralbricks-settings", {
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
+ }