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.
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/custom-models.json +1 -0
- package/index.ts +672 -0
- package/models.json +139 -0
- package/package.json +40 -0
- package/patch.json +64 -0
- package/scripts/test-discounts.ts +454 -0
- package/scripts/update-models.js +342 -0
|
@@ -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();
|