@mrgoonie/multix 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.15.2](https://github.com/mrgoonie/multix-cli/compare/v0.15.1...v0.15.2) (2026-10-11)
9
+
10
+
11
+ ### Fixed
12
+
13
+ * **doc:** do not retry configuration and non-transient errors ([#96](https://github.com/mrgoonie/multix-cli/issues/96)) ([6c505a6](https://github.com/mrgoonie/multix-cli/commit/6c505a64f88cbcc3ee992ca4112473400189305f))
14
+
15
+ ## [0.15.1](https://github.com/mrgoonie/multix-cli/compare/v0.15.0...v0.15.1) (2026-10-11)
16
+
17
+
18
+ ### Performance
19
+
20
+ * **gemini:** stream file uploads with resumable Files API ([#85](https://github.com/mrgoonie/multix-cli/issues/85)) ([b2cc886](https://github.com/mrgoonie/multix-cli/commit/b2cc886cf5bdf960e88aeea5155ae2623547d918))
21
+
8
22
  ## [0.15.0](https://github.com/mrgoonie/multix-cli/compare/v0.14.1...v0.15.0) (2026-10-11)
9
23
 
10
24
 
package/README.md CHANGED
@@ -99,6 +99,7 @@ Set at least one provider key. Add to `.env` in your project root or `~/.multix/
99
99
  | `CLOUDFLARE_AI_TTS_MODEL` | No | Speech model setting; only `@cf/myshell-ai/melotts` is accepted |
100
100
  | `MULTIX_OUTPUT_DIR` | No | Override default output dir (`./multix-output`) |
101
101
  | `MULTIX_MAX_DOWNLOAD_MB` | No | Size limit per downloaded Replicate output file (default `2048`) |
102
+ | `MULTIX_UPLOAD_MIN_KBPS` | No | Slowest Gemini Files API upload speed to allow for, in KB/s (default `1000`); lower it on slow links |
102
103
  | `MULTIX_IMAGE_FORMAT` | No | `webp` (default) or `original` to keep provider image format/quality |
103
104
  | `OPENROUTER_IMAGE_MODEL` | No | Default OpenRouter model |
104
105
  | `OPENROUTER_FALLBACK_MODELS` | No | Comma-separated fallback model ids |
package/dist/cli.js CHANGED
@@ -46,8 +46,8 @@ var init_errors = __esm({
46
46
  "use strict";
47
47
  init_redact();
48
48
  MultixError = class extends Error {
49
- constructor(message, code) {
50
- super(message);
49
+ constructor(message, code, options) {
50
+ super(message, options);
51
51
  this.code = code;
52
52
  this.name = "MultixError";
53
53
  Error.captureStackTrace?.(
@@ -64,8 +64,8 @@ var init_errors = __esm({
64
64
  }
65
65
  };
66
66
  ProviderError = class extends MultixError {
67
- constructor(message, provider) {
68
- super(message, "PROVIDER_ERROR");
67
+ constructor(message, provider, options) {
68
+ super(message, "PROVIDER_ERROR", options);
69
69
  this.provider = provider;
70
70
  this.name = "ProviderError";
71
71
  }
@@ -78,8 +78,8 @@ var init_errors = __esm({
78
78
  }
79
79
  };
80
80
  HttpError = class extends MultixError {
81
- constructor(status, snippet, url, retryAfterMs) {
82
- super(`HTTP ${status} from ${redactUrl(url)}: ${snippet}`, "HTTP_ERROR");
81
+ constructor(status, snippet, url, retryAfterMs, options) {
82
+ super(`HTTP ${status} from ${redactUrl(url)}: ${snippet}`, "HTTP_ERROR", options);
83
83
  this.status = status;
84
84
  this.snippet = snippet;
85
85
  this.retryAfterMs = retryAfterMs;
@@ -489,7 +489,8 @@ var PollTimeoutError = class extends MultixError {
489
489
  };
490
490
  function isTransientHttpError(error) {
491
491
  if (!(error instanceof HttpError)) return false;
492
- return error.status === 0 || error.status === 429 || error.status >= 500;
492
+ const { status } = error;
493
+ return status === 0 || status === 408 || status === 429 || status >= 500;
493
494
  }
494
495
  var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
495
496
  async function pollUntil(opts) {
@@ -902,7 +903,7 @@ var AtlasCloudClient = class {
902
903
  }
903
904
  /**
904
905
  * Poll until the prediction is terminal (success or failure) or the deadline
905
- * passes. Network errors, 429 (honouring Retry-After) and 5xx are retried.
906
+ * passes. Network errors, 408, 429 (honouring Retry-After) and 5xx are retried.
906
907
  * Throws PollTimeoutError on timeout; returns the terminal prediction otherwise.
907
908
  */
908
909
  async waitForPrediction(id, opts) {
@@ -4651,11 +4652,20 @@ init_errors();
4651
4652
  import fs20 from "fs";
4652
4653
  import os2 from "os";
4653
4654
  import path23 from "path";
4655
+
4656
+ // src/core/limits.ts
4657
+ var FILES_API_MAX_BYTES = 2e9;
4658
+
4659
+ // src/core/stdin-file.ts
4654
4660
  var STDIN_ARG = "-";
4655
4661
  var STDIN_LABEL = "<stdin>";
4656
4662
  var HEAD_BYTES = 4096;
4657
- var MAX_STDIN_BYTES = 2 * 1024 ** 3;
4663
+ var MAX_STDIN_BYTES = FILES_API_MAX_BYTES;
4658
4664
  var WAIT_HINT_MS = 2e3;
4665
+ function stdinTooLargeMessage(maxBytes) {
4666
+ const bytes = maxBytes.toLocaleString("en-US");
4667
+ return maxBytes === FILES_API_MAX_BYTES ? `Piped stdin exceeds the Gemini Files API limit of 2 GB (${bytes} bytes)` : `Piped stdin exceeds the ${bytes}-byte limit`;
4668
+ }
4659
4669
  var ascii = (buf, start, end) => buf.toString("latin1", start, end);
4660
4670
  var HEIF_BRANDS = /* @__PURE__ */ new Set([
4661
4671
  "heic",
@@ -4795,9 +4805,7 @@ async function stdinToTempFile(opts = {}) {
4795
4805
  clearTimeout(hint);
4796
4806
  const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
4797
4807
  if (size + buf.length > maxBytes) {
4798
- throw new ValidationError(
4799
- `Piped stdin exceeds ${Math.floor(maxBytes / 1024 ** 2)} MB; save it to a file instead`
4800
- );
4808
+ throw new ValidationError(stdinTooLargeMessage(maxBytes));
4801
4809
  }
4802
4810
  if (head.length < HEAD_BYTES) {
4803
4811
  head = Buffer.concat([head, buf.subarray(0, HEAD_BYTES - head.length)]);
@@ -4859,9 +4867,9 @@ async function resolveFileInputs(files, opts = {}) {
4859
4867
  }
4860
4868
 
4861
4869
  // src/providers/gemini/client.ts
4862
- init_errors();
4863
4870
  import fs21 from "fs";
4864
4871
  import path25 from "path";
4872
+ init_errors();
4865
4873
 
4866
4874
  // src/providers/gemini/key-pool.ts
4867
4875
  init_errors();
@@ -4985,49 +4993,123 @@ function withKey(opts, fn) {
4985
4993
  function authHeaders2(apiKey) {
4986
4994
  return { "x-goog-api-key": apiKey };
4987
4995
  }
4996
+ var DEFAULT_TIMEOUT_MS3 = 3e5;
4997
+ var UPLOAD_MIN_KBPS_ENV = "MULTIX_UPLOAD_MIN_KBPS";
4998
+ var DEFAULT_MIN_UPLOAD_BYTES_PER_SEC = 1e6;
4999
+ var UPLOAD_TIMEOUT_SLACK_MS = 6e4;
5000
+ function minUploadBytesPerSec() {
5001
+ const raw = resolveKey(UPLOAD_MIN_KBPS_ENV)?.trim();
5002
+ if (!raw) return DEFAULT_MIN_UPLOAD_BYTES_PER_SEC;
5003
+ const kbps = Number(raw);
5004
+ if (!Number.isFinite(kbps) || kbps < 1) {
5005
+ throw new ConfigError(
5006
+ `${UPLOAD_MIN_KBPS_ENV} must be a number of kilobytes per second, at least 1, got "${raw}"`
5007
+ );
5008
+ }
5009
+ return kbps * 1e3;
5010
+ }
5011
+ function uploadTimeoutMs(size, timeoutMs = DEFAULT_TIMEOUT_MS3, bytesPerSec = DEFAULT_MIN_UPLOAD_BYTES_PER_SEC) {
5012
+ return Math.max(timeoutMs, UPLOAD_TIMEOUT_SLACK_MS + Math.ceil(size / bytesPerSec * 1e3));
5013
+ }
4988
5014
  async function uploadFile(filePath, opts = {}) {
4989
- return withKey(opts, (apiKey) => uploadFileWithKey(filePath, apiKey, opts));
5015
+ const size = fs21.statSync(filePath).size;
5016
+ if (size > FILES_API_MAX_BYTES) {
5017
+ throw new ValidationError(
5018
+ `File too large for the Gemini Files API: ${path25.basename(filePath)} is ${formatMb3(size)} MB (limit 2 GB = ${FILES_API_MAX_BYTES} bytes)`
5019
+ );
5020
+ }
5021
+ const bytesPerSec = minUploadBytesPerSec();
5022
+ return withKey(opts, (apiKey) => uploadFileWithKey(filePath, apiKey, { ...opts, bytesPerSec }));
5023
+ }
5024
+ function formatMb3(bytes) {
5025
+ return (bytes / 1024 / 1024).toFixed(2);
5026
+ }
5027
+ async function uploadFetch(url, init, timeoutMs, step) {
5028
+ let resp;
5029
+ try {
5030
+ resp = await globalThis.fetch(url, {
5031
+ ...init,
5032
+ // With the default "follow", fetch clones the request body (tee) to replay
5033
+ // it on a redirect, and that copy keeps every chunk in memory.
5034
+ redirect: "error",
5035
+ signal: AbortSignal.timeout(timeoutMs)
5036
+ });
5037
+ } catch (err2) {
5038
+ const e = err2;
5039
+ const timedOut = e?.name === "TimeoutError";
5040
+ const redirected = e?.cause instanceof Error && e.cause.message === "unexpected redirect";
5041
+ const reason = timedOut ? `timed out after ${Math.round(timeoutMs / 1e3)} s` : e?.cause instanceof Error ? `${e.message}: ${e.cause.message}` : String(e?.message ?? err2);
5042
+ const message = `File upload failed: ${reason}`;
5043
+ if (redirected || timedOut && step === "upload") {
5044
+ throw new ProviderError(message, "gemini", { cause: err2 });
5045
+ }
5046
+ throw new HttpError(0, message, FILES_BASE, void 0, { cause: err2 });
5047
+ }
5048
+ if (!resp.ok) {
5049
+ const txt = await resp.text();
5050
+ throw new HttpError(resp.status, `File upload failed: ${txt.slice(0, 500)}`, FILES_BASE);
5051
+ }
5052
+ return resp;
5053
+ }
5054
+ async function readFinalizedFile(resp) {
5055
+ const status = resp.headers.get("x-goog-upload-status")?.trim().toLowerCase();
5056
+ let file;
5057
+ let readError;
5058
+ try {
5059
+ file = (await resp.json())?.file;
5060
+ } catch (err2) {
5061
+ readError = err2;
5062
+ }
5063
+ if (status !== "final" || typeof file?.uri !== "string" || typeof file?.name !== "string") {
5064
+ const detail = status !== "final" ? `upload status is "${status?.slice(0, 40) ?? "missing"}"` : readError ? "unreadable response body" : "no file in the response";
5065
+ throw new ProviderError(`File upload failed: upload was not finalized (${detail})`, "gemini", {
5066
+ cause: readError
5067
+ });
5068
+ }
5069
+ return file;
4990
5070
  }
4991
5071
  async function uploadFileWithKey(filePath, apiKey, opts) {
4992
- const { timeoutMs = 3e5, logger } = opts;
5072
+ const { timeoutMs = DEFAULT_TIMEOUT_MS3, logger, bytesPerSec } = opts;
4993
5073
  const mimeType = getMimeType(filePath);
4994
- const fileBytes = fs21.readFileSync(filePath);
4995
5074
  const displayName = path25.basename(filePath);
4996
- const boundary = `multix_${Date.now()}`;
4997
- const metaJson = JSON.stringify({ file: { displayName, mimeType } });
4998
- const body = Buffer.concat([
4999
- Buffer.from(
5000
- `--${boundary}\r
5001
- Content-Type: application/json; charset=UTF-8\r
5002
- \r
5003
- ${metaJson}\r
5004
- --${boundary}\r
5005
- Content-Type: ${mimeType}\r
5006
- \r
5007
- `
5008
- ),
5009
- fileBytes,
5010
- Buffer.from(`\r
5011
- --${boundary}--`)
5012
- ]);
5013
- logger?.debug(`Uploading ${displayName} (${(fileBytes.length / 1024 / 1024).toFixed(2)} MB)`);
5014
- const resp = await globalThis.fetch(FILES_BASE, {
5015
- method: "POST",
5016
- headers: {
5017
- "x-goog-api-key": apiKey,
5018
- "Content-Type": `multipart/related; boundary=${boundary}`,
5019
- "Content-Length": String(body.length),
5020
- "X-Goog-Upload-Protocol": "multipart"
5075
+ const file = await fs21.openAsBlob(filePath);
5076
+ logger?.debug(`Uploading ${displayName} (${formatMb3(file.size)} MB)`);
5077
+ const start = await uploadFetch(
5078
+ FILES_BASE,
5079
+ {
5080
+ method: "POST",
5081
+ headers: {
5082
+ "x-goog-api-key": apiKey,
5083
+ "Content-Type": "application/json",
5084
+ "X-Goog-Upload-Protocol": "resumable",
5085
+ "X-Goog-Upload-Command": "start",
5086
+ "X-Goog-Upload-Header-Content-Length": String(file.size),
5087
+ "X-Goog-Upload-Header-Content-Type": mimeType
5088
+ },
5089
+ body: JSON.stringify({ file: { display_name: displayName } })
5021
5090
  },
5022
- body,
5023
- signal: AbortSignal.timeout(timeoutMs)
5024
- });
5025
- if (!resp.ok) {
5026
- const txt = await resp.text();
5027
- throw new HttpError(resp.status, `File upload failed: ${txt.slice(0, 500)}`, FILES_BASE);
5091
+ timeoutMs,
5092
+ "start"
5093
+ );
5094
+ const uploadUrl = start.headers.get("x-goog-upload-url");
5095
+ await start.body?.cancel();
5096
+ if (!uploadUrl) {
5097
+ throw new ProviderError("File upload failed: the Files API returned no upload URL", "gemini");
5028
5098
  }
5029
- const data = await resp.json();
5030
- let fileRef = data.file;
5099
+ const resp = await uploadFetch(
5100
+ uploadUrl,
5101
+ {
5102
+ method: "POST",
5103
+ headers: {
5104
+ "X-Goog-Upload-Offset": "0",
5105
+ "X-Goog-Upload-Command": "upload, finalize"
5106
+ },
5107
+ body: file
5108
+ },
5109
+ uploadTimeoutMs(file.size, timeoutMs, bytesPerSec),
5110
+ "upload"
5111
+ );
5112
+ let fileRef = await readFinalizedFile(resp);
5031
5113
  if (requiresProcessingWait(mimeType)) {
5032
5114
  const deadline = Date.now() + timeoutMs;
5033
5115
  while (fileRef.state === "PROCESSING" && Date.now() < deadline) {
@@ -9474,7 +9556,7 @@ import path45 from "path";
9474
9556
  import fs37 from "fs";
9475
9557
  import path44 from "path";
9476
9558
  init_errors();
9477
- var DEFAULT_TIMEOUT_MS3 = 24e4;
9559
+ var DEFAULT_TIMEOUT_MS4 = 24e4;
9478
9560
  function requireOpenAIKey() {
9479
9561
  const key = resolveKey("OPENAI_API_KEY");
9480
9562
  if (!key) {
@@ -9490,7 +9572,7 @@ function endpoint(pathname) {
9490
9572
  function authHeaders4(apiKey, extra) {
9491
9573
  return { Authorization: `Bearer ${apiKey}`, ...extra ?? {} };
9492
9574
  }
9493
- function postOpenAIJson(pathname, body, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS3) {
9575
+ function postOpenAIJson(pathname, body, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS4) {
9494
9576
  return httpJson({
9495
9577
  url: endpoint(pathname),
9496
9578
  method: "POST",
@@ -9499,7 +9581,7 @@ function postOpenAIJson(pathname, body, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS3)
9499
9581
  timeoutMs
9500
9582
  });
9501
9583
  }
9502
- async function postOpenAIForm(pathname, form, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS3) {
9584
+ async function postOpenAIForm(pathname, form, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS4) {
9503
9585
  const url = endpoint(pathname);
9504
9586
  let res;
9505
9587
  try {
@@ -9518,7 +9600,7 @@ async function postOpenAIForm(pathname, form, apiKey, timeoutMs = DEFAULT_TIMEOU
9518
9600
  }
9519
9601
  return res.json();
9520
9602
  }
9521
- async function postOpenAIBinary(pathname, body, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS3) {
9603
+ async function postOpenAIBinary(pathname, body, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS4) {
9522
9604
  const url = endpoint(pathname);
9523
9605
  let res;
9524
9606
  try {
@@ -10796,7 +10878,7 @@ var ReplicateClient = class {
10796
10878
  /**
10797
10879
  * Poll until the prediction reaches a terminal status (succeeded, failed,
10798
10880
  * canceled, aborted) and return it — callers decide success via
10799
- * assertSucceeded. Retries network/429/5xx; throws PollTimeoutError on deadline.
10881
+ * assertSucceeded. Retries network/408/429/5xx; throws PollTimeoutError on deadline.
10800
10882
  */
10801
10883
  async waitForPrediction(prediction, opts) {
10802
10884
  if (typeof prediction !== "string" && isTerminalStatus2(prediction.status)) return prediction;
@@ -11441,7 +11523,7 @@ import fs45 from "fs";
11441
11523
  import path52 from "path";
11442
11524
  var DATA_URI_MAX_BYTES = 256 * 1024;
11443
11525
  var UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
11444
- function formatMb3(bytes) {
11526
+ function formatMb4(bytes) {
11445
11527
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
11446
11528
  }
11447
11529
  async function resolveFileInput(value, client, logger) {
@@ -11457,7 +11539,7 @@ async function resolveFileInput(value, client, logger) {
11457
11539
  if (stat.size === 0) throw new ValidationError(`Input file is empty: ${value}`);
11458
11540
  if (stat.size >= UPLOAD_MAX_BYTES) {
11459
11541
  throw new ValidationError(
11460
- `Input file ${value} is ${formatMb3(stat.size)}; Replicate accepts uploads under 100MB. Host it and pass an https URL instead.`
11542
+ `Input file ${value} is ${formatMb4(stat.size)}; Replicate accepts uploads under 100MB. Host it and pass an https URL instead.`
11461
11543
  );
11462
11544
  }
11463
11545
  const filename = path52.basename(filePath);
@@ -11467,7 +11549,7 @@ async function resolveFileInput(value, client, logger) {
11467
11549
  logger.debug(`Inlining ${filename} (${stat.size} bytes) as a data URI`);
11468
11550
  return `data:${contentType};base64,${bytes.toString("base64")}`;
11469
11551
  }
11470
- logger.info(`Uploading ${filename} (${formatMb3(stat.size)}) to Replicate Files...`);
11552
+ logger.info(`Uploading ${filename} (${formatMb4(stat.size)}) to Replicate Files...`);
11471
11553
  const file = await client.uploadFile({
11472
11554
  data: new Blob([bytes], { type: contentType }),
11473
11555
  filename,
@@ -11980,6 +12062,7 @@ async function convertResolved(inputs, ctx) {
11980
12062
  } catch (e) {
11981
12063
  errorMsg = labelError(e instanceof Error ? e.message : String(e), input);
11982
12064
  if (isGeminiRateLimitError(e)) break;
12065
+ if (!isTransientHttpError(e)) break;
11983
12066
  if (attempt < 2) {
11984
12067
  const wait = 2 ** attempt * 1e3;
11985
12068
  logger?.debug(`Retry ${attempt + 1} after ${wait / 1e3}s: ${errorMsg}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrgoonie/multix",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "description": "AI multimodal CLI — OpenAI, Gemini, MiniMax, OpenRouter, Leonardo, BytePlus, ElevenLabs, fal.ai, Atlas Cloud, Replicate, HeyGen, ffmpeg, ImageMagick, doc-to-md",
5
5
  "type": "module",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: multix
3
3
  description: AI multimodal CLI — generate and edit images (OpenAI, Codex driver, Gemini Nano Banana, Imagen, MiniMax, OpenRouter, Leonardo, BytePlus Seedream, Cloudflare Workers AI FLUX.1 Schnell, fal.ai FLUX, Replicate FLUX), generate video (Gemini Veo, MiniMax Hailuo, OpenRouter via Veo/Seedance/Kling, Leonardo via Motion/Veo/Kling/Hailuo/Seedance/LTX, BytePlus Seedance, Cloudflare AI Gateway via Replicate, fal.ai queue models, Replicate direct API models incl. P-Video, HeyGen Video Agent and scripted avatar videos), video translation and lipsync (HeyGen), TTS/STT/music (OpenAI, Gemini Flash TTS incl. Gemini 3.8 with delivery-style control, MiniMax, Cloudflare Workers AI MeloTTS, ElevenLabs), 3D models (BytePlus Hyper3D/Hitem3d), analyze/transcribe media, convert documents to Markdown, optimize media via ffmpeg/ImageMagick, and self-update the CLI. Use whenever the user wants to create, edit, or transform images/video/audio/3D/documents from a CLI, mentions any of: OpenAI, Codex image driver, image-to-image, i2i, image edit, watercolor/cyberpunk style transfer, reference image, OpenRouter, Nano Banana, Flux, Seedream, Veo, Hailuo, Seedance, Kling, Cloudflare Workers AI, AI Gateway, fal.ai, Atlas Cloud, Replicate, replicate.com models, Replicate predictions, HeyGen, avatar video, talking head, Video Agent, video translation, dubbing, lipsync, video generation, text-to-video, image-to-video, i2v, ElevenLabs voice cloning, updating/upgrading multix, or asks for batch media optimization.
4
- version: 0.15.0 # x-release-please-version
4
+ version: 0.15.2 # x-release-please-version
5
5
  ---
6
6
 
7
7
  # multix — AI Multimodal CLI
@@ -387,6 +387,7 @@ Requires `cwebp` (libwebp: `brew install webp`, `apt install webp`) on `PATH`.
387
387
  |---|---|
388
388
  | `MULTIX_OUTPUT_DIR` | Override default output dir |
389
389
  | `MULTIX_MAX_DOWNLOAD_MB` | Size limit per downloaded Replicate output file (default 2048) |
390
+ | `MULTIX_UPLOAD_MIN_KBPS` | Slowest Gemini Files API upload speed to allow for, in KB/s (default 1000); lower it on slow links |
390
391
  | `OPENAI_IMAGE_MODEL` / `OPENAI_TTS_MODEL` / `OPENAI_STT_MODEL` | OpenAI model defaults |
391
392
  | `OPENROUTER_IMAGE_MODEL` | Default OpenRouter image model |
392
393
  | `OPENROUTER_FALLBACK_MODELS` | CSV; applies to `generate` AND `i2i` |