Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/agents/provider-transport-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,13 +743,13 @@ function canApplyFakeIpHostnamePolicy(value: unknown): value is string {
);
}

function resolveModelTransportSsrFPolicy(params: {
model: Model;
export function resolveProviderTransportSsrFPolicy(params: {
baseUrl?: string;
url: string;
allowPrivateNetwork?: boolean;
trustConfiguredBaseUrlOrigin?: boolean;
}): SsrFPolicy | undefined {
const baseUrl = (params.model as { baseUrl?: unknown }).baseUrl;
const baseUrl = params.baseUrl;
const baseOrigin = resolveHttpOrigin(baseUrl);
const requestOrigin = resolveHttpOrigin(params.url);
const requestMatchesBaseOrigin =
Expand Down Expand Up @@ -811,8 +811,8 @@ export function buildGuardedModelFetch(
: (() => {
throw new Error("Unsupported fetch input for transport-aware model request");
})());
const policy = resolveModelTransportSsrFPolicy({
model,
const policy = resolveProviderTransportSsrFPolicy({
baseUrl: model.baseUrl,
url,
allowPrivateNetwork: requestConfig.allowPrivateNetwork,
// Only operator-configured custom/local endpoints get exact-origin trust;
Expand Down
188 changes: 136 additions & 52 deletions src/agents/tools/pdf-native-providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function makeAnthropicAnalyzeParams(
pdfs: Array<{ base64: string; filename: string }>;
maxTokens: number;
baseUrl: string;
requestConfig: Parameters<typeof pdfNativeProviders.anthropicAnalyzePdf>[0]["requestConfig"];
}> = {},
) {
return {
Expand Down Expand Up @@ -50,7 +51,16 @@ function makeGeminiAnalyzeParams(
describe("native PDF provider API calls", () => {
const priorFetch = global.fetch;

const mockFetchResponse = (response: unknown) => {
const jsonResponse = (payload: unknown, init?: ResponseInit): Response =>
new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});

const textResponse = (body: string, init?: ResponseInit): Response => new Response(body, init);

const mockFetchResponse = (response: Response) => {
const fetchMock = vi.fn().mockResolvedValue(response);
global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof global.fetch;
return fetchMock;
Expand All @@ -70,12 +80,11 @@ describe("native PDF provider API calls", () => {
});

it("anthropicAnalyzePdf sends correct request shape", async () => {
const fetchMock = mockFetchResponse({
ok: true,
json: async () => ({
const fetchMock = mockFetchResponse(
jsonResponse({
content: [{ type: "text", text: "Analysis of PDF" }],
}),
});
);

const result = await pdfNativeProviders.anthropicAnalyzePdf(
makeAnthropicAnalyzeParams({
Expand All @@ -89,9 +98,10 @@ describe("native PDF provider API calls", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = firstFetchCall(fetchMock) as [
string,
{ body: string; signal: AbortSignal },
{ body: string; headers: Headers; signal: AbortSignal },
];
expect(url).toContain("/v1/messages");
expect(opts.headers.get("x-api-key")).toBe("test-key");
expect(opts.signal).toBeInstanceOf(AbortSignal);
expect(opts.signal.aborted).toBe(false);
const body = JSON.parse(opts.body);
Expand All @@ -104,12 +114,11 @@ describe("native PDF provider API calls", () => {

it("anthropicAnalyzePdf honors ANTHROPIC_BASE_URL when no base URL is configured", async () => {
vi.stubEnv("ANTHROPIC_BASE_URL", "https://anthropic-pdf-proxy.example/v1");
const fetchMock = mockFetchResponse({
ok: true,
json: async () => ({
const fetchMock = mockFetchResponse(
jsonResponse({
content: [{ type: "text", text: "Analysis of PDF" }],
}),
});
);

await pdfNativeProviders.anthropicAnalyzePdf(makeAnthropicAnalyzeParams());

Expand All @@ -118,12 +127,7 @@ describe("native PDF provider API calls", () => {
});

it("anthropicAnalyzePdf throws on API error", async () => {
mockFetchResponse({
ok: false,
status: 400,
statusText: "Bad Request",
text: async () => "invalid request",
});
mockFetchResponse(textResponse("invalid request", { status: 400, statusText: "Bad Request" }));

await expect(
pdfNativeProviders.anthropicAnalyzePdf(makeAnthropicAnalyzeParams()),
Expand Down Expand Up @@ -196,27 +200,117 @@ describe("native PDF provider API calls", () => {
});

it("anthropicAnalyzePdf throws when response has no text", async () => {
mockFetchResponse({
ok: true,
json: async () => ({
mockFetchResponse(
jsonResponse({
content: [{ type: "text", text: " " }],
}),
});
);

await expect(
pdfNativeProviders.anthropicAnalyzePdf(makeAnthropicAnalyzeParams()),
).rejects.toThrow("Anthropic PDF returned no text");
});

it("anthropicAnalyzePdf trusts the exact configured local provider origin", async () => {
const fetchMock = mockFetchResponse(
jsonResponse({
content: [{ type: "text", text: "ok" }],
}),
);

await expect(
pdfNativeProviders.anthropicAnalyzePdf(
makeAnthropicAnalyzeParams({ baseUrl: "http://127.0.0.1:11434" }),
),
).resolves.toBe("ok");
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("anthropicAnalyzePdf honors explicit private-network denial for a configured local origin", async () => {
const fetchMock = mockFetchResponse(
jsonResponse({
content: [{ type: "text", text: "ok" }],
}),
);

await expect(
pdfNativeProviders.anthropicAnalyzePdf(
makeAnthropicAnalyzeParams({
baseUrl: "http://127.0.0.1:11434",
requestConfig: {
request: { allowPrivateNetwork: false },
},
}),
),
).rejects.toThrow(/private|SSRF|blocked/i);
expect(fetchMock).not.toHaveBeenCalled();
});

it("anthropicAnalyzePdf does not carry exact-origin trust across redirects", async () => {
const fetchMock = mockFetchResponse(
new Response(null, {
status: 302,
headers: { location: "http://127.0.0.1:4321/v1/messages" },
}),
);

await expect(
pdfNativeProviders.anthropicAnalyzePdf(
makeAnthropicAnalyzeParams({ baseUrl: "http://127.0.0.1:11434" }),
),
).rejects.toThrow(/private|SSRF|blocked/i);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("anthropicAnalyzePdf allows off-origin private redirects with explicit opt-in", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(null, {
status: 302,
headers: { location: "http://127.0.0.1:4321/v1/messages" },
}),
)
.mockResolvedValueOnce(
jsonResponse({
content: [{ type: "text", text: "ok" }],
}),
);
global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof global.fetch;

await expect(
pdfNativeProviders.anthropicAnalyzePdf(
makeAnthropicAnalyzeParams({
baseUrl: "http://127.0.0.1:11434",
requestConfig: {
request: { allowPrivateNetwork: true },
},
}),
),
).resolves.toBe("ok");
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("anthropicAnalyzePdf rejects oversized successful JSON responses", async () => {
mockFetchResponse(
jsonResponse({
content: [{ type: "text", text: "x".repeat(17 * 1024 * 1024) }],
}),
);

await expect(
pdfNativeProviders.anthropicAnalyzePdf(makeAnthropicAnalyzeParams()),
).rejects.toThrow("JSON response exceeds");
});

it("geminiAnalyzePdf sends correct request shape", async () => {
// Gemini API keys belong in headers here, not query strings that are more
// likely to leak through logs and URL diagnostics.
const fetchMock = mockFetchResponse({
ok: true,
json: async () => ({
const fetchMock = mockFetchResponse(
jsonResponse({
candidates: [{ content: { parts: [{ text: "Gemini PDF analysis" }] } }],
}),
});
);

const result = await pdfNativeProviders.geminiAnalyzePdf(
makeGeminiAnalyzeParams({
Expand All @@ -229,12 +323,12 @@ describe("native PDF provider API calls", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = firstFetchCall(fetchMock) as [
string,
{ body: string; headers: Record<string, string>; signal: AbortSignal },
{ body: string; headers: Headers; signal: AbortSignal },
];
expect(url).toContain("generateContent");
expect(url).toContain("gemini-2.5-pro");
expect(url).not.toContain("?key=");
expect(opts.headers["x-goog-api-key"]).toBe("test-key");
expect(opts.headers.get("x-goog-api-key")).toBe("test-key");
expect(opts.signal).toBeInstanceOf(AbortSignal);
expect(opts.signal.aborted).toBe(false);
const body = JSON.parse(opts.body);
Expand All @@ -244,36 +338,29 @@ describe("native PDF provider API calls", () => {
});

it("geminiAnalyzePdf throws on API error", async () => {
mockFetchResponse({
ok: false,
status: 500,
statusText: "Internal Server Error",
text: async () => "server error",
});
mockFetchResponse(
textResponse("server error", { status: 500, statusText: "Internal Server Error" }),
);

await expect(pdfNativeProviders.geminiAnalyzePdf(makeGeminiAnalyzeParams())).rejects.toThrow(
"Gemini PDF request failed",
);
});

it("geminiAnalyzePdf throws when no candidates returned", async () => {
mockFetchResponse({
ok: true,
json: async () => ({ candidates: [] }),
});
mockFetchResponse(jsonResponse({ candidates: [] }));

await expect(pdfNativeProviders.geminiAnalyzePdf(makeGeminiAnalyzeParams())).rejects.toThrow(
"Gemini PDF returned no candidates",
);
});

it("anthropicAnalyzePdf supports multiple PDFs", async () => {
const fetchMock = mockFetchResponse({
ok: true,
json: async () => ({
const fetchMock = mockFetchResponse(
jsonResponse({
content: [{ type: "text", text: "Multi-doc analysis" }],
}),
});
);

await pdfNativeProviders.anthropicAnalyzePdf(
makeAnthropicAnalyzeParams({
Expand All @@ -295,12 +382,11 @@ describe("native PDF provider API calls", () => {
});

it("anthropicAnalyzePdf uses custom base URL", async () => {
const fetchMock = mockFetchResponse({
ok: true,
json: async () => ({
const fetchMock = mockFetchResponse(
jsonResponse({
content: [{ type: "text", text: "ok" }],
}),
});
);

await pdfNativeProviders.anthropicAnalyzePdf(
makeAnthropicAnalyzeParams({ baseUrl: "https://custom.example.com" }),
Expand All @@ -322,12 +408,11 @@ describe("native PDF provider API calls", () => {
});

it("geminiAnalyzePdf does not duplicate /v1beta when baseUrl already includes it", async () => {
const fetchMock = mockFetchResponse({
ok: true,
json: async () => ({
const fetchMock = mockFetchResponse(
jsonResponse({
candidates: [{ content: { parts: [{ text: "ok" }] } }],
}),
});
);

await pdfNativeProviders.geminiAnalyzePdf(
makeGeminiAnalyzeParams({
Expand All @@ -341,12 +426,11 @@ describe("native PDF provider API calls", () => {
});

it("geminiAnalyzePdf normalizes bare Google API hosts to a single /v1beta root", async () => {
const fetchMock = mockFetchResponse({
ok: true,
json: async () => ({
const fetchMock = mockFetchResponse(
jsonResponse({
candidates: [{ content: { parts: [{ text: "ok" }] } }],
}),
});
);

await pdfNativeProviders.geminiAnalyzePdf(
makeGeminiAnalyzeParams({
Expand Down
Loading
Loading