@stephane-klein/youtube-to-markdown 0.1.0 → 0.2.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
@@ -1,8 +1,17 @@
1
1
  # youtube-feed-to-markdown
2
2
 
3
- I collect the videos of a few YouTube channels into `youtube_to_markdown.yaml`,
4
- using [yt-dlp](https://github.com/yt-dlp/yt-dlp) to fetch the metadata. It is
5
- published on npmjs as `@stephane-klein/youtube-to-markdown`.
3
+ The `youtube-to-markdown` CLI generates Markdown files from YouTube video
4
+ transcripts.
5
+
6
+ I use it to search through the content of popular-science videos and shows of
7
+ all kinds. I also use it to feed that corpus into a RAG.
8
+
9
+ Technically, it is fairly simple: the project relies on
10
+ [yt-dlp](https://github.com/yt-dlp/yt-dlp) to fetch a YouTube channel's metadata
11
+ and then to download its transcripts.
12
+
13
+ In the final step, I use an LLM through a standard OpenAI-compatible API to
14
+ reconstruct a complete text from the video transcripts.
6
15
 
7
16
  ## Requirements
8
17
 
@@ -28,11 +37,93 @@ Or run it without installing it, with `npx`:
28
37
  $ npx @stephane-klein/youtube-to-markdown extract-video-metadata
29
38
  ```
30
39
 
31
- Either way, `yt-dlp` must be on your `PATH` (see Requirements). The commands
32
- read `youtube_to_markdown.yaml` from the current directory unless you pass
33
- `--config`.
40
+ Either way, `yt-dlp` must be on your `PATH` (see Requirements).
41
+
42
+ Run `youtube-to-markdown --help` to list the available commands:
43
+
44
+ ```sh
45
+ $ youtube-to-markdown --help
46
+ youtube-to-markdown <command>
47
+
48
+ Commands:
49
+ extract-video-metadata Fetch the channel videos from YouTube into the
50
+ configuration file
51
+ download-vtt Download the VTT transcripts of the marked videos
52
+ generate-markdown Turn the VTT transcripts into Markdown prose with an
53
+ LLM
54
+ markdown-stats Report global tokens, cost and time from the Markdown
55
+ frontmatter
56
+ reorganize Move the transcripts and Markdown into their
57
+ folder_slug folders
58
+ doctor Check the runtime dependencies and the LLM access
59
+ completion Generate a shell completion script for bash or zsh
60
+
61
+ Options:
62
+ --config Path to the YAML configuration file
63
+ [string] [default: "youtube_to_markdown.yaml"]
64
+ --version Show version number [boolean]
65
+ --help Show help [boolean]
66
+ ```
34
67
 
35
- ## Run
68
+ Install the shell completion for bash or zsh (`$SHELL` selects the script):
69
+
70
+ ```sh
71
+ $ youtube-to-markdown completion >> ~/.zshrc
72
+ ```
73
+
74
+ Use `~/.bashrc` on bash.
75
+
76
+ ## Getting started
77
+
78
+ **1. Create `youtube_to_markdown.yaml`** with the LLM settings and your channels.
79
+ The example below uses [OpenCode Go](https://opencode.ai/en/go), an OpenAI-compatible API:
80
+
81
+ ```yaml
82
+ x_opencode_session: "youtube-to-markdown/0.1"
83
+ model_id: "mimo-v2.5"
84
+ openaiapi_endpoint: "https://opencode.ai/zen/go/v1/chat/completions"
85
+ feed:
86
+ - title: Science4All
87
+ url: https://www.youtube.com/@le_science4all
88
+ videos: []
89
+ ```
90
+
91
+ Only the `url` is required: `extract-video-metadata` fills the titles, the dates
92
+ and the video lists. `model_id` and `openaiapi_endpoint` are not specific to
93
+ OpenCode Go — point them at any OpenAI-compatible provider. The other settings
94
+ are documented below.
95
+
96
+ **2. Store your API key** in `.secret.sh` and source it. Write it with your editor
97
+ so the key never lands in your shell history:
98
+
99
+ ```sh
100
+ $ $EDITOR .secret.sh
101
+ $ cat .secret.sh
102
+ export OPENAI_API_KEY="sk-…"
103
+ $ source .secret.sh
104
+ ```
105
+
106
+ **3. Check the setup** with `doctor` and fix every `error` (a `warn` is fine):
107
+
108
+ ```sh
109
+ $ youtube-to-markdown doctor
110
+ node v24.21.0 ok
111
+ yt-dlp 2026.08.19 ok
112
+ config youtube_to_markdown.yaml ok (2 channel(s), 636 video(s))
113
+ contents contents ok
114
+ model mimo-v2.5 ok
115
+ endpoint https://opencode.ai/zen/go/v1/chat/completions ok
116
+ api key OPENAI_API_KEY ok
117
+ session set info
118
+ llm 1.0s ok (248 in / 8 out)
119
+
120
+ summary: 8 ok, 0 warning, 0 error
121
+ ```
122
+
123
+ `doctor` exits with a non-zero status as soon as one line is an `error`, so it
124
+ can gate a CI job.
125
+
126
+ **4. Fetch the channel videos** into the file:
36
127
 
37
128
  ```sh
38
129
  $ youtube-to-markdown extract-video-metadata
@@ -40,29 +131,28 @@ $ youtube-to-markdown extract-video-metadata
40
131
  → https://www.youtube.com/@MonsieurPhi
41
132
  ```
42
133
 
43
- `youtube-to-markdown` exposes five subcommands: `extract-video-metadata`,
44
- `download-vtt`, `generate-markdown`, `markdown-stats` and `reorganize`. Run
45
- `youtube-to-markdown <command> --help` for the available options.
134
+ The command is idempotent: a new run only adds the missing videos.
46
135
 
47
- The configuration file is `youtube_to_markdown.yaml` (override it with
48
- `--config` or `YT_TO_MD_CONFIG`). Its settings resolve in this order: command-line
49
- flags, then `YT_TO_MD_*` environment variables, then the settings written in the
50
- YAML file, then the built-in defaults. The API key stays a secret: `--api-key` or
51
- `OPENAI_API_KEY`.
136
+ **5. Generate the Markdown** with `download-vtt`, then `generate-markdown`. The
137
+ other commands are `markdown-stats`, `reorganize` and `doctor`; run
138
+ `youtube-to-markdown <command> --help` for their options.
52
139
 
53
- The `extract-video-metadata` command reads the channel URLs from the
54
- configuration file, asks yt-dlp for each channel's videos, and writes the result
55
- back into the file. I run it from time to time: it is idempotent, so a new run
56
- only adds the videos that are missing.
140
+ ## Configure the settings and the channels
57
141
 
58
- ## Configuration
59
-
60
- `youtube_to_markdown.yaml` holds the settings and the channels. Settings are
61
- `x_opencode_session`, `model_id`, `openaiapi_endpoint`, `concurrency` and
62
- `ytdlp_concurrency` (plus the optional `api_key`, `retry_delays` and
63
- `max_output_tokens`). I only set the channel URLs, and the
142
+ The commands read `youtube_to_markdown.yaml` from the current directory; override
143
+ it with `--config` or `YT_TO_MD_CONFIG`. It holds the settings and the channels.
144
+ Settings resolve in this order: command-line flags, then `YT_TO_MD_*` environment
145
+ variables, then the settings written in the YAML file, then the built-in
146
+ defaults. The API key stays a secret: `--api-key` or `OPENAI_API_KEY`. The
147
+ settings are `x_opencode_session`, `model_id`, `openaiapi_endpoint`,
148
+ `concurrency` and `ytdlp_concurrency` (plus the optional `api_key`,
149
+ `retry_delays` and `max_output_tokens`). I only set the channel URLs, and the
64
150
  `extract-video-metadata` command fills the rest:
65
151
 
152
+ `x_opencode_session` is sent as the `X-OpenCode-Session` header; it is optional
153
+ and specific to OpenCode. `model_id` and `openaiapi_endpoint` are not: point them
154
+ at any OpenAI-compatible provider.
155
+
66
156
  ```yaml
67
157
  x_opencode_session: "youtube-to-markdown/0.1"
68
158
  model_id: "mimo-v2.5"
@@ -98,7 +188,7 @@ It is a relative path, so it stays inside `contents_path`; a channel without
98
188
  `_orphans` is rejected. Change it whenever you like, then run
99
189
  `youtube-to-markdown reorganize` to move the existing files.
100
190
 
101
- ## Transcripts
191
+ ## Download the transcripts of the videos
102
192
 
103
193
  I mark the videos I want as transcripts with a `download_vtt` field:
104
194
 
@@ -135,7 +225,7 @@ field.
135
225
  When a video has no subtitle at all, the command writes a `<…>.vtt.missing`
136
226
  marker and stops trying. I delete that marker to force a new attempt.
137
227
 
138
- ## Markdown
228
+ ## Generate Markdown text from the transcripts
139
229
 
140
230
  I mark the videos I want as Markdown with `generate_markdown`. The transcript is
141
231
  downloaded first if it is not there yet:
@@ -188,7 +278,7 @@ from the server is honored when longer than the schedule. Each retry is shown in
188
278
  the phase output; after the last attempt the file is reported as an error and is
189
279
  retried on the next run.
190
280
 
191
- ### Frontmatter
281
+ ### Generated frontmatter
192
282
 
193
283
  Each generated file starts with a YAML frontmatter describing the run:
194
284
 
@@ -214,7 +304,7 @@ when the model is not in the price table. Existing files are left untouched: run
214
304
  `youtube-to-markdown generate-markdown --force` to regenerate every file, which
215
305
  is also the way to add the frontmatter to files generated before it existed.
216
306
 
217
- ## Folders
307
+ ## Organize the files into folders
218
308
 
219
309
  A channel may set `folder_slug` to keep its files in their own subdirectory of
220
310
  `contents_path`. When you add, change or remove that setting, `reorganize` moves
@@ -234,7 +324,7 @@ moves it under `contents_path/_orphans/`, keeping its relative path. A file whos
234
324
  target already exists, or a filename shared by two channels with different
235
325
  `folder_slug`, is reported as a conflict and left untouched.
236
326
 
237
- ## Stats
327
+ ## Report the tokens, cost and time
238
328
 
239
329
  `markdown-stats` reads the frontmatter of every `*.md` under `contents_path`
240
330
  (recursively) and prints global totals — tokens, estimated cost, processing
@@ -280,21 +370,23 @@ folders
280
370
  Files without frontmatter (generated before the frontmatter existed) are counted
281
371
  but left out of the totals.
282
372
 
283
- ## Development
373
+ ## Set up the development environment
284
374
 
285
375
  To work on this project, [mise](https://mise.jdx.dev/) installs both yt-dlp and
286
- Node.js and defines the `youtube-to-markdown` shell alias; `npm install` fetches
287
- the JavaScript dependencies:
376
+ Node.js; `npm install` fetches the JavaScript dependencies:
288
377
 
289
378
  ```sh
290
379
  $ mise install
291
380
  $ npm install
292
381
  ```
293
382
 
294
- The shell alias maps `youtube-to-markdown` to `node src/cli.js`. It is set when
295
- you enter the project from an interactive bash, zsh or fish shell, so it is only
296
- available in interactive shells; in scripts and CI, call
297
- `node src/cli.js <command>` instead.
383
+ The `enter` hook defines a `youtube-to-markdown` shell function that calls
384
+ `node src/cli.js`, and loads the shell completion for bash and zsh; the `leave`
385
+ hook removes both. Everything is set when you enter the project from an
386
+ interactive bash or zsh shell, so it is only available in interactive shells; in
387
+ scripts and CI, call `node src/cli.js <command>` instead. A function, rather than
388
+ a shell alias, is what lets `youtube-to-markdown <TAB>` complete the subcommands:
389
+ zsh expands aliases before dispatching completion, which would bypass `compdef`.
298
390
 
299
391
  To generate Markdown without exporting `OPENAI_API_KEY` in your shell, copy
300
392
  `.secret.sh.example` to `.secret.sh` and put your API key in it; mise sources
@@ -305,7 +397,7 @@ $ cp .secret.sh.example .secret.sh
305
397
  $ $EDITOR .secret.sh
306
398
  ```
307
399
 
308
- ## Publish
400
+ ## Publish the package to npmjs
309
401
 
310
402
  I publish the package to npmjs from this repository with mise:
311
403
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stephane-klein/youtube-to-markdown",
3
- "version": "0.1.0",
3
+ "version": "0.2.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
@@ -10,6 +10,7 @@ import { runDownloadVtt } from "./download-vtt.js";
10
10
  import { runGenerateMarkdown } from "./generate-markdown.js";
11
11
  import { runMarkdownStats } from "./markdown-stats.js";
12
12
  import { runReorganize } from "./organize-files.js";
13
+ import { runDoctor } from "./doctor.js";
13
14
 
14
15
  const { version } = createRequire(import.meta.url)("../package.json");
15
16
 
@@ -49,8 +50,9 @@ const project = (argv) => {
49
50
  return { path: argv.config, config: yaml, feed: yaml.feed ?? [], settings, dir };
50
51
  };
51
52
 
52
- yargs(hideBin(process.argv))
53
- .scriptName("youtube-to-markdown")
53
+ const cli = yargs(hideBin(process.argv))
54
+ .scriptName("")
55
+ .usage("youtube-to-markdown <command>")
54
56
  .config(loadEnv())
55
57
  .option("config", config)
56
58
  .command(
@@ -147,9 +149,65 @@ yargs(hideBin(process.argv))
147
149
  await runReorganize({ feed, dir, dryRun: argv.dryRun });
148
150
  }),
149
151
  )
150
- .demandCommand(1, "Use one of the available commands")
152
+ .command(
153
+ "doctor",
154
+ "Check the runtime dependencies and the LLM access",
155
+ (yargs) =>
156
+ yargs
157
+ .option("model", {
158
+ describe: "LLM model id to test (default: model_id in the configuration)",
159
+ type: "string",
160
+ })
161
+ .option("endpoint", {
162
+ describe: "OpenAI-compatible chat completions endpoint to test",
163
+ type: "string",
164
+ })
165
+ .option("api-key", {
166
+ describe: "API key to test (defaults to OPENAI_API_KEY)",
167
+ type: "string",
168
+ })
169
+ .option("session", {
170
+ describe: "Value of the X-OpenCode-Session header",
171
+ type: "string",
172
+ })
173
+ .option("llm", {
174
+ describe: "Run a minimal LLM request to test the API access",
175
+ type: "boolean",
176
+ default: true,
177
+ })
178
+ .option("timeout", {
179
+ describe: "Timeout in seconds for the LLM request (default: 30)",
180
+ type: "number",
181
+ default: 30,
182
+ }),
183
+ handle(async (argv) => {
184
+ await runDoctor({
185
+ configPath: argv.config,
186
+ overrides: {
187
+ model: argv.model,
188
+ endpoint: argv.endpoint,
189
+ apiKey: argv.apiKey,
190
+ session: argv.session,
191
+ },
192
+ llm: argv.llm,
193
+ timeoutMs: argv.timeout * 1000,
194
+ });
195
+ }),
196
+ )
197
+ .command(
198
+ "completion",
199
+ "Generate a shell completion script for bash or zsh",
200
+ () => {},
201
+ () => cli.showCompletionScript("youtube-to-markdown", "completion"),
202
+ )
203
+ .demandCommand(
204
+ 1,
205
+ "Use one of the available commands, or run `youtube-to-markdown doctor` " +
206
+ "first.",
207
+ )
151
208
  .strictCommands()
152
209
  .recommendCommands()
153
210
  .version(version)
154
- .help()
155
- .parse();
211
+ .help();
212
+
213
+ cli.parse();
package/src/doctor.js ADDED
@@ -0,0 +1,211 @@
1
+ import { access, constants } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { generateText } from "ai";
4
+ import { loadConfig, settingsFromConfig } from "./config.js";
5
+ import { createModel, describeError } from "./generate-markdown.js";
6
+ import { exists } from "./vtt.js";
7
+ import { runYtDlp } from "./ytdlp.js";
8
+
9
+ const MIN_NODE_MAJOR = 22;
10
+
11
+ function resolveApiKey(overrides, config, env = process.env) {
12
+ if (overrides.apiKey !== undefined) {
13
+ const envKey = env.YT_TO_MD_API_KEY;
14
+ const source = envKey !== undefined && envKey === overrides.apiKey
15
+ ? "YT_TO_MD_API_KEY"
16
+ : "--api-key";
17
+ return { key: overrides.apiKey, source };
18
+ }
19
+ if (config?.api_key !== undefined) {
20
+ return { key: config.api_key, source: "api_key (config)" };
21
+ }
22
+ if (env.OPENAI_API_KEY) {
23
+ return { key: env.OPENAI_API_KEY, source: "OPENAI_API_KEY" };
24
+ }
25
+ return { key: undefined, source: null };
26
+ }
27
+
28
+ async function checkContents(dir) {
29
+ try {
30
+ await access(dir, constants.W_OK);
31
+ return { status: "ok", note: null };
32
+ } catch (error) {
33
+ if (error.code !== "ENOENT") {
34
+ return { status: "error", note: "not writable" };
35
+ }
36
+ }
37
+ let parent = dirname(dir);
38
+ while (true) {
39
+ try {
40
+ await access(parent, constants.W_OK);
41
+ return { status: "warn", note: "not created yet" };
42
+ } catch (error) {
43
+ if (error.code !== "ENOENT") {
44
+ return { status: "error", note: "not writable" };
45
+ }
46
+ const next = dirname(parent);
47
+ if (next === parent) return { status: "error", note: "not writable" };
48
+ parent = next;
49
+ }
50
+ }
51
+ }
52
+
53
+ function checkNode() {
54
+ const version = process.versions.node;
55
+ const major = Number(version.split(".")[0]);
56
+ return major >= MIN_NODE_MAJOR
57
+ ? { status: "ok", value: `v${version}`, note: null }
58
+ : { status: "error", value: `v${version}`, note: `requires >= ${MIN_NODE_MAJOR}` };
59
+ }
60
+
61
+ async function checkYtDlp() {
62
+ try {
63
+ const { stdout } = await runYtDlp(["--version"]);
64
+ return { status: "ok", value: stdout.trim() || "found", note: null };
65
+ } catch (error) {
66
+ return { status: "error", value: "-", note: error.message };
67
+ }
68
+ }
69
+
70
+ async function loadConfigSafe(configPath) {
71
+ if (!(await exists(configPath))) {
72
+ return {
73
+ config: null,
74
+ row: { status: "warn", value: configPath, note: "not found" },
75
+ };
76
+ }
77
+ try {
78
+ const config = loadConfig(configPath);
79
+ const channels = config.feed?.length ?? 0;
80
+ const videos = (config.feed ?? []).reduce(
81
+ (total, channel) => total + (channel.videos?.length ?? 0),
82
+ 0,
83
+ );
84
+ return {
85
+ config,
86
+ row: {
87
+ status: "ok",
88
+ value: configPath,
89
+ note: `${channels} channel(s), ${videos} video(s)`,
90
+ },
91
+ };
92
+ } catch (error) {
93
+ return {
94
+ config: null,
95
+ row: { status: "error", value: configPath, note: error.message },
96
+ };
97
+ }
98
+ }
99
+
100
+ function checkEndpoint(endpoint) {
101
+ if (!endpoint) return { status: "error", value: "-", note: "not set" };
102
+ try {
103
+ new URL(endpoint);
104
+ return { status: "ok", value: endpoint, note: null };
105
+ } catch {
106
+ return { status: "error", value: endpoint, note: "not a valid URL" };
107
+ }
108
+ }
109
+
110
+ async function checkLlm({ llm, settings, key, timeoutMs }) {
111
+ if (!llm) {
112
+ return { status: "warn", value: "-", note: "skipped (--no-llm)" };
113
+ }
114
+ if (!settings.model) return { status: "error", value: "-", note: "model not set" };
115
+ if (!settings.endpoint) return { status: "error", value: "-", note: "endpoint not set" };
116
+ if (!key) return { status: "error", value: "-", note: "api key not set" };
117
+
118
+ try {
119
+ const model = createModel({
120
+ modelId: settings.model,
121
+ endpoint: settings.endpoint,
122
+ apiKey: key,
123
+ session: settings.session,
124
+ });
125
+ const started = performance.now();
126
+ const { usage } = await generateText({
127
+ model,
128
+ prompt: "ping",
129
+ maxOutputTokens: 8,
130
+ maxRetries: 0,
131
+ abortSignal: AbortSignal.timeout(timeoutMs),
132
+ });
133
+ const elapsed = (performance.now() - started) / 1000;
134
+ return {
135
+ status: "ok",
136
+ value: `${elapsed.toFixed(1)}s`,
137
+ note: `${usage?.inputTokens ?? 0} in / ${usage?.outputTokens ?? 0} out`,
138
+ };
139
+ } catch (error) {
140
+ return { status: "error", value: "-", note: describeError(error, 1) };
141
+ }
142
+ }
143
+
144
+ function render(rows) {
145
+ const labelWidth = Math.max(...rows.map((row) => row.label.length));
146
+ const valueWidth = Math.max(...rows.map((row) => row.value.length));
147
+ return rows.map((row) => {
148
+ const note = row.note ? ` (${row.note})` : "";
149
+ return `${row.label.padEnd(labelWidth)} ${row.value.padEnd(valueWidth)} ${row.status}${note}`;
150
+ });
151
+ }
152
+
153
+ export async function runDoctor({
154
+ configPath = "youtube_to_markdown.yaml",
155
+ overrides = {},
156
+ llm = true,
157
+ timeoutMs = 30000,
158
+ } = {}) {
159
+ const { config, row: configRow } = await loadConfigSafe(configPath);
160
+
161
+ const settings = { ...settingsFromConfig(config ?? {}) };
162
+ for (const [key, value] of Object.entries(overrides)) {
163
+ if (value !== undefined) settings[key] = value;
164
+ }
165
+
166
+ const { key, source } = resolveApiKey(overrides, config);
167
+ const dir = resolve(dirname(configPath), config?.contents_path ?? "contents");
168
+
169
+ const rows = [
170
+ { label: "node", ...checkNode() },
171
+ { label: "yt-dlp", ...(await checkYtDlp()) },
172
+ ];
173
+ rows.push({ label: "config", ...configRow });
174
+ rows.push({
175
+ label: "contents",
176
+ value: config?.contents_path ?? "contents",
177
+ ...(await checkContents(dir)),
178
+ });
179
+ rows.push({
180
+ label: "model",
181
+ value: settings.model ?? "-",
182
+ status: settings.model ? "ok" : "error",
183
+ note: settings.model ? null : "not set",
184
+ });
185
+ rows.push({ label: "endpoint", ...checkEndpoint(settings.endpoint) });
186
+ rows.push({
187
+ label: "api key",
188
+ value: source ?? "-",
189
+ status: key ? "ok" : "error",
190
+ note: key ? null : "not set",
191
+ });
192
+ if (settings.session) {
193
+ rows.push({ label: "session", value: "set", status: "info", note: null });
194
+ }
195
+ const llmRow = await checkLlm({ llm, settings, key, timeoutMs });
196
+ rows.push({ label: "llm", ...llmRow });
197
+
198
+ const counts = { ok: 0, warn: 0, error: 0 };
199
+ for (const row of rows) {
200
+ if (row.status in counts) counts[row.status]++;
201
+ }
202
+
203
+ console.log(`youtube-to-markdown doctor`);
204
+ console.log(render(rows).join("\n"));
205
+ console.log();
206
+ console.log(
207
+ `summary: ${counts.ok} ok, ${counts.warn} warning, ${counts.error} error`,
208
+ );
209
+
210
+ if (counts.error > 0) process.exitCode = 1;
211
+ }
@@ -183,7 +183,7 @@ function retryDelayMs(error, fallbackMs) {
183
183
  return Math.max(headerMs, fallbackMs);
184
184
  }
185
185
 
186
- function describeError(error, attempts) {
186
+ export function describeError(error, attempts) {
187
187
  const suffix = ` after ${attempts} attempt(s)`;
188
188
  if (!APICallError.isInstance(error)) return `${error.message}${suffix}`;
189
189
 
@@ -201,6 +201,16 @@ function parseRetryDelays(value) {
201
201
  .filter((item) => Number.isFinite(item) && item >= 0);
202
202
  }
203
203
 
204
+ export function createModel({ modelId, endpoint, apiKey, session }) {
205
+ const provider = createOpenAICompatible({
206
+ name: "opencode",
207
+ apiKey,
208
+ baseURL: endpoint.replace(/\/chat\/completions\/?$/, ""),
209
+ headers: session ? { "X-OpenCode-Session": session } : {},
210
+ });
211
+ return provider(modelId);
212
+ }
213
+
204
214
  async function toMarkdown(
205
215
  { model, retryDelays, maxOutputTokens },
206
216
  title,
@@ -274,14 +284,7 @@ export async function runGenerateMarkdown({
274
284
  const llmConcurrency = Number(concurrency);
275
285
  const vttConcurrency = Number(ytdlpConcurrency);
276
286
 
277
- const provider = createOpenAICompatible({
278
- name: "opencode",
279
- apiKey: key,
280
- baseURL: endpoint.replace(/\/chat\/completions\/?$/, ""),
281
- headers: session ? { "X-OpenCode-Session": session } : {},
282
- });
283
-
284
- const model = provider(modelId);
287
+ const model = createModel({ modelId, endpoint, apiKey: key, session });
285
288
 
286
289
  await mkdir(dir, { recursive: true });
287
290