pi-wafer-provider 1.1.2 → 1.1.4

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/AGENTS.md CHANGED
@@ -7,6 +7,7 @@ The following files are **idempotent** and regenerated by `scripts/update-models
7
7
  | File | Why it's auto-generated |
8
8
  |------|------------------------|
9
9
  | `models.json` | Built from the provider API. `update-models.js` fetches models, preserves curated data for known IDs, and writes this file. |
10
+ | `deprecated-models.json` | Graveyard for models the API delisted. update-models.js stamps them with deprecatedAt and pi keeps serving them for a 2-week grace period, then evicts them. Never edit by hand. |
10
11
  | `README.md` (model table) | The table under `## Available Models` is replaced in-place by `update-models.js` after merging base models → patch → custom models. |
11
12
 
12
13
  ## Correct Files to Edit
package/README.md CHANGED
@@ -76,12 +76,14 @@ pi
76
76
 
77
77
  | Model | Type | Context | Max Output | Input Cost | Output Cost | Cached Input |
78
78
  |-------|------|---------|------------|------------|-------------|--------------|
79
- | GLM 5.1 | Text | 203K | 33K | $1.50 | $4.50 | $0.15 |
80
- | GLM 5.2 | Text | 1M | 16K | Free | Free | Free |
81
- | Glm5.2 Fast | Text | 1M | 16K | Free | Free | Free |
82
- | Kimi K2.6 | Text | 262K | 33K | $1.10 | $4.80 | $0.11 |
83
- | MiniMax M3 | Text | 1M | 16K | Free | Free | Free |
84
- | Qwen 3.5 397B (A17B) | Text + Image | 262K | 33K | $0.60 | $3.60 | $0.06 |
79
+ | GLM-5.1 | Text | 203K | 33K | $1.00 | $3.20 | $0.10 |
80
+ | GLM-5.2 | Text | 1M | 16K | $1.26 | $3.96 | $0.23 |
81
+ | GLM5.2-Fast | Text | 1M | 16K | $2.10 | $6.60 | $0.21 |
82
+ | Kimi-K2.6 | Text + Image | 262K | 33K | $1.14 | $4.80 | $0.19 |
83
+ | Kimi-K3 | Text + Image | 912K | 16K | $3.00 | $15.00 | $0.30 |
84
+ | Kimi-K3-Fast | Text + Image | 1M | 16K | $4.50 | $22.50 | $0.45 |
85
+ | MiniMax-M3 | Text + Image | 1M | 16K | $0.33 | $1.32 | $0.07 |
86
+ | Qwen 3.5 397B (A17B) | Text | 262K | 33K | Free | Free | Free |
85
87
 
86
88
  *Costs are per million tokens. Prices based on official provider pricing.*
87
89
 
@@ -0,0 +1 @@
1
+ {}
package/index.ts CHANGED
@@ -35,6 +35,7 @@ import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-wo
35
35
  import modelsData from "./models.json" with { type: "json" };
36
36
  import customModelsData from "./custom-models.json" with { type: "json" };
37
37
  import patchData from "./patch.json" with { type: "json" };
38
+ import deprecatedData from "./deprecated-models.json" with { type: "json" };
38
39
  import fs from "fs";
39
40
  import path from "path";
40
41
 
