@apifuse/provider-sdk 2.2.0-beta.21 → 2.2.0-beta.23

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.
@@ -42,19 +42,35 @@ type PoolReleaseRequest = {
42
42
  pageId: string;
43
43
  };
44
44
 
45
- type JsonRpcId = number;
45
+ type WebSocketCommandId = number;
46
46
 
47
- type JsonRpcError = {
47
+ type WebSocketCommandError = {
48
48
  code?: number;
49
49
  message?: string;
50
50
  };
51
51
 
52
- type JsonRpcMessage = {
53
- error?: JsonRpcError;
54
- id?: JsonRpcId;
52
+ type WebSocketCommandMessage = {
53
+ error?: WebSocketCommandError;
54
+ id?: WebSocketCommandId;
55
+ jsonrpc?: "2.0";
55
56
  method?: string;
56
57
  params?: Record<string, unknown>;
57
58
  result?: Record<string, unknown>;
59
+ sessionId?: string;
60
+ };
61
+
62
+ type CdpCommandFrame = {
63
+ id: WebSocketCommandId;
64
+ method: string;
65
+ params: Record<string, unknown>;
66
+ sessionId?: string;
67
+ };
68
+
69
+ type JsonRpcCommandFrame = {
70
+ id: WebSocketCommandId;
71
+ jsonrpc: "2.0";
72
+ method: string;
73
+ params: Record<string, unknown>;
58
74
  };
59
75
 
60
76
  type CdpFrameTreeNode = {
@@ -641,12 +657,12 @@ function normalizeWebSocketEndpoint(endpoint: string): string {
641
657
  throw new Error(`Unsupported WebSocket endpoint protocol: ${url.protocol}`);
642
658
  }
643
659
 
644
- class JsonRpcWebSocketClient {
660
+ abstract class WebSocketCommandClient {
645
661
  private nextId = 1;
646
662
  private readonly endpoint: string;
647
663
  private readonly listeners = new Map<string, Set<(params: unknown) => void>>();
648
664
  private readonly pending = new Map<
649
- JsonRpcId,
665
+ WebSocketCommandId,
650
666
  {
651
667
  reject: (reason?: unknown) => void;
652
668
  resolve: (value: Record<string, unknown>) => void;
@@ -682,14 +698,7 @@ class JsonRpcWebSocketClient {
682
698
  return await new Promise<Record<string, unknown>>((resolve, reject) => {
683
699
  this.pending.set(id, { resolve, reject });
684
700
 
685
- socket.send(
686
- JSON.stringify({
687
- id,
688
- jsonrpc: "2.0",
689
- method,
690
- params,
691
- }),
692
- );
701
+ socket.send(JSON.stringify(this.createCommandFrame(id, method, params)));
693
702
  });
694
703
  }
695
704
 
@@ -705,6 +714,12 @@ class JsonRpcWebSocketClient {
705
714
  this.socketPromise = undefined;
706
715
  }
707
716
 
717
+ protected abstract createCommandFrame(
718
+ id: WebSocketCommandId,
719
+ method: string,
720
+ params: Record<string, unknown>,
721
+ ): CdpCommandFrame | JsonRpcCommandFrame;
722
+
708
723
  private async getSocket(): Promise<WebSocket> {
709
724
  if (this.socket?.readyState === WebSocket.OPEN) {
710
725
  return this.socket;
@@ -727,7 +742,7 @@ class JsonRpcWebSocketClient {
727
742
  typeof event.data === "string"
728
743
  ? event.data
729
744
  : Buffer.from(event.data as ArrayBufferLike).toString("utf8");
730
- const payload = JSON.parse(rawData) as JsonRpcMessage;
745
+ const payload = JSON.parse(rawData) as WebSocketCommandMessage;
731
746
 
732
747
  if (typeof payload.id === "number") {
733
748
  const pending = this.pending.get(payload.id);
@@ -738,7 +753,11 @@ class JsonRpcWebSocketClient {
738
753
  this.pending.delete(payload.id);
739
754
 
740
755
  if (payload.error) {
741
- pending.reject(new Error(payload.error.message ?? "JSON-RPC command failed"));
756
+ const error = new Error(payload.error.message ?? "JSON-RPC command failed");
757
+ if (typeof payload.error.code === "number") {
758
+ Object.assign(error, { code: payload.error.code });
759
+ }
760
+ pending.reject(error);
742
761
  return;
743
762
  }
744
763
 
@@ -774,6 +793,38 @@ class JsonRpcWebSocketClient {
774
793
  }
775
794
  }
776
795
 
796
+ class CdpWebSocketClient extends WebSocketCommandClient {
797
+ constructor(
798
+ endpoint: string,
799
+ private readonly sessionId?: string,
800
+ ) {
801
+ super(endpoint);
802
+ }
803
+
804
+ protected createCommandFrame(
805
+ id: WebSocketCommandId,
806
+ method: string,
807
+ params: Record<string, unknown>,
808
+ ): CdpCommandFrame {
809
+ return {
810
+ id,
811
+ method,
812
+ params,
813
+ ...(this.sessionId === undefined ? {} : { sessionId: this.sessionId }),
814
+ };
815
+ }
816
+ }
817
+
818
+ class JsonRpcWebSocketClient extends WebSocketCommandClient {
819
+ protected createCommandFrame(
820
+ id: WebSocketCommandId,
821
+ method: string,
822
+ params: Record<string, unknown>,
823
+ ): JsonRpcCommandFrame {
824
+ return { id, jsonrpc: "2.0", method, params };
825
+ }
826
+ }
827
+
777
828
  function flattenCdpFrameTree(
778
829
  node: CdpFrameTreeNode | undefined,
779
830
  out: CdpFrameTreeNode["frame"][] = [],
@@ -974,7 +1025,7 @@ class CdpPoolBrowserPage implements BrowserPageContract {
974
1025
  constructor(
975
1026
  readonly pageId: string,
976
1027
  private readonly browserContextId: string | undefined,
977
- private readonly pageClient: JsonRpcWebSocketClient,
1028
+ private readonly pageClient: CdpWebSocketClient,
978
1029
  private readonly release: (request: PoolReleaseRequest) => Promise<void>,
979
1030
  ) {}
980
1031
 
@@ -1335,7 +1386,7 @@ class CdpPoolBrowserClient implements SupportedBrowserClient {
1335
1386
  ...(options?.isolatedContext ? { isolationMode: "browserContext" } : {}),
1336
1387
  }),
1337
1388
  );
1338
- const pageClient = new JsonRpcWebSocketClient(acquireResult.wsEndpoint);
1389
+ const pageClient = new CdpWebSocketClient(acquireResult.wsEndpoint);
1339
1390
  const page = new CdpPoolBrowserPage(
1340
1391
  acquireResult.pageId,
1341
1392
  acquireResult.browserContextId,
@@ -0,0 +1,523 @@
1
+ import { ProviderError, TransportError } from "../errors.js";
2
+ import type {
3
+ OcrCaptchaCandidate,
4
+ OcrCaptchaOptions,
5
+ OcrCaptchaResult,
6
+ OcrContext,
7
+ OcrImageInput,
8
+ OcrRecognizeRequest,
9
+ OcrResult,
10
+ ProviderOcrConfig,
11
+ } from "../types.js";
12
+ import { CLOUDFLARE_ACCOUNT_ID_ENV } from "./stt.js";
13
+ import { createTimeoutController, isTimeoutLikeError } from "./timeout.js";
14
+
15
+ export { CLOUDFLARE_ACCOUNT_ID_ENV } from "./stt.js";
16
+
17
+ export const APIFUSE__OCR__BACKEND_ENV = "APIFUSE__OCR__BACKEND";
18
+ export const APIFUSE__OCR__MODEL_ENV = "APIFUSE__OCR__MODEL";
19
+ export const APIFUSE__OCR__CLOUDFLARE_API_TOKEN_ENV = "APIFUSE__OCR__CLOUDFLARE_API_TOKEN";
20
+ export const APIFUSE__OCR__BASE_URL_ENV = "APIFUSE__OCR__BASE_URL";
21
+ export const APIFUSE__OCR__API_KEY_ENV = "APIFUSE__OCR__API_KEY";
22
+ export const CLOUDFLARE_WORKERS_AI_OCR_BACKEND = "cloudflare-workers-ai";
23
+ export const OPENAI_COMPATIBLE_OCR_BACKEND = "openai-compatible";
24
+ export const DEFAULT_CLOUDFLARE_WORKERS_AI_OCR_MODEL = "@cf/google/gemma-4-26b-a4b-it";
25
+
26
+ export const DEFAULT_OCR_TIMEOUT_MS = 30_000;
27
+ const DEFAULT_CAPTCHA_MAX_TOKENS = 64;
28
+ const DEFAULT_DOCUMENT_MAX_TOKENS = 4_096;
29
+ const KIMI_MIN_MAX_TOKENS = 3_000;
30
+ const DEFAULT_MAX_CAPTCHA_CANDIDATES = 3;
31
+ const MAX_HOMOGLYPH_SEARCH_NODES = 512;
32
+ const HOMOGLYPH_CLASSES: readonly (readonly string[])[] = [
33
+ ["I", "l", "1", "i"],
34
+ ["O", "0", "o"],
35
+ ["S", "5", "s"],
36
+ ["Z", "2", "z"],
37
+ ["B", "8"],
38
+ ];
39
+
40
+ const HINT_PROMPTS = {
41
+ captcha:
42
+ "Read the CAPTCHA image. Return only the characters, with no explanation. Preserve character case.",
43
+ document: "Transcribe all visible text in the document image.",
44
+ generic: "Read and return the visible text in this image.",
45
+ } as const;
46
+
47
+ type EnvLike = Record<string, string | undefined>;
48
+
49
+ type CloudflareWorkersAiOcrClientOptions = {
50
+ accountId: string;
51
+ apiToken: string;
52
+ model?: string;
53
+ fetch?: typeof fetch;
54
+ };
55
+
56
+ type OpenAiCompatibleOcrClientOptions = {
57
+ baseUrl: string;
58
+ apiKey?: string;
59
+ model: string;
60
+ fetch?: typeof fetch;
61
+ };
62
+
63
+ type ErrorOcrClientOptions = {
64
+ code: string;
65
+ message: string;
66
+ fix?: string;
67
+ };
68
+
69
+ function providerError(message: string, options: { code: string; fix?: string }): ProviderError {
70
+ return new ProviderError(message, options);
71
+ }
72
+
73
+ function createErrorOcrClient(options: ErrorOcrClientOptions): OcrContext {
74
+ const unavailable = (): never => {
75
+ throw providerError(options.message, {
76
+ code: options.code,
77
+ fix: options.fix,
78
+ });
79
+ };
80
+ return {
81
+ async recognize() {
82
+ return unavailable();
83
+ },
84
+ async extractCaptchaText() {
85
+ return unavailable();
86
+ },
87
+ };
88
+ }
89
+
90
+ export function createUnsupportedOcrClient(reason?: string): OcrContext {
91
+ return createErrorOcrClient({
92
+ code: "OCR_UNAVAILABLE",
93
+ message: reason ?? "OCR runtime is not configured",
94
+ 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.`,
95
+ });
96
+ }
97
+
98
+ function normalizedEnvValue(env: EnvLike, key: string): string | undefined {
99
+ const value = env[key]?.trim();
100
+ return value ? value : undefined;
101
+ }
102
+
103
+ export function createOcrClientFromEnv(
104
+ config: ProviderOcrConfig | undefined,
105
+ env: EnvLike = process.env,
106
+ ): OcrContext {
107
+ if (!config) {
108
+ return createUnsupportedOcrClient("Provider does not declare OCR capability");
109
+ }
110
+
111
+ const backend =
112
+ normalizedEnvValue(env, APIFUSE__OCR__BACKEND_ENV) ?? CLOUDFLARE_WORKERS_AI_OCR_BACKEND;
113
+ const configuredModel = normalizedEnvValue(env, APIFUSE__OCR__MODEL_ENV);
114
+
115
+ if (backend === CLOUDFLARE_WORKERS_AI_OCR_BACKEND) {
116
+ const model = configuredModel ?? DEFAULT_CLOUDFLARE_WORKERS_AI_OCR_MODEL;
117
+ const accountId = normalizedEnvValue(env, CLOUDFLARE_ACCOUNT_ID_ENV);
118
+ const apiToken = normalizedEnvValue(env, APIFUSE__OCR__CLOUDFLARE_API_TOKEN_ENV);
119
+ if (!accountId || !apiToken) {
120
+ return createUnsupportedOcrClient(
121
+ `OCR backend ${backend} requires ${CLOUDFLARE_ACCOUNT_ID_ENV} and ${APIFUSE__OCR__CLOUDFLARE_API_TOKEN_ENV}`,
122
+ );
123
+ }
124
+ return createCloudflareWorkersAiOcrClient({ accountId, apiToken, model });
125
+ }
126
+
127
+ if (backend === OPENAI_COMPATIBLE_OCR_BACKEND) {
128
+ const baseUrl = normalizedEnvValue(env, APIFUSE__OCR__BASE_URL_ENV);
129
+ if (!baseUrl) {
130
+ return createUnsupportedOcrClient(
131
+ `OCR backend ${backend} requires ${APIFUSE__OCR__BASE_URL_ENV}`,
132
+ );
133
+ }
134
+ if (!configuredModel) {
135
+ return createErrorOcrClient({
136
+ code: "OCR_UNAVAILABLE",
137
+ message: `OCR backend ${backend} requires ${APIFUSE__OCR__MODEL_ENV}`,
138
+ 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.`,
139
+ });
140
+ }
141
+ return createOpenAiCompatibleOcrClient({
142
+ baseUrl,
143
+ apiKey: normalizedEnvValue(env, APIFUSE__OCR__API_KEY_ENV),
144
+ model: configuredModel,
145
+ });
146
+ }
147
+
148
+ return createErrorOcrClient({
149
+ code: "UNSUPPORTED_OCR_BACKEND",
150
+ message: `Unsupported OCR backend "${backend}"`,
151
+ 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.`,
152
+ });
153
+ }
154
+
155
+ function unknownRecord(value: unknown): Record<string, unknown> | undefined {
156
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
157
+ return Object.fromEntries(Object.entries(value));
158
+ }
159
+
160
+ function imageUrl(image: OcrImageInput): string {
161
+ if (image.kind === "url") return image.url.trim();
162
+ return `data:${image.mediaType?.trim() || "image/png"};base64,${image.data.trim()}`;
163
+ }
164
+
165
+ function resolvePrompt(request: OcrRecognizeRequest): string {
166
+ return request.prompt ?? HINT_PROMPTS[request.hint ?? "generic"];
167
+ }
168
+
169
+ function isGemmaModel(model: string): boolean {
170
+ return model.toLowerCase().includes("gemma");
171
+ }
172
+
173
+ function isKimiModel(model: string): boolean {
174
+ return model.toLowerCase().includes("kimi");
175
+ }
176
+
177
+ function isMoondreamModel(model: string): boolean {
178
+ return model.toLowerCase().includes("moondream");
179
+ }
180
+
181
+ function defaultMaxTokens(request: OcrRecognizeRequest): number {
182
+ return request.hint === "captcha" ? DEFAULT_CAPTCHA_MAX_TOKENS : DEFAULT_DOCUMENT_MAX_TOKENS;
183
+ }
184
+
185
+ function resolvedMaxTokens(request: OcrRecognizeRequest, model = ""): number {
186
+ const requestedMaxTokens = request.maxTokens ?? defaultMaxTokens(request);
187
+ return isKimiModel(model)
188
+ ? Math.max(requestedMaxTokens, KIMI_MIN_MAX_TOKENS)
189
+ : requestedMaxTokens;
190
+ }
191
+
192
+ function messagesPayload(request: OcrRecognizeRequest, model?: string): Record<string, unknown> {
193
+ const payload: Record<string, unknown> = {
194
+ ...(model ? { model } : {}),
195
+ messages: [
196
+ {
197
+ role: "user",
198
+ content: [
199
+ { type: "text", text: resolvePrompt(request) },
200
+ { type: "image_url", image_url: { url: imageUrl(request.image) } },
201
+ ],
202
+ },
203
+ ],
204
+ max_tokens: resolvedMaxTokens(request, model),
205
+ temperature: 0,
206
+ };
207
+ if (isGemmaModel(model ?? "")) {
208
+ payload.chat_template_kwargs = { enable_thinking: false };
209
+ }
210
+ return payload;
211
+ }
212
+
213
+ function cloudflareMessagesPayload(
214
+ request: OcrRecognizeRequest,
215
+ model: string,
216
+ ): Record<string, unknown> {
217
+ const payload = messagesPayload(request, model);
218
+ delete payload.model;
219
+ return payload;
220
+ }
221
+
222
+ function moondreamPayload(request: OcrRecognizeRequest): Record<string, unknown> {
223
+ return {
224
+ task: "query",
225
+ image: imageUrl(request.image),
226
+ question: resolvePrompt(request),
227
+ stream: true,
228
+ reasoning: false,
229
+ temperature: 0,
230
+ max_tokens: resolvedMaxTokens(request),
231
+ };
232
+ }
233
+
234
+ function incompleteResponseError(model: string, finishReason: string): TransportError {
235
+ return new TransportError(
236
+ `OCR model "${model}" did not complete normally (finish_reason: ${finishReason})`,
237
+ {
238
+ code: "OCR_INCOMPLETE_RESPONSE",
239
+ status: 502,
240
+ details: { finishReason },
241
+ },
242
+ );
243
+ }
244
+
245
+ function responseContent(payload: unknown, cloudflare: boolean, model: string): string | undefined {
246
+ const envelope = unknownRecord(payload);
247
+ const root = cloudflare ? unknownRecord(envelope?.result) : envelope;
248
+ const choices = root?.choices;
249
+ if (!Array.isArray(choices)) return undefined;
250
+ const choice = unknownRecord(choices[0]);
251
+ if (typeof choice?.finish_reason === "string" && choice.finish_reason !== "stop") {
252
+ throw incompleteResponseError(model, choice.finish_reason);
253
+ }
254
+ const message = unknownRecord(choice?.message);
255
+ return typeof message?.content === "string" ? message.content.trim() || undefined : undefined;
256
+ }
257
+
258
+ function malformedResponseError(model: string, cause: Error): TransportError {
259
+ return new TransportError(`OCR model "${model}" returned a malformed response`, {
260
+ code: "OCR_UPSTREAM_FAILED",
261
+ status: 502,
262
+ cause,
263
+ });
264
+ }
265
+
266
+ async function responseJson(response: Response, model: string): Promise<unknown> {
267
+ try {
268
+ return await response.json();
269
+ } catch (error) {
270
+ if (isTimeoutLikeError(error)) throw toOcrTransportError(error);
271
+ throw malformedResponseError(
272
+ model,
273
+ error instanceof Error ? error : new Error("Failed to decode OCR response JSON"),
274
+ );
275
+ }
276
+ }
277
+
278
+ function moondreamSseContent(body: string, model: string): string | undefined {
279
+ let finalAnswer: string | undefined;
280
+ let firstDecodingFailure: Error | undefined;
281
+ let terminalFinishReason: string | undefined;
282
+ for (const line of body.split(/\r?\n/u)) {
283
+ const trimmed = line.trim();
284
+ if (!trimmed.startsWith("data:")) continue;
285
+ const data = trimmed.slice("data:".length).trim();
286
+ if (!data || data === "[DONE]") continue;
287
+ let parsed: unknown;
288
+ try {
289
+ parsed = JSON.parse(data);
290
+ } catch (error) {
291
+ firstDecodingFailure ??=
292
+ error instanceof Error ? error : new Error("Failed to decode OCR SSE event");
293
+ continue;
294
+ }
295
+ const event = unknownRecord(parsed);
296
+ const chunk = unknownRecord(event?.chunk) ?? unknownRecord(event?.result) ?? event;
297
+ if (
298
+ chunk?.finish_reason === "stop" &&
299
+ typeof chunk.answer === "string" &&
300
+ chunk.answer.trim()
301
+ ) {
302
+ finalAnswer = chunk.answer.trim();
303
+ } else if (typeof chunk?.finish_reason === "string") {
304
+ terminalFinishReason = chunk.finish_reason;
305
+ }
306
+ }
307
+ if (finalAnswer) return finalAnswer;
308
+ if (terminalFinishReason) throw incompleteResponseError(model, terminalFinishReason);
309
+ if (firstDecodingFailure) throw malformedResponseError(model, firstDecodingFailure);
310
+ return finalAnswer;
311
+ }
312
+
313
+ function emptyResponseError(model: string): TransportError {
314
+ return new TransportError(`OCR model "${model}" returned no usable text`, {
315
+ code: "OCR_UPSTREAM_FAILED",
316
+ status: 502,
317
+ fix: `Verify ${APIFUSE__OCR__MODEL_ENV}="${model}" supports image input and the runtime calling convention for that model.`,
318
+ });
319
+ }
320
+
321
+ function toOcrTransportError(error: unknown): TransportError {
322
+ if (error instanceof TransportError) return error;
323
+ if (isTimeoutLikeError(error)) {
324
+ return new TransportError("OCR upstream request timed out", {
325
+ code: "transport_timeout",
326
+ status: 0,
327
+ cause: error,
328
+ });
329
+ }
330
+ return new TransportError("OCR upstream network request failed", {
331
+ code: "transport_network_error",
332
+ status: 0,
333
+ cause: error instanceof Error ? error : undefined,
334
+ });
335
+ }
336
+
337
+ function createOcrClient(
338
+ model: string,
339
+ recognize: (request: OcrRecognizeRequest) => Promise<OcrResult>,
340
+ ): OcrContext {
341
+ return {
342
+ recognize,
343
+ async extractCaptchaText(image, options = {}): Promise<OcrCaptchaResult> {
344
+ const result = await recognize({ image, hint: "captcha" });
345
+ const candidates = extractCaptchaCandidates(result.text, options);
346
+ const primary = candidates[0];
347
+ if (!primary?.text) throw emptyResponseError(model);
348
+ return {
349
+ text: primary.text,
350
+ candidates,
351
+ satisfiesConstraints: primary.satisfiesConstraints,
352
+ model: result.model,
353
+ };
354
+ },
355
+ };
356
+ }
357
+
358
+ export function createCloudflareWorkersAiOcrClient(
359
+ options: CloudflareWorkersAiOcrClientOptions,
360
+ ): OcrContext {
361
+ const model = options.model ?? DEFAULT_CLOUDFLARE_WORKERS_AI_OCR_MODEL;
362
+ const runFetch = options.fetch ?? fetch;
363
+ return createOcrClient(model, async (request) => {
364
+ const timeout = createTimeoutController(request.timeoutMs ?? DEFAULT_OCR_TIMEOUT_MS);
365
+ try {
366
+ let response: Response;
367
+ try {
368
+ response = await runFetch(
369
+ `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(options.accountId)}/ai/run/${model}`,
370
+ {
371
+ method: "POST",
372
+ headers: {
373
+ Authorization: `Bearer ${options.apiToken}`,
374
+ "Content-Type": "application/json",
375
+ },
376
+ body: JSON.stringify(
377
+ isMoondreamModel(model)
378
+ ? moondreamPayload(request)
379
+ : cloudflareMessagesPayload(request, model),
380
+ ),
381
+ signal: timeout.controller.signal,
382
+ },
383
+ );
384
+ } catch (error) {
385
+ throw toOcrTransportError(error);
386
+ }
387
+ if (!response.ok) {
388
+ throw new TransportError("OCR upstream request failed", {
389
+ code: "OCR_UPSTREAM_FAILED",
390
+ status: response.status,
391
+ upstreamStatus: response.status,
392
+ });
393
+ }
394
+ const text = isMoondreamModel(model)
395
+ ? moondreamSseContent(await response.text(), model)
396
+ : responseContent(await responseJson(response, model), true, model);
397
+ if (!text) throw emptyResponseError(model);
398
+ return { text, model };
399
+ } catch (error) {
400
+ throw toOcrTransportError(error);
401
+ } finally {
402
+ timeout.clear();
403
+ }
404
+ });
405
+ }
406
+
407
+ export function createOpenAiCompatibleOcrClient(
408
+ options: OpenAiCompatibleOcrClientOptions,
409
+ ): OcrContext {
410
+ const model = options.model;
411
+ const runFetch = options.fetch ?? fetch;
412
+ return createOcrClient(model, async (request) => {
413
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
414
+ if (options.apiKey) headers.Authorization = `Bearer ${options.apiKey}`;
415
+ const timeout = createTimeoutController(request.timeoutMs ?? DEFAULT_OCR_TIMEOUT_MS);
416
+ try {
417
+ let response: Response;
418
+ try {
419
+ response = await runFetch(`${options.baseUrl.replace(/\/+$/u, "")}/chat/completions`, {
420
+ method: "POST",
421
+ headers,
422
+ body: JSON.stringify(messagesPayload(request, model)),
423
+ signal: timeout.controller.signal,
424
+ });
425
+ } catch (error) {
426
+ throw toOcrTransportError(error);
427
+ }
428
+ if (!response.ok) {
429
+ throw new TransportError("OCR upstream request failed", {
430
+ code: "OCR_UPSTREAM_FAILED",
431
+ status: response.status,
432
+ upstreamStatus: response.status,
433
+ });
434
+ }
435
+ const text = responseContent(await responseJson(response, model), false, model);
436
+ if (!text) throw emptyResponseError(model);
437
+ return { text, model };
438
+ } catch (error) {
439
+ throw toOcrTransportError(error);
440
+ } finally {
441
+ timeout.clear();
442
+ }
443
+ });
444
+ }
445
+
446
+ function cleanCaptchaText(text: string): string {
447
+ const afterColon = text.slice(text.lastIndexOf(":") + 1).trim();
448
+ return afterColon.replace(/^[\s\p{P}\p{S}]+|[\s\p{P}\p{S}]+$/gu, "");
449
+ }
450
+
451
+ function caseFold(value: string, caseSensitive: boolean): string {
452
+ return caseSensitive ? value : value.toLocaleLowerCase();
453
+ }
454
+
455
+ function matchesCharset(
456
+ text: string,
457
+ charset: string | RegExp | undefined,
458
+ caseSensitive: boolean,
459
+ ): boolean {
460
+ if (charset === undefined) return true;
461
+ if (typeof charset === "string") {
462
+ const allowed = new Set([...caseFold(charset, caseSensitive)]);
463
+ return [...caseFold(text, caseSensitive)].every((character) => allowed.has(character));
464
+ }
465
+ const flags = caseSensitive || charset.flags.includes("i") ? charset.flags : `${charset.flags}i`;
466
+ const matcher = new RegExp(charset.source, flags);
467
+ return [...text].every((character) => {
468
+ matcher.lastIndex = 0;
469
+ return matcher.test(character);
470
+ });
471
+ }
472
+
473
+ function satisfiesCaptchaConstraints(text: string, options: OcrCaptchaOptions): boolean {
474
+ const lengthMatches = options.length === undefined || [...text].length === options.length;
475
+ return lengthMatches && matchesCharset(text, options.charset, options.caseSensitive !== false);
476
+ }
477
+
478
+ function homoglyphAlternatives(character: string): readonly string[] {
479
+ const group = HOMOGLYPH_CLASSES.find((characters) => characters.includes(character));
480
+ return group?.filter((alternative) => alternative !== character) ?? [];
481
+ }
482
+
483
+ export function extractCaptchaCandidates(
484
+ modelText: string,
485
+ options: OcrCaptchaOptions = {},
486
+ ): readonly OcrCaptchaCandidate[] {
487
+ const primaryText = cleanCaptchaText(modelText);
488
+ const primary = {
489
+ text: primaryText,
490
+ satisfiesConstraints: satisfiesCaptchaConstraints(primaryText, options),
491
+ };
492
+ const requestedMaxCandidates = options.maxCandidates ?? DEFAULT_MAX_CAPTCHA_CANDIDATES;
493
+ const maxCandidates = Number.isFinite(requestedMaxCandidates)
494
+ ? Math.max(1, Math.floor(requestedMaxCandidates))
495
+ : DEFAULT_MAX_CAPTCHA_CANDIDATES;
496
+ if (primary.satisfiesConstraints || maxCandidates === 1) return [primary];
497
+ if (options.length !== undefined && [...primaryText].length !== options.length) return [primary];
498
+
499
+ const candidates: OcrCaptchaCandidate[] = [primary];
500
+ const queue = [primaryText];
501
+ const visited = new Set(queue);
502
+ for (let index = 0; index < queue.length && candidates.length < maxCandidates; index += 1) {
503
+ const current = queue[index] ?? "";
504
+ const characters = [...current];
505
+ for (let position = 0; position < characters.length; position += 1) {
506
+ const character = characters[position] ?? "";
507
+ for (const alternative of homoglyphAlternatives(character)) {
508
+ const nextCharacters = [...characters];
509
+ nextCharacters[position] = alternative;
510
+ const next = nextCharacters.join("");
511
+ if (visited.has(next)) continue;
512
+ visited.add(next);
513
+ queue.push(next);
514
+ if (satisfiesCaptchaConstraints(next, options)) {
515
+ candidates.push({ text: next, satisfiesConstraints: true });
516
+ if (candidates.length >= maxCandidates) return candidates;
517
+ }
518
+ if (visited.size >= MAX_HOMOGLYPH_SEARCH_NODES) return candidates;
519
+ }
520
+ }
521
+ }
522
+ return candidates;
523
+ }