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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- MinerU gateways that return image URLs instead of base64: slide images taken from a PDF's figures were stored as a few bytes of garbage, because the parser wrapped each URL as a base64 data URL and the server decoded it. The parser now keeps the URL, and the server downloads the image when it stores the extraction (uploaded materials and the knowledge base) under the provider-result URL policy (HTTPS and public addresses only) with per-image, total-size and time limits. An image that cannot be downloaded is left out and counted (`imagesSkipped`) without failing the extraction. A stored extraction that still holds image URLs is not reused for a new upload of the same file, and a course generated from it downloads the images then. Base64 decoding of extracted images and provider data URLs now refuses a payload that is not base64.
- MinerU extracted images: a self-hosted MinerU that returns JPEG 2000 images (a boxed JP2 file or a raw codestream) as bare base64 now has them stored with the extraction instead of dropped as neither bytes nor a URL (the prebuilt image library cannot decode JPEG 2000, so knowledge-base images and vision input still leave them out), and each bare base64 image is now labelled with its real type (`image/jpeg`, `image/jp2`, ...) instead of always `image/png`. A ready extraction that left images out only because of a limit (the image count, the stored result's size, an image too large) is now reused for a new upload of the same file, because those images would be left out again; only an extraction with images that failed to download, answered something other than an image (often a temporary sign-in, firewall or rate-limit page), timed out or ran out of time is extracted again. Extractions stored by earlier versions are not reused when they left out any image, as before. The knowledge base keeps its cache entry in the same cases.
- Knowledge base: a MinerU gateway that hosts images names them by URL in its markdown (`![](https://…)`, `<img src="https://…">`). Those references now point to the stored figure, as `images/<file>` references already did, instead of the provider's link, which can expire. Other external image links in the text are left as written.
- Knowledge base: a PDF that a MinerU gateway refuses for its page count (`pdf_too_many_pages`) now shows "Too many pages" and explains it, with the page count and the limit when the gateway states them, instead of a generic parse failure. The refusal is not retried and not handed to another parser, and Parse again is no longer the first action for it. Other self-hosted MinerU errors now keep the service's message (truncated) in the stored diagnostic when the server manages the service; a caller-supplied service is still reported by status only, and error bodies are read only up to 64 KB.
- Knowledge base: a PDF that a MinerU gateway refuses for its page count (`pdf_too_many_pages`) now shows "Too many pages" and explains it, with the page count and the limit when the gateway states them, instead of a generic parse failure. The refusal is not retried and not handed to another parser, and Parse again is no longer the first action for it. Other self-hosted MinerU errors are reported by status only (`MinerU API error (500)`), whether the deployment or the caller configured the service: the parse response and stored extraction errors never include the service's response body, which goes to the server log only. Error bodies are read only up to 64 KB.

## [1.3.0-rc.1] - 2026-10-22

Expand Down
35 changes: 10 additions & 25 deletions lib/pdf/pdf-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,7 @@ export function getMinerUBackend(): string {
*
* Exported for unit testing.
*/
export function describeSelfHostedMinerUError(
status: number,
rawBody: string,
options: { quoteMessage?: boolean } = {},
): string {
export function describeSelfHostedMinerUError(status: number, rawBody: string): string {
const body = rawBody.toLowerCase();
const missingDependency =
body.includes('modulenotfounderror') ||
Expand All @@ -189,20 +185,15 @@ export function describeSelfHostedMinerUError(
);
}

// Unknown failure: report the status only. The body is logged by the caller
// and never echoed, so the parse route cannot relay a target's content.
// Only a server-managed service -- the operator's own -- has its message
// (truncated) kept, for the stored diagnostic of a failed extraction.
const base = `MinerU API error (${status})`;
if (!options.quoteMessage) return base;
const { code, message } = parseMinerUErrorBody(rawBody);
const detail = [code, message].filter(Boolean).join(': ');
return detail ? `${base}: ${detail.slice(0, MINERU_ERROR_MESSAGE_MAX_CHARS)}` : base;
// Unknown failure: report the status only, whoever configured the service.
// The thrown message reaches clients (the parse route's response, stored
// extraction errors), so the body -- which can name a server's paths or
// configuration -- is only written to the server log by the caller.
return `MinerU API error (${status})`;
}

/** How much of a failed response's body is read, and how much of its message is kept. */
/** How much of a failed response's body is read. */
const MINERU_ERROR_BODY_MAX_BYTES = 64 * 1024;
const MINERU_ERROR_MESSAGE_MAX_CHARS = 300;

/**
* A MinerU (or MinerU gateway) error body: an OpenAI-style
Expand Down Expand Up @@ -272,11 +263,7 @@ async function readBoundedErrorBody(response: Response): Promise<string> {
* with the counts it states: not retried, and no other provider is tried for
* the same file. Its message carries only those counts, never the body.
*/
export function selfHostedMinerUFailure(
status: number,
rawBody: string,
options: { managed?: boolean } = {},
): Error {
export function selfHostedMinerUFailure(status: number, rawBody: string): Error {
const { code, message } = parseMinerUErrorBody(rawBody);
if (code === 'pdf_too_many_pages') {
const { pages, maxPages } = pageLimitOf(message);
Expand All @@ -292,9 +279,7 @@ export function selfHostedMinerUFailure(
},
);
}
return new Error(
describeSelfHostedMinerUError(status, rawBody, { quoteMessage: options.managed }),
);
return new Error(describeSelfHostedMinerUError(status, rawBody));
}

// A caller-supplied self-hosted MinerU URL runs under the operator address
Expand Down Expand Up @@ -803,7 +788,7 @@ export async function parseWithMinerUDocument(
if (!response.ok) {
const errorText = await readBoundedErrorBody(response);
log.error(`[MinerU] file_parse failed [status=${response.status}]: ${errorText.slice(0, 500)}`);
throw selfHostedMinerUFailure(response.status, errorText, { managed: config.managed });
throw selfHostedMinerUFailure(response.status, errorText);
}

// A body that is not JSON must not surface the parser's message: Node's
Expand Down
21 changes: 20 additions & 1 deletion tests/agent-runtime/material-extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,6 @@ describe('document parsing service unavailable (session chain)', () => {
message: 'PDF has 461 pages, which exceeds the 200-page limit',
},
}),
{ managed: true },
);
}) as never,
};
Expand All @@ -496,6 +495,26 @@ describe('document parsing service unavailable (session chain)', () => {
expect(unpdf).not.toHaveBeenCalled();
});

