@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 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 `chunk_target_tokens`). I only set the
188
- source URLs, and the `extract-video-metadata` command fills the rest:
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 is shown in
339
- the phase output; after the last attempt the file is reported as an error and is
340
- retried on the next run.
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 frontmatter records how many chunks were used.
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.5.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({ feed, ...settings, dir, force: argv.force });
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 { createModel, describeError } from "./generate-markdown.js";
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 });
@@ -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(job, { modelId, usage, elapsed, cost, finishReason, price, chunks }) {
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 describeError(error, attempts) {
209
- const suffix = ` after ${attempts} attempt(s)`;
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}${suffix}${body ? `: ${body}` : ""}`;
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
- { model, retryDelays, session, limiter, priceAt },
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: "low",
382
+ ...reasoningOptions(reasoning, endpoint),
295
383
  maxOutputTokens,
296
384
  maxRetries: 0,
297
385
  headers: { "X-OpenCode-Session": session },
298
386
  });
299
387
  };
300
- const { text, usage, finishReason } = await (
301
- limiter ? limiter(call) : call()
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
- return { text, usage, finishReason, elapsed, price, cost };
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
- { model, retryDelays, session, limiter, priceAt },
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
- { model, retryDelays, session, limiter, priceAt },
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 { body, usage, finishReason, elapsed, chunks, cost, price } =
682
- await toMarkdown(
683
- {
684
- model,
685
- retryDelays: RETRY_DELAYS,
686
- maxOutputTokens,
687
- ceiling,
688
- chunkTargetTokens,
689
- session: videoSession(sessionBase, job),
690
- limiter,
691
- priceAt,
692
- },
693
- job.title,
694
- transcript,
695
- (nextAttempt, totalAttempts, waitMs) => {
696
- task.output = `retrying in ${Math.round(waitMs / 1000)}s ` +
697
- `(attempt ${nextAttempt}/${totalAttempts}) ${name}`;
698
- },
699
- (part, totalParts) => {
700
- if (totalParts > 1) {
701
- task.output = `generating part ${part}/${totalParts} ${name}`;
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,