@isomorph.ai/app-sdk 1.34.1 → 1.35.1

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/README.md CHANGED
@@ -36,6 +36,21 @@ Replace the connection/resource with the app's existing approved integration. To
36
36
 
37
37
  For images call `generateImage({model: gateway.image(process.env.ISOMORPH_AI_IMAGE_MODEL_ALIAS!), prompt: 'A blue notebook'})`. Editing uses `prompt: {text: 'Make it red', images: [inputBytes]}` only when `isomorph ai models` advertises edit. Reuse app files upload/signed reads for authorized input and output storage; respect existing transfer/action limits and never return credentials.
38
38
 
39
+ To read a PDF or an image (a scan, a photo of a form) call `isomorph.ai.ocr(file, options?)` from an action or a job. It sends the document to the governed gateway's OCR route under the company's document model and answers with the text of each page as markdown; `layout: true` also returns each page's blocks (kind, text, box normalised to 0..1 of the page, confidence), and `pages` narrows the read (`'0-2'` or `[0, 3]`). The file is a `Blob`, bytes (`Uint8Array` or `ArrayBuffer`, taken as a PDF) or `{ mediaType, data }`. It is a server call: a page that needs a document read invokes the action. When IT has not routed an OCR model the call fails with `aiErrorCode(error) === 'AI_NOT_ENABLED'`.
40
+
41
+ ```ts
42
+ // actions/read-invoice.ts
43
+ import { createClient, aiErrorCode } from '@isomorph.ai/app-sdk';
44
+
45
+ export default async function readInvoice(input: { path: string }) {
46
+ const isomorph = createClient();
47
+ const { signedUrl } = await isomorph.files.createSignedUrl(input.path);
48
+ const file = await (await fetch(signedUrl)).blob();
49
+ const read = await isomorph.ai.ocr(file, { layout: true });
50
+ return { text: read.markdown, pages: read.pages.length, tables: read.pages.flatMap(page => page.blocks!.filter(block => block.kind === 'table').map(block => block.text)) };
51
+ }
52
+ ```
53
+
39
54
  ## Company integrations
40
55
 
41
56
  `isomorph.integrations.execute(connection, request)` is one method over a closed
package/dist/index.d.ts CHANGED
@@ -1815,6 +1815,55 @@ export interface AiEmbedResult {
1815
1815
  };
1816
1816
  traceId?: string;
1817
1817
  }
1818
+ export type AiOcrFile = Blob | Uint8Array | ArrayBuffer | {
1819
+ /** The document's media type: application/pdf, image/png, image/jpeg, … */
1820
+ mediaType: string;
1821
+ /** The bytes, or their base64 text. */
1822
+ data: Uint8Array | ArrayBuffer | string;
1823
+ /** For the caller's bookkeeping only; it is not sent to the gateway. */
1824
+ filename?: string;
1825
+ };
1826
+ export interface AiOcrOptions {
1827
+ /** The pages to read: a range such as '0-2' or a list of zero-based page indexes. Omitted reads every page. */
1828
+ pages?: string | number[];
1829
+ /** true also returns each page's layout blocks, with their boxes and confidence. */
1830
+ layout?: boolean;
1831
+ }
1832
+ export interface AiOcrBlock {
1833
+ /** The block's kind as the provider names it: text, table, figure, … */
1834
+ kind: string;
1835
+ text: string;
1836
+ /** The block's box on its page, normalised to 0..1 of the page's width and height. */
1837
+ box: {
1838
+ x0: number;
1839
+ y0: number;
1840
+ x1: number;
1841
+ y1: number;
1842
+ };
1843
+ confidence?: number;
1844
+ }
1845
+ export interface AiOcrPage {
1846
+ /** The zero-based page index in the document. */
1847
+ index: number;
1848
+ /** The page's text as markdown. */
1849
+ text: string;
1850
+ /** The page size in the provider's rendering units (pixels at `dpi`). */
1851
+ width: number;
1852
+ height: number;
1853
+ confidence?: number;
1854
+ /** Present when the call asked for layout. */
1855
+ blocks?: AiOcrBlock[];
1856
+ }
1857
+ export interface AiOcrResult {
1858
+ /** Every page's text, in order, separated by blank lines. */
1859
+ markdown: string;
1860
+ pages: AiOcrPage[];
1861
+ /** The model that read the document, as the gateway reports it. */
1862
+ model: string;
1863
+ usage: {
1864
+ pages: number;
1865
+ };
1866
+ }
1818
1867
  /** Stable reason codes governed AI answers with in IsomorphError.details.code. */