it('keeps a MinerU error body out of the failure it stores and reports', async () => {
const failing = {
...documentService('mineru', ['application/pdf']),
extract: vi.fn(async () => {
throw selfHostedMinerUFailure(
500,
JSON.stringify({ detail: 'config not found at /srv/internal/mineru.yaml' }),
);
}) as never,
};
const plan = await planSourceExtraction(
{ bytes: Buffer.from('%PDF'), mime: 'application/pdf' },
'doc.pdf',
{ providers: () => [failing], configuredProviderIds: () => ['mineru'] },
);
const failure = await runSourceExtraction(plan, 'doc.pdf').catch((error) => error);
expect(failure.message).toContain('mineru: MinerU API error (500)');
expect(failure.message).not.toContain('/srv/internal');
});

it('reads whether a candidate is a service from its metadata, not its id', async () => {
const reasonFor = async (requiresServiceConfig: boolean) => {
const plan = await planSourceExtraction(
Expand Down
25 changes: 24 additions & 1 deletion tests/api/parse-pdf-self-hosted-mineru.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({
resolvePDFBaseUrl: vi.fn(),
promisesLookup: vi.fn(),
callbackLookup: vi.fn(),
logError: vi.fn(),
}));

vi.mock('@/lib/server/provider-config', async (importOriginal) => ({
Expand All @@ -49,7 +50,7 @@ vi.mock('node:dns', async (importOriginal) => {
});

vi.mock('@/lib/logger', () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: mocks.logError, debug: vi.fn() }),
}));

const CONNECTION_FAILED = {
Expand Down Expand Up @@ -256,6 +257,28 @@ describe('POST /api/parse-pdf with self-hosted MinerU', () => {
expect(clientTarget.requests()).toBe(0);
});

it('keeps a server-managed MinerU error body in the server log only', async () => {
const managed = await startLoopback((_req, res) => {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ detail: 'config not found at /srv/internal/mineru.yaml' }));
});
mocks.isServerConfiguredProvider.mockReturnValue(true);
mocks.resolvePDFBaseUrl.mockImplementation((_id: string, baseUrl?: string) =>
baseUrl === undefined ? managed.origin : baseUrl,
);

const res = await postParsePdf({ providerId: 'mineru' });

expect(res).toEqual({
status: 500,
json: { success: false, errorCode: 'PARSE_FAILED', error: 'MinerU API error (500)' },
});
expect(JSON.stringify(res.json)).not.toContain('/srv/internal');
expect(mocks.logError).toHaveBeenCalledWith(
expect.stringContaining('config not found at /srv/internal/mineru.yaml'),
);
});

