@mrgoonie/multix 1.0.0 → 1.0.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
+ ## [1.0.2](https://github.com/mrgoonie/multix-cli/compare/v1.0.1...v1.0.2) (2026-10-11)
9
+
10
+
11
+ ### Fixed
12
+
13
+ * **fal:** only send the key to trusted queue status and response URLs ([#112](https://github.com/mrgoonie/multix-cli/issues/112)) ([92e61c5](https://github.com/mrgoonie/multix-cli/commit/92e61c547999e0bb30f1f4be6d9bbc1ce8226d24))
14
+
15
+ ## [1.0.1](https://github.com/mrgoonie/multix-cli/compare/v1.0.0...v1.0.1) (2026-10-11)
16
+
17
+
18
+ ### Changed
19
+
20
+ * **gemini:** move file-system work out of the Gemini client ([#109](https://github.com/mrgoonie/multix-cli/issues/109)) ([7df49b2](https://github.com/mrgoonie/multix-cli/commit/7df49b24994bf169cb5709adbada61663a66c786))
21
+
8
22
  ## [1.0.0](https://github.com/mrgoonie/multix-cli/compare/v0.15.3...v1.0.0) (2026-10-11)
9
23
 
10
24
 
package/dist/cli.js CHANGED
@@ -4520,6 +4520,7 @@ var FAL_DEFAULTS = {
4520
4520
  };
4521
4521
 
4522
4522
  // src/providers/fal/run-helpers.ts
4523
+ init_errors();
4523
4524
  import fs18 from "fs";
4524
4525
  import path21 from "path";
4525
4526
  init_redact();
@@ -4528,6 +4529,34 @@ init_redact();
4528
4529
  init_errors();
4529
4530
  init_errors();
4530
4531
  init_redact();
4532
+ var FAL_QUEUE_HOST = "queue.fal.run";
4533
+ function parseUrl(url) {
4534
+ try {
4535
+ return new URL(url);
4536
+ } catch {
4537
+ return void 0;
4538
+ }
4539
+ }
4540
+ function normalizedHost(u) {
4541
+ return u.hostname.toLowerCase().replace(/\.$/, "");
4542
+ }
4543
+ function normalizedOrigin(u) {
4544
+ return `${u.protocol}//${normalizedHost(u)}:${u.port}`;
4545
+ }
4546
+ function assertTrustedFalUrl(url, baseUrl) {
4547
+ const target = parseUrl(url);
4548
+ if (!target) {
4549
+ throw new ProviderError("Refusing to send the fal key to a malformed URL", "fal");
4550
+ }
4551
+ const base = parseUrl(baseUrl);
4552
+ if (base && normalizedOrigin(target) === normalizedOrigin(base)) return;
4553
+ const host = normalizedHost(target);
4554
+ if (target.protocol === "https:" && host === FAL_QUEUE_HOST && target.port === "") return;
4555
+ throw new ProviderError(
4556
+ `Refusing to send the fal key to ${host || "this URL"}: not https://${FAL_QUEUE_HOST} or the FAL_BASE_URL origin`,
4557
+ "fal"
4558
+ );
4559
+ }
4531
4560
  function requireFalKey() {
4532
4561
  const key = resolveRoutedKey(FAL_ROUTING);
4533
4562
  if (!key) {
@@ -4549,10 +4578,22 @@ var FalClient = class {
4549
4578
  async request(path57, opts = {}) {
4550
4579
  const base = this.baseUrl.replace(/\/$/, "");
4551
4580
  const suffix = path57.startsWith("/") ? path57 : `/${path57}`;
4552
- return this.requestAbsolute(base + suffix, opts);
4581
+ return this.send(base + suffix, opts);
4553
4582
  }
4554
- /** Same auth/headers as `request`, but `url` is used verbatim (e.g. a status_url/response_url returned by submit). */
4583
+ /**
4584
+ * Same auth/headers as `request`, but `url` is used verbatim (e.g. a
4585
+ * status_url/response_url returned by submit). The URL must pass
4586
+ * {@link assertTrustedFalUrl} before the key is sent.
4587
+ */
4555
4588
  async requestAbsolute(url, opts = {}) {
4589
+ assertTrustedFalUrl(url, this.baseUrl);
4590
+ return this.send(url, opts);
4591
+ }
4592
+ /** Throws a ProviderError when `url` may not receive the key (see {@link assertTrustedFalUrl}). */
4593
+ assertTrustedUrl(url) {
4594
+ assertTrustedFalUrl(url, this.baseUrl);
4595
+ }
4596
+ async send(url, opts) {
4556
4597
  opts.logger?.debug(`${opts.method ?? "GET"} ${redactUrl(url)}`);
4557
4598
  return httpJson({
4558
4599
  url,
@@ -4620,6 +4661,13 @@ async function submitAndWait(opts) {
4620
4661
  const submitRes = await client.post(submitPath(model), input, logger);
4621
4662
  const requestId = submitRes.request_id;
4622
4663
  logger.debug(`request_id: ${requestId}`);
4664
+ for (const field of ["status_url", "response_url"]) {
4665
+ const url = submitRes[field];
4666
+ if (typeof url !== "string" || url === "") {
4667
+ throw new ProviderError(`fal submit response is missing ${field}`, "fal");
4668
+ }
4669
+ client.assertTrustedUrl(url);
4670
+ }
4623
4671
  logger.info("Polling for completion...");
4624
4672
  const waitTimeoutMs = opts.waitTimeoutMs ?? 48e4;
4625
4673
  const intervalMs = opts.pollIntervalMs ?? 3e3;
@@ -5150,118 +5198,9 @@ async function resolveFileInputs(files, opts = {}) {
5150
5198
  }
5151
5199
 
5152
5200
  // src/providers/gemini/client.ts
5153
- import fs21 from "fs";
5154
- import path25 from "path";
5155
5201
  init_errors();
5156
5202
 
5157
- // src/providers/gemini/key-pool.ts
5158
- init_errors();
5159
- var PRIMARY_KEY_ENV = "GEMINI_API_KEY";
5160
- var DEFAULT_COOLDOWN_MS = 6e4;
5161
- var cooldownUntil = /* @__PURE__ */ new Map();
5162
- function listGeminiKeys(env = process.env) {
5163
- const numbered = Object.keys(env).map((name) => ({ name, match: /^GEMINI_API_KEY_(\d+)$/.exec(name) })).filter((e) => e.match !== null).sort((a, b) => Number(a.match[1]) - Number(b.match[1])).map((e) => e.name);
5164
- const seen = /* @__PURE__ */ new Set();
5165
- const keys = [];
5166
- for (const name of [PRIMARY_KEY_ENV, ...numbered]) {
5167
- const value = env[name]?.trim();
5168
- if (!value || seen.has(value)) continue;
5169
- seen.add(value);
5170
- keys.push({ name, value });
5171
- }
5172
- return keys;
5173
- }
5174
- function isGeminiRateLimitError(error) {
5175
- if (!(error instanceof HttpError)) return false;
5176
- if (error.status === 429) return true;
5177
- return (error.status === 400 || error.status === 403) && error.snippet.includes("RESOURCE_EXHAUSTED");
5178
- }
5179
- function cooldownMs(error) {
5180
- if (!(error instanceof HttpError)) return DEFAULT_COOLDOWN_MS;
5181
- if (error.retryAfterMs !== void 0 && error.retryAfterMs > 0) return error.retryAfterMs;
5182
- const delay = /"retryDelay"\s*:\s*"(\d+(?:\.\d+)?)s"/.exec(error.snippet);
5183
- if (delay?.[1]) return Math.ceil(Number(delay[1]) * 1e3);
5184
- return DEFAULT_COOLDOWN_MS;
5185
- }
5186
- function keysToTry(keys, now) {
5187
- const until = (k) => cooldownUntil.get(k.value) ?? 0;
5188
- const ready = keys.filter((k) => until(k) <= now);
5189
- if (ready.length > 0) return ready;
5190
- const soonest = [...keys].sort((a, b) => until(a) - until(b))[0];
5191
- return soonest ? [soonest] : [];
5192
- }
5193
- async function withGeminiKey(fn, opts = {}) {
5194
- const now = opts.now ?? Date.now;
5195
- const keys = listGeminiKeys(opts.env);
5196
- if (keys.length === 0) {
5197
- throw new ConfigError(
5198
- "GEMINI_API_KEY is not set. Get one at https://aistudio.google.com/apikey"
5199
- );
5200
- }
5201
- const ordered = keysToTry(keys, now());
5202
- let lastError;
5203
- for (const [i, key] of ordered.entries()) {
5204
- try {
5205
- return await fn(key.value, key.name);
5206
- } catch (error) {
5207
- if (!isGeminiRateLimitError(error)) throw error;
5208
- lastError = error;
5209
- cooldownUntil.set(key.value, now() + cooldownMs(error));
5210
- const next = ordered[i + 1];
5211
- if (next) opts.logger?.warn(`${key.name} is rate-limited; retrying with ${next.name}`);
5212
- }
5213
- }
5214
- if (keys.length > 1) {
5215
- opts.logger?.warn(`All ${keys.length} Gemini keys are rate-limited; try again later`);
5216
- }
5217
- throw lastError;
5218
- }
5219
-
5220
- // src/providers/gemini/task-resolver.ts
5221
- import path24 from "path";
5222
- var MIME_MAP = {
5223
- // Audio
5224
- ".mp3": "audio/mp3",
5225
- ".wav": "audio/wav",
5226
- ".aac": "audio/aac",
5227
- ".flac": "audio/flac",
5228
- ".ogg": "audio/ogg",
5229
- ".aiff": "audio/aiff",
5230
- ".m4a": "audio/mp4",
5231
- // Image
5232
- ".jpg": "image/jpeg",
5233
- ".jpeg": "image/jpeg",
5234
- ".png": "image/png",
5235
- ".webp": "image/webp",
5236
- ".heic": "image/heic",
5237
- ".heif": "image/heif",
5238
- ".gif": "image/gif",
5239
- ".bmp": "image/bmp",
5240
- // Video
5241
- ".mp4": "video/mp4",
5242
- ".mpeg": "video/mpeg",
5243
- ".mov": "video/quicktime",
5244
- ".avi": "video/x-msvideo",
5245
- ".flv": "video/x-flv",
5246
- ".mpg": "video/mpeg",
5247
- ".webm": "video/webm",
5248
- ".wmv": "video/x-ms-wmv",
5249
- ".3gpp": "video/3gpp",
5250
- // Document
5251
- ".pdf": "application/pdf",
5252
- ".txt": "text/plain",
5253
- ".html": "text/html",
5254
- ".htm": "text/html",
5255
- ".md": "text/markdown",
5256
- ".csv": "text/csv",
5257
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
5258
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
5259
- ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
5260
- };
5261
- function getMimeType(filePath) {
5262
- const ext = path24.extname(filePath).toLowerCase();
5263
- return MIME_MAP[ext] ?? "application/octet-stream";
5264
- }
5203
+ // src/providers/gemini/media-types.ts
5265
5204
  function requiresProcessingWait(mimeType) {
5266
5205
  return mimeType.startsWith("audio/") || mimeType.startsWith("video/");
5267
5206
  }
@@ -5270,39 +5209,24 @@ function requiresProcessingWait(mimeType) {
5270
5209
  var BASE = "https://generativelanguage.googleapis.com";
5271
5210
  var FILES_BASE = `${BASE}/upload/v1beta/files`;
5272
5211
  var GENERATE_BASE = `${BASE}/v1beta/models`;
5273
- function withKey(opts, fn) {
5274
- return opts.apiKey ? fn(opts.apiKey) : withGeminiKey(fn, { logger: opts.logger });
5275
- }
5276
5212
  function authHeaders2(apiKey) {
5277
5213
  return { "x-goog-api-key": apiKey };
5278
5214
  }
5279
5215
  var DEFAULT_TIMEOUT_MS3 = 3e5;
5280
- var UPLOAD_MIN_KBPS_ENV = "MULTIX_UPLOAD_MIN_KBPS";
5281
5216
  var DEFAULT_MIN_UPLOAD_BYTES_PER_SEC = 1e6;
5282
5217
  var UPLOAD_TIMEOUT_SLACK_MS = 6e4;
5283
- function minUploadBytesPerSec() {
5284
- const raw = resolveKey(UPLOAD_MIN_KBPS_ENV)?.trim();
5285
- if (!raw) return DEFAULT_MIN_UPLOAD_BYTES_PER_SEC;
5286
- const kbps = Number(raw);
5287
- if (!Number.isFinite(kbps) || kbps < 1) {
5288
- throw new ConfigError(
5289
- `${UPLOAD_MIN_KBPS_ENV} must be a number of kilobytes per second, at least 1, got "${raw}"`
5290
- );
5291
- }
5292
- return kbps * 1e3;
5293
- }
5294
5218
  function uploadTimeoutMs(size, timeoutMs = DEFAULT_TIMEOUT_MS3, bytesPerSec = DEFAULT_MIN_UPLOAD_BYTES_PER_SEC) {
5295
5219
  return Math.max(timeoutMs, UPLOAD_TIMEOUT_SLACK_MS + Math.ceil(size / bytesPerSec * 1e3));
5296
5220
  }
5297
- async function uploadFile(filePath, opts = {}) {
5298
- const size = fs21.statSync(filePath).size;
5221
+ async function uploadFile(source, opts) {
5222
+ const size = source.blob.size;
5299
5223
  if (size > FILES_API_MAX_BYTES) {
5300
5224
  throw new ValidationError(
5301
- `File too large for the Gemini Files API: ${path25.basename(filePath)} is ${formatMb3(size)} MB (limit 2 GB = ${FILES_API_MAX_BYTES} bytes)`
5225
+ `File too large for the Gemini Files API: ${source.displayName} is ${formatMb3(size)} MB (limit 2 GB = ${FILES_API_MAX_BYTES} bytes)`
5302
5226
  );
5303
5227
  }
5304
- const bytesPerSec = minUploadBytesPerSec();
5305
- return withKey(opts, (apiKey) => uploadFileWithKey(filePath, apiKey, { ...opts, bytesPerSec }));
5228
+ const { apiKey, minUploadBytesPerSec: minUploadBytesPerSec2 = DEFAULT_MIN_UPLOAD_BYTES_PER_SEC } = opts;
5229
+ return uploadFileWithKey(source, apiKey, { ...opts, bytesPerSec: minUploadBytesPerSec2 });
5306
5230
  }
5307
5231
  function formatMb3(bytes) {
5308
5232
  return (bytes / 1024 / 1024).toFixed(2);
@@ -5351,11 +5275,10 @@ async function readFinalizedFile(resp) {
5351
5275
  }
5352
5276
  return file;
5353
5277
  }
5354
- async function uploadFileWithKey(filePath, apiKey, opts) {
5278
+ async function uploadFileWithKey(source, apiKey, opts) {
5355
5279
  const { timeoutMs = DEFAULT_TIMEOUT_MS3, logger, bytesPerSec } = opts;
5356
- const mimeType = getMimeType(filePath);
5357
- const displayName = path25.basename(filePath);
5358
- const file = await fs21.openAsBlob(filePath);
5280
+ const { mimeType, displayName, label = source.displayName } = source;
5281
+ const file = source.blob.type ? source.blob.slice(0, source.blob.size, "") : source.blob;
5359
5282
  logger?.debug(`Uploading ${displayName} (${formatMb3(file.size)} MB)`);
5360
5283
  const start = await uploadFetch(
5361
5284
  FILES_BASE,
@@ -5397,44 +5320,38 @@ async function uploadFileWithKey(filePath, apiKey, opts) {
5397
5320
  const deadline = Date.now() + timeoutMs;
5398
5321
  while (fileRef.state === "PROCESSING" && Date.now() < deadline) {
5399
5322
  await sleep2(2e3);
5400
- fileRef = await httpJson({
5323
+ fileRef = await fetchJson({
5401
5324
  url: `${BASE}/v1beta/${fileRef.name}`,
5402
5325
  headers: authHeaders2(apiKey)
5403
5326
  }).then((r) => r.file);
5404
5327
  logger?.debug(`Processing... state=${fileRef.state}`);
5405
5328
  }
5406
5329
  if (fileRef.state === "FAILED")
5407
- throw new ProviderError(`File processing failed: ${filePath}`, "gemini");
5330
+ throw new ProviderError(`File processing failed: ${label}`, "gemini");
5408
5331
  if (fileRef.state === "PROCESSING")
5409
- throw new ProviderError(`Processing timeout: ${filePath}`, "gemini");
5332
+ throw new ProviderError(`Processing timeout: ${label}`, "gemini");
5410
5333
  }
5411
5334
  logger?.debug(`Uploaded: ${fileRef.name} (${fileRef.uri})`);
5412
5335
  return fileRef;
5413
5336
  }
5414
- async function generateContent(req, opts = {}) {
5415
- return withKey(
5416
- opts,
5417
- (apiKey) => httpJson({
5418
- url: `${GENERATE_BASE}/${req.model}:generateContent`,
5419
- method: "POST",
5420
- headers: authHeaders2(apiKey),
5421
- body: {
5422
- contents: req.contents,
5423
- ...req.generationConfig ? { generationConfig: req.generationConfig } : {}
5424
- }
5425
- })
5426
- );
5337
+ async function generateContent(req, opts) {
5338
+ return fetchJson({
5339
+ url: `${GENERATE_BASE}/${req.model}:generateContent`,
5340
+ method: "POST",
5341
+ headers: authHeaders2(opts.apiKey),
5342
+ body: {
5343
+ contents: req.contents,
5344
+ ...req.generationConfig ? { generationConfig: req.generationConfig } : {}
5345
+ }
5346
+ });
5427
5347
  }
5428
- async function createInteraction(body, opts = {}) {
5429
- return withKey(
5430
- opts,
5431
- (apiKey) => httpJson({
5432
- url: `${BASE}/v1beta/interactions`,
5433
- method: "POST",
5434
- headers: authHeaders2(apiKey),
5435
- body
5436
- })
5437
- );
5348
+ async function createInteraction(body, opts) {
5349
+ return fetchJson({
5350
+ url: `${BASE}/v1beta/interactions`,
5351
+ method: "POST",
5352
+ headers: authHeaders2(opts.apiKey),
5353
+ body
5354
+ });
5438
5355
  }
5439
5356
  function extractInteractionAudio(resp) {
5440
5357
  let found = null;
@@ -5473,6 +5390,150 @@ function extractImages(resp) {
5473
5390
  (p) => p.inlineData !== void 0
5474
5391
  ).map((p) => ({ mimeType: p.inlineData.mimeType, data: p.inlineData.data }));
5475
5392
  }
5393
+ function sleep2(ms) {
5394
+ return new Promise((resolve) => setTimeout(resolve, ms));
5395
+ }
5396
+
5397
+ // src/providers/gemini/key-pool.ts
5398
+ init_errors();
5399
+ var PRIMARY_KEY_ENV = "GEMINI_API_KEY";
5400
+ var DEFAULT_COOLDOWN_MS = 6e4;
5401
+ var cooldownUntil = /* @__PURE__ */ new Map();
5402
+ function listGeminiKeys(env = process.env) {
5403
+ const numbered = Object.keys(env).map((name) => ({ name, match: /^GEMINI_API_KEY_(\d+)$/.exec(name) })).filter((e) => e.match !== null).sort((a, b) => Number(a.match[1]) - Number(b.match[1])).map((e) => e.name);
5404
+ const seen = /* @__PURE__ */ new Set();
5405
+ const keys = [];
5406
+ for (const name of [PRIMARY_KEY_ENV, ...numbered]) {
5407
+ const value = env[name]?.trim();
5408
+ if (!value || seen.has(value)) continue;
5409
+ seen.add(value);
5410
+ keys.push({ name, value });
5411
+ }
5412
+ return keys;
5413
+ }
5414
+ function isGeminiRateLimitError(error) {
5415
+ if (!(error instanceof HttpError)) return false;
5416
+ if (error.status === 429) return true;
5417
+ return (error.status === 400 || error.status === 403) && error.snippet.includes("RESOURCE_EXHAUSTED");
5418
+ }
5419
+ function cooldownMs(error) {
5420
+ if (!(error instanceof HttpError)) return DEFAULT_COOLDOWN_MS;
5421
+ if (error.retryAfterMs !== void 0 && error.retryAfterMs > 0) return error.retryAfterMs;
5422
+ const delay = /"retryDelay"\s*:\s*"(\d+(?:\.\d+)?)s"/.exec(error.snippet);
5423
+ if (delay?.[1]) return Math.ceil(Number(delay[1]) * 1e3);
5424
+ return DEFAULT_COOLDOWN_MS;
5425
+ }
5426
+ function keysToTry(keys, now) {
5427
+ const until = (k) => cooldownUntil.get(k.value) ?? 0;
5428
+ const ready = keys.filter((k) => until(k) <= now);
5429
+ if (ready.length > 0) return ready;
5430
+ const soonest = [...keys].sort((a, b) => until(a) - until(b))[0];
5431
+ return soonest ? [soonest] : [];
5432
+ }
5433
+ async function withGeminiKey(fn, opts = {}) {
5434
+ const now = opts.now ?? Date.now;
5435
+ const keys = listGeminiKeys(opts.env);
5436
+ if (keys.length === 0) {
5437
+ throw new ConfigError(
5438
+ "GEMINI_API_KEY is not set. Get one at https://aistudio.google.com/apikey"
5439
+ );
5440
+ }
5441
+ const ordered = keysToTry(keys, now());
5442
+ let lastError;
5443
+ for (const [i, key] of ordered.entries()) {
5444
+ try {
5445
+ return await fn(key.value, key.name);
5446
+ } catch (error) {
5447
+ if (!isGeminiRateLimitError(error)) throw error;
5448
+ lastError = error;
5449
+ cooldownUntil.set(key.value, now() + cooldownMs(error));
5450
+ const next = ordered[i + 1];
5451
+ if (next) opts.logger?.warn(`${key.name} is rate-limited; retrying with ${next.name}`);
5452
+ }
5453
+ }
5454
+ if (keys.length > 1) {
5455
+ opts.logger?.warn(`All ${keys.length} Gemini keys are rate-limited; try again later`);
5456
+ }
5457
+ throw lastError;
5458
+ }
5459
+
5460
+ // src/providers/gemini/local-files.ts
5461
+ import fs21 from "fs";
5462
+ import path25 from "path";
5463
+ init_errors();
5464
+
5465
+ // src/providers/gemini/task-resolver.ts
5466
+ import path24 from "path";
5467
+ var MIME_MAP = {
5468
+ // Audio
5469
+ ".mp3": "audio/mp3",
5470
+ ".wav": "audio/wav",
5471
+ ".aac": "audio/aac",
5472
+ ".flac": "audio/flac",
5473
+ ".ogg": "audio/ogg",
5474
+ ".aiff": "audio/aiff",
5475
+ ".m4a": "audio/mp4",
5476
+ // Image
5477
+ ".jpg": "image/jpeg",
5478
+ ".jpeg": "image/jpeg",
5479
+ ".png": "image/png",
5480
+ ".webp": "image/webp",
5481
+ ".heic": "image/heic",
5482
+ ".heif": "image/heif",
5483
+ ".gif": "image/gif",
5484
+ ".bmp": "image/bmp",
5485
+ // Video
5486
+ ".mp4": "video/mp4",
5487
+ ".mpeg": "video/mpeg",
5488
+ ".mov": "video/quicktime",
5489
+ ".avi": "video/x-msvideo",
5490
+ ".flv": "video/x-flv",
5491
+ ".mpg": "video/mpeg",
5492
+ ".webm": "video/webm",
5493
+ ".wmv": "video/x-ms-wmv",
5494
+ ".3gpp": "video/3gpp",
5495
+ // Document
5496
+ ".pdf": "application/pdf",
5497
+ ".txt": "text/plain",
5498
+ ".html": "text/html",
5499
+ ".htm": "text/html",
5500
+ ".md": "text/markdown",
5501
+ ".csv": "text/csv",
5502
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
5503
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
5504
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
5505
+ };
5506
+ function getMimeType(filePath) {
5507
+ const ext = path24.extname(filePath).toLowerCase();
5508
+ return MIME_MAP[ext] ?? "application/octet-stream";
5509
+ }
5510
+
5511
+ // src/providers/gemini/local-files.ts
5512
+ var INLINE_MAX_BYTES = 20 * 1024 * 1024;
5513
+ var UPLOAD_MIN_KBPS_ENV = "MULTIX_UPLOAD_MIN_KBPS";
5514
+ function minUploadBytesPerSec() {
5515
+ const raw = resolveKey(UPLOAD_MIN_KBPS_ENV)?.trim();
5516
+ if (!raw) return DEFAULT_MIN_UPLOAD_BYTES_PER_SEC;
5517
+ const kbps = Number(raw);
5518
+ if (!Number.isFinite(kbps) || kbps < 1) {
5519
+ throw new ConfigError(
5520
+ `${UPLOAD_MIN_KBPS_ENV} must be a number of kilobytes per second, at least 1, got "${raw}"`
5521
+ );
5522
+ }
5523
+ return kbps * 1e3;
5524
+ }
5525
+ async function uploadLocalFile(filePath, opts = {}) {
5526
+ const bytesPerSec = minUploadBytesPerSec();
5527
+ const blob = await fs21.openAsBlob(filePath);
5528
+ const source = {
5529
+ blob,
5530
+ displayName: path25.basename(filePath),
5531
+ mimeType: getMimeType(filePath),
5532
+ label: filePath
5533
+ };
5534
+ const upload = (apiKey) => uploadFile(source, { ...opts, apiKey, minUploadBytesPerSec: bytesPerSec });
5535
+ return opts.apiKey ? upload(opts.apiKey) : withGeminiKey(upload, { logger: opts.logger });
5536
+ }
5476
5537
  function inlinePart(filePath) {
5477
5538
  const data = fs21.readFileSync(filePath).toString("base64");
5478
5539
  const mimeType = getMimeType(filePath);
@@ -5480,10 +5541,7 @@ function inlinePart(filePath) {
5480
5541
  }
5481
5542
  function shouldUseFileApi(filePath) {
5482
5543
  const stat = fs21.statSync(filePath);
5483
- return stat.size > 20 * 1024 * 1024;
5484
- }
5485
- function sleep2(ms) {
5486
- return new Promise((resolve) => setTimeout(resolve, ms));
5544
+ return stat.size > INLINE_MAX_BYTES;
5487
5545
  }
5488
5546
 
5489
5547
  // src/providers/gemini/models.ts
@@ -5619,7 +5677,7 @@ function registerAnalyzeCommand(parent) {
5619
5677
  let contentParts;
5620
5678
  if (useApi) {
5621
5679
  logger.debug("File >20MB \u2014 using Files API");
5622
- const ref = await uploadFile(filePath, { logger, apiKey });
5680
+ const ref = await uploadLocalFile(filePath, { logger, apiKey });
5623
5681
  contentParts = [
5624
5682
  { text: opts.prompt },
5625
5683
  { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
@@ -5682,7 +5740,7 @@ function registerExtractCommand(parent) {
5682
5740
  const useApi = shouldUseFileApi(filePath);
5683
5741
  let contentParts;
5684
5742
  if (useApi) {
5685
- const ref = await uploadFile(filePath, { logger, apiKey });
5743
+ const ref = await uploadLocalFile(filePath, { logger, apiKey });
5686
5744
  contentParts = [
5687
5745
  { text: opts.prompt },
5688
5746
  { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
@@ -5808,12 +5866,15 @@ async function generateGeminiSpeech(opts) {
5808
5866
  logger?.debug(`Gemini TTS model=${model} mode=${speakers?.length ? "multi" : "single"}`);
5809
5867
  let resp;
5810
5868
  try {
5811
- resp = await generateContent(
5812
- {
5813
- model,
5814
- contents: [{ parts: [{ text }] }],
5815
- generationConfig
5816
- },
5869
+ resp = await withGeminiKey(
5870
+ (apiKey) => generateContent(
5871
+ {
5872
+ model,
5873
+ contents: [{ parts: [{ text }] }],
5874
+ generationConfig
5875
+ },
5876
+ { apiKey, logger }
5877
+ ),
5817
5878
  { logger }
5818
5879
  );
5819
5880
  } catch (e) {
@@ -5851,7 +5912,10 @@ async function generateInteractionsSpeech(opts) {
5851
5912
  }
5852
5913
  let resp;
5853
5914
  try {
5854
- resp = await createInteraction(buildInteractionsSpeechBody(opts), { logger });
5915
+ resp = await withGeminiKey(
5916
+ (apiKey) => createInteraction(buildInteractionsSpeechBody(opts), { apiKey, logger }),
5917
+ { logger }
5918
+ );
5855
5919
  } catch (e) {
5856
5920
  return { status: "error", error: e instanceof Error ? e.message : String(e) };
5857
5921
  }
@@ -6209,12 +6273,15 @@ function registerGenerateCommand(parent) {
6209
6273
  const savedFiles = [];
6210
6274
  for (let i = 0; i < numImages; i++) {
6211
6275
  try {
6212
- const resp = await generateContent(
6213
- {
6214
- model,
6215
- contents: [{ parts: [{ text: opts.prompt }] }],
6216
- generationConfig
6217
- },
6276
+ const resp = await withGeminiKey(
6277
+ (apiKey) => generateContent(
6278
+ {
6279
+ model,
6280
+ contents: [{ parts: [{ text: opts.prompt }] }],
6281
+ generationConfig
6282
+ },
6283
+ { apiKey, logger }
6284
+ ),
6218
6285
  { logger }
6219
6286
  );
6220
6287
  const images = extractImages(resp);
@@ -6304,7 +6371,9 @@ function registerGeminiImageToImageCommand(parent) {
6304
6371
  contents: [{ parts }],
6305
6372
  generationConfig: { responseModalities: ["IMAGE"] }
6306
6373
  };
6307
- const resp = await generateContent(req, { logger });
6374
+ const resp = await withGeminiKey((apiKey) => generateContent(req, { apiKey, logger }), {
6375
+ logger
6376
+ });
6308
6377
  const images = extractImages(resp);
6309
6378
  if (images.length === 0) {
6310
6379
  logger.error(
@@ -6437,7 +6506,7 @@ function registerTranscribeCommand2(parent) {
6437
6506
  let contentParts;
6438
6507
  if (useApi) {
6439
6508
  logger.debug("File >20MB \u2014 using Files API");
6440
- const ref = await uploadFile(filePath, { logger, apiKey });
6509
+ const ref = await uploadLocalFile(filePath, { logger, apiKey });
6441
6510
  contentParts = [
6442
6511
  { text: opts.prompt },
6443
6512
  { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
@@ -7533,7 +7602,7 @@ function createHeygenClientFromEnv() {
7533
7602
  const baseUrl = resolveProviderBaseUrl(HEYGEN_ROUTING);
7534
7603
  return new HeygenClient({ apiKey, baseUrl: checkBaseUrl(baseUrl, "HEYGEN_BASE_URL") });
7535
7604
  }
7536
- async function uploadLocalFile(client, filePath, logger) {
7605
+ async function uploadLocalFile2(client, filePath, logger) {
7537
7606
  const abs = path32.resolve(filePath);
7538
7607
  let stat;
7539
7608
  try {
@@ -7569,7 +7638,7 @@ async function resolveMediaInput(client, value, logger) {
7569
7638
  const ref = classifyMediaRef(value);
7570
7639
  if (ref.kind === "url") return { type: "url", url: ref.url };
7571
7640
  if (ref.kind === "asset") return { type: "asset_id", asset_id: ref.assetId };
7572
- return uploadLocalFile(client, ref.path, logger);
7641
+ return uploadLocalFile2(client, ref.path, logger);
7573
7642
  }
7574
7643
  async function resolveMediaInputs(client, values, logger) {
7575
7644
  const out = [];
@@ -12375,7 +12444,7 @@ async function convertResolved(inputs, ctx) {
12375
12444
  let contentParts;
12376
12445
  if (useApi) {
12377
12446
  logger?.debug("File >20MB \u2014 using Files API");
12378
- const ref = await uploadFile(filePath, { logger, apiKey });
12447
+ const ref = await uploadLocalFile(filePath, { logger, apiKey });
12379
12448
  contentParts = [
12380
12449
  { text: prompt },
12381
12450
  { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrgoonie/multix",
3
- "version": "1.0.0",
3
+ "version": "1.0.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: 1.0.0 # x-release-please-version
4
+ version: 1.0.2 # x-release-please-version
5
5
  ---
6
6
 
7
7
  # multix — AI Multimodal CLI