@isomorph.ai/app-sdk 1.34.1 → 1.35.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/README.md +15 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +171 -0
- package/dist/runtime.js +2 -0
- package/package.json +1 -1
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
|
@@ -840,9 +840,180 @@ export function createClient(options = {}) {
|
|
|
840
840
|
embed: (request) => transport.json('/_harbour/ai/embed', {
|
|
841
841
|
method: 'POST', body: JSON.stringify(request),
|
|
842
842
|
}),
|
|
843
|
+
ocr: (file, ocrOptions = {}) => ocrDocument(options.fetch ?? globalThis.fetch.bind(globalThis), file, ocrOptions),
|
|
843
844
|
},
|
|
844
845
|
};
|
|
845
846
|
}
|
|
847
|
+
const OCR_NOT_ROUTED = 'No OCR model is routed for this app. Ask IT to connect an OCR provider under AI governance.';
|
|
848
|
+
const OCR_ROUTING_CODES = new Set(['model_capability_unassigned', 'model_route_not_approved', 'route_targets_unavailable', 'file_input_unsupported']);
|
|
849
|
+
// The OCR call: the document goes to the governed gateway as a data URL in
|
|
850
|
+
// Mistral's OCR wire shape and the answer comes back as pages of markdown,
|
|
851
|
+
// with boxes normalised to the page. Refusals are IsomorphErrors whose
|
|
852
|
+
// details.code is an AiErrorCode, like the rest of governed AI.
|
|
853
|
+
async function ocrDocument(fetchImpl, file, options) {
|
|
854
|
+
if (typeof window !== 'undefined' && typeof window.document !== 'undefined') {
|
|
855
|
+
throw new IsomorphError('VALIDATION_FAILED', 'isomorph.ai.ocr runs in actions or jobs', undefined, { code: 'VALIDATION_FAILED' });
|
|
856
|
+
}
|
|
857
|
+
const env = typeof process === 'undefined' ? {} : process.env;
|
|
858
|
+
const baseUrl = env.OPENAI_BASE_URL;
|
|
859
|
+
const key = env.OPENAI_API_KEY;
|
|
860
|
+
if (!baseUrl || !key) {
|
|
861
|
+
throw new IsomorphError('FORBIDDEN', 'Governed AI is not connected for this app version yet.', undefined, { code: 'AI_NOT_ENABLED' });
|
|
862
|
+
}
|
|
863
|
+
const model = env.ISOMORPH_AI_DOCUMENT_MODEL_ALIAS;
|
|
864
|
+
if (!model)
|
|
865
|
+
throw new IsomorphError('FORBIDDEN', OCR_NOT_ROUTED, undefined, { code: 'AI_NOT_ENABLED' });
|
|
866
|
+
const { mediaType, base64 } = await ocrDocumentBytes(file);
|
|
867
|
+
if (options.pages !== undefined && typeof options.pages !== 'string' && !(Array.isArray(options.pages) && options.pages.every(page => Number.isInteger(page) && page >= 0))) {
|
|
868
|
+
throw new IsomorphError('VALIDATION_FAILED', 'pages must be a range string or a list of zero-based page indexes', undefined, { code: 'VALIDATION_FAILED' });
|
|
869
|
+
}
|
|
870
|
+
const dataUrl = `data:${mediaType};base64,${base64}`;
|
|
871
|
+
const body = {
|
|
872
|
+
model,
|
|
873
|
+
document: mediaType.startsWith('image/') ? { type: 'image_url', image_url: dataUrl } : { type: 'document_url', document_url: dataUrl },
|
|
874
|
+
...(options.pages !== undefined ? { pages: options.pages } : {}),
|
|
875
|
+
include_blocks: options.layout === true,
|
|
876
|
+
...(options.layout === true ? { confidence_scores_granularity: 'block' } : {}),
|
|
877
|
+
};
|
|
878
|
+
let response;
|
|
879
|
+
try {
|
|
880
|
+
response = await fetchImpl(`${baseUrl.replace(/\/$/, '')}/ocr`, {
|
|
881
|
+
method: 'POST',
|
|
882
|
+
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
883
|
+
body: JSON.stringify(body),
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
throw new IsomorphError('UNAVAILABLE', `The AI gateway could not be reached: ${error instanceof Error ? error.message : String(error)}`, undefined, { code: 'AI_UNAVAILABLE' });
|
|
888
|
+
}
|
|
889
|
+
const requestId = response.headers.get('x-request-id') ?? undefined;
|
|
890
|
+
const raw = await response.json().catch(() => undefined);
|
|
891
|
+
if (!response.ok) {
|
|
892
|
+
const gatewayCode = response.headers.get('x-harbour-error-code') ?? raw?.error?.code ?? undefined;
|
|
893
|
+
const message = raw?.error?.message;
|
|
894
|
+
const [category, code, fallback] = ocrRefusal(response.status, gatewayCode);
|
|
895
|
+
throw new IsomorphError(category, code === 'AI_NOT_ENABLED' ? OCR_NOT_ROUTED : message ?? fallback, requestId, { code, gatewayCode }, response.status);
|
|
896
|
+
}
|
|
897
|
+
const wire = raw;
|
|
898
|
+
if (!wire || !Array.isArray(wire.pages)) {
|
|
899
|
+
throw new IsomorphError('UNAVAILABLE', 'The AI gateway answered without pages.', requestId, { code: 'AI_PROVIDER_ERROR' }, response.status);
|
|
900
|
+
}
|
|
901
|
+
const pages = wire.pages.map((page, position) => {
|
|
902
|
+
const width = finiteNumber(page.dimensions?.width) ?? 0;
|
|
903
|
+
const height = finiteNumber(page.dimensions?.height) ?? 0;
|
|
904
|
+
const confidence = confidenceOf(page.confidence_scores);
|
|
905
|
+
const blocks = options.layout === true
|
|
906
|
+
? (page.blocks ?? []).map((block) => {
|
|
907
|
+
const blockConfidence = confidenceOf(block.confidence_scores);
|
|
908
|
+
return {
|
|
909
|
+
kind: typeof block.type === 'string' ? block.type : 'text',
|
|
910
|
+
text: typeof block.content === 'string' ? block.content : '',
|
|
911
|
+
box: {
|
|
912
|
+
x0: normalise(block.top_left_x, width), y0: normalise(block.top_left_y, height),
|
|
913
|
+
x1: normalise(block.bottom_right_x, width), y1: normalise(block.bottom_right_y, height),
|
|
914
|
+
},
|
|
915
|
+
...(blockConfidence !== undefined ? { confidence: blockConfidence } : {}),
|
|
916
|
+
};
|
|
917
|
+
})
|
|
918
|
+
: undefined;
|
|
919
|
+
return {
|
|
920
|
+
index: finiteNumber(page.index) ?? position,
|
|
921
|
+
text: typeof page.markdown === 'string' ? page.markdown : '',
|
|
922
|
+
width, height,
|
|
923
|
+
...(confidence !== undefined ? { confidence } : {}),
|
|
924
|
+
...(blocks !== undefined ? { blocks } : {}),
|
|
925
|
+
};
|
|
926
|
+
});
|
|
927
|
+
return {
|
|
928
|
+
markdown: pages.map(page => page.text).join('\n\n'),
|
|
929
|
+
pages,
|
|
930
|
+
model: typeof wire.model === 'string' ? wire.model : model,
|
|
931
|
+
usage: { pages: finiteNumber(wire.usage_info?.pages_processed) ?? pages.length },
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
function ocrRefusal(status, gatewayCode) {
|
|
935
|
+
if (gatewayCode !== undefined && OCR_ROUTING_CODES.has(gatewayCode))
|
|
936
|
+
return ['FORBIDDEN', 'AI_NOT_ENABLED', OCR_NOT_ROUTED];
|
|
937
|
+
if (status === 401 || status === 403)
|
|
938
|
+
return ['FORBIDDEN', 'AI_POLICY_DENIED', 'AI governance refused this document.'];
|
|
939
|
+
if (status === 429)
|
|
940
|
+
return ['RATE_LIMITED', 'AI_QUOTA_EXCEEDED', 'The AI provider is rate limited; try again shortly.'];
|
|
941
|
+
if (status === 413)
|
|
942
|
+
return ['VALIDATION_FAILED', 'REQUEST_TOO_LARGE', 'The document is larger than the AI gateway accepts.'];
|
|
943
|
+
if (status >= 400 && status < 500)
|
|
944
|
+
return ['VALIDATION_FAILED', 'VALIDATION_FAILED', `The AI gateway refused the OCR request (${status}).`];
|
|
945
|
+
return ['UNAVAILABLE', 'AI_PROVIDER_ERROR', `The OCR provider failed (${status}).`];
|
|
946
|
+
}
|
|
947
|
+
async function ocrDocumentBytes(file) {
|
|
948
|
+
const invalid = (message) => new IsomorphError('VALIDATION_FAILED', message, undefined, { code: 'VALIDATION_FAILED' });
|
|
949
|
+
if (typeof Blob !== 'undefined' && file instanceof Blob) {
|
|
950
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
951
|
+
if (bytes.byteLength === 0)
|
|
952
|
+
throw invalid('The document is empty.');
|
|
953
|
+
return { mediaType: file.type || 'application/pdf', base64: toBase64(bytes) };
|
|
954
|
+
}
|
|
955
|
+
if (file instanceof Uint8Array || file instanceof ArrayBuffer) {
|
|
956
|
+
const bytes = file instanceof Uint8Array ? file : new Uint8Array(file);
|
|
957
|
+
if (bytes.byteLength === 0)
|
|
958
|
+
throw invalid('The document is empty.');
|
|
959
|
+
return { mediaType: 'application/pdf', base64: toBase64(bytes) };
|
|
960
|
+
}
|
|
961
|
+
if (file && typeof file === 'object' && 'mediaType' in file && typeof file.mediaType === 'string' && /^[a-z]+\/[a-z0-9.+-]+$/i.test(file.mediaType)) {
|
|
962
|
+
const { mediaType, data } = file;
|
|
963
|
+
if (typeof data === 'string') {
|
|
964
|
+
if (data.length === 0)
|
|
965
|
+
throw invalid('The document is empty.');
|
|
966
|
+
return { mediaType, base64: data };
|
|
967
|
+
}
|
|
968
|
+
if (data instanceof Uint8Array || data instanceof ArrayBuffer) {
|
|
969
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
970
|
+
if (bytes.byteLength === 0)
|
|
971
|
+
throw invalid('The document is empty.');
|
|
972
|
+
return { mediaType, base64: toBase64(bytes) };
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
throw invalid('file must be a Blob, bytes, or { mediaType, data }');
|
|
976
|
+
}
|
|
977
|
+
function toBase64(bytes) {
|
|
978
|
+
if (typeof Buffer !== 'undefined')
|
|
979
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
|
|
980
|
+
let binary = '';
|
|
981
|
+
for (let at = 0; at < bytes.length; at += 0x8000)
|
|
982
|
+
binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000));
|
|
983
|
+
return btoa(binary);
|
|
984
|
+
}
|
|
985
|
+
function finiteNumber(value) {
|
|
986
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
987
|
+
}
|
|
988
|
+
function normalise(value, extent) {
|
|
989
|
+
const at = finiteNumber(value);
|
|
990
|
+
if (at === undefined || !(extent > 0))
|
|
991
|
+
return 0;
|
|
992
|
+
return Math.min(1, Math.max(0, at / extent));
|
|
993
|
+
}
|
|
994
|
+
// A confidence is a number, or the provider's object of numbers (the
|
|
995
|
+
// granularity's score among them): the first finite number is the score.
|
|
996
|
+
// Mistral's OCR shape names the averages (`average_page_confidence_score`,
|
|
997
|
+
// `average_content_confidence_score`); another vendor mapped onto the shape
|
|
998
|
+
// may give a bare number. The first finite number is the last resort.
|
|
999
|
+
function confidenceOf(value) {
|
|
1000
|
+
if (typeof value === 'number')
|
|
1001
|
+
return finiteNumber(value);
|
|
1002
|
+
if (value && typeof value === 'object') {
|
|
1003
|
+
const scores = value;
|
|
1004
|
+
for (const key of ['average_page_confidence_score', 'average_content_confidence_score', 'average', 'confidence']) {
|
|
1005
|
+
const score = finiteNumber(scores[key]);
|
|
1006
|
+
if (score !== undefined)
|
|
1007
|
+
return score;
|
|
1008
|
+
}
|
|
1009
|
+
for (const inner of Object.values(scores)) {
|
|
1010
|
+
const score = finiteNumber(inner);
|
|
1011
|
+
if (score !== undefined)
|
|
1012
|
+
return score;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return undefined;
|
|
1016
|
+
}
|
|
846
1017
|
// The contract's integration input rules (vendored verbatim from
|
|
847
1018
|
// @fourier-labs/harbour-contracts gen-ts/integration-execute-input-rules.ts;
|
|
848
1019
|
// 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
|
}
|