mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(pdf): guard native provider requests (#97872)
* fix(pdf): guard native provider requests * fix(pdf): preserve configured origin trust * fix(pdf): preserve resolver compatibility
This commit is contained in:
@@ -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 =
|
||||
@@ -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;
|
||||
|
||||
@@ -18,6 +18,7 @@ function makeAnthropicAnalyzeParams(
|
||||
pdfs: Array<{ base64: string; filename: string }>;
|
||||
maxTokens: number;
|
||||
baseUrl: string;
|
||||
requestConfig: Parameters<typeof pdfNativeProviders.anthropicAnalyzePdf>[0]["requestConfig"];
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
@@ -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);
|
||||
@@ -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());
|
||||
|
||||
@@ -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()),
|
||||
@@ -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({
|
||||
@@ -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);
|
||||
@@ -244,12 +338,9 @@ 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",
|
||||
@@ -257,10 +348,7 @@ describe("native PDF provider API calls", () => {
|
||||
});
|
||||
|
||||
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",
|
||||
@@ -268,12 +356,11 @@ describe("native PDF provider API calls", () => {
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -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" }),
|
||||
@@ -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({
|
||||
@@ -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({
|
||||
|
||||
@@ -4,10 +4,17 @@
|
||||
*/
|
||||
|
||||
import { readResponseBodySnippet } from "../../infra/http-error-body.js";
|
||||
import {
|
||||
postJsonRequest,
|
||||
readProviderJsonResponse,
|
||||
resolveProviderHttpRequestConfigWithOriginTrust,
|
||||
} from "../../media-understanding/shared.js";
|
||||
import { normalizeProviderTransportWithPlugin } from "../../plugins/provider-runtime.js";
|
||||
import { isRecord } from "../../utils.js";
|
||||
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
|
||||
import { resolveAnthropicMessagesUrl } from "../anthropic-transport-stream.js";
|
||||
import type { ModelProviderRequestTransportOverrides } from "../provider-request-config.js";
|
||||
import { resolveProviderTransportSsrFPolicy } from "../provider-transport-fetch.js";
|
||||
|
||||
type PdfInput = {
|
||||
base64: string;
|
||||
@@ -18,6 +25,56 @@ const NATIVE_PDF_PROVIDER_FETCH_TIMEOUT_MS = 120_000;
|
||||
const NATIVE_PDF_ERROR_BODY_MAX_BYTES = 8 * 1024;
|
||||
const NATIVE_PDF_ERROR_BODY_MAX_CHARS = 400;
|
||||
|
||||
type NativePdfProviderRequestConfig = {
|
||||
headers?: Record<string, string>;
|
||||
request?: ModelProviderRequestTransportOverrides;
|
||||
};
|
||||
|
||||
type NativePdfJsonRequest = {
|
||||
url: string;
|
||||
headers: Headers;
|
||||
body: unknown;
|
||||
allowPrivateNetwork: boolean;
|
||||
ssrfPolicy: Parameters<typeof postJsonRequest>[0]["ssrfPolicy"];
|
||||
dispatcherPolicy: Parameters<typeof postJsonRequest>[0]["dispatcherPolicy"];
|
||||
failureLabel: string;
|
||||
responseLabel: string;
|
||||
nonJsonMessage: string;
|
||||
};
|
||||
|
||||
async function postNativePdfJson(params: NativePdfJsonRequest): Promise<Record<string, unknown>> {
|
||||
const { response, release } = await postJsonRequest({
|
||||
url: params.url,
|
||||
headers: params.headers,
|
||||
body: params.body,
|
||||
timeoutMs: NATIVE_PDF_PROVIDER_FETCH_TIMEOUT_MS,
|
||||
fetchFn: fetch,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
dispatcherPolicy: params.dispatcherPolicy,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const body = await readResponseBodySnippet(response, {
|
||||
maxBytes: NATIVE_PDF_ERROR_BODY_MAX_BYTES,
|
||||
maxChars: NATIVE_PDF_ERROR_BODY_MAX_CHARS,
|
||||
});
|
||||
throw new Error(
|
||||
`${params.failureLabel} (${response.status} ${response.statusText})${body ? `: ${body}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = await readProviderJsonResponse<unknown>(response, params.responseLabel);
|
||||
if (!isRecord(json)) {
|
||||
throw new Error(params.nonJsonMessage);
|
||||
}
|
||||
return json;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anthropic – native PDF via Messages API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -47,6 +104,7 @@ export async function anthropicAnalyzePdf(params: {
|
||||
pdfs: PdfInput[];
|
||||
maxTokens?: number;
|
||||
baseUrl?: string;
|
||||
requestConfig?: NativePdfProviderRequestConfig;
|
||||
}): Promise<string> {
|
||||
const apiKey = normalizeSecretInput(params.apiKey);
|
||||
if (!apiKey) {
|
||||
@@ -66,37 +124,46 @@ export async function anthropicAnalyzePdf(params: {
|
||||
}
|
||||
content.push({ type: "text", text: params.prompt });
|
||||
|
||||
const res = await fetch(resolveAnthropicMessagesUrl(params.baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-beta": "pdfs-2024-09-25",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy, trustConfiguredBaseUrlOrigin } =
|
||||
resolveProviderHttpRequestConfigWithOriginTrust({
|
||||
baseUrl: params.baseUrl,
|
||||
defaultBaseUrl: resolveAnthropicMessagesUrl(undefined).replace(/\/messages$/u, ""),
|
||||
defaultHeaders: {
|
||||
...params.requestConfig?.headers,
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-beta": "pdfs-2024-09-25",
|
||||
},
|
||||
request: params.requestConfig?.request,
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
capability: "other",
|
||||
transport: "http",
|
||||
});
|
||||
headers.set("Content-Type", "application/json");
|
||||
const url = resolveAnthropicMessagesUrl(baseUrl);
|
||||
|
||||
const json = await postNativePdfJson({
|
||||
url,
|
||||
headers,
|
||||
body: {
|
||||
model: params.modelId,
|
||||
max_tokens: params.maxTokens ?? 4096,
|
||||
messages: [{ role: "user", content }],
|
||||
},
|
||||
allowPrivateNetwork,
|
||||
ssrfPolicy: resolveProviderTransportSsrFPolicy({
|
||||
baseUrl,
|
||||
url,
|
||||
allowPrivateNetwork,
|
||||
trustConfiguredBaseUrlOrigin,
|
||||
}),
|
||||
signal: AbortSignal.timeout(NATIVE_PDF_PROVIDER_FETCH_TIMEOUT_MS),
|
||||
dispatcherPolicy,
|
||||
failureLabel: "Anthropic PDF request failed",
|
||||
responseLabel: "Anthropic PDF response",
|
||||
nonJsonMessage: "Anthropic PDF response was not JSON.",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await readResponseBodySnippet(res, {
|
||||
maxBytes: NATIVE_PDF_ERROR_BODY_MAX_BYTES,
|
||||
maxChars: NATIVE_PDF_ERROR_BODY_MAX_CHARS,
|
||||
});
|
||||
throw new Error(
|
||||
`Anthropic PDF request failed (${res.status} ${res.statusText})${body ? `: ${body}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await res.json().catch(() => null)) as unknown;
|
||||
if (!isRecord(json)) {
|
||||
throw new Error("Anthropic PDF response was not JSON.");
|
||||
}
|
||||
|
||||
const responseContent = json.content as AnthropicResponseContent | undefined;
|
||||
if (!Array.isArray(responseContent)) {
|
||||
throw new Error("Anthropic PDF response missing content array.");
|
||||
@@ -130,6 +197,7 @@ export async function geminiAnalyzePdf(params: {
|
||||
prompt: string;
|
||||
pdfs: PdfInput[];
|
||||
baseUrl?: string;
|
||||
requestConfig?: NativePdfProviderRequestConfig;
|
||||
}): Promise<string> {
|
||||
const apiKey = normalizeSecretInput(params.apiKey);
|
||||
if (!apiKey) {
|
||||
@@ -155,35 +223,42 @@ export async function geminiAnalyzePdf(params: {
|
||||
baseUrl: params.baseUrl,
|
||||
},
|
||||
}) ?? { baseUrl: params.baseUrl };
|
||||
const baseUrl = (transport.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta").replace(
|
||||
/\/v1beta$/i,
|
||||
"",
|
||||
);
|
||||
const url = `${baseUrl}/v1beta/models/${encodeURIComponent(params.modelId)}:generateContent`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey },
|
||||
body: JSON.stringify({
|
||||
contents: [{ role: "user", parts }],
|
||||
}),
|
||||
signal: AbortSignal.timeout(NATIVE_PDF_PROVIDER_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await readResponseBodySnippet(res, {
|
||||
maxBytes: NATIVE_PDF_ERROR_BODY_MAX_BYTES,
|
||||
maxChars: NATIVE_PDF_ERROR_BODY_MAX_CHARS,
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy, trustConfiguredBaseUrlOrigin } =
|
||||
resolveProviderHttpRequestConfigWithOriginTrust({
|
||||
baseUrl: transport.baseUrl,
|
||||
defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
defaultHeaders: {
|
||||
...params.requestConfig?.headers,
|
||||
"x-goog-api-key": apiKey,
|
||||
},
|
||||
request: params.requestConfig?.request,
|
||||
provider: "google",
|
||||
api: "google-generative-ai",
|
||||
capability: "other",
|
||||
transport: "http",
|
||||
});
|
||||
throw new Error(
|
||||
`Gemini PDF request failed (${res.status} ${res.statusText})${body ? `: ${body}` : ""}`,
|
||||
);
|
||||
}
|
||||
headers.set("Content-Type", "application/json");
|
||||
const normalizedBaseUrl = baseUrl.replace(/\/v1beta$/i, "");
|
||||
const url = `${normalizedBaseUrl}/v1beta/models/${encodeURIComponent(params.modelId)}:generateContent`;
|
||||
|
||||
const json = (await res.json().catch(() => null)) as unknown;
|
||||
if (!isRecord(json)) {
|
||||
throw new Error("Gemini PDF response was not JSON.");
|
||||
}
|
||||
const json = await postNativePdfJson({
|
||||
url,
|
||||
headers,
|
||||
body: {
|
||||
contents: [{ role: "user", parts }],
|
||||
},
|
||||
allowPrivateNetwork,
|
||||
ssrfPolicy: resolveProviderTransportSsrFPolicy({
|
||||
baseUrl,
|
||||
url,
|
||||
allowPrivateNetwork,
|
||||
trustConfiguredBaseUrlOrigin,
|
||||
}),
|
||||
dispatcherPolicy,
|
||||
failureLabel: "Gemini PDF request failed",
|
||||
responseLabel: "Gemini PDF response",
|
||||
nonJsonMessage: "Gemini PDF response was not JSON.",
|
||||
});
|
||||
|
||||
const candidates = json.candidates as GeminiCandidate[] | undefined;
|
||||
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { extractPdfContent, type PdfExtractedContent } from "../../media/pdf-ext
|
||||
import { loadWebMediaRaw } from "../../media/web-media.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import type { AuthProfileStore } from "../auth-profiles/types.js";
|
||||
import { getModelProviderRequestTransport } from "../provider-request-config.js";
|
||||
import { optionalFiniteNumberSchema } from "../schema/typebox.js";
|
||||
import { readFiniteNumberParam, ToolInputError } from "./common.js";
|
||||
import { coerceImageModelConfig, type ImageModelConfig } from "./image-tool.helpers.js";
|
||||
@@ -208,6 +209,10 @@ async function runPdfPrompt(params: {
|
||||
pdfs,
|
||||
maxTokens: resolvePdfToolMaxTokens(model.maxTokens),
|
||||
baseUrl: model.baseUrl,
|
||||
requestConfig: {
|
||||
headers: model.headers,
|
||||
request: getModelProviderRequestTransport(model),
|
||||
},
|
||||
});
|
||||
return { text, provider, model: modelId, native: true };
|
||||
}
|
||||
@@ -219,6 +224,10 @@ async function runPdfPrompt(params: {
|
||||
prompt: params.prompt,
|
||||
pdfs,
|
||||
baseUrl: model.baseUrl,
|
||||
requestConfig: {
|
||||
headers: model.headers,
|
||||
request: getModelProviderRequestTransport(model),
|
||||
},
|
||||
});
|
||||
return { text, provider, model: modelId, native: true };
|
||||
}
|
||||
|
||||
@@ -40,8 +40,9 @@ import {
|
||||
pollProviderOperationJson,
|
||||
postJsonRequest,
|
||||
postTranscriptionRequest,
|
||||
resolveProviderOperationTimeoutMs,
|
||||
resolveProviderHttpRequestConfig,
|
||||
resolveProviderHttpRequestConfigWithOriginTrust,
|
||||
resolveProviderOperationTimeoutMs,
|
||||
waitProviderOperationPollInterval,
|
||||
} from "./shared.js";
|
||||
|
||||
@@ -537,6 +538,30 @@ describe("resolveProviderHttpRequestConfig", () => {
|
||||
expect(resolved.headers.get("x-goog-api-key")).toBe("test-key");
|
||||
});
|
||||
|
||||
it("keeps configured-origin trust eligibility internal to core callers", () => {
|
||||
const custom = resolveProviderHttpRequestConfigWithOriginTrust({
|
||||
baseUrl: "https://models.internal/v1",
|
||||
defaultBaseUrl: "https://api.example.com/v1",
|
||||
provider: "example",
|
||||
});
|
||||
const deniedLocal = resolveProviderHttpRequestConfigWithOriginTrust({
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
defaultBaseUrl: "https://api.example.com/v1",
|
||||
provider: "example",
|
||||
request: { allowPrivateNetwork: false },
|
||||
});
|
||||
|
||||
expect(custom.trustConfiguredBaseUrlOrigin).toBe(true);
|
||||
expect(deniedLocal.trustConfiguredBaseUrlOrigin).toBe(false);
|
||||
expect(
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: "https://models.internal/v1",
|
||||
defaultBaseUrl: "https://api.example.com/v1",
|
||||
provider: "example",
|
||||
}),
|
||||
).not.toHaveProperty("trustConfiguredBaseUrlOrigin");
|
||||
});
|
||||
|
||||
it("surfaces dispatcher policy for explicit proxy and mTLS transport overrides", () => {
|
||||
const resolved = resolveProviderHttpRequestConfig({
|
||||
baseUrl: "https://api.deepgram.com/v1",
|
||||
|
||||
@@ -324,7 +324,19 @@ function sanitizeAuditContext(auditContext: string | undefined): string | undefi
|
||||
return cleaned.slice(0, MAX_AUDIT_CONTEXT_CHARS);
|
||||
}
|
||||
|
||||
export function resolveProviderHttpRequestConfig(params: {
|
||||
type ResolvedProviderHttpRequestConfig = {
|
||||
baseUrl: string;
|
||||
allowPrivateNetwork: boolean;
|
||||
headers: Headers;
|
||||
dispatcherPolicy?: PinnedDispatcherPolicy;
|
||||
requestConfig: ResolvedProviderRequestConfig;
|
||||
};
|
||||
|
||||
type ResolvedProviderHttpRequestConfigWithOriginTrust = ResolvedProviderHttpRequestConfig & {
|
||||
trustConfiguredBaseUrlOrigin: boolean;
|
||||
};
|
||||
|
||||
function resolveProviderHttpRequestConfigWithOriginTrustInternal(params: {
|
||||
baseUrl?: string;
|
||||
defaultBaseUrl: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
@@ -335,13 +347,7 @@ export function resolveProviderHttpRequestConfig(params: {
|
||||
api?: string;
|
||||
capability?: ProviderRequestCapability;
|
||||
transport?: ProviderRequestTransport;
|
||||
}): {
|
||||
baseUrl: string;
|
||||
allowPrivateNetwork: boolean;
|
||||
headers: Headers;
|
||||
dispatcherPolicy?: PinnedDispatcherPolicy;
|
||||
requestConfig: ResolvedProviderRequestConfig;
|
||||
} {
|
||||
}): ResolvedProviderHttpRequestConfigWithOriginTrust {
|
||||
const requestConfig = resolveProviderRequestPolicyConfig({
|
||||
provider: params.provider ?? "",
|
||||
baseUrl: params.baseUrl,
|
||||
@@ -368,9 +374,32 @@ export function resolveProviderHttpRequestConfig(params: {
|
||||
headers,
|
||||
dispatcherPolicy: buildProviderRequestDispatcherPolicy(requestConfig),
|
||||
requestConfig,
|
||||
trustConfiguredBaseUrlOrigin:
|
||||
!requestConfig.privateNetworkExplicitlyDenied &&
|
||||
(requestConfig.policy.endpointClass === "custom" ||
|
||||
requestConfig.policy.endpointClass === "local"),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProviderHttpRequestConfig(
|
||||
params: Parameters<typeof resolveProviderHttpRequestConfigWithOriginTrustInternal>[0],
|
||||
): ResolvedProviderHttpRequestConfig {
|
||||
const resolved = resolveProviderHttpRequestConfigWithOriginTrustInternal(params);
|
||||
return {
|
||||
baseUrl: resolved.baseUrl,
|
||||
allowPrivateNetwork: resolved.allowPrivateNetwork,
|
||||
headers: resolved.headers,
|
||||
dispatcherPolicy: resolved.dispatcherPolicy,
|
||||
requestConfig: resolved.requestConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProviderHttpRequestConfigWithOriginTrust(
|
||||
params: Parameters<typeof resolveProviderHttpRequestConfigWithOriginTrustInternal>[0],
|
||||
): ResolvedProviderHttpRequestConfigWithOriginTrust {
|
||||
return resolveProviderHttpRequestConfigWithOriginTrustInternal(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to auto-upgrade a provider HTTP request into
|
||||
* `TRUSTED_ENV_PROXY` mode based on the runtime environment.
|
||||
|
||||
Reference in New Issue
Block a user