pi-coralbricks-provider 1.0.16 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # 🪸 pi-coralbricks-provider
4
4
 
5
- **GLM 5.3, GLM 5.3 Flash, Kimi K3 & GPT-OSS 120B through [Coral Bricks](https://www.coralbricks.ai)**
5
+ **GLM 5.3, GLM 5.3 Flash & DeepSeek V4.1 Flash through [Coral Bricks](https://www.coralbricks.ai)**
6
6
 
7
7
  _A [pi](https://github.com/earendil-works/pi-coding-agent) provider extension for Coral's OpenAI-compatible inference gateway — up to **1M context** on open models._
8
8
 
@@ -17,13 +17,13 @@ _A [pi](https://github.com/earendil-works/pi-coding-agent) provider extension fo
17
17
 
18
18
  ## Features
19
19
 
20
- - **4 reasoning models** from Coral's live catalog — GLM 5.3 FP4, GLM 5.3 Flash, Kimi K3, and GPT-OSS 120B
21
- - **1M token context** on GLM and Kimi, with vision (image input) on Kimi K3 and GLM 5.3 Flash
20
+ - **3 reasoning models** from Coral's live catalog — GLM 5.3 FP4, GLM 5.3 Flash, and DeepSeek V4.1 Flash
21
+ - **1M token context** on GLM and DeepSeek, with vision (image input) on GLM 5.3 Flash and DeepSeek V4.1 Flash
22
22
  - **OpenAI-compatible API** — standard `/v1/chat/completions`, streaming, and tool calling
23
- - **Per-family thinking levels** — zai-style `thinking` control for GLM (including a *real* off switch), `reasoning_effort` for Kimi K3 and GPT-OSS
24
- - **Accurate cost tracking** — pricing mirrors Coral's published rates, and cached reads are **$0 on every model**, so pi's computed cost matches Coral's own `usage.cost` to the token
23
+ - **Per-family thinking levels** — zai-style `thinking` control for GLM (including a *real* off switch), `reasoning_effort` for DeepSeek V4.1 Flash
24
+ - **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**
25
25
  - **Self-healing model sync** — stale-while-revalidate from the authenticated `/v1/models` (or the unauthenticated [public catalog](https://www.coralbricks.ai/api/public/models) before auth), hot-swapped at session start
26
- - **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 and Kimi K3 in both unary and streaming modes
26
+ - **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
27
27
 
28
28
  ## Installation
29
29
 
@@ -78,14 +78,13 @@ pi
78
78
 
79
79
  ## Available Models
80
80
 
81
- | Model | Context | Vision | Reasoning | Input $/M | Cache Read $/M | Output $/M |
82
- |-------|---------|--------|-----------|-----------|-----------------|------------|
83
- | DeepSeek V4.1 Flash | 1.0M | ❌ | ❌ | $0.30 | — | $1.20 |
84
- | GLM 5.3 Flash | 1.0M | ✅ | ✅ | $0.15 | — | $0.50 |
85
- | GLM 5.3 FP4 | 1.0M | ❌ | ✅ | $1.12 | — | $4.40 |
86
- | GPT-OSS 120B | 131K | ❌ | ✅ | $0.12 | — | $0.60 |
81
+ | Model | Context | Vision | Reasoning | Input $/M | Cache Read $/M | Cache Write $/M | Output $/M |
82
+ |-------|---------|--------|-----------|-----------|-----------------|------------------|------------|
83
+ | DeepSeek V4.1 Flash | 1.0M | ✅ | ✅ | $0.30 | — | $0.09 | $1.20 |
84
+ | GLM 5.3 Flash | 1.0M | ✅ | ✅ | $0.15 | — | $0.23 | $0.50 |
85
+ | GLM 5.3 FP4 | 1.0M | ❌ | ✅ | $1.12 | — | $1.68 | $4.40 |
87
86
 
88
- *Costs are per million tokens. Cache Read shows — because Coral bills cached input at **$0** on every model. Prices subject to change — check [Coral's live catalog](https://www.coralbricks.ai/api/public/models).*
87
+ *Costs are per million tokens. Cache Read shows — because Coral bills cached input at **$0** on every model. Prompt tokens Coral has not cached yet are billed once at the Cache Write rate, in place of the Input rate. Prices subject to change — check [Coral's live catalog](https://www.coralbricks.ai/api/public/models).*
89
88
 
90
89
  ## Usage
91
90
 
@@ -98,10 +97,10 @@ After loading the extension, use the `/model` command in pi to select your prefe
98
97
  Or start pi directly with a Coral model:
99
98
 
100
99
  ```bash
101
- pi -e /path/to/pi-coralbricks-provider --model coralbricks/kimi-k3:high
100
+ pi -e /path/to/pi-coralbricks-provider --model coralbricks/deepseek-v4.1-flash-fast-fp4:high
102
101
  ```
103
102
 
104
- Thinking levels attach to the model id with `:<level>` — e.g. `:low`, `:high`, `:max`, or `:off` (GLM only).
103
+ Thinking levels attach to the model id with `:<level>` — e.g. `:low`, `:high`, `:max`, or `:off` (GLM and DeepSeek).
105
104
 
106
105
  ## Authentication
107
106
 
@@ -131,12 +130,10 @@ Verified against the live gateway:
131
130
  |-------|--------|-----|-----|--------|------|-----|
132
131
  | GLM 5.3 FP4 | `thinking: {type}` + `reasoning_effort` | ✅ | ✅ | — | ✅ | ✅ |
133
132
  | GLM 5.3 Flash | `thinking: {type}` + `reasoning_effort` | ✅ | ✅ | — | ✅ | ✅ |
134
- | Kimi K3 | `reasoning_effort` | — | ✅ | — | ✅ | ✅ |
135
- | GPT-OSS 120B | `reasoning_effort` | — | ✅ | ✅ | ✅ | — |
133
+ | DeepSeek V4.1 Flash | `reasoning_effort` | ✅ | ✅ | — | ✅ | ✅ |
136
134
 
137
135
  - **GLM** accepts zai-style `thinking: {type: "disabled"}` on Coral — pi's *off* level turns thinking off, though a short preamble of a few dozen tokens can still appear (the upstream Z.ai API has no off at all, so this differs from the canonical Z.ai map).
138
- - **Kimi K3** always thinks: `reasoning_effort: "none"` is accepted but doesn't disable reasoning, so *off* is intentionally not offered. Assistant replays include `reasoning_content`.
139
- - **GPT-OSS** exposes the standard low/medium/high reasoning efforts; reasoning arrives in `reasoning_content`.
136
+ - **DeepSeek V4.1 Flash** reasons only when asked: a request without `reasoning_effort`, or with `"none"`, gets no reasoning, which is what pi's *off* sends. `thinking: {type}` has no effect on this model.
140
137
  - Coral streams a duplicate `reasoning` field alongside `reasoning_content`; pi dedupes these automatically.
141
138
 
142
139
  ## Compat Settings
@@ -146,30 +143,29 @@ Coral's gateway follows the OpenAI Chat Completions API:
146
143
  - **`supportsStore: false`** / **`supportsDeveloperRole: false`** — all models; Coral serves open models on the classic roles.
147
144
  - **`maxTokensField: "max_tokens"`** — all models.
148
145
  - **`thinkingFormat: "zai"`** — GLM 5.3: `thinking: {type: "enabled"|"disabled"}` toggles reasoning, `reasoning_effort` picks the depth.
149
- - **`thinkingFormat: "openai"`** — Kimi K3 and GPT-OSS 120B: `reasoning_effort` drives thinking depth.
150
- - **`supportsStrictMode: false`** — Kimi K3 (no strict JSON-schema tool definitions).
151
- - **`requiresReasoningContentOnAssistantMessages: true`** — Kimi K3.
146
+ - **`thinkingFormat: "openai"`** — DeepSeek V4.1 Flash: `reasoning_effort` drives thinking depth.
152
147
 
153
148
  ### Patch Overrides & Custom Models
154
149
 
155
- - **`patch.json`** — per-model overrides applied on top of `models.json` (reasoning flags, pricing corrections, compat settings, thinking level maps). Currently carries the GLM 5.3 Flash entry: reasoning, image input, its thinking level map and compat settings.
150
+ - **`patch.json`** — per-model overrides applied on top of `models.json` (reasoning flags, pricing corrections, compat settings, thinking level maps). Currently carries two entries: GLM 5.3 Flash (reasoning, image input, its thinking level map and compat settings) and DeepSeek V4.1 Flash (reasoning, its thinking level map and compat settings).
156
151
  - **`custom-models.json`** — full model definitions for models Coral doesn't list. Merged after patch.
157
152
 
158
153
  Merge order: `[live|cache|embedded] → patch.json → custom-models.json`
159
154
 
160
155
  ## Inference-Quality Testing
161
156
 
162
- Validated with [synbad](https://github.com/synthetic-lab/synbad) — Synthetic's tool-calling and reasoning-parsing eval suite for LLM inference providers (current 13-eval suite, `--reasoning-effort high`, five `--count 1` runs per mode on 2026-09-15; a cell shows the evals that passed in every run):
157
+ Validated with [synbad](https://github.com/synthetic-lab/synbad) — Synthetic's tool-calling and reasoning-parsing eval suite for LLM inference providers (current 13-eval suite, `--reasoning-effort high`, five `--count 1` runs per mode on 2026-09-15, DeepSeek on 2026-09-22; a cell shows the evals that passed in every run):
163
158
 
164
159
  | Model | Unary | Stream | Notes |
165
160
  |-------|-------|--------|-------|
166
161
  | GLM 5.3 FP4 | 13/13 ✅ | 13/13 ✅ | 5/5 runs |
167
162
  | GLM 5.3 Flash | 12/13 ⚠️ | 12/13 ⚠️ | `reasoning/reasoning-parsing` passes 4 of 5 runs; see below |
168
- | Kimi K3 | 13/13 ✅ | 13/13 ✅ | 5/5 runs |
169
- | GPT-OSS 120B | 12/13 ⚠️ | 12/13 ⚠️ | `tools/parallel-tool` 0 of 5 runs; see below |
163
+ | GPT-OSS 120B (retired) | 12/13 ⚠️ | 12/13 ⚠️ | `tools/parallel-tool` 0 of 5 runs, before Coral retired the model |
164
+ | DeepSeek V4.1 Flash | 12/13 ⚠️ | 12/13 ⚠️ | `tools/octo-list-no-optional-args` passes 1 of 5 runs; see below |
165
+ | Kimi K3 (retired) | 13/13 ✅ | 13/13 ✅ | 5/5 runs, before Coral retired the model |
170
166
  | GLM 5.2 FP4 (retired) | 15/15 ✅ | 15/15 ✅ | 15-eval suite, single run, before Coral retired the model |
171
167
 
172
- The two misses are model-side, not transport: gpt-oss answers "Paris and London" with one call after another instead of two parallel calls even when `parallel_tool_calls: true`, in every run; and on the trivial prompt `reasoning-parsing` uses, GLM 5.3 Flash answers without a think block about one run in five, in both modes, with the answer itself unaffected.
168
+ The misses are model-side, not transport: gpt-oss answered "Paris and London" with one call after another instead of two parallel calls even when `parallel_tool_calls: true`, in every run; on the trivial prompt `reasoning-parsing` uses, GLM 5.3 Flash answers without a think block about one run in five, in both modes, with the answer itself unaffected; and asked to "call the list tool with no args", DeepSeek V4.1 Flash usually fills in the optional `dirPath` as `"."`.
173
169
 
174
170
  Reproduce:
175
171
 
@@ -1,38 +1 @@
1
- {
2
- "kimi-k3": {
3
- "id": "kimi-k3",
4
- "name": "Kimi K3",
5
- "reasoning": true,
6
- "input": [
7
- "text",
8
- "image"
9
- ],
10
- "cost": {
11
- "input": 3,
12
- "output": 15,
13
- "cacheRead": 0,
14
- "cacheWrite": 0
15
- },
16
- "contextWindow": 1048576,
17
- "maxTokens": 131072,
18
- "thinkingLevelMap": {
19
- "off": null,
20
- "minimal": null,
21
- "low": "low",
22
- "medium": null,
23
- "high": "high",
24
- "xhigh": null,
25
- "max": "max"
26
- },
27
- "compat": {
28
- "supportsStore": false,
29
- "supportsDeveloperRole": false,
30
- "supportsReasoningEffort": true,
31
- "maxTokensField": "max_tokens",
32
- "supportsStrictMode": false,
33
- "thinkingFormat": "openai",
34
- "requiresReasoningContentOnAssistantMessages": true
35
- },
36
- "deprecatedAt": "2026-09-21T00:00:07.264Z"
37
- }
38
- }
1
+ {}
package/index.ts CHANGED
@@ -224,7 +224,8 @@ function baseCompat() {
224
224
  /**
225
225
  * Transform a Coral /v1/models row. Coral reports per-million USD pricing
226
226
  * (input_per_m / output_per_m / cached_input_per_m — cached reads are $0 on
227
- * every model), context_length, and supports_image_input.
227
+ * every model — and cache_write_per_m, the rate for prompt tokens written to
228
+ * the cache), context_length, and supports_image_input.
228
229
  */
229
230
  function transformApiModel(apiModel: any): JsonModel | null {
230
231
  if (!apiModel?.id) return null;
@@ -238,7 +239,7 @@ function transformApiModel(apiModel: any): JsonModel | null {
238
239
  input: toNumber(pricing.input_per_m),
239
240
  output: toNumber(pricing.output_per_m),
240
241
  cacheRead: toNumber(pricing.cached_input_per_m),
241
- cacheWrite: 0,
242
+ cacheWrite: toNumber(pricing.cache_write_per_m),
242
243
  },
243
244
  contextWindow: parseContextWindow(apiModel.context_length),
244
245
  maxTokens: DEFAULT_MAX_TOKENS[apiModel.id] ?? FALLBACK_MAX_TOKENS,
@@ -252,7 +253,9 @@ function transformApiModel(apiModel: any): JsonModel | null {
252
253
  * (https://www.coralbricks.ai/api/public/models) — the no-auth mirror of the
253
254
  * gateway control plane. Fields: slug, name, contextWindow ("1M"),
254
255
  * inputPerM / outputPerM. Coral's own prices are authoritative here; the
255
- * parity/field vendor comparison fields are ignored.
256
+ * parity/field vendor comparison fields are ignored. The catalog carries no
257
+ * cache-write rate, so cacheWrite stays 0 and mergeWithEmbedded keeps the
258
+ * embedded rate.
256
259
  */
257
260
  function transformCatalogModel(entry: any): JsonModel | null {
258
261
  if (!entry?.slug) return null;
@@ -347,7 +350,8 @@ function mergeWithEmbedded(liveModels: JsonModel[], embeddedModels: JsonModel[])
347
350
  // Self-heal: live API pricing is authoritative field-by-field. Prefer the
348
351
  // live cost when the API reports it (non-zero); fall back to embedded when
349
352
  // the API is silent (0). Coral reports cached_input_per_m as 0 because
350
- // cached reads are free, which matches the curated cacheRead: 0.
353
+ // cached reads are free, which matches the curated cacheRead: 0. The
354
+ // public catalog has no cache-write rate, so its 0 keeps the embedded one.
351
355
  // Curation (reasoning/input/compat/name/thinkingLevelMap/maxTokens) still wins via ...embedded.
352
356
  result.push({
353
357
  ...liveModel,
package/models.json CHANGED
@@ -4,13 +4,14 @@
4
4
  "name": "DeepSeek V4.1 Flash",
5
5
  "reasoning": false,
6
6
  "input": [
7
- "text"
7
+ "text",
8
+ "image"
8
9
  ],
9
10
  "cost": {
10
11
  "input": 0.3,
11
12
  "output": 1.2,
12
13
  "cacheRead": 0,
13
- "cacheWrite": 0
14
+ "cacheWrite": 0.09
14
15
  },
15
16
  "contextWindow": 1048576,
16
17
  "maxTokens": 32768,
@@ -32,7 +33,7 @@
32
33
  "input": 0.15,
33
34
  "output": 0.5,
34
35
  "cacheRead": 0,
35
- "cacheWrite": 0
36
+ "cacheWrite": 0.23
36
37
  },
37
38
  "contextWindow": 1048576,
38
39
  "maxTokens": 32768,
@@ -53,7 +54,7 @@
53
54
  "input": 1.12,
54
55
  "output": 4.4,
55
56
  "cacheRead": 0,
56
- "cacheWrite": 0
57
+ "cacheWrite": 1.68
57
58
  },
58
59
  "contextWindow": 1048576,
59
60
  "maxTokens": 131072,
@@ -73,37 +74,5 @@
73
74
  "maxTokensField": "max_tokens",
74
75
  "thinkingFormat": "zai"
75
76
  }
76
- },
77
- {
78
- "id": "gpt-oss-120b",
79
- "name": "GPT-OSS 120B",
80
- "reasoning": true,
81
- "input": [
82
- "text"
83
- ],
84
- "cost": {
85
- "input": 0.12,
86
- "output": 0.6,
87
- "cacheRead": 0,
88
- "cacheWrite": 0
89
- },
90
- "contextWindow": 131072,
91
- "maxTokens": 40960,
92
- "thinkingLevelMap": {
93
- "off": null,
94
- "minimal": null,
95
- "low": "low",
96
- "medium": "medium",
97
- "high": "high",
98
- "xhigh": null,
99
- "max": null
100
- },
101
- "compat": {
102
- "supportsStore": false,
103
- "supportsDeveloperRole": false,
104
- "supportsReasoningEffort": true,
105
- "maxTokensField": "max_tokens",
106
- "thinkingFormat": "openai"
107
- }
108
77
  }
109
78
  ]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-coralbricks-provider",
3
- "version": "1.0.16",
4
- "description": "CoralBricks provider extension for pi - Access GLM 5.3, GLM 5.3 Flash, Kimi K3, and GPT-OSS 120B through the Coral Inference API",
3
+ "version": "1.0.18",
4
+ "description": "CoralBricks provider extension for pi - Access GLM 5.3, GLM 5.3 Flash, and DeepSeek V4.1 Flash through the Coral Inference API",
5
5
  "author": "monotykamary",
6
6
  "homepage": "https://github.com/monotykamary/pi-coralbricks-provider#readme",
7
7
  "repository": {
@@ -13,6 +13,13 @@
13
13
  },
14
14
  "type": "module",
15
15
  "main": "index.ts",
16
+ "scripts": {
17
+ "clean": "echo 'nothing to clean'",
18
+ "build": "echo 'nothing to build'",
19
+ "check": "tsc --noEmit && vitest run",
20
+ "test": "vitest run",
21
+ "update-models": "node scripts/update-models.js"
22
+ },
16
23
  "keywords": [
17
24
  "pi-package",
18
25
  "pi",
@@ -23,8 +30,8 @@
23
30
  "ai",
24
31
  "llm",
25
32
  "glm",
26
- "kimi",
27
- "gpt-oss"
33
+ "deepseek",
34
+ "deepseek"
28
35
  ],
29
36
  "license": "MIT",
30
37
  "files": [
@@ -50,12 +57,5 @@
50
57
  "dependencies": {
51
58
  "@earendil-works/pi-ai": "0.87.0",
52
59
  "@earendil-works/pi-coding-agent": "0.87.0"
53
- },
54
- "scripts": {
55
- "clean": "echo 'nothing to clean'",
56
- "build": "echo 'nothing to build'",
57
- "check": "tsc --noEmit && vitest run",
58
- "test": "vitest run",
59
- "update-models": "node scripts/update-models.js"
60
60
  }
61
- }
61
+ }
package/patch.json CHANGED
@@ -1,4 +1,20 @@
1
1
  {
2
+ "deepseek-v4.1-flash-fast-fp4": {
3
+ "reasoning": true,
4
+ "thinkingLevelMap": {
5
+ "off": "none",
6
+ "minimal": null,
7
+ "low": "low",
8
+ "medium": null,
9
+ "high": "high",
10
+ "xhigh": null,
11
+ "max": "max"
12
+ },
13
+ "compat": {
14
+ "supportsReasoningEffort": true,
15
+ "thinkingFormat": "openai"
16
+ }
17
+ },
2
18
  "glm-5.3-flash-fp4": {
3
19
  "reasoning": true,
4
20
  "input": ["text", "image"],
@@ -136,6 +136,7 @@ function resolveApiKey() {
136
136
  }
137
137
 
138
138
  const MODELS_API_URL = 'https://inference.coralbricks.ai/v1/models';
139
+ const CHAT_COMPLETIONS_URL = 'https://inference.coralbricks.ai/v1/chat/completions';
139
140
  const PUBLIC_CATALOG_URL = 'https://www.coralbricks.ai/api/public/models';
140
141
  const MODELS_JSON_PATH = path.join(__dirname, '..', 'models.json');
141
142
  const PATCH_JSON_PATH = path.join(__dirname, '..', 'patch.json');
@@ -249,6 +250,7 @@ function transformApiModel(apiModel, existingModelsMap) {
249
250
  if (typeof pricing.input_per_m === 'number') existing.cost.input = pricing.input_per_m;
250
251
  if (typeof pricing.output_per_m === 'number') existing.cost.output = pricing.output_per_m;
251
252
  existing.cost.cacheRead = toNumber(pricing.cached_input_per_m);
253
+ if (typeof pricing.cache_write_per_m === 'number') existing.cost.cacheWrite = pricing.cache_write_per_m;
252
254
  existing.input = input;
253
255
  return existing;
254
256
  }
@@ -263,7 +265,7 @@ function transformApiModel(apiModel, existingModelsMap) {
263
265
  input: toNumber(pricing.input_per_m),
264
266
  output: toNumber(pricing.output_per_m),
265
267
  cacheRead: toNumber(pricing.cached_input_per_m),
266
- cacheWrite: 0,
268
+ cacheWrite: toNumber(pricing.cache_write_per_m),
267
269
  },
268
270
  contextWindow,
269
271
  maxTokens,
@@ -392,8 +394,8 @@ function formatCost(cost) {
392
394
 
393
395
  function generateReadmeTable(models) {
394
396
  const lines = [
395
- '| Model | Context | Vision | Reasoning | Input $/M | Cache Read $/M | Output $/M |',
396
- '|-------|---------|--------|-----------|-----------|-----------------|------------|',
397
+ '| Model | Context | Vision | Reasoning | Input $/M | Cache Read $/M | Cache Write $/M | Output $/M |',
398
+ '|-------|---------|--------|-----------|-----------|-----------------|------------------|------------|',
397
399
  ];
398
400
 
399
401
  for (const model of models) {
@@ -402,9 +404,10 @@ function generateReadmeTable(models) {
402
404
  const reasoning = model.reasoning ? '✅' : '❌';
403
405
  const inputCost = formatCost(model.cost.input);
404
406
  const cacheReadCost = formatCost(model.cost.cacheRead);
407
+ const cacheWriteCost = formatCost(model.cost.cacheWrite);
405
408
  const outputCost = formatCost(model.cost.output);
406
409
 
407
- lines.push(`| ${model.name} | ${context} | ${vision} | ${reasoning} | ${inputCost} | ${cacheReadCost} | ${outputCost} |`);
410
+ lines.push(`| ${model.name} | ${context} | ${vision} | ${reasoning} | ${inputCost} | ${cacheReadCost} | ${cacheWriteCost} | ${outputCost} |`);
408
411
  }
409
412
 
410
413
  return lines.join('\n');
@@ -429,9 +432,41 @@ function updateReadme(models) {
429
432
  // longer lists into deprecated-models.json (stamped with deprecatedAt) instead
430
433
  // of dropping them; the runtime appends them back so sessions and saved model
431
434
  // settings keep working, and after 14 days they are evicted permanently.
435
+ //
436
+ // The grace period exists for a model that is still ANSWERING while it leaves
437
+ // the catalog. A retired one is not: Coral answers 404 with
438
+ // `error.code == "model_retired"`, so keeping it registered only offers the
439
+ // user a model that cannot reply, and /model still lists it for two weeks.
440
+ // Each delisted model is probed once, and a retired one is dropped instead of
441
+ // parked. Anything else — an unknown id, a key without access, a network
442
+ // blip — keeps the grace period, because only `model_retired` is a statement
443
+ // that the model is gone for everybody rather than missing for this key.
432
444
  const DEPRECATED_MODEL_TTL_MS = 14 * 24 * 60 * 60 * 1000;
445
+ const RETIRED_PROBE_TIMEOUT_MS = 10000;
446
+
447
+ /**
448
+ * Ask the gateway whether a model is retired. Returns true ONLY on an explicit
449
+ * 404 `model_retired`; every other outcome returns false, so a probe that
450
+ * cannot answer never shortens a grace period.
451
+ */
452
+ async function isRetiredUpstream(id, apiKey) {
453
+ if (!apiKey) return false;
454
+ try {
455
+ const response = await fetch(CHAT_COMPLETIONS_URL, {
456
+ method: 'POST',
457
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
458
+ body: JSON.stringify({ model: id, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }),
459
+ signal: AbortSignal.timeout(RETIRED_PROBE_TIMEOUT_MS),
460
+ });
461
+ if (response.status !== 404) return false;
462
+ const body = await response.json();
463
+ return body?.error?.code === 'model_retired';
464
+ } catch {
465
+ return false; // offline, timeout, unparseable body: keep the grace period
466
+ }
467
+ }
433
468
 
434
- function updateDeprecatedModels(modelsJsonPath, newModels) {
469
+ async function updateDeprecatedModels(modelsJsonPath, newModels, apiKey) {
435
470
  const deprecatedPath = path.join(path.dirname(modelsJsonPath), 'deprecated-models.json');
436
471
 
437
472
  let oldModels = [];
@@ -451,12 +486,25 @@ function updateDeprecatedModels(modelsJsonPath, newModels) {
451
486
  const added = [];
452
487
  const resurrected = [];
453
488
  const evicted = [];
454
-
455
- for (const old of oldModels) {
456
- if (old && old.id && !currentIds.has(old.id) && !deprecated[old.id]) {
457
- deprecated[old.id] = { ...old, deprecatedAt: now };
458
- added.push(old.id);
489
+ const retired = [];
490
+
491
+ const candidates = oldModels.filter((old) => old && old.id && !currentIds.has(old.id) && !deprecated[old.id]);
492
+ // Probe the delisted models plus anything already parked: a model can retire
493
+ // during its own grace period, which is exactly when it starts 404ing.
494
+ const parked = Object.keys(deprecated).filter((id) => !currentIds.has(id));
495
+ const toProbe = [...new Set([...candidates.map((m) => m.id), ...parked])];
496
+ const retiredIds = new Set(
497
+ (await Promise.all(toProbe.map(async (id) => ((await isRetiredUpstream(id, apiKey)) ? id : null))))
498
+ .filter((id) => id !== null)
499
+ );
500
+
501
+ for (const old of candidates) {
502
+ if (retiredIds.has(old.id)) {
503
+ retired.push(old.id);
504
+ continue; // retired upstream: 404s, so a grace period buys nothing
459
505
  }
506
+ deprecated[old.id] = { ...old, deprecatedAt: now };
507
+ added.push(old.id);
460
508
  }
461
509
 
462
510
  for (const [id, entry] of Object.entries(deprecated)) {
@@ -465,6 +513,11 @@ function updateDeprecatedModels(modelsJsonPath, newModels) {
465
513
  resurrected.push(id);
466
514
  continue;
467
515
  }
516
+ if (retiredIds.has(id)) {
517
+ delete deprecated[id];
518
+ retired.push(id);
519
+ continue;
520
+ }
468
521
  const removedAt = Date.parse(entry && entry.deprecatedAt ? entry.deprecatedAt : '');
469
522
  if (Number.isNaN(removedAt) || Date.now() - removedAt > DEPRECATED_MODEL_TTL_MS) {
470
523
  delete deprecated[id];
@@ -472,9 +525,9 @@ function updateDeprecatedModels(modelsJsonPath, newModels) {
472
525
  }
473
526
  }
474
527
 
475
- if (added.length > 0 || resurrected.length > 0 || evicted.length > 0) {
528
+ if (added.length > 0 || resurrected.length > 0 || evicted.length > 0 || retired.length > 0) {
476
529
  fs.writeFileSync(deprecatedPath, JSON.stringify(deprecated, null, 2) + '\n');
477
- console.log('Updated deprecated-models.json ' + JSON.stringify({ added, resurrected, evicted }));
530
+ console.log('Updated deprecated-models.json ' + JSON.stringify({ added, resurrected, evicted, retired }));
478
531
  }
479
532
  }
480
533
 
@@ -517,7 +570,7 @@ async function main() {
517
570
 
518
571
  // Save models.json (pure API output, no patch/custom baked in)
519
572
  // Move delisted models to deprecated-models.json BEFORE models.json is overwritten
520
- updateDeprecatedModels(MODELS_JSON_PATH, models);
573
+ await updateDeprecatedModels(MODELS_JSON_PATH, models, resolveApiKey());
521
574
  saveJson(MODELS_JSON_PATH, models);
522
575
 
523
576
  // Build the README model list: base → patch → custom. Grace-period models stay
@@ -549,4 +602,10 @@ async function main() {
549
602
  }
550
603
  }
551
604
 
552
- main();
605
+ // Run only when invoked as a script, so the deprecation logic below can be
606
+ // imported by tests without kicking off a sync.
607
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
608
+ main();
609
+ }
610
+
611
+ export { isRetiredUpstream, updateDeprecatedModels };