@@ -18,6 +18,7 @@ function makeAnthropicAnalyzeParams(
1818 pdfs : Array < { base64 : string ; filename : string } > ;
1919 maxTokens : number ;
2020 baseUrl : string ;
21+ requestConfig : Parameters < typeof pdfNativeProviders . anthropicAnalyzePdf > [ 0 ] [ "requestConfig" ] ;
2122 } > = { } ,
2223) {
2324 return {
@@ -50,7 +51,16 @@ function makeGeminiAnalyzeParams(
5051describe ( "native PDF provider API calls" , ( ) => {
5152 const priorFetch = global . fetch ;
5253
53- const mockFetchResponse = ( response : unknown ) => {
54+ const jsonResponse = ( payload : unknown , init ?: ResponseInit ) : Response =>
55+ new Response ( JSON . stringify ( payload ) , {
56+ status : 200 ,
57+ headers : { "Content-Type" : "application/json" } ,
58+ ...init ,
59+ } ) ;
60+
61+ const textResponse = ( body : string , init ?: ResponseInit ) : Response => new Response ( body , init ) ;
62+
63+ const mockFetchResponse = ( response : Response ) => {
5464 const fetchMock = vi . fn ( ) . mockResolvedValue ( response ) ;
5565 global . fetch = Object . assign ( fetchMock , { preconnect : vi . fn ( ) } ) as typeof global . fetch ;
5666 return fetchMock ;
@@ -70,12 +80,11 @@ describe("native PDF provider API calls", () => {
7080 } ) ;
7181
7282 it ( "anthropicAnalyzePdf sends correct request shape" , async ( ) => {
73- const fetchMock = mockFetchResponse ( {
74- ok : true ,
75- json : async ( ) => ( {
83+ const fetchMock = mockFetchResponse (
84+ jsonResponse ( {
7685 content : [ { type : "text" , text : "Analysis of PDF" } ] ,
7786 } ) ,
78- } ) ;
87+ ) ;
7988
8089 const result = await pdfNativeProviders . anthropicAnalyzePdf (
8190 makeAnthropicAnalyzeParams ( {
@@ -89,9 +98,10 @@ describe("native PDF provider API calls", () => {
8998 expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
9099 const [ url , opts ] = firstFetchCall ( fetchMock ) as [
91100 string ,
92- { body : string ; signal : AbortSignal } ,
101+ { body : string ; headers : Headers ; signal : AbortSignal } ,
93102 ] ;
94103 expect ( url ) . toContain ( "/v1/messages" ) ;
104+ expect ( opts . headers . get ( "x-api-key" ) ) . toBe ( "test-key" ) ;
95105 expect ( opts . signal ) . toBeInstanceOf ( AbortSignal ) ;
96106 expect ( opts . signal . aborted ) . toBe ( false ) ;
97107 const body = JSON . parse ( opts . body ) ;
@@ -104,12 +114,11 @@ describe("native PDF provider API calls", () => {
104114
105115 it ( "anthropicAnalyzePdf honors ANTHROPIC_BASE_URL when no base URL is configured" , async ( ) => {
106116 vi . stubEnv ( "ANTHROPIC_BASE_URL" , "https://anthropic-pdf-proxy.example/v1" ) ;
107- const fetchMock = mockFetchResponse ( {
108- ok : true ,
109- json : async ( ) => ( {
117+ const fetchMock = mockFetchResponse (
118+ jsonResponse ( {
110119 content : [ { type : "text" , text : "Analysis of PDF" } ] ,
111120 } ) ,
112- } ) ;
121+ ) ;
113122
114123 await pdfNativeProviders . anthropicAnalyzePdf ( makeAnthropicAnalyzeParams ( ) ) ;
115124
@@ -118,12 +127,7 @@ describe("native PDF provider API calls", () => {
118127 } ) ;
119128
120129 it ( "anthropicAnalyzePdf throws on API error" , async ( ) => {
121- mockFetchResponse ( {
122- ok : false ,
123- status : 400 ,
124- statusText : "Bad Request" ,
125- text : async ( ) => "invalid request" ,
126- } ) ;
130+ mockFetchResponse ( textResponse ( "invalid request" , { status : 400 , statusText : "Bad Request" } ) ) ;
127131
128132 await expect (
129133 pdfNativeProviders . anthropicAnalyzePdf ( makeAnthropicAnalyzeParams ( ) ) ,
@@ -196,27 +200,117 @@ describe("native PDF provider API calls", () => {
196200 } ) ;
197201
198202 it ( "anthropicAnalyzePdf throws when response has no text" , async ( ) => {
199- mockFetchResponse ( {
200- ok : true ,
201- json : async ( ) => ( {
203+ mockFetchResponse (
204+ jsonResponse ( {
202205 content : [ { type : "text" , text : " " } ] ,
203206 } ) ,
204- } ) ;
207+ ) ;
205208
206209 await expect (
207210 pdfNativeProviders . anthropicAnalyzePdf ( makeAnthropicAnalyzeParams ( ) ) ,
208211 ) . rejects . toThrow ( "Anthropic PDF returned no text" ) ;
209212 } ) ;
210213
214+ it ( "anthropicAnalyzePdf trusts the exact configured local provider origin" , async ( ) => {
215+ const fetchMock = mockFetchResponse (
216+ jsonResponse ( {
217+ content : [ { type : "text" , text : "ok" } ] ,
218+ } ) ,
219+ ) ;
220+
221+ await expect (
222+ pdfNativeProviders . anthropicAnalyzePdf (
223+ makeAnthropicAnalyzeParams ( { baseUrl : "http://127.0.0.1:11434" } ) ,
224+ ) ,
225+ ) . resolves . toBe ( "ok" ) ;
226+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
227+ } ) ;
228+
229+ it ( "anthropicAnalyzePdf honors explicit private-network denial for a configured local origin" , async ( ) => {
230+ const fetchMock = mockFetchResponse (
231+ jsonResponse ( {
232+ content : [ { type : "text" , text : "ok" } ] ,
233+ } ) ,
234+ ) ;
235+
236+ await expect (
237+ pdfNativeProviders . anthropicAnalyzePdf (
238+ makeAnthropicAnalyzeParams ( {
239+ baseUrl : "http://127.0.0.1:11434" ,
240+ requestConfig : {
241+ request : { allowPrivateNetwork : false } ,
242+ } ,
243+ } ) ,
244+ ) ,
245+ ) . rejects . toThrow ( / p r i v a t e | S S R F | b l o c k e d / i) ;
246+ expect ( fetchMock ) . not . toHaveBeenCalled ( ) ;
247+ } ) ;
248+
249+ it ( "anthropicAnalyzePdf does not carry exact-origin trust across redirects" , async ( ) => {
250+ const fetchMock = mockFetchResponse (
251+ new Response ( null , {
252+ status : 302 ,
253+ headers : { location : "http://127.0.0.1:4321/v1/messages" } ,
254+ } ) ,
255+ ) ;
256+
257+ await expect (
258+ pdfNativeProviders . anthropicAnalyzePdf (
259+ makeAnthropicAnalyzeParams ( { baseUrl : "http://127.0.0.1:11434" } ) ,
260+ ) ,
261+ ) . rejects . toThrow ( / p r i v a t e | S S R F | b l o c k e d / i) ;
262+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
263+ } ) ;
264+
265+ it ( "anthropicAnalyzePdf allows off-origin private redirects with explicit opt-in" , async ( ) => {
266+ const fetchMock = vi
267+ . fn ( )
268+ . mockResolvedValueOnce (
269+ new Response ( null , {
270+ status : 302 ,
271+ headers : { location : "http://127.0.0.1:4321/v1/messages" } ,
272+ } ) ,
273+ )
274+ . mockResolvedValueOnce (
275+ jsonResponse ( {
276+ content : [ { type : "text" , text : "ok" } ] ,
277+ } ) ,
278+ ) ;
279+ global . fetch = Object . assign ( fetchMock , { preconnect : vi . fn ( ) } ) as typeof global . fetch ;
280+
281+ await expect (
282+ pdfNativeProviders . anthropicAnalyzePdf (
283+ makeAnthropicAnalyzeParams ( {
284+ baseUrl : "http://127.0.0.1:11434" ,
285+ requestConfig : {
286+ request : { allowPrivateNetwork : true } ,
287+ } ,
288+ } ) ,
289+ ) ,
290+ ) . resolves . toBe ( "ok" ) ;
291+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 2 ) ;
292+ } ) ;
293+
294+ it ( "anthropicAnalyzePdf rejects oversized successful JSON responses" , async ( ) => {
295+ mockFetchResponse (
296+ jsonResponse ( {
297+ content : [ { type : "text" , text : "x" . repeat ( 17 * 1024 * 1024 ) } ] ,
298+ } ) ,
299+ ) ;
300+
301+ await expect (
302+ pdfNativeProviders . anthropicAnalyzePdf ( makeAnthropicAnalyzeParams ( ) ) ,
303+ ) . rejects . toThrow ( "JSON response exceeds" ) ;
304+ } ) ;
305+
211306 it ( "geminiAnalyzePdf sends correct request shape" , async ( ) => {
212307 // Gemini API keys belong in headers here, not query strings that are more
213308 // likely to leak through logs and URL diagnostics.
214- const fetchMock = mockFetchResponse ( {
215- ok : true ,
216- json : async ( ) => ( {
309+ const fetchMock = mockFetchResponse (
310+ jsonResponse ( {
217311 candidates : [ { content : { parts : [ { text : "Gemini PDF analysis" } ] } } ] ,
218312 } ) ,
219- } ) ;
313+ ) ;
220314
221315 const result = await pdfNativeProviders . geminiAnalyzePdf (
222316 makeGeminiAnalyzeParams ( {
@@ -229,12 +323,12 @@ describe("native PDF provider API calls", () => {
229323 expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
230324 const [ url , opts ] = firstFetchCall ( fetchMock ) as [
231325 string ,
232- { body : string ; headers : Record < string , string > ; signal : AbortSignal } ,
326+ { body : string ; headers : Headers ; signal : AbortSignal } ,
233327 ] ;
234328 expect ( url ) . toContain ( "generateContent" ) ;
235329 expect ( url ) . toContain ( "gemini-2.5-pro" ) ;
236330 expect ( url ) . not . toContain ( "?key=" ) ;
237- expect ( opts . headers [ "x-goog-api-key" ] ) . toBe ( "test-key" ) ;
331+ expect ( opts . headers . get ( "x-goog-api-key" ) ) . toBe ( "test-key" ) ;
238332 expect ( opts . signal ) . toBeInstanceOf ( AbortSignal ) ;
239333 expect ( opts . signal . aborted ) . toBe ( false ) ;
240334 const body = JSON . parse ( opts . body ) ;
@@ -244,36 +338,29 @@ describe("native PDF provider API calls", () => {
244338 } ) ;
245339
246340 it ( "geminiAnalyzePdf throws on API error" , async ( ) => {
247- mockFetchResponse ( {
248- ok : false ,
249- status : 500 ,
250- statusText : "Internal Server Error" ,
251- text : async ( ) => "server error" ,
252- } ) ;
341+ mockFetchResponse (
342+ textResponse ( "server error" , { status : 500 , statusText : "Internal Server Error" } ) ,
343+ ) ;
253344
254345 await expect ( pdfNativeProviders . geminiAnalyzePdf ( makeGeminiAnalyzeParams ( ) ) ) . rejects . toThrow (
255346 "Gemini PDF request failed" ,
256347 ) ;
257348 } ) ;
258349
259350 it ( "geminiAnalyzePdf throws when no candidates returned" , async ( ) => {
260- mockFetchResponse ( {
261- ok : true ,
262- json : async ( ) => ( { candidates : [ ] } ) ,
263- } ) ;
351+ mockFetchResponse ( jsonResponse ( { candidates : [ ] } ) ) ;
264352
265353 await expect ( pdfNativeProviders . geminiAnalyzePdf ( makeGeminiAnalyzeParams ( ) ) ) . rejects . toThrow (
266354 "Gemini PDF returned no candidates" ,
267355 ) ;
268356 } ) ;
269357
270358 it ( "anthropicAnalyzePdf supports multiple PDFs" , async ( ) => {
271- const fetchMock = mockFetchResponse ( {
272- ok : true ,
273- json : async ( ) => ( {
359+ const fetchMock = mockFetchResponse (
360+ jsonResponse ( {
274361 content : [ { type : "text" , text : "Multi-doc analysis" } ] ,
275362 } ) ,
276- } ) ;
363+ ) ;
277364
278365 await pdfNativeProviders . anthropicAnalyzePdf (
279366 makeAnthropicAnalyzeParams ( {
@@ -295,12 +382,11 @@ describe("native PDF provider API calls", () => {
295382 } ) ;
296383
297384 it ( "anthropicAnalyzePdf uses custom base URL" , async ( ) => {
298- const fetchMock = mockFetchResponse ( {
299- ok : true ,
300- json : async ( ) => ( {
385+ const fetchMock = mockFetchResponse (
386+ jsonResponse ( {
301387 content : [ { type : "text" , text : "ok" } ] ,
302388 } ) ,
303- } ) ;
389+ ) ;
304390
305391 await pdfNativeProviders . anthropicAnalyzePdf (
306392 makeAnthropicAnalyzeParams ( { baseUrl : "https://custom.example.com" } ) ,
@@ -322,12 +408,11 @@ describe("native PDF provider API calls", () => {
322408 } ) ;
323409
324410 it ( "geminiAnalyzePdf does not duplicate /v1beta when baseUrl already includes it" , async ( ) => {
325- const fetchMock = mockFetchResponse ( {
326- ok : true ,
327- json : async ( ) => ( {
411+ const fetchMock = mockFetchResponse (
412+ jsonResponse ( {
328413 candidates : [ { content : { parts : [ { text : "ok" } ] } } ] ,
329414 } ) ,
330- } ) ;
415+ ) ;
331416
332417 await pdfNativeProviders . geminiAnalyzePdf (
333418 makeGeminiAnalyzeParams ( {
@@ -341,12 +426,11 @@ describe("native PDF provider API calls", () => {
341426 } ) ;
342427
343428 it ( "geminiAnalyzePdf normalizes bare Google API hosts to a single /v1beta root" , async ( ) => {
344- const fetchMock = mockFetchResponse ( {
345- ok : true ,
346- json : async ( ) => ( {
429+ const fetchMock = mockFetchResponse (
430+ jsonResponse ( {
347431 candidates : [ { content : { parts : [ { text : "ok" } ] } } ] ,
348432 } ) ,
349- } ) ;
433+ ) ;
350434
351435 await pdfNativeProviders . geminiAnalyzePdf (
352436 makeGeminiAnalyzeParams ( {
0 commit comments