feat: extract text from fetched PDFs

This commit is contained in:
2026-11-01 14:20:30 -08:00
parent a390c7287a
commit cb9d5a787b
13 changed files with 768 additions and 43 deletions
+100 -1
View File
@@ -4,6 +4,7 @@ import { type ToolAwareStreamingEvent } from "../src/llm/chat-tools.js";
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "../src/llm/protocols/chat-completions-api.js";
import { completeWithMessagesApi, streamWithMessagesApi } from "../src/llm/protocols/messages-api.js";
import { streamWithResponsesApi } from "../src/llm/protocols/responses-api.js";
import { createPdf } from "./pdf-fixture.js";
async function* streamFrom(events: any[]) {
for (const event of events) {
@@ -241,10 +242,12 @@ test("fetch_url sends browser-like navigation headers", async () => {
try {
let requestCount = 0;
const requestBodies: any[] = [];
const client = {
chat: {
completions: {
create: async () => {
create: async (body: any) => {
requestBodies.push(body);
requestCount += 1;
if (requestCount === 1) {
return {
@@ -295,12 +298,108 @@ test("fetch_url sends browser-like navigation headers", async () => {
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
});
const toolResult = requestBodies[1]?.messages?.find((message: any) => message.role === "tool");
const fetched = JSON.parse(toolResult?.content ?? "{}");
assert.match(fetched.text, /Consumer price index/);
assert.equal(fetched.title, "CPI");
assert.equal(result.toolEvents[0]?.status, "completed");
} finally {
globalThis.fetch = originalFetch;
}
});
test("fetch_url extracts a mislabeled PDF and returns page handles", async () => {
const originalFetch = globalThis.fetch;
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
const pdf = createPdf(["Revenue is 42.", "Costs are 18."]);
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
fetchCalls.push({ input, init });
return new Response(pdf, { status: 200, headers: { "content-type": "text/plain" } });
}) as typeof fetch;
try {
const requestBodies: any[] = [];
const client = {
messages: {
create: async (body: any) => {
requestBodies.push(body);
if (requestBodies.length === 1) {
return {
content: [{
type: "tool_use",
id: "pdf_call",
name: "fetch_url",
input: { url: "https://example.com/report.pdf", pages: [2] },
}],
usage: { input_tokens: 1, output_tokens: 1 },
};
}
return { content: [{ type: "text", text: "Read page two" }], usage: { input_tokens: 1, output_tokens: 1 } };
},
},
};
const result = await completeWithMessagesApi({
client: client as any,
model: "claude-test",
messages: [{ role: "user", content: "Read the report" }],
});
const toolResult = requestBodies[1]?.messages?.at(-1)?.content?.[0];
const fetched = JSON.parse(toolResult?.content ?? "{}");
assert.equal(result.text, "Read page two");
assert.equal(fetchCalls.length, 1);
assert.equal(fetched.ok, true);
assert.equal(fetched.pdfStatus, "ok");
assert.equal(fetched.pageCount, 2);
assert.deepEqual(fetched.pages, [2]);
assert.equal(fetched.pagesTruncated, true);
assert.match(fetched.text, /--- Page 2 ---/);
assert.match(fetched.text, /Costs are 18/);
assert.doesNotMatch(fetched.text, /Revenue is 42/);
} finally {
globalThis.fetch = originalFetch;
}
});
test("fetch_url marks encrypted and text-free PDFs as unavailable", async () => {
const originalFetch = globalThis.fetch;
let responseBytes = createPdf([""]);
globalThis.fetch = (async () => new Response(responseBytes, {
status: 200,
headers: { "content-type": "application/pdf" },
})) as typeof fetch;
try {
for (const [pdf, expectedStatus] of [[createPdf([""]), "no_text"], [createPdf(["Secret"], true), "encrypted"]] as const) {
responseBytes = pdf;
const requestBodies: any[] = [];
const client = {
messages: {
create: async (body: any) => {
requestBodies.push(body);
if (requestBodies.length === 1) {
return {
content: [{ type: "tool_use", id: "pdf_status_call", name: "fetch_url", input: { url: "https://example.com/report.pdf" } }],
usage: { input_tokens: 1, output_tokens: 1 },
};
}
return { content: [{ type: "text", text: "Unavailable" }], usage: { input_tokens: 1, output_tokens: 1 } };
},
},
};
await completeWithMessagesApi({ client: client as any, model: "claude-test", messages: [{ role: "user", content: "Read PDF" }] });
const toolResult = requestBodies[1]?.messages?.at(-1)?.content?.[0];
const fetched = JSON.parse(toolResult?.content ?? "{}");
assert.equal(fetched.ok, false);
assert.equal(fetched.pdfStatus, expectedStatus);
assert.equal(toolResult?.is_error, true);
assert.ok(fetched.text.length > 0);
}
} finally {
globalThis.fetch = originalFetch;
}
});
test("Messages API executes tool_use blocks and sends tool_result follow-up", async () => {
const originalFetch = globalThis.fetch;
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];