@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 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 requireApiKey() {
3151
- const key = resolveKey("GEMINI_API_KEY");
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
- const apiKey = requireApiKey();
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 ProviderError(
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
- const apiKey = requireApiKey();
3225
- return httpJson({
3226
- url: `${GENERATE_BASE}/${req.model}:generateContent`,
3227
- method: "POST",
3228
- headers: authHeaders2(apiKey),
3229
- body: {
3230
- contents: req.contents,
3231
- ...req.generationConfig ? { generationConfig: req.generationConfig } : {}
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
- const apiKey = requireApiKey();
3237
- return httpJson({
3238
- url: `${BASE}/v1beta/interactions`,
3239
- method: "POST",
3240
- headers: authHeaders2(apiKey),
3241
- body
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 useApi = shouldUseFileApi(filePath);
3422
- let contentParts;
3423
- if (useApi) {
3424
- logger.debug("File >20MB \u2014 using Files API");
3425
- const ref = await uploadFile(filePath, { logger });
3426
- contentParts = [
3427
- { text: opts.prompt },
3428
- { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
3429
- ];
3430
- } else {
3431
- contentParts = [{ text: opts.prompt }, inlinePart(filePath)];
3432
- }
3433
- const resp = await generateContent({
3434
- model,
3435
- contents: [{ parts: contentParts }]
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 useApi = shouldUseFileApi(filePath);
3476
- let contentParts;
3477
- if (useApi) {
3478
- const ref = await uploadFile(filePath, { logger });
3479
- contentParts = [
3480
- { text: opts.prompt },
3481
- { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
3482
- ];
3483
- } else {
3484
- contentParts = [{ text: opts.prompt }, inlinePart(filePath)];
3485
- }
3486
- const resp = await generateContent({
3487
- model,
3488
- contents: [{ parts: contentParts }],
3489
- generationConfig
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
- model,
3596
- contents: [{ parts: [{ text }] }],
3597
- generationConfig
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(apiKey, model, request, dest, logger) {
3948
- const operation = await submitVeo(apiKey, model, buildVeoRequest(request));
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>", "Upload timeout in ms", "120000").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(
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
- const apiKey = resolveKey("GEMINI_API_KEY");
3971
- if (!apiKey) throw new ValidationError("GEMINI_API_KEY is not set");
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
- model,
4048
- contents: [{ parts: [{ text: opts.prompt }] }],
4049
- generationConfig
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 useApi = shouldUseFileApi(filePath);
4268
- let contentParts;
4269
- if (useApi) {
4270
- logger.debug("File >20MB \u2014 using Files API");
4271
- const ref = await uploadFile(filePath, { logger });
4272
- contentParts = [
4273
- { text: opts.prompt },
4274
- { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
4275
- ];
4276
- } else {
4277
- contentParts = [{ text: opts.prompt }, inlinePart(filePath)];
4278
- }
4279
- const resp = await generateContent({
4280
- model,
4281
- contents: [{ parts: contentParts }]
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 ping = await pingGemini(geminiKey);
7040
- if (ping.status === "success") {
7041
- logger.success(`Gemini API reachable \u2014 ${ping.modelCount ?? 0} models available`);
7042
- } else if (ping.status === "auth_error") {
7043
- logger.error(`Gemini auth failed \u2014 check GEMINI_API_KEY: ${ping.error ?? ""}`);
7044
- exitCode = 1;
7045
- } else {
7046
- logger.warn(`Gemini network error (API may be down): ${ping.error ?? ""}`);
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 useApi = shouldUseFileApi(filePath);
7114
- let contentParts;
7115
- if (useApi) {
7116
- logger?.debug("File >20MB \u2014 using Files API");
7117
- const ref = await uploadFile(filePath, { logger });
7118
- contentParts = [
7119
- { text: prompt },
7120
- { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
7121
- ];
7122
- } else {
7123
- contentParts = [{ text: prompt }, inlinePart(filePath)];
7124
- }
7125
- const resp = await generateContent({
7126
- model,
7127
- contents: [{ parts: contentParts }]
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrgoonie/multix",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "AI multimodal CLI — OpenAI, Gemini, MiniMax, OpenRouter, Leonardo, BytePlus, ElevenLabs, fal.ai, 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), 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.7.3 # x-release-please-version
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)