pi-multimodal-proxy 1.14.0 → 1.16.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 +30 -0
- package/README.md +22 -4
- package/extensions/__tests__/internal.test.ts +324 -3
- package/extensions/internal.ts +402 -7
- package/extensions/vision-proxy.ts +467 -51
- package/package.json +1 -1
package/extensions/internal.ts
CHANGED
|
@@ -85,6 +85,15 @@ export interface VisionConfig {
|
|
|
85
85
|
// "youtube:player_client=web_safari,web").
|
|
86
86
|
ytdlpCookiesFromBrowser: string;
|
|
87
87
|
ytdlpExtractorArgs: string;
|
|
88
|
+
// 1.16.0 — reliability (borrowed from atlas-vision-mcp): transient-error
|
|
89
|
+
// retry budget for vision calls, upload downscale thresholds, and an
|
|
90
|
+
// optional fallback vision model used when the primary fails after
|
|
91
|
+
// retries. Both fallback halves must be set together or neither.
|
|
92
|
+
retryMax: number;
|
|
93
|
+
maxUploadDim: number;
|
|
94
|
+
maxUploadBytes: number;
|
|
95
|
+
fallbackProvider?: string;
|
|
96
|
+
fallbackModelId?: string;
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
export interface ImageMeta {
|
|
@@ -712,6 +721,12 @@ export const DEFAULT_CONFIG: VisionConfig = {
|
|
|
712
721
|
},
|
|
713
722
|
ytdlpCookiesFromBrowser: "",
|
|
714
723
|
ytdlpExtractorArgs: "",
|
|
724
|
+
// 1.16.0 reliability defaults: 2 retries with exponential backoff+jitter;
|
|
725
|
+
// downscale uploads larger than 2048px on the long edge or 5 MB raw bytes
|
|
726
|
+
// (base64 inflates ~4/3, so 5 MB ≈ 6.7 MB payload — within provider limits).
|
|
727
|
+
retryMax: 2,
|
|
728
|
+
maxUploadDim: 2048,
|
|
729
|
+
maxUploadBytes: 5 * 1024 * 1024,
|
|
715
730
|
};
|
|
716
731
|
|
|
717
732
|
// ── Persistent file storage ────────────────────────────────────────────────
|
|
@@ -739,6 +754,11 @@ const PERSISTED_CONFIG_KEYS = new Set([
|
|
|
739
754
|
"pathDetection",
|
|
740
755
|
"ytdlpCookiesFromBrowser",
|
|
741
756
|
"ytdlpExtractorArgs",
|
|
757
|
+
"retryMax",
|
|
758
|
+
"maxUploadDim",
|
|
759
|
+
"maxUploadBytes",
|
|
760
|
+
"fallbackProvider",
|
|
761
|
+
"fallbackModelId",
|
|
742
762
|
]);
|
|
743
763
|
|
|
744
764
|
/** Read config from the persistent file. Returns empty object on any failure. */
|
|
@@ -807,6 +827,50 @@ export function parseAllowHomeEnv(raw: string | undefined): boolean | undefined
|
|
|
807
827
|
return undefined;
|
|
808
828
|
}
|
|
809
829
|
|
|
830
|
+
// ── 1.16.0 reliability env parsers ──────────────────────────────────────────
|
|
831
|
+
// Shared by readEnvOverrides (apply) and envFlags (presence), so an invalid
|
|
832
|
+
// value can never lock its command without producing an effective override.
|
|
833
|
+
|
|
834
|
+
/** Retry budget 0..5; anything else is no override. */
|
|
835
|
+
export function parseRetryMaxEnv(raw: string | undefined): number | undefined {
|
|
836
|
+
if (!raw) return undefined;
|
|
837
|
+
const n = Number.parseInt(raw, 10);
|
|
838
|
+
return Number.isFinite(n) && n >= 0 && n <= 5 ? n : undefined;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Upload long-edge threshold: 0 (downscaling disabled entirely), or
|
|
843
|
+
* 512..8192 px; anything else is no override.
|
|
844
|
+
*/
|
|
845
|
+
export function parseUploadDimEnv(raw: string | undefined): number | undefined {
|
|
846
|
+
if (!raw) return undefined;
|
|
847
|
+
const n = Number.parseInt(raw, 10);
|
|
848
|
+
return Number.isFinite(n) && (n === 0 || (n >= 512 && n <= 8192)) ? n : undefined;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Upload byte budget 0.5..20 MB → bytes; anything else is no override. */
|
|
852
|
+
export function parseUploadMbEnv(raw: string | undefined): number | undefined {
|
|
853
|
+
if (!raw) return undefined;
|
|
854
|
+
const n = parseFloat(raw);
|
|
855
|
+
return Number.isFinite(n) && n >= 0.5 && n <= 20 ? Math.round(n * 1024 * 1024) : undefined;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Fallback vision model: a clear sentinel ("", "none", "off"), a valid
|
|
860
|
+
* provider/model-id, or no override.
|
|
861
|
+
*/
|
|
862
|
+
export function parseFallbackModelEnv(
|
|
863
|
+
raw: string | undefined,
|
|
864
|
+
): { provider: string; modelId: string; clear?: false } | { clear: true } | undefined {
|
|
865
|
+
if (raw === undefined) return undefined;
|
|
866
|
+
const trimmed = raw.trim();
|
|
867
|
+
if (trimmed === "" || trimmed.toLowerCase() === "none" || trimmed.toLowerCase() === "off") {
|
|
868
|
+
return { clear: true };
|
|
869
|
+
}
|
|
870
|
+
const parsed = parseModelString(trimmed);
|
|
871
|
+
return parsed ? { provider: parsed.provider, modelId: parsed.modelId } : undefined;
|
|
872
|
+
}
|
|
873
|
+
|
|
810
874
|
export function readEnvOverrides(env: NodeJS.ProcessEnv = process.env): Partial<VisionConfig> {
|
|
811
875
|
const overrides: Partial<VisionConfig> = {};
|
|
812
876
|
const modeEnv = env.PI_VISION_PROXY_MODE;
|
|
@@ -883,10 +947,29 @@ export function readEnvOverrides(env: NodeJS.ProcessEnv = process.env): Partial<
|
|
|
883
947
|
if (cookiesEnv !== undefined) overrides.ytdlpCookiesFromBrowser = sanitizeYtdlpCookiesFromBrowser(cookiesEnv);
|
|
884
948
|
const extractorArgsEnv = env.PI_VISION_PROXY_YTDLP_EXTRACTOR_ARGS;
|
|
885
949
|
if (extractorArgsEnv !== undefined) overrides.ytdlpExtractorArgs = sanitizeYtdlpExtractorArgs(extractorArgsEnv);
|
|
950
|
+
// 1.16.0 reliability env overrides (shared parsers keep envFlags' presence
|
|
951
|
+
// detection consistent with what actually applies here)
|
|
952
|
+
const retryEnv = parseRetryMaxEnv(env.PI_VISION_PROXY_RETRY_MAX);
|
|
953
|
+
if (retryEnv !== undefined) overrides.retryMax = retryEnv;
|
|
954
|
+
const uploadDimEnv = parseUploadDimEnv(env.PI_VISION_PROXY_MAX_UPLOAD_DIM);
|
|
955
|
+
if (uploadDimEnv !== undefined) overrides.maxUploadDim = uploadDimEnv;
|
|
956
|
+
const uploadMbEnv = parseUploadMbEnv(env.PI_VISION_PROXY_MAX_UPLOAD_MB);
|
|
957
|
+
if (uploadMbEnv !== undefined) overrides.maxUploadBytes = uploadMbEnv;
|
|
958
|
+
const fallbackModelEnv = parseFallbackModelEnv(env.PI_VISION_PROXY_FALLBACK_MODEL);
|
|
959
|
+
if (fallbackModelEnv) {
|
|
960
|
+
if (fallbackModelEnv.clear) {
|
|
961
|
+
// Undefined values delete the keys when spread over defaults.
|
|
962
|
+
overrides.fallbackProvider = undefined;
|
|
963
|
+
overrides.fallbackModelId = undefined;
|
|
964
|
+
} else {
|
|
965
|
+
overrides.fallbackProvider = fallbackModelEnv.provider;
|
|
966
|
+
overrides.fallbackModelId = fallbackModelEnv.modelId;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
886
969
|
return overrides;
|
|
887
970
|
}
|
|
888
971
|
|
|
889
|
-
export function envFlags(env: NodeJS.ProcessEnv = process.env): { mode: boolean; model: boolean; context: boolean; tool: boolean; maxImagesPerCall: boolean; maxBatch: boolean; cacheSize: boolean; videoModel: boolean; allowedProviders: boolean; allowHome: boolean; allowedFolders: boolean; statusLine: boolean; pathDetection: boolean; ytdlpCookies: boolean; ytdlpExtractorArgs: boolean } {
|
|
972
|
+
export function envFlags(env: NodeJS.ProcessEnv = process.env): { mode: boolean; model: boolean; context: boolean; tool: boolean; maxImagesPerCall: boolean; maxBatch: boolean; cacheSize: boolean; videoModel: boolean; allowedProviders: boolean; allowHome: boolean; allowedFolders: boolean; statusLine: boolean; pathDetection: boolean; ytdlpCookies: boolean; ytdlpExtractorArgs: boolean; retryMax: boolean; maxUpload: boolean; fallbackModel: boolean } {
|
|
890
973
|
return {
|
|
891
974
|
mode: Boolean(env.PI_VISION_PROXY_MODE),
|
|
892
975
|
model: Boolean(env.PI_VISION_PROXY_MODEL),
|
|
@@ -908,6 +991,13 @@ export function envFlags(env: NodeJS.ProcessEnv = process.env): { mode: boolean;
|
|
|
908
991
|
pathDetection: env.PI_VISION_PROXY_PATH_DETECTION === "on" || env.PI_VISION_PROXY_PATH_DETECTION === "off",
|
|
909
992
|
ytdlpCookies: env.PI_VISION_PROXY_YTDLP_COOKIES_FROM_BROWSER !== undefined,
|
|
910
993
|
ytdlpExtractorArgs: env.PI_VISION_PROXY_YTDLP_EXTRACTOR_ARGS !== undefined,
|
|
994
|
+
// 1.16.0 — only a value readEnvOverrides actually applies counts as an
|
|
995
|
+
// override; an invalid value must not lock the command.
|
|
996
|
+
retryMax: parseRetryMaxEnv(env.PI_VISION_PROXY_RETRY_MAX) !== undefined,
|
|
997
|
+
maxUpload:
|
|
998
|
+
parseUploadDimEnv(env.PI_VISION_PROXY_MAX_UPLOAD_DIM) !== undefined ||
|
|
999
|
+
parseUploadMbEnv(env.PI_VISION_PROXY_MAX_UPLOAD_MB) !== undefined,
|
|
1000
|
+
fallbackModel: parseFallbackModelEnv(env.PI_VISION_PROXY_FALLBACK_MODEL) !== undefined,
|
|
911
1001
|
};
|
|
912
1002
|
}
|
|
913
1003
|
|
|
@@ -1028,6 +1118,27 @@ export function sanitizeYtdlpExtractorArgs(value: unknown): string {
|
|
|
1028
1118
|
return value.replace(/[\x00-\x1f\x7f]/g, "").trim().slice(0, YTDLP_EXTRACTOR_ARGS_MAX);
|
|
1029
1119
|
}
|
|
1030
1120
|
|
|
1121
|
+
/**
|
|
1122
|
+
* Drop `null` header values from a `ProviderHeaders` map. Since pi 0.84.0,
|
|
1123
|
+
* `ModelRegistry.getApiKeyAndHeaders()` returns `Record<string, string | null>`
|
|
1124
|
+
* where `null` marks a header for deletion. Raw `fetch()` calls (the xAI STT /
|
|
1125
|
+
* file-upload / responses endpoints) can't carry `null` — undici throws
|
|
1126
|
+
* `TypeError` on a non-string header value, or sends a literal `"null"` — so
|
|
1127
|
+
* strip them before building fetch headers. (`undefined` values are also
|
|
1128
|
+
* dropped defensively.)
|
|
1129
|
+
*/
|
|
1130
|
+
export function sanitizeProviderHeaders(
|
|
1131
|
+
headers: Record<string, string | null> | undefined,
|
|
1132
|
+
): Record<string, string> {
|
|
1133
|
+
const out: Record<string, string> = {};
|
|
1134
|
+
if (headers) {
|
|
1135
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
1136
|
+
if (value !== null && value !== undefined) out[key] = value;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return out;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1031
1142
|
export function sanitize(config: VisionConfig): VisionConfig {
|
|
1032
1143
|
const safe: VisionConfig = { ...config };
|
|
1033
1144
|
if (typeof safe.provider === "string") safe.provider = canonicalProvider(safe.provider);
|
|
@@ -1099,6 +1210,40 @@ export function sanitize(config: VisionConfig): VisionConfig {
|
|
|
1099
1210
|
// 1.12.1 yt-dlp tuning fields
|
|
1100
1211
|
safe.ytdlpCookiesFromBrowser = sanitizeYtdlpCookiesFromBrowser(safe.ytdlpCookiesFromBrowser);
|
|
1101
1212
|
safe.ytdlpExtractorArgs = sanitizeYtdlpExtractorArgs(safe.ytdlpExtractorArgs);
|
|
1213
|
+
// 1.16.0 reliability fields
|
|
1214
|
+
if (!Number.isFinite(safe.retryMax) || safe.retryMax < 0 || safe.retryMax > 5) {
|
|
1215
|
+
safe.retryMax = DEFAULT_CONFIG.retryMax;
|
|
1216
|
+
}
|
|
1217
|
+
safe.retryMax = Math.round(safe.retryMax);
|
|
1218
|
+
// maxUploadDim 0 = upload downscaling disabled entirely (review: "off"
|
|
1219
|
+
// previously clamped to 8192 px and still transformed oversized uploads).
|
|
1220
|
+
if (
|
|
1221
|
+
!Number.isFinite(safe.maxUploadDim) ||
|
|
1222
|
+
!(safe.maxUploadDim === 0 || (safe.maxUploadDim >= 512 && safe.maxUploadDim <= 8192))
|
|
1223
|
+
) {
|
|
1224
|
+
safe.maxUploadDim = DEFAULT_CONFIG.maxUploadDim;
|
|
1225
|
+
}
|
|
1226
|
+
safe.maxUploadDim = Math.round(safe.maxUploadDim);
|
|
1227
|
+
if (
|
|
1228
|
+
!Number.isFinite(safe.maxUploadBytes) ||
|
|
1229
|
+
safe.maxUploadBytes < 512 * 1024 ||
|
|
1230
|
+
safe.maxUploadBytes > 20 * 1024 * 1024
|
|
1231
|
+
) {
|
|
1232
|
+
safe.maxUploadBytes = DEFAULT_CONFIG.maxUploadBytes;
|
|
1233
|
+
}
|
|
1234
|
+
safe.maxUploadBytes = Math.round(safe.maxUploadBytes);
|
|
1235
|
+
// Fallback model: canonicalize, validate, and require both halves together.
|
|
1236
|
+
// Both halves must be real strings before the pattern checks — RegExp.test()
|
|
1237
|
+
// coerces its argument, so a numeric modelId (e.g. 123) could otherwise pass.
|
|
1238
|
+
if (typeof safe.fallbackProvider !== "string") delete safe.fallbackProvider;
|
|
1239
|
+
else safe.fallbackProvider = canonicalProvider(safe.fallbackProvider);
|
|
1240
|
+
if (typeof safe.fallbackModelId !== "string") delete safe.fallbackModelId;
|
|
1241
|
+
if (safe.fallbackProvider !== undefined && !PROVIDER_PATTERN.test(safe.fallbackProvider)) delete safe.fallbackProvider;
|
|
1242
|
+
if (safe.fallbackModelId !== undefined && !MODEL_ID_PATTERN.test(safe.fallbackModelId)) delete safe.fallbackModelId;
|
|
1243
|
+
if (!safe.fallbackProvider || !safe.fallbackModelId) {
|
|
1244
|
+
delete safe.fallbackProvider;
|
|
1245
|
+
delete safe.fallbackModelId;
|
|
1246
|
+
}
|
|
1102
1247
|
return safe;
|
|
1103
1248
|
}
|
|
1104
1249
|
|
|
@@ -1268,6 +1413,100 @@ export function hasConsent(
|
|
|
1268
1413
|
return Boolean(provider && allowedProviders?.includes(canonicalProvider(provider)));
|
|
1269
1414
|
}
|
|
1270
1415
|
|
|
1416
|
+
// ── Vision-call retry & backoff (1.16.0) ───────────────────────────────────
|
|
1417
|
+
|
|
1418
|
+
/** HTTP statuses worth retrying (rate limit + transient server/network). */
|
|
1419
|
+
const TRANSIENT_STATUSES = new Set([429, 500, 502, 503, 504]);
|
|
1420
|
+
|
|
1421
|
+
/** Statuses that retrying can never fix — fall through to the next model. */
|
|
1422
|
+
const NON_TRANSIENT_STATUSES = new Set([400, 401, 403, 404, 405, 413, 422]);
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* Message patterns of transient provider/network failures. Written against
|
|
1426
|
+
* common pi-ai / undici error text; the status-code checks above take priority
|
|
1427
|
+
* when the error carries one.
|
|
1428
|
+
*/
|
|
1429
|
+
const TRANSIENT_MESSAGE_RE =
|
|
1430
|
+
/\b(429|too many requests|rate[ _-]?limit|overloaded|econnreset|econnrefused|etimedout|econnaborted|enotfound|eai_again|epipe|enotfound|timeout|timed out|fetch failed|network error|socket hang up|internal server error|bad gateway|service unavailable|gateway timeout)\b/i;
|
|
1431
|
+
|
|
1432
|
+
/** Node/DOM abort signals — never retried, never failed over. */
|
|
1433
|
+
export function isAbortError(err: unknown): boolean {
|
|
1434
|
+
if (!err || typeof err !== "object") return false;
|
|
1435
|
+
const e = err as { name?: unknown; code?: unknown };
|
|
1436
|
+
return e.name === "AbortError" || e.code === "ABORT_ERR";
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Whether a vision-call failure is worth retrying: rate limits, transient 5xx,
|
|
1441
|
+
* and network hiccups qualify; auth/permission/payload errors and user aborts
|
|
1442
|
+
* do not. Defensive about error shape — providers throw plain Errors,
|
|
1443
|
+
* status-carrying API errors, undici TypeErrors, and occasionally strings.
|
|
1444
|
+
*/
|
|
1445
|
+
export function isTransientVisionError(err: unknown): boolean {
|
|
1446
|
+
if (!err) return false;
|
|
1447
|
+
if (typeof err === "string") return TRANSIENT_MESSAGE_RE.test(err);
|
|
1448
|
+
if (isAbortError(err)) return false;
|
|
1449
|
+
const e = err as { status?: unknown; statusCode?: unknown; code?: unknown; message?: unknown };
|
|
1450
|
+
for (const key of ["status", "statusCode"] as const) {
|
|
1451
|
+
const v = e[key];
|
|
1452
|
+
if (typeof v === "number" && Number.isFinite(v)) {
|
|
1453
|
+
if (TRANSIENT_STATUSES.has(v)) return true;
|
|
1454
|
+
if (NON_TRANSIENT_STATUSES.has(v)) return false;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
if (typeof e.code === "string" && /^(ECONNRESET|ECONNREFUSED|ETIMEDOUT|ECONNABORTED|ENOTFOUND|EAI_AGAIN|EPIPE|ENETUNREACH|EHOSTUNREACH)$/.test(e.code)) {
|
|
1458
|
+
return true;
|
|
1459
|
+
}
|
|
1460
|
+
return typeof e.message === "string" ? TRANSIENT_MESSAGE_RE.test(e.message) : false;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* Construct an abort-shaped error (name "AbortError", message "aborted") so
|
|
1465
|
+
* callers classify a mid-retry user cancel as cancellation — not as the last
|
|
1466
|
+
* transient provider error. The message "aborted" matches the exact-string
|
|
1467
|
+
* checks call sites already use for the cancelled path.
|
|
1468
|
+
*/
|
|
1469
|
+
export function createAbortError(): Error {
|
|
1470
|
+
const err = new Error("aborted");
|
|
1471
|
+
err.name = "AbortError";
|
|
1472
|
+
return err;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* Backoff delay before retry attempt N (0-based): exponential 1s·2^N capped
|
|
1477
|
+
* at 8s, plus 0–30% jitter to de-synchronize parallel image calls.
|
|
1478
|
+
*/
|
|
1479
|
+
export function retryDelayMs(attempt: number): number {
|
|
1480
|
+
const base = Math.min(1000 * 2 ** attempt, 8000);
|
|
1481
|
+
return Math.round(base + Math.random() * 0.3 * base);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Sleep for ms, resolving early with false when the signal aborts. Never
|
|
1486
|
+
* rejects; callers treat a false result as cancellation.
|
|
1487
|
+
*/
|
|
1488
|
+
export function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<boolean> {
|
|
1489
|
+
return new Promise((resolve) => {
|
|
1490
|
+
if (signal?.aborted) {
|
|
1491
|
+
resolve(false);
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
const onAbort = () => {
|
|
1495
|
+
cleanup();
|
|
1496
|
+
resolve(false);
|
|
1497
|
+
};
|
|
1498
|
+
const timer = setTimeout(() => {
|
|
1499
|
+
cleanup();
|
|
1500
|
+
resolve(true);
|
|
1501
|
+
}, ms);
|
|
1502
|
+
const cleanup = () => {
|
|
1503
|
+
clearTimeout(timer);
|
|
1504
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1505
|
+
};
|
|
1506
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1271
1510
|
// ── Image helpers ──────────────────────────────────────────────────────────
|
|
1272
1511
|
|
|
1273
1512
|
export function toPiAiImage(img: PiAiImage | LegacyImage): PiAiImage {
|
|
@@ -1925,6 +2164,30 @@ export function fuzzyMatches(target: string, query: string): boolean {
|
|
|
1925
2164
|
return true;
|
|
1926
2165
|
}
|
|
1927
2166
|
|
|
2167
|
+
/** Minimal model shape for vision selection (keeps this helper testable
|
|
2168
|
+
* without pulling in pi-ai's `Model`/`Api` types). */
|
|
2169
|
+
interface VisionModelLike {
|
|
2170
|
+
input: readonly string[];
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
/**
|
|
2174
|
+
* Vision-capable models for the picker. Honors the session's model scope
|
|
2175
|
+
* (`ctx.scopedModels`, pi ≥ 0.83.0) when one is configured, so
|
|
2176
|
+
* `/multimodal-proxy pick` mirrors the built-in `/model` selector instead of
|
|
2177
|
+
* enumerating the whole catalogue. Falls back to the full list when no scope
|
|
2178
|
+
* is set — an empty scope means every available model is usable — and on
|
|
2179
|
+
* runtimes that predate `scopedModels`, where `scoped` is undefined.
|
|
2180
|
+
*/
|
|
2181
|
+
export function selectVisionModels<T extends VisionModelLike>(
|
|
2182
|
+
scoped: readonly { model: T }[] | undefined,
|
|
2183
|
+
all: readonly T[],
|
|
2184
|
+
): T[] {
|
|
2185
|
+
if (scoped && scoped.length > 0) {
|
|
2186
|
+
return scoped.map((s) => s.model).filter((m) => m.input.includes("image"));
|
|
2187
|
+
}
|
|
2188
|
+
return all.filter((m) => m.input.includes("image"));
|
|
2189
|
+
}
|
|
2190
|
+
|
|
1928
2191
|
export function shouldStripImages(config: VisionConfig, modelInput: readonly string[] | undefined): boolean {
|
|
1929
2192
|
if (config.mode === "off") return false;
|
|
1930
2193
|
if (config.mode === "always") return true;
|
|
@@ -2198,12 +2461,21 @@ const CROP_WORKER_SRC = `
|
|
|
2198
2461
|
const { parentPort, workerData } = require("worker_threads");
|
|
2199
2462
|
const { Image } = require(workerData.imagescriptPath);
|
|
2200
2463
|
parentPort.on("message", async (task) => {
|
|
2201
|
-
const { bytes,
|
|
2464
|
+
const { bytes, mimeType, maxDim } = task;
|
|
2202
2465
|
try {
|
|
2203
2466
|
const img = await Image.decode(new Uint8Array(bytes));
|
|
2204
2467
|
if (img.width > maxDim || img.height > maxDim) { parentPort.postMessage({ ok: false }); return; }
|
|
2205
|
-
|
|
2206
|
-
|
|
2468
|
+
let encoded;
|
|
2469
|
+
if (task.op === "resize") {
|
|
2470
|
+
const scale = Math.min(task.targetDim / img.width, task.targetDim / img.height, 1);
|
|
2471
|
+
const w = Math.max(1, Math.round(img.width * scale));
|
|
2472
|
+
const h = Math.max(1, Math.round(img.height * scale));
|
|
2473
|
+
// Downscaled uploads are re-encoded as JPEG — the size reduction is the point.
|
|
2474
|
+
encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(88);
|
|
2475
|
+
} else {
|
|
2476
|
+
const cropped = img.crop(task.crop.x, task.crop.y, task.crop.width, task.crop.height);
|
|
2477
|
+
encoded = mimeType === "image/png" ? await cropped.encode(1) : await cropped.encodeJPEG(90);
|
|
2478
|
+
}
|
|
2207
2479
|
const u8 = encoded instanceof Uint8Array ? encoded : new Uint8Array(encoded);
|
|
2208
2480
|
const out = u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
|
2209
2481
|
parentPort.postMessage({ ok: true, data: out }, [out]);
|
|
@@ -2305,10 +2577,15 @@ function releaseWorker(worker: NodeWorker): void {
|
|
|
2305
2577
|
});
|
|
2306
2578
|
}
|
|
2307
2579
|
|
|
2308
|
-
/**
|
|
2580
|
+
/** A crop or resize job for the decode worker (1.16.0 added the resize op). */
|
|
2581
|
+
type ImageWorkerTask =
|
|
2582
|
+
| { op?: "crop"; bytes: ArrayBuffer; crop: ResolvedCrop; mimeType?: string; maxDim: number }
|
|
2583
|
+
| { op: "resize"; bytes: ArrayBuffer; targetDim: number; mimeType?: string; maxDim: number };
|
|
2584
|
+
|
|
2585
|
+
/** Run one crop/resize task on a worker with a hard timeout. `reusable` is false on timeout/error. */
|
|
2309
2586
|
function runCropTask(
|
|
2310
2587
|
worker: NodeWorker,
|
|
2311
|
-
task:
|
|
2588
|
+
task: ImageWorkerTask,
|
|
2312
2589
|
timeoutMs: number,
|
|
2313
2590
|
): Promise<{ result: Buffer | null; reusable: boolean }> {
|
|
2314
2591
|
return new Promise((resolve) => {
|
|
@@ -2362,7 +2639,7 @@ async function cropInWorker(
|
|
|
2362
2639
|
|
|
2363
2640
|
const { result, reusable } = await runCropTask(
|
|
2364
2641
|
worker,
|
|
2365
|
-
{ bytes: ab, crop, mimeType, maxDim: MAX_IMAGE_DIMENSION },
|
|
2642
|
+
{ op: "crop", bytes: ab, crop, mimeType, maxDim: MAX_IMAGE_DIMENSION },
|
|
2366
2643
|
timeoutMs,
|
|
2367
2644
|
);
|
|
2368
2645
|
if (reusable) releaseWorker(worker);
|
|
@@ -2430,6 +2707,124 @@ export function bufferToPiAiImage(buf: Buffer, originalMimeType?: string): PiAiI
|
|
|
2430
2707
|
return { type: "image", data: buf.toString("base64"), mimeType };
|
|
2431
2708
|
}
|
|
2432
2709
|
|
|
2710
|
+
// ── Upload downscale (1.16.0) ───────────────────────────────────────────────
|
|
2711
|
+
|
|
2712
|
+
/**
|
|
2713
|
+
* Pure decision helper: whether an image exceeds the configured upload
|
|
2714
|
+
* thresholds, and the long-edge bound to downscale to when it does. Returns
|
|
2715
|
+
* null when the image is small enough to send as-is.
|
|
2716
|
+
*/
|
|
2717
|
+
export function downscaleTargetDim(
|
|
2718
|
+
dims: { width: number; height: number } | undefined,
|
|
2719
|
+
byteLength: number,
|
|
2720
|
+
config: Pick<VisionConfig, "maxUploadDim" | "maxUploadBytes">,
|
|
2721
|
+
): number | null {
|
|
2722
|
+
// 0 = downscaling disabled entirely (including the byte trigger).
|
|
2723
|
+
if (config.maxUploadDim === 0) return null;
|
|
2724
|
+
return overUploadDim(dims, config) || byteLength > config.maxUploadBytes ? config.maxUploadDim : null;
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
/**
|
|
2728
|
+
* True when either edge exceeds the configured upload dimension. Shared by
|
|
2729
|
+
* downscaleTargetDim and downscaleForUpload so the threshold decision cannot
|
|
2730
|
+
* drift between them (CodeRabbit PR #27). 0 = disabled → always false.
|
|
2731
|
+
*/
|
|
2732
|
+
export function overUploadDim(
|
|
2733
|
+
dims: { width: number; height: number } | undefined,
|
|
2734
|
+
config: Pick<VisionConfig, "maxUploadDim">,
|
|
2735
|
+
): boolean {
|
|
2736
|
+
if (config.maxUploadDim === 0) return false;
|
|
2737
|
+
return Boolean(dims && (dims.width > config.maxUploadDim || dims.height > config.maxUploadDim));
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
/** In-thread decode → resize → JPEG encode, mirroring cropInThread. */
|
|
2741
|
+
async function resizeInThread(imageBytes: Buffer, targetDim: number): Promise<Buffer | null> {
|
|
2742
|
+
const img = await decodeWithTimeout(imageBytes);
|
|
2743
|
+
if (img.width > MAX_IMAGE_DIMENSION || img.height > MAX_IMAGE_DIMENSION) {
|
|
2744
|
+
return null;
|
|
2745
|
+
}
|
|
2746
|
+
const scale = Math.min(targetDim / img.width, targetDim / img.height, 1);
|
|
2747
|
+
const w = Math.max(1, Math.round(img.width * scale));
|
|
2748
|
+
const h = Math.max(1, Math.round(img.height * scale));
|
|
2749
|
+
const encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(88);
|
|
2750
|
+
return Buffer.from(encoded);
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
/** Worker-path resize — same pooling/timeout guarantees as cropInWorker. */
|
|
2754
|
+
async function resizeInWorker(
|
|
2755
|
+
imageBytes: Buffer,
|
|
2756
|
+
targetDim: number,
|
|
2757
|
+
timeoutMs: number,
|
|
2758
|
+
): Promise<Buffer | null | typeof WORKER_UNAVAILABLE> {
|
|
2759
|
+
if (!(await ensureWorkerInfra())) return WORKER_UNAVAILABLE;
|
|
2760
|
+
|
|
2761
|
+
let worker: NodeWorker;
|
|
2762
|
+
try {
|
|
2763
|
+
worker = acquireWorker();
|
|
2764
|
+
} catch {
|
|
2765
|
+
return WORKER_UNAVAILABLE;
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
const ab = imageBytes.buffer.slice(imageBytes.byteOffset, imageBytes.byteOffset + imageBytes.byteLength);
|
|
2769
|
+
|
|
2770
|
+
const { result, reusable } = await runCropTask(
|
|
2771
|
+
worker,
|
|
2772
|
+
{ op: "resize", bytes: ab, targetDim, mimeType: "image/jpeg", maxDim: MAX_IMAGE_DIMENSION },
|
|
2773
|
+
timeoutMs,
|
|
2774
|
+
);
|
|
2775
|
+
if (reusable) releaseWorker(worker);
|
|
2776
|
+
else void worker.terminate();
|
|
2777
|
+
return result;
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
/**
|
|
2781
|
+
* Downscale image bytes so the long edge fits targetDim, re-encoding as
|
|
2782
|
+
* JPEG q88. Returns null on decode/encode failure — callers then send the
|
|
2783
|
+
* original bytes rather than failing the whole analysis.
|
|
2784
|
+
*/
|
|
2785
|
+
export async function downscaleImage(imageBytes: Buffer, targetDim: number): Promise<Buffer | null> {
|
|
2786
|
+
try {
|
|
2787
|
+
const dims = extractDimensions(imageBytes);
|
|
2788
|
+
if (dims && (dims.width > MAX_IMAGE_DIMENSION || dims.height > MAX_IMAGE_DIMENSION)) {
|
|
2789
|
+
return null;
|
|
2790
|
+
}
|
|
2791
|
+
if (decodeWorkerEnabled()) {
|
|
2792
|
+
const viaWorker = await resizeInWorker(imageBytes, targetDim, decodeTimeoutMs());
|
|
2793
|
+
if (viaWorker !== WORKER_UNAVAILABLE) return viaWorker;
|
|
2794
|
+
// else: worker infra unavailable — fall through to the in-thread path
|
|
2795
|
+
}
|
|
2796
|
+
return await resizeInThread(imageBytes, targetDim);
|
|
2797
|
+
} catch {
|
|
2798
|
+
return null;
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
/**
|
|
2803
|
+
* Downscale an image for upload when it exceeds the configured thresholds
|
|
2804
|
+
* (1.16.0). Returns the original image untouched when it is small enough or
|
|
2805
|
+
* when downscaling fails for any reason — upload downscale is best-effort
|
|
2806
|
+
* cost/limit protection, never a hard failure.
|
|
2807
|
+
*
|
|
2808
|
+
* Dimension-triggered resizes are accepted even when the JPEG re-encode grows
|
|
2809
|
+
* the payload slightly (flat-color PNGs compress better than JPEG) — the pixel
|
|
2810
|
+
* reduction is what matters for provider dimension limits. Byte-budget-triggered
|
|
2811
|
+
* resizes only pay off when the result is genuinely smaller.
|
|
2812
|
+
*/
|
|
2813
|
+
export async function downscaleForUpload(
|
|
2814
|
+
img: PiAiImage,
|
|
2815
|
+
config: Pick<VisionConfig, "maxUploadDim" | "maxUploadBytes">,
|
|
2816
|
+
): Promise<PiAiImage> {
|
|
2817
|
+
if (config.maxUploadDim === 0) return img; // downscaling disabled
|
|
2818
|
+
const buf = piAiImageToBuffer(img);
|
|
2819
|
+
const dims = extractDimensions(buf);
|
|
2820
|
+
const overDim = overUploadDim(dims, config);
|
|
2821
|
+
if (downscaleTargetDim(dims, buf.byteLength, config) === null) return img;
|
|
2822
|
+
const resized = await downscaleImage(buf, config.maxUploadDim);
|
|
2823
|
+
if (!resized) return img;
|
|
2824
|
+
if (!overDim && resized.byteLength >= buf.byteLength) return img;
|
|
2825
|
+
return bufferToPiAiImage(resized, "image/jpeg");
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2433
2828
|
// ── Perceptual hashing (imghash) ────────────────────────────────────────────
|
|
2434
2829
|
|
|
2435
2830
|
let _imghash: typeof import("imghash") | null = null;
|