pi-model-sync 0.1.0
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/LICENSE +21 -0
- package/README.md +199 -0
- package/index.js +163 -0
- package/lib/cache.js +96 -0
- package/lib/decode.js +45 -0
- package/lib/discover.js +399 -0
- package/lib/modelsdev.js +357 -0
- package/lib/store.js +326 -0
- package/lib/sync.js +484 -0
- package/lib/thinking.js +115 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AdityaVG13
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# pi-model-sync
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/pi-model-sync)
|
|
4
|
+
[](https://github.com/AdityaVG13/pi-stack/blob/main/packages/pi-model-sync/LICENSE)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
[](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md)
|
|
7
|
+
|
|
8
|
+
`/model-sync` keeps Pi's model catalog fresh. Pi ships static per-provider
|
|
9
|
+
model seeds and can refresh Pi's curated catalog. This extension additionally
|
|
10
|
+
checks live lists exposed by configured providers, including custom gateways
|
|
11
|
+
and native extension providers. Legacy extensions with explicit model lists
|
|
12
|
+
keep ownership of those lists and are reported as skipped. Providers whose
|
|
13
|
+
composed models mix discovery families (Copilot, Fireworks) are skipped rather
|
|
14
|
+
than stamping one guessed `api` onto new ids. It walks providers
|
|
15
|
+
represented in Pi's composed model registry, lists each live catalog with that
|
|
16
|
+
provider's own credentials, enriches from models.dev, and writes missing models
|
|
17
|
+
to `models.json`.
|
|
18
|
+
|
|
19
|
+
Pi only. OMP already has `@oh-my-pi/pi-catalog`.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pi install npm:pi-model-sync
|
|
23
|
+
# from a checkout:
|
|
24
|
+
pi install ./packages/pi-model-sync
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Use
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
/model-sync sync all logged-in providers
|
|
31
|
+
/model-sync <provider> sync one provider only
|
|
32
|
+
/model-sync --dry-run report only; models.json untouched
|
|
33
|
+
/model-sync --refresh refetch models.dev now; ignore the 24h cache
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Example report:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
model-sync: 12 providers
|
|
40
|
+
models.dev: cache hit (3h old)
|
|
41
|
+
anthropic: skipped (list failed (HTTP 401))
|
|
42
|
+
vercel-ai-gateway: +1 ~0 -0 =0 (390 live)
|
|
43
|
+
! vercel-ai-gateway/stealth/pixel-canary: prompts may be retained for training
|
|
44
|
+
+added ~updated -removed =kept (1 added, 0 updated, 0 removed)
|
|
45
|
+
backup: /Users/you/.pi/agent/models.json.bak-20260926T023000Z
|
|
46
|
+
wrote /Users/you/.pi/agent/models.json
|
|
47
|
+
catalog live now; no restart needed
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## How it works
|
|
51
|
+
|
|
52
|
+
1. **Enumerate** providers from Pi's composed registry (built-ins and
|
|
53
|
+
extensions alike: anything with models and credentials).
|
|
54
|
+
2. **Discover** each live catalog with the provider's resolved auth.
|
|
55
|
+
Request shape follows the provider's API family (OpenAI, Anthropic,
|
|
56
|
+
Google, Ollama); unknown families try each shape in turn. Bare and
|
|
57
|
+
versioned base URLs are both normalized to the right list path.
|
|
58
|
+
Paginating families (Anthropic cursors, Google page tokens) are walked
|
|
59
|
+
to the end. A later-page transport, shape, or cursor failure is an
|
|
60
|
+
incomplete catalog: the provider is skipped and no other API family is
|
|
61
|
+
probed, so a partial first page never authorizes pruning. OpenAI-family
|
|
62
|
+
lists that advertise `has_more` without a paginating family are skipped
|
|
63
|
+
the same way. Extra auth headers merge case-insensitively; the project
|
|
64
|
+
User-Agent always wins.
|
|
65
|
+
3. **Enrich** from models.dev (context, costs, modalities, reasoning
|
|
66
|
+
options). Live endpoint metadata wins when richer; models.dev fills the
|
|
67
|
+
rest. The catalog is cached for 24h in
|
|
68
|
+
`pi-model-sync-models-dev.json` next to models.json, so repeat runs cost
|
|
69
|
+
no download; `--refresh` refetches on demand. If models.dev is
|
|
70
|
+
unreachable the run falls back to any cached copy (fresh or stale),
|
|
71
|
+
else continues live-only.
|
|
72
|
+
4. **Merge** into the latest `models.json`, re-read after discovery so edits
|
|
73
|
+
completed during discovery survive. A corrupt file aborts before any
|
|
74
|
+
network work as well. Synced entries are stamped
|
|
75
|
+
`_managedBy: "pi-model-sync"` and include configured `api`/`baseUrl`
|
|
76
|
+
defaults so Pi can compose new models for extension providers too. Resolved
|
|
77
|
+
request credentials and request-time URL overrides are not persisted.
|
|
78
|
+
Hand-written fields and values stay intact. Delisted models are pruned only
|
|
79
|
+
after a successful, complete, non-empty discovery. An orphan sweep checks
|
|
80
|
+
the current registry (including registered ids with no composed models),
|
|
81
|
+
not the pre-discovery snapshot.
|
|
82
|
+
5. **Reload** the local file via `modelRegistry.refresh({allowNetwork:false})`.
|
|
83
|
+
A thrown refresh, reported registry error, or missing discovered model is a
|
|
84
|
+
failed activation, never a claim that the catalog is live. Successful discovery
|
|
85
|
+
reloads even when the file is unchanged, so retrying a failed activation does
|
|
86
|
+
not require another file edit. Hosts without refresh get a restart note.
|
|
87
|
+
|
|
88
|
+
Discovery failures are isolated per provider as `skipped (reason)` lines.
|
|
89
|
+
Skip text is locally generated (`DiscoveryError` / `Skip`); raw auth and
|
|
90
|
+
transport exception messages never reach the report.
|
|
91
|
+
`models.json` is backed up with an exclusive, timestamped `.bak-` copy before
|
|
92
|
+
publication. A complete, flushed temporary file is renamed into place; partial
|
|
93
|
+
writes leave the previous file intact. Existing symlinks are followed rather
|
|
94
|
+
than replaced; dangling links abort. File permissions are preserved.
|
|
95
|
+
`--dry-run` previews without writing the model file or cache.
|
|
96
|
+
|
|
97
|
+
The final read/merge/write does not yield within this process. There is no
|
|
98
|
+
cross-process lock or external-writer compare-and-swap: an external write in
|
|
99
|
+
that final window can still race. Do not run concurrent external writers when
|
|
100
|
+
you need lossless coordination.
|
|
101
|
+
|
|
102
|
+
## Public surface
|
|
103
|
+
|
|
104
|
+
- `/model-sync [--dry-run] [--refresh] [provider]` slash command.
|
|
105
|
+
- `lib/` modules (`discover`, `modelsdev`, `cache`, `thinking`, `store`,
|
|
106
|
+
`sync`, plus shared `decode` helpers) are hermetically tested; I/O
|
|
107
|
+
boundaries (`fetchImpl`, `fs`, registry) are injected, never imported.
|
|
108
|
+
- `MODELSYNC_MODELS_PATH` overrides the models.json location (also the test
|
|
109
|
+
seam); otherwise the host `getModelsPath()`, `PI_CODING_AGENT_DIR`, or
|
|
110
|
+
`~/.pi/agent/models.json`.
|
|
111
|
+
|
|
112
|
+
## Invariants
|
|
113
|
+
|
|
114
|
+
- Live ids already composed in the registry (builtin or extension seeds) are
|
|
115
|
+
not written as models.json overlays; Pi would replace the curated definition.
|
|
116
|
+
File-resident managed entries still refresh in place.
|
|
117
|
+
- Managed **chat** entries (`_managedBy`, with absent `type` or `type: "chat"`)
|
|
118
|
+
are the only ones the sync updates or removes. Identity is chat type plus ID,
|
|
119
|
+
not ID alone. Image, classifier and unknown operation types are preserved,
|
|
120
|
+
even when tagged or sharing a chat ID. Hand-stamping adopts only chat entries.
|
|
121
|
+
Untagged fields and values are preserved, including duplicates; JSON
|
|
122
|
+
formatting and entry order can change.
|
|
123
|
+
- In-provider pruning requires successful, complete, non-empty discovery. A
|
|
124
|
+
section that held only pruned chat entries is removed (Pi composition-errors
|
|
125
|
+
on `{ models: [] }` with no other keys); sections with user keys keep their
|
|
126
|
+
shape. The orphan sweep removes only managed chat entries of absent providers;
|
|
127
|
+
registered providers with no visible models remain protected, and an empty
|
|
128
|
+
model registry disables sweeping.
|
|
129
|
+
- A missing models.json starts empty; a corrupt one aborts before any write.
|
|
130
|
+
Pi JSONC (BOM, `//` comments, trailing commas) is valid input; writes are
|
|
131
|
+
strict JSON, so comments are not preserved.
|
|
132
|
+
- Models with known non-text output (video, image, embeddings) are skipped
|
|
133
|
+
by policy (this is a chat-model catalog). Google's advertised method lists
|
|
134
|
+
must include `generateContent` when non-empty; embedding/predict-only models
|
|
135
|
+
are excluded even without models.dev enrichment. Missing method metadata
|
|
136
|
+
remains syncable. Empty Google ids are dropped. Zero context windows are
|
|
137
|
+
omitted (Pi throws on them at composition). A display name that would be
|
|
138
|
+
empty after title-casing falls back to the model id (Pi rejects `name: ""`).
|
|
139
|
+
- Skip reasons exclude response bodies and raw auth/transport exception text.
|
|
140
|
+
Resolved keys and auth headers are read through Pi's registry, never stored.
|
|
141
|
+
- All outbound requests send `User-Agent: OpenAI File Downloader, XaiImageApiFetch/1.0`.
|
|
142
|
+
|
|
143
|
+
Pi 0.99 typed image/classifier catalogs belong to native or explicit provider
|
|
144
|
+
registrations. Its `models.json` composer still treats file model definitions
|
|
145
|
+
as chat models; preserving non-chat-shaped file records here does not make
|
|
146
|
+
them load as typed models. Sync neither creates those records nor discovers
|
|
147
|
+
non-chat catalogs. Native non-chat catalogs remain owned by their provider.
|
|
148
|
+
|
|
149
|
+
## Error model
|
|
150
|
+
|
|
151
|
+
- Unknown provider filter, unreadable registry, corrupt models.json: the
|
|
152
|
+
command reports the error and changes nothing.
|
|
153
|
+
- Per-provider failures (no credentials, rejected key, no list endpoint,
|
|
154
|
+
unexpected shape, mixed discovery families, explicit extension model list,
|
|
155
|
+
incomplete pagination): one skip line each; the run continues.
|
|
156
|
+
- models.dev outage: falls back to the cache (stale if needed); with no
|
|
157
|
+
cache at all, noted once and the run continues live-only.
|
|
158
|
+
|
|
159
|
+
## Conformance tests
|
|
160
|
+
|
|
161
|
+
`npm test` runs hermetic tests under `tests/` (`node --test`, no network, no Pi required):
|
|
162
|
+
discovery shapes, case-insensitive auth headers, incomplete pagination
|
|
163
|
+
(later-page 404/body/cursor failures and the 100-page cap cannot prune via
|
|
164
|
+
another API probe), models.dev mapping, cache rules, thinking ladders,
|
|
165
|
+
atomic publication and backup races, edits during discovery, configured
|
|
166
|
+
transport defaults, reload failures, credential-safe reports, mixed-type
|
|
167
|
+
catalog preservation, and command wiring. Real Pi composition
|
|
168
|
+
and live gateway behavior are separate verification steps, not implied by
|
|
169
|
+
these mock-provider tests.
|
|
170
|
+
|
|
171
|
+
## No-claim boundaries
|
|
172
|
+
|
|
173
|
+
- Thinking levels for unknown models are the conservative family ladder
|
|
174
|
+
(`minimal`/`low`/`medium`/`high`, no `xhigh`/`max`); explicit wire values
|
|
175
|
+
from live metadata or models.dev always win. `thinkingLevelMap.off` is
|
|
176
|
+
omitted unless the source advertises `none` or `off`; a null map value
|
|
177
|
+
hides that Pi level. Provider-specific effort gates (e.g. Meta Contributor
|
|
178
|
+
`max`, which needs a client fingerprint) belong to that provider's
|
|
179
|
+
extension, not the generic engine.
|
|
180
|
+
- Providers without a listable endpoint (subscription transports, SigV4,
|
|
181
|
+
management-plane lists) report `skipped` with the reason; they are not
|
|
182
|
+
guessed.
|
|
183
|
+
- Costs and limits are point-in-time; re-run to refresh. Vercel AI Gateway's
|
|
184
|
+
per-token list prices (including cache legs) are converted to Pi's per-million
|
|
185
|
+
units; models.dev fallback prices already use those units. Google list
|
|
186
|
+
`inputTokenLimit`/`outputTokenLimit` fill context and max-token limits when
|
|
187
|
+
present. The sync never changes prices on hand-written entries. Tiered
|
|
188
|
+
pricing (context over a threshold) is ignored; flat rates only.
|
|
189
|
+
- Cached models.dev data is up to 24h old; `--refresh` forces a refetch.
|
|
190
|
+
The report always says which it used (`fetched fresh`, `cache hit`,
|
|
191
|
+
`stale cache`, or live-only).
|
|
192
|
+
- Pagination stops at 100 pages per provider. A catalog still advertising
|
|
193
|
+
another page at that limit is rejected, not truncated and used for pruning.
|
|
194
|
+
Broken cursors, unsupported OpenAI `has_more`, and mid-list transport or
|
|
195
|
+
shape failures also skip the provider.
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT. [AdityaVG13/pi-stack](https://github.com/AdityaVG13/pi-stack/tree/main/packages/pi-model-sync)
|
package/index.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-model-sync: keep Pi models fresh.
|
|
3
|
+
*
|
|
4
|
+
* /model-sync walks every provider Pi knows (built-in and extension
|
|
5
|
+
* registered), lists each live catalog with that provider's own
|
|
6
|
+
* credentials, enriches from models.dev, and writes missing models to
|
|
7
|
+
* models.json as tagged managed entries. One provider's dead token or
|
|
8
|
+
* missing list endpoint skips just that provider, never the run.
|
|
9
|
+
*
|
|
10
|
+
* Host boundary: index.js only wires Pi APIs (registerCommand, registry,
|
|
11
|
+
* ui). All logic lives in lib/ over injected deps, hermetically tested.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { isFunction, isNonEmptyString, isString } from "./lib/decode.js";
|
|
18
|
+
import { runSync } from "./lib/sync.js";
|
|
19
|
+
|
|
20
|
+
// Project law: every outbound request carries this User-Agent.
|
|
21
|
+
export const USER_AGENT = "OpenAI File Downloader, XaiImageApiFetch/1.0";
|
|
22
|
+
|
|
23
|
+
const HELP = [
|
|
24
|
+
"model-sync: refresh Pi model catalogs from live provider lists.",
|
|
25
|
+
"",
|
|
26
|
+
" /model-sync sync all logged-in providers",
|
|
27
|
+
" /model-sync <provider> sync one provider only",
|
|
28
|
+
" /model-sync --dry-run report only; models.json untouched",
|
|
29
|
+
" /model-sync --refresh refetch models.dev now; ignore the 24h cache",
|
|
30
|
+
"",
|
|
31
|
+
"Writes tagged entries to models.json (backed up first). Hand-written",
|
|
32
|
+
"entries are never touched; managed entries refresh in place.",
|
|
33
|
+
].join("\n");
|
|
34
|
+
|
|
35
|
+
const FLAGS = { "--dry-run": "dryRun", "--refresh": "refresh", "--help": "help", "-h": "help" };
|
|
36
|
+
|
|
37
|
+
export function parseArgs(raw) {
|
|
38
|
+
const parsed = { dryRun: false, filter: undefined, help: false, refresh: false, error: undefined };
|
|
39
|
+
// Hosts pass a string; be liberal like pi-rotator and join arrays.
|
|
40
|
+
const text = Array.isArray(raw) ? raw.join(" ") : raw;
|
|
41
|
+
|
|
42
|
+
if (!isString(text)) {
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const token of text.split(/\s+/).filter((part) => part !== "")) {
|
|
47
|
+
// hasOwn, not lookup: inherited keys (toString) are positionals, not flags.
|
|
48
|
+
if (Object.hasOwn(FLAGS, token)) {
|
|
49
|
+
parsed[FLAGS[token]] = true;
|
|
50
|
+
} else if (token.startsWith("--")) {
|
|
51
|
+
parsed.error = `unknown flag "${token}"`;
|
|
52
|
+
|
|
53
|
+
return parsed;
|
|
54
|
+
} else if (parsed.filter === undefined) {
|
|
55
|
+
parsed.filter = token;
|
|
56
|
+
} else {
|
|
57
|
+
parsed.error = `unexpected argument "${token}"`;
|
|
58
|
+
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return parsed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// models.json location: explicit env override first (also the test seam),
|
|
67
|
+
// then the host helper, then the default agent dir.
|
|
68
|
+
export async function resolveModelsPath() {
|
|
69
|
+
if (isNonEmptyString(process.env.MODELSYNC_MODELS_PATH)) {
|
|
70
|
+
return process.env.MODELSYNC_MODELS_PATH;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const host = await import("@earendil-works/pi-coding-agent");
|
|
75
|
+
|
|
76
|
+
if (isFunction(host.getModelsPath)) {
|
|
77
|
+
return host.getModelsPath();
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Outside Pi (tests, scripts): fall through to the default.
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Mirror Pi's own default (config.getAgentDir): env override with tilde
|
|
84
|
+
// expansion, else ~/.pi/agent. Only reachable outside Pi; inside Pi the
|
|
85
|
+
// host helper above wins.
|
|
86
|
+
const envDir = process.env.PI_CODING_AGENT_DIR;
|
|
87
|
+
|
|
88
|
+
if (isNonEmptyString(envDir)) {
|
|
89
|
+
const expanded = envDir === "~" ? homedir() : envDir.startsWith("~/") ? join(homedir(), envDir.slice(2)) : envDir;
|
|
90
|
+
|
|
91
|
+
return join(expanded, "models.json");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return join(homedir(), ".pi", "agent", "models.json");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function attempt(run) {
|
|
98
|
+
try {
|
|
99
|
+
run();
|
|
100
|
+
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Persistent widget beats the transient notify flash; notify stays as the
|
|
108
|
+
// fallback. All cosmetic failures are swallowed: the report text is the
|
|
109
|
+
// return value either way.
|
|
110
|
+
function showText(ctx, text) {
|
|
111
|
+
const body = String(text);
|
|
112
|
+
|
|
113
|
+
if (
|
|
114
|
+
ctx?.ui?.setWidget != null &&
|
|
115
|
+
attempt(() => ctx.ui.setWidget("pi-model-sync", body.split("\n"), { placement: "aboveEditor" }))
|
|
116
|
+
) {
|
|
117
|
+
return body;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (ctx?.ui?.notify != null) {
|
|
121
|
+
attempt(() => ctx.ui.notify(body, "info"));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return body;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function onCommand(raw, ctx) {
|
|
128
|
+
const args = parseArgs(raw);
|
|
129
|
+
|
|
130
|
+
if (args.error !== undefined) {
|
|
131
|
+
return showText(ctx, `${args.error}\n\n${HELP}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (args.help) {
|
|
135
|
+
return showText(ctx, HELP);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (ctx?.modelRegistry === undefined) {
|
|
139
|
+
return showText(ctx, "model-sync: this Pi version exposes no model registry; update Pi and retry.");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const modelsPath = await resolveModelsPath();
|
|
143
|
+
|
|
144
|
+
const result = await runSync({
|
|
145
|
+
registry: ctx.modelRegistry,
|
|
146
|
+
fetchImpl: globalThis.fetch,
|
|
147
|
+
fs,
|
|
148
|
+
modelsPath,
|
|
149
|
+
userAgent: USER_AGENT,
|
|
150
|
+
filter: args.filter,
|
|
151
|
+
dryRun: args.dryRun,
|
|
152
|
+
refresh: args.refresh,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return showText(ctx, result.lines.join("\n"));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export default function piModelSync(pi) {
|
|
159
|
+
pi.registerCommand("model-sync", {
|
|
160
|
+
description: "Refresh model catalogs from live providers into models.json",
|
|
161
|
+
handler: (raw, ctx) => onCommand(raw, ctx),
|
|
162
|
+
});
|
|
163
|
+
}
|
package/lib/cache.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-model-sync models.dev cache.
|
|
3
|
+
*
|
|
4
|
+
* The 5MB catalog is fetched at most once per TTL; repeat runs read disk.
|
|
5
|
+
* Cache reads never fail a run: corrupt or missing caches are misses, and
|
|
6
|
+
* a failed fetch falls back to any cached copy (fresh or stale) before
|
|
7
|
+
* giving up to live-only mode. Persistence is best-effort by design.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { isFunction, isNumber, isObject } from "./decode.js";
|
|
12
|
+
import { fetchModelsDevCatalog } from "./modelsdev.js";
|
|
13
|
+
|
|
14
|
+
export const MODELS_DEV_TTL_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
export const MODELS_DEV_CACHE_FILE = "pi-model-sync-models-dev.json";
|
|
17
|
+
|
|
18
|
+
export function cachePathFor(modelsPath) {
|
|
19
|
+
return join(dirname(modelsPath), MODELS_DEV_CACHE_FILE);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readCache(cachePath, fs) {
|
|
23
|
+
let parsed;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
parsed = JSON.parse(fs.readFileSync(cachePath, "utf8"));
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!isObject(parsed) || parsed.version !== 1 || !isObject(parsed.catalog) || !isNumber(parsed.fetchedAt)) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ageLabel(ageMs) {
|
|
39
|
+
const minutes = Math.floor(ageMs / 60000);
|
|
40
|
+
|
|
41
|
+
if (minutes < 1) {
|
|
42
|
+
return "just now";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (minutes < 60) {
|
|
46
|
+
return `${minutes}m old`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return `${Math.floor(minutes / 60)}h old`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isFresh(fetchedAt, clock) {
|
|
53
|
+
const age = clock() - fetchedAt;
|
|
54
|
+
|
|
55
|
+
return age >= 0 && age < MODELS_DEV_TTL_MS;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Resolve the catalog: fresh cache hit, network fetch, stale fallback, or
|
|
59
|
+
// offline. Returns {catalog|null, status, note}. status is one of "fresh",
|
|
60
|
+
// "cache", "stale", "offline". now defaults to Date.now (injectable).
|
|
61
|
+
export async function loadCatalog({ fetchImpl, userAgent, cachePath, fs, refresh, dryRun, now }) {
|
|
62
|
+
const clock = isFunction(now) ? now : Date.now;
|
|
63
|
+
const cached = readCache(cachePath, fs);
|
|
64
|
+
|
|
65
|
+
if (refresh !== true && cached !== undefined && isFresh(cached.fetchedAt, clock)) {
|
|
66
|
+
return {
|
|
67
|
+
catalog: cached.catalog,
|
|
68
|
+
status: "cache",
|
|
69
|
+
note: `models.dev: cache hit (${ageLabel(clock() - cached.fetchedAt)})`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const catalog = await fetchModelsDevCatalog(fetchImpl, userAgent);
|
|
75
|
+
|
|
76
|
+
if (dryRun !== true) {
|
|
77
|
+
try {
|
|
78
|
+
fs.writeFileSync(cachePath, JSON.stringify({ version: 1, fetchedAt: clock(), catalog }), "utf8");
|
|
79
|
+
} catch {
|
|
80
|
+
// Best-effort: a broken cache must never fail the sync.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { catalog, status: "fresh", note: "models.dev: fetched fresh" };
|
|
85
|
+
} catch {
|
|
86
|
+
if (cached !== undefined) {
|
|
87
|
+
return {
|
|
88
|
+
catalog: cached.catalog,
|
|
89
|
+
status: "stale",
|
|
90
|
+
note: `models.dev unreachable; stale cache (${ageLabel(clock() - cached.fetchedAt)})`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { catalog: null, status: "offline", note: "models.dev unreachable; using live metadata only" };
|
|
95
|
+
}
|
|
96
|
+
}
|
package/lib/decode.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-model-sync shared value helpers.
|
|
3
|
+
*
|
|
4
|
+
* House idiom (pi-supernova decode.js): Object.prototype.toString probes
|
|
5
|
+
* instead of typeof, centralized here so every other module branches on
|
|
6
|
+
* decoded contracts rather than representations. Record builders live here
|
|
7
|
+
* too, so sparse-object shapes stay single-sourced.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const toStr = Object.prototype.toString;
|
|
11
|
+
|
|
12
|
+
export function isString(value) {
|
|
13
|
+
return toStr.call(value) === "[object String]";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isNonEmptyString(value) {
|
|
17
|
+
return isString(value) && value !== "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isObject(value) {
|
|
21
|
+
return toStr.call(value) === "[object Object]";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isFunction(value) {
|
|
25
|
+
return toStr.call(value) === "[object Function]" || value instanceof Function;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isNumber(value) {
|
|
29
|
+
return toStr.call(value) === "[object Number]" && Number.isFinite(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Build a record from entries, dropping undefined values. Both model-entry
|
|
33
|
+
// builders emit sparse records (unknown fields omitted, never null); key
|
|
34
|
+
// order follows the literal, so output order is unchanged.
|
|
35
|
+
export function defined(entries) {
|
|
36
|
+
const kept = {};
|
|
37
|
+
|
|
38
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
39
|
+
if (value !== undefined) {
|
|
40
|
+
kept[key] = value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return kept;
|
|
45
|
+
}
|