mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
299 lines
9.7 KiB
TypeScript
299 lines
9.7 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
import * as pdfExtractModule from "../../media/pdf-extract.js";
|
|
import * as webMedia from "../../media/web-media.js";
|
|
import * as modelAuth from "../model-auth.js";
|
|
import * as modelsConfig from "../models-config.js";
|
|
import * as modelDiscovery from "../pi-model-discovery.js";
|
|
import * as pdfNativeProviders from "./pdf-native-providers.js";
|
|
|
|
const completeMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@mariozechner/pi-ai", async () => {
|
|
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
|
|
return {
|
|
...actual,
|
|
complete: completeMock,
|
|
};
|
|
});
|
|
|
|
type PdfToolModule = typeof import("./pdf-tool.js");
|
|
let createPdfTool: PdfToolModule["createPdfTool"];
|
|
let PdfToolSchema: PdfToolModule["PdfToolSchema"];
|
|
|
|
async function loadCreatePdfTool() {
|
|
if (!createPdfTool || !PdfToolSchema) {
|
|
({ createPdfTool, PdfToolSchema } = await import("./pdf-tool.js"));
|
|
}
|
|
return createPdfTool;
|
|
}
|
|
|
|
async function withTempAgentDir<T>(run: (agentDir: string) => Promise<T>): Promise<T> {
|
|
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pdf-"));
|
|
try {
|
|
return await run(agentDir);
|
|
} finally {
|
|
await fs.rm(agentDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const ANTHROPIC_PDF_MODEL = "anthropic/claude-opus-4-6";
|
|
const OPENAI_PDF_MODEL = "openai/gpt-5.4-mini";
|
|
const FAKE_PDF_MEDIA = {
|
|
kind: "document",
|
|
buffer: Buffer.from("%PDF-1.4 fake"),
|
|
contentType: "application/pdf",
|
|
fileName: "doc.pdf",
|
|
} as const;
|
|
|
|
function requirePdfTool(
|
|
tool: Awaited<ReturnType<typeof loadCreatePdfTool>> extends (...args: any[]) => infer R
|
|
? R
|
|
: never,
|
|
) {
|
|
expect(tool).not.toBeNull();
|
|
if (!tool) {
|
|
throw new Error("expected pdf tool");
|
|
}
|
|
return tool;
|
|
}
|
|
|
|
type PdfToolInstance = ReturnType<typeof requirePdfTool>;
|
|
|
|
async function withConfiguredPdfTool(
|
|
run: (tool: PdfToolInstance, agentDir: string) => Promise<void>,
|
|
) {
|
|
await withTempAgentDir(async (agentDir) => {
|
|
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
|
|
const tool = requirePdfTool((await loadCreatePdfTool())({ config: cfg, agentDir }));
|
|
await run(tool, agentDir);
|
|
});
|
|
}
|
|
|
|
function resetAuthEnv() {
|
|
vi.stubEnv("OPENAI_API_KEY", "");
|
|
vi.stubEnv("ANTHROPIC_API_KEY", "");
|
|
vi.stubEnv("ANTHROPIC_OAUTH_TOKEN", "");
|
|
vi.stubEnv("GEMINI_API_KEY", "");
|
|
vi.stubEnv("GOOGLE_API_KEY", "");
|
|
vi.stubEnv("MINIMAX_API_KEY", "");
|
|
vi.stubEnv("ZAI_API_KEY", "");
|
|
vi.stubEnv("Z_AI_API_KEY", "");
|
|
vi.stubEnv("COPILOT_GITHUB_TOKEN", "");
|
|
vi.stubEnv("GH_TOKEN", "");
|
|
vi.stubEnv("GITHUB_TOKEN", "");
|
|
}
|
|
|
|
function withPdfModel(primary: string): OpenClawConfig {
|
|
return {
|
|
agents: { defaults: { pdfModel: { primary } } },
|
|
} as OpenClawConfig;
|
|
}
|
|
|
|
async function stubPdfToolInfra(
|
|
agentDir: string,
|
|
params?: {
|
|
provider?: string;
|
|
input?: string[];
|
|
modelFound?: boolean;
|
|
},
|
|
) {
|
|
const loadSpy = vi.spyOn(webMedia, "loadWebMediaRaw").mockResolvedValue(FAKE_PDF_MEDIA as never);
|
|
|
|
vi.spyOn(modelDiscovery, "discoverAuthStorage").mockReturnValue({
|
|
setRuntimeApiKey: vi.fn(),
|
|
} as never);
|
|
const find =
|
|
params?.modelFound === false
|
|
? () => null
|
|
: () =>
|
|
({
|
|
provider: params?.provider ?? "anthropic",
|
|
maxTokens: 8192,
|
|
input: params?.input ?? ["text", "document"],
|
|
}) as never;
|
|
vi.spyOn(modelDiscovery, "discoverModels").mockReturnValue({ find } as never);
|
|
|
|
vi.spyOn(modelsConfig, "ensureOpenClawModelsJson").mockResolvedValue({
|
|
agentDir,
|
|
wrote: false,
|
|
});
|
|
|
|
vi.spyOn(modelAuth, "getApiKeyForModel").mockResolvedValue({ apiKey: "test-key" } as never);
|
|
vi.spyOn(modelAuth, "requireApiKey").mockReturnValue("test-key");
|
|
|
|
return { loadSpy };
|
|
}
|
|
|
|
describe("createPdfTool", () => {
|
|
const priorFetch = global.fetch;
|
|
|
|
beforeEach(() => {
|
|
resetAuthEnv();
|
|
completeMock.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
global.fetch = priorFetch;
|
|
});
|
|
|
|
it("returns null without agentDir and no explicit config", async () => {
|
|
expect((await loadCreatePdfTool())()).toBeNull();
|
|
});
|
|
|
|
it("throws when agentDir missing but explicit config present", async () => {
|
|
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
|
|
const createTool = await loadCreatePdfTool();
|
|
expect(() => createTool({ config: cfg })).toThrow("requires agentDir");
|
|
});
|
|
|
|
it("creates tool when a PDF model is configured", async () => {
|
|
await withConfiguredPdfTool(async (tool) => {
|
|
expect(tool.name).toBe("pdf");
|
|
expect(tool.label).toBe("PDF");
|
|
expect(tool.description).toContain("PDF documents");
|
|
});
|
|
});
|
|
|
|
it("rejects when no pdf input provided", async () => {
|
|
await withConfiguredPdfTool(async (tool) => {
|
|
await expect(tool.execute("t1", { prompt: "test" })).rejects.toThrow("pdf required");
|
|
});
|
|
});
|
|
|
|
it("rejects too many PDFs", async () => {
|
|
await withConfiguredPdfTool(async (tool) => {
|
|
const manyPdfs = Array.from({ length: 15 }, (_, i) => `/tmp/doc${i}.pdf`);
|
|
const result = await tool.execute("t1", { prompt: "test", pdfs: manyPdfs });
|
|
expect(result).toMatchObject({
|
|
details: { error: "too_many_pdfs" },
|
|
});
|
|
});
|
|
});
|
|
|
|
it("respects fsPolicy.workspaceOnly for non-sandbox pdf paths", async () => {
|
|
await withTempAgentDir(async (agentDir) => {
|
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pdf-ws-"));
|
|
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pdf-out-"));
|
|
try {
|
|
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
|
|
const tool = requirePdfTool(
|
|
(await loadCreatePdfTool())({
|
|
config: cfg,
|
|
agentDir,
|
|
workspaceDir,
|
|
fsPolicy: { workspaceOnly: true },
|
|
}),
|
|
);
|
|
|
|
const outsidePdf = path.join(outsideDir, "secret.pdf");
|
|
await fs.writeFile(outsidePdf, "%PDF-1.4 fake");
|
|
|
|
await expect(tool.execute("t1", { prompt: "test", pdf: outsidePdf })).rejects.toThrow(
|
|
/not under an allowed directory/i,
|
|
);
|
|
} finally {
|
|
await fs.rm(workspaceDir, { recursive: true, force: true });
|
|
await fs.rm(outsideDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
it("rejects unsupported scheme references", async () => {
|
|
await withConfiguredPdfTool(async (tool) => {
|
|
const result = await tool.execute("t1", {
|
|
prompt: "test",
|
|
pdf: "ftp://example.com/doc.pdf",
|
|
});
|
|
expect(result).toMatchObject({
|
|
details: { error: "unsupported_pdf_reference" },
|
|
});
|
|
});
|
|
});
|
|
|
|
it("uses native PDF path without eager extraction", async () => {
|
|
await withTempAgentDir(async (agentDir) => {
|
|
await stubPdfToolInfra(agentDir, { provider: "anthropic", input: ["text", "document"] });
|
|
vi.spyOn(pdfNativeProviders, "anthropicAnalyzePdf").mockResolvedValue("native summary");
|
|
const extractSpy = vi.spyOn(pdfExtractModule, "extractPdfContent");
|
|
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
|
|
const tool = requirePdfTool((await loadCreatePdfTool())({ config: cfg, agentDir }));
|
|
|
|
const result = await tool.execute("t1", {
|
|
prompt: "summarize",
|
|
pdf: "/tmp/doc.pdf",
|
|
});
|
|
|
|
expect(extractSpy).not.toHaveBeenCalled();
|
|
expect(result).toMatchObject({
|
|
content: [{ type: "text", text: "native summary" }],
|
|
details: { native: true, model: ANTHROPIC_PDF_MODEL },
|
|
});
|
|
});
|
|
});
|
|
|
|
it("rejects pages parameter for native PDF providers", async () => {
|
|
await withTempAgentDir(async (agentDir) => {
|
|
await stubPdfToolInfra(agentDir, { provider: "anthropic", input: ["text", "document"] });
|
|
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
|
|
const tool = requirePdfTool((await loadCreatePdfTool())({ config: cfg, agentDir }));
|
|
|
|
await expect(
|
|
tool.execute("t1", {
|
|
prompt: "summarize",
|
|
pdf: "/tmp/doc.pdf",
|
|
pages: "1-2",
|
|
}),
|
|
).rejects.toThrow("pages is not supported with native PDF providers");
|
|
});
|
|
});
|
|
|
|
it("uses extraction fallback for non-native models", async () => {
|
|
await withTempAgentDir(async (agentDir) => {
|
|
await stubPdfToolInfra(agentDir, { provider: "openai", input: ["text"] });
|
|
const extractSpy = vi.spyOn(pdfExtractModule, "extractPdfContent").mockResolvedValue({
|
|
text: "Extracted content",
|
|
images: [],
|
|
});
|
|
completeMock.mockResolvedValue({
|
|
role: "assistant",
|
|
stopReason: "stop",
|
|
content: [{ type: "text", text: "fallback summary" }],
|
|
} as never);
|
|
|
|
const cfg = withPdfModel(OPENAI_PDF_MODEL);
|
|
const tool = requirePdfTool((await loadCreatePdfTool())({ config: cfg, agentDir }));
|
|
|
|
const result = await tool.execute("t1", {
|
|
prompt: "summarize",
|
|
pdf: "/tmp/doc.pdf",
|
|
});
|
|
|
|
expect(extractSpy).toHaveBeenCalledTimes(1);
|
|
expect(result).toMatchObject({
|
|
content: [{ type: "text", text: "fallback summary" }],
|
|
details: { native: false, model: OPENAI_PDF_MODEL },
|
|
});
|
|
});
|
|
});
|
|
|
|
it("tool parameters have correct schema shape", async () => {
|
|
await loadCreatePdfTool();
|
|
const schema = PdfToolSchema;
|
|
expect(schema.type).toBe("object");
|
|
expect(schema.properties).toBeDefined();
|
|
const props = schema.properties as Record<string, { type?: string }>;
|
|
expect(props.prompt).toBeDefined();
|
|
expect(props.pdf).toBeDefined();
|
|
expect(props.pdfs).toBeDefined();
|
|
expect(props.pages).toBeDefined();
|
|
expect(props.model).toBeDefined();
|
|
expect(props.maxBytesMb).toBeDefined();
|
|
});
|
|
});
|