From e82090eb979d3daa9be31aecf2833e0fbe6d0f14 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 3 Sep 2026 06:28:29 +0700 Subject: [PATCH] fix(delegate): accept pi-ai 0.84 ProviderHeaders (null-valued headers filtered before fetch) pi-ai 0.84 widened ProviderHeaders from Record to Record. callVisionModel and callWithRetryAndFallback now accept the canonical ProviderHeaders type; the single header-merge site filters null values ("unset") before fetch's HeadersInit, which rejects null. Regression test pins both the filtering and the widened signature. --- lib/delegate.ts | 14 ++++++++++---- package.json | 2 +- tests/delegate.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/lib/delegate.ts b/lib/delegate.ts index 8d6d85a..58338e3 100644 --- a/lib/delegate.ts +++ b/lib/delegate.ts @@ -17,7 +17,7 @@ * Clean-room: the OpenAI-compatible `/chat/completions` request shape with a * base64 data-URL image is standard API usage, not copied from pi-vision-tool. */ -import type { Model, Api } from "@earendil-works/pi-ai"; +import type { Model, Api, ProviderHeaders } from "@earendil-works/pi-ai"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { isConfiguredForDelegation, loadConfig, type ReasoningLevel, type VisionConfig } from "./config.ts"; @@ -77,7 +77,7 @@ function buildReasoningParams( export async function callVisionModel( visionModel: Model, apiKey: string | undefined, - providerHeaders: Record | undefined, + providerHeaders: ProviderHeaders | undefined, image: LoadedImage, prompt: string, signal: AbortSignal | undefined, @@ -110,7 +110,13 @@ export async function callVisionModel( const headers: Record = { "Content-Type": "application/json" }; if (apiKey) headers.Authorization = `Bearer ${apiKey}`; - if (providerHeaders) Object.assign(headers, providerHeaders); + // pi-ai 0.84 widened ProviderHeaders to Record — null means + // "unset"; a null value must never reach fetch's HeadersInit (typed Record). + if (providerHeaders) { + for (const [key, value] of Object.entries(providerHeaders)) { + if (value !== null) headers[key] = value; + } + } const response = await fetch(`${baseUrl}/chat/completions`, { method: "POST", @@ -375,7 +381,7 @@ async function callWithRetryAndFallback( signal: AbortSignal | undefined, primaryModel: Model, apiKey: string | undefined, - headers: Record | undefined, + headers: ProviderHeaders | undefined, image: LoadedImage, modelId: string, baseDetails: Omit, diff --git a/package.json b/package.json index ad5f913..38e911a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@getpipher/vision", - "version": "0.5.2", + "version": "0.5.3", "description": "Capability-aware vision + paste extension for the pi coding agent. Delegates image analysis to a vision model only when the active primary model is text-only; passes images through natively for multimodal models (zero delegation).", "keywords": [ "pi-package", diff --git a/tests/delegate.test.ts b/tests/delegate.test.ts index 415f25d..1505704 100644 --- a/tests/delegate.test.ts +++ b/tests/delegate.test.ts @@ -143,6 +143,32 @@ test("callVisionModel: sends chat/completions POST with image data URL + prompt" } }); +test("callVisionModel: null-valued provider headers are filtered before fetch (pi-ai 0.84 ProviderHeaders)", async () => { + const m = mockFetch({ + status: 200, + body: { choices: [{ message: { content: "ok" } }] }, + }); + try { + await callVisionModel( + makeVisionModel(), + "key-123", + { "X-Real-Header": "alive", "X-Null-Header": null }, + { data: PNG_1x1_B64, mimeType: "image/png" }, + "describe this", + undefined, + "off", + ); + assert.equal(m.calls.length, 1); + const headers = m.calls[0]!.init.headers as Record; + assert.equal(headers["X-Real-Header"], "alive"); + assert.ok(!("X-Null-Header" in headers), "null-valued header must not reach fetch"); + assert.equal(headers.Authorization, "Bearer key-123"); + assert.equal(headers["Content-Type"], "application/json"); + } finally { + m.restore(); + } +}); + test("callVisionModel: falls back to reasoning_content when content is empty", async () => { const m = mockFetch({ status: 200,