@monotykamary/pi-opencode-provider 1.0.10 → 1.0.12

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.
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Script to update opencode models from models.dev API
5
+ * Writes to models.json (Pi-native format) and updates README.md.
6
+ *
7
+ * Pipeline: models.dev API → models.json (idempotent, re-runnable)
8
+ * patch.json + custom-models.json for our layered overrides
9
+ */
10
+
11
+ import https from 'https';
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { fileURLToPath } from 'url';
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = path.dirname(__filename);
18
+
19
+ const API_URL = 'https://models.dev/api.json';
20
+ const PROVIDER_ID = 'opencode';
21
+
22
+ // Map models.dev provider.npm to pi API type and base URL
23
+ const NPM_TO_API = {
24
+ '@ai-sdk/anthropic': { api: 'anthropic-messages', baseUrl: 'https://opencode.ai/zen' },
25
+ '@ai-sdk/openai': { api: 'openai-responses', baseUrl: 'https://opencode.ai/zen/v1' },
26
+ '@ai-sdk/google': { api: 'google-generative-ai', baseUrl: 'https://opencode.ai/zen/v1' },
27
+ };
28
+ const DEFAULT_API = { api: 'openai-completions', baseUrl: 'https://opencode.ai/zen/v1' };
29
+
30
+ // Fetch JSON from URL
31
+ function fetchJSON(url) {
32
+ return new Promise((resolve, reject) => {
33
+ https.get(url, (res) => {
34
+ let data = '';
35
+ res.on('data', chunk => data += chunk);
36
+ res.on('end', () => {
37
+ try {
38
+ resolve(JSON.parse(data));
39
+ } catch (e) {
40
+ reject(new Error(`Failed to parse JSON: ${e.message}`));
41
+ }
42
+ });
43
+ }).on('error', reject);
44
+ });
45
+ }
46
+
47
+ // Format cost for display
48
+ function formatCost(cost) {
49
+ if (cost === 0) return '—';
50
+ if (cost === null || cost === undefined) return '—';
51
+ return '$' + cost.toFixed(2);
52
+ }
53
+
54
+ // Format number with K/M suffix
55
+ function formatNumber(num) {
56
+ if (num === null || num === undefined) return '-';
57
+ if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
58
+ if (num >= 1000) return `${(num / 1000).toFixed(0)}K`;
59
+ return num.toString();
60
+ }
61
+
62
+ // Get input types from modalities (pi supports "text" and "image" only)
63
+ function getInputTypes(modalities) {
64
+ const raw = modalities?.input || ['text'];
65
+ const filtered = raw.filter(m => m === 'text' || m === 'image');
66
+ if (!filtered.includes('text')) filtered.unshift('text');
67
+ return filtered;
68
+ }
69
+
70
+ // Get API label for display
71
+ function getApiLabel(api) {
72
+ const labels = {
73
+ 'anthropic-messages': 'Anthropic',
74
+ 'openai-responses': 'Responses',
75
+ 'openai-completions': 'Completions',
76
+ 'google-generative-ai': 'Gemini',
77
+ };
78
+ return labels[api] || api;
79
+ }
80
+
81
+ // Convert API model to Pi-native format with per-model api/baseUrl
82
+ function convertModel(model) {
83
+ const npm = model.provider?.npm;
84
+ const { api, baseUrl } = (npm && NPM_TO_API[npm]) || DEFAULT_API;
85
+ const inputTypes = getInputTypes(model.modalities);
86
+ const cost = model.cost || {};
87
+ const limit = model.limit || {};
88
+
89
+ return {
90
+ id: model.id,
91
+ name: model.name,
92
+ api,
93
+ baseUrl,
94
+ reasoning: model.reasoning || false,
95
+ input: inputTypes,
96
+ cost: {
97
+ input: cost.input || 0,
98
+ output: cost.output || 0,
99
+ cacheRead: cost.cache_read || 0,
100
+ cacheWrite: cost.cache_write || 0,
101
+ },
102
+ contextWindow: limit.context || 0,
103
+ maxTokens: limit.output || 0,
104
+ };
105
+ }
106
+
107
+ // Load a layered JSON model file.
108
+ function loadJson(fileName) {
109
+ try {
110
+ return JSON.parse(fs.readFileSync(path.join(process.cwd(), fileName), 'utf8'));
111
+ } catch {
112
+ return fileName === 'patch.json' ? {} : [];
113
+ }
114
+ }
115
+
116
+ // Deep-merge patch overrides into a model for README documentation. The
117
+ // generated models.json remains API-derived; only the displayed model map uses patches.
118
+ function applyPatch(model, patch) {
119
+ if (!patch) return model;
120
+ const result = { ...model };
121
+ for (const [key, value] of Object.entries(patch)) {
122
+ if (key === 'cost' || key === 'compat' || key === 'thinkingLevelMap') {
123
+ result[key] = { ...(result[key] || {}), ...value };
124
+ } else {
125
+ result[key] = value;
126
+ }
127
+ }
128
+ if (!result.reasoning) {
129
+ delete result.thinkingLevelMap;
130
+ if (result.compat?.thinkingFormat) delete result.compat.thinkingFormat;
131
+ }
132
+ return result;
133
+ }
134
+
135
+ // Merge API models, patches, and custom models in the same order as index.ts.
136
+ function buildModels(baseModels, customModels, patch) {
137
+ const byId = new Map(baseModels.map(model => [model.id, model]));
138
+ for (const [id, entry] of Object.entries(patch)) {
139
+ if (byId.has(id)) byId.set(id, applyPatch(byId.get(id), entry));
140
+ }
141
+ for (const model of customModels) {
142
+ byId.set(model.id, applyPatch(model, patch[model.id]));
143
+ }
144
+ return Array.from(byId.values());
145
+ }
146
+
147
+ // Generate README model table row
148
+ function generateReadmeRow(model) {
149
+ const cost = model.cost || {};
150
+ const hasImage = model.input.includes('image');
151
+ const typeLabel = hasImage ? 'Text + Image' : 'Text';
152
+ return `| ${model.name} | ${getApiLabel(model.api)} | ${typeLabel} | ${formatNumber(model.contextWindow)} | ${formatNumber(model.maxTokens)} | ${formatCost(cost.input)} | ${formatCost(cost.output)} |`;
153
+ }
154
+
155
+ // Update README model table
156
+ function updateReadme(models) {
157
+ const readmePath = path.join(process.cwd(), 'README.md');
158
+ let readme;
159
+
160
+ try {
161
+ readme = fs.readFileSync(readmePath, 'utf8');
162
+ } catch {
163
+ console.log(' No README.md found, skipping README update');
164
+ return;
165
+ }
166
+
167
+ // Sort models by name
168
+ const sortedModels = [...models].sort((a, b) => a.name.localeCompare(b.name));
169
+
170
+ // Generate table rows
171
+ const tableRows = sortedModels.map(generateReadmeRow).join('\n');
172
+ const newTable = `| Model | API | Type | Context | Max Tokens | Input Cost | Output Cost |
173
+ |-------|-----|------|---------|------------|------------|-------------|
174
+ ${tableRows}`;
175
+
176
+ // Replace table in README
177
+ const tableRegex = /\| Model \| API \| Type \| Context \| Max Tokens \| Input Cost \| Output Cost \|[\s\S]*?(?=\n\*Costs are per million)/;
178
+ if (readme.match(tableRegex)) {
179
+ readme = readme.replace(tableRegex, newTable);
180
+ } else {
181
+ // Fallback: replace old 6-column table format
182
+ const oldTableRegex = /\| Model \| Type \| Context \| Max Tokens \| Input Cost \| Output Cost \|[\s\S]*?(?=\n\*Costs are per million)/;
183
+ if (readme.match(oldTableRegex)) {
184
+ readme = readme.replace(oldTableRegex, newTable);
185
+ }
186
+ }
187
+
188
+ // Update model count in features
189
+ readme = readme.replace(/\*\*\d+\+ AI Models\*\*/, `**${models.length}+ AI Models**`);
190
+
191
+ fs.writeFileSync(readmePath, readme);
192
+ console.log(` Updated README.md with ${models.length} models`);
193
+ }
194
+
195
+ // Grace period for delisted models: update-models.js moves models the API no
196
+ // longer lists into deprecated-models.json (stamped with deprecatedAt) instead
197
+ // of dropping them; the runtime appends them back so sessions and saved model
198
+ // settings keep working, and after 14 days they are evicted permanently.
199
+ const DEPRECATED_MODEL_TTL_MS = 14 * 24 * 60 * 60 * 1000;
200
+
201
+ /**
202
+ * Reconcile deprecated-models.json against the freshly fetched model list.
203
+ * - in old models.json but not the API: moved into the deprecated file
204
+ * (deprecatedAt = now; preserved on repeat runs so the grace clock is not reset)
205
+ * - back in the API: resurrected (dropped from the deprecated file)
206
+ * - deprecatedAt older than 14 days: evicted permanently
207
+ * Must run BEFORE the new models.json is written; it reads the old file itself.
208
+ */
209
+ function updateDeprecatedModels(modelsJsonPath, newModels) {
210
+ const deprecatedPath = path.join(path.dirname(modelsJsonPath), 'deprecated-models.json');
211
+
212
+ let oldModels = [];
213
+ try {
214
+ const parsed = JSON.parse(fs.readFileSync(modelsJsonPath, 'utf8'));
215
+ if (Array.isArray(parsed)) oldModels = parsed;
216
+ } catch { /* first run: no previous models.json */ }
217
+
218
+ let deprecated = {};
219
+ try {
220
+ const parsed = JSON.parse(fs.readFileSync(deprecatedPath, 'utf8'));
221
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) deprecated = parsed;
222
+ } catch { /* no graveyard yet */ }
223
+
224
+ const currentIds = new Set(newModels.map(m => m.id));
225
+ const now = new Date().toISOString();
226
+ const added = [];
227
+ const resurrected = [];
228
+ const evicted = [];
229
+
230
+ for (const old of oldModels) {
231
+ if (old && old.id && !currentIds.has(old.id) && !deprecated[old.id]) {
232
+ deprecated[old.id] = { ...old, deprecatedAt: now };
233
+ added.push(old.id);
234
+ }
235
+ }
236
+
237
+ for (const [id, entry] of Object.entries(deprecated)) {
238
+ if (currentIds.has(id)) {
239
+ delete deprecated[id];
240
+ resurrected.push(id);
241
+ continue;
242
+ }
243
+ const removedAt = Date.parse(entry && entry.deprecatedAt ? entry.deprecatedAt : '');
244
+ if (Number.isNaN(removedAt) || Date.now() - removedAt > DEPRECATED_MODEL_TTL_MS) {
245
+ delete deprecated[id];
246
+ evicted.push(id);
247
+ }
248
+ }
249
+
250
+ if (added.length > 0 || resurrected.length > 0 || evicted.length > 0) {
251
+ fs.writeFileSync(deprecatedPath, JSON.stringify(deprecated, null, 2) + '\n');
252
+ console.log('Updated deprecated-models.json ' + JSON.stringify({ added, resurrected, evicted }));
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Grace-period deprecated models (deprecatedAt within TTL) with metadata stripped.
258
+ * Keeps the README table serving models that are delisted but still within their
259
+ * 14-day grace window.
260
+ */
261
+ function withDeprecatedForReadme(models) {
262
+ const deprecatedPath = path.join(process.cwd(), 'deprecated-models.json');
263
+ let deprecated = {};
264
+ try {
265
+ const parsed = JSON.parse(fs.readFileSync(deprecatedPath, 'utf8'));
266
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) deprecated = parsed;
267
+ } catch { /* no graveyard yet */ }
268
+ const now = Date.now();
269
+ const seen = new Set(models.map(m => m.id));
270
+ const extras = [];
271
+ for (const entry of Object.values(deprecated)) {
272
+ if (!entry || !entry.id || seen.has(entry.id)) continue;
273
+ const removedAt = Date.parse(entry.deprecatedAt || '');
274
+ if (Number.isNaN(removedAt) || now - removedAt > DEPRECATED_MODEL_TTL_MS) continue;
275
+ const m = { ...entry };
276
+ delete m.deprecatedAt;
277
+ extras.push(m);
278
+ }
279
+ return extras.length > 0 ? [...models, ...extras] : models;
280
+ }
281
+ async function main() {
282
+ console.log('Fetching models from API...');
283
+
284
+ try {
285
+ const data = await fetchJSON(API_URL);
286
+ const provider = data[PROVIDER_ID];
287
+
288
+ if (!provider) {
289
+ throw new Error(`Provider "${PROVIDER_ID}" not found in API`);
290
+ }
291
+
292
+ if (!provider.models) {
293
+ throw new Error(`No models found for provider "${PROVIDER_ID}"`);
294
+ }
295
+
296
+ // Convert models object to array and filter out deprecated
297
+ const apiModels = Object.values(provider.models).filter(m => m.status !== 'deprecated');
298
+
299
+ console.log(`Found ${apiModels.length} active models`);
300
+
301
+ // Convert to Pi-native format and save to models.json
302
+ const models = apiModels.map(convertModel);
303
+ const modelsPath = path.join(process.cwd(), 'models.json');
304
+ // Move delisted models to deprecated-models.json BEFORE models.json is overwritten
305
+ updateDeprecatedModels(modelsPath, models);
306
+ fs.writeFileSync(modelsPath, JSON.stringify(models, null, 2) + '\n');
307
+ console.log(` Saved ${models.length} models to models.json`);
308
+
309
+ // Apply layered overrides for documentation: models.json → patch.json → custom-models.json.
310
+ const patch = loadJson('patch.json');
311
+ const customModels = Array.isArray(loadJson('custom-models.json')) ? loadJson('custom-models.json') : [];
312
+ updateReadme(buildModels(models, customModels, patch));
313
+
314
+ console.log('\nDone!');
315
+ } catch (error) {
316
+ console.error('Error:', error.message);
317
+ process.exit(1);
318
+ }
319
+ }
320
+
321
+ main();