1819
1868
  export type AiErrorCode = 'AI_NOT_ENABLED' | 'AI_POLICY_DENIED' | 'AI_QUOTA_EXCEEDED' | 'AI_PROVIDER_ERROR' | 'AI_UNAVAILABLE' | 'REQUEST_TOO_LARGE' | 'VALIDATION_FAILED';
1820
1869
  /** The AI reason code carried by an error, when it is a governed-AI refusal. */
@@ -2008,6 +2057,7 @@ export declare function createClient(options?: ClientOptions): {
2008
2057
  ai: {
2009
2058
  chat: (request: AiChatRequest) => Promise<AiChatResult>;
2010
2059
  embed: (request: AiEmbedRequest) => Promise<AiEmbedResult>;
2060
+ ocr: (file: AiOcrFile, ocrOptions?: AiOcrOptions) => Promise<AiOcrResult>;
2011
2061
  };
2012
2062
  };
2013
2063
  export type IsomorphClient = ReturnType<typeof createClient>;
package/dist/index.js CHANGED
@@ -212,7 +212,11 @@ class Transport {
212
212
  const action = currentAction();
213
213
  // (headers with an undefined value are removed so a multipart body can
214
214
  // clear the JSON default and let the browser set its own boundary)
215
- const response = await this.fetch(`${action?.baseUrl ?? this.baseUrl}${path}`, {
215
+ // A trusted action uses its local gateway for database calls, including
216
+ // transactions. The local gateway deliberately has only the data
217
+ // capability; every other declared surface remains on the shared gateway.
218
+ const baseUrl = action?.baseUrl && path.startsWith('/_harbour/data/') ? action.baseUrl : this.baseUrl;
219
+ const response = await this.fetch(`${baseUrl}${path}`, {
216
220
  credentials: 'same-origin',
217
221
  ...init,
218
222
  headers: mergeHeaders(
@@ -840,9 +844,180 @@ export function createClient(options = {}) {
840
844
  embed: (request) => transport.json('/_harbour/ai/embed', {
841
845
  method: 'POST', body: JSON.stringify(request),
842
846
  }),
847
+ ocr: (file, ocrOptions = {}) => ocrDocument(options.fetch ?? globalThis.fetch.bind(globalThis), file, ocrOptions),
843
848
  },
844
849
  };
845
850
  }
851
+ const OCR_NOT_ROUTED = 'No OCR model is routed for this app. Ask IT to connect an OCR provider under AI governance.';
852
+ const OCR_ROUTING_CODES = new Set(['model_capability_unassigned', 'model_route_not_approved', 'route_targets_unavailable', 'file_input_unsupported']);
853
+ // The OCR call: the document goes to the governed gateway as a data URL in
854
+ // Mistral's OCR wire shape and the answer comes back as pages of markdown,
855
+ // with boxes normalised to the page. Refusals are IsomorphErrors whose
856
+ // details.code is an AiErrorCode, like the rest of governed AI.
857
+ async function ocrDocument(fetchImpl, file, options) {
858
+ if (typeof window !== 'undefined' && typeof window.document !== 'undefined') {
859
+ throw new IsomorphError('VALIDATION_FAILED', 'isomorph.ai.ocr runs in actions or jobs', undefined, { code: 'VALIDATION_FAILED' });
860
+ }
861
+ const env = typeof process === 'undefined' ? {} : process.env;
862
+ const baseUrl = env.OPENAI_BASE_URL;
863
+ const key = env.OPENAI_API_KEY;
864
+ if (!baseUrl || !key) {
865
+ throw new IsomorphError('FORBIDDEN', 'Governed AI is not connected for this app version yet.', undefined, { code: 'AI_NOT_ENABLED' });
866
+ }
867
+ const model = env.ISOMORPH_AI_DOCUMENT_MODEL_ALIAS;
868
+ if (!model)
869
+ throw new IsomorphError('FORBIDDEN', OCR_NOT_ROUTED, undefined, { code: 'AI_NOT_ENABLED' });
870
+ const { mediaType, base64 } = await ocrDocumentBytes(file);
871
+ if (options.pages !== undefined && typeof options.pages !== 'string' && !(Array.isArray(options.pages) && options.pages.every(page => Number.isInteger(page) && page >= 0))) {
872
+ throw new IsomorphError('VALIDATION_FAILED', 'pages must be a range string or a list of zero-based page indexes', undefined, { code: 'VALIDATION_FAILED' });
873
+ }
874
+ const dataUrl = `data:${mediaType};base64,${base64}`;
875
+ const body = {
876
+ model,
877
+ document: mediaType.startsWith('image/') ? { type: 'image_url', image_url: dataUrl } : { type: 'document_url', document_url: dataUrl },
878
+ ...(options.pages !== undefined ? { pages: options.pages } : {}),
879
+ include_blocks: options.layout === true,
880
+ ...(options.layout === true ? { confidence_scores_granularity: 'block' } : {}),
881
+ };
882
+ let response;
883
+ try {
884
+ response = await fetchImpl(`${baseUrl.replace(/\/$/, '')}/ocr`, {
885
+ method: 'POST',
886
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
887
+ body: JSON.stringify(body),
888
+ });
889
+ }
890
+ catch (error) {
891
+ throw new IsomorphError('UNAVAILABLE', `The AI gateway could not be reached: ${error instanceof Error ? error.message : String(error)}`, undefined, { code: 'AI_UNAVAILABLE' });
892
+ }
893
+ const requestId = response.headers.get('x-request-id') ?? undefined;
894
+ const raw = await response.json().catch(() => undefined);
895
+ if (!response.ok) {
896
+ const gatewayCode = response.headers.get('x-harbour-error-code') ?? raw?.error?.code ?? undefined;
897
+ const message = raw?.error?.message;
898
+ const [category, code, fallback] = ocrRefusal(response.status, gatewayCode);
899
+ throw new IsomorphError(category, code === 'AI_NOT_ENABLED' ? OCR_NOT_ROUTED : message ?? fallback, requestId, { code, gatewayCode }, response.status);
900
+ }
901
+ const wire = raw;
902
+ if (!wire || !Array.isArray(wire.pages)) {
903
+ throw new IsomorphError('UNAVAILABLE', 'The AI gateway answered without pages.', requestId, { code: 'AI_PROVIDER_ERROR' }, response.status);
904
+ }
905
+ const pages = wire.pages.map((page, position) => {
906
+ const width = finiteNumber(page.dimensions?.width) ?? 0;
907
+ const height = finiteNumber(page.dimensions?.height) ?? 0;
908
+ const confidence = confidenceOf(page.confidence_scores);
909
+ const blocks = options.layout === true
910
+ ? (page.blocks ?? []).map((block) => {
911
+ const blockConfidence = confidenceOf(block.confidence_scores);
912
+ return {
913
+ kind: typeof block.type === 'string' ? block.type : 'text',
914
+ text: typeof block.content === 'string' ? block.content : '',
915
+ box: {
916
+ x0: normalise(block.top_left_x, width), y0: normalise(block.top_left_y, height),
917
+ x1: normalise(block.bottom_right_x, width), y1: normalise(block.bottom_right_y, height),
918
+ },
919
+ ...(blockConfidence !== undefined ? { confidence: blockConfidence } : {}),
920
+ };
921
+ })
922
+ : undefined;
923
+ return {
924
+ index: finiteNumber(page.index) ?? position,
925
+ text: typeof page.markdown === 'string' ? page.markdown : '',
926
+ width, height,
927
+ ...(confidence !== undefined ? { confidence } : {}),
928
+ ...(blocks !== undefined ? { blocks } : {}),
929
+ };
930
+ });
931
+ return {
932
+ markdown: pages.map(page => page.text).join('\n\n'),
933
+ pages,
934
+ model: typeof wire.model === 'string' ? wire.model : model,
935
+ usage: { pages: finiteNumber(wire.usage_info?.pages_processed) ?? pages.length },
936
+ };
937
+ }
938
+ function ocrRefusal(status, gatewayCode) {
939
+ if (gatewayCode !== undefined && OCR_ROUTING_CODES.has(gatewayCode))
940
+ return ['FORBIDDEN', 'AI_NOT_ENABLED', OCR_NOT_ROUTED];
941
+ if (status === 401 || status === 403)
942
+ return ['FORBIDDEN', 'AI_POLICY_DENIED', 'AI governance refused this document.'];
943
+ if (status === 429)
944
+ return ['RATE_LIMITED', 'AI_QUOTA_EXCEEDED', 'The AI provider is rate limited; try again shortly.'];
945
+ if (status === 413)
946
+ return ['VALIDATION_FAILED', 'REQUEST_TOO_LARGE', 'The document is larger than the AI gateway accepts.'];
947
+ if (status >= 400 && status < 500)
948
+ return ['VALIDATION_FAILED', 'VALIDATION_FAILED', `The AI gateway refused the OCR request (${status}).`];
949
+ return ['UNAVAILABLE', 'AI_PROVIDER_ERROR', `The OCR provider failed (${status}).`];
950
+ }
951
+ async function ocrDocumentBytes(file) {
952
+ const invalid = (message) => new IsomorphError('VALIDATION_FAILED', message, undefined, { code: 'VALIDATION_FAILED' });
953
+ if (typeof Blob !== 'undefined' && file instanceof Blob) {
954
+ const bytes = new Uint8Array(await file.arrayBuffer());
955
+ if (bytes.byteLength === 0)
956
+ throw invalid('The document is empty.');
957
+ return { mediaType: file.type || 'application/pdf', base64: toBase64(bytes) };
958
+ }
959
+ if (file instanceof Uint8Array || file instanceof ArrayBuffer) {
960
+ const bytes = file instanceof Uint8Array ? file : new Uint8Array(file);
961
+ if (bytes.byteLength === 0)
962
+ throw invalid('The document is empty.');
963
+ return { mediaType: 'application/pdf', base64: toBase64(bytes) };
964
+ }
965
+ if (file && typeof file === 'object' && 'mediaType' in file && typeof file.mediaType === 'string' && /^[a-z]+\/[a-z0-9.+-]+$/i.test(file.mediaType)) {
966
+ const { mediaType, data } = file;
967
+ if (typeof data === 'string') {
968
+ if (data.length === 0)
969
+ throw invalid('The document is empty.');
970
+ return { mediaType, base64: data };
971
+ }
972
+ if (data instanceof Uint8Array || data instanceof ArrayBuffer) {
973
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
974
+ if (bytes.byteLength === 0)
975
+ throw invalid('The document is empty.');
976
+ return { mediaType, base64: toBase64(bytes) };
977
+ }
978
+ }
979
+ throw invalid('file must be a Blob, bytes, or { mediaType, data }');
980
+ }
981
+ function toBase64(bytes) {
982
+ if (typeof Buffer !== 'undefined')
983
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
984
+ let binary = '';
985
+ for (let at = 0; at < bytes.length; at += 0x8000)
986
+ binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000));
987
+ return btoa(binary);
988
+ }
989
+ function finiteNumber(value) {
990
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
991
+ }
992
+ function normalise(value, extent) {
993
+ const at = finiteNumber(value);
994
+ if (at === undefined || !(extent > 0))
995
+ return 0;
996
+ return Math.min(1, Math.max(0, at / extent));
997
+ }
998
+ // A confidence is a number, or the provider's object of numbers (the
999
+ // granularity's score among them): the first finite number is the score.
1000
+ // Mistral's OCR shape names the averages (`average_page_confidence_score`,
1001
+ // `average_content_confidence_score`); another vendor mapped onto the shape
1002
+ // may give a bare number. The first finite number is the last resort.
1003
+ function confidenceOf(value) {
1004
+ if (typeof value === 'number')
1005
+ return finiteNumber(value);
1006
+ if (value && typeof value === 'object') {
1007
+ const scores = value;
1008
+ for (const key of ['average_page_confidence_score', 'average_content_confidence_score', 'average', 'confidence']) {
1009
+ const score = finiteNumber(scores[key]);
1010
+ if (score !== undefined)
1011
+ return score;
1012
+ }
1013
+ for (const inner of Object.values(scores)) {
1014
+ const score = finiteNumber(inner);
1015
+ if (score !== undefined)
1016
+ return score;
1017
+ }
1018
+ }
1019
+ return undefined;
1020
+ }
846
1021
  // The contract's integration input rules (vendored verbatim from
847
1022
  // @fourier-labs/harbour-contracts gen-ts/integration-execute-input-rules.ts;
848
1023
  // test/integration-input-rules.test.mjs pins the copy's digest), so an app or
package/dist/runtime.js CHANGED
@@ -486,6 +486,8 @@ export class Runtime {
486
486
  process.env.ISOMORPH_AI_GENERATION_MODEL_ALIAS = binding.modelAliases.generation;
487
487
  if (binding.modelAliases?.image)
488
488
  process.env.ISOMORPH_AI_IMAGE_MODEL_ALIAS = binding.modelAliases.image;
489
+ if (binding.modelAliases?.document)
490
+ process.env.ISOMORPH_AI_DOCUMENT_MODEL_ALIAS = binding.modelAliases.document;
489
491
  })().catch(error => { this.aiBinding = undefined; throw error; });
490
492
  await this.aiBinding;
491
493
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.34.1",
3
+ "version": "1.35.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",