pi-lilac-provider 1.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.
@@ -0,0 +1,342 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Update Lilac models from API
4
+ *
5
+ * Fetches models from https://api.getlilac.com/v1/models and updates:
6
+ * - models.json: Provider model definitions (enriched with pricing & compat)
7
+ * - README.md: Model table in the Available Models section
8
+ *
9
+ * The Lilac /v1/models API returns model info including:
10
+ * - Per-token pricing (prompt, completion, input_cache_read)
11
+ * - Context length, max completion tokens
12
+ * - Architecture (input modalities, output modalities)
13
+ * - Supported features (tools, reasoning)
14
+ * - Supported parameters
15
+ *
16
+ * Pricing is converted from per-token to per-million-tokens for pi.
17
+ *
18
+ * models.json is the source of truth for curated specs — the script preserves
19
+ * existing data and only adds new models with API-derived defaults.
20
+ * Curate models.json manually after new model discovery.
21
+ *
22
+ * patch.json and custom-models.json are applied at runtime by the provider.
23
+ * They are NOT baked into models.json, but ARE used to generate the README table.
24
+ *
25
+ * Requires LILAC_API_KEY environment variable.
26
+ */
27
+
28
+ import fs from 'fs';
29
+ import path from 'path';
30
+ import { fileURLToPath } from 'url';
31
+
32
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
33
+
34
+ const MODELS_API_URL = 'https://api.getlilac.com/v1/models';
35
+ const MODELS_JSON_PATH = path.join(__dirname, '..', 'models.json');
36
+ const PATCH_JSON_PATH = path.join(__dirname, '..', 'patch.json');
37
+ const CUSTOM_MODELS_JSON_PATH = path.join(__dirname, '..', 'custom-models.json');
38
+ const README_PATH = path.join(__dirname, '..', 'README.md');
39
+
40
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
41
+
42
+ function loadJson(filePath) {
43
+ try {
44
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ function saveJson(filePath, data) {
51
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
52
+ console.log(`✓ Saved ${path.basename(filePath)}`);
53
+ }
54
+
55
+ // Convert per-token pricing from API to per-million-tokens
56
+ function toPerMillion(val) {
57
+ if (val === '' || val === null || val === undefined) return null;
58
+ return Math.round(parseFloat(val) * 1_000_000 * 100) / 100;
59
+ }
60
+
61
+ // ─── API fetch ───────────────────────────────────────────────────────────────
62
+
63
+ async function fetchModels() {
64
+ const apiKey = process.env.LILAC_API_KEY;
65
+ if (!apiKey) {
66
+ throw new Error('LILAC_API_KEY environment variable is required');
67
+ }
68
+
69
+ console.log(`Fetching models from ${MODELS_API_URL}...`);
70
+ const response = await fetch(MODELS_API_URL, {
71
+ headers: { 'Authorization': `Bearer ${apiKey}` },
72
+ });
73
+
74
+ if (!response.ok) {
75
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
76
+ }
77
+
78
+ const data = await response.json();
79
+ const models = data.data || [];
80
+ console.log(`✓ Fetched ${models.length} models from API`);
81
+ return models;
82
+ }
83
+
84
+ // ─── Transform API model → models.json entry ────────────────────────────────
85
+
86
+ function transformApiModel(apiModel, existingModelsMap) {
87
+ const id = apiModel.id;
88
+
89
+ // Preserve existing curated data (pricing, reasoning, compat, etc.)
90
+ if (existingModelsMap[id]) {
91
+ const existing = { ...existingModelsMap[id] };
92
+ // Update context window from API if changed
93
+ if (apiModel.context_length) {
94
+ existing.contextWindow = apiModel.context_length;
95
+ }
96
+ // Update max output tokens from API (but trust curated value for GLM 5.1)
97
+ if (apiModel.top_provider?.max_completion_tokens && id !== 'zai-org/glm-5.1') {
98
+ existing.maxTokens = apiModel.top_provider.max_completion_tokens;
99
+ }
100
+ // Update features from API
101
+ const features = apiModel.supported_features || [];
102
+ if (features.includes('reasoning')) {
103
+ existing.reasoning = true;
104
+ }
105
+ // Update modalities from API
106
+ const modalities = apiModel.architecture?.input_modalities || [];
107
+ if (modalities.includes('image') && !existing.input.includes('image')) {
108
+ existing.input = ['text', 'image'];
109
+ }
110
+ // Update pricing from API
111
+ const pricing = apiModel.pricing || {};
112
+ const inputCost = toPerMillion(pricing.prompt);
113
+ const outputCost = toPerMillion(pricing.completion);
114
+ const cacheReadCost = toPerMillion(pricing.input_cache_read);
115
+ if (inputCost !== null && inputCost > 0) existing.cost.input = inputCost;
116
+ if (outputCost !== null && outputCost > 0) existing.cost.output = outputCost;
117
+ if (cacheReadCost !== null) existing.cost.cacheRead = cacheReadCost;
118
+ return existing;
119
+ }
120
+
121
+ // New model — build from API data + sensible defaults
122
+ const features = apiModel.supported_features || [];
123
+ const modalities = apiModel.architecture?.input_modalities || [];
124
+ const pricing = apiModel.pricing || {};
125
+ const hasReasoning = features.includes('reasoning');
126
+ const hasImage = modalities.includes('image');
127
+
128
+ const inputTypes = ['text'];
129
+ if (hasImage) inputTypes.push('image');
130
+
131
+ const inputCost = toPerMillion(pricing.prompt) || 0;
132
+ const outputCost = toPerMillion(pricing.completion) || 0;
133
+ const cacheReadCost = toPerMillion(pricing.input_cache_read) || 0;
134
+
135
+ const model = {
136
+ id,
137
+ name: apiModel.name || generateDisplayName(id),
138
+ reasoning: hasReasoning,
139
+ input: inputTypes,
140
+ cost: {
141
+ input: inputCost,
142
+ output: outputCost,
143
+ cacheRead: cacheReadCost,
144
+ cacheWrite: 0,
145
+ },
146
+ contextWindow: apiModel.context_length || 131072,
147
+ maxTokens: apiModel.top_provider?.max_completion_tokens || apiModel.context_length || 131072,
148
+ };
149
+
150
+ // Add compat — all Lilac models use chat_template_kwargs for reasoning toggle
151
+ const compat = {
152
+ supportsDeveloperRole: true,
153
+ supportsStore: false,
154
+ maxTokensField: 'max_completion_tokens',
155
+ };
156
+
157
+ if (hasReasoning) {
158
+ compat.thinkingFormat = 'qwen-chat-template';
159
+ }
160
+
161
+ model.compat = compat;
162
+
163
+ return model;
164
+ }
165
+
166
+ function generateDisplayName(id) {
167
+ // Handle known naming patterns
168
+ const KNOWN_NAMES = {
169
+ 'moonshotai/kimi-k2.6': 'Kimi K2.6',
170
+ 'zai-org/glm-5.1': 'GLM 5.1',
171
+ 'google/gemma-4-31b-it': 'Gemma 4',
172
+ };
173
+ if (KNOWN_NAMES[id]) return KNOWN_NAMES[id];
174
+
175
+ // Fallback: prettify the ID
176
+ return id
177
+ .split('/')
178
+ .pop()
179
+ .replace(/[-_]/g, ' ')
180
+ .replace(/\b\w/g, c => c.toUpperCase());
181
+ }
182
+
183
+ function applyPatch(model, patch) {
184
+ const result = { ...model };
185
+ if (patch.name !== undefined) result.name = patch.name;
186
+ if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
187
+ if (patch.input !== undefined) result.input = patch.input;
188
+ if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
189
+ if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
190
+ if (patch.cost) {
191
+ result.cost = {
192
+ input: patch.cost.input ?? result.cost.input,
193
+ output: patch.cost.output ?? result.cost.output,
194
+ cacheRead: patch.cost.cacheRead ?? result.cost.cacheRead,
195
+ cacheWrite: patch.cost.cacheWrite ?? result.cost.cacheWrite,
196
+ };
197
+ }
198
+ if (patch.compat) {
199
+ result.compat = { ...(result.compat || {}), ...patch.compat };
200
+ }
201
+ if (!result.reasoning && result.compat?.thinkingFormat) {
202
+ delete result.compat.thinkingFormat;
203
+ }
204
+ if (result.compat && Object.keys(result.compat).length === 0) {
205
+ delete result.compat;
206
+ }
207
+ return result;
208
+ }
209
+
210
+ function buildModels(baseModels, customModels, patchData) {
211
+ const modelMap = new Map();
212
+ for (const model of baseModels) {
213
+ modelMap.set(model.id, model);
214
+ }
215
+ for (const [id, patchEntry] of Object.entries(patchData)) {
216
+ const existing = modelMap.get(id);
217
+ if (existing) {
218
+ modelMap.set(id, applyPatch(existing, patchEntry));
219
+ }
220
+ }
221
+ for (const model of customModels) {
222
+ const existing = modelMap.get(model.id);
223
+ const patchEntry = patchData[model.id];
224
+ if (existing && patchEntry) {
225
+ modelMap.set(model.id, applyPatch(model, patchEntry));
226
+ } else if (existing) {
227
+ modelMap.set(model.id, model);
228
+ } else if (patchEntry) {
229
+ modelMap.set(model.id, applyPatch(model, patchEntry));
230
+ } else {
231
+ modelMap.set(model.id, model);
232
+ }
233
+ }
234
+ return Array.from(modelMap.values());
235
+ }
236
+
237
+ // ─── README generation ──────────────────────────────────────────────────────
238
+
239
+ function formatContext(n) {
240
+ if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
241
+ if (n >= 1000) return `${Math.round(n / 1000)}K`;
242
+ return n.toString();
243
+ }
244
+
245
+ function formatCost(cost) {
246
+ if (cost === 0) return '—';
247
+ if (cost === null || cost === undefined) return '—';
248
+ return `$${cost.toFixed(2)}`;
249
+ }
250
+
251
+ function generateReadmeTable(models) {
252
+ const lines = [
253
+ '| Model | Context | Vision | Reasoning | Input $/M | Cache Read $/M | Output $/M |',
254
+ '|-------|---------|--------|-----------|-----------|-----------------|------------|',
255
+ ];
256
+
257
+ for (const model of models) {
258
+ const context = formatContext(model.contextWindow);
259
+ const vision = model.input.includes('image') ? '✅' : '❌';
260
+ const reasoning = model.reasoning ? '✅' : '❌';
261
+ const inputCost = formatCost(model.cost.input);
262
+ const cacheReadCost = formatCost(model.cost.cacheRead);
263
+ const outputCost = formatCost(model.cost.output);
264
+
265
+ lines.push(`| ${model.name} | ${context} | ${vision} | ${reasoning} | ${inputCost} | ${cacheReadCost} | ${outputCost} |`);
266
+ }
267
+
268
+ return lines.join('\n');
269
+ }
270
+
271
+ function updateReadme(models) {
272
+ let readme = fs.readFileSync(README_PATH, 'utf8');
273
+ const newTable = generateReadmeTable(models);
274
+
275
+ const tableRegex = /(## Available Models\n\n)\| Model \|[^\n]+\|\n\|[-| ]+\|(\n\|[^\n]+\|)*\n*/;
276
+
277
+ if (tableRegex.test(readme)) {
278
+ readme = readme.replace(tableRegex, (match, header) => `${header}${newTable}\n\n`);
279
+ fs.writeFileSync(README_PATH, readme);
280
+ console.log('✓ Updated README.md');
281
+ } else {
282
+ console.warn('⚠ Could not find model table in "## Available Models" section');
283
+ }
284
+ }
285
+
286
+ // ─── Main ────────────────────────────────────────────────────────────────────
287
+
288
+ async function main() {
289
+ try {
290
+ const apiModels = await fetchModels();
291
+
292
+ // Load existing models.json — source of truth for curated specs
293
+ const existingModels = loadJson(MODELS_JSON_PATH);
294
+ const existingModelsMap = {};
295
+ for (const m of (Array.isArray(existingModels) ? existingModels : [])) {
296
+ existingModelsMap[m.id] = m;
297
+ }
298
+
299
+ // Transform API models, preserving existing data where available
300
+ let models = apiModels.map(m =>
301
+ transformApiModel(m, existingModelsMap)
302
+ );
303
+
304
+ // Live API is authoritative — models absent from API are removed
305
+ // (embedded data is already used for enrichment in transformApiModel)
306
+
307
+ // Sort by model name
308
+ models.sort((a, b) => a.name.localeCompare(b.name));
309
+
310
+ // Save models.json (pure API output, no patch/custom baked in)
311
+ saveJson(MODELS_JSON_PATH, models);
312
+
313
+ // Build full model list for README: base → patch → custom
314
+ const patchData = loadJson(PATCH_JSON_PATH);
315
+ const customModels = loadJson(CUSTOM_MODELS_JSON_PATH);
316
+ const readmeModels = buildModels(models, Array.isArray(customModels) ? customModels : [], patchData);
317
+ readmeModels.sort((a, b) => a.name.localeCompare(b.name));
318
+
319
+ // Update README
320
+ updateReadme(readmeModels);
321
+
322
+ // Summary
323
+ const newIds = new Set(models.map(m => m.id));
324
+ const oldIds = new Set(Object.keys(existingModelsMap));
325
+ const added = [...newIds].filter(id => !oldIds.has(id));
326
+ const removed = [...oldIds].filter(id => !newIds.has(id));
327
+
328
+ console.log('\n--- Summary ---');
329
+ console.log(`Total models: ${models.length}`);
330
+ console.log(`Reasoning models: ${models.filter(m => m.reasoning).length}`);
331
+ console.log(`Vision models: ${models.filter(m => m.input.includes('image')).length}`);
332
+ console.log(`Cache-enabled models: ${models.filter(m => m.cost.cacheRead > 0).length}`);
333
+ if (added.length > 0) console.log(`New models: ${added.join(', ')} — curate models.json manually`);
334
+ if (removed.length > 0) console.log(`Removed models: ${removed.join(', ')}`);
335
+
336
+ } catch (error) {
337
+ console.error('Error:', error.message);
338
+ process.exit(1);
339
+ }
340
+ }
341
+
342
+ main();