@mrgoonie/multix 0.15.0 → 0.15.1

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,13 @@ 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.1](https://github.com/mrgoonie/multix-cli/compare/v0.15.0...v0.15.1) (2026-10-11)
9
+
10
+
11
+ ### Performance
12
+
13
+ * **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))
14
+
8
15
  ## [0.15.0](https://github.com/mrgoonie/multix-cli/compare/v0.14.1...v0.15.0) (2026-10-11)
9
16
 
10
17
 
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;
@@ -4651,11 +4651,20 @@ init_errors();
4651
4651
  import fs20 from "fs";
4652
4652
  import os2 from "os";
4653
4653
  import path23 from "path";
4654
+
4655
+ // src/core/limits.ts
4656
+ var FILES_API_MAX_BYTES = 2e9;
4657
+
4658
+ // src/core/stdin-file.ts
4654
4659
  var STDIN_ARG = "-";
4655
4660
  var STDIN_LABEL = "<stdin>";
4656
4661
  var HEAD_BYTES = 4096;
4657
- var MAX_STDIN_BYTES = 2 * 1024 ** 3;
4662
+ var MAX_STDIN_BYTES = FILES_API_MAX_BYTES;
4658
4663
  var WAIT_HINT_MS = 2e3;
4664
+ function stdinTooLargeMessage(maxBytes) {
4665
+ const bytes = maxBytes.toLocaleString("en-US");
4666
+ 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`;
4667
+ }
4659
4668
  var ascii = (buf, start, end) => buf.toString("latin1", start, end);
4660
4669
  var HEIF_BRANDS = /* @__PURE__ */ new Set([
4661
4670
  "heic",
@@ -4795,9 +4804,7 @@ async function stdinToTempFile(opts = {}) {
4795
4804
  clearTimeout(hint);
4796
4805
  const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
4797
4806
  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
- );
4807
+ throw new ValidationError(stdinTooLargeMessage(maxBytes));
4801
4808
  }
4802
4809
  if (head.length < HEAD_BYTES) {
4803
4810
  head = Buffer.concat([head, buf.subarray(0, HEAD_BYTES - head.length)]);
@@ -4859,9 +4866,9 @@ async function resolveFileInputs(files, opts = {}) {
4859
4866
  }
4860
4867
 
4861
4868
  // src/providers/gemini/client.ts
4862
- init_errors();
4863
4869
  import fs21 from "fs";
4864
4870
  import path25 from "path";
4871
+ init_errors();
4865
4872
 
4866
4873
  // src/providers/gemini/key-pool.ts
4867
4874
  init_errors();
@@ -4985,49 +4992,123 @@ function withKey(opts, fn) {
4985
4992
  function authHeaders2(apiKey) {
4986
4993
  return { "x-goog-api-key": apiKey };
4987
4994
  }
4995
+ var DEFAULT_TIMEOUT_MS3 = 3e5;
4996
+ var UPLOAD_MIN_KBPS_ENV = "MULTIX_UPLOAD_MIN_KBPS";
4997
+ var DEFAULT_MIN_UPLOAD_BYTES_PER_SEC = 1e6;
4998
+ var UPLOAD_TIMEOUT_SLACK_MS = 6e4;
4999
+ function minUploadBytesPerSec() {
5000
+ const raw = resolveKey(UPLOAD_MIN_KBPS_ENV)?.trim();
5001
+ if (!raw) return DEFAULT_MIN_UPLOAD_BYTES_PER_SEC;
5002
+ const kbps = Number(raw);
5003
+ if (!Number.isFinite(kbps) || kbps < 1) {
5004
+ throw new ConfigError(
5005
+ `${UPLOAD_MIN_KBPS_ENV} must be a number of kilobytes per second, at least 1, got "${raw}"`
5006
+ );
5007
+ }
5008
+ return kbps * 1e3;
5009
+ }
5010
+ function uploadTimeoutMs(size, timeoutMs = DEFAULT_TIMEOUT_MS3, bytesPerSec = DEFAULT_MIN_UPLOAD_BYTES_PER_SEC) {
5011
+ return Math.max(timeoutMs, UPLOAD_TIMEOUT_SLACK_MS + Math.ceil(size / bytesPerSec * 1e3));
5012
+ }
4988
5013
  async function uploadFile(filePath, opts = {}) {
4989
- return withKey(opts, (apiKey) => uploadFileWithKey(filePath, apiKey, opts));
5014
+ const size = fs21.statSync(filePath).size;
5015
+ if (size > FILES_API_MAX_BYTES) {
5016
+ throw new ValidationError(
5017
+ `File too large for the Gemini Files API: ${path25.basename(filePath)} is ${formatMb3(size)} MB (limit 2 GB = ${FILES_API_MAX_BYTES} bytes)`
5018
+ );
5019
+ }
5020
+ const bytesPerSec = minUploadBytesPerSec();
5021
+ return withKey(opts, (apiKey) => uploadFileWithKey(filePath, apiKey, { ...opts, bytesPerSec }));
5022
+ }
5023
+ function formatMb3(bytes) {
5024
+ return (bytes / 1024 / 1024).toFixed(2);
5025
+ }
5026
+ async function uploadFetch(url, init, timeoutMs, step) {
5027
+ let resp;
5028
+ try {
5029
+ resp = await globalThis.fetch(url, {
5030
+ ...init,
5031
+ // With the default "follow", fetch clones the request body (tee) to replay
5032
+ // it on a redirect, and that copy keeps every chunk in memory.
5033
+ redirect: "error",
5034
+ signal: AbortSignal.timeout(timeoutMs)
5035
+ });
5036
+ } catch (err2) {
5037
+ const e = err2;
5038
+ const timedOut = e?.name === "TimeoutError";
5039
+ const redirected = e?.cause instanceof Error && e.cause.message === "unexpected redirect";
5040
+ const reason = timedOut ? `timed out after ${Math.round(timeoutMs / 1e3)} s` : e?.cause instanceof Error ? `${e.message}: ${e.cause.message}` : String(e?.message ?? err2);
5041
+ const message = `File upload failed: ${reason}`;
5042
+ if (redirected || timedOut && step === "upload") {
5043
+ throw new ProviderError(message, "gemini", { cause: err2 });
5044
+ }
5045
+ throw new HttpError(0, message, FILES_BASE, void 0, { cause: err2 });
5046
+ }
5047
+ if (!resp.ok) {
5048
+ const txt = await resp.text();
5049
+ throw new HttpError(resp.status, `File upload failed: ${txt.slice(0, 500)}`, FILES_BASE);
5050
+ }
5051
+ return resp;
5052
+ }
5053
+ async function readFinalizedFile(resp) {
5054
+ const status = resp.headers.get("x-goog-upload-status")?.trim().toLowerCase();
5055
+ let file;
5056
+ let readError;
5057
+ try {
5058
+ file = (await resp.json())?.file;
5059
+ } catch (err2) {
5060
+ readError = err2;
5061
+ }
5062
+ if (status !== "final" || typeof file?.uri !== "string" || typeof file?.name !== "string") {
5063
+ const detail = status !== "final" ? `upload status is "${status?.slice(0, 40) ?? "missing"}"` : readError ? "unreadable response body" : "no file in the response";
5064
+ throw new ProviderError(`File upload failed: upload was not finalized (${detail})`, "gemini", {
5065
+ cause: readError
5066
+ });
5067
+ }
5068
+ return file;
4990
5069
  }
4991
5070
  async function uploadFileWithKey(filePath, apiKey, opts) {
4992
- const { timeoutMs = 3e5, logger } = opts;
5071
+ const { timeoutMs = DEFAULT_TIMEOUT_MS3, logger, bytesPerSec } = opts;
4993
5072
  const mimeType = getMimeType(filePath);
4994
- const fileBytes = fs21.readFileSync(filePath);
4995
5073
  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"
5074
+ const file = await fs21.openAsBlob(filePath);
5075
+ logger?.debug(`Uploading ${displayName} (${formatMb3(file.size)} MB)`);
5076
+ const start = await uploadFetch(
5077
+ FILES_BASE,
5078
+ {
5079
+ method: "POST",
5080
+ headers: {
5081
+ "x-goog-api-key": apiKey,
5082
+ "Content-Type": "application/json",
5083
+ "X-Goog-Upload-Protocol": "resumable",
5084
+ "X-Goog-Upload-Command": "start",
5085
+ "X-Goog-Upload-Header-Content-Length": String(file.size),
5086
+ "X-Goog-Upload-Header-Content-Type": mimeType
5087
+ },
5088
+ body: JSON.stringify({ file: { display_name: displayName } })
5021
5089
  },
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);
5090
+ timeoutMs,
5091
+ "start"
5092
+ );
5093
+ const uploadUrl = start.headers.get("x-goog-upload-url");
5094
+ await start.body?.cancel();
5095
+ if (!uploadUrl) {
5096
+ throw new ProviderError("File upload failed: the Files API returned no upload URL", "gemini");
5028
5097
  }
5029
- const data = await resp.json();
5030
- let fileRef = data.file;
5098
+ const resp = await uploadFetch(
5099
+ uploadUrl,
5100
+ {
5101
+ method: "POST",
5102
+ headers: {
5103
+ "X-Goog-Upload-Offset": "0",
5104
+ "X-Goog-Upload-Command": "upload, finalize"
5105
+ },
5106
+ body: file
5107
+ },
5108
+ uploadTimeoutMs(file.size, timeoutMs, bytesPerSec),
5109
+ "upload"
5110
+ );
5111
+ let fileRef = await readFinalizedFile(resp);
5031
5112
  if (requiresProcessingWait(mimeType)) {
5032
5113
  const deadline = Date.now() + timeoutMs;
5033
5114
  while (fileRef.state === "PROCESSING" && Date.now() < deadline) {
@@ -9474,7 +9555,7 @@ import path45 from "path";
9474
9555
  import fs37 from "fs";
9475
9556
  import path44 from "path";
9476
9557
  init_errors();
9477
- var DEFAULT_TIMEOUT_MS3 = 24e4;
9558
+ var DEFAULT_TIMEOUT_MS4 = 24e4;
9478
9559
  function requireOpenAIKey() {
9479
9560
  const key = resolveKey("OPENAI_API_KEY");
9480
9561
  if (!key) {
@@ -9490,7 +9571,7 @@ function endpoint(pathname) {
9490
9571
  function authHeaders4(apiKey, extra) {
9491
9572
  return { Authorization: `Bearer ${apiKey}`, ...extra ?? {} };
9492
9573
  }
9493
- function postOpenAIJson(pathname, body, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS3) {
9574
+ function postOpenAIJson(pathname, body, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS4) {
9494
9575
  return httpJson({
9495
9576
  url: endpoint(pathname),
9496
9577
  method: "POST",
@@ -9499,7 +9580,7 @@ function postOpenAIJson(pathname, body, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS3)
9499
9580
  timeoutMs
9500
9581
  });
9501
9582
  }
9502
- async function postOpenAIForm(pathname, form, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS3) {
9583
+ async function postOpenAIForm(pathname, form, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS4) {
9503
9584
  const url = endpoint(pathname);
9504
9585
  let res;
9505
9586
  try {
@@ -9518,7 +9599,7 @@ async function postOpenAIForm(pathname, form, apiKey, timeoutMs = DEFAULT_TIMEOU
9518
9599
  }
9519
9600
  return res.json();
9520
9601
  }
9521
- async function postOpenAIBinary(pathname, body, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS3) {
9602
+ async function postOpenAIBinary(pathname, body, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS4) {
9522
9603
  const url = endpoint(pathname);
9523
9604
  let res;
9524
9605
  try {
@@ -11441,7 +11522,7 @@ import fs45 from "fs";
11441
11522
  import path52 from "path";
11442
11523
  var DATA_URI_MAX_BYTES = 256 * 1024;
11443
11524
  var UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
11444
- function formatMb3(bytes) {
11525
+ function formatMb4(bytes) {
11445
11526
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
11446
11527
  }
11447
11528
  async function resolveFileInput(value, client, logger) {
@@ -11457,7 +11538,7 @@ async function resolveFileInput(value, client, logger) {
11457
11538
  if (stat.size === 0) throw new ValidationError(`Input file is empty: ${value}`);
11458
11539
  if (stat.size >= UPLOAD_MAX_BYTES) {
11459
11540
  throw new ValidationError(
11460
- `Input file ${value} is ${formatMb3(stat.size)}; Replicate accepts uploads under 100MB. Host it and pass an https URL instead.`
11541
+ `Input file ${value} is ${formatMb4(stat.size)}; Replicate accepts uploads under 100MB. Host it and pass an https URL instead.`
11461
11542
  );
11462
11543
  }
11463
11544
  const filename = path52.basename(filePath);
@@ -11467,7 +11548,7 @@ async function resolveFileInput(value, client, logger) {
11467
11548
  logger.debug(`Inlining ${filename} (${stat.size} bytes) as a data URI`);
11468
11549
  return `data:${contentType};base64,${bytes.toString("base64")}`;
11469
11550
  }
11470
- logger.info(`Uploading ${filename} (${formatMb3(stat.size)}) to Replicate Files...`);
11551
+ logger.info(`Uploading ${filename} (${formatMb4(stat.size)}) to Replicate Files...`);
11471
11552
  const file = await client.uploadFile({
11472
11553
  data: new Blob([bytes], { type: contentType }),
11473
11554
  filename,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrgoonie/multix",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
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.1 # 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` |