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 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
+ [![npm](https://img.shields.io/npm/v/pi-model-sync.svg)](https://www.npmjs.com/package/pi-model-sync)
4
+ [![license](https://img.shields.io/npm/l/pi-model-sync.svg)](https://github.com/AdityaVG13/pi-stack/blob/main/packages/pi-model-sync/LICENSE)
5
+ [![node](https://img.shields.io/node/v/pi-model-sync.svg)](https://nodejs.org)
6
+ [![pi-package](https://img.shields.io/badge/pi--package-extension-7aa2f7)](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
+ }