@mrgoonie/multix 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +5 -0
- package/dist/cli.js +244 -126
- package/package.json +1 -1
- package/skill/SKILL.md +2 -2
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.8.0](https://github.com/mrgoonie/multix-cli/compare/v0.7.3...v0.8.0) (2026-10-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
* **gemini:** rotate across GEMINI_API_KEY_N keys on quota errors ([#65](https://github.com/mrgoonie/multix-cli/issues/65)) ([d24efd3](https://github.com/mrgoonie/multix-cli/commit/d24efd36676d3823f9aac6e2aa0a8bd7a393b282))
|
|
14
|
+
|
|
8
15
|
## [0.7.3](https://github.com/mrgoonie/multix-cli/compare/v0.7.2...v0.7.3) (2026-10-11)
|
|
9
16
|
|
|
10
17
|
|
package/README.md
CHANGED
|
@@ -56,6 +56,7 @@ Set at least one provider key. Add to `.env` in your project root or `~/.multix/
|
|
|
56
56
|
| Variable | Required | Purpose |
|
|
57
57
|
|---|---|---|
|
|
58
58
|
| `GEMINI_API_KEY` | For Gemini | [AI Studio](https://aistudio.google.com/apikey) |
|
|
59
|
+
| `GEMINI_API_KEY_2`, `GEMINI_API_KEY_3`, … | No | Extra Gemini keys for quota rotation (see below) |
|
|
59
60
|
| `OPENAI_API_KEY` | For OpenAI API | [OpenAI](https://platform.openai.com/api-keys) |
|
|
60
61
|
| `OPENROUTER_API_KEY` | For OpenRouter | [OpenRouter](https://openrouter.ai/settings/keys) |
|
|
61
62
|
| `MINIMAX_API_KEY` | For MiniMax | [MiniMax](https://platform.minimax.io/user-center/basic-information/interface-key) |
|
|
@@ -95,6 +96,10 @@ Set at least one provider key. Add to `.env` in your project root or `~/.multix/
|
|
|
95
96
|
| `GEMINI_MODEL` / `MULTIMODAL_MODEL` | No | Override Gemini analyze/transcribe/extract model (doc convert uses `--model`; default `gemini-3.6-flash`) |
|
|
96
97
|
| `GEMINI_TTS_MODEL` / `TTS_MODEL` | No | Override Gemini TTS model (default `gemini-3.8-flash-lite-tts`) |
|
|
97
98
|
|
|
99
|
+
### Gemini key rotation
|
|
100
|
+
|
|
101
|
+
Set extra keys as `GEMINI_API_KEY_2`, `GEMINI_API_KEY_3`, … (any numeric suffix; numbered keys alone are enough). `multix` tries `GEMINI_API_KEY` first, then the numbered keys in ascending order, skipping duplicates. When a key returns HTTP 429 (or 400/403 with `RESOURCE_EXHAUSTED`), it cools down for the server's `Retry-After`/`retryDelay` (default 60s) and the same request is retried with the next ready key; other errors are not rotated. A Files API upload is repeated on the new key if the following generate call is rate-limited. Veo rotates on submit only, so a rate limit while polling or downloading never starts a second billed job. `doc convert` stops its own retries on a rate limit. Cooldowns last for the current process only, and logs name the env var, never the key. `multix check` lists the keys in rotation and pings each one. Quotas are per Google Cloud project: keys from the same project share one quota, so rotation only helps across projects.
|
|
102
|
+
|
|
98
103
|
Priority: `process.env` > `cwd/.env` > `~/.multix/.env`.
|
|
99
104
|
|
|
100
105
|
## Commands
|
package/dist/cli.js
CHANGED
|
@@ -3090,9 +3090,72 @@ init_errors();
|
|
|
3090
3090
|
import fs20 from "fs";
|
|
3091
3091
|
|
|
3092
3092
|
// src/providers/gemini/client.ts
|
|
3093
|
+
init_errors();
|
|
3093
3094
|
import fs18 from "fs";
|
|
3094
3095
|
import path22 from "path";
|
|
3096
|
+
|
|
3097
|
+
// src/providers/gemini/key-pool.ts
|
|
3095
3098
|
init_errors();
|
|
3099
|
+
var PRIMARY_KEY_ENV = "GEMINI_API_KEY";
|
|
3100
|
+
var DEFAULT_COOLDOWN_MS = 6e4;
|
|
3101
|
+
var cooldownUntil = /* @__PURE__ */ new Map();
|
|
3102
|
+
function listGeminiKeys(env = process.env) {
|
|
3103
|
+
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);
|
|
3104
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3105
|
+
const keys = [];
|
|
3106
|
+
for (const name of [PRIMARY_KEY_ENV, ...numbered]) {
|
|
3107
|
+
const value = env[name]?.trim();
|
|
3108
|
+
if (!value || seen.has(value)) continue;
|
|
3109
|
+
seen.add(value);
|
|
3110
|
+
keys.push({ name, value });
|
|
3111
|
+
}
|
|
3112
|
+
return keys;
|
|
3113
|
+
}
|
|
3114
|
+
function isGeminiRateLimitError(error) {
|
|
3115
|
+
if (!(error instanceof HttpError)) return false;
|
|
3116
|
+
if (error.status === 429) return true;
|
|
3117
|
+
return (error.status === 400 || error.status === 403) && error.snippet.includes("RESOURCE_EXHAUSTED");
|
|
3118
|
+
}
|
|
3119
|
+
function cooldownMs(error) {
|
|
3120
|
+
if (!(error instanceof HttpError)) return DEFAULT_COOLDOWN_MS;
|
|
3121
|
+
if (error.retryAfterMs !== void 0 && error.retryAfterMs > 0) return error.retryAfterMs;
|
|
3122
|
+
const delay = /"retryDelay"\s*:\s*"(\d+(?:\.\d+)?)s"/.exec(error.snippet);
|
|
3123
|
+
if (delay?.[1]) return Math.ceil(Number(delay[1]) * 1e3);
|
|
3124
|
+
return DEFAULT_COOLDOWN_MS;
|
|
3125
|
+
}
|
|
3126
|
+
function keysToTry(keys, now) {
|
|
3127
|
+
const until = (k) => cooldownUntil.get(k.value) ?? 0;
|
|
3128
|
+
const ready = keys.filter((k) => until(k) <= now);
|
|
3129
|
+
if (ready.length > 0) return ready;
|
|
3130
|
+
const soonest = [...keys].sort((a, b) => until(a) - until(b))[0];
|
|
3131
|
+
return soonest ? [soonest] : [];
|
|
3132
|
+
}
|
|
3133
|
+
async function withGeminiKey(fn, opts = {}) {
|
|
3134
|
+
const now = opts.now ?? Date.now;
|
|
3135
|
+
const keys = listGeminiKeys(opts.env);
|
|
3136
|
+
if (keys.length === 0) {
|
|
3137
|
+
throw new ConfigError(
|
|
3138
|
+
"GEMINI_API_KEY is not set. Get one at https://aistudio.google.com/apikey"
|
|
3139
|
+
);
|
|
3140
|
+
}
|
|
3141
|
+
const ordered = keysToTry(keys, now());
|
|
3142
|
+
let lastError;
|
|
3143
|
+
for (const [i, key] of ordered.entries()) {
|
|
3144
|
+
try {
|
|
3145
|
+
return await fn(key.value, key.name);
|
|
3146
|
+
} catch (error) {
|
|
3147
|
+
if (!isGeminiRateLimitError(error)) throw error;
|
|
3148
|
+
lastError = error;
|
|
3149
|
+
cooldownUntil.set(key.value, now() + cooldownMs(error));
|
|
3150
|
+
const next = ordered[i + 1];
|
|
3151
|
+
if (next) opts.logger?.warn(`${key.name} is rate-limited; retrying with ${next.name}`);
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
if (keys.length > 1) {
|
|
3155
|
+
opts.logger?.warn(`All ${keys.length} Gemini keys are rate-limited; try again later`);
|
|
3156
|
+
}
|
|
3157
|
+
throw lastError;
|
|
3158
|
+
}
|
|
3096
3159
|
|
|
3097
3160
|
// src/providers/gemini/task-resolver.ts
|
|
3098
3161
|
import path21 from "path";
|
|
@@ -3147,19 +3210,16 @@ function requiresProcessingWait(mimeType) {
|
|
|
3147
3210
|
var BASE = "https://generativelanguage.googleapis.com";
|
|
3148
3211
|
var FILES_BASE = `${BASE}/upload/v1beta/files`;
|
|
3149
3212
|
var GENERATE_BASE = `${BASE}/v1beta/models`;
|
|
3150
|
-
function
|
|
3151
|
-
|
|
3152
|
-
if (!key)
|
|
3153
|
-
throw new ConfigError(
|
|
3154
|
-
"GEMINI_API_KEY is not set. Get one at https://aistudio.google.com/apikey"
|
|
3155
|
-
);
|
|
3156
|
-
return key;
|
|
3213
|
+
function withKey(opts, fn) {
|
|
3214
|
+
return opts.apiKey ? fn(opts.apiKey) : withGeminiKey(fn, { logger: opts.logger });
|
|
3157
3215
|
}
|
|
3158
3216
|
function authHeaders2(apiKey) {
|
|
3159
3217
|
return { "x-goog-api-key": apiKey };
|
|
3160
3218
|
}
|
|
3161
3219
|
async function uploadFile(filePath, opts = {}) {
|
|
3162
|
-
|
|
3220
|
+
return withKey(opts, (apiKey) => uploadFileWithKey(filePath, apiKey, opts));
|
|
3221
|
+
}
|
|
3222
|
+
async function uploadFileWithKey(filePath, apiKey, opts) {
|
|
3163
3223
|
const { timeoutMs = 3e5, logger } = opts;
|
|
3164
3224
|
const mimeType = getMimeType(filePath);
|
|
3165
3225
|
const fileBytes = fs18.readFileSync(filePath);
|
|
@@ -3195,10 +3255,7 @@ Content-Type: ${mimeType}\r
|
|
|
3195
3255
|
});
|
|
3196
3256
|
if (!resp.ok) {
|
|
3197
3257
|
const txt = await resp.text();
|
|
3198
|
-
throw new
|
|
3199
|
-
`File upload failed (HTTP ${resp.status}): ${txt.slice(0, 500)}`,
|
|
3200
|
-
"gemini"
|
|
3201
|
-
);
|
|
3258
|
+
throw new HttpError(resp.status, `File upload failed: ${txt.slice(0, 500)}`, FILES_BASE);
|
|
3202
3259
|
}
|
|
3203
3260
|
const data = await resp.json();
|
|
3204
3261
|
let fileRef = data.file;
|
|
@@ -3220,26 +3277,30 @@ Content-Type: ${mimeType}\r
|
|
|
3220
3277
|
logger?.debug(`Uploaded: ${fileRef.name} (${fileRef.uri})`);
|
|
3221
3278
|
return fileRef;
|
|
3222
3279
|
}
|
|
3223
|
-
async function generateContent(req) {
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3280
|
+
async function generateContent(req, opts = {}) {
|
|
3281
|
+
return withKey(
|
|
3282
|
+
opts,
|
|
3283
|
+
(apiKey) => httpJson({
|
|
3284
|
+
url: `${GENERATE_BASE}/${req.model}:generateContent`,
|
|
3285
|
+
method: "POST",
|
|
3286
|
+
headers: authHeaders2(apiKey),
|
|
3287
|
+
body: {
|
|
3288
|
+
contents: req.contents,
|
|
3289
|
+
...req.generationConfig ? { generationConfig: req.generationConfig } : {}
|
|
3290
|
+
}
|
|
3291
|
+
})
|
|
3292
|
+
);
|
|
3234
3293
|
}
|
|
3235
|
-
async function createInteraction(body) {
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3294
|
+
async function createInteraction(body, opts = {}) {
|
|
3295
|
+
return withKey(
|
|
3296
|
+
opts,
|
|
3297
|
+
(apiKey) => httpJson({
|
|
3298
|
+
url: `${BASE}/v1beta/interactions`,
|
|
3299
|
+
method: "POST",
|
|
3300
|
+
headers: authHeaders2(apiKey),
|
|
3301
|
+
body
|
|
3302
|
+
})
|
|
3303
|
+
);
|
|
3243
3304
|
}
|
|
3244
3305
|
function extractInteractionAudio(resp) {
|
|
3245
3306
|
let found = null;
|
|
@@ -3418,22 +3479,30 @@ function registerAnalyzeCommand(parent) {
|
|
|
3418
3479
|
const filePath = opts.files[i];
|
|
3419
3480
|
logger.debug(`[${i + 1}/${opts.files.length}] Processing: ${filePath}`);
|
|
3420
3481
|
try {
|
|
3421
|
-
const
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3482
|
+
const resp = await withGeminiKey(
|
|
3483
|
+
async (apiKey) => {
|
|
3484
|
+
const useApi = shouldUseFileApi(filePath);
|
|
3485
|
+
let contentParts;
|
|
3486
|
+
if (useApi) {
|
|
3487
|
+
logger.debug("File >20MB \u2014 using Files API");
|
|
3488
|
+
const ref = await uploadFile(filePath, { logger, apiKey });
|
|
3489
|
+
contentParts = [
|
|
3490
|
+
{ text: opts.prompt },
|
|
3491
|
+
{ fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
|
|
3492
|
+
];
|
|
3493
|
+
} else {
|
|
3494
|
+
contentParts = [{ text: opts.prompt }, inlinePart(filePath)];
|
|
3495
|
+
}
|
|
3496
|
+
return generateContent(
|
|
3497
|
+
{
|
|
3498
|
+
model,
|
|
3499
|
+
contents: [{ parts: contentParts }]
|
|
3500
|
+
},
|
|
3501
|
+
{ apiKey }
|
|
3502
|
+
);
|
|
3503
|
+
},
|
|
3504
|
+
{ logger }
|
|
3505
|
+
);
|
|
3437
3506
|
results.push({
|
|
3438
3507
|
file: filePath,
|
|
3439
3508
|
status: "success",
|
|
@@ -3472,22 +3541,30 @@ function registerExtractCommand(parent) {
|
|
|
3472
3541
|
const filePath = opts.files[i];
|
|
3473
3542
|
logger.debug(`[${i + 1}/${opts.files.length}] Extracting: ${filePath}`);
|
|
3474
3543
|
try {
|
|
3475
|
-
const
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3544
|
+
const resp = await withGeminiKey(
|
|
3545
|
+
async (apiKey) => {
|
|
3546
|
+
const useApi = shouldUseFileApi(filePath);
|
|
3547
|
+
let contentParts;
|
|
3548
|
+
if (useApi) {
|
|
3549
|
+
const ref = await uploadFile(filePath, { logger, apiKey });
|
|
3550
|
+
contentParts = [
|
|
3551
|
+
{ text: opts.prompt },
|
|
3552
|
+
{ fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
|
|
3553
|
+
];
|
|
3554
|
+
} else {
|
|
3555
|
+
contentParts = [{ text: opts.prompt }, inlinePart(filePath)];
|
|
3556
|
+
}
|
|
3557
|
+
return generateContent(
|
|
3558
|
+
{
|
|
3559
|
+
model,
|
|
3560
|
+
contents: [{ parts: contentParts }],
|
|
3561
|
+
generationConfig
|
|
3562
|
+
},
|
|
3563
|
+
{ apiKey }
|
|
3564
|
+
);
|
|
3565
|
+
},
|
|
3566
|
+
{ logger }
|
|
3567
|
+
);
|
|
3491
3568
|
results.push({ file: filePath, status: "success", response: extractText(resp) });
|
|
3492
3569
|
} catch (e) {
|
|
3493
3570
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -3591,11 +3668,14 @@ async function generateGeminiSpeech(opts) {
|
|
|
3591
3668
|
logger?.debug(`Gemini TTS model=${model} mode=${speakers?.length ? "multi" : "single"}`);
|
|
3592
3669
|
let resp;
|
|
3593
3670
|
try {
|
|
3594
|
-
resp = await generateContent(
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3671
|
+
resp = await generateContent(
|
|
3672
|
+
{
|
|
3673
|
+
model,
|
|
3674
|
+
contents: [{ parts: [{ text }] }],
|
|
3675
|
+
generationConfig
|
|
3676
|
+
},
|
|
3677
|
+
{ logger }
|
|
3678
|
+
);
|
|
3599
3679
|
} catch (e) {
|
|
3600
3680
|
return { status: "error", error: e instanceof Error ? e.message : String(e) };
|
|
3601
3681
|
}
|
|
@@ -3631,7 +3711,7 @@ async function generateInteractionsSpeech(opts) {
|
|
|
3631
3711
|
}
|
|
3632
3712
|
let resp;
|
|
3633
3713
|
try {
|
|
3634
|
-
resp = await createInteraction(buildInteractionsSpeechBody(opts));
|
|
3714
|
+
resp = await createInteraction(buildInteractionsSpeechBody(opts), { logger });
|
|
3635
3715
|
} catch (e) {
|
|
3636
3716
|
return { status: "error", error: e instanceof Error ? e.message : String(e) };
|
|
3637
3717
|
}
|
|
@@ -3787,9 +3867,9 @@ function collectSpeakers(value, prev) {
|
|
|
3787
3867
|
}
|
|
3788
3868
|
|
|
3789
3869
|
// src/providers/gemini/commands/generate-video.ts
|
|
3870
|
+
init_errors();
|
|
3790
3871
|
import fs24 from "fs";
|
|
3791
3872
|
import path25 from "path";
|
|
3792
|
-
init_errors();
|
|
3793
3873
|
|
|
3794
3874
|
// src/providers/gemini/veo.ts
|
|
3795
3875
|
import fs23 from "fs";
|
|
@@ -3907,6 +3987,7 @@ async function submitVeo(apiKey, model, body) {
|
|
|
3907
3987
|
body
|
|
3908
3988
|
});
|
|
3909
3989
|
} catch (e) {
|
|
3990
|
+
if (e instanceof HttpError) throw e;
|
|
3910
3991
|
const msg = e instanceof Error ? e.message : String(e);
|
|
3911
3992
|
throw new ProviderError(`Veo submit failed: ${msg}`, "gemini");
|
|
3912
3993
|
}
|
|
@@ -3944,8 +4025,12 @@ async function downloadVeoVideo(apiKey, uri, dest) {
|
|
|
3944
4025
|
}
|
|
3945
4026
|
await downloadFile(uri, dest, 3e5, host === API_HOST ? headers(apiKey) : void 0);
|
|
3946
4027
|
}
|
|
3947
|
-
async function runVeo(
|
|
3948
|
-
const
|
|
4028
|
+
async function runVeo(model, request, dest, logger) {
|
|
4029
|
+
const body = buildVeoRequest(request);
|
|
4030
|
+
const { apiKey, operation } = await withGeminiKey(
|
|
4031
|
+
async (key) => ({ apiKey: key, operation: await submitVeo(key, model, body) }),
|
|
4032
|
+
{ logger }
|
|
4033
|
+
);
|
|
3949
4034
|
logger.debug(`Operation: ${operation.name}`);
|
|
3950
4035
|
const finalOp = await waitForVeo(apiKey, operation, logger);
|
|
3951
4036
|
const uri = extractVeoVideoUri(finalOp);
|
|
@@ -3964,11 +4049,12 @@ function registerGenerateVideoCommand(parent) {
|
|
|
3964
4049
|
parent.command("generate-video").description("Generate video with Veo via Gemini API [EXPERIMENTAL]").requiredOption("--prompt <text>", "Video generation prompt").option("--model <id>", "Veo model to use").option("--resolution <res>", "Video resolution: 720p|1080p", "1080p").option("--aspect-ratio <ratio>", "Aspect ratio", "16:9").option(
|
|
3965
4050
|
"--reference-images <paths...>",
|
|
3966
4051
|
"Reference images (first=opening frame, second=closing)"
|
|
3967
|
-
).option("--upload-timeout <ms>", "
|
|
4052
|
+
).option("--upload-timeout <ms>", "Deprecated and ignored (images are sent inline)").option("--output <path>", "Copy generated video to this path").option("--no-thumb", "Skip downloading the thumbnail if the API returns one").option("-v, --verbose", "Verbose logging").action(
|
|
3968
4053
|
async (opts) => {
|
|
3969
4054
|
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
3970
|
-
|
|
3971
|
-
|
|
4055
|
+
if (opts.uploadTimeout !== void 0) {
|
|
4056
|
+
logger.warn("--upload-timeout is deprecated and ignored (images are sent inline)");
|
|
4057
|
+
}
|
|
3972
4058
|
const model = opts.model ?? getDefaultModel("generate-video");
|
|
3973
4059
|
if (!model.startsWith("veo-")) {
|
|
3974
4060
|
throw new ValidationError(
|
|
@@ -3986,7 +4072,6 @@ function registerGenerateVideoCommand(parent) {
|
|
|
3986
4072
|
const frames = veoFramesFromPaths(opts.referenceImages);
|
|
3987
4073
|
const dest = path25.join(getOutputDir(), `veo_generated_${Date.now()}.mp4`);
|
|
3988
4074
|
const finalOp = await runVeo(
|
|
3989
|
-
apiKey,
|
|
3990
4075
|
model,
|
|
3991
4076
|
{
|
|
3992
4077
|
prompt: opts.prompt,
|
|
@@ -4043,11 +4128,14 @@ function registerGenerateCommand(parent) {
|
|
|
4043
4128
|
const savedFiles = [];
|
|
4044
4129
|
for (let i = 0; i < numImages; i++) {
|
|
4045
4130
|
try {
|
|
4046
|
-
const resp = await generateContent(
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4131
|
+
const resp = await generateContent(
|
|
4132
|
+
{
|
|
4133
|
+
model,
|
|
4134
|
+
contents: [{ parts: [{ text: opts.prompt }] }],
|
|
4135
|
+
generationConfig
|
|
4136
|
+
},
|
|
4137
|
+
{ logger }
|
|
4138
|
+
);
|
|
4051
4139
|
const images = extractImages(resp);
|
|
4052
4140
|
if (images.length === 0) {
|
|
4053
4141
|
logger.warn(
|
|
@@ -4135,7 +4223,7 @@ function registerGeminiImageToImageCommand(parent) {
|
|
|
4135
4223
|
contents: [{ parts }],
|
|
4136
4224
|
generationConfig: { responseModalities: ["IMAGE"] }
|
|
4137
4225
|
};
|
|
4138
|
-
const resp = await generateContent(req);
|
|
4226
|
+
const resp = await generateContent(req, { logger });
|
|
4139
4227
|
const images = extractImages(resp);
|
|
4140
4228
|
if (images.length === 0) {
|
|
4141
4229
|
logger.error(
|
|
@@ -4193,15 +4281,13 @@ function sniffImageMime(buf, url) {
|
|
|
4193
4281
|
}
|
|
4194
4282
|
|
|
4195
4283
|
// src/providers/gemini/commands/image-to-video.ts
|
|
4284
|
+
init_errors();
|
|
4196
4285
|
import fs27 from "fs";
|
|
4197
4286
|
import path28 from "path";
|
|
4198
|
-
init_errors();
|
|
4199
4287
|
function registerGeminiImageToVideoCommand(parent) {
|
|
4200
4288
|
parent.command("image-to-video <imagePath>").alias("i2v").description("Generate video from a starting image with Veo (image-to-video) [EXPERIMENTAL]").requiredOption("--prompt <text>", "Motion/scene prompt").option("--last-frame <path>", "Optional closing frame image (Veo 3.1)").option("--model <id>", "Veo model id").option("--resolution <res>", "720p|1080p", "1080p").option("--aspect-ratio <ratio>", "Aspect ratio", "16:9").option("--output <path>", "Copy generated video to this path").option("--no-thumb", "Skip downloading the thumbnail if the API returns one").option("-v, --verbose", "Verbose logging").action(
|
|
4201
4289
|
async (imagePath, opts) => {
|
|
4202
4290
|
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
4203
|
-
const apiKey = resolveKey("GEMINI_API_KEY");
|
|
4204
|
-
if (!apiKey) throw new ValidationError("GEMINI_API_KEY is not set");
|
|
4205
4291
|
const model = opts.model ?? getDefaultModel("generate-video");
|
|
4206
4292
|
if (!model.startsWith("veo-")) {
|
|
4207
4293
|
throw new ValidationError(
|
|
@@ -4219,7 +4305,6 @@ function registerGeminiImageToVideoCommand(parent) {
|
|
|
4219
4305
|
);
|
|
4220
4306
|
const dest = path28.join(getOutputDir(), `veo_i2v_${Date.now()}.mp4`);
|
|
4221
4307
|
const finalOp = await runVeo(
|
|
4222
|
-
apiKey,
|
|
4223
4308
|
model,
|
|
4224
4309
|
{
|
|
4225
4310
|
prompt: opts.prompt,
|
|
@@ -4264,22 +4349,30 @@ function registerTranscribeCommand2(parent) {
|
|
|
4264
4349
|
const filePath = opts.files[i];
|
|
4265
4350
|
logger.debug(`[${i + 1}/${opts.files.length}] Transcribing: ${filePath}`);
|
|
4266
4351
|
try {
|
|
4267
|
-
const
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4352
|
+
const resp = await withGeminiKey(
|
|
4353
|
+
async (apiKey) => {
|
|
4354
|
+
const useApi = shouldUseFileApi(filePath);
|
|
4355
|
+
let contentParts;
|
|
4356
|
+
if (useApi) {
|
|
4357
|
+
logger.debug("File >20MB \u2014 using Files API");
|
|
4358
|
+
const ref = await uploadFile(filePath, { logger, apiKey });
|
|
4359
|
+
contentParts = [
|
|
4360
|
+
{ text: opts.prompt },
|
|
4361
|
+
{ fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
|
|
4362
|
+
];
|
|
4363
|
+
} else {
|
|
4364
|
+
contentParts = [{ text: opts.prompt }, inlinePart(filePath)];
|
|
4365
|
+
}
|
|
4366
|
+
return generateContent(
|
|
4367
|
+
{
|
|
4368
|
+
model,
|
|
4369
|
+
contents: [{ parts: contentParts }]
|
|
4370
|
+
},
|
|
4371
|
+
{ apiKey }
|
|
4372
|
+
);
|
|
4373
|
+
},
|
|
4374
|
+
{ logger }
|
|
4375
|
+
);
|
|
4283
4376
|
results.push({
|
|
4284
4377
|
file: filePath,
|
|
4285
4378
|
status: "success",
|
|
@@ -6940,6 +7033,10 @@ var PROVIDERS = [
|
|
|
6940
7033
|
}
|
|
6941
7034
|
];
|
|
6942
7035
|
function resolveProviderKey(p) {
|
|
7036
|
+
if (p.envPrimary === "GEMINI_API_KEY") {
|
|
7037
|
+
const first = listGeminiKeys()[0];
|
|
7038
|
+
return first ? { key: first.value, envUsed: first.name } : {};
|
|
7039
|
+
}
|
|
6943
7040
|
const primary = resolveKey(p.envPrimary);
|
|
6944
7041
|
if (primary) return { key: primary, envUsed: p.envPrimary };
|
|
6945
7042
|
if (p.envFallback) {
|
|
@@ -6991,6 +7088,14 @@ function registerCheckCommand(program2) {
|
|
|
6991
7088
|
const envLabel = r.envUsed ?? p.envPrimary;
|
|
6992
7089
|
if (r.key) {
|
|
6993
7090
|
logger.success(`${envLabel} found \u2014 ${redact(r.key)}`);
|
|
7091
|
+
if (p.name === "Gemini") {
|
|
7092
|
+
const pool = listGeminiKeys();
|
|
7093
|
+
if (pool.length > 1) {
|
|
7094
|
+
logger.info(
|
|
7095
|
+
`Gemini key rotation: ${pool.length} keys (${pool.map((k) => k.name).join(", ")})`
|
|
7096
|
+
);
|
|
7097
|
+
}
|
|
7098
|
+
}
|
|
6994
7099
|
} else if (p.optional) {
|
|
6995
7100
|
const fbNote = p.envFallback ? ` (or ${p.envFallback})` : "";
|
|
6996
7101
|
logger.info(`${p.envPrimary}${fbNote} not set (optional)`);
|
|
@@ -7036,14 +7141,18 @@ function registerCheckCommand(program2) {
|
|
|
7036
7141
|
if (geminiKey) {
|
|
7037
7142
|
logger.header("Gemini Connectivity");
|
|
7038
7143
|
logger.info("Pinging Gemini models endpoint...");
|
|
7039
|
-
const
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
|
|
7043
|
-
|
|
7044
|
-
|
|
7045
|
-
|
|
7046
|
-
|
|
7144
|
+
for (const key of listGeminiKeys()) {
|
|
7145
|
+
const ping = await pingGemini(key.value);
|
|
7146
|
+
if (ping.status === "success") {
|
|
7147
|
+
logger.success(
|
|
7148
|
+
`${key.name}: Gemini API reachable \u2014 ${ping.modelCount ?? 0} models available`
|
|
7149
|
+
);
|
|
7150
|
+
} else if (ping.status === "auth_error") {
|
|
7151
|
+
logger.error(`Gemini auth failed \u2014 check ${key.name}: ${ping.error ?? ""}`);
|
|
7152
|
+
exitCode = 1;
|
|
7153
|
+
} else {
|
|
7154
|
+
logger.warn(`${key.name}: Gemini network error (API may be down): ${ping.error ?? ""}`);
|
|
7155
|
+
}
|
|
7047
7156
|
}
|
|
7048
7157
|
}
|
|
7049
7158
|
logger.header("Summary");
|
|
@@ -7110,26 +7219,35 @@ async function convertDocuments(opts) {
|
|
|
7110
7219
|
let errorMsg;
|
|
7111
7220
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
7112
7221
|
try {
|
|
7113
|
-
const
|
|
7114
|
-
|
|
7115
|
-
|
|
7116
|
-
|
|
7117
|
-
|
|
7118
|
-
|
|
7119
|
-
|
|
7120
|
-
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
|
|
7222
|
+
const resp = await withGeminiKey(
|
|
7223
|
+
async (apiKey) => {
|
|
7224
|
+
const useApi = shouldUseFileApi(filePath);
|
|
7225
|
+
let contentParts;
|
|
7226
|
+
if (useApi) {
|
|
7227
|
+
logger?.debug("File >20MB \u2014 using Files API");
|
|
7228
|
+
const ref = await uploadFile(filePath, { logger, apiKey });
|
|
7229
|
+
contentParts = [
|
|
7230
|
+
{ text: prompt },
|
|
7231
|
+
{ fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
|
|
7232
|
+
];
|
|
7233
|
+
} else {
|
|
7234
|
+
contentParts = [{ text: prompt }, inlinePart(filePath)];
|
|
7235
|
+
}
|
|
7236
|
+
return generateContent(
|
|
7237
|
+
{
|
|
7238
|
+
model,
|
|
7239
|
+
contents: [{ parts: contentParts }]
|
|
7240
|
+
},
|
|
7241
|
+
{ apiKey }
|
|
7242
|
+
);
|
|
7243
|
+
},
|
|
7244
|
+
{ logger }
|
|
7245
|
+
);
|
|
7129
7246
|
markdown = extractText(resp);
|
|
7130
7247
|
break;
|
|
7131
7248
|
} catch (e) {
|
|
7132
7249
|
errorMsg = e instanceof Error ? e.message : String(e);
|
|
7250
|
+
if (isGeminiRateLimitError(e)) break;
|
|
7133
7251
|
if (attempt < 2) {
|
|
7134
7252
|
const wait = 2 ** attempt * 1e3;
|
|
7135
7253
|
logger?.debug(`Retry ${attempt + 1} after ${wait / 1e3}s: ${errorMsg}`);
|
package/package.json
CHANGED
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), 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), 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, video generation, text-to-video, image-to-video, i2v, ElevenLabs voice cloning, updating/upgrading multix, or asks for batch media optimization.
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0 # x-release-please-version
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# multix — AI Multimodal CLI
|
|
@@ -35,7 +35,7 @@ Detects whether `multix` was installed via npm, pnpm, yarn, or bun global instal
|
|
|
35
35
|
## Required env (set at least one provider key)
|
|
36
36
|
|
|
37
37
|
```
|
|
38
|
-
GEMINI_API_KEY # Google AI Studio
|
|
38
|
+
GEMINI_API_KEY # Google AI Studio (extra keys: GEMINI_API_KEY_2, _3, … rotate on 429/RESOURCE_EXHAUSTED)
|
|
39
39
|
OPENAI_API_KEY # OpenAI API (image, image edit, TTS, STT)
|
|
40
40
|
OPENROUTER_API_KEY # OpenRouter (multi-model gateway: image + video via Veo, Seedance, Kling)
|
|
41
41
|
MINIMAX_API_KEY # MiniMax (image/video/speech/music)
|