it('still refuses cloud metadata as a server-managed base URL', async () => {
mocks.isServerConfiguredProvider.mockReturnValue(true);
mocks.resolvePDFBaseUrl.mockReturnValue('http://169.254.169.254/latest');
Expand Down
48 changes: 19 additions & 29 deletions tests/document/pdf-providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,19 @@ describe('a MinerU gateway refusal', () => {
});

it('turns pdf_too_many_pages into a too_many_pages refusal with its counts', () => {
for (const managed of [true, false]) {
const error = selfHostedMinerUFailure(422, GATEWAY_BODY, { managed });
expect(error).toBeInstanceOf(MaterialSourceRefusedError);
expect(error).toMatchObject({
reasonCode: 'too_many_pages',
reasonValues: { pages: 461, maxPages: 200 },
retryable: false,
});
expect(isTransientExtractionError(error)).toBe(false);
expect(error.message).toBe(
'MinerU refused the document: too many pages (status 422; 461 pages, limit 200 pages)',
);
// Only the counts, never the provider's words.
expect(error.message).not.toContain('Split the file');
}
const error = selfHostedMinerUFailure(422, GATEWAY_BODY);
expect(error).toBeInstanceOf(MaterialSourceRefusedError);
expect(error).toMatchObject({
reasonCode: 'too_many_pages',
reasonValues: { pages: 461, maxPages: 200 },
retryable: false,
});
expect(isTransientExtractionError(error)).toBe(false);
expect(error.message).toBe(
'MinerU refused the document: too many pages (status 422; 461 pages, limit 200 pages)',
);
// Only the counts, never the provider's words.
expect(error.message).not.toContain('Split the file');
});

it('keeps the refusal when the message states no counts', () => {
Expand All @@ -91,27 +89,19 @@ describe('a MinerU gateway refusal', () => {
expect(error.message).toBe('MinerU refused the document: too many pages (status 422)');
});

it('keeps a managed service message, truncated, for any other 4xx', () => {
it('never quotes the body of any other failure', () => {
const body = JSON.stringify({
error: { code: 'unsupported_file', message: `Encrypted PDF ${'x'.repeat(1000)}` },
error: { code: 'unsupported_file', message: 'Encrypted PDF at /srv/internal/mineru.yaml' },
});
const error = selfHostedMinerUFailure(415, body, { managed: true });
const error = selfHostedMinerUFailure(415, body);
expect(error).not.toBeInstanceOf(MaterialSourceRefusedError);
expect(
error.message.startsWith('MinerU API error (415): unsupported_file: Encrypted PDF'),
).toBe(true);
expect(error.message.length).toBeLessThan(350);
expect(error.message).toBe('MinerU API error (415)');
expect(isTransientExtractionError(error)).toBe(false);
expect(selfHostedMinerUFailure(400, 'plain refusal text', { managed: true }).message).toBe(
'MinerU API error (400): plain refusal text',
expect(selfHostedMinerUFailure(400, 'plain refusal text').message).toBe(
'MinerU API error (400)',
);
});

it('never quotes the body of a caller-supplied service', () => {
const body = JSON.stringify({ error: { code: 'x', message: 'internal-secret-body' } });
expect(selfHostedMinerUFailure(400, body).message).toBe('MinerU API error (400)');
});

it('parses OpenAI-style, FastAPI and plain-text bodies', () => {
expect(parseMinerUErrorBody(GATEWAY_BODY)).toMatchObject({ code: 'pdf_too_many_pages' });
expect(parseMinerUErrorBody('{"detail":"bad backend"}')).toEqual({ message: 'bad backend' });
Expand Down
1 change: 0 additions & 1 deletion tests/persistence/_owner-extraction-scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1837,7 +1837,6 @@ export async function tooManyPagesScenario(h: ExtractionHarness): Promise<void>
message: 'PDF has 461 pages, which exceeds the 200-page limit',
},
}),
{ managed: true },
);
});
const providers = [provider('mineru', pdf, refusal), provider('unpdf', pdf, h.documentExtract)];
Expand Down
Loading