@modelprofile.com/flexharness 8.1.0 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/flexharness',
6
- version: '8.1.0',
6
+ version: '8.2.0',
7
7
  description: 'The FlexHarness toolbox source repository and shared dependency catalog.'
8
8
  };
9
9
  //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSwrQkFBK0I7SUFDckMsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLDBFQUEwRTtDQUN4RixDQUFBIn0=
@@ -1,19 +1,66 @@
1
1
  import type { ModelMessage } from 'ai';
2
- import type { TSmartAiLanguageModel } from '@modelprofile.com/flexharness-models';
3
- export interface IDocumentOptions {
2
+ import type { TSmartAiLanguageModel, TSmartAiProviderOptions } from '@modelprofile.com/flexharness-models';
3
+ /** Options every document request accepts in addition to its model and messages. */
4
+ export interface IDocumentRequestOptions {
5
+ /** Passed to the model call unchanged, for example `{ openai: { store: false } }`. */
6
+ providerOptions?: TSmartAiProviderOptions;
7
+ /**
8
+ * Checked before each document is rendered and before the model call, and passed to the model
9
+ * call. SmartPDF cannot stop a document it is rendering, so that render finishes first.
10
+ */
11
+ abortSignal?: AbortSignal;
12
+ /**
13
+ * The most rendered pages one request may send to the model. A request with more pages rejects
14
+ * with `DocumentPageLimitError` before the model is called; documents after the one that
15
+ * exceeded the limit are not rendered.
16
+ */
17
+ maxPages?: number;
18
+ }
19
+ export interface IDocumentOptions extends IDocumentRequestOptions {
4
20
  model: TSmartAiLanguageModel;
5
21
  systemMessage?: string;
6
22
  userMessage: string;
7
23
  pdfDocuments: Uint8Array[];
8
24
  messageHistory?: ModelMessage[];
9
25
  }
10
- export interface IPdfTextExtractionOptions {
26
+ export interface IPdfTextExtractionOptions extends IDocumentRequestOptions {
11
27
  model: TSmartAiLanguageModel;
12
28
  systemMessage?: string;
13
29
  userMessage?: string;
14
30
  messageHistory?: ModelMessage[];
15
31
  }
32
+ /** Token usage of a document request, summed over its model steps; usage a provider leaves out counts as zero. */
33
+ export interface IDocumentUsage {
34
+ inputTokens: number;
35
+ outputTokens: number;
36
+ totalTokens: number;
37
+ cacheReadTokens: number;
38
+ cacheWriteTokens: number;
39
+ }
40
+ export interface IDocumentAnalysisResult {
41
+ text: string;
42
+ usage: IDocumentUsage;
43
+ /** Rendered pages sent to the model. A page that renders to an empty image is not sent. */
44
+ pageCount: number;
45
+ }
46
+ export interface IDocumentPageLimitErrorDetails {
47
+ renderedPages: number;
48
+ maxPages: number;
49
+ documentCount: number;
50
+ allDocumentsRendered: boolean;
51
+ }
52
+ /** A request whose documents render to more pages than its `maxPages`; the model was not called. */
53
+ export declare class DocumentPageLimitError extends Error {
54
+ /** Pages rendered before the request stopped; with several documents, later documents are not counted. */
55
+ readonly renderedPages: number;
56
+ readonly maxPages: number;
57
+ constructor(details: IDocumentPageLimitErrorDetails);
58
+ }
16
59
  /** The model-based PDF OCR recipe formerly provided by SmartOCR. */
17
60
  export declare function extractTextFromPdf(pdfDocument: Uint8Array, options: IPdfTextExtractionOptions): Promise<string>;
61
+ /** `extractTextFromPdf` that also returns the request's token usage and the pages sent. */
62
+ export declare function extractTextFromPdfWithUsage(pdfDocument: Uint8Array, options: IPdfTextExtractionOptions): Promise<IDocumentAnalysisResult>;
18
63
  export declare function analyzeDocuments(options: IDocumentOptions): Promise<string>;
64
+ /** `analyzeDocuments` that also returns the request's token usage and the pages sent. */
65
+ export declare function analyzeDocumentsWithUsage(options: IDocumentOptions): Promise<IDocumentAnalysisResult>;
19
66
  export declare function stopSmartpdf(): Promise<void>;
@@ -1,28 +1,81 @@
1
1
  import * as plugins from './plugins.js';
2
- let smartpdfInstance = null;
3
- async function ensureSmartpdf() {
4
- if (!smartpdfInstance) {
5
- smartpdfInstance = new plugins.smartpdf.SmartPdf();
6
- await smartpdfInstance.start();
2
+ /** The one start of the shared SmartPDF instance; every request awaits it, and a failed start is forgotten. */
3
+ let smartpdfStart = null;
4
+ function ensureSmartpdf() {
5
+ if (!smartpdfStart) {
6
+ const instance = new plugins.smartpdf.SmartPdf();
7
+ const start = instance.start().then(() => instance);
8
+ smartpdfStart = start;
9
+ start.catch(() => {
10
+ if (smartpdfStart === start) {
11
+ smartpdfStart = null;
12
+ }
13
+ });
7
14
  }
8
- return smartpdfInstance;
15
+ return smartpdfStart;
9
16
  }
17
+ /** A request whose documents render to more pages than its `maxPages`; the model was not called. */
18
+ export class DocumentPageLimitError extends Error {
19
+ /** Pages rendered before the request stopped; with several documents, later documents are not counted. */
20
+ renderedPages;
21
+ maxPages;
22
+ constructor(details) {
23
+ const subject = details.documentCount === 1
24
+ ? `The document has ${details.renderedPages} pages`
25
+ : details.allDocumentsRendered
26
+ ? `The documents have ${details.renderedPages} pages`
27
+ : `The documents have at least ${details.renderedPages} pages`;
28
+ super(`${subject}, more than the ${details.maxPages} allowed.`);
29
+ this.name = 'DocumentPageLimitError';
30
+ this.renderedPages = details.renderedPages;
31
+ this.maxPages = details.maxPages;
32
+ }
33
+ }
34
+ const DEFAULT_OCR_SYSTEM_MESSAGE = 'You are a precise OCR engine. Extract text faithfully and do not summarize.';
35
+ const DEFAULT_OCR_USER_MESSAGE = 'Extract all readable text from this PDF. Preserve page order, line breaks, table structure, and obvious headings. Return only the extracted text.';
10
36
  /** The model-based PDF OCR recipe formerly provided by SmartOCR. */
11
37
  export async function extractTextFromPdf(pdfDocument, options) {
12
- return analyzeDocuments({
38
+ return (await extractTextFromPdfWithUsage(pdfDocument, options)).text;
39
+ }
40
+ /** `extractTextFromPdf` that also returns the request's token usage and the pages sent. */
41
+ export async function extractTextFromPdfWithUsage(pdfDocument, options) {
42
+ return analyzeDocumentsWithUsage({
13
43
  model: options.model,
14
- systemMessage: options.systemMessage ?? 'You are a precise OCR engine. Extract text faithfully and do not summarize.',
15
- userMessage: options.userMessage ?? 'Extract all readable text from this PDF. Preserve page order, line breaks, table structure, and obvious headings. Return only the extracted text.',
44
+ systemMessage: options.systemMessage ?? DEFAULT_OCR_SYSTEM_MESSAGE,
45
+ userMessage: options.userMessage ?? DEFAULT_OCR_USER_MESSAGE,
16
46
  pdfDocuments: [pdfDocument],
17
47
  messageHistory: options.messageHistory,
48
+ providerOptions: options.providerOptions,
49
+ abortSignal: options.abortSignal,
50
+ maxPages: options.maxPages,
18
51
  });
19
52
  }
20
53
  export async function analyzeDocuments(options) {
21
- const pdf = await ensureSmartpdf();
54
+ return (await analyzeDocumentsWithUsage(options)).text;
55
+ }
56
+ /** `analyzeDocuments` that also returns the request's token usage and the pages sent. */
57
+ export async function analyzeDocumentsWithUsage(options) {
58
+ const maxPages = options.maxPages;
59
+ if (maxPages !== undefined && (!Number.isSafeInteger(maxPages) || maxPages < 1)) {
60
+ throw new RangeError('maxPages must be a positive whole number.');
61
+ }
62
+ const abortSignal = options.abortSignal;
63
+ abortSignal?.throwIfAborted();
22
64
  const imagePages = [];
23
- for (const doc of options.pdfDocuments) {
65
+ for (const [index, doc] of options.pdfDocuments.entries()) {
66
+ const pdf = await ensureSmartpdf();
67
+ abortSignal?.throwIfAborted();
24
68
  const pages = await pdf.convertPDFToPngBytes(doc);
69
+ abortSignal?.throwIfAborted();
25
70
  imagePages.push(...pages);
71
+ if (maxPages !== undefined && imagePages.length > maxPages) {
72
+ throw new DocumentPageLimitError({
73
+ renderedPages: imagePages.length,
74
+ maxPages,
75
+ documentCount: options.pdfDocuments.length,
76
+ allDocumentsRendered: index === options.pdfDocuments.length - 1,
77
+ });
78
+ }
26
79
  }
27
80
  // Filter out empty buffers
28
81
  const validPages = imagePages.filter(page => page && page.length > 0);
@@ -43,13 +96,40 @@ export async function analyzeDocuments(options) {
43
96
  ],
44
97
  },
45
98
  ],
99
+ providerOptions: options.providerOptions,
100
+ abortSignal,
46
101
  });
47
- return result.text;
102
+ const usage = result.totalUsage;
103
+ const inputTokens = usage.inputTokens ?? 0;
104
+ const outputTokens = usage.outputTokens ?? 0;
105
+ return {
106
+ text: result.text,
107
+ usage: {
108
+ inputTokens,
109
+ outputTokens,
110
+ totalTokens: inputTokens + outputTokens,
111
+ cacheReadTokens: usage.inputTokenDetails.cacheReadTokens ?? 0,
112
+ cacheWriteTokens: usage.inputTokenDetails.cacheWriteTokens ?? 0,
113
+ },
114
+ pageCount: validPages.length,
115
+ };
48
116
  }
49
117
  export async function stopSmartpdf() {
50
- if (smartpdfInstance) {
51
- await smartpdfInstance.stop();
52
- smartpdfInstance = null;
118
+ const start = smartpdfStart;
119
+ if (!start) {
120
+ return;
121
+ }
122
+ // forget the instance before waiting on anything, so a request made from now on starts a fresh one
123
+ // instead of receiving the instance that is being stopped
124
+ smartpdfStart = null;
125
+ let instance;
126
+ try {
127
+ instance = await start;
128
+ }
129
+ catch {
130
+ // a failed start left no started instance that could be stopped
131
+ return;
53
132
  }
133
+ await instance.stop();
54
134
  }
55
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90c19kb2N1bWVudC9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUssT0FBTyxNQUFNLGNBQWMsQ0FBQztBQUl4QyxJQUFJLGdCQUFnQixHQUEwRCxJQUFJLENBQUM7QUFFbkYsS0FBSyxVQUFVLGNBQWM7SUFDM0IsSUFBSSxDQUFDLGdCQUFnQixFQUFFLENBQUM7UUFDdEIsZ0JBQWdCLEdBQUcsSUFBSSxPQUFPLENBQUMsUUFBUSxDQUFDLFFBQVEsRUFBRSxDQUFDO1FBQ25ELE1BQU0sZ0JBQWdCLENBQUMsS0FBSyxFQUFFLENBQUM7SUFDakMsQ0FBQztJQUNELE9BQU8sZ0JBQWdCLENBQUM7QUFDMUIsQ0FBQztBQWlCRCxvRUFBb0U7QUFDcEUsTUFBTSxDQUFDLEtBQUssVUFBVSxrQkFBa0IsQ0FDdEMsV0FBdUIsRUFDdkIsT0FBa0M7SUFFbEMsT0FBTyxnQkFBZ0IsQ0FBQztRQUN0QixLQUFLLEVBQUUsT0FBTyxDQUFDLEtBQUs7UUFDcEIsYUFBYSxFQUFFLE9BQU8sQ0FBQyxhQUFhLElBQUksNkVBQTZFO1FBQ3JILFdBQVcsRUFBRSxPQUFPLENBQUMsV0FBVyxJQUFJLG1KQUFtSjtRQUN2TCxZQUFZLEVBQUUsQ0FBQyxXQUFXLENBQUM7UUFDM0IsY0FBYyxFQUFFLE9BQU8sQ0FBQyxjQUFjO0tBQ3ZDLENBQUMsQ0FBQztBQUNMLENBQUM7QUFFRCxNQUFNLENBQUMsS0FBSyxVQUFVLGdCQUFnQixDQUFDLE9BQXlCO0lBQzlELE1BQU0sR0FBRyxHQUFHLE1BQU0sY0FBYyxFQUFFLENBQUM7SUFFbkMsTUFBTSxVQUFVLEdBQWlCLEVBQUUsQ0FBQztJQUNwQyxLQUFLLE1BQU0sR0FBRyxJQUFJLE9BQU8sQ0FBQyxZQUFZLEVBQUUsQ0FBQztRQUN2QyxNQUFNLEtBQUssR0FBRyxNQUFNLEdBQUcsQ0FBQyxvQkFBb0IsQ0FBQyxHQUFHLENBQUMsQ0FBQztRQUNsRCxVQUFVLENBQUMsSUFBSSxDQUFDLEdBQUcsS0FBSyxDQUFDLENBQUM7SUFDNUIsQ0FBQztJQUVELDJCQUEyQjtJQUMzQixNQUFNLFVBQVUsR0FBRyxVQUFVLENBQUMsTUFBTSxDQUFDLElBQUksQ0FBQyxFQUFFLENBQUMsSUFBSSxJQUFJLElBQUksQ0FBQyxNQUFNLEdBQUcsQ0FBQyxDQUFDLENBQUM7SUFFdEUsTUFBTSxNQUFNLEdBQUcsTUFBTSxPQUFPLENBQUMsWUFBWSxDQUFDO1FBQ3hDLEtBQUssRUFBRSxPQUFPLENBQUMsS0FBSztRQUNwQixZQUFZLEVBQUUsT0FBTyxDQUFDLGFBQWE7UUFDbkMsUUFBUSxFQUFFO1lBQ1IsR0FBRyxDQUFDLE9BQU8sQ0FBQyxjQUFjLElBQUksRUFBRSxDQUFDO1lBQ2pDO2dCQUNFLElBQUksRUFBRSxNQUFNO2dCQUNaLE9BQU8sRUFBRTtvQkFDUCxFQUFFLElBQUksRUFBRSxNQUFNLEVBQUUsSUFBSSxFQUFFLE9BQU8sQ0FBQyxXQUFXLEVBQUU7b0JBQzNDLEdBQUcsVUFBVSxDQUFDLEdBQUcsQ0FBQyxJQUFJLENBQUMsRUFBRSxDQUFDLENBQUM7d0JBQ3pCLElBQUksRUFBRSxNQUFlO3dCQUNyQixJQUFJLEVBQUUsSUFBSTt3QkFDVixTQUFTLEVBQUUsV0FBb0I7cUJBQ2hDLENBQUMsQ0FBQztpQkFDSjthQUNGO1NBQ0Y7S0FDRixDQUFDLENBQUM7SUFDSCxPQUFPLE1BQU0sQ0FBQyxJQUFJLENBQUM7QUFDckIsQ0FBQztBQUVELE1BQU0sQ0FBQyxLQUFLLFVBQVUsWUFBWTtJQUNoQyxJQUFJLGdCQUFnQixFQUFFLENBQUM7UUFDckIsTUFBTSxnQkFBZ0IsQ0FBQyxJQUFJLEVBQUUsQ0FBQztRQUM5QixnQkFBZ0IsR0FBRyxJQUFJLENBQUM7SUFDMUIsQ0FBQztBQUNILENBQUMifQ==
135
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90c19kb2N1bWVudC9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUssT0FBTyxNQUFNLGNBQWMsQ0FBQztBQU14QywrR0FBK0c7QUFDL0csSUFBSSxhQUFhLEdBQThCLElBQUksQ0FBQztBQUVwRCxTQUFTLGNBQWM7SUFDckIsSUFBSSxDQUFDLGFBQWEsRUFBRSxDQUFDO1FBQ25CLE1BQU0sUUFBUSxHQUFHLElBQUksT0FBTyxDQUFDLFFBQVEsQ0FBQyxRQUFRLEVBQUUsQ0FBQztRQUNqRCxNQUFNLEtBQUssR0FBRyxRQUFRLENBQUMsS0FBSyxFQUFFLENBQUMsSUFBSSxDQUFDLEdBQUcsRUFBRSxDQUFDLFFBQVEsQ0FBQyxDQUFDO1FBQ3BELGFBQWEsR0FBRyxLQUFLLENBQUM7UUFDdEIsS0FBSyxDQUFDLEtBQUssQ0FBQyxHQUFHLEVBQUU7WUFDZixJQUFJLGFBQWEsS0FBSyxLQUFLLEVBQUUsQ0FBQztnQkFDNUIsYUFBYSxHQUFHLElBQUksQ0FBQztZQUN2QixDQUFDO1FBQ0gsQ0FBQyxDQUFDLENBQUM7SUFDTCxDQUFDO0lBQ0QsT0FBTyxhQUFhLENBQUM7QUFDdkIsQ0FBQztBQXlERCxvR0FBb0c7QUFDcEcsTUFBTSxPQUFPLHNCQUF1QixTQUFRLEtBQUs7SUFDL0MsMEdBQTBHO0lBQzFGLGFBQWEsQ0FBUztJQUN0QixRQUFRLENBQVM7SUFFakMsWUFBWSxPQUF1QztRQUNqRCxNQUFNLE9BQU8sR0FBRyxPQUFPLENBQUMsYUFBYSxLQUFLLENBQUM7WUFDekMsQ0FBQyxDQUFDLG9CQUFvQixPQUFPLENBQUMsYUFBYSxRQUFRO1lBQ25ELENBQUMsQ0FBQyxPQUFPLENBQUMsb0JBQW9CO2dCQUM1QixDQUFDLENBQUMsc0JBQXNCLE9BQU8sQ0FBQyxhQUFhLFFBQVE7Z0JBQ3JELENBQUMsQ0FBQywrQkFBK0IsT0FBTyxDQUFDLGFBQWEsUUFBUSxDQUFD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
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "author": "Task Venture Capital GmbH",
3
3
  "license": "MIT",
4
4
  "name": "@modelprofile.com/flexharness",
5
- "version": "8.1.0",
5
+ "version": "8.2.0",
6
6
  "type": "module",
7
7
  "description": "Managed agent sessions with tools, MCP, chat, OCR, documents and browser control through focused subpath imports.",
8
8
  "main": "./dist_ts/index.js",
@@ -62,8 +62,8 @@
62
62
  }
63
63
  },
64
64
  "dependencies": {
65
- "@modelprofile.com/flexharness-agent": "8.1.0",
66
- "@modelprofile.com/flexharness-models": "8.1.0",
65
+ "@modelprofile.com/flexharness-agent": "8.2.0",
66
+ "@modelprofile.com/flexharness-models": "8.2.0",
67
67
  "@push.rocks/smartrequest": "^5.0.1",
68
68
  "ai": "^7.0.79",
69
69
  "zod": "^4.4.1"
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/flexharness',
6
- version: '8.1.0',
6
+ version: '8.2.0',
7
7
  description: 'The FlexHarness toolbox source repository and shared dependency catalog.'
8
8
  }
@@ -1,18 +1,44 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { ModelMessage } from 'ai';
3
- import type { TSmartAiLanguageModel } from '@modelprofile.com/flexharness-models';
3
+ import type { TSmartAiLanguageModel, TSmartAiProviderOptions } from '@modelprofile.com/flexharness-models';
4
4
 
5
- let smartpdfInstance: InstanceType<typeof plugins.smartpdf.SmartPdf> | null = null;
5
+ type TSmartPdf = InstanceType<typeof plugins.smartpdf.SmartPdf>;
6
6
 
7
- async function ensureSmartpdf(): Promise<InstanceType<typeof plugins.smartpdf.SmartPdf>> {
8
- if (!smartpdfInstance) {
9
- smartpdfInstance = new plugins.smartpdf.SmartPdf();
10
- await smartpdfInstance.start();
7
+ /** The one start of the shared SmartPDF instance; every request awaits it, and a failed start is forgotten. */
8
+ let smartpdfStart: Promise<TSmartPdf> | null = null;
9
+
10
+ function ensureSmartpdf(): Promise<TSmartPdf> {
11
+ if (!smartpdfStart) {
12
+ const instance = new plugins.smartpdf.SmartPdf();
13
+ const start = instance.start().then(() => instance);
14
+ smartpdfStart = start;
15
+ start.catch(() => {
16
+ if (smartpdfStart === start) {
17
+ smartpdfStart = null;
18
+ }
19
+ });
11
20
  }
12
- return smartpdfInstance;
21
+ return smartpdfStart;
22
+ }
23
+
24
+ /** Options every document request accepts in addition to its model and messages. */
25
+ export interface IDocumentRequestOptions {
26
+ /** Passed to the model call unchanged, for example `{ openai: { store: false } }`. */
27
+ providerOptions?: TSmartAiProviderOptions;
28
+ /**
29
+ * Checked before each document is rendered and before the model call, and passed to the model
30
+ * call. SmartPDF cannot stop a document it is rendering, so that render finishes first.
31
+ */
32
+ abortSignal?: AbortSignal;
33
+ /**
34
+ * The most rendered pages one request may send to the model. A request with more pages rejects
35
+ * with `DocumentPageLimitError` before the model is called; documents after the one that
36
+ * exceeded the limit are not rendered.
37
+ */
38
+ maxPages?: number;
13
39
  }
14
40
 
15
- export interface IDocumentOptions {
41
+ export interface IDocumentOptions extends IDocumentRequestOptions {
16
42
  model: TSmartAiLanguageModel;
17
43
  systemMessage?: string;
18
44
  userMessage: string;
@@ -20,34 +46,111 @@ export interface IDocumentOptions {
20
46
  messageHistory?: ModelMessage[];
21
47
  }
22
48
 
23
- export interface IPdfTextExtractionOptions {
49
+ export interface IPdfTextExtractionOptions extends IDocumentRequestOptions {
24
50
  model: TSmartAiLanguageModel;
25
51
  systemMessage?: string;
26
52
  userMessage?: string;
27
53
  messageHistory?: ModelMessage[];
28
54
  }
29
55
 
56
+ /** Token usage of a document request, summed over its model steps; usage a provider leaves out counts as zero. */
57
+ export interface IDocumentUsage {
58
+ inputTokens: number;
59
+ outputTokens: number;
60
+ totalTokens: number;
61
+ cacheReadTokens: number;
62
+ cacheWriteTokens: number;
63
+ }
64
+
65
+ export interface IDocumentAnalysisResult {
66
+ text: string;
67
+ usage: IDocumentUsage;
68
+ /** Rendered pages sent to the model. A page that renders to an empty image is not sent. */
69
+ pageCount: number;
70
+ }
71
+
72
+ export interface IDocumentPageLimitErrorDetails {
73
+ renderedPages: number;
74
+ maxPages: number;
75
+ documentCount: number;
76
+ allDocumentsRendered: boolean;
77
+ }
78
+
79
+ /** A request whose documents render to more pages than its `maxPages`; the model was not called. */
80
+ export class DocumentPageLimitError extends Error {
81
+ /** Pages rendered before the request stopped; with several documents, later documents are not counted. */
82
+ public readonly renderedPages: number;
83
+ public readonly maxPages: number;
84
+
85
+ constructor(details: IDocumentPageLimitErrorDetails) {
86
+ const subject = details.documentCount === 1
87
+ ? `The document has ${details.renderedPages} pages`
88
+ : details.allDocumentsRendered
89
+ ? `The documents have ${details.renderedPages} pages`
90
+ : `The documents have at least ${details.renderedPages} pages`;
91
+ super(`${subject}, more than the ${details.maxPages} allowed.`);
92
+ this.name = 'DocumentPageLimitError';
93
+ this.renderedPages = details.renderedPages;
94
+ this.maxPages = details.maxPages;
95
+ }
96
+ }
97
+
98
+ const DEFAULT_OCR_SYSTEM_MESSAGE = 'You are a precise OCR engine. Extract text faithfully and do not summarize.';
99
+ const DEFAULT_OCR_USER_MESSAGE = 'Extract all readable text from this PDF. Preserve page order, line breaks, table structure, and obvious headings. Return only the extracted text.';
100
+
30
101
  /** The model-based PDF OCR recipe formerly provided by SmartOCR. */
31
102
  export async function extractTextFromPdf(
32
103
  pdfDocument: Uint8Array,
33
104
  options: IPdfTextExtractionOptions,
34
105
  ): Promise<string> {
35
- return analyzeDocuments({
106
+ return (await extractTextFromPdfWithUsage(pdfDocument, options)).text;
107
+ }
108
+
109
+ /** `extractTextFromPdf` that also returns the request's token usage and the pages sent. */
110
+ export async function extractTextFromPdfWithUsage(
111
+ pdfDocument: Uint8Array,
112
+ options: IPdfTextExtractionOptions,
113
+ ): Promise<IDocumentAnalysisResult> {
114
+ return analyzeDocumentsWithUsage({
36
115
  model: options.model,
37
- systemMessage: options.systemMessage ?? 'You are a precise OCR engine. Extract text faithfully and do not summarize.',
38
- userMessage: options.userMessage ?? 'Extract all readable text from this PDF. Preserve page order, line breaks, table structure, and obvious headings. Return only the extracted text.',
116
+ systemMessage: options.systemMessage ?? DEFAULT_OCR_SYSTEM_MESSAGE,
117
+ userMessage: options.userMessage ?? DEFAULT_OCR_USER_MESSAGE,
39
118
  pdfDocuments: [pdfDocument],
40
119
  messageHistory: options.messageHistory,
120
+ providerOptions: options.providerOptions,
121
+ abortSignal: options.abortSignal,
122
+ maxPages: options.maxPages,
41
123
  });
42
124
  }
43
125
 
44
126
  export async function analyzeDocuments(options: IDocumentOptions): Promise<string> {
45
- const pdf = await ensureSmartpdf();
127
+ return (await analyzeDocumentsWithUsage(options)).text;
128
+ }
129
+
130
+ /** `analyzeDocuments` that also returns the request's token usage and the pages sent. */
131
+ export async function analyzeDocumentsWithUsage(options: IDocumentOptions): Promise<IDocumentAnalysisResult> {
132
+ const maxPages = options.maxPages;
133
+ if (maxPages !== undefined && (!Number.isSafeInteger(maxPages) || maxPages < 1)) {
134
+ throw new RangeError('maxPages must be a positive whole number.');
135
+ }
136
+ const abortSignal = options.abortSignal;
137
+ abortSignal?.throwIfAborted();
46
138
 
47
139
  const imagePages: Uint8Array[] = [];
48
- for (const doc of options.pdfDocuments) {
140
+ for (const [index, doc] of options.pdfDocuments.entries()) {
141
+ const pdf = await ensureSmartpdf();
142
+ abortSignal?.throwIfAborted();
49
143
  const pages = await pdf.convertPDFToPngBytes(doc);
144
+ abortSignal?.throwIfAborted();
50
145
  imagePages.push(...pages);
146
+ if (maxPages !== undefined && imagePages.length > maxPages) {
147
+ throw new DocumentPageLimitError({
148
+ renderedPages: imagePages.length,
149
+ maxPages,
150
+ documentCount: options.pdfDocuments.length,
151
+ allDocumentsRendered: index === options.pdfDocuments.length - 1,
152
+ });
153
+ }
51
154
  }
52
155
 
53
156
  // Filter out empty buffers
@@ -70,13 +173,39 @@ export async function analyzeDocuments(options: IDocumentOptions): Promise<strin
70
173
  ],
71
174
  },
72
175
  ],
176
+ providerOptions: options.providerOptions,
177
+ abortSignal,
73
178
  });
74
- return result.text;
179
+ const usage = result.totalUsage;
180
+ const inputTokens = usage.inputTokens ?? 0;
181
+ const outputTokens = usage.outputTokens ?? 0;
182
+ return {
183
+ text: result.text,
184
+ usage: {
185
+ inputTokens,
186
+ outputTokens,
187
+ totalTokens: inputTokens + outputTokens,
188
+ cacheReadTokens: usage.inputTokenDetails.cacheReadTokens ?? 0,
189
+ cacheWriteTokens: usage.inputTokenDetails.cacheWriteTokens ?? 0,
190
+ },
191
+ pageCount: validPages.length,
192
+ };
75
193
  }
76
194
 
77
195
  export async function stopSmartpdf(): Promise<void> {
78
- if (smartpdfInstance) {
79
- await smartpdfInstance.stop();
80
- smartpdfInstance = null;
196
+ const start = smartpdfStart;
197
+ if (!start) {
198
+ return;
199
+ }
200
+ // forget the instance before waiting on anything, so a request made from now on starts a fresh one
201
+ // instead of receiving the instance that is being stopped
202
+ smartpdfStart = null;
203
+ let instance: TSmartPdf;
204
+ try {
205
+ instance = await start;
206
+ } catch {
207
+ // a failed start left no started instance that could be stopped
208
+ return;
81
209
  }
210
+ await instance.stop();
82
211
  }
@@ -23,6 +23,32 @@ Supply a model from your selected FlexHarness provider. `extractTextFromPdf` rep
23
23
 
24
24
  `analyzeDocuments({ model, userMessage, pdfDocuments, systemMessage?, messageHistory? })` supports general analysis across multiple PDFs. This component owns a shared SmartPDF instance; call `stopSmartpdf()` when all document work has finished. Image-only OCR is available independently in `flexharness/ocr`.
25
25
 
26
+ Both functions accept three request options:
27
+
28
+ - `providerOptions` is passed to the model call unchanged, for example `{ openai: { store: false } }` so the provider does not keep the request as application state.
29
+ - `abortSignal` is checked before each document is rendered and before the model call, and is passed to the model call. SmartPDF cannot stop a document it is already rendering, so that render finishes first.
30
+ - `maxPages` is the most rendered pages one request may send to the model. A request with more pages rejects with `DocumentPageLimitError` (`renderedPages`, `maxPages`) before the model is called, and documents after the one that exceeded the limit are not rendered.
31
+
32
+ `extractTextFromPdfWithUsage` and `analyzeDocumentsWithUsage` take the same arguments and return `{ text, usage, pageCount }`. `usage` has the shape of an agent run's usage (`inputTokens`, `outputTokens`, `totalTokens`, `cacheReadTokens`, `cacheWriteTokens`), summed over the request's model steps, with usage the provider leaves out counted as zero; `pageCount` is the number of rendered pages sent to the model.
33
+
34
+ ```typescript
35
+ import { extractTextFromPdfWithUsage, DocumentPageLimitError } from '@modelprofile.com/flexharness/documents';
36
+
37
+ try {
38
+ const { text, usage, pageCount } = await extractTextFromPdfWithUsage(pdfBytes, {
39
+ model,
40
+ providerOptions: { openai: { store: false } },
41
+ abortSignal,
42
+ maxPages: 20,
43
+ });
44
+ } catch (error) {
45
+ if (error instanceof DocumentPageLimitError) {
46
+ console.log(`${error.renderedPages} pages, at most ${error.maxPages}`);
47
+ }
48
+ throw error;
49
+ }
50
+ ```
51
+
26
52
  ## Issue Reporting and Security
27
53
 
28
54
  Report issues and security concerns through [community.foss.global](https://community.foss.global).