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 +33 -6
- package/auth.ts +30 -0
- package/guard.ts +155 -0
- package/index.ts +153 -83
- package/package.json +4 -1
- package/settings.ts +17 -5
- package/sync-policy.ts +309 -0
package/README.md
CHANGED
|
@@ -27,7 +27,9 @@ Run `bun run test:pi` for offline manifest, catalog, lifecycle and streaming che
|
|
|
27
27
|
- **OpenAI-compatible API** — standard `/v1/chat/completions`, streaming, and tool calling
|
|
28
28
|
- **Per-family thinking levels** — zai-style `thinking` control for GLM (including a *real* off switch), `reasoning_effort` for DeepSeek V4.1 Flash
|
|
29
29
|
- **Accurate cost tracking** — input, cache-write and output rates mirror Coral's [published pricing](https://www.coralbricks.ai/pricing), and cached reads are **$0 on every model**
|
|
30
|
-
- **Self-healing model sync** — stale-while-revalidate from the authenticated `/v1/models
|
|
30
|
+
- **Self-healing model sync** — stale-while-revalidate from the authenticated `/v1/models`, hot-swapped at session start and after `/login`. Without a key it makes no network request and serves the cached or embedded catalog
|
|
31
|
+
- **Quiet startup sync** — startup never waits on the network, and the catalog is refreshed at most hourly, by one pi process at a time, so `/new`, `pi -p` and fan-out child agents share one cache (see [Model Sync](#model-sync))
|
|
32
|
+
- **Safe to install twice** — when a standalone install and a copy bundled in another Pi package (such as [pi-fabric](https://github.com/fabric-runtime/pi-fabric)) load together, only the newest copy registers the provider (see [Installed more than once](#installed-more-than-once))
|
|
31
33
|
- **synbad-validated** — [synbad](https://github.com/synthetic-lab/synbad) tool-calling and reasoning-parsing evals pass 13/13 in every run on GLM 5.3 in both unary and streaming modes
|
|
32
34
|
|
|
33
35
|
## Installation
|
|
@@ -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:
|
|
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:
|
|
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.
|
|
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
|
|
11
|
-
* set + per-million USD pricing)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* 2. Revalidate in background once a key resolves: authenticated /v1/models
|
|
11
|
+
* (authoritative enabled set + per-million USD pricing)
|
|
12
|
+
* → merge with embedded → cache → hot-swap. Without a key nothing is
|
|
13
|
+
* fetched; the stale catalog serves until /login or a key appears.
|
|
14
|
+
* Quiet startup sync (sync-policy.ts): only when the disk cache is older
|
|
15
|
+
* than the TTL (1h, PI_PROVIDER_SYNC_TTL_MS), missing, or fetched with a
|
|
16
|
+
* different key, or Pi forces it (after /login), and only one pi process
|
|
17
|
+
* at a time (lock file next to the cache).
|
|
14
18
|
* 3. patch.json + custom-models.json applied on top of whichever source won
|
|
15
19
|
*
|
|
20
|
+
* When several copies of this package load into one Pi runtime (a standalone
|
|
21
|
+
* install plus a copy bundled in another package), only the newest registers
|
|
22
|
+
* the provider and syncs models (guard.ts).
|
|
23
|
+
*
|
|
16
24
|
* Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
|
|
17
25
|
*
|
|
18
26
|
* Usage:
|
|
19
|
-
* # Option 1:
|
|
20
|
-
* #
|
|
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,
|
|
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
|
|
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
|
|
336
|
-
*
|
|
337
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
377
|
-
//
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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.
|
|
471
|
-
|
|
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
|
-
|
|
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:
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
577
|
-
|
|
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,
|
|
662
|
+
export { applyPatch, buildModels, mergeWithEmbedded, transformApiModel, activeDeprecatedModels, withDeprecated, parseContextWindow, PROVIDER_ID, BASE_URL };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-coralbricks-provider",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.32",
|
|
4
4
|
"description": "CoralBricks provider extension for pi - Access GLM 5.3 and DeepSeek V4.1 Flash (FP4 variants included) through the Coral Inference API",
|
|
5
5
|
"author": "monotykamary",
|
|
6
6
|
"homepage": "https://github.com/monotykamary/pi-coralbricks-provider#readme",
|
|
@@ -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
|
-
|
|
54
|
-
|
|
53
|
+
type SettingsCommand = Parameters<ExtensionAPI["registerCommand"]>[1];
|
|
54
|
+
|
|
55
|
+
export const SETTINGS_COMMAND = "coralbricks-settings";
|
|
56
|
+
|
|
57
|
+
/** `/coralbricks-settings` for one loaded copy; index.ts registers it once per runtime. */
|
|
58
|
+
export function createSettingsCommand(
|
|
55
59
|
getConfig: () => CoralConfig,
|
|
56
60
|
apply: (config: CoralConfig) => void,
|
|
57
|
-
):
|
|
58
|
-
|
|
61
|
+
): SettingsCommand {
|
|
62
|
+
return {
|
|
59
63
|
description: "Configure CoralBricks API (Chat Completions or opt-in Responses) and Responses parking",
|
|
60
64
|
async handler(_args, ctx) {
|
|
61
65
|
if (!ctx.hasUI) {
|
|
@@ -127,5 +131,13 @@ export function registerSettingsCommand(
|
|
|
127
131
|
};
|
|
128
132
|
});
|
|
129
133
|
},
|
|
130
|
-
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function registerSettingsCommand(
|
|
138
|
+
pi: ExtensionAPI,
|
|
139
|
+
getConfig: () => CoralConfig,
|
|
140
|
+
apply: (config: CoralConfig) => void,
|
|
141
|
+
): void {
|
|
142
|
+
pi.registerCommand(SETTINGS_COMMAND, createSettingsCommand(getConfig, apply));
|
|
131
143
|
}
|
package/sync-policy.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiet startup sync — shared policy for background model-catalog revalidation.
|
|
3
|
+
*
|
|
4
|
+
* Every pi process (interactive start, /new, /reload, `pi -p`, and every child
|
|
5
|
+
* agent a fan-out tool spawns) fires `session_start`. Revalidating the live
|
|
6
|
+
* `/models` catalog on each of those spams the provider API, so this module
|
|
7
|
+
* decides — without any network I/O and without ever throwing — whether *this*
|
|
8
|
+
* process should fetch at all:
|
|
9
|
+
*
|
|
10
|
+
* 1. No credentials → no fetch (serve disk cache → embedded models.json).
|
|
11
|
+
* 2. Freshness window → skip while the disk cache is younger than the TTL.
|
|
12
|
+
* TTL source: the cache file's mtime. The cache is only (re)written after a
|
|
13
|
+
* successful live fetch, so mtime is "last fetched at" — and caches written
|
|
14
|
+
* by older extension versions carry it too, with no format change.
|
|
15
|
+
* Default 1 hour; override with PI_PROVIDER_SYNC_TTL_MS (0 = always).
|
|
16
|
+
* 3. Forced refresh ignores the TTL: cache missing/corrupt, API key changed
|
|
17
|
+
* since the cache was fetched (compared via a salted sha256 fingerprint —
|
|
18
|
+
* never the key itself), or the caller passes `force` (e.g. after /login).
|
|
19
|
+
* 4. Failure backoff → after a failed fetch with the same key, non-forced
|
|
20
|
+
* attempts wait min(5 min, TTL) so a bad key or outage is not retried by
|
|
21
|
+
* every process that starts.
|
|
22
|
+
* 5. Cross-process single-flight → an atomic lock file (`open(…, "wx")`) next
|
|
23
|
+
* to the cache; whoever creates it fetches, everyone else skips and keeps
|
|
24
|
+
* serving the cache. Locks older than the stale timeout (crashed holder)
|
|
25
|
+
* are reclaimed. Any filesystem error fails open to "skip the fetch".
|
|
26
|
+
*
|
|
27
|
+
* Files (for a cache at `<agentDir>/cache/<provider>-models.json`):
|
|
28
|
+
* <provider>-models.json the cache itself — unchanged plain JSON array
|
|
29
|
+
* <provider>-models.sync.json sidecar: { version, keyFingerprint, fetchedAt, failedAt?, failedKeyFingerprint? }
|
|
30
|
+
* <provider>-models.lock present only while one process is fetching
|
|
31
|
+
*
|
|
32
|
+
* Older extension versions ignore the sidecar/lock and read the cache as
|
|
33
|
+
* before; a cache without a sidecar (written by an older version) is treated
|
|
34
|
+
* as "fetched with an unknown key" and revalidated once (still single-flight).
|
|
35
|
+
*
|
|
36
|
+
* This file is provider-agnostic: it is copied verbatim into every
|
|
37
|
+
* pi-*-provider repo. Keep it free of pi imports and provider specifics.
|
|
38
|
+
*
|
|
39
|
+
* Typical use inside a provider's revalidateModels():
|
|
40
|
+
*
|
|
41
|
+
* const lease = acquireModelSync({ cachePath: CACHE_PATH, apiKey, force });
|
|
42
|
+
* if (!lease) return cacheChangedSinceLoad(CACHE_PATH) ? loadStaleModels(embedded) : null;
|
|
43
|
+
* let merged = null;
|
|
44
|
+
* try { …fetch, merge, cacheModels(merged)…; return merged; }
|
|
45
|
+
* finally { lease.release(merged ? "fetched" : signal?.aborted ? "aborted" : "failed"); }
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import crypto from "crypto";
|
|
49
|
+
import fs from "fs";
|
|
50
|
+
import path from "path";
|
|
51
|
+
|
|
52
|
+
/** Env override for the freshness window, shared by all pi-*-provider extensions. */
|
|
53
|
+
export const SYNC_TTL_ENV = "PI_PROVIDER_SYNC_TTL_MS";
|
|
54
|
+
export const DEFAULT_SYNC_TTL_MS = 60 * 60 * 1000;
|
|
55
|
+
/** A lock older than this is assumed abandoned (crashed holder). Must exceed the fetch timeout. */
|
|
56
|
+
export const DEFAULT_LOCK_STALE_MS = 60 * 1000;
|
|
57
|
+
/** Pause after a failed fetch (same key) before non-forced retries; capped at the TTL. */
|
|
58
|
+
export const FAILURE_BACKOFF_MS = 5 * 60 * 1000;
|
|
59
|
+
|
|
60
|
+
/** Why this process was granted the fetch. */
|
|
61
|
+
export type SyncReason = "missing" | "key-changed" | "forced" | "stale";
|
|
62
|
+
/** Why this process must not fetch (serve cache/embedded instead). */
|
|
63
|
+
export type SkipReason = "no-key" | "fresh" | "backoff" | "locked" | "unwritable";
|
|
64
|
+
/** How a granted fetch ended; "aborted" (session replaced/shut down) records no failure. */
|
|
65
|
+
export type SyncOutcome = "fetched" | "failed" | "aborted";
|
|
66
|
+
|
|
67
|
+
export type SyncPlan = { fetch: true; reason: SyncReason } | { fetch: false; reason: SkipReason };
|
|
68
|
+
|
|
69
|
+
export interface ModelSyncOptions {
|
|
70
|
+
/** Absolute path of the provider's disk cache, e.g. `<agentDir>/cache/<provider>-models.json`. */
|
|
71
|
+
cachePath: string;
|
|
72
|
+
/** Resolved credential. Falsy → never fetch. */
|
|
73
|
+
apiKey: string | undefined | null;
|
|
74
|
+
/** Revalidate regardless of TTL/backoff (e.g. right after /login). Still single-flight. */
|
|
75
|
+
force?: boolean;
|
|
76
|
+
/** Freshness window; defaults to $PI_PROVIDER_SYNC_TTL_MS, else 1 hour. */
|
|
77
|
+
ttlMs?: number;
|
|
78
|
+
lockStaleMs?: number;
|
|
79
|
+
/** Cache validity check; defaults to "non-empty JSON array" (the models cache format). */
|
|
80
|
+
isValidCache?: (data: unknown) => boolean;
|
|
81
|
+
/** Clock override for tests. */
|
|
82
|
+
now?: number;
|
|
83
|
+
/** Optional diagnostics hook: called with the reason whenever the fetch is skipped. */
|
|
84
|
+
onSkip?: (reason: SkipReason) => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ModelSyncLease {
|
|
88
|
+
readonly reason: SyncReason;
|
|
89
|
+
/** Always call exactly once (use try/finally). Records the outcome and drops the lock. */
|
|
90
|
+
release(outcome: SyncOutcome): void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface SyncMeta {
|
|
94
|
+
version: 1;
|
|
95
|
+
keyFingerprint?: string;
|
|
96
|
+
fetchedAt?: number;
|
|
97
|
+
failedAt?: number;
|
|
98
|
+
failedKeyFingerprint?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Small helpers ────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/** Salted, truncated sha256 of the key: detects key changes, cannot be reversed into the key. */
|
|
104
|
+
export function keyFingerprint(apiKey: string): string {
|
|
105
|
+
return crypto.createHash("sha256").update(`pi-provider-sync\0${apiKey}`).digest("hex").slice(0, 16);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function resolveSyncTtlMs(ttlMs?: number): number {
|
|
109
|
+
if (typeof ttlMs === "number" && Number.isFinite(ttlMs) && ttlMs >= 0) return ttlMs;
|
|
110
|
+
const raw = process.env[SYNC_TTL_ENV];
|
|
111
|
+
const fromEnv = raw === undefined || raw.trim() === "" ? NaN : Number(raw);
|
|
112
|
+
return Number.isFinite(fromEnv) && fromEnv >= 0 ? fromEnv : DEFAULT_SYNC_TTL_MS;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `foo-models.json` → `foo-models` (sidecar/lock base name). */
|
|
116
|
+
function sidecarBase(cachePath: string): string {
|
|
117
|
+
return cachePath.replace(/\.json$/i, "");
|
|
118
|
+
}
|
|
119
|
+
export const syncMetaPath = (cachePath: string): string => `${sidecarBase(cachePath)}.sync.json`;
|
|
120
|
+
export const syncLockPath = (cachePath: string): string => `${sidecarBase(cachePath)}.lock`;
|
|
121
|
+
|
|
122
|
+
function mtimeMs(file: string): number | null {
|
|
123
|
+
try {
|
|
124
|
+
return fs.statSync(file).mtimeMs;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readJson(file: string): unknown {
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readMeta(cachePath: string): SyncMeta | null {
|
|
139
|
+
const data = readJson(syncMetaPath(cachePath));
|
|
140
|
+
return data && typeof data === "object" && !Array.isArray(data) ? (data as SyncMeta) : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Write via temp file + rename so concurrent readers in other processes never
|
|
145
|
+
* observe a half-written file (a torn cache would otherwise read as "corrupt"
|
|
146
|
+
* and force a refetch). Throws on failure — callers decide whether that is fatal.
|
|
147
|
+
*/
|
|
148
|
+
export function writeFileAtomic(file: string, contents: string): void {
|
|
149
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
150
|
+
try {
|
|
151
|
+
fs.writeFileSync(tmp, contents);
|
|
152
|
+
fs.renameSync(tmp, file);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
try { fs.unlinkSync(tmp); } catch { /* already gone */ }
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function writeMeta(cachePath: string, meta: SyncMeta): void {
|
|
160
|
+
try {
|
|
161
|
+
writeFileAtomic(syncMetaPath(cachePath), JSON.stringify(meta) + "\n");
|
|
162
|
+
} catch {
|
|
163
|
+
// Non-fatal: worst case the next process revalidates once more.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── In-process view of the shared cache ──────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
// mtime of the cache as this process last loaded/wrote it, per cache path.
|
|
170
|
+
const loadedCacheMtimes = new Map<string, number>();
|
|
171
|
+
|
|
172
|
+
/** Call right before reading the disk cache at startup (stat-before-read keeps races harmless). */
|
|
173
|
+
export function markCacheLoaded(cachePath: string): void {
|
|
174
|
+
loadedCacheMtimes.set(cachePath, mtimeMs(cachePath) ?? 0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* True when another process rewrote the shared cache since this process last
|
|
179
|
+
* loaded it, so a long-lived session can adopt the newer catalog from disk
|
|
180
|
+
* instead of fetching it again.
|
|
181
|
+
*/
|
|
182
|
+
export function cacheChangedSinceLoad(cachePath: string): boolean {
|
|
183
|
+
const current = mtimeMs(cachePath);
|
|
184
|
+
return current !== null && current !== loadedCacheMtimes.get(cachePath);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Policy ───────────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
const isNonEmptyArray = (data: unknown): boolean => Array.isArray(data) && data.length > 0;
|
|
190
|
+
|
|
191
|
+
/** Decide whether a fetch is warranted (no lock taken, no I/O beyond local stat/read). */
|
|
192
|
+
export function planModelSync(options: ModelSyncOptions): SyncPlan {
|
|
193
|
+
const { cachePath, apiKey, force = false } = options;
|
|
194
|
+
if (!apiKey) return { fetch: false, reason: "no-key" };
|
|
195
|
+
|
|
196
|
+
const now = options.now ?? Date.now();
|
|
197
|
+
const ttlMs = resolveSyncTtlMs(options.ttlMs);
|
|
198
|
+
const fingerprint = keyFingerprint(apiKey);
|
|
199
|
+
const meta = readMeta(cachePath);
|
|
200
|
+
const cacheMtime = mtimeMs(cachePath);
|
|
201
|
+
const valid = cacheMtime !== null && (options.isValidCache ?? isNonEmptyArray)(readJson(cachePath));
|
|
202
|
+
|
|
203
|
+
let reason: SyncReason;
|
|
204
|
+
if (!valid) reason = "missing";
|
|
205
|
+
// A cache without a sidecar (older extension version) counts as an unknown key.
|
|
206
|
+
else if (meta?.keyFingerprint !== fingerprint) reason = "key-changed";
|
|
207
|
+
else if (force) reason = "forced";
|
|
208
|
+
else if (now - cacheMtime < ttlMs) return { fetch: false, reason: "fresh" };
|
|
209
|
+
else reason = "stale";
|
|
210
|
+
|
|
211
|
+
if (!force && meta?.failedKeyFingerprint === fingerprint && typeof meta.failedAt === "number"
|
|
212
|
+
&& now - meta.failedAt < Math.min(FAILURE_BACKOFF_MS, ttlMs)) {
|
|
213
|
+
return { fetch: false, reason: "backoff" };
|
|
214
|
+
}
|
|
215
|
+
return { fetch: true, reason };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Atomically create the lock file; reclaim it once if its holder looks dead. */
|
|
219
|
+
function tryLock(lockPath: string, token: string, lockStaleMs: number, now: number): "locked" | "held" | "unwritable" {
|
|
220
|
+
try {
|
|
221
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
222
|
+
} catch {
|
|
223
|
+
return "unwritable";
|
|
224
|
+
}
|
|
225
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
226
|
+
let fd: number;
|
|
227
|
+
try {
|
|
228
|
+
fd = fs.openSync(lockPath, "wx");
|
|
229
|
+
} catch (error) {
|
|
230
|
+
// EEXIST: someone holds the lock. Anything else (EACCES, ENOTDIR, EROFS…):
|
|
231
|
+
// the cache dir is unusable — fail open to "skip the fetch".
|
|
232
|
+
if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") return "unwritable";
|
|
233
|
+
if (attempt > 0) return "held";
|
|
234
|
+
const lockMtime = mtimeMs(lockPath);
|
|
235
|
+
if (lockMtime === null) continue; // released between open and stat: retry once
|
|
236
|
+
if (now - lockMtime <= lockStaleMs) return "held"; // a live process is fetching
|
|
237
|
+
// Stale: the holder crashed or hung. Reclaim and retry once. (Two processes
|
|
238
|
+
// reclaiming the same stale lock at the same instant may both fetch — a rare,
|
|
239
|
+
// harmless duplicate.)
|
|
240
|
+
try { fs.unlinkSync(lockPath); } catch { /* someone else reclaimed it first */ }
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
// We own the lock. Record a token so release() only deletes our own lock.
|
|
244
|
+
let wrote = false;
|
|
245
|
+
try {
|
|
246
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, token, at: now }));
|
|
247
|
+
wrote = true;
|
|
248
|
+
} catch {
|
|
249
|
+
// e.g. ENOSPC — handled below
|
|
250
|
+
} finally {
|
|
251
|
+
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
252
|
+
}
|
|
253
|
+
if (wrote) return "locked";
|
|
254
|
+
try { fs.unlinkSync(lockPath); } catch { /* ignore */ }
|
|
255
|
+
return "unwritable";
|
|
256
|
+
}
|
|
257
|
+
return "held";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Decide and, when this process should fetch, take the cross-process lock.
|
|
262
|
+
* Returns null when the caller must skip the network (serve cache/embedded).
|
|
263
|
+
* Never throws.
|
|
264
|
+
*/
|
|
265
|
+
export function acquireModelSync(options: ModelSyncOptions): ModelSyncLease | null {
|
|
266
|
+
try {
|
|
267
|
+
const plan = planModelSync(options);
|
|
268
|
+
if (!plan.fetch) return skip(options, plan.reason);
|
|
269
|
+
|
|
270
|
+
const { cachePath } = options;
|
|
271
|
+
const now = options.now ?? Date.now();
|
|
272
|
+
const lockPath = syncLockPath(cachePath);
|
|
273
|
+
const token = crypto.randomBytes(8).toString("hex");
|
|
274
|
+
const lock = tryLock(lockPath, token, options.lockStaleMs ?? DEFAULT_LOCK_STALE_MS, now);
|
|
275
|
+
if (lock !== "locked") return skip(options, lock === "held" ? "locked" : "unwritable");
|
|
276
|
+
|
|
277
|
+
const fingerprint = keyFingerprint(options.apiKey as string);
|
|
278
|
+
let released = false;
|
|
279
|
+
return {
|
|
280
|
+
reason: plan.reason,
|
|
281
|
+
release(outcome: SyncOutcome): void {
|
|
282
|
+
if (released) return;
|
|
283
|
+
released = true;
|
|
284
|
+
try {
|
|
285
|
+
const at = Date.now();
|
|
286
|
+
if (outcome === "fetched") {
|
|
287
|
+
writeMeta(cachePath, { version: 1, keyFingerprint: fingerprint, fetchedAt: at });
|
|
288
|
+
markCacheLoaded(cachePath); // our own write is not "another process's" update
|
|
289
|
+
} else if (outcome === "failed") {
|
|
290
|
+
const previous = readMeta(cachePath);
|
|
291
|
+
writeMeta(cachePath, { ...previous, version: 1, failedAt: at, failedKeyFingerprint: fingerprint });
|
|
292
|
+
}
|
|
293
|
+
// Only remove the lock if it is still ours (it may have been reclaimed as stale).
|
|
294
|
+
const held = readJson(lockPath) as { token?: string } | undefined;
|
|
295
|
+
if (held?.token === token) fs.unlinkSync(lockPath);
|
|
296
|
+
} catch {
|
|
297
|
+
// Never throw from cleanup; a leftover lock expires after lockStaleMs.
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
} catch {
|
|
302
|
+
return skip(options, "unwritable");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function skip(options: ModelSyncOptions, reason: SkipReason): null {
|
|
307
|
+
try { options.onSkip?.(reason); } catch { /* diagnostics must never break startup */ }
|
|
308
|
+
return null;
|
|
309
|
+
}
|