@modelprofile.com/flexharness 9.7.0 → 9.9.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.
package/ts/interfaces.ts CHANGED
@@ -532,9 +532,24 @@ export interface IFlexPermissionRequest
532
532
  sessionId: string;
533
533
  runId: string;
534
534
  createdAt: string;
535
+ /** A bound policy requires an explicit response and disallows remembered approval. */
536
+ requiresExplicitResponse?: true;
535
537
  }
536
538
 
537
539
  export type TFlexPermissionDecision = 'once' | 'always' | 'reject';
540
+ export type TFlexSessionPermissionPolicyDecision = 'inherit' | 'allow' | 'ask' | 'deny';
541
+
542
+ export interface IFlexSessionPermissionPolicyContext<TScope> extends IFlexSessionGeneration {
543
+ readonly scopeId: string;
544
+ readonly scope: TScope;
545
+ readonly storageKey: string;
546
+ readonly sessionId: string;
547
+ readonly runId: string;
548
+ readonly configurationRef: string;
549
+ readonly agent?: string;
550
+ readonly request: Readonly<IFlexPermissionRequest>;
551
+ readonly signal: AbortSignal;
552
+ }
538
553
 
539
554
  export interface IFlexToolProviderContext<TScope> extends IFlexSessionGeneration {
540
555
  scopeId: string;
@@ -753,6 +768,12 @@ export interface IFlexSessionConfigurationResolverContext<TScope> extends IFlexS
753
768
  export interface IFlexResolvedSessionConfiguration<TScope> {
754
769
  subagents?: readonly IFlexSubagentDefinition[];
755
770
  slashCommands?: readonly TFlexSlashCommandRegistration<TScope>[];
771
+ /** Exact model-facing names permitted for this bound session; omitted means unrestricted. */
772
+ allowedTools?: readonly string[];
773
+ /** Evaluated for each request before a remembered grant can be reused. */
774
+ permissionPolicy?: (
775
+ context: IFlexSessionPermissionPolicyContext<TScope>,
776
+ ) => TFlexSessionPermissionPolicyDecision | Promise<TFlexSessionPermissionPolicyDecision>;
756
777
  }
757
778
 
758
779
  export interface IFlexSessionConfigurationResolver<TScope> {
@@ -1138,6 +1159,7 @@ export interface IFlexRedoSessionResult {
1138
1159
  export type TFlexExternalErrorSource =
1139
1160
  | 'modelResolver'
1140
1161
  | 'sessionConfigurationResolver'
1162
+ | 'permissionPolicy'
1141
1163
  | 'toolProvider'
1142
1164
  | 'delegatedRunAdmissionProvider'
1143
1165
  | 'toolExecution'
@@ -1268,6 +1290,8 @@ export interface IFlexSubagentDefinition {
1268
1290
  modelHint?: string;
1269
1291
  system?: string;
1270
1292
  maxSteps?: number;
1293
+ /** Exact model-facing names permitted for this agent; omitted means unrestricted. */
1294
+ allowedTools?: readonly string[];
1271
1295
  }
1272
1296
 
1273
1297
  export interface IFlexProjectManagementBuiltInToolsOptions {
@@ -1420,6 +1444,8 @@ export interface IFlexPermissionResolvedEvent extends IFlexEventBase {
1420
1444
  readonly runId: string;
1421
1445
  readonly request: IFlexPermissionRequest;
1422
1446
  readonly decision: TFlexPermissionDecision;
1447
+ /** Present when a bound policy resolved the request without making it pending. */
1448
+ readonly origin?: 'policy';
1423
1449
  }
1424
1450
 
1425
1451
  export interface IFlexRunFinishedEvent extends IFlexEventBase {
package/ts/readme.md CHANGED
@@ -62,7 +62,7 @@ or provider SDKs.
62
62
  | `/chat/cli` | Ink/React terminal chat | `ink ink-text-input react`; TypeScript: `@types/react` |
63
63
  | `/chat/web` | Lit `flexchat-window`, `flexchat-message`, `flexchat-input` | `lit` |
64
64
  | `/kvm` | Browser control, terminal framing and OCR observation | `puppeteer` |
65
- | `/documents` | PDF processing and `extractTextFromPdf` | `@push.rocks/smartpdf` |
65
+ | `/documents` | PDF and image documents, `extractTextFromPdf` | `@push.rocks/smartpdf`; for image documents `sharp` |
66
66
  | `/mcp` | MCP clients and AI SDK tool conversion | `@modelcontextprotocol/sdk` |
67
67
  | `/migration` | Existing versioned harness migrations | None |
68
68
 
@@ -54,12 +54,39 @@ export const maxSubagentNameBytes = 128;
54
54
  const maxSubagentDescriptionBytes = 2048;
55
55
  const maxSubagentModelHintBytes = 512;
56
56
  const maxSubagentSystemBytes = 64 * 1024;
57
+ const maxAllowedToolNames = 128;
58
+ const maxAllowedToolNameBytes = 512;
59
+ const toolNameControlPattern = /[\u0000-\u001f\u007f-\u009f]/u;
60
+ const toolNameUnpairedSurrogatePattern = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u;
57
61
  const maxSlashCommandRegistrations = 128;
58
62
  const maxSlashCommandDescriptionBytes = 2048;
59
63
  const maxSlashCommandTemplateBytes = FLEX_SLASH_COMMAND_MAX_INPUT_BYTES;
60
64
  const defaultReversionMaintenanceTimeoutMs = 30_000;
61
65
  const maximumReversionMaintenanceTimeoutMs = 30 * 60 * 1000;
62
66
 
67
+ export function normalizeAllowedTools(
68
+ names: readonly string[] | undefined,
69
+ label = 'allowedTools',
70
+ ): readonly string[] | undefined {
71
+ if (names === undefined) return undefined;
72
+ if (!Array.isArray(names) || names.length > maxAllowedToolNames) {
73
+ throw new FlexHarnessValidationError(`${label} must be an array with at most ${maxAllowedToolNames} names.`);
74
+ }
75
+ const seen = new Set<string>();
76
+ const normalized: string[] = [];
77
+ for (let index = 0; index < names.length; index++) {
78
+ const name = names[index];
79
+ validateUtf8String(name, `${label}[${index}]`, maxAllowedToolNameBytes, true);
80
+ if (toolNameControlPattern.test(name) || toolNameUnpairedSurrogatePattern.test(name)) {
81
+ throw new FlexHarnessValidationError(`${label}[${index}] must be a well-formed name without control characters.`);
82
+ }
83
+ if (seen.has(name)) throw new FlexHarnessValidationError(`${label} contains duplicate name "${name}".`);
84
+ seen.add(name);
85
+ normalized.push(name);
86
+ }
87
+ return Object.freeze(normalized);
88
+ }
89
+
63
90
  export function normalizeSubagents(
64
91
  definitions: readonly IFlexSubagentDefinition[] | undefined,
65
92
  ): ReadonlyMap<string, Readonly<IFlexSubagentDefinition>> {
@@ -81,7 +108,7 @@ export function normalizeSubagents(
81
108
  throw new FlexHarnessValidationError(`subagents[${index}] must be a plain object.`);
82
109
  }
83
110
  const unsupported = Object.keys(definition).find(
84
- (key) => !['name', 'description', 'modelHint', 'system', 'maxSteps'].includes(key),
111
+ (key) => !['name', 'description', 'modelHint', 'system', 'maxSteps', 'allowedTools'].includes(key),
85
112
  );
86
113
  if (unsupported) {
87
114
  throw new FlexHarnessValidationError(`subagents[${index}] does not support "${unsupported}".`);
@@ -121,6 +148,9 @@ export function normalizeSubagents(
121
148
  ...(definition.modelHint === undefined ? {} : { modelHint: definition.modelHint }),
122
149
  ...(definition.system === undefined ? {} : { system: definition.system }),
123
150
  ...(definition.maxSteps === undefined ? {} : { maxSteps: definition.maxSteps }),
151
+ ...(definition.allowedTools === undefined ? {} : {
152
+ allowedTools: normalizeAllowedTools(definition.allowedTools, `subagents[${index}].allowedTools`),
153
+ }),
124
154
  }));
125
155
  }
126
156
  return Object.freeze(normalized);
@@ -38,6 +38,11 @@ export interface IDocumentRequestOptions {
38
38
  * refused.
39
39
  */
40
40
  maxPages?: number;
41
+ /**
42
+ * The longest edge, in pixels, of an image document as it is sent to the model; a larger image is
43
+ * scaled down to it. Defaults to `DEFAULT_MAX_IMAGE_EDGE`.
44
+ */
45
+ maxImageEdge?: number;
41
46
  /**
42
47
  * Called once for every model call of the request, as soon as its usage is known, whatever the
43
48
  * request's outcome: `reported` with the provider's usage, or `unreported` with the reason when
@@ -52,7 +57,19 @@ export interface IDocumentOptions extends IDocumentRequestOptions {
52
57
  model: plugins.TSmartAiLanguageModel;
53
58
  systemMessage?: string;
54
59
  userMessage: string;
55
- pdfDocuments: Uint8Array[];
60
+ /** PDFs, rendered page by page. The model sees their pages first, in the order given. */
61
+ pdfDocuments?: Uint8Array[];
62
+ /**
63
+ * Images, after the PDFs' pages, in the order given: JPEG, PNG, WebP, GIF, TIFF (every page) and
64
+ * AVIF. A JPEG, PNG or WebP within `maxImageEdge` and `MAX_IMAGE_BYTES` that needs no turning is
65
+ * sent as it is. Any other is turned upright by its EXIF orientation, scaled down to fit
66
+ * `maxImageEdge` and sent as JPEG (from a JPEG or AVIF, transparency on white) or PNG, for this
67
+ * request only. Each image and each TIFF page is a page of the request. Refused with
68
+ * `DocumentFormatError` before the model is called: an image that cannot be read, a format outside
69
+ * these, HEIC (not decodable here), a GIF or WebP of more than one frame, an AVIF of more than one
70
+ * image, and an image or TIFF page of more than `MAX_IMAGE_PIXELS`. Needs the optional peer dependency `sharp`.
71
+ */
72
+ imageDocuments?: Uint8Array[];
56
73
  messageHistory?: plugins.ModelMessage[];
57
74
  }
58
75
 
@@ -75,7 +92,10 @@ export interface IDocumentUsage {
75
92
  export interface IDocumentAnalysisResult {
76
93
  text: string;
77
94
  usage: IDocumentUsage;
78
- /** Rendered pages sent to the model. A page that renders to an empty image is not sent. */
95
+ /**
96
+ * Pages sent to the model: the PDFs' rendered pages and the images' pages. A PDF page that renders
97
+ * to an empty image is not sent.
98
+ */
79
99
  pageCount: number;
80
100
  }
81
101
 
@@ -117,6 +137,251 @@ export class DocumentPageLimitError extends Error {
117
137
  }
118
138
  }
119
139
 
140
+ /*
141
+ * The image limits below stay within what the providers publish (read 2026-09-28):
142
+ * - Anthropic, https://platform.claude.com/docs/en/build-with-claude/vision: at most 8000x8000 px per
143
+ * image; in a request with more than 20 images at most 2000 px per side; at most 10 MB per image
144
+ * (base64) on the Claude API and 5 MB (base64) on Amazon Bedrock and Google Cloud; JPEG, PNG, GIF
145
+ * and WebP, of an animation only the first frame.
146
+ * - OpenAI, https://developers.openai.com/api/docs/guides/images-vision: PNG, JPEG, WebP and
147
+ * non-animated GIF; up to 512 MB payload and 1500 images per request; the page names no size per
148
+ * image. Images are scaled down on the provider's side.
149
+ */
150
+
151
+ /**
152
+ * The longest edge, in pixels, of an image sent to the model when a request names none: Anthropic's
153
+ * 2000 pixels per side for a request with more than 20 images, the tightest of the limits above.
154
+ */
155
+ export const DEFAULT_MAX_IMAGE_EDGE = 2000;
156
+
157
+ /**
158
+ * The most bytes of one image sent to the model: 5 000 000 characters once base64-encoded, within
159
+ * Anthropic's 5 MB per image on Amazon Bedrock and Google Cloud, the smallest of the limits above.
160
+ */
161
+ export const MAX_IMAGE_BYTES = 3_750_000;
162
+
163
+ /**
164
+ * The most pixels (width times height) of one image or TIFF page that is decoded: an A3 page scanned
165
+ * at 600 dpi has about 70 million. The images come from mail and uploads, so a larger one is refused
166
+ * from its header, before it is decoded, instead of being given sharp's own limit of about 268 million.
167
+ */
168
+ export const MAX_IMAGE_PIXELS = 100_000_000;
169
+
170
+ /** Why an image document is refused. */
171
+ export type TDocumentFormatRefusal =
172
+ /** it could not be read at all */
173
+ | 'unreadable'
174
+ /** it is a format this component does not read */
175
+ | 'unsupported'
176
+ /** it is HEIC, which the prebuilt sharp cannot decode */
177
+ | 'heic'
178
+ /** it is a GIF or WebP of more than one frame: frames are no pages, and none is dropped */
179
+ | 'animated'
180
+ /** it is an AVIF container of more than one image: which of them is the document is not known, and none is dropped */
181
+ | 'multiple_images'
182
+ /** it has more pixels than `MAX_IMAGE_PIXELS` */
183
+ | 'too_many_pixels'
184
+ /** it stays larger than `MAX_IMAGE_BYTES` after scaling */
185
+ | 'too_large';
186
+
187
+ /** An image document this component does not read; the model was not called. */
188
+ export class DocumentFormatError extends Error {
189
+ /** Zero-based position, in the request's documents (the PDFs first, then the images), of the image. */
190
+ public readonly documentIndex: number;
191
+ /** The format the image was read as, or null when it could not be read at all. */
192
+ public readonly format: string | null;
193
+ public readonly reason: TDocumentFormatRefusal;
194
+
195
+ constructor(
196
+ message: string,
197
+ details: { documentIndex: number; format: string | null; reason: TDocumentFormatRefusal },
198
+ options?: ErrorOptions,
199
+ ) {
200
+ super(message, options);
201
+ this.name = 'DocumentFormatError';
202
+ this.documentIndex = details.documentIndex;
203
+ this.format = details.format;
204
+ this.reason = details.reason;
205
+ }
206
+ }
207
+
208
+ /** A page as it is sent to the model. */
209
+ interface IModelPage {
210
+ data: Uint8Array;
211
+ mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
212
+ }
213
+
214
+ /** The formats sent as they are when they need no scaling and no turning. */
215
+ const AS_IS_MEDIA_TYPES: ReadonlyMap<string, IModelPage['mediaType']> = new Map([
216
+ ['jpeg', 'image/jpeg'],
217
+ ['png', 'image/png'],
218
+ ['webp', 'image/webp'],
219
+ ]);
220
+
221
+ /** The formats read at all: sharp's names, HEIF only with AV1 compression (AVIF). */
222
+ const READABLE_IMAGE_FORMATS: ReadonlySet<string> = new Set(['jpeg', 'png', 'webp', 'gif', 'tiff', 'heif']);
223
+
224
+ /** The formats whose further pages are frames of an animation, not pages of a document. */
225
+ const ANIMATED_FORMATS: ReadonlySet<string> = new Set(['gif', 'webp']);
226
+
227
+ /** The one load of the optional peer dependency sharp; a failed load is forgotten. */
228
+ let sharpLoad: Promise<plugins.TImageReader> | null = null;
229
+
230
+ function loadSharp(): Promise<plugins.TImageReader> {
231
+ if (!sharpLoad) {
232
+ const load = plugins.importSharp().catch((error: unknown) => {
233
+ if (sharpLoad === load) {
234
+ sharpLoad = null;
235
+ }
236
+ throw new Error('Image documents need the optional peer dependency sharp.', { cause: error });
237
+ });
238
+ sharpLoad = load;
239
+ }
240
+ return sharpLoad;
241
+ }
242
+
243
+ /** Refuses a page with more pixels than `MAX_IMAGE_PIXELS`, from what its header says. */
244
+ function checkPixels(metadata: plugins.IImageMetadata, documentIndex: number, format: string): void {
245
+ const pixels = (metadata.width ?? 0) * (metadata.height ?? 0);
246
+ if (pixels > MAX_IMAGE_PIXELS) {
247
+ throw new DocumentFormatError(
248
+ `The image has ${metadata.width}x${metadata.height} pixels, more than the ${MAX_IMAGE_PIXELS} read here.`,
249
+ { documentIndex, format, reason: 'too_many_pixels' },
250
+ );
251
+ }
252
+ }
253
+
254
+ /** What an image document is, read from its header without decoding it. */
255
+ async function readImage(
256
+ sharp: plugins.TImageReader,
257
+ image: Uint8Array,
258
+ documentIndex: number,
259
+ ): Promise<{ metadata: plugins.IImageMetadata; pageCount: number }> {
260
+ let metadata: plugins.IImageMetadata;
261
+ try {
262
+ metadata = await sharp(image).metadata();
263
+ } catch (error) {
264
+ throw new DocumentFormatError('The image could not be read.', { documentIndex, format: null, reason: 'unreadable' }, { cause: error });
265
+ }
266
+ const format = metadata.format ?? null;
267
+ if (format === 'heif' && metadata.compression !== 'av1') {
268
+ throw new DocumentFormatError(
269
+ 'The image is HEIC, which cannot be decoded here; send it as JPEG.',
270
+ { documentIndex, format, reason: 'heic' },
271
+ );
272
+ }
273
+ if (!format || !READABLE_IMAGE_FORMATS.has(format)) {
274
+ throw new DocumentFormatError(
275
+ `The image is ${format ?? 'of an unknown format'}; images are read as JPEG, PNG, WebP, GIF, TIFF or AVIF.`,
276
+ { documentIndex, format, reason: 'unsupported' },
277
+ );
278
+ }
279
+ const pages = Math.max(metadata.pages ?? 1, 1);
280
+ if (ANIMATED_FORMATS.has(format) && pages > 1) {
281
+ throw new DocumentFormatError(
282
+ `The image is an animated ${format === 'gif' ? 'GIF' : 'WebP'} of ${pages} frames; its frames are no pages, so it is not read.`,
283
+ { documentIndex, format, reason: 'animated' },
284
+ );
285
+ }
286
+ // sharp reports the top-level images of a HEIF container as its pages (libvips heifload, n-pages);
287
+ // only a TIFF's pages are pages of a document
288
+ if (format !== 'tiff' && pages > 1) {
289
+ throw new DocumentFormatError(
290
+ `The image is an AVIF holding ${pages} images; which of them is the document is not known, so it is not read.`,
291
+ { documentIndex, format, reason: 'multiple_images' },
292
+ );
293
+ }
294
+ checkPixels(metadata, documentIndex, format);
295
+ return { metadata, pageCount: format === 'tiff' ? pages : 1 };
296
+ }
297
+
298
+ /** One page of an image document, upright, scaled down and encoded; a page that cannot be decoded is refused. */
299
+ async function imagePage(
300
+ sharp: plugins.TImageReader,
301
+ image: Uint8Array,
302
+ page: number,
303
+ format: string,
304
+ photo: boolean,
305
+ maxEdge: number,
306
+ documentIndex: number,
307
+ ): Promise<IModelPage> {
308
+ const pipeline = () => sharp(image, { page, limitInputPixels: MAX_IMAGE_PIXELS })
309
+ .autoOrient()
310
+ .resize({ width: maxEdge, height: maxEdge, fit: 'inside', withoutEnlargement: true });
311
+ // JPEG has no transparency: a transparent background becomes white, not black
312
+ const jpeg = async (quality: number): Promise<IModelPage> => ({
313
+ data: await pipeline().flatten({ background: '#ffffff' }).jpeg({ quality }).toBuffer(),
314
+ mediaType: 'image/jpeg',
315
+ });
316
+ let encoded: IModelPage;
317
+ try {
318
+ if (page > 0) {
319
+ // each TIFF page has its own size; the first was checked with the document
320
+ checkPixels(await sharp(image, { page }).metadata(), documentIndex, format);
321
+ }
322
+ encoded = photo ? await jpeg(90) : { data: await pipeline().png().toBuffer(), mediaType: 'image/png' };
323
+ // too large: a photo tries a lower quality, any other a JPEG at the higher one first
324
+ for (const quality of photo ? [75] : [90, 75]) {
325
+ if (encoded.data.byteLength <= MAX_IMAGE_BYTES) {
326
+ break;
327
+ }
328
+ encoded = await jpeg(quality);
329
+ }
330
+ } catch (error) {
331
+ if (error instanceof DocumentFormatError) {
332
+ throw error;
333
+ }
334
+ throw new DocumentFormatError(
335
+ pageLabel(page, 'could not be decoded.'),
336
+ { documentIndex, format, reason: 'unreadable' },
337
+ { cause: error },
338
+ );
339
+ }
340
+ if (encoded.data.byteLength > MAX_IMAGE_BYTES) {
341
+ throw new DocumentFormatError(
342
+ pageLabel(page, `stays larger than ${MAX_IMAGE_BYTES} bytes after scaling to ${maxEdge} pixels.`),
343
+ { documentIndex, format, reason: 'too_large' },
344
+ );
345
+ }
346
+ // a Buffer is a Uint8Array view of a pooled ArrayBuffer; send exactly its bytes
347
+ return { data: new Uint8Array(encoded.data), mediaType: encoded.mediaType };
348
+ }
349
+
350
+ /** "The image …" for a one-page image, "Page n of the image …" for a TIFF page after the first. */
351
+ const pageLabel = (page: number, predicate: string): string =>
352
+ page === 0 ? `The image ${predicate}` : `Page ${page + 1} of the image ${predicate}`;
353
+
354
+ /** An image document's pages as they are sent: as they are where that fits, else upright and scaled down. */
355
+ async function imagePages(
356
+ sharp: plugins.TImageReader,
357
+ image: Uint8Array,
358
+ metadata: plugins.IImageMetadata,
359
+ pageCount: number,
360
+ maxEdge: number,
361
+ documentIndex: number,
362
+ abortSignal: AbortSignal | undefined,
363
+ ): Promise<IModelPage[]> {
364
+ const format = metadata.format!;
365
+ const asIs = AS_IS_MEDIA_TYPES.get(format);
366
+ if (
367
+ asIs
368
+ && pageCount === 1
369
+ && (metadata.orientation ?? 1) === 1
370
+ && (metadata.width ?? Infinity) <= maxEdge
371
+ && (metadata.height ?? Infinity) <= maxEdge
372
+ && image.byteLength <= MAX_IMAGE_BYTES
373
+ ) {
374
+ return [{ data: image, mediaType: asIs }];
375
+ }
376
+ const photo = format === 'jpeg' || format === 'heif';
377
+ const pages: IModelPage[] = [];
378
+ for (let page = 0; page < pageCount; page++) {
379
+ abortSignal?.throwIfAborted();
380
+ pages.push(await imagePage(sharp, image, page, format, photo, maxEdge, documentIndex));
381
+ }
382
+ return pages;
383
+ }
384
+
120
385
  const DEFAULT_OCR_SYSTEM_MESSAGE = 'You are a precise OCR engine. Extract text faithfully and do not summarize.';
121
386
  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.';
122
387
 
@@ -156,42 +421,65 @@ export async function analyzeDocumentsWithUsage(options: IDocumentOptions): Prom
156
421
  if (maxPages !== undefined && (!Number.isSafeInteger(maxPages) || maxPages < 1)) {
157
422
  throw new RangeError('maxPages must be a positive whole number.');
158
423
  }
424
+ const maxImageEdge = options.maxImageEdge ?? DEFAULT_MAX_IMAGE_EDGE;
425
+ if (!Number.isSafeInteger(maxImageEdge) || maxImageEdge < 1) {
426
+ throw new RangeError('maxImageEdge must be a positive whole number.');
427
+ }
159
428
  const abortSignal = options.abortSignal;
160
429
  abortSignal?.throwIfAborted();
161
430
 
162
- const imagePages: Uint8Array[] = [];
163
- for (const [index, doc] of options.pdfDocuments.entries()) {
431
+ const pdfDocuments = options.pdfDocuments ?? [];
432
+ const imageDocuments = options.imageDocuments ?? [];
433
+ const documentCount = pdfDocuments.length + imageDocuments.length;
434
+ const limitDetails = (index: number, renderedPages: number, limit: number): IDocumentPageLimitErrorDetails => ({
435
+ renderedPages,
436
+ maxPages: limit,
437
+ documentCount,
438
+ documentIndex: index,
439
+ allDocumentsRendered: index === documentCount - 1,
440
+ });
441
+ const modelPages: IModelPage[] = [];
442
+ // every rendered page counts against maxPages, also a PDF page that renders empty and is not sent
443
+ let countedPages = 0;
444
+ for (const [index, doc] of pdfDocuments.entries()) {
164
445
  const pdf = await ensureSmartpdf();
165
446
  abortSignal?.throwIfAborted();
166
- const limitDetails = (renderedPages: number, limit: number): IDocumentPageLimitErrorDetails => ({
167
- renderedPages,
168
- maxPages: limit,
169
- documentCount: options.pdfDocuments.length,
170
- documentIndex: index,
171
- allDocumentsRendered: index === options.pdfDocuments.length - 1,
172
- });
173
447
  // SmartPDF limits one document, so each render gets the pages the request has left. It takes
174
448
  // no limit of zero: when the earlier documents used up the request's pages, the smallest limit
175
449
  // lets the document's page count be known, and a document of one page renders and is refused below.
176
- const renderMaxPages = maxPages === undefined ? undefined : Math.max(maxPages - imagePages.length, 1);
450
+ const renderMaxPages = maxPages === undefined ? undefined : Math.max(maxPages - countedPages, 1);
177
451
  let pages: Uint8Array[];
178
452
  try {
179
453
  pages = await pdf.convertPDFToPngBytes(doc, { signal: abortSignal, maxPages: renderMaxPages });
180
454
  } catch (error) {
181
455
  if (maxPages !== undefined && error instanceof plugins.smartpdf.SmartPdfPageLimitError) {
182
- throw new DocumentPageLimitError(limitDetails(imagePages.length + error.pageCount, maxPages), { cause: error });
456
+ throw new DocumentPageLimitError(limitDetails(index, countedPages + error.pageCount, maxPages), { cause: error });
183
457
  }
184
458
  throw error;
185
459
  }
186
460
  abortSignal?.throwIfAborted();
187
- imagePages.push(...pages);
188
- if (maxPages !== undefined && imagePages.length > maxPages) {
189
- throw new DocumentPageLimitError(limitDetails(imagePages.length, maxPages));
461
+ countedPages += pages.length;
462
+ modelPages.push(...pages.filter((page) => page && page.length > 0).map((page) => ({ data: page, mediaType: 'image/png' as const })));
463
+ if (maxPages !== undefined && countedPages > maxPages) {
464
+ throw new DocumentPageLimitError(limitDetails(index, countedPages, maxPages));
190
465
  }
191
466
  }
192
467
 
193
- // Filter out empty buffers
194
- const validPages = imagePages.filter(page => page && page.length > 0);
468
+ if (imageDocuments.length) {
469
+ const sharp = await loadSharp();
470
+ for (const [imageIndex, image] of imageDocuments.entries()) {
471
+ abortSignal?.throwIfAborted();
472
+ const index = pdfDocuments.length + imageIndex;
473
+ const { metadata, pageCount } = await readImage(sharp, image, index);
474
+ // an image's pages are known from its header, so an image over the limit is not decoded
475
+ if (maxPages !== undefined && countedPages + pageCount > maxPages) {
476
+ throw new DocumentPageLimitError(limitDetails(index, countedPages + pageCount, maxPages));
477
+ }
478
+ countedPages += pageCount;
479
+ modelPages.push(...await imagePages(sharp, image, metadata, pageCount, maxImageEdge, index, abortSignal));
480
+ }
481
+ abortSignal?.throwIfAborted();
482
+ }
195
483
 
196
484
  const onUsage = options.onUsage;
197
485
  const modelCalls = onUsage ? new plugins.AgentModelCallUsageRecorder(options.model, onUsage) : null;
@@ -219,10 +507,10 @@ export async function analyzeDocumentsWithUsage(options: IDocumentOptions): Prom
219
507
  role: 'user',
220
508
  content: [
221
509
  { type: 'text', text: options.userMessage },
222
- ...validPages.map(page => ({
510
+ ...modelPages.map(page => ({
223
511
  type: 'file' as const,
224
- data: page,
225
- mediaType: 'image/png' as const,
512
+ data: page.data,
513
+ mediaType: page.mediaType,
226
514
  })),
227
515
  ],
228
516
  },
@@ -260,7 +548,7 @@ export async function analyzeDocumentsWithUsage(options: IDocumentOptions): Prom
260
548
  cacheReadTokens: usage.inputTokenDetails.cacheReadTokens ?? 0,
261
549
  cacheWriteTokens: usage.inputTokenDetails.cacheWriteTokens ?? 0,
262
550
  },
263
- pageCount: validPages.length,
551
+ pageCount: modelPages.length,
264
552
  };
265
553
  }
266
554
 
@@ -5,3 +5,38 @@ export { generateText, wrapLanguageModel, smartpdf, AgentModelCallUsageRecorder
5
5
  export type { ModelMessage } from 'ai';
6
6
  export type { TSmartAiLanguageModel, TSmartAiProviderOptions } from '@modelprofile.com/flexharness-models';
7
7
  export type { TAgentModelCallUsageReporter } from '@modelprofile.com/flexharness-agent';
8
+
9
+ // sharp is an optional peer dependency: it is loaded when a request carries its first image
10
+ // document, so a project that sends only PDFs does not install it. What of it this component uses is
11
+ // typed here, structurally, so that no declaration of /documents names sharp: a project without it
12
+ // type-checks /documents all the same.
13
+
14
+ /** What sharp reads from an image's header (`sharp.Metadata`, the fields used here). */
15
+ export interface IImageMetadata {
16
+ format?: string;
17
+ compression?: string;
18
+ pages?: number;
19
+ orientation?: number;
20
+ width?: number;
21
+ height?: number;
22
+ }
23
+
24
+ /** A sharp pipeline, the operations used here. */
25
+ export interface IImagePipeline {
26
+ metadata(): Promise<IImageMetadata>;
27
+ autoOrient(): IImagePipeline;
28
+ resize(options: { width: number; height: number; fit: 'inside'; withoutEnlargement: boolean }): IImagePipeline;
29
+ flatten(options: { background: string }): IImagePipeline;
30
+ jpeg(options: { quality: number }): IImagePipeline;
31
+ png(): IImagePipeline;
32
+ toBuffer(): Promise<Uint8Array>;
33
+ }
34
+
35
+ /** Opens an image (a page of it) as a pipeline, as `sharp(input, options)` does. */
36
+ export type TImageReader = (input: Uint8Array, options?: { page?: number; limitInputPixels?: number }) => IImagePipeline;
37
+
38
+ /** Loads sharp and hands out its reader. */
39
+ export const importSharp = async (): Promise<TImageReader> => {
40
+ const sharp = (await import('sharp')).default;
41
+ return (input, options) => sharp(input, options);
42
+ };
@@ -1,4 +1,4 @@
1
- PDF processing and language-model document recipes.
1
+ PDF and image processing and language-model document recipes.
2
2
 
3
3
  Part of the [FlexHarness toolbox](https://code.foss.global/modelprofile.com/flexharness).
4
4
  Install the components your project uses. Provider adapters are registered explicitly;
@@ -6,6 +6,8 @@ the agent and harness do not install or register vendor SDKs.
6
6
 
7
7
  ```sh
8
8
  pnpm add @modelprofile.com/flexharness @push.rocks/smartpdf
9
+ # for image documents as well
10
+ pnpm add sharp
9
11
  ```
10
12
 
11
13
  ```typescript
@@ -23,11 +25,46 @@ Supply a model from your selected FlexHarness provider. `extractTextFromPdf` rep
23
25
 
24
26
  `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
27
 
26
- Both functions accept four request options:
28
+ `analyzeDocuments` also takes `imageDocuments`, sent after the PDFs' pages in the order given. They need the optional peer dependency `sharp`, loaded with the first image:
29
+
30
+ - JPEG, PNG and WebP that fit `maxImageEdge` and `MAX_IMAGE_BYTES` and need no turning go to the model as they are.
31
+ - Any other image is turned upright by its EXIF orientation, scaled down to fit `maxImageEdge` and sent as JPEG (from a JPEG or AVIF) or PNG (from PNG, WebP, GIF or TIFF), for this request only; nothing is stored. A PNG that stays larger than `MAX_IMAGE_BYTES` is sent as JPEG. A transparent background becomes white in a JPEG.
32
+ - Each image and each TIFF page is a page of the request. Their count is read from the header, so an image over `maxPages` is refused with `DocumentPageLimitError` before it is decoded.
33
+ - A TIFF that stores a pyramid, its lower resolutions as further pages, sends every level as a page: sharp's metadata does not tell such levels from the pages of a document, and none is dropped.
34
+ - Refused with `DocumentFormatError` before the model is called, its `reason` saying why:
35
+ - `unreadable`: the image cannot be read, or a page of it cannot be decoded;
36
+ - `unsupported`: none of JPEG, PNG, WebP, GIF, TIFF and AVIF;
37
+ - `heic`: the prebuilt sharp decodes AVIF but has no HEVC decoder;
38
+ - `animated`: a GIF or WebP of more than one frame, since frames are no pages and none is dropped;
39
+ - `multiple_images`: an AVIF container of more than one top-level image, since which of them is the document is not known and none is dropped;
40
+ - `too_many_pixels`: an image or TIFF page of more than `MAX_IMAGE_PIXELS` (100 000 000, an A3 page at 600 dpi has about 70 million), refused from its header before anything is decoded;
41
+ - `too_large`: an image that stays larger than `MAX_IMAGE_BYTES` after scaling.
42
+
43
+ The limits stay within what the providers publish (read 2026-09-28):
44
+
45
+ - Anthropic, <https://platform.claude.com/docs/en/build-with-claude/vision>: at most 8000x8000 px per image, at most 2000 px per side in a request with more than 20 images, at most 10 MB per image (base64) on the Claude API and 5 MB (base64) on Amazon Bedrock and Google Cloud.
46
+ - OpenAI, <https://developers.openai.com/api/docs/guides/images-vision>: PNG, JPEG, WebP and non-animated GIF, up to 512 MB payload and 1500 images per request; the page names no limit per image.
47
+ - `DEFAULT_MAX_IMAGE_EDGE` (2000 pixels) is Anthropic's limit for a request with more than 20 images, the tightest; `MAX_IMAGE_BYTES` (3 750 000 bytes, 5 000 000 characters once base64-encoded) is within Anthropic's 5 MB on Amazon Bedrock and Google Cloud.
48
+
49
+ ```typescript
50
+ import { analyzeDocumentsWithUsage, DocumentFormatError } from '@modelprofile.com/flexharness/documents';
51
+
52
+ const { text, pageCount } = await analyzeDocumentsWithUsage({
53
+ model,
54
+ userMessage: 'Read the invoice: supplier, date, number, net, VAT and gross amounts.',
55
+ imageDocuments: [photoBytes],
56
+ maxPages: 20,
57
+ });
58
+ ```
59
+
60
+ The request options below apply to image documents as they do to PDFs: an abort is checked before each document and each TIFF page, and before the model call.
61
+
62
+ Both functions accept these request options:
27
63
 
28
64
  - `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
65
  - `abortSignal` is checked before each document is rendered and before the model call, and is passed to both the render and the model call, so an abort also stops a document that is rendering. The request rejects with the signal's reason.
30
66
  - `maxPages` is the most rendered pages one request may send to the model, counted over all its documents. A request with more pages rejects with `DocumentPageLimitError` before the model is called. Each document is rendered only when its page count fits the pages the request has left, so the document that exceeds the limit and every document after it are not rendered; SmartPDF counts that document's pages without rendering them. The exception is a one-page document that comes after the request's pages are used up: SmartPDF takes no limit of zero pages, so that document renders and is then refused. The error carries `renderedPages` (the pages of the documents up to and including the one that exceeded the limit), `maxPages` and `documentIndex` (the zero-based position of that document), and SmartPDF's `SmartPdfPageLimitError` as its `cause` when SmartPDF refused the document.
67
+ - `maxImageEdge` is the longest edge, in pixels, of an image document as it is sent; see above.
31
68
  - `onUsage` receives the usage of each model call of the request, also of a request that fails or is aborted; see [Usage of a request that fails or is aborted](#usage-of-a-request-that-fails-or-is-aborted).
32
69
 
33
70
  `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.