@apifuse/provider-sdk 2.2.0-beta.22 → 2.2.0-beta.24
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/CHANGELOG.md +8 -0
- package/bin/apifuse-dev.ts +4 -0
- package/bin/apifuse-pack-types.ts +234 -38
- package/bin/apifuse-perf.ts +15 -12
- package/bin/apifuse-record.ts +4 -0
- package/dist/config/loader.d.ts +8 -19
- package/dist/config/loader.js +28 -86
- package/dist/contract-types.d.ts +1 -0
- package/dist/contract.js +2 -0
- package/dist/define.d.ts +5 -1
- package/dist/define.js +79 -6
- package/dist/error-resolution.js +5 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -0
- package/dist/provider.d.ts +1 -1
- package/dist/runtime/auth-flow.d.ts +2 -1
- package/dist/runtime/auth-flow.js +4 -0
- package/dist/runtime/browser.js +78 -9
- package/dist/runtime/http.js +0 -1
- package/dist/runtime/instrumentation.js +26 -1
- package/dist/runtime/ocr.d.ts +29 -0
- package/dist/runtime/ocr.js +440 -0
- package/dist/runtime/resolver-vendors/bindings.d.ts +8 -0
- package/dist/runtime/resolver-vendors/bindings.js +15 -0
- package/dist/runtime/resolver-vendors/browser.d.ts +24 -0
- package/dist/runtime/resolver-vendors/browser.js +287 -0
- package/dist/runtime/resolver-vendors/types.d.ts +42 -0
- package/dist/runtime/resolver-vendors/types.js +57 -0
- package/dist/runtime/resolver.d.ts +39 -0
- package/dist/runtime/resolver.js +414 -0
- package/dist/runtime/state.d.ts +3 -0
- package/dist/runtime/state.js +245 -141
- package/dist/runtime/stealth.js +3 -6
- package/dist/runtime/stt.js +1 -12
- package/dist/runtime/timeout.d.ts +5 -0
- package/dist/runtime/timeout.js +12 -0
- package/dist/server/serve.d.ts +6 -1
- package/dist/server/serve.js +39 -8
- package/dist/testing/run.js +10 -0
- package/dist/types.d.ts +163 -4
- package/package.json +1 -1
- package/src/config/loader.ts +35 -111
- package/src/contract-types.ts +1 -0
- package/src/contract.ts +2 -0
- package/src/define.ts +121 -7
- package/src/error-resolution.ts +5 -0
- package/src/index.ts +45 -1
- package/src/provider.ts +1 -0
- package/src/runtime/auth-flow.ts +6 -0
- package/src/runtime/browser.ts +139 -19
- package/src/runtime/http.ts +0 -1
- package/src/runtime/instrumentation.ts +36 -2
- package/src/runtime/ocr.ts +523 -0
- package/src/runtime/resolver-vendors/bindings.ts +31 -0
- package/src/runtime/resolver-vendors/browser.ts +420 -0
- package/src/runtime/resolver-vendors/types.ts +113 -0
- package/src/runtime/resolver.ts +668 -0
- package/src/runtime/state.ts +323 -166
- package/src/runtime/stealth.ts +3 -6
- package/src/runtime/stt.ts +1 -19
- package/src/runtime/timeout.ts +18 -0
- package/src/server/serve.ts +80 -5
- package/src/testing/run.ts +15 -0
- package/src/types.ts +188 -4
package/dist/runtime/browser.js
CHANGED
|
@@ -357,6 +357,9 @@ class PlaywrightBrowserPage {
|
|
|
357
357
|
async close() {
|
|
358
358
|
await this.page.close();
|
|
359
359
|
}
|
|
360
|
+
async cookies() {
|
|
361
|
+
return (await this.page.context().cookies()).map(toBrowserCookie);
|
|
362
|
+
}
|
|
360
363
|
async withResourcePolicy(policy, run) {
|
|
361
364
|
const allowedMethods = new Set(policy.allowedMethods ?? DEFAULT_RESOURCE_METHODS);
|
|
362
365
|
const handler = async (route) => {
|
|
@@ -457,7 +460,7 @@ function normalizeWebSocketEndpoint(endpoint) {
|
|
|
457
460
|
}
|
|
458
461
|
throw new Error(`Unsupported WebSocket endpoint protocol: ${url.protocol}`);
|
|
459
462
|
}
|
|
460
|
-
class
|
|
463
|
+
class WebSocketCommandClient {
|
|
461
464
|
nextId = 1;
|
|
462
465
|
endpoint;
|
|
463
466
|
listeners = new Map();
|
|
@@ -483,12 +486,7 @@ class JsonRpcWebSocketClient {
|
|
|
483
486
|
const id = this.nextId++;
|
|
484
487
|
return await new Promise((resolve, reject) => {
|
|
485
488
|
this.pending.set(id, { resolve, reject });
|
|
486
|
-
socket.send(JSON.stringify(
|
|
487
|
-
id,
|
|
488
|
-
jsonrpc: "2.0",
|
|
489
|
-
method,
|
|
490
|
-
params,
|
|
491
|
-
}));
|
|
489
|
+
socket.send(JSON.stringify(this.createCommandFrame(id, method, params)));
|
|
492
490
|
});
|
|
493
491
|
}
|
|
494
492
|
async close() {
|
|
@@ -526,7 +524,11 @@ class JsonRpcWebSocketClient {
|
|
|
526
524
|
}
|
|
527
525
|
this.pending.delete(payload.id);
|
|
528
526
|
if (payload.error) {
|
|
529
|
-
|
|
527
|
+
const error = new Error(payload.error.message ?? "JSON-RPC command failed");
|
|
528
|
+
if (typeof payload.error.code === "number") {
|
|
529
|
+
Object.assign(error, { code: payload.error.code });
|
|
530
|
+
}
|
|
531
|
+
pending.reject(error);
|
|
530
532
|
return;
|
|
531
533
|
}
|
|
532
534
|
pending.resolve(payload.result ?? {});
|
|
@@ -554,6 +556,26 @@ class JsonRpcWebSocketClient {
|
|
|
554
556
|
return this.socketPromise;
|
|
555
557
|
}
|
|
556
558
|
}
|
|
559
|
+
class CdpWebSocketClient extends WebSocketCommandClient {
|
|
560
|
+
sessionId;
|
|
561
|
+
constructor(endpoint, sessionId) {
|
|
562
|
+
super(endpoint);
|
|
563
|
+
this.sessionId = sessionId;
|
|
564
|
+
}
|
|
565
|
+
createCommandFrame(id, method, params) {
|
|
566
|
+
return {
|
|
567
|
+
id,
|
|
568
|
+
method,
|
|
569
|
+
params,
|
|
570
|
+
...(this.sessionId === undefined ? {} : { sessionId: this.sessionId }),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
class JsonRpcWebSocketClient extends WebSocketCommandClient {
|
|
575
|
+
createCommandFrame(id, method, params) {
|
|
576
|
+
return { id, jsonrpc: "2.0", method, params };
|
|
577
|
+
}
|
|
578
|
+
}
|
|
557
579
|
function flattenCdpFrameTree(node, out = []) {
|
|
558
580
|
if (!node) {
|
|
559
581
|
return out;
|
|
@@ -567,6 +589,48 @@ function flattenCdpFrameTree(node, out = []) {
|
|
|
567
589
|
function isRecord(value) {
|
|
568
590
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
569
591
|
}
|
|
592
|
+
function isBrowserCookieSameSite(value) {
|
|
593
|
+
return value === "Strict" || value === "Lax" || value === "None";
|
|
594
|
+
}
|
|
595
|
+
function toBrowserCookie(cookie) {
|
|
596
|
+
return {
|
|
597
|
+
name: cookie.name,
|
|
598
|
+
value: cookie.value,
|
|
599
|
+
domain: cookie.domain,
|
|
600
|
+
path: cookie.path,
|
|
601
|
+
...(cookie.expires !== undefined && cookie.expires > 0 ? { expires: cookie.expires } : {}),
|
|
602
|
+
httpOnly: cookie.httpOnly,
|
|
603
|
+
secure: cookie.secure,
|
|
604
|
+
...(isBrowserCookieSameSite(cookie.sameSite) ? { sameSite: cookie.sameSite } : {}),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
function parseCdpCookies(value) {
|
|
608
|
+
if (!Array.isArray(value)) {
|
|
609
|
+
throw new Error("CDP Network.getCookies returned an invalid cookie list");
|
|
610
|
+
}
|
|
611
|
+
return value.map((cookie) => {
|
|
612
|
+
if (!isRecord(cookie) ||
|
|
613
|
+
typeof cookie.name !== "string" ||
|
|
614
|
+
typeof cookie.value !== "string" ||
|
|
615
|
+
typeof cookie.domain !== "string" ||
|
|
616
|
+
typeof cookie.path !== "string" ||
|
|
617
|
+
typeof cookie.expires !== "number" ||
|
|
618
|
+
typeof cookie.httpOnly !== "boolean" ||
|
|
619
|
+
typeof cookie.secure !== "boolean") {
|
|
620
|
+
throw new Error("CDP Network.getCookies returned an invalid cookie");
|
|
621
|
+
}
|
|
622
|
+
return toBrowserCookie({
|
|
623
|
+
name: cookie.name,
|
|
624
|
+
value: cookie.value,
|
|
625
|
+
domain: cookie.domain,
|
|
626
|
+
path: cookie.path,
|
|
627
|
+
expires: cookie.expires,
|
|
628
|
+
httpOnly: cookie.httpOnly,
|
|
629
|
+
secure: cookie.secure,
|
|
630
|
+
sameSite: cookie.sameSite,
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
}
|
|
570
634
|
function parsePoolAcquireResponse(value) {
|
|
571
635
|
if (!isRecord(value) ||
|
|
572
636
|
typeof value.pageId !== "string" ||
|
|
@@ -845,6 +909,11 @@ class CdpPoolBrowserPage {
|
|
|
845
909
|
});
|
|
846
910
|
return Buffer.from(String(result.data ?? ""), "base64");
|
|
847
911
|
}
|
|
912
|
+
async cookies() {
|
|
913
|
+
await this.initialize();
|
|
914
|
+
const result = await this.pageClient.send("Network.getCookies");
|
|
915
|
+
return parseCdpCookies(result.cookies);
|
|
916
|
+
}
|
|
848
917
|
async close() {
|
|
849
918
|
if (this.closed) {
|
|
850
919
|
return;
|
|
@@ -996,7 +1065,7 @@ class CdpPoolBrowserClient {
|
|
|
996
1065
|
...(this.allowedHosts.length > 0 ? { allowedHosts: this.allowedHosts } : {}),
|
|
997
1066
|
...(options?.isolatedContext ? { isolationMode: "browserContext" } : {}),
|
|
998
1067
|
}));
|
|
999
|
-
const pageClient = new
|
|
1068
|
+
const pageClient = new CdpWebSocketClient(acquireResult.wsEndpoint);
|
|
1000
1069
|
const page = new CdpPoolBrowserPage(acquireResult.pageId, acquireResult.browserContextId, pageClient, async (request) => {
|
|
1001
1070
|
await this.poolClient.send("release", request);
|
|
1002
1071
|
});
|
package/dist/runtime/http.js
CHANGED
|
@@ -376,7 +376,6 @@ async function resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffs
|
|
|
376
376
|
const resolvedProxy = await resolveProxyConfigAsync({
|
|
377
377
|
proxy: options.proxy ?? clientOptions.proxy,
|
|
378
378
|
upstream: clientOptions.upstream,
|
|
379
|
-
apifuseConfig: clientOptions.apifuseConfig,
|
|
380
379
|
proxyPolicy: clientOptions.proxyPolicy,
|
|
381
380
|
affinityKey: clientOptions.affinityKey,
|
|
382
381
|
proxyAttempt: computeProxyAttemptIndex({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readableBytes, readableLines, readableTextChunks } from "../stream.js";
|
|
2
2
|
import { parseHttpRequestInvocation, isSensitiveKey, redactSensitiveError, redactSensitiveText, redactUrlQueryParams, requestOptionsFromHttpInvocation, serializeRequestUrl, } from "./request-options.js";
|
|
3
3
|
import { createTraceContext, getTraceRecorder, } from "./trace.js";
|
|
4
|
+
import { RESOLVER_INSTRUMENTATION_METADATA } from "./resolver.js";
|
|
4
5
|
const BROWSER_PAGE_METHODS = new Set(["goto", "fill", "click", "type", "waitForSelector"]);
|
|
5
6
|
const DIAGNOSTIC_BASE_URL = "http://apifuse-instrumentation.invalid";
|
|
6
7
|
function isThenable(value) {
|
|
@@ -420,12 +421,35 @@ function wrapNamespace(namespace, target, trace, shouldInstrument) {
|
|
|
420
421
|
return target;
|
|
421
422
|
}
|
|
422
423
|
const wrappedMethods = new Map();
|
|
424
|
+
const resolverMetadata = namespace === "resolver" ? { target, traceRecorder: recorder } : undefined;
|
|
423
425
|
return new Proxy(target, {
|
|
424
426
|
get(namespaceTarget, property, receiver) {
|
|
427
|
+
if (property === RESOLVER_INSTRUMENTATION_METADATA && resolverMetadata) {
|
|
428
|
+
return resolverMetadata;
|
|
429
|
+
}
|
|
425
430
|
const value = Reflect.get(namespaceTarget, property, receiver);
|
|
426
431
|
if (typeof value !== "function" || property === "constructor") {
|
|
427
432
|
return value;
|
|
428
433
|
}
|
|
434
|
+
if (namespace === "resolver" && property === "solve") {
|
|
435
|
+
if (wrappedMethods.has(property)) {
|
|
436
|
+
return wrappedMethods.get(property);
|
|
437
|
+
}
|
|
438
|
+
const wrapped = (...args) => {
|
|
439
|
+
const challenge = args[0];
|
|
440
|
+
const challengeKind = typeof challenge === "object" &&
|
|
441
|
+
challenge !== null &&
|
|
442
|
+
"kind" in challenge &&
|
|
443
|
+
typeof challenge.kind === "string"
|
|
444
|
+
? challenge.kind
|
|
445
|
+
: undefined;
|
|
446
|
+
return recorder.runSpan("resolver.solve", () => Reflect.apply(value, namespaceTarget, [args[0], args[1], recorder]), {
|
|
447
|
+
attributes: challengeKind ? { challenge_kind: challengeKind } : undefined,
|
|
448
|
+
});
|
|
449
|
+
};
|
|
450
|
+
wrappedMethods.set(property, wrapped);
|
|
451
|
+
return wrapped;
|
|
452
|
+
}
|
|
429
453
|
if (namespace === "browser" && property === "newPage") {
|
|
430
454
|
if (wrappedMethods.has(property)) {
|
|
431
455
|
return wrappedMethods.get(property);
|
|
@@ -573,7 +597,8 @@ export function wrapWithInstrumentation(ctx, options = {}) {
|
|
|
573
597
|
property === "stealth" ||
|
|
574
598
|
property === "browser" ||
|
|
575
599
|
property === "session" ||
|
|
576
|
-
property === "state"
|
|
600
|
+
property === "state" ||
|
|
601
|
+
property === "resolver") {
|
|
577
602
|
const namespace = property;
|
|
578
603
|
if (wrappedTargets.has(namespace)) {
|
|
579
604
|
return wrappedTargets.get(namespace);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { OcrCaptchaCandidate, OcrCaptchaOptions, OcrContext, ProviderOcrConfig } from "../types.js";
|
|
2
|
+
export { CLOUDFLARE_ACCOUNT_ID_ENV } from "./stt.js";
|
|
3
|
+
export declare const APIFUSE__OCR__BACKEND_ENV = "APIFUSE__OCR__BACKEND";
|
|
4
|
+
export declare const APIFUSE__OCR__MODEL_ENV = "APIFUSE__OCR__MODEL";
|
|
5
|
+
export declare const APIFUSE__OCR__CLOUDFLARE_API_TOKEN_ENV = "APIFUSE__OCR__CLOUDFLARE_API_TOKEN";
|
|
6
|
+
export declare const APIFUSE__OCR__BASE_URL_ENV = "APIFUSE__OCR__BASE_URL";
|
|
7
|
+
export declare const APIFUSE__OCR__API_KEY_ENV = "APIFUSE__OCR__API_KEY";
|
|
8
|
+
export declare const CLOUDFLARE_WORKERS_AI_OCR_BACKEND = "cloudflare-workers-ai";
|
|
9
|
+
export declare const OPENAI_COMPATIBLE_OCR_BACKEND = "openai-compatible";
|
|
10
|
+
export declare const DEFAULT_CLOUDFLARE_WORKERS_AI_OCR_MODEL = "@cf/google/gemma-4-26b-a4b-it";
|
|
11
|
+
export declare const DEFAULT_OCR_TIMEOUT_MS = 30000;
|
|
12
|
+
type EnvLike = Record<string, string | undefined>;
|
|
13
|
+
type CloudflareWorkersAiOcrClientOptions = {
|
|
14
|
+
accountId: string;
|
|
15
|
+
apiToken: string;
|
|
16
|
+
model?: string;
|
|
17
|
+
fetch?: typeof fetch;
|
|
18
|
+
};
|
|
19
|
+
type OpenAiCompatibleOcrClientOptions = {
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
apiKey?: string;
|
|
22
|
+
model: string;
|
|
23
|
+
fetch?: typeof fetch;
|
|
24
|
+
};
|
|
25
|
+
export declare function createUnsupportedOcrClient(reason?: string): OcrContext;
|
|
26
|
+
export declare function createOcrClientFromEnv(config: ProviderOcrConfig | undefined, env?: EnvLike): OcrContext;
|
|
27
|
+
export declare function createCloudflareWorkersAiOcrClient(options: CloudflareWorkersAiOcrClientOptions): OcrContext;
|
|
28
|
+
export declare function createOpenAiCompatibleOcrClient(options: OpenAiCompatibleOcrClientOptions): OcrContext;
|
|
29
|
+
export declare function extractCaptchaCandidates(modelText: string, options?: OcrCaptchaOptions): readonly OcrCaptchaCandidate[];
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { ProviderError, TransportError } from "../errors.js";
|
|
2
|
+
import { CLOUDFLARE_ACCOUNT_ID_ENV } from "./stt.js";
|
|
3
|
+
import { createTimeoutController, isTimeoutLikeError } from "./timeout.js";
|
|
4
|
+
export { CLOUDFLARE_ACCOUNT_ID_ENV } from "./stt.js";
|
|
5
|
+
export const APIFUSE__OCR__BACKEND_ENV = "APIFUSE__OCR__BACKEND";
|
|
6
|
+
export const APIFUSE__OCR__MODEL_ENV = "APIFUSE__OCR__MODEL";
|
|
7
|
+
export const APIFUSE__OCR__CLOUDFLARE_API_TOKEN_ENV = "APIFUSE__OCR__CLOUDFLARE_API_TOKEN";
|
|
8
|
+
export const APIFUSE__OCR__BASE_URL_ENV = "APIFUSE__OCR__BASE_URL";
|
|
9
|
+
export const APIFUSE__OCR__API_KEY_ENV = "APIFUSE__OCR__API_KEY";
|
|
10
|
+
export const CLOUDFLARE_WORKERS_AI_OCR_BACKEND = "cloudflare-workers-ai";
|
|
11
|
+
export const OPENAI_COMPATIBLE_OCR_BACKEND = "openai-compatible";
|
|
12
|
+
export const DEFAULT_CLOUDFLARE_WORKERS_AI_OCR_MODEL = "@cf/google/gemma-4-26b-a4b-it";
|
|
13
|
+
export const DEFAULT_OCR_TIMEOUT_MS = 30_000;
|
|
14
|
+
const DEFAULT_CAPTCHA_MAX_TOKENS = 64;
|
|
15
|
+
const DEFAULT_DOCUMENT_MAX_TOKENS = 4_096;
|
|
16
|
+
const KIMI_MIN_MAX_TOKENS = 3_000;
|
|
17
|
+
const DEFAULT_MAX_CAPTCHA_CANDIDATES = 3;
|
|
18
|
+
const MAX_HOMOGLYPH_SEARCH_NODES = 512;
|
|
19
|
+
const HOMOGLYPH_CLASSES = [
|
|
20
|
+
["I", "l", "1", "i"],
|
|
21
|
+
["O", "0", "o"],
|
|
22
|
+
["S", "5", "s"],
|
|
23
|
+
["Z", "2", "z"],
|
|
24
|
+
["B", "8"],
|
|
25
|
+
];
|
|
26
|
+
const HINT_PROMPTS = {
|
|
27
|
+
captcha: "Read the CAPTCHA image. Return only the characters, with no explanation. Preserve character case.",
|
|
28
|
+
document: "Transcribe all visible text in the document image.",
|
|
29
|
+
generic: "Read and return the visible text in this image.",
|
|
30
|
+
};
|
|
31
|
+
function providerError(message, options) {
|
|
32
|
+
return new ProviderError(message, options);
|
|
33
|
+
}
|
|
34
|
+
function createErrorOcrClient(options) {
|
|
35
|
+
const unavailable = () => {
|
|
36
|
+
throw providerError(options.message, {
|
|
37
|
+
code: options.code,
|
|
38
|
+
fix: options.fix,
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
async recognize() {
|
|
43
|
+
return unavailable();
|
|
44
|
+
},
|
|
45
|
+
async extractCaptchaText() {
|
|
46
|
+
return unavailable();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function createUnsupportedOcrClient(reason) {
|
|
51
|
+
return createErrorOcrClient({
|
|
52
|
+
code: "OCR_UNAVAILABLE",
|
|
53
|
+
message: reason ?? "OCR runtime is not configured",
|
|
54
|
+
fix: `Configure ${APIFUSE__OCR__BACKEND_ENV} and ${APIFUSE__OCR__MODEL_ENV}; Cloudflare uses ${CLOUDFLARE_ACCOUNT_ID_ENV} and ${APIFUSE__OCR__CLOUDFLARE_API_TOKEN_ENV}, while openai-compatible uses ${APIFUSE__OCR__BASE_URL_ENV} and optional ${APIFUSE__OCR__API_KEY_ENV}. Alternatively, provide a test OcrContext override.`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function normalizedEnvValue(env, key) {
|
|
58
|
+
const value = env[key]?.trim();
|
|
59
|
+
return value ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
export function createOcrClientFromEnv(config, env = process.env) {
|
|
62
|
+
if (!config) {
|
|
63
|
+
return createUnsupportedOcrClient("Provider does not declare OCR capability");
|
|
64
|
+
}
|
|
65
|
+
const backend = normalizedEnvValue(env, APIFUSE__OCR__BACKEND_ENV) ?? CLOUDFLARE_WORKERS_AI_OCR_BACKEND;
|
|
66
|
+
const configuredModel = normalizedEnvValue(env, APIFUSE__OCR__MODEL_ENV);
|
|
67
|
+
if (backend === CLOUDFLARE_WORKERS_AI_OCR_BACKEND) {
|
|
68
|
+
const model = configuredModel ?? DEFAULT_CLOUDFLARE_WORKERS_AI_OCR_MODEL;
|
|
69
|
+
const accountId = normalizedEnvValue(env, CLOUDFLARE_ACCOUNT_ID_ENV);
|
|
70
|
+
const apiToken = normalizedEnvValue(env, APIFUSE__OCR__CLOUDFLARE_API_TOKEN_ENV);
|
|
71
|
+
if (!accountId || !apiToken) {
|
|
72
|
+
return createUnsupportedOcrClient(`OCR backend ${backend} requires ${CLOUDFLARE_ACCOUNT_ID_ENV} and ${APIFUSE__OCR__CLOUDFLARE_API_TOKEN_ENV}`);
|
|
73
|
+
}
|
|
74
|
+
return createCloudflareWorkersAiOcrClient({ accountId, apiToken, model });
|
|
75
|
+
}
|
|
76
|
+
if (backend === OPENAI_COMPATIBLE_OCR_BACKEND) {
|
|
77
|
+
const baseUrl = normalizedEnvValue(env, APIFUSE__OCR__BASE_URL_ENV);
|
|
78
|
+
if (!baseUrl) {
|
|
79
|
+
return createUnsupportedOcrClient(`OCR backend ${backend} requires ${APIFUSE__OCR__BASE_URL_ENV}`);
|
|
80
|
+
}
|
|
81
|
+
if (!configuredModel) {
|
|
82
|
+
return createErrorOcrClient({
|
|
83
|
+
code: "OCR_UNAVAILABLE",
|
|
84
|
+
message: `OCR backend ${backend} requires ${APIFUSE__OCR__MODEL_ENV}`,
|
|
85
|
+
fix: `Set ${APIFUSE__OCR__MODEL_ENV} to the exact model ID served by your self-hosted endpoint, for example ${APIFUSE__OCR__MODEL_ENV}=zai-org/GLM-OCR.`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return createOpenAiCompatibleOcrClient({
|
|
89
|
+
baseUrl,
|
|
90
|
+
apiKey: normalizedEnvValue(env, APIFUSE__OCR__API_KEY_ENV),
|
|
91
|
+
model: configuredModel,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return createErrorOcrClient({
|
|
95
|
+
code: "UNSUPPORTED_OCR_BACKEND",
|
|
96
|
+
message: `Unsupported OCR backend "${backend}"`,
|
|
97
|
+
fix: `Use ${APIFUSE__OCR__BACKEND_ENV}=${CLOUDFLARE_WORKERS_AI_OCR_BACKEND} or ${APIFUSE__OCR__BACKEND_ENV}=${OPENAI_COMPATIBLE_OCR_BACKEND}, or provide a custom OcrContext override.`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function unknownRecord(value) {
|
|
101
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
102
|
+
return undefined;
|
|
103
|
+
return Object.fromEntries(Object.entries(value));
|
|
104
|
+
}
|
|
105
|
+
function imageUrl(image) {
|
|
106
|
+
if (image.kind === "url")
|
|
107
|
+
return image.url.trim();
|
|
108
|
+
return `data:${image.mediaType?.trim() || "image/png"};base64,${image.data.trim()}`;
|
|
109
|
+
}
|
|
110
|
+
function resolvePrompt(request) {
|
|
111
|
+
return request.prompt ?? HINT_PROMPTS[request.hint ?? "generic"];
|
|
112
|
+
}
|
|
113
|
+
function isGemmaModel(model) {
|
|
114
|
+
return model.toLowerCase().includes("gemma");
|
|
115
|
+
}
|
|
116
|
+
function isKimiModel(model) {
|
|
117
|
+
return model.toLowerCase().includes("kimi");
|
|
118
|
+
}
|
|
119
|
+
function isMoondreamModel(model) {
|
|
120
|
+
return model.toLowerCase().includes("moondream");
|
|
121
|
+
}
|
|
122
|
+
function defaultMaxTokens(request) {
|
|
123
|
+
return request.hint === "captcha" ? DEFAULT_CAPTCHA_MAX_TOKENS : DEFAULT_DOCUMENT_MAX_TOKENS;
|
|
124
|
+
}
|
|
125
|
+
function resolvedMaxTokens(request, model = "") {
|
|
126
|
+
const requestedMaxTokens = request.maxTokens ?? defaultMaxTokens(request);
|
|
127
|
+
return isKimiModel(model)
|
|
128
|
+
? Math.max(requestedMaxTokens, KIMI_MIN_MAX_TOKENS)
|
|
129
|
+
: requestedMaxTokens;
|
|
130
|
+
}
|
|
131
|
+
function messagesPayload(request, model) {
|
|
132
|
+
const payload = {
|
|
133
|
+
...(model ? { model } : {}),
|
|
134
|
+
messages: [
|
|
135
|
+
{
|
|
136
|
+
role: "user",
|
|
137
|
+
content: [
|
|
138
|
+
{ type: "text", text: resolvePrompt(request) },
|
|
139
|
+
{ type: "image_url", image_url: { url: imageUrl(request.image) } },
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
max_tokens: resolvedMaxTokens(request, model),
|
|
144
|
+
temperature: 0,
|
|
145
|
+
};
|
|
146
|
+
if (isGemmaModel(model ?? "")) {
|
|
147
|
+
payload.chat_template_kwargs = { enable_thinking: false };
|
|
148
|
+
}
|
|
149
|
+
return payload;
|
|
150
|
+
}
|
|
151
|
+
function cloudflareMessagesPayload(request, model) {
|
|
152
|
+
const payload = messagesPayload(request, model);
|
|
153
|
+
delete payload.model;
|
|
154
|
+
return payload;
|
|
155
|
+
}
|
|
156
|
+
function moondreamPayload(request) {
|
|
157
|
+
return {
|
|
158
|
+
task: "query",
|
|
159
|
+
image: imageUrl(request.image),
|
|
160
|
+
question: resolvePrompt(request),
|
|
161
|
+
stream: true,
|
|
162
|
+
reasoning: false,
|
|
163
|
+
temperature: 0,
|
|
164
|
+
max_tokens: resolvedMaxTokens(request),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function incompleteResponseError(model, finishReason) {
|
|
168
|
+
return new TransportError(`OCR model "${model}" did not complete normally (finish_reason: ${finishReason})`, {
|
|
169
|
+
code: "OCR_INCOMPLETE_RESPONSE",
|
|
170
|
+
status: 502,
|
|
171
|
+
details: { finishReason },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function responseContent(payload, cloudflare, model) {
|
|
175
|
+
const envelope = unknownRecord(payload);
|
|
176
|
+
const root = cloudflare ? unknownRecord(envelope?.result) : envelope;
|
|
177
|
+
const choices = root?.choices;
|
|
178
|
+
if (!Array.isArray(choices))
|
|
179
|
+
return undefined;
|
|
180
|
+
const choice = unknownRecord(choices[0]);
|
|
181
|
+
if (typeof choice?.finish_reason === "string" && choice.finish_reason !== "stop") {
|
|
182
|
+
throw incompleteResponseError(model, choice.finish_reason);
|
|
183
|
+
}
|
|
184
|
+
const message = unknownRecord(choice?.message);
|
|
185
|
+
return typeof message?.content === "string" ? message.content.trim() || undefined : undefined;
|
|
186
|
+
}
|
|
187
|
+
function malformedResponseError(model, cause) {
|
|
188
|
+
return new TransportError(`OCR model "${model}" returned a malformed response`, {
|
|
189
|
+
code: "OCR_UPSTREAM_FAILED",
|
|
190
|
+
status: 502,
|
|
191
|
+
cause,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
async function responseJson(response, model) {
|
|
195
|
+
try {
|
|
196
|
+
return await response.json();
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (isTimeoutLikeError(error))
|
|
200
|
+
throw toOcrTransportError(error);
|
|
201
|
+
throw malformedResponseError(model, error instanceof Error ? error : new Error("Failed to decode OCR response JSON"));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function moondreamSseContent(body, model) {
|
|
205
|
+
let finalAnswer;
|
|
206
|
+
let firstDecodingFailure;
|
|
207
|
+
let terminalFinishReason;
|
|
208
|
+
for (const line of body.split(/\r?\n/u)) {
|
|
209
|
+
const trimmed = line.trim();
|
|
210
|
+
if (!trimmed.startsWith("data:"))
|
|
211
|
+
continue;
|
|
212
|
+
const data = trimmed.slice("data:".length).trim();
|
|
213
|
+
if (!data || data === "[DONE]")
|
|
214
|
+
continue;
|
|
215
|
+
let parsed;
|
|
216
|
+
try {
|
|
217
|
+
parsed = JSON.parse(data);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
firstDecodingFailure ??=
|
|
221
|
+
error instanceof Error ? error : new Error("Failed to decode OCR SSE event");
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const event = unknownRecord(parsed);
|
|
225
|
+
const chunk = unknownRecord(event?.chunk) ?? unknownRecord(event?.result) ?? event;
|
|
226
|
+
if (chunk?.finish_reason === "stop" &&
|
|
227
|
+
typeof chunk.answer === "string" &&
|
|
228
|
+
chunk.answer.trim()) {
|
|
229
|
+
finalAnswer = chunk.answer.trim();
|
|
230
|
+
}
|
|
231
|
+
else if (typeof chunk?.finish_reason === "string") {
|
|
232
|
+
terminalFinishReason = chunk.finish_reason;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (finalAnswer)
|
|
236
|
+
return finalAnswer;
|
|
237
|
+
if (terminalFinishReason)
|
|
238
|
+
throw incompleteResponseError(model, terminalFinishReason);
|
|
239
|
+
if (firstDecodingFailure)
|
|
240
|
+
throw malformedResponseError(model, firstDecodingFailure);
|
|
241
|
+
return finalAnswer;
|
|
242
|
+
}
|
|
243
|
+
function emptyResponseError(model) {
|
|
244
|
+
return new TransportError(`OCR model "${model}" returned no usable text`, {
|
|
245
|
+
code: "OCR_UPSTREAM_FAILED",
|
|
246
|
+
status: 502,
|
|
247
|
+
fix: `Verify ${APIFUSE__OCR__MODEL_ENV}="${model}" supports image input and the runtime calling convention for that model.`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
function toOcrTransportError(error) {
|
|
251
|
+
if (error instanceof TransportError)
|
|
252
|
+
return error;
|
|
253
|
+
if (isTimeoutLikeError(error)) {
|
|
254
|
+
return new TransportError("OCR upstream request timed out", {
|
|
255
|
+
code: "transport_timeout",
|
|
256
|
+
status: 0,
|
|
257
|
+
cause: error,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return new TransportError("OCR upstream network request failed", {
|
|
261
|
+
code: "transport_network_error",
|
|
262
|
+
status: 0,
|
|
263
|
+
cause: error instanceof Error ? error : undefined,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function createOcrClient(model, recognize) {
|
|
267
|
+
return {
|
|
268
|
+
recognize,
|
|
269
|
+
async extractCaptchaText(image, options = {}) {
|
|
270
|
+
const result = await recognize({ image, hint: "captcha" });
|
|
271
|
+
const candidates = extractCaptchaCandidates(result.text, options);
|
|
272
|
+
const primary = candidates[0];
|
|
273
|
+
if (!primary?.text)
|
|
274
|
+
throw emptyResponseError(model);
|
|
275
|
+
return {
|
|
276
|
+
text: primary.text,
|
|
277
|
+
candidates,
|
|
278
|
+
satisfiesConstraints: primary.satisfiesConstraints,
|
|
279
|
+
model: result.model,
|
|
280
|
+
};
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
export function createCloudflareWorkersAiOcrClient(options) {
|
|
285
|
+
const model = options.model ?? DEFAULT_CLOUDFLARE_WORKERS_AI_OCR_MODEL;
|
|
286
|
+
const runFetch = options.fetch ?? fetch;
|
|
287
|
+
return createOcrClient(model, async (request) => {
|
|
288
|
+
const timeout = createTimeoutController(request.timeoutMs ?? DEFAULT_OCR_TIMEOUT_MS);
|
|
289
|
+
try {
|
|
290
|
+
let response;
|
|
291
|
+
try {
|
|
292
|
+
response = await runFetch(`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(options.accountId)}/ai/run/${model}`, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers: {
|
|
295
|
+
Authorization: `Bearer ${options.apiToken}`,
|
|
296
|
+
"Content-Type": "application/json",
|
|
297
|
+
},
|
|
298
|
+
body: JSON.stringify(isMoondreamModel(model)
|
|
299
|
+
? moondreamPayload(request)
|
|
300
|
+
: cloudflareMessagesPayload(request, model)),
|
|
301
|
+
signal: timeout.controller.signal,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
throw toOcrTransportError(error);
|
|
306
|
+
}
|
|
307
|
+
if (!response.ok) {
|
|
308
|
+
throw new TransportError("OCR upstream request failed", {
|
|
309
|
+
code: "OCR_UPSTREAM_FAILED",
|
|
310
|
+
status: response.status,
|
|
311
|
+
upstreamStatus: response.status,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const text = isMoondreamModel(model)
|
|
315
|
+
? moondreamSseContent(await response.text(), model)
|
|
316
|
+
: responseContent(await responseJson(response, model), true, model);
|
|
317
|
+
if (!text)
|
|
318
|
+
throw emptyResponseError(model);
|
|
319
|
+
return { text, model };
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
throw toOcrTransportError(error);
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
timeout.clear();
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
export function createOpenAiCompatibleOcrClient(options) {
|
|
330
|
+
const model = options.model;
|
|
331
|
+
const runFetch = options.fetch ?? fetch;
|
|
332
|
+
return createOcrClient(model, async (request) => {
|
|
333
|
+
const headers = { "Content-Type": "application/json" };
|
|
334
|
+
if (options.apiKey)
|
|
335
|
+
headers.Authorization = `Bearer ${options.apiKey}`;
|
|
336
|
+
const timeout = createTimeoutController(request.timeoutMs ?? DEFAULT_OCR_TIMEOUT_MS);
|
|
337
|
+
try {
|
|
338
|
+
let response;
|
|
339
|
+
try {
|
|
340
|
+
response = await runFetch(`${options.baseUrl.replace(/\/+$/u, "")}/chat/completions`, {
|
|
341
|
+
method: "POST",
|
|
342
|
+
headers,
|
|
343
|
+
body: JSON.stringify(messagesPayload(request, model)),
|
|
344
|
+
signal: timeout.controller.signal,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
throw toOcrTransportError(error);
|
|
349
|
+
}
|
|
350
|
+
if (!response.ok) {
|
|
351
|
+
throw new TransportError("OCR upstream request failed", {
|
|
352
|
+
code: "OCR_UPSTREAM_FAILED",
|
|
353
|
+
status: response.status,
|
|
354
|
+
upstreamStatus: response.status,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
const text = responseContent(await responseJson(response, model), false, model);
|
|
358
|
+
if (!text)
|
|
359
|
+
throw emptyResponseError(model);
|
|
360
|
+
return { text, model };
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
throw toOcrTransportError(error);
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
timeout.clear();
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
function cleanCaptchaText(text) {
|
|
371
|
+
const afterColon = text.slice(text.lastIndexOf(":") + 1).trim();
|
|
372
|
+
return afterColon.replace(/^[\s\p{P}\p{S}]+|[\s\p{P}\p{S}]+$/gu, "");
|
|
373
|
+
}
|
|
374
|
+
function caseFold(value, caseSensitive) {
|
|
375
|
+
return caseSensitive ? value : value.toLocaleLowerCase();
|
|
376
|
+
}
|
|
377
|
+
function matchesCharset(text, charset, caseSensitive) {
|
|
378
|
+
if (charset === undefined)
|
|
379
|
+
return true;
|
|
380
|
+
if (typeof charset === "string") {
|
|
381
|
+
const allowed = new Set([...caseFold(charset, caseSensitive)]);
|
|
382
|
+
return [...caseFold(text, caseSensitive)].every((character) => allowed.has(character));
|
|
383
|
+
}
|
|
384
|
+
const flags = caseSensitive || charset.flags.includes("i") ? charset.flags : `${charset.flags}i`;
|
|
385
|
+
const matcher = new RegExp(charset.source, flags);
|
|
386
|
+
return [...text].every((character) => {
|
|
387
|
+
matcher.lastIndex = 0;
|
|
388
|
+
return matcher.test(character);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
function satisfiesCaptchaConstraints(text, options) {
|
|
392
|
+
const lengthMatches = options.length === undefined || [...text].length === options.length;
|
|
393
|
+
return lengthMatches && matchesCharset(text, options.charset, options.caseSensitive !== false);
|
|
394
|
+
}
|
|
395
|
+
function homoglyphAlternatives(character) {
|
|
396
|
+
const group = HOMOGLYPH_CLASSES.find((characters) => characters.includes(character));
|
|
397
|
+
return group?.filter((alternative) => alternative !== character) ?? [];
|
|
398
|
+
}
|
|
399
|
+
export function extractCaptchaCandidates(modelText, options = {}) {
|
|
400
|
+
const primaryText = cleanCaptchaText(modelText);
|
|
401
|
+
const primary = {
|
|
402
|
+
text: primaryText,
|
|
403
|
+
satisfiesConstraints: satisfiesCaptchaConstraints(primaryText, options),
|
|
404
|
+
};
|
|
405
|
+
const requestedMaxCandidates = options.maxCandidates ?? DEFAULT_MAX_CAPTCHA_CANDIDATES;
|
|
406
|
+
const maxCandidates = Number.isFinite(requestedMaxCandidates)
|
|
407
|
+
? Math.max(1, Math.floor(requestedMaxCandidates))
|
|
408
|
+
: DEFAULT_MAX_CAPTCHA_CANDIDATES;
|
|
409
|
+
if (primary.satisfiesConstraints || maxCandidates === 1)
|
|
410
|
+
return [primary];
|
|
411
|
+
if (options.length !== undefined && [...primaryText].length !== options.length)
|
|
412
|
+
return [primary];
|
|
413
|
+
const candidates = [primary];
|
|
414
|
+
const queue = [primaryText];
|
|
415
|
+
const visited = new Set(queue);
|
|
416
|
+
for (let index = 0; index < queue.length && candidates.length < maxCandidates; index += 1) {
|
|
417
|
+
const current = queue[index] ?? "";
|
|
418
|
+
const characters = [...current];
|
|
419
|
+
for (let position = 0; position < characters.length; position += 1) {
|
|
420
|
+
const character = characters[position] ?? "";
|
|
421
|
+
for (const alternative of homoglyphAlternatives(character)) {
|
|
422
|
+
const nextCharacters = [...characters];
|
|
423
|
+
nextCharacters[position] = alternative;
|
|
424
|
+
const next = nextCharacters.join("");
|
|
425
|
+
if (visited.has(next))
|
|
426
|
+
continue;
|
|
427
|
+
visited.add(next);
|
|
428
|
+
queue.push(next);
|
|
429
|
+
if (satisfiesCaptchaConstraints(next, options)) {
|
|
430
|
+
candidates.push({ text: next, satisfiesConstraints: true });
|
|
431
|
+
if (candidates.length >= maxCandidates)
|
|
432
|
+
return candidates;
|
|
433
|
+
}
|
|
434
|
+
if (visited.size >= MAX_HOMOGLYPH_SEARCH_NODES)
|
|
435
|
+
return candidates;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return candidates;
|
|
440
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ProviderChallenge } from "../../types.js";
|
|
2
|
+
import type { ResolverIssuingIdentity } from "./types.js";
|
|
3
|
+
export declare const RESOLVER_CHALLENGE_BINDINGS: {
|
|
4
|
+
readonly aws_waf: "portable";
|
|
5
|
+
readonly cloudflare_interstitial: "identity_scoped";
|
|
6
|
+
};
|
|
7
|
+
export declare function resolverChallengeIsIdentityScoped(challenge: ProviderChallenge): boolean;
|
|
8
|
+
export declare function resolverChallengeIssuingIdentity(challenge: ProviderChallenge, identity: ResolverIssuingIdentity): ResolverIssuingIdentity;
|