@@ -123,7 +124,10 @@ function applyPatch(model: JsonModel, patch: PatchEntry): JsonModel {
123
124
  function buildModels(base: JsonModel[], custom: JsonModel[], patch: PatchData): JsonModel[] {
124
125
  const modelMap = new Map<string, JsonModel>();
125
126
 
126
- for (const model of base) {
127
+ // Seed with the base list plus grace-period deprecated models so patch.json
128
+ // entries apply to deprecated models exactly as while the model was live
129
+ // (withDeprecated keeps live data on id conflicts).
130
+ for (const model of withDeprecated(base)) {
127
131
  modelMap.set(model.id, model);
128
132
  }
129
133
 
@@ -185,17 +189,29 @@ interface ProviderConfig {
185
189
 
186
190
  /** Transform a model from the Wafer /v1/models API. */
187
191
  function transformApiModel(apiModel: any): JsonModel | null {
192
+ const details = apiModel.wafer || {};
193
+ const capabilities = details.capabilities || {};
194
+ const pricing = details.pricing || {};
195
+ const reasoning = capabilities.reasoning === true;
188
196
  return {
189
197
  id: apiModel.id,
190
- name: apiModel.id,
191
- reasoning: false,
192
- input: ["text"],
193
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
194
- contextWindow: apiModel.max_model_len || 0,
195
- maxTokens: 0,
198
+ name: details.display_name || apiModel.id,
199
+ reasoning,
200
+ input: capabilities.vision === true ? ["text", "image"] : ["text"],
201
+ cost: {
202
+ input: (pricing.input_cents_per_million || 0) / 100,
203
+ output: (pricing.output_cents_per_million || 0) / 100,
204
+ cacheRead: (pricing.cache_read_cents_per_million || 0) / 100,
205
+ cacheWrite: 0,
206
+ },
207
+ contextWindow: details.context_length || apiModel.max_model_len || 0,
208
+ maxTokens: details.context_length || apiModel.max_model_len || 0,
196
209
  compat: {
210
+ supportsStore: false,
211
+ supportsDeveloperRole: false,
212
+ maxTokensField: "max_completion_tokens",
197
213
  supportsZdr: apiModel.zdr_supported ?? undefined,
198
- supportsReasoningEffort: true,
214
+ ...(reasoning ? { supportsReasoningEffort: true } : {}),
199
215
  },
200
216
  };
201
217
  }
@@ -275,6 +291,35 @@ function mergeWithEmbedded(liveModels: JsonModel[], embeddedModels: JsonModel[])
275
291
  return result;
276
292
  }
277
293
 
294
+ // Grace period for delisted models. When the provider API stops listing a
295
+ // model, update-models.js moves its last-known definition into
296
+ // deprecated-models.json (stamped with deprecatedAt) instead of dropping it.
297
+ // For 14 days the model keeps working here so in-flight sessions and saved
298
+ // model settings do not break; afterwards it is evicted permanently.
299
+ const DEPRECATED_MODEL_TTL_MS = 14 * 24 * 60 * 60 * 1000;
300
+
301
+ // Grace-period deprecated models with deprecation metadata stripped.
302
+ function activeDeprecatedModels(): JsonModel[] {
303
+ const now = Date.now();
304
+ const result: JsonModel[] = [];
305
+ for (const entry of Object.values(deprecatedData as Record<string, JsonModel & { deprecatedAt?: string }>)) {
306
+ if (!entry?.id) continue;
307
+ const removedAt = Date.parse(entry.deprecatedAt ?? "");
308
+ if (Number.isNaN(removedAt) || now - removedAt > DEPRECATED_MODEL_TTL_MS) continue;
309
+ const model = { ...entry } as JsonModel & { deprecatedAt?: string };
310
+ delete model.deprecatedAt;
311
+ result.push(model);
312
+ }
313
+ return result;
314
+ }
315
+
316
+ // Append grace-period deprecated models the list does not already have (live data wins).
317
+ function withDeprecated(models: JsonModel[]): JsonModel[] {
318
+ const seen = new Set(models.map((m) => m.id));
319
+ const extras = activeDeprecatedModels().filter((m) => !seen.has(m.id));
320
+ return extras.length > 0 ? [...models, ...extras] : models;
321
+ }
322
+
278
323
  function loadStaleModels(providerId: string, embeddedModels: JsonModel[]): JsonModel[] {
279
324
  const cached = loadCachedModels(providerId);
280
325
  if (!cached || cached.length === 0) return embeddedModels;
package/models.json CHANGED
@@ -1,15 +1,15 @@
1
1
  [
2
2
  {
3
3
  "id": "GLM-5.1",
4
- "name": "GLM 5.1",
5
- "reasoning": false,
4
+ "name": "GLM-5.1",
5
+ "reasoning": true,
6
6
  "input": [
7
7
  "text"
8
8
  ],
9
9
  "cost": {
10
- "input": 1.5,
11
- "output": 4.5,
12
- "cacheRead": 0.15,
10
+ "input": 1,
11
+ "output": 3.2,
12
+ "cacheRead": 0.1,
13
13
  "cacheWrite": 0
14
14
  },
15
15
  "contextWindow": 202752,
@@ -17,20 +17,22 @@
17
17
  "compat": {
18
18
  "maxTokensField": "max_completion_tokens",
19
19
  "supportsDeveloperRole": false,
20
- "supportsZdr": true
20
+ "supportsZdr": true,
21
+ "supportsStore": false,
22
+ "supportsReasoningEffort": true
21
23
  }
22
24
  },
23
25
  {
24
26
  "id": "GLM-5.2",
25
- "name": "GLM 5.2",
26
- "reasoning": false,
27
+ "name": "GLM-5.2",
28
+ "reasoning": true,
27
29
  "input": [
28
30
  "text"
29
31
  ],
30
32
  "cost": {
31
- "input": 0,
32
- "output": 0,
33
- "cacheRead": 0,
33
+ "input": 1.26,
34
+ "output": 3.96,
35
+ "cacheRead": 0.23,
34
36
  "cacheWrite": 0
35
37
  },
36
38
  "contextWindow": 1048576,
@@ -39,20 +41,21 @@
39
41
  "maxTokensField": "max_completion_tokens",
40
42
  "supportsDeveloperRole": false,
41
43
  "supportsStore": false,
42
- "supportsZdr": true
44
+ "supportsZdr": true,
45
+ "supportsReasoningEffort": true
43
46
  }
44
47
  },
45
48
  {
46
49
  "id": "glm5.2-fast",
47
- "name": "Glm5.2 Fast",
48
- "reasoning": false,
50
+ "name": "GLM5.2-Fast",
51
+ "reasoning": true,
49
52
  "input": [
50
53
  "text"
51
54
  ],
52
55
  "cost": {
53
- "input": 0,
54
- "output": 0,
55
- "cacheRead": 0,
56
+ "input": 2.1,
57
+ "output": 6.6,
58
+ "cacheRead": 0.21,
56
59
  "cacheWrite": 0
57
60
  },
58
61
  "contextWindow": 1048576,
@@ -61,20 +64,22 @@
61
64
  "maxTokensField": "max_completion_tokens",
62
65
  "supportsDeveloperRole": false,
63
66
  "supportsStore": false,
64
- "supportsZdr": true
67
+ "supportsZdr": true,
68
+ "supportsReasoningEffort": true
65
69
  }
66
70
  },
67
71
  {
68
72
  "id": "Kimi-K2.6",
69
- "name": "Kimi K2.6",
70
- "reasoning": false,
73
+ "name": "Kimi-K2.6",
74
+ "reasoning": true,
71
75
  "input": [
72
- "text"
76
+ "text",
77
+ "image"
73
78
  ],
74
79
  "cost": {
75
- "input": 1.1,
80
+ "input": 1.14,
76
81
  "output": 4.8,
77
- "cacheRead": 0.11,
82
+ "cacheRead": 0.19,
78
83
  "cacheWrite": 0
79
84
  },
80
85
  "contextWindow": 262144,
@@ -82,20 +87,71 @@
82
87
  "compat": {
83
88
  "maxTokensField": "max_completion_tokens",
84
89
  "supportsDeveloperRole": false,
85
- "supportsZdr": false
90
+ "supportsZdr": false,
91
+ "supportsStore": false,
92
+ "supportsReasoningEffort": true
93
+ }
94
+ },
95
+ {
96
+ "id": "Kimi-K3",
97
+ "name": "Kimi-K3",
98
+ "reasoning": true,
99
+ "input": [
100
+ "text",
101
+ "image"
102
+ ],
103
+ "cost": {
104
+ "input": 3,
105
+ "output": 15,
106
+ "cacheRead": 0.3,
107
+ "cacheWrite": 0
108
+ },
109
+ "contextWindow": 912384,
110
+ "maxTokens": 16384,
111
+ "compat": {
112
+ "maxTokensField": "max_completion_tokens",
113
+ "supportsDeveloperRole": false,
114
+ "supportsStore": false,
115
+ "supportsZdr": true,
116
+ "supportsReasoningEffort": true
117
+ }
118
+ },
119
+ {
120
+ "id": "kimi-k3-fast",
121
+ "name": "Kimi-K3-Fast",
122
+ "reasoning": true,
123
+ "input": [
124
+ "text",
125
+ "image"
126
+ ],
127
+ "cost": {
128
+ "input": 4.5,
129
+ "output": 22.5,
130
+ "cacheRead": 0.45,
131
+ "cacheWrite": 0
132
+ },
133
+ "contextWindow": 1048576,
134
+ "maxTokens": 16384,
135
+ "compat": {
136
+ "maxTokensField": "max_completion_tokens",
137
+ "supportsDeveloperRole": false,
138
+ "supportsStore": false,
139
+ "supportsZdr": true,
140
+ "supportsReasoningEffort": true
86
141
  }
87
142
  },
88
143
  {
89
144
  "id": "MiniMax-M3",
90
- "name": "MiniMax M3",
91
- "reasoning": false,
145
+ "name": "MiniMax-M3",
146
+ "reasoning": true,
92
147
  "input": [
93
- "text"
148
+ "text",
149
+ "image"
94
150
  ],
95
151
  "cost": {
96
- "input": 0,
97
- "output": 0,
98
- "cacheRead": 0,
152
+ "input": 0.33,
153
+ "output": 1.32,
154
+ "cacheRead": 0.07,
99
155
  "cacheWrite": 0
100
156
  },
101
157
  "contextWindow": 1048576,
@@ -104,7 +160,8 @@
104
160
  "maxTokensField": "max_completion_tokens",
105
161
  "supportsDeveloperRole": false,
106
162
  "supportsStore": false,
107
- "supportsZdr": false
163
+ "supportsZdr": false,
164
+ "supportsReasoningEffort": true
108
165
  }
109
166
  },
110
167
  {
@@ -112,13 +169,12 @@
112
169
  "name": "Qwen 3.5 397B (A17B)",
113
170
  "reasoning": false,
114
171
  "input": [
115
- "text",
116
- "image"
172
+ "text"
117
173
  ],
118
174
  "cost": {
119
- "input": 0.6,
120
- "output": 3.6,
121
- "cacheRead": 0.06,
175
+ "input": 0,
176
+ "output": 0,
177
+ "cacheRead": 0,
122
178
  "cacheWrite": 0
123
179
  },
124
180
  "contextWindow": 262144,
@@ -126,7 +182,8 @@
126
182
  "compat": {
127
183
  "maxTokensField": "max_completion_tokens",
128
184
  "supportsDeveloperRole": false,
129
- "supportsZdr": false
185
+ "supportsZdr": false,
186
+ "supportsStore": false
130
187
  }
131
188
  }
132
189
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-wafer-provider",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Wafer Serverless provider extension for pi - Access Qwen3.5-397B-A17B, GLM-5.1, Kimi K2.6, and DeepSeek-V4 through the Wafer Serverless API",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/patch.json CHANGED
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "Qwen3.5-397B-A17B": {
3
- "providers": ["wafer-serverless"],
3
+ "providers": [
4
+ "wafer-serverless"
5
+ ],
4
6
  "reasoning": true,
5
7
  "compat": {
6
8
  "thinkingFormat": "qwen",
@@ -10,7 +12,9 @@
10
12
  }
11
13
  },
12
14
  "GLM-5.1": {
13
- "providers": ["wafer-serverless"],
15
+ "providers": [
16
+ "wafer-serverless"
17
+ ],
14
18
  "reasoning": true,
15
19
  "compat": {
16
20
  "thinkingFormat": "zai",
@@ -20,67 +24,14 @@
20
24
  }
21
25
  },
22
26
  "Kimi-K2.6": {
23
- "providers": ["wafer-serverless"],
27
+ "providers": [
28
+ "wafer-serverless"
29
+ ],
24
30
  "reasoning": true,
25
31
  "compat": {
26
32
  "maxTokensField": "max_completion_tokens",
27
33
  "supportsDeveloperRole": false,
28
34
  "supportsReasoningEffort": true
29
35
  }
30
- },
31
- "Qwen3.6-35B-A3B": {
32
- "providers": ["wafer-serverless"],
33
- "reasoning": true,
34
- "compat": {
35
- "thinkingFormat": "qwen",
36
- "maxTokensField": "max_completion_tokens",
37
- "supportsDeveloperRole": false,
38
- "supportsReasoningEffort": true
39
- }
40
- },
41
- "deepseek-v4-flash": {
42
- "providers": ["wafer-serverless"],
43
- "reasoning": true,
44
- "compat": {
45
- "thinkingFormat": "deepseek",
46
- "maxTokensField": "max_completion_tokens",
47
- "supportsDeveloperRole": false,
48
- "supportsReasoningEffort": true
49
- }
50
- },
51
- "deepseek-v4-pro": {
52
- "providers": ["wafer-serverless"],
53
- "reasoning": true,
54
- "contextWindow": 1000000,
55
- "maxTokens": 384000,
56
- "thinkingLevelMap": {
57
- "minimal": null,
58
- "low": null,
59
- "medium": null,
60
- "high": "high",
61
- "max": "max"
62
- },
63
- "compat": {
64
- "thinkingFormat": "deepseek",
65
- "maxTokensField": "max_completion_tokens",
66
- "supportsDeveloperRole": false,
67
- "supportsReasoningEffort": true
68
- }
69
- },
70
- "qwen3.7-max": {
71
- "providers": ["wafer-serverless"],
72
- "reasoning": true,
73
- "cost": {
74
- "input": 5.0,
75
- "output": 15.0,
76
- "cacheRead": 0.5,
77
- "cacheWrite": 0
78
- },
79
- "compat": {
80
- "thinkingFormat": "qwen",
81
- "maxTokensField": "max_completion_tokens",
82
- "supportsDeveloperRole": false,
83
- "supportsReasoningEffort": true
84
- }
85
36
  }
86
37
  }
@@ -6,11 +6,9 @@
6
6
  * - models.json: Provider model definitions (enriched with pricing & compat)
7
7
  * - README.md: Model table in the Available Models section
8
8
  *
9
- * The Wafer /v1/models API returns basic model info (id, max_model_len)
10
- * but does NOT include pricing or max output tokens.
11
- * models.json is the source of truth for curated specs — the script preserves
12
- * existing data and only adds new models with sensible defaults.
13
- * Curate models.json manually after new model discovery.
9
+ * The Wafer /v1/models API returns model limits plus nested capability, pricing,
10
+ * modality, and ZDR metadata. models.json mirrors those API-owned fields while
11
+ * patch.json remains the source of truth for thinking controls and corrections.
14
12
  *
15
13
  * patch.json is applied at runtime by the provider — not baked into models.json.
16
14
  *
@@ -72,39 +70,51 @@ async function fetchModels() {
72
70
  function transformApiModel(apiModel, existingModelsMap) {
73
71
  const id = apiModel.id;
74
72
 
75
- // Preserve existing curated data (pricing, reasoning, compat, etc.)
73
+ const details = apiModel.wafer || {};
74
+ const capabilities = details.capabilities || {};
75
+ const pricing = details.pricing || {};
76
+ const reasoning = capabilities.reasoning === true;
77
+ const input = capabilities.vision === true ? ['text', 'image'] : ['text'];
78
+ const cost = {
79
+ input: (pricing.input_cents_per_million || 0) / 100,
80
+ output: (pricing.output_cents_per_million || 0) / 100,
81
+ cacheRead: (pricing.cache_read_cents_per_million || 0) / 100,
82
+ cacheWrite: 0,
83
+ };
84
+
76
85
  if (existingModelsMap[id]) {
77
86
  const existing = { ...existingModelsMap[id] };
78
- // Update API-derived fields if changed
79
- if (apiModel.max_model_len) {
80
- existing.contextWindow = apiModel.max_model_len;
81
- }
82
- if (apiModel.zdr_supported !== undefined) {
83
- existing.compat = existing.compat || {};
84
- existing.compat.supportsZdr = apiModel.zdr_supported;
85
- }
87
+ existing.name = details.display_name || existing.name;
88
+ existing.reasoning = reasoning;
89
+ existing.input = input;
90
+ existing.cost = cost;
91
+ existing.contextWindow = details.context_length || apiModel.max_model_len || existing.contextWindow;
92
+ existing.compat = {
93
+ ...(existing.compat || {}),
94
+ supportsStore: false,
95
+ supportsDeveloperRole: false,
96
+ maxTokensField: 'max_completion_tokens',
97
+ supportsZdr: apiModel.zdr_supported,
98
+ ...(reasoning ? { supportsReasoningEffort: true } : {}),
99
+ };
86
100
  return existing;
87
101
  }
88
102
 
89
- // New model — sensible defaults; curate models.json manually after discovery
103
+ const contextWindow = details.context_length || apiModel.max_model_len || 131072;
90
104
  const model = {
91
105
  id,
92
- name: generateDisplayName(id),
93
- reasoning: false,
94
- input: ['text'],
95
- cost: {
96
- input: 0,
97
- output: 0,
98
- cacheRead: 0,
99
- cacheWrite: 0,
100
- },
101
- contextWindow: apiModel.max_model_len || 131072,
102
- maxTokens: 16384,
106
+ name: details.display_name || generateDisplayName(id),
107
+ reasoning,
108
+ input,
109
+ cost,
110
+ contextWindow,
111
+ maxTokens: contextWindow,
103
112
  compat: {
104
113
  maxTokensField: 'max_completion_tokens',
105
114
  supportsDeveloperRole: false,
106
115
  supportsStore: false,
107
116
  supportsZdr: apiModel.zdr_supported,
117
+ ...(reasoning ? { supportsReasoningEffort: true } : {}),
108
118
  },
109
119
  };
110
120
 
@@ -226,6 +236,67 @@ function updateReadme(models) {
226
236
 
227
237
  // ─── Main ────────────────────────────────────────────────────────────────────
228
238
 
239
+ // Grace period for delisted models: update-models.js moves models the API no
240
+ // longer lists into deprecated-models.json (stamped with deprecatedAt) instead
241
+ // of dropping them; the runtime appends them back so sessions and saved model
242
+ // settings keep working, and after 14 days they are evicted permanently.
243
+ const DEPRECATED_MODEL_TTL_MS = 14 * 24 * 60 * 60 * 1000;
244
+
245
+ /**
246
+ * Reconcile deprecated-models.json against the freshly fetched model list.
247
+ * - in old models.json but not the API: moved into the deprecated file
248
+ * (deprecatedAt = now; preserved on repeat runs so the grace clock is not reset)
249
+ * - back in the API: resurrected (dropped from the deprecated file)
250
+ * - deprecatedAt older than 14 days: evicted permanently
251
+ * Must run BEFORE the new models.json is written; it reads the old file itself.
252
+ */
253
+ function updateDeprecatedModels(modelsJsonPath, newModels) {
254
+ const deprecatedPath = path.join(path.dirname(modelsJsonPath), 'deprecated-models.json');
255
+
256
+ let oldModels = [];
257
+ try {
258
+ const parsed = JSON.parse(fs.readFileSync(modelsJsonPath, 'utf8'));
259
+ if (Array.isArray(parsed)) oldModels = parsed;
260
+ } catch { /* first run: no previous models.json */ }
261
+
262
+ let deprecated = {};
263
+ try {
264
+ const parsed = JSON.parse(fs.readFileSync(deprecatedPath, 'utf8'));
265
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) deprecated = parsed;
266
+ } catch { /* no graveyard yet */ }
267
+
268
+ const currentIds = new Set(newModels.map(m => m.id));
269
+ const now = new Date().toISOString();
270
+ const added = [];
271
+ const resurrected = [];
272
+ const evicted = [];
273
+
274
+ for (const old of oldModels) {
275
+ if (old && old.id && !currentIds.has(old.id) && !deprecated[old.id]) {
276
+ deprecated[old.id] = { ...old, deprecatedAt: now };
277
+ added.push(old.id);
278
+ }
279
+ }
280
+
281
+ for (const [id, entry] of Object.entries(deprecated)) {
282
+ if (currentIds.has(id)) {
283
+ delete deprecated[id];
284
+ resurrected.push(id);
285
+ continue;
286
+ }
287
+ const removedAt = Date.parse(entry && entry.deprecatedAt ? entry.deprecatedAt : '');
288
+ if (Number.isNaN(removedAt) || Date.now() - removedAt > DEPRECATED_MODEL_TTL_MS) {
289
+ delete deprecated[id];
290
+ evicted.push(id);
291
+ }
292
+ }
293
+
294
+ if (added.length > 0 || resurrected.length > 0 || evicted.length > 0) {
295
+ fs.writeFileSync(deprecatedPath, JSON.stringify(deprecated, null, 2) + '\n');
296
+ console.log('Updated deprecated-models.json ' + JSON.stringify({ added, resurrected, evicted }));
297
+ }
298
+ }
299
+
229
300
  async function main() {
230
301
  try {
231
302
  const apiModels = await fetchModels();
@@ -249,6 +320,8 @@ async function main() {
249
320
  models.sort((a, b) => a.name.localeCompare(b.name));
250
321
 
251
322
  // Save models.json (pure API output, no patch/custom baked in)
323
+ // Move delisted models to deprecated-models.json BEFORE models.json is overwritten
324
+ updateDeprecatedModels(MODELS_JSON_PATH, models);
252
325
  saveJson(MODELS_JSON_PATH, models);
253
326
 
254
327
  // Build full model list for README: base → patch → custom