@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 +14 -0
- package/dist/cli.js +255 -186
- package/package.json +1 -1
- package/skill/SKILL.md +1 -1
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.
|
|
4581
|
+
return this.send(base + suffix, opts);
|
|
4553
4582
|
}
|
|
4554
|
-
/**
|
|
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/
|
|
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(
|
|
5298
|
-
const 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: ${
|
|
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
|
|
5305
|
-
return
|
|
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(
|
|
5278
|
+
async function uploadFileWithKey(source, apiKey, opts) {
|
|
5355
5279
|
const { timeoutMs = DEFAULT_TIMEOUT_MS3, logger, bytesPerSec } = opts;
|
|
5356
|
-
const mimeType =
|
|
5357
|
-
const
|
|
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
|
|
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: ${
|
|
5330
|
+
throw new ProviderError(`File processing failed: ${label}`, "gemini");
|
|
5408
5331
|
if (fileRef.state === "PROCESSING")
|
|
5409
|
-
throw new ProviderError(`Processing timeout: ${
|
|
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
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
|
|
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
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
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 >
|
|
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
|
|
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
|
|
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
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
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
|
|
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
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
4
|
+
version: 1.0.2 # x-release-please-version
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# multix — AI Multimodal CLI
|