@apifuse/provider-sdk 2.2.0-beta.22 → 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.
- package/CHANGELOG.md +4 -0
- package/bin/apifuse-dev.ts +2 -0
- package/bin/apifuse-record.ts +2 -0
- package/dist/contract-types.d.ts +1 -0
- package/dist/contract.js +2 -0
- package/dist/define.d.ts +2 -1
- package/dist/define.js +15 -0
- package/dist/error-resolution.js +5 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/runtime/auth-flow.d.ts +2 -1
- package/dist/runtime/auth-flow.js +2 -0
- package/dist/runtime/browser.js +28 -9
- package/dist/runtime/ocr.d.ts +29 -0
- package/dist/runtime/ocr.js +440 -0
- 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 +3 -1
- package/dist/server/serve.js +4 -0
- package/dist/testing/run.js +3 -0
- package/dist/types.d.ts +51 -0
- package/package.json +1 -1
- package/src/contract-types.ts +1 -0
- package/src/contract.ts +2 -0
- package/src/define.ts +17 -0
- package/src/error-resolution.ts +5 -0
- package/src/index.ts +25 -0
- package/src/runtime/auth-flow.ts +4 -0
- package/src/runtime/browser.ts +70 -19
- package/src/runtime/ocr.ts +523 -0
- package/src/runtime/stt.ts +1 -19
- package/src/runtime/timeout.ts +18 -0
- package/src/server/serve.ts +7 -0
- package/src/testing/run.ts +7 -0
- package/src/types.ts +58 -0
|
@@ -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
|
+
}
|
package/dist/runtime/stt.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ProviderError, TransportError, ValidationError } from "../errors.js";
|
|
2
|
+
import { createTimeoutController, isTimeoutLikeError } from "./timeout.js";
|
|
2
3
|
export const APIFUSE__STT__BACKEND_ENV = "APIFUSE__STT__BACKEND";
|
|
3
4
|
export const APIFUSE__STT__MODEL_ENV = "APIFUSE__STT__MODEL";
|
|
4
5
|
export const CLOUDFLARE_ACCOUNT_ID_ENV = "APIFUSE__CLOUDFLARE__ACCOUNT_ID";
|
|
@@ -112,12 +113,6 @@ function warnOrThrowUnsupportedOption(request, message) {
|
|
|
112
113
|
function normalizeCloudflareLanguage(language) {
|
|
113
114
|
return language?.split("-")[0]?.toLowerCase();
|
|
114
115
|
}
|
|
115
|
-
function isTimeoutLikeError(error) {
|
|
116
|
-
return (error instanceof Error &&
|
|
117
|
-
(error.name === "AbortError" ||
|
|
118
|
-
error.name === "TimeoutError" ||
|
|
119
|
-
/\b(timed out|timeout|deadline exceeded)\b/i.test(error.message)));
|
|
120
|
-
}
|
|
121
116
|
function toSttTransportError(error) {
|
|
122
117
|
if (error instanceof TransportError)
|
|
123
118
|
return error;
|
|
@@ -134,12 +129,6 @@ function toSttTransportError(error) {
|
|
|
134
129
|
cause: error instanceof Error ? error : undefined,
|
|
135
130
|
});
|
|
136
131
|
}
|
|
137
|
-
function createTimeoutController(signalTimeoutMs) {
|
|
138
|
-
const controller = new AbortController();
|
|
139
|
-
const timeout = setTimeout(() => controller.abort(), signalTimeoutMs);
|
|
140
|
-
timeout.unref?.();
|
|
141
|
-
return { controller, clear: () => clearTimeout(timeout) };
|
|
142
|
-
}
|
|
143
132
|
function toCloudflareInput(request) {
|
|
144
133
|
const prompt = resolveSttPrompt(request);
|
|
145
134
|
const input = {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function isTimeoutLikeError(error) {
|
|
2
|
+
return (error instanceof Error &&
|
|
3
|
+
(error.name === "AbortError" ||
|
|
4
|
+
error.name === "TimeoutError" ||
|
|
5
|
+
/\b(timed out|timeout|deadline exceeded)\b/i.test(error.message)));
|
|
6
|
+
}
|
|
7
|
+
export function createTimeoutController(signalTimeoutMs) {
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
const timeout = setTimeout(() => controller.abort(), signalTimeoutMs);
|
|
10
|
+
timeout.unref?.();
|
|
11
|
+
return { controller, clear: () => clearTimeout(timeout) };
|
|
12
|
+
}
|
package/dist/server/serve.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { type ProviderErrorCategory } from "../observability.js";
|
|
4
|
-
import type { ProviderContext, ProviderDefinition, ProviderRuntimeState, SttContext } from "../types.js";
|
|
4
|
+
import type { OcrContext, ProviderContext, ProviderDefinition, ProviderRuntimeState, SttContext } from "../types.js";
|
|
5
5
|
import { type OperationRequest } from "./types.js";
|
|
6
6
|
/** Compact SDK-owned error classification emitted separately from the public response body. */
|
|
7
7
|
export declare const ERROR_OBSERVABILITY_HEADER = "X-ApiFuse-Error-Observability";
|
|
@@ -133,6 +133,8 @@ export type ProviderServerOptions = {
|
|
|
133
133
|
};
|
|
134
134
|
/** Optional STT override for tests or custom hosts; local/prod normally resolves from env. */
|
|
135
135
|
stt?: SttContext;
|
|
136
|
+
/** Optional OCR override for tests or custom hosts; local/prod normally resolves from env. */
|
|
137
|
+
ocr?: OcrContext;
|
|
136
138
|
/** Optional runtime state override for tests or custom hosts. Production resolves Redis from env and fails closed when unavailable. */
|
|
137
139
|
state?: ProviderRuntimeState;
|
|
138
140
|
/** Allow process-local runtime state only for local development and tests. */
|
package/dist/server/serve.js
CHANGED
|
@@ -18,6 +18,7 @@ import { createHttpClient } from "../runtime/http.js";
|
|
|
18
18
|
import { wrapWithInstrumentation } from "../runtime/instrumentation.js";
|
|
19
19
|
import { createEnvVendorCredentialResolver, createNativeNetworkClient, } from "../runtime/native-network.js";
|
|
20
20
|
import { getProviderBaseUrl } from "../runtime/provider.js";
|
|
21
|
+
import { createOcrClientFromEnv } from "../runtime/ocr.js";
|
|
21
22
|
import { PROXY_AUTH_IP_DENIED_CODE, PROXY_EDGE_AUTH_REJECTED_CODE, PROXY_POOL_EXHAUSTED_CODE, } from "../runtime/proxy-errors.js";
|
|
22
23
|
import { PROVIDER_TELEMETRY_HEADER, ProxyTelemetryCollector } from "../runtime/proxy-telemetry.js";
|
|
23
24
|
import { assertRequiredSecretsPresent, listMissingRequiredSecrets, MISSING_SECRET_CODE, } from "../runtime/secrets.js";
|
|
@@ -232,6 +233,7 @@ function createProviderContext(provider, request, operationId, options = {}, sta
|
|
|
232
233
|
: {}),
|
|
233
234
|
trace: createTraceContext(),
|
|
234
235
|
auth: createAuthStub(),
|
|
236
|
+
ocr: options.ocr ?? createOcrClientFromEnv(provider.ocr),
|
|
235
237
|
stt: options.stt ?? createSttClientFromEnv(provider.stt),
|
|
236
238
|
choice: createProviderChoiceContext({
|
|
237
239
|
providerId: provider.id,
|
|
@@ -327,6 +329,7 @@ function createAuthFlowContext(provider, request, options = {}, signal) {
|
|
|
327
329
|
]),
|
|
328
330
|
credential,
|
|
329
331
|
context: flowContextStore.context,
|
|
332
|
+
ocr: options.ocr ?? createOcrClientFromEnv(provider.ocr),
|
|
330
333
|
stt: options.stt ?? createSttClientFromEnv(provider.stt),
|
|
331
334
|
auth: createAuthFlowHelpers({ signal }),
|
|
332
335
|
},
|
|
@@ -1608,6 +1611,7 @@ export async function serve(provider, options = {}) {
|
|
|
1608
1611
|
const configuredSignals = resolveShutdownSignals(options.shutdown?.signals ?? true);
|
|
1609
1612
|
const app = createServerApp(provider, {
|
|
1610
1613
|
logger: options.logger,
|
|
1614
|
+
ocr: options.ocr,
|
|
1611
1615
|
stt: options.stt,
|
|
1612
1616
|
state: options.state,
|
|
1613
1617
|
allowMemoryStateFallback: options.allowMemoryStateFallback,
|
package/dist/testing/run.js
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
|
|
|
2
2
|
import { createProviderCache } from "../runtime/cache.js";
|
|
3
3
|
import { createTestProviderChoiceContext } from "../runtime/choice.js";
|
|
4
4
|
import { createMemoryProviderRuntimeState } from "../runtime/state.js";
|
|
5
|
+
import { createUnsupportedOcrClient } from "../runtime/ocr.js";
|
|
5
6
|
import { createUnsupportedSttClient } from "../runtime/stt.js";
|
|
6
7
|
import { createNativeEgressAuthorization, NativeNetworkError, snapshotNativeConnectInput, snapshotNativeGrantInput, } from "../runtime/native-network.js";
|
|
7
8
|
import { safeParseSchemaSync } from "../schema.js";
|
|
@@ -397,6 +398,7 @@ function createUpstreamContext(provider, operationName, upstreamStub) {
|
|
|
397
398
|
: {}),
|
|
398
399
|
trace: { span: async (_name, fn) => fn() },
|
|
399
400
|
auth: { requestField: async (name) => unsupported(`ctx.auth.requestField(${name})`) },
|
|
401
|
+
ocr: createUnsupportedOcrClient("Standard test upstream context does not support ctx.ocr.recognize"),
|
|
400
402
|
stt: createUnsupportedSttClient("Standard test upstream context does not support ctx.stt.transcribe"),
|
|
401
403
|
choice: createTestProviderChoiceContext({
|
|
402
404
|
providerId: `standard-test-${operationName}`,
|
|
@@ -503,6 +505,7 @@ export function createSnapshotContext(rawFixture) {
|
|
|
503
505
|
auth: {
|
|
504
506
|
requestField: async (name) => unsupported(`ctx.auth.requestField(${name})`),
|
|
505
507
|
},
|
|
508
|
+
ocr: createUnsupportedOcrClient("Standard test snapshot context does not support ctx.ocr.recognize"),
|
|
506
509
|
stt: createUnsupportedSttClient("Standard test snapshot context does not support ctx.stt.transcribe"),
|
|
507
510
|
choice: createTestProviderChoiceContext({
|
|
508
511
|
providerId: "standard-test",
|
package/dist/types.d.ts
CHANGED
|
@@ -196,6 +196,54 @@ export interface SmsOtpMatcherDefinition {
|
|
|
196
196
|
/** Runtime/fixture helper. Not serialized into generated registry artifacts. */
|
|
197
197
|
extractOtp(body: string): string | null;
|
|
198
198
|
}
|
|
199
|
+
export interface ProviderOcrConfig {
|
|
200
|
+
readonly mode: "required" | "optional";
|
|
201
|
+
}
|
|
202
|
+
export type OcrImageInput = {
|
|
203
|
+
readonly kind: "base64";
|
|
204
|
+
readonly data: string;
|
|
205
|
+
readonly mediaType?: string;
|
|
206
|
+
} | {
|
|
207
|
+
readonly kind: "url";
|
|
208
|
+
readonly url: string;
|
|
209
|
+
};
|
|
210
|
+
export interface OcrRecognizeRequest {
|
|
211
|
+
readonly image: OcrImageInput;
|
|
212
|
+
readonly hint?: "captcha" | "document" | "generic";
|
|
213
|
+
readonly prompt?: string;
|
|
214
|
+
readonly maxTokens?: number;
|
|
215
|
+
readonly timeoutMs?: number;
|
|
216
|
+
}
|
|
217
|
+
export interface OcrWarning {
|
|
218
|
+
readonly code: string;
|
|
219
|
+
readonly message: string;
|
|
220
|
+
}
|
|
221
|
+
export interface OcrResult {
|
|
222
|
+
readonly text: string;
|
|
223
|
+
readonly model: string;
|
|
224
|
+
readonly warnings?: readonly OcrWarning[];
|
|
225
|
+
}
|
|
226
|
+
export interface OcrCaptchaOptions {
|
|
227
|
+
readonly length?: number;
|
|
228
|
+
/** Allowed characters. A RegExp is applied to each character, not to the whole text. */
|
|
229
|
+
readonly charset?: string | RegExp;
|
|
230
|
+
readonly caseSensitive?: boolean;
|
|
231
|
+
readonly maxCandidates?: number;
|
|
232
|
+
}
|
|
233
|
+
export interface OcrCaptchaCandidate {
|
|
234
|
+
readonly text: string;
|
|
235
|
+
readonly satisfiesConstraints: boolean;
|
|
236
|
+
}
|
|
237
|
+
export interface OcrCaptchaResult {
|
|
238
|
+
readonly text: string;
|
|
239
|
+
readonly candidates: readonly OcrCaptchaCandidate[];
|
|
240
|
+
readonly satisfiesConstraints: boolean;
|
|
241
|
+
readonly model: string;
|
|
242
|
+
}
|
|
243
|
+
export interface OcrContext {
|
|
244
|
+
recognize(request: OcrRecognizeRequest): Promise<OcrResult>;
|
|
245
|
+
extractCaptchaText(image: OcrImageInput, options?: OcrCaptchaOptions): Promise<OcrCaptchaResult>;
|
|
246
|
+
}
|
|
199
247
|
export type SttTranscribeMode = "general" | "otp";
|
|
200
248
|
export type SttPromptPolicy = "none" | "default-hint" | "custom-hint";
|
|
201
249
|
export type SttUnsupportedOptionPolicy = "warn" | "error";
|
|
@@ -1543,6 +1591,7 @@ export interface FlowContext {
|
|
|
1543
1591
|
env: EnvContext;
|
|
1544
1592
|
credential?: CredentialContext;
|
|
1545
1593
|
context: ContextScratchpad;
|
|
1594
|
+
ocr: OcrContext;
|
|
1546
1595
|
stt: SttContext;
|
|
1547
1596
|
auth: AuthFlowTerminalContext;
|
|
1548
1597
|
}
|
|
@@ -1634,6 +1683,7 @@ export interface ProviderContext {
|
|
|
1634
1683
|
browser: BrowserClient;
|
|
1635
1684
|
trace: TraceContext;
|
|
1636
1685
|
auth: AuthContext;
|
|
1686
|
+
ocr: OcrContext;
|
|
1637
1687
|
stt: SttContext;
|
|
1638
1688
|
choice: ProviderChoiceContext;
|
|
1639
1689
|
}
|
|
@@ -1785,6 +1835,7 @@ export interface ProviderDefinition {
|
|
|
1785
1835
|
platform: StealthPlatform;
|
|
1786
1836
|
};
|
|
1787
1837
|
proxy?: ProviderProxyConfig;
|
|
1838
|
+
ocr?: ProviderOcrConfig;
|
|
1788
1839
|
stt?: ProviderSttConfig;
|
|
1789
1840
|
browser?: {
|
|
1790
1841
|
engine: BrowserEngine;
|
package/package.json
CHANGED
package/src/contract-types.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface ProviderContractSnapshot {
|
|
|
13
13
|
readonly allowedHosts?: readonly string[];
|
|
14
14
|
readonly stealth?: JsonValue;
|
|
15
15
|
readonly proxy?: JsonValue;
|
|
16
|
+
readonly ocr?: JsonValue;
|
|
16
17
|
readonly stt?: JsonValue;
|
|
17
18
|
readonly browser?: JsonValue;
|
|
18
19
|
readonly auth?: JsonValue;
|
package/src/contract.ts
CHANGED
|
@@ -35,6 +35,7 @@ export function extractProviderContract(provider: ProviderDefinition): ProviderC
|
|
|
35
35
|
const auth = extractAuth(provider.auth);
|
|
36
36
|
const stealth = toJsonValue(provider.stealth);
|
|
37
37
|
const proxy = toJsonValue(provider.proxy);
|
|
38
|
+
const ocr = toJsonValue(provider.ocr);
|
|
38
39
|
const stt = toJsonValue(provider.stt);
|
|
39
40
|
const browser = toJsonValue(provider.browser);
|
|
40
41
|
const reviewed = toJsonValue(provider.reviewed);
|
|
@@ -59,6 +60,7 @@ export function extractProviderContract(provider: ProviderDefinition): ProviderC
|
|
|
59
60
|
...(provider.allowedHosts ? { allowedHosts: [...provider.allowedHosts].sort() } : {}),
|
|
60
61
|
...(stealth === undefined ? {} : { stealth }),
|
|
61
62
|
...(proxy === undefined ? {} : { proxy }),
|
|
63
|
+
...(ocr === undefined ? {} : { ocr }),
|
|
62
64
|
...(stt === undefined ? {} : { stt }),
|
|
63
65
|
...(browser === undefined ? {} : { browser }),
|
|
64
66
|
...(auth === undefined ? {} : { auth }),
|