@mrgoonie/multix 0.15.3 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ 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.1](https://github.com/mrgoonie/multix-cli/compare/v1.0.0...v1.0.1) (2026-10-11)
9
+
10
+
11
+ ### Changed
12
+
13
+ * **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))
14
+
15
+ ## [1.0.0](https://github.com/mrgoonie/multix-cli/compare/v0.15.3...v1.0.0) (2026-10-11)
16
+
17
+
18
+ ### ⚠ BREAKING CHANGES
19
+
20
+ * **core:** a project `.env` can no longer redirect a provider base URL or Cloudflare routing ID while the API key comes from the shell or `~/.multix/.env`. Keep the key in the same project `.env`, move the override to `~/.multix/.env`, or opt in explicitly as described in the environment reference.
21
+
22
+ ### Fixed
23
+
24
+ * **core:** only trust project .env endpoint overrides that ship their own key ([#97](https://github.com/mrgoonie/multix-cli/issues/97)) ([5c503ec](https://github.com/mrgoonie/multix-cli/commit/5c503eca08aeca6998cf5cf520180a32c0aeee0c))
25
+
8
26
  ## [0.15.3](https://github.com/mrgoonie/multix-cli/compare/v0.15.2...v0.15.3) (2026-10-11)
9
27
 
10
28
 
package/README.md CHANGED
@@ -117,6 +117,8 @@ Set extra keys as `GEMINI_API_KEY_2`, `GEMINI_API_KEY_3`, … (any numeric suffi
117
117
 
118
118
  Priority: `process.env` > `cwd/.env` > `~/.multix/.env`. Set `MULTIX_LEGACY_CLAUDE_ENV=1` to also read legacy AgentKit files (`.claude/skills/{ai-multimodal,ak-ai-multimodal}/.env`, `.claude/skills/.env`, `.claude/.env`, project first, then home) with the lowest priority (credential names only, never endpoint/proxy settings); `multix check -v` lists the env files that were loaded.
119
119
 
120
+ `*_BASE_URL`, `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_AI_GATEWAY_ID` decide where your API key is sent. Values from the shell or `~/.multix/.env` are always used. A value from the project `.env` (or a legacy `.claude` file under it) is used only when every key the provider sends with it comes from that same project file (the repository's own key). That protects your key, not your data: a project `.env` that ships its own key and base URL routes that project's requests, including your prompts and any files you upload, to that host, and its key replaces yours from `~/.multix/.env`, so review a cloned repository's `.env` before running `multix` in it. If a key comes from anywhere else, the project value is ignored: the `~/.multix/.env` value is used with a warning (variable, file and key source named, never the value), or, if there is none, the command stops with an error instead of falling back to the vendor default. `multix check` shows whether each such setting is in effect or ignored. To use a project value for one command, run `MULTIX_ALLOW_PROJECT_BASE_URL=1 multix ...`; do not put the opt-in in `~/.multix/.env`, which disables the check for every directory. Non-default base URLs must be `https://` (plain `http://` only for `localhost`, `127.0.0.1`, `[::1]`), and `multix` warns once per variable on stderr with the provider and hostname only. This covers base URLs and Cloudflare routing IDs only; other variables a project `.env` can set are tracked in [#99](https://github.com/mrgoonie/multix-cli/issues/99).
121
+
120
122
  ## Commands
121
123
 
122
124
  ### `multix check`
package/dist/cli.js CHANGED
@@ -308,6 +308,11 @@ function validateRawInput(spec, body) {
308
308
 
309
309
  // src/providers/atlascloud/models.ts
310
310
  var DEFAULT_ATLAS_BASE_URL = "https://api.atlascloud.ai/api/v1";
311
+ var ATLAS_ROUTING = {
312
+ name: "ATLASCLOUD_BASE_URL",
313
+ defaultUrl: DEFAULT_ATLAS_BASE_URL,
314
+ keyEnvs: ["ATLASCLOUD_API_KEY"]
315
+ };
311
316
  var SUBMIT_PATHS = {
312
317
  image: "/model/generateImage",
313
318
  video: "/model/generateVideo",
@@ -339,13 +344,20 @@ import fs2 from "fs/promises";
339
344
  import path2 from "path";
340
345
 
341
346
  // src/core/env-loader.ts
347
+ init_errors();
342
348
  init_redact();
343
349
  import fs from "fs";
344
350
  import os from "os";
345
351
  import path from "path";
346
- import { config as dotenvConfig, parse as dotenvParse } from "dotenv";
352
+ import { parse as dotenvParse } from "dotenv";
353
+ function envNameKey(name, platform = process.platform) {
354
+ return platform === "win32" ? name.toUpperCase() : name;
355
+ }
347
356
  var loaded = false;
348
357
  var loadedFiles = [];
358
+ var fileOrigins = /* @__PURE__ */ new Map();
359
+ var userFileValues = {};
360
+ var warned = /* @__PURE__ */ new Set();
349
361
  var LEGACY_SKILL_DIRS = ["ai-multimodal", "ak-ai-multimodal"];
350
362
  function legacyFilesUnder(root) {
351
363
  const claude = path.join(root, ".claude");
@@ -368,8 +380,7 @@ function legacyClaudeEnvFiles(cwd, home) {
368
380
  return files;
369
381
  }
370
382
  function legacyClaudeEnvEnabled(env = process.env) {
371
- const value = env.MULTIX_LEGACY_CLAUDE_ENV;
372
- return value !== void 0 && ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
383
+ return isTruthy(env.MULTIX_LEGACY_CLAUDE_ENV);
373
384
  }
374
385
  var LEGACY_ALLOWED_NAME = /^[A-Z][A-Z0-9_]*_(API_KEY(_\d+)?|API_TOKEN|ACCOUNT_ID|GATEWAY_ID)$|^(FAL_KEY|ARK_API_KEY)$/;
375
386
  function isLegacyAllowedName(name) {
@@ -382,36 +393,174 @@ function isReadableFile(file) {
382
393
  return false;
383
394
  }
384
395
  }
385
- function loadFile(file) {
386
- if (!isReadableFile(file)) return;
387
- const result = dotenvConfig({ path: file, override: false });
388
- if (!result.error) loadedFiles.push(file);
389
- }
390
- function loadLegacyFile(file) {
391
- if (!isReadableFile(file)) return;
392
- let parsed;
396
+ function parseFile(file) {
397
+ if (!isReadableFile(file)) return void 0;
393
398
  try {
394
- parsed = dotenvParse(fs.readFileSync(file));
399
+ return dotenvParse(fs.readFileSync(file));
395
400
  } catch {
396
- return;
401
+ return void 0;
397
402
  }
403
+ }
404
+ function loadFile(file, source, accept = () => true) {
405
+ const parsed = parseFile(file);
406
+ if (!parsed) return void 0;
398
407
  for (const [name, value] of Object.entries(parsed)) {
399
- if (isLegacyAllowedName(name) && process.env[name] === void 0) process.env[name] = value;
408
+ if (!accept(name) || process.env[name] !== void 0) continue;
409
+ process.env[name] = value;
410
+ fileOrigins.set(envNameKey(name), { source, file, value });
400
411
  }
401
412
  loadedFiles.push(file);
413
+ return parsed;
414
+ }
415
+ function byNameKey(values) {
416
+ const out = {};
417
+ for (const [name, value] of Object.entries(values ?? {})) {
418
+ const key = envNameKey(name);
419
+ if (!(key in out)) out[key] = value;
420
+ }
421
+ return out;
402
422
  }
403
423
  function loadEnv() {
404
424
  if (loaded) return;
405
425
  loaded = true;
406
426
  loadedFiles = [];
427
+ fileOrigins = /* @__PURE__ */ new Map();
428
+ userFileValues = {};
407
429
  const cwd = process.cwd();
408
430
  const home = process.env.MULTIX_DISABLE_HOME_ENV ? void 0 : os.homedir();
409
- loadFile(path.resolve(cwd, ".env"));
410
- if (home) loadFile(path.join(home, ".multix", ".env"));
431
+ loadFile(path.resolve(cwd, ".env"), "project");
432
+ if (home) userFileValues = byNameKey(loadFile(path.join(home, ".multix", ".env"), "user"));
411
433
  if (legacyClaudeEnvEnabled()) {
412
- for (const file of legacyClaudeEnvFiles(cwd, home)) loadLegacyFile(file);
434
+ const projectLegacy = new Set(legacyFilesUnder(cwd));
435
+ for (const file of legacyClaudeEnvFiles(cwd, home)) {
436
+ loadFile(file, projectLegacy.has(file) ? "project" : "legacy", isLegacyAllowedName);
437
+ }
438
+ }
439
+ }
440
+ function envFileOrigin(name) {
441
+ const origin = fileOrigins.get(envNameKey(name));
442
+ if (!origin || process.env[name] !== origin.value) return void 0;
443
+ return { source: origin.source, file: origin.file };
444
+ }
445
+ function isTruthy(value) {
446
+ return value !== void 0 && ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
447
+ }
448
+ var ALLOW_PROJECT_BASE_URL_ENV = "MULTIX_ALLOW_PROJECT_BASE_URL";
449
+ function projectBaseUrlAllowed() {
450
+ if (!isTruthy(process.env[ALLOW_PROJECT_BASE_URL_ENV])) return false;
451
+ return envFileOrigin(ALLOW_PROJECT_BASE_URL_ENV)?.source !== "project";
452
+ }
453
+ var OPT_IN_HINT = `To use the project value for one command, run it as \`${ALLOW_PROJECT_BASE_URL_ENV}=1 multix ...\`. Do not put ${ALLOW_PROJECT_BASE_URL_ENV} in ~/.multix/.env: that trusts the .env of every directory you run multix in.`;
454
+ function describeSource(origin) {
455
+ if (!origin) return "the shell";
456
+ if (origin.source === "user") return "~/.multix/.env";
457
+ if (origin.source === "legacy") return origin.file;
458
+ return `another project file (${origin.file})`;
459
+ }
460
+ function foreignCredential(credentials, file) {
461
+ for (const spec of credentials) {
462
+ const names = typeof spec === "string" ? [spec] : spec;
463
+ const used = names.find((n) => process.env[n]?.trim());
464
+ if (!used) continue;
465
+ const origin = envFileOrigin(used);
466
+ if (origin?.source === "project" && origin.file === file) continue;
467
+ return `${used} comes from ${describeSource(origin)}`;
468
+ }
469
+ return void 0;
470
+ }
471
+ function inspectTrustedSetting(name, credentials, harmless) {
472
+ const value = process.env[name]?.trim() || void 0;
473
+ const origin = envFileOrigin(name);
474
+ if (origin?.source !== "project") {
475
+ return value ? { status: "used", value, source: origin?.source ?? "shell" } : { status: "unset" };
476
+ }
477
+ const userValue = userFileValues[envNameKey(name)]?.trim() || void 0;
478
+ if (value === void 0) {
479
+ return userValue ? { status: "fallback", value: userValue, file: origin.file, reason: "it is empty" } : { status: "unset" };
480
+ }
481
+ const project = (basis) => ({
482
+ status: "used",
483
+ value,
484
+ source: "project",
485
+ basis
486
+ });
487
+ if (value === harmless) return project("default");
488
+ if (projectBaseUrlAllowed()) return project("opt-in");
489
+ const reason = foreignCredential(credentials, origin.file);
490
+ if (!reason) return project("same-file");
491
+ return userValue ? { status: "fallback", value: userValue, file: origin.file, reason } : { status: "refused", file: origin.file, reason };
492
+ }
493
+ function trustedSettingRefusal(name, state) {
494
+ return `${name} is set in project env file ${state.file}, but ${state.reason}, so the project is not trusted to choose where that key is sent. Set ${name} in your shell or ~/.multix/.env, or keep the key in the same project file. ${OPT_IN_HINT}`;
495
+ }
496
+ function applyTrustedSetting(name, state) {
497
+ if (state.status === "unset") return void 0;
498
+ if (state.status === "used") return state.value;
499
+ if (state.status === "refused") throw new ConfigError(trustedSettingRefusal(name, state));
500
+ const hint = state.reason === "it is empty" ? "" : ` ${OPT_IN_HINT}`;
501
+ warnOnce(
502
+ `ignored:${name}`,
503
+ `Ignoring ${name} from project env file ${state.file} (${state.reason}); using the value from ~/.multix/.env.${hint}`
504
+ );
505
+ return state.value;
506
+ }
507
+ function warnOnce(key, message) {
508
+ if (warned.has(key)) return;
509
+ warned.add(key);
510
+ console.warn(`\u26A0 ${message}`);
511
+ }
512
+ function hostOf(url) {
513
+ try {
514
+ return new URL(url).hostname || "(no host)";
515
+ } catch {
516
+ return "(unparseable URL)";
413
517
  }
414
518
  }
519
+ var stripSlash = (url) => url.replace(/\/+$/, "");
520
+ var LOOPBACK_HTTP_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
521
+ function baseUrlSchemeError(name, value) {
522
+ let url;
523
+ try {
524
+ url = new URL(value);
525
+ } catch {
526
+ return `${name} must be an absolute https:// URL`;
527
+ }
528
+ if (url.protocol === "https:") return void 0;
529
+ if (url.protocol === "http:" && LOOPBACK_HTTP_HOSTS.has(url.hostname)) return void 0;
530
+ return `${name} must use https:// (host ${url.hostname || "(none)"}); plain http:// is only allowed for localhost, 127.0.0.1, or [::1]`;
531
+ }
532
+ function defaultHarmless(name, defaultUrl) {
533
+ const raw = process.env[name]?.trim();
534
+ return raw && stripSlash(raw) === stripSlash(defaultUrl) ? raw : void 0;
535
+ }
536
+ function inspectBaseUrl(name, defaultUrl, credentials) {
537
+ const state = inspectTrustedSetting(name, credentials, defaultHarmless(name, defaultUrl));
538
+ if (state.status !== "used" && state.status !== "fallback") return state;
539
+ if (stripSlash(state.value) === stripSlash(defaultUrl)) return state;
540
+ const schemeError = baseUrlSchemeError(name, state.value);
541
+ return schemeError ? { ...state, schemeError } : state;
542
+ }
543
+ function inspectProviderBaseUrl(routing) {
544
+ return inspectBaseUrl(routing.name, routing.defaultUrl, [routing.keyEnvs]);
545
+ }
546
+ function resolveBaseUrl(name, defaultUrl, credentials) {
547
+ const state = inspectBaseUrl(name, defaultUrl, credentials);
548
+ const value = applyTrustedSetting(name, state);
549
+ if (!value || stripSlash(value) === stripSlash(defaultUrl)) return defaultUrl;
550
+ if (state.schemeError) throw new ConfigError(state.schemeError);
551
+ const provider = name.replace(/_BASE_URL$/, "");
552
+ warnOnce(
553
+ `custom:${name}`,
554
+ `${name} is set: ${provider} API requests, including your API key, go to ${hostOf(value)} instead of the default host.`
555
+ );
556
+ return value;
557
+ }
558
+ function resolveProviderBaseUrl(routing) {
559
+ return resolveBaseUrl(routing.name, routing.defaultUrl, [routing.keyEnvs]);
560
+ }
561
+ function resolveRoutedKey(routing) {
562
+ return resolveFirstKey(...routing.keyEnvs)?.value;
563
+ }
415
564
  function getLoadedEnvFiles() {
416
565
  return [...loadedFiles];
417
566
  }
@@ -1158,12 +1307,17 @@ var MODEL_ENV = {
1158
1307
  function atlasDefaultModel(key) {
1159
1308
  return resolveKey(MODEL_ENV[key])?.trim() || ATLAS_DEFAULT_MODELS[key];
1160
1309
  }
1161
- function createAtlasClientFromEnv() {
1162
- const apiKey = resolveKey("ATLASCLOUD_API_KEY")?.trim();
1310
+ function requireAtlasKey() {
1311
+ const apiKey = resolveRoutedKey(ATLAS_ROUTING);
1163
1312
  if (!apiKey) {
1164
1313
  throw new ConfigError(`ATLASCLOUD_API_KEY is not set. Get one at ${ATLAS_KEY_URL}`);
1165
1314
  }
1166
- return new AtlasCloudClient({ apiKey, baseUrl: resolveKey("ATLASCLOUD_BASE_URL") });
1315
+ return apiKey;
1316
+ }
1317
+ function createAtlasClientFromEnv() {
1318
+ const apiKey = requireAtlasKey();
1319
+ const baseUrl = resolveProviderBaseUrl(ATLAS_ROUTING);
1320
+ return new AtlasCloudClient({ apiKey, baseUrl });
1167
1321
  }
1168
1322
  async function resolveMediaRef(client, ref, logger) {
1169
1323
  const trimmed = ref.trim();
@@ -2346,6 +2500,11 @@ init_errors();
2346
2500
 
2347
2501
  // src/providers/byteplus/models.ts
2348
2502
  var DEFAULT_BYTEPLUS_BASE_URL = "https://ark.ap-southeast.bytepluses.com/api/v3";
2503
+ var BYTEPLUS_ROUTING = {
2504
+ name: "BYTEPLUS_BASE_URL",
2505
+ defaultUrl: DEFAULT_BYTEPLUS_BASE_URL,
2506
+ keyEnvs: ["BYTEPLUS_API_KEY", "ARK_API_KEY"]
2507
+ };
2349
2508
  var DEFAULT_BYTEPLUS_IMAGE_MODEL = "seedream-4-0-250828";
2350
2509
  var DEFAULT_BYTEPLUS_VIDEO_MODEL = "seedance-2.0";
2351
2510
  var DEFAULT_BYTEPLUS_3D_MODEL = "hyper3d-gen2-260112";
@@ -2369,7 +2528,7 @@ function bytePlusVideoParamsMode() {
2369
2528
 
2370
2529
  // src/providers/byteplus/client.ts
2371
2530
  function requireBytePlusKey() {
2372
- const key = resolveKey("BYTEPLUS_API_KEY") ?? resolveKey("ARK_API_KEY");
2531
+ const key = resolveRoutedKey(BYTEPLUS_ROUTING);
2373
2532
  if (!key) {
2374
2533
  throw new ConfigError(
2375
2534
  "BytePlus API key not set. Set BYTEPLUS_API_KEY or ARK_API_KEY. Get one at https://console.byteplus.com/auth/api-keys"
@@ -2378,7 +2537,7 @@ function requireBytePlusKey() {
2378
2537
  return key;
2379
2538
  }
2380
2539
  function bytePlusBaseUrl() {
2381
- return resolveKey("BYTEPLUS_BASE_URL") ?? DEFAULT_BYTEPLUS_BASE_URL;
2540
+ return resolveProviderBaseUrl(BYTEPLUS_ROUTING);
2382
2541
  }
2383
2542
  var BytePlusClient = class {
2384
2543
  constructor(apiKey, baseUrl = bytePlusBaseUrl()) {
@@ -3159,11 +3318,15 @@ var CLOUDFLARE_TTS_MODEL = "@cf/myshell-ai/melotts";
3159
3318
  var CLOUDFLARE_VIDEO_MODEL = "prunaai/p-video";
3160
3319
 
3161
3320
  // src/providers/cloudflare/models.ts
3162
- function required(name) {
3163
- const value = resolveKey(name)?.trim();
3164
- if (!value) throw new ConfigError(`${name} is required for Cloudflare media generation`);
3165
- return value;
3166
- }
3321
+ var CLOUDFLARE_ACCOUNT_ID_ENV = "CLOUDFLARE_ACCOUNT_ID";
3322
+ var CLOUDFLARE_GATEWAY_ID_ENV = "CLOUDFLARE_AI_GATEWAY_ID";
3323
+ var CLOUDFLARE_TOKEN_ENV = "CLOUDFLARE_API_TOKEN";
3324
+ var GATEWAY_REPLICATE_TOKEN_ENV = "REPLICATE_API_TOKEN";
3325
+ var CLOUDFLARE_NATIVE_CREDENTIALS = [CLOUDFLARE_TOKEN_ENV];
3326
+ var CLOUDFLARE_VIDEO_CREDENTIALS = [
3327
+ CLOUDFLARE_TOKEN_ENV,
3328
+ GATEWAY_REPLICATE_TOKEN_ENV
3329
+ ];
3167
3330
  function getCloudflareModel(kind, requested) {
3168
3331
  const expected = kind === "image" ? CLOUDFLARE_IMAGE_MODEL : CLOUDFLARE_TTS_MODEL;
3169
3332
  const overrideName = kind === "image" ? "CLOUDFLARE_AI_IMAGE_MODEL" : "CLOUDFLARE_AI_TTS_MODEL";
@@ -3183,22 +3346,57 @@ function validateImageSteps(value) {
3183
3346
  }
3184
3347
  return steps;
3185
3348
  }
3186
- function requireCloudflareConfig() {
3349
+ var requiredMessage = (name) => `${name} is required for Cloudflare media generation`;
3350
+ var token = (name) => resolveKey(name)?.trim() || void 0;
3351
+ function inspectCloudflareConfig(use = "native") {
3352
+ const credentials = use === "video" ? CLOUDFLARE_VIDEO_CREDENTIALS : CLOUDFLARE_NATIVE_CREDENTIALS;
3353
+ const accountId = inspectTrustedSetting(CLOUDFLARE_ACCOUNT_ID_ENV, credentials);
3354
+ const gatewayId = inspectTrustedSetting(CLOUDFLARE_GATEWAY_ID_ENV, credentials);
3355
+ const result = (problem, refused = false) => ({
3356
+ problem,
3357
+ refused,
3358
+ accountId,
3359
+ gatewayId
3360
+ });
3361
+ if (accountId.status === "refused") {
3362
+ return result(trustedSettingRefusal(CLOUDFLARE_ACCOUNT_ID_ENV, accountId), true);
3363
+ }
3364
+ if (accountId.status === "unset") return result(requiredMessage(CLOUDFLARE_ACCOUNT_ID_ENV));
3365
+ if (!token(CLOUDFLARE_TOKEN_ENV)) return result(requiredMessage(CLOUDFLARE_TOKEN_ENV));
3366
+ if (gatewayId.status === "refused") {
3367
+ return result(trustedSettingRefusal(CLOUDFLARE_GATEWAY_ID_ENV, gatewayId), true);
3368
+ }
3369
+ if (use === "video") {
3370
+ if (gatewayId.status === "unset") {
3371
+ return result(`${CLOUDFLARE_GATEWAY_ID_ENV} is required for Cloudflare video generation`);
3372
+ }
3373
+ if (!token(GATEWAY_REPLICATE_TOKEN_ENV)) {
3374
+ return result(requiredMessage(GATEWAY_REPLICATE_TOKEN_ENV));
3375
+ }
3376
+ }
3377
+ return result();
3378
+ }
3379
+ function requireConfig(use) {
3380
+ const check = inspectCloudflareConfig(use);
3381
+ if (check.problem) throw new ConfigError(check.problem);
3187
3382
  return {
3188
- accountId: required("CLOUDFLARE_ACCOUNT_ID"),
3189
- apiToken: required("CLOUDFLARE_API_TOKEN"),
3190
- gatewayId: resolveKey("CLOUDFLARE_AI_GATEWAY_ID")?.trim() || void 0
3383
+ // applyTrustedSetting prints the one-time warning when a ~/.multix/.env fallback is used.
3384
+ accountId: applyTrustedSetting(CLOUDFLARE_ACCOUNT_ID_ENV, check.accountId),
3385
+ apiToken: token(CLOUDFLARE_TOKEN_ENV),
3386
+ gatewayId: applyTrustedSetting(CLOUDFLARE_GATEWAY_ID_ENV, check.gatewayId),
3387
+ ...use === "video" ? { replicateApiToken: token(GATEWAY_REPLICATE_TOKEN_ENV) } : {}
3191
3388
  };
3192
3389
  }
3390
+ function requireCloudflareConfig() {
3391
+ return requireConfig("native");
3392
+ }
3193
3393
  function requireCloudflareVideoConfig() {
3194
- const config = requireCloudflareConfig();
3195
- if (!config.gatewayId) {
3196
- throw new ConfigError("CLOUDFLARE_AI_GATEWAY_ID is required for Cloudflare video generation");
3197
- }
3394
+ const config = requireConfig("video");
3198
3395
  return {
3199
- ...config,
3396
+ accountId: config.accountId,
3397
+ apiToken: config.apiToken,
3200
3398
  gatewayId: config.gatewayId,
3201
- replicateApiToken: required("REPLICATE_API_TOKEN")
3399
+ replicateApiToken: config.replicateApiToken
3202
3400
  };
3203
3401
  }
3204
3402
 
@@ -4307,6 +4505,11 @@ import path22 from "path";
4307
4505
 
4308
4506
  // src/providers/fal/models.ts
4309
4507
  var DEFAULT_FAL_BASE_URL = "https://queue.fal.run";
4508
+ var FAL_ROUTING = {
4509
+ name: "FAL_BASE_URL",
4510
+ defaultUrl: DEFAULT_FAL_BASE_URL,
4511
+ keyEnvs: ["FAL_KEY"]
4512
+ };
4310
4513
  var FAL_DEFAULTS = {
4311
4514
  // FLUX.1 [schnell] — fast, low-cost default text-to-image model.
4312
4515
  imageModel: process.env.FAL_IMAGE_MODEL ?? "fal-ai/flux/schnell",
@@ -4326,14 +4529,14 @@ init_errors();
4326
4529
  init_errors();
4327
4530
  init_redact();
4328
4531
  function requireFalKey() {
4329
- const key = resolveKey("FAL_KEY");
4532
+ const key = resolveRoutedKey(FAL_ROUTING);
4330
4533
  if (!key) {
4331
4534
  throw new ConfigError("FAL_KEY is not set. Get one at https://fal.ai/dashboard/keys");
4332
4535
  }
4333
4536
  return key;
4334
4537
  }
4335
4538
  function falBaseUrl() {
4336
- return resolveKey("FAL_BASE_URL") ?? DEFAULT_FAL_BASE_URL;
4539
+ return resolveProviderBaseUrl(FAL_ROUTING);
4337
4540
  }
4338
4541
  var FalClient = class {
4339
4542
  constructor(apiKey, baseUrl = falBaseUrl()) {
@@ -4947,118 +5150,9 @@ async function resolveFileInputs(files, opts = {}) {
4947
5150
  }
4948
5151
 
4949
5152
  // src/providers/gemini/client.ts
4950
- import fs21 from "fs";
4951
- import path25 from "path";
4952
- init_errors();
4953
-
4954
- // src/providers/gemini/key-pool.ts
4955
5153
  init_errors();
4956
- var PRIMARY_KEY_ENV = "GEMINI_API_KEY";
4957
- var DEFAULT_COOLDOWN_MS = 6e4;
4958
- var cooldownUntil = /* @__PURE__ */ new Map();
4959
- function listGeminiKeys(env = process.env) {
4960
- 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);
4961
- const seen = /* @__PURE__ */ new Set();
4962
- const keys = [];
4963
- for (const name of [PRIMARY_KEY_ENV, ...numbered]) {
4964
- const value = env[name]?.trim();
4965
- if (!value || seen.has(value)) continue;
4966
- seen.add(value);
4967
- keys.push({ name, value });
4968
- }
4969
- return keys;
4970
- }
4971
- function isGeminiRateLimitError(error) {
4972
- if (!(error instanceof HttpError)) return false;
4973
- if (error.status === 429) return true;
4974
- return (error.status === 400 || error.status === 403) && error.snippet.includes("RESOURCE_EXHAUSTED");
4975
- }
4976
- function cooldownMs(error) {
4977
- if (!(error instanceof HttpError)) return DEFAULT_COOLDOWN_MS;
4978
- if (error.retryAfterMs !== void 0 && error.retryAfterMs > 0) return error.retryAfterMs;
4979
- const delay = /"retryDelay"\s*:\s*"(\d+(?:\.\d+)?)s"/.exec(error.snippet);
4980
- if (delay?.[1]) return Math.ceil(Number(delay[1]) * 1e3);
4981
- return DEFAULT_COOLDOWN_MS;
4982
- }
4983
- function keysToTry(keys, now) {
4984
- const until = (k) => cooldownUntil.get(k.value) ?? 0;
4985
- const ready = keys.filter((k) => until(k) <= now);
4986
- if (ready.length > 0) return ready;
4987
- const soonest = [...keys].sort((a, b) => until(a) - until(b))[0];
4988
- return soonest ? [soonest] : [];
4989
- }
4990
- async function withGeminiKey(fn, opts = {}) {
4991
- const now = opts.now ?? Date.now;
4992
- const keys = listGeminiKeys(opts.env);
4993
- if (keys.length === 0) {
4994
- throw new ConfigError(
4995
- "GEMINI_API_KEY is not set. Get one at https://aistudio.google.com/apikey"
4996
- );
4997
- }
4998
- const ordered = keysToTry(keys, now());
4999
- let lastError;
5000
- for (const [i, key] of ordered.entries()) {
5001
- try {
5002
- return await fn(key.value, key.name);
5003
- } catch (error) {
5004
- if (!isGeminiRateLimitError(error)) throw error;
5005
- lastError = error;
5006
- cooldownUntil.set(key.value, now() + cooldownMs(error));
5007
- const next = ordered[i + 1];
5008
- if (next) opts.logger?.warn(`${key.name} is rate-limited; retrying with ${next.name}`);
5009
- }
5010
- }
5011
- if (keys.length > 1) {
5012
- opts.logger?.warn(`All ${keys.length} Gemini keys are rate-limited; try again later`);
5013
- }
5014
- throw lastError;
5015
- }
5016
5154
 
5017
- // src/providers/gemini/task-resolver.ts
5018
- import path24 from "path";
5019
- var MIME_MAP = {
5020
- // Audio
5021
- ".mp3": "audio/mp3",
5022
- ".wav": "audio/wav",
5023
- ".aac": "audio/aac",
5024
- ".flac": "audio/flac",
5025
- ".ogg": "audio/ogg",
5026
- ".aiff": "audio/aiff",
5027
- ".m4a": "audio/mp4",
5028
- // Image
5029
- ".jpg": "image/jpeg",
5030
- ".jpeg": "image/jpeg",
5031
- ".png": "image/png",
5032
- ".webp": "image/webp",
5033
- ".heic": "image/heic",
5034
- ".heif": "image/heif",
5035
- ".gif": "image/gif",
5036
- ".bmp": "image/bmp",
5037
- // Video
5038
- ".mp4": "video/mp4",
5039
- ".mpeg": "video/mpeg",
5040
- ".mov": "video/quicktime",
5041
- ".avi": "video/x-msvideo",
5042
- ".flv": "video/x-flv",
5043
- ".mpg": "video/mpeg",
5044
- ".webm": "video/webm",
5045
- ".wmv": "video/x-ms-wmv",
5046
- ".3gpp": "video/3gpp",
5047
- // Document
5048
- ".pdf": "application/pdf",
5049
- ".txt": "text/plain",
5050
- ".html": "text/html",
5051
- ".htm": "text/html",
5052
- ".md": "text/markdown",
5053
- ".csv": "text/csv",
5054
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
5055
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
5056
- ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
5057
- };
5058
- function getMimeType(filePath) {
5059
- const ext = path24.extname(filePath).toLowerCase();
5060
- return MIME_MAP[ext] ?? "application/octet-stream";
5061
- }
5155
+ // src/providers/gemini/media-types.ts
5062
5156
  function requiresProcessingWait(mimeType) {
5063
5157
  return mimeType.startsWith("audio/") || mimeType.startsWith("video/");
5064
5158
  }
@@ -5067,39 +5161,24 @@ function requiresProcessingWait(mimeType) {
5067
5161
  var BASE = "https://generativelanguage.googleapis.com";
5068
5162
  var FILES_BASE = `${BASE}/upload/v1beta/files`;
5069
5163
  var GENERATE_BASE = `${BASE}/v1beta/models`;
5070
- function withKey(opts, fn) {
5071
- return opts.apiKey ? fn(opts.apiKey) : withGeminiKey(fn, { logger: opts.logger });
5072
- }
5073
5164
  function authHeaders2(apiKey) {
5074
5165
  return { "x-goog-api-key": apiKey };
5075
5166
  }
5076
5167
  var DEFAULT_TIMEOUT_MS3 = 3e5;
5077
- var UPLOAD_MIN_KBPS_ENV = "MULTIX_UPLOAD_MIN_KBPS";
5078
5168
  var DEFAULT_MIN_UPLOAD_BYTES_PER_SEC = 1e6;
5079
5169
  var UPLOAD_TIMEOUT_SLACK_MS = 6e4;
5080
- function minUploadBytesPerSec() {
5081
- const raw = resolveKey(UPLOAD_MIN_KBPS_ENV)?.trim();
5082
- if (!raw) return DEFAULT_MIN_UPLOAD_BYTES_PER_SEC;
5083
- const kbps = Number(raw);
5084
- if (!Number.isFinite(kbps) || kbps < 1) {
5085
- throw new ConfigError(
5086
- `${UPLOAD_MIN_KBPS_ENV} must be a number of kilobytes per second, at least 1, got "${raw}"`
5087
- );
5088
- }
5089
- return kbps * 1e3;
5090
- }
5091
5170
  function uploadTimeoutMs(size, timeoutMs = DEFAULT_TIMEOUT_MS3, bytesPerSec = DEFAULT_MIN_UPLOAD_BYTES_PER_SEC) {
5092
5171
  return Math.max(timeoutMs, UPLOAD_TIMEOUT_SLACK_MS + Math.ceil(size / bytesPerSec * 1e3));
5093
5172
  }
5094
- async function uploadFile(filePath, opts = {}) {
5095
- const size = fs21.statSync(filePath).size;
5173
+ async function uploadFile(source, opts) {
5174
+ const size = source.blob.size;
5096
5175
  if (size > FILES_API_MAX_BYTES) {
5097
5176
  throw new ValidationError(
5098
- `File too large for the Gemini Files API: ${path25.basename(filePath)} is ${formatMb3(size)} MB (limit 2 GB = ${FILES_API_MAX_BYTES} bytes)`
5177
+ `File too large for the Gemini Files API: ${source.displayName} is ${formatMb3(size)} MB (limit 2 GB = ${FILES_API_MAX_BYTES} bytes)`
5099
5178
  );
5100
5179
  }
5101
- const bytesPerSec = minUploadBytesPerSec();
5102
- return withKey(opts, (apiKey) => uploadFileWithKey(filePath, apiKey, { ...opts, bytesPerSec }));
5180
+ const { apiKey, minUploadBytesPerSec: minUploadBytesPerSec2 = DEFAULT_MIN_UPLOAD_BYTES_PER_SEC } = opts;
5181
+ return uploadFileWithKey(source, apiKey, { ...opts, bytesPerSec: minUploadBytesPerSec2 });
5103
5182
  }
5104
5183
  function formatMb3(bytes) {
5105
5184
  return (bytes / 1024 / 1024).toFixed(2);
@@ -5148,11 +5227,10 @@ async function readFinalizedFile(resp) {
5148
5227
  }
5149
5228
  return file;
5150
5229
  }
5151
- async function uploadFileWithKey(filePath, apiKey, opts) {
5230
+ async function uploadFileWithKey(source, apiKey, opts) {
5152
5231
  const { timeoutMs = DEFAULT_TIMEOUT_MS3, logger, bytesPerSec } = opts;
5153
- const mimeType = getMimeType(filePath);
5154
- const displayName = path25.basename(filePath);
5155
- const file = await fs21.openAsBlob(filePath);
5232
+ const { mimeType, displayName, label = source.displayName } = source;
5233
+ const file = source.blob.type ? source.blob.slice(0, source.blob.size, "") : source.blob;
5156
5234
  logger?.debug(`Uploading ${displayName} (${formatMb3(file.size)} MB)`);
5157
5235
  const start = await uploadFetch(
5158
5236
  FILES_BASE,
@@ -5194,44 +5272,38 @@ async function uploadFileWithKey(filePath, apiKey, opts) {
5194
5272
  const deadline = Date.now() + timeoutMs;
5195
5273
  while (fileRef.state === "PROCESSING" && Date.now() < deadline) {
5196
5274
  await sleep2(2e3);
5197
- fileRef = await httpJson({
5275
+ fileRef = await fetchJson({
5198
5276
  url: `${BASE}/v1beta/${fileRef.name}`,
5199
5277
  headers: authHeaders2(apiKey)
5200
5278
  }).then((r) => r.file);
5201
5279
  logger?.debug(`Processing... state=${fileRef.state}`);
5202
5280
  }
5203
5281
  if (fileRef.state === "FAILED")
5204
- throw new ProviderError(`File processing failed: ${filePath}`, "gemini");
5282
+ throw new ProviderError(`File processing failed: ${label}`, "gemini");
5205
5283
  if (fileRef.state === "PROCESSING")
5206
- throw new ProviderError(`Processing timeout: ${filePath}`, "gemini");
5284
+ throw new ProviderError(`Processing timeout: ${label}`, "gemini");
5207
5285
  }
5208
5286
  logger?.debug(`Uploaded: ${fileRef.name} (${fileRef.uri})`);
5209
5287
  return fileRef;
5210
5288
  }
5211
- async function generateContent(req, opts = {}) {
5212
- return withKey(
5213
- opts,
5214
- (apiKey) => httpJson({
5215
- url: `${GENERATE_BASE}/${req.model}:generateContent`,
5216
- method: "POST",
5217
- headers: authHeaders2(apiKey),
5218
- body: {
5219
- contents: req.contents,
5220
- ...req.generationConfig ? { generationConfig: req.generationConfig } : {}
5221
- }
5222
- })
5223
- );
5289
+ async function generateContent(req, opts) {
5290
+ return fetchJson({
5291
+ url: `${GENERATE_BASE}/${req.model}:generateContent`,
5292
+ method: "POST",
5293
+ headers: authHeaders2(opts.apiKey),
5294
+ body: {
5295
+ contents: req.contents,
5296
+ ...req.generationConfig ? { generationConfig: req.generationConfig } : {}
5297
+ }
5298
+ });
5224
5299
  }
5225
- async function createInteraction(body, opts = {}) {
5226
- return withKey(
5227
- opts,
5228
- (apiKey) => httpJson({
5229
- url: `${BASE}/v1beta/interactions`,
5230
- method: "POST",
5231
- headers: authHeaders2(apiKey),
5232
- body
5233
- })
5234
- );
5300
+ async function createInteraction(body, opts) {
5301
+ return fetchJson({
5302
+ url: `${BASE}/v1beta/interactions`,
5303
+ method: "POST",
5304
+ headers: authHeaders2(opts.apiKey),
5305
+ body
5306
+ });
5235
5307
  }
5236
5308
  function extractInteractionAudio(resp) {
5237
5309
  let found = null;
@@ -5270,6 +5342,150 @@ function extractImages(resp) {
5270
5342
  (p) => p.inlineData !== void 0
5271
5343
  ).map((p) => ({ mimeType: p.inlineData.mimeType, data: p.inlineData.data }));
5272
5344
  }
5345
+ function sleep2(ms) {
5346
+ return new Promise((resolve) => setTimeout(resolve, ms));
5347
+ }
5348
+
5349
+ // src/providers/gemini/key-pool.ts
5350
+ init_errors();
5351
+ var PRIMARY_KEY_ENV = "GEMINI_API_KEY";
5352
+ var DEFAULT_COOLDOWN_MS = 6e4;
5353
+ var cooldownUntil = /* @__PURE__ */ new Map();
5354
+ function listGeminiKeys(env = process.env) {
5355
+ 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);
5356
+ const seen = /* @__PURE__ */ new Set();
5357
+ const keys = [];
5358
+ for (const name of [PRIMARY_KEY_ENV, ...numbered]) {
5359
+ const value = env[name]?.trim();
5360
+ if (!value || seen.has(value)) continue;
5361
+ seen.add(value);
5362
+ keys.push({ name, value });
5363
+ }
5364
+ return keys;
5365
+ }
5366
+ function isGeminiRateLimitError(error) {
5367
+ if (!(error instanceof HttpError)) return false;
5368
+ if (error.status === 429) return true;
5369
+ return (error.status === 400 || error.status === 403) && error.snippet.includes("RESOURCE_EXHAUSTED");
5370
+ }
5371
+ function cooldownMs(error) {
5372
+ if (!(error instanceof HttpError)) return DEFAULT_COOLDOWN_MS;
5373
+ if (error.retryAfterMs !== void 0 && error.retryAfterMs > 0) return error.retryAfterMs;
5374
+ const delay = /"retryDelay"\s*:\s*"(\d+(?:\.\d+)?)s"/.exec(error.snippet);
5375
+ if (delay?.[1]) return Math.ceil(Number(delay[1]) * 1e3);
5376
+ return DEFAULT_COOLDOWN_MS;
5377
+ }
5378
+ function keysToTry(keys, now) {
5379
+ const until = (k) => cooldownUntil.get(k.value) ?? 0;
5380
+ const ready = keys.filter((k) => until(k) <= now);
5381
+ if (ready.length > 0) return ready;
5382
+ const soonest = [...keys].sort((a, b) => until(a) - until(b))[0];
5383
+ return soonest ? [soonest] : [];
5384
+ }
5385
+ async function withGeminiKey(fn, opts = {}) {
5386
+ const now = opts.now ?? Date.now;
5387
+ const keys = listGeminiKeys(opts.env);
5388
+ if (keys.length === 0) {
5389
+ throw new ConfigError(
5390
+ "GEMINI_API_KEY is not set. Get one at https://aistudio.google.com/apikey"
5391
+ );
5392
+ }
5393
+ const ordered = keysToTry(keys, now());
5394
+ let lastError;
5395
+ for (const [i, key] of ordered.entries()) {
5396
+ try {
5397
+ return await fn(key.value, key.name);
5398
+ } catch (error) {
5399
+ if (!isGeminiRateLimitError(error)) throw error;
5400
+ lastError = error;
5401
+ cooldownUntil.set(key.value, now() + cooldownMs(error));
5402
+ const next = ordered[i + 1];
5403
+ if (next) opts.logger?.warn(`${key.name} is rate-limited; retrying with ${next.name}`);
5404
+ }
5405
+ }
5406
+ if (keys.length > 1) {
5407
+ opts.logger?.warn(`All ${keys.length} Gemini keys are rate-limited; try again later`);
5408
+ }
5409
+ throw lastError;
5410
+ }
5411
+
5412
+ // src/providers/gemini/local-files.ts
5413
+ import fs21 from "fs";
5414
+ import path25 from "path";
5415
+ init_errors();
5416
+
5417
+ // src/providers/gemini/task-resolver.ts
5418
+ import path24 from "path";
5419
+ var MIME_MAP = {
5420
+ // Audio
5421
+ ".mp3": "audio/mp3",
5422
+ ".wav": "audio/wav",
5423
+ ".aac": "audio/aac",
5424
+ ".flac": "audio/flac",
5425
+ ".ogg": "audio/ogg",
5426
+ ".aiff": "audio/aiff",
5427
+ ".m4a": "audio/mp4",
5428
+ // Image
5429
+ ".jpg": "image/jpeg",
5430
+ ".jpeg": "image/jpeg",
5431
+ ".png": "image/png",
5432
+ ".webp": "image/webp",
5433
+ ".heic": "image/heic",
5434
+ ".heif": "image/heif",
5435
+ ".gif": "image/gif",
5436
+ ".bmp": "image/bmp",
5437
+ // Video
5438
+ ".mp4": "video/mp4",
5439
+ ".mpeg": "video/mpeg",
5440
+ ".mov": "video/quicktime",
5441
+ ".avi": "video/x-msvideo",
5442
+ ".flv": "video/x-flv",
5443
+ ".mpg": "video/mpeg",
5444
+ ".webm": "video/webm",
5445
+ ".wmv": "video/x-ms-wmv",
5446
+ ".3gpp": "video/3gpp",
5447
+ // Document
5448
+ ".pdf": "application/pdf",
5449
+ ".txt": "text/plain",
5450
+ ".html": "text/html",
5451
+ ".htm": "text/html",
5452
+ ".md": "text/markdown",
5453
+ ".csv": "text/csv",
5454
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
5455
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
5456
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
5457
+ };
5458
+ function getMimeType(filePath) {
5459
+ const ext = path24.extname(filePath).toLowerCase();
5460
+ return MIME_MAP[ext] ?? "application/octet-stream";
5461
+ }
5462
+
5463
+ // src/providers/gemini/local-files.ts
5464
+ var INLINE_MAX_BYTES = 20 * 1024 * 1024;
5465
+ var UPLOAD_MIN_KBPS_ENV = "MULTIX_UPLOAD_MIN_KBPS";
5466
+ function minUploadBytesPerSec() {
5467
+ const raw = resolveKey(UPLOAD_MIN_KBPS_ENV)?.trim();
5468
+ if (!raw) return DEFAULT_MIN_UPLOAD_BYTES_PER_SEC;
5469
+ const kbps = Number(raw);
5470
+ if (!Number.isFinite(kbps) || kbps < 1) {
5471
+ throw new ConfigError(
5472
+ `${UPLOAD_MIN_KBPS_ENV} must be a number of kilobytes per second, at least 1, got "${raw}"`
5473
+ );
5474
+ }
5475
+ return kbps * 1e3;
5476
+ }
5477
+ async function uploadLocalFile(filePath, opts = {}) {
5478
+ const bytesPerSec = minUploadBytesPerSec();
5479
+ const blob = await fs21.openAsBlob(filePath);
5480
+ const source = {
5481
+ blob,
5482
+ displayName: path25.basename(filePath),
5483
+ mimeType: getMimeType(filePath),
5484
+ label: filePath
5485
+ };
5486
+ const upload = (apiKey) => uploadFile(source, { ...opts, apiKey, minUploadBytesPerSec: bytesPerSec });
5487
+ return opts.apiKey ? upload(opts.apiKey) : withGeminiKey(upload, { logger: opts.logger });
5488
+ }
5273
5489
  function inlinePart(filePath) {
5274
5490
  const data = fs21.readFileSync(filePath).toString("base64");
5275
5491
  const mimeType = getMimeType(filePath);
@@ -5277,10 +5493,7 @@ function inlinePart(filePath) {
5277
5493
  }
5278
5494
  function shouldUseFileApi(filePath) {
5279
5495
  const stat = fs21.statSync(filePath);
5280
- return stat.size > 20 * 1024 * 1024;
5281
- }
5282
- function sleep2(ms) {
5283
- return new Promise((resolve) => setTimeout(resolve, ms));
5496
+ return stat.size > INLINE_MAX_BYTES;
5284
5497
  }
5285
5498
 
5286
5499
  // src/providers/gemini/models.ts
@@ -5416,7 +5629,7 @@ function registerAnalyzeCommand(parent) {
5416
5629
  let contentParts;
5417
5630
  if (useApi) {
5418
5631
  logger.debug("File >20MB \u2014 using Files API");
5419
- const ref = await uploadFile(filePath, { logger, apiKey });
5632
+ const ref = await uploadLocalFile(filePath, { logger, apiKey });
5420
5633
  contentParts = [
5421
5634
  { text: opts.prompt },
5422
5635
  { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
@@ -5479,7 +5692,7 @@ function registerExtractCommand(parent) {
5479
5692
  const useApi = shouldUseFileApi(filePath);
5480
5693
  let contentParts;
5481
5694
  if (useApi) {
5482
- const ref = await uploadFile(filePath, { logger, apiKey });
5695
+ const ref = await uploadLocalFile(filePath, { logger, apiKey });
5483
5696
  contentParts = [
5484
5697
  { text: opts.prompt },
5485
5698
  { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
@@ -5605,12 +5818,15 @@ async function generateGeminiSpeech(opts) {
5605
5818
  logger?.debug(`Gemini TTS model=${model} mode=${speakers?.length ? "multi" : "single"}`);
5606
5819
  let resp;
5607
5820
  try {
5608
- resp = await generateContent(
5609
- {
5610
- model,
5611
- contents: [{ parts: [{ text }] }],
5612
- generationConfig
5613
- },
5821
+ resp = await withGeminiKey(
5822
+ (apiKey) => generateContent(
5823
+ {
5824
+ model,
5825
+ contents: [{ parts: [{ text }] }],
5826
+ generationConfig
5827
+ },
5828
+ { apiKey, logger }
5829
+ ),
5614
5830
  { logger }
5615
5831
  );
5616
5832
  } catch (e) {
@@ -5648,7 +5864,10 @@ async function generateInteractionsSpeech(opts) {
5648
5864
  }
5649
5865
  let resp;
5650
5866
  try {
5651
- resp = await createInteraction(buildInteractionsSpeechBody(opts), { logger });
5867
+ resp = await withGeminiKey(
5868
+ (apiKey) => createInteraction(buildInteractionsSpeechBody(opts), { apiKey, logger }),
5869
+ { logger }
5870
+ );
5652
5871
  } catch (e) {
5653
5872
  return { status: "error", error: e instanceof Error ? e.message : String(e) };
5654
5873
  }
@@ -6006,12 +6225,15 @@ function registerGenerateCommand(parent) {
6006
6225
  const savedFiles = [];
6007
6226
  for (let i = 0; i < numImages; i++) {
6008
6227
  try {
6009
- const resp = await generateContent(
6010
- {
6011
- model,
6012
- contents: [{ parts: [{ text: opts.prompt }] }],
6013
- generationConfig
6014
- },
6228
+ const resp = await withGeminiKey(
6229
+ (apiKey) => generateContent(
6230
+ {
6231
+ model,
6232
+ contents: [{ parts: [{ text: opts.prompt }] }],
6233
+ generationConfig
6234
+ },
6235
+ { apiKey, logger }
6236
+ ),
6015
6237
  { logger }
6016
6238
  );
6017
6239
  const images = extractImages(resp);
@@ -6101,7 +6323,9 @@ function registerGeminiImageToImageCommand(parent) {
6101
6323
  contents: [{ parts }],
6102
6324
  generationConfig: { responseModalities: ["IMAGE"] }
6103
6325
  };
6104
- const resp = await generateContent(req, { logger });
6326
+ const resp = await withGeminiKey((apiKey) => generateContent(req, { apiKey, logger }), {
6327
+ logger
6328
+ });
6105
6329
  const images = extractImages(resp);
6106
6330
  if (images.length === 0) {
6107
6331
  logger.error(
@@ -6234,7 +6458,7 @@ function registerTranscribeCommand2(parent) {
6234
6458
  let contentParts;
6235
6459
  if (useApi) {
6236
6460
  logger.debug("File >20MB \u2014 using Files API");
6237
- const ref = await uploadFile(filePath, { logger, apiKey });
6461
+ const ref = await uploadLocalFile(filePath, { logger, apiKey });
6238
6462
  contentParts = [
6239
6463
  { text: opts.prompt },
6240
6464
  { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
@@ -6291,6 +6515,11 @@ function registerGeminiCommands(program2) {
6291
6515
 
6292
6516
  // src/providers/heygen/models.ts
6293
6517
  var DEFAULT_HEYGEN_BASE_URL = "https://api.heygen.com";
6518
+ var HEYGEN_ROUTING = {
6519
+ name: "HEYGEN_BASE_URL",
6520
+ defaultUrl: DEFAULT_HEYGEN_BASE_URL,
6521
+ keyEnvs: ["HEYGEN_API_KEY"]
6522
+ };
6294
6523
  var HEYGEN_API_SETTINGS_URL = "https://app.heygen.com/developers/api";
6295
6524
  var HEYGEN_USAGE_URL = "https://app.heygen.com/developers/usage";
6296
6525
  var AVATAR_VIDEO_RESOLUTIONS = ["4k", "1080p", "720p"];
@@ -7312,7 +7541,7 @@ function qualityMode(value) {
7312
7541
 
7313
7542
  // src/providers/heygen/node-helpers.ts
7314
7543
  function requireHeygenKey() {
7315
- const key = resolveKey("HEYGEN_API_KEY")?.trim();
7544
+ const key = resolveRoutedKey(HEYGEN_ROUTING);
7316
7545
  if (!key) {
7317
7546
  throw new ConfigError(
7318
7547
  `HEYGEN_API_KEY is not set. Create an API key at ${HEYGEN_API_SETTINGS_URL} (API usage is billed separately from HeyGen web plans).`
@@ -7322,13 +7551,10 @@ function requireHeygenKey() {
7322
7551
  }
7323
7552
  function createHeygenClientFromEnv() {
7324
7553
  const apiKey = requireHeygenKey();
7325
- const override = resolveKey("HEYGEN_BASE_URL")?.trim();
7326
- return new HeygenClient({
7327
- apiKey,
7328
- baseUrl: override ? checkBaseUrl(override, "HEYGEN_BASE_URL") : DEFAULT_HEYGEN_BASE_URL
7329
- });
7554
+ const baseUrl = resolveProviderBaseUrl(HEYGEN_ROUTING);
7555
+ return new HeygenClient({ apiKey, baseUrl: checkBaseUrl(baseUrl, "HEYGEN_BASE_URL") });
7330
7556
  }
7331
- async function uploadLocalFile(client, filePath, logger) {
7557
+ async function uploadLocalFile2(client, filePath, logger) {
7332
7558
  const abs = path32.resolve(filePath);
7333
7559
  let stat;
7334
7560
  try {
@@ -7364,7 +7590,7 @@ async function resolveMediaInput(client, value, logger) {
7364
7590
  const ref = classifyMediaRef(value);
7365
7591
  if (ref.kind === "url") return { type: "url", url: ref.url };
7366
7592
  if (ref.kind === "asset") return { type: "asset_id", asset_id: ref.assetId };
7367
- return uploadLocalFile(client, ref.path, logger);
7593
+ return uploadLocalFile2(client, ref.path, logger);
7368
7594
  }
7369
7595
  async function resolveMediaInputs(client, values, logger) {
7370
7596
  const out = [];
@@ -8186,6 +8412,11 @@ init_errors();
8186
8412
 
8187
8413
  // src/providers/leonardo/models.ts
8188
8414
  var DEFAULT_LEONARDO_BASE_URL = "https://cloud.leonardo.ai/api/rest/v1";
8415
+ var LEONARDO_ROUTING = {
8416
+ name: "LEONARDO_BASE_URL",
8417
+ defaultUrl: DEFAULT_LEONARDO_BASE_URL,
8418
+ keyEnvs: ["LEONARDO_API_KEY"]
8419
+ };
8189
8420
  var DEFAULT_LEONARDO_MODEL_ID = "7b592283-e8a7-4c5a-9ba6-d18c31f258b9";
8190
8421
  var LEONARDO_DEFAULTS = {
8191
8422
  modelId: process.env.LEONARDO_DEFAULT_MODEL ?? DEFAULT_LEONARDO_MODEL_ID,
@@ -8234,7 +8465,7 @@ function isGptImageModel(modelId) {
8234
8465
 
8235
8466
  // src/providers/leonardo/client.ts
8236
8467
  function requireLeonardoKey() {
8237
- const key = resolveKey("LEONARDO_API_KEY");
8468
+ const key = resolveRoutedKey(LEONARDO_ROUTING);
8238
8469
  if (!key) {
8239
8470
  throw new ConfigError(
8240
8471
  "LEONARDO_API_KEY is not set. Get one at https://app.leonardo.ai/settings/api-keys"
@@ -8243,7 +8474,7 @@ function requireLeonardoKey() {
8243
8474
  return key;
8244
8475
  }
8245
8476
  function leonardoBaseUrl() {
8246
- return resolveKey("LEONARDO_BASE_URL") ?? DEFAULT_LEONARDO_BASE_URL;
8477
+ return resolveProviderBaseUrl(LEONARDO_ROUTING);
8247
8478
  }
8248
8479
  var LeonardoClient = class {
8249
8480
  constructor(apiKey, baseUrl = leonardoBaseUrl()) {
@@ -10686,6 +10917,11 @@ function registerOpenRouterCommands(program2) {
10686
10917
  // src/providers/replicate/models.ts
10687
10918
  init_errors();
10688
10919
  var DEFAULT_REPLICATE_BASE_URL = "https://api.replicate.com/v1";
10920
+ var REPLICATE_ROUTING = {
10921
+ name: "REPLICATE_BASE_URL",
10922
+ defaultUrl: DEFAULT_REPLICATE_BASE_URL,
10923
+ keyEnvs: ["REPLICATE_API_TOKEN", "REPLICATE_API_KEY"]
10924
+ };
10689
10925
  var DEFAULT_REPLICATE_IMAGE_MODEL = "black-forest-labs/flux-schnell";
10690
10926
  var DEFAULT_REPLICATE_VIDEO_MODEL = "prunaai/p-video";
10691
10927
  var TERMINAL_STATUSES = [
@@ -11009,10 +11245,9 @@ var ReplicateClient = class {
11009
11245
  };
11010
11246
 
11011
11247
  // src/providers/replicate/node-client.ts
11012
- var REPLICATE_TOKEN_ENV = "REPLICATE_API_TOKEN";
11013
- var REPLICATE_TOKEN_FALLBACK_ENV = "REPLICATE_API_KEY";
11248
+ var [REPLICATE_TOKEN_ENV, REPLICATE_TOKEN_FALLBACK_ENV] = REPLICATE_ROUTING.keyEnvs;
11014
11249
  function resolveReplicateToken() {
11015
- const found = resolveFirstKey(REPLICATE_TOKEN_ENV, REPLICATE_TOKEN_FALLBACK_ENV);
11250
+ const found = resolveFirstKey(...REPLICATE_ROUTING.keyEnvs);
11016
11251
  return found ? { token: found.value, envUsed: found.envUsed } : void 0;
11017
11252
  }
11018
11253
  function requireReplicateToken() {
@@ -11025,7 +11260,7 @@ function requireReplicateToken() {
11025
11260
  return resolved.token;
11026
11261
  }
11027
11262
  function replicateBaseUrl() {
11028
- return resolveKey("REPLICATE_BASE_URL")?.trim() || DEFAULT_REPLICATE_BASE_URL;
11263
+ return resolveProviderBaseUrl(REPLICATE_ROUTING);
11029
11264
  }
11030
11265
  function replicateImageModel() {
11031
11266
  return resolveKey("REPLICATE_IMAGE_MODEL")?.trim() || DEFAULT_REPLICATE_IMAGE_MODEL;
@@ -11364,7 +11599,7 @@ async function saveOne(item, predictionId, index, count, opts) {
11364
11599
  fs44.writeFileSync(dest, bytes);
11365
11600
  return dest;
11366
11601
  }
11367
- const warned = opts.warnedHosts;
11602
+ const warned2 = opts.warnedHosts;
11368
11603
  try {
11369
11604
  const { dest } = await downloadToFile(item.url, {
11370
11605
  dest: (response) => {
@@ -11376,8 +11611,8 @@ async function saveOne(item, predictionId, index, count, opts) {
11376
11611
  fetch: opts.fetch,
11377
11612
  validateUrl: (url) => {
11378
11613
  const { host, trusted } = checkOutputUrl(url);
11379
- if (!trusted && !warned.has(host)) {
11380
- warned.add(host);
11614
+ if (!trusted && !warned2.has(host)) {
11615
+ warned2.add(host);
11381
11616
  opts.logger.warn(
11382
11617
  `Output ${item.path} is served from ${host}, not ${REPLICATE_DELIVERY_HOST}`
11383
11618
  );
@@ -11807,6 +12042,10 @@ var FFMPEG_HINT = "ffmpeg not found \u2014 media optimize/split/batch commands u
11807
12042
  var MAGICK_HINT = "ImageMagick (magick) not found \u2014 image optimization unavailable.\n Linux: sudo apt-get install imagemagick\n macOS: brew install imagemagick\n Windows: https://imagemagick.org/script/download.php";
11808
12043
 
11809
12044
  // src/commands/check.ts
12045
+ function keyNames(routing) {
12046
+ const [envPrimary, envFallback] = routing.keyEnvs;
12047
+ return envFallback ? { envPrimary, envFallback } : { envPrimary };
12048
+ }
11810
12049
  var PROVIDERS = [
11811
12050
  {
11812
12051
  name: "Gemini",
@@ -11834,14 +12073,13 @@ var PROVIDERS = [
11834
12073
  },
11835
12074
  {
11836
12075
  name: "Leonardo.Ai",
11837
- envPrimary: "LEONARDO_API_KEY",
12076
+ ...keyNames(LEONARDO_ROUTING),
11838
12077
  optional: true,
11839
12078
  link: "https://app.leonardo.ai/settings/api-keys"
11840
12079
  },
11841
12080
  {
11842
12081
  name: "BytePlus",
11843
- envPrimary: "BYTEPLUS_API_KEY",
11844
- envFallback: "ARK_API_KEY",
12082
+ ...keyNames(BYTEPLUS_ROUTING),
11845
12083
  optional: true,
11846
12084
  link: "https://console.byteplus.com/auth/api-keys"
11847
12085
  },
@@ -11853,26 +12091,25 @@ var PROVIDERS = [
11853
12091
  },
11854
12092
  {
11855
12093
  name: "fal.ai",
11856
- envPrimary: "FAL_KEY",
12094
+ ...keyNames(FAL_ROUTING),
11857
12095
  optional: true,
11858
12096
  link: "https://fal.ai/dashboard/keys"
11859
12097
  },
11860
12098
  {
11861
12099
  name: "Atlas Cloud",
11862
- envPrimary: "ATLASCLOUD_API_KEY",
12100
+ ...keyNames(ATLAS_ROUTING),
11863
12101
  optional: true,
11864
12102
  link: "https://www.atlascloud.ai/console/api-keys"
11865
12103
  },
11866
12104
  {
11867
12105
  name: "Replicate",
11868
- envPrimary: "REPLICATE_API_TOKEN",
11869
- envFallback: "REPLICATE_API_KEY",
12106
+ ...keyNames(REPLICATE_ROUTING),
11870
12107
  optional: true,
11871
12108
  link: "https://replicate.com/account/api-tokens"
11872
12109
  },
11873
12110
  {
11874
12111
  name: "HeyGen",
11875
- envPrimary: "HEYGEN_API_KEY",
12112
+ ...keyNames(HEYGEN_ROUTING),
11876
12113
  optional: true,
11877
12114
  link: "https://app.heygen.com/developers/api"
11878
12115
  }
@@ -11887,14 +12124,64 @@ function resolveProviderKey(p) {
11887
12124
  return found ? { key: found.value, envUsed: found.envUsed } : {};
11888
12125
  }
11889
12126
  function cloudflareReadiness() {
11890
- const accountId = resolveKey("CLOUDFLARE_ACCOUNT_ID")?.trim();
11891
- const apiToken = resolveKey("CLOUDFLARE_API_TOKEN")?.trim();
11892
- const gatewayId = resolveKey("CLOUDFLARE_AI_GATEWAY_ID")?.trim();
11893
- const replicateToken = resolveKey("REPLICATE_API_TOKEN")?.trim();
11894
- return {
11895
- native: Boolean(accountId && apiToken),
11896
- video: Boolean(accountId && apiToken && gatewayId && replicateToken)
12127
+ const nativeCheck = inspectCloudflareConfig("native");
12128
+ const videoCheck = inspectCloudflareConfig("video");
12129
+ return { native: !nativeCheck.problem, video: !videoCheck.problem, nativeCheck, videoCheck };
12130
+ }
12131
+ var BASE_URL_ROUTING = [
12132
+ ATLAS_ROUTING,
12133
+ BYTEPLUS_ROUTING,
12134
+ FAL_ROUTING,
12135
+ HEYGEN_ROUTING,
12136
+ LEONARDO_ROUTING,
12137
+ REPLICATE_ROUTING
12138
+ ];
12139
+ function usedFrom(state) {
12140
+ if (state.source === "project") {
12141
+ if (state.basis === "default") return "project .env; same as the default endpoint";
12142
+ if (state.basis === "opt-in") return `project .env; allowed by ${ALLOW_PROJECT_BASE_URL_ENV}`;
12143
+ return "project .env, which also holds every key sent with it";
12144
+ }
12145
+ if (state.source === "user") return "~/.multix/.env";
12146
+ if (state.source === "legacy") return "legacy .claude file";
12147
+ return "shell";
12148
+ }
12149
+ function settingLine(label, state, commands = "commands that use it") {
12150
+ const stops = `${commands} will stop with an error`;
12151
+ if (state.status === "unset") return void 0;
12152
+ if (state.status === "refused") {
12153
+ return {
12154
+ level: "warn",
12155
+ message: `${label} ignored: set in project .env while ${state.reason}; ${stops}`
12156
+ };
12157
+ }
12158
+ if (state.schemeError) return { level: "warn", message: `${state.schemeError}; ${stops}` };
12159
+ if (state.status === "used") {
12160
+ return { level: "info", message: `${label} in effect (from ${usedFrom(state)})` };
12161
+ }
12162
+ const why = state.reason === "it is empty" ? "set empty in project .env" : `set in project .env while ${state.reason}`;
12163
+ return { level: "warn", message: `${label} ignored: ${why}; using ~/.multix/.env` };
12164
+ }
12165
+ var sameState = (a, b) => JSON.stringify(a) === JSON.stringify(b);
12166
+ function routingSettingReport() {
12167
+ const lines = [];
12168
+ const push = (line) => {
12169
+ if (line) lines.push(line);
11897
12170
  };
12171
+ for (const routing of BASE_URL_ROUTING) {
12172
+ push(settingLine(routing.name, inspectProviderBaseUrl(routing)));
12173
+ }
12174
+ const { nativeCheck, videoCheck } = cloudflareReadiness();
12175
+ for (const [name, key] of [
12176
+ [CLOUDFLARE_ACCOUNT_ID_ENV, "accountId"],
12177
+ [CLOUDFLARE_GATEWAY_ID_ENV, "gatewayId"]
12178
+ ]) {
12179
+ push(settingLine(name, nativeCheck[key]));
12180
+ if (!sameState(nativeCheck[key], videoCheck[key])) {
12181
+ push(settingLine(`${name} (video)`, videoCheck[key], "video commands"));
12182
+ }
12183
+ }
12184
+ return lines;
11898
12185
  }
11899
12186
  async function checkCodexImageReadiness(hasAnyProviderKey, verbose = false, checker = checkCodexAuthenticated) {
11900
12187
  if (hasAnyProviderKey && !verbose) return false;
@@ -11958,18 +12245,31 @@ function registerCheckCommand(program2) {
11958
12245
  }
11959
12246
  }
11960
12247
  const cloudflare = cloudflareReadiness();
12248
+ const blocked = (what) => logger.warn(
12249
+ `${what} will stop: a project .env setting is not trusted (see Endpoint Overrides)`
12250
+ );
11961
12251
  if (cloudflare.native) {
11962
12252
  anyKey = true;
11963
12253
  logger.success("Cloudflare Workers AI credentials configured (image and speech)");
11964
12254
  if (cloudflare.video) {
11965
12255
  logger.success("Cloudflare AI Gateway video credentials configured");
12256
+ } else if (cloudflare.videoCheck.refused) {
12257
+ blocked("Cloudflare video");
11966
12258
  } else {
11967
- logger.info("Cloudflare video needs CLOUDFLARE_AI_GATEWAY_ID and REPLICATE_API_TOKEN");
12259
+ logger.info(`Cloudflare video unavailable: ${cloudflare.videoCheck.problem}`);
11968
12260
  }
12261
+ } else if (cloudflare.nativeCheck.refused) {
12262
+ blocked("Cloudflare image, speech and video");
11969
12263
  } else {
11970
- logger.info(
11971
- "Cloudflare media needs CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN (optional)"
11972
- );
12264
+ logger.info(`Cloudflare media unavailable (optional): ${cloudflare.nativeCheck.problem}`);
12265
+ }
12266
+ const routing = routingSettingReport();
12267
+ if (routing.length > 0) {
12268
+ logger.header("Endpoint Overrides");
12269
+ for (const line of routing) {
12270
+ if (line.level === "warn") logger.warn(line.message);
12271
+ else logger.info(line.message);
12272
+ }
11973
12273
  }
11974
12274
  const shouldCheckCodex = !anyKey || opts.verbose;
11975
12275
  let codexReady = false;
@@ -12096,7 +12396,7 @@ async function convertResolved(inputs, ctx) {
12096
12396
  let contentParts;
12097
12397
  if (useApi) {
12098
12398
  logger?.debug("File >20MB \u2014 using Files API");
12099
- const ref = await uploadFile(filePath, { logger, apiKey });
12399
+ const ref = await uploadLocalFile(filePath, { logger, apiKey });
12100
12400
  contentParts = [
12101
12401
  { text: prompt },
12102
12402
  { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrgoonie/multix",
3
- "version": "0.15.3",
3
+ "version": "1.0.1",
4
4
  "description": "AI multimodal CLI — OpenAI, Gemini, MiniMax, OpenRouter, Leonardo, BytePlus, ElevenLabs, fal.ai, Atlas Cloud, Replicate, HeyGen, ffmpeg, ImageMagick, doc-to-md",
5
5
  "type": "module",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: multix
3
3
  description: AI multimodal CLI — generate and edit images (OpenAI, Codex driver, Gemini Nano Banana, Imagen, MiniMax, OpenRouter, Leonardo, BytePlus Seedream, Cloudflare Workers AI FLUX.1 Schnell, fal.ai FLUX, Replicate FLUX), generate video (Gemini Veo, MiniMax Hailuo, OpenRouter via Veo/Seedance/Kling, Leonardo via Motion/Veo/Kling/Hailuo/Seedance/LTX, BytePlus Seedance, Cloudflare AI Gateway via Replicate, fal.ai queue models, Replicate direct API models incl. P-Video, HeyGen Video Agent and scripted avatar videos), video translation and lipsync (HeyGen), TTS/STT/music (OpenAI, Gemini Flash TTS incl. Gemini 3.8 with delivery-style control, MiniMax, Cloudflare Workers AI MeloTTS, ElevenLabs), 3D models (BytePlus Hyper3D/Hitem3d), analyze/transcribe media, convert documents to Markdown, optimize media via ffmpeg/ImageMagick, and self-update the CLI. Use whenever the user wants to create, edit, or transform images/video/audio/3D/documents from a CLI, mentions any of: OpenAI, Codex image driver, image-to-image, i2i, image edit, watercolor/cyberpunk style transfer, reference image, OpenRouter, Nano Banana, Flux, Seedream, Veo, Hailuo, Seedance, Kling, Cloudflare Workers AI, AI Gateway, fal.ai, Atlas Cloud, Replicate, replicate.com models, Replicate predictions, HeyGen, avatar video, talking head, Video Agent, video translation, dubbing, lipsync, video generation, text-to-video, image-to-video, i2v, ElevenLabs voice cloning, updating/upgrading multix, or asks for batch media optimization.
4
- version: 0.15.3 # x-release-please-version
4
+ version: 1.0.1 # x-release-please-version
5
5
  ---
6
6
 
7
7
  # multix — AI Multimodal CLI
@@ -414,6 +414,7 @@ Requires `cwebp` (libwebp: `brew install webp`, `apt install webp`) on `PATH`.
414
414
  | `CLOUDFLARE_AI_IMAGE_MODEL` / `CLOUDFLARE_AI_TTS_MODEL` | Fixed-model settings: only `@cf/black-forest-labs/flux-1-schnell` and `@cf/myshell-ai/melotts` respectively are accepted |
415
415
  | `MULTIX_DISABLE_HOME_ENV` | `1` to skip every home-directory env file (`~/.multix/.env`, `~/.claude/...`) |
416
416
  | `MULTIX_LEGACY_CLAUDE_ENV` | `1` to also read legacy `.claude` env files (credential names only) |
417
+ | `MULTIX_ALLOW_PROJECT_BASE_URL` | Set `1` for one command (`MULTIX_ALLOW_PROJECT_BASE_URL=1 multix ...`) to honour `*_BASE_URL`, `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_AI_GATEWAY_ID` from the project `.env` even when the key comes from elsewhere. Without it, a project value is used only when every key sent with it is in the same project file (a project `.env` that ships its own key and base URL then receives that project's prompts and uploaded files, and its key replaces yours, so review a cloned repo's `.env` first); otherwise the `~/.multix/.env` value is used with a warning, or the command stops with a config error. `multix check` shows the effective state. Ignored in a project `.env`; avoid `~/.multix/.env` (it trusts every directory). Non-default base URLs must be `https://` (http only for localhost/127.0.0.1/[::1]) |
417
418
 
418
419
  ## Quick examples for agents
419
420