pi-multimodal-proxy 1.14.0 → 1.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 CHANGED
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [1.15.0] - 2026-08-11
8
+
9
+ ### Added
10
+
11
+ - **Vision-model picker respects the session's model scope.** `/multimodal-proxy pick` now honors `ctx.scopedModels` (pi ≥ 0.83.0): when a scope is configured via `--models` or `enabledModels`, the picker lists only scoped vision-capable models, mirroring the built-in `/model` selector instead of enumerating the whole catalogue. Falls back to the full registry when no scope is set, and on runtimes that predate `scopedModels` (gracefully undefined). When the persisted provider is excluded by the configured scope, the picker opens on the first in-scope provider instead of an empty model list. New tested pure helper `selectVisionModels`.
12
+
13
+ ### Fixed
14
+
15
+ - **Forward-compat with pi 0.84.0 `null` header-deletion markers.** Since pi 0.84.0, `ModelRegistry.getApiKeyAndHeaders()` returns `ProviderHeaders` (`Record<string, string | null>`) where `null` marks a header for deletion. The xAI native video path (STT / file-upload / `/v1/responses`) builds raw `fetch()` calls that cannot carry `null` — undici throws `TypeError` on a non-string header value, or sends a literal `"null"`. Those headers are now stripped at the call boundary (and at the `xaiHeaders` choke point as defense-in-depth). An explicit `Authorization: null` deletion marker is now honored — the header is suppressed rather than re-filled with the API key — so a credential deliberately suppressed by a provider/header hook is not forwarded. The pi-ai `complete()` paths continue to pass headers through unchanged, as required. New tested pure helper `sanitizeProviderHeaders`.
16
+
7
17
  ## [1.14.0] - 2026-08-09
8
18
 
9
19
  ### Added
@@ -53,6 +53,7 @@ import {
53
53
  sanitizeAllowedFolders,
54
54
  sanitizeYtdlpCookiesFromBrowser,
55
55
  sanitizeYtdlpExtractorArgs,
56
+ sanitizeProviderHeaders,
56
57
  YTDLP_COOKIES_BROWSERS,
57
58
  pathAccessFromConfig,
58
59
  MAX_ALLOWED_FOLDERS,
@@ -77,6 +78,7 @@ import {
77
78
  piAiImageToBuffer,
78
79
  bufferToPiAiImage,
79
80
  shouldStripImages,
81
+ selectVisionModels,
80
82
  splitSubcommand,
81
83
  stripImagePaths,
82
84
  stripMediaPaths,
@@ -319,6 +321,58 @@ describe("yt-dlp config sanitizers", () => {
319
321
  });
320
322
  });
321
323
 
