@mrgoonie/multix 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +35 -1
- package/dist/cli.js +506 -221
- package/package.json +6 -2
- package/skill/SKILL.md +18 -2
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.0](https://github.com/mrgoonie/multix-cli/compare/v0.14.1...v0.15.0) (2026-10-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
* **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))
|
|
14
|
+
|
|
15
|
+
## [0.14.1](https://github.com/mrgoonie/multix-cli/compare/v0.14.0...v0.14.1) (2026-10-11)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
* **replicate:** harden output downloads, --prefer-wait and create errors ([#88](https://github.com/mrgoonie/multix-cli/issues/88)) ([e29e81d](https://github.com/mrgoonie/multix-cli/commit/e29e81d7b3e54e5b65f4905c1adb629f2d4df6c4))
|
|
21
|
+
|
|
8
22
|
## [0.14.0](https://github.com/mrgoonie/multix-cli/compare/v0.13.0...v0.14.0) (2026-10-11)
|
|
9
23
|
|
|
10
24
|
|
package/README.md
CHANGED
|
@@ -98,6 +98,7 @@ Set at least one provider key. Add to `.env` in your project root or `~/.multix/
|
|
|
98
98
|
| `CLOUDFLARE_AI_IMAGE_MODEL` | No | Image model setting; only `@cf/black-forest-labs/flux-1-schnell` is accepted |
|
|
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
|
+
| `MULTIX_MAX_DOWNLOAD_MB` | No | Size limit per downloaded Replicate output file (default `2048`) |
|
|
101
102
|
| `MULTIX_IMAGE_FORMAT` | No | `webp` (default) or `original` to keep provider image format/quality |
|
|
102
103
|
| `OPENROUTER_IMAGE_MODEL` | No | Default OpenRouter model |
|
|
103
104
|
| `OPENROUTER_FALLBACK_MODELS` | No | Comma-separated fallback model ids |
|
|
@@ -556,9 +557,11 @@ multix replicate cancel <prediction-id>
|
|
|
556
557
|
|
|
557
558
|
**Notes:**
|
|
558
559
|
- Auth: `Authorization: Bearer $REPLICATE_API_TOKEN` (`REPLICATE_API_KEY` accepted as fallback).
|
|
559
|
-
- Default behavior waits with bounded polling; `--async` prints the prediction id plus resume/cancel commands. `--prefer-wait` sends `Prefer: wait=N` and keeps polling if the job is still running.
|
|
560
|
+
- Default behavior waits with bounded polling; `--async` prints the prediction id plus resume/cancel commands. `--prefer-wait [seconds]` (an integer from 1 to 60; the bare flag means 60) sends `Prefer: wait=N` and keeps polling if the job is still running. Put the prompt before a bare `--prefer-wait`; any other value is rejected.
|
|
560
561
|
- Only `succeeded` exits 0; `failed`, `canceled`, and `aborted` exit non-zero with Replicate's error.
|
|
562
|
+
- If submitting fails with a network error, timeout, 5xx, or an unreadable 2xx response, the prediction may still have been created and billed (Replicate has no idempotency key). Check https://replicate.com/predictions before retrying.
|
|
561
563
|
- Every media URL in the output (string, list, or nested object) is downloaded as `replicate-<id8>-NN.<ext>` (`<id8>` = first 8 characters of the prediction id); text outputs are printed. Output URLs expire, so download promptly. If any download fails, the command exits non-zero and prints `multix replicate status <id> --download` to retry.
|
|
564
|
+
- Output downloads use `https://` only and refuse `localhost` and loopback, private, link-local, or other special-purpose IP literals (hostnames are not resolved), including on redirects. Files are written to `<path>.part` and renamed when complete, so a failed download never clobbers an existing file. Hosts other than `replicate.delivery` are downloaded with a warning that names the host. Each file is capped at `MULTIX_MAX_DOWNLOAD_MB` (default 2048).
|
|
562
565
|
- `replicate image` converts images to WebP by default; `--image-format original`, `--no-webp`, `MULTIX_IMAGE_FORMAT=original`, an explicit `--output-format`, or a non-`.webp` `--output` extension keeps the provider format.
|
|
563
566
|
- Local files (`--image`, `--file key=path`) up to 256 KB are inlined as data URIs; larger files (< 100 MB) are uploaded via the Replicate Files API.
|
|
564
567
|
|
|
@@ -671,6 +674,37 @@ Thumbnail detection is automatic: if the provider response includes any of `cove
|
|
|
671
674
|
|
|
672
675
|
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.
|
|
673
676
|
|
|
677
|
+
## Remote MCP server (Cloudflare Workers)
|
|
678
|
+
|
|
679
|
+
`worker/` contains an optional, stateless MCP server that exposes remote-safe
|
|
680
|
+
image and video tools over Streamable HTTP at `/mcp`, for agents that cannot
|
|
681
|
+
run the CLI locally. It is a separate package and is not included in the npm
|
|
682
|
+
CLI.
|
|
683
|
+
|
|
684
|
+
- Tools: `multix_list_providers`, `multix_generate_image` (Replicate, Atlas
|
|
685
|
+
Cloud), `multix_generate_video` (Replicate, Atlas Cloud, HeyGen, Cloudflare
|
|
686
|
+
AI Gateway; returns an ID without blocking), `multix_prediction_status`,
|
|
687
|
+
`multix_prediction_cancel` (Replicate). Results are provider URLs, not bytes.
|
|
688
|
+
- Auth: `Authorization: Bearer <MCP_AUTH_TOKEN>` (at least 32 characters; the
|
|
689
|
+
server returns `503` if it is unset). `/health` is public.
|
|
690
|
+
- Single-tenant: provider keys are Worker secrets, and every caller spends the
|
|
691
|
+
deployer's credits. Limit exposure with `MULTIX_MCP_ALLOWED_PROVIDERS`,
|
|
692
|
+
`MULTIX_MCP_ALLOWED_MODELS`, `MULTIX_MCP_MAX_OUTPUTS`,
|
|
693
|
+
`MULTIX_MCP_MAX_VIDEO_SECONDS` (also sent as `min(5, cap)` when a caller
|
|
694
|
+
omits `duration`), `MULTIX_MCP_ALLOWED_RESOLUTIONS` (default
|
|
695
|
+
`480p,720p,1080p`), `MULTIX_MCP_ALLOWED_ORIGINS` (browser origins, with
|
|
696
|
+
CORS), and the approximate per-IP rate limiter. JSON-RPC batches are
|
|
697
|
+
rejected.
|
|
698
|
+
|
|
699
|
+
```bash
|
|
700
|
+
npm ci --prefix worker
|
|
701
|
+
cd worker && openssl rand -hex 32 | npx wrangler secret put MCP_AUTH_TOKEN
|
|
702
|
+
npx wrangler secret put REPLICATE_API_TOKEN # and any other provider keys
|
|
703
|
+
cd .. && npm run deploy:mcp # or: npm run dev:mcp (local, port 8787)
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
Full guide: [Remote MCP server](https://multix.zuey.me/guides/mcp-server/).
|
|
707
|
+
|
|
674
708
|
## Troubleshooting
|
|
675
709
|
|
|
676
710
|
**No API key found:** Run `multix check` — it prints setup instructions.
|
package/dist/cli.js
CHANGED
|
@@ -50,9 +50,10 @@ var init_errors = __esm({
|
|
|
50
50
|
super(message);
|
|
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
|
};
|
|
@@ -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
|
}
|
|
@@ -1120,40 +1121,168 @@ import fs8 from "fs";
|
|
|
1120
1121
|
import path7 from "path";
|
|
1121
1122
|
|
|
1122
1123
|
// src/core/http-client.ts
|
|
1123
|
-
init_errors();
|
|
1124
|
-
import { once } from "events";
|
|
1125
1124
|
import fs3 from "fs";
|
|
1126
1125
|
import path3 from "path";
|
|
1126
|
+
import { Readable } from "stream";
|
|
1127
1127
|
import { pipeline } from "stream/promises";
|
|
1128
|
+
init_errors();
|
|
1129
|
+
init_redact();
|
|
1128
1130
|
async function httpJson(opts) {
|
|
1129
1131
|
return fetchJson(opts);
|
|
1130
1132
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1133
|
+
var MAX_DOWNLOAD_ENV = "MULTIX_MAX_DOWNLOAD_MB";
|
|
1134
|
+
var DEFAULT_MAX_DOWNLOAD_MB = 2048;
|
|
1135
|
+
var MAX_REDIRECTS = 5;
|
|
1136
|
+
function formatMb2(bytes) {
|
|
1137
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1138
|
+
}
|
|
1139
|
+
var DownloadTooLargeError = class extends MultixError {
|
|
1140
|
+
constructor(limitBytes, url) {
|
|
1141
|
+
super(
|
|
1142
|
+
`Download from ${redactUrl(url)} exceeds the ${formatMb2(limitBytes)} limit (set ${MAX_DOWNLOAD_ENV} to change it)`,
|
|
1143
|
+
"DOWNLOAD_TOO_LARGE"
|
|
1144
|
+
);
|
|
1145
|
+
this.limitBytes = limitBytes;
|
|
1146
|
+
this.name = "DownloadTooLargeError";
|
|
1147
|
+
}
|
|
1148
|
+
limitBytes;
|
|
1149
|
+
};
|
|
1150
|
+
function maxDownloadBytes() {
|
|
1151
|
+
const raw = resolveKey(MAX_DOWNLOAD_ENV)?.trim();
|
|
1152
|
+
if (!raw) return DEFAULT_MAX_DOWNLOAD_MB * 1024 * 1024;
|
|
1153
|
+
const mb = Number(raw);
|
|
1154
|
+
if (!Number.isFinite(mb) || mb <= 0) {
|
|
1155
|
+
throw new ConfigError(
|
|
1156
|
+
`${MAX_DOWNLOAD_ENV} must be a positive number of megabytes, got "${raw}"`
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
const bytes = Math.floor(mb * 1024 * 1024);
|
|
1160
|
+
if (bytes < 1) {
|
|
1161
|
+
throw new ConfigError(`${MAX_DOWNLOAD_ENV} is too small: "${raw}" MB is less than one byte`);
|
|
1162
|
+
}
|
|
1163
|
+
return bytes;
|
|
1164
|
+
}
|
|
1165
|
+
async function fetchWithValidatedRedirects(url, opts = {}) {
|
|
1166
|
+
const doFetch = opts.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
1167
|
+
const { validateUrl, signal } = opts;
|
|
1168
|
+
const maxRedirects = opts.maxRedirects ?? MAX_REDIRECTS;
|
|
1169
|
+
let current = url;
|
|
1170
|
+
let headers2 = opts.headers;
|
|
1171
|
+
for (let hop = 0; ; hop++) {
|
|
1172
|
+
await validateUrl?.(current);
|
|
1173
|
+
let response;
|
|
1174
|
+
try {
|
|
1175
|
+
response = await doFetch(current, {
|
|
1176
|
+
...signal ? { signal } : {},
|
|
1177
|
+
...headers2 ? { headers: headers2 } : {},
|
|
1178
|
+
...validateUrl ? { redirect: "manual" } : {}
|
|
1179
|
+
});
|
|
1180
|
+
} catch (cause) {
|
|
1181
|
+
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
1182
|
+
throw new HttpError(0, msg, current);
|
|
1183
|
+
}
|
|
1184
|
+
const location = response.headers.get("location");
|
|
1185
|
+
if (!validateUrl || !location || response.status < 300 || response.status >= 400) {
|
|
1186
|
+
return { response, url: current };
|
|
1187
|
+
}
|
|
1188
|
+
await response.body?.cancel().catch(() => {
|
|
1138
1189
|
});
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1190
|
+
if (hop >= maxRedirects) {
|
|
1191
|
+
throw new HttpError(response.status, `too many redirects (over ${maxRedirects})`, current);
|
|
1192
|
+
}
|
|
1193
|
+
let next;
|
|
1194
|
+
try {
|
|
1195
|
+
next = new URL(location, current);
|
|
1196
|
+
} catch {
|
|
1197
|
+
throw new HttpError(response.status, "redirect with a malformed Location header", current);
|
|
1198
|
+
}
|
|
1199
|
+
if (next.origin !== new URL(current).origin) headers2 = void 0;
|
|
1200
|
+
current = next.toString();
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
function removeQuietly(file) {
|
|
1204
|
+
try {
|
|
1205
|
+
fs3.rmSync(file, { force: true });
|
|
1206
|
+
} catch {
|
|
1142
1207
|
}
|
|
1208
|
+
}
|
|
1209
|
+
function replaceFile(from, to) {
|
|
1210
|
+
try {
|
|
1211
|
+
fs3.renameSync(from, to);
|
|
1212
|
+
} catch (err2) {
|
|
1213
|
+
const code = err2.code;
|
|
1214
|
+
const existing = fs3.statSync(to, { throwIfNoEntry: false });
|
|
1215
|
+
if (!existing?.isFile() || code !== "EEXIST" && code !== "EPERM" && code !== "EACCES") {
|
|
1216
|
+
throw err2;
|
|
1217
|
+
}
|
|
1218
|
+
fs3.rmSync(to, { force: true });
|
|
1219
|
+
fs3.renameSync(from, to);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
async function downloadToFile(url, opts) {
|
|
1223
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
1224
|
+
const { response, url: finalUrl } = await fetchWithValidatedRedirects(url, {
|
|
1225
|
+
signal,
|
|
1226
|
+
headers: opts.headers,
|
|
1227
|
+
validateUrl: opts.validateUrl,
|
|
1228
|
+
fetch: opts.fetch
|
|
1229
|
+
});
|
|
1143
1230
|
if (!response.ok) {
|
|
1144
|
-
|
|
1231
|
+
await response.body?.cancel().catch(() => {
|
|
1232
|
+
});
|
|
1233
|
+
throw new HttpError(response.status, "(download failed)", finalUrl);
|
|
1145
1234
|
}
|
|
1146
1235
|
if (!response.body) {
|
|
1147
|
-
throw new HttpError(0, "Response body is null",
|
|
1236
|
+
throw new HttpError(0, "Response body is null", finalUrl);
|
|
1237
|
+
}
|
|
1238
|
+
const body = response.body;
|
|
1239
|
+
const { maxBytes } = opts;
|
|
1240
|
+
const declared = Number(response.headers.get("content-length") ?? Number.NaN);
|
|
1241
|
+
if (maxBytes !== void 0 && Number.isFinite(declared) && declared > maxBytes) {
|
|
1242
|
+
await body.cancel().catch(() => {
|
|
1243
|
+
});
|
|
1244
|
+
throw new DownloadTooLargeError(maxBytes, finalUrl);
|
|
1245
|
+
}
|
|
1246
|
+
let dest;
|
|
1247
|
+
try {
|
|
1248
|
+
dest = typeof opts.dest === "function" ? opts.dest(response) : opts.dest;
|
|
1249
|
+
fs3.mkdirSync(path3.dirname(dest), { recursive: true });
|
|
1250
|
+
} catch (err2) {
|
|
1251
|
+
await body.cancel().catch(() => {
|
|
1252
|
+
});
|
|
1253
|
+
throw err2;
|
|
1254
|
+
}
|
|
1255
|
+
let bytes = 0;
|
|
1256
|
+
async function* countBytes(source) {
|
|
1257
|
+
for await (const chunk of source) {
|
|
1258
|
+
bytes += chunk.byteLength;
|
|
1259
|
+
if (maxBytes !== void 0 && bytes > maxBytes)
|
|
1260
|
+
throw new DownloadTooLargeError(maxBytes, finalUrl);
|
|
1261
|
+
yield chunk;
|
|
1262
|
+
}
|
|
1148
1263
|
}
|
|
1149
|
-
const
|
|
1264
|
+
const part = `${dest}.part`;
|
|
1265
|
+
let fileStream;
|
|
1150
1266
|
try {
|
|
1151
|
-
|
|
1267
|
+
fileStream = fs3.createWriteStream(part);
|
|
1268
|
+
await pipeline(
|
|
1269
|
+
Readable.fromWeb(body),
|
|
1270
|
+
countBytes,
|
|
1271
|
+
fileStream
|
|
1272
|
+
);
|
|
1273
|
+
replaceFile(part, dest);
|
|
1152
1274
|
} catch (err2) {
|
|
1153
|
-
if (!fileStream.closed)
|
|
1275
|
+
if (fileStream && !fileStream.closed) {
|
|
1276
|
+
await new Promise((resolve) => fileStream?.once("close", () => resolve()));
|
|
1277
|
+
}
|
|
1278
|
+
removeQuietly(part);
|
|
1154
1279
|
throw err2;
|
|
1155
1280
|
}
|
|
1156
|
-
return response.headers.get("content-type") ?? void 0;
|
|
1281
|
+
return { dest, contentType: response.headers.get("content-type") ?? void 0, bytes };
|
|
1282
|
+
}
|
|
1283
|
+
async function downloadFile(url, dest, timeoutMs = DEFAULT_TIMEOUT_MS, headers2) {
|
|
1284
|
+
const result = await downloadToFile(url, { dest, timeoutMs, headers: headers2 });
|
|
1285
|
+
return result.contentType;
|
|
1157
1286
|
}
|
|
1158
1287
|
async function fetchBytes(urlOrDataUri) {
|
|
1159
1288
|
if (urlOrDataUri.startsWith("data:")) {
|
|
@@ -1566,22 +1695,22 @@ function planOutputPaths(urls, cfg) {
|
|
|
1566
1695
|
const base = abs.slice(0, -path7.extname(abs).length);
|
|
1567
1696
|
return exts.map((ext, i) => `${base}-${String(i + 1).padStart(2, "0")}.${ext}`);
|
|
1568
1697
|
}
|
|
1569
|
-
async function deliverPrediction(
|
|
1698
|
+
async function deliverPrediction(prediction, cfg) {
|
|
1570
1699
|
const { logger, opts } = cfg;
|
|
1571
|
-
if (
|
|
1572
|
-
logger.info(`Cost: $${
|
|
1700
|
+
if (prediction.price !== void 0 && prediction.price !== "") {
|
|
1701
|
+
logger.info(`Cost: $${prediction.price}`);
|
|
1573
1702
|
}
|
|
1574
|
-
const classified = classifyOutputs(
|
|
1703
|
+
const classified = classifyOutputs(prediction);
|
|
1575
1704
|
if (classified.mode === "empty") {
|
|
1576
1705
|
throw new ProviderError(
|
|
1577
|
-
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.`,
|
|
1578
1707
|
ATLAS_PROVIDER
|
|
1579
1708
|
);
|
|
1580
1709
|
}
|
|
1581
1710
|
for (const text of classified.texts) console.log(text);
|
|
1582
1711
|
if (classified.mode === "text") {
|
|
1583
1712
|
if (opts.output) {
|
|
1584
|
-
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);
|
|
1585
1714
|
fs8.mkdirSync(path7.dirname(dest), { recursive: true });
|
|
1586
1715
|
fs8.writeFileSync(dest, `${classified.texts.join("\n\n")}
|
|
1587
1716
|
`, "utf8");
|
|
@@ -1595,7 +1724,7 @@ async function deliverPrediction(prediction2, cfg) {
|
|
|
1595
1724
|
return { files: [], texts: classified.texts };
|
|
1596
1725
|
}
|
|
1597
1726
|
const dests = planOutputPaths(classified.urls, {
|
|
1598
|
-
predictionId:
|
|
1727
|
+
predictionId: prediction.id,
|
|
1599
1728
|
outDir: getOutputDir,
|
|
1600
1729
|
output: opts.output,
|
|
1601
1730
|
hint: cfg.hint
|
|
@@ -1623,7 +1752,7 @@ async function deliverPrediction(prediction2, cfg) {
|
|
|
1623
1752
|
}
|
|
1624
1753
|
if (saved.length === 0) {
|
|
1625
1754
|
throw new ProviderError(
|
|
1626
|
-
`Prediction ${
|
|
1755
|
+
`Prediction ${prediction.id} produced ${classified.urls.length} output(s) but none downloaded. Retry with: ${resumeCommand(prediction.id)}`,
|
|
1627
1756
|
ATLAS_PROVIDER
|
|
1628
1757
|
);
|
|
1629
1758
|
}
|
|
@@ -1638,14 +1767,14 @@ async function deliverPrediction(prediction2, cfg) {
|
|
|
1638
1767
|
const files = saved.map((f) => finalImages[images.indexOf(f)] ?? f);
|
|
1639
1768
|
const firstVideo = files.find((f) => outputCategory(f) === "video");
|
|
1640
1769
|
if (firstVideo) {
|
|
1641
|
-
await maybeDownloadThumb(
|
|
1770
|
+
await maybeDownloadThumb(prediction, firstVideo, { skip: opts.thumb === false, logger });
|
|
1642
1771
|
}
|
|
1643
1772
|
console.log(`
|
|
1644
|
-
Prediction ${
|
|
1773
|
+
Prediction ${prediction.id}: saved ${files.length} file(s)`);
|
|
1645
1774
|
for (const f of files) console.log(` ${f}`);
|
|
1646
1775
|
if (failed > 0) {
|
|
1647
1776
|
throw new ProviderError(
|
|
1648
|
-
`Prediction ${
|
|
1777
|
+
`Prediction ${prediction.id}: downloaded ${saved.length} of ${classified.urls.length} output(s); ${failed} failed. Saved: ${files.join(", ")}. Retry with: ${resumeCommand(prediction.id)}`,
|
|
1649
1778
|
ATLAS_PROVIDER
|
|
1650
1779
|
);
|
|
1651
1780
|
}
|
|
@@ -1877,18 +2006,18 @@ function registerAtlasStatusCommand(parent) {
|
|
|
1877
2006
|
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
1878
2007
|
const client = createAtlasClientFromEnv();
|
|
1879
2008
|
const wait = Boolean(opts.wait || opts.download);
|
|
1880
|
-
const
|
|
2009
|
+
const prediction = wait ? await client.waitForPrediction(predictionId, {
|
|
1881
2010
|
timeoutMs: parseTimeoutMs(opts.waitTimeout, STATUS_WAIT_MS),
|
|
1882
2011
|
resumeHint: `Resume with: ${resumeCommand(predictionId)}`,
|
|
1883
2012
|
onPoll: (p, n) => logger.debug(`poll ${n}: ${p.status}`)
|
|
1884
2013
|
}) : await client.getPrediction(predictionId);
|
|
1885
2014
|
if (!opts.download) {
|
|
1886
|
-
console.log(JSON.stringify(
|
|
1887
|
-
if (isFailureStatus(
|
|
2015
|
+
console.log(JSON.stringify(prediction, null, 2));
|
|
2016
|
+
if (isFailureStatus(prediction.status)) throw new AtlasPredictionFailedError(prediction);
|
|
1888
2017
|
return;
|
|
1889
2018
|
}
|
|
1890
|
-
assertPredictionSucceeded(
|
|
1891
|
-
await deliverPrediction(
|
|
2019
|
+
assertPredictionSucceeded(prediction);
|
|
2020
|
+
await deliverPrediction(prediction, { logger, opts: { ...opts, download: true } });
|
|
1892
2021
|
});
|
|
1893
2022
|
}
|
|
1894
2023
|
|
|
@@ -2947,9 +3076,13 @@ init_errors();
|
|
|
2947
3076
|
|
|
2948
3077
|
// src/providers/cloudflare/models.ts
|
|
2949
3078
|
init_errors();
|
|
3079
|
+
|
|
3080
|
+
// src/providers/cloudflare/model-ids.ts
|
|
2950
3081
|
var CLOUDFLARE_IMAGE_MODEL = "@cf/black-forest-labs/flux-1-schnell";
|
|
2951
3082
|
var CLOUDFLARE_TTS_MODEL = "@cf/myshell-ai/melotts";
|
|
2952
3083
|
var CLOUDFLARE_VIDEO_MODEL = "prunaai/p-video";
|
|
3084
|
+
|
|
3085
|
+
// src/providers/cloudflare/models.ts
|
|
2953
3086
|
function required(name) {
|
|
2954
3087
|
const value = resolveKey(name)?.trim();
|
|
2955
3088
|
if (!value) throw new ConfigError(`${name} is required for Cloudflare media generation`);
|
|
@@ -2993,6 +3126,68 @@ function requireCloudflareVideoConfig() {
|
|
|
2993
3126
|
};
|
|
2994
3127
|
}
|
|
2995
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
|
+
|
|
2996
3191
|
// src/providers/cloudflare/client.ts
|
|
2997
3192
|
function collectPayloadHeader() {
|
|
2998
3193
|
const envName = ["CLOUDFLARE", "AI", "GATEWAY", "COLLECT", "LOG", "PAYLOAD"].join("_");
|
|
@@ -3006,17 +3201,6 @@ function gatewayHeaders(config) {
|
|
|
3006
3201
|
"cf-aig-collect-log-payload": collectPayloadHeader()
|
|
3007
3202
|
};
|
|
3008
3203
|
}
|
|
3009
|
-
function providerFailure(status) {
|
|
3010
|
-
return new ProviderError(`Cloudflare request failed with HTTP ${status}`, "Cloudflare");
|
|
3011
|
-
}
|
|
3012
|
-
async function readJson(response) {
|
|
3013
|
-
if (!response.ok) throw providerFailure(response.status);
|
|
3014
|
-
try {
|
|
3015
|
-
return await response.json();
|
|
3016
|
-
} catch {
|
|
3017
|
-
throw new ProviderError("Cloudflare returned an invalid JSON response", "Cloudflare");
|
|
3018
|
-
}
|
|
3019
|
-
}
|
|
3020
3204
|
function nativeUrl(accountId, model) {
|
|
3021
3205
|
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/run/${model}`;
|
|
3022
3206
|
}
|
|
@@ -3034,7 +3218,7 @@ async function runCloudflareJson(model, input) {
|
|
|
3034
3218
|
body: JSON.stringify(useGateway ? { model, input } : input)
|
|
3035
3219
|
}
|
|
3036
3220
|
);
|
|
3037
|
-
const payload = await
|
|
3221
|
+
const payload = await readCloudflareJson(response);
|
|
3038
3222
|
if (payload.success === false || payload.result === void 0) {
|
|
3039
3223
|
throw new ProviderError("Cloudflare returned no usable result", "Cloudflare");
|
|
3040
3224
|
}
|
|
@@ -3051,53 +3235,23 @@ async function runCloudflareBytes(model, input) {
|
|
|
3051
3235
|
body: JSON.stringify(useGateway ? { model, input } : input)
|
|
3052
3236
|
}
|
|
3053
3237
|
);
|
|
3054
|
-
if (!response.ok) throw
|
|
3238
|
+
if (!response.ok) throw cloudflareProviderFailure(response.status);
|
|
3055
3239
|
const contentType = response.headers.get("content-type") ?? "";
|
|
3056
3240
|
if (!contentType.includes("audio/mpeg")) {
|
|
3057
3241
|
throw new ProviderError("Cloudflare did not return MPEG audio", "Cloudflare");
|
|
3058
3242
|
}
|
|
3059
3243
|
return new Uint8Array(await response.arrayBuffer());
|
|
3060
3244
|
}
|
|
3061
|
-
function videoUrl(config, suffix = "") {
|
|
3062
|
-
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(config.accountId)}/${encodeURIComponent(config.gatewayId)}/replicate/predictions${suffix}`;
|
|
3063
|
-
}
|
|
3064
|
-
function videoHeaders(config, wait = false) {
|
|
3065
|
-
return {
|
|
3066
|
-
Authorization: `Bearer ${config.replicateApiToken}`,
|
|
3067
|
-
"cf-aig-authorization": `Bearer ${config.apiToken}`,
|
|
3068
|
-
"cf-aig-collect-log-payload": collectPayloadHeader(),
|
|
3069
|
-
"Content-Type": "application/json",
|
|
3070
|
-
...wait ? { Prefer: "wait" } : {}
|
|
3071
|
-
};
|
|
3072
|
-
}
|
|
3073
|
-
function prediction(value) {
|
|
3074
|
-
if (!value || typeof value !== "object") {
|
|
3075
|
-
throw new ProviderError("Cloudflare returned an invalid video prediction", "Cloudflare");
|
|
3076
|
-
}
|
|
3077
|
-
const result = value;
|
|
3078
|
-
if (typeof result.id !== "string" || typeof result.status !== "string") {
|
|
3079
|
-
throw new ProviderError("Cloudflare returned an invalid video prediction", "Cloudflare");
|
|
3080
|
-
}
|
|
3081
|
-
if (result.output !== void 0 && typeof result.output !== "string" && (!Array.isArray(result.output) || !result.output.every((item) => typeof item === "string"))) {
|
|
3082
|
-
throw new ProviderError("Cloudflare returned an invalid video output", "Cloudflare");
|
|
3083
|
-
}
|
|
3084
|
-
return { id: result.id, status: result.status, output: result.output };
|
|
3085
|
-
}
|
|
3086
3245
|
async function createVideoPrediction(input, wait = false) {
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
headers: videoHeaders(config, wait),
|
|
3091
|
-
body: JSON.stringify({ version: CLOUDFLARE_VIDEO_MODEL, input })
|
|
3246
|
+
return createCloudflareVideoPrediction(requireCloudflareVideoConfig(), input, {
|
|
3247
|
+
wait,
|
|
3248
|
+
collectLogPayload: collectPayloadHeader() === "true"
|
|
3092
3249
|
});
|
|
3093
|
-
return prediction(await readJson(response));
|
|
3094
3250
|
}
|
|
3095
3251
|
async function getVideoPrediction(id) {
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
headers: videoHeaders(config)
|
|
3252
|
+
return getCloudflareVideoPrediction(requireCloudflareVideoConfig(), id, {
|
|
3253
|
+
collectLogPayload: collectPayloadHeader() === "true"
|
|
3099
3254
|
});
|
|
3100
|
-
return prediction(await readJson(response));
|
|
3101
3255
|
}
|
|
3102
3256
|
async function downloadVideo2(url) {
|
|
3103
3257
|
let response;
|
|
@@ -3174,20 +3328,20 @@ function positiveInteger(value, flag) {
|
|
|
3174
3328
|
throw new ValidationError(`${flag} must be a positive integer`);
|
|
3175
3329
|
return parsed;
|
|
3176
3330
|
}
|
|
3177
|
-
function outputUrl(
|
|
3178
|
-
if (typeof
|
|
3179
|
-
return
|
|
3331
|
+
function outputUrl(prediction) {
|
|
3332
|
+
if (typeof prediction.output === "string") return prediction.output;
|
|
3333
|
+
return prediction.output?.find((value) => typeof value === "string");
|
|
3180
3334
|
}
|
|
3181
|
-
async function saveCompletedVideo(
|
|
3182
|
-
const url = outputUrl(
|
|
3335
|
+
async function saveCompletedVideo(prediction, output) {
|
|
3336
|
+
const url = outputUrl(prediction);
|
|
3183
3337
|
if (!url)
|
|
3184
3338
|
throw new ProviderError("Cloudflare completed the video without an output", "Cloudflare");
|
|
3185
3339
|
const bytes = await downloadVideo2(url);
|
|
3186
|
-
return saveBytes(bytes, `cloudflare_video_${
|
|
3340
|
+
return saveBytes(bytes, `cloudflare_video_${prediction.id}.mp4`, output);
|
|
3187
3341
|
}
|
|
3188
|
-
async function waitForVideo(
|
|
3342
|
+
async function waitForVideo(prediction, timeoutMs, onTick) {
|
|
3189
3343
|
const deadline = Date.now() + timeoutMs;
|
|
3190
|
-
let current =
|
|
3344
|
+
let current = prediction;
|
|
3191
3345
|
while (!isTerminalVideoStatus(current.status)) {
|
|
3192
3346
|
if (Date.now() >= deadline)
|
|
3193
3347
|
throw new ProviderError("Cloudflare video polling timed out", "Cloudflare");
|
|
@@ -3219,15 +3373,15 @@ function registerCloudflareVideoCommand(parent) {
|
|
|
3219
3373
|
fps: positiveInteger(opts.fps, "--fps")
|
|
3220
3374
|
};
|
|
3221
3375
|
const shouldWait = Boolean(opts.wait || opts.download);
|
|
3222
|
-
const
|
|
3223
|
-
logger.success(`Video job ${
|
|
3376
|
+
const prediction = await createVideoPrediction(input, shouldWait);
|
|
3377
|
+
logger.success(`Video job ${prediction.id} (${prediction.status})`);
|
|
3224
3378
|
if (!shouldWait) {
|
|
3225
|
-
console.log(
|
|
3226
|
-
console.error(`Poll with: multix cloudflare video-status ${
|
|
3379
|
+
console.log(prediction.id);
|
|
3380
|
+
console.error(`Poll with: multix cloudflare video-status ${prediction.id}`);
|
|
3227
3381
|
return;
|
|
3228
3382
|
}
|
|
3229
|
-
const completed =
|
|
3230
|
-
|
|
3383
|
+
const completed = prediction.status === "succeeded" ? prediction : await waitForVideo(
|
|
3384
|
+
prediction,
|
|
3231
3385
|
positiveInteger(opts.waitTimeout, "--wait-timeout"),
|
|
3232
3386
|
(status) => logger.debug(`Video status: ${status}`)
|
|
3233
3387
|
);
|
|
@@ -3277,12 +3431,12 @@ function registerCloudflareVideoStatusCommand(parent) {
|
|
|
3277
3431
|
async (id, opts) => {
|
|
3278
3432
|
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
3279
3433
|
const shouldWait = Boolean(opts.wait || opts.download);
|
|
3280
|
-
const
|
|
3281
|
-
const completed = shouldWait &&
|
|
3282
|
-
|
|
3434
|
+
const prediction = await getVideoPrediction(id);
|
|
3435
|
+
const completed = shouldWait && prediction.status !== "succeeded" ? await waitForVideo(
|
|
3436
|
+
prediction,
|
|
3283
3437
|
positiveInteger(opts.waitTimeout, "--wait-timeout"),
|
|
3284
3438
|
(status) => logger.debug(`Video status: ${status}`)
|
|
3285
|
-
) :
|
|
3439
|
+
) : prediction;
|
|
3286
3440
|
console.log(`${completed.id}: ${completed.status}`);
|
|
3287
3441
|
if (opts.download) {
|
|
3288
3442
|
if (completed.status !== "succeeded") {
|
|
@@ -6080,7 +6234,7 @@ var HEYGEN_OPERATIONS = {
|
|
|
6080
6234
|
};
|
|
6081
6235
|
|
|
6082
6236
|
// src/providers/heygen/node-helpers.ts
|
|
6083
|
-
import { once
|
|
6237
|
+
import { once } from "events";
|
|
6084
6238
|
import fs29 from "fs";
|
|
6085
6239
|
import path32 from "path";
|
|
6086
6240
|
import { pipeline as pipeline2 } from "stream/promises";
|
|
@@ -7087,7 +7241,7 @@ function inferExtension(url, fallback) {
|
|
|
7087
7241
|
function safeSlug(value) {
|
|
7088
7242
|
return value.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "out";
|
|
7089
7243
|
}
|
|
7090
|
-
var
|
|
7244
|
+
var MAX_REDIRECTS2 = 5;
|
|
7091
7245
|
var isRedirect = (status) => [301, 302, 303, 307, 308].includes(status);
|
|
7092
7246
|
async function downloadHttpsOnly(url, dest, timeoutMs) {
|
|
7093
7247
|
const signal = AbortSignal.timeout(timeoutMs);
|
|
@@ -7109,7 +7263,7 @@ async function downloadHttpsOnly(url, dest, timeoutMs) {
|
|
|
7109
7263
|
const location = response.headers.get("location");
|
|
7110
7264
|
if (isRedirect(response.status) && location) {
|
|
7111
7265
|
await response.body?.cancel();
|
|
7112
|
-
if (hop >=
|
|
7266
|
+
if (hop >= MAX_REDIRECTS2) throw new HttpError(0, "too many redirects", url);
|
|
7113
7267
|
current = new URL(location, current).href;
|
|
7114
7268
|
continue;
|
|
7115
7269
|
}
|
|
@@ -7120,7 +7274,7 @@ async function downloadHttpsOnly(url, dest, timeoutMs) {
|
|
|
7120
7274
|
try {
|
|
7121
7275
|
await pipeline2(response.body, fileStream);
|
|
7122
7276
|
} catch (err2) {
|
|
7123
|
-
if (!fileStream.closed) await
|
|
7277
|
+
if (!fileStream.closed) await once(fileStream, "close");
|
|
7124
7278
|
throw err2;
|
|
7125
7279
|
}
|
|
7126
7280
|
return;
|
|
@@ -10225,8 +10379,8 @@ async function downloadOpenRouterVideo(opts) {
|
|
|
10225
10379
|
}
|
|
10226
10380
|
fs43.mkdirSync(path50.dirname(dest), { recursive: true });
|
|
10227
10381
|
const ws = fs43.createWriteStream(dest);
|
|
10228
|
-
const { pipeline:
|
|
10229
|
-
await
|
|
10382
|
+
const { pipeline: pipeline3 } = await import("stream/promises");
|
|
10383
|
+
await pipeline3(r.body, ws);
|
|
10230
10384
|
logger.success(`Saved ${dest}`);
|
|
10231
10385
|
if (opts.output) {
|
|
10232
10386
|
fs43.mkdirSync(path50.dirname(path50.resolve(opts.output)), { recursive: true });
|
|
@@ -10484,13 +10638,13 @@ var ReplicateApiError = class extends ProviderError {
|
|
|
10484
10638
|
retryAfterMs;
|
|
10485
10639
|
};
|
|
10486
10640
|
var ReplicatePredictionError = class extends ProviderError {
|
|
10487
|
-
constructor(
|
|
10488
|
-
const detail = formatPredictionError(
|
|
10641
|
+
constructor(prediction) {
|
|
10642
|
+
const detail = formatPredictionError(prediction.error);
|
|
10489
10643
|
super(
|
|
10490
|
-
`Replicate prediction ${
|
|
10644
|
+
`Replicate prediction ${prediction.id} ${prediction.status}${detail ? `: ${detail}` : ""}`,
|
|
10491
10645
|
PROVIDER
|
|
10492
10646
|
);
|
|
10493
|
-
this.prediction =
|
|
10647
|
+
this.prediction = prediction;
|
|
10494
10648
|
this.name = "ReplicatePredictionError";
|
|
10495
10649
|
}
|
|
10496
10650
|
prediction;
|
|
@@ -10537,6 +10691,19 @@ function mapReplicateError(error, subject) {
|
|
|
10537
10691
|
}
|
|
10538
10692
|
return new ReplicateApiError(message, status, retryAfterMs);
|
|
10539
10693
|
}
|
|
10694
|
+
var ORPHAN_HINT = "The prediction may have been created anyway (Replicate has no idempotency key); check https://replicate.com/predictions before retrying to avoid paying twice";
|
|
10695
|
+
function withOrphanHint(error) {
|
|
10696
|
+
if (error instanceof ReplicateApiError) {
|
|
10697
|
+
const { status } = error;
|
|
10698
|
+
const mayExist = status === 0 || status >= 200 && status < 300 || status >= 500;
|
|
10699
|
+
if (!mayExist) return error;
|
|
10700
|
+
return new ReplicateApiError(`${error.message}. ${ORPHAN_HINT}`, status, error.retryAfterMs);
|
|
10701
|
+
}
|
|
10702
|
+
if (error instanceof ProviderError) {
|
|
10703
|
+
return new ProviderError(`${error.message}. ${ORPHAN_HINT}`, error.provider);
|
|
10704
|
+
}
|
|
10705
|
+
return error;
|
|
10706
|
+
}
|
|
10540
10707
|
function validatePrediction(value) {
|
|
10541
10708
|
if (value === null || typeof value !== "object" || typeof value.id !== "string" || typeof value.status !== "string") {
|
|
10542
10709
|
throw new ProviderError("Malformed Replicate response: missing prediction id/status", PROVIDER);
|
|
@@ -10546,11 +10713,11 @@ function validatePrediction(value) {
|
|
|
10546
10713
|
}
|
|
10547
10714
|
return value;
|
|
10548
10715
|
}
|
|
10549
|
-
function assertSucceeded(
|
|
10550
|
-
if (
|
|
10551
|
-
if (isTerminalStatus2(
|
|
10716
|
+
function assertSucceeded(prediction) {
|
|
10717
|
+
if (prediction.status === "succeeded") return prediction;
|
|
10718
|
+
if (isTerminalStatus2(prediction.status)) throw new ReplicatePredictionError(prediction);
|
|
10552
10719
|
throw new ProviderError(
|
|
10553
|
-
`Replicate prediction ${
|
|
10720
|
+
`Replicate prediction ${prediction.id} is not finished (status: ${prediction.status})`,
|
|
10554
10721
|
PROVIDER
|
|
10555
10722
|
);
|
|
10556
10723
|
}
|
|
@@ -10605,7 +10772,7 @@ var ReplicateClient = class {
|
|
|
10605
10772
|
await this.sleep(delay);
|
|
10606
10773
|
continue;
|
|
10607
10774
|
}
|
|
10608
|
-
throw mapReplicateError(error, subject);
|
|
10775
|
+
throw withOrphanHint(mapReplicateError(error, subject));
|
|
10609
10776
|
}
|
|
10610
10777
|
}
|
|
10611
10778
|
}
|
|
@@ -10631,9 +10798,9 @@ var ReplicateClient = class {
|
|
|
10631
10798
|
* canceled, aborted) and return it — callers decide success via
|
|
10632
10799
|
* assertSucceeded. Retries network/429/5xx; throws PollTimeoutError on deadline.
|
|
10633
10800
|
*/
|
|
10634
|
-
async waitForPrediction(
|
|
10635
|
-
if (typeof
|
|
10636
|
-
const safeId = assertPredictionId(typeof
|
|
10801
|
+
async waitForPrediction(prediction, opts) {
|
|
10802
|
+
if (typeof prediction !== "string" && isTerminalStatus2(prediction.status)) return prediction;
|
|
10803
|
+
const safeId = assertPredictionId(typeof prediction === "string" ? prediction : prediction.id);
|
|
10637
10804
|
try {
|
|
10638
10805
|
return await pollUntil({
|
|
10639
10806
|
fetchStatus: async ({ remainingMs }) => validatePrediction(
|
|
@@ -10736,13 +10903,13 @@ function registerReplicateCancelCommand(parent) {
|
|
|
10736
10903
|
parent.command("cancel <predictionId>").description("Cancel a running Replicate prediction").option("-v, --verbose", "Verbose logging").action(async (predictionId, opts) => {
|
|
10737
10904
|
const logger = createLogger({ verbose: opts.verbose ?? false });
|
|
10738
10905
|
const client = createReplicateClient();
|
|
10739
|
-
const
|
|
10740
|
-
if (
|
|
10741
|
-
logger.success(`Prediction ${
|
|
10742
|
-
} else if (isTerminalStatus2(
|
|
10743
|
-
logger.warn(`Prediction ${
|
|
10906
|
+
const prediction = await client.cancelPrediction(predictionId);
|
|
10907
|
+
if (prediction.status === "canceled") {
|
|
10908
|
+
logger.success(`Prediction ${prediction.id} canceled`);
|
|
10909
|
+
} else if (isTerminalStatus2(prediction.status)) {
|
|
10910
|
+
logger.warn(`Prediction ${prediction.id} already finished (status: ${prediction.status})`);
|
|
10744
10911
|
} else {
|
|
10745
|
-
logger.info(`Cancel requested for ${
|
|
10912
|
+
logger.info(`Cancel requested for ${prediction.id} (status: ${prediction.status})`);
|
|
10746
10913
|
}
|
|
10747
10914
|
});
|
|
10748
10915
|
}
|
|
@@ -10752,23 +10919,137 @@ init_errors();
|
|
|
10752
10919
|
|
|
10753
10920
|
// src/providers/replicate/commands/prediction-flow.ts
|
|
10754
10921
|
init_errors();
|
|
10922
|
+
import { InvalidArgumentError, Option as Option2 } from "commander";
|
|
10755
10923
|
|
|
10756
10924
|
// src/providers/replicate/node-outputs.ts
|
|
10757
10925
|
init_errors();
|
|
10758
|
-
init_redact();
|
|
10759
|
-
import { once as once3 } from "events";
|
|
10760
10926
|
import fs44 from "fs";
|
|
10761
10927
|
import path51 from "path";
|
|
10762
|
-
|
|
10763
|
-
import { pipeline as pipeline3 } from "stream/promises";
|
|
10928
|
+
init_redact();
|
|
10764
10929
|
|
|
10765
10930
|
// src/providers/replicate/output.ts
|
|
10766
10931
|
init_redact();
|
|
10932
|
+
|
|
10933
|
+
// src/core/url-safety.ts
|
|
10934
|
+
init_errors();
|
|
10935
|
+
function ipv4Octets(host) {
|
|
10936
|
+
const parts = host.split(".");
|
|
10937
|
+
if (parts.length !== 4 || !parts.every((p) => /^\d{1,3}$/.test(p))) return void 0;
|
|
10938
|
+
const octets = parts.map(Number);
|
|
10939
|
+
return octets.every((o) => o <= 255) ? octets : void 0;
|
|
10940
|
+
}
|
|
10941
|
+
function isNonPublicIpv4([a = 0, b = 0, c2 = 0]) {
|
|
10942
|
+
return a === 0 || // "this network" / unspecified
|
|
10943
|
+
a === 10 || a === 127 || // loopback
|
|
10944
|
+
a === 100 && b >= 64 && b <= 127 || // carrier-grade NAT
|
|
10945
|
+
a === 169 && b === 254 || // link-local (cloud metadata)
|
|
10946
|
+
a === 172 && b >= 16 && b <= 31 || a === 192 && b === 0 && c2 === 0 || // IETF protocol assignments
|
|
10947
|
+
a === 192 && b === 0 && c2 === 2 || // TEST-NET-1
|
|
10948
|
+
a === 192 && b === 168 || a === 198 && (b === 18 || b === 19) || // benchmarking
|
|
10949
|
+
a === 198 && b === 51 && c2 === 100 || // TEST-NET-2
|
|
10950
|
+
a === 203 && b === 0 && c2 === 113 || // TEST-NET-3
|
|
10951
|
+
a >= 224;
|
|
10952
|
+
}
|
|
10953
|
+
function ipv6Groups(host) {
|
|
10954
|
+
if (!/^[0-9a-f:.]+$/i.test(host) || !host.includes(":")) return void 0;
|
|
10955
|
+
let text = host;
|
|
10956
|
+
const tail = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(text);
|
|
10957
|
+
if (tail) {
|
|
10958
|
+
const octets = ipv4Octets(tail[2]);
|
|
10959
|
+
if (!octets) return void 0;
|
|
10960
|
+
const [a = 0, b = 0, c2 = 0, d = 0] = octets;
|
|
10961
|
+
text = `${tail[1]}${(a << 8 | b).toString(16)}:${(c2 << 8 | d).toString(16)}`;
|
|
10962
|
+
}
|
|
10963
|
+
const halves = text.split("::");
|
|
10964
|
+
if (halves.length > 2) return void 0;
|
|
10965
|
+
const parse = (s) => s === "" ? [] : s.split(":");
|
|
10966
|
+
const head = parse(halves[0]);
|
|
10967
|
+
const rest = halves.length === 2 ? parse(halves[1]) : [];
|
|
10968
|
+
const missing = 8 - head.length - rest.length;
|
|
10969
|
+
if (halves.length === 2 ? missing < 1 : missing !== 0) return void 0;
|
|
10970
|
+
const all = [...head, ...Array(halves.length === 2 ? missing : 0).fill("0"), ...rest];
|
|
10971
|
+
if (!all.every((g) => /^[0-9a-f]{1,4}$/i.test(g))) return void 0;
|
|
10972
|
+
return all.map((g) => Number.parseInt(g, 16));
|
|
10973
|
+
}
|
|
10974
|
+
function embeddedIpv4(hi, lo) {
|
|
10975
|
+
return [hi >> 8, hi & 255, lo >> 8, lo & 255];
|
|
10976
|
+
}
|
|
10977
|
+
function isNonPublicIpv6(groups) {
|
|
10978
|
+
const [g0 = 0, g1 = 0, g2 = 0, g3 = 0, g4 = 0, g5 = 0, g6 = 0, g7 = 0] = groups;
|
|
10979
|
+
const zeroUpTo = (n) => groups.slice(0, n).every((g) => g === 0);
|
|
10980
|
+
if (zeroUpTo(5) && g5 === 65535) return isNonPublicIpv4(embeddedIpv4(g6, g7));
|
|
10981
|
+
if (g0 === 100 && g1 === 65435 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) {
|
|
10982
|
+
return isNonPublicIpv4(embeddedIpv4(g6, g7));
|
|
10983
|
+
}
|
|
10984
|
+
if (g0 === 8194) return isNonPublicIpv4(embeddedIpv4(g1, g2));
|
|
10985
|
+
return g0 === 0 || // unspecified (::), loopback (::1), IPv4-compatible and other reserved
|
|
10986
|
+
g0 === 256 && g1 === 0 && g2 === 0 && g3 === 0 || // discard-only 100::/64
|
|
10987
|
+
(g0 & 65024) === 64512 || // unique local fc00::/7
|
|
10988
|
+
(g0 & 65472) === 65152 || // link-local fe80::/10
|
|
10989
|
+
(g0 & 65472) === 65216 || // deprecated site-local fec0::/10
|
|
10990
|
+
(g0 & 65280) === 65280;
|
|
10991
|
+
}
|
|
10992
|
+
function bareHost(hostname) {
|
|
10993
|
+
const host = hostname.toLowerCase().replace(/\.$/, "");
|
|
10994
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
10995
|
+
}
|
|
10996
|
+
function isLoopbackHost(hostname) {
|
|
10997
|
+
const host = bareHost(hostname);
|
|
10998
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
10999
|
+
const octets = ipv4Octets(host);
|
|
11000
|
+
if (octets) return octets[0] === 127;
|
|
11001
|
+
const groups = ipv6Groups(host);
|
|
11002
|
+
if (!groups) return false;
|
|
11003
|
+
if (groups.slice(0, 5).every((g) => g === 0) && groups[5] === 65535) {
|
|
11004
|
+
return groups[6] !== void 0 && groups[6] >> 8 === 127;
|
|
11005
|
+
}
|
|
11006
|
+
return groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1;
|
|
11007
|
+
}
|
|
11008
|
+
function isNonPublicHost(hostname) {
|
|
11009
|
+
const host = bareHost(hostname);
|
|
11010
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
11011
|
+
const octets = ipv4Octets(host);
|
|
11012
|
+
if (octets) return isNonPublicIpv4(octets);
|
|
11013
|
+
const groups = ipv6Groups(host);
|
|
11014
|
+
return groups !== void 0 && isNonPublicIpv6(groups);
|
|
11015
|
+
}
|
|
11016
|
+
function checkUrlSafety(url, opts) {
|
|
11017
|
+
const action = opts.action ?? "download from";
|
|
11018
|
+
let parsed;
|
|
11019
|
+
try {
|
|
11020
|
+
parsed = new URL(url);
|
|
11021
|
+
} catch {
|
|
11022
|
+
throw new ProviderError(`Refusing to ${action} a malformed URL`, opts.provider);
|
|
11023
|
+
}
|
|
11024
|
+
const host = parsed.hostname.toLowerCase().replace(/\.$/, "");
|
|
11025
|
+
const localOk = opts.allowLocalhostHttp === true && isLoopbackHost(host);
|
|
11026
|
+
const httpOk = parsed.protocol === "https:" || localOk && parsed.protocol === "http:";
|
|
11027
|
+
if (!httpOk) {
|
|
11028
|
+
throw new ProviderError(
|
|
11029
|
+
`Refusing to ${action} ${host || "this URL"}: only https:// URLs are allowed`,
|
|
11030
|
+
opts.provider
|
|
11031
|
+
);
|
|
11032
|
+
}
|
|
11033
|
+
if (!localOk && isNonPublicHost(host)) {
|
|
11034
|
+
throw new ProviderError(
|
|
11035
|
+
`Refusing to ${action} ${host}: it points to a local or private address`,
|
|
11036
|
+
opts.provider
|
|
11037
|
+
);
|
|
11038
|
+
}
|
|
11039
|
+
const trusted = (opts.trustedHosts ?? []).some((t) => host === t || host.endsWith(`.${t}`));
|
|
11040
|
+
return { host, trusted };
|
|
11041
|
+
}
|
|
11042
|
+
|
|
11043
|
+
// src/providers/replicate/output.ts
|
|
10767
11044
|
var HTTP_RE = /^https?:\/\//i;
|
|
10768
11045
|
var DATA_URI_RE = /^data:[^,]*,/i;
|
|
10769
11046
|
function isMediaRef(value) {
|
|
10770
11047
|
return typeof value === "string" && (HTTP_RE.test(value) || DATA_URI_RE.test(value));
|
|
10771
11048
|
}
|
|
11049
|
+
var REPLICATE_DELIVERY_HOST = "replicate.delivery";
|
|
11050
|
+
function checkOutputUrl(url) {
|
|
11051
|
+
return checkUrlSafety(url, { provider: "replicate", trustedHosts: [REPLICATE_DELIVERY_HOST] });
|
|
11052
|
+
}
|
|
10772
11053
|
function extractOutput(output) {
|
|
10773
11054
|
const result = { media: [], texts: [] };
|
|
10774
11055
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -10927,59 +11208,53 @@ function resolveDestination(predictionId, index, count, ext, opts) {
|
|
|
10927
11208
|
const base = givenExt ? output.slice(0, -givenExt.length) : output;
|
|
10928
11209
|
return `${base}-${String(index + 1).padStart(2, "0")}${ext}`;
|
|
10929
11210
|
}
|
|
10930
|
-
async function writeResponse(response, dest) {
|
|
10931
|
-
if (!response.body) throw new HttpError(0, "Response body is null", response.url);
|
|
10932
|
-
const fileStream = fs44.createWriteStream(dest);
|
|
10933
|
-
try {
|
|
10934
|
-
await pipeline3(
|
|
10935
|
-
Readable.fromWeb(response.body),
|
|
10936
|
-
fileStream
|
|
10937
|
-
);
|
|
10938
|
-
} catch (err2) {
|
|
10939
|
-
if (!fileStream.closed) await once3(fileStream, "close");
|
|
10940
|
-
throw err2;
|
|
10941
|
-
}
|
|
10942
|
-
}
|
|
10943
11211
|
async function saveOne(item, predictionId, index, count, opts) {
|
|
10944
11212
|
if (item.url.startsWith("data:")) {
|
|
10945
11213
|
const comma = item.url.indexOf(",");
|
|
10946
11214
|
const meta = item.url.slice(5, comma);
|
|
10947
11215
|
const payload = item.url.slice(comma + 1);
|
|
10948
11216
|
const bytes = meta.endsWith(";base64") ? Buffer.from(payload, "base64") : Buffer.from(decodeURIComponent(payload));
|
|
10949
|
-
const
|
|
11217
|
+
const dest = resolveDestination(
|
|
10950
11218
|
predictionId,
|
|
10951
11219
|
index,
|
|
10952
11220
|
count,
|
|
10953
11221
|
extFromUrl4(item.url) ?? ".bin",
|
|
10954
11222
|
opts
|
|
10955
11223
|
);
|
|
10956
|
-
fs44.mkdirSync(path51.dirname(
|
|
10957
|
-
fs44.writeFileSync(
|
|
10958
|
-
return
|
|
10959
|
-
}
|
|
10960
|
-
const doFetch = opts.fetch ?? ((url, init) => globalThis.fetch(url, init));
|
|
10961
|
-
let response;
|
|
10962
|
-
try {
|
|
10963
|
-
response = await doFetch(item.url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
10964
|
-
} catch (cause) {
|
|
10965
|
-
throw new HttpError(0, cause instanceof Error ? cause.message : String(cause), item.url);
|
|
10966
|
-
}
|
|
10967
|
-
if (!response.ok) {
|
|
10968
|
-
const hint = response.status === 404 || response.status === 410 ? " (output may have expired)" : "";
|
|
10969
|
-
throw new HttpError(response.status, `download failed${hint}`, item.url);
|
|
11224
|
+
fs44.mkdirSync(path51.dirname(dest), { recursive: true });
|
|
11225
|
+
fs44.writeFileSync(dest, bytes);
|
|
11226
|
+
return dest;
|
|
10970
11227
|
}
|
|
10971
|
-
const
|
|
10972
|
-
const dest = resolveDestination(predictionId, index, count, ext, opts);
|
|
10973
|
-
fs44.mkdirSync(path51.dirname(dest), { recursive: true });
|
|
11228
|
+
const warned = opts.warnedHosts;
|
|
10974
11229
|
try {
|
|
10975
|
-
await
|
|
11230
|
+
const { dest } = await downloadToFile(item.url, {
|
|
11231
|
+
dest: (response) => {
|
|
11232
|
+
const ext = extFromUrl4(item.url) ?? extFromContentType(response.headers.get("content-type")) ?? ".bin";
|
|
11233
|
+
return resolveDestination(predictionId, index, count, ext, opts);
|
|
11234
|
+
},
|
|
11235
|
+
timeoutMs: DOWNLOAD_TIMEOUT_MS,
|
|
11236
|
+
maxBytes: opts.maxBytes,
|
|
11237
|
+
fetch: opts.fetch,
|
|
11238
|
+
validateUrl: (url) => {
|
|
11239
|
+
const { host, trusted } = checkOutputUrl(url);
|
|
11240
|
+
if (!trusted && !warned.has(host)) {
|
|
11241
|
+
warned.add(host);
|
|
11242
|
+
opts.logger.warn(
|
|
11243
|
+
`Output ${item.path} is served from ${host}, not ${REPLICATE_DELIVERY_HOST}`
|
|
11244
|
+
);
|
|
11245
|
+
}
|
|
11246
|
+
}
|
|
11247
|
+
});
|
|
11248
|
+
return dest;
|
|
10976
11249
|
} catch (err2) {
|
|
10977
|
-
|
|
11250
|
+
if (err2 instanceof HttpError && (err2.status === 404 || err2.status === 410)) {
|
|
11251
|
+
throw new HttpError(err2.status, "download failed (output may have expired)", err2.rawUrl);
|
|
11252
|
+
}
|
|
10978
11253
|
throw err2;
|
|
10979
11254
|
}
|
|
10980
|
-
return dest;
|
|
10981
11255
|
}
|
|
10982
11256
|
async function saveMediaOutputs(predictionId, media, opts) {
|
|
11257
|
+
const withCap = { ...opts, maxBytes: maxDownloadBytes(), warnedHosts: /* @__PURE__ */ new Set() };
|
|
10983
11258
|
const saved = [];
|
|
10984
11259
|
const failed = [];
|
|
10985
11260
|
for (let i = 0; i < media.length; i++) {
|
|
@@ -10987,7 +11262,7 @@ async function saveMediaOutputs(predictionId, media, opts) {
|
|
|
10987
11262
|
if (!item) continue;
|
|
10988
11263
|
opts.logger.debug(`Downloading ${item.path} from ${redactUrl(item.url).slice(0, 200)}`);
|
|
10989
11264
|
try {
|
|
10990
|
-
const dest = await saveOne(item, predictionId, i, media.length,
|
|
11265
|
+
const dest = await saveOne(item, predictionId, i, media.length, withCap);
|
|
10991
11266
|
saved.push(dest);
|
|
10992
11267
|
opts.logger.success(`Saved ${dest}`);
|
|
10993
11268
|
} catch (e) {
|
|
@@ -11004,10 +11279,22 @@ async function saveMediaOutputs(predictionId, media, opts) {
|
|
|
11004
11279
|
}
|
|
11005
11280
|
|
|
11006
11281
|
// src/providers/replicate/commands/prediction-flow.ts
|
|
11282
|
+
var DEFAULT_PREFER_WAIT_SECONDS = 60;
|
|
11283
|
+
function parsePreferWaitSeconds(raw) {
|
|
11284
|
+
const n = Number(raw);
|
|
11285
|
+
if (!/^\d+$/.test(raw.trim()) || n < 1 || n > 60) {
|
|
11286
|
+
throw new InvalidArgumentError(
|
|
11287
|
+
`Expected an integer number of seconds from 1 to 60 (a bare --prefer-wait means ${DEFAULT_PREFER_WAIT_SECONDS}); put the prompt before the flag.`
|
|
11288
|
+
);
|
|
11289
|
+
}
|
|
11290
|
+
return n;
|
|
11291
|
+
}
|
|
11007
11292
|
function addFlowOptions(cmd, defaultTimeoutMs) {
|
|
11008
|
-
return cmd.option("--async", "Submit and return immediately with the prediction id").option("--wait", "Wait for a terminal status (default unless --async)").
|
|
11009
|
-
|
|
11010
|
-
|
|
11293
|
+
return cmd.option("--async", "Submit and return immediately with the prediction id").option("--wait", "Wait for a terminal status (default unless --async)").addOption(
|
|
11294
|
+
new Option2(
|
|
11295
|
+
"--prefer-wait [seconds]",
|
|
11296
|
+
"Ask Replicate to hold the create request open for 1-60s (bare flag: 60); keeps polling if still running"
|
|
11297
|
+
).argParser(parsePreferWaitSeconds).preset(String(DEFAULT_PREFER_WAIT_SECONDS))
|
|
11011
11298
|
).option("--wait-timeout <ms>", "Polling timeout in milliseconds", String(defaultTimeoutMs)).option("--download", "Download media outputs (default)").option("--no-download", "Print output URLs instead of downloading").option("--output <path>", "Output file path, or a directory for multiple outputs").option("-v, --verbose", "Verbose logging");
|
|
11012
11299
|
}
|
|
11013
11300
|
function parsePositiveInt2(raw, flag, max) {
|
|
@@ -11019,50 +11306,45 @@ function parsePositiveInt2(raw, flag, max) {
|
|
|
11019
11306
|
}
|
|
11020
11307
|
return n;
|
|
11021
11308
|
}
|
|
11022
|
-
function parsePreferWait(value) {
|
|
11023
|
-
if (value === void 0 || value === false) return void 0;
|
|
11024
|
-
if (value === true) return 60;
|
|
11025
|
-
return parsePositiveInt2(value, "--prefer-wait", 60);
|
|
11026
|
-
}
|
|
11027
11309
|
function resumeCommand3(id) {
|
|
11028
11310
|
return `multix replicate status ${id} --wait --download`;
|
|
11029
11311
|
}
|
|
11030
|
-
function printAsyncHandle(
|
|
11031
|
-
console.log(`Prediction submitted: ${
|
|
11032
|
-
console.log(` Check: multix replicate status ${
|
|
11033
|
-
console.log(` Resume: ${resumeCommand3(
|
|
11034
|
-
console.log(` Cancel: multix replicate cancel ${
|
|
11312
|
+
function printAsyncHandle(prediction) {
|
|
11313
|
+
console.log(`Prediction submitted: ${prediction.id} (status: ${prediction.status})`);
|
|
11314
|
+
console.log(` Check: multix replicate status ${prediction.id}`);
|
|
11315
|
+
console.log(` Resume: ${resumeCommand3(prediction.id)}`);
|
|
11316
|
+
console.log(` Cancel: multix replicate cancel ${prediction.id}`);
|
|
11035
11317
|
}
|
|
11036
|
-
async function reportOutputs(
|
|
11037
|
-
const { media, texts } = extractOutput(
|
|
11318
|
+
async function reportOutputs(prediction, opts) {
|
|
11319
|
+
const { media, texts } = extractOutput(prediction.output);
|
|
11038
11320
|
for (const text of texts) console.log(text);
|
|
11039
11321
|
if (media.length === 0) {
|
|
11040
|
-
if (texts.length === 0) console.log(JSON.stringify(summarizePrediction(
|
|
11322
|
+
if (texts.length === 0) console.log(JSON.stringify(summarizePrediction(prediction), null, 2));
|
|
11041
11323
|
return [];
|
|
11042
11324
|
}
|
|
11043
11325
|
if (!opts.download) {
|
|
11044
11326
|
for (const m of media) console.log(m.url);
|
|
11045
11327
|
return [];
|
|
11046
11328
|
}
|
|
11047
|
-
const saved = await saveMediaOutputs(
|
|
11329
|
+
const saved = await saveMediaOutputs(prediction.id, media, {
|
|
11048
11330
|
outDir: opts.output ? "" : getOutputDir(),
|
|
11049
11331
|
output: opts.output,
|
|
11050
11332
|
logger: opts.logger,
|
|
11051
|
-
retryHint: `Retry while the output URLs are live: multix replicate status ${
|
|
11333
|
+
retryHint: `Retry while the output URLs are live: multix replicate status ${prediction.id} --download`
|
|
11052
11334
|
});
|
|
11053
11335
|
const finals = opts.finalize ? opts.finalize(saved) : saved;
|
|
11054
11336
|
console.log(`
|
|
11055
|
-
Saved ${finals.length} file(s) from prediction ${
|
|
11337
|
+
Saved ${finals.length} file(s) from prediction ${prediction.id}:`);
|
|
11056
11338
|
for (const f of finals) console.log(` ${f}`);
|
|
11057
11339
|
return finals;
|
|
11058
11340
|
}
|
|
11059
|
-
async function completePrediction(client,
|
|
11060
|
-
const id = typeof
|
|
11061
|
-
let lastStatus = typeof
|
|
11062
|
-
if (typeof
|
|
11341
|
+
async function completePrediction(client, prediction, opts) {
|
|
11342
|
+
const id = typeof prediction === "string" ? prediction : prediction.id;
|
|
11343
|
+
let lastStatus = typeof prediction === "string" ? "" : prediction.status;
|
|
11344
|
+
if (typeof prediction === "string" || !isTerminalStatus2(prediction.status)) {
|
|
11063
11345
|
opts.logger.info(`Waiting for prediction ${id}...`);
|
|
11064
11346
|
}
|
|
11065
|
-
const final = await client.waitForPrediction(
|
|
11347
|
+
const final = await client.waitForPrediction(prediction, {
|
|
11066
11348
|
timeoutMs: opts.waitTimeoutMs,
|
|
11067
11349
|
resumeHint: `Resume with: ${resumeCommand3(id)}`,
|
|
11068
11350
|
onPoll: (p, attempt) => {
|
|
@@ -11078,19 +11360,22 @@ async function executePrediction2(params) {
|
|
|
11078
11360
|
const { client, model, input, opts, logger } = params;
|
|
11079
11361
|
if (opts.async && opts.wait)
|
|
11080
11362
|
throw new ValidationError("--async and --wait are mutually exclusive");
|
|
11081
|
-
if (opts.async && opts.preferWait) {
|
|
11363
|
+
if (opts.async && opts.preferWait !== void 0) {
|
|
11082
11364
|
throw new ValidationError("--async and --prefer-wait are mutually exclusive");
|
|
11083
11365
|
}
|
|
11084
11366
|
const waitTimeoutMs = parsePositiveInt2(opts.waitTimeout, "--wait-timeout");
|
|
11085
|
-
const preferWaitSeconds = parsePreferWait(opts.preferWait);
|
|
11086
11367
|
logger.info(`Submitting to Replicate ${describeModelRef(model)}...`);
|
|
11087
|
-
const
|
|
11368
|
+
const prediction = await client.createPrediction({
|
|
11369
|
+
model,
|
|
11370
|
+
input,
|
|
11371
|
+
preferWaitSeconds: opts.preferWait
|
|
11372
|
+
});
|
|
11088
11373
|
if (opts.async) {
|
|
11089
|
-
printAsyncHandle(
|
|
11374
|
+
printAsyncHandle(prediction);
|
|
11090
11375
|
return;
|
|
11091
11376
|
}
|
|
11092
|
-
logger.info(`Prediction ${
|
|
11093
|
-
await completePrediction(client,
|
|
11377
|
+
logger.info(`Prediction ${prediction.id} (status: ${prediction.status})`);
|
|
11378
|
+
await completePrediction(client, prediction, {
|
|
11094
11379
|
waitTimeoutMs,
|
|
11095
11380
|
logger,
|
|
11096
11381
|
download: opts.download !== false,
|
|
@@ -11156,7 +11441,7 @@ import fs45 from "fs";
|
|
|
11156
11441
|
import path52 from "path";
|
|
11157
11442
|
var DATA_URI_MAX_BYTES = 256 * 1024;
|
|
11158
11443
|
var UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
|
11159
|
-
function
|
|
11444
|
+
function formatMb3(bytes) {
|
|
11160
11445
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
11161
11446
|
}
|
|
11162
11447
|
async function resolveFileInput(value, client, logger) {
|
|
@@ -11172,7 +11457,7 @@ async function resolveFileInput(value, client, logger) {
|
|
|
11172
11457
|
if (stat.size === 0) throw new ValidationError(`Input file is empty: ${value}`);
|
|
11173
11458
|
if (stat.size >= UPLOAD_MAX_BYTES) {
|
|
11174
11459
|
throw new ValidationError(
|
|
11175
|
-
`Input file ${value} is ${
|
|
11460
|
+
`Input file ${value} is ${formatMb3(stat.size)}; Replicate accepts uploads under 100MB. Host it and pass an https URL instead.`
|
|
11176
11461
|
);
|
|
11177
11462
|
}
|
|
11178
11463
|
const filename = path52.basename(filePath);
|
|
@@ -11182,7 +11467,7 @@ async function resolveFileInput(value, client, logger) {
|
|
|
11182
11467
|
logger.debug(`Inlining ${filename} (${stat.size} bytes) as a data URI`);
|
|
11183
11468
|
return `data:${contentType};base64,${bytes.toString("base64")}`;
|
|
11184
11469
|
}
|
|
11185
|
-
logger.info(`Uploading ${filename} (${
|
|
11470
|
+
logger.info(`Uploading ${filename} (${formatMb3(stat.size)}) to Replicate Files...`);
|
|
11186
11471
|
const file = await client.uploadFile({
|
|
11187
11472
|
data: new Blob([bytes], { type: contentType }),
|
|
11188
11473
|
filename,
|
|
@@ -11242,16 +11527,16 @@ function registerReplicateStatusCommand(parent) {
|
|
|
11242
11527
|
});
|
|
11243
11528
|
return;
|
|
11244
11529
|
}
|
|
11245
|
-
const
|
|
11246
|
-
if (
|
|
11247
|
-
await reportOutputs(
|
|
11530
|
+
const prediction = await client.getPrediction(predictionId);
|
|
11531
|
+
if (prediction.status === "succeeded" && opts.download) {
|
|
11532
|
+
await reportOutputs(prediction, { download: true, output: opts.output, logger });
|
|
11248
11533
|
return;
|
|
11249
11534
|
}
|
|
11250
|
-
console.log(JSON.stringify(summarizePrediction(
|
|
11251
|
-
if (!isTerminalStatus2(
|
|
11252
|
-
logger.info(`Still running. Resume with: ${resumeCommand3(
|
|
11253
|
-
} else if (
|
|
11254
|
-
throw new ReplicatePredictionError(
|
|
11535
|
+
console.log(JSON.stringify(summarizePrediction(prediction), null, 2));
|
|
11536
|
+
if (!isTerminalStatus2(prediction.status)) {
|
|
11537
|
+
logger.info(`Still running. Resume with: ${resumeCommand3(prediction.id)}`);
|
|
11538
|
+
} else if (prediction.status !== "succeeded") {
|
|
11539
|
+
throw new ReplicatePredictionError(prediction);
|
|
11255
11540
|
}
|
|
11256
11541
|
});
|
|
11257
11542
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrgoonie/multix",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
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.0 # x-release-please-version
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# multix — AI Multimodal CLI
|
|
@@ -178,7 +178,9 @@ multix replicate status <prediction-id> --wait --download
|
|
|
178
178
|
multix replicate cancel <prediction-id>
|
|
179
179
|
```
|
|
180
180
|
|
|
181
|
-
Shared flags: `--async`, `--wait`, `--prefer-wait [seconds]` (sends `Prefer: wait=N`, still polls if unfinished), `--wait-timeout <ms>`, `--no-download` (print URLs), `--output <path|dir/>`. `failed`/`canceled`/`aborted` predictions exit non-zero. Local files up to 256 KB are inlined as data URIs; larger files (< 100 MB) go through the Replicate Files API.
|
|
181
|
+
Shared flags: `--async`, `--wait`, `--prefer-wait [seconds]` (integer 1-60, bare flag = 60; sends `Prefer: wait=N`, still polls if unfinished; put the prompt before a bare flag), `--wait-timeout <ms>`, `--no-download` (print URLs), `--output <path|dir/>`. `failed`/`canceled`/`aborted` predictions exit non-zero. Local files up to 256 KB are inlined as data URIs; larger files (< 100 MB) go through the Replicate Files API.
|
|
182
|
+
|
|
183
|
+
Output downloads are `https://` only; `localhost` and loopback/private/link-local/special-purpose IP literals (including redirects; hostnames are not resolved) are refused, non-`replicate.delivery` hosts are downloaded with a warning, and each file is capped at `MULTIX_MAX_DOWNLOAD_MB` (default 2048). If submitting fails with a network error, timeout, 5xx, or an unreadable 2xx response, the prediction may still exist and bill: check https://replicate.com/predictions before retrying instead of resubmitting blindly.
|
|
182
184
|
|
|
183
185
|
## HeyGen
|
|
184
186
|
|
|
@@ -384,6 +386,7 @@ Requires `cwebp` (libwebp: `brew install webp`, `apt install webp`) on `PATH`.
|
|
|
384
386
|
| Variable | Purpose |
|
|
385
387
|
|---|---|
|
|
386
388
|
| `MULTIX_OUTPUT_DIR` | Override default output dir |
|
|
389
|
+
| `MULTIX_MAX_DOWNLOAD_MB` | Size limit per downloaded Replicate output file (default 2048) |
|
|
387
390
|
| `OPENAI_IMAGE_MODEL` / `OPENAI_TTS_MODEL` / `OPENAI_STT_MODEL` | OpenAI model defaults |
|
|
388
391
|
| `OPENROUTER_IMAGE_MODEL` | Default OpenRouter image model |
|
|
389
392
|
| `OPENROUTER_FALLBACK_MODELS` | CSV; applies to `generate` AND `i2i` |
|
|
@@ -463,6 +466,19 @@ multix update --check
|
|
|
463
466
|
multix update
|
|
464
467
|
```
|
|
465
468
|
|
|
469
|
+
## Remote MCP server
|
|
470
|
+
|
|
471
|
+
If the user cannot run the CLI locally but has deployed the optional multix
|
|
472
|
+
MCP Worker (`worker/` in the repo; guide: https://multix.zuey.me/guides/mcp-server/),
|
|
473
|
+
call its tools instead: `multix_list_providers` first, then
|
|
474
|
+
`multix_generate_image` / `multix_generate_video`, and poll
|
|
475
|
+
`multix_prediction_status` until `done` is true. It returns provider URLs (no
|
|
476
|
+
local files), supports only Replicate, Atlas Cloud, HeyGen, and Cloudflare
|
|
477
|
+
video, and accepts only public `https://` image URLs. Check `limits` from
|
|
478
|
+
`multix_list_providers` before a video call: `duration` above
|
|
479
|
+
`max_video_seconds` or a `resolution` outside `allowed_resolutions` is
|
|
480
|
+
rejected, and an omitted `duration` becomes `default_video_seconds`.
|
|
481
|
+
|
|
466
482
|
## Security & scope
|
|
467
483
|
|
|
468
484
|
This skill produces shell commands for the local `multix` CLI. It does NOT execute the commands itself, exfiltrate API keys, or modify provider accounts.
|