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 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 and after `/login`
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 (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:
@@ -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, registerSettingsCommand } from "./settings";
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 PUBLIC_CATALOG_URL = "https://www.coralbricks.ai/api/public/models";
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. The authenticated /v1/models is authoritative (it is the
339
- * exact per-key enabled set); the public catalog is the unauthenticated
340
- * 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).
341
308
  */
342
309
  async function fetchLiveModels(apiKey: string | undefined, signal?: AbortSignal): Promise<JsonModel[] | null> {
343
- if (apiKey) {
344
- const fromApi = await fetchV1Models(apiKey, signal);
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
- 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");
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. The
380
- // 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.
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
- async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal): Promise<JsonModel[] | null> {
450
- const liveModels = await fetchLiveModels(apiKey, signal);
451
- if (!liveModels || liveModels.length === 0) return null;
452
- const merged = mergeWithEmbedded(liveModels, embeddedModels);
453
- cacheModels(merged);
454
- 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
+ }
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
- const freshBase = await revalidateModels(apiKey, embeddedModels, context.signal);
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
- registerSettingsCommand(pi, () => config, (next) => {
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
- void resolved
637
- .then(() => revalidateModels(cachedApiKey, embeddedModels, signal))
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, 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.31",
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
- 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
+ }