@stephane-klein/youtube-to-markdown 0.5.0 → 0.9.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/README.md +51 -6
- package/package.json +1 -1
- package/src/cli.js +22 -1
- package/src/config.js +3 -0
- package/src/doctor.js +14 -1
- package/src/generate-markdown.js +231 -38
package/README.md
CHANGED
|
@@ -184,8 +184,9 @@ variables, then the settings written in the YAML file, then the built-in
|
|
|
184
184
|
defaults. The API key stays a secret: `--api-key` or `OPENAI_API_KEY`. The
|
|
185
185
|
settings are `x_opencode_session`, `model_id`, `openaiapi_endpoint`,
|
|
186
186
|
`concurrency` and `ytdlp_concurrency` (plus the optional `api_key`,
|
|
187
|
-
`retry_delays`, `max_output_tokens` and `
|
|
188
|
-
source URLs, and the `extract-video-metadata` command fills the
|
|
187
|
+
`retry_delays`, `max_output_tokens`, `chunk_target_tokens` and `reasoning`). I
|
|
188
|
+
only set the source URLs, and the `extract-video-metadata` command fills the
|
|
189
|
+
rest:
|
|
189
190
|
|
|
190
191
|
`x_opencode_session` is the base of the `X-OpenCode-Session` header; it is
|
|
191
192
|
optional and specific to OpenCode. A stable per-video suffix is appended to it,
|
|
@@ -335,9 +336,49 @@ only the last 20 events on screen, so it stays bounded even with a large feed.
|
|
|
335
336
|
Retryable API errors (HTTP 408, 409, 429 or >= 500) are retried with growing
|
|
336
337
|
delays — 10 s, 30 s, 60 s by default, configurable with `--retry-delays`
|
|
337
338
|
(comma-separated seconds, or `YT_TO_MD_RETRY_DELAYS`). A `Retry-After` header
|
|
338
|
-
from the server is honored when longer than the schedule. Each retry
|
|
339
|
-
the
|
|
340
|
-
|
|
339
|
+
from the server is honored when longer than the schedule. Each retry line shows
|
|
340
|
+
the reason reported by the API (status and response body), for example:
|
|
341
|
+
|
|
342
|
+
```
|
|
343
|
+
retrying in 30s (attempt 3/4) HTTP 429 rate limited david-cayla/....fr.md
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
After the last attempt the file is reported as an error and is retried on the
|
|
347
|
+
next run. At the end of the run, a `failures:` block lists each failed file with
|
|
348
|
+
its reason, so the cause stays visible even after the phase output has scrolled.
|
|
349
|
+
The command exits with a non-zero status when at least one file failed.
|
|
350
|
+
|
|
351
|
+
### Empty output
|
|
352
|
+
|
|
353
|
+
A model can finish with `finish_reason: stop` but return no text at all, for
|
|
354
|
+
example when it spends its whole response on internal reasoning. Such a
|
|
355
|
+
generation is reported as an error and no file is written:
|
|
356
|
+
|
|
357
|
+
```
|
|
358
|
+
error (empty output: part 1/1, finish=stop, text_tokens=1, reasoning_tokens=4001) ....fr.md
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
The `failures:` block repeats the reason at the end of the run. Add
|
|
362
|
+
`--debug-llm` (or `YT_TO_MD_DEBUG_LLM=1`) to log the request's `max_tokens` and
|
|
363
|
+
`reasoning_effort`, plus the size of the provider's `content` and
|
|
364
|
+
`reasoning_content`, which helps diagnose provider-side empty answers.
|
|
365
|
+
|
|
366
|
+
### Reasoning effort
|
|
367
|
+
|
|
368
|
+
The command asks the model for a reasoning effort of `low` by default. Set
|
|
369
|
+
`reasoning` in the YAML file, `YT_TO_MD_REASONING`, or `--reasoning` to change
|
|
370
|
+
it. The accepted values are `provider-default`, `none`, `minimal`, `low`,
|
|
371
|
+
`medium`, `high` and `xhigh`, as exposed by ai-sdk. `none` disables reasoning:
|
|
372
|
+
on a DeepSeek endpoint it sends the provider-specific `thinking: disabled`
|
|
373
|
+
option, elsewhere it sends `reasoning: none`. `provider-default` leaves the
|
|
374
|
+
choice to the provider.
|
|
375
|
+
|
|
376
|
+
```yaml
|
|
377
|
+
reasoning: "none"
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
The `doctor` command shows the effective value and applies it to its test
|
|
381
|
+
request, so it reflects the real configuration.
|
|
341
382
|
|
|
342
383
|
### Output budget and long transcripts
|
|
343
384
|
|
|
@@ -356,7 +397,8 @@ LLM call that rewrites the beginning of the next section so it flows from the
|
|
|
356
397
|
previous one. `--chunk-target-tokens` (or `YT_TO_MD_CHUNK_TARGET_TOKENS`,
|
|
357
398
|
default 8000) sets the target input size of each chunk. The generation of a
|
|
358
399
|
chunk is guaranteed to stop with `stop`; otherwise the file is reported as an
|
|
359
|
-
error and nothing is written. The
|
|
400
|
+
error and nothing is written. The same happens when a part comes back with an
|
|
401
|
+
empty body. The frontmatter records how many chunks were used.
|
|
360
402
|
|
|
361
403
|
### Generated frontmatter
|
|
362
404
|
|
|
@@ -374,8 +416,11 @@ llm:
|
|
|
374
416
|
input_tokens: 4428
|
|
375
417
|
cached_input_tokens: 0
|
|
376
418
|
output_tokens: 3262
|
|
419
|
+
reasoning_tokens: 0
|
|
420
|
+
text_tokens: 3262
|
|
377
421
|
estimated_cost_usd: 0.000927
|
|
378
422
|
finish_reason: stop
|
|
423
|
+
raw_finish_reason: stop
|
|
379
424
|
pricing:
|
|
380
425
|
source: models.dev
|
|
381
426
|
peak: null
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stephane-klein/youtube-to-markdown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Collect YouTube channel videos into a YAML config with yt-dlp, download transcripts and turn them into Markdown prose with an LLM.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli.js
CHANGED
|
@@ -24,6 +24,7 @@ const SETTING_KEYS = [
|
|
|
24
24
|
"retryDelays",
|
|
25
25
|
"maxOutputTokens",
|
|
26
26
|
"chunkTargetTokens",
|
|
27
|
+
"reasoning",
|
|
27
28
|
];
|
|
28
29
|
|
|
29
30
|
const handle = (run) => async (argv) => {
|
|
@@ -121,6 +122,15 @@ const cli = yargs(hideBin(process.argv))
|
|
|
121
122
|
describe: "Target input tokens per chunk when a transcript is split (default: 8000)",
|
|
122
123
|
type: "number",
|
|
123
124
|
})
|
|
125
|
+
.option("reasoning", {
|
|
126
|
+
describe: "Reasoning effort: provider-default, none, minimal, low, medium, high or xhigh (default: low)",
|
|
127
|
+
type: "string",
|
|
128
|
+
})
|
|
129
|
+
.option("debug-llm", {
|
|
130
|
+
describe: "Print LLM request and response diagnostics",
|
|
131
|
+
type: "boolean",
|
|
132
|
+
default: false,
|
|
133
|
+
})
|
|
124
134
|
.option("force", {
|
|
125
135
|
describe: "Regenerate every Markdown file",
|
|
126
136
|
type: "boolean",
|
|
@@ -128,7 +138,13 @@ const cli = yargs(hideBin(process.argv))
|
|
|
128
138
|
}),
|
|
129
139
|
handle(async (argv) => {
|
|
130
140
|
const { feed, settings, dir } = project(argv);
|
|
131
|
-
await runGenerateMarkdown({
|
|
141
|
+
await runGenerateMarkdown({
|
|
142
|
+
feed,
|
|
143
|
+
...settings,
|
|
144
|
+
dir,
|
|
145
|
+
force: argv.force,
|
|
146
|
+
debugLlm: argv.debugLlm,
|
|
147
|
+
});
|
|
132
148
|
}),
|
|
133
149
|
)
|
|
134
150
|
.command(
|
|
@@ -175,6 +191,10 @@ const cli = yargs(hideBin(process.argv))
|
|
|
175
191
|
describe: "Value of the X-OpenCode-Session header",
|
|
176
192
|
type: "string",
|
|
177
193
|
})
|
|
194
|
+
.option("reasoning", {
|
|
195
|
+
describe: "Reasoning effort to test (default: low)",
|
|
196
|
+
type: "string",
|
|
197
|
+
})
|
|
178
198
|
.option("llm", {
|
|
179
199
|
describe: "Run a minimal LLM request to test the API access",
|
|
180
200
|
type: "boolean",
|
|
@@ -193,6 +213,7 @@ const cli = yargs(hideBin(process.argv))
|
|
|
193
213
|
endpoint: argv.endpoint,
|
|
194
214
|
apiKey: argv.apiKey,
|
|
195
215
|
session: argv.session,
|
|
216
|
+
reasoning: argv.reasoning,
|
|
196
217
|
},
|
|
197
218
|
llm: argv.llm,
|
|
198
219
|
timeoutMs: argv.timeout * 1000,
|
package/src/config.js
CHANGED
|
@@ -15,6 +15,7 @@ const YAML_KEYS = {
|
|
|
15
15
|
retryDelays: "retry_delays",
|
|
16
16
|
maxOutputTokens: "max_output_tokens",
|
|
17
17
|
chunkTargetTokens: "chunk_target_tokens",
|
|
18
|
+
reasoning: "reasoning",
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
export function settingsFromConfig(config) {
|
|
@@ -36,6 +37,8 @@ const ENV = {
|
|
|
36
37
|
retryDelays: "YT_TO_MD_RETRY_DELAYS",
|
|
37
38
|
maxOutputTokens: "YT_TO_MD_MAX_OUTPUT_TOKENS",
|
|
38
39
|
chunkTargetTokens: "YT_TO_MD_CHUNK_TARGET_TOKENS",
|
|
40
|
+
reasoning: "YT_TO_MD_REASONING",
|
|
41
|
+
debugLlm: "YT_TO_MD_DEBUG_LLM",
|
|
39
42
|
};
|
|
40
43
|
|
|
41
44
|
export function loadEnv(env = process.env) {
|
package/src/doctor.js
CHANGED
|
@@ -2,7 +2,12 @@ import { access, constants } from "node:fs/promises";
|
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { generateText } from "ai";
|
|
4
4
|
import { loadConfig, settingsFromConfig } from "./config.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
createModel,
|
|
7
|
+
DEFAULT_REASONING,
|
|
8
|
+
describeError,
|
|
9
|
+
reasoningOptions,
|
|
10
|
+
} from "./generate-markdown.js";
|
|
6
11
|
import { loadPricing } from "./pricing.js";
|
|
7
12
|
import { exists } from "./vtt.js";
|
|
8
13
|
import { runYtDlp } from "./ytdlp.js";
|
|
@@ -124,11 +129,13 @@ async function checkLlm({ llm, settings, key, timeoutMs }) {
|
|
|
124
129
|
session: settings.session,
|
|
125
130
|
});
|
|
126
131
|
const started = performance.now();
|
|
132
|
+
const reasoning = settings.reasoning ?? DEFAULT_REASONING;
|
|
127
133
|
const { usage } = await generateText({
|
|
128
134
|
model,
|
|
129
135
|
prompt: "ping",
|
|
130
136
|
maxOutputTokens: 8,
|
|
131
137
|
maxRetries: 0,
|
|
138
|
+
...reasoningOptions(reasoning, settings.endpoint),
|
|
132
139
|
abortSignal: AbortSignal.timeout(timeoutMs),
|
|
133
140
|
});
|
|
134
141
|
const elapsed = (performance.now() - started) / 1000;
|
|
@@ -218,6 +225,12 @@ export async function runDoctor({
|
|
|
218
225
|
if (settings.session) {
|
|
219
226
|
rows.push({ label: "session", value: "set", status: "info", note: null });
|
|
220
227
|
}
|
|
228
|
+
rows.push({
|
|
229
|
+
label: "reasoning",
|
|
230
|
+
value: settings.reasoning ?? DEFAULT_REASONING,
|
|
231
|
+
status: "info",
|
|
232
|
+
note: null,
|
|
233
|
+
});
|
|
221
234
|
rows.push({ label: "pricing", ...(await checkPricing({ settings })) });
|
|
222
235
|
const llmRow = await checkLlm({ llm, settings, key, timeoutMs });
|
|
223
236
|
rows.push({ label: "llm", ...llmRow });
|
package/src/generate-markdown.js
CHANGED
|
@@ -68,6 +68,7 @@ function estimateCost(price, usage) {
|
|
|
68
68
|
const OUTPUT_FACTOR = 2;
|
|
69
69
|
const OUTPUT_FLOOR = 2048;
|
|
70
70
|
const DEFAULT_CEILING = 32768;
|
|
71
|
+
const DEFAULT_REASONING = "low";
|
|
71
72
|
const DEFAULT_CHUNK_TARGET_TOKENS = 8000;
|
|
72
73
|
|
|
73
74
|
function isSet(value) {
|
|
@@ -88,19 +89,33 @@ function budgetForPart(text, { maxOutputTokens, ceiling }) {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
function metricsLine(modelId, usage, elapsed, cost) {
|
|
92
|
+
const reasoning = usage?.outputTokenDetails?.reasoningTokens;
|
|
91
93
|
return [
|
|
92
94
|
modelId,
|
|
93
95
|
`llm ${elapsed.toFixed(1)}s`,
|
|
94
96
|
usage
|
|
95
97
|
? `${usage.inputTokens ?? 0} in / ${usage.outputTokens ?? 0} out`
|
|
96
98
|
: "usage ?",
|
|
99
|
+
reasoning ? `${reasoning} reasoning` : null,
|
|
97
100
|
cost === null ? null : `~$${cost.toFixed(6)}`,
|
|
98
101
|
]
|
|
99
102
|
.filter(Boolean)
|
|
100
103
|
.join(" ");
|
|
101
104
|
}
|
|
102
105
|
|
|
103
|
-
function frontmatter(
|
|
106
|
+
function frontmatter(
|
|
107
|
+
job,
|
|
108
|
+
{
|
|
109
|
+
modelId,
|
|
110
|
+
usage,
|
|
111
|
+
elapsed,
|
|
112
|
+
cost,
|
|
113
|
+
finishReason,
|
|
114
|
+
rawFinishReason,
|
|
115
|
+
price,
|
|
116
|
+
chunks,
|
|
117
|
+
},
|
|
118
|
+
) {
|
|
104
119
|
const data = {
|
|
105
120
|
source_url: job.url,
|
|
106
121
|
video_title: titleForLang(job.titles, job.lang),
|
|
@@ -112,8 +127,11 @@ function frontmatter(job, { modelId, usage, elapsed, cost, finishReason, price,
|
|
|
112
127
|
input_tokens: usage?.inputTokens ?? null,
|
|
113
128
|
cached_input_tokens: usage ? cachedInputTokens(usage) : null,
|
|
114
129
|
output_tokens: usage?.outputTokens ?? null,
|
|
130
|
+
reasoning_tokens: usage?.outputTokenDetails?.reasoningTokens ?? null,
|
|
131
|
+
text_tokens: usage?.outputTokenDetails?.textTokens ?? null,
|
|
115
132
|
estimated_cost_usd: cost === null ? null : Number(cost.toFixed(6)),
|
|
116
133
|
finish_reason: finishReason,
|
|
134
|
+
raw_finish_reason: rawFinishReason ?? null,
|
|
117
135
|
pricing: price ? { source: price.source, peak: price.peak } : null,
|
|
118
136
|
},
|
|
119
137
|
};
|
|
@@ -121,6 +139,26 @@ function frontmatter(job, { modelId, usage, elapsed, cost, finishReason, price,
|
|
|
121
139
|
return `---\n${stringify(data, { lineWidth: 0 }).trimEnd()}\n---\n\n`;
|
|
122
140
|
}
|
|
123
141
|
|
|
142
|
+
export function emptyOutputReason({
|
|
143
|
+
emptyParts,
|
|
144
|
+
chunks,
|
|
145
|
+
finishReason,
|
|
146
|
+
rawFinishReason,
|
|
147
|
+
usage,
|
|
148
|
+
}) {
|
|
149
|
+
const raw =
|
|
150
|
+
rawFinishReason && rawFinishReason !== finishReason
|
|
151
|
+
? `/${rawFinishReason}`
|
|
152
|
+
: "";
|
|
153
|
+
const text = usage?.outputTokenDetails?.textTokens ?? null;
|
|
154
|
+
const reasoning = usage?.outputTokenDetails?.reasoningTokens ?? null;
|
|
155
|
+
return (
|
|
156
|
+
`empty output: part ${emptyParts.join(",")}/${chunks}, ` +
|
|
157
|
+
`finish=${finishReason}${raw}, text_tokens=${text}, ` +
|
|
158
|
+
`reasoning_tokens=${reasoning}`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
124
162
|
const isHeading = (text) => /^#{1,6}\s/.test(text);
|
|
125
163
|
|
|
126
164
|
const isStructured = (text) =>
|
|
@@ -205,13 +243,16 @@ function retryDelayMs(error, fallbackMs) {
|
|
|
205
243
|
return Math.max(headerMs, fallbackMs);
|
|
206
244
|
}
|
|
207
245
|
|
|
208
|
-
export function
|
|
209
|
-
|
|
210
|
-
if (!APICallError.isInstance(error)) return `${error.message}${suffix}`;
|
|
246
|
+
export function describeCause(error) {
|
|
247
|
+
if (!APICallError.isInstance(error)) return `${error.message}`;
|
|
211
248
|
|
|
212
249
|
const status = error.statusCode ? `HTTP ${error.statusCode}` : "API error";
|
|
213
250
|
const body = error.responseBody?.trim().replace(/\s+/g, " ").slice(0, 200);
|
|
214
|
-
return `${status} ${error.message}${
|
|
251
|
+
return `${status} ${error.message}${body ? `: ${body}` : ""}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function describeError(error, attempts) {
|
|
255
|
+
return `${describeCause(error)} after ${attempts} attempt(s)`;
|
|
215
256
|
}
|
|
216
257
|
|
|
217
258
|
function parseRetryDelays(value) {
|
|
@@ -258,6 +299,44 @@ export function createModel({ modelId, endpoint, apiKey, session }) {
|
|
|
258
299
|
return provider(modelId);
|
|
259
300
|
}
|
|
260
301
|
|
|
302
|
+
export function reasoningOptions(reasoning, endpoint = "") {
|
|
303
|
+
if (reasoning === "none") {
|
|
304
|
+
return /deepseek/i.test(endpoint)
|
|
305
|
+
? {
|
|
306
|
+
providerOptions: {
|
|
307
|
+
opencode: { thinking: { type: "disabled" } },
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
: { reasoning: "none" };
|
|
311
|
+
}
|
|
312
|
+
return reasoning ? { reasoning } : {};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function logLlmDebug({
|
|
316
|
+
maxOutputTokens,
|
|
317
|
+
reasoning,
|
|
318
|
+
finishReason,
|
|
319
|
+
rawFinishReason,
|
|
320
|
+
text,
|
|
321
|
+
reasoningText,
|
|
322
|
+
warnings,
|
|
323
|
+
}) {
|
|
324
|
+
console.error(
|
|
325
|
+
`llm debug: max_tokens=${maxOutputTokens ?? "-"} ` +
|
|
326
|
+
`reasoning=${reasoning ?? "provider-default"} ` +
|
|
327
|
+
`finish=${finishReason} raw=${rawFinishReason ?? "-"} ` +
|
|
328
|
+
`text_chars=${text?.length ?? 0} ` +
|
|
329
|
+
`reasoning_chars=${reasoningText?.length ?? 0}`,
|
|
330
|
+
);
|
|
331
|
+
if (warnings?.length) {
|
|
332
|
+
console.error(`llm debug: warnings=${JSON.stringify(warnings)}`);
|
|
333
|
+
}
|
|
334
|
+
if (!`${text ?? ""}`.trim() && reasoningText) {
|
|
335
|
+
const preview = reasoningText.replace(/\s+/g, " ").slice(0, 300);
|
|
336
|
+
console.error(`llm debug: empty text, reasoning preview: ${preview}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
261
340
|
function videoSession(base, job) {
|
|
262
341
|
const id = createHash("sha256")
|
|
263
342
|
.update(`${job.url}#${job.lang}`)
|
|
@@ -275,7 +354,16 @@ function cleanBody(text) {
|
|
|
275
354
|
}
|
|
276
355
|
|
|
277
356
|
async function generateOnce(
|
|
278
|
-
{
|
|
357
|
+
{
|
|
358
|
+
model,
|
|
359
|
+
retryDelays,
|
|
360
|
+
session,
|
|
361
|
+
limiter,
|
|
362
|
+
priceAt,
|
|
363
|
+
reasoning,
|
|
364
|
+
endpoint,
|
|
365
|
+
debug,
|
|
366
|
+
},
|
|
279
367
|
{ system, prompt, maxOutputTokens, onRetry },
|
|
280
368
|
) {
|
|
281
369
|
const totalAttempts = retryDelays.length + 1;
|
|
@@ -291,26 +379,52 @@ async function generateOnce(
|
|
|
291
379
|
system,
|
|
292
380
|
prompt,
|
|
293
381
|
temperature: 0.2,
|
|
294
|
-
reasoning
|
|
382
|
+
...reasoningOptions(reasoning, endpoint),
|
|
295
383
|
maxOutputTokens,
|
|
296
384
|
maxRetries: 0,
|
|
297
385
|
headers: { "X-OpenCode-Session": session },
|
|
298
386
|
});
|
|
299
387
|
};
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
388
|
+
const result = await (limiter ? limiter(call) : call());
|
|
389
|
+
const {
|
|
390
|
+
text,
|
|
391
|
+
usage,
|
|
392
|
+
finishReason,
|
|
393
|
+
rawFinishReason,
|
|
394
|
+
reasoningText,
|
|
395
|
+
warnings,
|
|
396
|
+
} = result;
|
|
303
397
|
const elapsed = (performance.now() - started) / 1000;
|
|
304
398
|
const price = priceAt ? priceAt(at) : null;
|
|
305
399
|
const cost = estimateCost(price, usage);
|
|
306
400
|
|
|
307
|
-
|
|
401
|
+
if (debug) {
|
|
402
|
+
logLlmDebug({
|
|
403
|
+
maxOutputTokens,
|
|
404
|
+
reasoning,
|
|
405
|
+
finishReason,
|
|
406
|
+
rawFinishReason,
|
|
407
|
+
text,
|
|
408
|
+
reasoningText,
|
|
409
|
+
warnings,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
text,
|
|
415
|
+
usage,
|
|
416
|
+
finishReason,
|
|
417
|
+
rawFinishReason,
|
|
418
|
+
elapsed,
|
|
419
|
+
price,
|
|
420
|
+
cost,
|
|
421
|
+
};
|
|
308
422
|
} catch (error) {
|
|
309
423
|
if (!isRetryable(error) || attempt > retryDelays.length) {
|
|
310
424
|
throw new Error(describeError(error, attempt), { cause: error });
|
|
311
425
|
}
|
|
312
426
|
const waitMs = retryDelayMs(error, retryDelays[attempt - 1]);
|
|
313
|
-
onRetry?.(attempt + 1, totalAttempts, waitMs);
|
|
427
|
+
onRetry?.(attempt + 1, totalAttempts, waitMs, error);
|
|
314
428
|
await delay(waitMs);
|
|
315
429
|
}
|
|
316
430
|
}
|
|
@@ -406,6 +520,9 @@ async function toMarkdown(
|
|
|
406
520
|
session,
|
|
407
521
|
limiter,
|
|
408
522
|
priceAt,
|
|
523
|
+
reasoning,
|
|
524
|
+
endpoint,
|
|
525
|
+
debug,
|
|
409
526
|
},
|
|
410
527
|
title,
|
|
411
528
|
transcript,
|
|
@@ -429,6 +546,7 @@ async function toMarkdown(
|
|
|
429
546
|
const usages = [];
|
|
430
547
|
let elapsed = 0;
|
|
431
548
|
let finishReason = "stop";
|
|
549
|
+
let rawFinishReason;
|
|
432
550
|
let cost = 0;
|
|
433
551
|
let costKnown = true;
|
|
434
552
|
let price = null;
|
|
@@ -437,7 +555,16 @@ async function toMarkdown(
|
|
|
437
555
|
parts.map(async (part, index) => {
|
|
438
556
|
onProgress?.(index + 1, total);
|
|
439
557
|
const result = await generateOnce(
|
|
440
|
-
{
|
|
558
|
+
{
|
|
559
|
+
model,
|
|
560
|
+
retryDelays,
|
|
561
|
+
session,
|
|
562
|
+
limiter,
|
|
563
|
+
priceAt,
|
|
564
|
+
reasoning,
|
|
565
|
+
endpoint,
|
|
566
|
+
debug,
|
|
567
|
+
},
|
|
441
568
|
{
|
|
442
569
|
system: SYSTEM,
|
|
443
570
|
prompt: chunkPrompt(title, part, index, total),
|
|
@@ -451,8 +578,12 @@ async function toMarkdown(
|
|
|
451
578
|
if (result.cost === null) costKnown = false;
|
|
452
579
|
else cost += result.cost;
|
|
453
580
|
if (price === null && result.price) price = result.price;
|
|
581
|
+
if (rawFinishReason === undefined) {
|
|
582
|
+
rawFinishReason = result.rawFinishReason;
|
|
583
|
+
}
|
|
454
584
|
if (result.finishReason !== "stop" && finishReason === "stop") {
|
|
455
585
|
finishReason = result.finishReason;
|
|
586
|
+
rawFinishReason = result.rawFinishReason;
|
|
456
587
|
}
|
|
457
588
|
}),
|
|
458
589
|
);
|
|
@@ -464,7 +595,16 @@ async function toMarkdown(
|
|
|
464
595
|
if (!previousTail || !nextHead) continue;
|
|
465
596
|
|
|
466
597
|
const smoothing = await generateOnce(
|
|
467
|
-
{
|
|
598
|
+
{
|
|
599
|
+
model,
|
|
600
|
+
retryDelays,
|
|
601
|
+
session,
|
|
602
|
+
limiter,
|
|
603
|
+
priceAt,
|
|
604
|
+
reasoning,
|
|
605
|
+
endpoint,
|
|
606
|
+
debug,
|
|
607
|
+
},
|
|
468
608
|
{
|
|
469
609
|
system: SMOOTH_SYSTEM,
|
|
470
610
|
prompt: smoothPrompt(previousTail, nextHead),
|
|
@@ -492,11 +632,16 @@ async function toMarkdown(
|
|
|
492
632
|
.replace(/\n{3,}/g, "\n\n")
|
|
493
633
|
.trim(),
|
|
494
634
|
);
|
|
635
|
+
const emptyParts = bodies.flatMap((part, index) =>
|
|
636
|
+
part ? [] : [index + 1],
|
|
637
|
+
);
|
|
495
638
|
|
|
496
639
|
return {
|
|
497
640
|
body,
|
|
498
641
|
usage: sumUsage(usages),
|
|
499
642
|
finishReason,
|
|
643
|
+
rawFinishReason,
|
|
644
|
+
emptyParts,
|
|
500
645
|
elapsed,
|
|
501
646
|
chunks: total,
|
|
502
647
|
cost: costKnown ? cost : null,
|
|
@@ -527,7 +672,9 @@ export async function runGenerateMarkdown({
|
|
|
527
672
|
retryDelays = "10,30,60",
|
|
528
673
|
maxOutputTokens,
|
|
529
674
|
chunkTargetTokens = DEFAULT_CHUNK_TARGET_TOKENS,
|
|
675
|
+
reasoning = DEFAULT_REASONING,
|
|
530
676
|
force = false,
|
|
677
|
+
debugLlm = false,
|
|
531
678
|
dir = DEFAULT_DIR,
|
|
532
679
|
} = {}) {
|
|
533
680
|
const key = apiKey ?? process.env.OPENAI_API_KEY;
|
|
@@ -535,6 +682,7 @@ export async function runGenerateMarkdown({
|
|
|
535
682
|
if (!modelId) throw new Error("model is not set (--model)");
|
|
536
683
|
if (!endpoint) throw new Error("endpoint is not set (--endpoint)");
|
|
537
684
|
|
|
685
|
+
const debug = debugLlm === true || /^(1|true|yes|on)$/i.test(`${debugLlm}`);
|
|
538
686
|
const RETRY_DELAYS = parseRetryDelays(retryDelays);
|
|
539
687
|
const llmConcurrency = Number(concurrency);
|
|
540
688
|
const vttConcurrency = Number(ytdlpConcurrency);
|
|
@@ -557,6 +705,7 @@ export async function runGenerateMarkdown({
|
|
|
557
705
|
already: 0,
|
|
558
706
|
skipped: 0,
|
|
559
707
|
errors: 0,
|
|
708
|
+
failures: [],
|
|
560
709
|
totalSeconds: 0,
|
|
561
710
|
totalInput: 0,
|
|
562
711
|
totalOutput: 0,
|
|
@@ -678,30 +827,43 @@ export async function runGenerateMarkdown({
|
|
|
678
827
|
const name = relative(dir, md);
|
|
679
828
|
try {
|
|
680
829
|
const transcript = vttToText(await readFile(job.vtt, "utf8"));
|
|
681
|
-
const {
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
830
|
+
const {
|
|
831
|
+
body,
|
|
832
|
+
usage,
|
|
833
|
+
finishReason,
|
|
834
|
+
rawFinishReason,
|
|
835
|
+
emptyParts,
|
|
836
|
+
elapsed,
|
|
837
|
+
chunks,
|
|
838
|
+
cost,
|
|
839
|
+
price,
|
|
840
|
+
} = await toMarkdown(
|
|
841
|
+
{
|
|
842
|
+
model,
|
|
843
|
+
retryDelays: RETRY_DELAYS,
|
|
844
|
+
maxOutputTokens,
|
|
845
|
+
ceiling,
|
|
846
|
+
chunkTargetTokens,
|
|
847
|
+
session: videoSession(sessionBase, job),
|
|
848
|
+
limiter,
|
|
849
|
+
priceAt,
|
|
850
|
+
reasoning,
|
|
851
|
+
endpoint,
|
|
852
|
+
debug,
|
|
853
|
+
},
|
|
854
|
+
job.title,
|
|
855
|
+
transcript,
|
|
856
|
+
(nextAttempt, totalAttempts, waitMs, error) => {
|
|
857
|
+
task.output = `retrying in ${Math.round(waitMs / 1000)}s ` +
|
|
858
|
+
`(attempt ${nextAttempt}/${totalAttempts}) ` +
|
|
859
|
+
`${describeCause(error)} ${name}`;
|
|
860
|
+
},
|
|
861
|
+
(part, totalParts) => {
|
|
862
|
+
task.output = totalParts > 1
|
|
863
|
+
? `generating part ${part}/${totalParts} ${name}`
|
|
864
|
+
: `generating ${name}`;
|
|
865
|
+
},
|
|
866
|
+
);
|
|
705
867
|
|
|
706
868
|
stats.totalSeconds += elapsed;
|
|
707
869
|
stats.totalInput += usage?.inputTokens ?? 0;
|
|
@@ -712,16 +874,35 @@ export async function runGenerateMarkdown({
|
|
|
712
874
|
|
|
713
875
|
if (finishReason !== "stop") {
|
|
714
876
|
stats.errors++;
|
|
877
|
+
stats.failures.push({
|
|
878
|
+
name,
|
|
879
|
+
reason: `finishReason=${finishReason}`,
|
|
880
|
+
});
|
|
715
881
|
task.output = `error (finishReason=${finishReason}) ${name}`;
|
|
716
882
|
return;
|
|
717
883
|
}
|
|
718
884
|
|
|
885
|
+
if (emptyParts.length > 0) {
|
|
886
|
+
const reason = emptyOutputReason({
|
|
887
|
+
emptyParts,
|
|
888
|
+
chunks,
|
|
889
|
+
finishReason,
|
|
890
|
+
rawFinishReason,
|
|
891
|
+
usage,
|
|
892
|
+
});
|
|
893
|
+
stats.errors++;
|
|
894
|
+
stats.failures.push({ name, reason });
|
|
895
|
+
task.output = `error (${reason}) ${name}`;
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
719
899
|
const front = frontmatter(job, {
|
|
720
900
|
modelId,
|
|
721
901
|
usage,
|
|
722
902
|
elapsed,
|
|
723
903
|
cost,
|
|
724
904
|
finishReason,
|
|
905
|
+
rawFinishReason,
|
|
725
906
|
price,
|
|
726
907
|
chunks,
|
|
727
908
|
});
|
|
@@ -734,6 +915,7 @@ export async function runGenerateMarkdown({
|
|
|
734
915
|
task.output = `generated ${name} ${metrics}`;
|
|
735
916
|
} catch (error) {
|
|
736
917
|
stats.errors++;
|
|
918
|
+
stats.failures.push({ name, reason: error.message });
|
|
737
919
|
task.output = `error (${error.message}) ${name}`;
|
|
738
920
|
} finally {
|
|
739
921
|
const upToDate = stats.already + stats.generated;
|
|
@@ -759,6 +941,15 @@ export async function runGenerateMarkdown({
|
|
|
759
941
|
stats.costKnown ? `, ~$${stats.totalCost.toFixed(6)}` : ""
|
|
760
942
|
}`,
|
|
761
943
|
);
|
|
944
|
+
|
|
945
|
+
if (stats.failures.length > 0) {
|
|
946
|
+
console.error("\nfailures:");
|
|
947
|
+
for (const failure of stats.failures) {
|
|
948
|
+
console.error(` - ${failure.name}: ${failure.reason}`);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (stats.errors > 0) process.exitCode = 1;
|
|
762
953
|
}
|
|
763
954
|
|
|
764
955
|
export {
|
|
@@ -766,7 +957,9 @@ export {
|
|
|
766
957
|
chunkPrompt,
|
|
767
958
|
cleanBody,
|
|
768
959
|
createLimiter,
|
|
960
|
+
DEFAULT_REASONING,
|
|
769
961
|
estimateCost,
|
|
962
|
+
metricsLine,
|
|
770
963
|
plannedBudget,
|
|
771
964
|
smoothPrompt,
|
|
772
965
|
sumUsage,
|