@stephane-klein/youtube-to-markdown 0.4.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
@@ -13,6 +13,37 @@ channel or playlist and then to download its transcripts.
13
13
  In the final step, I use an LLM through a standard OpenAI-compatible API to
14
14
  reconstruct a complete text from the video transcripts.
15
15
 
16
+ ## AI usage in this project
17
+
18
+ This project was developed using:
19
+
20
+ - Harness: [OpenCode](https://opencode.ai)
21
+ - Models: [DeepSeek-V4.1-Flash](https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash) via [OpenCode Go](https://opencode.ai/go)
22
+ - I mostly vibe coded this project: I made the implementation, library and architecture choices, but I reviewed the code very little.
23
+
24
+ The figures below cover the period from the start of the project to version 0.4.0
25
+ ([de39801](https://github.com/stephane-klein/youtube-to-markdown/commit/de39801f123fa1cfaeaa3b9cfed5071968977474)).
26
+
27
+ Metrics:
28
+
29
+ - 16 OpenCode sessions, 1426 interactions;
30
+ - 11 h of human time on this project, against 4 h of AI generation time over the same period, so a human/AI time ratio of 2.75 (11 h / 4 h);
31
+ - Cost: $2.4958;
32
+ - Tokens:
33
+ - input: 7,047,916
34
+ - output: 421,770
35
+ - cache read: 228,841,600
36
+ - total: 236,311,286
37
+
38
+ Per agent:
39
+
40
+ | agent | interactions | share | output tokens | active hours | active share | generation hours | cost |
41
+ |:--------|-------------:|------:|--------------:|-------------:|-------------:|-----------------:|-------:|
42
+ | build | 962 | 68% | 259,706 | 3.71 | 38% | 1.93 | $1.385 |
43
+ | plan | 450 | 32% | 155,136 | 5.91 | 61% | 2.10 | $1.098 |
44
+ | explore | 14 | 1% | 6,928 | 0.02 | 0% | 0.02 | $0.013 |
45
+
46
+
16
47
  ## Requirements
17
48
 
18
49
  - Node.js 22 or later;
@@ -129,7 +160,13 @@ can gate a CI job.
129
160
  ```sh
130
161
  $ youtube-to-markdown extract-video-metadata
131
162
  → https://www.youtube.com/@le_science4all
163
+ exact dates 50/2217 2.4s elapsed ~1m 44s left
164
+ exact dates 100/2217 4.8s elapsed ~1m 42s left
165
+ exact dates 2217/2217 1m 46s elapsed
166
+ done in 1m 46s
132
167
  → https://www.youtube.com/@MonsieurPhi
168
+ done in 12.3s
169
+ summary: 2 sources, 2678 videos, 2m 05s elapsed
133
170
  ```
134
171
 
135
172
  The command is idempotent: a new run only adds the missing videos.
@@ -147,8 +184,9 @@ variables, then the settings written in the YAML file, then the built-in
147
184
  defaults. The API key stays a secret: `--api-key` or `OPENAI_API_KEY`. The
148
185
  settings are `x_opencode_session`, `model_id`, `openaiapi_endpoint`,
149
186
  `concurrency` and `ytdlp_concurrency` (plus the optional `api_key`,
150
- `retry_delays`, `max_output_tokens` and `chunk_target_tokens`). I only set the
151
- 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:
152
190
 
153
191
  `x_opencode_session` is the base of the `X-OpenCode-Session` header; it is
154
192
  optional and specific to OpenCode. A stable per-video suffix is appended to it,
@@ -298,9 +336,49 @@ only the last 20 events on screen, so it stays bounded even with a large feed.
298
336
  Retryable API errors (HTTP 408, 409, 429 or >= 500) are retried with growing
299
337
  delays — 10 s, 30 s, 60 s by default, configurable with `--retry-delays`
300
338
  (comma-separated seconds, or `YT_TO_MD_RETRY_DELAYS`). A `Retry-After` header
301
- from the server is honored when longer than the schedule. Each retry is shown in
302
- the phase output; after the last attempt the file is reported as an error and is
303
- 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.
304
382
 
305
383
  ### Output budget and long transcripts
306
384
 
@@ -319,7 +397,8 @@ LLM call that rewrites the beginning of the next section so it flows from the
319
397
  previous one. `--chunk-target-tokens` (or `YT_TO_MD_CHUNK_TARGET_TOKENS`,
320
398
  default 8000) sets the target input size of each chunk. The generation of a
321
399
  chunk is guaranteed to stop with `stop`; otherwise the file is reported as an
322
- 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.
323
402
 
324
403
  ### Generated frontmatter
325
404
 
@@ -337,8 +416,11 @@ llm:
337
416
  input_tokens: 4428
338
417
  cached_input_tokens: 0
339
418
  output_tokens: 3262
419
+ reasoning_tokens: 0
420
+ text_tokens: 3262
340
421
  estimated_cost_usd: 0.000927
341
422
  finish_reason: stop
423
+ raw_finish_reason: stop
342
424
  pricing:
343
425
  source: models.dev
344
426
  peak: null
@@ -348,10 +430,10 @@ llm:
348
430
  `video_title` is the original video title in the file's language and
349
431
  `generated_at` is the UTC time of the generation. `pricing.source` names the
350
432
  catalog the rates came from and `pricing.peak` tells whether the peak rate
351
- applied (`null` when the source has no peak/off-peak pricing). Existing files
352
- are left untouched: run `youtube-to-markdown generate-markdown --force` to
353
- regenerate every file, which is also the way to add the frontmatter to files
354
- generated before it existed.
433
+ applied to the first call (`null` when the source has no peak/off-peak pricing).
434
+ Existing files are left untouched: run `youtube-to-markdown generate-markdown
435
+ --force` to regenerate every file, which is also the way to add the frontmatter
436
+ to files generated before it existed.
355
437
 
356
438
  ### Pricing
357
439
 
@@ -364,6 +446,10 @@ catalogs are cached for one day under `$XDG_CACHE_HOME/youtube-to-markdown`
364
446
  (`~/.cache/youtube-to-markdown` by default). When a catalog or the model is
365
447
  unavailable, `estimated_cost_usd` is `null` and the run continues.
366
448
 
449
+ The rate is sampled at the start of every LLM call, so a run that straddles a
450
+ peak/off-peak boundary is priced with the right rate on each side. The
451
+ `pricing` block in the frontmatter describes the first call of the file.
452
+
367
453
  ## Organize the files into folders
368
454
 
369
455
  A channel may set `folder_slug` to keep its files in their own subdirectory of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stephane-klein/youtube-to-markdown",
3
- "version": "0.4.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 });
@@ -1,9 +1,12 @@
1
1
  import { writeFile } from "node:fs/promises";
2
2
  import { stringify } from "yaml";
3
+ import { formatDuration, progressLine } from "./progress.js";
3
4
  import { runYtDlp } from "./ytdlp.js";
4
5
 
5
6
  const DATE_CHUNK = 50;
6
7
 
8
+ const count = (value, word) => `${value} ${word}${value === 1 ? "" : "s"}`;
9
+
7
10
  const toIso = (raw) =>
8
11
  /^\d{8}$/.test(raw)
9
12
  ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
@@ -61,12 +64,10 @@ const videoId = (url) => /[?&]v=([\w-]+)/.exec(url)?.[1] ?? url;
61
64
 
62
65
  async function fetchExactDates(urls) {
63
66
  const dates = new Map();
67
+ const startedAt = performance.now();
64
68
 
65
69
  for (let i = 0; i < urls.length; i += DATE_CHUNK) {
66
70
  const chunk = urls.slice(i, i + DATE_CHUNK);
67
- console.error(
68
- ` exact dates ${Math.min(i + DATE_CHUNK, urls.length)}/${urls.length}`,
69
- );
70
71
  const out = await ytDlp(
71
72
  [
72
73
  "--skip-download",
@@ -84,6 +85,15 @@ async function fetchExactDates(urls) {
84
85
  const iso = toIso(raw ?? "");
85
86
  if (id && iso) dates.set(id, iso);
86
87
  }
88
+
89
+ console.error(
90
+ progressLine({
91
+ label: "exact dates",
92
+ done: Math.min(i + DATE_CHUNK, urls.length),
93
+ total: urls.length,
94
+ startedAt,
95
+ }),
96
+ );
87
97
  }
88
98
 
89
99
  return dates;
@@ -180,9 +190,14 @@ export async function runExtract({
180
190
  config = {},
181
191
  forceDates = false,
182
192
  } = {}) {
193
+ const startedAt = performance.now();
194
+ let sourceCount = 0;
195
+ let videoCount = 0;
196
+
183
197
  for (const channel of config.feed ?? []) {
184
198
  if (typeof channel.url !== "string" || !channel.url) continue;
185
199
 
200
+ const channelStartedAt = performance.now();
186
201
  const videos = channel.videos ?? [];
187
202
  const knownDates = forceDates
188
203
  ? new Map()
@@ -197,7 +212,17 @@ export async function runExtract({
197
212
  channel.videos = mergeVideos(videos, fetched.videos, {
198
213
  sort: !fetched.playlist,
199
214
  });
215
+
216
+ sourceCount += 1;
217
+ videoCount += channel.videos.length;
218
+ console.error(
219
+ ` done in ${formatDuration(performance.now() - channelStartedAt)}`,
220
+ );
200
221
  }
201
222
 
202
223
  await writeFile(path, stringify(config, { lineWidth: 0 }));
224
+
225
+ console.error(
226
+ `summary: ${count(sourceCount, "source")}, ${count(videoCount, "video")}, ${formatDuration(performance.now() - startedAt)} elapsed`,
227
+ );
203
228
  }
@@ -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 },
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;
@@ -283,29 +371,60 @@ async function generateOnce(
283
371
  for (let attempt = 1;; attempt++) {
284
372
  const started = performance.now();
285
373
  try {
286
- const call = () =>
287
- generateText({
374
+ let at;
375
+ const call = () => {
376
+ at = new Date();
377
+ return generateText({
288
378
  model,
289
379
  system,
290
380
  prompt,
291
381
  temperature: 0.2,
292
- reasoning: "low",
382
+ ...reasoningOptions(reasoning, endpoint),
293
383
  maxOutputTokens,
294
384
  maxRetries: 0,
295
385
  headers: { "X-OpenCode-Session": session },
296
386
  });
297
- const { text, usage, finishReason } = await (
298
- limiter ? limiter(call) : call()
299
- );
387
+ };
388
+ const result = await (limiter ? limiter(call) : call());
389
+ const {
390
+ text,
391
+ usage,
392
+ finishReason,
393
+ rawFinishReason,
394
+ reasoningText,
395
+ warnings,
396
+ } = result;
300
397
  const elapsed = (performance.now() - started) / 1000;
398
+ const price = priceAt ? priceAt(at) : null;
399
+ const cost = estimateCost(price, usage);
400
+
401
+ if (debug) {
402
+ logLlmDebug({
403
+ maxOutputTokens,
404
+ reasoning,
405
+ finishReason,
406
+ rawFinishReason,
407
+ text,
408
+ reasoningText,
409
+ warnings,
410
+ });
411
+ }
301
412
 
302
- return { text, usage, finishReason, elapsed };
413
+ return {
414
+ text,
415
+ usage,
416
+ finishReason,
417
+ rawFinishReason,
418
+ elapsed,
419
+ price,
420
+ cost,
421
+ };
303
422
  } catch (error) {
304
423
  if (!isRetryable(error) || attempt > retryDelays.length) {
305
424
  throw new Error(describeError(error, attempt), { cause: error });
306
425
  }
307
426
  const waitMs = retryDelayMs(error, retryDelays[attempt - 1]);
308
- onRetry?.(attempt + 1, totalAttempts, waitMs);
427
+ onRetry?.(attempt + 1, totalAttempts, waitMs, error);
309
428
  await delay(waitMs);
310
429
  }
311
430
  }
@@ -400,6 +519,10 @@ async function toMarkdown(
400
519
  chunkTargetTokens,
401
520
  session,
402
521
  limiter,
522
+ priceAt,
523
+ reasoning,
524
+ endpoint,
525
+ debug,
403
526
  },
404
527
  title,
405
528
  transcript,
@@ -423,12 +546,25 @@ async function toMarkdown(
423
546
  const usages = [];
424
547
  let elapsed = 0;
425
548
  let finishReason = "stop";
549
+ let rawFinishReason;
550
+ let cost = 0;
551
+ let costKnown = true;
552
+ let price = null;
426
553
 
427
554
  await Promise.all(
428
555
  parts.map(async (part, index) => {
429
556
  onProgress?.(index + 1, total);
430
557
  const result = await generateOnce(
431
- { model, retryDelays, session, limiter },
558
+ {
559
+ model,
560
+ retryDelays,
561
+ session,
562
+ limiter,
563
+ priceAt,
564
+ reasoning,
565
+ endpoint,
566
+ debug,
567
+ },
432
568
  {
433
569
  system: SYSTEM,
434
570
  prompt: chunkPrompt(title, part, index, total),
@@ -439,8 +575,15 @@ async function toMarkdown(
439
575
  bodies[index] = cleanBody(result.text);
440
576
  usages.push(result.usage);
441
577
  elapsed += result.elapsed;
578
+ if (result.cost === null) costKnown = false;
579
+ else cost += result.cost;
580
+ if (price === null && result.price) price = result.price;
581
+ if (rawFinishReason === undefined) {
582
+ rawFinishReason = result.rawFinishReason;
583
+ }
442
584
  if (result.finishReason !== "stop" && finishReason === "stop") {
443
585
  finishReason = result.finishReason;
586
+ rawFinishReason = result.rawFinishReason;
444
587
  }
445
588
  }),
446
589
  );
@@ -452,7 +595,16 @@ async function toMarkdown(
452
595
  if (!previousTail || !nextHead) continue;
453
596
 
454
597
  const smoothing = await generateOnce(
455
- { model, retryDelays, session, limiter },
598
+ {
599
+ model,
600
+ retryDelays,
601
+ session,
602
+ limiter,
603
+ priceAt,
604
+ reasoning,
605
+ endpoint,
606
+ debug,
607
+ },
456
608
  {
457
609
  system: SMOOTH_SYSTEM,
458
610
  prompt: smoothPrompt(previousTail, nextHead),
@@ -462,6 +614,9 @@ async function toMarkdown(
462
614
  );
463
615
  usages.push(smoothing.usage);
464
616
  elapsed += smoothing.elapsed;
617
+ if (smoothing.cost === null) costKnown = false;
618
+ else cost += smoothing.cost;
619
+ if (price === null && smoothing.price) price = smoothing.price;
465
620
  if (smoothing.finishReason === "stop") {
466
621
  const rewritten = cleanBody(smoothing.text);
467
622
  if (rewritten) {
@@ -477,13 +632,20 @@ async function toMarkdown(
477
632
  .replace(/\n{3,}/g, "\n\n")
478
633
  .trim(),
479
634
  );
635
+ const emptyParts = bodies.flatMap((part, index) =>
636
+ part ? [] : [index + 1],
637
+ );
480
638
 
481
639
  return {
482
640
  body,
483
641
  usage: sumUsage(usages),
484
642
  finishReason,
643
+ rawFinishReason,
644
+ emptyParts,
485
645
  elapsed,
486
646
  chunks: total,
647
+ cost: costKnown ? cost : null,
648
+ price,
487
649
  };
488
650
  }
489
651
 
@@ -510,7 +672,9 @@ export async function runGenerateMarkdown({
510
672
  retryDelays = "10,30,60",
511
673
  maxOutputTokens,
512
674
  chunkTargetTokens = DEFAULT_CHUNK_TARGET_TOKENS,
675
+ reasoning = DEFAULT_REASONING,
513
676
  force = false,
677
+ debugLlm = false,
514
678
  dir = DEFAULT_DIR,
515
679
  } = {}) {
516
680
  const key = apiKey ?? process.env.OPENAI_API_KEY;
@@ -518,6 +682,7 @@ export async function runGenerateMarkdown({
518
682
  if (!modelId) throw new Error("model is not set (--model)");
519
683
  if (!endpoint) throw new Error("endpoint is not set (--endpoint)");
520
684
 
685
+ const debug = debugLlm === true || /^(1|true|yes|on)$/i.test(`${debugLlm}`);
521
686
  const RETRY_DELAYS = parseRetryDelays(retryDelays);
522
687
  const llmConcurrency = Number(concurrency);
523
688
  const vttConcurrency = Number(ytdlpConcurrency);
@@ -540,6 +705,7 @@ export async function runGenerateMarkdown({
540
705
  already: 0,
541
706
  skipped: 0,
542
707
  errors: 0,
708
+ failures: [],
543
709
  totalSeconds: 0,
544
710
  totalInput: 0,
545
711
  totalOutput: 0,
@@ -581,6 +747,7 @@ export async function runGenerateMarkdown({
581
747
  : isSet(modelOutputLimit)
582
748
  ? Number(modelOutputLimit)
583
749
  : DEFAULT_CEILING;
750
+ const priceAt = prices ? (at) => prices.priceAt(modelId, at) : null;
584
751
  console.error(
585
752
  `output budget: up to ${ceiling} tokens per call, chunk target ${chunkTargetTokens} tokens`,
586
753
  );
@@ -660,8 +827,17 @@ export async function runGenerateMarkdown({
660
827
  const name = relative(dir, md);
661
828
  try {
662
829
  const transcript = vttToText(await readFile(job.vtt, "utf8"));
663
- const price = prices ? prices.priceAt(modelId) : null;
664
- const { body, usage, finishReason, elapsed, chunks } = await toMarkdown(
830
+ const {
831
+ body,
832
+ usage,
833
+ finishReason,
834
+ rawFinishReason,
835
+ emptyParts,
836
+ elapsed,
837
+ chunks,
838
+ cost,
839
+ price,
840
+ } = await toMarkdown(
665
841
  {
666
842
  model,
667
843
  retryDelays: RETRY_DELAYS,
@@ -670,40 +846,63 @@ export async function runGenerateMarkdown({
670
846
  chunkTargetTokens,
671
847
  session: videoSession(sessionBase, job),
672
848
  limiter,
849
+ priceAt,
850
+ reasoning,
851
+ endpoint,
852
+ debug,
673
853
  },
674
854
  job.title,
675
855
  transcript,
676
- (nextAttempt, totalAttempts, waitMs) => {
856
+ (nextAttempt, totalAttempts, waitMs, error) => {
677
857
  task.output = `retrying in ${Math.round(waitMs / 1000)}s ` +
678
- `(attempt ${nextAttempt}/${totalAttempts}) ${name}`;
858
+ `(attempt ${nextAttempt}/${totalAttempts}) ` +
859
+ `${describeCause(error)} ${name}`;
679
860
  },
680
861
  (part, totalParts) => {
681
- if (totalParts > 1) {
682
- task.output = `generating part ${part}/${totalParts} ${name}`;
683
- }
862
+ task.output = totalParts > 1
863
+ ? `generating part ${part}/${totalParts} ${name}`
864
+ : `generating ${name}`;
684
865
  },
685
866
  );
686
867
 
687
868
  stats.totalSeconds += elapsed;
688
869
  stats.totalInput += usage?.inputTokens ?? 0;
689
870
  stats.totalOutput += usage?.outputTokens ?? 0;
690
- const cost = estimateCost(price, usage);
691
871
  if (cost === null) stats.costKnown = false;
692
872
  else stats.totalCost += cost;
693
873
  const metrics = metricsLine(modelId, usage, elapsed, cost);
694
874
 
695
875
  if (finishReason !== "stop") {
696
876
  stats.errors++;
877
+ stats.failures.push({
878
+ name,
879
+ reason: `finishReason=${finishReason}`,
880
+ });
697
881
  task.output = `error (finishReason=${finishReason}) ${name}`;
698
882
  return;
699
883
  }
700
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
+
701
899
  const front = frontmatter(job, {
702
900
  modelId,
703
901
  usage,
704
902
  elapsed,
705
903
  cost,
706
904
  finishReason,
905
+ rawFinishReason,
707
906
  price,
708
907
  chunks,
709
908
  });
@@ -716,6 +915,7 @@ export async function runGenerateMarkdown({
716
915
  task.output = `generated ${name} ${metrics}`;
717
916
  } catch (error) {
718
917
  stats.errors++;
918
+ stats.failures.push({ name, reason: error.message });
719
919
  task.output = `error (${error.message}) ${name}`;
720
920
  } finally {
721
921
  const upToDate = stats.already + stats.generated;
@@ -741,6 +941,15 @@ export async function runGenerateMarkdown({
741
941
  stats.costKnown ? `, ~$${stats.totalCost.toFixed(6)}` : ""
742
942
  }`,
743
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;
744
953
  }
745
954
 
746
955
  export {
@@ -748,6 +957,9 @@ export {
748
957
  chunkPrompt,
749
958
  cleanBody,
750
959
  createLimiter,
960
+ DEFAULT_REASONING,
961
+ estimateCost,
962
+ metricsLine,
751
963
  plannedBudget,
752
964
  smoothPrompt,
753
965
  sumUsage,
@@ -0,0 +1,32 @@
1
+ export function formatDuration(ms) {
2
+ const value = Number.isFinite(ms) && ms > 0 ? ms : 0;
3
+ if (value < 1000) return `${Math.round(value)}ms`;
4
+
5
+ const seconds = value / 1000;
6
+ if (seconds < 59.95) return `${seconds.toFixed(1)}s`;
7
+
8
+ const rounded = Math.round(seconds);
9
+ const minutes = Math.floor(rounded / 60);
10
+ if (minutes < 60) return `${minutes}m ${String(rounded % 60).padStart(2, "0")}s`;
11
+
12
+ const hours = Math.floor(minutes / 60);
13
+ return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
14
+ }
15
+
16
+ export function progressStats({ done, total, startedAt, now = performance.now() }) {
17
+ const elapsed = Math.max(0, now - startedAt);
18
+ const completed = Math.min(Math.max(done, 0), Math.max(total, 0));
19
+
20
+ if (completed === 0 || total <= 0) return { elapsed, eta: null };
21
+
22
+ return { elapsed, eta: (elapsed * (total - completed)) / completed };
23
+ }
24
+
25
+ export function progressLine({ label, done, total, startedAt, now }) {
26
+ const { elapsed, eta } = progressStats({ done, total, startedAt, now });
27
+ const parts = [` ${label} ${done}/${total}`, `${formatDuration(elapsed)} elapsed`];
28
+
29
+ if (eta !== null && eta > 0) parts.push(`~${formatDuration(eta)} left`);
30
+
31
+ return parts.join(" ");
32
+ }