@mrgoonie/multix 0.14.1 → 0.15.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 +14 -0
- package/README.md +32 -0
- package/dist/cli.js +305 -198
- package/package.json +6 -2
- package/skill/SKILL.md +15 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.15.1](https://github.com/mrgoonie/multix-cli/compare/v0.15.0...v0.15.1) (2026-10-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Performance
|
|
12
|
+
|
|
13
|
+
* **gemini:** stream file uploads with resumable Files API ([#85](https://github.com/mrgoonie/multix-cli/issues/85)) ([b2cc886](https://github.com/mrgoonie/multix-cli/commit/b2cc886cf5bdf960e88aeea5155ae2623547d918))
|
|
14
|
+
|
|
15
|
+
## [0.15.0](https://github.com/mrgoonie/multix-cli/compare/v0.14.1...v0.15.0) (2026-10-11)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
* **mcp:** add Streamable HTTP MCP server for Cloudflare Workers ([#91](https://github.com/mrgoonie/multix-cli/issues/91)) ([45a662c](https://github.com/mrgoonie/multix-cli/commit/45a662c638d9211009e429bc129edc913a010729))
|
|
21
|
+
|
|
8
22
|
## [0.14.1](https://github.com/mrgoonie/multix-cli/compare/v0.14.0...v0.14.1) (2026-10-11)
|
|
9
23
|
|
|
10
24
|
|
package/README.md
CHANGED
|
@@ -99,6 +99,7 @@ Set at least one provider key. Add to `.env` in your project root or `~/.multix/
|
|
|
99
99
|
| `CLOUDFLARE_AI_TTS_MODEL` | No | Speech model setting; only `@cf/myshell-ai/melotts` is accepted |
|
|
100
100
|
| `MULTIX_OUTPUT_DIR` | No | Override default output dir (`./multix-output`) |
|
|
101
101
|
| `MULTIX_MAX_DOWNLOAD_MB` | No | Size limit per downloaded Replicate output file (default `2048`) |
|
|
102
|
+
| `MULTIX_UPLOAD_MIN_KBPS` | No | Slowest Gemini Files API upload speed to allow for, in KB/s (default `1000`); lower it on slow links |
|
|
102
103
|
| `MULTIX_IMAGE_FORMAT` | No | `webp` (default) or `original` to keep provider image format/quality |
|
|
103
104
|
| `OPENROUTER_IMAGE_MODEL` | No | Default OpenRouter model |
|
|
104
105
|
| `OPENROUTER_FALLBACK_MODELS` | No | Comma-separated fallback model ids |
|
|
@@ -674,6 +675,37 @@ Thumbnail detection is automatic: if the provider response includes any of `cove
|
|
|
674
675
|
|
|
675
676
|
Cloudflare video is intentionally separate: it has no thumbnail flags, and `generate-video --wait` already saves the completed MP4. See [`multix cloudflare`](#multix-cloudflare) for its exact polling and download behavior.
|
|
676
677
|
|
|
678
|
+
## Remote MCP server (Cloudflare Workers)
|
|
679
|
+
|
|
680
|
+
`worker/` contains an optional, stateless MCP server that exposes remote-safe
|
|
681
|
+
image and video tools over Streamable HTTP at `/mcp`, for agents that cannot
|
|
682
|
+
run the CLI locally. It is a separate package and is not included in the npm
|
|
683
|
+
CLI.
|
|
684
|
+
|
|
685
|
+
- Tools: `multix_list_providers`, `multix_generate_image` (Replicate, Atlas
|
|
686
|
+
Cloud), `multix_generate_video` (Replicate, Atlas Cloud, HeyGen, Cloudflare
|
|
687
|
+
AI Gateway; returns an ID without blocking), `multix_prediction_status`,
|
|
688
|
+
`multix_prediction_cancel` (Replicate). Results are provider URLs, not bytes.
|
|
689
|
+
- Auth: `Authorization: Bearer <MCP_AUTH_TOKEN>` (at least 32 characters; the
|
|
690
|
+
server returns `503` if it is unset). `/health` is public.
|
|
691
|
+
- Single-tenant: provider keys are Worker secrets, and every caller spends the
|
|
692
|
+
deployer's credits. Limit exposure with `MULTIX_MCP_ALLOWED_PROVIDERS`,
|
|
693
|
+
`MULTIX_MCP_ALLOWED_MODELS`, `MULTIX_MCP_MAX_OUTPUTS`,
|
|
694
|
+
`MULTIX_MCP_MAX_VIDEO_SECONDS` (also sent as `min(5, cap)` when a caller
|
|
695
|
+
omits `duration`), `MULTIX_MCP_ALLOWED_RESOLUTIONS` (default
|
|
696
|
+
`480p,720p,1080p`), `MULTIX_MCP_ALLOWED_ORIGINS` (browser origins, with
|
|
697
|
+
CORS), and the approximate per-IP rate limiter. JSON-RPC batches are
|
|
698
|
+
rejected.
|
|
699
|
+
|
|
700
|
+
```bash
|
|
701
|
+
npm ci --prefix worker
|
|
702
|
+
cd worker && openssl rand -hex 32 | npx wrangler secret put MCP_AUTH_TOKEN
|
|
703
|
+
npx wrangler secret put REPLICATE_API_TOKEN # and any other provider keys
|
|
704
|
+
cd .. && npm run deploy:mcp # or: npm run dev:mcp (local, port 8787)
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
Full guide: [Remote MCP server](https://multix.zuey.me/guides/mcp-server/).
|
|
708
|
+
|
|
677
709
|
## Troubleshooting
|
|
678
710
|
|
|
679
711
|
**No API key found:** Run `multix check` — it prints setup instructions.
|
package/dist/cli.js
CHANGED
|
@@ -46,13 +46,14 @@ var init_errors = __esm({
|
|
|
46
46
|
"use strict";
|
|
47
47
|
init_redact();
|
|
48
48
|
MultixError = class extends Error {
|
|
49
|
-
constructor(message, code) {
|
|
50
|
-
super(message);
|
|
49
|
+
constructor(message, code, options) {
|
|
50
|
+
super(message, options);
|
|
51
51
|
this.code = code;
|
|
52
52
|
this.name = "MultixError";
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
Error.captureStackTrace?.(
|
|
54
|
+
this,
|
|
55
|
+
this.constructor
|
|
56
|
+
);
|
|
56
57
|
}
|
|
57
58
|
code;
|
|
58
59
|
};
|
|
@@ -63,8 +64,8 @@ var init_errors = __esm({
|
|
|
63
64
|
}
|
|
64
65
|
};
|
|
65
66
|
ProviderError = class extends MultixError {
|
|
66
|
-
constructor(message, provider) {
|
|
67
|
-
super(message, "PROVIDER_ERROR");
|
|
67
|
+
constructor(message, provider, options) {
|
|
68
|
+
super(message, "PROVIDER_ERROR", options);
|
|
68
69
|
this.provider = provider;
|
|
69
70
|
this.name = "ProviderError";
|
|
70
71
|
}
|
|
@@ -77,8 +78,8 @@ var init_errors = __esm({
|
|
|
77
78
|
}
|
|
78
79
|
};
|
|
79
80
|
HttpError = class extends MultixError {
|
|
80
|
-
constructor(status, snippet, url, retryAfterMs) {
|
|
81
|
-
super(`HTTP ${status} from ${redactUrl(url)}: ${snippet}`, "HTTP_ERROR");
|
|
81
|
+
constructor(status, snippet, url, retryAfterMs, options) {
|
|
82
|
+
super(`HTTP ${status} from ${redactUrl(url)}: ${snippet}`, "HTTP_ERROR", options);
|
|
82
83
|
this.status = status;
|
|
83
84
|
this.snippet = snippet;
|
|
84
85
|
this.retryAfterMs = retryAfterMs;
|
|
@@ -640,8 +641,8 @@ function predictionErrorMessage(error) {
|
|
|
640
641
|
}
|
|
641
642
|
return "";
|
|
642
643
|
}
|
|
643
|
-
function collectOutputs(
|
|
644
|
-
const raw =
|
|
644
|
+
function collectOutputs(prediction) {
|
|
645
|
+
const raw = prediction.outputs ?? prediction.output;
|
|
645
646
|
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
|
646
647
|
const out = [];
|
|
647
648
|
for (const item of list) {
|
|
@@ -751,16 +752,16 @@ function defaultExtensionFor(category) {
|
|
|
751
752
|
return "bin";
|
|
752
753
|
}
|
|
753
754
|
}
|
|
754
|
-
function classifyOutputs(
|
|
755
|
-
const outputs = collectOutputs(
|
|
755
|
+
function classifyOutputs(prediction) {
|
|
756
|
+
const outputs = collectOutputs(prediction);
|
|
756
757
|
if (outputs.length === 0) return { mode: "empty" };
|
|
757
758
|
const urls = outputs.filter(isMediaOutput);
|
|
758
759
|
const texts = outputs.filter((o) => !isMediaOutput(o));
|
|
759
760
|
if (urls.length === 0) return { mode: "text", texts };
|
|
760
761
|
return { mode: "media", urls, texts };
|
|
761
762
|
}
|
|
762
|
-
function hasNsfwFlag(
|
|
763
|
-
return Array.isArray(
|
|
763
|
+
function hasNsfwFlag(prediction) {
|
|
764
|
+
return Array.isArray(prediction.has_nsfw_contents) && prediction.has_nsfw_contents.some(Boolean);
|
|
764
765
|
}
|
|
765
766
|
function outputFileName(predictionId, index, ext) {
|
|
766
767
|
const prefix = predictionId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 8) || "output";
|
|
@@ -773,10 +774,10 @@ var DEFAULT_UPLOAD_TIMEOUT_MS = 3e5;
|
|
|
773
774
|
var CATALOG_TIMEOUT_MS = 15e3;
|
|
774
775
|
var PREDICTION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
775
776
|
var AtlasPredictionFailedError = class extends ProviderError {
|
|
776
|
-
constructor(
|
|
777
|
-
const reason = predictionErrorMessage(
|
|
778
|
-
super(`Atlas Cloud prediction ${
|
|
779
|
-
this.prediction =
|
|
777
|
+
constructor(prediction) {
|
|
778
|
+
const reason = predictionErrorMessage(prediction.error) || `status "${prediction.status}"`;
|
|
779
|
+
super(`Atlas Cloud prediction ${prediction.id} failed: ${reason}`, ATLAS_PROVIDER);
|
|
780
|
+
this.prediction = prediction;
|
|
780
781
|
this.name = "AtlasPredictionFailedError";
|
|
781
782
|
}
|
|
782
783
|
prediction;
|
|
@@ -786,11 +787,11 @@ function assertValidPredictionId(id) {
|
|
|
786
787
|
throw new ValidationError(`Invalid Atlas Cloud prediction id: "${id}"`);
|
|
787
788
|
}
|
|
788
789
|
}
|
|
789
|
-
function assertPredictionSucceeded(
|
|
790
|
-
if (isFailureStatus(
|
|
791
|
-
if (!isSuccessStatus(
|
|
790
|
+
function assertPredictionSucceeded(prediction) {
|
|
791
|
+
if (isFailureStatus(prediction.status)) throw new AtlasPredictionFailedError(prediction);
|
|
792
|
+
if (!isSuccessStatus(prediction.status)) {
|
|
792
793
|
throw new ProviderError(
|
|
793
|
-
`Atlas Cloud prediction ${
|
|
794
|
+
`Atlas Cloud prediction ${prediction.id} is not finished (status "${prediction.status}")`,
|
|
794
795
|
ATLAS_PROVIDER
|
|
795
796
|
);
|
|
796
797
|
}
|
|
@@ -1694,22 +1695,22 @@ function planOutputPaths(urls, cfg) {
|
|
|
1694
1695
|
const base = abs.slice(0, -path7.extname(abs).length);
|
|
1695
1696
|
return exts.map((ext, i) => `${base}-${String(i + 1).padStart(2, "0")}.${ext}`);
|
|
1696
1697
|
}
|
|
1697
|
-
async function deliverPrediction(
|
|
1698
|
+
async function deliverPrediction(prediction, cfg) {
|
|
1698
1699
|
const { logger, opts } = cfg;
|
|
1699
|
-
if (
|
|
1700
|
-
logger.info(`Cost: $${
|
|
1700
|
+
if (prediction.price !== void 0 && prediction.price !== "") {
|
|
1701
|
+
logger.info(`Cost: $${prediction.price}`);
|
|
1701
1702
|
}
|
|
1702
|
-
const classified = classifyOutputs(
|
|
1703
|
+
const classified = classifyOutputs(prediction);
|
|
1703
1704
|
if (classified.mode === "empty") {
|
|
1704
1705
|
throw new ProviderError(
|
|
1705
|
-
hasNsfwFlag(
|
|
1706
|
+
hasNsfwFlag(prediction) ? `Prediction ${prediction.id} returned no output: blocked by content safety (has_nsfw_contents). Try a different prompt or input.` : `Prediction ${prediction.id} completed but returned no outputs.`,
|
|
1706
1707
|
ATLAS_PROVIDER
|
|
1707
1708
|
);
|
|
1708
1709
|
}
|
|
1709
1710
|
for (const text of classified.texts) console.log(text);
|
|
1710
1711
|
if (classified.mode === "text") {
|
|
1711
1712
|
if (opts.output) {
|
|
1712
|
-
const dest = isDirectoryOutput(opts.output) ? path7.resolve(opts.output, outputFileName(
|
|
1713
|
+
const dest = isDirectoryOutput(opts.output) ? path7.resolve(opts.output, outputFileName(prediction.id, 0, "txt")) : path7.resolve(opts.output);
|
|
1713
1714
|
fs8.mkdirSync(path7.dirname(dest), { recursive: true });
|
|
1714
1715
|
fs8.writeFileSync(dest, `${classified.texts.join("\n\n")}
|
|
1715
1716
|
`, "utf8");
|
|
@@ -1723,7 +1724,7 @@ async function deliverPrediction(prediction2, cfg) {
|
|
|
1723
1724
|
return { files: [], texts: classified.texts };
|
|
1724
1725
|
}
|
|
1725
1726
|
const dests = planOutputPaths(classified.urls, {
|
|
1726
|
-
predictionId:
|
|
1727
|
+
predictionId: prediction.id,
|
|
1727
1728
|
outDir: getOutputDir,
|
|
1728
1729
|
output: opts.output,
|
|
1729
1730
|
hint: cfg.hint
|
|
@@ -1751,7 +1752,7 @@ async function deliverPrediction(prediction2, cfg) {
|
|
|
1751
1752
|
}
|
|
1752
1753
|
if (saved.length === 0) {
|
|
1753
1754
|
throw new ProviderError(
|
|
1754
|
-
`Prediction ${
|
|
1755
|
+
`Prediction ${prediction.id} produced ${classified.urls.length} output(s) but none downloaded. Retry with: ${resumeCommand(prediction.id)}`,
|
|
1755
1756
|
ATLAS_PROVIDER
|
|
1756
1757
|
);
|
|
1757
1758
|
}
|
|
@@ -1766,14 +1767,14 @@ async function deliverPrediction(prediction2, cfg) {
|
|
|
1766
1767
|
const files = saved.map((f) => finalImages[images.indexOf(f)] ?? f);
|
|
1767
1768
|
const firstVideo = files.find((f) => outputCategory(f) === "video");
|
|
1768
1769
|
if (firstVideo) {
|
|
1769
|
-
await maybeDownloadThumb(
|
|
1770
|
+
await maybeDownloadThumb(prediction, firstVideo, { skip: opts.thumb === false, logger });
|
|
1770
1771
|
}
|
|
1771
1772
|
console.log(`
|
|
1772
|
-
Prediction ${
|
|
1773
|
+
Prediction ${prediction.id}: saved ${files.length} file(s)`);
|
|
1773
1774
|
for (const f of files) console.log(` ${f}`);
|
|
1774
1775
|
if (failed > 0) {
|
|
1775
1776
|
throw new ProviderError(
|
|
1776
|
-
`Prediction ${
|
|
1777
|
+
`Prediction ${prediction.id}: downloaded ${saved.length} of ${classified.urls.length} output(s); ${failed} failed. Saved: ${files.join(", ")}. Retry with: ${resumeCommand(prediction.id)}`,
|
|
1777
1778
|
ATLAS_PROVIDER
|
|
1778
1779
|
);
|
|
1779
1780
|
}
|
|
@@ -2005,18 +2006,18 @@ function registerAtlasStatusCommand(parent) {
|
|
|
2005
2006
|
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
2006
2007
|
const client = createAtlasClientFromEnv();
|
|
2007
2008
|
const wait = Boolean(opts.wait || opts.download);
|
|
2008
|
-
const
|
|
2009
|
+
const prediction = wait ? await client.waitForPrediction(predictionId, {
|
|
2009
2010
|
timeoutMs: parseTimeoutMs(opts.waitTimeout, STATUS_WAIT_MS),
|
|
2010
2011
|
resumeHint: `Resume with: ${resumeCommand(predictionId)}`,
|
|
2011
2012
|
onPoll: (p, n) => logger.debug(`poll ${n}: ${p.status}`)
|
|
2012
2013
|
}) : await client.getPrediction(predictionId);
|
|
2013
2014
|
if (!opts.download) {
|
|
2014
|
-
console.log(JSON.stringify(
|
|
2015
|
-
if (isFailureStatus(
|
|
2015
|
+
console.log(JSON.stringify(prediction, null, 2));
|
|
2016
|
+
if (isFailureStatus(prediction.status)) throw new AtlasPredictionFailedError(prediction);
|
|
2016
2017
|
return;
|
|
2017
2018
|
}
|
|
2018
|
-
assertPredictionSucceeded(
|
|
2019
|
-
await deliverPrediction(
|
|
2019
|
+
assertPredictionSucceeded(prediction);
|
|
2020
|
+
await deliverPrediction(prediction, { logger, opts: { ...opts, download: true } });
|
|
2020
2021
|
});
|
|
2021
2022
|
}
|
|
2022
2023
|
|
|
@@ -3075,9 +3076,13 @@ init_errors();
|
|
|
3075
3076
|
|
|
3076
3077
|
// src/providers/cloudflare/models.ts
|
|
3077
3078
|
init_errors();
|
|
3079
|
+
|
|
3080
|
+
// src/providers/cloudflare/model-ids.ts
|
|
3078
3081
|
var CLOUDFLARE_IMAGE_MODEL = "@cf/black-forest-labs/flux-1-schnell";
|
|
3079
3082
|
var CLOUDFLARE_TTS_MODEL = "@cf/myshell-ai/melotts";
|
|
3080
3083
|
var CLOUDFLARE_VIDEO_MODEL = "prunaai/p-video";
|
|
3084
|
+
|
|
3085
|
+
// src/providers/cloudflare/models.ts
|
|
3081
3086
|
function required(name) {
|
|
3082
3087
|
const value = resolveKey(name)?.trim();
|
|
3083
3088
|
if (!value) throw new ConfigError(`${name} is required for Cloudflare media generation`);
|
|
@@ -3121,6 +3126,68 @@ function requireCloudflareVideoConfig() {
|
|
|
3121
3126
|
};
|
|
3122
3127
|
}
|
|
3123
3128
|
|
|
3129
|
+
// src/providers/cloudflare/video-client.ts
|
|
3130
|
+
init_errors();
|
|
3131
|
+
function cloudflareProviderFailure(status) {
|
|
3132
|
+
return new ProviderError(`Cloudflare request failed with HTTP ${status}`, "Cloudflare");
|
|
3133
|
+
}
|
|
3134
|
+
async function readCloudflareJson(response) {
|
|
3135
|
+
if (!response.ok) throw cloudflareProviderFailure(response.status);
|
|
3136
|
+
try {
|
|
3137
|
+
return await response.json();
|
|
3138
|
+
} catch {
|
|
3139
|
+
throw new ProviderError("Cloudflare returned an invalid JSON response", "Cloudflare");
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
function videoUrl(config, suffix = "") {
|
|
3143
|
+
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(config.accountId)}/${encodeURIComponent(config.gatewayId)}/replicate/predictions${suffix}`;
|
|
3144
|
+
}
|
|
3145
|
+
function videoHeaders(config, opts) {
|
|
3146
|
+
return {
|
|
3147
|
+
Authorization: `Bearer ${config.replicateApiToken}`,
|
|
3148
|
+
"cf-aig-authorization": `Bearer ${config.apiToken}`,
|
|
3149
|
+
"cf-aig-collect-log-payload": opts.collectLogPayload ? "true" : "false",
|
|
3150
|
+
"Content-Type": "application/json",
|
|
3151
|
+
...opts.wait ? { Prefer: "wait" } : {}
|
|
3152
|
+
};
|
|
3153
|
+
}
|
|
3154
|
+
function send(opts, url, init) {
|
|
3155
|
+
const f = opts.fetch;
|
|
3156
|
+
const withTimeout = {
|
|
3157
|
+
...init,
|
|
3158
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
3159
|
+
};
|
|
3160
|
+
return f ? f(url, withTimeout) : globalThis.fetch(url, withTimeout);
|
|
3161
|
+
}
|
|
3162
|
+
function parseCloudflareVideoPrediction(value) {
|
|
3163
|
+
if (!value || typeof value !== "object") {
|
|
3164
|
+
throw new ProviderError("Cloudflare returned an invalid video prediction", "Cloudflare");
|
|
3165
|
+
}
|
|
3166
|
+
const result = value;
|
|
3167
|
+
if (typeof result.id !== "string" || typeof result.status !== "string") {
|
|
3168
|
+
throw new ProviderError("Cloudflare returned an invalid video prediction", "Cloudflare");
|
|
3169
|
+
}
|
|
3170
|
+
const output = result.output === null ? void 0 : result.output;
|
|
3171
|
+
if (output !== void 0 && typeof output !== "string" && (!Array.isArray(output) || !output.every((item) => typeof item === "string"))) {
|
|
3172
|
+
throw new ProviderError("Cloudflare returned an invalid video output", "Cloudflare");
|
|
3173
|
+
}
|
|
3174
|
+
return { id: result.id, status: result.status, output };
|
|
3175
|
+
}
|
|
3176
|
+
async function createCloudflareVideoPrediction(config, input, opts = {}) {
|
|
3177
|
+
const response = await send(opts, videoUrl(config), {
|
|
3178
|
+
method: "POST",
|
|
3179
|
+
headers: videoHeaders(config, opts),
|
|
3180
|
+
body: JSON.stringify({ version: CLOUDFLARE_VIDEO_MODEL, input })
|
|
3181
|
+
});
|
|
3182
|
+
return parseCloudflareVideoPrediction(await readCloudflareJson(response));
|
|
3183
|
+
}
|
|
3184
|
+
async function getCloudflareVideoPrediction(config, id, opts = {}) {
|
|
3185
|
+
const response = await send(opts, videoUrl(config, `/${encodeURIComponent(id)}`), {
|
|
3186
|
+
headers: videoHeaders(config, { ...opts, wait: false })
|
|
3187
|
+
});
|
|
3188
|
+
return parseCloudflareVideoPrediction(await readCloudflareJson(response));
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3124
3191
|
// src/providers/cloudflare/client.ts
|
|
3125
3192
|
function collectPayloadHeader() {
|
|
3126
3193
|
const envName = ["CLOUDFLARE", "AI", "GATEWAY", "COLLECT", "LOG", "PAYLOAD"].join("_");
|
|
@@ -3134,17 +3201,6 @@ function gatewayHeaders(config) {
|
|
|
3134
3201
|
"cf-aig-collect-log-payload": collectPayloadHeader()
|
|
3135
3202
|
};
|
|
3136
3203
|
}
|
|
3137
|
-
function providerFailure(status) {
|
|
3138
|
-
return new ProviderError(`Cloudflare request failed with HTTP ${status}`, "Cloudflare");
|
|
3139
|
-
}
|
|
3140
|
-
async function readJson(response) {
|
|
3141
|
-
if (!response.ok) throw providerFailure(response.status);
|
|
3142
|
-
try {
|
|
3143
|
-
return await response.json();
|
|
3144
|
-
} catch {
|
|
3145
|
-
throw new ProviderError("Cloudflare returned an invalid JSON response", "Cloudflare");
|
|
3146
|
-
}
|
|
3147
|
-
}
|
|
3148
3204
|
function nativeUrl(accountId, model) {
|
|
3149
3205
|
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/run/${model}`;
|
|
3150
3206
|
}
|
|
@@ -3162,7 +3218,7 @@ async function runCloudflareJson(model, input) {
|
|
|
3162
3218
|
body: JSON.stringify(useGateway ? { model, input } : input)
|
|
3163
3219
|
}
|
|
3164
3220
|
);
|
|
3165
|
-
const payload = await
|
|
3221
|
+
const payload = await readCloudflareJson(response);
|
|
3166
3222
|
if (payload.success === false || payload.result === void 0) {
|
|
3167
3223
|
throw new ProviderError("Cloudflare returned no usable result", "Cloudflare");
|
|
3168
3224
|
}
|
|
@@ -3179,53 +3235,23 @@ async function runCloudflareBytes(model, input) {
|
|
|
3179
3235
|
body: JSON.stringify(useGateway ? { model, input } : input)
|
|
3180
3236
|
}
|
|
3181
3237
|
);
|
|
3182
|
-
if (!response.ok) throw
|
|
3238
|
+
if (!response.ok) throw cloudflareProviderFailure(response.status);
|
|
3183
3239
|
const contentType = response.headers.get("content-type") ?? "";
|
|
3184
3240
|
if (!contentType.includes("audio/mpeg")) {
|
|
3185
3241
|
throw new ProviderError("Cloudflare did not return MPEG audio", "Cloudflare");
|
|
3186
3242
|
}
|
|
3187
3243
|
return new Uint8Array(await response.arrayBuffer());
|
|
3188
3244
|
}
|
|
3189
|
-
function videoUrl(config, suffix = "") {
|
|
3190
|
-
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(config.accountId)}/${encodeURIComponent(config.gatewayId)}/replicate/predictions${suffix}`;
|
|
3191
|
-
}
|
|
3192
|
-
function videoHeaders(config, wait = false) {
|
|
3193
|
-
return {
|
|
3194
|
-
Authorization: `Bearer ${config.replicateApiToken}`,
|
|
3195
|
-
"cf-aig-authorization": `Bearer ${config.apiToken}`,
|
|
3196
|
-
"cf-aig-collect-log-payload": collectPayloadHeader(),
|
|
3197
|
-
"Content-Type": "application/json",
|
|
3198
|
-
...wait ? { Prefer: "wait" } : {}
|
|
3199
|
-
};
|
|
3200
|
-
}
|
|
3201
|
-
function prediction(value) {
|
|
3202
|
-
if (!value || typeof value !== "object") {
|
|
3203
|
-
throw new ProviderError("Cloudflare returned an invalid video prediction", "Cloudflare");
|
|
3204
|
-
}
|
|
3205
|
-
const result = value;
|
|
3206
|
-
if (typeof result.id !== "string" || typeof result.status !== "string") {
|
|
3207
|
-
throw new ProviderError("Cloudflare returned an invalid video prediction", "Cloudflare");
|
|
3208
|
-
}
|
|
3209
|
-
if (result.output !== void 0 && typeof result.output !== "string" && (!Array.isArray(result.output) || !result.output.every((item) => typeof item === "string"))) {
|
|
3210
|
-
throw new ProviderError("Cloudflare returned an invalid video output", "Cloudflare");
|
|
3211
|
-
}
|
|
3212
|
-
return { id: result.id, status: result.status, output: result.output };
|
|
3213
|
-
}
|
|
3214
3245
|
async function createVideoPrediction(input, wait = false) {
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
headers: videoHeaders(config, wait),
|
|
3219
|
-
body: JSON.stringify({ version: CLOUDFLARE_VIDEO_MODEL, input })
|
|
3246
|
+
return createCloudflareVideoPrediction(requireCloudflareVideoConfig(), input, {
|
|
3247
|
+
wait,
|
|
3248
|
+
collectLogPayload: collectPayloadHeader() === "true"
|
|
3220
3249
|
});
|
|
3221
|
-
return prediction(await readJson(response));
|
|
3222
3250
|
}
|
|
3223
3251
|
async function getVideoPrediction(id) {
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
headers: videoHeaders(config)
|
|
3252
|
+
return getCloudflareVideoPrediction(requireCloudflareVideoConfig(), id, {
|
|
3253
|
+
collectLogPayload: collectPayloadHeader() === "true"
|
|
3227
3254
|
});
|
|
3228
|
-
return prediction(await readJson(response));
|
|
3229
3255
|
}
|
|
3230
3256
|
async function downloadVideo2(url) {
|
|
3231
3257
|
let response;
|
|
@@ -3302,20 +3328,20 @@ function positiveInteger(value, flag) {
|
|
|
3302
3328
|
throw new ValidationError(`${flag} must be a positive integer`);
|
|
3303
3329
|
return parsed;
|
|
3304
3330
|
}
|
|
3305
|
-
function outputUrl(
|
|
3306
|
-
if (typeof
|
|
3307
|
-
return
|
|
3331
|
+
function outputUrl(prediction) {
|
|
3332
|
+
if (typeof prediction.output === "string") return prediction.output;
|
|
3333
|
+
return prediction.output?.find((value) => typeof value === "string");
|
|
3308
3334
|
}
|
|
3309
|
-
async function saveCompletedVideo(
|
|
3310
|
-
const url = outputUrl(
|
|
3335
|
+
async function saveCompletedVideo(prediction, output) {
|
|
3336
|
+
const url = outputUrl(prediction);
|
|
3311
3337
|
if (!url)
|
|
3312
3338
|
throw new ProviderError("Cloudflare completed the video without an output", "Cloudflare");
|
|
3313
3339
|
const bytes = await downloadVideo2(url);
|
|
3314
|
-
return saveBytes(bytes, `cloudflare_video_${
|
|
3340
|
+
return saveBytes(bytes, `cloudflare_video_${prediction.id}.mp4`, output);
|
|
3315
3341
|
}
|
|
3316
|
-
async function waitForVideo(
|
|
3342
|
+
async function waitForVideo(prediction, timeoutMs, onTick) {
|
|
3317
3343
|
const deadline = Date.now() + timeoutMs;
|
|
3318
|
-
let current =
|
|
3344
|
+
let current = prediction;
|
|
3319
3345
|
while (!isTerminalVideoStatus(current.status)) {
|
|
3320
3346
|
if (Date.now() >= deadline)
|
|
3321
3347
|
throw new ProviderError("Cloudflare video polling timed out", "Cloudflare");
|
|
@@ -3347,15 +3373,15 @@ function registerCloudflareVideoCommand(parent) {
|
|
|
3347
3373
|
fps: positiveInteger(opts.fps, "--fps")
|
|
3348
3374
|
};
|
|
3349
3375
|
const shouldWait = Boolean(opts.wait || opts.download);
|
|
3350
|
-
const
|
|
3351
|
-
logger.success(`Video job ${
|
|
3376
|
+
const prediction = await createVideoPrediction(input, shouldWait);
|
|
3377
|
+
logger.success(`Video job ${prediction.id} (${prediction.status})`);
|
|
3352
3378
|
if (!shouldWait) {
|
|
3353
|
-
console.log(
|
|
3354
|
-
console.error(`Poll with: multix cloudflare video-status ${
|
|
3379
|
+
console.log(prediction.id);
|
|
3380
|
+
console.error(`Poll with: multix cloudflare video-status ${prediction.id}`);
|
|
3355
3381
|
return;
|
|
3356
3382
|
}
|
|
3357
|
-
const completed =
|
|
3358
|
-
|
|
3383
|
+
const completed = prediction.status === "succeeded" ? prediction : await waitForVideo(
|
|
3384
|
+
prediction,
|
|
3359
3385
|
positiveInteger(opts.waitTimeout, "--wait-timeout"),
|
|
3360
3386
|
(status) => logger.debug(`Video status: ${status}`)
|
|
3361
3387
|
);
|
|
@@ -3405,12 +3431,12 @@ function registerCloudflareVideoStatusCommand(parent) {
|
|
|
3405
3431
|
async (id, opts) => {
|
|
3406
3432
|
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
3407
3433
|
const shouldWait = Boolean(opts.wait || opts.download);
|
|
3408
|
-
const
|
|
3409
|
-
const completed = shouldWait &&
|
|
3410
|
-
|
|
3434
|
+
const prediction = await getVideoPrediction(id);
|
|
3435
|
+
const completed = shouldWait && prediction.status !== "succeeded" ? await waitForVideo(
|
|
3436
|
+
prediction,
|
|
3411
3437
|
positiveInteger(opts.waitTimeout, "--wait-timeout"),
|
|
3412
3438
|
(status) => logger.debug(`Video status: ${status}`)
|
|
3413
|
-
) :
|
|
3439
|
+
) : prediction;
|
|
3414
3440
|
console.log(`${completed.id}: ${completed.status}`);
|
|
3415
3441
|
if (opts.download) {
|
|
3416
3442
|
if (completed.status !== "succeeded") {
|
|
@@ -4625,11 +4651,20 @@ init_errors();
|
|
|
4625
4651
|
import fs20 from "fs";
|
|
4626
4652
|
import os2 from "os";
|
|
4627
4653
|
import path23 from "path";
|
|
4654
|
+
|
|
4655
|
+
// src/core/limits.ts
|
|
4656
|
+
var FILES_API_MAX_BYTES = 2e9;
|
|
4657
|
+
|
|
4658
|
+
// src/core/stdin-file.ts
|
|
4628
4659
|
var STDIN_ARG = "-";
|
|
4629
4660
|
var STDIN_LABEL = "<stdin>";
|
|
4630
4661
|
var HEAD_BYTES = 4096;
|
|
4631
|
-
var MAX_STDIN_BYTES =
|
|
4662
|
+
var MAX_STDIN_BYTES = FILES_API_MAX_BYTES;
|
|
4632
4663
|
var WAIT_HINT_MS = 2e3;
|
|
4664
|
+
function stdinTooLargeMessage(maxBytes) {
|
|
4665
|
+
const bytes = maxBytes.toLocaleString("en-US");
|
|
4666
|
+
return maxBytes === FILES_API_MAX_BYTES ? `Piped stdin exceeds the Gemini Files API limit of 2 GB (${bytes} bytes)` : `Piped stdin exceeds the ${bytes}-byte limit`;
|
|
4667
|
+
}
|
|
4633
4668
|
var ascii = (buf, start, end) => buf.toString("latin1", start, end);
|
|
4634
4669
|
var HEIF_BRANDS = /* @__PURE__ */ new Set([
|
|
4635
4670
|
"heic",
|
|
@@ -4769,9 +4804,7 @@ async function stdinToTempFile(opts = {}) {
|
|
|
4769
4804
|
clearTimeout(hint);
|
|
4770
4805
|
const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
4771
4806
|
if (size + buf.length > maxBytes) {
|
|
4772
|
-
throw new ValidationError(
|
|
4773
|
-
`Piped stdin exceeds ${Math.floor(maxBytes / 1024 ** 2)} MB; save it to a file instead`
|
|
4774
|
-
);
|
|
4807
|
+
throw new ValidationError(stdinTooLargeMessage(maxBytes));
|
|
4775
4808
|
}
|
|
4776
4809
|
if (head.length < HEAD_BYTES) {
|
|
4777
4810
|
head = Buffer.concat([head, buf.subarray(0, HEAD_BYTES - head.length)]);
|
|
@@ -4833,9 +4866,9 @@ async function resolveFileInputs(files, opts = {}) {
|
|
|
4833
4866
|
}
|
|
4834
4867
|
|
|
4835
4868
|
// src/providers/gemini/client.ts
|
|
4836
|
-
init_errors();
|
|
4837
4869
|
import fs21 from "fs";
|
|
4838
4870
|
import path25 from "path";
|
|
4871
|
+
init_errors();
|
|
4839
4872
|
|
|
4840
4873
|
// src/providers/gemini/key-pool.ts
|
|
4841
4874
|
init_errors();
|
|
@@ -4959,49 +4992,123 @@ function withKey(opts, fn) {
|
|
|
4959
4992
|
function authHeaders2(apiKey) {
|
|
4960
4993
|
return { "x-goog-api-key": apiKey };
|
|
4961
4994
|
}
|
|
4995
|
+
var DEFAULT_TIMEOUT_MS3 = 3e5;
|
|
4996
|
+
var UPLOAD_MIN_KBPS_ENV = "MULTIX_UPLOAD_MIN_KBPS";
|
|
4997
|
+
var DEFAULT_MIN_UPLOAD_BYTES_PER_SEC = 1e6;
|
|
4998
|
+
var UPLOAD_TIMEOUT_SLACK_MS = 6e4;
|
|
4999
|
+
function minUploadBytesPerSec() {
|
|
5000
|
+
const raw = resolveKey(UPLOAD_MIN_KBPS_ENV)?.trim();
|
|
5001
|
+
if (!raw) return DEFAULT_MIN_UPLOAD_BYTES_PER_SEC;
|
|
5002
|
+
const kbps = Number(raw);
|
|
5003
|
+
if (!Number.isFinite(kbps) || kbps < 1) {
|
|
5004
|
+
throw new ConfigError(
|
|
5005
|
+
`${UPLOAD_MIN_KBPS_ENV} must be a number of kilobytes per second, at least 1, got "${raw}"`
|
|
5006
|
+
);
|
|
5007
|
+
}
|
|
5008
|
+
return kbps * 1e3;
|
|
5009
|
+
}
|
|
5010
|
+
function uploadTimeoutMs(size, timeoutMs = DEFAULT_TIMEOUT_MS3, bytesPerSec = DEFAULT_MIN_UPLOAD_BYTES_PER_SEC) {
|
|
5011
|
+
return Math.max(timeoutMs, UPLOAD_TIMEOUT_SLACK_MS + Math.ceil(size / bytesPerSec * 1e3));
|
|
5012
|
+
}
|
|
4962
5013
|
async function uploadFile(filePath, opts = {}) {
|
|
4963
|
-
|
|
5014
|
+
const size = fs21.statSync(filePath).size;
|
|
5015
|
+
if (size > FILES_API_MAX_BYTES) {
|
|
5016
|
+
throw new ValidationError(
|
|
5017
|
+
`File too large for the Gemini Files API: ${path25.basename(filePath)} is ${formatMb3(size)} MB (limit 2 GB = ${FILES_API_MAX_BYTES} bytes)`
|
|
5018
|
+
);
|
|
5019
|
+
}
|
|
5020
|
+
const bytesPerSec = minUploadBytesPerSec();
|
|
5021
|
+
return withKey(opts, (apiKey) => uploadFileWithKey(filePath, apiKey, { ...opts, bytesPerSec }));
|
|
5022
|
+
}
|
|
5023
|
+
function formatMb3(bytes) {
|
|
5024
|
+
return (bytes / 1024 / 1024).toFixed(2);
|
|
5025
|
+
}
|
|
5026
|
+
async function uploadFetch(url, init, timeoutMs, step) {
|
|
5027
|
+
let resp;
|
|
5028
|
+
try {
|
|
5029
|
+
resp = await globalThis.fetch(url, {
|
|
5030
|
+
...init,
|
|
5031
|
+
// With the default "follow", fetch clones the request body (tee) to replay
|
|
5032
|
+
// it on a redirect, and that copy keeps every chunk in memory.
|
|
5033
|
+
redirect: "error",
|
|
5034
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
5035
|
+
});
|
|
5036
|
+
} catch (err2) {
|
|
5037
|
+
const e = err2;
|
|
5038
|
+
const timedOut = e?.name === "TimeoutError";
|
|
5039
|
+
const redirected = e?.cause instanceof Error && e.cause.message === "unexpected redirect";
|
|
5040
|
+
const reason = timedOut ? `timed out after ${Math.round(timeoutMs / 1e3)} s` : e?.cause instanceof Error ? `${e.message}: ${e.cause.message}` : String(e?.message ?? err2);
|
|
5041
|
+
const message = `File upload failed: ${reason}`;
|
|
5042
|
+
if (redirected || timedOut && step === "upload") {
|
|
5043
|
+
throw new ProviderError(message, "gemini", { cause: err2 });
|
|
5044
|
+
}
|
|
5045
|
+
throw new HttpError(0, message, FILES_BASE, void 0, { cause: err2 });
|
|
5046
|
+
}
|
|
5047
|
+
if (!resp.ok) {
|
|
5048
|
+
const txt = await resp.text();
|
|
5049
|
+
throw new HttpError(resp.status, `File upload failed: ${txt.slice(0, 500)}`, FILES_BASE);
|
|
5050
|
+
}
|
|
5051
|
+
return resp;
|
|
5052
|
+
}
|
|
5053
|
+
async function readFinalizedFile(resp) {
|
|
5054
|
+
const status = resp.headers.get("x-goog-upload-status")?.trim().toLowerCase();
|
|
5055
|
+
let file;
|
|
5056
|
+
let readError;
|
|
5057
|
+
try {
|
|
5058
|
+
file = (await resp.json())?.file;
|
|
5059
|
+
} catch (err2) {
|
|
5060
|
+
readError = err2;
|
|
5061
|
+
}
|
|
5062
|
+
if (status !== "final" || typeof file?.uri !== "string" || typeof file?.name !== "string") {
|
|
5063
|
+
const detail = status !== "final" ? `upload status is "${status?.slice(0, 40) ?? "missing"}"` : readError ? "unreadable response body" : "no file in the response";
|
|
5064
|
+
throw new ProviderError(`File upload failed: upload was not finalized (${detail})`, "gemini", {
|
|
5065
|
+
cause: readError
|
|
5066
|
+
});
|
|
5067
|
+
}
|
|
5068
|
+
return file;
|
|
4964
5069
|
}
|
|
4965
5070
|
async function uploadFileWithKey(filePath, apiKey, opts) {
|
|
4966
|
-
const { timeoutMs =
|
|
5071
|
+
const { timeoutMs = DEFAULT_TIMEOUT_MS3, logger, bytesPerSec } = opts;
|
|
4967
5072
|
const mimeType = getMimeType(filePath);
|
|
4968
|
-
const fileBytes = fs21.readFileSync(filePath);
|
|
4969
5073
|
const displayName = path25.basename(filePath);
|
|
4970
|
-
const
|
|
4971
|
-
|
|
4972
|
-
const
|
|
4973
|
-
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
--${boundary}--`)
|
|
4986
|
-
]);
|
|
4987
|
-
logger?.debug(`Uploading ${displayName} (${(fileBytes.length / 1024 / 1024).toFixed(2)} MB)`);
|
|
4988
|
-
const resp = await globalThis.fetch(FILES_BASE, {
|
|
4989
|
-
method: "POST",
|
|
4990
|
-
headers: {
|
|
4991
|
-
"x-goog-api-key": apiKey,
|
|
4992
|
-
"Content-Type": `multipart/related; boundary=${boundary}`,
|
|
4993
|
-
"Content-Length": String(body.length),
|
|
4994
|
-
"X-Goog-Upload-Protocol": "multipart"
|
|
5074
|
+
const file = await fs21.openAsBlob(filePath);
|
|
5075
|
+
logger?.debug(`Uploading ${displayName} (${formatMb3(file.size)} MB)`);
|
|
5076
|
+
const start = await uploadFetch(
|
|
5077
|
+
FILES_BASE,
|
|
5078
|
+
{
|
|
5079
|
+
method: "POST",
|
|
5080
|
+
headers: {
|
|
5081
|
+
"x-goog-api-key": apiKey,
|
|
5082
|
+
"Content-Type": "application/json",
|
|
5083
|
+
"X-Goog-Upload-Protocol": "resumable",
|
|
5084
|
+
"X-Goog-Upload-Command": "start",
|
|
5085
|
+
"X-Goog-Upload-Header-Content-Length": String(file.size),
|
|
5086
|
+
"X-Goog-Upload-Header-Content-Type": mimeType
|
|
5087
|
+
},
|
|
5088
|
+
body: JSON.stringify({ file: { display_name: displayName } })
|
|
4995
5089
|
},
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5090
|
+
timeoutMs,
|
|
5091
|
+
"start"
|
|
5092
|
+
);
|
|
5093
|
+
const uploadUrl = start.headers.get("x-goog-upload-url");
|
|
5094
|
+
await start.body?.cancel();
|
|
5095
|
+
if (!uploadUrl) {
|
|
5096
|
+
throw new ProviderError("File upload failed: the Files API returned no upload URL", "gemini");
|
|
5002
5097
|
}
|
|
5003
|
-
const
|
|
5004
|
-
|
|
5098
|
+
const resp = await uploadFetch(
|
|
5099
|
+
uploadUrl,
|
|
5100
|
+
{
|
|
5101
|
+
method: "POST",
|
|
5102
|
+
headers: {
|
|
5103
|
+
"X-Goog-Upload-Offset": "0",
|
|
5104
|
+
"X-Goog-Upload-Command": "upload, finalize"
|
|
5105
|
+
},
|
|
5106
|
+
body: file
|
|
5107
|
+
},
|
|
5108
|
+
uploadTimeoutMs(file.size, timeoutMs, bytesPerSec),
|
|
5109
|
+
"upload"
|
|
5110
|
+
);
|
|
5111
|
+
let fileRef = await readFinalizedFile(resp);
|
|
5005
5112
|
if (requiresProcessingWait(mimeType)) {
|
|
5006
5113
|
const deadline = Date.now() + timeoutMs;
|
|
5007
5114
|
while (fileRef.state === "PROCESSING" && Date.now() < deadline) {
|
|
@@ -9448,7 +9555,7 @@ import path45 from "path";
|
|
|
9448
9555
|
import fs37 from "fs";
|
|
9449
9556
|
import path44 from "path";
|
|
9450
9557
|
init_errors();
|
|
9451
|
-
var
|
|
9558
|
+
var DEFAULT_TIMEOUT_MS4 = 24e4;
|
|
9452
9559
|
function requireOpenAIKey() {
|
|
9453
9560
|
const key = resolveKey("OPENAI_API_KEY");
|
|
9454
9561
|
if (!key) {
|
|
@@ -9464,7 +9571,7 @@ function endpoint(pathname) {
|
|
|
9464
9571
|
function authHeaders4(apiKey, extra) {
|
|
9465
9572
|
return { Authorization: `Bearer ${apiKey}`, ...extra ?? {} };
|
|
9466
9573
|
}
|
|
9467
|
-
function postOpenAIJson(pathname, body, apiKey, timeoutMs =
|
|
9574
|
+
function postOpenAIJson(pathname, body, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS4) {
|
|
9468
9575
|
return httpJson({
|
|
9469
9576
|
url: endpoint(pathname),
|
|
9470
9577
|
method: "POST",
|
|
@@ -9473,7 +9580,7 @@ function postOpenAIJson(pathname, body, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS3)
|
|
|
9473
9580
|
timeoutMs
|
|
9474
9581
|
});
|
|
9475
9582
|
}
|
|
9476
|
-
async function postOpenAIForm(pathname, form, apiKey, timeoutMs =
|
|
9583
|
+
async function postOpenAIForm(pathname, form, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS4) {
|
|
9477
9584
|
const url = endpoint(pathname);
|
|
9478
9585
|
let res;
|
|
9479
9586
|
try {
|
|
@@ -9492,7 +9599,7 @@ async function postOpenAIForm(pathname, form, apiKey, timeoutMs = DEFAULT_TIMEOU
|
|
|
9492
9599
|
}
|
|
9493
9600
|
return res.json();
|
|
9494
9601
|
}
|
|
9495
|
-
async function postOpenAIBinary(pathname, body, apiKey, timeoutMs =
|
|
9602
|
+
async function postOpenAIBinary(pathname, body, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS4) {
|
|
9496
9603
|
const url = endpoint(pathname);
|
|
9497
9604
|
let res;
|
|
9498
9605
|
try {
|
|
@@ -10612,13 +10719,13 @@ var ReplicateApiError = class extends ProviderError {
|
|
|
10612
10719
|
retryAfterMs;
|
|
10613
10720
|
};
|
|
10614
10721
|
var ReplicatePredictionError = class extends ProviderError {
|
|
10615
|
-
constructor(
|
|
10616
|
-
const detail = formatPredictionError(
|
|
10722
|
+
constructor(prediction) {
|
|
10723
|
+
const detail = formatPredictionError(prediction.error);
|
|
10617
10724
|
super(
|
|
10618
|
-
`Replicate prediction ${
|
|
10725
|
+
`Replicate prediction ${prediction.id} ${prediction.status}${detail ? `: ${detail}` : ""}`,
|
|
10619
10726
|
PROVIDER
|
|
10620
10727
|
);
|
|
10621
|
-
this.prediction =
|
|
10728
|
+
this.prediction = prediction;
|
|
10622
10729
|
this.name = "ReplicatePredictionError";
|
|
10623
10730
|
}
|
|
10624
10731
|
prediction;
|
|
@@ -10687,11 +10794,11 @@ function validatePrediction(value) {
|
|
|
10687
10794
|
}
|
|
10688
10795
|
return value;
|
|
10689
10796
|
}
|
|
10690
|
-
function assertSucceeded(
|
|
10691
|
-
if (
|
|
10692
|
-
if (isTerminalStatus2(
|
|
10797
|
+
function assertSucceeded(prediction) {
|
|
10798
|
+
if (prediction.status === "succeeded") return prediction;
|
|
10799
|
+
if (isTerminalStatus2(prediction.status)) throw new ReplicatePredictionError(prediction);
|
|
10693
10800
|
throw new ProviderError(
|
|
10694
|
-
`Replicate prediction ${
|
|
10801
|
+
`Replicate prediction ${prediction.id} is not finished (status: ${prediction.status})`,
|
|
10695
10802
|
PROVIDER
|
|
10696
10803
|
);
|
|
10697
10804
|
}
|
|
@@ -10772,9 +10879,9 @@ var ReplicateClient = class {
|
|
|
10772
10879
|
* canceled, aborted) and return it — callers decide success via
|
|
10773
10880
|
* assertSucceeded. Retries network/429/5xx; throws PollTimeoutError on deadline.
|
|
10774
10881
|
*/
|
|
10775
|
-
async waitForPrediction(
|
|
10776
|
-
if (typeof
|
|
10777
|
-
const safeId = assertPredictionId(typeof
|
|
10882
|
+
async waitForPrediction(prediction, opts) {
|
|
10883
|
+
if (typeof prediction !== "string" && isTerminalStatus2(prediction.status)) return prediction;
|
|
10884
|
+
const safeId = assertPredictionId(typeof prediction === "string" ? prediction : prediction.id);
|
|
10778
10885
|
try {
|
|
10779
10886
|
return await pollUntil({
|
|
10780
10887
|
fetchStatus: async ({ remainingMs }) => validatePrediction(
|
|
@@ -10877,13 +10984,13 @@ function registerReplicateCancelCommand(parent) {
|
|
|
10877
10984
|
parent.command("cancel <predictionId>").description("Cancel a running Replicate prediction").option("-v, --verbose", "Verbose logging").action(async (predictionId, opts) => {
|
|
10878
10985
|
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
10879
10986
|
const client = createReplicateClient();
|
|
10880
|
-
const
|
|
10881
|
-
if (
|
|
10882
|
-
logger.success(`Prediction ${
|
|
10883
|
-
} else if (isTerminalStatus2(
|
|
10884
|
-
logger.warn(`Prediction ${
|
|
10987
|
+
const prediction = await client.cancelPrediction(predictionId);
|
|
10988
|
+
if (prediction.status === "canceled") {
|
|
10989
|
+
logger.success(`Prediction ${prediction.id} canceled`);
|
|
10990
|
+
} else if (isTerminalStatus2(prediction.status)) {
|
|
10991
|
+
logger.warn(`Prediction ${prediction.id} already finished (status: ${prediction.status})`);
|
|
10885
10992
|
} else {
|
|
10886
|
-
logger.info(`Cancel requested for ${
|
|
10993
|
+
logger.info(`Cancel requested for ${prediction.id} (status: ${prediction.status})`);
|
|
10887
10994
|
}
|
|
10888
10995
|
});
|
|
10889
10996
|
}
|
|
@@ -11283,42 +11390,42 @@ function parsePositiveInt2(raw, flag, max) {
|
|
|
11283
11390
|
function resumeCommand3(id) {
|
|
11284
11391
|
return `multix replicate status ${id} --wait --download`;
|
|
11285
11392
|
}
|
|
11286
|
-
function printAsyncHandle(
|
|
11287
|
-
console.log(`Prediction submitted: ${
|
|
11288
|
-
console.log(` Check: multix replicate status ${
|
|
11289
|
-
console.log(` Resume: ${resumeCommand3(
|
|
11290
|
-
console.log(` Cancel: multix replicate cancel ${
|
|
11393
|
+
function printAsyncHandle(prediction) {
|
|
11394
|
+
console.log(`Prediction submitted: ${prediction.id} (status: ${prediction.status})`);
|
|
11395
|
+
console.log(` Check: multix replicate status ${prediction.id}`);
|
|
11396
|
+
console.log(` Resume: ${resumeCommand3(prediction.id)}`);
|
|
11397
|
+
console.log(` Cancel: multix replicate cancel ${prediction.id}`);
|
|
11291
11398
|
}
|
|
11292
|
-
async function reportOutputs(
|
|
11293
|
-
const { media, texts } = extractOutput(
|
|
11399
|
+
async function reportOutputs(prediction, opts) {
|
|
11400
|
+
const { media, texts } = extractOutput(prediction.output);
|
|
11294
11401
|
for (const text of texts) console.log(text);
|
|
11295
11402
|
if (media.length === 0) {
|
|
11296
|
-
if (texts.length === 0) console.log(JSON.stringify(summarizePrediction(
|
|
11403
|
+
if (texts.length === 0) console.log(JSON.stringify(summarizePrediction(prediction), null, 2));
|
|
11297
11404
|
return [];
|
|
11298
11405
|
}
|
|
11299
11406
|
if (!opts.download) {
|
|
11300
11407
|
for (const m of media) console.log(m.url);
|
|
11301
11408
|
return [];
|
|
11302
11409
|
}
|
|
11303
|
-
const saved = await saveMediaOutputs(
|
|
11410
|
+
const saved = await saveMediaOutputs(prediction.id, media, {
|
|
11304
11411
|
outDir: opts.output ? "" : getOutputDir(),
|
|
11305
11412
|
output: opts.output,
|
|
11306
11413
|
logger: opts.logger,
|
|
11307
|
-
retryHint: `Retry while the output URLs are live: multix replicate status ${
|
|
11414
|
+
retryHint: `Retry while the output URLs are live: multix replicate status ${prediction.id} --download`
|
|
11308
11415
|
});
|
|
11309
11416
|
const finals = opts.finalize ? opts.finalize(saved) : saved;
|
|
11310
11417
|
console.log(`
|
|
11311
|
-
Saved ${finals.length} file(s) from prediction ${
|
|
11418
|
+
Saved ${finals.length} file(s) from prediction ${prediction.id}:`);
|
|
11312
11419
|
for (const f of finals) console.log(` ${f}`);
|
|
11313
11420
|
return finals;
|
|
11314
11421
|
}
|
|
11315
|
-
async function completePrediction(client,
|
|
11316
|
-
const id = typeof
|
|
11317
|
-
let lastStatus = typeof
|
|
11318
|
-
if (typeof
|
|
11422
|
+
async function completePrediction(client, prediction, opts) {
|
|
11423
|
+
const id = typeof prediction === "string" ? prediction : prediction.id;
|
|
11424
|
+
let lastStatus = typeof prediction === "string" ? "" : prediction.status;
|
|
11425
|
+
if (typeof prediction === "string" || !isTerminalStatus2(prediction.status)) {
|
|
11319
11426
|
opts.logger.info(`Waiting for prediction ${id}...`);
|
|
11320
11427
|
}
|
|
11321
|
-
const final = await client.waitForPrediction(
|
|
11428
|
+
const final = await client.waitForPrediction(prediction, {
|
|
11322
11429
|
timeoutMs: opts.waitTimeoutMs,
|
|
11323
11430
|
resumeHint: `Resume with: ${resumeCommand3(id)}`,
|
|
11324
11431
|
onPoll: (p, attempt) => {
|
|
@@ -11339,17 +11446,17 @@ async function executePrediction2(params) {
|
|
|
11339
11446
|
}
|
|
11340
11447
|
const waitTimeoutMs = parsePositiveInt2(opts.waitTimeout, "--wait-timeout");
|
|
11341
11448
|
logger.info(`Submitting to Replicate ${describeModelRef(model)}...`);
|
|
11342
|
-
const
|
|
11449
|
+
const prediction = await client.createPrediction({
|
|
11343
11450
|
model,
|
|
11344
11451
|
input,
|
|
11345
11452
|
preferWaitSeconds: opts.preferWait
|
|
11346
11453
|
});
|
|
11347
11454
|
if (opts.async) {
|
|
11348
|
-
printAsyncHandle(
|
|
11455
|
+
printAsyncHandle(prediction);
|
|
11349
11456
|
return;
|
|
11350
11457
|
}
|
|
11351
|
-
logger.info(`Prediction ${
|
|
11352
|
-
await completePrediction(client,
|
|
11458
|
+
logger.info(`Prediction ${prediction.id} (status: ${prediction.status})`);
|
|
11459
|
+
await completePrediction(client, prediction, {
|
|
11353
11460
|
waitTimeoutMs,
|
|
11354
11461
|
logger,
|
|
11355
11462
|
download: opts.download !== false,
|
|
@@ -11415,7 +11522,7 @@ import fs45 from "fs";
|
|
|
11415
11522
|
import path52 from "path";
|
|
11416
11523
|
var DATA_URI_MAX_BYTES = 256 * 1024;
|
|
11417
11524
|
var UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
|
11418
|
-
function
|
|
11525
|
+
function formatMb4(bytes) {
|
|
11419
11526
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
11420
11527
|
}
|
|
11421
11528
|
async function resolveFileInput(value, client, logger) {
|
|
@@ -11431,7 +11538,7 @@ async function resolveFileInput(value, client, logger) {
|
|
|
11431
11538
|
if (stat.size === 0) throw new ValidationError(`Input file is empty: ${value}`);
|
|
11432
11539
|
if (stat.size >= UPLOAD_MAX_BYTES) {
|
|
11433
11540
|
throw new ValidationError(
|
|
11434
|
-
`Input file ${value} is ${
|
|
11541
|
+
`Input file ${value} is ${formatMb4(stat.size)}; Replicate accepts uploads under 100MB. Host it and pass an https URL instead.`
|
|
11435
11542
|
);
|
|
11436
11543
|
}
|
|
11437
11544
|
const filename = path52.basename(filePath);
|
|
@@ -11441,7 +11548,7 @@ async function resolveFileInput(value, client, logger) {
|
|
|
11441
11548
|
logger.debug(`Inlining ${filename} (${stat.size} bytes) as a data URI`);
|
|
11442
11549
|
return `data:${contentType};base64,${bytes.toString("base64")}`;
|
|
11443
11550
|
}
|
|
11444
|
-
logger.info(`Uploading ${filename} (${
|
|
11551
|
+
logger.info(`Uploading ${filename} (${formatMb4(stat.size)}) to Replicate Files...`);
|
|
11445
11552
|
const file = await client.uploadFile({
|
|
11446
11553
|
data: new Blob([bytes], { type: contentType }),
|
|
11447
11554
|
filename,
|
|
@@ -11501,16 +11608,16 @@ function registerReplicateStatusCommand(parent) {
|
|
|
11501
11608
|
});
|
|
11502
11609
|
return;
|
|
11503
11610
|
}
|
|
11504
|
-
const
|
|
11505
|
-
if (
|
|
11506
|
-
await reportOutputs(
|
|
11611
|
+
const prediction = await client.getPrediction(predictionId);
|
|
11612
|
+
if (prediction.status === "succeeded" && opts.download) {
|
|
11613
|
+
await reportOutputs(prediction, { download: true, output: opts.output, logger });
|
|
11507
11614
|
return;
|
|
11508
11615
|
}
|
|
11509
|
-
console.log(JSON.stringify(summarizePrediction(
|
|
11510
|
-
if (!isTerminalStatus2(
|
|
11511
|
-
logger.info(`Still running. Resume with: ${resumeCommand3(
|
|
11512
|
-
} else if (
|
|
11513
|
-
throw new ReplicatePredictionError(
|
|
11616
|
+
console.log(JSON.stringify(summarizePrediction(prediction), null, 2));
|
|
11617
|
+
if (!isTerminalStatus2(prediction.status)) {
|
|
11618
|
+
logger.info(`Still running. Resume with: ${resumeCommand3(prediction.id)}`);
|
|
11619
|
+
} else if (prediction.status !== "succeeded") {
|
|
11620
|
+
throw new ReplicatePredictionError(prediction);
|
|
11514
11621
|
}
|
|
11515
11622
|
});
|
|
11516
11623
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrgoonie/multix",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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": {
|
|
@@ -35,7 +35,11 @@
|
|
|
35
35
|
"test": "vitest run",
|
|
36
36
|
"test:watch": "vitest",
|
|
37
37
|
"test:smoke": "vitest run tests/smoke",
|
|
38
|
-
"verify:docs": "npm run verify --prefix docs-site"
|
|
38
|
+
"verify:docs": "npm run verify --prefix docs-site",
|
|
39
|
+
"dev:mcp": "npm run dev --prefix worker",
|
|
40
|
+
"deploy:mcp": "npm run deploy --prefix worker",
|
|
41
|
+
"test:mcp": "npm test --prefix worker",
|
|
42
|
+
"check:mcp": "npm run typecheck --prefix worker && npm test --prefix worker && npm run check:bundle --prefix worker"
|
|
39
43
|
},
|
|
40
44
|
"keywords": [
|
|
41
45
|
"cli",
|
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.
|
|
4
|
+
version: 0.15.1 # x-release-please-version
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# multix — AI Multimodal CLI
|
|
@@ -387,6 +387,7 @@ Requires `cwebp` (libwebp: `brew install webp`, `apt install webp`) on `PATH`.
|
|
|
387
387
|
|---|---|
|
|
388
388
|
| `MULTIX_OUTPUT_DIR` | Override default output dir |
|
|
389
389
|
| `MULTIX_MAX_DOWNLOAD_MB` | Size limit per downloaded Replicate output file (default 2048) |
|
|
390
|
+
| `MULTIX_UPLOAD_MIN_KBPS` | Slowest Gemini Files API upload speed to allow for, in KB/s (default 1000); lower it on slow links |
|
|
390
391
|
| `OPENAI_IMAGE_MODEL` / `OPENAI_TTS_MODEL` / `OPENAI_STT_MODEL` | OpenAI model defaults |
|
|
391
392
|
| `OPENROUTER_IMAGE_MODEL` | Default OpenRouter image model |
|
|
392
393
|
| `OPENROUTER_FALLBACK_MODELS` | CSV; applies to `generate` AND `i2i` |
|
|
@@ -466,6 +467,19 @@ multix update --check
|
|
|
466
467
|
multix update
|
|
467
468
|
```
|
|
468
469
|
|
|
470
|
+
## Remote MCP server
|
|
471
|
+
|
|
472
|
+
If the user cannot run the CLI locally but has deployed the optional multix
|
|
473
|
+
MCP Worker (`worker/` in the repo; guide: https://multix.zuey.me/guides/mcp-server/),
|
|
474
|
+
call its tools instead: `multix_list_providers` first, then
|
|
475
|
+
`multix_generate_image` / `multix_generate_video`, and poll
|
|
476
|
+
`multix_prediction_status` until `done` is true. It returns provider URLs (no
|
|
477
|
+
local files), supports only Replicate, Atlas Cloud, HeyGen, and Cloudflare
|
|
478
|
+
video, and accepts only public `https://` image URLs. Check `limits` from
|
|
479
|
+
`multix_list_providers` before a video call: `duration` above
|
|
480
|
+
`max_video_seconds` or a `resolution` outside `allowed_resolutions` is
|
|
481
|
+
rejected, and an omitted `duration` becomes `default_video_seconds`.
|
|
482
|
+
|
|
469
483
|
## Security & scope
|
|
470
484
|
|
|
471
485
|
This skill produces shell commands for the local `multix` CLI. It does NOT execute the commands itself, exfiltrate API keys, or modify provider accounts.
|