324
+ describe("selectVisionModels", () => {
325
+ const img = (id: string) => ({ id, input: ["text", "image"] as readonly string[] });
326
+ const txt = (id: string) => ({ id, input: ["text"] as readonly string[] });
327
+
328
+ it("returns all vision models when no scope is configured (undefined)", () => {
329
+ const all = [img("a"), txt("b"), img("c")];
330
+ assert.deepEqual(selectVisionModels(undefined, all).map((m) => m.id), ["a", "c"]);
331
+ });
332
+ it("returns all vision models when scope is empty (every model usable)", () => {
333
+ const all = [img("a"), txt("b")];
334
+ assert.deepEqual(selectVisionModels([], all).map((m) => m.id), ["a"]);
335
+ });
336
+ it("restricts to scoped vision models when a scope is configured", () => {
337
+ const all = [img("a"), img("b"), img("c")];
338
+ const scoped = [{ model: img("b") }, { model: txt("x") }];
339
+ assert.deepEqual(selectVisionModels(scoped, all).map((m) => m.id), ["b"]);
340
+ });
341
+ it("uses the scope verbatim and ignores the full catalogue", () => {
342
+ // `b` is not in `all`; the scope is the source of truth when configured
343
+ const all = [img("a"), img("c")];
344
+ const scoped = [{ model: img("b") }];
345
+ assert.deepEqual(selectVisionModels(scoped, all).map((m) => m.id), ["b"]);
346
+ });
347
+ it("drops non-image scoped models", () => {
348
+ const scoped = [{ model: txt("x") }, { model: txt("y") }];
349
+ assert.deepEqual(selectVisionModels(scoped, [img("a")]).map((m) => m.id), []);
350
+ });
351
+ });
352
+
353
+ describe("sanitizeProviderHeaders", () => {
354
+ it("drops null deletion markers", () => {
355
+ assert.deepEqual(
356
+ sanitizeProviderHeaders({ Authorization: "Bearer x", "X-Delete": null, Keep: "v" }),
357
+ { Authorization: "Bearer x", Keep: "v" },
358
+ );
359
+ });
360
+ it("drops undefined values defensively", () => {
361
+ assert.deepEqual(
362
+ sanitizeProviderHeaders({ A: undefined as unknown as null, B: "b" }),
363
+ { B: "b" },
364
+ );
365
+ });
366
+ it("returns an empty object for undefined / empty input", () => {
367
+ assert.deepEqual(sanitizeProviderHeaders(undefined), {});
368
+ assert.deepEqual(sanitizeProviderHeaders({}), {});
369
+ });
370
+ it("never lets a null survive into the output", () => {
371
+ const out = sanitizeProviderHeaders({ "X-N": null, "X-S": "s" });
372
+ for (const v of Object.values(out)) assert.equal(typeof v, "string");
373
+ });
374
+ });
375
+
322
376
  describe("resolveConfig", () => {
323
377
  it("returns defaults with no entries and empty env", () => {
324
378
  const cfg = resolveConfig([], {});
@@ -1028,6 +1028,27 @@ export function sanitizeYtdlpExtractorArgs(value: unknown): string {
1028
1028
  return value.replace(/[\x00-\x1f\x7f]/g, "").trim().slice(0, YTDLP_EXTRACTOR_ARGS_MAX);
1029
1029
  }
1030
1030
 
1031
+ /**
1032
+ * Drop `null` header values from a `ProviderHeaders` map. Since pi 0.84.0,
1033
+ * `ModelRegistry.getApiKeyAndHeaders()` returns `Record<string, string | null>`
1034
+ * where `null` marks a header for deletion. Raw `fetch()` calls (the xAI STT /
1035
+ * file-upload / responses endpoints) can't carry `null` — undici throws
1036
+ * `TypeError` on a non-string header value, or sends a literal `"null"` — so
1037
+ * strip them before building fetch headers. (`undefined` values are also
1038
+ * dropped defensively.)
1039
+ */
1040
+ export function sanitizeProviderHeaders(
1041
+ headers: Record<string, string | null> | undefined,
1042
+ ): Record<string, string> {
1043
+ const out: Record<string, string> = {};
1044
+ if (headers) {
1045
+ for (const [key, value] of Object.entries(headers)) {
1046
+ if (value !== null && value !== undefined) out[key] = value;
1047
+ }
1048
+ }
1049
+ return out;
1050
+ }
1051
+
1031
1052
  export function sanitize(config: VisionConfig): VisionConfig {
1032
1053
  const safe: VisionConfig = { ...config };
1033
1054
  if (typeof safe.provider === "string") safe.provider = canonicalProvider(safe.provider);
@@ -1925,6 +1946,30 @@ export function fuzzyMatches(target: string, query: string): boolean {
1925
1946
  return true;
1926
1947
  }
1927
1948
 
1949
+ /** Minimal model shape for vision selection (keeps this helper testable
1950
+ * without pulling in pi-ai's `Model`/`Api` types). */
1951
+ interface VisionModelLike {
1952
+ input: readonly string[];
1953
+ }
1954
+
1955
+ /**
1956
+ * Vision-capable models for the picker. Honors the session's model scope
1957
+ * (`ctx.scopedModels`, pi ≥ 0.83.0) when one is configured, so
1958
+ * `/multimodal-proxy pick` mirrors the built-in `/model` selector instead of
1959
+ * enumerating the whole catalogue. Falls back to the full list when no scope
1960
+ * is set — an empty scope means every available model is usable — and on
1961
+ * runtimes that predate `scopedModels`, where `scoped` is undefined.
1962
+ */
1963
+ export function selectVisionModels<T extends VisionModelLike>(
1964
+ scoped: readonly { model: T }[] | undefined,
1965
+ all: readonly T[],
1966
+ ): T[] {
1967
+ if (scoped && scoped.length > 0) {
1968
+ return scoped.map((s) => s.model).filter((m) => m.input.includes("image"));
1969
+ }
1970
+ return all.filter((m) => m.input.includes("image"));
1971
+ }
1972
+
1928
1973
  export function shouldStripImages(config: VisionConfig, modelInput: readonly string[] | undefined): boolean {
1929
1974
  if (config.mode === "off") return false;
1930
1975
  if (config.mode === "always") return true;
@@ -49,7 +49,7 @@ import { access, copyFile, mkdir, mkdtemp, readFile, readdir, rm } from "node:fs
49
49
  import os from "node:os";
50
50
  import { isAbsolute, join } from "node:path";
51
51
  import { promisify } from "node:util";
52
- import { type ImageContent as PiAiImage, type Api, type Model, type Context, type ProviderStreamOptions, type AssistantMessage } from "@earendil-works/pi-ai";
52
+ import { type ImageContent as PiAiImage, type Api, type Model, type Context, type ProviderHeaders, type ProviderStreamOptions, type AssistantMessage } from "@earendil-works/pi-ai";
53
53
 
54
54
  type LegacyComplete = <TApi extends Api>(model: Model<TApi>, context: Context, options?: ProviderStreamOptions) => Promise<AssistantMessage>;
55
55
 
@@ -185,7 +185,9 @@ import {
185
185
  resolveCropEntry,
186
186
  sanitize,
187
187
  sanitizeForLog,
188
+ sanitizeProviderHeaders,
188
189
  shouldStripImages as shouldStripImagesPure,
190
+ selectVisionModels,
189
191
  splitSubcommand,
190
192
  stripImagePaths,
191
193
  stripMediaPaths,
@@ -348,7 +350,11 @@ async function pickVisionModel(
348
350
  );
349
351
  return;
350
352
  }
351
- const vision = ctx.modelRegistry.getAll().filter((m) => m.input.includes("image"));
353
+ // Honor the session's model scope (ctx.scopedModels, pi ≥ 0.83.0) when set,
354
+ // so the picker mirrors the built-in /model selector instead of listing the
355
+ // whole catalogue. Falls back to the full registry when no scope is set or
356
+ // on runtimes that predate scopedModels.
357
+ const vision = selectVisionModels(ctx.scopedModels, ctx.modelRegistry.getAll());
352
358
  if (vision.length === 0) {
353
359
  ctx.ui.notify("[multimodal-proxy] No vision-capable models in registry.", "error");
354
360
  return;
@@ -376,9 +382,11 @@ async function pickVisionModel(
376
382
  if (providerSet.length === 1) {
377
383
  providerPicked = providerSet[0];
378
384
  } else {
379
- // Start directly at the model list for the current (★) provider
380
- // User can navigate back to pick a different provider
381
- providerPicked = currentProvider;
385
+ // Start at the current (★) provider's model list when it's still in the
386
+ // scoped set; otherwise fall back to the first available scoped provider
387
+ // so the picker never opens on a provider with zero models (e.g. when a
388
+ // model scope excludes the persisted provider).
389
+ providerPicked = providerSet.includes(currentProvider) ? currentProvider : providerSet[0];
382
390
  }
383
391
 
384
392
  // Provider selection loop - re-enters when user picks "← Change provider"
@@ -812,7 +820,9 @@ async function analyzeVideo(
812
820
  );
813
821
 
814
822
  if (isXaiProvider(config.videoProvider)) {
815
- return analyzeVideoViaXaiNative(mediaFile, filename, prompt, conversationContext, config, auth.apiKey, auth.headers, ctx, hash, mediaPath);
823
+ // sanitizeProviderHeaders: auth.headers is ProviderHeaders (Record<string, string | null>,
824
+ // pi ≥ 0.84) where null = deletion marker; the xAI raw-fetch path needs clean strings.
825
+ return analyzeVideoViaXaiNative(mediaFile, filename, prompt, conversationContext, config, auth.apiKey, sanitizeProviderHeaders(auth.headers), ctx, hash, mediaPath);
816
826
  }
817
827
 
818
828
  const contextBlock = conversationContext
@@ -897,11 +907,18 @@ async function analyzeVideoViaXaiNative(
897
907
 
898
908
  // ── analyze_image tool handler ─────────────────────────────────────────────
899
909
 
900
- function xaiHeaders(apiKey: string, extra?: Record<string, string>, contentType?: string): Record<string, string> {
901
- const headers: Record<string, string> = {
902
- ...(extra ?? {}),
903
- Authorization: extra?.Authorization ?? `Bearer ${apiKey}`,
904
- };
910
+ function xaiHeaders(apiKey: string, extra?: Record<string, string | null>, contentType?: string): Record<string, string> {
911
+ // `extra` may carry `null` header-deletion markers from ProviderHeaders (pi ≥ 0.84).
912
+ // Strip them: undici rejects non-string header values (TypeError) or would send a
913
+ // literal "null". Defense-in-depth — the xAI path also sanitizes at its boundary.
914
+ const headers: Record<string, string> = sanitizeProviderHeaders(extra);
915
+ // Only inject the default Bearer when the caller didn't address Authorization
916
+ // at all. An explicit `Authorization: null` is a pi ≥ 0.84 deletion marker and
917
+ // must be honored (suppress the header) rather than re-adding the key and
918
+ // forwarding a credential that was deliberately suppressed.
919
+ if (!extra || !("Authorization" in extra)) {
920
+ headers.Authorization ??= `Bearer ${apiKey}`;
921
+ }
905
922
  if (contentType) headers["Content-Type"] = contentType;
906
923
  return headers;
907
924
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multimodal-proxy",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Automatic image, video and audio description for any model in Pi. Routes media to a multimodal model and injects descriptions into context.",
5
5
  "keywords": [
6
6
  "pi-package"