pi-multimodal-proxy 1.18.1 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -94,6 +94,15 @@ export interface VisionConfig {
94
94
  maxUploadBytes: number;
95
95
  fallbackProvider?: string;
96
96
  fallbackModelId?: string;
97
+ // 1.19.0 — virtual model (pi ≥ 0.99): registers `multimodal-proxy/auto`, a
98
+ // selectable model that routes media-bearing turns to the configured vision
99
+ // model and text turns to a base model. "on" registers it, "off" never
100
+ // does. The optional base pin names the physical text model; unset = stay on
101
+ // whatever physical model handled the session before, falling back to the
102
+ // vision model for the first turn.
103
+ virtualModel: ToolSetting;
104
+ virtualBaseProvider?: string;
105
+ virtualBaseModelId?: string;
97
106
  }
98
107
 
99
108
  export interface ImageMeta {
@@ -727,6 +736,9 @@ export const DEFAULT_CONFIG: VisionConfig = {
727
736
  retryMax: 2,
728
737
  maxUploadDim: 2048,
729
738
  maxUploadBytes: 5 * 1024 * 1024,
739
+ // 1.19.0 — virtual model registered on hosts with pi.registerVirtualModel()
740
+ // (pi ≥ 0.99); feature-detected and skipped on older hosts.
741
+ virtualModel: "on",
730
742
  };
731
743
 
732
744
  // ── Persistent file storage ────────────────────────────────────────────────
@@ -759,6 +771,9 @@ const PERSISTED_CONFIG_KEYS = new Set([
759
771
  "maxUploadBytes",
760
772
  "fallbackProvider",
761
773
  "fallbackModelId",
774
+ "virtualModel",
775
+ "virtualBaseProvider",
776
+ "virtualBaseModelId",
762
777
  ]);
763
778
 
764
779
  /** Read config from the persistent file. Returns empty object on any failure. */
@@ -871,6 +886,7 @@ export function parseFallbackModelEnv(
871
886
  return parsed ? { provider: parsed.provider, modelId: parsed.modelId } : undefined;
872
887
  }
873
888
 
889
+ /** Environment overrides (PI_VISION_PROXY_*) applied last; only recognized values land. */
874
890
  export function readEnvOverrides(env: NodeJS.ProcessEnv = process.env): Partial<VisionConfig> {
875
891
  const overrides: Partial<VisionConfig> = {};
876
892
  const modeEnv = env.PI_VISION_PROXY_MODE;
@@ -966,10 +982,24 @@ export function readEnvOverrides(env: NodeJS.ProcessEnv = process.env): Partial<
966
982
  overrides.fallbackModelId = fallbackModelEnv.modelId;
967
983
  }
968
984
  }
985
+ // 1.19.0 virtual-model overrides
986
+ const virtualEnv = env.PI_VISION_PROXY_VIRTUAL;
987
+ if (virtualEnv === "on" || virtualEnv === "off") overrides.virtualModel = virtualEnv;
988
+ const virtualBaseEnv = parseFallbackModelEnv(env.PI_VISION_PROXY_VIRTUAL_BASE);
989
+ if (virtualBaseEnv) {
990
+ if (virtualBaseEnv.clear) {
991
+ overrides.virtualBaseProvider = undefined;
992
+ overrides.virtualBaseModelId = undefined;
993
+ } else {
994
+ overrides.virtualBaseProvider = virtualBaseEnv.provider;
995
+ overrides.virtualBaseModelId = virtualBaseEnv.modelId;
996
+ }
997
+ }
969
998
  return overrides;
970
999
  }
