@steipete/summarize 0.3.0 → 0.5.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/CHANGELOG.md +80 -5
- package/README.md +122 -20
- package/dist/cli.cjs +8446 -4360
- package/dist/cli.cjs.map +4 -4
- package/dist/esm/cli-main.js +47 -2
- package/dist/esm/cli-main.js.map +1 -1
- package/dist/esm/config.js +368 -3
- package/dist/esm/config.js.map +1 -1
- package/dist/esm/content/link-preview/content/index.js +13 -0
- package/dist/esm/content/link-preview/content/index.js.map +1 -1
- package/dist/esm/content/link-preview/content/utils.js +3 -1
- package/dist/esm/content/link-preview/content/utils.js.map +1 -1
- package/dist/esm/content/link-preview/content/video.js +96 -0
- package/dist/esm/content/link-preview/content/video.js.map +1 -0
- package/dist/esm/content/link-preview/transcript/providers/youtube/captions.js +21 -21
- package/dist/esm/content/link-preview/transcript/providers/youtube/captions.js.map +1 -1
- package/dist/esm/costs.js.map +1 -1
- package/dist/esm/flags.js +41 -1
- package/dist/esm/flags.js.map +1 -1
- package/dist/esm/generate-free.js +616 -0
- package/dist/esm/generate-free.js.map +1 -0
- package/dist/esm/llm/cli.js +290 -0
- package/dist/esm/llm/cli.js.map +1 -0
- package/dist/esm/llm/generate-text.js +159 -105
- package/dist/esm/llm/generate-text.js.map +1 -1
- package/dist/esm/llm/html-to-markdown.js +4 -2
- package/dist/esm/llm/html-to-markdown.js.map +1 -1
- package/dist/esm/markitdown.js +54 -0
- package/dist/esm/markitdown.js.map +1 -0
- package/dist/esm/model-auto.js +353 -0
- package/dist/esm/model-auto.js.map +1 -0
- package/dist/esm/model-spec.js +82 -0
- package/dist/esm/model-spec.js.map +1 -0
- package/dist/esm/prompts/cli.js +18 -0
- package/dist/esm/prompts/cli.js.map +1 -0
- package/dist/esm/prompts/file.js +21 -2
- package/dist/esm/prompts/file.js.map +1 -1
- package/dist/esm/prompts/index.js +2 -1
- package/dist/esm/prompts/index.js.map +1 -1
- package/dist/esm/prompts/link-summary.js +3 -8
- package/dist/esm/prompts/link-summary.js.map +1 -1
- package/dist/esm/refresh-free.js +667 -0
- package/dist/esm/refresh-free.js.map +1 -0
- package/dist/esm/run.js +1612 -533
- package/dist/esm/run.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/dist/types/config.d.ts +58 -5
- package/dist/types/content/link-preview/content/types.d.ts +10 -0
- package/dist/types/content/link-preview/content/utils.d.ts +1 -1
- package/dist/types/content/link-preview/content/video.d.ts +5 -0
- package/dist/types/costs.d.ts +2 -1
- package/dist/types/flags.d.ts +7 -0
- package/dist/types/generate-free.d.ts +17 -0
- package/dist/types/llm/cli.d.ts +24 -0
- package/dist/types/llm/generate-text.d.ts +13 -4
- package/dist/types/llm/html-to-markdown.d.ts +9 -3
- package/dist/types/markitdown.d.ts +10 -0
- package/dist/types/model-auto.d.ts +23 -0
- package/dist/types/model-spec.d.ts +33 -0
- package/dist/types/prompts/cli.d.ts +8 -0
- package/dist/types/prompts/file.d.ts +7 -0
- package/dist/types/prompts/index.d.ts +2 -1
- package/dist/types/refresh-free.d.ts +19 -0
- package/dist/types/run.d.ts +3 -1
- package/dist/types/version.d.ts +1 -1
- package/docs/README.md +4 -1
- package/docs/cli.md +95 -0
- package/docs/config.md +123 -1
- package/docs/extract-only.md +10 -7
- package/docs/firecrawl.md +2 -2
- package/docs/llm.md +24 -4
- package/docs/manual-tests.md +40 -0
- package/docs/model-auto.md +92 -0
- package/docs/site/assets/site.js +20 -17
- package/docs/site/docs/config.html +3 -3
- package/docs/site/docs/extract-only.html +7 -5
- package/docs/site/docs/firecrawl.html +6 -6
- package/docs/site/docs/index.html +2 -2
- package/docs/site/docs/llm.html +2 -2
- package/docs/site/docs/openai.html +2 -2
- package/docs/site/docs/website.html +7 -4
- package/docs/site/docs/youtube.html +2 -2
- package/docs/site/index.html +1 -1
- package/docs/smoketest.md +58 -0
- package/docs/website.md +13 -8
- package/docs/youtube.md +1 -1
- package/package.json +8 -4
- package/dist/esm/content/link-preview/transcript/providers/twitter.js +0 -12
- package/dist/esm/content/link-preview/transcript/providers/twitter.js.map +0 -1
- package/dist/esm/content/link-preview/transcript/providers/youtube/ytdlp.js +0 -114
- package/dist/esm/content/link-preview/transcript/providers/youtube/ytdlp.js.map +0 -1
- package/dist/esm/summarizeHome.js +0 -20
- package/dist/esm/summarizeHome.js.map +0 -1
- package/dist/esm/tty/live-markdown.js +0 -52
- package/dist/esm/tty/live-markdown.js.map +0 -1
- package/dist/types/content/link-preview/transcript/providers/twitter.d.ts +0 -3
- package/dist/types/content/link-preview/transcript/providers/youtube/ytdlp.d.ts +0 -3
- package/dist/types/summarizeHome.d.ts +0 -6
- package/dist/types/tty/live-markdown.d.ts +0 -10
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import JSON5 from 'json5';
|
|
5
|
+
import { generateTextWithModelId } from './llm/generate-text.js';
|
|
6
|
+
function supportsColor(stream, env) {
|
|
7
|
+
if (env.NO_COLOR)
|
|
8
|
+
return false;
|
|
9
|
+
if (env.FORCE_COLOR && env.FORCE_COLOR !== '0')
|
|
10
|
+
return true;
|
|
11
|
+
if (!stream.isTTY)
|
|
12
|
+
return false;
|
|
13
|
+
const term = env.TERM?.toLowerCase();
|
|
14
|
+
if (!term || term === 'dumb')
|
|
15
|
+
return false;
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
function ansi(code, input, enabled) {
|
|
19
|
+
if (!enabled)
|
|
20
|
+
return input;
|
|
21
|
+
return `\u001b[${code}m${input}\u001b[0m`;
|
|
22
|
+
}
|
|
23
|
+
function formatMs(ms) {
|
|
24
|
+
if (!Number.isFinite(ms))
|
|
25
|
+
return `${ms}`;
|
|
26
|
+
if (ms < 1000)
|
|
27
|
+
return `${Math.round(ms)}ms`;
|
|
28
|
+
return `${Math.round(ms / 100) / 10}s`;
|
|
29
|
+
}
|
|
30
|
+
function formatTokenK(value) {
|
|
31
|
+
if (!Number.isFinite(value))
|
|
32
|
+
return `${value}`;
|
|
33
|
+
if (value < 1024)
|
|
34
|
+
return `${Math.round(value)}`;
|
|
35
|
+
const k = Math.round(value / 1024);
|
|
36
|
+
return `${k}k`;
|
|
37
|
+
}
|
|
38
|
+
function inferParamBFromIdOrName(text) {
|
|
39
|
+
const raw = text.toLowerCase();
|
|
40
|
+
// Common patterns:
|
|
41
|
+
// - "...-70b-..." / "...-32b" / "...-8b-..."
|
|
42
|
+
// - "...-e2b-..." (gemma-3n-e2b)
|
|
43
|
+
// - "...-1.5b-..."
|
|
44
|
+
const matches = raw.matchAll(/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g);
|
|
45
|
+
let best = null;
|
|
46
|
+
for (const m of matches) {
|
|
47
|
+
const numRaw = m[1];
|
|
48
|
+
if (!numRaw)
|
|
49
|
+
continue;
|
|
50
|
+
const value = Number(numRaw);
|
|
51
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
52
|
+
continue;
|
|
53
|
+
if (best === null || value > best)
|
|
54
|
+
best = value;
|
|
55
|
+
}
|
|
56
|
+
return best;
|
|
57
|
+
}
|
|
58
|
+
function classifyOpenRouterRateLimit(message) {
|
|
59
|
+
const m = message.toLowerCase();
|
|
60
|
+
if (!m.includes('rate limit exceeded'))
|
|
61
|
+
return null;
|
|
62
|
+
if (m.includes('per-day') || m.includes('per day') || m.includes('free-models-per-day')) {
|
|
63
|
+
return 'perDay';
|
|
64
|
+
}
|
|
65
|
+
if (m.includes('per-min') || m.includes('per min') || m.includes('free-models-per-min')) {
|
|
66
|
+
return 'perMin';
|
|
67
|
+
}
|
|
68
|
+
// Default: assume per-minute (most common for free models).
|
|
69
|
+
return 'perMin';
|
|
70
|
+
}
|
|
71
|
+
function assertNoComments(raw, path) {
|
|
72
|
+
let inString = null;
|
|
73
|
+
let escaped = false;
|
|
74
|
+
let line = 1;
|
|
75
|
+
let col = 1;
|
|
76
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
77
|
+
const ch = raw[i] ?? '';
|
|
78
|
+
const next = raw[i + 1] ?? '';
|
|
79
|
+
if (inString) {
|
|
80
|
+
if (escaped) {
|
|
81
|
+
escaped = false;
|
|
82
|
+
col += 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (ch === '\\') {
|
|
86
|
+
escaped = true;
|
|
87
|
+
col += 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (ch === inString) {
|
|
91
|
+
inString = null;
|
|
92
|
+
}
|
|
93
|
+
if (ch === '\n') {
|
|
94
|
+
line += 1;
|
|
95
|
+
col = 1;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
col += 1;
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (ch === '"' || ch === "'") {
|
|
103
|
+
inString = ch;
|
|
104
|
+
escaped = false;
|
|
105
|
+
col += 1;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (ch === '/' && next === '/') {
|
|
109
|
+
throw new Error(`Invalid config file ${path}: comments are not allowed (found // at ${line}:${col}).`);
|
|
110
|
+
}
|
|
111
|
+
if (ch === '/' && next === '*') {
|
|
112
|
+
throw new Error(`Invalid config file ${path}: comments are not allowed (found /* at ${line}:${col}).`);
|
|
113
|
+
}
|
|
114
|
+
if (ch === '\n') {
|
|
115
|
+
line += 1;
|
|
116
|
+
col = 1;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
col += 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function resolveConfigPath(env) {
|
|
124
|
+
const home = env.HOME?.trim() || homedir();
|
|
125
|
+
if (!home)
|
|
126
|
+
throw new Error('Missing HOME');
|
|
127
|
+
return join(home, '.summarize', 'config.json');
|
|
128
|
+
}
|
|
129
|
+
function isRecord(value) {
|
|
130
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
131
|
+
}
|
|
132
|
+
async function mapWithConcurrency(items, concurrency, fn) {
|
|
133
|
+
const limit = Math.max(1, Math.floor(concurrency));
|
|
134
|
+
const results = new Array(items.length);
|
|
135
|
+
let nextIndex = 0;
|
|
136
|
+
const worker = async () => {
|
|
137
|
+
while (true) {
|
|
138
|
+
const current = nextIndex;
|
|
139
|
+
nextIndex += 1;
|
|
140
|
+
if (current >= items.length)
|
|
141
|
+
return;
|
|
142
|
+
results[current] = await fn(items[current], current);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
|
|
146
|
+
return results;
|
|
147
|
+
}
|
|
148
|
+
export async function refreshFree({ env, fetchImpl, stdout, stderr, verbose = false, options = {}, }) {
|
|
149
|
+
const color = supportsColor(stderr, env);
|
|
150
|
+
const okLabel = (text) => ansi('1;32', text, color);
|
|
151
|
+
const failLabel = (text) => ansi('1;31', text, color);
|
|
152
|
+
const dim = (text) => ansi('2', text, color);
|
|
153
|
+
const heading = (text) => ansi('1;36', text, color);
|
|
154
|
+
const cmdName = heading('Refresh Free');
|
|
155
|
+
const openrouterKey = typeof env.OPENROUTER_API_KEY === 'string' && env.OPENROUTER_API_KEY.trim().length > 0
|
|
156
|
+
? env.OPENROUTER_API_KEY.trim()
|
|
157
|
+
: null;
|
|
158
|
+
if (!openrouterKey) {
|
|
159
|
+
throw new Error('Missing OPENROUTER_API_KEY (required for refresh-free)');
|
|
160
|
+
}
|
|
161
|
+
const resolved = {
|
|
162
|
+
runs: 2,
|
|
163
|
+
smart: 3,
|
|
164
|
+
maxCandidates: 10,
|
|
165
|
+
concurrency: 4,
|
|
166
|
+
timeoutMs: 10_000,
|
|
167
|
+
minParamB: 27,
|
|
168
|
+
maxAgeDays: 180,
|
|
169
|
+
setDefault: false,
|
|
170
|
+
...options,
|
|
171
|
+
};
|
|
172
|
+
const EXTRA_RUNS = Math.max(0, Math.floor(resolved.runs));
|
|
173
|
+
const TOTAL_RUNS = 1 + EXTRA_RUNS;
|
|
174
|
+
const SMART = Math.max(0, Math.floor(resolved.smart));
|
|
175
|
+
const MAX_CANDIDATES = Math.max(1, Math.floor(resolved.maxCandidates));
|
|
176
|
+
const CONCURRENCY = Math.max(1, Math.floor(resolved.concurrency));
|
|
177
|
+
const TIMEOUT_MS = Math.max(1, Math.floor(resolved.timeoutMs));
|
|
178
|
+
const MIN_PARAM_B = Math.max(0, Math.floor(resolved.minParamB));
|
|
179
|
+
const MAX_AGE_DAYS = Math.max(0, Math.floor(resolved.maxAgeDays));
|
|
180
|
+
const applyMaxAgeFilter = MAX_AGE_DAYS > 0;
|
|
181
|
+
stderr.write(`${cmdName}: fetching OpenRouter models…\n`);
|
|
182
|
+
const response = await fetchImpl('https://openrouter.ai/api/v1/models', {
|
|
183
|
+
headers: { Accept: 'application/json' },
|
|
184
|
+
});
|
|
185
|
+
if (!response.ok) {
|
|
186
|
+
throw new Error(`OpenRouter /models failed: HTTP ${response.status}`);
|
|
187
|
+
}
|
|
188
|
+
const payload = (await response.json());
|
|
189
|
+
const entries = (Array.isArray(payload.data) ? payload.data : []);
|
|
190
|
+
const catalogModels = entries
|
|
191
|
+
.map((entry) => {
|
|
192
|
+
if (!entry || typeof entry !== 'object')
|
|
193
|
+
return null;
|
|
194
|
+
const obj = entry;
|
|
195
|
+
const id = typeof obj.id === 'string' ? obj.id.trim() : '';
|
|
196
|
+
if (!id)
|
|
197
|
+
return null;
|
|
198
|
+
const name = typeof obj.name === 'string' ? obj.name.trim() : '';
|
|
199
|
+
const contextLength = typeof obj.context_length === 'number' && Number.isFinite(obj.context_length)
|
|
200
|
+
? obj.context_length
|
|
201
|
+
: null;
|
|
202
|
+
const topProvider = obj.top_provider && typeof obj.top_provider === 'object'
|
|
203
|
+
? obj.top_provider
|
|
204
|
+
: null;
|
|
205
|
+
const maxCompletionTokens = typeof topProvider?.max_completion_tokens === 'number' &&
|
|
206
|
+
Number.isFinite(topProvider.max_completion_tokens)
|
|
207
|
+
? topProvider.max_completion_tokens
|
|
208
|
+
: null;
|
|
209
|
+
const supportedParametersCount = (() => {
|
|
210
|
+
const sp = obj.supported_parameters;
|
|
211
|
+
if (!Array.isArray(sp))
|
|
212
|
+
return 0;
|
|
213
|
+
return sp.filter((v) => typeof v === 'string' && v.trim().length > 0).length;
|
|
214
|
+
})();
|
|
215
|
+
const modality = (() => {
|
|
216
|
+
const arch = obj.architecture && typeof obj.architecture === 'object'
|
|
217
|
+
? obj.architecture
|
|
218
|
+
: null;
|
|
219
|
+
const raw = typeof arch?.modality === 'string' ? arch.modality.trim() : '';
|
|
220
|
+
return raw.length > 0 ? raw : null;
|
|
221
|
+
})();
|
|
222
|
+
const createdAtMs = (() => {
|
|
223
|
+
const created = obj.created;
|
|
224
|
+
if (typeof created !== 'number' || !Number.isFinite(created) || created <= 0)
|
|
225
|
+
return null;
|
|
226
|
+
// OpenRouter uses unix timestamp seconds.
|
|
227
|
+
return Math.round(created * 1000);
|
|
228
|
+
})();
|
|
229
|
+
return {
|
|
230
|
+
id,
|
|
231
|
+
contextLength,
|
|
232
|
+
maxCompletionTokens,
|
|
233
|
+
supportedParametersCount,
|
|
234
|
+
modality,
|
|
235
|
+
inferredParamB: inferParamBFromIdOrName(`${id} ${name}`),
|
|
236
|
+
createdAtMs,
|
|
237
|
+
};
|
|
238
|
+
})
|
|
239
|
+
.filter((v) => Boolean(v));
|
|
240
|
+
const freeModelsAll = catalogModels.filter((m) => m.id.endsWith(':free'));
|
|
241
|
+
const freeModelsAgeFiltered = freeModelsAll.filter((m) => {
|
|
242
|
+
if (!applyMaxAgeFilter)
|
|
243
|
+
return true;
|
|
244
|
+
if (m.createdAtMs === null)
|
|
245
|
+
return false;
|
|
246
|
+
const ageMs = Date.now() - m.createdAtMs;
|
|
247
|
+
return ageMs >= 0 && ageMs <= MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
|
|
248
|
+
});
|
|
249
|
+
const freeModels = freeModelsAgeFiltered.filter((m) => {
|
|
250
|
+
if (m.inferredParamB === null)
|
|
251
|
+
return true;
|
|
252
|
+
return m.inferredParamB >= MIN_PARAM_B;
|
|
253
|
+
});
|
|
254
|
+
if (freeModels.length === 0) {
|
|
255
|
+
if (applyMaxAgeFilter) {
|
|
256
|
+
throw new Error(`OpenRouter /models returned no :free models from the last ${MAX_AGE_DAYS} days`);
|
|
257
|
+
}
|
|
258
|
+
throw new Error('OpenRouter /models returned no :free models');
|
|
259
|
+
}
|
|
260
|
+
const ageFilteredCount = freeModelsAll.length - freeModelsAgeFiltered.length;
|
|
261
|
+
if (ageFilteredCount > 0) {
|
|
262
|
+
stderr.write(`${cmdName}: filtered ${ageFilteredCount}/${freeModelsAll.length} old models (>${MAX_AGE_DAYS}d)\n`);
|
|
263
|
+
if (verbose) {
|
|
264
|
+
const filteredIds = freeModelsAll
|
|
265
|
+
.filter((m) => {
|
|
266
|
+
if (m.createdAtMs === null)
|
|
267
|
+
return true;
|
|
268
|
+
const ageMs = Date.now() - m.createdAtMs;
|
|
269
|
+
return !(ageMs >= 0 && ageMs <= MAX_AGE_DAYS * 24 * 60 * 60 * 1000);
|
|
270
|
+
})
|
|
271
|
+
.map((m) => m.id)
|
|
272
|
+
.sort((a, b) => a.localeCompare(b));
|
|
273
|
+
for (const id of filteredIds)
|
|
274
|
+
stderr.write(`${dim(`skip ${id}`)}\n`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const filteredCount = freeModelsAgeFiltered.length - freeModels.length;
|
|
278
|
+
if (filteredCount > 0) {
|
|
279
|
+
stderr.write(`${cmdName}: filtered ${filteredCount}/${freeModelsAgeFiltered.length} small models (<${MIN_PARAM_B}B)\n`);
|
|
280
|
+
if (verbose) {
|
|
281
|
+
const filteredIds = freeModelsAgeFiltered
|
|
282
|
+
.filter((m) => m.inferredParamB !== null && m.inferredParamB < MIN_PARAM_B)
|
|
283
|
+
.map((m) => m.id)
|
|
284
|
+
.sort((a, b) => a.localeCompare(b));
|
|
285
|
+
for (const id of filteredIds)
|
|
286
|
+
stderr.write(`${dim(`skip ${id}`)}\n`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const smartSorted = freeModels.slice().sort((a, b) => {
|
|
290
|
+
const aCreated = a.createdAtMs ?? -1;
|
|
291
|
+
const bCreated = b.createdAtMs ?? -1;
|
|
292
|
+
if (aCreated !== bCreated)
|
|
293
|
+
return bCreated - aCreated;
|
|
294
|
+
const aContext = a.contextLength ?? -1;
|
|
295
|
+
const bContext = b.contextLength ?? -1;
|
|
296
|
+
if (aContext !== bContext)
|
|
297
|
+
return bContext - aContext;
|
|
298
|
+
const aOut = a.maxCompletionTokens ?? -1;
|
|
299
|
+
const bOut = b.maxCompletionTokens ?? -1;
|
|
300
|
+
if (aOut !== bOut)
|
|
301
|
+
return bOut - aOut;
|
|
302
|
+
if (a.supportedParametersCount !== b.supportedParametersCount) {
|
|
303
|
+
return b.supportedParametersCount - a.supportedParametersCount;
|
|
304
|
+
}
|
|
305
|
+
return a.id.localeCompare(b.id);
|
|
306
|
+
});
|
|
307
|
+
const freeIds = smartSorted.map((m) => m.id);
|
|
308
|
+
stderr.write(`${cmdName}: found ${freeIds.length} :free models; testing (runs=${TOTAL_RUNS}, concurrency=${CONCURRENCY}, timeout=${formatMs(TIMEOUT_MS)})…\n`);
|
|
309
|
+
const apiKeys = {
|
|
310
|
+
xaiApiKey: null,
|
|
311
|
+
openaiApiKey: null,
|
|
312
|
+
googleApiKey: null,
|
|
313
|
+
anthropicApiKey: null,
|
|
314
|
+
openrouterApiKey: openrouterKey,
|
|
315
|
+
};
|
|
316
|
+
const isTty = Boolean(stderr.isTTY);
|
|
317
|
+
let done = 0;
|
|
318
|
+
let okCount = 0;
|
|
319
|
+
const failureCounts = {
|
|
320
|
+
empty: 0,
|
|
321
|
+
rateLimitMin: 0,
|
|
322
|
+
rateLimitDay: 0,
|
|
323
|
+
noProviders: 0,
|
|
324
|
+
timeout: 0,
|
|
325
|
+
providerError: 0,
|
|
326
|
+
other: 0,
|
|
327
|
+
};
|
|
328
|
+
const startedAt = Date.now();
|
|
329
|
+
let lastProgressPrint = 0;
|
|
330
|
+
// Global cooldown gate for OpenRouter free-model per-minute limits.
|
|
331
|
+
let cooldownUntilMs = 0;
|
|
332
|
+
let cooldownNotifiedAtMs = 0;
|
|
333
|
+
const COOLDOWN_MS = 65_000;
|
|
334
|
+
const progress = (label) => {
|
|
335
|
+
const now = Date.now();
|
|
336
|
+
const everyMs = isTty ? 150 : 1500;
|
|
337
|
+
if (now - lastProgressPrint < everyMs)
|
|
338
|
+
return;
|
|
339
|
+
lastProgressPrint = now;
|
|
340
|
+
const elapsedSec = Math.round((now - startedAt) / 100) / 10;
|
|
341
|
+
const line = `Refresh Free: ${label} ${done}/${freeIds.length}, ok=${okCount} (elapsed ${elapsedSec}s)…`;
|
|
342
|
+
if (isTty) {
|
|
343
|
+
stderr.write(`\x1b[2K\r${line}`);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
stderr.write(`${line}\n`);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
const note = (line) => {
|
|
350
|
+
if (isTty) {
|
|
351
|
+
// Clear current progress line, print note, then progress will redraw on next tick.
|
|
352
|
+
stderr.write(`\x1b[2K\r${line}\n`);
|
|
353
|
+
lastProgressPrint = 0;
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
stderr.write(`${line}\n`);
|
|
357
|
+
};
|
|
358
|
+
const results = [];
|
|
359
|
+
const idToMeta = new Map(smartSorted.map((m) => [m.id, m]));
|
|
360
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
361
|
+
const waitForCooldown = async () => {
|
|
362
|
+
const now = Date.now();
|
|
363
|
+
if (cooldownUntilMs <= now)
|
|
364
|
+
return;
|
|
365
|
+
const remaining = cooldownUntilMs - now;
|
|
366
|
+
if (now - cooldownNotifiedAtMs > 5_000) {
|
|
367
|
+
cooldownNotifiedAtMs = now;
|
|
368
|
+
note(`${dim(`rate limit hit; sleeping ${formatMs(remaining)}…`)}`);
|
|
369
|
+
}
|
|
370
|
+
await sleep(remaining);
|
|
371
|
+
};
|
|
372
|
+
const setCooldown = (ms) => {
|
|
373
|
+
const next = Date.now() + ms;
|
|
374
|
+
if (next > cooldownUntilMs)
|
|
375
|
+
cooldownUntilMs = next;
|
|
376
|
+
};
|
|
377
|
+
const classifyFailure = (message) => {
|
|
378
|
+
const m = message.toLowerCase();
|
|
379
|
+
if (m.includes('empty summary'))
|
|
380
|
+
return 'empty';
|
|
381
|
+
const rl = classifyOpenRouterRateLimit(message);
|
|
382
|
+
if (rl === 'perMin')
|
|
383
|
+
return 'rateLimitMin';
|
|
384
|
+
if (rl === 'perDay')
|
|
385
|
+
return 'rateLimitDay';
|
|
386
|
+
if (m.includes('no allowed providers are available'))
|
|
387
|
+
return 'noProviders';
|
|
388
|
+
if (m.includes('timed out') || m.includes('timeout') || m.includes('aborted'))
|
|
389
|
+
return 'timeout';
|
|
390
|
+
if (m.includes('provider returned error') || m.includes('provider error'))
|
|
391
|
+
return 'providerError';
|
|
392
|
+
return 'other';
|
|
393
|
+
};
|
|
394
|
+
// Pass 1: test all free models once.
|
|
395
|
+
{
|
|
396
|
+
const batchResults = await mapWithConcurrency(freeIds, CONCURRENCY, async (openrouterModelId) => {
|
|
397
|
+
const runStartedAt = Date.now();
|
|
398
|
+
try {
|
|
399
|
+
await waitForCooldown();
|
|
400
|
+
await generateTextWithModelId({
|
|
401
|
+
modelId: `openai/${openrouterModelId}`,
|
|
402
|
+
apiKeys,
|
|
403
|
+
prompt: 'Reply with a single word: OK',
|
|
404
|
+
temperature: 0,
|
|
405
|
+
maxOutputTokens: 16,
|
|
406
|
+
timeoutMs: TIMEOUT_MS,
|
|
407
|
+
fetchImpl,
|
|
408
|
+
forceOpenRouter: true,
|
|
409
|
+
retries: 0,
|
|
410
|
+
});
|
|
411
|
+
const latencyMs = Date.now() - runStartedAt;
|
|
412
|
+
done += 1;
|
|
413
|
+
okCount += 1;
|
|
414
|
+
progress('tested');
|
|
415
|
+
const meta = idToMeta.get(openrouterModelId) ?? null;
|
|
416
|
+
note(`${okLabel('ok')} ${openrouterModelId} ${dim(`(${formatMs(latencyMs)})`)}`);
|
|
417
|
+
return {
|
|
418
|
+
ok: true,
|
|
419
|
+
value: {
|
|
420
|
+
openrouterModelId,
|
|
421
|
+
initialLatencyMs: latencyMs,
|
|
422
|
+
medianLatencyMs: latencyMs,
|
|
423
|
+
totalLatencyMs: latencyMs,
|
|
424
|
+
successCount: 1,
|
|
425
|
+
contextLength: meta?.contextLength ?? null,
|
|
426
|
+
maxCompletionTokens: meta?.maxCompletionTokens ?? null,
|
|
427
|
+
supportedParametersCount: meta?.supportedParametersCount ?? 0,
|
|
428
|
+
modality: meta?.modality ?? null,
|
|
429
|
+
inferredParamB: meta?.inferredParamB ?? null,
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
435
|
+
const kind = classifyFailure(message);
|
|
436
|
+
failureCounts[kind] += 1;
|
|
437
|
+
if (kind === 'rateLimitMin') {
|
|
438
|
+
// Back off globally and retry once.
|
|
439
|
+
setCooldown(COOLDOWN_MS);
|
|
440
|
+
await waitForCooldown();
|
|
441
|
+
try {
|
|
442
|
+
const retryStartedAt = Date.now();
|
|
443
|
+
await generateTextWithModelId({
|
|
444
|
+
modelId: `openai/${openrouterModelId}`,
|
|
445
|
+
apiKeys,
|
|
446
|
+
prompt: 'Reply with a single word: OK',
|
|
447
|
+
temperature: 0,
|
|
448
|
+
maxOutputTokens: 16,
|
|
449
|
+
timeoutMs: TIMEOUT_MS,
|
|
450
|
+
fetchImpl,
|
|
451
|
+
forceOpenRouter: true,
|
|
452
|
+
retries: 0,
|
|
453
|
+
});
|
|
454
|
+
const retryLatencyMs = Date.now() - retryStartedAt;
|
|
455
|
+
done += 1;
|
|
456
|
+
okCount += 1;
|
|
457
|
+
progress('tested');
|
|
458
|
+
const meta = idToMeta.get(openrouterModelId) ?? null;
|
|
459
|
+
note(`${okLabel('ok')} ${openrouterModelId} ${dim(`(${formatMs(retryLatencyMs)})`)}`);
|
|
460
|
+
return {
|
|
461
|
+
ok: true,
|
|
462
|
+
value: {
|
|
463
|
+
openrouterModelId,
|
|
464
|
+
initialLatencyMs: retryLatencyMs,
|
|
465
|
+
medianLatencyMs: retryLatencyMs,
|
|
466
|
+
totalLatencyMs: retryLatencyMs,
|
|
467
|
+
successCount: 1,
|
|
468
|
+
contextLength: meta?.contextLength ?? null,
|
|
469
|
+
maxCompletionTokens: meta?.maxCompletionTokens ?? null,
|
|
470
|
+
supportedParametersCount: meta?.supportedParametersCount ?? 0,
|
|
471
|
+
modality: meta?.modality ?? null,
|
|
472
|
+
inferredParamB: meta?.inferredParamB ?? null,
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
// fall through to failure handling below
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
done += 1;
|
|
481
|
+
progress('tested');
|
|
482
|
+
if (verbose) {
|
|
483
|
+
note(`${failLabel('fail')} ${openrouterModelId} ${dim(`(${kind})`)}: ${message}`);
|
|
484
|
+
}
|
|
485
|
+
return { ok: false, openrouterModelId, error: message };
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
for (const r of batchResults)
|
|
489
|
+
results.push(r);
|
|
490
|
+
}
|
|
491
|
+
if (isTty)
|
|
492
|
+
stderr.write('\n');
|
|
493
|
+
const ok = results
|
|
494
|
+
.filter((r) => r.ok)
|
|
495
|
+
.map((r) => r.value)
|
|
496
|
+
.sort((a, b) => a.medianLatencyMs - b.medianLatencyMs);
|
|
497
|
+
if (ok.length === 0) {
|
|
498
|
+
throw new Error(`No working :free models found (tested ${results.length})`);
|
|
499
|
+
}
|
|
500
|
+
{
|
|
501
|
+
const failed = results.length - ok.length;
|
|
502
|
+
const parts = [
|
|
503
|
+
`ok=${ok.length}`,
|
|
504
|
+
`failed=${failed}`,
|
|
505
|
+
...Object.entries(failureCounts)
|
|
506
|
+
.filter(([, v]) => v > 0)
|
|
507
|
+
.map(([k, v]) => `${k}=${v}`),
|
|
508
|
+
];
|
|
509
|
+
stderr.write(`${cmdName}: results ${parts.join(' ')}\n`);
|
|
510
|
+
if (failureCounts.rateLimitMin > 0) {
|
|
511
|
+
stderr.write(`${dim('Note: OpenRouter free-model rate limits were hit; retrying later may find more working models.')}\n`);
|
|
512
|
+
}
|
|
513
|
+
if (failureCounts.rateLimitDay > 0) {
|
|
514
|
+
stderr.write(`${dim('Note: OpenRouter per-day free-model quota was hit.')}\n`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const buildSelection = (working) => {
|
|
518
|
+
const smartFirst = working.slice().sort((a, b) => {
|
|
519
|
+
const aContext = a.contextLength ?? -1;
|
|
520
|
+
const bContext = b.contextLength ?? -1;
|
|
521
|
+
if (aContext !== bContext)
|
|
522
|
+
return bContext - aContext;
|
|
523
|
+
const aOut = a.maxCompletionTokens ?? -1;
|
|
524
|
+
const bOut = b.maxCompletionTokens ?? -1;
|
|
525
|
+
if (aOut !== bOut)
|
|
526
|
+
return bOut - aOut;
|
|
527
|
+
if (a.supportedParametersCount !== b.supportedParametersCount) {
|
|
528
|
+
return b.supportedParametersCount - a.supportedParametersCount;
|
|
529
|
+
}
|
|
530
|
+
if (a.successCount !== b.successCount)
|
|
531
|
+
return b.successCount - a.successCount;
|
|
532
|
+
if (a.medianLatencyMs !== b.medianLatencyMs)
|
|
533
|
+
return a.medianLatencyMs - b.medianLatencyMs;
|
|
534
|
+
return a.openrouterModelId.localeCompare(b.openrouterModelId);
|
|
535
|
+
});
|
|
536
|
+
const fastFirst = working.slice().sort((a, b) => {
|
|
537
|
+
if (a.successCount !== b.successCount)
|
|
538
|
+
return b.successCount - a.successCount;
|
|
539
|
+
if (a.medianLatencyMs !== b.medianLatencyMs)
|
|
540
|
+
return a.medianLatencyMs - b.medianLatencyMs;
|
|
541
|
+
return a.openrouterModelId.localeCompare(b.openrouterModelId);
|
|
542
|
+
});
|
|
543
|
+
const picked = new Set();
|
|
544
|
+
const ordered = [];
|
|
545
|
+
for (const m of smartFirst) {
|
|
546
|
+
if (ordered.length >= Math.min(SMART, MAX_CANDIDATES))
|
|
547
|
+
break;
|
|
548
|
+
if (picked.has(m.openrouterModelId))
|
|
549
|
+
continue;
|
|
550
|
+
picked.add(m.openrouterModelId);
|
|
551
|
+
ordered.push(m.openrouterModelId);
|
|
552
|
+
}
|
|
553
|
+
for (const m of fastFirst) {
|
|
554
|
+
if (ordered.length >= MAX_CANDIDATES)
|
|
555
|
+
break;
|
|
556
|
+
if (picked.has(m.openrouterModelId))
|
|
557
|
+
continue;
|
|
558
|
+
picked.add(m.openrouterModelId);
|
|
559
|
+
ordered.push(m.openrouterModelId);
|
|
560
|
+
}
|
|
561
|
+
return ordered;
|
|
562
|
+
};
|
|
563
|
+
const selectedIdsInitial = buildSelection(ok);
|
|
564
|
+
// Pass 2: refine timing for selected candidates only (RUNS total)
|
|
565
|
+
const refined = ok.slice();
|
|
566
|
+
if (EXTRA_RUNS > 0 && selectedIdsInitial.length > 0) {
|
|
567
|
+
stderr.write(`${cmdName}: refining ${selectedIdsInitial.length} candidates (extra runs=${EXTRA_RUNS})…\n`);
|
|
568
|
+
const byId = new Map(refined.map((m) => [m.openrouterModelId, m]));
|
|
569
|
+
for (const openrouterModelId of selectedIdsInitial) {
|
|
570
|
+
const entry = byId.get(openrouterModelId);
|
|
571
|
+
if (!entry)
|
|
572
|
+
continue;
|
|
573
|
+
const latencies = [entry.initialLatencyMs];
|
|
574
|
+
let successCountForModel = entry.successCount;
|
|
575
|
+
let lastError = null;
|
|
576
|
+
for (let run = 0; run < EXTRA_RUNS; run += 1) {
|
|
577
|
+
const runStartedAt = Date.now();
|
|
578
|
+
try {
|
|
579
|
+
await generateTextWithModelId({
|
|
580
|
+
modelId: `openai/${openrouterModelId}`,
|
|
581
|
+
apiKeys,
|
|
582
|
+
prompt: 'Reply with a single word: OK',
|
|
583
|
+
temperature: 0,
|
|
584
|
+
maxOutputTokens: 16,
|
|
585
|
+
timeoutMs: TIMEOUT_MS,
|
|
586
|
+
fetchImpl,
|
|
587
|
+
forceOpenRouter: true,
|
|
588
|
+
retries: 0,
|
|
589
|
+
});
|
|
590
|
+
successCountForModel += 1;
|
|
591
|
+
const latencyMs = Date.now() - runStartedAt;
|
|
592
|
+
entry.totalLatencyMs += latencyMs;
|
|
593
|
+
latencies.push(latencyMs);
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
lastError = error;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (successCountForModel === 0 && lastError) {
|
|
600
|
+
if (verbose)
|
|
601
|
+
stderr.write(`fail refine ${openrouterModelId}: ${String(lastError)}\n`);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
latencies.sort((a, b) => a - b);
|
|
605
|
+
entry.medianLatencyMs = latencies[Math.floor(latencies.length / 2)] ?? entry.medianLatencyMs;
|
|
606
|
+
entry.successCount = successCountForModel;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const selectedIds = buildSelection(refined);
|
|
610
|
+
const selected = selectedIds.length > 0
|
|
611
|
+
? selectedIds.map((id) => `openrouter/${id}`)
|
|
612
|
+
: refined.slice(0, MAX_CANDIDATES).map((r) => `openrouter/${r.openrouterModelId}`);
|
|
613
|
+
stderr.write(`${cmdName}: selected ${selected.length} candidates.\n`);
|
|
614
|
+
const configPath = resolveConfigPath(env);
|
|
615
|
+
let root = {};
|
|
616
|
+
try {
|
|
617
|
+
const raw = await readFile(configPath, 'utf8');
|
|
618
|
+
assertNoComments(raw, configPath);
|
|
619
|
+
const parsed = JSON5.parse(raw);
|
|
620
|
+
if (!isRecord(parsed)) {
|
|
621
|
+
throw new Error(`Invalid config file ${configPath}: expected an object at the top level`);
|
|
622
|
+
}
|
|
623
|
+
root = parsed;
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
const code = error?.code;
|
|
627
|
+
if (code !== 'ENOENT')
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
const configModelsRaw = root.models;
|
|
631
|
+
const configModels = (() => {
|
|
632
|
+
if (typeof configModelsRaw === 'undefined')
|
|
633
|
+
return {};
|
|
634
|
+
if (!isRecord(configModelsRaw)) {
|
|
635
|
+
throw new Error(`Invalid config file ${configPath}: "models" must be an object.`);
|
|
636
|
+
}
|
|
637
|
+
return { ...configModelsRaw };
|
|
638
|
+
})();
|
|
639
|
+
configModels.free = { rules: [{ candidates: selected }] };
|
|
640
|
+
root.models = configModels;
|
|
641
|
+
if (resolved.setDefault) {
|
|
642
|
+
root.model = 'free';
|
|
643
|
+
}
|
|
644
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
645
|
+
const next = `${JSON.stringify(root, null, 2)}\n`;
|
|
646
|
+
const tmp = `${configPath}.tmp-${process.pid}-${Date.now()}`;
|
|
647
|
+
await writeFile(tmp, next, 'utf8');
|
|
648
|
+
await rename(tmp, configPath);
|
|
649
|
+
stdout.write(`Wrote ${configPath} (models.free)\n`);
|
|
650
|
+
const refinedById = new Map(refined.map((m) => [m.openrouterModelId, m]));
|
|
651
|
+
stderr.write(`\n${heading('Selected')} (sorted, Δ latency)\n`);
|
|
652
|
+
for (const modelId of selectedIds) {
|
|
653
|
+
const r = refinedById.get(modelId);
|
|
654
|
+
if (!r)
|
|
655
|
+
continue;
|
|
656
|
+
const avg = r.successCount > 0 ? r.totalLatencyMs / r.successCount : r.medianLatencyMs;
|
|
657
|
+
const ctx = typeof r.contextLength === 'number' ? `ctx=${formatTokenK(r.contextLength)}` : null;
|
|
658
|
+
const out = typeof r.maxCompletionTokens === 'number'
|
|
659
|
+
? `out=${formatTokenK(r.maxCompletionTokens)}`
|
|
660
|
+
: null;
|
|
661
|
+
const modality = r.modality ? r.modality : null;
|
|
662
|
+
const params = typeof r.inferredParamB === 'number' ? `~${r.inferredParamB}B` : null;
|
|
663
|
+
const meta = [params, ctx, out, modality].filter(Boolean).join(' ');
|
|
664
|
+
stderr.write(`- ${modelId} ${dim(`Δ ${formatMs(avg)} (n=${r.successCount})`)} ${dim(meta)}\n`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
//# sourceMappingURL=refresh-free.js.map
|