971
1000
 
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 } {
1001
+ /** Which env vars actually override a setting — used to lock the matching commands. */
1002
+ 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; virtual: boolean; virtualBase: boolean } {
973
1003
  return {
974
1004
  mode: Boolean(env.PI_VISION_PROXY_MODE),
975
1005
  model: Boolean(env.PI_VISION_PROXY_MODEL),
@@ -998,6 +1028,9 @@ export function envFlags(env: NodeJS.ProcessEnv = process.env): { mode: boolean;
998
1028
  parseUploadDimEnv(env.PI_VISION_PROXY_MAX_UPLOAD_DIM) !== undefined ||
999
1029
  parseUploadMbEnv(env.PI_VISION_PROXY_MAX_UPLOAD_MB) !== undefined,
1000
1030
  fallbackModel: parseFallbackModelEnv(env.PI_VISION_PROXY_FALLBACK_MODEL) !== undefined,
1031
+ // 1.19.0 — only recognized values count as overrides.
1032
+ virtual: env.PI_VISION_PROXY_VIRTUAL === "on" || env.PI_VISION_PROXY_VIRTUAL === "off",
1033
+ virtualBase: parseFallbackModelEnv(env.PI_VISION_PROXY_VIRTUAL_BASE) !== undefined,
1001
1034
  };
1002
1035
  }
1003
1036
 
@@ -1139,6 +1172,7 @@ export function sanitizeProviderHeaders(
1139
1172
  return out;
1140
1173
  }
1141
1174
 
1175
+ /** Validate and normalize a merged config: canonicalize providers, clamp numbers, drop malformed pairs. */
1142
1176
  export function sanitize(config: VisionConfig): VisionConfig {
1143
1177
  const safe: VisionConfig = { ...config };
1144
1178
  if (typeof safe.provider === "string") safe.provider = canonicalProvider(safe.provider);
@@ -1244,6 +1278,17 @@ export function sanitize(config: VisionConfig): VisionConfig {
1244
1278
  delete safe.fallbackProvider;
1245
1279
  delete safe.fallbackModelId;
1246
1280
  }
1281
+ // 1.19.0 virtual-model fields
1282
+ if (safe.virtualModel !== "on" && safe.virtualModel !== "off") safe.virtualModel = DEFAULT_CONFIG.virtualModel;
1283
+ if (typeof safe.virtualBaseProvider !== "string") delete safe.virtualBaseProvider;
1284
+ else safe.virtualBaseProvider = canonicalProvider(safe.virtualBaseProvider);
1285
+ if (typeof safe.virtualBaseModelId !== "string") delete safe.virtualBaseModelId;
1286
+ if (safe.virtualBaseProvider !== undefined && !PROVIDER_PATTERN.test(safe.virtualBaseProvider)) delete safe.virtualBaseProvider;
1287
+ if (safe.virtualBaseModelId !== undefined && !MODEL_ID_PATTERN.test(safe.virtualBaseModelId)) delete safe.virtualBaseModelId;
1288
+ if (!safe.virtualBaseProvider || !safe.virtualBaseModelId) {
1289
+ delete safe.virtualBaseProvider;
1290
+ delete safe.virtualBaseModelId;
1291
+ }
1247
1292
  return safe;
1248
1293
  }
1249
1294
 
@@ -1251,6 +1296,11 @@ export function persistedBase(entries: readonly SessionEntry[]): VisionConfig {
1251
1296
  return sanitize({ ...DEFAULT_CONFIG, ...readPersistedConfig(entries) });
1252
1297
  }
1253
1298
 
1299
+ /**
1300
+ * Resolve the effective config: defaults ← persisted file ← session-entry
1301
+ * config ← environment overrides, then sanitize (invalid values fall back to
1302
+ * defaults; half-set model pairs are dropped).
1303
+ */
1254
1304
  export function resolveConfig(
1255
1305
  entries: readonly SessionEntry[],
1256
1306
  env: NodeJS.ProcessEnv = process.env,
@@ -2323,6 +2373,68 @@ export function selectVisionModels<T extends VisionModelLike>(
2323
2373
  return all.filter((m) => m.input.includes("image"));
2324
2374
  }
2325
2375
 
2376
+ /** Lowercased bigram set of a string, for Dice-coefficient similarity. */
2377
+ function bigrams(s: string): Set<string> {
2378
+ const t = s.toLowerCase();
2379
+ const out = new Set<string>();
2380
+ for (let i = 0; i + 1 < t.length; i++) out.add(t.slice(i, i + 2));
2381
+ return out;
2382
+ }
2383
+
2384
+ /**
2385
+ * Close-match suggestions for a mistyped identifier, in the spirit of Pi
2386
+ * 1.0's codemode recovery errors ("tools.Bash suggests tools.bash").
2387
+ * Ranks by Dice bigram similarity with a strong exact/substring bonus;
2388
+ * returns at most `limit` candidates above the relevance floor, best first,
2389
+ * ties broken alphabetically so the output is deterministic.
2390
+ */
2391
+ export function closeMatches(query: string, candidates: readonly string[], limit = 3): string[] {
2392
+ if (!query || candidates.length === 0) return [];
2393
+ const q = query.toLowerCase();
2394
+ const scored = candidates.map((candidate) => {
2395
+ const c = candidate.toLowerCase();
2396
+ let score: number;
2397
+ if (c === q) score = 2;
2398
+ else if (c.includes(q) || q.includes(c)) score = 1.5;
2399
+ else {
2400
+ const bq = bigrams(q);
2401
+ const bc = bigrams(c);
2402
+ let shared = 0;
2403
+ for (const g of bq) if (bc.has(g)) shared++;
2404
+ score = bq.size === 0 || bc.size === 0 ? 0 : (2 * shared) / (bq.size + bc.size);
2405
+ }
2406
+ return { candidate, score };
2407
+ });
2408
+ return scored
2409
+ .filter((s) => s.score >= 0.3)
2410
+ .sort((a, b) => b.score - a.score || (a.candidate < b.candidate ? -1 : a.candidate > b.candidate ? 1 : 0))
2411
+ .slice(0, limit)
2412
+ .map((s) => s.candidate);
2413
+ }
2414
+
2415
+ /** Minimal shape for dynamic (registry-provided) vision candidates. */
2416
+ export interface DynamicCandidateLike {
2417
+ provider: string;
2418
+ modelId: string;
2419
+ input?: readonly string[];
2420
+ }
2421
+
2422
+ /** Cheap-tier heuristic for implicit default resolution (Pi 1.0 last resort). */
2423
+ const CHEAP_MODEL_RE = /flash|mini|fast|haiku|express|lite/i;
2424
+
2425
+ /**
2426
+ * First keyed registry model with image input — the last-resort implicit
2427
+ * vision default when the curated fallback chain is entirely unkeyed
2428
+ * (Pi ≥ 1.0 `getAvailableOfType("chat")`). Cheap-tier ids are preferred so
2429
+ * an implicit default never lands on a frontier-priced model; registry
2430
+ * order breaks remaining ties.
2431
+ */
2432
+ export function pickDynamicVisionCandidate<T extends DynamicCandidateLike>(models: readonly T[]): T | undefined {
2433
+ const vision = models.filter((m) => m.input?.includes("image"));
2434
+ if (vision.length === 0) return undefined;
2435
+ return vision.find((m) => CHEAP_MODEL_RE.test(m.modelId)) ?? vision[0];
2436
+ }
2437
+
2326
2438
  export function shouldStripImages(config: VisionConfig, modelInput: readonly string[] | undefined): boolean {
2327
2439
  if (config.mode === "off") return false;
2328
2440
  if (config.mode === "always") return true;
@@ -2474,12 +2586,20 @@ export function resolveCropEntry(crop: CropEntry, imgWidth: number, imgHeight: n
2474
2586
  }
2475
2587
  if ("normalized" in crop) {
2476
2588
  const result = normalizedToPixels(crop.normalized, imgWidth, imgHeight);
2477
- if (!result) throw new Error(`Normalized crop has zero area after clamping (image: ${imgWidth}x${imgHeight})`);
2589
+ if (!result)
2590
+ throw new Error(
2591
+ `Normalized crop has zero area after clamping (image: ${imgWidth}x${imgHeight}). ` +
2592
+ `Use fractions of the image: x ≥ 0, y ≥ 0, x + width ≤ 1, y + height ≤ 1.`,
2593
+ );
2478
2594
  return result;
2479
2595
  }
2480
2596
  if ("pixels" in crop) {
2481
2597
  const result = clampPixels(crop.pixels, imgWidth, imgHeight);
2482
- if (!result) throw new Error(`Pixel crop has zero area after clamping (image: ${imgWidth}x${imgHeight})`);
2598
+ if (!result)
2599
+ throw new Error(
2600
+ `Pixel crop has zero area after clamping (image: ${imgWidth}x${imgHeight}). ` +
2601
+ `Keep the rectangle inside the image: x ≥ 0, y ≥ 0, x + width ≤ ${imgWidth}, y + height ≤ ${imgHeight}.`,
2602
+ );
2483
2603
  return result;
2484
2604
  }
2485
2605
  throw new Error("Invalid CropEntry: must have exactly one of region, normalized, or pixels");
@@ -2606,7 +2726,7 @@ parentPort.on("message", async (task) => {
2606
2726
  const w = Math.max(1, Math.round(img.width * scale));
2607
2727
  const h = Math.max(1, Math.round(img.height * scale));
2608
2728
  // Downscaled uploads are re-encoded as JPEG — the size reduction is the point.
2609
- encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(88);
2729
+ encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(task.quality ?? 88);
2610
2730
  } else {
2611
2731
  const cropped = img.crop(task.crop.x, task.crop.y, task.crop.width, task.crop.height);
2612
2732
  encoded = mimeType === "image/png" ? await cropped.encode(1) : await cropped.encodeJPEG(90);
@@ -2873,7 +2993,7 @@ export function overUploadDim(
2873
2993
  }
2874
2994
 
2875
2995
  /** In-thread decode → resize → JPEG encode, mirroring cropInThread. */
2876
- async function resizeInThread(imageBytes: Buffer, targetDim: number): Promise<Buffer | null> {
2996
+ async function resizeInThread(imageBytes: Buffer, targetDim: number, quality = 88): Promise<Buffer | null> {
2877
2997
  const img = await decodeWithTimeout(imageBytes);
2878
2998
  if (img.width > MAX_IMAGE_DIMENSION || img.height > MAX_IMAGE_DIMENSION) {
2879
2999
  return null;
@@ -2881,7 +3001,7 @@ async function resizeInThread(imageBytes: Buffer, targetDim: number): Promise<Bu
2881
3001
  const scale = Math.min(targetDim / img.width, targetDim / img.height, 1);
2882
3002
  const w = Math.max(1, Math.round(img.width * scale));
2883
3003
  const h = Math.max(1, Math.round(img.height * scale));
2884
- const encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(88);
3004
+ const encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(quality);
2885
3005
  return Buffer.from(encoded);
2886
3006
  }
2887
3007
 
@@ -2890,6 +3010,7 @@ async function resizeInWorker(
2890
3010
  imageBytes: Buffer,
2891
3011
  targetDim: number,
2892
3012
  timeoutMs: number,
3013
+ quality?: number,
2893
3014
  ): Promise<Buffer | null | typeof WORKER_UNAVAILABLE> {
2894
3015
  if (!(await ensureWorkerInfra())) return WORKER_UNAVAILABLE;
2895
3016
 
@@ -2904,7 +3025,7 @@ async function resizeInWorker(
2904
3025
 
2905
3026
  const { result, reusable } = await runCropTask(
2906
3027
  worker,
2907
- { op: "resize", bytes: ab, targetDim, mimeType: "image/jpeg", maxDim: MAX_IMAGE_DIMENSION },
3028
+ { op: "resize", bytes: ab, targetDim, quality, mimeType: "image/jpeg", maxDim: MAX_IMAGE_DIMENSION },
2908
3029
  timeoutMs,
2909
3030
  );
2910
3031
  if (reusable) releaseWorker(worker);
@@ -2913,32 +3034,97 @@ async function resizeInWorker(
2913
3034
  }
2914
3035
 
2915
3036
  /**
2916
- * Downscale image bytes so the long edge fits targetDim, re-encoding as
2917
- * JPEG q88. Returns null on decode/encode failure — callers then send the
2918
- * original bytes rather than failing the whole analysis.
3037
+ * Downscale image bytes so the long edge fits targetDim, re-encoding as JPEG
3038
+ * (quality 88 unless overridden). Returns null on decode/encode failure —
3039
+ * callers then send the original bytes rather than failing the whole analysis.
2919
3040
  */
2920
- export async function downscaleImage(imageBytes: Buffer, targetDim: number): Promise<Buffer | null> {
3041
+ export async function downscaleImage(
3042
+ imageBytes: Buffer,
3043
+ targetDim: number,
3044
+ quality = 88,
3045
+ ): Promise<Buffer | null> {
2921
3046
  try {
2922
3047
  const dims = extractDimensions(imageBytes);
2923
3048
  if (dims && (dims.width > MAX_IMAGE_DIMENSION || dims.height > MAX_IMAGE_DIMENSION)) {
2924
3049
  return null;
2925
3050
  }
2926
3051
  if (decodeWorkerEnabled()) {
2927
- const viaWorker = await resizeInWorker(imageBytes, targetDim, decodeTimeoutMs());
3052
+ const viaWorker = await resizeInWorker(imageBytes, targetDim, decodeTimeoutMs(), quality);
2928
3053
  if (viaWorker !== WORKER_UNAVAILABLE) return viaWorker;
2929
3054
  // else: worker infra unavailable — fall through to the in-thread path
2930
3055
  }
2931
- return await resizeInThread(imageBytes, targetDim);
3056
+ return await resizeInThread(imageBytes, targetDim, quality);
2932
3057
  } catch {
2933
3058
  return null;
2934
3059
  }
2935
3060
  }
2936
3061
 
3062
+ /**
3063
+ * Per-model image upload limits declared on a catalog entry (pi ≥ 0.87):
3064
+ * `inputLimits.images.resize` = { maxWidth, maxHeight, maxBytes, jpegQuality }.
3065
+ * `maxBytes` limits the base64 payload; here it is converted to a raw-byte
3066
+ * budget (~3/4) so it can share the raw-byte comparison of the user config.
3067
+ * Returns null when the model declares nothing (or an older pi host).
3068
+ */
3069
+ export interface ModelImageResizeLimits {
3070
+ /** Long-edge target that satisfies the model's max-width/max-height box. Absent when the model declares no dimension limits (byte-budget-only declarations keep the user's dim target). */
3071
+ targetDim?: number;
3072
+ /** JPEG re-encode quality (default 88). */
3073
+ quality: number;
3074
+ /** Raw-byte budget derived from the base64 maxBytes, when declared. */
3075
+ maxBytes?: number;
3076
+ }
3077
+
3078
+ export function modelImageResizeLimits(model: unknown): ModelImageResizeLimits | null {
3079
+ const resize = (
3080
+ model as
3081
+ | {
3082
+ inputLimits?: {
3083
+ images?: {
3084
+ resize?: {
3085
+ maxWidth?: unknown;
3086
+ maxHeight?: unknown;
3087
+ maxBytes?: unknown;
3088
+ jpegQuality?: unknown;
3089
+ };
3090
+ };
3091
+ };
3092
+ }
3093
+ | null
3094
+ | undefined
3095
+ )?.inputLimits?.images?.resize;
3096
+ if (!resize) return null;
3097
+ const maxW = typeof resize.maxWidth === "number" && resize.maxWidth > 0 ? resize.maxWidth : undefined;
3098
+ const maxH = typeof resize.maxHeight === "number" && resize.maxHeight > 0 ? resize.maxHeight : undefined;
3099
+ const dims = [maxW, maxH].filter((d): d is number => d !== undefined);
3100
+ const quality =
3101
+ typeof resize.jpegQuality === "number" && resize.jpegQuality >= 1 && resize.jpegQuality <= 100
3102
+ ? Math.round(resize.jpegQuality)
3103
+ : 88;
3104
+ const maxBytes =
3105
+ typeof resize.maxBytes === "number" && resize.maxBytes > 0
3106
+ ? Math.max(1, Math.floor(resize.maxBytes * 0.75))
3107
+ : undefined;
3108
+ // A declaration must carry at least one meaningful limit — dimensions or a
3109
+ // byte budget. Quality alone changes nothing until a resize triggers, so a
3110
+ // quality-only declaration is treated as absent. A maxBytes-only
3111
+ // declaration is honored (PR #34 review): targetDim stays unset and only
3112
+ // the byte budget and quality tighten the user's thresholds.
3113
+ if (dims.length === 0 && maxBytes === undefined) return null;
3114
+ return {
3115
+ targetDim: dims.length > 0 ? Math.min(...dims) : undefined,
3116
+ quality,
3117
+ maxBytes,
3118
+ };
3119
+ }
3120
+
2937
3121
  /**
2938
3122
  * Downscale an image for upload when it exceeds the configured thresholds
2939
- * (1.16.0). Returns the original image untouched when it is small enough or
2940
- * when downscaling fails for any reason — upload downscale is best-effort
2941
- * cost/limit protection, never a hard failure.
3123
+ * (1.16.0). Model-declared limits (pi ≥ 0.87 `inputLimits.images.resize`,
3124
+ * 1.19.0) tighten the user's thresholds; they never re-enable a downscale the
3125
+ * user disabled with `maxUploadDim: 0`. Returns the original image untouched
3126
+ * when it is small enough or when downscaling fails for any reason — upload
3127
+ * downscale is best-effort cost/limit protection, never a hard failure.
2942
3128
  *
2943
3129
  * Dimension-triggered resizes are accepted even when the JPEG re-encode grows
2944
3130
  * the payload slightly (flat-color PNGs compress better than JPEG) — the pixel
@@ -2948,13 +3134,23 @@ export async function downscaleImage(imageBytes: Buffer, targetDim: number): Pro
2948
3134
  export async function downscaleForUpload(
2949
3135
  img: PiAiImage,
2950
3136
  config: Pick<VisionConfig, "maxUploadDim" | "maxUploadBytes">,
3137
+ modelLimits?: ModelImageResizeLimits | null,
2951
3138
  ): Promise<PiAiImage> {
2952
3139
  if (config.maxUploadDim === 0) return img; // downscaling disabled
3140
+ const targetDim = modelLimits?.targetDim
3141
+ ? Math.min(config.maxUploadDim, modelLimits.targetDim)
3142
+ : config.maxUploadDim;
3143
+ const maxUploadBytes =
3144
+ modelLimits?.maxBytes !== undefined
3145
+ ? Math.min(config.maxUploadBytes, modelLimits.maxBytes)
3146
+ : config.maxUploadBytes;
3147
+ const quality = modelLimits?.quality ?? 88;
3148
+ const effective = { maxUploadDim: targetDim, maxUploadBytes };
2953
3149
  const buf = piAiImageToBuffer(img);
2954
3150
  const dims = extractDimensions(buf);
2955
- const overDim = overUploadDim(dims, config);
2956
- if (downscaleTargetDim(dims, buf.byteLength, config) === null) return img;
2957
- const resized = await downscaleImage(buf, config.maxUploadDim);
3151
+ const overDim = overUploadDim(dims, effective);
3152
+ if (downscaleTargetDim(dims, buf.byteLength, effective) === null) return img;
3153
+ const resized = await downscaleImage(buf, targetDim, quality);
2958
3154
  if (!resized) return img;
2959
3155
  if (!overDim && resized.byteLength >= buf.byteLength) return img;
2960
3156
  return bufferToPiAiImage(resized, "image/jpeg");